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,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);
};
}