Render only when shared state changes

Add shared-data and bundle invalidation signals, and have handles publish them automatically for SAB and graph mutations.

Document the raw core worker protocol in a dedicated VitePress site and simplify the glTF import and picking example.

Amp-Thread-ID: https://ampcode.com/threads/T-01a01ff8-b91f-724f-8952-f07c6b5042fd
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-21 04:02:33 +00:00
co-authored by heaust
parent 481d105f19
commit 0e6d7e367c
24 changed files with 761 additions and 101 deletions
+4
View File
@@ -61,7 +61,9 @@ export class Mesh extends Node {
set material(value: PBRMaterial) {
if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene");
this.scene.markDirty(true);
this.scene.array("meshInfo").row(this.id)[1] = value.id;
void this.scene.updateRenderGraph().catch(() => undefined);
}
clone(options: Omit<MeshOptions, "geometryId" | "vertexData"> = {}) {
@@ -83,6 +85,7 @@ export class Mesh extends Node {
this.scene.releaseGeometry(original);
this.scene.referenceGeometry(this.geometryId);
this.instanceOf = this.id;
this.scene.markDirty(true);
this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, this.id]);
}
if (kind === "positions") this.vertexCount = data.length / 3;
@@ -97,6 +100,7 @@ export class Mesh extends Node {
const list = Array.isArray(faces) ? faces : [faces];
const maximum = Math.max(...list);
const array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32");
this.scene.markDirty(true);
for (const face of list) {
if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
array.row(face)[0] = material.id + 1;
+70 -1
View File
@@ -326,6 +326,14 @@ function serialize(graph: object) {
return `(yawn-graph 1 ${encode(graph)})`;
}
const typedArrayMutators = new Set([
"copyWithin",
"fill",
"reverse",
"set",
"sort",
]);
/** The conventional single-loadout scene layer; hot values always remain direct SAB writes. */
export class Scene {
readonly core: YawnCore;
@@ -343,6 +351,9 @@ export class Scene {
#nextTexture = 0;
#graphBatchDepth = 0;
#graphBatchDirty = false;
#signals?: Float32Array;
#arrays = new WeakMap<object, object>();
#views = new WeakMap<object, object>();
constructor(
canvas: HTMLCanvasElement,
@@ -358,6 +369,7 @@ export class Scene {
async #initialize(fps?: number) {
await this.core.ready;
this.#signals = this.core.array("signals").row(0);
for (const [name, stride, format] of rows)
await this.core.createRows({ name, rows: 1, stride, format });
await this.core.createRows({
@@ -379,7 +391,62 @@ export class Scene {
}
array(name: string) {
return this.core.array(name);
const array = this.core.array(name);
const current = this.#arrays.get(array);
if (current) return current as typeof array;
let proxy: typeof array;
proxy = new Proxy(array, {
get: (target, property) => {
if (property === "row")
return (index: number) => this.#mutable(target.row(index));
if (property === "view") return this.#mutable(target.view);
if (property === "write")
return (index: number, values: ArrayLike<number>) => {
target.write(index, values);
this.markDirty();
return proxy;
};
const value = Reflect.get(target, property, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
this.#arrays.set(array, proxy);
return proxy;
}
#mutable<T extends Float32Array | Uint32Array | Int32Array>(view: T): T {
const current = this.#views.get(view);
if (current) return current as T;
let proxy: T;
proxy = new Proxy(view, {
get: (target, property) => {
if (property === "subarray")
return (begin?: number, end?: number) =>
this.#mutable(target.subarray(begin, end) as T);
const value = Reflect.get(target, property, target);
if (typeof value !== "function") return value;
if (property === "constructor") return value;
if (!typedArrayMutators.has(String(property))) return value.bind(target);
return (...arguments_: unknown[]) => {
const result = Reflect.apply(value, target, arguments_);
this.markDirty();
return result === target ? proxy : result;
};
},
set: (target, property, value) => {
const written = Reflect.set(target, property, value, target);
if (written) this.markDirty();
return written;
},
});
this.#views.set(view, proxy);
return proxy;
}
markDirty(bundle = false) {
if (!this.#signals) return;
this.#signals[5] = 1;
if (bundle) this.#signals[6] = 1;
}
async ensureRows(
@@ -596,6 +663,7 @@ export class Scene {
16,
integer ? "u32" : "f32",
);
if (updateGraph) this.markDirty(true);
const view = target.view;
view.fill(0);
if (integer || width === 4) view.set(data);
@@ -642,6 +710,7 @@ export class Scene {
}
updateRenderGraph() {
this.markDirty(true);
if (this.#graphBatchDepth) {
this.#graphBatchDirty = true;
return Promise.resolve();
+1 -1
View File
@@ -35,7 +35,7 @@ export class Picking {
return this.#request("sync", {
shares: Object.fromEntries(
[
"info",
"signals",
"nodes",
"nodePositions",
"nodeQuaternions",
+2 -2
View File
@@ -102,7 +102,7 @@ function rebuild() {
boxes.push({ id, min, max });
}
root = build(boxes);
builtFrame = Number(view("info")?.[1] ?? builtFrame + 1);
builtFrame = Number(view("signals")?.[1] ?? builtFrame + 1);
}
function intersection(
@@ -149,7 +149,7 @@ addEventListener("message", ({ data }) => {
return;
}
if (data.type === "pick") {
const frame = Number(view("info")?.[1] ?? -1);
const frame = Number(view("signals")?.[1] ?? -1);
if (frame !== builtFrame) rebuild();
const inverse = data.direction.map((lane: number) => 1 / lane);
const hits: { id: number; distance: number }[] = [];