Rewrite core around shared rows and render graphs
Co-authored-by: Heaust Azure <heaust.azure@gmail.com> Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
This commit is contained in:
@@ -2,5 +2,6 @@
|
||||
"name": "@yawn/core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js"
|
||||
"exports": "./src/index.js",
|
||||
"files": ["src"]
|
||||
}
|
||||
|
||||
+78
-398
@@ -1,420 +1,100 @@
|
||||
const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
|
||||
const OP = { INSTALL_RENDER_DATA: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 };
|
||||
const TYPES = { f32: Float32Array, u32: Uint32Array, i32: Int32Array };
|
||||
|
||||
export class RendererError extends Error {
|
||||
constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; }
|
||||
}
|
||||
|
||||
export class YawnCore extends EventTarget {
|
||||
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
|
||||
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
|
||||
#readyResolve; #readyReject; #arrays = new Map();
|
||||
#transportReady = false;
|
||||
#telemetry; #stopped = false; #renderDataSnapshot;
|
||||
#graphQueue = []; #graphBusy = false;
|
||||
|
||||
constructor(bridge) {
|
||||
super();
|
||||
this.#bridge = bridge;
|
||||
this.#worker = bridge.worker;
|
||||
this.#ready = new Promise((resolve, reject) => {
|
||||
this.#readyResolve = resolve;
|
||||
this.#readyReject = reject;
|
||||
});
|
||||
if (bridge.memory && Number.isInteger(bridge.ringPtr)) {
|
||||
this.#installTransport(bridge.memory, bridge.ringPtr);
|
||||
}
|
||||
this.#worker.addEventListener("message", e => this.#message(e.data));
|
||||
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
|
||||
this.#worker.start?.();
|
||||
export class SharedRows {
|
||||
constructor(buffer, descriptor) {
|
||||
this.buffer = buffer;
|
||||
this.descriptor = Object.freeze(descriptor);
|
||||
}
|
||||
|
||||
get ready() { return this.#ready; }
|
||||
get telemetry() { return this.#telemetry; }
|
||||
get renderDataSnapshot() { return this.#renderDataSnapshot; }
|
||||
get name() { return this.descriptor.name; }
|
||||
get rows() { return this.descriptor.rows; }
|
||||
get stride() { return this.descriptor.stride; }
|
||||
get format() { return this.descriptor.format; }
|
||||
get view() {
|
||||
return new TYPES[this.format](this.buffer, this.descriptor.offset, this.rows * this.stride / 4);
|
||||
}
|
||||
|
||||
array(name) {
|
||||
const array = this.#arrays.get(name);
|
||||
if (!array) throw new RendererError("SOA_ARRAY_UNKNOWN", { message: `Unknown shared array '${name}'` });
|
||||
row(index) {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE");
|
||||
const width = this.stride / 4;
|
||||
return this.view.subarray(index * width, (index + 1) * width);
|
||||
}
|
||||
|
||||
read(index) { return Array.from(this.row(index)); }
|
||||
write(index, values) {
|
||||
const row = this.row(index);
|
||||
if (!values || values.length !== row.length) throw new RangeError("ROW_WIDTH");
|
||||
row.set(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
share() { return { buffer: this.buffer, descriptor: this.descriptor }; }
|
||||
}
|
||||
|
||||
export class YawnCore {
|
||||
#worker;
|
||||
#buffer;
|
||||
#arrays = new Map();
|
||||
#pending = new Map();
|
||||
#next = 1;
|
||||
|
||||
constructor(canvas, { arenaBytes = 64 * 1024 * 1024, workerFactory } = {}) {
|
||||
if (!canvas) throw new TypeError("canvas is required");
|
||||
this.#worker = workerFactory?.() ?? new Worker(new URL("./worker.js", import.meta.url), {
|
||||
type: "module",
|
||||
name: "yawn-core",
|
||||
});
|
||||
this.#worker.addEventListener("message", ({ data }) => this.#message(data));
|
||||
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
|
||||
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR"));
|
||||
this.#worker.start?.();
|
||||
const offscreen = canvas.transferControlToOffscreen?.() ?? canvas;
|
||||
this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen])
|
||||
.then(({ buffer }) => { this.#buffer = buffer; });
|
||||
}
|
||||
|
||||
async allocateRows({ name, rows, stride, format }) {
|
||||
await this.ready;
|
||||
const descriptor = await this.#request("allocate", { name, rows, stride, format });
|
||||
const array = new SharedRows(this.#buffer, descriptor);
|
||||
this.#arrays.set(name, array);
|
||||
return array;
|
||||
}
|
||||
|
||||
async allocateArray(layout) {
|
||||
await this.#ready;
|
||||
let source;
|
||||
try { source = JSON.stringify(layout); }
|
||||
catch (error) { throw new RendererError("SOA_LAYOUT_INVALID", { message: error?.message }); }
|
||||
const descriptor = await this.#withPayload(new TextEncoder().encode(source).buffer, OP.ALLOCATE_SOA);
|
||||
return this.#installArray(descriptor);
|
||||
array(name) {
|
||||
const array = this.#arrays.get(name);
|
||||
if (!array) throw new Error(`UNKNOWN_ARRAY: ${name}`);
|
||||
return array;
|
||||
}
|
||||
|
||||
#refreshViews() {
|
||||
if (!this.#bridge.memory || !Number.isInteger(this.#bridge.ringPtr)) return;
|
||||
const buffer = this.#bridge.memory.buffer;
|
||||
if (buffer === this.#buffer) return;
|
||||
this.#buffer = buffer;
|
||||
this.#header = new Int32Array(buffer, this.#bridge.ringPtr, HEADER_WORDS);
|
||||
this.#slots = new Int32Array(buffer, this.#bridge.ringPtr + 64, CAPACITY * SLOT_WORDS);
|
||||
async loadGraph(serialized) {
|
||||
await this.ready;
|
||||
return this.#request("load-graph", { serialized });
|
||||
}
|
||||
|
||||
#installTransport(memory, ringPtr) {
|
||||
this.#bridge.memory = memory;
|
||||
this.#bridge.ringPtr = ringPtr;
|
||||
this.#refreshViews();
|
||||
if (Atomics.load(this.#header, 0) !== 0x4e574159 || Atomics.load(this.#header, 1) !== 2 || Atomics.load(this.#header, 2) !== CAPACITY || Atomics.load(this.#header, 3) !== SLOT_WORDS) {
|
||||
const actual = Array.from(this.#header.subarray(0, 4), value => value >>> 0);
|
||||
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
||||
try { this.#bridge?.free?.(); } catch { /* best effort */ }
|
||||
throw new RendererError("PROTOCOL_MISMATCH", { message: `Invalid command ring at ${ringPtr}: ${actual.join(",")}` });
|
||||
}
|
||||
this.#transportReady = true;
|
||||
#request(type, payload, transfer = []) {
|
||||
const request = this.#next++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#pending.set(request, { resolve, reject });
|
||||
this.#worker.postMessage({ type, request, ...payload }, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
#message(message) {
|
||||
if (message?.type === "bootstrap") {
|
||||
if (this.#transportReady) { this.#fail("PROTOCOL_MISMATCH"); return; }
|
||||
try { this.#installTransport(message.memory, message.ringPtr); }
|
||||
catch (error) { this.#readyReject?.(error); this.#fail(error.code || "PROTOCOL_MISMATCH"); }
|
||||
} else if (message?.type === "reply") {
|
||||
const pending = this.#pending.get(message.request);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(message.request);
|
||||
message.ok ? pending.resolve(message.result) : pending.reject(new RendererError(message.code, message.details));
|
||||
} else if (message?.type === "payload-ready") {
|
||||
const pending = this.#payloadPending.get(message.id);
|
||||
if (pending) { this.#payloadPending.delete(message.id); pending.resolve(); }
|
||||
} else if (message?.type === "telemetry") {
|
||||
this.#telemetry = message;
|
||||
this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
|
||||
} else if (message?.type === "fatal") {
|
||||
console.error("renderer worker fatal", JSON.stringify(message));
|
||||
this.#fail(message.code || "WORKER_FATAL");
|
||||
} else if (message?.type === "soa-init" || message?.type === "soa-layout") {
|
||||
try {
|
||||
for (const descriptor of message.arrays ?? []) this.#installArray(descriptor);
|
||||
if (message.type === "soa-init") this.#readyResolve?.(this);
|
||||
this.dispatchEvent(new CustomEvent("yawn-soa-layout", { detail: this.#arrays }));
|
||||
} catch (error) {
|
||||
this.#readyReject?.(error);
|
||||
this.#fail("SOA_PROTOCOL_MISMATCH");
|
||||
}
|
||||
} else if (message?.type === "snapshot-init") {
|
||||
try {
|
||||
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
|
||||
this.#renderDataSnapshot = Object.freeze({
|
||||
memory: this.#bridge.memory,
|
||||
controlPtr: message.controlPtr,
|
||||
controlVersion: message.controlVersion,
|
||||
schemaVersion: message.schemaVersion,
|
||||
});
|
||||
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot", { detail: this.#renderDataSnapshot }));
|
||||
} catch { this.#fail("SNAPSHOT_PROTOCOL_MISMATCH"); }
|
||||
} else if (message?.type === "snapshot-published") {
|
||||
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot-published", {
|
||||
detail: Object.freeze({ epoch: message.epoch >>> 0 }),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#installArray(descriptor) {
|
||||
const existing = this.#arrays.get(descriptor?.name);
|
||||
if (existing) existing.update(descriptor);
|
||||
else this.#arrays.set(descriptor?.name, new SharedSoaArray(this.#bridge.memory, descriptor));
|
||||
return this.#arrays.get(descriptor.name);
|
||||
}
|
||||
|
||||
#stop() {
|
||||
if (this.#stopped) return;
|
||||
this.#stopped = true;
|
||||
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
||||
try { this.#bridge?.free?.(); } catch { /* best effort */ }
|
||||
this.#bridge = null;
|
||||
const pending = this.#pending.get(message?.request);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(message.request);
|
||||
if (message.error) pending.reject(Object.assign(new Error(message.error), { code: message.error }));
|
||||
else pending.resolve(message.result);
|
||||
}
|
||||
|
||||
#fail(code) {
|
||||
if (this.#disposed) { this.#stop(); return; }
|
||||
this.#disposed = true;
|
||||
const error = new RendererError(code);
|
||||
this.#readyReject?.(error);
|
||||
for (const pending of this.#pending.values()) pending.reject(error);
|
||||
for (const { reject } of this.#pending.values()) reject(Object.assign(new Error(code), { code }));
|
||||
this.#pending.clear();
|
||||
for (const pending of this.#payloadPending.values()) pending.reject(error);
|
||||
this.#payloadPending.clear();
|
||||
for (const pending of this.#graphQueue) pending.reject(error);
|
||||
this.#graphQueue.length = 0;
|
||||
this.#stop();
|
||||
}
|
||||
|
||||
#corrupt(code) {
|
||||
Atomics.store(this.#header, 6, 1);
|
||||
this.#fail(code);
|
||||
return Promise.reject(new RendererError(code));
|
||||
}
|
||||
|
||||
#enqueue(opcode, words = []) {
|
||||
if (this.#disposed) return Promise.reject(new RendererError("DISPOSED"));
|
||||
this.#refreshViews();
|
||||
if (Atomics.load(this.#header, 6) !== 0) return this.#corrupt("RING_CLOSED");
|
||||
const read = Atomics.load(this.#header, 4) >>> 0;
|
||||
const write = Atomics.load(this.#header, 5) >>> 0;
|
||||
const backlog = (write - read) >>> 0;
|
||||
if (backlog > CAPACITY) return this.#corrupt("RING_CORRUPT");
|
||||
if (backlog === CAPACITY) return Promise.reject(new RendererError("RING_FULL"));
|
||||
let request = this.#next++ >>> 0;
|
||||
if (request === 0) { request = 1; this.#next = 2; }
|
||||
const base = (write % CAPACITY) * SLOT_WORDS;
|
||||
const promise = new Promise((resolve, reject) => this.#pending.set(request, { resolve, reject }));
|
||||
try {
|
||||
for (let i = 0; i < SLOT_WORDS; i++) Atomics.store(this.#slots, base + i, 0);
|
||||
for (let i = 0; i < words.length; i++) Atomics.store(this.#slots, base + 3 + i, words[i]);
|
||||
Atomics.store(this.#slots, base + 2, request);
|
||||
Atomics.store(this.#slots, base + 1, opcode);
|
||||
// The slot tag is its publication marker; write_index publishes the complete slot.
|
||||
Atomics.store(this.#slots, base, SLOT_VERSION);
|
||||
Atomics.store(this.#header, 5, (write + 1) | 0);
|
||||
Atomics.notify(this.#header, 5);
|
||||
} catch (error) {
|
||||
this.#pending.delete(request);
|
||||
this.#fail("PUBLICATION_FAILED");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
commitRenderDataUpload(array, byteLength) {
|
||||
if (this.#disposed) throw new RendererError("DISPOSED");
|
||||
if (!(array instanceof SharedSoaArray) || array.domain !== "fixed" || array.scalar !== "u32" || array.stride !== array.lanes * 4)
|
||||
throw new TypeError("array must be a packed fixed uint32 shared array");
|
||||
if (!Number.isInteger(byteLength) || byteLength < 1 || byteLength > array.length * array.lanes * 4)
|
||||
throw new RangeError("byteLength is outside the shared array");
|
||||
return this.#enqueue(OP.INSTALL_RENDER_DATA, [array.id, byteLength]);
|
||||
}
|
||||
|
||||
async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) {
|
||||
validateHandle(mesh, "mesh");
|
||||
const result = await this.#enqueue(OP.CREATE_INSTANCE, [...mesh, ...floatWords(transform), ...typeWords(type)]);
|
||||
validateHandle(result, "instance");
|
||||
return result;
|
||||
}
|
||||
|
||||
setInstanceTransform(instance, transform) {
|
||||
this.#validateLiveInstance(instance);
|
||||
this.array("instance.transform").write(instance[0], floatValues(transform), instance[1]);
|
||||
}
|
||||
|
||||
setInstanceType(instance, type) {
|
||||
this.#validateLiveInstance(instance);
|
||||
this.array("instance.type").write(instance[0], typeWords(type), instance[1]);
|
||||
}
|
||||
|
||||
destroyInstance(instance) {
|
||||
this.#validateLiveInstance(instance);
|
||||
return this.#enqueue(OP.DESTROY_INSTANCE, instance);
|
||||
}
|
||||
|
||||
#validateLiveInstance(instance) {
|
||||
validateHandle(instance, "instance");
|
||||
const generation = this.array("instance.generation").read(instance[0])[0];
|
||||
if (generation !== instance[1]) throw new RendererError("STALE_HANDLE");
|
||||
}
|
||||
|
||||
|
||||
async #withPayload(buffer, opcode, words = []) {
|
||||
if (this.#disposed) throw new RendererError("DISPOSED");
|
||||
let id;
|
||||
do { id = this.#payload++ >>> 0; if (!id) id = this.#payload++ >>> 0; }
|
||||
while (!id || this.#payloadActive.has(id));
|
||||
this.#payloadActive.add(id);
|
||||
const worker = this.#worker;
|
||||
const ready = new Promise((resolve, reject) => this.#payloadPending.set(id, { resolve, reject }));
|
||||
try {
|
||||
worker.postMessage({ type: "payload", id, buffer }, [buffer]);
|
||||
await ready;
|
||||
return await this.#enqueue(opcode, [id, ...words]);
|
||||
} finally {
|
||||
this.#payloadPending.delete(id);
|
||||
this.#payloadActive.delete(id);
|
||||
try { worker.postMessage({ type: "payload-release", id }); } catch { /* best effort after termination */ }
|
||||
}
|
||||
}
|
||||
|
||||
#graphCall(operation) {
|
||||
const result = new Promise((resolve, reject) => this.#graphQueue.push({operation, resolve, reject}));
|
||||
this.#pumpGraphQueue();
|
||||
return result;
|
||||
}
|
||||
|
||||
#pumpGraphQueue() {
|
||||
if (this.#graphBusy || !this.#graphQueue.length) return;
|
||||
const call = this.#graphQueue.shift();
|
||||
if (this.#disposed) { call.reject(new RendererError("DISPOSED")); this.#pumpGraphQueue(); return; }
|
||||
this.#graphBusy = true;
|
||||
let outcome;
|
||||
try { outcome = call.operation(); } catch (error) { outcome = Promise.reject(error); }
|
||||
Promise.resolve(outcome).then(call.resolve, call.reject).finally(() => { this.#graphBusy = false; this.#pumpGraphQueue(); });
|
||||
}
|
||||
|
||||
compileGraph(graph) {
|
||||
return this.#graphCall(() => this.#compileGraph(graph));
|
||||
}
|
||||
|
||||
async #compileGraph(graph) {
|
||||
if (this.#disposed) throw new RendererError("DISPOSED");
|
||||
if (typeof graph !== "string") throw new TypeError("graph must be a serialized render-graph AST");
|
||||
const source = graph;
|
||||
const buffer = new TextEncoder().encode(source).buffer;
|
||||
if (buffer.byteLength > 1024 * 1024) throw new RendererError("GRAPH_PAYLOAD_TOO_LARGE");
|
||||
return this.#withPayload(buffer, OP.COMPILE_GRAPH);
|
||||
}
|
||||
|
||||
dropCompiledGraph(compiledId) {
|
||||
validateCompiledId(compiledId);
|
||||
return this.#graphCall(() => this.#enqueue(OP.DROP_GRAPH, compiledId));
|
||||
}
|
||||
|
||||
switchCompiledGraph(compiledId) {
|
||||
validateCompiledId(compiledId);
|
||||
if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero");
|
||||
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, compiledId));
|
||||
}
|
||||
|
||||
dispose() { this.#fail("DISPOSED"); }
|
||||
}
|
||||
|
||||
function validateCompiledId(compiledId) {
|
||||
if (!Array.isArray(compiledId) || compiledId.length !== 2 || compiledId.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError("compiledId must contain exactly two uint32 values");
|
||||
}
|
||||
|
||||
function validateHandle(handle, name) {
|
||||
if (!Array.isArray(handle) || handle.length !== 2 || handle.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError(`${name} handle must contain exactly two uint32 values`);
|
||||
}
|
||||
|
||||
function typeWords(words) { if (!words || words.length !== 16 || [...words].some(x => !Number.isInteger(x) || x < 0 || x > 0xffffffff)) throw new TypeError("type must contain exactly 16 uint32 values"); return Array.from(words, x => x >>> 0); }
|
||||
|
||||
function floatWords(matrix) {
|
||||
return [...new Int32Array(new Float32Array(floatValues(matrix)).buffer)];
|
||||
}
|
||||
|
||||
function floatValues(values) {
|
||||
if (!values || values.length !== 16 || [...values].some(value => typeof value !== "number" || !Number.isFinite(value))) throw new TypeError("transform must contain 16 finite numbers");
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
const SOA_MAGIC = 0x414f5359;
|
||||
const SCALAR_TAG = { u32: 1, i32: 2, f32: 3 };
|
||||
|
||||
export class SharedSoaArray {
|
||||
#memory; #descriptor; #buffer; #control; #words;
|
||||
|
||||
constructor(memory, descriptor) {
|
||||
this.#memory = memory;
|
||||
this.update(descriptor);
|
||||
}
|
||||
|
||||
get name() { return this.#descriptor.name; }
|
||||
get id() { return this.#descriptor.id; }
|
||||
get domain() { return this.#descriptor.domain; }
|
||||
get scalar() { return this.#descriptor.scalar; }
|
||||
get lanes() { return this.#descriptor.lanes; }
|
||||
get stride() { return this.#descriptor.stride; }
|
||||
get length() { this.#refresh(); return Atomics.load(this.#control, 6) >>> 0; }
|
||||
get capacity() { return this.#descriptor.capacity; }
|
||||
|
||||
/** Returns the shared backing store and current wire descriptor for another worker. */
|
||||
share() {
|
||||
this.#refresh();
|
||||
return { buffer: this.#memory.buffer, descriptor: { ...this.#descriptor } };
|
||||
}
|
||||
|
||||
update(descriptor) {
|
||||
if (!descriptor || typeof descriptor.name !== "string" || !SCALAR_TAG[descriptor.scalar] || typeof descriptor.writable !== "boolean" || (descriptor.generationGuard !== undefined && descriptor.generationGuard !== "instance" && descriptor.generationGuard !== "mesh"))
|
||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
||||
if (this.#descriptor && (descriptor.id !== this.#descriptor.id || descriptor.layoutEpoch < this.#descriptor.layoutEpoch))
|
||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
||||
this.#descriptor = Object.freeze({ ...descriptor });
|
||||
this.#buffer = null;
|
||||
this.#refresh();
|
||||
}
|
||||
|
||||
#refresh() {
|
||||
const buffer = this.#memory.buffer;
|
||||
if (this.#buffer === buffer && this.#control?.byteOffset === this.#descriptor.controlPtr) return;
|
||||
const descriptor = this.#descriptor;
|
||||
if (!(buffer instanceof SharedArrayBuffer) || descriptor.controlPtr % 64 || descriptor.dataOffset !== 64 || descriptor.stride % 16)
|
||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
||||
this.#buffer = buffer;
|
||||
this.#control = new Int32Array(buffer, descriptor.controlPtr, 16);
|
||||
this.#words = new Int32Array(buffer, descriptor.controlPtr + descriptor.dataOffset, descriptor.byteLength / 4);
|
||||
if ((Atomics.load(this.#control, 0) >>> 0) !== SOA_MAGIC || (Atomics.load(this.#control, 1) >>> 0) !== 1 || (Atomics.load(this.#control, 2) >>> 0) !== descriptor.id || (Atomics.load(this.#control, 3) >>> 0) !== SCALAR_TAG[descriptor.scalar])
|
||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
||||
}
|
||||
|
||||
#encode(value) {
|
||||
if (this.scalar === "u32") {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw new TypeError("value must be a uint32");
|
||||
return value | 0;
|
||||
}
|
||||
if (this.scalar === "i32") {
|
||||
if (!Number.isInteger(value) || value < -0x80000000 || value > 0x7fffffff) throw new TypeError("value must be an int32");
|
||||
return value | 0;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError("value must be a finite float32");
|
||||
return new Int32Array(new Float32Array([value]).buffer)[0];
|
||||
}
|
||||
|
||||
#decode(word) {
|
||||
if (this.scalar === "u32") return word >>> 0;
|
||||
if (this.scalar === "i32") return word | 0;
|
||||
return new Float32Array(new Int32Array([word]).buffer)[0];
|
||||
}
|
||||
|
||||
#lock() {
|
||||
this.#refresh();
|
||||
for (let attempt = 0; attempt < 1024; attempt++) {
|
||||
const sequence = Atomics.load(this.#control, 9) >>> 0;
|
||||
if (!(sequence & 1) && (Atomics.compareExchange(this.#control, 9, sequence | 0, (sequence + 1) | 0) >>> 0) === sequence)
|
||||
return sequence;
|
||||
}
|
||||
throw new RendererError("SOA_BUSY");
|
||||
}
|
||||
|
||||
#unlock(sequence) {
|
||||
Atomics.store(this.#control, 9, (sequence + 2) | 0);
|
||||
Atomics.notify(this.#control, 9);
|
||||
}
|
||||
|
||||
read(slot) {
|
||||
this.#refresh();
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot >= this.length) throw new RangeError("slot is outside the shared array");
|
||||
const base = slot * (this.stride / 4);
|
||||
for (let attempt = 0; attempt < 1024; attempt++) {
|
||||
const before = Atomics.load(this.#control, 9) >>> 0;
|
||||
if (before & 1) continue;
|
||||
const values = Array.from({ length: this.lanes }, (_, lane) => this.#decode(Atomics.load(this.#words, base + lane)));
|
||||
const after = Atomics.load(this.#control, 9) >>> 0;
|
||||
if (before === after && !(after & 1)) return values;
|
||||
}
|
||||
throw new RendererError("SOA_BUSY");
|
||||
}
|
||||
|
||||
write(slot, values, generation) {
|
||||
if (!this.#descriptor.writable) throw new RendererError("SOA_READ_ONLY");
|
||||
if (!values || values.length !== this.lanes) throw new TypeError(`values must contain ${this.lanes} lanes`);
|
||||
if (this.#descriptor.generationGuard !== undefined && (!Number.isInteger(generation) || generation < 1 || generation > 0xffffffff))
|
||||
throw new TypeError("generation must be a nonzero uint32");
|
||||
const encoded = Array.from(values, value => this.#encode(value));
|
||||
const sequence = this.#lock();
|
||||
try {
|
||||
if (!Number.isInteger(slot) || slot < 0 || slot >= (Atomics.load(this.#control, 6) >>> 0)) throw new RangeError("slot is outside the shared array");
|
||||
const base = slot * (this.stride / 4);
|
||||
encoded.forEach((word, lane) => Atomics.store(this.#words, base + lane, word));
|
||||
if (this.#descriptor.generationGuard !== undefined) {
|
||||
Atomics.store(this.#words, base + this.lanes, generation | 0);
|
||||
Atomics.add(this.#words, base + this.lanes + 1, 1);
|
||||
}
|
||||
} finally {
|
||||
this.#unlock(sequence);
|
||||
}
|
||||
dispose() {
|
||||
this.#fail("DISPOSED");
|
||||
this.#worker.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
let canvas, context, device, surfaceFormat, memory, used = 0, loadout;
|
||||
const arrays = new Map();
|
||||
const align = (value, multiple) => Math.ceil(value / multiple) * multiple;
|
||||
const fail = code => { throw new Error(code); };
|
||||
const list = value => value === undefined ? [] : Array.isArray(value) ? value : fail("GRAPH_ARRAY");
|
||||
|
||||
function parse(source) {
|
||||
if (typeof source !== "string") fail("GRAPH_WIRE");
|
||||
const tokens = source.match(/\s*(\(|\)|"(?:\\.|[^"\\])*"|[^\s()]+)/gu) ?? [];
|
||||
let at = 0;
|
||||
const read = () => {
|
||||
const token = tokens[at++]?.trim();
|
||||
if (token === "(") {
|
||||
const value = [];
|
||||
while (tokens[at]?.trim() !== ")") {
|
||||
if (at >= tokens.length) fail("GRAPH_WIRE");
|
||||
value.push(read());
|
||||
}
|
||||
at++;
|
||||
return value;
|
||||
}
|
||||
if (!token || token === ")") fail("GRAPH_WIRE");
|
||||
if (token[0] === '"') return JSON.parse(token);
|
||||
if (token === "true") return true;
|
||||
if (token === "false") return false;
|
||||
if (token === "null") return null;
|
||||
return Number.isFinite(Number(token)) ? Number(token) : token;
|
||||
};
|
||||
const root = read();
|
||||
if (at !== tokens.length || root[0] !== "yawn-graph" || root[1] !== 1) fail("GRAPH_WIRE");
|
||||
const decode = value => {
|
||||
if (!Array.isArray(value)) return value;
|
||||
if (value[0] === "array") return value.slice(1).map(decode);
|
||||
if (value[0] === "object") return Object.fromEntries(value.slice(1).map(field => {
|
||||
if (field[0] !== "field" || field.length !== 3) fail("GRAPH_WIRE");
|
||||
return [field[1], decode(field[2])];
|
||||
}));
|
||||
fail("GRAPH_WIRE");
|
||||
};
|
||||
return decode(root[2]);
|
||||
}
|
||||
|
||||
function index(items, code) {
|
||||
const result = new Map();
|
||||
for (const item of list(items)) {
|
||||
if (!item || typeof item.id !== "string" || result.has(item.id)) fail(code);
|
||||
result.set(item.id, item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortPasses(passes) {
|
||||
const byId = index(passes, "GRAPH_PASS");
|
||||
const waiting = new Map([...byId].map(([id, pass]) => [id, new Set(list(pass.after))]));
|
||||
for (const dependencies of waiting.values())
|
||||
for (const dependency of dependencies) if (!byId.has(dependency)) fail("GRAPH_DEPENDENCY");
|
||||
const result = [];
|
||||
while (waiting.size) {
|
||||
const ready = [...waiting].find(([, dependencies]) => !dependencies.size);
|
||||
if (!ready) fail("GRAPH_CYCLE");
|
||||
waiting.delete(ready[0]);
|
||||
result.push(byId.get(ready[0]));
|
||||
for (const dependencies of waiting.values()) dependencies.delete(ready[0]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const bufferUsage = names => list(names).reduce((usage, name) => usage | ({
|
||||
uniform: GPUBufferUsage.UNIFORM, storage: GPUBufferUsage.STORAGE,
|
||||
vertex: GPUBufferUsage.VERTEX, index: GPUBufferUsage.INDEX,
|
||||
indirect: GPUBufferUsage.INDIRECT, copySrc: GPUBufferUsage.COPY_SRC,
|
||||
}[name] ?? fail("GRAPH_BUFFER_USAGE")), GPUBufferUsage.COPY_DST);
|
||||
const textureUsage = names => list(names).reduce((usage, name) => usage | ({
|
||||
render: GPUTextureUsage.RENDER_ATTACHMENT, sampled: GPUTextureUsage.TEXTURE_BINDING,
|
||||
storage: GPUTextureUsage.STORAGE_BINDING, copySrc: GPUTextureUsage.COPY_SRC,
|
||||
copyDst: GPUTextureUsage.COPY_DST,
|
||||
}[name] ?? fail("GRAPH_TEXTURE_USAGE")), 0);
|
||||
|
||||
async function compile(graph) {
|
||||
if (!graph || typeof graph.id !== "string") fail("GRAPH_SHAPE");
|
||||
const passes = sortPasses(graph.passes);
|
||||
const renderDeclarations = index(graph.pipelines?.render, "GRAPH_PIPELINE");
|
||||
const computeDeclarations = index(graph.pipelines?.compute, "GRAPH_PIPELINE");
|
||||
const resources = new Map(), owned = [];
|
||||
try {
|
||||
const usedResources = new Set(passes.flatMap(pass => [
|
||||
...list(pass.bindings).map(x => x.resource),
|
||||
...list(pass.color).map(x => x.resource),
|
||||
...(pass.depth ? [pass.depth.resource] : []),
|
||||
...list(pass.vertexBuffers).map(x => x.resource),
|
||||
...(pass.indexBuffer ? [pass.indexBuffer.resource] : []),
|
||||
]));
|
||||
for (const declaration of list(graph.resources?.buffers)) {
|
||||
if (!usedResources.has(declaration.id)) continue;
|
||||
const source = arrays.get(declaration.array);
|
||||
if (!source) fail("GRAPH_ARRAY_UNKNOWN");
|
||||
const gpu = device.createBuffer({ size: align(source.bytes, 4), usage: bufferUsage(declaration.usage) });
|
||||
resources.set(declaration.id, { kind: "buffer", gpu, source });
|
||||
owned.push(gpu);
|
||||
}
|
||||
|
||||
const textures = index(graph.resources?.textures, "GRAPH_RESOURCE"), lifetimes = new Map(), slots = [];
|
||||
passes.forEach((pass, frame) => {
|
||||
for (const id of [...list(pass.bindings).map(x => x.resource), ...list(pass.color).map(x => x.resource), ...(pass.depth ? [pass.depth.resource] : [])]) {
|
||||
if (!textures.has(id)) continue;
|
||||
const lifetime = lifetimes.get(id) ?? [frame, frame];
|
||||
lifetime[1] = frame;
|
||||
lifetimes.set(id, lifetime);
|
||||
}
|
||||
});
|
||||
for (const declaration of textures.values()) {
|
||||
const lifetime = lifetimes.get(declaration.id);
|
||||
if (!lifetime) continue;
|
||||
const size = declaration.size ?? ["canvas", "canvas"];
|
||||
if (!Array.isArray(size) || size.length < 2 || size.length > 3) fail("GRAPH_TEXTURE_SIZE");
|
||||
const descriptor = {
|
||||
size: [size[0] === "canvas" ? canvas.width : size[0], size[1] === "canvas" ? canvas.height : size[1], size[2] ?? 1],
|
||||
format: declaration.format,
|
||||
usage: textureUsage(declaration.usage),
|
||||
mipLevelCount: declaration.mipLevelCount ?? 1,
|
||||
sampleCount: declaration.sampleCount ?? 1,
|
||||
dimension: declaration.dimension ?? "2d",
|
||||
};
|
||||
const key = JSON.stringify(descriptor);
|
||||
let slot = declaration.transient === false ? undefined : slots.find(value => value.key === key && value.last < lifetime[0]);
|
||||
if (!slot) {
|
||||
const gpu = device.createTexture(descriptor);
|
||||
slot = { key, last: lifetime[1], gpu, view: gpu.createView() };
|
||||
slots.push(slot);
|
||||
owned.push(gpu);
|
||||
} else slot.last = lifetime[1];
|
||||
resources.set(declaration.id, { kind: "texture", gpu: slot.gpu, view: slot.view });
|
||||
}
|
||||
for (const declaration of list(graph.resources?.samplers))
|
||||
if (usedResources.has(declaration.id)) resources.set(declaration.id, {
|
||||
kind: "sampler", gpu: device.createSampler(declaration.descriptor),
|
||||
});
|
||||
|
||||
const renderPipelines = new Map(), computePipelines = new Map();
|
||||
await Promise.all([...new Set(passes.filter(x => x.type === "render").map(x => x.pipeline))].map(async id => {
|
||||
const declaration = renderDeclarations.get(id);
|
||||
if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE");
|
||||
const module = device.createShaderModule({ code: declaration.code });
|
||||
renderPipelines.set(id, await device.createRenderPipelineAsync({
|
||||
layout: "auto",
|
||||
vertex: { module, entryPoint: declaration.vertex?.entry ?? "vertex", buffers: declaration.vertex?.buffers ?? [] },
|
||||
fragment: {
|
||||
module,
|
||||
entryPoint: declaration.fragment?.entry ?? "fragment",
|
||||
targets: list(declaration.fragment?.targets).map(target => ({
|
||||
...target, format: target.format === "canvas" ? surfaceFormat : target.format,
|
||||
})),
|
||||
},
|
||||
primitive: declaration.primitive,
|
||||
depthStencil: declaration.depthStencil,
|
||||
multisample: declaration.multisample,
|
||||
}));
|
||||
}));
|
||||
await Promise.all([...new Set(passes.filter(x => x.type === "compute").map(x => x.pipeline))].map(async id => {
|
||||
const declaration = computeDeclarations.get(id);
|
||||
if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE");
|
||||
const module = device.createShaderModule({ code: declaration.code });
|
||||
computePipelines.set(id, await device.createComputePipelineAsync({
|
||||
layout: "auto", compute: { module, entryPoint: declaration.entry ?? "main" },
|
||||
}));
|
||||
}));
|
||||
|
||||
const bindGroups = (pass, pipeline) => {
|
||||
const groups = new Map();
|
||||
for (const binding of list(pass.bindings)) {
|
||||
const resource = resources.get(binding.resource);
|
||||
if (!resource) fail("GRAPH_RESOURCE_UNKNOWN");
|
||||
const value = resource.kind === "buffer"
|
||||
? { buffer: resource.gpu, offset: binding.offset ?? 0, ...(binding.size ? { size: binding.size } : {}) }
|
||||
: resource.kind === "texture" ? resource.view : resource.gpu;
|
||||
if (!groups.has(binding.group ?? 0)) groups.set(binding.group ?? 0, []);
|
||||
groups.get(binding.group ?? 0).push({ binding: binding.binding, resource: value });
|
||||
}
|
||||
return [...groups].map(([group, entries]) => [group, device.createBindGroup({
|
||||
layout: pipeline.getBindGroupLayout(group), entries,
|
||||
})]);
|
||||
};
|
||||
const compiled = passes.map(pass => {
|
||||
const pipeline = (pass.type === "render" ? renderPipelines : computePipelines).get(pass.pipeline);
|
||||
if (!pipeline) fail("GRAPH_PASS");
|
||||
return { pass, pipeline, bindGroups: bindGroups(pass, pipeline) };
|
||||
});
|
||||
return { id: graph.id, passes: compiled, resources, owned };
|
||||
} catch (error) {
|
||||
owned.forEach(resource => resource.destroy?.());
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const clearColor = (value = [0, 0, 0, 1]) => Array.isArray(value)
|
||||
? { r: value[0], g: value[1], b: value[2], a: value[3] }
|
||||
: value;
|
||||
|
||||
function render() {
|
||||
if (!loadout) return;
|
||||
for (const resource of loadout.resources.values())
|
||||
if (resource.kind === "buffer") device.queue.writeBuffer(
|
||||
resource.gpu, 0, new Uint8Array(memory, resource.source.offset, resource.source.bytes),
|
||||
);
|
||||
const encoder = device.createCommandEncoder();
|
||||
for (const { pass, pipeline, bindGroups } of loadout.passes) {
|
||||
if (pass.type === "compute") {
|
||||
const command = encoder.beginComputePass();
|
||||
command.setPipeline(pipeline);
|
||||
bindGroups.forEach(([group, value]) => command.setBindGroup(group, value));
|
||||
command.dispatchWorkgroups(...(pass.dispatch ?? [1, 1, 1]));
|
||||
command.end();
|
||||
continue;
|
||||
}
|
||||
const view = id => id === "canvas"
|
||||
? context.getCurrentTexture().createView()
|
||||
: loadout.resources.get(id)?.view ?? fail("GRAPH_ATTACHMENT");
|
||||
const command = encoder.beginRenderPass({
|
||||
colorAttachments: list(pass.color).map(attachment => ({
|
||||
view: view(attachment.resource), loadOp: attachment.load ?? "clear",
|
||||
storeOp: attachment.store ?? "store", clearValue: clearColor(attachment.clear),
|
||||
})),
|
||||
...(pass.depth ? { depthStencilAttachment: {
|
||||
view: view(pass.depth.resource), depthLoadOp: pass.depth.load ?? "clear",
|
||||
depthStoreOp: pass.depth.store ?? "store", depthClearValue: pass.depth.clear ?? 1,
|
||||
} } : {}),
|
||||
});
|
||||
command.setPipeline(pipeline);
|
||||
bindGroups.forEach(([group, value]) => command.setBindGroup(group, value));
|
||||
list(pass.vertexBuffers).forEach(binding => command.setVertexBuffer(
|
||||
binding.slot ?? 0, loadout.resources.get(binding.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"), binding.offset ?? 0,
|
||||
));
|
||||
if (pass.indexBuffer) command.setIndexBuffer(
|
||||
loadout.resources.get(pass.indexBuffer.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"),
|
||||
pass.indexBuffer.format ?? "uint32", pass.indexBuffer.offset ?? 0,
|
||||
);
|
||||
const draw = pass.draw ?? {};
|
||||
if (pass.indexBuffer) command.drawIndexed(draw.indices ?? 0, draw.instances ?? 1, draw.firstIndex ?? 0, draw.baseVertex ?? 0, draw.firstInstance ?? 0);
|
||||
else command.draw(draw.vertices ?? 3, draw.instances ?? 1, draw.firstVertex ?? 0, draw.firstInstance ?? 0);
|
||||
command.end();
|
||||
}
|
||||
device.queue.submit([encoder.finish()]);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
try { render(); } catch (error) {
|
||||
postMessage({ type: "runtime-error", error: error?.message ?? "RENDER_ERROR" });
|
||||
loadout = undefined;
|
||||
}
|
||||
(globalThis.requestAnimationFrame ?? (callback => setTimeout(callback, 16)))(tick);
|
||||
}
|
||||
|
||||
addEventListener("message", async ({ data: message }) => {
|
||||
try {
|
||||
let result;
|
||||
if (message.type === "init") {
|
||||
if (!(message.canvas instanceof OffscreenCanvas) || !Number.isInteger(message.arenaBytes) || message.arenaBytes < 64) fail("INIT");
|
||||
canvas = message.canvas;
|
||||
memory = new SharedArrayBuffer(align(message.arenaBytes, 64));
|
||||
const adapter = await navigator.gpu?.requestAdapter();
|
||||
if (!adapter) fail("WEBGPU_UNAVAILABLE");
|
||||
device = await adapter.requestDevice();
|
||||
context = canvas.getContext("webgpu");
|
||||
surfaceFormat = navigator.gpu.getPreferredCanvasFormat();
|
||||
context.configure({ device, format: surfaceFormat, alphaMode: "opaque" });
|
||||
device.lost.then(() => { postMessage({ type: "runtime-error", error: "DEVICE_LOST" }); loadout = undefined; });
|
||||
result = { buffer: memory };
|
||||
tick();
|
||||
} else if (message.type === "allocate") {
|
||||
const { name, rows, stride, format } = message;
|
||||
if (typeof name !== "string" || !name || arrays.has(name) || !Number.isInteger(rows) || rows < 1 ||
|
||||
!Number.isInteger(stride) || stride < 16 || stride % 16 || !["f32", "u32", "i32"].includes(format)) fail("ALLOCATION");
|
||||
const offset = align(used, 64), bytes = rows * stride;
|
||||
if (!Number.isSafeInteger(bytes) || offset + bytes > memory.byteLength) fail("ARENA_OOM");
|
||||
result = { name, rows, stride, format, offset };
|
||||
arrays.set(name, { ...result, bytes });
|
||||
used = offset + bytes;
|
||||
} else if (message.type === "load-graph") {
|
||||
const next = await compile(parse(message.serialized));
|
||||
const previous = loadout;
|
||||
loadout = next;
|
||||
previous?.owned.forEach(resource => resource.destroy?.());
|
||||
result = { id: next.id };
|
||||
} else fail("MESSAGE");
|
||||
postMessage({ request: message.request, result });
|
||||
} catch (error) {
|
||||
postMessage({ request: message.request, error: error?.message ?? "CORE_ERROR" });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user