Assets guide (cinder/assets)
cinder/assets gets content from disk into the renderer. It has two paths:
- Direct import:
load_gltfparses a.gltf/.glbthrough cgltf into a CPU-sideGltfAsset;upload_gltf/instantiatepush it to the GPU. Simple, synchronous, and what the sample's Sponza level and the golden tests use. - Cooked assets:
cook_gltf(and thecinder-cookCLI) turn a glTF source into versioned binary files (.cmesh,.cmat,.cclip,.cskel,.ktx2) named by stable ids and listed in a manifest; theAssetServerloads 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. Tests:
test_gltf_loader.mojo,
test_cook.mojo,
test_textures.mojo,
test_asset_server.mojo,
test_fuzz.mojo. Related decisions:
ADR-0003 (C shim, no Mojo in C callbacks),
ADR-0014 (the sample imports glTF directly and does
not yet use the cooked path).
glTF import #
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
GltfChannels: 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:
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_meshes 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).
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.
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) #
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 pathscook_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) #
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.
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 removedEvery 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:
- A job (
_load_job) reads and parses the file into a heap_LoadSlot:read_mesh,read_clip,read_material, orload_ktx2(libktx reads and transcodes on the job). The slot'sdoneatomic is stored with release ordering last. update()on the main thread polls slots with acquire loads and finishes them: a mesh goes throughRenderer.add_cooked_mesh(orreplace_cooked_meshon reload), a texture throughupload_loaded, a clip is simply stored. A material's slot yields aCookedMaterial; the server then callsload_texturefor each referenced image and the material becomes ready in_finish_materialsonce 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 MeshRenderers 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_changesand the accessors are main-thread calls:AssetServerhas 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 touchRenderer,Resourcesor wgpu; GPU uploads happen inupdate(). - 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 itsdoneflag is set. Do not move or shrink that list. JobSystem.submitreturnsFalsewhen the job table is full; the server then runs the load inline on the main thread, soload_*can block briefly under a saturated job system.load_gltf,upload_gltf,instantiate,cook_gltfandcook_texture_ktx2are synchronous and, where they create GPU resources, main-thread only.cook_texture_ktx2asks 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*_VERSIONconstants incooked.mojowhen 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-cookderives prefixes from base names, soa/Fox.glbandb/Fox.glboverwrite each other's entries. Callcook_gltfwith 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 withtexture_view()and bound it elsewhere must fetch it again after a reload. AssetServer.clip()returns a copy of the wholeClip(all keyframes); fetch it once when the asset becomes ready, not every frame.releasepast zero raisesreleased too often; indices stay valid but dead, andstateof a released record isfailed.- 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.mdapply to every C call here (keep(text)after passing aString's C span to cgltf or libktx; descriptors owned until the call returns). Follow the existing_cstr+keeppattern when extending the loaders.