Crafter.Asset/AGENTS.consumer.md
2026-10-31 06:13:44 +01:00

12 KiB
Raw Blame History

Crafter.Asset

The asset library of the Crafter engine. It decodes source assets (images through stb_image, Wavefront OBJ, binary glTF), GDeflate-compresses vertex and pixel streams into .ctex and .cmesh files that the GPU decompresses through VK_EXT_memory_decompression, and reads those files back, with a CPU fallback. import Crafter.Asset; gets all of it; the vector and matrix types are Crafter.Math's, so import Crafter.Math; as well. Everything is in namespace Crafter, the compression layer in Crafter::Compression. Source and issues: https://forgejo.catcrafts.net/Catcrafts/Crafter.Asset (LGPL-3.0-only; the vendored GDeflate is Apache-2.0, libdeflate and nlohmann/json MIT, stb public domain). These notes are its AGENTS.consumer.md: a fix to them goes there, not into the copy crafter-build writes.

Textures

TextureAsset<T> is sizeX, sizeY (std::uint16_t), opaque and std::vector<T> pixels, row-major from the top-left, with T a pixel vector of Crafter.Math: Vector<std::uint8_t, 4, 4> (RGBA8, 4 bytes), Vector<_Float16, 4, 4> or Vector<float, 4, 4>.

  • TextureAsset<T>::LoadPNG<TT>(path) decodes any format stb_image reads (PNG, JPEG, TGA, BMP, GIF, PSD, HDR, PNM) to RGBA: 8-bit for TT = std::uint8_t, 0..1 for _Float16, float or double; TT is T's component type. It throws std::runtime_error naming the file and stb's reason when the file doesn't decode (and aborts where there are no exceptions). LoadPNGFromMemory<TT>(bytes) returns std::expected<TextureAsset<T>, std::string> instead, for an image inside a larger asset; SizeFromMemory(bytes) reads only the header, as std::expected<std::pair<std::uint32_t, std::uint32_t>, std::string>. All three refuse images over 65535 a side, over 2^28 pixels (16384×16384), or over INT_MAX encoded bytes.
  • opaque is classified at decode: OpaqueType::FullyOpaque (every alpha is full), SemiOpaque (only full or zero: a cutout, no blending needed) or Transparent.
  • Resize(w, h) is a bilinear resize in place, RGBA8 only; Crafter.Graphics uses it to give every layer of a texture array the same size.
  • Save(path) and Load(path) are the raw .tex format: u16 sizeX, u16 sizeY, u8 opaque, pixels, no magic, no version. LoadInfo(path) reads that header as a TextureAssetInfo; Load(path, T* pixels, sizeX, sizeY) reads the pixels into memory you own, such as a mapped staging buffer.
  • SaveCompressed(path) writes .ctex. LoadCompressedTexture(path) returns a CompressedTextureAsset { sizeX, sizeY, opaque, pixelStride, blob } without decompressing: one region, sizeX * sizeY * pixelStride bytes when decompressed.

Meshes

MeshAsset<T> is vertexes (Vector<float, 3, 3>: 12 bytes each, tightly packed), indexes (std::uint32_t, a triangle list) and datas, one T per vertex. T is normally VertexNormalTangentUVPacked: normal, tangent (Vector<float, 3, 4>) and uv (Vector<float, 2, 4>), each padded to 16 bytes, 48 in all. MeshAsset<void> has positions and indices only.

  • MeshAsset<T>::LoadOBJ(path) reads an OBJ into one mesh. Faces must be triangles with v/vt/vn on every vertex. Identical vertices are merged, with a linear scan, so it is quadratic: for anything large use LoadOBJSplit(path), which returns one std::pair<std::string, MeshAsset<T>> per usemtl group in encounter order (faces before any usemtl go under ""), merges through a hash, fan-triangulates polygons and skips degenerate UV triangles when computing tangents. Both accumulate per-vertex tangents from the UVs. LoadMTL(path) gives std::unordered_map<std::string, MtlMaterial>, each MtlMaterial { mapKd } the diffuse texture path relative to the .mtl's directory, or empty; nothing else of a material is read.
  • Save(path) and Load(path) are the raw .mesh format: u32 vertexCount, u32 indexCount, u32 dataCount, vertexes, indexes, datas, no magic, no version. MeshAsset<void>'s raw form has no dataCount, so the two can't read each other's files.
  • SaveCompressed(path) writes .cmesh. LoadCompressedMesh(path) returns a CompressedMeshAsset { vertexCount, indexCount, dataCount, dataStride, blob }: always three regions, in order vertices (vertexCount * 12 bytes), indices (indexCount * 4) and vertex data (dataCount * dataStride; for MeshAsset<void> the count and both sizes are 0). dataStride is sizeof(T) at save time; check it against the T you are about to read the bytes as.

Compressed blobs and the GPU path

Compression::CompressStreams(spans) compresses each span as an independent GDeflate stream (64 KiB tiles, maximum level) and returns a CompressedBlob { bytes, regions }. regions[i] is a RegionMeta { srcOffset, compressedSize, decompressedSize }, three std::uint64_t: the fields of VkDecompressMemoryRegionEXT minus the two device addresses. Upload bytes to a buffer, add its address to each srcOffset, point each region's destination where its output goes, and the GPU decompresses them with VK_EXT_memory_decompression (method GDeflate 1.0). An empty input becomes a region with both sizes 0; skip it. blob.TotalDecompressedSize() sums the regions.

DecompressCPU(blob, outputs) is the fallback and the reference, single-threaded: outputs.size() must equal regions.size() and each outputs[i].size() its region's decompressedSize, exactly, or it aborts.

WriteBlob and ReadBlob serialise a blob behind a format's own header: u32 regionCount, then regionCount RegionMetas, then u64 payloadSize and the payload. Every integer in every format is written in native byte order, as laid out in memory; all supported targets are little-endian.

GLB

GlbAsset::Load(path, decodeLimit = std::nullopt) and Load(std::span<const std::byte> glb, decodeLimit) return std::expected<GlbAsset, std::string>. Every error starts with GLB: and says what is wrong, with the path appended by the file overload. A broken file is an error, never a crash or an abort; that is the contract. What it reads:

  • nodes, each with parent (-1 at a root) and children, the local transform as translation, rotation (quaternion, xyzw), scale, or, when hasMatrix, as matrix, and mesh, skin, camera, light indices (-1 when absent), per-node morph weights and extras. sceneRoots are the default scene's roots (the file's scene, else scene 0, else every parentless node).
  • meshes[].primitives[], triangle lists only: positions, normals (Vector<float, 3, 3>), tangents (Vector<float, 4, 4>, the bitangent's handedness in w; computed from the UV set the material's normal map samples when the file has none and the primitive has normals), uv0, uv1 (Vector<float, 2, 2>), joints (std::array<std::uint16_t, 4>), weights (std::array<float, 4>, summing to 1; the four heaviest when the file gives more), indices (std::uint32_t, generated as 0..n-1 for an unindexed primitive), targets (morph targets: position, normal and tangent deltas) and material (-1 for none). A stream the primitive doesn't have is empty; one it has holds one entry per vertex. Strips and fans arrive as lists; points and lines arrive as primitives with no vertices, so indices into primitives still match the file. meshes[].weights are the morph weights, 0 where the file has none, and meshes[].extras is where Blender and three.js put targetNames.
  • skins with joints, inverseBindMatrices (identity when absent) and skeleton. materials with the PBR factors, texture refs as GlbTextureRef { texture, texCoord (0 or 1), scale } (scale is normalTexture's scale or occlusionTexture's strength), alphaMode, alphaCutoff, doubleSided, and KHR_materials_emissive_strength, KHR_materials_transmission, KHR_materials_ior. textures (image, sampler), samplers (GL enums as in the file; filters 0 when unset, wrap 10497 by default). images as TextureAsset<Vector<std::uint8_t, 4, 4>>, every one decoded at load from the BIN chunk when its bytes are PNG or JPEG; another format (WebP, KTX2) stays empty with sizeX == 0, a texture pointing at one is an error, and an image with a uri (external file or data URI) is an error. cameras look down their node's -Z with +Y up (yfov in radians; aspectRatio 0 means the viewport's; zfar 0 means infinite). lights of KHR_lights_punctual sit at their node's origin and shine down -Z (intensity in candela, lux for directional; range 0 means unlimited; cone angles in radians).
  • json and bin are the two chunks verbatim, and extras (of the scene, each node, mesh, material and light) are JSON text: animations, other extensions, Blender's custom properties and anything else the loader doesn't interpret are there for you to parse with a JSON library of your own.

Matrices: GlbNode::matrix and GlbSkin::inverseBindMatrices are MatrixRowMajor<float, 4, 4, 1> in Crafter.Math's row-vector form (translation in row 3), which is glTF's column-major array copied into the rows unchanged. A vertex transforms as v * M; a TRS node's local matrix is MatrixRowMajor<float, 4, 4, 1>::TRS(translation, rotation, scale); a node's world matrix is local * parentWorld; and at rest world[joint] * inverseBind[joint] is the identity for every joint of a skin.

Refusals and limits: a file whose extensionsRequired names anything but KHR_lights_punctual, KHR_materials_emissive_strength, KHR_materials_ior or KHR_materials_transmission is an error, as glTF asks; a .gltf with external .bin buffers isn't a GLB and isn't read. What is decoded (vertices, indices, pixels) is held to decodeLimit bytes, by default 1 GiB plus 64 bytes per byte of file, and a file past it is an error rather than an allocation, so a few KB of JSON can't point a thousand primitives at one buffer and exhaust memory. Running out of memory short of the limit throws std::bad_alloc, as anywhere. Every image is decoded at load, so a GLB with large textures costs their RGBA8 size up front.

There is no compressed form of a GlbAsset and no .glb step in crafter-build's asset pipeline: load one at runtime, or convert in a build script, copying primitives into a MeshAsset and calling SaveCompressed; images[i] is a TextureAsset already, so images[i].SaveCompressed(path) works as is.

Build-time pipeline, in crafter-build

crafter-build links this library and compresses a project's cfg.assets while building it, when Crafter.Asset is reachable through cfg.dependencies:

  • A file entry .png, .tga, .jpg, .jpeg or .bmp (case-insensitive) becomes <name>.ctex in the bin directory, decoded as RGBA8; .obj becomes <name>.cmesh through MeshAsset<VertexNormalTangentUVPacked>::LoadOBJ. Any other extension fails the build.
  • A directory entry is recursed and mirrored under <dirname>/ in the bin directory: those extensions compressed, every other file copied unchanged, so a mod.json beside them keeps referring to cannon/base.cmesh.
  • A file is skipped when its output is newer than its source. Outputs are forwarded into a consuming executable's bin directory, like shaders and cfg.files.
  • BuildOBJBundle(objPath, mtlPath, outDir, albedoSize = 1024), from import Crafter.Build; in a project.cpp, splits an OBJ by material into mesh_<i>.cmesh, resizes each distinct diffuse map to albedoSize square as tex_<i>.ctex, and writes scene.txt (albedo count, mesh count, then one albedo index per mesh, -1 for none). It returns at once when scene.txt exists; forward the directory's files through cfg.files. Crafter.Graphics's Sponza example is the pattern.
  • crafter-asset, the CLI this repository builds with crafter-build --exe, writes the raw .tex and .mesh formats, not the compressed ones: crafter-asset in.png [out.tex] [--format u8|f16], crafter-asset in.obj [out.mesh].

Errors

Call On bad input
GlbAsset::Load, LoadPNGFromMemory, SizeFromMemory std::unexpected with a message. User content: you decide.
LoadPNG throws std::runtime_error (crafter-build reports it per file); aborts on wasm.
LoadCompressedTexture, LoadCompressedMesh, DecompressCPU, CompressStreams Compression::Fatal: prints Crafter.Asset fatal: ... to stderr and std::abort()s. These read the build's own output, and a mismatch means the data and the binary disagree. Never route user content through them.
raw Load, LoadInfo, LoadOBJ, LoadOBJSplit, LoadMTL no checks: a missing file reads as zeros or empty, a malformed OBJ can throw from std::stoi.