Motor VFX

Starter pack

Até agora, criamos componentes personalizados para criar partículas em nossas cenas 3D. Na maioria das vezes, queremos fazer quase a mesma coisa: emitir partículas de um ponto no espaço e animá-las ao longo do tempo. (Cor, tamanho, posição, etc.)

Em vez de duplicar o mesmo código repetidamente, podemos criar um motor VFX relativamente genérico que pode ser usado para criar diferentes tipos de efeitos de partículas.

Isso traz muitos benefícios:

  • Reutilização: Você pode usar o mesmo motor para criar diferentes tipos de efeitos de partículas em seus projetos.
  • Desempenho: O motor pode ser otimizado para lidar com um grande número de partículas de forma eficiente e para mesclar múltiplos sistemas de partículas em um único.
  • Flexibilidade: Você pode personalizar facilmente o comportamento das partículas alterando os parâmetros do motor.
  • Facilidade de uso: Você pode criar efeitos de partículas complexos com apenas algumas linhas de código.
  • Evitar duplicação de código: Você não precisa escrever o mesmo código várias vezes.

Usaremos este motor VFX nas próximas lições para criar vários efeitos. Embora você possa pular esta lição e usar o motor diretamente, entender como ele funciona ajudará você a dominar mais profundamente o desempenho e a flexibilidade em seus projetos 3D.

Pronto para construir seu motor VFX? Vamos começar!

Partículas GPU

Vimos nas lições anteriores como podemos usar o componente <Instances /> do drei para criar partículas controladas em nossas cenas 3D.

Mas essa abordagem tem uma limitação principal: o número de partículas que podemos manipular é limitado pela CPU. Quanto mais partículas tivermos, mais a CPU terá que gerenciá-las, o que pode levar a problemas de desempenho.

Isso se deve ao fato de que, por trás das cenas, o componente <Instances /> faz cálculos para obter a posição, cor e tamanho de cada <Instance /> no seu loop useFrame. Você pode ver o código aqui.

Para o nosso Motor VFX, queremos ser capazes de gerar muito mais partículas do que podemos gerenciar com o componente <Instances />. Usaremos a GPU para lidar com a posição, cor e tamanho das partículas. Isso nos permitirá manipular centenas de milhares de partículas (milhões? 👀) sem qualquer problema de desempenho.

Mesh Instanciado

Embora possamos usar Sprite ou Points para criar partículas, usaremos InstancedMesh.

Ele nos permite renderizar não apenas formas simples como pontos ou sprites, mas também formas 3D como cubos, esferas e geometria personalizada.

Vamos criar um componente em uma nova pasta vfxs chamada 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>
    </>
  );
};

Criamos a geometry que será usada para cada partícula. Neste caso, usamos uma geometria plana simples com um tamanho de 0.5 em ambos os eixos. Mais tarde, adicionaremos uma prop para passar qualquer geometria que desejarmos.

O componente instancedMesh aceita três argumentos:

  • A geometry das partículas.
  • O material das partículas. Passamos null para defini-lo declarativamente dentro do componente.
  • O número de instances que o componente será capaz de manipular. Para nós, representa o número máximo de partículas que podem ser exibidas ao mesmo tempo.

Vamos substituir o cubo laranja pelo nosso componente VFXParticles no arquivo Experience.jsx:

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

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

Partícula laranja no meio da cena

Você pode ver uma partícula laranja no meio da cena. Este é o nosso componente VFXParticles.

Nosso número de partículas está definido como 1000, mas só conseguimos ver uma. Isso ocorre porque todas são renderizadas na mesma posição (0, 0, 0). Vamos mudar isso.

Instância de Matriz

A mesh instanciada utiliza uma matriz para definir a posição, rotação e escala de cada instância. Ao atualizar a propriedade instanceMatrix de nossa mesh, podemos mover, rotacionar e escalar cada partícula individualmente.

Para cada instância, a matriz é uma matriz 4x4 que representa a transformação da partícula. A classe Matrix4 do Three.js nos permite compose e decompose a matriz para definir/obter a posição, rotação e escala da partícula de uma forma mais fácil de entender.

No topo da declaração VFXParticles, vamos declarar algumas variáveis auxiliares para manipular as partículas sem recriar Vectors e Matrices frequentemente:

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

Agora vamos criar uma função emit para configurar nossas partículas:

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

A função emit faz um loop sobre o número de partículas que queremos emitir e define uma posição, rotação e escala aleatórias para cada partícula. Em seguida, compomos a matriz com esses valores e a atribuímos à instância no índice atual.

Partículas aleatórias na cena

Você pode ver partículas aleatórias na cena. Cada partícula tem uma posição, rotação e escala aleatórias.

Para animar nossas partículas, definiremos atributos como lifetime, speed, direction para que o cálculo possa ser feito na GPU.

Antes de fazer isso, precisamos trocar para um material de shader personalizado para lidar com esses atributos, já que não temos acesso e controle sobre os atributos do meshBasicMaterial.

Material de Partículas

Nosso primeiro objetivo será não ver nenhuma alteração entre o meshBasicMaterial e nosso novo shaderMaterial. Vamos criar um material de shader simples que renderizará as partículas da mesma forma que o meshBasicMaterial faz atualmente.

No componente VFXParticles, vamos criar um novo material de 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 });

Este é um material de shader muito simples que recebe uma uniforme color e renderiza as partículas com esta cor. A única novidade aqui é o instanceMatrix que usamos para obter a position, rotation e scale de cada partícula.

Note que não precisávamos declarar o atributo instanceMatrix, pois este é um dos atributos internos do WebGLProgram quando usamos instancing. Mais informações podem ser encontradas aqui.

Vamos substituir o meshBasicMaterial pelo nosso novo ParticlesMaterial:

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

Partículas aleatórias na cena com uma cor laranja

Perfeito! Nossa position, rotation e scale ainda estão funcionando como esperado. As partículas são renderizadas com uma cor laranja ligeiramente diferente. Isso ocorre porque não levamos o environment em consideração no nosso material de shader. Para simplificar, manteremos assim.

Agora estamos prontos para adicionar atributos personalizados às nossas partículas para animá-las.

Instanced Buffer Attributes

Até agora, só usamos o atributo instanceMatrix. Agora, vamos adicionar atributos personalizados para ter mais controle sobre cada partícula.

Para isso, utilizaremos o InstancedBufferAttribute do Three.js.

Adicionaremos os seguintes atributos às nossas partículas:

  • instanceColor: Um vector3 representando a cor da partícula.
  • instanceColorEnd: Um vector3 representando a cor para a qual a partícula vai se transformar ao longo do tempo.
  • instanceDirection: Um vector3 representando a direção na qual a partícula se moverá.
  • instanceSpeed: Um float para definir a velocidade com a qual a partícula se moverá em sua direção.
  • instanceRotationSpeed: Um vector3 para determinar a velocidade de rotação da partícula por eixo.
  • instanceLifetime: Um vector2 para definir o tempo de vida da partícula. O primeiro valor (x) é a hora de início, e o segundo valor (y) é o tempo de vida/duração. Combinado com uma uniform de tempo, podemos calcular a idade, progresso, e se uma partícula está viva ou morta.

Vamos criar os diferentes buffers para nossos atributos:

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

  // ...
};

// ...

Estou usando um useState para criar os diferentes buffers dos nossos atributos para evitar recriá-los a cada renderização. Escolhi não usar o hook useMemo pois mudar o número máximo de partículas durante o ciclo de vida do componente não é algo que queremos lidar.

O Float32Array é usado para armazenar os valores dos atributos. Multiplicamos o número de partículas pelo número de componentes do atributo para obter o número total de valores no array.

Esquema explicando o atributo instanceColor

No atributo instanceColor, os primeiros 3 valores representarão a cor da primeira partícula, os próximos 3 valores representarão a cor da segunda partícula, e assim por diante.

Vamos começar nos familiarizando com o InstancedBufferAttribute e como usá-lo. Para isso, implementaremos o atributo 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>
    </>
  );
};

No nosso <instancedMesh />, adicionamos um componente <instancedBufferAttribute /> para definir o atributo instanceColor. Nós o anexamos ao atributo geometry-attributes-instanceColor da mesh. Passamos o array attributeArrays.instanceColor como a fonte de dados, configuramos o itemSize para 3 pois temos um vector3, e o count para nbParticles.

A prop usage está configurada para DynamicDrawUsage para informar ao renderizador que os dados serão atualizados frequentemente. Os outros valores possíveis e mais detalhes podem ser encontrados aqui.

Não os atualizaremos a cada frame, mas toda vez que emitirmos novas partículas, os dados serão atualizados. O suficiente para considerá-lo como DynamicDrawUsage.

Perfeito, vamos criar uma variável dummy tmpColor no topo do nosso arquivo para manipular as cores das partículas:

// ...

const tmpColor = new Color();

Agora vamos atualizar a função emit para definir o atributo 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);
  }
};

Começamos obtendo o atributo instanceColor da geometria da mesh. Em seguida, percorremos o número de partículas que queremos emitir e definimos uma cor aleatória para cada partícula.

Vamos atualizar o particlesMaterial para usar o atributo instanceColor em vez do uniform color:

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

Adicionamos um attribute vec3 instanceColor; ao vertex shader e configuramos o varying vColor para passar a cor ao fragment shader. Em seguida, definimos o gl_FragColor como vColor para renderizar as partículas com sua cor.

Partículas aleatórias na cena com cores aleatórias

Definimos com sucesso uma cor aleatória para cada partícula. As partículas são renderizadas com suas cores.

Perfeito, vamos adicionar os outros atributos às nossas partículas. Primeiro, vamos atualizar nossa função emit para definir os atributos instanceColorEnd, instanceDirection, instanceLifetime, instanceSpeed, e instanceRotationSpeed com valores aleatórios:

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 crie os componentes instancedBufferAttribute para cada atributo:

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

Agora é hora de dar vida às nossas partículas, implementando sua lógica de movimento, cor e tempo de vida.

Tempo de vida das partículas

Para calcular o comportamento das nossas partículas, precisamos passar o tempo decorrido para o nosso shader. Usaremos a prop uniforms do shaderMaterial para passar o tempo para ele.

Vamos atualizar nosso ParticlesMaterial para adicionar um uniforme uTime:

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

E em um loop useFrame, atualizaremos o 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;
  });

  // ...
};

No vertex shader, calcularemos a idade e o progresso de cada partícula com base no uniforme uTime e no atributo instanceLifetime. Passaremos o progresso para o fragment shader para animar as partículas usando um varying chamado 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;
}

A idade é calculada subtraindo o startTime do uTime. O progresso é calculado dividindo a idade pela duration.

Agora no fragment shader, interpolaremos a cor das partículas entre o instanceColor e instanceColorEnd com base no 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);
}

Partículas mudando de cor ao longo do tempo

Podemos ver as partículas mudando de cor ao longo do tempo, mas estamos enfrentando um problema. Todas as partículas são visíveis no início enquanto seu tempo de início é aleatório. Precisamos ocultar as partículas que ainda não estão ativas.

Para impedir que partículas não nascidas e mortas sejam renderizadas, usaremos a palavra-chave discard no fragment shader:

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

A palavra-chave discard informa ao renderizador para descartar o fragmento atual e não renderizá-lo.

Perfeito, nossas partículas agora nascem, vivem e morrem ao longo do tempo. Podemos agora adicionar a lógica de movimento e rotação.

Movimento de Partículas

Usando a direção, velocidade e idade das partículas, podemos calcular sua posição ao longo do tempo.

No vertex shader, vamos ajustar o gl_Position para levar em consideração a direção e velocidade das partículas.

Primeiro, normalizamos a direção para evitar que as partículas se movam mais rápido quando a direção não é um vetor unitário:

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

Em seguida, calculamos o offset da partícula com base na velocidade e idade:

vec3 offset = normalizedDirection * age * instanceSpeed;

Vamos obter a posição da instância:

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

E aplicar o offset a ela:

vec3 finalPosition = instancePosition + offset;

Finalmente, obtemos a posição da exibição do modelo mvPosition aplicando a modelViewMatrix à finalPosition para transformar a posição para o espaço mundial:

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

E aplicamos a projectionMatrix para transformar a posição mundial para o espaço da câmera:

gl_Position = projectionMatrix * mvPosition;

Aqui está nosso vertex shader completo até agora:

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

As partículas agora estão se movendo em várias direções em diferentes velocidades. Isso é caótico, mas é por causa dos nossos valores aleatórios.

Vamos corrigir isso ajustando nossos valores aleatórios na função emit para ter uma visão mais clara do movimento das partículas:

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

Começando a ganhar forma!

Adicionaremos controles de UI simples para ajustar as variáveis mais tarde. Agora, vamos finalizar as partículas adicionando a lógica de rotação.

Enquanto separamos a direção e a velocidade para o movimento, para a rotação, usaremos um único atributo instanceRotationSpeed para definir a velocidade de rotação por eixo.

No vertex shader, podemos calcular a rotação da partícula com base na velocidade de rotação e idade:

vec3 rotationSpeed = instanceRotationSpeed * age;

Então, para poder aplicar essa "offset rotation" à partícula, precisamos convertê-la em uma matriz de rotação:

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

rotationX, rotationY e rotationZ são funções que retornam uma matriz de rotação em torno do eixo X, Y e Z, respectivamente. Vamos defini-las sobre a função main no 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
  );
}

Para saber mais sobre matrizes de rotação, você pode conferir este artigo na Wikipédia, o incrível Game Math Explained Simply por Simon Dev, ou a Seção de Matrizes do The Book of Shaders.

Finalmente, podemos aplicar a matriz de rotação à posição inicial da partícula:

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

Vamos experimentar:

As partículas agora estão se movendo, mudando de cor e girando! ✨

Perfeito, temos uma base sólida para nosso VFX Engine. Antes de adicionar mais recursos e controles, vamos preparar uma segunda parte importante do motor: o emissor.

Emissores

Em exemplos e tutoriais, é frequentemente uma parte negligenciada do sistema de partículas. Porém, é uma parte crucial para integrar partículas em seus projetos de forma fácil e eficiente:

  • Facilmente porque seu componente <VFXParticles /> estará no topo de sua hierarquia e seu emissor pode gerar partículas a partir de qualquer subcomponente em sua cena. Tornando fácil gerá-las de um ponto específico, anexá-las a um objeto em movimento ou a um osso em movimento.
  • Efetivamente porque, em vez de recriar instanced meshes, compilar shader materials e ajustar attributes toda vez que deseja gerar partículas, você pode reutilizar o mesmo componente VFXParticles e simplesmente chamar uma função para gerar partículas com as configurações desejadas.

useVFX

Queremos ser capazes de chamar a função emit de nosso componente VFXParticles de qualquer lugar em nosso projeto. Para isso, criaremos um hook customizado chamado useVFX que cuidará do registro e cancelamento de registro dos emissores do componente VFXParticles.

Usaremos Zustand, pois é uma maneira simples e eficiente de gerenciar estado global no React com ótimo desempenho.

Vamos adicioná-lo ao nosso projeto:

yarn add zustand

Em nossa pasta vfxs, vamos criar um arquivo VFXStore.js:

import { create } from "zustand";

export const useVFX = create((set, get) => ({
  emitters: {},
  registerEmitter: (name, emitter) => {
    if (get().emitters[name]) {
      console.warn(`Emitter ${name} já existe`);
      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} não encontrado`);
      return;
    }
    emitter(...params);
  },
}));

O que ele contém:

  • emitters: Um objeto que armazenará todos os emissores dos nossos componentes VFXParticles.
  • registerEmitter: Uma função para registrar um emissor com um nome específico.
  • unregisterEmitter: Uma função para cancelar o registro de um emissor com um nome específico.
  • emit: Uma função para chamar o emissor com um nome e parâmetros especificados a partir de qualquer lugar em nosso projeto.

Vamos conectá-lo ao nosso 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);
    };
  }, []);
  // ...
};

// ...

Adicionamos uma prop name ao nosso componente VFXParticles para identificar o emissor. Em seguida, utilizamos o hook useVFX para obter as funções registerEmitter e unregisterEmitter.

Chamamos registerEmitter com o name e a função emit dentro do hook useEffect para registrar o emissor quando o componente for montado e cancelar o registro quando ele for desmontado.

No componente Experience, vamos adicionar a prop name ao nosso componente VFXParticles:

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

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

VFXEmitter

Agora que temos nosso hook useVFX, podemos criar um componente VFXEmitter que será responsável por gerar partículas a partir de nosso componente VFXParticles.

Na pasta vfxs, vamos criar um arquivo 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.