Particles on WebGPU
The particle buffer lives on the graphics card between frames. The CPU never touches it.
A particle field computed on the graphics card. The motion settles into slow vortices and the pointer bends them.
This browser has no WebGL. The frame and the code below stay.
Lead the pointer, or one finger, across the frame. The field follows it. To push it apart hold the mouse button, or on a touchscreen add a second finger beside the first: a quick touch throws, a held one keeps pushing.
This browser does not expose WebGPU. The demo runs on WebGL2.
The address forces the pool — the switch is off.
What's hard here
A million positions will not fit in a JavaScript loop. A compute shader runs them, one thread per particle, and the buffer stays in graphics memory between frames.
Velocity comes from the curl of a noise volume, so particles circulate instead of drifting off the frame. The pointer only bends that flow.
The same TSL code compiles to WGSL on WebGPU and to transform feedback on WebGL2. The switch swaps the backend. The shader stays put.
Why the rest of the site runs on WebGL2
The rest of this site renders through WebGL2 and will stay there. Safari shipped WebGPU in macOS 26 and iOS 26.4; Firefox exposes it on Windows and Apple Silicon. A slice of every audience still lands on the fallback, and I would be keeping two paths alive. Three.js still marks WebGPURenderer experimental: it takes neither ShaderMaterial nor the old EffectComposer, and TSL moves its API between releases. Here I pay that price on purpose, because the experiment is about the ceiling. On a site a client depends on, I pick the thing that runs everywhere.
How it's built
/** Rotacja pola szumu: różnice skończone na trzech osiach. */
function curlOfNoise(p: THREE.Node<"vec3">) {
const e = 0.14;
const xa = mx_noise_vec3(p.sub(vec3(e, 0, 0)));
const xb = mx_noise_vec3(p.add(vec3(e, 0, 0)));
const ya = mx_noise_vec3(p.sub(vec3(0, e, 0)));
const yb = mx_noise_vec3(p.add(vec3(0, e, 0)));
const za = mx_noise_vec3(p.sub(vec3(0, 0, e)));
const zb = mx_noise_vec3(p.add(vec3(0, 0, e)));
return vec3(
yb.z.sub(ya.z).sub(zb.y.sub(za.y)),
zb.x.sub(za.x).sub(xb.z.sub(xa.z)),
xb.y.sub(xa.y).sub(yb.x.sub(ya.x)),
).div(2 * e);
}
/** Miejsce startu cząstki: punkt w kuli, rozłożony równomiernie po objętości
(stąd pierwiastek sześcienny z trzeciej liczby, inaczej środek byłby
gęstszy). Ta sama funkcja daje cząstce nowe miejsce, gdy wypłynie za
krawędź, więc pole samo się odnawia i nie potrzebuje licznika życia. */
function seedInBall(seed: THREE.Node<"uint">) {
const angle = hash(seed).mul(Math.PI * 2);
const height = hash(seed.add(1)).mul(2).sub(1);
const radius = hash(seed.add(2)).cbrt().mul(SEED_RADIUS);
const ring = height.mul(height).oneMinus().sqrt();
return vec3(angle.cos().mul(ring), angle.sin().mul(ring), height).mul(radius);
}
const update = Fn(() => {
const particle = particles.element(instanceIndex);
const velocity = velocities.element(instanceIndex);
const position = particle.xyz;
// pole prędkości z rotacji szumu, przesuwane w czasie
const sample = position
.mul(NOISE_SCALE)
.add(vec3(0, 0, elapsed.mul(FLOW_SPEED)));
velocity.addAssign(curlOfNoise(sample).mul(FIELD_FORCE).mul(dt));
// kursor: przyciąga, a wciśnięty rozgarnia (znak niesie pointerForce)
const toPointer = pointer.sub(position);
const reach = toPointer.lengthSq().add(POINTER_SOFT);
velocity.addAssign(
toPointer.normalize().mul(pointerForce).div(reach).mul(dt),
);
// sprężyna trzymająca chmurę w kadrze: im dalej za promieniem, tym
// mocniej ciągnie do środka, więc krawędź wychodzi miękka
const outside = position.length().sub(HOLD_RADIUS).max(0);
velocity.subAssign(
position.normalize().mul(outside).mul(HOLD_FORCE).mul(dt),
);
// tłumienie niezależne od długości klatki
velocity.mulAssign(dt.div(-DAMP_TAU).exp());
// bezpiecznik: cząstka wyrzucona kursorem poza kadr wraca na start
const moved = position.add(velocity.mul(dt));
const escaped = moved.length().greaterThan(KILL_RADIUS);
velocity.mulAssign(select(escaped, 0, 1));
particle.assign(
vec4(
select(escaped, seedInBall(instanceIndex.mul(3)), moved),
velocity.length().div(HOT_SPEED).saturate(),
),
);
})().compute(maxPool);