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, więc 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