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:
@@ -3,8 +3,5 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Conventional mesh, instance, camera, and material handles over Yawn render data",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@yawn/core": "0.1.0"
|
||||
}
|
||||
"exports": "./src/index.js"
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/** Worker-local acceleration structure used by the optional mesh-handle addon. */
|
||||
export class DerivedBvh {
|
||||
constructor() { this.count = 0; this.identity = new Uint32Array(); this.meshIdentity = new Uint32Array(); this.bounds = new Float32Array(); this.nodeBounds = new Float32Array(); this.left = this.right = new Int32Array(); this.leafStart = this.leafCount = new Uint32Array(); this.leaves = new Uint32Array(); this.root = -1; this.rebuilds = 0; this.refits = 0; }
|
||||
update(snapshot) {
|
||||
const s = snapshot.streams, n = snapshot.instanceCount;
|
||||
let changed = n !== this.count;
|
||||
if (!changed) for (let i = 0; i < n; i++) if (this.identity[i * 2] !== s.instanceSlot[i] || this.identity[i * 2 + 1] !== s.instanceGeneration[i] || this.meshIdentity[i * 2] !== s.instanceMeshSlot[i] || this.meshIdentity[i * 2 + 1] !== s.instanceMeshGeneration[i]) { changed = true; break; }
|
||||
this.count = n; this.identity = new Uint32Array(n * 2); this.meshIdentity = new Uint32Array(n * 2); this.bounds = new Float32Array(n * 6);
|
||||
for (let i = 0; i < n; i++) { this.identity.set([s.instanceSlot[i], s.instanceGeneration[i]], i * 2); this.meshIdentity.set([s.instanceMeshSlot[i], s.instanceMeshGeneration[i]], i * 2); this.bounds.set(s.instanceWorldMin.subarray(i * 3, i * 3 + 3), i * 6); this.bounds.set(s.instanceWorldMax.subarray(i * 3, i * 3 + 3), i * 6 + 3); }
|
||||
changed ? this.rebuild() : this.refit();
|
||||
}
|
||||
rebuild() {
|
||||
this.rebuilds++; const nodes = [], leaves = [];
|
||||
const build = indices => { const at = nodes.length, node = {left: -1, right: -1, start: 0, count: 0, bounds: [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity]}; nodes.push(node); for (const i of indices) for (let a = 0; a < 3; a++) { node.bounds[a] = Math.min(node.bounds[a], this.bounds[i * 6 + a]); node.bounds[a + 3] = Math.max(node.bounds[a + 3], this.bounds[i * 6 + a + 3]); } if (indices.length <= 2) { node.start = leaves.length; node.count = indices.length; leaves.push(...indices); return at; } let axis = 0, extent = node.bounds[3] - node.bounds[0]; for (let a = 1; a < 3; a++) if (node.bounds[a + 3] - node.bounds[a] > extent) { axis = a; extent = node.bounds[a + 3] - node.bounds[a]; } indices.sort((a, b) => (this.bounds[a * 6 + axis] + this.bounds[a * 6 + axis + 3]) - (this.bounds[b * 6 + axis] + this.bounds[b * 6 + axis + 3]) || a - b); const mid = indices.length >> 1; node.left = build(indices.slice(0, mid)); node.right = build(indices.slice(mid)); return at; };
|
||||
this.root = this.count ? build(Array.from({length: this.count}, (_, i) => i)) : -1; const n = nodes.length;
|
||||
this.nodeBounds = new Float32Array(n * 6); this.left = new Int32Array(n); this.right = new Int32Array(n); this.leafStart = new Uint32Array(n); this.leafCount = new Uint32Array(n); this.leaves = Uint32Array.from(leaves);
|
||||
nodes.forEach((x, i) => { this.nodeBounds.set(x.bounds, i * 6); this.left[i] = x.left; this.right[i] = x.right; this.leafStart[i] = x.start; this.leafCount[i] = x.count; });
|
||||
}
|
||||
refit() { this.refits++; for (let n = this.left.length - 1; n >= 0; n--) { const at = n * 6; for (let a = 0; a < 3; a++) { let lo = Infinity, hi = -Infinity; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; lo = Math.min(lo, this.bounds[i * 6 + a]); hi = Math.max(hi, this.bounds[i * 6 + a + 3]); } else { lo = Math.min(this.nodeBounds[this.left[n] * 6 + a], this.nodeBounds[this.right[n] * 6 + a]); hi = Math.max(this.nodeBounds[this.left[n] * 6 + a + 3], this.nodeBounds[this.right[n] * 6 + a + 3]); } this.nodeBounds[at + a] = lo; this.nodeBounds[at + a + 3] = hi; } } }
|
||||
pick(origin, direction, maxDistance = Infinity, maxHits = 1) {
|
||||
if (this.root < 0) return []; let magnitude = Math.hypot(...direction); const dir = direction.map(v => v / magnitude);
|
||||
const intersect = (array, at) => { let lo = 0, hi = maxDistance; for (let a = 0; a < 3; a++) { const min = array[at + a], max = array[at + a + 3]; if (dir[a] === 0) { if (origin[a] < min || origin[a] > max) return Infinity; } else { let x = (min - origin[a]) / dir[a], y = (max - origin[a]) / dir[a]; if (x > y) [x, y] = [y, x]; lo = Math.max(lo, x); hi = Math.min(hi, y); if (lo > hi) return Infinity; } } return lo; };
|
||||
const hits = [], stack = [this.root]; while (stack.length) { const n = stack.pop(); if (intersect(this.nodeBounds, n * 6) === Infinity) continue; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; const distance = intersect(this.bounds, i * 6); if (distance !== Infinity) hits.push({slot: this.identity[i * 2], generation: this.identity[i * 2 + 1], distance}); } else stack.push(this.right[n], this.left[n]); }
|
||||
hits.sort((a, b) => a.distance - b.distance || a.slot - b.slot || a.generation - b.generation); return hits.slice(0, maxHits);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SnapshotReader } from "./snapshot.js";
|
||||
import { DerivedBvh } from "./bvh-core.js";
|
||||
|
||||
let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0;
|
||||
function ensureEpoch(expected) {
|
||||
if (!reader) return false;
|
||||
const latest = reader.latest();
|
||||
if (latest.epoch !== expected) return false;
|
||||
if (epoch === expected) return true;
|
||||
const result = reader.transaction(snapshot => { bvh.update(snapshot); epoch = snapshot.epoch; }, expected);
|
||||
return result !== null && epoch === expected;
|
||||
}
|
||||
function coalescedUpdate(hint = 0) {
|
||||
requestedEpoch = Math.max(requestedEpoch, hint >>> 0);
|
||||
if (updating) return;
|
||||
updating = true;
|
||||
queueMicrotask(() => { try { const latest = reader?.latest(); if (latest?.epoch && latest.epoch !== epoch) ensureEpoch(latest.epoch); if (epoch) postMessage({type: "updated", epoch}); } catch (error) { postMessage({type: "fatal", code: "PICK_PROTOCOL_MISMATCH", message: String(error)}); } finally { updating = false; if (requestedEpoch > epoch) coalescedUpdate(); } });
|
||||
}
|
||||
addEventListener("message", event => {
|
||||
const m = event.data;
|
||||
try {
|
||||
if (m.type === "init") { reader = new SnapshotReader(m.memory, m.controlPtr); if (m.controlVersion !== 1 || m.schemaVersion !== 2) throw Object.assign(new Error("version"), {code: "PICK_PROTOCOL_MISMATCH"}); postMessage({type: "ready"}); }
|
||||
else if (m.type === "update") coalescedUpdate(m.epoch);
|
||||
else if (m.type === "pick") { if (!ensureEpoch(m.epoch)) { postMessage({type: "pick", request: m.request, stale: true, epoch}); return; } const hits = bvh.pick(m.origin, m.direction, m.maxDistance, m.maxHits); const latest = reader.latest().epoch; postMessage({type: "pick", request: m.request, stale: latest !== m.epoch || epoch !== m.epoch, epoch, hits}); }
|
||||
else if (m.type === "dispose") close();
|
||||
} catch (error) { postMessage({type: "fatal", code: error.name === "SnapshotProtocolError" || error.code === "PICK_PROTOCOL_MISMATCH" ? "PICK_PROTOCOL_MISMATCH" : "PICK_WORKER_ERROR", message: String(error)}); }
|
||||
});
|
||||
@@ -1,362 +1,55 @@
|
||||
import { RendererError } from "@yawn/core";
|
||||
import { SnapshotReader } from "./snapshot.js";
|
||||
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
|
||||
const TOKEN = Symbol("yawn mesh handle addon");
|
||||
const SNAPSHOT_EVENT = "yawn-render-data-snapshot";
|
||||
const PUBLISHED_EVENT = "yawn-render-data-snapshot-published";
|
||||
const createPickingWorker = () => new Worker(
|
||||
new URL("./bvh-worker.js", import.meta.url),
|
||||
{ type: "module", name: "yawn-spatial-query" },
|
||||
);
|
||||
|
||||
/** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */
|
||||
export class MeshHandles {
|
||||
#core;
|
||||
#factory; #worker; #reader; #snapshot; #epoch = 0; #next = 1; #picks = new Map(); #disposed = false;
|
||||
#onSnapshot; #onPublished;
|
||||
|
||||
constructor(core, { pickingWorkerFactory = createPickingWorker } = {}) {
|
||||
this.#core = core;
|
||||
this.#factory = pickingWorkerFactory;
|
||||
this.#onSnapshot = event => this.#installSnapshot(event.detail);
|
||||
this.#onPublished = event => this.#publish(event.detail?.epoch);
|
||||
core.addEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
|
||||
core.addEventListener?.(PUBLISHED_EVENT, this.#onPublished);
|
||||
if (core.renderDataSnapshot) this.#installSnapshot(core.renderDataSnapshot);
|
||||
class Handle {
|
||||
constructor(array, row = 0) {
|
||||
this.array = array;
|
||||
this.row = row;
|
||||
}
|
||||
|
||||
fromImportedScene(result) {
|
||||
if (!result || !Array.isArray(result.meshes)) throw new TypeError("invalid imported scene");
|
||||
return result.meshes.map((mesh) => new Mesh(TOKEN, this.#core, mesh));
|
||||
}
|
||||
|
||||
async pickRay(origin, direction, options) {
|
||||
const result = await this.#pickRay(origin, direction, options);
|
||||
return {
|
||||
...result,
|
||||
hits: result.hits.map((hit) => ({
|
||||
...hit,
|
||||
instance: new Instance(TOKEN, this.#core, hit.instance),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
#installSnapshot(snapshot) {
|
||||
try {
|
||||
if (snapshot?.controlVersion !== 1 || snapshot?.schemaVersion !== 2) throw new Error("version");
|
||||
this.#snapshot = snapshot;
|
||||
this.#reader = new SnapshotReader(snapshot.memory, snapshot.controlPtr);
|
||||
this.#epoch = this.#reader.latest().epoch;
|
||||
this.#worker?.postMessage({ type: "init", ...snapshot });
|
||||
} catch {
|
||||
this.#disable("PICK_PROTOCOL_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
#publish(epoch) {
|
||||
if (this.#disposed || !this.#reader) return;
|
||||
try {
|
||||
this.#epoch = this.#reader.latest().epoch;
|
||||
this.#worker?.postMessage({ type: "update", epoch: this.#epoch || (epoch >>> 0) });
|
||||
} catch {
|
||||
this.#disable("PICK_PROTOCOL_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
#ensureWorker() {
|
||||
if (this.#worker) return true;
|
||||
if (!this.#reader || !this.#factory) return false;
|
||||
try {
|
||||
this.#worker = this.#factory();
|
||||
this.#worker.addEventListener("message", event => this.#workerMessage(event.data));
|
||||
this.#worker.addEventListener("error", () => this.#disable("PICK_WORKER_ERROR"));
|
||||
this.#worker.addEventListener("messageerror", () => this.#disable("PICK_WORKER_ERROR"));
|
||||
this.#worker.start?.();
|
||||
this.#worker.postMessage({ type: "init", ...this.#snapshot });
|
||||
if (this.#epoch) this.#worker.postMessage({ type: "update", epoch: this.#epoch });
|
||||
return true;
|
||||
} catch {
|
||||
this.#disable("PICK_WORKER_ERROR");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#disable(code) {
|
||||
const error = new RendererError(code);
|
||||
for (const pending of this.#picks.values()) pending.reject(error);
|
||||
this.#picks.clear();
|
||||
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
|
||||
this.#worker = null;
|
||||
}
|
||||
|
||||
#workerMessage(message) {
|
||||
if (message?.type === "fatal") { this.#disable(message.code || "PICK_WORKER_ERROR"); return; }
|
||||
if (message?.type !== "pick") return;
|
||||
const pending = this.#picks.get(message.request);
|
||||
if (!pending) return;
|
||||
this.#picks.delete(message.request);
|
||||
let latest;
|
||||
try { latest = this.#reader.latest().epoch; this.#epoch = latest; }
|
||||
catch { pending.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); this.#disable("PICK_PROTOCOL_MISMATCH"); return; }
|
||||
if (message.stale || pending.epoch !== message.epoch || message.epoch !== latest) {
|
||||
if (!pending.retried && latest) this.#sendPick({ ...pending, retried: true }, latest);
|
||||
else pending.reject(new RendererError("PICK_STALE"));
|
||||
return;
|
||||
}
|
||||
pending.resolve({
|
||||
epoch: latest,
|
||||
hits: (message.hits || []).map(hit => ({
|
||||
instance: [hit.slot >>> 0, hit.generation >>> 0],
|
||||
distance: hit.distance,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
#sendPick(pending, epoch) {
|
||||
const request = this.#next++ >>> 0 || this.#next++;
|
||||
pending.epoch = epoch;
|
||||
this.#picks.set(request, pending);
|
||||
try {
|
||||
this.#worker.postMessage({
|
||||
type: "pick", request, epoch,
|
||||
origin: pending.origin, direction: pending.direction,
|
||||
maxDistance: pending.maxDistance, maxHits: pending.maxHits,
|
||||
});
|
||||
} catch {
|
||||
this.#picks.delete(request);
|
||||
pending.reject(new RendererError("PICK_WORKER_ERROR"));
|
||||
}
|
||||
}
|
||||
|
||||
#pickRay(origin, direction, { maxDistance = Infinity, maxHits = 1 } = {}) {
|
||||
const vector = (value, name) => {
|
||||
if (!value || value.length !== 3 || [...value].some(x => typeof x !== "number" || !Number.isFinite(x)))
|
||||
throw new TypeError(`${name} must contain 3 finite numbers`);
|
||||
return [...value];
|
||||
};
|
||||
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.#ensureWorker()) return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
|
||||
let epoch;
|
||||
try { epoch = this.#reader.latest().epoch; this.#epoch = 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));
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
this.#core.removeEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
|
||||
this.#core.removeEventListener?.(PUBLISHED_EVENT, this.#onPublished);
|
||||
try { this.#worker?.postMessage?.({ type: "dispose" }); } catch { /* best effort */ }
|
||||
this.#disable("DISPOSED");
|
||||
}
|
||||
}
|
||||
|
||||
export class Mesh {
|
||||
#core; #handle; #defaultInstance; #defaultType;
|
||||
|
||||
constructor(token, core, descriptor) {
|
||||
if (token !== TOKEN) throw new TypeError("Mesh cannot be constructed directly");
|
||||
this.#core = core;
|
||||
this.#handle = Object.freeze([...descriptor.handle]);
|
||||
this.#defaultInstance = new Instance(TOKEN, core, descriptor.defaultInstance);
|
||||
this.#defaultType = Object.freeze([...descriptor.defaultType]);
|
||||
}
|
||||
|
||||
get handle() { return this.#handle; }
|
||||
get defaultInstance() { return this.#defaultInstance; }
|
||||
|
||||
async createInstance(transform, { type = this.#defaultType } = {}) {
|
||||
return new Instance(TOKEN, this.#core, await this.#core.createInstance(this.#handle, transform, { type }));
|
||||
}
|
||||
}
|
||||
|
||||
export class Instance {
|
||||
#core; #handle; #dead = false;
|
||||
|
||||
constructor(token, core, handle) {
|
||||
if (token !== TOKEN) throw new TypeError("Instance cannot be constructed directly");
|
||||
this.#core = core;
|
||||
this.#handle = Object.freeze([...handle]);
|
||||
}
|
||||
|
||||
get handle() { return this.#handle; }
|
||||
#live() { if (this.#dead) throw new Error("STALE_HANDLE"); }
|
||||
setType(words) { this.#live(); this.#core.setInstanceType(this.#handle, words); }
|
||||
setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); }
|
||||
async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; }
|
||||
}
|
||||
|
||||
function requireArray(core, name, { domain, scalar, lanes }) {
|
||||
if (!core?.array) throw new TypeError("core must implement the Yawn shared render-data protocol");
|
||||
const array = core.array(name);
|
||||
if (array.domain !== domain || array.scalar !== scalar || array.lanes !== lanes)
|
||||
throw new RendererError("SOA_PROTOCOL_MISMATCH");
|
||||
return array;
|
||||
}
|
||||
|
||||
function vector(value, length, name) {
|
||||
if (!value || value.length !== length || [...value].some(item => typeof item !== "number" || !Number.isFinite(item)))
|
||||
throw new TypeError(`${name} must contain ${length} finite numbers`);
|
||||
return Array.from(value);
|
||||
}
|
||||
|
||||
function finite(value, name) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Conventional camera properties backed by the canonical SIMD-width camera SOA row. */
|
||||
export class CameraHandle {
|
||||
#array;
|
||||
|
||||
constructor(core) {
|
||||
this.#array = requireArray(core, "camera.state", { domain: "fixed", scalar: "f32", lanes: 16 });
|
||||
}
|
||||
|
||||
get state() { return this.#array.read(0); }
|
||||
set state(value) { this.#write(vector(value, 16, "state")); }
|
||||
get position() { return this.state.slice(0, 3); }
|
||||
set position(value) { this.update({ position: value }); }
|
||||
get target() { return this.state.slice(4, 7); }
|
||||
set target(value) { this.update({ target: value }); }
|
||||
get up() { return this.state.slice(8, 11); }
|
||||
set up(value) { this.update({ up: value }); }
|
||||
get fovY() { return this.state[12]; }
|
||||
set fovY(value) { this.update({ fovY: value }); }
|
||||
get aspect() { return this.state[13]; }
|
||||
set aspect(value) { this.update({ aspect: value }); }
|
||||
get near() { return this.state[14]; }
|
||||
set near(value) { this.update({ near: value }); }
|
||||
get far() { return this.state[15]; }
|
||||
set far(value) { this.update({ far: value }); }
|
||||
|
||||
update(properties = {}) {
|
||||
if (!properties || typeof properties !== "object") throw new TypeError("camera properties must be an object");
|
||||
const known = new Set(["position", "target", "up", "fovY", "aspect", "near", "far"]);
|
||||
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown camera property '${key}'`);
|
||||
get state() { return this.array.read(this.row); }
|
||||
set state(value) { this.array.write(this.row, value); }
|
||||
patch(offset, values) {
|
||||
const state = this.state;
|
||||
if (properties.position !== undefined) state.splice(0, 3, ...vector(properties.position, 3, "position"));
|
||||
if (properties.target !== undefined) state.splice(4, 3, ...vector(properties.target, 3, "target"));
|
||||
if (properties.up !== undefined) state.splice(8, 3, ...vector(properties.up, 3, "up"));
|
||||
if (properties.fovY !== undefined) state[12] = finite(properties.fovY, "fovY");
|
||||
if (properties.aspect !== undefined) state[13] = finite(properties.aspect, "aspect");
|
||||
if (properties.near !== undefined) state[14] = finite(properties.near, "near");
|
||||
if (properties.far !== undefined) state[15] = finite(properties.far, "far");
|
||||
this.#write(state);
|
||||
return this;
|
||||
}
|
||||
|
||||
lookAt(position, target, { up = this.up } = {}) {
|
||||
return this.update({ position, target, up });
|
||||
}
|
||||
|
||||
#write(state) {
|
||||
const offset = state.slice(0, 3).map((value, axis) => state[4 + axis] - value);
|
||||
const up = state.slice(8, 11);
|
||||
const cross = [
|
||||
offset[1] * up[2] - offset[2] * up[1],
|
||||
offset[2] * up[0] - offset[0] * up[2],
|
||||
offset[0] * up[1] - offset[1] * up[0],
|
||||
];
|
||||
if (Math.hypot(...offset) < 0.1 || Math.hypot(...up) === 0 || Math.hypot(...cross) === 0)
|
||||
throw new RangeError("camera position, target, and up do not define a view");
|
||||
if (!(state[12] > 0 && state[12] < Math.PI) || state[13] <= 0 || state[14] <= 0 || state[15] <= state[14])
|
||||
throw new RangeError("camera projection is invalid");
|
||||
state[3] = state[7] = 1;
|
||||
state[11] = 0;
|
||||
this.#array.write(0, state);
|
||||
state.splice(offset, values.length, ...values);
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
const FLOAT_WORD = new ArrayBuffer(4);
|
||||
const FLOAT_VIEW = new Float32Array(FLOAT_WORD);
|
||||
const WORD_VIEW = new Uint32Array(FLOAT_WORD);
|
||||
function wordToFloat(word) { WORD_VIEW[0] = word; return FLOAT_VIEW[0]; }
|
||||
function floatToWord(value) { FLOAT_VIEW[0] = value; return WORD_VIEW[0]; }
|
||||
|
||||
/** Creates scene-scoped material objects over packed material SOA rows. */
|
||||
export class MaterialHandles {
|
||||
#array;
|
||||
|
||||
constructor(core) {
|
||||
this.#array = requireArray(core, "material.state", { domain: "fixed", scalar: "u32", lanes: 28 });
|
||||
}
|
||||
|
||||
fromImportedScene(result) {
|
||||
if (!result || !Array.isArray(result.materials)) throw new TypeError("invalid imported scene");
|
||||
return result.materials.map(material => this.get(material?.key));
|
||||
}
|
||||
|
||||
get(key) {
|
||||
if (!Number.isInteger(key) || key < 0 || key > 0xffffffff || key >= this.#array.length)
|
||||
throw new RangeError("material key is outside the shared material rows");
|
||||
return new MaterialHandle(TOKEN, this.#array, key);
|
||||
/** Conventional camera values over one caller-owned shared row. */
|
||||
export class CameraHandle extends Handle {
|
||||
static async create(core, name = "camera") {
|
||||
const array = await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" });
|
||||
const camera = new CameraHandle(array);
|
||||
camera.state = [0, 0, 4, 0, 0, 0, 0, 0, 0, 1, 0, 0, Math.PI / 3, 1, 0.1, 1000];
|
||||
return camera;
|
||||
}
|
||||
get position() { return this.state.slice(0, 3); }
|
||||
set position(value) { this.patch(0, value); }
|
||||
get target() { return this.state.slice(4, 7); }
|
||||
set target(value) { this.patch(4, value); }
|
||||
}
|
||||
|
||||
export class MaterialHandle {
|
||||
#array; #key;
|
||||
|
||||
constructor(token, array, key) {
|
||||
if (token !== TOKEN) throw new TypeError("MaterialHandle cannot be constructed directly");
|
||||
this.#array = array;
|
||||
this.#key = key;
|
||||
/** Conventional material properties over one eight-float shared row. */
|
||||
export class MaterialHandle extends Handle {
|
||||
static async create(core, name = "material") {
|
||||
const material = new MaterialHandle(await core.allocateRows({ name, rows: 1, stride: 32, format: "f32" }));
|
||||
material.state = [1, 1, 1, 1, 0, 1, 0, 0];
|
||||
return material;
|
||||
}
|
||||
get baseColor() { return this.state.slice(0, 4); }
|
||||
set baseColor(value) { this.patch(0, value); }
|
||||
get metallic() { return this.state[4]; }
|
||||
set metallic(value) { this.patch(4, [value]); }
|
||||
get roughness() { return this.state[5]; }
|
||||
set roughness(value) { this.patch(5, [value]); }
|
||||
}
|
||||
|
||||
get key() { return this.#key; }
|
||||
get baseColor() { return this.#floats(0, 4); }
|
||||
set baseColor(value) { this.update({ baseColor: value }); }
|
||||
get emissive() { return this.#floats(4, 3); }
|
||||
set emissive(value) { this.update({ emissive: value }); }
|
||||
get metallic() { return this.#float(8); }
|
||||
set metallic(value) { this.update({ metallic: value }); }
|
||||
get roughness() { return this.#float(9); }
|
||||
set roughness(value) { this.update({ roughness: value }); }
|
||||
get normalScale() { return this.#float(10); }
|
||||
set normalScale(value) { this.update({ normalScale: value }); }
|
||||
get occlusionStrength() { return this.#float(11); }
|
||||
set occlusionStrength(value) { this.update({ occlusionStrength: value }); }
|
||||
get alphaCutoff() { return this.#float(13); }
|
||||
set alphaCutoff(value) { this.update({ alphaCutoff: value }); }
|
||||
get ior() { return this.#float(14); }
|
||||
set ior(value) { this.update({ ior: value }); }
|
||||
|
||||
update(properties = {}) {
|
||||
if (!properties || typeof properties !== "object") throw new TypeError("material properties must be an object");
|
||||
const known = new Set(["baseColor", "emissive", "metallic", "roughness", "normalScale", "occlusionStrength", "alphaCutoff", "ior"]);
|
||||
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown material property '${key}'`);
|
||||
const words = this.#array.read(this.#key);
|
||||
const setFloat = (lane, value, name) => { words[lane] = floatToWord(finite(value, name)); };
|
||||
if (properties.baseColor !== undefined)
|
||||
vector(properties.baseColor, 4, "baseColor").forEach((value, lane) => setFloat(lane, value, "baseColor"));
|
||||
if (properties.emissive !== undefined)
|
||||
vector(properties.emissive, 3, "emissive").forEach((value, lane) => setFloat(4 + lane, value, "emissive"));
|
||||
for (const [name, lane] of [["metallic", 8], ["roughness", 9], ["normalScale", 10], ["occlusionStrength", 11], ["alphaCutoff", 13]]) {
|
||||
if (properties[name] !== undefined) setFloat(lane, properties[name], name);
|
||||
}
|
||||
if (properties.metallic !== undefined && !(properties.metallic >= 0 && properties.metallic <= 1)) throw new RangeError("metallic must be in [0, 1]");
|
||||
if (properties.roughness !== undefined && !(properties.roughness >= 0 && properties.roughness <= 1)) throw new RangeError("roughness must be in [0, 1]");
|
||||
if (properties.occlusionStrength !== undefined && !(properties.occlusionStrength >= 0 && properties.occlusionStrength <= 1)) throw new RangeError("occlusionStrength must be in [0, 1]");
|
||||
if (properties.alphaCutoff !== undefined && !(properties.alphaCutoff >= 0 && properties.alphaCutoff <= 1)) throw new RangeError("alphaCutoff must be in [0, 1]");
|
||||
if (properties.ior !== undefined) {
|
||||
const ior = finite(properties.ior, "ior");
|
||||
if (ior !== 0 && ior < 1) throw new RangeError("ior must be 0 or at least 1");
|
||||
setFloat(14, ior, "ior");
|
||||
setFloat(15, ior === 0 ? 1 : ((ior - 1) / (ior + 1)) ** 2, "ior");
|
||||
}
|
||||
this.#array.write(this.#key, words);
|
||||
return this;
|
||||
/** Conventional mesh transform over one SIMD-aligned shared row. */
|
||||
export class MeshHandle extends Handle {
|
||||
static async create(core, name = "mesh") {
|
||||
const mesh = new MeshHandle(await core.allocateRows({ name, rows: 1, stride: 64, format: "f32" }));
|
||||
mesh.transform = identity;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
#float(lane) { return wordToFloat(this.#array.read(this.#key)[lane]); }
|
||||
#floats(start, length) { return this.#array.read(this.#key).slice(start, start + length).map(wordToFloat); }
|
||||
get transform() { return this.state; }
|
||||
set transform(value) { this.state = value; }
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/** Shared render-data snapshot protocol consumed by the mesh-handle BVH worker. */
|
||||
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