Motor VFX
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 /> </> ); };
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.
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 doWebGLProgram
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>
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.
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.
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); }
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} /> </> ); } );
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
One-time payment. Lifetime updates included.