Moteur VFX

Starter pack

Jusqu'à présent, nous avons créé des composants personnalisés pour créer des particules dans nos scènes 3D. La plupart du temps, nous voulons faire presque la même chose : émettre des particules à partir d'un point dans l'espace et les animer au fil du temps. (Couleur, taille, position, etc.)

Au lieu de dupliquer le même code encore et encore, nous pouvons créer un moteur VFX relativement générique qui peut être utilisé pour créer différents types d'effets de particules.

Cela présente de nombreux avantages :

  • RĂ©utilisabilitĂ© : Vous pouvez utiliser le mĂŞme moteur pour crĂ©er diffĂ©rents types d'effets de particules dans vos projets.
  • Performance : Le moteur peut ĂŞtre optimisĂ© pour gĂ©rer efficacement un grand nombre de particules et pour fusionner plusieurs systèmes de particules en un seul.
  • FlexibilitĂ© : Vous pouvez facilement personnaliser le comportement des particules en modifiant les paramètres du moteur.
  • FacilitĂ© d'utilisation : Vous pouvez crĂ©er des effets de particules complexes avec seulement quelques lignes de code.
  • Éviter la duplication de code : Vous n'avez pas Ă  Ă©crire le mĂŞme code plusieurs fois.

Nous utiliserons ce moteur VFX dans les prochaines leçons pour créer divers effets. Bien que vous puissiez passer cette leçon et utiliser directement le moteur, comprendre comment il fonctionne vous aidera à maîtriser plus en profondeur la performance et la flexibilité dans vos projets 3D.

Prêt à construire votre moteur VFX ? Commençons !

Particules GPU

Nous avons vu dans les leçons précédentes comment nous pouvons utiliser le composant <Instances /> de drei pour créer des particules contrôlées dans nos scènes 3D.

Mais cette approche a une principale limitation : le nombre de particules que nous pouvons gérer est limité par le CPU. Plus nous avons de particules, plus le CPU doit les gérer, ce qui peut entraîner des problèmes de performance.

Cela est dĂ» au fait que sous le capot, le composant <Instances /> effectue des calculs pour obtenir la position, la couleur et la taille de chaque <Instance /> dans sa boucle useFrame. Vous pouvez voir le code ici.

Pour notre Moteur VFX, nous voulons pouvoir générer bien plus de particules que ce que nous pouvons gérer avec le composant <Instances />. Nous utiliserons le GPU pour gérer la position, la couleur et la taille des particules. Cela nous permettra de gérer des centaines de milliers de particules (millions ? 👀) sans aucun problème de performance.

Maillage Instancié

Bien que nous puissions utiliser Sprite ou Points pour créer des particules, nous allons utiliser InstancedMesh.

Cela nous permet de rendre non seulement des formes simples comme des points ou des sprites, mais aussi des formes 3D comme des cubes, des sphères et des géométries personnalisées.

Créons un composant dans un nouveau dossier vfxs appelé VFXParticles.jsx :

import { useMemo, useRef } from "react";
import { PlaneGeometry } from "three";

export const VFXParticles = ({ settings = {} }) => {
  const { nbParticles = 1000 } = settings;
  const mesh = useRef();
  const defaultGeometry = useMemo(() => new PlaneGeometry(0.5, 0.5), []);
  return (
    <>
      <instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
        <meshBasicMaterial color="orange" />
      </instancedMesh>
    </>
  );
};

Nous créons la géométrie qui sera utilisée pour chaque particule. Dans ce cas, nous utilisons une simple géométrie plane de taille 0.5 sur les deux axes. Plus tard, nous ajouterons une prop pour passer n'importe quelle géométrie que nous souhaitons.

Le composant instancedMesh prend trois arguments :

  • La gĂ©omĂ©trie des particules.
  • Le material des particules. Nous avons passĂ© null pour le dĂ©finir de manière dĂ©clarative Ă  l'intĂ©rieur du composant.
  • Le nombre d'instances que le composant pourra gĂ©rer. Pour nous, il reprĂ©sente le nombre maximum de particules pouvant ĂŞtre affichĂ©es en mĂŞme temps.

Remplaçons le cube orange par notre composant VFXParticles dans le fichier Experience.jsx :

// ...
import { VFXParticles } from "./vfxs/VFXParticles";

export const Experience = () => {
  return (
    <>
      {/* ... */}
      <VFXParticles />
    </>
  );
};

Particule orange au milieu de la scène

Vous pouvez voir une particule orange au milieu de la scène. C'est notre composant VFXParticles.

Notre nombre de particules est défini sur 1000 mais nous ne pouvons en voir qu'une. C'est parce qu'elles sont toutes rendues à la même position (0, 0, 0). Changeons cela.

Matrice d'Instances

Le maillage instancié utilise une matrice pour définir la position, la rotation et l'échelle de chaque instance. En mettant à jour la propriété instanceMatrix de notre maillage, nous pouvons déplacer, faire pivoter et mettre à l'échelle chaque particule individuellement.

Pour chaque instance, la matrice est une matrice 4x4 qui représente la transformation de la particule. La classe Matrix4 de Three.js nous permet de composer et décomposer la matrice pour définir/obtenir la position, la rotation et l'échelle de la particule d'une manière plus compréhensible.

En haut de la déclaration de VFXParticles, déclarons quelques variables temporaires pour manipuler les particules sans recréer trop souvent des Vectors et des Matrices :

// ...
import { Euler, Matrix4, PlaneGeometry, Quaternion, Vector3 } from "three";

const tmpPosition = new Vector3();
const tmpRotationEuler = new Euler();
const tmpRotation = new Quaternion();
const tmpScale = new Vector3(1, 1, 1);
const tmpMatrix = new Matrix4();

Créons maintenant une fonction emit pour configurer nos particules :

// ...
import { useEffect } from "react";

// ...

export const VFXParticles = ({ settings = {} }) => {
  // ...

  const emit = (count) => {
    for (let i = 0; i < count; i++) {
      const position = [
        randFloatSpread(5),
        randFloatSpread(5),
        randFloatSpread(5),
      ];
      const scale = [
        randFloatSpread(1),
        randFloatSpread(1),
        randFloatSpread(1),
      ];
      const rotation = [
        randFloatSpread(Math.PI),
        randFloatSpread(Math.PI),
        randFloatSpread(Math.PI),
      ];
      tmpPosition.set(...position);
      tmpRotationEuler.set(...rotation);
      tmpRotation.setFromEuler(tmpRotationEuler);
      tmpScale.set(...scale);
      tmpMatrix.compose(tmpPosition, tmpRotation, tmpScale);
      mesh.current.setMatrixAt(i, tmpMatrix);
    }
  };
  useEffect(() => {
    emit(nbParticles);
  }, []);
  // ...
};

La fonction emit boucle sur le nombre de particules que nous voulons émettre et définit une position, une rotation et une échelle aléatoires pour chaque particule. Nous composons ensuite la matrice avec ces valeurs et l'attribuons à l'instance à l'index actuel.

Particules aléatoires dans la scène

Vous pouvez voir des particules aléatoires dans la scène. Chaque particule a une position, une rotation et une échelle aléatoires.

Pour animer nos particules, nous définirons des attributs tels que lifetime (durée de vie), speed (vitesse), direction afin que le calcul puisse être effectué sur le GPU.

Avant de faire cela, nous devons passer à un shader material personnalisé pour gérer ces attributs, car nous n'avons pas accès et contrôle sur les attributs du meshBasicMaterial.

Matériau Particules

Notre premier objectif sera de ne constater aucun changement entre le meshBasicMaterial et notre nouveau shaderMaterial. Nous allons créer un matériau shader simple qui affichera les particules de la même manière que le meshBasicMaterial le fait actuellement.

Dans le composant VFXParticles, créons un nouveau matériau shader :

// ...
import { shaderMaterial } from "@react-three/drei";
import { extend } from "@react-three/fiber";
import { Color } from "three";

const ParticlesMaterial = shaderMaterial(
  {
    color: new Color("white"),
  },
  /* glsl */ `
varying vec2 vUv;

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0));
  vUv = uv;
}
`,
  /* glsl */ `
uniform vec3 color;
varying vec2 vUv;


void main() {
  gl_FragColor = vec4(color, 1.0);
}`
);

extend({ ParticlesMaterial });

Ceci est un matériau shader très simple qui prend une uniforme color et affiche les particules avec cette couleur. La seule nouvelle chose ici est le instanceMatrix que nous utilisons pour obtenir la position, la rotation, et l'échelle de chaque particule.

Notez que nous n'avions pas besoin de déclarer l'attribut instanceMatrix car c'est l'un des attributs intégrés du WebGLProgram lors de l'utilisation de l'instancing. Plus d'informations peuvent être trouvées ici.

Remplaçons le meshBasicMaterial par notre nouveau ParticlesMaterial :

<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
  <particlesMaterial color="orange" />
</instancedMesh>

Particules aléatoires dans la scène avec une couleur orange

Parfait ! Notre position, rotation, et échelle fonctionnent toujours comme prévu. Les particules sont rendues avec une couleur orange légèrement différente. C'est parce que nous ne prenons pas en compte l'environnement dans notre matériau shader. Pour simplifier les choses, nous allons le laisser ainsi.

Nous sommes maintenant prêts à ajouter des attributs personnalisés à nos particules pour les animer.

Attributs de Buffer Instanciés

Jusqu'à présent, nous avons uniquement utilisé l'attribut instanceMatrix, nous allons maintenant ajouter des attributs personnalisés pour avoir plus de contrôle sur chaque particule.

Pour cela, nous allons utiliser le InstancedBufferAttribute de Three.js.

Nous ajouterons les attributs suivants Ă  nos particules :

  • instanceColor: Un vector3 reprĂ©sentant la couleur de la particule.
  • instanceColorEnd: Un vector3 reprĂ©sentant la couleur que prendra la particule au fil du temps.
  • instanceDirection: Un vector3 reprĂ©sentant la direction dans laquelle la particule se dĂ©placera.
  • instanceSpeed: Un float pour dĂ©finir la vitesse Ă  laquelle la particule se dĂ©placera dans sa direction.
  • instanceRotationSpeed: Un vector3 pour dĂ©terminer la vitesse de rotation de la particule par axe.
  • instanceLifetime: Un vector2 pour dĂ©finir la durĂ©e de vie de la particule. La première valeur (x) est le temps de dĂ©but, et la seconde valeur (y) est la durĂ©e de vie/la durĂ©e. CombinĂ© avec un uniforme de temps, nous pouvons calculer l'âge, le progrès, et si une particule est vivante ou morte.

Créons les différents buffers pour nos attributs :

// ...
import { useState } from "react";

// ...

export const VFXParticles = ({ settings = {} }) => {
  // ...

  const [attributeArrays] = useState({
    instanceColor: new Float32Array(nbParticles * 3),
    instanceColorEnd: new Float32Array(nbParticles * 3),
    instanceDirection: new Float32Array(nbParticles * 3),
    instanceLifetime: new Float32Array(nbParticles * 2),
    instanceSpeed: new Float32Array(nbParticles * 1),
    instanceRotationSpeed: new Float32Array(nbParticles * 3),
  });

  // ...
};

// ...

J'utilise un useState pour créer les différents buffers pour nos attributs afin d'éviter de les recréer à chaque rendu. J'ai choisi de ne pas utiliser le hook useMemo car changer le nombre maximal de particules pendant le cycle de vie du composant n'est pas quelque chose que nous souhaitons gérer.

Le Float32Array est utilisé pour stocker les valeurs des attributs. Nous multiplions le nombre de particules par le nombre de composants de l'attribut pour obtenir le nombre total de valeurs dans le tableau.

Schéma expliquant l'attribut instanceColor

Dans l'attribut instanceColor, les 3 premières valeurs représenteront la couleur de la première particule, les 3 valeurs suivantes représenteront la couleur de la deuxième particule, et ainsi de suite.

Commençons par nous familiariser avec le InstancedBufferAttribute et comment l'utiliser. Pour cela, nous allons implémenter l'attribut instanceColor :

// ...
import { DynamicDrawUsage } from "three";

export const VFXParticles = ({ settings = {} }) => {
  // ...

  return (
    <>
      <instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
        <particlesMaterial color="orange" />
        <instancedBufferAttribute
          attach={"geometry-attributes-instanceColor"}
          args={[attributeArrays.instanceColor]}
          itemSize={3}
          count={nbParticles}
          usage={DynamicDrawUsage}
        />
      </instancedMesh>
    </>
  );
};

Dans notre <instancedMesh />, nous ajoutons un composant <instancedBufferAttribute /> pour définir l'attribut instanceColor. Nous le joignons à l'attribut geometry-attributes-instanceColor du mesh. Nous passons le tableau attributeArrays.instanceColor comme source de données, configurons le itemSize à 3 car nous avons un vector3, et le count à nbParticles.

L'attribut usage est configuré à DynamicDrawUsage pour indiquer au rendu que les données seront fréquemment mises à jour. Les autres valeurs possibles et plus de détails peuvent être trouvés ici.

Nous ne les mettrons pas à jour à chaque frame, mais chaque fois que nous émettons de nouvelles particules, les données seront mises à jour. Suffisamment pour être considéré comme DynamicDrawUsage.

Parfait, créons une variable fictive tmpColor en haut de notre fichier pour manipuler les couleurs des particules :

// ...

const tmpColor = new Color();

Mettons maintenant à jour la fonction emit pour définir l'attribut instanceColor :

const emit = (count) => {
  const instanceColor = mesh.current.geometry.getAttribute("instanceColor");

  for (let i = 0; i < count; i++) {
    // ...

    tmpColor.setRGB(Math.random(), Math.random(), Math.random());
    instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);
  }
};

Nous commençons par obtenir l'attribut instanceColor de la géométrie du mesh. Nous parcourons ensuite le nombre de particules que nous voulons émettre et définissons une couleur aléatoire pour chaque particule.

Mettons Ă  jour le particlesMaterial pour utiliser l'attribut instanceColor au lieu de l'uniforme de couleur :

const ParticlesMaterial = shaderMaterial(
  {
    // color: new Color("white"),
  },
  /* glsl */ `
varying vec2 vUv;
varying vec3 vColor;

attribute vec3 instanceColor;

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0));
  vUv = uv;
  vColor = instanceColor;
}
`,
  /* glsl */ `
varying vec3 vColor;
varying vec2 vUv;


void main() {
  gl_FragColor = vec4(vColor, 1.0);
}`
);
// ...

Nous avons ajouté un attribute vec3 instanceColor; au vertex shader et configuré le vColor variant pour passer la couleur au fragment shader. Nous définissons ensuite le gl_FragColor au vColor pour rendre les particules avec leur couleur.

Particules aléatoires dans la scène avec des couleurs aléatoires

Nous avons réussi à définir une couleur aléatoire pour chaque particule. Les particules sont rendues avec leur couleur.

Parfait, ajoutons les autres attributs à nos particules. D'abord, mettons à jour notre fonction emit pour définir les attributs instanceColorEnd, instanceDirection, instanceLifetime, instanceSpeed, et instanceRotationSpeed avec des valeurs aléatoires :

const emit = (count) => {
  const instanceColor = mesh.current.geometry.getAttribute("instanceColor");
  const instanceColorEnd =
    mesh.current.geometry.getAttribute("instanceColorEnd");
  const instanceDirection =
    mesh.current.geometry.getAttribute("instanceDirection");
  const instanceLifetime =
    mesh.current.geometry.getAttribute("instanceLifetime");
  const instanceSpeed = mesh.current.geometry.getAttribute("instanceSpeed");
  const instanceRotationSpeed = mesh.current.geometry.getAttribute(
    "instanceRotationSpeed"
  );

  for (let i = 0; i < count; i++) {
    // ...

    tmpColor.setRGB(Math.random(), Math.random(), Math.random());
    instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

    tmpColor.setRGB(Math.random(), Math.random(), Math.random());
    instanceColorEnd.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

    const direction = [
      randFloatSpread(1),
      randFloatSpread(1),
      randFloatSpread(1),
    ];
    instanceDirection.set(direction, i * 3);

    const lifetime = [randFloat(0, 5), randFloat(0.1, 5)];
    instanceLifetime.set(lifetime, i * 2);

    const speed = randFloat(5, 20);
    instanceSpeed.set([speed], i);

    const rotationSpeed = [
      randFloatSpread(1),
      randFloatSpread(1),
      randFloatSpread(1),
    ];
    instanceRotationSpeed.set(rotationSpeed, i * 3);
  }
};

Et créons les composants instancedBufferAttribute pour chaque attribut :

<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}>
  <particlesMaterial color="orange" />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceColor"}
    args={[attributeArrays.instanceColor]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceColorEnd"}
    args={[attributeArrays.instanceColorEnd]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceDirection"}
    args={[attributeArrays.instanceDirection]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceLifetime"}
    args={[attributeArrays.instanceLifetime]}
    itemSize={2}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceSpeed"}
    args={[attributeArrays.instanceSpeed]}
    itemSize={1}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
  <instancedBufferAttribute
    attach={"geometry-attributes-instanceRotationSpeed"}
    args={[attributeArrays.instanceRotationSpeed]}
    itemSize={3}
    count={nbParticles}
    usage={DynamicDrawUsage}
  />
</instancedMesh>

Il est maintenant temps d'ajouter de la vie à nos particules en implémentant leur mouvement, leur couleur et leur logique de durée de vie.

Durée de vie des particules

Pour calculer le comportement de nos particules, nous devons passer le temps écoulé à notre shader. Nous utiliserons la propriété uniforms du shaderMaterial pour lui passer le temps.

Mettons Ă  jour notre ParticlesMaterial pour ajouter une uniforme uTime :

const ParticlesMaterial = shaderMaterial(
  {
    uTime: 0,
  },
  /* glsl */ `
uniform float uTime;
// ...
`,
  /* glsl */ `
// ...
`
);

Et dans une boucle useFrame, nous mettrons Ă  jour l'uniforme uTime :

// ...
import { useFrame } from "@react-three/fiber";

// ...
export const VFXParticles = ({ settings = {} }) => {
  // ...

  useFrame(({ clock }) => {
    if (!mesh.current) {
      return;
    }
    mesh.current.material.uniforms.uTime.value = clock.elapsedTime;
  });

  // ...
};

Dans le vertex shader, nous allons calculer l'âge et le progrès de chaque particule en fonction de l'uniforme uTime et de l'attribut instanceLifetime. Nous transmettrons le progrès dans le fragment shader pour animer les particules à l'aide d'un varying nommé vProgress.

uniform float uTime;

varying vec2 vUv;
varying vec3 vColor;
varying vec3 vColorEnd;
varying float vProgress;

attribute float instanceSpeed;
attribute vec3 instanceRotationSpeed;
attribute vec3 instanceDirection;
attribute vec3 instanceColor;
attribute vec3 instanceColorEnd;
attribute vec2 instanceLifetime; // x: startTime, y: duration

void main() {
  float startTime = instanceLifetime.x;
  float duration = instanceLifetime.y;
  float age = uTime - startTime;
  vProgress = age / duration;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(instanceMatrix * vec4(position, 1.0));

  vUv = uv;
  vColor = instanceColor;
  vColorEnd = instanceColorEnd;
}

L'âge est calculé en soustrayant le startTime de uTime. Le progrès est ensuite calculé en divisant l'âge par la duration.

Maintenant, dans le fragment shader, nous allons interpoler la couleur des particules entre instanceColor et instanceColorEnd en fonction du progrès :

varying vec3 vColor;
varying vec3 vColorEnd;
varying float vProgress;
varying vec2 vUv;

void main() {
  vec3 finalColor = mix(vColor, vColorEnd, vProgress);
  gl_FragColor = vec4(finalColor, 1.0);
}

Les particules changent de couleur au fil du temps

Nous pouvons voir les particules changer de couleur au fil du temps, mais nous faisons face à un problème. Toutes les particules sont visibles au début alors que leur temps de départ est aléatoire. Nous devons cacher les particules qui ne sont pas encore nées.

Pour empêcher le rendu des particules non nées et mortes, nous utiliserons le mot-clé discard dans le fragment shader :

// ...
void main() {
  if (vProgress < 0.0 || vProgress > 1.0) {
    discard;
  }
  // ...
}

Le mot-clé discard indique au renderer de supprimer le fragment actuel et de ne pas le rendre.

Parfait, nos particules naissent maintenant, vivent et meurent au fil du temps. Nous pouvons maintenant ajouter la logique de mouvement et de rotation.

Mouvement des particules

En utilisant la direction, la vitesse et l'âge des particules, nous pouvons calculer leur position au fil du temps.

Dans le vertex shader, ajustons le gl_Position pour prendre en compte la direction et la vitesse des particules.

Tout d'abord, nous normalisons la direction pour éviter que les particules se déplacent plus vite lorsque la direction n'est pas un vecteur unitaire :

vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0);

Ensuite, nous calculons le offset des particules basé sur la vitesse et l'âge :

vec3 offset = normalizedDirection * age * instanceSpeed;

Obtenons la position de l'instance :

vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0);
vec3 instancePosition = startPosition.xyz;

Et appliquons l'offset :

vec3 finalPosition = instancePosition + offset;

Enfin, nous obtenons la position en vue modèle mvPosition en appliquant la modelViewMatrix à la finalPosition pour transformer la position en espace monde :

vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0);

Et appliquons la projectionMatrix pour transformer la position du monde en espace caméra :

gl_Position = projectionMatrix * mvPosition;

Voici notre vertex shader complet jusqu'à présent :

uniform float uTime;

varying vec2 vUv;
varying vec3 vColor;
varying vec3 vColorEnd;
varying float vProgress;

attribute float instanceSpeed;
attribute vec3 instanceRotationSpeed;
attribute vec3 instanceDirection;
attribute vec3 instanceColor;
attribute vec3 instanceColorEnd;
attribute vec2 instanceLifetime; // x: startTime, y: duration

void main() {
  float startTime = instanceLifetime.x;
  float duration = instanceLifetime.y;
  float age = uTime - startTime;
  vProgress = age / duration;

  vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0);
  vec3 offset = normalizedDirection * age * instanceSpeed;

  vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0);
  vec3 instancePosition = startPosition.xyz;

  vec3 finalPosition = instancePosition + offset;
  vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0);
  gl_Position = projectionMatrix * mvPosition;

  vUv = uv;
  vColor = instanceColor;
  vColorEnd = instanceColorEnd;
}

Les particules se déplacent maintenant dans différentes directions à différentes vitesses. C'est chaotique, mais c'est à cause de nos valeurs aléatoires.

Remédions à cela en ajustant nos valeurs aléatoires dans la fonction emit pour avoir une vue plus claire du mouvement des particules :

for (let i = 0; i < count; i++) {
  const position = [
    randFloatSpread(0.1),
    randFloatSpread(0.1),
    randFloatSpread(0.1),
  ];
  const scale = [randFloatSpread(1), randFloatSpread(1), randFloatSpread(1)];
  const rotation = [
    randFloatSpread(Math.PI),
    randFloatSpread(Math.PI),
    randFloatSpread(Math.PI),
  ];
  tmpPosition.set(...position);
  tmpRotationEuler.set(...rotation);
  tmpRotation.setFromEuler(tmpRotationEuler);
  tmpScale.set(...scale);
  tmpMatrix.compose(tmpPosition, tmpRotation, tmpScale);
  mesh.current.setMatrixAt(i, tmpMatrix);

  tmpColor.setRGB(1, 1, 1);
  instanceColor.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

  tmpColor.setRGB(0, 0, 0);
  instanceColorEnd.set([tmpColor.r, tmpColor.g, tmpColor.b], i * 3);

  const direction = [randFloatSpread(0.5), 1, randFloatSpread(0.5)];
  instanceDirection.set(direction, i * 3);

  const lifetime = [randFloat(0, 5), randFloat(0.1, 5)];
  instanceLifetime.set(lifetime, i * 2);

  const speed = randFloat(1, 5);
  instanceSpeed.set([speed], i);

  const rotationSpeed = [
    randFloatSpread(1),
    randFloatSpread(1),
    randFloatSpread(1),
  ];
  instanceRotationSpeed.set(rotationSpeed, i * 3);
}

Ça commence à prendre forme !

Nous ajouterons plus tard des contrĂ´les d'interface utilisateur simples pour ajuster les variables. Maintenant, finalisons les particules en ajoutant la logique de rotation.

Bien que nous ayons séparé la direction et la vitesse pour le mouvement, pour la rotation, nous utiliserons un attribut instanceRotationSpeed unique pour définir la vitesse de rotation par axe.

Dans le vertex shader, nous pouvons calculer la rotation de la particule en fonction de la vitesse de rotation et de l'âge :

vec3 rotationSpeed = instanceRotationSpeed * age;

Ensuite, pour pouvoir appliquer cette "rotation de décalage" à la particule, nous devons la convertir en une matrice de rotation :

mat4 rotX = rotationX(rotationSpeed.x);
mat4 rotY = rotationY(rotationSpeed.y);
mat4 rotZ = rotationZ(rotationSpeed.z);
mat4 rotationMatrix = rotZ * rotY * rotX;

rotationX, rotationY, et rotationZ sont des fonctions qui renvoient une matrice de rotation autour des axes X, Y, et Z respectivement. Nous les définirons dans la fonction main dans le vertex shader :

mat4 rotationX(float angle) {
  float s = sin(angle);
  float c = cos(angle);
  return mat4(
      1,  0,  0,  0,
      0,  c, -s,  0,
      0,  s,  c,  0,
      0,  0,  0,  1
  );
}

mat4 rotationY(float angle) {
  float s = sin(angle);
  float c = cos(angle);
  return mat4(
       c,  0,  s,  0,
       0,  1,  0,  0,
      -s,  0,  c,  0,
       0,  0,  0,  1
  );
}

mat4 rotationZ(float angle) {
  float s = sin(angle);
  float c = cos(angle);
  return mat4(
      c, -s,  0,  0,
      s,  c,  0,  0,
      0,  0,  1,  0,
      0,  0,  0,  1
  );
}

Pour en savoir plus sur les matrices de rotation, vous pouvez consulter cet article Wikipédia, l'incroyable Game Math Explained Simply par Simon Dev, ou la section Matrice du Livre des Shaders.

Enfin, nous pouvons appliquer la matrice de rotation à la position de départ de la particule :

vec4 startPosition = modelMatrix * instanceMatrix * rotationMatrix * vec4(position, 1.0);

Essayons-le :

Les particules se déplacent désormais, changent de couleur et tournent ! ✨

Parfait, nous avons une base solide pour notre moteur VFX. Avant d'ajouter plus de fonctionnalités et de contrôles, préparons une deuxième partie importante du moteur : l'émetteur.

Émetteurs

Dans les exemples et tutoriels, c'est souvent une partie négligée du système de particules. Mais c'est une partie cruciale pour intégrer les particules dans vos projets de manière simple et efficace :

  • Simplement parce que votre composant <VFXParticles /> sera au sommet de votre hiĂ©rarchie et votre Ă©metteur pourra les faire apparaĂ®tre depuis n'importe quel sous-composant de votre scène. Ce qui permet de les faire apparaĂ®tre facilement depuis un point spĂ©cifique, de les attacher Ă  un objet en mouvement ou Ă  un os en mouvement.
  • Efficacement parce qu'au lieu de recrĂ©er des meshes instanciĂ©es, de compiler des shader materials, et de dĂ©finir des attributs chaque fois que vous voulez faire apparaĂ®tre des particules, vous pouvez rĂ©utiliser le mĂŞme composant VFXParticles et simplement appeler une fonction pour faire apparaĂ®tre des particules avec les paramètres souhaitĂ©s.

useVFX

Nous souhaitons pouvoir appeler la fonction emit de notre composant VFXParticles depuis n'importe où dans notre projet. Pour cela, nous allons créer un hook personnalisé appelé useVFX qui s'occupera de l'enregistrement et du désenregistrement des émetteurs du composant VFXParticles.

Nous allons utiliser Zustand car c'est un moyen simple et efficace de gérer l'état global dans React avec de bonnes performances.

Ajoutons-le Ă  notre projet :

yarn add zustand

Dans notre dossier vfxs, créons un fichier VFXStore.js :

import { create } from "zustand";

export const useVFX = create((set, get) => ({
  emitters: {},
  registerEmitter: (name, emitter) => {
    if (get().emitters[name]) {
      console.warn(`Emitter ${name} already exists`);
      return;
    }
    set((state) => {
      state.emitters[name] = emitter;
      return state;
    });
  },
  unregisterEmitter: (name) => {
    set((state) => {
      delete state.emitters[name];
      return state;
    });
  },
  emit: (name, ...params) => {
    const emitter = get().emitters[name];
    if (!emitter) {
      console.warn(`Emitter ${name} not found`);
      return;
    }
    emitter(...params);
  },
}));

Ce qu'il contient :

  • emitters : Un objet qui stockera tous les Ă©metteurs de nos composants VFXParticles.
  • registerEmitter : Une fonction pour enregistrer un Ă©metteur avec un nom donnĂ©.
  • unregisterEmitter : Une fonction pour dĂ©senregistrer un Ă©metteur avec un nom donnĂ©.
  • emit : Une fonction pour appeler l'Ă©metteur avec un nom et des paramètres donnĂ©s depuis n'importe oĂą dans notre projet.

Branchons-le dans notre composant VFXParticles :

// ...
import { useVFX } from "./VFXStore";

// ...
export const VFXParticles = ({ name, settings = {} }) => {
  // ...
  const registerEmitter = useVFX((state) => state.registerEmitter);
  const unregisterEmitter = useVFX((state) => state.unregisterEmitter);

  useEffect(() => {
    // emit(nbParticles);
    registerEmitter(name, emit);
    return () => {
      unregisterEmitter(name);
    };
  }, []);
  // ...
};

// ...

Nous ajoutons une prop name à notre composant VFXParticles pour identifier l'émetteur. Nous utilisons ensuite le hook useVFX pour obtenir les fonctions registerEmitter et unregisterEmitter.

Nous appelons registerEmitter avec le name et la fonction emit à l'intérieur du hook useEffect pour enregistrer l'émetteur lorsque le composant est monté et le désenregistrer lorsqu'il est démonté.

Dans le composant Experience, ajoutons la prop name Ă  notre composant VFXParticles :

// ...
import { VFXParticles } from "./vfxs/VFXParticles";

export const Experience = () => {
  return (
    <>
      {/* ... */}
      <VFXParticles name="sparks" />
    </>
  );
};

VFXEmitter

Maintenant que nous avons notre hook useVFX, nous pouvons créer un composant VFXEmitter qui sera responsable de l'émission de particules depuis notre composant VFXParticles.

Dans le dossier vfxs, créons un fichier VFXEmitter.jsx :

import { forwardRef, useImperativeHandle, useRef } from "react";
import { useVFX } from "./VFXStore";

export const VFXEmitter = forwardRef(
  ({ emitter, settings = {}, ...props }, forwardedRef) => {
    const {
      duration = 1,
      nbParticles = 1000,
      spawnMode = "time", // time, burst
      loop = false,
      delay = 0,
    } = settings;

    const emit = useVFX((state) => state.emit);

    const ref = useRef();
    useImperativeHandle(forwardedRef, () => ref.current);

    return (
      <>
        <object3D {...props} ref={ref} />
      </>
    );
  }
);
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.