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
+82
View File
@@ -0,0 +1,82 @@
# Boot and transport
## Isolation and deployment
`SharedArrayBuffer` requires a secure, cross-origin-isolated page. Serve the document and worker/package assets over HTTPS (localhost is also a secure context) with these response headers:
```http
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
Every cross-origin subresource must satisfy COEP (normally CORS or `Cross-Origin-Resource-Policy`). Check `window.crossOriginIsolated === true`, `HTMLCanvasElement.prototype.transferControlToOffscreen`, and `navigator.gpu` before boot. This site's VitePress config applies both headers in dev and preview; production hosting must do the same.
The worker imports `./pkg/yawn_core.js` relative to `core/worker.js`, so deploy the wasm-pack output beside it. The WASM build must use shared memory; initialization otherwise replies `WASM_MEMORY_NOT_SHARED`.
## A robust raw client
```js
const canvas = document.querySelector("canvas");
if (!crossOriginIsolated) throw new Error("cross-origin isolation required");
if (!navigator.gpu) throw new Error("WebGPU required");
const worker = new Worker(new URL("../../core/worker.js", import.meta.url), {
type: "module",
name: "yawn-core-raw",
});
let nextRequest = 1;
const pending = new Map();
const profileListeners = new Set();
worker.addEventListener("message", ({ data: message }) => {
if (message?.type === "profile") {
for (const listener of profileListeners) listener(message.stats);
return;
}
const operation = pending.get(message?.request);
if (!operation) return; // late, duplicate, or foreign reply
pending.delete(message.request);
if (Object.prototype.hasOwnProperty.call(message, "error")) {
const error = Object.assign(new Error(message.error), { code: message.error });
operation.reject(error);
} else {
operation.resolve(message.result);
}
});
function failAll(code) {
for (const { reject } of pending.values())
reject(Object.assign(new Error(code), { code }));
pending.clear();
}
worker.addEventListener("error", () => failAll("WORKER_ERROR"));
worker.addEventListener("messageerror", () => failAll("WORKER_ERROR"));
function request(type, fields = {}, transfer = []) {
const request = nextRequest++;
return new Promise((resolve, reject) => {
pending.set(request, { resolve, reject });
try {
worker.postMessage({ type, request, ...fields }, transfer);
} catch (error) {
pending.delete(request);
reject(error);
}
});
}
const offscreen = canvas.transferControlToOffscreen();
const { buffer, rows } = await request(
"init",
{ canvas: offscreen, arenaBytes: 64 * 1024 * 1024 },
[offscreen],
);
```
Requests are `{ type: string, request: any, ...fields }`. Success is `{ request, result }`; void operations have `result: undefined`. Failure is `{ request, error: string }`. The worker merely echoes `request`, so use unique values. Requests can complete out of order. An unknown message type returns `MESSAGE`; all non-init operations before init return `UNINITIALIZED`.
`init` is one-shot per worker. Its `canvas` **must** be an `OffscreenCanvas` and must appear in the transfer list. `arenaBytes` reaches a Rust `u32`; use an integer from 64 through `2^32 - 64` in practical JS calls. The result contains the shared WASM memory buffer and all initial row descriptors (currently `signals`). Do not transfer the `SharedArrayBuffer`.
## Shutdown
Reject local pending promises, clear listeners/timers, and call `worker.terminate()`. There is no dispose request. Transferred canvases and `ImageBitmap`s cannot be reused by the sender. A fresh worker and canvas are required to restart after termination or failed initialization.
+72
View File
@@ -0,0 +1,72 @@
# Render graphs from raw JavaScript
## Wire encoder
`compile-graph` does not accept JSON. It accepts one S-expression: `(yawn-graph 1 VALUE)`. Objects are `(object (field KEY VALUE)...)`; arrays are `(array VALUE...)`. Strings use JSON quoting. Bare `true`, `false`, `null`, and JSON numbers become their corresponding values; other bare atoms become strings. Quote all object keys and application strings to avoid ambiguity.
```js
function graphValue(value) {
if (value === null) return "null";
if (Array.isArray(value)) return `(array ${value.map(graphValue).join(" ")})`;
if (typeof value === "object") {
return `(object ${Object.entries(value).map(([key, item]) =>
`(field ${JSON.stringify(key)} ${graphValue(item)})`
).join(" ")})`;
}
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "boolean") return String(value);
if (typeof value === "number" && Number.isFinite(value)) return String(value);
throw new TypeError("graph contains an unsupported value");
}
function encodeGraph(graph) {
return `(yawn-graph 1 ${graphValue(graph)})`;
}
```
Duplicate object fields, malformed/trailing expressions, the wrong tag/version, and empty lists fail with `GRAPH_WIRE`.
## Minimal complete triangle
This graph needs no row arrays. `canvas` is a special attachment and fragment target format; do not declare it as a texture.
```js
const triangle = {
id: "raw-triangle",
pipelines: {
render: [{
id: "triangle-pipeline",
code: `
struct Out { @builtin(position) position: vec4f, @location(0) color: vec3f }
@vertex fn vertex(@builtin(vertex_index) i: u32) -> Out {
var positions = array<vec2f, 3>(vec2f(0.0, 0.7), vec2f(-0.7, -0.7), vec2f(0.7, -0.7));
var colors = array<vec3f, 3>(vec3f(1,0,0), vec3f(0,1,0), vec3f(0,0,1));
var out: Out; out.position = vec4f(positions[i], 0, 1); out.color = colors[i]; return out;
}
@fragment fn fragment(in: Out) -> @location(0) vec4f { return vec4f(in.color, 1); }
`,
vertex: { entry: "vertex", buffers: [] },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
}],
},
passes: [{
id: "triangle",
type: "render",
pipeline: "triangle-pipeline",
color: [{ resource: "canvas", load: "clear", store: "store", clear: [0.02, 0.02, 0.03, 1] }],
draw: { vertices: 3, instances: 1 },
}],
};
const id = await request("compile-graph", { serialized: encodeGraph(triangle) });
await request("switch-loadout", { id }); // activates and requests the first frame
```
## Ordering and resources
`passes` declaration order is only a tie-breaker. Each pass's `after` IDs form a dependency DAG; compilation performs a stable-like first-ready topological ordering. Missing dependency → `GRAPH_DEPENDENCY`; cycle → `GRAPH_CYCLE`. Unused resources and pipelines are removed. Used textures receive lifetime slots; compatible transient textures with non-overlapping lifetimes can alias. Therefore do not expect an unused declaration to exist at runtime.
Adjacent compatible render passes may execute in one render pass/bundle. To merge they need matching attachments/sample count, previous `store`, next `load`, matching depth behavior, and the next pass must not bind its attachment as an input. Compute passes remain separate.
Buffers map an entire named SAB row array into a GPU buffer. Bindings are grouped by `group` and use the pipeline's automatically inferred WGSL layout. A binding resource ID may identify a buffer, texture view, or sampler. Vertex/index bindings must identify buffers. Color/depth attachments identify declared textures or special `canvas`. The WGSL declarations, usage flags, offsets, sizes, target formats, and attachment formats must agree; WebGPU validation failures can otherwise surface as generic worker/core errors.
See [Graph schema](/reference/graph-schema) for every field and default.
+80
View File
@@ -0,0 +1,80 @@
# Shared rows and invalidation
## Descriptor and views
Every descriptor is:
```js
{ name, rows, stride, format, offset, bytes }
```
`offset` and `bytes` are byte units into the returned WASM `SharedArrayBuffer`; `bytes === rows * stride`. `format` is exactly `"f32"`, `"u32"`, or `"i32"`. `stride` is bytes per row, at least 16 and divisible by 16. Allocations begin on 64-byte boundaries. A row is homogeneous—the selected scalar type covers its entire stride.
```js
const constructors = { f32: Float32Array, u32: Uint32Array, i32: Int32Array };
function viewFor(buffer, d) {
const Ctor = constructors[d.format];
if (!Ctor) throw new Error("unsupported row format");
return new Ctor(buffer, d.offset, d.bytes / Ctor.BYTES_PER_ELEMENT);
}
function rowFor(buffer, d, index) {
if (!Number.isInteger(index) || index < 0 || index >= d.rows) throw new RangeError();
const view = viewFor(buffer, d);
const width = d.stride / view.BYTES_PER_ELEMENT;
return view.subarray(index * width, (index + 1) * width);
}
```
The buffer stays the same, but a growing row array may relocate. Replace the cached descriptor—and recreate cached views—after every `create-rows`, batch result, and `allocate-object` result. `allocate-object` always returns `{ id, rows: descriptor }`, even without growth. Never infer an address from a prior descriptor.
## Slots
Creating rows establishes capacity but does not allocate objects. `allocate-object` returns a zero-based row ID. IDs increase until capacity is exhausted; allocation then grows to `id + 1`. Deleted IDs are kept in a LIFO free list and reused. `delete-object` zeroes the entire row and releases the ID. It rejects an inactive/duplicate ID. A row array cannot be deleted while any IDs are active, while `signals` cannot be allocated or deleted, and an array used by the active graph cannot be deleted.
Capacity does not shrink. Deletion makes descriptors/views invalid for application use even though stale bytes may remain in memory.
## Signals
`signals` is one `f32` row with 32-byte stride:
| Lane | Name | Meaning |
|---:|---|---|
| 0 | `deltaTime` | Seconds since the preceding accepted loop tick |
| 1 | `frameCount` | Frame counter represented as f32 |
| 2 | `elapsedTime` | Accumulated seconds |
| 3 | `targetFps` | Configured cap; 0 means uncapped |
| 4 | `skipRender` | Nonzero suppresses rendering |
| 5 | `sabDirty` | Nonzero requests a frame; consumed (set to 0) at frame start |
| 6 | `bundleDirty` | Nonzero suppresses stale bundles until a successful loadout switch |
| 7 | `reserved` | No current contract; leave unchanged/zero |
The core updates lanes 03 while playing. A render starts only when lanes 4 and 6 are zero and lane 5 is nonzero. Finish the row writes first, then set the dirty signal:
```js
const signalsF32 = rowFor(buffer, signalDescriptor, 0);
function publishRows(write) {
write(); // finish every row write
signalsF32[5] = 1; // request the frame last
}
async function replaceBundle(mutate, compileAndSwitch) {
signalsF32[6] = 1; // suppress the old bundle first
mutate();
signalsF32[5] = 1;
try {
await compileAndSwitch(); // successful switch clears lane 6 and requests a frame
} catch (error) {
// Keep lane 6 set: rendering stale bindings would be unsafe. Repair/retry or stop.
throw error;
}
}
```
The signals are invalidation state, not a lock or transaction boundary. If multiple threads can write one logical update, coordinate those writers so core cannot observe a partially updated value. Core marks lane 5 after object deletion, texture upload, and a successful switch, but direct SAB writes do not. Always publish lane 5 after direct data writes.
## Frame-sync versus loadout-sync
A graph buffer's `sync` defaults to `"frame"`. Before each actual render, the full named row array is copied SAB → GPU, so ordinary value edits only require `sabDirty`.
`sync: "loadout"` is copied only when GPU resources are activated: on `switch-loadout`, and when core refreshes an active graph because a used row array grows/is recreated. Direct edits afterward do **not** reach that GPU buffer. To change loadout-synced content reliably, set `bundleDirty`, mutate, and switch a compiled replacement loadout (the replacement may use a newly compiled graph with the same schema). Structural changes affecting buffer size, bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations also require bundle invalidation and a graph/loadout switch. This distinction matters because render commands are precompiled into render bundles.