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

@ -0,0 +1,694 @@
//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"
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;
std::unexpected<std::string> Error(std::string message) {
return std::unexpected("GLB: " + std::move(message));
}
// 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;
}
std::int64_t Integer(const Json& object, const char* key, std::int64_t fallback) {
const Json* value = Find(object, key);
return value != nullptr && value->is_number_integer() ? value->get<std::int64_t>() : fallback;
}
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;
}
// An object's extras as JSON text, or empty when it has none.
std::string Extras(const Json& object) {
const Json* value = Find(object, "extras");
return value != nullptr ? value->dump() : std::string();
}
// 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;
}
// Up to N numbers from a JSON array into `out`, which keeps its values
// where the array is shorter or holds something else.
template <std::size_t N>
void Numbers(const Json& object, const char* key, std::array<float, N>& out) {
const Json& values = Array(object, key);
for (std::size_t i = 0; i < N && i < values.size(); ++i) {
if (values[i].is_number()) out[i] = values[i].get<float>();
}
}
std::size_t ComponentSize(std::uint32_t componentType) {
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;
}
// An accessor resolved to its bytes in the BIN chunk.
struct Accessor {
const std::byte* data;
std::size_t count;
std::size_t stride;
std::uint32_t componentType;
std::uint32_t components;
bool normalized;
float Float(std::size_t element, std::uint32_t component) const {
const std::byte* p = data + element * stride + component * ComponentSize(componentType);
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 {
const std::byte* p = data + element * stride + component * ComponentSize(componentType);
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;
};
// 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");
if (index < 0 || static_cast<std::size_t>(index) >= views.size()) {
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));
}
std::expected<Accessor, std::string> ReadAccessor(const Context& ctx, std::int64_t index) {
const Json& accessors = Array(ctx.root, "accessors");
if (index < 0 || static_cast<std::size_t>(index) >= accessors.size()) {
return Error(std::format("accessor {} doesn't exist", index));
}
const Json& accessor = accessors[static_cast<std::size_t>(index)];
if (Find(accessor, "sparse") != nullptr) {
return Error(std::format("accessor {} is sparse, which isn't supported", index));
}
const std::uint32_t componentType = static_cast<std::uint32_t>(Integer(accessor, "componentType", 0));
const std::uint32_t components = ComponentCount(String(accessor, "type"));
if (ComponentSize(componentType) == 0 || components == 0) {
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.
if (components == 16 && ComponentSize(componentType) != 4) {
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));
if (Find(accessor, "bufferView") == nullptr) {
return Error(std::format("accessor {} has no bufferView", index));
}
std::size_t viewStride = 0;
const auto view = ViewBytes(ctx, Integer(accessor, "bufferView", -1), &viewStride);
if (!view) return std::unexpected(view.error());
const std::size_t elementSize = ComponentSize(componentType) * components;
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));
}
return Accessor{
.data = view->data() + offset,
.count = static_cast<std::size_t>(count),
.stride = stride,
.componentType = componentType,
.components = components,
.normalized = Boolean(accessor, "normalized", false),
};
}
// An accessor of `components` per element whose component type is one of
// `allowed`, with exactly `count` elements unless count is 0.
std::expected<Accessor, std::string> Attribute(const 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) {
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));
}
if (count != 0 && accessor->count != count) {
return Error(std::format("{} of {} has {} elements, POSITION has {}", name, where, accessor->count, count));
}
return accessor;
}
// Per-vertex tangents from the UV gradients, accumulated over the
// 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.
void ComputeTangents(GlbPrimitive& p) {
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;
const float du1 = p.uv0[i[1]].x - p.uv0[i[0]].x, dv1 = p.uv0[i[1]].y - p.uv0[i[0]].y;
const float du2 = p.uv0[i[2]].x - p.uv0[i[0]].x, dv2 = p.uv0[i[2]].y - p.uv0[i[0]].y;
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]);
if (tangent.LengthSq() < 1e-20f) {
// No usable UV gradient: any direction perpendicular to the
// normal keeps the frame valid.
tangent = V3::Cross(std::abs(normal.x) < 0.9f ? V3(1, 0, 0) : V3(0, 1, 0), normal);
}
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);
}
}
std::expected<GlbPrimitive, std::string> ReadPrimitive(const Context& ctx, const Json& primitive, const std::string& where) {
if (Integer(primitive, "mode", 4) != 4) {
return Error(std::format("{} is not a triangle list (mode {})", where, Integer(primitive, "mode", 4)));
}
const Json* attributes = Find(primitive, "attributes");
if (attributes == nullptr || Find(*attributes, "POSITION") == nullptr) {
return Error(std::format("{} has no POSITION", where));
}
GlbPrimitive p;
const auto positions = Attribute(ctx, *attributes, "POSITION", 3, {kFloat}, 0, where);
if (!positions) return std::unexpected(positions.error());
const std::size_t n = positions->count;
p.positions.reserve(n);
for (std::size_t v = 0; v < n; ++v) {
p.positions.emplace_back(positions->Float(v, 0), positions->Float(v, 1), positions->Float(v, 2));
}
if (Find(*attributes, "NORMAL") != nullptr) {
const auto normals = Attribute(ctx, *attributes, "NORMAL", 3, {kFloat}, n, where);
if (!normals) return std::unexpected(normals.error());
p.normals.reserve(n);
for (std::size_t v = 0; v < n; ++v) {
p.normals.emplace_back(normals->Float(v, 0), normals->Float(v, 1), normals->Float(v, 2));
}
}
if (Find(*attributes, "TEXCOORD_0") != nullptr) {
const auto uv = Attribute(ctx, *attributes, "TEXCOORD_0", 2, {kFloat, kUnsignedByte, kUnsignedShort}, n, where);
if (!uv) return std::unexpected(uv.error());
p.uv0.reserve(n);
for (std::size_t v = 0; v < n; ++v) {
p.uv0.emplace_back(uv->Float(v, 0), uv->Float(v, 1));
}
}
if (Find(*attributes, "TANGENT") != nullptr) {
const auto tangents = Attribute(ctx, *attributes, "TANGENT", 4, {kFloat}, n, where);
if (!tangents) return std::unexpected(tangents.error());
p.tangents.reserve(n);
for (std::size_t v = 0; v < n; ++v) {
p.tangents.emplace_back(tangents->Float(v, 0), tangents->Float(v, 1), tangents->Float(v, 2), tangents->Float(v, 3));
}
}
if (Find(*attributes, "JOINTS_0") != nullptr) {
const auto joints = Attribute(ctx, *attributes, "JOINTS_0", 4, {kUnsignedByte, kUnsignedShort}, n, where);
if (!joints) return std::unexpected(joints.error());
p.joints.resize(n);
for (std::size_t v = 0; v < n; ++v) {
for (std::uint32_t c = 0; c < 4; ++c) p.joints[v][c] = static_cast<std::uint16_t>(joints->Unsigned(v, c));
}
}
if (Find(*attributes, "WEIGHTS_0") != nullptr) {
const auto weights = Attribute(ctx, *attributes, "WEIGHTS_0", 4, {kFloat, kUnsignedByte, kUnsignedShort}, n, where);
if (!weights) return std::unexpected(weights.error());
p.weights.resize(n);
for (std::size_t v = 0; v < n; ++v) {
float sum = 0;
for (std::uint32_t c = 0; c < 4; ++c) {
p.weights[v][c] = weights->Float(v, c);
sum += p.weights[v][c];
}
if (sum > 0) {
for (float& w : p.weights[v]) w /= sum;
}
}
}
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));
}
p.indices.reserve(indices->count);
for (std::size_t i = 0; i < indices->count; ++i) p.indices.push_back(indices->Unsigned(i, 0));
} else {
p.indices.resize(n);
std::iota(p.indices.begin(), p.indices.end(), 0u);
}
if (p.indices.size() % 3 != 0) {
return Error(std::format("{} has {} indices, not whole triangles", where, p.indices.size()));
}
if (std::ranges::any_of(p.indices, [n](std::uint32_t i) { return i >= n; })) {
return Error(std::format("{} indexes past its {} vertices", where, n));
}
p.material = static_cast<std::int32_t>(Integer(primitive, "material", -1));
if (p.tangents.empty() && !p.normals.empty() && !p.uv0.empty()) ComputeTangents(p);
return p;
}
GlbTextureRef TextureRef(const Json& material, const char* key, const char* scaleKey) {
GlbTextureRef ref;
if (const Json* info = Find(material, key)) {
ref.texture = static_cast<std::int32_t>(Integer(*info, "index", -1));
ref.texCoord = static_cast<std::uint32_t>(Integer(*info, "texCoord", 0));
if (scaleKey != nullptr) ref.scale = Number(*info, scaleKey, 1.0f);
}
return ref;
}
// Each index points at an element that exists, so callers can index
// without checking.
std::expected<void, std::string> CheckReferences(const GlbAsset& a) {
auto valid = [](std::int64_t index, std::size_t size) { return index == -1 || (index >= 0 && static_cast<std::size_t>(index) < size); };
for (std::size_t i = 0; i < a.nodes.size(); ++i) {
if (!valid(a.nodes[i].mesh, a.meshes.size())) return Error(std::format("node {} uses a mesh that doesn't exist", i));
if (!valid(a.nodes[i].skin, a.skins.size())) return Error(std::format("node {} uses a skin that doesn't exist", i));
if (!valid(a.nodes[i].camera, a.cameras.size())) return Error(std::format("node {} uses a camera that doesn't exist", i));
if (!valid(a.nodes[i].light, a.lights.size())) return Error(std::format("node {} uses a light that doesn't exist", i));
}
for (const GlbMesh& mesh : a.meshes) {
for (const GlbPrimitive& p : mesh.primitives) {
if (!valid(p.material, a.materials.size())) return Error(std::format("mesh '{}' uses a material that doesn't exist", mesh.name));
}
}
// 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;
const std::size_t jointCount = a.skins[static_cast<std::size_t>(node.skin)].joints.size();
for (const GlbPrimitive& p : a.meshes[static_cast<std::size_t>(node.mesh)].primitives) {
for (const auto& joints : p.joints) {
if (std::ranges::any_of(joints, [jointCount](std::uint16_t j) { return j >= jointCount; })) {
return Error(std::format("node '{}' skins mesh '{}' with joint indices beyond its skin's {} joints",
node.name, a.meshes[static_cast<std::size_t>(node.mesh)].name, jointCount));
}
}
}
}
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::uint32_t joint : skin.joints) {
if (joint >= a.nodes.size()) return Error(std::format("skin '{}' uses node {}, which doesn't exist", skin.name, joint));
}
}
for (std::size_t i = 0; i < a.textures.size(); ++i) {
if (!valid(a.textures[i].image, a.images.size())) return Error(std::format("texture {} uses an image that doesn't exist", i));
if (!valid(a.textures[i].sampler, a.samplers.size())) return Error(std::format("texture {} uses a sampler that doesn't exist", i));
}
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));
}
}
return {};
}
}
std::expected<GlbAsset, std::string> GlbAsset::Load(const fs::path& path) {
// A directory or a pipe opens too, with no size to read.
std::error_code error;
if (!fs::is_regular_file(path, error)) return Error(std::format("{} isn't a file", path.string()));
const std::uintmax_t size = fs::file_size(path, error);
if (error) return Error(std::format("can't read {}: {}", path.string(), error.message()));
std::ifstream file(path, std::ios::binary);
if (!file) return Error(std::format("can't open {}", path.string()));
std::vector<std::byte> bytes(static_cast<std::size_t>(size));
if (!file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
return Error(std::format("can't read {}", path.string()));
}
auto asset = Load(std::span<const std::byte>(bytes));
if (!asset) return std::unexpected(std::format("{} ({})", asset.error(), path.string()));
return asset;
}
std::expected<GlbAsset, std::string> GlbAsset::Load(std::span<const std::byte> glb) {
auto u32 = [&](std::size_t offset) {
std::uint32_t value;
std::memcpy(&value, glb.data() + offset, sizeof(value));
return value;
};
if (glb.size() < 12) return Error("too short to be a GLB");
if (u32(0) != kMagic) return Error("bad magic, not a .glb");
if (u32(4) != 2) return Error(std::format("container version {}, only 2 is supported", u32(4)));
const std::size_t length = u32(8);
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,
// as the spec asks.
std::span<const std::byte> jsonChunk;
std::span<const std::byte> binChunk;
for (std::size_t offset = 12; offset + 8 <= length;) {
const std::size_t chunkLength = u32(offset);
const std::uint32_t chunkType = u32(offset + 4);
// Not offset + 8 + chunkLength: that wraps round where size_t has 32 bits.
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");
const Context ctx{root, binChunk};
// Nodes, then the parent links their children lists imply.
for (const Json& node : Array(root, "nodes")) {
GlbNode& n = asset.nodes.emplace_back();
n.name = String(node, "name");
for (const Json& child : Array(node, "children")) {
if (!child.is_number_unsigned()) return Error(std::format("node '{}' has a child that isn't a node index", n.name));
n.children.push_back(child.get<std::uint32_t>());
}
std::array<float, 3> t = {0, 0, 0};
std::array<float, 4> r = {0, 0, 0, 1};
std::array<float, 3> s = {1, 1, 1};
Numbers(node, "translation", t);
Numbers(node, "rotation", r);
Numbers(node, "scale", s);
n.translation = Vector<float, 3, 0>(t[0], t[1], t[2]);
n.rotation = Vector<float, 4, 0>(r[0], r[1], r[2], r[3]);
n.scale = Vector<float, 3, 0>(s[0], s[1], s[2]);
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]);
}
n.mesh = static_cast<std::int32_t>(Integer(node, "mesh", -1));
n.skin = static_cast<std::int32_t>(Integer(node, "skin", -1));
n.camera = static_cast<std::int32_t>(Integer(node, "camera", -1));
if (const Json* punctual = Extension(node, "KHR_lights_punctual")) {
n.light = static_cast<std::int32_t>(Integer(*punctual, "light", -1));
}
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 (child >= asset.nodes.size()) return Error(std::format("node {} has child {}, which doesn't exist", i, child));
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;
}
const Json& scenes = Array(root, "scenes");
const std::int64_t scene = Integer(root, "scene", 0);
if (scene >= 0 && static_cast<std::size_t>(scene) < scenes.size()) {
for (const Json& node : Array(scenes[static_cast<std::size_t>(scene)], "nodes")) {
if (!node.is_number_unsigned() || node.get<std::size_t>() >= asset.nodes.size()) {
return Error("the default scene names a node that doesn't exist");
}
asset.sceneRoots.push_back(node.get<std::uint32_t>());
}
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));
}
}
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");
for (std::size_t p = 0; p < primitives.size(); ++p) {
auto primitive = ReadPrimitive(ctx, primitives[p], std::format("mesh {} primitive {}", m, p));
if (!primitive) return std::unexpected(primitive.error());
mesh.primitives.push_back(std::move(*primitive));
}
}
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")) {
if (!joint.is_number_unsigned()) return Error(std::format("skin '{}' has a joint that isn't a node index", s.name));
s.joints.push_back(joint.get<std::uint32_t>());
}
s.skeleton = static_cast<std::int32_t>(Integer(skin, "skeleton", -1));
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& 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", 1.0f);
m.roughnessFactor = Number(*pbr, "roughnessFactor", 1.0f);
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", 0.5f);
m.doubleSided = Boolean(material, "doubleSided", false);
if (const Json* strength = Extension(material, "KHR_materials_emissive_strength")) {
m.emissiveStrength = Number(*strength, "emissiveStrength", 1.0f);
}
if (const Json* transmission = Extension(material, "KHR_materials_transmission")) {
m.transmissionFactor = Number(*transmission, "transmissionFactor", 0.0f);
m.transmissionTexture = TextureRef(*transmission, "transmissionTexture", nullptr);
}
if (const Json* ior = Extension(material, "KHR_materials_ior")) {
m.ior = Number(*ior, "ior", 1.5f);
}
m.extras = Extras(material);
}
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) {
c.xmag = Number(*orthographic, "xmag", 0.0f);
c.ymag = Number(*orthographic, "ymag", 0.0f);
c.znear = Number(*orthographic, "znear", 0.0f);
c.zfar = Number(*orthographic, "zfar", 0.0f);
} else if (const Json* perspective = Find(camera, "perspective"); !c.orthographic && perspective != nullptr) {
c.yfov = Number(*perspective, "yfov", 0.0f);
c.aspectRatio = Number(*perspective, "aspectRatio", 0.0f);
c.znear = Number(*perspective, "znear", 0.0f);
c.zfar = Number(*perspective, "zfar", 0.0f);
} 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);
l.intensity = Number(light, "intensity", 1.0f);
l.range = Number(light, "range", 0.0f);
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({
.image = static_cast<std::int32_t>(Integer(texture, "source", -1)),
.sampler = static_cast<std::int32_t>(Integer(texture, "sampler", -1)),
});
}
for (const Json& sampler : Array(root, "samplers")) {
asset.samplers.push_back({
.magFilter = static_cast<std::uint32_t>(Integer(sampler, "magFilter", 0)),
.minFilter = static_cast<std::uint32_t>(Integer(sampler, "minFilter", 0)),
.wrapS = static_cast<std::uint32_t>(Integer(sampler, "wrapS", 10497)),
.wrapT = static_cast<std::uint32_t>(Integer(sampler, "wrapT", 10497)),
});
}
const Json& images = Array(root, "images");
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));
}
const auto bytes = ViewBytes(ctx, Integer(images[i], "bufferView", -1), nullptr);
if (!bytes) return std::unexpected(std::format("{} (image {})", bytes.error(), i));
auto image = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(*bytes);
if (!image) return Error(std::format("image {} doesn't decode: {}", i, image.error()));
asset.images.push_back(std::move(*image));
}
if (auto valid = CheckReferences(asset); !valid) return std::unexpected(valid.error());
return asset;
}

View file

@ -0,0 +1,191 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Asset:GLB;
import :Texture;
import std;
import Crafter.Math;
namespace fs = std::filesystem;
// glTF 2.0 binary (.glb) loading: the node hierarchy, every mesh's
// triangle primitives with their vertex streams, skins, materials, textures
// and decoded images, cameras, and the lights of KHR_lights_punctual. The JSON
// chunk stays available as text, for the extensions this loader doesn't
// interpret (VRMC_springBone, ...), and so do the `extras` of the scene,
// nodes, materials and lights (Blender writes custom properties there);
// parsing those is the caller's business, which keeps the JSON library out of
// this interface and out of everything that imports Crafter.Asset.
//
// Errors in the file come back as the error of the std::expected rather than
// through Compression::Fatal: a GLB is user content, and a bad one shouldn't
// take the program down.
export namespace Crafter {
struct GlbRGBA8 {
std::uint8_t r, g, b, a;
};
struct GlbNode {
std::string name;
std::int32_t parent = -1;
std::vector<std::uint32_t> children;
// The local transform, as TRS unless the file gives a matrix. The
// matrix is the 4x4 row-vector form (translation in row 3), which is
// glTF's column-major array copied into the rows unchanged.
Vector<float, 3, 0> translation = Vector<float, 3, 0>(0, 0, 0);
Vector<float, 4, 0> rotation = Vector<float, 4, 0>(0, 0, 0, 1);
Vector<float, 3, 0> scale = Vector<float, 3, 0>(1, 1, 1);
bool hasMatrix = false;
MatrixRowMajor<float, 4, 4, 1> matrix = MatrixRowMajor<float, 4, 4, 1>::Identity();
std::int32_t mesh = -1;
std::int32_t skin = -1;
std::int32_t camera = -1;
// A light of KHR_lights_punctual: GlbAsset::lights.
std::int32_t light = -1;
// The node's extras as JSON text, or empty.
std::string extras;
};
// A camera looks down its node's -Z, with +Y up.
struct GlbCamera {
std::string name;
bool orthographic = false;
// Perspective: the vertical field of view in radians, and the aspect
// ratio, or 0 when the file leaves it to the viewport.
float yfov = 0.0f;
float aspectRatio = 0.0f;
// Orthographic: half the view's width and height.
float xmag = 0.0f;
float ymag = 0.0f;
float znear = 0.0f;
// 0 for a perspective camera without a far plane.
float zfar = 0.0f;
};
enum class GlbLightType : std::uint8_t {
Directional,
Point,
Spot,
};
// A KHR_lights_punctual light. It sits at its node's origin; a
// directional or spot light shines down the node's -Z.
struct GlbLight {
std::string name;
GlbLightType type = GlbLightType::Point;
std::array<float, 3> color = {1, 1, 1};
// Candela for a point or spot light, lux for a directional one.
float intensity = 1.0f;
// Where its light ends, or 0 when it goes on with distance's falloff.
float range = 0.0f;
// A spot light's cone, from its axis, in radians.
float innerConeAngle = 0.0f;
float outerConeAngle = std::numbers::pi_v<float> / 4.0f;
// The light's extras as JSON text, or empty.
std::string extras;
};
// One triangle list. Streams the primitive doesn't have stay empty;
// otherwise each has one entry per vertex.
struct GlbPrimitive {
std::vector<Vector<float, 3, 3>> positions;
std::vector<Vector<float, 3, 3>> normals;
// xyz plus the bitangent's handedness in w. Computed from the UVs when
// the file has none but has normals and TEXCOORD_0.
std::vector<Vector<float, 4, 4>> tangents;
std::vector<Vector<float, 2, 2>> uv0;
std::vector<std::array<std::uint16_t, 4>> joints;
// Normalized to sum to 1, which the file is meant to do and often
// only nearly does.
std::vector<std::array<float, 4>> weights;
// 0..n-1 when the primitive isn't indexed.
std::vector<std::uint32_t> indices;
std::int32_t material = -1;
};
struct GlbMesh {
std::string name;
std::vector<GlbPrimitive> primitives;
};
struct GlbSkin {
std::string name;
std::vector<std::uint32_t> joints;
// One per joint, in the 4x4 row-vector form like GlbNode::matrix;
// identity when the file leaves them out.
std::vector<MatrixRowMajor<float, 4, 4, 1>> inverseBindMatrices;
std::int32_t skeleton = -1;
};
enum class GlbAlphaMode : std::uint8_t {
Opaque,
Mask,
Blend,
};
struct GlbTextureRef {
std::int32_t texture = -1;
std::uint32_t texCoord = 0;
// normalTexture's scale or occlusionTexture's strength.
float scale = 1.0f;
};
struct GlbMaterial {
std::string name;
std::array<float, 4> baseColorFactor = {1, 1, 1, 1};
float metallicFactor = 1.0f;
float roughnessFactor = 1.0f;
std::array<float, 3> emissiveFactor = {0, 0, 0};
GlbTextureRef baseColorTexture;
GlbTextureRef metallicRoughnessTexture;
GlbTextureRef normalTexture;
GlbTextureRef occlusionTexture;
GlbTextureRef emissiveTexture;
GlbAlphaMode alphaMode = GlbAlphaMode::Opaque;
float alphaCutoff = 0.5f;
bool doubleSided = false;
// KHR_materials_emissive_strength: emissiveFactor's multiplier.
float emissiveStrength = 1.0f;
// KHR_materials_transmission: how much light passes through the
// surface, times the texture's red channel.
float transmissionFactor = 0.0f;
GlbTextureRef transmissionTexture;
// KHR_materials_ior.
float ior = 1.5f;
// The material's extras as JSON text, or empty.
std::string extras;
};
// The OpenGL filter and wrap enums, as in the file; 0 when unspecified.
struct GlbSampler {
std::uint32_t magFilter = 0;
std::uint32_t minFilter = 0;
std::uint32_t wrapS = 10497;
std::uint32_t wrapT = 10497;
};
struct GlbTexture {
std::int32_t image = -1;
std::int32_t sampler = -1;
};
struct GlbAsset {
std::vector<GlbNode> nodes;
// The root nodes of the default scene.
std::vector<std::uint32_t> sceneRoots;
std::vector<GlbMesh> meshes;
std::vector<GlbSkin> skins;
std::vector<GlbMaterial> materials;
std::vector<GlbTexture> textures;
std::vector<GlbSampler> samplers;
std::vector<TextureAsset<GlbRGBA8>> images;
std::vector<GlbCamera> cameras;
std::vector<GlbLight> lights;
// The default scene's extras as JSON text, or empty.
std::string extras;
// The JSON chunk, verbatim.
std::string json;
static std::expected<GlbAsset, std::string> Load(const fs::path& path);
static std::expected<GlbAsset, std::string> Load(std::span<const std::byte> glb);
};
}

View file

@ -143,16 +143,57 @@ export namespace Crafter {
file.read(reinterpret_cast<char*>(pixels), sizeX * sizeY * sizeof(T)); 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> template <typename TT>
static TextureAsset<T> LoadPNG(fs::path path) { static TextureAsset<T> LoadPNG(fs::path path) {
TextureAsset<T> tex;
std::filesystem::path abs = std::filesystem::absolute(path); std::filesystem::path abs = std::filesystem::absolute(path);
int sizeX; int sizeX;
int sizeY; int sizeY;
unsigned char* data = stbi_load(abs.string().c_str(), &sizeX, &sizeY, nullptr, 4); 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.sizeX = sizeX;
tex.sizeY = sizeY; tex.sizeY = sizeY;

View file

@ -5,3 +5,4 @@ export module Crafter.Asset;
export import :Compression; export import :Compression;
export import :Mesh; export import :Mesh;
export import :Texture; export import :Texture;
export import :GLB;

25677
lib/json.hpp Normal file

File diff suppressed because it is too large Load diff

View file

@ -72,24 +72,32 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
} }
for (const fs::path& p : libdeflateSources) cfg.cFiles.push_back(p); for (const fs::path& p : libdeflateSources) cfg.cFiles.push_back(p);
std::array<fs::path, 4> ifaces = { std::array<fs::path, 5> ifaces = {
"interfaces/Crafter.Asset", "interfaces/Crafter.Asset",
"interfaces/Crafter.Asset-Compression", "interfaces/Crafter.Asset-Compression",
"interfaces/Crafter.Asset-Texture", "interfaces/Crafter.Asset-Texture",
"interfaces/Crafter.Asset-Mesh", "interfaces/Crafter.Asset-Mesh",
"interfaces/Crafter.Asset-GLB",
}; };
if (cfg.type == ConfigurationType::Executable) { if (cfg.type == ConfigurationType::Executable) {
std::array<fs::path, 2> impls = { std::array<fs::path, 3> impls = {
"implementations/Crafter.Asset-Compression", "implementations/Crafter.Asset-Compression",
"implementations/Crafter.Asset-GLB",
"implementations/main", "implementations/main",
}; };
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);
} else { } else {
std::array<fs::path, 1> impls = { std::array<fs::path, 2> impls = {
"implementations/Crafter.Asset-Compression", "implementations/Crafter.Asset-Compression",
"implementations/Crafter.Asset-GLB",
}; };
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);
} }
// Tests link this project's static library. wasm builds run no tests.
if (!isWasm && cfg.type == ConfigurationType::LibraryStatic) {
cfg.AddTest("GLB").Dependencies({ &cfg });
}
return cfg; return cfg;
} }

552
tests/GLB/main.cpp Normal file
View file

@ -0,0 +1,552 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// GlbAsset::Load on a GLB built here in memory: the node hierarchy (TRS and
// matrix nodes), two primitives with two materials, an interleaved
// position/normal buffer view (strided accessors), normalized UV and weight
// accessors, generated indices, computed tangents, a skin's inverse-bind
// matrices, an embedded PNG and an extension kept in the raw JSON. A room as
// Blender exports one: a camera, KHR_lights_punctual lights, glowing and glass
// materials, and extras. Then the ways a file can be broken, which must come
// back as errors rather than crash: bad magic, truncation, a sparse accessor,
// an accessor past its view, a light that doesn't exist, a stride or count
// whose arithmetic wraps round to look in bounds, a skin's skeleton that
// doesn't exist, a chunk longer than the file, a path that isn't a file, an
// image wider than a texture's 16-bit size holds, and children that form a
// cycle. A chain 100,000 nodes deep loads in good time: the cycle check
// walked every node up to its root.
//
// With CRAFTER_ASSET_TEST_GLB pointing at a real skinned .glb (a rig exported
// from Blender, say), it also loads that and checks every joint's world
// matrix times its inverse-bind matrix is the identity in the rest pose,
// which is what a skinned mesh at rest relies on.
#include <cstdlib>
import Crafter.Asset;
import Crafter.Math;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
bool Near(float a, float b, float epsilon = 1e-4f) {
return std::abs(a - b) < epsilon;
}
// A 2x2 RGBA PNG: red, green, blue, and white with alpha 0.
constexpr std::array<std::uint8_t, 76> kPng = {
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d,
0x24, 0x00, 0x00, 0x00, 0x13, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x0c, 0x81, 0x34, 0x08, 0x30, 0x00, 0x00, 0x48, 0xc9, 0x08, 0xf8, 0xc5, 0x34, 0xfd, 0x05,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
};
struct Writer {
std::vector<std::byte> bytes;
template <typename T>
void Put(T value) {
const auto* p = reinterpret_cast<const std::byte*>(&value);
bytes.insert(bytes.end(), p, p + sizeof(T));
}
void Pad(std::size_t alignment, std::byte fill) {
while (bytes.size() % alignment != 0) bytes.push_back(fill);
}
};
// header + JSON chunk (space-padded) + BIN chunk (zero-padded), as the GLB
// container lays them out.
std::vector<std::byte> BuildGlb(std::string_view json, const std::vector<std::byte>& bin) {
Writer jsonChunk;
for (char c : json) jsonChunk.Put(c);
jsonChunk.Pad(4, std::byte{' '});
std::vector<std::byte> binPadded = bin;
while (binPadded.size() % 4 != 0) binPadded.push_back(std::byte{0});
Writer out;
out.Put<std::uint32_t>(0x46546C67); // "glTF"
out.Put<std::uint32_t>(2);
out.Put<std::uint32_t>(static_cast<std::uint32_t>(12 + 8 + jsonChunk.bytes.size() + 8 + binPadded.size()));
out.Put<std::uint32_t>(static_cast<std::uint32_t>(jsonChunk.bytes.size()));
out.Put<std::uint32_t>(0x4E4F534A); // "JSON"
out.bytes.insert(out.bytes.end(), jsonChunk.bytes.begin(), jsonChunk.bytes.end());
out.Put<std::uint32_t>(static_cast<std::uint32_t>(binPadded.size()));
out.Put<std::uint32_t>(0x004E4942); // "BIN\0"
out.bytes.insert(out.bytes.end(), binPadded.begin(), binPadded.end());
return out.bytes;
}
std::vector<std::byte> FixtureBin() {
Writer w;
// bufferView 0, offset 0: interleaved position + normal, stride 24.
const std::array<std::array<float, 3>, 3> positions = {{{0, 0, 0}, {1, 0, 0}, {0, 1, 0}}};
for (const auto& p : positions) {
for (float f : p) w.Put(f);
for (float f : {0.0f, 0.0f, 1.0f}) w.Put(f);
}
// bufferView 1, offset 72: TEXCOORD_0 as normalized u16.
for (std::uint16_t u : {0, 0, 65535, 0, 0, 65535}) w.Put(u);
// bufferView 2, offset 84: u8 indices.
for (std::uint8_t i : {0, 1, 2}) w.Put(i);
w.Pad(4, std::byte{0});
// bufferView 3, offset 88: JOINTS_0 as u8.
for (int v = 0; v < 3; ++v) for (std::uint8_t j : {0, 1, 0, 0}) w.Put(j);
// bufferView 4, offset 100: WEIGHTS_0 as normalized u8 that don't sum to 255.
for (int v = 0; v < 3; ++v) for (std::uint8_t j : {128, 64, 0, 0}) w.Put(j);
// bufferView 5, offset 112: the second primitive's positions.
for (float f : {0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f}) w.Put(f);
// bufferView 6, offset 148: two inverse-bind matrices, column-major.
for (float f : {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f}) w.Put(f);
for (float f : {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f}) w.Put(f);
// bufferView 7, offset 276: the PNG.
for (std::uint8_t b : kPng) w.Put(b);
return w.bytes;
}
std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t accessor7Count = 2) {
return std::format(R"({{
"asset": {{"version": "2.0"}},
"scene": 0,
"scenes": [{{"nodes": [0]}}],
"nodes": [
{{"name": "Root", "children": [1], "mesh": 0, "skin": 0,
"translation": [1, 2, 3], "rotation": [0, 0, 0.70710678, 0.70710678], "scale": [2, 2, 2]}},
{{"name": "Child", "matrix": [1,0,0,0, 0,1,0,0, 0,0,1,0, 5,6,7,1]}}
],
"meshes": [{{"name": "Mesh", "primitives": [
{{"attributes": {{"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, "JOINTS_0": 4, "WEIGHTS_0": 5}}, "indices": 3, "material": 0}},
{{"attributes": {{"POSITION": 6}}, "material": 1}}
]}}],
"skins": [{{"name": "Skin", "joints": [0, 1], "inverseBindMatrices": 7}}],
"materials": [
{{"name": "Mat0", "pbrMetallicRoughness": {{"baseColorFactor": [0.5, 0.25, 1, 1], "metallicFactor": 0.1,
"roughnessFactor": 0.7, "baseColorTexture": {{"index": 0}}}},
"normalTexture": {{"index": 0, "scale": 0.5}}, "alphaMode": "MASK", "alphaCutoff": 0.3, "doubleSided": true}},
{{"name": "Mat1"}}
],
"textures": [{{"source": 0, "sampler": 0}}],
"samplers": [{{"magFilter": 9728, "minFilter": 9729}}],
"images": [{{"bufferView": 7, "mimeType": "image/png"}}],
"accessors": [
{{"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3, "type": "VEC3"}},
{{"bufferView": 0, "byteOffset": 12, "componentType": 5126, "count": 3, "type": "VEC3"}},
{{"bufferView": 1, "componentType": 5123, "normalized": true, "count": 3, "type": "VEC2"}},
{{"bufferView": 2, "componentType": 5121, "count": 3, "type": "SCALAR"}},
{{"bufferView": 3, "componentType": 5121, "count": 3, "type": "VEC4"}},
{{"bufferView": 4, "componentType": 5121, "normalized": true, "count": 3, "type": "VEC4"}},
{{"bufferView": 5, "componentType": 5126, "count": 3, "type": "VEC3"{}}},
{{"bufferView": 6, "componentType": 5126, "count": {}, "type": "MAT4"}}
],
"bufferViews": [
{{"buffer": 0, "byteOffset": 0, "byteLength": 72, "byteStride": 24}},
{{"buffer": 0, "byteOffset": 72, "byteLength": 12}},
{{"buffer": 0, "byteOffset": 84, "byteLength": 3}},
{{"buffer": 0, "byteOffset": 88, "byteLength": 12}},
{{"buffer": 0, "byteOffset": 100, "byteLength": 12}},
{{"buffer": 0, "byteOffset": 112, "byteLength": 36}},
{{"buffer": 0, "byteOffset": 148, "byteLength": 128}},
{{"buffer": 0, "byteOffset": 276, "byteLength": 76}}
],
"buffers": [{{"byteLength": 352}}],
"extensionsUsed": ["VRMC_springBone"],
"extensions": {{"VRMC_springBone": {{"specVersion": "1.0"}}}}
}})", accessor6Extra, accessor7Count);
}
bool NearMatrix(const MatrixRowMajor<float, 4, 4, 1>& m, std::array<float, 16> expected) {
const std::array<float, 16> got = m.Store();
for (std::size_t i = 0; i < 16; ++i) {
if (!Near(got[i], expected[i])) return false;
}
return true;
}
void TestFixture() {
const std::vector<std::byte> glb = BuildGlb(FixtureJson(), FixtureBin());
const std::expected<GlbAsset, std::string> loaded = GlbAsset::Load(glb);
Check(loaded.has_value(), loaded.has_value() ? "the fixture loads" : std::format("the fixture loads: {}", loaded.error()));
if (!loaded) return;
const GlbAsset& a = *loaded;
// Nodes and scene.
Check(a.nodes.size() == 2 && a.nodes[0].name == "Root" && a.nodes[1].name == "Child", "two named nodes");
Check(a.nodes[0].parent == -1 && a.nodes[1].parent == 0 && a.nodes[0].children == std::vector<std::uint32_t>{1},
"parent and children links");
Check(a.sceneRoots == std::vector<std::uint32_t>{0}, "the default scene's roots");
const GlbNode& root = a.nodes[0];
Check(!root.hasMatrix && Near(root.translation.x, 1) && Near(root.translation.y, 2) && Near(root.translation.z, 3)
&& Near(root.rotation.z, 0.70710678f) && Near(root.rotation.w, 0.70710678f) && Near(root.scale.y, 2),
"TRS node");
Check(root.mesh == 0 && root.skin == 0 && a.nodes[1].mesh == -1, "mesh and skin references");
Check(a.nodes[1].hasMatrix && NearMatrix(a.nodes[1].matrix, {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 7, 1}),
"matrix node, translation in row 3");
// Primitives.
Check(a.meshes.size() == 1 && a.meshes[0].primitives.size() == 2, "one mesh with two primitives");
if (a.meshes.size() != 1 || a.meshes[0].primitives.size() != 2) return;
const GlbPrimitive& p0 = a.meshes[0].primitives[0];
const GlbPrimitive& p1 = a.meshes[0].primitives[1];
Check(p0.positions.size() == 3 && Near(p0.positions[1].x, 1) && Near(p0.positions[2].y, 1),
"positions read through a strided view");
Check(p0.normals.size() == 3 && Near(p0.normals[2].z, 1) && Near(p0.normals[0].x, 0), "normals read at their byte offset");
Check(p0.uv0.size() == 3 && Near(p0.uv0[1].x, 1) && Near(p0.uv0[2].y, 1) && Near(p0.uv0[1].y, 0),
"normalized u16 UVs");
Check(p0.indices == std::vector<std::uint32_t>{0, 1, 2}, "u8 indices widened");
Check(p0.joints.size() == 3 && p0.joints[0] == std::array<std::uint16_t, 4>{0, 1, 0, 0}, "u8 joints");
Check(p0.weights.size() == 3 && Near(p0.weights[0][0], 2.0f / 3.0f) && Near(p0.weights[0][1], 1.0f / 3.0f),
"normalized u8 weights, renormalized to sum 1");
// glTF's v runs down the image, so the bitangent (normal map +Y, "up")
// points along -v: here -Y, which is cross(normal, tangent) * -1.
Check(p0.tangents.size() == 3 && Near(p0.tangents[0].x, 1) && Near(p0.tangents[0].y, 0)
&& Near(p0.tangents[0].z, 0) && Near(p0.tangents[0].w, -1),
"tangents computed from the UVs, with glTF's bitangent sign");
Check(p0.material == 0 && p1.material == 1, "primitive materials");
Check(p1.positions.size() == 3 && Near(p1.positions[2].z, 1) && p1.indices == std::vector<std::uint32_t>{0, 1, 2},
"a primitive without indices gets 0..n-1");
Check(p1.normals.empty() && p1.tangents.empty() && p1.joints.empty(), "absent streams stay empty");
// Skin.
Check(a.skins.size() == 1 && a.skins[0].joints == std::vector<std::uint32_t>{0, 1}
&& a.skins[0].inverseBindMatrices.size() == 2
&& NearMatrix(a.skins[0].inverseBindMatrices[1], {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1, 0, 0, 1}),
"skin joints and inverse-bind matrices");
// Materials, textures, images.
Check(a.materials.size() == 2, "two materials");
if (a.materials.size() == 2) {
const GlbMaterial& m0 = a.materials[0];
Check(Near(m0.baseColorFactor[1], 0.25f) && Near(m0.metallicFactor, 0.1f) && Near(m0.roughnessFactor, 0.7f)
&& m0.baseColorTexture.texture == 0 && m0.normalTexture.texture == 0 && Near(m0.normalTexture.scale, 0.5f)
&& m0.alphaMode == GlbAlphaMode::Mask && Near(m0.alphaCutoff, 0.3f) && m0.doubleSided,
"material factors, textures and alpha mode");
const GlbMaterial& m1 = a.materials[1];
Check(Near(m1.metallicFactor, 1) && Near(m1.roughnessFactor, 1) && m1.baseColorTexture.texture == -1
&& m1.alphaMode == GlbAlphaMode::Opaque && !m1.doubleSided,
"material defaults");
}
Check(a.textures.size() == 1 && a.textures[0].image == 0 && a.textures[0].sampler == 0, "texture source and sampler");
Check(a.samplers.size() == 1 && a.samplers[0].magFilter == 9728 && a.samplers[0].minFilter == 9729, "sampler filters");
Check(a.images.size() == 1 && a.images[0].sizeX == 2 && a.images[0].sizeY == 2 && a.images[0].pixels.size() == 4,
"the embedded PNG decodes");
if (a.images.size() == 1 && a.images[0].pixels.size() == 4) {
const GlbRGBA8 first = a.images[0].pixels[0];
Check(first.r == 255 && first.g == 0 && first.b == 0 && first.a == 255 && a.images[0].pixels[3].a == 0
&& a.images[0].opaque == OpaqueType::SemiOpaque,
"image pixels and opacity");
}
Check(a.json.find("VRMC_springBone") != std::string::npos, "the raw JSON keeps extensions");
}
// What a room exported from Blender carries beyond meshes: a camera,
// KHR_lights_punctual lights, glowing and glass materials
// (KHR_materials_emissive_strength, _transmission, _ior), and the custom
// properties Blender writes as `extras` on the scene, nodes, materials and
// lights. The image is the fixture's PNG, at the same place in the buffer.
std::string RoomJson(std::string_view candleLight = "0") {
return std::format(R"({{
"asset": {{"version": "2.0"}},
"scene": 0,
"scenes": [{{"nodes": [0, 1, 2, 3, 4], "extras": {{"exposure": 1.5}}}}],
"nodes": [
{{"name": "Camera", "camera": 0, "translation": [0, 1.2, 1.5]}},
{{"name": "Candle", "extensions": {{"KHR_lights_punctual": {{"light": {}}}}}, "extras": {{"flicker": 0.3}}}},
{{"name": "Moon", "extensions": {{"KHR_lights_punctual": {{"light": 1}}}}}},
{{"name": "Sun", "extensions": {{"KHR_lights_punctual": {{"light": 2}}}}}},
{{"name": "Seat", "extras": {{"seat": true, "height": 1.6}}}}
],
"cameras": [{{"name": "Webcam", "type": "perspective",
"perspective": {{"yfov": 0.75, "aspectRatio": 1.7777778, "znear": 0.05, "zfar": 100}}}}],
"extensionsUsed": ["KHR_lights_punctual", "KHR_materials_emissive_strength", "KHR_materials_transmission", "KHR_materials_ior"],
"extensions": {{"KHR_lights_punctual": {{"lights": [
{{"name": "CandleLight", "type": "point", "color": [1, 0.6, 0.3], "intensity": 5, "range": 4, "extras": {{"shadows": false}}}},
{{"name": "MoonLight", "type": "spot", "intensity": 20, "spot": {{"innerConeAngle": 0.2, "outerConeAngle": 0.5}}}},
{{"name": "SunLight", "type": "directional", "color": [0.5, 0.6, 1], "intensity": 0.8}}
]}}}},
"materials": [
{{"name": "Flame", "emissiveFactor": [1, 0.5, 0.1],
"extensions": {{"KHR_materials_emissive_strength": {{"emissiveStrength": 8}}}}, "extras": {{"effect": "flicker"}}}},
{{"name": "StainedGlass", "pbrMetallicRoughness": {{"baseColorTexture": {{"index": 0}}}},
"extensions": {{"KHR_materials_transmission": {{"transmissionFactor": 0.9, "transmissionTexture": {{"index": 0}}}},
"KHR_materials_ior": {{"ior": 1.45}}}}}},
{{"name": "Plain"}}
],
"textures": [{{"source": 0}}],
"images": [{{"bufferView": 0, "mimeType": "image/png"}}],
"bufferViews": [{{"buffer": 0, "byteOffset": 276, "byteLength": 76}}],
"buffers": [{{"byteLength": 352}}]
}})", candleLight);
}
void TestRoom() {
const std::expected<GlbAsset, std::string> loaded = GlbAsset::Load(BuildGlb(RoomJson(), FixtureBin()));
Check(loaded.has_value(), loaded.has_value() ? "the room fixture loads" : std::format("the room fixture loads: {}", loaded.error()));
if (!loaded) return;
const GlbAsset& a = *loaded;
Check(a.cameras.size() == 1 && a.nodes[0].camera == 0 && a.nodes[1].camera == -1, "a node's camera");
if (a.cameras.size() == 1) {
const GlbCamera& c = a.cameras[0];
Check(c.name == "Webcam" && !c.orthographic && Near(c.yfov, 0.75f) && Near(c.aspectRatio, 1.7777778f)
&& Near(c.znear, 0.05f) && Near(c.zfar, 100),
"a perspective camera's field of view, aspect and clip planes");
}
Check(a.lights.size() == 3 && a.nodes[1].light == 0 && a.nodes[2].light == 1 && a.nodes[3].light == 2 && a.nodes[0].light == -1,
"nodes' KHR_lights_punctual lights");
if (a.lights.size() == 3) {
const GlbLight& point = a.lights[0];
Check(point.name == "CandleLight" && point.type == GlbLightType::Point && Near(point.color[1], 0.6f)
&& Near(point.intensity, 5) && Near(point.range, 4),
"a point light's colour, intensity and range");
const GlbLight& spot = a.lights[1];
Check(spot.type == GlbLightType::Spot && Near(spot.innerConeAngle, 0.2f) && Near(spot.outerConeAngle, 0.5f)
&& Near(spot.color[0], 1) && Near(spot.range, 0),
"a spot light's cone, with white and no range by default");
const GlbLight& sun = a.lights[2];
Check(sun.type == GlbLightType::Directional && Near(sun.intensity, 0.8f) && Near(sun.outerConeAngle, std::numbers::pi_v<float> / 4),
"a directional light, the cone left at the extension's default");
Check(point.extras == R"({"shadows":false})" && spot.extras.empty(), "a light's extras, as JSON text");
}
Check(a.materials.size() == 3, "three materials");
if (a.materials.size() == 3) {
const GlbMaterial& flame = a.materials[0];
Check(Near(flame.emissiveStrength, 8) && Near(flame.emissiveFactor[1], 0.5f) && Near(flame.transmissionFactor, 0),
"KHR_materials_emissive_strength");
const GlbMaterial& glass = a.materials[1];
Check(Near(glass.transmissionFactor, 0.9f) && glass.transmissionTexture.texture == 0 && Near(glass.ior, 1.45f),
"KHR_materials_transmission with its texture, and KHR_materials_ior");
const GlbMaterial& plain = a.materials[2];
Check(Near(plain.emissiveStrength, 1) && Near(plain.transmissionFactor, 0) && plain.transmissionTexture.texture == -1
&& Near(plain.ior, 1.5f) && plain.extras.empty(),
"the extensions' defaults where a material has none");
Check(flame.extras == R"({"effect":"flicker"})", "a material's extras, as JSON text");
}
Check(a.nodes[1].extras == R"({"flicker":0.3})" && a.nodes[4].extras == R"({"height":1.6,"seat":true})" && a.nodes[0].extras.empty(),
"nodes' extras, as JSON text");
Check(a.extras == R"({"exposure":1.5})", "the default scene's extras, as JSON text");
// A node may only name a light the extension defines.
const auto missingLight = GlbAsset::Load(BuildGlb(RoomJson("7"), FixtureBin()));
Check(!missingLight && missingLight.error().find("light") != std::string::npos, "a node naming a light that doesn't exist is an error");
}
// A grey PNG `width` by 1, its row stored without compression: big-endian
// chunks with their CRCs, and zlib's stored blocks with its Adler-32.
std::vector<std::byte> WidePng(std::uint32_t width) {
std::vector<std::uint8_t> row(1 + width, 128);
row[0] = 0; // no filter
std::vector<std::uint8_t> zlib = {0x78, 0x01};
for (std::size_t at = 0; at < row.size();) {
const std::size_t n = std::min<std::size_t>(65535, row.size() - at);
zlib.push_back(at + n == row.size() ? 1 : 0);
for (std::uint16_t v : {static_cast<std::uint16_t>(n), static_cast<std::uint16_t>(~n)}) {
zlib.push_back(static_cast<std::uint8_t>(v));
zlib.push_back(static_cast<std::uint8_t>(v >> 8));
}
zlib.insert(zlib.end(), row.begin() + static_cast<std::ptrdiff_t>(at), row.begin() + static_cast<std::ptrdiff_t>(at + n));
at += n;
}
std::uint32_t a = 1, b = 0;
for (std::uint8_t c : row) {
a = (a + c) % 65521;
b = (b + a) % 65521;
}
std::vector<std::uint8_t> png = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
auto be32 = [](std::vector<std::uint8_t>& out, std::uint32_t v) {
for (int shift = 24; shift >= 0; shift -= 8) out.push_back(static_cast<std::uint8_t>(v >> shift));
};
be32(zlib, (b << 16) | a);
auto chunk = [&](const char* type, const std::vector<std::uint8_t>& data) {
be32(png, static_cast<std::uint32_t>(data.size()));
std::vector<std::uint8_t> typed(type, type + 4);
typed.insert(typed.end(), data.begin(), data.end());
std::uint32_t crc = 0xFFFFFFFF;
for (std::uint8_t byte : typed) {
crc ^= byte;
for (int k = 0; k < 8; ++k) crc = (crc >> 1) ^ (0xEDB88320 & (0 - (crc & 1)));
}
png.insert(png.end(), typed.begin(), typed.end());
be32(png, ~crc);
};
std::vector<std::uint8_t> header;
be32(header, width);
be32(header, 1);
header.insert(header.end(), {8, 0, 0, 0, 0}); // 8-bit grey
chunk("IHDR", header);
chunk("IDAT", zlib);
chunk("IEND", {});
std::vector<std::byte> out(png.size());
std::memcpy(out.data(), png.data(), png.size());
return out;
}
void TestBrokenFiles() {
std::vector<std::byte> glb = BuildGlb(FixtureJson(), FixtureBin());
std::vector<std::byte> badMagic = glb;
badMagic[0] = std::byte{'X'};
const auto magic = GlbAsset::Load(badMagic);
Check(!magic && magic.error().find("magic") != std::string::npos, "bad magic is an error");
const std::vector<std::byte> truncated(glb.begin(), glb.begin() + static_cast<std::ptrdiff_t>(glb.size() / 2));
Check(!GlbAsset::Load(truncated), "a truncated file is an error");
const auto sparse = GlbAsset::Load(BuildGlb(
FixtureJson(R"(, "sparse": {"count": 1, "indices": {"bufferView": 2, "componentType": 5121}, "values": {"bufferView": 5}})"),
FixtureBin()));
Check(!sparse && sparse.error().find("sparse") != std::string::npos, "a sparse accessor is an error");
const auto outside = GlbAsset::Load(BuildGlb(FixtureJson("", 3), FixtureBin()));
Check(!outside && outside.error().find("bufferView") != std::string::npos, "an accessor past its buffer view is an error");
Check(!GlbAsset::Load(std::filesystem::path("/nonexistent/file.glb")), "a missing file is an error");
// The fixture's JSON with each `from` replaced by its `to`.
auto with = [](std::initializer_list<std::pair<std::string_view, std::string_view>> changes) {
std::string json = FixtureJson();
for (const auto& [from, to] : changes) json.replace(json.find(from), from.size(), to);
return BuildGlb(json, FixtureBin());
};
// Accessor 0 reads the interleaved view: 3 elements, 24 bytes apart. An
// offset and a negative stride that wrap round to land in bounds.
const auto wrappedStride = GlbAsset::Load(with({{R"("byteStride": 24)", R"("byteStride": -4096)"},
{R"({"bufferView": 0, "byteOffset": 0,)", R"({"bufferView": 0, "byteOffset": 8192,)"}}));
Check(!wrappedStride, "a negative byteStride is an error, however the offsets add up");
// A count whose elements' 24-byte strides wrap round to nothing.
const auto wrappedCount = GlbAsset::Load(with({{R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3,)",
R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 2305843009213693953,)"}}));
Check(!wrappedCount, "a count too large for its view is an error, however its size wraps");
const auto skeleton = GlbAsset::Load(with({{R"("joints": [0, 1],)", R"("joints": [0, 1], "skeleton": 99,)"}}));
Check(!skeleton && skeleton.error().find("skeleton") != std::string::npos, "a skin's skeleton that doesn't exist is an error");
Check(!GlbAsset::Load(std::filesystem::temp_directory_path()), "a directory is an error");
// The first chunk's length, past the end of the file; on a 32-bit build
// its sum with the offset wraps round to a small number.
std::vector<std::byte> longChunk = glb;
const std::uint32_t huge = 0xFFFFFFF0;
std::memcpy(longChunk.data() + 12, &huge, sizeof(huge));
Check(!GlbAsset::Load(longChunk), "a chunk longer than the file is an error");
// Nodes alone: a cycle, and a long chain.
auto nodesOnly = [](std::string_view nodes) {
return BuildGlb(std::format(R"({{"asset": {{"version": "2.0"}}, "nodes": [{}]}})", nodes), {});
};
const auto cycle = GlbAsset::Load(nodesOnly(R"({"children": [1]}, {"children": [2]}, {"children": [0]})"));
Check(!cycle && cycle.error().find("cycle") != std::string::npos, "children that form a cycle are an error");
constexpr std::size_t kChain = 100000;
std::string chain;
for (std::size_t i = 0; i + 1 < kChain; ++i) chain += std::format(R"({{"children": [{}]}},)", i + 1);
chain += "{}";
const auto started = std::chrono::steady_clock::now();
const auto deep = GlbAsset::Load(nodesOnly(chain));
const auto took = std::chrono::steady_clock::now() - started;
Check(deep && deep->nodes.size() == kChain && deep->nodes.back().parent == static_cast<std::int32_t>(kChain - 2),
deep ? "a chain 100,000 nodes deep loads, each its parent's child" : std::format("a chain 100,000 nodes deep loads: {}", deep.error()));
Check(took < std::chrono::seconds(2), std::format("in good time ({} ms)", std::chrono::duration_cast<std::chrono::milliseconds>(took).count()));
const auto narrow = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(WidePng(16));
Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes");
const auto wide = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(WidePng(70000));
Check(!wide, wide ? std::format("an image 70000 wide is an error, not {} wide", wide->sizeX) : "an image 70000 wide is an error");
}
// Column-major 4x4 helpers for the rest-pose check, kept separate from the
// matrix types under test.
using Mat = std::array<float, 16>;
Mat Multiply(const Mat& a, const Mat& b) {
Mat r{};
for (int col = 0; col < 4; ++col) {
for (int row = 0; row < 4; ++row) {
float sum = 0;
for (int k = 0; k < 4; ++k) sum += a[k * 4 + row] * b[col * 4 + k];
r[col * 4 + row] = sum;
}
}
return r;
}
Mat LocalMatrix(const GlbNode& node) {
if (node.hasMatrix) return node.matrix.Store(); // rows of the row-major copy are glTF's columns
const float x = node.rotation.x, y = node.rotation.y, z = node.rotation.z, w = node.rotation.w;
const float sx = node.scale.x, sy = node.scale.y, sz = node.scale.z;
return {
(1 - 2 * (y * y + z * z)) * sx, (2 * (x * y + w * z)) * sx, (2 * (x * z - w * y)) * sx, 0,
(2 * (x * y - w * z)) * sy, (1 - 2 * (x * x + z * z)) * sy, (2 * (y * z + w * x)) * sy, 0,
(2 * (x * z + w * y)) * sz, (2 * (y * z - w * x)) * sz, (1 - 2 * (x * x + y * y)) * sz, 0,
node.translation.x, node.translation.y, node.translation.z, 1,
};
}
void TestRealFile(const char* path) {
const auto loaded = GlbAsset::Load(std::filesystem::path(path));
Check(loaded.has_value(), loaded ? std::format("{} loads", path) : std::format("{} loads: {}", path, loaded.error()));
if (!loaded) return;
const GlbAsset& a = *loaded;
std::size_t vertices = 0;
bool streamsMatch = true;
bool indicesValid = true;
for (const GlbMesh& mesh : a.meshes) {
for (const GlbPrimitive& p : mesh.primitives) {
vertices += p.positions.size();
streamsMatch = streamsMatch && p.normals.size() == p.positions.size()
&& (p.joints.empty() || (p.joints.size() == p.positions.size() && p.weights.size() == p.positions.size()));
indicesValid = indicesValid && p.indices.size() % 3 == 0
&& std::ranges::all_of(p.indices, [&](std::uint32_t i) { return i < p.positions.size(); });
}
}
std::println(" {} meshes, {} vertices, {} nodes, {} skins ({} joints), {} materials, {} images",
a.meshes.size(), vertices, a.nodes.size(), a.skins.size(),
a.skins.empty() ? 0 : a.skins[0].joints.size(), a.materials.size(), a.images.size());
Check(streamsMatch, "every vertex stream has one entry per vertex");
Check(indicesValid, "every index list is whole triangles of valid vertices");
Check(std::ranges::all_of(a.images, [](const auto& image) { return image.sizeX > 0 && image.pixels.size() == std::size_t(image.sizeX) * image.sizeY; }),
"every image decodes");
// World matrices by walking down from the roots, then world * inverseBind
// per joint.
std::vector<Mat> world(a.nodes.size());
std::function<void(std::uint32_t, const Mat&)> walk = [&](std::uint32_t node, const Mat& parent) {
world[node] = Multiply(parent, LocalMatrix(a.nodes[node]));
for (std::uint32_t child : a.nodes[node].children) walk(child, world[node]);
};
const Mat identity = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
for (std::uint32_t r = 0; r < a.nodes.size(); ++r) {
if (a.nodes[r].parent == -1) walk(r, identity);
}
for (const GlbSkin& skin : a.skins) {
float worst = 0;
for (std::size_t j = 0; j < skin.joints.size(); ++j) {
const Mat bound = Multiply(world[skin.joints[j]], skin.inverseBindMatrices[j].Store());
for (std::size_t i = 0; i < 16; ++i) worst = std::max(worst, std::abs(bound[i] - identity[i]));
}
Check(worst < 1e-3f, std::format("skin '{}': world * inverseBind is the identity at rest (worst {})", skin.name, worst));
}
}
} // namespace
int main() {
TestFixture();
TestRoom();
TestBrokenFiles();
if (const char* real = std::getenv("CRAFTER_ASSET_TEST_GLB")) {
TestRealFile(real);
} else {
std::println("SKIP real-file check (set CRAFTER_ASSET_TEST_GLB to a skinned .glb)");
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}