You know nothing, Jon Snow. 在實作粒子系統的動畫效果時,踩到了個大坑,本篇將會繼續完成前一天的雪花飄落動畫,並分享實作中遇到的問題及可以優化效能的部分。
Photo by Zack Sharf on IndieWire
這是本系列第 15 篇,如果還沒看過第 14 篇可以點以下連結前往:
用 Three.js 來當個創世神 (14):粒子系統 - 雪花 Part.1
今天將接續昨天的靜態雪花場景,來將飄落的雪花動畫完成:
function createPoints() {
...
for (let i = 0; i < particleCount; i++) {
...
point.velocityX = THREE.Math.randFloat(-0.16, 0.16) // 粒子橫向移動速度
point.velocityY = THREE.Math.randFloat(0.1, 0.3) // 粒子縱向移動速度
...
}
points = new THREE.Points(geometry, material)
scene.add(points)
}
// 雪花落下循環動畫
function pointsAnimation() {
points.geometry.vertices.forEach(function(v) {
v.y = v.y - v.velocityY
v.x = v.x - v.velocityX
if (v.y <= -250) v.y = 250
if (v.x <= -250 || v.x >= 250) v.velocityX = v.velocityX * -1
})
points.geometry.verticesNeedUpdate = true // 告訴渲染器需更新頂點位置
}
這邊先在建立每一個頂點的地方加上兩個屬性,分別是 x 與 y 軸的移動速度,x 軸為模擬雪花橫向飄動的速度,介於(-0.16, 0.16)
間,而 y 軸模擬雪花落下的速度,介於(0.1, 0.3)
之間。
並且在 render()
中實作一個動畫函式處理動畫的邏輯,遍歷所有粒子的頂點位置,讓每個粒子沿著各方向的速度運動,當打到底部及左右的邊界時,底部的粒子會直接循環回最頂部,而在左右邊界的粒子會往反向繼續飄落。
最後要加上 points.geometry.verticesNeedUpdate = true
這行,告訴渲染器需更新頂點位置,動畫才會有效果。
友情提醒:實作內容到此已完成,以下為開發過程中,遇到問題的筆記,請耐心服用或直接跳至今日小結。
儘管動畫邏輯看起來不難,但還是踩到了個大坑,就是如果沒寫 points.geometry.verticesNeedUpdate = true
這行,那動畫就不會動,過程中還懷疑是不是 Three.js 版本問題、或是需要使用 BufferGeometry
等等原因,抽絲剝繭再參考其他範例怎麼做動畫的,把 points.geometry
印出來才發現還真有個屬性叫 verticesNeedUpdate
預設是 false
。
原因是因為,在一般的幾何體中並不需要頻繁的更新頂點位置,所以為了節省效能,幾何體中的 verticesNeedUpdate
這個屬性預設都是 false
,但在粒子系統中需要「操作頂點位移來達到動畫效果」時,就需要告訴 renderer 說:「嘿!幫我更新一下!」,它才會重新去渲染新的頂點位置。
深入地分析可以參考這篇文章:浅谈three.js中的needsUpdate。
另外記錄一下因為做粒子系統動畫效果時卡關這麼久的 debug 過程:
由於遇到前面動畫一直不會動的問題,仔細地參考了官方雪花的範例,並參考它在動畫處的寫法:
for (let i = 0; i < scene.children.length; i++) {
var object = scene.children[i]
if (object instanceof THREE.Points) {
object.position.y -= 0.16
if (object.position.y < 0) {
object.position.y = 100
}
}
}
是成功讓雪花飄落了,但卻會遇到全部粒子一同在某一瞬間位移的問題,仔細一看才知道官方的範例是控制五大群不同貼圖的粒子系統,讓它們有不同的運動才這樣寫,所以意思是這樣改會控到一整群的粒子,因此才會大家一起位移。
於是改參考這個很華麗的粒子流的寫法,使用 BufferGeometry
來改寫才終於正常,也才發現這個 needsUpdate
好像是關鍵,然後才回去試原本的寫法,果然只是需要設定屬性:
function render() {
const array = points.geometry.attributes['position'].array
let offset = 1
for (let i = 0; i < particleCount; i++) {
array[offset] -= THREE.Math.randFloat(0.1, 0.3)
if (array[offset] < -250) array[offset] = 250
offset += 3
}
points.geometry.attributes.position.needsUpdate = true
...
requestAnimationFrame(render)
renderer.render(scene, camera)
}
動畫正常後遇到的另一個問題,怎麼會有某一層特別厚,而這個原因就比較蠢一點,前面說過每一個頂點的初始位置是將(x, y, z)
值分別設定在介於(-250, 250)
的隨機值,但因為一開始在動畫循環寫法如下:
function pointsAnimation() {
points.geometry.vertices.forEach(function(v) {
v.y = v.y - 0.5
if (v.y <= 0) v.y = 250
})
points.geometry.verticesNeedUpdate = true
}
眼尖的讀者應該可以容易地找出 bug,就在 if (v.y <= 0) v.y = 250
這行,沒錯,底部邊界是在 -250,也就是說設成 0,一開始就會有一半左右的粒子被循環回頂點,所以才會有一層特別厚。
在實作中筆者使用 Geometry
這個幾何體來做為粒子系統的外殼,它相當的方便,因為它具有許多基本屬性像是 vertices、faces、colors 等等,但其實有個更節省記憶體與 GPU 計算的幾何體常被拿來做粒子系統,那就是 BufferGeometry
。
但其實 BufferGeometry
寫起來有點沒那麼友善,需要什麼屬性要自己加:
function createPoints() {
const geometry = new THREE.BufferGeometry()
const vertices = []
material = new THREE.PointsMaterial({
size: 5
})
const range = 500
for (let i = 0; i < particleCount; i++) {
const x = Math.random() * range - range / 2
const y = Math.random() * range - range / 2
const z = Math.random() * range - range / 2
vertices.push(x, y, z)
}
// 加個 position 屬性記錄各頂點位置
geometry.addAttribute(
'position',
new THREE.Float32BufferAttribute(vertices, 3)
)
points = new THREE.Points(geometry, material)
scene.add(points)
}
但它也的確可以節省很多計算上的資源,等哪天專案開始風扇一直轉的時候可能要考慮優化這部分一下。
這篇文章在討論有人實驗發現 Geometry
比 BufferGeometry
還快,但大部分回覆者都表示 Geometry
會配置更多記憶體、在 Three.js 內部機制最後仍會被轉換成 BufferGeometry
、之後可能會移除 Geometry
等問題。
在 google 前面說的大坑時,找到這篇問答:Best way to update THREE.Points Geometry,看起來透過更新頂點位置的方法是使用 CPU 計算,如果能用 shader 改寫的話直接讓 GPU 計算應該在效能上會更好。
這兩天完成了粒子系統中一個經典的雪花效果,並且透過載入貼圖及更新頂點的方法,一路跌跌撞撞終於成功創造出一場大雪,也利用開發途中遇到的一些問題學習粒子系統的觀念,明天會利用這幾天的練習,將更多的粒子特效套用到遊戲的場景中,我們明天見!