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.
Stamping writes every voxel of a model into the world's bricks. A forest of the same six 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. A renderer that supports instances
(Renderer.addModel and setInstances) keeps one copy of each model
and draws it wherever it is placed, at any heading, mirrored, at fractional positions.
This is the engine side of that: a library that uploads each EntityModel once, a library of
palettes, and a layer that keeps static placements apart from moving ones.
import { makeInstanceLayer, orientationYaw } from "@voxolith/engine";
const layer = makeInstanceLayer(renderer);
const oakBase = layer.palettes.of("oak", oak.model.roles);
const scenery = points.map((pt) => {
const v = oaks.pick(pt.rng); // a variant pool: { entity, orientation }
const { yaw, mirror } = orientationYaw(v.orientation);
return { model: v.entity.model, x: pt.x, y: groundAt(pt.x, pt.z), z: pt.z, yaw, mirror, base: oakBase };
});
layer.setStatic(scenery);
layer.commit(); // scenery is sent once; call again after changesThe layer
makeInstanceLayer(target) returns an
InstanceLayer:
models: aModelLibrary.id(model, scale?)uploads the model the first time it sees that object (by identity) at that scale and returns its id;has(model, scale?)says whether it is uploaded, without uploading it;release(model, scale?)frees it (at every scale without one);scaleOf(id)gives the scale an id was uploaded at. Dense and sparse models both upload as they are. A model whose id you asked for is yours: the layer never releases it by itself.palettes: aPaletteLibrary, below.setStatic(list): replaces the static placements (scenery), given asEntityPlacements by model rather than id; the model's own anchor is used. Once a new static set is drawing, the models the layer uploaded for earlier sets and no longer needs are released (below).setDynamic(list): replaces the moving placements (a crowd's, every frame), given asInstancePlacements by model id.commit(): sends the static set if it changed and the dynamic set always. Cheap to call every frame: the renderer keeps the two apart, so the scenery is not re-sent with each move.commitAsync():commit(), with the static set's models encoded and the set baked on a scene worker (below).bakingis true while a static set is in flight there, anduploadscounts the models sent to be encoded and not yet added.
import { makeInstanceLayer } from "@voxolith/engine";
const layer = makeInstanceLayer(renderer, { load, worker }); // both optional
const oakBase = layer.palettes.of("oak", oak.model.roles);
layer.setStatic(sites.map((s) => ({ model: oak.model, x: s.x, y: s.y, z: s.z, yaw: s.yaw, base: oakBase })));
layer.commit(); // uploads and places the static set here and now: blocks for a big set
// or
await layer.commitAsync(); // encodes and bakes it on `worker`, resolves once it draws (commit() without one)
// Moving things: ids from the library, every frame.
const ratId = layer.models.id(rat.model);
layer.setDynamic(rats.map((r) => ({ model: ratId, x: r.x, y: r.y, z: r.z, yaw: r.yaw, anchor: rat.model.anchor, base: ratBase })));
layer.commit(); // the static set is unchanged, so only the moving set is senttarget is an InstanceTarget, which
Renderer implements; the layer and libraries
can also be built alone with makeModelLibrary
and makePaletteLibrary.
A placement puts the model's anchor at x, y, z in world voxels (fractions are fine), turns it
by yaw radians about +y and optionally mirrors it along the model's x first. base is the palette
slot of role 1.
Scaled placements
scale on an EntityPlacement draws the model enlarged by an integer factor: each of its voxels
covers scale³ world voxels (the renderer's ModelOptions.scale).
The factor is the world's resolution over the model's, so a tree generated at 10 voxels per metre
in a 100 voxels per metre world takes 10. It is how a scene shows coarse placeholders while its
fine models are made:
import { makeInstanceLayer } from "@voxolith/engine";
// oak10 and oak100: the same oak, generated at 10 and at 100 voxels per metre.
const layer = makeInstanceLayer(renderer);
const base = layer.palettes.of("oak", oak100.model.roles); // every scale has the same roles
// A 100 voxels per metre world: the 10 vox/m oak drawn 10 times enlarged, anchored at 10 × anchor.
layer.setStatic(sites.map((s) => ({ model: oak10.model, scale: 10, x: s.x, y: s.y, z: s.z, yaw: s.yaw, base })));
layer.commit();
// Later: the fine oak at the same placements. Once it draws, the coarse oak is released.
layer.setStatic(sites.map((s) => ({ model: oak100.model, x: s.x, y: s.y, z: s.z, yaw: s.yaw, base })));
layer.commit();- The anchor scales. The layer places the anchor at
scale × model.anchor, which is the anchor a generator gives the same design builtscaletimes finer, so a coarse placeholder and its fine model share a placement. - The scale is explicit. It is not read from the model, since only the app knows the world's
resolution. It must be an integer of at least 1, or
setStaticthrows. - One upload per model and scale. The same
EntityModelat two scales is two renderer models, each costing its bricks once. With a scene worker it is encoded once, and the encoding is added at every scale the set names. - Drawn at rest. A scaled model cannot be posed:
setDynamicthrows for a posed placement (parts) naming a model the layer holds at a scale above 1. - Its own pipeline variant. Scaled models are drawn by a variant a scene without them does
not compile; with
deferPipelines, prepare it withrenderer.prepare({ instances: true, scaled: true }).
Replacing a set releases its models
Once a new static set is drawing (after commit() places it, or when commitAsync() applies its
bake), the layer releases the models it uploaded for earlier sets that nothing needs any more,
freeing their bricks. Never before, so the old set never draws a freed model. Kept are the models
the drawn set names, those of a setStatic not committed yet or still in flight, and any whose id
the host asked for with models.id.
keepUnused: true
keeps them all, for a host that switches between the same sets and would rather not upload them
again.
That makes a coarse-to-fine swap one call pair. Place the coarse models first (scale), commit and
show the scene; then give the fine set to setStatic and await commitAsync() in the same
task. The coarse set keeps drawing while the fine models encode, upload and bake, the fine set
replaces it in one apply, and the coarse models are released right after. A commit() in between
(a crowd's, every frame) would place the fine set synchronously. With deferPipelines, prepare
both variants up front (prepare({ instances: true, scaled: true }) and
prepare({ instances: true })), or the first frame after the swap compiles the plain one. The
manual's coarse first, one swap has the
whole pattern and what it gave the examples.
Encoding and baking on a worker
On the main thread, setStatic uploads each model it has not seen (addModel encodes its
bricks), and commit() places the changed static set. Both take seconds for a fine scene. Given a
scene worker (makeSceneWorker, see
Workers), setStatic only sends new models to be
encoded, and commitAsync() does
the rest:
import { makeInstanceLayer } from "@voxolith/engine";
const layer = makeInstanceLayer(renderer, { load, worker, modelKey: pool.modelKey });
layer.setStatic(scenery); // returns at once: new models go to the worker
await layer.commitAsync(); // encodes, adds, bakes; the page keeps drawing
loop.invalidate();- Encodes, then adds.
commitAsync()waits for the encodes the set needs, calls the target'sreserveBricksonce for all of them, and adds them withaddEncodedModel, paced:uploadBudgetMsof adds per frame (8 by default), at least one, so a big model gets a frame of its own;pacereplaces the wait for the next frame. Each add registers the model for placement on the worker by its keys, so the bake that follows needs nothing sent. - Then the bake. It resolves once the static set of this call, or a newer one, is drawing (at
once when it had not changed). The moving set is sent at once,
commit()calls meanwhile send only the moving set, and the old static set draws until the new one is applied. - Load phases. With
load,uploadcounts each model in bricks from its encode request to its add (one short until the add), andplacementcounts up with the bake (one short of the total until the apply). Without a scene worker,uploadis one tick per model. - The newest set wins. A newer static set supersedes one in flight: its bake result is
dropped, models it already added stay added, and encodes of models the new set does not name are
cancelled. A model removed or replaced during a bake makes the layer bake again. A superseded or
aborted job is discarded from its phase, so its
donecan drop. - A synchronous
commit()aftersetStaticdoes not wait: it adds the encodings that have landed, uploads the rest withaddModeland places the set.models.id(model)for a model still being encoded uploads it there and then. - Aborting.
commitAsync({ signal }): the encodes carry on, the worker skips the bake if it has not started (a started one runs to the end), the promise rejects withsignal.reason, and the static set counts as unsent, so the nextcommit()orcommitAsync()sends it again. A failed encode or bake does the same. - What the target needs. The optional
InstanceTargetmethodsaddEncodedModelandreserveBricksfor the encodes, andplacementModel,placementInputandapplyPlacementfor the bake (the renderer has all five). A target withoutaddEncodedModelonly bakes on the worker; without the placement methods, or without a worker,commitAsync()iscommit(). - Cached. With a scene worker that has a cache (
serveScene({ cache })), a warm visit reads the bake and the encodings instead of computing them. Bakes need nothing from you. Encodings need the model's identity:modelKey(pool.modelKey, for generated models; a hit never sends the bricks) orhashModels: true(a hash of the bytes on the worker, for models you built; saves the encode only). What the cache served ticks its phase withcached: true. - Bake only. The deprecated
makePlacementWorker, given asplacement, bakes as before and leaves the uploads insetStatic.workertakes the place ofplacementwhen both are given.
The manual's scene worker section covers it at length, with what it saved in the examples.
Matching stamped orientations
orientationYaw(o) turns an axis-aligned
Orientation into the { yaw, mirror } that
draws exactly the same voxels as stamping with o. A layout written for stamping (the examples'
world/layout.ts picks an orientation per placement) can therefore be drawn as instances without
changing a single choice, which is how examples/valley draws the same village as
examples/world at 20, 50 or 100 voxels per metre.
Palettes of their own
World voxels are 8-bit, so the world's palette has 256 slots and the
PaletteAllocator rations them.
Instances do not draw from it. Each instance palette lives after the world's 256 slots on the
renderer, so a scene of instances has no colour budget.
// one palette per species...
const rockBase = layer.palettes.of("granite", rock.model.roles);
// ...or one per placement, tinted, so no two houses match
const houseBase = layer.palettes.of(`house#${n}`, house.model.roles, (color, role) =>
role.id.startsWith("wall") ? [color[0] * shade, color[1] * shade, color[2] * shade] : color,
);
// autumn: every instance using the key changes colour in place
layer.palettes.restyle("oak", autumnRoles);of(key, roles, tint?)returns the base slot of the palette forkey, making it fromroles(and the optionaltint(color, role, index)) the first time.restyle(key, roles, tint?)recolours it in place.release(key)frees it;sizeandslotsreport what is held.
Underneath, instancePalette(roles, tint?)
builds the data Renderer.addPalette takes: RGBA per role (role r at entry r - 1) and, when
any role has a material hint, 8 floats per role.
Moving and animated instances
The dynamic set is for anything that moves. makeCrowd({ instances: layer })
draws an animated crowd this way: one pose model per variant, clip and frame (no heading buckets,
since the instance turns it), members at their exact position and yaw, nothing written into the
world. It calls layer.commit() at the end of each update; when a scene has no crowd members,
call commit() yourself.
Stamp or instance
stamp (blitModel, blitModelToBricks, stamper) | instance (makeInstanceLayer) | |
|---|---|---|
| memory | every copy's voxels | one copy per model |
| headings | eight axis-aligned orientations | any yaw, mirrored |
| position | whole voxels | fractional |
| colours | a range in the world's 256 slots | palettes of their own, unlimited |
| sparse models | no (dense data only) | yes |
| editable by world edits (digging, explosions) | yes | no, the model is shared |
Stamp what the world should own and be able to change; instance what is repeated, large, fine or moving.
Placement and worlds
Axis-aligned orientations, stamping models into dense grids and brick storage, chunked worlds that stream around a focus point, scatter and variant pools.
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.