今天要利用前面練習過的粒子系統,學習如何在網頁上實作 3D 爆炸的特效,並會透過 dat.GUI 來觸發爆炸、改變爆炸粒子尺寸、相機參數來測試更合適的視覺效果。同時會討論如何將已經消散的爆炸粒子移除並釋放記憶體,並分享實作爆炸時遇到的問題。
Photo by Erwan Hesry on Unsplash
這是本系列第 17 篇,如果還沒看過第 16 篇可以點以下連結前往:
用 Three.js 來當個創世神 (16):專案實作#8 - 粒子特效場景
接下來要來研究如何利用粒子系統做爆炸效果,觀察一下預期的效果大概要長這樣:
行為跟下雪不太一樣,一開始筆者也沒什麼頭緒,於是 google 找到這個 Codepen 範例,看起來是比較接近我們需要的效果。
稍微 trace 一下 code 後,大概可以理解他的邏輯如下:
簡單說爆炸就是「沿著爆炸中心點讓粒子向四面八發隨機運動」就可以做到了,看起來很合理也不難做,但最後直覺應該要加一個回收機制,達成某特定條件後,將上一個消散的粒子群清掉,不然會產生一堆粒子散落在場景不可知的地方繼續飄移。
瞭解了原理後下面就開始來實作吧!
請參考完整原始碼及成果展示,請點擊右上角 explosionTrigger
觸發爆炸。
友情提醒,爆炸中心可能有點亮,會畏光的讀者可以先將螢幕調暗保護眼睛,筆者已將 Demo 預設粒子尺寸先降至 20
,看起來比較不刺眼,但似乎效果比較像煙火;大約需設到 100
以上,比較有厚實的爆炸感。
// 變數宣告
const pointCount = 10000 // 粒子總數
const movementSpeed = 10 // 移速種子
let explosion // 爆炸物件
let size = 50 // 粒子尺寸
const textureLoader = new THREE.TextureLoader()
const smokeTexture = textureLoader.load('./smoke.png')
// 爆炸類別
class Explosion {
constructor(x, y) {
// 幾何體
const geometry = new THREE.Geometry()
// 材質
this.material = new THREE.PointsMaterial({
size: size,
color: new THREE.Color(Math.random() * 0xffffff), // 顏色隨機
map: smokeTexture,
blending: THREE.AdditiveBlending,
depthTest: false,
transparent: true,
opacity: 0.7
})
this.pCount = pointCount
this.movementSpeed = movementSpeed
this.dirs = []
// 建立粒子系統所需頂點
for (let i = 0; i < this.pCount; i++) {
const vertex = new THREE.Vector3(x, y, 0) // 每個頂點起點都在爆炸起源點
geometry.vertices.push(vertex)
}
let points = new THREE.Points(geometry, this.material)
this.object = points
scene.add(this.object)
}
}
這邊將爆炸寫成一個 class 物件,可以在之後要做多重爆炸效果時派上用場,建立粒子系統的方法都跟前面差不多,唯一不同的是這裡每個頂點的起點都設在同一點,才會有一群粒子從爆炸中心散射的效果。
class Explosion {
constructor(x, y) {
...
for (let i = 0; i < this.pCount; i++) {
...
const r = this.movementSpeed * THREE.Math.randFloat(0, 1)
// 噴射方向隨機 -> 不規則球體
const theta = Math.random() * Math.PI * 2
const phi = Math.random() * Math.PI
this.dirs.push({
x: r * Math.sin(phi) * Math.cos(theta),
y: r * Math.sin(phi) * Math.sin(theta),
z: r * Math.cos(phi)
})
}
}
}
這邊在爆炸物件中另外建立一個爆炸方向的屬性,取名為 dirs
,是一個陣列,儲存每一個粒子的爆炸方向,而在前言中那個範例他將每個方向都設為 Math.random() * r - r / 2
,會讓爆炸時的形狀類似正方體:
於是參考這篇文章做球體的方法,改寫成球體座標,並且將半徑用移動速度再乘上一個隨機值就能產生接近球體但比較像爆炸的不規則形狀。
class Explosion {
...
update() {
let p = this.pCount
const d = this.dirs
while (p--) {
let particle = this.object.geometry.vertices[p]
particle.x += d[p].x
particle.y += d[p].y
particle.z += d[p].z
}
this.object.geometry.verticesNeedUpdate = true
}
}
function render() {
if (explosion) {
explosion.update()
}
...
requestAnimationFrame(render)
renderer.render(scene, camera)
}
在爆炸物件實作一個 method 用來做爆炸的動畫 update()
,方法就是每個頂點依據自己的噴射方向屬性一直疊加,最後會漸漸淡出視野可見範圍,達到爆炸的效果。
由於一直產生一大群粒子系統消散後不清除,會是一件很佔記憶體的行為,所以參考官方提供將物體從場景中移除的正確寫法:
function render() {
var geometry = new THREE.SphereBufferGeometry(...);
var material = new THREE.MeshBasicMaterial(...);
var mesh = new THREE.Mesh( geometry, material );
scene.add( mesh );
renderer.render( scene, camera );
scene.remove( mesh );
// clean up
geometry.dispose();
material.dispose();
texture.dispose();
}
於是在我們的爆炸類別中添加一個移除粒子系統的 method 來做 dispose()
:
class Explosion {
...
destroy() {
this.object.geometry.dispose()
scene.remove(this.object)
// console.log(renderer.info) // 驗證
this.dirs.length = 0
}
}
並且在每次點擊 dat.GUI 觸發爆炸時去做 destroy
:
this.explosionTrigger = function() {
if (explosion) {
explosion.destroy()
}
explosion = new Explosion(0, 0)
}
但是不是真的成功釋放了,筆者找到一個驗證方法是把 renderer.info
印出來看:
在觸發三次爆炸後,能觀察發現渲染器中始終都只有存在一個粒子系統,並且粒子數一直維持一萬顆。
在試著加粒子總數與移動速度的 dat.GUI 控制時,一開始加上去後會沒有效果,參考了其他人針對粒子總數用 dat.GUI 的寫法後,看起來在更新畫面前,要把舊的釋放掉,再重新建立一個新的粒子系統才會生效。
但試著照做後一直遇到原本粒子系統的 update
正在使用 pointCount
的問題,直覺是資料開始互相衝突了,開始體會到框架開發的方便性,之後要用 Vue 整合時再來解決這個問題,順便把爆炸及其他功能模組化,不然程式也是越來越大包了,可讀性開始變差。
觀察 Demo 會覺得爆炸的效果好像哪裡不太像,所以試著調整了爆炸方向的形狀,並且把相機的近平面距離利用 dat.GUI 調整比較高的參數,讓過近的爆炸粒子能直接消失,視覺效果會好一些。
上面有提到目前是藉由 renderer.info
來確認,但不確定這樣驗證正不正確,如果讀者有其他更正確的驗證方法,麻煩不吝指教,感恩!
今天成功地練習了如何利用粒子系統來實作爆炸的效果,並試著使用 dispose()
來做記憶體的釋放,達到優化的作用,明天會將爆炸效果套到專案中的苦力怕自爆上,並試著模擬得更像一些,我們明天見!