Why a flow field is the best first compute shader
If you've seen those smoky, drifting particle pieces on the web and wondered how they're done - most of them are a flow field. A flow field (or vector field) assigns a direction to every point in space. You drop a lot of particles in, each one reads the direction at its position, takes a step, and repeats. If that sounds simple, it's because it kind of is! The organic motion, the trails, the sense of wind or current - all of it happens in a tiny loop.
I think it's also the best possible excuse to write your first compute shader. In Three.js with WebGPU and TSL, moving 130,000 particles through a 3D field is a few dozen lines of code, and it runs at 120fps on a laptop. I've built a lot of these - Formation was my first proper series - and the technique keeps giving.
This post describes the entire process, using the same architecture as the Flow Field technique in Fragments: a grid of angles computed once on the GPU, a particle buffer that samples it, and sprites to draw it. The technique goes much deeper - a reusable component, a library of field functions, mesh-instance particles - but you'll have something moving on screen by the end of this.
What we're building
Three GPU-side pieces, each one a compute shader or a material:
- A
grid: a storage buffer ofcolumns × rows × depthangles. One compute pass fills it from a field function Particles: a buffer of positions (plus lifespan), scattered into a ball. A per-frame compute pass reads the grid cell under each particle and steps it forward- A
spritematerial whosepositionNodereads straight from the particle buffer
The grid is the part people skip when they first try this, and it's the part that makes the whole thing reusable. Swap the field function, keep everything else.
Everything below uses three/webgpu and three/tsl. If you're on React Three Fiber, the WebGPU Scene utility is the wrapper I use - or grab the boilerplate and skip the setup entirely. For a plain Vite project, this walkthrough gets you a renderer and a camera in ten minutes.
The grid
We store the field as a flat float buffer. Cell (x, y, z) lives at index z·columns·rows + y·columns + x, and a compute shader decodes its own instanceIndex back into a cell position:
import { Fn, float, instanceIndex, storage, vec3 } from 'three/tsl'
import { StorageInstancedBufferAttribute } from 'three/webgpu'
const COLUMNS = 128
const ROWS = 128
const DEPTH = 32
const GRID_COUNT = COLUMNS * ROWS * DEPTH // 524,288 cells
const flowField = storage(new StorageInstancedBufferAttribute(GRID_COUNT, 1), 'float', GRID_COUNT)
// Which cell is this thread? x = i % cols, y = floor(i / cols) % rows, z = floor(i / (cols * rows))
const decodeGridPosition = () => {
const idx = float(instanceIndex)
const x = idx.mod(COLUMNS).floor()
const y = idx.div(COLUMNS).floor().mod(ROWS)
const z = idx.div(COLUMNS * ROWS).floor()
return vec3(x, y, z)
}COLUMNS/ROWS/DEPTH are plain JavaScript numbers on purpose. TSL will happily turn them into nodes if you pass them into a Fn as arguments, and then COLUMNS * ROWS becomes node arithmetic you didn't ask for. Keep them as module constants and do the multiplication in JS.
The field function
A field function returns the angle for the current cell. Trigonometry is the classic starting point because it's smooth and periodic - you get swirls really easily:
import { cos, sin, time } from 'three/tsl'
const spiral = Fn(() => {
const zoom = 0.015
const curve = -3
const t = time.mul(0.15) // drop this for a static field
const p = decodeGridPosition()
const c = cos(p.x.mul(zoom).add(t))
const s = sin(p.y.mul(zoom).add(t))
const d = sin(p.z.mul(zoom).add(t)) // z term → genuinely volumetric
return c.add(s).add(d).mul(curve)
})Then a compute pass that writes each cell's angle into the buffer:
const fillGrid = Fn(() => {
flowField.element(instanceIndex).assign(spiral())
})().compute(GRID_COUNT)
await renderer.computeAsync(fillGrid)Run this once for a static field. If your field reads time like the spiral above, run it every frame instead so the flow evolves. Swap spiral for a Perlin or simplex noise sampled at p and you get the cellular, curl-like fields behind the ethereal flow field. Point every cell at a centre with atan(toCentre.y, toCentre.x) and you get an attractor. You could say that the function is the personality of the piece, and it's where your experimentation time should go.
The particles
Each particle needs a position, and it's convenient to pack its lifespan into the fourth component. We also keep a copy of the spawn position so a dead particle can respawn where it started, plus a velocity for colouring later:
import { vec4 } from 'three/tsl'
const COUNT = 2 ** 17 // 131,072 particles
const position = storage(new StorageInstancedBufferAttribute(COUNT, 4), 'vec4', COUNT) // xyz + lifespan in w
const basePosition = storage(new StorageInstancedBufferAttribute(COUNT, 4), 'vec4', COUNT)
const velocity = storage(new StorageInstancedBufferAttribute(COUNT, 4), 'vec4', COUNT) // xyz used, w padding
const PARTICLE_SPEED = 0.02
const PARTICLE_LIFESPAN = 1
const PARTICLE_DECAY = 0.001Why vec4 for velocity when we only need three components? Storage buffers pad vec3 to 16 bytes, and reading one back as a vertex attribute misaligns every instance. vec4 sidesteps it. This one cost me an afternoon.
Seeding the ball
Random scatter looks like static. A low-discrepancy sequence gives an even, ungridded spread, and biasing the radius toward the centre gives you a dense core that sprays into streams as the field pulls it apart:
import { hash, If, int, Loop } from 'three/tsl'
// Halton sequence: even spacing without a visible grid
const halton = Fn(([index, base]) => {
const result = float(0).toVar()
const f = float(1).toVar()
const i = float(index).toVar()
Loop({ start: int(0), end: int(10), type: 'int', condition: '<' }, () => {
f.assign(f.div(base))
result.addAssign(f.mul(i.mod(base)))
i.assign(i.div(base).floor())
})
return result
})
const init = Fn(() => {
const id = instanceIndex
const dir = vec3(halton(id, 2).sub(0.5), halton(id, 3).sub(0.5), halton(id, 5).sub(0.5)).add(1e-4)
const radius = hash(id.add(5)).pow(1.6).mul(1.1) // pow > 1/3 packs mass toward the centre
const p = vec4(dir.normalize().mul(radius), hash(id.mul(3)).mul(PARTICLE_LIFESPAN)) // staggered lifespans
position.element(id).assign(p)
basePosition.element(id).assign(p)
velocity.element(id).assign(vec4(0))
})().compute(COUNT)
await renderer.computeAsync(init)Stepping through the field
This is the heart of it. Map the particle's [-1, 1] position into grid space, find its cell, read the angle, take a step. When the lifespan runs out, respawn at the base position:
import { atan, clamp, cos, sin } from 'three/tsl'
const update = Fn(() => {
const id = instanceIndex
const pos = position.element(id).xyz
const life = position.element(id).w
If(life.greaterThan(0), () => {
// [-1, 1] → [0, 1] → cell index (inverse of decodeGridPosition)
const norm = pos.add(1).div(2)
const ix = clamp(norm.x.mul(COLUMNS).floor(), 0, COLUMNS - 1)
const iy = clamp(norm.y.mul(ROWS).floor(), 0, ROWS - 1)
const iz = clamp(norm.z.mul(DEPTH).floor(), 0, DEPTH - 1)
const cell = int(iz.mul(COLUMNS * ROWS).add(iy.mul(COLUMNS)).add(ix))
const angle = flowField.element(cell)
// Speed fades with remaining life; a scalar angle still gives 3D motion
const speed = float(PARTICLE_SPEED).mul(life)
const step = vec3(cos(angle).mul(speed), sin(angle).mul(speed), atan(angle).mul(speed))
pos.addAssign(step)
life.subAssign(PARTICLE_DECAY)
velocity.element(id).assign(vec4(step, 0))
}).Else(() => {
const base = basePosition.element(id)
pos.assign(base.xyz)
life.assign(base.w)
velocity.element(id).assign(vec4(0))
})
})().compute(COUNT)
// In the render loop, before renderer.render():
renderer.compute(update)Two things worth noticing. There's no for loop over particles - the GPU runs this body once per particle, in parallel. And If/Else are TSL nodes, not JavaScript - a JS if would run once at graph-build time, not once per particle.
Drawing the particles as sprites
Point a SpriteNodeMaterial at the buffers with .toAttribute() and render one instance per particle. Colour by speed so fresh and dying particles sit cool and the fast streams glow:
import { InstancedMesh, PlaneGeometry, SpriteNodeMaterial, AdditiveBlending } from 'three/webgpu'
import { mix, smoothstep, uv, vec2 } from 'three/tsl'
const positionAttr = position.toAttribute()
const velocityAttr = velocity.toAttribute()
const material = new SpriteNodeMaterial({ transparent: true, depthWrite: false, blending: AdditiveBlending })
material.positionNode = positionAttr.xyz // w is lifespan, not a coordinate
material.scaleNode = vec2(0.018)
// Slow → fast heat ramp: deep blue → magenta → amber
const speed = velocityAttr.xyz.length().mul(60).clamp(0, 1)
const lower = mix(vec3(0.05, 0.12, 0.55), vec3(0.75, 0.15, 0.55), speed.mul(2).clamp(0, 1))
material.colorNode = mix(lower, vec3(1.0, 0.85, 0.35), speed.sub(0.5).mul(2).clamp(0, 1))
// Soft round dot, and fade out particles that are barely moving
const dot = smoothstep(0.5, 0.0, uv().sub(0.5).length())
material.opacityNode = dot.mul(velocityAttr.xyz.length().mul(90).clamp(0, 1)).mul(0.3)
const particles = new InstancedMesh(new PlaneGeometry(1, 1), material, COUNT)
scene.add(particles).toAttribute() is the bridge between compute and render - the same buffer the update pass writes is the one each sprite instance reads. No copies, no textures, no readback.
Where to take it
Once the loop is running, the field function is where the fun is. A few directions I've enjoyed over the years:
- Noise fields -
perlinNoise3d(p.mul(0.5)).mul(2)instead of trig gives cellular, organic structure. The Noise technique covers the functions - Attractors - a handful of points that pull or push; layer two with different strengths and the streams braid
- Colour by direction -
atan(v.y, v.x)as a hue instead of speed as brightness; a cosine palette makes this trivial - Feedback - render to a texture, sample the previous frame at slightly reduced opacity, and the field smears into smoke. This is the single biggest step up in feel
- Mesh instances instead of sprites - lit, oriented boxes that point along their velocity and stretch with speed. Same buffers, different material. Covered in the full technique
The bit I'd stress
The compute shader is the part that used to be hard on the web, and it isn't anymore. If you've been putting off WebGPU because it felt like a rewrite, a flow field is the gentlest possible way in - two buffers, two compute passes, one material. Everything else is taste.
The Flow Field technique has the complete component with props for the field function, grid size and particle count, the full set of field functions, and the mesh-mode renderer. The Noise technique is the natural next stop for making the field itself more interesting.