//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // GlbAsset::Load on a GLB built here in memory: 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, 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. Then the ways a file can be broken, which must come // back as errors rather than crash: bad magic, truncation, a sparse accessor, // an accessor past its view, a light that doesn't exist, a stride or count // whose arithmetic wraps round to look in bounds, a skin's skeleton that // doesn't exist, a chunk longer than the file, a path that isn't a file, an // image wider than a texture's 16-bit size holds, and children that form a // cycle. A chain 100,000 nodes deep loads in good time: the cycle check // 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 { 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; } // 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, }; 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::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); 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, "JOINTS_0": 4, "WEIGHTS_0": 5}}, "indices": 3, "material": 0}}, {{"attributes": {{"POSITION": 6}}, "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"}} ], "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}} ], "buffers": [{{"byteLength": 352}}], "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); Check(loaded.has_value(), loaded.has_value() ? "the fixture loads" : std::format("the fixture loads: {}", loaded.error())); if (!loaded) 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.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.joints.empty(), "absent streams stay empty"); // 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, "sampler filters"); 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 GlbRGBA8 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"); } // 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": 352}}] }})", candleLight); } void TestRoom() { const std::expected loaded = GlbAsset::Load(BuildGlb(RoomJson(), FixtureBin())); Check(loaded.has_value(), loaded.has_value() ? "the room fixture loads" : std::format("the room fixture loads: {}", loaded.error())); if (!loaded) 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. const auto missingLight = GlbAsset::Load(BuildGlb(RoomJson("7"), FixtureBin())); Check(!missingLight && missingLight.error().find("light") != std::string::npos, "a node naming a light that doesn't exist is an error"); } // A grey PNG `width` by 1, its row stored without compression: big-endian // chunks with their CRCs, and 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; } 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)); }; be32(zlib, (b << 16) | a); 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, 1); header.insert(header.end(), {8, 0, 0, 0, 0}); // 8-bit grey chunk("IHDR", header); chunk("IDAT", zlib); chunk("IEND", {}); std::vector out(png.size()); std::memcpy(out.data(), png.data(), png.size()); return out; } void TestBrokenFiles() { std::vector glb = BuildGlb(FixtureJson(), FixtureBin()); std::vector badMagic = glb; badMagic[0] = std::byte{'X'}; const auto magic = GlbAsset::Load(badMagic); Check(!magic && magic.error().find("magic") != std::string::npos, "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"); const auto sparse = GlbAsset::Load(BuildGlb( FixtureJson(R"(, "sparse": {"count": 1, "indices": {"bufferView": 2, "componentType": 5121}, "values": {"bufferView": 5}})"), FixtureBin())); Check(!sparse && sparse.error().find("sparse") != std::string::npos, "a sparse accessor is an error"); const auto outside = GlbAsset::Load(BuildGlb(FixtureJson("", 3), FixtureBin())); Check(!outside && outside.error().find("bufferView") != std::string::npos, "an accessor past its buffer view is an error"); Check(!GlbAsset::Load(std::filesystem::path("/nonexistent/file.glb")), "a missing file is an error"); // The fixture's JSON with each `from` replaced by its `to`. auto with = [](std::initializer_list> changes) { std::string json = FixtureJson(); for (const auto& [from, to] : changes) json.replace(json.find(from), from.size(), to); return BuildGlb(json, FixtureBin()); }; // Accessor 0 reads the interleaved view: 3 elements, 24 bytes apart. An // offset and a negative stride that wrap round to land in bounds. const auto wrappedStride = GlbAsset::Load(with({{R"("byteStride": 24)", R"("byteStride": -4096)"}, {R"({"bufferView": 0, "byteOffset": 0,)", R"({"bufferView": 0, "byteOffset": 8192,)"}})); Check(!wrappedStride, "a negative byteStride is an error, however the offsets add up"); // 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"); const auto skeleton = GlbAsset::Load(with({{R"("joints": [0, 1],)", R"("joints": [0, 1], "skeleton": 99,)"}})); Check(!skeleton && skeleton.error().find("skeleton") != std::string::npos, "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; on a 32-bit build // its sum with the offset wraps round to a small number. std::vector longChunk = glb; const std::uint32_t huge = 0xFFFFFFF0; std::memcpy(longChunk.data() + 12, &huge, sizeof(huge)); Check(!GlbAsset::Load(longChunk), "a chunk longer than the file is an error"); // Nodes alone: a cycle, and a long chain. auto nodesOnly = [](std::string_view nodes) { return BuildGlb(std::format(R"({{"asset": {{"version": "2.0"}}, "nodes": [{}]}})", nodes), {}); }; const auto cycle = GlbAsset::Load(nodesOnly(R"({"children": [1]}, {"children": [2]}, {"children": [0]})")); Check(!cycle && cycle.error().find("cycle") != std::string::npos, "children that form a cycle are an error"); constexpr std::size_t kChain = 100000; std::string chain; for (std::size_t i = 0; i + 1 < kChain; ++i) chain += std::format(R"({{"children": [{}]}},)", i + 1); chain += "{}"; const auto started = std::chrono::steady_clock::now(); const auto deep = GlbAsset::Load(nodesOnly(chain)); const auto took = std::chrono::steady_clock::now() - started; 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(took < std::chrono::seconds(2), std::format("in good time ({} ms)", std::chrono::duration_cast(took).count())); const auto narrow = TextureAsset::LoadPNGFromMemory(WidePng(16)); Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes"); const auto wide = TextureAsset::LoadPNGFromMemory(WidePng(70000)); Check(!wide, wide ? std::format("an image 70000 wide is an error, not {} wide", wide->sizeX) : "an image 70000 wide is an error"); } // 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)); Check(loaded.has_value(), loaded ? std::format("{} loads", path) : std::format("{} loads: {}", path, loaded.error())); if (!loaded) 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.sizeX > 0 && image.pixels.size() == std::size_t(image.sizeX) * image.sizeY; }), "every 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(); 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; }