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:
@@ -1,7 +1,10 @@
|
||||
const TOKEN = Symbol("yawn mesh handle addon");
|
||||
import { RendererError } from "@yawn/core";
|
||||
import { SnapshotReader } from "./snapshot.js";
|
||||
|
||||
/** Creates the optional snapshot/BVH worker used by core's picking protocol. */
|
||||
export const createPickingWorker = () => new Worker(
|
||||
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" },
|
||||
);
|
||||
@@ -9,9 +12,17 @@ export const createPickingWorker = () => new Worker(
|
||||
/** 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) {
|
||||
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);
|
||||
}
|
||||
|
||||
fromImportedScene(result) {
|
||||
@@ -20,7 +31,7 @@ export class MeshHandles {
|
||||
}
|
||||
|
||||
async pickRay(origin, direction, options) {
|
||||
const result = await this.#core.pickRay(origin, direction, options);
|
||||
const result = await this.#pickRay(origin, direction, options);
|
||||
return {
|
||||
...result,
|
||||
hits: result.hits.map((hit) => ({
|
||||
@@ -29,6 +40,124 @@ export class MeshHandles {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
#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 {
|
||||
@@ -65,3 +194,169 @@ export class Instance {
|
||||
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}'`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user