Particelle GPGPU con TSL & WebGPU
In questa lezione, creeremo centinaia di migliaia di particelle fluttuanti per rendere modelli 3D e testo 3D utilizzando Three Shading Language (TSL) e WebGPU.
Invece di usare facce, utilizziamo tons di particelle, permettendoci di effettuare transizioni fluide tra diversi modelli.
Un modello 3D di una volpe, un libro e testo 3D reso con particelle GPGPU! 🚀
Sistema di Particelle GPGPU
Prima di immergerci nel codice, prendiamoci un momento per capire cos'è GPGPU e come può essere usato in Three.js.
Cos'è GPGPU?
GPGPU (General-Purpose computing on Graphics Processing Units) è una tecnica che sfrutta la potenza di elaborazione parallela delle GPU per eseguire calcoli tipicamente gestiti dalla CPU.
In Three.js, GPGPU è spesso utilizzato per simulazioni in tempo reale, sistemi di particelle e fisica memorizzando e aggiornando dati in texture anziché affidarsi a calcoli a carico della CPU.
Questa tecnica consente agli shader di avere funzionalità di memoria e calcolo, consentendo loro di eseguire calcoli complessi e memorizzare i risultati in texture senza la necessità di intervento della CPU.
Ciò consente calcoli su larga scala, altamente efficienti, direttamente sulla GPU.
Grazie a TSL, il processo per creare simulazioni GPGPU è molto più semplice e intuitivo. Con nodi di storage e buffer combinati a funzioni di compute, possiamo creare simulazioni complesse con codice minimo.
Ecco alcune idee di progetti per cui GPGPU può essere utilizzato:
- Sistemi di particelle
- Simulazioni di fluidi
- Simulazioni fisiche
- Simulazioni di boid
- Elaborazione delle immagini
È tempo di passare dalla teoria alla pratica! Creiamo un sistema di particelle GPGPU usando TSL e WebGPU.
Sistema di particelle
Il pacchetto di avvio è un template WebGPU ready basato sull'implementazione della lezione WebGPU/TSL.
Sostituiamo il pink mesh con un nuovo componente chiamato GPGPUParticles
. Crea un nuovo file denominato GPGPUParticles.jsx
nella cartella src/components
e aggiungi il seguente codice:
import { extend } from "@react-three/fiber"; import { useMemo } from "react"; import { color, uniform } from "three/tsl"; import { AdditiveBlending, SpriteNodeMaterial } from "three/webgpu"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // uniforms const uniforms = { color: uniform(color("white")), }; return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); return ( <> <sprite count={nbParticles}> <spriteNodeMaterial {...nodes} transparent depthWrite={false} blending={AdditiveBlending} /> </sprite> </> ); }; extend({ SpriteNodeMaterial });
Niente di nuovo qui, stiamo creando un componente GPGPUParticles
che utilizza uno Sprite con un SpriteNodeMaterial
per renderizzare le particelle.
Il vantaggio dell'utilizzo di uno Sprite
rispetto ad un InstancedMesh
è che è più leggero e viene fornito con un effetto billboard di default.
Aggiungiamo il componente GPGPUParticles
al componente Experience
:
import { OrbitControls } from "@react-three/drei"; import { GPGPUParticles } from "./GPGPUParticles"; export const Experience = () => { return ( <> {/* <Environment preset="warehouse" /> */} <OrbitControls /> <GPGPUParticles /> {/* <mesh> <boxGeometry /> <meshStandardMaterial color="hotpink" /> </mesh> */} </> ); };
Possiamo eliminare i componenti mesh e environment.
Possiamo vedere un quadrato al centro dello schermo, queste sono le particelle white sprite. Tutte nella stessa posizione.
È ora di impostare il nostro sistema di particelle!
Buffer / Storage / Instanced Array
Per la nostra simulazione GPGPU, abbiamo bisogno che le particelle memorizzino la loro position, velocity, age e color senza usare la CPU.
Alcune cose non richiederanno di memorizzare i dati. Possiamo calcolare il color basandoci sull'age combinato con gli uniforms. E possiamo generare la velocity casualmente utilizzando un valore seed fisso.
Ma per la position, poiché la posizione di destinazione può evolvere, dobbiamo memorizzarla in un buffer. Lo stesso vale per l'age, vogliamo gestire il ciclo di vita delle particelle nella GPU.
Per memorizzare i dati nella GPU, possiamo utilizzare lo storage node. Ci consente di memorizzare grandi quantità di dati strutturati che possono essere aggiornati sulla GPU.
Per utilizzarlo con un minimo di codice, useremo la funzione TSL InstancedArray che si basa sullo storage node.
Questa parte dei Three.js nodes non è ancora documentata, è immergendosi negli esempi e nel codice sorgente che possiamo capire come funziona.
Prepariamo il nostro buffer nel useMemo
dove inseriamo i nostri shader nodes:
// ... import { instancedArray } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // uniforms const uniforms = { color: uniform(color("white")), }; // buffers const spawnPositionsBuffer = instancedArray(nbParticles, "vec3"); const offsetPositionsBuffer = instancedArray(nbParticles, "vec3"); const agesBuffer = instancedArray(nbParticles, "float"); return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); // ... }; // ...
instancedArray
è una funzione TSL che crea un buffer della dimensione e tipo specificato.
Lo stesso codice utilizzando lo storage node apparirebbe così:
import { storage } from "three/tsl"; import { StorageInstancedBufferAttribute } from "three/webgpu"; const spawnPositionsBuffer = storage( new StorageInstancedBufferAttribute(nbParticles, 3), "vec3", nbParticles );
Con questi buffer, possiamo memorizzare la position e l'age di ciascuna particella e aggiornarli nella GPU.
Per accedere ai dati nei buffer, possiamo usare .element(index)
per ottenere il valore all'indice specificato.
Nel nostro caso, useremo l'instancedIndex
di ciascuna particella per accedere ai dati nei buffer:
// ... import { instanceIndex } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // ... // buffers const spawnPositionsBuffer = instancedArray(nbParticles, "vec3"); const offsetPositionsBuffer = instancedArray(nbParticles, "vec3"); const agesBuffer = instancedArray(nbParticles, "float"); const spawnPosition = spawnPositionsBuffer.element(instanceIndex); const offsetPosition = offsetPositionsBuffer.element(instanceIndex); const age = agesBuffer.element(instanceIndex); return { uniforms, nodes: { colorNode: uniforms.color, }, }; }, []); // ... }; // ...
instanceIndex
è una funzione TSL incorporata che restituisce l'indice dell'istanza corrente in elaborazione.
Questo ci consente di accedere ai dati nei buffer per ciascuna particella.
Non ne avremo bisogno per questo progetto, ma potendo accedere ai dati di un'altra istanza, possiamo creare interazioni complesse tra particelle. Ad esempio, potremmo creare uno stormo di uccelli che si seguono.
Calcolo iniziale
Per impostare la posizione e l'età delle particelle, dobbiamo creare una funzione di calcolo che verrà eseguita sulla GPU all'inizio della simulazione.
Per creare una funzione di calcolo con TSL, dobbiamo utilizzare il nodo Fn
, chiamarlo e utilizzare il metodo compute
che restituisce con il numero di particelle:
// ... import { Fn } from "three/src/nodes/TSL.js"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const { nodes, uniforms } = useMemo(() => { // ... const spawnPosition = spawnPositionsBuffer.element(instanceIndex); const offsetPosition = offsetPositionsBuffer.element(instanceIndex); const age = agesBuffer.element(instanceIndex); // init Fn const lifetime = randValue({ min: 0.1, max: 6, seed: 13 }); const computeInit = Fn(() => { spawnPosition.assign( vec3( randValue({ min: -3, max: 3, seed: 0 }), randValue({ min: -3, max: 3, seed: 1 }), randValue({ min: -3, max: 3, seed: 2 }) ) ); offsetPosition.assign(0); age.assign(randValue({ min: 0, max: lifetime, seed: 11 })); })().compute(nbParticles); // ... }, []); // ... }; // ...
Creiamo una funzione computeInit
che assegna ai buffer valori casuali.
La funzione randValue
non esiste, dobbiamo crearla noi stessi.
Le funzioni a nostra disposizione sono:
hash(seed)
: Per generare un valore casuale basato su un seed tra 0 e 1.range(min, max)
: Per generare un valore casuale tra min e max.
Maggiori informazioni sul Three.js Shading Language Wiki.
Ma la funzione range
definisce un attributo e memorizza il suo valore. Non è quello che vogliamo.
Creiamo una funzione randValue
che restituisca un valore casuale tra min e max basato su un seed:
import { hash } from "three/tsl"; const randValue = /*#__PURE__*/ Fn(({ min, max, seed = 42 }) => { return hash(instanceIndex.add(seed)).mul(max.sub(min)).add(min); }); export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... }; // ...
La funzione randValue
prende un valore min
, max
e seed
e restituisce un valore casuale tra min e max basato sul seed.
/*#__PURE__*/
è un commento utilizzato per l'eliminazione del codice non utilizzato (tree-shaking). Indica al bundler di rimuovere la funzione se non viene utilizzata. Maggiori dettagli qui.
Ora dobbiamo chiamare la nostra funzione computeInit
. Questo è compito del renderer. Importiamolo con useThree
e chiamiamolo subito dopo la sua dichiarazione:
// ... import { useThree } from "@react-three/fiber"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { const gl = useThree((state) => state.gl); const { nodes, uniforms } = useMemo(() => { // ... const computeInit = Fn(() => { // ... })().compute(nbParticles); gl.computeAsync(computeInit); // ... }, []); // ... }; // ...
Per poter visualizzarlo, dobbiamo cambiare il positionNode
del SpriteNodeMaterial
per usare i buffer spawnPosition
e offsetPosition
.
// ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... return { uniforms, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: uniforms.color, }, }; }, []); // ... }; // ...
Impostiamo il positionNode
sulla somma dei vettori spawnPosition
e offsetPosition
.
Funziona? Verifichiamolo!
S.O.S.! È tutto bianco! ⬜️
Proviamo a rimpicciolire un po'?
Phew, possiamo vedere le particelle, sono solo troppo grandi e hanno coperto l'intero schermo! 😮💨
Correggiamo impostando il scaleNode
con un valore casuale:
// ... import { range } from "three/tsl"; // ... export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... const scale = vec3(range(0.001, 0.01)); return { uniforms, nodes: { positionNode: spawnPosition.add(offsetPosition), colorNode: uniforms.color, scaleNode: scale, }, }; }, []); return ( <> <sprite count={nbParticles}> <spriteNodeMaterial {...nodes} transparent depthWrite={false} blending={AdditiveBlending} /> </sprite> </> ); }; // ...
In questo scenario, possiamo utilizzare la funzione range
per generare un valore casuale tra 0.001
e 0.01
.
Perfetto, abbiamo le nostre particelle con dimensioni e posizioni diverse! 🎉
Tuttavia è un po' statico, dobbiamo aggiungere un po' di movimento.
Aggiornare il calcolo
Come abbiamo fatto per la funzione di inizializzazione del calcolo, creiamo una funzione di aggiornamento del calcolo che sarà eseguita ad ogni frame.
In questa funzione, aggiorneremo la posizione e l'età delle particelle:
// ... import { deltaTime, If } from "three/tsl"; export const GPGPUParticles = ({ nbParticles = 1000 }) => { // ... const { nodes, uniforms } = useMemo(() => { // ... const instanceSpeed = randValue({ min: 0.01, max: 0.05, seed: 12 }); // update Fn const computeUpdate = Fn(() => { age.addAssign(deltaTime); If(age.greaterThan(lifetime), () => { age.assign(0); offsetPosition.assign(0); }); offsetPosition.addAssign(vec3(instanceSpeed)); })().compute(nbParticles); // ... }, []); // ... }; // ...
React Three Fiber: The Ultimate Guide to 3D Web Development
✨ You have reached the end of the preview ✨
Go to the next level with Three.js and React Three Fiber!
Get full access to this lesson and the complete course when you enroll:
- 🔓 Full lesson videos with no limits
- 💻 Access to the final source code
- 🎓 Course progress tracking & completion
- 💬 Invite to our private Discord community
One-time payment. Lifetime updates included.