42
←→

How to build a flow field shader in Three.js with TSL

A flow field particle system in Three.js, step by step: the vector field, a GPU storage buffer, a compute shader that moves millions of particles, and sprite rendering with TSL on WebGPU.

Loading...
←Fragments is one year oldPost-processing in React Three Fiber with TSL and WebGPU→
←→

Ready to start learning?

What's included in the course ↓

Access to the course: master shader techniques, use workflow-enhancing utilities, learn from shader breakdowns with full code, and get downloadable R3F and vanilla projects.

One single payment. No subscription required. 30-day money-back guarantee. No questions asked.

Not sure which to pick? Most people choose Pro for the full collection and all future updates. Fundamentals is a focused, lower-cost starting point you can upgrade from any time.

“Knowing shaders is your unreasonable advantage in UI. Ben has worked super hard on this course, recommended!”
Joshua Crowley - Designer and Educator
Loading...

Fragments

Learn creative coding with shaders. For design engineers, creative coders and shader artists: techniques, tools, deep dives. Powered by ThreeJS and TSL.

New techniques, breakdowns and shader experiments — straight to your inbox.

2026 Phobon

phobon.ioartifice.shshadercraft

Pages

HomeTechniquesUtilitiesBreakdownsWorksWriting

Contact

X @thenoumenonhey@fragments.supplyOKAY DEV @phobon
All rights reserved.
1st anniversary sale: 33% off until Sept 17
Curriculum
Works171
Writing45
1st anniversary sale: 33% off until Sept 17

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 of columns × rows × depth angles. 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 sprite material whose positionNode reads 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:

Grid buffer and cell decode
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:

A spiral field
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:

Fill the grid
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:

Particle buffers
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.001

Why 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:

Init compute pass
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:

Update compute pass
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:

Sprite rendering
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.

ProMost popularFull access to the entire Fragments collection. Includes all techniques, utilities, breakdowns and all future updates
  • ✓ 12 long-form technique lessons
  • ✓ 7 fundamentals lessons
  • ✓ 60 workflow enhancing utilities
  • ✓ 171+ full shader breakdowns
  • ✓ Downloadable R3F and Vanilla projects
  • ✓ Access to community Discord
Get Pro →$199$133USD
FundamentalsAccess to foundational shader techniques and utilities
  • ✓ 5 foundational long-form technique lessons
  • ✓ 7 fundamentals lessons
  • ✓ 24 foundational workflow enhancing utilities
  • ✓ 71 full shader breakdowns
  • ✓ Downloadable R3F and Vanilla projects
  • ✓ Access to community Discord
Get Fundamentals →$99$66USD
You'll be redirected to our secured payment platform and get instant access.