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..bb0ba5be77 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 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` 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/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 24a9c50488..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(); @@ -558,34 +584,129 @@ 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 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 -root['~unstable'].beginRenderPass( - { - colorAttachments: [{ - ... - }], - }, - (pass) => { - pass.setPipeline(renderPipeline); - pass.setBindGroup(layout, group); - pass.draw(3); +```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({ + 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(); ``` +The `beginRenderPass` method accepts a descriptor similar to WebGPU's `GPURenderPassDescriptor`, with a few conveniences: + +- 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. + +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 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, 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 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); +pass.end(); +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 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. + 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/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/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..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) { @@ -159,39 +158,36 @@ 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-gl/src/tgpuRootWebGL.ts b/packages/typegpu-gl/src/tgpuRootWebGL.ts index 1725fd4e20..f0256acb8d 100644 --- a/packages/typegpu-gl/src/tgpuRootWebGL.ts +++ b/packages/typegpu-gl/src/tgpuRootWebGL.ts @@ -492,12 +492,12 @@ export class TgpuRootWebGL { throw new WebGLFallbackUnsupportedError('createGuardedComputePipeline'); } - beginRenderPass(): never { - throw new WebGLFallbackUnsupportedError('beginRenderPass'); + createCommandEncoder(): never { + throw new WebGLFallbackUnsupportedError('createCommandEncoder'); } - beginRenderBundleEncoder(): never { - throw new WebGLFallbackUnsupportedError('beginRenderBundleEncoder'); + createRenderBundleEncoder(): never { + throw new WebGLFallbackUnsupportedError('createRenderBundleEncoder'); } createTexture(): never { 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/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/attachments.ts b/packages/typegpu/src/core/commandEncoder/attachments.ts new file mode 100644 index 0000000000..b1b39cf956 --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/attachments.ts @@ -0,0 +1,197 @@ +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 { ExperimentalTgpuRoot } from '../root/rootTypes.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'] + | 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 { + const { querySet, beginningOfPassWriteIndex, endOfPassWriteIndex } = timestampWrites; + + const result: GPURenderPassTimestampWrites = { + 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..58d760927c --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/commandEncoder.ts @@ -0,0 +1,148 @@ +import { $internal } from '../../shared/symbols.ts'; +import { logger } from '../../tgpuLogger.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; + /** 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>; + /** 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. texture 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); +} + +export function INTERNAL_adoptCommandEncoder( + root: ExperimentalTgpuRoot, + rawEncoder: GPUCommandEncoder, +): TgpuCommandEncoder { + return new TgpuCommandEncoderImpl(root, rawEncoder, true); +} + +// -------------- +// Implementation +// -------------- + +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, root, afterSubmit } = this[$internal]; + this.#recordPendingCommands(); + + if (afterSubmit.size > 0) { + 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.', + ); + } + + 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..2d91d8ab4f --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/computePass.ts @@ -0,0 +1,181 @@ +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 { isGPUBuffer } from '../../types.ts'; +import type { IndirectFlag, TgpuBuffer } from '../buffer/buffer.ts'; +import { DISPATCH_INDIRECT_SIZE, resolveIndirectOffset } from '../pipeline/pipelineUtils.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'; +import type { TgpuCommandEncoder } from './commandEncoder.ts'; + +// ---------- +// Public API +// ---------- + +/** + * The TypeGPU equivalent of {@link GPUComputePassDescriptor}. + * Query sets accept TypeGPU query sets next to raw {@link GPUQuerySet}s. + */ +export interface TgpuComputePassDescriptor { + label?: string | undefined; + timestampWrites?: TgpuPassTimestampWrites | undefined; +} + +export interface ComputePassInternals { + readonly rawPass: GPUComputePassEncoder; + readonly state: ComputeDrawState; + /** Undefined for raw pass encoders the caller owns */ + readonly owner: TgpuCommandEncoder | undefined; + appliedVersion: 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 */ + setBindGroup(bindGroup: TgpuBindGroup): void; + /** Associates a bind group with the given layout */ + setBindGroup>( + bindGroupLayout: TgpuBindGroupLayout, + bindGroup: TgpuBindGroup | GPUBindGroup, + ): void; + + dispatchWorkgroups(x: number, y?: number, z?: number): 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; +} + +// -------------- +// 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); + } + + return new TgpuComputePassImpl(root, rawEncoder.beginComputePass(rawDescriptor), encoder); +} + +export function INTERNAL_adoptComputePass( + root: ExperimentalTgpuRoot, + rawPass: GPUComputePassEncoder, +): TgpuComputePass { + const adopted = new TgpuComputePassImpl(root, rawPass, undefined); + adopted[$internal].state.rawAccessed = true; + 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, + appliedVersion: 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 { + stampComputePipeline(this[$internal].state, pipeline); + } + + setBindGroup>( + first: TgpuBindGroup | TgpuBindGroupLayout, + bindGroup?: TgpuBindGroup | GPUBindGroup, + ): void { + recordBindGroup(this[$internal].state, first as TgpuBindGroup | TgpuBindGroupLayout, bindGroup); + } + + dispatchWorkgroups(x: number, y?: number, z?: number): void { + this.#emit((rawPass) => rawPass.dispatchWorkgroups(x, y, z)); + } + + 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 { + 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..1cfe258d89 --- /dev/null +++ b/packages/typegpu/src/core/commandEncoder/renderPass.ts @@ -0,0 +1,526 @@ +import type { Disarray } from '../../data/dataTypes.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 { 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, + 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'; +import type { TgpuCommandEncoder } from './commandEncoder.ts'; +import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; +import { + type ColorAttachment, + type DepthStencilAttachment, + 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; + /** Undefined for bundle encoders and for raw pass encoders the caller owns */ + readonly owner: TgpuCommandEncoder | undefined; + appliedVersion: 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-encoder'; + + /** Sets the current {@link TgpuRenderPipeline} for subsequent draw calls */ + setPipeline(pipeline: TgpuRenderPipeline): void; + + /** Associates a bind group with the layout it was created from */ + setBindGroup(bindGroup: TgpuBindGroup): void; + /** Associates a bind group with the given layout */ + setBindGroup>( + bindGroupLayout: TgpuBindGroupLayout, + bindGroup: TgpuBindGroup | GPUBindGroup, + ): void; + + /** Binds a vertex buffer to the given vertex layout */ + setVertexBuffer( + vertexLayout: TgpuVertexLayout, + buffer: (TgpuBuffer & VertexFlag) | GPUBuffer, + offset?: number, + size?: number, + ): void; + + /** Sets the current index buffer */ + setIndexBuffer( + buffer: (TgpuBuffer & IndexFlag) | 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; + /** + * 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; +} + +/** + * 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. + * + * 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: readonly [number, number, number, number] | 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); + } + + if (descriptor.maxDrawCount !== undefined) { + rawDescriptor.maxDrawCount = descriptor.maxDrawCount; + } + + return new TgpuRenderPassImpl(root, rawEncoder.beginRenderPass(rawDescriptor), encoder); +} + +export function INTERNAL_createRenderBundleEncoder( + root: ExperimentalTgpuRoot, + descriptor: GPURenderBundleEncoderDescriptor, +): TgpuRenderBundleEncoder { + return new TgpuRenderBundleEncoderImpl( + root, + root.device.createRenderBundleEncoder(descriptor), + undefined, + ); +} + +export function INTERNAL_adoptRenderCommands( + root: ExperimentalTgpuRoot, + rawPass: GPURenderPassEncoder | GPURenderBundleEncoder, +): TgpuRenderCommands { + const adopted = + 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; +} + +class TgpuRenderCommandsImpl< + TRaw extends GPURenderPassEncoder | GPURenderBundleEncoder = + | GPURenderPassEncoder + | GPURenderBundleEncoder, +> implements TgpuRenderCommands { + readonly [$internal]: RenderPassInternals; + readonly resourceType: 'render-pass' | 'render-bundle-encoder' = 'render-bundle-encoder'; + readonly #root: ExperimentalTgpuRoot; + + constructor(root: ExperimentalTgpuRoot, rawPass: TRaw, owner: TgpuCommandEncoder | undefined) { + this.#root = root; + this[$internal] = { + rawPass, + state: new RenderDrawState(), + owner, + appliedVersion: 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 { + stampRenderPipeline(this[$internal].state, pipeline); + } + + setBindGroup>( + first: TgpuBindGroup | TgpuBindGroupLayout, + bindGroup?: TgpuBindGroup | GPUBindGroup, + ): void { + recordBindGroup(this[$internal].state, first as TgpuBindGroup | TgpuBindGroupLayout, bindGroup); + } + + 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 & IndexFlag) | 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: (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: (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)); + } +} + +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 +{ + 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: readonly [number, number, number, number] | GPUColor): void { + this[$internal].rawPass.setBlendConstant(color as GPUColor); + } + + 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.appliedVersion = 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 deleted file mode 100644 index df2e708ddc..0000000000 --- a/packages/typegpu/src/core/pipeline/applyPipelineState.ts +++ /dev/null @@ -1,86 +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'; -import { warnIfOverflow } from './limitsOverflow.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 function applyBindGroups( - encoder: GPURenderPassEncoder | GPURenderBundleEncoder | GPUComputePassEncoder, - root: ExperimentalTgpuRoot, - usedBindGroupLayouts: TgpuBindGroupLayout[], - catchall: [number, TgpuBindGroup] | undefined, - resolveBindGroup: BindGroupResolver, -): 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])); - 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 fef3a07b8e..9aaf4af163 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'; @@ -11,12 +10,24 @@ 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, } 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, finalizeOwnEncoder } 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'; @@ -27,14 +38,13 @@ import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import type { TgpuSlot } from '../slot/slotTypes.ts'; import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; -import { resolveIndirectOffset } from './pipelineUtils.ts'; +import { warnIfOverflow } from './limitsOverflow.ts'; +import { DISPATCH_INDIRECT_SIZE, resolveIndirectOffset } from './pipelineUtils.ts'; import { createWithPerformanceCallback, createWithTimestampWrites, - setupTimestampWrites, type Timeable, type TimestampWritesPriors, - triggerPerformanceCallback, } from './timeable.ts'; import type { IndirectFlag, TgpuBuffer } from '../buffer/buffer.ts'; import { @@ -44,8 +54,8 @@ import { } from './performanceTracker.ts'; import { logger } from '../../tgpuLogger.ts'; -interface ComputePipelineInternals { - readonly rawPipeline: GPUComputePipeline; +export interface ComputePipelineInternals { + readonly core: ComputePipelineCore; 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,44 +153,28 @@ type Memo = { logResources: LogResources | undefined; }; -const _lastAppliedCompute = new WeakMap(); - class TgpuComputePipelineImpl implements TgpuComputePipeline { public readonly [$internal]: ComputePipelineInternals; 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] = { - 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; } [$resolve](ctx: ResolutionCtx): ResolvedSnippet { - return ctx.resolve(this.#core); + return ctx.resolve(this[$internal].core); } toString(): string { return `computePipeline:${getName(this) ?? ''}`; } - get rawPipeline(): GPUComputePipeline { - return this.#core.unwrap().pipeline; + #withPriors(patch: Partial): this { + const { core, priors } = this[$internal]; + + return new TgpuComputePipelineImpl(core, { ...priors, ...patch }) as this; } with>( @@ -177,56 +183,68 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { ): 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 { + const internals = this[$internal]; + + if (isTgpuComputePass(first)) { + return this.#withPriors({ pass: first, encoder: undefined }); + } + + if (isTgpuCommandEncoder(first)) { + return this.#withPriors({ pass: undefined, encoder: first }); + } + if (isGPUComputePassEncoder(first)) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - externalPass: first, - externalEncoder: undefined, - }) as this; + return this.#withPriors({ + pass: INTERNAL_adoptComputePass(internals.root, first), + encoder: undefined, + }); } if (isGPUCommandEncoder(first)) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - externalEncoder: first, - externalPass: undefined, - }) as this; + return this.#withPriors({ + pass: undefined, + encoder: INTERNAL_adoptCommandEncoder(internals.root, first), + }); } - if (isBindGroup(first)) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, + if (isBindGroup(first) || isBindGroupLayout(first)) { + const [layout, group] = isBindGroup(first) + ? [first.layout, first] + : [first, bindGroup as TgpuBindGroup | GPUBindGroup]; + + return this.#withPriors({ bindGroupLayoutMap: new Map([ - ...(this.#priors.bindGroupLayoutMap ?? []), - [first.layout, first], + ...(internals.priors.bindGroupLayoutMap ?? []), + [layout, group], ]), - }) as this; + }); } - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - bindGroupLayoutMap: new Map([ - ...(this.#priors.bindGroupLayoutMap ?? []), - [first, bindGroup as TgpuBindGroup | GPUBindGroup], - ]), - }) as this; + throw new Error('Unsupported value passed into .with()'); } withPerformanceCallback(callback: (start: bigint, end: bigint) => void | Promise): this { - if (this.#priors.timestampWrites) { - return new TgpuComputePipelineImpl(this.#core, { - ...this.#priors, - performanceCallback: callback, - }) as this; + 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', @@ -234,8 +252,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(internals.priors, callback, querySet)); } withTimestampWrites(options: { @@ -243,97 +260,55 @@ 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; + const internals = this[$internal]; + + return this.#withPriors(createWithTimestampWrites(internals.priors, options, internals.root)); } 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( 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', ); - this._executeComputePass((pass) => pass.dispatchWorkgroupsIndirect(rawBuffer, offset)); + this.#execute((pass) => pass.dispatchWorkgroupsIndirect(rawBuffer, offset)); } initAsync(): Promise { - return this.#core.initAsync(); + return this[$internal].core.initAsync(); } initSync() { - 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), - ); + this[$internal].core.initSync(); } - private _executeComputePass(dispatch: (pass: GPUComputePassEncoder) => void): void { - const { root } = this.#core; - - 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); - return; - } + #execute(dispatch: (pass: GPUComputePassEncoder) => void): void { + const { core, priors, root } = this[$internal]; - 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(); + if (priors.pass) { + emitComputeDispatch(root, priors.pass[$internal], this, dispatch); return; } - const memo = this.#core.unwrap(); - - const passDescriptor: GPUComputePassDescriptor = { - label: getName(this.#core) ?? '', - ...setupTimestampWrites(this.#priors, root), - }; - - const commandEncoder = root.device.createCommandEncoder(); - const pass = commandEncoder.beginComputePass(passDescriptor); - this._applyComputeState(pass); - dispatch(pass); + const encoder = priors.encoder ?? INTERNAL_createCommandEncoder(root); + const pass = encoder.beginComputePass({ + label: getName(core) ?? '', + 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); - } - - if (this.#priors.performanceCallback) { - void triggerPerformanceCallback({ - root, - priors: this.#priors, - }); - } + finalizeOwnEncoder(encoder, core, core.unwrap().logResources, priors); } $name(label: string): this { @@ -482,6 +457,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/connectAttachmentToShader.ts b/packages/typegpu/src/core/pipeline/connectAttachmentToShader.ts index b0f39de99b..2e76672e07 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 BaseData, isVoid, isWgslStruct } from '../../data/wgslTypes.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; @@ -10,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 new file mode 100644 index 0000000000..7085ac33dd --- /dev/null +++ b/packages/typegpu/src/core/pipeline/drawState.ts @@ -0,0 +1,390 @@ +import { MissingBindGroupsError, MissingVertexBuffersError } from '../../errors.ts'; +import type { BaseData } from '../../data/wgslTypes.ts'; +import { $internal } from '../../shared/symbols.ts'; +import { logger } from '../../tgpuLogger.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 { 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'; +import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; +import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.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 & IndexFlag) | GPUBuffer; + indexFormat: GPUIndexFormat; + offsetBytes?: number | undefined; + sizeBytes?: number | undefined; +} + +export class RenderDrawState { + readonly bindGroups = new Map(); + readonly vertexBuffers = new Map(); + currentPipeline: TgpuRenderPipeline | undefined; + indexBuffer: IndexBufferEntry | undefined; + stencilReference: GPUStencilValue | undefined; + /** What the raw pass holds, starting at the WebGPU default; survives executeBundles */ + appliedStencilReference: GPUStencilValue = 0; + version = 0; + /** Raw access via `root.unwrap(pass)` can mutate state invisibly, disabling deduplication */ + rawAccessed = false; +} + +export class ComputeDrawState { + readonly bindGroups = new Map(); + currentPipeline: TgpuComputePipeline | undefined; + version = 0; + rawAccessed = false; +} + +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++; +} + +/** 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, + 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, + passState: RenderDrawState, +): void { + const memo = pipeline[$internal].core.unwrap(); + encoder.setPipeline(memo.pipeline); + + applyBindGroups(encoder, root, memo.usedBindGroupLayouts, memo.catchall, (layout) => + passState.bindGroups.get(layout), + ); + + applyVertexBuffers(encoder, root, memo.usedVertexLayouts, (vertexLayout) => + passState.vertexBuffers.get(vertexLayout), + ); + + if (passState.indexBuffer !== undefined) { + applyIndexBuffer(encoder, root, passState.indexBuffer); + } + + if ( + typeof (encoder as GPURenderPassEncoder).setStencilReference === 'function' && + passState.stencilReference !== undefined + ) { + if (passState.rawAccessed || passState.stencilReference !== passState.appliedStencilReference) { + (encoder as GPURenderPassEncoder).setStencilReference(passState.stencilReference); + passState.appliedStencilReference = passState.stencilReference; + } + } +} + +function applyComputePipelineState( + encoder: GPUComputePassEncoder, + root: ExperimentalTgpuRoot, + pipeline: TgpuComputePipeline, + passState: ComputeDrawState, +): void { + const memo = pipeline[$internal].core.unwrap(); + encoder.setPipeline(memo.pipeline); + + applyBindGroups(encoder, root, memo.usedBindGroupLayouts, memo.catchall, (layout) => + passState.bindGroups.get(layout), + ); +} + +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.', + ); + } +} + +function warnAboutUnreachableSubmission(core: object, what: string): void { + 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.`, + ); +} + +/** 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 { + if (!encoder || encoder[$internal].adopted) { + return false; + } + + encoder[$internal].afterSubmit.set(logResources, () => logDataFromGPU(logResources)); + 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', + 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, + hasTimestampWrites: boolean, + logResources: LogResources | undefined, + passKind: 'render' | 'compute', + hasAttachments = false, +): void { + const wording = PassKindWording[passKind]; + + if (hasAttachments) { + logger.warnOnce( + 'suspicious', + core, + 'attachments', + `Pipeline-level attachments are ignored when ${wording.into}. Pass \`colorAttachments\` and \`depthStencilAttachment\` to encoder.${wording.begin} instead.`, + ); + } + + if (hasTimestampWrites) { + logger.warnOnce( + 'suspicious', + core, + 'timestampWrites', + `Pipeline-level timestamp writes are ignored when ${wording.into}. Pass \`timestampWrites\` to encoder.${wording.begin} instead.`, + ); + } + + if (logResources && !queueLogDrain(owner, logResources)) { + 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.`, + ); + } +} + +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 (state.currentPipeline !== pipeline) { + stampRenderPipeline(state, pipeline); + } + + if (usesIndexBuffer) { + requireIndexBuffer(state.indexBuffer); + } + + const memo = core.unwrap(); + if (!ownsPass) { + reportIgnoredPriors( + core, + passInternals.owner, + !!priors.timestampWrites, + memo.logResources, + 'render', + !!priors.colorAttachment || !!priors.depthStencilAttachment, + ); + } + + if (state.rawAccessed || passInternals.appliedVersion !== state.version) { + applyRenderPipelineState(rawPass, root, pipeline, state); + passInternals.appliedVersion = state.version; + } + + emit(rawPass); +} + +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]; + + if (state.currentPipeline !== pipeline) { + stampComputePipeline(state, pipeline); + } + + const memo = core.unwrap(); + if (!ownsPass) { + reportIgnoredPriors( + core, + passInternals.owner, + !!priors.timestampWrites, + memo.logResources, + 'compute', + ); + } + + if (state.rawAccessed || passInternals.appliedVersion !== state.version) { + applyComputePipelineState(rawPass, root, pipeline, state); + passInternals.appliedVersion = state.version; + } + + emit(rawPass); +} 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 194ff7b6a9..f0adae84a7 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, @@ -34,7 +29,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'; @@ -53,36 +47,42 @@ 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 { - isTexture, - isTextureView, - 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'; import { connectTargetsToShader } from './connectTargetsToShader.ts'; -import { applyBindGroups, applyVertexBuffers } from './applyPipelineState.ts'; +import { + INTERNAL_adoptCommandEncoder, + 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, finalizeOwnEncoder, requireIndexBuffer } from './drawState.ts'; import { isGPUCommandEncoder, isGPURenderBundleEncoder, isGPURenderPassEncoder, + isTgpuCommandEncoder, + isTgpuRenderCommands, } from './typeGuards.ts'; import { createWithPerformanceCallback, createWithTimestampWrites, - setupTimestampWrites, type Timeable, type TimestampWritesPriors, - triggerPerformanceCallback, } from './timeable.ts'; import { type PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; -import { resolveIndirectOffset } from './pipelineUtils.ts'; +import { warnIfOverflow } from './limitsOverflow.ts'; +import { + DRAW_INDEXED_INDIRECT_SIZE, + DRAW_INDIRECT_SIZE, + resolveIndirectOffset, +} from './pipelineUtils.ts'; import { NullPerformanceTracker, PerformanceTrackerImpl, @@ -90,10 +90,7 @@ 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 - -interface RenderPipelineInternals { +export interface RenderPipelineInternals { readonly core: RenderPipelineCore; readonly priors: TgpuRenderPipelinePriors & TimestampWritesPriors; readonly root: ExperimentalTgpuRoot; @@ -155,6 +152,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; @@ -305,139 +312,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?: 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 = { @@ -470,8 +344,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 = { @@ -480,13 +356,9 @@ type Memo = { catchall: [number, TgpuBindGroup] | undefined; logResources: LogResources | undefined; usedVertexLayouts: TgpuVertexLayout[]; - fragmentOut: BaseData; + fragmentOut: BaseData | undefined; }; -const _lastAppliedRender = new WeakMap< - GPURenderPassEncoder | GPURenderBundleEncoder, - TgpuRenderPipelineImpl ->(); class TgpuRenderPipelineImpl implements TgpuRenderPipeline { public readonly [$internal]: RenderPipelineInternals; public readonly resourceType = 'render-pipeline'; @@ -515,6 +387,12 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { return this; } + #withPriors(patch: Partial): this { + const { core, priors } = this[$internal]; + + return new TgpuRenderPipelineImpl(core, { ...priors, ...patch }) as this; + } + with( vertexLayout: TgpuVertexLayout, buffer: TgpuBuffer & VertexFlag, @@ -526,6 +404,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 +414,8 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { | TgpuVertexLayout | TgpuBindGroupLayout | TgpuBindGroup + | TgpuRenderCommands + | TgpuCommandEncoder | GPUCommandEncoder | GPURenderPassEncoder | GPURenderBundleEncoder, @@ -541,50 +423,48 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { ): this { const internals = this[$internal]; + if (isTgpuRenderCommands(first)) { + return this.#withPriors({ pass: first, encoder: undefined }); + } + + if (isTgpuCommandEncoder(first)) { + return this.#withPriors({ pass: undefined, encoder: first }); + } + if (isGPURenderPassEncoder(first) || isGPURenderBundleEncoder(first)) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - externalRenderEncoder: first, - externalEncoder: undefined, - }) as this; + return this.#withPriors({ + pass: INTERNAL_adoptRenderCommands(internals.root, first), + encoder: undefined, + }); } if (isGPUCommandEncoder(first)) { - return new TgpuRenderPipelineImpl(internals.core, { - ...internals.priors, - externalEncoder: first, - externalRenderEncoder: undefined, - }) as this; + return this.#withPriors({ + pass: undefined, + encoder: INTERNAL_adoptCommandEncoder(internals.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()'); @@ -594,10 +474,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; @@ -608,8 +485,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: { @@ -618,39 +494,20 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { endOfPassWriteIndex?: number; }): this { const internals = this[$internal]; - const newPriors = createWithTimestampWrites( - internals.priors, - options, - internals.core.options.root, - ); - return new TgpuRenderPipelineImpl(internals.core, newPriors) as this; + + return this.#withPriors(createWithTimestampWrites(internals.priors, options, internals.root)); } 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( @@ -670,15 +527,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, @@ -695,8 +549,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], @@ -720,100 +573,42 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { this[$internal].core.initSync(); } - private _createRenderPass(encoder: GPUCommandEncoder): GPURenderPassEncoder { - 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(); - } - - 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[]) - : []; + #ownPassDescriptor(): TgpuRenderPassDescriptor { + const { core, priors } = this[$internal]; + const { fragmentOut } = core.unwrap(); - const renderPassDescriptor: GPURenderPassDescriptor = { - label: getName(internals.core) ?? '', - colorAttachments, - ...setupTimestampWrites(internals.priors, root), + return { + label: getName(core) ?? '', + colorAttachments: fragmentOut + ? connectAttachmentToShader(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 { - const internals = this[$internal]; - const memo = internals.core.unwrap(); - 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; - }); + #execute( + usesIndexBuffer: boolean, + emit: (rawPass: GPURenderPassEncoder | GPURenderBundleEncoder) => void, + ): void { + const { core, priors, root } = this[$internal]; - if (internals.priors.stencilReference !== undefined && 'setStencilReference' in encoder) { - encoder.setStencilReference(internals.priors.stencilReference); + if (priors.pass) { + emitRenderDraw(root, priors.pass[$internal], this, usesIndexBuffer, emit); + return; } - } - - private _setIndexBuffer(encoder: GPURenderPassEncoder | GPURenderBundleEncoder): void { - const internals = this[$internal]; - const { root } = internals.core.options; - if (!internals.priors.indexBuffer) { - throw new Error('No index buffer set for this render pipeline.'); + // checked up front so a rejected draw never leaves a half-recorded pass behind + if (usesIndexBuffer) { + requireIndexBuffer(priors.indexBuffer); } - const { buffer, indexFormat, offsetBytes, sizeBytes } = internals.priors.indexBuffer; + 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 (isGPUBuffer(buffer)) { - encoder.setIndexBuffer(buffer, indexFormat, offsetBytes, sizeBytes); - } else { - encoder.setIndexBuffer(root.unwrap(buffer), indexFormat, offsetBytes, sizeBytes); - } + finalizeOwnEncoder(encoder, core, core.unwrap().logResources, priors); } draw( @@ -822,47 +617,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 +629,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 +646,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 +661,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)); } } @@ -1107,7 +751,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; @@ -1120,7 +764,7 @@ class RenderPipelineCore implements SelfResolvable { catchall, logResources, usedVertexLayouts: connectedAttribs.usedVertexLayouts, - fragmentOut: this.#latestAutoFragmentOut as BaseData, + fragmentOut, }; this.#performanceTracker.measureCompile(device); }) @@ -1141,7 +785,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 = { @@ -1150,7 +795,7 @@ class RenderPipelineCore implements SelfResolvable { catchall, logResources, usedVertexLayouts: connectedAttribs.usedVertexLayouts, - fragmentOut: this.#latestAutoFragmentOut as BaseData, + fragmentOut, }; this.#performanceTracker.measureCompile(device); @@ -1187,6 +832,8 @@ class RenderPipelineCore implements SelfResolvable { ); } + warnIfOverflow(usedBindGroupLayouts, device.limits); + const module = device.createShaderModule({ label: `${getName(this) ?? ''} - Shader`, code, @@ -1251,7 +898,7 @@ class RenderPipelineCore implements SelfResolvable { descriptor.multisample = tgpuDescriptor.multisample; } - return { resolutionResult, descriptor, connectedAttribs }; + return { resolutionResult, descriptor, connectedAttribs, fragmentOut }; } } @@ -1315,7 +962,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..463cd3f384 100644 --- a/packages/typegpu/src/core/pipeline/timeable.ts +++ b/packages/typegpu/src/core/pipeline/timeable.ts @@ -1,8 +1,15 @@ import { isQuerySet, type TgpuQuerySet } from '../querySet/querySet.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 { + /** + * 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: { @@ -76,39 +83,43 @@ export function createWithTimestampWrites( }; } -export function setupTimestampWrites( - priors: TimestampWritesPriors, - root: ExperimentalTgpuRoot, -): { - timestampWrites?: GPUComputePassTimestampWrites | GPURenderPassTimestampWrites; -} { - if (!priors.timestampWrites) { - return {}; +type TimestampRegistration = { + priors: TimestampWritesPriors; + callback: (start: bigint, end: bigint) => void | Promise; +}; + +const pendingTimestampReads = new WeakMap< + TgpuCommandEncoder, + Map, TimestampRegistration[]> +>(); + +async function readTimestamps( + querySet: TgpuQuerySet<'timestamp'>, + registrations: TimestampRegistration[], +): Promise { + if (!querySet.available) { + return; } - const { querySet, beginningOfPassWriteIndex, endOfPassWriteIndex } = priors.timestampWrites; + const result = await querySet.read(); - const timestampWrites: GPUComputePassTimestampWrites | GPURenderPassTimestampWrites = { - querySet: isQuerySet(querySet) ? root.unwrap(querySet) : querySet, - }; + for (const { priors, callback } of registrations) { + const start = result[priors.timestampWrites?.beginningOfPassWriteIndex ?? 0]; + const end = result[priors.timestampWrites?.endOfPassWriteIndex ?? 1]; - if (beginningOfPassWriteIndex !== undefined) { - timestampWrites.beginningOfPassWriteIndex = beginningOfPassWriteIndex; - } - if (endOfPassWriteIndex !== undefined) { - timestampWrites.endOfPassWriteIndex = endOfPassWriteIndex; - } + if (start === undefined || end === undefined) { + throw new Error('QuerySet did not return valid timestamps.'); + } - return { timestampWrites }; + await callback(start, end); + } } -export function triggerPerformanceCallback({ - root, - priors, -}: { - root: ExperimentalTgpuRoot; - priors: TimestampWritesPriors; -}): void | Promise { +/** Returns false when the encoder is one we cannot defer work to, meaning the callback never fires */ +export function queueTimestampResolve( + encoder: TgpuCommandEncoder, + priors: TimestampWritesPriors, +): boolean { const querySet = priors.timestampWrites?.querySet; const callback = priors.performanceCallback as ( start: bigint, @@ -125,28 +136,51 @@ 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]; + const internals = encoder[$internal]; + if (internals.adopted) { + return false; + } - if (start === undefined || end === undefined) { - throw new Error('QuerySet did not return valid timestamps.'); - } + const { root } = internals; - await callback(start, end); + // recorded at submission time to capture the last pass written into this encoder + internals.beforeFinish.set(querySet, (rawEncoder) => { + rawEncoder.resolveQuerySet( + root.unwrap(querySet), + 0, + querySet.count, + querySet[$internal].resolveBuffer, + 0, + ); }); + + 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(querySet, regs); + }); + } + + if (registrations.some((reg) => reg.priors === priors)) { + 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.', + ); + } 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 903f7f2c35..69e95573d5 100644 --- a/packages/typegpu/src/core/pipeline/typeGuards.ts +++ b/packages/typegpu/src/core/pipeline/typeGuards.ts @@ -1,51 +1,92 @@ -import { $internal } 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'; import type { TgpuComputePipeline } from './computePipeline.ts'; 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 { return isRenderPipeline(value) || isComputePipeline(value); } +export function isTgpuCommandEncoder(value: unknown): value is TgpuCommandEncoder { + const maybe = value as TgpuCommandEncoder | undefined; + 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' && 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') && + isMarkedInternal(maybe) + ); +} + +export function isTgpuComputePass(value: unknown): value is TgpuComputePass { + const maybe = value as TgpuComputePass | undefined; + return maybe?.resourceType === 'compute-pass' && isMarkedInternal(maybe); +} + +export function isGPUCanvasContext(value: unknown): value is GPUCanvasContext { + return typeof (value as GPUCanvasContext)?.getCurrentTexture === 'function'; +} + export function isGPUCommandEncoder(value: unknown): value is GPUCommandEncoder { + const maybe = value as GPUCommandEncoder | undefined; return ( - !!value && - typeof value === 'object' && - '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' && - 'dispatchWorkgroups' in value && - !('beginRenderPass' in value) + !isMarkedInternal(maybe) && + typeof maybe?.dispatchWorkgroups === 'function' && + maybe?.beginRenderPass === undefined ); } export function isGPURenderPassEncoder(value: unknown): value is GPURenderPassEncoder { - return !!value && typeof value === 'object' && 'executeBundles' in value && 'draw' in value; + const maybe = value as GPURenderPassEncoder | undefined; + return ( + !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' && - '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 a3544d67e8..909ca0658c 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, + isTgpuRenderCommands, +} from '../pipeline/typeGuards.ts'; +import { + INTERNAL_createCommandEncoder, + type TgpuCommandEncoder, +} from '../commandEncoder/commandEncoder.ts'; +import type { TgpuComputePass } from '../commandEncoder/computePass.ts'; +import { + INTERNAL_createRenderBundleEncoder, + type TgpuRenderBundleEncoder, + 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, @@ -123,12 +136,16 @@ export class TgpuGuardedComputePipelineImpl< this.#lastSize = vec3u(); } - with(bindGroup: TgpuBindGroup): TgpuGuardedComputePipeline; - with(encoder: GPUCommandEncoder): TgpuGuardedComputePipeline; - with(bindGroupOrEncoder: TgpuBindGroup | 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, ); @@ -435,6 +452,10 @@ 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: TgpuRenderBundleEncoder): GPURenderBundleEncoder; unwrap(resource: TgpuBindGroupLayout): GPUBindGroupLayout; unwrap(resource: TgpuBindGroup): GPUBindGroup; unwrap(resource: TgpuBuffer): GPUBuffer; @@ -449,6 +470,10 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu resource: | TgpuComputePipeline | TgpuRenderPipeline + | TgpuCommandEncoder + | TgpuRenderPass + | TgpuComputePass + | TgpuRenderBundleEncoder | TgpuBindGroupLayout | TgpuBindGroup | TgpuBuffer @@ -462,6 +487,10 @@ class TgpuRootImpl extends WithBindingImpl implements TgpuRoot, ExperimentalTgpu ): | GPUComputePipeline | GPURenderPipeline + | GPUCommandEncoder + | GPURenderPassEncoder + | GPUComputePassEncoder + | GPURenderBundleEncoder | GPUBindGroupLayout | GPUBindGroup | GPUBuffer @@ -471,7 +500,16 @@ 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)) { + return resource[$internal].rawEncoder; + } + + if (isTgpuRenderCommands(resource) || isTgpuComputePass(resource)) { + resource[$internal].state.rawAccessed = true; + return resource[$internal].rawPass; } if (isRenderPipeline(resource)) { @@ -523,140 +561,12 @@ 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); - }, - }; + createCommandEncoder(descriptor?: GPUCommandEncoderDescriptor): TgpuCommandEncoder { + return INTERNAL_createCommandEncoder(this, descriptor); } - 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()]); - } - - beginRenderBundleEncoder( - descriptor: GPURenderBundleEncoderDescriptor, - callback: (pass: RenderBundleEncoderPass) => void, - ): GPURenderBundle { - const bundleEncoder = this.device.createRenderBundleEncoder(descriptor); - - callback(this.createDrawablePassProxy(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 e06c2da95b..313f4bad21 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 { 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'; @@ -45,7 +47,6 @@ import type { AttribRecordToDefaultDataTypes, LayoutToAllowedAttribs, } from '../vertexLayout/vertexAttribute.ts'; -import type { TgpuVertexLayout } from '../vertexLayout/vertexLayout.ts'; // ---------- // Public API @@ -58,13 +59,6 @@ export interface TgpuGuardedComputePipeline e */ with(bindGroup: TgpuBindGroup): 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: GPUCommandEncoder): TgpuGuardedComputePipeline; - /** * Returns a pipeline wrapper with the given performance callback attached. * Analogous to `TgpuComputePipeline.withPerformanceCallback(callback)`. @@ -464,249 +458,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,10 +702,10 @@ export interface TgpuRoot extends Unwrapper, WithBinding { '~unstable': Pick< ExperimentalTgpuRoot, - | 'beginRenderPass' - | 'beginRenderBundleEncoder' + | 'createCommandEncoder' | 'createComparisonSampler' | 'createGuardedComputePipeline' + | 'createRenderBundleEncoder' | 'createSampler' | 'createTexture' | 'flush' @@ -997,24 +748,45 @@ 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}. + * 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: RenderBundleEncoderPass) => void, - ): GPURenderBundle; + createRenderBundleEncoder(descriptor: GPURenderBundleEncoderDescriptor): TgpuRenderBundleEncoder; /** @deprecated Use `root.createSampler` instead. */ createSampler(props: WgslSamplerProps): TgpuFixedSampler; 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..49f21cc191 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -58,12 +58,20 @@ 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, - 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 { + TgpuRenderBundleEncoder, + 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/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/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/src/unwrapper.ts b/packages/typegpu/src/unwrapper.ts index 11c9ae9590..6e2625fbff 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 { 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'; @@ -13,6 +16,10 @@ 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: 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 new file mode 100644 index 0000000000..44d674bbe5 --- /dev/null +++ b/packages/typegpu/tests/commandEncoder.test.ts @@ -0,0 +1,689 @@ +import { describe, expect, type Mock, vi } from 'vitest'; +import { Void } from 'typegpu/data'; +import { tgpu, d, type TgpuRoot } 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 } }); + + 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, 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: [] }); + pipeline.with(pass).draw(3); + pipeline.with(pass).draw(6, 2); + pass.end(); + encoder.submit(); + + 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', ({ + 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); + bound.draw(3); + bound.draw(3); + pass.end(); + encoder.submit(); + + 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 }) => { + 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(); + + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(3); + }); + + it('re-applies pipeline state after pass-level setBindGroup', ({ root, renderPassEncoder }) => { + 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(); + + 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 }) => { + 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(); + + 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 }) => { + 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).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 }) => { + 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(); + + expect(renderPassEncoder.setIndexBuffer).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.setIndexBuffer).toHaveBeenCalledWith( + root.unwrap(indexBuffer), + 'uint16', + undefined, + undefined, + ); + expect(renderPassEncoder.drawIndexed).toHaveBeenCalledTimes(1); + }); + + 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'); + + 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(); + + // The pipeline's index buffer overwrites the pass one and stays set, + // just like on a raw WebGPU pass + expect(renderPassEncoder.setIndexBuffer).toHaveBeenCalledTimes(2); + expect(renderPassEncoder.setIndexBuffer).toHaveBeenNthCalledWith( + 1, + root.unwrap(pipelineIndexBuffer), + 'uint16', + undefined, + undefined, + ); + expect(renderPassEncoder.setIndexBuffer).toHaveBeenNthCalledWith( + 2, + root.unwrap(pipelineIndexBuffer), + 'uint16', + undefined, + undefined, + ); + }); + + it('applies the effective stencil reference at draw time', ({ root, renderPassEncoder }) => { + 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(); + + // 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 }) => { + 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(); + + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledTimes(1); + expect(renderPassEncoder.setStencilReference).toHaveBeenCalledWith(5); + }); + + it('disables state deduplication after the pass is unwrapped', ({ root, renderPassEncoder }) => { + 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(); + + expect(renderPassEncoder.setPipeline).toHaveBeenCalledTimes(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).toHaveBeenCalledTimes(2); + }); + + 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)).toThrowErrorMatchingInlineSnapshot( + `[Error: Missing bind groups for layouts: 'layout'. Please provide it using pipeline.with(bindGroup).(...)]`, + ); + }); + + it('unwraps to raw WebGPU objects', ({ root, commandEncoder, renderPassEncoder }) => { + const encoder = root.createCommandEncoder(); + const renderPass = encoder.beginRenderPass({ colorAttachments: [] }); + const computePass = encoder.beginComputePass(); + + expect(root.unwrap(encoder)).toBe(commandEncoder); + expect(root.unwrap(renderPass)).toBe(renderPassEncoder); + expect(root.unwrap(computePass)).toBe( + commandEncoder.mock.beginComputePass.mock.results[0]?.value, + ); + }); + + 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'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + colorAttachments: [{ view: colorTexture }], + depthStencilAttachment: { view: depthTexture }, + }); + + expect(root.unwrap(colorTexture).createView).toHaveBeenCalled(); + expect(root.unwrap(depthTexture).createView).toHaveBeenCalled(); + + 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": "", + }, + } + `); + }); + + 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'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ depthStencilAttachment: { view: depthTexture } }); + encoder.beginRenderPass({ colorAttachments: { view: colorTexture } }); + + const omitted = passDescriptor(commandEncoder.mock.beginRenderPass, 0); + const single = passDescriptor(commandEncoder.mock.beginRenderPass, 1); + expect([...omitted.colorAttachments]).toHaveLength(0); + expect([...single.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 }, + }); + + expect(passDescriptor(commandEncoder.mock.beginRenderPass).depthStencilAttachment) + .toMatchInlineSnapshot(` + { + "depthReadOnly": true, + "view": { + "label": "", + }, + } + `); + }); + + 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 }, + }); + + expect(passDescriptor(commandEncoder.mock.beginRenderPass).depthStencilAttachment) + .toMatchInlineSnapshot(` + { + "depthClearValue": 1, + "depthLoadOp": "clear", + "depthStoreOp": "store", + "stencilLoadOp": "clear", + "stencilStoreOp": "store", + "view": { + "label": "", + }, + } + `); + }); + + 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'); + + const encoder = root.createCommandEncoder(); + encoder.beginRenderPass({ + depthStencilAttachment: { view: depthStencilTexture.createView('render') }, + }); + encoder.beginRenderPass({ + depthStencilAttachment: { + view: depthStencilTexture.createView('render', { aspect: 'depth-only' }), + }, + }); + + 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": "", + }, + } + `); + }); + + 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', + }, + }); + + 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": {}, + } + `); + }); + }); + + 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).toHaveBeenCalledTimes(1); + 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 }) => { + 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.toHaveBeenCalled(); + }); + }); + + 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).toHaveBeenCalledTimes(1); + + const computePassMock = commandEncoder.mock.beginComputePass.mock.results[0]?.value as { + setPipeline: unknown; + dispatchWorkgroups: unknown; + end: unknown; + }; + expect(computePassMock.setPipeline).toHaveBeenCalledTimes(1); + expect(computePassMock.dispatchWorkgroups).toHaveBeenCalledTimes(2); + expect(computePassMock.end).toHaveBeenCalledTimes(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).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 92b10789a8..aed07182ad 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -106,6 +106,199 @@ describe('TgpuComputePipeline', () => { `); }); + it('drains shader logs when dispatching into an encoder-owned pass', ({ + root, + commandEncoder, + }) => { + using 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(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + // The index and data log buffers are both read back once the encoder submits + expect(commandEncoder.copyBufferToBuffer).toHaveBeenCalledTimes(2); + }); + + it('warns that shader logs are lost when dispatching into a raw pass', ({ + root, + commandEncoder, + }) => { + using 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( + '⚠️ [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.', + ); + }); + + 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); + + expect(commandEncoder.resolveQuerySet).toHaveBeenCalledTimes(1); + expect(device.queue.submit).toHaveBeenCalledTimes(2); + }); + + 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); + + expect(commandEncoder.resolveQuerySet).not.toHaveBeenCalled(); + + encoder.submit(); + 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 }) => { + using 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( + '⚠️ [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.', + ); + + encoder.submit(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('warns that a performance callback cannot be reported on a raw encoder', ({ + root, + commandEncoder, + }) => { + using 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( + '⚠️ [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.", + ); + }); + + 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 }) => { + 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 can mutate the pass between dispatches, so nothing about its + // state can be assumed + 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/guardedComputePipeline.test.ts b/packages/typegpu/tests/guardedComputePipeline.test.ts index 6f47db4d48..3ecd5859e0 100644 --- a/packages/typegpu/tests/guardedComputePipeline.test.ts +++ b/packages/typegpu/tests/guardedComputePipeline.test.ts @@ -41,6 +41,22 @@ describe('TgpuGuardedComputePipeline', () => { expect(spy).toHaveBeenCalledWith(callback); }); + 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(); + + // @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 }) => { const querySet = root.createQuerySet('timestamp', 2); const guarded = root.createGuardedComputePipeline(() => { 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); diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index 58a1569af6..ffa8352de0 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 can mutate the encoder between draws, so nothing about its + // state can be assumed + expect(encoder.setPipeline).toHaveBeenCalledTimes(2); expect(encoder.draw).toHaveBeenCalledTimes(2); }); @@ -1484,6 +1486,107 @@ 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('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); + + 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; + draw: ReturnType; + }; + + // 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); + }); + 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..7698007def 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 @@ -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'), }); @@ -229,24 +229,20 @@ describe('TgpuRoot', () => { fragment: mainFragment, }); - root.beginRenderPass( - { - colorAttachments: [], - }, - (pass) => { - pass.setPipeline(pipeline); - pass.setBindGroup(layout, group); - pass.draw(1); - }, - ); - - const renderPassMock = commandEncoder.mock.beginRenderPass.mock.results[0] - ?.value as GPURenderPassEncoder; - expect(renderPassMock.setPipeline).toBeCalled(); - expect(renderPassMock.setBindGroup).not.toBeCalled(); + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setPipeline(pipeline); + pass.setBindGroup(layout, group); + pass.draw(1); + pass.end(); + encoder.submit(); + + 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'), }); @@ -256,25 +252,20 @@ describe('TgpuRoot', () => { fragment: mainFragment, }); - root.beginRenderPass( - { - colorAttachments: [], - }, - (pass) => { - pass.setPipeline(pipeline); - pass.setBindGroup(layout, group); - pass.draw(1); - }, - ); - - 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)); + const encoder = root.createCommandEncoder(); + const pass = encoder.beginRenderPass({ colorAttachments: [] }); + pass.setPipeline(pipeline); + pass.setBindGroup(layout, group); + pass.draw(1); + pass.end(); + encoder.submit(); + + 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'), }); @@ -286,21 +277,16 @@ 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; - 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)); }); }); 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'; 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: