//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // GlbAsset::Load on GLBs built here in memory. A fixture: the node hierarchy // (TRS and matrix nodes), two primitives with two materials, an interleaved // position/normal buffer view (strided accessors), normalized UV and weight // accessors, a second UV set, generated indices, computed tangents, a skin's // inverse-bind matrices, an embedded PNG and an extension kept in the raw // JSON. A room as Blender exports one: a camera, KHR_lights_punctual lights, // glowing and glass materials, and extras. Morph targets, one of them // sparse; strips and fans made lists, points and lines passed over; tangents // from the UV set the normal map samples; the four heaviest of eight joints // a vertex; images in formats only an extension reads; and the decode limit, // which a file repeating the same data runs into. // // Then the ways a file can be broken, which must come back as errors rather // than crash or load wrong: bad magic, truncation, an accessor past its view, // references that don't exist or that wrap round 32 bits, a stride or count // whose arithmetic wraps round to look in bounds, a chunk longer than the // file, a path that isn't a file, an extension the loader doesn't implement // that the file requires, an image too large for a texture or one stb fails // to decode without saying why, extras nested 200,000 deep, and children that // form a cycle. A chain 100,000 nodes deep loads in time in proportion to its // depth: the cycle check once walked every node up to its root. // // With CRAFTER_ASSET_TEST_GLB pointing at a real skinned .glb (a rig exported // from Blender, say), it also loads that and checks every joint's world // matrix times its inverse-bind matrix is the identity in the rest pose, // which is what a skinned mesh at rest relies on. #include import Crafter.Asset; import Crafter.Math; import std; using namespace Crafter; namespace { using Pixel = Vector; int failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); if (!ok) ++failures; } bool Near(float a, float b, float epsilon = 1e-4f) { return std::abs(a - b) < epsilon; } // That `loaded` came back as an error with `text` in it. template void CheckError(const std::expected& loaded, std::string_view text, std::string_view what) { const bool ok = !loaded && loaded.error().find(text) != std::string::npos; Check(ok, ok ? std::string(what) : loaded ? std::format("{} (it loaded)", what) : std::format("{} (the error: {})", what, loaded.error())); } // That `loaded` loaded, and if not, why. bool CheckLoads(const std::expected& loaded, std::string_view what) { Check(loaded.has_value(), loaded ? std::string(what) : std::format("{}: {}", what, loaded.error())); return loaded.has_value(); } // A 2x2 RGBA PNG: red, green, blue, and white with alpha 0. constexpr std::array kPng = { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x13, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0x0c, 0x81, 0x34, 0x08, 0x30, 0x00, 0x00, 0x48, 0xc9, 0x08, 0xf8, 0xc5, 0x34, 0xfd, 0x05, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, }; // A 1x1 GIF: white. constexpr std::array kGif = { 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b, }; struct Writer { std::vector bytes; template void Put(T value) { const auto* p = reinterpret_cast(&value); bytes.insert(bytes.end(), p, p + sizeof(T)); } void Pad(std::size_t alignment, std::byte fill) { while (bytes.size() % alignment != 0) bytes.push_back(fill); } }; // header + JSON chunk (space-padded) + BIN chunk (zero-padded), as the GLB // container lays them out. std::vector BuildGlb(std::string_view json, const std::vector& bin) { Writer jsonChunk; for (char c : json) jsonChunk.Put(c); jsonChunk.Pad(4, std::byte{' '}); std::vector binPadded = bin; while (binPadded.size() % 4 != 0) binPadded.push_back(std::byte{0}); Writer out; out.Put(0x46546C67); // "glTF" out.Put(2); out.Put(static_cast(12 + 8 + jsonChunk.bytes.size() + 8 + binPadded.size())); out.Put(static_cast(jsonChunk.bytes.size())); out.Put(0x4E4F534A); // "JSON" out.bytes.insert(out.bytes.end(), jsonChunk.bytes.begin(), jsonChunk.bytes.end()); out.Put(static_cast(binPadded.size())); out.Put(0x004E4942); // "BIN\0" out.bytes.insert(out.bytes.end(), binPadded.begin(), binPadded.end()); return out.bytes; } std::expected LoadJson(std::string_view json, const std::vector& bin, std::optional decodeLimit = std::nullopt) { return GlbAsset::Load(BuildGlb(json, bin), decodeLimit); } // `json` with each `from` replaced by its `to`. A `from` that isn't there is // a mistake in the test, and fails it. std::string Replace(std::string json, std::initializer_list> changes) { for (const auto& [from, to] : changes) { const std::size_t at = json.find(from); if (at == std::string::npos) { Check(false, std::format("the test's own edit: '{}' is in the JSON", from)); continue; } json.replace(at, from.size(), to); } return json; } // A file in the temp directory whose name is this run's own, as the test may // run alongside itself. std::filesystem::path TempFile(std::string_view name) { static const unsigned run = std::random_device{}(); return std::filesystem::temp_directory_path() / std::format("crafter-asset-glb-test-{}-{}", run, name); } std::vector Bytes(std::span bytes) { std::vector out(bytes.size()); std::memcpy(out.data(), bytes.data(), bytes.size()); return out; } std::vector FixtureBin() { Writer w; // bufferView 0, offset 0: interleaved position + normal, stride 24. const std::array, 3> positions = {{{0, 0, 0}, {1, 0, 0}, {0, 1, 0}}}; for (const auto& p : positions) { for (float f : p) w.Put(f); for (float f : {0.0f, 0.0f, 1.0f}) w.Put(f); } // bufferView 1, offset 72: TEXCOORD_0 as normalized u16. for (std::uint16_t u : {0, 0, 65535, 0, 0, 65535}) w.Put(u); // bufferView 2, offset 84: u8 indices. for (std::uint8_t i : {0, 1, 2}) w.Put(i); w.Pad(4, std::byte{0}); // bufferView 3, offset 88: JOINTS_0 as u8. for (int v = 0; v < 3; ++v) for (std::uint8_t j : {0, 1, 0, 0}) w.Put(j); // bufferView 4, offset 100: WEIGHTS_0 as normalized u8 that don't sum to 255. for (int v = 0; v < 3; ++v) for (std::uint8_t j : {128, 64, 0, 0}) w.Put(j); // bufferView 5, offset 112: the second primitive's positions. for (float f : {0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f}) w.Put(f); // bufferView 6, offset 148: two inverse-bind matrices, column-major. for (float f : {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f}) w.Put(f); for (float f : {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f}) w.Put(f); // bufferView 7, offset 276: the PNG. for (std::uint8_t b : kPng) w.Put(b); // bufferView 8, offset 352: TEXCOORD_1 as floats. for (float f : {0.25f, 0.5f, 0.75f, 0.5f, 0.25f, 1.0f}) w.Put(f); return w.bytes; } std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t accessor7Count = 2) { return std::format(R"({{ "asset": {{"version": "2.0"}}, "scene": 0, "scenes": [{{"nodes": [0]}}], "nodes": [ {{"name": "Root", "children": [1], "mesh": 0, "skin": 0, "translation": [1, 2, 3], "rotation": [0, 0, 0.70710678, 0.70710678], "scale": [2, 2, 2]}}, {{"name": "Child", "matrix": [1,0,0,0, 0,1,0,0, 0,0,1,0, 5,6,7,1]}} ], "meshes": [{{"name": "Mesh", "primitives": [ {{"attributes": {{"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, "TEXCOORD_1": 8, "JOINTS_0": 4, "WEIGHTS_0": 5}}, "indices": 3, "material": 0}}, {{"attributes": {{"POSITION": 6, "JOINTS_0": 4, "WEIGHTS_0": 5}}, "material": 1}} ]}}], "skins": [{{"name": "Skin", "joints": [0, 1], "inverseBindMatrices": 7}}], "materials": [ {{"name": "Mat0", "pbrMetallicRoughness": {{"baseColorFactor": [0.5, 0.25, 1, 1], "metallicFactor": 0.1, "roughnessFactor": 0.7, "baseColorTexture": {{"index": 0}}}}, "normalTexture": {{"index": 0, "scale": 0.5}}, "alphaMode": "MASK", "alphaCutoff": 0.3, "doubleSided": true}}, {{"name": "Mat1"}} ], "textures": [{{"source": 0, "sampler": 0}}], "samplers": [{{"magFilter": 9728, "minFilter": 9729}}], "images": [{{"bufferView": 7, "mimeType": "image/png"}}], "accessors": [ {{"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3, "type": "VEC3"}}, {{"bufferView": 0, "byteOffset": 12, "componentType": 5126, "count": 3, "type": "VEC3"}}, {{"bufferView": 1, "componentType": 5123, "normalized": true, "count": 3, "type": "VEC2"}}, {{"bufferView": 2, "componentType": 5121, "count": 3, "type": "SCALAR"}}, {{"bufferView": 3, "componentType": 5121, "count": 3, "type": "VEC4"}}, {{"bufferView": 4, "componentType": 5121, "normalized": true, "count": 3, "type": "VEC4"}}, {{"bufferView": 5, "componentType": 5126, "count": 3, "type": "VEC3"{}}}, {{"bufferView": 6, "componentType": 5126, "count": {}, "type": "MAT4"}}, {{"bufferView": 8, "componentType": 5126, "count": 3, "type": "VEC2"}} ], "bufferViews": [ {{"buffer": 0, "byteOffset": 0, "byteLength": 72, "byteStride": 24}}, {{"buffer": 0, "byteOffset": 72, "byteLength": 12}}, {{"buffer": 0, "byteOffset": 84, "byteLength": 3}}, {{"buffer": 0, "byteOffset": 88, "byteLength": 12}}, {{"buffer": 0, "byteOffset": 100, "byteLength": 12}}, {{"buffer": 0, "byteOffset": 112, "byteLength": 36}}, {{"buffer": 0, "byteOffset": 148, "byteLength": 128}}, {{"buffer": 0, "byteOffset": 276, "byteLength": 76}}, {{"buffer": 0, "byteOffset": 352, "byteLength": 24}} ], "buffers": [{{"byteLength": 376}}], "extensionsUsed": ["VRMC_springBone"], "extensions": {{"VRMC_springBone": {{"specVersion": "1.0"}}}} }})", accessor6Extra, accessor7Count); } bool NearMatrix(const MatrixRowMajor& m, std::array expected) { const std::array got = m.Store(); for (std::size_t i = 0; i < 16; ++i) { if (!Near(got[i], expected[i])) return false; } return true; } void TestFixture() { const std::vector glb = BuildGlb(FixtureJson(), FixtureBin()); const std::expected loaded = GlbAsset::Load(glb); if (!CheckLoads(loaded, "the fixture loads")) return; const GlbAsset& a = *loaded; // Nodes and scene. Check(a.nodes.size() == 2 && a.nodes[0].name == "Root" && a.nodes[1].name == "Child", "two named nodes"); Check(a.nodes[0].parent == -1 && a.nodes[1].parent == 0 && a.nodes[0].children == std::vector{1}, "parent and children links"); Check(a.sceneRoots == std::vector{0}, "the default scene's roots"); const GlbNode& root = a.nodes[0]; Check(!root.hasMatrix && Near(root.translation.x, 1) && Near(root.translation.y, 2) && Near(root.translation.z, 3) && Near(root.rotation.z, 0.70710678f) && Near(root.rotation.w, 0.70710678f) && Near(root.scale.y, 2), "TRS node"); Check(root.mesh == 0 && root.skin == 0 && a.nodes[1].mesh == -1, "mesh and skin references"); Check(a.nodes[1].hasMatrix && NearMatrix(a.nodes[1].matrix, {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 7, 1}), "matrix node, translation in row 3"); // Primitives. Check(a.meshes.size() == 1 && a.meshes[0].primitives.size() == 2, "one mesh with two primitives"); if (a.meshes.size() != 1 || a.meshes[0].primitives.size() != 2) return; const GlbPrimitive& p0 = a.meshes[0].primitives[0]; const GlbPrimitive& p1 = a.meshes[0].primitives[1]; Check(p0.positions.size() == 3 && Near(p0.positions[1].x, 1) && Near(p0.positions[2].y, 1), "positions read through a strided view"); Check(p0.normals.size() == 3 && Near(p0.normals[2].z, 1) && Near(p0.normals[0].x, 0), "normals read at their byte offset"); Check(p0.uv0.size() == 3 && Near(p0.uv0[1].x, 1) && Near(p0.uv0[2].y, 1) && Near(p0.uv0[1].y, 0), "normalized u16 UVs"); Check(p0.uv1.size() == 3 && Near(p0.uv1[0].x, 0.25f) && Near(p0.uv1[1].x, 0.75f) && Near(p0.uv1[2].y, 1), "a second UV set, TEXCOORD_1"); Check(p0.indices == std::vector{0, 1, 2}, "u8 indices widened"); Check(p0.joints.size() == 3 && p0.joints[0] == std::array{0, 1, 0, 0}, "u8 joints"); Check(p0.weights.size() == 3 && Near(p0.weights[0][0], 2.0f / 3.0f) && Near(p0.weights[0][1], 1.0f / 3.0f), "normalized u8 weights, renormalized to sum 1"); // glTF's v runs down the image, so the bitangent (normal map +Y, "up") // points along -v: here -Y, which is cross(normal, tangent) * -1. Check(p0.tangents.size() == 3 && Near(p0.tangents[0].x, 1) && Near(p0.tangents[0].y, 0) && Near(p0.tangents[0].z, 0) && Near(p0.tangents[0].w, -1), "tangents computed from the UVs, with glTF's bitangent sign"); Check(p0.material == 0 && p1.material == 1, "primitive materials"); Check(p1.positions.size() == 3 && Near(p1.positions[2].z, 1) && p1.indices == std::vector{0, 1, 2}, "a primitive without indices gets 0..n-1"); Check(p1.normals.empty() && p1.tangents.empty() && p1.uv0.empty() && p1.uv1.empty(), "absent streams stay empty"); Check(p0.targets.empty() && a.meshes[0].weights.empty() && a.nodes[0].weights.empty() && a.meshes[0].extras.empty(), "no morph targets, no weights for them, and no extras"); // Skin. Check(a.skins.size() == 1 && a.skins[0].joints == std::vector{0, 1} && a.skins[0].inverseBindMatrices.size() == 2 && NearMatrix(a.skins[0].inverseBindMatrices[1], {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1, 0, 0, 1}), "skin joints and inverse-bind matrices"); // Materials, textures, images. Check(a.materials.size() == 2, "two materials"); if (a.materials.size() == 2) { const GlbMaterial& m0 = a.materials[0]; Check(Near(m0.baseColorFactor[1], 0.25f) && Near(m0.metallicFactor, 0.1f) && Near(m0.roughnessFactor, 0.7f) && m0.baseColorTexture.texture == 0 && m0.normalTexture.texture == 0 && Near(m0.normalTexture.scale, 0.5f) && m0.alphaMode == GlbAlphaMode::Mask && Near(m0.alphaCutoff, 0.3f) && m0.doubleSided, "material factors, textures and alpha mode"); const GlbMaterial& m1 = a.materials[1]; Check(Near(m1.metallicFactor, 1) && Near(m1.roughnessFactor, 1) && m1.baseColorTexture.texture == -1 && m1.alphaMode == GlbAlphaMode::Opaque && !m1.doubleSided, "material defaults"); } Check(a.textures.size() == 1 && a.textures[0].image == 0 && a.textures[0].sampler == 0, "texture source and sampler"); Check(a.samplers.size() == 1 && a.samplers[0].magFilter == 9728 && a.samplers[0].minFilter == 9729 && a.samplers[0].wrapS == 10497, "sampler filters, and the wrap mode's default"); Check(a.images.size() == 1 && a.images[0].sizeX == 2 && a.images[0].sizeY == 2 && a.images[0].pixels.size() == 4, "the embedded PNG decodes"); if (a.images.size() == 1 && a.images[0].pixels.size() == 4) { const Pixel first = a.images[0].pixels[0]; Check(first.r == 255 && first.g == 0 && first.b == 0 && first.a == 255 && a.images[0].pixels[3].a == 0 && a.images[0].opaque == OpaqueType::SemiOpaque, "image pixels and opacity"); } Check(a.json.find("VRMC_springBone") != std::string::npos, "the raw JSON keeps extensions"); Check(a.bin == FixtureBin(), "the BIN chunk is kept, for the data the loader doesn't read"); } // What a room exported from Blender carries beyond meshes: a camera, // KHR_lights_punctual lights, glowing and glass materials // (KHR_materials_emissive_strength, _transmission, _ior), and the custom // properties Blender writes as `extras` on the scene, nodes, materials and // lights. The image is the fixture's PNG, at the same place in the buffer. std::string RoomJson(std::string_view candleLight = "0") { return std::format(R"({{ "asset": {{"version": "2.0"}}, "scene": 0, "scenes": [{{"nodes": [0, 1, 2, 3, 4], "extras": {{"exposure": 1.5}}}}], "nodes": [ {{"name": "Camera", "camera": 0, "translation": [0, 1.2, 1.5]}}, {{"name": "Candle", "extensions": {{"KHR_lights_punctual": {{"light": {}}}}}, "extras": {{"flicker": 0.3}}}}, {{"name": "Moon", "extensions": {{"KHR_lights_punctual": {{"light": 1}}}}}}, {{"name": "Sun", "extensions": {{"KHR_lights_punctual": {{"light": 2}}}}}}, {{"name": "Seat", "extras": {{"seat": true, "height": 1.6}}}} ], "cameras": [{{"name": "Webcam", "type": "perspective", "perspective": {{"yfov": 0.75, "aspectRatio": 1.7777778, "znear": 0.05, "zfar": 100}}}}], "extensionsUsed": ["KHR_lights_punctual", "KHR_materials_emissive_strength", "KHR_materials_transmission", "KHR_materials_ior"], "extensions": {{"KHR_lights_punctual": {{"lights": [ {{"name": "CandleLight", "type": "point", "color": [1, 0.6, 0.3], "intensity": 5, "range": 4, "extras": {{"shadows": false}}}}, {{"name": "MoonLight", "type": "spot", "intensity": 20, "spot": {{"innerConeAngle": 0.2, "outerConeAngle": 0.5}}}}, {{"name": "SunLight", "type": "directional", "color": [0.5, 0.6, 1], "intensity": 0.8}} ]}}}}, "materials": [ {{"name": "Flame", "emissiveFactor": [1, 0.5, 0.1], "extensions": {{"KHR_materials_emissive_strength": {{"emissiveStrength": 8}}}}, "extras": {{"effect": "flicker"}}}}, {{"name": "StainedGlass", "pbrMetallicRoughness": {{"baseColorTexture": {{"index": 0}}}}, "extensions": {{"KHR_materials_transmission": {{"transmissionFactor": 0.9, "transmissionTexture": {{"index": 0}}}}, "KHR_materials_ior": {{"ior": 1.45}}}}}}, {{"name": "Plain"}} ], "textures": [{{"source": 0}}], "images": [{{"bufferView": 0, "mimeType": "image/png"}}], "bufferViews": [{{"buffer": 0, "byteOffset": 276, "byteLength": 76}}], "buffers": [{{"byteLength": 376}}] }})", candleLight); } void TestRoom() { const std::expected loaded = GlbAsset::Load(BuildGlb(RoomJson(), FixtureBin())); if (!CheckLoads(loaded, "the room fixture loads")) return; const GlbAsset& a = *loaded; Check(a.cameras.size() == 1 && a.nodes[0].camera == 0 && a.nodes[1].camera == -1, "a node's camera"); if (a.cameras.size() == 1) { const GlbCamera& c = a.cameras[0]; Check(c.name == "Webcam" && !c.orthographic && Near(c.yfov, 0.75f) && Near(c.aspectRatio, 1.7777778f) && Near(c.znear, 0.05f) && Near(c.zfar, 100), "a perspective camera's field of view, aspect and clip planes"); } Check(a.lights.size() == 3 && a.nodes[1].light == 0 && a.nodes[2].light == 1 && a.nodes[3].light == 2 && a.nodes[0].light == -1, "nodes' KHR_lights_punctual lights"); if (a.lights.size() == 3) { const GlbLight& point = a.lights[0]; Check(point.name == "CandleLight" && point.type == GlbLightType::Point && Near(point.color[1], 0.6f) && Near(point.intensity, 5) && Near(point.range, 4), "a point light's colour, intensity and range"); const GlbLight& spot = a.lights[1]; Check(spot.type == GlbLightType::Spot && Near(spot.innerConeAngle, 0.2f) && Near(spot.outerConeAngle, 0.5f) && Near(spot.color[0], 1) && Near(spot.range, 0), "a spot light's cone, with white and no range by default"); const GlbLight& sun = a.lights[2]; Check(sun.type == GlbLightType::Directional && Near(sun.intensity, 0.8f) && Near(sun.outerConeAngle, std::numbers::pi_v / 4), "a directional light, the cone left at the extension's default"); Check(point.extras == R"({"shadows":false})" && spot.extras.empty(), "a light's extras, as JSON text"); } Check(a.materials.size() == 3, "three materials"); if (a.materials.size() == 3) { const GlbMaterial& flame = a.materials[0]; Check(Near(flame.emissiveStrength, 8) && Near(flame.emissiveFactor[1], 0.5f) && Near(flame.transmissionFactor, 0), "KHR_materials_emissive_strength"); const GlbMaterial& glass = a.materials[1]; Check(Near(glass.transmissionFactor, 0.9f) && glass.transmissionTexture.texture == 0 && Near(glass.ior, 1.45f), "KHR_materials_transmission with its texture, and KHR_materials_ior"); const GlbMaterial& plain = a.materials[2]; Check(Near(plain.emissiveStrength, 1) && Near(plain.transmissionFactor, 0) && plain.transmissionTexture.texture == -1 && Near(plain.ior, 1.5f) && plain.extras.empty(), "the extensions' defaults where a material has none"); Check(flame.extras == R"({"effect":"flicker"})", "a material's extras, as JSON text"); } Check(a.nodes[1].extras == R"({"flicker":0.3})" && a.nodes[4].extras == R"({"height":1.6,"seat":true})" && a.nodes[0].extras.empty(), "nodes' extras, as JSON text"); Check(a.extras == R"({"exposure":1.5})", "the default scene's extras, as JSON text"); // A node may only name a light the extension defines. CheckError(GlbAsset::Load(BuildGlb(RoomJson("7"), FixtureBin())), "light", "a node naming a light that doesn't exist is an error"); const auto required = LoadJson(Replace(RoomJson(), {{R"("extensionsUsed")", R"("extensionsRequired": ["KHR_lights_punctual"], "extensionsUsed")"}}), FixtureBin()); Check(required && required->lights.size() == 3, "a file that requires KHR_lights_punctual, which the loader implements, loads"); const auto nullExtras = LoadJson(Replace(RoomJson(), {{R"("extras": {"seat": true, "height": 1.6})", R"("extras": null)"}}), FixtureBin()); Check(nullExtras && nullExtras->nodes[4].extras.empty(), "extras of null are no extras"); } // Three vertices and two morph targets: one moves the positions and normals, // and one, sparse over no bufferView, moves vertex 2 alone. std::vector MorphBin() { Writer w; // bufferView 0, offset 0: positions. for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}) w.Put(f); // bufferView 1, offset 36: target 0's position offsets. for (float f : {0.0f, 0.0f, 0.5f, 0.0f, 0.0f, 0.25f, 0.0f, 0.0f, 0.125f}) w.Put(f); // bufferView 2, offset 72: normals along +Z, and target 0's normal offsets. for (int v = 0; v < 3; ++v) for (float f : {0.0f, 0.0f, 1.0f}) w.Put(f); // bufferView 3, offset 108: target 1's sparse index, u8. w.Put(2); w.Pad(4, std::byte{0}); // bufferView 4, offset 112: target 1's sparse value. for (float f : {0.0f, 2.0f, 0.0f}) w.Put(f); return w.bytes; } std::string MorphJson() { return R"({ "asset": {"version": "2.0"}, "nodes": [{"mesh": 0, "weights": [1, 0.5]}, {"mesh": 0}], "meshes": [{"name": "Face", "weights": [0.25, 0.75], "extras": {"targetNames": ["Smile", "Blink"]}, "primitives": [{"attributes": {"POSITION": 0, "NORMAL": 2}, "targets": [{"POSITION": 1, "NORMAL": 2}, {"POSITION": 3}]}]}], "accessors": [ {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}, {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"}, {"componentType": 5126, "count": 3, "type": "VEC3", "sparse": {"count": 1, "indices": {"bufferView": 3, "componentType": 5121}, "values": {"bufferView": 4}}} ], "bufferViews": [ {"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"buffer": 0, "byteOffset": 36, "byteLength": 36}, {"buffer": 0, "byteOffset": 72, "byteLength": 36}, {"buffer": 0, "byteOffset": 108, "byteLength": 1}, {"buffer": 0, "byteOffset": 112, "byteLength": 12} ], "buffers": [{"byteLength": 124}] })"; } void TestMorphTargets() { const auto loaded = LoadJson(MorphJson(), MorphBin()); if (!CheckLoads(loaded, "a mesh with morph targets loads")) return; const GlbMesh& mesh = loaded->meshes[0]; const GlbPrimitive& p = mesh.primitives[0]; Check(p.targets.size() == 2, "two morph targets"); if (p.targets.size() != 2) return; const GlbMorphTarget& smile = p.targets[0]; Check(smile.positions.size() == 3 && Near(smile.positions[1].z, 0.25f) && Near(smile.positions[2].z, 0.125f) && smile.normals.size() == 3 && Near(smile.normals[0].z, 1) && smile.tangents.empty(), "a target's position and normal offsets"); const GlbMorphTarget& blink = p.targets[1]; Check(blink.positions.size() == 3 && Near(blink.positions[0].y, 0) && Near(blink.positions[1].y, 0) && Near(blink.positions[2].y, 2) && blink.normals.empty(), "a sparse target over no bufferView: zeros, and the one value it gives"); Check(mesh.weights == std::vector{0.25f, 0.75f}, "the mesh's morph weights"); Check(loaded->nodes[0].weights == std::vector{1, 0.5f} && loaded->nodes[1].weights.empty(), "a node's own morph weights"); Check(mesh.extras == R"({"targetNames":["Smile","Blink"]})", "the mesh's extras, with the targets' names"); CheckError(LoadJson(Replace(MorphJson(), {{R"("weights": [1, 0.5])", R"("weights": [1])"}}), MorphBin()), "morph weights", "a node with a morph weight short is an error"); CheckError(LoadJson(Replace(MorphJson(), {{R"("weights": [0.25, 0.75])", R"("weights": [0.25])"}}), MorphBin()), "morph weights", "a mesh with a morph weight short is an error"); CheckError(LoadJson(Replace(MorphJson(), {{R"({"POSITION": 3}]})", R"({"POSITION": 3}]}, {"attributes": {"POSITION": 0}})"}}), MorphBin()), "same number of morph targets", "primitives of a mesh with different morph targets are an error"); CheckError(LoadJson(Replace(MorphJson(), {{R"("count": 3, "type": "VEC3",)", R"("count": 2, "type": "VEC3",)"}}), MorphBin()), "sparse", "a sparse index past its accessor is an error"); } // The four corners of a square. std::vector QuadBin() { Writer w; for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f}) w.Put(f); return w.bytes; } std::string ModesJson() { return R"({ "asset": {"version": "2.0"}, "meshes": [{"primitives": [ {"attributes": {"POSITION": 0}, "mode": 5}, {"attributes": {"POSITION": 0}, "mode": 6}, {"attributes": {"POSITION": 0}, "mode": 1, "material": 0}, {"attributes": {"POSITION": 0}, "mode": 0} ]}], "materials": [{"name": "Wire"}], "accessors": [{"bufferView": 0, "componentType": 5126, "count": 4, "type": "VEC3"}], "bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 48}], "buffers": [{"byteLength": 48}] })"; } void TestPrimitiveModes() { const auto loaded = LoadJson(ModesJson(), QuadBin()); if (!CheckLoads(loaded, "a mesh of strips, fans, lines and points loads")) return; const std::vector& p = loaded->meshes[0].primitives; Check(p.size() == 4, "every primitive is kept, so they keep the file's numbering"); if (p.size() != 4) return; // glTF's numbering: a strip's triangle i is (i, i+1, i+2), its last two // swapped when i is odd; a fan's is (i+1, i+2, 0). Check(p[0].indices == std::vector{0, 1, 2, 1, 3, 2} && p[0].positions.size() == 4, "a triangle strip as a list"); Check(p[1].indices == std::vector{1, 2, 0, 2, 3, 0}, "a triangle fan as a list"); Check(p[2].positions.empty() && p[2].indices.empty() && p[2].material == 0 && p[3].positions.empty(), "lines and points come back with no vertices"); CheckError(LoadJson(Replace(ModesJson(), {{R"("mode": 5)", R"("mode": 7)"}}), QuadBin()), "mode 7", "a mode glTF doesn't define is an error"); } // A triangle whose TEXCOORD_0 runs u along +X and TEXCOORD_1 runs it along // +Y, normal-mapped through TEXCOORD_1. std::vector TangentBin() { Writer w; // bufferView 0, offset 0: a triangle in the XY plane. for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}) w.Put(f); // bufferView 1, offset 36: normals along +Z. for (int v = 0; v < 3; ++v) for (float f : {0.0f, 0.0f, 1.0f}) w.Put(f); // bufferView 2, offset 72: TEXCOORD_0, u along +X and v along +Y. for (float f : {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f}) w.Put(f); // bufferView 3, offset 96: TEXCOORD_1, u along +Y and v along +X. for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f}) w.Put(f); // bufferView 4, offset 120: normals of zero length. for (int i = 0; i < 9; ++i) w.Put(0.0f); // bufferView 5, offset 156: UVs all at one point. for (int i = 0; i < 6; ++i) w.Put(0.0f); // bufferView 6, offset 180: the PNG. for (std::uint8_t b : kPng) w.Put(b); return w.bytes; } std::string TangentJson() { return R"({ "asset": {"version": "2.0"}, "meshes": [{"primitives": [ {"attributes": {"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, "TEXCOORD_1": 3}, "material": 0}, {"attributes": {"POSITION": 0, "NORMAL": 1, "TEXCOORD_1": 3}, "material": 0}, {"attributes": {"POSITION": 0, "NORMAL": 4, "TEXCOORD_0": 5}} ]}], "materials": [{"name": "Bumpy", "normalTexture": {"index": 0, "texCoord": 1}}], "textures": [{"source": 0}], "images": [{"bufferView": 6, "mimeType": "image/png"}], "accessors": [ {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}, {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC2"}, {"bufferView": 3, "componentType": 5126, "count": 3, "type": "VEC2"}, {"bufferView": 4, "componentType": 5126, "count": 3, "type": "VEC3"}, {"bufferView": 5, "componentType": 5126, "count": 3, "type": "VEC2"} ], "bufferViews": [ {"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"buffer": 0, "byteOffset": 36, "byteLength": 36}, {"buffer": 0, "byteOffset": 72, "byteLength": 24}, {"buffer": 0, "byteOffset": 96, "byteLength": 24}, {"buffer": 0, "byteOffset": 120, "byteLength": 36}, {"buffer": 0, "byteOffset": 156, "byteLength": 24}, {"buffer": 0, "byteOffset": 180, "byteLength": 76} ], "buffers": [{"byteLength": 256}] })"; } void TestTangents() { const auto loaded = LoadJson(TangentJson(), TangentBin()); if (!CheckLoads(loaded, "normal-mapped primitives with two UV sets load")) return; const std::vector& p = loaded->meshes[0].primitives; // Along +Y, uv1's u; uv1's v runs along +X, so the bitangent, along -v, // is -X: cross(normal, tangent) * +1. auto alongY = [](const GlbPrimitive& primitive) { return primitive.tangents.size() == 3 && Near(primitive.tangents[0].x, 0) && Near(primitive.tangents[0].y, 1) && Near(primitive.tangents[0].z, 0) && Near(primitive.tangents[0].w, 1); }; Check(alongY(p[0]), "tangents from the UV set the normal texture samples, TEXCOORD_1, not TEXCOORD_0"); Check(alongY(p[1]), "tangents for a primitive with TEXCOORD_1 alone"); Check(p[2].tangents.size() == 3 && std::ranges::all_of(p[2].tangents, [](const Vector& t) { return std::isfinite(t.x) && std::isfinite(t.y) && std::isfinite(t.z) && std::isfinite(t.w) && Near(t.x * t.x + t.y * t.y + t.z * t.z, 1); }), "a zero normal with no UV gradient still gets a unit tangent, not NaN"); } // A triangle skinned through JOINTS_0/WEIGHTS_0 and JOINTS_1/WEIGHTS_1: // vertex 0 names joint 1 in both, vertex 1 has eight influences, and vertex // 2 no weight at all. std::vector SkinBin() { Writer w; // bufferView 0, offset 0: a triangle. for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}) w.Put(f); // bufferView 1, offset 36: JOINTS_0 as u8. for (std::uint8_t j : {0, 1, 2, 3, 0, 1, 2, 3, 5, 1, 2, 3}) w.Put(j); // bufferView 2, offset 48: WEIGHTS_0 as floats. for (float f : {0.3f, 0.2f, 0.0f, 0.0f, 0.1f, 0.2f, 0.05f, 0.15f, 0.0f, 0.0f, 0.0f, 0.0f}) w.Put(f); // bufferView 3, offset 96: JOINTS_1. for (std::uint8_t j : {1, 4, 5, 6, 4, 5, 6, 7, 0, 1, 2, 3}) w.Put(j); // bufferView 4, offset 108: WEIGHTS_1. for (float f : {0.5f, 0.0f, 0.0f, 0.0f, 0.25f, 0.1f, 0.1f, 0.05f, 0.0f, 0.0f, 0.0f, 0.0f}) w.Put(f); return w.bytes; } std::string SkinJson() { return R"({ "asset": {"version": "2.0"}, "nodes": [{"name": "Body", "mesh": 0, "skin": 0}, {}, {}, {}, {}, {}, {}, {}], "meshes": [{"name": "Body", "primitives": [{"attributes": {"POSITION": 0, "JOINTS_0": 1, "WEIGHTS_0": 2, "JOINTS_1": 3, "WEIGHTS_1": 4}}]}], "skins": [{"name": "Rig", "joints": [0, 1, 2, 3, 4, 5, 6, 7]}], "accessors": [ {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, {"bufferView": 1, "componentType": 5121, "count": 3, "type": "VEC4"}, {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC4"}, {"bufferView": 3, "componentType": 5121, "count": 3, "type": "VEC4"}, {"bufferView": 4, "componentType": 5126, "count": 3, "type": "VEC4"} ], "bufferViews": [ {"buffer": 0, "byteOffset": 0, "byteLength": 36}, {"buffer": 0, "byteOffset": 36, "byteLength": 12}, {"buffer": 0, "byteOffset": 48, "byteLength": 48}, {"buffer": 0, "byteOffset": 96, "byteLength": 12}, {"buffer": 0, "byteOffset": 108, "byteLength": 48} ], "buffers": [{"byteLength": 156}] })"; } void TestSkinning() { const auto loaded = LoadJson(SkinJson(), SkinBin()); if (!CheckLoads(loaded, "a mesh skinned through two sets of joints loads")) return; const GlbPrimitive& p = loaded->meshes[0].primitives[0]; Check(p.joints.size() == 3 && p.weights.size() == 3, "joints and weights for every vertex"); if (p.joints.size() != 3 || p.weights.size() != 3) return; Check(p.joints[0] == std::array{0, 1, 0, 0} && Near(p.weights[0][0], 0.3f) && Near(p.weights[0][1], 0.7f) && Near(p.weights[0][2], 0) && Near(p.weights[0][3], 0), "a joint in both sets counted once, its weights added: 0.3 and 0.7"); Check(p.joints[1] == std::array{4, 1, 3, 0} && Near(p.weights[1][0], 0.25f / 0.7f) && Near(p.weights[1][1], 0.2f / 0.7f) && Near(p.weights[1][2], 0.15f / 0.7f) && Near(p.weights[1][3], 0.1f / 0.7f), "of eight influences the four heaviest, their weights summing to 1"); Check(p.joints[2] == std::array{5, 0, 0, 0} && p.weights[2] == std::array{1, 0, 0, 0}, "a vertex with no weight is bound wholly to its first joint"); CheckError(LoadJson(Replace(SkinJson(), {{R"("JOINTS_0": 1, "WEIGHTS_0": 2, "JOINTS_1": 3, "WEIGHTS_1": 4)", R"("JOINTS_0": 1)"}}), SkinBin()), "pair up", "JOINTS_0 without WEIGHTS_0 is an error"); CheckError(LoadJson(Replace(SkinJson(), {{R"(, "WEIGHTS_1": 4)", ""}}), SkinBin()), "pair up", "JOINTS_1 without WEIGHTS_1 is an error"); CheckError(LoadJson(Replace(SkinJson(), {{R"("WEIGHTS_1": 4}})", R"("WEIGHTS_1": 4}}, {"attributes": {"POSITION": 0}})"}}), SkinBin()), "without JOINTS_0", "a skinned node whose mesh has a primitive without joints is an error"); CheckError(LoadJson(Replace(SkinJson(), {{R"({"name": "Body", "mesh": 0, "skin": 0})", R"({"name": "Body", "skin": 0})"}}), SkinBin()), "no mesh", "a skin on a node without a mesh is an error"); CheckError(LoadJson(Replace(SkinJson(), {{R"("joints": [0, 1, 2, 3, 4, 5, 6, 7])", R"("joints": [0, 1, 2, 3])"}}), SkinBin()), "beyond its skin's 4 joints", "a joint past the skin's joints is an error"); } // References that wrap round when narrowed to 32 bits, whole numbers written // as JSON floats, UV sets, scenes, and what a file may require. void TestReferences() { auto fixture = [](std::initializer_list> changes) { return LoadJson(Replace(FixtureJson(), changes), FixtureBin()); }; CheckError(fixture({{R"("children": [1])", R"("children": [4294967297])"}}), "child", "a child 2^32 + 1 is an error, not node 1"); CheckError(fixture({{R"("mesh": 0, "skin": 0)", R"("mesh": 4294967296, "skin": 0)"}}), "mesh", "a mesh 2^32 is an error, not mesh 0"); CheckError(fixture({{R"("mesh": 0, "skin": 0)", R"("mesh": -1, "skin": 0)"}}), "mesh", "a mesh of -1 is an error, not no mesh"); CheckError(fixture({{R"("textures": [{"source": 0,)", R"("textures": [{"source": 4294967296,)"}}), "image", "a texture's image 2^32 is an error, not image 0"); CheckError(fixture({{R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126,)", R"({"bufferView": 0, "byteOffset": 0, "componentType": 4294972422,)"}}), "componentType", "a componentType of FLOAT + 2^32 is an error, not FLOAT"); CheckError(LoadJson(Replace(RoomJson(), {{R"("camera": 0)", R"("camera": 4294967295)"}}), FixtureBin()), "camera", "a camera 2^32 - 1 is an error, not no camera"); CheckError(fixture({{R"("magFilter": 9728)", R"("magFilter": 4294977024)"}}), "sampler", "a magFilter of NEAREST + 2^32 is an error"); const auto floats = fixture({{R"("byteStride": 24)", R"("byteStride": 24.0)"}, {R"("mesh": 0, "skin": 0)", R"("mesh": 0.0, "skin": 0)"}, {R"("componentType": 5126, "count": 3, "type": "VEC3"})", R"("componentType": 5126, "count": 3e0, "type": "VEC3"})"}}); Check(floats && floats->nodes[0].mesh == 0 && Near(floats->meshes[0].primitives[0].positions[1].x, 1) && Near(floats->meshes[0].primitives[0].normals[2].z, 1), floats ? "whole numbers written as 24.0 or 3e0 read as integers" : std::format("whole numbers written as 24.0 or 3e0 read as integers: {}", floats.error())); const auto uv1 = fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": 1})"}}); Check(uv1 && uv1->materials[0].baseColorTexture.texCoord == 1, "a texture sampled with TEXCOORD_1"); CheckError(fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": 1})"}, {R"("TEXCOORD_1": 8, )", ""}}), "TEXCOORD_1", "a texture sampled with TEXCOORD_1 on a primitive without it is an error"); CheckError(fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": 2})"}}), "UV set", "a texture sampled with TEXCOORD_2, which the loader doesn't read, is an error"); CheckError(fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": -1})"}}), "UV set", "a texCoord of -1 is an error"); CheckError(fixture({{R"("scene": 0)", R"("scene": 5)"}}), "default scene", "a default scene that doesn't exist is an error"); CheckError(fixture({{R"("nodes": [0]})", R"("nodes": [0, 1]})"}}), "a child", "a scene with a child node as a root is an error"); CheckError(fixture({{R"("nodes": [0]})", R"("nodes": [0, 0]})"}}), "twice", "a scene naming a root twice is an error"); CheckError(fixture({{R"("extensionsUsed": ["VRMC_springBone"])", R"("extensionsUsed": ["KHR_draco_mesh_compression"], "extensionsRequired": ["KHR_draco_mesh_compression"])"}}), "KHR_draco_mesh_compression", "a file that requires an extension the loader doesn't implement is an error"); } // The fixture's BIN, followed by images in formats core glTF doesn't have: a // WebP's start, as EXT_texture_webp adds, and a GIF. std::vector OtherImagesBin() { Writer w{FixtureBin()}; // bufferView 9, offset 376. for (char c : std::string_view("RIFF\x08\0\0\0WEBPVP8 ", 16)) w.Put(c); // bufferView 10, offset 392. for (std::uint8_t b : kGif) w.Put(b); return w.bytes; } std::string OtherImagesJson(std::string_view images, std::string_view textures) { return Replace(FixtureJson(), { {R"({"buffer": 0, "byteOffset": 352, "byteLength": 24})", R"({"buffer": 0, "byteOffset": 352, "byteLength": 24}, {"buffer": 0, "byteOffset": 376, "byteLength": 16}, {"buffer": 0, "byteOffset": 392, "byteLength": 35})"}, {R"("images": [{"bufferView": 7, "mimeType": "image/png"}])", images}, {R"("textures": [{"source": 0, "sampler": 0}])", textures}, {R"("extensionsUsed": ["VRMC_springBone"])", R"("extensionsUsed": ["VRMC_springBone", "EXT_texture_webp"])"}, }); } // A PNG of the given header and IDAT: big-endian chunks with their CRCs. std::vector Png(std::uint32_t width, std::uint32_t height, std::uint8_t depth, std::uint8_t colorType, const std::vector& idat) { std::vector png = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; auto be32 = [](std::vector& out, std::uint32_t v) { for (int shift = 24; shift >= 0; shift -= 8) out.push_back(static_cast(v >> shift)); }; auto chunk = [&](const char* type, const std::vector& data) { be32(png, static_cast(data.size())); std::vector typed(type, type + 4); typed.insert(typed.end(), data.begin(), data.end()); std::uint32_t crc = 0xFFFFFFFF; for (std::uint8_t byte : typed) { crc ^= byte; for (int k = 0; k < 8; ++k) crc = (crc >> 1) ^ (0xEDB88320 & (0 - (crc & 1))); } png.insert(png.end(), typed.begin(), typed.end()); be32(png, ~crc); }; std::vector header; be32(header, width); be32(header, height); header.insert(header.end(), {depth, colorType, 0, 0, 0}); chunk("IHDR", header); chunk("IDAT", idat); chunk("IEND", {}); return Bytes(png); } // A grey PNG `width` by 1, its row stored without compression: zlib's stored // blocks with its Adler-32. std::vector WidePng(std::uint32_t width) { std::vector row(1 + width, 128); row[0] = 0; // no filter std::vector zlib = {0x78, 0x01}; for (std::size_t at = 0; at < row.size();) { const std::size_t n = std::min(65535, row.size() - at); zlib.push_back(at + n == row.size() ? 1 : 0); for (std::uint16_t v : {static_cast(n), static_cast(~n)}) { zlib.push_back(static_cast(v)); zlib.push_back(static_cast(v >> 8)); } zlib.insert(zlib.end(), row.begin() + static_cast(at), row.begin() + static_cast(at + n)); at += n; } std::uint32_t a = 1, b = 0; for (std::uint8_t c : row) { a = (a + c) % 65521; b = (b + a) % 65521; } for (int shift = 24; shift >= 0; shift -= 8) zlib.push_back(static_cast(((b << 16) | a) >> shift)); return Png(width, 1, 8, 0, zlib); } void TestImages() { using Image = TextureAsset; // Only an extension reads WebP; stb doesn't, so the file failed whole. const auto webp = LoadJson(OtherImagesJson(R"("images": [{"bufferView": 7, "mimeType": "image/png"}, {"bufferView": 9, "mimeType": "image/webp"}])", R"("textures": [{"source": 0, "sampler": 0, "extensions": {"EXT_texture_webp": {"source": 1}}}])"), OtherImagesBin()); Check(webp && webp->images.size() == 2 && webp->images[0].pixels.size() == 4 && webp->images[1].sizeX == 0 && webp->images[1].pixels.empty(), webp ? "a WebP that EXT_texture_webp falls back from stays undecoded, and the file loads" : std::format("a WebP stays undecoded: {}", webp.error())); CheckError(LoadJson(OtherImagesJson(R"("images": [{"bufferView": 7, "mimeType": "image/png"}, {"bufferView": 9, "mimeType": "image/webp"}])", R"("textures": [{"source": 1, "sampler": 0}])"), OtherImagesBin()), "neither a PNG nor a JPEG", "a texture whose image is a WebP is an error"); // stb decodes this GIF; a GLB has no business with it. const auto gif = Image::LoadPNGFromMemory(Bytes(kGif)); Check(gif && gif->sizeX == 1, "the test's GIF decodes, as stb reads GIFs"); const auto gifInGlb = LoadJson(OtherImagesJson(R"("images": [{"bufferView": 7, "mimeType": "image/png"}, {"bufferView": 10, "mimeType": "image/png"}])", R"("textures": [{"source": 0, "sampler": 0}])"), OtherImagesBin()); Check(gifInGlb && gifInGlb->images[1].pixels.empty(), "a GIF in a GLB, even tagged image/png, isn't decoded"); // Two images of the same bufferView: decoded once, copied once, and the // copy is paid for out of the decode limit. auto twice = [](std::optional limit) { return LoadJson(R"({"asset": {"version": "2.0"}, "images": [{"bufferView": 0}, {"bufferView": 0}], "bufferViews": [{"buffer": 0, "byteLength": 76}], "buffers": [{"byteLength": 76}]})", Bytes(kPng), limit); }; const auto shared = twice(std::nullopt); Check(shared && shared->images.size() == 2 && shared->images[1].pixels.size() == 4 && shared->images[1].pixels[3].a == 0, "two images of one bufferView both come back"); Check(twice(32).has_value(), "two 2x2 images fit a limit of 32 bytes"); CheckError(twice(20), "image 1 decodes past the limit of 20 bytes", "the second one doesn't fit a limit of 20 bytes"); // A 16-bit PNG that stb's 32-bit arithmetic overflowed decoding: turned // down from its header, whose IDAT here is no image at all. const auto huge = Image::LoadPNGFromMemory(Png(32768, 16385, 16, 0, {0x78, 0x01})); CheckError(huge, "pixels in all", "a 32768x16385 16-bit PNG is turned down from its header, before decoding"); const auto narrow = Image::LoadPNGFromMemory(WidePng(16)); Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes"); CheckError(Image::LoadPNGFromMemory(WidePng(70000)), "65535 a side", "an image 70000 wide is an error"); // A deflate block of the reserved type 3, which stb fails without a // reason. After other failures, so a stale reason would show: reading // its header, stb tries it as a JPEG first, which fails with "no SOI". const std::vector reasonless = Png(1, 1, 8, 6, {0x78, 0x01, 0x07}); CheckError(Image::LoadPNGFromMemory(Bytes(std::array{1, 2, 3, 4})), "unknown image type", "bytes that are no image are an error"); const auto noReason = Image::LoadPNGFromMemory(reasonless); Check(!noReason && noReason.error() == "corrupt image", noReason ? "an image stb fails without a reason is an error" : std::format("an image stb fails without a reason is an error, not a stale reason ({})", noReason.error())); CheckError(LoadJson(R"({"asset": {"version": "2.0"}, "images": [{"bufferView": 0}], "bufferViews": [{"buffer": 0, "byteLength": )" + std::to_string(reasonless.size()) + "}]}", reasonless), "image 0 doesn't decode", "a GLB with that image in it is an error"); #if __cpp_exceptions // LoadPNG throws for a file it can't decode, which crafter-build catches // to report the file, rather than aborting. const std::filesystem::path file = TempFile("reasonless.png"); std::ofstream(file, std::ios::binary).write(reinterpret_cast(reasonless.data()), static_cast(reasonless.size())); for (const std::filesystem::path& path : {file, std::filesystem::path("/nonexistent/texture.png")}) { std::string message; try { (void)Image::LoadPNG(path); } catch (const std::runtime_error& e) { message = e.what(); } Check(message.find(path.filename().string()) != std::string::npos, std::format("LoadPNG throws std::runtime_error for {} ({})", path.filename().string(), message)); } std::filesystem::remove(file); #endif } void TestDecodeLimit() { CheckError(GlbAsset::Load(BuildGlb(FixtureJson(), FixtureBin()), 0), "past the limit of 0 bytes", "the fixture doesn't fit a limit of 0 bytes"); // Three primitives drawing the one accessor, each 48 bytes decoded: 36 // of positions, 12 of indices. auto shared = [](std::uint64_t limit) { return LoadJson(R"({"asset": {"version": "2.0"}, "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}, {"attributes": {"POSITION": 0}}, {"attributes": {"POSITION": 0}}]}], "accessors": [{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}], "bufferViews": [{"buffer": 0, "byteLength": 36}], "buffers": [{"byteLength": 48}]})", QuadBin(), limit); }; Check(shared(144).has_value(), "three primitives sharing an accessor fit a limit of 3 x 48 bytes"); CheckError(shared(100), "mesh 0 primitive 2 decodes past the limit", "the third doesn't fit a limit of 100 bytes"); } void TestBrokenFiles() { std::vector glb = BuildGlb(FixtureJson(), FixtureBin()); std::vector badMagic = glb; badMagic[0] = std::byte{'X'}; CheckError(GlbAsset::Load(badMagic), "magic", "bad magic is an error"); const std::vector truncated(glb.begin(), glb.begin() + static_cast(glb.size() / 2)); Check(!GlbAsset::Load(truncated), "a truncated file is an error"); // A sparse accessor: its values written over its bufferView's. Accessor // 6, the second primitive's positions, gets the interleaved view's first // position, (0, 0, 0), for its element 0. const auto sparse = GlbAsset::Load(BuildGlb( FixtureJson(R"(, "sparse": {"count": 1, "indices": {"bufferView": 2, "componentType": 5121}, "values": {"bufferView": 0}})"), FixtureBin())); Check(sparse && Near(sparse->meshes[0].primitives[1].positions[0].z, 0) && Near(sparse->meshes[0].primitives[1].positions[1].z, 1), sparse ? "a sparse accessor's values replace its view's" : std::format("a sparse accessor's values replace its view's: {}", sparse.error())); CheckError(GlbAsset::Load(BuildGlb( FixtureJson(R"(, "sparse": {"count": 3, "indices": {"bufferView": 3, "componentType": 5121}, "values": {"bufferView": 5}})"), FixtureBin())), "sparse indices don't rise", "sparse indices that don't rise are an error"); CheckError(GlbAsset::Load(BuildGlb(FixtureJson("", 3), FixtureBin())), "bufferView", "an accessor past its buffer view is an error"); CheckError(GlbAsset::Load(std::filesystem::path("/nonexistent/file.glb")), "No such file", "a missing file is an error that says so"); // The fixture's JSON with each `from` replaced by its `to`, and its BIN // padded to 1 KiB, for views longer than the fixture's. auto with = [](std::initializer_list> changes) { std::vector bin = FixtureBin(); bin.resize(1024); return BuildGlb(Replace(FixtureJson(), changes), bin); }; // A stride past 252, in a view long enough for its 3 elements at that // stride: only the stride's range turns it down. CheckError(GlbAsset::Load(with({{R"("byteLength": 72, "byteStride": 24)", R"("byteLength": 600, "byteStride": 256)"}})), "byteStride", "a byteStride outside 4..252 is an error"); // A count whose elements' 24-byte strides wrap round to nothing. const auto wrappedCount = GlbAsset::Load(with({{R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3,)", R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 2305843009213693953,)"}})); Check(!wrappedCount, "a count too large for its view is an error, however its size wraps"); CheckError(GlbAsset::Load(with({{R"("joints": [0, 1],)", R"("joints": [0, 1], "skeleton": 99,)"}})), "skeleton", "a skin's skeleton that doesn't exist is an error"); Check(!GlbAsset::Load(std::filesystem::temp_directory_path()), "a directory is an error"); // The first chunk's length, past the end of the file. The loader works // in 32 bits, as the header does, where this length added to the chunk's // offset, 12, and its header, 8, wraps round to 12: a check made that way // would pass it, and step from the chunk back to itself forever. std::vector longChunk = glb; const std::uint32_t huge = 0xFFFFFFF8; std::memcpy(longChunk.data() + 12, &huge, sizeof(huge)); CheckError(GlbAsset::Load(longChunk), "truncated chunk", "a chunk longer than the file is an error"); // Files on disk: a header that claims 4 GiB in a 12-byte file, and one // that isn't a GLB at all. const std::filesystem::path file = TempFile("header.glb"); auto load = [&](std::span bytes) { std::ofstream(file, std::ios::binary).write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); return GlbAsset::Load(file); }; std::vector claims4GiB(glb.begin(), glb.begin() + 12); const std::uint32_t fourGiB = 0xFFFFFFFF; std::memcpy(claims4GiB.data() + 8, &fourGiB, sizeof(fourGiB)); CheckError(load(claims4GiB), "truncated", "a header that claims more than the file has is an error"); CheckError(load(Bytes(kPng)), "magic", "a file that isn't a GLB is turned down from its header"); std::filesystem::remove(file); // Extras nested 200,000 deep, which Json::dump recursed into once a // level and ran out of stack. constexpr std::size_t kDepth = 200000; const std::string nested = std::string(kDepth, '[') + std::string(kDepth, ']'); const auto deepExtras = LoadJson(std::format(R"({{"asset": {{"version": "2.0"}}, "nodes": [{{"extras": {}}}]}})", nested), {}); Check(deepExtras && deepExtras->nodes[0].extras == nested, "extras nested 200,000 deep come back as text"); // Nodes alone: a cycle, and long chains. auto nodesOnly = [](std::string_view nodes) { return BuildGlb(std::format(R"({{"asset": {{"version": "2.0"}}, "nodes": [{}]}})", nodes), {}); }; CheckError(GlbAsset::Load(nodesOnly(R"({"children": [1]}, {"children": [2]}, {"children": [0]})")), "cycle", "children that form a cycle are an error"); auto chainOf = [&](std::size_t depth) { std::string chain; for (std::size_t i = 0; i + 1 < depth; ++i) chain += std::format(R"({{"children": [{}]}},)", i + 1); chain += "{}"; return nodesOnly(chain); }; // Loads of a chain until one takes less than `within`, three at most: the // last load, and the fastest one's time, so that a busy machine counts // for less. using Micro = std::chrono::microseconds; auto timed = [](const std::vector& glb, Micro within) { std::expected last = std::unexpected(std::string()); Micro fastest = Micro::max(); for (int run = 0; run < 3 && fastest >= within; ++run) { last = std::unexpected(std::string()); // freed before the clock starts const auto started = std::chrono::steady_clock::now(); auto loaded = GlbAsset::Load(glb); fastest = std::min(fastest, std::chrono::duration_cast(std::chrono::steady_clock::now() - started)); last = std::move(loaded); } return std::pair(std::move(last), fastest); }; // Timed against a chain a quarter as deep rather than against the clock, // which a debug build on a busy machine can take any time to beat: in // proportion to depth it takes 4 times as long, walking up to the root // from every node 16. constexpr std::size_t kChain = 100000; const Micro quarter = timed(chainOf(kChain / 4), Micro::zero()).second; const auto [deep, whole] = timed(chainOf(kChain), quarter * 8); Check(deep && deep->nodes.size() == kChain && deep->nodes.back().parent == static_cast(kChain - 2), deep ? "a chain 100,000 nodes deep loads, each its parent's child" : std::format("a chain 100,000 nodes deep loads: {}", deep.error())); Check(whole < quarter * 8, std::format("in time in proportion to its depth ({} ms, against {} ms a quarter as deep)", whole.count() / 1000.0, quarter.count() / 1000.0)); } // Column-major 4x4 helpers for the rest-pose check, kept separate from the // matrix types under test. using Mat = std::array; Mat Multiply(const Mat& a, const Mat& b) { Mat r{}; for (int col = 0; col < 4; ++col) { for (int row = 0; row < 4; ++row) { float sum = 0; for (int k = 0; k < 4; ++k) sum += a[k * 4 + row] * b[col * 4 + k]; r[col * 4 + row] = sum; } } return r; } Mat LocalMatrix(const GlbNode& node) { if (node.hasMatrix) return node.matrix.Store(); // rows of the row-major copy are glTF's columns const float x = node.rotation.x, y = node.rotation.y, z = node.rotation.z, w = node.rotation.w; const float sx = node.scale.x, sy = node.scale.y, sz = node.scale.z; return { (1 - 2 * (y * y + z * z)) * sx, (2 * (x * y + w * z)) * sx, (2 * (x * z - w * y)) * sx, 0, (2 * (x * y - w * z)) * sy, (1 - 2 * (x * x + z * z)) * sy, (2 * (y * z + w * x)) * sy, 0, (2 * (x * z + w * y)) * sz, (2 * (y * z - w * x)) * sz, (1 - 2 * (x * x + y * y)) * sz, 0, node.translation.x, node.translation.y, node.translation.z, 1, }; } void TestRealFile(const char* path) { const auto loaded = GlbAsset::Load(std::filesystem::path(path)); if (!CheckLoads(loaded, std::format("{} loads", path))) return; const GlbAsset& a = *loaded; std::size_t vertices = 0; bool streamsMatch = true; bool indicesValid = true; for (const GlbMesh& mesh : a.meshes) { for (const GlbPrimitive& p : mesh.primitives) { vertices += p.positions.size(); streamsMatch = streamsMatch && p.normals.size() == p.positions.size() && (p.joints.empty() || (p.joints.size() == p.positions.size() && p.weights.size() == p.positions.size())); indicesValid = indicesValid && p.indices.size() % 3 == 0 && std::ranges::all_of(p.indices, [&](std::uint32_t i) { return i < p.positions.size(); }); } } std::println(" {} meshes, {} vertices, {} nodes, {} skins ({} joints), {} materials, {} images", a.meshes.size(), vertices, a.nodes.size(), a.skins.size(), a.skins.empty() ? 0 : a.skins[0].joints.size(), a.materials.size(), a.images.size()); Check(streamsMatch, "every vertex stream has one entry per vertex"); Check(indicesValid, "every index list is whole triangles of valid vertices"); Check(std::ranges::all_of(a.images, [](const auto& image) { return image.pixels.size() == std::size_t(image.sizeX) * image.sizeY; }), "every PNG and JPEG image decodes"); // World matrices by walking down from the roots, then world * inverseBind // per joint. std::vector world(a.nodes.size()); std::function walk = [&](std::uint32_t node, const Mat& parent) { world[node] = Multiply(parent, LocalMatrix(a.nodes[node])); for (std::uint32_t child : a.nodes[node].children) walk(child, world[node]); }; const Mat identity = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; for (std::uint32_t r = 0; r < a.nodes.size(); ++r) { if (a.nodes[r].parent == -1) walk(r, identity); } for (const GlbSkin& skin : a.skins) { float worst = 0; for (std::size_t j = 0; j < skin.joints.size(); ++j) { const Mat bound = Multiply(world[skin.joints[j]], skin.inverseBindMatrices[j].Store()); for (std::size_t i = 0; i < 16; ++i) worst = std::max(worst, std::abs(bound[i] - identity[i])); } Check(worst < 1e-3f, std::format("skin '{}': world * inverseBind is the identity at rest (worst {})", skin.name, worst)); } } } // namespace int main() { TestFixture(); TestRoom(); TestMorphTargets(); TestPrimitiveModes(); TestTangents(); TestSkinning(); TestReferences(); TestImages(); TestDecodeLimit(); TestBrokenFiles(); if (const char* real = std::getenv("CRAFTER_ASSET_TEST_GLB")) { TestRealFile(real); } else { std::println("SKIP real-file check (set CRAFTER_ASSET_TEST_GLB to a skinned .glb)"); } std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; }