12 KiB
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 forTT = std::uint8_t, 0..1 for_Float16,floatordouble;TTisT's component type. It throwsstd::runtime_errornaming the file and stb's reason when the file doesn't decode (and aborts where there are no exceptions).LoadPNGFromMemory<TT>(bytes)returnsstd::expected<TextureAsset<T>, std::string>instead, for an image inside a larger asset;SizeFromMemory(bytes)reads only the header, asstd::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 overINT_MAXencoded bytes.opaqueis classified at decode:OpaqueType::FullyOpaque(every alpha is full),SemiOpaque(only full or zero: a cutout, no blending needed) orTransparent.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)andLoad(path)are the raw.texformat:u16 sizeX, u16 sizeY, u8 opaque, pixels, no magic, no version.LoadInfo(path)reads that header as aTextureAssetInfo;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 aCompressedTextureAsset { sizeX, sizeY, opaque, pixelStride, blob }without decompressing: one region,sizeX * sizeY * pixelStridebytes 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 withv/vt/vnon every vertex. Identical vertices are merged, with a linear scan, so it is quadratic: for anything large useLoadOBJSplit(path), which returns onestd::pair<std::string, MeshAsset<T>>perusemtlgroup in encounter order (faces before anyusemtlgo 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)givesstd::unordered_map<std::string, MtlMaterial>, eachMtlMaterial { mapKd }the diffuse texture path relative to the.mtl's directory, or empty; nothing else of a material is read.Save(path)andLoad(path)are the raw.meshformat:u32 vertexCount, u32 indexCount, u32 dataCount, vertexes, indexes, datas, no magic, no version.MeshAsset<void>'s raw form has nodataCount, so the two can't read each other's files.SaveCompressed(path)writes.cmesh.LoadCompressedMesh(path)returns aCompressedMeshAsset { vertexCount, indexCount, dataCount, dataStride, blob }: always three regions, in order vertices (vertexCount * 12bytes), indices (indexCount * 4) and vertex data (dataCount * dataStride; forMeshAsset<void>the count and both sizes are 0).dataStrideissizeof(T)at save time; check it against theTyou 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 withparent(-1 at a root) andchildren, the local transform astranslation,rotation(quaternion, xyzw),scale, or, whenhasMatrix, asmatrix, andmesh,skin,camera,lightindices (-1 when absent), per-node morphweightsandextras.sceneRootsare the default scene's roots (the file'sscene, 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 inw; 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) andmaterial(-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 intoprimitivesstill match the file.meshes[].weightsare the morph weights, 0 where the file has none, andmeshes[].extrasis where Blender and three.js puttargetNames.skinswithjoints,inverseBindMatrices(identity when absent) andskeleton.materialswith the PBR factors, texture refs asGlbTextureRef { texture, texCoord (0 or 1), scale }(scaleisnormalTexture's scale orocclusionTexture's strength),alphaMode,alphaCutoff,doubleSided, andKHR_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).imagesasTextureAsset<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 withsizeX == 0, a texture pointing at one is an error, and an image with auri(external file or data URI) is an error.cameraslook down their node's -Z with +Y up (yfovin radians;aspectRatio0 means the viewport's;zfar0 means infinite).lightsofKHR_lights_punctualsit at their node's origin and shine down -Z (intensityin candela, lux for directional;range0 means unlimited; cone angles in radians).jsonandbinare the two chunks verbatim, andextras(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,.jpegor.bmp(case-insensitive) becomes<name>.ctexin the bin directory, decoded as RGBA8;.objbecomes<name>.cmeshthroughMeshAsset<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 amod.jsonbeside them keeps referring tocannon/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), fromimport Crafter.Build;in aproject.cpp, splits an OBJ by material intomesh_<i>.cmesh, resizes each distinct diffuse map toalbedoSizesquare astex_<i>.ctex, and writesscene.txt(albedo count, mesh count, then one albedo index per mesh, -1 for none). It returns at once whenscene.txtexists; forward the directory's files throughcfg.files. Crafter.Graphics's Sponza example is the pattern.crafter-asset, the CLI this repository builds withcrafter-build --exe, writes the raw.texand.meshformats, 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. |