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
+300 -5
View File
@@ -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); }
}