Skip to content

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 the answer is what this example is for.

lib/main.dart
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,
);
}
}

There is no add, no move and no remove. Every frame is a complete description, and the renderer works out the difference by key.

Which means the reconciliation rules are 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 mesh rebuilds 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.

key: 1000 + index is arbitrary. It only has to be stable across frames and unique within the scene.

Both halves bite. A key derived 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’s 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.

Individually keyed objects are the right tool up to a few thousand. Past that, when the things are the same mesh and you do not 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.