feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
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>
2026-10-31 03:25:12 +01:00
// GlbAsset::Load on GLBs built here in memory. A fixture: the node hierarchy
// (TRS and matrix nodes), two primitives with two materials, an interleaved
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
// position/normal buffer view (strided accessors), normalized UV and weight
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>
2026-10-31 03:25:12 +01:00
// 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.
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
//
// With CRAFTER_ASSET_TEST_GLB pointing at a real skinned .glb (a rig exported
// from Blender, say), it also loads that and checks every joint's world
// matrix times its inverse-bind matrix is the identity in the rest pose,
// which is what a skinned mesh at rest relies on.
# include <cstdlib>
import Crafter . Asset ;
import Crafter . Math ;
import std ;
using namespace Crafter ;
namespace {
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>
2026-10-31 03:25:12 +01:00
using Pixel = Vector < std : : uint8_t , 4 , 4 > ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
int failures = 0 ;
void Check ( bool ok , std : : string_view what ) {
std : : println ( " {} {} " , ok ? " PASS " : " FAIL " , what ) ;
if ( ! ok ) + + failures ;
}
bool Near ( float a , float b , float epsilon = 1e-4 f ) {
return std : : abs ( a - b ) < epsilon ;
}
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>
2026-10-31 03:25:12 +01:00
// 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 ( ) ;
}
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
// A 2x2 RGBA PNG: red, green, blue, and white with alpha 0.
constexpr std : : array < std : : uint8_t , 76 > kPng = {
0x89 , 0x50 , 0x4e , 0x47 , 0x0d , 0x0a , 0x1a , 0x0a , 0x00 , 0x00 , 0x00 , 0x0d , 0x49 , 0x48 , 0x44 , 0x52 ,
0x00 , 0x00 , 0x00 , 0x02 , 0x00 , 0x00 , 0x00 , 0x02 , 0x08 , 0x06 , 0x00 , 0x00 , 0x00 , 0x72 , 0xb6 , 0x0d ,
0x24 , 0x00 , 0x00 , 0x00 , 0x13 , 0x49 , 0x44 , 0x41 , 0x54 , 0x78 , 0xda , 0x63 , 0xf8 , 0xcf , 0xc0 , 0xf0 ,
0x1f , 0x0c , 0x81 , 0x34 , 0x08 , 0x30 , 0x00 , 0x00 , 0x48 , 0xc9 , 0x08 , 0xf8 , 0xc5 , 0x34 , 0xfd , 0x05 ,
0x00 , 0x00 , 0x00 , 0x00 , 0x49 , 0x45 , 0x4e , 0x44 , 0xae , 0x42 , 0x60 , 0x82 ,
} ;
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>
2026-10-31 03:25:12 +01:00
// 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 ,
} ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
struct Writer {
std : : vector < std : : byte > bytes ;
template < typename T >
void Put ( T value ) {
const auto * p = reinterpret_cast < const std : : byte * > ( & value ) ;
bytes . insert ( bytes . end ( ) , p , p + sizeof ( T ) ) ;
}
void Pad ( std : : size_t alignment , std : : byte fill ) {
while ( bytes . size ( ) % alignment ! = 0 ) bytes . push_back ( fill ) ;
}
} ;
// header + JSON chunk (space-padded) + BIN chunk (zero-padded), as the GLB
// container lays them out.
std : : vector < std : : byte > BuildGlb ( std : : string_view json , const std : : vector < std : : byte > & bin ) {
Writer jsonChunk ;
for ( char c : json ) jsonChunk . Put ( c ) ;
jsonChunk . Pad ( 4 , std : : byte { ' ' } ) ;
std : : vector < std : : byte > binPadded = bin ;
while ( binPadded . size ( ) % 4 ! = 0 ) binPadded . push_back ( std : : byte { 0 } ) ;
Writer out ;
out . Put < std : : uint32_t > ( 0x46546C67 ) ; // "glTF"
out . Put < std : : uint32_t > ( 2 ) ;
out . Put < std : : uint32_t > ( static_cast < std : : uint32_t > ( 12 + 8 + jsonChunk . bytes . size ( ) + 8 + binPadded . size ( ) ) ) ;
out . Put < std : : uint32_t > ( static_cast < std : : uint32_t > ( jsonChunk . bytes . size ( ) ) ) ;
out . Put < std : : uint32_t > ( 0x4E4F534A ) ; // "JSON"
out . bytes . insert ( out . bytes . end ( ) , jsonChunk . bytes . begin ( ) , jsonChunk . bytes . end ( ) ) ;
out . Put < std : : uint32_t > ( static_cast < std : : uint32_t > ( binPadded . size ( ) ) ) ;
out . Put < std : : uint32_t > ( 0x004E4942 ) ; // "BIN\0"
out . bytes . insert ( out . bytes . end ( ) , binPadded . begin ( ) , binPadded . end ( ) ) ;
return out . bytes ;
}
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>
2026-10-31 03:25:12 +01:00
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 ;
}
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
std : : vector < std : : byte > FixtureBin ( ) {
Writer w ;
// bufferView 0, offset 0: interleaved position + normal, stride 24.
const std : : array < std : : array < float , 3 > , 3 > positions = { { { 0 , 0 , 0 } , { 1 , 0 , 0 } , { 0 , 1 , 0 } } } ;
for ( const auto & p : positions ) {
for ( float f : p ) w . Put ( f ) ;
for ( float f : { 0.0f , 0.0f , 1.0f } ) w . Put ( f ) ;
}
// bufferView 1, offset 72: TEXCOORD_0 as normalized u16.
for ( std : : uint16_t u : { 0 , 0 , 65535 , 0 , 0 , 65535 } ) w . Put ( u ) ;
// bufferView 2, offset 84: u8 indices.
for ( std : : uint8_t i : { 0 , 1 , 2 } ) w . Put ( i ) ;
w . Pad ( 4 , std : : byte { 0 } ) ;
// bufferView 3, offset 88: JOINTS_0 as u8.
for ( int v = 0 ; v < 3 ; + + v ) for ( std : : uint8_t j : { 0 , 1 , 0 , 0 } ) w . Put ( j ) ;
// bufferView 4, offset 100: WEIGHTS_0 as normalized u8 that don't sum to 255.
for ( int v = 0 ; v < 3 ; + + v ) for ( std : : uint8_t j : { 128 , 64 , 0 , 0 } ) w . Put ( j ) ;
// bufferView 5, offset 112: the second primitive's positions.
for ( float f : { 0.0f , 0.0f , 1.0f , 1.0f , 0.0f , 1.0f , 0.0f , 1.0f , 1.0f } ) w . Put ( f ) ;
// bufferView 6, offset 148: two inverse-bind matrices, column-major.
for ( float f : { 1.0f , 0.0f , 0.0f , 0.0f , 0.0f , 1.0f , 0.0f , 0.0f , 0.0f , 0.0f , 1.0f , 0.0f , 0.0f , 0.0f , 0.0f , 1.0f } ) w . Put ( f ) ;
for ( float f : { 1.0f , 0.0f , 0.0f , 0.0f , 0.0f , 1.0f , 0.0f , 0.0f , 0.0f , 0.0f , 1.0f , 0.0f , - 1.0f , 0.0f , 0.0f , 1.0f } ) w . Put ( f ) ;
// bufferView 7, offset 276: the PNG.
for ( std : : uint8_t b : kPng ) w . Put ( b ) ;
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>
2026-10-31 03:25:12 +01:00
// 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 ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
return w . bytes ;
}
std : : string FixtureJson ( std : : string_view accessor6Extra = " " , std : : uint32_t accessor7Count = 2 ) {
return std : : format ( R " ({{
" asset " : { { " version " : " 2.0 " } } ,
" scene " : 0 ,
" scenes " : [ { { " nodes " : [ 0 ] } } ] ,
" nodes " : [
{ { " name " : " Root " , " children " : [ 1 ] , " mesh " : 0 , " skin " : 0 ,
" translation " : [ 1 , 2 , 3 ] , " rotation " : [ 0 , 0 , 0.70710678 , 0.70710678 ] , " scale " : [ 2 , 2 , 2 ] } } ,
{ { " name " : " Child " , " matrix " : [ 1 , 0 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 , 1 , 0 , 5 , 6 , 7 , 1 ] } }
] ,
" meshes " : [ { { " name " : " Mesh " , " primitives " : [
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>
2026-10-31 03:25:12 +01:00
{ { " 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 } }
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
] } } ] ,
" skins " : [ { { " name " : " Skin " , " joints " : [ 0 , 1 ] , " inverseBindMatrices " : 7 } } ] ,
" materials " : [
{ { " name " : " Mat0 " , " pbrMetallicRoughness " : { { " baseColorFactor " : [ 0.5 , 0.25 , 1 , 1 ] , " metallicFactor " : 0.1 ,
" roughnessFactor " : 0.7 , " baseColorTexture " : { { " index " : 0 } } } } ,
" normalTexture " : { { " index " : 0 , " scale " : 0.5 } } , " alphaMode " : " MASK " , " alphaCutoff " : 0.3 , " doubleSided " : true } } ,
{ { " name " : " Mat1 " } }
] ,
" textures " : [ { { " source " : 0 , " sampler " : 0 } } ] ,
" samplers " : [ { { " magFilter " : 9728 , " minFilter " : 9729 } } ] ,
" images " : [ { { " bufferView " : 7 , " mimeType " : " image/png " } } ] ,
" accessors " : [
{ { " bufferView " : 0 , " byteOffset " : 0 , " componentType " : 5126 , " count " : 3 , " type " : " VEC3 " } } ,
{ { " bufferView " : 0 , " byteOffset " : 12 , " componentType " : 5126 , " count " : 3 , " type " : " VEC3 " } } ,
{ { " bufferView " : 1 , " componentType " : 5123 , " normalized " : true , " count " : 3 , " type " : " VEC2 " } } ,
{ { " bufferView " : 2 , " componentType " : 5121 , " count " : 3 , " type " : " SCALAR " } } ,
{ { " bufferView " : 3 , " componentType " : 5121 , " count " : 3 , " type " : " VEC4 " } } ,
{ { " bufferView " : 4 , " componentType " : 5121 , " normalized " : true , " count " : 3 , " type " : " VEC4 " } } ,
{ { " bufferView " : 5 , " componentType " : 5126 , " count " : 3 , " type " : " VEC3 " { } } } ,
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>
2026-10-31 03:25:12 +01:00
{ { " bufferView " : 6 , " componentType " : 5126 , " count " : { } , " type " : " MAT4 " } } ,
{ { " bufferView " : 8 , " componentType " : 5126 , " count " : 3 , " type " : " VEC2 " } }
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
] ,
" bufferViews " : [
{ { " buffer " : 0 , " byteOffset " : 0 , " byteLength " : 72 , " byteStride " : 24 } } ,
{ { " buffer " : 0 , " byteOffset " : 72 , " byteLength " : 12 } } ,
{ { " buffer " : 0 , " byteOffset " : 84 , " byteLength " : 3 } } ,
{ { " buffer " : 0 , " byteOffset " : 88 , " byteLength " : 12 } } ,
{ { " buffer " : 0 , " byteOffset " : 100 , " byteLength " : 12 } } ,
{ { " buffer " : 0 , " byteOffset " : 112 , " byteLength " : 36 } } ,
{ { " buffer " : 0 , " byteOffset " : 148 , " byteLength " : 128 } } ,
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>
2026-10-31 03:25:12 +01:00
{ { " buffer " : 0 , " byteOffset " : 276 , " byteLength " : 76 } } ,
{ { " buffer " : 0 , " byteOffset " : 352 , " byteLength " : 24 } }
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
] ,
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>
2026-10-31 03:25:12 +01:00
" buffers " : [ { { " byteLength " : 376 } } ] ,
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
" extensionsUsed " : [ " VRMC_springBone " ] ,
" extensions " : { { " VRMC_springBone " : { { " specVersion " : " 1.0 " } } } }
} } ) " , accessor6Extra, accessor7Count);
}
bool NearMatrix ( const MatrixRowMajor < float , 4 , 4 , 1 > & m , std : : array < float , 16 > expected ) {
const std : : array < float , 16 > got = m . Store ( ) ;
for ( std : : size_t i = 0 ; i < 16 ; + + i ) {
if ( ! Near ( got [ i ] , expected [ i ] ) ) return false ;
}
return true ;
}
void TestFixture ( ) {
const std : : vector < std : : byte > glb = BuildGlb ( FixtureJson ( ) , FixtureBin ( ) ) ;
const std : : expected < GlbAsset , std : : string > loaded = GlbAsset : : Load ( glb ) ;
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>
2026-10-31 03:25:12 +01:00
if ( ! CheckLoads ( loaded , " the fixture loads " ) ) return ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
const GlbAsset & a = * loaded ;
// Nodes and scene.
Check ( a . nodes . size ( ) = = 2 & & a . nodes [ 0 ] . name = = " Root " & & a . nodes [ 1 ] . name = = " Child " , " two named nodes " ) ;
Check ( a . nodes [ 0 ] . parent = = - 1 & & a . nodes [ 1 ] . parent = = 0 & & a . nodes [ 0 ] . children = = std : : vector < std : : uint32_t > { 1 } ,
" parent and children links " ) ;
Check ( a . sceneRoots = = std : : vector < std : : uint32_t > { 0 } , " the default scene's roots " ) ;
const GlbNode & root = a . nodes [ 0 ] ;
Check ( ! root . hasMatrix & & Near ( root . translation . x , 1 ) & & Near ( root . translation . y , 2 ) & & Near ( root . translation . z , 3 )
& & Near ( root . rotation . z , 0.70710678f ) & & Near ( root . rotation . w , 0.70710678f ) & & Near ( root . scale . y , 2 ) ,
" TRS node " ) ;
Check ( root . mesh = = 0 & & root . skin = = 0 & & a . nodes [ 1 ] . mesh = = - 1 , " mesh and skin references " ) ;
Check ( a . nodes [ 1 ] . hasMatrix & & NearMatrix ( a . nodes [ 1 ] . matrix , { 1 , 0 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 , 1 , 0 , 5 , 6 , 7 , 1 } ) ,
" matrix node, translation in row 3 " ) ;
// Primitives.
Check ( a . meshes . size ( ) = = 1 & & a . meshes [ 0 ] . primitives . size ( ) = = 2 , " one mesh with two primitives " ) ;
if ( a . meshes . size ( ) ! = 1 | | a . meshes [ 0 ] . primitives . size ( ) ! = 2 ) return ;
const GlbPrimitive & p0 = a . meshes [ 0 ] . primitives [ 0 ] ;
const GlbPrimitive & p1 = a . meshes [ 0 ] . primitives [ 1 ] ;
Check ( p0 . positions . size ( ) = = 3 & & Near ( p0 . positions [ 1 ] . x , 1 ) & & Near ( p0 . positions [ 2 ] . y , 1 ) ,
" positions read through a strided view " ) ;
Check ( p0 . normals . size ( ) = = 3 & & Near ( p0 . normals [ 2 ] . z , 1 ) & & Near ( p0 . normals [ 0 ] . x , 0 ) , " normals read at their byte offset " ) ;
Check ( p0 . uv0 . size ( ) = = 3 & & Near ( p0 . uv0 [ 1 ] . x , 1 ) & & Near ( p0 . uv0 [ 2 ] . y , 1 ) & & Near ( p0 . uv0 [ 1 ] . y , 0 ) ,
" normalized u16 UVs " ) ;
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
Check ( p0 . indices = = std : : vector < std : : uint32_t > { 0 , 1 , 2 } , " u8 indices widened " ) ;
Check ( p0 . joints . size ( ) = = 3 & & p0 . joints [ 0 ] = = std : : array < std : : uint16_t , 4 > { 0 , 1 , 0 , 0 } , " u8 joints " ) ;
Check ( p0 . weights . size ( ) = = 3 & & Near ( p0 . weights [ 0 ] [ 0 ] , 2.0f / 3.0f ) & & Near ( p0 . weights [ 0 ] [ 1 ] , 1.0f / 3.0f ) ,
" normalized u8 weights, renormalized to sum 1 " ) ;
// glTF's v runs down the image, so the bitangent (normal map +Y, "up")
// points along -v: here -Y, which is cross(normal, tangent) * -1.
Check ( p0 . tangents . size ( ) = = 3 & & Near ( p0 . tangents [ 0 ] . x , 1 ) & & Near ( p0 . tangents [ 0 ] . y , 0 )
& & Near ( p0 . tangents [ 0 ] . z , 0 ) & & Near ( p0 . tangents [ 0 ] . w , - 1 ) ,
" tangents computed from the UVs, with glTF's bitangent sign " ) ;
Check ( p0 . material = = 0 & & p1 . material = = 1 , " primitive materials " ) ;
Check ( p1 . positions . size ( ) = = 3 & & Near ( p1 . positions [ 2 ] . z , 1 ) & & p1 . indices = = std : : vector < std : : uint32_t > { 0 , 1 , 2 } ,
" a primitive without indices gets 0..n-1 " ) ;
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
// Skin.
Check ( a . skins . size ( ) = = 1 & & a . skins [ 0 ] . joints = = std : : vector < std : : uint32_t > { 0 , 1 }
& & a . skins [ 0 ] . inverseBindMatrices . size ( ) = = 2
& & NearMatrix ( a . skins [ 0 ] . inverseBindMatrices [ 1 ] , { 1 , 0 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 , 1 , 0 , - 1 , 0 , 0 , 1 } ) ,
" skin joints and inverse-bind matrices " ) ;
// Materials, textures, images.
Check ( a . materials . size ( ) = = 2 , " two materials " ) ;
if ( a . materials . size ( ) = = 2 ) {
const GlbMaterial & m0 = a . materials [ 0 ] ;
Check ( Near ( m0 . baseColorFactor [ 1 ] , 0.25f ) & & Near ( m0 . metallicFactor , 0.1f ) & & Near ( m0 . roughnessFactor , 0.7f )
& & m0 . baseColorTexture . texture = = 0 & & m0 . normalTexture . texture = = 0 & & Near ( m0 . normalTexture . scale , 0.5f )
& & m0 . alphaMode = = GlbAlphaMode : : Mask & & Near ( m0 . alphaCutoff , 0.3f ) & & m0 . doubleSided ,
" material factors, textures and alpha mode " ) ;
const GlbMaterial & m1 = a . materials [ 1 ] ;
Check ( Near ( m1 . metallicFactor , 1 ) & & Near ( m1 . roughnessFactor , 1 ) & & m1 . baseColorTexture . texture = = - 1
& & m1 . alphaMode = = GlbAlphaMode : : Opaque & & ! m1 . doubleSided ,
" material defaults " ) ;
}
Check ( a . textures . size ( ) = = 1 & & a . textures [ 0 ] . image = = 0 & & a . textures [ 0 ] . sampler = = 0 , " texture source and sampler " ) ;
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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 ) {
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>
2026-10-31 03:25:12 +01:00
const Pixel first = a . images [ 0 ] . pixels [ 0 ] ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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 " ) ;
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>
2026-10-31 03:25:12 +01:00
Check ( a . bin = = FixtureBin ( ) , " the BIN chunk is kept, for the data the loader doesn't read " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
}
// What a room exported from Blender carries beyond meshes: a camera,
// KHR_lights_punctual lights, glowing and glass materials
// (KHR_materials_emissive_strength, _transmission, _ior), and the custom
// properties Blender writes as `extras` on the scene, nodes, materials and
// lights. The image is the fixture's PNG, at the same place in the buffer.
std : : string RoomJson ( std : : string_view candleLight = " 0 " ) {
return std : : format ( R " ({{
" asset " : { { " version " : " 2.0 " } } ,
" scene " : 0 ,
" scenes " : [ { { " nodes " : [ 0 , 1 , 2 , 3 , 4 ] , " extras " : { { " exposure " : 1.5 } } } } ] ,
" nodes " : [
{ { " name " : " Camera " , " camera " : 0 , " translation " : [ 0 , 1.2 , 1.5 ] } } ,
{ { " name " : " Candle " , " extensions " : { { " KHR_lights_punctual " : { { " light " : { } } } } } , " extras " : { { " flicker " : 0.3 } } } } ,
{ { " name " : " Moon " , " extensions " : { { " KHR_lights_punctual " : { { " light " : 1 } } } } } } ,
{ { " name " : " Sun " , " extensions " : { { " KHR_lights_punctual " : { { " light " : 2 } } } } } } ,
{ { " name " : " Seat " , " extras " : { { " seat " : true , " height " : 1.6 } } } }
] ,
" cameras " : [ { { " name " : " Webcam " , " type " : " perspective " ,
" perspective " : { { " yfov " : 0.75 , " aspectRatio " : 1.7777778 , " znear " : 0.05 , " zfar " : 100 } } } } ] ,
" extensionsUsed " : [ " KHR_lights_punctual " , " KHR_materials_emissive_strength " , " KHR_materials_transmission " , " KHR_materials_ior " ] ,
" extensions " : { { " KHR_lights_punctual " : { { " lights " : [
{ { " name " : " CandleLight " , " type " : " point " , " color " : [ 1 , 0.6 , 0.3 ] , " intensity " : 5 , " range " : 4 , " extras " : { { " shadows " : false } } } } ,
{ { " name " : " MoonLight " , " type " : " spot " , " intensity " : 20 , " spot " : { { " innerConeAngle " : 0.2 , " outerConeAngle " : 0.5 } } } } ,
{ { " name " : " SunLight " , " type " : " directional " , " color " : [ 0.5 , 0.6 , 1 ] , " intensity " : 0.8 } }
] } } } } ,
" materials " : [
{ { " name " : " Flame " , " emissiveFactor " : [ 1 , 0.5 , 0.1 ] ,
" extensions " : { { " KHR_materials_emissive_strength " : { { " emissiveStrength " : 8 } } } } , " extras " : { { " effect " : " flicker " } } } } ,
{ { " name " : " StainedGlass " , " pbrMetallicRoughness " : { { " baseColorTexture " : { { " index " : 0 } } } } ,
" extensions " : { { " KHR_materials_transmission " : { { " transmissionFactor " : 0.9 , " transmissionTexture " : { { " index " : 0 } } } } ,
" KHR_materials_ior " : { { " ior " : 1.45 } } } } } } ,
{ { " name " : " Plain " } }
] ,
" textures " : [ { { " source " : 0 } } ] ,
" images " : [ { { " bufferView " : 0 , " mimeType " : " image/png " } } ] ,
" bufferViews " : [ { { " buffer " : 0 , " byteOffset " : 276 , " byteLength " : 76 } } ] ,
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>
2026-10-31 03:25:12 +01:00
" buffers " : [ { { " byteLength " : 376 } } ]
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
} } ) " , candleLight);
}
void TestRoom ( ) {
const std : : expected < GlbAsset , std : : string > loaded = GlbAsset : : Load ( BuildGlb ( RoomJson ( ) , FixtureBin ( ) ) ) ;
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>
2026-10-31 03:25:12 +01:00
if ( ! CheckLoads ( loaded , " the room fixture loads " ) ) return ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
const GlbAsset & a = * loaded ;
Check ( a . cameras . size ( ) = = 1 & & a . nodes [ 0 ] . camera = = 0 & & a . nodes [ 1 ] . camera = = - 1 , " a node's camera " ) ;
if ( a . cameras . size ( ) = = 1 ) {
const GlbCamera & c = a . cameras [ 0 ] ;
Check ( c . name = = " Webcam " & & ! c . orthographic & & Near ( c . yfov , 0.75f ) & & Near ( c . aspectRatio , 1.7777778f )
& & Near ( c . znear , 0.05f ) & & Near ( c . zfar , 100 ) ,
" a perspective camera's field of view, aspect and clip planes " ) ;
}
Check ( a . lights . size ( ) = = 3 & & a . nodes [ 1 ] . light = = 0 & & a . nodes [ 2 ] . light = = 1 & & a . nodes [ 3 ] . light = = 2 & & a . nodes [ 0 ] . light = = - 1 ,
" nodes' KHR_lights_punctual lights " ) ;
if ( a . lights . size ( ) = = 3 ) {
const GlbLight & point = a . lights [ 0 ] ;
Check ( point . name = = " CandleLight " & & point . type = = GlbLightType : : Point & & Near ( point . color [ 1 ] , 0.6f )
& & Near ( point . intensity , 5 ) & & Near ( point . range , 4 ) ,
" a point light's colour, intensity and range " ) ;
const GlbLight & spot = a . lights [ 1 ] ;
Check ( spot . type = = GlbLightType : : Spot & & Near ( spot . innerConeAngle , 0.2f ) & & Near ( spot . outerConeAngle , 0.5f )
& & Near ( spot . color [ 0 ] , 1 ) & & Near ( spot . range , 0 ) ,
" a spot light's cone, with white and no range by default " ) ;
const GlbLight & sun = a . lights [ 2 ] ;
Check ( sun . type = = GlbLightType : : Directional & & Near ( sun . intensity , 0.8f ) & & Near ( sun . outerConeAngle , std : : numbers : : pi_v < float > / 4 ) ,
" a directional light, the cone left at the extension's default " ) ;
Check ( point . extras = = R " ({ " shadows " :false}) " & & spot . extras . empty ( ) , " a light's extras, as JSON text " ) ;
}
Check ( a . materials . size ( ) = = 3 , " three materials " ) ;
if ( a . materials . size ( ) = = 3 ) {
const GlbMaterial & flame = a . materials [ 0 ] ;
Check ( Near ( flame . emissiveStrength , 8 ) & & Near ( flame . emissiveFactor [ 1 ] , 0.5f ) & & Near ( flame . transmissionFactor , 0 ) ,
" KHR_materials_emissive_strength " ) ;
const GlbMaterial & glass = a . materials [ 1 ] ;
Check ( Near ( glass . transmissionFactor , 0.9f ) & & glass . transmissionTexture . texture = = 0 & & Near ( glass . ior , 1.45f ) ,
" KHR_materials_transmission with its texture, and KHR_materials_ior " ) ;
const GlbMaterial & plain = a . materials [ 2 ] ;
Check ( Near ( plain . emissiveStrength , 1 ) & & Near ( plain . transmissionFactor , 0 ) & & plain . transmissionTexture . texture = = - 1
& & Near ( plain . ior , 1.5f ) & & plain . extras . empty ( ) ,
" the extensions' defaults where a material has none " ) ;
Check ( flame . extras = = R " ({ " effect " : " flicker " }) " , " a material's extras, as JSON text " ) ;
}
Check ( a . nodes [ 1 ] . extras = = R " ({ " flicker " :0.3}) " & & a . nodes [ 4 ] . extras = = R " ({ " height " :1.6, " seat " :true}) " & & a . nodes [ 0 ] . extras . empty ( ) ,
" nodes' extras, as JSON text " ) ;
Check ( a . extras = = R " ({ " exposure " :1.5}) " , " the default scene's extras, as JSON text " ) ;
// A node may only name a light the extension defines.
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
}
// 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 } ]
} ) " ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
}
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>
2026-10-31 03:25:12 +01:00
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 \0 WEBPVP8 " , 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.
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
std : : vector < std : : byte > WidePng ( std : : uint32_t width ) {
std : : vector < std : : uint8_t > row ( 1 + width , 128 ) ;
row [ 0 ] = 0 ; // no filter
std : : vector < std : : uint8_t > zlib = { 0x78 , 0x01 } ;
for ( std : : size_t at = 0 ; at < row . size ( ) ; ) {
const std : : size_t n = std : : min < std : : size_t > ( 65535 , row . size ( ) - at ) ;
zlib . push_back ( at + n = = row . size ( ) ? 1 : 0 ) ;
for ( std : : uint16_t v : { static_cast < std : : uint16_t > ( n ) , static_cast < std : : uint16_t > ( ~ n ) } ) {
zlib . push_back ( static_cast < std : : uint8_t > ( v ) ) ;
zlib . push_back ( static_cast < std : : uint8_t > ( v > > 8 ) ) ;
}
zlib . insert ( zlib . end ( ) , row . begin ( ) + static_cast < std : : ptrdiff_t > ( at ) , row . begin ( ) + static_cast < std : : ptrdiff_t > ( at + n ) ) ;
at + = n ;
}
std : : uint32_t a = 1 , b = 0 ;
for ( std : : uint8_t c : row ) {
a = ( a + c ) % 65521 ;
b = ( b + a ) % 65521 ;
}
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>
2026-10-31 03:25:12 +01:00
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 ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
} ;
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>
2026-10-31 03:25:12 +01:00
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 ( ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
}
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>
2026-10-31 03:25:12 +01:00
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 ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
} ;
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
}
void TestBrokenFiles ( ) {
std : : vector < std : : byte > glb = BuildGlb ( FixtureJson ( ) , FixtureBin ( ) ) ;
std : : vector < std : : byte > badMagic = glb ;
badMagic [ 0 ] = std : : byte { ' X ' } ;
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>
2026-10-31 03:25:12 +01:00
CheckError ( GlbAsset : : Load ( badMagic ) , " magic " , " bad magic is an error " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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 " ) ;
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>
2026-10-31 03:25:12 +01:00
// 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.
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
const auto sparse = GlbAsset : : Load ( BuildGlb (
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>
2026-10-31 03:25:12 +01:00
FixtureJson ( R " (, " sparse " : { " count " : 1, " indices " : { " bufferView " : 2, " componentType " : 5121}, " values " : { " bufferView " : 0}}) " ) ,
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
FixtureBin ( ) ) ) ;
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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>
2026-10-31 03:25:12 +01:00
CheckError ( GlbAsset : : Load ( BuildGlb ( FixtureJson ( " " , 3 ) , FixtureBin ( ) ) ) , " bufferView " , " an accessor past its buffer view is an error " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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>
2026-10-31 03:25:12 +01:00
CheckError ( GlbAsset : : Load ( std : : filesystem : : path ( " /nonexistent/file.glb " ) ) , " No such file " , " a missing file is an error that says so " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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>
2026-10-31 03:25:12 +01:00
// 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.
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
auto with = [ ] ( std : : initializer_list < std : : pair < std : : string_view , std : : string_view > > changes ) {
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>
2026-10-31 03:25:12 +01:00
std : : vector < std : : byte > bin = FixtureBin ( ) ;
bin . resize ( 1024 ) ;
return BuildGlb ( Replace ( FixtureJson ( ) , changes ) , bin ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
} ;
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>
2026-10-31 03:25:12 +01:00
// 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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
// 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 " ) ;
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
Check ( ! GlbAsset : : Load ( std : : filesystem : : temp_directory_path ( ) ) , " a directory is an error " ) ;
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>
2026-10-31 03:25:12 +01:00
// 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.
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
std : : vector < std : : byte > longChunk = glb ;
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>
2026-10-31 03:25:12 +01:00
const std : : uint32_t huge = 0xFFFFFFF8 ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
std : : memcpy ( longChunk . data ( ) + 12 , & huge , sizeof ( huge ) ) ;
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>
2026-10-31 03:25:12 +01:00
CheckError ( GlbAsset : : Load ( longChunk ) , " truncated chunk " , " a chunk longer than the file is an error " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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>
2026-10-31 03:25:12 +01:00
// 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.
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
auto nodesOnly = [ ] ( std : : string_view nodes ) {
return BuildGlb ( std : : format ( R " ({{ " asset " : {{ " version " : " 2.0 " }}, " nodes " : [{}]}}) " , nodes), { } ) ;
} ;
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>
2026-10-31 03:25:12 +01:00
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.
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
constexpr std : : size_t kChain = 100000 ;
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>
2026-10-31 03:25:12 +01:00
const Micro quarter = timed ( chainOf ( kChain / 4 ) , Micro : : zero ( ) ) . second ;
const auto [ deep , whole ] = timed ( chainOf ( kChain ) , quarter * 8 ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
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 ( ) ) ) ;
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>
2026-10-31 03:25:12 +01:00
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 ) ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
}
// Column-major 4x4 helpers for the rest-pose check, kept separate from the
// matrix types under test.
using Mat = std : : array < float , 16 > ;
Mat Multiply ( const Mat & a , const Mat & b ) {
Mat r { } ;
for ( int col = 0 ; col < 4 ; + + col ) {
for ( int row = 0 ; row < 4 ; + + row ) {
float sum = 0 ;
for ( int k = 0 ; k < 4 ; + + k ) sum + = a [ k * 4 + row ] * b [ col * 4 + k ] ;
r [ col * 4 + row ] = sum ;
}
}
return r ;
}
Mat LocalMatrix ( const GlbNode & node ) {
if ( node . hasMatrix ) return node . matrix . Store ( ) ; // rows of the row-major copy are glTF's columns
const float x = node . rotation . x , y = node . rotation . y , z = node . rotation . z , w = node . rotation . w ;
const float sx = node . scale . x , sy = node . scale . y , sz = node . scale . z ;
return {
( 1 - 2 * ( y * y + z * z ) ) * sx , ( 2 * ( x * y + w * z ) ) * sx , ( 2 * ( x * z - w * y ) ) * sx , 0 ,
( 2 * ( x * y - w * z ) ) * sy , ( 1 - 2 * ( x * x + z * z ) ) * sy , ( 2 * ( y * z + w * x ) ) * sy , 0 ,
( 2 * ( x * z + w * y ) ) * sz , ( 2 * ( y * z - w * x ) ) * sz , ( 1 - 2 * ( x * x + y * y ) ) * sz , 0 ,
node . translation . x , node . translation . y , node . translation . z , 1 ,
} ;
}
void TestRealFile ( const char * path ) {
const auto loaded = GlbAsset : : Load ( std : : filesystem : : path ( path ) ) ;
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>
2026-10-31 03:25:12 +01:00
if ( ! CheckLoads ( loaded , std : : format ( " {} loads " , path ) ) ) return ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
const GlbAsset & a = * loaded ;
std : : size_t vertices = 0 ;
bool streamsMatch = true ;
bool indicesValid = true ;
for ( const GlbMesh & mesh : a . meshes ) {
for ( const GlbPrimitive & p : mesh . primitives ) {
vertices + = p . positions . size ( ) ;
streamsMatch = streamsMatch & & p . normals . size ( ) = = p . positions . size ( )
& & ( p . joints . empty ( ) | | ( p . joints . size ( ) = = p . positions . size ( ) & & p . weights . size ( ) = = p . positions . size ( ) ) ) ;
indicesValid = indicesValid & & p . indices . size ( ) % 3 = = 0
& & std : : ranges : : all_of ( p . indices , [ & ] ( std : : uint32_t i ) { return i < p . positions . size ( ) ; } ) ;
}
}
std : : println ( " {} meshes, {} vertices, {} nodes, {} skins ({} joints), {} materials, {} images " ,
a . meshes . size ( ) , vertices , a . nodes . size ( ) , a . skins . size ( ) ,
a . skins . empty ( ) ? 0 : a . skins [ 0 ] . joints . size ( ) , a . materials . size ( ) , a . images . size ( ) ) ;
Check ( streamsMatch , " every vertex stream has one entry per vertex " ) ;
Check ( indicesValid , " every index list is whole triangles of valid vertices " ) ;
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>
2026-10-31 03:25:12 +01:00
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 " ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
// World matrices by walking down from the roots, then world * inverseBind
// per joint.
std : : vector < Mat > world ( a . nodes . size ( ) ) ;
std : : function < void ( std : : uint32_t , const Mat & ) > walk = [ & ] ( std : : uint32_t node , const Mat & parent ) {
world [ node ] = Multiply ( parent , LocalMatrix ( a . nodes [ node ] ) ) ;
for ( std : : uint32_t child : a . nodes [ node ] . children ) walk ( child , world [ node ] ) ;
} ;
const Mat identity = { 1 , 0 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 , 1 , 0 , 0 , 0 , 0 , 1 } ;
for ( std : : uint32_t r = 0 ; r < a . nodes . size ( ) ; + + r ) {
if ( a . nodes [ r ] . parent = = - 1 ) walk ( r , identity ) ;
}
for ( const GlbSkin & skin : a . skins ) {
float worst = 0 ;
for ( std : : size_t j = 0 ; j < skin . joints . size ( ) ; + + j ) {
const Mat bound = Multiply ( world [ skin . joints [ j ] ] , skin . inverseBindMatrices [ j ] . Store ( ) ) ;
for ( std : : size_t i = 0 ; i < 16 ; + + i ) worst = std : : max ( worst , std : : abs ( bound [ i ] - identity [ i ] ) ) ;
}
Check ( worst < 1e-3 f , std : : format ( " skin '{}': world * inverseBind is the identity at rest (worst {}) " , skin . name , worst ) ) ;
}
}
} // namespace
int main ( ) {
TestFixture ( ) ;
TestRoom ( ) ;
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>
2026-10-31 03:25:12 +01:00
TestMorphTargets ( ) ;
TestPrimitiveModes ( ) ;
TestTangents ( ) ;
TestSkinning ( ) ;
TestReferences ( ) ;
TestImages ( ) ;
TestDecodeLimit ( ) ;
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:07:14 +02:00
TestBrokenFiles ( ) ;
if ( const char * real = std : : getenv ( " CRAFTER_ASSET_TEST_GLB " ) ) {
TestRealFile ( real ) ;
} else {
std : : println ( " SKIP real-file check (set CRAFTER_ASSET_TEST_GLB to a skinned .glb) " ) ;
}
std : : println ( " {} " , failures = = 0 ? " all checks passed " : " check(s) failed " ) ;
return failures = = 0 ? EXIT_SUCCESS : EXIT_FAILURE ;
}