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:
makeGeneratorPoolon the main thread owns the workers and the queue.serveGeneratorsinside the worker answers requests from the worker's own registry.
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:
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();sizedefaults 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
GenerateSpecisgenerator,params,seed, an optionalentityId, and an optionalctxpassed to the generator ({ voxelsPerMetre: 100 }for a fine model). generateManyresolves in the order given, not the order the models finish;onDonereports progress as each lands. Its options apply to every request (the oldergenerateMany(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 tocloseTimeoutMs, 10 s), so the models just made are kept. Callers need not wait for it.pendingcounts requests not yet settled,queuedthose waiting for a worker;cachedcounts 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: anAbortSignal. 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.respawnOnAbortterminates 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 regionResults 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 withCompressionStream. - 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(orserveScene) throw aRangeError; - 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, withconsole.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
encodepacks a sparse model's bricks into chunks and transfers them, one main-thread task per chunk:chunkBricksbricks each (16384, 8 MiB, by default). A dense model's arrays go in one message. - Progress. While a job runs the worker posts
progressmessages. The layer turns an encode's into itsuploadcount, in bricks, and a bake's into itsplacementcount, 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, sodonecan 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.
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 underplacement|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 examplepool.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 frommodelKeyandhashModels. - 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 itsuploadandplacementphases ascached. - Stored as they are.
SceneCacheOptionscompress: truedeflates entries, about 6 to 7 times smaller, at about 0.8 s more per warm visit and seconds of worker time per write;maxBytesevicts 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)givesentries,usage,clearandtrimon the main thread, filtered bykind("encoded"or"placement") andsalt;makeSceneCacheControlsdoes the same on aCacheStore.openSceneCacheandmakeSceneCacheare 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 startssizeworkers (default one per core less one, up to 4, as for the generator pool; size the two together) and sends eachinitonce, structured-cloned. It is aChunkFillSource:fill(boxes, { signal })goes to the least busy worker, up todepthrequests each (default 2), and resolves with theFilledBricksit wrote, transferred.capacityissize × depth,ready()resolves once every worker has built its fill, anddestroy()terminates them.serveChunks({ make })in the worker callsmake(init)once for aChunkFill,(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
initsent again. Amakethat 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):
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.
Stamping and crowds
Moving entities through a streamed brick world with makeBrickStamper, and animating many rigged members on a budget with makeCrowd, pose caches and LOD.
Load events
makeLoadTracker: the engine's loaders report their phases (shaders, pipelines, models, upload, placement, ground) into one tracker, for an app's loading screen and a load timeline; and the opt-in controls an app steers its load with.