Remove vsync and dirty-flag frame gating, optimize PBR sampling and lighting, add a direct-to-canvas loadout, and preserve camera state on viewer resize. Add regression coverage and update rendering documentation. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b382-1e79-712b-abd1-d29201c9bb5e Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
81 lines
6.1 KiB
Markdown
81 lines
6.1 KiB
Markdown
# 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, commit }`, even without growth. Never infer an address from a prior descriptor.
|
||
|
||
### Relocation is owned by the writer
|
||
|
||
The worker never copies rows. When growth cannot extend in place it reserves a zeroed region, returns that descriptor with `commit: true` (`create-rows`, `allocate-object`) or the moved names in `commit` (`create-rows-batch`), and keeps rendering from—and the arena owning—the old rows. The main thread is the only writer, so it is the only party that knows its last write landed: on receiving the result it copies the old bytes into the new region, swaps its descriptor, and sends `commit-rows { names }`. The worker then publishes the new descriptor, frees the old rows, and rebuilds the GPU buffers bound to them. Until that commit arrives the worker queues every other request, so a staged relocation is a short, exclusive transaction and no SAB write made at any moment is lost. `index.js` does all of this inside `createRows`, `createRowsBatch`, and `allocateObject`; a raw client must copy and commit synchronously in the same task that received the result.
|
||
|
||
## Slots
|
||
|
||
Creating rows establishes capacity but does not allocate objects. `allocate-object` reserves `count` contiguous rows (default 1) and returns the zero-based first row as the ID. Spans are placed first-fit into the lowest free gap, so deleted rows are reused before capacity grows; when nothing fits, capacity doubles (or grows to fit the span, whichever is larger) so repeated allocations rarely relocate. Rows added by an explicit `create-rows` growth join the same free list. `delete-object` zeroes the whole span and releases it. 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 | `reserved` | Legacy `sabDirty` lane; ignored by the renderer |
|
||
| 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 and renders continuously when lanes 4 and 6 are zero. No dirty signal is needed. The worker yields to control messages and waits for GPU completion between submissions, rather than waiting for `requestAnimationFrame` or main-thread vsync. An explicit FPS cap still applies; the browser controls display presentation.
|
||
|
||
```js
|
||
const signalsF32 = rowFor(buffer, signalDescriptor, 0);
|
||
|
||
async function replaceBundle(mutate, compileAndSwitch) {
|
||
signalsF32[6] = 1; // suppress the old bundle first
|
||
mutate();
|
||
try {
|
||
await compileAndSwitch(); // successful switch clears lane 6 and resumes rendering
|
||
} catch (error) {
|
||
// Keep lane 6 set: rendering stale bindings would be unsafe. Repair/retry or stop.
|
||
throw error;
|
||
}
|
||
}
|
||
```
|
||
|
||
The signals are control state, not a lock or transaction boundary. Shared row writes can overlap worker reads; callers needing an atomic multi-field update should await `pause`, write the rows, then `play`.
|
||
|
||
## Frame-sync versus manual-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 need no notification. Use it for small, frequently changing arrays (transforms, lights, draw commands).
|
||
|
||
`sync: "manual"` is copied in full only when its GPU buffer is created: on the first `switch-loadout` that declares it, and whenever the source array grows/relocates or the declaration's usage changes. Later SAB edits reach the GPU only through `sync-rows { name, first, count }`, which uploads that row range into every active buffer bound to the array. Use it for large, mostly static arrays such as vertex and index data: write the rows, then `sync-rows` exactly that span.
|
||
|
||
GPU resources survive graph switches when nothing about them changed: a buffer is reused when its declaration ID keeps the same source array, byte size, and usage; a render or compute pipeline is reused when a previous declaration had identical code and state (IDs may differ); non-transient textures with the same descriptor are kept. Bind groups and render bundles are always rebuilt. Structural changes affecting bindings, offsets, draw ranges, passes, pipelines, or attachment/resource declarations require bundle invalidation and a graph/loadout switch because render commands are precompiled into render bundles.
|