A thousand things
The scene is stated whole, every frame. At two objects that is obviously fine. The question is what it costs at a thousand, and that is what this example is for.
import 'dart:math' as math;
import 'package:flutter/material.dart';import 'package:flutter/scheduler.dart';import 'package:orblit_filament/orblit_filament.dart';import 'package:vector_math/vector_math_64.dart' hide Colors;
void main() => runApp(const CrowdApp());
class CrowdApp extends StatefulWidget { const CrowdApp({super.key});
@override State<CrowdApp> createState() => _CrowdAppState();}
class _CrowdAppState extends State<CrowdApp> with SingleTickerProviderStateMixin { static const _count = 1000;
late final Ticker _clock = createTicker((elapsed) { setState(() => _seconds = elapsed.inMicroseconds / 1e6); })..start();
double _seconds = 0;
@override void dispose() { _clock.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return MaterialApp( home: Scaffold(body: OrblitView(scene: _scene())), ); }
OrblitScene _scene() { return OrblitScene( camera: OrblitCamera( position: Vector3(0, 14, 34), target: Vector3(0, 0, 0), ), objects: [ for (var i = 0; i < _count; i++) _crate(i), OrblitObject( key: 1, transform: Matrix4.identity() ..setTranslation(Vector3(0, -2.6, 0)) ..scaleByDouble(30, 0.06, 30, 1), colour: Vector3(0.05, 0.06, 0.07), castShadows: false, ), ], lights: [ OrblitLight( key: 2, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.4)..normalize(), intensity: 78000, ), ], sky: OrblitSky(ambient: 12000), ); }
OrblitObject _crate(int index) { // A spiral, so changing the count does not make everything jump. final angle = index * 2.399; final radius = math.sqrt(index + 1) * 0.62; final wobble = math.sin(_seconds + index * 0.7);
return OrblitObject( // Its own number, for as long as it exists. Two objects sharing one key // is one of them quietly taking the other's place. key: 1000 + index, transform: Matrix4.identity() ..setTranslation( Vector3( math.cos(angle) * radius, -2.2 + wobble * 0.6, math.sin(angle) * radius, ), ) ..rotateY(angle + wobble * 0.3) ..scaleByDouble(0.34, 0.5, 0.34, 1), colour: Vector3(0.85, 0.42, 0.16), // A thousand shadow casters is a thousand things in the shadow map. // Worth being deliberate about at this count. castShadows: index % 7 == 0, ); }}Nothing on the wire says what changed
Section titled “Nothing on the wire says what changed”There is no add, no move and no remove. Every frame is a complete
description, and the renderer works out the difference by key.
That makes the reconciliation rules short enough to state in full:
- A key that was there last frame and is there now: the transform is compared before it is written, because writing one dirties the node and everything under it.
- A key that is new: an instance is taken from a pool if there is one.
- A key that is gone: the instance goes back to the pool rather than being destroyed.
- Only a change of
meshrebuilds anything.
Stop half of them moving and what the renderer does drops with them, without anybody telling it that half of them stopped. That is the property the whole arrangement is for.
Keys are yours, and they are load-bearing
Section titled “Keys are yours, and they are load-bearing”key: 1000 + index is arbitrary. It only has to be stable across frames
and unique within the scene.
Both halves bite. A key taken from a list position changes when something earlier is removed, and every object after it silently becomes a different object: meshes rebuild, and anything that was interpolating starts again. Two objects sharing a key is worse, and quieter. One of them simply never appears, with no error, because from the renderer’s side that is a single object being described twice.
The ground plane above is key: 1 and the sun is key: 2. Lights and
objects are keyed separately, so those two don’t collide, but the crates
still start at 1000 to leave obvious room. It costs nothing and saves you an
afternoon.
When a thousand is not enough
Section titled “When a thousand is not enough”Individually keyed objects are the right tool up to a few thousand. Past
that, when the things are the same mesh and you don’t need to address them
one at a time, OrblitScene.populations takes a buffer of transforms and
draws them 64 to a call. Grass, crowds, debris, forests.
The difference is addressability: an object has a key you can reason about, and a population is a block of matrices. Reach for the population when you stop caring which one is which.
