fix(glb): the code review's findings, and morph targets

Images (Crafter.Asset:Texture):
- LoadPNGFromMemory reads an image's size from its header before decoding
  and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path
  sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG
  reads its file and goes the same way. SizeFromMemory is public.
- A failure stb gives no reason for no longer passes NULL to std::string,
  and the reason is cleared before each decode, so it is never an older one.
- LoadPNG throws std::runtime_error for a file that doesn't load, which
  crafter-build's try/catch reports per file; without exceptions it is Fatal.

Bounds (Crafter.Asset:GLB):
- Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for
  each byte of the file. Vertices, indices and pixels are paid for before
  they are allocated, so primitives or images naming the same data can't
  exhaust memory.
- extras are written without recursion: deep nesting overflowed the stack.
- The skinned-joint check is O(vertices + nodes), not O(nodes x vertices).
- Load(path) reads the 12-byte header before the rest, and says why a path
  doesn't open.
- Chunk arithmetic is in 32 bits, as the header's is, ordered so that it
  can't wrap round.

Correctness:
- Indices are range-checked before they are narrowed to 32 bits, and whole
  numbers written as 24.0 read as integers.
- Missing tangents come from the UV set the normal texture samples, and a
  zero normal no longer makes them NaN.
- A file that requires an extension the loader doesn't implement is an
  error.
- Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and
  no texture may use one. An image repeating another's bufferView is copied.
- Strips and fans become lists; points and lines come back empty.
- texCoord is 0 or 1, and every primitive whose material samples it has it.
- JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a
  vertex without weight goes to its first joint, and a skinned node's mesh
  has joints in every primitive.
- The default scene exists, and its roots are distinct root nodes.
- Sampler enums are glTF's.

New:
- TEXCOORD_1, as uv1.
- Morph targets with the mesh's and nodes' weights; sparse accessors and
  ones without a bufferView; the meshes' extras; the BIN chunk.
- Images are TextureAsset<Vector<std::uint8_t, 4, 4>>; GlbRGBA8 is gone.

Tests: each of the above. The deep chain is timed against one a quarter as
deep instead of a 2 s clock, which flaked in debug builds; the byteStride
and chunk-length tests now fail when the check they test is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 03:25:12 +01:00
commit fbf3e7fc2e
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
4 changed files with 1468 additions and 400 deletions

View file

@ -1,20 +1,27 @@
//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
// 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, 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.
// 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
@ -30,6 +37,8 @@ using namespace Crafter;
namespace {
using Pixel = Vector<std::uint8_t, 4, 4>;
int failures = 0;
void Check(bool ok, std::string_view what) {
@ -41,6 +50,19 @@ 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 <typename T>
void CheckError(const std::expected<T, std::string>& 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<GlbAsset, std::string>& 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<std::uint8_t, 76> kPng = {
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
@ -50,6 +72,13 @@ constexpr std::array<std::uint8_t, 76> kPng = {
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
};
// A 1x1 GIF: white.
constexpr std::array<std::uint8_t, 35> 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<std::byte> bytes;
@ -85,6 +114,38 @@ std::vector<std::byte> BuildGlb(std::string_view json, const std::vector<std::by
return out.bytes;
}
std::expected<GlbAsset, std::string> LoadJson(std::string_view json, const std::vector<std::byte>& bin,
std::optional<std::uint64_t> 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<std::pair<std::string_view, std::string_view>> 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<std::byte> Bytes(std::span<const std::uint8_t> bytes) {
std::vector<std::byte> out(bytes.size());
std::memcpy(out.data(), bytes.data(), bytes.size());
return out;
}
std::vector<std::byte> FixtureBin() {
Writer w;
// bufferView 0, offset 0: interleaved position + normal, stride 24.
@ -109,6 +170,8 @@ std::vector<std::byte> FixtureBin() {
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;
}
@ -123,8 +186,8 @@ std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t acce
{{"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}}
{{"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": [
@ -144,7 +207,8 @@ std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t acce
{{"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": 6, "componentType": 5126, "count": {}, "type": "MAT4"}},
{{"bufferView": 8, "componentType": 5126, "count": 3, "type": "VEC2"}}
],
"bufferViews": [
{{"buffer": 0, "byteOffset": 0, "byteLength": 72, "byteStride": 24}},
@ -154,9 +218,10 @@ std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t acce
{{"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": 276, "byteLength": 76}},
{{"buffer": 0, "byteOffset": 352, "byteLength": 24}}
],
"buffers": [{{"byteLength": 352}}],
"buffers": [{{"byteLength": 376}}],
"extensionsUsed": ["VRMC_springBone"],
"extensions": {{"VRMC_springBone": {{"specVersion": "1.0"}}}}
}})", accessor6Extra, accessor7Count);
@ -173,8 +238,7 @@ bool NearMatrix(const MatrixRowMajor<float, 4, 4, 1>& m, std::array<float, 16> e
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;
if (!CheckLoads(loaded, "the fixture loads")) return;
const GlbAsset& a = *loaded;
// Nodes and scene.
@ -200,6 +264,8 @@ void TestFixture() {
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<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),
@ -212,7 +278,9 @@ void TestFixture() {
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");
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<std::uint32_t>{0, 1}
@ -234,16 +302,18 @@ void TestFixture() {
"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.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 GlbRGBA8 first = a.images[0].pixels[0];
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,
@ -282,14 +352,13 @@ std::string RoomJson(std::string_view candleLight = "0") {
"textures": [{{"source": 0}}],
"images": [{{"bufferView": 0, "mimeType": "image/png"}}],
"bufferViews": [{{"buffer": 0, "byteOffset": 276, "byteLength": 76}}],
"buffers": [{{"byteLength": 352}}]
"buffers": [{{"byteLength": 376}}]
}})", 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;
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");
@ -337,12 +406,356 @@ void TestRoom() {
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");
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");
}
// 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.
// Three vertices and two morph targets: one moves the positions and normals,
// and one, sparse over no bufferView, moves vertex 2 alone.
std::vector<std::byte> 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<std::uint8_t>(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<float>{0.25f, 0.75f}, "the mesh's morph weights");
Check(loaded->nodes[0].weights == std::vector<float>{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<std::byte> 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<GlbPrimitive>& 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<std::uint32_t>{0, 1, 2, 1, 3, 2} && p[0].positions.size() == 4, "a triangle strip as a list");
Check(p[1].indices == std::vector<std::uint32_t>{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<std::byte> 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<GlbPrimitive>& 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<float, 4, 4>& 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<std::byte> 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<std::uint16_t, 4>{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<std::uint16_t, 4>{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<std::uint16_t, 4>{5, 0, 0, 0} && p.weights[2] == std::array<float, 4>{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<std::pair<std::string_view, std::string_view>> 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<std::byte> 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<std::byte> Png(std::uint32_t width, std::uint32_t height, std::uint8_t depth, std::uint8_t colorType,
const std::vector<std::uint8_t>& idat) {
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));
};
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, 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<std::byte> WidePng(std::uint32_t width) {
std::vector<std::uint8_t> row(1 + width, 128);
row[0] = 0; // no filter
@ -362,33 +775,99 @@ std::vector<std::byte> WidePng(std::uint32_t width) {
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));
for (int shift = 24; shift >= 0; shift -= 8) zlib.push_back(static_cast<std::uint8_t>(((b << 16) | a) >> shift));
return Png(width, 1, 8, 0, zlib);
}
void TestImages() {
using Image = TextureAsset<Pixel>;
// 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<std::uint8_t>(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<std::uint64_t> limit) {
return LoadJson(R"({"asset": {"version": "2.0"}, "images": [{"bufferView": 0}, {"bufferView": 0}],
"bufferViews": [{"buffer": 0, "byteLength": 76}], "buffers": [{"byteLength": 76}]})",
Bytes(kPng), limit);
};
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)));
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<std::uint8_t>(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<std::uint8_t>(WidePng(16));
Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes");
CheckError(Image::LoadPNGFromMemory<std::uint8_t>(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<std::byte> reasonless = Png(1, 1, 8, 6, {0x78, 0x01, 0x07});
CheckError(Image::LoadPNGFromMemory<std::uint8_t>(Bytes(std::array<std::uint8_t, 4>{1, 2, 3, 4})), "unknown image type",
"bytes that are no image are an error");
const auto noReason = Image::LoadPNGFromMemory<std::uint8_t>(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<const char*>(reasonless.data()), static_cast<std::streamsize>(reasonless.size()));
for (const std::filesystem::path& path : {file, std::filesystem::path("/nonexistent/texture.png")}) {
std::string message;
try {
(void)Image::LoadPNG<std::uint8_t>(path);
} catch (const std::runtime_error& e) {
message = e.what();
}
png.insert(png.end(), typed.begin(), typed.end());
be32(png, ~crc);
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);
};
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;
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() {
@ -396,69 +875,116 @@ void TestBrokenFiles() {
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");
CheckError(GlbAsset::Load(badMagic), "magic", "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");
// 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": 5}})"),
FixtureJson(R"(, "sparse": {"count": 1, "indices": {"bufferView": 2, "componentType": 5121}, "values": {"bufferView": 0}})"),
FixtureBin()));
Check(!sparse && sparse.error().find("sparse") != std::string::npos, "a sparse accessor is an error");
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");
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");
CheckError(GlbAsset::Load(BuildGlb(FixtureJson("", 3), FixtureBin())), "bufferView", "an accessor past its buffer view is an error");
Check(!GlbAsset::Load(std::filesystem::path("/nonexistent/file.glb")), "a missing file 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`.
// 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<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());
std::vector<std::byte> bin = FixtureBin();
bin.resize(1024);
return BuildGlb(Replace(FixtureJson(), changes), bin);
};
// 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 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");
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");
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; on a 32-bit build
// its sum with the offset wraps round to a small number.
// 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<std::byte> longChunk = glb;
const std::uint32_t huge = 0xFFFFFFF0;
const std::uint32_t huge = 0xFFFFFFF8;
std::memcpy(longChunk.data() + 12, &huge, sizeof(huge));
Check(!GlbAsset::Load(longChunk), "a chunk longer than the file is an error");
CheckError(GlbAsset::Load(longChunk), "truncated chunk", "a chunk longer than the file is an error");
// Nodes alone: a cycle, and a long chain.
// 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<const std::byte> bytes) {
std::ofstream(file, std::ios::binary).write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return GlbAsset::Load(file);
};
std::vector<std::byte> 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), {});
};
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");
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<std::byte>& glb, Micro within) {
std::expected<GlbAsset, std::string> 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<Micro>(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;
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;
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<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");
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
@ -491,8 +1017,7 @@ Mat LocalMatrix(const GlbNode& node) {
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;
if (!CheckLoads(loaded, std::format("{} loads", path))) return;
const GlbAsset& a = *loaded;
std::size_t vertices = 0;
@ -512,8 +1037,8 @@ void TestRealFile(const char* path) {
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");
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.
@ -541,6 +1066,13 @@ void TestRealFile(const char* path) {
int main() {
TestFixture();
TestRoom();
TestMorphTargets();
TestPrimitiveModes();
TestTangents();
TestSkinning();
TestReferences();
TestImages();
TestDecodeLimit();
TestBrokenFiles();
if (const char* real = std::getenv("CRAFTER_ASSET_TEST_GLB")) {
TestRealFile(real);