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

File diff suppressed because it is too large Load diff

View file

@ -8,22 +8,24 @@ import Crafter.Math;
namespace fs = std::filesystem;
// glTF 2.0 binary (.glb) loading: the node hierarchy, every mesh's
// triangle primitives with their vertex streams, skins, materials, textures
// and decoded images, cameras, and the lights of KHR_lights_punctual. The JSON
// chunk stays available as text, for the extensions this loader doesn't
// interpret (VRMC_springBone, ...), and so do the `extras` of the scene,
// nodes, materials and lights (Blender writes custom properties there);
// parsing those is the caller's business, which keeps the JSON library out of
// this interface and out of everything that imports Crafter.Asset.
// triangle primitives with their vertex streams and morph targets, skins,
// materials, textures and decoded images, cameras, and the lights of
// KHR_lights_punctual. The JSON and BIN chunks stay available, for what this
// loader doesn't interpret (animations, VRMC_springBone, ...), and so do the
// `extras` of the scene, nodes, meshes, materials and lights (Blender writes
// custom properties there); parsing those is the caller's business, which
// keeps the JSON library out of this interface and out of everything that
// imports Crafter.Asset.
//
// Errors in the file come back as the error of the std::expected rather than
// through Compression::Fatal: a GLB is user content, and a bad one shouldn't
// take the program down.
// take the program down. A file that requires an extension this loader
// doesn't implement is an error too, as glTF asks, and so is one that would
// decode to more than Load's decode limit: a few bytes of JSON can point a
// thousand primitives or images at the same data. Running out of memory
// short of the limit isn't an error in the file; it throws std::bad_alloc,
// as it would anywhere else.
export namespace Crafter {
struct GlbRGBA8 {
std::uint8_t r, g, b, a;
};
struct GlbNode {
std::string name;
std::int32_t parent = -1;
@ -41,6 +43,9 @@ export namespace Crafter {
std::int32_t camera = -1;
// A light of KHR_lights_punctual: GlbAsset::lights.
std::int32_t light = -1;
// Its mesh's morph target weights, for this node: one per target, or
// empty to keep the mesh's.
std::vector<float> weights;
// The node's extras as JSON text, or empty.
std::string extras;
};
@ -84,27 +89,53 @@ export namespace Crafter {
std::string extras;
};
// What a morph target adds to each vertex of its primitive, times the
// target's weight. Streams the target doesn't move stay empty.
struct GlbMorphTarget {
std::vector<Vector<float, 3, 3>> positions;
std::vector<Vector<float, 3, 3>> normals;
// Added to the tangents' xyz; the handedness in w stays.
std::vector<Vector<float, 3, 3>> tangents;
};
// One triangle list. Streams the primitive doesn't have stay empty;
// otherwise each has one entry per vertex.
// otherwise each has one entry per vertex. Strips and fans come back as
// lists. Points and lines aren't read: their primitives come back with no
// vertices, so the rest keep the file's numbering.
struct GlbPrimitive {
std::vector<Vector<float, 3, 3>> positions;
std::vector<Vector<float, 3, 3>> normals;
// xyz plus the bitangent's handedness in w. Computed from the UVs when
// the file has none but has normals and TEXCOORD_0.
// xyz plus the bitangent's handedness in w. Where the file has none
// and the primitive has normals, computed from the UV set its
// material's normal texture samples (TEXCOORD_0 without one), as glTF
// asks.
std::vector<Vector<float, 4, 4>> tangents;
std::vector<Vector<float, 2, 2>> uv0;
// TEXCOORD_1: a second UV set, for the textures whose GlbTextureRef
// says texCoord 1.
std::vector<Vector<float, 2, 2>> uv1;
// Up to four joints a vertex, their weights summing to 1. Where the
// file gives more (JOINTS_1 and WEIGHTS_1, ...), the four heaviest,
// a joint named twice counted once; where it gives a vertex no weight
// at all, all of it on the vertex's first joint, as three.js does. A
// slot left over holds joint 0 with weight 0.
std::vector<std::array<std::uint16_t, 4>> joints;
// Normalized to sum to 1, which the file is meant to do and often
// only nearly does.
std::vector<std::array<float, 4>> weights;
// 0..n-1 when the primitive isn't indexed.
std::vector<std::uint32_t> indices;
std::vector<GlbMorphTarget> targets;
std::int32_t material = -1;
};
struct GlbMesh {
std::string name;
std::vector<GlbPrimitive> primitives;
// The weights of the morph targets its primitives share, one per
// target; 0 where the file gives none. A node can override them.
std::vector<float> weights;
// The mesh's extras as JSON text, or empty. Blender and three.js
// write the morph targets' names there, as targetNames.
std::string extras;
};
struct GlbSkin {
@ -124,6 +155,8 @@ export namespace Crafter {
struct GlbTextureRef {
std::int32_t texture = -1;
// The UV set the texture is sampled with: 0 for uv0, 1 for uv1. Every
// primitive whose material samples a set has it.
std::uint32_t texCoord = 0;
// normalTexture's scale or occlusionTexture's strength.
float scale = 1.0f;
@ -155,7 +188,9 @@ export namespace Crafter {
std::string extras;
};
// The OpenGL filter and wrap enums, as in the file; 0 when unspecified.
// The OpenGL filter and wrap enums, as in the file. The filters are 0
// when the file leaves them to the renderer; the wrap modes default to
// REPEAT, 10497, as glTF's do.
struct GlbSampler {
std::uint32_t magFilter = 0;
std::uint32_t minFilter = 0;
@ -177,15 +212,25 @@ export namespace Crafter {
std::vector<GlbMaterial> materials;
std::vector<GlbTexture> textures;
std::vector<GlbSampler> samplers;
std::vector<TextureAsset<GlbRGBA8>> images;
// One per image in the file, in RGBA8. An image in a format other
// than PNG and JPEG (the WebP or KTX2 of EXT_texture_webp or
// KHR_texture_basisu) isn't decoded and stays empty, sizeX 0; no
// texture's image is one.
std::vector<TextureAsset<Vector<std::uint8_t, 4, 4>>> images;
std::vector<GlbCamera> cameras;
std::vector<GlbLight> lights;
// The default scene's extras as JSON text, or empty.
std::string extras;
// The JSON chunk, verbatim.
std::string json;
// The BIN chunk, verbatim: the data of what the JSON describes and
// this loader doesn't read (animations, ...).
std::vector<std::byte> bin;
static std::expected<GlbAsset, std::string> Load(const fs::path& path);
static std::expected<GlbAsset, std::string> Load(std::span<const std::byte> glb);
// What Load decodes (vertices, indices and pixels) is held to
// `decodeLimit` bytes; without one, to 1 GiB plus 64 bytes for each
// byte of the file.
static std::expected<GlbAsset, std::string> Load(const fs::path& path, std::optional<std::uint64_t> decodeLimit = std::nullopt);
static std::expected<GlbAsset, std::string> Load(std::span<const std::byte> glb, std::optional<std::uint64_t> decodeLimit = std::nullopt);
};
}

View file

@ -6,6 +6,12 @@ module;
#include "../lib/stb_image.h"
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include "../lib/stb_image_resize2.h"
// stb keeps the reason for its last failure until the next one, and a few
// of its failures give none: cleared before each decode, the reason after a
// failure is that failure's or null, never an older one's.
void CrafterAssetClearStbiFailure() {
stbi__g_failure_reason = nullptr;
}
export module Crafter.Asset:Texture;
import :Compression;
import std;
@ -143,51 +149,110 @@ export namespace Crafter {
file.read(reinterpret_cast<char*>(pixels), sizeX * sizeY * sizeof(T));
}
// Decodes anything stb_image reads (PNG, JPEG, ...) to 8-bit RGBA, or
// to normalized floats when TT is a floating-point component type.
// Decodes anything stb_image reads (PNG, JPEG, TGA, BMP, ...) to 8-bit
// RGBA, or to normalized floats when TT is a floating-point component
// type. A file that doesn't decode throws std::runtime_error, which
// callers such as crafter-build catch to report the file; built
// without exceptions (wasm), there is nothing to catch, and it is
// Fatal.
template <typename TT>
static TextureAsset<T> LoadPNG(fs::path path) {
std::filesystem::path abs = std::filesystem::absolute(path);
int sizeX;
int sizeY;
unsigned char* data = stbi_load(abs.string().c_str(), &sizeX, &sizeY, nullptr, 4);
if (data == nullptr) {
Compression::Fatal(std::format("LoadPNG: {}: {}", abs.string(), stbi_failure_reason()));
// Read whole and decoded from memory, so that the size checked
// before decoding is the size of the bytes that are decoded.
std::error_code error;
if (!fs::is_regular_file(abs, error)) {
Fail(std::format("LoadPNG: {}: {}", abs.string(), error ? error.message() : "not a file"));
}
if (!FitsSize(sizeX, sizeY)) {
stbi_image_free(data);
Compression::Fatal(std::format("LoadPNG: {}: {}x{} is more than a texture's 65535 a side", abs.string(), sizeX, sizeY));
const std::uintmax_t size = fs::file_size(abs, error);
if (error) Fail(std::format("LoadPNG: {}: {}", abs.string(), error.message()));
if (size > kMaxEncoded) Fail(std::format("LoadPNG: {}: {} bytes is more than stb_image reads", abs.string(), size));
std::vector<std::byte> encoded(static_cast<std::size_t>(size));
std::ifstream file(abs, std::ios::binary);
if (!file.read(reinterpret_cast<char*>(encoded.data()), static_cast<std::streamsize>(encoded.size()))) {
Fail(std::format("LoadPNG: {}: can't read it", abs.string()));
}
return FromRGBA8<TT>(data, sizeX, sizeY);
std::expected<TextureAsset<T>, std::string> tex = LoadPNGFromMemory<TT>(encoded);
if (!tex) Fail(std::format("LoadPNG: {}: {}", abs.string(), tex.error()));
return std::move(*tex);
}
// LoadPNG from an image that is already in memory, e.g. one embedded
// in a GLB. An image that doesn't decode comes back as an error value
// rather than a Fatal: the caller is reading a larger asset and can
// say which part was bad.
// rather than an exception or a Fatal: the caller is reading a larger
// asset and can say which part was bad. One too large for a texture
// is turned down from its header, before anything is decoded.
template <typename TT>
static std::expected<TextureAsset<T>, std::string> LoadPNGFromMemory(std::span<const std::byte> encoded) {
const auto size = SizeFromMemory(encoded);
if (!size) return std::unexpected(size.error());
int sizeX;
int sizeY;
CrafterAssetClearStbiFailure();
unsigned char* data = stbi_load_from_memory(reinterpret_cast<const stbi_uc*>(encoded.data()),
static_cast<int>(encoded.size()), &sizeX, &sizeY, nullptr, 4);
if (data == nullptr) {
return std::unexpected(std::string(stbi_failure_reason()));
return std::unexpected(FailureReason());
}
if (!FitsSize(sizeX, sizeY)) {
stbi_image_free(data);
return std::unexpected(std::format("{}x{} is more than a texture's 65535 a side", sizeX, sizeY));
return std::unexpected(std::format("{}x{} decoded from a header of {}x{}", sizeX, sizeY, size->first, size->second));
}
return FromRGBA8<TT>(data, sizeX, sizeY);
}
// The width and height LoadPNGFromMemory would decode an image to,
// read from its header alone, or why it wouldn't: an image it can't
// read, or one too large for a texture.
static std::expected<std::pair<std::uint32_t, std::uint32_t>, std::string> SizeFromMemory(std::span<const std::byte> encoded) {
if (encoded.size() > kMaxEncoded) {
return std::unexpected(std::format("{} bytes is more than stb_image reads", encoded.size()));
}
int sizeX;
int sizeY;
int components;
CrafterAssetClearStbiFailure();
if (!stbi_info_from_memory(reinterpret_cast<const stbi_uc*>(encoded.data()), static_cast<int>(encoded.size()),
&sizeX, &sizeY, &components)) {
return std::unexpected(FailureReason());
}
if (!FitsSize(sizeX, sizeY)) {
return std::unexpected(std::format("{}x{} is more than a texture takes: 65535 a side, {} pixels in all", sizeX, sizeY, kMaxPixels));
}
return std::pair<std::uint32_t, std::uint32_t>(sizeX, sizeY);
}
private:
// sizeX and sizeY hold 16 bits.
static bool FitsSize(int sizeX, int sizeY) {
return sizeX <= std::numeric_limits<std::uint16_t>::max() && sizeY <= std::numeric_limits<std::uint16_t>::max();
// stb_image takes an encoded image's length as an int.
static constexpr std::uintmax_t kMaxEncoded = std::numeric_limits<int>::max();
// sizeX and sizeY hold 16 bits, and stb_image's arithmetic 32: its
// 16-bit PNG decoding sizes a buffer of 8 bytes a pixel in an
// unsigned int, which wraps round from 2^29 pixels. 2^28 is
// 16384x16384, 1 GiB of RGBA8.
static constexpr std::uint64_t kMaxPixels = std::uint64_t{1} << 28;
static bool FitsSize(std::uint64_t sizeX, std::uint64_t sizeY) {
return sizeX <= std::numeric_limits<std::uint16_t>::max() && sizeY <= std::numeric_limits<std::uint16_t>::max()
&& sizeX * sizeY <= kMaxPixels;
}
// stb_image's reason for the failure just now. CrafterAssetClearStbiFailure
// before the call makes it this failure's, but some failures give
// none.
static std::string FailureReason() {
const char* reason = stbi_failure_reason();
return reason != nullptr ? reason : "corrupt image";
}
[[noreturn]] static void Fail(const std::string& message) {
#if __cpp_exceptions
throw std::runtime_error(message);
#else
Compression::Fatal(message);
#endif
}
// Converts stb's RGBA8 buffer, classifies its opacity and frees it.

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));
};
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)));
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);
}
png.insert(png.end(), typed.begin(), typed.end());
be32(png, ~crc);
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);
};
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;
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();
}
Check(message.find(path.filename().string()) != std::string::npos,
std::format("LoadPNG throws std::runtime_error for {} ({})", path.filename().string(), message));
}
std::filesystem::remove(file);
#endif
}
void TestDecodeLimit() {
CheckError(GlbAsset::Load(BuildGlb(FixtureJson(), FixtureBin()), 0), "past the limit of 0 bytes", "the fixture doesn't fit a limit of 0 bytes");
// Three primitives drawing the one accessor, each 48 bytes decoded: 36
// of positions, 12 of indices.
auto shared = [](std::uint64_t limit) {
return LoadJson(R"({"asset": {"version": "2.0"},
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}}, {"attributes": {"POSITION": 0}}, {"attributes": {"POSITION": 0}}]}],
"accessors": [{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}],
"bufferViews": [{"buffer": 0, "byteLength": 36}], "buffers": [{"byteLength": 48}]})",
QuadBin(), limit);
};
Check(shared(144).has_value(), "three primitives sharing an accessor fit a limit of 3 x 48 bytes");
CheckError(shared(100), "mesh 0 primitive 2 decodes past the limit", "the third doesn't fit a limit of 100 bytes");
}
void TestBrokenFiles() {
@ -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");
constexpr std::size_t kChain = 100000;
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 < kChain; ++i) chain += std::format(R"({{"children": [{}]}},)", i + 1);
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();
const auto deep = GlbAsset::Load(nodesOnly(chain));
const auto took = std::chrono::steady_clock::now() - started;
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;
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);