Crafter.Asset/tests/GLB/main.cpp

552 lines
30 KiB
C++
Raw Permalink Normal View History

//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 <cstdlib>
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<std::uint8_t, 76> 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<std::byte> bytes;
template <typename T>
void Put(T value) {
const auto* p = reinterpret_cast<const std::byte*>(&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<std::byte> BuildGlb(std::string_view json, const std::vector<std::byte>& bin) {
Writer jsonChunk;
for (char c : json) jsonChunk.Put(c);
jsonChunk.Pad(4, std::byte{' '});
std::vector<std::byte> binPadded = bin;
while (binPadded.size() % 4 != 0) binPadded.push_back(std::byte{0});
Writer out;
out.Put<std::uint32_t>(0x46546C67); // "glTF"
out.Put<std::uint32_t>(2);
out.Put<std::uint32_t>(static_cast<std::uint32_t>(12 + 8 + jsonChunk.bytes.size() + 8 + binPadded.size()));
out.Put<std::uint32_t>(static_cast<std::uint32_t>(jsonChunk.bytes.size()));
out.Put<std::uint32_t>(0x4E4F534A); // "JSON"
out.bytes.insert(out.bytes.end(), jsonChunk.bytes.begin(), jsonChunk.bytes.end());
out.Put<std::uint32_t>(static_cast<std::uint32_t>(binPadded.size()));
out.Put<std::uint32_t>(0x004E4942); // "BIN\0"
out.bytes.insert(out.bytes.end(), binPadded.begin(), binPadded.end());
return out.bytes;
}
std::vector<std::byte> FixtureBin() {
Writer w;
// bufferView 0, offset 0: interleaved position + normal, stride 24.
const std::array<std::array<float, 3>, 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<float, 4, 4, 1>& m, std::array<float, 16> expected) {
const std::array<float, 16> 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<std::byte> glb = BuildGlb(FixtureJson(), FixtureBin());
const std::expected<GlbAsset, std::string> 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<std::uint32_t>{1},
"parent and children links");
Check(a.sceneRoots == std::vector<std::uint32_t>{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<std::uint32_t>{0, 1, 2}, "u8 indices widened");
Check(p0.joints.size() == 3 && p0.joints[0] == std::array<std::uint16_t, 4>{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<std::uint32_t>{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<std::uint32_t>{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<GlbAsset, std::string> 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<float> / 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<std::byte> WidePng(std::uint32_t width) {
std::vector<std::uint8_t> row(1 + width, 128);
row[0] = 0; // no filter
std::vector<std::uint8_t> zlib = {0x78, 0x01};
for (std::size_t at = 0; at < row.size();) {
const std::size_t n = std::min<std::size_t>(65535, row.size() - at);
zlib.push_back(at + n == row.size() ? 1 : 0);
for (std::uint16_t v : {static_cast<std::uint16_t>(n), static_cast<std::uint16_t>(~n)}) {
zlib.push_back(static_cast<std::uint8_t>(v));
zlib.push_back(static_cast<std::uint8_t>(v >> 8));
}
zlib.insert(zlib.end(), row.begin() + static_cast<std::ptrdiff_t>(at), row.begin() + static_cast<std::ptrdiff_t>(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<std::uint8_t> png = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
auto be32 = [](std::vector<std::uint8_t>& out, std::uint32_t v) {
for (int shift = 24; shift >= 0; shift -= 8) out.push_back(static_cast<std::uint8_t>(v >> shift));
};
be32(zlib, (b << 16) | a);
auto chunk = [&](const char* type, const std::vector<std::uint8_t>& data) {
be32(png, static_cast<std::uint32_t>(data.size()));
std::vector<std::uint8_t> 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<std::uint8_t> 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<std::byte> out(png.size());
std::memcpy(out.data(), png.data(), png.size());
return out;
}
void TestBrokenFiles() {
std::vector<std::byte> glb = BuildGlb(FixtureJson(), FixtureBin());
std::vector<std::byte> 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<std::byte> truncated(glb.begin(), glb.begin() + static_cast<std::ptrdiff_t>(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<std::pair<std::string_view, std::string_view>> 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<std::byte> 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<std::int32_t>(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<std::chrono::milliseconds>(took).count()));
const auto narrow = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(WidePng(16));
Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes");
const auto wide = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(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<float, 16>;
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<Mat> world(a.nodes.size());
std::function<void(std::uint32_t, const Mat&)> 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;
}