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:
Amp
2026-08-19 15:30:32 +00:00
co-authored by heaust
parent d34722d66d
commit f12c7c9603
118 changed files with 687 additions and 34500 deletions
+2 -1
View File
@@ -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
View File
@@ -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();
}
}
+289
View File
@@ -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" });
}
});