LccDecoders.ts
examples/lcc/LccDecoders.ts · raw · open the example · all examples · site home
Exports: LccDecoders
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 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
// A pool of LCC decode workers (lccWorker.ts): LCC v1 blocks and
// environments, LCC2 .spz / .ply chunks. Each worker gets the URL of
// Radix's core module (WorkerPool.coreUrl, the copy Radix's own
// workers run), since a worker has no import map. Each runs up to two
// requests at once, so one block downloads while another decodes; callers
// look at `free` and pick their most wanted blocks every frame instead of
// queueing requests that may go stale.
import { ArrayData, WorkerPool } from "@sparkjsdev/radix";
import type { CollisionChunk, LciHeader, LciMesh } from "./collision.js";
import type { LccBlock, LccRanges, LccSource } from "./LccScene.js";
import type { LccReply, LccRequest } from "./lccWorker.js";
const PER_WORKER = 2;
interface Job {
worker: Worker;
resolve(data: unknown): void;
reject(error: Error): void;
}
interface DecodeOptions {
sh: boolean;
ranges: LccRanges;
normal?: { min: number[]; max: number[] };
signal?: AbortSignal;
}
export class LccDecoders {
private readonly workers: Worker[];
private readonly busy = new Map<Worker, number>();
private readonly jobs = new Map<number, Job>();
private nextId = 1;
// Totals since the pool was made
readonly stats = { decoded: 0, bytes: 0, ms: 0, lastMs: 0 };
constructor({ workers = Math.max(2, Math.min(4, Math.floor((navigator.hardwareConcurrency || 4) / 2))) }: { workers?: number } = {}) {
const coreUrl = WorkerPool.coreUrl;
this.workers = Array.from({ length: workers }, () => {
const worker = new Worker(new URL("./lccWorker.js", import.meta.url), { type: "module" });
worker.onmessage = (event: MessageEvent<LccReply>) => this.receive(event.data);
worker.onerror = (event) => console.error("LCC decode worker:", event.message);
worker.postMessage({ type: "init", coreUrl } satisfies LccRequest);
this.busy.set(worker, 0);
return worker;
});
}
get size() {
return this.workers.length;
}
// Requests the pool can start now
get free() {
return this.workers.length * PER_WORKER - this.jobs.size;
}
get running() {
return this.jobs.size;
}
// The block decoded into ext splats (and extSh with `sh`); rejects with an
// AbortError when `signal` aborts first
decode(source: LccSource, block: LccBlock, { sh, ranges, normal, signal }: DecodeOptions): Promise<ArrayData> {
return this.post({ type: "decode", id: 0, source, block, sh, ranges, normal }, signal) as Promise<ArrayData>;
}
// environment.bin decoded likewise (with the scene's env ranges), or
// undefined when the scene has none
environment(source: LccSource, { sh, ranges, normal, signal }: DecodeOptions): Promise<ArrayData | undefined> {
return this.post({ type: "environment", id: 0, source, sh, ranges, normal }, signal) as Promise<ArrayData | undefined>;
}
// An LCC2 .spz / .ply chunk file (the first of `paths` the source has) as
// ext splats, with extSh and meta.lccSh when `sh` and it has SH
chunk(source: LccSource, paths: string[], { sh, signal }: { sh: boolean; signal?: AbortSignal }): Promise<ArrayData> {
return this.post({ type: "chunk", id: 0, source, paths, sh }, signal) as Promise<ArrayData>;
}
// collision.lci's header, or undefined when the scene has none
lciHeader(source: LccSource): Promise<LciHeader | undefined> {
return this.post({ type: "lciHeader", id: 0, source }) as Promise<LciHeader | undefined>;
}
// A collision chunk: a mesh of collision.lci, or an LCC2 node's mesh .ply
// and .btree (the first of each list of paths the source has)
collision(source: LccSource, what: { mesh: LciMesh } | { node: string; ply: string[]; btree: string[] }, signal?: AbortSignal): Promise<CollisionChunk> {
const request: LccRequest = "mesh" in what ? { type: "lciMesh", id: 0, source, mesh: what.mesh } : { type: "lcc2Collision", id: 0, source, ...what };
return this.post(request, signal) as Promise<CollisionChunk>;
}
private post(request: Exclude<LccRequest, { type: "init" | "cancel" }>, signal?: AbortSignal) {
const worker = this.workers.reduce((best, each) => (this.busy.get(each)! < this.busy.get(best)! ? each : best));
const id = this.nextId++;
this.busy.set(worker, this.busy.get(worker)! + 1);
return new Promise<unknown>((resolve, reject) => {
this.jobs.set(id, { worker, resolve, reject });
signal?.addEventListener("abort", () => worker.postMessage({ type: "cancel", id } satisfies LccRequest), { once: true });
worker.postMessage({ ...request, id } satisfies LccRequest);
});
}
private receive(reply: LccReply) {
const job = this.jobs.get(reply.id);
if (!job) {
return;
}
this.jobs.delete(reply.id);
this.busy.set(job.worker, this.busy.get(job.worker)! - 1);
if (reply.type === "decoded") {
this.stats.decoded += 1;
this.stats.bytes += reply.bytes;
this.stats.ms += reply.ms;
this.stats.lastMs = reply.ms;
job.resolve(ArrayData.fromTransferable(reply.data));
} else if (reply.type === "missing") {
job.resolve(undefined);
} else if (reply.type === "value") {
job.resolve(reply.value);
} else {
job.reject(reply.message === "AbortError" ? new DOMException("Cancelled", "AbortError") : new Error(reply.message));
}
}
dispose() {
for (const worker of this.workers) {
worker.terminate();
}
for (const job of this.jobs.values()) {
job.reject(new DOMException("Cancelled", "AbortError"));
}
this.jobs.clear();
}
}