voxolith

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:

fieldmeansdefault
instancesinstances will be drawnany instance is placed now
partsinstances posed on the GPU (Instance.parts) will be drawna placed instance is posed now
temporaltemporal accumulation will be onthe quality's temporal now; ignored by the fragment pipeline
targetframes will go to an offscreen RenderTarget as well as the canvasfalse
scaledinstances 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:

pipelineprepare 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, setStatic uploads it with renderer.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 in setInstances: 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:

scene.worker.ts
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 it

One 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

stepwherenotes
setStatic(list)main threadsends 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
encodeworkerencodeModel, in the order asked
waitmain thread, idlecommitAsync() waits until every model of the set has landed
reservemain threadreserveBricks once for the batch, below
addmain thread, pacedbeginEncodedModel 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
bakeworkerthe static set's per-cell lists and sub-cell tables
applymain threadapplyPlacement 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). done follows 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:

pagebeforeafter
valley, 100 voxels per metre4.15 s74 ms
valley, 50 voxels per metre0.85 s76 ms
nightwood, 100 voxels per metre3.43 s168 ms (during the bake)
nightwood, 50 voxels per metre0.76 s78 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 later commit() 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.baking is true;
  • the placement phase is one task from the bake request to its apply, with the main thread free. Its total is the number of static placements, and its done follows 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, so done can 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 frame

A 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 drew

Aborting

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 namenow
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 to addEncodedModel at the start.
  • progress is the fraction copied, by bytes, from 0 to 1; it never goes back.
  • cancel() gives back everything it claimed.
  • id may be named in instances only once step has returned true; before that setInstances throws 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:

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 0

Abort

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:

controldoes
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).

gen.worker.ts
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:

  1. Build for production (the caches are off in development) and leave maxBytes out.
  2. Load each of the game's heaviest scenes once, on an empty cache.
  3. 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:

maxByteswhat 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 entrya 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 with usage() and trim() 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.

scenery.worker.ts
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" });
creatures.worker.ts
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 own

Controls 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:

scene.worker.ts
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

entrykeywhat the digest coversstored
an encodingencoded|v<ENCODED_MODEL_VERSION>|<digest>the model's identity (below), its part boxes and its jointsthe whole EncodedModel, sub-cells included
a bakeplacement|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.modelKey names 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 undefined for 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.

pagestored
valley, 20 voxels per metre17 MB
valley, 50 voxels per metre177 MB
valley, 100 voxels per metre824 MB
nightwood, 20 voxels per metre13 MB
nightwood, 50 voxels per metre129 MB
nightwood, 100 voxels per metre655 MB
instances1 MB
every page1816 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:

pagebeforeafter
nightwood, 100 voxels per metre30.8 s13.9 s
valley, 100 voxels per metre13.6 s6.4 s
nightwood, 50 voxels per metre6.6 s3.7 s
valley, 50 voxels per metre4.9 s3.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 background

The 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.scale draws a model enlarged by an integer factor: each of its voxels covers scale³ 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 at scale × 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 setStatic and call commitAsync() 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 a commit() 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 SCALED constant), which a scene without them never compiles. With deferPipelines, prepare both up front: prepare({ instances: true, scaled: true }) for the coarse set, and prepare({ 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=1 forces it on at any scale and on a phone, and ?coarse=0 off.
  • 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 mark fine when the swap has applied; that is example code, in shared/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:

pagemodel cache+ scene cachenow
nightwood, 100 voxels per metre30.8 s13.9 s1.0 s
valley, 100 voxels per metre13.6 s6.4 s2.5 s
nightwood, 50 voxels per metre6.6 s3.7 s0.5 s
valley, 50 voxels per metre4.9 s3.1 s1.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:

callgives
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 what focus queued. So keep stepping (the frame loop does), and focus on at least radius around the point, or whenReady never resolves.
  • Chunk centres are what is tested, as focus tests 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 them init once (structured-cloned: plain data and typed arrays). What init holds is the app's; for gen-terrain it is the terrain as data (below). size is the number of workers (default one per core less one, up to 4), and depth how many requests each keeps queued (default 2). The world keeps at most the pool's capacity (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 turns init into a ChunkFill, a function that fills one brick the way an editMany fill does. make runs once and may return a promise.
  • boxes says what to fill of a chunk (default its whole box). columnBoxes(box, span) gives one box per 8 × 8 brick column, as tall as span says, which is what makes a fine terrain cheap; columns with nothing in them are left out.
  • generate becomes optional. Given with fill, 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).
main.ts
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, the ground phase) once its bricks are applied, and step returns 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 generate writes into a new or cleared chunk. So chunk must 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 (default console.error), is not retried until it has been freed, and makes a whenReady over 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.task you 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 scale and 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.

main.ts
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

On this page