DocsGuides

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:

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. 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 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 + f32s 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 QuerySpecs (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) 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 Components 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 MeshRenderers 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.
View as MarkdownSource: docs/guides/scene.md