Crafter.Asset/interfaces/Crafter.Asset-GLB.cppm
Jorijn van der Graaf fbf3e7fc2e
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

236 lines
10 KiB
C++

//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 and morph targets, skins,
// materials, textures and decoded images, cameras, and the lights of
// KHR_lights_punctual. The JSON and BIN chunks stay available, for what this
// loader doesn't interpret (animations, VRMC_springBone, ...), and so do the
// `extras` of the scene, nodes, meshes, 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. A file that requires an extension this loader
// doesn't implement is an error too, as glTF asks, and so is one that would
// decode to more than Load's decode limit: a few bytes of JSON can point a
// thousand primitives or images at the same data. Running out of memory
// short of the limit isn't an error in the file; it throws std::bad_alloc,
// as it would anywhere else.
export namespace Crafter {
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;
// Its mesh's morph target weights, for this node: one per target, or
// empty to keep the mesh's.
std::vector<float> weights;
// 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;
};
// What a morph target adds to each vertex of its primitive, times the
// target's weight. Streams the target doesn't move stay empty.
struct GlbMorphTarget {
std::vector<Vector<float, 3, 3>> positions;
std::vector<Vector<float, 3, 3>> normals;
// Added to the tangents' xyz; the handedness in w stays.
std::vector<Vector<float, 3, 3>> tangents;
};
// One triangle list. Streams the primitive doesn't have stay empty;
// otherwise each has one entry per vertex. Strips and fans come back as
// lists. Points and lines aren't read: their primitives come back with no
// vertices, so the rest keep the file's numbering.
struct GlbPrimitive {
std::vector<Vector<float, 3, 3>> positions;
std::vector<Vector<float, 3, 3>> normals;
// xyz plus the bitangent's handedness in w. Where the file has none
// and the primitive has normals, computed from the UV set its
// material's normal texture samples (TEXCOORD_0 without one), as glTF
// asks.
std::vector<Vector<float, 4, 4>> tangents;
std::vector<Vector<float, 2, 2>> uv0;
// TEXCOORD_1: a second UV set, for the textures whose GlbTextureRef
// says texCoord 1.
std::vector<Vector<float, 2, 2>> uv1;
// Up to four joints a vertex, their weights summing to 1. Where the
// file gives more (JOINTS_1 and WEIGHTS_1, ...), the four heaviest,
// a joint named twice counted once; where it gives a vertex no weight
// at all, all of it on the vertex's first joint, as three.js does. A
// slot left over holds joint 0 with weight 0.
std::vector<std::array<std::uint16_t, 4>> joints;
std::vector<std::array<float, 4>> weights;
// 0..n-1 when the primitive isn't indexed.
std::vector<std::uint32_t> indices;
std::vector<GlbMorphTarget> targets;
std::int32_t material = -1;
};
struct GlbMesh {
std::string name;
std::vector<GlbPrimitive> primitives;
// The weights of the morph targets its primitives share, one per
// target; 0 where the file gives none. A node can override them.
std::vector<float> weights;
// The mesh's extras as JSON text, or empty. Blender and three.js
// write the morph targets' names there, as targetNames.
std::string extras;
};
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;
// The UV set the texture is sampled with: 0 for uv0, 1 for uv1. Every
// primitive whose material samples a set has it.
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. The filters are 0
// when the file leaves them to the renderer; the wrap modes default to
// REPEAT, 10497, as glTF's do.
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;
// One per image in the file, in RGBA8. An image in a format other
// than PNG and JPEG (the WebP or KTX2 of EXT_texture_webp or
// KHR_texture_basisu) isn't decoded and stays empty, sizeX 0; no
// texture's image is one.
std::vector<TextureAsset<Vector<std::uint8_t, 4, 4>>> 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;
// The BIN chunk, verbatim: the data of what the JSON describes and
// this loader doesn't read (animations, ...).
std::vector<std::byte> bin;
// What Load decodes (vertices, indices and pixels) is held to
// `decodeLimit` bytes; without one, to 1 GiB plus 64 bytes for each
// byte of the file.
static std::expected<GlbAsset, std::string> Load(const fs::path& path, std::optional<std::uint64_t> decodeLimit = std::nullopt);
static std::expected<GlbAsset, std::string> Load(std::span<const std::byte> glb, std::optional<std::uint64_t> decodeLimit = std::nullopt);
};
}