Crafter.Asset/AGENTS.consumer.md

63 lines
12 KiB
Markdown
Raw Normal View History

2026-10-31 06:13:44 +01:00
## 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`. |