voxolith

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 changes

The layer

makeInstanceLayer(target) returns an InstanceLayer:

  • models: a ModelLibrary. 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: a PaletteLibrary, below.
  • setStatic(list): replaces the static placements (scenery), given as EntityPlacements 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 as InstancePlacements 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). baking is true while a static set is in flight there, and uploads counts 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 sent

target 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 built scale times 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 setStatic throws.
  • One upload per model and scale. The same EntityModel at 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: setDynamic throws 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 with renderer.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's reserveBricks once for all of them, and adds them with addEncodedModel, paced: uploadBudgetMs of adds per frame (8 by default), at least one, so a big model gets a frame of its own; pace replaces 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, upload counts each model in bricks from its encode request to its add (one short until the add), and placement counts up with the bake (one short of the total until the apply). Without a scene worker, upload is 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 done can drop.
  • A synchronous commit() after setStatic does not wait: it adds the encodings that have landed, uploads the rest with addModel and 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 with signal.reason, and the static set counts as unsent, so the next commit() or commitAsync() sends it again. A failed encode or bake does the same.
  • What the target needs. The optional InstanceTarget methods addEncodedModel and reserveBricks for the encodes, and placementModel, placementInput and applyPlacement for the bake (the renderer has all five). A target without addEncodedModel only bakes on the worker; without the placement methods, or without a worker, commitAsync() is commit().
  • 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) or hashModels: true (a hash of the bytes on the worker, for models you built; saves the encode only). What the cache served ticks its phase with cached: true.
  • Bake only. The deprecated makePlacementWorker, given as placement, bakes as before and leaves the uploads in setStatic. worker takes the place of placement when 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 for key, making it from roles (and the optional tint(color, role, index)) the first time.
  • restyle(key, roles, tint?) recolours it in place.
  • release(key) frees it; size and slots report 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)
memoryevery copy's voxelsone copy per model
headingseight axis-aligned orientationsany yaw, mirrored
positionwhole voxelsfractional
coloursa range in the world's 256 slotspalettes of their own, unlimited
sparse modelsno (dense data only)yes
editable by world edits (digging, explosions)yesno, the model is shared

Stamp what the world should own and be able to change; instance what is repeated, large, fine or moving.

On this page