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

63 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## 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` `RegionMeta`s, 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`. |