Instances
Drawing models by reference with addModel and setInstances, at any yaw, mirrored, at fractional positions and enlarged by an integer scale, with palettes beyond the world's 256; encoding models and baking static sets on a worker.
Stamping writes every voxel of a model into the world's bricks. A forest of six distinct trees then costs six trees' worth of memory per copy, a turned tree has to be resampled into new voxels, and a moving thing re-uploads bricks every frame. Instances keep one copy of each model on the GPU and draw it wherever it is placed.
Models and placements
renderer.addModel(source) uploads a model once
into the shared brick pool and returns its id. A
ModelSource is a size plus either dense
data or a sparse volume. Its voxel values are role
values (1 and up), not palette slots: each placement maps them.
setInstances(list) places them. Each
Instance is:
Prop
Type
const oak = renderer.addModel({ size: tree.model.size, data: tree.model.data });
const oakPalette = renderer.addPalette(colours); // RGBA per role, role r at entry r - 1
renderer.setInstances([
{ model: oak, x: 120, y: 14, z: 88, yaw: 0.4, anchor: tree.model.anchor, base: oakPalette },
{ model: oak, x: 170.5, y: 16, z: 60, yaw: 2.1, mirror: true, anchor: tree.model.anchor, base: oakPalette },
]);addModel encodes the model's bricks on the main thread, and a fine scene has dozens of big
models to encode; that can run on a worker instead (see
encoding models on a worker). removeModel(id) frees a model's
bricks; replace the instances naming it first.
instanceStats() reports models, instances, index blocks and bytes for overlays and budgets.
Scaled models
addModel(src, { scale }) and addEncodedModel(encoded, { scale })
(ModelOptions) draw a model enlarged by
an integer factor: each of its voxels covers scale³ world voxels. A model built at 10 voxels per
metre, drawn in a 100 voxels per metre world, takes 10. The scale belongs to the model, not the
instance, so every instance of it is enlarged.
// A 100 voxels per metre world: the 10 vox/m model, each voxel 10³ world voxels.
const k = 10;
const coarseOak = renderer.addModel({ size: oak10.model.size, data: oak10.model.data }, { scale: k });
const [ax, ay, az] = oak10.model.anchor;
renderer.setInstances([
// The anchor is in the enlarged model's voxels: k times the model's own.
{ model: coarseOak, x: 4000, y: 120, z: 2500, yaw: 0.7, anchor: [ax * k, ay * k, az * k], base: bark },
]);
await renderer.prepare({ instances: true, scaled: true }); // the scaled variant, before its first frame- Exactly the upsampled model. An instance draws what the model upsampled
scaletimes by nearest neighbour would draw at scale 1: the world voxel at p reads model voxelfloor(q / scale), q being p's cell in the enlarged model. That holds at any yaw, mirrored and at fractional positions, with the same shadows, ambient occlusion and faces. The renderer's checks compare every cell against the upsampled model: 0 of 7,075,252 differ. - Anchors are in enlarged voxels. The instance's space is the enlarged model's, so
Instance.anchoris in its voxels. The engine's layer passesscale × anchorfor you. - One encoding, any scale. The scale is not part of the encoding, so one
EncodedModelcan be added at several scales, each add a model of its own. - Drawn at rest. Instances of a scaled model cannot be posed:
Instance.partson one throws. Draw it plain, or bake the pose. - A variant of its own. Scaled sampling sits behind the
SCALEDpipeline constant, so a scene without scaled models does not pay for it: folding it into every scene cost 6 to 9% at scale 1 (measured), and with the constantrender-benchshows no regression. Prepare it withPrepareNeeds.scaled; by defaultpreparecompiles it only if a scaled model is placed now. - A little more to bake. A set of scaled models listed about 35% more sub-cell entries in its bake than the fine set it stood in for (measured), since a coarse model's occupied sub-cells cover more of the world once enlarged.
The engine's scaled placements are built on this; the coarse-first pattern is what they are for.
How an instance is drawn
Each 64³ top cell of the world lists the instances whose box touches it. A voxel the world leaves empty is looked up in each of them: the world point is turned into the model's space, pivoting on the anchor voxel's centre, so a quarter turn is exact. That is resampling at world voxel centres, so a turned instance still reads as axis-aligned cubes, the same as a baked pose. Shadows, ambient occlusion, point lights and water need nothing special.
World voxels win where both are solid: an instance only shows where the world is empty. Empty model
bricks next to content are flagged (NEAR_BIT), because a ray through a rotated model cannot trust
"empty" at one brick, so it skips only where the neighbourhood is empty too.
A cell lists at most 255 instances; entries beyond that are dropped with a console warning.
Crowded cells
Overlapping boxes make long lists: in a dense wood at 100 voxels per metre a 64³ cell lists about 20 instances, up to 75, and every step of a ray through a canopy would test them all. So every static cell with a list also gets a table, built once with the static set. Each of its 4³ sub-cells (16³ voxels) lists only the instances that can draw a voxel there, in the cell list's order, so the same instance wins, and masks which of its eight bricks they reach. A step then tests two or three instances, and a ray skips bricks and sub-cells nothing reaches without asking any model.
What an instance reaches is tight. Every occupied 2³ sub-cell of its model is turned into world space, and a brick is marked, and the instance listed, only where a world voxel centre can sample one of them (with a little slack for the shader's f32 rounding). A turned brick box would reach far more of the world than its voxels do: marked from boxes, 58% of nightwood's fine steps were in bricks marked "an instance may be here" that held no voxel.
Measured on the nightwood page at 1280 × 720 on a desktop GPU, the tight marks on every listed cell take a frame down by:
| voxels per metre | render scale 1 | render scale 0.5 |
|---|---|---|
| 20 | 31% | 29% |
| 50 | 41% | 37% |
| 100 | 47% |
That is on top of the first tables, which were built only for cells of 8 or more instances and marked from occupied model bricks, and which already made nightwood at 100 voxels per metre about 8 times faster than walking whole lists (2.4 fps against 0.3 at full scale).
The cost is build time. The tables are built on the main thread in setInstances (or on a worker,
see below): on nightwood
about 0.65 s at 20 voxels per metre, 5 s at 50 and 20 s at 100 (it was 0.17, 1.3 and 6 s with
brick marks). Their index region ("subs") stays about the same size at 20 and 50 (1.5 and
14.5 MiB) and grows from 83 to 93 MiB at 100.
Cells a dynamic instance touches fall back to their whole list while it is there, as does any cell over the 255 cap.
Matching stamped orientations
The engine stamps models in eight axis-aligned orientations (four quarter turns, optionally
mirrored). An instance with yaw = -(o & 3) * π / 2 and mirror = (o & 4) !== 0 draws exactly
what stamping with orientation o would; the engine's
orientationYaw(o) returns that pair.
Static and dynamic sets
There are two sets of instances, sent separately:
- Static (the default), for scenery:
setInstances(list)replaces the static set and builds its per-cell lists and their sub-cell tables once. - Dynamic, for a crowd:
setInstances(list, { dynamic: true })replaces only the moving set, every frame. Its cost is the moving instances and the cells they touch, however much scenery there is: moving instances go after the static ones, each cell they touch gets a fresh list (its static entries, then the moving ones), and cells they left get their static entry back.
// Once, behind the loading screen: the scenery (lists and sub-cell tables built here).
// oakId and ratId come from addModel, the palettes from addPalette.
renderer.setInstances(trees.map((t) => ({ model: oakId, x: t.x, y: t.y, z: t.z, yaw: t.yaw, anchor: oakAnchor, base: oakPalette })));
// Every frame: only what moves. The static set is left as it is.
renderer.setInstances(
rats.map((r) => ({ model: ratId, x: r.x, y: r.y, z: r.z, yaw: r.yaw, anchor: ratAnchor, base: ratPalette })),
{ dynamic: true },
);Instance sampling is a pipeline constant. A scene that never places an instance runs a shader with
that code compiled away (it costs even when never taken: several times the frame on SwiftShader),
and the instanced pipeline is built the first time one is placed, in the next frame's render.
prepare() compiles it ahead instead (see
Entry points).
Baking the static set on a worker
A static setInstances(list) is a headless bake (the instance records, packed poses, per-cell
lists and sub-cell tables) followed by a cheap apply that copies the result into the
renderer's buffers. The two halves are public, so the bake can run on a worker while the main
thread keeps drawing:
| step | where | API |
|---|---|---|
send each model's placement data once, after addModel | main thread → worker | placementModel(id) → PlacementModel |
| take the bake's input for a static set (a cheap copy of the list) | main thread | placementInput(list) → PlacementInput |
| bake it | worker | PlacementBaker.bake(input, opts?) or bakePlacement(input, lookup, opts?) → PlacementBake |
| report progress (optional) | worker → main thread | PlacementBakeOptions.onProgress(done, total) |
| send it back without copying | worker → main thread | postMessage(bake, placementTransferables(bake)) (placementTransferables) |
| draw it | main thread | applyPlacement(bake) |
setInstances runs this same code on the main thread, so both paths upload the same bytes. The
bake half imports nothing that needs a DOM, a GPU or a bundler: import it from
@voxolith/renderer/core in the worker. Model data is sent once per model, keyed by a
registration key that is unique on the page and never reused; applyPlacement checks those keys
and throws when a model the bake names was removed or replaced since placementInput (bake again
then), or when the bake is for another renderer's grid. The moving set stays and is redrawn on
top, and the old static set draws until the apply.
import type { Instance, PlacementBake } from "@voxolith/renderer";
const worker = new Worker(new URL("./placement.worker.ts", import.meta.url), { type: "module" });
// Each model once, after addModel; its key again after removeModel.
const oak = renderer.addModel({ size: tree.model.size, data: tree.model.data });
worker.postMessage({ type: "model", model: renderer.placementModel(oak) });
function removeTree(id: number): void {
const key = renderer.placementModel(id)?.key;
renderer.removeModel(id);
if (key !== undefined) worker.postMessage({ type: "drop", key });
}
// The static set: take the input here, bake there, apply the newest result.
let latest = 0;
let scenery: Instance[] = [];
function placeStatic(list: Instance[]): void {
scenery = list;
worker.postMessage({ type: "bake", id: ++latest, input: renderer.placementInput(list) });
}
type Reply = { id: number; done: number; total: number } | { id: number; bake: PlacementBake };
worker.onmessage = ({ data }: MessageEvent<Reply>) => {
if (data.id !== latest) return; // superseded
if (!("bake" in data)) {
bar.value = data.done / data.total; // opaque units: only the fraction means anything
return;
}
try {
renderer.applyPlacement(data.bake);
loop.invalidate();
} catch {
placeStatic(scenery); // a model it names was removed meanwhile: bake without it
}
};onProgress counts opaque work units, so only done / total means anything: the first call is
(0, total), the last (total, total) just before the bake returns, and in between at most one
every 50 ms or so. The count moves only between instances, so at fine scales it advances in steps
(about 190 ms apart at 100 voxels per metre). Without onProgress the bake does no progress work.
Messages to one worker are handled in order, so a drop or a new model posted before a bake
is seen before it. The engine's serveScene
adds cancelling a bake that has not started, and error replies.
Storing bakes
A bake is a pure function of its input and the models' placement data, so a host can keep it for the next visit. Two things make that safe:
PLACEMENT_BAKE_VERSIONchanges whenever the same input would bake differently (a changed word, array or field, or the same words computed another way). Key stored bakes by it. The renderer's verify holds a digest of a fixed scene's bake next to the version it was recorded under, and fails when the bake changes and the version does not.- Ids are per page. Model ids are slots, numbered by what was free, so the same scene gets
other ids on another visit.
normalizePlacement(bake)renumbers the instance records' models by first appearance in the set and emptiesmodels, for storing;bindPlacement(stored, input)puts back the ids and keys of this page's input. Nothing else in a bake depends on the ids, so the bound bake is byte for byte whatbakePlacement(input)makes, provided the storing key covers the grid, the instances in order (models named by content, not id) and the models' placement data.bindPlacementchecks the grid, the count and the pattern of model names, and throws otherwise.
Both return a bake with its own inst and grid and share the other arrays with the one they
were given, so store the normalised bake before transferring the original. The engine's scene
cache does this for you (caching encodings and bakes).
The engine wraps all of it, with the model encodes below: makeSceneWorker and
makeInstanceLayer(renderer, { worker }) with commitAsync() (see From the engine
and the manual's scene worker).
Encoding models on a worker
addModel is two steps as well: a headless encode (the model's 8³ brick payloads and index
blocks, the part grid and part boxes of a model with parts, and the occupied 2³ sub-cells the
placement bake reads) and a cheap apply that claims pool slots for it and uploads the bytes.
addModel(src) is exactly addEncodedModel(encodeModel(src)), so the two halves can run apart:
| step | where | API |
|---|---|---|
encode a ModelSource | worker | encodeModel(src, opts?) → EncodedModel |
| report progress (optional) | worker → main thread | EncodeOptions.onProgress(done, total), like the bake's |
| send it back without copying | worker → main thread | postMessage(encoded, encodedTransferables(encoded)) (encodedTransferables) |
| make room for a batch (optional) | main thread | reserveBricks(encoded[]) |
| add it | main thread | addEncodedModel(encoded, opts?) → model id (opts.scale as for addModel) |
| or add it over several frames | main thread | beginEncodedModel(encoded, opts?) → PendingModel, stepped until done |
-
What each half costs. The encode is about 2.5 µs per stored brick of a sparse model (measured on nightwood's models at 100 voxels per metre, which have 27k to 230k), so up to about 0.6 s for one model. The apply only claims slots and copies the payload in: 1 to 37 ms for the examples' big models (measured). The renderer takes over the encoding's arrays, so don't reuse them.
-
The same bytes. The encode is written against a pool of the model's own, numbered in the order it claimed slots; the apply claims real slots in that same order, so it uploads what
addModelwould, and the id is the oneaddModelwould have returned. -
Reserving. The brick pools grow as models are added, in 1.5x steps. A step copies the GPU pools GPU-side (1 ms or less of main thread), but the CPU mirror still reallocates and copies itself, and for a moment the GPU holds both buffers.
reserveBricksgrows them once for a whole batch before its adds. Only the pools' spare capacity differs from adding without it: model ids, the index and everything drawn are the same. See growth and limits. -
In slices. A whole add of a fine tree at 100 voxels per metre takes 70 to 94 ms, one long task.
beginEncodedModelclaims the slots, index blocks and id at once, asaddEncodedModelwould, and itsPendingModeldoes the copy in slices of about 1 MB:step(budgetMs)works until the budget is spent (at least one slice) and returns true once the model is added,progressis the fraction done by bytes, andcancel()gives back what it claimed. The result is byte-identical toaddEncodedModel; anything may run between steps, pool growth included, but an instance may nameidonly once it is done (setInstancesthrows before). The engine's layer steps big models this way within itsuploadBudgetMs, and its largest step is about 8 ms (measured).const pending = renderer.beginEncodedModel(encoded); const tick = () => { if (!pending.step(4)) return void requestAnimationFrame(tick); // 4 ms a frame renderer.setInstances([{ model: pending.id, base: bark, x: 40, y: 12, z: 60 }]); loop.invalidate(); }; requestAnimationFrame(tick); -
Versioned. An encoding carries
ENCODED_MODEL_VERSION, which changes with the payload layout.addEncodedModelthrows on any other version, so a host that caches encodings (in IndexedDB, say) keys them by it. The engine's scene cache keeps encodings under it and a digest of the model's identity. -
Placement from the same worker. An encoding holds everything the bake reads about the model. Transfer it with
encodedTransferables(encoded, { keepPlacement: true }), which copies the sub-cells rather than moving them, and once the main thread has added it and sent back the keys fromplacementModel(id),placementModelOf(encoded, keys)registers it with the worker'sPlacementBaker. The sub-cells (millions of words at 100 voxels per metre) then never travel back.
import type { EncodedModel } from "@voxolith/renderer";
const worker = new Worker(new URL("./encode.worker.ts", import.meta.url), { type: "module" });
const landed = new Map<number, (e: EncodedModel) => void>();
worker.onmessage = ({ data }: MessageEvent<{ id: number; encoded: EncodedModel }>) => landed.get(data.id)?.(data.encoded);
const encodeThere = (id: number, model: { size: { x: number; y: number; z: number }; data: Uint8Array }) =>
new Promise<EncodedModel>((resolve) => {
landed.set(id, resolve);
worker.postMessage({ id, model });
});
const batch = await Promise.all(trees.map((t, i) => encodeThere(i, { size: t.model.size, data: t.model.data })));
renderer.reserveBricks(batch); // the pools grow once
const ids = batch.map((e) => renderer.addEncodedModel(e)); // or one per frame, to spread the copiesA sparse model's brick map is a Map of many small arrays, which is slow to structured-clone at
fine scales; the engine's scene worker packs it into transferred chunks of bricks instead. The
manual has the whole round trip, encodes and
bake on one worker, without the engine.
Instance palettes
Instances draw from palettes of their own, stored after the world's 256 slots (WORLD_SLOTS) in
the same buffer. World voxels are 8-bit, so they can only name the first 256; instances name a
base of any size.
| method | does |
|---|---|
addPalette(colors, materials?) | a new palette; colors is RGBA per entry, materials optionally 8 floats per entry. Returns the base slot |
setPaletteColors(base, colors, materials?) | recolour in place: every instance using it changes |
removePalette(base, entries) | free it; instances still naming it draw garbage colours |
The buffer grows as needed, so a scene is not limited to 255 colours: every species, or every
placement, can have its own palette, and restyling one (a season, a faction, damage) is one
setPaletteColors call. An instance may also use the world's palette by passing a world base.
From the engine
The engine drives all of this for entities:
makeInstanceLayer(renderer)uploads eachEntityModelonce (by identity), keeps static placements apart from moving ones and sends both, and itspalettes.of(key, roles, tint?)makes and caches instance palettes from colour roles.makeCrowd({ instances })places animated members as pose models, with a pose cache and distance LOD.
import { makeInstanceLayer } from "@voxolith/engine";
const layer = makeInstanceLayer(renderer);
const base = layer.palettes.of("oak", tree.model.roles);
layer.setStatic([{ model: tree.model, x: 120, y: 14, z: 88, yaw: 0.4, base }]);
layer.commit(); // sends the static and moving sets; cheap to call every framesetStatic takes entity models rather than ids and places each on the model's own anchor. Given a
scene worker, setStatic only sends new models to be encoded, and layer.commitAsync() adds them
and bakes the static set there
(Engine: instances).
See Instances in the engine section for the layer, and Stamping and crowds for when to stamp instead.
Posed on the GPU
A model added with parts (one part index per voxel, beside its role values) can be drawn with one transform per part instead of one per instance: pose a skeleton on the GPU from a single rest model, with nothing baked. The renderer takes each world cell back through every part's inverse into the rest model; the first part whose own voxel is there wins, parents first, and the cells around each joint may be filled from either side (a weld), so a turned limb stays on.
const id = renderer.addModel({ size, data, parts, joints }); // joints: parent and meeting point per part
renderer.setInstances(
[{ model: id, x, y, z, anchor, yaw, base, parts: boneMatrices }], // 12 floats per part, model space
{ dynamic: true },
);Each such instance is a single entry in the per-cell lists. Its pose (the part inverses, joints and
a small grid of part masks, two 32-bit words per 8³ brick of the posed model saying which parts can
be in the brick and which joints weld there) lives in the model's own space, so the renderer packs
and uploads it once, keyed by the part transforms' identity, and every instance showing it shares
it; per frame an instance costs its record. The masks let a sample test one or two parts and a ray
skip the bricks no part reaches. Pass partBoxes to addModel (e.g. an undamaged model's) so
damaged copies share poses too.
Limits:
- A dense model.
addModeltakespartsonly beside densedata, not asparsevolume. - At most 32 parts.
- Rigid parts. Each voxel follows exactly one part; there is no smooth (skinned or cage) deformation between parts beyond the weld at each joint.
- Measured on a desktop GPU only. Posing on the GPU has not been measured on a phone yet.
The engine drives this from a rig: prepareRigged and
makeCrowd({ instances, rigged: true }) (stamping and crowds).
Instances also take a full rotation (a 3x3, row-major) instead of yaw: tumbling debris, a
tilted placement.
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
- Atsushi Yoshimura, Takahiro Harada. Subspace Culling for Ray–Box Intersection. Proc. ACM Comput. Graph. Interact. Tech. 6(1) (I3D 2023). doi:10.1145/3585503 The idea of tight occupancy instead of loose boxes behind the crowded-cell tables; their technique itself was tried and not kept.
What Voxolith took from them and what is its own: Research and credits.
Storage
How the renderer stores voxels: 8³ bricks with per-brick palettes, uniform bricks, a two-level index in one buffer, and editing a brick at a time.
Lighting and materials
The key light, sky, sun and moon, shadows and AO, up to 32 range-limited point lights with glow, and the emissive, metal, glass and water materials.