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
+1 -1
View File
@@ -183,7 +183,7 @@ function sampleFps(time = performance.now()) {
try {
const core = current?.scene?.core ?? current?.core;
if (!core) throw new Error();
const frame = Number(core.array("info").row(0)[1]);
const frame = Number(core.array("signals").row(0)[1]);
if (frame < sampledFrame) sampledAt = 0;
if (!sampledAt) {
sampledFrame = frame;
+8 -58
View File
@@ -270,86 +270,37 @@ log("HDR → exposure → color grading → FXAA → canvas");
return { scene, mesh, exposure, grade, fxaa, dispose: () => scene.dispose() };`;
const importing = `import { AmbientLight, ArcRotateCamera, Picking, Scene, importGltf } from "@yawn/handles";
const importing = `import { ArcRotateCamera, Picking, Scene, importGltf } from "@yawn/handles";
const scene = new Scene(canvas, { hdr: true, arenaBytes: 384 * 1024 * 1024 });
await scene.ready;
await scene.core.pause();
log("Importing /models/sponza.glb in the importer worker…");
const meshes = await importGltf(scene, "/models/sponza.glb");
const minimum = [Infinity, Infinity, Infinity];
const maximum = [-Infinity, -Infinity, -Infinity];
const rotate = (q, v) => {
const t = [
2 * (q[1] * v[2] - q[2] * v[1]),
2 * (q[2] * v[0] - q[0] * v[2]),
2 * (q[0] * v[1] - q[1] * v[0]),
];
return [
v[0] + q[3] * t[0] + q[1] * t[2] - q[2] * t[1],
v[1] + q[3] * t[1] + q[2] * t[0] - q[0] * t[2],
v[2] + q[3] * t[2] + q[0] * t[1] - q[1] * t[0],
];
};
const worldBounds = (mesh) => {
const bounds = scene.array("bounds").row(mesh.id);
const low = [Infinity, Infinity, Infinity];
const high = [-Infinity, -Infinity, -Infinity];
for (let corner = 0; corner < 8; corner++) {
const local = [0, 1, 2].map((lane) =>
bounds[(corner & (1 << lane) ? 4 : 0) + lane] * mesh.scale[lane]);
const point = rotate(mesh.quaternion, local).map((value, lane) => value + mesh.position[lane]);
for (let lane = 0; lane < 3; lane++) {
low[lane] = Math.min(low[lane], point[lane]);
high[lane] = Math.max(high[lane], point[lane]);
}
}
return [low, high];
};
for (const mesh of meshes) {
const [low, high] = worldBounds(mesh);
for (let lane = 0; lane < 3; lane++) {
minimum[lane] = Math.min(minimum[lane], low[lane]);
maximum[lane] = Math.max(maximum[lane], high[lane]);
}
}
const center = minimum.map((value, lane) => (value + maximum[lane]) * 0.5);
const extent = Math.max(...maximum.map((value, lane) => value - minimum[lane]));
const scale = 1.5 / extent;
for (const mesh of meshes) {
mesh.position = mesh.position.map((value, lane) => (value - center[lane]) * scale);
mesh.scale = mesh.scale.map((value) => value * scale);
}
const target = [0, 8, 0];
const camera = new ArcRotateCamera(scene, {
targetPosition: [-0.3, 0, 0],
targetPosition: target,
alpha: Math.PI / 2,
beta: Math.PI / 2,
radius: 0.3,
near: 0.01,
far: 10,
beta: Math.PI / 3,
radius: 30,
near: 0.1,
far: 100,
aspect: canvas.width / canvas.height,
controls: { element: canvas, pointer: true },
});
await camera.ready;
const ambient = new AmbientLight(scene, { color: [0.7, 0.8, 1], intensity: 0.7 });
await ambient.ready;
const picking = new Picking(scene);
await picking.ready;
const [targetLow, targetHigh] = worldBounds(meshes[0]);
const pickTarget = targetLow.map((value, lane) => (value + targetHigh[lane]) * 0.5);
const pick = async () => {
const origin = Array.from(camera.position);
const direction = pickTarget.map((value, lane) => value - origin[lane]);
const direction = target.map((value, lane) => value - origin[lane]);
const length = Math.hypot(...direction);
const hits = await picking.pick(origin, direction.map((value) => value / length));
log(\`Imported \${meshes.length} primitives; BVH ray returned \${hits.length} hit(s).\`);
};
canvas.addEventListener("click", pick);
await pick();
const play = setTimeout(() => scene.core.play(), 0);
return {
scene,
@@ -357,7 +308,6 @@ return {
camera,
picking,
async dispose() {
clearTimeout(play);
canvas.removeEventListener("click", pick);
picking.dispose();
await camera.dispose();
+1 -1
View File
@@ -11,7 +11,7 @@ infrequent worker messages hot shared mutations
│ create/delete rows │ │ transforms │
│ allocate/delete object slot │ │ cameras/materials │
│ compile/switch graph │ │ lights/app data │
│ play/pause/set FPS │ │ info.skipRender
│ play/pause/set FPS │ │ signals + row data
└──────────────┬───────────────┘ └──────────┬───────────┘
└─────────────────┬─────────────────────┘
+1 -1
View File
@@ -24,7 +24,7 @@ const scene = new Scene(canvas, { hdr: true, fps: 60 });
await scene.ready;
```
Omit `fps` to render as fast as the browser and GPU allow. `Scene` initializes conventional SOA rows and loads one clustered-forward HDR render graph. The core itself still starts with only its eight-float `info` row.
Omit `fps` to render as soon as shared data changes. `Scene` initializes conventional SOA rows and loads one clustered-forward HDR render graph. The core itself still starts with only its eight-float `signals` row.
## 3. Add a triangle
+1 -1
View File
@@ -34,7 +34,7 @@ If row allocations relocated since `Picking` was created, refresh its shared des
await picking.refresh();
```
The playground below imports the repository's full LFS-backed `sponza.glb` in the importer worker, hydrates all 138 primitives, frames them with an arc camera, and sends a real ray to the BVH worker. Click the preview to pick again.
The playground below imports the repository's full LFS-backed `sponza.glb` in the importer worker, preserves its authored transforms, places an arc camera in its coordinate system, and sends a real ray to the BVH worker. Click the preview to pick again.
<Playground example="importing" />
+9 -7
View File
@@ -29,21 +29,23 @@ particles.row(42).set([1, 0, 0, 0]);
Rows are 16-byte-stride-aligned and arena allocations are 64-byte aligned. Formats are `f32`, `u32`, or `i32`.
## Timing and render skipping
## Timing and render signals
Core always creates `info` as:
Core always creates `signals` as:
```text
[deltaTime, frameCount, elapsedTime, targetFps, skipRender, 0, 0, 0]
[deltaTime, frameCount, elapsedTime, targetFps, skipRender, sabDirty, bundleDirty, 0]
```
```ts
const info = scene.array("info").row(0);
info[4] = 1; // keep timing, skip GPU work
info[4] = 0; // resume rendering
const signals = scene.array("signals").row(0);
signals[4] = 1; // keep timing, skip GPU work
signals[4] = 0; // resume and request a frame
```
Use messages for rare control changes (`setFps`, graph updates, allocation); use SAB writes for existing hot state.
The handles layer sets `sabDirty` for writes made through `scene.array(...)` and its node, camera, mesh, material, and light APIs. It sets `bundleDirty` before rebuilding a graph whose recorded pipeline, bindings, geometry, or draw commands changed. Core clears `sabDirty` when it starts a frame; switching to the replacement loadout clears `bundleDirty` and requests a fresh frame.
Use messages for rare control changes (`setFps`, graph updates, allocation); use SAB writes for existing hot state. Code using `@yawn/core` directly must set `signals[5] = 1` after completing its own row writes.
<script setup>
import Playground from "../.vitepress/Playground.vue";