Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions apps/typegpu-docs/src/content/docs/apis/buffers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,122 @@ pipeline.dispatchThreads(100);
TypeGPU automatically generates a "catch-all" bind group and populates it with the fixed resources.

You can also use [`resolveWithContext`](/TypeGPU/apis/resolve/#resolvewithcontext) to access the automatically generated bind group and layout containing your fixed resources.

## Buffer regions

A buffer region is a typed range of bytes in a buffer.
It reads, writes and binds only that range, and it can be used wherever a buffer can: in bind groups, as a fixed resource,
and as a vertex, index or indirect buffer. This lets a single buffer hold data for many consumers, each of which only sees its own part.

### Creating a region

Pass the region's schema and an accessor that navigates to the element the region covers.
The schema has to match that element. An array schema can also cover a prefix of an array element, or run over the elements of an array starting at the element.

```ts twoslash
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
// ---cut---
const Boid = d.struct({
position: d.vec2f,
velocity: d.vec2f,
});

const Scene = d.struct({
time: d.f32,
boids: d.arrayOf(Boid, 100),
});

const sceneBuffer = root.createBuffer(Scene).$usage('storage');

const thirdBoid = sceneBuffer.region(Boid, (scene) => scene.boids[2]);
// ^?
const lastTen = sceneBuffer.region(d.arrayOf(Boid, 10), (scene) => scene.boids[90]);

console.log(thirdBoid.offset, thirdBoid.size); // 40 16
```

To read bytes as a different type, pass their offset instead of an accessor. You are then responsible for those bytes holding data laid out as the schema.

```ts twoslash
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const Boid = d.struct({ position: d.vec2f, velocity: d.vec2f });
// ---cut---
const pool = root.createBuffer(d.arrayOf(d.u32, 1024)).$usage('storage');
const boids = pool.region(d.arrayOf(Boid, 16), { offsetBytes: 256 });
```

A region takes its usage flags from its buffer, so `thirdBoid` above can be bound as storage, but not as a uniform.

### Reading and writing a region

`.write()`, `.patch()`, `.read()`, `.clear()` and `.copyFrom()` work on regions just like on buffers, touching only the region's bytes.
Writes go through the same CPU copy as writes to the whole buffer.

```ts twoslash
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const Boid = d.struct({ position: d.vec2f, velocity: d.vec2f });
const Scene = d.struct({ time: d.f32, boids: d.arrayOf(Boid, 100) });
const sceneBuffer = root.createBuffer(Scene).$usage('storage');
const thirdBoid = sceneBuffer.region(Boid, (scene) => scene.boids[2]);
// ---cut---
thirdBoid.write({ position: d.vec2f(1, 2), velocity: d.vec2f(0, 1) });
thirdBoid.patch({ velocity: d.vec2f(1, 0) });

const boid = await thirdBoid.read();
```

WebGPU copies data in 4-byte steps, so writes, clears and copies require the region to start at a multiple of 4 bytes and to be a multiple of 4 bytes long.
A region that ends the buffer also covers the buffer's trailing padding. Copies between two regions of the same buffer are not allowed by WebGPU.

### Binding a region

A region binds only its own bytes of the buffer, both in bind groups and through `.as('uniform' | 'readonly' | 'mutable')`.
The shader sees a variable of the region's schema.

```ts twoslash
import { tgpu, d } from 'typegpu';
const root = await tgpu.init();
const Boid = d.struct({ position: d.vec2f, velocity: d.vec2f });
// ---cut---
const Frame = d.struct({
time: d.f32,
boids: d.align(256, d.arrayOf(Boid, 100)),
});

const frameBuffer = root.createBuffer(Frame).$usage('storage');
const boids = frameBuffer.region(d.arrayOf(Boid, 100), (frame) => frame.boids).as('mutable');

const main = tgpu.computeFn({
workgroupSize: [64],
in: { gid: d.builtin.globalInvocationId },
})(({ gid }) => {
'use gpu';
if (gid.x >= 100) {
return;
}
const boid = boids.$[gid.x]!;
boid.position = boid.position + boid.velocity;
});

const pipeline = root.createComputePipeline({ compute: main });
pipeline.dispatchWorkgroups(2);
```

:::caution[Offset alignment]
WebGPU only binds regions that start at a multiple of the device's `minUniformBufferOffsetAlignment` (for uniforms)
or `minStorageBufferOffsetAlignment` (for storage). Both limits are at most 256 bytes, and 256 bytes on many devices.
Binding a region at a different offset throws an error naming the limit. To bind it, place it at a multiple of the limit,
for example by wrapping its struct member in `d.align(256, ...)` as above.

Regions that are only read, written or copied, and never bound on their own, carry no such restriction.
:::

### Other uses

Regions of buffers with the matching usage can be passed as vertex buffers, as index buffers to `.withIndexBuffer()`, and as indirect buffers
to `.dispatchWorkgroupsIndirect()`, `.drawIndirect()` and `.drawIndexedIndirect()`. Offsets passed alongside a region count from its start.

`root.unwrap(region)` returns the region as a `GPUBufferBinding`, ready for raw WebGPU bind group entries.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<style>
#counter {
position: absolute;
top: 0.5rem;
left: 0.5rem;
font-family: monospace;
color: white;
}
</style>

<canvas></canvas>
<div id="counter"></div>
186 changes: 186 additions & 0 deletions apps/typegpu-docs/src/examples/rendering/indirect-culling/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { d, std, tgpu } from 'typegpu';
import { defineControls } from '../../common/defineControls.ts';

const root = await tgpu.init();
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = root.configureContext({ canvas, alphaMode: 'premultiplied' });
const counter = document.querySelector('#counter') as HTMLElement;

const FLOWER_COUNT = 20_000;
const WORLD_SIZE = 6;
const SEGMENTS = 64;
const WORKGROUP_SIZE = 64;

const Flower = d.struct({ position: d.vec2f, size: d.f32, hue: d.f32 });
const DrawArgs = d.struct({
vertexCount: d.u32,
instanceCount: d.atomic(d.u32),
firstVertex: d.u32,
firstInstance: d.u32,
});

const Scene = d.struct({
draw: DrawArgs,
flowers: d.align(256, d.arrayOf(Flower, FLOWER_COUNT)),
visible: d.align(256, d.arrayOf(d.u32, FLOWER_COUNT)),
});

const scene = root.createBuffer(Scene).$usage('storage', 'vertex', 'indirect');
const drawArgs = scene.region(DrawArgs, (s) => s.draw);
const flowers = scene.region(d.arrayOf(Flower, FLOWER_COUNT), (s) => s.flowers);
const visible = scene.region(d.arrayOf(d.u32, FLOWER_COUNT), (s) => s.visible);

const Params = d.struct({ camera: d.vec2f, frame: d.f32, time: d.f32 });
const params = root.createUniform(Params);

const toView = (position: d.v2f) => {
'use gpu';
return (std.fract((position - params.$.camera) / WORLD_SIZE + 0.5) - 0.5) * WORLD_SIZE;
};

const flowersMutable = flowers.as('mutable');
const drawArgsMutable = drawArgs.as('mutable');
const visibleMutable = visible.as('mutable');
const survivorCount = tgpu.workgroupVar(d.atomic(d.u32));
const survivorBase = tgpu.workgroupVar(d.u32);

const cull = tgpu.computeFn({
workgroupSize: [WORKGROUP_SIZE],
in: { gid: d.builtin.globalInvocationId, lid: d.builtin.localInvocationIndex },
})(({ gid, lid }) => {
'use gpu';
let keep = false;
if (gid.x < FLOWER_COUNT) {
const flower = flowersMutable.$[gid.x];
const position = toView(flower.position);
keep = std.max(std.abs(position.x), std.abs(position.y)) < params.$.frame + flower.size;
}

let slot = d.u32(0);
if (keep) {
slot = std.atomicAdd(survivorCount.$, 1);
}
std.workgroupBarrier();

if (lid === 0) {
survivorBase.$ = std.atomicAdd(
drawArgsMutable.$.instanceCount,
std.atomicLoad(survivorCount.$),
);
}
std.workgroupBarrier();

if (keep) {
visibleMutable.$[survivorBase.$ + slot] = gid.x;
}
});

const cullPipeline = root.createComputePipeline({ compute: cull });

const flowersReadonly = flowers.as('readonly');
const indexLayout = tgpu.vertexLayout(d.arrayOf(d.u32), 'instance');

const renderPipeline = root
.createRenderPipeline({
attribs: { index: indexLayout.attrib },
vertex: ({ index, $vertexIndex }) => {
'use gpu';
const flower = flowersReadonly.$[index];
const edge = d.f32(std.min($vertexIndex % 3, 1));
const angle =
(std.floor($vertexIndex / 3) + d.f32($vertexIndex % 3) - 1) * ((Math.PI * 2) / SEGMENTS) +
params.$.time * (flower.hue - 0.5);
const petals = std.floor(3 + flower.hue * 5);
const radius = flower.size * (0.65 + 0.35 * std.cos(petals * angle)) * edge;
const position = toView(flower.position) + radius * d.vec2f(std.cos(angle), std.sin(angle));
return { $position: d.vec4f(position, 0, 1), hue: flower.hue, edge };
},
fragment: ({ hue, edge }) => {
'use gpu';
const color = 0.5 + 0.5 * std.cos(6.2832 * (hue + d.vec3f(0, 0.33, 0.67)));
return d.vec4f(color * (0.15 + 0.45 * edge), 1);
},
targets: {
blend: {
color: { srcFactor: 'one', dstFactor: 'one' },
alpha: { srcFactor: 'one', dstFactor: 'one' },
},
},
})
.with(indexLayout, visible);

flowers.write(
Array.from({ length: FLOWER_COUNT }, () => ({
position: d.vec2f((Math.random() - 0.5) * WORLD_SIZE, (Math.random() - 0.5) * WORLD_SIZE),
size: 0.008 + Math.random() ** 4 * 0.06,
hue: Math.random(),
})),
);
drawArgs.write({ vertexCount: SEGMENTS * 3, instanceCount: 0, firstVertex: 0, firstInstance: 0 });

let culling = true;
let frameSize = 0.6;
let reading = false;
let frameId: number;

function frame(timestamp: number) {
const time = timestamp / 1000;
params.patch({ camera: d.vec2f(time * 0.1, Math.sin(time * 0.2)), time });
drawArgs.patch({ instanceCount: 0 });

const encoder = root['~unstable'].createCommandEncoder();
cullPipeline.with(encoder).dispatchWorkgroups(Math.ceil(FLOWER_COUNT / WORKGROUP_SIZE));
renderPipeline
.with(encoder)
.withColorAttachment({ view: context, clearValue: [0.02, 0.02, 0.04, 1] })
.drawIndirect(drawArgs);
encoder.submit();

if (!reading) {
reading = true;
void drawArgs
.read()
.then(({ instanceCount }) => {
counter.textContent = `${instanceCount} of ${FLOWER_COUNT} flowers drawn`;
reading = false;
})
.catch(() => {});
}

frameId = requestAnimationFrame(frame);
}

frameId = requestAnimationFrame(frame);

function updateFrame() {
params.patch({ frame: culling ? frameSize : WORLD_SIZE });
}

// #region Example controls and cleanup

export const controls = defineControls({
culling: {
initial: culling,
onToggleChange: (value) => {
culling = value;
updateFrame();
},
},
'frame size': {
initial: frameSize,
min: 0.1,
max: 1.2,
step: 0.05,
onSliderChange: (value) => {
frameSize = value;
updateFrame();
},
},
});

export function onCleanup() {
cancelAnimationFrame(frameId);
root.destroy();
}

// #endregion
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"title": "Indirect Culling",
"description": "Culls a drifting field of procedural flowers in a compute pass and draws the visible ones with an indirect draw, keeping the draw arguments, flowers and visible indices in regions of one buffer.",
"category": "rendering",
"tags": ["instancing", "compute", "performance", "buffer"],
"coolFactor": 5
}
Loading
Loading