Przejdź do treści

Wszystkie eksperymenty

Cząstki na WebGPU

WebGPU · compute shader

Bufor pozycji cząstek żyje na karcie graficznej między klatkami. CPU po niego nie sięga.

Pole cząstek liczone na karcie graficznej. Ruch układa się w powolne wiry, a kursor je zakrzywia.

Ta przeglądarka nie daje WebGL-a. Zostaje kadr i kod pod spodem.

Prowadź po kadrze kursorem albo jednym palcem. Pole ciągnie za nim. Rozgarnia je wciśnięty przycisk myszy, a na ekranie dotykowym drugi palec dołożony obok pierwszego: krótko dotknięty wyrzuca, trzymany odsuwa bez przerwy.

Ta przeglądarka nie wystawia WebGPU. Demo jedzie na WebGL2.

Pulę narzuca adres strony — przełącznik stoi.

Co tu jest trudne

Milion pozycji nie zmieści się w pętli JavaScriptu. Liczy je compute shader: jeden wątek na cząstkę, a bufor zostaje w pamięci karty między klatkami.

Prędkość bierze się z rotacji pola szumu. Dzięki temu cząstki krążą, zamiast rozpłynąć się po kadrze, a kursor tylko zakrzywia ten przepływ.

Ten sam zapis TSL kompiluje się do WGSL na WebGPU i do transform feedback na WebGL2. Przełącznik wymienia backend. Shader zostaje ten sam.

Dlaczego reszta strony jedzie na WebGL2

Reszta strony renderuje przez WebGL2 i przy nim zostanie. Safari dostało WebGPU dopiero w macOS 26 i iOS 26.4, Firefox wystawia je na Windowsie i Apple Silicon. Część gości i tak zobaczyłaby ścieżkę zapasową, a ja utrzymywałbym dwie. WebGPURenderer w three.js jest wciąż oznaczony jako eksperymentalny: nie przyjmuje ShaderMaterial ani starego EffectComposera, a TSL zmienia API między wydaniami. Tutaj płacę tę cenę świadomie, bo eksperyment ma pokazać granicę. Na stronie, która ma działać u klienta, wybieram to, co działa wszędzie.

Jak to jest zrobione

/** 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);
Wyceń projekt