VFX 엔진
지금까지 우리는 3D 장면에서 입자를 생성하기 위해 커스텀 컴포넌트를 만들어왔습니다. 대부분의 경우, 우리는 거의 같은 작업을 원합니다: 공간에서 한 점에서 입자를 방출하고 시간에 따라 이를 애니메이션 처리하는 것입니다. (색상, 크기, 위치 등)
중복된 코드를 계속 작성하는 대신, 다양한 유형의 입자 효과를 생성할 수 있는 비교적 일반적인 VFX 엔진을 만들 수 있습니다.
이 방식은 여러 가지 이점을 제공합니다:
- 재사용성: 동일한 엔진을 사용하여 프로젝트에서 다양한 유형의 입자 효과를 생성할 수 있습니다.
- 성능: 엔진은 대량의 입자를 효율적으로 처리하고 여러 파티클 시스템을 하나로 통합하도록 최적화할 수 있습니다.
- 유연성: 엔진의 매개변수를 변경하여 입자의 동작을 쉽게 사용자 정의할 수 있습니다.
- 사용 편의성: 몇 줄의 코드로 복잡한 입자 효과를 생성할 수 있습니다.
- 코드 중복 방지: 동일한 코드를 여러 번 작성할 필요가 없습니다.
다음 강의에서 이 VFX 엔진을 사용하여 다양한 효과를 생성할 것입니다. 이 수업을 건너뛰고 엔진을 직접 사용할 수 있지만, 엔진이 작동하는 방식을 이해하면 3D 프로젝트에서 성능과 유연성을 마스터하는 데 보다 깊이 있는 이해를 도울 것입니다.
준비되셨나요? VFX 엔진을 시작해봅시다!
GPU 입자
이전 강의에서 how we can use <Instances />
를 이용하여 drei에서 3D 장면에 제어된 입자를 만드는 방법을 살펴보았습니다.
그러나 이 접근 방식에는 한 가지 주요 한계가 있습니다: 우리가 처리할 수 있는 입자 수가 CPU에 의해 제한됩니다. 입자의 수가 많을수록 CPU가 이를 처리해야 하여 성능 문제를 일으킬 수 있습니다.
이는 <Instances />
컴포넌트가 내부적으로 useFrame
루프에서 각 <Instance />
의 위치, 색상 및 크기를 계산하여 발생합니다. 코드를 여기에서 확인할 수 있습니다.
우리의 VFX 엔진을 위해, <Instances />
컴포넌트로 처리할 수 있는 것보다 훨씬 더 많은 입자를 생성할 수 있기를 원합니다. 우리는 GPU를 사용하여 입자의 위치, 색상 및 크기를 처리합니다. 이를 통해 수십만 개의 입자 (수백만 개? 👀) 를 성능 문제 없이 처리할 수 있습니다.
인스턴스 메시
입자 생성에 Sprite나 Points를 사용할 수 있지만, 우리는 InstancedMesh를 사용할 것입니다.
이것은 점이나 스프라이트 같은 단순한 형태뿐만 아니라 큐브, 구 및 사용자 정의 기하학적 형태 등의 3D 형태도 렌더링할 수 있게 해줍니다.
VFXParticles.jsx
라는 이름의 컴포넌트를 새 vfxs
폴더에 만들어 봅시다:
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> </> ); };
우리는 각 입자에 사용될 geometry를 생성합니다. 이 경우, 크기가 0.5
인 간단한 평면 기하학을 사용합니다. 나중에 원하는 기하학을 전달할 수 있는 prop을 추가할 것입니다.
instancedMesh
컴포넌트는 세 개의 인수를 받습니다:
- 입자의 geometry.
- 입자의 material. 컴포넌트 내에서 선언적으로 정의하기 위해
null
을 전달했습니다. - 컴포넌트가 처리할 수 있는 인스턴스의 수. 이는 한 번에 표시될 수 있는 최대 입자 수를 나타냅니다.
Experience.jsx
파일의 오렌지 큐브를 VFXParticles
컴포넌트로 교체해 봅시다:
// ... import { VFXParticles } from "./vfxs/VFXParticles"; export const Experience = () => { return ( <> {/* ... */} <VFXParticles /> </> ); };
장면 중앙에 하나의 오렌지 입자가 보입니다. 이것이 우리의 VFXParticles
컴포넌트입니다.
입자 수는 1000
으로 설정되어 있지만 하나만 보입니다. 이는 모든 입자가 동일한 위치 (0, 0, 0)
에 렌더링되기 때문입니다. 이를 변경해 보겠습니다.
인스턴스 매트릭스
인스턴스된 mesh는 각 인스턴스의 위치, 회전 및 크기를 정의하기 위해 매트릭스를 사용합니다. mesh의 instanceMatrix 속성을 업데이트함으로써, 각 입자를 개별적으로 이동, 회전 및 크기를 조정할 수 있습니다.
각 인스턴스에 대해, 매트릭스는 입자의 변환을 나타내는 4x4 매트릭스입니다. Three.js의 Matrix4 클래스는 입자의 위치, 회전 및 크기를 보다 인간이 읽기 쉽게 compose
하고 decompose
하는 기능을 제공합니다.
VFXParticles
선언 상단에, 벡터와 매트릭스를 자주 재생성하지 않고 입자를 조작하기 위한 더미 변수를 선언해 봅시다:
// ... 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();
이제 입자를 설정하기 위한 emit
함수를 만들어 봅시다:
// ... 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); }, []); // ... };
emit
함수는 방출하려는 입자의 수를 순회하며 각 입자에 대해 무작위 위치, 회전 및 크기를 설정합니다. 그런 다음 이 값들로 매트릭스를 구성하고 현재 인덱스의 인스턴스에 설정합니다.
장면 내 무작위 입자를 볼 수 있습니다. 각 입자는 무작위 위치, 회전 및 크기를 가지고 있습니다.
입자를 애니메이션화하기 위해 lifetime, speed, direction과 같은 속성을 정의하여 GPU에서 계산할 수 있게 할 것입니다.
그 전에, 우리는 meshBasicMaterial
의 속성에 접근하고 제어할 수 없기 때문에 이러한 속성을 처리하기 위해 사용자 정의 셰이더 material로 전환해야 합니다.
Particles Material
우리의 첫 번째 목표는 meshBasicMaterial
과 새로운 shaderMaterial
사이에 변화를 주지 않는 것입니다. 간단한 셰이더 재질을 만들어 현재 meshBasicMaterial
과 동일한 방식으로 입자를 렌더링할 것입니다.
VFXParticles
컴포넌트에서 새로운 셰이더 재질을 만들어 봅시다:
// ... 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 });
이것은 color
유니폼을 받아 이 색상으로 입자를 렌더링하는 매우 간단한 셰이더 재질입니다. 여기서 새롭게 등장한 것은 입자마다 position
, rotation
, scale
을 가져올 때 사용하는 instanceMatrix
입니다.
instancing을 사용할 때
WebGLProgram
의 내장 속성 중 하나인instanceMatrix
속성을 선언할 필요가 없다는 점에 유의하십시오. 더 많은 정보는 여기에서 찾을 수 있습니다.
다음은 meshBasicMaterial
을 새 ParticlesMaterial
로 대체하는 코드입니다:
<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}> <particlesMaterial color="orange" /> </instancedMesh>
완벽합니다! position
, rotation
, scale
은 여전히 정상 작동합니다. 입자는 약간 다른 오렌지색으로 렌더링됩니다. 이는 셰이더 재질에서 environment를 고려하지 않았기 때문입니다. 간단하게 유지하기 위해 이렇게 둘 것입니다.
이제 입자에 애니메이션을 적용하기 위한 커스텀 속성을 추가할 준비가 되었습니다.
인스턴스 버퍼 속성
지금까지 우리는 instanceMatrix
속성만 사용했으며, 이제 각 파티클을 더 잘 제어하기 위해 사용자 지정 속성을 추가할 것입니다.
이를 위해 Three.js의 InstancedBufferAttribute를 사용할 것입니다.
파티클에 다음 속성을 추가할 것입니다:
instanceColor
: 파티클의 색상을 나타내는 vector3.instanceColorEnd
: 시간이 지남에 따라 변화할 색상을 나타내는 vector3.instanceDirection
: 파티클이 이동할 방향을 나타내는 vector3.instanceSpeed
: 파티클이 자신의 방향으로 얼마나 빠르게 이동할지 정의하는 float.instanceRotationSpeed
: 각 축에 대한 파티클의 회전 속도를 결정하는 vector3.instanceLifetime
: 파티클의 수명을 정의하는 vector2. 첫 번째 값(x
)은 시작 시간이고, 두 번째 값(y
)은 수명/지속 시간입니다. 시간을 나타내는 uniform과 결합하여 파티클의 나이, 진행 상황, 생존 상태 여부를 계산할 수 있습니다.
다음은 속성을 위한 서로 다른 버퍼를 생성하는 단계입니다:
// ... 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), }); // ... }; // ...
속성을 위한 다양한 버퍼를 생성하기 위해 useState
를 사용했고, 매 렌더링마다 다시 생성하지 않도록 했습니다. 컴포넌트의 생명 주기 동안 파티클의 최대 수를 변경하는 것은 우리가 처리하고자 하는 사항은 아니기 때문에 useMemo
훅은 사용하지 않았습니다.
Float32Array
는 속성의 값을 저장하는 데 사용됩니다. 속성의 구성 요소 수에 파티클 수를 곱해 배열의 총 값 수를 얻습니다.
instanceColor
속성에서는 처음 3개의 값이 첫 번째 파티클의 색상을 나타내고, 다음 3개의 값이 두 번째 파티클의 색상을 나타냅니다.
InstancedBufferAttribute
와 이를 사용하는 방법에 대해 익숙해지도록 하겠습니다. 이를 위해 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> </> ); };
<instancedMesh />
에 instanceColor
속성을 정의하기 위해 <instancedBufferAttribute />
컴포넌트를 추가합니다. mesh의 geometry-attributes-instanceColor
속성에 이를 연결합니다. 데이터 소스로 attributeArrays.instanceColor
배열을 전달하고, itemSize
를 vector3이므로 3
으로 설정하고, count
를 nbParticles
로 설정합니다.
usage
속성은 렌더러에게 데이터가 자주 업데이트될 것임을 알리기 위해 DynamicDrawUsage
로 설정됩니다. 다른 가능한 값과 자세한 내용은 여기에서 확인할 수 있습니다.
매 프레임 업데이트를 할 계획은 아니지만, 새로운 파티클이 방출될 때마다 데이터가 업데이트될 것이므로 DynamicDrawUsage
로 간주됩니다.
이제 파일 상단에 더미 tmpColor
변수를 생성하여 파티클의 색상을 조작합시다:
// ... const tmpColor = new Color();
이제 emit
함수를 업데이트하여 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); } };
mesh의 geometry에서 instanceColor
속성을 가져오면서 시작합니다. 그런 다음 방출을 원하는 파티클 수만큼 반복하여 각 파티클에 랜덤 색상을 설정합니다.
particlesMaterial
을 업데이트하여 색상 uniform 대신 instanceColor
속성을 사용하도록 합시다:
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); }` ); // ...
vertex shader에 attribute vec3 instanceColor;
를 추가하고, vColor
varying을 설정하여 fragment shader로 색상을 전달합니다. 그런 다음 파티클의 색상으로 gl_FragColor
를 설정합니다.
각 파티클에 랜덤 색상을 성공적으로 설정했습니다. 파티클은 자신만의 색상으로 렌더링됩니다.
이제 다른 속성을 파티클에 추가해 봅시다. 먼저 emit
함수를 업데이트하여 instanceColorEnd
, instanceDirection
, instanceLifetime
, instanceSpeed
, instanceRotationSpeed
속성을 랜덤 값으로 설정합니다:
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); } };
각 속성을 위한 instancedBufferAttribute
컴포넌트를 생성합니다:
<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>
이제 파티클의 움직임, 색상 및 수명 로직을 구현하여 파티클에 생명을 불어넣을 차례입니다.
입자의 생명 주기
입자의 동작을 계산하려면 경과 시간을 셰이더에 전달해야 합니다. shaderMaterial
의 uniforms
속성을 사용하여 시간을 전달하겠습니다.
ParticlesMaterial
에 uTime
uniform을 추가하도록 업데이트하겠습니다:
const ParticlesMaterial = shaderMaterial( { uTime: 0, }, /* glsl */ ` uniform float uTime; // ... `, /* glsl */ ` // ... ` );
그리고 useFrame
루프 내에서 uTime
uniform을 업데이트할 것입니다:
// ... import { useFrame } from "@react-three/fiber"; // ... export const VFXParticles = ({ settings = {} }) => { // ... useFrame(({ clock }) => { if (!mesh.current) { return; } mesh.current.material.uniforms.uTime.value = clock.elapsedTime; }); // ... };
vertex shader에서 각 입자의 age와 progress를 uTime
uniform과 instanceLifetime
attribute를 기반으로 계산하겠습니다. progress를 fragment shader에 전달하여 vProgress
라는 varying을 사용하여 입자에 애니메이션을 추가할 것입니다.
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; }
age는 uTime
에서 startTime
을 빼서 계산됩니다. progress는 age를 duration
으로 나누어 계산됩니다.
이제 fragment shader에서 progress를 기반으로 입자의 색상을 instanceColor
와 instanceColorEnd
사이에서 보간하겠습니다:
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); }
입자의 색상이 시간이 지남에 따라 변하는 것을 볼 수 있지만, 문제점이 있습니다. 모든 입자가 시작 시간은 무작위인 것에 비해 처음에는 보입니다. 아직 살아 있지 않은 입자를 숨겨야 합니다.
태어나지 않은 입자와 죽은 입자가 렌더링되지 않도록 하기 위해 fragment shader에서 discard
키워드를 사용합니다:
// ... void main() { if (vProgress < 0.0 || vProgress > 1.0) { discard; } // ... }
discard
키워드는 현재 fragment를 버리고 렌더링하지 않도록 렌더러에 지시합니다.
완벽합니다. 이제 입자는 태어나고, 살고, 시간이 지남에 따라 죽습니다. 이제 움직임과 회전 논리를 추가할 수 있습니다.
입자의 움직임
방향, 속도, 나이를 사용하여 시간이 지남에 따라 입자의 위치를 계산할 수 있습니다.
vertex shader에서 gl_Position
을 조정하여 입자의 방향 및 속도를 고려해 봅시다.
먼저 방향을 정규화하여 방향이 단위 벡터가 아닐 때 입자가 더 빠르게 움직이는 것을 방지합니다:
vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0);
그런 다음 속도와 나이를 기반으로 입자의 offset을 계산합니다:
vec3 offset = normalizedDirection * age * instanceSpeed;
instance position을 가져옵니다:
vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0); vec3 instancePosition = startPosition.xyz;
그리고 offset을 적용합니다:
vec3 finalPosition = instancePosition + offset;
마지막으로 최종 위치에 modelViewMatrix를 적용하여 world space로 변환합니다:
vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0);
그리고 projectionMatrix를 적용하여 camera space로 변환합니다:
gl_Position = projectionMatrix * mvPosition;
지금까지의 vertex shader는 다음과 같습니다:
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; }
입자가 이제 다양한 방향으로 다양한 속도로 움직이고 있습니다. 이것은 무작위 값 때문에 혼란스러워 보일 수 있습니다.
emit
함수의 무작위 값을 조정하여 입자의 움직임을 더 명확히 파악해 보겠습니다:
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); }
모양을 갖추기 시작합니다!
나중에 간단한 UI controls을 추가하여 변수를 조정할 수 있게 할 것입니다. 이제 회전 로직을 추가하여 입자를 마무리하도록 합시다.
우리는 움직임을 위해 방향과 속도를 분리했지만, 회전을 위해서는 각 축에 대한 회전 속도를 정의하기 위해 단일 instanceRotationSpeed
속성을 사용할 것입니다.
vertex shader에서 회전 속도와 나이를 기반으로 입자의 회전을 계산할 수 있습니다:
vec3 rotationSpeed = instanceRotationSpeed * age;
그런 다음 이 "offset 회전"을 입자에 적용하려면 이를 회전 행렬로 변환해야 합니다:
mat4 rotX = rotationX(rotationSpeed.x); mat4 rotY = rotationY(rotationSpeed.y); mat4 rotZ = rotationZ(rotationSpeed.z); mat4 rotationMatrix = rotZ * rotY * rotX;
rotationX
, rotationY
및 rotationZ
는 각각 X, Y 및 Z 축을 중심으로 회전 행렬을 반환하는 함수입니다. 이를 vertex shader의 main
함수에서 정의할 것입니다:
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 ); }
회전 행렬에 대해 더 알아보려면 이 Wikipedia article, Simon Dev의 놀라운 Game Math Explained Simply 또는 The Book of Shaders의 Matrix section을 참조하십시오.
마지막으로 입자의 초기 위치에 회전 행렬을 적용할 수 있습니다:
vec4 startPosition = modelMatrix * instanceMatrix * rotationMatrix * vec4(position, 1.0);
시도해 봅시다:
입자가 이제 움직이고, 색상이 변하고, 회전하고 있습니다! ✨
완벽합니다. VFX 엔진의 확실한 기초를 마련했습니다. 더 많은 기능과 컨트롤을 추가하기 전에 엔진의 두 번째 중요한 부분인 emitter를 준비합시다.
방사체
예제와 튜토리얼에서는 입자 시스템의 이 부분이 종종 간과됩니다. 하지만 입자를 프로젝트에 쉽게 그리고 효율적으로 통합하는 것은 매우 중요합니다:
쉽게
라는 것은<VFXParticles />
컴포넌트가 계층 구조의 맨 위에 있으며, emitter가 씬 내의 하위 컴포넌트 어디에서나 입자를 생성할 수 있다는 것입니다. 그렇게 함으로써 특정 지점에서 입자를 생성하거나 움직이는 물체나 움직이는 뼈에 부착하기 쉽게 해줍니다.효율적으로
라는 것은 입자를 생성할 때마다 instanced meshes를 재생성, shader materials를 컴파일, 속성을 설정하는 대신 동일한 VFXParticles 컴포넌트를 재사용하고 입자를 원하는 설정으로 생성하는 함수를 호출할 수 있기 때문입니다.
useVFX
프로젝트의 어디에서든지 VFXParticles
컴포넌트의 emit
함수를 호출할 수 있기를 원합니다. 이를 위해 VFXParticles 컴포넌트에서 emitters를 등록하고 등록 해제하는 작업을 담당하는 useVFX
라는 커스텀 훅을 만들 것입니다.
Zustand를 사용할 것입니다. 이는 높은 성능과 함께 React에서 전역 상태를 관리하는 간단하고 효율적인 방법입니다.
프로젝트에 추가해 보겠습니다:
yarn add zustand
vfxs
폴더에서 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); }, }));
이 파일에는 다음과 같은 내용이 포함됩니다:
- emitters: 우리의
VFXParticles
컴포넌트에서 모든 방사체를 저장할 객체입니다. - registerEmitter: 주어진 이름으로 방사체를 등록하는 함수입니다.
- unregisterEmitter: 주어진 이름으로 방사체 등록을 해제하는 함수입니다.
- emit: 프로젝트 내 어디에서든 주어진 이름과 매개변수로 방사체를 호출하는 함수입니다.
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); }; }, []); // ... }; // ...
VFXParticles
컴포넌트에 방사체를 식별하기 위한 name
prop을 추가하고, useVFX
훅을 사용하여 registerEmitter
와 unregisterEmitter
함수를 받습니다.
컴포넌트가 마운트될 때 name
과 emit
함수와 함께 registerEmitter
를 호출하여 방사체를 등록하고, 언마운트될 때 등록을 해제합니다.
Experience
컴포넌트에서 VFXParticles
컴포넌트에 name
prop을 추가해 보겠습니다:
// ... import { VFXParticles } from "./vfxs/VFXParticles"; export const Experience = () => { return ( <> {/* ... */} <VFXParticles name="sparks" /> </> ); };
VFXEmitter
이제 useVFX
hook을 만들었으니, VFXParticles
컴포넌트에서 파티클을 생성하는 역할을 담당할 VFXEmitter
컴포넌트를 생성해 보겠습니다.
vfxs
폴더에 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.