Motore VFX

Starter pack

Finora, abbiamo creato componenti personalizzati per creare particelle nelle nostre scene 3D. La maggior parte delle volte, vogliamo fare quasi la stessa cosa: emettere particelle da un punto nello spazio e animarle nel tempo. (Colore, dimensione, posizione, ecc.)

Invece di duplicare lo stesso codice più e più volte, possiamo creare un motore VFX relativamente generico che può essere utilizzato per creare diversi tipi di effetti particellari.

I benefici sono molti:

  • Riutilizzabilità: Puoi usare lo stesso motore per creare diversi tipi di effetti particellari nei tuoi progetti.
  • Prestazioni: Il motore può essere ottimizzato per gestire un gran numero di particelle in modo efficiente e per unire più sistemi particellari in uno solo.
  • Flessibilità: Puoi facilmente personalizzare il comportamento delle particelle cambiando i parametri del motore.
  • Facilità d'uso: Puoi creare effetti particellari complessi con solo poche righe di codice.
  • Evitare la duplicazione del codice: Non devi scrivere lo stesso codice più volte.

Utilizzeremo questo motore VFX nelle prossime lezioni per creare vari effetti. Sebbene tu possa saltare questa lezione e utilizzare direttamente il motore, comprendere come funziona ti aiuterà a capire più approfonditamente come padroneggiare prestazioni e flessibilità nei tuoi progetti 3D.

Pronto a costruire il tuo motore VFX? Cominciamo!

Particelle GPU

Abbiamo visto nelle lezioni precedenti come possiamo utilizzare il componente <Instances /> di drei per creare particelle controllate nelle nostre scene 3D.

Ma questo approccio ha un limite principale: il numero di particelle che possiamo gestire è limitato dal CPU. Più particelle abbiamo, più il CPU deve gestirle, il che può portare a problemi di prestazioni.

Questo è dovuto al fatto che sotto il cofano, il componente <Instances /> esegue calcoli per ottenere la posizione, il colore e la dimensione di ciascun <Instance /> nel suo loop useFrame. Puoi vedere il codice qui.

Per il nostro Motore VFX, vogliamo essere in grado di generare molte più particelle di quelle che possiamo gestire con il componente <Instances />. Useremo la GPU per gestire la posizione, il colore e la dimensione delle particelle. Questo ci permetterà di gestire centinaia di migliaia di particelle (milioni? 👀) senza alcun problema di prestazioni.

Instanced Mesh

Anche se potremmo usare Sprite o Points per creare particelle, utilizzeremo InstancedMesh.

Ci permette di renderizzare non solo forme semplici come punti o sprite, ma anche forme 3D come cubi, sfere e geometrie personalizzate.

Creiamo un componente in una nuova cartella vfxs chiamata 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>
    </>
  );
};

Creiamo la geometria che verrà utilizzata per ciascuna particella. In questo caso, utilizziamo una semplice geometria piana con una dimensione di 0.5 su entrambi gli assi. Successivamente aggiungeremo un prop per passare qualsiasi geometria desideriamo.

Il componente instancedMesh accetta tre argomenti:

  • La geometria delle particelle.
  • Il materiale delle particelle. Abbiamo passato null per definirlo in modo dichiarativo all'interno del componente.
  • Il numero di istanze che il componente sarà in grado di gestire. Per noi rappresenta il numero massimo di particelle che possono essere visualizzate contemporaneamente.

Sostituiamo il cubo arancione con il nostro componente VFXParticles nel file Experience.jsx:

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

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

Particella arancione al centro della scena

Puoi vedere una particella arancione al centro della scena. Questo è il nostro componente VFXParticles.

Il nostro numero di particelle è impostato a 1000 ma ne possiamo vedere solo una. Questo perché tutte sono renderizzate nella stessa posizione (0, 0, 0). Cambiamo questo.

Matrice delle istanze

Il mesh con istanze utilizza una matrice per definire la posizione, la rotazione e la scala di ogni istanza. Aggiornando la proprietà instanceMatrix del nostro mesh, possiamo muovere, ruotare e scalare ciascuna particella individualmente.

Per ogni istanza, la matrice è una matrice 4x4 che rappresenta la trasformazione della particella. La classe Matrix4 di Three.js ci consente di compose e decompose la matrice in modo da impostare/ottenere la posizione, la rotazione e la scala della particella in modo più leggibile.

Sopra la dichiarazione di VFXParticles, dichiareremo alcune variabili temporanee per manipolare le particelle senza ricreare Vettori e Matrici troppo spesso:

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

Ora creiamo una funzione emit per impostare le nostre particelle:

// ...
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 funzione emit cicla sul numero di particelle che vogliamo emettere e imposta una posizione, rotazione e scala casuali per ciascuna particella. Componiamo quindi la matrice con questi valori e la impostiamo sull'istanza all'indice corrente.

Particelle casuali nella scena

Puoi vedere particelle casuali nella scena. Ciascuna particella ha una posizione, rotazione e scala casuali.

Per animare le nostre particelle, definiremo attributi come lifetime, speed, direction in modo che il calcolo possa essere eseguito sulla GPU.

Prima di fare ciò, dobbiamo passare a un custom shader material per gestire questi attributi poiché non abbiamo accesso e controllo sugli attributi di meshBasicMaterial.

Materiale Particelle

Il nostro primo obiettivo sarà non vedere alcun cambiamento tra il meshBasicMaterial e il nostro nuovo shaderMaterial. Creeremo un semplice shader material che renderizzerà le particelle allo stesso modo di come attualmente fa il meshBasicMaterial.

Nel componente VFXParticles, creiamo un nuovo shader material:

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

Questo è uno shader material molto semplice che accetta un uniform color e renderizza le particelle con questo colore. L'unica novità qui è l'instanceMatrix che usiamo per ottenere la position, rotation e scale di ciascuna particella.

Nota che non abbiamo avuto bisogno di dichiarare l'attributo instanceMatrix poiché questo è uno degli attributi integrati del WebGLProgram quando si utilizza l'instancing. Maggiori informazioni possono essere trovate qui.

Sostituiamo il meshBasicMaterial con il nostro nuovo ParticlesMaterial:

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

Particelle casuali nella scena con un colore arancione

Perfetto! La nostra position, rotation e scale funzionano ancora come previsto. Le particelle sono renderizzate con un colore arancione leggermente diverso. Questo accade perché non teniamo conto dell'environment nel nostro shader material. Per mantenere le cose semplici, lo manterremo così.

Ora siamo pronti ad aggiungere attributi personalizzati alle nostre particelle per animarle.

Attributi Bufferistanziati

Finora, abbiamo usato solo l'attributo instanceMatrix. Ora aggiungeremo attributi personalizzati per avere più controllo su ogni particella.

Per questo, utilizzeremo InstancedBufferAttribute di Three.js.

Aggiungeremo i seguenti attributi alle nostre particelle:

  • instanceColor: Un vector3 che rappresenta il colore della particella.
  • instanceColorEnd: Un vector3 che rappresenta il colore in cui la particella si trasformerà nel tempo.
  • instanceDirection: Un vector3 che rappresenta la direzione in cui si muoverà la particella.
  • instanceSpeed: Un float per definire quanto velocemente si muoverà la particella nella sua direzione.
  • instanceRotationSpeed: Un vector3 per determinare la velocità di rotazione della particella per asse.
  • instanceLifetime: Un vector2 per definire la durata della particella. Il primo valore (x) è il tempo di inizio, e il secondo valore (y) è la durata. Combinato con un uniforme di tempo, possiamo calcolare età, progresso e se una particella è viva o morta.

Creiamo i diversi buffer per i nostri attributi:

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

  // ...
};

// ...

Sto usando un useState per creare i diversi buffer per i nostri attributi, in modo da evitare di ricrearli ad ogni rendering. Ho scelto di non utilizzare il hook useMemo in quanto cambiare il numero massimo di particelle durante il ciclo di vita del componente non è qualcosa che vogliamo gestire.

Viene utilizzato Float32Array per memorizzare i valori degli attributi. Moltiplichiamo il numero di particelle per il numero di componenti dell'attributo per ottenere il numero totale di valori nell'array.

Schema che spiega l'attributo instanceColor

Nell'attributo instanceColor, i primi 3 valori rappresenteranno il colore della prima particella, i successivi 3 valori rappresenteranno il colore della seconda particella, e così via.

Iniziamo a familiarizzare con InstancedBufferAttribute e come usarlo. Per farlo, implementeremo l'attributo 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>
    </>
  );
};

Nel nostro <instancedMesh />, aggiungiamo un componente <instancedBufferAttribute /> per definire l'attributo instanceColor. Lo colleghiamo all'attributo geometry-attributes-instanceColor della mesh. Passiamo l'array attributeArrays.instanceColor come sorgente dei dati, impostiamo itemSize a 3 poiché abbiamo un vector3, e count a nbParticles.

Il prop usage è impostato su DynamicDrawUsage per indicare al renderer che i dati verranno aggiornati frequentemente. Altri valori possibili e maggiori dettagli possono essere trovati qui.

Non li aggiorneremo a ogni frame, ma ogni volta che emettiamo nuove particelle, i dati verranno aggiornati. Abbastanza per considerarlo come DynamicDrawUsage.

Perfetto, creiamo una variabile tmpColor di servizio in cima al nostro file per manipolare i colori delle particelle:

// ...

const tmpColor = new Color();

Ora aggiorniamo la funzione emit per impostare l'attributo 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);
  }
};

Iniziamo recuperando l'attributo instanceColor dalla geometria della mesh. Poi cicliamo sul numero di particelle che vogliamo emettere e impostiamo un colore casuale per ciascuna particella.

Aggiorniamo il particlesMaterial per usare l'attributo instanceColor invece dell'uniforme colore:

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

Abbiamo aggiunto un attribute vec3 instanceColor; al vertex shader e impostato la variabile vColor per passare il colore al fragment shader. Poi impostiamo il gl_FragColor su vColor per rendere le particelle con il loro colore.

Particelle casuali nella scena con colori casuali

Abbiamo impostato con successo un colore casuale per ciascuna particella. Le particelle vengono rese con il loro colore.

Perfetto, aggiungiamo gli altri attributi alle nostre particelle. Iniziamo aggiornando la nostra funzione emit per impostare gli attributi instanceColorEnd, instanceDirection, instanceLifetime, instanceSpeed e instanceRotationSpeed con valori casuali:

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

E creiamo i componenti instancedBufferAttribute per ciascun attributo:

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

Ora è il momento di dare vita alle nostre particelle implementando la logica del loro movimento, colore e durata.

Durata delle particelle

Per calcolare il comportamento delle nostre particelle, dobbiamo passare il tempo trascorso al nostro shader. Utilizzeremo il prop uniforms del shaderMaterial per passare il tempo.

Aggiorniamo il nostro ParticlesMaterial per aggiungere uno uniform uTime:

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

E in un ciclo useFrame, aggiorneremo lo uniform 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;
  });

  // ...
};

In vertex shader, calcoleremo l'età e il progresso di ogni particella basandoci su uTime uniform e l'attributo instanceLifetime. Passeremo il progresso nello fragment shader per animare le particelle utilizzando un varying chiamato 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'età viene calcolata sottraendo il startTime dal uTime. Il progresso viene calcolato dividendo l'età per la duration.

Ora nello fragment shader, interpoleremo il colore delle particelle tra instanceColor e instanceColorEnd basandoci sul progresso:

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

Particelle che cambiano colore nel tempo

Possiamo vedere le particelle cambiare colore nel tempo, ma stiamo affrontando un problema. Tutte le particelle sono visibili all'inizio mentre il loro tempo di inizio è casuale. Dobbiamo nascondere le particelle che non sono ancora vive.

Per evitare che le particelle non nate e morte vengano renderizzate, utilizzeremo la keyword discard nello fragment shader:

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

La keyword discard dice al renderer di scartare il frammento corrente e non renderizzarlo.

Perfetto, le nostre particelle ora nascono, vivono e muoiono nel tempo. Possiamo ora aggiungere la logica di movimento e rotazione.

Movimento delle particelle

Utilizzando la direction, la speed e l'age delle particelle, possiamo calcolare la loro position nel tempo.

Nel vertex shader, adattiamo il gl_Position per tenere conto della direction e della speed delle particelle.

Per prima cosa normalizziamo la direction per evitare che le particelle si muovano più velocemente quando la direzione non è un vettore unitario:

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

Quindi calcoliamo lo offset della particella basato su speed e age:

vec3 offset = normalizedDirection * age * instanceSpeed;

Otteniamo la posizione dell'istanza:

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

E applichiamo lo offset a essa:

vec3 finalPosition = instancePosition + offset;

Infine, otteniamo la posizione nella vista del modello mvPosition applicando il modelViewMatrix alla finalPosition per trasformare la posizione nello spazio del mondo:

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

E applichiamo il projectionMatrix per trasformare la posizione del mondo nello spazio della camera:

gl_Position = projectionMatrix * mvPosition;

Ecco il nostro vertex shader completo finora:

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

Le particelle ora si muovono in varie direzioni a velocità diverse. Questo è caotico, ma è causato dai nostri valori casuali.

Correggiamo la situazione regolando i nostri valori casuali nella funzione emit per avere una visione più chiara del movimento delle particelle:

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

Sta iniziando a prendere forma!

Aggiungeremo semplici controlli UI per regolare le variabili più avanti. Ora completiamo le particelle aggiungendo la logica di rotazione.

Mentre abbiamo separato la direzione e la velocità per il movimento, per la rotazione useremo un singolo attributo instanceRotationSpeed per definire la velocità di rotazione per asse.

Nel vertex shader possiamo calcolare la rotazione della particella basata sulla rotation speed e sull'age:

vec3 rotationSpeed = instanceRotationSpeed * age;

Quindi, per poter applicare questa "offset rotation" alla particella, dobbiamo convertirla in una matrice di rotazione:

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

rotationX, rotationY e rotationZ sono funzioni che restituiscono una matrice di rotazione intorno agli assi X, Y e Z rispettivamente. Le definiremo sopra la funzione main nel 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
  );
}

Per saperne di più sulle matrici di rotazione, puoi consultare questo articolo di Wikipedia, l'incredibile Game Math Explained Simply di Simon Dev, o la sezione Matrix di The Book of Shaders.

Infine, possiamo applicare la matrice di rotazione alla posizione iniziale della particella:

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

Proviamo:

Le particelle ora si muovono, cambiano colore e ruotano! ✨

Perfetto, abbiamo una solida base per il nostro VFX Engine. Prima di aggiungere più funzionalità e controlli, prepariamo una seconda parte importante del motore: l'emitter.

Emittenti

Negli esempi e nei tutorial è spesso una parte trascurata del sistema di particelle. Ma è una parte cruciale per integrare facilmente ed efficientemente particelle nei tuoi progetti:

  • Facilmente perché il tuo componente <VFXParticles /> sarà in cima alla tua gerarchia e il tuo emitter potrà generarli da qualsiasi sotto-componente nella tua scena. Rendendo facile generarli da un punto specifico, attaccarli a un oggetto in movimento, o a un osso in movimento.
  • Efficientemente perché invece di ricreare instanced meshes, compilare shader materials, e impostare attributi ogni volta che vuoi generare particelle, puoi riutilizzare lo stesso componente VFXParticles e semplicemente chiamare una funzione per generare particelle con le impostazioni desiderate.

useVFX

Vogliamo essere in grado di chiamare la funzione emit dal nostro componente VFXParticles da qualsiasi parte nel progetto. Per farlo, creeremo un hook personalizzato chiamato useVFX che si occuperà di registrare e deregistrare gli emittenti nel componente VFXParticles.

Utilizzeremo Zustand poiché è un modo semplice ed efficiente per gestire lo stato globale in React con grandi prestazioni.

Aggiungiamolo al nostro progetto:

yarn add zustand

Nella nostra cartella vfxs, creiamo un file 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);
  },
}));

Cosa contiene:

  • emitters: Un oggetto che memorizzerà tutti gli emittenti dai nostri componenti VFXParticles.
  • registerEmitter: Una funzione per registrare un emittente con un nome specifico.
  • unregisterEmitter: Una funzione per deregistrare un emittente con un nome specifico.
  • emit: Una funzione per chiamare l'emittente con un nome e parametri specifici da qualsiasi parte del nostro progetto.

Connettiamolo al nostro componente 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);
    };
  }, []);
  // ...
};

// ...

Aggiungiamo una name prop al nostro componente VFXParticles per identificare l'emittente. Utilizziamo poi l'hook useVFX per ottenere le funzioni registerEmitter e unregisterEmitter.

Chiamiamo registerEmitter con il name e la funzione emit all'interno dell'hook useEffect per registrare l'emittente quando il componente viene montato e deregistrarlo quando viene smontato.

Nel componente Experience, aggiungiamo la prop name al nostro componente VFXParticles:

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

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

VFXEmitter

Ora che abbiamo il nostro hook useVFX, possiamo creare un componente VFXEmitter che sarà responsabile di generare particelle dal nostro componente VFXParticles.

Nella cartella vfxs, creiamo un file 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.