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®
|
|
|
|
|
|
|
|
|
|
module;
|
|
|
|
|
// nlohmann/json stays inside this unit. Included from the interface, it
|
|
|
|
|
// would be parsed again by everything that imports Crafter.Asset.
|
|
|
|
|
#include "../lib/json.hpp"
|
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
|
|
|
// errno, for why a file that exists doesn't open.
|
|
|
|
|
#include <cerrno>
|
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
|
|
|
module Crafter.Asset;
|
|
|
|
|
import std;
|
|
|
|
|
import Crafter.Math;
|
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
|
// Json, not json: GlbAsset has a member called json, which would hide it
|
|
|
|
|
// inside GlbAsset::Load.
|
|
|
|
|
using Json = nlohmann::json;
|
|
|
|
|
using namespace Crafter;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
constexpr std::uint32_t kMagic = 0x46546C67; // "glTF"
|
|
|
|
|
constexpr std::uint32_t kChunkJson = 0x4E4F534A; // "JSON"
|
|
|
|
|
constexpr std::uint32_t kChunkBin = 0x004E4942; // "BIN\0"
|
|
|
|
|
|
|
|
|
|
constexpr std::uint32_t kByte = 5120;
|
|
|
|
|
constexpr std::uint32_t kUnsignedByte = 5121;
|
|
|
|
|
constexpr std::uint32_t kShort = 5122;
|
|
|
|
|
constexpr std::uint32_t kUnsignedShort = 5123;
|
|
|
|
|
constexpr std::uint32_t kUnsignedInt = 5125;
|
|
|
|
|
constexpr std::uint32_t kFloat = 5126;
|
|
|
|
|
|
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
|
|
|
// Primitive modes; those below kTriangles are points and lines.
|
|
|
|
|
constexpr std::int64_t kTriangles = 4;
|
|
|
|
|
constexpr std::int64_t kTriangleStrip = 5;
|
|
|
|
|
constexpr std::int64_t kTriangleFan = 6;
|
|
|
|
|
|
|
|
|
|
// The decode limit when Load isn't given one: 1 GiB, the largest image a
|
|
|
|
|
// texture takes, and 64 bytes for each byte of the file, more than a
|
|
|
|
|
// model's vertices and images decode to unless the same data is decoded
|
|
|
|
|
// over and over.
|
|
|
|
|
constexpr std::uint64_t kDecodeFloor = std::uint64_t{1} << 30;
|
|
|
|
|
constexpr std::uint64_t kDecodePerByte = 64;
|
|
|
|
|
|
|
|
|
|
// The extensions this loader implements. A file that requires another
|
|
|
|
|
// can't be loaded right without it, so, as glTF asks, it isn't loaded.
|
|
|
|
|
constexpr std::array<std::string_view, 4> kImplemented = {
|
|
|
|
|
"KHR_lights_punctual", "KHR_materials_emissive_strength", "KHR_materials_ior", "KHR_materials_transmission",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// An index the file gives that can't name anything, as GlbAsset's int32
|
|
|
|
|
// keeps it; CheckReferences turns it down.
|
|
|
|
|
constexpr std::int32_t kBadIndex = std::numeric_limits<std::int32_t>::min();
|
|
|
|
|
// A texCoord past the two UV sets a primitive keeps, likewise.
|
|
|
|
|
constexpr std::uint32_t kBadTexCoord = std::numeric_limits<std::uint32_t>::max();
|
|
|
|
|
|
|
|
|
|
using Pixel = Vector<std::uint8_t, 4, 4>;
|
|
|
|
|
using Image = TextureAsset<Pixel>;
|
|
|
|
|
|
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::unexpected<std::string> Error(std::string message) {
|
|
|
|
|
return std::unexpected("GLB: " + std::move(message));
|
|
|
|
|
}
|
|
|
|
|
|
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::uint32_t U32(std::span<const std::byte> bytes, std::size_t offset) {
|
|
|
|
|
std::uint32_t value;
|
|
|
|
|
std::memcpy(&value, bytes.data() + offset, sizeof(value));
|
|
|
|
|
return 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
|
|
|
// Reads from the parsed JSON that never throw (a -fno-exceptions build
|
|
|
|
|
// aborts on a throw): a missing value or one of the wrong type reads as
|
|
|
|
|
// the fallback.
|
|
|
|
|
const Json* Find(const Json& object, const char* key) {
|
|
|
|
|
if (!object.is_object()) return nullptr;
|
|
|
|
|
const auto it = object.find(key);
|
|
|
|
|
return it == object.end() ? nullptr : &*it;
|
|
|
|
|
}
|
|
|
|
|
|
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 whole number: 24, but also 24.0 or 2.4e1, as JSON may write one.
|
|
|
|
|
// One past int64, which no index or count reaches, comes back as
|
|
|
|
|
// int64's limit of its sign, which range checks still turn down.
|
|
|
|
|
std::optional<std::int64_t> AsInteger(const Json& value) {
|
|
|
|
|
if (value.is_number_unsigned()) {
|
|
|
|
|
return static_cast<std::int64_t>(std::min<std::uint64_t>(value.get<std::uint64_t>(), std::numeric_limits<std::int64_t>::max()));
|
|
|
|
|
}
|
|
|
|
|
if (value.is_number_integer()) return value.get<std::int64_t>();
|
|
|
|
|
if (!value.is_number_float()) return std::nullopt;
|
|
|
|
|
const double number = value.get<double>();
|
|
|
|
|
if (number != std::trunc(number)) return std::nullopt; // a fraction, or NaN
|
|
|
|
|
constexpr double kTwoTo63 = 9223372036854775808.0;
|
|
|
|
|
if (number >= kTwoTo63) return std::numeric_limits<std::int64_t>::max();
|
|
|
|
|
if (number < -kTwoTo63) return std::numeric_limits<std::int64_t>::min();
|
|
|
|
|
return static_cast<std::int64_t>(number);
|
|
|
|
|
}
|
|
|
|
|
|
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::int64_t Integer(const Json& object, const char* key, std::int64_t fallback) {
|
|
|
|
|
const Json* value = Find(object, key);
|
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
|
|
|
return value != nullptr ? AsInteger(*value).value_or(fallback) : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An index into one of the file's arrays, as GlbAsset keeps it: -1 when
|
|
|
|
|
// `key` is absent, kBadIndex when it can't name an element (negative,
|
|
|
|
|
// not a whole number, past int32). Checked before it is narrowed, so
|
|
|
|
|
// that 4294967296 can't wrap round to 0.
|
|
|
|
|
std::int32_t Index(const Json& object, const char* key) {
|
|
|
|
|
const Json* value = Find(object, key);
|
|
|
|
|
if (value == nullptr) return -1;
|
|
|
|
|
const std::optional<std::int64_t> index = AsInteger(*value);
|
|
|
|
|
return index && *index >= 0 && *index <= std::numeric_limits<std::int32_t>::max() ? static_cast<std::int32_t>(*index) : kBadIndex;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An element of a list of indices (children, joints, a scene's nodes)
|
|
|
|
|
// that names one of `count`, or nullopt.
|
|
|
|
|
std::optional<std::uint32_t> Element(const Json& value, std::size_t count) {
|
|
|
|
|
const std::optional<std::int64_t> index = AsInteger(value);
|
|
|
|
|
if (!index || *index < 0 || static_cast<std::uint64_t>(*index) >= count || *index > std::numeric_limits<std::uint32_t>::max()) {
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
}
|
|
|
|
|
return static_cast<std::uint32_t>(*index);
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
float Number(const Json& object, const char* key, float fallback) {
|
|
|
|
|
const Json* value = Find(object, key);
|
|
|
|
|
return value != nullptr && value->is_number() ? value->get<float>() : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool Boolean(const Json& object, const char* key, bool fallback) {
|
|
|
|
|
const Json* value = Find(object, key);
|
|
|
|
|
return value != nullptr && value->is_boolean() ? value->get<bool>() : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string String(const Json& object, const char* key) {
|
|
|
|
|
const Json* value = Find(object, key);
|
|
|
|
|
return value != nullptr && value->is_string() ? value->get<std::string>() : std::string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const Json& Array(const Json& object, const char* key) {
|
|
|
|
|
static const Json empty = Json::array();
|
|
|
|
|
const Json* value = Find(object, key);
|
|
|
|
|
return value != nullptr && value->is_array() ? *value : empty;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// An object's extras as JSON text, or empty when it has none. Written
|
|
|
|
|
// without recursion: Json::dump recurses once a level, and extras nested
|
|
|
|
|
// deep enough run it out of stack, where the parser and the destructor
|
|
|
|
|
// don't recurse.
|
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::string Extras(const Json& object) {
|
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 Json* extras = Find(object, "extras");
|
|
|
|
|
if (extras == nullptr || extras->is_null()) return {};
|
|
|
|
|
std::string text;
|
|
|
|
|
// The arrays and objects being written, each with its next element.
|
|
|
|
|
std::vector<std::pair<const Json*, Json::const_iterator>> open;
|
|
|
|
|
const Json* value = extras;
|
|
|
|
|
while (true) {
|
|
|
|
|
if (value != nullptr) {
|
|
|
|
|
if (value->is_structured() && !value->empty()) {
|
|
|
|
|
text += value->is_object() ? '{' : '[';
|
|
|
|
|
open.emplace_back(value, value->cbegin());
|
|
|
|
|
} else {
|
|
|
|
|
text += value->dump(); // a scalar, {} or []: nothing nested
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (open.empty()) return text;
|
|
|
|
|
auto& [container, next] = open.back();
|
|
|
|
|
if (next == container->cend()) {
|
|
|
|
|
text += container->is_object() ? '}' : ']';
|
|
|
|
|
open.pop_back();
|
|
|
|
|
value = nullptr;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (next != container->cbegin()) text += ',';
|
|
|
|
|
if (container->is_object()) {
|
|
|
|
|
text += Json(next.key()).dump();
|
|
|
|
|
text += ':';
|
|
|
|
|
}
|
|
|
|
|
value = &*next;
|
|
|
|
|
++next;
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The object an extension keeps in `object`'s extensions, or null.
|
|
|
|
|
const Json* Extension(const Json& object, const char* name) {
|
|
|
|
|
const Json* extensions = Find(object, "extensions");
|
|
|
|
|
return extensions != nullptr ? Find(*extensions, name) : nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// Numbers from a JSON array into `out`, which keeps its values where the
|
|
|
|
|
// array is shorter or holds something else.
|
|
|
|
|
void Numbers(const Json& object, const char* key, std::span<float> 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
|
|
|
const Json& values = Array(object, key);
|
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 (std::size_t i = 0; i < out.size() && i < values.size(); ++i) {
|
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
|
|
|
if (values[i].is_number()) out[i] = values[i].get<float>();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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::size_t ComponentSize(std::int64_t componentType) {
|
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
|
|
|
switch (componentType) {
|
|
|
|
|
case kByte: case kUnsignedByte: return 1;
|
|
|
|
|
case kShort: case kUnsignedShort: return 2;
|
|
|
|
|
case kUnsignedInt: case kFloat: return 4;
|
|
|
|
|
default: return 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::uint32_t ComponentCount(const std::string& type) {
|
|
|
|
|
if (type == "SCALAR") return 1;
|
|
|
|
|
if (type == "VEC2") return 2;
|
|
|
|
|
if (type == "VEC3") return 3;
|
|
|
|
|
if (type == "VEC4") return 4;
|
|
|
|
|
if (type == "MAT4") return 16;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// An accessor resolved to its bytes: in the BIN chunk, or, for one that
|
|
|
|
|
// is sparse or has no bufferView, in `dense`, where they were put
|
|
|
|
|
// together.
|
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 Accessor {
|
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::byte* data = nullptr;
|
|
|
|
|
std::size_t count = 0;
|
|
|
|
|
std::size_t stride = 0;
|
|
|
|
|
std::uint32_t componentType = 0;
|
|
|
|
|
std::uint32_t components = 0;
|
|
|
|
|
bool normalized = false;
|
|
|
|
|
std::vector<std::byte> dense;
|
|
|
|
|
|
|
|
|
|
const std::byte* At(std::size_t element, std::uint32_t component) const {
|
|
|
|
|
return (dense.empty() ? data : dense.data()) + element * stride + component * ComponentSize(componentType);
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
float Float(std::size_t element, std::uint32_t component) const {
|
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::byte* p = At(element, component);
|
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
|
|
|
switch (componentType) {
|
|
|
|
|
case kFloat: { float v; std::memcpy(&v, p, sizeof(v)); return v; }
|
|
|
|
|
case kUnsignedByte: { std::uint8_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? v / 255.0f : v; }
|
|
|
|
|
case kUnsignedShort: { std::uint16_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? v / 65535.0f : v; }
|
|
|
|
|
case kByte: { std::int8_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? std::max(v / 127.0f, -1.0f) : v; }
|
|
|
|
|
case kShort: { std::int16_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? std::max(v / 32767.0f, -1.0f) : v; }
|
|
|
|
|
case kUnsignedInt: { std::uint32_t v; std::memcpy(&v, p, sizeof(v)); return static_cast<float>(v); }
|
|
|
|
|
default: return 0.0f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::uint32_t Unsigned(std::size_t element, std::uint32_t component) const {
|
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::byte* p = At(element, component);
|
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
|
|
|
switch (componentType) {
|
|
|
|
|
case kUnsignedByte: { std::uint8_t v; std::memcpy(&v, p, sizeof(v)); return v; }
|
|
|
|
|
case kUnsignedShort: { std::uint16_t v; std::memcpy(&v, p, sizeof(v)); return v; }
|
|
|
|
|
case kUnsignedInt: { std::uint32_t v; std::memcpy(&v, p, sizeof(v)); return v; }
|
|
|
|
|
default: return 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
struct Context {
|
|
|
|
|
const Json& root;
|
|
|
|
|
std::span<const std::byte> bin;
|
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 decode limit, and what is left of it.
|
|
|
|
|
std::uint64_t limit;
|
|
|
|
|
std::uint64_t budget;
|
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
|
|
|
// Takes `count` elements of `size` bytes out of the budget; false when
|
|
|
|
|
// they don't fit in it, or in a size_t.
|
|
|
|
|
bool Spend(Context& ctx, std::uint64_t count, std::uint64_t size) {
|
|
|
|
|
if (count > ctx.budget / size || count > std::numeric_limits<std::size_t>::max() / size) return false;
|
|
|
|
|
ctx.budget -= count * size;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::unexpected<std::string> OverLimit(const Context& ctx, std::string_view what) {
|
|
|
|
|
return Error(std::format("{} decodes past the limit of {} bytes", what, ctx.limit));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Sizes `stream` to `count` elements, out of the budget.
|
|
|
|
|
template <typename T>
|
|
|
|
|
bool Allocate(Context& ctx, std::vector<T>& stream, std::size_t count) {
|
|
|
|
|
if (!Spend(ctx, count, sizeof(T))) return false;
|
|
|
|
|
stream.resize(count);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// The bytes of buffer view `index`, which must lie in the BIN chunk.
|
|
|
|
|
std::expected<std::span<const std::byte>, std::string> ViewBytes(const Context& ctx, std::int64_t index, std::size_t* strideOut) {
|
|
|
|
|
const Json& views = Array(ctx.root, "bufferViews");
|
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 (index < 0 || static_cast<std::uint64_t>(index) >= views.size()) {
|
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 Error(std::format("bufferView {} doesn't exist", index));
|
|
|
|
|
}
|
|
|
|
|
const Json& view = views[static_cast<std::size_t>(index)];
|
|
|
|
|
if (Integer(view, "buffer", -1) != 0) {
|
|
|
|
|
return Error(std::format("bufferView {} uses an external buffer, which a GLB loader doesn't read", index));
|
|
|
|
|
}
|
|
|
|
|
const std::int64_t offset = Integer(view, "byteOffset", 0);
|
|
|
|
|
const std::int64_t length = Integer(view, "byteLength", -1);
|
|
|
|
|
// Compared so that nothing can overflow.
|
|
|
|
|
const std::uint64_t size = ctx.bin.size();
|
|
|
|
|
if (offset < 0 || length < 0 || static_cast<std::uint64_t>(offset) > size || static_cast<std::uint64_t>(length) > size - static_cast<std::uint64_t>(offset)) {
|
|
|
|
|
return Error(std::format("bufferView {} lies outside the BIN chunk", index));
|
|
|
|
|
}
|
|
|
|
|
// glTF allows 4 to 252 bytes between elements; 0 or none packs them.
|
|
|
|
|
const std::int64_t stride = Integer(view, "byteStride", 0);
|
|
|
|
|
if (stride != 0 && (stride < 4 || stride > 252)) {
|
|
|
|
|
return Error(std::format("bufferView {} has a byteStride of {}, outside 4..252", index, stride));
|
|
|
|
|
}
|
|
|
|
|
if (strideOut != nullptr) *strideOut = static_cast<std::size_t>(stride);
|
|
|
|
|
return ctx.bin.subspan(static_cast<std::size_t>(offset), static_cast<std::size_t>(length));
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// `count` elements of `size` bytes, packed at the bufferView and
|
|
|
|
|
// byteOffset `object` names: a sparse accessor's indices or values.
|
|
|
|
|
std::expected<std::span<const std::byte>, std::string> Packed(const Context& ctx, const Json& object, std::uint64_t count, std::size_t size) {
|
|
|
|
|
const auto view = ViewBytes(ctx, Integer(object, "bufferView", -1), nullptr);
|
|
|
|
|
if (!view) return std::unexpected(view.error());
|
|
|
|
|
const std::int64_t offset = Integer(object, "byteOffset", 0);
|
|
|
|
|
const std::uint64_t available = view->size();
|
|
|
|
|
if (offset < 0 || static_cast<std::uint64_t>(offset) > available || count > (available - static_cast<std::uint64_t>(offset)) / size) {
|
|
|
|
|
return Error("sparse data reads past the end of its bufferView");
|
|
|
|
|
}
|
|
|
|
|
return view->subspan(static_cast<std::size_t>(offset), static_cast<std::size_t>(count * size));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A sparse accessor's values, written over the elements its indices name
|
|
|
|
|
// in `accessor`, whose elements are already in `dense`.
|
|
|
|
|
std::expected<void, std::string> ApplySparse(const Context& ctx, const Json& sparse, Accessor& accessor, std::int64_t index) {
|
|
|
|
|
const std::int64_t count = Integer(sparse, "count", -1);
|
|
|
|
|
const Json* indices = Find(sparse, "indices");
|
|
|
|
|
const Json* values = Find(sparse, "values");
|
|
|
|
|
const std::int64_t indexType = indices != nullptr ? Integer(*indices, "componentType", 0) : 0;
|
|
|
|
|
if (count < 0 || static_cast<std::uint64_t>(count) > accessor.count || values == nullptr
|
|
|
|
|
|| (indexType != kUnsignedByte && indexType != kUnsignedShort && indexType != kUnsignedInt)) {
|
|
|
|
|
return Error(std::format("accessor {} has a malformed sparse part", index));
|
|
|
|
|
}
|
|
|
|
|
const std::size_t indexSize = ComponentSize(indexType);
|
|
|
|
|
const auto indexBytes = Packed(ctx, *indices, static_cast<std::uint64_t>(count), indexSize);
|
|
|
|
|
if (!indexBytes) return std::unexpected(std::format("{} (sparse indices of accessor {})", indexBytes.error(), index));
|
|
|
|
|
const std::size_t elementSize = accessor.stride;
|
|
|
|
|
const auto valueBytes = Packed(ctx, *values, static_cast<std::uint64_t>(count), elementSize);
|
|
|
|
|
if (!valueBytes) return std::unexpected(std::format("{} (sparse values of accessor {})", valueBytes.error(), index));
|
|
|
|
|
|
|
|
|
|
const Accessor elements{
|
|
|
|
|
.data = indexBytes->data(),
|
|
|
|
|
.count = static_cast<std::size_t>(count),
|
|
|
|
|
.stride = indexSize,
|
|
|
|
|
.componentType = static_cast<std::uint32_t>(indexType),
|
|
|
|
|
.components = 1,
|
|
|
|
|
};
|
|
|
|
|
std::int64_t previous = -1;
|
|
|
|
|
for (std::size_t i = 0; i < elements.count; ++i) {
|
|
|
|
|
const std::uint32_t element = elements.Unsigned(i, 0);
|
|
|
|
|
// glTF has them strictly increase, which also keeps each one
|
|
|
|
|
// inside the accessor once.
|
|
|
|
|
if (element <= previous || element >= accessor.count) {
|
|
|
|
|
return Error(std::format("accessor {}'s sparse indices don't rise within its {} elements", index, accessor.count));
|
|
|
|
|
}
|
|
|
|
|
previous = element;
|
|
|
|
|
std::memcpy(accessor.dense.data() + std::size_t{element} * elementSize, valueBytes->data() + i * elementSize, elementSize);
|
|
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::expected<Accessor, std::string> ReadAccessor(Context& ctx, std::int64_t index) {
|
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 Json& accessors = Array(ctx.root, "accessors");
|
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 (index < 0 || static_cast<std::uint64_t>(index) >= accessors.size()) {
|
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 Error(std::format("accessor {} doesn't exist", index));
|
|
|
|
|
}
|
|
|
|
|
const Json& accessor = accessors[static_cast<std::size_t>(index)];
|
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
|
|
|
// Compared as the file has it, so that 4294972422 can't wrap round
|
|
|
|
|
// to FLOAT, 5126.
|
|
|
|
|
const std::int64_t componentType = Integer(accessor, "componentType", 0);
|
|
|
|
|
const std::size_t componentSize = ComponentSize(componentType);
|
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::uint32_t components = ComponentCount(String(accessor, "type"));
|
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 (componentSize == 0 || components == 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
|
|
|
return Error(std::format("accessor {} has an unsupported type or componentType", index));
|
|
|
|
|
}
|
|
|
|
|
// Matrices of 1- and 2-byte components pad their columns; nothing
|
|
|
|
|
// here reads those.
|
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 (components == 16 && componentSize != 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
|
|
|
return Error(std::format("accessor {} is a MAT4 of small components, which isn't supported", index));
|
|
|
|
|
}
|
|
|
|
|
const std::int64_t count = Integer(accessor, "count", -1);
|
|
|
|
|
if (count < 0) return Error(std::format("accessor {} has no count", index));
|
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::size_t elementSize = componentSize * components;
|
|
|
|
|
Accessor result{
|
|
|
|
|
.stride = elementSize,
|
|
|
|
|
.componentType = static_cast<std::uint32_t>(componentType),
|
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
|
|
|
.components = components,
|
|
|
|
|
.normalized = Boolean(accessor, "normalized", false),
|
|
|
|
|
};
|
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 bool hasView = Find(accessor, "bufferView") != nullptr;
|
|
|
|
|
if (hasView) {
|
|
|
|
|
std::size_t viewStride = 0;
|
|
|
|
|
const auto view = ViewBytes(ctx, Integer(accessor, "bufferView", -1), &viewStride);
|
|
|
|
|
if (!view) return std::unexpected(view.error());
|
|
|
|
|
if (viewStride != 0 && viewStride < elementSize) {
|
|
|
|
|
return Error(std::format("accessor {}'s elements overlap: {} bytes in a stride of {}", index, elementSize, viewStride));
|
|
|
|
|
}
|
|
|
|
|
const std::size_t stride = viewStride != 0 ? viewStride : elementSize;
|
|
|
|
|
const std::int64_t offset = Integer(accessor, "byteOffset", 0);
|
|
|
|
|
// The last element ends inside the view, compared so that nothing
|
|
|
|
|
// can overflow, whatever the count.
|
|
|
|
|
const std::uint64_t size = view->size();
|
|
|
|
|
const bool inside = offset >= 0 && static_cast<std::uint64_t>(offset) <= size
|
|
|
|
|
&& (count == 0 || (elementSize <= size - static_cast<std::uint64_t>(offset)
|
|
|
|
|
&& static_cast<std::uint64_t>(count - 1) <= (size - static_cast<std::uint64_t>(offset) - elementSize) / stride));
|
|
|
|
|
if (!inside) {
|
|
|
|
|
return Error(std::format("accessor {} reads past the end of its bufferView", index));
|
|
|
|
|
}
|
|
|
|
|
result.data = view->data() + offset;
|
|
|
|
|
result.stride = stride;
|
|
|
|
|
}
|
|
|
|
|
const Json* sparse = Find(accessor, "sparse");
|
|
|
|
|
if (hasView && sparse == nullptr) {
|
|
|
|
|
result.count = static_cast<std::size_t>(count); // inside its view, so a size_t holds it
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Put together in `dense`: the view's elements, or zeros without one,
|
|
|
|
|
// as glTF says, with the sparse values written over them.
|
|
|
|
|
if (!Spend(ctx, static_cast<std::uint64_t>(count), elementSize)) return OverLimit(ctx, std::format("accessor {}", index));
|
|
|
|
|
result.count = static_cast<std::size_t>(count);
|
|
|
|
|
result.dense.resize(result.count * elementSize);
|
|
|
|
|
if (hasView) {
|
|
|
|
|
for (std::size_t i = 0; i < result.count; ++i) {
|
|
|
|
|
std::memcpy(result.dense.data() + i * elementSize, result.data + i * result.stride, elementSize);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
result.stride = elementSize;
|
|
|
|
|
if (sparse != nullptr) {
|
|
|
|
|
if (auto applied = ApplySparse(ctx, *sparse, result, index); !applied) return std::unexpected(applied.error());
|
|
|
|
|
}
|
|
|
|
|
return result;
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An accessor of `components` per element whose component type is one of
|
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
|
|
|
// `allowed`, with exactly `count` elements when a count is given.
|
|
|
|
|
std::expected<Accessor, std::string> Attribute(Context& ctx, const Json& attributes, const char* name, std::uint32_t components,
|
|
|
|
|
std::initializer_list<std::uint32_t> allowed, std::optional<std::size_t> count,
|
|
|
|
|
const std::string& where) {
|
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 accessor = ReadAccessor(ctx, Integer(attributes, name, -1));
|
|
|
|
|
if (!accessor) return std::unexpected(std::format("{} ({} of {})", accessor.error(), name, where));
|
|
|
|
|
if (accessor->components != components || std::ranges::find(allowed, accessor->componentType) == allowed.end()) {
|
|
|
|
|
return Error(std::format("{} of {} has the wrong type", name, where));
|
|
|
|
|
}
|
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 (count && accessor->count != *count) {
|
|
|
|
|
return Error(std::format("{} of {} has {} elements for {} vertices", name, where, accessor->count, *count));
|
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 accessor;
|
|
|
|
|
}
|
|
|
|
|
|
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 vertex stream `name` into `out`: `components` numbers for each of
|
|
|
|
|
// the `count` vertices. Left empty when `attributes` doesn't have it.
|
|
|
|
|
template <typename T>
|
|
|
|
|
std::expected<void, std::string> Stream(Context& ctx, const Json& attributes, const char* name, std::uint32_t components,
|
|
|
|
|
std::initializer_list<std::uint32_t> allowed, std::size_t count, const std::string& where,
|
|
|
|
|
std::vector<T>& out) {
|
|
|
|
|
if (Find(attributes, name) == nullptr) return {};
|
|
|
|
|
const auto accessor = Attribute(ctx, attributes, name, components, allowed, count, where);
|
|
|
|
|
if (!accessor) return std::unexpected(accessor.error());
|
|
|
|
|
if (!Allocate(ctx, out, count)) return OverLimit(ctx, where);
|
|
|
|
|
for (std::size_t i = 0; i < count; ++i) {
|
|
|
|
|
for (std::uint32_t c = 0; c < components; ++c) out[i].v[c] = accessor->Float(i, c);
|
|
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Per-vertex tangents from the gradients of `uv`, accumulated over the
|
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
|
|
|
// triangles a vertex is in and made orthogonal to its normal. glTF's v
|
|
|
|
|
// runs down the image while a normal map's +Y points up, so its
|
|
|
|
|
// bitangent is the one along -v: hence the sign below, the same
|
|
|
|
|
// correction three.js applies to MikkTSpace output for glTF.
|
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 ComputeTangents(GlbPrimitive& p, const std::vector<Vector<float, 2, 2>>& uv) {
|
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
|
|
|
using V3 = Vector<float, 3, 0>;
|
|
|
|
|
const std::size_t n = p.positions.size();
|
|
|
|
|
std::vector<V3> tangentSum(n, V3(0, 0, 0));
|
|
|
|
|
std::vector<V3> bitangentSum(n, V3(0, 0, 0));
|
|
|
|
|
for (std::size_t t = 0; t + 2 < p.indices.size(); t += 3) {
|
|
|
|
|
const std::uint32_t i[3] = {p.indices[t], p.indices[t + 1], p.indices[t + 2]};
|
|
|
|
|
const V3 p0(p.positions[i[0]].x, p.positions[i[0]].y, p.positions[i[0]].z);
|
|
|
|
|
const V3 e1 = V3(p.positions[i[1]].x, p.positions[i[1]].y, p.positions[i[1]].z) - p0;
|
|
|
|
|
const V3 e2 = V3(p.positions[i[2]].x, p.positions[i[2]].y, p.positions[i[2]].z) - p0;
|
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 float du1 = uv[i[1]].x - uv[i[0]].x, dv1 = uv[i[1]].y - uv[i[0]].y;
|
|
|
|
|
const float du2 = uv[i[2]].x - uv[i[0]].x, dv2 = uv[i[2]].y - uv[i[0]].y;
|
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 float determinant = du1 * dv2 - du2 * dv1;
|
|
|
|
|
if (std::abs(determinant) < 1e-12f) continue; // degenerate UVs carry no direction
|
|
|
|
|
const float f = 1.0f / determinant;
|
|
|
|
|
const V3 tangent = (e1 * dv2 - e2 * dv1) * f;
|
|
|
|
|
const V3 bitangent = (e2 * du1 - e1 * du2) * f;
|
|
|
|
|
for (std::uint32_t v : i) {
|
|
|
|
|
tangentSum[v] += tangent;
|
|
|
|
|
bitangentSum[v] += bitangent;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
p.tangents.resize(n);
|
|
|
|
|
for (std::size_t v = 0; v < n; ++v) {
|
|
|
|
|
const V3 normal(p.normals[v].x, p.normals[v].y, p.normals[v].z);
|
|
|
|
|
V3 tangent = tangentSum[v] - normal * V3::Dot(normal, tangentSum[v]);
|
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
|
|
|
// Written so that NaN takes these branches too.
|
|
|
|
|
if (!(tangent.LengthSq() >= 1e-20f)) {
|
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
|
|
|
// No usable UV gradient: any direction perpendicular to the
|
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
|
|
|
// normal keeps the frame valid, and any at all where the
|
|
|
|
|
// normal is zero.
|
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
|
|
|
tangent = V3::Cross(std::abs(normal.x) < 0.9f ? V3(1, 0, 0) : V3(0, 1, 0), normal);
|
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 (!(tangent.LengthSq() >= 1e-20f)) tangent = V3(1, 0, 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
|
|
|
}
|
|
|
|
|
tangent.Normalize();
|
|
|
|
|
const float sign = V3::Dot(V3::Cross(normal, tangent), bitangentSum[v]) < 0 ? 1.0f : -1.0f;
|
|
|
|
|
p.tangents[v] = Vector<float, 4, 4>(tangent.x, tangent.y, tangent.z, sign);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// JOINTS_n and WEIGHTS_n, which come in pairs from n = 0 up, as up to
|
|
|
|
|
// four influences a vertex: the heaviest, a joint named twice counted
|
|
|
|
|
// once with its weights added, and the weights made to sum to 1.
|
|
|
|
|
std::expected<void, std::string> ReadInfluences(Context& ctx, const Json& attributes, std::size_t n, const std::string& where, GlbPrimitive& p) {
|
|
|
|
|
std::size_t sets = 0;
|
|
|
|
|
while (Find(attributes, std::format("JOINTS_{}", sets).c_str()) != nullptr
|
|
|
|
|
&& Find(attributes, std::format("WEIGHTS_{}", sets).c_str()) != nullptr) {
|
|
|
|
|
++sets;
|
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
|
|
|
// Every JOINTS_ and WEIGHTS_ is one of those pairs: no half of one,
|
|
|
|
|
// and no gap before one.
|
|
|
|
|
std::size_t named = 0;
|
|
|
|
|
for (auto it = attributes.begin(); it != attributes.end(); ++it) {
|
|
|
|
|
named += it.key().starts_with("JOINTS_") || it.key().starts_with("WEIGHTS_");
|
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
|
|
|
if (named != 2 * sets) return Error(std::format("{}'s JOINTS_n and WEIGHTS_n don't pair up from n = 0", where));
|
|
|
|
|
if (sets == 0) 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
|
|
|
|
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::pair<Accessor, Accessor>> influences;
|
|
|
|
|
for (std::size_t set = 0; set < sets; ++set) {
|
|
|
|
|
const std::string joints = std::format("JOINTS_{}", set);
|
|
|
|
|
const std::string weights = std::format("WEIGHTS_{}", set);
|
|
|
|
|
auto j = Attribute(ctx, attributes, joints.c_str(), 4, {kUnsignedByte, kUnsignedShort}, n, where);
|
|
|
|
|
if (!j) return std::unexpected(j.error());
|
|
|
|
|
auto w = Attribute(ctx, attributes, weights.c_str(), 4, {kFloat, kUnsignedByte, kUnsignedShort}, n, where);
|
|
|
|
|
if (!w) return std::unexpected(w.error());
|
|
|
|
|
influences.emplace_back(std::move(*j), std::move(*w));
|
|
|
|
|
}
|
|
|
|
|
// Each set is read for every vertex, so each beyond the first is
|
|
|
|
|
// paid for as if it were kept too.
|
|
|
|
|
if (!Spend(ctx, static_cast<std::uint64_t>(n) * (sets - 1), sizeof(std::array<std::uint16_t, 4>) + sizeof(std::array<float, 4>))
|
|
|
|
|
|| !Allocate(ctx, p.joints, n) || !Allocate(ctx, p.weights, n)) {
|
|
|
|
|
return OverLimit(ctx, where);
|
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
|
|
|
using Influence = std::pair<std::uint16_t, float>;
|
|
|
|
|
std::vector<Influence> heaviest;
|
|
|
|
|
for (std::size_t v = 0; v < n; ++v) {
|
|
|
|
|
heaviest.clear();
|
|
|
|
|
for (const auto& [joints, weights] : influences) {
|
|
|
|
|
for (std::uint32_t c = 0; c < 4; ++c) {
|
|
|
|
|
const float weight = weights.Float(v, c);
|
|
|
|
|
// A weight of nothing: zero, negative, or not a number.
|
|
|
|
|
if (!(weight > 0.0f) || !std::isfinite(weight)) continue;
|
|
|
|
|
const auto joint = static_cast<std::uint16_t>(joints.Unsigned(v, c));
|
|
|
|
|
const auto same = std::ranges::find(heaviest, joint, &Influence::first);
|
|
|
|
|
if (same != heaviest.end()) same->second += weight;
|
|
|
|
|
else heaviest.emplace_back(joint, weight);
|
|
|
|
|
}
|
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
|
|
|
if (heaviest.empty()) {
|
|
|
|
|
p.joints[v][0] = static_cast<std::uint16_t>(influences[0].first.Unsigned(v, 0));
|
|
|
|
|
p.weights[v][0] = 1.0f;
|
|
|
|
|
continue;
|
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
|
|
|
if (heaviest.size() > 4) {
|
|
|
|
|
std::ranges::stable_sort(heaviest, std::ranges::greater(), &Influence::second);
|
|
|
|
|
heaviest.resize(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
|
|
|
}
|
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
|
|
|
float total = 0;
|
|
|
|
|
for (const auto& [joint, weight] : heaviest) total += weight;
|
|
|
|
|
for (std::size_t k = 0; k < heaviest.size(); ++k) {
|
|
|
|
|
p.joints[v][k] = heaviest[k].first;
|
|
|
|
|
p.weights[v][k] = heaviest[k].second / total;
|
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
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::expected<GlbPrimitive, std::string> ReadPrimitive(Context& ctx, const Json& primitive, const std::vector<GlbMaterial>& materials,
|
|
|
|
|
const std::string& where) {
|
|
|
|
|
GlbPrimitive p;
|
|
|
|
|
p.material = Index(primitive, "material");
|
|
|
|
|
const std::int64_t mode = Integer(primitive, "mode", kTriangles);
|
|
|
|
|
if (mode < 0 || mode > kTriangleFan) return Error(std::format("{} has mode {}, which glTF doesn't define", where, mode));
|
|
|
|
|
// Points and lines: no triangles to read.
|
|
|
|
|
if (mode < kTriangles) return p;
|
|
|
|
|
const Json* attributes = Find(primitive, "attributes");
|
|
|
|
|
if (attributes == nullptr || Find(*attributes, "POSITION") == nullptr) {
|
|
|
|
|
return Error(std::format("{} has no POSITION", where));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const auto positions = Attribute(ctx, *attributes, "POSITION", 3, {kFloat}, std::nullopt, where);
|
|
|
|
|
if (!positions) return std::unexpected(positions.error());
|
|
|
|
|
const std::size_t n = positions->count;
|
|
|
|
|
if (n > std::numeric_limits<std::uint32_t>::max()) return Error(std::format("{} has more vertices than 32-bit indices reach", where));
|
|
|
|
|
if (!Allocate(ctx, p.positions, n)) return OverLimit(ctx, where);
|
|
|
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
|
|
|
for (std::uint32_t c = 0; c < 3; ++c) p.positions[i].v[c] = positions->Float(i, c);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (auto read = Stream(ctx, *attributes, "NORMAL", 3, {kFloat}, n, where, p.normals); !read) return std::unexpected(read.error());
|
|
|
|
|
for (const auto& [name, set] : { std::pair { "TEXCOORD_0", &p.uv0 }, std::pair { "TEXCOORD_1", &p.uv1 } }) {
|
|
|
|
|
if (auto read = Stream(ctx, *attributes, name, 2, {kFloat, kUnsignedByte, kUnsignedShort}, n, where, *set); !read) {
|
|
|
|
|
return std::unexpected(read.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
|
|
|
if (auto read = Stream(ctx, *attributes, "TANGENT", 4, {kFloat}, n, where, p.tangents); !read) return std::unexpected(read.error());
|
|
|
|
|
if (auto read = ReadInfluences(ctx, *attributes, n, where, p); !read) return std::unexpected(read.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
|
|
|
// The vertices in the order the file lists them, then as a list of
|
|
|
|
|
// triangles.
|
|
|
|
|
std::vector<std::uint32_t> listed;
|
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
|
|
|
if (Find(primitive, "indices") != nullptr) {
|
|
|
|
|
const auto indices = ReadAccessor(ctx, Integer(primitive, "indices", -1));
|
|
|
|
|
if (!indices) return std::unexpected(std::format("{} (indices of {})", indices.error(), where));
|
|
|
|
|
if (indices->components != 1 || (indices->componentType != kUnsignedByte && indices->componentType != kUnsignedShort
|
|
|
|
|
&& indices->componentType != kUnsignedInt)) {
|
|
|
|
|
return Error(std::format("indices of {} have the wrong type", where));
|
|
|
|
|
}
|
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 (!Allocate(ctx, listed, indices->count)) return OverLimit(ctx, where);
|
|
|
|
|
for (std::size_t i = 0; i < indices->count; ++i) listed[i] = indices->Unsigned(i, 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
|
|
|
} else {
|
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 (!Allocate(ctx, listed, n)) return OverLimit(ctx, where);
|
|
|
|
|
std::iota(listed.begin(), listed.end(), 0u);
|
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
|
|
|
if (std::ranges::any_of(listed, [n](std::uint32_t i) { return i >= n; })) {
|
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 Error(std::format("{} indexes past its {} vertices", where, n));
|
|
|
|
|
}
|
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 (mode == kTriangles) {
|
|
|
|
|
if (listed.size() % 3 != 0) {
|
|
|
|
|
return Error(std::format("{} has {} indices, not whole triangles", where, listed.size()));
|
|
|
|
|
}
|
|
|
|
|
p.indices = std::move(listed);
|
|
|
|
|
} else {
|
|
|
|
|
// A strip's or a fan's triangles as glTF numbers them: a strip's
|
|
|
|
|
// odd ones swap their last two corners to keep the winding, and
|
|
|
|
|
// a fan's all end at its first vertex.
|
|
|
|
|
const std::size_t triangles = listed.size() >= 3 ? listed.size() - 2 : 0;
|
|
|
|
|
if (!Allocate(ctx, p.indices, triangles * 3)) return OverLimit(ctx, where);
|
|
|
|
|
for (std::size_t t = 0; t < triangles; ++t) {
|
|
|
|
|
const std::array<std::size_t, 3> corners = mode == kTriangleStrip ? std::array<std::size_t, 3>{t, t + 1 + t % 2, t + 2 - t % 2}
|
|
|
|
|
: std::array<std::size_t, 3>{t + 1, t + 2, 0};
|
|
|
|
|
for (std::size_t c = 0; c < 3; ++c) p.indices[t * 3 + c] = listed[corners[c]];
|
|
|
|
|
}
|
|
|
|
|
}
|
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 Json& targets = Array(primitive, "targets");
|
|
|
|
|
p.targets.resize(targets.size());
|
|
|
|
|
for (std::size_t t = 0; t < targets.size(); ++t) {
|
|
|
|
|
const std::string target = std::format("{} morph target {}", where, t);
|
|
|
|
|
GlbMorphTarget& morph = p.targets[t];
|
|
|
|
|
for (const auto& [name, stream] : { std::pair { "POSITION", &morph.positions }, std::pair { "NORMAL", &morph.normals },
|
|
|
|
|
std::pair { "TANGENT", &morph.tangents } }) {
|
|
|
|
|
if (auto read = Stream(ctx, targets[t], name, 3, {kFloat}, n, target, *stream); !read) return std::unexpected(read.error());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tangents the file leaves out, from the UV set its normal map is
|
|
|
|
|
// sampled with, as glTF asks.
|
|
|
|
|
if (p.tangents.empty() && !p.normals.empty()) {
|
|
|
|
|
std::uint32_t set = 0;
|
|
|
|
|
if (p.material >= 0 && static_cast<std::size_t>(p.material) < materials.size()) {
|
|
|
|
|
const GlbTextureRef& normalTexture = materials[static_cast<std::size_t>(p.material)].normalTexture;
|
|
|
|
|
if (normalTexture.texture != -1) set = normalTexture.texCoord;
|
|
|
|
|
}
|
|
|
|
|
const std::vector<Vector<float, 2, 2>>* uv = set == 0 ? &p.uv0 : set == 1 ? &p.uv1 : nullptr;
|
|
|
|
|
if (uv != nullptr && !uv->empty()) {
|
|
|
|
|
if (!Spend(ctx, n, sizeof(Vector<float, 4, 4>))) return OverLimit(ctx, where);
|
|
|
|
|
ComputeTangents(p, *uv);
|
|
|
|
|
}
|
|
|
|
|
}
|
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 p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GlbTextureRef TextureRef(const Json& material, const char* key, const char* scaleKey) {
|
|
|
|
|
GlbTextureRef ref;
|
|
|
|
|
if (const Json* info = Find(material, key)) {
|
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
|
|
|
ref.texture = Index(*info, "index");
|
|
|
|
|
const std::int64_t texCoord = Integer(*info, "texCoord", ref.texCoord);
|
|
|
|
|
ref.texCoord = texCoord == 0 || texCoord == 1 ? static_cast<std::uint32_t>(texCoord) : kBadTexCoord;
|
|
|
|
|
if (scaleKey != nullptr) ref.scale = Number(*info, scaleKey, ref.scale);
|
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 ref;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// Core glTF's image formats, PNG and JPEG, told by their first bytes.
|
|
|
|
|
// stb_image reads more (GIF, PSD, TGA, ...), which have no business in a
|
|
|
|
|
// GLB, so those don't reach it.
|
|
|
|
|
bool PngOrJpeg(std::span<const std::byte> bytes) {
|
|
|
|
|
constexpr std::array<std::uint8_t, 8> kPng = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
|
|
|
|
constexpr std::array<std::uint8_t, 3> kJpeg = {0xFF, 0xD8, 0xFF};
|
|
|
|
|
auto startsWith = [bytes](std::span<const std::uint8_t> magic) {
|
|
|
|
|
return bytes.size() >= magic.size() && std::memcmp(bytes.data(), magic.data(), magic.size()) == 0;
|
|
|
|
|
};
|
|
|
|
|
return startsWith(kPng) || startsWith(kJpeg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A sampler's enum, which must be one of `allowed`: `fallback` when the
|
|
|
|
|
// file leaves it out, nullopt when it is anything else.
|
|
|
|
|
std::optional<std::uint32_t> Enum(const Json& sampler, const char* key, std::uint32_t fallback, std::initializer_list<std::uint32_t> allowed) {
|
|
|
|
|
if (Find(sampler, key) == nullptr) return fallback;
|
|
|
|
|
const std::int64_t value = Integer(sampler, key, -1);
|
|
|
|
|
if (std::ranges::find(allowed, value) == allowed.end()) return std::nullopt;
|
|
|
|
|
return static_cast<std::uint32_t>(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Each index points at an element that exists, and what the interface
|
|
|
|
|
// promises of them holds, so callers can index without checking.
|
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::expected<void, std::string> CheckReferences(const GlbAsset& a) {
|
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
|
|
|
auto valid = [](std::int64_t index, std::size_t size) { return index == -1 || (index >= 0 && static_cast<std::uint64_t>(index) < size); };
|
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
|
|
|
for (std::size_t i = 0; i < a.nodes.size(); ++i) {
|
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 GlbNode& node = a.nodes[i];
|
|
|
|
|
if (!valid(node.mesh, a.meshes.size())) return Error(std::format("node {} uses a mesh that doesn't exist", i));
|
|
|
|
|
if (!valid(node.skin, a.skins.size())) return Error(std::format("node {} uses a skin that doesn't exist", i));
|
|
|
|
|
if (!valid(node.camera, a.cameras.size())) return Error(std::format("node {} uses a camera that doesn't exist", i));
|
|
|
|
|
if (!valid(node.light, a.lights.size())) return Error(std::format("node {} uses a light that doesn't exist", i));
|
|
|
|
|
if (node.skin != -1 && node.mesh == -1) return Error(std::format("node {} has a skin and no mesh to skin", i));
|
|
|
|
|
if (!node.weights.empty() && (node.mesh == -1 || node.weights.size() != a.meshes[static_cast<std::size_t>(node.mesh)].weights.size())) {
|
|
|
|
|
return Error(std::format("node {} has {} morph weights, not one for each of its mesh's targets", i, node.weights.size()));
|
|
|
|
|
}
|
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
|
|
|
for (const GlbMaterial& m : a.materials) {
|
|
|
|
|
for (const GlbTextureRef* ref : {&m.baseColorTexture, &m.metallicRoughnessTexture, &m.normalTexture, &m.occlusionTexture, &m.emissiveTexture,
|
|
|
|
|
&m.transmissionTexture}) {
|
|
|
|
|
if (!valid(ref->texture, a.textures.size())) return Error(std::format("material '{}' uses a texture that doesn't exist", m.name));
|
|
|
|
|
if (ref->texture != -1 && ref->texCoord == kBadTexCoord) {
|
|
|
|
|
return Error(std::format("material '{}' samples a UV set other than TEXCOORD_0 and TEXCOORD_1, the ones the loader reads", m.name));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// For each mesh, once however many nodes draw it: its highest joint
|
|
|
|
|
// index, and whether every primitive with vertices has joints.
|
|
|
|
|
std::vector<std::int64_t> highestJoint(a.meshes.size(), -1);
|
|
|
|
|
std::vector<bool> skinnable(a.meshes.size(), true);
|
|
|
|
|
for (std::size_t m = 0; m < a.meshes.size(); ++m) {
|
|
|
|
|
for (std::size_t p = 0; p < a.meshes[m].primitives.size(); ++p) {
|
|
|
|
|
const GlbPrimitive& primitive = a.meshes[m].primitives[p];
|
|
|
|
|
if (!valid(primitive.material, a.materials.size())) {
|
|
|
|
|
return Error(std::format("mesh '{}' uses a material that doesn't exist", a.meshes[m].name));
|
|
|
|
|
}
|
|
|
|
|
if (primitive.positions.empty()) continue; // points, lines, or nothing to draw
|
|
|
|
|
if (primitive.material != -1) {
|
|
|
|
|
const GlbMaterial& material = a.materials[static_cast<std::size_t>(primitive.material)];
|
|
|
|
|
for (const GlbTextureRef* ref : {&material.baseColorTexture, &material.metallicRoughnessTexture, &material.normalTexture,
|
|
|
|
|
&material.occlusionTexture, &material.emissiveTexture, &material.transmissionTexture}) {
|
|
|
|
|
if (ref->texture != -1 && (ref->texCoord == 0 ? primitive.uv0 : primitive.uv1).empty()) {
|
|
|
|
|
return Error(std::format("material '{}' samples TEXCOORD_{}, which mesh {} primitive {} doesn't have", material.name,
|
|
|
|
|
ref->texCoord, m, p));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
skinnable[m] = skinnable[m] && !primitive.joints.empty();
|
|
|
|
|
for (const auto& joints : primitive.joints) {
|
|
|
|
|
for (std::uint16_t joint : joints) highestJoint[m] = std::max<std::int64_t>(highestJoint[m], joint);
|
|
|
|
|
}
|
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 vertex's joint indices pick from the joint list of the skin on
|
|
|
|
|
// the node that draws its mesh.
|
|
|
|
|
for (const GlbNode& node : a.nodes) {
|
|
|
|
|
if (node.mesh == -1 || node.skin == -1) continue;
|
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::size_t mesh = static_cast<std::size_t>(node.mesh);
|
|
|
|
|
if (!skinnable[mesh]) {
|
|
|
|
|
return Error(std::format("node '{}' skins mesh '{}', which has a primitive without JOINTS_0 and WEIGHTS_0", node.name, a.meshes[mesh].name));
|
|
|
|
|
}
|
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::size_t jointCount = a.skins[static_cast<std::size_t>(node.skin)].joints.size();
|
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 (highestJoint[mesh] >= static_cast<std::int64_t>(jointCount)) {
|
|
|
|
|
return Error(std::format("node '{}' skins mesh '{}' with joint indices beyond its skin's {} joints", node.name, a.meshes[mesh].name,
|
|
|
|
|
jointCount));
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const GlbSkin& skin : a.skins) {
|
|
|
|
|
if (!valid(skin.skeleton, a.nodes.size())) return Error(std::format("skin '{}' has a skeleton node that doesn't exist", skin.name));
|
|
|
|
|
}
|
|
|
|
|
for (std::size_t i = 0; i < a.textures.size(); ++i) {
|
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 GlbTexture& texture = a.textures[i];
|
|
|
|
|
if (!valid(texture.image, a.images.size())) return Error(std::format("texture {} uses an image that doesn't exist", i));
|
|
|
|
|
if (texture.image != -1 && a.images[static_cast<std::size_t>(texture.image)].pixels.empty()) {
|
|
|
|
|
return Error(std::format("texture {} uses image {}, which is neither a PNG nor a JPEG", i, texture.image));
|
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
|
|
|
if (!valid(texture.sampler, a.samplers.size())) return Error(std::format("texture {} uses a sampler that doesn't exist", i));
|
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 {};
|
|
|
|
|
}
|
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 12-byte header: the length the file says it has, no less than the
|
|
|
|
|
// header's own.
|
|
|
|
|
std::expected<std::uint32_t, std::string> Header(std::span<const std::byte> glb) {
|
|
|
|
|
if (glb.size() < 12) return Error("too short to be a GLB");
|
|
|
|
|
if (U32(glb, 0) != kMagic) return Error("bad magic, not a .glb");
|
|
|
|
|
if (U32(glb, 4) != 2) return Error(std::format("container version {}, only 2 is supported", U32(glb, 4)));
|
|
|
|
|
if (U32(glb, 8) < 12) return Error(std::format("the header says the file has {} bytes, fewer than the header", U32(glb, 8)));
|
|
|
|
|
return U32(glb, 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
|
|
|
}
|
|
|
|
|
|
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> GlbAsset::Load(const fs::path& path, std::optional<std::uint64_t> decodeLimit) {
|
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::error_code 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
|
|
|
const fs::file_status status = fs::status(path, error);
|
|
|
|
|
if (error) return Error(std::format("can't read {}: {}", path.string(), error.message()));
|
|
|
|
|
// A directory or a pipe opens too, with no size to read.
|
|
|
|
|
if (!fs::is_regular_file(status)) return Error(std::format("{} isn't a file", path.string()));
|
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::uintmax_t size = fs::file_size(path, error);
|
|
|
|
|
if (error) return Error(std::format("can't read {}: {}", path.string(), error.message()));
|
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
|
|
|
errno = 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
|
|
|
std::ifstream file(path, std::ios::binary);
|
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 (!file) {
|
|
|
|
|
return Error(std::format("can't open {}: {}", path.string(), errno != 0 ? std::generic_category().message(errno) : "reason unknown"));
|
|
|
|
|
}
|
|
|
|
|
// The header first. A file that isn't a GLB is turned down unread, and
|
|
|
|
|
// one that is says how much of it to read, which can't pass 4 GiB
|
|
|
|
|
// however large the file.
|
|
|
|
|
std::array<std::byte, 12> header;
|
|
|
|
|
if (size < header.size() || !file.read(reinterpret_cast<char*>(header.data()), header.size())) {
|
|
|
|
|
return Error(std::format("too short to be a GLB ({})", path.string()));
|
|
|
|
|
}
|
|
|
|
|
const auto length = Header(header);
|
|
|
|
|
if (!length) return std::unexpected(std::format("{} ({})", length.error(), path.string()));
|
|
|
|
|
if (*length > size) return Error(std::format("truncated: the header says {} bytes, there are {} ({})", *length, size, path.string()));
|
|
|
|
|
std::vector<std::byte> bytes(*length);
|
|
|
|
|
std::memcpy(bytes.data(), header.data(), header.size());
|
|
|
|
|
if (!file.read(reinterpret_cast<char*>(bytes.data() + header.size()), static_cast<std::streamsize>(bytes.size() - header.size()))) {
|
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 Error(std::format("can't read {}", path.string()));
|
|
|
|
|
}
|
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
|
|
|
auto asset = Load(std::span<const std::byte>(bytes), decodeLimit);
|
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
|
|
|
if (!asset) return std::unexpected(std::format("{} ({})", asset.error(), path.string()));
|
|
|
|
|
return asset;
|
|
|
|
|
}
|
|
|
|
|
|
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> GlbAsset::Load(std::span<const std::byte> glb, std::optional<std::uint64_t> decodeLimit) {
|
|
|
|
|
const auto header = Header(glb);
|
|
|
|
|
if (!header) return std::unexpected(header.error());
|
|
|
|
|
const std::uint32_t length = *header;
|
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
|
|
|
if (length > glb.size()) return Error(std::format("truncated: the header says {} bytes, there are {}", length, glb.size()));
|
|
|
|
|
|
|
|
|
|
// Chunks follow the header back to back. Unknown chunk types are skipped,
|
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
|
|
|
// as the spec asks. The arithmetic is in 32 bits, like the header's, and
|
|
|
|
|
// ordered so that it can't wrap round.
|
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::span<const std::byte> jsonChunk;
|
|
|
|
|
std::span<const std::byte> binChunk;
|
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 (std::uint32_t offset = 12; length - offset >= 8;) {
|
|
|
|
|
const std::uint32_t chunkLength = U32(glb, offset);
|
|
|
|
|
const std::uint32_t chunkType = U32(glb, offset + 4);
|
|
|
|
|
// Not offset + 8 + chunkLength > length, which wraps round.
|
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
|
|
|
if (chunkLength > length - offset - 8) return Error("truncated chunk");
|
|
|
|
|
const std::span<const std::byte> chunk = glb.subspan(offset + 8, chunkLength);
|
|
|
|
|
if (chunkType == kChunkJson && jsonChunk.empty()) jsonChunk = chunk;
|
|
|
|
|
else if (chunkType == kChunkBin && binChunk.empty()) binChunk = chunk;
|
|
|
|
|
offset += 8 + chunkLength;
|
|
|
|
|
}
|
|
|
|
|
if (jsonChunk.empty()) return Error("no JSON chunk");
|
|
|
|
|
|
|
|
|
|
GlbAsset asset;
|
|
|
|
|
asset.json.assign(reinterpret_cast<const char*>(jsonChunk.data()), jsonChunk.size());
|
|
|
|
|
const Json root = Json::parse(asset.json, nullptr, false);
|
|
|
|
|
if (root.is_discarded() || !root.is_object()) return Error("the JSON chunk doesn't parse");
|
|
|
|
|
const Json* info = Find(root, "asset");
|
|
|
|
|
if (info == nullptr || !String(*info, "version").starts_with("2")) return Error("not glTF 2.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
|
|
|
for (const Json& name : Array(root, "extensionsRequired")) {
|
|
|
|
|
if (!name.is_string()) return Error("extensionsRequired names something that isn't an extension");
|
|
|
|
|
if (std::ranges::find(kImplemented, name.get_ref<const std::string&>()) == kImplemented.end()) {
|
|
|
|
|
return Error(std::format("the file requires {}, which this loader doesn't implement", name.get_ref<const std::string&>()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const std::uint64_t limit = decodeLimit.value_or(kDecodeFloor + kDecodePerByte * length);
|
|
|
|
|
Context ctx{.root = root, .bin = binChunk, .limit = limit, .budget = 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
|
|
|
|
|
|
|
|
// Nodes, then the parent links their children lists imply.
|
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 Json& nodes = Array(root, "nodes");
|
|
|
|
|
for (std::size_t i = 0; i < nodes.size(); ++i) {
|
|
|
|
|
const Json& node = nodes[i];
|
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
|
|
|
GlbNode& n = asset.nodes.emplace_back();
|
|
|
|
|
n.name = String(node, "name");
|
|
|
|
|
for (const Json& child : Array(node, "children")) {
|
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::optional<std::uint32_t> index = Element(child, nodes.size());
|
|
|
|
|
if (!index) return Error(std::format("node {} has a child that isn't one of the {} nodes", i, nodes.size()));
|
|
|
|
|
n.children.push_back(*index);
|
|
|
|
|
}
|
|
|
|
|
Numbers(node, "translation", n.translation.v);
|
|
|
|
|
Numbers(node, "rotation", n.rotation.v);
|
|
|
|
|
Numbers(node, "scale", n.scale.v);
|
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
|
|
|
if (Array(node, "matrix").size() == 16) {
|
|
|
|
|
std::array<float, 16> m = {};
|
|
|
|
|
Numbers(node, "matrix", m);
|
|
|
|
|
n.hasMatrix = true;
|
|
|
|
|
n.matrix = MatrixRowMajor<float, 4, 4, 1>(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7],
|
|
|
|
|
m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15]);
|
|
|
|
|
}
|
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
|
|
|
n.mesh = Index(node, "mesh");
|
|
|
|
|
n.skin = Index(node, "skin");
|
|
|
|
|
n.camera = Index(node, "camera");
|
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
|
|
|
if (const Json* punctual = Extension(node, "KHR_lights_punctual")) {
|
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
|
|
|
n.light = Index(*punctual, "light");
|
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
|
|
|
for (const Json& weight : Array(node, "weights")) n.weights.push_back(weight.is_number() ? weight.get<float>() : 0.0f);
|
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
|
|
|
n.extras = Extras(node);
|
|
|
|
|
}
|
|
|
|
|
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
|
|
|
|
|
for (std::uint32_t child : asset.nodes[i].children) {
|
|
|
|
|
if (asset.nodes[child].parent != -1) return Error(std::format("node {} has two parents", child));
|
|
|
|
|
asset.nodes[child].parent = static_cast<std::int32_t>(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Every node reaches a root, or the children lists form a cycle. Each
|
|
|
|
|
// walk up stops at a node an earlier one found reaching a root, so every
|
|
|
|
|
// node is stepped through once, however deep the hierarchy.
|
|
|
|
|
enum class Seen : std::uint8_t { No, OnThisWalk, ReachesRoot };
|
|
|
|
|
std::vector<Seen> seen(asset.nodes.size(), Seen::No);
|
|
|
|
|
std::vector<std::size_t> walked;
|
|
|
|
|
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
|
|
|
|
|
walked.clear();
|
|
|
|
|
for (std::int32_t at = static_cast<std::int32_t>(i); at != -1 && seen[static_cast<std::size_t>(at)] != Seen::ReachesRoot;
|
|
|
|
|
at = asset.nodes[static_cast<std::size_t>(at)].parent) {
|
|
|
|
|
if (seen[static_cast<std::size_t>(at)] == Seen::OnThisWalk) return Error("the node hierarchy has a cycle");
|
|
|
|
|
seen[static_cast<std::size_t>(at)] = Seen::OnThisWalk;
|
|
|
|
|
walked.push_back(static_cast<std::size_t>(at));
|
|
|
|
|
}
|
|
|
|
|
for (std::size_t node : walked) seen[node] = Seen::ReachesRoot;
|
|
|
|
|
}
|
|
|
|
|
|
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 default scene, or the first when the file names none.
|
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 Json& scenes = Array(root, "scenes");
|
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::int64_t scene = Index(root, "scene");
|
|
|
|
|
if (scene == -1 && !scenes.empty()) scene = 0;
|
|
|
|
|
if (scene != -1) {
|
|
|
|
|
if (scene < 0 || static_cast<std::uint64_t>(scene) >= scenes.size()) {
|
|
|
|
|
return Error(std::format("the default scene isn't one of the {} scenes", scenes.size()));
|
|
|
|
|
}
|
|
|
|
|
std::vector<bool> isRoot(asset.nodes.size(), false);
|
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
|
|
|
for (const Json& node : Array(scenes[static_cast<std::size_t>(scene)], "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
|
|
|
const std::optional<std::uint32_t> index = Element(node, asset.nodes.size());
|
|
|
|
|
if (!index) return Error("the default scene names a node that doesn't exist");
|
|
|
|
|
if (asset.nodes[*index].parent != -1) return Error(std::format("the default scene has node {}, a child, as a root", *index));
|
|
|
|
|
if (isRoot[*index]) return Error(std::format("the default scene names node {} twice", *index));
|
|
|
|
|
isRoot[*index] = true;
|
|
|
|
|
asset.sceneRoots.push_back(*index);
|
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
|
|
|
}
|
|
|
|
|
asset.extras = Extras(scenes[static_cast<std::size_t>(scene)]);
|
|
|
|
|
} else {
|
|
|
|
|
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
|
|
|
|
|
if (asset.nodes[i].parent == -1) asset.sceneRoots.push_back(static_cast<std::uint32_t>(i));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// Materials before meshes: a primitive's tangents depend on its
|
|
|
|
|
// material's normal texture.
|
|
|
|
|
for (const Json& material : Array(root, "materials")) {
|
|
|
|
|
GlbMaterial& m = asset.materials.emplace_back();
|
|
|
|
|
m.name = String(material, "name");
|
|
|
|
|
if (const Json* pbr = Find(material, "pbrMetallicRoughness")) {
|
|
|
|
|
Numbers(*pbr, "baseColorFactor", m.baseColorFactor);
|
|
|
|
|
m.metallicFactor = Number(*pbr, "metallicFactor", m.metallicFactor);
|
|
|
|
|
m.roughnessFactor = Number(*pbr, "roughnessFactor", m.roughnessFactor);
|
|
|
|
|
m.baseColorTexture = TextureRef(*pbr, "baseColorTexture", nullptr);
|
|
|
|
|
m.metallicRoughnessTexture = TextureRef(*pbr, "metallicRoughnessTexture", nullptr);
|
|
|
|
|
}
|
|
|
|
|
m.normalTexture = TextureRef(material, "normalTexture", "scale");
|
|
|
|
|
m.occlusionTexture = TextureRef(material, "occlusionTexture", "strength");
|
|
|
|
|
m.emissiveTexture = TextureRef(material, "emissiveTexture", nullptr);
|
|
|
|
|
Numbers(material, "emissiveFactor", m.emissiveFactor);
|
|
|
|
|
const std::string alphaMode = String(material, "alphaMode");
|
|
|
|
|
m.alphaMode = alphaMode == "MASK" ? GlbAlphaMode::Mask : alphaMode == "BLEND" ? GlbAlphaMode::Blend : GlbAlphaMode::Opaque;
|
|
|
|
|
m.alphaCutoff = Number(material, "alphaCutoff", m.alphaCutoff);
|
|
|
|
|
m.doubleSided = Boolean(material, "doubleSided", m.doubleSided);
|
|
|
|
|
if (const Json* strength = Extension(material, "KHR_materials_emissive_strength")) {
|
|
|
|
|
m.emissiveStrength = Number(*strength, "emissiveStrength", m.emissiveStrength);
|
|
|
|
|
}
|
|
|
|
|
if (const Json* transmission = Extension(material, "KHR_materials_transmission")) {
|
|
|
|
|
m.transmissionFactor = Number(*transmission, "transmissionFactor", m.transmissionFactor);
|
|
|
|
|
m.transmissionTexture = TextureRef(*transmission, "transmissionTexture", nullptr);
|
|
|
|
|
}
|
|
|
|
|
if (const Json* ior = Extension(material, "KHR_materials_ior")) {
|
|
|
|
|
m.ior = Number(*ior, "ior", m.ior);
|
|
|
|
|
}
|
|
|
|
|
m.extras = Extras(material);
|
|
|
|
|
}
|
|
|
|
|
|
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 Json& meshes = Array(root, "meshes");
|
|
|
|
|
for (std::size_t m = 0; m < meshes.size(); ++m) {
|
|
|
|
|
GlbMesh& mesh = asset.meshes.emplace_back();
|
|
|
|
|
mesh.name = String(meshes[m], "name");
|
|
|
|
|
const Json& primitives = Array(meshes[m], "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
|
|
|
// glTF has every primitive of a mesh carry the same morph targets,
|
|
|
|
|
// which the mesh's weights weigh.
|
|
|
|
|
const std::size_t targets = primitives.empty() ? 0 : Array(primitives[0], "targets").size();
|
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
|
|
|
for (std::size_t p = 0; p < primitives.size(); ++p) {
|
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 (Array(primitives[p], "targets").size() != targets) {
|
|
|
|
|
return Error(std::format("mesh {}'s primitives don't have the same number of morph targets", m));
|
|
|
|
|
}
|
|
|
|
|
auto primitive = ReadPrimitive(ctx, primitives[p], asset.materials, std::format("mesh {} primitive {}", m, p));
|
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
|
|
|
if (!primitive) return std::unexpected(primitive.error());
|
|
|
|
|
mesh.primitives.push_back(std::move(*primitive));
|
|
|
|
|
}
|
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
|
|
|
mesh.weights.assign(targets, 0.0f);
|
|
|
|
|
if (Find(meshes[m], "weights") != nullptr && Array(meshes[m], "weights").size() != targets) {
|
|
|
|
|
return Error(std::format("mesh {} has {} morph weights for {} targets", m, Array(meshes[m], "weights").size(), targets));
|
|
|
|
|
}
|
|
|
|
|
Numbers(meshes[m], "weights", mesh.weights);
|
|
|
|
|
mesh.extras = Extras(meshes[m]);
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const Json& skin : Array(root, "skins")) {
|
|
|
|
|
GlbSkin& s = asset.skins.emplace_back();
|
|
|
|
|
s.name = String(skin, "name");
|
|
|
|
|
for (const Json& joint : Array(skin, "joints")) {
|
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::optional<std::uint32_t> index = Element(joint, asset.nodes.size());
|
|
|
|
|
if (!index) return Error(std::format("skin '{}' has a joint that isn't one of the {} nodes", s.name, asset.nodes.size()));
|
|
|
|
|
s.joints.push_back(*index);
|
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
|
|
|
s.skeleton = Index(skin, "skeleton");
|
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
|
|
|
if (Find(skin, "inverseBindMatrices") != nullptr) {
|
|
|
|
|
const auto ibm = ReadAccessor(ctx, Integer(skin, "inverseBindMatrices", -1));
|
|
|
|
|
if (!ibm) return std::unexpected(std::format("{} (inverseBindMatrices of skin '{}')", ibm.error(), s.name));
|
|
|
|
|
if (ibm->components != 16 || ibm->componentType != kFloat || ibm->count < s.joints.size()) {
|
|
|
|
|
return Error(std::format("inverseBindMatrices of skin '{}' aren't one float MAT4 per joint", s.name));
|
|
|
|
|
}
|
|
|
|
|
for (std::size_t j = 0; j < s.joints.size(); ++j) {
|
|
|
|
|
float m[16];
|
|
|
|
|
for (std::uint32_t c = 0; c < 16; ++c) m[c] = ibm->Float(j, c);
|
|
|
|
|
// glTF's column-major array as the rows: the row-vector form.
|
|
|
|
|
s.inverseBindMatrices.emplace_back(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7],
|
|
|
|
|
m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15]);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
s.inverseBindMatrices.assign(s.joints.size(), MatrixRowMajor<float, 4, 4, 1>::Identity());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const Json& camera : Array(root, "cameras")) {
|
|
|
|
|
GlbCamera& c = asset.cameras.emplace_back();
|
|
|
|
|
c.name = String(camera, "name");
|
|
|
|
|
c.orthographic = String(camera, "type") == "orthographic";
|
|
|
|
|
if (const Json* orthographic = Find(camera, "orthographic"); c.orthographic && orthographic != nullptr) {
|
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
|
|
|
c.xmag = Number(*orthographic, "xmag", c.xmag);
|
|
|
|
|
c.ymag = Number(*orthographic, "ymag", c.ymag);
|
|
|
|
|
c.znear = Number(*orthographic, "znear", c.znear);
|
|
|
|
|
c.zfar = Number(*orthographic, "zfar", c.zfar);
|
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
|
|
|
} else if (const Json* perspective = Find(camera, "perspective"); !c.orthographic && perspective != nullptr) {
|
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
|
|
|
c.yfov = Number(*perspective, "yfov", c.yfov);
|
|
|
|
|
c.aspectRatio = Number(*perspective, "aspectRatio", c.aspectRatio);
|
|
|
|
|
c.znear = Number(*perspective, "znear", c.znear);
|
|
|
|
|
c.zfar = Number(*perspective, "zfar", c.zfar);
|
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
|
|
|
} else {
|
|
|
|
|
return Error(std::format("camera '{}' has no {} properties", c.name, c.orthographic ? "orthographic" : "perspective"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (const Json* punctual = Extension(root, "KHR_lights_punctual")) {
|
|
|
|
|
for (const Json& light : Array(*punctual, "lights")) {
|
|
|
|
|
GlbLight& l = asset.lights.emplace_back();
|
|
|
|
|
l.name = String(light, "name");
|
|
|
|
|
const std::string type = String(light, "type");
|
|
|
|
|
if (type == "directional") l.type = GlbLightType::Directional;
|
|
|
|
|
else if (type == "point") l.type = GlbLightType::Point;
|
|
|
|
|
else if (type == "spot") l.type = GlbLightType::Spot;
|
|
|
|
|
else return Error(std::format("light '{}' is of type '{}', which KHR_lights_punctual doesn't define", l.name, type));
|
|
|
|
|
Numbers(light, "color", l.color);
|
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
|
|
|
l.intensity = Number(light, "intensity", l.intensity);
|
|
|
|
|
l.range = Number(light, "range", l.range);
|
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
|
|
|
if (const Json* spot = Find(light, "spot")) {
|
|
|
|
|
l.innerConeAngle = Number(*spot, "innerConeAngle", l.innerConeAngle);
|
|
|
|
|
l.outerConeAngle = Number(*spot, "outerConeAngle", l.outerConeAngle);
|
|
|
|
|
}
|
|
|
|
|
l.extras = Extras(light);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const Json& texture : Array(root, "textures")) {
|
|
|
|
|
asset.textures.push_back({
|
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
|
|
|
.image = Index(texture, "source"),
|
|
|
|
|
.sampler = Index(texture, "sampler"),
|
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 Json& samplers = Array(root, "samplers");
|
|
|
|
|
for (std::size_t i = 0; i < samplers.size(); ++i) {
|
|
|
|
|
GlbSampler& s = asset.samplers.emplace_back();
|
|
|
|
|
const auto magFilter = Enum(samplers[i], "magFilter", s.magFilter, {9728, 9729});
|
|
|
|
|
const auto minFilter = Enum(samplers[i], "minFilter", s.minFilter, {9728, 9729, 9984, 9985, 9986, 9987});
|
|
|
|
|
const auto wrapS = Enum(samplers[i], "wrapS", s.wrapS, {33071, 33648, 10497});
|
|
|
|
|
const auto wrapT = Enum(samplers[i], "wrapT", s.wrapT, {33071, 33648, 10497});
|
|
|
|
|
if (!magFilter || !minFilter || !wrapS || !wrapT) return Error(std::format("sampler {} has a filter or wrap mode glTF doesn't define", i));
|
|
|
|
|
s = {.magFilter = *magFilter, .minFilter = *minFilter, .wrapS = *wrapS, .wrapT = *wrapT};
|
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 Json& images = Array(root, "images");
|
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 image that decoded each bufferView first: another naming it is
|
|
|
|
|
// copied rather than decoded again.
|
|
|
|
|
std::unordered_map<std::int64_t, std::size_t> decodedFrom;
|
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
|
|
|
for (std::size_t i = 0; i < images.size(); ++i) {
|
|
|
|
|
if (Find(images[i], "uri") != nullptr) {
|
|
|
|
|
return Error(std::format("image {} is an external file or data URI, which a GLB loader doesn't read", i));
|
|
|
|
|
}
|
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::int64_t view = Integer(images[i], "bufferView", -1);
|
|
|
|
|
const auto bytes = ViewBytes(ctx, view, nullptr);
|
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
|
|
|
if (!bytes) return std::unexpected(std::format("{} (image {})", bytes.error(), i));
|
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
|
|
|
Image& image = asset.images.emplace_back();
|
|
|
|
|
// An extension's format (EXT_texture_webp's WebP, KHR_texture_basisu's
|
|
|
|
|
// KTX2, ...) stays undecoded; CheckReferences turns down a texture
|
|
|
|
|
// that has one as its image.
|
|
|
|
|
if (!PngOrJpeg(*bytes)) continue;
|
|
|
|
|
if (const auto same = decodedFrom.find(view); same != decodedFrom.end()) {
|
|
|
|
|
const Image& decoded = asset.images[same->second];
|
|
|
|
|
if (!Spend(ctx, decoded.pixels.size(), sizeof(Pixel))) return OverLimit(ctx, std::format("image {}", i));
|
|
|
|
|
image = decoded;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
// Its size from its header, so that it is paid for before it is
|
|
|
|
|
// decoded.
|
|
|
|
|
const auto size = Image::SizeFromMemory(*bytes);
|
|
|
|
|
if (!size) return Error(std::format("image {} doesn't decode: {}", i, size.error()));
|
|
|
|
|
if (!Spend(ctx, std::uint64_t{size->first} * size->second, sizeof(Pixel))) return OverLimit(ctx, std::format("image {}", i));
|
|
|
|
|
auto decoded = Image::LoadPNGFromMemory<std::uint8_t>(*bytes);
|
|
|
|
|
if (!decoded) return Error(std::format("image {} doesn't decode: {}", i, decoded.error()));
|
|
|
|
|
image = std::move(*decoded);
|
|
|
|
|
decodedFrom.emplace(view, i);
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (auto valid = CheckReferences(asset); !valid) return std::unexpected(valid.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
|
|
|
asset.bin.assign(binChunk.begin(), binChunk.end());
|
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 asset;
|
|
|
|
|
}
|