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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user