workers.md
docs/workers.md · raw · all examples · site home
Headings: WorkersWorkerPool and WorkerLeaseHandlerssortSplatKeysTransfers and progressCancellingFailuresBundlingYour own workers and the core moduleAdding a handler
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
# Workers
`WorkerPool` runs `core/` functions on up to `size` workers. A worker is made only when a call needs one, so a pool that is never used starts no workers. Most apps don't use the pool directly: `loadSplats`, `writeSplats`, and the CPU sort use the shared pool (`WorkerPool.default`) or the shared sort pool (`SplatRenderer.sortPool`).
Code: `src/worker/` (imports `core/` only): `WorkerPool.ts`, `coreWorker.ts` (the worker's handlers), `coreModule.ts` (what workers import), `splatFiles.ts`, and `splatSort.ts`.
```ts
const pool = new WorkerPool({ size: 2 });
// Runs on the next free worker; callers queue in order
const { splats } = await pool.call("loadSplats", { url }, { onProgress });
// splats is a TransferableArrayData; ArrayData.fromTransferable(splats) rebuilds the table
// One worker, held until released
const lease = await pool.acquire();
await lease.call("writeSplats", { data: splats, format: "ply" });
lease.release();
// Terminates the workers
pool.dispose();
```
## WorkerPool and WorkerLease
| Member | What it does |
|---|---|
| `new WorkerPool({ size? })` | a pool of up to `size` workers (default 4), none made yet |
| `WorkerPool.default` | the shared pool, made on first use (still without workers) |
| `WorkerPool.available` | whether this context can make workers: a page can; Node and workers cannot |
| `WorkerPool.coreUrl` | a blob URL of `core/` as one ES module, the one the pool's workers run, for an app's own workers to `import()` ([below](#your-own-workers-and-the-core-module)) |
| `call(name, args, { onProgress?, signal? })` | runs a handler on the next free worker (making one if the pool has room), and returns its result |
| `acquire({ signal? })` | a `WorkerLease`: one worker, held until `release()`; an abort while waiting for one rejects |
| `workerCount` | workers made so far, and alive |
| `dispose()` | terminates the workers (their pending calls reject) and rejects queued callers |
| `lease.call(name, args, options?)`, `lease.release()` | runs a handler on the leased worker; gives the worker back. A released lease throws on `call` |
Types: `WorkerCall` (the handler names), `WorkerArgs<N>` and `WorkerResult<N>` (a handler's argument and result), and `CallOptions` (`{ onProgress?, signal? }`).
## Handlers
| Handler | Arguments | Result |
|---|---|---|
| `loadSplats` | `{ url \| bytes, name?, layout?, encoding?, sh?, coordinateSystem?, palette?, request? }` (`request`: `fetch` options, plain data) | `{ splats, shPalette? }`: the table (`TransferableArrayData`), its SH bundle included, and the palette's table for RAD's clustered SH; `palette: false` expands it per splat instead |
| `writeSplats` | `{ data, shPalette?, format, options? }` | `{ bytes, error, data, shPalette }`: the data's (and palette's) arrays come back even when encoding fails |
| `sortKeys` | `{ keys, count, bits, ordering? }` | `{ ordering, visible, keys }`: the order, the visible count, and the keys, back for reuse |
Pages call the wrappers instead: `loadSplats` / `loadSplatFile` / `writeSplats` ([files.md](files.md)) and `sortSplatKeys` (below).
## sortSplatKeys
`sortSplatKeys(keys, { bits, ordering?, worker? })` sorts splat sort keys back to front (`SortKeys.sort`) on a worker or on the current thread, with the same `worker` choices as `loadSplats`. It returns `SortedSplats`: `ordering` (splat indices back to front; its first `visible` have valid keys), `visible`, and `keys`. The keys and the order are transferred to the worker and back, so pass the previous `ordering` to reuse its array. `SplatView.sort` calls it ([sorting.md](sorting.md#the-cpu-sort)).
## Transfers and progress
- Arrays in arguments and results are transferred, not copied (`ArrayData.transferables` finds every `ArrayBuffer` in a value). The sender's arrays are detached until they come back, and `ArrayData.setFromTransferable` puts them back in place ([arraydata.md](arraydata.md#sending-to-workers)).
- A handler reports progress as `(fraction, stage)`. The worker posts it at most every 50 ms (and whenever the stage changes or it finishes), and never reports a lower fraction than before.
## Cancelling
A call's `signal` (`CallOptions`) rejects the call with `signal.reason` as soon as it aborts. The pool then posts `{ id, cancel: true }`, and the worker aborts that call's own `AbortController`: `fetch` stops, and the handler's `progress` throws at its next report, so a decode stops there too. The worker stays, so a lease keeps working. A call aborted while it waits for a worker leaves the queue. A result that arrives after the abort is dropped.
## Failures
- A handler that throws rejects its call with the error's message; the worker stays.
- When a worker fails outside a handler (an uncaught error, or a message that cannot be read), its pending calls reject and the worker is replaced, so a waiting caller gets a new worker.
- `dispose()` rejects pending and queued calls.
## Bundling
The workers' code is one ES module in the bundle: `worker/coreModule.ts` (`core/` plus `serveWorker` from `coreWorker.ts`), imported as a string (`import coreModule from "./coreModule?module&inline"`, built by `scripts/inline-worker-plugin.mjs`, minified in the library build, since an app's minifier cannot shrink a string). `WorkerPool.coreUrl` is a blob URL of it, made on first use, and each pool worker starts from a one-line blob that imports `serveWorker` from that URL and calls it. Pages therefore need no separate worker file, and every worker loads the same code. A bundler other than this repo's esbuild setup needs the same `?module&inline` import support. Pages need `worker-src blob:` if they set a Content Security Policy.
## Your own workers and the core module
A page's own module worker gets no import map, so it cannot `import "@sparkjsdev/radix"`. It imports `core/` from `WorkerPool.coreUrl` instead: the module Radix's own workers run, already in the bundle, so no second file is loaded. The page hands the URL to the worker:
```ts
// Page
const worker = new Worker(new URL("./myWorker.js", import.meta.url), { type: "module" });
worker.postMessage({ coreUrl: WorkerPool.coreUrl });
// myWorker.ts
let core: Promise<typeof import("@sparkjsdev/radix/core")> | undefined;
self.onmessage = async (event) => {
core ??= import(event.data.coreUrl);
const { SplatFiles, Encoding } = await core;
// ...
};
```
The LCC example's decoders work this way (`examples/lcc/LccDecoders.ts`, `lccWorker.ts`). The module also exports `serveWorker`, which an app's worker does not call.
`npm run build` also writes `dist/radix-core.module.js`: `core/` alone (formats, tables, codecs, the CPU encoder), with no imports at all, for Node and for pages that load workers without the library. Such a page maps it in its import map (`"@sparkjsdev/radix/core": "/dist/radix-core.module.js"`) and hands `import.meta.resolve("@sparkjsdev/radix/core")` to its workers. The build fails if `core/` gains an import.
## Adding a handler
To add a handler, add a function to `handlers` in `coreWorker.ts`. It receives its arguments and `{ progress, signal }` (`progress` throws once the call is cancelled), and its return value is transferred back. `WorkerCall`, `WorkerArgs`, and `WorkerResult` are derived from the handlers' types, so they include the new one.