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>
363 lines
16 KiB
JavaScript
363 lines
16 KiB
JavaScript
import { RendererError } from "@yawn/core";
|
|
import { SnapshotReader } from "./snapshot.js";
|
|
|
|
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);
|
|
}
|
|
|
|
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}'`);
|
|
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); }
|
|
}
|