Strip core to render data and render graphs

Move glTF, picking, camera controls, and conventional handles into addons. Keep camera and material mutations in SIMD-aligned shared SOA rows and synchronize material updates directly into GPU buffers.

Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-19 09:41:41 +00:00
co-authored by heaust
parent 0e44917f9e
commit 6bbf8039e4
67 changed files with 3152 additions and 3669 deletions
+1 -4
View File
@@ -2,8 +2,5 @@
"name": "@yawn/core",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.js",
"./snapshot": "./src/snapshot.js"
}
"exports": "./src/index.js"
}
+17 -51
View File
@@ -1,7 +1,5 @@
import { SnapshotReader } from "./snapshot.js";
const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
const OP = { IMPORT_GLB: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 };
const OP = { INSTALL_RENDER_DATA: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 };
export class RendererError extends Error {
constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; }
@@ -12,9 +10,8 @@ export class YawnCore extends EventTarget {
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
#readyResolve; #readyReject; #arrays = new Map();
#transportReady = false;
#telemetry; #profile; #stopped = false;
#telemetry; #stopped = false; #renderDataSnapshot;
#graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
constructor(bridge) {
super();
@@ -31,20 +28,11 @@ export class YawnCore extends EventTarget {
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
this.#worker.start?.();
try {
const factory = bridge.pickingWorkerFactory;
if (factory) {
this.#bvh = factory();
this.#bvh.addEventListener("message", e => this.#bvhMessage(e.data));
this.#bvh.addEventListener("error", () => this.#disablePicking("PICKING_FAILED"));
this.#bvh.addEventListener("messageerror", () => this.#disablePicking("PICKING_FAILED"));
} else this.#picking = false;
} catch { this.#picking = false; }
}
get ready() { return this.#ready; }
get telemetry() { return this.#telemetry; }
get profile() { return this.#profile; }
get renderDataSnapshot() { return this.#renderDataSnapshot; }
array(name) {
const array = this.#arrays.get(name);
@@ -99,9 +87,6 @@ export class YawnCore extends EventTarget {
} else if (message?.type === "telemetry") {
this.#telemetry = message;
this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
} else if (message?.type === "profile-snapshot") {
this.#profile = message;
this.dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
} else if (message?.type === "fatal") {
console.error("renderer worker fatal", JSON.stringify(message));
this.#fail(message.code || "WORKER_FATAL");
@@ -117,11 +102,18 @@ export class YawnCore extends EventTarget {
} else if (message?.type === "snapshot-init") {
try {
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
this.#snapshotReader = new SnapshotReader(this.#bridge.memory, message.controlPtr);
this.#bvh?.postMessage({type:"init",memory:this.#bridge.memory,controlPtr:message.controlPtr,controlVersion:1,schemaVersion:2});
} catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
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") {
try { this.#snapshotEpoch=this.#snapshotReader?.latest().epoch||0; this.#bvh?.postMessage({type:"update",epoch:this.#snapshotEpoch}); } catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot-published", {
detail: Object.freeze({ epoch: message.epoch >>> 0 }),
}));
}
}
@@ -132,30 +124,10 @@ export class YawnCore extends EventTarget {
return this.#arrays.get(descriptor.name);
}
#disablePicking(code) { this.#picking=false; const allowed=new Set(["PICK_UNAVAILABLE","PICK_PROTOCOL_MISMATCH","PICK_WORKER_ERROR","PICK_STALE","DISPOSED"]); const error=new RendererError(allowed.has(code)?code:"PICK_WORKER_ERROR"); for(const p of this.#picks.values())p.reject(error); this.#picks.clear(); try{this.#bvh?.terminate?.();}catch{} this.#bvh=null; }
#bvhMessage(message) {
if(message?.type==="fatal"){this.#disablePicking(message.code);return;}
if(message?.type!=="pick")return;
const p=this.#picks.get(message.request);if(!p)return;this.#picks.delete(message.request);
let latest=0;try{latest=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=latest;}catch{this.#disablePicking("PICK_PROTOCOL_MISMATCH");p.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));return;}
if(message.stale||p.epoch!==message.epoch||message.epoch!==latest){if(!p.retried&&latest){this.#sendPick({...p,retried:true},latest);}else p.reject(new RendererError("PICK_STALE"));return;}
const hits=(message.hits||[]).map(hit=>({instance:[hit.slot>>>0,hit.generation>>>0],distance:hit.distance}));p.resolve({epoch:latest,hits});
}
#sendPick(p,epoch){const request=this.#pickNext++>>>0||this.#pickNext++;p.epoch=epoch;this.#picks.set(request,p);try{this.#bvh.postMessage({type:"pick",request,epoch,origin:p.origin,direction:p.direction,maxDistance:p.maxDistance,maxHits:p.maxHits});}catch{this.#picks.delete(request);p.reject(new RendererError("PICK_WORKER_ERROR"));}}
pickRay(origin,direction,{maxDistance=Infinity,maxHits=1}={}) {
const vector=(v,name)=>{if(!v||v.length!==3||[...v].some(x=>typeof x!=="number"||!Number.isFinite(x)))throw new TypeError(`${name} must contain 3 finite numbers`);return [...v];};
origin=vector(origin,"origin");direction=vector(direction,"direction");if(direction.every(x=>x===0))throw new TypeError("direction must be nonzero");if(typeof maxDistance!=="number"||(!(Number.isFinite(maxDistance)&&maxDistance>=0)&&maxDistance!==Infinity)||!Number.isInteger(maxHits)||maxHits<1||maxHits>64)throw new TypeError("invalid pick options");
if(this.#disposed)return Promise.reject(new RendererError("DISPOSED"));if(!this.#picking||!this.#bvh||!this.#snapshotReader)return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
let epoch;try{epoch=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=epoch;}catch{return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));}if(!epoch)return Promise.reject(new RendererError("PICK_STALE"));
return new Promise((resolve,reject)=>this.#sendPick({resolve,reject,origin,direction,maxDistance,maxHits,retried:false},epoch));
}
#stop() {
if (this.#stopped) return;
this.#stopped = true;
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
try { this.#bvh?.postMessage?.({type:"dispose"}); this.#bvh?.terminate?.(); } catch { /* best effort */ }
try { this.#bridge?.free?.(); } catch { /* best effort */ }
this.#bridge = null;
}
@@ -171,7 +143,6 @@ export class YawnCore extends EventTarget {
this.#payloadPending.clear();
for (const pending of this.#graphQueue) pending.reject(error);
this.#graphQueue.length = 0;
this.#disablePicking(code === "DISPOSED" ? "DISPOSED" : "PICK_WORKER_ERROR");
this.#stop();
}
@@ -211,14 +182,13 @@ export class YawnCore extends EventTarget {
return promise;
}
commitGlbUpload(array, byteLength, { framing = "exterior" } = {}) {
commitRenderDataUpload(array, byteLength) {
if (this.#disposed) throw new RendererError("DISPOSED");
if (framing !== "exterior" && framing !== "interior") throw new TypeError("framing must be exterior or interior");
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.IMPORT_GLB, [array.id, byteLength, framing === "interior" ? 1 : 0]);
return this.#enqueue(OP.INSTALL_RENDER_DATA, [array.id, byteLength]);
}
async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) {
@@ -306,11 +276,7 @@ export class YawnCore extends EventTarget {
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, [1, ...compiledId]));
}
switchToImmediate() {
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [0, 0, 0]));
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, compiledId));
}
dispose() { this.#fail("DISPOSED"); }
-96
View File
@@ -1,96 +0,0 @@
/** Shared render-data snapshot protocol used by core picking workers. */
export const SNAPSHOT = Object.freeze({
MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256,
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2,
CLOSED: 3, FREE: 0, WRITING: 1, READY: 2, READING: 3,
});
export const STREAM_NAMES = ["meshSlot", "meshGeneration", "meshLocalMin", "meshLocalMax", "instanceSlot", "instanceGeneration", "instanceMeshSlot", "instanceMeshGeneration", "instanceModel", "instanceWorldMin", "instanceWorldMax", "instanceType"];
const COMPONENTS = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16];
const SCALARS = [1, 1, 2, 2, 1, 1, 1, 1, 2, 2, 2, 1];
const STRIDES = COMPONENTS.map(n => n * 4);
export class SnapshotProtocolError extends Error {
constructor(code) { super(code); this.code = code; this.name = "SnapshotProtocolError"; }
}
const bad = code => { throw new SnapshotProtocolError(code); };
const add = (a, b) => { const n = a + b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; };
const mul = (a, b) => { const n = a * b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; };
export class SnapshotReader {
constructor(memory, controlPtr) {
if (!memory || !(memory.buffer instanceof SharedArrayBuffer)) bad("BAD_MEMORY");
if (!Number.isInteger(controlPtr) || controlPtr < 0 || controlPtr % 64 || add(controlPtr, 256) > memory.buffer.byteLength) bad("BAD_CONTROL_POINTER");
this.memory = memory; this.controlPtr = controlPtr; this.buffer = null;
this.refresh(); this.validateControl();
}
refresh() {
if (this.buffer === this.memory.buffer) return;
this.buffer = this.memory.buffer;
if (add(this.controlPtr, 256) > this.buffer.byteLength) bad("BAD_CONTROL_POINTER");
this.control = new Int32Array(this.buffer, this.controlPtr, 64);
}
validateControl() {
const h = this.control;
if ((Atomics.load(h, 0) >>> 0) !== SNAPSHOT.MAGIC) bad("BAD_MAGIC");
if ((Atomics.load(h, 1) >>> 0) !== SNAPSHOT.VERSION) bad("BAD_VERSION");
if ((Atomics.load(h, 2) >>> 0) !== SNAPSHOT.BYTES || (Atomics.load(h, 3) >>> 0) !== SNAPSHOT.SLOTS || (Atomics.load(h, 4) >>> 0) !== SNAPSHOT.SLOT_BYTES || (Atomics.load(h, 5) >>> 0) !== SNAPSHOT.SCHEMA) bad("BAD_LAYOUT");
const lifecycle = Atomics.load(h, 6) >>> 0;
if (lifecycle > SNAPSHOT.CLOSED) bad("BAD_LIFECYCLE");
if ((Atomics.load(h, 15) >>> 0) !== 0) bad("BAD_RESERVED");
}
latest() {
this.refresh(); this.validateControl();
for (let tries = 0; tries < 16; tries++) {
const a = Atomics.load(this.control, 7) >>> 0;
if (a & 1) continue;
const lifecycle = Atomics.load(this.control, 6) >>> 0;
const value = { lifecycle, epoch: Atomics.load(this.control, 8) >>> 0, slot: Atomics.load(this.control, 9) >>> 0, revisionLo: Atomics.load(this.control, 10) >>> 0, revisionHi: Atomics.load(this.control, 11) >>> 0, wasmPages: Atomics.load(this.control, 12) >>> 0, layoutEpoch: Atomics.load(this.control, 13) >>> 0, error: Atomics.load(this.control, 14) >>> 0 };
const b = Atomics.load(this.control, 7) >>> 0;
if (a === b && !(b & 1)) {
if (lifecycle === SNAPSHOT.FAILED) bad("SNAPSHOT_FAILED");
if (lifecycle === SNAPSHOT.CLOSED) bad("SNAPSHOT_CLOSED");
if (lifecycle !== SNAPSHOT.INIT && lifecycle !== SNAPSHOT.OPEN) bad("BAD_LIFECYCLE");
if (value.wasmPages && value.wasmPages > this.buffer.byteLength / 65536) bad("BAD_WASM_PAGES");
return value;
}
}
bad("UNSTABLE_CONTROL");
}
transaction(fn, expectedEpoch = 0) {
this.refresh();
const latest = this.latest();
if (!latest.epoch || latest.slot >= SNAPSHOT.SLOTS || (expectedEpoch && latest.epoch !== expectedEpoch)) return null;
let control = this.control;
const base = 16 + latest.slot * 16;
if (Atomics.compareExchange(control, base, SNAPSHOT.READY, SNAPSHOT.READING) !== SNAPSHOT.READY) return null;
try {
// memory.grow replaces memory.buffer even after the slot has been pinned.
this.refresh(); control = this.control;
const slot = Array.from({length: 16}, (_, i) => Atomics.load(control, base + i) >>> 0);
if (slot[0] !== SNAPSHOT.READING || slot[1] !== latest.epoch || slot[2] !== latest.layoutEpoch || slot[5] !== latest.revisionLo || slot[6] !== latest.revisionHi || slot[9] !== SNAPSHOT.SCHEMA || slot[10] !== 64) return null;
if (slot.slice(11).some(Boolean)) bad("BAD_SLOT_RESERVED");
const ptr = slot[3], bytes = slot[4];
if (ptr % 16 || bytes < 448 || bytes % 16 || add(ptr, bytes) > this.buffer.byteLength) bad("BAD_SLOT");
const u32 = new Uint32Array(this.buffer, ptr, bytes / 4);
if (u32[0] !== SNAPSHOT.BLOB_MAGIC || u32[1] !== SNAPSHOT.SCHEMA || u32[2] !== 64 || u32[3] !== bytes || u32[4] !== slot[1] || u32[5] !== slot[5] || u32[6] !== slot[6] || u32[7] !== 12 || u32[8] !== 64 || u32[9] !== 32 || u32[10] !== slot[7] || u32[11] !== slot[8] || u32[12] !== 0x01020304 || u32[13] !== 3) bad("BAD_BLOB");
if (u32[14] || u32[15]) bad("BAD_BLOB_RESERVED");
const ranges = [], streams = {};
for (let i = 0; i < 12; i++) {
const d = 16 + i * 8, semantic = u32[d], scalar = u32[d + 1], offset = u32[d + 2], count = u32[d + 3], components = u32[d + 4], stride = u32[d + 5], width = u32[d + 6], reserved = u32[d + 7];
const want = i < 4 ? slot[7] : slot[8];
if (semantic !== i + 1 || scalar !== SCALARS[i] || count !== want || components !== COMPONENTS[i] || stride !== STRIDES[i] || width !== 4 || reserved || offset < 448 || offset % 16) bad("BAD_DESCRIPTOR");
const end = add(offset, mul(stride, count));
if (end > bytes) bad("BAD_DESCRIPTOR_RANGE");
if (count) ranges.push([offset, end]);
const Type = scalar === 2 ? Float32Array : Uint32Array;
streams[STREAM_NAMES[i]] = new Type(this.buffer, add(ptr, offset), mul(count, components));
}
ranges.sort((a, b) => a[0] - b[0]);
for (let i = 1; i < ranges.length; i++) if (ranges[i][0] < ranges[i - 1][1]) bad("OVERLAPPING_STREAMS");
return fn(Object.freeze({epoch: slot[1], revisionLo: slot[5], revisionHi: slot[6], meshCount: slot[7], instanceCount: slot[8], streams: Object.freeze(streams)}));
} finally {
Atomics.store(control, base, SNAPSHOT.FREE); Atomics.notify(control, base);
}
}
}