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), withSceneComponent/SceneRegistrydeciding which component types are saved and how old documents migrate.render_sync.mojo: the render components (MeshRenderer,LightSource,CameraComponent,JointPalette) andRenderBridge, which turns the world into the renderer'sRenderScene.gltf_spawn.mojo:spawn_gltf, which turns a loadedGltfAssetinto an entity hierarchy, andcollision_trianglesfor 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. 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:
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:
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:
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=42Grammar as implemented by read_text:
- The first line is
cinder-scene <version>; anything else ismissing header. - Blank lines and lines whose first non-blank character is
#are skipped. entity <n>opens entityn; 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 firstentityis an error. - Values:
"..."string with\",\\and\nescapes;#nentity 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, or0x+ 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.0is 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:
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.
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)andParentpointing 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 theParentlinks never form a cycle; - for a node with a mesh: when the mesh has one primitive, the
MeshRenderergoes on the node entity itself; otherwise one child entity per primitive with an identityLocalTransform. Primitives without a material useuploaded.default_material. - skinned mesh nodes are included unless
skinned_meshes=False; the caller attaches anAnimatorRefso the animation system publishes aJointPalettefor 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.
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_gltfandRenderBridge.buildall touch theWorld(instantiate and spawn structurally) and are main-thread calls.spawn_gltfandupload_gltfcreate GPU resources throughRenderer/Resources; GPU work is main-thread only in Cinder.read_text,read_binary,write_text,write_binaryand migrations only touch aSceneDocand could run anywhere, but nothing in the engine currently runs them off the main thread.JointPaletteis written byAnimationSystem.publishon the main thread after its pose jobs complete; the bridge reads it afterwards in the same frame.
Pitfalls #
SceneRegistryhas no duplicate-name check: registering two types with the samescene_name()means the first wins on load and both are written on save.FieldReader.floataccepts an integer field (textx=1parses as an int), butintdoes not accept a float,boolaccepts onlytrue/false(not0/1), andstringdoes not accept a bare token, so write strings quoted.to_docwritesParentreferences only ifParentis registered as aSceneComponent; the engine'sParent,LocalTransformand render components are plainComponents 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 afterinstantiate(ADR-0014).- A
FieldReaderthat 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 freshWorldor clear the level first, as the sample does. RenderBridgepicks the first active camera in archetype order; keep exactly oneCameraComponentactive.spawn_gltfalways uploads a fresh copy of the asset's meshes and materials; spawning the same asset twice doubles GPU memory. ReuseSpawnedGltf.uploadedids with your ownMeshRenderers for repeated props.- Mutations of scene bytes must raise, never crash:
tests/assets/test_fuzz.mojomutates text and binary documents and asserts exactly that. Keep new field kinds bounds-checked on read.