VFX Engine
Cho đến nay, chúng ta đã tạo ra các thành phần tùy chỉnh để tạo ra các hạt trong các cảnh 3D của mình. Hầu hết thời gian, chúng ta muốn làm gần như cùng một điều: phát ra các hạt từ một điểm trong không gian và làm chúng động qua thời gian. (Màu sắc, kích thước, vị trí, v.v.)
Thay vì sao chép cùng một mã lặp đi lặp lại, chúng ta có thể tạo ra một engine VFX tương đối chung có thể được sử dụng để tạo ra các loại hiệu ứng hạt khác nhau.
Nó đi kèm với nhiều lợi ích:
- Tái sử dụng: Bạn có thể sử dụng cùng một engine để tạo ra các loại hiệu ứng hạt khác nhau trong các dự án của mình.
- Hiệu suất: Engine có thể được tối ưu hóa để xử lý một số lượng lớn các hạt một cách hiệu quả và gộp nhiều hệ thống hạt vào một.
- Tính linh hoạt: Bạn có thể dễ dàng tùy chỉnh hành vi của các hạt bằng cách thay đổi các thông số của engine.
- Dễ sử dụng: Bạn có thể tạo ra các hiệu ứng hạt phức tạp chỉ với một vài dòng mã.
- Tránh trùng lặp mã: Bạn không cần phải viết lại cùng một mã nhiều lần.
Chúng tôi sẽ sử dụng engine VFX này trong các bài học tiếp theo để tạo ra các hiệu ứng khác nhau. Mặc dù bạn có thể bỏ qua bài học này và sử dụng engine trực tiếp, nhưng việc hiểu cách nó hoạt động sẽ giúp bạn hiểu sâu hơn cách nắm vững hiệu suất và tính linh hoạt trong các dự án 3D của bạn.
Sẵn sàng để xây dựng engine VFX của bạn chưa? Hãy bắt đầu nào!
GPU Particles
Chúng ta đã thấy trong các bài học trước cách chúng ta có thể sử dụng thành phần <Instances />
từ drei để tạo ra các hạt được kiểm soát trong các cảnh 3D của mình.
Nhưng sự tiếp cận này có một hạn chế chính: số lượng hạt mà chúng ta có thể xử lý bị giới hạn bởi CPU. Càng nhiều hạt chúng ta có, CPU càng phải xử lý chúng, điều này có thể dẫn đến các vấn đề về hiệu suất.
Điều này là do dưới lớp, thành phần <Instances />
thực hiện các phép tính để lấy vị trí, màu sắc và kích thước của từng <Instance />
trong vòng lặp useFrame
của nó. Bạn có thể xem mã tại đây.
Đối với VFX Engine của chúng ta, chúng ta muốn có thể phát sinh nhiều hạt hơn nhiều so với khả năng chúng ta có thể xử lý với thành phần <Instances />
. Chúng ta sẽ sử dụng GPU để xử lý vị trí, màu sắc, và kích thước của các hạt. Cho phép chúng ta xử lý hàng trăm ngàn hạt (hàng triệu? 👀) mà không gặp vấn đề về hiệu suất.
Instanced Mesh
Mặc dù chúng ta có thể sử dụng Sprite hoặc Points để tạo hạt, chúng ta sẽ sử dụng InstancedMesh.
Nó cho phép chúng ta render không chỉ các hình dạng đơn giản như điểm hoặc sprite mà còn cả các hình dạng 3D như hình lập phương, hình cầu và các hình học tùy chỉnh.
Hãy tạo một component trong thư mục vfxs
mới được gọi là 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> </> ); };
Chúng ta tạo ra geometry sẽ được sử dụng cho mỗi hạt. Trong trường hợp này, chúng ta sử dụng một hình học mặt phẳng đơn giản với kích thước 0.5
trên cả hai trục. Sau này chúng ta sẽ thêm một prop để truyền bất kỳ geometry nào chúng ta muốn.
Component instancedMesh
nhận ba tham số:
- geometry của các hạt.
- material của các hạt. Chúng ta đã truyền
null
để định nghĩa nó một cách khai báo bên trong component. - Số lượng instances mà component có thể xử lý. Đối với chúng ta, nó đại diện cho số lượng hạt tối đa có thể được hiển thị cùng một lúc.
Hãy thay thế khối lập phương màu cam bằng component VFXParticles
trong tập tin Experience.jsx
:
// ... import { VFXParticles } from "./vfxs/VFXParticles"; export const Experience = () => { return ( <> {/* ... */} <VFXParticles /> </> ); };
Bạn có thể thấy một hạt màu cam ở giữa cảnh. Đây là component VFXParticles
của chúng ta.
Số lượng hạt của chúng ta được đặt là 1000
nhưng chúng ta chỉ có thể thấy một. Điều này là do tất cả đều được render tại cùng vị trí (0, 0, 0)
. Hãy thay đổi điều đó.
Ma trận Instance
Instanced mesh sử dụng ma trận để xác định vị trí, xoay và tỷ lệ của mỗi instance. Bằng cách cập nhật thuộc tính instanceMatrix của mesh của chúng ta, chúng ta có thể di chuyển, xoay và thay đổi kích thước từng hạt một cách riêng lẻ.
Đối với mỗi instance, ma trận là một ma trận 4x4 đại diện cho phép biến đổi của hạt. Lớp Matrix4 từ Three.js cho phép chúng ta compose
và decompose
ma trận để thiết lập/lấy vị trí, xoay và tỷ lệ của hạt theo cách dễ đọc hơn đối với con người.
Ngay trên phần khai báo VFXParticles
, hãy khai báo một số biến giả để thao tác với hạt mà không cần tạo lại Vectors và Matrices quá thường xuyên:
// ... 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();
Bây giờ hãy tạo một hàm emit
để thiết lập các hạt của chúng ta:
// ... 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); }, []); // ... };
Hàm emit
lặp qua số lượng hạt mà chúng ta muốn phát ra và thiết lập vị trí, xoay, và tỷ lệ ngẫu nhiên cho mỗi hạt. Sau đó, chúng ta compose
ma trận với các giá trị này và thiết lập nó cho instance ở chỉ số hiện tại.
Bạn có thể thấy các hạt ngẫu nhiên trong cảnh. Mỗi hạt có một vị trí, xoay, và tỷ lệ ngẫu nhiên.
Để tạo hoạt cảnh cho các hạt của chúng ta, chúng ta sẽ xác định các thuộc tính như lifetime, speed, direction để việc tính toán có thể được thực hiện trên GPU.
Trước khi làm điều đó, chúng ta cần chuyển sang một custom shader material để xử lý các thuộc tính này vì chúng ta không có quyền truy cập và kiểm soát các thuộc tính của meshBasicMaterial
.
Material Hạt
Mục tiêu đầu tiên của chúng ta sẽ là không thấy bất kỳ thay đổi nào giữa meshBasicMaterial
và shaderMaterial
mới của chúng ta. Chúng ta sẽ tạo một shader material đơn giản mà sẽ render các hạt giống như cách meshBasicMaterial
hiện tại đang hoạt động.
Trong thành phần VFXParticles
, hãy tạo một shader material mới:
// ... 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 });
Đây là một shader material rất đơn giản mà nhận một uniform color
và render các hạt với màu này. Điều mới duy nhất ở đây là instanceMatrix
mà chúng ta sử dụng để lấy position
, rotation
, và scale
của mỗi hạt.
Lưu ý rằng chúng ta không cần khai báo thuộc tính
instanceMatrix
do đây là một trong những thuộc tính tích hợp sẵn củaWebGLProgram
khi sử dụng instancing. Thông tin thêm có thể được tìm thấy ở đây.
Hãy thay thế meshBasicMaterial
bằng ParticlesMaterial
mới của chúng ta:
<instancedMesh args={[defaultGeometry, null, nbParticles]} ref={mesh}> <particlesMaterial color="orange" /> </instancedMesh>
Hoàn hảo! position
, rotation
, và scale
của chúng ta vẫn hoạt động như mong đợi. Các hạt được render với một màu cam hơi khác. Điều này là do chúng ta không tính đến environment trong shader material của chúng ta. Để đơn giản, chúng ta sẽ giữ nguyên như vậy.
Bây giờ chúng ta sẵn sàng thêm các thuộc tính tùy chỉnh cho các hạt để làm cho chúng chuyển động.
Thuộc Tính Bộ Đệm Instanced
Cho đến lúc này, chúng ta chỉ sử dụng thuộc tính instanceMatrix
, bây giờ chúng ta sẽ thêm các thuộc tính tùy chỉnh để có thêm quyền kiểm soát từng hạt.
Đối với điều này, chúng ta sẽ sử dụng InstancedBufferAttribute từ Three.js.
Chúng ta sẽ thêm các thuộc tính sau vào các hạt:
instanceColor
: Một vector3 đại diện cho màu của hạt.instanceColorEnd
: Một vector3 đại diện cho màu mà hạt sẽ chuyển đổi theo thời gian.instanceDirection
: Một vector3 đại diện cho hướng mà hạt sẽ di chuyển.instanceSpeed
: Một float để xác định tốc độ hạt di chuyển theo hướng của nó.instanceRotationSpeed
: Một vector3 để xác định tốc độ quay của hạt trên mỗi trục.instanceLifetime
: Một vector2 để xác định tuổi thọ của hạt. Giá trị đầu tiên (x
) là thời gian bắt đầu, và giá trị thứ hai (y
) là tuổi thọ/thời gian. Kết hợp với đồng nhất thời gian, chúng ta có thể tính toán tuổi, tiến trình, và nếu hạt tồn tại hay chết.
Hãy tạo các bộ đệm khác nhau cho các thuộc tính của chúng ta:
// ... 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), }); // ... }; // ...
Tôi đang sử dụng useState
để tạo các bộ đệm khác nhau cho các thuộc tính của chúng ta để tránh tạo lại chúng ở mỗi lần render. Tôi chọn không sử dụng hook useMemo
vì việc thay đổi số lượng hạt tối đa trong suốt vòng đời của component không phải là vấn đề chúng ta muốn xử lý.
Float32Array
được sử dụng để lưu trữ giá trị của các thuộc tính. Chúng ta nhân số hạt với số lượng thành phần của thuộc tính để có được tổng số giá trị trong mảng.
Trong thuộc tính instanceColor
, 3 giá trị đầu tiên sẽ đại diện cho màu của hạt đầu tiên, 3 giá trị tiếp theo sẽ đại diện cho màu của hạt thứ hai, và tiếp tục như vậy.
Hãy bắt đầu bằng cách làm quen với InstancedBufferAttribute
và cách sử dụng nó. Để làm điều này, chúng ta sẽ triển khai thuộc tính 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> </> ); };
Trong <instancedMesh />
của chúng ta, chúng ta thêm một component <instancedBufferAttribute />
để định nghĩa thuộc tính instanceColor
. Chúng ta gắn nó vào thuộc tính geometry-attributes-instanceColor
của mesh. Chúng ta truyền mảng attributeArrays.instanceColor
làm nguồn dữ liệu, đặt itemSize
là 3
vì chúng ta có một vector3, và count
là nbParticles
.
Thuộc tính usage
được đặt thành DynamicDrawUsage
để thông báo cho trình render rằng dữ liệu sẽ được cập nhật thường xuyên. Các giá trị có thể khác và chi tiết hơn có thể được tìm thấy ở đây.
Chúng ta sẽ không cập nhật chúng trong mỗi frame, nhưng mỗi khi chúng ta phát sinh các hạt mới, dữ liệu sẽ được cập nhật. Đầy đủ để coi nó như DynamicDrawUsage
.
Hoàn hảo, hãy tạo một biến tmpColor
giả lập ở đầu file của chúng ta để thao tác với màu sắc của các hạt:
// ... const tmpColor = new Color();
Bây giờ hãy cập nhật hàm emit
để thiết lập thuộc tính 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); } };
Chúng ta bắt đầu bằng cách lấy thuộc tính instanceColor
từ geometry của mesh. Sau đó, chúng ta lặp qua số lượng hạt mà chúng ta muốn phát sinh và đặt một màu ngẫu nhiên cho mỗi hạt.
Hãy cập nhật particlesMaterial
để sử dụng thuộc tính instanceColor
thay vì color uniform:
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); }` ); // ...
Chúng ta thêm một attribute vec3 instanceColor;
vào vertex shader và đặt vColor
varying để truyền màu sắc đến fragment shader. Sau đó, chúng ta đặt gl_FragColor
thành vColor
để render các hạt với màu của chúng.
Chúng ta đã thành công đặt một màu ngẫu nhiên cho mỗi hạt. Các hạt được render với màu của chúng.
Hoàn hảo, hãy thêm các thuộc tính khác vào các hạt của chúng ta. Đầu tiên, hãy cập nhật hàm emit
để thiết lập các thuộc tính instanceColorEnd
, instanceDirection
, instanceLifetime
, instanceSpeed
và instanceRotationSpeed
với các giá trị ngẫu nhiên:
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); } };
Và tạo các component instancedBufferAttribute
cho mỗi thuộc tính:
<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>
Bây giờ là lúc thêm sự sống vào các hạt của chúng ta bằng cách thực hiện logic di chuyển, màu sắc và tuổi thọ của chúng.
Thời gian tồn tại của Particles
Để tính toán hành vi của particles, chúng ta cần truyền thời gian đã trôi qua tới shader. Chúng ta sẽ sử dụng thuộc tính uniforms
của shaderMaterial
để truyền thời gian này.
Hãy cập nhật ParticlesMaterial
của chúng ta để thêm một uTime
uniform:
const ParticlesMaterial = shaderMaterial( { uTime: 0, }, /* glsl */ ` uniform float uTime; // ... `, /* glsl */ ` // ... ` );
Và trong một vòng lặp useFrame
, chúng ta sẽ cập nhật 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; }); // ... };
Trong vertex shader, chúng ta sẽ tính toán tuổi và tiến độ của mỗi particle dựa trên uTime
uniform và thuộc tính instanceLifetime
. Chúng ta sẽ chuyển tiến độ vào fragment shader để tạo hiệu ứng động cho các particles bằng một varying có tên 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; }
Tuổi được tính bằng cách trừ startTime
khỏi uTime
. Tiến độ được tính bằng cách chia tuổi cho duration
.
Bây giờ trong fragment shader, chúng ta sẽ nội suy màu của particles giữa instanceColor
và instanceColorEnd
dựa trên tiến độ:
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); }
Chúng ta có thể thấy particles đổi màu theo thời gian nhưng đang gặp một vấn đề. Tất cả các particles đều hiển thị từ đầu trong khi thời gian bắt đầu của chúng là ngẫu nhiên. Chúng ta cần ẩn các particles chưa hoạt động.
Để ngăn các particles chưa được sinh ra và đã chết khỏi việc được render, chúng ta sẽ sử dụng từ khóa discard
trong fragment shader:
// ... void main() { if (vProgress < 0.0 || vProgress > 1.0) { discard; } // ... }
Từ khóa discard
cho trình diễn biết để bỏ qua fragment hiện tại và không render nó.
Hoàn hảo, giờ đây các particles của chúng ta bắt đầu, sống và chết theo thời gian. Chúng ta có thể thêm logic di chuyển và xoay chuyển.
Chuyển động của hạt
Bằng cách sử dụng hướng, tốc độ, và tuổi của các hạt, chúng ta có thể tính toán vị trí của chúng theo thời gian.
Trong vertex shader, hãy điều chỉnh gl_Position
để tính đến hướng và tốc độ của các hạt.
Đầu tiên, chúng ta chuẩn hóa hướng để tránh các hạt di chuyển nhanh hơn khi hướng không phải là vector đơn vị:
vec3 normalizedDirection = length(instanceDirection) > 0.0 ? normalize(instanceDirection) : vec3(0.0);
Tiếp theo, chúng ta tính toán offset của hạt dựa trên tốc độ và tuổi:
vec3 offset = normalizedDirection * age * instanceSpeed;
Lấy vị trí instance:
vec4 startPosition = modelMatrix * instanceMatrix * vec4(position, 1.0); vec3 instancePosition = startPosition.xyz;
Và áp dụng offset lên nó:
vec3 finalPosition = instancePosition + offset;
Cuối cùng, chúng ta lấy vị trí model view mvPosition
bằng cách áp dụng modelViewMatrix lên finalPosition để chuyển đổi vị trí sang world space:
vec4 mvPosition = modelViewMatrix * vec4(finalPosition, 1.0);
Và áp dụng projectionMatrix để chuyển đổi vị trí thế giới sang camera space:
gl_Position = projectionMatrix * mvPosition;
Dưới đây là vertex shader đầy đủ của chúng ta cho đến nay:
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; }
Các hạt bây giờ đang di chuyển theo nhiều hướng khác nhau với các tốc độ khác nhau. Điều này hơi hỗn loạn nhưng là do các giá trị ngẫu nhiên của chúng ta.
Hãy khắc phục điều này bằng cách điều chỉnh các giá trị ngẫu nhiên của chúng ta trong hàm emit
để có cái nhìn rõ ràng hơn về chuyển động của các hạt:
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); }
Bắt đầu định hình dần!
Chúng ta sẽ thêm các UI controls đơn giản để điều chỉnh các biến sau này. Bây giờ hãy hoàn thiện các hạt bằng cách thêm logic xoay.
Trong khi chúng ta tách biệt hướng và tốc độ cho chuyển động, đối với xoay, chúng ta sẽ sử dụng một thuộc tính instanceRotationSpeed
để xác định tốc độ xoay từng trục.
Trong vertex shader, chúng ta có thể tính toán xoay của hạt dựa trên tốc độ xoay và tuổi:
vec3 rotationSpeed = instanceRotationSpeed * age;
Sau đó, để có thể áp dụng "xoay lệch" này lên hạt, chúng ta cần chuyển nó thành một ma trận xoay:
mat4 rotX = rotationX(rotationSpeed.x); mat4 rotY = rotationY(rotationSpeed.y); mat4 rotZ = rotationZ(rotationSpeed.z); mat4 rotationMatrix = rotZ * rotY * rotX;
rotationX
, rotationY
, và rotationZ
là các hàm trả về một ma trận xoay quanh trục X, Y, và Z tương ứng. Chúng ta sẽ định nghĩa chúng bên trên hàm main
trong 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 ); }
Để tìm hiểu thêm về các ma trận xoay, bạn có thể tham khảo bài viết Wiki này, tài liệu tuyệt vời Giải thích Toán Game một cách Đơn giản của Simon Dev, hoặc phần Ma trận từ The Book of Shaders.
Cuối cùng, chúng ta có thể áp dụng ma trận xoay lên vị trí bắt đầu của hạt:
vec4 startPosition = modelMatrix * instanceMatrix * rotationMatrix * vec4(position, 1.0);
Hãy thử nghiệm:
Các hạt giờ đây đang di chuyển, thay đổi màu sắc, và xoay! ✨
Hoàn hảo, chúng ta đã có một nền tảng vững chắc cho VFX Engine. Trước khi thêm nhiều tính năng và điều khiển hơn, hãy chuẩn bị phần quan trọng thứ hai của engine: emitter.
Bộ phát (Emitters)
Trong các ví dụ và hướng dẫn, đây thường là phần bị bỏ qua trong hệ thống hạt. Nhưng nó là một phần quan trọng để tích hợp các hạt vào dự án của bạn một cách dễ dàng và hiệu quả:
- Dễ dàng vì thành phần
<VFXParticles />
của bạn sẽ nằm ở đầu cấu trúc phân cấp và emitter của bạn có thể sinh ra chúng từ bất kỳ tiểu thành phần nào trong cảnh của bạn. Giúp dễ dàng sinh ra từ một điểm cụ thể, gắn vào một đối tượng di chuyển, hoặc một xương chuyển động. - Hiệu quả vì thay vì tạo lại mesh instanced, biên dịch shader materials, và thiết lập attributes mỗi khi bạn muốn sinh ra các hạt, bạn có thể tái sử dụng cùng một thành phần VFXParticles và chỉ cần gọi một hàm để sinh ra các hạt với các cài đặt mong muốn.
useVFX
Chúng ta muốn có khả năng gọi hàm emit
từ thành phần VFXParticles
của mình từ bất kỳ đâu trong dự án. Để làm điều đó, chúng ta sẽ tạo một hook tùy chỉnh gọi là useVFX
sẽ đảm nhiệm việc đăng ký và hủy đăng ký các emitters từ thành phần VFXParticles.
Chúng ta sẽ sử dụng Zustand bởi vì nó là một cách đơn giản và hiệu quả để quản lý trạng thái toàn cục trong React với hiệu suất tuyệt vời.
Hãy thêm nó vào dự án của chúng ta:
yarn add zustand
Trong thư mục vfxs
của chúng ta, hãy tạo một tập tin VFXStore.js
:
import { create } from "zustand"; export const useVFX = create((set, get) => ({ emitters: {}, registerEmitter: (name, emitter) => { if (get().emitters[name]) { console.warn(`Emitter ${name} đã tồn tại`); 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} không tồn tại`); return; } emitter(...params); }, }));
Nội dung của nó:
- emitters: Một đối tượng sẽ lưu trữ tất cả các emitters từ các thành phần
VFXParticles
của chúng ta. - registerEmitter: Một hàm để đăng ký một emitter với tên cho trước.
- unregisterEmitter: Một hàm để hủy đăng ký một emitter với tên cho trước.
- emit: Một hàm để gọi emitter với tên và tham số cho trước từ bất kỳ đâu trong dự án của chúng ta.
Hãy kết nối nó vào thành phần VFXParticles
của chúng ta:
// ... 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); }; }, []); // ... }; // ...
Chúng ta thêm một prop name
cho thành phần VFXParticles
của mình để xác định emitter. Sau đó, chúng ta sử dụng hook useVFX
để lấy hàm registerEmitter
và unregisterEmitter
.
Chúng ta gọi registerEmitter
với name
và hàm emit
bên trong hook useEffect
để đăng ký emitter khi thành phần được dựng lên và hủy đăng ký khi nó bị tháo dỡ.
Trong thành phần Experience
, hãy thêm prop name
vào thành phần VFXParticles
của chúng ta:
// ... import { VFXParticles } from "./vfxs/VFXParticles"; export const Experience = () => { return ( <> {/* ... */} <VFXParticles name="sparks" /> </> ); };
VFXEmitter
Bây giờ chúng ta đã có hook useVFX
, chúng ta có thể tạo một thành phần VFXEmitter
có trách nhiệm sinh ra các hạt từ thành phần VFXParticles
của chúng ta.
Trong thư mục vfxs
, hãy tạo một 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} /> </> ); } );
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.