From f6c90a244a49e5271e906bc5b58fb7790808cb90 Mon Sep 17 00:00:00 2001 From: Joel Arbuckle Date: Wed, 3 Jun 2026 07:26:45 -0700 Subject: [PATCH 01/20] Plans and initial experiments --- .../core/src/rendering/webgpu/graphs/nodes.ts | 366 ++++++++++++++++++ .../webgpu/pipelines/binding-graphs.ts | 116 ++++++ .../src/rendering/webgpu/plan-2026-06-01.md | 184 +++++++++ 3 files changed, 666 insertions(+) create mode 100644 packages/core/src/rendering/webgpu/graphs/nodes.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts create mode 100644 packages/core/src/rendering/webgpu/plan-2026-06-01.md diff --git a/packages/core/src/rendering/webgpu/graphs/nodes.ts b/packages/core/src/rendering/webgpu/graphs/nodes.ts new file mode 100644 index 00000000..b73795c6 --- /dev/null +++ b/packages/core/src/rendering/webgpu/graphs/nodes.ts @@ -0,0 +1,366 @@ +class GraphNode { + constructor(public id: string, public type: string, public data: any, public children: GraphNode[] = []) {} +} + +/** + * Merges two DAGs into a single DAG that preserves every ancestor-descendant + * relationship present in either source graph. Concretely, if node A is an + * ancestor of node B in either input graph it will remain an ancestor of B in + * the merged result. + * + * When the same node `id` appears in both graphs the first graph's `type` and + * `data` are kept; edges from both graphs are unioned. + * + * @throws if the union of edges from both graphs contains a cycle, which + * means the two graphs impose contradictory ordering constraints and a + * consistent merge is impossible. + * + * @param rootsA - Root nodes of the first graph. + * @param rootsB - Root nodes of the second graph. + * @returns Root nodes of the merged graph (nodes with no incoming edges). + */ +function mergeGraphs(rootsA: GraphNode[], rootsB: GraphNode[]): GraphNode[] { + // ------------------------------------------------------------------ + // Step 1 – Collect all unique nodes from both graphs. + // A fresh `seen` set is used per graph so that a node shared between the + // two graphs is fully traversed in both contexts (important when the node + // has graph-specific children that live below it in only one graph). + // ------------------------------------------------------------------ + const nodeMap = new Map(); + + function gatherNodes(roots: GraphNode[]): void { + const seen = new Set(); + function visit(node: GraphNode): void { + if (seen.has(node.id)) return; // TODO: this should actually be an error, since it indicates that the input graph has a cycle + seen.add(node.id); + if (!nodeMap.has(node.id)) { + // Children are intentionally omitted here; they are wired up + // in Step 4 after the full edge union is known. + nodeMap.set(node.id, new GraphNode(node.id, node.type, node.data)); + } + for (const child of node.children) visit(child); + } + for (const root of roots) visit(root); + } + + gatherNodes(rootsA); + gatherNodes(rootsB); + + // ------------------------------------------------------------------ + // Step 2 – Build the union of directed edges from both graphs. + // Again a per-graph `seen` set ensures that shared nodes contribute + // their edges from both graphs even though they share the same id. + // ------------------------------------------------------------------ + const edges = new Map>(); + + function gatherEdges(roots: GraphNode[]): void { + const seen = new Set(); + function visit(node: GraphNode): void { + if (seen.has(node.id)) return; // TODO: this should actually be an error, since it indicates that the input graph has a cycle + seen.add(node.id); + + // TODO: will want to restructure this to avoid .get() for every child, maybe? want this to be as memory/cpu efficient as possible + if (!edges.has(node.id)) edges.set(node.id, new Set()); + for (const child of node.children) { + const childSet = edges.get(node.id); + if (childSet) { + childSet.add(child.id); + } + visit(child); + } + } + for (const root of roots) visit(root); + } + + gatherEdges(rootsA); + gatherEdges(rootsB); + + // ------------------------------------------------------------------ + // Step 3 – Topological sort via Kahn's algorithm. + // Serves two purposes: produces a valid processing order AND detects + // cycles. A cycle in the union means the two graphs have contradictory + // ordering constraints (A before B in one graph, B before A in the other). + // ------------------------------------------------------------------ + const inDegree = new Map(); + for (const id of nodeMap.keys()) inDegree.set(id, 0); + + for (const [, childIds] of edges) { + for (const childId of childIds) { + inDegree.set(childId, (inDegree.get(childId) ?? 0) + 1); + } + } + + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const topoOrder: string[] = []; + while (queue.length > 0) { + const id = queue.shift()!; + topoOrder.push(id); + for (const childId of edges.get(id) ?? []) { + const newDeg = inDegree.get(childId)! - 1; + inDegree.set(childId, newDeg); + if (newDeg === 0) queue.push(childId); + } + } + + if (topoOrder.length !== nodeMap.size) { + throw new Error( + 'Cannot merge graphs: the combined edge set contains a cycle. ' + + 'The two graphs impose contradictory ordering constraints.' + ); + } + + // ------------------------------------------------------------------ + // Step 4 – Wire up children on the merged nodes using the combined edges. + // ------------------------------------------------------------------ + // TODO: this is a problem: rather than potentially inserting nodes in between ancestors and descendants, this is just reconnecting + // them, causing "gaps" in the traversal logic of the new graph + for (const [parentId, childIds] of edges) { + const parent = nodeMap.get(parentId)!; + parent.children = [...childIds].map(id => nodeMap.get(id)!); + } + + // ------------------------------------------------------------------ + // Step 5 – Return root nodes (those that have no incoming edges). + // ------------------------------------------------------------------ + const hasParent = new Set(); + for (const [, childIds] of edges) { + for (const childId of childIds) hasParent.add(childId); + } + + return [...nodeMap.keys()] + .filter(id => !hasParent.has(id)) + .map(id => nodeMap.get(id)!); +} + +/** + * Merges a WebGPU Command Graph (CG) with a Bind Group Graph (BGG) to + * produce a command hierarchy suitable for driving a GPURenderPassEncoder. + * + * ── Why this is different from mergeGraphs ───────────────────────────── + * + * mergeGraphs performs a topological union of edges: every node is shared + * by ID and every edge from both graphs is preserved. That strategy fails + * for this domain in two ways: + * + * 1. Command ordering. A plain edge union leaves BindGroup and Drawable + * nodes as unordered siblings under their Pipeline node. WebGPU + * requires setBindGroup calls to precede draw calls in the encoded + * command stream. + * + * 2. Multiple pipeline occurrences. The same Pipeline may be referenced + * from several points in the CG (each heading a distinct draw batch). + * In a shared-node DAG the Pipeline (and therefore its bind-group + * sub-tree) would be visited exactly once during traversal, so each + * later batch would never re-emit setBindGroup — producing incorrect + * GPU state. Each occurrence must own an independent copy of the + * bind-group sub-tree so the encoder visits it the correct number of + * times. + * + * ── Join point ───────────────────────────────────────────────────────── + * + * The two graphs share Pipeline nodes as their only common node type: + * + * BGG ── Pipeline → BindGroup(s) → Resource(s) + * CG ── … → Pipeline → Drawable(s) [ → other state commands ] + * + * For every Pipeline occurrence found during CG traversal the algorithm: + * + * a. Clones the corresponding BGG sub-tree independently (one clone per + * CG occurrence) so that each pipeline occurrence is self-contained. + * + * b. Identifies the leaf nodes of the cloned BGG sub-tree. Leaves + * represent fully-specified bind-group states; Drawables placed there + * are guaranteed to fire after all ancestor setBindGroup calls. + * + * c. Distributes the Drawable children of the CG Pipeline node across the + * appropriate BGG leaf nodes via the configurable `matchDrawablesToLeaf` + * callback (default: attach all Drawables to every leaf, which is + * correct when all draws under a pipeline share a single bind-group + * path). + * + * d. Prepends any non-Drawable CG children of the Pipeline (viewport + * settings, scissor rects, etc.) before the BGG sub-tree so that + * general state commands are encoded before bind-group / draw commands. + * + * ── Efficiency preservation ──────────────────────────────────────────── + * + * The BGG tree structure encodes the setBindGroup efficiency strategy: bind + * groups shared across many draws sit high in the tree (set once per batch) + * while per-draw bind groups sit at the leaves. Cloning the sub-tree + * wholesale preserves this structure verbatim, so no redundant setBindGroup + * calls are introduced within a single pipeline batch. + * + * @param commandRoots - Root nodes of the Command Graph. + * @param bindGroupRoots - Root nodes of the Bind Group Graph. + * @param options + * @param options.pipelineType - Node type string identifying Pipeline + * nodes (default: `'Pipeline'`). + * @param options.drawableType - Node type string identifying Drawable + * nodes (default: `'Drawable'`). + * @param options.matchDrawablesToLeaf - Optional callback invoked for each + * leaf of the cloned BGG sub-tree. Receives the leaf node and the full + * list of Drawable siblings from the CG; returns the subset to attach + * under that leaf. Override when different Drawables require different + * bind-group states (i.e. when the BGG sub-tree has multiple leaves, each + * representing a distinct fully-specified configuration). + * + * @returns Root nodes of the merged graph. + */ +function mergeCommandAndBindGroupGraphs( + commandRoots: GraphNode[], + bindGroupRoots: GraphNode[], + { + pipelineType = 'Pipeline', + drawableType = 'Drawable', + matchDrawablesToLeaf = (_leaf: GraphNode, drawables: GraphNode[]) => drawables, + }: { + pipelineType?: string; + drawableType?: string; + matchDrawablesToLeaf?: (leaf: GraphNode, drawables: GraphNode[]) => GraphNode[]; + } = {} +): GraphNode[] { + // ── Step 1 ───────────────────────────────────────────────────────────── + // Index the BGG: for each Pipeline node record its direct children + // (= the roots of its bind-group sub-tree). One pass, DAG-safe. + // ─────────────────────────────────────────────────────────────────────── + const bgSubtrees = new Map(); + { + const visited = new Set(); + function indexBGG(node: GraphNode): void { + if (visited.has(node.id)) return; + visited.add(node.id); + if (node.type === pipelineType) { + bgSubtrees.set(node.id, node.children); + } + for (const child of node.children) indexBGG(child); + } + for (const root of bindGroupRoots) indexBGG(root); + } + + // ── Step 2 ───────────────────────────────────────────────────────────── + // Deep-clone a BGG sub-tree. + // + // Each CG occurrence of a pipeline needs its own independent copy. + // If two CG contexts shared the same BGG sub-tree nodes, a traversal + // that drives command encoding would only visit the bind-group nodes + // once (whichever context was reached first), leaving the second + // context without the required setBindGroup preamble. + // ─────────────────────────────────────────────────────────────────────── + function cloneSubtree(node: GraphNode): GraphNode { + return new GraphNode( + node.id, + node.type, + node.data, + node.children.map(cloneSubtree) + ); + } + + // ── Step 3 ───────────────────────────────────────────────────────────── + // Collect the leaf nodes of an array of sub-tree roots. + // + // Leaves are the deepest bind-group nodes in the BGG sub-tree. + // Drawable nodes appended here will always be reached after all + // ancestor setBindGroup commands have been encoded. + // ─────────────────────────────────────────────────────────────────────── + function collectLeaves(roots: GraphNode[]): GraphNode[] { + const leaves: GraphNode[] = []; + function walk(node: GraphNode): void { + if (node.children.length === 0) { + leaves.push(node); + } else { + for (const child of node.children) walk(child); + } + } + for (const root of roots) walk(root); + return leaves; + } + + // ── Step 4 ───────────────────────────────────────────────────────────── + // Traverse the CG and construct the merged graph. + // + // Non-pipeline nodes: built once and cached by ID (standard DAG + // semantics — multiple parents can safely share a single merged node). + // + // Pipeline nodes: NEVER cached. A fresh GraphNode (with a freshly + // cloned BGG sub-tree) is created for every visit, which is what + // allows the same logical pipeline to appear multiple times in the + // merged output, each with its own independent bind-group preamble. + // ─────────────────────────────────────────────────────────────────────── + const mergedCache = new Map(); + + function buildNode(node: GraphNode): GraphNode { + if (node.type === pipelineType) { + return buildPipelineNode(node); + } + + if (mergedCache.has(node.id)) { + return mergedCache.get(node.id)!; + } + + const merged = new GraphNode(node.id, node.type, node.data); + // Cache before recursing so that any (non-pipeline) diamond shapes + // in the CG are handled without double-building. + mergedCache.set(node.id, merged); + merged.children = node.children.map(buildNode); + return merged; + } + + function buildPipelineNode(pipeline: GraphNode): GraphNode { + // Partition this pipeline's CG children into: + // drawables – will become leaves of the BGG sub-tree + // otherState – viewport, scissor, etc.; must precede bind-group + // and draw commands in the encoded stream + const drawables = pipeline.children.filter(c => c.type === drawableType); + const otherState = pipeline.children + .filter(c => c.type !== drawableType) + .map(buildNode); + + const bgRoots = bgSubtrees.get(pipeline.id); + + let bindGroupSubtreeRoots: GraphNode[]; + + if (bgRoots && bgRoots.length > 0) { + // Clone the entire BGG sub-tree for this pipeline occurrence so + // it is independent of every other occurrence. + const clonedRoots = bgRoots.map(cloneSubtree); + const leaves = collectLeaves(clonedRoots); + + // Attach each leaf's assigned Drawables. + // + // Drawable nodes are themselves leaves (no children), so sharing + // original CG Drawable references across multiple BGG leaves is + // safe — the consumer will simply encode the draw call once per + // leaf that owns it. Provide matchDrawablesToLeaf to restrict + // assignment when different draws require different bind-group + // configurations. + for (const leaf of leaves) { + const assigned = matchDrawablesToLeaf(leaf, drawables); + if (assigned.length > 0) { + leaf.children = [...leaf.children, ...assigned]; + } + } + + bindGroupSubtreeRoots = clonedRoots; + } else { + // No BGG entry for this pipeline — degenerate case, preserve + // the CG's Drawable children directly. + bindGroupSubtreeRoots = drawables; + } + + return new GraphNode( + pipeline.id, + pipeline.type, + pipeline.data, + // Non-Drawable state commands first, then the BGG sub-tree + // (with Drawables appended at its leaves). + [...otherState, ...bindGroupSubtreeRoots] + ); + } + + return commandRoots.map(buildNode); +} + diff --git a/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts new file mode 100644 index 00000000..a8c06b38 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts @@ -0,0 +1,116 @@ +import { WgslShader } from '../shaders'; +import { WgpuResource } from './resources'; + +export type BindingGraphResourceNode = { + __nodeType: 'resource'; + resource: Resource; + pipelines: BindingGraphPipelineNode[]; + label?: string; +}; + +export function isBindingGraphResourceNode(value: unknown): value is BindingGraphResourceNode { + return ( + typeof value === 'object' && value !== null && + '__nodeType' in value && value.__nodeType === 'resource' && + 'resource' in value && typeof value.resource === 'object' && + 'pipelines' in value && Array.isArray(value.pipelines) + ); +} + +export type BindingGraphGroupNode = { + __nodeType: 'group'; + resources: BindingGraphResourceNode[]; + subgroup?: BindingGraphGroupNode; + label?: string; +}; + +export type BindingGraphPipelineNode = { + __nodeType: 'pipeline'; + shader: WgslShader; + depthStencil?: GPUDepthStencilState; + multisample?: GPUMultisampleState; + primitive?: GPUPrimitiveTopology; + label?: string; +}; + +export type BindingGraph = { + groups: BindingGraphGroupNode[]; +}; + +function recursivelyCheckGroup(group: BindingGraphGroupNode, depth: number, depthLimit: number): boolean { + if (depth > depthLimit) { + return false; + } + if (group.subgroup) { + if (!recursivelyCheckGroup(group.subgroup, depth + 1, depthLimit)) { + return false; + } + } + return true; +} + +function checkGroupDepth(groups: BindingGraphGroupNode[], depthLimit: number): boolean { + for (const group of groups) { + if (group.__nodeType !== 'group') { + throw new Error('expected group node'); + } + if (!recursivelyCheckGroup(group, 1, depthLimit)) { + return false; + } + } + return true; +} + +export function makeBindingGraph(groups: BindingGraphGroupNode[], groupDepthLimit = 4): BindingGraph { + if (groupDepthLimit > 0) { + if (!checkGroupDepth(groups, groupDepthLimit)) { + throw new Error(`binding graph group depth exceeds the limit of ${groupDepthLimit}`); + } + } + return { groups }; +} + +export function isBindingGraph(value: unknown): value is BindingGraph { + return typeof value === 'object' && value !== null && 'groups' in value && Array.isArray(value.groups); +} + +export function group( + label: string | undefined, + resources: (WgpuResource | BindingGraphResourceNode)[], + subgroup?: BindingGraphGroupNode +): BindingGraphGroupNode { + return { + __nodeType: 'group', + label, + resources: resources.map((r) => (isBindingGraphResourceNode(r) ? r : resource(undefined, r, []))), + subgroup, + }; +} + +export function resource( + label: string | undefined, + resource: WgpuResource, + pipelines: BindingGraphPipelineNode[] +): BindingGraphResourceNode { + return { + __nodeType: 'resource', + label, + resource, + pipelines, + }; +} + +export function pipeline( + shader: WgslShader, + depthStencil?: GPUDepthStencilState, + multisample?: GPUMultisampleState, + primitive?: GPUPrimitiveTopology +): BindingGraphPipelineNode { + return { + __nodeType: 'pipeline', + shader, + depthStencil, + multisample, + primitive, + }; +} diff --git a/packages/core/src/rendering/webgpu/plan-2026-06-01.md b/packages/core/src/rendering/webgpu/plan-2026-06-01.md new file mode 100644 index 00000000..616b5307 --- /dev/null +++ b/packages/core/src/rendering/webgpu/plan-2026-06-01.md @@ -0,0 +1,184 @@ +# Plan: Stateful WebGPU Graph Encoder with Generalized No-Op Elision + Incremental Re-Encoding + +## TL;DR + +The merged graph (produced by `mergeCommandAndBindGroupGraphs`) contains redundant state-setting nodes. A **stateful encoder** tracks all active GPU state and skips no-op commands via a generalized `resolveFingerprint` callback. On top of this, a **persistent command cache** enables incremental re-encoding: unchanged subtrees (same node versions + same entry state) replay their cached command segment without re-traversing, re-resolving, or re-computing. A layout compatibility oracle handles bind-group invalidation on pipeline switch. + +## Context + +- File: `packages/core/src/rendering/webgpu/graphs/nodes.ts` +- The merged graph is a tree (pipelines are cloned per-occurrence) traversed via DFS to drive a `GPURenderPassEncoder`. +- Many node types represent idempotent state-setting commands; redundant calls are safe but wasteful. +- The encoder generalizes no-op detection: any node that resolves to the same `(stateKey, stateValue)` already active is skipped. +- **Incremental re-encoding**: structure is stable across frames; data (uniforms, textures, bind group contents) changes. The encoder caches subtree command segments and replays them when versions + entry state match. + +## Steps + +### Phase 1: Types & Callbacks + +1. **`StateFingerprint`** — `{ key: string; value: string } | null` + - `key`: the state category/slot being set (e.g., `"pipeline"`, `"bindGroup:0"`, `"viewport"`, `"scissor"`, `"stencilRef"`) + - `value`: opaque identity for the specific value being set + - `null`: the node is not a state-setting command (e.g., Drawable) — always encode, never skip. + +2. **`ResolveStateFingerprint`** — `(node: GraphNode) => StateFingerprint` + User-supplied. Keeps the encoder decoupled from node data conventions. + +3. **`IsSlotCompatible`** — `(prevPipeline: GraphNode, newPipeline: GraphNode, slotKey: string) => boolean` + - Called on pipeline switch for every active `"bindGroup:*"` key. + - `true` → slot remains valid. `false` → slot invalidated. + - Default: invalidate all bind-group slots on any pipeline switch (conservative). + +4. **`EncodedCommand`** — a cached instruction: + ```ts + type EncodedCommand = + | { action: 'encode'; node: GraphNode } + | { action: 'skip'; node: GraphNode } + ``` + The command list is the output of the "what to encode" phase. Consumers iterate it and call the actual GPU APIs. + +5. **`SubtreeCacheEntry`** — persistent across frames: + ```ts + type SubtreeCacheEntry = { + valid: boolean; // false if markDirty() has been called + version: number; // node version when this was recorded + entryState: Map; // active state snapshot at subtree entry + exitState: Map; // active state snapshot at subtree exit + commands: EncodedCommand[]; // the command segment for this subtree + } + ``` + Keyed by node ID. Since structure is stable, a node's position in the DFS is deterministic — its cache entry is valid whenever `valid === true` + entry state matches. + +6. **`GraphNode` extension** — add a `version: number` field (or the user exposes it via a callback `getVersion(node: GraphNode) => number`). Bumped by the user whenever node data changes. + +7. **`EncodeGraphOptions`**: + ```ts + { + resolveFingerprint: ResolveStateFingerprint; + isSlotCompatible?: IsSlotCompatible; + getVersion: (node: GraphNode) => number; + pipelineType?: string; // default 'Pipeline' + onEncode: (node: GraphNode) => void; + onSkip?: (node: GraphNode) => void; + } + ``` + +### Phase 2: Two-Phase Encoder + +The encoder is split into two phases: + +**Phase 2a: Plan (produces command list)** + +8. `planEncode(roots, options, cache: Map)`: + - DFS traversal maintaining `activeState: Map`. + - At each node: + a. Check cache: if `cache[node.id].valid === true` AND `cache[node.id].entryState` deep-equals current `activeState`: + - **Cache hit** → append `cache[node.id].commands` to output, fast-forward `activeState` to `cache[node.id].exitState`, skip recursion into children. + b. **Cache miss** → resolve fingerprint, apply no-op elision logic (same as before), emit `{ action: 'encode' | 'skip', node }`, recurse into children. + - After processing a subtree root (on DFS exit), update cache: + - Record `{ valid: true, version, entryState (snapshot at entry), exitState (snapshot at exit), commands (segment produced during this subtree) }`. + +9. **Entry state snapshot**: taken *before* processing the node. If the node itself is a state command, `entryState` is the state before it fires. + +10. **Exit state snapshot**: taken *after* processing the node and all its descendants. This is the state the rest of the graph sees after the subtree. + +**Phase 2b: Execute (replays command list)** + +11. `executeCommands(commands: EncodedCommand[], onEncode, onSkip?)`: + - Iterate the flat command list. For each `'encode'` entry → call `onEncode(node)`. For each `'skip'` entry → call `onSkip(node)` if provided. + - This is a simple linear pass — no tree traversal, no fingerprint resolution, no state tracking. + +### Phase 3: Dirty Propagation & Structural Changes + +12. **Parent map**: The encoder builds an internal `parentMap: Map` during the first full encode (and updates it on structural changes). This is a one-time O(N) cost, amortized across frames. + +13. **`markDirty(nodeId: string)`** — public API on `GraphEncoder`: + - Looks up `nodeId` in the parent map. + - Walks from `nodeId` up to root via parent pointers, marking each ancestor's cache entry as invalid (`entry.valid = false`). + - Cost: O(depth) per call. + - The user calls this whenever: + - A Drawable is added/removed from a node + - A node's data changes (alternative to version-bump; either mechanism works) + - Any structural change occurs + +14. **Cache hit logic (updated)**: + During `planEncode`, at each node: + - If `cache[node.id].valid === true` AND `cache[node.id].entryState` matches current `activeState`: + → **TRUE SKIP**: splice cached commands, fast-forward activeState to exitState, **do not visit children at all**. + - If `valid === false` OR entry-state mismatch: + → Process normally (resolve fingerprints, no-op elision, recurse into children). On DFS exit, rebuild cache entry with `valid = true`. + - Non-dirty subtrees are skipped in O(1) — no recursion, no fingerprint comparison. + +15. **Parent map maintenance on structural change**: + When `markDirty` is called due to child add/remove, the encoder also updates parent pointers for the affected children: + - New child added → `parentMap.set(child.id, parentId)`. + - Child removed → `parentMap.delete(child.id)`. + - The encoder detects the structural change on next visit to the dirty node (children IDs differ from cache) and reconciles the parent map for that subtree only. + +16. **`getVersion` callback is still used** — but primarily as a secondary check within dirty subtrees to determine if individual node data changed (enabling no-op elision within re-traversed regions). Clean subtrees are never version-checked — they're skipped entirely. + +17. **Batch marking**: For common patterns (e.g., "re-sort all Drawables under this Pipeline"), expose `markSubtreeDirty(nodeId)` which marks the node + all descendants as invalid. This avoids O(descendant_count) individual `markDirty` calls when the user knows an entire subtree is stale. + +### Phase 4: Pipeline-Switch & State Invalidation + +18. On pipeline switch (key changes to different value): + - Same pipeline ID → no invalidation (fast path). + - Different pipeline → call `isSlotCompatible` for each active `"bindGroup:*"` key. Delete incompatible entries from `activeState`. + - This interacts with caching: if a subtree's entry state included a bind-group slot that was subsequently invalidated by an upstream pipeline switch, the entry state won't match → cache miss → correct re-encoding. + +### Phase 5: Encoder Lifecycle + +19. The encoder is a **persistent object**: + ```ts + class GraphEncoder { + private cache: Map; + private parentMap: Map; + constructor(private options: EncodeGraphOptions) {} + + encode(roots: GraphNode[]): EncodedCommand[]; + markDirty(nodeId: string): void; + markSubtreeDirty(nodeId: string): void; + reset(): void; // full cache clear (graph rebuilt from scratch) + } + ``` + - First `encode()` call: full DFS, builds cache + parent map. + - Subsequent calls: skips clean subtrees in O(1), re-traverses only dirty paths. + - `markDirty()`: O(depth) propagation to root. + - `reset()`: for rare full structural rebuilds (e.g., graph is discarded and rebuilt). + +## Cost Summary + +| Operation | Cost | +|-----------|------| +| `markDirty(nodeId)` | O(depth) | +| `encode()` — nothing dirty | O(1) at root | +| `encode()` — one leaf dirty | O(depth) | +| `encode()` — k leaves dirty | O(k × depth) worst case | +| First encode (cold cache) | O(N) | +| `reset()` + next encode | O(N) | + +## Relevant files + +- `packages/core/src/rendering/webgpu/graphs/nodes.ts` — `GraphNode` type, `mergeCommandAndBindGroupGraphs` (unchanged) +- New file: `packages/core/src/rendering/webgpu/graphs/encoder.ts` — `GraphEncoder` class, types, `planEncode`, `executeCommands` + +## Verification + +1. Unit test: first encode → full DFS, all nodes processed, cache + parent map built. +2. Unit test: second encode with no `markDirty` calls → O(1) at root (cache hit), same command list replayed. +3. Unit test: `markDirty(leafId)` → only root-to-leaf path re-traversed; sibling subtrees skipped entirely (verify they are never visited). +4. Unit test: add a Drawable to a Pipeline, call `markDirty(pipelineId)` → Pipeline re-processed with new Drawable, parent chain re-processed, siblings untouched. +5. Unit test: remove a Drawable, call `markDirty(pipelineId)` → correct commands produced without the removed Drawable. +6. Unit test: pipeline switch on dirty path invalidates bind-group slot → downstream entry-state mismatch on a sibling that WAS clean → that sibling also re-encodes (correctly detected via entry-state check). +7. Unit test: `markSubtreeDirty(nodeId)` → entire subtree re-traversed. +8. Performance test: 1000-node graph, `markDirty` on one leaf → encode time proportional to depth, not node count. + +## Decisions + +- Merge algorithm (`mergeCommandAndBindGroupGraphs`) stays unchanged. +- Incremental invalidation via explicit `markDirty()` — O(depth) per change, O(1) skip for clean subtrees. +- Parent map maintained internally by the encoder (built on first encode, updated on structural changes). +- No-op elision still applies within dirty re-traversals (unchanged data nodes are still skipped). +- `getVersion` callback remains useful for no-op elision within dirty paths, but is NOT the primary cache invalidation mechanism (that's `markDirty`). +- Cache entry `valid` flag is the primary gate — no per-frame version computation for clean subtrees. +- Entry-state check is the secondary gate — catches cases where an upstream state change (pipeline switch) requires re-encoding a nominally "clean" subtree. From fdfcd85ceb5b391ec0893221fd77163507f0a6e0 Mon Sep 17 00:00:00 2001 From: Joel Arbuckle Date: Wed, 3 Jun 2026 07:27:40 -0700 Subject: [PATCH 02/20] Forgot one! --- .../rendering/webgpu/pipelines/resources.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 packages/core/src/rendering/webgpu/pipelines/resources.ts diff --git a/packages/core/src/rendering/webgpu/pipelines/resources.ts b/packages/core/src/rendering/webgpu/pipelines/resources.ts new file mode 100644 index 00000000..8685c2b2 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/resources.ts @@ -0,0 +1,63 @@ +export type WgpuResource = { + texture: GPUTexture; +} | { + buffer: GPUBuffer; +} | { + sampler: GPUSampler; +} + +/* +What do we need to know about resources at the start? + + +BindGroupLayout +- `binding` is obviously going to be set the bindinggraph traversal +- the `visibility` parameter will be determined by which shader stages are present in the pipeline(s) that use(s) that resource +- Resource Layout objects + - buffer + - type (optional, default: "uniform") + - uniform + - read-only-storage + - storage + - hasDynamicOffset (optional) + - minBindingSize (optional) + = can be specified in Resource definition + = maybe calculatable by shader code/webgpu-utils? + - externalTexture + = no properties + - storageTexture + - access (optional, default: "write-only") + - read-only + - read-write + - write-only + - format + - viewDimension (optional, default: "2d") + - 1d + - 2d + - 2d-array + - cube + - cube-array + - 3d + - texture + - multisampled (optional, default: false) + - sampleType (optional, default: "float") + - depth + - float + - sint + - uint + - unfilterable-float + - viewDimension (optional, default: "2d") + - 1d + - 2d + - 2d-array + - cube + - cube-array + - 3d + - sampler + - type (optional, default: "filtering") + - comparison + - filtering + - non-filtering + + +*/ \ No newline at end of file From d5f2bdb7e80ced9bef4ab4bee2f2c177868801a5 Mon Sep 17 00:00:00 2001 From: Joel Arbuckle Date: Thu, 4 Jun 2026 12:58:19 -0700 Subject: [PATCH 03/20] Work-in-progress and Agent plan --- .../core/src/rendering/webgpu/graphs/nodes.ts | 33 ++--- .../webgpu/pipelines/binding-graphs.ts | 16 ++- .../rendering/webgpu/pipelines/resources.ts | 19 +-- .../src/rendering/webgpu/plan-2026-06-01.md | 120 +++++++++--------- 4 files changed, 97 insertions(+), 91 deletions(-) diff --git a/packages/core/src/rendering/webgpu/graphs/nodes.ts b/packages/core/src/rendering/webgpu/graphs/nodes.ts index b73795c6..f5436acf 100644 --- a/packages/core/src/rendering/webgpu/graphs/nodes.ts +++ b/packages/core/src/rendering/webgpu/graphs/nodes.ts @@ -1,5 +1,10 @@ class GraphNode { - constructor(public id: string, public type: string, public data: any, public children: GraphNode[] = []) {} + constructor( + public id: string, + public type: string, + public data: any, + public children: GraphNode[] = [] + ) {} } /** @@ -59,7 +64,7 @@ function mergeGraphs(rootsA: GraphNode[], rootsB: GraphNode[]): GraphNode[] { if (seen.has(node.id)) return; // TODO: this should actually be an error, since it indicates that the input graph has a cycle seen.add(node.id); - // TODO: will want to restructure this to avoid .get() for every child, maybe? want this to be as memory/cpu efficient as possible + // TODO: will want to restructure this to avoid .get() for every child, maybe? want this to be as memory/cpu efficient as possible if (!edges.has(node.id)) edges.set(node.id, new Set()); for (const child of node.children) { const childSet = edges.get(node.id); @@ -109,7 +114,7 @@ function mergeGraphs(rootsA: GraphNode[], rootsB: GraphNode[]): GraphNode[] { if (topoOrder.length !== nodeMap.size) { throw new Error( 'Cannot merge graphs: the combined edge set contains a cycle. ' + - 'The two graphs impose contradictory ordering constraints.' + 'The two graphs impose contradictory ordering constraints.' ); } @@ -120,7 +125,7 @@ function mergeGraphs(rootsA: GraphNode[], rootsB: GraphNode[]): GraphNode[] { // them, causing "gaps" in the traversal logic of the new graph for (const [parentId, childIds] of edges) { const parent = nodeMap.get(parentId)!; - parent.children = [...childIds].map(id => nodeMap.get(id)!); + parent.children = [...childIds].map((id) => nodeMap.get(id)!); } // ------------------------------------------------------------------ @@ -131,9 +136,7 @@ function mergeGraphs(rootsA: GraphNode[], rootsB: GraphNode[]): GraphNode[] { for (const childId of childIds) hasParent.add(childId); } - return [...nodeMap.keys()] - .filter(id => !hasParent.has(id)) - .map(id => nodeMap.get(id)!); + return [...nodeMap.keys()].filter((id) => !hasParent.has(id)).map((id) => nodeMap.get(id)!); } /** @@ -251,12 +254,7 @@ function mergeCommandAndBindGroupGraphs( // context without the required setBindGroup preamble. // ─────────────────────────────────────────────────────────────────────── function cloneSubtree(node: GraphNode): GraphNode { - return new GraphNode( - node.id, - node.type, - node.data, - node.children.map(cloneSubtree) - ); + return new GraphNode(node.id, node.type, node.data, node.children.map(cloneSubtree)); } // ── Step 3 ───────────────────────────────────────────────────────────── @@ -314,10 +312,8 @@ function mergeCommandAndBindGroupGraphs( // drawables – will become leaves of the BGG sub-tree // otherState – viewport, scissor, etc.; must precede bind-group // and draw commands in the encoded stream - const drawables = pipeline.children.filter(c => c.type === drawableType); - const otherState = pipeline.children - .filter(c => c.type !== drawableType) - .map(buildNode); + const drawables = pipeline.children.filter((c) => c.type === drawableType); + const otherState = pipeline.children.filter((c) => c.type !== drawableType).map(buildNode); const bgRoots = bgSubtrees.get(pipeline.id); @@ -327,7 +323,7 @@ function mergeCommandAndBindGroupGraphs( // Clone the entire BGG sub-tree for this pipeline occurrence so // it is independent of every other occurrence. const clonedRoots = bgRoots.map(cloneSubtree); - const leaves = collectLeaves(clonedRoots); + const leaves = collectLeaves(clonedRoots); // Attach each leaf's assigned Drawables. // @@ -363,4 +359,3 @@ function mergeCommandAndBindGroupGraphs( return commandRoots.map(buildNode); } - diff --git a/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts index a8c06b38..692e77cb 100644 --- a/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts @@ -1,5 +1,5 @@ -import { WgslShader } from '../shaders'; -import { WgpuResource } from './resources'; +import type { WgslShader } from '../shaders'; +import type { WgpuResource } from './resources'; export type BindingGraphResourceNode = { __nodeType: 'resource'; @@ -10,10 +10,14 @@ export type BindingGraphResourceNode = { export function isBindingGraphResourceNode(value: unknown): value is BindingGraphResourceNode { return ( - typeof value === 'object' && value !== null && - '__nodeType' in value && value.__nodeType === 'resource' && - 'resource' in value && typeof value.resource === 'object' && - 'pipelines' in value && Array.isArray(value.pipelines) + typeof value === 'object' && + value !== null && + '__nodeType' in value && + value.__nodeType === 'resource' && + 'resource' in value && + typeof value.resource === 'object' && + 'pipelines' in value && + Array.isArray(value.pipelines) ); } diff --git a/packages/core/src/rendering/webgpu/pipelines/resources.ts b/packages/core/src/rendering/webgpu/pipelines/resources.ts index 8685c2b2..0e6e9688 100644 --- a/packages/core/src/rendering/webgpu/pipelines/resources.ts +++ b/packages/core/src/rendering/webgpu/pipelines/resources.ts @@ -1,10 +1,13 @@ -export type WgpuResource = { - texture: GPUTexture; -} | { - buffer: GPUBuffer; -} | { - sampler: GPUSampler; -} +export type WgpuResource = + | { + texture: GPUTexture; + } + | { + buffer: GPUBuffer; + } + | { + sampler: GPUSampler; + }; /* What do we need to know about resources at the start? @@ -60,4 +63,4 @@ BindGroupLayout - non-filtering -*/ \ No newline at end of file +*/ diff --git a/packages/core/src/rendering/webgpu/plan-2026-06-01.md b/packages/core/src/rendering/webgpu/plan-2026-06-01.md index 616b5307..d4460c9e 100644 --- a/packages/core/src/rendering/webgpu/plan-2026-06-01.md +++ b/packages/core/src/rendering/webgpu/plan-2026-06-01.md @@ -17,51 +17,53 @@ The merged graph (produced by `mergeCommandAndBindGroupGraphs`) contains redunda ### Phase 1: Types & Callbacks 1. **`StateFingerprint`** — `{ key: string; value: string } | null` - - `key`: the state category/slot being set (e.g., `"pipeline"`, `"bindGroup:0"`, `"viewport"`, `"scissor"`, `"stencilRef"`) - - `value`: opaque identity for the specific value being set - - `null`: the node is not a state-setting command (e.g., Drawable) — always encode, never skip. + - `key`: the state category/slot being set (e.g., `"pipeline"`, `"bindGroup:0"`, `"viewport"`, `"scissor"`, `"stencilRef"`) + - `value`: opaque identity for the specific value being set + - `null`: the node is not a state-setting command (e.g., Drawable) — always encode, never skip. 2. **`ResolveStateFingerprint`** — `(node: GraphNode) => StateFingerprint` User-supplied. Keeps the encoder decoupled from node data conventions. 3. **`IsSlotCompatible`** — `(prevPipeline: GraphNode, newPipeline: GraphNode, slotKey: string) => boolean` - - Called on pipeline switch for every active `"bindGroup:*"` key. - - `true` → slot remains valid. `false` → slot invalidated. - - Default: invalidate all bind-group slots on any pipeline switch (conservative). + - Called on pipeline switch for every active `"bindGroup:*"` key. + - `true` → slot remains valid. `false` → slot invalidated. + - Default: invalidate all bind-group slots on any pipeline switch (conservative). 4. **`EncodedCommand`** — a cached instruction: - ```ts - type EncodedCommand = - | { action: 'encode'; node: GraphNode } - | { action: 'skip'; node: GraphNode } - ``` - The command list is the output of the "what to encode" phase. Consumers iterate it and call the actual GPU APIs. + + ```ts + type EncodedCommand = { action: 'encode'; node: GraphNode } | { action: 'skip'; node: GraphNode }; + ``` + + The command list is the output of the "what to encode" phase. Consumers iterate it and call the actual GPU APIs. 5. **`SubtreeCacheEntry`** — persistent across frames: - ```ts - type SubtreeCacheEntry = { - valid: boolean; // false if markDirty() has been called - version: number; // node version when this was recorded - entryState: Map; // active state snapshot at subtree entry - exitState: Map; // active state snapshot at subtree exit - commands: EncodedCommand[]; // the command segment for this subtree - } - ``` - Keyed by node ID. Since structure is stable, a node's position in the DFS is deterministic — its cache entry is valid whenever `valid === true` + entry state matches. + + ```ts + type SubtreeCacheEntry = { + valid: boolean; // false if markDirty() has been called + version: number; // node version when this was recorded + entryState: Map; // active state snapshot at subtree entry + exitState: Map; // active state snapshot at subtree exit + commands: EncodedCommand[]; // the command segment for this subtree + }; + ``` + + Keyed by node ID. Since structure is stable, a node's position in the DFS is deterministic — its cache entry is valid whenever `valid === true` + entry state matches. 6. **`GraphNode` extension** — add a `version: number` field (or the user exposes it via a callback `getVersion(node: GraphNode) => number`). Bumped by the user whenever node data changes. 7. **`EncodeGraphOptions`**: - ```ts - { - resolveFingerprint: ResolveStateFingerprint; - isSlotCompatible?: IsSlotCompatible; - getVersion: (node: GraphNode) => number; - pipelineType?: string; // default 'Pipeline' - onEncode: (node: GraphNode) => void; - onSkip?: (node: GraphNode) => void; - } - ``` + ```ts + { + resolveFingerprint: ResolveStateFingerprint; + isSlotCompatible?: IsSlotCompatible; + getVersion: (node: GraphNode) => number; + pipelineType?: string; // default 'Pipeline' + onEncode: (node: GraphNode) => void; + onSkip?: (node: GraphNode) => void; + } + ``` ### Phase 2: Two-Phase Encoder @@ -70,17 +72,17 @@ The encoder is split into two phases: **Phase 2a: Plan (produces command list)** 8. `planEncode(roots, options, cache: Map)`: - - DFS traversal maintaining `activeState: Map`. - - At each node: - a. Check cache: if `cache[node.id].valid === true` AND `cache[node.id].entryState` deep-equals current `activeState`: + - DFS traversal maintaining `activeState: Map`. + - At each node: + a. Check cache: if `cache[node.id].valid === true` AND `cache[node.id].entryState` deep-equals current `activeState`: - **Cache hit** → append `cache[node.id].commands` to output, fast-forward `activeState` to `cache[node.id].exitState`, skip recursion into children. - b. **Cache miss** → resolve fingerprint, apply no-op elision logic (same as before), emit `{ action: 'encode' | 'skip', node }`, recurse into children. - - After processing a subtree root (on DFS exit), update cache: - - Record `{ valid: true, version, entryState (snapshot at entry), exitState (snapshot at exit), commands (segment produced during this subtree) }`. + b. **Cache miss** → resolve fingerprint, apply no-op elision logic (same as before), emit `{ action: 'encode' | 'skip', node }`, recurse into children. + - After processing a subtree root (on DFS exit), update cache: + - Record `{ valid: true, version, entryState (snapshot at entry), exitState (snapshot at exit), commands (segment produced during this subtree) }`. -9. **Entry state snapshot**: taken *before* processing the node. If the node itself is a state command, `entryState` is the state before it fires. +9. **Entry state snapshot**: taken _before_ processing the node. If the node itself is a state command, `entryState` is the state before it fires. -10. **Exit state snapshot**: taken *after* processing the node and all its descendants. This is the state the rest of the graph sees after the subtree. +10. **Exit state snapshot**: taken _after_ processing the node and all its descendants. This is the state the rest of the graph sees after the subtree. **Phase 2b: Execute (replays command list)** @@ -97,9 +99,9 @@ The encoder is split into two phases: - Walks from `nodeId` up to root via parent pointers, marking each ancestor's cache entry as invalid (`entry.valid = false`). - Cost: O(depth) per call. - The user calls this whenever: - - A Drawable is added/removed from a node - - A node's data changes (alternative to version-bump; either mechanism works) - - Any structural change occurs + - A Drawable is added/removed from a node + - A node's data changes (alternative to version-bump; either mechanism works) + - Any structural change occurs 14. **Cache hit logic (updated)**: During `planEncode`, at each node: @@ -129,18 +131,20 @@ The encoder is split into two phases: ### Phase 5: Encoder Lifecycle 19. The encoder is a **persistent object**: + ```ts class GraphEncoder { - private cache: Map; - private parentMap: Map; - constructor(private options: EncodeGraphOptions) {} - - encode(roots: GraphNode[]): EncodedCommand[]; - markDirty(nodeId: string): void; - markSubtreeDirty(nodeId: string): void; - reset(): void; // full cache clear (graph rebuilt from scratch) + private cache: Map; + private parentMap: Map; + constructor(private options: EncodeGraphOptions) {} + + encode(roots: GraphNode[]): EncodedCommand[]; + markDirty(nodeId: string): void; + markSubtreeDirty(nodeId: string): void; + reset(): void; // full cache clear (graph rebuilt from scratch) } ``` + - First `encode()` call: full DFS, builds cache + parent map. - Subsequent calls: skips clean subtrees in O(1), re-traverses only dirty paths. - `markDirty()`: O(depth) propagation to root. @@ -148,14 +152,14 @@ The encoder is split into two phases: ## Cost Summary -| Operation | Cost | -|-----------|------| -| `markDirty(nodeId)` | O(depth) | -| `encode()` — nothing dirty | O(1) at root | -| `encode()` — one leaf dirty | O(depth) | +| Operation | Cost | +| --------------------------- | ----------------------- | +| `markDirty(nodeId)` | O(depth) | +| `encode()` — nothing dirty | O(1) at root | +| `encode()` — one leaf dirty | O(depth) | | `encode()` — k leaves dirty | O(k × depth) worst case | -| First encode (cold cache) | O(N) | -| `reset()` + next encode | O(N) | +| First encode (cold cache) | O(N) | +| `reset()` + next encode | O(N) | ## Relevant files From 66860e5b491c971221081d72c3bfd0a2b66e40fe Mon Sep 17 00:00:00 2001 From: Joel Arbuckle Date: Thu, 11 Jun 2026 16:28:01 -0700 Subject: [PATCH 04/20] Type-safe WGSL native types; binding graph & resource progress --- packages/core/package.json | 3 +- .../webgpu/examples/quantitativeTrack.ts | 17 + .../core/src/rendering/webgpu/native-types.ts | 405 +++++++++++++++ .../webgpu/pipelines/binding-graphs.ts | 52 +- .../rendering/webgpu/pipelines/resources.ts | 2 +- .../webgpu/pipelines/traversal.test.ts | 132 +++++ .../rendering/webgpu/pipelines/traversal.ts | 114 +++++ .../rendering/webgpu/resources/bind.test.ts | 41 ++ .../src/rendering/webgpu/resources/bind.ts | 42 ++ .../rendering/webgpu/resources/bound.test.ts | 129 +++++ .../src/rendering/webgpu/resources/bound.ts | 129 +++++ .../src/rendering/webgpu/resources/index.ts | 31 ++ .../webgpu/resources/resource.test.ts | 79 +++ .../rendering/webgpu/resources/resource.ts | 271 ++++++++++ .../rendering/webgpu/shaders/declarations.ts | 18 +- .../src/rendering/webgpu/shaders/index.ts | 167 ++++-- .../src/rendering/webgpu/shaders/shader.ts | 12 +- .../rendering/webgpu/shaders/wgsl-types.ts | 484 ++++++++++++++++++ pnpm-lock.yaml | 3 + 19 files changed, 2060 insertions(+), 71 deletions(-) create mode 100644 packages/core/src/rendering/webgpu/examples/quantitativeTrack.ts create mode 100644 packages/core/src/rendering/webgpu/native-types.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/traversal.test.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/traversal.ts create mode 100644 packages/core/src/rendering/webgpu/resources/bind.test.ts create mode 100644 packages/core/src/rendering/webgpu/resources/bind.ts create mode 100644 packages/core/src/rendering/webgpu/resources/bound.test.ts create mode 100644 packages/core/src/rendering/webgpu/resources/bound.ts create mode 100644 packages/core/src/rendering/webgpu/resources/index.ts create mode 100644 packages/core/src/rendering/webgpu/resources/resource.test.ts create mode 100644 packages/core/src/rendering/webgpu/resources/resource.ts create mode 100644 packages/core/src/rendering/webgpu/shaders/wgsl-types.ts diff --git a/packages/core/package.json b/packages/core/package.json index d958606c..c39e7aca 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,6 +59,7 @@ "@alleninstitute/vis-geometry": "workspace:*", "lodash": "4.18.1", "regl": "2.1.1", - "uuid": "14.0.0" + "uuid": "14.0.0", + "zod": "4.3.6" } } diff --git a/packages/core/src/rendering/webgpu/examples/quantitativeTrack.ts b/packages/core/src/rendering/webgpu/examples/quantitativeTrack.ts new file mode 100644 index 00000000..aa012284 --- /dev/null +++ b/packages/core/src/rendering/webgpu/examples/quantitativeTrack.ts @@ -0,0 +1,17 @@ +import { shader, $s, $a, f32, vec3f } from '../shaders'; + +const Gradient = $s.struct('Gradient', [ + $s.member('low', vec3f), + $s.member('mid', vec3f), + $s.member('top', vec3f), + $s.member('domain', vec3f) +]); + +const Aesthetic = $s.struct('Aesthetic', [ + $s.member('gradient', Gradient), + $s.member('y', f32), +]); + +const sh = shader([ + +]); \ No newline at end of file diff --git a/packages/core/src/rendering/webgpu/native-types.ts b/packages/core/src/rendering/webgpu/native-types.ts new file mode 100644 index 00000000..f1f708d7 --- /dev/null +++ b/packages/core/src/rendering/webgpu/native-types.ts @@ -0,0 +1,405 @@ +/** + * Zod v4 schemas and inferred types for native WebGPU API descriptor objects + * and enum/flags types. Schemas can be used for runtime validation; inferred + * types are structurally compatible with the global WebGPU typings. + */ +import { z } from 'zod'; + +// ---- String Literal Enums ---- + +export const PrimitiveTopologySchema = z.enum([ + 'point-list', + 'line-list', + 'line-strip', + 'triangle-list', + 'triangle-strip', +]); +export type PrimitiveTopology = z.infer; + +export const IndexFormatSchema = z.enum(['uint16', 'uint32']); +export type IndexFormat = z.infer; + +export const FrontFaceSchema = z.enum(['ccw', 'cw']); +export type FrontFace = z.infer; + +export const CullModeSchema = z.enum(['none', 'front', 'back']); +export type CullMode = z.infer; + +export const CompareFunctionSchema = z.enum([ + 'never', + 'less', + 'equal', + 'less-equal', + 'greater', + 'not-equal', + 'greater-equal', + 'always', +]); +export type CompareFunction = z.infer; + +export const StencilOperationSchema = z.enum([ + 'keep', + 'zero', + 'replace', + 'invert', + 'increment-clamp', + 'decrement-clamp', + 'increment-wrap', + 'decrement-wrap', +]); +export type StencilOperation = z.infer; + +export const BlendOperationSchema = z.enum([ + 'add', + 'subtract', + 'reverse-subtract', + 'min', + 'max', +]); +export type BlendOperation = z.infer; + +export const BlendFactorSchema = z.enum([ + 'zero', + 'one', + 'src', + 'one-minus-src', + 'src-alpha', + 'one-minus-src-alpha', + 'dst', + 'one-minus-dst', + 'dst-alpha', + 'one-minus-dst-alpha', + 'src-alpha-saturated', + 'constant', + 'one-minus-constant', + 'src1', + 'one-minus-src1', + 'src1-alpha', + 'one-minus-src1-alpha', +]); +export type BlendFactor = z.infer; + +export const BufferBindingTypeSchema = z.enum(['uniform', 'storage', 'read-only-storage']); +export type BufferBindingType = z.infer; + +export const SamplerBindingTypeSchema = z.enum(['filtering', 'non-filtering', 'comparison']); +export type SamplerBindingType = z.infer; + +export const TextureSampleTypeSchema = z.enum([ + 'float', + 'unfilterable-float', + 'depth', + 'sint', + 'uint', +]); +export type TextureSampleType = z.infer; + +export const TextureViewDimensionSchema = z.enum([ + '1d', + '2d', + '2d-array', + 'cube', + 'cube-array', + '3d', +]); +export type TextureViewDimension = z.infer; + +export const StorageTextureAccessSchema = z.enum(['write-only', 'read-only', 'read-write']); +export type StorageTextureAccess = z.infer; + +export const VertexFormatSchema = z.enum([ + 'uint8', + 'uint8x2', + 'uint8x4', + 'sint8', + 'sint8x2', + 'sint8x4', + 'unorm8', + 'unorm8x2', + 'unorm8x4', + 'snorm8', + 'snorm8x2', + 'snorm8x4', + 'uint16', + 'uint16x2', + 'uint16x4', + 'sint16', + 'sint16x2', + 'sint16x4', + 'unorm16', + 'unorm16x2', + 'unorm16x4', + 'snorm16', + 'snorm16x2', + 'snorm16x4', + 'float16', + 'float16x2', + 'float16x4', + 'float32', + 'float32x2', + 'float32x3', + 'float32x4', + 'uint32', + 'uint32x2', + 'uint32x3', + 'uint32x4', + 'sint32', + 'sint32x2', + 'sint32x3', + 'sint32x4', + 'unorm10-10-10-2', + 'unorm8x4-bgra', +]); +export type VertexFormat = z.infer; + +export const VertexStepModeSchema = z.enum(['vertex', 'instance']); +export type VertexStepMode = z.infer; + +export const TextureFormatSchema = z.enum([ + // 8-bit formats + 'r8unorm', + 'r8snorm', + 'r8uint', + 'r8sint', + // 16-bit formats + 'r16uint', + 'r16sint', + 'r16float', + 'rg8unorm', + 'rg8snorm', + 'rg8uint', + 'rg8sint', + // 32-bit formats + 'r32uint', + 'r32sint', + 'r32float', + 'rg16uint', + 'rg16sint', + 'rg16float', + 'rgba8unorm', + 'rgba8unorm-srgb', + 'rgba8snorm', + 'rgba8uint', + 'rgba8sint', + 'bgra8unorm', + 'bgra8unorm-srgb', + // Packed 32-bit formats + 'rgb9e5ufloat', + 'rgb10a2uint', + 'rgb10a2unorm', + 'rg11b10ufloat', + // 64-bit formats + 'rg32uint', + 'rg32sint', + 'rg32float', + 'rgba16uint', + 'rgba16sint', + 'rgba16float', + // 128-bit formats + 'rgba32uint', + 'rgba32sint', + 'rgba32float', + // Depth/stencil formats + 'stencil8', + 'depth16unorm', + 'depth24plus', + 'depth24plus-stencil8', + 'depth32float', + 'depth32float-stencil8', + // BC compressed formats (optional feature: texture-compression-bc) + 'bc1-rgba-unorm', + 'bc1-rgba-unorm-srgb', + 'bc2-rgba-unorm', + 'bc2-rgba-unorm-srgb', + 'bc3-rgba-unorm', + 'bc3-rgba-unorm-srgb', + 'bc4-r-unorm', + 'bc4-r-snorm', + 'bc5-rg-unorm', + 'bc5-rg-snorm', + 'bc6h-rgb-ufloat', + 'bc6h-rgb-float', + 'bc7-rgba-unorm', + 'bc7-rgba-unorm-srgb', + // ETC2 compressed formats (optional feature: texture-compression-etc2) + 'etc2-rgb8unorm', + 'etc2-rgb8unorm-srgb', + 'etc2-rgb8a1unorm', + 'etc2-rgb8a1unorm-srgb', + 'etc2-rgba8unorm', + 'etc2-rgba8unorm-srgb', + 'eac-r11unorm', + 'eac-r11snorm', + 'eac-rg11unorm', + 'eac-rg11snorm', + // ASTC compressed formats (optional feature: texture-compression-astc) + 'astc-4x4-unorm', + 'astc-4x4-unorm-srgb', + 'astc-5x4-unorm', + 'astc-5x4-unorm-srgb', + 'astc-5x5-unorm', + 'astc-5x5-unorm-srgb', + 'astc-6x5-unorm', + 'astc-6x5-unorm-srgb', + 'astc-6x6-unorm', + 'astc-6x6-unorm-srgb', + 'astc-8x5-unorm', + 'astc-8x5-unorm-srgb', + 'astc-8x6-unorm', + 'astc-8x6-unorm-srgb', + 'astc-8x8-unorm', + 'astc-8x8-unorm-srgb', + 'astc-10x5-unorm', + 'astc-10x5-unorm-srgb', + 'astc-10x6-unorm', + 'astc-10x6-unorm-srgb', + 'astc-10x8-unorm', + 'astc-10x8-unorm-srgb', + 'astc-10x10-unorm', + 'astc-10x10-unorm-srgb', + 'astc-12x10-unorm', + 'astc-12x10-unorm-srgb', + 'astc-12x12-unorm', + 'astc-12x12-unorm-srgb', +]); +export type TextureFormat = z.infer; + +// ---- Flags (bitmask numbers) ---- + +/** ShaderStage flags: VERTEX = 0x1, FRAGMENT = 0x2, COMPUTE = 0x4 */ +export const ShaderStageFlagsSchema = z.number().int().nonnegative(); +export type ShaderStageFlags = z.infer; +export enum ShaderStageFlag { + VERTEX = 0x1, + FRAGMENT = 0x2, + COMPUTE = 0x4, +}; + +/** ColorWrite flags: RED = 0x1, GREEN = 0x2, BLUE = 0x4, ALPHA = 0x8, ALL = 0xF */ +export const ColorWriteFlagsSchema = z.number().int().nonnegative(); +export type ColorWriteFlags = z.infer; +export enum ColorWriteFlag { + RED = 0x1, + GREEN = 0x2, + BLUE = 0x4, + ALPHA = 0x8, + ALL = 0xF, +}; + +// ---- Bind Group Layout Entry types ---- + +export const BufferBindingLayoutSchema = z.object({ + type: BufferBindingTypeSchema.optional(), + hasDynamicOffset: z.boolean().optional(), + minBindingSize: z.number().optional(), +}); +export type BufferBindingLayout = z.infer; + +export const SamplerBindingLayoutSchema = z.object({ + type: SamplerBindingTypeSchema.optional(), +}); +export type SamplerBindingLayout = z.infer; + +export const TextureBindingLayoutSchema = z.object({ + sampleType: TextureSampleTypeSchema.optional(), + viewDimension: TextureViewDimensionSchema.optional(), + multisampled: z.boolean().optional(), +}); +export type TextureBindingLayout = z.infer; + +export const StorageTextureBindingLayoutSchema = z.object({ + access: StorageTextureAccessSchema.optional(), + format: TextureFormatSchema, + viewDimension: TextureViewDimensionSchema.optional(), +}); +export type StorageTextureBindingLayout = z.infer; + +export const ExternalTextureBindingLayoutSchema = z.object({}); +export type ExternalTextureBindingLayout = z.infer; + +export const BindGroupLayoutEntrySchema = z.object({ + binding: z.number().int().nonnegative(), + visibility: ShaderStageFlagsSchema, + buffer: BufferBindingLayoutSchema.optional(), + sampler: SamplerBindingLayoutSchema.optional(), + texture: TextureBindingLayoutSchema.optional(), + storageTexture: StorageTextureBindingLayoutSchema.optional(), + externalTexture: ExternalTextureBindingLayoutSchema.optional(), +}); +export type BindGroupLayoutEntry = z.infer; + +// ---- Pipeline State types ---- + +export const StencilFaceStateSchema = z.object({ + compare: CompareFunctionSchema.optional(), + failOp: StencilOperationSchema.optional(), + depthFailOp: StencilOperationSchema.optional(), + passOp: StencilOperationSchema.optional(), +}); +export type StencilFaceState = z.infer; + +export const DepthStencilStateSchema = z.object({ + format: TextureFormatSchema, + depthWriteEnabled: z.boolean().optional(), + depthCompare: CompareFunctionSchema.optional(), + stencilFront: StencilFaceStateSchema.optional(), + stencilBack: StencilFaceStateSchema.optional(), + stencilReadMask: z.number().int().nonnegative().optional(), + stencilWriteMask: z.number().int().nonnegative().optional(), + depthBias: z.number().int().optional(), + depthBiasSlopeScale: z.number().optional(), + depthBiasClamp: z.number().optional(), +}); +export type DepthStencilState = z.infer; + +export const MultisampleStateSchema = z.object({ + count: z.number().int().positive().optional(), + mask: z.number().int().nonnegative().optional(), + alphaToCoverageEnabled: z.boolean().optional(), +}); +export type MultisampleState = z.infer; + +export const PrimitiveStateSchema = z.object({ + topology: PrimitiveTopologySchema.optional(), + stripIndexFormat: IndexFormatSchema.optional(), + frontFace: FrontFaceSchema.optional(), + cullMode: CullModeSchema.optional(), + unclippedDepth: z.boolean().optional(), +}); +export type PrimitiveState = z.infer; + +export const BlendComponentSchema = z.object({ + operation: BlendOperationSchema.optional(), + srcFactor: BlendFactorSchema.optional(), + dstFactor: BlendFactorSchema.optional(), +}); +export type BlendComponent = z.infer; + +export const BlendStateSchema = z.object({ + color: BlendComponentSchema, + alpha: BlendComponentSchema, +}); +export type BlendState = z.infer; + +export const ColorTargetStateSchema = z.object({ + format: TextureFormatSchema, + blend: BlendStateSchema.optional(), + writeMask: ColorWriteFlagsSchema.optional(), +}); +export type ColorTargetState = z.infer; + +// ---- Vertex Layout types ---- + +export const VertexAttributeSchema = z.object({ + format: VertexFormatSchema, + offset: z.number().int().nonnegative(), + shaderLocation: z.number().int().nonnegative(), +}); +export type VertexAttribute = z.infer; + +export const VertexBufferLayoutSchema = z.object({ + arrayStride: z.number().int().nonnegative(), + stepMode: VertexStepModeSchema.optional(), + attributes: z.array(VertexAttributeSchema), +}); +export type VertexBufferLayout = z.infer; diff --git a/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts index 692e77cb..350a4bf9 100644 --- a/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts @@ -1,9 +1,21 @@ +import type { ShaderStageFlags } from '../native-types'; +import type { Resource } from '../resources'; import type { WgslShader } from '../shaders'; -import type { WgpuResource } from './resources'; +import type { ResourceData } from './resources'; -export type BindingGraphResourceNode = { +/** + * A resource node in the binding graph. Pairs a metadata-only `Resource` *descriptor* (used to + * generate the WGSL declaration and to populate the `GPUBindGroupLayoutEntry`) with the concrete + * `ResourceData` (the actual GPU object that backs `GPUBindGroupEntry.resource`). + */ +export type BindingGraphResourceNode = { __nodeType: 'resource'; - resource: Resource; + /** Metadata-only descriptor; used by binding-graph traversal to produce the BGL entry and to + * generate the WGSL declaration once a `{group, binding}` is assigned. */ + descriptor: Resource; + /** The concrete GPU object that will populate the bind-group entry at draw time. */ + gpu: ResourceData; + /** Pipelines that reference this resource. The union of their `stages` drives visibility. */ pipelines: BindingGraphPipelineNode[]; label?: string; }; @@ -14,10 +26,10 @@ export function isBindingGraphResourceNode(value: unknown): value is BindingGrap value !== null && '__nodeType' in value && value.__nodeType === 'resource' && - 'resource' in value && - typeof value.resource === 'object' && + 'descriptor' in value && + 'gpu' in value && 'pipelines' in value && - Array.isArray(value.pipelines) + Array.isArray((value as { pipelines: unknown }).pipelines) ); } @@ -31,6 +43,10 @@ export type BindingGraphGroupNode = { export type BindingGraphPipelineNode = { __nodeType: 'pipeline'; shader: WgslShader; + /** Shader stages this pipeline contributes to. Unioned per-resource by traversal to compute + * `GPUBindGroupLayoutEntry.visibility`. If omitted, traversal falls back to the resource's + * own `visibility` field (or defaults to all stages when neither is set). */ + stages?: ShaderStageFlags; depthStencil?: GPUDepthStencilState; multisample?: GPUMultisampleState; primitive?: GPUPrimitiveTopology; @@ -80,41 +96,45 @@ export function isBindingGraph(value: unknown): value is BindingGraph { export function group( label: string | undefined, - resources: (WgpuResource | BindingGraphResourceNode)[], + resources: BindingGraphResourceNode[], subgroup?: BindingGraphGroupNode ): BindingGraphGroupNode { return { __nodeType: 'group', label, - resources: resources.map((r) => (isBindingGraphResourceNode(r) ? r : resource(undefined, r, []))), + resources, subgroup, }; } export function resource( label: string | undefined, - resource: WgpuResource, + descriptor: Resource, + gpu: ResourceData, pipelines: BindingGraphPipelineNode[] ): BindingGraphResourceNode { return { __nodeType: 'resource', label, - resource, + descriptor, + gpu, pipelines, }; } export function pipeline( shader: WgslShader, - depthStencil?: GPUDepthStencilState, - multisample?: GPUMultisampleState, - primitive?: GPUPrimitiveTopology + options: { + stages?: ShaderStageFlags; + depthStencil?: GPUDepthStencilState; + multisample?: GPUMultisampleState; + primitive?: GPUPrimitiveTopology; + label?: string; + } = {} ): BindingGraphPipelineNode { return { __nodeType: 'pipeline', shader, - depthStencil, - multisample, - primitive, + ...options, }; } diff --git a/packages/core/src/rendering/webgpu/pipelines/resources.ts b/packages/core/src/rendering/webgpu/pipelines/resources.ts index 0e6e9688..99576169 100644 --- a/packages/core/src/rendering/webgpu/pipelines/resources.ts +++ b/packages/core/src/rendering/webgpu/pipelines/resources.ts @@ -1,4 +1,4 @@ -export type WgpuResource = +export type ResourceData = | { texture: GPUTexture; } diff --git a/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts b/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts new file mode 100644 index 00000000..b45deda3 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { ShaderStageFlag } from '../native-types'; +import { bindShader, samplerResource, textureResource, uniformResource } from '../resources'; +import { asSource, shader } from '../shaders'; +import { group, makeBindingGraph, pipeline, resource } from './binding-graphs'; +import type { ResourceData } from './resources'; +import { traverseBindingGraph } from './traversal'; + +const fakeBuffer: ResourceData = { buffer: {} as GPUBuffer }; +const fakeTexture: ResourceData = { texture: {} as GPUTexture }; +const fakeSampler: ResourceData = { sampler: {} as GPUSampler }; + +describe('traverseBindingGraph — single group', () => { + it('assigns sequential bindings within a group', () => { + const u = uniformResource('u', 'U'); + const t = textureResource('tex', 'texture_2d'); + const s = samplerResource('samp', 'sampler'); + const pl = pipeline(shader([u, t, s]), { stages: ShaderStageFlag.FRAGMENT }); + + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, fakeBuffer, [pl]), + resource(undefined, t, fakeTexture, [pl]), + resource(undefined, s, fakeSampler, [pl]), + ]), + ]); + + const result = traverseBindingGraph(bg); + expect(result.bindings.get(u)).toEqual({ group: 0, binding: 0 }); + expect(result.bindings.get(t)).toEqual({ group: 0, binding: 1 }); + expect(result.bindings.get(s)).toEqual({ group: 0, binding: 2 }); + expect(result.layouts).toHaveLength(1); + expect(result.layouts[0]).toHaveLength(3); + expect(result.bindGroupResources[0]).toEqual([fakeBuffer, fakeTexture, fakeSampler]); + }); +}); + +describe('traverseBindingGraph — nested subgroups', () => { + it('assigns each level of the subgroup chain a distinct group index', () => { + const a = uniformResource('a', 'A'); + const b = uniformResource('b', 'B'); + const c = uniformResource('c', 'C'); + const pl = pipeline(shader([a, b, c]), { stages: ShaderStageFlag.VERTEX }); + + const bg = makeBindingGraph([ + group( + 'outer', + [resource(undefined, a, fakeBuffer, [pl])], + group( + 'middle', + [resource(undefined, b, fakeBuffer, [pl])], + group('inner', [resource(undefined, c, fakeBuffer, [pl])]) + ) + ), + ]); + + const result = traverseBindingGraph(bg); + expect(result.bindings.get(a)).toEqual({ group: 0, binding: 0 }); + expect(result.bindings.get(b)).toEqual({ group: 1, binding: 0 }); + expect(result.bindings.get(c)).toEqual({ group: 2, binding: 0 }); + expect(result.layouts).toHaveLength(3); + }); +}); + +describe('traverseBindingGraph — visibility', () => { + it('explicit Resource.visibility wins', () => { + const u = uniformResource('u', 'U', { visibility: ShaderStageFlag.VERTEX }); + const pl = pipeline(shader([u]), { stages: ShaderStageFlag.FRAGMENT }); + const bg = makeBindingGraph([group(undefined, [resource(undefined, u, fakeBuffer, [pl])])]); + const result = traverseBindingGraph(bg); + expect(result.layouts[0][0].visibility).toBe(ShaderStageFlag.VERTEX); + }); + + it('unions stages of all referencing pipelines when Resource.visibility is unset', () => { + const u = uniformResource('u', 'U'); + const plV = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); + const plF = pipeline(shader([u]), { stages: ShaderStageFlag.FRAGMENT }); + const bg = makeBindingGraph([group(undefined, [resource(undefined, u, fakeBuffer, [plV, plF])])]); + const result = traverseBindingGraph(bg); + expect(result.layouts[0][0].visibility).toBe(ShaderStageFlag.VERTEX | ShaderStageFlag.FRAGMENT); + }); + + it('defaults to all stages when neither is provided', () => { + const u = uniformResource('u', 'U'); + const pl = pipeline(shader([u])); + const bg = makeBindingGraph([group(undefined, [resource(undefined, u, fakeBuffer, [pl])])]); + const result = traverseBindingGraph(bg); + expect(result.layouts[0][0].visibility).toBe( + ShaderStageFlag.VERTEX | ShaderStageFlag.FRAGMENT | ShaderStageFlag.COMPUTE + ); + }); +}); + +describe('traverseBindingGraph — duplicate detection', () => { + it('throws when the same Resource appears at multiple positions', () => { + const u = uniformResource('u', 'U'); + const pl = pipeline(shader([u])); + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, fakeBuffer, [pl]), + resource(undefined, u, fakeBuffer, [pl]), + ]), + ]); + expect(() => traverseBindingGraph(bg)).toThrow(/'u'/); + }); +}); + +describe('integration: traverse → bindShader → asSource', () => { + it('produces fully resolved WGSL combining raw declarations and Resources', () => { + const u = uniformResource('unis', 'Uniforms'); + const t = textureResource('tex', 'texture_2d'); + const s = samplerResource('samp', 'sampler'); + const sh = shader([u, t, s]); + const pl = pipeline(sh, { stages: ShaderStageFlag.FRAGMENT }); + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, fakeBuffer, [pl]), + resource(undefined, t, fakeTexture, [pl]), + resource(undefined, s, fakeSampler, [pl]), + ]), + ]); + const { bindings } = traverseBindingGraph(bg); + const bound = bindShader(sh, bindings); + expect(asSource(bound)).toBe( + [ + '@group(0) @binding(0) var unis: Uniforms;', + '@group(0) @binding(1) var tex: texture_2d;', + '@group(0) @binding(2) var samp: sampler;', + ].join('\n') + ); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/traversal.ts b/packages/core/src/rendering/webgpu/pipelines/traversal.ts new file mode 100644 index 00000000..0dcc3522 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/traversal.ts @@ -0,0 +1,114 @@ +/** + * Walks a `BindingGraph` and assigns concrete `{group, binding}` indices to every `Resource` it + * contains, producing the artifacts needed to drive a WebGPU pipeline: + * + * - `bindings`: a `BindingMap` consumable by `bindShader()` to resolve a `WgslShader`'s `Resource` + * declarations into renderable WGSL. + * - `layouts`: per-group bind-group-layout-entry arrays (one per group index). + * - `bindGroupResources`: per-group `ResourceData` arrays, parallel to `layouts`, so the caller + * can build `GPUBindGroupEntry` objects at draw time (typically by calling `createView()` on + * textures, which requires a live `GPUDevice`). + * + * Group/binding assignment is deterministic and based purely on position in the graph: + * - Group index = position of the group in the flattened walk of `graph.groups` (each top-level + * group plus its `subgroup` chain expands to one index per node). + * - Binding index = position within the group's `resources[]` array. + */ + +import { + type BindGroupLayoutEntry, + ShaderStageFlag, + type ShaderStageFlags, +} from '../native-types'; +import { type BindingMap, bind, type Resource, toBindGroupLayoutEntry } from '../resources'; +import type { BindingGraph, BindingGraphGroupNode, BindingGraphPipelineNode } from './binding-graphs'; +import type { ResourceData } from './resources'; + +const ALL_STAGES: ShaderStageFlags = ShaderStageFlag.VERTEX | ShaderStageFlag.FRAGMENT | ShaderStageFlag.COMPUTE; + +export type TraversalResult = { + bindings: BindingMap; + /** `layouts[group]` is the BGL entry list for `@group(group)`. */ + layouts: BindGroupLayoutEntry[][]; + /** `bindGroupResources[group][binding]` is the GPU object for that slot. */ + bindGroupResources: ResourceData[][]; +}; + +/** + * Walks `graph` and produces a `BindingMap` + per-group BGL entries + per-group resource arrays. + * + * Throws if the same `Resource` object is referenced at more than one position in the graph + * (a single resource cannot occupy two distinct `{group, binding}` slots). + */ +export function traverseBindingGraph(graph: BindingGraph): TraversalResult { + const flattened = flattenGroups(graph.groups); + + const bindings = new Map(); + const layouts: BindGroupLayoutEntry[][] = []; + const bindGroupResources: ResourceData[][] = []; + + flattened.forEach((groupNode, groupIndex) => { + const groupEntries: BindGroupLayoutEntry[] = []; + const groupResources: ResourceData[] = []; + + groupNode.resources.forEach((resNode, bindingIndex) => { + const descriptor = resNode.descriptor; + + if (bindings.has(descriptor)) { + const prior = bindings.get(descriptor) as { group: number; binding: number }; + throw new Error( + `traverseBindingGraph: resource '${descriptor.name}' appears at multiple ` + + `positions in the graph (group ${prior.group} binding ${prior.binding}, and ` + + `group ${groupIndex} binding ${bindingIndex}). A Resource may only be bound once.` + ); + } + + bindings.set(descriptor, { group: groupIndex, binding: bindingIndex }); + + const visibility = resolveVisibility(descriptor, resNode.pipelines); + const bound = bind(descriptor, groupIndex, bindingIndex); + groupEntries.push(toBindGroupLayoutEntry(bound, visibility)); + groupResources.push(resNode.gpu); + }); + + layouts.push(groupEntries); + bindGroupResources.push(groupResources); + }); + + return { bindings, layouts, bindGroupResources }; +} + +/** + * Expands `graph.groups` into a flat array, walking each top-level group's `subgroup` chain. The + * resulting index is the `@group(N)` index assigned to each group node. + */ +function flattenGroups(roots: BindingGraphGroupNode[]): BindingGraphGroupNode[] { + const out: BindingGraphGroupNode[] = []; + for (const root of roots) { + let cur: BindingGraphGroupNode | undefined = root; + while (cur) { + out.push(cur); + cur = cur.subgroup; + } + } + return out; +} + +/** + * Resolves the `visibility` (`ShaderStageFlags`) for a resource: + * - If `descriptor.visibility` is set, it wins. + * - Else, union the `stages` of every pipeline that references the resource. + * - Else, fall back to VERTEX|FRAGMENT|COMPUTE (permissive default). + */ +function resolveVisibility(descriptor: Resource, pipelines: BindingGraphPipelineNode[]): ShaderStageFlags { + if (descriptor.visibility !== undefined) return descriptor.visibility; + let union: ShaderStageFlags = 0; + let anySpecified = false; + for (const p of pipelines) { + if (p.stages !== undefined) { + union |= p.stages; + anySpecified = true; + } + } + return anySpecified ? union : ALL_STAGES; +} diff --git a/packages/core/src/rendering/webgpu/resources/bind.test.ts b/packages/core/src/rendering/webgpu/resources/bind.test.ts new file mode 100644 index 00000000..e70f7a4f --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/bind.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { constant, shader } from '../shaders'; +import { type BindingMap, bindShader } from './bind'; +import { uniformResource } from './resource'; + +describe('bindShader', () => { + it('replaces Resource declarations with BoundResource wrappers', () => { + const u = uniformResource('u', 'U'); + const s = shader([u]); + const bindings: BindingMap = new Map([[u, { group: 0, binding: 0 }]]); + const bound = bindShader(s, bindings); + expect(bound.declarations[0]).not.toBe(u); + expect(bound.declarations[0].__gen()).toBe('@group(0) @binding(0) var u: U;'); + }); + + it('passes through non-Resource declarations by reference', () => { + const c = constant('pi', 3.14); + const u = uniformResource('u', 'U'); + const s = shader([c, u]); + const bindings: BindingMap = new Map([[u, { group: 0, binding: 0 }]]); + const bound = bindShader(s, bindings); + expect(bound.declarations[0]).toBe(c); + expect(bound.declarations[1]).not.toBe(u); + }); + + it('throws listing every unbound resource name', () => { + const a = uniformResource('alpha', 'A'); + const b = uniformResource('beta', 'B'); + const s = shader([a, b]); + expect(() => bindShader(s, new Map())).toThrow(/alpha.*beta|beta.*alpha/); + }); + + it('returns a new shader (does not mutate input)', () => { + const u = uniformResource('u', 'U'); + const s = shader([u]); + const bindings: BindingMap = new Map([[u, { group: 0, binding: 0 }]]); + const bound = bindShader(s, bindings); + expect(bound).not.toBe(s); + expect(s.declarations[0]).toBe(u); + }); +}); diff --git a/packages/core/src/rendering/webgpu/resources/bind.ts b/packages/core/src/rendering/webgpu/resources/bind.ts new file mode 100644 index 00000000..6cabc42f --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/bind.ts @@ -0,0 +1,42 @@ +/** + * `bindShader` substitutes every `Resource` in a `WgslShader`'s declarations array with a + * `BoundResource` (using `{group, binding}` entries supplied by the caller, typically produced + * by a binding-graph traversal). Non-Resource declarations pass through unchanged. + * + * After `bindShader`, the returned shader is fully renderable via `asSource()`. + */ + +import type { DeclarationGenerator, WgslShader } from '../shaders'; +import { bind } from './bound'; +import { isResource, type Resource } from './resource'; + +/** + * Maps each `Resource` referenced by a shader to its assigned `{group, binding}`. The keys are + * the original `Resource` object references — identity-based lookup, no name matching. + */ +export type BindingMap = ReadonlyMap; + +/** + * Returns a new `WgslShader` whose `Resource` declarations have been replaced with `BoundResource` + * wrappers carrying the assigned `{group, binding}`. Other declarations are passed through by + * reference. + * + * Throws if any `Resource` in the shader has no entry in `bindings`. The error lists every + * unbound resource name so the caller can diagnose all gaps in a single pass. + */ +export function bindShader(shader: WgslShader, bindings: BindingMap): WgslShader { + const missing: string[] = []; + const declarations: DeclarationGenerator[] = shader.declarations.map((d) => { + if (!isResource(d)) return d; + const entry = bindings.get(d); + if (!entry) { + missing.push(d.name); + return d; + } + return bind(d, entry.group, entry.binding); + }); + if (missing.length > 0) { + throw new Error(`bindShader: no binding provided for resources: ${missing.join(', ')}`); + } + return { declarations }; +} diff --git a/packages/core/src/rendering/webgpu/resources/bound.test.ts b/packages/core/src/rendering/webgpu/resources/bound.test.ts new file mode 100644 index 00000000..dbcc26e5 --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/bound.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { ShaderStageFlag } from '../native-types'; +import { sampler, storage, texture, uniform } from '../shaders'; +import { bind, toBindGroupLayoutEntry } from './bound'; +import { + externalTextureResource, + samplerResource, + storageResource, + storageTextureResource, + textureResource, + uniformResource, +} from './resource'; + +describe('bind', () => { + it('returns a frozen object', () => { + const r = uniformResource('u', 'U'); + const b = bind(r, 0, 1); + expect(Object.isFrozen(b)).toBe(true); + }); + + it('does not mutate the source Resource', () => { + const r = uniformResource('u', 'U'); + bind(r, 0, 1); + expect('group' in r).toBe(false); + expect('binding' in r).toBe(false); + }); + + it('attaches group and binding to the wrapper', () => { + const r = uniformResource('u', 'U'); + const b = bind(r, 2, 5); + expect(b.group).toBe(2); + expect(b.binding).toBe(5); + }); + + it('uniform: __gen() byte-matches the equivalent raw $s.uniform output', () => { + const r = uniformResource('unis', 'Uniforms'); + expect(bind(r, 0, 0).__gen()).toBe(uniform('unis', 'Uniforms', 0, 0).__gen()); + }); + + it('storage: __gen() byte-matches the equivalent raw $s.storage output (with accessMode)', () => { + const r = storageResource('buf', 'BufType', { accessMode: 'read_write' }); + expect(bind(r, 1, 2).__gen()).toBe(storage('buf', 'BufType', 1, 2, 'read_write').__gen()); + }); + + it('storage: __gen() byte-matches when accessMode is omitted', () => { + const r = storageResource('buf', 'BufType'); + expect(bind(r, 0, 0).__gen()).toBe(storage('buf', 'BufType', 0, 0).__gen()); + }); + + it('texture: __gen() byte-matches the equivalent raw $s.texture output', () => { + const r = textureResource('colorMap', 'texture_2d'); + expect(bind(r, 0, 1).__gen()).toBe(texture('colorMap', 'texture_2d', 0, 1).__gen()); + }); + + it('storageTexture: __gen() emits a `var` declaration with the supplied type', () => { + const r = storageTextureResource('img', 'texture_storage_2d', 'rgba8unorm'); + // delegates to $s.texture under the hood — the type identifier carries format/access. + expect(bind(r, 0, 3).__gen()).toBe( + texture('img', 'texture_storage_2d', 0, 3).__gen() + ); + }); + + it('sampler: __gen() byte-matches the equivalent raw $s.sampler output', () => { + const r = samplerResource('samp', 'sampler'); + expect(bind(r, 0, 2).__gen()).toBe(sampler('samp', 'sampler', 0, 2).__gen()); + }); + + it('externalTexture: __gen() emits a texture_external var declaration', () => { + const r = externalTextureResource('vid'); + expect(bind(r, 0, 0).__gen()).toBe(texture('vid', 'texture_external', 0, 0).__gen()); + }); +}); + +describe('toBindGroupLayoutEntry', () => { + const VERTEX = ShaderStageFlag.VERTEX; + + it('uniform → buffer.type=uniform', () => { + const r = uniformResource('u', 'U', { hasDynamicOffset: true, minBindingSize: 64 }); + const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); + expect(entry).toEqual({ + binding: 0, + visibility: VERTEX, + buffer: { type: 'uniform', hasDynamicOffset: true, minBindingSize: 64 }, + }); + }); + + it('storage with accessMode=read → buffer.type=read-only-storage', () => { + const r = storageResource('b', 'B', { accessMode: 'read' }); + const entry = toBindGroupLayoutEntry(bind(r, 0, 1), VERTEX); + expect(entry.buffer?.type).toBe('read-only-storage'); + }); + + it('storage with accessMode=read_write → buffer.type=storage', () => { + const r = storageResource('b', 'B', { accessMode: 'read_write' }); + const entry = toBindGroupLayoutEntry(bind(r, 0, 1), VERTEX); + expect(entry.buffer?.type).toBe('storage'); + }); + + it('texture → texture entry with sampleType + viewDimension + multisampled', () => { + const r = textureResource('t', 'texture_2d', { + sampleType: 'float', + viewDimension: '2d', + multisampled: false, + }); + const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); + expect(entry.texture).toEqual({ sampleType: 'float', viewDimension: '2d', multisampled: false }); + }); + + it('storageTexture → storageTexture entry with format + access + viewDimension', () => { + const r = storageTextureResource('img', 'texture_storage_2d', 'rgba8unorm', { + access: 'write-only', + viewDimension: '2d', + }); + const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); + expect(entry.storageTexture).toEqual({ format: 'rgba8unorm', access: 'write-only', viewDimension: '2d' }); + }); + + it('sampler → sampler entry with bindingType', () => { + const r = samplerResource('s', 'sampler', { bindingType: 'filtering' }); + const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); + expect(entry.sampler).toEqual({ type: 'filtering' }); + }); + + it('externalTexture → externalTexture entry (empty object)', () => { + const r = externalTextureResource('ext'); + const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); + expect(entry.externalTexture).toEqual({}); + }); +}); diff --git a/packages/core/src/rendering/webgpu/resources/bound.ts b/packages/core/src/rendering/webgpu/resources/bound.ts new file mode 100644 index 00000000..e12bb435 --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/bound.ts @@ -0,0 +1,129 @@ +/** + * Defines `BoundResource` — a `Resource` that has been assigned a `{group, binding}` pair via + * a binding-graph traversal. The wrapper is frozen (immutable) and carries a working `__gen()` + * that produces the WGSL declaration by delegating to the existing `$s.uniform / $s.texture / + * $s.sampler / $s.storage` constructors in `shaders/declarations.ts`. + * + * Producing a `BoundResource` does NOT mutate the original `Resource` — the source descriptor + * stays metadata-only and can be reused across multiple binding layouts. + */ + +import type { BindGroupLayoutEntry, ShaderStageFlags } from '../native-types'; +import { sampler as samplerDecl, storage as storageDecl, texture as textureDecl, uniform as uniformDecl } from '../shaders'; +import type { Resource } from './resource'; + +/** + * A `BoundResource` is a `Resource` extended with `{group, binding}` and a working `__gen()`. + * It is the value the shader-source generator actually emits WGSL for. + * + * The generic parameter narrows to the underlying `Resource` variant so consumers can pattern- + * match on `kind` and access kind-specific metadata. + */ +export type BoundResource = Readonly< + R & { + readonly group: number; + readonly binding: number; + __gen(): string; + } +>; + +/** + * Wraps a `Resource` with a `{group, binding}` and a working `__gen()`. The returned object is + * frozen. The original `Resource` is not mutated. + */ +export function bind(resource: R, group: number, binding: number): BoundResource { + const gen = makeGenFor(resource, group, binding); + const bound = { + ...resource, + group, + binding, + __gen: gen, + }; + return Object.freeze(bound) as BoundResource; +} + +/** + * Builds the `__gen` thunk for a bound resource by delegating to the existing declaration + * constructors. This keeps WGSL formatting in a single place (`shaders/declarations.ts`). + */ +function makeGenFor(r: Resource, group: number, binding: number): () => string { + switch (r.kind) { + case 'uniform': + return uniformDecl(r.name, r.type, group, binding, r.attributes).__gen; + case 'storage': + return storageDecl(r.name, r.type, group, binding, r.accessMode, r.attributes).__gen; + case 'texture': + return textureDecl(r.name, r.type, group, binding, r.attributes).__gen; + case 'storageTexture': + // Storage textures share the WGSL `var` declaration syntax with sampled textures; + // the texel-format/access metadata is encoded in the type identifier (e.g., + // `texture_storage_2d`) which the caller supplied as `r.type`. + return textureDecl(r.name, r.type, group, binding, r.attributes).__gen; + case 'sampler': + return samplerDecl(r.name, r.type, group, binding, r.attributes).__gen; + case 'externalTexture': + return textureDecl( + r.name, + 'texture_external' as `texture_${string}`, + group, + binding, + r.attributes + ).__gen; + } +} + +/** + * Derives a `GPUBindGroupLayoutEntry` for a bound resource. The `visibility` argument lets the + * caller (typically the binding-graph traversal) supply the union of stages from every pipeline + * that references this resource; pass `bound.visibility` directly when no traversal is involved. + */ +export function toBindGroupLayoutEntry(bound: BoundResource, visibility: ShaderStageFlags): BindGroupLayoutEntry { + const base = { binding: bound.binding, visibility }; + switch (bound.kind) { + case 'uniform': + return { + ...base, + buffer: { + type: 'uniform', + ...(bound.hasDynamicOffset !== undefined && { hasDynamicOffset: bound.hasDynamicOffset }), + ...(bound.minBindingSize !== undefined && { minBindingSize: bound.minBindingSize }), + }, + }; + case 'storage': + return { + ...base, + buffer: { + type: bound.accessMode === 'read' ? 'read-only-storage' : 'storage', + ...(bound.hasDynamicOffset !== undefined && { hasDynamicOffset: bound.hasDynamicOffset }), + ...(bound.minBindingSize !== undefined && { minBindingSize: bound.minBindingSize }), + }, + }; + case 'texture': + return { + ...base, + texture: { + ...(bound.sampleType !== undefined && { sampleType: bound.sampleType }), + ...(bound.viewDimension !== undefined && { viewDimension: bound.viewDimension }), + ...(bound.multisampled !== undefined && { multisampled: bound.multisampled }), + }, + }; + case 'storageTexture': + return { + ...base, + storageTexture: { + format: bound.format, + ...(bound.access !== undefined && { access: bound.access }), + ...(bound.viewDimension !== undefined && { viewDimension: bound.viewDimension }), + }, + }; + case 'sampler': + return { + ...base, + sampler: { + ...(bound.bindingType !== undefined && { type: bound.bindingType }), + }, + }; + case 'externalTexture': + return { ...base, externalTexture: {} }; + } +} diff --git a/packages/core/src/rendering/webgpu/resources/index.ts b/packages/core/src/rendering/webgpu/resources/index.ts new file mode 100644 index 00000000..40d25b17 --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/index.ts @@ -0,0 +1,31 @@ +export type { BindingMap } from './bind'; +export { bindShader } from './bind'; + +export type { BoundResource } from './bound'; +export { bind, toBindGroupLayoutEntry } from './bound'; +export type { + ExternalTextureResource, + ExternalTextureResourceOptions, + Resource, + ResourceKind, + SamplerResource, + SamplerResourceOptions, + StorageResource, + StorageResourceOptions, + StorageTextureResource, + StorageTextureResourceOptions, + TextureResource, + TextureResourceOptions, + UniformResource, + UniformResourceOptions, +} from './resource'; +export { + externalTextureResource, + isResource, + RESOURCE_BRAND, + samplerResource, + storageResource, + storageTextureResource, + textureResource, + uniformResource, +} from './resource'; diff --git a/packages/core/src/rendering/webgpu/resources/resource.test.ts b/packages/core/src/rendering/webgpu/resources/resource.test.ts new file mode 100644 index 00000000..67c78e3d --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/resource.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + externalTextureResource, + isResource, + RESOURCE_BRAND, + samplerResource, + storageResource, + storageTextureResource, + textureResource, + uniformResource, +} from './resource'; + +describe('isResource', () => { + it('returns true for objects created by Resource constructors', () => { + expect(isResource(uniformResource('u', 'U'))).toBe(true); + expect(isResource(storageResource('s', 'S'))).toBe(true); + expect(isResource(textureResource('t', 'texture_2d'))).toBe(true); + expect(isResource(storageTextureResource('st', 'texture_storage_2d', 'rgba8unorm'))).toBe( + true + ); + expect(isResource(samplerResource('samp', 'sampler'))).toBe(true); + expect(isResource(externalTextureResource('ext'))).toBe(true); + }); + + it('returns false for plain objects, primitives, and null', () => { + expect(isResource(null)).toBe(false); + expect(isResource(undefined)).toBe(false); + expect(isResource(42)).toBe(false); + expect(isResource('uniform')).toBe(false); + expect(isResource({})).toBe(false); + expect(isResource({ __brand: 'not-it' })).toBe(false); + expect(isResource({ name: 'u', kind: 'uniform' })).toBe(false); + }); +}); + +describe('Resource.__gen (unbound)', () => { + it('throws a useful error mentioning the resource name', () => { + const u = uniformResource('myUniform', 'MyType'); + expect(() => u.__gen()).toThrow(/myUniform/); + expect(() => u.__gen()).toThrow(/bound/); + }); + + it.each([ + ['uniform', () => uniformResource('u', 'U')], + ['storage', () => storageResource('s', 'S')], + ['texture', () => textureResource('t', 'texture_2d')], + ['storageTexture', () => storageTextureResource('st', 'texture_storage_2d', 'rgba8unorm')], + ['sampler', () => samplerResource('samp', 'sampler')], + ['externalTexture', () => externalTextureResource('ext')], + ] as const)('throws for unbound %s resource', (_, build) => { + expect(() => build().__gen()).toThrow(); + }); +}); + +describe('Resource constructors', () => { + it('attach the brand symbol', () => { + expect(uniformResource('u', 'U').__brand).toBe(RESOURCE_BRAND); + }); + + it('preserve provided fields', () => { + const r = uniformResource('u', 'U', { hasDynamicOffset: true, minBindingSize: 64, visibility: 0x3 }); + expect(r.name).toBe('u'); + expect(r.type).toBe('U'); + expect(r.hasDynamicOffset).toBe(true); + expect(r.minBindingSize).toBe(64); + expect(r.visibility).toBe(0x3); + expect(r.kind).toBe('uniform'); + }); + + it('storageResource carries accessMode', () => { + const r = storageResource('buf', 'BufType', { accessMode: 'read_write' }); + expect(r.accessMode).toBe('read_write'); + }); + + it('storageTextureResource carries format', () => { + const r = storageTextureResource('st', 'texture_storage_2d', 'rgba8unorm'); + expect(r.format).toBe('rgba8unorm'); + }); +}); diff --git a/packages/core/src/rendering/webgpu/resources/resource.ts b/packages/core/src/rendering/webgpu/resources/resource.ts new file mode 100644 index 00000000..22b7be83 --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/resource.ts @@ -0,0 +1,271 @@ +/** + * Defines the `Resource` type — a metadata-only descriptor of a shader binding (uniform buffer, + * texture, sampler, storage buffer, storage texture, external texture) that carries everything + * needed to: + * 1. Generate the corresponding WGSL declaration once a `{group, binding}` has been assigned. + * 2. Construct a `GPUBindGroupLayoutEntry` for the resource. + * + * `Resource` implements the `DeclarationGenerator` interface from `shaders/`, so it can be dropped + * directly into a `WgslShader`'s `declarations` array. Its `__gen()` throws until the resource has + * been "bound" (see `bound.ts` and `bind.ts`). The shaders module never imports from this module — + * the dependency is strictly one-way (`resources/` → `shaders/`). + */ + +import type { + SamplerBindingType, + ShaderStageFlags, + StorageTextureAccess, + TextureFormat, + TextureSampleType, + TextureViewDimension, +} from '../native-types'; +import type { + TypeIdentifier, + VariableOrValueAttribute, + WgslSampler, + WgslSamplerComparison, + WgslTextureDataType, +} from '../shaders'; + +/** Brand symbol used by `isResource` to discriminate `Resource` objects at runtime. */ +export const RESOURCE_BRAND = Symbol.for('vis-core.webgpu.Resource'); + +export type ResourceKind = + | 'uniform' + | 'storage' + | 'texture' + | 'storageTexture' + | 'sampler' + | 'externalTexture'; + +/** Fields common to every `Resource` variant. */ +interface ResourceCommon { + readonly __brand: typeof RESOURCE_BRAND; + readonly kind: ResourceKind; + readonly name: string; + /** + * Optional explicit visibility. Binding-graph traversal may union additional stages from any + * pipelines that reference this resource; when both are present, the union is used. + */ + readonly visibility?: ShaderStageFlags; + /** Optional attributes applied to the variable declaration (e.g., `@align(N)`, `@size(N)`). */ + readonly attributes?: VariableOrValueAttribute[]; + /** + * Throws unless this resource has been bound to a `{group, binding}` (see `bind()` / + * `bindShader()`). After binding, the bound wrapper's `__gen()` returns the WGSL declaration. + */ + __gen(): string; +} + +export interface UniformResource extends ResourceCommon { + readonly kind: 'uniform'; + readonly type: TypeIdentifier; + /** GPUBindGroupLayoutEntry.buffer.hasDynamicOffset */ + readonly hasDynamicOffset?: boolean; + /** GPUBindGroupLayoutEntry.buffer.minBindingSize */ + readonly minBindingSize?: number; +} + +export interface StorageResource extends ResourceCommon { + readonly kind: 'storage'; + readonly type: TypeIdentifier; + /** WGSL storage access mode. Mirrors the optional `accessMode` of `$s.storage(...)`. */ + readonly accessMode?: 'read' | 'write' | 'read_write'; + /** GPUBindGroupLayoutEntry.buffer.hasDynamicOffset */ + readonly hasDynamicOffset?: boolean; + /** GPUBindGroupLayoutEntry.buffer.minBindingSize */ + readonly minBindingSize?: number; +} + +export interface TextureResource extends ResourceCommon { + readonly kind: 'texture'; + readonly type: WgslTextureDataType | `texture_${string}`; + /** GPUBindGroupLayoutEntry.texture.sampleType (default 'float') */ + readonly sampleType?: TextureSampleType; + /** GPUBindGroupLayoutEntry.texture.viewDimension (default '2d') */ + readonly viewDimension?: TextureViewDimension; + /** GPUBindGroupLayoutEntry.texture.multisampled (default false) */ + readonly multisampled?: boolean; +} + +export interface StorageTextureResource extends ResourceCommon { + readonly kind: 'storageTexture'; + readonly type: WgslTextureDataType | `texture_${string}`; + /** Required: storage textures must specify a texel format. */ + readonly format: TextureFormat; + /** GPUBindGroupLayoutEntry.storageTexture.access (default 'write-only') */ + readonly access?: StorageTextureAccess; + /** GPUBindGroupLayoutEntry.storageTexture.viewDimension (default '2d') */ + readonly viewDimension?: TextureViewDimension; +} + +export interface SamplerResource extends ResourceCommon { + readonly kind: 'sampler'; + readonly type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison'; + /** GPUBindGroupLayoutEntry.sampler.type (default 'filtering') */ + readonly bindingType?: SamplerBindingType; +} + +export interface ExternalTextureResource extends ResourceCommon { + readonly kind: 'externalTexture'; +} + +export type Resource = + | UniformResource + | StorageResource + | TextureResource + | StorageTextureResource + | SamplerResource + | ExternalTextureResource; + +/** Runtime discriminator for `Resource` (used by `bindShader` and binding-graph traversal). */ +export function isResource(value: unknown): value is Resource { + return ( + typeof value === 'object' && + value !== null && + '__brand' in value && + (value as { __brand: unknown }).__brand === RESOURCE_BRAND + ); +} + +function unboundGen(name: string): () => string { + return () => { + throw new Error( + `Resource '${name}' must be bound to a {group, binding} before source generation; ` + + 'see bindShader() in @alleninstitute/vis-core/rendering/webgpu/resources' + ); + }; +} + +// ---- Constructors ----------------------------------------------------------------------------- + +export type UniformResourceOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + hasDynamicOffset?: boolean; + minBindingSize?: number; +}; + +export function uniformResource( + name: string, + type: TypeIdentifier, + options: UniformResourceOptions = {} +): UniformResource { + return { + __brand: RESOURCE_BRAND, + kind: 'uniform', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type StorageResourceOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + accessMode?: 'read' | 'write' | 'read_write'; + hasDynamicOffset?: boolean; + minBindingSize?: number; +}; + +export function storageResource( + name: string, + type: TypeIdentifier, + options: StorageResourceOptions = {} +): StorageResource { + return { + __brand: RESOURCE_BRAND, + kind: 'storage', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type TextureResourceOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + sampleType?: TextureSampleType; + viewDimension?: TextureViewDimension; + multisampled?: boolean; +}; + +export function textureResource( + name: string, + type: WgslTextureDataType | `texture_${string}`, + options: TextureResourceOptions = {} +): TextureResource { + return { + __brand: RESOURCE_BRAND, + kind: 'texture', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type StorageTextureResourceOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + access?: StorageTextureAccess; + viewDimension?: TextureViewDimension; +}; + +export function storageTextureResource( + name: string, + type: WgslTextureDataType | `texture_${string}`, + format: TextureFormat, + options: StorageTextureResourceOptions = {} +): StorageTextureResource { + return { + __brand: RESOURCE_BRAND, + kind: 'storageTexture', + name, + type, + format, + ...options, + __gen: unboundGen(name), + }; +} + +export type SamplerResourceOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + bindingType?: SamplerBindingType; +}; + +export function samplerResource( + name: string, + type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison', + options: SamplerResourceOptions = {} +): SamplerResource { + return { + __brand: RESOURCE_BRAND, + kind: 'sampler', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type ExternalTextureResourceOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; +}; + +export function externalTextureResource( + name: string, + options: ExternalTextureResourceOptions = {} +): ExternalTextureResource { + return { + __brand: RESOURCE_BRAND, + kind: 'externalTexture', + name, + ...options, + __gen: unboundGen(name), + }; +} diff --git a/packages/core/src/rendering/webgpu/shaders/declarations.ts b/packages/core/src/rendering/webgpu/shaders/declarations.ts index aa05e045..d98a56f2 100644 --- a/packages/core/src/rendering/webgpu/shaders/declarations.ts +++ b/packages/core/src/rendering/webgpu/shaders/declarations.ts @@ -12,6 +12,8 @@ import { type FunctionAttribute, type VariableOrValueAttribute, } from './attributes'; +import type { WgslDataType, WgslSampler, WgslSamplerComparison, WgslTextureDataType } from './wgsl-types'; +import { wgslTypeName } from './wgsl-types'; function renderAttrs(attrs: DeclarationAttribute[] | undefined): string { return attrs && attrs.length > 0 ? attrs.map((attr) => `${attr.__gen()}`).join(' ') + ' ' : ''; @@ -21,7 +23,11 @@ function renderTypeIdentifier(type: TypeIdentifier): string { if (typeof type === 'string') { return type; } - return type.name; + if (!('kind' in type)) { + // StructDeclaration or AliasDeclaration — both extend IdentifierDeclaration + return type.name; + } + return wgslTypeName(type as WgslDataType); } /// TYPES @@ -53,7 +59,7 @@ export type AliasDeclaration = IdentifierDeclaration & aliasedType: TypeIdentifier; }; -export type WgslType = string; // TODO: enumerate builtins someday, eg. 'vec2i' | 'vec3f' ... +export type WgslType = string | WgslDataType; export type TypeIdentifier = WgslType | StructDeclaration | AliasDeclaration; @@ -143,7 +149,7 @@ export type TextureVariableDeclaration = IdentifierDeclaration & DeclarationGenerator & { __identType: 'variable'; readonly assignmentType: 'texture'; - readonly type: `texture_${string}`; + readonly type: WgslTextureDataType | `texture_${string}`; readonly attributes?: VariableOrValueAttribute[]; }; @@ -152,7 +158,7 @@ export type SamplerVariableDeclaration = IdentifierDeclaration & DeclarationGenerator & { __identType: 'variable'; readonly assignmentType: 'sampler'; - readonly type: 'sampler' | 'sampler_comparison'; + readonly type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison'; readonly attributes?: VariableOrValueAttribute[]; }; @@ -263,7 +269,7 @@ export function uniform( export function texture( name: string, - type: `texture_${string}`, + type: WgslTextureDataType | `texture_${string}`, group: number, binding: number, attributes?: VariableOrValueAttribute[] @@ -283,7 +289,7 @@ export function texture( export function sampler( name: string, - type: 'sampler' | 'sampler_comparison', + type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison', group: number, binding: number, attributes?: VariableOrValueAttribute[] diff --git a/packages/core/src/rendering/webgpu/shaders/index.ts b/packages/core/src/rendering/webgpu/shaders/index.ts index 80a77ca5..8b761d96 100644 --- a/packages/core/src/rendering/webgpu/shaders/index.ts +++ b/packages/core/src/rendering/webgpu/shaders/index.ts @@ -2,51 +2,30 @@ export type { AlignAttribute, BlendSrcAttribute, BuiltinAttribute, + ComputeAttribute, ConstAttribute, DiagnosticAttribute, + FragmentAttribute, + FunctionAttribute, IdAttribute, InterpolateAttribute, InvariantAttribute, LocationAttribute, MustUseAttribute, SizeAttribute, - WorkgroupSizeAttribute, - VertexAttribute, - FragmentAttribute, - ComputeAttribute, VariableOrValueAttribute, - FunctionAttribute, + VertexAttribute, + WorkgroupSizeAttribute, } from './attributes'; - -export type { - IdentifierDeclaration, - ConstValueDeclaration, - OverrideValueDeclaration, - ValueDeclaration, - PrivateVariableDeclaration, - WorkgroupVariableDeclaration, - ResourceIdentifierDeclaration, - UniformVariableDeclaration, - TextureVariableDeclaration, - SamplerVariableDeclaration, - StorageVariableDeclaration, - ResourceDeclaration, - StructMemberDeclaration, - StructDeclaration, - FunctionParameterDeclaration, - FunctionReturnTypeDeclaration, - FunctionDeclaration, - Declaration, -} from './declarations'; - -export type { WgslShader } from './shader'; - export { align, blendSrc, builtin, + compute, constAttr, + constructors as $a, diagnostic, + fragment, id, interpolate, invariant, @@ -54,29 +33,129 @@ export { mustUse, size, vertex, - fragment, - compute, workgroupSize, - constructors as $a, } from './attributes'; - +export type { + AliasDeclaration, + ConstValueDeclaration, + Declaration, + DeclarationGenerator, + FunctionDeclaration, + FunctionParameterDeclaration, + FunctionReturnTypeDeclaration, + IdentifierDeclaration, + OverrideValueDeclaration, + PrivateVariableDeclaration, + ResourceDeclaration, + ResourceIdentifierDeclaration, + SamplerVariableDeclaration, + StorageVariableDeclaration, + StructDeclaration, + StructMemberDeclaration, + TextureVariableDeclaration, + TypeIdentifier, + UniformVariableDeclaration, + ValueDeclaration, + WgslType, + WorkgroupVariableDeclaration, +} from './declarations'; export { + alias, + computeEntry, constant, + constructors as $s, + fragmentEntry, + func, + member, override, + param, privateVar, - workgroupVar, - uniform, - texture, + returns, sampler, - member, + storage, struct, - param, - returns, - func, + texture, + uniform, vertexEntry, - fragmentEntry, - computeEntry, - constructors as $s, + workgroupVar, } from './declarations'; - +export type { WgslShader } from './shader'; export { asSource, isWgslShader, shader } from './shader'; + +export type { + WgslAtomic, + WgslDataType, + WgslDepthMultisampledTexture, + WgslDepthTexture, + WgslDepthTextureDimension, + WgslExternalTexture, + WgslFixedArray, + WgslFloatScalarType, + WgslMat, + WgslMultisampledTexture, + WgslNumericScalarType, + WgslRuntimeArray, + WgslSampledType, + WgslSampler, + WgslSamplerComparison, + WgslScalar, + WgslScalarType, + WgslStorageAccessMode, + WgslStorageTexture, + WgslStorageTextureDimension, + WgslTexelFormat, + WgslTexture, + WgslTextureDataType, + WgslTextureDimension, + WgslVec, +} from './wgsl-types'; +export { + atomic, + bool, + depthTexture, + f16, + f32, + fixedArray, + i32, + mat, + mat2x2f, + mat2x2h, + mat2x3f, + mat2x3h, + mat2x4f, + mat2x4h, + mat3x2f, + mat3x2h, + mat3x3f, + mat3x3h, + mat3x4f, + mat3x4h, + mat4x2f, + mat4x2h, + mat4x3f, + mat4x3h, + mat4x4f, + mat4x4h, + multisampledTexture, + runtimeArray, + sampler_comparison, + scalar, + storageTexture, + texture_depth_multisampled_2d, + texture_external, + u32, + vec, + vec2f, + vec2h, + vec2i, + vec2u, + vec3f, + vec3h, + vec3i, + vec3u, + vec4f, + vec4h, + vec4i, + vec4u, + wgslTypeName, +} from './wgsl-types'; diff --git a/packages/core/src/rendering/webgpu/shaders/shader.ts b/packages/core/src/rendering/webgpu/shaders/shader.ts index ac4d1d0a..08c6081a 100644 --- a/packages/core/src/rendering/webgpu/shaders/shader.ts +++ b/packages/core/src/rendering/webgpu/shaders/shader.ts @@ -3,12 +3,18 @@ * Also included is a method for generating the WGSL source code from a `WgslShader` * object and its declarations. The `shader` function is a simple helper function for * creating a new `WgslShader` object from an array of declarations. + * + * NOTE: `WgslShader.declarations` is intentionally typed as `DeclarationGenerator[]` + * (the minimal `{ __gen(): string }` interface) rather than the concrete `Declaration` + * union. This lets higher-level modules (e.g., `resources/`) define their own objects + * that satisfy the declaration interface and drop them directly into a shader without + * `shaders/` needing to know about them — preserving a one-way dependency. */ -import type { Declaration } from './declarations'; +import type { DeclarationGenerator } from './declarations'; export type WgslShader = { - declarations: Declaration[]; + declarations: DeclarationGenerator[]; }; // NOTE: In the future, we may want to add further typeguards for the different declarations @@ -25,6 +31,6 @@ export function asSource(shader: WgslShader): string { throw new Error('Invalid shader object'); } -export function shader(declarations: Declaration[]): WgslShader { +export function shader(declarations: DeclarationGenerator[]): WgslShader { return { declarations }; } diff --git a/packages/core/src/rendering/webgpu/shaders/wgsl-types.ts b/packages/core/src/rendering/webgpu/shaders/wgsl-types.ts new file mode 100644 index 00000000..e2850a91 --- /dev/null +++ b/packages/core/src/rendering/webgpu/shaders/wgsl-types.ts @@ -0,0 +1,484 @@ +/** + * TypeScript representations and Zod v4 schemas for WGSL built-in data types. + * + * Each WGSL type is modelled as a plain discriminated-union object keyed on + * `kind`. These objects are fully serialisable and can be validated at runtime + * via their corresponding `*Schema` exports. + * + * Use `wgslTypeName()` to turn any `WgslDataType` value into its WGSL source + * string (e.g. `"vec3f"`, `"texture_2d"`, `"array"`). + * + * Relation to the existing shader-declaration system + * -------------------------------------------------- + * `declarations.ts` defines `WgslType = string` as a type identifier. You can + * bridge the two layers with `wgslTypeName(myType)` wherever a raw WGSL type + * string is expected. + */ +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Component-type enums +// --------------------------------------------------------------------------- + +/** All WGSL scalar types. */ +export const WgslScalarTypeSchema = z.enum(['bool', 'i32', 'u32', 'f32', 'f16']); +export type WgslScalarType = z.infer; + +/** Numeric scalars valid as vector component types (excludes `bool`). */ +export const WgslNumericScalarTypeSchema = z.enum(['i32', 'u32', 'f32', 'f16']); +export type WgslNumericScalarType = z.infer; + +/** Float scalars valid as matrix component types. */ +export const WgslFloatScalarTypeSchema = z.enum(['f32', 'f16']); +export type WgslFloatScalarType = z.infer; + +/** Integer scalars valid inside `atomic`. */ +export const WgslAtomicInnerTypeSchema = z.enum(['i32', 'u32']); +export type WgslAtomicInnerType = z.infer; + +/** Component types valid for sampled textures. */ +export const WgslSampledTypeSchema = z.enum(['f32', 'i32', 'u32']); +export type WgslSampledType = z.infer; + +// --------------------------------------------------------------------------- +// Scalar → bool | i32 | u32 | f32 | f16 +// --------------------------------------------------------------------------- + +export const WgslScalarSchema = z.object({ + kind: z.literal('scalar'), + type: WgslScalarTypeSchema, +}); +export type WgslScalar = z.infer; + +// --------------------------------------------------------------------------- +// Vectors → vec2 / vec3 / vec4 +// --------------------------------------------------------------------------- + +export const WgslVecSizeSchema = z.union([z.literal(2), z.literal(3), z.literal(4)]); +export type WgslVecSize = z.infer; + +export const WgslVecSchema = z.object({ + kind: z.literal('vec'), + size: WgslVecSizeSchema, + componentType: WgslNumericScalarTypeSchema, +}); +export type WgslVec = z.infer; + +// --------------------------------------------------------------------------- +// Matrices → matCxR (C columns × R rows) +// --------------------------------------------------------------------------- + +export const WgslMatDimSchema = z.union([z.literal(2), z.literal(3), z.literal(4)]); +export type WgslMatDim = z.infer; + +export const WgslMatSchema = z.object({ + kind: z.literal('mat'), + cols: WgslMatDimSchema, + rows: WgslMatDimSchema, + componentType: WgslFloatScalarTypeSchema, +}); +export type WgslMat = z.infer; + +// --------------------------------------------------------------------------- +// Samplers +// --------------------------------------------------------------------------- + +export const WgslSamplerSchema = z.object({ kind: z.literal('sampler') }); +export type WgslSampler = z.infer; + +export const WgslSamplerComparisonSchema = z.object({ kind: z.literal('sampler_comparison') }); +export type WgslSamplerComparison = z.infer; + +// --------------------------------------------------------------------------- +// Texture dimension enums +// --------------------------------------------------------------------------- + +/** Valid dimensions for sampled (non-depth) textures. */ +export const WgslTextureDimensionSchema = z.enum(['1d', '2d', '2d_array', '3d', 'cube', 'cube_array']); +export type WgslTextureDimension = z.infer; + +/** Valid dimensions for depth textures (`1d` is not a valid depth texture dimension). */ +export const WgslDepthTextureDimensionSchema = z.enum(['2d', '2d_array', 'cube', 'cube_array']); +export type WgslDepthTextureDimension = z.infer; + +/** Valid dimensions for storage textures (`cube` and `cube_array` are not supported). */ +export const WgslStorageTextureDimensionSchema = z.enum(['1d', '2d', '2d_array', '3d']); +export type WgslStorageTextureDimension = z.infer; + +// --------------------------------------------------------------------------- +// Sampled textures → texture_1d … texture_cube_array +// --------------------------------------------------------------------------- + +export const WgslTextureSchema = z.object({ + kind: z.literal('texture'), + dimension: WgslTextureDimensionSchema, + componentType: WgslSampledTypeSchema, +}); +export type WgslTexture = z.infer; + +// --------------------------------------------------------------------------- +// Depth textures → texture_depth_2d … texture_depth_cube_array +// --------------------------------------------------------------------------- + +export const WgslDepthTextureSchema = z.object({ + kind: z.literal('texture_depth'), + dimension: WgslDepthTextureDimensionSchema, +}); +export type WgslDepthTexture = z.infer; + +// --------------------------------------------------------------------------- +// Multisampled textures +// --------------------------------------------------------------------------- + +export const WgslMultisampledTextureSchema = z.object({ + kind: z.literal('texture_multisampled_2d'), + componentType: WgslSampledTypeSchema, +}); +export type WgslMultisampledTexture = z.infer; + +export const WgslDepthMultisampledTextureSchema = z.object({ + kind: z.literal('texture_depth_multisampled_2d'), +}); +export type WgslDepthMultisampledTexture = z.infer; + +// --------------------------------------------------------------------------- +// Storage textures → texture_storage_* +// --------------------------------------------------------------------------- + +/** Access modes for WGSL storage textures. Note: underscores, not hyphens. */ +export const WgslStorageAccessModeSchema = z.enum(['read', 'write', 'read_write']); +export type WgslStorageAccessMode = z.infer; + +/** + * Texel formats valid for WGSL storage textures (spec §14.9.1). + * Formats after the first group require optional WebGPU features. + */ +export const WgslTexelFormatSchema = z.enum([ + // Core texel formats + 'bgra8unorm', + 'r32float', + 'r32sint', + 'r32uint', + 'rg32float', + 'rg32sint', + 'rg32uint', + 'rgba8unorm', + 'rgba8snorm', + 'rgba8uint', + 'rgba8sint', + 'rgba16float', + 'rgba16uint', + 'rgba16sint', + 'rgba32float', + 'rgba32uint', + 'rgba32sint', + // Optional feature: rg11b10ufloat-renderable + 'rg11b10ufloat', + // Optional feature: rw-storage-texture-8-bit-formats + 'r8unorm', + 'r8snorm', + 'r8uint', + 'r8sint', + 'rg8unorm', + 'rg8snorm', + 'rg8uint', + 'rg8sint', + 'rgba8unorm-srgb', + // Optional feature: shader-f16 + 'r16float', + 'r16unorm', + 'r16snorm', + 'r16uint', + 'r16sint', + 'rg16float', + 'rg16unorm', + 'rg16snorm', + 'rg16uint', + 'rg16sint', + 'rgba16unorm', + 'rgba16snorm', +]); +export type WgslTexelFormat = z.infer; + +export const WgslStorageTextureSchema = z.object({ + kind: z.literal('texture_storage'), + dimension: WgslStorageTextureDimensionSchema, + format: WgslTexelFormatSchema, + access: WgslStorageAccessModeSchema, +}); +export type WgslStorageTexture = z.infer; + +// --------------------------------------------------------------------------- +// External texture → texture_external +// --------------------------------------------------------------------------- + +export const WgslExternalTextureSchema = z.object({ kind: z.literal('texture_external') }); +export type WgslExternalTexture = z.infer; + +/** Union of all WGSL texture-type variants. Used as the type constraint for texture variable declarations. */ +export type WgslTextureDataType = + | WgslTexture + | WgslDepthTexture + | WgslMultisampledTexture + | WgslDepthMultisampledTexture + | WgslStorageTexture + | WgslExternalTexture; + +// --------------------------------------------------------------------------- +// Atomic → atomic | atomic +// --------------------------------------------------------------------------- + +export const WgslAtomicSchema = z.object({ + kind: z.literal('atomic'), + componentType: WgslAtomicInnerTypeSchema, +}); +export type WgslAtomic = z.infer; + +// --------------------------------------------------------------------------- +// Arrays — recursive: element type is any WgslDataType +// --------------------------------------------------------------------------- + +/** Fixed-size array: `array`. */ +export type WgslFixedArray = { + kind: 'array'; + elementType: WgslDataType; + size: number; +}; + +/** Runtime-sized array: `array`. Only valid in storage buffers. */ +export type WgslRuntimeArray = { + kind: 'runtime_array'; + elementType: WgslDataType; +}; + +// --------------------------------------------------------------------------- +// Master union +// --------------------------------------------------------------------------- + +export type WgslDataType = + | WgslScalar + | WgslVec + | WgslMat + | WgslSampler + | WgslSamplerComparison + | WgslTexture + | WgslDepthTexture + | WgslMultisampledTexture + | WgslDepthMultisampledTexture + | WgslStorageTexture + | WgslExternalTexture + | WgslAtomic + | WgslFixedArray + | WgslRuntimeArray; + +// Array schemas are declared after `WgslDataType` so that the `z.lazy` +// callback can close over `WgslDataTypeSchema`. +export const WgslFixedArraySchema = z.object({ + kind: z.literal('array'), + elementType: z.lazy((): z.ZodType => WgslDataTypeSchema), + size: z.number().int().positive(), +}); + +export const WgslRuntimeArraySchema = z.object({ + kind: z.literal('runtime_array'), + elementType: z.lazy((): z.ZodType => WgslDataTypeSchema), +}); + +// `z.union` is used rather than `z.discriminatedUnion` so that the recursive +// array schemas (which carry a `z.lazy` elementType) are accepted without +// TypeScript complaining about the relaxed ZodType constraint. +export const WgslDataTypeSchema: z.ZodType = z.union([ + WgslScalarSchema, + WgslVecSchema, + WgslMatSchema, + WgslSamplerSchema, + WgslSamplerComparisonSchema, + WgslTextureSchema, + WgslDepthTextureSchema, + WgslMultisampledTextureSchema, + WgslDepthMultisampledTextureSchema, + WgslStorageTextureSchema, + WgslExternalTextureSchema, + WgslAtomicSchema, + WgslFixedArraySchema, + WgslRuntimeArraySchema, +]); + +// --------------------------------------------------------------------------- +// WGSL source string conversion +// --------------------------------------------------------------------------- + +const VEC_SUFFIX: Record = { + i32: 'i', + u32: 'u', + f32: 'f', + f16: 'h', +}; + +const MAT_SUFFIX: Record = { + f32: 'f', + f16: 'h', +}; + +/** + * Returns the WGSL source string for the given data type. + * + * @example + * wgslTypeName(vec3f) // → "vec3f" + * wgslTypeName(mat4x4f) // → "mat4x4f" + * wgslTypeName(texture('2d', 'f32')) // → "texture_2d" + * wgslTypeName(storageTexture('2d','rgba8unorm','write')) // → "texture_storage_2d" + * wgslTypeName(fixedArray(f32, 4)) // → "array" + */ +export function wgslTypeName(type: WgslDataType): string { + switch (type.kind) { + case 'scalar': + return type.type; + case 'vec': + return `vec${type.size}${VEC_SUFFIX[type.componentType]}`; + case 'mat': + return `mat${type.cols}x${type.rows}${MAT_SUFFIX[type.componentType]}`; + case 'sampler': + return 'sampler'; + case 'sampler_comparison': + return 'sampler_comparison'; + case 'texture': + return `texture_${type.dimension}<${type.componentType}>`; + case 'texture_depth': + return `texture_depth_${type.dimension}`; + case 'texture_multisampled_2d': + return `texture_multisampled_2d<${type.componentType}>`; + case 'texture_depth_multisampled_2d': + return 'texture_depth_multisampled_2d'; + case 'texture_storage': + return `texture_storage_${type.dimension}<${type.format}, ${type.access}>`; + case 'texture_external': + return 'texture_external'; + case 'atomic': + return `atomic<${type.componentType}>`; + case 'array': + return `array<${wgslTypeName(type.elementType)}, ${type.size}>`; + case 'runtime_array': + return `array<${wgslTypeName(type.elementType)}>`; + } +} + +// --------------------------------------------------------------------------- +// Constructor functions +// --------------------------------------------------------------------------- + +export const scalar = (type: WgslScalarType): WgslScalar => ({ kind: 'scalar', type }); + +export const vec = (size: WgslVecSize, componentType: WgslNumericScalarType): WgslVec => ({ + kind: 'vec', + size, + componentType, +}); + +export const mat = (cols: WgslMatDim, rows: WgslMatDim, componentType: WgslFloatScalarType): WgslMat => ({ + kind: 'mat', + cols, + rows, + componentType, +}); + +export const texture = (dimension: WgslTextureDimension, componentType: WgslSampledType): WgslTexture => ({ + kind: 'texture', + dimension, + componentType, +}); + +export const depthTexture = (dimension: WgslDepthTextureDimension): WgslDepthTexture => ({ + kind: 'texture_depth', + dimension, +}); + +export const multisampledTexture = (componentType: WgslSampledType): WgslMultisampledTexture => ({ + kind: 'texture_multisampled_2d', + componentType, +}); + +export const storageTexture = ( + dimension: WgslStorageTextureDimension, + format: WgslTexelFormat, + access: WgslStorageAccessMode +): WgslStorageTexture => ({ kind: 'texture_storage', dimension, format, access }); + +export const atomic = (componentType: WgslAtomicInnerType): WgslAtomic => ({ + kind: 'atomic', + componentType, +}); + +export const fixedArray = (elementType: WgslDataType, size: number): WgslFixedArray => ({ + kind: 'array', + elementType, + size, +}); + +export const runtimeArray = (elementType: WgslDataType): WgslRuntimeArray => ({ + kind: 'runtime_array', + elementType, +}); + +// --------------------------------------------------------------------------- +// Pre-instantiated shorthands — names match WGSL built-in type aliases +// --------------------------------------------------------------------------- + +// Scalars +export const bool: WgslScalar = { kind: 'scalar', type: 'bool' }; +export const i32: WgslScalar = { kind: 'scalar', type: 'i32' }; +export const u32: WgslScalar = { kind: 'scalar', type: 'u32' }; +export const f32: WgslScalar = { kind: 'scalar', type: 'f32' }; +export const f16: WgslScalar = { kind: 'scalar', type: 'f16' }; + +// vec2 variants +export const vec2i: WgslVec = { kind: 'vec', size: 2, componentType: 'i32' }; +export const vec2u: WgslVec = { kind: 'vec', size: 2, componentType: 'u32' }; +export const vec2f: WgslVec = { kind: 'vec', size: 2, componentType: 'f32' }; +export const vec2h: WgslVec = { kind: 'vec', size: 2, componentType: 'f16' }; + +// vec3 variants +export const vec3i: WgslVec = { kind: 'vec', size: 3, componentType: 'i32' }; +export const vec3u: WgslVec = { kind: 'vec', size: 3, componentType: 'u32' }; +export const vec3f: WgslVec = { kind: 'vec', size: 3, componentType: 'f32' }; +export const vec3h: WgslVec = { kind: 'vec', size: 3, componentType: 'f16' }; + +// vec4 variants +export const vec4i: WgslVec = { kind: 'vec', size: 4, componentType: 'i32' }; +export const vec4u: WgslVec = { kind: 'vec', size: 4, componentType: 'u32' }; +export const vec4f: WgslVec = { kind: 'vec', size: 4, componentType: 'f32' }; +export const vec4h: WgslVec = { kind: 'vec', size: 4, componentType: 'f16' }; + +// mat (f32) +export const mat2x2f: WgslMat = { kind: 'mat', cols: 2, rows: 2, componentType: 'f32' }; +export const mat2x3f: WgslMat = { kind: 'mat', cols: 2, rows: 3, componentType: 'f32' }; +export const mat2x4f: WgslMat = { kind: 'mat', cols: 2, rows: 4, componentType: 'f32' }; +export const mat3x2f: WgslMat = { kind: 'mat', cols: 3, rows: 2, componentType: 'f32' }; +export const mat3x3f: WgslMat = { kind: 'mat', cols: 3, rows: 3, componentType: 'f32' }; +export const mat3x4f: WgslMat = { kind: 'mat', cols: 3, rows: 4, componentType: 'f32' }; +export const mat4x2f: WgslMat = { kind: 'mat', cols: 4, rows: 2, componentType: 'f32' }; +export const mat4x3f: WgslMat = { kind: 'mat', cols: 4, rows: 3, componentType: 'f32' }; +export const mat4x4f: WgslMat = { kind: 'mat', cols: 4, rows: 4, componentType: 'f32' }; + +// mat (f16) +export const mat2x2h: WgslMat = { kind: 'mat', cols: 2, rows: 2, componentType: 'f16' }; +export const mat2x3h: WgslMat = { kind: 'mat', cols: 2, rows: 3, componentType: 'f16' }; +export const mat2x4h: WgslMat = { kind: 'mat', cols: 2, rows: 4, componentType: 'f16' }; +export const mat3x2h: WgslMat = { kind: 'mat', cols: 3, rows: 2, componentType: 'f16' }; +export const mat3x3h: WgslMat = { kind: 'mat', cols: 3, rows: 3, componentType: 'f16' }; +export const mat3x4h: WgslMat = { kind: 'mat', cols: 3, rows: 4, componentType: 'f16' }; +export const mat4x2h: WgslMat = { kind: 'mat', cols: 4, rows: 2, componentType: 'f16' }; +export const mat4x3h: WgslMat = { kind: 'mat', cols: 4, rows: 3, componentType: 'f16' }; +export const mat4x4h: WgslMat = { kind: 'mat', cols: 4, rows: 4, componentType: 'f16' }; + +// Samplers +export const sampler: WgslSampler = { kind: 'sampler' }; +// eslint-disable-next-line camelcase +export const sampler_comparison: WgslSamplerComparison = { kind: 'sampler_comparison' }; + +// Zero-parameter texture singletons +export const texture_external: WgslExternalTexture = { kind: 'texture_external' }; +// eslint-disable-next-line camelcase +export const texture_depth_multisampled_2d: WgslDepthMultisampledTexture = { + kind: 'texture_depth_multisampled_2d', +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be646ee3..0132b93e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: uuid: specifier: 14.0.0 version: 14.0.0 + zod: + specifier: 4.3.6 + version: 4.3.6 devDependencies: '@types/lodash': specifier: 4.17.24 From 04242473350ebe501ab7399b576e28846fce7e91 Mon Sep 17 00:00:00 2001 From: Joel Arbuckle Date: Wed, 24 Jun 2026 10:02:21 -0700 Subject: [PATCH 05/20] Removed the 1:1 assumption about abstract resources vs. bound resources --- .../webgpu/pipelines/bind-group-cache.test.ts | 74 +++++++ .../webgpu/pipelines/bind-group-cache.ts | 79 ++++++++ .../webgpu/pipelines/binding-graphs.ts | 15 +- .../webgpu/pipelines/draw-context.test.ts | 92 +++++++++ .../webgpu/pipelines/draw-context.ts | 93 +++++++++ .../webgpu/pipelines/traversal.test.ts | 185 +++++++++++++++++- .../rendering/webgpu/pipelines/traversal.ts | 111 +++++++++-- 7 files changed, 622 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/bind-group-cache.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/draw-context.test.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/draw-context.ts diff --git a/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts b/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts new file mode 100644 index 00000000..369a785a --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { ShaderStageFlag } from '../native-types'; +import { textureResource, uniformResource } from '../resources'; +import { shader } from '../shaders'; +import { computeBindGroupCacheKey } from './bind-group-cache'; +import { group, makeBindingGraph, pipeline, resource } from './binding-graphs'; +import type { DrawContext } from './draw-context'; +import type { ResourceData } from './resources'; +import { traverseBindingGraphLayout } from './traversal'; + +const fakeBuffer: ResourceData = { buffer: {} as GPUBuffer }; +const fakeTexture: ResourceData = { texture: {} as GPUTexture }; + +const makeCtx = (overrides: Partial = {}): DrawContext => ({ + drawableId: 'd1', + frameIndex: 0, + ...overrides, +}); + +function makeTwoBindingLayout() { + const u = uniformResource('u', 'U'); + const t = textureResource('tex', 'texture_2d'); + const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); + const bg = makeBindingGraph([ + group(undefined, [resource(undefined, u, fakeBuffer, [pl]), resource(undefined, t, fakeTexture, [pl])]), + ]); + return traverseBindingGraphLayout(bg); +} + +describe('computeBindGroupCacheKey', () => { + it('produces a stable, deterministic string for identical inputs', () => { + const layout = makeTwoBindingLayout(); + const ctx = makeCtx(); + const versions = [[0, 0]]; + expect(computeBindGroupCacheKey(layout, ctx, versions)).toBe( + computeBindGroupCacheKey(layout, ctx, versions) + ); + }); + + it('key changes when drawableId changes', () => { + const layout = makeTwoBindingLayout(); + const versions = [[0, 0]]; + const a = computeBindGroupCacheKey(layout, makeCtx({ drawableId: 'A' }), versions); + const b = computeBindGroupCacheKey(layout, makeCtx({ drawableId: 'B' }), versions); + expect(a).not.toBe(b); + }); + + it('key changes when any content version changes', () => { + const layout = makeTwoBindingLayout(); + const ctx = makeCtx(); + const before = computeBindGroupCacheKey(layout, ctx, [[0, 0]]); + const afterFirst = computeBindGroupCacheKey(layout, ctx, [[1, 0]]); + const afterSecond = computeBindGroupCacheKey(layout, ctx, [[0, 1]]); + expect(afterFirst).not.toBe(before); + expect(afterSecond).not.toBe(before); + expect(afterFirst).not.toBe(afterSecond); + }); + + it('key does NOT change when frameIndex alone changes', () => { + // frameIndex is informational for providers; bind-group identity does not depend on it. + const layout = makeTwoBindingLayout(); + const versions = [[0, 0]]; + const a = computeBindGroupCacheKey(layout, makeCtx({ frameIndex: 0 }), versions); + const b = computeBindGroupCacheKey(layout, makeCtx({ frameIndex: 99 }), versions); + expect(a).toBe(b); + }); + + it('throws when contentVersions shape does not match the layout', () => { + const layout = makeTwoBindingLayout(); + expect(() => computeBindGroupCacheKey(layout, makeCtx(), [])).toThrow(/groups/); + expect(() => computeBindGroupCacheKey(layout, makeCtx(), [[0]])).toThrow(/bindings/); + expect(() => computeBindGroupCacheKey(layout, makeCtx(), [[0, 0, 0]])).toThrow(/bindings/); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.ts b/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.ts new file mode 100644 index 00000000..9f9d962f --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.ts @@ -0,0 +1,79 @@ +/** + * Cache-key helpers for assembled `GPUBindGroup` objects. + * + * The actual bind-group cache lives in the encoder layer (see + * `packages/core/src/rendering/webgpu/plan-2026-06-01.md`); this module just declares the agreed + * key shape so the encoder and any provider-aware caller produce identical keys for the same + * `(layout, drawable, content)` triple. + * + * What the key encodes (and what it does not). The key must change whenever the assembled + * `GPUBindGroup` would change. That happens when: + * 1. The drawable identity changes (`ctx.drawableId`). + * 2. The layout shape changes (which group, which binding count) - captured as a layout + * fingerprint computed from `LayoutResult.layouts`. + * 3. The content backing any binding changes - captured by the caller-supplied + * `contentVersions` parallel array. + * + * The key does NOT encode bind-group-layout entry contents (sample types, formats, etc.) - those + * are static for the lifetime of the `LayoutResult`. If the caller swaps in a new `LayoutResult` + * (e.g., after a layout-shape change), keys minted against the new layout are naturally distinct + * from keys minted against the old one because the layout fingerprint changes. + * + * Phase 1 ships these declarations only; no consumer wires them up. The encoder PR (see + * `plan-2026-06-01.md`) will be the first consumer. + */ + +import type { DrawContext } from './draw-context'; +import type { LayoutResult } from './traversal'; + +/** Stable string identifier for an assembled `GPUBindGroup`. Suitable as a `Map` key. */ +export type BindGroupCacheKey = string; + +/** + * Per-slot content version vector parallel to `LayoutResult.slots`. `contentVersions[g][b]` is a + * monotonically increasing number bumped by the owner of the binding's data whenever that data + * changes in a way the cached `GPUBindGroup` would not see (typically: the provider returns a + * different `GPUBuffer` object, or the underlying texture view is recreated). + * + * For fixed slots whose data never changes, the caller may pass `0` indefinitely. + */ +export type ContentVersions = ReadonlyArray>; + +/** + * Compute the stable cache key for the bind groups assembled from `layout` against `ctx` with + * the given per-slot `contentVersions`. The function is pure: identical inputs produce an + * identical key string. + * + * Throws if `contentVersions` does not match the shape of `layout.slots`. + */ +export function computeBindGroupCacheKey( + layout: LayoutResult, + ctx: DrawContext, + contentVersions: ContentVersions +): BindGroupCacheKey { + if (contentVersions.length !== layout.slots.length) { + throw new Error( + `computeBindGroupCacheKey: contentVersions has ${contentVersions.length} groups, ` + + `but layout has ${layout.slots.length}.` + ); + } + + const layoutFingerprint = layout.layouts.map((entries) => entries.length).join(','); + const versionParts: string[] = []; + for (let g = 0; g < layout.slots.length; g++) { + const groupSlots = layout.slots[g]; + const groupVersions = contentVersions[g]; + if (groupSlots === undefined || groupVersions === undefined) { + throw new Error(`computeBindGroupCacheKey: missing entry at group ${g}.`); + } + if (groupVersions.length !== groupSlots.length) { + throw new Error( + `computeBindGroupCacheKey: contentVersions[${g}] has ${groupVersions.length} entries, ` + + `but layout group ${g} has ${groupSlots.length} bindings.` + ); + } + versionParts.push(groupVersions.join(',')); + } + + return `${ctx.drawableId}#L:${layoutFingerprint}#V:${versionParts.join('|')}`; +} diff --git a/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts index 350a4bf9..4fee171c 100644 --- a/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts @@ -1,20 +1,25 @@ import type { ShaderStageFlags } from '../native-types'; import type { Resource } from '../resources'; import type { WgslShader } from '../shaders'; +import type { ResourceProvider } from './draw-context'; import type { ResourceData } from './resources'; /** * A resource node in the binding graph. Pairs a metadata-only `Resource` *descriptor* (used to - * generate the WGSL declaration and to populate the `GPUBindGroupLayoutEntry`) with the concrete - * `ResourceData` (the actual GPU object that backs `GPUBindGroupEntry.resource`). + * generate the WGSL declaration and to populate the `GPUBindGroupLayoutEntry`) with either a + * concrete `ResourceData` (a single shared GPU object for every drawable that touches this slot) + * or a `ResourceProvider` callable that yields a `ResourceData` per `DrawContext` (for + * per-drawable bindings such as per-instance uniforms). */ export type BindingGraphResourceNode = { __nodeType: 'resource'; /** Metadata-only descriptor; used by binding-graph traversal to produce the BGL entry and to * generate the WGSL declaration once a `{group, binding}` is assigned. */ descriptor: Resource; - /** The concrete GPU object that will populate the bind-group entry at draw time. */ - gpu: ResourceData; + /** Either the concrete GPU object that will populate the bind-group entry at draw time, or a + * per-draw `ResourceProvider` invoked during `assembleBindGroupResources`. Both options + * produce identical `layouts`/`bindings`; only the resolution timing differs. */ + gpu: ResourceData | ResourceProvider; /** Pipelines that reference this resource. The union of their `stages` drives visibility. */ pipelines: BindingGraphPipelineNode[]; label?: string; @@ -110,7 +115,7 @@ export function group( export function resource( label: string | undefined, descriptor: Resource, - gpu: ResourceData, + gpu: ResourceData | ResourceProvider, pipelines: BindingGraphPipelineNode[] ): BindingGraphResourceNode { return { diff --git a/packages/core/src/rendering/webgpu/pipelines/draw-context.test.ts b/packages/core/src/rendering/webgpu/pipelines/draw-context.test.ts new file mode 100644 index 00000000..c12d5603 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/draw-context.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DrawableLeases, isResourceProvider, type ResourceProvider } from './draw-context'; +import type { ResourceData } from './resources'; + +describe('isResourceProvider', () => { + it('returns true for a callable', () => { + const provider: ResourceProvider = () => ({ buffer: {} as GPUBuffer }); + expect(isResourceProvider(provider)).toBe(true); + }); + + it('returns false for a fixed ResourceData object', () => { + const buffer: ResourceData = { buffer: {} as GPUBuffer }; + const texture: ResourceData = { texture: {} as GPUTexture }; + const sampler: ResourceData = { sampler: {} as GPUSampler }; + expect(isResourceProvider(buffer)).toBe(false); + expect(isResourceProvider(texture)).toBe(false); + expect(isResourceProvider(sampler)).toBe(false); + }); + + it('returns false for primitives and null', () => { + expect(isResourceProvider(null)).toBe(false); + expect(isResourceProvider(undefined)).toBe(false); + expect(isResourceProvider(false)).toBe(false); + expect(isResourceProvider(42)).toBe(false); + expect(isResourceProvider('not a provider')).toBe(false); + }); +}); + +describe('DrawableLeases', () => { + it('starts empty', () => { + const leases = new DrawableLeases(); + expect(leases.count()).toBe(0); + }); + + it('add() increments count and releaseAll() invokes each release exactly once', () => { + const leases = new DrawableLeases(); + const a = { release: vi.fn() }; + const b = { release: vi.fn() }; + const c = { release: vi.fn() }; + + leases.add(a); + leases.add(b); + leases.add(c); + expect(leases.count()).toBe(3); + + leases.releaseAll(); + + expect(a.release).toHaveBeenCalledTimes(1); + expect(b.release).toHaveBeenCalledTimes(1); + expect(c.release).toHaveBeenCalledTimes(1); + expect(leases.count()).toBe(0); + }); + + it('releases tokens in insertion order', () => { + const leases = new DrawableLeases(); + const order: string[] = []; + leases.add({ release: () => order.push('a') }); + leases.add({ release: () => order.push('b') }); + leases.add({ release: () => order.push('c') }); + leases.releaseAll(); + expect(order).toEqual(['a', 'b', 'c']); + }); + + it('a second releaseAll() is a no-op until further tokens are added', () => { + const leases = new DrawableLeases(); + const r = { release: vi.fn() }; + leases.add(r); + leases.releaseAll(); + leases.releaseAll(); + expect(r.release).toHaveBeenCalledTimes(1); + + const r2 = { release: vi.fn() }; + leases.add(r2); + leases.releaseAll(); + expect(r.release).toHaveBeenCalledTimes(1); + expect(r2.release).toHaveBeenCalledTimes(1); + }); + + it('nested DrawableLeases releases its children when released by an outer bundle', () => { + const outer = new DrawableLeases(); + const inner = new DrawableLeases(); + const leaf = { release: vi.fn() }; + inner.add(leaf); + outer.add(inner); + + outer.releaseAll(); + + expect(leaf.release).toHaveBeenCalledTimes(1); + expect(inner.count()).toBe(0); + expect(outer.count()).toBe(0); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/draw-context.ts b/packages/core/src/rendering/webgpu/pipelines/draw-context.ts new file mode 100644 index 00000000..14ad862f --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/draw-context.ts @@ -0,0 +1,93 @@ +/** + * `DrawContext` and `ResourceProvider` enable a single binding-graph `Resource` to be backed by a + * different `ResourceData` per drawable, without rebuilding the graph for each draw. + * + * The binding-graph layout (group/binding assignments, BGL entries) is computed once with + * `traverseBindingGraphLayout(graph)` (see `./traversal`). Per drawable, the caller invokes + * `assembleBindGroupResources(layout, ctx)` to gather the concrete `ResourceData[][]`. Provider + * slots invoke their callable with the supplied `ctx`; fixed slots pass through their literal + * `ResourceData`. + * + * Phase 1 ships the types and a usable `DrawableLeases` collector. Phase 2 (the buffer-adapter + * implementation) will arrange for providers that acquire from the pool to push their lease + * tokens into `ctx.leases`, so a drawable's destruction can release all of its leases via a + * single `releaseAll()` call. + */ + +import type { ResourceData } from './resources'; + +/** + * Per-drawable, per-frame context threaded through `assembleBindGroupResources` to providers. + * + * `drawableId` is the stable identity used for bind-group cache keying (see + * `./bind-group-cache`). `frameIndex` lets providers vary by frame (e.g., a triple-buffered + * uniform). `leases`, when present, is the destination for any per-draw `release()`-able tokens + * a provider acquires (e.g., buffer leases from the Phase 2 adapter). + */ +export type DrawContext = { + readonly drawableId: string; + readonly frameIndex: number; + readonly leases?: DrawableLeases; +}; + +/** + * Callable that yields a concrete `ResourceData` for a given draw. Providers should be pure + * with respect to `ctx` and any external state they read (a provider invoked twice with the same + * `ctx` should return equivalent `ResourceData`). + */ +export type ResourceProvider = (ctx: DrawContext) => ResourceData; + +/** + * Runtime guard distinguishing a `ResourceProvider` (a callable) from a fixed `ResourceData` + * (always an object). Because `ResourceData` is exclusively object-shaped + * (`{ buffer }` | `{ texture }` | `{ sampler }`), `typeof === 'function'` is unambiguous. + */ +export function isResourceProvider(value: unknown): value is ResourceProvider { + return typeof value === 'function'; +} + +/** Anything that can be released by a drawable's lease bundle. */ +export interface Releasable { + release(): void; +} + +/** + * Bundle of `Releasable` tokens scoped to a single drawable. Providers (in Phase 2) push their + * acquired buffer leases here so the drawable's owner can `releaseAll()` on destruction without + * tracking each handle individually. + * + * Phase 1: usable as a plain collector; calling `releaseAll()` invokes each `release()` once and + * clears the internal list. + */ +export class DrawableLeases implements Releasable { + #items: Releasable[] = []; + + /** Add a releasable token to this bundle. Insertion order is preserved. */ + add(releasable: Releasable): void { + this.#items.push(releasable); + } + + /** + * Release every token added since construction (or since the last `releaseAll()`), in + * insertion order, then clear the internal list. Safe to call repeatedly; subsequent calls + * are no-ops until further tokens are added. + */ + releaseAll(): void { + const items = this.#items; + this.#items = []; + for (const item of items) { + item.release(); + } + } + + /** Convenience: behaves identically to `releaseAll()`, so a bundle can itself be added to + * another bundle. */ + release(): void { + this.releaseAll(); + } + + /** Number of currently-held tokens. */ + count(): number { + return this.#items.length; + } +} diff --git a/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts b/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts index b45deda3..49e06d8b 100644 --- a/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts +++ b/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts @@ -1,15 +1,26 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ShaderStageFlag } from '../native-types'; import { bindShader, samplerResource, textureResource, uniformResource } from '../resources'; import { asSource, shader } from '../shaders'; import { group, makeBindingGraph, pipeline, resource } from './binding-graphs'; +import type { DrawContext, ResourceProvider } from './draw-context'; import type { ResourceData } from './resources'; -import { traverseBindingGraph } from './traversal'; +import { + assembleBindGroupResources, + traverseBindingGraph, + traverseBindingGraphLayout, +} from './traversal'; const fakeBuffer: ResourceData = { buffer: {} as GPUBuffer }; const fakeTexture: ResourceData = { texture: {} as GPUTexture }; const fakeSampler: ResourceData = { sampler: {} as GPUSampler }; +const makeCtx = (overrides: Partial = {}): DrawContext => ({ + drawableId: 'drawable-1', + frameIndex: 0, + ...overrides, +}); + describe('traverseBindingGraph — single group', () => { it('assigns sequential bindings within a group', () => { const u = uniformResource('u', 'U'); @@ -130,3 +141,173 @@ describe('integration: traverse → bindShader → asSource', () => { ); }); }); + +describe('traverseBindingGraphLayout — fixed-only graphs', () => { + it('emits the same bindings and layouts as the legacy single-call API', () => { + const u = uniformResource('u', 'U'); + const t = textureResource('tex', 'texture_2d'); + const s = samplerResource('samp', 'sampler'); + const pl = pipeline(shader([u, t, s]), { stages: ShaderStageFlag.FRAGMENT }); + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, fakeBuffer, [pl]), + resource(undefined, t, fakeTexture, [pl]), + resource(undefined, s, fakeSampler, [pl]), + ]), + ]); + + const legacy = traverseBindingGraph(bg); + const layout = traverseBindingGraphLayout(bg); + + expect(layout.bindings.get(u)).toEqual(legacy.bindings.get(u)); + expect(layout.bindings.get(t)).toEqual(legacy.bindings.get(t)); + expect(layout.bindings.get(s)).toEqual(legacy.bindings.get(s)); + expect(layout.layouts).toEqual(legacy.layouts); + + // All slots are fixed and carry the original ResourceData by identity. + expect(layout.slots).toHaveLength(1); + expect(layout.slots[0]).toHaveLength(3); + expect(layout.slots[0]?.[0]).toEqual({ kind: 'fixed', data: fakeBuffer }); + expect(layout.slots[0]?.[1]).toEqual({ kind: 'fixed', data: fakeTexture }); + expect(layout.slots[0]?.[2]).toEqual({ kind: 'fixed', data: fakeSampler }); + }); + + it('assembleBindGroupResources on a fixed-only layout matches the legacy bindGroupResources', () => { + const u = uniformResource('u', 'U'); + const t = textureResource('tex', 'texture_2d'); + const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, fakeBuffer, [pl]), + resource(undefined, t, fakeTexture, [pl]), + ]), + ]); + + const layout = traverseBindingGraphLayout(bg); + const legacy = traverseBindingGraph(bg); + const assembled = assembleBindGroupResources(layout, makeCtx()); + + expect(assembled).toEqual(legacy.bindGroupResources); + }); +}); + +describe('traverseBindingGraphLayout — provider slots', () => { + it('emits a provider slot for callable gpu values', () => { + const u = uniformResource('u', 'U'); + const pl = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); + const provide: ResourceProvider = vi.fn(() => fakeBuffer); + const bg = makeBindingGraph([group(undefined, [resource(undefined, u, provide, [pl])])]); + + const layout = traverseBindingGraphLayout(bg); + expect(layout.slots[0]?.[0]).toEqual({ kind: 'provider', provide }); + // Providers are not invoked during layout traversal. + expect(provide).not.toHaveBeenCalled(); + }); + + it('assembleBindGroupResources invokes each provider once per call with the supplied ctx', () => { + const u = uniformResource('u', 'U'); + const t = textureResource('tex', 'texture_2d'); + const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); + + const provideU: ResourceProvider = vi.fn(() => fakeBuffer); + const provideT: ResourceProvider = vi.fn(() => fakeTexture); + + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, provideU, [pl]), + resource(undefined, t, provideT, [pl]), + ]), + ]); + const layout = traverseBindingGraphLayout(bg); + + const ctx = makeCtx({ drawableId: 'd1', frameIndex: 7 }); + const assembled = assembleBindGroupResources(layout, ctx); + + expect(provideU).toHaveBeenCalledTimes(1); + expect(provideU).toHaveBeenCalledWith(ctx); + expect(provideT).toHaveBeenCalledTimes(1); + expect(provideT).toHaveBeenCalledWith(ctx); + expect(assembled).toEqual([[fakeBuffer, fakeTexture]]); + + // A second call invokes providers again (assembly is not memoized). + assembleBindGroupResources(layout, ctx); + expect(provideU).toHaveBeenCalledTimes(2); + expect(provideT).toHaveBeenCalledTimes(2); + }); + + it('yields different ResourceData per drawable when the provider varies on ctx', () => { + const u = uniformResource('u', 'U'); + const pl = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); + + const bufferA: ResourceData = { buffer: {} as GPUBuffer }; + const bufferB: ResourceData = { buffer: {} as GPUBuffer }; + const provide: ResourceProvider = (ctx) => (ctx.drawableId === 'A' ? bufferA : bufferB); + + const bg = makeBindingGraph([group(undefined, [resource(undefined, u, provide, [pl])])]); + const layout = traverseBindingGraphLayout(bg); + + expect(assembleBindGroupResources(layout, makeCtx({ drawableId: 'A' }))[0]?.[0]).toBe(bufferA); + expect(assembleBindGroupResources(layout, makeCtx({ drawableId: 'B' }))[0]?.[0]).toBe(bufferB); + }); + + it('mixed fixed + provider slots resolve independently in the same group', () => { + const u = uniformResource('u', 'U'); + const t = textureResource('tex', 'texture_2d'); + const s = samplerResource('samp', 'sampler'); + const pl = pipeline(shader([u, t, s]), { stages: ShaderStageFlag.FRAGMENT }); + + const perDrawBuffer: ResourceData = { buffer: {} as GPUBuffer }; + const provideU: ResourceProvider = vi.fn(() => perDrawBuffer); + + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, provideU, [pl]), + resource(undefined, t, fakeTexture, [pl]), + resource(undefined, s, fakeSampler, [pl]), + ]), + ]); + const layout = traverseBindingGraphLayout(bg); + + expect(layout.slots[0]?.[0]?.kind).toBe('provider'); + expect(layout.slots[0]?.[1]?.kind).toBe('fixed'); + expect(layout.slots[0]?.[2]?.kind).toBe('fixed'); + + const assembled = assembleBindGroupResources(layout, makeCtx()); + expect(assembled).toEqual([[perDrawBuffer, fakeTexture, fakeSampler]]); + expect(provideU).toHaveBeenCalledTimes(1); + }); + + it('throws on the legacy traverseBindingGraph shim when any slot is a provider', () => { + const u = uniformResource('u', 'U'); + const pl = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); + const provide: ResourceProvider = () => fakeBuffer; + const bg = makeBindingGraph([group(undefined, [resource(undefined, u, provide, [pl])])]); + + expect(() => traverseBindingGraph(bg)).toThrow(/ResourceProvider/); + }); +}); + +describe('traverseBindingGraphLayout — layout identity stability', () => { + it('back-to-back calls produce structurally identical bindings and layouts', () => { + const u = uniformResource('u', 'U'); + const t = textureResource('tex', 'texture_2d'); + const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); + const provideU: ResourceProvider = () => fakeBuffer; + const bg = makeBindingGraph([ + group(undefined, [ + resource(undefined, u, provideU, [pl]), + resource(undefined, t, fakeTexture, [pl]), + ]), + ]); + + const a = traverseBindingGraphLayout(bg); + const b = traverseBindingGraphLayout(bg); + + expect(b.bindings.get(u)).toEqual(a.bindings.get(u)); + expect(b.bindings.get(t)).toEqual(a.bindings.get(t)); + expect(b.layouts).toEqual(a.layouts); + // Slot kinds and the underlying fixed-data identities are stable across calls. + expect(b.slots[0]?.[0]?.kind).toBe('provider'); + expect(b.slots[0]?.[1]).toEqual({ kind: 'fixed', data: fakeTexture }); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/traversal.ts b/packages/core/src/rendering/webgpu/pipelines/traversal.ts index 0dcc3522..52ae07ca 100644 --- a/packages/core/src/rendering/webgpu/pipelines/traversal.ts +++ b/packages/core/src/rendering/webgpu/pipelines/traversal.ts @@ -1,31 +1,63 @@ /** * Walks a `BindingGraph` and assigns concrete `{group, binding}` indices to every `Resource` it - * contains, producing the artifacts needed to drive a WebGPU pipeline: + * contains. Exposes a two-phase API for use with per-drawable resource providers: * - * - `bindings`: a `BindingMap` consumable by `bindShader()` to resolve a `WgslShader`'s `Resource` - * declarations into renderable WGSL. - * - `layouts`: per-group bind-group-layout-entry arrays (one per group index). - * - `bindGroupResources`: per-group `ResourceData` arrays, parallel to `layouts`, so the caller - * can build `GPUBindGroupEntry` objects at draw time (typically by calling `createView()` on - * textures, which requires a live `GPUDevice`). + * - `traverseBindingGraphLayout(graph)` — run once per graph. Computes the binding map, per-group + * BGL entries, and per-group `slots[]` arrays describing how each binding's concrete + * `ResourceData` is obtained (`{ kind: 'fixed', data }` or `{ kind: 'provider', provide }`). + * - `assembleBindGroupResources(layout, ctx)` — run once per drawable. Returns the + * `ResourceData[][]` ready to feed `GPUBindGroupEntry` creation. Provider slots are invoked + * with the supplied `ctx`; fixed slots pass their literal value through. + * + * `traverseBindingGraph` remains as a guarded single-call shim for legacy fixed-data graphs. + * Passing a graph containing any `ResourceProvider` slot causes it to throw. * * Group/binding assignment is deterministic and based purely on position in the graph: * - Group index = position of the group in the flattened walk of `graph.groups` (each top-level * group plus its `subgroup` chain expands to one index per node). * - Binding index = position within the group's `resources[]` array. + * + * **Layout vs. content invalidation discipline.** A `ResourceProvider` returning a different + * `GPUBuffer` (or other `ResourceData`) of the *same shape* (same `usage` flags, within the same + * declared size class) only invalidates the assembled bind group — the layout is unchanged. + * Returning a `ResourceData` with different `usage` flags or a buffer outside the declared size + * class is a *layout-shape* change: callers must reconstruct the graph and re-run + * `traverseBindingGraphLayout`. The two-phase split exists to make this distinction cheap. */ -import { - type BindGroupLayoutEntry, - ShaderStageFlag, - type ShaderStageFlags, -} from '../native-types'; +import { type BindGroupLayoutEntry, ShaderStageFlag, type ShaderStageFlags } from '../native-types'; import { type BindingMap, bind, type Resource, toBindGroupLayoutEntry } from '../resources'; import type { BindingGraph, BindingGraphGroupNode, BindingGraphPipelineNode } from './binding-graphs'; +import { type DrawContext, isResourceProvider, type ResourceProvider } from './draw-context'; import type { ResourceData } from './resources'; const ALL_STAGES: ShaderStageFlags = ShaderStageFlag.VERTEX | ShaderStageFlag.FRAGMENT | ShaderStageFlag.COMPUTE; +/** + * Per-slot description emitted by `traverseBindingGraphLayout`. A `'fixed'` slot carries its + * `ResourceData` directly; a `'provider'` slot carries a `ResourceProvider` to be invoked per + * draw inside `assembleBindGroupResources`. + */ +export type SlotProvider = + | { readonly kind: 'fixed'; readonly data: ResourceData } + | { readonly kind: 'provider'; readonly provide: ResourceProvider }; + +/** + * Output of `traverseBindingGraphLayout`. `bindings` and `layouts` are stable across drawables; + * `slots` is consumed per draw by `assembleBindGroupResources`. + */ +export type LayoutResult = { + bindings: BindingMap; + /** `layouts[group]` is the BGL entry list for `@group(group)`. */ + layouts: BindGroupLayoutEntry[][]; + /** `slots[group][binding]` describes how to obtain the concrete `ResourceData` for that slot. */ + slots: SlotProvider[][]; +}; + +/** + * Output of the legacy single-call `traverseBindingGraph`. Identical in shape to before the + * two-phase split; emitted only when every slot is fixed. + */ export type TraversalResult = { bindings: BindingMap; /** `layouts[group]` is the BGL entry list for `@group(group)`. */ @@ -35,21 +67,22 @@ export type TraversalResult = { }; /** - * Walks `graph` and produces a `BindingMap` + per-group BGL entries + per-group resource arrays. + * Phase 1 of the two-phase API. Walks `graph` and produces the static layout artifacts: a + * `BindingMap`, per-group `GPUBindGroupLayoutEntry` arrays, and per-group `SlotProvider` arrays. * * Throws if the same `Resource` object is referenced at more than one position in the graph * (a single resource cannot occupy two distinct `{group, binding}` slots). */ -export function traverseBindingGraph(graph: BindingGraph): TraversalResult { +export function traverseBindingGraphLayout(graph: BindingGraph): LayoutResult { const flattened = flattenGroups(graph.groups); const bindings = new Map(); const layouts: BindGroupLayoutEntry[][] = []; - const bindGroupResources: ResourceData[][] = []; + const slots: SlotProvider[][] = []; flattened.forEach((groupNode, groupIndex) => { const groupEntries: BindGroupLayoutEntry[] = []; - const groupResources: ResourceData[] = []; + const groupSlots: SlotProvider[] = []; groupNode.resources.forEach((resNode, bindingIndex) => { const descriptor = resNode.descriptor; @@ -57,7 +90,7 @@ export function traverseBindingGraph(graph: BindingGraph): TraversalResult { if (bindings.has(descriptor)) { const prior = bindings.get(descriptor) as { group: number; binding: number }; throw new Error( - `traverseBindingGraph: resource '${descriptor.name}' appears at multiple ` + + `traverseBindingGraphLayout: resource '${descriptor.name}' appears at multiple ` + `positions in the graph (group ${prior.group} binding ${prior.binding}, and ` + `group ${groupIndex} binding ${bindingIndex}). A Resource may only be bound once.` ); @@ -68,14 +101,52 @@ export function traverseBindingGraph(graph: BindingGraph): TraversalResult { const visibility = resolveVisibility(descriptor, resNode.pipelines); const bound = bind(descriptor, groupIndex, bindingIndex); groupEntries.push(toBindGroupLayoutEntry(bound, visibility)); - groupResources.push(resNode.gpu); + + const slot: SlotProvider = isResourceProvider(resNode.gpu) + ? { kind: 'provider', provide: resNode.gpu } + : { kind: 'fixed', data: resNode.gpu }; + groupSlots.push(slot); }); layouts.push(groupEntries); - bindGroupResources.push(groupResources); + slots.push(groupSlots); }); - return { bindings, layouts, bindGroupResources }; + return { bindings, layouts, slots }; +} + +/** + * Phase 2 of the two-phase API. Resolves each `SlotProvider` against `ctx` and returns a per-group + * `ResourceData[][]` ready to populate `GPUBindGroupEntry` arrays. Cheap enough to call per + * drawable per frame; provider invocations are the only non-trivial work. + */ +export function assembleBindGroupResources(layout: LayoutResult, ctx: DrawContext): ResourceData[][] { + return layout.slots.map((groupSlots) => + groupSlots.map((slot) => (slot.kind === 'fixed' ? slot.data : slot.provide(ctx))) + ); +} + +/** + * Legacy single-call API. Computes layout and resolves all slots eagerly; equivalent to + * `traverseBindingGraphLayout` followed by an `assembleBindGroupResources` that requires every + * slot to be fixed. **Throws** if any slot is a `ResourceProvider` — provider-based graphs must + * use the two-phase API directly. + */ +export function traverseBindingGraph(graph: BindingGraph): TraversalResult { + const layout = traverseBindingGraphLayout(graph); + const bindGroupResources: ResourceData[][] = layout.slots.map((groupSlots, groupIndex) => + groupSlots.map((slot, bindingIndex) => { + if (slot.kind === 'provider') { + throw new Error( + `traverseBindingGraph: slot at group ${groupIndex} binding ${bindingIndex} ` + + 'is a ResourceProvider; provider-based graphs must use ' + + 'traverseBindingGraphLayout + assembleBindGroupResources.' + ); + } + return slot.data; + }) + ); + return { bindings: layout.bindings, layouts: layout.layouts, bindGroupResources }; } /** From 2edc92558ddad0008091b63c343a2b32d22f11e8 Mon Sep 17 00:00:00 2001 From: Joel Arbuckle Date: Thu, 25 Jun 2026 11:49:39 -0700 Subject: [PATCH 06/20] Added base Buffer Manager and BatchPool implementation --- .../batch-pool-buffer-manager.test.ts | 457 ++++++++++++++++++ .../batch-pool/batch-pool-buffer-manager.ts | 360 ++++++++++++++ .../webgpu/memory/batch-pool/errors.ts | 20 + .../webgpu/memory/batch-pool/types.ts | 213 ++++++++ .../src/rendering/webgpu/memory/errors.ts | 40 ++ .../core/src/rendering/webgpu/memory/index.ts | 18 + .../core/src/rendering/webgpu/memory/types.ts | 185 +++++++ 7 files changed, 1293 insertions(+) create mode 100644 packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.test.ts create mode 100644 packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.ts create mode 100644 packages/core/src/rendering/webgpu/memory/batch-pool/errors.ts create mode 100644 packages/core/src/rendering/webgpu/memory/batch-pool/types.ts create mode 100644 packages/core/src/rendering/webgpu/memory/errors.ts create mode 100644 packages/core/src/rendering/webgpu/memory/index.ts create mode 100644 packages/core/src/rendering/webgpu/memory/types.ts diff --git a/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.test.ts b/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.test.ts new file mode 100644 index 00000000..0c4a1d3b --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.test.ts @@ -0,0 +1,457 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DisposedBufferError, InvalidHandleError, OutOfBudgetError } from '../errors'; +import type { + BufferManager, + BufferHandle, + BufferUsageFlags, +} from '../types'; +import { BatchPoolBufferManager } from './batch-pool-buffer-manager'; +import { OutOfBucketError } from './errors'; +import { BatchPoolBufferManagerConfig } from './types'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +type FakeBuffer = GPUBuffer & { + readonly fakeSize: number; + readonly fakeUsage: BufferUsageFlags; + readonly destroyMock: ReturnType; +}; + +function makeFakeDevice(): GPUDevice & { + createBufferMock: ReturnType; + allCreated: FakeBuffer[]; +} { + const created: FakeBuffer[] = []; + const createBufferMock = vi.fn((descriptor: GPUBufferDescriptor) => { + const destroyMock = vi.fn(); + const buf = { + size: descriptor.size, + usage: descriptor.usage, + label: descriptor.label ?? '', + mapState: 'unmapped', + fakeSize: descriptor.size, + fakeUsage: descriptor.usage, + destroyMock, + destroy: destroyMock, + getMappedRange: vi.fn(), + mapAsync: vi.fn(), + unmap: vi.fn(), + } as unknown as FakeBuffer; + created.push(buf); + return buf; + }); + // We only ever poke `createBuffer` — the rest of GPUDevice is irrelevant to the manager. + const device = { createBuffer: createBufferMock } as unknown as GPUDevice; + return Object.assign(device, { createBufferMock, allCreated: created }); +} + +const USAGE_A: BufferUsageFlags = 0x0040; // STORAGE +const USAGE_B: BufferUsageFlags = 0x0080; // INDEX (arbitrary distinct flag) + +function baseConfig( + device: GPUDevice, + overrides: Partial = {} +): BatchPoolBufferManagerConfig { + return { + device, + maxBytes: 1 << 16, + sizeBuckets: [256, 1024, 4096, 16_384], + idleFrameLimit: 2, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// BufferManager interface conformance — an in-test no-op implementation. +// (Validates the public interface is implementable without leaking implementation details.) +// --------------------------------------------------------------------------- + +describe('BufferManager interface conformance', () => { + it('is satisfied by a minimal in-test no-op implementation', () => { + class NoopManager implements BufferManager { + acquire(): BufferHandle { + throw new Error('noop'); + } + release(): void {} + beginFrame(): void {} + endFrame(): void {} + frameLease(): BufferHandle { + throw new Error('noop'); + } + stats() { + return { + residentBytes: 0, + leasedBytes: 0, + freeBytes: 0, + poolCount: 0, + perPool: [], + }; + } + dispose(): void {} + } + // If this compiles, the structural contract is satisfied. + const a: BufferManager = new NoopManager(); + expect(a.stats().residentBytes).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// BufferManager behavior tests (BatchPoolBufferManager) +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — basic acquire/release', () => { + let device: ReturnType; + let manager: BatchPoolBufferManager; + + beforeEach(() => { + device = makeFakeDevice(); + manager = new BatchPoolBufferManager({ ...baseConfig(device), growthBatchSize: 2 }); + }); + + // (1) acquire-after-release returns the same buffer (single-buffer batch isolates the + // FIFO-of-one case; with growthBatchSize > 1 the freed buffer goes to the tail and the + // next acquire takes a different free buffer first — see the next test for that case). + it('returns the same buffer when re-acquired after release (single-buffer batch)', () => { + const single = new BatchPoolBufferManager({ ...baseConfig(device), growthBatchSize: 1 }); + const h1 = single.acquire(200, USAGE_A); + const buf1 = h1.buffer; + h1.release(); // note: importantly, this does NOT invalidate h1.buffer, which is still held in buf1! + const h2 = single.acquire(200, USAGE_A); + expect(h2.buffer).toBe(buf1); + }); + + it('reuses buffers in FIFO order within a multi-buffer batch', () => { + // Batch of 2: free=[b0,b1]. acquire→b0 leased, free=[b1]. release(h1)→free=[b1,b0]. + // Next acquire returns b1 (head), not b0. + const h1 = manager.acquire(200, USAGE_A); + const b0 = h1.buffer; + h1.release(); + const h2 = manager.acquire(200, USAGE_A); + expect(h2.buffer).not.toBe(b0); + h2.release(); + const h3 = manager.acquire(200, USAGE_A); + expect(h3.buffer).toBe(b0); + }); + + it('rounds the requested size up to the smallest matching bucket', () => { + const h = manager.acquire(200, USAGE_A); + expect(h.bucketSize).toBe(256); + expect(h.sizeBytes).toBe(200); + expect(h.sizeInBytes()).toBe(256); + }); + + it('routes requests to disjoint pools by (bucketSize, usage)', () => { + const a = manager.acquire(200, USAGE_A); + const b = manager.acquire(200, USAGE_B); + const c = manager.acquire(1000, USAGE_A); + expect(a.buffer).not.toBe(b.buffer); + expect(a.buffer).not.toBe(c.buffer); + expect(b.buffer).not.toBe(c.buffer); + expect(manager.stats().poolCount).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// Miss under budget allocates `growthBatchSize` buffers +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — batch allocation on miss', () => { + it('allocates `growthBatchSize` buffers on the first acquire of a new pool', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 4, + }); + manager.acquire(200, USAGE_A); + expect(device.createBufferMock).toHaveBeenCalledTimes(4); + // 3 free, 1 leased. + const s = manager.stats(); + expect(s.leasedBytes).toBe(256); + expect(s.freeBytes).toBe(256 * 3); + expect(s.residentBytes).toBe(256 * 4); + }); + + it('does not allocate on subsequent acquires while free entries exist', () => { + const device = makeFakeDevice(); + const batchSize = 4; + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: batchSize, + }); + const h1 = manager.acquire(200, USAGE_A); + expect(device.createBufferMock).toHaveBeenCalledTimes(batchSize); + manager.acquire(200, USAGE_A); + manager.acquire(200, USAGE_A); + manager.acquire(200, USAGE_A); + // We have exactly 4 in the batch and we just took the 4th. No new allocations yet. + expect(device.createBufferMock).toHaveBeenCalledTimes(batchSize); + // 5th acquire triggers a new batch. + manager.acquire(200, USAGE_A); + expect(device.createBufferMock).toHaveBeenCalledTimes(batchSize + batchSize); + h1.release(); + }); + + it('fires telemetry hooks (onMiss, onAllocate) when a new batch is allocated', () => { + const device = makeFakeDevice(); + const onMiss = vi.fn(); + const onAllocate = vi.fn(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 2, + telemetry: { onMiss, onAllocate }, + }); + manager.acquire(200, USAGE_A); + expect(onMiss).toHaveBeenCalledWith({ + sizeBytes: 200, + bucketSize: 256, + usage: USAGE_A, + }); + expect(onAllocate).toHaveBeenCalledWith({ + bucketSize: 256, + usage: USAGE_A, + batchSize: 2, + }); + }); +}); + +// --------------------------------------------------------------------------- +// - Miss over budget triggers eviction; retries; throws if still over. +// - Leased batch never destroyed. +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — budget enforcement and eviction', () => { + it('evicts idle batches to make room for a new allocation', () => { + // Two pools: USAGE_A bucket 256, USAGE_B bucket 256. Budget = 2 batches' worth. + const device = makeFakeDevice(); + const onEvict = vi.fn(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device, { maxBytes: 256 * 2 }), + growthBatchSize: 1, + telemetry: { onEvict }, + }); + // Allocate and immediately release the USAGE_A buffer, then advance idle-frame state. + const a = manager.acquire(200, USAGE_A); + a.release(); + // The batch is now fully-free but resident. Allocating USAGE_B fits (1+1 = 2). + const b = manager.acquire(200, USAGE_B); + expect(manager.stats().residentBytes).toBe(256 * 2); + // Third acquire forces eviction: USAGE_A's idle batch is the only victim. + manager.acquire(200, USAGE_B); // Same pool as `b`; growthBatchSize=1 → new batch needed. + // The very first buffer should now be destroyed. + expect(a.buffer.destroy).toHaveBeenCalledTimes(1); + expect(onEvict).toHaveBeenCalledWith({ + bucketSize: 256, + usage: USAGE_A, + batchSize: 1, + }); + expect(manager.stats().residentBytes).toBe(256 * 2); + // Release b so dispose later doesn't trip; not strictly required. + b.release(); + }); + + it('never destroys a leased batch during eviction; throws OutOfBudgetError instead', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device, { maxBytes: 256 * 2 }), + growthBatchSize: 1, + }); + // Two leased buffers consume the full budget. + const a = manager.acquire(200, USAGE_A); + const b = manager.acquire(200, USAGE_B); + // Third request cannot evict (both batches are leased) — must throw. + expect(() => manager.acquire(200, USAGE_A)).toThrow(OutOfBudgetError); + // Neither buffer was destroyed. + expect(a.buffer.destroy).not.toHaveBeenCalled(); + expect(b.buffer.destroy).not.toHaveBeenCalled(); + }); + + it('throws OutOfBucketError when sizeBytes exceeds the largest bucket', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager(baseConfig(device)); + expect(() => manager.acquire(20_000, USAGE_A)).toThrow(OutOfBucketError); + }); +}); + +// --------------------------------------------------------------------------- +// idleFrameLimit + 1 endFrames after last release → destroy. +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — idle-batch sweep at endFrame', () => { + it('destroys a batch that has been fully-free for idleFrameLimit frames', () => { + const device = makeFakeDevice(); + const onEvict = vi.fn(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device, { idleFrameLimit: 2 }), + growthBatchSize: 1, + telemetry: { onEvict }, + }); + // Frame 0: acquire + release. Batch becomes fully-free at frame 0. + const h = manager.acquire(200, USAGE_A); + h.release(); + // endFrame at frame 0: age = 0 - 0 = 0 < 2 → keep. + manager.endFrame(); + expect(h.buffer.destroy).not.toHaveBeenCalled(); + // Frame 1: endFrame: age = 1 < 2 → keep. + manager.endFrame(); + expect(h.buffer.destroy).not.toHaveBeenCalled(); + // Frame 2: endFrame: age = 2 >= 2 → destroy. + manager.endFrame(); + expect(h.buffer.destroy).toHaveBeenCalledTimes(1); + expect(onEvict).toHaveBeenCalledTimes(1); + expect(manager.stats().residentBytes).toBe(0); + }); + + it('keeps a batch alive if any of its buffers are re-acquired before the idle window expires', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device, { idleFrameLimit: 1 }), + growthBatchSize: 1, + }); + const h = manager.acquire(200, USAGE_A); + h.release(); + manager.endFrame(); + // Re-acquire before endFrame; batch is now leased → not fully-free. + const h2 = manager.acquire(200, USAGE_A); + expect(h2.buffer).toBe(h.buffer); + manager.endFrame(); + expect(h2.buffer.destroy).not.toHaveBeenCalled(); + h2.release(); + }); +}); + +// --------------------------------------------------------------------------- +// frameLease handles released exactly once at next endFrame. +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — frameLease', () => { + it('releases frame-leased handles exactly once at the next endFrame', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + const h = manager.frameLease(200, USAGE_A); + expect(manager.stats().leasedBytes).toBe(256); + manager.endFrame(); + expect(manager.stats().leasedBytes).toBe(0); + expect(manager.stats().freeBytes).toBe(256); + // The handle is no longer valid; a second release must throw. + expect(() => h.release()).toThrow(InvalidHandleError); + }); +}); + +// --------------------------------------------------------------------------- +// Double-release throws; foreign-handle release throws. +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — handle validation', () => { + it('throws on double-release', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + const h = manager.acquire(200, USAGE_A); + h.release(); + expect(() => h.release()).toThrow(InvalidHandleError); + }); + + it('throws on a foreign handle (minted by another manager)', () => { + const deviceA = makeFakeDevice(); + const deviceB = makeFakeDevice(); + const a = new BatchPoolBufferManager({ ...baseConfig(deviceA), growthBatchSize: 1 }); + const b = new BatchPoolBufferManager({ ...baseConfig(deviceB), growthBatchSize: 1 }); + const fromB = b.acquire(200, USAGE_A); + expect(() => a.release(fromB)).toThrow(InvalidHandleError); + fromB.release(); + }); + + it('throws on a fabricated/foreign object', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + const fake: BufferHandle = { + buffer: {} as GPUBuffer, + sizeBytes: 100, + bucketSize: 256, + usage: USAGE_A, + release() {}, + sizeInBytes() { + return 256; + }, + destroy() {}, + }; + expect(() => manager.release(fake)).toThrow(InvalidHandleError); + }); +}); + +// --------------------------------------------------------------------------- +// dispose() destroys all and zeros stats. +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — dispose', () => { + it('destroys every owned buffer and zeros the stats', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 2, + }); + manager.acquire(200, USAGE_A); + manager.acquire(1000, USAGE_B); + const totalCreated = device.allCreated.length; + manager.dispose(); + for (const buf of device.allCreated) { + expect(buf.destroyMock).toHaveBeenCalledTimes(1); + } + expect(totalCreated).toBeGreaterThan(0); + const s = manager.stats(); + expect(s.residentBytes).toBe(0); + expect(s.poolCount).toBe(0); + }); + + it('rejects further use after dispose', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + manager.dispose(); + expect(() => manager.acquire(200, USAGE_A)).toThrow(DisposedBufferError); + expect(() => manager.endFrame()).toThrow(DisposedBufferError); + }); + + it('is idempotent', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + manager.acquire(200, USAGE_A); + manager.dispose(); + expect(() => manager.dispose()).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Cacheable interop — BufferHandle implements Cacheable. +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — Cacheable interop on handles', () => { + it('sizeInBytes returns the bucketSize and destroy() releases the handle', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + const h = manager.acquire(200, USAGE_A); + expect(h.sizeInBytes()).toBe(256); + h.destroy(); + // After release, the entry is back on the free deque. + expect(manager.stats().leasedBytes).toBe(0); + expect(manager.stats().freeBytes).toBe(256); + }); +}); diff --git a/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.ts b/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.ts new file mode 100644 index 00000000..92dc34b2 --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.ts @@ -0,0 +1,360 @@ +/** + * `BatchPoolBufferManager` — A concrete implementation of `BufferManager`. + * + * Allocation strategy: one `Pool` per `(bucketSize, usage)` pair; each pool owns one or more + * batches of `growthBatchSize` buffers allocated together. The batch is the unit of allocation + * and the unit of destruction. + * + * Eviction strategy: + * - Within a pool, free buffers are reused in FIFO order (warm-buffer reuse). + * - At `endFrame()`, any batch that has been fully-free for `>= idleFrameLimit` frames is + * destroyed. + * - During `acquire()`, if granting the request would exceed `maxBytes`, the manager evicts + * fully-free batches in oldest-first order across all pools. If the request still cannot fit + * after exhausting evictable batches, `OutOfBudgetError` is thrown. + * + * Handle validity: each `BufferHandle` carries a unique `Symbol` token; the manager tracks + * active tokens in a private map. `release()` rejects unknown tokens (foreign handles, + * double-release, use-after-dispose) by throwing `InvalidHandleError`. + */ + +import { DisposedBufferError, InvalidHandleError, OutOfBudgetError } from '../errors'; +import { type BufferHandle, type BufferUsageFlags, type PoolStats, BufferManagerBase } from '../types'; +import { OutOfBucketError } from './errors'; +import { + type Batch, + type BatchPoolBufferManagerConfig, + type BatchPoolBufferManagerStats, + type FreeEntry, + PoolFacade, + poolKey, +} from './types'; + +/** Internal record tying a live token to the buffer it represents. */ +type ReleaseRecord = { + readonly token: symbol; + readonly handle: BufferHandle; + readonly buffer: GPUBuffer; + readonly batch: Batch; + readonly pool: PoolFacade; +}; + +export class BatchPoolBufferManager extends BufferManagerBase { + /** The supported bucket sizes. */ + readonly sizeBuckets: readonly number[]; + /** Number of buffers allocated in a single batch on a miss. */ + readonly growthBatchSize: number; + + /** Maps pool keys to their corresponding pool facade. */ + #poolsByKey: Map = new Map(); + /** token -> record. Source of truth for "is this handle still leased?". */ + #activeTokens: Map = new Map(); + /** Reverse lookup so `release(handle)` can find its token without trusting the handle. */ + #tokenByHandle: WeakMap = new WeakMap(); + /** Handles vended via `frameLease`; released en-masse at next `endFrame()`. */ + #frameLeases: BufferHandle[] = []; + /** The current frame index. */ + #currentFrame = 0; + /** The number of bytes currently resident in GPU memory for this Buffer Manager. */ + #residentBytes = 0; + /** Monotonically increasing batch id for diagnostics and tiebreaking. */ + #nextBatchId = 0; + /** Whether or not this Buffer Manager has been disposed. */ + #disposed = false; + + constructor(config: BatchPoolBufferManagerConfig) { + super(config); + if (config.sizeBuckets.length === 0) { + throw new Error('BatchPoolBufferManager: sizeBuckets must be non-empty.'); + } + config.sizeBuckets.forEach((b, i) => { + if (!(b > 0) || !Number.isInteger(b)) { + throw new Error(`BatchPoolBufferManager: sizeBuckets[${i}] = ${b} must be a positive integer.`); + } + }); + const buckets = config.sizeBuckets.slice().sort((a, b) => a - b); + const growth = config.growthBatchSize ?? 2; + if (!(growth >= 1) || !Number.isInteger(growth)) { + throw new Error('BatchPoolBufferManager: growthBatchSize must be a positive integer.'); + } + this.sizeBuckets = buckets; + this.growthBatchSize = growth; + } + + acquire(sizeBytes: number, usage: BufferUsageFlags): BufferHandle { + this.#assertNotDisposed(); + if (!(sizeBytes > 0) || !Number.isFinite(sizeBytes)) { + throw new Error(`BatchPoolBufferManager.acquire: sizeBytes must be > 0 (got ${sizeBytes}).`); + } + const bucketSize = this.#bucketFor(sizeBytes, usage); + const key = poolKey(bucketSize, usage); + let pool = this.#poolsByKey.get(key); + if (pool === undefined) { + pool = new PoolFacade(bucketSize, usage, this.#currentFrame); + this.#poolsByKey.set(key, pool); + } + pool.lastAcquireFrame = this.#currentFrame; + + let entry = pool.freeDeque.shift(); + if (entry === undefined) { + // Miss: allocate a new batch (possibly evicting idle batches first to make room). + this.telemetry.onMiss?.({ sizeBytes, bucketSize, usage }); + entry = this.#allocateBatchAndTakeOne(pool); + } + entry.batch.leasedCount++; + return this.#mintHandle(entry, pool, sizeBytes); + } + + release(handle: BufferHandle): void { + this.#assertNotDisposed(); + const token = this.#tokenByHandle.get(handle); + if (token === undefined) { + throw new InvalidHandleError('foreign'); + } + const record = this.#activeTokens.get(token); + if (record === undefined) { + throw new InvalidHandleError('double-release'); + } + this.#activeTokens.delete(token); + this.#tokenByHandle.delete(handle); + record.batch.leasedCount--; + record.pool.freeDeque.push({ buffer: record.buffer, batch: record.batch }); + if (record.batch.leasedCount === 0) { + record.batch.lastFullyFreeFrame = this.#currentFrame; + } + } + + endFrame(): void { + this.#assertNotDisposed(); + // 1) Release frame-scoped leases. + if (this.#frameLeases.length > 0) { + // Drain into a local copy first so the array is empty before we mutate token state + // (release() never reads #frameLeases, but the discipline keeps invariants tidy). + const drained = this.#frameLeases.slice(); + this.#frameLeases.length = 0; + for (const h of drained) { + this.release(h); + } + } + // 2) Sweep idle batches. + const threshold = this.idleFrameLimit; + for (const pool of this.#poolsByKey.values()) { + // Collect victims first; destroyBatch mutates `pool.batches` during iteration otherwise. + const victims: Batch[] = []; + for (const batch of pool.batches) { + if (batch.destroyed) continue; + if (batch.leasedCount !== 0) continue; + if (this.#currentFrame - batch.lastFullyFreeFrame >= threshold) { + victims.push(batch); + } + } + for (const batch of victims) { + const freed = pool.destroyBatch(batch); + this.#residentBytes -= freed; + this.telemetry.onEvict?.({ + bucketSize: pool.bucketSize, + usage: pool.usage, + batchSize: batch.buffers.length, + }); + } + } + // 3) Increment the frame counter + this.#currentFrame += 1; + } + + frameLease(sizeBytes: number, usage: BufferUsageFlags): BufferHandle { + const handle = this.acquire(sizeBytes, usage); + this.#frameLeases.push(handle); + return handle; + } + + stats(): BatchPoolBufferManagerStats { + let leasedBytes = 0; + let freeBytes = 0; + const perPool: PoolStats[] = []; + for (const pool of this.#poolsByKey.values()) { + const leased = pool.leasedCount(); + const free = pool.freeCount(); + leasedBytes += leased * pool.bucketSize; + freeBytes += free * pool.bucketSize; + perPool.push(pool.toStats()); + } + return { + residentBytes: this.#residentBytes, + leasedBytes, + freeBytes, + poolCount: this.#poolsByKey.size, + perPool, + }; + } + + dispose(): void { + if (this.#disposed) return; + for (const pool of this.#poolsByKey.values()) { + // Force-destroy every batch even if leased; behavior of outstanding handles is + // undefined post-dispose by contract. + const batches = Array.from(pool.batches); + for (const batch of batches) { + batch.leasedCount = 0; + pool.destroyBatch(batch); + } + } + this.#poolsByKey.clear(); + this.#activeTokens.clear(); + this.#frameLeases.length = 0; + this.#residentBytes = 0; + this.#disposed = true; + } + + // ---------------------------------------------------------------------------------------- + // Internals + // ---------------------------------------------------------------------------------------- + + #assertNotDisposed(): void { + if (this.#disposed) { + throw new DisposedBufferError(); + } + } + + /** Smallest bucket >= `sizeBytes`. Throws `OutOfBucketError` if `sizeBytes` exceeds the largest. */ + #bucketFor(sizeBytes: number, usage: BufferUsageFlags): number { + const buckets = this.sizeBuckets; + const last = buckets[buckets.length - 1] as number; + if (sizeBytes > last) { + throw new OutOfBucketError(sizeBytes, usage, last); + } + // Binary search for first bucket >= sizeBytes. + let lo = 0; + let hi = buckets.length - 1; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if ((buckets[mid] as number) >= sizeBytes) { + hi = mid; + } else { + lo = mid + 1; + } + } + return buckets[lo] as number; + } + + /** + * Allocate a new batch in `pool`, reclaiming idle batches across all pools as necessary to + * stay within `maxBytes`. Returns the first free entry of the new batch (already removed + * from `pool.freeDeque`; caller will increment `leasedCount`). + */ + #allocateBatchAndTakeOne(pool: PoolFacade): FreeEntry { + const bucketSize = pool.bucketSize; + const usage = pool.usage; + const newBytes = bucketSize * this.growthBatchSize; + + // Evict oldest fully-free batches (across all pools) until we fit, or run out. + while (this.#residentBytes + newBytes > this.maxBytes) { + const victim = this.#findGlobalEvictionVictim(); + if (victim === undefined) { + throw new OutOfBudgetError(this.#residentBytes, this.maxBytes, newBytes); + } + const freed = victim.pool.destroyBatch(victim.batch); + this.#residentBytes -= freed; + this.telemetry.onEvict?.({ + bucketSize: victim.pool.bucketSize, + usage: victim.pool.usage, + batchSize: victim.batch.buffers.length, + }); + } + + // Allocate the new batch. + const buffers: GPUBuffer[] = []; + for (let i = 0; i < this.growthBatchSize; i++) { + buffers.push( + this.device.createBuffer({ + size: bucketSize, + usage, + }) + ); + } + const batch: Batch = { + id: this.#nextBatchId++, + bucketSize, + usage, + poolKey: pool.key, + buffers, + leasedCount: 0, + lastFullyFreeFrame: this.#currentFrame, + destroyed: false, + }; + pool.batches.add(batch); + this.#residentBytes += newBytes; + this.telemetry.onAllocate?.({ bucketSize, usage, batchSize: buffers.length }); + + // Hand the first buffer back; queue the rest as free. + const first: FreeEntry = { buffer: buffers[0] as GPUBuffer, batch }; + for (let i = 1; i < buffers.length; i++) { + pool.freeDeque.push({ buffer: buffers[i] as GPUBuffer, batch }); + } + return first; + } + + /** + * Linear scan over all pools to find the globally-oldest fully-free batch. Tiebreaker is + * the pool's `lastAcquireFrame` (older pool wins), then batch id (stable). Returns + * `undefined` if no batch is currently fully-free in any pool. + */ + #findGlobalEvictionVictim(): { pool: PoolFacade; batch: Batch } | undefined { + let bestPool: PoolFacade | undefined; + let bestBatch: Batch | undefined; + for (const pool of this.#poolsByKey.values()) { + const candidate = pool.oldestFullyFreeBatch(); + if (candidate === undefined) continue; + if (bestBatch === undefined || bestPool === undefined) { + bestPool = pool; + bestBatch = candidate; + continue; + } + if (candidate.lastFullyFreeFrame < bestBatch.lastFullyFreeFrame) { + bestPool = pool; + bestBatch = candidate; + } else if (candidate.lastFullyFreeFrame === bestBatch.lastFullyFreeFrame) { + if (pool.lastAcquireFrame < bestPool.lastAcquireFrame) { + bestPool = pool; + bestBatch = candidate; + } else if (pool.lastAcquireFrame === bestPool.lastAcquireFrame && candidate.id < bestBatch.id) { + bestPool = pool; + bestBatch = candidate; + } + } + } + if (bestPool === undefined || bestBatch === undefined) return undefined; + return { pool: bestPool, batch: bestBatch }; + } + + /** Build a `BufferHandle` and register its token. */ + #mintHandle(entry: FreeEntry, pool: PoolFacade, sizeBytesRequested: number): BufferHandle { + const token = Symbol('BufferHandle'); + const buffer = entry.buffer; + const batch = entry.batch; + const bucketSize = pool.bucketSize; + const usage = pool.usage; + + // We need `handle` to capture itself for `release()`. Define the closure that calls back + // into the manager; using `this.release(handle)` keeps token validation centralized. + const manager = this; + const handle: BufferHandle = { + buffer, + sizeBytes: sizeBytesRequested, + bucketSize, + usage, + release(): void { + manager.release(handle); + }, + sizeInBytes(): number { + return bucketSize; + }, + destroy(): void { + manager.release(handle); + }, + }; + this.#activeTokens.set(token, { token, handle, buffer, batch, pool }); + this.#tokenByHandle.set(handle, token); + return handle; + } +} diff --git a/packages/core/src/rendering/webgpu/memory/batch-pool/errors.ts b/packages/core/src/rendering/webgpu/memory/batch-pool/errors.ts new file mode 100644 index 00000000..78ec0f2d --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/batch-pool/errors.ts @@ -0,0 +1,20 @@ +import { BufferUsageFlags } from "../types"; + +/** + * Thrown by `acquire()` when the requested `sizeBytes` exceeds the largest configured bucket. + * Indicates the caller's `sizeBuckets` list does not cover the working set; either widen the + * bucket list at construction time or refactor the caller to use smaller buffers. + */ +export class OutOfBucketError extends Error { + readonly name = 'OutOfBucketError'; + constructor( + public readonly requestedBytes: number, + public readonly usage: BufferUsageFlags, + public readonly largestBucket: number + ) { + super( + `BufferAdapter: requested ${requestedBytes} bytes (usage 0x${usage.toString(16)}) ` + + `exceeds the largest configured bucket (${largestBucket} bytes).` + ); + } +} diff --git a/packages/core/src/rendering/webgpu/memory/batch-pool/types.ts b/packages/core/src/rendering/webgpu/memory/batch-pool/types.ts new file mode 100644 index 00000000..2cc302a7 --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/batch-pool/types.ts @@ -0,0 +1,213 @@ +/** + * Internal data structures backing `BatchPoolBufferAdapter`. + * + * Each `(bucketSize, usage)` pair has a single `Pool`. A pool owns one or more `Batch`es, each a + * fixed-size group of `GPUBuffer`s allocated together via a single round of `device.createBuffer` + * calls. The batch is the unit of allocation and the unit of destruction: when every member of a + * batch has been free for `idleFrameLimit` consecutive frames, the entire batch is destroyed at + * `endFrame()`. + * + * Within a pool, individual free buffers live in a FIFO `freeDeque` keyed across batches. This + * gives LRU semantics within a pool (oldest-released buffer is the next one handed out, which + * lets the most-recently-used buffers stay warm for upcoming acquires of the same shape). + * + * `PoolFacade` implements `Cacheable` so each pool can flow through cache-aware machinery + * elsewhere in the codebase. `sizeInBytes()` reports only the pool's **free** (evictable) bytes; + * leased bytes are excluded so any cache layer that thinks in terms of evictable footprint sees + * a truthful number. `destroy()` shrinks the pool by one full free batch (oldest-first), + * matching Design A's "batch as the unit of release" property. + * + * **Why we don't use `PriorityCache` directly.** The earlier design called for wrapping each + * pool as an entry in a `PriorityCache` for cross-pool eviction arbitration. + * `PriorityCache` snapshots `sizeInBytes()` at `put()` time and has no public API to either + * (a) notify it that an entry's size changed (our pools' free bytes change continuously) or + * (b) remove an entry without going through eviction. Mutating an entry's reported size while it + * lives in the cache would silently desynchronize the cache's internal `#used` accounting. We + * still honor the design's interop goal by implementing `Cacheable` here; the cross-pool victim + * selection inside the adapter is a small linear scan over the pools map (typically <100 pools). + */ + +import type { Cacheable } from '../../../../shared-priority-cache/priority-cache'; +import type { BufferUsageFlags, PoolStats, BufferManagerConfig, BufferManagerStats } from '../types'; + +export type BatchPoolBufferManagerConfig = BufferManagerConfig & { + /** + * Sorted-ascending list of buffer sizes the manager will allocate. Requests are rounded up + * to the smallest bucket >= the requested size. Requests larger than the largest bucket + * throw `OutOfBucketError`. + */ + sizeBuckets: readonly number[]; + /** + * Number of buffers allocated in a single batch on a miss. Larger values reduce + * `createBuffer` traffic at the cost of coarser eviction granularity. Defaults to 2 — small + * enough that idle eviction shrinks gracefully, large enough to amortize bursty acquires. + */ + growthBatchSize?: number; +}; + +export type BatchPoolBufferManagerStats = BufferManagerStats & { + /** Number of `(bucketSize, usage)` pools tracked. */ + poolCount: number; + /** Per-pool stats included in `BufferManagerStats`. */ + perPool: readonly PoolStats[]; +}; + +/** + * One fixed-size allocation made via `device.createBuffer` repeated `buffers.length` times. The + * batch is the unit of allocation and the unit of destruction. + */ +export type Batch = { + /** Stable monotonically increasing batch id. Used for diagnostics and as a tiebreaker. */ + readonly id: number; + readonly bucketSize: number; + readonly usage: BufferUsageFlags; + /** Pointer back to the owning pool's key for fast lookup during release. */ + readonly poolKey: string; + /** All `GPUBuffer`s in this batch; identity-stable for the lifetime of the batch. */ + readonly buffers: readonly GPUBuffer[]; + /** Number of `buffers` currently leased (not in the pool's `freeDeque`). */ + leasedCount: number; + /** + * The frame at which this batch most recently transitioned to `leasedCount === 0`. Set at + * batch creation (since freshly created batches are fully-free for an instant before the + * first acquire pops from them) and updated on every release that completes the batch. + */ + lastFullyFreeFrame: number; + /** Set once `destroy()` has been called on all `buffers`; used to short-circuit accidental + * re-entry from late releases that race with eviction. */ + destroyed: boolean; +}; + +/** Entry in a pool's free FIFO. */ +export type FreeEntry = { + readonly buffer: GPUBuffer; + readonly batch: Batch; +}; + +/** + * One `(bucketSize, usage)` pool. Implements `Cacheable` so external machinery can reason about + * a pool's evictable footprint via the shared `Cacheable` contract. + */ +export class PoolFacade implements Cacheable { + /** Stable identifier `${bucketSize}|${usage}`. */ + readonly key: string; + readonly bucketSize: number; + readonly usage: BufferUsageFlags; + /** All batches currently owned by this pool. Iteration order is creation order. */ + readonly batches: Set = new Set(); + /** FIFO of free buffers. Push to tail on release, shift from head on acquire. */ + readonly freeDeque: FreeEntry[] = []; + /** Frame index of the most recent `acquire()` from this pool. Drives LRU arbiter score. */ + lastAcquireFrame: number; + + constructor(bucketSize: number, usage: BufferUsageFlags, createFrame: number) { + this.key = poolKey(bucketSize, usage); + this.bucketSize = bucketSize; + this.usage = usage; + this.lastAcquireFrame = createFrame; + } + + /** Number of currently-leased buffers across all batches. */ + leasedCount(): number { + let n = 0; + for (const batch of this.batches) { + n += batch.leasedCount; + } + return n; + } + + /** Number of currently-free buffers across all batches. */ + freeCount(): number { + return this.freeDeque.length; + } + + /** Total bytes (leased + free) currently owned by this pool. */ + residentBytes(): number { + let total = 0; + for (const batch of this.batches) { + if (!batch.destroyed) { + total += batch.buffers.length * this.bucketSize; + } + } + return total; + } + + /** `Cacheable.sizeInBytes`: free (evictable) bytes only. Leased bytes are excluded. */ + sizeInBytes(): number { + return this.freeDeque.length * this.bucketSize; + } + + /** + * `Cacheable.destroy`: destroy one fully-free batch (oldest-first), or no-op if no batch is + * currently fully-free. Returns the number of bytes released so the caller can iterate + * until a target is met. + */ + destroy(): number { + const victim = this.oldestFullyFreeBatch(); + if (victim === undefined) return 0; + return this.destroyBatch(victim); + } + + /** Snapshot for `BufferAdapterStats.perPool`. */ + toStats(): PoolStats { + return { + bucketSize: this.bucketSize, + usage: this.usage, + leased: this.leasedCount(), + free: this.freeCount(), + }; + } + + /** + * Find the oldest fully-free batch in this pool (lowest `lastFullyFreeFrame`). Returns + * `undefined` if no batch is currently fully-free. + */ + oldestFullyFreeBatch(): Batch | undefined { + let oldest: Batch | undefined; + for (const batch of this.batches) { + if (batch.destroyed) continue; + if (batch.leasedCount !== 0) continue; + if (oldest === undefined || batch.lastFullyFreeFrame < oldest.lastFullyFreeFrame) { + oldest = batch; + } + } + return oldest; + } + + /** + * Destroy `batch`: call `destroy()` on every `GPUBuffer` it owns, remove its entries from + * `freeDeque`, drop the batch from `batches`, and mark it destroyed. Returns the byte count + * freed (always `bucketSize * batch.buffers.length`). + * + * Caller is responsible for asserting `batch.leasedCount === 0` (we assert here too). + */ + destroyBatch(batch: Batch): number { + if (batch.destroyed) return 0; + if (batch.leasedCount !== 0) { + throw new Error( + `PoolFacade.destroyBatch: refusing to destroy batch ${batch.id} ` + + `with ${batch.leasedCount} leased buffer(s).` + ); + } + const freedBytes = batch.buffers.length * this.bucketSize; + // Remove this batch's entries from the FIFO. Walk once; preserve relative order of others. + if (this.freeDeque.length > 0) { + const kept: FreeEntry[] = []; + for (const entry of this.freeDeque) { + if (entry.batch !== batch) kept.push(entry); + } + this.freeDeque.length = 0; + for (const entry of kept) this.freeDeque.push(entry); + } + for (const buf of batch.buffers) { + buf.destroy(); + } + batch.destroyed = true; + this.batches.delete(batch); + return freedBytes; + } +} + +export function poolKey(bucketSize: number, usage: BufferUsageFlags): string { + return `${bucketSize}|${usage}`; +} diff --git a/packages/core/src/rendering/webgpu/memory/errors.ts b/packages/core/src/rendering/webgpu/memory/errors.ts new file mode 100644 index 00000000..6ab8e021 --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/errors.ts @@ -0,0 +1,40 @@ +/** + * Thrown by `acquire()` when a request cannot be satisfied within the configured `maxBytes` + * budget, even after attempting to reclaim idle entries. + */ +export class OutOfBudgetError extends Error { + readonly name = 'OutOfBudgetError'; + constructor( + public readonly residentBytes: number, + public readonly maxBytes: number, + public readonly requestedBytes: number + ) { + super( + `BufferAdapter: cannot allocate ${requestedBytes} bytes; ` + + `resident ${residentBytes} / max ${maxBytes}. ` + + 'Reclaimable idle entries (if any) were already evicted.' + ); + } +} + +/** + * Thrown by `release()` when the supplied handle is not currently owned by the adapter (foreign + * handle, double-release, or use-after-dispose). + */ +export class InvalidHandleError extends Error { + readonly name = 'InvalidHandleError'; + constructor(reason: 'foreign' | 'double-release') { + super(`BufferAdapter: invalid handle (${reason}).`); + } +} + +/** + * Thrown whenever an operation is attempted on a buffer that has already been disposed. + */ +export class DisposedBufferError extends Error { + readonly name = 'DisposedBufferError'; + constructor() { + super(`BufferAdapter: operation attempted on a disposed buffer.`); + } +} + diff --git a/packages/core/src/rendering/webgpu/memory/index.ts b/packages/core/src/rendering/webgpu/memory/index.ts new file mode 100644 index 00000000..2e479c37 --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/index.ts @@ -0,0 +1,18 @@ +// Base API +export { InvalidHandleError, OutOfBudgetError } from './errors'; +export type { + BatchEventInfo, + BufferManager, + BufferManagerConfig, + BufferManagerStats, + BufferManagerTelemetry, + BufferHandle, + BufferUsageFlags, + MissEventInfo, + PoolStats, +} from './types'; +export { BufferManagerBase } from './types'; + +// Batch Pool implementation +export { BatchPoolBufferManager as BatchPoolBufferAdapter } from './batch-pool/batch-pool-buffer-manager'; +export { OutOfBucketError } from './batch-pool/errors'; diff --git a/packages/core/src/rendering/webgpu/memory/types.ts b/packages/core/src/rendering/webgpu/memory/types.ts new file mode 100644 index 00000000..9a4e4d6b --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/types.ts @@ -0,0 +1,185 @@ +/** + * Public types for the WebGPU buffer memory manager. + * + * The `BufferManager` interface is the contract every concrete implementation honors. Consumers + * (binding-graph providers, encoder code, etc.) should type their parameters as `BufferManager` + * so the underlying allocation strategy can be swapped without changing call sites. + * + * The first concrete implementation is `BatchPoolBufferManager` (Design A: batched per-bucket + * pools). Future siblings — e.g. `LruPoolBufferManager` (per-buffer LRU) or `SlabBufferManager` + * (sub-allocating slabs) — will conform to the same interface and may be selected at + * construction. + */ + +import type { Cacheable } from '../../../shared-priority-cache/priority-cache'; + +/** + * GPU buffer usage flag-set. WebGPU's `GPUBufferUsageFlags` is the bag of flags from + * `GPUBufferUsage.*`. We accept any union (a `number`) since the typing depends on the WebGPU + * lib edition in scope. + */ +export type BufferUsageFlags = GPUBufferUsageFlags; + +/** + * Optional callbacks for debug overlays, profiling, and telemetry pipelines. Hooks fire after + * the corresponding internal state change has settled. + */ +export type BufferManagerTelemetry = { + /** Fired when a fresh batch of `bucketSize * batchSize` bytes is allocated from `device`. */ + onAllocate?: (info: BatchEventInfo) => void; + /** Fired when an idle batch is destroyed and its bytes returned to `device`. */ + onEvict?: (info: BatchEventInfo) => void; + /** Fired when `acquire()` cannot satisfy a request from existing free entries. */ + onMiss?: (info: MissEventInfo) => void; +}; + +export type BatchEventInfo = { + bucketSize: number; + usage: BufferUsageFlags; + /** Number of buffers in the affected batch. */ + batchSize: number; +}; + +export type MissEventInfo = { + /** The caller's original size request. */ + sizeBytes: number; + /** The bucket size the request was rounded up to. */ + bucketSize: number; + usage: BufferUsageFlags; +}; + +/** Shared base config for any `BufferManager` implementation. */ +export type BufferManagerConfig = { + device: GPUDevice; + /** Hard ceiling on total bytes the manager may hold live (resident) at any moment. */ + maxBytes: number; + /** + * Frames a fully-free batch may sit unused before becoming a candidate for destruction. + * Counted at `endFrame()`. An entry released at frame F is gone no earlier than `endFrame()` + * called during frame `F + idleFrameLimit`. + */ + idleFrameLimit: number; + /** Optional debug/telemetry callbacks. */ + telemetry?: BufferManagerTelemetry; +}; + +/** Per-pool stats included in `BufferManagerStats`. */ +export type PoolStats = { + bucketSize: number; + usage: BufferUsageFlags; + /** Currently-leased buffer count. */ + leased: number; + /** Currently-free buffer count (across all batches in this pool). */ + free: number; +}; + +export type BufferManagerStats = { + /** Total bytes the manager currently owns (leased + free). */ + residentBytes: number; + /** Bytes corresponding to leased buffers (not eligible for eviction). */ + leasedBytes: number; + /** Bytes corresponding to free buffers (eligible for eviction subject to idle policy). */ + freeBytes: number; +}; + +/** + * Handle returned by `BufferManager.acquire`. Holders bind against `handle.buffer` and call + * `release()` (or `manager.release(handle)`) when done. + * + * Implements `Cacheable` so handles can be threaded through any other cache-aware machinery in + * the codebase. `sizeInBytes()` returns the actual `bucketSize` (the physical buffer's byte + * length), not the caller's `sizeBytes` request. `destroy()` is an alias for `release()` so a + * handle can also be added to a `Cacheable` cache that drives eviction. + */ +export interface BufferHandle extends Cacheable { + /** The underlying GPUBuffer, suitable for bind-group entries. Stable for the handle's lifetime. */ + readonly buffer: GPUBuffer; + /** The caller's original size request (in bytes). */ + readonly sizeBytes: number; + /** The actual physical size of `buffer`. Always >= `sizeBytes` and a member of `sizeBuckets`. */ + readonly bucketSize: number; + /** The usage flag-set this buffer was allocated with. */ + readonly usage: BufferUsageFlags; + /** Return this buffer to the pool. Equivalent to `manager.release(this)`. Idempotent-safe + * is NOT guaranteed: calling twice throws (use the token-checked `release` path). */ + release(): void; + /** `Cacheable.sizeInBytes`: returns `bucketSize`. */ + sizeInBytes(): number; + /** `Cacheable.destroy`: alias for `release()` so handles plug into cache eviction paths. */ + destroy(): void; +} + +/** + * Public surface of every concrete buffer manager. See `BatchPoolBufferManager` for the first + * concrete implementation. + */ +export interface BufferManager { + /** + * Obtain a buffer of at least `sizeBytes`, allocated with `usage`. The returned handle's + * `buffer.size` will be the bucket the request rounds up to. Throws `OutOfBucketError` if + * the request exceeds the largest bucket; throws `OutOfBudgetError` if granting the request + * would exceed `maxBytes` and no idle entries can be reclaimed. + */ + acquire(sizeBytes: number, usage: BufferUsageFlags): BufferHandle; + + /** Return a handle to the pool. Throws on double-release or foreign handles. */ + release(handle: BufferHandle): void; + + /** + * End the current frame. Releases any handles vended via `frameLease()`, then runs the + * idle-batch sweep (destroying batches that have been fully-free for >= `idleFrameLimit` + * frames). Advances the current frame counter after everything else is completed. + */ + endFrame(): void; + + /** + * Like `acquire`, but the returned handle is registered for automatic release at the next + * `endFrame()`. Useful for compute scratch buffers and other within-frame transients. + */ + frameLease(sizeBytes: number, usage: BufferUsageFlags): BufferHandle; + + /** Snapshot of current memory usage and per-pool breakdown. Cheap; suitable for HUDs. */ + stats(): Stats; + + /** + * Destroy every buffer the manager owns (leased and free) and drop internal references. + * Behavior of any outstanding handle after `dispose()` is undefined; callers should ensure + * no handles are in use first. + */ + dispose(): void; +} + +/** + * Base abstract class for all buffer managers. Provides common functionality and enforces the + * `BufferManager` interface. + */ +export abstract class BufferManagerBase + implements BufferManager +{ + protected device: GPUDevice; + protected maxBytes: number; + protected idleFrameLimit: number; + protected telemetry: NonNullable; + + constructor(config: BufferManagerConfig) { + if (!(config.maxBytes > 0)) { + throw new Error('BufferManagerBase: maxBytes must be > 0.'); + } + if (!(config.idleFrameLimit >= 0) || !Number.isInteger(config.idleFrameLimit)) { + throw new Error( + 'BufferManagerBase: idleFrameLimit must be a non-negative integer.' + ); + } + this.device = config.device; + this.maxBytes = config.maxBytes; + this.idleFrameLimit = config.idleFrameLimit; + this.telemetry = config.telemetry ?? {}; + } + + abstract acquire(sizeBytes: number, usage: BufferUsageFlags): BufferHandle; + abstract release(handle: BufferHandle): void; + abstract endFrame(): void; + abstract frameLease(sizeBytes: number, usage: BufferUsageFlags): BufferHandle; + abstract stats(): Stats; + abstract dispose(): void; +} From 0cb85dea7eb14ac4d7e5a9a0a36fba6118123089 Mon Sep 17 00:00:00 2001 From: Joel Arbuckle Date: Fri, 26 Jun 2026 10:49:23 -0700 Subject: [PATCH 07/20] Initial run of updates --- packages/core/package.json | 1 + packages/core/src/rendering/webgpu/index.ts | 77 ++++++ .../webgpu/pipelines/bind-group-cache.test.ts | 6 +- .../webgpu/pipelines/binding-graph.test.ts | 230 ++++++++++++++++ .../webgpu/pipelines/binding-graph.ts | 254 ++++++++++++++++++ .../webgpu/pipelines/traversal.test.ts | 58 ++-- .../rendering/webgpu/pipelines/traverse.ts | 84 ++++++ .../src/rendering/webgpu/plan-2026-06-25.md | 214 +++++++++++++++ .../rendering/webgpu/resources/bind.test.ts | 14 +- .../src/rendering/webgpu/resources/bind.ts | 29 +- .../rendering/webgpu/resources/bound.test.ts | 46 ++-- .../src/rendering/webgpu/resources/bound.ts | 36 +-- .../src/rendering/webgpu/resources/index.ts | 46 ++-- .../webgpu/resources/resource.test.ts | 68 ++--- .../rendering/webgpu/resources/resource.ts | 132 ++++----- .../rendering/webgpu/shaders/declarations.ts | 22 +- .../src/rendering/webgpu/shaders/index.ts | 1 + .../rendering/webgpu/shaders/shader.test.ts | 22 +- .../src/rendering/webgpu/shaders/shader.ts | 20 +- packages/core/src/rendering/webgpu/slot.ts | 132 +++++++++ pnpm-lock.yaml | 8 + 21 files changed, 1278 insertions(+), 222 deletions(-) create mode 100644 packages/core/src/rendering/webgpu/index.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/binding-graph.test.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/binding-graph.ts create mode 100644 packages/core/src/rendering/webgpu/pipelines/traverse.ts create mode 100644 packages/core/src/rendering/webgpu/plan-2026-06-25.md create mode 100644 packages/core/src/rendering/webgpu/slot.ts diff --git a/packages/core/package.json b/packages/core/package.json index c39e7aca..97ebfd40 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -60,6 +60,7 @@ "lodash": "4.18.1", "regl": "2.1.1", "uuid": "14.0.0", + "webgpu-utils": "^2.1.1", "zod": "4.3.6" } } diff --git a/packages/core/src/rendering/webgpu/index.ts b/packages/core/src/rendering/webgpu/index.ts new file mode 100644 index 00000000..ee3c927a --- /dev/null +++ b/packages/core/src/rendering/webgpu/index.ts @@ -0,0 +1,77 @@ +/** + * Public barrel for `@alleninstitute/vis-core/rendering/webgpu`. + * + * This module re-exports the v1 authoring surface — the declarative API used by + * example/application code to describe shaders, bindings, pipelines, drawables, + * and scenes — and intentionally does NOT re-export internal helpers (binding-graph + * traversal, bind-group cache keys, slab BufferHandle plumbing, etc.). Consumers + * should depend on the symbols below; everything else is implementation detail. + * + * The surface is being built out phase-by-phase per `plan-2026-06-25.md`. Symbols + * that are not yet implemented are exported here as `unknown` stubs so downstream + * code can begin to import them without breaking the build; each stub will be + * replaced with the real implementation in the phase that defines it. + */ + +// ---- Implemented in Phase 1 ------------------------------------------------------------------- + +export { slot } from './slot'; +export type { + TypedExternalTextureSlot, + TypedSamplerSlot, + TypedStorageSlot, + TypedStorageTextureSlot, + TypedTextureSlot, + TypedUniformSlot, +} from './slot'; + +export { asSource, isWgslShader, member, shader, struct } from './shaders'; +export type { StructDecl, StructDeclaration, StructMemberDeclaration, WgslShader } from './shaders'; + +// ---- Phase 2: DAG bindings + traversal -------------------------------------------------------- + +export { bindings, binding, group as bindingGroup, isBindingGraph } from './pipelines/binding-graph'; +export type { + BindingGraph, + BindingsDescriptor, + GroupDescriptor, + GroupNode, + SlotDescriptor, + SlotNode, +} from './pipelines/binding-graph'; +export { resolveShaderBindings, shaderSlotEntries } from './pipelines/traverse'; + +// ---- Phase 3: Pipeline / Drawable / Scene authoring ------------------------------------------- + +/** Phase 3: declarative pipeline factory (replaces the legacy `pipelines/binding-graphs` pipeline()). */ +export const pipeline: unknown = undefined; + +/** Phase 3: bind a typed slot to a data-bearing `Resource` (buffer/texture/sampler). */ +export const resource: unknown = undefined; + +/** Phase 3: a `Drawable` is a pipeline + resource set + draw-call descriptor. */ +export const drawable: unknown = undefined; + +/** Phase 3: a `Scene` is the v1 replacement for the legacy `Graph` of drawables. */ +export const scene: unknown = undefined; + +// ---- Phase 6: Encoder state ops ---------------------------------------------------------------- + +/** Phase 6: viewport state command. */ +export const viewport: unknown = undefined; +/** Phase 6: scissor state command. */ +export const scissor: unknown = undefined; +/** Phase 6: stencil-reference state command. */ +export const stencilref: unknown = undefined; +/** Phase 6: blend-constant state command. */ +export const blendconstant: unknown = undefined; +/** Phase 6: composite container that scopes encoder state to a sub-tree of drawables. */ +export const container: unknown = undefined; +/** Phase 6: override-pipeline-constant command. */ +export const override: unknown = undefined; +/** Phase 6: explicit draw-call command (used inside containers). */ +export const draw: unknown = undefined; +/** Phase 6: low-level encoder primitive (advanced authoring escape hatch). */ +export const encoder: unknown = undefined; +/** Phase 6: `submit(scene, ...)` — the top-level frame command. */ +export const submit: unknown = undefined; diff --git a/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts b/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts index 369a785a..59b0f285 100644 --- a/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts +++ b/packages/core/src/rendering/webgpu/pipelines/bind-group-cache.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { ShaderStageFlag } from '../native-types'; -import { textureResource, uniformResource } from '../resources'; +import { textureSlot, uniformSlot } from '../resources'; import { shader } from '../shaders'; import { computeBindGroupCacheKey } from './bind-group-cache'; import { group, makeBindingGraph, pipeline, resource } from './binding-graphs'; @@ -18,8 +18,8 @@ const makeCtx = (overrides: Partial = {}): DrawContext => ({ }); function makeTwoBindingLayout() { - const u = uniformResource('u', 'U'); - const t = textureResource('tex', 'texture_2d'); + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); const bg = makeBindingGraph([ group(undefined, [resource(undefined, u, fakeBuffer, [pl]), resource(undefined, t, fakeTexture, [pl])]), diff --git a/packages/core/src/rendering/webgpu/pipelines/binding-graph.test.ts b/packages/core/src/rendering/webgpu/pipelines/binding-graph.test.ts new file mode 100644 index 00000000..b42db3b2 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graph.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest'; +import { samplerSlot, textureSlot, uniformSlot } from '../resources'; +import { shader } from '../shaders'; +import { + binding, + bindings, + group, + isBindingGraph, + isGroupNode, + isSlotNode, +} from './binding-graph'; +import { resolveShaderBindings, shaderSlotEntries } from './traverse'; + +describe('bindings()', () => { + it('constructs a single-root, single-slot graph', () => { + const u = uniformSlot('u', 'U'); + const sh = shader([u]); + const g = bindings({ + groups: [group({ slots: [binding(u, [sh])] })], + }); + expect(isBindingGraph(g)).toBe(true); + expect(g.roots).toHaveLength(1); + expect(isGroupNode(g.roots[0])).toBe(true); + expect(g.roots[0]?.slots).toHaveLength(1); + expect(isSlotNode(g.roots[0]?.slots[0])).toBe(true); + expect(g.roots[0]?.slots[0]?.slot).toBe(u); + expect(g.roots[0]?.slots[0]?.usedBy).toEqual([sh]); + }); + + it('throws when descriptor has no groups', () => { + expect(() => bindings({ groups: [] })).toThrow(/at least one root group/); + }); + + it('throws when the same slot appears in two SlotNodes', () => { + const u = uniformSlot('u', 'U'); + const sh = shader([u]); + expect(() => + bindings({ + groups: [ + group({ slots: [binding(u, [sh])] }), + group({ slots: [binding(u, [sh])] }), + ], + }) + ).toThrow(/appears in multiple SlotNodes/); + }); + + it('throws when group depth exceeds the limit', () => { + const u = uniformSlot('u', 'U'); + const sh = shader([u]); + // Build a chain deeper than the default limit (4). + const deepest = group({ slots: [binding(u, [sh])] }); + const d4 = group({ groups: [deepest] }); + const d3 = group({ groups: [d4] }); + const d2 = group({ groups: [d3] }); + const d1 = group({ groups: [d2] }); + expect(() => bindings({ groups: [d1] })).toThrow(/exceeds the limit/); + }); + + it('honours a custom groupDepthLimit', () => { + const u = uniformSlot('u', 'U'); + const sh = shader([u]); + const deepest = group({ slots: [binding(u, [sh])] }); + const d3 = group({ groups: [deepest] }); + const d2 = group({ groups: [d3] }); + const d1 = group({ groups: [d2] }); + // depth 1→2→3→4: with limit 4, the deepest carrying the slot is at depth 4 → ok. + expect(() => bindings({ groups: [d1], groupDepthLimit: 4 })).not.toThrow(); + // with limit 3, depth 4 is too deep. + expect(() => bindings({ groups: [d1], groupDepthLimit: 3 })).toThrow(/exceeds the limit/); + }); + + it('throws when a shader declares a slot not provided by the graph', () => { + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); + const sh = shader([u, t]); + expect(() => + bindings({ + groups: [group({ slots: [binding(u, [sh])] })], + }) + ).toThrow(/declares slot 'tex'/); + }); + + it('allows a slot in usedBy that the shader does not declare (harmless extra)', () => { + const u = uniformSlot('u', 'U'); + const extra = uniformSlot('extra', 'X'); + const sh = shader([u]); // does NOT declare `extra` + expect(() => + bindings({ + groups: [ + group({ + slots: [binding(u, [sh]), binding(extra, [sh])], + }), + ], + }) + ).not.toThrow(); + }); + + it('deduplicates duplicate shaders in usedBy', () => { + const u = uniformSlot('u', 'U'); + const sh = shader([u]); + const g = bindings({ + groups: [group({ slots: [binding(u, [sh, sh, sh])] })], + }); + expect(g.roots[0]?.slots[0]?.usedBy).toEqual([sh]); + }); + + it('throws when a slot field is not a ResourceSlot', () => { + const sh = shader([]); + expect(() => + bindings({ + groups: [ + group({ + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input + slots: [{ slot: {} as any, usedBy: [sh] }], + }), + ], + }) + ).toThrow(/not a ResourceSlot/); + }); +}); + +describe('resolveShaderBindings()', () => { + it('assigns binding indices in slot-order within a group', () => { + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); + const s = samplerSlot('samp', 'sampler'); + const sh = shader([u, t, s]); + const g = bindings({ + groups: [ + group({ + slots: [binding(u, [sh]), binding(t, [sh]), binding(s, [sh])], + }), + ], + }); + const map = resolveShaderBindings(g, sh); + expect(map.get(u)).toEqual({ group: 0, binding: 0 }); + expect(map.get(t)).toEqual({ group: 0, binding: 1 }); + expect(map.get(s)).toEqual({ group: 0, binding: 2 }); + }); + + it('assigns group index = depth from root for nested groups', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const c = uniformSlot('c', 'C'); + const sh = shader([a, b, c]); + const g = bindings({ + groups: [ + group({ + slots: [binding(a, [sh])], + groups: [ + group({ + slots: [binding(b, [sh])], + groups: [group({ slots: [binding(c, [sh])] })], + }), + ], + }), + ], + }); + const map = resolveShaderBindings(g, sh); + expect(map.get(a)).toEqual({ group: 0, binding: 0 }); + expect(map.get(b)).toEqual({ group: 1, binding: 0 }); + expect(map.get(c)).toEqual({ group: 2, binding: 0 }); + }); + + it('returns identical {group, binding} for a slot shared across two shaders (fan-out)', () => { + const camera = uniformSlot('camera', 'Camera'); + const shaderA = shader([camera]); + const shaderB = shader([camera]); + const g = bindings({ + groups: [group({ slots: [binding(camera, [shaderA, shaderB])] })], + }); + const mapA = resolveShaderBindings(g, shaderA); + const mapB = resolveShaderBindings(g, shaderB); + expect(mapA.get(camera)).toEqual({ group: 0, binding: 0 }); + expect(mapB.get(camera)).toEqual({ group: 0, binding: 0 }); + expect(mapA.get(camera)).toEqual(mapB.get(camera)); + }); + + it('returns an empty map for a shader not present in any SlotNode', () => { + const u = uniformSlot('u', 'U'); + const sh = shader([u]); + const orphan = shader([]); + const g = bindings({ + groups: [group({ slots: [binding(u, [sh])] })], + }); + const map = resolveShaderBindings(g, orphan); + expect(map.size).toBe(0); + }); +}); + +describe('shaderSlotEntries()', () => { + it('returns entries sorted by (group, binding)', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const c = uniformSlot('c', 'C'); + const sh = shader([a, b, c]); + const g = bindings({ + groups: [ + group({ + slots: [binding(a, [sh]), binding(b, [sh])], + groups: [group({ slots: [binding(c, [sh])] })], + }), + ], + }); + const entries = shaderSlotEntries(g, sh); + expect(entries.map((e) => ({ group: e.group, binding: e.binding, name: e.node.slot.name }))).toEqual([ + { group: 0, binding: 0, name: 'a' }, + { group: 0, binding: 1, name: 'b' }, + { group: 1, binding: 0, name: 'c' }, + ]); + }); + + it('skips slots whose usedBy does not include the supplied shader', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const shaderA = shader([a]); + const shaderB = shader([b]); + const g = bindings({ + groups: [ + group({ + slots: [binding(a, [shaderA]), binding(b, [shaderB])], + }), + ], + }); + const entriesA = shaderSlotEntries(g, shaderA); + const entriesB = shaderSlotEntries(g, shaderB); + expect(entriesA.map((e) => e.node.slot.name)).toEqual(['a']); + expect(entriesB.map((e) => e.node.slot.name)).toEqual(['b']); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/binding-graph.ts b/packages/core/src/rendering/webgpu/pipelines/binding-graph.ts new file mode 100644 index 00000000..3c8f8a17 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graph.ts @@ -0,0 +1,254 @@ +/** + * `BindingGraph` (DAG) + `bindings()` factory — Phase 2 replacement for the legacy chain-style + * `binding-graphs.ts`. + * + * The graph is a **DAG over Groups and Slots**, with `WgslShader` instances as leaves: + * + * ``` + * GroupNode ──contains──▶ GroupNode ─────────────▶ SlotNode + * └──▶ SlotNode ──usedBy──▶ WgslShader + * ``` + * + * - `GroupNode` defines a `@group(N)` index by its depth from a root group. Nested children + * become deeper group indices. Multiple GroupNodes may live at the same depth (siblings) but + * any single `ResourceSlot` belongs to exactly one path → exactly one `(group, binding)` pair. + * - `SlotNode` carries a `ResourceSlot` and a `usedBy: WgslShader[]` fan-out. Several shaders + * may consume the same slot (DAG fan-out); they all see it at the same `(group, binding)`. + * - The graph deliberately has no `PipelineNode`. Pipeline state (blend/depth/primitive/etc.) is + * supplied at build time by `pipeline(graph, shader, state, device)` (Phase 3). The leaves of + * the binding DAG are bare `WgslShader` instances, identified by their internal `id`. + * + * Validation performed by `bindings()`: + * 1. **Slot uniqueness in the DAG** — each `ResourceSlot` may appear in at most one `SlotNode`. + * 2. **Slot coverage per shader** — for every shader appearing in any `usedBy`, every + * `ResourceSlot` declared in `shader.declarations` is supplied by exactly one `SlotNode` + * that lists this shader in its `usedBy`. + * 3. **No orphan declarations** — a shader cannot reference a slot that the graph does not + * provide for it (under coverage above). + * 4. **Group depth limit** — defaults to 4 (matches the WebGPU spec's typical `maxBindGroups`). + * + * Per-shader `(group, binding)` resolution is performed by `resolveShaderBindings(graph, shader)` + * in `./traverse.ts`. Group index = depth of containing group from any root that reaches the + * shader. Binding index = position of the slot within that group's `slots` array. + */ + +import { isResourceSlot, type ResourceSlot } from '../resources'; +import type { WgslShader } from '../shaders'; + +// ---- DAG types -------------------------------------------------------------------------------- + +/** A `ResourceSlot` placed in a group and shared by one or more shader leaves. */ +export type SlotNode = { + readonly __nodeType: 'slot'; + readonly id: string; + readonly slot: ResourceSlot; + /** Shader leaves that consume this slot. Duplicates ignored; identity-compared. */ + readonly usedBy: readonly WgslShader[]; +}; + +/** A group containing slots and/or nested groups. Determines the `@group(N)` index by depth. */ +export type GroupNode = { + readonly __nodeType: 'group'; + readonly id: string; + readonly label?: string; + /** Slots placed directly in this group. Order defines binding indices. */ + readonly slots: readonly SlotNode[]; + /** Nested child groups (each becomes the next deeper group index). */ + readonly groups: readonly GroupNode[]; +}; + +/** Root of a validated binding graph: a non-empty list of root groups. */ +export type BindingGraph = { + readonly __graphType: 'binding-graph'; + readonly roots: readonly GroupNode[]; +}; + +// ---- POJO descriptors (input shape) ----------------------------------------------------------- + +/** Input shape for a slot in the POJO descriptor passed to `bindings()`. */ +export type SlotDescriptor = { + readonly slot: ResourceSlot; + readonly usedBy: readonly WgslShader[]; +}; + +/** Input shape for a group in the POJO descriptor passed to `bindings()`. */ +export type GroupDescriptor = { + readonly label?: string; + readonly slots?: readonly SlotDescriptor[]; + readonly groups?: readonly GroupDescriptor[]; +}; + +/** Top-level descriptor accepted by `bindings()`. Mirrors `BindingGraph` but with POJO inputs. */ +export type BindingsDescriptor = { + readonly groups: readonly GroupDescriptor[]; + /** Optional override of the default depth limit (4, matching the typical `maxBindGroups`). */ + readonly groupDepthLimit?: number; +}; + +// ---- Factories -------------------------------------------------------------------------------- + +/** Convenience factory for a `SlotDescriptor` (object form). */ +export function binding(slot: ResourceSlot, usedBy: readonly WgslShader[]): SlotDescriptor { + return { slot, usedBy }; +} + +/** Convenience factory for a `GroupDescriptor` (object form). */ +export function group( + options: { label?: string; slots?: readonly SlotDescriptor[]; groups?: readonly GroupDescriptor[] } = {} +): GroupDescriptor { + return options; +} + +// ---- Runtime type guards ----------------------------------------------------------------------- + +export function isSlotNode(value: unknown): value is SlotNode { + return ( + typeof value === 'object' && + value !== null && + (value as { __nodeType?: unknown }).__nodeType === 'slot' + ); +} + +export function isGroupNode(value: unknown): value is GroupNode { + return ( + typeof value === 'object' && + value !== null && + (value as { __nodeType?: unknown }).__nodeType === 'group' + ); +} + +export function isBindingGraph(value: unknown): value is BindingGraph { + return ( + typeof value === 'object' && + value !== null && + (value as { __graphType?: unknown }).__graphType === 'binding-graph' + ); +} + +// ---- Validation + construction ----------------------------------------------------------------- + +const DEFAULT_GROUP_DEPTH_LIMIT = 4; + +let nextNodeId = 0; +function freshId(prefix: string): string { + nextNodeId += 1; + return `${prefix}#${nextNodeId}`; +} + +/** + * Construct and validate a `BindingGraph` from a POJO descriptor. + * + * @throws if any of the validation rules in this module's file-level doc-comment are violated. + */ +export function bindings(descriptor: BindingsDescriptor): BindingGraph { + const depthLimit = descriptor.groupDepthLimit ?? DEFAULT_GROUP_DEPTH_LIMIT; + + if (!descriptor.groups || descriptor.groups.length === 0) { + throw new Error('bindings: descriptor must contain at least one root group'); + } + + // Step 1: walk POJO → typed DAG nodes. Track every slot occurrence for uniqueness check. + const slotOccurrences = new Map(); + + const buildGroup = (desc: GroupDescriptor, depth: number): GroupNode => { + if (depth > depthLimit) { + throw new Error( + `bindings: group depth ${depth} exceeds the limit of ${depthLimit} ` + + `(label='${desc.label ?? ''}')` + ); + } + const slots: SlotNode[] = (desc.slots ?? []).map((s, bindingIndex) => { + // Validate the slot is actually a ResourceSlot. + if (!isResourceSlot(s.slot)) { + throw new Error( + `bindings: entry at group '${desc.label ?? ''}' binding ${bindingIndex} ` + + `is not a ResourceSlot (did you forget to use slot.uniform/.texture/.sampler/etc.?)` + ); + } + // Deduplicate `usedBy` by identity while preserving first-seen order. + const seen = new Set(); + const usedBy: WgslShader[] = []; + for (const sh of s.usedBy) { + if (!seen.has(sh)) { + seen.add(sh); + usedBy.push(sh); + } + } + const node: SlotNode = { + __nodeType: 'slot', + id: freshId('slot'), + slot: s.slot, + usedBy, + }; + return node; + }); + + const node: GroupNode = { + __nodeType: 'group', + id: freshId('group'), + ...(desc.label !== undefined && { label: desc.label }), + slots, + groups: (desc.groups ?? []).map((g) => buildGroup(g, depth + 1)), + }; + + // Record slot occurrences for uniqueness validation, *after* the node exists. + slots.forEach((sn, bindingIndex) => { + const prior = slotOccurrences.get(sn.slot); + if (prior !== undefined) { + throw new Error( + `bindings: slot '${sn.slot.name}' appears in multiple SlotNodes ` + + `(group '${prior.group.label ?? ''}' binding ${prior.binding}, ` + + `and group '${node.label ?? ''}' binding ${bindingIndex}). ` + + `Each ResourceSlot may belong to exactly one SlotNode.` + ); + } + slotOccurrences.set(sn.slot, { group: node, binding: bindingIndex }); + }); + + return node; + }; + + const roots = descriptor.groups.map((g) => buildGroup(g, 1)); + + // Step 2: per-shader coverage validation. + // For every shader appearing in any SlotNode.usedBy, every ResourceSlot in its declarations + // must be supplied by some SlotNode.usedBy ⟶ this shader. The reverse — a SlotNode naming a + // shader that does not actually declare the slot — is permitted (harmless; the slot simply + // contributes to that shader's bind-group layout without being read). + const shaderToProvidedSlots = new Map>(); + const walk = (g: GroupNode): void => { + for (const sn of g.slots) { + for (const shader of sn.usedBy) { + let set = shaderToProvidedSlots.get(shader); + if (!set) { + set = new Set(); + shaderToProvidedSlots.set(shader, set); + } + set.add(sn.slot); + } + } + for (const child of g.groups) walk(child); + }; + for (const r of roots) walk(r); + + for (const [shader, provided] of shaderToProvidedSlots) { + const declared = new Set(); + for (const d of shader.declarations) { + if (isResourceSlot(d)) declared.add(d); + } + for (const ds of declared) { + if (!provided.has(ds)) { + throw new Error( + `bindings: shader (id='${shader.id}') declares slot '${ds.name}' but the ` + + 'graph does not provide it for this shader (add the shader to that ' + + "SlotNode's usedBy, or remove the slot from the shader)." + ); + } + } + } + + return { + __graphType: 'binding-graph', + roots, + }; +} diff --git a/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts b/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts index 49e06d8b..389cf296 100644 --- a/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts +++ b/packages/core/src/rendering/webgpu/pipelines/traversal.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { ShaderStageFlag } from '../native-types'; -import { bindShader, samplerResource, textureResource, uniformResource } from '../resources'; +import { bindShader, samplerSlot, textureSlot, uniformSlot } from '../resources'; import { asSource, shader } from '../shaders'; import { group, makeBindingGraph, pipeline, resource } from './binding-graphs'; import type { DrawContext, ResourceProvider } from './draw-context'; @@ -23,9 +23,9 @@ const makeCtx = (overrides: Partial = {}): DrawContext => ({ describe('traverseBindingGraph — single group', () => { it('assigns sequential bindings within a group', () => { - const u = uniformResource('u', 'U'); - const t = textureResource('tex', 'texture_2d'); - const s = samplerResource('samp', 'sampler'); + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); + const s = samplerSlot('samp', 'sampler'); const pl = pipeline(shader([u, t, s]), { stages: ShaderStageFlag.FRAGMENT }); const bg = makeBindingGraph([ @@ -48,9 +48,9 @@ describe('traverseBindingGraph — single group', () => { describe('traverseBindingGraph — nested subgroups', () => { it('assigns each level of the subgroup chain a distinct group index', () => { - const a = uniformResource('a', 'A'); - const b = uniformResource('b', 'B'); - const c = uniformResource('c', 'C'); + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const c = uniformSlot('c', 'C'); const pl = pipeline(shader([a, b, c]), { stages: ShaderStageFlag.VERTEX }); const bg = makeBindingGraph([ @@ -75,7 +75,7 @@ describe('traverseBindingGraph — nested subgroups', () => { describe('traverseBindingGraph — visibility', () => { it('explicit Resource.visibility wins', () => { - const u = uniformResource('u', 'U', { visibility: ShaderStageFlag.VERTEX }); + const u = uniformSlot('u', 'U', { visibility: ShaderStageFlag.VERTEX }); const pl = pipeline(shader([u]), { stages: ShaderStageFlag.FRAGMENT }); const bg = makeBindingGraph([group(undefined, [resource(undefined, u, fakeBuffer, [pl])])]); const result = traverseBindingGraph(bg); @@ -83,7 +83,7 @@ describe('traverseBindingGraph — visibility', () => { }); it('unions stages of all referencing pipelines when Resource.visibility is unset', () => { - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const plV = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); const plF = pipeline(shader([u]), { stages: ShaderStageFlag.FRAGMENT }); const bg = makeBindingGraph([group(undefined, [resource(undefined, u, fakeBuffer, [plV, plF])])]); @@ -92,7 +92,7 @@ describe('traverseBindingGraph — visibility', () => { }); it('defaults to all stages when neither is provided', () => { - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const pl = pipeline(shader([u])); const bg = makeBindingGraph([group(undefined, [resource(undefined, u, fakeBuffer, [pl])])]); const result = traverseBindingGraph(bg); @@ -104,7 +104,7 @@ describe('traverseBindingGraph — visibility', () => { describe('traverseBindingGraph — duplicate detection', () => { it('throws when the same Resource appears at multiple positions', () => { - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const pl = pipeline(shader([u])); const bg = makeBindingGraph([ group(undefined, [ @@ -118,9 +118,9 @@ describe('traverseBindingGraph — duplicate detection', () => { describe('integration: traverse → bindShader → asSource', () => { it('produces fully resolved WGSL combining raw declarations and Resources', () => { - const u = uniformResource('unis', 'Uniforms'); - const t = textureResource('tex', 'texture_2d'); - const s = samplerResource('samp', 'sampler'); + const u = uniformSlot('unis', 'Uniforms'); + const t = textureSlot('tex', 'texture_2d'); + const s = samplerSlot('samp', 'sampler'); const sh = shader([u, t, s]); const pl = pipeline(sh, { stages: ShaderStageFlag.FRAGMENT }); const bg = makeBindingGraph([ @@ -144,9 +144,9 @@ describe('integration: traverse → bindShader → asSource', () => { describe('traverseBindingGraphLayout — fixed-only graphs', () => { it('emits the same bindings and layouts as the legacy single-call API', () => { - const u = uniformResource('u', 'U'); - const t = textureResource('tex', 'texture_2d'); - const s = samplerResource('samp', 'sampler'); + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); + const s = samplerSlot('samp', 'sampler'); const pl = pipeline(shader([u, t, s]), { stages: ShaderStageFlag.FRAGMENT }); const bg = makeBindingGraph([ group(undefined, [ @@ -173,8 +173,8 @@ describe('traverseBindingGraphLayout — fixed-only graphs', () => { }); it('assembleBindGroupResources on a fixed-only layout matches the legacy bindGroupResources', () => { - const u = uniformResource('u', 'U'); - const t = textureResource('tex', 'texture_2d'); + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); const bg = makeBindingGraph([ group(undefined, [ @@ -193,7 +193,7 @@ describe('traverseBindingGraphLayout — fixed-only graphs', () => { describe('traverseBindingGraphLayout — provider slots', () => { it('emits a provider slot for callable gpu values', () => { - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const pl = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); const provide: ResourceProvider = vi.fn(() => fakeBuffer); const bg = makeBindingGraph([group(undefined, [resource(undefined, u, provide, [pl])])]); @@ -205,8 +205,8 @@ describe('traverseBindingGraphLayout — provider slots', () => { }); it('assembleBindGroupResources invokes each provider once per call with the supplied ctx', () => { - const u = uniformResource('u', 'U'); - const t = textureResource('tex', 'texture_2d'); + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); const provideU: ResourceProvider = vi.fn(() => fakeBuffer); @@ -236,7 +236,7 @@ describe('traverseBindingGraphLayout — provider slots', () => { }); it('yields different ResourceData per drawable when the provider varies on ctx', () => { - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const pl = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); const bufferA: ResourceData = { buffer: {} as GPUBuffer }; @@ -251,9 +251,9 @@ describe('traverseBindingGraphLayout — provider slots', () => { }); it('mixed fixed + provider slots resolve independently in the same group', () => { - const u = uniformResource('u', 'U'); - const t = textureResource('tex', 'texture_2d'); - const s = samplerResource('samp', 'sampler'); + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); + const s = samplerSlot('samp', 'sampler'); const pl = pipeline(shader([u, t, s]), { stages: ShaderStageFlag.FRAGMENT }); const perDrawBuffer: ResourceData = { buffer: {} as GPUBuffer }; @@ -278,7 +278,7 @@ describe('traverseBindingGraphLayout — provider slots', () => { }); it('throws on the legacy traverseBindingGraph shim when any slot is a provider', () => { - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const pl = pipeline(shader([u]), { stages: ShaderStageFlag.VERTEX }); const provide: ResourceProvider = () => fakeBuffer; const bg = makeBindingGraph([group(undefined, [resource(undefined, u, provide, [pl])])]); @@ -289,8 +289,8 @@ describe('traverseBindingGraphLayout — provider slots', () => { describe('traverseBindingGraphLayout — layout identity stability', () => { it('back-to-back calls produce structurally identical bindings and layouts', () => { - const u = uniformResource('u', 'U'); - const t = textureResource('tex', 'texture_2d'); + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); const pl = pipeline(shader([u, t]), { stages: ShaderStageFlag.FRAGMENT }); const provideU: ResourceProvider = () => fakeBuffer; const bg = makeBindingGraph([ diff --git a/packages/core/src/rendering/webgpu/pipelines/traverse.ts b/packages/core/src/rendering/webgpu/pipelines/traverse.ts new file mode 100644 index 00000000..21e2b0f1 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/traverse.ts @@ -0,0 +1,84 @@ +/** + * Traversal helpers for the new DAG `BindingGraph` (see `./binding-graph.ts`). + * + * The traversal is **per-shader**: each shader leaf appearing in any `SlotNode.usedBy` gets its + * own `(group, binding)` resolution. Two shaders may share a slot — they will both see that + * slot at the same `(group, binding)` because the slot lives in exactly one group node. + * + * Resolution rules: + * - **Group index** = the depth of the containing `GroupNode` along the path from the first root + * that reaches the shader. Root groups are at depth 0. Nested children at depth+1. + * - **Binding index** = the position of the slot within its containing group's `slots` array + * (slots ordered left-to-right in the POJO descriptor). + * - A shader that does not appear in any `SlotNode.usedBy` resolves to an empty `BindingMap` + * (the caller likely passed the wrong graph). + * + * This module is intentionally distinct from the legacy `traversal.ts` (which operates over the + * chain-style `binding-graphs.ts` and will be removed in Phase 9). The two coexist during the + * phase transition. + */ + +import type { BindingMap } from '../resources'; +import type { ResourceSlot } from '../resources'; +import type { WgslShader } from '../shaders'; +import type { BindingGraph, GroupNode, SlotNode } from './binding-graph'; + +/** + * Resolve `{group, binding}` indices for every slot the supplied `shader` consumes in `graph`. + * + * @returns a `BindingMap` covering the shader's slots; suitable for `bindShader(shader, map)`. + * + * @throws if the same slot reaches the shader along two different group paths (this should be + * impossible if `bindings()` accepted the graph, since slot-uniqueness is enforced at + * construction; the check here is a defensive invariant). + */ +export function resolveShaderBindings(graph: BindingGraph, shader: WgslShader): BindingMap { + const result = new Map(); + + const visit = (g: GroupNode, depth: number): void => { + g.slots.forEach((sn, bindingIndex) => { + if (sn.usedBy.includes(shader)) { + const prior = result.get(sn.slot); + if (prior !== undefined && (prior.group !== depth || prior.binding !== bindingIndex)) { + throw new Error( + `resolveShaderBindings: slot '${sn.slot.name}' reachable along two paths for ` + + `shader id='${shader.id}' (prior {group: ${prior.group}, binding: ${prior.binding}}, ` + + `new {group: ${depth}, binding: ${bindingIndex}}). This indicates a corrupted ` + + 'BindingGraph; rebuild it with bindings().' + ); + } + result.set(sn.slot, { group: depth, binding: bindingIndex }); + } + }); + g.groups.forEach(child => visit(child, depth + 1)); + }; + + graph.roots.forEach(r => visit(r, 0)); + + return result; +} + +/** + * Enumerate every `(group, binding, slot)` reachable for a shader. Sorted by group then binding. + * Used by Phase 3 pipeline build (and potentially diagnostics) to walk a shader's binding layout + * without going through `bindShader` first. + */ +export function shaderSlotEntries( + graph: BindingGraph, + shader: WgslShader +): ReadonlyArray<{ readonly group: number; readonly binding: number; readonly node: SlotNode }> { + const entries: { group: number; binding: number; node: SlotNode }[] = []; + + const visit = (g: GroupNode, depth: number): void => { + g.slots.forEach((sn, bindingIndex) => { + if (sn.usedBy.includes(shader)) { + entries.push({ group: depth, binding: bindingIndex, node: sn }); + } + }); + g.groups.forEach(child => visit(child, depth + 1)); + }; + + graph.roots.forEach(r => visit(r, 0)); + entries.sort((a, b) => (a.group - b.group) || (a.binding - b.binding)); + return entries; +} diff --git a/packages/core/src/rendering/webgpu/plan-2026-06-25.md b/packages/core/src/rendering/webgpu/plan-2026-06-25.md new file mode 100644 index 00000000..afbbeb5d --- /dev/null +++ b/packages/core/src/rendering/webgpu/plan-2026-06-25.md @@ -0,0 +1,214 @@ +# Plan: Unified WebGPU Rendering & Memory Architecture + +Replace the current fragmented binding/render graph code with a layered, persistent, mutation-driven system that mirrors the user's 9-step workflow: `ResourceSlot` → `WgslShader` → DAG `BindingGraph` (Pipelines as leaves) → `Resource` (data-bearing, backed by `BufferManager`) → `Drawable` → persistent `Scene` (render graph) → state-deduplicating `Encoder`. Keep `resources/` (after rename), `shaders/`, `memory/`. Replace `graphs/nodes.ts` and `pipelines/binding-graphs.ts` wholesale. Adopt `webgpu-utils` as a dependency to eliminate hand-written WGSL introspection / structured buffer view / BGL descriptor / vertex-buffer interleave code. + +## Locked decisions +- Existing `Resource` → `ResourceSlot`. New data-bearing object inherits the name `Resource`. +- `BindingGraph` is a **DAG**: shared Groups/Slots fan out to multiple Pipeline leaves. +- `Scene` (the persistent render graph) is **mutable**: `add`/`remove`/`replace`/`markDirty`; Encoder caches per-subtree commands. +- `graphs/nodes.ts` + `pipelines/binding-graphs.ts` replaced wholesale. +- **Resource ownership**: strict refcount with `share()` for explicit cross-Drawable sharing. +- **State nodes**: discriminated variants (`ViewportNode`, `ScissorNode`, …) — not a single `StateNode{kind,value}`. +- **No `PipelineRefNode` in the Scene.** Drawable owns its pipeline assignment (singular). Multi-pipeline drawing = multiple Drawable instances sharing geometry via `Resource.share()`. State nodes sit *above* drawables; Encoder emits `setPipeline` lazily when a Drawable's pipeline differs from `ActiveState.pipeline`. +- **Worker-friendly**: all public constructors take plain-object descriptors (no closures). Scene mutations serialize as messages. Encoder/Pipeline/Resource instances are thread-local but reconstructable from descriptors on either side. +- **Public API style**: single-word, lowercase functions wherever feasible. Compound-word factories grouped under namespaces (e.g., `slot.uniform`, `slot.storageTexture`). +- **`webgpu-utils` dependency** for: `makeShaderDataDefinitions` (WGSL introspection), `makeStructuredView` (typed CPU views over buffers), `makeBindGroupLayoutDescriptors` (BGL descriptors from WGSL + pipelineDesc), `createBuffersAndAttributesFromArrays` (interleaved vertex buffer **layouts only** — never used for GPU buffer creation), `createTextureFromSource`/`Image`/`Images`, `numMipLevels`, `generateMipmap`. +- **`override` semantics**: slot-by-slot. `BindingOverrideNode.overrides: Map` replaces specific slots for its subtree; non-overridden slots fall through to the Drawable's own bindings. +- **State restoration**: state nodes (`viewport`, `scissor`, `stencilref`, `blendconstant`) **restore-on-exit** via `ActiveState` snapshots captured at subtree entry. Mental model: nested lexical scope. +- **Pipeline state lives at build time, not in the binding graph**. The binding graph's leaves are **`WgslShader` instances directly** (no `PipelineNode`/`PipelineDef` wrapper). `pipeline(graph, shader, state, device)` takes `state: PipelineStateDescriptor` at the call site. The same `shader` can be `pipeline()`-built multiple times producing distinct `BuiltPipeline`s — same shader+slots, different blend/depth/primitive/etc. `BuiltPipeline.fingerprint` incorporates `(shaderId, slotIndex, state)`. +- **Slots carry their struct type via the shader DSL**, not raw WGSL strings. `slot.uniform({ name, type: structDecl })` where `structDecl = struct('Camera', [member('view', 'mat4x4f'), ...])`. Slot construction emits the struct to WGSL once internally and feeds it to `webgpu-utils.makeShaderDataDefinitions` so the slot self-describes its layout — `resource(slot)` works without needing a `BuiltPipeline.defs` round-trip. Struct descriptors are reusable across slots and shaders. +- **`drawable.reuse(other, overrides)`** convenience method auto-shares vertex/index Resources and inherits unspecified bindings from `other`; explicit `share()` increments refcounts. +- **BufferManager owns all GPU buffer creation**. `device.createBuffer` is forbidden anywhere outside `BufferManager` implementations. Every caller obtains a buffer via `BufferManager.acquire(size, usage)` and receives a `BufferHandle { gpu: GPUBuffer, offset: number, size: number, … }`. This makes slab-style managers (one giant `GPUBuffer` carved into slices) a drop-in replacement for `BatchPoolBufferManager` without touching callers. All WebGPU calls thread `handle.offset` + `handle.size`: `setVertexBuffer(slot, gpu, offset, size)`, `setIndexBuffer(gpu, format, offset, size)`, bind-group entries `{ buffer: gpu, offset, size }`, `queue.writeBuffer(gpu, offset, data)`. Alignment (uniform/storage `minOffsetAlignment`, vertex/index 4-byte) is the manager's responsibility. +- **End-to-end type safety, Tier 1 only in v1**: `struct → slot → resource → resource.set({...})` is fully typed. `member()` / `struct()` use `` generics; the `StructDecl` carries forward through `slot.uniform`, `ResourceSlot`, `resource(slot): BufferResource`, and `BufferResource.set(values: Partial)` + `setField(k: K, v: T[K])`. Tier 2 (drawable bindings type-checked against pipeline slot map) and Tier 3 (shader-declarations → pipeline slot inference) are deferred — the v1 API surface is designed to be generic-ready (internal `unknown`s where Tiers 2/3 would inject type params) so they can be added post-v1 without breaking changes. + +## Public API surface (single-word/namespaced) + +| Function | Purpose | Phase | +|--------------------|------------------------------------------------------------------|-------| +| `slot.uniform` | Create a uniform ResourceSlot | 1 | +| `slot.texture` | Create a texture ResourceSlot | 1 | +| `slot.sampler` | Create a sampler ResourceSlot | 1 | +| `slot.storage` | Create a storage-buffer ResourceSlot | 1 | +| `slot.storageTexture` | Create a storage-texture ResourceSlot | 1 | +| `slot.external` | Create an external-texture ResourceSlot | 1 | +| `shader` | Create a WgslShader from declarations | 1 | +| `bindings` | Create a BindingGraph (DAG) from a POJO descriptor | 2 | +| `pipeline` | Build a GPURenderPipeline from BindingGraph + shader + state | 3 | +| `resource` | Construct a data-bearing Resource (dispatches on slot kind) | 4 | +| `drawable` | Construct a Drawable (pipeline + vertex/index + bindings + draw) | 5 | +| `scene` | Construct a Scene (persistent render-graph) from a POJO descriptor | 6 | +| `viewport` | Scene state-node factory | 6 | +| `scissor` | Scene state-node factory | 6 | +| `stencilref` | Scene state-node factory | 6 | +| `blendconstant` | Scene state-node factory | 6 | +| `container` | Scene grouping node (no state effect) | 6 | +| `override` | Scene node that overrides bindings for its subtree | 6 | +| `draw` | Scene leaf wrapping a Drawable | 6 | +| `encoder` | Construct a GraphEncoder bound to a device | 7 | +| `submit` | Plan+execute a frame from an encoder + scene | 7 | + +## Phases + +### Phase 1 — Rename `Resource` → `ResourceSlot` + freeze public API surface + struct-DSL slots +*Prep; runs first to free the `Resource` name.* +- Rename in `resources/resource.ts`: type, brand symbol (→ `vis-core.webgpu.ResourceSlot`), internal constructors. +- Update `bind.ts`, `bound.ts`, `pipelines/resources.ts`, `pipelines/draw-context.ts`, `pipelines/bind-group-cache.ts`, `pipelines/traversal.ts`, examples/tests. +- `BindingMap` becomes `ReadonlyMap`. +- **Slot factories take struct descriptors from the shader DSL** (not raw WGSL): + - `slot.uniform({ name, type: StructDecl }): ResourceSlot` — internally renders `type` to WGSL and runs `makeShaderDataDefinitions` once at slot construction; the slot caches its `ShaderDataDefinitions` entry so `resource(slot)` is self-sufficient. + - `slot.storage({ name, type: StructDecl, access: 'read' | 'read_write' })`. + - `slot.texture({ name, sampleType, viewDimension? })`, `slot.storageTexture({ name, format, viewDimension? })`, `slot.sampler({ name, type? })`, `slot.external({ name })`. + - **Const-generic API surface from day one**: `member(name, type)` and `struct(name, members)` produce a `StructDecl` where `TsShape` is derived from the members tuple via a `WgslToTs` conditional-type map (mat4x4f → `Float32Array | readonly number[]`, vec3f → `[number, number, number] | Float32Array`, scalars → `number`, etc.). `slot.uniform`/`slot.storage` carry the parameter forward: `slot.uniform({ name, type: StructDecl }): ResourceSlot`. Tier 1 lights up in Phase 4 the moment `BufferResource.set(values: Partial)` is implemented — no breaking API change. Tier 2/3 generics (drawable bindings type-checked against pipeline; shader-declarations inferred into pipeline slot map) remain `unknown` placeholders in v1 so they can be added later without breaking changes. +- `WgslShader` constructor assigns each instance an internal `id: string` (UUID-ish) — used by `bindings()` to index leaves and by `pipeline()`/encoder fingerprinting. +- Expose public-API namespace from `packages/core/src/rendering/webgpu/index.ts`: + - `slot.{uniform,texture,sampler,storage,storageTexture,external}` factory namespace + - `shader`, `struct`, `member` re-exports from `shaders/` + - Reserve type stubs (interfaces / `unknown`-returning declarations) for `bindings`, `pipeline`, `resource`, `drawable`, `scene`, `viewport`, `scissor`, `stencilref`, `blendconstant`, `container`, `override`, `draw`, `encoder`, `submit` so downstream code can be type-checked against the final shape from day one. +- Add `webgpu-utils` to `packages/core/package.json` dependencies. + +### Phase 2 — DAG BindingGraph + `bindings()` factory +*Depends on Phase 1.* +- Replace `pipelines/binding-graphs.ts` with a DAG: + - `BindingGraphNode = GroupNode | SlotNode | ShaderLeaf` + - `GroupNode { id, contains: (GroupNode | SlotNode)[] }` + - `SlotNode { id, slot: ResourceSlot, usedBy: WgslShader[] }` + - **Shaders themselves are the leaves**. `WgslShader` instances are tagged with an internal `id` (assigned at construction) so the BindingGraph can index them; no separate `ShaderNode`/`PipelineNode` wrapper exists. +- `bindings(descriptor)` validates: + - DAG (no cycles); every Shader is reachable from a root Group. + - Every Slot a Shader declares is supplied by exactly one path from a root Group to that Shader. Validation uses `webgpu-utils.makeShaderDataDefinitions(asSource(shader))` to enumerate declared bindings and cross-check. +- New `traverse.ts`: + - `resolveShaderBindings(graph, shader): Map` — group index = depth from root, binding index = order within group. Deterministic, per-shader. + - `shaderBindGroupLayoutDescriptors(graph, shader, device): GPUBindGroupLayoutDescriptor[]` — primary path delegates to `webgpu-utils.makeBindGroupLayoutDescriptors(defs, pipelineDesc)`; fallback to manual entry construction for slot kinds it doesn't fully cover. + +### Phase 3 — Pipeline build + cache: `pipeline()` +*Depends on Phase 2.* +- New `pipelines/build.ts`: + - `pipeline(graph, shader, state, device): BuiltPipeline` — takes the `WgslShader` directly (it's the binding-graph leaf), plus `state: PipelineStateDescriptor` (primitive / depthStencil / multisample / blend / fragment targets, typed from `native-types.ts`) supplied at build time. The same shader may be built any number of times with different states; each call produces a distinct `BuiltPipeline`. + - Internals: `resolveShaderBindings(graph, shader)` → `bindShader(shader, bindings)` for final WGSL → `webgpu-utils.makeBindGroupLayoutDescriptors(defs, pipelineDesc)` → `GPUBindGroupLayout[]` → `GPUPipelineLayout` → `GPURenderPipeline` (the `state` argument feeds the `GPURenderPipelineDescriptor`). + - `BuiltPipeline { id, gpu, layout, bindGroupLayouts, slotIndex, defs: ShaderDataDefinitions, fingerprint }`. `defs` carries struct layouts. `fingerprint` = hash of `(shader.id, slotIndex, state)` — drives encoder no-op elision and identity-based reuse. + - Internal fingerprint → `BuiltPipeline` cache so repeated calls with the same `(graph, shader, state)` return the same instance. +- Content-addressable: same shader + slots + state ⇒ same fingerprint ⇒ same `BuiltPipeline`. + +### Phase 4 — Data-bearing `Resource` + BufferManager extensions: `resource()` +*Depends on Phase 1 for freed name; structurally independent of Phases 2/3.* +- New `webgpu/data/`: + - `resource.ts`: `Resource` discriminated union — `BufferResource`, `TextureResource`, `SamplerResource`, `StorageBufferResource`, `StorageTextureResource`, `ExternalTextureResource`. `` is the Tier-1 type parameter carried from the slot's `StructDecl`. + - `resource(slot, init?)` — single dispatch factory. `init` is kind-specific (initial data for buffers; image/canvas/data for textures via `webgpu-utils.createTextureFromSource`). + - `BufferResource`: + - Holds a `BufferHandle` from `BufferManager`. + - Uses `webgpu-utils.makeStructuredView(slot.def, optionalArrayBuffer)` for typed views. **Size derives from the slot's own cached `ShaderDataDefinitions` entry (populated at slot construction in Phase 1) — no dependency on a `BuiltPipeline` is required.** A `BuiltPipeline.defs` round-trip is *only* used when the slot kind doesn't carry its own struct (e.g., unsized storage arrays whose size is configured per-binding). + - `set(values: Partial)` / `setField(k: K, v: T[K])` — **fully typed against the slot's struct** (Tier 1). + - `commit(device)` flushes the CPU `arrayBuffer` to GPU via `queue.writeBuffer(handle.gpu, handle.offset, arrayBuffer)`. Bumps `version` for bind-group cache invalidation. **Always passes `handle.offset`** so slab-style managers work transparently. + - `usage` declared at construction (defaulted from the slot kind); validated against slot at bind time. + - `destroy()` releases `BufferHandle`. `share()` increments refcount, returns `this`. + - `TextureResource` factory delegates to `webgpu-utils.createTextureFromSource`/`Image`/`Images`; mip handling via `numMipLevels` + `generateMipmap`. (Textures are NOT slab-managed; `TextureResource` directly owns its `GPUTexture` in v1. Future managers may wrap textures, but `BufferManager` stays buffer-only.) +- Extend `memory/`: + - **`BufferHandle` carries `{ gpu: GPUBuffer, offset: number, size: number, … }`** as required fields. `BatchPoolBufferManager` uses `offset: 0, size: bucketSize`. A future `SlabBufferManager` carves non-zero offsets out of one giant buffer with no change to consumers. + - `acquireForSlot(slot: ResourceSlot, sizeBytes, usage): BufferHandle` on `BufferManager` — validates `usage & required === required` and applies the slot kind's required alignment (uniform/storage `minOffsetAlignment` from `device.limits`). + - `preflight(budgetCost: number): boolean` — non-allocating budget check used by `resource()` and `drawable()` to fail fast before partial allocation. + - `OutOfBudgetError`/`OutOfBucketError` surface at the construction site. + - **Hard rule (documented + lint-checked)**: `device.createBuffer` may only be called from inside a `BufferManager` implementation. Any other call site is a code-review-blocking error. + +### Phase 5 — `Drawable`: `drawable()` +*Depends on Phases 3 + 4.* +- New `webgpu/drawable.ts`: + - `Drawable { id, pipeline: BuiltPipeline, vertexBuffers: Map, indexBuffer?: { resource, format }, bindings: Map, draw: DrawCall }`. + - `DrawCall = { kind:'array', vertexCount, instanceCount, firstVertex, firstInstance } | { kind:'indexed', indexCount, instanceCount, firstIndex, baseVertex, firstInstance }`. + - `drawable({ pipeline, vertex, index?, bindings, draw })`: + - `vertex` may be a `Map` (assumes pre-built) or a raw arrays object. In the raw-arrays case: `webgpu-utils.createBuffersAndAttributesFromArrays` is used **only to derive the interleaved byte layout, attribute formats, and `bufferLayouts`**. **GPU buffer allocation goes through `BufferManager.acquire(sizeBytes, GPUBufferUsage.VERTEX | COPY_DST)`** (and `GPUBufferUsage.INDEX | COPY_DST` for the index buffer); upload via `device.queue.writeBuffer(handle.gpu, handle.offset, interleavedBytes)`; wrap each in a `BufferResource`. **`device.createBuffer` is never called from this code path** — the buffer manager owns all GPU buffer creation so slab-style managers remain transparent. + - `index` similarly: a `BufferResource` + format, or raw indices that go through `BufferManager` for the GPU buffer. + - `bindings` is a `Record` validated against `pipeline.slotIndex`: every required slot supplied; each `Resource.usage` compatible with its slot. + - Multi-pipeline pattern: construct N Drawables sharing the same vertex/index/binding Resources via `Resource.share()`. The factory has a convenience: `drawable.reuse(otherDrawable, { pipeline: B, bindings: { ... } })` that auto-shares the vertex/index resources and validates the new pipeline. + - `destroy()` decrefs every owned `Resource` (vertex BufferResources, index BufferResource, and any owned binding Resources — `share()`'d ones are not destroyed until their last owner releases). + +### Phase 6 — Persistent `Scene` + state-node factories +*Depends on Phase 5.* +- New `webgpu/scene/`: + - `types.ts`: + - `Scene { id, target, root: SceneNode, parents: Map, dirty: Set }` + - `RenderTarget = { color: GPURenderPassColorAttachment[], depthStencil?: GPURenderPassDepthStencilAttachment }` + - `SceneNode` discriminated union (**no `PipelineRefNode`**): + - `ContainerNode { id, kind:'container', children }` + - `ViewportNode { id, kind:'viewport', x, y, width, height, minDepth, maxDepth, children }` + - `ScissorNode { id, kind:'scissor', x, y, width, height, children }` + - `StencilRefNode { id, kind:'stencilref', value, children }` + - `BlendConstantNode { id, kind:'blendconstant', color, children }` + - `BindingOverrideNode { id, kind:'override', overrides: Map, children }` + - `DrawableNode { id, kind:'draw', drawable: Drawable }` — leaf. + - `scene.ts`: + - `scene(descriptor)` constructs from a worker-transferable POJO. + - Public factories (each single-word lowercase): `container(children)`, `viewport({...})`, `scissor({...})`, `stencilref(value, children)`, `blendconstant(color, children)`, `override(map, children)`, `draw(drawable)`. + - `Scene` methods: `add(parentId, node) → NodeId`, `remove(id)`, `replace(id, node)`, `markDirty(id)`, `markSubtreeDirty(id)`. O(depth) dirty propagation via `parents`. + - `'structure-changed'` event fired after each mutation for downstream cache trimming. +- **Worker shape**: every node descriptor is a POJO referencing only string IDs (pipeline/resource/drawable IDs). The scene itself is `structuredClone`-able. The thread that owns the encoder keeps an `id → live-object` dictionary; mutations from the other thread arrive as messages and replay via the same public methods. +- **Compute-future-proofing**: the base `Graph` interface (which `Scene` extends) is parameterized; a future `ComputeScene`/`ComputeGraph` reuses `parents`/`dirty`/event infrastructure with different node variants. Not implemented in v1. + +### Phase 7 — `Encoder`: `encoder()` + `submit()` +*Depends on Phases 2, 3, 6. Fulfills the existing `plan-2026-06-01.md`.* +- New `webgpu/encoder/`: + - `state.ts`: `ActiveState { pipeline?, bindGroups: (GPUBindGroup|undefined)[], vertexBuffers: Map, indexBuffer?, viewport?, scissor?, stencilRef?, blendConstant? }`. Fingerprint-able for snapshot comparison. Vertex/index entries store the full handle (gpu + offset + size) so equality checks correctly distinguish two slices of the same underlying slab buffer. + - `bind-group-builder.ts`: takes a Pipeline + scene-resolved Resources + any `BindingOverrideNode` overrides + Drawable bindings → `GPUBindGroup[]` keyed by `(pipelineId, groupIndex, ...resourceVersions)`. Reuses existing `bind-group-cache.ts` extended to include `Resource.version`. **Bind-group entries always specify `offset` + `size` from the `BufferHandle`** — never assume offset 0 or whole-buffer size, so slab managers work transparently. + - `encoder.ts`: `GraphEncoder` per `plan-2026-06-01.md`: + - Two-phase per frame: **plan** (walk dirty subtrees, recompute command lists, restore snapshots) → **execute** (replay cached + freshly-encoded segments in correct child order against a `GPURenderPassEncoder`). + - State elision via `ActiveState` comparison; per-subtree command cache invalidated by `dirty`. + - Entry/exit `ActiveState` snapshots so a clean cache subtree can be replayed without re-walking. + - **State restore-on-exit**: on entering a state-node subtree (`ViewportNode`, `ScissorNode`, `StencilRefNode`, `BlendConstantNode`), the encoder snapshots the relevant field of `ActiveState` and emits the new setter; on exit, if the snapshotted value differs from the value left active, emits the restore setter. Bind-group overrides via `BindingOverrideNode` snapshot/restore the affected bind groups identically. + - **Pipeline emission**: on visiting a `DrawableNode`, if `drawable.pipeline.id !== activeState.pipeline?.id`, emit `setPipeline` and invalidate incompatible bind-group slots (per WebGPU rules, with `slotIndex` layout compatibility). Then `setBindGroup` for slots that changed, then `setVertexBuffer(loc, handle.gpu, handle.offset, handle.size)` and `setIndexBuffer(handle.gpu, format, handle.offset, handle.size)`, then `draw`/`drawIndexed`. Pipeline state is **not** auto-restored when leaving a subtree — pipeline is a draw-call attribute, not a scoped state node. + - `encoder(device)` constructs one; `submit(encoder, scene)` runs plan+execute and submits the encoded `GPUCommandBuffer` to the device queue. + +### Phase 8 — Memory lifecycle & safeguards +*Crosscuts Phases 4–7.* +- `Resource.destroy()` → `BufferHandle.release()` (or texture/sampler destroy). +- `Drawable.destroy()` → decref owned `Resource`s. +- `Scene.remove(id)` → decref Drawables in removed subtree (caller policy whether decref ⇒ destroy). +- Encoder holds weak references to `GPUBindGroup`s in cache; on `Resource.destroy()`, sweep cache entries whose version vector references the destroyed resource. +- Budget surfacing: `BufferManager.preflight()` used by `resource()` (Phase 4) and `drawable()` (Phase 5). +- `Scene.on('structure-changed', ...)` is the integration point for trimming bind-group cache after subtree removals. + +### Phase 9 — Cleanup, tests, examples +*Final.* +- Delete `graphs/nodes.ts`, old `pipelines/binding-graphs.ts`, old `pipelines/traversal.ts`. Grep-confirm no remaining references to `mergeGraphs` / `mergeCommandAndBindGroupGraphs` / `BindingGraphPipelineNode`. +- New tests: + - `pipelines/binding-graph.test.ts` — DAG validation; resolves identical `{group,binding}` for a shared Slot across two Pipelines; webgpu-utils cross-check rejects mismatched shader/graph. + - `data/resource.test.ts` — `BufferResource` round-trips a struct via `makeStructuredView`; refcount/share/destroy lifecycle. **Tier-1 type-safety test**: `tsc --noEmit` compile-pass case (`camera.set({ view, proj })`) + `expect-error` case (`camera.set({ typo: x })`) using `tsd`-style assertions or `@ts-expect-error` markers in a dedicated `*.types.test.ts`. + - `memory/batch-pool.test.ts` — `BufferHandle` carries `{ gpu, offset, size }`; `BatchPoolBufferManager` uses `offset: 0`; mock `SlabBufferManager` allocates non-zero offsets and `BufferResource.commit` writes to the correct slice (verified by inspecting `writeBuffer` arguments on a recording device). + - `scene/scene.test.ts` — mutations dirty exactly the affected ancestors; `markSubtreeDirty(root)` invalidates everything. + - `encoder/encoder.test.ts` (recording mock device) — WebGPU ordering respected; no redundant `setPipeline` between consecutive same-pipeline draws; `setBindGroup(N, X)` not re-emitted when X is already bound for slot N under a compatible layout; dirtying one Drawable re-encodes only its subtree; **`setVertexBuffer`/`setIndexBuffer`/bind-group entries always include `offset` and `size` from the BufferHandle**. + - `memory` — `acquireForSlot` rejects mismatched usage; `preflight` returns false at budget exhaustion. +- New examples in `site/src/examples/`: + - Q2 scenario: one Pipeline + two Drawables sharing geometry with different bind groups. + - Worker scenario: scene constructed on a worker, encoder running there too, presenting via OffscreenCanvas. + +## Relevant files +- `packages/core/src/rendering/webgpu/resources/resource.ts` — Phase 1 rename. +- `packages/core/src/rendering/webgpu/resources/bind.ts`, `bound.ts` — Phase 1 cascade. +- `packages/core/src/rendering/webgpu/pipelines/binding-graphs.ts` — **deleted in Phase 9**; replaced in Phase 2. +- `packages/core/src/rendering/webgpu/pipelines/traversal.ts` — **deleted in Phase 9**; replaced by new `traverse.ts` in Phase 2. +- `packages/core/src/rendering/webgpu/pipelines/bind-group-cache.ts` — Phase 7 extends key with `Resource.version`. +- `packages/core/src/rendering/webgpu/pipelines/draw-context.ts` — refactor/retire; `DrawableLeases` collector folds into Phase 8 lifecycle. +- `packages/core/src/rendering/webgpu/graphs/nodes.ts` — **deleted in Phase 9**; replaced by Phase 6 + Phase 7. +- `packages/core/src/rendering/webgpu/memory/types.ts` — Phase 4 additions: `BufferHandle { gpu, offset, size, … }`, `acquireForSlot`, `preflight`. +- `packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.ts` — implement new methods; emit handles with `offset: 0`. +- `packages/core/src/rendering/webgpu/plan-2026-06-01.md` — source design for Phase 7 encoder; supersede or update after Phase 7 lands. +- `packages/core/src/rendering/webgpu/index.ts` — Phase 1 introduces public-API barrel. +- `packages/core/package.json` — Phase 1 adds `webgpu-utils` dependency. +- New paths: `webgpu/data/resource.ts` (Phase 4), `webgpu/drawable.ts` (Phase 5), `webgpu/scene/{types,scene}.ts` (Phase 6), `webgpu/encoder/{state,bind-group-builder,encoder}.ts` (Phase 7), `webgpu/pipelines/{binding-graph,traverse,build}.ts` (Phases 2–3). + +## Verification +1. `binding-graph.test.ts` — shared Slot resolves to identical `{group, binding}` across two Pipelines; shader/graph mismatch fails construction with a descriptive error sourced from `makeShaderDataDefinitions`. +2. `scene.test.ts` — `dirty` after a mutation contains exactly the affected ancestors; `markSubtreeDirty(root)` invalidates everything; `'structure-changed'` fires once per mutation. +3. `encoder.test.ts` (recording mock device) — (a) WebGPU ordering respected, (b) no redundant `setPipeline` between same-pipeline consecutive draws, (c) `setBindGroup` elided when slot already holds X under compatible layout, (d) one dirty Drawable re-encodes only its subtree, (e) state-node subtree restores the prior viewport/scissor/stencilRef/blendConstant on exit when they differ, (f) `BindingOverrideNode` subtree restores prior bind-group bindings on exit, (g) `BufferHandle.offset` is threaded into all `setVertexBuffer` / `setIndexBuffer` / bind-group calls. +4. `data/resource.test.ts` — `BufferResource` round-trips a struct via `makeStructuredView`; refcount semantics (share → destroy → destroy is a noop until refcount hits 0, then releases the `BufferHandle`); Tier-1 typed `set()`/`setField()` compile-pass + `@ts-expect-error` cases. +5. `memory/batch-pool.test.ts` — verifies that with a mock `SlabBufferManager` returning `{ gpu, offset: 1024, size: 256 }`, the `BufferResource.commit` writes at byte 1024 (not 0) and the encoder's bind-group entry uses `{ buffer: gpu, offset: 1024, size: 256 }`. +6. `BufferManager` — `acquireForSlot` rejects mismatched usage; `preflight` returns false when bucket budget is exhausted. +7. Integration example in `site/src/examples/` renders the Q2 scenario correctly in browser. +8. Worker example in `site/src/examples/` renders correctly with all construction + mutation on a worker, encoder on the worker, presenting via OffscreenCanvas. +9. Grep confirms `mergeGraphs`/`mergeCommandAndBindGroupGraphs`/`BindingGraphPipelineNode` removed; `graph(` API entry-point removed in favor of `scene(`. + +## Scope boundaries +- **In**: render pipelines, persistent `Scene` (render-graph), Encoder, Drawables, `BufferResource` via `BufferManager`-owned buffer allocation with slab-ready `BufferHandle`, Tier-1 typed `resource.set`, `webgpu-utils` integration, Worker-compatible API shape (verified by example). +- **Deferred**: compute pipelines / ComputeScene (design Phase 6 base types so they fit later), texture/sampler pooling (treat as caller-owned for now; `TextureResource` is a thin wrapper), multi-render-pass scheduling within one scene (one scene = one render pass for v1), integration with legacy WebGL `RenderServer` / `beginLongRunningFrame`, **Tier-2 type-safety** (drawable bindings checked against pipeline slot map at compile time), **Tier-3 type-safety** (shader declarations → pipeline slot inference), **SlabBufferManager implementation** (mock used in tests; full impl is a follow-up since the architecture supports it without API change). +- **Removed**: `graphs/nodes.ts` (`mergeGraphs`, `mergeCommandAndBindGroupGraphs`), old chain-style `BindingGraph`. + +## Open considerations +None outstanding — all prior open items locked. Refinement after this point is execution detail (test names, file paths, error message wording) and can happen during implementation. diff --git a/packages/core/src/rendering/webgpu/resources/bind.test.ts b/packages/core/src/rendering/webgpu/resources/bind.test.ts index e70f7a4f..a82fd8cb 100644 --- a/packages/core/src/rendering/webgpu/resources/bind.test.ts +++ b/packages/core/src/rendering/webgpu/resources/bind.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; import { constant, shader } from '../shaders'; import { type BindingMap, bindShader } from './bind'; -import { uniformResource } from './resource'; +import { uniformSlot } from './resource'; describe('bindShader', () => { - it('replaces Resource declarations with BoundResource wrappers', () => { - const u = uniformResource('u', 'U'); + it('replaces ResourceSlot declarations with BoundSlot wrappers', () => { + const u = uniformSlot('u', 'U'); const s = shader([u]); const bindings: BindingMap = new Map([[u, { group: 0, binding: 0 }]]); const bound = bindShader(s, bindings); @@ -15,7 +15,7 @@ describe('bindShader', () => { it('passes through non-Resource declarations by reference', () => { const c = constant('pi', 3.14); - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const s = shader([c, u]); const bindings: BindingMap = new Map([[u, { group: 0, binding: 0 }]]); const bound = bindShader(s, bindings); @@ -24,14 +24,14 @@ describe('bindShader', () => { }); it('throws listing every unbound resource name', () => { - const a = uniformResource('alpha', 'A'); - const b = uniformResource('beta', 'B'); + const a = uniformSlot('alpha', 'A'); + const b = uniformSlot('beta', 'B'); const s = shader([a, b]); expect(() => bindShader(s, new Map())).toThrow(/alpha.*beta|beta.*alpha/); }); it('returns a new shader (does not mutate input)', () => { - const u = uniformResource('u', 'U'); + const u = uniformSlot('u', 'U'); const s = shader([u]); const bindings: BindingMap = new Map([[u, { group: 0, binding: 0 }]]); const bound = bindShader(s, bindings); diff --git a/packages/core/src/rendering/webgpu/resources/bind.ts b/packages/core/src/rendering/webgpu/resources/bind.ts index 6cabc42f..f9f0f2b8 100644 --- a/packages/core/src/rendering/webgpu/resources/bind.ts +++ b/packages/core/src/rendering/webgpu/resources/bind.ts @@ -1,33 +1,33 @@ /** - * `bindShader` substitutes every `Resource` in a `WgslShader`'s declarations array with a - * `BoundResource` (using `{group, binding}` entries supplied by the caller, typically produced - * by a binding-graph traversal). Non-Resource declarations pass through unchanged. + * `bindShader` substitutes every `ResourceSlot` in a `WgslShader`'s declarations array with a + * `BoundSlot` (using `{group, binding}` entries supplied by the caller, typically produced by a + * binding-graph traversal). Non-slot declarations pass through unchanged. * * After `bindShader`, the returned shader is fully renderable via `asSource()`. */ import type { DeclarationGenerator, WgslShader } from '../shaders'; import { bind } from './bound'; -import { isResource, type Resource } from './resource'; +import { isResourceSlot, type ResourceSlot } from './resource'; /** - * Maps each `Resource` referenced by a shader to its assigned `{group, binding}`. The keys are - * the original `Resource` object references — identity-based lookup, no name matching. + * Maps each `ResourceSlot` referenced by a shader to its assigned `{group, binding}`. The keys + * are the original `ResourceSlot` object references — identity-based lookup, no name matching. */ -export type BindingMap = ReadonlyMap; +export type BindingMap = ReadonlyMap; /** - * Returns a new `WgslShader` whose `Resource` declarations have been replaced with `BoundResource` + * Returns a new `WgslShader` whose `ResourceSlot` declarations have been replaced with `BoundSlot` * wrappers carrying the assigned `{group, binding}`. Other declarations are passed through by * reference. * - * Throws if any `Resource` in the shader has no entry in `bindings`. The error lists every - * unbound resource name so the caller can diagnose all gaps in a single pass. + * Throws if any `ResourceSlot` in the shader has no entry in `bindings`. The error lists every + * unbound slot name so the caller can diagnose all gaps in a single pass. */ export function bindShader(shader: WgslShader, bindings: BindingMap): WgslShader { const missing: string[] = []; const declarations: DeclarationGenerator[] = shader.declarations.map((d) => { - if (!isResource(d)) return d; + if (!isResourceSlot(d)) return d; const entry = bindings.get(d); if (!entry) { missing.push(d.name); @@ -36,7 +36,10 @@ export function bindShader(shader: WgslShader, bindings: BindingMap): WgslShader return bind(d, entry.group, entry.binding); }); if (missing.length > 0) { - throw new Error(`bindShader: no binding provided for resources: ${missing.join(', ')}`); + throw new Error(`bindShader: no binding provided for slots: ${missing.join(', ')}`); } - return { declarations }; + // Preserve the original shader's identity: bound and unbound forms reflect the same + // logical shader, so downstream caches keyed on `id` (pipeline cache, reflection cache) + // continue to hit. Only the slot declarations have been rewritten. + return { id: shader.id, declarations }; } diff --git a/packages/core/src/rendering/webgpu/resources/bound.test.ts b/packages/core/src/rendering/webgpu/resources/bound.test.ts index dbcc26e5..04fcd318 100644 --- a/packages/core/src/rendering/webgpu/resources/bound.test.ts +++ b/packages/core/src/rendering/webgpu/resources/bound.test.ts @@ -3,57 +3,57 @@ import { ShaderStageFlag } from '../native-types'; import { sampler, storage, texture, uniform } from '../shaders'; import { bind, toBindGroupLayoutEntry } from './bound'; import { - externalTextureResource, - samplerResource, - storageResource, - storageTextureResource, - textureResource, - uniformResource, + externalTextureSlot, + samplerSlot, + storageSlot, + storageTextureSlot, + textureSlot, + uniformSlot, } from './resource'; describe('bind', () => { it('returns a frozen object', () => { - const r = uniformResource('u', 'U'); + const r = uniformSlot('u', 'U'); const b = bind(r, 0, 1); expect(Object.isFrozen(b)).toBe(true); }); it('does not mutate the source Resource', () => { - const r = uniformResource('u', 'U'); + const r = uniformSlot('u', 'U'); bind(r, 0, 1); expect('group' in r).toBe(false); expect('binding' in r).toBe(false); }); it('attaches group and binding to the wrapper', () => { - const r = uniformResource('u', 'U'); + const r = uniformSlot('u', 'U'); const b = bind(r, 2, 5); expect(b.group).toBe(2); expect(b.binding).toBe(5); }); it('uniform: __gen() byte-matches the equivalent raw $s.uniform output', () => { - const r = uniformResource('unis', 'Uniforms'); + const r = uniformSlot('unis', 'Uniforms'); expect(bind(r, 0, 0).__gen()).toBe(uniform('unis', 'Uniforms', 0, 0).__gen()); }); it('storage: __gen() byte-matches the equivalent raw $s.storage output (with accessMode)', () => { - const r = storageResource('buf', 'BufType', { accessMode: 'read_write' }); + const r = storageSlot('buf', 'BufType', { accessMode: 'read_write' }); expect(bind(r, 1, 2).__gen()).toBe(storage('buf', 'BufType', 1, 2, 'read_write').__gen()); }); it('storage: __gen() byte-matches when accessMode is omitted', () => { - const r = storageResource('buf', 'BufType'); + const r = storageSlot('buf', 'BufType'); expect(bind(r, 0, 0).__gen()).toBe(storage('buf', 'BufType', 0, 0).__gen()); }); it('texture: __gen() byte-matches the equivalent raw $s.texture output', () => { - const r = textureResource('colorMap', 'texture_2d'); + const r = textureSlot('colorMap', 'texture_2d'); expect(bind(r, 0, 1).__gen()).toBe(texture('colorMap', 'texture_2d', 0, 1).__gen()); }); it('storageTexture: __gen() emits a `var` declaration with the supplied type', () => { - const r = storageTextureResource('img', 'texture_storage_2d', 'rgba8unorm'); + const r = storageTextureSlot('img', 'texture_storage_2d', 'rgba8unorm'); // delegates to $s.texture under the hood — the type identifier carries format/access. expect(bind(r, 0, 3).__gen()).toBe( texture('img', 'texture_storage_2d', 0, 3).__gen() @@ -61,12 +61,12 @@ describe('bind', () => { }); it('sampler: __gen() byte-matches the equivalent raw $s.sampler output', () => { - const r = samplerResource('samp', 'sampler'); + const r = samplerSlot('samp', 'sampler'); expect(bind(r, 0, 2).__gen()).toBe(sampler('samp', 'sampler', 0, 2).__gen()); }); it('externalTexture: __gen() emits a texture_external var declaration', () => { - const r = externalTextureResource('vid'); + const r = externalTextureSlot('vid'); expect(bind(r, 0, 0).__gen()).toBe(texture('vid', 'texture_external', 0, 0).__gen()); }); }); @@ -75,7 +75,7 @@ describe('toBindGroupLayoutEntry', () => { const VERTEX = ShaderStageFlag.VERTEX; it('uniform → buffer.type=uniform', () => { - const r = uniformResource('u', 'U', { hasDynamicOffset: true, minBindingSize: 64 }); + const r = uniformSlot('u', 'U', { hasDynamicOffset: true, minBindingSize: 64 }); const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); expect(entry).toEqual({ binding: 0, @@ -85,19 +85,19 @@ describe('toBindGroupLayoutEntry', () => { }); it('storage with accessMode=read → buffer.type=read-only-storage', () => { - const r = storageResource('b', 'B', { accessMode: 'read' }); + const r = storageSlot('b', 'B', { accessMode: 'read' }); const entry = toBindGroupLayoutEntry(bind(r, 0, 1), VERTEX); expect(entry.buffer?.type).toBe('read-only-storage'); }); it('storage with accessMode=read_write → buffer.type=storage', () => { - const r = storageResource('b', 'B', { accessMode: 'read_write' }); + const r = storageSlot('b', 'B', { accessMode: 'read_write' }); const entry = toBindGroupLayoutEntry(bind(r, 0, 1), VERTEX); expect(entry.buffer?.type).toBe('storage'); }); it('texture → texture entry with sampleType + viewDimension + multisampled', () => { - const r = textureResource('t', 'texture_2d', { + const r = textureSlot('t', 'texture_2d', { sampleType: 'float', viewDimension: '2d', multisampled: false, @@ -107,7 +107,7 @@ describe('toBindGroupLayoutEntry', () => { }); it('storageTexture → storageTexture entry with format + access + viewDimension', () => { - const r = storageTextureResource('img', 'texture_storage_2d', 'rgba8unorm', { + const r = storageTextureSlot('img', 'texture_storage_2d', 'rgba8unorm', { access: 'write-only', viewDimension: '2d', }); @@ -116,13 +116,13 @@ describe('toBindGroupLayoutEntry', () => { }); it('sampler → sampler entry with bindingType', () => { - const r = samplerResource('s', 'sampler', { bindingType: 'filtering' }); + const r = samplerSlot('s', 'sampler', { bindingType: 'filtering' }); const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); expect(entry.sampler).toEqual({ type: 'filtering' }); }); it('externalTexture → externalTexture entry (empty object)', () => { - const r = externalTextureResource('ext'); + const r = externalTextureSlot('ext'); const entry = toBindGroupLayoutEntry(bind(r, 0, 0), VERTEX); expect(entry.externalTexture).toEqual({}); }); diff --git a/packages/core/src/rendering/webgpu/resources/bound.ts b/packages/core/src/rendering/webgpu/resources/bound.ts index e12bb435..3e3db376 100644 --- a/packages/core/src/rendering/webgpu/resources/bound.ts +++ b/packages/core/src/rendering/webgpu/resources/bound.ts @@ -1,25 +1,25 @@ /** - * Defines `BoundResource` — a `Resource` that has been assigned a `{group, binding}` pair via + * Defines `BoundSlot` — a `ResourceSlot` that has been assigned a `{group, binding}` pair via * a binding-graph traversal. The wrapper is frozen (immutable) and carries a working `__gen()` * that produces the WGSL declaration by delegating to the existing `$s.uniform / $s.texture / * $s.sampler / $s.storage` constructors in `shaders/declarations.ts`. * - * Producing a `BoundResource` does NOT mutate the original `Resource` — the source descriptor + * Producing a `BoundSlot` does NOT mutate the original `ResourceSlot` — the source descriptor * stays metadata-only and can be reused across multiple binding layouts. */ import type { BindGroupLayoutEntry, ShaderStageFlags } from '../native-types'; import { sampler as samplerDecl, storage as storageDecl, texture as textureDecl, uniform as uniformDecl } from '../shaders'; -import type { Resource } from './resource'; +import type { ResourceSlot } from './resource'; /** - * A `BoundResource` is a `Resource` extended with `{group, binding}` and a working `__gen()`. + * A `BoundSlot` is a `ResourceSlot` extended with `{group, binding}` and a working `__gen()`. * It is the value the shader-source generator actually emits WGSL for. * - * The generic parameter narrows to the underlying `Resource` variant so consumers can pattern- - * match on `kind` and access kind-specific metadata. + * The generic parameter narrows to the underlying `ResourceSlot` variant so consumers can + * pattern-match on `kind` and access kind-specific metadata. */ -export type BoundResource = Readonly< +export type BoundSlot = Readonly< R & { readonly group: number; readonly binding: number; @@ -28,25 +28,25 @@ export type BoundResource = Readonly< >; /** - * Wraps a `Resource` with a `{group, binding}` and a working `__gen()`. The returned object is - * frozen. The original `Resource` is not mutated. + * Wraps a `ResourceSlot` with a `{group, binding}` and a working `__gen()`. The returned object + * is frozen. The original `ResourceSlot` is not mutated. */ -export function bind(resource: R, group: number, binding: number): BoundResource { - const gen = makeGenFor(resource, group, binding); +export function bind(slot: R, group: number, binding: number): BoundSlot { + const gen = makeGenFor(slot, group, binding); const bound = { - ...resource, + ...slot, group, binding, __gen: gen, }; - return Object.freeze(bound) as BoundResource; + return Object.freeze(bound) as BoundSlot; } /** - * Builds the `__gen` thunk for a bound resource by delegating to the existing declaration + * Builds the `__gen` thunk for a bound slot by delegating to the existing declaration * constructors. This keeps WGSL formatting in a single place (`shaders/declarations.ts`). */ -function makeGenFor(r: Resource, group: number, binding: number): () => string { +function makeGenFor(r: ResourceSlot, group: number, binding: number): () => string { switch (r.kind) { case 'uniform': return uniformDecl(r.name, r.type, group, binding, r.attributes).__gen; @@ -73,11 +73,11 @@ function makeGenFor(r: Resource, group: number, binding: number): () => string { } /** - * Derives a `GPUBindGroupLayoutEntry` for a bound resource. The `visibility` argument lets the + * Derives a `GPUBindGroupLayoutEntry` for a bound slot. The `visibility` argument lets the * caller (typically the binding-graph traversal) supply the union of stages from every pipeline - * that references this resource; pass `bound.visibility` directly when no traversal is involved. + * that references this slot; pass `bound.visibility` directly when no traversal is involved. */ -export function toBindGroupLayoutEntry(bound: BoundResource, visibility: ShaderStageFlags): BindGroupLayoutEntry { +export function toBindGroupLayoutEntry(bound: BoundSlot, visibility: ShaderStageFlags): BindGroupLayoutEntry { const base = { binding: bound.binding, visibility }; switch (bound.kind) { case 'uniform': diff --git a/packages/core/src/rendering/webgpu/resources/index.ts b/packages/core/src/rendering/webgpu/resources/index.ts index 40d25b17..4750f14e 100644 --- a/packages/core/src/rendering/webgpu/resources/index.ts +++ b/packages/core/src/rendering/webgpu/resources/index.ts @@ -1,31 +1,31 @@ export type { BindingMap } from './bind'; export { bindShader } from './bind'; -export type { BoundResource } from './bound'; +export type { BoundSlot } from './bound'; export { bind, toBindGroupLayoutEntry } from './bound'; export type { - ExternalTextureResource, - ExternalTextureResourceOptions, - Resource, - ResourceKind, - SamplerResource, - SamplerResourceOptions, - StorageResource, - StorageResourceOptions, - StorageTextureResource, - StorageTextureResourceOptions, - TextureResource, - TextureResourceOptions, - UniformResource, - UniformResourceOptions, + ExternalTextureSlot, + ExternalTextureSlotOptions, + ResourceSlot, + ResourceSlotKind, + SamplerSlot, + SamplerSlotOptions, + StorageSlot, + StorageSlotOptions, + StorageTextureSlot, + StorageTextureSlotOptions, + TextureSlot, + TextureSlotOptions, + UniformSlot, + UniformSlotOptions, } from './resource'; export { - externalTextureResource, - isResource, - RESOURCE_BRAND, - samplerResource, - storageResource, - storageTextureResource, - textureResource, - uniformResource, + externalTextureSlot, + isResourceSlot, + RESOURCE_SLOT_BRAND, + samplerSlot, + storageSlot, + storageTextureSlot, + textureSlot, + uniformSlot, } from './resource'; diff --git a/packages/core/src/rendering/webgpu/resources/resource.test.ts b/packages/core/src/rendering/webgpu/resources/resource.test.ts index 67c78e3d..14e08c1e 100644 --- a/packages/core/src/rendering/webgpu/resources/resource.test.ts +++ b/packages/core/src/rendering/webgpu/resources/resource.test.ts @@ -1,52 +1,52 @@ import { describe, expect, it } from 'vitest'; import { - externalTextureResource, - isResource, - RESOURCE_BRAND, - samplerResource, - storageResource, - storageTextureResource, - textureResource, - uniformResource, + externalTextureSlot, + isResourceSlot, + RESOURCE_SLOT_BRAND, + samplerSlot, + storageSlot, + storageTextureSlot, + textureSlot, + uniformSlot, } from './resource'; describe('isResource', () => { it('returns true for objects created by Resource constructors', () => { - expect(isResource(uniformResource('u', 'U'))).toBe(true); - expect(isResource(storageResource('s', 'S'))).toBe(true); - expect(isResource(textureResource('t', 'texture_2d'))).toBe(true); - expect(isResource(storageTextureResource('st', 'texture_storage_2d', 'rgba8unorm'))).toBe( + expect(isResourceSlot(uniformSlot('u', 'U'))).toBe(true); + expect(isResourceSlot(storageSlot('s', 'S'))).toBe(true); + expect(isResourceSlot(textureSlot('t', 'texture_2d'))).toBe(true); + expect(isResourceSlot(storageTextureSlot('st', 'texture_storage_2d', 'rgba8unorm'))).toBe( true ); - expect(isResource(samplerResource('samp', 'sampler'))).toBe(true); - expect(isResource(externalTextureResource('ext'))).toBe(true); + expect(isResourceSlot(samplerSlot('samp', 'sampler'))).toBe(true); + expect(isResourceSlot(externalTextureSlot('ext'))).toBe(true); }); it('returns false for plain objects, primitives, and null', () => { - expect(isResource(null)).toBe(false); - expect(isResource(undefined)).toBe(false); - expect(isResource(42)).toBe(false); - expect(isResource('uniform')).toBe(false); - expect(isResource({})).toBe(false); - expect(isResource({ __brand: 'not-it' })).toBe(false); - expect(isResource({ name: 'u', kind: 'uniform' })).toBe(false); + expect(isResourceSlot(null)).toBe(false); + expect(isResourceSlot(undefined)).toBe(false); + expect(isResourceSlot(42)).toBe(false); + expect(isResourceSlot('uniform')).toBe(false); + expect(isResourceSlot({})).toBe(false); + expect(isResourceSlot({ __brand: 'not-it' })).toBe(false); + expect(isResourceSlot({ name: 'u', kind: 'uniform' })).toBe(false); }); }); describe('Resource.__gen (unbound)', () => { it('throws a useful error mentioning the resource name', () => { - const u = uniformResource('myUniform', 'MyType'); + const u = uniformSlot('myUniform', 'MyType'); expect(() => u.__gen()).toThrow(/myUniform/); expect(() => u.__gen()).toThrow(/bound/); }); it.each([ - ['uniform', () => uniformResource('u', 'U')], - ['storage', () => storageResource('s', 'S')], - ['texture', () => textureResource('t', 'texture_2d')], - ['storageTexture', () => storageTextureResource('st', 'texture_storage_2d', 'rgba8unorm')], - ['sampler', () => samplerResource('samp', 'sampler')], - ['externalTexture', () => externalTextureResource('ext')], + ['uniform', () => uniformSlot('u', 'U')], + ['storage', () => storageSlot('s', 'S')], + ['texture', () => textureSlot('t', 'texture_2d')], + ['storageTexture', () => storageTextureSlot('st', 'texture_storage_2d', 'rgba8unorm')], + ['sampler', () => samplerSlot('samp', 'sampler')], + ['externalTexture', () => externalTextureSlot('ext')], ] as const)('throws for unbound %s resource', (_, build) => { expect(() => build().__gen()).toThrow(); }); @@ -54,11 +54,11 @@ describe('Resource.__gen (unbound)', () => { describe('Resource constructors', () => { it('attach the brand symbol', () => { - expect(uniformResource('u', 'U').__brand).toBe(RESOURCE_BRAND); + expect(uniformSlot('u', 'U').__brand).toBe(RESOURCE_SLOT_BRAND); }); it('preserve provided fields', () => { - const r = uniformResource('u', 'U', { hasDynamicOffset: true, minBindingSize: 64, visibility: 0x3 }); + const r = uniformSlot('u', 'U', { hasDynamicOffset: true, minBindingSize: 64, visibility: 0x3 }); expect(r.name).toBe('u'); expect(r.type).toBe('U'); expect(r.hasDynamicOffset).toBe(true); @@ -67,13 +67,13 @@ describe('Resource constructors', () => { expect(r.kind).toBe('uniform'); }); - it('storageResource carries accessMode', () => { - const r = storageResource('buf', 'BufType', { accessMode: 'read_write' }); + it('storageSlot carries accessMode', () => { + const r = storageSlot('buf', 'BufType', { accessMode: 'read_write' }); expect(r.accessMode).toBe('read_write'); }); - it('storageTextureResource carries format', () => { - const r = storageTextureResource('st', 'texture_storage_2d', 'rgba8unorm'); + it('storageTextureSlot carries format', () => { + const r = storageTextureSlot('st', 'texture_storage_2d', 'rgba8unorm'); expect(r.format).toBe('rgba8unorm'); }); }); diff --git a/packages/core/src/rendering/webgpu/resources/resource.ts b/packages/core/src/rendering/webgpu/resources/resource.ts index 22b7be83..3387fd24 100644 --- a/packages/core/src/rendering/webgpu/resources/resource.ts +++ b/packages/core/src/rendering/webgpu/resources/resource.ts @@ -1,14 +1,18 @@ /** - * Defines the `Resource` type — a metadata-only descriptor of a shader binding (uniform buffer, - * texture, sampler, storage buffer, storage texture, external texture) that carries everything - * needed to: + * Defines the `ResourceSlot` type — a metadata-only descriptor of a shader binding (uniform + * buffer, texture, sampler, storage buffer, storage texture, external texture) that carries + * everything needed to: * 1. Generate the corresponding WGSL declaration once a `{group, binding}` has been assigned. - * 2. Construct a `GPUBindGroupLayoutEntry` for the resource. + * 2. Construct a `GPUBindGroupLayoutEntry` for the slot. * - * `Resource` implements the `DeclarationGenerator` interface from `shaders/`, so it can be dropped - * directly into a `WgslShader`'s `declarations` array. Its `__gen()` throws until the resource has - * been "bound" (see `bound.ts` and `bind.ts`). The shaders module never imports from this module — - * the dependency is strictly one-way (`resources/` → `shaders/`). + * `ResourceSlot` implements the `DeclarationGenerator` interface from `shaders/`, so it can be + * dropped directly into a `WgslShader`'s `declarations` array. Its `__gen()` throws until the + * slot has been "bound" (see `bound.ts` and `bind.ts`). The shaders module never imports from + * this module — the dependency is strictly one-way (`resources/` → `shaders/`). + * + * `ResourceSlot` is a *descriptor* (it tells the system what the binding looks like). The + * data-bearing object that actually carries a `GPUBuffer`/`GPUTexture` for a slot is named + * `Resource` and lives in `webgpu/data/resource.ts` (Phase 4). */ import type { @@ -27,10 +31,10 @@ import type { WgslTextureDataType, } from '../shaders'; -/** Brand symbol used by `isResource` to discriminate `Resource` objects at runtime. */ -export const RESOURCE_BRAND = Symbol.for('vis-core.webgpu.Resource'); +/** Brand symbol used by `isResourceSlot` to discriminate `ResourceSlot` objects at runtime. */ +export const RESOURCE_SLOT_BRAND = Symbol.for('vis-core.webgpu.ResourceSlot'); -export type ResourceKind = +export type ResourceSlotKind = | 'uniform' | 'storage' | 'texture' @@ -38,26 +42,26 @@ export type ResourceKind = | 'sampler' | 'externalTexture'; -/** Fields common to every `Resource` variant. */ -interface ResourceCommon { - readonly __brand: typeof RESOURCE_BRAND; - readonly kind: ResourceKind; +/** Fields common to every `ResourceSlot` variant. */ +interface ResourceSlotCommon { + readonly __brand: typeof RESOURCE_SLOT_BRAND; + readonly kind: ResourceSlotKind; readonly name: string; /** * Optional explicit visibility. Binding-graph traversal may union additional stages from any - * pipelines that reference this resource; when both are present, the union is used. + * pipelines that reference this slot; when both are present, the union is used. */ readonly visibility?: ShaderStageFlags; /** Optional attributes applied to the variable declaration (e.g., `@align(N)`, `@size(N)`). */ readonly attributes?: VariableOrValueAttribute[]; /** - * Throws unless this resource has been bound to a `{group, binding}` (see `bind()` / + * Throws unless this slot has been bound to a `{group, binding}` (see `bind()` / * `bindShader()`). After binding, the bound wrapper's `__gen()` returns the WGSL declaration. */ __gen(): string; } -export interface UniformResource extends ResourceCommon { +export interface UniformSlot extends ResourceSlotCommon { readonly kind: 'uniform'; readonly type: TypeIdentifier; /** GPUBindGroupLayoutEntry.buffer.hasDynamicOffset */ @@ -66,7 +70,7 @@ export interface UniformResource extends ResourceCommon { readonly minBindingSize?: number; } -export interface StorageResource extends ResourceCommon { +export interface StorageSlot extends ResourceSlotCommon { readonly kind: 'storage'; readonly type: TypeIdentifier; /** WGSL storage access mode. Mirrors the optional `accessMode` of `$s.storage(...)`. */ @@ -77,7 +81,7 @@ export interface StorageResource extends ResourceCommon { readonly minBindingSize?: number; } -export interface TextureResource extends ResourceCommon { +export interface TextureSlot extends ResourceSlotCommon { readonly kind: 'texture'; readonly type: WgslTextureDataType | `texture_${string}`; /** GPUBindGroupLayoutEntry.texture.sampleType (default 'float') */ @@ -88,7 +92,7 @@ export interface TextureResource extends ResourceCommon { readonly multisampled?: boolean; } -export interface StorageTextureResource extends ResourceCommon { +export interface StorageTextureSlot extends ResourceSlotCommon { readonly kind: 'storageTexture'; readonly type: WgslTextureDataType | `texture_${string}`; /** Required: storage textures must specify a texel format. */ @@ -99,39 +103,39 @@ export interface StorageTextureResource extends ResourceCommon { readonly viewDimension?: TextureViewDimension; } -export interface SamplerResource extends ResourceCommon { +export interface SamplerSlot extends ResourceSlotCommon { readonly kind: 'sampler'; readonly type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison'; /** GPUBindGroupLayoutEntry.sampler.type (default 'filtering') */ readonly bindingType?: SamplerBindingType; } -export interface ExternalTextureResource extends ResourceCommon { +export interface ExternalTextureSlot extends ResourceSlotCommon { readonly kind: 'externalTexture'; } -export type Resource = - | UniformResource - | StorageResource - | TextureResource - | StorageTextureResource - | SamplerResource - | ExternalTextureResource; +export type ResourceSlot = + | UniformSlot + | StorageSlot + | TextureSlot + | StorageTextureSlot + | SamplerSlot + | ExternalTextureSlot; -/** Runtime discriminator for `Resource` (used by `bindShader` and binding-graph traversal). */ -export function isResource(value: unknown): value is Resource { +/** Runtime discriminator for `ResourceSlot` (used by `bindShader` and binding-graph traversal). */ +export function isResourceSlot(value: unknown): value is ResourceSlot { return ( typeof value === 'object' && value !== null && '__brand' in value && - (value as { __brand: unknown }).__brand === RESOURCE_BRAND + (value as { __brand: unknown }).__brand === RESOURCE_SLOT_BRAND ); } function unboundGen(name: string): () => string { return () => { throw new Error( - `Resource '${name}' must be bound to a {group, binding} before source generation; ` + + `ResourceSlot '${name}' must be bound to a {group, binding} before source generation; ` + 'see bindShader() in @alleninstitute/vis-core/rendering/webgpu/resources' ); }; @@ -139,20 +143,20 @@ function unboundGen(name: string): () => string { // ---- Constructors ----------------------------------------------------------------------------- -export type UniformResourceOptions = { +export type UniformSlotOptions = { visibility?: ShaderStageFlags; attributes?: VariableOrValueAttribute[]; hasDynamicOffset?: boolean; minBindingSize?: number; }; -export function uniformResource( +export function uniformSlot( name: string, type: TypeIdentifier, - options: UniformResourceOptions = {} -): UniformResource { + options: UniformSlotOptions = {} +): UniformSlot { return { - __brand: RESOURCE_BRAND, + __brand: RESOURCE_SLOT_BRAND, kind: 'uniform', name, type, @@ -161,7 +165,7 @@ export function uniformResource( }; } -export type StorageResourceOptions = { +export type StorageSlotOptions = { visibility?: ShaderStageFlags; attributes?: VariableOrValueAttribute[]; accessMode?: 'read' | 'write' | 'read_write'; @@ -169,13 +173,13 @@ export type StorageResourceOptions = { minBindingSize?: number; }; -export function storageResource( +export function storageSlot( name: string, type: TypeIdentifier, - options: StorageResourceOptions = {} -): StorageResource { + options: StorageSlotOptions = {} +): StorageSlot { return { - __brand: RESOURCE_BRAND, + __brand: RESOURCE_SLOT_BRAND, kind: 'storage', name, type, @@ -184,7 +188,7 @@ export function storageResource( }; } -export type TextureResourceOptions = { +export type TextureSlotOptions = { visibility?: ShaderStageFlags; attributes?: VariableOrValueAttribute[]; sampleType?: TextureSampleType; @@ -192,13 +196,13 @@ export type TextureResourceOptions = { multisampled?: boolean; }; -export function textureResource( +export function textureSlot( name: string, type: WgslTextureDataType | `texture_${string}`, - options: TextureResourceOptions = {} -): TextureResource { + options: TextureSlotOptions = {} +): TextureSlot { return { - __brand: RESOURCE_BRAND, + __brand: RESOURCE_SLOT_BRAND, kind: 'texture', name, type, @@ -207,21 +211,21 @@ export function textureResource( }; } -export type StorageTextureResourceOptions = { +export type StorageTextureSlotOptions = { visibility?: ShaderStageFlags; attributes?: VariableOrValueAttribute[]; access?: StorageTextureAccess; viewDimension?: TextureViewDimension; }; -export function storageTextureResource( +export function storageTextureSlot( name: string, type: WgslTextureDataType | `texture_${string}`, format: TextureFormat, - options: StorageTextureResourceOptions = {} -): StorageTextureResource { + options: StorageTextureSlotOptions = {} +): StorageTextureSlot { return { - __brand: RESOURCE_BRAND, + __brand: RESOURCE_SLOT_BRAND, kind: 'storageTexture', name, type, @@ -231,19 +235,19 @@ export function storageTextureResource( }; } -export type SamplerResourceOptions = { +export type SamplerSlotOptions = { visibility?: ShaderStageFlags; attributes?: VariableOrValueAttribute[]; bindingType?: SamplerBindingType; }; -export function samplerResource( +export function samplerSlot( name: string, type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison', - options: SamplerResourceOptions = {} -): SamplerResource { + options: SamplerSlotOptions = {} +): SamplerSlot { return { - __brand: RESOURCE_BRAND, + __brand: RESOURCE_SLOT_BRAND, kind: 'sampler', name, type, @@ -252,17 +256,17 @@ export function samplerResource( }; } -export type ExternalTextureResourceOptions = { +export type ExternalTextureSlotOptions = { visibility?: ShaderStageFlags; attributes?: VariableOrValueAttribute[]; }; -export function externalTextureResource( +export function externalTextureSlot( name: string, - options: ExternalTextureResourceOptions = {} -): ExternalTextureResource { + options: ExternalTextureSlotOptions = {} +): ExternalTextureSlot { return { - __brand: RESOURCE_BRAND, + __brand: RESOURCE_SLOT_BRAND, kind: 'externalTexture', name, ...options, diff --git a/packages/core/src/rendering/webgpu/shaders/declarations.ts b/packages/core/src/rendering/webgpu/shaders/declarations.ts index d98a56f2..ec8ac639 100644 --- a/packages/core/src/rendering/webgpu/shaders/declarations.ts +++ b/packages/core/src/rendering/webgpu/shaders/declarations.ts @@ -53,6 +53,26 @@ export type StructDeclaration = IdentifierDeclaration & fields: StructMemberDeclaration[]; }; +/** + * `StructDecl` is a `StructDeclaration` annotated with a phantom TypeScript + * shape describing the host-side representation of the struct's contents. + * + * The phantom is consumed by typed slot factories (e.g. `slot.uniform`) so that + * downstream resources can expose strongly-typed `set(values)` APIs without runtime + * cost. When `TsShape` is `unknown` (the default), the struct behaves like an + * untyped `StructDeclaration` and slots fall back to `unknown`-keyed updates. + * + * Example: + * ```ts + * type MyUniforms = { time: number; color: readonly number[] }; + * const U = struct('U', [member('time', 'f32'), member('color', 'vec3f')]); + * const u = slot.uniform('u', U); // `u` carries `MyUniforms` through to `set()` + * ``` + */ +export type StructDecl = StructDeclaration & { + readonly __tsShape?: TsShape; +}; + export type AliasDeclaration = IdentifierDeclaration & DeclarationGenerator & { __identType: 'alias'; @@ -342,7 +362,7 @@ export function member( }; } -export function struct(name: string, fields: StructMemberDeclaration[]): StructDeclaration { +export function struct(name: string, fields: StructMemberDeclaration[]): StructDecl { return { __identType: 'struct', name, diff --git a/packages/core/src/rendering/webgpu/shaders/index.ts b/packages/core/src/rendering/webgpu/shaders/index.ts index 8b761d96..fa01cb7f 100644 --- a/packages/core/src/rendering/webgpu/shaders/index.ts +++ b/packages/core/src/rendering/webgpu/shaders/index.ts @@ -50,6 +50,7 @@ export type { ResourceIdentifierDeclaration, SamplerVariableDeclaration, StorageVariableDeclaration, + StructDecl, StructDeclaration, StructMemberDeclaration, TextureVariableDeclaration, diff --git a/packages/core/src/rendering/webgpu/shaders/shader.test.ts b/packages/core/src/rendering/webgpu/shaders/shader.test.ts index 6e696742..a6816ae8 100644 --- a/packages/core/src/rendering/webgpu/shaders/shader.test.ts +++ b/packages/core/src/rendering/webgpu/shaders/shader.test.ts @@ -4,11 +4,11 @@ import { asSource, isWgslShader, shader } from './shader'; describe('isWgslShader', () => { it('returns true for a valid shader with no declarations', () => { - expect(isWgslShader({ declarations: [] })).toBe(true); + expect(isWgslShader({ id: 'test-id', declarations: [] })).toBe(true); }); it('returns true for a shader with declarations', () => { - expect(isWgslShader({ declarations: [constant('x', 1)] })).toBe(true); + expect(isWgslShader({ id: 'test-id', declarations: [constant('x', 1)] })).toBe(true); }); it('returns false for null', () => { @@ -25,9 +25,13 @@ describe('isWgslShader', () => { expect(isWgslShader({})).toBe(false); }); + it('returns false for an object missing the id property', () => { + expect(isWgslShader({ declarations: [] })).toBe(false); + }); + it('returns false when declarations is not an array', () => { - expect(isWgslShader({ declarations: 'not an array' })).toBe(false); - expect(isWgslShader({ declarations: null })).toBe(false); + expect(isWgslShader({ id: 'x', declarations: 'not an array' })).toBe(false); + expect(isWgslShader({ id: 'x', declarations: null })).toBe(false); }); }); @@ -43,6 +47,14 @@ describe('shader', () => { expect(s.declarations).toHaveLength(2); expect(s.declarations[0]).toBe(decls[0]); }); + + it('stamps each shader with a unique non-empty id', () => { + const a = shader([]); + const b = shader([]); + expect(typeof a.id).toBe('string'); + expect(a.id.length).toBeGreaterThan(0); + expect(a.id).not.toBe(b.id); + }); }); describe('asSource', () => { @@ -69,6 +81,6 @@ describe('asSource', () => { }); it('throws for an invalid shader object', () => { - expect(() => asSource({ declarations: 'bad' as unknown as Declaration[] })).toThrow(); + expect(() => asSource({ id: 'x', declarations: 'bad' as unknown as Declaration[] })).toThrow(); }); }); diff --git a/packages/core/src/rendering/webgpu/shaders/shader.ts b/packages/core/src/rendering/webgpu/shaders/shader.ts index 08c6081a..44deee38 100644 --- a/packages/core/src/rendering/webgpu/shaders/shader.ts +++ b/packages/core/src/rendering/webgpu/shaders/shader.ts @@ -4,6 +4,12 @@ * object and its declarations. The `shader` function is a simple helper function for * creating a new `WgslShader` object from an array of declarations. * + * Each `WgslShader` carries an internally-assigned `id` (a UUID v4) used by downstream + * caches — pipeline cache, reflection cache (`webgpu-utils` ShaderDataDefinitions), + * binding-graph traversal results — as a stable identity key. The id is opaque and + * stable for the lifetime of the shader object; callers should not generate it + * themselves nor assume any structural meaning. + * * NOTE: `WgslShader.declarations` is intentionally typed as `DeclarationGenerator[]` * (the minimal `{ __gen(): string }` interface) rather than the concrete `Declaration` * union. This lets higher-level modules (e.g., `resources/`) define their own objects @@ -11,9 +17,12 @@ * `shaders/` needing to know about them — preserving a one-way dependency. */ +import { v4 as uuidv4 } from 'uuid'; import type { DeclarationGenerator } from './declarations'; export type WgslShader = { + /** Opaque stable identity used by downstream caches. Assigned by `shader()`. */ + readonly id: string; declarations: DeclarationGenerator[]; }; @@ -21,7 +30,14 @@ export type WgslShader = { // so that we can confirm the structure of the whole shader; for now, this is sufficient for // some basic type safety for shader string rendering, deserialization, etc. export function isWgslShader(value: unknown): value is WgslShader { - return typeof value === 'object' && value !== null && 'declarations' in value && Array.isArray(value.declarations); + return ( + typeof value === 'object' && + value !== null && + 'declarations' in value && + Array.isArray((value as { declarations: unknown }).declarations) && + 'id' in value && + typeof (value as { id: unknown }).id === 'string' + ); } export function asSource(shader: WgslShader): string { @@ -32,5 +48,5 @@ export function asSource(shader: WgslShader): string { } export function shader(declarations: DeclarationGenerator[]): WgslShader { - return { declarations }; + return { id: uuidv4(), declarations }; } diff --git a/packages/core/src/rendering/webgpu/slot.ts b/packages/core/src/rendering/webgpu/slot.ts new file mode 100644 index 00000000..7f978468 --- /dev/null +++ b/packages/core/src/rendering/webgpu/slot.ts @@ -0,0 +1,132 @@ +/** + * `slot` — public-API factory namespace for declaring resource bindings on a shader. + * + * Wraps the lower-level `*Slot` constructors from `./resources/resource` with + * type-parameterized signatures so that an explicit or inferred TypeScript shape + * (`T`) flows through the slot, the bound graph, and ultimately the data-bearing + * `BufferResource` / `TextureResource` returned from `resource(...)` in later + * phases. The phantom carries no runtime cost and never appears in the generated + * WGSL. + * + * Each typed slot is a structural superset of its underlying `*Slot` type, so it + * remains assignable to existing untyped consumers (binding-graph traversal, + * `bindShader`, etc.) without any cast. Tier-1 type safety means that the buffer + * `set()` method downstream receives `Partial` rather than `Record`; + * Tier-2 (member-level inference) and Tier-3 (per-call validation) are deferred to + * later phases but the `` parameter is already in place to receive them. + * + * Cached `webgpu-utils.makeShaderDataDefinitions(...)` reflection — used to derive + * runtime byte offsets for typed `set()` — is intentionally deferred to Phase 4 + * (BufferManager). For Phase 1 the typed slot is a pure type-system construct. + */ + +import { + externalTextureSlot, + samplerSlot, + storageSlot, + storageTextureSlot, + textureSlot, + uniformSlot, + type ExternalTextureSlot, + type ExternalTextureSlotOptions, + type SamplerSlot, + type SamplerSlotOptions, + type StorageSlot, + type StorageSlotOptions, + type StorageTextureSlot, + type StorageTextureSlotOptions, + type TextureSlot, + type TextureSlotOptions, + type UniformSlot, + type UniformSlotOptions, +} from './resources/resource'; +import type { + StructDecl, + TypeIdentifier, + WgslSampler, + WgslSamplerComparison, + WgslTextureDataType, +} from './shaders'; + +/** A `UniformSlot` annotated with a phantom TS shape `T`. */ +export type TypedUniformSlot = UniformSlot & { readonly __tsShape?: T }; + +/** A `StorageSlot` annotated with a phantom TS shape `T`. */ +export type TypedStorageSlot = StorageSlot & { readonly __tsShape?: T }; + +/** A `TextureSlot` (no TS shape — textures are addressed by `GPUTextureView`, not host data). */ +export type TypedTextureSlot = TextureSlot; + +/** A `StorageTextureSlot` (no TS shape — see `TypedTextureSlot`). */ +export type TypedStorageTextureSlot = StorageTextureSlot; + +/** A `SamplerSlot` (no TS shape — samplers are opaque GPU objects). */ +export type TypedSamplerSlot = SamplerSlot; + +/** An `ExternalTextureSlot` (no TS shape — backed by a `GPUExternalTexture`). */ +export type TypedExternalTextureSlot = ExternalTextureSlot; + +/** + * `slot` is the public-API namespace for declaring shader bindings. Each method + * returns a typed slot suitable for inclusion in a `WgslShader`'s declarations + * array and for later association with concrete GPU data via `resource(...)`. + */ +export const slot = { + /** + * Declare a uniform-buffer binding. When called with a `StructDecl`, the + * resulting slot carries `T` through to the data-bearing resource so that + * `set(values)` accepts `Partial`. + */ + uniform( + name: string, + type: StructDecl | TypeIdentifier, + options?: UniformSlotOptions + ): TypedUniformSlot { + return uniformSlot(name, type as TypeIdentifier, options) as TypedUniformSlot; + }, + + /** + * Declare a storage-buffer binding. When called with a `StructDecl`, the + * resulting slot carries `T` for typed `set(values)` downstream. + */ + storage( + name: string, + type: StructDecl | TypeIdentifier, + options?: StorageSlotOptions + ): TypedStorageSlot { + return storageSlot(name, type as TypeIdentifier, options) as TypedStorageSlot; + }, + + /** Declare a sampled-texture binding (the value supplied at draw time is a `GPUTextureView`). */ + texture( + name: string, + type: WgslTextureDataType | `texture_${string}`, + options?: TextureSlotOptions + ): TypedTextureSlot { + return textureSlot(name, type, options); + }, + + /** Declare a storage-texture binding. `format` is required by the WebGPU API. */ + storageTexture( + name: string, + type: WgslTextureDataType | `texture_${string}`, + format: GPUTextureFormat, + options?: StorageTextureSlotOptions + ): TypedStorageTextureSlot { + return storageTextureSlot(name, type, format, options); + }, + + /** Declare a sampler binding. */ + sampler( + name: string, + type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison', + options?: SamplerSlotOptions + ): TypedSamplerSlot { + return samplerSlot(name, type, options); + }, + + /** Declare an external-texture binding (e.g. `