From 1dae8f527033e113292d41b9c8a36f17adbba6ca Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 27 Jul 2026 04:28:47 +0200 Subject: [PATCH 01/10] typed command encoder and passes --- .../docs/advanced/timestamp-queries.mdx | 3 + .../src/content/docs/apis/pipelines.mdx | 81 ++- .../rendering/render-bundles-with/index.ts | 13 +- .../rendering/render-bundles/index.ts | 43 +- .../examples/rendering/simple-shadow/index.ts | 80 +-- .../typegpu-testing-utility/src/extendedIt.ts | 38 +- .../src/core/commandEncoder/attachments.ts | 52 ++ .../src/core/commandEncoder/commandEncoder.ts | 174 +++++ .../src/core/commandEncoder/computePass.ts | 185 ++++++ .../src/core/commandEncoder/renderPass.ts | 487 ++++++++++++++ .../src/core/pipeline/applyPipelineState.ts | 20 + .../src/core/pipeline/computePipeline.ts | 154 +++-- .../typegpu/src/core/pipeline/drawState.ts | 282 +++++++++ .../src/core/pipeline/renderPipeline.ts | 382 ++++------- .../typegpu/src/core/pipeline/timeable.ts | 87 ++- .../typegpu/src/core/pipeline/typeGuards.ts | 30 + packages/typegpu/src/core/root/init.ts | 174 ++--- packages/typegpu/src/core/root/rootTypes.ts | 276 +------- packages/typegpu/src/core/texture/texture.ts | 8 + packages/typegpu/src/indexNamedExports.ts | 11 + packages/typegpu/src/unwrapper.ts | 6 + packages/typegpu/tests/commandEncoder.test.ts | 598 ++++++++++++++++++ .../typegpu/tests/computePipeline.test.ts | 111 ++++ packages/typegpu/tests/renderPipeline.test.ts | 47 +- packages/typegpu/tests/root.test.ts | 52 +- pnpm-lock.yaml | 35 +- pnpm-workspace.yaml | 2 +- 27 files changed, 2519 insertions(+), 912 deletions(-) create mode 100644 packages/typegpu/src/core/commandEncoder/attachments.ts create mode 100644 packages/typegpu/src/core/commandEncoder/commandEncoder.ts create mode 100644 packages/typegpu/src/core/commandEncoder/computePass.ts create mode 100644 packages/typegpu/src/core/commandEncoder/renderPass.ts create mode 100644 packages/typegpu/src/core/pipeline/drawState.ts create mode 100644 packages/typegpu/tests/commandEncoder.test.ts diff --git a/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx b/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx index 44a1bdeb9a..f3fbd37c65 100644 --- a/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx +++ b/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx @@ -45,6 +45,9 @@ const pipeline = root * **Automatic query set** If you haven’t provided a `TgpuQuerySet` before calling `.withPerformanceCallback()`, TypeGPU will allocate one for you along with the necessary resolve buffers. +* **With a command encoder** + Performance callbacks also work when the pipeline is bound to a [command encoder](/TypeGPU/apis/pipelines/#command-encoders-and-passes) with `pipeline.with(encoder)`. The timestamps are resolved as part of that encoder's submission, and the callback fires from `encoder.submit()`. They cannot be used with a pass begun by someone else — a pass writes its timestamps as part of its descriptor, so pass those to `encoder.beginRenderPass` / `encoder.beginComputePass` instead. + ## Using `TgpuQuerySet` For finer control, create and manage your own `TgpuQuerySet`. You can attach it either to a TypeGPU pipeline or directly to a raw WebGPU encoder. diff --git a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx index 24a9c50488..d3f76e9ea0 100644 --- a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx @@ -558,34 +558,79 @@ When passed a `GPUComputePassEncoder` or `GPURenderPassEncoder`, TypeGPU applies Render pipelines also accept a `GPURenderBundleEncoder`, allowing `.draw(...)`, `.drawIndexed(...)`, and their indirect variants to be recorded in a render bundle. The caller remains responsible for ending the pass or finishing the bundle. -## Experimental low-level render-pass API +## Command encoders and passes :::caution[Experimental] -`root['~unstable'].beginRenderPass` is an *unstable* feature. The API may be subject to change in the near future. +`createCommandEncoder` and typed passes are *unstable* features. The API may be subject to change in the near future. ::: -The higher-level API has several limitations, therefore another way of executing pipelines is exposed, for some custom, more demanding scenarios. For example, with the high-level API, it is not possible to execute multiple pipelines per one render pass. It also may be missing some more niche features of the WebGPU API. - -`root['~unstable'].beginRenderPass` is a method that mirrors the WebGPU API, but enriches it with a direct TypeGPU resource support. +When executed directly via `draw` or `dispatchWorkgroups`, each pipeline records its own pass into its own command encoder and submits it immediately. +For more demanding scenarios — batching multiple pipelines into a single render pass, or multiple passes into a single submission — TypeGPU exposes a command encoder API that mirrors WebGPU, enriched with direct TypeGPU resource support. ```ts -root['~unstable'].beginRenderPass( - { - colorAttachments: [{ - ... - }], - }, - (pass) => { - pass.setPipeline(renderPipeline); - pass.setBindGroup(layout, group); - pass.draw(3); +const encoder = root['~unstable'].createCommandEncoder(); + +const pass = encoder.beginRenderPass({ + colorAttachments: [{ + view: msaaTexture, + resolveTarget: context, + }], + depthStencilAttachment: { + view: depthTexture, }, -); +}); + +scenePipeline.with(pass).draw(mesh.vertexCount); +lightPipeline.with(pass).draw(6, lightCount); +skyPipeline.with(pass).draw(3); + +pass.end(); +encoder.submit(); +``` + +Compared to the raw WebGPU API: + +- Attachment views accept TypeGPU textures, texture views and canvas contexts, next to raw `GPUTextureView`s. +- Common descriptor properties get sensible defaults: `loadOp: 'clear'`, `storeOp: 'store'`, and for depth attachments `depthClearValue: 1`. A single color attachment can be passed without wrapping it in an array. +- `occlusionQuerySet` and `timestampWrites` accept [TypeGPU query sets](/TypeGPU/advanced/timestamp-queries/) next to raw `GPUQuerySet`s. + +Pipelines can draw into a pass in two equivalent ways. +Binding the pass with `pipeline.with(pass)` keeps the pipeline-centric API, including all of its `with*` methods. +Alternatively, the pass itself exposes a proxy surface mirroring `GPURenderPassEncoder`, but accepting TypeGPU resources. + +```ts +pass.setPipeline(renderPipeline); +pass.setBindGroup(bindGroup); +pass.setVertexBuffer(vertexLayout, vertexBuffer); +pass.draw(3); ``` +In both styles, the underlying pipeline, bind groups and vertex buffers are applied lazily and deduplicated — repeated draws only re-record what actually changed. Pipeline-level bindings (`pipeline.with(bindGroup)`) take precedence over pass-level ones (`pass.setBindGroup`). + +Note that `pipeline.with(pass)` returns a new pipeline wrapper on every call — hoist it out of draw loops (`const bound = pipeline.with(pass)`) so deduplication can kick in. For values that change between draws, prefer pass-level state (`pass.setBindGroup`) over the allocating `with*` methods. + +Compute passes work the same way: + +```ts +const encoder = root['~unstable'].createCommandEncoder(); +const pass = encoder.beginComputePass(); +computePipeline.with(pass).dispatchWorkgroups(16); +pass.end(); +encoder.submit(); +``` + +Work that can only be reported once the GPU has been given the commands — shader `console.log` output and [performance callbacks](/TypeGPU/advanced/timestamp-queries/) — is carried out by `encoder.submit()`. +Timestamps are resolved as part of that same submission, so no extra command buffer is needed for them. + +For anything not covered by the typed surface, there are escape hatches: + +- `root.unwrap(encoder)`, `root.unwrap(pass)` — access the raw `GPUCommandEncoder`, `GPURenderPassEncoder` or `GPUComputePassEncoder`, e.g. for buffer copies. Since raw pass commands can change state invisibly to TypeGPU, unwrapping a pass turns off state deduplication for it — every subsequent typed draw re-applies its full state. +- `encoder.finish()` — returns the `GPUCommandBuffer` without submitting, for manual multi-encoder batching via `device.queue.submit([...])`. Since TypeGPU never sees the submission, shader logs and performance callbacks do not fire for such a command buffer. + +Passing a raw `GPUCommandEncoder` or a raw pass encoder to `pipeline.with(...)` works too, but comes with the same two limitations: TypeGPU cannot know when the caller submits, and cannot assume anything about state the caller may have set, so every draw re-applies its full state. + It is also possible to access the underlying WebGPU resources for the TypeGPU pipelines, by calling `root.unwrap(pipeline)`. -That way, they can be used with a regular WebGPU API, but unlike the `root['~unstable'].beginRenderPass` API, it also requires unwrapping all the necessary -resources. +That way, they can be used with a regular WebGPU API, though this also requires unwrapping all the necessary resources. ```ts twoslash import { tgpu, d } from 'typegpu'; diff --git a/apps/typegpu-docs/src/examples/rendering/render-bundles-with/index.ts b/apps/typegpu-docs/src/examples/rendering/render-bundles-with/index.ts index ebb184f6ef..0ab3494a51 100644 --- a/apps/typegpu-docs/src/examples/rendering/render-bundles-with/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/render-bundles-with/index.ts @@ -161,22 +161,17 @@ function frame() { }); } - const encoder = root.device.createCommandEncoder(); + const encoder = root['~unstable'].createCommandEncoder(); const pass = encoder.beginRenderPass({ colorAttachments: [ { - view: context.getCurrentTexture().createView(), - clearValue: [1, 0.85, 0.74, 1] as const, - loadOp: 'clear' as const, - storeOp: 'store' as const, + view: context, + clearValue: [1, 0.85, 0.74, 1], }, ], depthStencilAttachment: { view: depthTexture.createView(), - depthClearValue: 1, - depthLoadOp: 'clear' as const, - depthStoreOp: 'store' as const, }, }); @@ -190,7 +185,7 @@ function frame() { } pass.end(); - root.device.queue.submit([encoder.finish()]); + encoder.submit(); requestAnimationFrame(frame); } diff --git a/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts b/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts index 16dda08ae5..dee179df1d 100644 --- a/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts @@ -159,38 +159,35 @@ function frame() { }); } - const passDescriptor = { + const encoder = root['~unstable'].createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [ { - view: context.getCurrentTexture().createView(), - clearValue: [1, 0.85, 0.74, 1] as const, - loadOp: 'clear' as const, - storeOp: 'store' as const, + view: context, + clearValue: [1, 0.85, 0.74, 1], }, ], depthStencilAttachment: { view: depthTexture.createView(), - depthClearValue: 1, - depthLoadOp: 'clear' as const, - depthStoreOp: 'store' as const, }, - }; - - root['~unstable'].beginRenderPass(passDescriptor, (pass) => { - if (useBundles) { - pass.executeBundles([renderBundle]); - } else { - pass.setPipeline(pipeline); - pass.setBindGroup(cameraLayout, cameraBindGroup); - pass.setBindGroup(cubeLayout, cubeBindGroup); - pass.setBindGroup(terrainLayout, terrainBindGroup); - pass.setVertexBuffer(vertexLayout, vertexBuffer); + }); - for (let i = 0; i < cubeCount; i++) { - pass.draw(VERTS_PER_CUBE, 1, 0, i); - } + if (useBundles) { + pass.executeBundles([renderBundle]); + } else { + pass.setPipeline(pipeline); + pass.setBindGroup(cameraLayout, cameraBindGroup); + pass.setBindGroup(cubeLayout, cubeBindGroup); + pass.setBindGroup(terrainLayout, terrainBindGroup); + pass.setVertexBuffer(vertexLayout, vertexBuffer); + + for (let i = 0; i < cubeCount; i++) { + pass.draw(VERTS_PER_CUBE, 1, 0, i); } - }); + } + + pass.end(); + encoder.submit(); requestAnimationFrame(frame); } diff --git a/apps/typegpu-docs/src/examples/rendering/simple-shadow/index.ts b/apps/typegpu-docs/src/examples/rendering/simple-shadow/index.ts index 870fa136bc..837c20896f 100644 --- a/apps/typegpu-docs/src/examples/rendering/simple-shadow/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/simple-shadow/index.ts @@ -285,58 +285,42 @@ let frameId: number | null = null; function render() { frameId = requestAnimationFrame(render); - root['~unstable'].beginRenderPass( - { - colorAttachments: [], - depthStencilAttachment: { - view: root.unwrap(shadowTextures.shadowMap), - depthLoadOp: 'clear', - depthStoreOp: 'store', - depthClearValue: 1.0, - }, - }, - (pass) => { - pass.setPipeline(shadowPipeline); - for (const geometry of Object.values(geometries)) { - pass.setBindGroup(bindGroupLayout, geometry.instanceInfo); - pass.setVertexBuffer(vertexLayout, geometry.vertexBuffer); - pass.setIndexBuffer(geometry.indexBuffer, 'uint16'); - pass.drawIndexed(geometry.indexCount); - } - }, - ); + const encoder = root['~unstable'].createCommandEncoder(); + + const shadowPass = encoder.beginRenderPass({ + depthStencilAttachment: { view: shadowTextures.shadowMap }, + }); + shadowPass.setPipeline(shadowPipeline); + for (const geometry of Object.values(geometries)) { + shadowPass.setBindGroup(bindGroupLayout, geometry.instanceInfo); + shadowPass.setVertexBuffer(vertexLayout, geometry.vertexBuffer); + shadowPass.setIndexBuffer(geometry.indexBuffer, 'uint16'); + shadowPass.drawIndexed(geometry.indexCount); + } + shadowPass.end(); - root['~unstable'].beginRenderPass( - { - colorAttachments: [ - { - view: root.unwrap(canvasTextures.msaa), - resolveTarget: context.getCurrentTexture(), - loadOp: 'clear', - storeOp: 'store', - clearValue: [0, 0, 0, 0], - }, - ], - depthStencilAttachment: { - view: root.unwrap(canvasTextures.depth), - depthLoadOp: 'clear', - depthStoreOp: 'store', - depthClearValue: 1, + const mainPass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: canvasTextures.msaa, + resolveTarget: context, }, - }, - (pass) => { - pass.setPipeline(pipeline); - pass.setBindGroup(shadowSampleLayout, shadowTextures.shadowBindGroup); + ], + depthStencilAttachment: { view: canvasTextures.depth }, + }); + mainPass.setPipeline(pipeline); + mainPass.setBindGroup(shadowSampleLayout, shadowTextures.shadowBindGroup); - for (const geometry of Object.values(geometries)) { - pass.setBindGroup(bindGroupLayout, geometry.instanceInfo); - pass.setVertexBuffer(vertexLayout, geometry.vertexBuffer); - pass.setIndexBuffer(geometry.indexBuffer, 'uint16'); + for (const geometry of Object.values(geometries)) { + mainPass.setBindGroup(bindGroupLayout, geometry.instanceInfo); + mainPass.setVertexBuffer(vertexLayout, geometry.vertexBuffer); + mainPass.setIndexBuffer(geometry.indexBuffer, 'uint16'); - pass.drawIndexed(geometry.indexCount); - } - }, - ); + mainPass.drawIndexed(geometry.indexCount); + } + mainPass.end(); + + encoder.submit(); } frameId = requestAnimationFrame(render); diff --git a/packages/typegpu-testing-utility/src/extendedIt.ts b/packages/typegpu-testing-utility/src/extendedIt.ts index 58676463c0..ebf60d52a9 100644 --- a/packages/typegpu-testing-utility/src/extendedIt.ts +++ b/packages/typegpu-testing-utility/src/extendedIt.ts @@ -72,7 +72,26 @@ export const it = base return mockCommandEncoder as unknown as GPUCommandEncoder & { mock: typeof mockCommandEncoder }; }) - .extend('device', ({ commandEncoder }) => { + .extend('renderBundleEncoder', () => { + const mockRenderBundleEncoder = { + get mock() { + return mockRenderBundleEncoder; + }, + draw: vi.fn(), + drawIndexed: vi.fn(), + setBindGroup: vi.fn(), + setPipeline: vi.fn(), + setVertexBuffer: vi.fn(), + setIndexBuffer: vi.fn(), + finish: vi.fn(() => 'mockRenderBundle'), + label: '', + }; + + return mockRenderBundleEncoder as unknown as GPURenderBundleEncoder & { + mock: typeof mockRenderBundleEncoder; + }; + }) + .extend('device', ({ commandEncoder, renderBundleEncoder }) => { const mockDevice = { get mock() { return mockDevice; @@ -121,6 +140,7 @@ export const it = base label: label ?? '', }), ), + createRenderBundleEncoder: vi.fn(() => renderBundleEncoder), createRenderPipeline: vi.fn(() => 'mockRenderPipeline'), createRenderPipelineAsync: vi.fn(async () => 'mockRenderPipeline'), createSampler: vi.fn(() => 'mockSampler'), @@ -251,22 +271,6 @@ export const it = base onCleanup(() => root.destroy()); return root as ExperimentalTgpuRoot; - }) - .extend('renderBundleEncoder', () => { - const mockRenderBundleEncoder = { - draw: vi.fn(), - drawIndexed: vi.fn(), - setBindGroup: vi.fn(), - setPipeline: vi.fn(), - setVertexBuffer: vi.fn(), - setIndexBuffer: vi.fn(), - finish: vi.fn(() => 'mockRenderBundle'), - label: '', - }; - - return mockRenderBundleEncoder as unknown as GPURenderBundleEncoder & { - mock: typeof mockRenderBundleEncoder; - }; }); export const test = it; diff --git a/packages/typegpu/src/core/commandEncoder/attachments.ts b/packages/typegpu/src/core/commandEncoder/attachments.ts new file mode 100644 index 0000000000..d5df7ad912 --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/attachments.ts @@ -0,0 +1,52 @@ +import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; +import { isGPUCanvasContext } from '../pipeline/typeGuards.ts'; +import type { ColorAttachment, DepthStencilAttachment } from '../pipeline/renderPipeline.ts'; +import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; +import { isTexture, isTextureView } from '../texture/texture.ts'; + +export type AnyAttachmentView = + | ColorAttachment['view'] + | NonNullable + | DepthStencilAttachment['view']; + +export function unwrapAttachmentView( + root: ExperimentalTgpuRoot, + view: AnyAttachmentView, +): GPUTextureView { + if (isTexture(view)) { + return root.unwrap(view).createView(); + } + if (isTextureView(view)) { + return root.unwrap(view); + } + if (isGPUCanvasContext(view)) { + return view.getCurrentTexture().createView(); + } + return view as GPUTextureView; +} + +export interface TgpuPassTimestampWrites { + querySet: TgpuQuerySet<'timestamp'> | GPUQuerySet; + beginningOfPassWriteIndex?: number | undefined; + endOfPassWriteIndex?: number | undefined; +} + +export function unwrapTimestampWrites( + root: ExperimentalTgpuRoot, + timestampWrites: TgpuPassTimestampWrites, +): GPURenderPassTimestampWrites | GPUComputePassTimestampWrites { + const { querySet, beginningOfPassWriteIndex, endOfPassWriteIndex } = timestampWrites; + + const result: GPURenderPassTimestampWrites | GPUComputePassTimestampWrites = { + querySet: isQuerySet(querySet) ? root.unwrap(querySet) : querySet, + }; + + if (beginningOfPassWriteIndex !== undefined) { + result.beginningOfPassWriteIndex = beginningOfPassWriteIndex; + } + if (endOfPassWriteIndex !== undefined) { + result.endOfPassWriteIndex = endOfPassWriteIndex; + } + + return result; +} diff --git a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts new file mode 100644 index 0000000000..fb0ae0e641 --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -0,0 +1,174 @@ +import { $internal } from '../../shared/symbols.ts'; +import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; +import { + INTERNAL_beginComputePass, + type TgpuComputePass, + type TgpuComputePassDescriptor, +} from './computePass.ts'; +import { + INTERNAL_beginRenderPass, + type TgpuRenderPass, + type TgpuRenderPassDescriptor, +} from './renderPass.ts'; + +// ---------- +// Public API +// ---------- + +export interface CommandEncoderInternals { + readonly rawEncoder: GPUCommandEncoder; + readonly root: ExperimentalTgpuRoot; + /** + * Whether the raw encoder came from the caller, which means submission is out + * of our hands and no work can be deferred to it. + */ + readonly adopted: boolean; + /** + * Commands recorded just before the encoder is finished, keyed for deduplication. + */ + readonly beforeFinish: Map void>; + /** + * Callbacks run once the recorded commands have been submitted, keyed for + * deduplication. + */ + readonly afterSubmit: Map void>; +} + +/** + * The TypeGPU equivalent of {@link GPUCommandEncoder}, for batching multiple + * passes (and draws within them) into a single submission. + * + * @example + * ```ts + * const encoder = root['~unstable'].createCommandEncoder(); + * const pass = encoder.beginRenderPass({ + * colorAttachments: [{ view: msaaTexture, resolveTarget: context }], + * }); + * scenePipeline.with(pass).draw(vertexCount); + * skyPipeline.with(pass).draw(3); + * pass.end(); + * encoder.submit(); + * ``` + * + * For anything not covered by the typed surface (e.g. buffer copies), grab + * the raw encoder via `root.unwrap(encoder)`. + */ +export interface TgpuCommandEncoder { + readonly [$internal]: CommandEncoderInternals; + readonly resourceType: 'command-encoder'; + + /** + * Begins recording a render pass. Attachment views accept TypeGPU textures, + * texture views and canvas contexts, next to raw {@link GPUTextureView}s. + */ + beginRenderPass(descriptor: TgpuRenderPassDescriptor): TgpuRenderPass; + + /** + * Begins recording a compute pass. + */ + beginComputePass(descriptor?: TgpuComputePassDescriptor): TgpuComputePass; + + /** + * Finishes the recording and submits the resulting command buffer + * to the device queue. + */ + submit(): void; + + /** + * Escape hatch: finishes the recording without submitting, for manual + * multi-encoder batching via `device.queue.submit([...])`. + */ + finish(descriptor?: GPUCommandBufferDescriptor): GPUCommandBuffer; +} + +export function INTERNAL_createCommandEncoder( + root: ExperimentalTgpuRoot, + descriptor?: GPUCommandEncoderDescriptor, +): TgpuCommandEncoder { + return new TgpuCommandEncoderImpl(root, root.device.createCommandEncoder(descriptor), false); +} + +const adoptedCommandEncoders = new WeakMap(); + +/** + * Wraps a raw command encoder the user owns, so that passes begun on it take + * the same route as passes begun on a TypeGPU encoder. Submission stays the + * caller's responsibility. + */ +export function INTERNAL_adoptCommandEncoder( + root: ExperimentalTgpuRoot, + rawEncoder: GPUCommandEncoder, +): TgpuCommandEncoder { + let adopted = adoptedCommandEncoders.get(rawEncoder); + + if (adopted === undefined) { + adopted = new TgpuCommandEncoderImpl(root, rawEncoder, true); + adoptedCommandEncoders.set(rawEncoder, adopted); + } + + return adopted; +} + +// -------------- +// Implementation +// -------------- + +const _warnedFinishWithPendingWork = new WeakSet(); + +class TgpuCommandEncoderImpl implements TgpuCommandEncoder { + readonly [$internal]: CommandEncoderInternals; + readonly resourceType = 'command-encoder'; + + constructor(root: ExperimentalTgpuRoot, rawEncoder: GPUCommandEncoder, adopted: boolean) { + this[$internal] = { + rawEncoder, + root, + adopted, + beforeFinish: new Map(), + afterSubmit: new Map(), + }; + } + + beginRenderPass(descriptor: TgpuRenderPassDescriptor): TgpuRenderPass { + return INTERNAL_beginRenderPass(this, descriptor); + } + + beginComputePass(descriptor?: TgpuComputePassDescriptor): TgpuComputePass { + return INTERNAL_beginComputePass(this, descriptor); + } + + #recordPendingCommands(): void { + const { rawEncoder, beforeFinish } = this[$internal]; + + for (const record of beforeFinish.values()) { + record(rawEncoder); + } + beforeFinish.clear(); + } + + submit(): void { + const { rawEncoder, root, afterSubmit } = this[$internal]; + this.#recordPendingCommands(); + + root.device.queue.submit([rawEncoder.finish()]); + + for (const hook of afterSubmit.values()) { + hook(); + } + afterSubmit.clear(); + } + + finish(descriptor?: GPUCommandBufferDescriptor): GPUCommandBuffer { + const { rawEncoder, afterSubmit } = this[$internal]; + this.#recordPendingCommands(); + + if (afterSubmit.size > 0 && !_warnedFinishWithPendingWork.has(this)) { + _warnedFinishWithPendingWork.add(this); + console.warn( + 'Shader console.log output and performance callbacks do not fire for command buffers produced by encoder.finish(). Use encoder.submit() instead.', + ); + } + + return rawEncoder.finish(descriptor); + } +} diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts new file mode 100644 index 0000000000..1c577746dc --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -0,0 +1,185 @@ +import { $internal } from '../../shared/symbols.ts'; +import { + isBindGroup, + type TgpuBindGroup, + type TgpuBindGroupLayout, + type TgpuLayoutEntry, +} from '../../tgpuBindGroupLayout.ts'; +import { ComputeDrawState, emitComputeDispatch } from '../pipeline/drawState.ts'; +import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts'; +import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; +import { type TgpuPassTimestampWrites, unwrapTimestampWrites } from './attachments.ts'; +import type { TgpuCommandEncoder } from './commandEncoder.ts'; + +// ---------- +// Public API +// ---------- + +/** + * The TypeGPU equivalent of {@link GPUComputePassDescriptor}. + * Query sets accept {@link TgpuQuerySet} next to raw {@link GPUQuerySet}s. + */ +export interface TgpuComputePassDescriptor { + label?: string | undefined; + timestampWrites?: TgpuPassTimestampWrites | undefined; +} + +export interface ComputePassInternals { + readonly rawPass: GPUComputePassEncoder; + readonly state: ComputeDrawState; + /** + * The encoder this pass records into, when it is one we can defer work to. + * Undefined for raw pass encoders the caller owns. + */ + readonly owner: TgpuCommandEncoder | undefined; + lastApplied: { pipeline: TgpuComputePipeline; version: number } | undefined; +} + +/** + * A compute pass recording into a {@link TgpuCommandEncoder}. + * + * Dispatch either by binding TypeGPU pipelines to it + * (`pipeline.with(pass).dispatchWorkgroups(...)`), or proxy-style via + * `pass.setPipeline(pipeline)` followed by `pass.dispatchWorkgroups(...)`. + * + * Call `end()` when done recording. + */ +export interface TgpuComputePass { + readonly [$internal]: ComputePassInternals; + readonly resourceType: 'compute-pass'; + + /** + * Sets the current {@link TgpuComputePipeline} for subsequent dispatches. + */ + setPipeline(pipeline: TgpuComputePipeline): void; + + /** + * Associates a bind group (with the layout it was created from) for subsequent dispatches. + */ + setBindGroup(bindGroup: TgpuBindGroup): void; + /** + * Associates a bind group with the given layout for subsequent dispatches. + */ + setBindGroup>( + bindGroupLayout: TgpuBindGroupLayout, + bindGroup: TgpuBindGroup | GPUBindGroup, + ): void; + + dispatchWorkgroups(x: number, y?: number, z?: number): void; + dispatchWorkgroupsIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; + + /** + * Completes the recording of this compute pass. + */ + end(): void; +} + +// -------------- +// Implementation +// -------------- + +export function INTERNAL_beginComputePass( + encoder: TgpuCommandEncoder, + descriptor?: TgpuComputePassDescriptor, +): TgpuComputePass { + const { rawEncoder, root } = encoder[$internal]; + const rawDescriptor: GPUComputePassDescriptor = {}; + + if (descriptor?.label !== undefined) { + rawDescriptor.label = descriptor.label; + } + + if (descriptor?.timestampWrites !== undefined) { + rawDescriptor.timestampWrites = unwrapTimestampWrites( + root, + descriptor.timestampWrites, + ) as GPUComputePassTimestampWrites; + } + + return new TgpuComputePassImpl(root, rawEncoder.beginComputePass(rawDescriptor), encoder); +} + +const adoptedComputePasses = new WeakMap(); + +/** + * Wraps a raw compute pass encoder the user owns, so that dispatches recorded + * into it take the same route as dispatches into a TypeGPU pass. The state is + * marked as raw-accessed, since the encoder can be mutated behind our back at + * any point. + */ +export function INTERNAL_adoptComputePass( + root: ExperimentalTgpuRoot, + rawPass: GPUComputePassEncoder, +): TgpuComputePass { + let adopted = adoptedComputePasses.get(rawPass); + + if (adopted === undefined) { + adopted = new TgpuComputePassImpl(root, rawPass, undefined); + adopted[$internal].state.rawAccessed = true; + adoptedComputePasses.set(rawPass, adopted); + } + + return adopted; +} + +class TgpuComputePassImpl implements TgpuComputePass { + readonly [$internal]: ComputePassInternals; + readonly resourceType = 'compute-pass'; + readonly #root: ExperimentalTgpuRoot; + + constructor( + root: ExperimentalTgpuRoot, + rawPass: GPUComputePassEncoder, + owner: TgpuCommandEncoder | undefined, + ) { + this.#root = root; + this[$internal] = { + rawPass, + state: new ComputeDrawState(), + owner, + lastApplied: undefined, + }; + } + + #emit(emit: (rawPass: GPUComputePassEncoder) => void): void { + const internals = this[$internal]; + const pipeline = internals.state.currentPipeline; + + if (!pipeline) { + throw new Error('Cannot dispatch without a call to pass.setPipeline'); + } + + emitComputeDispatch(this.#root, internals, pipeline, emit); + } + + setPipeline(pipeline: TgpuComputePipeline): void { + const { state } = this[$internal]; + state.currentPipeline = pipeline; + state.version++; + } + + setBindGroup>( + first: TgpuBindGroup | TgpuBindGroupLayout, + bindGroup?: TgpuBindGroup | GPUBindGroup, + ): void { + const { state } = this[$internal]; + if (isBindGroup(first)) { + state.bindGroups.set(first.layout, first); + } else { + state.bindGroups.set(first as TgpuBindGroupLayout, bindGroup as TgpuBindGroup | GPUBindGroup); + } + state.version++; + } + + dispatchWorkgroups(x: number, y?: number, z?: number): void { + this.#emit((rawPass) => rawPass.dispatchWorkgroups(x, y, z)); + } + + dispatchWorkgroupsIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void { + this.#emit((rawPass) => rawPass.dispatchWorkgroupsIndirect(indirectBuffer, indirectOffset)); + } + + end(): void { + this[$internal].rawPass.end(); + } +} diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts new file mode 100644 index 0000000000..d609c8ba00 --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -0,0 +1,487 @@ +import type { Disarray } from '../../data/dataTypes.ts'; +import type { WgslArray } from '../../data/wgslTypes.ts'; +import { $internal } from '../../shared/symbols.ts'; +import { + isBindGroup, + type TgpuBindGroup, + type TgpuBindGroupLayout, + type TgpuLayoutEntry, +} from '../../tgpuBindGroupLayout.ts'; +import type { TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; +import { isTexture, isTextureView } from '../texture/texture.ts'; +import { emitRenderDraw, RenderDrawState } from '../pipeline/drawState.ts'; +import type { ColorAttachment, DepthStencilAttachment } from '../pipeline/renderPipeline.ts'; +import type { TgpuRenderPipeline } from '../pipeline/renderPipeline.ts'; +import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; +import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; +import type { TgpuCommandEncoder } from './commandEncoder.ts'; +import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; +import { + type TgpuPassTimestampWrites, + unwrapAttachmentView, + unwrapTimestampWrites, +} from './attachments.ts'; + +// ---------- +// Public API +// ---------- + +/** + * The TypeGPU equivalent of {@link GPURenderPassDescriptor}. Attachment views accept + * TypeGPU textures, texture views and canvas contexts (next to raw {@link GPUTextureView}s), + * and query sets accept {@link TgpuQuerySet}. + * + * Load/store operations default to `loadOp: 'clear'`, `storeOp: 'store'` + * (and `depthClearValue: 1` for depth attachments). For depth/stencil attachments, + * defaults are derived from the view's format and aspect. A raw {@link GPUTextureView} + * with no explicit operations is assumed to be depth-only; provide explicit + * operations for raw stencil or depth-stencil views. + */ +export interface TgpuRenderPassDescriptor { + label?: string | undefined; + colorAttachments?: ColorAttachment | readonly (ColorAttachment | null)[] | undefined; + depthStencilAttachment?: DepthStencilAttachment | undefined; + occlusionQuerySet?: TgpuQuerySet<'occlusion'> | GPUQuerySet | undefined; + timestampWrites?: TgpuPassTimestampWrites | undefined; + maxDrawCount?: number | undefined; +} + +export interface RenderPassInternals< + TRaw extends GPURenderPassEncoder | GPURenderBundleEncoder = + | GPURenderPassEncoder + | GPURenderBundleEncoder, +> { + readonly rawPass: TRaw; + readonly state: RenderDrawState; + /** + * The encoder this pass records into, when it is one we can defer work to. + * Undefined for bundle encoders and for raw pass encoders the caller owns. + */ + readonly owner: TgpuCommandEncoder | undefined; + lastApplied: { pipeline: TgpuRenderPipeline; version: number } | undefined; +} + +/** + * The draw-recording surface shared by render passes and render bundle encoders, + * mirroring {@link GPURenderCommandsMixin}. + * + * Draw either by binding TypeGPU pipelines to it (`pipeline.with(pass).draw(...)`), + * or proxy-style via `pass.setPipeline(pipeline)` followed by `pass.draw(...)`. + * Pipeline resolution is lazy - shaders compile on the first draw. + */ +export interface TgpuRenderCommands { + readonly [$internal]: RenderPassInternals; + readonly resourceType: 'render-pass' | 'render-bundle-pass'; + + /** + * Sets the current {@link TgpuRenderPipeline} for subsequent draw calls. + */ + setPipeline(pipeline: TgpuRenderPipeline): void; + + /** + * Associates a bind group (with the layout it was created from) for subsequent draw calls. + */ + setBindGroup(bindGroup: TgpuBindGroup): void; + /** + * Associates a bind group with the given layout for subsequent draw calls. + */ + setBindGroup>( + bindGroupLayout: TgpuBindGroupLayout, + bindGroup: TgpuBindGroup | GPUBindGroup, + ): void; + + /** + * Binds a vertex buffer to the given vertex layout for subsequent draw calls. + * @param offset - Offset in bytes into `buffer`. Defaults to `0`. + * @param size - Size in bytes to bind. Defaults to the remainder of the buffer. + */ + setVertexBuffer( + vertexLayout: TgpuVertexLayout, + buffer: (TgpuBuffer & VertexFlag) | GPUBuffer, + offset?: number, + size?: number, + ): void; + + /** + * Sets the current index buffer. + * @param offset - Offset in bytes into `buffer` where the index data begins. Defaults to `0`. + * @param size - Size in bytes of the index data in `buffer`. + * Defaults to the size of the buffer minus the offset. + */ + setIndexBuffer( + buffer: TgpuBuffer | GPUBuffer, + indexFormat: GPUIndexFormat, + offset?: number, + size?: number, + ): void; + + draw( + vertexCount: number, + instanceCount?: number, + firstVertex?: number, + firstInstance?: number, + ): void; + drawIndexed( + indexCount: number, + instanceCount?: number, + firstIndex?: number, + baseVertex?: number, + firstInstance?: number, + ): void; + drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; + drawIndexedIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; +} + +/** + * A render pass recording into a {@link TgpuCommandEncoder}. On top of the + * draw commands, it exposes the state that WebGPU scopes to a render pass. + * + * Call `end()` when done recording. + */ +export interface TgpuRenderPass extends TgpuRenderCommands { + readonly [$internal]: RenderPassInternals; + readonly resourceType: 'render-pass'; + + setViewport( + x: number, + y: number, + width: number, + height: number, + minDepth: number, + maxDepth: number, + ): void; + setScissorRect(x: number, y: number, width: number, height: number): void; + setBlendConstant(color: GPUColor): void; + setStencilReference(reference: GPUStencilValue): void; + beginOcclusionQuery(queryIndex: GPUSize32): void; + endOcclusionQuery(): void; + + /** + * Executes previously recorded {@link GPURenderBundle}s as part of this pass. + * As per the WebGPU spec, this resets the raw pass's pipeline, bind group + * and vertex/index buffer state. The state tracked by this typed pass is + * re-applied on the next draw. + */ + executeBundles(bundles: Iterable): void; + + /** + * Completes the recording of this render pass. + */ + end(): void; +} + +// -------------- +// Implementation +// -------------- + +function attachmentAspects( + view: DepthStencilAttachment['view'], +): { hasDepth: boolean; hasStencil: boolean } | undefined { + let format: GPUTextureFormat | undefined; + let aspect: GPUTextureAspect = 'all'; + + if (isTexture(view)) { + format = view.props.format; + } else if (isTextureView(view)) { + format = view[$internal].format; + aspect = view[$internal].aspect ?? 'all'; + } + + if (format === undefined) { + return undefined; + } + + return { + hasDepth: format.includes('depth') && aspect !== 'stencil-only', + hasStencil: format.includes('stencil') && aspect !== 'depth-only', + }; +} + +function withDepthStencilDefaults( + attachment: DepthStencilAttachment, + view: GPUTextureView, +): GPURenderPassDepthStencilAttachment { + const rawAttachment = { ...attachment, view } as GPURenderPassDepthStencilAttachment; + const aspects = attachmentAspects(attachment.view); + + if (aspects === undefined) { + const hasExplicitOps = + attachment.depthLoadOp !== undefined || + attachment.depthStoreOp !== undefined || + attachment.stencilLoadOp !== undefined || + attachment.stencilStoreOp !== undefined; + if (hasExplicitOps) { + return rawAttachment; + } + } + + const { hasDepth, hasStencil } = aspects ?? { hasDepth: true, hasStencil: false }; + + if (hasDepth && !attachment.depthReadOnly) { + rawAttachment.depthLoadOp ??= 'clear'; + rawAttachment.depthStoreOp ??= 'store'; + rawAttachment.depthClearValue ??= 1; + } + + if (hasStencil && !attachment.stencilReadOnly) { + rawAttachment.stencilLoadOp ??= 'clear'; + rawAttachment.stencilStoreOp ??= 'store'; + } + + return rawAttachment; +} + +export function INTERNAL_beginRenderPass( + encoder: TgpuCommandEncoder, + descriptor: TgpuRenderPassDescriptor, +): TgpuRenderPass { + const { rawEncoder, root } = encoder[$internal]; + const colorAttachments = + descriptor.colorAttachments === undefined + ? [] + : Array.isArray(descriptor.colorAttachments) + ? (descriptor.colorAttachments as readonly (ColorAttachment | null)[]) + : [descriptor.colorAttachments as ColorAttachment]; + + const rawDescriptor: GPURenderPassDescriptor = { + colorAttachments: colorAttachments.map((attachment) => { + if (attachment === null) { + return null; + } + + const rawAttachment = { + ...attachment, + loadOp: attachment.loadOp ?? 'clear', + storeOp: attachment.storeOp ?? 'store', + view: unwrapAttachmentView(root, attachment.view), + } as GPURenderPassColorAttachment; + + if (attachment.resolveTarget !== undefined) { + rawAttachment.resolveTarget = unwrapAttachmentView(root, attachment.resolveTarget); + } + + return rawAttachment; + }), + }; + + if (descriptor.label !== undefined) { + rawDescriptor.label = descriptor.label; + } + + if (descriptor.depthStencilAttachment !== undefined) { + rawDescriptor.depthStencilAttachment = withDepthStencilDefaults( + descriptor.depthStencilAttachment, + unwrapAttachmentView(root, descriptor.depthStencilAttachment.view), + ); + } + + if (descriptor.occlusionQuerySet !== undefined) { + rawDescriptor.occlusionQuerySet = isQuerySet(descriptor.occlusionQuerySet) + ? root.unwrap(descriptor.occlusionQuerySet) + : descriptor.occlusionQuerySet; + } + + if (descriptor.timestampWrites !== undefined) { + rawDescriptor.timestampWrites = unwrapTimestampWrites( + root, + descriptor.timestampWrites, + ) as GPURenderPassTimestampWrites; + } + + if (descriptor.maxDrawCount !== undefined) { + rawDescriptor.maxDrawCount = descriptor.maxDrawCount; + } + + return new TgpuRenderPassImpl(root, rawEncoder.beginRenderPass(rawDescriptor), encoder); +} + +export function INTERNAL_beginRenderBundlePass( + root: ExperimentalTgpuRoot, + bundleEncoder: GPURenderBundleEncoder, +): TgpuRenderCommands { + return new TgpuRenderCommandsImpl(root, bundleEncoder, undefined); +} + +const adoptedRenderCommands = new WeakMap< + GPURenderPassEncoder | GPURenderBundleEncoder, + TgpuRenderCommands +>(); + +/** + * Wraps a raw pass encoder the user owns, so that draws recorded into it take + * the same route as draws into a TypeGPU pass. The state is marked as + * raw-accessed, since the encoder can be mutated behind our back at any point. + */ +export function INTERNAL_adoptRenderCommands( + root: ExperimentalTgpuRoot, + rawPass: GPURenderPassEncoder | GPURenderBundleEncoder, +): TgpuRenderCommands { + let adopted = adoptedRenderCommands.get(rawPass); + + if (adopted === undefined) { + adopted = + 'executeBundles' in rawPass + ? new TgpuRenderPassImpl(root, rawPass, undefined) + : new TgpuRenderCommandsImpl(root, rawPass, undefined); + adopted[$internal].state.rawAccessed = true; + adoptedRenderCommands.set(rawPass, adopted); + } + + return adopted; +} + +class TgpuRenderCommandsImpl< + TRaw extends GPURenderPassEncoder | GPURenderBundleEncoder = + | GPURenderPassEncoder + | GPURenderBundleEncoder, +> implements TgpuRenderCommands { + readonly [$internal]: RenderPassInternals; + readonly resourceType: 'render-pass' | 'render-bundle-pass' = 'render-bundle-pass'; + readonly #root: ExperimentalTgpuRoot; + + constructor(root: ExperimentalTgpuRoot, rawPass: TRaw, owner: TgpuCommandEncoder | undefined) { + this.#root = root; + this[$internal] = { + rawPass, + state: new RenderDrawState(), + owner, + lastApplied: undefined, + }; + } + + #emit( + usesIndexBuffer: boolean, + emit: (rawPass: GPURenderPassEncoder | GPURenderBundleEncoder) => void, + ): void { + const internals = this[$internal]; + const pipeline = internals.state.currentPipeline; + + if (!pipeline) { + throw new Error('Cannot draw without a call to pass.setPipeline'); + } + + emitRenderDraw(this.#root, internals, pipeline, usesIndexBuffer, emit); + } + + setPipeline(pipeline: TgpuRenderPipeline): void { + const { state } = this[$internal]; + state.currentPipeline = pipeline; + state.version++; + } + + setBindGroup>( + first: TgpuBindGroup | TgpuBindGroupLayout, + bindGroup?: TgpuBindGroup | GPUBindGroup, + ): void { + const { state } = this[$internal]; + if (isBindGroup(first)) { + state.bindGroups.set(first.layout, first); + } else { + state.bindGroups.set(first as TgpuBindGroupLayout, bindGroup as TgpuBindGroup | GPUBindGroup); + } + state.version++; + } + + setVertexBuffer( + vertexLayout: TgpuVertexLayout, + buffer: (TgpuBuffer & VertexFlag) | GPUBuffer, + offset?: number, + size?: number, + ): void { + const { state } = this[$internal]; + state.vertexBuffers.set(vertexLayout, { buffer, offset, size }); + state.version++; + } + + setIndexBuffer( + buffer: TgpuBuffer | GPUBuffer, + indexFormat: GPUIndexFormat, + offset?: number, + size?: number, + ): void { + const { state } = this[$internal]; + state.indexBuffer = { buffer, indexFormat, offsetBytes: offset, sizeBytes: size }; + state.version++; + } + + draw( + vertexCount: number, + instanceCount?: number, + firstVertex?: number, + firstInstance?: number, + ): void { + this.#emit(false, (rawPass) => + rawPass.draw(vertexCount, instanceCount, firstVertex, firstInstance), + ); + } + + drawIndexed( + indexCount: number, + instanceCount?: number, + firstIndex?: number, + baseVertex?: number, + firstInstance?: number, + ): void { + this.#emit(true, (rawPass) => + rawPass.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance), + ); + } + + drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void { + this.#emit(false, (rawPass) => rawPass.drawIndirect(indirectBuffer, indirectOffset)); + } + + drawIndexedIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void { + this.#emit(true, (rawPass) => rawPass.drawIndexedIndirect(indirectBuffer, indirectOffset)); + } +} + +class TgpuRenderPassImpl + extends TgpuRenderCommandsImpl + implements TgpuRenderPass +{ + override readonly resourceType = 'render-pass'; + + setViewport( + x: number, + y: number, + width: number, + height: number, + minDepth: number, + maxDepth: number, + ): void { + this[$internal].rawPass.setViewport(x, y, width, height, minDepth, maxDepth); + } + + setScissorRect(x: number, y: number, width: number, height: number): void { + this[$internal].rawPass.setScissorRect(x, y, width, height); + } + + setBlendConstant(color: GPUColor): void { + this[$internal].rawPass.setBlendConstant(color); + } + + setStencilReference(reference: GPUStencilValue): void { + const { state } = this[$internal]; + state.stencilReference = reference; + state.version++; + } + + beginOcclusionQuery(queryIndex: GPUSize32): void { + this[$internal].rawPass.beginOcclusionQuery(queryIndex); + } + + endOcclusionQuery(): void { + this[$internal].rawPass.endOcclusionQuery(); + } + + executeBundles(bundles: Iterable): void { + const internals = this[$internal]; + internals.rawPass.executeBundles(bundles); + internals.lastApplied = undefined; + } + + end(): void { + this[$internal].rawPass.end(); + } +} diff --git a/packages/typegpu/src/core/pipeline/applyPipelineState.ts b/packages/typegpu/src/core/pipeline/applyPipelineState.ts index df2e708ddc..87ce844cb7 100644 --- a/packages/typegpu/src/core/pipeline/applyPipelineState.ts +++ b/packages/typegpu/src/core/pipeline/applyPipelineState.ts @@ -28,6 +28,26 @@ export interface VertexBufferEntry { export type VertexBufferResolver = (layout: TgpuVertexLayout) => VertexBufferEntry | undefined; +export interface IndexBufferEntry { + buffer: TgpuBuffer | GPUBuffer; + indexFormat: GPUIndexFormat; + offsetBytes?: number | undefined; + sizeBytes?: number | undefined; +} + +export function applyIndexBuffer( + encoder: GPURenderPassEncoder | GPURenderBundleEncoder, + root: ExperimentalTgpuRoot, + entry: IndexBufferEntry, +): void { + const { buffer, indexFormat, offsetBytes, sizeBytes } = entry; + if (isBuffer(buffer)) { + encoder.setIndexBuffer(root.unwrap(buffer), indexFormat, offsetBytes, sizeBytes); + } else { + encoder.setIndexBuffer(buffer, indexFormat, offsetBytes, sizeBytes); + } +} + export function applyBindGroups( encoder: GPURenderPassEncoder | GPURenderBundleEncoder | GPUComputePassEncoder, root: ExperimentalTgpuRoot, diff --git a/packages/typegpu/src/core/pipeline/computePipeline.ts b/packages/typegpu/src/core/pipeline/computePipeline.ts index fef3a07b8e..792929e3fe 100644 --- a/packages/typegpu/src/core/pipeline/computePipeline.ts +++ b/packages/typegpu/src/core/pipeline/computePipeline.ts @@ -3,7 +3,6 @@ import type { TgpuQuerySet } from '../querySet/querySet.ts'; import { type ResolvedSnippet, snip } from '../../data/snippet.ts'; import type { AnyWgslData } from '../../data/wgslTypes.ts'; import { Void } from '../../data/wgslTypes.ts'; -import { applyBindGroups } from './applyPipelineState.ts'; import { resolve } from '../../resolutionCtx.ts'; import type { TgpuNamable } from '../../shared/meta.ts'; import { getName, PERF, setName } from '../../shared/meta.ts'; @@ -15,8 +14,19 @@ import { type TgpuBindGroupLayout, type TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; -import { isGPUCommandEncoder, isGPUComputePassEncoder } from './typeGuards.ts'; -import { logDataFromGPU } from '../../tgsl/consoleLog/deserializers.ts'; +import { + INTERNAL_adoptCommandEncoder, + INTERNAL_createCommandEncoder, + type TgpuCommandEncoder, +} from '../commandEncoder/commandEncoder.ts'; +import { INTERNAL_adoptComputePass, type TgpuComputePass } from '../commandEncoder/computePass.ts'; +import { emitComputeDispatch, queueLogDrain, warnAboutUnreachableSubmission } from './drawState.ts'; +import { + isGPUCommandEncoder, + isGPUComputePassEncoder, + isTgpuCommandEncoder, + isTgpuComputePass, +} from './typeGuards.ts'; import type { LogResources } from '../../tgsl/consoleLog/types.ts'; import { isGPUBuffer, type ResolutionCtx, type SelfResolvable } from '../../types.ts'; import { wgslEnableExtensions, wgslEnableExtensionToFeatureName } from '../../wgslExtensions.ts'; @@ -31,10 +41,9 @@ import { resolveIndirectOffset } from './pipelineUtils.ts'; import { createWithPerformanceCallback, createWithTimestampWrites, - setupTimestampWrites, + queueTimestampResolve, type Timeable, type TimestampWritesPriors, - triggerPerformanceCallback, } from './timeable.ts'; import type { IndirectFlag, TgpuBuffer } from '../buffer/buffer.ts'; import { @@ -44,7 +53,8 @@ import { } from './performanceTracker.ts'; import { logger } from '../../tgpuLogger.ts'; -interface ComputePipelineInternals { +export interface ComputePipelineInternals { + readonly core: ComputePipelineCore; readonly rawPipeline: GPUComputePipeline; readonly priors: TgpuComputePipelinePriors & TimestampWritesPriors; readonly root: ExperimentalTgpuRoot; @@ -68,6 +78,16 @@ export interface TgpuComputePipeline extends TgpuNamable, SelfResolvable, Timeab ): this; with(bindGroupLayout: TgpuBindGroupLayout, bindGroup: GPUBindGroup): this; with(bindGroup: TgpuBindGroup): this; + /** + * Directs subsequent dispatches into the given compute pass, letting multiple + * pipelines share one pass (and one submission). + */ + with(pass: TgpuComputePass): this; + /** + * Directs subsequent dispatches into the given command encoder. Each dispatch + * records its own compute pass; the caller owns the submission. + */ + with(encoder: TgpuCommandEncoder): this; with(encoder: GPUCommandEncoder): this; with(pass: GPUComputePassEncoder): this; @@ -120,8 +140,10 @@ export function INTERNAL_createComputePipeline( type TgpuComputePipelinePriors = { readonly bindGroupLayoutMap?: Map; - readonly externalEncoder?: GPUCommandEncoder | undefined; - readonly externalPass?: GPUComputePassEncoder | undefined; + /** A pass the pipeline dispatches into, but does not own. */ + readonly pass?: TgpuComputePass | undefined; + /** An encoder the pipeline records its own passes into, but does not submit. */ + readonly encoder?: TgpuCommandEncoder | undefined; } & TimestampWritesPriors; type Memo = { @@ -131,8 +153,6 @@ type Memo = { logResources: LogResources | undefined; }; -const _lastAppliedCompute = new WeakMap(); - class TgpuComputePipelineImpl implements TgpuComputePipeline { public readonly [$internal]: ComputePipelineInternals; public readonly resourceType = 'compute-pipeline'; @@ -146,6 +166,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { this.#priors = priors; this[$internal] = { + core, get rawPipeline() { return core.unwrap().pipeline; }, @@ -171,32 +192,49 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { return this.#core.unwrap().pipeline; } + /** Rebinds the target this pipeline records into. The two are mutually exclusive. */ + #withTarget(target: { pass?: TgpuComputePass; encoder?: TgpuCommandEncoder }): this { + return new TgpuComputePipelineImpl(this.#core, { + ...this.#priors, + pass: target.pass, + encoder: target.encoder, + }) as this; + } + with>( bindGroupLayout: TgpuBindGroupLayout, bindGroup: TgpuBindGroup, ): this; with(bindGroupLayout: TgpuBindGroupLayout, bindGroup: GPUBindGroup): this; with(bindGroup: TgpuBindGroup): this; + with(pass: TgpuComputePass): this; + with(encoder: TgpuCommandEncoder): this; with(encoder: GPUCommandEncoder): this; with(pass: GPUComputePassEncoder): this; with( - first: TgpuBindGroupLayout | TgpuBindGroup | GPUCommandEncoder | GPUComputePassEncoder, + first: + | TgpuBindGroupLayout + | TgpuBindGroup + | TgpuComputePass + | TgpuCommandEncoder + | GPUCommandEncoder + | GPUComputePassEncoder, bindGroup?: TgpuBindGroup | GPUBindGroup, ): this { + if (isTgpuComputePass(first)) { + return this.#withTarget({ pass: first }); + } + + if (isTgpuCommandEncoder(first)) { + return this.#withTarget({ encoder: first }); + } + if (isGPUComputePassEncoder(first)) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - externalPass: first, - externalEncoder: undefined, - }) as this; + return this.#withTarget({ pass: INTERNAL_adoptComputePass(this.#core.root, first) }); } if (isGPUCommandEncoder(first)) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - externalEncoder: first, - externalPass: undefined, - }) as this; + return this.#withTarget({ encoder: INTERNAL_adoptCommandEncoder(this.#core.root, first) }); } if (isBindGroup(first)) { @@ -248,7 +286,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { } dispatchWorkgroups(x: number, y?: number, z?: number): void { - this._executeComputePass((pass) => pass.dispatchWorkgroups(x, y, z)); + this.#execute((pass) => pass.dispatchWorkgroups(x, y, z)); } dispatchWorkgroupsIndirect( @@ -265,7 +303,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { 'dispatchWorkgroupsIndirect', ); - this._executeComputePass((pass) => pass.dispatchWorkgroupsIndirect(rawBuffer, offset)); + this.#execute((pass) => pass.dispatchWorkgroupsIndirect(rawBuffer, offset)); } initAsync(): Promise { @@ -276,63 +314,39 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { this.#core.initSync(); } - private _applyComputeState(pass: GPUComputePassEncoder): void { - const memo = this.#core.unwrap(); - const { root } = this.#core; - pass.setPipeline(memo.pipeline); - - applyBindGroups(pass, root, memo.usedBindGroupLayouts, memo.catchall, (layout) => - this.#priors.bindGroupLayoutMap?.get(layout), - ); - } - - private _executeComputePass(dispatch: (pass: GPUComputePassEncoder) => void): void { + /** + * The single route from a dispatch call to the GPU. Either the pipeline was + * given a pass to dispatch into, or it begins one of its own - and if it was + * not given an encoder either, it owns the submission too. + */ + #execute(dispatch: (pass: GPUComputePassEncoder) => void): void { const { root } = this.#core; + const priors = this.#priors; - if (this.#priors.externalPass) { - if (_lastAppliedCompute.get(this.#priors.externalPass) !== this) { - this._applyComputeState(this.#priors.externalPass); - _lastAppliedCompute.set(this.#priors.externalPass, this); - } - dispatch(this.#priors.externalPass); + if (priors.pass) { + emitComputeDispatch(root, priors.pass[$internal], this, dispatch); return; } - if (this.#priors.externalEncoder) { - const passDescriptor: GPUComputePassDescriptor = { - label: getName(this.#core) ?? '', - ...setupTimestampWrites(this.#priors, root), - }; - const pass = this.#priors.externalEncoder.beginComputePass(passDescriptor); - this._applyComputeState(pass); - dispatch(pass); - pass.end(); - return; - } - - const memo = this.#core.unwrap(); - - const passDescriptor: GPUComputePassDescriptor = { + const encoder = priors.encoder ?? INTERNAL_createCommandEncoder(root); + const pass = encoder.beginComputePass({ label: getName(this.#core) ?? '', - ...setupTimestampWrites(this.#priors, root), - }; - - const commandEncoder = root.device.createCommandEncoder(); - const pass = commandEncoder.beginComputePass(passDescriptor); - this._applyComputeState(pass); - dispatch(pass); + timestampWrites: priors.timestampWrites, + }); + emitComputeDispatch(root, pass[$internal], this, dispatch, /* ownsPass */ true); pass.end(); - root.device.queue.submit([commandEncoder.finish()]); - if (memo.logResources) { - logDataFromGPU(memo.logResources); + const { logResources } = this.#core.unwrap(); + if (logResources && !queueLogDrain(encoder, logResources)) { + warnAboutUnreachableSubmission(this.#core, 'Shader console.log output'); + } + + if (priors.performanceCallback && !queueTimestampResolve(encoder, priors)) { + warnAboutUnreachableSubmission(this.#core, 'The performance callback'); } - if (this.#priors.performanceCallback) { - void triggerPerformanceCallback({ - root, - priors: this.#priors, - }); + if (priors.encoder === undefined) { + encoder.submit(); } } diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts new file mode 100644 index 0000000000..b275009c47 --- /dev/null +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -0,0 +1,282 @@ +import { $internal } from '../../shared/symbols.ts'; +import type { TgpuBindGroup, TgpuBindGroupLayout } from '../../tgpuBindGroupLayout.ts'; +import { logDataFromGPU } from '../../tgsl/consoleLog/deserializers.ts'; +import type { LogResources } from '../../tgsl/consoleLog/types.ts'; +import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; +import type { ComputePassInternals } from '../commandEncoder/computePass.ts'; +import type { RenderPassInternals } from '../commandEncoder/renderPass.ts'; +import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; +import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; +import { + applyBindGroups, + applyIndexBuffer, + applyVertexBuffers, + type IndexBufferEntry, + type VertexBufferEntry, +} from './applyPipelineState.ts'; +import type { TgpuComputePipeline } from './computePipeline.ts'; +import type { TgpuRenderPipeline } from './renderPipeline.ts'; + +export class RenderDrawState { + readonly bindGroups = new Map(); + readonly vertexBuffers = new Map(); + currentPipeline: TgpuRenderPipeline | undefined; + indexBuffer: IndexBufferEntry | undefined; + stencilReference: GPUStencilValue | undefined; + /** + * The stencil reference currently set on the raw pass — 0 is the WebGPU + * default, and render bundles cannot change it, so it survives executeBundles. + */ + appliedStencilReference: GPUStencilValue = 0; + version = 0; + /** + * Set once the raw pass encoder has been handed out via `root.unwrap(pass)`. + * Raw calls can mutate pass state invisibly, so state deduplication is + * disabled from that point on. + */ + rawAccessed = false; +} + +export class ComputeDrawState { + readonly bindGroups = new Map(); + currentPipeline: TgpuComputePipeline | undefined; + version = 0; + rawAccessed = false; +} + +export function applyRenderPipelineState( + encoder: GPURenderPassEncoder | GPURenderBundleEncoder, + root: ExperimentalTgpuRoot, + pipeline: TgpuRenderPipeline, + passState: RenderDrawState, +): void { + const { core, priors } = pipeline[$internal]; + const memo = core.unwrap(); + encoder.setPipeline(memo.pipeline); + + applyBindGroups( + encoder, + root, + memo.usedBindGroupLayouts, + memo.catchall, + (layout) => priors.bindGroupLayoutMap?.get(layout) ?? passState.bindGroups.get(layout), + ); + + applyVertexBuffers(encoder, root, memo.usedVertexLayouts, (vertexLayout) => { + const priorBuffer = priors.vertexLayoutMap?.get(vertexLayout); + return priorBuffer + ? { buffer: priorBuffer, offset: undefined, size: undefined } + : passState.vertexBuffers.get(vertexLayout); + }); + + const indexBuffer = priors.indexBuffer ?? passState.indexBuffer; + if (indexBuffer !== undefined) { + applyIndexBuffer(encoder, root, indexBuffer); + } + + if ('setStencilReference' in encoder) { + const stencilReference = priors.stencilReference ?? passState.stencilReference ?? 0; + if (passState.rawAccessed || stencilReference !== passState.appliedStencilReference) { + encoder.setStencilReference(stencilReference); + passState.appliedStencilReference = stencilReference; + } + } +} + +export function applyComputePipelineState( + encoder: GPUComputePassEncoder, + root: ExperimentalTgpuRoot, + pipeline: TgpuComputePipeline, + passState: ComputeDrawState, +): void { + const { core, priors } = pipeline[$internal]; + const memo = core.unwrap(); + encoder.setPipeline(memo.pipeline); + + applyBindGroups( + encoder, + root, + memo.usedBindGroupLayouts, + memo.catchall, + (layout) => priors.bindGroupLayoutMap?.get(layout) ?? passState.bindGroups.get(layout), + ); +} + +/** + * Guards an indexed draw, given the index buffer the pipeline was configured + * with and the one set on the pass it draws into (if any). + */ +export function requireIndexBuffer( + priorIndexBuffer: IndexBufferEntry | undefined, + passIndexBuffer: IndexBufferEntry | undefined, +): void { + if (!priorIndexBuffer && !passIndexBuffer) { + throw new Error( + 'No index buffer is set. Call pipeline.withIndexBuffer or pass.setIndexBuffer before drawing indexed geometry.', + ); + } +} + +const _warnedIgnoredTimestamps = new WeakSet(); +const _warnedIgnoredLogs = new WeakSet(); + +const _warnedUnreachableSubmission = new WeakMap>(); + +/** + * Warns that work which can only be reported after submission is lost, because + * the raw encoder belongs to the caller and is submitted behind our back. + */ +export function warnAboutUnreachableSubmission(core: object, what: string): void { + let warned = _warnedUnreachableSubmission.get(core); + + if (!warned) { + warned = new Set(); + _warnedUnreachableSubmission.set(core, warned); + } + + if (warned.has(what)) { + return; + } + warned.add(what); + + console.warn( + `${what} is ignored when recording into a raw GPUCommandEncoder, since there is no submission to report after. Use root['~unstable'].createCommandEncoder() instead.`, + ); +} + +/** + * Queues a drain of the shader's log buffers for after the encoder is + * submitted. Returns false when there is no encoder to defer the read to, + * meaning the output is lost. + */ +export function queueLogDrain( + encoder: TgpuCommandEncoder | undefined, + logResources: LogResources, +): boolean { + if (!encoder || encoder[$internal].adopted) { + return false; + } + + encoder[$internal].afterSubmit.set(logResources, () => logDataFromGPU(logResources)); + return true; +} + +/** + * Reports the pass-level priors that a pipeline cannot honor, because the pass + * it draws into was begun by someone else. Shader logs are the exception: they + * are read back after submission, so they can still be drained as long as the + * pass belongs to a TypeGPU encoder. + */ +function reportIgnoredPriors( + core: object, + owner: TgpuCommandEncoder | undefined, + hasTimestampWrites: boolean, + logResources: LogResources | undefined, + passKind: 'render' | 'compute', +): void { + if (hasTimestampWrites && !_warnedIgnoredTimestamps.has(core)) { + _warnedIgnoredTimestamps.add(core); + console.warn( + `Pipeline-level timestamp writes are ignored when ${ + passKind === 'render' ? 'drawing into a render pass' : 'dispatching into a compute pass' + }. Pass \`timestampWrites\` to encoder.begin${ + passKind === 'render' ? 'Render' : 'Compute' + }Pass instead.`, + ); + } + + if (logResources && !queueLogDrain(owner, logResources) && !_warnedIgnoredLogs.has(core)) { + _warnedIgnoredLogs.add(core); + console.warn( + `Shader console.log output is ignored when ${ + passKind === 'render' + ? 'drawing into a raw render pass' + : 'dispatching into a raw compute pass' + } encoder, since there is no submission to read it back after.`, + ); + } +} + +/** + * Records a draw into a typed render pass, applying the pipeline's state + * (and the pass's, where the pipeline does not override it) beforehand. + * The single route every draw takes, whether the pass is the pipeline's own, + * one it was handed (`pipeline.with(pass).draw()`), or one driving it + * (`pass.setPipeline(pipeline)` followed by `pass.draw()`). + * + * @param ownsPass - Whether the pipeline began this pass itself, and so honors + * its own pass-level priors instead of dropping them. + */ +export function emitRenderDraw( + root: ExperimentalTgpuRoot, + passInternals: RenderPassInternals, + pipeline: TgpuRenderPipeline, + usesIndexBuffer: boolean, + emit: (rawPass: GPURenderPassEncoder | GPURenderBundleEncoder) => void, + ownsPass = false, +): void { + const { state, rawPass } = passInternals; + const { core, priors } = pipeline[$internal]; + + if (usesIndexBuffer) { + requireIndexBuffer(priors.indexBuffer, state.indexBuffer); + } + + const memo = core.unwrap(); + if (!ownsPass) { + reportIgnoredPriors( + core, + passInternals.owner, + !!priors.timestampWrites, + memo.logResources, + 'render', + ); + } + + if ( + state.rawAccessed || + passInternals.lastApplied?.pipeline !== pipeline || + passInternals.lastApplied.version !== state.version + ) { + applyRenderPipelineState(rawPass, root, pipeline, state); + passInternals.lastApplied = { pipeline, version: state.version }; + } + + emit(rawPass); +} + +/** + * The compute counterpart of {@link emitRenderDraw}. + */ +export function emitComputeDispatch( + root: ExperimentalTgpuRoot, + passInternals: ComputePassInternals, + pipeline: TgpuComputePipeline, + emit: (rawPass: GPUComputePassEncoder) => void, + ownsPass = false, +): void { + const { state, rawPass } = passInternals; + const { core, priors } = pipeline[$internal]; + + const memo = core.unwrap(); + if (!ownsPass) { + reportIgnoredPriors( + core, + passInternals.owner, + !!priors.timestampWrites, + memo.logResources, + 'compute', + ); + } + + if ( + state.rawAccessed || + passInternals.lastApplied?.pipeline !== pipeline || + passInternals.lastApplied.version !== state.version + ) { + applyComputePipelineState(rawPass, root, pipeline, state); + passInternals.lastApplied = { pipeline, version: state.version }; + } + + emit(rawPass); +} diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index 194ff7b6a9..0a257c0108 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -34,7 +34,6 @@ import { type TgpuBindGroupLayout, type TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; -import { logDataFromGPU } from '../../tgsl/consoleLog/deserializers.ts'; import type { LogResources } from '../../tgsl/consoleLog/types.ts'; import type { ResolutionCtx, SelfResolvable } from '../../types.ts'; import { isGPUBuffer } from '../../types.ts'; @@ -54,8 +53,6 @@ import { namespace } from '../resolve/namespace.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import type { TgpuSlot } from '../slot/slotTypes.ts'; import { - isTexture, - isTextureView, type TextureInternals, // oxlint-disable-next-line no-unused-vars -- used in docs type TgpuTexture, @@ -67,19 +64,35 @@ import { connectAttributesToShader } from '../vertexLayout/connectAttributesToSh import { isVertexLayout, type TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; import { connectAttachmentToShader } from './connectAttachmentToShader.ts'; import { connectTargetsToShader } from './connectTargetsToShader.ts'; -import { applyBindGroups, applyVertexBuffers } from './applyPipelineState.ts'; +import { + INTERNAL_adoptCommandEncoder, + INTERNAL_createCommandEncoder, + type TgpuCommandEncoder, +} from '../commandEncoder/commandEncoder.ts'; +import { + INTERNAL_adoptRenderCommands, + type TgpuRenderCommands, + type TgpuRenderPassDescriptor, +} from '../commandEncoder/renderPass.ts'; +import { + emitRenderDraw, + queueLogDrain, + requireIndexBuffer, + warnAboutUnreachableSubmission, +} from './drawState.ts'; import { isGPUCommandEncoder, isGPURenderBundleEncoder, isGPURenderPassEncoder, + isTgpuCommandEncoder, + isTgpuRenderCommands, } from './typeGuards.ts'; import { createWithPerformanceCallback, createWithTimestampWrites, - setupTimestampWrites, + queueTimestampResolve, type Timeable, type TimestampWritesPriors, - triggerPerformanceCallback, } from './timeable.ts'; import { type PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; import { resolveIndirectOffset } from './pipelineUtils.ts'; @@ -93,7 +106,7 @@ import { logger } from '../../tgpuLogger.ts'; const DRAW_INDIRECT_SIZE = 16; // 4 x 4 const DRAW_INDEXED_INDIRECT_SIZE = 20; // 5 x 4 -interface RenderPipelineInternals { +export interface RenderPipelineInternals { readonly core: RenderPipelineCore; readonly priors: TgpuRenderPipelinePriors & TimestampWritesPriors; readonly root: ExperimentalTgpuRoot; @@ -155,6 +168,16 @@ export interface TgpuRenderPipeline ): this; with(bindGroupLayout: TgpuBindGroupLayout, bindGroup: GPUBindGroup): this; with(bindGroup: TgpuBindGroup): this; + /** + * Directs subsequent draw calls into the given render pass or render bundle + * encoder, letting multiple pipelines share one pass (and one submission). + */ + with(pass: TgpuRenderCommands): this; + /** + * Directs subsequent draw calls into the given command encoder. Each draw + * records its own render pass; the caller owns the submission. + */ + with(encoder: TgpuCommandEncoder): this; with(encoder: GPUCommandEncoder): this; with(pass: GPURenderPassEncoder): this; with(bundleEncoder: GPURenderBundleEncoder): this; @@ -348,7 +371,7 @@ export interface ColorAttachment { * They are converted to a texel value of texture format matching the render attachment. * If conversion fails, a validation error is generated. */ - clearValue?: GPUColor; + clearValue?: readonly [number, number, number, number] | GPUColor; /** * Indicates the load operation to perform on {@link GPURenderPassColorAttachment#view} prior to * executing the render pass. @@ -470,8 +493,10 @@ type TgpuRenderPipelinePriors = { sizeBytes?: number | undefined; } | undefined; - readonly externalEncoder?: GPUCommandEncoder | undefined; - readonly externalRenderEncoder?: GPURenderPassEncoder | GPURenderBundleEncoder | undefined; + /** A pass the pipeline draws into, but does not own. */ + readonly pass?: TgpuRenderCommands | undefined; + /** An encoder the pipeline records its own passes into, but does not submit. */ + readonly encoder?: TgpuCommandEncoder | undefined; } & TimestampWritesPriors; type Memo = { @@ -483,10 +508,6 @@ type Memo = { fragmentOut: BaseData; }; -const _lastAppliedRender = new WeakMap< - GPURenderPassEncoder | GPURenderBundleEncoder, - TgpuRenderPipelineImpl ->(); class TgpuRenderPipelineImpl implements TgpuRenderPipeline { public readonly [$internal]: RenderPipelineInternals; public readonly resourceType = 'render-pipeline'; @@ -515,6 +536,17 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { return this; } + /** Rebinds the target this pipeline records into. The two are mutually exclusive. */ + #withTarget(target: { pass?: TgpuRenderCommands; encoder?: TgpuCommandEncoder }): this { + const internals = this[$internal]; + + return new TgpuRenderPipelineImpl(internals.core, { + ...internals.priors, + pass: target.pass, + encoder: target.encoder, + }) as this; + } + with( vertexLayout: TgpuVertexLayout, buffer: TgpuBuffer & VertexFlag, @@ -526,6 +558,8 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { vertexLayout: TgpuVertexLayout, buffer: GPUBuffer, ): this; + with(pass: TgpuRenderCommands): this; + with(encoder: TgpuCommandEncoder): this; with(encoder: GPUCommandEncoder): this; with(pass: GPURenderPassEncoder): this; with(bundleEncoder: GPURenderBundleEncoder): this; @@ -534,6 +568,8 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { | TgpuVertexLayout | TgpuBindGroupLayout | TgpuBindGroup + | TgpuRenderCommands + | TgpuCommandEncoder | GPUCommandEncoder | GPURenderPassEncoder | GPURenderBundleEncoder, @@ -541,20 +577,24 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { ): this { const internals = this[$internal]; + if (isTgpuRenderCommands(first)) { + return this.#withTarget({ pass: first }); + } + + if (isTgpuCommandEncoder(first)) { + return this.#withTarget({ encoder: first }); + } + if (isGPURenderPassEncoder(first) || isGPURenderBundleEncoder(first)) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - externalRenderEncoder: first, - externalEncoder: undefined, - }) as this; + return this.#withTarget({ + pass: INTERNAL_adoptRenderCommands(internals.core.options.root, first), + }); } if (isGPUCommandEncoder(first)) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - externalEncoder: first, - externalRenderEncoder: undefined, - }) as this; + return this.#withTarget({ + encoder: INTERNAL_adoptCommandEncoder(internals.core.options.root, first), + }); } if (isBindGroup(first)) { @@ -720,99 +760,66 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { this[$internal].core.initSync(); } - private _createRenderPass(encoder: GPUCommandEncoder): GPURenderPassEncoder { + /** The descriptor of the pass this pipeline begins when it is not given one. */ + #ownPassDescriptor(): TgpuRenderPassDescriptor { const internals = this[$internal]; - const { root, descriptor } = internals.core.options; - - const memo = internals.core.unwrap(); - const colorAttachments = descriptor.fragment - ? (connectAttachmentToShader( - (descriptor.fragment as TgpuFragmentFn)?.shell?.returnType ?? memo.fragmentOut, - internals.priors.colorAttachment ?? {}, - ).map((_attachment) => { - const attachment = { - loadOp: 'clear', - storeOp: 'store', - ..._attachment, - }; - - if (isTexture(attachment.view)) { - attachment.view = root.unwrap(attachment.view).createView(); - } else if (isTextureView(attachment.view)) { - attachment.view = root.unwrap(attachment.view); - } else if (isGPUCanvasContext(attachment.view)) { - attachment.view = attachment.view.getCurrentTexture().createView(); - } + const { descriptor } = internals.core.options; + const { priors } = internals; - if (isTexture(attachment.resolveTarget)) { - attachment.resolveTarget = root.unwrap(attachment.resolveTarget).createView(); - } else if (isTextureView(attachment.resolveTarget)) { - attachment.resolveTarget = root.unwrap(attachment.resolveTarget); - } else if (isGPUCanvasContext(attachment.resolveTarget)) { - attachment.resolveTarget = attachment.resolveTarget.getCurrentTexture().createView(); - } - - return attachment; - }) as GPURenderPassColorAttachment[]) - : []; - - const renderPassDescriptor: GPURenderPassDescriptor = { + return { label: getName(internals.core) ?? '', - colorAttachments, - ...setupTimestampWrites(internals.priors, root), + colorAttachments: descriptor.fragment + ? connectAttachmentToShader( + (descriptor.fragment as TgpuFragmentFn)?.shell?.returnType ?? + internals.core.unwrap().fragmentOut, + priors.colorAttachment ?? {}, + ) + : [], + depthStencilAttachment: priors.depthStencilAttachment, + timestampWrites: priors.timestampWrites, }; - - const depthStencil = internals.priors.depthStencilAttachment; - if (depthStencil !== undefined) { - const view = isTexture(depthStencil.view) - ? root.unwrap(depthStencil.view).createView() - : isTextureView(depthStencil.view) - ? root.unwrap(depthStencil.view) - : depthStencil.view; - - renderPassDescriptor.depthStencilAttachment = { - ...depthStencil, - view, - } as GPURenderPassDepthStencilAttachment; - } - - return encoder.beginRenderPass(renderPassDescriptor); } - private _applyRenderState(encoder: GPURenderPassEncoder | GPURenderBundleEncoder): void { + /** + * The single route from a draw call to the GPU. Either the pipeline was + * given a pass to draw into, or it begins one of its own - and if it was not + * given an encoder either, it owns the submission too. + */ + #execute( + usesIndexBuffer: boolean, + emit: (rawPass: GPURenderPassEncoder | GPURenderBundleEncoder) => void, + ): void { const internals = this[$internal]; - const memo = internals.core.unwrap(); + const { priors } = internals; const { root } = internals.core.options; - encoder.setPipeline(memo.pipeline); - - applyBindGroups(encoder, root, memo.usedBindGroupLayouts, memo.catchall, (layout) => - internals.priors.bindGroupLayoutMap?.get(layout), - ); - applyVertexBuffers(encoder, root, memo.usedVertexLayouts, (layout) => { - const buffer = internals.priors.vertexLayoutMap?.get(layout); - return buffer ? { buffer } : undefined; - }); + if (priors.pass) { + emitRenderDraw(root, priors.pass[$internal], this, usesIndexBuffer, emit); + return; + } - if (internals.priors.stencilReference !== undefined && 'setStencilReference' in encoder) { - encoder.setStencilReference(internals.priors.stencilReference); + // Checked up front, so that a rejected draw never leaves a half-recorded + // pass behind on an encoder the caller owns. + if (usesIndexBuffer) { + requireIndexBuffer(priors.indexBuffer, undefined); } - } - private _setIndexBuffer(encoder: GPURenderPassEncoder | GPURenderBundleEncoder): void { - const internals = this[$internal]; - const { root } = internals.core.options; + const encoder = priors.encoder ?? INTERNAL_createCommandEncoder(root); + const pass = encoder.beginRenderPass(this.#ownPassDescriptor()); + emitRenderDraw(root, pass[$internal], this, usesIndexBuffer, emit, /* ownsPass */ true); + pass.end(); - if (!internals.priors.indexBuffer) { - throw new Error('No index buffer set for this render pipeline.'); + const { logResources } = internals.core.unwrap(); + if (logResources && !queueLogDrain(encoder, logResources)) { + warnAboutUnreachableSubmission(internals.core, 'Shader console.log output'); } - const { buffer, indexFormat, offsetBytes, sizeBytes } = internals.priors.indexBuffer; + if (priors.performanceCallback && !queueTimestampResolve(encoder, priors)) { + warnAboutUnreachableSubmission(internals.core, 'The performance callback'); + } - if (isGPUBuffer(buffer)) { - encoder.setIndexBuffer(buffer, indexFormat, offsetBytes, sizeBytes); - } else { - encoder.setIndexBuffer(root.unwrap(buffer), indexFormat, offsetBytes, sizeBytes); + if (priors.encoder === undefined) { + encoder.submit(); } } @@ -822,47 +829,9 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { firstVertex?: number, firstInstance?: number, ): void { - const internals = this[$internal]; - const { root } = internals.core.options; - - if (internals.priors.externalRenderEncoder) { - if (_lastAppliedRender.get(internals.priors.externalRenderEncoder) !== this) { - this._applyRenderState(internals.priors.externalRenderEncoder); - _lastAppliedRender.set(internals.priors.externalRenderEncoder, this); - } - internals.priors.externalRenderEncoder.draw( - vertexCount, - instanceCount, - firstVertex, - firstInstance, - ); - return; - } - - if (internals.priors.externalEncoder) { - const pass = this._createRenderPass(internals.priors.externalEncoder); - this._applyRenderState(pass); - pass.draw(vertexCount, instanceCount, firstVertex, firstInstance); - pass.end(); - return; - } - - const { logResources } = internals.core.unwrap(); - - const commandEncoder = root.device.createCommandEncoder(); - const pass = this._createRenderPass(commandEncoder); - this._applyRenderState(pass); - pass.draw(vertexCount, instanceCount, firstVertex, firstInstance); - pass.end(); - root.device.queue.submit([commandEncoder.finish()]); - - if (logResources) { - logDataFromGPU(logResources); - } - - if (internals.priors.performanceCallback) { - void triggerPerformanceCallback({ root, priors: internals.priors }); - } + this.#execute(false, (rawPass) => + rawPass.draw(vertexCount, instanceCount, firstVertex, firstInstance), + ); } drawIndexed( @@ -872,59 +841,15 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { baseVertex?: number, firstInstance?: number, ): void { - const internals = this[$internal]; - const { root } = internals.core.options; - - if (internals.priors.externalRenderEncoder) { - if (_lastAppliedRender.get(internals.priors.externalRenderEncoder) !== this) { - this._applyRenderState(internals.priors.externalRenderEncoder); - this._setIndexBuffer(internals.priors.externalRenderEncoder); - _lastAppliedRender.set(internals.priors.externalRenderEncoder, this); - } - internals.priors.externalRenderEncoder.drawIndexed( - indexCount, - instanceCount, - firstIndex, - baseVertex, - firstInstance, - ); - return; - } - - if (internals.priors.externalEncoder) { - const pass = this._createRenderPass(internals.priors.externalEncoder); - this._applyRenderState(pass); - this._setIndexBuffer(pass); - pass.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance); - pass.end(); - return; - } - - const { logResources } = internals.core.unwrap(); - - const commandEncoder = root.device.createCommandEncoder(); - const pass = this._createRenderPass(commandEncoder); - this._applyRenderState(pass); - this._setIndexBuffer(pass); - pass.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance); - pass.end(); - root.device.queue.submit([commandEncoder.finish()]); - - if (logResources) { - logDataFromGPU(logResources); - } - - if (internals.priors.performanceCallback) { - void triggerPerformanceCallback({ root, priors: internals.priors }); - } + this.#execute(true, (rawPass) => + rawPass.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance), + ); } drawIndirect( indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, indirectOffset?: PrimitiveOffsetInfo | number, ): void { - const internals = this[$internal]; - const { root } = internals.core.options; const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; const offset = resolveIndirectOffset( indirectBuffer, @@ -933,48 +858,14 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { 'drawIndirect', ); - if (internals.priors.externalRenderEncoder) { - if (_lastAppliedRender.get(internals.priors.externalRenderEncoder) !== this) { - this._applyRenderState(internals.priors.externalRenderEncoder); - _lastAppliedRender.set(internals.priors.externalRenderEncoder, this); - } - internals.priors.externalRenderEncoder.drawIndirect(rawBuffer, offset); - return; - } - - if (internals.priors.externalEncoder) { - const pass = this._createRenderPass(internals.priors.externalEncoder); - this._applyRenderState(pass); - pass.drawIndirect(rawBuffer, offset); - pass.end(); - return; - } - - const { logResources } = internals.core.unwrap(); - - const commandEncoder = root.device.createCommandEncoder(); - const pass = this._createRenderPass(commandEncoder); - this._applyRenderState(pass); - pass.drawIndirect(rawBuffer, offset); - pass.end(); - root.device.queue.submit([commandEncoder.finish()]); - - if (logResources) { - logDataFromGPU(logResources); - } - - if (internals.priors.performanceCallback) { - void triggerPerformanceCallback({ root, priors: internals.priors }); - } + this.#execute(false, (rawPass) => rawPass.drawIndirect(rawBuffer, offset)); } drawIndexedIndirect( indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, indirectOffset?: PrimitiveOffsetInfo | number, ): void { - const internals = this[$internal]; - const { root } = internals.core.options; - const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : root.unwrap(indirectBuffer); + const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; const offset = resolveIndirectOffset( indirectBuffer, indirectOffset, @@ -982,42 +873,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { 'drawIndexedIndirect', ); - if (internals.priors.externalRenderEncoder) { - if (_lastAppliedRender.get(internals.priors.externalRenderEncoder) !== this) { - this._applyRenderState(internals.priors.externalRenderEncoder); - this._setIndexBuffer(internals.priors.externalRenderEncoder); - _lastAppliedRender.set(internals.priors.externalRenderEncoder, this); - } - internals.priors.externalRenderEncoder.drawIndexedIndirect(rawBuffer, offset); - return; - } - - if (internals.priors.externalEncoder) { - const pass = this._createRenderPass(internals.priors.externalEncoder); - this._applyRenderState(pass); - this._setIndexBuffer(pass); - pass.drawIndexedIndirect(rawBuffer, offset); - pass.end(); - return; - } - - const { logResources } = internals.core.unwrap(); - - const commandEncoder = root.device.createCommandEncoder(); - const pass = this._createRenderPass(commandEncoder); - this._applyRenderState(pass); - this._setIndexBuffer(pass); - pass.drawIndexedIndirect(rawBuffer, offset); - pass.end(); - root.device.queue.submit([commandEncoder.finish()]); - - if (logResources) { - logDataFromGPU(logResources); - } - - if (internals.priors.performanceCallback) { - void triggerPerformanceCallback({ root, priors: internals.priors }); - } + this.#execute(true, (rawPass) => rawPass.drawIndexedIndirect(rawBuffer, offset)); } } @@ -1315,7 +1171,3 @@ export function matchUpVaryingLocations( return locations; } - -function isGPUCanvasContext(value: unknown): value is GPUCanvasContext { - return typeof (value as GPUCanvasContext)?.getCurrentTexture === 'function'; -} diff --git a/packages/typegpu/src/core/pipeline/timeable.ts b/packages/typegpu/src/core/pipeline/timeable.ts index cbe75a5437..5f7de2dcae 100644 --- a/packages/typegpu/src/core/pipeline/timeable.ts +++ b/packages/typegpu/src/core/pipeline/timeable.ts @@ -1,6 +1,7 @@ import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { $internal } from '../../shared/symbols.ts'; +import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; export interface Timeable { withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this; @@ -102,13 +103,39 @@ export function setupTimestampWrites( return { timestampWrites }; } -export function triggerPerformanceCallback({ - root, - priors, -}: { - root: ExperimentalTgpuRoot; - priors: TimestampWritesPriors; -}): void | Promise { +async function readTimestamps( + root: ExperimentalTgpuRoot, + querySet: TgpuQuerySet<'timestamp'>, + priors: TimestampWritesPriors, + callback: (start: bigint, end: bigint) => void | Promise, +): Promise { + await root.device.queue.onSubmittedWorkDone(); + + if (!querySet.available) { + return; + } + + const result = await querySet.read(); + const start = result[priors.timestampWrites?.beginningOfPassWriteIndex ?? 0]; + const end = result[priors.timestampWrites?.endOfPassWriteIndex ?? 1]; + + if (start === undefined || end === undefined) { + throw new Error('QuerySet did not return valid timestamps.'); + } + + await callback(start, end); +} + +/** + * Arranges for the pipeline's timestamps to be resolved as part of the given + * encoder's submission, and for the performance callback to fire afterwards. + * Returns false when the encoder is one we cannot defer work to, meaning the + * callback will never fire. + */ +export function queueTimestampResolve( + encoder: TgpuCommandEncoder, + priors: TimestampWritesPriors, +): boolean { const querySet = priors.timestampWrites?.querySet; const callback = priors.performanceCallback as ( start: bigint, @@ -125,28 +152,28 @@ export function triggerPerformanceCallback({ ); } - const commandEncoder = root.device.createCommandEncoder(); - commandEncoder.resolveQuerySet( - root.unwrap(querySet), - 0, - querySet.count, - querySet[$internal].resolveBuffer, - 0, - ); - root.device.queue.submit([commandEncoder.finish()]); - - void root.device.queue.onSubmittedWorkDone().then(async () => { - if (!querySet.available) { - return; - } - const result = await querySet.read(); - const start = result[priors.timestampWrites?.beginningOfPassWriteIndex ?? 0]; - const end = result[priors.timestampWrites?.endOfPassWriteIndex ?? 1]; - - if (start === undefined || end === undefined) { - throw new Error('QuerySet did not return valid timestamps.'); - } - - await callback(start, end); + const internals = encoder[$internal]; + if (internals.adopted) { + return false; + } + + const { root } = internals; + + // Recorded at submission time, so that it captures the last pass written into + // this encoder rather than whichever one happened to register first. + internals.beforeFinish.set(querySet, (rawEncoder) => { + rawEncoder.resolveQuerySet( + root.unwrap(querySet), + 0, + querySet.count, + querySet[$internal].resolveBuffer, + 0, + ); + }); + + internals.afterSubmit.set(querySet, () => { + void readTimestamps(root, querySet, priors, callback); }); + + return true; } diff --git a/packages/typegpu/src/core/pipeline/typeGuards.ts b/packages/typegpu/src/core/pipeline/typeGuards.ts index 903f7f2c35..166d87edd1 100644 --- a/packages/typegpu/src/core/pipeline/typeGuards.ts +++ b/packages/typegpu/src/core/pipeline/typeGuards.ts @@ -1,4 +1,7 @@ import { $internal } from '../../shared/symbols.ts'; +import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; +import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; +import type { TgpuRenderCommands, TgpuRenderPass } from '../commandEncoder/renderPass.ts'; import type { TgpuComputePipeline } from './computePipeline.ts'; import type { TgpuRenderPipeline } from './renderPipeline.ts'; @@ -16,6 +19,33 @@ export function isPipeline(value: unknown): value is TgpuComputePipeline | TgpuR return isRenderPipeline(value) || isComputePipeline(value); } +export function isTgpuCommandEncoder(value: unknown): value is TgpuCommandEncoder { + const maybe = value as TgpuCommandEncoder | undefined; + return maybe?.resourceType === 'command-encoder' && !!maybe[$internal]; +} + +export function isTgpuRenderPass(value: unknown): value is TgpuRenderPass { + const maybe = value as TgpuRenderPass | undefined; + return maybe?.resourceType === 'render-pass' && !!maybe[$internal]; +} + +export function isTgpuRenderCommands(value: unknown): value is TgpuRenderCommands { + const maybe = value as TgpuRenderCommands | undefined; + return ( + (maybe?.resourceType === 'render-pass' || maybe?.resourceType === 'render-bundle-pass') && + !!maybe[$internal] + ); +} + +export function isTgpuComputePass(value: unknown): value is TgpuComputePass { + const maybe = value as TgpuComputePass | undefined; + return maybe?.resourceType === 'compute-pass' && !!maybe[$internal]; +} + +export function isGPUCanvasContext(value: unknown): value is GPUCanvasContext { + return typeof (value as GPUCanvasContext)?.getCurrentTexture === 'function'; +} + export function isGPUCommandEncoder(value: unknown): value is GPUCommandEncoder { return ( !!value && diff --git a/packages/typegpu/src/core/root/init.ts b/packages/typegpu/src/core/root/init.ts index a3544d67e8..e7036ed7c4 100644 --- a/packages/typegpu/src/core/root/init.ts +++ b/packages/typegpu/src/core/root/init.ts @@ -1,7 +1,7 @@ import { type AnyComputeBuiltin, builtin } from '../../builtin.ts'; import { INTERNAL_createQuerySet, isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; -import type { AnyData, Disarray } from '../../data/dataTypes.ts'; -import type { AnyWgslData, BaseData, v3u, Vec3u, WgslArray } from '../../data/wgslTypes.ts'; +import type { AnyData } from '../../data/dataTypes.ts'; +import type { AnyWgslData, BaseData, v3u, Vec3u } from '../../data/wgslTypes.ts'; import { WeakMemo } from '../../memo.ts'; import { clearTextureUtilsCache } from '../texture/textureUtils.ts'; import type { BufferInitialData } from '../buffer/buffer.ts'; @@ -15,7 +15,7 @@ import type { import { isBindGroup, isBindGroupLayout, TgpuBindGroupImpl } from '../../tgpuBindGroupLayout.ts'; import type { LogGeneratorOptions } from '../../tgsl/consoleLog/types.ts'; import type { ShaderGenerator } from '../../tgsl/shaderGenerator.ts'; -import { INTERNAL_createBuffer, type TgpuBuffer, type VertexFlag } from '../buffer/buffer.ts'; +import { INTERNAL_createBuffer, type TgpuBuffer } from '../buffer/buffer.ts'; import { isBuffer } from '../../types.ts'; import { isBufferBinding, @@ -35,8 +35,23 @@ import { INTERNAL_createRenderPipeline, type TgpuRenderPipeline, } from '../pipeline/renderPipeline.ts'; -import { isComputePipeline, isRenderPipeline } from '../pipeline/typeGuards.ts'; -import { applyBindGroups, applyVertexBuffers } from '../pipeline/applyPipelineState.ts'; +import { + isComputePipeline, + isRenderPipeline, + isTgpuCommandEncoder, + isTgpuComputePass, + isTgpuRenderPass, +} from '../pipeline/typeGuards.ts'; +import { + INTERNAL_createCommandEncoder, + type TgpuCommandEncoder, +} from '../commandEncoder/commandEncoder.ts'; +import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; +import { + INTERNAL_beginRenderBundlePass, + type TgpuRenderCommands, + type TgpuRenderPass, +} from '../commandEncoder/renderPass.ts'; import { INTERNAL_createComparisonSampler, INTERNAL_createSampler, @@ -70,8 +85,6 @@ import type { CreateTextureOptions, CreateTextureResult, ExperimentalTgpuRoot, - RenderBundleEncoderPass, - RenderPass, TgpuGuardedComputePipeline, TgpuRoot, WithBinding, @@ -435,6 +448,9 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu unwrap(resource: TgpuComputePipeline): GPUComputePipeline; unwrap(resource: TgpuRenderPipeline): GPURenderPipeline; + unwrap(resource: TgpuCommandEncoder): GPUCommandEncoder; + unwrap(resource: TgpuRenderPass): GPURenderPassEncoder; + unwrap(resource: TgpuComputePass): GPUComputePassEncoder; unwrap(resource: TgpuBindGroupLayout): GPUBindGroupLayout; unwrap(resource: TgpuBindGroup): GPUBindGroup; unwrap(resource: TgpuBuffer): GPUBuffer; @@ -449,6 +465,9 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu resource: | TgpuComputePipeline | TgpuRenderPipeline + | TgpuCommandEncoder + | TgpuRenderPass + | TgpuComputePass | TgpuBindGroupLayout | TgpuBindGroup | TgpuBuffer @@ -462,6 +481,9 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu ): | GPUComputePipeline | GPURenderPipeline + | GPUCommandEncoder + | GPURenderPassEncoder + | GPUComputePassEncoder | GPUBindGroupLayout | GPUBindGroup | GPUBuffer @@ -474,6 +496,15 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu return resource[$internal].rawPipeline; } + if (isTgpuCommandEncoder(resource)) { + return resource[$internal].rawEncoder; + } + + if (isTgpuRenderPass(resource) || isTgpuComputePass(resource)) { + resource[$internal].state.rawAccessed = true; + return resource[$internal].rawPass; + } + if (isRenderPipeline(resource)) { return resource[$internal].core.unwrap().pipeline; } @@ -523,138 +554,17 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu throw new Error(`Unknown resource type: ${resource}`); } - private createDrawablePassProxy( - encoder: GPURenderPassEncoder | GPURenderBundleEncoder, - ): RenderBundleEncoderPass { - const bindGroups = new Map(); - const vertexBuffers = new Map< - TgpuVertexLayout, - { - buffer: (TgpuBuffer & VertexFlag) | GPUBuffer; - offset?: number | undefined; - size?: number | undefined; - } - >(); - - let currentPipeline: TgpuRenderPipeline | undefined; - let dirty = true; - - const applyPipelineState = () => { - if (!currentPipeline) { - throw new Error('Cannot draw without a call to pass.setPipeline'); - } - if (!dirty) { - return; - } - dirty = false; - const { core, priors } = currentPipeline[$internal]; - const memo = core.unwrap(); - encoder.setPipeline(memo.pipeline); - - applyBindGroups( - encoder, - this, - memo.usedBindGroupLayouts, - memo.catchall, - (layout) => priors.bindGroupLayoutMap?.get(layout) ?? bindGroups.get(layout), - ); - - applyVertexBuffers(encoder, this, memo.usedVertexLayouts, (vertexLayout) => { - const priorBuffer = priors.vertexLayoutMap?.get(vertexLayout); - return priorBuffer - ? { buffer: priorBuffer, offset: undefined, size: undefined } - : vertexBuffers.get(vertexLayout); - }); - }; - - return { - setPipeline(pipeline) { - currentPipeline = pipeline; - dirty = true; - }, - - setIndexBuffer: (buffer, indexFormat, offset, size) => { - if (isBuffer(buffer)) { - encoder.setIndexBuffer(this.unwrap(buffer), indexFormat, offset, size); - } else { - encoder.setIndexBuffer(buffer, indexFormat, offset, size); - } - }, - - setVertexBuffer(vertexLayout, buffer, offset, size) { - vertexBuffers.set(vertexLayout, { buffer, offset, size }); - dirty = true; - }, - - setBindGroup(bindGroupLayout, bindGroup) { - bindGroups.set(bindGroupLayout, bindGroup); - dirty = true; - }, - - draw(vertexCount, instanceCount, firstVertex, firstInstance) { - applyPipelineState(); - encoder.draw(vertexCount, instanceCount, firstVertex, firstInstance); - }, - - drawIndexed(...args) { - applyPipelineState(); - encoder.drawIndexed(...args); - }, - - drawIndirect(...args) { - applyPipelineState(); - encoder.drawIndirect(...args); - }, - - drawIndexedIndirect(...args) { - applyPipelineState(); - encoder.drawIndexedIndirect(...args); - }, - }; - } - - beginRenderPass(descriptor: GPURenderPassDescriptor, callback: (pass: RenderPass) => void): void { - const commandEncoder = this.device.createCommandEncoder(); - const pass = commandEncoder.beginRenderPass(descriptor); - - const proxy = this.createDrawablePassProxy(pass); - - callback({ - setViewport(...args) { - pass.setViewport(...args); - }, - setScissorRect(...args) { - pass.setScissorRect(...args); - }, - setBlendConstant(...args) { - pass.setBlendConstant(...args); - }, - setStencilReference(...args) { - pass.setStencilReference(...args); - }, - beginOcclusionQuery(...args) { - pass.beginOcclusionQuery(...args); - }, - endOcclusionQuery(...args) { - pass.endOcclusionQuery(...args); - }, - executeBundles(...args) { - pass.executeBundles(...args); - }, - ...proxy, - }); - - pass.end(); - this.device.queue.submit([commandEncoder.finish()]); + createCommandEncoder(descriptor?: GPUCommandEncoderDescriptor): TgpuCommandEncoder { + return INTERNAL_createCommandEncoder(this, descriptor); } beginRenderBundleEncoder( descriptor: GPURenderBundleEncoderDescriptor, - callback: (pass: RenderBundleEncoderPass) => void, + callback: (pass: TgpuRenderCommands) => void, ): GPURenderBundle { const bundleEncoder = this.device.createRenderBundleEncoder(descriptor); - callback(this.createDrawablePassProxy(bundleEncoder)); + callback(INTERNAL_beginRenderBundlePass(this, bundleEncoder)); return bundleEncoder.finish(); } diff --git a/packages/typegpu/src/core/root/rootTypes.ts b/packages/typegpu/src/core/root/rootTypes.ts index e06c2da95b..6721513acd 100644 --- a/packages/typegpu/src/core/root/rootTypes.ts +++ b/packages/typegpu/src/core/root/rootTypes.ts @@ -1,9 +1,9 @@ import type { AnyComputeBuiltin, AnyFragmentInputBuiltin, OmitBuiltins } from '../../builtin.ts'; import type { TgpuQuerySet } from '../querySet/querySet.ts'; -import type { AnyData, Disarray } from '../../data/dataTypes.ts'; +import type { AnyData } from '../../data/dataTypes.ts'; import type { InstanceToSchema } from '../../data/instanceToSchema.ts'; import type { WgslComparisonSamplerProps, WgslSamplerProps } from '../../data/sampler.ts'; -import type { AnyWgslData, BaseData, v4f, Vec3u, Void, WgslArray } from '../../data/wgslTypes.ts'; +import type { AnyWgslData, BaseData, v4f, Vec3u, Void } from '../../data/wgslTypes.ts'; import type { TgpuNamable } from '../../shared/meta.ts'; import type { ExtractInvalidSchemaError, @@ -24,7 +24,7 @@ import type { import type { LogGeneratorOptions } from '../../tgsl/consoleLog/types.ts'; import type { ShaderGenerator } from '../../tgsl/shaderGenerator.ts'; import type { Unwrapper } from '../../unwrapper.ts'; -import type { TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; +import type { TgpuBuffer } from '../buffer/buffer.ts'; import type { TgpuMutable, TgpuReadonly, TgpuUniform } from '../buffer/bufferBinding.ts'; import type { AnyAutoCustoms, @@ -36,6 +36,8 @@ import type { import type { IORecord } from '../function/fnTypes.ts'; import type { TgpuFragmentFn, VertexOutToVarying } from '../function/tgpuFragmentFn.ts'; import type { TgpuVertexFn } from '../function/tgpuVertexFn.ts'; +import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; +import type { TgpuRenderCommands } from '../commandEncoder/renderPass.ts'; import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts'; import type { FragmentOutToTargets, TgpuRenderPipeline } from '../pipeline/renderPipeline.ts'; import type { TgpuFixedComparisonSampler, TgpuFixedSampler } from '../sampler/sampler.ts'; @@ -45,7 +47,6 @@ import type { AttribRecordToDefaultDataTypes, LayoutToAllowedAttribs, } from '../vertexLayout/vertexAttribute.ts'; -import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; // ---------- // Public API @@ -464,249 +465,6 @@ export type CreateTextureResult< > >; -export interface RenderBundleEncoderPass { - /** - * Sets the current {@link TgpuRenderPipeline} for subsequent draw calls. - * @param pipeline - The render pipeline to use. - */ - setPipeline(pipeline: TgpuRenderPipeline): void; - - /** - * Sets the current index buffer. - * @param buffer - Buffer containing index data to use for subsequent drawing commands. - * @param indexFormat - Format of the index data contained in `buffer`. - * @param offset - Offset in bytes into `buffer` where the index data begins. Defaults to `0`. - * @param size - Size in bytes of the index data in `buffer`. - * Defaults to the size of the buffer minus the offset. - */ - setIndexBuffer( - buffer: TgpuBuffer | GPUBuffer, - indexFormat: GPUIndexFormat, - offset?: GPUSize64, - size?: GPUSize64, - ): void; - - /** - * Binds a vertex buffer to the given vertex layout for subsequent draw calls. - * @param vertexLayout - The vertex layout describing the buffer's structure. - * @param buffer - The vertex buffer to bind. - * @param offset - Offset in bytes into `buffer`. Defaults to `0`. - * @param size - Size in bytes to bind. Defaults to the remainder of the buffer. - */ - setVertexBuffer( - vertexLayout: TgpuVertexLayout, - buffer: (TgpuBuffer & VertexFlag) | GPUBuffer, - offset?: GPUSize64, - size?: GPUSize64, - ): void; - - /** - * Associates a bind group with the given layout for subsequent draw calls. - * @param bindGroupLayout - The layout the bind group conforms to. - * @param bindGroup - The bind group to associate. - */ - setBindGroup>( - bindGroupLayout: TgpuBindGroupLayout, - bindGroup: TgpuBindGroup | GPUBindGroup, - ): void; - - /** - * Draws primitives. - * @param vertexCount - The number of vertices to draw. - * @param instanceCount - The number of instances to draw. - * @param firstVertex - Offset into the vertex buffers, in vertices, to begin drawing from. - * @param firstInstance - First instance to draw. - */ - draw( - vertexCount: number, - instanceCount?: number, - firstVertex?: number, - firstInstance?: number, - ): void; - /** - * Draws indexed primitives. - * @param indexCount - The number of indices to draw. - * @param instanceCount - The number of instances to draw. - * @param firstIndex - Offset into the index buffer, in indices, begin drawing from. - * @param baseVertex - Added to each index value before indexing into the vertex buffers. - * @param firstInstance - First instance to draw. - */ - drawIndexed( - indexCount: number, - instanceCount?: number, - firstIndex?: number, - baseVertex?: number, - firstInstance?: number, - ): void; - /** - * Draws primitives using parameters read from a {@link GPUBuffer}. - * @param indirectBuffer - Buffer containing the indirect draw parameters. - * @param indirectOffset - Offset in bytes into `indirectBuffer` where the drawing data begins. - */ - drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; - /** - * Draws indexed primitives using parameters read from a {@link GPUBuffer}. - * @param indirectBuffer - Buffer containing the indirect drawIndexed parameters. - * @param indirectOffset - Offset in bytes into `indirectBuffer` where the drawing data begins. - */ - drawIndexedIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; -} - -type Replace = Omit & R; - -/** - * The same as {@link GPURenderPassDescriptor}, but accepting readonly tuples as the clearValue - */ -type TgpuRenderPassDescriptor = Replace< - GPURenderPassDescriptor, - { - colorAttachments: (Replace< - GPURenderPassColorAttachment, - { - clearValue?: readonly [number, number, number, number] | GPUColor | undefined; - } - > | null)[]; - } ->; - -export interface RenderPass extends RenderBundleEncoderPass { - /** - * Sets the viewport used during the rasterization stage to linearly map from - * NDC (i.e., normalized device coordinates) to viewport coordinates. - * @param x - Minimum X value of the viewport in pixels. - * @param y - Minimum Y value of the viewport in pixels. - * @param width - Width of the viewport in pixels. - * @param height - Height of the viewport in pixels. - * @param minDepth - Minimum depth value of the viewport. - * @param maxDepth - Maximum depth value of the viewport. - */ - setViewport( - x: number, - y: number, - width: number, - height: number, - minDepth: number, - maxDepth: number, - ): void; - - /** - * Sets the scissor rectangle used during the rasterization stage. - * After transformation into viewport coordinates any fragments which fall outside the scissor - * rectangle will be discarded. - * @param x - Minimum X value of the scissor rectangle in pixels. - * @param y - Minimum Y value of the scissor rectangle in pixels. - * @param width - Width of the scissor rectangle in pixels. - * @param height - Height of the scissor rectangle in pixels. - */ - setScissorRect(x: number, y: number, width: number, height: number): void; - - /** - * Sets the constant blend color and alpha values used with {@link GPUBlendFactor#constant} - * and {@link GPUBlendFactor#"one-minus-constant"} {@link GPUBlendFactor}s. - * @param color - The color to use when blending. - */ - setBlendConstant(color: GPUColor): void; - - /** - * Sets the {@link RenderState#[[stencilReference]]} value used during stencil tests with - * the {@link GPUStencilOperation#"replace"} {@link GPUStencilOperation}. - * @param reference - The new stencil reference value. - */ - setStencilReference(reference: GPUStencilValue): void; - - /** - * @param queryIndex - The index of the query in the query set. - */ - beginOcclusionQuery(queryIndex: GPUSize32): void; - - endOcclusionQuery(): void; - - /** - * Executes the commands previously recorded into the given {@link GPURenderBundle}s as part of - * this render pass. - * When a {@link GPURenderBundle} is executed, it does not inherit the render pass's pipeline, bind - * groups, or vertex and index buffers. After a {@link GPURenderBundle} has executed, the render - * pass's pipeline, bind group, and vertex/index buffer state is cleared - * (to the initial, empty values). - * Note: The state is cleared, not restored to the previous state. - * This occurs even if zero {@link GPURenderBundle|GPURenderBundles} are executed. - * @param bundles - List of render bundles to execute. - */ - executeBundles(bundles: Iterable): undefined; - setPipeline(pipeline: TgpuRenderPipeline): void; - - /** - * Sets the current index buffer. - * @param buffer - Buffer containing index data to use for subsequent drawing commands. - * @param indexFormat - Format of the index data contained in `buffer`. - * @param offset - Offset in bytes into `buffer` where the index data begins. Defaults to `0`. - * @param size - Size in bytes of the index data in `buffer`. - * Defaults to the size of the buffer minus the offset. - */ - setIndexBuffer( - // TODO: Allow only typed buffers marked with Index usage - buffer: TgpuBuffer | GPUBuffer, - indexFormat: GPUIndexFormat, - offset?: GPUSize64, - size?: GPUSize64, - ): void; - setVertexBuffer( - vertexLayout: TgpuVertexLayout, - buffer: (TgpuBuffer & VertexFlag) | GPUBuffer, - offset?: GPUSize64, - size?: GPUSize64, - ): void; - setBindGroup>( - bindGroupLayout: TgpuBindGroupLayout, - bindGroup: TgpuBindGroup | GPUBindGroup, - ): void; - - /** - * Draws primitives. - * @param vertexCount - The number of vertices to draw. - * @param instanceCount - The number of instances to draw. - * @param firstVertex - Offset into the vertex buffers, in vertices, to begin drawing from. - * @param firstInstance - First instance to draw. - */ - draw( - vertexCount: number, - instanceCount?: number, - firstVertex?: number, - firstInstance?: number, - ): void; - /** - * Draws indexed primitives. - * @param indexCount - The number of indices to draw. - * @param instanceCount - The number of instances to draw. - * @param firstIndex - Offset into the index buffer, in indices, begin drawing from. - * @param baseVertex - Added to each index value before indexing into the vertex buffers. - * @param firstInstance - First instance to draw. - */ - drawIndexed( - indexCount: number, - instanceCount?: number, - firstIndex?: number, - baseVertex?: number, - firstInstance?: number, - ): void; - /** - * Draws primitives using parameters read from a {@link GPUBuffer}. - * Packed block of **four 32-bit unsigned integer values (16 bytes total)**, given in the same - * order as the arguments for {@link GPURenderEncoderBase#draw}. For example: - * @param indirectBuffer - Buffer containing the indirect draw parameters. - * @param indirectOffset - Offset in bytes into `indirectBuffer` where the drawing data begins. - */ - drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; - /** - * Draws indexed primitives using parameters read from a {@link GPUBuffer}. - * Tightly packed block of **five 32-bit unsigned integer values (20 bytes total)**, given in - * the same order as the arguments for {@link GPURenderEncoderBase#drawIndexed}. For example: - * @param indirectBuffer - Buffer containing the indirect drawIndexed parameters. - * @param indirectOffset - Offset in bytes into `indirectBuffer` where the drawing data begins. - */ - drawIndexedIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; -} - export type ValidateBufferSchema = IsValidBufferSchema extends false ? ExtractInvalidSchemaError : TData; @@ -951,8 +709,8 @@ export interface TgpuRoot extends Unwrapper, WithBinding { '~unstable': Pick< ExperimentalTgpuRoot, - | 'beginRenderPass' | 'beginRenderBundleEncoder' + | 'createCommandEncoder' | 'createComparisonSampler' | 'createGuardedComputePipeline' | 'createSampler' @@ -997,12 +755,28 @@ export interface ExperimentalTgpuRoot CreateTextureResult >; - beginRenderPass(descriptor: TgpuRenderPassDescriptor, callback: (pass: RenderPass) => void): void; + /** + * Creates a {@link TgpuCommandEncoder} for batching multiple render/compute + * passes (and draws within them) into a single submission. + * + * @example + * ```ts + * const encoder = root['~unstable'].createCommandEncoder(); + * const pass = encoder.beginRenderPass({ + * colorAttachments: [{ view: msaaTexture, resolveTarget: context }], + * }); + * scenePipeline.with(pass).draw(vertexCount); + * skyPipeline.with(pass).draw(3); + * pass.end(); + * encoder.submit(); + * ``` + */ + createCommandEncoder(descriptor?: GPUCommandEncoderDescriptor): TgpuCommandEncoder; /** * Creates a {@link GPURenderBundle} by recording draw commands into a * {@link GPURenderBundleEncoder}. The resulting bundle can be replayed in a - * render pass via {@link RenderPass.executeBundles}. + * render pass via `pass.executeBundles`. * * The caller is responsible for ensuring that the `descriptor` (e.g. * `colorFormats`, `depthStencilFormat`) is compatible with the render pass @@ -1013,7 +787,7 @@ export interface ExperimentalTgpuRoot */ beginRenderBundleEncoder( descriptor: GPURenderBundleEncoderDescriptor, - callback: (pass: RenderBundleEncoderPass) => void, + callback: (pass: TgpuRenderCommands) => void, ): GPURenderBundle; /** @deprecated Use `root.createSampler` instead. */ diff --git a/packages/typegpu/src/core/texture/texture.ts b/packages/typegpu/src/core/texture/texture.ts index f496b7d34a..1056895680 100644 --- a/packages/typegpu/src/core/texture/texture.ts +++ b/packages/typegpu/src/core/texture/texture.ts @@ -41,6 +41,8 @@ export type TextureInternals = { type TextureViewInternals = { readonly unwrap: (() => GPUTextureView) | undefined; + readonly format?: GPUTextureFormat | undefined; + readonly aspect?: GPUTextureAspect | undefined; }; // Public API @@ -594,6 +596,10 @@ class TgpuFixedTextureViewImpl } return this.#view; }, + format: + descriptor?.format ?? + (isWgslStorageTexture(schema) ? schema.format : baseTexture.props.format), + aspect: descriptor?.aspect, }; } @@ -745,6 +751,8 @@ export class TgpuTextureRenderViewImpl implements TgpuTextureRenderView { ...this.descriptor, }); }, + format: descriptor.format ?? baseTexture.props.format, + aspect: descriptor.aspect, }; } } diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts index 809af0e2bd..0e0d9b28da 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -60,10 +60,21 @@ export type { Storage, StorageFlag } from './extension.ts'; export type { TgpuVertexLayout } from './core/vertexLayout/vertexLayout.ts'; export type { ColorAttachment, + DepthStencilAttachment, TgpuPrimitiveState, TgpuRenderPipeline, } from './core/pipeline/renderPipeline.ts'; export type { TgpuComputePipeline } from './core/pipeline/computePipeline.ts'; +export type { TgpuCommandEncoder } from './core/commandEncoder/commandEncoder.ts'; +export type { + TgpuRenderCommands, + TgpuRenderPass, + TgpuRenderPassDescriptor, +} from './core/commandEncoder/renderPass.ts'; +export type { + TgpuComputePass, + TgpuComputePassDescriptor, +} from './core/commandEncoder/computePass.ts'; export type { IndexFlag, TgpuBuffer, diff --git a/packages/typegpu/src/unwrapper.ts b/packages/typegpu/src/unwrapper.ts index 11c9ae9590..996fc2b8e9 100644 --- a/packages/typegpu/src/unwrapper.ts +++ b/packages/typegpu/src/unwrapper.ts @@ -1,5 +1,8 @@ import type { TgpuQuerySet } from './core/querySet/querySet.ts'; import type { TgpuBuffer } from './core/buffer/buffer.ts'; +import type { TgpuCommandEncoder } from './core/commandEncoder/commandEncoder.ts'; +import type { TgpuComputePass } from './core/commandEncoder/computePass.ts'; +import type { TgpuRenderPass } from './core/commandEncoder/renderPass.ts'; import type { TgpuComputePipeline } from './core/pipeline/computePipeline.ts'; import type { TgpuRenderPipeline } from './core/pipeline/renderPipeline.ts'; import type { TgpuComparisonSampler, TgpuSampler } from './core/sampler/sampler.ts'; @@ -13,6 +16,9 @@ export interface Unwrapper { readonly device: GPUDevice; unwrap(resource: TgpuComputePipeline): GPUComputePipeline; unwrap(resource: TgpuRenderPipeline): GPURenderPipeline; + unwrap(resource: TgpuCommandEncoder): GPUCommandEncoder; + unwrap(resource: TgpuRenderPass): GPURenderPassEncoder; + unwrap(resource: TgpuComputePass): GPUComputePassEncoder; unwrap(resource: TgpuBindGroupLayout): GPUBindGroupLayout; unwrap(resource: TgpuBindGroup): GPUBindGroup; unwrap(resource: TgpuBuffer): GPUBuffer; diff --git a/packages/typegpu/tests/commandEncoder.test.ts b/packages/typegpu/tests/commandEncoder.test.ts new file mode 100644 index 0000000000..1a5f4ec9ee --- /dev/null +++ b/packages/typegpu/tests/commandEncoder.test.ts @@ -0,0 +1,598 @@ +import { describe, expect } from 'vitest'; +import { Void } from 'typegpu/data'; +import { tgpu, d } from 'typegpu'; +import { it } from 'typegpu-testing-utility'; + +describe('TgpuCommandEncoder', () => { + const layout = tgpu.bindGroupLayout({ foo: { uniform: d.f32 } }); + + const mainVertex = tgpu.vertexFn({ + out: { pos: d.builtin.position }, + })(() => { + layout.$.foo; + return { pos: d.vec4f() }; + }); + + const mainFragment = tgpu.fragmentFn({ out: Void })(() => {}); + + const plainVertex = tgpu.vertexFn({ + out: { pos: d.builtin.position }, + })(() => { + return { pos: d.vec4f() }; + }); + + const secondLayout = tgpu.bindGroupLayout({ bar: { uniform: d.f32 } }); + + const secondVertex = tgpu.vertexFn({ + out: { pos: d.builtin.position }, + })(() => { + secondLayout.$.bar; + return { pos: d.vec4f() }; + }); + + it('submits a single command buffer for multiple draws', ({ root, commandEncoder }) => { + const group = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(group); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pipeline.with(pass).draw(3); + pipeline.with(pass).draw(6, 2); + pass.end(); + encoder.submit(); + + expect(root.device.createCommandEncoder).toBeCalledTimes(1); + expect(root.device.queue.submit).toBeCalledTimes(1); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.draw).toBeCalledTimes(2); + expect(renderPassMock.end).toBeCalledTimes(1); + }); + + it('applies pipeline state once for consecutive draws with the same pipeline', ({ + root, + commandEncoder, + }) => { + const group = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(group); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + const bound = pipeline.with(pass); + bound.draw(3); + bound.draw(3); + bound.draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setPipeline).toBeCalledTimes(1); + expect(renderPassMock.setBindGroup).toBeCalledTimes(1); + expect(renderPassMock.draw).toBeCalledTimes(3); + }); + + it('re-applies pipeline state after another pipeline drew', ({ root, commandEncoder }) => { + const group = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + const secondGroup = root.createBindGroup(secondLayout, { + bar: root.createBuffer(d.f32).$usage('uniform'), + }); + + const first = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(group); + const second = root + .createRenderPipeline({ vertex: secondVertex, fragment: mainFragment }) + .with(secondGroup); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + first.with(pass).draw(3); + second.with(pass).draw(3); + first.with(pass).draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setPipeline).toBeCalledTimes(3); + }); + + it('re-applies pipeline state after pass-level setBindGroup', ({ root, commandEncoder }) => { + const groupA = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + const groupB = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root.createRenderPipeline({ + vertex: mainVertex, + fragment: mainFragment, + }); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setBindGroup(groupA); + const bound = pipeline.with(pass); + bound.draw(3); + pass.setBindGroup(groupB); + bound.draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setPipeline).toBeCalledTimes(2); + expect(renderPassMock.setBindGroup).nthCalledWith(1, 0, root.unwrap(groupA)); + expect(renderPassMock.setBindGroup).nthCalledWith(2, 0, root.unwrap(groupB)); + }); + + it('prefers pipeline-level bind groups over pass-level ones', ({ root, commandEncoder }) => { + const passGroup = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + const pipelineGroup = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(pipelineGroup); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setBindGroup(passGroup); + pipeline.with(pass).draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setBindGroup).toBeCalledTimes(1); + expect(renderPassMock.setBindGroup).toBeCalledWith(0, root.unwrap(pipelineGroup)); + }); + + it('applies a prepared index buffer when drawing proxy-style', ({ root, commandEncoder }) => { + const indexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); + const pipeline = root + .createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }) + .withIndexBuffer(indexBuffer); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setPipeline(pipeline); + pass.drawIndexed(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setIndexBuffer).toBeCalledTimes(1); + expect(renderPassMock.setIndexBuffer).toBeCalledWith( + root.unwrap(indexBuffer), + 'uint16', + undefined, + undefined, + ); + expect(renderPassMock.drawIndexed).toBeCalledTimes(1); + }); + + it('restores the pass-level index buffer after a pipeline override', ({ + root, + commandEncoder, + }) => { + const passIndexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); + const pipelineIndexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); + + const plain = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); + const prepared = plain.withIndexBuffer(pipelineIndexBuffer); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setIndexBuffer(passIndexBuffer, 'uint16'); + prepared.with(pass).drawIndexed(3); + pass.setPipeline(plain); + pass.drawIndexed(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setIndexBuffer).nthCalledWith( + 1, + root.unwrap(pipelineIndexBuffer), + 'uint16', + undefined, + undefined, + ); + expect(renderPassMock.setIndexBuffer).nthCalledWith( + 2, + root.unwrap(passIndexBuffer), + 'uint16', + undefined, + undefined, + ); + expect(renderPassMock.setStencilReference).not.toBeCalled(); + }); + + it('prefers a pipeline stencil reference and falls back to pass state', ({ + root, + commandEncoder, + }) => { + const plain = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); + const withRef = plain.withStencilReference(5); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setStencilReference(7); + withRef.with(pass).draw(3); + plain.with(pass).draw(3); + pass.setStencilReference(2); + plain.with(pass).draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setStencilReference).toBeCalledTimes(3); + expect(renderPassMock.setStencilReference).nthCalledWith(1, 5); + expect(renderPassMock.setStencilReference).nthCalledWith(2, 7); + expect(renderPassMock.setStencilReference).nthCalledWith(3, 2); + }); + + it('resets a pipeline stencil reference for the next pipeline', ({ root, commandEncoder }) => { + const plain = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); + const withRef = plain.withStencilReference(5); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + withRef.with(pass).draw(3); + plain.with(pass).draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setStencilReference).nthCalledWith(1, 5); + expect(renderPassMock.setStencilReference).nthCalledWith(2, 0); + }); + + it('disables state deduplication after the pass is unwrapped', ({ root, commandEncoder }) => { + const pipeline = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + const bound = pipeline.with(pass); + bound.draw(3); + bound.draw(3); + root.unwrap(pass); + bound.draw(3); + bound.draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setPipeline).toBeCalledTimes(3); + }); + + it('throws when drawing without a pipeline', ({ root }) => { + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + + expect(() => pass.draw(3)).toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot draw without a call to pass.setPipeline]`, + ); + }); + + it('throws when a used bind group is missing', ({ root }) => { + const pipeline = root.createRenderPipeline({ + vertex: mainVertex, + fragment: mainFragment, + }); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + + expect(() => pipeline.with(pass).draw(3)).toThrow(/Missing bind groups/); + }); + + it('unwraps TypeGPU textures passed as attachment views', ({ root, commandEncoder }) => { + const colorTexture = root + .createTexture({ size: [64, 64], format: 'rgba8unorm' }) + .$usage('render'); + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + colorAttachments: [{ view: colorTexture }], + depthStencilAttachment: { view: depthTexture }, + }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + const [colorAttachment] = [...rawDescriptor.colorAttachments]; + + expect(root.unwrap(colorTexture).createView).toBeCalled(); + expect(root.unwrap(depthTexture).createView).toBeCalled(); + expect(colorAttachment?.loadOp).toBe('clear'); + expect(colorAttachment?.storeOp).toBe('store'); + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); + expect(rawDescriptor.depthStencilAttachment?.depthStoreOp).toBe('store'); + expect(rawDescriptor.depthStencilAttachment?.depthClearValue).toBe(1); + }); + + it('allows omitting color attachments for depth-only passes', ({ root, commandEncoder }) => { + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ depthStencilAttachment: { view: depthTexture } }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect([...rawDescriptor.colorAttachments]).toHaveLength(0); + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); + }); + + it('accepts a single color attachment without an array', ({ root, commandEncoder }) => { + const colorTexture = root + .createTexture({ size: [64, 64], format: 'rgba8unorm' }) + .$usage('render'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ colorAttachments: { view: colorTexture } }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect([...rawDescriptor.colorAttachments]).toHaveLength(1); + }); + + it('does not apply depth defaults to read-only depth attachments', ({ root, commandEncoder }) => { + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + colorAttachments: [], + depthStencilAttachment: { view: depthTexture, depthReadOnly: true }, + }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBeUndefined(); + expect(rawDescriptor.depthStencilAttachment?.depthStoreOp).toBeUndefined(); + }); + + it('applies stencil defaults only for formats with a stencil aspect', ({ + root, + commandEncoder, + }) => { + const depthStencilTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) + .$usage('render'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + colorAttachments: [], + depthStencilAttachment: { view: depthStencilTexture }, + }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBe('clear'); + expect(rawDescriptor.depthStencilAttachment?.stencilStoreOp).toBe('store'); + }); + + it('derives depth/stencil defaults from TypeGPU texture views', ({ root, commandEncoder }) => { + const depthStencilTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) + .$usage('render'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + depthStencilAttachment: { view: depthStencilTexture.createView('render') }, + }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); + expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBe('clear'); + }); + + it('respects the view aspect when deriving depth/stencil defaults', ({ + root, + commandEncoder, + }) => { + const depthStencilTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) + .$usage('render'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + depthStencilAttachment: { + view: depthStencilTexture.createView('render', { aspect: 'depth-only' }), + }, + }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); + expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBeUndefined(); + }); + + it('assumes depth-only defaults for raw views without explicit operations', ({ + root, + commandEncoder, + }) => { + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + depthStencilAttachment: { view: {} as GPUTextureView }, + }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); + expect(rawDescriptor.depthStencilAttachment?.depthStoreOp).toBe('store'); + expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBeUndefined(); + }); + + it('passes raw views through untouched when explicit operations are given', ({ + root, + commandEncoder, + }) => { + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + depthStencilAttachment: { + view: {} as GPUTextureView, + stencilLoadOp: 'clear', + stencilStoreOp: 'store', + }, + }); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBeUndefined(); + expect(rawDescriptor.depthStencilAttachment?.depthClearValue).toBeUndefined(); + expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBe('clear'); + }); + + it('resets applied state after executeBundles', ({ root, commandEncoder }) => { + const group = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(group); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + const bound = pipeline.with(pass); + bound.draw(3); + pass.executeBundles([]); + bound.draw(3); + pass.end(); + encoder.submit(); + + const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] + ?.value as GPURenderPassEncoder; + expect(renderPassMock.setPipeline).toBeCalledTimes(2); + }); + + it('unwraps to raw WebGPU objects', ({ root, commandEncoder }) => { + const encoder = root.createCommandEncoder(); + const renderPass = encoder.beginRenderPass({ colorAttachments: [] }); + const computePass = encoder.beginComputePass(); + + expect(root.unwrap(encoder)).toBe(commandEncoder); + expect(root.unwrap(renderPass)).toBe( + commandEncoder.mock.beginRenderPass.mock.results[0]?.value, + ); + expect(root.unwrap(computePass)).toBe( + commandEncoder.mock.beginComputePass.mock.results[0]?.value, + ); + }); + + describe('compute pass', () => { + const computeLayout = tgpu.bindGroupLayout({ data: { uniform: d.f32 } }); + + const entry = tgpu.computeFn({ workgroupSize: [1] })(() => { + computeLayout.$.data; + }); + + it('dispatches into a shared pass without submitting', ({ root, commandEncoder }) => { + const group = root.createBindGroup(computeLayout, { + data: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root.createComputePipeline({ compute: entry }).with(group); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginComputePass(); + const bound = pipeline.with(pass); + bound.dispatchWorkgroups(1); + bound.dispatchWorkgroups(2); + pass.end(); + encoder.submit(); + + expect(root.device.queue.submit).toBeCalledTimes(1); + + const computePassMock = commandEncoder.mock.beginComputePass.mock.results[0]?.value as { + setPipeline: unknown; + dispatchWorkgroups: unknown; + end: unknown; + }; + expect(computePassMock.setPipeline).toBeCalledTimes(1); + expect(computePassMock.dispatchWorkgroups).toBeCalledTimes(2); + expect(computePassMock.end).toBeCalledTimes(1); + }); + + it('throws when dispatching without a pipeline', ({ root }) => { + const encoder = root.createCommandEncoder(); + const pass = encoder.beginComputePass(); + + expect(() => pass.dispatchWorkgroups(1)).toThrowErrorMatchingInlineSnapshot( + `[Error: Cannot dispatch without a call to pass.setPipeline]`, + ); + }); + + it('mixes render and compute passes in one encoder', ({ root, commandEncoder }) => { + const group = root.createBindGroup(computeLayout, { + data: root.createBuffer(d.f32).$usage('uniform'), + }); + const renderGroup = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const computePipeline = root.createComputePipeline({ compute: entry }).with(group); + const renderPipeline = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(renderGroup); + + const encoder = root.createCommandEncoder(); + + const computePass = encoder.beginComputePass(); + computePipeline.with(computePass).dispatchWorkgroups(1); + computePass.end(); + + const renderPass = encoder.beginRenderPass({ colorAttachments: [] }); + renderPipeline.with(renderPass).draw(3); + renderPass.end(); + + encoder.submit(); + + expect(root.device.createCommandEncoder).toBeCalledTimes(1); + expect(root.device.queue.submit).toBeCalledTimes(1); + expect(commandEncoder.mock.beginComputePass).toBeCalledTimes(1); + expect(commandEncoder.mock.beginRenderPass).toBeCalledTimes(1); + }); + }); +}); diff --git a/packages/typegpu/tests/computePipeline.test.ts b/packages/typegpu/tests/computePipeline.test.ts index 92b10789a8..073bb2f401 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -106,6 +106,117 @@ describe('TgpuComputePipeline', () => { `); }); + it('drains shader logs when dispatching into an encoder-owned pass', ({ root }) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => { + console.log(1); + }); + const pipeline = root.createComputePipeline({ compute: entryFn }); + + const encoder = root['~unstable'].createCommandEncoder(); + const pass = encoder.beginComputePass(); + pipeline.with(pass).dispatchWorkgroups(1); + pass.end(); + encoder.submit(); + + // The read-back is deferred to the encoder's submission instead of dropped. + expect(consoleWarnSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + + it('warns that shader logs are lost when dispatching into a raw pass', ({ + root, + commandEncoder, + }) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => { + console.log(1); + }); + + root + .createComputePipeline({ compute: entryFn }) + .with(commandEncoder.beginComputePass()) + .dispatchWorkgroups(1); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Shader console.log output is ignored when dispatching into a raw compute pass encoder, since there is no submission to read it back after.', + ); + consoleWarnSpy.mockRestore(); + }); + + it('resolves timestamps into the same submission as the pass', ({ + root, + commandEncoder, + device, + }) => { + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const querySet = root.createQuerySet('timestamp', 2); + + root + .createComputePipeline({ compute: entryFn }) + .withTimestampWrites({ querySet }) + .withPerformanceCallback(() => {}) + .dispatchWorkgroups(1); + + // The resolve used to need a second encoder and a second submission. + expect(commandEncoder.resolveQuerySet).toHaveBeenCalledTimes(1); + expect(device.queue.submit).toHaveBeenCalledTimes(1); + }); + + it('defers timestamp resolution to the encoder it was given', ({ root, commandEncoder }) => { + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const querySet = root.createQuerySet('timestamp', 2); + + const encoder = root['~unstable'].createCommandEncoder(); + root + .createComputePipeline({ compute: entryFn }) + .withTimestampWrites({ querySet }) + .withPerformanceCallback(() => {}) + .with(encoder) + .dispatchWorkgroups(1); + + // Nothing resolved yet - the caller has not submitted. + expect(commandEncoder.resolveQuerySet).not.toHaveBeenCalled(); + + encoder.submit(); + expect(commandEncoder.resolveQuerySet).toHaveBeenCalledTimes(1); + }); + + it('warns that a performance callback cannot be reported on a raw encoder', ({ + root, + commandEncoder, + }) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const querySet = root.createQuerySet('timestamp', 2); + + root + .createComputePipeline({ compute: entryFn }) + .withTimestampWrites({ querySet }) + .withPerformanceCallback(() => {}) + .with(commandEncoder) + .dispatchWorkgroups(1); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + "The performance callback is ignored when recording into a raw GPUCommandEncoder, since there is no submission to report after. Use root['~unstable'].createCommandEncoder() instead.", + ); + consoleWarnSpy.mockRestore(); + }); + + it('re-applies state on every dispatch into a raw compute pass', ({ root, commandEncoder }) => { + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const rawPass = commandEncoder.beginComputePass(); + + const pipeline = root.createComputePipeline({ compute: entryFn }).with(rawPass); + pipeline.dispatchWorkgroups(1); + pipeline.dispatchWorkgroups(2); + + // The caller owns the pass and can mutate it between dispatches, so nothing + // about its state can be assumed - same as after `root.unwrap(pass)`. + expect(rawPass.setPipeline).toHaveBeenCalledTimes(2); + expect(rawPass.dispatchWorkgroups).toHaveBeenCalledTimes(2); + }); + it('should setup timestamp writes in compute pass descriptor', ({ root, commandEncoder }) => { const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index 58a1569af6..bce9a6551b 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -520,7 +520,7 @@ describe('render pipeline behavior', () => { //@ts-expect-error: No index buffer assigned expect(() => pipeline.drawIndexed(3)).toThrowErrorMatchingInlineSnapshot( - `[Error: No index buffer set for this render pipeline.]`, + `[Error: No index buffer is set. Call pipeline.withIndexBuffer or pass.setIndexBuffer before drawing indexed geometry.]`, ); const indexBuffer = root.createBuffer(d.arrayOf(d.u16, 2)).$usage('index'); @@ -1402,7 +1402,7 @@ describe('Render Bundles', () => { expect(encoder.draw).toHaveBeenCalledWith(6, undefined, undefined, undefined); }); - it('skips redundant state application when same pipeline draws twice (dirty flag)', ({ + it('re-applies state on every draw into a raw bundle encoder', ({ root, renderBundleEncoder, }) => { @@ -1416,7 +1416,9 @@ describe('Render Bundles', () => { draw: ReturnType; }; - expect(encoder.setPipeline).toHaveBeenCalledTimes(1); + // The caller owns the encoder and can mutate it between draws, so nothing + // about its state can be assumed - same as after `root.unwrap(pass)`. + expect(encoder.setPipeline).toHaveBeenCalledTimes(2); expect(encoder.draw).toHaveBeenCalledTimes(2); }); @@ -1484,6 +1486,45 @@ describe('Render Bundles', () => { expect(encoder.drawIndexed).toHaveBeenCalledWith(6, undefined, undefined, undefined, undefined); }); + it('defaults the depth/stencil operations of the pass it begins itself', ({ + root, + commandEncoder, + }) => { + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); + + createPipeline(root).withDepthStencilAttachment({ view: depthTexture }).draw(3); + + const rawDescriptor = ( + commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] + )?.[0] as GPURenderPassDescriptor; + + expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); + expect(rawDescriptor.depthStencilAttachment?.depthStoreOp).toBe('store'); + expect(rawDescriptor.depthStencilAttachment?.depthClearValue).toBe(1); + }); + + it('binds to a typed bundle pass', ({ root, renderBundleEncoder }) => { + const pipeline = createPipeline(root); + + root['~unstable'].beginRenderBundleEncoder({ colorFormats: ['rgba8unorm'] }, (pass) => { + const withPass = pipeline.with(pass); + withPass.draw(6); + withPass.draw(3); + }); + + const encoder = renderBundleEncoder as unknown as { + setPipeline: ReturnType; + draw: ReturnType; + }; + + // Both draws land on the bundle encoder, and - unlike a raw one, which we + // do not own - the typed pass lets the second draw reuse the applied state. + expect(encoder.setPipeline).toHaveBeenCalledTimes(1); + expect(encoder.draw).toHaveBeenCalledTimes(2); + }); + it('creates its own render pass when using external command encoder', ({ root, commandEncoder, diff --git a/packages/typegpu/tests/root.test.ts b/packages/typegpu/tests/root.test.ts index 55fc8c6bda..ae38e62ec4 100644 --- a/packages/typegpu/tests/root.test.ts +++ b/packages/typegpu/tests/root.test.ts @@ -195,7 +195,7 @@ describe('TgpuRoot', () => { }); }); - describe('beginRenderPass', () => { + describe('createCommandEncoder', () => { const layout = tgpu.bindGroupLayout({ foo: { uniform: d.f32 } }); // A vertex function that is using entries from the layout @@ -229,21 +229,19 @@ describe('TgpuRoot', () => { fragment: mainFragment, }); - root.beginRenderPass( - { - colorAttachments: [], - }, - (pass) => { - pass.setPipeline(pipeline); - pass.setBindGroup(layout, group); - pass.draw(1); - }, - ); + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setPipeline(pipeline); + pass.setBindGroup(layout, group); + pass.draw(1); + pass.end(); + encoder.submit(); const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] ?.value as GPURenderPassEncoder; expect(renderPassMock.setPipeline).toBeCalled(); expect(renderPassMock.setBindGroup).not.toBeCalled(); + expect(renderPassMock.end).toBeCalled(); }); it('accepts bind groups that are used in the shader', ({ root, commandEncoder }) => { @@ -256,16 +254,13 @@ describe('TgpuRoot', () => { fragment: mainFragment, }); - root.beginRenderPass( - { - colorAttachments: [], - }, - (pass) => { - pass.setPipeline(pipeline); - pass.setBindGroup(layout, group); - pass.draw(1); - }, - ); + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setPipeline(pipeline); + pass.setBindGroup(layout, group); + pass.draw(1); + pass.end(); + encoder.submit(); const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] ?.value as GPURenderPassEncoder; @@ -286,15 +281,12 @@ describe('TgpuRoot', () => { }) .with(group); - root.beginRenderPass( - { - colorAttachments: [], - }, - (pass) => { - pass.setPipeline(pipeline); - pass.draw(1); - }, - ); + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setPipeline(pipeline); + pass.draw(1); + pass.end(); + encoder.submit(); const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] ?.value as GPURenderPassEncoder; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b581ab2042..26295c0a2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,8 +48,8 @@ catalogs: specifier: ^0.181.0 version: 0.181.0 '@webgpu/types': - specifier: ^0.1.66 - version: 0.1.69 + specifier: ^0.1.71 + version: 0.1.71 overrides: rollup: ^4.60.0 @@ -82,7 +82,7 @@ importers: version: 3.1.2(@vitest/browser@3.2.4(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.10.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3))(vitest@4.1.2))(vitest@4.1.2) '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 bun: specifier: 'catalog:' version: 1.3.10 @@ -198,7 +198,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 typescript: specifier: npm:tsover@^6.0.2 version: tsover@6.0.2 @@ -465,7 +465,7 @@ importers: version: 4.1.2(vite@8.0.5(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.10.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3))(vitest@4.1.2) '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 astro-vtbot: specifier: ^2.1.10 version: 2.1.10(prettier-plugin-astro@0.14.1)(prettier@3.9.5)(tsover@6.0.2) @@ -662,7 +662,7 @@ importers: version: 24.10.0 '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 arktype: specifier: 'catalog:' version: 2.1.28 @@ -736,7 +736,7 @@ importers: version: link:../tgpu-dev-cli '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 typegpu: specifier: workspace:* version: link:../typegpu @@ -758,7 +758,7 @@ importers: version: link:../tgpu-dev-cli '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 typegpu: specifier: workspace:* version: link:../typegpu @@ -780,7 +780,7 @@ importers: version: link:../tgpu-dev-cli '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 tinyest: specifier: workspace:^0.3.0 version: link:../tinyest @@ -811,7 +811,7 @@ importers: version: link:../tgpu-dev-cli '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 typegpu: specifier: workspace:* version: link:../typegpu @@ -833,7 +833,7 @@ importers: version: link:../tgpu-dev-cli '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 tsdown: specifier: catalog:build version: 0.15.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(tsover@6.0.2)(unrun@0.2.31(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)) @@ -871,7 +871,7 @@ importers: version: 19.1.6(@types/react@19.1.8) '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 jiti: specifier: catalog:build version: 2.6.1 @@ -911,7 +911,7 @@ importers: version: link:../tgpu-dev-cli '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 tsdown: specifier: catalog:build version: 0.15.12(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(tsover@6.0.2)(unrun@0.2.31(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)) @@ -933,7 +933,7 @@ importers: version: link:../tgpu-dev-cli '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 typegpu: specifier: workspace:* version: link:../typegpu @@ -978,7 +978,7 @@ importers: version: 0.181.0 '@webgpu/types': specifier: catalog:types - version: 0.1.69 + version: 0.1.71 typegpu: specifier: workspace:* version: link:../typegpu @@ -4635,6 +4635,9 @@ packages: '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@webgpu/types@0.1.71': + resolution: {integrity: sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==} + '@webpack-cli/configtest@3.0.1': resolution: {integrity: sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==} engines: {node: '>=18.12.0'} @@ -12797,6 +12800,8 @@ snapshots: '@webgpu/types@0.1.69': {} + '@webgpu/types@0.1.71': {} + '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.104.1)': dependencies: webpack: 5.104.1(esbuild@0.25.12)(webpack-cli@6.0.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cca3b3e826..6444f4d7e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,7 +35,7 @@ catalogs: types: '@types/node': ^24.10.0 '@types/three': ^0.181.0 - '@webgpu/types': ^0.1.66 + '@webgpu/types': ^0.1.71 typescript: npm:tsover@^6.0.2 overrides: From e14ccd2c4b4fe0eda612f2069e075ea216d5c247 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 27 Jul 2026 04:48:13 +0200 Subject: [PATCH 02/10] trim encoder scaffolding --- .../src/core/commandEncoder/commandEncoder.ts | 30 +++------ .../src/core/commandEncoder/computePass.ts | 28 ++------- .../src/core/commandEncoder/renderPass.ts | 50 ++++----------- .../typegpu/src/core/pipeline/drawState.ts | 63 +++++++++---------- .../typegpu/src/core/pipeline/timeable.ts | 26 -------- packages/typegpu/src/shared/warnOnce.ts | 18 ++++++ 6 files changed, 72 insertions(+), 143 deletions(-) create mode 100644 packages/typegpu/src/shared/warnOnce.ts diff --git a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts index fb0ae0e641..f9037455a9 100644 --- a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -1,4 +1,5 @@ import { $internal } from '../../shared/symbols.ts'; +import { warnOnce } from '../../shared/warnOnce.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { INTERNAL_beginComputePass, @@ -63,15 +64,10 @@ export interface TgpuCommandEncoder { */ beginRenderPass(descriptor: TgpuRenderPassDescriptor): TgpuRenderPass; - /** - * Begins recording a compute pass. - */ + /** Begins recording a compute pass */ beginComputePass(descriptor?: TgpuComputePassDescriptor): TgpuComputePass; - /** - * Finishes the recording and submits the resulting command buffer - * to the device queue. - */ + /** Finishes the recording and submits the resulting command buffer to the device queue */ submit(): void; /** @@ -88,8 +84,6 @@ export function INTERNAL_createCommandEncoder( return new TgpuCommandEncoderImpl(root, root.device.createCommandEncoder(descriptor), false); } -const adoptedCommandEncoders = new WeakMap(); - /** * Wraps a raw command encoder the user owns, so that passes begun on it take * the same route as passes begun on a TypeGPU encoder. Submission stays the @@ -99,22 +93,13 @@ export function INTERNAL_adoptCommandEncoder( root: ExperimentalTgpuRoot, rawEncoder: GPUCommandEncoder, ): TgpuCommandEncoder { - let adopted = adoptedCommandEncoders.get(rawEncoder); - - if (adopted === undefined) { - adopted = new TgpuCommandEncoderImpl(root, rawEncoder, true); - adoptedCommandEncoders.set(rawEncoder, adopted); - } - - return adopted; + return new TgpuCommandEncoderImpl(root, rawEncoder, true); } // -------------- // Implementation // -------------- -const _warnedFinishWithPendingWork = new WeakSet(); - class TgpuCommandEncoderImpl implements TgpuCommandEncoder { readonly [$internal]: CommandEncoderInternals; readonly resourceType = 'command-encoder'; @@ -162,9 +147,10 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { const { rawEncoder, afterSubmit } = this[$internal]; this.#recordPendingCommands(); - if (afterSubmit.size > 0 && !_warnedFinishWithPendingWork.has(this)) { - _warnedFinishWithPendingWork.add(this); - console.warn( + if (afterSubmit.size > 0) { + warnOnce( + this, + 'finishWithPendingWork', 'Shader console.log output and performance callbacks do not fire for command buffers produced by encoder.finish(). Use encoder.submit() instead.', ); } diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts index 1c577746dc..13e504a915 100644 --- a/packages/typegpu/src/core/commandEncoder/computePass.ts +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -48,18 +48,12 @@ export interface TgpuComputePass { readonly [$internal]: ComputePassInternals; readonly resourceType: 'compute-pass'; - /** - * Sets the current {@link TgpuComputePipeline} for subsequent dispatches. - */ + /** Sets the current {@link TgpuComputePipeline} for subsequent dispatches */ setPipeline(pipeline: TgpuComputePipeline): void; - /** - * Associates a bind group (with the layout it was created from) for subsequent dispatches. - */ + /** Associates a bind group with the layout it was created from */ setBindGroup(bindGroup: TgpuBindGroup): void; - /** - * Associates a bind group with the given layout for subsequent dispatches. - */ + /** Associates a bind group with the given layout */ setBindGroup>( bindGroupLayout: TgpuBindGroupLayout, bindGroup: TgpuBindGroup | GPUBindGroup, @@ -68,9 +62,7 @@ export interface TgpuComputePass { dispatchWorkgroups(x: number, y?: number, z?: number): void; dispatchWorkgroupsIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; - /** - * Completes the recording of this compute pass. - */ + /** Completes the recording of this compute pass */ end(): void; } @@ -99,8 +91,6 @@ export function INTERNAL_beginComputePass( return new TgpuComputePassImpl(root, rawEncoder.beginComputePass(rawDescriptor), encoder); } -const adoptedComputePasses = new WeakMap(); - /** * Wraps a raw compute pass encoder the user owns, so that dispatches recorded * into it take the same route as dispatches into a TypeGPU pass. The state is @@ -111,14 +101,8 @@ export function INTERNAL_adoptComputePass( root: ExperimentalTgpuRoot, rawPass: GPUComputePassEncoder, ): TgpuComputePass { - let adopted = adoptedComputePasses.get(rawPass); - - if (adopted === undefined) { - adopted = new TgpuComputePassImpl(root, rawPass, undefined); - adopted[$internal].state.rawAccessed = true; - adoptedComputePasses.set(rawPass, adopted); - } - + const adopted = new TgpuComputePassImpl(root, rawPass, undefined); + adopted[$internal].state.rawAccessed = true; return adopted; } diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts index d609c8ba00..0bb92d83ea 100644 --- a/packages/typegpu/src/core/commandEncoder/renderPass.ts +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -73,28 +73,18 @@ export interface TgpuRenderCommands { readonly [$internal]: RenderPassInternals; readonly resourceType: 'render-pass' | 'render-bundle-pass'; - /** - * Sets the current {@link TgpuRenderPipeline} for subsequent draw calls. - */ + /** Sets the current {@link TgpuRenderPipeline} for subsequent draw calls */ setPipeline(pipeline: TgpuRenderPipeline): void; - /** - * Associates a bind group (with the layout it was created from) for subsequent draw calls. - */ + /** Associates a bind group with the layout it was created from */ setBindGroup(bindGroup: TgpuBindGroup): void; - /** - * Associates a bind group with the given layout for subsequent draw calls. - */ + /** Associates a bind group with the given layout */ setBindGroup>( bindGroupLayout: TgpuBindGroupLayout, bindGroup: TgpuBindGroup | GPUBindGroup, ): void; - /** - * Binds a vertex buffer to the given vertex layout for subsequent draw calls. - * @param offset - Offset in bytes into `buffer`. Defaults to `0`. - * @param size - Size in bytes to bind. Defaults to the remainder of the buffer. - */ + /** Binds a vertex buffer to the given vertex layout */ setVertexBuffer( vertexLayout: TgpuVertexLayout, buffer: (TgpuBuffer & VertexFlag) | GPUBuffer, @@ -102,12 +92,7 @@ export interface TgpuRenderCommands { size?: number, ): void; - /** - * Sets the current index buffer. - * @param offset - Offset in bytes into `buffer` where the index data begins. Defaults to `0`. - * @param size - Size in bytes of the index data in `buffer`. - * Defaults to the size of the buffer minus the offset. - */ + /** Sets the current index buffer */ setIndexBuffer( buffer: TgpuBuffer | GPUBuffer, indexFormat: GPUIndexFormat, @@ -164,9 +149,7 @@ export interface TgpuRenderPass extends TgpuRenderCommands { */ executeBundles(bundles: Iterable): void; - /** - * Completes the recording of this render pass. - */ + /** Completes the recording of this render pass */ end(): void; } @@ -302,11 +285,6 @@ export function INTERNAL_beginRenderBundlePass( return new TgpuRenderCommandsImpl(root, bundleEncoder, undefined); } -const adoptedRenderCommands = new WeakMap< - GPURenderPassEncoder | GPURenderBundleEncoder, - TgpuRenderCommands ->(); - /** * Wraps a raw pass encoder the user owns, so that draws recorded into it take * the same route as draws into a TypeGPU pass. The state is marked as @@ -316,17 +294,11 @@ export function INTERNAL_adoptRenderCommands( root: ExperimentalTgpuRoot, rawPass: GPURenderPassEncoder | GPURenderBundleEncoder, ): TgpuRenderCommands { - let adopted = adoptedRenderCommands.get(rawPass); - - if (adopted === undefined) { - adopted = - 'executeBundles' in rawPass - ? new TgpuRenderPassImpl(root, rawPass, undefined) - : new TgpuRenderCommandsImpl(root, rawPass, undefined); - adopted[$internal].state.rawAccessed = true; - adoptedRenderCommands.set(rawPass, adopted); - } - + const adopted = + 'executeBundles' in rawPass + ? new TgpuRenderPassImpl(root, rawPass, undefined) + : new TgpuRenderCommandsImpl(root, rawPass, undefined); + adopted[$internal].state.rawAccessed = true; return adopted; } diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts index b275009c47..d800648ae2 100644 --- a/packages/typegpu/src/core/pipeline/drawState.ts +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -1,4 +1,5 @@ import { $internal } from '../../shared/symbols.ts'; +import { warnOnce } from '../../shared/warnOnce.ts'; import type { TgpuBindGroup, TgpuBindGroupLayout } from '../../tgpuBindGroupLayout.ts'; import { logDataFromGPU } from '../../tgsl/consoleLog/deserializers.ts'; import type { LogResources } from '../../tgsl/consoleLog/types.ts'; @@ -117,29 +118,14 @@ export function requireIndexBuffer( } } -const _warnedIgnoredTimestamps = new WeakSet(); -const _warnedIgnoredLogs = new WeakSet(); - -const _warnedUnreachableSubmission = new WeakMap>(); - /** * Warns that work which can only be reported after submission is lost, because * the raw encoder belongs to the caller and is submitted behind our back. */ export function warnAboutUnreachableSubmission(core: object, what: string): void { - let warned = _warnedUnreachableSubmission.get(core); - - if (!warned) { - warned = new Set(); - _warnedUnreachableSubmission.set(core, warned); - } - - if (warned.has(what)) { - return; - } - warned.add(what); - - console.warn( + warnOnce( + core, + what, `${what} is ignored when recording into a raw GPUCommandEncoder, since there is no submission to report after. Use root['~unstable'].createCommandEncoder() instead.`, ); } @@ -167,6 +153,19 @@ export function queueLogDrain( * are read back after submission, so they can still be drained as long as the * pass belongs to a TypeGPU encoder. */ +const PassKindWording = { + render: { + into: 'drawing into a render pass', + intoRaw: 'drawing into a raw render pass', + begin: 'beginRenderPass', + }, + compute: { + into: 'dispatching into a compute pass', + intoRaw: 'dispatching into a raw compute pass', + begin: 'beginComputePass', + }, +} as const; + function reportIgnoredPriors( core: object, owner: TgpuCommandEncoder | undefined, @@ -174,25 +173,21 @@ function reportIgnoredPriors( logResources: LogResources | undefined, passKind: 'render' | 'compute', ): void { - if (hasTimestampWrites && !_warnedIgnoredTimestamps.has(core)) { - _warnedIgnoredTimestamps.add(core); - console.warn( - `Pipeline-level timestamp writes are ignored when ${ - passKind === 'render' ? 'drawing into a render pass' : 'dispatching into a compute pass' - }. Pass \`timestampWrites\` to encoder.begin${ - passKind === 'render' ? 'Render' : 'Compute' - }Pass instead.`, + const wording = PassKindWording[passKind]; + + if (hasTimestampWrites) { + warnOnce( + core, + 'timestampWrites', + `Pipeline-level timestamp writes are ignored when ${wording.into}. Pass \`timestampWrites\` to encoder.${wording.begin} instead.`, ); } - if (logResources && !queueLogDrain(owner, logResources) && !_warnedIgnoredLogs.has(core)) { - _warnedIgnoredLogs.add(core); - console.warn( - `Shader console.log output is ignored when ${ - passKind === 'render' - ? 'drawing into a raw render pass' - : 'dispatching into a raw compute pass' - } encoder, since there is no submission to read it back after.`, + if (logResources && !queueLogDrain(owner, logResources)) { + warnOnce( + core, + 'logs', + `Shader console.log output is ignored when ${wording.intoRaw} encoder, since there is no submission to read it back after.`, ); } } diff --git a/packages/typegpu/src/core/pipeline/timeable.ts b/packages/typegpu/src/core/pipeline/timeable.ts index 5f7de2dcae..075056ccc3 100644 --- a/packages/typegpu/src/core/pipeline/timeable.ts +++ b/packages/typegpu/src/core/pipeline/timeable.ts @@ -77,32 +77,6 @@ export function createWithTimestampWrites( }; } -export function setupTimestampWrites( - priors: TimestampWritesPriors, - root: ExperimentalTgpuRoot, -): { - timestampWrites?: GPUComputePassTimestampWrites | GPURenderPassTimestampWrites; -} { - if (!priors.timestampWrites) { - return {}; - } - - const { querySet, beginningOfPassWriteIndex, endOfPassWriteIndex } = priors.timestampWrites; - - const timestampWrites: GPUComputePassTimestampWrites | GPURenderPassTimestampWrites = { - querySet: isQuerySet(querySet) ? root.unwrap(querySet) : querySet, - }; - - if (beginningOfPassWriteIndex !== undefined) { - timestampWrites.beginningOfPassWriteIndex = beginningOfPassWriteIndex; - } - if (endOfPassWriteIndex !== undefined) { - timestampWrites.endOfPassWriteIndex = endOfPassWriteIndex; - } - - return { timestampWrites }; -} - async function readTimestamps( root: ExperimentalTgpuRoot, querySet: TgpuQuerySet<'timestamp'>, diff --git a/packages/typegpu/src/shared/warnOnce.ts b/packages/typegpu/src/shared/warnOnce.ts new file mode 100644 index 0000000000..69307e329a --- /dev/null +++ b/packages/typegpu/src/shared/warnOnce.ts @@ -0,0 +1,18 @@ +const _warned = new WeakMap>(); + +/** Emits a warning at most once per key and tag. */ +export function warnOnce(key: object, tag: string, message: string): void { + let tags = _warned.get(key); + + if (!tags) { + tags = new Set(); + _warned.set(key, tags); + } + + if (tags.has(tag)) { + return; + } + tags.add(tag); + + console.warn(message); +} From 826b432cb5f3c10112bcb03a34d29d780e230c43 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 27 Jul 2026 17:44:15 +0200 Subject: [PATCH 03/10] fixes --- .../docs/advanced/timestamp-queries.mdx | 2 +- .../src/content/docs/apis/pipelines.mdx | 14 +-- .../src/core/commandEncoder/attachments.ts | 4 +- .../src/core/commandEncoder/commandEncoder.ts | 9 +- .../src/core/commandEncoder/computePass.ts | 5 +- .../src/core/commandEncoder/renderPass.ts | 7 +- .../src/core/pipeline/applyPipelineState.ts | 3 - .../src/core/pipeline/computePipeline.ts | 86 +++++--------- .../typegpu/src/core/pipeline/drawState.ts | 23 ++-- .../src/core/pipeline/renderPipeline.ts | 106 ++++++------------ packages/typegpu/src/core/root/init.ts | 2 +- packages/typegpu/src/shared/warnOnce.ts | 2 +- .../typegpu/tests/computePipeline.test.ts | 7 +- packages/typegpu/tests/renderPipeline.test.ts | 8 +- 14 files changed, 99 insertions(+), 179 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx b/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx index f3fbd37c65..965839733b 100644 --- a/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx +++ b/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx @@ -46,7 +46,7 @@ const pipeline = root If you haven’t provided a `TgpuQuerySet` before calling `.withPerformanceCallback()`, TypeGPU will allocate one for you along with the necessary resolve buffers. * **With a command encoder** - Performance callbacks also work when the pipeline is bound to a [command encoder](/TypeGPU/apis/pipelines/#command-encoders-and-passes) with `pipeline.with(encoder)`. The timestamps are resolved as part of that encoder's submission, and the callback fires from `encoder.submit()`. They cannot be used with a pass begun by someone else — a pass writes its timestamps as part of its descriptor, so pass those to `encoder.beginRenderPass` / `encoder.beginComputePass` instead. + Performance callbacks also work when the pipeline is bound to a [command encoder](/TypeGPU/apis/pipelines/#command-encoders-and-passes) with `pipeline.with(encoder)`. The timestamps are resolved as part of that encoder's submission, and the callback fires from `encoder.submit()`. They cannot be used with a pass begun by someone else, since a pass writes its timestamps as part of its descriptor. Give those to `encoder.beginRenderPass` / `encoder.beginComputePass` instead. ## Using `TgpuQuerySet` diff --git a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx index d3f76e9ea0..3c9c8df1c5 100644 --- a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx @@ -565,7 +565,7 @@ The caller remains responsible for ending the pass or finishing the bundle. ::: When executed directly via `draw` or `dispatchWorkgroups`, each pipeline records its own pass into its own command encoder and submits it immediately. -For more demanding scenarios — batching multiple pipelines into a single render pass, or multiple passes into a single submission — TypeGPU exposes a command encoder API that mirrors WebGPU, enriched with direct TypeGPU resource support. +For more demanding scenarios, such as batching multiple pipelines into a single render pass, or multiple passes into a single submission, TypeGPU exposes a command encoder API that mirrors WebGPU, enriched with direct TypeGPU resource support. ```ts const encoder = root['~unstable'].createCommandEncoder(); @@ -605,9 +605,9 @@ pass.setVertexBuffer(vertexLayout, vertexBuffer); pass.draw(3); ``` -In both styles, the underlying pipeline, bind groups and vertex buffers are applied lazily and deduplicated — repeated draws only re-record what actually changed. Pipeline-level bindings (`pipeline.with(bindGroup)`) take precedence over pass-level ones (`pass.setBindGroup`). +In both styles, the underlying pipeline, bind groups and vertex buffers are applied lazily and deduplicated, so repeated draws only re-record what actually changed. Pipeline-level bindings (`pipeline.with(bindGroup)`) take precedence over pass-level ones (`pass.setBindGroup`). -Note that `pipeline.with(pass)` returns a new pipeline wrapper on every call — hoist it out of draw loops (`const bound = pipeline.with(pass)`) so deduplication can kick in. For values that change between draws, prefer pass-level state (`pass.setBindGroup`) over the allocating `with*` methods. +Note that `pipeline.with(pass)` returns a new pipeline wrapper on every call, so hoist it out of draw loops (`const bound = pipeline.with(pass)`) to let deduplication kick in. For values that change between draws, prefer pass-level state (`pass.setBindGroup`) over the allocating `with*` methods. Compute passes work the same way: @@ -619,13 +619,13 @@ pass.end(); encoder.submit(); ``` -Work that can only be reported once the GPU has been given the commands — shader `console.log` output and [performance callbacks](/TypeGPU/advanced/timestamp-queries/) — is carried out by `encoder.submit()`. -Timestamps are resolved as part of that same submission, so no extra command buffer is needed for them. +`encoder.submit()` carries out the work that can only be reported once the GPU has been given the commands: shader `console.log` output and [performance callbacks](/TypeGPU/advanced/timestamp-queries/). +Timestamps are resolved as part of that same submission. For anything not covered by the typed surface, there are escape hatches: -- `root.unwrap(encoder)`, `root.unwrap(pass)` — access the raw `GPUCommandEncoder`, `GPURenderPassEncoder` or `GPUComputePassEncoder`, e.g. for buffer copies. Since raw pass commands can change state invisibly to TypeGPU, unwrapping a pass turns off state deduplication for it — every subsequent typed draw re-applies its full state. -- `encoder.finish()` — returns the `GPUCommandBuffer` without submitting, for manual multi-encoder batching via `device.queue.submit([...])`. Since TypeGPU never sees the submission, shader logs and performance callbacks do not fire for such a command buffer. +- `root.unwrap(encoder)`, `root.unwrap(pass)`: access the raw `GPUCommandEncoder`, `GPURenderPassEncoder` or `GPUComputePassEncoder`, e.g. for buffer copies. Since raw pass commands can change state invisibly to TypeGPU, unwrapping a pass turns off state deduplication for it, and every subsequent typed draw re-applies its full state. +- `encoder.finish()`: returns the `GPUCommandBuffer` without submitting, for manual multi-encoder batching via `device.queue.submit([...])`. Since TypeGPU never sees the submission, shader logs and performance callbacks do not fire for such a command buffer. Passing a raw `GPUCommandEncoder` or a raw pass encoder to `pipeline.with(...)` works too, but comes with the same two limitations: TypeGPU cannot know when the caller submits, and cannot assume anything about state the caller may have set, so every draw re-applies its full state. diff --git a/packages/typegpu/src/core/commandEncoder/attachments.ts b/packages/typegpu/src/core/commandEncoder/attachments.ts index d5df7ad912..2a09dc8aad 100644 --- a/packages/typegpu/src/core/commandEncoder/attachments.ts +++ b/packages/typegpu/src/core/commandEncoder/attachments.ts @@ -34,10 +34,10 @@ export interface TgpuPassTimestampWrites { export function unwrapTimestampWrites( root: ExperimentalTgpuRoot, timestampWrites: TgpuPassTimestampWrites, -): GPURenderPassTimestampWrites | GPUComputePassTimestampWrites { +): GPURenderPassTimestampWrites { const { querySet, beginningOfPassWriteIndex, endOfPassWriteIndex } = timestampWrites; - const result: GPURenderPassTimestampWrites | GPUComputePassTimestampWrites = { + const result: GPURenderPassTimestampWrites = { querySet: isQuerySet(querySet) ? root.unwrap(querySet) : querySet, }; diff --git a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts index f9037455a9..31d78b2edf 100644 --- a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -24,14 +24,9 @@ export interface CommandEncoderInternals { * of our hands and no work can be deferred to it. */ readonly adopted: boolean; - /** - * Commands recorded just before the encoder is finished, keyed for deduplication. - */ + /** Commands recorded just before the encoder is finished, keyed for deduplication */ readonly beforeFinish: Map void>; - /** - * Callbacks run once the recorded commands have been submitted, keyed for - * deduplication. - */ + /** Callbacks run once the recorded commands have been submitted, keyed for deduplication */ readonly afterSubmit: Map void>; } diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts index 13e504a915..6f167703dd 100644 --- a/packages/typegpu/src/core/commandEncoder/computePass.ts +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -82,10 +82,7 @@ export function INTERNAL_beginComputePass( } if (descriptor?.timestampWrites !== undefined) { - rawDescriptor.timestampWrites = unwrapTimestampWrites( - root, - descriptor.timestampWrites, - ) as GPUComputePassTimestampWrites; + rawDescriptor.timestampWrites = unwrapTimestampWrites(root, descriptor.timestampWrites); } return new TgpuComputePassImpl(root, rawEncoder.beginComputePass(rawDescriptor), encoder); diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts index 0bb92d83ea..5ede93d677 100644 --- a/packages/typegpu/src/core/commandEncoder/renderPass.ts +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -67,7 +67,7 @@ export interface RenderPassInternals< * * Draw either by binding TypeGPU pipelines to it (`pipeline.with(pass).draw(...)`), * or proxy-style via `pass.setPipeline(pipeline)` followed by `pass.draw(...)`. - * Pipeline resolution is lazy - shaders compile on the first draw. + * Pipeline resolution is lazy: shaders compile on the first draw. */ export interface TgpuRenderCommands { readonly [$internal]: RenderPassInternals; @@ -265,10 +265,7 @@ export function INTERNAL_beginRenderPass( } if (descriptor.timestampWrites !== undefined) { - rawDescriptor.timestampWrites = unwrapTimestampWrites( - root, - descriptor.timestampWrites, - ) as GPURenderPassTimestampWrites; + rawDescriptor.timestampWrites = unwrapTimestampWrites(root, descriptor.timestampWrites); } if (descriptor.maxDrawCount !== undefined) { diff --git a/packages/typegpu/src/core/pipeline/applyPipelineState.ts b/packages/typegpu/src/core/pipeline/applyPipelineState.ts index 87ce844cb7..58aa7038e8 100644 --- a/packages/typegpu/src/core/pipeline/applyPipelineState.ts +++ b/packages/typegpu/src/core/pipeline/applyPipelineState.ts @@ -10,7 +10,6 @@ import type { BaseData } from '../../data/wgslTypes.ts'; import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; -import { warnIfOverflow } from './limitsOverflow.ts'; // ----------------------------------------------- // shared helpers for applying pipeline state to render/compute pass encoders @@ -57,8 +56,6 @@ export function applyBindGroups( ): void { const missingBindGroups = new Set(usedBindGroupLayouts); - warnIfOverflow(usedBindGroupLayouts, root.device.limits); - usedBindGroupLayouts.forEach((layout, idx) => { if (catchall && idx === catchall[0]) { encoder.setBindGroup(idx, root.unwrap(catchall[1])); diff --git a/packages/typegpu/src/core/pipeline/computePipeline.ts b/packages/typegpu/src/core/pipeline/computePipeline.ts index 792929e3fe..0c6044f41a 100644 --- a/packages/typegpu/src/core/pipeline/computePipeline.ts +++ b/packages/typegpu/src/core/pipeline/computePipeline.ts @@ -37,6 +37,7 @@ import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import type { TgpuSlot } from '../slot/slotTypes.ts'; import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import { warnIfOverflow } from './limitsOverflow.ts'; import { resolveIndirectOffset } from './pipelineUtils.ts'; import { createWithPerformanceCallback, @@ -55,7 +56,6 @@ import { logger } from '../../tgpuLogger.ts'; export interface ComputePipelineInternals { readonly core: ComputePipelineCore; - readonly rawPipeline: GPUComputePipeline; readonly priors: TgpuComputePipelinePriors & TimestampWritesPriors; readonly root: ExperimentalTgpuRoot; } @@ -140,9 +140,9 @@ export function INTERNAL_createComputePipeline( type TgpuComputePipelinePriors = { readonly bindGroupLayoutMap?: Map; - /** A pass the pipeline dispatches into, but does not own. */ + /** A pass the pipeline dispatches into, but does not own */ readonly pass?: TgpuComputePass | undefined; - /** An encoder the pipeline records its own passes into, but does not submit. */ + /** An encoder the pipeline records its own passes into, but does not submit */ readonly encoder?: TgpuCommandEncoder | undefined; } & TimestampWritesPriors; @@ -165,18 +165,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { this.#core = core; this.#priors = priors; - this[$internal] = { - core, - get rawPipeline() { - return core.unwrap().pipeline; - }, - get priors() { - return priors; - }, - get root() { - return core.root; - }, - }; + this[$internal] = { core, priors, root: core.root }; this[$getNameForward] = core; } @@ -188,17 +177,9 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { return `computePipeline:${getName(this) ?? ''}`; } - get rawPipeline(): GPUComputePipeline { - return this.#core.unwrap().pipeline; - } - - /** Rebinds the target this pipeline records into. The two are mutually exclusive. */ - #withTarget(target: { pass?: TgpuComputePass; encoder?: TgpuCommandEncoder }): this { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - pass: target.pass, - encoder: target.encoder, - }) as this; + /** Derives a pipeline sharing this one's core, with the given priors overridden */ + #withPriors(patch: Partial): this { + return new TgpuComputePipelineImpl(this.#core, { ...this.#priors, ...patch }) as this; } with>( @@ -222,46 +203,39 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { bindGroup?: TgpuBindGroup | GPUBindGroup, ): this { if (isTgpuComputePass(first)) { - return this.#withTarget({ pass: first }); + return this.#withPriors({ pass: first, encoder: undefined }); } if (isTgpuCommandEncoder(first)) { - return this.#withTarget({ encoder: first }); + return this.#withPriors({ pass: undefined, encoder: first }); } if (isGPUComputePassEncoder(first)) { - return this.#withTarget({ pass: INTERNAL_adoptComputePass(this.#core.root, first) }); + return this.#withPriors({ + pass: INTERNAL_adoptComputePass(this.#core.root, first), + encoder: undefined, + }); } if (isGPUCommandEncoder(first)) { - return this.#withTarget({ encoder: INTERNAL_adoptCommandEncoder(this.#core.root, first) }); + return this.#withPriors({ + pass: undefined, + encoder: INTERNAL_adoptCommandEncoder(this.#core.root, first), + }); } - if (isBindGroup(first)) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - bindGroupLayoutMap: new Map([ - ...(this.#priors.bindGroupLayoutMap ?? []), - [first.layout, first], - ]), - }) as this; - } + const [layout, group] = isBindGroup(first) + ? [first.layout, first] + : [first, bindGroup as TgpuBindGroup | GPUBindGroup]; - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - bindGroupLayoutMap: new Map([ - ...(this.#priors.bindGroupLayoutMap ?? []), - [first, bindGroup as TgpuBindGroup | GPUBindGroup], - ]), - }) as this; + return this.#withPriors({ + bindGroupLayoutMap: new Map([...(this.#priors.bindGroupLayoutMap ?? []), [layout, group]]), + }); } withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this { if (this.#priors.timestampWrites) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - performanceCallback: callback, - }) as this; + return this.#withPriors({ performanceCallback: callback }); } const querySet = this.#core.performanceCallbackQuerySet; @@ -272,8 +246,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { ); return this; } - const newPriors = createWithPerformanceCallback(this.#priors, callback, querySet); - return new TgpuComputePipelineImpl(this.#core, newPriors) as this; + return this.#withPriors(createWithPerformanceCallback(this.#priors, callback, querySet)); } withTimestampWrites(options: { @@ -281,8 +254,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { beginningOfPassWriteIndex?: number; endOfPassWriteIndex?: number; }): this { - const newPriors = createWithTimestampWrites(this.#priors, options, this.#core.root); - return new TgpuComputePipelineImpl(this.#core, newPriors) as this; + return this.#withPriors(createWithTimestampWrites(this.#priors, options, this.#core.root)); } dispatchWorkgroups(x: number, y?: number, z?: number): void { @@ -316,8 +288,8 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { /** * The single route from a dispatch call to the GPU. Either the pipeline was - * given a pass to dispatch into, or it begins one of its own - and if it was - * not given an encoder either, it owns the submission too. + * given a pass to dispatch into, or it begins one of its own. If it was not + * given an encoder either, it owns the submission too. */ #execute(dispatch: (pass: GPUComputePassEncoder) => void): void { const { root } = this.#core; @@ -496,6 +468,8 @@ class ComputePipelineCore implements SelfResolvable { ); } + warnIfOverflow(usedBindGroupLayouts, device.limits); + const module = device.createShaderModule({ label: `${getName(this) ?? ''} - Shader`, code, diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts index d800648ae2..27237303ba 100644 --- a/packages/typegpu/src/core/pipeline/drawState.ts +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -25,8 +25,8 @@ export class RenderDrawState { indexBuffer: IndexBufferEntry | undefined; stencilReference: GPUStencilValue | undefined; /** - * The stencil reference currently set on the raw pass — 0 is the WebGPU - * default, and render bundles cannot change it, so it survives executeBundles. + * The stencil reference currently set on the raw pass. It starts at the + * WebGPU default of 0, and survives executeBundles, which cannot change it. */ appliedStencilReference: GPUStencilValue = 0; version = 0; @@ -147,12 +147,6 @@ export function queueLogDrain( return true; } -/** - * Reports the pass-level priors that a pipeline cannot honor, because the pass - * it draws into was begun by someone else. Shader logs are the exception: they - * are read back after submission, so they can still be drained as long as the - * pass belongs to a TypeGPU encoder. - */ const PassKindWording = { render: { into: 'drawing into a render pass', @@ -166,6 +160,12 @@ const PassKindWording = { }, } as const; +/** + * Reports the pass-level priors that a pipeline cannot honor, because the pass + * it draws into was begun by someone else. Shader logs are the exception: they + * are read back after submission, so they can still be drained as long as the + * pass belongs to a TypeGPU encoder. + */ function reportIgnoredPriors( core: object, owner: TgpuCommandEncoder | undefined, @@ -195,9 +195,6 @@ function reportIgnoredPriors( /** * Records a draw into a typed render pass, applying the pipeline's state * (and the pass's, where the pipeline does not override it) beforehand. - * The single route every draw takes, whether the pass is the pipeline's own, - * one it was handed (`pipeline.with(pass).draw()`), or one driving it - * (`pass.setPipeline(pipeline)` followed by `pass.draw()`). * * @param ownsPass - Whether the pipeline began this pass itself, and so honors * its own pass-level priors instead of dropping them. @@ -240,9 +237,7 @@ export function emitRenderDraw( emit(rawPass); } -/** - * The compute counterpart of {@link emitRenderDraw}. - */ +/** The compute counterpart of {@link emitRenderDraw} */ export function emitComputeDispatch( root: ExperimentalTgpuRoot, passInternals: ComputePassInternals, diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index 0a257c0108..525446b6f7 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -95,6 +95,7 @@ import { type TimestampWritesPriors, } from './timeable.ts'; import { type PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import { warnIfOverflow } from './limitsOverflow.ts'; import { resolveIndirectOffset } from './pipelineUtils.ts'; import { NullPerformanceTracker, @@ -493,9 +494,9 @@ type TgpuRenderPipelinePriors = { sizeBytes?: number | undefined; } | undefined; - /** A pass the pipeline draws into, but does not own. */ + /** A pass the pipeline draws into, but does not own */ readonly pass?: TgpuRenderCommands | undefined; - /** An encoder the pipeline records its own passes into, but does not submit. */ + /** An encoder the pipeline records its own passes into, but does not submit */ readonly encoder?: TgpuCommandEncoder | undefined; } & TimestampWritesPriors; @@ -536,15 +537,11 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { return this; } - /** Rebinds the target this pipeline records into. The two are mutually exclusive. */ - #withTarget(target: { pass?: TgpuRenderCommands; encoder?: TgpuCommandEncoder }): this { - const internals = this[$internal]; + /** Derives a pipeline sharing this one's core, with the given priors overridden */ + #withPriors(patch: Partial): this { + const { core, priors } = this[$internal]; - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - pass: target.pass, - encoder: target.encoder, - }) as this; + return new TgpuRenderPipelineImpl(core, { ...priors, ...patch }) as this; } with( @@ -578,53 +575,47 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { const internals = this[$internal]; if (isTgpuRenderCommands(first)) { - return this.#withTarget({ pass: first }); + return this.#withPriors({ pass: first, encoder: undefined }); } if (isTgpuCommandEncoder(first)) { - return this.#withTarget({ encoder: first }); + return this.#withPriors({ pass: undefined, encoder: first }); } if (isGPURenderPassEncoder(first) || isGPURenderBundleEncoder(first)) { - return this.#withTarget({ + return this.#withPriors({ pass: INTERNAL_adoptRenderCommands(internals.core.options.root, first), + encoder: undefined, }); } if (isGPUCommandEncoder(first)) { - return this.#withTarget({ + return this.#withPriors({ + pass: undefined, encoder: INTERNAL_adoptCommandEncoder(internals.core.options.root, first), }); } - if (isBindGroup(first)) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - bindGroupLayoutMap: new Map([ - ...(internals.priors.bindGroupLayoutMap ?? []), - [first.layout, first], - ]), - }) as this; - } + if (isBindGroup(first) || isBindGroupLayout(first)) { + const [layout, group] = isBindGroup(first) + ? [first.layout, first] + : [first, resource as TgpuBindGroup | GPUBindGroup]; - if (isBindGroupLayout(first)) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, + return this.#withPriors({ bindGroupLayoutMap: new Map([ ...(internals.priors.bindGroupLayoutMap ?? []), - [first, resource as TgpuBindGroup | GPUBindGroup], + [layout, group], ]), - }) as this; + }); } if (isVertexLayout(first)) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, + return this.#withPriors({ vertexLayoutMap: new Map([ ...(internals.priors.vertexLayoutMap ?? []), [first, resource as (TgpuBuffer & VertexFlag) | GPUBuffer], ]), - }) as this; + }); } throw new Error('Unsupported value passed into .with()'); @@ -634,10 +625,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { const internals = this[$internal]; if (internals.priors.timestampWrites) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - performanceCallback: callback, - }) as this; + return this.#withPriors({ performanceCallback: callback }); } const querySet = internals.core.performanceCallbackQuerySet; @@ -648,8 +636,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { ); return this; } - const newPriors = createWithPerformanceCallback(internals.priors, callback, querySet); - return new TgpuRenderPipelineImpl(internals.core, newPriors) as this; + return this.#withPriors(createWithPerformanceCallback(internals.priors, callback, querySet)); } withTimestampWrites(options: { @@ -658,39 +645,22 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { endOfPassWriteIndex?: number; }): this { const internals = this[$internal]; - const newPriors = createWithTimestampWrites( - internals.priors, - options, - internals.core.options.root, + + return this.#withPriors( + createWithTimestampWrites(internals.priors, options, internals.core.options.root), ); - return new TgpuRenderPipelineImpl(internals.core, newPriors) as this; } withColorAttachment(attachment: AnyFragmentColorAttachment): this { - const internals = this[$internal]; - - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - colorAttachment: attachment, - }) as this; + return this.#withPriors({ colorAttachment: attachment }); } withDepthStencilAttachment(attachment: DepthStencilAttachment): this { - const internals = this[$internal]; - - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - depthStencilAttachment: attachment, - }) as this; + return this.#withPriors({ depthStencilAttachment: attachment }); } withStencilReference(reference: GPUStencilValue): this { - const internals = this[$internal]; - - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - stencilReference: reference, - }) as this; + return this.#withPriors({ stencilReference: reference }); } withIndexBuffer( @@ -710,15 +680,12 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { offsetElementsOrSizeBytes?: number, sizeElementsOrUndefined?: number, ): this & HasIndexBuffer { - const internals = this[$internal]; - if (isGPUBuffer(buffer)) { if (typeof indexFormatOrOffset !== 'string') { throw new Error('If a GPUBuffer is passed, indexFormat must be provided.'); } - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, + return this.#withPriors({ indexBuffer: { buffer, indexFormat: indexFormatOrOffset, @@ -735,8 +702,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { const elementType = (buffer.dataType as WgslArray).elementType; - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, + return this.#withPriors({ indexBuffer: { buffer, indexFormat: dataTypeToIndexFormat[elementType.type], @@ -760,7 +726,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { this[$internal].core.initSync(); } - /** The descriptor of the pass this pipeline begins when it is not given one. */ + /** The descriptor of the pass this pipeline begins when it is not given one */ #ownPassDescriptor(): TgpuRenderPassDescriptor { const internals = this[$internal]; const { descriptor } = internals.core.options; @@ -782,8 +748,8 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { /** * The single route from a draw call to the GPU. Either the pipeline was - * given a pass to draw into, or it begins one of its own - and if it was not - * given an encoder either, it owns the submission too. + * given a pass to draw into, or it begins one of its own. If it was not given + * an encoder either, it owns the submission too. */ #execute( usesIndexBuffer: boolean, @@ -1043,6 +1009,8 @@ class RenderPipelineCore implements SelfResolvable { ); } + warnIfOverflow(usedBindGroupLayouts, device.limits); + const module = device.createShaderModule({ label: `${getName(this) ?? ''} - Shader`, code, diff --git a/packages/typegpu/src/core/root/init.ts b/packages/typegpu/src/core/root/init.ts index e7036ed7c4..49020ede00 100644 --- a/packages/typegpu/src/core/root/init.ts +++ b/packages/typegpu/src/core/root/init.ts @@ -493,7 +493,7 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu | GPUSampler | GPUQuerySet { if (isComputePipeline(resource)) { - return resource[$internal].rawPipeline; + return resource[$internal].core.unwrap().pipeline; } if (isTgpuCommandEncoder(resource)) { diff --git a/packages/typegpu/src/shared/warnOnce.ts b/packages/typegpu/src/shared/warnOnce.ts index 69307e329a..d93540bebf 100644 --- a/packages/typegpu/src/shared/warnOnce.ts +++ b/packages/typegpu/src/shared/warnOnce.ts @@ -1,6 +1,6 @@ const _warned = new WeakMap>(); -/** Emits a warning at most once per key and tag. */ +/** Emits a warning at most once per key and tag */ export function warnOnce(key: object, tag: string, message: string): void { let tags = _warned.get(key); diff --git a/packages/typegpu/tests/computePipeline.test.ts b/packages/typegpu/tests/computePipeline.test.ts index 073bb2f401..d59195acb5 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -119,7 +119,6 @@ describe('TgpuComputePipeline', () => { pass.end(); encoder.submit(); - // The read-back is deferred to the encoder's submission instead of dropped. expect(consoleWarnSpy).not.toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); @@ -158,7 +157,6 @@ describe('TgpuComputePipeline', () => { .withPerformanceCallback(() => {}) .dispatchWorkgroups(1); - // The resolve used to need a second encoder and a second submission. expect(commandEncoder.resolveQuerySet).toHaveBeenCalledTimes(1); expect(device.queue.submit).toHaveBeenCalledTimes(1); }); @@ -175,7 +173,6 @@ describe('TgpuComputePipeline', () => { .with(encoder) .dispatchWorkgroups(1); - // Nothing resolved yet - the caller has not submitted. expect(commandEncoder.resolveQuerySet).not.toHaveBeenCalled(); encoder.submit(); @@ -211,8 +208,8 @@ describe('TgpuComputePipeline', () => { pipeline.dispatchWorkgroups(1); pipeline.dispatchWorkgroups(2); - // The caller owns the pass and can mutate it between dispatches, so nothing - // about its state can be assumed - same as after `root.unwrap(pass)`. + // The caller can mutate the pass between dispatches, so nothing about its + // state can be assumed expect(rawPass.setPipeline).toHaveBeenCalledTimes(2); expect(rawPass.dispatchWorkgroups).toHaveBeenCalledTimes(2); }); diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index bce9a6551b..e424c09595 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -1416,8 +1416,8 @@ describe('Render Bundles', () => { draw: ReturnType; }; - // The caller owns the encoder and can mutate it between draws, so nothing - // about its state can be assumed - same as after `root.unwrap(pass)`. + // The caller can mutate the encoder between draws, so nothing about its + // state can be assumed expect(encoder.setPipeline).toHaveBeenCalledTimes(2); expect(encoder.draw).toHaveBeenCalledTimes(2); }); @@ -1519,8 +1519,8 @@ describe('Render Bundles', () => { draw: ReturnType; }; - // Both draws land on the bundle encoder, and - unlike a raw one, which we - // do not own - the typed pass lets the second draw reuse the applied state. + // A typed bundle pass is only mutated through TypeGPU, so the second draw + // can reuse the applied state expect(encoder.setPipeline).toHaveBeenCalledTimes(1); expect(encoder.draw).toHaveBeenCalledTimes(2); }); From e99695566f86aa2275018f8c8125c391ac80fe67 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 27 Jul 2026 17:55:27 +0200 Subject: [PATCH 04/10] test fixes --- packages/typegpu/tests/commandEncoder.test.ts | 476 +++++++++--------- .../typegpu/tests/computePipeline.test.ts | 7 +- packages/typegpu/tests/root.test.ts | 30 +- 3 files changed, 255 insertions(+), 258 deletions(-) diff --git a/packages/typegpu/tests/commandEncoder.test.ts b/packages/typegpu/tests/commandEncoder.test.ts index 1a5f4ec9ee..7c24a70aaa 100644 --- a/packages/typegpu/tests/commandEncoder.test.ts +++ b/packages/typegpu/tests/commandEncoder.test.ts @@ -1,8 +1,12 @@ -import { describe, expect } from 'vitest'; +import { describe, expect, type Mock } from 'vitest'; import { Void } from 'typegpu/data'; import { tgpu, d } from 'typegpu'; import { it } from 'typegpu-testing-utility'; +function passDescriptor(beginRenderPass: Mock, index = 0): GPURenderPassDescriptor { + return (beginRenderPass.mock.calls[index] as unknown[])?.[0] as GPURenderPassDescriptor; +} + describe('TgpuCommandEncoder', () => { const layout = tgpu.bindGroupLayout({ foo: { uniform: d.f32 } }); @@ -30,7 +34,7 @@ describe('TgpuCommandEncoder', () => { return { pos: d.vec4f() }; }); - it('submits a single command buffer for multiple draws', ({ root, commandEncoder }) => { + it('submits a single command buffer for multiple draws', ({ root, renderPassEncoder }) => { const group = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -48,16 +52,13 @@ describe('TgpuCommandEncoder', () => { expect(root.device.createCommandEncoder).toBeCalledTimes(1); expect(root.device.queue.submit).toBeCalledTimes(1); - - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.draw).toBeCalledTimes(2); - expect(renderPassMock.end).toBeCalledTimes(1); + expect(renderPassEncoder.draw).toBeCalledTimes(2); + expect(renderPassEncoder.end).toBeCalledTimes(1); }); it('applies pipeline state once for consecutive draws with the same pipeline', ({ root, - commandEncoder, + renderPassEncoder, }) => { const group = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), @@ -76,14 +77,12 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalledTimes(1); - expect(renderPassMock.setBindGroup).toBeCalledTimes(1); - expect(renderPassMock.draw).toBeCalledTimes(3); + expect(renderPassEncoder.setPipeline).toBeCalledTimes(1); + expect(renderPassEncoder.setBindGroup).toBeCalledTimes(1); + expect(renderPassEncoder.draw).toBeCalledTimes(3); }); - it('re-applies pipeline state after another pipeline drew', ({ root, commandEncoder }) => { + it('re-applies pipeline state after another pipeline drew', ({ root, renderPassEncoder }) => { const group = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -106,12 +105,10 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalledTimes(3); + expect(renderPassEncoder.setPipeline).toBeCalledTimes(3); }); - it('re-applies pipeline state after pass-level setBindGroup', ({ root, commandEncoder }) => { + it('re-applies pipeline state after pass-level setBindGroup', ({ root, renderPassEncoder }) => { const groupA = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -134,14 +131,12 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalledTimes(2); - expect(renderPassMock.setBindGroup).nthCalledWith(1, 0, root.unwrap(groupA)); - expect(renderPassMock.setBindGroup).nthCalledWith(2, 0, root.unwrap(groupB)); + expect(renderPassEncoder.setPipeline).toBeCalledTimes(2); + expect(renderPassEncoder.setBindGroup).nthCalledWith(1, 0, root.unwrap(groupA)); + expect(renderPassEncoder.setBindGroup).nthCalledWith(2, 0, root.unwrap(groupB)); }); - it('prefers pipeline-level bind groups over pass-level ones', ({ root, commandEncoder }) => { + it('prefers pipeline-level bind groups over pass-level ones', ({ root, renderPassEncoder }) => { const passGroup = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -160,13 +155,11 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setBindGroup).toBeCalledTimes(1); - expect(renderPassMock.setBindGroup).toBeCalledWith(0, root.unwrap(pipelineGroup)); + expect(renderPassEncoder.setBindGroup).toBeCalledTimes(1); + expect(renderPassEncoder.setBindGroup).toBeCalledWith(0, root.unwrap(pipelineGroup)); }); - it('applies a prepared index buffer when drawing proxy-style', ({ root, commandEncoder }) => { + it('applies a prepared index buffer when drawing proxy-style', ({ root, renderPassEncoder }) => { const indexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); const pipeline = root .createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }) @@ -179,21 +172,19 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setIndexBuffer).toBeCalledTimes(1); - expect(renderPassMock.setIndexBuffer).toBeCalledWith( + expect(renderPassEncoder.setIndexBuffer).toBeCalledTimes(1); + expect(renderPassEncoder.setIndexBuffer).toBeCalledWith( root.unwrap(indexBuffer), 'uint16', undefined, undefined, ); - expect(renderPassMock.drawIndexed).toBeCalledTimes(1); + expect(renderPassEncoder.drawIndexed).toBeCalledTimes(1); }); it('restores the pass-level index buffer after a pipeline override', ({ root, - commandEncoder, + renderPassEncoder, }) => { const passIndexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); const pipelineIndexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); @@ -210,28 +201,26 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setIndexBuffer).nthCalledWith( + expect(renderPassEncoder.setIndexBuffer).nthCalledWith( 1, root.unwrap(pipelineIndexBuffer), 'uint16', undefined, undefined, ); - expect(renderPassMock.setIndexBuffer).nthCalledWith( + expect(renderPassEncoder.setIndexBuffer).nthCalledWith( 2, root.unwrap(passIndexBuffer), 'uint16', undefined, undefined, ); - expect(renderPassMock.setStencilReference).not.toBeCalled(); + expect(renderPassEncoder.setStencilReference).not.toBeCalled(); }); it('prefers a pipeline stencil reference and falls back to pass state', ({ root, - commandEncoder, + renderPassEncoder, }) => { const plain = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); const withRef = plain.withStencilReference(5); @@ -246,15 +235,13 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setStencilReference).toBeCalledTimes(3); - expect(renderPassMock.setStencilReference).nthCalledWith(1, 5); - expect(renderPassMock.setStencilReference).nthCalledWith(2, 7); - expect(renderPassMock.setStencilReference).nthCalledWith(3, 2); + expect(renderPassEncoder.setStencilReference).toBeCalledTimes(3); + expect(renderPassEncoder.setStencilReference).nthCalledWith(1, 5); + expect(renderPassEncoder.setStencilReference).nthCalledWith(2, 7); + expect(renderPassEncoder.setStencilReference).nthCalledWith(3, 2); }); - it('resets a pipeline stencil reference for the next pipeline', ({ root, commandEncoder }) => { + it('resets a pipeline stencil reference for the next pipeline', ({ root, renderPassEncoder }) => { const plain = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); const withRef = plain.withStencilReference(5); @@ -265,13 +252,11 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setStencilReference).nthCalledWith(1, 5); - expect(renderPassMock.setStencilReference).nthCalledWith(2, 0); + expect(renderPassEncoder.setStencilReference).nthCalledWith(1, 5); + expect(renderPassEncoder.setStencilReference).nthCalledWith(2, 0); }); - it('disables state deduplication after the pass is unwrapped', ({ root, commandEncoder }) => { + it('disables state deduplication after the pass is unwrapped', ({ root, renderPassEncoder }) => { const pipeline = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); const encoder = root.createCommandEncoder(); @@ -285,9 +270,28 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalledTimes(3); + expect(renderPassEncoder.setPipeline).toBeCalledTimes(3); + }); + + it('resets applied state after executeBundles', ({ root, renderPassEncoder }) => { + const group = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(group); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + const bound = pipeline.with(pass); + bound.draw(3); + pass.executeBundles([]); + bound.draw(3); + pass.end(); + encoder.submit(); + + expect(renderPassEncoder.setPipeline).toBeCalledTimes(2); }); it('throws when drawing without a pipeline', ({ root }) => { @@ -308,217 +312,211 @@ describe('TgpuCommandEncoder', () => { const encoder = root.createCommandEncoder(); const pass = encoder.beginRenderPass({ colorAttachments: [] }); - expect(() => pipeline.with(pass).draw(3)).toThrow(/Missing bind groups/); - }); - - it('unwraps TypeGPU textures passed as attachment views', ({ root, commandEncoder }) => { - const colorTexture = root - .createTexture({ size: [64, 64], format: 'rgba8unorm' }) - .$usage('render'); - const depthTexture = root - .createTexture({ size: [64, 64], format: 'depth24plus' }) - .$usage('render'); - - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ - colorAttachments: [{ view: colorTexture }], - depthStencilAttachment: { view: depthTexture }, - }); - - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - const [colorAttachment] = [...rawDescriptor.colorAttachments]; - - expect(root.unwrap(colorTexture).createView).toBeCalled(); - expect(root.unwrap(depthTexture).createView).toBeCalled(); - expect(colorAttachment?.loadOp).toBe('clear'); - expect(colorAttachment?.storeOp).toBe('store'); - expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); - expect(rawDescriptor.depthStencilAttachment?.depthStoreOp).toBe('store'); - expect(rawDescriptor.depthStencilAttachment?.depthClearValue).toBe(1); + expect(() => pipeline.with(pass).draw(3)).toThrowErrorMatchingInlineSnapshot( + `[Error: Missing bind groups for layouts: 'layout'. Please provide it using pipeline.with(bindGroup).(...)]`, + ); }); - it('allows omitting color attachments for depth-only passes', ({ root, commandEncoder }) => { - const depthTexture = root - .createTexture({ size: [64, 64], format: 'depth24plus' }) - .$usage('render'); - + it('unwraps to raw WebGPU objects', ({ root, commandEncoder, renderPassEncoder }) => { const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ depthStencilAttachment: { view: depthTexture } }); + const renderPass = encoder.beginRenderPass({ colorAttachments: [] }); + const computePass = encoder.beginComputePass(); - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect([...rawDescriptor.colorAttachments]).toHaveLength(0); - expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); + expect(root.unwrap(encoder)).toBe(commandEncoder); + expect(root.unwrap(renderPass)).toBe(renderPassEncoder); + expect(root.unwrap(computePass)).toBe( + commandEncoder.mock.beginComputePass.mock.results[0]?.value, + ); }); - it('accepts a single color attachment without an array', ({ root, commandEncoder }) => { - const colorTexture = root - .createTexture({ size: [64, 64], format: 'rgba8unorm' }) - .$usage('render'); - - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ colorAttachments: { view: colorTexture } }); - - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect([...rawDescriptor.colorAttachments]).toHaveLength(1); - }); + describe('pass descriptor', () => { + it('unwraps TypeGPU textures passed as attachment views', ({ root, commandEncoder }) => { + const colorTexture = root + .createTexture({ size: [64, 64], format: 'rgba8unorm' }) + .$usage('render'); + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); - it('does not apply depth defaults to read-only depth attachments', ({ root, commandEncoder }) => { - const depthTexture = root - .createTexture({ size: [64, 64], format: 'depth24plus' }) - .$usage('render'); + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + colorAttachments: [{ view: colorTexture }], + depthStencilAttachment: { view: depthTexture }, + }); - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ - colorAttachments: [], - depthStencilAttachment: { view: depthTexture, depthReadOnly: true }, + expect(root.unwrap(colorTexture).createView).toBeCalled(); + expect(root.unwrap(depthTexture).createView).toBeCalled(); + + const descriptor = passDescriptor(commandEncoder.mock.beginRenderPass); + const [colorAttachment] = [...descriptor.colorAttachments]; + expect(colorAttachment).toMatchInlineSnapshot(` + { + "loadOp": "clear", + "storeOp": "store", + "view": { + "label": "", + }, + } + `); + expect(descriptor.depthStencilAttachment).toMatchInlineSnapshot(` + { + "depthClearValue": 1, + "depthLoadOp": "clear", + "depthStoreOp": "store", + "view": { + "label": "", + }, + } + `); }); - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBeUndefined(); - expect(rawDescriptor.depthStencilAttachment?.depthStoreOp).toBeUndefined(); - }); + it('normalizes color attachments', ({ root, commandEncoder }) => { + const colorTexture = root + .createTexture({ size: [64, 64], format: 'rgba8unorm' }) + .$usage('render'); + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); - it('applies stencil defaults only for formats with a stencil aspect', ({ - root, - commandEncoder, - }) => { - const depthStencilTexture = root - .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) - .$usage('render'); + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ depthStencilAttachment: { view: depthTexture } }); + encoder.beginRenderPass({ colorAttachments: { view: colorTexture } }); - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ - colorAttachments: [], - depthStencilAttachment: { view: depthStencilTexture }, + const omitted = passDescriptor(commandEncoder.mock.beginRenderPass, 0); + const single = passDescriptor(commandEncoder.mock.beginRenderPass, 1); + expect([...omitted.colorAttachments]).toHaveLength(0); + expect([...single.colorAttachments]).toHaveLength(1); }); - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBe('clear'); - expect(rawDescriptor.depthStencilAttachment?.stencilStoreOp).toBe('store'); - }); + it('does not apply depth defaults to read-only depth attachments', ({ + root, + commandEncoder, + }) => { + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); - it('derives depth/stencil defaults from TypeGPU texture views', ({ root, commandEncoder }) => { - const depthStencilTexture = root - .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) - .$usage('render'); + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + colorAttachments: [], + depthStencilAttachment: { view: depthTexture, depthReadOnly: true }, + }); - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ - depthStencilAttachment: { view: depthStencilTexture.createView('render') }, + expect(passDescriptor(commandEncoder.mock.beginRenderPass).depthStencilAttachment) + .toMatchInlineSnapshot(` + { + "depthReadOnly": true, + "view": { + "label": "", + }, + } + `); }); - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); - expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBe('clear'); - }); + it('applies stencil defaults only for formats with a stencil aspect', ({ + root, + commandEncoder, + }) => { + const depthStencilTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) + .$usage('render'); - it('respects the view aspect when deriving depth/stencil defaults', ({ - root, - commandEncoder, - }) => { - const depthStencilTexture = root - .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) - .$usage('render'); + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + colorAttachments: [], + depthStencilAttachment: { view: depthStencilTexture }, + }); - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ - depthStencilAttachment: { - view: depthStencilTexture.createView('render', { aspect: 'depth-only' }), - }, + expect(passDescriptor(commandEncoder.mock.beginRenderPass).depthStencilAttachment) + .toMatchInlineSnapshot(` + { + "depthClearValue": 1, + "depthLoadOp": "clear", + "depthStoreOp": "store", + "stencilLoadOp": "clear", + "stencilStoreOp": "store", + "view": { + "label": "", + }, + } + `); }); - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); - expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBeUndefined(); - }); + it('derives depth/stencil defaults from the aspect of a TypeGPU view', ({ + root, + commandEncoder, + }) => { + const depthStencilTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus-stencil8' }) + .$usage('render'); - it('assumes depth-only defaults for raw views without explicit operations', ({ - root, - commandEncoder, - }) => { - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ - depthStencilAttachment: { view: {} as GPUTextureView }, - }); - - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBe('clear'); - expect(rawDescriptor.depthStencilAttachment?.depthStoreOp).toBe('store'); - expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBeUndefined(); - }); + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + depthStencilAttachment: { view: depthStencilTexture.createView('render') }, + }); + encoder.beginRenderPass({ + depthStencilAttachment: { + view: depthStencilTexture.createView('render', { aspect: 'depth-only' }), + }, + }); - it('passes raw views through untouched when explicit operations are given', ({ - root, - commandEncoder, - }) => { - const encoder = root.createCommandEncoder(); - encoder.beginRenderPass({ - depthStencilAttachment: { - view: {} as GPUTextureView, - stencilLoadOp: 'clear', - stencilStoreOp: 'store', - }, + const bothAspects = passDescriptor(commandEncoder.mock.beginRenderPass, 0); + const depthOnly = passDescriptor(commandEncoder.mock.beginRenderPass, 1); + expect(bothAspects.depthStencilAttachment).toMatchInlineSnapshot(` + { + "depthClearValue": 1, + "depthLoadOp": "clear", + "depthStoreOp": "store", + "stencilLoadOp": "clear", + "stencilStoreOp": "store", + "view": { + "label": "", + }, + } + `); + expect(depthOnly.depthStencilAttachment).toMatchInlineSnapshot(` + { + "depthClearValue": 1, + "depthLoadOp": "clear", + "depthStoreOp": "store", + "view": { + "label": "", + }, + } + `); }); - const rawDescriptor = ( - commandEncoder.mock.beginRenderPass.mock.calls[0] as unknown[] - )?.[0] as GPURenderPassDescriptor; - expect(rawDescriptor.depthStencilAttachment?.depthLoadOp).toBeUndefined(); - expect(rawDescriptor.depthStencilAttachment?.depthClearValue).toBeUndefined(); - expect(rawDescriptor.depthStencilAttachment?.stencilLoadOp).toBe('clear'); - }); + it('defaults raw views all-or-nothing', ({ root, commandEncoder }) => { + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + depthStencilAttachment: { view: {} as GPUTextureView }, + }); + encoder.beginRenderPass({ + depthStencilAttachment: { + view: {} as GPUTextureView, + stencilLoadOp: 'clear', + stencilStoreOp: 'store', + }, + }); - it('resets applied state after executeBundles', ({ root, commandEncoder }) => { - const group = root.createBindGroup(layout, { - foo: root.createBuffer(d.f32).$usage('uniform'), + const noOps = passDescriptor(commandEncoder.mock.beginRenderPass, 0); + const explicitOps = passDescriptor(commandEncoder.mock.beginRenderPass, 1); + expect(noOps.depthStencilAttachment).toMatchInlineSnapshot(` + { + "depthClearValue": 1, + "depthLoadOp": "clear", + "depthStoreOp": "store", + "view": {}, + } + `); + expect(explicitOps.depthStencilAttachment).toMatchInlineSnapshot(` + { + "stencilLoadOp": "clear", + "stencilStoreOp": "store", + "view": {}, + } + `); }); - - const pipeline = root - .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) - .with(group); - - const encoder = root.createCommandEncoder(); - const pass = encoder.beginRenderPass({ colorAttachments: [] }); - const bound = pipeline.with(pass); - bound.draw(3); - pass.executeBundles([]); - bound.draw(3); - pass.end(); - encoder.submit(); - - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalledTimes(2); - }); - - it('unwraps to raw WebGPU objects', ({ root, commandEncoder }) => { - const encoder = root.createCommandEncoder(); - const renderPass = encoder.beginRenderPass({ colorAttachments: [] }); - const computePass = encoder.beginComputePass(); - - expect(root.unwrap(encoder)).toBe(commandEncoder); - expect(root.unwrap(renderPass)).toBe( - commandEncoder.mock.beginRenderPass.mock.results[0]?.value, - ); - expect(root.unwrap(computePass)).toBe( - commandEncoder.mock.beginComputePass.mock.results[0]?.value, - ); }); describe('compute pass', () => { diff --git a/packages/typegpu/tests/computePipeline.test.ts b/packages/typegpu/tests/computePipeline.test.ts index d59195acb5..8e659a06d2 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -106,7 +106,10 @@ describe('TgpuComputePipeline', () => { `); }); - it('drains shader logs when dispatching into an encoder-owned pass', ({ root }) => { + it('drains shader logs when dispatching into an encoder-owned pass', ({ + root, + commandEncoder, + }) => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => { console.log(1); @@ -120,6 +123,8 @@ describe('TgpuComputePipeline', () => { encoder.submit(); expect(consoleWarnSpy).not.toHaveBeenCalled(); + // The index and data log buffers are both read back once the encoder submits + expect(commandEncoder.copyBufferToBuffer).toHaveBeenCalledTimes(2); consoleWarnSpy.mockRestore(); }); diff --git a/packages/typegpu/tests/root.test.ts b/packages/typegpu/tests/root.test.ts index ae38e62ec4..7698007def 100644 --- a/packages/typegpu/tests/root.test.ts +++ b/packages/typegpu/tests/root.test.ts @@ -219,7 +219,7 @@ describe('TgpuRoot', () => { const mainFragment = tgpu.fragmentFn({ out: Void })(() => {}); - it('ignores bind groups that are not used in the shader', ({ root, commandEncoder }) => { + it('ignores bind groups that are not used in the shader', ({ root, renderPassEncoder }) => { const group = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -237,14 +237,12 @@ describe('TgpuRoot', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalled(); - expect(renderPassMock.setBindGroup).not.toBeCalled(); - expect(renderPassMock.end).toBeCalled(); + expect(renderPassEncoder.setPipeline).toBeCalled(); + expect(renderPassEncoder.setBindGroup).not.toBeCalled(); + expect(renderPassEncoder.end).toBeCalled(); }); - it('accepts bind groups that are used in the shader', ({ root, commandEncoder }) => { + it('accepts bind groups that are used in the shader', ({ root, renderPassEncoder }) => { const group = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -262,14 +260,12 @@ describe('TgpuRoot', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalled(); - expect(renderPassMock.setBindGroup).toBeCalledTimes(1); - expect(renderPassMock.setBindGroup).toBeCalledWith(0, root.unwrap(group)); + expect(renderPassEncoder.setPipeline).toBeCalled(); + expect(renderPassEncoder.setBindGroup).toBeCalledTimes(1); + expect(renderPassEncoder.setBindGroup).toBeCalledWith(0, root.unwrap(group)); }); - it('respects bind groups bound directly to pipelines', ({ root, commandEncoder }) => { + it('respects bind groups bound directly to pipelines', ({ root, renderPassEncoder }) => { const group = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -288,11 +284,9 @@ describe('TgpuRoot', () => { pass.end(); encoder.submit(); - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalled(); - expect(renderPassMock.setBindGroup).toBeCalledTimes(1); - expect(renderPassMock.setBindGroup).toBeCalledWith(0, root.unwrap(group)); + expect(renderPassEncoder.setPipeline).toBeCalled(); + expect(renderPassEncoder.setBindGroup).toBeCalledTimes(1); + expect(renderPassEncoder.setBindGroup).toBeCalledWith(0, root.unwrap(group)); }); }); From 72265e9f0fca9ecaa9d8d70af479c1bc70725a8a Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 27 Jul 2026 18:49:49 +0200 Subject: [PATCH 05/10] warn about pipeline attachments dropped by a shared pass --- .../src/core/commandEncoder/attachments.ts | 149 +++++++++++++- .../src/core/commandEncoder/commandEncoder.ts | 10 +- .../src/core/commandEncoder/computePass.ts | 32 +-- .../src/core/commandEncoder/renderPass.ts | 37 ++-- .../src/core/pipeline/applyPipelineState.ts | 103 ---------- .../src/core/pipeline/computePipeline.ts | 66 +++--- .../pipeline/connectAttachmentToShader.ts | 3 +- .../typegpu/src/core/pipeline/drawState.ts | 188 ++++++++++++----- .../src/core/pipeline/renderPipeline.ts | 190 +----------------- .../typegpu/src/core/pipeline/timeable.ts | 10 +- .../typegpu/src/core/pipeline/typeGuards.ts | 11 +- packages/typegpu/src/indexNamedExports.ts | 8 +- packages/typegpu/tests/commandEncoder.test.ts | 61 +++++- 13 files changed, 414 insertions(+), 454 deletions(-) delete mode 100644 packages/typegpu/src/core/pipeline/applyPipelineState.ts diff --git a/packages/typegpu/src/core/commandEncoder/attachments.ts b/packages/typegpu/src/core/commandEncoder/attachments.ts index 2a09dc8aad..b1b39cf956 100644 --- a/packages/typegpu/src/core/commandEncoder/attachments.ts +++ b/packages/typegpu/src/core/commandEncoder/attachments.ts @@ -1,8 +1,153 @@ +import type { + WgslTexture, + WgslTextureDepth2d, + WgslTextureDepthMultisampled2d, +} from '../../data/texture.ts'; +import { $internal } from '../../shared/symbols.ts'; import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; import { isGPUCanvasContext } from '../pipeline/typeGuards.ts'; -import type { ColorAttachment, DepthStencilAttachment } from '../pipeline/renderPipeline.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; -import { isTexture, isTextureView } from '../texture/texture.ts'; +import { + isTexture, + isTextureView, + type TextureInternals, + type TgpuTextureRenderView, + type TgpuTextureView, +} from '../texture/texture.ts'; +import type { RenderFlag } from '../texture/usageExtension.ts'; + +interface ColorTextureConstraint { + readonly [$internal]: TextureInternals; + readonly resourceType: 'texture'; + readonly props: { format: GPUTextureFormat }; +} + +export interface ColorAttachment { + /** + * A {@link GPUTextureView} describing the texture subresource that will be output to for this + * color attachment. + */ + view: + | (ColorTextureConstraint & RenderFlag) + | GPUTextureView + | TgpuTextureView + | TgpuTextureRenderView + // We call `.getCurrentTexture().createView()` underneath + | GPUCanvasContext; + /** + * Indicates the depth slice index of {@link GPUTextureViewDimension#"3d"} {@link GPURenderPassColorAttachment#view} + * that will be output to for this color attachment. + */ + depthSlice?: GPUIntegerCoordinate; + /** + * A {@link GPUTextureView} describing the texture subresource that will receive the resolved + * output for this color attachment if {@link GPURenderPassColorAttachment#view} is + * multisampled. + */ + resolveTarget?: + | (ColorTextureConstraint & RenderFlag) + | GPUTextureView + | TgpuTextureView + | TgpuTextureRenderView + // We call `.getCurrentTexture().createView()` underneath + | GPUCanvasContext; + /** + * Indicates the value to clear {@link GPURenderPassColorAttachment#view} to prior to executing the + * render pass. If not provided, defaults to `{r: 0, g: 0, b: 0, a: 0}`. Ignored + * if {@link GPURenderPassColorAttachment#loadOp} is not {@link GPULoadOp#"clear"}. + * The components of {@link GPURenderPassColorAttachment#clearValue} are all double values. + * They are converted to a texel value of texture format matching the render attachment. + * If conversion fails, a validation error is generated. + */ + clearValue?: readonly [number, number, number, number] | GPUColor; + /** + * Indicates the load operation to perform on {@link GPURenderPassColorAttachment#view} prior to + * executing the render pass. + * Note: It is recommended to prefer clearing; see {@link GPULoadOp#"clear"} for details. + * + * @default 'clear' + */ + loadOp?: GPULoadOp | undefined; + /** + * The store operation to perform on {@link GPURenderPassColorAttachment#view} + * after executing the render pass. + * + * @default 'store' + */ + storeOp?: GPUStoreOp | undefined; +} + +export type DepthStencilFormat = + | 'stencil8' + | 'depth16unorm' + | 'depth24plus' + | 'depth24plus-stencil8' + | 'depth32float' + | 'depth32float-stencil8'; + +interface DepthStencilTextureConstraint { + readonly [$internal]: TextureInternals; + readonly resourceType: 'texture'; + readonly props: { format: DepthStencilFormat }; +} + +export interface DepthStencilAttachment { + /** + * The texture subresource that will be output to and read from for this + * depth/stencil attachment. + */ + view: + | (DepthStencilTextureConstraint & RenderFlag) + | TgpuTextureView + | TgpuTextureRenderView + | GPUTextureView; + /** + * Indicates the value to clear {@link GPURenderPassDepthStencilAttachment#view}'s depth component + * to prior to executing the render pass. Ignored if {@link GPURenderPassDepthStencilAttachment#depthLoadOp} + * is not {@link GPULoadOp#"clear"}. Must be between 0.0 and 1.0, inclusive (unless unrestricted depth is enabled). + */ + depthClearValue?: number; + /** + * Indicates the load operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s + * depth component prior to executing the render pass. + * Note: It is recommended to prefer clearing; see {@link GPULoadOp#"clear"} for details. + */ + depthLoadOp?: GPULoadOp; + /** + * The store operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s + * depth component after executing the render pass. + */ + depthStoreOp?: GPUStoreOp; + /** + * Indicates that the depth component of {@link GPURenderPassDepthStencilAttachment#view} + * is read only. + */ + depthReadOnly?: boolean; + /** + * Indicates the value to clear {@link GPURenderPassDepthStencilAttachment#view}'s stencil component + * to prior to executing the render pass. Ignored if {@link GPURenderPassDepthStencilAttachment#stencilLoadOp} + * is not {@link GPULoadOp#"clear"}. + * The value will be converted to the type of the stencil aspect of `view` by taking the same + * number of LSBs as the number of bits in the stencil aspect of one texel of `view`. + */ + stencilClearValue?: GPUStencilValue; + /** + * Indicates the load operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s + * stencil component prior to executing the render pass. + * Note: It is recommended to prefer clearing; see {@link GPULoadOp#"clear"} for details. + */ + stencilLoadOp?: GPULoadOp; + /** + * The store operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s + * stencil component after executing the render pass. + */ + stencilStoreOp?: GPUStoreOp; + /** + * Indicates that the stencil component of {@link GPURenderPassDepthStencilAttachment#view} + * is read only. + */ + stencilReadOnly?: boolean; +} export type AnyAttachmentView = | ColorAttachment['view'] diff --git a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts index 31d78b2edf..c2a2283869 100644 --- a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -19,10 +19,7 @@ import { export interface CommandEncoderInternals { readonly rawEncoder: GPUCommandEncoder; readonly root: ExperimentalTgpuRoot; - /** - * Whether the raw encoder came from the caller, which means submission is out - * of our hands and no work can be deferred to it. - */ + /** A caller-owned raw encoder, so submission is out of our hands and no work can be deferred */ readonly adopted: boolean; /** Commands recorded just before the encoder is finished, keyed for deduplication */ readonly beforeFinish: Map void>; @@ -79,11 +76,6 @@ export function INTERNAL_createCommandEncoder( return new TgpuCommandEncoderImpl(root, root.device.createCommandEncoder(descriptor), false); } -/** - * Wraps a raw command encoder the user owns, so that passes begun on it take - * the same route as passes begun on a TypeGPU encoder. Submission stays the - * caller's responsibility. - */ export function INTERNAL_adoptCommandEncoder( root: ExperimentalTgpuRoot, rawEncoder: GPUCommandEncoder, diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts index 6f167703dd..6ffdb1b10d 100644 --- a/packages/typegpu/src/core/commandEncoder/computePass.ts +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -1,11 +1,10 @@ import { $internal } from '../../shared/symbols.ts'; -import { - isBindGroup, - type TgpuBindGroup, - type TgpuBindGroupLayout, - type TgpuLayoutEntry, +import type { + TgpuBindGroup, + TgpuBindGroupLayout, + TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; -import { ComputeDrawState, emitComputeDispatch } from '../pipeline/drawState.ts'; +import { ComputeDrawState, emitComputeDispatch, recordBindGroup } from '../pipeline/drawState.ts'; import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { type TgpuPassTimestampWrites, unwrapTimestampWrites } from './attachments.ts'; @@ -17,7 +16,7 @@ import type { TgpuCommandEncoder } from './commandEncoder.ts'; /** * The TypeGPU equivalent of {@link GPUComputePassDescriptor}. - * Query sets accept {@link TgpuQuerySet} next to raw {@link GPUQuerySet}s. + * Query sets accept TypeGPU query sets next to raw {@link GPUQuerySet}s. */ export interface TgpuComputePassDescriptor { label?: string | undefined; @@ -27,10 +26,7 @@ export interface TgpuComputePassDescriptor { export interface ComputePassInternals { readonly rawPass: GPUComputePassEncoder; readonly state: ComputeDrawState; - /** - * The encoder this pass records into, when it is one we can defer work to. - * Undefined for raw pass encoders the caller owns. - */ + /** Undefined for raw pass encoders the caller owns */ readonly owner: TgpuCommandEncoder | undefined; lastApplied: { pipeline: TgpuComputePipeline; version: number } | undefined; } @@ -88,12 +84,6 @@ export function INTERNAL_beginComputePass( return new TgpuComputePassImpl(root, rawEncoder.beginComputePass(rawDescriptor), encoder); } -/** - * Wraps a raw compute pass encoder the user owns, so that dispatches recorded - * into it take the same route as dispatches into a TypeGPU pass. The state is - * marked as raw-accessed, since the encoder can be mutated behind our back at - * any point. - */ export function INTERNAL_adoptComputePass( root: ExperimentalTgpuRoot, rawPass: GPUComputePassEncoder, @@ -143,13 +133,7 @@ class TgpuComputePassImpl implements TgpuComputePass { first: TgpuBindGroup | TgpuBindGroupLayout, bindGroup?: TgpuBindGroup | GPUBindGroup, ): void { - const { state } = this[$internal]; - if (isBindGroup(first)) { - state.bindGroups.set(first.layout, first); - } else { - state.bindGroups.set(first as TgpuBindGroupLayout, bindGroup as TgpuBindGroup | GPUBindGroup); - } - state.version++; + recordBindGroup(this[$internal].state, first as TgpuBindGroup | TgpuBindGroupLayout, bindGroup); } dispatchWorkgroups(x: number, y?: number, z?: number): void { diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts index 5ede93d677..7ccaca7043 100644 --- a/packages/typegpu/src/core/commandEncoder/renderPass.ts +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -1,22 +1,22 @@ import type { Disarray } from '../../data/dataTypes.ts'; import type { WgslArray } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; -import { - isBindGroup, - type TgpuBindGroup, - type TgpuBindGroupLayout, - type TgpuLayoutEntry, +import type { + TgpuBindGroup, + TgpuBindGroupLayout, + TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; import type { TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; import { isTexture, isTextureView } from '../texture/texture.ts'; -import { emitRenderDraw, RenderDrawState } from '../pipeline/drawState.ts'; -import type { ColorAttachment, DepthStencilAttachment } from '../pipeline/renderPipeline.ts'; +import { emitRenderDraw, recordBindGroup, RenderDrawState } from '../pipeline/drawState.ts'; import type { TgpuRenderPipeline } from '../pipeline/renderPipeline.ts'; import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import type { TgpuCommandEncoder } from './commandEncoder.ts'; import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; import { + type ColorAttachment, + type DepthStencilAttachment, type TgpuPassTimestampWrites, unwrapAttachmentView, unwrapTimestampWrites, @@ -53,10 +53,7 @@ export interface RenderPassInternals< > { readonly rawPass: TRaw; readonly state: RenderDrawState; - /** - * The encoder this pass records into, when it is one we can defer work to. - * Undefined for bundle encoders and for raw pass encoders the caller owns. - */ + /** Undefined for bundle encoders and for raw pass encoders the caller owns */ readonly owner: TgpuCommandEncoder | undefined; lastApplied: { pipeline: TgpuRenderPipeline; version: number } | undefined; } @@ -282,11 +279,6 @@ export function INTERNAL_beginRenderBundlePass( return new TgpuRenderCommandsImpl(root, bundleEncoder, undefined); } -/** - * Wraps a raw pass encoder the user owns, so that draws recorded into it take - * the same route as draws into a TypeGPU pass. The state is marked as - * raw-accessed, since the encoder can be mutated behind our back at any point. - */ export function INTERNAL_adoptRenderCommands( root: ExperimentalTgpuRoot, rawPass: GPURenderPassEncoder | GPURenderBundleEncoder, @@ -342,13 +334,7 @@ class TgpuRenderCommandsImpl< first: TgpuBindGroup | TgpuBindGroupLayout, bindGroup?: TgpuBindGroup | GPUBindGroup, ): void { - const { state } = this[$internal]; - if (isBindGroup(first)) { - state.bindGroups.set(first.layout, first); - } else { - state.bindGroups.set(first as TgpuBindGroupLayout, bindGroup as TgpuBindGroup | GPUBindGroup); - } - state.version++; + recordBindGroup(this[$internal].state, first as TgpuBindGroup | TgpuBindGroupLayout, bindGroup); } setVertexBuffer( @@ -431,8 +417,11 @@ class TgpuRenderPassImpl } setStencilReference(reference: GPUStencilValue): void { - const { state } = this[$internal]; + const { state, rawPass } = this[$internal]; state.stencilReference = reference; + rawPass.setStencilReference(reference); + state.appliedStencilReference = reference; + // a pipeline-level stencil reference still has to win on the next draw state.version++; } diff --git a/packages/typegpu/src/core/pipeline/applyPipelineState.ts b/packages/typegpu/src/core/pipeline/applyPipelineState.ts deleted file mode 100644 index 58aa7038e8..0000000000 --- a/packages/typegpu/src/core/pipeline/applyPipelineState.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - isBindGroup, - type TgpuBindGroup, - type TgpuBindGroupLayout, -} from '../../tgpuBindGroupLayout.ts'; -import { type TgpuBuffer, type VertexFlag } from '../buffer/buffer.ts'; -import { isBuffer } from '../../types.ts'; -import { MissingBindGroupsError, MissingVertexBuffersError } from '../../errors.ts'; -import type { BaseData } from '../../data/wgslTypes.ts'; - -import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; -import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; - -// ----------------------------------------------- -// shared helpers for applying pipeline state to render/compute pass encoders -// ----------------------------------------------- - -export type BindGroupResolver = ( - layout: TgpuBindGroupLayout, -) => TgpuBindGroup | GPUBindGroup | undefined; - -export interface VertexBufferEntry { - buffer: (TgpuBuffer & VertexFlag) | GPUBuffer; - offset?: number | undefined; - size?: number | undefined; -} - -export type VertexBufferResolver = (layout: TgpuVertexLayout) => VertexBufferEntry | undefined; - -export interface IndexBufferEntry { - buffer: TgpuBuffer | GPUBuffer; - indexFormat: GPUIndexFormat; - offsetBytes?: number | undefined; - sizeBytes?: number | undefined; -} - -export function applyIndexBuffer( - encoder: GPURenderPassEncoder | GPURenderBundleEncoder, - root: ExperimentalTgpuRoot, - entry: IndexBufferEntry, -): void { - const { buffer, indexFormat, offsetBytes, sizeBytes } = entry; - if (isBuffer(buffer)) { - encoder.setIndexBuffer(root.unwrap(buffer), indexFormat, offsetBytes, sizeBytes); - } else { - encoder.setIndexBuffer(buffer, indexFormat, offsetBytes, sizeBytes); - } -} - -export function applyBindGroups( - encoder: GPURenderPassEncoder | GPURenderBundleEncoder | GPUComputePassEncoder, - root: ExperimentalTgpuRoot, - usedBindGroupLayouts: TgpuBindGroupLayout[], - catchall: [number, TgpuBindGroup] | undefined, - resolveBindGroup: BindGroupResolver, -): void { - const missingBindGroups = new Set(usedBindGroupLayouts); - - usedBindGroupLayouts.forEach((layout, idx) => { - if (catchall && idx === catchall[0]) { - encoder.setBindGroup(idx, root.unwrap(catchall[1])); - missingBindGroups.delete(layout); - } else { - const bindGroup = resolveBindGroup(layout); - if (bindGroup !== undefined) { - missingBindGroups.delete(layout); - if (isBindGroup(bindGroup)) { - encoder.setBindGroup(idx, root.unwrap(bindGroup)); - } else { - encoder.setBindGroup(idx, bindGroup); - } - } - } - }); - - if (missingBindGroups.size > 0) { - throw new MissingBindGroupsError(missingBindGroups); - } -} - -export function applyVertexBuffers( - encoder: GPURenderPassEncoder | GPURenderBundleEncoder, - root: ExperimentalTgpuRoot, - usedVertexLayouts: TgpuVertexLayout[], - resolveVertexBuffer: VertexBufferResolver, -): void { - const missingVertexLayouts = new Set(); - - usedVertexLayouts.forEach((vertexLayout, idx) => { - const entry = resolveVertexBuffer(vertexLayout); - if (!entry || !entry.buffer) { - missingVertexLayouts.add(vertexLayout); - } else if (isBuffer(entry.buffer)) { - encoder.setVertexBuffer(idx, root.unwrap(entry.buffer), entry.offset, entry.size); - } else { - encoder.setVertexBuffer(idx, entry.buffer, entry.offset, entry.size); - } - }); - - if (missingVertexLayouts.size > 0) { - throw new MissingVertexBuffersError(missingVertexLayouts); - } -} diff --git a/packages/typegpu/src/core/pipeline/computePipeline.ts b/packages/typegpu/src/core/pipeline/computePipeline.ts index 0c6044f41a..e119226360 100644 --- a/packages/typegpu/src/core/pipeline/computePipeline.ts +++ b/packages/typegpu/src/core/pipeline/computePipeline.ts @@ -20,7 +20,7 @@ import { type TgpuCommandEncoder, } from '../commandEncoder/commandEncoder.ts'; import { INTERNAL_adoptComputePass, type TgpuComputePass } from '../commandEncoder/computePass.ts'; -import { emitComputeDispatch, queueLogDrain, warnAboutUnreachableSubmission } from './drawState.ts'; +import { emitComputeDispatch, finalizeOwnEncoder } from './drawState.ts'; import { isGPUCommandEncoder, isGPUComputePassEncoder, @@ -42,7 +42,6 @@ import { resolveIndirectOffset } from './pipelineUtils.ts'; import { createWithPerformanceCallback, createWithTimestampWrites, - queueTimestampResolve, type Timeable, type TimestampWritesPriors, } from './timeable.ts'; @@ -158,28 +157,23 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { public readonly resourceType = 'compute-pipeline'; readonly [$getNameForward]: ComputePipelineCore; - readonly #core: ComputePipelineCore; - readonly #priors: TgpuComputePipelinePriors; - constructor(core: ComputePipelineCore, priors: TgpuComputePipelinePriors) { - this.#core = core; - this.#priors = priors; - this[$internal] = { core, priors, root: core.root }; this[$getNameForward] = core; } [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - return ctx.resolve(this.#core); + return ctx.resolve(this[$internal].core); } toString(): string { return `computePipeline:${getName(this) ?? ''}`; } - /** Derives a pipeline sharing this one's core, with the given priors overridden */ #withPriors(patch: Partial): this { - return new TgpuComputePipelineImpl(this.#core, { ...this.#priors, ...patch }) as this; + const { core, priors } = this[$internal]; + + return new TgpuComputePipelineImpl(core, { ...priors, ...patch }) as this; } with>( @@ -202,6 +196,8 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { | GPUComputePassEncoder, bindGroup?: TgpuBindGroup | GPUBindGroup, ): this { + const internals = this[$internal]; + if (isTgpuComputePass(first)) { return this.#withPriors({ pass: first, encoder: undefined }); } @@ -212,7 +208,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { if (isGPUComputePassEncoder(first)) { return this.#withPriors({ - pass: INTERNAL_adoptComputePass(this.#core.root, first), + pass: INTERNAL_adoptComputePass(internals.root, first), encoder: undefined, }); } @@ -220,7 +216,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { if (isGPUCommandEncoder(first)) { return this.#withPriors({ pass: undefined, - encoder: INTERNAL_adoptCommandEncoder(this.#core.root, first), + encoder: INTERNAL_adoptCommandEncoder(internals.root, first), }); } @@ -229,16 +225,21 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { : [first, bindGroup as TgpuBindGroup | GPUBindGroup]; return this.#withPriors({ - bindGroupLayoutMap: new Map([...(this.#priors.bindGroupLayoutMap ?? []), [layout, group]]), + bindGroupLayoutMap: new Map([ + ...(internals.priors.bindGroupLayoutMap ?? []), + [layout, group], + ]), }); } withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this { - if (this.#priors.timestampWrites) { + const internals = this[$internal]; + + if (internals.priors.timestampWrites) { return this.#withPriors({ performanceCallback: callback }); } - const querySet = this.#core.performanceCallbackQuerySet; + const querySet = internals.core.performanceCallbackQuerySet; if (!querySet) { logger.warn( 'webgpu-feature-missing', @@ -246,7 +247,7 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { ); return this; } - return this.#withPriors(createWithPerformanceCallback(this.#priors, callback, querySet)); + return this.#withPriors(createWithPerformanceCallback(internals.priors, callback, querySet)); } withTimestampWrites(options: { @@ -254,7 +255,9 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { beginningOfPassWriteIndex?: number; endOfPassWriteIndex?: number; }): this { - return this.#withPriors(createWithTimestampWrites(this.#priors, options, this.#core.root)); + const internals = this[$internal]; + + return this.#withPriors(createWithTimestampWrites(internals.priors, options, internals.root)); } dispatchWorkgroups(x: number, y?: number, z?: number): void { @@ -279,21 +282,15 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { } initAsync(): Promise { - return this.#core.initAsync(); + return this[$internal].core.initAsync(); } initSync() { - this.#core.initSync(); + this[$internal].core.initSync(); } - /** - * The single route from a dispatch call to the GPU. Either the pipeline was - * given a pass to dispatch into, or it begins one of its own. If it was not - * given an encoder either, it owns the submission too. - */ #execute(dispatch: (pass: GPUComputePassEncoder) => void): void { - const { root } = this.#core; - const priors = this.#priors; + const { core, priors, root } = this[$internal]; if (priors.pass) { emitComputeDispatch(root, priors.pass[$internal], this, dispatch); @@ -302,24 +299,13 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { const encoder = priors.encoder ?? INTERNAL_createCommandEncoder(root); const pass = encoder.beginComputePass({ - label: getName(this.#core) ?? '', + label: getName(core) ?? '', timestampWrites: priors.timestampWrites, }); emitComputeDispatch(root, pass[$internal], this, dispatch, /* ownsPass */ true); pass.end(); - const { logResources } = this.#core.unwrap(); - if (logResources && !queueLogDrain(encoder, logResources)) { - warnAboutUnreachableSubmission(this.#core, 'Shader console.log output'); - } - - if (priors.performanceCallback && !queueTimestampResolve(encoder, priors)) { - warnAboutUnreachableSubmission(this.#core, 'The performance callback'); - } - - if (priors.encoder === undefined) { - encoder.submit(); - } + finalizeOwnEncoder(encoder, core, core.unwrap().logResources, priors); } $name(label: string): this { diff --git a/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts b/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts index b0f39de99b..51320a5d54 100644 --- a/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts +++ b/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts @@ -1,6 +1,7 @@ import { isBuiltin } from '../../data/attributes.ts'; import { type BaseData, isWgslStruct } from '../../data/wgslTypes.ts'; -import type { AnyFragmentColorAttachment, ColorAttachment } from './renderPipeline.ts'; +import type { ColorAttachment } from '../commandEncoder/attachments.ts'; +import type { AnyFragmentColorAttachment } from './renderPipeline.ts'; function isColorAttachment(value: unknown): value is ColorAttachment { return !!(value as ColorAttachment)?.view; diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts index 27237303ba..881df89aab 100644 --- a/packages/typegpu/src/core/pipeline/drawState.ts +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -1,22 +1,37 @@ +import { MissingBindGroupsError, MissingVertexBuffersError } from '../../errors.ts'; +import type { BaseData } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; import { warnOnce } from '../../shared/warnOnce.ts'; -import type { TgpuBindGroup, TgpuBindGroupLayout } from '../../tgpuBindGroupLayout.ts'; +import { + isBindGroup, + type TgpuBindGroup, + type TgpuBindGroupLayout, +} from '../../tgpuBindGroupLayout.ts'; import { logDataFromGPU } from '../../tgsl/consoleLog/deserializers.ts'; import type { LogResources } from '../../tgsl/consoleLog/types.ts'; +import { isBuffer } from '../../types.ts'; +import type { TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; import type { ComputePassInternals } from '../commandEncoder/computePass.ts'; import type { RenderPassInternals } from '../commandEncoder/renderPass.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; -import { - applyBindGroups, - applyIndexBuffer, - applyVertexBuffers, - type IndexBufferEntry, - type VertexBufferEntry, -} from './applyPipelineState.ts'; import type { TgpuComputePipeline } from './computePipeline.ts'; import type { TgpuRenderPipeline } from './renderPipeline.ts'; +import { queueTimestampResolve, type TimestampWritesPriors } from './timeable.ts'; + +export interface VertexBufferEntry { + buffer: (TgpuBuffer & VertexFlag) | GPUBuffer; + offset?: number | undefined; + size?: number | undefined; +} + +export interface IndexBufferEntry { + buffer: TgpuBuffer | GPUBuffer; + indexFormat: GPUIndexFormat; + offsetBytes?: number | undefined; + sizeBytes?: number | undefined; +} export class RenderDrawState { readonly bindGroups = new Map(); @@ -24,17 +39,10 @@ export class RenderDrawState { currentPipeline: TgpuRenderPipeline | undefined; indexBuffer: IndexBufferEntry | undefined; stencilReference: GPUStencilValue | undefined; - /** - * The stencil reference currently set on the raw pass. It starts at the - * WebGPU default of 0, and survives executeBundles, which cannot change it. - */ + /** What the raw pass holds, starting at the WebGPU default; survives executeBundles */ appliedStencilReference: GPUStencilValue = 0; version = 0; - /** - * Set once the raw pass encoder has been handed out via `root.unwrap(pass)`. - * Raw calls can mutate pass state invisibly, so state deduplication is - * disabled from that point on. - */ + /** Raw access via `root.unwrap(pass)` can mutate state invisibly, disabling deduplication */ rawAccessed = false; } @@ -45,7 +53,88 @@ export class ComputeDrawState { rawAccessed = false; } -export function applyRenderPipelineState( +export function recordBindGroup( + state: RenderDrawState | ComputeDrawState, + first: TgpuBindGroup | TgpuBindGroupLayout, + bindGroup: TgpuBindGroup | GPUBindGroup | undefined, +): void { + if (isBindGroup(first)) { + state.bindGroups.set(first.layout, first); + } else { + state.bindGroups.set(first, bindGroup as TgpuBindGroup | GPUBindGroup); + } + state.version++; +} + +function applyIndexBuffer( + encoder: GPURenderPassEncoder | GPURenderBundleEncoder, + root: ExperimentalTgpuRoot, + entry: IndexBufferEntry, +): void { + const { buffer, indexFormat, offsetBytes, sizeBytes } = entry; + if (isBuffer(buffer)) { + encoder.setIndexBuffer(root.unwrap(buffer), indexFormat, offsetBytes, sizeBytes); + } else { + encoder.setIndexBuffer(buffer, indexFormat, offsetBytes, sizeBytes); + } +} + +function applyBindGroups( + encoder: GPURenderPassEncoder | GPURenderBundleEncoder | GPUComputePassEncoder, + root: ExperimentalTgpuRoot, + usedBindGroupLayouts: TgpuBindGroupLayout[], + catchall: [number, TgpuBindGroup] | undefined, + resolveBindGroup: (layout: TgpuBindGroupLayout) => TgpuBindGroup | GPUBindGroup | undefined, +): void { + const missingBindGroups = new Set(usedBindGroupLayouts); + + usedBindGroupLayouts.forEach((layout, idx) => { + if (catchall && idx === catchall[0]) { + encoder.setBindGroup(idx, root.unwrap(catchall[1])); + missingBindGroups.delete(layout); + } else { + const bindGroup = resolveBindGroup(layout); + if (bindGroup !== undefined) { + missingBindGroups.delete(layout); + if (isBindGroup(bindGroup)) { + encoder.setBindGroup(idx, root.unwrap(bindGroup)); + } else { + encoder.setBindGroup(idx, bindGroup); + } + } + } + }); + + if (missingBindGroups.size > 0) { + throw new MissingBindGroupsError(missingBindGroups); + } +} + +function applyVertexBuffers( + encoder: GPURenderPassEncoder | GPURenderBundleEncoder, + root: ExperimentalTgpuRoot, + usedVertexLayouts: TgpuVertexLayout[], + resolveVertexBuffer: (layout: TgpuVertexLayout) => VertexBufferEntry | undefined, +): void { + const missingVertexLayouts = new Set(); + + usedVertexLayouts.forEach((vertexLayout, idx) => { + const entry = resolveVertexBuffer(vertexLayout); + if (!entry || !entry.buffer) { + missingVertexLayouts.add(vertexLayout); + } else if (isBuffer(entry.buffer)) { + encoder.setVertexBuffer(idx, root.unwrap(entry.buffer), entry.offset, entry.size); + } else { + encoder.setVertexBuffer(idx, entry.buffer, entry.offset, entry.size); + } + }); + + if (missingVertexLayouts.size > 0) { + throw new MissingVertexBuffersError(missingVertexLayouts); + } +} + +function applyRenderPipelineState( encoder: GPURenderPassEncoder | GPURenderBundleEncoder, root: ExperimentalTgpuRoot, pipeline: TgpuRenderPipeline, @@ -84,7 +173,7 @@ export function applyRenderPipelineState( } } -export function applyComputePipelineState( +function applyComputePipelineState( encoder: GPUComputePassEncoder, root: ExperimentalTgpuRoot, pipeline: TgpuComputePipeline, @@ -103,10 +192,6 @@ export function applyComputePipelineState( ); } -/** - * Guards an indexed draw, given the index buffer the pipeline was configured - * with and the one set on the pass it draws into (if any). - */ export function requireIndexBuffer( priorIndexBuffer: IndexBufferEntry | undefined, passIndexBuffer: IndexBufferEntry | undefined, @@ -118,11 +203,7 @@ export function requireIndexBuffer( } } -/** - * Warns that work which can only be reported after submission is lost, because - * the raw encoder belongs to the caller and is submitted behind our back. - */ -export function warnAboutUnreachableSubmission(core: object, what: string): void { +function warnAboutUnreachableSubmission(core: object, what: string): void { warnOnce( core, what, @@ -130,12 +211,8 @@ export function warnAboutUnreachableSubmission(core: object, what: string): void ); } -/** - * Queues a drain of the shader's log buffers for after the encoder is - * submitted. Returns false when there is no encoder to defer the read to, - * meaning the output is lost. - */ -export function queueLogDrain( +/** Returns false when there is no encoder to defer the read to, meaning the output is lost */ +function queueLogDrain( encoder: TgpuCommandEncoder | undefined, logResources: LogResources, ): boolean { @@ -147,6 +224,25 @@ export function queueLogDrain( return true; } +export function finalizeOwnEncoder( + encoder: TgpuCommandEncoder, + core: object, + logResources: LogResources | undefined, + priors: TimestampWritesPriors & { readonly encoder?: TgpuCommandEncoder | undefined }, +): void { + if (logResources && !queueLogDrain(encoder, logResources)) { + warnAboutUnreachableSubmission(core, 'Shader console.log output'); + } + + if (priors.performanceCallback && !queueTimestampResolve(encoder, priors)) { + warnAboutUnreachableSubmission(core, 'The performance callback'); + } + + if (priors.encoder === undefined) { + encoder.submit(); + } +} + const PassKindWording = { render: { into: 'drawing into a render pass', @@ -160,21 +256,24 @@ const PassKindWording = { }, } as const; -/** - * Reports the pass-level priors that a pipeline cannot honor, because the pass - * it draws into was begun by someone else. Shader logs are the exception: they - * are read back after submission, so they can still be drained as long as the - * pass belongs to a TypeGPU encoder. - */ function reportIgnoredPriors( core: object, owner: TgpuCommandEncoder | undefined, hasTimestampWrites: boolean, logResources: LogResources | undefined, passKind: 'render' | 'compute', + hasAttachments = false, ): void { const wording = PassKindWording[passKind]; + if (hasAttachments) { + warnOnce( + core, + 'attachments', + `Pipeline-level attachments are ignored when ${wording.into}. Pass \`colorAttachments\` and \`depthStencilAttachment\` to encoder.${wording.begin} instead.`, + ); + } + if (hasTimestampWrites) { warnOnce( core, @@ -192,13 +291,6 @@ function reportIgnoredPriors( } } -/** - * Records a draw into a typed render pass, applying the pipeline's state - * (and the pass's, where the pipeline does not override it) beforehand. - * - * @param ownsPass - Whether the pipeline began this pass itself, and so honors - * its own pass-level priors instead of dropping them. - */ export function emitRenderDraw( root: ExperimentalTgpuRoot, passInternals: RenderPassInternals, @@ -222,6 +314,7 @@ export function emitRenderDraw( !!priors.timestampWrites, memo.logResources, 'render', + !!priors.colorAttachment || !!priors.depthStencilAttachment, ); } @@ -237,7 +330,6 @@ export function emitRenderDraw( emit(rawPass); } -/** The compute counterpart of {@link emitRenderDraw} */ export function emitComputeDispatch( root: ExperimentalTgpuRoot, passInternals: ComputePassInternals, diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index 525446b6f7..c0a805e5fc 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -5,11 +5,6 @@ import { isBuiltin } from '../../data/attributes.ts'; import { type Disarray, getCustomLocation, type UndecorateRecord } from '../../data/dataTypes.ts'; import { sizeOf } from '../../data/sizeOf.ts'; import { type ResolvedSnippet, snip } from '../../data/snippet.ts'; -import type { - WgslTexture, - WgslTextureDepth2d, - WgslTextureDepthMultisampled2d, -} from '../../data/texture.ts'; import { formatToWGSLType } from '../../data/vertexFormatData.ts'; import { type AnyVecInstance, @@ -52,14 +47,6 @@ import type { TgpuVertexFn } from '../function/tgpuVertexFn.ts'; import { namespace } from '../resolve/namespace.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import type { TgpuSlot } from '../slot/slotTypes.ts'; -import { - type TextureInternals, - // oxlint-disable-next-line no-unused-vars -- used in docs - type TgpuTexture, - type TgpuTextureRenderView, - type TgpuTextureView, -} from '../texture/texture.ts'; -import type { RenderFlag } from '../texture/usageExtension.ts'; import { connectAttributesToShader } from '../vertexLayout/connectAttributesToShader.ts'; import { isVertexLayout, type TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; import { connectAttachmentToShader } from './connectAttachmentToShader.ts'; @@ -69,17 +56,13 @@ import { INTERNAL_createCommandEncoder, type TgpuCommandEncoder, } from '../commandEncoder/commandEncoder.ts'; +import type { ColorAttachment, DepthStencilAttachment } from '../commandEncoder/attachments.ts'; import { INTERNAL_adoptRenderCommands, type TgpuRenderCommands, type TgpuRenderPassDescriptor, } from '../commandEncoder/renderPass.ts'; -import { - emitRenderDraw, - queueLogDrain, - requireIndexBuffer, - warnAboutUnreachableSubmission, -} from './drawState.ts'; +import { emitRenderDraw, finalizeOwnEncoder, requireIndexBuffer } from './drawState.ts'; import { isGPUCommandEncoder, isGPURenderBundleEncoder, @@ -90,7 +73,6 @@ import { import { createWithPerformanceCallback, createWithTimestampWrites, - queueTimestampResolve, type Timeable, type TimestampWritesPriors, } from './timeable.ts'; @@ -329,139 +311,6 @@ export type FragmentOutToColorAttachment = T extends { export type AnyFragmentTargets = TgpuColorTargetState | Record; -interface ColorTextureConstraint { - readonly [$internal]: TextureInternals; - readonly resourceType: 'texture'; - readonly props: { format: GPUTextureFormat }; -} - -export interface ColorAttachment { - /** - * A {@link GPUTextureView} describing the texture subresource that will be output to for this - * color attachment. - */ - view: - | (ColorTextureConstraint & RenderFlag) - | GPUTextureView - | TgpuTextureView - | TgpuTextureRenderView - // We call `.getCurrentTexture().createView()` underneath - | GPUCanvasContext; - /** - * Indicates the depth slice index of {@link GPUTextureViewDimension#"3d"} {@link GPURenderPassColorAttachment#view} - * that will be output to for this color attachment. - */ - depthSlice?: GPUIntegerCoordinate; - /** - * A {@link GPUTextureView} describing the texture subresource that will receive the resolved - * output for this color attachment if {@link GPURenderPassColorAttachment#view} is - * multisampled. - */ - resolveTarget?: - | (ColorTextureConstraint & RenderFlag) - | GPUTextureView - | TgpuTextureView - | TgpuTextureRenderView - // We call `.getCurrentTexture().createView()` underneath - | GPUCanvasContext; - /** - * Indicates the value to clear {@link GPURenderPassColorAttachment#view} to prior to executing the - * render pass. If not map/exist|provided, defaults to `{r: 0, g: 0, b: 0, a: 0}`. Ignored - * if {@link GPURenderPassColorAttachment#loadOp} is not {@link GPULoadOp#"clear"}. - * The components of {@link GPURenderPassColorAttachment#clearValue} are all double values. - * They are converted to a texel value of texture format matching the render attachment. - * If conversion fails, a validation error is generated. - */ - clearValue?: readonly [number, number, number, number] | GPUColor; - /** - * Indicates the load operation to perform on {@link GPURenderPassColorAttachment#view} prior to - * executing the render pass. - * Note: It is recommended to prefer clearing; see {@link GPULoadOp#"clear"} for details. - * - * @default 'clear' - */ - loadOp?: GPULoadOp | undefined; - /** - * The store operation to perform on {@link GPURenderPassColorAttachment#view} - * after executing the render pass. - * - * @default 'store' - */ - storeOp?: GPUStoreOp | undefined; -} - -export type DepthStencilFormat = - | 'stencil8' - | 'depth16unorm' - | 'depth24plus' - | 'depth24plus-stencil8' - | 'depth32float' - | 'depth32float-stencil8'; - -interface DepthStencilTextureConstraint { - readonly [$internal]: TextureInternals; - readonly resourceType: 'texture'; - readonly props: { format: DepthStencilFormat }; -} - -export interface DepthStencilAttachment { - /** - * A {@link GPUTextureView} | ({@link TgpuTexture} & {@link RenderFlag}) describing the texture subresource that will be output to - * and read from for this depth/stencil attachment. - */ - view: - | (DepthStencilTextureConstraint & RenderFlag) - | TgpuTextureView - | TgpuTextureRenderView - | GPUTextureView; - /** - * Indicates the value to clear {@link GPURenderPassDepthStencilAttachment#view}'s depth component - * to prior to executing the render pass. Ignored if {@link GPURenderPassDepthStencilAttachment#depthLoadOp} - * is not {@link GPULoadOp#"clear"}. Must be between 0.0 and 1.0, inclusive (unless unrestricted depth is enabled). - */ - depthClearValue?: number; - /** - * Indicates the load operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s - * depth component prior to executing the render pass. - * Note: It is recommended to prefer clearing; see {@link GPULoadOp#"clear"} for details. - */ - depthLoadOp?: GPULoadOp; - /** - * The store operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s - * depth component after executing the render pass. - */ - depthStoreOp?: GPUStoreOp; - /** - * Indicates that the depth component of {@link GPURenderPassDepthStencilAttachment#view} - * is read only. - */ - depthReadOnly?: boolean; - /** - * Indicates the value to clear {@link GPURenderPassDepthStencilAttachment#view}'s stencil component - * to prior to executing the render pass. Ignored if {@link GPURenderPassDepthStencilAttachment#stencilLoadOp} - * is not {@link GPULoadOp#"clear"}. - * The value will be converted to the type of the stencil aspect of `view` by taking the same - * number of LSBs as the number of bits in the stencil aspect of one texel block|texel of `view`. - */ - stencilClearValue?: GPUStencilValue; - /** - * Indicates the load operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s - * stencil component prior to executing the render pass. - * Note: It is recommended to prefer clearing; see {@link GPULoadOp#"clear"} for details. - */ - stencilLoadOp?: GPULoadOp; - /** - * The store operation to perform on {@link GPURenderPassDepthStencilAttachment#view}'s - * stencil component after executing the render pass. - */ - stencilStoreOp?: GPUStoreOp; - /** - * Indicates that the stencil component of {@link GPURenderPassDepthStencilAttachment#view} - * is read only. - */ - stencilReadOnly?: boolean; -} - export type AnyFragmentColorAttachment = ColorAttachment | Record; export type RenderPipelineCoreOptions = { @@ -537,7 +386,6 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { return this; } - /** Derives a pipeline sharing this one's core, with the given priors overridden */ #withPriors(patch: Partial): this { const { core, priors } = this[$internal]; @@ -584,7 +432,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { if (isGPURenderPassEncoder(first) || isGPURenderBundleEncoder(first)) { return this.#withPriors({ - pass: INTERNAL_adoptRenderCommands(internals.core.options.root, first), + pass: INTERNAL_adoptRenderCommands(internals.root, first), encoder: undefined, }); } @@ -592,7 +440,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { if (isGPUCommandEncoder(first)) { return this.#withPriors({ pass: undefined, - encoder: INTERNAL_adoptCommandEncoder(internals.core.options.root, first), + encoder: INTERNAL_adoptCommandEncoder(internals.root, first), }); } @@ -646,9 +494,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { }): this { const internals = this[$internal]; - return this.#withPriors( - createWithTimestampWrites(internals.priors, options, internals.core.options.root), - ); + return this.#withPriors(createWithTimestampWrites(internals.priors, options, internals.root)); } withColorAttachment(attachment: AnyFragmentColorAttachment): this { @@ -726,7 +572,6 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { this[$internal].core.initSync(); } - /** The descriptor of the pass this pipeline begins when it is not given one */ #ownPassDescriptor(): TgpuRenderPassDescriptor { const internals = this[$internal]; const { descriptor } = internals.core.options; @@ -746,26 +591,18 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { }; } - /** - * The single route from a draw call to the GPU. Either the pipeline was - * given a pass to draw into, or it begins one of its own. If it was not given - * an encoder either, it owns the submission too. - */ #execute( usesIndexBuffer: boolean, emit: (rawPass: GPURenderPassEncoder | GPURenderBundleEncoder) => void, ): void { - const internals = this[$internal]; - const { priors } = internals; - const { root } = internals.core.options; + const { core, priors, root } = this[$internal]; if (priors.pass) { emitRenderDraw(root, priors.pass[$internal], this, usesIndexBuffer, emit); return; } - // Checked up front, so that a rejected draw never leaves a half-recorded - // pass behind on an encoder the caller owns. + // checked up front so a rejected draw never leaves a half-recorded pass behind if (usesIndexBuffer) { requireIndexBuffer(priors.indexBuffer, undefined); } @@ -775,18 +612,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { emitRenderDraw(root, pass[$internal], this, usesIndexBuffer, emit, /* ownsPass */ true); pass.end(); - const { logResources } = internals.core.unwrap(); - if (logResources && !queueLogDrain(encoder, logResources)) { - warnAboutUnreachableSubmission(internals.core, 'Shader console.log output'); - } - - if (priors.performanceCallback && !queueTimestampResolve(encoder, priors)) { - warnAboutUnreachableSubmission(internals.core, 'The performance callback'); - } - - if (priors.encoder === undefined) { - encoder.submit(); - } + finalizeOwnEncoder(encoder, core, core.unwrap().logResources, priors); } draw( diff --git a/packages/typegpu/src/core/pipeline/timeable.ts b/packages/typegpu/src/core/pipeline/timeable.ts index 075056ccc3..f5381edd24 100644 --- a/packages/typegpu/src/core/pipeline/timeable.ts +++ b/packages/typegpu/src/core/pipeline/timeable.ts @@ -100,12 +100,7 @@ async function readTimestamps( await callback(start, end); } -/** - * Arranges for the pipeline's timestamps to be resolved as part of the given - * encoder's submission, and for the performance callback to fire afterwards. - * Returns false when the encoder is one we cannot defer work to, meaning the - * callback will never fire. - */ +/** Returns false when the encoder is one we cannot defer work to, meaning the callback never fires */ export function queueTimestampResolve( encoder: TgpuCommandEncoder, priors: TimestampWritesPriors, @@ -133,8 +128,7 @@ export function queueTimestampResolve( const { root } = internals; - // Recorded at submission time, so that it captures the last pass written into - // this encoder rather than whichever one happened to register first. + // recorded at submission time to capture the last pass written into this encoder internals.beforeFinish.set(querySet, (rawEncoder) => { rawEncoder.resolveQuerySet( root.unwrap(querySet), diff --git a/packages/typegpu/src/core/pipeline/typeGuards.ts b/packages/typegpu/src/core/pipeline/typeGuards.ts index 166d87edd1..f83c26530b 100644 --- a/packages/typegpu/src/core/pipeline/typeGuards.ts +++ b/packages/typegpu/src/core/pipeline/typeGuards.ts @@ -50,6 +50,7 @@ export function isGPUCommandEncoder(value: unknown): value is GPUCommandEncoder return ( !!value && typeof value === 'object' && + !($internal in value) && 'beginRenderPass' in value && 'beginComputePass' in value ); @@ -59,19 +60,27 @@ export function isGPUComputePassEncoder(value: unknown): value is GPUComputePass return ( !!value && typeof value === 'object' && + !($internal in value) && 'dispatchWorkgroups' in value && !('beginRenderPass' in value) ); } export function isGPURenderPassEncoder(value: unknown): value is GPURenderPassEncoder { - return !!value && typeof value === 'object' && 'executeBundles' in value && 'draw' in value; + return ( + !!value && + typeof value === 'object' && + !($internal in value) && + 'executeBundles' in value && + 'draw' in value + ); } export function isGPURenderBundleEncoder(value: unknown): value is GPURenderBundleEncoder { return ( !!value && typeof value === 'object' && + !($internal in value) && 'draw' in value && 'finish' in value && !('executeBundles' in value) && diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts index 0e0d9b28da..e54d61b55e 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -58,12 +58,8 @@ export type { } from './core/root/rootTypes.ts'; export type { Storage, StorageFlag } from './extension.ts'; export type { TgpuVertexLayout } from './core/vertexLayout/vertexLayout.ts'; -export type { - ColorAttachment, - DepthStencilAttachment, - TgpuPrimitiveState, - TgpuRenderPipeline, -} from './core/pipeline/renderPipeline.ts'; +export type { TgpuPrimitiveState, TgpuRenderPipeline } from './core/pipeline/renderPipeline.ts'; +export type { ColorAttachment, DepthStencilAttachment } from './core/commandEncoder/attachments.ts'; export type { TgpuComputePipeline } from './core/pipeline/computePipeline.ts'; export type { TgpuCommandEncoder } from './core/commandEncoder/commandEncoder.ts'; export type { diff --git a/packages/typegpu/tests/commandEncoder.test.ts b/packages/typegpu/tests/commandEncoder.test.ts index 7c24a70aaa..a7e2d10ac2 100644 --- a/packages/typegpu/tests/commandEncoder.test.ts +++ b/packages/typegpu/tests/commandEncoder.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, type Mock } from 'vitest'; +import { describe, expect, type Mock, vi } from 'vitest'; import { Void } from 'typegpu/data'; -import { tgpu, d } from 'typegpu'; +import { tgpu, d, type TgpuRoot } from 'typegpu'; import { it } from 'typegpu-testing-utility'; function passDescriptor(beginRenderPass: Mock, index = 0): GPURenderPassDescriptor { @@ -235,10 +235,13 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setStencilReference).toBeCalledTimes(3); - expect(renderPassEncoder.setStencilReference).nthCalledWith(1, 5); - expect(renderPassEncoder.setStencilReference).nthCalledWith(2, 7); - expect(renderPassEncoder.setStencilReference).nthCalledWith(3, 2); + // Pass-level references apply eagerly; pipeline-level ones override at + // draw time and the pass state is restored for the next pipeline + expect(renderPassEncoder.setStencilReference).toBeCalledTimes(4); + expect(renderPassEncoder.setStencilReference).nthCalledWith(1, 7); + expect(renderPassEncoder.setStencilReference).nthCalledWith(2, 5); + expect(renderPassEncoder.setStencilReference).nthCalledWith(3, 7); + expect(renderPassEncoder.setStencilReference).nthCalledWith(4, 2); }); it('resets a pipeline stencil reference for the next pipeline', ({ root, renderPassEncoder }) => { @@ -519,6 +522,52 @@ describe('TgpuCommandEncoder', () => { }); }); + describe('ignored pipeline priors', () => { + const colorFragment = tgpu.fragmentFn({ out: { color: d.vec4f } })(''); + + function colorPipeline(root: TgpuRoot) { + return root.createRenderPipeline({ + vertex: plainVertex, + fragment: colorFragment, + targets: { color: { format: 'rgba8unorm' } }, + }); + } + + it('warns once that pipeline attachments are dropped when drawing into a pass', ({ root }) => { + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const pipeline = colorPipeline(root) + .withColorAttachment({ color: { view: {} as unknown as GPUTextureView } }) + .withDepthStencilAttachment({ view: {} as unknown as GPUTextureView }); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pipeline.with(pass).draw(3); + pipeline.with(pass).draw(3); + pass.end(); + encoder.submit(); + + expect(consoleWarnSpy).toBeCalledTimes(1); + expect(consoleWarnSpy.mock.calls[0]?.[0]).toMatchInlineSnapshot( + `"Pipeline-level attachments are ignored when drawing into a render pass. Pass \`colorAttachments\` and \`depthStencilAttachment\` to encoder.beginRenderPass instead."`, + ); + }); + + it('does not warn when the pipeline begins its own pass', ({ root }) => { + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const pipeline = colorPipeline(root).withColorAttachment({ + color: { view: {} as unknown as GPUTextureView }, + }); + + const encoder = root.createCommandEncoder(); + pipeline.with(encoder).draw(3); + encoder.submit(); + + expect(consoleWarnSpy).not.toBeCalled(); + }); + }); + describe('compute pass', () => { const computeLayout = tgpu.bindGroupLayout({ data: { uniform: d.f32 } }); From 1598f204fd446d2a1578cf592a0072adeeaa0352 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 27 Jul 2026 19:45:35 +0200 Subject: [PATCH 06/10] passes hold all draw state, with pipelines stamping their bindings into it in call order --- .../docs/advanced/timestamp-queries.mdx | 2 +- .../src/content/docs/apis/pipelines.mdx | 36 +++--- .../src/core/commandEncoder/computePass.ts | 15 ++- .../src/core/commandEncoder/renderPass.ts | 19 +-- .../typegpu/src/core/pipeline/drawState.ts | 115 ++++++++++-------- .../src/core/pipeline/renderPipeline.ts | 2 +- packages/typegpu/tests/commandEncoder.test.ts | 53 +++++--- 7 files changed, 144 insertions(+), 98 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx b/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx index 965839733b..bb0ba5be77 100644 --- a/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx +++ b/apps/typegpu-docs/src/content/docs/advanced/timestamp-queries.mdx @@ -46,7 +46,7 @@ const pipeline = root If you haven’t provided a `TgpuQuerySet` before calling `.withPerformanceCallback()`, TypeGPU will allocate one for you along with the necessary resolve buffers. * **With a command encoder** - Performance callbacks also work when the pipeline is bound to a [command encoder](/TypeGPU/apis/pipelines/#command-encoders-and-passes) with `pipeline.with(encoder)`. The timestamps are resolved as part of that encoder's submission, and the callback fires from `encoder.submit()`. They cannot be used with a pass begun by someone else, since a pass writes its timestamps as part of its descriptor. Give those to `encoder.beginRenderPass` / `encoder.beginComputePass` instead. + Performance callbacks also work when the pipeline is bound to a [command encoder](/TypeGPU/apis/pipelines/#command-encoders-and-passes) with `pipeline.with(encoder)`. The timestamps are resolved as part of that encoder's submission, and the callback is invoked after `encoder.submit()`. They are not supported when drawing into a shared pass, since timestamp writes are part of the pass descriptor. In that case, provide `timestampWrites` to `encoder.beginRenderPass` or `encoder.beginComputePass` instead. ## Using `TgpuQuerySet` diff --git a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx index 3c9c8df1c5..e34f18bc88 100644 --- a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx @@ -564,8 +564,9 @@ The caller remains responsible for ending the pass or finishing the bundle. `createCommandEncoder` and typed passes are *unstable* features. The API may be subject to change in the near future. ::: -When executed directly via `draw` or `dispatchWorkgroups`, each pipeline records its own pass into its own command encoder and submits it immediately. -For more demanding scenarios, such as batching multiple pipelines into a single render pass, or multiple passes into a single submission, TypeGPU exposes a command encoder API that mirrors WebGPU, enriched with direct TypeGPU resource support. +When a pipeline is executed directly via `draw` or `dispatchWorkgroups`, it records its own pass and submits it to the GPU queue immediately. +For scenarios that require more control, such as executing multiple pipelines in a single render pass or batching multiple passes into a single submission, TypeGPU provides a typed equivalent of the WebGPU command encoder. +It can be created with the `createCommandEncoder` method on the root object and mirrors `GPUCommandEncoder`, while accepting TypeGPU resources directly. ```ts const encoder = root['~unstable'].createCommandEncoder(); @@ -588,15 +589,15 @@ pass.end(); encoder.submit(); ``` -Compared to the raw WebGPU API: +The `beginRenderPass` method accepts a descriptor similar to WebGPU's `GPURenderPassDescriptor`, with a few conveniences: -- Attachment views accept TypeGPU textures, texture views and canvas contexts, next to raw `GPUTextureView`s. -- Common descriptor properties get sensible defaults: `loadOp: 'clear'`, `storeOp: 'store'`, and for depth attachments `depthClearValue: 1`. A single color attachment can be passed without wrapping it in an array. -- `occlusionQuerySet` and `timestampWrites` accept [TypeGPU query sets](/TypeGPU/advanced/timestamp-queries/) next to raw `GPUQuerySet`s. +- Attachment views can be TypeGPU textures, texture views and canvas contexts, as well as raw `GPUTextureView`s. +- `loadOp`, `storeOp` and `depthClearValue` default to `'clear'`, `'store'` and `1` respectively. A single color attachment does not need to be wrapped in an array. +- `occlusionQuerySet` and `timestampWrites` accept [TypeGPU query sets](/TypeGPU/advanced/timestamp-queries/) as well as raw `GPUQuerySet`s. -Pipelines can draw into a pass in two equivalent ways. -Binding the pass with `pipeline.with(pass)` keeps the pipeline-centric API, including all of its `with*` methods. -Alternatively, the pass itself exposes a proxy surface mirroring `GPURenderPassEncoder`, but accepting TypeGPU resources. +There are two equivalent ways to execute pipelines in a pass. +Passing the pass to `pipeline.with(pass)` keeps the pipeline-centric API, together with all of its `with*` methods. +Alternatively, the pass itself mirrors the `GPURenderPassEncoder` API, while accepting TypeGPU resources. ```ts pass.setPipeline(renderPipeline); @@ -605,9 +606,8 @@ pass.setVertexBuffer(vertexLayout, vertexBuffer); pass.draw(3); ``` -In both styles, the underlying pipeline, bind groups and vertex buffers are applied lazily and deduplicated, so repeated draws only re-record what actually changed. Pipeline-level bindings (`pipeline.with(bindGroup)`) take precedence over pass-level ones (`pass.setBindGroup`). - -Note that `pipeline.with(pass)` returns a new pipeline wrapper on every call, so hoist it out of draw loops (`const bound = pipeline.with(pass)`) to let deduplication kick in. For values that change between draws, prefer pass-level state (`pass.setBindGroup`) over the allocating `with*` methods. +In both cases, the pipeline, bind groups and vertex buffers are applied lazily when a draw call is recorded, and only if they changed since the previous one. +Both styles operate on the same pass state and follow the WebGPU ordering rules: executing a pipeline sets the resources bound to it (`pipeline.with(bindGroup)`) on the pass, later `set*` calls overwrite them, and all state persists until overwritten. Compute passes work the same way: @@ -619,15 +619,15 @@ pass.end(); encoder.submit(); ``` -`encoder.submit()` carries out the work that can only be reported once the GPU has been given the commands: shader `console.log` output and [performance callbacks](/TypeGPU/advanced/timestamp-queries/). -Timestamps are resolved as part of that same submission. +Calling `encoder.submit()` finishes the recording and submits it to the device queue. +Shader `console.log` output and [performance callbacks](/TypeGPU/advanced/timestamp-queries/) are processed as part of that submission. -For anything not covered by the typed surface, there are escape hatches: +Whenever something is not covered by the typed API, the underlying WebGPU resources remain accessible: -- `root.unwrap(encoder)`, `root.unwrap(pass)`: access the raw `GPUCommandEncoder`, `GPURenderPassEncoder` or `GPUComputePassEncoder`, e.g. for buffer copies. Since raw pass commands can change state invisibly to TypeGPU, unwrapping a pass turns off state deduplication for it, and every subsequent typed draw re-applies its full state. -- `encoder.finish()`: returns the `GPUCommandBuffer` without submitting, for manual multi-encoder batching via `device.queue.submit([...])`. Since TypeGPU never sees the submission, shader logs and performance callbacks do not fire for such a command buffer. +- `root.unwrap(encoder)` and `root.unwrap(pass)` return the raw `GPUCommandEncoder`, `GPURenderPassEncoder` or `GPUComputePassEncoder`, which can be used e.g. for buffer copies. Commands recorded this way are invisible to TypeGPU, so after unwrapping a pass, every draw applies its full state again. +- `encoder.finish()` returns the raw `GPUCommandBuffer` without submitting it, allowing manual batching via `device.queue.submit([...])`. TypeGPU never sees such a submission, so shader logs and performance callbacks are not processed for it. -Passing a raw `GPUCommandEncoder` or a raw pass encoder to `pipeline.with(...)` works too, but comes with the same two limitations: TypeGPU cannot know when the caller submits, and cannot assume anything about state the caller may have set, so every draw re-applies its full state. +Raw `GPUCommandEncoder`s and pass encoders can also be passed to `pipeline.with(...)` directly, with the same limitations, since TypeGPU cannot know when they are submitted, nor what state has been set on them. It is also possible to access the underlying WebGPU resources for the TypeGPU pipelines, by calling `root.unwrap(pipeline)`. That way, they can be used with a regular WebGPU API, though this also requires unwrapping all the necessary resources. diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts index 6ffdb1b10d..03a43f83e4 100644 --- a/packages/typegpu/src/core/commandEncoder/computePass.ts +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -4,7 +4,12 @@ import type { TgpuBindGroupLayout, TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; -import { ComputeDrawState, emitComputeDispatch, recordBindGroup } from '../pipeline/drawState.ts'; +import { + ComputeDrawState, + emitComputeDispatch, + recordBindGroup, + stampComputePipeline, +} from '../pipeline/drawState.ts'; import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { type TgpuPassTimestampWrites, unwrapTimestampWrites } from './attachments.ts'; @@ -28,7 +33,7 @@ export interface ComputePassInternals { readonly state: ComputeDrawState; /** Undefined for raw pass encoders the caller owns */ readonly owner: TgpuCommandEncoder | undefined; - lastApplied: { pipeline: TgpuComputePipeline; version: number } | undefined; + appliedVersion: number | undefined; } /** @@ -108,7 +113,7 @@ class TgpuComputePassImpl implements TgpuComputePass { rawPass, state: new ComputeDrawState(), owner, - lastApplied: undefined, + appliedVersion: undefined, }; } @@ -124,9 +129,7 @@ class TgpuComputePassImpl implements TgpuComputePass { } setPipeline(pipeline: TgpuComputePipeline): void { - const { state } = this[$internal]; - state.currentPipeline = pipeline; - state.version++; + stampComputePipeline(this[$internal].state, pipeline); } setBindGroup>( diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts index 7ccaca7043..25a6be2344 100644 --- a/packages/typegpu/src/core/commandEncoder/renderPass.ts +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -8,7 +8,12 @@ import type { } from '../../tgpuBindGroupLayout.ts'; import type { TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; import { isTexture, isTextureView } from '../texture/texture.ts'; -import { emitRenderDraw, recordBindGroup, RenderDrawState } from '../pipeline/drawState.ts'; +import { + emitRenderDraw, + recordBindGroup, + RenderDrawState, + stampRenderPipeline, +} from '../pipeline/drawState.ts'; import type { TgpuRenderPipeline } from '../pipeline/renderPipeline.ts'; import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; @@ -55,7 +60,7 @@ export interface RenderPassInternals< readonly state: RenderDrawState; /** Undefined for bundle encoders and for raw pass encoders the caller owns */ readonly owner: TgpuCommandEncoder | undefined; - lastApplied: { pipeline: TgpuRenderPipeline; version: number } | undefined; + appliedVersion: number | undefined; } /** @@ -306,7 +311,7 @@ class TgpuRenderCommandsImpl< rawPass, state: new RenderDrawState(), owner, - lastApplied: undefined, + appliedVersion: undefined, }; } @@ -325,9 +330,7 @@ class TgpuRenderCommandsImpl< } setPipeline(pipeline: TgpuRenderPipeline): void { - const { state } = this[$internal]; - state.currentPipeline = pipeline; - state.version++; + stampRenderPipeline(this[$internal].state, pipeline); } setBindGroup>( @@ -421,8 +424,6 @@ class TgpuRenderPassImpl state.stencilReference = reference; rawPass.setStencilReference(reference); state.appliedStencilReference = reference; - // a pipeline-level stencil reference still has to win on the next draw - state.version++; } beginOcclusionQuery(queryIndex: GPUSize32): void { @@ -436,7 +437,7 @@ class TgpuRenderPassImpl executeBundles(bundles: Iterable): void { const internals = this[$internal]; internals.rawPass.executeBundles(bundles); - internals.lastApplied = undefined; + internals.appliedVersion = undefined; } end(): void { diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts index 881df89aab..9ed7951a05 100644 --- a/packages/typegpu/src/core/pipeline/drawState.ts +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -66,6 +66,43 @@ export function recordBindGroup( state.version++; } +/** Writes the pipeline and its bound resources into pass state; later set* calls overwrite them */ +export function stampRenderPipeline(state: RenderDrawState, pipeline: TgpuRenderPipeline): void { + const { priors } = pipeline[$internal]; + state.currentPipeline = pipeline; + + if (priors.bindGroupLayoutMap) { + for (const [layout, group] of priors.bindGroupLayoutMap) { + state.bindGroups.set(layout, group); + } + } + if (priors.vertexLayoutMap) { + for (const [layout, buffer] of priors.vertexLayoutMap) { + state.vertexBuffers.set(layout, { buffer, offset: undefined, size: undefined }); + } + } + if (priors.indexBuffer) { + state.indexBuffer = priors.indexBuffer; + } + if (priors.stencilReference !== undefined) { + state.stencilReference = priors.stencilReference; + } + state.version++; +} + +/** The compute counterpart of {@link stampRenderPipeline} */ +export function stampComputePipeline(state: ComputeDrawState, pipeline: TgpuComputePipeline): void { + const { priors } = pipeline[$internal]; + state.currentPipeline = pipeline; + + if (priors.bindGroupLayoutMap) { + for (const [layout, group] of priors.bindGroupLayoutMap) { + state.bindGroups.set(layout, group); + } + } + state.version++; +} + function applyIndexBuffer( encoder: GPURenderPassEncoder | GPURenderBundleEncoder, root: ExperimentalTgpuRoot, @@ -140,35 +177,25 @@ function applyRenderPipelineState( pipeline: TgpuRenderPipeline, passState: RenderDrawState, ): void { - const { core, priors } = pipeline[$internal]; - const memo = core.unwrap(); + const memo = pipeline[$internal].core.unwrap(); encoder.setPipeline(memo.pipeline); - applyBindGroups( - encoder, - root, - memo.usedBindGroupLayouts, - memo.catchall, - (layout) => priors.bindGroupLayoutMap?.get(layout) ?? passState.bindGroups.get(layout), + applyBindGroups(encoder, root, memo.usedBindGroupLayouts, memo.catchall, (layout) => + passState.bindGroups.get(layout), ); - applyVertexBuffers(encoder, root, memo.usedVertexLayouts, (vertexLayout) => { - const priorBuffer = priors.vertexLayoutMap?.get(vertexLayout); - return priorBuffer - ? { buffer: priorBuffer, offset: undefined, size: undefined } - : passState.vertexBuffers.get(vertexLayout); - }); + applyVertexBuffers(encoder, root, memo.usedVertexLayouts, (vertexLayout) => + passState.vertexBuffers.get(vertexLayout), + ); - const indexBuffer = priors.indexBuffer ?? passState.indexBuffer; - if (indexBuffer !== undefined) { - applyIndexBuffer(encoder, root, indexBuffer); + if (passState.indexBuffer !== undefined) { + applyIndexBuffer(encoder, root, passState.indexBuffer); } - if ('setStencilReference' in encoder) { - const stencilReference = priors.stencilReference ?? passState.stencilReference ?? 0; - if (passState.rawAccessed || stencilReference !== passState.appliedStencilReference) { - encoder.setStencilReference(stencilReference); - passState.appliedStencilReference = stencilReference; + if ('setStencilReference' in encoder && passState.stencilReference !== undefined) { + if (passState.rawAccessed || passState.stencilReference !== passState.appliedStencilReference) { + encoder.setStencilReference(passState.stencilReference); + passState.appliedStencilReference = passState.stencilReference; } } } @@ -179,24 +206,16 @@ function applyComputePipelineState( pipeline: TgpuComputePipeline, passState: ComputeDrawState, ): void { - const { core, priors } = pipeline[$internal]; - const memo = core.unwrap(); + const memo = pipeline[$internal].core.unwrap(); encoder.setPipeline(memo.pipeline); - applyBindGroups( - encoder, - root, - memo.usedBindGroupLayouts, - memo.catchall, - (layout) => priors.bindGroupLayoutMap?.get(layout) ?? passState.bindGroups.get(layout), + applyBindGroups(encoder, root, memo.usedBindGroupLayouts, memo.catchall, (layout) => + passState.bindGroups.get(layout), ); } -export function requireIndexBuffer( - priorIndexBuffer: IndexBufferEntry | undefined, - passIndexBuffer: IndexBufferEntry | undefined, -): void { - if (!priorIndexBuffer && !passIndexBuffer) { +export function requireIndexBuffer(indexBuffer: IndexBufferEntry | undefined): void { + if (!indexBuffer) { throw new Error( 'No index buffer is set. Call pipeline.withIndexBuffer or pass.setIndexBuffer before drawing indexed geometry.', ); @@ -302,8 +321,12 @@ export function emitRenderDraw( const { state, rawPass } = passInternals; const { core, priors } = pipeline[$internal]; + if (state.currentPipeline !== pipeline) { + stampRenderPipeline(state, pipeline); + } + if (usesIndexBuffer) { - requireIndexBuffer(priors.indexBuffer, state.indexBuffer); + requireIndexBuffer(state.indexBuffer); } const memo = core.unwrap(); @@ -318,13 +341,9 @@ export function emitRenderDraw( ); } - if ( - state.rawAccessed || - passInternals.lastApplied?.pipeline !== pipeline || - passInternals.lastApplied.version !== state.version - ) { + if (state.rawAccessed || passInternals.appliedVersion !== state.version) { applyRenderPipelineState(rawPass, root, pipeline, state); - passInternals.lastApplied = { pipeline, version: state.version }; + passInternals.appliedVersion = state.version; } emit(rawPass); @@ -340,6 +359,10 @@ export function emitComputeDispatch( const { state, rawPass } = passInternals; const { core, priors } = pipeline[$internal]; + if (state.currentPipeline !== pipeline) { + stampComputePipeline(state, pipeline); + } + const memo = core.unwrap(); if (!ownsPass) { reportIgnoredPriors( @@ -351,13 +374,9 @@ export function emitComputeDispatch( ); } - if ( - state.rawAccessed || - passInternals.lastApplied?.pipeline !== pipeline || - passInternals.lastApplied.version !== state.version - ) { + if (state.rawAccessed || passInternals.appliedVersion !== state.version) { applyComputePipelineState(rawPass, root, pipeline, state); - passInternals.lastApplied = { pipeline, version: state.version }; + passInternals.appliedVersion = state.version; } emit(rawPass); diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index c0a805e5fc..091ffef003 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -604,7 +604,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { // checked up front so a rejected draw never leaves a half-recorded pass behind if (usesIndexBuffer) { - requireIndexBuffer(priors.indexBuffer, undefined); + requireIndexBuffer(priors.indexBuffer); } const encoder = priors.encoder ?? INTERNAL_createCommandEncoder(root); diff --git a/packages/typegpu/tests/commandEncoder.test.ts b/packages/typegpu/tests/commandEncoder.test.ts index a7e2d10ac2..bdc8559b08 100644 --- a/packages/typegpu/tests/commandEncoder.test.ts +++ b/packages/typegpu/tests/commandEncoder.test.ts @@ -136,7 +136,7 @@ describe('TgpuCommandEncoder', () => { expect(renderPassEncoder.setBindGroup).nthCalledWith(2, 0, root.unwrap(groupB)); }); - it('prefers pipeline-level bind groups over pass-level ones', ({ root, renderPassEncoder }) => { + it('stamps pipeline-bound bind groups onto the pass', ({ root, renderPassEncoder }) => { const passGroup = root.createBindGroup(layout, { foo: root.createBuffer(d.f32).$usage('uniform'), }); @@ -159,6 +159,32 @@ describe('TgpuCommandEncoder', () => { expect(renderPassEncoder.setBindGroup).toBeCalledWith(0, root.unwrap(pipelineGroup)); }); + it('lets a later setBindGroup overwrite a stamped bind group', ({ root, renderPassEncoder }) => { + const passGroup = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + const pipelineGroup = root.createBindGroup(layout, { + foo: root.createBuffer(d.f32).$usage('uniform'), + }); + + const pipeline = root + .createRenderPipeline({ vertex: mainVertex, fragment: mainFragment }) + .with(pipelineGroup); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + const bound = pipeline.with(pass); + bound.draw(3); + pass.setBindGroup(passGroup); + bound.draw(3); + pass.end(); + encoder.submit(); + + expect(renderPassEncoder.setBindGroup).toBeCalledTimes(2); + expect(renderPassEncoder.setBindGroup).nthCalledWith(1, 0, root.unwrap(pipelineGroup)); + expect(renderPassEncoder.setBindGroup).nthCalledWith(2, 0, root.unwrap(passGroup)); + }); + it('applies a prepared index buffer when drawing proxy-style', ({ root, renderPassEncoder }) => { const indexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); const pipeline = root @@ -182,10 +208,7 @@ describe('TgpuCommandEncoder', () => { expect(renderPassEncoder.drawIndexed).toBeCalledTimes(1); }); - it('restores the pass-level index buffer after a pipeline override', ({ - root, - renderPassEncoder, - }) => { + it('keeps a stamped index buffer for the next pipeline', ({ root, renderPassEncoder }) => { const passIndexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); const pipelineIndexBuffer = root.createBuffer(d.arrayOf(d.u16, 4)).$usage('index'); @@ -201,6 +224,9 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); + // The pipeline's index buffer overwrites the pass one and stays set, + // just like on a raw WebGPU pass + expect(renderPassEncoder.setIndexBuffer).toBeCalledTimes(2); expect(renderPassEncoder.setIndexBuffer).nthCalledWith( 1, root.unwrap(pipelineIndexBuffer), @@ -210,7 +236,7 @@ describe('TgpuCommandEncoder', () => { ); expect(renderPassEncoder.setIndexBuffer).nthCalledWith( 2, - root.unwrap(passIndexBuffer), + root.unwrap(pipelineIndexBuffer), 'uint16', undefined, undefined, @@ -218,7 +244,7 @@ describe('TgpuCommandEncoder', () => { expect(renderPassEncoder.setStencilReference).not.toBeCalled(); }); - it('prefers a pipeline stencil reference and falls back to pass state', ({ + it('applies pass and pipeline stencil references in call order', ({ root, renderPassEncoder, }) => { @@ -235,16 +261,13 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - // Pass-level references apply eagerly; pipeline-level ones override at - // draw time and the pass state is restored for the next pipeline - expect(renderPassEncoder.setStencilReference).toBeCalledTimes(4); + expect(renderPassEncoder.setStencilReference).toBeCalledTimes(3); expect(renderPassEncoder.setStencilReference).nthCalledWith(1, 7); expect(renderPassEncoder.setStencilReference).nthCalledWith(2, 5); - expect(renderPassEncoder.setStencilReference).nthCalledWith(3, 7); - expect(renderPassEncoder.setStencilReference).nthCalledWith(4, 2); + expect(renderPassEncoder.setStencilReference).nthCalledWith(3, 2); }); - it('resets a pipeline stencil reference for the next pipeline', ({ root, renderPassEncoder }) => { + it('keeps a stamped stencil reference for the next pipeline', ({ root, renderPassEncoder }) => { const plain = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); const withRef = plain.withStencilReference(5); @@ -255,8 +278,8 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setStencilReference).nthCalledWith(1, 5); - expect(renderPassEncoder.setStencilReference).nthCalledWith(2, 0); + expect(renderPassEncoder.setStencilReference).toBeCalledTimes(1); + expect(renderPassEncoder.setStencilReference).toBeCalledWith(5); }); it('disables state deduplication after the pass is unwrapped', ({ root, renderPassEncoder }) => { From 02c8dd4986bab647b6374f91b70a52da140f571c Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 28 Jul 2026 00:54:32 +0200 Subject: [PATCH 07/10] polish and bug prevention --- .../src/core/commandEncoder/commandEncoder.ts | 8 ++- .../src/core/commandEncoder/computePass.ts | 5 ++ .../src/core/commandEncoder/renderPass.ts | 6 +- .../pipeline/connectAttachmentToShader.ts | 4 +- .../typegpu/src/core/pipeline/drawState.ts | 7 ++- .../src/core/pipeline/renderPipeline.ts | 28 ++++----- .../typegpu/src/core/pipeline/timeable.ts | 63 +++++++++++++++---- .../typegpu/src/core/pipeline/typeGuards.ts | 50 ++++++++------- packages/typegpu/src/core/root/init.ts | 31 ++++++++- packages/typegpu/src/core/root/rootTypes.ts | 8 +++ packages/typegpu/src/tgsl/consoleLog/types.ts | 1 + packages/typegpu/src/tgsl/wgslGenerator.ts | 6 ++ .../typegpu/tests/computePipeline.test.ts | 60 ++++++++++++++++++ .../tests/guardedComputePipeline.test.ts | 31 +++++++++ packages/typegpu/tests/renderPipeline.test.ts | 60 ++++++++++++++++++ packages/typegpu/tests/tgsl/shellless.test.ts | 21 +++++++ 16 files changed, 329 insertions(+), 60 deletions(-) diff --git a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts index c2a2283869..352631acb7 100644 --- a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -1,5 +1,7 @@ +import type { v3u, Vec3u } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; import { warnOnce } from '../../shared/warnOnce.ts'; +import type { TgpuUniform } from '../buffer/bufferBinding.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { INTERNAL_beginComputePass, @@ -25,6 +27,8 @@ export interface CommandEncoderInternals { readonly beforeFinish: Map void>; /** Callbacks run once the recorded commands have been submitted, keyed for deduplication */ readonly afterSubmit: Map void>; + /** Sizes recorded by guarded dispatches this submission, keyed by their size uniform */ + readonly guardedDispatchSizes: Map, v3u>; } /** @@ -98,6 +102,7 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { adopted, beforeFinish: new Map(), afterSubmit: new Map(), + guardedDispatchSizes: new Map(), }; } @@ -119,7 +124,7 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { } submit(): void { - const { rawEncoder, root, afterSubmit } = this[$internal]; + const { rawEncoder, root, afterSubmit, guardedDispatchSizes } = this[$internal]; this.#recordPendingCommands(); root.device.queue.submit([rawEncoder.finish()]); @@ -128,6 +133,7 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { hook(); } afterSubmit.clear(); + guardedDispatchSizes.clear(); } finish(descriptor?: GPUCommandBufferDescriptor): GPUCommandBuffer { diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts index 03a43f83e4..e5900e8c23 100644 --- a/packages/typegpu/src/core/commandEncoder/computePass.ts +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -1,9 +1,11 @@ +import type { v3u, Vec3u } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; import type { TgpuBindGroup, TgpuBindGroupLayout, TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; +import type { TgpuUniform } from '../buffer/bufferBinding.ts'; import { ComputeDrawState, emitComputeDispatch, @@ -33,6 +35,8 @@ export interface ComputePassInternals { readonly state: ComputeDrawState; /** Undefined for raw pass encoders the caller owns */ readonly owner: TgpuCommandEncoder | undefined; + /** Sizes recorded by guarded dispatches into an owner-less pass, keyed by their size uniform */ + readonly guardedDispatchSizes: Map, v3u>; appliedVersion: number | undefined; } @@ -113,6 +117,7 @@ class TgpuComputePassImpl implements TgpuComputePass { rawPass, state: new ComputeDrawState(), owner, + guardedDispatchSizes: new Map(), appliedVersion: undefined, }; } diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts index 25a6be2344..05d438c053 100644 --- a/packages/typegpu/src/core/commandEncoder/renderPass.ts +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -289,9 +289,9 @@ export function INTERNAL_adoptRenderCommands( rawPass: GPURenderPassEncoder | GPURenderBundleEncoder, ): TgpuRenderCommands { const adopted = - 'executeBundles' in rawPass - ? new TgpuRenderPassImpl(root, rawPass, undefined) - : new TgpuRenderCommandsImpl(root, rawPass, undefined); + typeof (rawPass as GPURenderPassEncoder).executeBundles === 'function' + ? new TgpuRenderPassImpl(root, rawPass as GPURenderPassEncoder, undefined) + : new TgpuRenderCommandsImpl(root, rawPass as GPURenderBundleEncoder, undefined); adopted[$internal].state.rawAccessed = true; return adopted; } diff --git a/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts b/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts index 51320a5d54..2e76672e07 100644 --- a/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts +++ b/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts @@ -1,5 +1,5 @@ import { isBuiltin } from '../../data/attributes.ts'; -import { type BaseData, isWgslStruct } from '../../data/wgslTypes.ts'; +import { type BaseData, isVoid, isWgslStruct } from '../../data/wgslTypes.ts'; import type { ColorAttachment } from '../commandEncoder/attachments.ts'; import type { AnyFragmentColorAttachment } from './renderPipeline.ts'; @@ -11,7 +11,7 @@ export function connectAttachmentToShader( fragmentOut: BaseData, attachment: AnyFragmentColorAttachment, ): ColorAttachment[] { - if (isBuiltin(fragmentOut)) { + if (isVoid(fragmentOut) || isBuiltin(fragmentOut)) { return []; } diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts index 9ed7951a05..e9bb8a7fc8 100644 --- a/packages/typegpu/src/core/pipeline/drawState.ts +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -192,9 +192,12 @@ function applyRenderPipelineState( applyIndexBuffer(encoder, root, passState.indexBuffer); } - if ('setStencilReference' in encoder && passState.stencilReference !== undefined) { + if ( + typeof (encoder as GPURenderPassEncoder).setStencilReference === 'function' && + passState.stencilReference !== undefined + ) { if (passState.rawAccessed || passState.stencilReference !== passState.appliedStencilReference) { - encoder.setStencilReference(passState.stencilReference); + (encoder as GPURenderPassEncoder).setStencilReference(passState.stencilReference); passState.appliedStencilReference = passState.stencilReference; } } diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index 091ffef003..0d6a554be7 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -355,7 +355,7 @@ type Memo = { catchall: [number, TgpuBindGroup] | undefined; logResources: LogResources | undefined; usedVertexLayouts: TgpuVertexLayout[]; - fragmentOut: BaseData; + fragmentOut: BaseData | undefined; }; class TgpuRenderPipelineImpl implements TgpuRenderPipeline { @@ -573,18 +573,13 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { } #ownPassDescriptor(): TgpuRenderPassDescriptor { - const internals = this[$internal]; - const { descriptor } = internals.core.options; - const { priors } = internals; + const { core, priors } = this[$internal]; + const { fragmentOut } = core.unwrap(); return { - label: getName(internals.core) ?? '', - colorAttachments: descriptor.fragment - ? connectAttachmentToShader( - (descriptor.fragment as TgpuFragmentFn)?.shell?.returnType ?? - internals.core.unwrap().fragmentOut, - priors.colorAttachment ?? {}, - ) + label: getName(core) ?? '', + colorAttachments: fragmentOut + ? connectAttachmentToShader(fragmentOut, priors.colorAttachment ?? {}) : [], depthStencilAttachment: priors.depthStencilAttachment, timestampWrites: priors.timestampWrites, @@ -755,7 +750,7 @@ class RenderPipelineCore implements SelfResolvable { if (this.#initAsyncPromise === undefined) { // the pipeline did not start resolution & compilation const device = this.options.root.device; - const { resolutionResult, descriptor, connectedAttribs } = + const { resolutionResult, descriptor, connectedAttribs, fragmentOut } = this.resolveAndCreateShaderModule(); const { usedBindGroupLayouts, catchall, logResources } = resolutionResult; @@ -768,7 +763,7 @@ class RenderPipelineCore implements SelfResolvable { catchall, logResources, usedVertexLayouts: connectedAttribs.usedVertexLayouts, - fragmentOut: this.#latestAutoFragmentOut as BaseData, + fragmentOut, }; this.#performanceTracker.measureCompile(device); }) @@ -789,7 +784,8 @@ class RenderPipelineCore implements SelfResolvable { } const device = this.options.root.device; - const { resolutionResult, descriptor, connectedAttribs } = this.resolveAndCreateShaderModule(); + const { resolutionResult, descriptor, connectedAttribs, fragmentOut } = + this.resolveAndCreateShaderModule(); const { usedBindGroupLayouts, catchall, logResources } = resolutionResult; this.#memo = { @@ -798,7 +794,7 @@ class RenderPipelineCore implements SelfResolvable { catchall, logResources, usedVertexLayouts: connectedAttribs.usedVertexLayouts, - fragmentOut: this.#latestAutoFragmentOut as BaseData, + fragmentOut, }; this.#performanceTracker.measureCompile(device); @@ -901,7 +897,7 @@ class RenderPipelineCore implements SelfResolvable { descriptor.multisample = tgpuDescriptor.multisample; } - return { resolutionResult, descriptor, connectedAttribs }; + return { resolutionResult, descriptor, connectedAttribs, fragmentOut }; } } diff --git a/packages/typegpu/src/core/pipeline/timeable.ts b/packages/typegpu/src/core/pipeline/timeable.ts index f5381edd24..d568e09f23 100644 --- a/packages/typegpu/src/core/pipeline/timeable.ts +++ b/packages/typegpu/src/core/pipeline/timeable.ts @@ -1,9 +1,15 @@ import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; +import { warnOnce } from '../../shared/warnOnce.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { $internal } from '../../shared/symbols.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; export interface Timeable { + /** + * Attaches a callback reporting the GPU-side start and end timestamps of the pipeline's pass. + * Repeated executions within one command encoder write to the same query set indices, + * so the callback fires once, with the timestamps of the last execution. + */ withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this; withTimestampWrites(options: { @@ -77,11 +83,20 @@ export function createWithTimestampWrites( }; } +type TimestampRegistration = { + priors: TimestampWritesPriors; + callback: (start: bigint, end: bigint) => void | Promise; +}; + +const pendingTimestampReads = new WeakMap< + TgpuCommandEncoder, + Map, TimestampRegistration[]> +>(); + async function readTimestamps( root: ExperimentalTgpuRoot, querySet: TgpuQuerySet<'timestamp'>, - priors: TimestampWritesPriors, - callback: (start: bigint, end: bigint) => void | Promise, + registrations: TimestampRegistration[], ): Promise { await root.device.queue.onSubmittedWorkDone(); @@ -90,14 +105,17 @@ async function readTimestamps( } const result = await querySet.read(); - const start = result[priors.timestampWrites?.beginningOfPassWriteIndex ?? 0]; - const end = result[priors.timestampWrites?.endOfPassWriteIndex ?? 1]; - if (start === undefined || end === undefined) { - throw new Error('QuerySet did not return valid timestamps.'); - } + for (const { priors, callback } of registrations) { + const start = result[priors.timestampWrites?.beginningOfPassWriteIndex ?? 0]; + const end = result[priors.timestampWrites?.endOfPassWriteIndex ?? 1]; - await callback(start, end); + if (start === undefined || end === undefined) { + throw new Error('QuerySet did not return valid timestamps.'); + } + + await callback(start, end); + } } /** Returns false when the encoder is one we cannot defer work to, meaning the callback never fires */ @@ -139,9 +157,32 @@ export function queueTimestampResolve( ); }); - internals.afterSubmit.set(querySet, () => { - void readTimestamps(root, querySet, priors, callback); - }); + let byQuerySet = pendingTimestampReads.get(encoder); + if (!byQuerySet) { + byQuerySet = new Map(); + pendingTimestampReads.set(encoder, byQuerySet); + } + + let registrations = byQuerySet.get(querySet); + if (!registrations) { + const regs: TimestampRegistration[] = []; + registrations = regs; + byQuerySet.set(querySet, regs); + internals.afterSubmit.set(querySet, () => { + byQuerySet.delete(querySet); + void readTimestamps(root, querySet, regs); + }); + } + + if (registrations.some((reg) => reg.priors === priors)) { + warnOnce( + querySet, + 'repeated-timed-execution', + 'Repeated executions of a timed pipeline within one command encoder write to the same query set indices, so the performance callback reports only the last execution.', + ); + } else { + registrations.push({ priors, callback }); + } return true; } diff --git a/packages/typegpu/src/core/pipeline/typeGuards.ts b/packages/typegpu/src/core/pipeline/typeGuards.ts index f83c26530b..d9343c90c0 100644 --- a/packages/typegpu/src/core/pipeline/typeGuards.ts +++ b/packages/typegpu/src/core/pipeline/typeGuards.ts @@ -1,4 +1,4 @@ -import { $internal } from '../../shared/symbols.ts'; +import { $internal, isMarkedInternal } from '../../shared/symbols.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; import type { TgpuRenderCommands, TgpuRenderPass } from '../commandEncoder/renderPass.ts'; @@ -47,44 +47,46 @@ export function isGPUCanvasContext(value: unknown): value is GPUCanvasContext { } export function isGPUCommandEncoder(value: unknown): value is GPUCommandEncoder { + const maybe = value as GPUCommandEncoder | undefined; return ( - !!value && - typeof value === 'object' && - !($internal in value) && - 'beginRenderPass' in value && - 'beginComputePass' in value + !isMarkedInternal(maybe) && + typeof maybe?.beginRenderPass === 'function' && + typeof maybe?.beginComputePass === 'function' ); } export function isGPUComputePassEncoder(value: unknown): value is GPUComputePassEncoder { + const maybe = value as (GPUComputePassEncoder & { beginRenderPass?: unknown }) | undefined; return ( - !!value && - typeof value === 'object' && - !($internal in value) && - 'dispatchWorkgroups' in value && - !('beginRenderPass' in value) + !isMarkedInternal(maybe) && + typeof maybe?.dispatchWorkgroups === 'function' && + maybe?.beginRenderPass === undefined ); } export function isGPURenderPassEncoder(value: unknown): value is GPURenderPassEncoder { + const maybe = value as GPURenderPassEncoder | undefined; return ( - !!value && - typeof value === 'object' && - !($internal in value) && - 'executeBundles' in value && - 'draw' in value + !isMarkedInternal(maybe) && + typeof maybe?.executeBundles === 'function' && + typeof maybe?.draw === 'function' ); } export function isGPURenderBundleEncoder(value: unknown): value is GPURenderBundleEncoder { + const maybe = value as + | (GPURenderBundleEncoder & { + executeBundles?: unknown; + beginRenderPass?: unknown; + dispatchWorkgroups?: unknown; + }) + | undefined; return ( - !!value && - typeof value === 'object' && - !($internal in value) && - 'draw' in value && - 'finish' in value && - !('executeBundles' in value) && - !('beginRenderPass' in value) && - !('dispatchWorkgroups' in value) + !isMarkedInternal(maybe) && + typeof maybe?.draw === 'function' && + typeof maybe?.finish === 'function' && + maybe?.executeBundles === undefined && + maybe?.beginRenderPass === undefined && + maybe?.dispatchWorkgroups === undefined ); } diff --git a/packages/typegpu/src/core/root/init.ts b/packages/typegpu/src/core/root/init.ts index 49020ede00..e177b73b7c 100644 --- a/packages/typegpu/src/core/root/init.ts +++ b/packages/typegpu/src/core/root/init.ts @@ -6,6 +6,7 @@ import { WeakMemo } from '../../memo.ts'; import { clearTextureUtilsCache } from '../texture/textureUtils.ts'; import type { BufferInitialData } from '../buffer/buffer.ts'; import { $getNameForward, $internal } from '../../shared/symbols.ts'; +import { warnOnce } from '../../shared/warnOnce.ts'; import type { ExtractBindGroupInputFromLayout, TgpuBindGroup, @@ -137,8 +138,12 @@ export class TgpuGuardedComputePipelineImpl< } with(bindGroup: TgpuBindGroup): TgpuGuardedComputePipeline; + with(pass: TgpuComputePass): TgpuGuardedComputePipeline; + with(encoder: TgpuCommandEncoder): TgpuGuardedComputePipeline; with(encoder: GPUCommandEncoder): TgpuGuardedComputePipeline; - with(bindGroupOrEncoder: TgpuBindGroup | GPUCommandEncoder): TgpuGuardedComputePipeline { + with( + bindGroupOrEncoder: TgpuBindGroup | TgpuComputePass | TgpuCommandEncoder | GPUCommandEncoder, + ): TgpuGuardedComputePipeline { return new TgpuGuardedComputePipelineImpl( this.#root, this.#pipeline.with(bindGroupOrEncoder as TgpuBindGroup & GPUCommandEncoder), @@ -171,8 +176,32 @@ export class TgpuGuardedComputePipelineImpl< ); } + #trackBatchedSize(size: v3u): void { + const priors = this.#pipeline[$internal].priors; + const target = priors.pass ?? priors.encoder; + if (!target) { + return; + } + + const scope = isTgpuComputePass(target) ? (target[$internal].owner ?? target) : target; + const submittable = isTgpuCommandEncoder(scope) && !scope[$internal].adopted; + const sizes = scope[$internal].guardedDispatchSizes; + + const prev = sizes.get(this.#sizeUniform); + if (prev && !allEq(prev, size)) { + const message = + 'Differently-sized dispatchThreads calls cannot be batched into one submission, since they share a size uniform and every recorded dispatch observes the last written size. Submit between the dispatches, or use separate pipelines.'; + if (submittable) { + throw new Error(message); + } + warnOnce(scope, 'guarded-dispatch-size', message); + } + sizes.set(this.#sizeUniform, size); + } + dispatchThreads(...threads: TArgs): void { const sanitizedSize = toVec3(threads); + this.#trackBatchedSize(sanitizedSize); const workgroupCount = ceil(vec3f(sanitizedSize).div(vec3f(this.#workgroupSize))); if (!allEq(sanitizedSize, this.#lastSize)) { // Only updating the size if it has changed from the last diff --git a/packages/typegpu/src/core/root/rootTypes.ts b/packages/typegpu/src/core/root/rootTypes.ts index 6721513acd..915324e106 100644 --- a/packages/typegpu/src/core/root/rootTypes.ts +++ b/packages/typegpu/src/core/root/rootTypes.ts @@ -37,6 +37,7 @@ import type { IORecord } from '../function/fnTypes.ts'; import type { TgpuFragmentFn, VertexOutToVarying } from '../function/tgpuFragmentFn.ts'; import type { TgpuVertexFn } from '../function/tgpuVertexFn.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; +import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; import type { TgpuRenderCommands } from '../commandEncoder/renderPass.ts'; import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts'; import type { FragmentOutToTargets, TgpuRenderPipeline } from '../pipeline/renderPipeline.ts'; @@ -59,11 +60,18 @@ export interface TgpuGuardedComputePipeline e */ with(bindGroup: TgpuBindGroup): TgpuGuardedComputePipeline; + /** + * Returns a pipeline wrapper that dispatches into the provided compute pass. + * Analogous to `TgpuComputePipeline.with(pass)`. + */ + with(pass: TgpuComputePass): TgpuGuardedComputePipeline; + /** * Returns a pipeline wrapper that encodes dispatches into the provided * command encoder instead of submitting them immediately. * Analogous to `TgpuComputePipeline.with(encoder)`. */ + with(encoder: TgpuCommandEncoder): TgpuGuardedComputePipeline; with(encoder: GPUCommandEncoder): TgpuGuardedComputePipeline; /** diff --git a/packages/typegpu/src/tgsl/consoleLog/types.ts b/packages/typegpu/src/tgsl/consoleLog/types.ts index 7f0114ea18..34ad9b763f 100644 --- a/packages/typegpu/src/tgsl/consoleLog/types.ts +++ b/packages/typegpu/src/tgsl/consoleLog/types.ts @@ -13,6 +13,7 @@ export interface LogGeneratorOptions { /** * The maximum number of logs that appear during a single draw/dispatch call. * If this number is exceeded, a warning containing the total number of calls is logged and further logs are dropped. + * Draws and dispatches recorded into a shared command encoder count against a single limit for the whole submission. * @default 64 */ logCountLimit?: number; diff --git a/packages/typegpu/src/tgsl/wgslGenerator.ts b/packages/typegpu/src/tgsl/wgslGenerator.ts index c56ca5f135..1399436130 100644 --- a/packages/typegpu/src/tgsl/wgslGenerator.ts +++ b/packages/typegpu/src/tgsl/wgslGenerator.ts @@ -1067,6 +1067,11 @@ ${this.ctx.pre}}`; ? this._typedExpression(returnNode, expectedReturnType) : this._expression(returnNode); + if (returnSnippet.value === undefined && wgsl.isVoid(returnSnippet.dataType)) { + this.ctx.reportReturnType(wgsl.Void); + return `${this.ctx.pre}return;`; + } + if (returnSnippet.value instanceof RefOperator) { throw new WgslTypeError( `Cannot return '${stringifyNode(returnNode)}' because it is a d.ref`, @@ -1127,6 +1132,7 @@ Try 'return ${typeStr}(${str});' instead. return stitch`${this.ctx.pre}return ${returnSnippet};`; } + this.ctx.reportReturnType(wgsl.Void); return `${this.ctx.pre}return;`; } diff --git a/packages/typegpu/tests/computePipeline.test.ts b/packages/typegpu/tests/computePipeline.test.ts index 8e659a06d2..3fdaf11ade 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -184,6 +184,66 @@ describe('TgpuComputePipeline', () => { expect(commandEncoder.resolveQuerySet).toHaveBeenCalledTimes(1); }); + it('reads a shared query set once and fires every performance callback', async ({ + root, + commandEncoder, + }) => { + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const querySet = root.createQuerySet('timestamp', 4); + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + const encoder = root['~unstable'].createCommandEncoder(); + + root + .createComputePipeline({ compute: entryFn }) + .withTimestampWrites({ querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 }) + .withPerformanceCallback(callback1) + .with(encoder) + .dispatchWorkgroups(1); + + root + .createComputePipeline({ compute: entryFn }) + .withTimestampWrites({ querySet, beginningOfPassWriteIndex: 2, endOfPassWriteIndex: 3 }) + .withPerformanceCallback(callback2) + .with(encoder) + .dispatchWorkgroups(1); + + encoder.submit(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(commandEncoder.resolveQuerySet).toHaveBeenCalledTimes(1); + expect(callback1).toHaveBeenCalledWith(0n, 0n); + expect(callback2).toHaveBeenCalledWith(0n, 0n); + }); + + it('warns when a timed pipeline executes repeatedly in one encoder', async ({ root }) => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const querySet = root.createQuerySet('timestamp', 2); + const callback = vi.fn(); + + const encoder = root['~unstable'].createCommandEncoder(); + const pipeline = root + .createComputePipeline({ compute: entryFn }) + .withTimestampWrites({ querySet }) + .withPerformanceCallback(callback) + .with(encoder); + + pipeline.dispatchWorkgroups(1); + pipeline.dispatchWorkgroups(1); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Repeated executions of a timed pipeline within one command encoder write to the same query set indices, so the performance callback reports only the last execution.', + ); + + encoder.submit(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(callback).toHaveBeenCalledTimes(1); + consoleWarnSpy.mockRestore(); + }); + it('warns that a performance callback cannot be reported on a raw encoder', ({ root, commandEncoder, diff --git a/packages/typegpu/tests/guardedComputePipeline.test.ts b/packages/typegpu/tests/guardedComputePipeline.test.ts index 6f47db4d48..c77deae337 100644 --- a/packages/typegpu/tests/guardedComputePipeline.test.ts +++ b/packages/typegpu/tests/guardedComputePipeline.test.ts @@ -41,6 +41,37 @@ describe('TgpuGuardedComputePipeline', () => { expect(spy).toHaveBeenCalledWith(callback); }); + it('rejects differently-sized dispatches recorded into one encoder', ({ root }) => { + const guarded = root.createGuardedComputePipeline((_x: number) => { + 'use gpu'; + }); + + const encoder = root['~unstable'].createCommandEncoder(); + const batched = guarded.with(encoder); + + batched.dispatchThreads(1); + expect(() => batched.dispatchThreads(512)).toThrowErrorMatchingInlineSnapshot( + `[Error: Differently-sized dispatchThreads calls cannot be batched into one submission, since they share a size uniform and every recorded dispatch observes the last written size. Submit between the dispatches, or use separate pipelines.]`, + ); + + encoder.submit(); + expect(() => batched.dispatchThreads(512)).not.toThrow(); + }); + + it('allows same-sized dispatches recorded into one pass', ({ root }) => { + const guarded = root.createGuardedComputePipeline((_x: number) => { + 'use gpu'; + }); + + const encoder = root['~unstable'].createCommandEncoder(); + const pass = encoder.beginComputePass(); + const batched = guarded.with(pass); + + batched.dispatchThreads(64); + expect(() => batched.dispatchThreads(64)).not.toThrow(); + expect(() => batched.dispatchThreads(65)).toThrow(); + }); + it('delegates `withTimestampWrites` to the underlying pipeline', ({ root }) => { const querySet = root.createQuerySet('timestamp', 2); const guarded = root.createGuardedComputePipeline(() => { diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index e424c09595..0efea462da 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -1505,6 +1505,66 @@ describe('Render Bundles', () => { expect(rawDescriptor.depthStencilAttachment?.depthClearValue).toBe(1); }); + it('begins a pass with no color attachments when the fragment outputs only builtins or nothing', ({ + root, + commandEncoder, + }) => { + const depthTexture = root + .createTexture({ size: [64, 64], format: 'depth24plus' }) + .$usage('render'); + + root + .createRenderPipeline({ + vertex: () => { + 'use gpu'; + return { $position: d.vec4f() }; + }, + fragment: () => { + 'use gpu'; + return { $fragDepth: 0.5 }; + }, + }) + .withDepthStencilAttachment({ view: depthTexture }) + .draw(3); + + root + .createRenderPipeline({ + vertex: () => { + 'use gpu'; + return { $position: d.vec4f() }; + }, + fragment: () => { + 'use gpu'; + return undefined; + }, + }) + .withDepthStencilAttachment({ view: depthTexture }) + .draw(3); + + const shelledFragment = tgpu.fragmentFn({ + out: d.builtin.fragDepth, + })`{ return 0.5; }`; + + root + .createRenderPipeline({ + vertex: () => { + 'use gpu'; + return { $position: d.vec4f() }; + }, + fragment: shelledFragment, + }) + .withDepthStencilAttachment({ view: depthTexture }) + .draw(3); + + expect(commandEncoder.beginRenderPass).toHaveBeenCalledTimes(3); + for (const call of [1, 2, 3]) { + expect(commandEncoder.beginRenderPass).toHaveBeenNthCalledWith( + call, + expect.objectContaining({ colorAttachments: [] }), + ); + } + }); + it('binds to a typed bundle pass', ({ root, renderBundleEncoder }) => { const pipeline = createPipeline(root); diff --git a/packages/typegpu/tests/tgsl/shellless.test.ts b/packages/typegpu/tests/tgsl/shellless.test.ts index 0bcd987d20..6bff1c0f98 100644 --- a/packages/typegpu/tests/tgsl/shellless.test.ts +++ b/packages/typegpu/tests/tgsl/shellless.test.ts @@ -137,6 +137,27 @@ describe('shellless', () => { `); }); + it('throws when mixing void and value returns', () => { + const someFn = (a: number, b: number) => { + 'use gpu'; + if (a > b) { + return; + } + return a + b; + }; + + const main = tgpu.fn([])(() => { + someFn(1.1, 2); + }); + + expect(() => tgpu.resolve([main])).toThrowErrorMatchingInlineSnapshot(` + [Error: Resolution of the following tree failed: + - + - fn:main + - fn*:someFn(f32, i32): Expected function to have a single return type, got [void, f32]. Cast explicitly to the desired type.] + `); + }); + it('handles nested shellless', () => { const fn1 = () => { 'use gpu'; From 50da747a5d45e221fe6649c46374788e10fe2920 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Tue, 28 Jul 2026 01:19:04 +0200 Subject: [PATCH 08/10] kill remnants of the old api: beginRenderBundleEncoder -> createRenderBundleEncoder --- .../rendering/render-bundles/index.ts | 33 ++++++++------- packages/typegpu-gl/src/tgpuRootWebGL.ts | 8 +--- .../src/core/commandEncoder/renderPass.ts | 41 ++++++++++++++++--- .../typegpu/src/core/pipeline/typeGuards.ts | 2 +- packages/typegpu/src/core/root/init.ts | 15 ++----- packages/typegpu/src/core/root/rootTypes.ts | 25 ++++++----- packages/typegpu/src/indexNamedExports.ts | 1 + packages/typegpu/tests/renderPipeline.test.ts | 10 +++-- 8 files changed, 80 insertions(+), 55 deletions(-) diff --git a/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts b/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts index dee179df1d..d778c6bf43 100644 --- a/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts +++ b/apps/typegpu-docs/src/examples/rendering/render-bundles/index.ts @@ -109,23 +109,22 @@ function generateCubes(count: number) { } function buildBundle(): GPURenderBundle { - return root['~unstable'].beginRenderBundleEncoder( - { - colorFormats: [presentationFormat], - depthStencilFormat: 'depth24plus', - }, - (pass) => { - pass.setPipeline(pipeline); - pass.setBindGroup(cameraLayout, cameraBindGroup); - pass.setBindGroup(cubeLayout, cubeBindGroup); - pass.setBindGroup(terrainLayout, terrainBindGroup); - pass.setVertexBuffer(vertexLayout, vertexBuffer); - - for (let i = 0; i < cubeCount; i++) { - pass.draw(VERTS_PER_CUBE, 1, 0, i); - } - }, - ); + const bundleEncoder = root['~unstable'].createRenderBundleEncoder({ + colorFormats: [presentationFormat], + depthStencilFormat: 'depth24plus', + }); + + bundleEncoder.setPipeline(pipeline); + bundleEncoder.setBindGroup(cameraLayout, cameraBindGroup); + bundleEncoder.setBindGroup(cubeLayout, cubeBindGroup); + bundleEncoder.setBindGroup(terrainLayout, terrainBindGroup); + bundleEncoder.setVertexBuffer(vertexLayout, vertexBuffer); + + for (let i = 0; i < cubeCount; i++) { + bundleEncoder.draw(VERTS_PER_CUBE, 1, 0, i); + } + + return bundleEncoder.finish(); } function setCubeCount(count: number) { diff --git a/packages/typegpu-gl/src/tgpuRootWebGL.ts b/packages/typegpu-gl/src/tgpuRootWebGL.ts index 1725fd4e20..19650bf600 100644 --- a/packages/typegpu-gl/src/tgpuRootWebGL.ts +++ b/packages/typegpu-gl/src/tgpuRootWebGL.ts @@ -492,12 +492,8 @@ export class TgpuRootWebGL { throw new WebGLFallbackUnsupportedError('createGuardedComputePipeline'); } - beginRenderPass(): never { - throw new WebGLFallbackUnsupportedError('beginRenderPass'); - } - - beginRenderBundleEncoder(): never { - throw new WebGLFallbackUnsupportedError('beginRenderBundleEncoder'); + createRenderBundleEncoder(): never { + throw new WebGLFallbackUnsupportedError('createRenderBundleEncoder'); } createTexture(): never { diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts index 05d438c053..f62a5b6b2e 100644 --- a/packages/typegpu/src/core/commandEncoder/renderPass.ts +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -73,7 +73,7 @@ export interface RenderPassInternals< */ export interface TgpuRenderCommands { readonly [$internal]: RenderPassInternals; - readonly resourceType: 'render-pass' | 'render-bundle-pass'; + readonly resourceType: 'render-pass' | 'render-bundle-encoder'; /** Sets the current {@link TgpuRenderPipeline} for subsequent draw calls */ setPipeline(pipeline: TgpuRenderPipeline): void; @@ -119,6 +119,20 @@ export interface TgpuRenderCommands { drawIndexedIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; } +/** + * Records draw commands into a render bundle, mirroring {@link GPURenderBundleEncoder}. + * + * Call `finish()` to obtain a {@link GPURenderBundle}, replayable in a render + * pass via {@link TgpuRenderPass.executeBundles}. + */ +export interface TgpuRenderBundleEncoder extends TgpuRenderCommands { + readonly [$internal]: RenderPassInternals; + readonly resourceType: 'render-bundle-encoder'; + + /** Completes the recording and returns the resulting {@link GPURenderBundle} */ + finish(descriptor?: GPURenderBundleDescriptor): GPURenderBundle; +} + /** * A render pass recording into a {@link TgpuCommandEncoder}. On top of the * draw commands, it exposes the state that WebGPU scopes to a render pass. @@ -277,11 +291,15 @@ export function INTERNAL_beginRenderPass( return new TgpuRenderPassImpl(root, rawEncoder.beginRenderPass(rawDescriptor), encoder); } -export function INTERNAL_beginRenderBundlePass( +export function INTERNAL_createRenderBundleEncoder( root: ExperimentalTgpuRoot, - bundleEncoder: GPURenderBundleEncoder, -): TgpuRenderCommands { - return new TgpuRenderCommandsImpl(root, bundleEncoder, undefined); + descriptor: GPURenderBundleEncoderDescriptor, +): TgpuRenderBundleEncoder { + return new TgpuRenderBundleEncoderImpl( + root, + root.device.createRenderBundleEncoder(descriptor), + undefined, + ); } export function INTERNAL_adoptRenderCommands( @@ -302,7 +320,7 @@ class TgpuRenderCommandsImpl< | GPURenderBundleEncoder, > implements TgpuRenderCommands { readonly [$internal]: RenderPassInternals; - readonly resourceType: 'render-pass' | 'render-bundle-pass' = 'render-bundle-pass'; + readonly resourceType: 'render-pass' | 'render-bundle-encoder' = 'render-bundle-encoder'; readonly #root: ExperimentalTgpuRoot; constructor(root: ExperimentalTgpuRoot, rawPass: TRaw, owner: TgpuCommandEncoder | undefined) { @@ -394,6 +412,17 @@ class TgpuRenderCommandsImpl< } } +class TgpuRenderBundleEncoderImpl + extends TgpuRenderCommandsImpl + implements TgpuRenderBundleEncoder +{ + override readonly resourceType = 'render-bundle-encoder'; + + finish(descriptor?: GPURenderBundleDescriptor): GPURenderBundle { + return this[$internal].rawPass.finish(descriptor); + } +} + class TgpuRenderPassImpl extends TgpuRenderCommandsImpl implements TgpuRenderPass diff --git a/packages/typegpu/src/core/pipeline/typeGuards.ts b/packages/typegpu/src/core/pipeline/typeGuards.ts index d9343c90c0..7875b84c83 100644 --- a/packages/typegpu/src/core/pipeline/typeGuards.ts +++ b/packages/typegpu/src/core/pipeline/typeGuards.ts @@ -32,7 +32,7 @@ export function isTgpuRenderPass(value: unknown): value is TgpuRenderPass { export function isTgpuRenderCommands(value: unknown): value is TgpuRenderCommands { const maybe = value as TgpuRenderCommands | undefined; return ( - (maybe?.resourceType === 'render-pass' || maybe?.resourceType === 'render-bundle-pass') && + (maybe?.resourceType === 'render-pass' || maybe?.resourceType === 'render-bundle-encoder') && !!maybe[$internal] ); } diff --git a/packages/typegpu/src/core/root/init.ts b/packages/typegpu/src/core/root/init.ts index e177b73b7c..9f9c3a43b9 100644 --- a/packages/typegpu/src/core/root/init.ts +++ b/packages/typegpu/src/core/root/init.ts @@ -49,8 +49,8 @@ import { } from '../commandEncoder/commandEncoder.ts'; import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; import { - INTERNAL_beginRenderBundlePass, - type TgpuRenderCommands, + INTERNAL_createRenderBundleEncoder, + type TgpuRenderBundleEncoder, type TgpuRenderPass, } from '../commandEncoder/renderPass.ts'; import { @@ -587,15 +587,8 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu return INTERNAL_createCommandEncoder(this, descriptor); } - beginRenderBundleEncoder( - descriptor: GPURenderBundleEncoderDescriptor, - callback: (pass: TgpuRenderCommands) => void, - ): GPURenderBundle { - const bundleEncoder = this.device.createRenderBundleEncoder(descriptor); - - callback(INTERNAL_beginRenderBundlePass(this, bundleEncoder)); - - return bundleEncoder.finish(); + createRenderBundleEncoder(descriptor: GPURenderBundleEncoderDescriptor): TgpuRenderBundleEncoder { + return INTERNAL_createRenderBundleEncoder(this, descriptor); } flush() { diff --git a/packages/typegpu/src/core/root/rootTypes.ts b/packages/typegpu/src/core/root/rootTypes.ts index 915324e106..b058e48a31 100644 --- a/packages/typegpu/src/core/root/rootTypes.ts +++ b/packages/typegpu/src/core/root/rootTypes.ts @@ -38,7 +38,7 @@ import type { TgpuFragmentFn, VertexOutToVarying } from '../function/tgpuFragmen import type { TgpuVertexFn } from '../function/tgpuVertexFn.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; -import type { TgpuRenderCommands } from '../commandEncoder/renderPass.ts'; +import type { TgpuRenderBundleEncoder } from '../commandEncoder/renderPass.ts'; import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts'; import type { FragmentOutToTargets, TgpuRenderPipeline } from '../pipeline/renderPipeline.ts'; import type { TgpuFixedComparisonSampler, TgpuFixedSampler } from '../sampler/sampler.ts'; @@ -717,10 +717,10 @@ export interface TgpuRoot extends Unwrapper, WithBinding { '~unstable': Pick< ExperimentalTgpuRoot, - | 'beginRenderBundleEncoder' | 'createCommandEncoder' | 'createComparisonSampler' | 'createGuardedComputePipeline' + | 'createRenderBundleEncoder' | 'createSampler' | 'createTexture' | 'flush' @@ -782,21 +782,26 @@ export interface ExperimentalTgpuRoot createCommandEncoder(descriptor?: GPUCommandEncoderDescriptor): TgpuCommandEncoder; /** - * Creates a {@link GPURenderBundle} by recording draw commands into a - * {@link GPURenderBundleEncoder}. The resulting bundle can be replayed in a - * render pass via `pass.executeBundles`. + * Creates a {@link TgpuRenderBundleEncoder} for recording draw commands into + * a {@link GPURenderBundle}. Call `finish()` on the encoder to obtain the + * bundle, replayable in a render pass via `pass.executeBundles`. * * The caller is responsible for ensuring that the `descriptor` (e.g. * `colorFormats`, `depthStencilFormat`) is compatible with the render pass * in which the bundle will be executed. * * @param descriptor - Describes the formats the bundle must be compatible with. - * @param callback - A function that records draw commands into the bundle. + * + * @example + * ```ts + * const bundleEncoder = root['~unstable'].createRenderBundleEncoder({ + * colorFormats: ['rgba8unorm'], + * }); + * scenePipeline.with(bundleEncoder).draw(vertexCount); + * const bundle = bundleEncoder.finish(); + * ``` */ - beginRenderBundleEncoder( - descriptor: GPURenderBundleEncoderDescriptor, - callback: (pass: TgpuRenderCommands) => void, - ): GPURenderBundle; + createRenderBundleEncoder(descriptor: GPURenderBundleEncoderDescriptor): TgpuRenderBundleEncoder; /** @deprecated Use `root.createSampler` instead. */ createSampler(props: WgslSamplerProps): TgpuFixedSampler; diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts index e54d61b55e..49f21cc191 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -63,6 +63,7 @@ export type { ColorAttachment, DepthStencilAttachment } from './core/commandEnco export type { TgpuComputePipeline } from './core/pipeline/computePipeline.ts'; export type { TgpuCommandEncoder } from './core/commandEncoder/commandEncoder.ts'; export type { + TgpuRenderBundleEncoder, TgpuRenderCommands, TgpuRenderPass, TgpuRenderPassDescriptor, diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index 0efea462da..ffa8352de0 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -1568,11 +1568,13 @@ describe('Render Bundles', () => { it('binds to a typed bundle pass', ({ root, renderBundleEncoder }) => { const pipeline = createPipeline(root); - root['~unstable'].beginRenderBundleEncoder({ colorFormats: ['rgba8unorm'] }, (pass) => { - const withPass = pipeline.with(pass); - withPass.draw(6); - withPass.draw(3); + const bundleEncoder = root['~unstable'].createRenderBundleEncoder({ + colorFormats: ['rgba8unorm'], }); + const withPass = pipeline.with(bundleEncoder); + withPass.draw(6); + withPass.draw(3); + bundleEncoder.finish(); const encoder = renderBundleEncoder as unknown as { setPipeline: ReturnType; From 993b153617c848b04d2d34cf0210c754646e3a35 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 3 Aug 2026 18:27:55 +0300 Subject: [PATCH 09/10] review fixes and logger integration --- packages/typegpu-gl/src/tgpuRootWebGL.ts | 4 +++ .../src/core/commandEncoder/commandEncoder.ts | 9 ++--- .../typegpu/src/core/pipeline/drawState.ts | 14 +++++--- .../typegpu/src/core/pipeline/timeable.ts | 5 +-- packages/typegpu/src/core/root/init.ts | 3 +- packages/typegpu/src/shared/warnOnce.ts | 18 ---------- packages/typegpu/src/tgpuLogger.ts | 22 +++++++++++++ packages/typegpu/tests/commandEncoder.test.ts | 9 +++-- .../typegpu/tests/computePipeline.test.ts | 30 ++++++++++++++--- .../typegpu/tests/internal/tgpuLogger.test.ts | 33 +++++++++++++++++++ 10 files changed, 109 insertions(+), 38 deletions(-) delete mode 100644 packages/typegpu/src/shared/warnOnce.ts diff --git a/packages/typegpu-gl/src/tgpuRootWebGL.ts b/packages/typegpu-gl/src/tgpuRootWebGL.ts index 19650bf600..f0256acb8d 100644 --- a/packages/typegpu-gl/src/tgpuRootWebGL.ts +++ b/packages/typegpu-gl/src/tgpuRootWebGL.ts @@ -492,6 +492,10 @@ export class TgpuRootWebGL { throw new WebGLFallbackUnsupportedError('createGuardedComputePipeline'); } + createCommandEncoder(): never { + throw new WebGLFallbackUnsupportedError('createCommandEncoder'); + } + createRenderBundleEncoder(): never { throw new WebGLFallbackUnsupportedError('createRenderBundleEncoder'); } diff --git a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts index 352631acb7..85b4d63dd4 100644 --- a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -1,6 +1,6 @@ import type { v3u, Vec3u } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; -import { warnOnce } from '../../shared/warnOnce.ts'; +import { logger } from '../../tgpuLogger.ts'; import type { TgpuUniform } from '../buffer/bufferBinding.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { @@ -137,12 +137,13 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { } finish(descriptor?: GPUCommandBufferDescriptor): GPUCommandBuffer { - const { rawEncoder, afterSubmit } = this[$internal]; + const { rawEncoder, root, afterSubmit } = this[$internal]; this.#recordPendingCommands(); if (afterSubmit.size > 0) { - warnOnce( - this, + logger.warnOnce( + 'suspicious', + root, 'finishWithPendingWork', 'Shader console.log output and performance callbacks do not fire for command buffers produced by encoder.finish(). Use encoder.submit() instead.', ); diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts index e9bb8a7fc8..b76cea3c7b 100644 --- a/packages/typegpu/src/core/pipeline/drawState.ts +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -1,7 +1,7 @@ import { MissingBindGroupsError, MissingVertexBuffersError } from '../../errors.ts'; import type { BaseData } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; -import { warnOnce } from '../../shared/warnOnce.ts'; +import { logger } from '../../tgpuLogger.ts'; import { isBindGroup, type TgpuBindGroup, @@ -226,7 +226,8 @@ export function requireIndexBuffer(indexBuffer: IndexBufferEntry | undefined): v } function warnAboutUnreachableSubmission(core: object, what: string): void { - warnOnce( + logger.warnOnce( + 'suspicious', core, what, `${what} is ignored when recording into a raw GPUCommandEncoder, since there is no submission to report after. Use root['~unstable'].createCommandEncoder() instead.`, @@ -289,7 +290,8 @@ function reportIgnoredPriors( const wording = PassKindWording[passKind]; if (hasAttachments) { - warnOnce( + logger.warnOnce( + 'suspicious', core, 'attachments', `Pipeline-level attachments are ignored when ${wording.into}. Pass \`colorAttachments\` and \`depthStencilAttachment\` to encoder.${wording.begin} instead.`, @@ -297,7 +299,8 @@ function reportIgnoredPriors( } if (hasTimestampWrites) { - warnOnce( + logger.warnOnce( + 'suspicious', core, 'timestampWrites', `Pipeline-level timestamp writes are ignored when ${wording.into}. Pass \`timestampWrites\` to encoder.${wording.begin} instead.`, @@ -305,7 +308,8 @@ function reportIgnoredPriors( } if (logResources && !queueLogDrain(owner, logResources)) { - warnOnce( + logger.warnOnce( + 'suspicious', core, 'logs', `Shader console.log output is ignored when ${wording.intoRaw} encoder, since there is no submission to read it back after.`, diff --git a/packages/typegpu/src/core/pipeline/timeable.ts b/packages/typegpu/src/core/pipeline/timeable.ts index d568e09f23..11890371e7 100644 --- a/packages/typegpu/src/core/pipeline/timeable.ts +++ b/packages/typegpu/src/core/pipeline/timeable.ts @@ -1,7 +1,7 @@ import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.ts'; -import { warnOnce } from '../../shared/warnOnce.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { $internal } from '../../shared/symbols.ts'; +import { logger } from '../../tgpuLogger.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; export interface Timeable { @@ -175,7 +175,8 @@ export function queueTimestampResolve( } if (registrations.some((reg) => reg.priors === priors)) { - warnOnce( + logger.warnOnce( + 'suspicious', querySet, 'repeated-timed-execution', 'Repeated executions of a timed pipeline within one command encoder write to the same query set indices, so the performance callback reports only the last execution.', diff --git a/packages/typegpu/src/core/root/init.ts b/packages/typegpu/src/core/root/init.ts index 9f9c3a43b9..0a8a09e1e6 100644 --- a/packages/typegpu/src/core/root/init.ts +++ b/packages/typegpu/src/core/root/init.ts @@ -6,7 +6,6 @@ import { WeakMemo } from '../../memo.ts'; import { clearTextureUtilsCache } from '../texture/textureUtils.ts'; import type { BufferInitialData } from '../buffer/buffer.ts'; import { $getNameForward, $internal } from '../../shared/symbols.ts'; -import { warnOnce } from '../../shared/warnOnce.ts'; import type { ExtractBindGroupInputFromLayout, TgpuBindGroup, @@ -194,7 +193,7 @@ export class TgpuGuardedComputePipelineImpl< if (submittable) { throw new Error(message); } - warnOnce(scope, 'guarded-dispatch-size', message); + logger.warnOnce('suspicious', scope, 'guarded-dispatch-size', message); } sizes.set(this.#sizeUniform, size); } diff --git a/packages/typegpu/src/shared/warnOnce.ts b/packages/typegpu/src/shared/warnOnce.ts deleted file mode 100644 index d93540bebf..0000000000 --- a/packages/typegpu/src/shared/warnOnce.ts +++ /dev/null @@ -1,18 +0,0 @@ -const _warned = new WeakMap>(); - -/** Emits a warning at most once per key and tag */ -export function warnOnce(key: object, tag: string, message: string): void { - let tags = _warned.get(key); - - if (!tags) { - tags = new Set(); - _warned.set(key, tags); - } - - if (tags.has(tag)) { - return; - } - tags.add(tag); - - console.warn(message); -} diff --git a/packages/typegpu/src/tgpuLogger.ts b/packages/typegpu/src/tgpuLogger.ts index 240b183b5c..793e9f21dc 100644 --- a/packages/typegpu/src/tgpuLogger.ts +++ b/packages/typegpu/src/tgpuLogger.ts @@ -19,6 +19,7 @@ type WarningType = (typeof warningTypes)[number]; // internal API interface Logger { warn(type: WarningType, ...args: unknown[]): void; + warnOnce(type: WarningType, key: object, tag: string, ...args: unknown[]): void; } /** @@ -42,6 +43,7 @@ interface Warn { export class TgpuLogger implements Logger, Warn { #initialEnabledWarnings: readonly WarningType[]; #enabledWarnings: Set; + #warned = new WeakMap>(); constructor(prod: boolean) { if (prod) { @@ -71,6 +73,26 @@ export class TgpuLogger implements Logger, Warn { console.warn(`⚠️ [${type}] `, ...args); } } + + warnOnce(type: WarningType, key: object, tag: string, ...args: unknown[]) { + if (!this.#enabledWarnings.has(type)) { + return; + } + + let warnings = this.#warned.get(key); + if (!warnings) { + warnings = new Set(); + this.#warned.set(key, warnings); + } + + const warningId = `${type}:${tag}`; + if (warnings.has(warningId)) { + return; + } + warnings.add(warningId); + + this.warn(type, ...args); + } } const tgpuLogger = new TgpuLogger(!(DEV || TEST)); diff --git a/packages/typegpu/tests/commandEncoder.test.ts b/packages/typegpu/tests/commandEncoder.test.ts index bdc8559b08..885b676897 100644 --- a/packages/typegpu/tests/commandEncoder.test.ts +++ b/packages/typegpu/tests/commandEncoder.test.ts @@ -571,9 +571,12 @@ describe('TgpuCommandEncoder', () => { encoder.submit(); expect(consoleWarnSpy).toBeCalledTimes(1); - expect(consoleWarnSpy.mock.calls[0]?.[0]).toMatchInlineSnapshot( - `"Pipeline-level attachments are ignored when drawing into a render pass. Pass \`colorAttachments\` and \`depthStencilAttachment\` to encoder.beginRenderPass instead."`, - ); + expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` + [ + "⚠️ [suspicious] ", + "Pipeline-level attachments are ignored when drawing into a render pass. Pass \`colorAttachments\` and \`depthStencilAttachment\` to encoder.beginRenderPass instead.", + ] + `); }); it('does not warn when the pipeline begins its own pass', ({ root }) => { diff --git a/packages/typegpu/tests/computePipeline.test.ts b/packages/typegpu/tests/computePipeline.test.ts index 3fdaf11ade..31b4007950 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -143,6 +143,7 @@ describe('TgpuComputePipeline', () => { .dispatchWorkgroups(1); expect(consoleWarnSpy).toHaveBeenCalledWith( + '⚠️ [suspicious] ', 'Shader console.log output is ignored when dispatching into a raw compute pass encoder, since there is no submission to read it back after.', ); consoleWarnSpy.mockRestore(); @@ -218,7 +219,7 @@ describe('TgpuComputePipeline', () => { }); it('warns when a timed pipeline executes repeatedly in one encoder', async ({ root }) => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); const querySet = root.createQuerySet('timestamp', 2); const callback = vi.fn(); @@ -234,6 +235,7 @@ describe('TgpuComputePipeline', () => { pipeline.dispatchWorkgroups(1); expect(consoleWarnSpy).toHaveBeenCalledWith( + '⚠️ [suspicious] ', 'Repeated executions of a timed pipeline within one command encoder write to the same query set indices, so the performance callback reports only the last execution.', ); @@ -241,14 +243,13 @@ describe('TgpuComputePipeline', () => { await new Promise((resolve) => setTimeout(resolve)); expect(callback).toHaveBeenCalledTimes(1); - consoleWarnSpy.mockRestore(); }); it('warns that a performance callback cannot be reported on a raw encoder', ({ root, commandEncoder, }) => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); const querySet = root.createQuerySet('timestamp', 2); @@ -260,9 +261,30 @@ describe('TgpuComputePipeline', () => { .dispatchWorkgroups(1); expect(consoleWarnSpy).toHaveBeenCalledWith( + '⚠️ [suspicious] ', "The performance callback is ignored when recording into a raw GPUCommandEncoder, since there is no submission to report after. Use root['~unstable'].createCommandEncoder() instead.", ); - consoleWarnSpy.mockRestore(); + }); + + it('warns only once per root when finish skips pending work', ({ root }) => { + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => { + console.log(1); + }); + const pipeline = root.createComputePipeline({ compute: entryFn }); + + for (let i = 0; i < 2; i++) { + const encoder = root['~unstable'].createCommandEncoder(); + pipeline.with(encoder).dispatchWorkgroups(1); + encoder.finish(); + } + + expect(consoleWarnSpy.mock.calls).toEqual([ + [ + '⚠️ [suspicious] ', + 'Shader console.log output and performance callbacks do not fire for command buffers produced by encoder.finish(). Use encoder.submit() instead.', + ], + ]); }); it('re-applies state on every dispatch into a raw compute pass', ({ root, commandEncoder }) => { diff --git a/packages/typegpu/tests/internal/tgpuLogger.test.ts b/packages/typegpu/tests/internal/tgpuLogger.test.ts index 0bcdd64083..40abffb069 100644 --- a/packages/typegpu/tests/internal/tgpuLogger.test.ts +++ b/packages/typegpu/tests/internal/tgpuLogger.test.ts @@ -63,6 +63,39 @@ describe('tgpuLogger', () => { `); }); + it('warns once per key and tag', () => { + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const logger = new TgpuLogger(false); + const firstKey = {}; + const secondKey = {}; + + logger.warnOnce('suspicious', firstKey, 'first', 'first warning'); + logger.warnOnce('suspicious', firstKey, 'first', 'first warning'); + logger.warnOnce('suspicious', firstKey, 'second', 'second warning'); + logger.warnOnce('fallback', firstKey, 'first', 'fallback warning'); + logger.warnOnce('suspicious', secondKey, 'first', 'first warning for another key'); + + expect(consoleWarnSpy.mock.calls).toEqual([ + ['⚠️ [suspicious] ', 'first warning'], + ['⚠️ [suspicious] ', 'second warning'], + ['⚠️ [fallback] ', 'fallback warning'], + ['⚠️ [suspicious] ', 'first warning for another key'], + ]); + }); + + it('does not remember a disabled warning as emitted', () => { + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const logger = new TgpuLogger(false); + const key = {}; + + logger.disable('suspicious'); + logger.warnOnce('suspicious', key, 'warning', 'warning'); + logger.reset(); + logger.warnOnce('suspicious', key, 'warning', 'warning'); + + expect(consoleWarnSpy.mock.calls).toEqual([['⚠️ [suspicious] ', 'warning']]); + }); + it('only silences the disabled type', () => { using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const logger = new TgpuLogger(false); From f691efbf67ae4680209c1f4f61a2792e7d324712 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Thu, 6 Aug 2026 00:54:45 +0200 Subject: [PATCH 10/10] review fixes + add encoders to clear/copy (trivial and it bugged me very much in ga example), I also rewrote ga to not use guarded, the inefficiency bugged me too much, sry --- .../src/content/docs/apis/buffers.mdx | 20 ++++ .../src/content/docs/apis/pipelines.mdx | 92 ++++++++++++-- .../examples/algorithms/genetic-racing/ga.ts | 53 ++++++--- .../algorithms/genetic-racing/index.ts | 70 +++++++---- packages/typegpu/src/core/buffer/buffer.ts | 32 +++-- .../src/core/commandEncoder/commandEncoder.ts | 10 +- .../src/core/commandEncoder/computePass.ts | 39 ++++-- .../src/core/commandEncoder/renderPass.ts | 83 ++++++++++--- .../src/core/pipeline/computePipeline.ts | 29 +++-- .../typegpu/src/core/pipeline/drawState.ts | 4 +- .../src/core/pipeline/pipelineUtils.ts | 4 + .../src/core/pipeline/renderPipeline.ts | 9 +- .../typegpu/src/core/pipeline/timeable.ts | 5 +- .../typegpu/src/core/pipeline/typeGuards.ts | 14 +-- packages/typegpu/src/core/root/init.ts | 47 ++------ packages/typegpu/src/core/root/rootTypes.ts | 15 --- packages/typegpu/src/unwrapper.ts | 3 +- packages/typegpu/tests/buffer.test.ts | 33 ++++++ packages/typegpu/tests/commandEncoder.test.ts | 112 ++++++++++-------- .../typegpu/tests/computePipeline.test.ts | 8 +- .../tests/guardedComputePipeline.test.ts | 29 ++--- 21 files changed, 464 insertions(+), 247 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/apis/buffers.mdx b/apps/typegpu-docs/src/content/docs/apis/buffers.mdx index cca30f307a..eda163bee0 100644 --- a/apps/typegpu-docs/src/content/docs/apis/buffers.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/buffers.mdx @@ -523,6 +523,26 @@ particleBuffer.clear(); For a mapped buffer, TypeGPU clears the mapped memory directly. Otherwise, it records and submits a WebGPU `clearBuffer` command. +Both `.copyFrom(...)` and `.clear()` accept an optional [command encoder](/TypeGPU/apis/pipelines/#command-encoders-and-passes) as the last argument. +The command is then recorded into the encoder instead of being submitted on its own. + +```ts twoslash +import { tgpu, d } from 'typegpu'; +const root = await tgpu.init(); + +const Particle = d.struct({ + position: d.vec2f, + health: d.u32, +}); + +const particleBuffer = root.createBuffer(Particle); +const backupParticleBuffer = root.createBuffer(Particle); +const encoder = root['~unstable'].createCommandEncoder(); +// ---cut--- +particleBuffer.clear(encoder); +backupParticleBuffer.copyFrom(particleBuffer, encoder); +``` + Call `.destroy()` when an owned buffer is no longer needed. TypeGPU releases a buffer that it created itself; if the TypeGPU wrapper was created around an existing `GPUBuffer`, the external buffer remains owned by its original caller. diff --git a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx index e34f18bc88..29e3842a54 100644 --- a/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/pipelines.mdx @@ -171,7 +171,7 @@ The underlying WebGPU resources for TypeGPU pipelines are created lazily, just b ### createGuardedComputePipeline -The `createGuardedComputePipeline` method streamlines running simple computations on the GPU. +The `createGuardedComputePipeline` method streamlines running simple computations on the GPU. Instead of dispatching workgroups, the guarded pipeline allows calling an exact number of GPU threads. Think of it as a parallelized `for` loop. Under the hood, it creates a compute pipeline that calls the provided callback only if the current thread ID is within the requested range. @@ -525,7 +525,20 @@ Offsets must be aligned to four bytes, and the required values must fit in a con Render and compute pipelines can record commands into encoders that your application manages. Pass an existing encoder to `.with(...)` before drawing or dispatching: -```ts +```ts twoslash +import { tgpu, d } from 'typegpu'; +const root = await tgpu.init(); +const canvas = {} as HTMLCanvasElement; +const context = canvas.getContext('webgpu') as GPUCanvasContext; +const computePipeline = root.createComputePipeline({ + compute: tgpu.computeFn({ workgroupSize: [1] })(() => {}), +}); +const renderPipeline = root.createRenderPipeline({ + vertex: tgpu.vertexFn({ out: { pos: d.builtin.position } })(() => ({ pos: d.vec4f() })), + fragment: tgpu.fragmentFn({ out: d.vec4f })(() => d.vec4f()), + targets: { format: 'rg8unorm' }, +}); +// ---cut--- const encoder = root.device.createCommandEncoder(); computePipeline @@ -544,7 +557,20 @@ With a `GPUCommandEncoder`, TypeGPU creates and ends the needed pass but leaves You can instead record directly into an existing pass: -```ts +```ts twoslash +import { tgpu, d } from 'typegpu'; +const root = await tgpu.init(); +const computePipeline = root.createComputePipeline({ + compute: tgpu.computeFn({ workgroupSize: [1] })(() => {}), +}); +const renderPipeline = root.createRenderPipeline({ + vertex: tgpu.vertexFn({ out: { pos: d.builtin.position } })(() => ({ pos: d.vec4f() })), + fragment: tgpu.fragmentFn({ out: d.vec4f })(() => d.vec4f()), + targets: { format: 'rg8unorm' }, +}); +const encoder = root.device.createCommandEncoder(); +const renderPassDescriptor: GPURenderPassDescriptor = { colorAttachments: [] }; +// ---cut--- const computePass = encoder.beginComputePass(); computePipeline.with(computePass).dispatchWorkgroups(16); computePass.end(); @@ -568,7 +594,29 @@ When a pipeline is executed directly via `draw` or `dispatchWorkgroups`, it reco For scenarios that require more control, such as executing multiple pipelines in a single render pass or batching multiple passes into a single submission, TypeGPU provides a typed equivalent of the WebGPU command encoder. It can be created with the `createCommandEncoder` method on the root object and mirrors `GPUCommandEncoder`, while accepting TypeGPU resources directly. -```ts +```ts twoslash +import { tgpu, d } from 'typegpu'; +const root = await tgpu.init(); +const canvas = {} as HTMLCanvasElement; +const context = canvas.getContext('webgpu') as GPUCanvasContext; +const msaaTexture = root + .createTexture({ size: [256, 256], format: 'rgba8unorm', sampleCount: 4 }) + .$usage('render'); +const depthTexture = root + .createTexture({ size: [256, 256], format: 'depth24plus' }) + .$usage('render'); +const createPipeline = () => + root.createRenderPipeline({ + vertex: tgpu.vertexFn({ out: { pos: d.builtin.position } })(() => ({ pos: d.vec4f() })), + fragment: tgpu.fragmentFn({ out: d.vec4f })(() => d.vec4f()), + targets: { format: 'rgba8unorm' }, + }); +const scenePipeline = createPipeline(); +const lightPipeline = createPipeline(); +const skyPipeline = createPipeline(); +const mesh = { vertexCount: 36 }; +const lightCount = 4; +// ---cut--- const encoder = root['~unstable'].createCommandEncoder(); const pass = encoder.beginRenderPass({ @@ -599,19 +647,45 @@ There are two equivalent ways to execute pipelines in a pass. Passing the pass to `pipeline.with(pass)` keeps the pipeline-centric API, together with all of its `with*` methods. Alternatively, the pass itself mirrors the `GPURenderPassEncoder` API, while accepting TypeGPU resources. -```ts +```ts twoslash +import { tgpu, d } from 'typegpu'; +const root = await tgpu.init(); +const renderPipeline = root.createRenderPipeline({ + vertex: tgpu.vertexFn({ out: { pos: d.builtin.position } })(() => ({ pos: d.vec4f() })), + fragment: tgpu.fragmentFn({ out: d.vec4f })(() => d.vec4f()), + targets: { format: 'rg8unorm' }, +}); +const vertexLayout = tgpu.vertexLayout(d.arrayOf(d.vec2f)); +const vertexBuffer = root.createBuffer(d.arrayOf(d.vec2f, 3)).$usage('vertex'); +const bindGroupLayout = tgpu.bindGroupLayout({ size: { uniform: d.vec2u } }); +const bindGroup = root.createBindGroup(bindGroupLayout, { + size: root.createBuffer(d.vec2u).$usage('uniform'), +}); +const encoder = root['~unstable'].createCommandEncoder(); +const pass = encoder.beginRenderPass({ colorAttachments: [] }); +// ---cut--- pass.setPipeline(renderPipeline); pass.setBindGroup(bindGroup); pass.setVertexBuffer(vertexLayout, vertexBuffer); pass.draw(3); ``` -In both cases, the pipeline, bind groups and vertex buffers are applied lazily when a draw call is recorded, and only if they changed since the previous one. +In both cases, the pipeline, bind groups, vertex and index buffers, and the stencil reference are applied lazily when a draw call is recorded, and only if they changed since the previous one. Both styles operate on the same pass state and follow the WebGPU ordering rules: executing a pipeline sets the resources bound to it (`pipeline.with(bindGroup)`) on the pass, later `set*` calls overwrite them, and all state persists until overwritten. +:::note +`pipeline.with(pass).draw(...)` sets the pass's current pipeline, so a subsequent `pass.draw(...)` executes that pipeline, not one set earlier via `pass.setPipeline(...)`. +::: + Compute passes work the same way: -```ts +```ts twoslash +import { tgpu } from 'typegpu'; +const root = await tgpu.init(); +const computePipeline = root.createComputePipeline({ + compute: tgpu.computeFn({ workgroupSize: [1] })(() => {}), +}); +// ---cut--- const encoder = root['~unstable'].createCommandEncoder(); const pass = encoder.beginComputePass(); computePipeline.with(pass).dispatchWorkgroups(16); @@ -622,9 +696,11 @@ encoder.submit(); Calling `encoder.submit()` finishes the recording and submits it to the device queue. Shader `console.log` output and [performance callbacks](/TypeGPU/advanced/timestamp-queries/) are processed as part of that submission. +Guarded compute pipelines (`createGuardedComputePipeline`) cannot record into passes or encoders, every `dispatchThreads` call submits on its own. + Whenever something is not covered by the typed API, the underlying WebGPU resources remain accessible: -- `root.unwrap(encoder)` and `root.unwrap(pass)` return the raw `GPUCommandEncoder`, `GPURenderPassEncoder` or `GPUComputePassEncoder`, which can be used e.g. for buffer copies. Commands recorded this way are invisible to TypeGPU, so after unwrapping a pass, every draw applies its full state again. +- `root.unwrap(encoder)` and `root.unwrap(pass)` return the raw `GPUCommandEncoder`, `GPURenderPassEncoder` or `GPUComputePassEncoder`, which can be used e.g. for texture copies. Commands recorded this way are invisible to TypeGPU, so after unwrapping a pass, every draw applies its full state again. - `encoder.finish()` returns the raw `GPUCommandBuffer` without submitting it, allowing manual batching via `device.queue.submit([...])`. TypeGPU never sees such a submission, so shader logs and performance callbacks are not processed for it. Raw `GPUCommandEncoder`s and pass encoders can also be passed to `pipeline.with(...)` directly, with the same limitations, since TypeGPU cannot know when they are submitted, nor what state has been set on them. diff --git a/apps/typegpu-docs/src/examples/algorithms/genetic-racing/ga.ts b/apps/typegpu-docs/src/examples/algorithms/genetic-racing/ga.ts index 68f12e74fc..4a1d364044 100644 --- a/apps/typegpu-docs/src/examples/algorithms/genetic-racing/ga.ts +++ b/apps/typegpu-docs/src/examples/algorithms/genetic-racing/ga.ts @@ -4,6 +4,11 @@ import type { TgpuRoot, TgpuUniform } from 'typegpu'; export const MAX_POP = 65536; export const DEFAULT_POP = 8192; +export const WORKGROUP_SIZE = 256; + +export function workgroupCount(threads: number) { + return Math.ceil(threads / WORKGROUP_SIZE); +} export const CarState = d.struct({ position: d.vec2f, @@ -183,18 +188,26 @@ const evolveOutputLayer = ( }); }; -const fitShader = (i: number) => { +const fitShader = tgpu.computeFn({ + in: { gid: d.builtin.globalInvocationId }, + workgroupSize: [WORKGROUP_SIZE], +})(({ gid }) => { 'use gpu'; - if (d.u32(i) >= paramsAccess.$.population) { + const i = gid.x; + if (i >= paramsAccess.$.population) { return; } const s = CarState(fitLayout.$.state[i]); fitLayout.$.fitness[i] = s.progress * 10 + d.f32(s.aliveSteps) * 0.003; -}; +}); -const initShader = (i: number) => { +const initShader = tgpu.computeFn({ + in: { gid: d.builtin.globalInvocationId }, + workgroupSize: [WORKGROUP_SIZE], +})(({ gid }) => { 'use gpu'; - if (d.u32(i) >= paramsAccess.$.population) { + const i = gid.x; + if (i >= paramsAccess.$.population) { return; } randf.seed2(d.vec2f(d.f32(i) + 1, paramsAccess.$.generation + 11)); @@ -210,16 +223,20 @@ const initShader = (i: number) => { out: { steer: randSignedVec4(), throttle: randSignedVec4(), bias: d.vec2f() }, }); initLayout.$.state[i] = makeSpawnState(); -}; +}); -const evolveShader = (i: number) => { +const evolveShader = tgpu.computeFn({ + in: { gid: d.builtin.globalInvocationId }, + workgroupSize: [WORKGROUP_SIZE], +})(({ gid }) => { 'use gpu'; - if (d.u32(i) >= paramsAccess.$.population) { + const i = gid.x; + if (i >= paramsAccess.$.population) { return; } // Elitism: champion always lives at index 0, copied unchanged - if (d.u32(i) === 0) { + if (i === 0) { evolveLayout.$.nextGenome[0] = Genome(evolveLayout.$.genome[evolveLayout.$.bestIdx]); evolveLayout.$.nextState[0] = makeSpawnState(); return; @@ -237,7 +254,7 @@ const evolveShader = (i: number) => { }); evolveLayout.$.nextState[i] = makeSpawnState(); -}; +}); export function createGeneticPopulation(root: TgpuRoot, params: TgpuUniform) { const stateBuffers = [0, 1].map(() => @@ -271,9 +288,9 @@ export function createGeneticPopulation(root: TgpuRoot, params: TgpuUniform root.with(paramsAccess, params).createComputePipeline({ compute }), + ); let current = 0; let generation = 0; @@ -299,20 +316,20 @@ export function createGeneticPopulation(root: TgpuRoot, params: TgpuUniform }), ); -const simulatePipeline = root.createGuardedComputePipeline((i) => { +const simulateShader = tgpu.computeFn({ + in: { gid: d.builtin.globalInvocationId }, + workgroupSize: [WORKGROUP_SIZE], +})(({ gid }) => { 'use gpu'; - if (d.u32(i) >= params.$.population) { + const i = gid.x; + if (i >= params.$.population) { return; } @@ -254,6 +260,8 @@ const simulatePipeline = root.createGuardedComputePipeline((i) => { }); }); +const simulatePipeline = root.createComputePipeline({ compute: simulateShader }); + // upper 16 bits = quantized fitness [0,65535], lower 16 bits = car index const reductionPackedBuffer = root.createBuffer(d.atomic(d.u32), 0).$usage('storage'); const bestFitnessBuffer = root.createBuffer(d.f32).$usage('storage'); @@ -276,22 +284,30 @@ const reductionBindGroups = [0, 1].map((i) => }), ); -const reductionPipeline = root.createGuardedComputePipeline((i) => { - 'use gpu'; - if (d.u32(i) >= params.$.population) { - return; - } - const fitness = reductionLayout.$.fitness[i]; - const quantized = d.u32(std.clamp(fitness / 64, 0, 1) * 65535); - const packed = (quantized << 16) | (d.u32(i) & 0xffff); - std.atomicMax(reductionLayout.$.packed, packed); +const reductionPipeline = root.createComputePipeline({ + compute: tgpu.computeFn({ + in: { gid: d.builtin.globalInvocationId }, + workgroupSize: [WORKGROUP_SIZE], + })(({ gid }) => { + 'use gpu'; + const i = gid.x; + if (i >= params.$.population) { + return; + } + const fitness = reductionLayout.$.fitness[i]; + const quantized = d.u32(std.clamp(fitness / 64, 0, 1) * 65535); + const packed = (quantized << 16) | (i & 0xffff); + std.atomicMax(reductionLayout.$.packed, packed); + }), }); -const finalizeReductionPipeline = root.createGuardedComputePipeline(() => { - 'use gpu'; - const packed = std.atomicLoad(reductionLayout.$.packed); - reductionLayout.$.bestIdx = packed & 0xffff; - reductionLayout.$.bestFitness = (d.f32(packed >> 16) / 65535) * 64; +const finalizeReductionPipeline = root.createComputePipeline({ + compute: tgpu.computeFn({ workgroupSize: [1] })(() => { + 'use gpu'; + const packed = std.atomicLoad(reductionLayout.$.packed); + reductionLayout.$.bestIdx = packed & 0xffff; + reductionLayout.$.bestFitness = (d.f32(packed >> 16) / 65535) * 64; + }), }); const colors = { @@ -433,12 +449,14 @@ function frame() { params.patch({ stepsPerDispatch: innerSteps }); const dispatchCount = Math.ceil(stepsToRun / innerSteps); - const simEncoder = root.device.createCommandEncoder(); - const encoderPipeline = simulatePipeline.with(simBindGroups[ga.current]).with(simEncoder); + const simEncoder = root['~unstable'].createCommandEncoder(); + const simPass = simEncoder.beginComputePass(); + const boundPipeline = simulatePipeline.with(simBindGroups[ga.current]).with(simPass); for (let dispatch = 0; dispatch < dispatchCount; dispatch++) { - encoderPipeline.dispatchThreads(population); + boundPipeline.dispatchWorkgroups(workgroupCount(population)); } - root.device.queue.submit([simEncoder.finish()]); + simPass.end(); + simEncoder.submit(); steps += dispatchCount * innerSteps; } @@ -448,11 +466,13 @@ function frame() { ga.precomputeFitness(population); const bg = reductionBindGroups[ga.current]; - const reductionEncoder = root.device.createCommandEncoder(); - reductionEncoder.clearBuffer(root.unwrap(reductionPackedBuffer)); - reductionPipeline.with(bg).with(reductionEncoder).dispatchThreads(population); - finalizeReductionPipeline.with(bg).with(reductionEncoder).dispatchThreads(); - root.device.queue.submit([reductionEncoder.finish()]); + const reductionEncoder = root['~unstable'].createCommandEncoder(); + reductionPackedBuffer.clear(reductionEncoder); + const reductionPass = reductionEncoder.beginComputePass(); + reductionPipeline.with(bg).with(reductionPass).dispatchWorkgroups(workgroupCount(population)); + finalizeReductionPipeline.with(bg).with(reductionPass).dispatchWorkgroups(1); + reductionPass.end(); + reductionEncoder.submit(); void bestFitnessBuffer.read().then((fitness) => { displayedBestFitness = fitness; diff --git a/packages/typegpu/src/core/buffer/buffer.ts b/packages/typegpu/src/core/buffer/buffer.ts index cab7ab00af..b0787e54a9 100644 --- a/packages/typegpu/src/core/buffer/buffer.ts +++ b/packages/typegpu/src/core/buffer/buffer.ts @@ -21,6 +21,7 @@ import type { import { $internal } from '../../shared/symbols.ts'; import type { Prettify, UnionToIntersection } from '../../shared/utilityTypes.ts'; import { isGPUBuffer } from '../../types.ts'; +import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { calculateOffsets, readFromArrayBuffer, writeToArrayBuffer } from '../../data/dataIO.ts'; import { patchArrayBuffer } from '../../data/partialIO.ts'; @@ -148,8 +149,8 @@ export interface TgpuBuffer extends TgpuNamable { /** @deprecated Use {@link patch} instead. */ writePartial(data: InferPartial): void; patch(data: InferPatch): void; - clear(): void; - copyFrom(srcBuffer: TgpuBuffer>): void; + clear(encoder?: TgpuCommandEncoder): void; + copyFrom(srcBuffer: TgpuBuffer>, encoder?: TgpuCommandEncoder): void; read(): Promise>; destroy(): void; toString(): string; @@ -375,28 +376,39 @@ class TgpuBufferImpl implements TgpuBuffer { } } - public clear(): void { + public clear(encoder?: TgpuCommandEncoder): void { const gpuBuffer = this.buffer; + if (encoder) { + encoder[$internal].rawEncoder.clearBuffer(gpuBuffer); + return; + } + if (gpuBuffer.mapState === 'mapped') { new Uint8Array(this.#getMappedRange()).fill(0); return; } - const encoder = this.#device.createCommandEncoder(); - encoder.clearBuffer(gpuBuffer); - this.#device.queue.submit([encoder.finish()]); + const rawEncoder = this.#device.createCommandEncoder(); + rawEncoder.clearBuffer(gpuBuffer); + this.#device.queue.submit([rawEncoder.finish()]); } - copyFrom(srcBuffer: TgpuBuffer>): void { + copyFrom(srcBuffer: TgpuBuffer>, encoder?: TgpuCommandEncoder): void { if (this.buffer.mapState === 'mapped') { throw new Error('Cannot copy to a mapped buffer.'); } const size = sizeOf(this.dataType); - const encoder = this.#device.createCommandEncoder(); - encoder.copyBufferToBuffer(srcBuffer.buffer, 0, this.buffer, 0, size); - this.#device.queue.submit([encoder.finish()]); + + if (encoder) { + encoder[$internal].rawEncoder.copyBufferToBuffer(srcBuffer.buffer, 0, this.buffer, 0, size); + return; + } + + const rawEncoder = this.#device.createCommandEncoder(); + rawEncoder.copyBufferToBuffer(srcBuffer.buffer, 0, this.buffer, 0, size); + this.#device.queue.submit([rawEncoder.finish()]); } async read(): Promise> { diff --git a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts index 85b4d63dd4..58d760927c 100644 --- a/packages/typegpu/src/core/commandEncoder/commandEncoder.ts +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -1,7 +1,5 @@ -import type { v3u, Vec3u } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; import { logger } from '../../tgpuLogger.ts'; -import type { TgpuUniform } from '../buffer/bufferBinding.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { INTERNAL_beginComputePass, @@ -27,8 +25,6 @@ export interface CommandEncoderInternals { readonly beforeFinish: Map void>; /** Callbacks run once the recorded commands have been submitted, keyed for deduplication */ readonly afterSubmit: Map void>; - /** Sizes recorded by guarded dispatches this submission, keyed by their size uniform */ - readonly guardedDispatchSizes: Map, v3u>; } /** @@ -47,7 +43,7 @@ export interface CommandEncoderInternals { * encoder.submit(); * ``` * - * For anything not covered by the typed surface (e.g. buffer copies), grab + * For anything not covered by the typed surface (e.g. texture copies), grab * the raw encoder via `root.unwrap(encoder)`. */ export interface TgpuCommandEncoder { @@ -102,7 +98,6 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { adopted, beforeFinish: new Map(), afterSubmit: new Map(), - guardedDispatchSizes: new Map(), }; } @@ -124,7 +119,7 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { } submit(): void { - const { rawEncoder, root, afterSubmit, guardedDispatchSizes } = this[$internal]; + const { rawEncoder, root, afterSubmit } = this[$internal]; this.#recordPendingCommands(); root.device.queue.submit([rawEncoder.finish()]); @@ -133,7 +128,6 @@ class TgpuCommandEncoderImpl implements TgpuCommandEncoder { hook(); } afterSubmit.clear(); - guardedDispatchSizes.clear(); } finish(descriptor?: GPUCommandBufferDescriptor): GPUCommandBuffer { diff --git a/packages/typegpu/src/core/commandEncoder/computePass.ts b/packages/typegpu/src/core/commandEncoder/computePass.ts index e5900e8c23..2d91d8ab4f 100644 --- a/packages/typegpu/src/core/commandEncoder/computePass.ts +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -1,11 +1,14 @@ -import type { v3u, Vec3u } from '../../data/wgslTypes.ts'; +import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import type { AnyWgslData } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; import type { TgpuBindGroup, TgpuBindGroupLayout, TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; -import type { TgpuUniform } from '../buffer/bufferBinding.ts'; +import { isGPUBuffer } from '../../types.ts'; +import type { IndirectFlag, TgpuBuffer } from '../buffer/buffer.ts'; +import { DISPATCH_INDIRECT_SIZE, resolveIndirectOffset } from '../pipeline/pipelineUtils.ts'; import { ComputeDrawState, emitComputeDispatch, @@ -35,8 +38,6 @@ export interface ComputePassInternals { readonly state: ComputeDrawState; /** Undefined for raw pass encoders the caller owns */ readonly owner: TgpuCommandEncoder | undefined; - /** Sizes recorded by guarded dispatches into an owner-less pass, keyed by their size uniform */ - readonly guardedDispatchSizes: Map, v3u>; appliedVersion: number | undefined; } @@ -65,7 +66,19 @@ export interface TgpuComputePass { ): void; dispatchWorkgroups(x: number, y?: number, z?: number): void; - dispatchWorkgroupsIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; + + /** + * Dispatches compute workgroups using parameters read from a buffer. + * The buffer must contain 3 consecutive u32 values (x, y, z workgroup counts). + * To get the correct offset within complex data structures, use `d.memoryLayoutOf(...)`. + * + * @param indirectBuffer - Buffer marked with 'indirect' usage containing dispatch parameters or raw GPUBuffer + * @param start - PrimitiveOffsetInfo pointing to the first dispatch parameter. If not provided, starts at offset 0. To obtain safe offsets, use `d.memoryLayoutOf(...)`. + */ + dispatchWorkgroupsIndirect( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + start?: PrimitiveOffsetInfo | number, + ): void; /** Completes the recording of this compute pass */ end(): void; @@ -117,7 +130,6 @@ class TgpuComputePassImpl implements TgpuComputePass { rawPass, state: new ComputeDrawState(), owner, - guardedDispatchSizes: new Map(), appliedVersion: undefined, }; } @@ -148,8 +160,19 @@ class TgpuComputePassImpl implements TgpuComputePass { this.#emit((rawPass) => rawPass.dispatchWorkgroups(x, y, z)); } - dispatchWorkgroupsIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void { - this.#emit((rawPass) => rawPass.dispatchWorkgroupsIndirect(indirectBuffer, indirectOffset)); + dispatchWorkgroupsIndirect( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + start?: PrimitiveOffsetInfo | number, + ): void { + const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; + const offset = resolveIndirectOffset( + indirectBuffer, + start, + DISPATCH_INDIRECT_SIZE, + 'dispatchWorkgroupsIndirect', + ); + + this.#emit((rawPass) => rawPass.dispatchWorkgroupsIndirect(rawBuffer, offset)); } end(): void { diff --git a/packages/typegpu/src/core/commandEncoder/renderPass.ts b/packages/typegpu/src/core/commandEncoder/renderPass.ts index f62a5b6b2e..1cfe258d89 100644 --- a/packages/typegpu/src/core/commandEncoder/renderPass.ts +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -1,12 +1,19 @@ import type { Disarray } from '../../data/dataTypes.ts'; -import type { WgslArray } from '../../data/wgslTypes.ts'; +import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import type { BaseData, WgslArray } from '../../data/wgslTypes.ts'; import { $internal } from '../../shared/symbols.ts'; import type { TgpuBindGroup, TgpuBindGroupLayout, TgpuLayoutEntry, } from '../../tgpuBindGroupLayout.ts'; -import type { TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; +import { isGPUBuffer } from '../../types.ts'; +import type { IndexFlag, IndirectFlag, TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; +import { + DRAW_INDEXED_INDIRECT_SIZE, + DRAW_INDIRECT_SIZE, + resolveIndirectOffset, +} from '../pipeline/pipelineUtils.ts'; import { isTexture, isTextureView } from '../texture/texture.ts'; import { emitRenderDraw, @@ -96,7 +103,7 @@ export interface TgpuRenderCommands { /** Sets the current index buffer */ setIndexBuffer( - buffer: TgpuBuffer | GPUBuffer, + buffer: (TgpuBuffer & IndexFlag) | GPUBuffer, indexFormat: GPUIndexFormat, offset?: number, size?: number, @@ -115,8 +122,31 @@ export interface TgpuRenderCommands { baseVertex?: number, firstInstance?: number, ): void; - drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; - drawIndexedIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void; + /** + * Draws primitives using parameters read from a buffer. + * The buffer must contain 4 consecutive u32 values (vertexCount, instanceCount, firstVertex, firstInstance). + * To get the correct offset within complex data structures, use `d.memoryLayoutOf(...)`. + * + * @param indirectBuffer - Buffer marked with 'indirect' usage containing draw parameters or raw GPUBuffer + * @param indirectOffset - PrimitiveOffsetInfo pointing to the first draw parameter. If not provided, starts at offset 0. To obtain safe offsets, use `d.memoryLayoutOf(...)`. + */ + drawIndirect( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, + ): void; + + /** + * Draws indexed primitives using parameters read from a buffer. + * The buffer must contain 5 consecutive 32-bit integer values (indexCount u32, instanceCount u32, firstIndex u32, baseVertex i32, firstInstance u32). + * To get the correct offset within complex data structures, use `d.memoryLayoutOf(...)`. + * + * @param indirectBuffer - Buffer marked with 'indirect' usage containing draw parameters or raw GPUBuffer + * @param indirectOffset - PrimitiveOffsetInfo pointing to the first draw parameter. If not provided, starts at offset 0. To obtain safe offsets, use `d.memoryLayoutOf(...)`. + */ + drawIndexedIndirect( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, + ): void; } /** @@ -152,7 +182,7 @@ export interface TgpuRenderPass extends TgpuRenderCommands { maxDepth: number, ): void; setScissorRect(x: number, y: number, width: number, height: number): void; - setBlendConstant(color: GPUColor): void; + setBlendConstant(color: readonly [number, number, number, number] | GPUColor): void; setStencilReference(reference: GPUStencilValue): void; beginOcclusionQuery(queryIndex: GPUSize32): void; endOcclusionQuery(): void; @@ -370,7 +400,7 @@ class TgpuRenderCommandsImpl< } setIndexBuffer( - buffer: TgpuBuffer | GPUBuffer, + buffer: (TgpuBuffer & IndexFlag) | GPUBuffer, indexFormat: GPUIndexFormat, offset?: number, size?: number, @@ -403,12 +433,34 @@ class TgpuRenderCommandsImpl< ); } - drawIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void { - this.#emit(false, (rawPass) => rawPass.drawIndirect(indirectBuffer, indirectOffset)); + drawIndirect( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, + ): void { + const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; + const offset = resolveIndirectOffset( + indirectBuffer, + indirectOffset, + DRAW_INDIRECT_SIZE, + 'drawIndirect', + ); + + this.#emit(false, (rawPass) => rawPass.drawIndirect(rawBuffer, offset)); } - drawIndexedIndirect(indirectBuffer: GPUBuffer, indirectOffset: GPUSize64): void { - this.#emit(true, (rawPass) => rawPass.drawIndexedIndirect(indirectBuffer, indirectOffset)); + drawIndexedIndirect( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, + ): void { + const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; + const offset = resolveIndirectOffset( + indirectBuffer, + indirectOffset, + DRAW_INDEXED_INDIRECT_SIZE, + 'drawIndexedIndirect', + ); + + this.#emit(true, (rawPass) => rawPass.drawIndexedIndirect(rawBuffer, offset)); } } @@ -444,15 +496,14 @@ class TgpuRenderPassImpl this[$internal].rawPass.setScissorRect(x, y, width, height); } - setBlendConstant(color: GPUColor): void { - this[$internal].rawPass.setBlendConstant(color); + setBlendConstant(color: readonly [number, number, number, number] | GPUColor): void { + this[$internal].rawPass.setBlendConstant(color as GPUColor); } setStencilReference(reference: GPUStencilValue): void { - const { state, rawPass } = this[$internal]; + const { state } = this[$internal]; state.stencilReference = reference; - rawPass.setStencilReference(reference); - state.appliedStencilReference = reference; + state.version++; } beginOcclusionQuery(queryIndex: GPUSize32): void { diff --git a/packages/typegpu/src/core/pipeline/computePipeline.ts b/packages/typegpu/src/core/pipeline/computePipeline.ts index e119226360..9aaf4af163 100644 --- a/packages/typegpu/src/core/pipeline/computePipeline.ts +++ b/packages/typegpu/src/core/pipeline/computePipeline.ts @@ -10,6 +10,7 @@ import { getName, PERF, setName } from '../../shared/meta.ts'; import { $getNameForward, $internal, $resolve } from '../../shared/symbols.ts'; import { isBindGroup, + isBindGroupLayout, type TgpuBindGroup, type TgpuBindGroupLayout, type TgpuLayoutEntry, @@ -38,7 +39,7 @@ import type { TgpuSlot } from '../slot/slotTypes.ts'; import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; import { warnIfOverflow } from './limitsOverflow.ts'; -import { resolveIndirectOffset } from './pipelineUtils.ts'; +import { DISPATCH_INDIRECT_SIZE, resolveIndirectOffset } from './pipelineUtils.ts'; import { createWithPerformanceCallback, createWithTimestampWrites, @@ -220,16 +221,20 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { }); } - const [layout, group] = isBindGroup(first) - ? [first.layout, first] - : [first, bindGroup as TgpuBindGroup | GPUBindGroup]; + if (isBindGroup(first) || isBindGroupLayout(first)) { + const [layout, group] = isBindGroup(first) + ? [first.layout, first] + : [first, bindGroup as TgpuBindGroup | GPUBindGroup]; - return this.#withPriors({ - bindGroupLayoutMap: new Map([ - ...(internals.priors.bindGroupLayoutMap ?? []), - [layout, group], - ]), - }); + return this.#withPriors({ + bindGroupLayoutMap: new Map([ + ...(internals.priors.bindGroupLayoutMap ?? []), + [layout, group], + ]), + }); + } + + throw new Error('Unsupported value passed into .with()'); } withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this { @@ -268,13 +273,11 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, start?: PrimitiveOffsetInfo | number, ): void { - const DISPATCH_SIZE = 12; // 3 x u32 (x, y, z) - const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; const offset = resolveIndirectOffset( indirectBuffer, start, - DISPATCH_SIZE, + DISPATCH_INDIRECT_SIZE, 'dispatchWorkgroupsIndirect', ); diff --git a/packages/typegpu/src/core/pipeline/drawState.ts b/packages/typegpu/src/core/pipeline/drawState.ts index b76cea3c7b..7085ac33dd 100644 --- a/packages/typegpu/src/core/pipeline/drawState.ts +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -10,7 +10,7 @@ import { import { logDataFromGPU } from '../../tgsl/consoleLog/deserializers.ts'; import type { LogResources } from '../../tgsl/consoleLog/types.ts'; import { isBuffer } from '../../types.ts'; -import type { TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; +import type { IndexFlag, TgpuBuffer, VertexFlag } from '../buffer/buffer.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; import type { ComputePassInternals } from '../commandEncoder/computePass.ts'; import type { RenderPassInternals } from '../commandEncoder/renderPass.ts'; @@ -27,7 +27,7 @@ export interface VertexBufferEntry { } export interface IndexBufferEntry { - buffer: TgpuBuffer | GPUBuffer; + buffer: (TgpuBuffer & IndexFlag) | GPUBuffer; indexFormat: GPUIndexFormat; offsetBytes?: number | undefined; sizeBytes?: number | undefined; diff --git a/packages/typegpu/src/core/pipeline/pipelineUtils.ts b/packages/typegpu/src/core/pipeline/pipelineUtils.ts index 0870f31f46..bced058e27 100644 --- a/packages/typegpu/src/core/pipeline/pipelineUtils.ts +++ b/packages/typegpu/src/core/pipeline/pipelineUtils.ts @@ -5,6 +5,10 @@ import type { BaseData } from '../../data/wgslTypes.ts'; import { isGPUBuffer } from '../../types.ts'; import { logger } from '../../tgpuLogger.ts'; +export const DISPATCH_INDIRECT_SIZE = 12; // 3 x u32 (x, y, z) +export const DRAW_INDIRECT_SIZE = 16; // 4 x 4 +export const DRAW_INDEXED_INDIRECT_SIZE = 20; // 5 x 4 + type IndirectOperation = 'dispatchWorkgroupsIndirect' | 'drawIndirect' | 'drawIndexedIndirect'; const IndirectOperationToRequiredData = { dispatchWorkgroupsIndirect: '3 x u32', diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index 0d6a554be7..f0adae84a7 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -78,7 +78,11 @@ import { } from './timeable.ts'; import { type PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; import { warnIfOverflow } from './limitsOverflow.ts'; -import { resolveIndirectOffset } from './pipelineUtils.ts'; +import { + DRAW_INDEXED_INDIRECT_SIZE, + DRAW_INDIRECT_SIZE, + resolveIndirectOffset, +} from './pipelineUtils.ts'; import { NullPerformanceTracker, PerformanceTrackerImpl, @@ -86,9 +90,6 @@ import { } from './performanceTracker.ts'; import { logger } from '../../tgpuLogger.ts'; -const DRAW_INDIRECT_SIZE = 16; // 4 x 4 -const DRAW_INDEXED_INDIRECT_SIZE = 20; // 5 x 4 - export interface RenderPipelineInternals { readonly core: RenderPipelineCore; readonly priors: TgpuRenderPipelinePriors & TimestampWritesPriors; diff --git a/packages/typegpu/src/core/pipeline/timeable.ts b/packages/typegpu/src/core/pipeline/timeable.ts index 11890371e7..463cd3f384 100644 --- a/packages/typegpu/src/core/pipeline/timeable.ts +++ b/packages/typegpu/src/core/pipeline/timeable.ts @@ -94,12 +94,9 @@ const pendingTimestampReads = new WeakMap< >(); async function readTimestamps( - root: ExperimentalTgpuRoot, querySet: TgpuQuerySet<'timestamp'>, registrations: TimestampRegistration[], ): Promise { - await root.device.queue.onSubmittedWorkDone(); - if (!querySet.available) { return; } @@ -170,7 +167,7 @@ export function queueTimestampResolve( byQuerySet.set(querySet, regs); internals.afterSubmit.set(querySet, () => { byQuerySet.delete(querySet); - void readTimestamps(root, querySet, regs); + void readTimestamps(querySet, regs); }); } diff --git a/packages/typegpu/src/core/pipeline/typeGuards.ts b/packages/typegpu/src/core/pipeline/typeGuards.ts index 7875b84c83..69e95573d5 100644 --- a/packages/typegpu/src/core/pipeline/typeGuards.ts +++ b/packages/typegpu/src/core/pipeline/typeGuards.ts @@ -1,4 +1,4 @@ -import { $internal, isMarkedInternal } from '../../shared/symbols.ts'; +import { isMarkedInternal } from '../../shared/symbols.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; import type { TgpuRenderCommands, TgpuRenderPass } from '../commandEncoder/renderPass.ts'; @@ -7,12 +7,12 @@ import type { TgpuRenderPipeline } from './renderPipeline.ts'; export function isComputePipeline(value: unknown): value is TgpuComputePipeline { const maybe = value as TgpuComputePipeline | undefined; - return maybe?.resourceType === 'compute-pipeline' && !!maybe[$internal]; + return maybe?.resourceType === 'compute-pipeline' && isMarkedInternal(maybe); } export function isRenderPipeline(value: unknown): value is TgpuRenderPipeline { const maybe = value as TgpuRenderPipeline | undefined; - return maybe?.resourceType === 'render-pipeline' && !!maybe[$internal]; + return maybe?.resourceType === 'render-pipeline' && isMarkedInternal(maybe); } export function isPipeline(value: unknown): value is TgpuComputePipeline | TgpuRenderPipeline { @@ -21,25 +21,25 @@ export function isPipeline(value: unknown): value is TgpuComputePipeline | TgpuR export function isTgpuCommandEncoder(value: unknown): value is TgpuCommandEncoder { const maybe = value as TgpuCommandEncoder | undefined; - return maybe?.resourceType === 'command-encoder' && !!maybe[$internal]; + return maybe?.resourceType === 'command-encoder' && isMarkedInternal(maybe); } export function isTgpuRenderPass(value: unknown): value is TgpuRenderPass { const maybe = value as TgpuRenderPass | undefined; - return maybe?.resourceType === 'render-pass' && !!maybe[$internal]; + return maybe?.resourceType === 'render-pass' && isMarkedInternal(maybe); } export function isTgpuRenderCommands(value: unknown): value is TgpuRenderCommands { const maybe = value as TgpuRenderCommands | undefined; return ( (maybe?.resourceType === 'render-pass' || maybe?.resourceType === 'render-bundle-encoder') && - !!maybe[$internal] + isMarkedInternal(maybe) ); } export function isTgpuComputePass(value: unknown): value is TgpuComputePass { const maybe = value as TgpuComputePass | undefined; - return maybe?.resourceType === 'compute-pass' && !!maybe[$internal]; + return maybe?.resourceType === 'compute-pass' && isMarkedInternal(maybe); } export function isGPUCanvasContext(value: unknown): value is GPUCanvasContext { diff --git a/packages/typegpu/src/core/root/init.ts b/packages/typegpu/src/core/root/init.ts index 0a8a09e1e6..909ca0658c 100644 --- a/packages/typegpu/src/core/root/init.ts +++ b/packages/typegpu/src/core/root/init.ts @@ -40,7 +40,7 @@ import { isRenderPipeline, isTgpuCommandEncoder, isTgpuComputePass, - isTgpuRenderPass, + isTgpuRenderCommands, } from '../pipeline/typeGuards.ts'; import { INTERNAL_createCommandEncoder, @@ -136,16 +136,16 @@ export class TgpuGuardedComputePipelineImpl< this.#lastSize = vec3u(); } - with(bindGroup: TgpuBindGroup): TgpuGuardedComputePipeline; - with(pass: TgpuComputePass): TgpuGuardedComputePipeline; - with(encoder: TgpuCommandEncoder): TgpuGuardedComputePipeline; - with(encoder: GPUCommandEncoder): TgpuGuardedComputePipeline; - with( - bindGroupOrEncoder: TgpuBindGroup | TgpuComputePass | TgpuCommandEncoder | GPUCommandEncoder, - ): TgpuGuardedComputePipeline { + with(bindGroup: TgpuBindGroup): TgpuGuardedComputePipeline { + if (!isBindGroup(bindGroup)) { + throw new Error( + 'Guarded pipelines only accept bind groups in .with(). To record into passes or encoders, use a regular compute pipeline.', + ); + } + return new TgpuGuardedComputePipelineImpl( this.#root, - this.#pipeline.with(bindGroupOrEncoder as TgpuBindGroup & GPUCommandEncoder), + this.#pipeline.with(bindGroup), this.#sizeUniform, this.#workgroupSize, ); @@ -175,32 +175,8 @@ export class TgpuGuardedComputePipelineImpl< ); } - #trackBatchedSize(size: v3u): void { - const priors = this.#pipeline[$internal].priors; - const target = priors.pass ?? priors.encoder; - if (!target) { - return; - } - - const scope = isTgpuComputePass(target) ? (target[$internal].owner ?? target) : target; - const submittable = isTgpuCommandEncoder(scope) && !scope[$internal].adopted; - const sizes = scope[$internal].guardedDispatchSizes; - - const prev = sizes.get(this.#sizeUniform); - if (prev && !allEq(prev, size)) { - const message = - 'Differently-sized dispatchThreads calls cannot be batched into one submission, since they share a size uniform and every recorded dispatch observes the last written size. Submit between the dispatches, or use separate pipelines.'; - if (submittable) { - throw new Error(message); - } - logger.warnOnce('suspicious', scope, 'guarded-dispatch-size', message); - } - sizes.set(this.#sizeUniform, size); - } - dispatchThreads(...threads: TArgs): void { const sanitizedSize = toVec3(threads); - this.#trackBatchedSize(sanitizedSize); const workgroupCount = ceil(vec3f(sanitizedSize).div(vec3f(this.#workgroupSize))); if (!allEq(sanitizedSize, this.#lastSize)) { // Only updating the size if it has changed from the last @@ -479,6 +455,7 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu unwrap(resource: TgpuCommandEncoder): GPUCommandEncoder; unwrap(resource: TgpuRenderPass): GPURenderPassEncoder; unwrap(resource: TgpuComputePass): GPUComputePassEncoder; + unwrap(resource: TgpuRenderBundleEncoder): GPURenderBundleEncoder; unwrap(resource: TgpuBindGroupLayout): GPUBindGroupLayout; unwrap(resource: TgpuBindGroup): GPUBindGroup; unwrap(resource: TgpuBuffer): GPUBuffer; @@ -496,6 +473,7 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu | TgpuCommandEncoder | TgpuRenderPass | TgpuComputePass + | TgpuRenderBundleEncoder | TgpuBindGroupLayout | TgpuBindGroup | TgpuBuffer @@ -512,6 +490,7 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu | GPUCommandEncoder | GPURenderPassEncoder | GPUComputePassEncoder + | GPURenderBundleEncoder | GPUBindGroupLayout | GPUBindGroup | GPUBuffer @@ -528,7 +507,7 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu return resource[$internal].rawEncoder; } - if (isTgpuRenderPass(resource) || isTgpuComputePass(resource)) { + if (isTgpuRenderCommands(resource) || isTgpuComputePass(resource)) { resource[$internal].state.rawAccessed = true; return resource[$internal].rawPass; } diff --git a/packages/typegpu/src/core/root/rootTypes.ts b/packages/typegpu/src/core/root/rootTypes.ts index b058e48a31..313f4bad21 100644 --- a/packages/typegpu/src/core/root/rootTypes.ts +++ b/packages/typegpu/src/core/root/rootTypes.ts @@ -37,7 +37,6 @@ import type { IORecord } from '../function/fnTypes.ts'; import type { TgpuFragmentFn, VertexOutToVarying } from '../function/tgpuFragmentFn.ts'; import type { TgpuVertexFn } from '../function/tgpuVertexFn.ts'; import type { TgpuCommandEncoder } from '../commandEncoder/commandEncoder.ts'; -import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; import type { TgpuRenderBundleEncoder } from '../commandEncoder/renderPass.ts'; import type { TgpuComputePipeline } from '../pipeline/computePipeline.ts'; import type { FragmentOutToTargets, TgpuRenderPipeline } from '../pipeline/renderPipeline.ts'; @@ -60,20 +59,6 @@ export interface TgpuGuardedComputePipeline e */ with(bindGroup: TgpuBindGroup): TgpuGuardedComputePipeline; - /** - * Returns a pipeline wrapper that dispatches into the provided compute pass. - * Analogous to `TgpuComputePipeline.with(pass)`. - */ - with(pass: TgpuComputePass): TgpuGuardedComputePipeline; - - /** - * Returns a pipeline wrapper that encodes dispatches into the provided - * command encoder instead of submitting them immediately. - * Analogous to `TgpuComputePipeline.with(encoder)`. - */ - with(encoder: TgpuCommandEncoder): TgpuGuardedComputePipeline; - with(encoder: GPUCommandEncoder): TgpuGuardedComputePipeline; - /** * Returns a pipeline wrapper with the given performance callback attached. * Analogous to `TgpuComputePipeline.withPerformanceCallback(callback)`. diff --git a/packages/typegpu/src/unwrapper.ts b/packages/typegpu/src/unwrapper.ts index 996fc2b8e9..6e2625fbff 100644 --- a/packages/typegpu/src/unwrapper.ts +++ b/packages/typegpu/src/unwrapper.ts @@ -2,7 +2,7 @@ import type { TgpuQuerySet } from './core/querySet/querySet.ts'; import type { TgpuBuffer } from './core/buffer/buffer.ts'; import type { TgpuCommandEncoder } from './core/commandEncoder/commandEncoder.ts'; import type { TgpuComputePass } from './core/commandEncoder/computePass.ts'; -import type { TgpuRenderPass } from './core/commandEncoder/renderPass.ts'; +import type { TgpuRenderBundleEncoder, TgpuRenderPass } from './core/commandEncoder/renderPass.ts'; import type { TgpuComputePipeline } from './core/pipeline/computePipeline.ts'; import type { TgpuRenderPipeline } from './core/pipeline/renderPipeline.ts'; import type { TgpuComparisonSampler, TgpuSampler } from './core/sampler/sampler.ts'; @@ -19,6 +19,7 @@ export interface Unwrapper { unwrap(resource: TgpuCommandEncoder): GPUCommandEncoder; unwrap(resource: TgpuRenderPass): GPURenderPassEncoder; unwrap(resource: TgpuComputePass): GPUComputePassEncoder; + unwrap(resource: TgpuRenderBundleEncoder): GPURenderBundleEncoder; unwrap(resource: TgpuBindGroupLayout): GPUBindGroupLayout; unwrap(resource: TgpuBindGroup): GPUBindGroup; unwrap(resource: TgpuBuffer): GPUBuffer; diff --git a/packages/typegpu/tests/buffer.test.ts b/packages/typegpu/tests/buffer.test.ts index 3473bb79ae..149de3cad3 100644 --- a/packages/typegpu/tests/buffer.test.ts +++ b/packages/typegpu/tests/buffer.test.ts @@ -638,6 +638,39 @@ describe('TgpuBuffer', () => { buffer3.copyFrom(copy32); }); + it('records clear into a given command encoder', ({ root, commandEncoder, device }) => { + const buffer = root.createBuffer(d.u32); + + const encoder = root.createCommandEncoder(); + buffer.clear(encoder); + + expect(commandEncoder.clearBuffer).toHaveBeenCalledWith(root.unwrap(buffer)); + expect(device.queue.submit).not.toHaveBeenCalled(); + + encoder.submit(); + expect(device.queue.submit).toHaveBeenCalledTimes(1); + }); + + it('records copyFrom into a given command encoder', ({ root, commandEncoder, device }) => { + const src = root.createBuffer(d.u32); + const dst = root.createBuffer(d.u32); + + const encoder = root.createCommandEncoder(); + dst.copyFrom(src, encoder); + + expect(commandEncoder.copyBufferToBuffer).toHaveBeenCalledWith( + root.unwrap(src), + 0, + root.unwrap(dst), + 0, + 4, + ); + expect(device.queue.submit).not.toHaveBeenCalled(); + + encoder.submit(); + expect(device.queue.submit).toHaveBeenCalledTimes(1); + }); + it('should be able to write to a buffer with atomic data', ({ root, device }) => { const buffer = root.createBuffer(d.arrayOf(d.atomic(d.u32), 3)); const NestedSchema = d.struct({ diff --git a/packages/typegpu/tests/commandEncoder.test.ts b/packages/typegpu/tests/commandEncoder.test.ts index 885b676897..44d674bbe5 100644 --- a/packages/typegpu/tests/commandEncoder.test.ts +++ b/packages/typegpu/tests/commandEncoder.test.ts @@ -50,10 +50,10 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(root.device.createCommandEncoder).toBeCalledTimes(1); - expect(root.device.queue.submit).toBeCalledTimes(1); - expect(renderPassEncoder.draw).toBeCalledTimes(2); - expect(renderPassEncoder.end).toBeCalledTimes(1); + expect(root.device.createCommandEncoder).toHaveBeenCalledTimes(1); + expect(root.device.queue.submit).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.end).toHaveBeenCalledTimes(1); }); it('applies pipeline state once for consecutive draws with the same pipeline', ({ @@ -77,9 +77,9 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setPipeline).toBeCalledTimes(1); - expect(renderPassEncoder.setBindGroup).toBeCalledTimes(1); - expect(renderPassEncoder.draw).toBeCalledTimes(3); + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(3); }); it('re-applies pipeline state after another pipeline drew', ({ root, renderPassEncoder }) => { @@ -105,7 +105,7 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setPipeline).toBeCalledTimes(3); + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(3); }); it('re-applies pipeline state after pass-level setBindGroup', ({ root, renderPassEncoder }) => { @@ -131,9 +131,9 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setPipeline).toBeCalledTimes(2); - expect(renderPassEncoder.setBindGroup).nthCalledWith(1, 0, root.unwrap(groupA)); - expect(renderPassEncoder.setBindGroup).nthCalledWith(2, 0, root.unwrap(groupB)); + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setBindGroup).toHaveBeenNthCalledWith(1, 0, root.unwrap(groupA)); + expect(renderPassEncoder.setBindGroup).toHaveBeenNthCalledWith(2, 0, root.unwrap(groupB)); }); it('stamps pipeline-bound bind groups onto the pass', ({ root, renderPassEncoder }) => { @@ -155,8 +155,8 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setBindGroup).toBeCalledTimes(1); - expect(renderPassEncoder.setBindGroup).toBeCalledWith(0, root.unwrap(pipelineGroup)); + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledWith(0, root.unwrap(pipelineGroup)); }); it('lets a later setBindGroup overwrite a stamped bind group', ({ root, renderPassEncoder }) => { @@ -180,9 +180,13 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setBindGroup).toBeCalledTimes(2); - expect(renderPassEncoder.setBindGroup).nthCalledWith(1, 0, root.unwrap(pipelineGroup)); - expect(renderPassEncoder.setBindGroup).nthCalledWith(2, 0, root.unwrap(passGroup)); + expect(renderPassEncoder.setBindGroup).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setBindGroup).toHaveBeenNthCalledWith( + 1, + 0, + root.unwrap(pipelineGroup), + ); + expect(renderPassEncoder.setBindGroup).toHaveBeenNthCalledWith(2, 0, root.unwrap(passGroup)); }); it('applies a prepared index buffer when drawing proxy-style', ({ root, renderPassEncoder }) => { @@ -198,14 +202,14 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setIndexBuffer).toBeCalledTimes(1); - expect(renderPassEncoder.setIndexBuffer).toBeCalledWith( + expect(renderPassEncoder.setIndexBuffer).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.setIndexBuffer).toHaveBeenCalledWith( root.unwrap(indexBuffer), 'uint16', undefined, undefined, ); - expect(renderPassEncoder.drawIndexed).toBeCalledTimes(1); + expect(renderPassEncoder.drawIndexed).toHaveBeenCalledTimes(1); }); it('keeps a stamped index buffer for the next pipeline', ({ root, renderPassEncoder }) => { @@ -226,28 +230,24 @@ describe('TgpuCommandEncoder', () => { // The pipeline's index buffer overwrites the pass one and stays set, // just like on a raw WebGPU pass - expect(renderPassEncoder.setIndexBuffer).toBeCalledTimes(2); - expect(renderPassEncoder.setIndexBuffer).nthCalledWith( + expect(renderPassEncoder.setIndexBuffer).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setIndexBuffer).toHaveBeenNthCalledWith( 1, root.unwrap(pipelineIndexBuffer), 'uint16', undefined, undefined, ); - expect(renderPassEncoder.setIndexBuffer).nthCalledWith( + expect(renderPassEncoder.setIndexBuffer).toHaveBeenNthCalledWith( 2, root.unwrap(pipelineIndexBuffer), 'uint16', undefined, undefined, ); - expect(renderPassEncoder.setStencilReference).not.toBeCalled(); }); - it('applies pass and pipeline stencil references in call order', ({ - root, - renderPassEncoder, - }) => { + it('applies the effective stencil reference at draw time', ({ root, renderPassEncoder }) => { const plain = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); const withRef = plain.withStencilReference(5); @@ -261,10 +261,28 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setStencilReference).toBeCalledTimes(3); - expect(renderPassEncoder.setStencilReference).nthCalledWith(1, 7); - expect(renderPassEncoder.setStencilReference).nthCalledWith(2, 5); - expect(renderPassEncoder.setStencilReference).nthCalledWith(3, 2); + // The 7 is overwritten by the pipeline stamp before any draw, so it is never emitted + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setStencilReference).toHaveBeenNthCalledWith(1, 5); + expect(renderPassEncoder.setStencilReference).toHaveBeenNthCalledWith(2, 2); + }); + + it('does not re-emit an unchanged stencil reference', ({ root, renderPassEncoder }) => { + const pipeline = root.createRenderPipeline({ vertex: plainVertex, fragment: mainFragment }); + + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + const bound = pipeline.with(pass); + pass.setStencilReference(1); + bound.draw(3); + pass.setStencilReference(1); + bound.draw(3); + pass.end(); + encoder.submit(); + + expect(renderPassEncoder.draw).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(1); }); it('keeps a stamped stencil reference for the next pipeline', ({ root, renderPassEncoder }) => { @@ -278,8 +296,8 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setStencilReference).toBeCalledTimes(1); - expect(renderPassEncoder.setStencilReference).toBeCalledWith(5); + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(5); }); it('disables state deduplication after the pass is unwrapped', ({ root, renderPassEncoder }) => { @@ -296,7 +314,7 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setPipeline).toBeCalledTimes(3); + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(3); }); it('resets applied state after executeBundles', ({ root, renderPassEncoder }) => { @@ -317,7 +335,7 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(renderPassEncoder.setPipeline).toBeCalledTimes(2); + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(2); }); it('throws when drawing without a pipeline', ({ root }) => { @@ -370,8 +388,8 @@ describe('TgpuCommandEncoder', () => { depthStencilAttachment: { view: depthTexture }, }); - expect(root.unwrap(colorTexture).createView).toBeCalled(); - expect(root.unwrap(depthTexture).createView).toBeCalled(); + expect(root.unwrap(colorTexture).createView).toHaveBeenCalled(); + expect(root.unwrap(depthTexture).createView).toHaveBeenCalled(); const descriptor = passDescriptor(commandEncoder.mock.beginRenderPass); const [colorAttachment] = [...descriptor.colorAttachments]; @@ -570,7 +588,7 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(consoleWarnSpy).toBeCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); expect(consoleWarnSpy.mock.calls[0]).toMatchInlineSnapshot(` [ "⚠️ [suspicious] ", @@ -590,7 +608,7 @@ describe('TgpuCommandEncoder', () => { pipeline.with(encoder).draw(3); encoder.submit(); - expect(consoleWarnSpy).not.toBeCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); }); }); @@ -616,16 +634,16 @@ describe('TgpuCommandEncoder', () => { pass.end(); encoder.submit(); - expect(root.device.queue.submit).toBeCalledTimes(1); + expect(root.device.queue.submit).toHaveBeenCalledTimes(1); const computePassMock = commandEncoder.mock.beginComputePass.mock.results[0]?.value as { setPipeline: unknown; dispatchWorkgroups: unknown; end: unknown; }; - expect(computePassMock.setPipeline).toBeCalledTimes(1); - expect(computePassMock.dispatchWorkgroups).toBeCalledTimes(2); - expect(computePassMock.end).toBeCalledTimes(1); + expect(computePassMock.setPipeline).toHaveBeenCalledTimes(1); + expect(computePassMock.dispatchWorkgroups).toHaveBeenCalledTimes(2); + expect(computePassMock.end).toHaveBeenCalledTimes(1); }); it('throws when dispatching without a pipeline', ({ root }) => { @@ -662,10 +680,10 @@ describe('TgpuCommandEncoder', () => { encoder.submit(); - expect(root.device.createCommandEncoder).toBeCalledTimes(1); - expect(root.device.queue.submit).toBeCalledTimes(1); - expect(commandEncoder.mock.beginComputePass).toBeCalledTimes(1); - expect(commandEncoder.mock.beginRenderPass).toBeCalledTimes(1); + expect(root.device.createCommandEncoder).toHaveBeenCalledTimes(1); + expect(root.device.queue.submit).toHaveBeenCalledTimes(1); + expect(commandEncoder.mock.beginComputePass).toHaveBeenCalledTimes(1); + expect(commandEncoder.mock.beginRenderPass).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/typegpu/tests/computePipeline.test.ts b/packages/typegpu/tests/computePipeline.test.ts index 31b4007950..aed07182ad 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -110,7 +110,7 @@ describe('TgpuComputePipeline', () => { root, commandEncoder, }) => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => { console.log(1); }); @@ -125,14 +125,13 @@ describe('TgpuComputePipeline', () => { expect(consoleWarnSpy).not.toHaveBeenCalled(); // The index and data log buffers are both read back once the encoder submits expect(commandEncoder.copyBufferToBuffer).toHaveBeenCalledTimes(2); - consoleWarnSpy.mockRestore(); }); it('warns that shader logs are lost when dispatching into a raw pass', ({ root, commandEncoder, }) => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + using consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => { console.log(1); }); @@ -146,7 +145,6 @@ describe('TgpuComputePipeline', () => { '⚠️ [suspicious] ', 'Shader console.log output is ignored when dispatching into a raw compute pass encoder, since there is no submission to read it back after.', ); - consoleWarnSpy.mockRestore(); }); it('resolves timestamps into the same submission as the pass', ({ @@ -164,7 +162,7 @@ describe('TgpuComputePipeline', () => { .dispatchWorkgroups(1); expect(commandEncoder.resolveQuerySet).toHaveBeenCalledTimes(1); - expect(device.queue.submit).toHaveBeenCalledTimes(1); + expect(device.queue.submit).toHaveBeenCalledTimes(2); }); it('defers timestamp resolution to the encoder it was given', ({ root, commandEncoder }) => { diff --git a/packages/typegpu/tests/guardedComputePipeline.test.ts b/packages/typegpu/tests/guardedComputePipeline.test.ts index c77deae337..3ecd5859e0 100644 --- a/packages/typegpu/tests/guardedComputePipeline.test.ts +++ b/packages/typegpu/tests/guardedComputePipeline.test.ts @@ -41,35 +41,20 @@ describe('TgpuGuardedComputePipeline', () => { expect(spy).toHaveBeenCalledWith(callback); }); - it('rejects differently-sized dispatches recorded into one encoder', ({ root }) => { - const guarded = root.createGuardedComputePipeline((_x: number) => { - 'use gpu'; - }); - - const encoder = root['~unstable'].createCommandEncoder(); - const batched = guarded.with(encoder); - - batched.dispatchThreads(1); - expect(() => batched.dispatchThreads(512)).toThrowErrorMatchingInlineSnapshot( - `[Error: Differently-sized dispatchThreads calls cannot be batched into one submission, since they share a size uniform and every recorded dispatch observes the last written size. Submit between the dispatches, or use separate pipelines.]`, - ); - - encoder.submit(); - expect(() => batched.dispatchThreads(512)).not.toThrow(); - }); - - it('allows same-sized dispatches recorded into one pass', ({ root }) => { + it('rejects passes and encoders in .with()', ({ root }) => { const guarded = root.createGuardedComputePipeline((_x: number) => { 'use gpu'; }); const encoder = root['~unstable'].createCommandEncoder(); const pass = encoder.beginComputePass(); - const batched = guarded.with(pass); - batched.dispatchThreads(64); - expect(() => batched.dispatchThreads(64)).not.toThrow(); - expect(() => batched.dispatchThreads(65)).toThrow(); + // @ts-expect-error guarded pipelines only accept bind groups + expect(() => guarded.with(encoder)).toThrowErrorMatchingInlineSnapshot( + `[Error: Guarded pipelines only accept bind groups in .with(). To record into passes or encoders, use a regular compute pipeline.]`, + ); + // @ts-expect-error guarded pipelines only accept bind groups + expect(() => guarded.with(pass)).toThrow(); }); it('delegates `withTimestampWrites` to the underlying pipeline', ({ root }) => {