Workers and caching
Run generators on a pool of Web Workers with makeGeneratorPool and serveGenerators, and keep generated models in IndexedDB between visits.
Generating models is the most expensive part of building a scene, and it is pure CPU work with no
DOM contact, so it belongs off the thread that has to draw. @voxolith/engine/worker runs
generators on a pool of Web Workers, spreads the work across cores, hands models back without
copying, and can keep them in IndexedDB so the next visit loads instead of generating.
The worker file
Functions cannot be posted across the worker boundary, so the pool sends a generator id and the worker resolves it from its own registry. Your worker file imports the generator packages it should offer, 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 : "my-game-models" });The pool
You supply spawn, because only your bundler can resolve a worker entry:
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(); // every worker has registered its generators
const specs = Array.from({ length: 8 }, (_, i) => ({
generator: "voxolith/tree.broadleaf",
params: PRESETS.oak,
seed: seed + i,
ctx: { voxelsPerMetre: 100 },
}));
const oaks = await pool.generateMany(specs, {
onDone: (done, total) => {
status.textContent = `generating ${done}/${total}${pool.cached ? ` (${pool.cached} from the cache)` : ""}`;
},
});
void pool.destroy(); // lets idle workers finish their cache writes first- The pool runs one worker per core less one, capped at 4 (
sizeoverrides). Generation is memory-heavy and the main thread still needs a core to draw. generateManyresolves in the order given, not the order they finish, and reports progress as each lands.generate(spec)does one.- Without options the queue is first in, first out. Requests can take a
priority, anAbortSignalandcache: false, and the pool canpause(),resume()andreprioritise()its queue: see generation queue controls. destroy()returns a promise: pending requests reject at once, and idle workers finish their cache writes before they are terminated, so the models just made are kept for the next visit.- Sparse models come back as their bricks, transferred rather than copied.
ctxpasses through to the generator, so fine models are generated on workers too.
The model cache
Generation is deterministic, so a model is a pure function of its generator id and version,
parameters, seed and context. serveGenerators({ cache: "name" }) keeps each result in IndexedDB
under that key, deflated, and serves it from there next time. Refined models at 100 voxels per
metre take seconds each on a phone and a valley needs dozens, so this is the difference between a
slow first visit and slow every visit.
The key is salted with the worker's own script URL. A production build hashes that URL, so new
generator code never reads a model the old code made, and entries from any other salt are deleted
when the cache opens. In development the URL does not change with the code, which is why the
example above turns the cache off under import.meta.env.DEV.
Give each worker script its own database name: opening the cache deletes the entries of every other salt, so two workers sharing a name would delete each other's models.
Where there is no IndexedDB or CompressionStream (bun, some private windows), the cache is simply
off and everything is generated. openModelCache, packEntity and unpackEntity are exported if
you want the same storage for models of your own.
Nothing is evicted unless you ask. To see what the cache holds, clear it, trim it or cap its size,
see the model cache under Managing loading. A cap
is the game's to size: load the heaviest scenes once with none, read usage(), and add headroom
(sizing a cap); a value that makes no sense throws,
and a doubtful one warns in the console.
The scene worker, which encodes models for the GPU and bakes instance placements, has a cache of
its own for those results, keyed by pool.modelKey: see
caching encodings and bakes.
Falling back
Keep a main-thread path for browsers or embeds where workers fail. The world example catches a failed pool and generates in place, reporting progress between models:
try {
await pool.ready();
return await pool.generateMany(specs, report);
} catch (err) {
console.warn("worker generation failed, falling back to the main thread:", err);
} finally {
void pool.destroy();
}Pitfalls
- A generator must be registered inside the worker. Registering on the main thread does nothing for the pool.
- Specs cross the boundary by structured clone, so
paramsmust be plain data (presets are). - Keep the variant count low before reaching for more workers: the valley example generates three models per species at 20, 50 or 100 voxels per metre, not ten.
See also
- Managing loading for queue priorities, aborts, the scene worker and the cache controls
- Engine: workers
- Using generators for variant pools and scales
- API:
makeGeneratorPool,serveGenerators,GenerateSpec,openModelCache,openModelCacheControls,GenerateOptions
File formats
Load MagicaVoxel .vox models and scenes, animate .vox frames, export entities back to .vox, and import Minecraft .mca regions.
Loading screens
Show what a Voxolith page is loading with makeLoadTracker, the engine's per-phase load events, time the load with formatTimeline, and mark the first frame.