Di 15 Haunted House kita udah bikin scene utuh. Sekarang kita belajar bikin ribuan titik kecil melayang buat bikin bintang, asap, hujan, debu, atau api.

Particles itu ya persis kaya namanya: partikel. Populer banget dan bisa dipakai buat macam-macam efek: bintang, asap, hujan, debu, api, dan banyak lagi.

Yang bikin particles menarik: kamu bisa naruh ratusan ribu partikel di layar dan frame rate-nya masih masuk akal. Kok bisa? Karena tiap partikel itu cuma satu bidang datar (dua segitiga) yang selalu menghadap kamera. Jadi seringan itu.

Bikinnya juga segampang bikin Mesh, cuma bahannya beda:

  1. BufferGeometry, yang udah kita kenal di 07 Geometry > Bikin Geometry Sendiri (BufferGeometry).
  2. PointsMaterial, material khusus partikel yang sempat disinggung di 10 Materials > Material untuk Nanti.
  3. Points, bukan Mesh.

Partikel Pertama

1. Geometry

Tiap vertex dari geometry bakal jadi satu partikel. Jadi kita bisa pakai geometry bawaan apapun buat mulai. Kita coba bola dulu:

/**
 * Particles
 */
// Geometry
const particlesGeometry = new THREE.SphereGeometry(1, 32, 32)

2. PointsMaterial

Material biasa gak bisa dipakai, harus PointsMaterial. Dia punya dua properti khas partikel:

  1. size, ukuran semua partikelnya.
  2. sizeAttenuation, nentuin apakah partikel yang jauh harus kelihatan lebih kecil daripada yang dekat. Ini yang bikin kesan kedalaman.
// Material
const particlesMaterial = new THREE.PointsMaterial({
    size: 0.02,
    sizeAttenuation: true
})

Kaya biasa, bisa juga di-set setelah instance-nya jadi:

const particlesMaterial = new THREE.PointsMaterial()
particlesMaterial.size = 0.02
particlesMaterial.sizeAttenuation = true

3. Points

Terakhir, kita bikinnya bukan pakai Mesh, tapi pakai Points:

❌ salah, Mesh bikin permukaan padat, bukan titik-titik

const particles = new THREE.Mesh(particlesGeometry, particlesMaterial)

✅ benar

// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)

Gampang kan? Sekarang bolanya kelihatan sebagai kumpulan titik-titik. Ayo kita bikin sendiri dari nol.

Bikin Geometry Sendiri

Prosesnya persis sama kaya waktu kita nyebar segitiga acak dulu, cuma sekarang hasilnya partikel, bukan segitiga:

// Geometry
const particlesGeometry = new THREE.BufferGeometry()
const count = 500
 
// Dikali 3 karena satu posisi terdiri dari 3 nilai (x, y, z)
const positions = new Float32Array(count * 3)
 
for(let i = 0; i < count * 3; i++)
{
    // - 0.5 biar nilainya bisa negatif juga
    positions[i] = (Math.random() - 0.5) * 10
}
 
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

Bedanya sama waktu bikin segitiga: dulu kita kali 3 dua kali (count * 3 * 3), karena satu segitiga butuh 3 vertex. Sekarang cuma sekali, karena satu partikel = satu vertex.

Kalau kamu belum bisa nulis kode ini dari kepala sendiri, jangan frustrasi. Ini emang agak rumit dan formatnya aneh.

Sekarang saatnya seru-seruan: coba tes batas kemampuan komputermu. Ganti count jadi 5000, terus 50000, terus 500000. Kamu bisa punya jutaan partikel dan frame rate-nya masih masuk akal.

Tapi tentu ada batasnya. Di komputer kentang atau HP, kamu gak bakal dapet 60fps dengan jutaan partikel. Dan nanti kita bakal nambahin efek yang bikin frame rate turun drastis. Tapi tetap aja, ini mengesankan.

Untuk sekarang kita balikin ke 5000 dan ukurannya kita gedein:

const count = 5000
 
// ...
 
particlesMaterial.size = 0.1

Warna, Map, dan Alpha Map

1. Color

Warna semua partikel bisa diganti pakai properti color. Dan lagi-lagi wajib dibungkus Color kalau di-set setelah material dibikin, aturan yang sama kaya di 10 Materials > 2. Color dan 13 Lights > AmbientLight:

particlesMaterial.color = new THREE.Color('#ff88cc')

2. Map

Kita bisa nempelin gambar ke tiap partikel pakai map. Gambar-gambarnya ada di /static/textures/particles/:

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()
const particleTexture = textureLoader.load('/textures/particles/2.png')
 
// ...
 
particlesMaterial.map = particleTexture

Texture-texture ini versi kecil dari paket bikinan Kenney, tapi kamu juga bisa bikin sendiri.

Kelihatan kan, color itu mewarnai gambarnya, sama persis kaya di material lain.

Tapi kalau diperhatiin, partikel yang di depan nutupin partikel yang di belakang dengan kotak hitam. Kenapa? Karena gambarnya itu kotak persegi dengan latar hitam, dan latar hitamnya ikut kegambar.

3. AlphaMap

Solusinya: nyalain transparansi dan pindahin texture-nya dari map ke alphaMap, sesuai cara kerja alphaMap:

// particlesMaterial.map = particleTexture
particlesMaterial.transparent = true
particlesMaterial.alphaMap = particleTexture

Jauh lebih bagus. Tapi masih ada masalah: kalau diliatin, kadang masih kelihatan garis tepi kotak partikelnya secara acak.

Penyebabnya: partikel digambar sesuai urutan dibuatnya, dan WebGL gak tau mana yang sebenarnya di depan. Jadi partikel belakang bisa keburu nutup partikel depan.

Ada tiga cara ngatasinnya, dan gak ada yang sempurna.

4. Cara 1: alphaTest

alphaTest itu nilai antara 0 sampai 1 yang ngasih tau WebGL kapan sebuah piksel gak usah digambar berdasarkan transparansinya.

Bawaannya 0, artinya semua piksel tetap digambar walaupun transparan total. Kalau kita kasih nilai kecil kaya 0.001, piksel dengan alpha 0 gak bakal digambar sama sekali:

particlesMaterial.alphaTest = 0.001

Solusinya gak sempurna, kalau diliatin banget masih ada cacat, tapi udah jauh lebih enak dipandang.

5. Cara 2: depthTest

Waktu menggambar, WebGL ngetes apakah yang lagi digambar itu lebih dekat daripada yang udah digambar. Itu namanya depth testing, dan bisa kita matiin:

// particlesMaterial.alphaTest = 0.001
particlesMaterial.depthTest = false

Kelihatannya masalahnya beres total. Tapi hati-hati banget sama solusi ini, karena kalau di scene kamu ada objek lain atau partikel dengan warna berbeda, partikelnya bisa kegambar seolah-olah dia di depan segalanya, padahal harusnya kehalang.

Coba tambahin kubus buat buktiin:

const cube = new THREE.Mesh(
    new THREE.BoxGeometry(),
    new THREE.MeshBasicMaterial()
)
scene.add(cube)

Nah kan, partikelnya nembus kubus.

6. Cara 3: depthWrite

Tadi kita bilang WebGL ngetes apakah yang digambar lebih dekat dari yang udah ada. Nah, kedalaman dari yang udah digambar itu disimpan di tempat bernama depth buffer.

Daripada gak ngetes sama sekali (yang bikin masalah tadi), kita bisa nyuruh WebGL jangan nulis partikelnya ke depth buffer itu:

// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false

Bedanya halus tapi penting:

  1. depthTest = false artinya “jangan ngecek apakah ada sesuatu di depanku”. Partikelnya jadi nembus semua.
  2. depthWrite = false artinya “tetap ngecek, tapi jangan catat keberadaanku buat objek berikutnya”. Partikelnya tetap kehalang objek lain, tapi gak saling nutup sesama partikel.

Di kasus kita, ini solusi terbaik dengan hampir tanpa kerugian. Tapi kadang objek lain masih bisa kegambar di depan atau di belakang partikel tergantung banyak faktor, kaya transparansinya atau urutan objek dimasukin ke scene.

Gak ada solusi yang sempurna. Kamu harus nyoba dan nyari kombinasi terbaik buat project-mu.

Blending

Sekarang WebGL menggambar piksel saling menimpa satu sama lain.

Dengan ngubah properti blending, kita bisa nyuruh WebGL bukan cuma menggambar pikselnya, tapi menambahkan warnanya ke warna piksel yang udah ada di situ. Efeknya jenuh dan berpendar, kaya cahaya yang numpuk, dan hasilnya bisa keren banget:

particlesMaterial.depthWrite = false
particlesMaterial.blending = THREE.AdditiveBlending

Bayangin dua senter yang sorotannya nimpa di tembok, bagian yang kena dua-duanya jadi lebih terang. Nah gitu.

Tambahin partikelnya jadi 20000 biar efeknya makin kerasa. Tapi hati-hati, efek ini berat, jadi kamu gak bakal bisa punya sebanyak tadi sambil tetap 60fps. Kubus tadi juga udah boleh dihapus.

Warna Berbeda Tiap Partikel

Kita bisa kasih warna beda buat tiap partikel. Caranya: tambahin attribute baru namanya color, persis kaya kita nambahin position.

Warna itu terdiri dari merah, hijau, dan biru, alias 3 nilai juga, jadi kodenya mirip banget sama posisi. Malah satu perulangan bisa dipakai buat dua-duanya:

const positions = new Float32Array(count * 3)
const colors = new Float32Array(count * 3)
 
for(let i = 0; i < count * 3; i++)
{
    positions[i] = (Math.random() - 0.5) * 10
    colors[i] = Math.random()
}
 
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

Hati-hati sama tunggal dan jamaknya: variabelnya colors (jamak) tapi nama attribute-nya color (tunggal). Ini jebakan klasik yang bikin bengong.

❌ salah, ThreeJS nyari nama 'color', bukan 'colors'

particlesGeometry.setAttribute('colors', new THREE.BufferAttribute(colors, 3))

✅ benar

particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))

Sama kaya nama 'position' yang harga mati di 07 Geometry > 4. Pasang ke Geometry Pakai setAttribute, 'color' juga nama yang dicari ThreeJS.

Terus nyalain biar warnanya kepakai:

particlesMaterial.vertexColors = true

Warna utama material tadi masih ikut mewarnai warna per-vertex ini. Kalau mau warnanya murni acak, komen aja:

// particlesMaterial.color = new THREE.Color('#ff88cc')

Animasi

Ada beberapa cara nganimasiin partikel, dari yang paling gampang sampai yang paling bener.

1. Menggerakkan Seluruhnya Sebagai Satu Objek

Karena class Points itu turunan dari Object3D, kita bisa geser, puter, dan perbesar dia kaya objek biasa:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
 
    // Update particles
    particles.rotation.y = elapsedTime * 0.2
 
    // ...
}

Ini udah keren, tapi kita gak punya kendali atas tiap partikel, semuanya gerak barengan kaya satu benda padat.

2. Dengan Ngubah Attribute-nya Langsung

Cara kedua: ubah posisi tiap vertex satu-satu, jadi tiap partikel bisa punya jalur sendiri-sendiri. Kita bikin partikelnya bergelombang kaya ombak.

Ingat, array position itu satu dimensi, di mana 3 nilai pertama itu x, y, z vertex pertama, 3 nilai berikutnya x, y, z vertex kedua, dan seterusnya.

Kita cuma mau gerakin naik-turun, artinya cuma sumbu y yang diubah. Jadi kita telusuri array-nya tiga-tiga dan cuma sentuh nilai kedua:

for(let i = 0; i < count; i++)
{
    const i3 = i * 3
}

Perhatiin bedanya sama perulangan waktu bikin posisi tadi. Di sini i cuma sampai count (5000), bukan count * 3, terus kita bikin variabel i3 yang loncat tiga-tiga. Dengan begitu:

  1. i3 itu koordinat x partikel ke-i.
  2. i3 + 1 itu koordinat y-nya.
  3. i3 + 2 itu koordinat z-nya.

Sekarang kita gerakin y-nya pakai sinus:

for(let i = 0; i < count; i++)
{
    const i3 = i * 3
 
    particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
}

Kok gak ada yang gerak? Karena ThreeJS harus dikasih tau kalau geometry-nya berubah. Ini persis kaya jebakan texture.needsUpdate waktu kita load gambar manual dulu:

❌ salah, ThreeJS gak tau ada yang berubah, jadi diem aja

for(let i = 0; i < count; i++)
{
    const i3 = i * 3
    particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
}

✅ benar, dikasih tau setelah semua vertex selesai diubah

for(let i = 0; i < count; i++)
{
    const i3 = i * 3
    particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime)
}
particlesGeometry.attributes.position.needsUpdate = true

Sekarang semua partikel naik-turun barengan kaya satu bidang datar, bukan ombak. Karena semuanya dikasih sudut sinus yang sama persis.

Biar jadi ombak, kita kasih pergeseran sudut antar partikel, dan yang paling masuk akal dipakai itu koordinat x-nya sendiri. Partikel yang posisinya beda di sumbu x bakal naik-turun di waktu yang beda:

for(let i = 0; i < count; i++)
{
    const i3 = i * 3
 
    const x = particlesGeometry.attributes.position.array[i3]
    particlesGeometry.attributes.position.array[i3 + 1] = Math.sin(elapsedTime + x)
}
particlesGeometry.attributes.position.needsUpdate = true

Hasilnya ombak partikel yang cantik banget.

Tapi jangan pakai teknik ini. Serius.

Kenapa? Karena kalau kita punya 20000 partikel, tiap frame kita menelusuri semuanya satu-satu, ngitung posisi baru, terus ngirim ulang seluruh attribute ke GPU. Itu 60 kali per detik. Buat partikel dikit sih jalan, tapi kita kan pengennya jutaan partikel.

Ini pekerjaan CPU yang harusnya dikerjain GPU.

3. Pakai Shader Sendiri

Buat nge-update jutaan partikel tiap frame dengan frame rate yang bagus, kita harus bikin material sendiri dengan shader sendiri. Tapi shader itu babnya sendiri nanti.

Code Lengkap

import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
 
/**
 * Base
 */
// Canvas
const canvas = document.querySelector('canvas.webgl')
 
// Scene
const scene = new THREE.Scene()
 
// Sizes
const sizes = {
    width: window.innerWidth,
    height: window.innerHeight
}
 
/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()
const particleTexture = textureLoader.load('/textures/particles/2.png')
 
/**
 * Particles
 */
// Geometry
const particlesGeometry = new THREE.BufferGeometry()
const count = 20000
 
const positions = new Float32Array(count * 3)
const colors = new Float32Array(count * 3)
 
for(let i = 0; i < count * 3; i++)
{
    positions[i] = (Math.random() - 0.5) * 10
    colors[i] = Math.random()
}
 
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
particlesGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
 
// Material
const particlesMaterial = new THREE.PointsMaterial()
particlesMaterial.size = 0.1
particlesMaterial.sizeAttenuation = true
particlesMaterial.transparent = true
particlesMaterial.alphaMap = particleTexture
// particlesMaterial.alphaTest = 0.001
// particlesMaterial.depthTest = false
particlesMaterial.depthWrite = false
particlesMaterial.blending = THREE.AdditiveBlending
particlesMaterial.vertexColors = true
 
// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)
 
/**
 * Camera
 */
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 3
scene.add(camera)
 
// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
 
/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
 
/**
 * Animate
 */
const clock = new THREE.Clock()
 
const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
 
    // Update particles
    particles.rotation.y = elapsedTime * 0.2
 
    // Update controls
    controls.update()
 
    // Render
    renderer.render(scene, camera)
 
    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}
 
tick()

Ringkasan

  1. Tiap partikel itu satu bidang datar (dua segitiga) yang selalu menghadap kamera, makanya enteng banget dan bisa dipakai ratusan ribu sekaligus.
  2. Bahannya tiga: BufferGeometry, PointsMaterial, dan Points (bukan Mesh).
  3. Tiap vertex dari geometry jadi satu partikel, jadi geometry bawaan apapun bisa langsung dipakai buat coba-coba.
  4. size ngatur ukuran semua partikel, sizeAttenuation bikin partikel jauh kelihatan lebih kecil dan itu yang ngasih kesan kedalaman.
  5. Bikin sendiri cuma butuh count * 3, bukan count * 3 * 3 kaya waktu bikin segitiga, karena satu partikel cuma butuh satu vertex.
  6. Texture partikel dipasang di alphaMap, bukan map, biar latar hitam kotaknya gak ikut kegambar. Dan jangan lupa transparent = true.
  7. Partikel digambar sesuai urutan dibuatnya, bukan sesuai jarak, makanya muncul tepi kotak yang saling nutup.
  8. Ada 3 obat dan gak ada yang sempurna: alphaTest (buang piksel transparan), depthTest = false (jangan ngecek kedalaman, bahaya, partikelnya nembus semua objek), dan depthWrite = false (tetap ngecek tapi gak nyatet diri sendiri, biasanya paling aman).
  9. THREE.AdditiveBlending menambahkan warna piksel ke warna yang udah ada, bikin efek berpendar yang keren, tapi berat.
  10. Warna per-partikel dibikin dengan attribute bernama 'color' (tunggal!) dan baru aktif kalau vertexColors = true.
  11. Warna material masih ikut mewarnai warna per-vertex, jadi komen kalau mau warnanya murni.
  12. Points itu turunan Object3D, jadi seluruh kumpulan partikel bisa digeser dan diputer kaya objek biasa.
  13. Kalau mau tiap partikel gerak sendiri-sendiri, telusuri array-nya tiga-tiga pakai i3 = i * 3, di mana i3 itu x, i3 + 1 itu y, dan i3 + 2 itu z.
  14. Wajib attributes.position.needsUpdate = true setelah selesai ngubah, kalau nggak ThreeJS gak tau ada yang berubah dan gak ada yang gerak.
  15. Pergeseran sudut sinus pakai koordinat x itu yang mengubah gerakan naik-turun serempak jadi gerakan ombak.
  16. Tapi teknik ngubah attribute tiap frame itu sebaiknya dihindari, karena CPU-nya kerja rodi. Solusi yang benar itu bikin shader sendiri, dan itu bab nanti.

Bab selanjutnya kita pakai ilmu partikel ini buat bikin galaksi spiral yang bisa diatur jumlah lengan, ketebalan, dan warnanya lewat Debug UI.