diff --git a/.gitignore b/.gitignore index 21d44254..53949bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage # generated types .astro/ +*.tsbuildinfo # logs npm-debug.log* diff --git a/packages/core/package.json b/packages/core/package.json index d958606c..86bed5f1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,7 +36,7 @@ "dist" ], "scripts": { - "typecheck": "tsc --noEmit", + "typecheck": "tsc --build", "build": "parcel build --no-cache", "dev": "parcel watch --port 1235", "test": "vitest --watch", @@ -53,12 +53,15 @@ "access": "public" }, "devDependencies": { - "@types/lodash": "4.17.24" + "@types/lodash": "4.17.24", + "@webgpu/types": "0.1.70" }, "dependencies": { "@alleninstitute/vis-geometry": "workspace:*", "lodash": "4.18.1", "regl": "2.1.1", - "uuid": "14.0.0" + "uuid": "14.0.0", + "webgpu-utils": "^2.1.1", + "zod": "4.3.6" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 45f8e8e7..41b110d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,25 +1,21 @@ -export { beginLongRunningFrame } from './render-queue'; -export { AsyncDataCache } from './dataset-cache'; -export { ReglLayer2D } from './layers/layer-2D'; -export * from './layers/buffer-pair'; -export * from './resources'; -export * from './errors'; -export * from './colors'; - export { beginFrame, buildAsyncRenderer, type RenderFrameConfig } from './abstract/async-frame'; -export type { CachedTexture, CachedVertexBuffer, ReglCacheEntry, Renderer } from './abstract/types'; export { RenderServer } from './abstract/render-server'; - +export type { CachedTexture, CachedVertexBuffer, ReglCacheEntry, Renderer } from './abstract/types'; +export * from './colors'; +export { AsyncDataCache } from './dataset-cache'; +export * from './errors'; +export * from './layers/buffer-pair'; +export { ReglLayer2D } from './layers/layer-2D'; export { Logger, logger } from './logger'; -export { PriorityCache, AsyncPriorityCache, type Cacheable } from './shared-priority-cache/priority-cache'; +export { beginLongRunningFrame } from './render-queue'; +export * from './resources'; +export { AsyncPriorityCache, type Cacheable, PriorityCache } from './shared-priority-cache/priority-cache'; export { SharedPriorityCache } from './shared-priority-cache/shared-cache'; - export { - type WorkerMessage, - type WorkerMessageWithId, + HEARTBEAT_RATE_MS, isWorkerMessage, isWorkerMessageWithId, - HEARTBEAT_RATE_MS, + type WorkerMessage, + type WorkerMessageWithId, } from './workers/messages'; - -export { WorkerPool, type WorkerInit } from './workers/worker-pool'; +export { type WorkerInit, WorkerPool } from './workers/worker-pool'; diff --git a/packages/core/src/rendering/webgpu/context-types.ts b/packages/core/src/rendering/webgpu/context-types.ts new file mode 100644 index 00000000..4e2d251e --- /dev/null +++ b/packages/core/src/rendering/webgpu/context-types.ts @@ -0,0 +1,173 @@ +/** + * Type-only companion to `context.ts`. + * + * Houses the public `RenderingContext` **interface** (plus its spec / stats / resource-init + * shapes) so that leaf modules like `drawable.ts` and `encoder/encoder.ts` can depend on the + * context's shape without importing the concrete `RenderingContextImpl` class — which would + * form a runtime import cycle (`context.ts` already imports those modules for their builders). + * + * Every import here is `type`-only; this module contributes no runtime code and therefore + * cannot participate in a runtime cycle. + */ + +import type { + BufferResource, + ExternalTextureResource, + Resource, + SamplerResource, + StorageTextureResource, + TextureResource, +} from './data/resource'; +import type { Drawable, DrawableSpec } from './drawable'; +import type { GraphEncoder } from './encoder/encoder'; +import type { BufferManager } from './memory/types'; +import type { BindingGraph } from './pipelines/binding-graph'; +import type { BuiltPipeline } from './pipelines/build'; +import type { PipelineStateDescriptor } from './pipelines/pipeline-state'; +import type { + ExternalTextureSlot, + ResourceSlot, + SamplerSlot, + StorageSlot, + StorageTextureSlot, + TextureSlot, + UniformSlot, +} from './resources/resource'; +import type { Scene } from './scene/types'; +import type { WgslShader } from './shaders'; + +/** + * Spec passed to `renderingContext()`. + * + * - `device`: the `GPUDevice` every built pipeline targets. + * - `label`: optional debug label; surfaces in error messages (e.g. use-after-dispose). + * - `bufferManager`: optional, user-constructed `BufferManager`. Phase 3 accepts but does not + * consume it. Phase 4 wires `ctx.resource(slot, init?)` over this reference. + */ +export interface RenderingContextSpec { + readonly device: GPUDevice; + readonly label?: string; + readonly bufferManager?: BufferManager; +} + +/** + * Telemetry snapshot returned by `ctx.stats()`. Extended per phase: Phase 4 surfaces a + * read-through `{bytes, leasedBytes}` view of the externally-supplied `BufferManager` (the + * memory fields are absent when no `bufferManager` was provided). Phase 7 adds bind-group + * cache fields. Shape is intentionally a single object so callers can spread or destructure + * without churn. + */ +export interface RenderingContextStats { + readonly pipelines: number; + /** Number of `GPUBindGroup`s currently cached on this context (Phase 7). */ + readonly bindGroups: number; + /** Bytes currently resident in the bound `BufferManager` (leased + free). Absent when no + * `bufferManager` is attached to this context. */ + readonly bytes?: number; + /** Bytes corresponding to leased (in-use) buffers in the bound `BufferManager`. Absent + * when no `bufferManager` is attached. */ + readonly leasedBytes?: number; +} + +/** + * Per-kind initializer accepted by `ctx.resource(slot, init?)`. Conditional over the slot + * variant so callers get a single overload that produces the right `Resource` subtype. + */ +export type ResourceInit = S extends UniformSlot + ? Partial> + : S extends StorageSlot + ? Partial> + : S extends TextureSlot + ? { texture: GPUTexture; view?: GPUTextureView } + : S extends StorageTextureSlot + ? { texture: GPUTexture; view?: GPUTextureView } + : S extends SamplerSlot + ? GPUSampler | GPUSamplerDescriptor + : S extends ExternalTextureSlot + ? GPUExternalTexture + : never; + +/** Output `Resource` subtype for a given slot variant. */ +export type ResourceFor = S extends UniformSlot + ? BufferResource + : S extends StorageSlot + ? BufferResource + : S extends TextureSlot + ? TextureResource + : S extends StorageTextureSlot + ? StorageTextureResource + : S extends SamplerSlot + ? SamplerResource + : S extends ExternalTextureSlot + ? ExternalTextureResource + : never; + +/** + * Device-scoped facade for pipeline build + resource / drawable / encoder construction. + * + * This is the **public interface**; the concrete implementation is `RenderingContextImpl` in + * `context.ts`, constructed via the lowercase factory `renderingContext(spec)`. Leaf modules + * depend on this interface rather than the class to keep the module graph acyclic. + */ +export interface RenderingContext { + readonly device: GPUDevice; + readonly label?: string; + readonly bufferManager?: BufferManager; + + /** Number of `BuiltPipeline`s currently cached on this instance. */ + readonly pipelineCount: number; + /** `true` once `dispose()` has been called. Further `pipeline()` calls will throw. */ + readonly disposed: boolean; + + /** + * Build (or return a cached) `BuiltPipeline` for `(graph, shader, state)` against this + * context's device. Identical inputs return the same instance; differing state (after + * canonical normalization) produces a distinct entry. + */ + pipeline( + graph: BindingGraph, + shader: WgslShader, + state: PipelineStateDescriptor + ): BuiltPipeline; + + /** + * Construct a data-bearing `Resource` for `slot`. Dispatches on `slot.kind`; requires a + * `bufferManager` for buffer-backed slot kinds. The returned resource starts with + * `refcount === 1` — pair every call with exactly one `destroy()`. + */ + resource(slot: S, init?: ResourceInit): ResourceFor; + + /** + * Construct a `Drawable` — a pipeline + vertex / index / binding resources + draw call — + * against this context. Pair every call with exactly one `drawable.destroy()`. + */ + drawable(spec: DrawableSpec): Drawable; + + /** Construct (or return the cached) `GraphEncoder` bound to this context. */ + encoder(): GraphEncoder; + + /** Encode + submit `scene` to the device queue in one call. */ + submit(scene: Scene): GPUCommandBuffer; + + /** Drop every cached `BuiltPipeline` without disposing the context. */ + disposePipelineCache(): void; + + /** Drop every cached `GPUBindGroup` without disposing the context. */ + disposeBindGroupCache(): void; + + /** + * Selectively drop the cached `GPUBindGroup`s referencing any of `resources`, leaving other + * entries intact. Resources built via `ctx.resource()` trigger this automatically on + * `commit()` / `destroy()`. Returns the number of bind groups removed. + */ + sweepBindGroups(resources: readonly Resource[]): number; + + /** + * Tear down everything this context owns. Idempotent. Does **not** dispose the + * externally-supplied `bufferManager` — that lifetime belongs to the caller. + */ + dispose(): void; + + /** Snapshot of current cache occupancy. Cheap; suitable for HUDs / instrumentation. */ + stats(): RenderingContextStats; +} diff --git a/packages/core/src/rendering/webgpu/context.test.ts b/packages/core/src/rendering/webgpu/context.test.ts new file mode 100644 index 00000000..cf0c3473 --- /dev/null +++ b/packages/core/src/rendering/webgpu/context.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { BufferHandle, BufferManager, BufferManagerStats } from './memory/types'; +import { renderingContext } from './context'; +import { isResource } from './data/resource'; +import { samplerSlot, uniformSlot } from './resources'; +import { bindings, group } from './pipelines/binding-graph'; +import { member, shader, struct } from './shaders'; +import { makeMockDevice } from './test/mock-device'; + +// ----- helpers ------------------------------------------------------------ + +const cameraStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +function fixture() { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + return { cam, root, sh, graph }; +} + +const colorState = () => ({ + primitive: { topology: 'triangle-list' as const }, + fragment: { targets: [{ format: 'bgra8unorm' as const }] }, +}); + +/** Minimal `BufferManager` stand-in. Only the methods exercised by these tests are real. */ +function makeMockBufferManager(): BufferManager { + const stub = (): never => { + throw new Error('mock BufferManager: method not implemented for these tests'); + }; + return { + acquire: vi.fn(stub) as unknown as BufferManager['acquire'], + acquireForSlot: vi.fn(stub) as unknown as BufferManager['acquireForSlot'], + precheck: vi.fn(() => true), + release: vi.fn(stub) as unknown as BufferManager['release'], + endFrame: vi.fn(), + frameLease: vi.fn(stub) as unknown as BufferManager['frameLease'], + stats: vi.fn( + (): BufferManagerStats => ({ + residentBytes: 0, + leasedBytes: 0, + freeBytes: 0, + }) + ), + dispose: vi.fn(), + }; +} + +// ----- tests -------------------------------------------------------------- + +describe('RenderingContext — construction', () => { + it('stores device, label, and bufferManager when supplied', () => { + const m = makeMockDevice(); + const bm = makeMockBufferManager(); + const ctx = renderingContext({ device: m.device, label: 'ctxA', bufferManager: bm }); + expect(ctx.device).toBe(m.device); + expect(ctx.label).toBe('ctxA'); + expect(ctx.bufferManager).toBe(bm); + }); + + it('omits label and bufferManager when absent', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + expect(ctx.label).toBeUndefined(); + expect(ctx.bufferManager).toBeUndefined(); + }); + + it('starts with disposed=false and pipelineCount=0', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + expect(ctx.disposed).toBe(false); + expect(ctx.pipelineCount).toBe(0); + }); +}); + +describe('RenderingContext — lifecycle', () => { + it('dispose() flips disposed and clears the pipeline cache', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, label: 'ctxL' }); + const { sh, graph } = fixture(); + ctx.pipeline(graph, sh, colorState()); + expect(ctx.pipelineCount).toBe(1); + ctx.dispose(); + expect(ctx.disposed).toBe(true); + expect(ctx.pipelineCount).toBe(0); + }); + + it('dispose() is idempotent (second call is a no-op)', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.dispose(); + expect(() => ctx.dispose()).not.toThrow(); + expect(ctx.disposed).toBe(true); + }); + + it('pipeline() after dispose() throws including the context label', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, label: 'frame-ctx' }); + const { sh, graph } = fixture(); + ctx.dispose(); + expect(() => ctx.pipeline(graph, sh, colorState())).toThrow(/frame-ctx/); + expect(() => ctx.pipeline(graph, sh, colorState())).toThrow(/use-after-dispose/); + }); + + it('disposePipelineCache() clears entries but does NOT mark the context disposed', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const { sh, graph } = fixture(); + ctx.pipeline(graph, sh, colorState()); + expect(ctx.pipelineCount).toBe(1); + ctx.disposePipelineCache(); + expect(ctx.pipelineCount).toBe(0); + expect(ctx.disposed).toBe(false); + // Should be usable again without throwing. + ctx.pipeline(graph, sh, colorState()); + expect(ctx.pipelineCount).toBe(1); + }); +}); + +describe('RenderingContext — telemetry', () => { + it('stats() returns {pipelines: N, bindGroups: 0} matching pipelineCount', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + expect(ctx.stats()).toEqual({ pipelines: 0, bindGroups: 0 }); + + const { sh, graph } = fixture(); + ctx.pipeline(graph, sh, colorState()); + expect(ctx.stats()).toEqual({ pipelines: 1, bindGroups: 0 }); + expect(ctx.stats().pipelines).toBe(ctx.pipelineCount); + }); +}); + +describe('RenderingContext — isolation', () => { + it('two contexts against the same device have independent caches', () => { + const m = makeMockDevice(); + const ctx1 = renderingContext({ device: m.device, label: 'ctx1' }); + const ctx2 = renderingContext({ device: m.device, label: 'ctx2' }); + const { sh, graph } = fixture(); + const state = colorState(); + + const a = ctx1.pipeline(graph, sh, state); + const b = ctx2.pipeline(graph, sh, state); + + expect(a).not.toBe(b); + // Content-addressable fingerprint is per-content, not per-context — so it matches. + expect(a.fingerprint).toBe(b.fingerprint); + // But each context built its own GPU pipeline. + expect(m.calls.createRenderPipeline).toHaveBeenCalledTimes(2); + expect(ctx1.pipelineCount).toBe(1); + expect(ctx2.pipelineCount).toBe(1); + }); +}); + +describe('RenderingContext — BufferManager ownership contract', () => { + it('dispose() does NOT call bufferManager.dispose()', () => { + const m = makeMockDevice(); + const bm = makeMockBufferManager(); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + ctx.dispose(); + expect(bm.dispose).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 4: ctx.resource() + stats() memory fields +// --------------------------------------------------------------------------- + +/** + * Recording `BufferManager` that issues real-looking handles backed by the mock device's + * `createBuffer`. Used to verify `ctx.resource()` wiring without dragging the full BatchPool + * implementation into the context tests. + */ +function makeRecordingBM(device: GPUDevice, residentBytes = 1024, leasedBytes = 512): BufferManager { + return { + acquire: vi.fn((sizeBytes: number, usage: GPUBufferUsageFlags) => { + const gpu = device.createBuffer({ size: sizeBytes, usage }); + const handle: BufferHandle = { + gpu, + buffer: gpu, + offset: 0, + size: sizeBytes, + sizeBytes, + bucketSize: sizeBytes, + usage, + release(): void {}, + sizeInBytes(): number { + return sizeBytes; + }, + destroy(): void {}, + }; + return handle; + }) as unknown as BufferManager['acquire'], + acquireForSlot: vi.fn((_slot, sizeBytes: number, usage: GPUBufferUsageFlags) => { + const gpu = device.createBuffer({ size: sizeBytes, usage }); + const handle: BufferHandle = { + gpu, + buffer: gpu, + offset: 0, + size: sizeBytes, + sizeBytes, + bucketSize: sizeBytes, + usage, + release(): void {}, + sizeInBytes(): number { + return sizeBytes; + }, + destroy(): void {}, + }; + return handle; + }) as unknown as BufferManager['acquireForSlot'], + precheck: vi.fn(() => true), + release: vi.fn(), + endFrame: vi.fn(), + frameLease: vi.fn(() => { + throw new Error('not supported'); + }) as unknown as BufferManager['frameLease'], + stats: vi.fn( + (): BufferManagerStats => ({ + residentBytes, + leasedBytes, + freeBytes: residentBytes - leasedBytes, + }) + ), + dispose: vi.fn(), + }; +} + +describe('RenderingContext.resource() — buffer-backed slots', () => { + it('constructs a BufferResource for a uniform slot', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + const { cam } = fixture(); + const r = ctx.resource(cam); + expect(isResource(r)).toBe(true); + expect(r.kind).toBe('uniform'); + expect(r.slot).toBe(cam); + expect(bm.acquireForSlot).toHaveBeenCalledTimes(1); + }); + + it('throws when called without a bufferManager (clear, labeled error)', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, label: 'ctxNoMem' }); + const { cam } = fixture(); + expect(() => ctx.resource(cam)).toThrow(/ctxNoMem/); + expect(() => ctx.resource(cam)).toThrow(/bufferManager/); + }); + + it('throws when called after dispose()', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + const { cam } = fixture(); + ctx.dispose(); + expect(() => ctx.resource(cam)).toThrow(/use-after-dispose/); + }); +}); + +describe('RenderingContext.resource() — sampler slots', () => { + it('constructs a SamplerResource without a bufferManager', () => { + const m = makeMockDevice(); + // device.createSampler is missing on the mock; supply a pre-built sampler instead. + const ctx = renderingContext({ device: m.device }); + const slot = samplerSlot('samp', 'sampler'); + const sampler = {} as unknown as GPUSampler; + const r = ctx.resource(slot, sampler); + expect(r.kind).toBe('sampler'); + expect(r.slot).toBe(slot); + }); +}); + +describe('RenderingContext.stats() — memory fields', () => { + it('omits bytes/leasedBytes when no bufferManager is attached', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const s = ctx.stats(); + expect(s.bytes).toBeUndefined(); + expect(s.leasedBytes).toBeUndefined(); + expect(s.pipelines).toBe(0); + }); + + it('includes bytes/leasedBytes when a bufferManager is attached (read-through)', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device, 4096, 1024); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + const s = ctx.stats(); + expect(s.bytes).toBe(4096); + expect(s.leasedBytes).toBe(1024); + expect(s.pipelines).toBe(0); + expect(bm.stats).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/rendering/webgpu/context.ts b/packages/core/src/rendering/webgpu/context.ts new file mode 100644 index 00000000..38c36cb6 --- /dev/null +++ b/packages/core/src/rendering/webgpu/context.ts @@ -0,0 +1,339 @@ +/** + * `RenderingContext` — device-scoped owner of pipeline build state. + * + * Replaces the previous module-level `WeakMap>` cache + * with a per-instance cache so applications and tests can hold isolated caches against the same + * `GPUDevice`, dispose them deterministically, and instrument them via `stats()`. + * + * Future phases extend this class — Phase 4 adds `ctx.resource(slot, init?)` over the + * externally-supplied `bufferManager`; Phase 5 adds `ctx.drawable({...})`; Phase 7 adds + * `ctx.encoder()` + `ctx.submit(scene)` and the bind-group cache. + * + * **Ownership contract**: a `BufferManager` is **always externally constructed**. The context + * holds a reference but never creates, manages, or disposes it. `ctx.dispose()` clears the + * pipeline cache only — the caller's `BufferManager` lifetime is untouched. + */ + +import type { + RenderingContext, + RenderingContextSpec, + RenderingContextStats, + ResourceFor, + ResourceInit, +} from './context-types'; +import { + makeBufferResource, + makeExternalTextureResource, + makeSamplerResource, + makeSlotReflectionCache, + makeStorageTextureResource, + makeTextureResource, + type Resource, + type SlotReflectionCache, +} from './data/resource'; +import { buildDrawable, type Drawable, type DrawableSpec } from './drawable'; +import { type BindGroupCacheStore, sweepBindGroupCache } from './encoder/bind-group-builder'; +import { type GraphEncoder, makeGraphEncoder } from './encoder/encoder'; +import type { BufferManager } from './memory/types'; +import type { BindingGraph } from './pipelines/binding-graph'; +import { type BuiltPipeline, buildPipeline } from './pipelines/build'; +import { pipelineFingerprint } from './pipelines/fingerprint'; +import { + normalizePipelineState, + type PipelineStateDescriptor, +} from './pipelines/pipeline-state'; +import { resolveShaderBindings } from './pipelines/traverse'; +import type { + ExternalTextureSlot, + ResourceSlot, + SamplerSlot, + StorageSlot, + StorageTextureSlot, + TextureSlot, + UniformSlot, +} from './resources/resource'; +import type { Scene } from './scene/types'; +import type { WgslShader } from './shaders'; + +/** + * Device-scoped facade for pipeline build + resource / drawable / encoder construction. + * Implements the public {@link RenderingContext} interface (declared in `context-types.ts`). + * + * Construct via the lowercase factory `renderingContext(spec)` to match the surrounding + * authoring style; this class is an implementation detail and is not part of the public + * barrel — callers annotate with the `RenderingContext` interface instead. + */ +export class RenderingContextImpl implements RenderingContext { + readonly device: GPUDevice; + readonly label?: string; + readonly bufferManager?: BufferManager; + + private readonly _pipelineCache: Map = new Map(); + /** Per-context `GPUBindGroup` cache consulted by the encoder. Phase 7. */ + private readonly _bindGroupCache: BindGroupCacheStore = { + cache: new Map(), + keyToResources: new Map(), + resourceToKeys: new WeakMap(), + }; + /** Stable hook handed to every resource this context builds. Invoked on `commit()` / + * `destroy()` to selectively drop the bind groups referencing that resource. */ + private readonly _invalidateBindGroups = (resource: Resource): void => { + sweepBindGroupCache(this._bindGroupCache, [resource]); + }; + /** Per-context memo of slot reflection, consumed by `makeBufferResource`. Replaces the + * former module-global `slotDefCache` WeakMap. */ + private readonly _slotReflectionCache: SlotReflectionCache = makeSlotReflectionCache(); + /** Cached `GraphEncoder` (Phase 7). One per context — re-created if disposed. */ + private _encoder: GraphEncoder | undefined; + private _disposed = false; + + constructor(spec: RenderingContextSpec) { + this.device = spec.device; + if (spec.label !== undefined) this.label = spec.label; + if (spec.bufferManager !== undefined) this.bufferManager = spec.bufferManager; + } + + /** Number of `BuiltPipeline`s currently cached on this instance. */ + get pipelineCount(): number { + return this._pipelineCache.size; + } + + /** `true` once `dispose()` has been called. Further `pipeline()` calls will throw. */ + get disposed(): boolean { + return this._disposed; + } + + /** + * Build (or return a cached) `BuiltPipeline` for `(graph, shader, state)` against this + * context's device. Identical inputs return the same instance; differing state (after + * canonical normalization) produces a distinct entry. + */ + pipeline( + graph: BindingGraph, + shader: WgslShader, + state: PipelineStateDescriptor + ): BuiltPipeline { + this.assertNotDisposed(); + const normalizedState = normalizePipelineState(state); + const slotIndex = resolveShaderBindings(graph, shader); + const fingerprint = pipelineFingerprint(shader, slotIndex, normalizedState); + const cached = this._pipelineCache.get(fingerprint); + if (cached !== undefined) return cached; + const built = buildPipeline( + this.device, + graph, + shader, + normalizedState, + slotIndex, + fingerprint + ); + this._pipelineCache.set(fingerprint, built); + return built; + } + + /** + * Construct a data-bearing `Resource` for `slot`. Dispatches on `slot.kind`: + * + * - `uniform` / `storage` → `BufferResource`. `init` may be a `Partial` of initial + * values (seeded into the CPU-side view; the first `commit()` uploads them). Requires + * `bufferManager` to have been supplied at construction; throws otherwise. + * - `texture` / `storageTexture` → wraps a caller-supplied `GPUTexture` (+ optional view). + * - `sampler` → wraps a caller-supplied `GPUSampler` or constructs one from a descriptor. + * - `externalTexture` → wraps a caller-supplied `GPUExternalTexture`. + * + * The returned resource starts with `refcount === 1`; pair every `ctx.resource()` with + * exactly one `destroy()` (or use `share()` to extend its lifetime across owners). + */ + resource(slot: S, init?: ResourceInit): ResourceFor { + this.assertNotDisposed(); + switch (slot.kind) { + case 'uniform': + case 'storage': { + const bm = this.bufferManager; + if (bm === undefined) { + const tag = this.label !== undefined ? ` '${this.label}'` : ''; + throw new Error( + `RenderingContext${tag}: resource(slot '${slot.name}') requires a bufferManager; ` + + 'pass one to renderingContext({ device, bufferManager }).' + ); + } + const resource = makeBufferResource( + slot as UniformSlot | StorageSlot, + bm, + init as Partial> | undefined, + this._slotReflectionCache, + this._invalidateBindGroups + ); + return resource as unknown as ResourceFor; + } + case 'sampler': { + const resource = makeSamplerResource( + slot as SamplerSlot, + this.device, + init as GPUSampler | GPUSamplerDescriptor | undefined, + this._invalidateBindGroups + ); + return resource as unknown as ResourceFor; + } + case 'texture': { + if (init === undefined) { + throw new Error( + `RenderingContext.resource: slot '${slot.name}' (kind 'texture') requires an ` + + 'init `{ texture, view? }`; texture creation from image sources is deferred to a follow-up.' + ); + } + const resource = makeTextureResource( + slot as TextureSlot, + init as { texture: GPUTexture; view?: GPUTextureView }, + this._invalidateBindGroups + ); + return resource as unknown as ResourceFor; + } + case 'storageTexture': { + if (init === undefined) { + throw new Error( + `RenderingContext.resource: slot '${slot.name}' (kind 'storageTexture') requires an ` + + 'init `{ texture, view? }`.' + ); + } + const resource = makeStorageTextureResource( + slot as StorageTextureSlot, + init as { texture: GPUTexture; view?: GPUTextureView }, + this._invalidateBindGroups + ); + return resource as unknown as ResourceFor; + } + case 'externalTexture': { + if (init === undefined) { + throw new Error( + `RenderingContext.resource: slot '${slot.name}' (kind 'externalTexture') requires an ` + + 'init `GPUExternalTexture`.' + ); + } + const resource = makeExternalTextureResource( + slot as ExternalTextureSlot, + init as GPUExternalTexture, + this._invalidateBindGroups + ); + return resource as unknown as ResourceFor; + } + } + } + + /** + * Construct a `Drawable` — a pipeline + vertex / index / binding resources + draw call — + * against this context. Phase 5 of the rendering refactor. + * + * Vertex / index input may be: + * - Pre-built: a `Map` (vertex) / + * `{ resource, format }` (index). The drawable `share()`s each supplied resource so + * the caller retains its own refcount. + * - Raw arrays: a `{ kind: 'arrays', arrays, options?, bufferSlots? }` descriptor. + * `webgpu-utils.createBufferLayoutsFromArrays` is used **only** to derive byte + * layouts; GPU buffer allocation funnels through `this.bufferManager.acquire`, and + * uploads go through `this.device.queue.writeBuffer(handle.gpu, handle.offset, …)` so + * slab-style managers remain transparent. Requires `bufferManager` to have been + * supplied at construction; throws otherwise. + * + * `bindings` must cover every slot in `pipeline.slotIndex`; resource kinds are validated. + * + * Pair every `ctx.drawable(spec)` with exactly one `drawable.destroy()` — the destroy + * decrefs every owned resource, freshly-allocated buffers fall to refcount 0 and release + * their `BufferHandle`s, pre-built ones fall back to the caller's refcount. + */ + drawable(spec: DrawableSpec): Drawable { + this.assertNotDisposed(); + return buildDrawable(this, spec); + } + + /** + * Construct (or return the cached) `GraphEncoder` bound to this context. The encoder is + * stateless across `submit` calls beyond what `RenderingContext` caches — the bind-group + * cache lives on this context, so an encoder freed and re-created here doesn't lose its + * cache. + */ + encoder(): GraphEncoder { + this.assertNotDisposed(); + if (this._encoder === undefined) { + this._encoder = makeGraphEncoder(this, this._bindGroupCache); + } + return this._encoder; + } + + /** + * Encode + submit `scene` to the device queue in one call. Convenience wrapper over + * `ctx.encoder().submit(scene)` — the encoder instance is constructed lazily and cached. + */ + submit(scene: Scene): GPUCommandBuffer { + return this.encoder().submit(scene); + } + + /** + * Drop every cached `BuiltPipeline` without disposing the context. Safe to call repeatedly; + * subsequent `pipeline()` calls rebuild on demand. + */ + disposePipelineCache(): void { + this._pipelineCache.clear(); + } + + /** + * Drop every cached `GPUBindGroup` without disposing the context. Phase 8 lifecycle hook: + * call after a large scene mutation (e.g. `Scene.remove` of a big subtree) to release the + * bind groups that are no longer referenced. Cheap; rebuilt lazily on next submit. + */ + disposeBindGroupCache(): void { + this._bindGroupCache.cache.clear(); + this._bindGroupCache.keyToResources.clear(); + } + + /** + * Selectively drop the cached `GPUBindGroup`s that reference any of `resources`, leaving + * every other entry intact. `ctx.resource()`-built resources call this automatically on + * `commit()` / `destroy()`; call it directly for resources built outside the context. + * Safe after `dispose()` (no-op on the already-cleared cache). Returns the number of bind + * groups removed. + */ + sweepBindGroups(resources: readonly Resource[]): number { + return sweepBindGroupCache(this._bindGroupCache, resources); + } + + /** + * Tear down everything this context owns. Idempotent. After `dispose()` further + * `pipeline()` calls throw. **Does not** dispose the externally-supplied `bufferManager` — + * that lifetime belongs to the caller. + */ + dispose(): void { + if (this._disposed) return; + this.disposePipelineCache(); + this.disposeBindGroupCache(); + this._encoder?.clearSubtreeCache(); + this._encoder = undefined; + this._disposed = true; + } + + /** Snapshot of current cache occupancy. Cheap; suitable for HUDs / instrumentation. */ + stats(): RenderingContextStats { + const base = { + pipelines: this._pipelineCache.size, + bindGroups: this._bindGroupCache.cache.size, + }; + if (this.bufferManager === undefined) return base; + const bm = this.bufferManager.stats(); + return { ...base, bytes: bm.residentBytes, leasedBytes: bm.leasedBytes }; + } + + private assertNotDisposed(): void { + if (!this._disposed) return; + const tag = this.label !== undefined ? ` '${this.label}'` : ''; + throw new Error(`RenderingContext${tag}: use-after-dispose.`); + } +} + +/** + * Lowercase factory for `RenderingContext`. Matches the convention used by `group`, `bindings`, + * `shader`, `struct`, etc. Returns the public `RenderingContext` interface; the concrete + * `RenderingContextImpl` class is an implementation detail. + */ +export function renderingContext(spec: RenderingContextSpec): RenderingContext { + return new RenderingContextImpl(spec); +} diff --git a/packages/core/src/rendering/webgpu/data/resource.test.ts b/packages/core/src/rendering/webgpu/data/resource.test.ts new file mode 100644 index 00000000..4217c7de --- /dev/null +++ b/packages/core/src/rendering/webgpu/data/resource.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { BufferHandle, BufferManager, BufferManagerStats } from '../memory/types'; +import { uniformSlot, storageSlot, samplerSlot, textureSlot, externalTextureSlot, storageTextureSlot } from '../resources'; +import { member, struct } from '../shaders'; +import { makeMockDevice } from '../test/mock-device'; +import { + isResource, + makeBufferResource, + makeExternalTextureResource, + makeSamplerResource, + makeStorageTextureResource, + makeTextureResource, + RESOURCE_BRAND, +} from './resource'; + +// ----- shared fixtures ---------------------------------------------------- + +type Camera = { + view: readonly number[]; + proj: readonly number[]; +}; + +const cameraStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +/** + * Minimal recording `BufferManager`. Hands out monotonically-allocated handles backed by the + * supplied mock-device `createBuffer`; tracks every released handle so refcount tests can + * verify cleanup. + */ +function makeRecordingBufferManager(device: GPUDevice): { + bm: BufferManager; + released: BufferHandle[]; + handlesIssued: () => BufferHandle[]; +} { + const issued: BufferHandle[] = []; + const released: BufferHandle[] = []; + const stats = vi.fn( + (): BufferManagerStats => ({ residentBytes: 0, leasedBytes: 0, freeBytes: 0 }) + ); + const acquire = vi.fn((sizeBytes: number, usage: GPUBufferUsageFlags) => { + const gpu = device.createBuffer({ size: sizeBytes, usage }); + const handle: BufferHandle = { + gpu, + buffer: gpu, + offset: 0, + size: sizeBytes, + sizeBytes, + bucketSize: sizeBytes, + usage, + release(): void { + released.push(handle); + }, + sizeInBytes(): number { + return sizeBytes; + }, + destroy(): void { + released.push(handle); + }, + }; + issued.push(handle); + return handle; + }); + const acquireForSlot = vi.fn( + (_slot: unknown, sizeBytes: number, usage: GPUBufferUsageFlags) => + acquire(sizeBytes, usage) + ); + const bm: BufferManager = { + acquire, + acquireForSlot, + precheck: vi.fn(() => true), + release: vi.fn((h: BufferHandle) => released.push(h)), + endFrame: vi.fn(), + frameLease: vi.fn(() => { + throw new Error('frameLease: not supported in this mock'); + }), + stats, + dispose: vi.fn(), + }; + return { bm, released, handlesIssued: () => issued }; +} + +// ----- BufferResource construction & round-trip -------------------------- + +describe('makeBufferResource — construction + round-trip', () => { + it('reflects a struct slot and allocates a sized buffer through the BufferManager', () => { + const m = makeMockDevice(); + const { bm, handlesIssued } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + + const r = makeBufferResource(cam, bm, undefined); + + expect(r.kind).toBe('uniform'); + expect(r.slot).toBe(cam); + expect(r.usage & GPUBufferUsage.UNIFORM).toBe(GPUBufferUsage.UNIFORM); + expect(r.usage & GPUBufferUsage.COPY_DST).toBe(GPUBufferUsage.COPY_DST); + // Two mat4x4f members = 2 * 64 = 128 bytes. + expect(r.arrayBuffer.byteLength).toBe(128); + const issued = handlesIssued(); + expect(issued).toHaveLength(1); + expect(issued[0]?.size).toBe(128); + // sanity: handle is the same one carried by the resource. + expect(r.handle).toBe(issued[0]); + }); + + it('seeds initial values into the structured view without committing', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const view = new Float32Array(16); + view[0] = 1; + view[5] = 1; + view[10] = 1; + view[15] = 1; + + const r = makeBufferResource(cam, bm, { view: Array.from(view) }); + + // The cpu-side bytes hold the identity matrix in the `view` field offset. + const dv = new DataView(r.arrayBuffer); + expect(dv.getFloat32(0, true)).toBe(1); + expect(dv.getFloat32(5 * 4, true)).toBe(1); + // No GPU upload happened during construction. + expect(m.calls.writeBuffer).not.toHaveBeenCalled(); + expect(r.version).toBe(0); + }); + + it('set() updates the CPU view; commit() uploads through queue.writeBuffer and bumps version', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + + const r = makeBufferResource(cam, bm, undefined); + const proj = new Float32Array(16).fill(2); + r.set({ proj: Array.from(proj) }); + r.commit(m.device); + + expect(r.version).toBe(1); + expect(m.calls.writeBuffer).toHaveBeenCalledTimes(1); + expect(m.writes).toHaveLength(1); + const write = m.writes[0]; + expect(write?.buffer).toBe(r.handle.gpu); + expect(write?.bufferOffset).toBe(r.handle.offset); + expect(write?.data.byteLength).toBe(128); + + // Second commit bumps version again. + r.commit(m.device); + expect(r.version).toBe(2); + }); + + it('setField() updates a single member through view.set', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const r = makeBufferResource(cam, bm, undefined); + + const identity = new Float32Array(16); + identity[0] = identity[5] = identity[10] = identity[15] = 1; + r.setField('view', Array.from(identity)); + + const dv = new DataView(r.arrayBuffer); + expect(dv.getFloat32(0, true)).toBe(1); + expect(dv.getFloat32(5 * 4, true)).toBe(1); + expect(dv.getFloat32(10 * 4, true)).toBe(1); + expect(dv.getFloat32(15 * 4, true)).toBe(1); + }); + + it('throws when setField() targets an unknown member', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const r = makeBufferResource(cam, bm, undefined); + expect(() => r.setField('typo', 1)).toThrow(/typo/); + expect(() => r.setField('typo', 1)).toThrow(/Camera/); + }); + + it('rejects construction when the slot type is a raw WGSL string', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const u = uniformSlot('raw', 'f32'); + expect(() => makeBufferResource(u, bm, undefined)).toThrow(/StructDecl/); + }); + + it('reflects storage slots with their access mode', () => { + const m = makeMockDevice(); + const { bm, handlesIssued } = makeRecordingBufferManager(m.device); + const sStruct = struct('SBuf', [member('flag', 'u32')]); + const s = storageSlot('s', sStruct, { accessMode: 'read_write' }); + + const r = makeBufferResource(s, bm, undefined); + + expect(r.kind).toBe('storage'); + expect(r.usage & GPUBufferUsage.STORAGE).toBe(GPUBufferUsage.STORAGE); + // u32 = 4 bytes. + expect(handlesIssued()[0]?.size).toBe(4); + }); +}); + +// ----- Refcount / share / destroy ----------------------------------------- + +describe('BufferResource — refcount semantics', () => { + it('starts with refcount 1; destroy() releases the handle and marks disposed', () => { + const m = makeMockDevice(); + const { bm, released } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const r = makeBufferResource(cam, bm, undefined); + + expect(r.refcount).toBe(1); + expect(r.disposed).toBe(false); + r.destroy(); + expect(r.disposed).toBe(true); + expect(released).toHaveLength(1); + expect(released[0]).toBe(r.handle); + }); + + it('share() bumps refcount; destroy() is idempotent after refcount hits 0', () => { + const m = makeMockDevice(); + const { bm, released } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const r = makeBufferResource(cam, bm, undefined); + + r.share(); + r.share(); + expect(r.refcount).toBe(3); + + r.destroy(); + r.destroy(); + expect(released).toHaveLength(0); + expect(r.refcount).toBe(1); + expect(r.disposed).toBe(false); + + r.destroy(); + expect(released).toHaveLength(1); + expect(r.disposed).toBe(true); + + // Further destroys are no-ops. + r.destroy(); + r.destroy(); + expect(released).toHaveLength(1); + }); + + it('share() returns the same instance', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const r = makeBufferResource(cam, bm, undefined); + expect(r.share()).toBe(r); + }); + + it('rejects mutations after destroy()', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const r = makeBufferResource(cam, bm, undefined); + r.destroy(); + expect(() => r.set({})).toThrow(/destroy/); + expect(() => r.setField('view', [])).toThrow(/destroy/); + expect(() => r.commit(m.device)).toThrow(/destroy/); + expect(() => r.share()).toThrow(/destroy/); + }); +}); + +// ----- Phase 8: precheck fast-fail ---------------------------------------- + +describe('makeBufferResource — precheck integration', () => { + it('throws (and never calls acquireForSlot) when bufferManager.precheck returns false', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + (bm.precheck as ReturnType).mockReturnValue(false); + + const cam = uniformSlot('camera', cameraStruct); + expect(() => makeBufferResource(cam, bm, undefined)).toThrow(/precheck refused/); + expect(bm.acquireForSlot).not.toHaveBeenCalled(); + }); +}); + +// ----- Other resource kinds ----------------------------------------------- + +describe('SamplerResource', () => { + it('uses a pre-built GPUSampler when supplied', () => { + const m = makeMockDevice(); + // Pretend a GPUSampler has no descriptor-shaped properties. + const sampler = { label: 'pre-built' } as unknown as GPUSampler; + const slot = samplerSlot('samp', 'sampler'); + const r = makeSamplerResource(slot, m.device, sampler); + expect(r.sampler).toBe(sampler); + expect(r.kind).toBe('sampler'); + r.destroy(); + expect(r.disposed).toBe(true); + }); + + it('refcounts via share()/destroy()', () => { + const m = makeMockDevice(); + const slot = samplerSlot('samp', 'sampler'); + const sampler = {} as unknown as GPUSampler; + const r = makeSamplerResource(slot, m.device, sampler); + r.share(); + r.destroy(); + expect(r.disposed).toBe(false); + r.destroy(); + expect(r.disposed).toBe(true); + }); +}); + +describe('TextureResource', () => { + it('wraps a GPUTexture and creates a view when none is supplied', () => { + const slot = textureSlot('tex', 'texture_2d'); + const createView = vi.fn(() => ({ __mockKind: 'textureView' as const } as unknown as GPUTextureView)); + const destroy = vi.fn(); + const texture = { createView, destroy } as unknown as GPUTexture; + const r = makeTextureResource(slot, { texture }); + expect(r.texture).toBe(texture); + expect(createView).toHaveBeenCalledTimes(1); + r.destroy(); + expect(destroy).toHaveBeenCalledTimes(1); + }); + + it('uses the explicit view when supplied', () => { + const slot = textureSlot('tex', 'texture_2d'); + const view = {} as unknown as GPUTextureView; + const texture = { + createView: vi.fn(), + destroy: vi.fn(), + } as unknown as GPUTexture; + const r = makeTextureResource(slot, { texture, view }); + expect(r.view).toBe(view); + expect((texture as unknown as { createView: unknown }).createView).not.toHaveBeenCalled(); + }); +}); + +describe('StorageTextureResource', () => { + it('wraps a GPUTexture and destroys it when refcount hits 0', () => { + const slot = storageTextureSlot('st', 'texture_storage_2d', 'rgba8unorm'); + const destroy = vi.fn(); + const createView = vi.fn(() => ({}) as GPUTextureView); + const texture = { createView, destroy } as unknown as GPUTexture; + const r = makeStorageTextureResource(slot, { texture }); + r.share(); + r.destroy(); + expect(destroy).not.toHaveBeenCalled(); + r.destroy(); + expect(destroy).toHaveBeenCalledTimes(1); + }); +}); + +describe('ExternalTextureResource', () => { + it('wraps a GPUExternalTexture and marks disposed on final destroy()', () => { + const slot = externalTextureSlot('ext'); + const external = {} as unknown as GPUExternalTexture; + const r = makeExternalTextureResource(slot, external); + expect(r.external).toBe(external); + r.destroy(); + expect(r.disposed).toBe(true); + }); +}); + +// ----- Brand / isResource ------------------------------------------------- + +describe('isResource', () => { + it('returns true for every concrete resource kind', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBufferManager(m.device); + const cam = uniformSlot('camera', cameraStruct); + const buf = makeBufferResource(cam, bm, undefined); + expect(isResource(buf)).toBe(true); + expect(buf.__brand).toBe(RESOURCE_BRAND); + }); + + it('returns false for plain objects', () => { + expect(isResource(null)).toBe(false); + expect(isResource(undefined)).toBe(false); + expect(isResource({})).toBe(false); + expect(isResource({ __brand: 'wrong' })).toBe(false); + }); +}); diff --git a/packages/core/src/rendering/webgpu/data/resource.ts b/packages/core/src/rendering/webgpu/data/resource.ts new file mode 100644 index 00000000..732402ed --- /dev/null +++ b/packages/core/src/rendering/webgpu/data/resource.ts @@ -0,0 +1,630 @@ +/** + * Data-bearing `Resource` family — the runtime counterpart to a `ResourceSlot`. + * + * Where a `ResourceSlot` is a *descriptor* (`{name, type, kind, ...}`), a `Resource` is an + * *instance* that carries an actual `GPUBuffer` (via a `BufferHandle`), `GPUTexture`, + * `GPUSampler`, or `GPUExternalTexture`. `RenderingContext.resource(slot, init?)` is the only + * public constructor; raw exports here are kinds and the discriminated union type. + * + * Tier-1 type safety: a `BufferResource` carries `T` (the host-side shape of the slot's + * struct, declared via `struct(...)` and threaded through `slot.uniform(...)`). `set()` + * accepts `Partial` and `setField(k, v: T[K])` is field-checked at + * compile time. Reflection (`webgpu-utils.makeShaderDataDefinitions` over a synthetic WGSL + * snippet built from the slot's `StructDecl`) is cached per-slot in a module-level WeakMap, so + * repeated `ctx.resource(slot)` calls pay the reflection cost exactly once per slot. + * + * Refcount semantics: + * - Every freshly-constructed resource has `refcount === 1`. + * - `share()` increments the refcount and returns `this` (so call sites can write + * `const child = parent.share()`). + * - `destroy()` decrements the refcount; when it reaches 0 the resource releases its + * `BufferHandle` (or destroys its texture/sampler) and is left in a `disposed === true` + * state. Further calls to `destroy()` are no-ops, so callers can always pair their + * constructions and tearings-down 1:1 without tracking who has the "last" reference. + * + * Slab-readiness: every WebGPU API call on a `BufferResource` threads `handle.offset` + the + * appropriate byte range through `queue.writeBuffer` so a future `SlabBufferManager` (one + * giant `GPUBuffer` carved into slices) is a drop-in replacement for `BatchPoolBufferManager`. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { StructDefinition, StructuredView, VariableDefinition } from 'webgpu-utils'; +import { makeShaderDataDefinitions, makeStructuredView } from 'webgpu-utils'; +import type { BufferHandle, BufferManager, BufferUsageFlags } from '../memory/types'; +import type { + ExternalTextureSlot, + ResourceSlot, + SamplerSlot, + StorageSlot, + StorageTextureSlot, + TextureSlot, + UniformSlot, +} from '../resources/resource'; +import type { StructDeclaration } from '../shaders'; + +// ---- Brand & shared base ---------------------------------------------------------------------- + +/** Brand symbol used by `isResource` to discriminate `Resource` objects at runtime. */ +export const RESOURCE_BRAND: unique symbol = Symbol.for('vis-core.webgpu.Resource'); + +/** Callback a resource invokes when a mutation (`commit()`) or teardown (`destroy()`) means + * any cached `GPUBindGroup` referencing it must be dropped. Wired by `RenderingContext` so + * the bind-group cache is swept automatically; resources built outside a context leave it + * unset and rely on `ctx.sweepBindGroups(...)`. */ +export type ResourceInvalidateHook = (resource: Resource) => void; + +/** Brand + lifecycle fields shared by every `Resource` variant — including slot-less ones + * such as `RawBufferResource` (used for vertex/index buffers in `Drawable`). */ +interface ResourceLifecycle { + readonly __brand: typeof RESOURCE_BRAND; + /** Globally-unique identity (UUIDv4), stable for the resource's lifetime. Distinguishes + * two resources that share a slot + `version` in the bind-group cache key. */ + readonly id: string; + /** Monotonically increasing version, bumped on every committed mutation. Drives the + * bind-group cache invalidation key in Phase 7. */ + readonly version: number; + /** `true` once the underlying GPU resource has been released. */ + readonly disposed: boolean; + /** Current refcount; starts at `1`, incremented by `share()`, decremented by `destroy()`. */ + readonly refcount: number; + /** Increment the refcount and return `this`. Use for explicit cross-Drawable sharing. */ + share(): this; + /** Decrement the refcount; release the underlying GPU resource when it reaches `0`. */ + destroy(): void; +} + +/** Fields common to every slot-bound `Resource` variant (everything except `RawBufferResource`). */ +interface ResourceCommon extends ResourceLifecycle { + readonly kind: ResourceSlot['kind']; + readonly slot: ResourceSlot; +} + +// ---- BufferResource --------------------------------------------------------------------------- + +/** + * Buffer-backed resource for `slot.uniform` / `slot.storage` declarations. + * + * `view` is a `webgpu-utils` `StructuredView` over `arrayBuffer`; mutate via `set()` / + * `setField()` then `commit(device)` to upload to the GPU. `view.views.` exposes + * the raw typed-array views per struct member for advanced patterns (e.g. partial-region + * uploads) that the typed `set()` API doesn't cover. + * + * `T` is the host-side shape derived from the slot's `StructDecl`; `unknown` when the slot + * carried only a raw WGSL type identifier. + */ +export interface BufferResource extends ResourceCommon { + readonly kind: 'uniform' | 'storage'; + readonly slot: UniformSlot | StorageSlot; + /** Underlying `BufferHandle` (carries `gpu` + `offset` + `size`). */ + readonly handle: BufferHandle; + /** The `webgpu-utils` structured view backing `set()` / `setField()`. */ + readonly view: StructuredView; + /** The CPU-side `ArrayBuffer` that `commit()` uploads. Alias for `view.arrayBuffer`. */ + readonly arrayBuffer: ArrayBuffer; + /** Usage flag-set this resource's `handle` was allocated with. */ + readonly usage: BufferUsageFlags; + /** Typed bulk update: `view.set(values)` followed by a `version` bump on next `commit`. */ + set(values: Partial): void; + /** Typed field update: convenience over `view.views[k] = v; nextCommitBumps()`. */ + setField(key: K, value: T[K]): void; + /** Flush the CPU-side `arrayBuffer` to GPU via `queue.writeBuffer(gpu, offset, ...)`. */ + commit(device: GPUDevice): void; +} + +// ---- RawBufferResource ------------------------------------------------------------------------ + +/** + * Slot-less buffer wrapper used for vertex / index buffers in `Drawable`. Unlike + * `BufferResource`, a `RawBufferResource` has no `ResourceSlot`, no `StructuredView`, and + * no typed `set()` / `setField()` — it exists solely to carry a `BufferHandle` through the + * `Drawable` lifecycle with proper refcount + share + destroy semantics. + * + * `ctx.drawable()` constructs these internally when given a raw-arrays vertex/index input; + * callers building drawables from pre-allocated `BufferHandle`s use `makeRawBufferResource` + * directly. + */ +export interface RawBufferResource extends ResourceLifecycle { + readonly kind: 'rawBuffer'; + /** Underlying `BufferHandle` (carries `gpu` + `offset` + `size`). */ + readonly handle: BufferHandle; + /** Usage flag-set the handle was allocated with. */ + readonly usage: BufferUsageFlags; + /** Optional debug label, threaded through error messages. */ + readonly label?: string; +} + +// ---- Texture / Sampler / External wrappers --------------------------------------------------- + +/** Wrapper around a pre-built `GPUTexture` (+ default view) for sampled-texture bindings. */ +export interface TextureResource extends ResourceCommon { + readonly kind: 'texture'; + readonly slot: TextureSlot; + readonly texture: GPUTexture; + readonly view: GPUTextureView; +} + +/** Wrapper around a `GPUTexture` whose usage includes `STORAGE_BINDING`. */ +export interface StorageTextureResource extends ResourceCommon { + readonly kind: 'storageTexture'; + readonly slot: StorageTextureSlot; + readonly texture: GPUTexture; + readonly view: GPUTextureView; +} + +/** Wrapper around a `GPUSampler`. */ +export interface SamplerResource extends ResourceCommon { + readonly kind: 'sampler'; + readonly slot: SamplerSlot; + readonly sampler: GPUSampler; +} + +/** Wrapper around a `GPUExternalTexture` (typically `device.importExternalTexture(...)`). */ +export interface ExternalTextureResource extends ResourceCommon { + readonly kind: 'externalTexture'; + readonly slot: ExternalTextureSlot; + readonly external: GPUExternalTexture; +} + +/** Discriminated union of every concrete `Resource` flavor. */ +export type Resource = + | BufferResource + | RawBufferResource + | StorageTextureResource + | TextureResource + | SamplerResource + | ExternalTextureResource; + +/** Runtime discriminator for `Resource` (mirror of `isResourceSlot`). */ +export function isResource(value: unknown): value is Resource { + return ( + typeof value === 'object' && + value !== null && + '__brand' in value && + (value as { __brand: unknown }).__brand === RESOURCE_BRAND + ); +} + +// ---- Init shapes ------------------------------------------------------------------------------ + +/** + * Per-kind initializers accepted by `ctx.resource(slot, init?)`. + * + * - Buffer slots accept an optional `Partial` of initial values (committed during + * construction so the first `commit()` after construction is a no-op until a `set()` runs). + * - Texture / storage-texture slots require a pre-built `GPUTexture` (and optional `view`). + * - Sampler slots accept either a pre-built `GPUSampler` or a `GPUSamplerDescriptor` (in which + * case the constructor calls `device.createSampler(descriptor)`). + * - External-texture slots require a pre-built `GPUExternalTexture`. + */ +export type ResourceInit = S extends UniformSlot + ? Partial> | undefined + : S extends StorageSlot + ? Partial> | undefined + : S extends TextureSlot + ? { texture: GPUTexture; view?: GPUTextureView } + : S extends StorageTextureSlot + ? { texture: GPUTexture; view?: GPUTextureView } + : S extends SamplerSlot + ? GPUSampler | GPUSamplerDescriptor | undefined + : S extends ExternalTextureSlot + ? GPUExternalTexture + : never; + +// ---- Reflection cache ------------------------------------------------------------------------- + +/** + * Per-context memo of `(slot) → webgpu-utils VariableDefinition`, keyed by slot identity. + * Owned by the `RenderingContext` (created via `makeSlotReflectionCache()`) and threaded into + * `makeBufferResource` so the reflection cost is paid once per (context, slot) instead of via + * module-global state. Slots whose `type` is a raw WGSL identifier (no `StructDeclaration`) + * cache `undefined` so subsequent lookups stay fast. + */ +export type SlotReflectionCache = WeakMap; + +/** Construct an empty {@link SlotReflectionCache}. */ +export function makeSlotReflectionCache(): SlotReflectionCache { + return new WeakMap(); +} + +/** + * Return the `VariableDefinition` reflected from `slot.type`, or `undefined` if not a struct. + * When a `cache` is supplied the result is memoized on it (checking `has` so a cached + * `undefined` still short-circuits); without a cache the reflection is recomputed each call. + */ +function reflectSlot( + slot: UniformSlot | StorageSlot, + cache?: SlotReflectionCache +): VariableDefinition | undefined { + if (cache?.has(slot)) return cache.get(slot); + const def = computeSlotDef(slot); + cache?.set(slot, def); + return def; +} + +function computeSlotDef(slot: UniformSlot | StorageSlot): VariableDefinition | undefined { + const t = slot.type; + if (typeof t === 'string') return undefined; + if (!isStructDeclaration(t)) return undefined; + const addressSpace = + slot.kind === 'storage' + ? slot.accessMode !== undefined + ? `storage, ${slot.accessMode}` + : 'storage' + : 'uniform'; + const wgsl = `${t.__gen()};\n@group(0) @binding(0) var<${addressSpace}> __slot: ${t.name};`; + const defs = makeShaderDataDefinitions(wgsl); + return slot.kind === 'storage' ? defs.storages.__slot : defs.uniforms.__slot; +} + +function isStructDeclaration(t: unknown): t is StructDeclaration { + return ( + typeof t === 'object' && + t !== null && + '__identType' in t && + (t as { __identType: unknown }).__identType === 'struct' + ); +} + +// ---- Construction helpers (used by RenderingContext.resource) -------------------------------- + +/** + * Default minimum `GPUBufferUsage` flag-set for a buffer-backed slot. `RenderingContext` + * uses this when the caller doesn't supply an explicit usage (the common path). + */ +export function defaultBufferUsageFor(slot: UniformSlot | StorageSlot): BufferUsageFlags { + return slot.kind === 'storage' + ? GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + : GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST; +} + +/** + * Construct a `BufferResource` for a buffer-backed slot. Internal — call sites go through + * `ctx.resource(slot, init?)`. + */ +export function makeBufferResource( + slot: UniformSlot | StorageSlot, + bufferManager: BufferManager, + init: Partial | undefined, + cache?: SlotReflectionCache, + onInvalidate?: ResourceInvalidateHook +): BufferResource { + const def = reflectSlot(slot, cache); + if (def === undefined) { + throw new Error( + `RenderingContext.resource: slot '${slot.name}' (kind '${slot.kind}') must carry a ` + + 'StructDecl type (see `struct(...)` in shaders) to construct a BufferResource. ' + + 'Raw WGSL string types are not supported in v1.' + ); + } + // `makeStructuredView` accepts both `VariableDefinition` and `StructDefinition`; we pass the + // `VariableDefinition` so it picks the correct `typeDefinition` internally. + const view = makeStructuredView(def); + const sizeBytes = view.arrayBuffer.byteLength; + const usage = defaultBufferUsageFor(slot); + // Fast-fail at the budget boundary before issuing any partial allocations. + if (!bufferManager.precheck(sizeBytes)) { + throw new Error( + `RenderingContext.resource: buffer-manager precheck refused ${sizeBytes} B for slot ` + + `'${slot.name}' (kind '${slot.kind}'). The request exceeds the current memory ` + + 'budget and no batches can be evicted to satisfy it.' + ); + } + const handle = bufferManager.acquireForSlot(slot, sizeBytes, usage); + + let refcount = 1; + let version = 0; + let disposed = false; + + const resource = { + __brand: RESOURCE_BRAND, + id: uuidv4(), + kind: slot.kind, + slot, + handle, + view, + arrayBuffer: view.arrayBuffer, + usage, + get version(): number { + return version; + }, + get refcount(): number { + return refcount; + }, + get disposed(): boolean { + return disposed; + }, + set(values: Partial): void { + if (disposed) throw new Error(`BufferResource '${slot.name}': set() after destroy().`); + view.set(values as unknown); + }, + setField(key: K, value: T[K]): void { + if (disposed) throw new Error(`BufferResource '${slot.name}': setField() after destroy().`); + const views = view.views as Record; + const target = views[key as string]; + if (target === undefined) { + throw new Error( + `BufferResource '${slot.name}': setField('${String(key)}') — no such field in struct '${(slot.type as StructDeclaration).name}'.` + ); + } + // Reuse `set()` for typed-array vs scalar handling. + view.set({ [key as string]: value } as unknown); + }, + commit(device: GPUDevice): void { + if (disposed) throw new Error(`BufferResource '${slot.name}': commit() after destroy().`); + device.queue.writeBuffer(handle.gpu, handle.offset, view.arrayBuffer); + version += 1; + onInvalidate?.(resource); + }, + share(): BufferResource { + if (disposed) throw new Error(`BufferResource '${slot.name}': share() after destroy().`); + refcount += 1; + return resource; + }, + destroy(): void { + if (disposed) return; + refcount -= 1; + if (refcount > 0) return; + handle.release(); + disposed = true; + onInvalidate?.(resource); + }, + } satisfies BufferResource & { share(): BufferResource }; + + // Seed initial values without a `commit` — defer device upload until the caller chooses. + if (init !== undefined) { + view.set(init as unknown); + } + return resource as BufferResource; +} + +/** Construct a `SamplerResource` from either a prebuilt `GPUSampler` or a descriptor. */ +export function makeSamplerResource( + slot: SamplerSlot, + device: GPUDevice, + init: GPUSampler | GPUSamplerDescriptor | undefined, + onInvalidate?: ResourceInvalidateHook +): SamplerResource { + const sampler = + init === undefined + ? device.createSampler() + : isGPUSampler(init) + ? init + : device.createSampler(init); + + let refcount = 1; + let disposed = false; + + const resource = { + __brand: RESOURCE_BRAND, + id: uuidv4(), + kind: 'sampler' as const, + slot, + sampler, + version: 0, + get refcount(): number { + return refcount; + }, + get disposed(): boolean { + return disposed; + }, + share(): SamplerResource { + if (disposed) throw new Error(`SamplerResource '${slot.name}': share() after destroy().`); + refcount += 1; + return resource; + }, + destroy(): void { + if (disposed) return; + refcount -= 1; + if (refcount > 0) return; + // GPUSampler has no .destroy() — drop the reference and mark disposed. + disposed = true; + onInvalidate?.(resource); + }, + } satisfies SamplerResource & { share(): SamplerResource }; + + return resource as SamplerResource; +} + +/** Construct a `TextureResource` from a prebuilt `GPUTexture` (+ optional explicit view). */ +export function makeTextureResource( + slot: TextureSlot, + init: { texture: GPUTexture; view?: GPUTextureView }, + onInvalidate?: ResourceInvalidateHook +): TextureResource { + const texture = init.texture; + const view = init.view ?? texture.createView(); + + let refcount = 1; + let disposed = false; + + const resource = { + __brand: RESOURCE_BRAND, + id: uuidv4(), + kind: 'texture' as const, + slot, + texture, + view, + version: 0, + get refcount(): number { + return refcount; + }, + get disposed(): boolean { + return disposed; + }, + share(): TextureResource { + if (disposed) throw new Error(`TextureResource '${slot.name}': share() after destroy().`); + refcount += 1; + return resource; + }, + destroy(): void { + if (disposed) return; + refcount -= 1; + if (refcount > 0) return; + texture.destroy(); + disposed = true; + onInvalidate?.(resource); + }, + } satisfies TextureResource & { share(): TextureResource }; + + return resource as TextureResource; +} + +/** Construct a `StorageTextureResource` from a prebuilt `GPUTexture`. */ +export function makeStorageTextureResource( + slot: StorageTextureSlot, + init: { texture: GPUTexture; view?: GPUTextureView }, + onInvalidate?: ResourceInvalidateHook +): StorageTextureResource { + const texture = init.texture; + const view = init.view ?? texture.createView(); + + let refcount = 1; + let disposed = false; + + const resource = { + __brand: RESOURCE_BRAND, + id: uuidv4(), + kind: 'storageTexture' as const, + slot, + texture, + view, + version: 0, + get refcount(): number { + return refcount; + }, + get disposed(): boolean { + return disposed; + }, + share(): StorageTextureResource { + if (disposed) throw new Error(`StorageTextureResource '${slot.name}': share() after destroy().`); + refcount += 1; + return resource; + }, + destroy(): void { + if (disposed) return; + refcount -= 1; + if (refcount > 0) return; + texture.destroy(); + disposed = true; + onInvalidate?.(resource); + }, + } satisfies StorageTextureResource & { share(): StorageTextureResource }; + + return resource as StorageTextureResource; +} + +/** Construct an `ExternalTextureResource` from a prebuilt `GPUExternalTexture`. */ +export function makeExternalTextureResource( + slot: ExternalTextureSlot, + external: GPUExternalTexture, + onInvalidate?: ResourceInvalidateHook +): ExternalTextureResource { + let refcount = 1; + let disposed = false; + + const resource = { + __brand: RESOURCE_BRAND, + id: uuidv4(), + kind: 'externalTexture' as const, + slot, + external, + version: 0, + get refcount(): number { + return refcount; + }, + get disposed(): boolean { + return disposed; + }, + share(): ExternalTextureResource { + if (disposed) throw new Error(`ExternalTextureResource '${slot.name}': share() after destroy().`); + refcount += 1; + return resource; + }, + destroy(): void { + if (disposed) return; + refcount -= 1; + if (refcount > 0) return; + // GPUExternalTexture has no .destroy() in v1 spec — drop the reference and mark disposed. + disposed = true; + onInvalidate?.(resource); + }, + } satisfies ExternalTextureResource & { share(): ExternalTextureResource }; + + return resource as ExternalTextureResource; +} + +/** + * Construct a `RawBufferResource` wrapping a pre-allocated `BufferHandle`. Used by + * `ctx.drawable()` for raw-arrays vertex / index buffers; also re-exported for callers who + * manage their own `BufferManager` allocations and want a refcounted lifecycle wrapper. + * + * The wrapper takes ownership of the handle: `destroy()` (when refcount hits 0) calls + * `handle.release()`. Callers must not invoke `handle.release()` themselves while a + * `RawBufferResource` holds a reference. + */ +export function makeRawBufferResource( + handle: BufferHandle, + usage: BufferUsageFlags, + label?: string, + onInvalidate?: ResourceInvalidateHook +): RawBufferResource { + let refcount = 1; + let disposed = false; + + const resource: RawBufferResource & { share(): RawBufferResource } = { + __brand: RESOURCE_BRAND, + id: uuidv4(), + kind: 'rawBuffer' as const, + handle, + usage, + ...(label !== undefined && { label }), + version: 0, + get refcount(): number { + return refcount; + }, + get disposed(): boolean { + return disposed; + }, + share(): RawBufferResource { + if (disposed) { + throw new Error( + `RawBufferResource${label !== undefined ? ` '${label}'` : ''}: share() after destroy().` + ); + } + refcount += 1; + return resource; + }, + destroy(): void { + if (disposed) return; + refcount -= 1; + if (refcount > 0) return; + handle.release(); + disposed = true; + onInvalidate?.(resource); + }, + }; + + return resource; +} + +// ---- Internal helpers ------------------------------------------------------------------------- + +function isGPUSampler(v: unknown): v is GPUSampler { + return ( + typeof v === 'object' && + v !== null && + // GPUSampler has a `label` getter and no method named `addressModeU` (those live on the + // descriptor). Brand-check via class-string when available; fall back to descriptor + // detection (absence of any descriptor-only properties). + !('addressModeU' in v) && + !('magFilter' in v) + ); +} + +/** Internal export used by the test suite to exercise reflection in isolation. */ +export const __internal = { + isStructDeclaration, +} as const; + +// Avoid an "unused import" error when only the StructDefinition type is referenced via JSDoc. +export type { StructDefinition }; diff --git a/packages/core/src/rendering/webgpu/drawable-typed-vertex.test.ts b/packages/core/src/rendering/webgpu/drawable-typed-vertex.test.ts new file mode 100644 index 00000000..49ad45e3 --- /dev/null +++ b/packages/core/src/rendering/webgpu/drawable-typed-vertex.test.ts @@ -0,0 +1,134 @@ +/** + * Integration test: the declaration-driven typed vertex-input path on `ctx.drawable(...)`. + * Verifies that a `vertexLayout(...)` used for BOTH pipeline state and the drawable's typed + * vertex data produces the expected interleaved buffers (correct byte sizes) allocated through + * the buffer manager. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { renderingContext } from './context'; +import type { BufferHandle, BufferManager, BufferManagerStats } from './memory/types'; +import { bindings, group } from './pipelines/binding-graph'; +import { buffer, vertexLayout } from './pipelines/vertex-layout'; +import { uniformSlot } from './resources'; +import { member, shader, struct } from './shaders'; +import { location } from './shaders/attributes'; +import { vertexInput } from './shaders/vertex-interface'; +import { makeMockDevice } from './test/mock-device'; + +type CameraShape = { view: readonly number[]; proj: readonly number[] }; +const cameraStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +const colorState = () => ({ + primitive: { topology: 'triangle-list' as const }, + fragment: { targets: [{ format: 'bgra8unorm' as const }] }, +}); + +function makeRecordingBM(device: GPUDevice): { + bm: BufferManager; + acquired: number[]; +} { + const acquired: number[] = []; + const make = (sizeBytes: number, usage: GPUBufferUsageFlags): BufferHandle => { + const gpu = device.createBuffer({ size: sizeBytes, usage }); + return { + gpu, + buffer: gpu, + offset: 0, + size: sizeBytes, + sizeBytes, + bucketSize: sizeBytes, + usage, + release: vi.fn(), + sizeInBytes: () => sizeBytes, + destroy: vi.fn(), + }; + }; + const bm: BufferManager = { + acquire: vi.fn((sizeBytes: number, usage: GPUBufferUsageFlags) => { + acquired.push(sizeBytes); + return make(sizeBytes, usage); + }), + acquireForSlot: vi.fn((_slot: unknown, sizeBytes: number, usage: GPUBufferUsageFlags) => + make(sizeBytes, usage) + ), + precheck: vi.fn(() => true), + release: vi.fn(), + endFrame: vi.fn(), + frameLease: vi.fn(() => { + throw new Error('frameLease not supported'); + }) as unknown as BufferManager['frameLease'], + stats: vi.fn((): BufferManagerStats => ({ residentBytes: 0, leasedBytes: 0, freeBytes: 0 })), + dispose: vi.fn(), + }; + return { bm, acquired }; +} + +describe('ctx.drawable() — typed vertex input', () => { + it('interleaves per-vertex + per-instance buffers from a declared layout', () => { + const m = makeMockDevice(); + const { bm, acquired } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const cam = uniformSlot('camera', cameraStruct); + const sh = shader([cameraStruct, cam]); + const graph = bindings(group(cam), sh); + + const vin = vertexInput([ + struct('Geo', [member('position', 'vec3f', [location(0)])]), // stride 12 + struct('Inst', [ + member('offset', 'vec3f', [location(1)]), + member('tint', 'vec4f', [location(2)]), + ]), // stride 16 (tint overridden to unorm8x4) + ]); + const layout = vertexLayout(vin, [ + buffer('vertex', [0]), + buffer('instance', [1, [2, 'unorm8x4']]), + ]); + + const pipeline = ctx.pipeline(graph, sh, { ...colorState(), vertex: { layout } }); + const camRes = ctx.resource(cam); + + const drawable = ctx.drawable({ + pipeline, + vertex: { + kind: 'typed', + layout, + data: { + position: [0, 0, 0, 1, 0, 0, 0, 1, 0], // 3 vertices + offset: [10, 10, 10, 20, 20, 20], // 2 instances + tint: [255, 0, 0, 255, 0, 255, 0, 255], + }, + }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 3, instanceCount: 2 }, + }); + + // Two vertex buffers produced, keyed by slot index. + expect(drawable.vertexBuffers.size).toBe(2); + expect(drawable.vertexBuffers.has(0)).toBe(true); + expect(drawable.vertexBuffers.has(1)).toBe(true); + + // Geo: 3 verts * stride 12 = 36; Inst: 2 instances * stride 16 = 32. + expect(acquired).toContain(36); + expect(acquired).toContain(32); + + // The pipeline derived its vertex.buffers from the same layout. + expect(pipeline.state.vertex.buffers).toEqual([ + { arrayStride: 12, attributes: [{ format: 'float32x3', offset: 0, shaderLocation: 0 }] }, + { + arrayStride: 16, + stepMode: 'instance', + attributes: [ + { format: 'float32x3', offset: 0, shaderLocation: 1 }, + { format: 'unorm8x4', offset: 12, shaderLocation: 2 }, + ], + }, + ]); + + drawable.destroy(); + }); +}); diff --git a/packages/core/src/rendering/webgpu/drawable.test.ts b/packages/core/src/rendering/webgpu/drawable.test.ts new file mode 100644 index 00000000..01276f48 --- /dev/null +++ b/packages/core/src/rendering/webgpu/drawable.test.ts @@ -0,0 +1,570 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { BufferHandle, BufferManager, BufferManagerStats } from './memory/types'; +import { renderingContext } from './context'; +import { + isResource, + makeRawBufferResource, + type RawBufferResource, +} from './data/resource'; +import { isDrawable, type DrawableSpec } from './drawable'; +import { bindings, group } from './pipelines/binding-graph'; +import { samplerSlot, uniformSlot } from './resources'; +import { member, shader, struct } from './shaders'; +import { makeMockDevice } from './test/mock-device'; + +// ----- shared fixtures ----------------------------------------------------- + +type Camera = { + view: readonly number[]; + proj: readonly number[]; +}; + +const cameraStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +function pipelineFixture() { + const cam = uniformSlot('camera', cameraStruct); + const samp = samplerSlot('linear', 'sampler'); + const root = group(cam, samp); + const sh = shader([cameraStruct, cam, samp]); + const graph = bindings(root, sh); + return { cam, samp, root, sh, graph }; +} + +const colorState = () => ({ + primitive: { topology: 'triangle-list' as const }, + fragment: { targets: [{ format: 'bgra8unorm' as const }] }, +}); + +/** Minimal recording `BufferManager`. Issues real-shape handles backed by the mock device, + * records every acquired / released handle, and runs every `acquire` through `device.createBuffer` + * so the mock-device call recorder sees the activity. */ +function makeRecordingBM(device: GPUDevice, slabOffset = 0): { + bm: BufferManager; + acquired: Array<{ sizeBytes: number; usage: GPUBufferUsageFlags; handle: BufferHandle }>; + released: BufferHandle[]; +} { + const acquired: Array<{ + sizeBytes: number; + usage: GPUBufferUsageFlags; + handle: BufferHandle; + }> = []; + const released: BufferHandle[] = []; + + const make = (sizeBytes: number, usage: GPUBufferUsageFlags): BufferHandle => { + const gpu = device.createBuffer({ size: sizeBytes + slabOffset, usage }); + const handle: BufferHandle = { + gpu, + buffer: gpu, + offset: slabOffset, + size: sizeBytes, + sizeBytes, + bucketSize: sizeBytes, + usage, + release(): void { + released.push(handle); + }, + sizeInBytes(): number { + return sizeBytes; + }, + destroy(): void { + released.push(handle); + }, + }; + acquired.push({ sizeBytes, usage, handle }); + return handle; + }; + + const bm: BufferManager = { + acquire: vi.fn(make), + acquireForSlot: vi.fn( + (_slot: unknown, sizeBytes: number, usage: GPUBufferUsageFlags) => + make(sizeBytes, usage) + ), + precheck: vi.fn(() => true), + release: vi.fn((h: BufferHandle) => released.push(h)), + endFrame: vi.fn(), + frameLease: vi.fn(() => { + throw new Error('frameLease: not supported in this mock'); + }) as unknown as BufferManager['frameLease'], + stats: vi.fn( + (): BufferManagerStats => ({ residentBytes: 0, leasedBytes: 0, freeBytes: 0 }) + ), + dispose: vi.fn(), + }; + return { bm, acquired, released }; +} + +// ============================================================================================= +// ctx.drawable() — construction (raw-arrays vertex path) +// ============================================================================================= + +describe('ctx.drawable() — raw-arrays vertex / index path', () => { + it('allocates vertex + index buffers through the bufferManager and uploads via writeBuffer', () => { + const m = makeMockDevice(); + const { bm, acquired } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + const positions = new Float32Array([0, 0, 1, 0, 0, 1]); // 3 vec2f vertices + const indices = new Uint16Array([0, 1, 2]); + + const spec: DrawableSpec = { + pipeline, + vertex: { kind: 'arrays', arrays: { position: positions } }, + index: { kind: 'arrays', data: indices }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'indexed', indexCount: 3 }, + label: 'tri', + }; + + const drawable = ctx.drawable(spec); + + expect(isDrawable(drawable)).toBe(true); + expect(drawable.pipeline).toBe(pipeline); + expect(drawable.draw).toEqual({ kind: 'indexed', indexCount: 3 }); + expect(drawable.vertexBuffers.size).toBe(1); + expect(drawable.indexBuffer).toBeDefined(); + expect(drawable.indexBuffer?.format).toBe('uint16'); + expect(drawable.bindings.size).toBe(2); + + // Buffer manager was asked for one VERTEX buffer + one INDEX buffer. + expect(acquired.filter((a) => (a.usage & GPUBufferUsage.VERTEX) !== 0).length).toBe(1); + expect(acquired.filter((a) => (a.usage & GPUBufferUsage.INDEX) !== 0).length).toBe(1); + + // queue.writeBuffer was called for both the vertex bytes and the index bytes. + const writes = m.calls.writeBuffer.mock.calls; + expect(writes.length).toBe(2); + }); + + it('threads `handle.offset` into queue.writeBuffer (slab-manager readiness)', () => { + const m = makeMockDevice(); + const SLAB_OFFSET = 1024; + const { bm } = makeRecordingBM(m.device, SLAB_OFFSET); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + const positions = new Float32Array([0, 0, 1, 0, 0, 1]); + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: positions } }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 3 }, + }); + + // Every writeBuffer call records its `bufferOffset` — verify the slab offset was + // threaded through (vertex buffer at offset 1024, not 0). + const offsets = m.calls.writeBuffer.mock.calls.map((c) => c[1]); + expect(offsets).toContain(SLAB_OFFSET); + }); + + it('throws when raw-arrays vertex input is supplied without a bufferManager', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, label: 'no-bm' }); + + const { samp, graph, sh } = pipelineFixture(); + // Build a pipeline against the same ctx (works without bufferManager — pipeline build + // doesn't allocate any buffers). + const pipeline = ctx.pipeline(graph, sh, colorState()); + + // We need *some* Resource objects for the bindings — fall back to a slot-less + // RawBufferResource via a hand-rolled handle (no real BufferManager required). + const camRes = {} as unknown as ReturnType; + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + expect(() => + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0]) } }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ).toThrow(/bufferManager/); + }); + + // Phase 8: precheck integration on the raw-arrays vertex path. + it('throws when bufferManager.precheck refuses a vertex allocation', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + // Precheck returns true for the camera-uniform allocation (so `ctx.resource(cam)` + // succeeds), then returns false for the vertex allocation. + (bm.precheck as ReturnType) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + expect(() => + ctx.drawable({ + pipeline, + vertex: { + kind: 'arrays', + arrays: { position: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) }, + }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 3 }, + }) + ).toThrow(/precheck refused/); + }); +}); + +// ============================================================================================= +// ctx.drawable() — binding validation +// ============================================================================================= + +describe('ctx.drawable() — binding validation', () => { + it('throws when a required slot is missing from bindings', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + expect(() => + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + // Missing `linear` (sampler) slot. + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ).toThrow(/missing binding for slot 'linear'/); + }); + + it('throws when a binding\'s kind does not match the slot kind', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + expect(() => + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + // Swap sampler resource into the camera (uniform) slot — kind mismatch. + bindings: { camera: sampRes, linear: sampRes.share() }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ).toThrow(/binding kind mismatch/); + }); + + it('throws when an indexed draw call lacks an `index` input', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + expect(() => + ctx.drawable({ + pipeline, + vertex: { + kind: 'arrays', + arrays: { position: new Float32Array([0, 0, 0, 1, 1, 1]) }, + }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'indexed', indexCount: 3 }, + }) + ).toThrow(/indexed.*requires an `index` input/); + }); + + it('throws when ctx is disposed', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm, label: 'X' }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + ctx.dispose(); + + expect(() => + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ).toThrow(/use-after-dispose/); + }); +}); + +// ============================================================================================= +// ctx.drawable() — pre-built vertex / index path +// ============================================================================================= + +describe('ctx.drawable() — pre-built vertex / index path', () => { + it('share()s pre-built BufferResources and does NOT allocate through bufferManager', () => { + const m = makeMockDevice(); + const { bm, acquired } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + // Hand-roll a vertex buffer outside the drawable path so we can verify it's NOT + // re-allocated when supplied pre-built. + const vHandle = bm.acquire(64, GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST); + const vRes: RawBufferResource = makeRawBufferResource( + vHandle, + GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST + ); + const allocsBefore = acquired.length; + expect(vRes.refcount).toBe(1); + + const preBuilt: ReadonlyMap = new Map([[0, vRes]]); + const drawable = ctx.drawable({ + pipeline, + vertex: preBuilt, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 3 }, + }); + + // No new vertex allocations through the BM. + expect(acquired.length).toBe(allocsBefore); + // The drawable shared vRes — refcount is now 2. + expect(vRes.refcount).toBe(2); + + drawable.destroy(); + // Drawable.destroy() decrefs vRes back to 1; caller still owns it. + expect(vRes.refcount).toBe(1); + expect(vRes.disposed).toBe(false); + vRes.destroy(); + expect(vRes.refcount).toBe(0); + expect(vRes.disposed).toBe(true); + }); + + it('rejects a pre-built vertex resource without VERTEX usage', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + // Build a handle with only UNIFORM usage — wrong for vertex. + const wrongHandle = bm.acquire(64, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST); + const wrongRes = makeRawBufferResource( + wrongHandle, + GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + ); + const preBuilt: ReadonlyMap = new Map([[0, wrongRes]]); + + expect(() => + ctx.drawable({ + pipeline, + vertex: preBuilt, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ).toThrow(/GPUBufferUsage\.VERTEX/); + }); +}); + +// ============================================================================================= +// Drawable.destroy() — refcount semantics +// ============================================================================================= + +describe('drawable.destroy()', () => { + it('decrefs every owned resource exactly once (raw-arrays path releases handles to 0)', () => { + const m = makeMockDevice(); + const { bm, acquired, released } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + const drawable = ctx.drawable({ + pipeline, + vertex: { + kind: 'arrays', + arrays: { position: new Float32Array([0, 0, 1, 0, 0, 1]) }, + }, + index: { kind: 'arrays', data: new Uint16Array([0, 1, 2]) }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'indexed', indexCount: 3 }, + }); + + // Bindings were share()'d into the drawable — caller refcounts are 2. + expect(camRes.refcount).toBe(2); + + const vertexAllocs = acquired.filter((a) => (a.usage & GPUBufferUsage.VERTEX) !== 0); + const indexAllocs = acquired.filter((a) => (a.usage & GPUBufferUsage.INDEX) !== 0); + expect(vertexAllocs.length).toBeGreaterThan(0); + expect(indexAllocs.length).toBeGreaterThan(0); + + drawable.destroy(); + + // Owned (raw-arrays) handles released; bindings decref'd back to caller's 1. + for (const a of vertexAllocs) expect(released).toContain(a.handle); + for (const a of indexAllocs) expect(released).toContain(a.handle); + expect(camRes.refcount).toBe(1); + }); + + it('is idempotent (second destroy() call is a no-op)', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + const drawable = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + + drawable.destroy(); + expect(() => drawable.destroy()).not.toThrow(); + // camRes was already decref'd back to 1 on first destroy; second is no-op. + expect(camRes.refcount).toBe(1); + }); +}); + +// ============================================================================================= +// Drawable.reuse() — multi-pipeline pattern +// ============================================================================================= + +describe('drawable.reuse()', () => { + it('share()s vertex / index buffers + bindings into a sibling drawable', () => { + const m = makeMockDevice(); + const { bm, acquired } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const colorPipeline = ctx.pipeline(graph, sh, colorState()); + // Second pipeline against the same graph/shader but different state — produces a + // distinct BuiltPipeline. + const altState = { + ...colorState(), + primitive: { topology: 'line-list' as const }, + }; + const linePipeline = ctx.pipeline(graph, sh, altState); + + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + const first = ctx.drawable({ + pipeline: colorPipeline, + vertex: { + kind: 'arrays', + arrays: { position: new Float32Array([0, 0, 1, 0, 0, 1]) }, + }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 3 }, + }); + + const allocsAfterFirst = acquired.length; + const camRefBefore = camRes.refcount; + + const second = first.reuse({ pipeline: linePipeline }); + + // No new buffer allocations — vertex resource was share()'d. + expect(acquired.length).toBe(allocsAfterFirst); + // Caller's camRes refcount went up by one more (drawable + drawable). + expect(camRes.refcount).toBe(camRefBefore + 1); + expect(second.pipeline).toBe(linePipeline); + // Both drawables reference the same vertex resource. + const firstVRes = [...first.vertexBuffers.values()][0]?.resource; + const secondVRes = [...second.vertexBuffers.values()][0]?.resource; + expect(firstVRes).toBe(secondVRes); + + // Destroying one does not break the other. + first.destroy(); + expect(secondVRes?.disposed).toBe(false); + second.destroy(); + }); + + it('allows binding overrides per slot, sharing the rest from the base drawable', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + + const camA = ctx.resource(cam); + const camB = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + const baseline = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camA, linear: sampRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + + const variant = baseline.reuse({ bindings: { camera: camB } }); + + // The sampler resource is shared from baseline; camB is a fresh share. + expect(variant.bindings.get(samp)).toBe(sampRes); + expect(variant.bindings.get(cam)).toBe(camB); + expect(camA.refcount).toBe(2); // caller + baseline drawable + expect(camB.refcount).toBe(2); // caller + variant drawable + expect(sampRes.refcount).toBe(3); // caller + baseline + variant + }); +}); + +// ============================================================================================= +// isDrawable + miscellaneous helpers +// ============================================================================================= + +describe('isDrawable', () => { + it('returns true for a constructed drawable and false otherwise', () => { + const m = makeMockDevice(); + const { bm } = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, samp, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const sampRes = ctx.resource(samp, {} as unknown as GPUSampler); + + const drawable = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes, linear: sampRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + + expect(isDrawable(drawable)).toBe(true); + expect(isDrawable({})).toBe(false); + expect(isDrawable(null)).toBe(false); + expect(isDrawable(undefined)).toBe(false); + // A Resource is not a Drawable. + expect(isDrawable(camRes)).toBe(false); + expect(isResource(drawable)).toBe(false); + }); +}); diff --git a/packages/core/src/rendering/webgpu/drawable.ts b/packages/core/src/rendering/webgpu/drawable.ts new file mode 100644 index 00000000..ec0066bf --- /dev/null +++ b/packages/core/src/rendering/webgpu/drawable.ts @@ -0,0 +1,673 @@ +/** + * `Drawable` — a `BuiltPipeline` + its concrete vertex / index / binding `Resource`s + a + * `DrawCall`. The atomic unit emitted by Phase 6 `Scene`'s `draw(...)` leaves and rendered by + * the Phase 7 `Encoder`. + * + * Construction funnels through `RenderingContext.drawable(spec)`; this module exports the + * public types (`Drawable`, `DrawCall`, `DrawableSpec`, …) and the internal `buildDrawable` + * helper invoked by the context. + * + * **Lifecycle**: every `Drawable` is constructed with `refcount === 1` on each of the + * resources it owns. The drawable always calls `share()` on a caller-supplied pre-built + * `Resource`, so the caller retains its own refcount independently of the drawable's. When + * `drawable.destroy()` runs it decrefs every owned resource exactly once — pre-built ones + * fall back to the caller's refcount, freshly-created ones (the raw-arrays path) drop to 0 + * and release their `BufferHandle`s. + * + * **Slab-readiness**: every vertex / index allocation goes through `bufferManager.acquire` + * (the raw-arrays path) — `device.createBuffer` is never called from this module. Uploads + * use `queue.writeBuffer(handle.gpu, handle.offset, data)` so a future `SlabBufferManager` + * is a drop-in replacement. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { Arrays, ArraysOptions } from 'webgpu-utils'; +import { createBufferLayoutsFromArrays } from 'webgpu-utils'; +import type { RenderingContext } from './context-types'; +import { + type BufferResource, + isResource, + makeRawBufferResource, + type RawBufferResource, + type Resource, +} from './data/resource'; +import type { BuiltPipeline } from './pipelines/build'; +import { + interleaveVertexBuffer, + type VertexAttrData, + type VertexLayoutDeclaration, +} from './pipelines/vertex-layout'; +import type { ResourceSlot } from './resources/resource'; + +// ---- Brand & identity ------------------------------------------------------------------------- + +/** Brand symbol used by `isDrawable` to discriminate `Drawable` objects at runtime. */ +export const DRAWABLE_BRAND: unique symbol = Symbol.for('vis-core.webgpu.Drawable'); + +// ---- DrawCall --------------------------------------------------------------------------------- + +/** Non-indexed draw — corresponds to `passEncoder.draw(...)`. */ +export interface ArrayDrawCall { + readonly kind: 'array'; + readonly vertexCount: number; + readonly instanceCount?: number; + readonly firstVertex?: number; + readonly firstInstance?: number; +} + +/** Indexed draw — corresponds to `passEncoder.drawIndexed(...)`. */ +export interface IndexedDrawCall { + readonly kind: 'indexed'; + readonly indexCount: number; + readonly instanceCount?: number; + readonly firstIndex?: number; + readonly baseVertex?: number; + readonly firstInstance?: number; +} + +/** Discriminated union of every draw-call variant. `kind: 'indexed'` requires an + * `indexBuffer` on the owning `Drawable`. */ +export type DrawCall = ArrayDrawCall | IndexedDrawCall; + +// ---- Drawable interfaces ---------------------------------------------------------------------- + +/** Bound vertex buffer for a single slot (matches `pipeline.vertex.buffers[slot]`). Carries the + * refcounted resource plus the layout the buffer was allocated against. */ +export interface VertexBufferBinding { + /** Refcount-managed wrapper around the underlying `BufferHandle`. */ + readonly resource: BufferResource | RawBufferResource; +} + +/** Bound index buffer for a `Drawable`. */ +export interface IndexBufferBinding { + readonly resource: BufferResource | RawBufferResource; + readonly format: GPUIndexFormat; +} + +/** The atomic, ready-to-encode draw unit. Frozen at construction. */ +export interface Drawable { + /** Stable per-instance id (UUID). */ + readonly id: string; + /** Optional debug label. Threaded through error messages. */ + readonly label?: string; + /** Brand for `isDrawable`. */ + readonly __brand: typeof DRAWABLE_BRAND; + /** The pipeline this drawable will render with. */ + readonly pipeline: BuiltPipeline; + /** Vertex buffers keyed by the **buffer slot index** (i.e. the index into + * `pipeline.state.vertex.buffers`), NOT a shader location. Each entry's + * resource carries a `BufferHandle` that the encoder feeds into + * `setVertexBuffer(slot, gpu, offset, size)`. */ + readonly vertexBuffers: ReadonlyMap; + /** Optional index buffer + format. Required when `draw.kind === 'indexed'`. */ + readonly indexBuffer?: IndexBufferBinding; + /** Resource per `ResourceSlot` in `pipeline.slotIndex`. Every required slot must be + * present; missing or kind-mismatched entries cause `ctx.drawable()` to throw. */ + readonly bindings: ReadonlyMap; + /** The draw-call descriptor consumed by the encoder. */ + readonly draw: DrawCall; + /** Decrement the refcount on every owned resource. Idempotent — second call is a no-op. */ + destroy(): void; + /** Construct a sibling `Drawable` sharing this one's vertex / index buffers (and any + * unspecified bindings) but using a new pipeline and/or binding overrides. Useful for + * multi-pipeline patterns (e.g. one geometry rendered with both a colour-pass and a + * picking-pass pipeline). */ + reuse(spec: DrawableReuseSpec): Drawable; +} + +/** Runtime discriminator for `Drawable`. */ +export function isDrawable(value: unknown): value is Drawable { + return ( + typeof value === 'object' && + value !== null && + '__brand' in value && + (value as { __brand: unknown }).__brand === DRAWABLE_BRAND + ); +} + +// ---- Spec / Input shapes ---------------------------------------------------------------------- + +/** Map. The keys are slot indices + * into `pipeline.state.vertex.buffers`; values are refcounted buffer wrappers. The drawable + * calls `share()` on each so the caller's refcount is preserved. */ +export type PreBuiltVertexInput = ReadonlyMap< + number, + BufferResource | RawBufferResource +>; + +/** + * Raw-arrays vertex input. The drawable runs `webgpu-utils.createBufferLayoutsFromArrays` on + * `arrays` to derive interleaved byte layouts (NOT to create GPU buffers — the buffer + * manager owns all GPU buffer creation), then allocates exactly one buffer per layout via + * `ctx.bufferManager.acquire` and uploads the interleaved bytes via `queue.writeBuffer`. + * + * Requires `ctx.bufferManager` to have been supplied at context construction. + */ +export interface RawArraysVertexInput { + readonly kind: 'arrays'; + /** Named arrays consumed by `webgpu-utils.createBufferLayoutsFromArrays`. */ + readonly arrays: Arrays; + /** Optional pass-through for stepMode / interleave / shaderLocation defaults. */ + readonly options?: ArraysOptions; + /** Optional override for the per-layout buffer slot index. Defaults to the layout's index + * in the returned `bufferLayouts` array (i.e. `[0, 1, 2, …]`). */ + readonly bufferSlots?: readonly number[]; +} + +/** Union of every accepted vertex input shape for `ctx.drawable({...})`. */ +export type VertexInput = PreBuiltVertexInput | RawArraysVertexInput | TypedVertexInput; + +/** + * Declaration-driven typed vertex input. Uses a `vertexLayout(...)` as the single source of truth + * for byte packing: `data` supplies each attribute's flat host array (keyed by attribute name), + * which is interleaved per the derived offsets/stride, allocated through `ctx.bufferManager`, and + * uploaded via `queue.writeBuffer`. One buffer is produced per entry in the layout. + * + * For `float16` attributes supply raw half-float bits; for normalized (`unorm*`/`snorm*`) formats + * supply pre-encoded integers — v1 does not auto-quantize floats. + */ +export interface TypedVertexInput { + readonly kind: 'typed'; + /** The layout declaration whose buffers/attributes drive packing. */ + readonly layout: VertexLayoutDeclaration; + /** Per-attribute host data, keyed by attribute name. */ + readonly data: Readonly>; + /** Optional override for the per-buffer slot index. Defaults to the buffer's index. */ + readonly bufferSlots?: readonly number[]; +} + +/** Pre-built index input — caller has already allocated a buffer and wrapped it. */ +export interface PreBuiltIndexInput { + readonly resource: BufferResource | RawBufferResource; + readonly format: GPUIndexFormat; +} + +/** Raw-array index input — the drawable allocates + uploads through `ctx.bufferManager`. */ +export interface RawArrayIndexInput { + readonly kind: 'arrays'; + /** Index data. `Uint16Array` ⇒ `format: 'uint16'`; `Uint32Array` ⇒ `'uint32'`. Plain + * `number[]` defaults to `uint32`. */ + readonly data: Uint16Array | Uint32Array | readonly number[]; + /** Override the auto-detected index format. */ + readonly format?: GPUIndexFormat; +} + +/** Union of every accepted index input shape for `ctx.drawable({...})`. */ +export type IndexInput = PreBuiltIndexInput | RawArrayIndexInput; + +/** Spec passed to `ctx.drawable({...})`. */ +export interface DrawableSpec { + /** The pipeline this drawable will render with. Must come from the same `RenderingContext` + * (or one targeting the same `GPUDevice`). */ + readonly pipeline: BuiltPipeline; + /** Vertex input — either a pre-built `Map` or a raw-arrays + * descriptor that allocates through `ctx.bufferManager`. */ + readonly vertex: VertexInput; + /** Optional index input. Required when `draw.kind === 'indexed'`. */ + readonly index?: IndexInput; + /** Bindings keyed by `ResourceSlot.name` (matching slots declared in `pipeline.slotIndex`) + * or directly by `ResourceSlot`. Every slot in `pipeline.slotIndex` must be present; + * resource kinds are validated against slot kinds. The drawable calls `share()` on each + * supplied resource. */ + readonly bindings: Record | ReadonlyMap; + /** The draw-call descriptor. */ + readonly draw: DrawCall; + /** Optional debug label. */ + readonly label?: string; +} + +/** Spec passed to `drawable.reuse({...})`. */ +export interface DrawableReuseSpec { + /** Optional replacement pipeline. Defaults to `this.pipeline`. The new pipeline must be + * compatible with the existing vertex / index buffers (their byte layouts are not + * re-validated — caller is responsible for picking a compatible pipeline). */ + readonly pipeline?: BuiltPipeline; + /** Bindings to override. Slots not present in `bindings` inherit from `this.bindings` + * (and are `share()`'d into the new drawable). */ + readonly bindings?: Record | ReadonlyMap; + /** Optional replacement draw-call. Defaults to `this.draw`. */ + readonly draw?: DrawCall; + /** Optional debug label for the new drawable. */ + readonly label?: string; +} + +// ---- Internal builder ------------------------------------------------------------------------- + +/** + * Build a `Drawable` from a `DrawableSpec`. Internal — call sites use `ctx.drawable(spec)`, + * which validates the spec, then forwards to this builder. + * + * Steps: + * 1. Resolve vertex input → `Map` (sharing pre-built + * resources, or allocating + uploading via `ctx.bufferManager` for the raw-arrays path). + * 2. Resolve index input similarly. Validate `draw.kind === 'indexed' ⇔ indexBuffer !== undefined`. + * 3. Resolve bindings against `pipeline.slotIndex` (matching by slot, or by `slot.name` for + * record-shaped inputs). Validate every required slot is present and `resource.kind` + * matches `slot.kind`. Call `share()` on every supplied resource. + * 4. Assemble a frozen `Drawable`, wiring `destroy()` to decref every owned resource and + * `reuse()` to produce a sibling drawable that re-shares the geometry. + */ +export function buildDrawable(ctx: RenderingContext, spec: DrawableSpec): Drawable { + // ---- Vertex buffers --------------------------------------------------- + const vertexBuffers = resolveVertexInput( + ctx, + spec.vertex, + spec.label ?? spec.pipeline.fingerprint + ); + + // ---- Index buffer (optional) ------------------------------------------ + const indexBuffer = + spec.index !== undefined + ? resolveIndexInput(ctx, spec.index, spec.label ?? spec.pipeline.fingerprint) + : undefined; + + if (spec.draw.kind === 'indexed' && indexBuffer === undefined) { + // Tear down anything we've already shared before throwing. + for (const b of vertexBuffers.values()) b.resource.destroy(); + throw new Error( + `ctx.drawable${spec.label !== undefined ? ` '${spec.label}'` : ''}: draw.kind === 'indexed' ` + + 'requires an `index` input on the spec.' + ); + } + + // ---- Bindings --------------------------------------------------------- + let bindings: Map; + try { + bindings = resolveBindings(spec.pipeline, spec.bindings, spec.label); + } catch (err) { + // Tear down everything we've shared so far before bubbling out. + for (const b of vertexBuffers.values()) b.resource.destroy(); + indexBuffer?.resource.destroy(); + throw err; + } + + // ---- Build the frozen Drawable ---------------------------------------- + return makeFrozenDrawable( + ctx, + { + pipeline: spec.pipeline, + draw: spec.draw, + ...(spec.label !== undefined && { label: spec.label }), + }, + vertexBuffers, + indexBuffer, + bindings + ); +} + +// ---- Helpers ---------------------------------------------------------------------------------- + +function makeFrozenDrawable( + ctx: RenderingContext, + args: { + readonly pipeline: BuiltPipeline; + readonly draw: DrawCall; + readonly label?: string; + }, + vertexBuffers: Map, + indexBuffer: IndexBufferBinding | undefined, + bindings: Map +): Drawable { + let disposed = false; + + const drawable: Drawable = { + __brand: DRAWABLE_BRAND, + id: uuidv4(), + ...(args.label !== undefined && { label: args.label }), + pipeline: args.pipeline, + vertexBuffers, + ...(indexBuffer !== undefined && { indexBuffer }), + bindings, + draw: args.draw, + destroy(): void { + if (disposed) return; + disposed = true; + for (const b of vertexBuffers.values()) b.resource.destroy(); + indexBuffer?.resource.destroy(); + for (const r of bindings.values()) r.destroy(); + }, + reuse(reuseSpec: DrawableReuseSpec): Drawable { + if (disposed) { + throw new Error( + `Drawable${args.label !== undefined ? ` '${args.label}'` : ''}: reuse() after destroy().` + ); + } + const newPipeline = reuseSpec.pipeline ?? args.pipeline; + const newDraw = reuseSpec.draw ?? args.draw; + const newLabel = reuseSpec.label; + + // 1. Build new bindings: start from the override map (validated against the new + // pipeline), then fill in any unspecified slots from `this.bindings` (matched + // by slot identity). Every retained resource is `share()`'d. + const overrideMap = reuseSpec.bindings; + const merged = mergeBindingsForReuse(newPipeline, bindings, overrideMap, newLabel); + + // 2. Share vertex / index buffers — produce a sibling map keyed on the same + // bufferSlots. NOTE: `vertexBuffers` slot indices must still be valid for the + // new pipeline; we don't re-derive layouts from arrays here. + const sharedVertex = new Map(); + for (const [slot, binding] of vertexBuffers) { + sharedVertex.set(slot, { resource: binding.resource.share() }); + } + const sharedIndex: IndexBufferBinding | undefined = + indexBuffer !== undefined + ? { resource: indexBuffer.resource.share(), format: indexBuffer.format } + : undefined; + + return makeFrozenDrawable( + ctx, + { + pipeline: newPipeline, + draw: newDraw, + ...(newLabel !== undefined && { label: newLabel }), + }, + sharedVertex, + sharedIndex, + merged + ); + }, + }; + + return Object.freeze(drawable); +} + +/** Decide whether the user-supplied `vertex` arg is the pre-built shape (a `Map`). */ +function isPreBuiltVertex(v: VertexInput): v is PreBuiltVertexInput { + return v instanceof Map; +} + +/** Decide whether the user-supplied `index` arg is the pre-built shape. */ +function isPreBuiltIndex(i: IndexInput): i is PreBuiltIndexInput { + // `RawArrayIndexInput` carries a `kind: 'arrays'` literal; pre-built carries a `resource` + // (which is brand-checkable). + return 'resource' in i && isResource(i.resource); +} + +function resolveVertexInput( + ctx: RenderingContext, + input: VertexInput, + labelForErrors: string +): Map { + const out = new Map(); + + if (isPreBuiltVertex(input)) { + for (const [bufferSlot, resource] of input) { + assertBufferLikeUsage(resource, GPUBufferUsage.VERTEX, 'vertex', labelForErrors); + out.set(bufferSlot, { resource: resource.share() as typeof resource }); + } + return out; + } + + if (input.kind === 'typed') { + return resolveTypedVertexInput(ctx, input, labelForErrors); + } + + // Raw-arrays path. + const bm = ctx.bufferManager; + if (bm === undefined) { + throw new Error( + `ctx.drawable '${labelForErrors}': raw-arrays vertex input requires a bufferManager; ` + + 'pass one to renderingContext({ device, bufferManager }).' + ); + } + + // `createBufferLayoutsFromArrays` returns the **layouts only** and the corresponding typed + // arrays. We never call `createBuffersAndAttributesFromArrays` because that creates GPU + // buffers — the buffer manager owns all GPU buffer creation in this architecture. + const { bufferLayouts, typedArrays } = createBufferLayoutsFromArrays( + input.arrays, + input.options + ); + + for (let i = 0; i < bufferLayouts.length; i++) { + const layout = bufferLayouts[i]; + const ta = typedArrays[i]; + if (layout === undefined || ta === undefined) continue; + const data = ta.data; + const byteLength = data.byteLength; + const usage = GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST; + if (!bm.precheck(byteLength)) { + throw new Error( + `ctx.drawable '${labelForErrors}': buffer-manager precheck refused ${byteLength} B ` + + `for vertex buffer slot ${input.bufferSlots?.[i] ?? i}. The request exceeds the ` + + 'current memory budget.' + ); + } + const handle = bm.acquire(byteLength, usage); + // Always include `handle.offset` so a future slab manager works transparently. + ctx.device.queue.writeBuffer(handle.gpu, handle.offset, data); + const slotIndex = input.bufferSlots?.[i] ?? i; + const resource = makeRawBufferResource( + handle, + usage, + `${labelForErrors}.vertex[${slotIndex}]` + ); + out.set(slotIndex, { resource }); + } + + return out; +} + +/** Resolve the declaration-driven typed vertex input: interleave each buffer's attribute data + * per its derived layout, allocate through the buffer manager, and upload. */ +function resolveTypedVertexInput( + ctx: RenderingContext, + input: TypedVertexInput, + labelForErrors: string +): Map { + const bm = ctx.bufferManager; + if (bm === undefined) { + throw new Error( + `ctx.drawable '${labelForErrors}': typed vertex input requires a bufferManager; ` + + 'pass one to renderingContext({ device, bufferManager }).' + ); + } + + const out = new Map(); + input.layout.buffers.forEach((buffer, i) => { + const bytes = interleaveVertexBuffer(buffer, input.data); + const usage = GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST; + const slotIndex = input.bufferSlots?.[i] ?? i; + if (!bm.precheck(bytes.byteLength)) { + throw new Error( + `ctx.drawable '${labelForErrors}': buffer-manager precheck refused ${bytes.byteLength} B ` + + `for vertex buffer slot ${slotIndex}. The request exceeds the current memory budget.` + ); + } + const handle = bm.acquire(bytes.byteLength, usage); + ctx.device.queue.writeBuffer(handle.gpu, handle.offset, bytes); + const resource = makeRawBufferResource(handle, usage, `${labelForErrors}.vertex[${slotIndex}]`); + out.set(slotIndex, { resource }); + }); + + return out; +} + +function resolveIndexInput( + ctx: RenderingContext, + input: IndexInput, + labelForErrors: string +): IndexBufferBinding { + if (isPreBuiltIndex(input)) { + assertBufferLikeUsage(input.resource, GPUBufferUsage.INDEX, 'index', labelForErrors); + return { + resource: input.resource.share() as typeof input.resource, + format: input.format, + }; + } + + const bm = ctx.bufferManager; + if (bm === undefined) { + throw new Error( + `ctx.drawable '${labelForErrors}': raw-arrays index input requires a bufferManager; ` + + 'pass one to renderingContext({ device, bufferManager }).' + ); + } + + const { typedArray, format } = normalizeIndexArray(input); + const usage = GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST; + if (!bm.precheck(typedArray.byteLength)) { + throw new Error( + `ctx.drawable '${labelForErrors}': buffer-manager precheck refused ${typedArray.byteLength} B ` + + 'for index buffer. The request exceeds the current memory budget.' + ); + } + const handle = bm.acquire(typedArray.byteLength, usage); + // Cast through `BufferSource` — the WebGPU types model `writeBuffer`'s data param as + // `GPUAllowSharedBufferSource`, which the union `Uint16Array | Uint32Array` does not + // narrow to cleanly without a non-shared `` annotation. + ctx.device.queue.writeBuffer( + handle.gpu, + handle.offset, + typedArray as unknown as BufferSource + ); + const resource = makeRawBufferResource(handle, usage, `${labelForErrors}.index`); + return { resource, format }; +} + +function normalizeIndexArray(input: RawArrayIndexInput): { + typedArray: Uint16Array | Uint32Array; + format: GPUIndexFormat; +} { + if (input.data instanceof Uint16Array) { + return { typedArray: input.data, format: input.format ?? 'uint16' }; + } + if (input.data instanceof Uint32Array) { + return { typedArray: input.data, format: input.format ?? 'uint32' }; + } + // Plain array → default to uint32 unless explicitly overridden to uint16. + const fmt: GPUIndexFormat = input.format ?? 'uint32'; + const typedArray = + fmt === 'uint16' ? new Uint16Array(input.data) : new Uint32Array(input.data); + return { typedArray, format: fmt }; +} + +function resolveBindings( + pipeline: BuiltPipeline, + input: DrawableSpec['bindings'], + label: string | undefined +): Map { + const out = new Map(); + const inputIsMap = input instanceof Map; + // Track which input entries got consumed — we can warn (via throw) if extras are supplied. + const consumedKeys = new Set(); + + for (const slot of pipeline.slotIndex.keys()) { + let resource: Resource | undefined; + if (inputIsMap) { + resource = input.get(slot); + if (resource !== undefined) consumedKeys.add(slot); + } else { + const rec = input as Record; + resource = rec[slot.name]; + if (resource !== undefined) consumedKeys.add(slot.name); + } + if (resource === undefined) { + throw new Error( + `ctx.drawable${label !== undefined ? ` '${label}'` : ''}: missing binding for ` + + `slot '${slot.name}' (kind '${slot.kind}') required by pipeline ${pipeline.fingerprint}.` + ); + } + if (!isResource(resource)) { + throw new Error( + `ctx.drawable${label !== undefined ? ` '${label}'` : ''}: binding for slot '${slot.name}' ` + + 'is not a Resource (did you forget to call `ctx.resource(slot, …)`?).' + ); + } + // Buffer-like slot kinds tolerate either a slot-bound `BufferResource` or a + // `RawBufferResource`; texture / sampler / external kinds must match exactly. + if (resource.kind === 'rawBuffer') { + if (slot.kind !== 'uniform' && slot.kind !== 'storage') { + throw new Error( + `ctx.drawable${label !== undefined ? ` '${label}'` : ''}: binding for slot '${slot.name}' ` + + `(kind '${slot.kind}') cannot be a RawBufferResource (only uniform/storage slots accept raw buffers).` + ); + } + } else if (resource.kind !== slot.kind) { + throw new Error( + `ctx.drawable${label !== undefined ? ` '${label}'` : ''}: binding kind mismatch for slot ` + + `'${slot.name}' — pipeline expects '${slot.kind}' but resource is '${resource.kind}'.` + ); + } + out.set(slot, resource.share()); + } + + // Detect stray entries supplied for slots the pipeline doesn't declare. (Map case only — + // record-shaped inputs may carry extras through other-shader bindings.) + if (inputIsMap) { + for (const k of input.keys()) { + if (!consumedKeys.has(k)) { + // Decref everything we already shared before throwing. + for (const r of out.values()) r.destroy(); + throw new Error( + `ctx.drawable${label !== undefined ? ` '${label}'` : ''}: bindings entry for slot ` + + `'${k.name}' is not referenced by pipeline ${pipeline.fingerprint}.` + ); + } + } + } + return out; +} + +function mergeBindingsForReuse( + newPipeline: BuiltPipeline, + base: ReadonlyMap, + overrides: DrawableSpec['bindings'] | undefined, + label: string | undefined +): Map { + const out = new Map(); + const overridesMap = overrides instanceof Map ? overrides : undefined; + const overridesRecord = + overrides !== undefined && !(overrides instanceof Map) + ? (overrides as Record) + : undefined; + + for (const slot of newPipeline.slotIndex.keys()) { + let candidate: Resource | undefined; + if (overridesMap !== undefined) candidate = overridesMap.get(slot); + if (candidate === undefined && overridesRecord !== undefined) + candidate = overridesRecord[slot.name]; + if (candidate === undefined) candidate = base.get(slot); + if (candidate === undefined) { + // Decref whatever we've already shared before throwing. + for (const r of out.values()) r.destroy(); + throw new Error( + `Drawable.reuse${label !== undefined ? ` '${label}'` : ''}: missing binding for slot ` + + `'${slot.name}' required by pipeline ${newPipeline.fingerprint}.` + ); + } + if (candidate.kind === 'rawBuffer') { + if (slot.kind !== 'uniform' && slot.kind !== 'storage') { + for (const r of out.values()) r.destroy(); + throw new Error( + `Drawable.reuse${label !== undefined ? ` '${label}'` : ''}: binding for slot '${slot.name}' ` + + `(kind '${slot.kind}') cannot be a RawBufferResource.` + ); + } + } else if (candidate.kind !== slot.kind) { + for (const r of out.values()) r.destroy(); + throw new Error( + `Drawable.reuse${label !== undefined ? ` '${label}'` : ''}: binding kind mismatch for slot ` + + `'${slot.name}' — pipeline expects '${slot.kind}' but resource is '${candidate.kind}'.` + ); + } + out.set(slot, candidate.share()); + } + return out; +} + +/** Ensure a buffer-like resource's `usage` mask carries the required bit (VERTEX / INDEX). */ +function assertBufferLikeUsage( + resource: BufferResource | RawBufferResource, + requiredBit: GPUBufferUsageFlags, + role: 'vertex' | 'index', + labelForErrors: string +): void { + if ((resource.usage & requiredBit) !== requiredBit) { + const roleName = role === 'vertex' ? 'GPUBufferUsage.VERTEX' : 'GPUBufferUsage.INDEX'; + throw new Error( + `ctx.drawable '${labelForErrors}': ${role} buffer resource was allocated without ` + + `${roleName} in its usage mask (got 0x${resource.usage.toString(16)}).` + ); + } +} diff --git a/packages/core/src/rendering/webgpu/encoder/bind-group-builder.test.ts b/packages/core/src/rendering/webgpu/encoder/bind-group-builder.test.ts new file mode 100644 index 00000000..529be904 --- /dev/null +++ b/packages/core/src/rendering/webgpu/encoder/bind-group-builder.test.ts @@ -0,0 +1,348 @@ +/** + * Tests for the bind-group builder + per-context `GPUBindGroup` cache. + * + * Unit block: exercises `buildBindGroupsForDraw` / `sweepBindGroupCache` directly with minimal + * hand-rolled fixtures, covering the identity-in-key collision fix and selective invalidation. + * + * Integration block: drives the real `RenderingContext` + encoder over the recording mock + * device to verify that `commit()` / `destroy()` on a `ctx.resource()` auto-sweep the cache and + * that `ctx.sweepBindGroups(...)` drops only the affected entries. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { renderingContext } from '../context'; +import type { Resource } from '../data/resource'; +import type { Drawable } from '../drawable'; +import type { BufferHandle, BufferManager, BufferManagerStats } from '../memory/types'; +import { bindings, group } from '../pipelines/binding-graph'; +import type { BuiltPipeline } from '../pipelines/build'; +import { uniformSlot } from '../resources'; +import type { ResourceSlot } from '../resources/resource'; +import { container, draw, scene } from '../scene/scene'; +import type { RenderTarget } from '../scene/types'; +import { member, shader, struct } from '../shaders'; +import { makeMockDevice } from '../test/mock-device'; +import { + type BindGroupBuildArgs, + type BindGroupCacheStore, + buildBindGroupsForDraw, + sweepBindGroupCache, +} from './bind-group-builder'; + +// ---- unit-level fixtures ---------------------------------------------------------------------- + +function makeStore(): BindGroupCacheStore { + return { cache: new Map(), keyToResources: new Map(), resourceToKeys: new WeakMap() }; +} + +function makeDevice(): GPUDevice { + let n = 0; + return { + createBindGroup: (desc: GPUBindGroupDescriptor): GPUBindGroup => + ({ __bg: n++, label: desc.label }) as unknown as GPUBindGroup, + } as unknown as GPUDevice; +} + +let resourceCounter = 0; +/** Minimal sampler-kind resource — the builder only reads `id`, `version`, `kind`, `sampler`. */ +function fakeResource(version = 0, id = `res-${resourceCounter++}`): Resource { + return { + id, + version, + kind: 'sampler', + sampler: {} as GPUSampler, + } as unknown as Resource; +} + +function fakeSlot(name: string): ResourceSlot { + return { name } as unknown as ResourceSlot; +} + +function fakePipeline(entries: Array<[ResourceSlot, { group: number; binding: number }]>): BuiltPipeline { + return { + fingerprint: 'pl', + bindGroupLayouts: [{} as GPUBindGroupLayout], + slotIndex: new Map(entries), + } as unknown as BuiltPipeline; +} + +function fakeDrawable(entries: Array<[ResourceSlot, Resource]>): Drawable { + return { + id: 'drw', + label: 'drw', + bindings: new Map(entries), + pipeline: { fingerprint: 'pl' }, + } as unknown as Drawable; +} + +function build( + device: GPUDevice, + pipeline: BuiltPipeline, + drawable: Drawable, + store: BindGroupCacheStore +): GPUBindGroup { + const args: BindGroupBuildArgs = { device, pipeline, drawable, overrideStack: [], store }; + const bg = buildBindGroupsForDraw(args).groups.get(0); + if (bg === undefined) throw new Error('expected a bind group for group 0'); + return bg; +} + +// ---- unit tests ------------------------------------------------------------------------------- + +describe('buildBindGroupsForDraw — identity-aware cache key', () => { + it('reuses the same GPUBindGroup for identical (resource id + version)', () => { + const device = makeDevice(); + const slot = fakeSlot('a'); + const pipeline = fakePipeline([[slot, { group: 0, binding: 0 }]]); + const res = fakeResource(); + const store = makeStore(); + + const g1 = build(device, pipeline, fakeDrawable([[slot, res]]), store); + const g2 = build(device, pipeline, fakeDrawable([[slot, res]]), store); + + expect(g2).toBe(g1); + expect(store.cache.size).toBe(1); + }); + + it('does NOT collide two distinct resources that share a slot + version', () => { + const device = makeDevice(); + const slot = fakeSlot('a'); + const pipeline = fakePipeline([[slot, { group: 0, binding: 0 }]]); + const r1 = fakeResource(0); + const r2 = fakeResource(0); // same version, different id + const store = makeStore(); + + const g1 = build(device, pipeline, fakeDrawable([[slot, r1]]), store); + const g2 = build(device, pipeline, fakeDrawable([[slot, r2]]), store); + + expect(g2).not.toBe(g1); + expect(store.cache.size).toBe(2); + }); +}); + +describe('sweepBindGroupCache — selective invalidation', () => { + it('drops only the entries referencing the invalidated resource', () => { + const device = makeDevice(); + const slot = fakeSlot('a'); + const pipeline = fakePipeline([[slot, { group: 0, binding: 0 }]]); + const r1 = fakeResource(); + const r2 = fakeResource(); + const store = makeStore(); + + build(device, pipeline, fakeDrawable([[slot, r1]]), store); + const g2 = build(device, pipeline, fakeDrawable([[slot, r2]]), store); + expect(store.cache.size).toBe(2); + + const removed = sweepBindGroupCache(store, [r1]); + expect(removed).toBe(1); + expect(store.cache.size).toBe(1); + + // r2's entry survives untouched — rebuilding it is a cache hit. + const g2again = build(device, pipeline, fakeDrawable([[slot, r2]]), store); + expect(g2again).toBe(g2); + expect(store.cache.size).toBe(1); + }); + + it('cleans cross-references so a co-bound resource sweeps to a no-op afterward', () => { + const device = makeDevice(); + const s0 = fakeSlot('a'); + const s1 = fakeSlot('b'); + const pipeline = fakePipeline([ + [s0, { group: 0, binding: 0 }], + [s1, { group: 0, binding: 1 }], + ]); + const rA = fakeResource(); + const rB = fakeResource(); + const store = makeStore(); + + build(device, pipeline, fakeDrawable([[s0, rA], [s1, rB]]), store); + expect(store.cache.size).toBe(1); + + expect(sweepBindGroupCache(store, [rA])).toBe(1); + expect(store.cache.size).toBe(0); + // rB's reverse entry for that key was cleaned when rA swept it. + expect(sweepBindGroupCache(store, [rB])).toBe(0); + }); + + it('a version bump then sweep drops the stale entry, and the rebuild is a fresh group', () => { + const device = makeDevice(); + const slot = fakeSlot('a'); + const pipeline = fakePipeline([[slot, { group: 0, binding: 0 }]]); + // Mutable resource modelling an in-place commit (version bump). + const res = fakeResource(0) as { version: number } & Resource; + const store = makeStore(); + + const g0 = build(device, pipeline, fakeDrawable([[slot, res]]), store); + res.version = 1; // commit() bumps version in place … + sweepBindGroupCache(store, [res]); // … and sweeps by object identity. + expect(store.cache.size).toBe(0); + + const g1 = build(device, pipeline, fakeDrawable([[slot, res]]), store); + expect(g1).not.toBe(g0); + expect(store.cache.size).toBe(1); + }); + + it('returns 0 for an empty resource list', () => { + const store = makeStore(); + expect(sweepBindGroupCache(store, [])).toBe(0); + }); +}); + +// ---- integration fixtures --------------------------------------------------------------------- + +type CameraShape = { view: readonly number[]; proj: readonly number[] }; +const cameraStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +function pipelineFixture() { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + return { cam, graph, sh }; +} + +const colorState = () => ({ + primitive: { topology: 'triangle-list' as const }, + fragment: { targets: [{ format: 'bgra8unorm' as const }] }, +}); + +const TARGET: RenderTarget = { + color: [ + { + view: {} as unknown as GPUTextureView, + loadOp: 'clear', + storeOp: 'store', + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + ], +}; + +function makeRecordingBM(device: GPUDevice): BufferManager { + const make = (sizeBytes: number, usage: GPUBufferUsageFlags): BufferHandle => { + const gpu = device.createBuffer({ size: sizeBytes, usage }); + const handle: BufferHandle = { + gpu, + buffer: gpu, + offset: 0, + size: sizeBytes, + sizeBytes, + bucketSize: sizeBytes, + usage, + release: vi.fn(), + sizeInBytes: () => sizeBytes, + destroy: vi.fn(), + }; + return handle; + }; + return { + acquire: vi.fn(make), + acquireForSlot: vi.fn( + (_slot: unknown, sizeBytes: number, usage: GPUBufferUsageFlags) => make(sizeBytes, usage) + ), + precheck: vi.fn(() => true), + release: vi.fn(), + endFrame: vi.fn(), + frameLease: vi.fn(() => { + throw new Error('frameLease not supported'); + }) as unknown as BufferManager['frameLease'], + stats: vi.fn((): BufferManagerStats => ({ residentBytes: 0, leasedBytes: 0, freeBytes: 0 })), + dispose: vi.fn(), + }; +} + +// ---- integration tests ------------------------------------------------------------------------ + +describe('RenderingContext — bind-group cache lifecycle', () => { + it('commit() on a bound uniform auto-sweeps its cached bind group', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, bufferManager: makeRecordingBM(m.device) }); + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const drawable = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 3 }, + }); + const s = scene({ target: TARGET, root: container([draw(drawable)]) }); + + ctx.submit(s); + expect(ctx.stats().bindGroups).toBe(1); + + // Mutating + committing the uniform invalidates the entry that referenced it. + camRes.commit(m.device); + expect(ctx.stats().bindGroups).toBe(0); + + // The per-scene subtree cache would otherwise replay the recorded commands without + // rebuilding bind groups; clear it so the next submit actually re-invokes the builder. + ctx.encoder().clearSubtreeCache(); + ctx.submit(s); + expect(ctx.stats().bindGroups).toBe(1); + }); + + it('two distinct resources at the same version produce two cache entries (no collision)', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, bufferManager: makeRecordingBM(m.device) }); + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + + const camA = ctx.resource(cam); + const camB = ctx.resource(cam); + const mkDrawable = (res: typeof camA) => + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: res }, + draw: { kind: 'array', vertexCount: 1 }, + }); + + const s = scene({ + target: TARGET, + root: container([draw(mkDrawable(camA)), draw(mkDrawable(camB))]), + }); + ctx.submit(s); + + expect(ctx.stats().bindGroups).toBe(2); + }); + + it('ctx.sweepBindGroups drops only entries referencing the given resource', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, bufferManager: makeRecordingBM(m.device) }); + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + + const camA = ctx.resource(cam); + const camB = ctx.resource(cam); + const mkDrawable = (res: typeof camA) => + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: res }, + draw: { kind: 'array', vertexCount: 1 }, + }); + const s = scene({ + target: TARGET, + root: container([draw(mkDrawable(camA)), draw(mkDrawable(camB))]), + }); + ctx.submit(s); + expect(ctx.stats().bindGroups).toBe(2); + + const removed = ctx.sweepBindGroups([camA]); + expect(removed).toBe(1); + expect(ctx.stats().bindGroups).toBe(1); + }); + + it('sweepBindGroups is a safe no-op after dispose()', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, bufferManager: makeRecordingBM(m.device) }); + const { cam } = pipelineFixture(); + const camRes = ctx.resource(cam); + ctx.dispose(); + expect(ctx.sweepBindGroups([camRes])).toBe(0); + }); +}); diff --git a/packages/core/src/rendering/webgpu/encoder/bind-group-builder.ts b/packages/core/src/rendering/webgpu/encoder/bind-group-builder.ts new file mode 100644 index 00000000..d15d356d --- /dev/null +++ b/packages/core/src/rendering/webgpu/encoder/bind-group-builder.ts @@ -0,0 +1,270 @@ +/** + * Bind-group builder + per-`RenderingContext` bind-group cache. + * + * The cache lives on `RenderingContext` (accessed via the `BindGroupCacheStore` shape passed + * to `buildBindGroupsForDraw`) so two contexts against the same device get independent caches + * and `ctx.dispose()` is the single point that drops every cached `GPUBindGroup`. + * + * Cache key shape: + * `${pipelineFingerprint}|g${groupIdx}|${resourceTokens.join(',')}|${overrideKey}` + * + * where each `resourceToken` is `${resource.id}.${resource.version}` (the resource's stable + * UUID plus its mutation version) for every slot in the group, in ascending `binding` order, + * and `overrideKey` is a `;`-joined list of `${binding}:${resource.id}.${resource.version}` + * for the slots supplied by the override stack (empty when none). Encoding the resource `id` + * (not just its `version`) makes the key identity-unique: two distinct resources that happen + * to share a slot + version no longer collide onto the same `GPUBindGroup`, and it lets + * `sweepBindGroupCache` map an invalidated resource back to its cache entries. + * Fingerprint (rather than a per-build id) is used because the per-context pipeline cache + * guarantees fingerprint ↔ `BuiltPipeline` instance uniqueness, and fingerprints are stable + * across rebuilds — a slightly more informative token to see in cache-key dumps. + * + * The bind-group entries that every buffer-backed binding produces always carry + * `{ buffer: handle.gpu, offset: handle.offset, size: handle.size }` so a slab manager that + * sub-allocates inside one big GPUBuffer is transparent to the encoder. + */ + +import type { + BufferResource, + ExternalTextureResource, + RawBufferResource, + Resource, + SamplerResource, + StorageTextureResource, + TextureResource, +} from '../data/resource'; +import type { Drawable } from '../drawable'; +import type { BuiltPipeline } from '../pipelines/build'; +import type { ResourceSlot } from '../resources/resource'; + +/** Storage shape the bind-group builder consults. Lives on `RenderingContext`. */ +export interface BindGroupCacheStore { + /** Map of cache key → assembled `GPUBindGroup`. The cache is unbounded in v1; callers + * invalidate selectively via `sweepBindGroupCache` or wholesale via `cache.clear()` + * (e.g. `ctx.dispose()`). */ + readonly cache: Map; + /** Forward index: cache key → the (deduped) resources whose id+version composed it. Used + * to clean the reverse index when an entry is swept. */ + readonly keyToResources: Map; + /** Reverse index: resource → the set of cache keys referencing it. `WeakMap` so a resource + * that becomes unreachable drops out automatically without an explicit reset. */ + readonly resourceToKeys: WeakMap>; +} + +/** Per-call inputs assembled by the encoder before calling into the builder. */ +export interface BindGroupBuildArgs { + readonly device: GPUDevice; + readonly pipeline: BuiltPipeline; + readonly drawable: Drawable; + /** Stack of binding-override maps from outermost (root-ward) to innermost (draw-ward). The + * innermost map that contains a given slot wins; slots not in any map fall through to + * `drawable.bindings`. */ + readonly overrideStack: readonly ReadonlyMap[]; + readonly store: BindGroupCacheStore; +} + +/** The set of bind groups + their cache keys for one drawable + override-stack combination. */ +export interface ResolvedBindGroups { + /** Bind groups keyed by group index (0..bindGroupLayouts.length-1). */ + readonly groups: ReadonlyMap; + /** Parallel cache-key vector for testing / instrumentation. */ + readonly keys: ReadonlyMap; +} + +/** Resolve every bind group required by `pipeline` for `drawable`, honoring the override + * stack. Cached: identical (pipeline, drawable bindings, overrides, resource versions) returns + * the same `GPUBindGroup` instance from the store on subsequent calls. */ +export function buildBindGroupsForDraw(args: BindGroupBuildArgs): ResolvedBindGroups { + const { device, pipeline, drawable, overrideStack, store } = args; + const groups = new Map(); + const keys = new Map(); + + // Bucket the pipeline's slots by group index. We use `pipeline.slotIndex` (which carries + // {group, binding} per slot used by the shader), pre-built at pipeline-build time. + const slotsByGroup = new Map>(); + for (const [slot, { group, binding }] of pipeline.slotIndex) { + let arr = slotsByGroup.get(group); + if (arr === undefined) { + arr = []; + slotsByGroup.set(group, arr); + } + arr.push({ slot, binding }); + } + + for (const [groupIdx, slotEntries] of slotsByGroup) { + // Sort by binding so the entries array order matches WebGPU's expectation (the order + // doesn't strictly matter — `binding` is what the driver indexes by — but a stable + // order makes the cache key deterministic). + slotEntries.sort((a, b) => a.binding - b.binding); + + const tokens: string[] = []; + const overrideTags: string[] = []; + const entries: GPUBindGroupEntry[] = []; + const groupResources: Resource[] = []; + + for (const { slot, binding } of slotEntries) { + const resolved = resolveBinding(slot, drawable, overrideStack); + const r = resolved.resource; + const token = `${r.id}.${r.version}`; + tokens.push(token); + if (resolved.fromOverride) { + overrideTags.push(`${binding}:${token}`); + } + groupResources.push(r); + entries.push(makeBindGroupEntry(binding, r, slot)); + } + + const key = `${pipeline.fingerprint}|g${groupIdx}|${tokens.join(',')}|${overrideTags.join(';')}`; + let bg = store.cache.get(key); + if (bg === undefined) { + const layout = pipeline.bindGroupLayouts[groupIdx]; + if (layout === undefined) { + throw new Error( + `buildBindGroupsForDraw: pipeline '${pipeline.fingerprint}' has no bindGroupLayout for group ${groupIdx}.` + ); + } + bg = device.createBindGroup({ + layout, + entries, + label: `${drawable.label ?? drawable.id}.bg${groupIdx}`, + }); + store.cache.set(key, bg); + indexBindGroupKey(store, key, groupResources); + } + groups.set(groupIdx, bg); + keys.set(groupIdx, key); + } + + return { groups, keys }; +} + +/** Record which resources composed `key` so `sweepBindGroupCache` can find and drop the entry + * when any of them is mutated or destroyed. `resources` is deduped first (a resource bound to + * two slots of the same group must not be double-counted). */ +function indexBindGroupKey( + store: BindGroupCacheStore, + key: string, + resources: readonly Resource[] +): void { + const unique = resources.length > 1 ? Array.from(new Set(resources)) : resources; + store.keyToResources.set(key, unique); + for (const r of unique) { + let set = store.resourceToKeys.get(r); + if (set === undefined) { + set = new Set(); + store.resourceToKeys.set(r, set); + } + set.add(key); + } +} + +/** Drop exactly the cached bind groups that reference any of `invalidatedResources`, using the + * reverse index (`resource → keys`) built at bind-group creation. Cross-references in the + * forward index (`key → resources`) are cleaned so no stale entries linger. Returns the number + * of `GPUBindGroup`s actually removed from the cache. Bind groups not touched by these + * resources are left intact, so the next submit only rebuilds what changed. */ +export function sweepBindGroupCache( + store: BindGroupCacheStore, + invalidatedResources: readonly Resource[] +): number { + if (invalidatedResources.length === 0) return 0; + let removed = 0; + for (const r of invalidatedResources) { + const keys = store.resourceToKeys.get(r); + if (keys === undefined) continue; + for (const key of keys) { + if (store.cache.delete(key)) removed += 1; + // Detach this key from every *other* resource that fed it, then drop the forward + // entry. `r`'s own reverse entry is removed wholesale below. + const others = store.keyToResources.get(key); + if (others !== undefined) { + for (const o of others) { + if (o === r) continue; + store.resourceToKeys.get(o)?.delete(key); + } + store.keyToResources.delete(key); + } + } + store.resourceToKeys.delete(r); + } + return removed; +} + +// ---- internals -------------------------------------------------------------------------------- + +/** Walk the override stack from innermost to outermost; return the first match (or the + * drawable's own binding when no override covers `slot`). */ +function resolveBinding( + slot: ResourceSlot, + drawable: Drawable, + overrideStack: readonly ReadonlyMap[] +): { resource: Resource; fromOverride: boolean } { + for (let i = overrideStack.length - 1; i >= 0; i--) { + const m = overrideStack[i]; + if (m === undefined) continue; + const r = m.get(slot); + if (r !== undefined) return { resource: r, fromOverride: true }; + } + const own = drawable.bindings.get(slot); + if (own === undefined) { + throw new Error( + `buildBindGroupsForDraw: drawable '${drawable.label ?? drawable.id}' is missing a binding ` + + `for slot '${slot.name}' required by pipeline '${drawable.pipeline.fingerprint}'.` + ); + } + return { resource: own, fromOverride: false }; +} + +function makeBindGroupEntry( + binding: number, + resource: Resource, + slot: ResourceSlot +): GPUBindGroupEntry { + switch (resource.kind) { + case 'uniform': + case 'storage': { + const buf = resource as BufferResource; + return { + binding, + resource: { + buffer: buf.handle.gpu, + offset: buf.handle.offset, + size: buf.handle.size, + }, + }; + } + case 'rawBuffer': { + // raw buffer used as a uniform/storage binding (validated by drawable construction) + const raw = resource as RawBufferResource; + return { + binding, + resource: { + buffer: raw.handle.gpu, + offset: raw.handle.offset, + size: raw.handle.size, + }, + }; + } + case 'texture': { + const t = resource as TextureResource; + return { binding, resource: t.view }; + } + case 'storageTexture': { + const t = resource as StorageTextureResource; + return { binding, resource: t.view }; + } + case 'sampler': { + const s = resource as SamplerResource; + return { binding, resource: s.sampler }; + } + case 'externalTexture': { + const e = resource as ExternalTextureResource; + return { binding, resource: e.external }; + } + default: { + const _exhaustive: never = resource; // intentionally marked as never being used, as a compile-time check for exhaustiveness + void _exhaustive; // marked as "used" so that the compiler does not complain about an unused variable + throw new Error(`makeBindGroupEntry: unmapped resource kind on slot '${slot.name}'.`); + } + } +} diff --git a/packages/core/src/rendering/webgpu/encoder/encoder.test.ts b/packages/core/src/rendering/webgpu/encoder/encoder.test.ts new file mode 100644 index 00000000..aec116b0 --- /dev/null +++ b/packages/core/src/rendering/webgpu/encoder/encoder.test.ts @@ -0,0 +1,566 @@ +/** + * Phase 7 — `GraphEncoder` integration tests. + * + * Uses the recording mock-device to assert exact pass-command sequences. Verifies: + * (a) WebGPU ordering (setPipeline → setBindGroup* → setVertexBuffer* → setIndexBuffer? → draw) + * (b) no redundant `setPipeline` between consecutive same-pipeline draws + * (c) `setBindGroup(N, X)` not re-emitted when X is already bound for slot N + * (e) state-node subtree restores prior viewport on exit + * (g) `BufferHandle.offset` is threaded through every `setVertexBuffer` / `setIndexBuffer` / + * bind-group entry + */ + +import { describe, expect, it, vi } from 'vitest'; +import { renderingContext } from '../context'; +import { container, draw, scene, viewport } from '../scene/scene'; +import type { RenderTarget } from '../scene/types'; +import { bindings, group } from '../pipelines/binding-graph'; +import { uniformSlot } from '../resources'; +import { member, shader, struct } from '../shaders'; +import { + makeMockDevice, + type MockDevice, + type MockGpuBindGroup, + type PassCommand, +} from '../test/mock-device'; +import type { + BufferHandle, + BufferManager, + BufferManagerStats, +} from '../memory/types'; + +// ---- fixtures --------------------------------------------------------------------------------- + +type CameraShape = { view: readonly number[]; proj: readonly number[] }; +const cameraStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +function pipelineFixture() { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + return { cam, graph, sh }; +} + +const colorState = () => ({ + primitive: { topology: 'triangle-list' as const }, + fragment: { targets: [{ format: 'bgra8unorm' as const }] }, +}); + +const TARGET: RenderTarget = { + color: [ + { + view: {} as unknown as GPUTextureView, + loadOp: 'clear', + storeOp: 'store', + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + ], +}; + +function makeRecordingBM(device: GPUDevice, slabOffset = 0): BufferManager { + const make = (sizeBytes: number, usage: GPUBufferUsageFlags): BufferHandle => { + const gpu = device.createBuffer({ size: sizeBytes + slabOffset, usage }); + const handle: BufferHandle = { + gpu, + buffer: gpu, + offset: slabOffset, + size: sizeBytes, + sizeBytes, + bucketSize: sizeBytes, + usage, + release: vi.fn(), + sizeInBytes: () => sizeBytes, + destroy: vi.fn(), + }; + return handle; + }; + return { + acquire: vi.fn(make), + acquireForSlot: vi.fn( + (_slot: unknown, sizeBytes: number, usage: GPUBufferUsageFlags) => + make(sizeBytes, usage) + ), + precheck: vi.fn(() => true), + release: vi.fn(), + endFrame: vi.fn(), + frameLease: vi.fn(() => { + throw new Error('frameLease not supported'); + }) as unknown as BufferManager['frameLease'], + stats: vi.fn( + (): BufferManagerStats => ({ residentBytes: 0, leasedBytes: 0, freeBytes: 0 }) + ), + dispose: vi.fn(), + }; +} + +function passCmds(m: MockDevice, kind: PassCommand['kind']): PassCommand[] { + return m.passCommands.filter((c) => c.kind === kind); +} + +// ---- tests ------------------------------------------------------------------------------------ + +describe('GraphEncoder — basic encoding', () => { + it('emits commands in WebGPU-mandated order for a single drawable', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const drawable = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 3 }, + }); + + const s = scene({ target: TARGET, root: container([draw(drawable)]) }); + ctx.submit(s); + + const kinds = m.passCommands.map((c) => c.kind); + // The first command after beginRenderPass should be setPipeline; followed by at least + // one setBindGroup, then setVertexBuffer, then draw. + const begin = kinds.indexOf('beginRenderPass'); + const sp = kinds.indexOf('setPipeline'); + const sbg = kinds.indexOf('setBindGroup'); + const svb = kinds.indexOf('setVertexBuffer'); + const dr = kinds.indexOf('draw'); + const end = kinds.indexOf('endRenderPass'); + + expect(begin).toBeGreaterThanOrEqual(0); + expect(sp).toBeGreaterThan(begin); + expect(sbg).toBeGreaterThan(sp); + expect(svb).toBeGreaterThan(sbg); + expect(dr).toBeGreaterThan(svb); + expect(end).toBeGreaterThan(dr); + + // Stats should match the emitted commands. + const stats = ctx.encoder().lastStats(); + expect(stats.setPipelineCalls).toBe(1); + expect(stats.setBindGroupCalls).toBe(1); + expect(stats.setVertexBufferCalls).toBe(1); + expect(stats.drawCalls).toBe(1); + + // Queue.submit was called once with the finished command buffer. + expect(m.calls.queueSubmit).toHaveBeenCalledTimes(1); + }); + + it('threads BufferHandle.offset through every setVertexBuffer / setBindGroup entry (slab readiness)', () => { + const m = makeMockDevice(); + const SLAB = 2048; + const bm = makeRecordingBM(m.device, SLAB); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const drawable = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + + const s = scene({ target: TARGET, root: container([draw(drawable)]) }); + ctx.submit(s); + + const svbs = passCmds(m, 'setVertexBuffer'); + expect(svbs.length).toBe(1); + const svb = svbs[0]; + if (svb?.kind !== 'setVertexBuffer') throw new Error('unreachable'); + expect(svb.offset).toBe(SLAB); + expect(svb.size).toBeGreaterThan(0); + + // The bind group entry for the camera uniform should also carry the slab offset. + const bgs = m.created.bindGroups as MockGpuBindGroup[]; + expect(bgs.length).toBeGreaterThan(0); + const bg = bgs[0]!; + const entries = bg.descriptor.entries as readonly GPUBindGroupEntry[]; + const bufEntry = entries[0]?.resource as { buffer: GPUBuffer; offset: number; size: number }; + expect(bufEntry.offset).toBe(SLAB); + expect(bufEntry.size).toBeGreaterThan(0); + }); + + it('threads setIndexBuffer offset + size for indexed draws', () => { + const m = makeMockDevice(); + const SLAB = 1024; + const bm = makeRecordingBM(m.device, SLAB); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const drawable = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) } }, + index: { kind: 'arrays', data: new Uint16Array([0, 1, 2]) }, + bindings: { camera: camRes }, + draw: { kind: 'indexed', indexCount: 3 }, + }); + + ctx.submit(scene({ target: TARGET, root: container([draw(drawable)]) })); + + const sib = passCmds(m, 'setIndexBuffer'); + expect(sib.length).toBe(1); + const ix = sib[0]; + if (ix?.kind !== 'setIndexBuffer') throw new Error('unreachable'); + expect(ix.offset).toBe(SLAB); + expect(ix.format).toBe('uint16'); + }); +}); + +describe('GraphEncoder — state elision', () => { + it('does NOT re-emit setPipeline between consecutive draws of the same pipeline', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const verts = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const d1 = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: verts } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 3 }, + }); + const d2 = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: verts } }, + bindings: { camera: camRes.share() }, + draw: { kind: 'array', vertexCount: 3 }, + }); + + const s = scene({ target: TARGET, root: container([draw(d1), draw(d2)]) }); + ctx.submit(s); + + const setPipelineCount = passCmds(m, 'setPipeline').length; + const drawCount = passCmds(m, 'draw').length; + expect(setPipelineCount).toBe(1); + expect(drawCount).toBe(2); + }); + + it('does NOT re-emit setBindGroup when the same group object is already bound', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const verts = new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const d1 = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: verts } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 3 }, + }); + const d2 = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: verts } }, + bindings: { camera: camRes.share() }, + draw: { kind: 'array', vertexCount: 3 }, + }); + + const s = scene({ target: TARGET, root: container([draw(d1), draw(d2)]) }); + ctx.submit(s); + + // Same camera resource (same version), same pipeline ⇒ bind-group cache hit ⇒ + // only one setBindGroup call across both draws. + const setBindGroupCount = passCmds(m, 'setBindGroup').length; + expect(setBindGroupCount).toBe(1); + }); + + it('caches GPUBindGroup objects across submits (no createBindGroup re-call when nothing changed)', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const drawable = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + const s = scene({ target: TARGET, root: container([draw(drawable)]) }); + + ctx.submit(s); + const createsAfterFirst = m.calls.createBindGroup.mock.calls.length; + ctx.submit(s); + expect(m.calls.createBindGroup.mock.calls.length).toBe(createsAfterFirst); + }); +}); + +describe('GraphEncoder — scoped state restoration', () => { + it('viewport subtree restores the prior viewport on exit', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const verts = new Float32Array([0, 0, 0]); + + const outerDraw = draw( + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: verts } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ); + const innerDraw = draw( + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: verts } }, + bindings: { camera: camRes.share() }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ); + const tailDraw = draw( + ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: verts } }, + bindings: { camera: camRes.share() }, + draw: { kind: 'array', vertexCount: 1 }, + }) + ); + + const root = container([ + viewport({ x: 0, y: 0, width: 100, height: 100 }, [ + outerDraw, + viewport({ x: 10, y: 10, width: 20, height: 20 }, [innerDraw]), + tailDraw, + ]), + ]); + + const s = scene({ target: TARGET, root }); + ctx.submit(s); + + const viewports = passCmds(m, 'setViewport').filter( + (c): c is Extract => c.kind === 'setViewport' + ); + // Outer enter (0,0,100,100), inner enter (10,10,20,20), inner restore (0,0,100,100). + // Outer restore on exit not emitted because there is no prior viewport (snapshot was undefined). + expect(viewports.length).toBe(3); + expect(viewports[0]?.width).toBe(100); + expect(viewports[1]?.width).toBe(20); + expect(viewports[2]?.width).toBe(100); // restored after inner subtree + }); +}); + +describe('GraphEncoder — Scene.dirty integration', () => { + it('clears Scene.dirty after each submit', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + + const d = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + const s = scene({ target: TARGET, root: container([draw(d)]) }); + s.markSubtreeDirty(s.root.id); + expect(s.dirty.size).toBeGreaterThan(0); + ctx.submit(s); + expect(s.dirty.size).toBe(0); + }); +}); + +describe('RenderingContext — encoder + bind-group cache lifecycle', () => { + it('ctx.dispose() clears the bind-group cache (stats().bindGroups goes back to 0)', () => { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const d = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + ctx.submit(scene({ target: TARGET, root: container([draw(d)]) })); + expect(ctx.stats().bindGroups).toBeGreaterThan(0); + + ctx.dispose(); + expect(ctx.stats().bindGroups).toBe(0); + expect(bm.dispose).not.toHaveBeenCalled(); // ownership contract preserved + }); + + it('encoder() returns the same instance until dispose()', () => { + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, bufferManager: makeRecordingBM(m.device) }); + const e1 = ctx.encoder(); + const e2 = ctx.encoder(); + expect(e1).toBe(e2); + ctx.dispose(); + // After dispose, encoder() should throw use-after-dispose. + expect(() => ctx.encoder()).toThrow(/use-after-dispose/); + }); +}); + +// ---- Phase 7.1: subtree-command cache ------------------------------------------------------- +// +// The encoder maintains a per-`Scene` cache keyed by composite node id. On a second `submit` +// of the same scene with no dirty nodes, the encoder should replay cached command lists +// instead of re-walking (and re-resolving bind groups). The tests below assert observable +// consequences of the cache: no new `createBindGroup` calls on cache hits, correct pass +// command replay, invalidation via `markSubtreeDirty`, and cache-entry eviction on structural +// changes. + +describe('GraphEncoder — subtree-command cache (Phase 7.1)', () => { + function buildFixture() { + const m = makeMockDevice(); + const bm = makeRecordingBM(m.device); + const ctx = renderingContext({ device: m.device, bufferManager: bm }); + const { cam, graph, sh } = pipelineFixture(); + const pipeline = ctx.pipeline(graph, sh, colorState()); + const camRes = ctx.resource(cam); + const d = ctx.drawable({ + pipeline, + vertex: { kind: 'arrays', arrays: { position: new Float32Array([0, 0, 0]) } }, + bindings: { camera: camRes }, + draw: { kind: 'array', vertexCount: 1 }, + }); + return { m, bm, ctx, d }; + } + + it('second submit of an unchanged scene reports cache hits and emits identical pass commands', () => { + const { m, ctx, d } = buildFixture(); + const s = scene({ target: TARGET, root: container([draw(d)]) }); + + ctx.submit(s); + const firstStats = ctx.encoder().lastStats(); + expect(firstStats.subtreeCacheHits).toBe(0); + expect(firstStats.subtreeCacheMisses).toBeGreaterThan(0); + const firstCommands = m.passCommands.slice(); + const firstBindGroupCount = (m.calls.createBindGroup as ReturnType).mock.calls.length; + + ctx.submit(s); + const secondStats = ctx.encoder().lastStats(); + // Second frame with no dirty nodes: every composite in the tree should be a cache hit + // (root container + one implicit — the test tree is just `container([draw(...)])`, so + // the container itself is the only composite; the draw leaf goes through walkDraw). + expect(secondStats.subtreeCacheHits).toBeGreaterThan(0); + expect(secondStats.subtreeCacheMisses).toBe(0); + // No new bind groups: the cached commands reference the same `MockGpuBindGroup` objects + // the first walk produced. + const secondBindGroupCount = (m.calls.createBindGroup as ReturnType).mock.calls.length; + expect(secondBindGroupCount).toBe(firstBindGroupCount); + // The recorded pass command sequence between the two submits should be equal + // (bar the `beginRenderPass` + `endRenderPass` bookends which happen every frame). + const secondFrameCommands = m.passCommands.slice(firstCommands.length); + // Same total draws in second frame: + expect(secondFrameCommands.filter((c) => c.kind === 'draw' || c.kind === 'drawIndexed').length).toBe( + firstCommands.filter((c) => c.kind === 'draw' || c.kind === 'drawIndexed').length + ); + }); + + it('markSubtreeDirty on the root forces a full re-record on next submit', () => { + const { ctx, d } = buildFixture(); + const s = scene({ target: TARGET, root: container([draw(d)]) }); + + ctx.submit(s); + s.markSubtreeDirty(s.root.id); + ctx.submit(s); + const stats = ctx.encoder().lastStats(); + // Every composite is dirty → every composite misses (and re-records). + expect(stats.subtreeCacheHits).toBe(0); + expect(stats.subtreeCacheMisses).toBeGreaterThan(0); + }); + + it('encoder.subtreeCacheSize() reports the number of cached composite entries', () => { + const { ctx, d } = buildFixture(); + const s = scene({ + target: TARGET, + root: container([viewport({ x: 0, y: 0, width: 10, height: 10 }, [draw(d)])]), + }); + + expect(ctx.encoder().subtreeCacheSize()).toBe(0); + ctx.submit(s); + // Root container + viewport = 2 composite entries. + expect(ctx.encoder().subtreeCacheSize()).toBe(2); + }); + + it('Scene.remove evicts the removed subtree and every descendant from the cache', () => { + const { ctx, d } = buildFixture(); + const inner = viewport({ x: 0, y: 0, width: 10, height: 10 }, [draw(d)]); + const s = scene({ target: TARGET, root: container([inner]) }); + + ctx.submit(s); + expect(ctx.encoder().subtreeCacheSize()).toBe(2); // root + inner viewport + s.remove(inner.id); + // Removal evicts the viewport entry immediately (via structure-changed listener). The + // root container entry is invalidated by `markAncestorsDirty` but stays in the cache + // until the next walk re-records it. So we expect exactly 1 entry (the root) after + // eviction, since the viewport composite was the only descendant. + expect(ctx.encoder().subtreeCacheSize()).toBe(1); + }); + + it('clearSubtreeCache() drops every cached entry across every scene', () => { + const { ctx, d } = buildFixture(); + const s = scene({ target: TARGET, root: container([draw(d)]) }); + ctx.submit(s); + expect(ctx.encoder().subtreeCacheSize()).toBeGreaterThan(0); + ctx.encoder().clearSubtreeCache(); + expect(ctx.encoder().subtreeCacheSize()).toBe(0); + }); + + it('nested composites: inner cache hit still tees commands into outer re-record', () => { + const { m, ctx, d } = buildFixture(); + const inner = viewport({ x: 0, y: 0, width: 10, height: 10 }, [draw(d)]); + const s = scene({ target: TARGET, root: container([inner]) }); + + // Frame 1: everything recorded fresh. + ctx.submit(s); + const frame1DrawCount = m.passCommands.filter( + (c) => c.kind === 'draw' || c.kind === 'drawIndexed' + ).length; + expect(frame1DrawCount).toBe(1); + + // Frame 2: mark ONLY the root container dirty. The inner viewport is still clean and + // should hit the cache; its replayed commands must be teed into the root's new recorder + // so the root cache entry we save this frame is complete. + s.markDirty(s.root.id); + ctx.submit(s); + const stats = ctx.encoder().lastStats(); + expect(stats.subtreeCacheHits).toBeGreaterThan(0); // inner viewport hit + expect(stats.subtreeCacheMisses).toBeGreaterThan(0); // root re-recorded + + // Frame 3: everything clean → both should hit; and the draw command should still be + // emitted exactly once. + const cmdCountBefore = m.passCommands.length; + ctx.submit(s); + const frame3 = m.passCommands.slice(cmdCountBefore); + const frame3Draws = frame3.filter((c) => c.kind === 'draw' || c.kind === 'drawIndexed'); + expect(frame3Draws.length).toBe(1); + const frame3Stats = ctx.encoder().lastStats(); + expect(frame3Stats.subtreeCacheMisses).toBe(0); + }); +}); + diff --git a/packages/core/src/rendering/webgpu/encoder/encoder.ts b/packages/core/src/rendering/webgpu/encoder/encoder.ts new file mode 100644 index 00000000..86668bff --- /dev/null +++ b/packages/core/src/rendering/webgpu/encoder/encoder.ts @@ -0,0 +1,749 @@ +/** + * `GraphEncoder` — Phase 7 of the WebGPU rendering refactor. + * + * Walks a `Scene` against a recording `GPURenderPassEncoder`, emitting state-deduplicated + * draw commands. State elision is driven by an `ActiveState` instance maintained for the + * lifetime of the pass. + * + * ## Subtree-command caching (Phase 7.1) + * + * Every composite node (`container` / `viewport` / `scissor` / `stencilref` / `blendconstant` + * / `override`) is a candidate for command-list caching. The cache is keyed by `NodeId` and + * lives on the encoder (one cache per `Scene`, held in a `WeakMap` so scene GC + * naturally drops entries). Each entry records the sequence of `PassCommand`s the walk + * emitted for that subtree plus the running `ActiveState` snapshots at subtree entry and + * exit. + * + * On each `encode(scene)` walk of a composite node: + * - **Cache hit** (node id absent from `scene.dirty` AND we have a cached entry AND the + * current `ActiveState` structurally matches the cached entry snapshot) → replay the + * cached commands into the pass encoder, then fast-forward `ActiveState` from the cached + * exit snapshot. Skips `buildBindGroupsForDraw`, `walk`-function dispatch, and every + * per-slot equality check inside the subtree. + * - **Cache miss / dirty / entry-state mismatch** → walk the subtree the normal way but tee + * every emitted command into a fresh `Recorder`; on subtree exit, cache the recorded + * commands + entry/exit snapshots. Nested composites each start their own recorder so + * inner subtrees are independently replayable; commands from an inner replay ARE also + * appended to any active outer recorder so the outer cache is complete. + * + * Cache invalidation is a combination of two mechanisms: + * 1. Consulting `scene.dirty` at each composite: caller mutations (`add` / `remove` / + * `replace` / `markDirty`) already propagate dirty up to the root, so a subtree is + * re-recorded whenever anything below it changed. + * 2. Subscribing to `scene.on('structure-changed', ...)` on first sight of a scene: on + * `remove` / `replace` we drop the affected node ids from the cache. This prevents + * unbounded growth for scenes that churn subtrees. + * + * v1 simplifications (documented for follow-up work): + * - **Pipeline-layout compatibility** for `setPipeline`-induced bind-group invalidation is + * treated as "identity = compatible". When `setPipeline` swaps pipelines, we conservatively + * drop every `ActiveState.bindGroups` entry so the encoder re-emits `setBindGroup` for + * every slot the new pipeline needs. A future refinement can compare + * `pipeline.layout` / `pipeline.bindGroupLayouts` and elide bind groups where layouts match. + * + * State-node semantics (scoped): + * - On entry to `ViewportNode` / `ScissorNode` / `StencilRefNode` / `BlendConstantNode`: + * snapshot the corresponding `ActiveState` field, apply the node's value (emitting the + * setter only if it differs from current). + * - On exit: if the snapshotted value differs from what's currently active, emit the + * restoration setter (or — if the snapshot is `undefined` — leave the state as-is; WebGPU + * has no "unset" verb, but the very-first-frame snapshot of `undefined` is moot because + * nothing reads it past the pass boundary). + * - `BindingOverrideNode`: pushes its overrides onto a stack consulted by the bind-group + * builder; on exit, pops them. The bind groups bound under the override are tagged in the + * cache key, so the executor naturally re-binds the original groups on exit when it + * encounters the next draw whose cache key differs. + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { BufferHandle } from '../memory/types'; +import type { Resource } from '../data/resource'; +import type { Drawable } from '../drawable'; +import type { + BindingOverrideNode, + BlendConstantNode, + DrawableNode, + NodeId, + RenderTarget, + Scene, + SceneEvent, + SceneNode, + ScissorNode, + StencilRefNode, + ViewportNode, +} from '../scene/types'; +import type { ResourceSlot } from '../resources/resource'; +import type { RenderingContext } from '../context-types'; +import { + type BindGroupCacheStore, + buildBindGroupsForDraw, +} from './bind-group-builder'; +import { applyPassCommand, type PassCommand } from './pass-commands'; +import { + ActiveState, + type ActiveStateSnapshot, + type BlendConstantValue, + type ScissorValue, + type ViewportValue, + activeStateSnapshotsEqual, + blendConstantsEqual, + indexBuffersEqual, + normalizeBlendConstant, + scissorsEqual, + vertexHandlesEqual, + viewportsEqual, +} from './state'; + +/** Public stats emitted by `encoder.lastStats()` after a `submit(scene)`. */ +export interface EncoderStats { + /** Number of `passEncoder.setPipeline` calls emitted. */ + readonly setPipelineCalls: number; + /** Number of `passEncoder.setBindGroup` calls emitted. */ + readonly setBindGroupCalls: number; + /** Number of `passEncoder.setVertexBuffer` calls emitted. */ + readonly setVertexBufferCalls: number; + /** Number of `passEncoder.setIndexBuffer` calls emitted. */ + readonly setIndexBufferCalls: number; + /** Number of `passEncoder.draw` + `drawIndexed` calls emitted (i.e. drawables visited). */ + readonly drawCalls: number; + /** Number of `passEncoder.setViewport` calls emitted (entry + restore combined). */ + readonly setViewportCalls: number; + /** Number of `passEncoder.setScissorRect` calls emitted. */ + readonly setScissorCalls: number; + /** Number of `passEncoder.setStencilReference` calls emitted. */ + readonly setStencilRefCalls: number; + /** Number of `passEncoder.setBlendConstant` calls emitted. */ + readonly setBlendConstantCalls: number; + /** Number of composite subtrees replayed from the subtree-command cache this frame. */ + readonly subtreeCacheHits: number; + /** Number of composite subtrees walked (and re-recorded into the cache) this frame. */ + readonly subtreeCacheMisses: number; +} + +const ZERO_STATS: EncoderStats = Object.freeze({ + setPipelineCalls: 0, + setBindGroupCalls: 0, + setVertexBufferCalls: 0, + setIndexBufferCalls: 0, + drawCalls: 0, + setViewportCalls: 0, + setScissorCalls: 0, + setStencilRefCalls: 0, + setBlendConstantCalls: 0, + subtreeCacheHits: 0, + subtreeCacheMisses: 0, +}); + +/** Brand symbol used by `isGraphEncoder` to discriminate encoder objects at runtime. */ +export const GRAPH_ENCODER_BRAND: unique symbol = Symbol.for('vis-core.webgpu.GraphEncoder'); + +/** The persistent encoder — bound to a single `RenderingContext`, reusable across frames. */ +export interface GraphEncoder { + readonly __brand: typeof GRAPH_ENCODER_BRAND; + readonly id: string; + /** Encode + submit `scene` to the device queue. */ + submit(scene: Scene): GPUCommandBuffer; + /** Encode `scene` and return the finished command buffer (no queue submit). */ + encode(scene: Scene): GPUCommandBuffer; + /** Stats from the most recent `submit` / `encode` call (zeroed before each). */ + lastStats(): EncoderStats; + /** + * Drop every cached subtree recording for every scene the encoder has seen. Called from + * `RenderingContext.dispose()`. Callers rarely need to invoke this directly; the encoder + * evicts stale entries automatically on scene structure changes. + */ + clearSubtreeCache(): void; + /** Number of cached subtree entries across every scene the encoder has seen. Testing hook. */ + subtreeCacheSize(): number; +} + +/** Construct a `GraphEncoder`. Internal — call `ctx.encoder()` instead. + * + * `cacheStore` is passed separately because the `GPUBindGroup` cache is a private field of the + * context (not part of the public `RenderingContext` interface); the context threads its own + * store in when it calls this factory. */ +export function makeGraphEncoder( + rc: RenderingContext, + cacheStore: BindGroupCacheStore +): GraphEncoder { + const id = uuidv4(); + let stats: EncoderStats = ZERO_STATS; + // Per-scene subtree cache. Boxed so `clearSubtreeCache()` can reassign — WeakMap has no + // `clear` and can't be iterated, so wholesale invalidation requires a fresh instance. + // Scene GC drops entries automatically via the WeakMap. + const perSceneBox: { map: WeakMap } = { map: new WeakMap() }; + // Parallel Set of every subscription unsubscribe fn so `clearSubtreeCache()` can eagerly + // detach from every scene we've hooked. Not a leak: unsubscribe closures hold a ref to + // the scene's internal listener array only — they do not keep the scene alive on their + // own once every other reference is dropped. + const activeSubscriptions: Set<() => void> = new Set(); + // Tracks how many entries exist across all seen scenes. + let totalCacheEntries = 0; + + const ensureSceneCache = (scene: Scene): PerSceneCache => { + let entry = perSceneBox.map.get(scene); + if (entry !== undefined) return entry; + const cache: Map = new Map(); + const listener = (ev: SceneEvent): void => { + if (ev.type !== 'structure-changed') return; + // Evict the primary node id; on 'remove' also evict every descendant id the event + // carries (Scene populates `removedNodeIds` for `remove` and any composite→composite + // replace that dropped children). + if (cache.delete(ev.nodeId)) totalCacheEntries -= 1; + if (ev.removedNodeIds !== undefined) { + for (const rid of ev.removedNodeIds) { + if (cache.delete(rid)) totalCacheEntries -= 1; + } + } + }; + const off = scene.on('structure-changed', listener); + activeSubscriptions.add(off); + entry = { cache, off }; + perSceneBox.map.set(scene, entry); + return entry; + }; + + const submit = (scene: Scene): GPUCommandBuffer => { + const cb = encode(scene); + rc.device.queue.submit([cb]); + return cb; + }; + + const encode = (scene: Scene): GPUCommandBuffer => { + const sceneCache = ensureSceneCache(scene); + const commandEncoder = rc.device.createCommandEncoder({ + label: `${rc.label ?? 'ctx'}.encoder`, + }); + const passDesc = makeBeginPassDescriptor(scene.target); + const pass = commandEncoder.beginRenderPass(passDesc); + + const ctx: WalkContext = { + pass, + active: new ActiveState(), + overrideStack: [], + counters: mkCounters(), + recorders: [], + sceneCache: sceneCache.cache, + sceneDirty: scene.dirty, + rc, + bindGroupCache: cacheStore, + cacheEntryDelta: 0, + }; + + walk(scene.root, ctx); + + pass.end(); + scene.clearDirty(); + totalCacheEntries += ctx.cacheEntryDelta; + stats = freezeStats(ctx.counters); + return commandEncoder.finish(); + }; + + const lastStats = (): EncoderStats => stats; + + const clearSubtreeCache = (): void => { + for (const off of activeSubscriptions) off(); + activeSubscriptions.clear(); + perSceneBox.map = new WeakMap(); + totalCacheEntries = 0; + }; + + const subtreeCacheSize = (): number => totalCacheEntries; + + return Object.freeze({ + __brand: GRAPH_ENCODER_BRAND, + id, + submit, + encode, + lastStats, + clearSubtreeCache, + subtreeCacheSize, + }); +} + +export function isGraphEncoder(value: unknown): value is GraphEncoder { + return ( + typeof value === 'object' && + value !== null && + (value as { __brand?: unknown }).__brand === GRAPH_ENCODER_BRAND + ); +} + +// ---- internals -------------------------------------------------------------------------------- + +/** Per-scene bookkeeping stored in the encoder's `WeakMap`. */ +interface PerSceneCache { + readonly cache: Map; + readonly off: () => void; +} + +/** Cache entry for a single composite node's subtree. */ +interface CachedSubtree { + /** Running state at the moment the walk entered this subtree. A later replay is only + * safe if the encoder's current state structurally matches this snapshot. */ + readonly entryState: ActiveStateSnapshot; + /** Running state at the moment the walk exited this subtree. Applied to `ActiveState` + * after a successful replay so subsequent siblings see the correct state. */ + readonly exitState: ActiveStateSnapshot; + /** The recorded pass commands, in the order the walk emitted them. */ + readonly commands: readonly PassCommand[]; + /** Snapshot (by reference) of the override maps on the stack at record time. If the + * current override stack differs — compared by map object identity, per index — we must + * re-record: the recorded bind groups were resolved under a different override context and + * may reference the wrong resources. Override maps are immutable-per-node (`Scene.replace` + * produces a new map), so identity comparison is exact. */ + readonly overrideStack: readonly ReadonlyMap[]; +} + +/** + * A single command-list recording in progress. The recorder stack in `WalkContext` lets a + * re-recording ancestor capture the commands emitted by nested re-records and nested replays + * alike, so the outer cache entry is a complete self-contained sequence. + */ +interface Recorder { + readonly nodeId: NodeId; + readonly entryState: ActiveStateSnapshot; + readonly overrideStack: readonly ReadonlyMap[]; + readonly commands: PassCommand[]; +} + +interface WalkContext { + readonly pass: GPURenderPassEncoder; + readonly active: ActiveState; + readonly overrideStack: ReadonlyMap[]; + readonly counters: Counters; + readonly recorders: Recorder[]; + readonly sceneCache: Map; + readonly sceneDirty: ReadonlySet; + readonly rc: RenderingContext; + readonly bindGroupCache: BindGroupCacheStore; + /** Net add/remove count of cache entries this walk applied. Reconciled into the + * encoder's running total at end of encode. */ + cacheEntryDelta: number; +} + +interface Counters { + setPipelineCalls: number; + setBindGroupCalls: number; + setVertexBufferCalls: number; + setIndexBufferCalls: number; + drawCalls: number; + setViewportCalls: number; + setScissorCalls: number; + setStencilRefCalls: number; + setBlendConstantCalls: number; + subtreeCacheHits: number; + subtreeCacheMisses: number; +} + +function mkCounters(): Counters { + return { + setPipelineCalls: 0, + setBindGroupCalls: 0, + setVertexBufferCalls: 0, + setIndexBufferCalls: 0, + drawCalls: 0, + setViewportCalls: 0, + setScissorCalls: 0, + setStencilRefCalls: 0, + setBlendConstantCalls: 0, + subtreeCacheHits: 0, + subtreeCacheMisses: 0, + }; +} + +function freezeStats(c: Counters): EncoderStats { + return Object.freeze({ ...c }); +} + +function makeBeginPassDescriptor(target: RenderTarget): GPURenderPassDescriptor { + const desc: GPURenderPassDescriptor = { + colorAttachments: [...target.color], + ...(target.depthStencil !== undefined && { depthStencilAttachment: target.depthStencil }), + ...(target.label !== undefined && { label: target.label }), + }; + return desc; +} + +/** + * Emit `cmd`: apply to the real pass encoder, tee to every active recorder in the stack, and + * increment the corresponding counter. Every state change / draw the encoder produces must + * go through this helper so recording, counting, and replaying stay perfectly consistent. + */ +function emit(cmd: PassCommand, ctx: WalkContext): void { + applyPassCommand(ctx.pass, cmd); + for (const r of ctx.recorders) r.commands.push(cmd); + incrementCounter(cmd, ctx.counters); +} + +function incrementCounter(cmd: PassCommand, c: Counters): void { + switch (cmd.kind) { + case 'setPipeline': + c.setPipelineCalls += 1; + return; + case 'setBindGroup': + c.setBindGroupCalls += 1; + return; + case 'setVertexBuffer': + c.setVertexBufferCalls += 1; + return; + case 'setIndexBuffer': + c.setIndexBufferCalls += 1; + return; + case 'setViewport': + c.setViewportCalls += 1; + return; + case 'setScissorRect': + c.setScissorCalls += 1; + return; + case 'setStencilReference': + c.setStencilRefCalls += 1; + return; + case 'setBlendConstant': + c.setBlendConstantCalls += 1; + return; + case 'draw': + case 'drawIndexed': + c.drawCalls += 1; + return; + } +} + +/** + * Whether two override stacks match. Compared by override-map object identity, per index — + * the override maps are `ReadonlyMap` and treated as immutable per-node (callers who want to + * change overrides use `Scene.replace`, which produces a new node object and a new map), so + * identity comparison is exact. Stacks are shallow (one entry per nested `override` on the + * path), so the per-index scan is cheaper than building and comparing a serialized key. + */ +function overrideStacksEqual( + a: readonly ReadonlyMap[], + b: readonly ReadonlyMap[] +): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** + * Attempt to serve `node` from the subtree cache; if not possible, walk it and record a + * fresh entry. Composite nodes only — draw leaves are handled by `walkDraw` directly. + */ +function walkComposite(node: SceneNode, ctx: WalkContext, walkChildren: () => void): void { + const cached = ctx.sceneCache.get(node.id); + const dirty = ctx.sceneDirty.has(node.id); + const currentEntry = ctx.active.snapshot(); + + if ( + !dirty && + cached !== undefined && + overrideStacksEqual(ctx.overrideStack, cached.overrideStack) && + activeStateSnapshotsEqual(currentEntry, cached.entryState) + ) { + // Cache hit — replay commands directly. + for (const cmd of cached.commands) { + applyPassCommand(ctx.pass, cmd); + // Tee into every outer recorder so their cache entries stay complete. + for (const r of ctx.recorders) r.commands.push(cmd); + incrementCounter(cmd, ctx.counters); + } + ctx.active.restore(cached.exitState); + ctx.counters.subtreeCacheHits += 1; + return; + } + + // Cache miss / dirty / mismatch — record a fresh entry. Snapshot the override stack by + // reference (the live `ctx.overrideStack` is mutated via push/pop as the walk descends). + const recorder: Recorder = { + nodeId: node.id, + entryState: currentEntry, + overrideStack: [...ctx.overrideStack], + commands: [], + }; + ctx.recorders.push(recorder); + walkChildren(); + ctx.recorders.pop(); + const exitState = ctx.active.snapshot(); + const isNew = !ctx.sceneCache.has(node.id); + ctx.sceneCache.set(node.id, { + entryState: recorder.entryState, + exitState, + commands: recorder.commands, + overrideStack: recorder.overrideStack, + }); + if (isNew) ctx.cacheEntryDelta += 1; + ctx.counters.subtreeCacheMisses += 1; +} + +function walk(node: SceneNode, ctx: WalkContext): void { + switch (node.kind) { + case 'container': + walkComposite(node, ctx, () => { + for (const c of node.children) walk(c, ctx); + }); + return; + case 'viewport': + walkComposite(node, ctx, () => walkViewport(node, ctx)); + return; + case 'scissor': + walkComposite(node, ctx, () => walkScissor(node, ctx)); + return; + case 'stencilref': + walkComposite(node, ctx, () => walkStencilRef(node, ctx)); + return; + case 'blendconstant': + walkComposite(node, ctx, () => walkBlendConstant(node, ctx)); + return; + case 'override': + walkComposite(node, ctx, () => walkOverride(node, ctx)); + return; + case 'draw': + walkDraw(node, ctx); + return; + } +} + +function walkViewport(node: ViewportNode, ctx: WalkContext): void { + const prior = ctx.active.snapshotViewport(); + const desired: ViewportValue = { + x: node.x, + y: node.y, + width: node.width, + height: node.height, + minDepth: node.minDepth, + maxDepth: node.maxDepth, + }; + if (!viewportsEqual(prior, desired)) { + emit( + { + kind: 'setViewport', + x: desired.x, + y: desired.y, + width: desired.width, + height: desired.height, + minDepth: desired.minDepth, + maxDepth: desired.maxDepth, + }, + ctx + ); + ctx.active.viewport = desired; + } + for (const c of node.children) walk(c, ctx); + if (!viewportsEqual(prior, ctx.active.viewport)) { + if (prior !== undefined) { + emit( + { + kind: 'setViewport', + x: prior.x, + y: prior.y, + width: prior.width, + height: prior.height, + minDepth: prior.minDepth, + maxDepth: prior.maxDepth, + }, + ctx + ); + } + ctx.active.viewport = prior; + } +} + +function walkScissor(node: ScissorNode, ctx: WalkContext): void { + const prior = ctx.active.snapshotScissor(); + const desired: ScissorValue = { + x: node.x, + y: node.y, + width: node.width, + height: node.height, + }; + if (!scissorsEqual(prior, desired)) { + emit( + { + kind: 'setScissorRect', + x: desired.x, + y: desired.y, + width: desired.width, + height: desired.height, + }, + ctx + ); + ctx.active.scissor = desired; + } + for (const c of node.children) walk(c, ctx); + if (!scissorsEqual(prior, ctx.active.scissor)) { + if (prior !== undefined) { + emit( + { + kind: 'setScissorRect', + x: prior.x, + y: prior.y, + width: prior.width, + height: prior.height, + }, + ctx + ); + } + ctx.active.scissor = prior; + } +} + +function walkStencilRef(node: StencilRefNode, ctx: WalkContext): void { + const prior = ctx.active.snapshotStencilRef(); + if (prior !== node.value) { + emit({ kind: 'setStencilReference', value: node.value }, ctx); + ctx.active.stencilRef = node.value; + } + for (const c of node.children) walk(c, ctx); + if (ctx.active.stencilRef !== prior) { + if (prior !== undefined) { + emit({ kind: 'setStencilReference', value: prior }, ctx); + } + ctx.active.stencilRef = prior; + } +} + +function walkBlendConstant(node: BlendConstantNode, ctx: WalkContext): void { + const prior = ctx.active.snapshotBlendConstant(); + const desired: BlendConstantValue = normalizeBlendConstant(node.color); + if (!blendConstantsEqual(prior, desired)) { + emit( + { + kind: 'setBlendConstant', + color: { r: desired[0], g: desired[1], b: desired[2], a: desired[3] }, + }, + ctx + ); + ctx.active.blendConstant = desired; + } + for (const c of node.children) walk(c, ctx); + if (!blendConstantsEqual(prior, ctx.active.blendConstant)) { + if (prior !== undefined) { + emit( + { + kind: 'setBlendConstant', + color: { r: prior[0], g: prior[1], b: prior[2], a: prior[3] }, + }, + ctx + ); + } + ctx.active.blendConstant = prior; + } +} + +function walkOverride(node: BindingOverrideNode, ctx: WalkContext): void { + ctx.overrideStack.push(node.overrides); + for (const c of node.children) walk(c, ctx); + ctx.overrideStack.pop(); + // No explicit "restore" of bind groups needed: the next draw past the override will + // produce a different cache key (no override tag) and naturally rebind the unwrapped + // groups. We DO need to flush bind-group state so the next draw's elision logic re-emits. + ctx.active.bindGroups.clear(); +} + +function walkDraw(node: DrawableNode, ctx: WalkContext): void { + emitDraw(node.drawable, ctx); +} + +function emitDraw(drawable: Drawable, ctx: WalkContext): void { + // ---- setPipeline (with bind-group invalidation on swap) ---- + // Reference equality is sound: `RenderingContext.pipeline()` caches by fingerprint, so a + // given fingerprint yields exactly one `BuiltPipeline` instance per context. + if (ctx.active.pipeline !== drawable.pipeline) { + emit({ kind: 'setPipeline', pipeline: drawable.pipeline.gpu }, ctx); + ctx.active.pipeline = drawable.pipeline; + // Conservative: clear bind-group state since layout compatibility checks are not yet + // implemented. The encoder will re-emit setBindGroup below for the new pipeline. + ctx.active.bindGroups.clear(); + } + + // ---- setBindGroup ---- + const resolved = buildBindGroupsForDraw({ + device: ctx.rc.device, + pipeline: drawable.pipeline, + drawable, + overrideStack: ctx.overrideStack, + store: ctx.bindGroupCache, + }); + for (const [groupIdx, bg] of resolved.groups) { + if (ctx.active.bindGroups.get(groupIdx) !== bg) { + emit({ kind: 'setBindGroup', index: groupIdx, bindGroup: bg }, ctx); + ctx.active.bindGroups.set(groupIdx, bg); + } + } + + // ---- setVertexBuffer ---- + for (const [slot, binding] of drawable.vertexBuffers) { + const handle = bufferHandleOf(binding.resource); + if (handle === undefined) continue; + if (!vertexHandlesEqual(ctx.active.vertexBuffers.get(slot), handle)) { + emit( + { + kind: 'setVertexBuffer', + slot, + buffer: handle.gpu, + offset: handle.offset, + size: handle.size, + }, + ctx + ); + ctx.active.vertexBuffers.set(slot, handle); + } + } + + // ---- setIndexBuffer ---- + if (drawable.indexBuffer !== undefined) { + const handle = bufferHandleOf(drawable.indexBuffer.resource); + if (handle !== undefined) { + const desired = { handle, format: drawable.indexBuffer.format }; + if (!indexBuffersEqual(ctx.active.indexBuffer, desired)) { + emit( + { + kind: 'setIndexBuffer', + buffer: handle.gpu, + format: desired.format, + offset: handle.offset, + size: handle.size, + }, + ctx + ); + ctx.active.indexBuffer = desired; + } + } + } + + // ---- draw / drawIndexed ---- + const draw = drawable.draw; + if (draw.kind === 'array') { + emit( + { + kind: 'draw', + vertexCount: draw.vertexCount, + instanceCount: draw.instanceCount ?? 1, + firstVertex: draw.firstVertex ?? 0, + firstInstance: draw.firstInstance ?? 0, + }, + ctx + ); + } else { + emit( + { + kind: 'drawIndexed', + indexCount: draw.indexCount, + instanceCount: draw.instanceCount ?? 1, + firstIndex: draw.firstIndex ?? 0, + baseVertex: draw.baseVertex ?? 0, + firstInstance: draw.firstInstance ?? 0, + }, + ctx + ); + } +} + +function bufferHandleOf(resource: Resource): BufferHandle | undefined { + if (resource.kind === 'uniform' || resource.kind === 'storage' || resource.kind === 'rawBuffer') { + return (resource as { handle: BufferHandle }).handle; + } + return undefined; +} diff --git a/packages/core/src/rendering/webgpu/encoder/pass-commands.ts b/packages/core/src/rendering/webgpu/encoder/pass-commands.ts new file mode 100644 index 00000000..1684bf8e --- /dev/null +++ b/packages/core/src/rendering/webgpu/encoder/pass-commands.ts @@ -0,0 +1,113 @@ +/** + * `PassCommand` — a plain-data description of every state-changing / draw method the encoder + * ever invokes on a `GPURenderPassEncoder`. The subtree-command cache records these into + * per-composite arrays so a later `encode(scene)` walk can `applyCommand` them directly to the + * pass encoder in lieu of re-walking (and re-computing) the subtree. + * + * `PassCommand` deliberately mirrors `GPURenderPassEncoder`'s recorded verbs one-for-one; any + * command the encoder emits must have a corresponding `PassCommand` variant here so it can be + * replayed and (optionally) counted for stats. + * + * Every buffer-carrying variant stores `(buffer, offset, size)` so a future slab manager works + * transparently — the cache never captures a `BufferHandle`, only the already-resolved + * `GPUBuffer + offset + size` triple, which is stable across frames as long as the underlying + * handle hasn't been released. + */ + +/** Discriminated union of every command the encoder can emit into a render pass. */ +export type PassCommand = + | { readonly kind: 'setPipeline'; readonly pipeline: GPURenderPipeline } + | { + readonly kind: 'setBindGroup'; + readonly index: number; + readonly bindGroup: GPUBindGroup; + } + | { + readonly kind: 'setVertexBuffer'; + readonly slot: number; + readonly buffer: GPUBuffer; + readonly offset: number; + readonly size: number; + } + | { + readonly kind: 'setIndexBuffer'; + readonly buffer: GPUBuffer; + readonly format: GPUIndexFormat; + readonly offset: number; + readonly size: number; + } + | { + readonly kind: 'setViewport'; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly minDepth: number; + readonly maxDepth: number; + } + | { + readonly kind: 'setScissorRect'; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + } + | { readonly kind: 'setStencilReference'; readonly value: number } + | { readonly kind: 'setBlendConstant'; readonly color: GPUColor } + | { + readonly kind: 'draw'; + readonly vertexCount: number; + readonly instanceCount: number; + readonly firstVertex: number; + readonly firstInstance: number; + } + | { + readonly kind: 'drawIndexed'; + readonly indexCount: number; + readonly instanceCount: number; + readonly firstIndex: number; + readonly baseVertex: number; + readonly firstInstance: number; + }; + +/** Apply a single command to the underlying pass encoder. */ +export function applyPassCommand(pass: GPURenderPassEncoder, cmd: PassCommand): void { + switch (cmd.kind) { + case 'setPipeline': + pass.setPipeline(cmd.pipeline); + return; + case 'setBindGroup': + pass.setBindGroup(cmd.index, cmd.bindGroup); + return; + case 'setVertexBuffer': + pass.setVertexBuffer(cmd.slot, cmd.buffer, cmd.offset, cmd.size); + return; + case 'setIndexBuffer': + pass.setIndexBuffer(cmd.buffer, cmd.format, cmd.offset, cmd.size); + return; + case 'setViewport': + pass.setViewport(cmd.x, cmd.y, cmd.width, cmd.height, cmd.minDepth, cmd.maxDepth); + return; + case 'setScissorRect': + pass.setScissorRect(cmd.x, cmd.y, cmd.width, cmd.height); + return; + case 'setStencilReference': + pass.setStencilReference(cmd.value); + return; + case 'setBlendConstant': + pass.setBlendConstant(cmd.color); + return; + case 'draw': + pass.draw(cmd.vertexCount, cmd.instanceCount, cmd.firstVertex, cmd.firstInstance); + return; + case 'drawIndexed': + pass.drawIndexed( + cmd.indexCount, + cmd.instanceCount, + cmd.firstIndex, + cmd.baseVertex, + cmd.firstInstance + ); + return; + } +} diff --git a/packages/core/src/rendering/webgpu/encoder/state.ts b/packages/core/src/rendering/webgpu/encoder/state.ts new file mode 100644 index 00000000..eadd4885 --- /dev/null +++ b/packages/core/src/rendering/webgpu/encoder/state.ts @@ -0,0 +1,274 @@ +/** + * `ActiveState` — the encoder's running model of the GPU pass encoder's state. Used to elide + * redundant `setPipeline` / `setBindGroup` / `setVertexBuffer` / `setIndexBuffer` / + * `setViewport` / `setScissorRect` / `setStencilReference` / `setBlendConstant` calls. + * + * Comparison semantics: + * - `pipeline` compared by reference. `RenderingContext.pipeline()`'s per-context cache + * guarantees a single `BuiltPipeline` instance per unique fingerprint, so `===` is the + * tightest and cheapest predicate — and it stays correct even if a hypothetical bug ever + * let two structurally-identical builds coexist (they'd force a redundant `setPipeline`, + * which is a no-op at the GPU level rather than an incorrectness). + * - `bindGroups` keyed by slot index; equal when the cached `GPUBindGroup` object identity + * matches (the bind-group cache hands out the same object for identical inputs). + * - `vertexBuffers` keyed by slot index; equal when `(handle.gpu, handle.offset, handle.size)` + * match. Storing the full `BufferHandle` (rather than the underlying `GPUBuffer`) is what + * lets a future slab manager work transparently — two slices of the same physical buffer + * compare unequal. + * - `indexBuffer` identical iff `(handle.gpu, handle.offset, handle.size, format)` match. + * - State node values (`viewport`, `scissor`, `stencilRef`, `blendConstant`) compared + * structurally via small per-field helpers. + */ + +import type { BufferHandle } from '../memory/types'; +import type { BuiltPipeline } from '../pipelines/build'; + +/** Snapshotable viewport state. */ +export interface ViewportValue { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly minDepth: number; + readonly maxDepth: number; +} + +/** Snapshotable scissor rect. */ +export interface ScissorValue { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +/** RGBA tuple normalised to a 4-number array for structural equality. */ +export type BlendConstantValue = readonly [number, number, number, number]; + +/** Index-buffer binding snapshot. */ +export interface IndexBufferState { + readonly handle: BufferHandle; + readonly format: GPUIndexFormat; +} + +/** + * Mutable running state. Owned by the encoder; reset at the start of every pass. + * + * ## Immutability invariant (load-bearing for `snapshot()`) + * + * Every object-valued field on this class — `pipeline`, `viewport`, `scissor`, + * `blendConstant`, `indexBuffer`, and the values stored in `bindGroups` / `vertexBuffers` — + * is treated as **immutable after assignment**. State transitions must always **replace** + * the field with a freshly-constructed object (or a new bind-group / handle reference from + * upstream caches); they must never mutate the currently-stored object in place. + * + * `snapshot()` captures references, not deep copies. The subtree-command cache in + * `encoder.ts` compares snapshots via `activeStateSnapshotsEqual`, which relies on object + * identity / structural equality of the referenced values. If a walk function ever mutated + * an already-stored value (e.g. reused a scratch `ViewportValue` and wrote to its fields), + * every previously-taken snapshot would silently drift — a cached subtree could hit as + * equal while representing different pass state, producing wrong replays. + * + * Correspondingly, the encoder's walk helpers always build a fresh literal (`const desired: + * ViewportValue = { x, y, … }`) and assign it. Keep it that way. + */ +export class ActiveState { + pipeline: BuiltPipeline | undefined; + /** Bind groups currently set on the pass, keyed by group index. */ + readonly bindGroups: Map = new Map(); + /** Vertex buffers currently set on the pass, keyed by buffer slot index. */ + readonly vertexBuffers: Map = new Map(); + indexBuffer: IndexBufferState | undefined; + viewport: ViewportValue | undefined; + scissor: ScissorValue | undefined; + stencilRef: number | undefined; + blendConstant: BlendConstantValue | undefined; + + reset(): void { + this.pipeline = undefined; + this.bindGroups.clear(); + this.vertexBuffers.clear(); + this.indexBuffer = undefined; + this.viewport = undefined; + this.scissor = undefined; + this.stencilRef = undefined; + this.blendConstant = undefined; + } + + snapshotViewport(): ViewportValue | undefined { + return this.viewport; + } + snapshotScissor(): ScissorValue | undefined { + return this.scissor; + } + snapshotStencilRef(): number | undefined { + return this.stencilRef; + } + snapshotBlendConstant(): BlendConstantValue | undefined { + return this.blendConstant; + } + /** Snapshot which bind groups are currently set, so a scoped binding-override can restore. */ + snapshotBindGroups(groups: readonly number[]): Map { + const out = new Map(); + for (const g of groups) out.set(g, this.bindGroups.get(g)); + return out; + } + + /** + * Capture a full snapshot of the current state. Used by the encoder's subtree-command cache + * to record the state at subtree entry / exit so a later replay can verify entry-state + * compatibility and restore exit state without re-walking. + */ + snapshot(): ActiveStateSnapshot { + return { + pipeline: this.pipeline, + bindGroups: new Map(this.bindGroups), + vertexBuffers: new Map(this.vertexBuffers), + indexBuffer: this.indexBuffer, + viewport: this.viewport, + scissor: this.scissor, + stencilRef: this.stencilRef, + blendConstant: this.blendConstant, + }; + } + + /** + * Overwrite this state with the contents of `snap`. Used by the subtree-command cache to + * fast-forward `active` to a subtree's cached exit state after a replay. + */ + restore(snap: ActiveStateSnapshot): void { + this.pipeline = snap.pipeline; + this.bindGroups.clear(); + for (const [k, v] of snap.bindGroups) this.bindGroups.set(k, v); + this.vertexBuffers.clear(); + for (const [k, v] of snap.vertexBuffers) this.vertexBuffers.set(k, v); + this.indexBuffer = snap.indexBuffer; + this.viewport = snap.viewport; + this.scissor = snap.scissor; + this.stencilRef = snap.stencilRef; + this.blendConstant = snap.blendConstant; + } +} + +/** + * Frozen-shape snapshot of an `ActiveState`, produced by `ActiveState.snapshot()`. All maps + * are independent copies (mutating the `ActiveState` after taking a snapshot leaves the + * snapshot intact). + */ +export interface ActiveStateSnapshot { + readonly pipeline: BuiltPipeline | undefined; + readonly bindGroups: ReadonlyMap; + readonly vertexBuffers: ReadonlyMap; + readonly indexBuffer: IndexBufferState | undefined; + readonly viewport: ViewportValue | undefined; + readonly scissor: ScissorValue | undefined; + readonly stencilRef: number | undefined; + readonly blendConstant: BlendConstantValue | undefined; +} + +/** Compare two index-buffer states for full equality. `undefined` vs `undefined` is true. */ +export function indexBuffersEqual( + a: IndexBufferState | undefined, + b: IndexBufferState | undefined +): boolean { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + return ( + a.handle.gpu === b.handle.gpu && + a.handle.offset === b.handle.offset && + a.handle.size === b.handle.size && + a.format === b.format + ); +} + +export function vertexHandlesEqual(a: BufferHandle | undefined, b: BufferHandle | undefined): boolean { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + return a.gpu === b.gpu && a.offset === b.offset && a.size === b.size; +} + +export function viewportsEqual(a: ViewportValue | undefined, b: ViewportValue | undefined): boolean { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + return ( + a.x === b.x && + a.y === b.y && + a.width === b.width && + a.height === b.height && + a.minDepth === b.minDepth && + a.maxDepth === b.maxDepth + ); +} + +export function scissorsEqual(a: ScissorValue | undefined, b: ScissorValue | undefined): boolean { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} + +export function blendConstantsEqual( + a: BlendConstantValue | undefined, + b: BlendConstantValue | undefined +): boolean { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]; +} + +/** Normalise a `GPUColorDict | readonly [r,g,b,a]` into the tuple form ActiveState stores. */ +export function normalizeBlendConstant( + color: GPUColorDict | readonly [number, number, number, number] +): BlendConstantValue { + if (Array.isArray(color)) { + const [r, g, b, a] = color as readonly [number, number, number, number]; + return [r, g, b, a]; + } + const dict = color as GPUColorDict; + return [dict.r, dict.g, dict.b, dict.a]; +} + +function bindGroupMapsEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a.size !== b.size) return false; + for (const [k, v] of a) { + if (b.get(k) !== v) return false; + } + return true; +} + +function vertexBufferMapsEqual( + a: ReadonlyMap, + b: ReadonlyMap +): boolean { + if (a.size !== b.size) return false; + for (const [k, v] of a) { + const other = b.get(k); + if (!vertexHandlesEqual(v, other)) return false; + } + return true; +} + +/** + * Structural equality across every field an `ActiveStateSnapshot` carries. The subtree-command + * cache uses this to check whether the current running state matches a cached entry state — a + * mismatch means the cached commands were recorded under different assumptions and cannot be + * safely replayed (they might, for example, omit a `setPipeline` the current caller needs). + */ +export function activeStateSnapshotsEqual( + a: ActiveStateSnapshot, + b: ActiveStateSnapshot +): boolean { + if (a === b) return true; + // Pipelines compared by reference — the per-context pipeline cache guarantees one + // `BuiltPipeline` instance per fingerprint, so `===` is the tightest sound predicate. + if (a.pipeline !== b.pipeline) return false; + if (!indexBuffersEqual(a.indexBuffer, b.indexBuffer)) return false; + if (!viewportsEqual(a.viewport, b.viewport)) return false; + if (!scissorsEqual(a.scissor, b.scissor)) return false; + if (a.stencilRef !== b.stencilRef) return false; + if (!blendConstantsEqual(a.blendConstant, b.blendConstant)) return false; + if (!bindGroupMapsEqual(a.bindGroups, b.bindGroups)) return false; + if (!vertexBufferMapsEqual(a.vertexBuffers, b.vertexBuffers)) return false; + return true; +} 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/index.ts b/packages/core/src/rendering/webgpu/index.ts new file mode 100644 index 00000000..e14e4df9 --- /dev/null +++ b/packages/core/src/rendering/webgpu/index.ts @@ -0,0 +1,166 @@ +/** + * 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 type { StructDecl, StructDeclaration, StructMemberDeclaration, WgslShader } from './shaders'; +export { + asSource, + builtin, + fragmentEntry, + isWgslShader, + location, + member, + param, + returns, + shader, + struct, + vertexEntry, +} from './shaders'; + +// ---- Declarative vertex inputs ---------------------------------------------------------------- + +export type { + VertexAttributeDecl, + VertexAttributeRef, + VertexBufferDecl, + VertexBufferSpec, + VertexLayoutDeclaration, +} from './pipelines/vertex-layout'; +/** Buffer grouping + `stepMode` + per-attribute format → `GPUVertexBufferLayout[]` + * (see `pipeline({ vertex: { layout } })`) and the typed drawable upload path. */ +export { buffer, isVertexLayout, VERTEX_LAYOUT_BRAND, vertexLayout } from './pipelines/vertex-layout'; +export type { VertexArrayKind, VertexComponentType, VertexFormatInfo } from './shaders/vertex-format'; +/** `GPUVertexFormat` metadata + the natural WGSL-type → format default. */ +export { defaultVertexFormat, VERTEX_FORMAT_INFO, vertexFormatInfo } from './shaders/vertex-format'; +export type { + VertexInputAttribute, + VertexInputBuiltin, + VertexInputBuiltinName, + VertexInputInterface, +} from './shaders/vertex-interface'; +/** The vertex shader *input interface*: ordinary `struct`s + loose `param`s (incl. builtins), + * validated up front. Feeds `vertexEntry(...)` and is grouped into buffers by `vertexLayout(...)`. */ +export { isVertexInput, VERTEX_INPUT_BUILTINS, vertexInput } from './shaders/vertex-interface'; +export type { + TypedExternalTextureSlot, + TypedSamplerSlot, + TypedStorageSlot, + TypedStorageTextureSlot, + TypedTextureSlot, + TypedUniformSlot, +} from './slot'; +export { slot } from './slot'; + +// ---- Phase 2: derived BindingGraph ------------------------------------------------------------ + +export type { BindingGraph, BindingGroup, GroupSpec } from './pipelines/binding-graph'; +export { bindings, group, isBindingGraph, isBindingGroup } from './pipelines/binding-graph'; +export type { + FragmentStateDescriptor, + NormalizedPipelineState, + PipelineStateDescriptor, + VertexStateDescriptor, +} from './pipelines/pipeline-state'; +export { resolveShaderBindings, shaderSlotEntries } from './pipelines/traverse'; + +// ---- Phase 3: Pipeline / Drawable / Scene authoring ------------------------------------------- + +/** Phase 3: device-scoped facade — owns the pipeline cache (and, per phase, BufferManager / encoder hooks). */ +export { renderingContext } from './context'; +export type { + RenderingContext, + RenderingContextSpec, + RenderingContextStats, + ResourceFor, + ResourceInit, +} from './context-types'; +export type { + BufferResource, + ExternalTextureResource, + RawBufferResource, + Resource, + SamplerResource, + StorageTextureResource, + TextureResource, +} from './data/resource'; +/** Phase 4: data-bearing `Resource` family. `ctx.resource(slot, init?)` is the public + * constructor; the raw factories are kept private to `RenderingContext` so all construction + * funnels through one place (consistent error wording, future telemetry, etc.). */ +export { isResource, RESOURCE_BRAND } from './data/resource'; +export type { + ArrayDrawCall, + Drawable, + DrawableReuseSpec, + DrawableSpec, + DrawCall, + IndexBufferBinding, + IndexedDrawCall, + IndexInput, + PreBuiltIndexInput, + PreBuiltVertexInput, + RawArrayIndexInput, + RawArraysVertexInput, + TypedVertexInput, + VertexBufferBinding, + VertexInput, +} from './drawable'; +/** Phase 5: a `Drawable` is a pipeline + resource set + draw-call descriptor. Construct via + * `ctx.drawable({...})`. */ +export { DRAWABLE_BRAND, isDrawable } from './drawable'; +export type { EncoderStats, GraphEncoder } from './encoder/encoder'; +// ---- Phase 7: encoder / submit live on `RenderingContext` (ctx.encoder() + ctx.submit(scene)). +export { GRAPH_ENCODER_BRAND, isGraphEncoder } from './encoder/encoder'; +export type { BufferManager } from './memory'; +/** A concrete `BufferManager` for `renderingContext({ bufferManager })`. */ +export { BatchPoolBufferAdapter } from './memory'; +/** Phase 3: `BuiltPipeline` is the artefact returned by `RenderingContext.pipeline()`. */ +export type { BuiltPipeline } from './pipelines/build'; +export type { ScissorSpec, ViewportSpec } from './scene/scene'; +/** Phase 6: a `Scene` is the v1 replacement for the legacy `Graph` of drawables. */ +export { + blendconstant, + container, + draw, + override, + scene, + scissor, + stencilref, + viewport, +} from './scene/scene'; +export type { + BindingOverrideNode, + BlendConstantNode, + CompositeSceneNode, + ContainerNode, + DrawableNode, + NodeId, + RenderTarget, + Scene, + SceneDescriptor, + SceneEvent, + SceneEventListener, + SceneNode, + ScissorNode, + StencilRefNode, + StructureChangedEvent, + ViewportNode, +} from './scene/types'; +export { + isScene, + isSceneNode, + SCENE_BRAND, + SCENE_NODE_BRAND, +} from './scene/types'; 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..d86be748 --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.test.ts @@ -0,0 +1,592 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DisposedBufferError, InvalidHandleError, OutOfBudgetError } from '../errors'; +import type { + BufferManager, + BufferHandle, + BufferUsageFlags, +} from '../types'; +import { uniformSlot, storageSlot, samplerSlot } from '../../resources'; +import { member, struct } from '../../shaders'; +import { BatchPoolBufferManager } from './batch-pool-buffer-manager'; +import { OutOfBucketError } from './errors'; +import type { 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'); + } + acquireForSlot(): BufferHandle { + throw new Error('noop'); + } + precheck(): boolean { + return true; + } + 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 fakeBuffer = {} as GPUBuffer; + const fake: BufferHandle = { + gpu: fakeBuffer, + offset: 0, + size: 256, + buffer: fakeBuffer, + 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); + }); +}); + +// --------------------------------------------------------------------------- +// Phase 4 extensions: slab-ready handle shape, acquireForSlot, precheck. +// --------------------------------------------------------------------------- + +describe('BatchPoolBufferManager — BufferHandle slab-ready fields', () => { + it('emits gpu/offset/size on every handle; offset is 0 and size == bucketSize for BatchPool', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + const h = manager.acquire(200, USAGE_A); + expect(h.gpu).toBe(h.buffer); + expect(h.offset).toBe(0); + expect(h.size).toBe(256); // === bucketSize for BatchPool + expect(h.bucketSize).toBe(256); + expect(h.sizeBytes).toBe(200); + }); +}); + +describe('BatchPoolBufferManager.acquireForSlot', () => { + let device: ReturnType; + let manager: BatchPoolBufferManager; + + beforeEach(() => { + device = makeFakeDevice(); + manager = new BatchPoolBufferManager({ + ...baseConfig(device), + growthBatchSize: 1, + }); + }); + + it('forwards to acquire when the usage flag-set includes the required bits', () => { + const cam = uniformSlot('cam', struct('Camera', [member('view', 'mat4x4f')])); + const h = manager.acquireForSlot( + cam, + 64, + GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + ); + expect(h.bucketSize).toBe(256); + expect(h.usage).toBe(GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST); + }); + + it('rejects a uniform slot whose requested usage is missing COPY_DST', () => { + const cam = uniformSlot('cam', struct('Camera', [member('view', 'mat4x4f')])); + expect(() => manager.acquireForSlot(cam, 64, GPUBufferUsage.UNIFORM)).toThrow( + /requires usage bits/ + ); + }); + + it('rejects a uniform slot whose requested usage is missing UNIFORM', () => { + const cam = uniformSlot('cam', struct('Camera', [member('view', 'mat4x4f')])); + expect(() => + manager.acquireForSlot(cam, 64, GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST) + ).toThrow(/UNIFORM/i); + }); + + it('accepts a storage slot with STORAGE | COPY_DST', () => { + const buf = storageSlot('buf', struct('Buf', [member('flag', 'u32')])); + const h = manager.acquireForSlot( + buf, + 4, + GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + ); + expect(h.usage).toBe(GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST); + }); + + it('throws on non-buffer-backed slot kinds', () => { + const s = samplerSlot('s', 'sampler'); + expect(() => manager.acquireForSlot(s, 64, 0)).toThrow(/not buffer-backed/); + }); +}); + +describe('BatchPoolBufferManager.precheck', () => { + it('returns true when there is plenty of budget headroom', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + maxBytes: 4096, + growthBatchSize: 1, + }); + expect(manager.precheck(256)).toBe(true); + }); + + it('returns false when adding the cost exceeds maxBytes (no evictable batches)', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + maxBytes: 256, + growthBatchSize: 1, + }); + manager.acquire(200, USAGE_A); // resident 256, 0 evictable + expect(manager.precheck(1)).toBe(false); + }); + + it('returns true when the cost fits only after counting evictable batches', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + maxBytes: 512, + growthBatchSize: 1, + }); + const h = manager.acquire(200, USAGE_A); // resident 256, 0 evictable + h.release(); // resident 256, 256 evictable + // Adding 256 fits because the existing 256 is evictable: 256 - 256 + 256 = 256 <= 512. + expect(manager.precheck(256)).toBe(true); + // Adding 257 still fits (256 - 256 + 257 = 257 <= 512). + expect(manager.precheck(257)).toBe(true); + // Adding 513 does not fit (256 - 256 + 513 = 513 > 512). + expect(manager.precheck(513)).toBe(false); + }); + + it('returns false for negative or NaN costs', () => { + const device = makeFakeDevice(); + const manager = new BatchPoolBufferManager({ + ...baseConfig(device), + maxBytes: 4096, + }); + expect(manager.precheck(-1)).toBe(false); + expect(manager.precheck(Number.NaN)).toBe(false); + }); +}); 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..cad5f2ae --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/batch-pool/batch-pool-buffer-manager.ts @@ -0,0 +1,416 @@ +/** + * `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 type { ResourceSlot } from '../../resources'; +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); + } + + acquireForSlot( + slot: ResourceSlot, + sizeBytes: number, + usage: BufferUsageFlags + ): BufferHandle { + const required = requiredUsageFor(slot); + if ((usage & required) !== required) { + throw new Error( + `BatchPoolBufferManager.acquireForSlot: slot '${slot.name}' (kind '${slot.kind}') requires ` + + `usage bits 0x${required.toString(16)} but received 0x${usage.toString(16)}.` + ); + } + return this.acquire(sizeBytes, usage); + } + + precheck(budgetCost: number): boolean { + if (!(budgetCost >= 0) || !Number.isFinite(budgetCost)) return false; + // Optimistic check: every fully-free batch can in principle be evicted to make room. + let evictableBytes = 0; + for (const pool of this.#poolsByKey.values()) { + for (const batch of pool.batches) { + if (batch.destroyed) continue; + if (batch.leasedCount === 0) { + evictableBytes += batch.buffers.length * pool.bucketSize; + } + } + } + return this.#residentBytes - evictableBytes + budgetCost <= this.maxBytes; + } + + 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 = { + gpu: buffer, + buffer, + offset: 0, + size: bucketSize, + 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; + } +} + +/** + * Map a `ResourceSlot` to the minimum `GPUBufferUsage` bits a backing buffer must carry. + * Only buffer-backed slot kinds participate; texture / sampler / external slots throw because + * they're not buffer-backed and should never reach `acquireForSlot`. + */ +function requiredUsageFor(slot: ResourceSlot): GPUBufferUsageFlags { + switch (slot.kind) { + case 'uniform': + return GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST; + case 'storage': + return GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST; + case 'texture': + case 'storageTexture': + case 'sampler': + case 'externalTexture': + throw new Error( + `BatchPoolBufferManager.acquireForSlot: slot '${slot.name}' has kind '${slot.kind}' ` + + 'which is not buffer-backed; use a TextureResource / SamplerResource factory instead.' + ); + } +} 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..df5c60b8 --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/batch-pool/errors.ts @@ -0,0 +1,20 @@ +import type { 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..52b612c8 --- /dev/null +++ b/packages/core/src/rendering/webgpu/memory/types.ts @@ -0,0 +1,231 @@ +/** + * 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'; +import type { ResourceSlot } from '../resources'; + +/** + * 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.gpu` (with + * `offset` + `size`) and call `release()` (or `manager.release(handle)`) when done. + * + * The `(gpu, offset, size)` triple is the slab-ready binding shape: every caller that issues + * WebGPU API calls — `setVertexBuffer(slot, gpu, offset, size)`, + * `setIndexBuffer(gpu, format, offset, size)`, bind-group entries `{ buffer: gpu, offset, size }`, + * `queue.writeBuffer(gpu, offset, data)` — passes `offset` and `size` straight through, so a + * future `SlabBufferManager` that hands out non-zero offsets into one giant buffer can replace + * `BatchPoolBufferManager` with no consumer changes. `BatchPoolBufferManager` always emits + * `offset: 0` and `size === bucketSize`. + * + * `buffer` is kept as a legacy alias for `gpu` so older code reading `handle.buffer` continues + * to compile during the Phase 4 migration; new code should prefer `gpu`. + * + * 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 gpu: GPUBuffer; + /** Byte offset into `gpu` where this handle's slice begins. `0` for whole-buffer handles. */ + readonly offset: number; + /** Byte length of this handle's slice. Always >= the caller's `sizeBytes` request. */ + readonly size: number; + /** Legacy alias for `gpu`. New code should prefer `gpu`. */ + readonly buffer: GPUBuffer; + /** The caller's original size request (in bytes). */ + readonly sizeBytes: number; + /** The actual physical size of the underlying allocation. For `BatchPoolBufferManager` this + * equals `size`; for slab managers it equals the full `gpu.size` and may be larger. */ + 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; + + /** + * Slot-aware `acquire`: validates that `usage` includes the required bits for the slot's + * binding kind (uniform → `UNIFORM | COPY_DST`; storage → `STORAGE | COPY_DST`) and + * forwards to `acquire(sizeBytes, usage)`. Throws `Error` with a clear message when the + * usage flag-set is missing required bits. Texture / sampler / external-texture slots + * (which never round-trip a `BufferHandle`) throw immediately. + */ + acquireForSlot(slot: ResourceSlot, sizeBytes: number, usage: BufferUsageFlags): BufferHandle; + + /** + * Non-allocating budget check used at construction sites (`ctx.resource()`, + * `ctx.drawable()`) to fail fast before any partial allocation. Returns `true` iff a + * request for `budgetCost` bytes could potentially be satisfied within `maxBytes` + * considering currently-resident bytes and any free batches that could be evicted. + * + * Implementations are expected to be conservative: a `true` result is not a guarantee of + * success (subsequent `acquire` can still fail under racy edge cases), but a `false` result + * means there is no possible way the request can fit. + */ + precheck(budgetCost: number): boolean; + + /** 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 acquireForSlot( + slot: ResourceSlot, + sizeBytes: number, + usage: BufferUsageFlags + ): BufferHandle; + abstract precheck(budgetCost: number): boolean; + abstract release(handle: BufferHandle): void; + abstract endFrame(): void; + abstract frameLease(sizeBytes: number, usage: BufferUsageFlags): BufferHandle; + abstract stats(): Stats; + abstract dispose(): void; +} 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..89e2675f --- /dev/null +++ b/packages/core/src/rendering/webgpu/native-types.ts @@ -0,0 +1,447 @@ +/** + * 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.exactOptional(), + hasDynamicOffset: z.boolean().exactOptional(), + minBindingSize: z.number().exactOptional(), +}); +export type BufferBindingLayout = z.infer; + +export const SamplerBindingLayoutSchema = z.object({ + type: SamplerBindingTypeSchema.exactOptional(), +}); +export type SamplerBindingLayout = z.infer; + +export const TextureBindingLayoutSchema = z.object({ + sampleType: TextureSampleTypeSchema.exactOptional(), + viewDimension: TextureViewDimensionSchema.exactOptional(), + multisampled: z.boolean().exactOptional(), +}); +export type TextureBindingLayout = z.infer; + +export const StorageTextureBindingLayoutSchema = z.object({ + access: StorageTextureAccessSchema.exactOptional(), + format: TextureFormatSchema, + viewDimension: TextureViewDimensionSchema.exactOptional(), +}); +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.exactOptional(), + sampler: SamplerBindingLayoutSchema.exactOptional(), + texture: TextureBindingLayoutSchema.exactOptional(), + storageTexture: StorageTextureBindingLayoutSchema.exactOptional(), + externalTexture: ExternalTextureBindingLayoutSchema.exactOptional(), +}); +export type BindGroupLayoutEntry = z.infer; + +// ---- Pipeline State types ---- + +export const StencilFaceStateSchema = z.object({ + compare: CompareFunctionSchema.exactOptional(), + failOp: StencilOperationSchema.exactOptional(), + depthFailOp: StencilOperationSchema.exactOptional(), + passOp: StencilOperationSchema.exactOptional(), +}); +export type StencilFaceState = z.infer; + +export const DepthStencilStateSchema = z.object({ + format: TextureFormatSchema, + depthWriteEnabled: z.boolean().exactOptional(), + depthCompare: CompareFunctionSchema.exactOptional(), + stencilFront: StencilFaceStateSchema.exactOptional(), + stencilBack: StencilFaceStateSchema.exactOptional(), + stencilReadMask: z.number().int().nonnegative().exactOptional(), + stencilWriteMask: z.number().int().nonnegative().exactOptional(), + depthBias: z.number().int().exactOptional(), + depthBiasSlopeScale: z.number().exactOptional(), + depthBiasClamp: z.number().exactOptional(), +}); +export type DepthStencilState = z.infer; + +export const MultisampleStateSchema = z.object({ + count: z.number().int().positive().exactOptional(), + mask: z.number().int().nonnegative().exactOptional(), + alphaToCoverageEnabled: z.boolean().exactOptional(), +}); +export type MultisampleState = z.infer; + +export const PrimitiveStateSchema = z.object({ + topology: PrimitiveTopologySchema.exactOptional(), + stripIndexFormat: IndexFormatSchema.exactOptional(), + frontFace: FrontFaceSchema.exactOptional(), + cullMode: CullModeSchema.exactOptional(), + unclippedDepth: z.boolean().exactOptional(), +}); +export type PrimitiveState = z.infer; + +export const BlendComponentSchema = z.object({ + operation: BlendOperationSchema.exactOptional(), + srcFactor: BlendFactorSchema.exactOptional(), + dstFactor: BlendFactorSchema.exactOptional(), +}); +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.exactOptional(), + writeMask: ColorWriteFlagsSchema.exactOptional(), +}); +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.exactOptional(), + attributes: z.array(VertexAttributeSchema), +}); +export type VertexBufferLayout = z.infer; + +// ---- Compile-time drift guards --------------------------------------------------------------- + +/** + * Assert that `T` is assignable to its ambient global counterpart `U`. Purely type-level (erased at + * build) — used only by `WebGpuEnumParity` below. + */ +type AssertAssignable = T; + +/** + * Compile-time cross-check that every enum schema above stays a subset of the corresponding + * ambient WebGPU global type. If a schema's literals ever drift from the WebGPU spec (a typo, a value + * removed from the ambient types, etc.) this tuple fails to type-check here — surfacing the mismatch + * at build time instead of silently at `createRenderPipeline`. The Zod schemas remain the runtime + * source of truth (no runtime enums are available from ambient sources); these guards only keep them + * honest. + */ +type WebGpuEnumParity = [ + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + // The dual-source `src1*` factors are valid current WebGPU but may be absent from older + // `lib.dom.d.ts` / `GPUBlendFactor` definitions; exclude them so the guard stays robust across + // toolchains while still catching typos in the mainstream factors. + AssertAssignable, GPUBlendFactor>, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, + AssertAssignable, +]; + +// Reference the guard tuple so it participates in type-checking (and isn't flagged as unused). +export type __WebGpuEnumParity = WebGpuEnumParity; + 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..56b9db22 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graph.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from 'vitest'; +import { samplerSlot, textureSlot, uniformSlot } from '../resources'; +import { shader } from '../shaders'; +import { bindings, group, isBindingGraph, isBindingGroup } from './binding-graph'; +import { resolveShaderBindings, shaderSlotEntries } from './traverse'; + +describe('group()', () => { + it('creates a node with slot children (no spec)', () => { + const u = uniformSlot('u', 'U'); + const g = group(u); + expect(isBindingGroup(g)).toBe(true); + expect(g.slots).toEqual([u]); + expect(g.subgroups).toEqual([]); + expect(g.label).toBeUndefined(); + expect(g.maxDepth).toBe(4); + }); + + it('accepts an optional spec object as the first argument', () => { + const u = uniformSlot('u', 'U'); + const g = group({ label: 'frame' }, u); + expect(g.label).toBe('frame'); + expect(g.slots).toEqual([u]); + }); + + it('accepts a custom maxDepth via spec', () => { + const g = group({ maxDepth: 2 }); + expect(g.maxDepth).toBe(2); + }); + + it('buckets slot vs subgroup children preserving relative source order', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const sub = group({ label: 'sub' }); + const g = group({ label: 'root' }, a, sub, b); + expect(g.slots).toEqual([a, b]); + expect(g.subgroups).toEqual([sub]); + }); + + it('throws when a child is neither a ResourceSlot nor a BindingGroup', () => { + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input + group({ label: 'g' }, { not: 'a slot' } as any) + ).toThrow(/neither a ResourceSlot nor a BindingGroup/); + }); + + it('does NOT validate within-tree slot uniqueness at construction time', () => { + // Within-tree uniqueness is enforced by `bindings()`, not by `group()`. + const u = uniformSlot('u', 'U'); + const a = group(u); + const b = group(u); + // Both nodes accept `u`; the error is raised only when `bindings()` walks a tree that + // would contain both copies (see the 'bindings()' suite below). + expect(a.slots).toEqual([u]); + expect(b.slots).toEqual([u]); + }); + + it('produces frozen BindingGroup nodes', () => { + const g = group(uniformSlot('u', 'U')); + expect(Object.isFrozen(g)).toBe(true); + }); +}); + +describe('bindings()', () => { + it('derives a graph from a single-group tree and one shader', () => { + const u = uniformSlot('u', 'U'); + const t = textureSlot('tex', 'texture_2d'); + const root = group({ label: 'frame' }, u, t); + const sh = shader([u, t]); + const g = bindings(root, sh); + expect(isBindingGraph(g)).toBe(true); + expect(g.root).toBe(root); + expect(g.shaders).toEqual([sh]); + expect(g.groups).toEqual([root]); + expect(g._groupDepth.get(root)).toBe(0); + }); + + it('accepts one or many shaders positionally', () => { + const u = uniformSlot('u', 'U'); + const root = group(u); + const shA = shader([u]); + const shB = shader([u]); + const single = bindings(root, shA); + const multi = bindings(root, shA, shB); + expect(single.shaders).toEqual([shA]); + expect(multi.shaders).toEqual([shA, shB]); + }); + + it('throws when no shaders are supplied', () => { + const root = group(uniformSlot('u', 'U')); + expect(() => bindings(root)).toThrow(/at least one shader/); + }); + + it('throws when the root is not a BindingGroup', () => { + const sh = shader([]); + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input + expect(() => bindings({} as any, sh)).toThrow(/must be a BindingGroup/); + }); + + it("throws when a shader declares a slot that is not present in the supplied tree", () => { + const present = uniformSlot('here', 'H'); + const absent = uniformSlot('missing', 'M'); + const root = group(present); + const sh = shader([absent]); + expect(() => bindings(root, sh)).toThrow(/'missing' but it is not present/); + }); + + it("ignores non-ResourceSlot declarations in the shader's declarations array", () => { + const u = uniformSlot('u', 'U'); + const root = group(u); + const extra = { __gen: () => '// noop' }; + const sh = shader([u, extra]); + expect(() => bindings(root, sh)).not.toThrow(); + }); + + it('walks subgroups top-down assigning depth = parent depth + 1', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const c = uniformSlot('c', 'C'); + const grand = group({ label: 'grand' }, c); + const child = group({ label: 'child' }, b, grand); + const root = group({ label: 'root' }, a, child); + const sh = shader([a, b, c]); + const g = bindings(root, sh); + expect(g._groupDepth.get(root)).toBe(0); + expect(g._groupDepth.get(child)).toBe(1); + expect(g._groupDepth.get(grand)).toBe(2); + // groups sorted shallowest first + expect(g.groups[0]).toBe(root); + expect(g.groups[1]).toBe(child); + expect(g.groups[2]).toBe(grand); + }); + + it('throws when within-tree slot uniqueness is violated via sibling placement', () => { + const u = uniformSlot('u', 'U'); + // The same slot appears twice as a sibling. + const root = group(u, u); + const sh = shader([u]); + expect(() => bindings(root, sh)).toThrow(/'u' appears more than once/); + }); + + it('throws when within-tree slot uniqueness is violated via subgroup nesting', () => { + const u = uniformSlot('u', 'U'); + const sub = group(u); + const root = group(u, sub); + const sh = shader([u]); + expect(() => bindings(root, sh)).toThrow(/'u' appears more than once/); + }); + + it('throws when depth meets or exceeds maxDepth', () => { + // Default maxDepth is 4 → legal depths are 0..3. Build 5 levels deep. + const lvl4 = group(uniformSlot('s4', 'S')); + const lvl3 = group(uniformSlot('s3', 'S'), lvl4); + const lvl2 = group(uniformSlot('s2', 'S'), lvl3); + const lvl1 = group(uniformSlot('s1', 'S'), lvl2); + const root = group(uniformSlot('s0', 'S'), lvl1); + const sh = shader([]); + expect(() => bindings(root, sh)).toThrow(/meets or exceeds its maxDepth of 4/); + }); + + it('honours a custom maxDepth on a subgroup node', () => { + const deep = group({ maxDepth: 2 }, uniformSlot('d', 'D')); + // deep gets depth=1 under root; its own maxDepth of 2 still permits it. + const root = group(uniformSlot('r', 'R'), deep); + const sh = shader([]); + expect(() => bindings(root, sh)).not.toThrow(); + // But pushing it deeper would fail. + const root2 = group({ maxDepth: 2 }, group(uniformSlot('x', 'X'), deep)); + expect(() => bindings(root2, shader([]))).toThrow(/meets or exceeds/); + }); + + it('throws when a BindingGroup instance is referenced more than once in the same tree', () => { + const shared = group(uniformSlot('s', 'S')); + const root = group(shared, shared); + const sh = shader([]); + expect(() => bindings(root, sh)).toThrow(/appears more than once in the tree/); + }); + + it('allows the same slot identity to live in two unrelated graph trees', () => { + const slot = uniformSlot('shared', 'S'); + const root1 = group(uniformSlot('pad', 'P'), slot); // slot becomes binding 1 here + const root2 = group(slot); // slot becomes binding 0 here + const sh = shader([slot]); + const g1 = bindings(root1, sh); + const g2 = bindings(root2, sh); + expect(g1._slotIndex.get(slot)?.binding).toBe(1); + expect(g2._slotIndex.get(slot)?.binding).toBe(0); + }); + + it('produces a frozen BindingGraph', () => { + const u = uniformSlot('u', 'U'); + const root = group(u); + const g = bindings(root, shader([u])); + expect(Object.isFrozen(g)).toBe(true); + }); +}); + +describe('resolveShaderBindings()', () => { + it('returns 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 root = group(u, t, s); + const sh = shader([u, t, s]); + const g = bindings(root, 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('uses graph-local depth as the @group(N) index for nested groups', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const c = uniformSlot('c', 'C'); + const g2 = group(c); + const g1 = group(b, g2); + const root = group(a, g1); + const sh = shader([a, b, c]); + const graph = bindings(root, sh); + const map = resolveShaderBindings(graph, 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('resolves a shared slot to identical (group, binding) across multiple shaders in one graph', () => { + const camera = uniformSlot('camera', 'Camera'); + const root = group(camera); + const shA = shader([camera]); + const shB = shader([camera]); + const g = bindings(root, shA, shB); + const a = resolveShaderBindings(g, shA); + const b = resolveShaderBindings(g, shB); + expect(a.get(camera)).toEqual({ group: 0, binding: 0 }); + expect(b.get(camera)).toEqual({ group: 0, binding: 0 }); + }); + + it('returns sparse binding indices when a shader uses a subset of a group', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const c = uniformSlot('c', 'C'); + const root = group(a, b, c); + const sh = shader([a, c]); + const g = bindings(root, sh); + const map = resolveShaderBindings(g, sh); + expect(map.get(a)).toEqual({ group: 0, binding: 0 }); + expect(map.get(c)).toEqual({ group: 0, binding: 2 }); + expect(map.has(b)).toBe(false); + }); +}); + +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 child = group(c); + const root = group(a, b, child); + const sh = shader([a, b, c]); + const g = bindings(root, sh); + const entries = shaderSlotEntries(g, sh); + expect(entries.map((e) => ({ group: e.group, binding: e.binding, name: e.slot.name }))).toEqual([ + { group: 0, binding: 0, name: 'a' }, + { group: 0, binding: 1, name: 'b' }, + { group: 1, binding: 0, name: 'c' }, + ]); + }); + + it('returns only the slots the supplied shader actually declares', () => { + const a = uniformSlot('a', 'A'); + const b = uniformSlot('b', 'B'); + const root = group(a, b); + const shA = shader([a]); + const shB = shader([b]); + const g = bindings(root, shA, shB); + expect(shaderSlotEntries(g, shA).map((e) => e.slot.name)).toEqual(['a']); + expect(shaderSlotEntries(g, shB).map((e) => e.slot.name)).toEqual(['b']); + }); + + it('includes the owning BindingGroup in each entry', () => { + const u = uniformSlot('u', 'U'); + const owner = group({ label: 'frame' }, u); + const sh = shader([u]); + const g = bindings(owner, sh); + const [entry] = shaderSlotEntries(g, sh); + expect(entry?.owner).toBe(owner); + }); +}); 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..33af9827 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/binding-graph.ts @@ -0,0 +1,277 @@ +/** + * `BindingGroup` + `bindings()` — variadic-children, graph-local-ownership model. + * + * A `BindingGroup` is a tree node that owns an ordered list of slot children (which take + * binding indices in *this* group) and an ordered list of subgroup children (which are nested + * trees). Authoring is inline: + * + * ```ts + * const material = group({ label: 'material' }, albedo, samp); + * const frame = group({ label: 'frame' }, camera, lighting, material); + * + * const shA = shader([camera, albedo, samp]); + * const graph = bindings(frame, shA); + * // multiple shaders are passed positionally: + * const merged = bindings(frame, shA, shB); + * ``` + * + * Key semantics: + * - **Slot ownership is graph-local.** A `ResourceSlot` may appear in multiple unrelated trees; + * its `(group, binding)` is determined per-graph by `bindings(root, shaders)`. + * - **Within a tree, a slot may appear at most once.** Validated by `bindings()` (not by `group()`). + * - **Binding indices** are assigned only among the *slot* children of a group, preserving their + * relative source order. Subgroup children can be freely interleaved without affecting indices. + * - **Group depth** is graph-local: root = 0, subgroup depth = parent depth + 1. The depth is + * stored on the graph (`_groupDepth`), NOT on the `BindingGroup` itself. + * - **`maxDepth`** is per-node (default 4). `bindings()` enforces it at each subgroup as the + * depth is computed. + * + * Per-shader resolution lives in `./traverse.ts`. `resolveShaderBindings(graph, shader)` walks + * `shader.declarations`, filters to `ResourceSlot`s, and looks each up in `graph._slotIndex`. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { isResourceSlot, type ResourceSlot } from '../resources'; +import type { WgslShader } from '../shaders'; + +// ---- Public types ----------------------------------------------------------------------------- + +/** + * An immutable tree node carrying ordered slot bindings and ordered subgroup children. Depth + * and parent links are graph-local and do NOT live on this object — see `BindingGraph._groupDepth`. + * + * To author a new layout, build a new `BindingGroup`. Two `bindings()` calls over disjoint trees + * may resolve the same slot identity to different `(group, binding)` indices. + */ +export type BindingGroup = { + readonly __nodeType: 'binding-group'; + readonly id: string; + readonly label?: string; + /** Slot children in source order; each one's index in this array is its binding index. */ + readonly slots: readonly ResourceSlot[]; + /** Subgroup children in source order. Each becomes a nested group at depth+1 in a graph. */ + readonly subgroups: readonly BindingGroup[]; + /** Per-node maximum depth permitted at this position in a tree (default 4). */ + readonly maxDepth: number; +}; + +/** Optional metadata accepted as the first positional argument of `group()`. */ +export type GroupSpec = { + readonly label?: string; + /** Override the default group-depth limit (4). */ + readonly maxDepth?: number; +}; + +/** + * A validated derivation from a `BindingGroup` tree plus one or more shaders. Carries the root, + * the shaders that were resolved, and graph-local slot↔binding + group↔depth indices. + */ +export type BindingGraph = { + readonly __graphType: 'binding-graph'; + /** The root group supplied to `bindings()`. Lives at depth 0 in this graph. */ + readonly root: BindingGroup; + readonly shaders: readonly WgslShader[]; + /** All groups reachable from `root`, sorted shallowest-first with ties broken by id. */ + readonly groups: readonly BindingGroup[]; + /** Graph-local resolution of every slot reachable from `root`. */ + readonly _slotIndex: ReadonlyMap< + ResourceSlot, + { readonly group: number; readonly binding: number; readonly owner: BindingGroup } + >; + /** Graph-local depth lookup for every group in `groups`. */ + readonly _groupDepth: ReadonlyMap; +}; + +// ---- Runtime type guards ----------------------------------------------------------------------- + +export function isBindingGroup(value: unknown): value is BindingGroup { + return ( + typeof value === 'object' && + value !== null && + (value as { __nodeType?: unknown }).__nodeType === 'binding-group' + ); +} + +export function isBindingGraph(value: unknown): value is BindingGraph { + return ( + typeof value === 'object' && + value !== null && + (value as { __graphType?: unknown }).__graphType === 'binding-graph' + ); +} + +// ---- Construction ------------------------------------------------------------------------------ + +const DEFAULT_GROUP_DEPTH_LIMIT = 4; + +/** + * Returns true when `value` is a plain `GroupSpec` (an object that is neither a `ResourceSlot` + * nor a `BindingGroup`). Used by `group()` to detect the optional first-arg-as-spec overload. + */ +function isGroupSpec(value: unknown): value is GroupSpec { + return ( + typeof value === 'object' && + value !== null && + !isResourceSlot(value) && + !isBindingGroup(value) + ); +} + +/** + * Construct a `BindingGroup`. Accepts an optional spec object followed by any number of + * `ResourceSlot` and/or `BindingGroup` children. Children are bucketed by kind preserving + * their relative source order; slot children's positions become this group's binding indices. + * + * Overloads: + * ```ts + * group(slotA, slotB) // no spec + * group({ label: 'frame' }, slotA, slotB) // with spec + * group({ label: 'frame' }, slotA, subgroup, slotB) // mixed children + * ``` + * + * Validation performed here is shallow: each variadic arg must be a `ResourceSlot` or + * `BindingGroup`. Within-tree slot uniqueness and per-node `maxDepth` are validated later by + * `bindings()` because they are tree-shape invariants, not local construction invariants. + */ +export function group(...args: Array): BindingGroup { + let spec: GroupSpec | undefined; + let children: Array; + if (args.length > 0 && isGroupSpec(args[0])) { + spec = args[0]; + children = args.slice(1) as Array; + } else { + children = args as Array; + } + + const slots: ResourceSlot[] = []; + const subgroups: BindingGroup[] = []; + for (let i = 0; i < children.length; i++) { + const c = children[i]; + if (isResourceSlot(c)) { + slots.push(c); + } else if (isBindingGroup(c)) { + subgroups.push(c); + } else { + throw new Error( + `group: child at position ${i} of group '${spec?.label ?? ''}' is ` + + 'neither a ResourceSlot nor a BindingGroup.' + ); + } + } + + const node: BindingGroup = { + __nodeType: 'binding-group', + id: uuidv4(), + ...(spec?.label !== undefined && { label: spec.label }), + slots, + subgroups, + maxDepth: spec?.maxDepth ?? DEFAULT_GROUP_DEPTH_LIMIT, + }; + + return Object.freeze(node); +} + +/** + * Derive a `BindingGraph` from a root `BindingGroup` and one or more shaders. Walks the tree + * top-down assigning each node a depth, builds the slot↔binding index, enforces per-node + * `maxDepth` + within-tree slot uniqueness, and validates that every shader's declared slots + * appear in the tree. + * + * @throws if the supplied input violates any of the validation rules listed above. + */ +export function bindings(root: BindingGroup, ...shaders: readonly WgslShader[]): BindingGraph { + if (!isBindingGroup(root)) { + throw new Error('bindings: first argument must be a BindingGroup (constructed via `group()`).'); + } + if (shaders.length === 0) { + throw new Error('bindings: at least one shader is required'); + } + + const slotIndex = new Map< + ResourceSlot, + { group: number; binding: number; owner: BindingGroup } + >(); + const groupDepth = new Map(); + const allGroups: BindingGroup[] = []; + + // Iterative DFS so the stack is explicit and the error messages can mention the offending + // node without recursion-driven obfuscation. + type Frame = { node: BindingGroup; depth: number }; + const stack: Frame[] = [{ node: root, depth: 0 }]; + while (stack.length > 0) { + const { node, depth } = stack.pop() as Frame; + + if (depth >= node.maxDepth) { + throw new Error( + `bindings: group '${node.label ?? ''}' (id='${node.id}') reached depth ` + + `${depth} which meets or exceeds its maxDepth of ${node.maxDepth}.` + ); + } + + if (groupDepth.has(node)) { + // A group reached twice via different paths means the same group instance was used + // as a subgroup of more than one parent (or transitively a descendant of itself). + // Rare in practice but cheap to catch. + throw new Error( + `bindings: group '${node.label ?? ''}' (id='${node.id}') appears more ` + + 'than once in the tree (a BindingGroup may be referenced as a subgroup at ' + + 'most once per graph).' + ); + } + groupDepth.set(node, depth); + allGroups.push(node); + + for (let i = 0; i < node.slots.length; i++) { + const slot = node.slots[i] as ResourceSlot; + if (slotIndex.has(slot)) { + const prior = slotIndex.get(slot) as { owner: BindingGroup }; + throw new Error( + `bindings: slot '${slot.name}' appears more than once in the tree ` + + `(first owner: group '${prior.owner.label ?? ''}'; ` + + `duplicate owner: group '${node.label ?? ''}'). A slot may ` + + 'appear at most once per graph.' + ); + } + slotIndex.set(slot, { group: depth, binding: i, owner: node }); + } + + // Push subgroups in reverse so DFS visits them left-to-right (stable, intuitive order). + for (let i = node.subgroups.length - 1; i >= 0; i--) { + stack.push({ node: node.subgroups[i] as BindingGroup, depth: depth + 1 }); + } + } + + // Validate every shader's declared slots are present in this graph. + for (const sh of shaders) { + for (const decl of sh.declarations) { + if (!isResourceSlot(decl)) continue; + if (!slotIndex.has(decl)) { + throw new Error( + `bindings: shader (id='${sh.id}') declares slot '${decl.name}' but it is not ` + + "present in the supplied group tree (rooted at '" + + (root.label ?? '') + + "').", + ); + } + } + } + + // Stable group order: shallowest first. `allGroups` is populated in deterministic DFS + // discovery order and `Array.prototype.sort` is stable, so same-depth ties keep that DFS + // order without needing a synthetic construction id. + allGroups.sort((a, b) => { + const da = groupDepth.get(a) as number; + const db = groupDepth.get(b) as number; + return da - db; + }); + + const graph: BindingGraph = { + __graphType: 'binding-graph', + root, + shaders, + groups: allGroups, + _slotIndex: slotIndex, + _groupDepth: groupDepth, + }; + return Object.freeze(graph); +} diff --git a/packages/core/src/rendering/webgpu/pipelines/build.test.ts b/packages/core/src/rendering/webgpu/pipelines/build.test.ts new file mode 100644 index 00000000..a1de510e --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/build.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from 'vitest'; +import { renderingContext } from '../context'; +import { ShaderStageFlag } from '../native-types'; +import { samplerSlot, textureSlot, uniformSlot } from '../resources'; +import { member, shader, struct } from '../shaders'; +import type { MockGpuBindGroupLayout, MockGpuRenderPipeline } from '../test/mock-device'; +import { makeMockDevice } from '../test/mock-device'; +import { bindings, group } from './binding-graph'; +import { resolveShaderBindings } from './traverse'; + +// ----- helpers ------------------------------------------------------------ + +const cameraStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +const minimalFragmentState = { + targets: [{ format: 'bgra8unorm' as const }], +}; + +function mkColorPipelineState(label?: string) { + return { + ...(label !== undefined && { label }), + primitive: { topology: 'triangle-list' as const }, + fragment: minimalFragmentState, + }; +} + +// ----- tests -------------------------------------------------------------- + +describe('RenderingContext.pipeline() — happy path', () => { + it('creates one BGL per group depth, one pipeline layout, one shader module, one pipeline', () => { + const camera = uniformSlot('camera', cameraStruct); + const root = group({ label: 'frame' }, camera); + const sh = shader([cameraStruct, camera]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const built = ctx.pipeline(graph, sh, mkColorPipelineState('p')); + + expect(m.calls.createBindGroupLayout).toHaveBeenCalledTimes(1); + expect(m.calls.createPipelineLayout).toHaveBeenCalledTimes(1); + expect(m.calls.createShaderModule).toHaveBeenCalledTimes(1); + expect(m.calls.createRenderPipeline).toHaveBeenCalledTimes(1); + + expect(built.bindGroupLayouts).toHaveLength(1); + expect((built.bindGroupLayouts[0] as unknown as MockGpuBindGroupLayout).__mockKind).toBe( + 'bindGroupLayout' + ); + }); + + it('builds one BGL per depth for nested groups (indexed by depth)', () => { + const cam = uniformSlot('camera', cameraStruct); + const tex = textureSlot('albedo', 'texture_2d'); + const samp = samplerSlot('samp', 'sampler'); + const material = group({ label: 'material' }, tex, samp); + const frame = group({ label: 'frame' }, cam, material); + const sh = shader([cameraStruct, cam, tex, samp]); + const graph = bindings(frame, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const built = ctx.pipeline(graph, sh, mkColorPipelineState()); + + expect(built.bindGroupLayouts).toHaveLength(2); + // depth 0 has the uniform camera; depth 1 has texture + sampler + const bgl0 = m.created.bindGroupLayouts[0]; + const bgl1 = m.created.bindGroupLayouts[1]; + expect(bgl0?.descriptor.entries).toHaveLength(1); + expect(bgl1?.descriptor.entries).toHaveLength(2); + }); + + it('emits an empty BGL at intermediate depths the shader does not use', () => { + // Build a 3-level group hierarchy but the shader only references depth 0 and depth 2. + const a = uniformSlot('a', cameraStruct); + const skipFiller = uniformSlot('skip', cameraStruct); + const c = uniformSlot('c', cameraStruct); + const g2 = group(c); + const g1 = group(skipFiller, g2); + const g0 = group(a, g1); + + const sh = shader([cameraStruct, a, c]); // declares depths 0 and 2 only + const graph = bindings(g0, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const built = ctx.pipeline(graph, sh, mkColorPipelineState()); + + expect(built.bindGroupLayouts).toHaveLength(3); + expect(m.created.bindGroupLayouts[0]?.descriptor.entries).toHaveLength(1); + expect(m.created.bindGroupLayouts[1]?.descriptor.entries).toHaveLength(0); // empty middle + expect(m.created.bindGroupLayouts[2]?.descriptor.entries).toHaveLength(1); + }); + + it('builds a fragment-less pipeline when state.fragment is absent', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, { primitive: { topology: 'triangle-list' } }); + + const desc = m.created.renderPipelines[0]?.descriptor; + expect(desc?.fragment).toBeUndefined(); + expect(desc?.vertex.entryPoint).toBe('vs_main'); + }); + + it('defaults entry points to vs_main and fs_main', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, mkColorPipelineState()); + + const desc = m.created.renderPipelines[0]?.descriptor; + expect(desc?.vertex.entryPoint).toBe('vs_main'); + expect(desc?.fragment?.entryPoint).toBe('fs_main'); + }); + + it('honours explicit entry-point overrides', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, { + vertex: { entryPoint: 'my_vs' }, + fragment: { entryPoint: 'my_fs', targets: [{ format: 'bgra8unorm' }] }, + }); + + const desc = m.created.renderPipelines[0]?.descriptor; + expect(desc?.vertex.entryPoint).toBe('my_vs'); + expect(desc?.fragment?.entryPoint).toBe('my_fs'); + }); +}); + +describe('RenderingContext.pipeline() — WGSL + reflection', () => { + it('feeds asSource(bindShader(...)) to createShaderModule', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, mkColorPipelineState()); + + // The WGSL fed to createShaderModule should contain the resolved binding annotation — + // i.e. bindShader ran before asSource. Slot 'camera' lives in group 0 binding 0. + const moduleDesc = m.created.shaderModules[0]?.descriptor; + expect(moduleDesc?.code).toContain('@group(0) @binding(0)'); + expect(moduleDesc?.code).toContain('camera'); + }); + + it('slotIndex matches resolveShaderBindings output', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const built = ctx.pipeline(graph, sh, mkColorPipelineState()); + + const expected = resolveShaderBindings(graph, sh); + expect([...built.slotIndex.entries()]).toEqual([...expected.entries()]); + }); + + it('populates defs from makeShaderDataDefinitions', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const built = ctx.pipeline(graph, sh, mkColorPipelineState()); + + // ShaderDataDefinitions exposes structs and uniforms; webgpu-utils picks them up from + // the WGSL source. Exact field names depend on the library, so just smoke-check shape. + expect(built.defs).toBeDefined(); + expect(built.defs.structs).toBeDefined(); + expect(built.defs.uniforms).toBeDefined(); + }); +}); + +describe('RenderingContext.pipeline() — visibility resolution', () => { + it('uses slot.visibility when explicitly set', () => { + const cam = uniformSlot('camera', cameraStruct, { visibility: ShaderStageFlag.VERTEX }); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, mkColorPipelineState()); + + const entry = m.created.bindGroupLayouts[0]?.descriptor.entries[0]; + expect(entry?.visibility).toBe(ShaderStageFlag.VERTEX); + }); + + it('defaults visibility to VERTEX | FRAGMENT when slot does not declare', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, mkColorPipelineState()); + + const entry = m.created.bindGroupLayouts[0]?.descriptor.entries[0]; + expect(entry?.visibility).toBe(ShaderStageFlag.VERTEX | ShaderStageFlag.FRAGMENT); + }); +}); + +describe('RenderingContext.pipeline() — error propagation', () => { + it('propagates createRenderPipeline errors', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + m.calls.createRenderPipeline.mockImplementationOnce(() => { + throw new Error('boom'); + }); + + const ctx = renderingContext({ device: m.device }); + expect(() => ctx.pipeline(graph, sh, mkColorPipelineState())).toThrow(/boom/); + }); + + it('throws on malformed PipelineStateDescriptor (Zod)', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + expect(() => + ctx.pipeline( + graph, + sh, + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input + { primitive: { topology: 'bogus' } } as any + ) + ).toThrow(); + }); +}); + +describe('RenderingContext.pipeline() — descriptor composition', () => { + it('wires the pipeline layout from the produced BGLs in depth order', () => { + const cam = uniformSlot('camera', cameraStruct); + const tex = textureSlot('albedo', 'texture_2d'); + const material = group(tex); + const frame = group(cam, material); + const sh = shader([cameraStruct, cam, tex]); + const graph = bindings(frame, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, mkColorPipelineState('p')); + + const layoutDesc = m.created.pipelineLayouts[0]?.descriptor; + expect(layoutDesc?.bindGroupLayouts).toHaveLength(2); + expect(layoutDesc?.bindGroupLayouts[0]).toBe(m.created.bindGroupLayouts[0]); + expect(layoutDesc?.bindGroupLayouts[1]).toBe(m.created.bindGroupLayouts[1]); + }); + + it('forwards primitive / depthStencil / multisample / label to the descriptor', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + ctx.pipeline(graph, sh, { + label: 'p', + primitive: { topology: 'line-strip', cullMode: 'none' }, + depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }, + multisample: { count: 4 }, + fragment: minimalFragmentState, + }); + const desc = m.created.renderPipelines[0]?.descriptor as GPURenderPipelineDescriptor & { + label?: string; + }; + expect(desc.label).toBe('p'); + expect(desc.primitive?.topology).toBe('line-strip'); + expect(desc.depthStencil?.format).toBe('depth24plus'); + expect(desc.multisample?.count).toBe(4); + }); + + it('returns a frozen BuiltPipeline', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const built = ctx.pipeline(graph, sh, mkColorPipelineState()); + expect(Object.isFrozen(built)).toBe(true); + expect(built.shader).toBe(sh); + expect((built.gpu as unknown as MockGpuRenderPipeline).__mockKind).toBe('renderPipeline'); + }); +}); + +describe('RenderingContext.pipeline() — cache', () => { + it('returns the same BuiltPipeline instance for identical inputs', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + const state = mkColorPipelineState('p'); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const a = ctx.pipeline(graph, sh, state); + const b = ctx.pipeline(graph, sh, state); + expect(a).toBe(b); + expect(m.calls.createRenderPipeline).toHaveBeenCalledTimes(1); + expect(m.calls.createBindGroupLayout).toHaveBeenCalledTimes(1); + expect(m.calls.createShaderModule).toHaveBeenCalledTimes(1); + }); + + it('caches across key-equivalent state objects (key-order independence)', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const a = ctx.pipeline(graph, sh, { + primitive: { topology: 'triangle-list', cullMode: 'back' }, + fragment: minimalFragmentState, + }); + const b = ctx.pipeline(graph, sh, { + fragment: minimalFragmentState, + primitive: { cullMode: 'back', topology: 'triangle-list' }, + }); + expect(a).toBe(b); + expect(m.calls.createRenderPipeline).toHaveBeenCalledTimes(1); + }); + + it('produces distinct instances for differing state', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const a = ctx.pipeline(graph, sh, { + primitive: { topology: 'triangle-list' }, + fragment: minimalFragmentState, + }); + const b = ctx.pipeline(graph, sh, { + primitive: { topology: 'line-list' }, + fragment: minimalFragmentState, + }); + expect(a).not.toBe(b); + expect(a.fingerprint).not.toBe(b.fingerprint); + expect(m.calls.createRenderPipeline).toHaveBeenCalledTimes(2); + }); + + it('isolates cache per-RenderingContext instance (two contexts → distinct instances)', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + const state = mkColorPipelineState(); + + // Two contexts against the same device — each gets its own cache. + const m = makeMockDevice(); + const ctx1 = renderingContext({ device: m.device, label: 'ctx1' }); + const ctx2 = renderingContext({ device: m.device, label: 'ctx2' }); + const a = ctx1.pipeline(graph, sh, state); + const b = ctx2.pipeline(graph, sh, state); + expect(a).not.toBe(b); + expect(a.fingerprint).toBe(b.fingerprint); // same content → same fingerprint + expect(m.calls.createRenderPipeline).toHaveBeenCalledTimes(2); + expect(ctx1.pipelineCount).toBe(1); + expect(ctx2.pipelineCount).toBe(1); + }); + + it('populates a real fingerprint (not the 3b placeholder)', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device }); + const built = ctx.pipeline(graph, sh, mkColorPipelineState()); + expect(built.fingerprint).not.toBe('pending'); + expect(built.fingerprint.startsWith('pl_')).toBe(true); + }); + + it('dispose() clears the cache; subsequent pipeline() throws with the context label', () => { + const cam = uniformSlot('camera', cameraStruct); + const root = group(cam); + const sh = shader([cameraStruct, cam]); + const graph = bindings(root, sh); + + const m = makeMockDevice(); + const ctx = renderingContext({ device: m.device, label: 'ctx-A' }); + ctx.pipeline(graph, sh, mkColorPipelineState()); + expect(ctx.pipelineCount).toBe(1); + + ctx.dispose(); + expect(ctx.pipelineCount).toBe(0); + expect(ctx.disposed).toBe(true); + expect(() => ctx.pipeline(graph, sh, mkColorPipelineState())).toThrow(/ctx-A/); + expect(() => ctx.pipeline(graph, sh, mkColorPipelineState())).toThrow( + /use-after-dispose/ + ); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/build.ts b/packages/core/src/rendering/webgpu/pipelines/build.ts new file mode 100644 index 00000000..1e824ff7 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/build.ts @@ -0,0 +1,197 @@ +/** + * Internal pipeline build helper used by `RenderingContext.pipeline()`. + * + * Build sequence (consumes pre-computed `normalizedState`, `slotIndex`, `fingerprint` — + * `RenderingContext` performs those steps and the cache check before calling in): + * 1. `bindShader(shader, slotIndex)` + `asSource(...)` — produce final WGSL. + * 2. `makeShaderDataDefinitions(wgsl)` — reflect struct layouts onto `BuiltPipeline.defs`. + * Stashed for Phase 4's `BufferResource` round-trip; not consumed by Phase 3 itself. + * 3. Bucket `shaderSlotEntries(graph, shader)` by group depth → manually construct + * `GPUBindGroupLayoutEntry`s via existing `toBindGroupLayoutEntry`. Sparse depths (a shader + * uses depth 0 + 2 but not 1) get an empty layout so `bindGroupLayouts[i]` always equals + * depth `i`. + * 4. `device.createBindGroupLayout` ×N → `createPipelineLayout` → `createShaderModule`. + * 5. Compose `GPURenderPipelineDescriptor` from the normalized state and `createRenderPipeline`. + * + * Pure function — no module-level state. `BuiltPipeline` is the type exposed publicly; this + * builder is internal (not re-exported from the webgpu barrel). + */ + +import { makeShaderDataDefinitions, type ShaderDataDefinitions } from 'webgpu-utils'; +import { type BindGroupLayoutEntry, ShaderStageFlag, type ShaderStageFlags } from '../native-types'; +import { + type BindingMap, + type BoundSlot, + bindShader, + bind as bindSlot, + type ResourceSlot, + toBindGroupLayoutEntry, +} from '../resources'; +import { asSource, type WgslShader } from '../shaders'; +import type { BindingGraph } from './binding-graph'; +import type { NormalizedPipelineState } from './pipeline-state'; +import { shaderSlotEntries } from './traverse'; + +/** + * The compiled artefact returned by `RenderingContext.pipeline()`. + * + * - `gpu` is the `GPURenderPipeline` ready for `setPipeline()`. + * - `bindGroupLayouts[i]` corresponds to group index `i` (= depth). Sparse depths are empty BGLs. + * - `slotIndex` is the `BindingMap` produced by `resolveShaderBindings` — Drawables consult it to + * look up `(group, binding)` for each `ResourceSlot` when assembling bind groups. + * - `defs` is the `webgpu-utils` reflection cache. Phase 4 will feed this to `makeStructuredView`. + * - `fingerprint` keys the per-`RenderingContext` cache. Because that cache guarantees a + * single `BuiltPipeline` instance per unique fingerprint, downstream code that needs to + * compare pipelines for equality does so by reference (`a === b`); the fingerprint is used + * only for stringy needs (bind-group cache keys, debug labels, error messages). + */ +export interface BuiltPipeline { + readonly gpu: GPURenderPipeline; + readonly layout: GPUPipelineLayout; + readonly bindGroupLayouts: readonly GPUBindGroupLayout[]; + readonly slotIndex: BindingMap; + readonly defs: ShaderDataDefinitions; + readonly fingerprint: string; + readonly state: NormalizedPipelineState; + readonly shader: WgslShader; +} + +/** Default visibility for a slot that doesn't declare its own. Tier-3 inference would tighten this. */ +const DEFAULT_VISIBILITY: ShaderStageFlags = ShaderStageFlag.VERTEX | ShaderStageFlag.FRAGMENT; + +/** + * Build a `BuiltPipeline`. Internal — call sites go through `RenderingContext.pipeline()`, + * which performs `normalizePipelineState` / `resolveShaderBindings` / `pipelineFingerprint` + * and the cache check before invoking this builder. + */ +export function buildPipeline( + device: GPUDevice, + graph: BindingGraph, + shader: WgslShader, + normalizedState: NormalizedPipelineState, + slotIndex: BindingMap, + fingerprint: string +): BuiltPipeline { + const label = normalizedState.label; + const boundShader = bindShader(shader, slotIndex); + const wgsl = asSource(boundShader); + const defs = makeShaderDataDefinitions(wgsl); + + const bindGroupLayouts = buildBindGroupLayouts(graph, shader, slotIndex, device, label); + const layout = device.createPipelineLayout({ + bindGroupLayouts: [...bindGroupLayouts], + ...(label !== undefined && { label: `${label}.layout` }), + }); + const module = device.createShaderModule({ + code: wgsl, + ...(label !== undefined && { label: `${label}.module` }), + }); + + const descriptor = composeRenderPipelineDescriptor(layout, module, normalizedState); + const gpu = device.createRenderPipeline(descriptor); + + return Object.freeze({ + gpu, + layout, + bindGroupLayouts, + slotIndex, + defs, + fingerprint, + state: normalizedState, + shader, + }); +} + +/** + * Build one `GPUBindGroupLayout` per group depth used (inclusive of intermediate skipped depths, + * which receive an empty layout). Returns an array indexed by depth. + */ +function buildBindGroupLayouts( + graph: BindingGraph, + shader: WgslShader, + slotIndex: BindingMap, + device: GPUDevice, + labelBase?: string +): readonly GPUBindGroupLayout[] { + // Bucket entries by depth. `shaderSlotEntries` already returns them sorted by (group, binding). + const entries = shaderSlotEntries(graph, shader); + const maxDepth = entries.reduce((m, e) => Math.max(m, e.group), -1); + if (maxDepth < 0) { + // Shader declares no resource slots — empty pipeline layout. + return []; + } + + const perDepth: Array<{ slot: ResourceSlot; binding: number }[]> = Array.from( + { length: maxDepth + 1 }, + () => [] + ); + for (const e of entries) { + perDepth[e.group]?.push({ slot: e.slot, binding: e.binding }); + } + + const layouts: GPUBindGroupLayout[] = []; + for (let depth = 0; depth <= maxDepth; depth++) { + const slots = perDepth[depth] ?? []; + const bglEntries: BindGroupLayoutEntry[] = slots.map((s) => { + // Bind each slot at its assigned (group, binding) so `toBindGroupLayoutEntry` can read + // metadata off the `BoundSlot`. Identity of the original slot is preserved via the + // wrapper's spread; only `__gen()` differs. + const idx = slotIndex.get(s.slot); + if (idx === undefined) { + // Defensive: `shaderSlotEntries` and `resolveShaderBindings` walk the same + // declarations, so this should be unreachable. Keep the check as a tripwire. + throw new Error( + `pipeline: slot '${s.slot.name}' was reported by shaderSlotEntries but is ` + + "absent from resolveShaderBindings output (internal inconsistency)." + ); + } + const bound: BoundSlot = bindSlot(s.slot, idx.group, idx.binding); + const visibility = s.slot.visibility ?? DEFAULT_VISIBILITY; + return toBindGroupLayoutEntry(bound, visibility); + }); + layouts.push( + device.createBindGroupLayout({ + entries: bglEntries as unknown as GPUBindGroupLayoutEntry[], + ...(labelBase !== undefined && { label: `${labelBase}.bgl[${depth}]` }), + }) + ); + } + return layouts; +} + +/** + * Compose a `GPURenderPipelineDescriptor` from a layout, a shader module, and a normalized state. + * Fragment is omitted entirely when `state.fragment` is undefined (depth-only / shadow pipelines). + */ +function composeRenderPipelineDescriptor( + layout: GPUPipelineLayout, + module: GPUShaderModule, + state: NormalizedPipelineState +): GPURenderPipelineDescriptor { + const desc: GPURenderPipelineDescriptor = { + layout, + vertex: { + module, + entryPoint: state.vertex.entryPoint, + ...(state.vertex.buffers !== undefined && { + buffers: state.vertex.buffers, + }), + ...(state.vertex.constants !== undefined && { constants: state.vertex.constants }), + }, + ...(state.primitive !== undefined && { primitive: state.primitive }), + ...(state.depthStencil !== undefined && { depthStencil: state.depthStencil }), + ...(state.multisample !== undefined && { multisample: state.multisample }), + ...(state.fragment !== undefined && { + fragment: { + module, + entryPoint: state.fragment.entryPoint, + targets: state.fragment.targets as unknown as GPUColorTargetState[], + ...(state.fragment.constants !== undefined && { + constants: state.fragment.constants, + }), + }, + }), + ...(state.label !== undefined && { label: state.label }), + }; + return desc; +} diff --git a/packages/core/src/rendering/webgpu/pipelines/fingerprint.test.ts b/packages/core/src/rendering/webgpu/pipelines/fingerprint.test.ts new file mode 100644 index 00000000..4472b8ad --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/fingerprint.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { uniformSlot } from '../resources'; +import { member, shader, struct } from '../shaders'; +import { bindings, group } from './binding-graph'; +import { pipelineFingerprint } from './fingerprint'; +import { normalizePipelineState, type PipelineStateDescriptor } from './pipeline-state'; +import { resolveShaderBindings } from './traverse'; + +const camStruct = struct('Camera', [ + member('view', 'mat4x4f'), + member('proj', 'mat4x4f'), +]); + +function setup() { + const cam = uniformSlot('camera', camStruct); + const root = group(cam); + const sh = shader([camStruct, cam]); + const graph = bindings(root, sh); + return { sh, graph, slotIndex: resolveShaderBindings(graph, sh) }; +} + +describe('pipelineFingerprint()', () => { + it('produces identical fingerprints for identical inputs', () => { + const { sh, slotIndex } = setup(); + const state = normalizePipelineState({ primitive: { topology: 'triangle-list' } }); + const a = pipelineFingerprint(sh, slotIndex, state); + const b = pipelineFingerprint(sh, slotIndex, state); + expect(a).toBe(b); + expect(a.startsWith('pl_')).toBe(true); + }); + + it('changes when shader.id differs', () => { + const a = setup(); + const b = setup(); + // Two distinct calls to shader() produce different uuid ids → different fingerprints. + const state = normalizePipelineState({}); + expect(pipelineFingerprint(a.sh, a.slotIndex, state)).not.toBe( + pipelineFingerprint(b.sh, b.slotIndex, state) + ); + }); + + it('changes when slot binding indices differ', () => { + const { sh } = setup(); + const state = normalizePipelineState({}); + // Build two different slot-index maps for the same shader.id. + const slot = uniformSlot('slot', camStruct); + const slotIndexA = new Map([[slot, { group: 0, binding: 0 }]]); + const slotIndexB = new Map([[slot, { group: 0, binding: 1 }]]); + expect(pipelineFingerprint(sh, slotIndexA, state)).not.toBe( + pipelineFingerprint(sh, slotIndexB, state) + ); + }); + + it('changes when any state field differs', () => { + const { sh, slotIndex } = setup(); + const a = pipelineFingerprint( + sh, + slotIndex, + normalizePipelineState({ primitive: { topology: 'triangle-list' } }) + ); + const b = pipelineFingerprint( + sh, + slotIndex, + normalizePipelineState({ primitive: { topology: 'line-list' } }) + ); + expect(a).not.toBe(b); + }); + + it('is invariant to state key order (delegated to normalize)', () => { + const { sh, slotIndex } = setup(); + const stateA: PipelineStateDescriptor = { + primitive: { topology: 'triangle-list', cullMode: 'back' }, + multisample: { count: 4 }, + }; + const stateB: PipelineStateDescriptor = { + multisample: { count: 4 }, + primitive: { cullMode: 'back', topology: 'triangle-list' }, + }; + expect( + pipelineFingerprint(sh, slotIndex, normalizePipelineState(stateA)) + ).toBe(pipelineFingerprint(sh, slotIndex, normalizePipelineState(stateB))); + }); + + it('changes when entry point names differ (via normalize)', () => { + const { sh, slotIndex } = setup(); + const a = pipelineFingerprint( + sh, + slotIndex, + normalizePipelineState({ vertex: { entryPoint: 'a' } }) + ); + const b = pipelineFingerprint( + sh, + slotIndex, + normalizePipelineState({ vertex: { entryPoint: 'b' } }) + ); + expect(a).not.toBe(b); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/fingerprint.ts b/packages/core/src/rendering/webgpu/pipelines/fingerprint.ts new file mode 100644 index 00000000..343e3ad8 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/fingerprint.ts @@ -0,0 +1,59 @@ +/** + * Deterministic fingerprint for a built pipeline. + * + * Two `pipeline(graph, shader, state, device)` calls collide iff: + * - they're for the same `WgslShader` (compared by `shader.id`); + * - every slot the shader declares resolves to the same `(group, binding)`; + * - the supplied `state` normalizes to the same canonical form (see `pipeline-state.ts`). + * + * The hash itself is djb2-32 over the canonical-JSON payload. djb2 is sync, fast, and 32-bit — + * collision risk for the small populations a single `GPUDevice` will ever cache is negligible, + * and we use it as a cache key (not for cryptographic intent). + */ + +import type { BindingMap } from '../resources'; +import type { WgslShader } from '../shaders'; +import type { NormalizedPipelineState } from './pipeline-state'; + +/** Canonical payload that the fingerprint hashes. */ +interface FingerprintPayload { + readonly shaderId: string; + readonly slots: ReadonlyArray; + readonly state: NormalizedPipelineState; +} + +/** + * Compute a deterministic fingerprint for a `(shader, slotIndex, normalizedState)` triple. + * Returns a hex string of the form `'pl_'` (the prefix makes the value easy to grep). + */ +export function pipelineFingerprint( + shader: WgslShader, + slotIndex: BindingMap, + normalizedState: NormalizedPipelineState +): string { + const slots: Array = []; + for (const [slot, { group, binding }] of slotIndex) { + slots.push([slot.name, group, binding] as const); + } + slots.sort((a, b) => a[0].localeCompare(b[0])); + const payload: FingerprintPayload = { + shaderId: shader.id, + slots, + state: normalizedState, + }; + const ps = JSON.stringify(payload); + return `pl_${djb2(ps).toString(16)}`; +} + +/** + * djb2 hash (32-bit unsigned). Sync, deterministic, no Web Crypto dependency. Adequate for + * cache-key use. + */ +function djb2(s: string): number { + let h = 5381; + for (let i = 0; i < s.length; i++) { + // Multiply by 33 (via shift+add) and XOR with the next char code. + h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; + } + return h; +} diff --git a/packages/core/src/rendering/webgpu/pipelines/pipeline-state.test.ts b/packages/core/src/rendering/webgpu/pipelines/pipeline-state.test.ts new file mode 100644 index 00000000..9e58c314 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/pipeline-state.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { ZodError } from 'zod'; +import { + DEFAULT_FRAGMENT_ENTRY_POINT, + DEFAULT_VERTEX_ENTRY_POINT, + type PipelineStateDescriptor, + PipelineStateDescriptorSchema, + normalizePipelineState, +} from './pipeline-state'; + +describe('PipelineStateDescriptorSchema', () => { + it('accepts an empty descriptor', () => { + expect(() => PipelineStateDescriptorSchema.parse({})).not.toThrow(); + }); + + it('accepts a fully-specified descriptor', () => { + const full: PipelineStateDescriptor = { + label: 'pipeline-A', + vertex: { + entryPoint: 'my_vs', + buffers: [ + { + arrayStride: 16, + attributes: [{ format: 'float32x3', offset: 0, shaderLocation: 0 }], + }, + ], + constants: { hairCount: 32 }, + }, + primitive: { topology: 'triangle-list', cullMode: 'back', frontFace: 'ccw' }, + depthStencil: { + format: 'depth24plus', + depthWriteEnabled: true, + depthCompare: 'less', + }, + multisample: { count: 4 }, + fragment: { + entryPoint: 'my_fs', + targets: [{ format: 'bgra8unorm' }], + }, + }; + expect(() => PipelineStateDescriptorSchema.parse(full)).not.toThrow(); + }); + + it('rejects malformed input', () => { + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input + PipelineStateDescriptorSchema.parse({ primitive: { topology: 'bogus' } } as any) + ).toThrow(ZodError); + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input + PipelineStateDescriptorSchema.parse({ fragment: {} } as any) + ).toThrow(ZodError); // fragment.targets required when fragment is present + }); +}); + +describe('normalizePipelineState()', () => { + it('defaults vertex.entryPoint to vs_main', () => { + const n = normalizePipelineState({}); + expect(n.vertex.entryPoint).toBe(DEFAULT_VERTEX_ENTRY_POINT); + }); + + it('defaults fragment.entryPoint to fs_main when fragment is present', () => { + const n = normalizePipelineState({ fragment: { targets: [{ format: 'bgra8unorm' }] } }); + expect(n.fragment?.entryPoint).toBe(DEFAULT_FRAGMENT_ENTRY_POINT); + }); + + it('omits fragment entirely when not supplied (depth-only pipeline)', () => { + const n = normalizePipelineState({}); + expect(n.fragment).toBeUndefined(); + }); + + it('preserves explicit entry-point names', () => { + const n = normalizePipelineState({ + vertex: { entryPoint: 'my_vs' }, + fragment: { entryPoint: 'my_fs', targets: [{ format: 'bgra8unorm' }] }, + }); + expect(n.vertex.entryPoint).toBe('my_vs'); + expect(n.fragment?.entryPoint).toBe('my_fs'); + }); + + it('produces deep-equal output for inputs that differ only in key order', () => { + const a = normalizePipelineState({ + primitive: { topology: 'triangle-list', cullMode: 'back' }, + multisample: { count: 4 }, + label: 'p', + }); + const b = normalizePipelineState({ + label: 'p', + multisample: { count: 4 }, + primitive: { cullMode: 'back', topology: 'triangle-list' }, + }); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it('is idempotent', () => { + const once = normalizePipelineState({ + vertex: { entryPoint: 'my_vs' }, + primitive: { topology: 'triangle-strip' }, + }); + const twice = normalizePipelineState(once as PipelineStateDescriptor); + expect(JSON.stringify(once)).toBe(JSON.stringify(twice)); + }); + + it('throws on malformed input', () => { + expect(() => + // biome-ignore lint/suspicious/noExplicitAny: deliberate invalid input + normalizePipelineState({ depthStencil: { format: 'not-a-format' } } as any) + ).toThrow(ZodError); + }); + + it('preserves vertex buffer layouts and pipeline constants', () => { + const n = normalizePipelineState({ + vertex: { + buffers: [ + { + arrayStride: 12, + attributes: [{ format: 'float32x3', offset: 0, shaderLocation: 0 }], + }, + ], + constants: { sampleCount: 8 }, + }, + }); + expect(n.vertex.buffers).toHaveLength(1); + expect(n.vertex.buffers?.[0]?.arrayStride).toBe(12); + expect(n.vertex.constants).toEqual({ sampleCount: 8 }); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/pipeline-state.ts b/packages/core/src/rendering/webgpu/pipelines/pipeline-state.ts new file mode 100644 index 00000000..3fb6071f --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/pipeline-state.ts @@ -0,0 +1,180 @@ +/** + * `PipelineStateDescriptor` — the typed POJO consumed by `pipeline(graph, shader, state, device)`. + * + * It mirrors `GPURenderPipelineDescriptor` minus the parts the pipeline builder derives itself: + * `layout` is built from the binding graph, and `vertex.module` / `fragment.module` come from + * compiling the shader. Everything else — primitive / depthStencil / multisample / vertex + * buffer layouts / fragment targets / entry-point names / pipeline-constants — must round-trip + * the caller's intent so the same shader paired with different states produces distinct + * `BuiltPipeline`s. + * + * The companion `normalizePipelineState(state)` runs Zod validation, defaults the entry-point + * names to `vs_main` / `fs_main`, and produces a canonical object (recursively key-sorted) ready + * for the fingerprint hash in Slice 3c. + */ + +import { z } from 'zod'; +import { + type ColorTargetState, + ColorTargetStateSchema, + type DepthStencilState, + DepthStencilStateSchema, + type MultisampleState, + MultisampleStateSchema, + type PrimitiveState, + PrimitiveStateSchema, + type VertexBufferLayout, + VertexBufferLayoutSchema, +} from '../native-types'; +import { deriveVertexBufferLayouts, type VertexLayoutDeclaration } from './vertex-layout'; + +/** Default WGSL entry point assumed when none is supplied on `state.vertex`. */ +export const DEFAULT_VERTEX_ENTRY_POINT = 'vs_main'; +/** Default WGSL entry point assumed when none is supplied on `state.fragment`. */ +export const DEFAULT_FRAGMENT_ENTRY_POINT = 'fs_main'; + +/** Vertex-stage configuration. `module` is derived from the shader, so it does not appear here. */ +export interface VertexStateDescriptor { + readonly entryPoint?: string; + readonly buffers?: readonly VertexBufferLayout[]; + /** Declarative vertex layout (see `vertexLayout(...)`). When present, `buffers` is derived + * from it during normalization; mutually exclusive with a hand-written `buffers`. */ + readonly layout?: VertexLayoutDeclaration; + readonly constants?: Readonly>; +} + +/** Fragment-stage configuration. Omit entirely for depth-only / shadow-pass pipelines. */ +export interface FragmentStateDescriptor { + readonly entryPoint?: string; + readonly targets: readonly ColorTargetState[]; + readonly constants?: Readonly>; +} + +/** The descriptor consumed by `pipeline()`. */ +export interface PipelineStateDescriptor { + readonly vertex?: VertexStateDescriptor; + readonly primitive?: PrimitiveState; + readonly depthStencil?: DepthStencilState; + readonly multisample?: MultisampleState; + readonly fragment?: FragmentStateDescriptor; + readonly label?: string; +} + +// ---- Zod schemas ---------------------------------------------------------------------------- + +const ConstantsSchema = z.record(z.string(), z.number()); + +const VertexStateDescriptorSchema = z.object({ + entryPoint: z.string().optional(), + buffers: z.array(VertexBufferLayoutSchema).optional(), + constants: ConstantsSchema.optional(), +}); + +const FragmentStateDescriptorSchema = z.object({ + entryPoint: z.string().optional(), + targets: z.array(ColorTargetStateSchema), + constants: ConstantsSchema.optional(), +}); + +export const PipelineStateDescriptorSchema = z.object({ + vertex: VertexStateDescriptorSchema.optional(), + primitive: PrimitiveStateSchema.optional(), + depthStencil: DepthStencilStateSchema.optional(), + multisample: MultisampleStateSchema.optional(), + fragment: FragmentStateDescriptorSchema.optional(), + label: z.string().optional(), +}); + +// ---- Normalization -------------------------------------------------------------------------- + +/** + * A `PipelineStateDescriptor` after Zod validation, entry-point defaulting, and canonical + * key-ordering. Two semantically-equal `PipelineStateDescriptor`s normalize to deeply-equal + * `NormalizedPipelineState`s, so the result is suitable for direct use as a fingerprint payload. + */ +export interface NormalizedPipelineState { + readonly vertex: { + readonly entryPoint: string; + readonly buffers?: readonly VertexBufferLayout[]; + readonly constants?: Readonly>; + }; + readonly primitive?: PrimitiveState; + readonly depthStencil?: DepthStencilState; + readonly multisample?: MultisampleState; + readonly fragment?: { + readonly entryPoint: string; + readonly targets: readonly ColorTargetState[]; + readonly constants?: Readonly>; + }; + readonly label?: string; +} + +/** + * Validate and canonicalize a `PipelineStateDescriptor`. + * + * - Throws a descriptive `ZodError` if `state` is structurally malformed. + * - Defaults `vertex.entryPoint` to `vs_main` and (when `fragment` is present) + * `fragment.entryPoint` to `fs_main`. + * - Returns an object whose own keys (and the keys of every nested object) are sorted + * alphabetically so that `JSON.stringify(result)` is stable regardless of input key order. + * - Idempotent: `normalizePipelineState(normalizePipelineState(s))` returns a deeply-equal value. + */ +export function normalizePipelineState(state: PipelineStateDescriptor): NormalizedPipelineState { + const parsed = PipelineStateDescriptorSchema.parse(resolveVertexLayout(state)); + const out: Record = {}; + out.vertex = sortKeys({ + entryPoint: parsed.vertex?.entryPoint ?? DEFAULT_VERTEX_ENTRY_POINT, + ...(parsed.vertex?.buffers !== undefined && { buffers: parsed.vertex.buffers }), + ...(parsed.vertex?.constants !== undefined && { constants: parsed.vertex.constants }), + }); + if (parsed.primitive !== undefined) out.primitive = sortKeys(parsed.primitive); + if (parsed.depthStencil !== undefined) out.depthStencil = sortKeys(parsed.depthStencil); + if (parsed.multisample !== undefined) out.multisample = sortKeys(parsed.multisample); + if (parsed.fragment !== undefined) { + out.fragment = sortKeys({ + entryPoint: parsed.fragment.entryPoint ?? DEFAULT_FRAGMENT_ENTRY_POINT, + targets: parsed.fragment.targets.map((t) => sortKeys(t)), + ...(parsed.fragment.constants !== undefined && { constants: parsed.fragment.constants }), + }); + } + if (parsed.label !== undefined) out.label = parsed.label; + return sortKeys(out) as unknown as NormalizedPipelineState; +} + +/** + * Recursively reorder an object's own enumerable keys alphabetically. Arrays are preserved in + * order (their semantics depend on index). Primitives pass through. Used to make the normalized + * state deeply key-stable so JSON serialization is canonical. + */ +function sortKeys(value: T): T { + if (Array.isArray(value)) { + return value.map((v) => sortKeys(v)) as unknown as T; + } + if (value !== null && typeof value === 'object') { + const src = value as Record; + const out: Record = {}; + for (const k of Object.keys(src).sort()) { + out[k] = sortKeys(src[k]); + } + return out as unknown as T; + } + return value; +} + +/** + * Derive `vertex.buffers` from a declarative `vertex.layout` when present, returning a plain + * descriptor for Zod validation (which strips the non-schema `layout` field). Throws if both + * `layout` and `buffers` are supplied. No-op when `layout` is absent. + */ +function resolveVertexLayout(state: PipelineStateDescriptor): PipelineStateDescriptor { + const vertex = state.vertex; + if (vertex?.layout === undefined) return state; + if (vertex.buffers !== undefined) { + throw new Error( + 'normalizePipelineState: vertex.layout and vertex.buffers are mutually exclusive — ' + + 'declare a vertexLayout(...) OR hand-write buffers, not both.' + ); + } + const { layout, ...vertexRest } = vertex; + return { ...state, vertex: { ...vertexRest, buffers: deriveVertexBufferLayouts(layout) } }; +} 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..5923bc09 --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/traverse.ts @@ -0,0 +1,87 @@ +/** + * Traversal helpers for the variadic-children `BindingGraph` model (see `./binding-graph.ts`). + * + * The traversal is **per-shader**: each shader's `(group, binding)` resolution comes from + * walking its own `declarations` array, filtering to `ResourceSlot`s, and looking up each slot + * in the graph-local `_slotIndex`. Slot ownership is graph-local — the same `ResourceSlot` can + * resolve to different indices in two different graphs. + * + * Resolution rules: + * - **Group index** = the slot's graph-local depth (root = 0). + * - **Binding index** = the slot's position within its owning group's `slots` array. + * - Two shaders that share a slot in the *same graph* resolve it to identical `(group, binding)`, + * because `bindings()` enforces within-tree slot uniqueness. + * + * 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 { isResourceSlot, type BindingMap, type ResourceSlot } from '../resources'; +import type { WgslShader } from '../shaders'; +import type { BindingGraph, BindingGroup } from './binding-graph'; + +/** Resolve a single slot's `(group, binding)` from the graph or throw if it is not present. */ +function resolveSlot( + slot: ResourceSlot, + graph: BindingGraph, + shaderId: string +): { group: number; binding: number } { + const entry = graph._slotIndex.get(slot); + if (entry === undefined) { + throw new Error( + `resolveShaderBindings: slot '${slot.name}' is not present in the supplied graph ` + + `(shader id='${shaderId}'). Did you forget to include it under the root passed to ` + + '`bindings(root, shaders)`?' + ); + } + return { group: entry.group, binding: entry.binding }; +} + +/** + * Resolve `{group, binding}` indices for every `ResourceSlot` the supplied `shader` declares. + * + * @returns a `BindingMap` covering the shader's slots; suitable for `bindShader(shader, map)`. + * + * @throws if any declared slot is not present in `graph._slotIndex`. This is normally impossible + * when `graph` was produced by `bindings(root, shader)` — the same validation runs there — but + * the per-slot check is repeated here as a defence-in-depth invariant. + */ +export function resolveShaderBindings(graph: BindingGraph, shader: WgslShader): BindingMap { + const result = new Map(); + for (const decl of shader.declarations) { + if (!isResourceSlot(decl)) continue; + result.set(decl, resolveSlot(decl, graph, shader.id)); + } + return result; +} + +/** + * Enumerate every `(group, binding, slot, owner)` for a shader, sorted by `(group, 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 slot: ResourceSlot; + readonly owner: BindingGroup; +}> { + const entries: { group: number; binding: number; slot: ResourceSlot; owner: BindingGroup }[] = []; + for (const decl of shader.declarations) { + if (!isResourceSlot(decl)) continue; + const entry = graph._slotIndex.get(decl); + if (entry === undefined) { + throw new Error( + `shaderSlotEntries: slot '${decl.name}' is not present in the supplied graph ` + + `(shader id='${shader.id}').` + ); + } + entries.push({ group: entry.group, binding: entry.binding, slot: decl, owner: entry.owner }); + } + entries.sort((a, b) => a.group - b.group || a.binding - b.binding); + return entries; +} diff --git a/packages/core/src/rendering/webgpu/pipelines/vertex-layout.test.ts b/packages/core/src/rendering/webgpu/pipelines/vertex-layout.test.ts new file mode 100644 index 00000000..e6a4aaef --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/vertex-layout.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for `vertexLayout(vin, ...)` — deriving `GPUVertexBufferLayout[]` and the typed-upload + * packing from a validated `vertexInput(...)` interface + a buffer grouping / stepMode / format + * descriptor. Covers format defaulting + overrides, tight offset/stride packing, multi-buffer + + * instancing, validation, pipeline-state integration + fingerprint parity, and the interleave packer. + */ + +import { describe, expect, it } from 'vitest'; +import { shader } from '../shaders'; +import { location } from '../shaders/attributes'; +import { member, param, struct } from '../shaders/declarations'; +import { defaultVertexFormat, VERTEX_FORMAT_INFO } from '../shaders/vertex-format'; +import { vertexInput } from '../shaders/vertex-interface'; +import { pipelineFingerprint } from './fingerprint'; +import { normalizePipelineState } from './pipeline-state'; +import { + buffer, + deriveVertexBufferLayouts, + interleaveVertexBuffer, + packVertexBuffer, + vertexLayout, +} from './vertex-layout'; + +// ---- shared fixtures -------------------------------------------------------------------------- + +/** position (vec3f, loc 0) + color (vec4f, loc 1). */ +function posColorInput() { + return vertexInput([ + struct('VertexIn', [ + member('position', 'vec3f', [location(0)]), + member('color', 'vec4f', [location(1)]), + ]), + ]); +} + +describe('vertex-format defaults', () => { + it('maps WGSL types to their natural full-precision format', () => { + expect(defaultVertexFormat('vec3f')).toBe('float32x3'); + expect(defaultVertexFormat('f32')).toBe('float32'); + expect(defaultVertexFormat('vec2u')).toBe('uint32x2'); + expect(defaultVertexFormat('vec4i')).toBe('sint32x4'); + expect(defaultVertexFormat('vec2h')).toBe('float16x2'); + }); + + it('has no default for types with no matching format (e.g. vec3h)', () => { + expect(defaultVertexFormat('vec3h')).toBeUndefined(); + }); + + it('format metadata stays intact after the module move', () => { + expect(VERTEX_FORMAT_INFO.unorm8x4.wgslType).toBe('vec4f'); + expect(VERTEX_FORMAT_INFO.float32x3.byteSize).toBe(12); + }); +}); + +describe('vertexLayout — single interleaved buffer with defaulted formats', () => { + it('defaults each attribute format from its WGSL type and packs tightly', () => { + const vin = posColorInput(); + const layout = vertexLayout(vin, [buffer('vertex', [0, 1])]); + expect(deriveVertexBufferLayouts(layout)).toEqual([ + { + arrayStride: 28, // vec3f(12) + vec4f(16) + attributes: [ + { format: 'float32x3', offset: 0, shaderLocation: 0 }, + { format: 'float32x4', offset: 12, shaderLocation: 1 }, + ], + }, + ]); + }); + + it('honors a per-attribute format override compatible with the WGSL type', () => { + const vin = posColorInput(); + // color is vec4f in-shader but unorm8x4 on the wire. + const layout = vertexLayout(vin, [buffer('vertex', [0, [1, 'unorm8x4']])]); + expect(deriveVertexBufferLayouts(layout)).toEqual([ + { + arrayStride: 16, // vec3f(12) + unorm8x4(4) + attributes: [ + { format: 'float32x3', offset: 0, shaderLocation: 0 }, + { format: 'unorm8x4', offset: 12, shaderLocation: 1 }, + ], + }, + ]); + }); +}); + +describe('vertexLayout — multi-buffer + instancing', () => { + it('emits one layout per buffer, stepMode only for instance buffers', () => { + const vin = vertexInput([ + struct('Geo', [member('position', 'vec3f', [location(0)])]), + param('offset', 'vec3f', [location(1)]), + param('tint', 'vec4f', [location(2)]), + ]); + const layout = vertexLayout(vin, [ + buffer('vertex', [0]), + buffer('instance', [1, [2, 'unorm8x4']]), + ]); + expect(deriveVertexBufferLayouts(layout)).toEqual([ + { arrayStride: 12, attributes: [{ format: 'float32x3', offset: 0, shaderLocation: 0 }] }, + { + arrayStride: 16, + stepMode: 'instance', + attributes: [ + { format: 'float32x3', offset: 0, shaderLocation: 1 }, + { format: 'unorm8x4', offset: 12, shaderLocation: 2 }, + ], + }, + ]); + }); +}); + +describe('vertexLayout — validation', () => { + it('throws when an interface attribute is left unassigned', () => { + const vin = posColorInput(); + expect(() => vertexLayout(vin, [buffer('vertex', [0])])).toThrow( + /@location\(1\) \('color'\) is not assigned/ + ); + }); + + it('throws when a location is placed in more than one buffer', () => { + const vin = posColorInput(); + expect(() => + vertexLayout(vin, [buffer('vertex', [0, 1]), buffer('instance', [1])]) + ).toThrow(/assigned to more than one buffer/); + }); + + it('throws when referencing a location not in the interface', () => { + const vin = posColorInput(); + expect(() => vertexLayout(vin, [buffer('vertex', [0, 1, 9])])).toThrow( + /@location\(9\) is not declared/ + ); + }); + + it('throws when an override format is WGSL-type-incompatible', () => { + const vin = posColorInput(); + // position is vec3f; unorm8x4 presents as vec4f → mismatch. + expect(() => vertexLayout(vin, [buffer('vertex', [[0, 'unorm8x4'], 1])])).toThrow( + /presents as WGSL 'vec4f' but the input declares 'vec3f'/ + ); + }); + + it('throws when a WGSL type has no default format and none is given', () => { + const vin = vertexInput([param('half3', 'vec3h', [location(0)])]); + expect(() => vertexLayout(vin, [buffer('vertex', [0])])).toThrow( + /no default GPUVertexFormat for WGSL type 'vec3h'/ + ); + }); +}); + +describe('pipeline-state integration', () => { + const vin = posColorInput(); + const handWritten = { + arrayStride: 28, + attributes: [ + { format: 'float32x3' as const, offset: 0, shaderLocation: 0 }, + { format: 'float32x4' as const, offset: 12, shaderLocation: 1 }, + ], + }; + + it('normalizes a declared layout to the same buffers as the hand-written form', () => { + const declared = normalizePipelineState({ + vertex: { layout: vertexLayout(vin, [buffer('vertex', [0, 1])]) }, + }); + const manual = normalizePipelineState({ vertex: { buffers: [handWritten] } }); + expect(declared.vertex.buffers).toEqual(manual.vertex.buffers); + }); + + it('produces an identical pipeline fingerprint to the hand-written buffers', () => { + const sh = shader([]); + const slots = new Map(); + const declared = pipelineFingerprint( + sh, + slots, + normalizePipelineState({ vertex: { layout: vertexLayout(vin, [buffer('vertex', [0, 1])]) } }) + ); + const manual = pipelineFingerprint( + sh, + slots, + normalizePipelineState({ vertex: { buffers: [handWritten] } }) + ); + expect(declared).toBe(manual); + }); +}); + +describe('interleaveVertexBuffer — typed packing', () => { + it('interleaves float + normalized attributes at derived offsets/stride', () => { + const vin = posColorInput(); + const layout = vertexLayout(vin, [buffer('vertex', [0, [1, 'unorm8x4']])]); + const [buf] = layout.buffers; + if (buf === undefined) throw new Error('unreachable'); + + const bytes = interleaveVertexBuffer(buf, { + position: [1, 2, 3, 4, 5, 6], // 2 vertices + color: [255, 0, 0, 255, 0, 255, 0, 255], + }); + expect(bytes.byteLength).toBe(32); // 2 * stride 16 + const dv = new DataView(bytes); + expect(dv.getFloat32(0, true)).toBe(1); + expect(dv.getUint8(12)).toBe(255); + expect(dv.getFloat32(16, true)).toBe(4); + expect(dv.getUint8(29)).toBe(255); + }); + + it('throws when attributes disagree on vertex count', () => { + const vin = posColorInput(); + const layout = vertexLayout(vin, [buffer('vertex', [0, 1])]); + const [buf] = layout.buffers; + if (buf === undefined) throw new Error('unreachable'); + expect(() => + interleaveVertexBuffer(buf, { position: [1, 2, 3], color: [1, 2, 3, 4, 5, 6, 7, 8] }) + ).toThrow(/share a vertex count/); + }); + + it('packVertexBuffer rejects an empty buffer', () => { + expect(() => packVertexBuffer({ stepMode: 'vertex', attributes: [] })).toThrow( + /at least one attribute/ + ); + }); +}); diff --git a/packages/core/src/rendering/webgpu/pipelines/vertex-layout.ts b/packages/core/src/rendering/webgpu/pipelines/vertex-layout.ts new file mode 100644 index 00000000..28be5b8f --- /dev/null +++ b/packages/core/src/rendering/webgpu/pipelines/vertex-layout.ts @@ -0,0 +1,317 @@ +/** + * Vertex buffer layout: compose a concrete `GPUVertexBufferLayout[]` (and the per-buffer packing + * used by the typed drawable upload) from a validated vertex *input interface* + * (`shaders/vertex-interface.ts`) plus a buffer-grouping/`stepMode`/format descriptor. + * + * The three concerns are kept separate on purpose: + * - the WGSL input interface + validation lives in `vertexInput(...)`; + * - **buffer grouping + `stepMode`** and **per-attribute wire format** live here, because neither + * is encoded by the WGSL interface. Each attribute's `GPUVertexFormat` defaults from its WGSL + * type (`vec3f → float32x3`) and may be overridden per attribute (`[loc, 'unorm8x4']`). + * + * Layout rules (WebGPU): + * - Attributes are packed tightly in the order listed per buffer; each offset is aligned up to + * `min(4, componentByteSize)`. + * - `arrayStride` is the packed size rounded up to a multiple of 4. + * - `@location`s are unique across the whole interface (enforced by `vertexInput`), and every + * `@location` attribute must be assigned to exactly one buffer here. + * - `stepMode` is omitted when `'vertex'` (the WebGPU default) so a derived layout is byte-for- + * byte identical to the equivalent hand-written `{ arrayStride, attributes }`, keeping the + * pipeline fingerprint stable. + */ + +import type { VertexAttribute, VertexBufferLayout, VertexFormat, VertexStepMode } from '../native-types'; +import { + defaultVertexFormat, + type VertexArrayKind, + vertexFormatInfo, +} from '../shaders/vertex-format'; +import type { VertexInputAttribute, VertexInputInterface } from '../shaders/vertex-interface'; + +// ---- Resolved layout data types -------------------------------------------------------------- + +/** A resolved attribute placed in a buffer: host name, wire format, and WGSL `@location`. */ +export interface VertexAttributeDecl { + readonly name: string; + readonly format: VertexFormat; + readonly location: number; +} + +/** A resolved buffer: its step mode + the attributes packed into it (in order). */ +export interface VertexBufferDecl { + readonly stepMode: VertexStepMode; + readonly attributes: readonly VertexAttributeDecl[]; +} + +/** Brand marking a resolved vertex-layout declaration. */ +export const VERTEX_LAYOUT_BRAND: unique symbol = Symbol.for('vis-core.webgpu.VertexLayout'); + +/** The resolved layout: ordered buffers (index = WebGPU vertex buffer slot). Produced by + * `vertexLayout(...)`, consumed by pipeline state (`deriveVertexBufferLayouts`) and the typed + * drawable upload (`interleaveVertexBuffer`). */ +export interface VertexLayoutDeclaration { + readonly __brand: typeof VERTEX_LAYOUT_BRAND; + readonly buffers: readonly VertexBufferDecl[]; +} + +/** Runtime discriminator for a `VertexLayoutDeclaration`. */ +export function isVertexLayout(value: unknown): value is VertexLayoutDeclaration { + return ( + typeof value === 'object' && + value !== null && + (value as { __brand?: unknown }).__brand === VERTEX_LAYOUT_BRAND + ); +} + +// ---- Layout authoring ------------------------------------------------------------------------ + +/** A reference to an interface attribute placed into a buffer: a bare `@location` (format + * defaulted from the WGSL type) or a `[location, format]` tuple to override the wire format. */ +export type VertexAttributeRef = number | readonly [location: number, format: VertexFormat]; + +/** One buffer's grouping: its `stepMode` (default `'vertex'`) and the attributes it carries. */ +export interface VertexBufferSpec { + readonly stepMode?: VertexStepMode; + readonly attributes: readonly VertexAttributeRef[]; +} + +/** Ergonomic `VertexBufferSpec` constructor: `buffer('instance', [2, [3, 'unorm8x4']])`. */ +export function buffer( + stepMode: VertexStepMode, + attributes: readonly VertexAttributeRef[] +): VertexBufferSpec { + return { stepMode, attributes }; +} + +function refParts(ref: VertexAttributeRef): readonly [number, VertexFormat | undefined] { + return typeof ref === 'number' ? [ref, undefined] : [ref[0], ref[1]]; +} + +/** + * Resolve a `VertexInputInterface` + buffer grouping into a concrete `VertexLayoutDeclaration`. + * + * Each attribute reference resolves to a `GPUVertexFormat` — defaulted from the interface + * attribute's WGSL type, or taken from an explicit `[location, format]` override (validated to be + * WGSL-type-compatible). Throws (aggregating every problem) when a referenced location is unknown, + * placed more than once, format-incompatible, or when some interface `@location` attribute is left + * unassigned. `@builtin` inputs are never placed (they aren't buffer-backed). + */ +export function vertexLayout( + vin: VertexInputInterface, + specs: readonly VertexBufferSpec[] +): VertexLayoutDeclaration { + const byLocation = new Map(); + for (const a of vin.attributes) byLocation.set(a.location, a); + + const errors: string[] = []; + const placed = new Set(); + const buffers: VertexBufferDecl[] = []; + + for (const spec of specs) { + if (spec.attributes.length === 0) { + errors.push('a vertex buffer must reference at least one attribute.'); + } + const attrs: VertexAttributeDecl[] = []; + for (const ref of spec.attributes) { + const [loc, overrideFmt] = refParts(ref); + const vinAttr = byLocation.get(loc); + if (vinAttr === undefined) { + errors.push(`@location(${loc}) is not declared by the vertex input interface.`); + continue; + } + if (placed.has(loc)) { + errors.push(`@location(${loc}) ('${vinAttr.name}') is assigned to more than one buffer.`); + continue; + } + placed.add(loc); + + let format: VertexFormat; + if (overrideFmt !== undefined) { + const meta = vertexFormatInfo(overrideFmt); + if (meta.wgslType !== vinAttr.wgslType) { + errors.push( + `@location(${loc}) ('${vinAttr.name}'): format '${overrideFmt}' presents as WGSL ` + + `'${meta.wgslType}' but the input declares '${vinAttr.wgslType}'.` + ); + continue; + } + format = overrideFmt; + } else { + const def = defaultVertexFormat(vinAttr.wgslType); + if (def === undefined) { + errors.push( + `@location(${loc}) ('${vinAttr.name}'): no default GPUVertexFormat for WGSL type ` + + `'${vinAttr.wgslType}' — specify one explicitly, e.g. [${loc}, 'unorm8x4'].` + ); + continue; + } + format = def; + } + attrs.push({ name: vinAttr.name, format, location: loc }); + } + buffers.push({ stepMode: spec.stepMode ?? 'vertex', attributes: attrs }); + } + + for (const a of vin.attributes) { + if (!placed.has(a.location)) { + errors.push(`@location(${a.location}) ('${a.name}') is not assigned to any vertex buffer.`); + } + } + + if (errors.length > 0) { + throw new Error(`vertexLayout: invalid layout:\n - ${errors.join('\n - ')}`); + } + + return { __brand: VERTEX_LAYOUT_BRAND, buffers }; +} + +// ---- Packing + derivation -------------------------------------------------------------------- + +function alignUp(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +/** One attribute after packing: its host name, wire format, and computed byte offset. */ +export interface PackedAttribute { + readonly name: string; + readonly format: VertexFormat; + readonly offset: number; + readonly location: number; +} + +/** A single buffer's packing result: stride + per-attribute offsets (keyed by host name). */ +export interface BufferPacking { + readonly arrayStride: number; + readonly stepMode: VertexBufferDecl['stepMode']; + readonly attributes: readonly PackedAttribute[]; +} + +/** Compute tight offsets + stride for one buffer declaration. Shared by the pipeline-layout + * derivation and the drawable's typed upload packer. */ +export function packVertexBuffer(buffer: VertexBufferDecl): BufferPacking { + if (buffer.attributes.length === 0) { + throw new Error('packVertexBuffer: a vertex buffer must declare at least one attribute.'); + } + let offset = 0; + const attributes: PackedAttribute[] = []; + for (const attr of buffer.attributes) { + const meta = vertexFormatInfo(attr.format); + offset = alignUp(offset, meta.alignment); + attributes.push({ name: attr.name, format: attr.format, offset, location: attr.location }); + offset += meta.byteSize; + } + return { arrayStride: alignUp(offset, 4), stepMode: buffer.stepMode, attributes }; +} + +/** Derive the concrete `GPUVertexBufferLayout[]` for pipeline state from a layout declaration. */ +export function deriveVertexBufferLayouts(layout: VertexLayoutDeclaration): VertexBufferLayout[] { + return layout.buffers.map((buf) => { + const packed = packVertexBuffer(buf); + const attributes: VertexAttribute[] = packed.attributes.map((a) => ({ + format: a.format, + offset: a.offset, + shaderLocation: a.location, + })); + return { + arrayStride: packed.arrayStride, + // Omit stepMode when 'vertex' (the WebGPU default) so derived layouts match hand-written + // ones byte-for-byte, keeping the pipeline fingerprint stable. + ...(packed.stepMode !== 'vertex' && { stepMode: packed.stepMode }), + attributes, + }; + }); +} + +// ---- Typed interleave packer (drawable upload path) ------------------------------------------ + +/** Host data for one vertex attribute: a flat `ArrayLike` of `count * elementsPerVertex` + * values. For `float16` supply raw half-float bits; for normalized (`unorm*`/`snorm*`) formats + * supply pre-encoded integers — v1 does not auto-quantize floats. */ +export type VertexAttrData = ArrayLike; + +function writeComponent( + dv: DataView, + byteOffset: number, + kind: VertexArrayKind, + value: number +): void { + switch (kind) { + case 'u8': + dv.setUint8(byteOffset, value); + return; + case 'i8': + dv.setInt8(byteOffset, value); + return; + case 'u16': + dv.setUint16(byteOffset, value, true); + return; + case 'i16': + dv.setInt16(byteOffset, value, true); + return; + case 'u32': + dv.setUint32(byteOffset, value, true); + return; + case 'i32': + dv.setInt32(byteOffset, value, true); + return; + case 'f32': + dv.setFloat32(byteOffset, value, true); + return; + } +} + +/** Interleave per-attribute host arrays into one tightly-packed buffer matching `buffer`'s + * derived layout. Every attribute must be present in `data` and agree on the vertex count. + * Returns the packed `ArrayBuffer` ready for `queue.writeBuffer`. */ +export function interleaveVertexBuffer( + buffer: VertexBufferDecl, + data: Readonly> +): ArrayBuffer { + const { arrayStride, attributes } = packVertexBuffer(buffer); + + let count = -1; + for (const attr of attributes) { + const meta = vertexFormatInfo(attr.format); + const src = data[attr.name]; + if (src === undefined) { + throw new Error(`interleaveVertexBuffer: missing data for attribute '${attr.name}'.`); + } + if (src.length % meta.elementsPerVertex !== 0) { + throw new Error( + `interleaveVertexBuffer: attribute '${attr.name}' length ${src.length} is not a ` + + `multiple of ${meta.elementsPerVertex} (elements per vertex for '${attr.format}').` + ); + } + const c = src.length / meta.elementsPerVertex; + if (count === -1) count = c; + else if (count !== c) { + throw new Error( + `interleaveVertexBuffer: attribute '${attr.name}' implies ${c} vertices but a prior ` + + `attribute implied ${count}. All attributes in a buffer must share a vertex count.` + ); + } + } + if (count <= 0) { + throw new Error('interleaveVertexBuffer: vertex count resolved to 0.'); + } + + const dest = new ArrayBuffer(count * arrayStride); + const dv = new DataView(dest); + for (const attr of attributes) { + const meta = vertexFormatInfo(attr.format); + const componentByteSize = meta.byteSize / meta.elementsPerVertex; + const src = data[attr.name] as VertexAttrData; + for (let v = 0; v < count; v++) { + const base = v * arrayStride + attr.offset; + for (let e = 0; e < meta.elementsPerVertex; e++) { + writeComponent( + dv, + base + e * componentByteSize, + meta.arrayKind, + src[v * meta.elementsPerVertex + e] as number + ); + } + } + } + return dest; +} 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..a82fd8cb --- /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 { uniformSlot } from './resource'; + +describe('bindShader', () => { + 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); + 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 = uniformSlot('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 = 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 = uniformSlot('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..f9f0f2b8 --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/bind.ts @@ -0,0 +1,45 @@ +/** + * `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 { isResourceSlot, type ResourceSlot } from './resource'; + +/** + * 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; + +/** + * 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 `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 (!isResourceSlot(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 slots: ${missing.join(', ')}`); + } + // 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 new file mode 100644 index 00000000..04fcd318 --- /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 { + externalTextureSlot, + samplerSlot, + storageSlot, + storageTextureSlot, + textureSlot, + uniformSlot, +} from './resource'; + +describe('bind', () => { + it('returns a frozen object', () => { + 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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() + ); + }); + + it('sampler: __gen() byte-matches the equivalent raw $s.sampler output', () => { + 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 = externalTextureSlot('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 = uniformSlot('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 = 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 = 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 = textureSlot('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 = storageTextureSlot('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 = 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 = 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 new file mode 100644 index 00000000..3e3db376 --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/bound.ts @@ -0,0 +1,129 @@ +/** + * 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 `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 { ResourceSlot } from './resource'; + +/** + * 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 `ResourceSlot` variant so consumers can + * pattern-match on `kind` and access kind-specific metadata. + */ +export type BoundSlot = Readonly< + R & { + readonly group: number; + readonly binding: number; + __gen(): string; + } +>; + +/** + * 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(slot: R, group: number, binding: number): BoundSlot { + const gen = makeGenFor(slot, group, binding); + const bound = { + ...slot, + group, + binding, + __gen: gen, + }; + return Object.freeze(bound) as BoundSlot; +} + +/** + * 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: ResourceSlot, 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 slot. The `visibility` argument lets the + * caller (typically the binding-graph traversal) supply the union of stages from every pipeline + * that references this slot; pass `bound.visibility` directly when no traversal is involved. + */ +export function toBindGroupLayoutEntry(bound: BoundSlot, 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..4750f14e --- /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 { BoundSlot } from './bound'; +export { bind, toBindGroupLayoutEntry } from './bound'; +export type { + ExternalTextureSlot, + ExternalTextureSlotOptions, + ResourceSlot, + ResourceSlotKind, + SamplerSlot, + SamplerSlotOptions, + StorageSlot, + StorageSlotOptions, + StorageTextureSlot, + StorageTextureSlotOptions, + TextureSlot, + TextureSlotOptions, + UniformSlot, + UniformSlotOptions, +} from './resource'; +export { + 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 new file mode 100644 index 00000000..14e08c1e --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/resource.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { + externalTextureSlot, + isResourceSlot, + RESOURCE_SLOT_BRAND, + samplerSlot, + storageSlot, + storageTextureSlot, + textureSlot, + uniformSlot, +} from './resource'; + +describe('isResource', () => { + it('returns true for objects created by Resource constructors', () => { + 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(isResourceSlot(samplerSlot('samp', 'sampler'))).toBe(true); + expect(isResourceSlot(externalTextureSlot('ext'))).toBe(true); + }); + + it('returns false for plain objects, primitives, and null', () => { + 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 = uniformSlot('myUniform', 'MyType'); + expect(() => u.__gen()).toThrow(/myUniform/); + expect(() => u.__gen()).toThrow(/bound/); + }); + + it.each([ + ['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(); + }); +}); + +describe('Resource constructors', () => { + it('attach the brand symbol', () => { + expect(uniformSlot('u', 'U').__brand).toBe(RESOURCE_SLOT_BRAND); + }); + + it('preserve provided fields', () => { + 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); + expect(r.minBindingSize).toBe(64); + expect(r.visibility).toBe(0x3); + expect(r.kind).toBe('uniform'); + }); + + it('storageSlot carries accessMode', () => { + const r = storageSlot('buf', 'BufType', { accessMode: 'read_write' }); + expect(r.accessMode).toBe('read_write'); + }); + + 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 new file mode 100644 index 00000000..3387fd24 --- /dev/null +++ b/packages/core/src/rendering/webgpu/resources/resource.ts @@ -0,0 +1,275 @@ +/** + * 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 slot. + * + * `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 { + SamplerBindingType, + ShaderStageFlags, + StorageTextureAccess, + TextureFormat, + TextureSampleType, + TextureViewDimension, +} from '../native-types'; +import type { + TypeIdentifier, + VariableOrValueAttribute, + WgslSampler, + WgslSamplerComparison, + WgslTextureDataType, +} from '../shaders'; + +/** Brand symbol used by `isResourceSlot` to discriminate `ResourceSlot` objects at runtime. */ +export const RESOURCE_SLOT_BRAND = Symbol.for('vis-core.webgpu.ResourceSlot'); + +export type ResourceSlotKind = + | 'uniform' + | 'storage' + | 'texture' + | 'storageTexture' + | 'sampler' + | 'externalTexture'; + +/** 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 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 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 UniformSlot extends ResourceSlotCommon { + readonly kind: 'uniform'; + readonly type: TypeIdentifier; + /** GPUBindGroupLayoutEntry.buffer.hasDynamicOffset */ + readonly hasDynamicOffset?: boolean; + /** GPUBindGroupLayoutEntry.buffer.minBindingSize */ + readonly minBindingSize?: number; +} + +export interface StorageSlot extends ResourceSlotCommon { + 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 TextureSlot extends ResourceSlotCommon { + 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 StorageTextureSlot extends ResourceSlotCommon { + 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 SamplerSlot extends ResourceSlotCommon { + readonly kind: 'sampler'; + readonly type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison'; + /** GPUBindGroupLayoutEntry.sampler.type (default 'filtering') */ + readonly bindingType?: SamplerBindingType; +} + +export interface ExternalTextureSlot extends ResourceSlotCommon { + readonly kind: 'externalTexture'; +} + +export type ResourceSlot = + | UniformSlot + | StorageSlot + | TextureSlot + | StorageTextureSlot + | SamplerSlot + | ExternalTextureSlot; + +/** 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_SLOT_BRAND + ); +} + +function unboundGen(name: string): () => string { + return () => { + throw new Error( + `ResourceSlot '${name}' must be bound to a {group, binding} before source generation; ` + + 'see bindShader() in @alleninstitute/vis-core/rendering/webgpu/resources' + ); + }; +} + +// ---- Constructors ----------------------------------------------------------------------------- + +export type UniformSlotOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + hasDynamicOffset?: boolean; + minBindingSize?: number; +}; + +export function uniformSlot( + name: string, + type: TypeIdentifier, + options: UniformSlotOptions = {} +): UniformSlot { + return { + __brand: RESOURCE_SLOT_BRAND, + kind: 'uniform', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type StorageSlotOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + accessMode?: 'read' | 'write' | 'read_write'; + hasDynamicOffset?: boolean; + minBindingSize?: number; +}; + +export function storageSlot( + name: string, + type: TypeIdentifier, + options: StorageSlotOptions = {} +): StorageSlot { + return { + __brand: RESOURCE_SLOT_BRAND, + kind: 'storage', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type TextureSlotOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + sampleType?: TextureSampleType; + viewDimension?: TextureViewDimension; + multisampled?: boolean; +}; + +export function textureSlot( + name: string, + type: WgslTextureDataType | `texture_${string}`, + options: TextureSlotOptions = {} +): TextureSlot { + return { + __brand: RESOURCE_SLOT_BRAND, + kind: 'texture', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type StorageTextureSlotOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + access?: StorageTextureAccess; + viewDimension?: TextureViewDimension; +}; + +export function storageTextureSlot( + name: string, + type: WgslTextureDataType | `texture_${string}`, + format: TextureFormat, + options: StorageTextureSlotOptions = {} +): StorageTextureSlot { + return { + __brand: RESOURCE_SLOT_BRAND, + kind: 'storageTexture', + name, + type, + format, + ...options, + __gen: unboundGen(name), + }; +} + +export type SamplerSlotOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; + bindingType?: SamplerBindingType; +}; + +export function samplerSlot( + name: string, + type: WgslSampler | WgslSamplerComparison | 'sampler' | 'sampler_comparison', + options: SamplerSlotOptions = {} +): SamplerSlot { + return { + __brand: RESOURCE_SLOT_BRAND, + kind: 'sampler', + name, + type, + ...options, + __gen: unboundGen(name), + }; +} + +export type ExternalTextureSlotOptions = { + visibility?: ShaderStageFlags; + attributes?: VariableOrValueAttribute[]; +}; + +export function externalTextureSlot( + name: string, + options: ExternalTextureSlotOptions = {} +): ExternalTextureSlot { + return { + __brand: RESOURCE_SLOT_BRAND, + kind: 'externalTexture', + name, + ...options, + __gen: unboundGen(name), + }; +} diff --git a/packages/core/src/rendering/webgpu/scene/scene.test.ts b/packages/core/src/rendering/webgpu/scene/scene.test.ts new file mode 100644 index 00000000..4ddef751 --- /dev/null +++ b/packages/core/src/rendering/webgpu/scene/scene.test.ts @@ -0,0 +1,292 @@ +/** + * Phase 6 — `Scene` mutation + dirty propagation + event tests. + * + * The DrawableNode tests use a hand-rolled stub Drawable (we don't need a real pipeline for + * scene-level semantics — the encoder tests cover that path). + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { Drawable } from '../drawable'; +import { DRAWABLE_BRAND } from '../drawable'; +import type { RenderTarget } from './types'; +import { + blendconstant, + container, + draw, + override, + scene, + scissor, + stencilref, + viewport, +} from './scene'; + +// ---- shared fixtures -------------------------------------------------------------------------- + +const TARGET: RenderTarget = { + color: [ + { + view: {} as unknown as GPUTextureView, + loadOp: 'clear', + storeOp: 'store', + clearValue: { r: 0, g: 0, b: 0, a: 1 }, + }, + ], +}; + +let drawableCounter = 0; +function fakeDrawable(label?: string): Drawable & { readonly destroyCount: () => number } { + drawableCounter += 1; + let destroyCalls = 0; + const d = { + __brand: DRAWABLE_BRAND, + id: `drawable-${drawableCounter}`, + ...(label !== undefined && { label }), + pipeline: {} as Drawable['pipeline'], + vertexBuffers: new Map(), + bindings: new Map(), + draw: { kind: 'array' as const, vertexCount: 3 }, + destroy: () => { + destroyCalls += 1; + }, + reuse: (() => { + throw new Error('fakeDrawable.reuse: not supported in scene tests'); + }) as Drawable['reuse'], + destroyCount: () => destroyCalls, + }; + return Object.freeze(d) as unknown as Drawable & { readonly destroyCount: () => number }; +} + +// ---- construction ----------------------------------------------------------------------------- + +describe('scene() construction', () => { + it('indexes every descendant into the parents map', () => { + const d1 = draw(fakeDrawable('d1')); + const d2 = draw(fakeDrawable('d2')); + const inner = container([d1, d2]); + const root = container([inner]); + const s = scene({ target: TARGET, root }); + expect(s.root).toBe(root); + expect(s.parents.get(inner.id)).toBe(root.id); + expect(s.parents.get(d1.id)).toBe(inner.id); + expect(s.parents.get(d2.id)).toBe(inner.id); + expect(s.parents.has(root.id)).toBe(false); + expect(s.getNode(d1.id)).toBe(d1); + expect(s.getNode(root.id)).toBe(root); + }); +}); + +// ---- add / remove / replace ------------------------------------------------------------------ + +describe('Scene.add / remove / replace', () => { + it('add(parent, node) attaches and dirties the affected ancestors', () => { + const root = container([]); + const s = scene({ target: TARGET, root }); + const d = draw(fakeDrawable('d')); + s.add(root.id, d); + + const newRoot = s.root; + expect(newRoot.id).toBe(root.id); // root.id stable + expect(newRoot.kind).toBe('container'); + if (newRoot.kind === 'container') { + expect(newRoot.children).toHaveLength(1); + expect(newRoot.children[0]?.id).toBe(d.id); + } + expect(s.parents.get(d.id)).toBe(root.id); + expect(s.dirty.has(d.id)).toBe(true); + expect(s.dirty.has(root.id)).toBe(true); + }); + + it('remove() detaches the subtree and clears it from the index', () => { + const d = draw(fakeDrawable()); + const inner = container([d]); + const root = container([inner]); + const s = scene({ target: TARGET, root }); + s.clearDirty(); + s.remove(inner.id); + + expect(s.getNode(inner.id)).toBeUndefined(); + expect(s.getNode(d.id)).toBeUndefined(); + expect(s.parents.has(d.id)).toBe(false); + // The root is dirtied because its children array changed. + expect(s.dirty.has(root.id)).toBe(true); + }); + + it('remove() throws when called on the root', () => { + const root = container([]); + const s = scene({ target: TARGET, root }); + expect(() => s.remove(root.id)).toThrow(/cannot remove the root/); + }); + + it('replace() keeps the node id stable and dirties ancestors', () => { + const root = container([viewport({ x: 0, y: 0, width: 10, height: 10 }, [])]); + const s = scene({ target: TARGET, root }); + const original = s.root.kind === 'container' ? s.root.children[0]! : null; + expect(original?.kind).toBe('viewport'); + s.clearDirty(); + + // Replace with a different viewport spec; keep the same id (replace API). + const replacement = viewport({ x: 5, y: 5, width: 20, height: 20 }, []); + s.replace(original!.id, replacement); + + const newNode = s.getNode(original!.id); + expect(newNode?.kind).toBe('viewport'); + if (newNode?.kind === 'viewport') { + expect(newNode.x).toBe(5); + expect(newNode.width).toBe(20); + } + expect(s.dirty.has(original!.id)).toBe(true); + expect(s.dirty.has(root.id)).toBe(true); + }); +}); + +// ---- dirty propagation ------------------------------------------------------------------------ + +describe('Scene.markDirty / markSubtreeDirty', () => { + it('markDirty(id) marks the node and every ancestor', () => { + const leaf = draw(fakeDrawable()); + const mid = container([leaf]); + const root = container([mid]); + const s = scene({ target: TARGET, root }); + s.clearDirty(); + + s.markDirty(leaf.id); + expect(s.dirty.has(leaf.id)).toBe(true); + expect(s.dirty.has(mid.id)).toBe(true); + expect(s.dirty.has(root.id)).toBe(true); + }); + + it('markSubtreeDirty(root) invalidates every node in the scene', () => { + const leaves = [draw(fakeDrawable()), draw(fakeDrawable()), draw(fakeDrawable())]; + const mid = container(leaves); + const root = container([mid]); + const s = scene({ target: TARGET, root }); + s.clearDirty(); + + s.markSubtreeDirty(root.id); + expect(s.dirty.has(root.id)).toBe(true); + expect(s.dirty.has(mid.id)).toBe(true); + for (const l of leaves) expect(s.dirty.has(l.id)).toBe(true); + }); + + it('markDirty throws on an unknown id', () => { + const s = scene({ target: TARGET, root: container([]) }); + expect(() => s.markDirty('does-not-exist')).toThrow(/not found/); + }); +}); + +// ---- events ----------------------------------------------------------------------------------- + +describe('Scene events', () => { + it("fires 'structure-changed' once per add/remove/replace", () => { + const root = container([]); + const s = scene({ target: TARGET, root }); + const listener = vi.fn(); + s.on('structure-changed', listener); + + const d = draw(fakeDrawable()); + s.add(root.id, d); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0]?.[0]).toMatchObject({ + type: 'structure-changed', + action: 'add', + nodeId: d.id, + parentId: root.id, + }); + + s.remove(d.id); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener.mock.calls[1]?.[0]).toMatchObject({ + type: 'structure-changed', + action: 'remove', + nodeId: d.id, + }); + }); + + it('off() / unsubscribe stops further notifications', () => { + const s = scene({ target: TARGET, root: container([]) }); + const listener = vi.fn(); + const off = s.on('structure-changed', listener); + off(); + s.add(s.root.id, draw(fakeDrawable())); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +// ---- factory smoke tests ---------------------------------------------------------------------- + +describe('node factories', () => { + it('viewport defaults minDepth/maxDepth to 0/1', () => { + const v = viewport({ x: 0, y: 0, width: 100, height: 100 }, []); + expect(v.minDepth).toBe(0); + expect(v.maxDepth).toBe(1); + expect(v.kind).toBe('viewport'); + }); + + it('scissor / stencilref / blendconstant return correctly-kinded nodes', () => { + const sc = scissor({ x: 0, y: 0, width: 100, height: 100 }, []); + expect(sc.kind).toBe('scissor'); + const sr = stencilref(42, []); + expect(sr.kind).toBe('stencilref'); + expect(sr.value).toBe(42); + const bc = blendconstant([0.1, 0.2, 0.3, 0.4], []); + expect(bc.kind).toBe('blendconstant'); + }); + + it("override only accepts Map (not a record)", () => { + expect(() => + override( + {} as unknown as ReadonlyMap, + [] + ) + ).toThrow(/Map/); + }); +}); + +// ---- Phase 8: drawable ownership --------------------------------------------------------------- + +describe('Scene ownership contract (Phase 8)', () => { + it('Scene.remove destroys every Drawable in the removed subtree', () => { + const a = fakeDrawable('a'); + const b = fakeDrawable('b'); + const c = fakeDrawable('c'); + const root = container([ + draw(a), + container([draw(b), draw(c)]), + ]); + const s = scene({ target: TARGET, root }); + // Look up the inner container's id (the second child of root). + const inner = s.root.kind === 'container' ? s.root.children[1] : undefined; + if (inner === undefined) throw new Error('inner container missing'); + + s.remove(inner.id); + + const destroys = (a as unknown as { destroyCount: () => number }).destroyCount; + expect(destroys()).toBe(0); // 'a' was outside the removed subtree + expect((b as unknown as { destroyCount: () => number }).destroyCount()).toBe(1); + expect((c as unknown as { destroyCount: () => number }).destroyCount()).toBe(1); + }); + + it('Scene.replace destroys the previous drawable when the new one is different', () => { + const a = fakeDrawable('a'); + const b = fakeDrawable('b'); + const root = container([draw(a)]); + const s = scene({ target: TARGET, root }); + const aNode = s.root.kind === 'container' ? s.root.children[0] : undefined; + if (aNode === undefined) throw new Error('a node missing'); + + s.replace(aNode.id, draw(b)); + expect((a as unknown as { destroyCount: () => number }).destroyCount()).toBe(1); + expect((b as unknown as { destroyCount: () => number }).destroyCount()).toBe(0); + }); + + it('Scene.replace does NOT destroy when the same drawable is re-wrapped', () => { + const a = fakeDrawable('a'); + const root = container([draw(a)]); + const s = scene({ target: TARGET, root }); + const aNode = s.root.kind === 'container' ? s.root.children[0] : undefined; + if (aNode === undefined) throw new Error('a node missing'); + + s.replace(aNode.id, draw(a, 'relabel')); + expect((a as unknown as { destroyCount: () => number }).destroyCount()).toBe(0); + }); +}); diff --git a/packages/core/src/rendering/webgpu/scene/scene.ts b/packages/core/src/rendering/webgpu/scene/scene.ts new file mode 100644 index 00000000..38169838 --- /dev/null +++ b/packages/core/src/rendering/webgpu/scene/scene.ts @@ -0,0 +1,527 @@ +/** + * `Scene` factories + mutation surface — Phase 6 of the WebGPU rendering refactor. + * + * Public exports: + * - Constructor: `scene({ target, root, label? })`. + * - Node factories (single-word lowercase): `container`, `viewport`, `scissor`, `stencilref`, + * `blendconstant`, `override`, `draw`. Each returns a frozen POJO branded for + * `isSceneNode`. IDs are assigned via `uuid` at construction. + * + * The returned `Scene` is mutable through its methods; mutations dirty the affected ancestors + * and fire a single `'structure-changed'` event for downstream cache trimming (encoder, + * bind-group cache). + */ + +import { v4 as uuidv4 } from 'uuid'; +import type { Resource } from '../data/resource'; +import type { Drawable } from '../drawable'; +import type { ResourceSlot } from '../resources/resource'; +import { + type BindingOverrideNode, + type BlendConstantNode, + type ContainerNode, + type DrawableNode, + type NodeId, + type RenderTarget, + type Scene, + type SceneDescriptor, + type SceneEvent, + type SceneEventListener, + type SceneNode, + type ScissorNode, + type StencilRefNode, + type ViewportNode, + isSceneNode, + SCENE_BRAND, + SCENE_NODE_BRAND, +} from './types'; + +// ---- Node factories --------------------------------------------------------------------------- + +/** Compose a `ContainerNode`. Pass-through grouping. */ +export function container(children: readonly SceneNode[], label?: string): ContainerNode { + return Object.freeze({ + __brand: SCENE_NODE_BRAND, + id: uuidv4(), + kind: 'container' as const, + children: Object.freeze([...children]) as readonly SceneNode[], + ...(label !== undefined && { label }), + }); +} + +/** Spec for `viewport(...)`. Mirrors `GPURenderPassEncoder.setViewport` shape. */ +export interface ViewportSpec { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly minDepth?: number; + readonly maxDepth?: number; + readonly label?: string; +} + +export function viewport(spec: ViewportSpec, children: readonly SceneNode[]): ViewportNode { + return Object.freeze({ + __brand: SCENE_NODE_BRAND, + id: uuidv4(), + kind: 'viewport' as const, + x: spec.x, + y: spec.y, + width: spec.width, + height: spec.height, + minDepth: spec.minDepth ?? 0, + maxDepth: spec.maxDepth ?? 1, + children: Object.freeze([...children]) as readonly SceneNode[], + ...(spec.label !== undefined && { label: spec.label }), + }); +} + +/** Spec for `scissor(...)`. Mirrors `setScissorRect`. */ +export interface ScissorSpec { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly label?: string; +} + +export function scissor(spec: ScissorSpec, children: readonly SceneNode[]): ScissorNode { + return Object.freeze({ + __brand: SCENE_NODE_BRAND, + id: uuidv4(), + kind: 'scissor' as const, + x: spec.x, + y: spec.y, + width: spec.width, + height: spec.height, + children: Object.freeze([...children]) as readonly SceneNode[], + ...(spec.label !== undefined && { label: spec.label }), + }); +} + +export function stencilref( + value: number, + children: readonly SceneNode[], + label?: string +): StencilRefNode { + return Object.freeze({ + __brand: SCENE_NODE_BRAND, + id: uuidv4(), + kind: 'stencilref' as const, + value, + children: Object.freeze([...children]) as readonly SceneNode[], + ...(label !== undefined && { label }), + }); +} + +export function blendconstant( + color: GPUColorDict | readonly [number, number, number, number], + children: readonly SceneNode[], + label?: string +): BlendConstantNode { + return Object.freeze({ + __brand: SCENE_NODE_BRAND, + id: uuidv4(), + kind: 'blendconstant' as const, + color, + children: Object.freeze([...children]) as readonly SceneNode[], + ...(label !== undefined && { label }), + }); +} + +export function override( + overrides: ReadonlyMap, + children: readonly SceneNode[], + label?: string +): BindingOverrideNode { + if (!(overrides instanceof Map)) { + throw new Error( + 'override(...): bindings must be supplied as a Map. ' + + 'Record is not supported on Scene nodes because the ' + + 'scene has no pipeline context.' + ); + } + const map: ReadonlyMap = new Map(overrides); + return Object.freeze({ + __brand: SCENE_NODE_BRAND, + id: uuidv4(), + kind: 'override' as const, + overrides: map, + children: Object.freeze([...children]) as readonly SceneNode[], + ...(label !== undefined && { label }), + }); +} + +/** + * Wrap a `Drawable` in a `DrawableNode` for inclusion in a scene tree. + * + * **Ownership contract**: when a `DrawableNode` is removed from a `Scene` (or replaced with a + * different `Drawable`), the `Scene` calls `drawable.destroy()` on the detached drawable. If the + * caller wants the same `Drawable` to outlive removal (e.g. it's shared between scenes or kept + * for later re-insertion), call `drawable.share()` first to incref it. + */ +export function draw(drawable: Drawable, label?: string): DrawableNode { + return Object.freeze({ + __brand: SCENE_NODE_BRAND, + id: uuidv4(), + kind: 'draw' as const, + drawable, + ...(label !== undefined && { label }), + }); +} + +// ---- Scene constructor ------------------------------------------------------------------------ + +/** + * Construct a `Scene` from a `SceneDescriptor`. The descriptor's `root` becomes the live root; + * children are walked once to populate the `parents` map. + */ +export function scene(descriptor: SceneDescriptor): Scene { + return new SceneImpl(descriptor); +} + +/** Internal helpers exposed only for tests / encoder traversal. */ +export function childrenOf(node: SceneNode): readonly SceneNode[] { + if (node.kind === 'draw') return []; + return node.children; +} + +// ---- Scene implementation --------------------------------------------------------------------- + +class SceneImpl implements Scene { + readonly __brand: typeof SCENE_BRAND = SCENE_BRAND; + readonly id: string; + readonly target: RenderTarget; + private _root: SceneNode; + private readonly _parents: Map = new Map(); + private readonly _nodes: Map = new Map(); + private readonly _dirty: Set = new Set(); + private readonly _listeners: Map> = new Map(); + + constructor(descriptor: SceneDescriptor) { + this.id = uuidv4(); + this.target = descriptor.target; + this._root = descriptor.root; + this.indexSubtree(descriptor.root, undefined); + } + + get root(): SceneNode { + return this._root; + } + get parents(): ReadonlyMap { + return this._parents; + } + get dirty(): ReadonlySet { + return this._dirty; + } + + getNode(id: NodeId): SceneNode | undefined { + return this._nodes.get(id); + } + + add(parentId: NodeId, node: SceneNode): NodeId { + const parent = this._nodes.get(parentId); + if (parent === undefined) { + throw new Error(`Scene.add: parent id '${parentId}' not found.`); + } + if (parent.kind === 'draw') { + throw new Error(`Scene.add: parent '${parentId}' is a draw leaf — cannot accept children.`); + } + if (!isSceneNode(node)) { + throw new Error('Scene.add: node is not a SceneNode (use a factory like `container(...)`).'); + } + if (this._nodes.has(node.id)) { + throw new Error(`Scene.add: node id '${node.id}' is already present in the scene.`); + } + const newParent = withChildren(parent, [...parent.children, node]); + this.swapNode(parent, newParent); + // The new subtree must be indexed before we propagate dirty (so descendants exist). + this.indexSubtree(node, newParent.id); + this.markAncestorsDirty(node.id); + this.emit({ type: 'structure-changed', action: 'add', nodeId: node.id, parentId: newParent.id }); + return node.id; + } + + remove(id: NodeId): void { + if (id === this._root.id) { + throw new Error('Scene.remove: cannot remove the root node.'); + } + const node = this._nodes.get(id); + if (node === undefined) { + throw new Error(`Scene.remove: node id '${id}' not found.`); + } + const parentId = this._parents.get(id); + if (parentId === undefined) { + throw new Error(`Scene.remove: node '${id}' has no parent (invariant violation).`); + } + const parent = this._nodes.get(parentId); + if (parent === undefined || parent.kind === 'draw') { + throw new Error(`Scene.remove: parent '${parentId}' is missing or not a composite.`); + } + // Collect descendant ids BEFORE unindexing so consumers of `removedNodeIds` can evict + // per-node state (e.g. the encoder's subtree-command cache) for the whole subtree in + // one pass. Excludes `id` itself — the event's `nodeId` field already carries that. + const removedNodeIds: NodeId[] = []; + this.collectDescendantIds(node, removedNodeIds); + const newParent = withChildren( + parent, + parent.children.filter((c) => c.id !== id) + ); + // Dirty ancestors first (uses the existing parents map), then unindex. + this.markAncestorsDirty(parentId); + this.unindexSubtree(node); + this.swapNode(parent, newParent); + // Decref every Drawable in the detached subtree (per the `draw(...)` ownership contract). + this.destroyDrawablesInSubtree(node); + this.emit({ + type: 'structure-changed', + action: 'remove', + nodeId: id, + parentId, + ...(removedNodeIds.length > 0 && { removedNodeIds }), + }); + } + + replace(id: NodeId, node: SceneNode): void { + const existing = this._nodes.get(id); + if (existing === undefined) { + throw new Error(`Scene.replace: node id '${id}' not found.`); + } + if (!isSceneNode(node)) { + throw new Error('Scene.replace: node is not a SceneNode.'); + } + if (node.kind !== existing.kind) { + // We allow this (the encoder will re-evaluate) but reject anything that would + // change the leaf/composite distinction since it'd break parent.children typing. + const wasComposite = existing.kind !== 'draw'; + const isComposite = node.kind !== 'draw'; + if (wasComposite !== isComposite) { + throw new Error( + `Scene.replace: cannot swap composite '${existing.kind}' with leaf '${node.kind}' (or vice versa).` + ); + } + } + // Rebrand `node` to carry the existing id + the existing children when the new node is + // a composite missing children (caller convenience: pass new state, keep children). + const rebranded: SceneNode = + node.kind === 'draw' + ? { ...node, id } + : node.children.length === 0 && existing.kind !== 'draw' && existing.children.length > 0 + ? { ...node, id, children: existing.children } + : { ...node, id }; + // Determine which existing descendants (if any) this replace detaches, so the emitted + // event carries `removedNodeIds`. Two cases produce removals: + // 1. existing is a composite AND rebranded is a leaf → every existing descendant is + // detached. + // 2. existing is a composite AND rebranded is a composite with a fresh (non-kept) + // children array → every existing descendant is detached (the new descendants are + // a fresh subtree). + // The "kept-children" path (rebranded.children === existing.children) is a no-op here. + const removedNodeIds: NodeId[] = []; + if (existing.kind !== 'draw') { + const keptChildren = + rebranded.kind !== 'draw' && rebranded.children === existing.children; + if (!keptChildren) { + for (const c of existing.children) this.collectDescendantIds(c, removedNodeIds, true); + } + } + // Reindex: drop the old node's children index entries, re-add the new node's. + if (existing.kind !== 'draw') this.unindexChildrenOnly(existing); + // If we replaced a DrawableNode with a different Drawable, destroy the old one + // (per the `draw(...)` ownership contract). Same-drawable replacements (e.g. relabel) + // are a no-op. + if ( + existing.kind === 'draw' && + rebranded.kind === 'draw' && + existing.drawable !== rebranded.drawable + ) { + existing.drawable.destroy(); + } + // Update _nodes for the rebranded id (same id, different node object). + this._nodes.set(id, rebranded); + // Patch parent's children array so traversal sees the new node. + const parentId = this._parents.get(id); + if (parentId !== undefined) { + const parent = this._nodes.get(parentId); + if (parent !== undefined && parent.kind !== 'draw') { + const newParent = withChildren( + parent, + parent.children.map((c) => (c.id === id ? rebranded : c)) + ); + this.swapNode(parent, newParent); + } + } else { + // Root replacement. + this._root = rebranded; + } + if (rebranded.kind !== 'draw') { + this.indexChildrenOnly(rebranded); + } + this.markAncestorsDirty(id); + const evParent = parentId; + this.emit({ + type: 'structure-changed', + action: 'replace', + nodeId: id, + ...(evParent !== undefined && { parentId: evParent }), + ...(removedNodeIds.length > 0 && { removedNodeIds }), + }); + } + + markDirty(id: NodeId): void { + if (!this._nodes.has(id)) { + throw new Error(`Scene.markDirty: node id '${id}' not found.`); + } + this.markAncestorsDirty(id); + } + + markSubtreeDirty(id: NodeId): void { + const node = this._nodes.get(id); + if (node === undefined) { + throw new Error(`Scene.markSubtreeDirty: node id '${id}' not found.`); + } + // Mark every descendant + the node itself + every ancestor. + const stack: SceneNode[] = [node]; + while (stack.length > 0) { + const cur = stack.pop()!; + this._dirty.add(cur.id); + if (cur.kind !== 'draw') for (const c of cur.children) stack.push(c); + } + // Walk up. + let p = this._parents.get(id); + while (p !== undefined) { + this._dirty.add(p); + p = this._parents.get(p); + } + } + + clearDirty(): void { + this._dirty.clear(); + } + + on(type: SceneEvent['type'], listener: SceneEventListener): () => void { + let set = this._listeners.get(type); + if (set === undefined) { + set = new Set(); + this._listeners.set(type, set); + } + set.add(listener); + return () => this.off(type, listener); + } + + off(type: SceneEvent['type'], listener: SceneEventListener): void { + this._listeners.get(type)?.delete(listener); + } + + // ---- internals ---- + + private emit(ev: SceneEvent): void { + const set = this._listeners.get(ev.type); + if (set === undefined) return; + for (const fn of set) fn(ev); + } + + private markAncestorsDirty(id: NodeId): void { + this._dirty.add(id); + let p = this._parents.get(id); + while (p !== undefined) { + this._dirty.add(p); + p = this._parents.get(p); + } + } + + private indexSubtree(node: SceneNode, parentId: NodeId | undefined): void { + this._nodes.set(node.id, node); + if (parentId !== undefined) this._parents.set(node.id, parentId); + if (node.kind === 'draw') return; + for (const child of node.children) { + this.indexSubtree(child, node.id); + } + } + + private unindexSubtree(node: SceneNode): void { + this._nodes.delete(node.id); + this._parents.delete(node.id); + this._dirty.delete(node.id); + if (node.kind === 'draw') return; + for (const child of node.children) this.unindexSubtree(child); + } + + /** + * Collect the ids of every node in the subtree rooted at `node` into `out`. `includeSelf` + * controls whether `node.id` itself is included: + * - `false` (default) — pushes ids of every descendant of `node` but NOT `node.id`. + * Used by `remove`, where the emitted event already carries the detached subtree's + * top id in its `nodeId` field. + * - `true` — also pushes `node.id`. Used by `replace` when iterating each detached + * child of the previous composite: from that call site, the child itself is a + * departing node and belongs in the list. + * The traversal order is unspecified. + */ + private collectDescendantIds(node: SceneNode, out: NodeId[], includeSelf = false): void { + if (includeSelf) out.push(node.id); + if (node.kind === 'draw') return; + for (const c of node.children) this.collectDescendantIds(c, out, true); + } + + /** + * Walk a (just-detached) subtree and call `destroy()` on every `Drawable`. Per the + * `draw(...)` ownership contract, the scene owns its drawables and releases them when the + * subtree they live in is removed or replaced. Idempotent: `Drawable.destroy()` is itself + * idempotent once refcount hits zero. + */ + private destroyDrawablesInSubtree(node: SceneNode): void { + if (node.kind === 'draw') { + node.drawable.destroy(); + return; + } + for (const child of node.children) this.destroyDrawablesInSubtree(child); + } + + /** Re-index every descendant of `node` (but not `node` itself). */ + private indexChildrenOnly(node: SceneNode): void { + if (node.kind === 'draw') return; + for (const c of node.children) this.indexSubtree(c, node.id); + } + private unindexChildrenOnly(node: SceneNode): void { + if (node.kind === 'draw') return; + for (const c of node.children) this.unindexSubtree(c); + } + + /** + * Replace `oldNode` with `newNode` (same id) in the scene tree, propagating the swap up to + * the root so every ancestor's `children` array is fresh. Re-points the parent map for the + * direct children of `newNode` (they keep the same parent id; only the parent object + * identity changed). + */ + private swapNode(oldNode: SceneNode, newNode: SceneNode): void { + this._nodes.set(newNode.id, newNode); + // Children's parent id is unchanged (same node id), but newer node object is now + // canonical; nothing further to update for them in `_parents`. + const parentId = this._parents.get(oldNode.id); + if (parentId === undefined) { + this._root = newNode; + return; + } + const parent = this._nodes.get(parentId); + if (parent === undefined || parent.kind === 'draw') return; // invariant violation; ignore + const replaced = withChildren( + parent, + parent.children.map((c) => (c.id === oldNode.id ? newNode : c)) + ); + this.swapNode(parent, replaced); + } +} + +// ---- helpers ---------------------------------------------------------------------------------- + +/** Produce a fresh frozen composite with the same kind/state but a new `children` array. */ +function withChildren(node: SceneNode, children: readonly SceneNode[]): SceneNode { + if (node.kind === 'draw') { + throw new Error(`withChildren: cannot give children to a draw leaf '${node.id}'.`); + } + return Object.freeze({ + ...node, + children: Object.freeze([...children]) as readonly SceneNode[], + }) as SceneNode; +} diff --git a/packages/core/src/rendering/webgpu/scene/types.ts b/packages/core/src/rendering/webgpu/scene/types.ts new file mode 100644 index 00000000..e217c12d --- /dev/null +++ b/packages/core/src/rendering/webgpu/scene/types.ts @@ -0,0 +1,211 @@ +/** + * Persistent `Scene` types — Phase 6 of the WebGPU rendering refactor. + * + * A `Scene` is a mutable, tree-shaped render-graph whose leaves are `DrawableNode`s wrapping + * `Drawable` instances built via `ctx.drawable(...)`. Inner nodes are either composite + * containers (`ContainerNode`) or **scoped state nodes** (`ViewportNode`, `ScissorNode`, + * `StencilRefNode`, `BlendConstantNode`) whose effect is restored on subtree exit by the + * encoder. `BindingOverrideNode` replaces specific `ResourceSlot` bindings for its subtree. + * + * **No `PipelineRefNode`**: each Drawable owns its pipeline assignment (singular). Multi- + * pipeline rendering is expressed by constructing multiple Drawables sharing geometry via + * `Resource.share()` (or `drawable.reuse(...)`). + * + * Every node descriptor is a POJO — no closures, no GPU references. The scene itself is + * `structuredClone`-safe **only** when all nodes are non-`draw` (DrawableNode carries a live + * Drawable object; in a worker scenario the worker that owns the encoder maintains an + * `id → drawable` dictionary and reconstructs DrawableNodes locally from the message-passing + * shape). + */ + +import type { Resource } from '../data/resource'; +import type { Drawable } from '../drawable'; +import type { ResourceSlot } from '../resources/resource'; + +/** Stable per-instance identifier (UUID-ish). Used by `Scene.add` / `remove` / `replace`. */ +export type NodeId = string; + +/** WebGPU render-target descriptor consumed by `ctx.submit(scene)` to open a render pass. */ +export interface RenderTarget { + readonly color: readonly GPURenderPassColorAttachment[]; + readonly depthStencil?: GPURenderPassDepthStencilAttachment; + /** Optional debug label propagated to the begin-pass descriptor. */ + readonly label?: string; +} + +// ---- Scene node variants ---------------------------------------------------------------------- + +/** Brand symbol — used by `isSceneNode` to identify any scene node at runtime. */ +export const SCENE_NODE_BRAND: unique symbol = Symbol.for('vis-core.webgpu.SceneNode'); + +interface SceneNodeBase { + readonly __brand: typeof SCENE_NODE_BRAND; + readonly id: NodeId; + readonly label?: string; +} + +/** Pass-through composite — has no encoder effect of its own. Used for grouping. */ +export interface ContainerNode extends SceneNodeBase { + readonly kind: 'container'; + readonly children: readonly SceneNode[]; +} + +/** Scoped viewport. Encoder calls `setViewport(x, y, width, height, minDepth, maxDepth)` on + * subtree entry; restores prior viewport on exit if it differed. */ +export interface ViewportNode extends SceneNodeBase { + readonly kind: 'viewport'; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly minDepth: number; + readonly maxDepth: number; + readonly children: readonly SceneNode[]; +} + +/** Scoped scissor rectangle. Encoder calls `setScissorRect` on entry, restores on exit. */ +export interface ScissorNode extends SceneNodeBase { + readonly kind: 'scissor'; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly children: readonly SceneNode[]; +} + +/** Scoped stencil reference value. */ +export interface StencilRefNode extends SceneNodeBase { + readonly kind: 'stencilref'; + readonly value: number; + readonly children: readonly SceneNode[]; +} + +/** Scoped blend constant color (RGBA). */ +export interface BlendConstantNode extends SceneNodeBase { + readonly kind: 'blendconstant'; + readonly color: GPUColorDict | readonly [number, number, number, number]; + readonly children: readonly SceneNode[]; +} + +/** Scoped binding override. The encoder snapshots the affected bind groups on entry and + * restores them on exit. Slot-by-slot: a `ResourceSlot` not present in `overrides` falls + * through to whatever the descendant `Drawable` declares. */ +export interface BindingOverrideNode extends SceneNodeBase { + readonly kind: 'override'; + readonly overrides: ReadonlyMap; + readonly children: readonly SceneNode[]; +} + +/** Leaf node wrapping a `Drawable`. */ +export interface DrawableNode extends SceneNodeBase { + readonly kind: 'draw'; + readonly drawable: Drawable; +} + +/** Discriminated union of every `Scene` node variant. */ +export type SceneNode = + | ContainerNode + | ViewportNode + | ScissorNode + | StencilRefNode + | BlendConstantNode + | BindingOverrideNode + | DrawableNode; + +/** Subset of nodes that carry children. Helpful for traversal narrowing. */ +export type CompositeSceneNode = Exclude; + +/** Runtime brand check. */ +export function isSceneNode(value: unknown): value is SceneNode { + return ( + typeof value === 'object' && + value !== null && + (value as { __brand?: unknown }).__brand === SCENE_NODE_BRAND + ); +} + +// ---- Events ------------------------------------------------------------------------------------ + +/** Event payload fired after every mutating call on `Scene`. */ +export interface StructureChangedEvent { + readonly type: 'structure-changed'; + readonly action: 'add' | 'remove' | 'replace'; + /** Id of the node that was added / removed / replaced. */ + readonly nodeId: NodeId; + /** For `add`: the parent the new node was attached to. For `replace`: the parent of the + * replaced node. Omitted for `remove` of the root (which is disallowed anyway). */ + readonly parentId?: NodeId; + /** + * For `remove` (and for `replace` when the old node was a composite whose descendants + * were not re-attached to the new node): the ids of every node that was detached from + * the scene, EXCLUDING `nodeId` itself. Consumers that maintain per-node state (e.g. the + * encoder's subtree-command cache) use this to evict entries for the entire removed + * subtree in one pass. For `add`, this field is omitted. + */ + readonly removedNodeIds?: readonly NodeId[]; +} + +export type SceneEvent = StructureChangedEvent; +export type SceneEventListener = (ev: SceneEvent) => void; + +// ---- Scene ------------------------------------------------------------------------------------- + +/** Brand symbol used by `isScene` to discriminate `Scene` objects at runtime. */ +export const SCENE_BRAND: unique symbol = Symbol.for('vis-core.webgpu.Scene'); + +/** + * The persistent render graph. Constructed via `scene(descriptor)`; mutated via + * `add`/`remove`/`replace`. `dirty` is the set of nodes whose cached encoded command + * sequences must be re-recorded next frame; the encoder consults and clears it during + * `plan`. + */ +export interface Scene { + readonly __brand: typeof SCENE_BRAND; + readonly id: string; + readonly target: RenderTarget; + /** Current scene root. Replaced wholesale by `replace(root.id, newRoot)`. */ + readonly root: SceneNode; + /** `parents.get(childId) === parentId` for every non-root node. The root is absent. */ + readonly parents: ReadonlyMap; + /** Nodes whose cached commands need to be re-encoded. Encoder reads + clears during plan. */ + readonly dirty: ReadonlySet; + + /** Locate a node by id. Returns `undefined` if no such node exists. */ + getNode(id: NodeId): SceneNode | undefined; + /** Attach `node` as the last child of `parentId`. Throws on missing parent or non-composite parent. */ + add(parentId: NodeId, node: SceneNode): NodeId; + /** Detach `id` (and its entire subtree) from the scene. Throws when called on the root. */ + remove(id: NodeId): void; + /** Substitute the node at `id` with `node`. The new node retains `id` (i.e. `node` is rebranded + * to carry the existing id) so external references to `id` remain valid. */ + replace(id: NodeId, node: SceneNode): void; + /** Mark `id` and every ancestor as dirty. */ + markDirty(id: NodeId): void; + /** Mark `id` plus every descendant **and** every ancestor as dirty. */ + markSubtreeDirty(id: NodeId): void; + /** Internal helper — clear the dirty set. Called by the encoder at the end of `plan`. */ + clearDirty(): void; + + /** Subscribe to scene events. Returns an unsubscribe function. */ + on(type: SceneEvent['type'], listener: SceneEventListener): () => void; + /** Remove a previously-registered listener. */ + off(type: SceneEvent['type'], listener: SceneEventListener): void; +} + +/** Runtime discriminator for `Scene`. */ +export function isScene(value: unknown): value is Scene { + return ( + typeof value === 'object' && + value !== null && + (value as { __brand?: unknown }).__brand === SCENE_BRAND + ); +} + +// ---- Constructor descriptor -------------------------------------------------------------------- + +/** Top-level descriptor passed to `scene({...})`. */ +export interface SceneDescriptor { + readonly target: RenderTarget; + readonly root: SceneNode; + readonly label?: string; +} diff --git a/packages/core/src/rendering/webgpu/shaders/declarations.ts b/packages/core/src/rendering/webgpu/shaders/declarations.ts index aa05e045..ec8ac639 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 @@ -47,13 +53,33 @@ 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'; 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 +169,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 +178,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 +289,7 @@ export function uniform( export function texture( name: string, - type: `texture_${string}`, + type: WgslTextureDataType | `texture_${string}`, group: number, binding: number, attributes?: VariableOrValueAttribute[] @@ -283,7 +309,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[] @@ -336,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 80a77ca5..fa01cb7f 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,130 @@ 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, + StructDecl, + 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.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 ac4d1d0a..44deee38 100644 --- a/packages/core/src/rendering/webgpu/shaders/shader.ts +++ b/packages/core/src/rendering/webgpu/shaders/shader.ts @@ -3,19 +3,41 @@ * 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. + * + * 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 + * 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 { v4 as uuidv4 } from 'uuid'; +import type { DeclarationGenerator } from './declarations'; export type WgslShader = { - declarations: Declaration[]; + /** Opaque stable identity used by downstream caches. Assigned by `shader()`. */ + readonly id: string; + declarations: DeclarationGenerator[]; }; // NOTE: In the future, we may want to add further typeguards for the different declarations // 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 { @@ -25,6 +47,6 @@ export function asSource(shader: WgslShader): string { throw new Error('Invalid shader object'); } -export function shader(declarations: Declaration[]): WgslShader { - return { declarations }; +export function shader(declarations: DeclarationGenerator[]): WgslShader { + return { id: uuidv4(), declarations }; } diff --git a/packages/core/src/rendering/webgpu/shaders/vertex-format.ts b/packages/core/src/rendering/webgpu/shaders/vertex-format.ts new file mode 100644 index 00000000..7f0f32ab --- /dev/null +++ b/packages/core/src/rendering/webgpu/shaders/vertex-format.ts @@ -0,0 +1,154 @@ +/** + * `GPUVertexFormat` metadata: WGSL-type derivation, byte sizes, offset alignment, and the + * host-array kinds used by the typed upload packer. Split out from the vertex-layout logic so both + * the layout derivation (`pipelines/vertex-layout.ts`) and any shader-side reasoning can share one + * table. + * + * Also provides `defaultVertexFormat(wgslType)` — the natural `GPUVertexFormat` for a WGSL member + * type (`vec3f → float32x3`, `vec2u → uint32x2`, `vec2h → float16x2`). Because the format→WGSL-type + * mapping is many-to-one (`unorm8x4`, `snorm8x4`, `float32x4` all → `vec4f`), the default is the + * full-precision natural format; normalized/packed formats must be requested explicitly. + */ + +import type { VertexFormat } from '../native-types'; +import { vec, type WgslNumericScalarType, type WgslVecSize, wgslTypeName } from './wgsl-types'; + +/** The WGSL component scalar types a vertex format's shader-visible type can have. Alias of + * `WgslNumericScalarType` so the vertex layer and the WGSL type model share one definition. */ +export type VertexComponentType = WgslNumericScalarType; + +/** Which host-side `TypedArray` element type the typed upload path expects for a format's + * components. `float16` supplies raw half-float bits as `u16`. Normalized formats (`unorm*` / + * `snorm*`) accept pre-encoded integer arrays — v1 does not auto-quantize floats. */ +export type VertexArrayKind = 'u8' | 'i8' | 'u16' | 'i16' | 'u32' | 'i32' | 'f32'; + +/** Per-`GPUVertexFormat` metadata used to derive WGSL types, pack byte layouts, and validate. */ +export interface VertexFormatInfo { + /** Total bytes one attribute of this format occupies on the wire. */ + readonly byteSize: number; + /** Shader-visible vector width (1–4). */ + readonly components: number; + /** Derived WGSL member type, e.g. `vec3f`, `vec2u`, `vec4h`, `f32`. */ + readonly wgslType: string; + /** Shader-visible component scalar type. */ + readonly componentType: VertexComponentType; + /** Required byte alignment of an attribute's offset (`min(4, componentByteSize)`). */ + readonly alignment: number; + /** `TypedArray` element kind the packer reads host data as. */ + readonly arrayKind: VertexArrayKind; + /** How many `arrayKind` elements make up one vertex of this attribute (usually `components`; + * `1` for the packed `unorm10-10-10-2`, `4` for `unorm8x4-bgra`). */ + readonly elementsPerVertex: number; +} + +/** Derive the shader-visible WGSL type string for a format's component type + vector width, + * reusing the WGSL type model (`wgslTypeName`) rather than a hand-maintained table. */ +function wgslTypeFor(componentType: VertexComponentType, components: number): string { + return components === 1 + ? componentType + : wgslTypeName(vec(components as WgslVecSize, componentType)); +} + +function info( + byteSize: number, + components: number, + componentType: VertexComponentType, + arrayKind: VertexArrayKind, + componentByteSize: number, + elementsPerVertex: number = components +): VertexFormatInfo { + return { + byteSize, + components, + wgslType: wgslTypeFor(componentType, components), + componentType, + alignment: Math.min(4, componentByteSize), + arrayKind, + elementsPerVertex, + }; +} + +/** Static metadata for every `GPUVertexFormat`. Source of truth for WGSL-type derivation, byte + * layout, offset alignment, and the typed upload packer. */ +export const VERTEX_FORMAT_INFO: Record = { + uint8: info(1, 1, 'u32', 'u8', 1), + uint8x2: info(2, 2, 'u32', 'u8', 1), + uint8x4: info(4, 4, 'u32', 'u8', 1), + sint8: info(1, 1, 'i32', 'i8', 1), + sint8x2: info(2, 2, 'i32', 'i8', 1), + sint8x4: info(4, 4, 'i32', 'i8', 1), + unorm8: info(1, 1, 'f32', 'u8', 1), + unorm8x2: info(2, 2, 'f32', 'u8', 1), + unorm8x4: info(4, 4, 'f32', 'u8', 1), + snorm8: info(1, 1, 'f32', 'i8', 1), + snorm8x2: info(2, 2, 'f32', 'i8', 1), + snorm8x4: info(4, 4, 'f32', 'i8', 1), + uint16: info(2, 1, 'u32', 'u16', 2), + uint16x2: info(4, 2, 'u32', 'u16', 2), + uint16x4: info(8, 4, 'u32', 'u16', 2), + sint16: info(2, 1, 'i32', 'i16', 2), + sint16x2: info(4, 2, 'i32', 'i16', 2), + sint16x4: info(8, 4, 'i32', 'i16', 2), + unorm16: info(2, 1, 'f32', 'u16', 2), + unorm16x2: info(4, 2, 'f32', 'u16', 2), + unorm16x4: info(8, 4, 'f32', 'u16', 2), + snorm16: info(2, 1, 'f32', 'i16', 2), + snorm16x2: info(4, 2, 'f32', 'i16', 2), + snorm16x4: info(8, 4, 'f32', 'i16', 2), + float16: info(2, 1, 'f16', 'u16', 2), + float16x2: info(4, 2, 'f16', 'u16', 2), + float16x4: info(8, 4, 'f16', 'u16', 2), + float32: info(4, 1, 'f32', 'f32', 4), + float32x2: info(8, 2, 'f32', 'f32', 4), + float32x3: info(12, 3, 'f32', 'f32', 4), + float32x4: info(16, 4, 'f32', 'f32', 4), + uint32: info(4, 1, 'u32', 'u32', 4), + uint32x2: info(8, 2, 'u32', 'u32', 4), + uint32x3: info(12, 3, 'u32', 'u32', 4), + uint32x4: info(16, 4, 'u32', 'u32', 4), + sint32: info(4, 1, 'i32', 'i32', 4), + sint32x2: info(8, 2, 'i32', 'i32', 4), + sint32x3: info(12, 3, 'i32', 'i32', 4), + sint32x4: info(16, 4, 'i32', 'i32', 4), + 'unorm10-10-10-2': info(4, 4, 'f32', 'u32', 4, 1), + 'unorm8x4-bgra': info(4, 4, 'f32', 'u8', 1, 4), +}; + +/** Look up format metadata, throwing a clear error for an unknown format string. */ +export function vertexFormatInfo(format: VertexFormat): VertexFormatInfo { + const i = VERTEX_FORMAT_INFO[format]; + if (i === undefined) { + throw new Error(`vertexFormatInfo: unknown GPUVertexFormat '${format}'.`); + } + return i; +} + +/** The full-precision "natural" formats a WGSL type maps to by default (before any explicit + * override). One per WGSL type; normalized/packed formats are never defaults. */ +const NATURAL_FORMATS: readonly VertexFormat[] = [ + 'float32', + 'float32x2', + 'float32x3', + 'float32x4', + 'uint32', + 'uint32x2', + 'uint32x3', + 'uint32x4', + 'sint32', + 'sint32x2', + 'sint32x3', + 'sint32x4', + 'float16', + 'float16x2', + 'float16x4', +]; + +const DEFAULT_FORMAT_BY_WGSL: ReadonlyMap = new Map( + NATURAL_FORMATS.map((f) => [VERTEX_FORMAT_INFO[f].wgslType, f] as const) +); + +/** The natural `GPUVertexFormat` for a WGSL member type (`vec3f → float32x3`), or `undefined` when + * none applies (e.g. `vec3h` — WebGPU has no `float16x3`; declare a format explicitly). */ +export function defaultVertexFormat(wgslType: string): VertexFormat | undefined { + return DEFAULT_FORMAT_BY_WGSL.get(wgslType); +} diff --git a/packages/core/src/rendering/webgpu/shaders/vertex-interface.test.ts b/packages/core/src/rendering/webgpu/shaders/vertex-interface.test.ts new file mode 100644 index 00000000..c4f1d7b2 --- /dev/null +++ b/packages/core/src/rendering/webgpu/shaders/vertex-interface.test.ts @@ -0,0 +1,81 @@ +/** + * Prototype demonstration for `vertexInput(...)` — the vertex shader *input interface* model. + * Shows it against a realistic mixed entry (a struct + a loose `@location` param + a builtin) and + * exercises the up-front validation. + */ + +import { describe, expect, it } from 'vitest'; +import { builtin, location } from './attributes'; +import { member, param, returns, struct } from './declarations'; +import { vertexInput } from './vertex-interface'; + +const VertexIn = struct('VertexIn', [ + member('position', 'vec3f', [location(0)]), + member('color', 'vec4f', [location(1)]), +]); + +describe('vertexInput — mixed interface', () => { + it('classifies struct members, loose @location params, and builtins', () => { + const vin = vertexInput([ + VertexIn, + param('instanceOffset', 'vec3f', [location(2)]), + param('vertIdx', 'u32', [builtin('vertex_index')]), + ]); + + expect(vin.attributes).toEqual([ + { name: 'position', location: 0, wgslType: 'vec3f', struct: 'VertexIn' }, + { name: 'color', location: 1, wgslType: 'vec4f', struct: 'VertexIn' }, + { name: 'instanceOffset', location: 2, wgslType: 'vec3f' }, + ]); + expect(vin.builtins).toEqual([ + { name: 'vertIdx', builtin: 'vertex_index', wgslType: 'u32' }, + ]); + // The referenced struct is surfaced for top-level emission. + expect(vin.structs).toEqual([VertexIn]); + }); + + it('feeds vertexEntry a signature containing the struct + loose params', () => { + const vin = vertexInput([ + VertexIn, + param('vertIdx', 'u32', [builtin('vertex_index')]), + ]); + const fn = vin.entry('vs_main', () => 'return vec4f(0.0, 0.0, 0.0, 1.0);', returns('vec4f', [builtin('position')])); + const src = fn.__gen(); + expect(src).toContain('@vertex'); + expect(src).toContain('fn vs_main('); + expect(src).toContain(': VertexIn'); // struct param, auto-named + expect(src).toContain('@builtin(vertex_index) vertIdx: u32'); + expect(src).toContain('-> @builtin(position) vec4f'); + }); +}); + +describe('vertexInput — validation', () => { + it('rejects a leaf with neither @location nor @builtin', () => { + expect(() => vertexInput([param('bad', 'vec3f')])).toThrow(/neither @location nor @builtin/); + }); + + it('rejects a leaf with both @location and @builtin', () => { + expect(() => + vertexInput([param('bad', 'u32', [location(0), builtin('vertex_index')])]) + ).toThrow(/both @location and @builtin/); + }); + + it('rejects a non-input builtin like position', () => { + expect(() => vertexInput([param('p', 'vec4f', [builtin('position')])])).toThrow( + /only vertex_index \/ instance_index/ + ); + }); + + it('rejects duplicate @location across the whole interface (struct + loose param)', () => { + const Dup = struct('Dup', [member('a', 'vec3f', [location(0)])]); + expect(() => vertexInput([Dup, param('b', 'vec2f', [location(0)])])).toThrow( + /duplicate @location\(0\)/ + ); + }); + + it('aggregates multiple problems into one error', () => { + expect(() => + vertexInput([param('x', 'f32'), param('y', 'f32', [builtin('position')])]) + ).toThrow(/neither @location[\s\S]*only vertex_index/); + }); +}); diff --git a/packages/core/src/rendering/webgpu/shaders/vertex-interface.ts b/packages/core/src/rendering/webgpu/shaders/vertex-interface.ts new file mode 100644 index 00000000..2b65afbf --- /dev/null +++ b/packages/core/src/rendering/webgpu/shaders/vertex-interface.ts @@ -0,0 +1,221 @@ +/** + * PROTOTYPE — declarative vertex shader *input interface*. + * + * Where `vertex-input.ts` models a special `vertexStruct`, this explores the more general shape: + * a `vertexInput([...])` that accepts ordinary `struct` declarations AND loose entry `param`s + * (including `@builtin(vertex_index | instance_index)`), validates that every leaf is a + * well-formed vertex-stage input, and hands its parameter list to `vertexEntry(...)`. No special + * "vertex struct" type is required — a plain `struct(...)` simply *works as* a vertex input. + * + * This deliberately covers only concern (1) from the design discussion — the WGSL input + * *interface* + validation. The two orthogonal concerns are left as follow-ups: + * - per-attribute wire **format** (`GPUVertexFormat`) — a plain `member('c','vec4f',[location(1)])` + * can't say whether `c` is `unorm8x4` vs `float32x4`; the derived `attributes` here expose the + * WGSL type only. + * - **buffer grouping + stepMode** — not encoded by the WGSL interface at all. + * + * Example (mixed struct + loose @location + builtin): + * ```ts + * const VertexIn = struct('VertexIn', [ + * member('position', 'vec3f', [location(0)]), + * member('color', 'vec4f', [location(1)]), + * ]); + * const vin = vertexInput([ + * VertexIn, // struct → flattened @location members + * param('instanceOffset', 'vec3f', [location(2)]), // loose per-instance-ish attribute + * param('vertIdx', 'u32', [builtin('vertex_index')]), // builtin (no buffer) + * ]); + * const shader = shader([...vin.structs, vin.entry('vs_main', () => '...', returns('vec4f', [builtin('position')]))]); + * ``` + */ + +import type { BuiltinAttribute, LocationAttribute, VariableOrValueAttribute } from './attributes'; +import { + type FunctionDeclaration, + type FunctionParameterDeclaration, + type FunctionReturnTypeDeclaration, + param, + type StructDeclaration, + type TypeIdentifier, + vertexEntry, +} from './declarations'; +import { type WgslDataType, wgslTypeName } from './wgsl-types'; + +/** The `@builtin`s valid as *vertex-stage inputs*. (`position` is a vertex *output*; the rest are + * compute/fragment builtins.) */ +export const VERTEX_INPUT_BUILTINS = ['vertex_index', 'instance_index'] as const; +export type VertexInputBuiltinName = (typeof VERTEX_INPUT_BUILTINS)[number]; + +/** A buffer-backed `@location` leaf of the interface. Carries the WGSL type only — the wire + * `GPUVertexFormat` is a follow-up concern (see file header). */ +export interface VertexInputAttribute { + readonly name: string; + readonly location: number; + readonly wgslType: string; + /** Source struct name when the leaf came from a struct member; omitted for loose params. */ + readonly struct?: string; +} + +/** A `@builtin` leaf of the interface (driver-generated; never buffer-backed). */ +export interface VertexInputBuiltin { + readonly name: string; + readonly builtin: VertexInputBuiltinName; + readonly wgslType: string; + readonly struct?: string; +} + +/** Brand marking a validated vertex input interface. */ +export const VERTEX_INPUT_BRAND: unique symbol = Symbol.for('vis-core.webgpu.VertexInput'); + +/** A validated vertex shader input interface: the entry parameter list, the struct declarations it + * references (for top-level emission), and the classified `@location` / `@builtin` leaves. */ +export interface VertexInputInterface { + readonly __brand: typeof VERTEX_INPUT_BRAND; + /** Parameters to hand to the vertex entry function's signature. */ + readonly params: readonly FunctionParameterDeclaration[]; + /** Struct declarations referenced by the interface (drop these into `shader([...])`). */ + readonly structs: readonly StructDeclaration[]; + /** Buffer-backed attributes (`@location` leaves), in declaration order. */ + readonly attributes: readonly VertexInputAttribute[]; + /** Builtin leaves (`@builtin(vertex_index | instance_index)`). */ + readonly builtins: readonly VertexInputBuiltin[]; + /** Convenience: build the `@vertex` entry function using this interface's parameters. */ + entry(name: string, body: () => string, returnType?: FunctionReturnTypeDeclaration): FunctionDeclaration; +} + +// ---- internals ------------------------------------------------------------------------------ + +function isLocationAttr(a: VariableOrValueAttribute): a is LocationAttribute { + return 'location' in a; +} +function isBuiltinAttr(a: VariableOrValueAttribute): a is BuiltinAttribute { + return 'builtin' in a; +} +function isStructDeclaration(t: unknown): t is StructDeclaration { + return ( + typeof t === 'object' && + t !== null && + (t as { __identType?: unknown }).__identType === 'struct' + ); +} + +/** Render a `TypeIdentifier` to its WGSL source string (mirror of the private helper in + * `declarations.ts`). */ +function renderType(type: TypeIdentifier): string { + if (typeof type === 'string') return type; + if (!('kind' in type)) return type.name; // StructDeclaration | AliasDeclaration + return wgslTypeName(type as WgslDataType); +} + +/** Derive an entry-param name for a bare struct input (`VertexIn` → `vertexIn`). Pass an explicit + * `param(name, struct)` instead if you need to control the name used in the shader body. */ +function deriveParamName(structName: string): string { + return structName.length > 0 ? structName.charAt(0).toLowerCase() + structName.slice(1) : 'input'; +} + +// ---- builder -------------------------------------------------------------------------------- + +/** Compose + validate a vertex shader input interface from ordinary `struct` declarations and/or + * loose entry `param`s. Throws (aggregating every problem) if any leaf is not a valid vertex + * input: each leaf must carry exactly one of `@location` or a vertex-stage `@builtin`, and all + * `@location`s must be unique across the whole interface. */ +export function vertexInput( + inputs: readonly (StructDeclaration | FunctionParameterDeclaration)[] +): VertexInputInterface { + const params: FunctionParameterDeclaration[] = []; + const structs: StructDeclaration[] = []; + const attributes: VertexInputAttribute[] = []; + const builtins: VertexInputBuiltin[] = []; + const seenLocations = new Map(); + const errors: string[] = []; + + const classifyLeaf = ( + leafName: string, + attrs: readonly VariableOrValueAttribute[] | undefined, + wgslType: string, + structName?: string + ): void => { + const loc = attrs?.find(isLocationAttr); + const bi = attrs?.find(isBuiltinAttr); + if (loc !== undefined && bi !== undefined) { + errors.push(`'${leafName}' has both @location and @builtin — a vertex input leaf must declare exactly one.`); + return; + } + if (loc === undefined && bi === undefined) { + errors.push(`'${leafName}' has neither @location nor @builtin — every vertex input leaf must declare one.`); + return; + } + if (bi !== undefined) { + if (!VERTEX_INPUT_BUILTINS.includes(bi.builtin as VertexInputBuiltinName)) { + errors.push( + `'${leafName}' uses @builtin(${bi.builtin}) — only ${VERTEX_INPUT_BUILTINS.join(' / ')} are valid vertex-stage inputs.` + ); + return; + } + builtins.push({ + name: leafName, + builtin: bi.builtin as VertexInputBuiltinName, + wgslType, + ...(structName !== undefined && { struct: structName }), + }); + return; + } + // location leaf + const prior = seenLocations.get((loc as LocationAttribute).location); + const locNum = (loc as LocationAttribute).location; + if (prior !== undefined) { + errors.push(`duplicate @location(${locNum}) on '${prior}' and '${leafName}' — vertex locations must be unique.`); + } else { + seenLocations.set(locNum, leafName); + } + attributes.push({ + name: leafName, + location: locNum, + wgslType, + ...(structName !== undefined && { struct: structName }), + }); + }; + + const addStruct = (s: StructDeclaration): void => { + if (!structs.includes(s)) structs.push(s); + for (const f of s.fields) classifyLeaf(f.name, f.attributes, renderType(f.type), s.name); + }; + + for (const input of inputs) { + if (isStructDeclaration(input)) { + params.push(param(deriveParamName(input.name), input)); + addStruct(input); + } else { + params.push(input); + if (isStructDeclaration(input.type)) { + addStruct(input.type); + } else { + classifyLeaf(input.name, input.attributes, renderType(input.type)); + } + } + } + + if (errors.length > 0) { + throw new Error(`vertexInput: invalid vertex input interface:\n - ${errors.join('\n - ')}`); + } + + return { + __brand: VERTEX_INPUT_BRAND, + params, + structs, + attributes, + builtins, + entry(name, body, returnType) { + return vertexEntry(name, params, body, returnType); + }, + }; +} + +/** Runtime discriminator for a `VertexInputInterface`. */ +export function isVertexInput(value: unknown): value is VertexInputInterface { + return ( + typeof value === 'object' && + value !== null && + (value as { __brand?: unknown }).__brand === VERTEX_INPUT_BRAND + ); +} 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/packages/core/src/rendering/webgpu/slot.ts b/packages/core/src/rendering/webgpu/slot.ts new file mode 100644 index 00000000..17c7ff62 --- /dev/null +++ b/packages/core/src/rendering/webgpu/slot.ts @@ -0,0 +1,133 @@ +/** + * `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 { TextureFormat } from './native-types'; +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: TextureFormat, + 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. `