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
+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.