Skip to content

Stating a scene

Every frame, you hand the renderer a complete OrblitScene. Not a diff, not a set of commands: the whole thing, built from scratch.

OrblitScene(
camera: ...,
objects: [...], // all of them, every frame
lights: [...],
materials: [...],
)

This is the same bargain Flutter makes about widgets, and it’s worth understanding why it isn’t as expensive as it looks.

Building the scene object costs an allocation and a loop over your game state. For a few thousand objects that’s microseconds, and it’s work you were doing anyway to decide what to draw.

Sending it doesn’t cost a re-upload. The objects are keyed, so object 7 this frame is object 7 from last frame. The renderer compares the two and sends only what changed, and an object whose transform is identical to last frame’s costs nothing beyond the comparison.

Which leaves the expensive thing, talking to the GPU, proportional to what actually changed, and the cheap thing, describing the world, proportional to how big the world is.

The scene can’t drift. There is no scene.add() you forgot to pair with a scene.remove(). An object that isn’t in this frame’s list isn’t in the scene, full stop. That whole category of bug, where the renderer’s idea of the world and the game’s idea of it slowly part company, doesn’t exist here.

Time travel is free. If the scene is a function of your state, rewinding your state rewinds the picture. That’s what makes the sequencer and the network’s interpolation possible without either of them knowing anything about the renderer.

Testing is possible. A scene is a value. You can build one in a unit test and assert on it, with no window, no GPU and no renderer. Most of orblit_filament’s hundred-odd tests do exactly that.

A key is an int you choose. Two rules:

  • Stable across frames. The renderer identifies an object by its key. If the key changes, the old object is destroyed and a new one created, which is occasionally what you want and usually a bug.
  • Unique within the scene. Two objects sharing a key are one object, described twice, and which description wins isn’t something to rely on.

The usual answer is to use your entity id, which already has both properties.

An OrblitObject is tracked individually: it has a key, it’s compared against last frame, it gets its own entity, and it gets its own draw call. That’s the right trade for hundreds of things and the wrong one for hundreds of thousands.

An OrblitPopulation is the other end of that. A buffer of transforms and colours, drawn instanced, sixty-four to a draw, with no keys, no per-item comparison and no per-item state. You hand over a Float32List and it gets drawn.

OrblitScene(
camera: ...,
objects: [player, ...props], // dozens, each individual
populations: [OrblitPopulation( // hundreds of thousands, in bulk
key: 1,
transforms: _transforms, // 16 floats each
colours: _colours, // 3 floats each
minimum: Vector3(-50, 0, -50), // the bounds they all sit inside,
maximum: Vector3(50, 4, 50), // so the lot can be culled at once
)],
)

Two hundred thousand members runs at about 27 ms a frame on an M-series Mac. The Benchmark example in the gallery lets you move the dials yourself, and reports what a frame actually costs the GPU rather than a frame rate. That’s deliberate: how often a frame is presented is the display’s business, and it looks identical whether the engine has ten per cent of headroom or two hundred.

A scene can ask for something that cannot be given: a mesh file that will not load, more lights than the view can shade. Those come back through onSceneNotes as a map, keyed by what was asked for and valued by what was wrong with it.

OrblitView(
scene: scene,
onSceneNotes: (notes) => setState(() => _problems = notes),
)

Deliberately back, rather than into a log. A scene gets built out of things somebody typed a path to, and the person who typed it is the one who needs to know it was wrong.