Particules GPGPU avec TSL et WebGPU

Starter pack

Dans cette leçon, nous allons créer des centaines de milliers de particules flottantes pour rendre des modèles 3D et du texte 3D en utilisant Three Shading Language (TSL) et WebGPU.

Au lieu d'utiliser des faces, nous utilisons des tonnes de particules, ce qui nous permet de passer en douceur entre différents modèles.

Un modèle 3D d'un renard, un livre et du texte 3D rendu avec des particules GPGPU ! 🚀

Système de Particules GPGPU

Avant de plonger dans le code, prenons un moment pour comprendre ce qu'est GPGPU et comment il peut être utilisé dans Three.js.

Qu'est-ce que le GPGPU ?

Le GPGPU (General-Purpose computing on Graphics Processing Units) est une technique qui exploite la puissance de traitement parallèle des GPU pour effectuer des calculs généralement gérés par le CPU.

Dans Three.js, le GPGPU est souvent utilisé pour les simulations en temps réel, les systèmes de particules et la physique en stockant et mettant à jour les données dans des textures au lieu de s'appuyer sur des calculs liés au CPU.

Cette technique permet aux shaders d'avoir des capacités de mémoire et de calcul, leur permettant d'effectuer des calculs complexes et de stocker les résultats dans des textures sans intervention du CPU.

Cela permet des calculs à grande échelle très efficaces directement sur le GPU.

Grâce à TSL, le processus de création de simulations GPGPU est beaucoup plus facile et intuitif. Avec des nœuds de storage et buffer combinés à des fonctions de compute, nous pouvons créer des simulations complexes avec un code minimal.

Voici quelques idées de projets pour lesquels le GPGPU peut être utilisé :

Il est temps de passer de la théorie à la pratique ! Créons un système de particules GPGPU en utilisant TSL et WebGPU.

Système de particules

Le pack de démarrage est un modèle compatible WebGPU basé sur l'implémentation de la leçon WebGPU/TSL.

GPGPU particles starter pack

Remplaçons le maillage rose par un nouveau composant nommé GPGPUParticles. Créez un nouveau fichier nommé GPGPUParticles.jsx dans le dossier src/components et ajoutez le code suivant :

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

Rien de nouveau ici, nous créons un composant GPGPUParticles qui utilise un Sprite avec un SpriteNodeMaterial pour rendre les particules.

L'avantage d'utiliser un Sprite plutôt qu'un InstancedMesh est qu'il est plus léger et offre un effet billboard par défaut.

Ajoutons le composant GPGPUParticles au composant 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> */}
    </>
  );
};

Nous pouvons nous débarrasser des composants mesh et environment.

White sprite particles

Nous pouvons voir un carré au milieu de l'écran, ce sont les particules du sprite blanc. Toutes à la même position.

Il est temps de configurer notre système de particules !

Tableau / Stockage / Tableau Instancié

Pour notre simulation GPGPU, nous avons besoin que nos particules mémorisent leur position, vélocité, âge et couleur sans utiliser le CPU.

Quelques éléments ne nécessiteront pas de stockage de données. Nous pouvons calculer la couleur en fonction de l'âge combiné aux uniforms. Et nous pouvons générer la vélocité aléatoirement en utilisant une valeur de seed fixe.

Mais pour la position, puisque la position cible peut évoluer, nous devons la stocker dans un buffer. Il en va de même pour l'âge, nous voulons gérer le cycle de vie des particules dans le GPU.

Pour stocker des données dans le GPU, nous pouvons utiliser le storage node. Cela nous permet de stocker de grandes quantités de données structurées pouvant être mises à jour sur le GPU.

Pour l'utiliser avec un minimum de code, nous allons utiliser la fonction TSL InstancedArray reposant sur le storage node.

Cette partie des nodes Three.js n'est pas encore documentée, c'est en plongeant dans les exemples et le code source que nous pouvons comprendre comment cela fonctionne.

Préparons notre buffer dans le useMemo où nous mettons nos 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 est une fonction TSL qui crée un buffer de la taille et du type spécifiés.

Le même code en utilisant le storage node ressemblerait à ceci :

import { storage } from "three/tsl";
import { StorageInstancedBufferAttribute } from "three/webgpu";

const spawnPositionsBuffer = storage(
  new StorageInstancedBufferAttribute(nbParticles, 3),
  "vec3",
  nbParticles
);

Grâce à ces buffers, nous pouvons stocker la position et l'âge de chaque particule et les mettre à jour dans le GPU.

Pour accéder aux données dans les buffers, nous pouvons utiliser .element(index) pour obtenir la valeur à l'index spécifié.

Dans notre cas, nous utiliserons le instancedIndex de chaque particule pour accéder aux données dans les buffers :

// ...
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 est une fonction TSL intégrée qui renvoie l'index de l'instance actuelle en cours de traitement.

Cela nous permet d'accéder aux données dans les buffers pour chaque particule.

Nous n'en aurons pas besoin pour ce projet, mais en étant capable d'accéder aux données d'une autre instance, nous pouvons créer des interactions complexes entre les particules. Par exemple, nous pourrions créer un vol d'oiseaux qui se suivent les uns les autres.

Calcul initial

Pour configurer la position et l'âge des particules, nous devons créer une fonction compute qui sera exécutée sur le GPU au début de la simulation.

Pour créer une fonction compute avec TSL, nous devons utiliser le nœud Fn, l'appeler, et utiliser la méthode compute qu'il retourne avec le nombre de particules :

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

    // ...
  }, []);

  // ...
};

// ...

Nous créons une fonction computeInit qui assigne à nos buffers des valeurs aléatoires.

La fonction randValue n'existe pas, nous devons la créer nous-mêmes.

Les fonctions à notre disposition sont :

  • hash(seed): Pour générer une valeur aléatoire basée sur une graine entre 0 et 1.
  • range(min, max): Pour générer une valeur aléatoire entre min et max.

Plus d'informations sur le Wiki Three.js Shading Language.

Mais la fonction range définit un attribut et stocke sa valeur. Pas ce que nous voulons.

Créons une fonction randValue qui retournera une valeur aléatoire entre min et max basée sur une graine :

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 fonction randValue prend une valeur min, max, et seed et retourne une valeur aléatoire entre min et max basée sur la graine.

/*#__PURE__*/ est un commentaire utilisé pour le tree-shaking. Il indique au bundler de supprimer la fonction si elle n'est pas utilisée. Plus de détails ici.

Maintenant nous devons appeler notre fonction computeInit. C'est un travail pour le renderer. Importons-la avec useThree et appelons-la juste après sa déclaration :

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

    // ...
  }, []);

  // ...
};

// ...

Pour pouvoir la visualiser, nous devons changer le positionNode du SpriteNodeMaterial pour utiliser les buffers spawnPosition et offsetPosition.

// ...

export const GPGPUParticles = ({ nbParticles = 1000 }) => {
  // ...

  const { nodes, uniforms } = useMemo(() => {
    // ...

    return {
      uniforms,
      nodes: {
        positionNode: spawnPosition.add(offsetPosition),
        colorNode: uniforms.color,
      },
    };
  }, []);

  // ...
};

// ...

Nous assignons le positionNode à la somme des vecteurs spawnPosition et offsetPosition.

Est-ce que ça fonctionne ? Vérifions ça !

Particles with random positions full white

Mayday ! C'est tout blanc ! ⬜️

Reculez un peu ?

Particles with random positions zoomed out

Ouf, nous pouvons voir les particules, elles sont juste trop grosses et ont peint tout l'écran ! 😮‍💨

Corrigeons cela en définissant le scaleNode avec une valeur aléatoire :

// ...
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>
    </>
  );
};

// ...

Dans ce scénario, nous pouvons utiliser la fonction range pour générer une valeur aléatoire entre 0.001 et 0.01.

Parfait, nous avons nos particules avec des tailles et positions différentes ! 🎉

C'est un peu statique toutefois, nous devons y ajouter un peu de mouvement.

Mettre à jour le calcul

Comme nous l'avons fait pour la fonction de calcul init, créons une fonction de calcul de mise à jour qui sera exécutée à chaque frame.

Dans cette fonction, nous allons mettre à jour la position et l'âge des particules :

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