voxolith

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 (size overrides). Generation is memory-heavy and the main thread still needs a core to draw.
  • generateMany resolves 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, an AbortSignal and cache: false, and the pool can pause(), resume() and reprioritise() 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.
  • ctx passes 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 params must 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

On this page