Particelle GPGPU con TSL & WebGPU

Starter pack

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:

È 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.

Pacchetto di avvio GPGPU particles

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.

Particelle sprite bianche

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!

Particelle con posizioni casuali tutte bianche

S.O.S.! È tutto bianco! ⬜️

Proviamo a rimpicciolire un po'?

Particelle con posizioni casuali ingrandite

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

    // ...
  }, []);

  // ...
};

// ...
Three.js logoReact logo

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
Unlock the Full Course – Just $85

One-time payment. Lifetime updates included.