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:
@@ -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 },
|
||||
},
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 0–3 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user