feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dfd7f899cf
commit
c04b7a331a
7 changed files with 27170 additions and 6 deletions
552
tests/GLB/main.cpp
Normal file
552
tests/GLB/main.cpp
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
//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;
|
||||
}
|
||||
Loading…
Reference in a new issue