Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions apps/typegpu-docs/src/content/docs/apis/buffers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
165 changes: 143 additions & 22 deletions apps/typegpu-docs/src/content/docs/apis/pipelines.mdx

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd make passes and encoders namable:

Image

stacked PR❓

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will open an issue, not a priority for me now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a note in docs that the API mixing may be unintuitive?

const encoder = root['~unstable'].createCommandEncoder();
const pass = encoder.beginComputePass();

pass.setPipeline(pipelineA);
pipelineB.with(pass).dispatchWorkgroups(1);
pass.dispatchWorkgroups(1);

pass.end();
encoder.submit(); // "B" is printed twice

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a note, take a look

Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand All @@ -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';
Expand Down
53 changes: 35 additions & 18 deletions apps/typegpu-docs/src/examples/algorithms/genetic-racing/ga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand All @@ -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;
Expand All @@ -237,7 +254,7 @@ const evolveShader = (i: number) => {
});

evolveLayout.$.nextState[i] = makeSpawnState();
};
});

export function createGeneticPopulation(root: TgpuRoot, params: TgpuUniform<typeof SimParams>) {
const stateBuffers = [0, 1].map(() =>
Expand Down Expand Up @@ -271,9 +288,9 @@ export function createGeneticPopulation(root: TgpuRoot, params: TgpuUniform<type
}),
);

const initPipeline = root.with(paramsAccess, params).createGuardedComputePipeline(initShader);
const fitPipeline = root.with(paramsAccess, params).createGuardedComputePipeline(fitShader);
const evolvePipeline = root.with(paramsAccess, params).createGuardedComputePipeline(evolveShader);
const [initPipeline, fitPipeline, evolvePipeline] = [initShader, fitShader, evolveShader].map(
(compute) => root.with(paramsAccess, params).createComputePipeline({ compute }),
);

let current = 0;
let generation = 0;
Expand All @@ -299,20 +316,20 @@ export function createGeneticPopulation(root: TgpuRoot, params: TgpuUniform<type
init() {
current = 0;
generation = 0;
initPipeline.with(initBindGroups[0]).dispatchThreads(MAX_POP);
initPipeline.with(initBindGroups[1]).dispatchThreads(MAX_POP);
initPipeline.with(initBindGroups[0]).dispatchWorkgroups(workgroupCount(MAX_POP));
initPipeline.with(initBindGroups[1]).dispatchWorkgroups(workgroupCount(MAX_POP));
},

reinitCurrent(population: number) {
initPipeline.with(initBindGroups[current]).dispatchThreads(population);
initPipeline.with(initBindGroups[current]).dispatchWorkgroups(workgroupCount(population));
},

precomputeFitness(population: number) {
fitPipeline.with(fitBindGroups[current]).dispatchThreads(population);
fitPipeline.with(fitBindGroups[current]).dispatchWorkgroups(workgroupCount(population));
},

evolve(population: number) {
evolvePipeline.with(evolveBindGroups[current]).dispatchThreads(population);
evolvePipeline.with(evolveBindGroups[current]).dispatchWorkgroups(workgroupCount(population));
current = 1 - current;
generation++;
},
Expand Down
Loading
Loading