wgpu.md
docs/wgpu.md · raw · all examples · site home
Headings: WebGPU computeStorage arraysWgpuArrayWriterWgpuArrayStoreWgpuCompute and WgpuComputeBatchWgpuBufferWgpuRadixSort and WgpuCompactWgpuSplatSorter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
# WebGPU compute
The `Wgpu*` classes provide the features only WebGPU has: compute kernels, storage buffers, compute writers and scattered stores into tables, and the GPU radix sort and compaction that `SplatRenderer` sorts with. Everything here needs the WebGPU backend and throws on WebGL2 (check `getGpuCaps(renderer).compute`).
Code: `src/wgpu/`. Kernels are TSL functions, like everything in `gpu/`, which three compiles to WGSL compute shaders ([shaders.md](shaders.md)).
```ts
const { If, uint } = TSL;
const counts = new WgpuBuffer({ renderer, format: "uint32", count: n });
const double = new WgpuCompute({
body: ({ index }) => {
If(index.lessThan(uint(n)), () => {
counts.element(index).assign(index.mul(2));
});
},
});
// n threads, in workgroups of 64
double.dispatch(renderer, n);
// A Uint32Array
const values = await counts.read();
```
## Storage arrays
`GpuArrays` made with `storage: true` can be written from compute. They also support everything other arrays do: uploads, readbacks, copies, swaps, readers, and fragment writers.
```ts
// Both are typed GpuArrays<S, true>
const arrays = new GpuArrays({
renderer,
bundles: { extSplats: Splats.EXT },
capacity,
storage: true,
});
const copy = GpuArrays.fromData(renderer, data, { storage: true });
```
- The type records the setting: `WgpuArrayWriter` and `WgpuArrayStore.bind` accept only `GpuArrays<S, true>`, so passing other arrays is a compile error. `arrays.hasStorage()` narrows arrays made with a `boolean`.
- Only some formats can be written this way (`channel.storage` tells which): 32-bit formats with 1, 2, or 4 components, and `rgba8unorm`, `rgba8snorm`, and `rgba16float` texels. That covers every splat bundle. Other channels stay read-only from compute.
- `swap` requires both arrays to have the same storage setting.
## WgpuArrayWriter
`WgpuArrayWriter` is the compute version of `GpuArrayWriter`. It takes the same constructor arguments, runs through the same `arrays.write` / `writeBatch`, and gives the same texels for the same indices. Both kinds can be mixed in one batch.
```ts
const writer = new WgpuArrayWriter({
outputs: { extSplats: Splats.EXT },
write: (index, out) => {
const splat = reader.loadSplat(index);
const pbr = reader.loadPbr(index);
GpuSplats.storeExt(out.extSplats, splat, pbr);
},
});
arrays.write(writer, { at: 0, count: n, first: 0 });
// A compute and a fragment writer in one batch
arrays.writeBatch([
{ writer, at: 0, count: 1000 },
{ writer: fragmentWriter, at: 4096, count: 500 },
]);
```
| Option | Default | What it does |
|---|---|---|
| `outputs` | | the bundles written (names to definitions) |
| `write(index, out)` | | assigns one texel per output channel |
| `workgroupSize` | 64 | threads per workgroup |
| `label` | from the channels | the kernel's name in GPU profilers |
- Each range runs as one dispatch with one thread per texel, across every layer.
- Limits: at most 4 output channels (`WgpuArrayWriter.MAX_CHANNELS`, from `maxStorageTexturesPerShaderStage`), one packing, storable formats only, and storage arrays only. There is no limit on bytes per texel.
- `write` must not read the arrays it writes: a texture cannot be sampled and written in the same dispatch. Read from other arrays, then `swap`.
- `writer.kernel` is its `WgpuCompute`.
## WgpuArrayStore
`WgpuArrayStore` writes elements at any index from a kernel. It is the writing counterpart of `GpuArrayReader`.
```ts
const store = new WgpuArrayStore(Splats.EXT).bind(arrays, "extSplats");
const scatter = new WgpuCompute({
body: ({ index }) => {
If(index.lessThan(n), () => {
// One element at any index (packing 1)
store.store("extA", target(index), value(index));
});
},
});
scatter.dispatch(renderer, count);
arrays.count = count;
```
| Member | What it does |
|---|---|
| `new WgpuArrayStore(def)` | a store of one bundle definition; the shader code is built from it alone |
| `bind(arrays, name)` | write bundle `name` of storage arrays from now on; bind again after the arrays are resized or swapped |
| `store(channel, index, value)` | writes one element of an unpacked bundle |
| `storeTexel(channel, texel, value)` | writes a whole texel (of a packed bundle) |
The kernel must set `arrays.count` itself.
## WgpuCompute and WgpuComputeBatch
```ts
const kernel = new WgpuCompute({
workgroupSize: [64],
label: "myKernel",
body: ({ index, globalId, localId, workgroupId, numWorkgroups }) => {
// ...
},
});
// A thread count, [x, y, z] workgroups, or an indirect buffer
kernel.dispatch(renderer, threads);
// Two kernels in one compute pass
const batch = new WgpuComputeBatch(renderer);
batch.add(a, n);
batch.add(b, n);
batch.submit();
```
| Member | What it does |
|---|---|
| `new WgpuCompute({ body, workgroupSize?, label? })` | a kernel built once from a TSL body. `workgroupSize` is 1 to 3 numbers (default `[64]`) |
| `dispatch(renderer, size)` | runs it now. `size` (`DispatchSize`): a thread count (rounded up to whole workgroups), workgroups per dimension, or an `IndirectStorageBufferAttribute` the GPU wrote (3 x uint32) |
| `dispose()` | frees the kernel |
| `ComputeIds` | the body's argument: `globalId`, `localId`, `workgroupId`, `numWorkgroups`, and `index` (the linear thread index) |
| `new WgpuComputeBatch(renderer)`, `add(kernel, size)`, `submit({ split? })` | dispatches recorded into as few compute passes as possible; `split: true` dispatches one by one, so profilers time each kernel |
- A body must check its own bounds: a thread count is rounded up to whole workgroups. When a dispatch needs more than 65535 workgroups, three splits it into a 2D grid, and `index` stays linear.
- In a batch, each dispatch sees the storage writes of the dispatches before it. A kernel queued again with a different size starts a new pass.
- A uniform holds one value for a whole pass: set uniforms before `add`, and leave them until `submit`.
**Workgroup scans** for your own kernels are static methods of `WgpuCompute`: `scanTier(renderer, tier?)` (`"subgroup"` where the device has subgroups, else `"portable"`: a `ScanTier`), `threadRank(tier, localId)` (each thread's rank in scan order), and `workgroupExclusiveScan(tier, scratch, rank, value)` (an exclusive prefix sum of one `uint` per thread, plus the total), over `SCAN_WORKGROUP` (256) threads.
## WgpuBuffer
`WgpuBuffer` is a typed, resizable storage buffer of a 32-bit format with 1, 2, or 4 components.
```ts
const buffer = new WgpuBuffer({
renderer,
format: "uint32",
count,
atomic: false,
indirect: false,
});
// In TSL
buffer.element(i);
// From JavaScript
buffer.upload(values);
await buffer.read();
buffer.clear();
buffer.copyFrom(other);
buffer.ensureCapacity(n, { keep: true });
```
| Member | What it does |
|---|---|
| `new WgpuBuffer({ renderer, format, count, atomic?, indirect?, label? })` | `atomic`: the elements are `atomic<u32>` or `atomic<i32>`; `indirect`: dispatch or draw arguments; `label`: the GPU buffer's name in WebGPU errors and debuggers |
| `element(i)` | element `i` in TSL |
| `upload(data, offset?)`, `read(offset?, count?)`, `clear(offset?, count?)` | CPU writes, readbacks, and zeroing |
| `copyFrom(src, { srcStart?, dstStart?, count? })` | a GPU copy from a buffer of the same format |
| `setCapacity(n, { keep? })`, `ensureCapacity(n, { keep? })` | resize; `keep` copies the contents. `ensureCapacity` doubles, but never past the binding limit |
| `swap(other)` | trades GPU buffers (and their labels) with a buffer of the same format and kind |
| `node`, `attribute`, `capacity`, `byteLength`, `components`, `gpuBuffer()` | the TSL node, three's attribute, the size, and the `GPUBuffer` |
| `dispose()` | frees the buffer |
- Growing or swapping keeps the same TSL node, so shaders built on the buffer keep working.
- Shaders bind a whole buffer, so a size larger than the device's `maxStorageBufferBindingSize` throws an error naming the limit. Radix requests 256 MiB by default, which 98% of devices support (64M uint32s); `createRenderer({ limits: { maxStorageBufferBindingSize: "adapter" } })` asks for all the adapter has.
## WgpuRadixSort and WgpuCompact
These are general building blocks: the sort takes any uint32 keys and values, and the compaction takes any TSL function that returns a key.
```ts
// keys and values are WgpuBuffer<"uint32">, sorted in place
const sort = new WgpuRadixSort({ renderer, keys, values });
// Or sortIndirect(countBuffer, { bits })
sort.sort(count, { bits: 24 });
const compact = new WgpuCompact({ renderer, key: (i) => keyOf(i) });
// Keys equal to this are dropped
compact.invalid.value = 0xffffff;
// Outputs compact.keys, compact.values (indices) and compact.count (element 0)
compact.compact(n);
```
| Member | What it does |
|---|---|
| `new WgpuRadixSort({ renderer, keys, values, tier?, keysPerThread? })` | a stable LSD sort with 8-bit digits. `tier`: `"auto"` (default), `"portable"`, or `"subgroup"`. `keysPerThread`: 8 (default) or 16 (`RadixKeysPerThread`) |
| `sort(count, { bits? })` | sorts the first `count` keys and their values; `bits` is 16, 24, or 32 (default 32): 2, 3, or 4 passes |
| `sortIndirect(countBuffer, { bits? })` | the count from a buffer the GPU wrote (after compaction); every dispatch is sized on the GPU |
| `new WgpuCompact({ renderer, key, tier? })` | stream compaction of `key(i)` for the first `n` indices |
| `compact(n)`, `invalid`, `keys`, `values`, `count` | run it; the dropped key (a `uint` uniform, default all ones); the outputs |
| `dispose()` | frees the kernels and buffers |
- Each radix pass is a histogram, a block scan, and a scatter (reduce, then scan, with no forward-progress assumptions), one kernel per pass, so a whole sort fits in one `WgpuComputeBatch`. It gives the same order as the CPU's `SortKeys.sort`.
- Scans use subgroup operations where the device has them and workgroup memory otherwise. Both tiers give the same results.
- 16 keys per thread need about 19 KB of workgroup memory and measured 10-25% slower on an Apple M3, so 8 is the default.
- With an odd number of passes (24-bit keys), the sort ends by swapping the buffers with its scratch buffers (`WgpuBuffer.swap`) instead of copying back.
- `WgpuCompact` uses a deterministic block scan, no atomics, and keeps the input order, so a stable sort after compaction gives the same order as sorting every element.
## WgpuSplatSorter
`SplatRenderer` sorts with this class in `sortMode: "gpu"`. Compaction drops the invalid keys of a projection's `sortKeys` bundle, `sortIndirect` orders the rest back to front, and a one-thread kernel writes the `drawIndexedIndirect` arguments. Nothing is read back.
| Member | What it does |
|---|---|
| `new WgpuSplatSorter(renderer)` | the sorter's kernels and buffers |
| `sort(projected, count, bits)` | sorts the first `count` splats of arrays with a `sortKeys` bundle (`SortKeys.KEYS`) |
| `values` | the sorted splat indices: `values.element(i)` is the i-th splat drawn |
| `count` | the visible count, as a TSL node |
| `indirect` | the draw arguments: `[6 x 1024, ceil(visible / 1024), 0, 0, 0]` |
| `readCount()` | the visible count, read back |
| `WgpuSplatSorter.QUADS_PER_INSTANCE` | 1024: splats per instance of the draw |
| `dispose()` | frees it |
More on how the renderer uses it: [sorting.md](sorting.md#the-gpu-sort).