Cząstki na WebGPU
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);