voxolith

Workers

Running Voxolith generators off the main thread with makeGeneratorPool and serveGenerators: packed, transferred results, queue priorities and aborts, the IndexedDB model cache and its controls, the scene worker that encodes models and bakes static placements, its cache of encodings and bakes, cache caps a game sizes itself, and chunk fill workers for a streamed ground.

Generation is the most expensive part of building a scene, and it is pure CPU work with no DOM contact, so it belongs off the render thread. Doing it inline competes with the frame loop: yielding between entities keeps the page responsive, but the work still lands on the one thread that has to draw. @voxolith/engine/worker runs generators on a pool of workers, which also spreads the work across cores.

It comes in two halves, because they run in different realms:

The worker file

The consumer writes the worker entry, because only their bundler can resolve one. It imports the generator packages it wants, registers them, and serves:

gen.worker.ts
import { registerTreeGenerators } from "@voxolith/gen-tree";
import { registerRockGenerators } from "@voxolith/gen-rock";
import { serveGenerators } from "@voxolith/engine/worker";

registerTreeGenerators();
registerRockGenerators();
serveGenerators({ cache: import.meta.env.DEV ? undefined : "voxolith-models" });

The registry has to be populated in the worker itself: a function cannot be posted across the boundary, so the pool sends a generator id and the worker resolves it locally. Generators are pure and take all their randomness from the injected rng, so they are worker-safe as written.

The pool

import { makeGeneratorPool } from "@voxolith/engine/worker";
import { PRESETS } from "@voxolith/gen-tree";

const pool = makeGeneratorPool({
  spawn: () => new Worker(new URL("./gen.worker.ts", import.meta.url), { type: "module" }),
});
await pool.ready();

const oak = await pool.generate({ generator: "voxolith/tree.broadleaf", params: PRESETS.oak, seed: 42 });

const forest = await pool.generateMany(
  Array.from({ length: 12 }, (_, n) => ({ generator: "voxolith/tree.broadleaf", params: PRESETS.oak, seed: 100 + n, entityId: `oak-${n}` })),
  { onDone: (done, total) => (progress.textContent = `${done} / ${total}`) },
);
void pool.destroy();
  • size defaults to one worker per core less one, capped at 4: generation is memory-heavy and the main thread still needs a core to draw on.
  • A GenerateSpec is generator, params, seed, an optional entityId, and an optional ctx passed to the generator ({ voxelsPerMetre: 100 } for a fine model).
  • generateMany resolves in the order given, not the order the models finish; onDone reports progress as each lands. Its options apply to every request (the older generateMany(specs, onDone) form still works).
  • A worker that dies fails the request it was running rather than hanging, and the remaining workers carry on.
  • destroy() rejects everything outstanding and resolves once every worker is terminated: workers still generating at once, idle ones after finishing their model cache writes (up to closeTimeoutMs, 10 s), so the models just made are kept. Callers need not wait for it.
  • pending counts requests not yet settled, queued those waiting for a worker; cached counts results served from the model cache.

Queue controls

Without options the queue is first in, first out. Each request can take GenerateOptions:

  • priority (default 0): a free worker takes the highest-priority queued request, in queue order within one priority. Running requests are never pre-empted.
  • signal: an AbortSignal. A queued request leaves the queue; a running one rejects at once and its result is dropped, but the worker finishes it, since a generator cannot be interrupted. respawnOnAbort terminates and replaces that worker instead (a few hundred ms to reload, and its pending cache writes are lost).
  • cache: false: neither read nor write the model cache for this request.

On the pool, pause() stops dispatching (running requests finish; models stays busy while work is queued), resume() starts again, and reprioritise(fn) re-sorts the queue by new priorities, for streaming models around a moving player. Which models come first is the game's call; see generation queue controls in the manual.

import { makeGeneratorPool } from "@voxolith/engine/worker";

const pool = makeGeneratorPool({ spawn, respawnOnAbort: true }); // aborted running requests restart their worker
const region = new AbortController();

const hero = pool.generate(heroSpec, { priority: 10 });            // taken before any priority-0 request
for (const spec of specsAround(player)) {
  pool.generate(spec, { priority: -distanceTo(spec), signal: region.signal }).then(place, () => {});
}

cutscene.addEventListener("start", () => pool.pause());            // running requests still finish
cutscene.addEventListener("end", () => pool.resume());
onPlayerMoved(() => pool.reprioritise((spec) => -distanceTo(spec))); // re-sort what is still queued
onRegionLeft(() => region.abort());                                 // drop the rest of that region

Results come back packed: the worker posts each entity as a 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 transfers that one buffer; a cache hit posts the stored record as it is. The pool unpacks it into views of that buffer, a Map entry per brick with no copies. Transferring every 512-byte brick as a buffer of its own, as it used to, was the slow part of a warm visit: on nightwood at 100 voxels per metre (about 800k bricks) the models phase went from 7.4 s to 0.28 s with the packed form (measured).

Each result also names what made it: pool.modelKey(model) returns the worker's URL, the generator id and version, seed, parameters and context for a model the pool returned (undefined for any other). Generation is deterministic, so that string names the model's content across visits, which is what a cache of anything derived from the model needs; the scene cache keys encodings by it. It names the model as generated: don't pass it on for a model you edit in place.

Keep a main-thread fallback for environments without module workers; the examples' world page catches a failed pool and generates inline.

The model cache

Generation is deterministic, so a model is a pure function of its generator id and version, parameters, seed and context: generate it once, keep it, and the next visit loads it instead. Refined models at 100 voxels per metre take seconds each on a phone, and a valley needs dozens.

serveGenerators({ cache: "name" }) keeps generated models in the IndexedDB database name:

  • Entries are packed (packEntity: a small header plus one byte buffer holding a sparse model's bricks, or its dense data, and its bones) and deflated with CompressionStream.
  • The key is the generator id and version, seed, parameters and context, prefixed by a salt: the worker's own script URL. A production build hashes that URL, so new generator code never reads a model the old code made; entries under any other salt are deleted when the cache opens.
  • Storage that is full or unavailable is not an error: the model still works, it is just not kept. Where there is no IndexedDB or CompressionStream (bun, some private windows) the cache is silently off.

Off in development

Leave the cache off in development. A dev server's worker URL does not change with the code, so edited generator code would be served models made by the old code. Hence import.meta.env.DEV ? undefined : "..." above.

  • Give each worker script its own database name: two scripts (or two apps on one origin) sharing one would delete each other's entries every time either opens it.
  • The database is at version 2, which added a small per-entry record; upgrading drops the version-1 records, so the first visit after the upgrade is cold once.

The object form, serveGenerators({ cache: { name, maxBytes } }) (ServeCacheOptions), caps the cache: after each write the least recently used entries are evicted until it fits. store replaces IndexedDB with a CacheStore of your own (memoryCacheStore in tests).

The cap is the game's to size, and the engine picks none: load the heaviest scenes once with no cap, read usage() from the controls below, and add headroom. The value is checked when the cache is made, for this cache and the scene cache alike:

  • one that is not a positive, finite number makes serveGenerators (or serveScene) throw a RangeError;
  • one under CACHE_CAP_FLOOR (16 MiB), one above the origin's storage quota, and the first entry bigger than the cap (which is then not kept) each warn once per cache, with console.warn. Every worker opens its own cache, so a pool of four may warn four times.

The manual's sizing a cap has the steps and what the examples measured.

openModelCacheControls(name) opens ModelCacheControls on the main thread: entries, usage, clear and trim, each by an optional ModelCacheFilter (generator id, salt). They read only the per-entry records, never the models, and nothing runs by itself; see the model cache in the manual, phones included.

import { openModelCacheControls } from "@voxolith/engine/worker";

const cache = await openModelCacheControls("voxolith-models"); // the name serveGenerators was given
if (cache) {
  const all = await cache.usage();
  const oaks = await cache.usage({ generator: "voxolith/tree.broadleaf" });
  console.log(`${all.entries} models, ${(all.bytes / 1e6).toFixed(1)} MB; oaks ${(oaks.bytes / 1e6).toFixed(1)} MB`);

  await cache.clear({ generator: "voxolith/tree.broadleaf" }); // after retuning the oak presets
  await cache.trim(100e6);                                    // least recently used out, down to 100 MB
  cache.close();
}

The cache is also usable on its own: openModelCache(name, salt) returns a ModelCache with get and put, or null where there is none; unpackEntity reverses packEntity.

The scene worker

A second worker does the CPU work of an instanced scene's static set: it encodes the models for the GPU (their 8³ bricks and the sub-cells the placement reads) and bakes the set's per-cell lists and sub-cell tables. Both take seconds on the main thread for a fine scene. The worker file calls serveScene(); the main thread starts it with makeSceneWorker({ spawn }) and gives it to makeInstanceLayer(renderer, { worker }). Its setStatic then only sends new models to be encoded, and commitAsync() adds them and bakes there (see Instances).

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
await layer.commitAsync(); // encoded and baked on the worker; the page keeps drawing
loop.invalidate();

It is not 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, and the bake needs every model registered on the worker that runs it. Encoding and baking share one worker for the same reason: the encode is what lists a model's sub-cells, so the worker keeps them and registers the model for placement once the main thread names its keys, and the sub-cells (millions of words at 100 voxels per metre) never travel back.

  • One queue. Jobs run one at a time, in the order asked, which is the order a scene needs them in anyway (a bake can start only once its models are registered). A cancelled job is skipped if it has not started; one that has started runs to the end, since it is one synchronous call.
  • Sparse models travel packed. A map of hundreds of thousands of small brick arrays is slow to structured-clone, so encode packs a sparse model's bricks into chunks and transfers them, one main-thread task per chunk: chunkBricks bricks each (16384, 8 MiB, by default). A dense model's arrays go in one message.
  • Progress. While a job runs the worker posts progress messages. The layer turns an encode's into its upload count, in bricks, and a bake's into its placement count, each held one short until the main thread has added the model or applied the bake. A superseded, cancelled or failed job is discarded from its phase, so done can drop.

SceneWorker is there for hosts that drive the renderer's halves themselves. encode(src, { signal, onProgress, keepPlacement }) resolves with an EncodedModel for renderer.addEncodedModel. With keepPlacement the worker keeps the sub-cells, and registerEncoded(encoded, keys) then registers the model for placement by its keys alone (or forget(encoded) lets the worker drop them). Before adding a batch, renderer.reserveBricks(batch) grows the pools once for all of it. bake(input, { signal, onProgress }) is as before:

import { makeSceneWorker } from "@voxolith/engine/worker";

const worker = makeSceneWorker({ spawn });
const leave = new AbortController();

// Encode there, add here, register for placement without sending the sub-cells back.
const encoded = await worker.encode({ size: tree.model.size, data: tree.model.data }, { keepPlacement: true, signal: leave.signal });
const oak = renderer.addEncodedModel(encoded);
const { key, poseKey } = renderer.placementModel(oak)!;
worker.registerEncoded(encoded, { key, poseKey });

const bake = await worker.bake(renderer.placementInput(sceneryInstances), {
  signal: leave.signal,
  onProgress: (done, total) => (bar.value = done / total), // opaque units: use the fraction
});
renderer.applyPlacement(bake);
loop.invalidate();

destroy() returns a promise: pending jobs reject at once, a worker still running a job is terminated at once, and an idle one first finishes its cache writes (up to closeTimeoutMs, 10 s). Callers need not wait for it.

The scene cache

serveScene({ cache }) keeps the worker's results in IndexedDB, and a warm visit reads them instead of encoding and baking: on the examples' nightwood page at 100 voxels per metre, the warm first frame went from 30.8 s to 13.9 s (measured; loading coarse first and filling the ground on workers took it to 1.0 s since). Leave it off in development, where the worker URL does not change with the code, and give it a database name of its own, not the model cache's.

scene.worker.ts
import { serveScene } from "@voxolith/engine/worker";

// The examples' cap: 2.5 GiB, for the 1816 MB that every page at every scale stores (measured).
serveScene({ cache: import.meta.env.DEV ? undefined : { name: "voxolith-scene", maxBytes: 2.5 * 1024 ** 3 } });
  • Keys. An encoding is stored under encoded|v<ENCODED_MODEL_VERSION>|<digest> of the model's identity, part boxes and joints; a bake under placement|v<PLACEMENT_BAKE_VERSION>|r<record>|<digest> of the grid, the instances (each naming its model by its placement data, not its id) and the placement data of every model it names. Both are salted with the worker's script URL, and a worker opening the cache deletes every other salt's entries.
  • Bakes are stored normalised. Renderer ids are slots and differ between visits, so a bake is stored with its models numbered by first appearance (normalizePlacement) and bound to this visit's ids on a hit (bindPlacement). A bake needs nothing from the host.
  • An encoding needs an identity. encode(src, { cacheKey }) names the source's voxels (for example pool.modelKey(model)): the worker is asked for it before any of the source is sent, so a hit never sends the bricks. encode(src, { hashKey: true }) lets the worker hash the source's bytes instead (about 30 ms per 100 MB); the source is sent in full, so that saves only the encode. With neither, the encoding is not cached. A layer passes these for you from modelKey and hashModels.
  • Per job. bake(input, { cache: false }) neither reads nor writes the cache. fromCache(result) says whether an encoding or a bake came from it, which is how a layer ticks its upload and placement phases as cached.
  • Stored as they are. SceneCacheOptions compress: true deflates entries, about 6 to 7 times smaller, at about 0.8 s more per warm visit and seconds of worker time per write; maxBytes evicts the least recently used entries after each write, and is checked and warned about as the model cache's is. The examples store 13 MB to 824 MB per page, 1816 MB in all, uncompressed, under a 2.5 GiB cap (measured).
  • Controls. openSceneCacheControls(name) gives entries, usage, clear and trim on the main thread, filtered by kind ("encoded" or "placement") and salt; makeSceneCacheControls does the same on a CacheStore. openSceneCache and makeSceneCache are the cache itself.
import { openSceneCacheControls } from "@voxolith/engine/worker";

const scene = await openSceneCacheControls("voxolith-scene");
if (scene) {
  const { bytes } = await scene.usage({ kind: "placement" });
  console.log(`bakes: ${(bytes / 1e6).toFixed(1)} MB`);
  await scene.clear({ kind: "placement" }); // encodings stay
  scene.close();
}

The manual's caching encodings and bakes has the trade-offs, the measured sizes and when a key is honest.

The older names

The scene worker used to be a placement worker, which only baked. makePlacementWorker and servePlacement still work and are deprecated: servePlacement() is serveScene(), and makePlacementWorker is makeSceneWorker typed as the bake-only PlacementWorker (register, has, drop, bake, pending, destroy, which a SceneWorker has too). Given to a layer as placement, it bakes as before and the models upload on the main thread in setStatic. To move over, swap the two names and pass the worker as worker instead of placement.

The request and response shapes are WorkerRequest and WorkerResponse for the generator pool, and SceneRequest / SceneResponse for the scene worker (EncodeSource is a model as it travels, its sparse bricks in chunk messages ahead of it; PlacementRequest and PlacementResponse are their bake-only subsets), for hosts that want to drive a worker directly.

Chunk fill workers

A third pair fills a streamed ground off the main thread, for makeChunkedWorld({ fill }). At 100 voxels per metre a chunk of fine terrain costs 20 to 40 ms in its fillBrick alone, on the thread that draws; the fill is a pure function of the terrain, so it moves.

  • makeChunkFillPool({ spawn, init, size?, depth? }) on the main thread starts size workers (default one per core less one, up to 4, as for the generator pool; size the two together) and sends each init once, structured-cloned. It is a ChunkFillSource: fill(boxes, { signal }) goes to the least busy worker, up to depth requests each (default 2), and resolves with the FilledBricks it wrote, transferred. capacity is size × depth, ready() resolves once every worker has built its fill, and destroy() terminates them.
  • serveChunks({ make }) in the worker calls make(init) once for a ChunkFill, (cells, ox, oy, oz) => boolean, and answers each request's boxes brick by brick, each brick from empty. Requests run in order, one at a time.
  • Failures. A fill that throws rejects its request. A worker that dies fails the request it was running, hands the others to another worker and is replaced, with init sent again. A make that throws, or a worker that dies before it is ready, fails the whole pool.

What init holds is the app's; the engine only moves boxes and bricks. For gen-terrain it is the terrain as data (fineTerrainInit, or terrainInit with the paths as topOverrideData, see the terrain page):

ground.worker.ts
import { serveChunks } from "@voxolith/engine/worker";
import { fineTerrainFrom, type FineTerrainInit } from "@voxolith/gen-terrain";

serveChunks({
  make(init) {
    const { fine, base } = init as { fine: FineTerrainInit; base: number };
    const ground = fineTerrainFrom(fine); // from its heights: nothing is sampled again
    return (cells, ox, oy, oz) => ground.fillBrick(cells, ox, oy, oz, base);
  },
});

The messages are ChunkRequest and ChunkResponse, for a host that drives a worker of its own. The manual's ground on workers has the whole wiring and what it saved.

On this page