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

# Scene guide (`cinder/scene`)

`cinder/scene` is the layer between the ECS and the rest of the engine's content pipeline. It has
three independent parts:

- `format.mojo`: versioned scene serialisation, a diffable text format and a binary format over
  the same intermediate document (`SceneDoc`), with `SceneComponent` / `SceneRegistry` deciding
  which component types are saved and how old documents migrate.
- `render_sync.mojo`: the render components (`MeshRenderer`, `LightSource`, `CameraComponent`,
  `JointPalette`) and `RenderBridge`, which turns the world into the renderer's `RenderScene`.
- `gltf_spawn.mojo`: `spawn_gltf`, which turns a loaded `GltfAsset` into an entity hierarchy, and
  `collision_triangles` for static mesh colliders.

It imports `cinder/ecs`, `cinder/assets` (glTF), `cinder/render` and `cinder/gfx`. `cinder/anim`
imports `JointPalette` from here, and `cinder/app` owns a `SceneRegistry` and a `RenderBridge`.

API reference: [`docs/api/scene.md`](https://cinder.ash10.dev/docs/api/scene.md). Tests:
`tests/scene/test_scene_format.mojo`,
`tests/scene/test_render_sync.mojo`,
`tests/scene/test_gltf_spawn.mojo`, and the loader fuzz
test `tests/assets/test_fuzz.mojo` which mutates scene files.
[ADR-0014](https://cinder.ash10.dev/docs/adr/0014-sample-game-and-performance.md) describes how the sample game saves through
scene components and spawns its level through `spawn_gltf`.

## Scene components and the registry

A component becomes saveable by conforming to `SceneComponent`, which extends `Component` with a
stable name and a field writer/reader pair:

```mojo
from cinder.scene import FieldReader, FieldWriter, SceneComponent

@fieldwise_init
struct Position(SceneComponent):
    var x: Float32
    var y: Float32
    var z: Float32

    @staticmethod
    def component_id() -> Int:
        return 20

    @staticmethod
    def scene_name() -> String:
        return String("Position")

    def write_scene(self, mut w: FieldWriter):
        w.float(String("x"), self.x)
        w.float(String("y"), self.y)
        w.float(String("z"), self.z)

    @staticmethod
    def read_scene(mut r: FieldReader) -> Position:
        return Position(r.float(String("x"), 0.0), r.float(String("y"), 0.0), r.float(String("z"), 0.0))
```

`FieldWriter` has `float`, `int`, `bool`, `string`, `entity`, `floats` (any fixed count), `vec3`
and `mat4` (16 floats, column-major). `FieldReader` has the matching readers; every scalar reader
takes a default used when the field is absent, `entity(name)` returns `Entity.null()` when absent
or unresolved, `floats(name, count)` returns zeros, `mat4(name)` identity. A field present with
the wrong kind sets `reader.error` (for example `field text: expected a string`) and the load
fails with that message. Field order inside a record is the order of `write_scene` calls, which
is what keeps the text output stable.

Entity references are written as file-local indices: `w.entity` maps the entity's slot through
`index_of`, so a reference to an entity that is not in the document becomes `#-1` and reads back
as null. `r.entity` maps the index back to the entity spawned for that record, so references
survive a round trip into a different world (the test asserts both sides point at the same
file-local slot).

The `SceneRegistry` holds the registered types as type-erased thin read/write functions plus the
migrations:

```mojo
var reg = SceneRegistry()
reg.register[Position]()
reg.register[Label]()
reg.register[Link]()
```

Order of registration is the order components appear inside each entity record. Only registered
types are saved; a document naming an unregistered component fails to instantiate with
`unknown component`.

## Documents, text and binary

`SceneDoc(version, entities: List[EntityRecord])`; an `EntityRecord` is a list of
`ComponentRecord(name, fields)`; a `Field` has a `name`, a `kind` (`KIND_FLOAT`, `KIND_INT`,
`KIND_BOOL`, `KIND_STRING`, `KIND_ENTITY`, `KIND_FLOATS`) and the value in `number`/`floats`,
`integer`, or `text`. The document is the unit that is read, migrated and written, in either
format, independently of a world:

```mojo
var doc = reg.to_doc(world)                   # every live entity, archetype order
var text = write_text(doc)                    # "cinder-scene 2\nentity 0\n  Position x=1.5 ..."
var back = read_text(text)
var world2 = World()
var spawned = reg.instantiate(world2, back)   # entities in file order
assert_equal(write_text(reg.to_doc(world2)), text)   # byte-identical: diffable

var bytes = write_binary(reg.to_doc(world))
var doc2 = read_binary(bytes.copy())
```

`to_doc` snapshots every live entity, including entities with no registered component (they get
an empty record). The sample game drops those before saving (ADR-0014) because it rebuilds the
static level on load and only wants gameplay entities in the file.

### Text format (version 2)

```
cinder-scene 2
entity 0
  Position x=1.5 y=-2.0 z=0.1
  Label text="quote \" backslash \\ newline\nend" visible=false layer=7
entity 1
  Link target=#0 transform=[1.0,0.0,0.0,0.0, ... ,3.0,1.0]
  Health health=42
```

Grammar as implemented by `read_text`:

- The first line is `cinder-scene <version>`; anything else is `missing header`.
- Blank lines and lines whose first non-blank character is `#` are skipped.
- `entity <n>` opens entity `n`; indices must be consecutive from 0 (`out of order`).
- Every other line is `<ComponentName> key=value ...`; leading whitespace is ignored. A component
  line before the first `entity` is an error.
- Values: `"..."` string with `\"`, `\\` and `\n` escapes; `#n` entity reference; `true`/`false`;
  `[a,b,c]` float list; an all-digit token (optionally `-`) is an integer; anything else is a
  float. Floats are the shortest decimal that round-trips, or `0x` + eight hex digits of the
  IEEE-754 bits when no decimal does (`format_float`/`parse_float`); the test checks 2000 random
  bit patterns round-trip exactly, and `-0.0` is preserved.
- Spaces and tabs separate tokens except inside quotes or brackets; an unterminated quote or
  bracket is an error.
- Text is UTF-8 and kept byte for byte: only `"`, `\` and newline are escaped, so non-ASCII
  strings (`Zoë — 東京 🚀`) appear as themselves in the file and round-trip exactly.

### Binary format

Little-endian, magic `CSCN`, then `u32 version`, `u32 entity count`, and per entity `u32
component count`, per component `string name` (`u32 length` + bytes), `u32 field count`, per field
`string name`, `u8 kind`, then the payload: `f32` for floats; two `u32` (low, high) of an `i64`
for ints, bools and entity indices; a `string` for strings; `u32 count` + `f32`s for float lists.
String bytes are UTF-8; a string that is not valid UTF-8 is rejected (`not valid UTF-8`).
`read_binary` bounds-checks every read and rejects implausible counts (more than ten million
entities, ten thousand components or fields, a million floats), bad magic and truncation
(`unexpected end of data`). Neither format is compressed.

## Versioning and migrations

`SCENE_VERSION` is 2. A document's version is whatever its header says; `SceneRegistry.migrate`
applies registered `(from_version, MigrationFn)` pairs one step at a time until the document is at
`SCENE_VERSION`, raising `no migration to N` when a step is missing and `newer than this engine`
for a document from the future. `instantiate` refuses a document that is not at the current
version, so the call order is read, migrate, instantiate:

```mojo
def _migrate_1_to_2(doc: Pointer[SceneDoc, MutUntrackedOrigin]):
    for i in range(len(doc[].entities)):
        for c in range(len(doc[].entities[i].components)):
            if doc[].entities[i].components[c].name == "Health":
                _ = doc[].entities[i].components[c].rename_field(String("hp"), String("health"))

var migrate: def(Pointer[SceneDoc, MutUntrackedOrigin]) thin -> None = _migrate_1_to_2
reg.add_migration(1, migrate)
var old_text = String("cinder-scene 1
...")   # a document saved by version 1
var doc = read_text(old_text)
reg.migrate(doc)                   # version is now 2
var spawned = reg.instantiate(world, doc)
```

Migrations operate on the document, not on components, so they can rename fields
(`ComponentRecord.rename_field`), rename components, add fields or drop records with ordinary
list code; `EntityRecord.find` and `ComponentRecord.find` locate records by name. Because
unknown fields are ignored and missing fields take defaults, adding a field to a component does
not need a migration; renaming or re-interpreting one does. Bump `SCENE_VERSION` in
`format.mojo` whenever a migration is added; there is a 1000-step guard against a migration that
does not advance the version.

## Render bridge

`render_sync.mojo` defines the components the renderer understands:

| component | id | fields |
|---|---|---|
| `MeshRenderer` | 4 | `mesh`, `material` (renderer indices), `cast_shadows` |
| `LightSource` | 5 | `kind: LightKind`, `color`, `intensity`, `range`, `inner_angle`, `outer_angle`, `cast_shadows`; position and direction (local -Z) come from `WorldTransform` |
| `CameraComponent` | 6 | `fov_y`, `near`, `active`; placed by `WorldTransform` (-Z forward, +Y up), aspect kept from the scene's current camera |
| `JointPalette` | 9 | `joints: List[Mat4]`, `morph_weights: List[Float32]`, written each frame by the animation system for entities with an `AnimatorRef` |

`RenderBridge.build(world, scene)` clears `scene.instances`, `scene.lights` and `scene.joints`
and refills them from three persistent `QuerySpec`s (each paired with `WorldTransform`), then sets
`scene.camera` from the first active `CameraComponent` it finds. An instance whose chunk also has
a `JointPalette` gets its joints appended to `scene.joints` and `joint_offset` set, and its morph
weights copied. The bridge reuses its chunk scratch and the scene's lists, so a steady-state build
allocates nothing. `build_render_scene(world, scene)` is the one-off form for tests.

```mojo
var floor = world.spawn()
world.add[LocalTransform](floor, LocalTransform(Mat4.identity()))
world.add[MeshRenderer](floor, MeshRenderer(ground, grey, False))
var sun = world.spawn()
world.add[LocalTransform](sun, LocalTransform(Mat4.rotation(Quat.from_axis_angle(Vec3.unit_x(), -1.2))))
world.add[LightSource](sun, LightSource(LightKind.directional(), Vec3.one(), 3.0, 0.0, 0.0, 0.0, True))
var camera = world.spawn()
world.add[LocalTransform](camera, LocalTransform(Mat4.look_at(eye, at, Vec3.unit_y()).inverse()))
world.add[CameraComponent](camera, CameraComponent(0.9, 0.1, True))

_ = propagate_transforms(world)
var scene = RenderScene(Camera.look_at(Vec3.zero(), Vec3.forward()))
var count = build_render_scene(world, scene)      # 1 instance, 1 light, camera placed
renderer.render(scene, target, width, height)
```

Note the camera: `LocalTransform` is the camera's *world placement*, so a view matrix from
`Mat4.look_at` must be inverted. Entities without a `WorldTransform` are invisible to the bridge;
run `propagate_transforms` first (the `App` does this every frame before `_render`).

## Spawning glTF

`spawn_gltf(renderer, res, asset, world, root_transform, lods, cast_shadows, skinned_meshes)`
uploads the asset (`upload_gltf`, see the [assets guide](https://cinder.ash10.dev/docs/guides/assets.md)) and builds entities:

- one root entity carrying `LocalTransform(root_transform)`;
- one entity per glTF node reachable from the asset's roots, with `LocalTransform(node.local)`
  and `Parent` pointing at the node's parent entity (or the root). A node the file reaches more
  than once (listed as a root and as a child, shared by two parents, or inside a cycle) is
  spawned once, the first time it is reached; when its glTF parent has not been spawned by then
  it hangs under the root, so the `Parent` links never form a cycle;
- for a node with a mesh: when the mesh has one primitive, the `MeshRenderer` goes on the node
  entity itself; otherwise one child entity per primitive with an identity `LocalTransform`.
  Primitives without a material use `uploaded.default_material`.
- skinned mesh nodes are included unless `skinned_meshes=False`; the caller attaches an
  `AnimatorRef` so the animation system publishes a `JointPalette` for them.

The result `SpawnedGltf` holds `root`, `node_entities` (null for nodes outside the scene graph),
`primitive_entities` (node order, then primitive order) and the `UploadedGltf` ids;
`entity_of_node(i)` is a bounds-checked lookup. Nothing is propagated or rendered yet: call
`propagate_transforms` and the bridge as usual.

```mojo
var hero = load_gltf(lib.ptr(), String("assets/game/character/KayKit_AnimatedCharacter.glb"))
var spawned = spawn_gltf(renderer, res, hero, world, Mat4.translation(Vec3(1.0, 0.0, 0.0)))
var body = spawned.primitive_entities[0]
assert_true(world.has[MeshRenderer](body) and world.has[Parent](body))

# Static level: no skinned renderers, plus a mesh collider from the same asset.
_ = spawn_gltf(renderer, res, sponza, world, root, True, True, False)
var tris = collision_triangles(sponza, root)       # (List[Vec3], List[UInt32]) in world space
var shape = physics.mesh(tris[0], tris[1])
```

`collision_triangles` walks every non-skinned mesh node, transforms positions by
`root_transform * node.world` and concatenates index lists with per-primitive base offsets; it does
not depend on the ECS. The sample's Sponza level (`sample/game/level.mojo`) is exactly this pair of
calls (ADR-0014).

## Threading rules

- `to_doc`, `instantiate`, `migrate`, `spawn_gltf` and `RenderBridge.build` all touch the
  `World` (instantiate and spawn structurally) and are main-thread calls.
- `spawn_gltf` and `upload_gltf` create GPU resources through `Renderer`/`Resources`; GPU work is
  main-thread only in Cinder.
- `read_text`, `read_binary`, `write_text`, `write_binary` and migrations only touch a `SceneDoc`
  and could run anywhere, but nothing in the engine currently runs them off the main thread.
- `JointPalette` is written by `AnimationSystem.publish` on the main thread after its pose jobs
  complete; the bridge reads it afterwards in the same frame.

## Pitfalls

- `SceneRegistry` has no duplicate-name check: registering two types with the same
  `scene_name()` means the first wins on load and both are written on save.
- `FieldReader.float` accepts an integer field (text `x=1` parses as an int), but `int` does not
  accept a float, `bool` accepts only `true`/`false` (not `0`/`1`), and `string` does not
  accept a bare token, so write strings quoted.
- `to_doc` writes `Parent` references only if `Parent` is registered as a `SceneComponent`; the
  engine's `Parent`, `LocalTransform` and render components are plain `Component`s and are not
  saved unless you wrap them. The sample saves its own gameplay components and rebuilds
  transforms, bodies and renderables in a "realise" pass after `instantiate` (ADR-0014).
- A `FieldReader` that reports an error still returns a default-constructed value; the registry
  then raises, so a partially instantiated world is possible after a failed load. Load into a
  fresh `World` or clear the level first, as the sample does.
- `RenderBridge` picks the first active camera in archetype order; keep exactly one
  `CameraComponent` active.
- `spawn_gltf` always uploads a fresh copy of the asset's meshes and materials; spawning the same
  asset twice doubles GPU memory. Reuse `SpawnedGltf.uploaded` ids with your own `MeshRenderer`s
  for repeated props.
- Mutations of scene bytes must raise, never crash: `tests/assets/test_fuzz.mojo` mutates text
  and binary documents and asserts exactly that. Keep new field kinds bounds-checked on read.
