Stamping and crowds
Moving entities through a streamed brick world with makeBrickStamper, and animating many rigged members on a budget with makeCrowd, pose caches and LOD.
A creature, a thrown limb or a projectile can be stamped into the same brick grid as the scenery. Moving it means putting back what it covered and writing it somewhere else. A streamed world has no dense copy to restore from, so the engine's stamper keeps, per brick, exactly the cells it overwrote. For many animated things, a crowd poses, caches and stamps them on a time budget.
The brick stamper
makeBrickStamper(target, { size? })
returns a BrickStamper:
import { makeBrickStamper, toSprite } from "@voxolith/engine";
const stamper = makeBrickStamper(renderer, { size: SIZE });
const sprite = toSprite(barrel.model); // surface voxels only
stamper.put(1, sprite, { x: 40, y: 12, z: 80 }, barrelBase);
stamper.commit(); // write
stamper.put(1, sprite, { x: 41, y: 12, z: 80 }, barrelBase);
const { bricks, voxels, movers } = stamper.commit(); // restore where it was, write where it isput(id, sprite, origin, base)places or moves moverid(its anchor atorigin, rolevas slotbase + v - 1). Nothing changes in the world untilcommit. Aputwith the same sprite, rounded position and base is ignored.remove(id)takes it out at the next commit.commit()touches only bricks where a changed mover was or now is. For each, inside the same brick edit, it restores what the movers had overwritten (reading the brick's current contents, so scenery edited meanwhile survives), then writes every mover in that brick in a stable order and records what they cover now. It returnsStampStats.
target is a MultiBrickTarget: an
edit(box, fill), and optionally editMany(boxes, fill), which the stamper prefers so a frame's
bricks go out in one upload. Renderer has both.
Pass size to drop voxels outside the world.
Sprites
Movers are handed in as Sprites: the voxels worth
writing as local indices and role values, grouped by destination brick at put.
toSprite(model, { surfaceOnly? }) keeps only
surface voxels by default: the inside of a solid mover is never seen, and skipping it more than
halves the work. Pass { surfaceOnly: false } for a mover that will be cut open.
Crowds
A crowd member is a rigged entity plus where it is, which way it faces, and its
animator. Each update,
makeCrowd picks every member's pose
frame, fetches the baked pose from a shared cache or bakes it within a millisecond budget, and
hands the result to a stamper or an instance layer.
import { makeBrickStamper } from "@voxolith/engine";
import { makeAnimator, makeCrowd, type CrowdMember } from "@voxolith/engine/animation";
const crowd = makeCrowd({ stamper: makeBrickStamper(renderer, { size: SIZE }), near: 140, farFps: 6, freeze: 700, budgetMs: 5 });
const rats: CrowdMember[] = variants.map((entity, i) => ({
id: i + 1, entity, variant: `rat${i}`, anim: makeAnimator(entity, "walk"),
base: bases[i], x: 100 + i * 20, y: 1, z: 100, yaw: 0,
}));
// every frame: move them, advance their animators, then
for (const r of rats) r.anim.update(dt);
const stats = crowd.update(rats, camPos);CrowdMember fields: a stable id
(unique among the stamper's movers), the entity, a variant key shared by members that look
identical, the anim, the palette base, the position x, y, z of the anchor and yaw in
radians (0 facing +z). To show damage, give the member its own rest model (from
wound or sever) and bump damage so cached poses of the old
body are not reused.
Output: stamper or instances
Exactly one of stamper or instances is required.
Members are written into the world's bricks through a BrickStamper. Poses
are baked at a bucketed heading (headings, default 16; plenty at voxel scale), stored as sprites,
and members land on whole voxels. Beyond near, a member is re-stamped only when its pose frame
changes (stepFar, default on), so distant members move in steps like sprite animation instead of
re-stamping whenever their rounded position changes.
Level of detail and budget
| option | default | meaning |
|---|---|---|
fps | 12 | clip sampling rate near the camera (the clips' own rate) |
near | 120 | full rate within this distance |
farFps | 6 | sampling rate beyond near |
freeze | 400 | beyond this, poses freeze (members still move) |
budgetMs | 4 | milliseconds per update allowed for new bakes |
headings | 16 | heading buckets (stamper only) |
stepFar | true | beyond near, re-stamp only on a new pose frame (stamper only) |
keepModels | false | keep each pose's full posed model, not just its sprite, for exact hit tests |
cache | a 96 MB pose cache | share one PoseCache between crowds |
Once the budget is spent in an update, a member whose pose is not cached keeps last frame's pose
(counted as deferred) rather than stalling the frame. Poses are keyed by variant, damage, clip,
frame and heading bucket, so a herd sharing a few variants and clips asks for the same few hundred
poses over and over. crowd.last(id) returns the
BakedPose a member was last shown with,
including its bone matrices, for hit tests. update returns
CrowdStats: members, bakes,
deferred, cache hits, and the stamper's bricks, voxels and movers.
Cost
Measured headless with bun run --cwd generators/creature bench: CPU per frame for the crowd
update and brick edits, not the upload or the draw, for rats wandering a 512 x 512 field at 60 Hz,
stamped.
| rats | ms/frame | bakes/frame | bricks/frame | upload KB/frame |
|---|---|---|---|---|
| 10 | 0.4 | 0.1 | 52 | 15 |
| 100 | 2.8 | 0.6 | 515 | 145 |
| 500 | 15.5 | 1.0 | 2347 | 660 |
| 1000 | 37.9 | 1.2 | 4293 | 1207 |
Past a few hundred, brick re-encoding dominates. The instance path removes both the re-encode and
the upload, which is why examples/valley draws its rats as instances; examples/swarm shows the
stamped path live.
Posed on the GPU
makeCrowd({ instances, rigged: true }) skips baking altogether. Each variant's rest model is
prepared once (prepareRigged: bone ids beside the roles, the rig's cover applied) and uploaded;
every member is then one instance carrying its bone matrices, and the renderer poses it while it
draws. There are no pose models, no pose cache of bakes and no per-pose uploads, and members turn
and move smoothly like any instance. The frame LOD (near, farFps, freeze) still applies; it
now only saves matrix work.
const crowd = makeCrowd({ instances: makeInstanceLayer(renderer), rigged: true });
// per frame, as with any crowd:
for (const m of members) m.anim.update(dt);
crowd.update(members, camera);It draws exactly what baking writes cell for cell (bake step one, checked in
engine/tools/verify-animation.ts), plus the joint weld; the bake's extra passes that bridge
diagonal gaps and fill joint cracks are not reproduced, which in practice is not visible (compare
them side by side in examples/rigged).
Each pose (a set of bone matrices for a clip frame) is packed in the model's own space and uploaded once, then shared by every rat showing it, wherever it stands; per frame a rat costs one instance record, as a baked instance does.
What each path costs, headless (bun run --cwd generators/creature bench --mode=stamped|instances|rigged [--damaged]:
the crowd's real code, the renderer's brick encoding and instance packing), for 1000 rats. "Stale"
counts rats shown last frame's pose because the bake budget ran out:
| 1000 rats, poses shared | CPU/frame | bakes/frame | stale/frame | models | upload/frame |
|---|---|---|---|---|---|
| stamped | 35.8 ms | 3.6 | 13 | 2.6 MB (world bricks) | 1.1 MB |
| baked instances | 1.0 ms | 0 | 0 | 1.4 MB | 94 KB |
| posed on the GPU | 0.7 ms | 0 | 0 | 0.4 MB | 94 KB |
| 1000 rats, each wounded differently | CPU/frame | bakes/frame | stale/frame | models | upload/frame |
|---|---|---|---|---|---|
| baked instances | 6.9 ms | 6.7 | 759 | 35.6 MB | 124 KB |
| posed on the GPU | 1.1 ms | 0 | 0 | 27.9 MB | 94 KB |
When poses repeat, baked instances and posing on the GPU cost the CPU and the bus about the same;
when they do not (damage, per-member variation), baking cannot keep up and most members freeze on
an old pose, while posing on the GPU animates every one. The price is GPU time per sample: on a
desktop Radeon, 300 rats in examples/swarm draw at 46 fps as baked instances and 29 fps posed on
the GPU (60 stamped, which costs 12 ms of CPU a frame instead). Baked instances remain the default;
choose rigged when poses stop repeating, when bakes stall members, or when pose memory matters.
Posing on the GPU is rigid per bone (each voxel follows one bone, welded at the joints), needs a dense rest model and at most 32 bones, and has not been measured on a phone yet; see the renderer's limits.
References
- Holger Gruen, Carsten Benthin, Michael Kern, David McAllister. Ray Tracing Massive Amounts of Animated Geometry. Proc. ACM Comput. Graph. Interact. Tech. 9(4), Article 49 (HPG 2026). doi:10.1145/3820014
- Chih-Chen Kao, Grzegorz Makowski, Shin Fujieda, Takahiro Harada. Voxel Deformation-Aware Neural Intersection Function. Eurographics 2026 Short Papers. doi:10.2312/egs.20261026
What Voxolith took from them and what is its own: Research and credits.
Instances
Drawing entity models by reference with makeInstanceLayer: static and dynamic placements, any yaw, mirroring, scaled placements for coarse placeholders, and palettes outside the world's 256 slots.
Workers
Running Voxolith generators off the main thread with makeGeneratorPool and serveGenerators: packed, transferred results, queue priorities and aborts, the IndexedDB model cache and its controls, the scene worker that encodes models and bakes static placements, its cache of encodings and bakes, cache caps a game sizes itself, and chunk fill workers for a streamed ground.