Skip to content

All experiments

Particles on WebGPU

WebGPU · compute shader

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);
Get a quote