feat(glb): a loader for binary glTF, and images decoded from memory

- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
  - nodes, with their parents;
  - every mesh's primitives: positions, normals, UV0, tangents (computed
    when absent), joints, weights (normalized), u32 indices;
  - skins with their inverse-bind matrices;
  - materials: PBR, emissive strength, transmission, ior, alpha mode;
  - images decoded to RGBA8, and samplers;
  - cameras and KHR_lights_punctual lights;
  - the raw JSON, and the extras of the scene, nodes, materials and lights.
  nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
  - bad magic, truncation, a sparse accessor;
  - a chunk, view or accessor past its bounds, checked so that nothing
    wraps round, with 32-bit size_t too;
  - a stride outside 4..252, or one its elements overlap in;
  - a reference to something that doesn't exist, or children that form a
    cycle (found stepping through each node once, however deep);
  - a path that isn't a file;
  - an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
  LoadPNG refuses images over 65535 a side instead of truncating their size
  (in its float path, past the end of the pixels).
- Tests: GLB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-20 19:07:14 +02:00
commit c04b7a331a
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
7 changed files with 27170 additions and 6 deletions

View file

@ -143,16 +143,57 @@ export namespace Crafter {
file.read(reinterpret_cast<char*>(pixels), sizeX * sizeY * sizeof(T));
}
// Decodes anything stb_image reads (PNG, JPEG, ...) to 8-bit RGBA, or
// to normalized floats when TT is a floating-point component type.
template <typename TT>
static TextureAsset<T> LoadPNG(fs::path path) {
TextureAsset<T> tex;
std::filesystem::path abs = std::filesystem::absolute(path);
int sizeX;
int sizeY;
unsigned char* data = stbi_load(abs.string().c_str(), &sizeX, &sizeY, nullptr, 4);
if (data == nullptr) {
Compression::Fatal(std::format("LoadPNG: {}: {}", abs.string(), stbi_failure_reason()));
}
if (!FitsSize(sizeX, sizeY)) {
stbi_image_free(data);
Compression::Fatal(std::format("LoadPNG: {}: {}x{} is more than a texture's 65535 a side", abs.string(), sizeX, sizeY));
}
return FromRGBA8<TT>(data, sizeX, sizeY);
}
// LoadPNG from an image that is already in memory, e.g. one embedded
// in a GLB. An image that doesn't decode comes back as an error value
// rather than a Fatal: the caller is reading a larger asset and can
// say which part was bad.
template <typename TT>
static std::expected<TextureAsset<T>, std::string> LoadPNGFromMemory(std::span<const std::byte> encoded) {
int sizeX;
int sizeY;
unsigned char* data = stbi_load_from_memory(reinterpret_cast<const stbi_uc*>(encoded.data()),
static_cast<int>(encoded.size()), &sizeX, &sizeY, nullptr, 4);
if (data == nullptr) {
return std::unexpected(std::string(stbi_failure_reason()));
}
if (!FitsSize(sizeX, sizeY)) {
stbi_image_free(data);
return std::unexpected(std::format("{}x{} is more than a texture's 65535 a side", sizeX, sizeY));
}
return FromRGBA8<TT>(data, sizeX, sizeY);
}
private:
// sizeX and sizeY hold 16 bits.
static bool FitsSize(int sizeX, int sizeY) {
return sizeX <= std::numeric_limits<std::uint16_t>::max() && sizeY <= std::numeric_limits<std::uint16_t>::max();
}
// Converts stb's RGBA8 buffer, classifies its opacity and frees it.
template <typename TT>
static TextureAsset<T> FromRGBA8(unsigned char* data, int sizeX, int sizeY) {
TextureAsset<T> tex;
tex.sizeX = sizeX;
tex.sizeY = sizeY;