voxolith

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 is
  • put(id, sprite, origin, base) places or moves mover id (its anchor at origin, role v as slot base + v - 1). Nothing changes in the world until commit. A put with 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 returns StampStats.

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

CrowdOptions:

optiondefaultmeaning
fps12clip sampling rate near the camera (the clips' own rate)
near120full rate within this distance
farFps6sampling rate beyond near
freeze400beyond this, poses freeze (members still move)
budgetMs4milliseconds per update allowed for new bakes
headings16heading buckets (stamper only)
stepFartruebeyond near, re-stamp only on a new pose frame (stamper only)
keepModelsfalsekeep each pose's full posed model, not just its sprite, for exact hit tests
cachea 96 MB pose cacheshare 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.

ratsms/framebakes/framebricks/frameupload KB/frame
100.40.15215
1002.80.6515145
50015.51.02347660
100037.91.242931207

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 sharedCPU/framebakes/framestale/framemodelsupload/frame
stamped35.8 ms3.6132.6 MB (world bricks)1.1 MB
baked instances1.0 ms001.4 MB94 KB
posed on the GPU0.7 ms000.4 MB94 KB
1000 rats, each wounded differentlyCPU/framebakes/framestale/framemodelsupload/frame
baked instances6.9 ms6.775935.6 MB124 KB
posed on the GPU1.1 ms0027.9 MB94 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.

On this page