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.
Placing an entity means writing its voxels into the world at an anchor, with its roles offset into the palette range the host allocated for it. This page covers stamping (voxels written into the world); drawing a model by reference instead is Instances, which is the better choice for large or sparse models.
Orientations
Copies of one model all facing the same way read as wallpaper. The eight axis-aligned orientations, four yaw steps of 90° and an optional mirror, fix that for free: they are pure index permutations, so nothing is resampled and no memory is allocated.
type Orientation = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; // low two bits: yaw step x 90°; bit 2: mirror along x firstArbitrary angles are deliberately not offered for stamping. Rotating a voxel grid by anything other than a right angle means resampling it, which blurs or shreds exactly the one-voxel twigs and grass blades the generators work hardest to keep 6-connected. (Instances turn at any angle, because the renderer samples the model per ray; animation bakes turned poses and accepts the resampling.)
ORIENTATIONSlists all eight.orientVoxel(o, x, y, z, size, out)andorientAnchor(o, anchor, size)map a cell and an anchor with the sameextent - 1 - iconvention, so a model and its anchor never drift apart.orientedSize(size, o)andswapsXZ(o)give the turned extents.orientModel(model, o)materialises a turned copy, for exporting or previewing. Placement never needs it: the blits apply the orientation while writing.orientationYaw(o)gives the instance turn{ yaw, mirror }that draws exactly the same voxels as stamping witho.
Stamping into a dense world
blitModel(world, model, origin, base, orientation?)
copies a dense model into a { size, data } grid with its anchor at origin, writing role v as
base + v - 1, and returns the box it touched (or null when nothing landed inside the world).
Empty voxels are skipped, so the model's air never erases the world.
const { base } = palette.allocateFor(oak);
const box = blitModel(world, oak.model, { x: 64, y: groundY, z: 64 }, base, 3);
if (box) renderer.updateVoxels(world.data, box); // re-upload only the bricks it touchedA dense world is simple and fast for a small scene. For a large one it is not: the examples forest
at 8x8 tiles would need 1.1 GB of Uint8Array purely as a staging copy of a world the GPU
already holds sparsely in 159 MB.
Stamping into bricks
blitModelToBricks(target, model, origin, base, orientation?, clip?)
writes straight into brick storage instead. target is any
BrickTarget, an object with an
edit(box, fill) method; Renderer.edit
satisfies it structurally, so the engine keeps its one-way dependency on the renderer.
It buckets the model's solid voxels by destination brick in one pass (visiting the model once per
brick would be quadratic: an oak is 462k cells over about 900 bricks) and writes each brick
through a 512-voxel scratch, so the cost is proportional to the model's box, not the world. The
optional clip box restricts writes, and a model entirely outside it is rejected before its voxels
are scanned; chunked worlds depend on both.
See Storage for the brick layout on the renderer side.
Chunked worlds
Building a whole world up front makes load time and memory scale with its size.
makeChunkedWorld keeps a resident set
instead: chunks near a focus point are generated on demand and chunks that fall outside it are
freed, so what is resident is bounded and the world it is cut from need not be.
Chunks are columns, a square footprint over the full height, which suits terrain and keeps addressing to two axes.
import { makeChunkedWorld } from "@voxolith/engine";
const world = makeChunkedWorld({
target: renderer, // edit(box, fill) and clear(box)
size: SIZE,
chunk: 256, // a multiple of the 8-voxel brick
seed,
generate(ctx) {
// ground for this column
ctx.edit(ctx.box, (cells, ox, oy, oz) => terrain.fillBrick(cells, ox, oy, oz, groundBase));
// every tree that reaches into it, including ones rooted next door
const m = 40;
scatterRegion({ cell: 48, seed, salt: 1 }, ctx.box.x0 - m, ctx.box.z0 - m, ctx.box.x1 + m, ctx.box.z1 + m, (pt) => {
const v = trees.pick(pt.rng);
ctx.blit(v.entity.model, { x: pt.x, y: terrain.heightAt(pt.x, pt.z) + 1, z: pt.z }, treeBase, v.orientation);
});
},
});
// every frame:
world.focus(camPos[0], camPos[2], 1200); // queue within the radius, free beyond 1.5x
world.step(8); // build for up to 8 ms, returns chunks still queuedThe rules that make it work:
- Generation is a pure function of the chunk. The order chunks are built in depends on where
the camera went, so
generatemust depend only on itsChunkContext:cx,cz, the chunk'sbox, and anrngseeded from the coordinates and the world seed. - Each chunk writes only its own voxels.
ctx.editclamps its box to the chunk andctx.blitclips the model to it. Pass entities rooted in neighbouring chunks too: a tree that straddles a boundary then comes out the same whichever side is built first. - Hysteresis.
focus(x, z, radius, keep?)queues chunks withinradius, nearest first, so the view fills from the middle, and frees chunks beyondkeep(defaultradius * 1.5), so a camera jittering on the boundary does not rebuild the same chunk every frame. It only diffs the resident set and is cheap to call every frame. - A budget.
step(budgetMs = 8)builds queued chunks until the budget is spent;pendingandresidentreport progress. - The queue follows the focus. When the focus point moves, chunks still queued are re-ranked from it, so the queue stays nearest first to the latest focus.
A chunk can also fill itself with one batched edit rather than ctx.edit:
columnBoxes(box, span) gives one box per 8 × 8
brick column, as tall as span says the column's contents reach (a fine terrain's
columnSpan), for ctx.edit or renderer.editMany.
Ready around a point
A loading screen that waits for the ground around the camera, rather than the whole focus radius, asks the world:
pendingWithin(x, z, radius): chunks whose centre lies withinradiusof (x, z) that are not built yet, queued or not; 0 once they all are. It costs one test per chunk in the square around the point.readyAround(x, z, radius):pendingWithin(...) === 0.whenReady(x, z, radius, { signal }): resolves oncereadyAroundholds, after thestepthat builds the last missing chunk. The world builds only instepand only whatfocusqueued, so keep stepping and focus on at leastradius, or it never resolves.signalrejects it withsignal.reason.
world.focus(x, z, 2400);
// Chunk centres are tested, so half a chunk's diagonal more takes every chunk reaching into 18 m.
const near = 1800 + 256 * Math.SQRT1_2;
await world.whenReady(x, z, near); // the frame loop keeps calling world.step meanwhile
loadingScreen.ready();Filling chunks on workers
With fill, chunks are
filled on workers instead of in generate, usually by
makeChunkFillPool from
@voxolith/engine/worker (see chunk fill workers).
focus queues chunks as before; the world keeps up to fill.capacity requests out, nearest
first, and step copies in the chunks that came back, nearest first, within its budget (at least
one when any has). The main thread's share is the copy.
import { columnBoxes, makeChunkedWorld } from "@voxolith/engine";
import { makeChunkFillPool } from "@voxolith/engine/worker";
import { fineTerrainInit } from "@voxolith/gen-terrain";
const world = makeChunkedWorld({
target: renderer, // editMany when it has it: one upload per chunk
size: SIZE,
chunk: 256, // a multiple of 8 with `fill`
seed,
load,
fill: makeChunkFillPool({ spawn, init: { fine: fineTerrainInit(ground), base: groundBase } }),
boxes: ({ box }) => columnBoxes(box, (ox, oz) => ground.columnSpan(ox, oz)),
onError: (err, { cx, cz }) => console.warn(`chunk ${cx},${cz} stays empty`, err),
});boxes(chunk)gives the boxes to fill for aChunkRef, clamped to the chunk (default its whole box). It runs on the main thread when the chunk is sent.generateis optional withfill. Given, it runs on the main thread after the chunk's bricks are in, for what cannot move to a worker (blitting models, counting).- Each brick is filled from empty and replaced by the result, which is what
generatewrites into a new or cleared chunk;chunkmust therefore be a multiple of the 8-voxel brick. - A chunk counts as built (
resident,pendingWithin,whenReady, thegroundphase) once it is applied.stepandpendingcount the chunks queued, out on a worker, or back and not applied. - A chunk freed while it is queued or out is dropped, and its result with it.
- A request that fails leaves its chunk unbuilt: it goes to
onError(defaultconsole.error), is not retried until the chunk has been freed, and awhenReadyover it rejects with the error.
Measured in bun on a gen-terrain fine ground at 100 voxels per metre (256² chunks, one box per brick column): 50 ms per chunk on the main thread synchronously, against 42 ms on a worker plus 10 ms on the main thread, almost all of it the brick grid encoding what it is given.
Scatter
A global loop that rejection-samples positions works only when the whole world is built at once,
because each point depends on the ones before it. scatterRegion
makes placement a pure function of position instead: a jittered grid, one candidate per cell,
its position and properties drawn from a hash of the cell's coordinates. Any chunk can work out its
own candidates and its neighbours' without shared state.
scatterRegion({ cell: 90, seed, salt: 21, jitter: 0.3 }, x0 - margin, z0 - margin, x1 + margin, z1 + margin, (pt) => {
if (pt.rng() > 0.6) return; // whether it exists: drawn from the cell's rng
const species = pt.rng() < 0.5 ? "oak" : "birch";
// ...
});ScatterOptions:cell(world voxels, which sets density),seed,salt(distinguishes layers, so trees and grass do not land on the same points),jitter(fraction of a cell a candidate may stray, 0 a rigid lattice, 0.5 anywhere in the cell; default 0.4, which looks scattered while keeping a minimum spacing).- Each
ScatterPointhasx,z, its cellcx,cz(a stable id) and a per-cellrng. Draw every decision about the candidate from that rng, and the result is identical however the world is traversed. The offset is drawn first, so the caller's own draws stay aligned whatever it does with the point. - Expand the rectangle by the largest thing a candidate can produce, or entities rooted just outside a chunk will be missing where they reach into it.
Variant pools
Generating every placement of a species uniquely is what makes a large scene slow to build. Measured on the examples forest at 4x4, generation is 89.6% of the build (10.8 of 12.1 seconds for 1120 entities) while placement is 2%, so cutting the number of models generated is the only change to load time that matters. Vegetation systems ship a handful of variants per species rather than a unique tree per instance, and so does this.
makeVariantPool({ count, make }) is a
lazily filled pool of interchangeable models. pick(rng) returns a
Variant, a model from the pool with a random
orientation, generating it on first use; at(index, rng) picks a given slot. count models in
eight orientations give count * 8 silhouettes for count generations; around a dozen per species
is plenty. Vary the parameters by index (height, age, seed) so the pool is not twelve copies of one
thing. A scene that places fewer entities than the pool size never pays for the rest; generated
counts what was built.
const oaks = makeVariantPool({
count: 12,
make: (i) => generateTree({ ...PRESETS.oak, shape: { ...PRESETS.oak.shape, height: 160 + i * 6 } }, seededRandom(1000 + i)).entity,
});
const v = oaks.pick(pt.rng); // { entity, orientation, index }Pools pair with a worker pool that generates the models off the main
thread, and with instances, where orientationYaw(v.orientation) turns
the same choice into an instance heading.
Share codes
How @voxolith/engine packs a generator id, version, parameters and seed into a short base64url code that rebuilds a model, and why stale codes fail.
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.