The worker no longer copies rows when an array cannot grow in place. It reserves a zeroed region, returns the staged descriptor with a commit flag, and queues every other request until the main thread (the only writer) has copied the old bytes and sent commit-rows. Writes that raced the old worker-side copy were lost; lights in the glTF viewer regularly ended up at the origin because of it. Amp-Thread-ID: https://ampcode.com/threads/T-01a0b2dd-db32-712d-99ce-12ce4a86d979 Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
6.2 KiB
Shared rows and invalidation
Descriptor and views
Every descriptor is:
{ 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.
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 | 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:
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 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 only require sabDirty. 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 and marks the frame dirty. 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.