> Cinder 0.1.0 documentation: Assets guide (cinder/assets)
> Canonical page: https://cinder.ash10.dev/docs/guides/assets/ · Source: `docs/guides/assets.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# Assets guide (`cinder/assets`)

`cinder/assets` gets content from disk into the renderer. It has two paths:

- **Direct import**: `load_gltf` parses a `.gltf`/`.glb` through cgltf into a CPU-side
  `GltfAsset`; `upload_gltf`/`instantiate` push it to the GPU. Simple, synchronous, and what the
  sample's Sponza level and the golden tests use.
- **Cooked assets**: `cook_gltf` (and the `cinder-cook` CLI) turn a glTF source into versioned
  binary files (`.cmesh`, `.cmat`, `.cclip`, `.cskel`, `.ktx2`) named by stable ids and listed in
  a manifest; the `AssetServer` loads those asynchronously on the job system with reference
  counting and hot reload.

The package sits beside `render`, `physics`, `anim`, `audio` and `scene` in the module map; it
imports `cinder/core` (bytes, fs, hash, image, jobs), `cinder/gfx` (resources), `cinder/render`
(mesh, material, renderer) and the cgltf, libktx and meshoptimizer bindings in `cinder/ffi`.
`cinder/scene` uses it to spawn glTF into the ECS; `cinder/app` mounts an `AssetServer`.

Files: `gltf.mojo`, `cooked.mojo`, `cook.mojo`, `textures.mojo`, `runtime.mojo`, and the CLI
`tools/cinder_cook.mojo`. API reference: [`docs/api/assets.md`](https://cinder.ash10.dev/docs/api/assets.md). Tests:
`test_gltf_loader.mojo`,
`test_cook.mojo`,
`test_textures.mojo`,
`test_asset_server.mojo`,
`test_fuzz.mojo`. Related decisions:
[ADR-0003](https://cinder.ash10.dev/docs/adr/0003-native-shim-pull-model.md) (C shim, no Mojo in C callbacks),
[ADR-0014](https://cinder.ash10.dev/docs/adr/0014-sample-game-and-performance.md) (the sample imports glTF directly and does
not yet use the cooked path).

## glTF import

```mojo
var lib = NativeLibrary()
var asset = load_gltf(lib.ptr(), String("tests/fixtures/skinned_quad.gltf"))
ref prim = asset.meshes[0].primitives[0]
assert_true(prim.mesh.has_skin())
assert_equal(len(prim.mesh.tangents), 4, "tangents generated when absent")
var b = asset.world_bounds()           # SceneBounds: min, max, centre(), radius()
```

`GltfAsset` is a plain tree of owned lists: `images` (`GltfImage`: name + decoded RGBA8
`Image`), `textures` (`GltfTexture`: image index, `linear_filter`, wgpu `address_mode`),
`materials` (`GltfMaterial`: a render `Material` plus five texture indices, -1 when absent, in
slot order base colour, metallic-roughness, normal, occlusion, emissive), `meshes`
(`GltfMesh` -> `GltfPrimitive(mesh: MeshData, material)`), `nodes` (`GltfNode` with `parent`,
`children`, `mesh`, `skin`, `local` and `world` matrices, morph `weights`), `skins`
(`GltfSkin`: joint node indices and inverse bind matrices), `animations` (`GltfAnimation` of
`GltfChannel`s: node, `path` 0..3 = translation/rotation/scale/weights, `interpolation` 0..2 =
linear/step/cubic, `components`, `times`, `values`) and `roots` (the default scene's nodes, else
every parentless node). All indices are into these lists.

What the importer does for you: only triangle primitives are kept; normals are computed when
missing, tangents generated with MikkTSpace when missing, `uv0` zero-filled, joint weights
renormalised, indices bounds-checked and a missing index buffer replaced by a sequential one;
images come from files (percent-decoded relative URIs), data URIs or GLB buffer views;
`KHR_texture_transform` and `KHR_materials_emissive_strength` are read into the `Material`.
Malformed input raises (`parse failed`, `validation failed`, `out of range`) and never crashes;
the fuzz test mutates glTF bytes to hold that line.

Upload and instantiate:

```mojo
var up = upload_gltf(renderer, res, asset, lods=True)
# up.mesh_ids[mesh][primitive], up.material_ids[material], up.default_material

var scene = RenderScene(Camera.look_at(Vec3(0.0, 1.0, 4.0), Vec3(0.0, 1.0, 0.0)))
var added = instantiate(renderer, res, asset, scene)   # one RenderInstance per (node, primitive)
```

`upload_gltf` creates one sampler per glTF texture, uploads each image once per colour space
(base colour and emissive as sRGB, the rest linear), adds every material and one default
material, and `renderer.add_mesh`es every primitive (building LODs with meshoptimizer when
`lods` is true). `instantiate` additionally appends instances with the node's world transform,
joints at bind pose (`inverse(node.world) * joint.world * inverse_bind`) and default morph
weights. For an entity hierarchy instead of a flat instance list, use `spawn_gltf` from
`cinder/scene` (see the [scene guide](https://cinder.ash10.dev/docs/guides/scene.md)).

## Cooked formats (`cooked.mojo`)

All cooked files are little-endian, start with a four-byte magic and a `u32` version, and are
fully bounds-checked on read: bad magic, a different version, truncation, trailing bytes, out of
range indices and inconsistent counts all raise `cooked ...` errors. Strings are `u32 length` +
bytes. Readers take `var data: List[UInt8]` and consume it.

| extension | magic | version | written by | read by |
|---|---|---|---|---|
| `.cmesh` | `CMSH` | 1 | `write_mesh(cook_mesh(data, lods, material))` | `read_mesh` |
| `.cclip` | `CCLP` | 1 | `write_clip(Clip)` | `read_clip` |
| `.cmat` | `CMAT` | 1 | `write_material(CookedMaterial)` | `read_material` |
| `.cskel` | `CSKL` | 1 | `cinder.anim.write_skeleton` | `cinder.anim.read_skeleton` |
| `.ktx2` | KTX 2.0 | - | `cook_texture_ktx2` (libktx) | `load_ktx2` |

**`.cmesh`** header after the version: `u32 vertex_count`, `u32 vertex_bytes`, `u32 skin_bytes`,
`u32 index_bytes`, `u32 lod_count`, `vec3 bounds_min`, `vec3 bounds_max`,
`u32 morph_target_count`, `u32 morph_bytes`, `string material` (logical path or empty); then
`lod_count` entries of `u32 first_index, u32 index_count, f32 error` (`MeshLod`), then the vertex,
skin, index and morph byte blocks. The vertex stream is the renderer's 56-byte `Vertex`
(position, normal, tangent+handedness, uv0, uv1), the skin stream 24 bytes per vertex (joints
u16x4, weights f32x4), indices `u32`, morph deltas 32 bytes per target per vertex. `read_mesh`
checks each block size against the counts, `1 <= lod_count <= 16`, LOD ranges within the index
buffer and a multiple of 3, and that every index addresses a vertex. A `CookedMesh` goes to the
GPU through `Renderer.add_cooked_mesh` without further processing.

**`.cclip`**: `string name`, `f32 duration`, `u32 channel_count`, then per `ClipChannel`
`u32 node, path, interpolation, components, key_count, value_count`, `f32 times[]`,
`f32 values[]`. `values` holds `components` floats per key (three times that for cubic spline:
in-tangent, value, out-tangent); times must be sorted.

**`.cmat`**: `string name`, `vec4 base_color`, `f32 metallic, roughness`, `vec3 emissive`,
`f32 emissive_strength, normal_scale, occlusion_strength`, `u32 alpha_mode` (0..2),
`f32 alpha_cutoff`, `u32 double_sided`, `vec2 uv_scale, uv_offset`, `f32 uv_rotation`, five
`(u8 texcoord, string image)` pairs, `u32 linear_filter`, `u32 address_mode`. `CookedMaterial`
is a render `Material` whose texture slots are *logical image paths* instead of GPU views; the
`AssetServer` resolves them.

Ids: `asset_id(logical) = fnv1a64(logical)` and `asset_file_name(logical, ext)` =
sixteen hex digits + `.` + ext. Files are addressed by logical path, not by content, so the name
of `Fox#mesh0/prim0` never changes while its bytes may.

```mojo
var sphere = uv_sphere(0.5, 12, 24)
var lods = sphere.build_lods(lib.ptr())
var cooked = cook_mesh(sphere, lods, String("mat"))
var back = read_mesh(write_mesh(cooked))
assert_equal(back.vertex_bytes, cooked.vertex_bytes)
```

## The cooker (`cook.mojo`, `tools/cinder_cook.mojo`)

```mojo
var manifest = CookManifest.load(String(OUT) + "/manifest.txt")
var report = cook_gltf(lib.ptr(), String("assets/samples/Fox/Fox.glb"), String("Fox"), String(OUT), manifest)
manifest.save(String(OUT) + "/manifest.txt")
print(report.cooked, "cooked,", report.skipped, "up to date")   # report.outputs: logical paths
```

`cook_gltf(lib, source_path, prefix, out_dir, manifest)` produces, for a source with logical
`prefix`:

| logical path | file | content |
|---|---|---|
| `<prefix>#mesh<i>/prim<p>` | `.cmesh` | primitive with LODs (`build_lods` when more than 64 triangles, else one LOD) and its material's logical path |
| `<prefix>#material<m>` | `.cmat` | material with `<prefix>#image<n>` per used slot, and one sampler (filter and address mode): the base colour texture's, else the first present map's in slot order (metallic-roughness, normal, occlusion, emissive) |
| `<prefix>#image<n>` | `.ktx2` | every image referenced by a material: sRGB when used as base colour or emissive, UASTC (and normal-map mode) when used as a normal map, ETC1S otherwise |
| `<prefix>#skeleton<s>` | `.cskel` | `skeleton_from_gltf(asset, s)` |
| `<prefix>#anim<a>` | `.cclip` | the animation's channels copied verbatim |
| `<prefix>#` | `.root` | the source path, as text; this entry carries the source hash |

Incrementality is keyed on content: the source file's FNV-1a hash (`hex64`) is recorded on every
entry. If the root entry's hash is unchanged and every output file still exists, the source is
not even parsed (`report.skipped` = number of outputs); otherwise everything is re-cooked. The
test appends one whitespace byte to a source and asserts a full re-cook. A re-cook prunes the
outputs the source no longer produces (it lost a mesh, material, image, skin or animation):
their entries leave the manifest and their files are deleted, so they are neither served nor
counted in `report.skipped` later.

**Manifest** (`<out_dir>/manifest.txt`): one line per entry, three space-separated fields
`<logical> <source_hash> <file>`; lines with any other field count are ignored on load. Logical
paths therefore must not contain spaces. `CookManifest.find`, `record` (replace or append),
`prune(root, produced)` (drop the entries under `root` not in `produced`) and
`up_to_date(logical, hash, out_dir)` are the whole API; the `AssetServer` loads the same file.
Strings inside cooked files are UTF-8; one that is not valid UTF-8 is rejected on load.

**CLI**: `pixi run cook <out_dir> <source.gltf|.glb> [more sources...]` runs
`tools/cinder_cook.mojo`. Each source's prefix is its file name without directory and extension
(`models/Fox.glb` becomes `Fox`), so two sources with the same base name collide. The manifest is
loaded before and saved after all sources; a failing source is printed and the others still cook;
the exit status is 1 if any failed.

## KTX2 textures (`textures.mojo`)

```mojo
cook_texture_ktx2(lib.ptr(), img, path, srgb=True, normal_map=False, uastc=False)   # quality=128
var loaded = load_ktx2(lib.ptr(), path, prefer_bc7=gfx.bc_supported)
assert_equal(len(loaded.levels), 7)                      # full chain for 64x32
var handles = upload_loaded(res, String("label"), loaded)   # (TextureHandle, TextureViewHandle)
```

Cooking builds the full mip chain on the CPU (`downsample`), writes it into a `ktxTexture2` and
compresses with BasisU: UASTC (higher quality, normal-map mode available) or ETC1S at `quality`
1..255. sRGB images record the transfer function in the data format descriptor. Loading transcodes
to BC7 when `prefer_bc7` (the device advertises BC compression) or RGBA8 otherwise, into
`LoadedTexture(width, height, levels, bc7, srgb)`; uncompressed KTX2 input is accepted only as
RGBA8. `upload_loaded` creates the texture in the matching format (`BC7RGBAUnorm[Srgb]` or
`RGBA8Unorm[Srgb]`) and writes every level, rounding BC7 copies up to whole 4x4 blocks. libktx
errors surface as `ktxTexture2_CreateFromNamedFile ...` and friends; a truncated file raises.

## The asset server (`runtime.mojo`)

`AssetServer(lib, jobs, renderer, root)` reads `root/manifest.txt` and resolves logical paths
through it. The `App` creates one with `App.mount_assets(root)` and calls `assets.update()` once
per frame.

```mojo
var jobs = JobSystem(lib.ptr(), 2, job_capacity=256)
var server = AssetServer(lib.ptr(), _ptr(jobs), _ptr(renderer), String(OUT))

var fox = server.load_mesh(String("Fox#mesh0/prim0"))     # asset index; a job starts now
var fox_again = server.load_mesh(String("Fox#mesh0/prim0"))
assert_equal(fox, fox_again)                              # one record, refcount 2
var clip = server.load_clip(String("Fox#anim0"))
var mat = server.load_material(String("Box#material0"))   # pulls in its KTX2 textures

server.wait_all()                                         # or: server.update() each frame
assert_true(server.state(fox) == AssetState.ready(), server.error(fox))
scene.instances.append(RenderInstance(server.mesh_index(fox), server.material_index(mat), Mat4.identity()))
var duration = server.clip(clip).duration                 # a copy of the Clip
server.release(fox)
server.release(fox)                                       # last reference: GPU mesh removed
```

Every `load_*` returns an `Int` index into `server.records` and counts as one reference; loading
the same logical path again returns the same index and bumps the refcount. Loading it as another
kind (`load_clip` on a mesh path) raises (`is a mesh, not a clip`), and loading a path whose
earlier load failed starts that load again on the same record. A logical path that is
not in the manifest raises immediately (`not in .../manifest.txt (run cinder-cook)`). States are
`AssetState.loading()`, `ready()`, `failed()`; `error(index)` holds the message of a failed load
(the test corrupts a cooked file and checks `cooked` appears in it). Accessors `mesh_index`,
`clip`, `texture_view`, `material_index` raise until the asset is ready.

Loading is split in two:

1. A job (`_load_job`) reads and parses the file into a heap `_LoadSlot`: `read_mesh`,
   `read_clip`, `read_material`, or `load_ktx2` (libktx reads and transcodes on the job). The
   slot's `done` atomic is stored with release ordering last.
2. `update()` on the main thread polls slots with acquire loads and finishes them: a mesh goes
   through `Renderer.add_cooked_mesh` (or `replace_cooked_mesh` on reload), a texture through
   `upload_loaded`, a clip is simply stored. A material's slot yields a `CookedMaterial`; the
   server then calls `load_texture` for each referenced image and the material becomes ready in
   `_finish_materials` once every pending texture is ready (the first failed texture fails the
   material). The sampler is created from the cooked filter and address mode.

`wait_all()` loops `jobs.wait(counter)` + `update()` until nothing is in flight; it is for tests
and loading screens. `release(index)` decrements; at zero it removes the renderer mesh, destroys
the texture and view, frees the material's renderer slot (uniform buffer and bind group; the
slot index is not reused) and its sampler, releases the textures a material pulled in, and marks
the record `failed` with error `released` (indices are never reused). Releasing while a load job
is outstanding waits for the job first; a material that only waits for its textures has no job
and is released at once. `live_count()` counts records with a positive refcount. `__deinit__` waits for all
jobs before freeing slots.

**Hot reload**: `check_for_changes()` compares each ready, referenced asset's file mtime with the
one recorded when its load started and restarts the load for changed files; the count of started
reloads is returned and `records[i].reloads` incremented. For meshes the renderer index is stable
(`replace_cooked_mesh`), so existing `MeshRenderer`s and instances keep working; the test rewrites
a cooked file and asserts the same `mesh_index`. A material reload is in place too: the new
version is uploaded into the same `material_index` once its textures are ready (the renderer
draws the old one until then), the old uniform buffer, bind group and sampler are freed, and the
texture references of the old version are released only then, so an unchanged texture keeps a
single reference and is never reloaded. Replacing and removing go through
`Renderer.replace_material` and `Renderer.remove_material`. Nothing calls `check_for_changes` automatically;
poll it from your frame loop or a debug key.

## Threading rules

- `load_*`, `update`, `wait_all`, `release`, `check_for_changes` and the accessors are
  main-thread calls: `AssetServer` has no locks and its record list is owned by the thread that
  owns the renderer.
- Job threads only run `_load_job`: file I/O, cooked-format parsing and KTX2 transcoding. They
  never touch `Renderer`, `Resources` or wgpu; GPU uploads happen in `update()`.
- Slots are stored as single-element lists (`slots: List[List[_LoadSlot]]`) so their heap
  addresses stay put while the outer list grows; a job may hold a pointer to its slot until its
  `done` flag is set. Do not move or shrink that list.
- `JobSystem.submit` returns `False` when the job table is full; the server then runs the load
  inline on the main thread, so `load_*` can block briefly under a saturated job system.
- `load_gltf`, `upload_gltf`, `instantiate`, `cook_gltf` and `cook_texture_ktx2` are synchronous
  and, where they create GPU resources, main-thread only. `cook_texture_ktx2` asks libktx for four
  compression threads internally (C threads; no Mojo runs on them).

## Pitfalls

- Cooked formats have no migrations: a version mismatch raises `cooked mesh version N (this engine
  reads M)` and the fix is to re-cook (`docs/STATUS.md`). Bump the `*_VERSION` constants in
  `cooked.mojo` when a layout changes.
- The cooker decides incrementality from the source hash alone. Changing cooker code or the
  cooked format does not re-cook an unchanged source; delete the output or the manifest.
- `cinder-cook` derives prefixes from base names, so `a/Fox.glb` and `b/Fox.glb` overwrite each
  other's entries. Call `cook_gltf` with explicit prefixes when that matters.
- A texture reload gets a new `TextureView`. Before the old texture is freed, every ready
  material that binds the old view is rebuilt in place (`Renderer.replace_material`, same index),
  so instances keep drawing. Code that copied a view out with `texture_view()` and bound it
  elsewhere must fetch it again after a reload.
- `AssetServer.clip()` returns a copy of the whole `Clip` (all keyframes); fetch it once when the
  asset becomes ready, not every frame.
- `release` past zero raises `released too often`; indices stay valid but dead, and `state` of a
  released record is `failed`.
- The importer decodes PNG/JPEG on the CPU during `load_gltf`; ADR-0014 notes the sample's Sponza
  start time comes from this. The cooked KTX2 path exists for that reason but the sample does not
  use it yet.
- Pointer-lifetime rules from [`docs/MOJO_NOTES.md`](https://cinder.ash10.dev/docs/mojo-notes.md) apply to every C call here
  (`keep(text)` after passing a `String`'s C span to cgltf or libktx; descriptors owned until the
  call returns). Follow the existing `_cstr` + `keep` pattern when extending the loaders.
