Managing loading
The controls a Voxolith app has over its load: awaitable pipeline warm-up with prepare, model encoding and static placement on a scene worker with commitAsync, a generation queue with priorities, aborts and pausing, a model cache and a scene cache sized by the game, coarse models first with one swap to the fine ones, and ground filled on workers with readiness around the camera.
What a game generates, in what order and what it waits for before the first picture are the game's decisions, and they differ from one game to the next. So the engine takes none of them. It offers controls, each opt-in: an app that uses none of them loads exactly as before. This page is one heading per control, then what stays in your code, then a complete page that uses them together.
Loading screens covers the other half: how the loaders report what they are doing, and how to show it.
Awaitable pipeline warm-up
Without it, the renderer makes its base pipelines in createRenderer and every other variant
(instances, posed instances, temporal accumulation, the offscreen kernels) in the first render
that needs one. Those are synchronous calls, and Chrome compiles a pipeline when it is first used,
so the compile lands in a frame: the page freezes on its first picture, for seconds a variant on a
software adapter.
renderer.prepare(needs?) compiles the
variants ahead of use with createRenderPipelineAsync / createComputePipelineAsync, and resolves
when they are ready. Call it behind your loading screen, after placing the instances and choosing
the quality, since those decide which variants a frame will use:
import { createRenderer } from "@voxolith/renderer";
import { makeLoadTracker, trackRenderer } from "@voxolith/engine";
const load = makeLoadTracker();
const renderer = await createRenderer(gpu, scene, {
onLoad: trackRenderer(load),
deferPipelines: true, // the base pipelines too, so prepare covers the whole compile
});
layer.setStatic(scenery);
layer.commit();
renderer.setQuality(saved ?? "medium");
await renderer.prepare({ parts: true }); // rats arrive later, posed on the GPU
loop.invalidate();
loadingScreen.ready();RendererOptions.deferPipelines
skips the base pipelines in the constructor (the plain fragment pass, the plain compute kernel and
the compute path's present pass), so that prepare compiles them asynchronously with the rest.
Without it the constructor still makes them synchronously, and their real compile still lands in
the first frame. The shader module is made in the constructor either way.
What needs covers
PrepareNeeds says which variants the
scene will need. A field left out is read from the renderer's state now:
| field | means | default |
|---|---|---|
instances | instances will be drawn | any instance is placed now |
parts | instances posed on the GPU (Instance.parts) will be drawn | a placed instance is posed now |
temporal | temporal accumulation will be on | the quality's temporal now; ignored by the fragment pipeline |
target | frames will go to an offscreen RenderTarget as well as the canvas | false |
scaled | instances of models added with a scale above 1 will be drawn (coarse first) | a placed instance draws one now |
So pass a field for anything that arrives after the call: rats spawned later, temporal turned on
from a settings menu, an instance set still encoding or baking on a scene worker
(placed instances count only once they are applied). Which pipelines that makes depends on the
renderer's pipeline
mode:
pipeline | prepare compiles |
|---|---|
"fragment" | the fragment pass for the plain scene and each variant asked for (+instances, +parts, each also +scaled with scaled) |
"compute" | the compute kernel for each of them, plus its temporal kernel when temporal is on |
"auto" (default) | as "compute", except that posed instances also get the fragment +parts pass, since "auto" draws them as fragments unless temporal accumulation is on |
On the compute path the kernels write the canvas directly when its format allows storage writes;
with target, the kernels that write a frame texture are compiled too, and the present pass
wherever a kernel writes a frame texture rather than the canvas.
Variants already made are skipped, and calls share compiles already in flight, so calling
prepare again (after adding posed instances, after turning temporal on) costs only what is new.
// Nothing is placed yet, but scenery, rats posed on the GPU and a photo mode that renders
// into an offscreen RenderTarget all come later: compile their variants now.
await renderer.prepare({ instances: true, parts: true, target: true });
// Temporal accumulation from a settings toggle: compile its kernels first, then switch.
temporalToggle.addEventListener("change", async () => {
if (temporalToggle.checked) await renderer.prepare({ temporal: true }); // only what is new
renderer.setQuality({ temporal: temporalToggle.checked });
loop.invalidate();
});If a frame comes first
prepare does not hold back rendering. A frame that needs a variant still compiling makes it
synchronously, exactly as without prepare, and the asynchronous result is dropped when it
arrives. So await prepare before the first frame that needs a variant; rendering behind the
loading screen meanwhile is fine, and only brings that variant's stall forward. A compile that
fails rejects the promise, and render then tries synchronously as before. A page that should
never hang on its loading screen catches it and lets the first frame compile instead, as the
examples do:
// Frames may run meanwhile (a plain scene behind the loading screen needs no new variant).
const compiled = renderer
.prepare({ instances: true })
.catch((err) => console.warn("async compile failed; the first frame compiles it:", err));
loop.invalidate();
await compiled; // resolves either way
layer.commit(); // the first instanced frame finds its pipeline ready (or compiles it now)
loop.invalidate();
loadingScreen.ready();Real compile times
The variants prepare compiles report on
onLoad from the call
to the pipeline being ready, which is the real compile. So with deferPipelines and prepare, the
pipelines phase of a load tracker measures the
whole compile. Pipelines made synchronously still report only the create call, which returns
before the browser has compiled.
// Each finished variant is a `progress` event labelled with its name.
load.on((e) => {
if (e.phase === "pipelines" && e.kind === "progress" && e.label) {
console.log(`${e.label} ready at ${Math.round(e.t)} ms (${e.done}/${e.total})`);
}
});
await renderer.prepare({ instances: true });
const pipelines = load.timeline().find((e) => e.phase === "pipelines");
if (pipelines) console.log(`pipelines busy ${Math.round(pipelines.busy)} ms in ${pipelines.spans} span(s)`);A second span means a later frame compiled a variant prepare was not asked for. To time each
variant on its own, pair the starts and ends of a raw
onLoad callback.
The scene worker
Two CPU jobs of an instanced scene run on the main thread by default, and both take seconds for a fine one:
- Encoding the models. The first time a static set names a model,
setStaticuploads it withrenderer.addModel, which encodes its 8³ bricks for the GPU and lists the sub-cells the placement reads. - Baking the static set.
commit()builds the set's per-cell lists and sub-cell tables insetInstances: on the nightwood forest about 0.65 s at 20 voxels per metre, 5 s at 50 and 20 s at 100 (see crowded cells).
A scene worker runs both off the main thread while the page keeps drawing. The main thread keeps only the cheap halves: sending each model's bricks, claiming its pool slots, and copying the finished bake in.
Wiring it up
The worker file serves encodes and bakes:
import { serveScene } from "@voxolith/engine/worker";
serveScene();On the main thread, makeSceneWorker({ spawn })
starts it. Give it to an instance layer as worker, then commit with
commitAsync():
import { makeInstanceLayer } from "@voxolith/engine";
import { makeSceneWorker } from "@voxolith/engine/worker";
const worker = makeSceneWorker({
spawn: () => new Worker(new URL("./scene.worker.ts", import.meta.url), { type: "module" }),
});
const layer = makeInstanceLayer(renderer, { load, worker });
layer.setStatic(scenery); // returns at once: new models go to the worker to be encoded
await layer.commitAsync(); // encodes, adds and bakes; the page keeps drawing
loop.invalidate(); // the new static set is drawing: render itOne worker can serve several layers and renderers. It is a worker of its own rather than a job for the generator pool: a generator worker runs one generation after another, so an encode or a bake queued behind one would wait for it. Encodes and bakes share its one queue, in the order they were asked for.
On its own the worker computes everything again on every visit. With serveScene({ cache }) it
keeps the encodings and bakes in IndexedDB, and a warm visit reads them instead: see
caching encodings and bakes.
What runs where
| step | where | notes |
|---|---|---|
setStatic(list) | main thread | sends each model it has not seen to the worker and returns. A sparse model's bricks go in packed chunks, one task each (chunkBricks, 16384 bricks or 8 MiB by default); a dense model's arrays go in one message |
| encode | worker | encodeModel, in the order asked |
| wait | main thread, idle | commitAsync() waits until every model of the set has landed |
| reserve | main thread | reserveBricks once for the batch, below |
| add | main thread, paced | beginEncodedModel per model, stepped in slices over frames (or addEncodedModel on a target without it): claims its slots and copies its bytes in. Each add registers the model for placement on the worker by its keys alone, since the worker kept the sub-cells the encode listed |
| bake | worker | the static set's per-cell lists and sub-cell tables |
| apply | main thread | applyPlacement copies the bake into the renderer's buffers |
The adds are paced so that no one frame takes them all.
uploadBudgetMs
(8 ms by default) is how long commitAsync spends adding before it waits for the next frame.
Small models share a frame. A big one is added in slices: the layer starts it with the renderer's
beginEncodedModel and steps it within the budget, so it spans several frames instead of being
one long task. A whole add of a fine tree at 100 voxels per metre takes 70 to 94 ms; in slices
the largest step is about 8 ms (measured). A target without beginEncodedModel (an
InstanceTarget of your own) gets at least one whole model per frame.
pace replaces that wait:
by default it is the next painted frame, or 100 ms at most, since a hidden page draws no frames.
// A tighter frame budget while a crowd is already walking: 4 ms of adds, then the next frame.
const layer = makeInstanceLayer(renderer, { load, worker, uploadBudgetMs: 4 });
// A test or a headless tool with no frames to wait for: add everything in one go.
const tool = makeInstanceLayer(renderer, { worker, uploadBudgetMs: Infinity, pace: async () => {} });Reserving the pools
The brick pools grow as models are added, in 1.5x steps. A step copies the GPU pools into bigger
buffers on the GPU (copyBufferToBuffer), which costs the main thread 1 ms or less, where
re-uploading them from the CPU cost 150 to 270 ms at a few hundred MB (measured). What a step
still costs is the CPU mirror of the pools, which reallocates and copies itself, and a moment in
which the GPU holds the old buffers and the new ones. So commitAsync calls reserveBricks with
every encoding of the batch first, while the pools are still small, and the adds after it only
copy their own bricks. The only difference from adding without it is the pools' spare capacity:
model ids, the index and everything drawn are the same.
reserveBricks also takes brick counts, { bricks4, bricks8 }, for content that is not encoded
yet, such as the ground a streamed world is about to edit in. renderer.stats() reports slots4
and slots8 (the bricks the pools hold) and cap4 and cap8 (their room), so a comparable
scene measured once gives the numbers. The headroom is capped at the device's buffer limits, and
so is the CPU mirror, so a scene that fits the device still fits; one that does not throws, with
the limit to raise. The renderer's storage page has
the details. The examples tried a reservation for their ground up front and measured no gain
after the first frame, so they leave the ground to grow the pools as it streams.
What the loading screen sees
The upload phase counts differently on the two paths:
- On the main thread (no scene worker), one tick per model.
- With a scene worker, in bricks: each model is one task from its encode request to its
add, weighted by what it costs (its stored bricks, or every brick position of a dense model).
donefollows the encode's progress on the worker but stays one short of the model's count until it is added. A cancelled or failed encode leaves the phase, its progress included. At 100 voxels per metre that is millions of units, so show this phase's bar and not its numbers (the examples'STEPS.encodedUpload).
The placement phase is the same on both: one task from the bake request to its apply, described
below. layer.uploads
counts the models sent to be encoded and not yet added, and
layer.baking is true
from commitAsync() until the set is drawing, encodes and adds included.
What it saved in the examples
Measured on a real GPU with a warm model cache: the longest main-thread task from the start of the
upload to the first frame, with the models uploaded in setStatic and the set baked on a worker
(before), against models encoded and the set baked on the scene worker (after). These are
measurements of those pages, not a promise for yours:
| page | before | after |
|---|---|---|
| valley, 100 voxels per metre | 4.15 s | 74 ms |
| valley, 50 voxels per metre | 0.85 s | 76 ms |
| nightwood, 100 voxels per metre | 3.43 s | 168 ms (during the bake) |
| nightwood, 50 voxels per metre | 0.76 s | 78 ms |
The first frame barely moved: it waits for the bake, which now gates it. What changed is that the page keeps drawing and taking input all the way there. Order matters too: the valley streams its ground while the encodes run. Awaiting the uploads first gave the same first frame at 100 voxels per metre, plus a task of 220 to 240 ms after it.
What it resolves on
commitAsync() resolves once the static set of this call, or a newer one, is drawing; at once
when the static set had not changed. Meanwhile:
- the moving set is sent at once, as by
commit(), and latercommit()calls (a crowd's, every frame) keep sending only the moving set, so moving things keep moving; - the old static set keeps drawing until the new one is applied (nothing, before the first);
layer.bakingis true;- the
placementphase is one task from the bake request to its apply, with the main thread free. Its total is the number of static placements, and itsdonefollows the worker's progress (the bake's fraction of that count), held one short of the total until the apply ticks the last. A superseded, aborted or failed bake is discarded: it leaves the phase, progress included, sodonecan drop. At fine scales the count moves in steps, since it only advances between instances (about 190 ms apart at 100 voxels per metre).
The layer does not redraw for you: a page that renders on demand calls loop.invalidate() once it
resolves.
import { makeFrameLoop } from "@voxolith/renderer";
layer.setStatic(forest);
const placed = layer.commitAsync(); // encodes, then the bake, on the worker
const loop = makeFrameLoop({
continuous: true, // a crowd is walking, so every frame is drawn anyway
render() {
layer.setDynamic(walkers()); // the moving set...
layer.commit(); // ...goes out every frame; the static set is left to the worker
placing.hidden = !layer.baking; // "Placing the forest..."
renderer.render(frame()); // the old static set (here: none) until the bake applies
},
});
await placed; // the forest is drawing from the next frameA synchronous commit() after a setStatic does not wait: it adds the encodings that have landed
(after one reservation for them), uploads the rest with addModel and places the set, all on the
main thread. So does asking for a model's id with layer.models.id(model) while its encode is in
flight: it is uploaded there and then.
The newest set wins
Only the newest static set is applied. A setStatic followed by commitAsync() (or by a
synchronous commit()) while one is in flight supersedes it: its bake result is dropped when it
lands, and it leaves the placement total. Models it already added stay added, and encodes of
models the new set names too carry on, so a change of season that keeps the rocks does not encode
them again. Encodes of models the new set does not name are cancelled, unless a commitAsync()
still waits for them. If a model the bake names was removed or replaced while it ran, the layer
takes the input again and re-bakes, rather than drawing the wrong model.
layer.setStatic(summerForest);
const first = layer.commitAsync(); // encodes, then bake 1
layer.setStatic(winterForest); // the season changed before it finished
const second = layer.commitAsync(); // supersedes it; models both name are encoded once
await first; // resolves too: "this call's set, or a newer one" is drawing
await second; // the winter forest; the summer set never drewAborting
commitAsync({ signal }) takes an AbortSignal. Aborting drops the pending apply and rejects with
signal.reason, unless another commitAsync() is still waiting for the same set. Encodes carry
on, since the static set still names their models. The worker skips the bake if it has not
started it; one that has started runs to the end and its result is dropped, because a bake is one
synchronous call and cannot be interrupted.
Afterwards the static set counts as unsent: the next commit() uploads what is missing and
places it synchronously, and the next commitAsync() bakes it again. An encode or a bake that
fails on the worker rejects with the same effect. That makes a fallback short; the examples use
this one:
try {
await layer.commitAsync();
} catch (err) {
console.warn("the scene worker failed; uploading and placing on the main thread:", err);
layer.commit();
}Bake only, and the older names
The scene worker used to be a placement worker, which only baked. Its names still work:
| older name | now |
|---|---|
makePlacementWorker({ spawn }) | deprecated: makeSceneWorker typed as the bake-only PlacementWorker |
servePlacement() | deprecated: serveScene(), so an existing placement.worker.ts also serves encodes |
makeInstanceLayer(renderer, { placement }) | bakes on the worker as before; setStatic still uploads new models on the main thread |
Moving to the new names is two lines, the worker's import and the layer's option. worker takes
the place of placement when both are given. A target without
addEncodedModel (an
InstanceTarget of your own) only bakes on a scene worker, and uploads on the main thread.
import { makeInstanceLayer } from "@voxolith/engine";
import { makeSceneWorker } from "@voxolith/engine/worker"; // was makePlacementWorker
const worker = makeSceneWorker({ spawn }); // the worker file: serveScene(), was servePlacement()
const layer = makeInstanceLayer(renderer, { load, worker }); // was { load, placement }Adding a big model in slices
renderer.beginEncodedModel(encoded, opts?)
is addEncodedModel spread over frames. It claims the model's pool slots, index blocks and id at
once, exactly as addEncodedModel would there, and returns a
PendingModel:
step(budgetMs)copies and uploads slices of about 1 MB (0.3 to 0.5 ms each, measured in bun) until the budget is spent, at least one per call, and returns true once the model is added; the step that finishes registers it. The result is byte-identical toaddEncodedModelat the start.progressis the fraction copied, by bytes, from 0 to 1; it never goes back.cancel()gives back everything it claimed.idmay be named in instances only oncestephas returned true; before thatsetInstancesthrows for it.
Anything else may run between steps, other adds and pool growth included. The layer does this for you with a scene worker; a host driving the renderer itself steps the model from its frame loop:
const pending = renderer.beginEncodedModel(encoded);
const loop = makeFrameLoop({
continuous: true,
render() {
if (!pending.done && pending.step(4)) {
// 4 ms of the add per frame; the frame it finishes, place it
renderer.setInstances([{ model: pending.id, x: 40, y: 12, z: 60, base: bark }]);
}
bar.value = pending.progress;
renderer.render(frame());
},
});Driving it yourself
The layer is a convenience over two splits in the renderer, each a headless half for a worker and a cheap half for the main thread:
- Encoding.
addModel(src)isaddEncodedModel(encodeModel(src)): encode on the worker, send the result back withencodedTransferables, and add it, after onereserveBricksfor a batch. - Placement.
setInstancesisplacementInput(list)on the main thread,bakePlacementor aPlacementBakeron the worker, andapplyPlacement(bake). The baker needs each model's placement data:placementModel(id)sends it from the main thread, or, when the same worker encoded the model,placementModelOfbuilds it there from the encoding and the keys the renderer gave it, so the sub-cells never travel back.
Both halves of each run the same code as the one-step calls, so they upload the same bytes. The
renderer's instances page has the details,
and SceneWorker has encode,
registerEncoded, forget, register, drop and bake for a host that uses the engine's worker
without a layer.
Without the engine, the whole round trip is a few messages of your own. This one encodes a batch, keeps the sub-cells on the worker, and bakes:
import type { EncodedModel, Instance, ModelSource, PlacementBake } from "@voxolith/renderer";
type Reply =
| { id: number; encoded: EncodedModel }
| { id: number; done: number; total: number }
| { id: number; bake: PlacementBake };
const worker = new Worker(new URL("./scene.worker.ts", import.meta.url), { type: "module" });
const waiting = new Map<number, (e: EncodedModel) => void>();
let nextId = 0;
let latest = 0;
let scenery: Instance[] = [];
function encode(model: ModelSource): Promise<{ job: number; encoded: EncodedModel }> {
const job = ++nextId;
worker.postMessage({ type: "encode", id: job, model }); // a big sparse Map clones slowly: chunk it
return new Promise((resolve) => waiting.set(job, (encoded) => resolve({ job, encoded })));
}
function placeStatic(list: Instance[]): void {
scenery = list;
worker.postMessage({ type: "bake", id: (latest = ++nextId), input: renderer.placementInput(list) });
}
worker.onmessage = ({ data }: MessageEvent<Reply>) => {
if ("encoded" in data) return waiting.get(data.id)?.(data.encoded);
if (data.id !== latest) return; // a newer static set was asked for: drop this one
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
}
};
// Encode the batch there, grow the pools once, add here (a page would add one per frame).
const batch = await Promise.all(trees.map((t) => encode({ size: t.model.size, data: t.model.data })));
renderer.reserveBricks(batch.map((b) => b.encoded));
const ids = batch.map(({ job, encoded }) => {
const id = renderer.addEncodedModel(encoded);
const { key, poseKey } = renderer.placementModel(id)!;
worker.postMessage({ type: "keys", id: job, keys: { key, poseKey } }); // register it for the bake
return id;
});
placeStatic(sites.map((s, i) => ({ model: ids[i % ids.length], x: s.x, y: s.y, z: s.z, yaw: s.yaw, base: oakPalette })));The worker imports only @voxolith/renderer/core, which needs no DOM, GPU or bundler plugin.
Messages to one worker are handled in order, so a keys posted before a bake is seen before it.
An encoding carries ENCODED_MODEL_VERSION,
and addEncodedModel throws on any other, so a host that caches encodings keys them by it.
A host that caches bakes of its own has one more problem: a bake names models by renderer id,
and ids differ between visits. normalizePlacement(bake)
takes them out (models are numbered by first appearance in the set) for storing, and
bindPlacement(stored, input) puts this
visit's ids and keys back from the input. The result is byte for byte what bake(input) would
give, provided the key under which it was stored covers everything the bake reads: the grid, the
instances in order with each model named by its content rather than its id, those models'
placement data, and PLACEMENT_BAKE_VERSION.
bindPlacement checks only what it can without the models (the grid, the instance count and the
pattern in which the input names its models) and throws otherwise. In the worker above:
import { bindPlacement, normalizePlacement, PLACEMENT_BAKE_VERSION, type PlacementBake, type PlacementInput } from "@voxolith/renderer/core";
// `store` is yours (IndexedDB, say); `digestOf` hashes the grid, the instances and their models' content.
async function bakeCached(input: PlacementInput): Promise<PlacementBake> {
const key = `placement|v${PLACEMENT_BAKE_VERSION}|${digestOf(input)}`;
const stored = await store.get(key);
if (stored) return bindPlacement(stored, input); // this visit's ids; throws on another set
const bake = baker.bake(input);
await store.put(key, normalizePlacement(bake)); // store it before transferring: they share arrays
return bake;
}normalizePlacement copies only the instance records; the other arrays are the bake's own, so
store (or copy) the normalised bake before the original is transferred. The engine's
scene cache does all of this.
Generation queue controls
makeGeneratorPool queues requests and
hands them to free workers. Without options the queue is a plain FIFO. Each request can take
GenerateOptions, and
generateMany(specs, options) applies them to every request (the older
generateMany(specs, onDone) still works; onDone is now an option).
Priority
generate(spec, { priority }): a free worker takes the highest-priority queued request, in the
order they were queued within one priority (default 0). Running requests are never pre-empted.
const hero = pool.generate(heroSpec, { priority: 10 }); // before the scenery
const scenery = pool.generateMany(scenerySpecs); // priority 0Abort
generate(spec, { signal }) cancels a request. A queued one leaves the queue. A running one
rejects at once and its result is discarded when it lands, but the worker still finishes it: a
generator runs synchronously and cannot be interrupted. Either way the promise rejects with
signal.reason (a DOMException named "AbortError" unless you gave abort() a reason), as
fetch does, and the request leaves the models total. A signal already aborted rejects without
queueing anything; one signal given to generateMany rejects the lot.
When single models take seconds (fine scales, phones) and a game cancels often,
GeneratorPoolOptions.respawnOnAbort
terminates the worker running an aborted request and starts a fresh one instead. It is off by
default, because a new worker reloads its script and generators (a few hundred ms) and any model
cache writes still in progress on the old one are lost.
import { makeGeneratorPool } from "@voxolith/engine/worker";
const pool = makeGeneratorPool({ spawn, load, respawnOnAbort: true }); // opt in, for slow models
const leaving = new AbortController();
const village = pool.generateMany(houseSpecs, { signal: leaving.signal });
exitButton.addEventListener("click", () => leaving.abort()); // the player left before it loaded
try {
placeVillage(await village);
} catch (err) {
// Our own cancel is not an error; anything else (a generator that threw) still is.
if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
}Pause and resume
pause() stops handing queued
requests to workers: running ones finish and resolve, new requests queue.
resume() starts again. The models
phase stays busy while anything is queued, since the work is still owed. pool.paused and
pool.queued read the state. Pausing is for a game that wants the cores for something else for a
while (a cutscene, a level transition), not a way to stop work already running.
// Hold the queue while the settings menu is open or the tab is hidden.
const update = () => (menu.open || document.hidden ? pool.pause() : pool.resume());
menu.addEventListener("close", update);
document.addEventListener("visibilitychange", update);
openMenuButton.addEventListener("click", () => {
menu.showModal();
update();
console.log(`${pool.queued} requests wait; ${pool.pending - pool.queued} still running`);
});Reprioritise
reprioritise(fn) gives queued
requests new priorities: fn(spec, priority) returns each one's new priority, and the queue is
re-sorted (keeping queue order within a priority). Running requests are unaffected. One call is a
sort of the queue, so a game streaming models around the player can call it whenever the player
has moved far enough:
// Near models first; drop the ones the player has walked away from.
const nearby = new AbortController();
for (const s of specsAround(player)) pool.generate(s, { priority: -distanceTo(s), signal: nearby.signal }).then(place, () => {});
onPlayerMoved(() => pool.reprioritise((s) => -distanceTo(s)));
onRegionLeft(() => nearby.abort());distanceTo and the thresholds are yours: the pool knows nothing about players or regions.
Skipping the cache
generate(spec, { cache: false }) neither reads nor writes the worker's
model cache for that request: for a model you know you will not see again, or
a debug build of one model.
import { PRESETS } from "@voxolith/gen-tree";
// A parameter slider in a tool: each value is seen once, so keep the cache for real content.
const preview = await pool.generate(
{ generator: "voxolith/tree.broadleaf", params: { ...PRESETS.oak, ...sliders }, seed: 1 },
{ cache: false },
);Destroying the pool
destroy() returns a promise.
Every pending request rejects at once. Workers still generating are terminated at once; idle ones
first finish their model cache writes (up to
closeTimeoutMs, 10 s by default),
so the models just made are there on the next visit. It resolves when every worker is gone, and
you need not wait for it. The wait matters because a worker writes to the cache after it has
posted its result, often while it is already generating the next model: a pool that terminated
its workers straight after the last result would lose the last few models' writes, and the next
visit would generate them again.
// Either: leaving a level, nothing waits; idle workers finish their cache writes in the background.
void pool.destroy();
// Or: a tool or a test that must know the workers are gone (and their writes committed).
await pool.destroy();The model cache
serveGenerators({ cache }) in the generator worker keeps generated models in IndexedDB and
serves them from there next time (Workers and caching
has the basics). What it caches is each entity a request returns, packed and deflated, under a key
of the form salt|generator@version|seed|params|context, where the salt is the worker's script
URL. A production build hashes that URL, so new generator code never reads a model the old code
made, and a worker opening the cache deletes every entry stored under another salt.
It is also what serves models offline. The app's service worker caches its code and fetched files, not models; with both, a page opened offline reads its models from here and generates the rest in the browser (Offline and installing).
Inspecting, clearing and trimming
openModelCacheControls(name) opens
ModelCacheControls on the same
database, from the main thread, while workers use it. It returns null where there is no
IndexedDB. The controls read only a small record per entry, never the models:
| control | does |
|---|---|
entries(filter?) | every matching ModelCacheEntry (key, salt, generator, bytes, created, last used), most recently used first |
usage(filter?) | how many entries match and their approximate bytes |
clear(filter?) | delete the matching entries (all without a filter); resolves to how many went |
trim(maxBytes) | evict least recently used entries until the cache is at most maxBytes |
close() | close the connection |
A ModelCacheFilter matches on
generator (an id such as "voxolith/tree.broadleaf"), salt, or both. None of this runs by
itself: the engine clears or trims nothing unless asked, apart from a worker dropping an older
build's entries and the cap below.
import { openModelCacheControls } from "@voxolith/engine/worker";
const cache = await openModelCacheControls("my-game-models");
if (cache) {
const { entries, bytes } = await cache.usage();
settings.cacheLabel = `${entries} models, ${(bytes / 1e6).toFixed(1)} MB`;
onClearCacheClicked(() => cache.clear());
}A size cap
The object form, serveGenerators({ cache: { name, maxBytes } })
(ServeCacheOptions), keeps the cache
under about maxBytes compressed: after each write the least recently used entries (of any salt)
are evicted until it fits, and a model bigger than the cap is not kept at all. A cache hit counts
as a use. Without a cap the cache grows until the browser's quota stops it; a write that fails
keeps nothing, and the model still works. store swaps IndexedDB for a
CacheStore of your own
(memoryCacheStore() in tests).
import { registerTreeGenerators } from "@voxolith/gen-tree";
import { serveGenerators } from "@voxolith/engine/worker";
registerTreeGenerators();
// Measured: every scene of this game stores 106 MB of models. 256 MiB leaves room to grow.
serveGenerators({ cache: import.meta.env.DEV ? undefined : { name: "my-game-models", maxBytes: 256 * 1024 ** 2 } });Sizing a cap
The engine picks no cap: what a game stores depends on its scenes, scales and variants, so each game sizes its own. Measure rather than guess:
- Build for production (the caches are off in development) and leave
maxBytesout. - Load each of the game's heaviest scenes once, on an empty cache.
- Read what the caches hold from the main thread, and set each cap to that plus headroom for more variants and for generators that grow.
import { openModelCacheControls, openSceneCacheControls } from "@voxolith/engine/worker";
// A production build, the caps left out, after loading the heaviest scenes once:
const models = await openModelCacheControls("my-game-models");
const scene = await openSceneCacheControls("my-game-scene");
const mb = (n = 0) => `${(n / 1e6).toFixed(0)} MB`;
console.log(`models ${mb((await models?.usage())?.bytes)}, scene ${mb((await scene?.usage())?.bytes)}`);The examples did this for every page at every scale: 106 MB of models under a 256 MiB cap, and
1816 MB of encodings and bakes under a 2.5 GiB scene cap (measured; the per-page sizes are in the
comments of their gen.worker.ts
and placement.worker.ts).
A cap below what one session needs evicts the models of the scene the player just left, and the
next visit to it is cold.
The engine checks the value, the same way for both caches:
maxBytes | what happens |
|---|---|
not a positive, finite number (0, a negative, NaN, Infinity, a string) | the serve call throws a RangeError when the worker starts; leave it out for no cap |
under CACHE_CAP_FLOOR (16 MiB) | a warning: one model or bake at a fine scale is often bigger, so the cache keeps little |
above the origin's quota (navigator.storage.estimate()) | a warning: the browser evicts before the cap is reached, so the cap never applies |
| smaller than an entry | a warning at the first such entry, which is not kept and is rebuilt every visit; later ones are skipped silently |
Each warning comes once per cache, with console.warn, and since every worker opens its own
cache, a pool of four workers may warn four times. The warnings say what to do: size the cap from
usage(), or ask for more room with navigator.storage.persist().
Phones
Storage on phones is small, and the browser may evict a whole origin under pressure. Two platform calls help an app decide, and both are the app's to make:
navigator.storage.estimate()reports the origin's usage and quota; compare it withusage()andtrim()to a share of the quota you choose.navigator.storage.persist()asks the browser not to evict the origin. It may prompt the user or refuse, so ask once the user has shown they will come back, not on the first load.
const { quota = Infinity } = (await navigator.storage?.estimate()) ?? {};
const { bytes } = await cache.usage();
if (bytes > quota / 4) await cache.trim(quota / 8);One database name per worker script
Opening the cache deletes every entry stored under another salt, and the salt is the worker script's URL. Two worker scripts sharing one database name therefore delete each other's models each time either opens it. Give each worker script its own name. This matters on GitHub Pages, which serves every repository of an account from one origin, so two apps there share one IndexedDB.
import { registerTreeGenerators } from "@voxolith/gen-tree";
import { registerRockGenerators } from "@voxolith/gen-rock";
import { serveGenerators } from "@voxolith/engine/worker";
registerTreeGenerators();
registerRockGenerators();
serveGenerators({ cache: "my-game-scenery" });import { registerCreatureGenerators } from "@voxolith/gen-creature";
import { serveGenerators } from "@voxolith/engine/worker";
registerCreatureGenerators();
serveGenerators({ cache: "my-game-creatures" }); // not "my-game-scenery": each script its ownControls are opened per name too: openModelCacheControls("my-game-scenery") sees only that
worker script's models.
The first visit after upgrading is cold
The cache database is at version 2, which added the per-entry records the controls read. Version-1 records have none, so the upgrade drops them: the first visit after an app moves to this engine version generates everything once, and caches as before from then on.
How models come back
A generator worker posts each entity packed
(PackedEntity): a small header plus one
byte buffer holding a sparse model's brick keys and bricks (or its dense data) and its bones, and
it transfers that one buffer. A cache hit posts the stored record as it is, without rebuilding the
entity on the worker. The pool unpacks it on the main thread into views of that buffer: a Map
entry per brick, no copies.
It used to transfer every 512-byte brick as a buffer of its own, and that, not IndexedDB, was the
slow part of a warm visit: nightwood's twelve models at 100 voxels per metre are about 800k bricks.
Measured on the nightwood page at 100 voxels per metre with a warm model cache, the models phase
went from 7.4 s to 0.28 s. Nothing changes for the caller: generate still resolves with an
Entity.
Caching encodings and bakes
The model cache saves the generation. A warm visit still encodes every model for the GPU and bakes the static set again, and at 100 voxels per metre that is most of the load: nightwood spends about 3 s encoding and 20 s baking. Both are pure functions of their inputs, so the scene worker can keep them too. It is opt-in, in two places:
import { serveScene } from "@voxolith/engine/worker";
serveScene({
// Sized from usage(): this game's scenes store about 700 MB. Off in development (below).
cache: import.meta.env.DEV ? undefined : { name: "my-game-scene", maxBytes: 1024 ** 3 },
});ServeSceneCacheOptions is the
object form: name, maxBytes, compress, and store for a
CacheStore of your own
(memoryCacheStore() in tests). A string is
the name alone. Where there is no IndexedDB the cache is off and everything is computed as before.
What is cached, and under which key
| entry | key | what the digest covers | stored |
|---|---|---|---|
| an encoding | encoded|v<ENCODED_MODEL_VERSION>|<digest> | the model's identity (below), its part boxes and its joints | the whole EncodedModel, sub-cells included |
| a bake | placement|v<PLACEMENT_BAKE_VERSION>|r<record>|<digest> | the grid; every instance in order (position, anchor, yaw, rotation, mirror, palette base, parts), each naming its model by the digest of its placement data rather than its id; which instances share poses; and each model's placement data (size, sub-cells, part boxes, joints) | the PlacementBake, normalised |
Every key is prefixed with a salt, the scene worker's script URL, as the model cache's is: a
production build hashes that URL, so new renderer code never reads what older code stored, and
opening the cache deletes every entry stored under another salt. r<record> is the layout of the
stored record itself.
Why the ids are left out. The renderer numbers models by free slot, so the same scene can get
different ids on another visit: another layer added a model first, an encode landed in another
order, a model was removed and its slot reused. A key with the ids in it would miss for no
reason, and a bake stored with them would name the wrong models when applied. So the key names
each model by its content, the bake is stored with its models numbered by order of first
appearance in the set (the renderer's
normalizePlacement), and on a hit the
worker binds it to this visit's ids and keys
(bindPlacement) before posting it. What
the main thread applies is byte for byte what a fresh bake would be.
A bake needs nothing from the host: the worker already holds the placement data of every model it bakes against, so it digests those. An encoding does, because its identity has to be known before the model is sent.
modelKey or hashModels
An encoding is looked up by the identity of the model's voxels. Two ways to give one, and a model with neither is not cached (its bake still is):
modelKey, a function from a model to a string.pool.modelKeynames what made a model the pool returned: the generator worker's URL (hashed in production, so it changes with the generator code), the generator id and version, the seed, the parameters and the context. The layer asks the worker for the key before anything is sent, so on a hit the main thread never packs or posts the model's bricks: one round trip, and the stored encoding comes back.hashModels: true, for models with no key: the worker hashes the source's bytes (about 30 ms per 100 MB) and looks the encoding up by that. The bricks are sent in full on every visit, so this saves only the encode.
When each is honest:
- A key is only as good as its promise that it changes whenever the voxels do. A generated
model, as generated, keeps that promise, since generation is deterministic. A model you edited
afterwards (carved, wounded, recoloured voxel by voxel) does not: return
undefinedfor it. A key two different models share hands the second the first one's encoding, without an error. - A hash is always honest, since it is the content. Use it for models the page builds itself.
The instances example grows its trees on the main thread, with no
pool and so no key, and uses
hashModels.
// Generated models by key, edited ones by hash.
const layer = makeInstanceLayer(renderer, {
load,
worker,
modelKey: (m) => (edited.has(m) ? undefined : pool.modelKey(m)),
hashModels: true, // only for models modelKey gives no key
});Versions
Both formats carry a version in their keys:
ENCODED_MODEL_VERSION and
PLACEMENT_BAKE_VERSION. The
renderer bumps them whenever the same input would encode or bake differently; its checks hold a
digest of a fixed scene's bake next to the version it was recorded under, and fail when the bake
changes and the version does not. In a production build the salt already changes with any new
code, so the versions are for builds whose worker URL stays the same. A bump makes the old entries
unreachable, not gone: they leave by the cap, by the browser, or by a clear().
Storage against speed
What each example page adds to the scene cache, visited in this order on an empty cache (a
production build in desktop Chrome, from the comment in the examples'
placement.worker.ts).
At 50 and 100 voxels per metre the pages load coarse first, so they
keep two stages: the coarse models' encodings are small, but their bake is about as big as the
fine one, since the tables are laid out in world cells, not model voxels.
| page | stored |
|---|---|
| valley, 20 voxels per metre | 17 MB |
| valley, 50 voxels per metre | 177 MB |
| valley, 100 voxels per metre | 824 MB |
| nightwood, 20 voxels per metre | 13 MB |
| nightwood, 50 voxels per metre | 129 MB |
| nightwood, 100 voxels per metre | 655 MB |
| instances | 1 MB |
| every page | 1816 MB |
That is much more than the models (which are deflated), because an encoding is what the GPU
reads. Entries are stored as they are by default. With
compress: true they
are deflated, about 6 to 7 times smaller on nightwood at 100 voxels per metre, but deflating them
takes about 14 s of the worker's time after a first visit (only while it is not baking), and
inflating adds about 0.8 s to every warm visit, where reading them as they are takes about 0.2 s.
So compression is for storage that is tight, not for speed; with it, give makeSceneWorker a
longer closeTimeoutMs,
or a short visit's writes are lost.
The examples chose speed with a cap sized to what they store: maxBytes of 2.5 GiB for the
1816 MB above, which leaves about 45% for ?variants= above the default and for generators that
grow. So a visitor who opens every page at every scale evicts nothing. After each write the least
recently used entries are evicted until the cache fits, and a result bigger than the cap is not
kept at all. Without a cap the cache grows until the browser's quota stops it, and a write that
fails keeps nothing. Size a game's own cap the same way, and on phones the
same advice as for the model cache applies.
What it saved in the examples
Measured on a real GPU in desktop Chrome, warm (the second visit): the first frame with the model cache alone (before), and with the scene cache as well (after). These are measurements of those pages, not a promise for yours:
| page | before | after |
|---|---|---|
| nightwood, 100 voxels per metre | 30.8 s | 13.9 s |
| valley, 100 voxels per metre | 13.6 s | 6.4 s |
| nightwood, 50 voxels per metre | 6.6 s | 3.7 s |
| valley, 50 voxels per metre | 4.9 s | 3.1 s |
A warm placement phase took 59 to 127 ms. Warm nightwood at 100 voxels per metre then waited on the
ground it streams, not on the scene; coarse first and
ground on workers took the first frames down from there.
Inspecting and clearing it
openSceneCacheControls(name) opens
SceneCacheControls on the main thread,
while the worker uses the cache: entries, usage, clear, trim and close, as for the model
cache, with a SceneCacheFilter by kind
("encoded" or "placement") and salt. They read only the small per-entry records, and it
returns null where there is no IndexedDB.
makeSceneCacheControls(store) is
the same on a CacheStore of your own. Nothing runs by itself.
import { openSceneCacheControls } from "@voxolith/engine/worker";
const scene = await openSceneCacheControls("my-game-scene"); // the name serveScene was given
if (scene) {
const enc = await scene.usage({ kind: "encoded" });
const bakes = await scene.usage({ kind: "placement" });
settings.sceneCacheLabel = `${enc.entries} models, ${bakes.entries} scenes, ${((enc.bytes + bakes.bytes) / 1e6).toFixed(0)} MB`;
onClearCacheClicked(() => scene.clear());
}Off in development, and one name each
Off in development
A dev server's worker URL does not change with the code, so edited renderer code would be served
what older code stored. Leave the cache off there (import.meta.env.DEV ? undefined : ...), as
for the model cache. pool.modelKey has the same limit in development: its key does not change
when you edit a generator.
Give the scene cache a database name of its own, not the model cache's: the two workers have different salts, and opening a cache deletes every entry stored under another salt.
Destroying the worker
destroy() returns a promise, as the
pool's does. Pending jobs reject at once. A worker still running a job is terminated at once; an
idle one first finishes its cache writes (up to closeTimeoutMs, 10 s by default), so what it just
made is there next visit. The writes come after the results are posted, so a page that destroys
the worker right after its last commitAsync() still keeps them. Don't make a first frame wait for
it:
await layer.commitAsync();
void worker.destroy(); // the bake is drawing; its cache write finishes in the backgroundThe deprecated PlacementWorker has the same destroy().
Coarse first, one swap
This is an optional routine, not something the engine does by default. A page at a fine scale (50 or 100 voxels per metre) can show its scene before the fine models exist. It first places every model generated at the generators' native 10 voxels per metre, drawn 5 or 10 times enlarged, then generates, uploads and bakes the fine set in the background and swaps it in within one frame. The first picture comes much sooner, but the load does not get cheaper: a second generation, upload and bake runs while the user is already in the scene. The scene finishes later, and frames during that work cost more.
Use it where the first picture matters more than those background seconds and the device has the
headroom: usually a desktop, at 50 voxels per metre or more. At 20 the fine models are cheap, and
a second stage only adds work. On a phone it lagged, so the examples leave it off there. Voxolith
leaves this choice to the developer: the engine gives the pieces below and turns none of them on.
The examples decide per device with a COARSE_FIRST = { desktop: true, phone: false } switch at
the top of valley and nightwood. It is on for desktops at 50 and 100, and off on phones (the phone
profile of shared/scale.ts), which load their fine models in one stage. ?coarse=0 or
?coarse=1 overrides it. The piece to copy is
shared/detail.ts.
It works because every entity generator builds the same design at 10 voxels per metre in a fraction of the time, with the same extent and anchor as at its finer scales, and the contract checks that a finer scale stays within one native voxel of the native model (at 100 voxels per metre nightwood's twelve fine models are about 800k bricks).
It takes three pieces, each opt-in:
- Scaled placements.
EntityPlacement.scaledraws a model enlarged by an integer factor: each of its voxels coversscale³ world voxels. The factor is the world's resolution over the model's, so a 10 voxels per metre model in a 100 voxels per metre world takes 10. The layer places the anchor atscale × anchor, which is where the generator puts the fine model's anchor, so the coarse model and the fine one share a placement. The scale is not read from the model, because only the app knows the world's resolution. The layer holds one renderer model per model and scale, and encodes each model once, since one encoding serves every scale. - The swap. Give the fine set to
setStaticand callcommitAsync()in the same task. The coarse set keeps drawing while the fine models encode, add and bake. The fine set replaces it in one apply, so the swap is one frame. Once the fine set is drawing, the layer releases the coarse models it no longer needs. The same task matters because acommit()in between (a crowd commits every frame) would place the fine set synchronously, on the main thread. - Both pipeline variants. Scaled models are drawn by a pipeline variant of their own (the
renderer's
SCALEDconstant), which a scene without them never compiles. WithdeferPipelines, prepare both up front:prepare({ instances: true, scaled: true })for the coarse set, andprepare({ instances: true })for after the swap. Otherwise the first frame after the swap compiles the plain variant.
import { makeInstanceLayer, type Entity, type EntityPlacement } from "@voxolith/engine";
import { makeGeneratorPool, makeSceneWorker } from "@voxolith/engine/worker";
const VPM = 100; // the world's resolution
const K = VPM / 10; // the coarse models are built at the generators' native 10 voxels per metre
const pool = makeGeneratorPool({ spawn: spawnGenerators, load });
const worker = makeSceneWorker({ spawn: spawnScene });
const layer = makeInstanceLayer(renderer, { load, worker, modelKey: pool.modelKey });
// The same specs twice: coarse first, fine queued behind them on the same workers.
const coarse = pool.generateMany(specs.map((s) => ({ ...s, ctx: { voxelsPerMetre: 10 } })));
const fine = pool.generateMany(specs.map((s) => ({ ...s, ctx: { voxelsPerMetre: VPM } })));
// Both variants: scaled models for the coarse set, plain ones for after the swap.
const compiled = renderer.prepare({ instances: true, scaled: true });
const compiledFine = renderer.prepare({ instances: true });
// One placement per site, whichever stage's models it draws.
const place = (models: Entity[], scale: number): EntityPlacement[] =>
sites.map((s) => ({ model: models[s.variant].model, x: s.x, y: s.y, z: s.z, yaw: s.yaw, base: s.base, scale }));
layer.setStatic(place(await coarse, K));
await Promise.all([compiled, layer.commitAsync()]);
loop.invalidate();
loadingScreen.ready(); // the scene is up, coarse
// The swap: setStatic and commitAsync in one task, so no commit() (a crowd's) comes in between.
const models = await fine;
await compiledFine;
layer.setStatic(place(models, 1));
await layer.commitAsync(); // the coarse set draws until this applies; its models are released after
loop.invalidate();Every scale of a model has the same roles, so both stages share their palettes. What the swap does not release:
- models whose id the host asked for with
layer.models.id(model, scale), which are the host's to release; - everything, with
keepUnused: true, for a host that switches between the same sets and would rather keep their models on the GPU.
Things to know before copying it:
- Scaled models are drawn at rest. Posing one (a placement with
parts) throws, so keep animated things, such as the valley's rats, out of the coarse stage. - The coarse stage is visibly coarse. Close up, 10 cm voxels are blocks, and undergrowth built at 10 voxels per metre is denser than its fine version, so it visibly thins at the swap. That is within the contract, which bounds a model's extent, not its density.
- It costs a second bake. The coarse set is baked too, and cached too: its encodings are small, but its bake is about as big as the fine one, since the tables are laid out in world cells, not model voxels.
- It does not pay at every scale or on every device. At 20 voxels per metre the factor is 2
and the fine models cost about a tenth of those at 50, so the examples' pages at 20 are up in
about 2 s warm, most of it terrain and ground, which a coarse stage does not shorten. On a phone
the background stage made the scene lag. The examples use it on desktops at 50 and 100 only;
?coarse=1forces it on at any scale and on a phone, and?coarse=0off. - Tell the stages apart on the loading screen. The engine's loaders report both stages under
the same phases. The examples wrap their tracker so that the fine stage's tasks go under phases
of their own (
fine.models,fine.upload,fine.placement), shown as rows in the corner pill after the first picture, and markfinewhen the swap has applied; that is example code, inshared/detail.ts.
What it gave the examples
Measured on a real GPU in desktop Chrome, the warm first frame (the second visit) with the model cache alone, with the scene cache as well, and now: coarse first, the ground filled on workers (below) and the terrain sampled on workers. The last column is the first picture, not the end of the load: the fine stage still runs after it. These are measurements of those pages, not a promise for yours:
| page | model cache | + scene cache | now |
|---|---|---|---|
| nightwood, 100 voxels per metre | 30.8 s | 13.9 s | 1.0 s |
| valley, 100 voxels per metre | 13.6 s | 6.4 s | 2.5 s |
| nightwood, 50 voxels per metre | 6.6 s | 3.7 s | 0.5 s |
| valley, 50 voxels per metre | 4.9 s | 3.1 s | 1.4 s |
The swap itself is one frame. The valley is the slower of the two because its first picture waits for more ground: 18 m around the view, against nightwood's 8 m (the near-ground gate).
Ground on workers
The streamed ground is the other half of a first view. At 100 voxels per metre a 256² chunk of gen-terrain's fine ground cost 50 to 57 ms on the main thread, the fill and the brick upload together, and a view needs dozens of chunks. Two opt-in pieces: knowing when the ground near the camera is in, and filling the chunks on workers.
Ready around the camera
A ChunkedWorld reports its readiness as
data, for a loading screen to wait on:
| call | gives |
|---|---|
pendingWithin(x, z, radius) | how many chunks whose centre lies within radius of (x, z) are not built yet, queued or not |
readyAround(x, z, radius) | pendingWithin(...) === 0 |
whenReady(x, z, radius, { signal }) | resolves once readyAround holds: at once, or after the step that builds the last missing chunk; rejects with signal.reason, or with the error when a chunk there failed to fill |
- The world builds only in
step, and only whatfocusqueued. So keep stepping (the frame loop does), and focus on at leastradiusaround the point, orwhenReadynever resolves. - Chunk centres are what is tested, as
focustests them. To cover a disc of ground, add half a chunk's diagonal (chunk * Math.SQRT1_2), which takes every chunk that reaches into it. - The queue follows the focus. When the focus moves, chunks still queued are re-ranked from the new point, so the queue stays nearest first wherever the camera went.
The examples call their use of it the near-ground gate: the first picture waits for the ground
within ?near= metres of the view (8 on nightwood, 18 on the valley), and the rest of the
streamed radius comes in after it, nearest first. A smaller gate shows the page sooner, with holes
at the edge of the first view where chunks are still missing.
Filling chunks on workers
makeChunkedWorld({ fill }) fills chunks off the main thread. The main thread only copies the
bricks that come back in, nearest first, within step's budget:
makeChunkFillPool({ spawn, init })on the main thread starts the workers and sends each of theminitonce (structured-cloned: plain data and typed arrays). Whatinitholds is the app's; for gen-terrain it is the terrain as data (below).sizeis the number of workers (default one per core less one, up to 4), anddepthhow many requests each keeps queued (default 2). The world keeps at most the pool'scapacity(size × depth) requests out, so the rest of its queue stays in its own nearest-first order and follows the focus.serveChunks({ make })in the worker turnsinitinto aChunkFill, a function that fills one brick the way aneditManyfill does.makeruns once and may return a promise.boxessays what to fill of a chunk (default its whole box).columnBoxes(box, span)gives one box per 8 × 8 brick column, as tall asspansays, which is what makes a fine terrain cheap; columns with nothing in them are left out.generatebecomes optional. Given withfill, it runs on the main thread after a chunk's bricks are in, for what cannot move to a worker (blitting models into the chunk, counting).
import { makeFrameLoop } from "@voxolith/renderer";
import { columnBoxes, makeChunkedWorld } from "@voxolith/engine";
import { makeChunkFillPool } from "@voxolith/engine/worker";
import { fineTerrainInit, refineTerrain } from "@voxolith/gen-terrain";
// Level the pads into terrain.heights first: the workers get the heights as they are now.
const ground = refineTerrain(terrain, 10, { seed, top: paths });
const fill = makeChunkFillPool({
spawn: () => new Worker(new URL("./ground.worker.ts", import.meta.url), { type: "module" }),
init: { fine: fineTerrainInit(ground), base: groundBase }, // sent once to each worker
});
const world = makeChunkedWorld({
target: renderer,
size: SIZE,
chunk: 256, // a multiple of 8 with `fill`
seed,
load,
fill,
boxes: ({ box }) => columnBoxes(box, (ox, oz) => ground.columnSpan(ox, oz)),
});
world.focus(camera.x, camera.z, 2400); // 24 m at 100 voxels per metre
const loop = makeFrameLoop({
continuous: true,
render() {
if (world.pending) world.step(8); // copies in what came back, nearest first
renderer.render(frame());
},
});
// Show the page once the ground within 18 m is in (plus half a chunk's diagonal, since chunk
// centres are tested); the rest streams in after it.
await world.whenReady(camera.x, camera.z, 1800 + 256 * Math.SQRT1_2);
loadingScreen.ready();What stays the same, and what to watch:
- Everything reads as before. A chunk counts as built (
resident,pendingWithin,whenReady, thegroundphase) once its bricks are applied, andstepreturns the chunks still queued, out on a worker, or back and not applied yet, so keep stepping while it is above 0. - Each brick is filled from empty and replaced by the result, which is what
generatewrites into a new or cleared chunk. Sochunkmust be a multiple of the 8-voxel brick, or two chunks would share bricks. - The description is taken when the pool is made. Level the terrain and settle the paths
first; a later height edit needs a new pool.
destroy()the pool when the ground is done. - Failures leave a hole, reported. A chunk whose fill fails (the fill threw, or the worker
died) stays unbuilt and goes to
onError(defaultconsole.error), is not retried until it has been freed, and makes awhenReadyover it reject. A worker that dies is replaced; the requests it had not started go to another. - Chunks freed while out are dropped, and their results with them.
- Cores are shared. The generator pool, the scene worker, the chunk fill pool and the main thread all load at once. The examples give the ground what is left after the generator pool's four and the scene worker's one, up to six: on valley at 100 voxels per metre (warm, 24 cores), 3 workers gave a first frame of 2.97 to 3.01 s, 4 gave 2.77 to 2.82 s, 6 gave 2.53 to 2.55 s and 8 gave 2.75 to 2.79 s, with the fewest long tasks after it at 6 (measured).
In bun, on a gen-terrain fine ground at 100 voxels per metre with 256² chunks and one box per brick column, a chunk took 50 ms 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 (measured). In the examples' pages that is about 10 ms of main thread per chunk, against 50 to 57 ms before.
The terrain itself can be made on workers too. gen-terrain's
terrainHeightsBand samples a
band of rows of the height map, byte for byte what generateTerrain samples there, and
generateTerrain(params, seed, { heights }) builds the terrain from the stitched bands. The
examples sample the valley in six bands (about 0.45 s), which took the 1.5 s terrain freeze out
of the world page. The terrain page has the data forms.
What stays game code
The engine gives controls and reports progress; it decides nothing about your load. These are yours:
- What to generate, and when. Which models, how many variants, at which scale, which first, which to cancel as the player moves, and whether to pause for a cutscene. The pool only runs what it is given, in the order you set.
- Which work gets a phase. The engine's loaders report their own phases; anything else (a
layout, a terrain pass, "placing the scenery" around several engine steps) is a
load.taskyou open, or not. - Which steps a loading screen lists. How many rows, in what order and in what words, which ones the page waits for and which continue after the first picture, and when to call it ready. The examples' loading screen is one answer, in the examples' code.
- What overlaps. Whether the ground streams while the scene worker encodes, how much of a frame
its adds may take (
uploadBudgetMs,pace), and what the first frame waits for: how much ground around the camera (whenReady's radius), and whether a coarse stage comes first. - The stages. Whether to show coarse models first, on which devices, at which scales, and
when to swap; the engine gives
scaleand a swap that releases what it replaced, and turns on neither. - How many workers. The generator pool, the scene worker and the chunk fill pool share the machine; their sizes are yours.
- The caches' budget. Whether the model and scene caches are on, their caps (sized by measuring), whether to deflate the scene cache, when to trim or clear, and whether to ask for persistent storage.
What that looks like in a game's own code: it decides what exists, reports it as a phase of its own, and orders the pool by distance to the player. The engine only runs the requests.
import type { GenerateSpec } from "@voxolith/engine/worker";
const planning = load.task("village"); // a phase the engine knows nothing about
const houses = planVillage(seed); // the game decides what exists...
planning.end();
const nearFirst = (h: House) => -Math.hypot(h.x - player.x, h.z - player.z);
const houseOf = new Map<GenerateSpec, House>();
for (const h of houses) {
const spec: GenerateSpec = { generator: "voxolith/house", params: h.params, seed: h.seed };
houseOf.set(spec, h);
pool.generate(spec, { priority: nearFirst(h) }).then((e) => placeHouse(h, e)); // ...and what comes first
}
onPlayerMoved(() => pool.reprioritise((spec) => nearFirst(houseOf.get(spec)!)));A complete page
A page with no loading-screen helper of its own: one tracker for every loader, pipelines compiled
and the forest encoded and placed on a scene worker behind a status line (both kept for the next
visit), then ground that keeps streaming after the first picture. The names it does not import
or define (worldPalette, treeSites, fillChunk) are the page's own.
import { createRenderer, initGpu, makeCamera, makeFrameLoop, observeResize, resizeToDisplay } from "@voxolith/renderer";
import { formatTimeline, makeChunkedWorld, makeInstanceLayer, makeLoadTracker, trackRenderer } from "@voxolith/engine";
import { makeGeneratorPool, makeSceneWorker } from "@voxolith/engine/worker";
import { timeOfDay } from "@voxolith/engine/atmosphere";
import { PRESETS } from "@voxolith/gen-tree";
const canvas = document.querySelector("canvas")!;
const status = document.querySelector<HTMLElement>("#status")!;
const load = makeLoadTracker();
const stopListening = load.on((e) => {
status.textContent = `${e.phase} ${e.done}/${e.total}${e.cached ? ` (${e.cached} cached)` : ""}`;
});
const gpu = await initGpu(canvas);
const size = { x: 1024, y: 128, z: 1024 };
const renderer = await createRenderer(gpu, { size, palette: worldPalette }, { deferPipelines: true, onLoad: trackRenderer(load) });
const pool = makeGeneratorPool({ spawn: () => new Worker(new URL("./gen.worker.ts", import.meta.url), { type: "module" }), load });
const worker = makeSceneWorker({ spawn: () => new Worker(new URL("./scene.worker.ts", import.meta.url), { type: "module" }) });
const layer = makeInstanceLayer(renderer, { load, worker, modelKey: pool.modelKey }); // cached encodings by what made them
// Three oak variants, placed a few hundred times.
const oaks = await pool.generateMany([1, 2, 3].map((seed) => ({ generator: "voxolith/tree.broadleaf", params: PRESETS.oak, seed })));
const base = layer.palettes.of("oak", oaks[0].model.roles);
layer.setStatic(treeSites.map((s, i) => ({ model: oaks[i % 3].model, x: s.x, y: s.y, z: s.z, yaw: s.yaw, base })));
// Compile, encode and bake at once, off the main thread; the status line keeps updating.
await Promise.all([renderer.prepare({ instances: true }), layer.commitAsync()]);
void worker.destroy(); // nothing else is encoded or baked here; its cache writes finish first
// The ground streams around the camera from here on, a few ms of chunks per frame.
const world = makeChunkedWorld({ target: renderer, size, chunk: 64, seed: 7, generate: fillChunk, load });
world.focus(512, 512, 384);
const camera = makeCamera({ target: [512, 40, 512], distance: 400, pitchDeg: 30, fovDeg: 45 });
const loop = makeFrameLoop({
render() {
if (world.step(6) > 0) loop.invalidate();
resizeToDisplay(gpu);
renderer.render({ ...camera(35), ...timeOfDay(0.4) });
},
});
observeResize(canvas, loop);
loop.invalidate(); // the first picture, with the first chunks
await load.idle(); // the ground around the camera is in
stopListening();
status.hidden = true;
console.log(formatTimeline(load.timeline()));On a second visit the last models event reads 3/3 (3 cached), the upload and placement
events count their work as cached too (the encodings and the bake come from the scene cache),
and the logged table shows where the time went: which phase was longest, and whether pipelines
stayed at one span (no frame had to compile a variant of its own).
See also
- Loading screens for the load events, a display, and the first-frame marks
- Offline and installing for the service worker
- Workers and caching for the pool and the worker file
- Instances for static and moving sets
- Engine: load events and loading controls
- API:
Renderer.prepare,PrepareNeeds,makeSceneWorker,serveScene,encodeModel,Renderer.addEncodedModel,Renderer.reserveBricks,InstanceLayer.commitAsync,GenerateOptions,GeneratorPool,openModelCacheControls,ServeCacheOptions,ServeSceneCacheOptions,openSceneCacheControls,CACHE_CAP_FLOOR,normalizePlacement,bindPlacement,EntityPlacement.scale,ModelOptions,ChunkedWorld,makeChunkFillPool,serveChunks,columnBoxes,fineTerrainInit
Loading screens
Show what a Voxolith page is loading with makeLoadTracker, the engine's per-phase load events, time the load with formatTimeline, and mark the first frame.
Performance
Keep a Voxolith game fast - render on demand, choose quality presets, cap resolution, adapt the render scale, spot software adapters and budget load time.