Skip to content

Commit 2dc1f2e

Browse files
feat(experimental) Add reusable WebGPU 2D FFT foundation (#2831)
1 parent 889b70b commit 2dc1f2e

12 files changed

Lines changed: 1113 additions & 3 deletions

File tree

docs/api-reference/experimental/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ OIT resolve pipelines compose into one render stack.
5757

5858
The [GPU Primitives and Command Graphs guide](/docs/api-reference/experimental/gpu-primitives)
5959
introduces explicit command scheduling, typed table-backed graph views, hierarchical scan, stable
60-
compaction, stable key/value sorting, and GPU-written indirect draw commands.
60+
compaction, stable key/value sorting, bounded two-dimensional complex FFTs, and GPU-written
61+
indirect draw commands.
6162

6263
## GPU Simulations
6364

docs/api-reference/experimental/gpu-primitives/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@ later render pass consume that count without a CPU synchronization point.
5858
The implementation consists of `GPUCommandGraph`, typed graph data views, `GPUScan`,
5959
`GPUCompaction`, `GPUMask`, `GPUVisibilityWorkflow`, `GPUHierarchyLayout`, `GPUGraphTraversal`,
6060
`GPUAncestorProjection`, `GPUSort`, `GPUBatchSort`, `GPUReduction`, `GPUHistogram`, `GPUGridBinning`,
61-
`GPUGridAggregation`, `GPUGroupAggregation`, `GPUIndexPickingTarget`, `GPUReadbackRing`, and
62-
`DrawCommandBuffer`. The accompanying hierarchical trace viewer applies these primitives to
61+
`GPUGridAggregation`, `GPUGroupAggregation`, `GPUFFT2D`, `GPUIndexPickingTarget`,
62+
`GPUReadbackRing`, and `DrawCommandBuffer`. The accompanying hierarchical trace viewer applies these primitives to
6363
process and thread collapse, source and topology filtering, dependency focusing, visible-parent
6464
projection, GPU picking, activity histograms, and indirect span and edge rendering over up to
6565
four million spans. The sort and data-analysis examples demonstrate independent composable
@@ -1585,6 +1585,7 @@ close enough to WebGPU that developers can reason about cost, ordering, and owne
15851585
- [`GPUGraphTraversal`](/docs/api-reference/experimental/gpu-primitives/gpu-graph-traversal)
15861586
- [`GPUAncestorProjection`](/docs/api-reference/experimental/gpu-primitives/gpu-ancestor-projection)
15871587
- [`GPUSort`](/docs/api-reference/experimental/gpu-primitives/gpu-sort)
1588+
- [`GPUFFT2D`](/docs/api-reference/experimental/gpu-primitives/gpu-fft2d)
15881589
- [`GPUReduction`](/docs/api-reference/experimental/gpu-primitives/gpu-reduction)
15891590
- [`GPUHistogram`](/docs/api-reference/experimental/gpu-primitives/gpu-histogram)
15901591
- [`GPUGridBinning`](/docs/api-reference/experimental/gpu-primitives/gpu-grid-binning)
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import {GPUPrimitivesDocsTabs} from '@site/src/components/docs/gpu-primitives-docs-tabs';
2+
3+
# GPUFFT2D
4+
5+
<GPUPrimitivesDocsTabs active="fft2d" />
6+
7+
## Overview
8+
9+
`GPUFFT2D` records a bounded, out-of-place two-dimensional complex fast Fourier transform on
10+
WebGPU. It accepts caller-owned row-major storage buffers, owns one equally sized scratch buffer,
11+
and records every bit-reversal and radix-2 butterfly pass onto the application's
12+
`CommandEncoder`. It never submits commands or reads values back to the CPU.
13+
14+
The initial implementation targets reusable simulation and signal-processing foundations such as
15+
spectral oceans, frequency-domain filters, convolution, and procedural fields. It deliberately
16+
does not own textures, convert real-valued inputs, select padding dimensions, or hide command
17+
submission.
18+
19+
## Usage
20+
21+
Each complex value occupies two consecutive `float32` components: real followed by imaginary.
22+
Values are row-major, so the complete field contains `width * height * 2` floats.
23+
24+
```ts
25+
import {Buffer} from '@luma.gl/core';
26+
import {GPUFFT2D} from '@luma.gl/experimental';
27+
28+
const width = 256;
29+
const height = 256;
30+
const complexByteLength = width * height * 2 * Float32Array.BYTES_PER_ELEMENT;
31+
32+
const inputBuffer = device.createBuffer({
33+
data: initialComplexValues,
34+
usage: Buffer.STORAGE | Buffer.COPY_DST
35+
});
36+
const frequencyBuffer = device.createBuffer({
37+
byteLength: complexByteLength,
38+
usage: Buffer.STORAGE
39+
});
40+
const reconstructedBuffer = device.createBuffer({
41+
byteLength: complexByteLength,
42+
usage: Buffer.STORAGE
43+
});
44+
const transform = new GPUFFT2D(device, {width, height});
45+
46+
const commandEncoder = device.createCommandEncoder({id: 'spectral-step'});
47+
transform.encode(commandEncoder, {
48+
inputBuffer,
49+
outputBuffer: frequencyBuffer,
50+
direction: 'forward'
51+
});
52+
transform.encode(commandEncoder, {
53+
inputBuffer: frequencyBuffer,
54+
outputBuffer: reconstructedBuffer,
55+
direction: 'inverse'
56+
});
57+
device.submit(commandEncoder.finish());
58+
```
59+
60+
The two calls above compose in one command buffer. The second transform observes the first
61+
transform's output through ordinary WebGPU command ordering; no intermediate submission or CPU
62+
synchronization is required.
63+
64+
## Constructor
65+
66+
### `new GPUFFT2D(device, props)`
67+
68+
```ts
69+
type GPUFFT2DProps = {
70+
id?: string;
71+
width: number;
72+
height: number;
73+
};
74+
```
75+
76+
`width` and `height` must each be powers of two from 2 through 2048. Rectangular transforms are
77+
supported. The bound keeps allocation and dispatch costs predictable: the maximum field contains
78+
4,194,304 complex values and occupies 32 MiB per complex buffer.
79+
80+
Construction allocates one field-sized scratch buffer, one compute pipeline, and two immutable
81+
32-byte parameter buffers per pass so forward and inverse encodings never race through rewritten
82+
uniforms. Input and output storage remain caller-owned.
83+
84+
## Encoding
85+
86+
### `encode(commandEncoder, options): Buffer`
87+
88+
```ts
89+
type GPUFFT2DEncodeOptions = {
90+
inputBuffer: Buffer;
91+
outputBuffer: Buffer;
92+
direction?: 'forward' | 'inverse';
93+
};
94+
```
95+
96+
Both buffers must belong to the transform's device, declare `Buffer.STORAGE`, and contain at least
97+
`stats.complexBufferByteLength` bytes. They must be separate allocations; the source is never
98+
modified. `encode()` returns `outputBuffer` for convenient downstream binding.
99+
100+
The normalization convention is:
101+
102+
- `forward`: negative complex exponent and no normalization;
103+
- `inverse`: positive complex exponent and division by `width * height` on the final pass.
104+
105+
The transform first bit-reverses and evaluates every row, then does the same for every column.
106+
Passes ping-pong between the class-owned scratch field and the caller's output so the final pass
107+
always lands in `outputBuffer`.
108+
109+
## Support query
110+
111+
### `getGPUFFT2DSupport(device, props): GPUFFT2DSupport`
112+
113+
The support query validates dimensions before allocation and reports WebGPU compute, workgroup,
114+
dispatch, storage-binding, and buffer-size limits. A valid plan is included in `stats` even when a
115+
device limit prevents execution.
116+
117+
```ts
118+
const support = getGPUFFT2DSupport(device, {width: 512, height: 256});
119+
if (!support.supported) {
120+
console.warn(support.reason);
121+
}
122+
```
123+
124+
## Statistics
125+
126+
`transform.stats` is an immutable `GPUFFT2DStats` object:
127+
128+
| Field | Meaning |
129+
| --- | --- |
130+
| `width`, `height`, `elementCount` | Logical complex-field dimensions and value count. |
131+
| `complexBufferByteLength` | Minimum byte length of input, output, and scratch fields. |
132+
| `horizontalStageCount`, `verticalStageCount` | Radix-2 butterfly stages per axis. |
133+
| `passCount`, `dispatchCountPerEncode` | Two bit-reversal passes plus all butterfly stages. |
134+
| `workgroupSize`, `workgroupCount` | Fixed 8-by-8 invocation tile and dispatch grid. |
135+
| `scratchBufferByteLength` | Class-owned transform scratch. |
136+
| `parameterBufferCount`, `parameterBufferByteLength` | Immutable forward/inverse pass metadata. |
137+
138+
`makeGPUFFT2DStats(width, height)` computes the same plan without a device or GPU allocation.
139+
140+
## Ownership and lifecycle
141+
142+
`GPUFFT2D` owns only its compute pipeline, scratch buffer, and parameter buffers. The caller owns
143+
input buffers, output buffers, command encoders, submission, and any optional readback. Destroying
144+
the transform releases only class-owned resources and is idempotent. Previously supplied caller
145+
buffers remain valid.
146+
147+
The same instance may encode more than one ordered transform into a command encoder. Do not encode
148+
the same instance concurrently into command buffers that may execute simultaneously because those
149+
encodings share its scratch field. Use separate instances when independent queues or overlapping
150+
submissions need the same dimensions.
151+
152+
## Current limits
153+
154+
- WebGPU only; no WebGL fallback.
155+
- Power-of-two dimensions from 2 through 2048.
156+
- Packed row-major complex `float32` fields only.
157+
- Out-of-place input and output only.
158+
- One shared scratch field per instance; no concurrent execution contract.
159+
- No hidden padding, real-to-complex packing, texture conversion, submission, or readback.
160+
161+
These constraints keep the primitive small and predictable while allowing higher-level systems to
162+
define spectrum generation, texture outputs, cascade policy, and scheduling separately.

docs/table-of-contents.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@
220220
"api-reference/experimental/gpu-primitives/gpu-graph-traversal",
221221
"api-reference/experimental/gpu-primitives/gpu-ancestor-projection",
222222
"api-reference/experimental/gpu-primitives/gpu-sort",
223+
"api-reference/experimental/gpu-primitives/gpu-fft2d",
223224
"api-reference/experimental/gpu-primitives/gpu-reduction",
224225
"api-reference/experimental/gpu-primitives/gpu-histogram",
225226
"api-reference/experimental/gpu-primitives/gpu-grid-binning",
@@ -315,6 +316,7 @@
315316
"api-reference/experimental/gpu-primitives/gpu-graph-traversal",
316317
"api-reference/experimental/gpu-primitives/gpu-ancestor-projection",
317318
"api-reference/experimental/gpu-primitives/gpu-sort",
319+
"api-reference/experimental/gpu-primitives/gpu-fft2d",
318320
"api-reference/experimental/gpu-primitives/gpu-reduction",
319321
"api-reference/experimental/gpu-primitives/gpu-histogram",
320322
"api-reference/experimental/gpu-primitives/gpu-grid-binning",

docs/whats-new.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Target Release Date: Q3, 2026
3434
- **GPU scan, compaction, and indirect drawing** - Typed graph views compose hierarchical `uint32` scan, stable ID compaction, and GPU-written `DrawCommandBuffer` instance counts. Scan and compaction accept fixed-width `GPUVector` imports as one logical sequence while preserving chunk topology. The [GPU Trace Viewer](/examples/experimental/gpu-trace-viewer) demonstrates the path over up to four million spans, while [GPU Frustum Culling](/examples/experimental/gpu-frustum-culling) applies it to indexed indirect rendering of a 3D instance field.
3535
- **GPU trace manipulation primitives** - `GPUMask` composes chunk-preserving selection predicates; `GPUHierarchyLayout` computes scan-based process and thread expansion; `GPUGraphTraversal` expands bounded, cycle-safe CSR dependency frontiers; and `GPUAncestorProjection` reconnects hidden spans to their nearest visible canonical parent. The [GPU Hierarchical Trace Viewer](/examples/experimental/gpu-trace-viewer) applies all four to live hierarchy controls, topology filters, dependency focusing, GPU picking, projected indirect edges, and collapsed-process activity.
3636
- **Graph-native GPU sort** - `GPUSort` stably orders one paired packed `uint32` domain, while `GPUBatchSort` independently orders aligned GPU vector chunks without hidden packing or lost batch boundaries. Bitonic or binary LSD radix selection occurs per work unit. The [GPU Sort example](/examples/experimental/gpu-sort) contrasts packed global order with preserved Arrow batches and exposes graph compilation and transient reuse.
37+
- **Reusable 2D GPU FFT** - [`GPUFFT2D`](/docs/api-reference/experimental/gpu-primitives/gpu-fft2d) records bounded power-of-two complex transforms into caller-owned WebGPU command encoders. Forward and normalized inverse passes share one explicit scratch field without hidden submission or readback, providing a reusable spectral-simulation and signal-processing foundation.
3738
- **Graph-native GPU data analysis** - `GPUReduction`, `GPUHistogram`, `GPUGridBinning`, `GPUGridAggregation`, and `GPUGroupAggregation` add deterministic scalar aggregates, equal-width or irregular-edge histogram counts, filtered categorical counts and floating-point statistics, row-major spatial counts, and weighted floating-point sum/min/max/mean cell statistics. GPU-resident histogram edges and group-selection masks can change between encodings without CPU readback or graph recompilation. Analysis operations initialize once and accumulate fixed-width vector chunks without packing. The [GPU Data Analysis example](/examples/experimental/gpu-data-analysis) composes the operations without hidden submission or readback.
3839
- **Semantic G-buffer targets** - `GBuffer` owns WebGPU MRT scene color, normal-roughness, velocity, and depth targets plus named extra channels, then exposes the standard depth, normal, and velocity bindings consumed by screen-space effect pipelines.
3940
- **Composable deferred lighting** - `deferredLighting` resolves Cook-Torrance opaque lighting from G-buffer material channels, reconstructed depth, one directional light, and a fixed-capacity WebGPU point-light storage buffer. The [Deferred Illumination Lab](/examples/experimental/deferred-rendering) exposes the material channels and animated lights live.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// luma.gl
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) vis.gl contributors
4+
5+
/** Number of invocations along each dimension of one GPUFFT2D workgroup. */
6+
export const GPU_FFT2D_WORKGROUP_DIMENSION = 8;
7+
8+
/** Byte length of the uniform block consumed by every GPUFFT2D pass. */
9+
export const GPU_FFT2D_PARAMETER_BYTE_LENGTH = 32;
10+
11+
/** Shared bit-reversal and butterfly kernel used by every GPUFFT2D pass. */
12+
export const GPU_FFT2D_SHADER = /* wgsl */ `\
13+
struct GPUFFT2DParameters {
14+
width: u32,
15+
height: u32,
16+
axis: u32,
17+
passKind: u32,
18+
transformSize: u32,
19+
stage: u32,
20+
directionSign: f32,
21+
normalizationScale: f32,
22+
};
23+
24+
@group(0) @binding(0) var<storage, read> inputValues: array<vec2f>;
25+
@group(0) @binding(1) var<storage, read_write> outputValues: array<vec2f>;
26+
@group(0) @binding(2) var<uniform> parameters: GPUFFT2DParameters;
27+
28+
const GPU_FFT2D_PI: f32 = 3.14159265358979323846;
29+
30+
fn reverseLowBits(value: u32, bitCount: u32) -> u32 {
31+
var source = value;
32+
var reversed = 0u;
33+
for (var bitIndex = 0u; bitIndex < bitCount; bitIndex++) {
34+
reversed = (reversed << 1u) | (source & 1u);
35+
source = source >> 1u;
36+
}
37+
return reversed;
38+
}
39+
40+
fn getLinearIndex(xCoordinate: u32, yCoordinate: u32) -> u32 {
41+
return yCoordinate * parameters.width + xCoordinate;
42+
}
43+
44+
fn multiplyComplex(left: vec2f, right: vec2f) -> vec2f {
45+
return vec2f(
46+
left.x * right.x - left.y * right.y,
47+
left.x * right.y + left.y * right.x
48+
);
49+
}
50+
51+
@compute @workgroup_size(${GPU_FFT2D_WORKGROUP_DIMENSION}, ${GPU_FFT2D_WORKGROUP_DIMENSION}, 1)
52+
fn main(@builtin(global_invocation_id) globalIdentifier: vec3u) {
53+
if (globalIdentifier.x >= parameters.width || globalIdentifier.y >= parameters.height) {
54+
return;
55+
}
56+
57+
let horizontal = parameters.axis == 0u;
58+
let coordinate = select(globalIdentifier.y, globalIdentifier.x, horizontal);
59+
var sourceCoordinate = coordinate;
60+
61+
if (parameters.passKind == 0u) {
62+
sourceCoordinate = reverseLowBits(coordinate, parameters.stage);
63+
} else {
64+
let butterflySpan = 1u << parameters.stage;
65+
let butterflyHalfSpan = butterflySpan >> 1u;
66+
let butterflyOffset = coordinate & (butterflySpan - 1u);
67+
let twiddleIndex = butterflyOffset & (butterflyHalfSpan - 1u);
68+
let butterflyStart = coordinate - butterflyOffset;
69+
let firstCoordinate = butterflyStart + twiddleIndex;
70+
let secondCoordinate = firstCoordinate + butterflyHalfSpan;
71+
72+
let firstX = select(globalIdentifier.x, firstCoordinate, horizontal);
73+
let firstY = select(firstCoordinate, globalIdentifier.y, horizontal);
74+
let secondX = select(globalIdentifier.x, secondCoordinate, horizontal);
75+
let secondY = select(secondCoordinate, globalIdentifier.y, horizontal);
76+
let firstValue = inputValues[getLinearIndex(firstX, firstY)];
77+
let secondValue = inputValues[getLinearIndex(secondX, secondY)];
78+
let angle = parameters.directionSign * 2.0 * GPU_FFT2D_PI *
79+
f32(twiddleIndex) / f32(butterflySpan);
80+
let twiddle = vec2f(cos(angle), sin(angle));
81+
let rotatedSecondValue = multiplyComplex(secondValue, twiddle);
82+
let butterflyValue = select(
83+
firstValue + rotatedSecondValue,
84+
firstValue - rotatedSecondValue,
85+
butterflyOffset >= butterflyHalfSpan
86+
);
87+
outputValues[getLinearIndex(globalIdentifier.x, globalIdentifier.y)] =
88+
butterflyValue * parameters.normalizationScale;
89+
return;
90+
}
91+
92+
let sourceX = select(globalIdentifier.x, sourceCoordinate, horizontal);
93+
let sourceY = select(sourceCoordinate, globalIdentifier.y, horizontal);
94+
outputValues[getLinearIndex(globalIdentifier.x, globalIdentifier.y)] =
95+
inputValues[getLinearIndex(sourceX, sourceY)] * parameters.normalizationScale;
96+
}
97+
`;

0 commit comments

Comments
 (0)