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
+3 -1
View File
@@ -8,7 +8,7 @@ JSO or FXNode → AST → S-expression → worker messages → Rust/WebGPU
any thread → direct shared row writes ─────────────┘
```
The arena starts with only one eight-float `info` row for frame timing and direct SAB render skipping. Messages create or delete other `{ name, rows, stride, format }` arrays, allocate named slots, compile graphs, switch loadouts, and control render pacing. Existing render data is changed by writing `f32`, `u32`, or `i32` rows directly. Allocations are 64-byte aligned, row strides are multiples of 16 bytes, and compatible non-overlapping transient textures share physical allocations.
The arena starts with only one eight-float `signals` row for frame timing and render invalidation. Messages create or delete other `{ name, rows, stride, format }` arrays, allocate named slots, compile graphs, switch loadouts, and control render pacing. Existing render data is changed by writing `f32`, `u32`, or `i32` rows directly, then setting the shared-data dirty signal. Allocations are 64-byte aligned, row strides are multiples of 16 bytes, and compatible non-overlapping transient textures share physical allocations.
WGSL, pipelines, glTF import, and conventional mesh/camera/material handles live in `addons/`; core contains no shader or scene model.
@@ -17,3 +17,5 @@ npm start
```
This opens the docs. The complete runnable example is at `/playground`.
Run `npm run coredocs` for the raw worker-message, shared-memory, and render-graph reference intended for custom handles, editors, and direct SAB clients.
+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 }[] = [];
+9 -3
View File
@@ -155,7 +155,9 @@ impl Core {
self.data
.borrow_mut()
.delete_object(name, id)
.map_err(JsError::new)
.map_err(JsError::new)?;
self.data.borrow_mut().mark_dirty();
Ok(())
}
pub fn compile_graph(&self, source: &str) -> Result<String, JsError> {
@@ -171,7 +173,9 @@ impl Core {
self.store
.borrow_mut()
.switch(id, gpu, &self.data.borrow())
.map_err(|error| JsError::new(&error))
.map_err(|error| JsError::new(&error))?;
self.data.borrow_mut().loadout_ready();
Ok(())
}
pub fn upload_texture(
@@ -187,7 +191,9 @@ impl Core {
self.store
.borrow_mut()
.upload_texture(name, mip_level, image, gpu)
.map_err(|error| JsError::new(&error))
.map_err(|error| JsError::new(&error))?;
self.data.borrow_mut().mark_dirty();
Ok(())
}
pub fn delete_texture(&self, name: &str) {
+42 -10
View File
@@ -46,7 +46,8 @@ impl RenderData {
rows: HashMap::new(),
slots: HashMap::new(),
};
data.create_rows("info".into(), 1, 32, "f32".into())?;
data.create_rows("signals".into(), 1, 32, "f32".into())?;
data.write_signal(5, 1.0);
Ok(data)
}
@@ -72,7 +73,7 @@ impl RenderData {
if rows <= current.rows {
return Ok(current);
}
if name == "info" {
if name == "signals" {
return Err("ROWS_BUILTIN");
}
return self.grow_rows(current, rows);
@@ -95,7 +96,7 @@ impl RenderData {
}
pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> {
if name == "info" {
if name == "signals" {
return Err("ROWS_BUILTIN");
}
if self
@@ -128,7 +129,7 @@ impl RenderData {
}
pub fn allocate_object(&mut self, name: &str) -> Result<(u32, bool), &'static str> {
if name == "info" {
if name == "signals" {
return Err("ROWS_BUILTIN");
}
let slots = self.slots.get(name).ok_or("ROWS_UNKNOWN")?;
@@ -198,8 +199,8 @@ impl RenderData {
Ok(descriptor)
}
pub fn update_info(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) {
let start = self.arena_start + (self.rows["info"].offset - self.base) as usize;
pub fn update_signals(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) {
let start = self.arena_start + (self.rows["signals"].offset - self.base) as usize;
for (index, value) in [delta, frame as f32, elapsed, fps as f32]
.iter()
.enumerate()
@@ -209,10 +210,37 @@ impl RenderData {
}
}
pub fn skip_render(&self) -> bool {
let rows = &self.rows["info"];
let start = self.arena_start + (rows.offset - self.base) as usize + 16;
f32::from_le_bytes(self.arena[start..start + 4].try_into().unwrap()) == 1.0
pub fn render_pending(&self) -> bool {
self.signal(4) == 0.0 && self.signal(5) != 0.0 && self.signal(6) == 0.0
}
pub fn begin_render(&mut self) -> bool {
if !self.render_pending() {
return false;
}
self.write_signal(5, 0.0);
true
}
pub fn mark_dirty(&mut self) {
self.write_signal(5, 1.0);
}
pub fn loadout_ready(&mut self) {
self.write_signal(6, 0.0);
self.mark_dirty();
}
fn signal(&self, index: usize) -> f32 {
let rows = &self.rows["signals"];
let start = self.arena_start + (rows.offset - self.base) as usize + index * 4;
f32::from_le_bytes(self.arena[start..start + 4].try_into().unwrap())
}
fn write_signal(&mut self, index: usize, value: f32) {
let rows = &self.rows["signals"];
let start = self.arena_start + (rows.offset - self.base) as usize + index * 4;
self.arena[start..start + 4].copy_from_slice(&value.to_le_bytes());
}
fn reserve(&mut self, bytes: u32) -> Result<u32, &'static str> {
@@ -263,3 +291,7 @@ fn align(value: u32) -> Option<u32> {
.checked_add(ALIGNMENT - 1)
.map(|value| value & !(ALIGNMENT - 1))
}
#[cfg(test)]
#[path = "render_data_tests.rs"]
mod tests;
+32
View File
@@ -0,0 +1,32 @@
use super::RenderData;
#[test]
fn dirty_signal_is_consumed_once() {
let mut data = RenderData::new(64).unwrap();
assert!(data.rows("signals").is_some());
assert!(data.begin_render());
assert!(!data.begin_render());
data.mark_dirty();
assert!(data.begin_render());
assert!(!data.render_pending());
}
#[test]
fn skip_and_bundle_signals_hold_dirty_work() {
let mut data = RenderData::new(64).unwrap();
assert!(data.begin_render());
data.write_signal(4, 1.0);
data.mark_dirty();
assert!(!data.begin_render());
data.write_signal(4, 0.0);
assert!(data.begin_render());
data.write_signal(6, 1.0);
data.mark_dirty();
assert!(!data.begin_render());
data.loadout_ready();
assert!(data.begin_render());
}
+22 -10
View File
@@ -116,22 +116,34 @@ impl RenderLoop {
control.elapsed.set(elapsed);
let frame = control.frame.get().wrapping_add(1);
control.frame.set(frame);
let skip = {
{
let mut data = data.borrow_mut();
data.update_info(delta as f32, frame, elapsed as f32, control.fps.get());
data.skip_render()
};
let submission = if !skip {
data.update_signals(delta as f32, frame, elapsed as f32, control.fps.get());
}
let submission = if data.borrow().render_pending() {
if let (Some(gpu), Some(loadout)) =
(gpu.borrow_mut().as_mut(), store.borrow_mut().active_mut())
{
if data.borrow_mut().begin_render() {
let profile = control.profiling.get()
&& !control.profile_pending.get()
&& started >= control.profile_after.get();
gpu.render(loadout, &data.borrow(), profile)
.ok()
.flatten()
.map(|profile| (profile, gpu.adapter.clone(), gpu.width, gpu.height))
let result = {
let data = data.borrow();
gpu.render(loadout, &data, profile)
};
match result {
Ok(profile) => profile.map(|profile| {
(profile, gpu.adapter.clone(), gpu.width, gpu.height)
}),
Err(_) => {
data.borrow_mut().mark_dirty();
None
}
}
} else {
None
}
} else {
None
}
@@ -206,7 +218,7 @@ impl Wgpu {
self.surface.configure(&self.device, &self.config);
self.surface.get_current_texture().map_err(|_| "SURFACE")?
}
Err(wgpu::SurfaceError::Timeout) => return Ok(None),
Err(wgpu::SurfaceError::Timeout) => return Err("SURFACE".into()),
Err(_) => return Err("SURFACE".into()),
};
let surface_view = output
+42
View File
@@ -0,0 +1,42 @@
const isolationHeaders = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
export default {
title: "Yawn Core Wire API",
description: "Raw worker, shared-memory, and render-graph reference",
cleanUrls: true,
themeConfig: {
nav: [
{ text: "Boot", link: "/guide/boot" },
{ text: "Shared memory", link: "/guide/shared-memory" },
{ text: "Render graphs", link: "/guide/render-graphs" },
{ text: "Wire reference", link: "/reference/worker" },
],
sidebar: [
{
text: "Guides",
items: [
{ text: "Raw-core overview", link: "/" },
{ text: "Boot and transport", link: "/guide/boot" },
{ text: "Shared memory", link: "/guide/shared-memory" },
{ text: "Render graphs", link: "/guide/render-graphs" },
],
},
{
text: "Reference",
items: [
{ text: "Worker messages", link: "/reference/worker" },
{ text: "Graph schema", link: "/reference/graph-schema" },
{ text: "Errors and lifecycle", link: "/reference/errors" },
],
},
],
search: { provider: "local" },
},
vite: {
server: { headers: isolationHeaders },
preview: { headers: isolationHeaders },
},
};
+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.
+33
View File
@@ -0,0 +1,33 @@
---
layout: home
hero:
name: Yawn raw core
text: Worker, shared memory, and render graphs
tagline: The protocol reference for building an alternative handles layer or editor without YawnCore or @yawn/handles.
actions:
- theme: brand
text: Boot from scratch
link: /guide/boot
- theme: alt
text: Worker reference
link: /reference/worker
features:
- title: Raw transport
details: Start core/worker.js, correlate replies, and handle profiler events.
- title: Shared rows
details: Build typed views, allocate slots, and follow the dirty-lane protocol.
- title: Graph wire format
details: Encode, compile, and activate complete WebGPU render and compute graphs.
---
## Contract at a glance
The browser main thread owns the HTML canvas and starts the module worker. The worker initializes the WASM core and WebGPU against an `OffscreenCanvas`. All control operations are structured-clone messages. Bulk numeric state lives in one returned `SharedArrayBuffer`; graphs describe how named row arrays become GPU resources.
The application is responsible for three important contracts:
1. Correlate each reply by its `request` value; profiler messages are unsolicited.
2. Finish shared-memory writes before setting `signals[5]` (`sabDirty`).
3. Before a mutation that invalidates compiled render bundles, set `signals[6]` (`bundleDirty`), make the mutation, compile and switch a replacement graph, then let successful `switch-loadout` clear lane 6.
This site describes the current wire API, not the higher-level wrapper API.
+73
View File
@@ -0,0 +1,73 @@
# Errors and lifecycle
Worker failures are string codes in `{ request, error }`. GPU/browser validation can also produce implementation-originated error messages rather than a stable code; preserve the complete string in logs while branching only on documented codes.
## Transport and initialization
| Code | Meaning / response |
|---|---|
| `INIT` | Duplicate init, wrong canvas type, or invalid arena capacity. Start a fresh worker/canvas or fix capacity. |
| `WASM_MEMORY_NOT_SHARED` | WASM was built/deployed without shared memory or isolation failed. |
| `UNINITIALIZED` | Non-init request arrived before successful init. Serialize boot. |
| `MESSAGE` | Unknown/missing message type. |
| `CORE_ERROR` | Fallback when the thrown value has no message. Treat as fatal/unknown. |
| `WEBGPU_UNINITIALIZED` | A GPU operation occurred without initialized WebGPU. |
`WORKER_ERROR` and `DISPOSED` are wrapper conventions, not messages emitted by `worker.js`; a raw client should use equivalent local codes when rejecting pending work.
## Rows, arena, and objects
| Code | Meaning |
|---|---|
| `ROWS` | Invalid row shape/format, incompatible recreation, empty batch, or malformed batch payload. |
| `ROWS_BUILTIN` | Attempt to grow/delete/allocate from `signals`. |
| `ROWS_UNKNOWN` | Row name does not exist (also possible during frame upload). |
| `ROWS_ACTIVE` | Delete blocked by active object IDs or active graph reference. |
| `ARENA_OOM` | Byte multiplication overflow or no aligned free arena block. |
| `OBJECT_LIMIT` | Slot ID cannot increment past u32. |
| `OBJECT_UNKNOWN` | Delete ID is not active for that row array. |
| `FPS` | FPS exceeds 1000. |
On row growth, consume the returned descriptor before the next access. Batch creation is not rollback-safe. Do not retry allocation blindly after an uncertain transport outcome: it may allocate a second slot.
## Graph parse and planning
| Code | Meaning |
|---|---|
| `GRAPH_WIRE` | Invalid S-expression/tag/version, duplicate field, or unsupported wire value. |
| `GRAPH_SHAPE` | Decoded object cannot deserialize, graph ID empty, or no passes. |
| `GRAPH_PASS` | Empty/duplicate pass ID or invalid pass type. |
| `GRAPH_DEPENDENCY` / `GRAPH_CYCLE` | Missing `after` ID / dependency cycle. |
| `GRAPH_RESOURCE` | Empty/duplicate resource ID or texture planning serialization failure. |
| `GRAPH_PIPELINE` | Empty/duplicate/missing or wrong-kind pipeline. |
| `GRAPH_BUFFER_SYNC` | Buffer sync is not `frame` or `loadout`. |
| `GRAPH_UNKNOWN` | Switch ID has not been compiled/stored. |
| `GRAPH_ARRAY_UNKNOWN` | Buffer declaration names no existing row array at activation. |
| `GRAPH_RESOURCE_UNKNOWN` | Pass binding or vertex/index resource cannot be resolved. |
| `GRAPH_ATTACHMENT` | Attachment is neither `canvas` nor a declared active texture. |
## GPU descriptor/value errors
| Codes | Meaning |
|---|---|
| `GRAPH_BUFFER_USAGE`, `GRAPH_TEXTURE_USAGE` | Unknown usage string. |
| `GRAPH_TEXTURE_SIZE`, `GRAPH_TEXTURE_DIMENSION`, `GRAPH_TEXTURE_FORMAT` | Invalid extent, dimension, or texture format. |
| `GRAPH_TEXTURE_UNKNOWN` | Upload target absent from active resources (normally only reached for an active upload path). |
| `GRAPH_VERTEX_FORMAT`, `GRAPH_VERTEX_STEP`, `GRAPH_INDEX_FORMAT` | Invalid vertex layout/index enum. |
| `GRAPH_PRIMITIVE`, `GRAPH_DEPTH_STENCIL`, `GRAPH_MULTISAMPLE` | Invalid pipeline descriptor. |
| `GRAPH_BLEND`, `GRAPH_WRITE_MASK` | Invalid fragment blend or write-mask bits. |
| `GRAPH_SAMPLER` | Invalid sampler enum/compare value. |
| `GRAPH_LOAD_OP`, `GRAPH_STORE_OP` | Attachment operation is not allowed. |
| `SURFACE` | Surface acquisition/recovery failed; core marks the SAB dirty to retry later. |
Shader compilation, bind-layout mismatch, device limits, invalid upload extents/mips, and WebGPU validation are not all normalized to these codes. Validate graph inputs before switching and retain the previous graph source for recovery.
## Safe state transitions
1. Initialize once and cache descriptors.
2. Create rows, allocate IDs, and initialize complete rows before publishing `sabDirty`.
3. Compile graphs before switching. Compilation stores a graph but does not validate every GPU-dependent property; activation can still fail.
4. For bundle-invalidating mutation, set `bundleDirty` first. Leave it set after compile/switch failure, because that intentionally suppresses stale rendering. Repair and switch successfully; never clear it merely to hide an error.
5. Upload textures with transfer ownership. Delete cached sources when no future loadout needs them.
6. Pause when editor state should stop timing/render-loop progress. `pause` does not cancel requests or clear dirty lanes.
7. On worker failure, reject all pending requests and rebuild the entire worker/canvas/core state. Requests are not generally safe to replay because create, allocate, delete, switch, and texture operations may already have committed.
+114
View File
@@ -0,0 +1,114 @@
# Render-graph schema
Field names are case-sensitive camelCase where shown. Unknown JSON fields are ignored by Serde; do not rely on that for versioning. Fields marked required have no default.
## Root and declarations
```text
Graph {
id: string required, nonempty
resources: Resources = {}
pipelines: Pipelines = {}
passes: Pass[] required, nonempty
}
Resources { buffers: Buffer[] = [], textures: Texture[] = [], samplers: Sampler[] = [] }
Pipelines { render: RenderPipeline[] = [], compute: ComputePipeline[] = [] }
```
Resource IDs must be nonempty and unique across buffers, textures, and samplers. Pipeline IDs must be nonempty and unique across render and compute pipelines. Pass IDs must be nonempty and unique. Only declarations reached from passes survive compilation.
### Buffer
```text
{ id: string required,
array: string required, // existing shared-row name at switch time
usage: string[] = [],
sync: "frame" | "loadout" = "frame" }
```
Allowed usage names: `uniform`, `storage`, `vertex`, `index`, `indirect`, `copySrc`. `COPY_DST` is always added. Include every way the graph uses the buffer. The allocation is at least 4 bytes, but data copied is the row descriptor's full `bytes`.
### Texture
```text
{ id: string required,
size: (number | "canvas")[] = [], // width, height, depth/layers
format: string required,
usage: string[] = [],
mipLevelCount: u32 = 1,
sampleCount: u32 = 1,
dimension: "1d" | "2d" | "3d" = "2d",
transient: boolean = true }
```
Missing size components default to canvas width, canvas height, then 1. A string extent is valid only when exactly `"canvas"`. Usage names are `render`, `sampled`, `storage`, `copySrc`, `copyDst`. `format` uses wgpu/WebGPU texture-format spellings such as `rgba8unorm`, `rgba8unorm-srgb`, `bgra8unorm`, `r32float`, `rgba16float`, `depth16unorm`, `depth24plus`, `depth24plus-stencil8`, or `depth32float`; support is device-dependent. `canvas` is allowed only as a fragment target/attachment pseudo-format, not as a declared texture format. Non-transient compatible textures may persist across loadouts; transient textures may alias when lifetimes do not overlap.
### Sampler
```text
{ id: string required, descriptor: object | any = {} }
```
If descriptor is not an object, all defaults apply. Object fields: `addressModeU/V/W` (`clamp-to-edge` default; also `repeat`, `mirror-repeat`), `magFilter`, `minFilter`, `mipmapFilter` (`nearest` default or `linear`), `lodMinClamp` (0), `lodMaxClamp` (32), `compare` (absent or WebGPU compare function: `never`, `less`, `equal`, `less-equal`, `greater`, `not-equal`, `greater-equal`, `always`), and `anisotropyClamp` (1).
## Pipelines
### Render pipeline
```text
{ id: string required, code: WGSL string required,
vertex: VertexStage = {}, fragment: FragmentStage = {},
primitive: object | null = null,
depthStencil: object | null = null,
multisample: object | null = null }
VertexStage { entry: string = "vertex", buffers: VertexLayout[] = [] }
VertexLayout { arrayStride: u64 required, stepMode: "vertex"|"instance" = "vertex",
attributes: VertexAttribute[] = [] }
VertexAttribute { format: string required, offset: u64 required, shaderLocation: u32 required }
FragmentStage { entry: string = "fragment", targets: FragmentTarget[] = [] }
FragmentTarget { format: string required, blend: object|null = null, writeMask?: u32 }
```
Vertex formats use wgpu/WebGPU names: `uint8x2/4`, `sint8x2/4`, `unorm8x2/4`, `snorm8x2/4`, `uint16x2/4`, `sint16x2/4`, `unorm16x2/4`, `snorm16x2/4`, `float16x2/4`, `float32`, `float32x2/3/4`, `uint32`, `uint32x2/3/4`, `sint32`, `sint32x2/3/4`, plus formats supported by the current wgpu build.
`primitive` and `blend` deserialize using wgpu's WebGPU-shaped camelCase descriptors. Important allowed primitive values are topology `point-list`, `line-list`, `line-strip`, `triangle-list` (default), `triangle-strip`; strip index format `uint16`/`uint32`; front face `ccw`/`cw`; cull mode absent/`front`/`back`; polygon mode normally `fill`. The `depthStencil` value follows wgpu's Rust Serde field names: it requires `format`, `depth_write_enabled`, and `depth_compare`; optional `stencil` uses `front`, `back`, `read_mask`, and `write_mask`, while optional `bias` uses `constant`, `slope_scale`, and `clamp`. Blend components use factors such as `zero`, `one`, `src`, `one-minus-src`, `src-alpha`, `one-minus-src-alpha`, `dst`, `one-minus-dst` and operations `add`, `subtract`, `reverse-subtract`, `min`, `max`. `writeMask` is ColorWrites bits (`RED=1`, `GREEN=2`, `BLUE=4`, `ALPHA=8`, all=15); omitted means all.
Multisample defaults are `{ count: 1, mask: 2^64-1, alphaToCoverageEnabled: false }`. Fragment targets omitted/empty means no fragment stage. Pipeline target order and formats must match pass color attachments.
### Compute pipeline
```text
{ id: string required, code: WGSL string required, entry: string = "main" }
```
## Passes
```text
Pass {
id: string required,
type: "render" | "compute" required,
pipeline: string required,
after: string[] = [],
bindings: Binding[] = [],
color: ColorAttachment[] = [],
depth?: DepthAttachment,
vertexBuffers: VertexBinding[] = [],
indexBuffer?: IndexBinding,
draw: Draw = {},
dispatch: [u32,u32,u32] = [1,1,1]
}
Binding { group: u32 = 0, binding: u32 required, resource: string required,
offset: u64 = 0, size?: u64 }
ColorAttachment { resource: string required, clear: number[] = [],
load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
DepthAttachment { resource: string required, clear: number = 1,
load: "clear"|"load" = "clear", store: "store"|"discard" = "store" }
VertexBinding { slot: u32 = 0, resource: string required, offset: u64 = 0 }
IndexBinding { resource: string required, format: "uint16"|"uint32" = "uint32", offset: u64 = 0 }
Draw { vertices: u32 = 3, indices: u32 = 0, instances: u32 = 1,
firstVertex: u32 = 0, firstIndex: u32 = 0, baseVertex: i32 = 0,
firstInstance: u32 = 0 }
```
Render passes use `draw`; when `indexBuffer` exists, the indexed path uses `indices` (so set it nonzero), `firstIndex`, and `baseVertex`. Otherwise it uses `vertices` and `firstVertex`. Both use instance fields. Compute passes use `dispatch`. A pipeline ID is validated against the pass kind when the loadout is built. Binding offset/size and vertex/index offsets are bytes and must satisfy WebGPU alignment and range rules. Attachments require matching usages and formats; sampled/storage bindings likewise require matching texture usage, while WGSL determines binding type and visibility.
+53
View File
@@ -0,0 +1,53 @@
# Worker message reference
All fields below are top-level beside `type` and `request`. Except for `init` and `upload-texture`, transfer lists are empty.
| `type` | Fields | Transfer list | Success `result` | Constraints / effects |
|---|---|---|---|---|
| `init` | `canvas: OffscreenCanvas`, `arenaBytes: number` | `[canvas]` | `{ buffer: SharedArrayBuffer, rows: Descriptor[] }` | Once only; initializes WASM/WebGPU. |
| `create-rows` | `name`, `rows`, `stride`, `format` | — | descriptor | Nonempty name; rows > 0; stride ≥16 and multiple of 16; format f32/u32/i32. Existing name may only retain format/stride and grow. |
| `create-rows-batch` | `rows: Array<{name,rows,stride,format}>` | — | descriptor array in input order | Nonempty. Sequential, **not transactional**: earlier creations can survive a later failure. Active GPU resources refresh once after successful batch. |
| `delete-rows` | `name` | — | `undefined` | Not `signals`, not active slots, not referenced by active graph. |
| `allocate-object` | `name` | — | `{ id, rows: descriptor }` | Not `signals`; may grow/relocate. |
| `delete-object` | `name`, `id: u32` | — | `undefined` | ID must currently be active; row is zeroed. |
| `compile-graph` | `serialized: string` | — | graph ID string | Parses and stores; same ID replaces previously stored graph. Does not activate it. |
| `switch-loadout` | `id` | — | `undefined` | Builds GPU resources and activates stored graph. Clears `bundleDirty`, sets `sabDirty`. |
| `upload-texture` | `name`, `mipLevel: u32`, `image: ImageBitmap` | `[image]` | `undefined` | Uploads immediately if active graph has the texture and caches source for future switches. Source extent must fit destination/mip. Sets dirty. |
| `delete-texture` | `name` | — | `undefined` | Deletes/closes cached mip sources; does not remove graph texture or mark dirty. |
| `play` | — | — | `undefined` | Enables render-loop ticks; resets last-time baseline. |
| `pause` | — | — | `undefined` | Stops loop updates/renders. |
| `set-fps` | `fps: u32` | — | `undefined` | 0 uncapped; maximum 1000. Does not itself dirty a frame. |
| `set-profiler` | `enabled` (boolean-coerced) | — | boolean | Result says timestamp queries are supported. Enables only when requested and supported. |
Rust/WASM numeric conversion applies to `u32` fields; callers should send finite nonnegative integers in range rather than rely on coercion.
## Profiler events
When enabled and supported, the worker polls every 250 ms and may send an unsolicited message with **no request field**:
```js
{
type: "profile",
stats: {
frame: 42,
milliseconds: 0.31,
readbackMilliseconds: 4.8,
adapter: "Adapter name · DeviceType · Backend",
canvas: { width: 1280, height: 720 },
passes: [{ name: "triangle", milliseconds: 0.31 }]
}
}
```
One pass timing corresponds to a compiled execution; compatible adjacent render passes may be merged and labeled together. Samples are throttled and asynchronous, so they are diagnostics, not one event per frame. Disabling clears pending published statistics and the worker polling interval.
## Texture lifecycle
Create an `ImageBitmap`, then relinquish it to the worker:
```js
const image = await createImageBitmap(blob);
await request("upload-texture", { name: "albedo", mipLevel: 0, image }, [image]);
```
The name is a graph texture ID, not an arbitrary row name. Upload can precede graph activation because sources are cached. Non-transient textures with an unchanged descriptor can be retained across switches; cached levels are reapplied when needed. `delete-texture` only removes these cached sources.
+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";
+3 -1
View File
@@ -5,7 +5,9 @@
"type": "module",
"workspaces": ["core", "addons/*"],
"scripts": {
"start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}"
"start": "wasm-pack build core --target web --out-dir pkg --release && vitepress dev docs --host 0.0.0.0 --port ${PORT:-8080}",
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
"build:coredocs": "vitepress build coredocs"
},
"devDependencies": {
"@codemirror/lang-javascript": "^6.2.5",