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 — 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);