VFX Engine

Starter pack

Sejauh ini, kita telah membuat komponen khusus untuk membuat partikel dalam adegan 3D kita. Sebagian besar waktu, kita ingin melakukan hal yang hampir sama: memancarkan partikel dari suatu titik di ruang dan menganimasikannya seiring waktu. (Warna, ukuran, posisi, dll.)

Daripada menduplikasi kode yang sama berulang kali, kita bisa membuat mesin VFX yang relatif generik yang dapat digunakan untuk membuat berbagai jenis efek partikel.

Ini memiliki banyak manfaat:

  • Penggunaan Kembali: Anda dapat menggunakan mesin yang sama untuk membuat berbagai jenis efek partikel dalam proyek Anda.
  • Performa: Mesin dapat dioptimalkan untuk menangani sejumlah besar partikel secara efisien dan menggabungkan beberapa sistem partikel menjadi satu.
  • Fleksibilitas: Anda dapat dengan mudah menyesuaikan perilaku partikel dengan mengubah parameter mesin.
  • Kemudahan penggunaan: Anda dapat membuat efek partikel yang kompleks hanya dengan beberapa baris kode.
  • Menghindari duplikasi kode: Anda tidak perlu menulis kode yang sama beberapa kali.

Kita akan menggunakan mesin VFX ini pada pelajaran selanjutnya untuk membuat berbagai efek. Meskipun Anda dapat melewati pelajaran ini dan langsung menggunakan mesin tersebut, memahami cara kerjanya akan membantu Anda memahami lebih dalam bagaimana menguasai kinerja dan fleksibilitas dalam proyek 3D Anda.

Siap untuk membangun mesin VFX Anda? Mari kita mulai!

GPU Particles

Kita telah melihat pada pelajaran sebelumnya bagaimana kita dapat menggunakan komponen <Instances /> dari drei untuk membuat partikel yang terkendali dalam adegan 3D kita.

Namun, pendekatan ini memiliki satu keterbatasan utama: jumlah partikel yang dapat kita tangani terbatas oleh CPU. Semakin banyak partikel yang kita miliki, semakin banyak CPU harus menangani mereka, yang dapat menyebabkan masalah performa.

Hal ini disebabkan oleh fakta bahwa di balik layar, komponen <Instances /> melakukan perhitungan untuk mendapatkan posisi, warna, dan ukuran dari setiap <Instance /> dalam loop useFrame-nya. Anda dapat melihat kode tersebut di sini.

Untuk VFX Engine kita, kita ingin dapat mengeluarkan lebih banyak partikel daripada yang bisa kita tangani dengan komponen <Instances />. Kita akan menggunakan GPU untuk menangani posisi, warna, dan ukuran partikel. Ini memungkinkan kita untuk menangani ratusan ribu partikel (jutaan? 👀) tanpa masalah performa.

Instanced Mesh

Meskipun kita bisa menggunakan Sprite atau Points untuk membuat partikel, kita akan menggunakan InstancedMesh.

Ini memungkinkan kita untuk merender tidak hanya bentuk sederhana seperti titik atau sprite, tetapi juga bentuk 3D seperti kubus, bola, dan geometri khusus.

Mari kita buat komponen dalam folder baru vfxs yang disebut 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>
    </>
  );
};

Kita membuat geometry yang akan digunakan untuk setiap partikel. Dalam hal ini, kita menggunakan geometri plane sederhana dengan ukuran 0.5 pada kedua sumbu. Nantinya, kita akan menambahkan prop untuk melewatkan geometri apa pun yang kita inginkan.

Komponen instancedMesh mengambil tiga argumen:

  • geometry dari partikel.
  • material dari partikel. Kita meneruskan null untuk mendefinisikannya secara deklaratif dalam komponen.
  • Jumlah instance yang dapat ditangani oleh komponen. Untuk kita, ini mewakili jumlah maksimum partikel yang dapat ditampilkan pada saat yang sama.

Mari kita gantikan kubus oranye dengan komponen VFXParticles kita di file Experience.jsx:

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

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

Partikel oranye di tengah adegan

Kamu dapat melihat satu partikel oranye di tengah adegan. Ini adalah komponen VFXParticles kita.

Jumlah partikel kita diatur ke 1000 tapi kita hanya bisa melihat satu. Ini karena semua dirender pada posisi yang sama (0, 0, 0). Mari kita ubah posisi tersebut.

Instance Matrix

Instanced mesh menggunakan matrix untuk menentukan posisi, rotasi, dan skala dari setiap instance. Dengan memperbarui properti instanceMatrix dari mesh kita, kita dapat memindahkan, memutar, dan menskalakan setiap partikel secara individu.

Untuk setiap instance, matrix adalah matrix 4x4 yang mewakili transformasi partikel. Kelas Matrix4 dari Three.js memungkinkan kita compose dan decompose matrix untuk mengatur/mendapatkan posisi, rotasi, dan skala partikel dengan cara yang lebih mudah dipahami.

Di atas deklarasi VFXParticles, mari kita deklarasikan beberapa variabel dummy untuk memanipulasi partikel tanpa menciptakan Vectors dan Matrices terlalu sering:

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

Sekarang mari kita buat fungsi emit untuk mengatur partikel kita:

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

Fungsi emit ini melakukan iterasi pada jumlah partikel yang ingin kita emit dan mengatur posisi, rotasi, dan skala acak untuk setiap partikel. Kemudian kita menyusun matrix dengan nilai-nilai ini dan mengaturnya pada instance di indeks saat ini.

Partikel acak dalam scene

Anda dapat melihat partikel acak dalam scene. Setiap partikel memiliki posisi, rotasi, dan skala yang acak.

Untuk menganimasikan partikel-partikel kita, kita akan mendefinisikan atribut seperti lifetime, speed, direction sehingga perhitungan dapat dilakukan pada GPU.

Sebelum melakukan itu, kita perlu beralih ke custom shader material untuk menangani atribut-atribut ini karena kita tidak memiliki akses dan kontrol atas atribut dari meshBasicMaterial.

Partikel Material

Tujuan pertama kita adalah agar tidak ada perubahan antara meshBasicMaterial dan shaderMaterial baru kita. Kita akan membuat shader material sederhana yang akan merender partikel dengan cara yang sama seperti meshBasicMaterial yang ada saat ini.

Dalam komponen VFXParticles, mari kita buat shader material baru:

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

Ini adalah shader material yang sangat sederhana yang menggunakan uniform color dan merender partikel dengan warna tersebut. Hal baru di sini adalah instanceMatrix yang kita gunakan untuk mendapatkan position, rotation, dan scale dari setiap partikel.

Perlu dicatat bahwa kita tidak perlu mendeklarasikan atribut instanceMatrix karena ini adalah salah satu atribut bawaan dari WebGLProgram ketika menggunakan instancing. Informasi lebih lanjut dapat ditemukan di sini.

Mari kita ganti meshBasicMaterial dengan ParticlesMaterial baru kita:

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

Partikel acak dalam adegan dengan warna oranye

Sempurna! position, rotation, dan scale kita masih berfungsi seperti yang diharapkan. Partikel dirender dengan warna oranye yang sedikit berbeda. Ini karena kita tidak mempertimbangkan environment dalam shader material kita. Untuk menjaga agar tetap sederhana, kita akan membiarkannya seperti ini.

Kita sekarang siap untuk menambahkan atribut khusus ke partikel kita untuk menganimasikannya.

Atribut Buffer Instanced

Sejauh ini, kita hanya menggunakan atribut instanceMatrix, sekarang kita akan menambahkan atribut kustom untuk mendapatkan lebih banyak kontrol atas setiap partikel.

Untuk ini, kita akan menggunakan InstancedBufferAttribute dari Three.js.

Kita akan menambahkan atribut berikut ke partikel kita:

  • instanceColor: Sebuah vector3 yang mewakili warna partikel.
  • instanceColorEnd: Sebuah vector3 yang mewakili warna yang akan berubah seiring waktu.
  • instanceDirection: Sebuah vector3 yang mewakili arah di mana partikel akan bergerak.
  • instanceSpeed: Sebuah float untuk menentukan seberapa cepat partikel akan bergerak di arah tertentu.
  • instanceRotationSpeed: Sebuah vector3 untuk menentukan kecepatan rotasi partikel per sumbu.
  • instanceLifetime: Sebuah vector2 untuk mendefinisikan masa hidup partikel. Nilai pertama (x) adalah waktu mulai, dan nilai kedua (y) adalah masa hidup/durasi. Dikombinasi dengan sebuah uniform waktu, kita dapat menghitung usia, progres, dan apakah sebuah partikel hidup atau mati.

Mari kita buat buffer berbeda untuk atribut kita:

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

  // ...
};

// ...

Saya menggunakan useState untuk membuat buffer berbeda untuk atribut kita agar tidak membuatnya ulang setiap kali render. Saya memilih untuk tidak menggunakan hook useMemo karena mengubah jumlah partikel maksimum selama siklus hidup komponen bukanlah sesuatu yang ingin kita tangani.

Float32Array digunakan untuk menyimpan nilai-nilai dari atribut. Kita mengalikan jumlah partikel dengan jumlah komponen dari atribut untuk mendapatkan total jumlah nilai dalam array.

Skema menjelaskan atribut instanceColor

Dalam atribut instanceColor, 3 nilai pertama akan mewakili warna partikel pertama, 3 nilai berikutnya akan mewakili warna partikel kedua, dan seterusnya.

Mari kita mulai dengan mengenal InstancedBufferAttribute dan cara menggunakannya. Untuk melakukannya, kita akan mengimplementasikan atribut 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>
    </>
  );
};

Dalam <instancedMesh /> kita, kita menambahkan komponen <instancedBufferAttribute /> untuk mendefinisikan atribut instanceColor. Kita melampirkannya ke atribut geometry-attributes-instanceColor dari mesh. Kita memberikan array attributeArrays.instanceColor sebagai sumber data, mengatur itemSize ke 3 karena kita memiliki vector3, dan count ke nbParticles.

Prop usage diatur ke DynamicDrawUsage untuk memberi tahu renderer bahwa data akan sering diperbarui. Nilai lain yang mungkin dan detail lebih lanjut bisa ditemukan di sini.

Kita tidak akan memperbaruinya setiap frame, tetapi setiap kali kita memancarkan partikel baru, data akan diperbarui. Cukup untuk mempertimbangkannya sebagai DynamicDrawUsage.

Bagus, mari kita buat variabel dummy tmpColor di bagian atas file kita untuk memanipulasi warna partikel:

// ...

const tmpColor = new Color();

Sekarang mari kita perbarui fungsi emit untuk mengatur atribut 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);
  }
};

Kita memulai dengan mendapatkan atribut instanceColor dari geometri mesh. Kemudian kita lakukan loop atas jumlah partikel yang ingin kita pancarkan dan mengatur warna acak untuk setiap partikel.

Mari kita perbarui particlesMaterial untuk menggunakan atribut instanceColor alih-alih uniform warna:

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

Kami menambahkan attribute vec3 instanceColor; ke vertex shader dan mengatur variabel vColor untuk melewatkan warna ke fragment shader. Kami kemudian mengatur gl_FragColor ke vColor untuk merender partikel dengan warnanya.

Partikel acak di scene dengan warna acak

Kami berhasil mengatur warna acak untuk setiap partikel. Partikel dirender dengan warnanya.

Bagus, mari tambahkan atribut lain ke partikel kita. Pertama, mari kita perbarui fungsi emit untuk mengatur atribut instanceColorEnd, instanceDirection, instanceLifetime, instanceSpeed, dan instanceRotationSpeed dengan nilai acak:

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

Dan buat komponen instancedBufferAttribute untuk setiap atribut:

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

Sekarang saatnya menambahkan kehidupan kepada partikel kita dengan mengimplementasikan logika pergerakan, warna, dan masa hidup mereka.

Masa Hidup Partikel

Untuk menghitung perilaku partikel kita, kita perlu melewatkan waktu yang berlalu ke shader kita. Kita akan menggunakan properti uniforms dari shaderMaterial untuk melewatkan waktu ke dalamnya.

Mari kita perbarui ParticlesMaterial kita untuk menambahkan uniform uTime:

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

Dan dalam loop useFrame, kita akan memperbarui uniform uTime:

// ...
import { useFrame } from "@react-three/fiber";

// ...
export const VFXParticles = ({ settings = {} }) => {
  // ...

  useFrame(({ clock }) => {
    if (!mesh.current) {
      return;
    }
    mesh.current.material.uniforms.uTime.value = clock.elapsedTime;
  });

  // ...
};

Dalam vertex shader, kita akan menghitung umur dan kemajuan dari setiap partikel berdasarkan uniform uTime dan atribut instanceLifetime. Kita akan meneruskan kemajuan ke dalam fragment shader untuk menganimasikan partikel menggunakan varying bernama 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;
}

Umur dihitung dengan mengurangi startTime dari uTime. Kemajuan kemudian dihitung dengan membagi umur dengan duration.

Sekarang dalam fragment shader, kita akan menginterpolasi warna partikel antara instanceColor dan instanceColorEnd berdasarkan kemajuan:

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

Partikel berubah warna seiring waktu

Kita dapat melihat partikel berubah warna seiring waktu, tetapi kita menghadapi masalah. Semua partikel terlihat di awal sedangkan waktu mulainya acak. Kita perlu menyembunyikan partikel yang belum hidup.

Untuk mencegah partikel yang belum lahir dan mati dari dirender, kita akan menggunakan kata kunci discard dalam fragment shader:

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

Kata kunci discard memberitahu renderer untuk mengabaikan fragment saat ini dan tidak merendernya.

Sempurna, partikel kita sekarang lahir, hidup, dan mati seiring waktu. Kita sekarang bisa menambahkan logika gerakan dan rotasi.

Gerakan Partikel

Dengan menggunakan direction, speed, dan age dari partikel, kita dapat menghitung position mereka seiring waktu.

Dalam vertex shader, mari sesuaikan gl_Position untuk mempertimbangkan direction dan speed partikel.

Pertama, kita menormalkan direction untuk menghindari partikel bergerak lebih cepat ketika arah tidak merupakan unit vector:

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

Kemudian kita menghitung offset partikel berdasarkan speed dan age:

vec3 offset = normalizedDirection * age * instanceSpeed;

Mari kita dapatkan instance position:

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

Dan terapkan offset padanya:

vec3 finalPosition = instancePosition + offset;

Terakhir, kita mendapatkan posisi model view mvPosition dengan menerapkan modelViewMatrix ke finalPosition untuk mentransformasikan posisi ke world space:

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

Dan menerapkan projectionMatrix untuk mentransformasikan posisi dunia ke camera space:

gl_Position = projectionMatrix * mvPosition;

Berikut adalah vertex shader lengkap kita sejauh ini:

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

Partikel sekarang bergerak ke berbagai arah dengan kecepatan yang berbeda. Ini memang tampak kacau tetapi disebabkan oleh nilai random kita.

Mari kita perbaiki ini dengan menyesuaikan nilai random dalam fungsi emit untuk mendapatkan gambaran lebih jelas tentang gerakan partikel:

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

Mulai terbentuk!

Kita akan menambahkan kontrol UI sederhana untuk menyesuaikan variabel nanti. Sekarang mari sempurnakan partikel dengan menambahkan logika rotasi.

Sementara kita memisahkan direction dan speed untuk gerakan, untuk rotasi kita akan menggunakan satu atribut instanceRotationSpeed untuk menentukan kecepatan rotasi per sumbu.

Dalam vertex shader kita dapat menghitung rotation partikel berdasarkan rotation speed dan age:

vec3 rotationSpeed = instanceRotationSpeed * age;

Kemudian, agar dapat menerapkan "offset rotation" ini pada partikel, kita perlu mengonversinya ke matriks rotasi:

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

rotationX, rotationY, dan rotationZ adalah fungsi-fungsi yang mengembalikan matriks rotasi di sekitar sumbu X, Y, dan Z secara berturut-turut. Kita akan mendefinisikannya tepat di atas fungsi main dalam 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
  );
}

Untuk mempelajari lebih lanjut tentang matriks rotasi, Anda dapat memeriksa artikel Wikipedia ini, Game Math Explained Simply yang luar biasa oleh Simon Dev, atau Bagian Matrix dari The Book of Shaders.

Akhirnya, kita dapat menerapkan rotation matrix ke start position partikel:

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

Mari kita coba:

Partikel sekarang bergerak, berubah warna, dan berotasi! ✨

Sempurna, kita memiliki basis yang solid untuk VFX Engine kita. Sebelum menambahkan lebih banyak fitur dan kontrol, mari kita siapkan bagian penting kedua dari engine: emitter.

Emitters

Dalam contoh dan tutorial, bagian ini sering diabaikan dalam sistem partikel. Namun, ini adalah bagian penting untuk mengintegrasikan partikel ke dalam proyek Anda dengan mudah dan efisien:

  • Mudah karena komponen <VFXParticles /> Anda akan berada di bagian atas hierarki dan emitter Anda dapat memunculkannya dari sub-komponen manapun di dalam scene Anda. Membuatnya mudah untuk memunculkan dari titik tertentu, menempelkannya ke objek yang bergerak, atau tulang yang bergerak.
  • Efisien karena alih-alih membuat ulang instanced meshes, menyusun shader materials, dan mengatur attributes setiap kali Anda ingin memunculkan partikel, Anda dapat menggunakan kembali komponen VFXParticles yang sama dan cukup memanggil fungsi untuk memunculkan partikel dengan pengaturan yang diinginkan.

useVFX

Kita ingin bisa memanggil fungsi emit dari komponen VFXParticles kita dari mana saja di proyek kita. Untuk melakukannya, kita akan membuat custom hook yang disebut useVFX yang akan mengurusi mendaftarkan dan mencabut emitters dari komponen VFXParticles.

Kita akan menggunakan Zustand karena ini adalah cara sederhana dan efisien untuk mengelola global state di React dengan performa yang baik.

Mari tambahkan ke proyek kita:

yarn add zustand

Di dalam folder vfxs, mari kita buat file VFXStore.js:

import { create } from "zustand";

export const useVFX = create((set, get) => ({
  emitters: {},
  registerEmitter: (name, emitter) => {
    if (get().emitters[name]) {
      console.warn(`Emitter ${name} already exists`);
      return;
    }
    set((state) => {
      state.emitters[name] = emitter;
      return state;
    });
  },
  unregisterEmitter: (name) => {
    set((state) => {
      delete state.emitters[name];
      return state;
    });
  },
  emit: (name, ...params) => {
    const emitter = get().emitters[name];
    if (!emitter) {
      console.warn(`Emitter ${name} not found`);
      return;
    }
    emitter(...params);
  },
}));

Apa yang dikandungnya:

  • emitters: Objek yang akan menyimpan semua emitters dari komponen VFXParticles kita.
  • registerEmitter: Fungsi untuk mendaftarkan emitter dengan nama yang diberikan.
  • unregisterEmitter: Fungsi untuk mencabut pendaftaran emitter dengan nama yang diberikan.
  • emit: Fungsi untuk memanggil emitter dengan nama dan parameter yang diberikan dari mana saja dalam proyek kita.

Mari kita hubungkan ke komponen VFXParticles kita:

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

// ...

Kita menambahkan prop name ke komponen VFXParticles kita untuk mengidentifikasi emitter. Kemudian kita menggunakan hook useVFX untuk mendapatkan fungsi registerEmitter dan unregisterEmitter.

Kita memanggil registerEmitter dengan name dan fungsi emit di dalam hook useEffect untuk mendaftarkan emitter saat komponen mount dan mencabutnya saat komponen unmount.

Di dalam komponen Experience, mari tambahkan prop name ke komponen VFXParticles kita:

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

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

VFXEmitter

Sekarang setelah kita memiliki hook useVFX, kita dapat membuat komponen VFXEmitter yang akan bertanggung jawab untuk memunculkan partikel dari komponen VFXParticles.

Di folder vfxs, mari kita buat file VFXEmitter.jsx:

import { forwardRef, useImperativeHandle, useRef } from "react";
import { useVFX } from "./VFXStore";

export const VFXEmitter = forwardRef(
  ({ emitter, settings = {}, ...props }, forwardedRef) => {
    const {
      duration = 1,
      nbParticles = 1000,
      spawnMode = "time", // time, burst
      loop = false,
      delay = 0,
    } = settings;

    const emit = useVFX((state) => state.emit);

    const ref = useRef();
    useImperativeHandle(forwardedRef, () => ref.current);

    return (
      <>
        <object3D {...props} ref={ref} />
      </>
    );
  }
);
Three.js logoReact logo

React Three Fiber: The Ultimate Guide to 3D Web Development

✨ You have reached the end of the preview ✨

Go to the next level with Three.js and React Three Fiber!

Get full access to this lesson and the complete course when you enroll:

  • 🔓 Full lesson videos with no limits
  • 💻 Access to the final source code
  • 🎓 Course progress tracking & completion
  • 💬 Invite to our private Discord community
Unlock the Full Course – Just $85

One-time payment. Lifetime updates included.