把網頁當畫布揮灑的 Canvas 09:粒子特效

火焰粒子特效

利用上一篇製作的模板來開發粒子特效吧!


我們透過物件導向開發的觀念,分別建立粒子類別以及重力場類別,各自包含粒子物件以及重力場物件的屬性與方法,接著利用 updatedraw 函式來更新資料與畫面,整體程式架構相當符合我們上一篇所製作的開發模板。

以下分別紀錄粒子類別、重力場類別以及函式模組執行的內容,和模板重複的部分就不多加贅述,

粒子類別

透過 ES6 的 class 宣告類別, constructor 函式用來建構物件實體的屬性,這裡我們傳入一個參數 args ,它會是一個物件,用來預設粒子產生時各屬性的初始值。

Object.assign() 語法可以合併兩個物件,我們首先定義一個預設的粒子物件 def ,並將它和傳入的參數 args 物件合併,接著再將實體化的物件 thisdef 合併,達到建構物件實體屬性的目的,如此一來,我們就不需要像以前一樣繁瑣地將一個個參數傳入,直接透過參數 args 定義類別屬性,並傳入 constructor 就可以囉!

粒子的 update 方法所負責的任務,包括更新粒子本身的物理狀態、改變半徑大小、判斷顏色變化以及檢查粒子是否超出邊界(若有,就改變粒子的速度方向)。

粒子物件的 update 方法裡的 this.v.move(0, controls.ay)讓粒子的速度額外增加一個 y 軸的加速度,這裡綁定到 Dat-gui 的 controls.ay 上,詳細可以拉到本文最底的 Dat-gui 部分。

因為我想讓粒子呈像火焰的效果(雖然實作出來還是很不像xD),所以我在粒子身上新增 t 屬性表示粒子誕生後所經過的時間,隨著 this.t++ ,判斷 t 值並改變粒子顏色 color ,色票參考來源為 Flame of Fire Color Palette

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class Particle {
constructor(args) {
// def means the default properties of the object
const def = {
p: new Vec2(0, 0), // Position
v: new Vec2(1, 0), // Velocity
a: new Vec2(0, 0), // Acceleration
r: 10, // Radius
t: 0, // Time
color: '#fff'
}
// Use Object.assign() to merge the args and def
Object.assign(def, args)
// Use Object.assign() to assign combined object (def) to the object instance (this)
Object.assign(this, def)
}

// Draw the particle
draw() {
ctx.save()
ctx.translate(this.p.x, this.p.y)
ctx.beginPath()
ctx.arc(0, 0, this.r, 0, Math.PI * 2)
ctx.fillStyle = this.color
ctx.fill()
ctx.restore()
}

// Update the particle's status
update() {
this.p = this.p.add(this.v)
this.v = this.v.add(this.a)
this.v.move(0, controls.ay)
this.v = this.v.mul(0.99) // The friction makes the particle slow down
this.r *= controls.fade
this.t++

// Change the particle's color as time increases
switch(this.t) {
case(5):
this.color = `rgba(255, 206, 0, 1)`
break;
case(10):
this.color = `rgba(255, 154, 0, 0.9)`
break;
case(15):
this.color = `rgba(255, 90, 0, 0.5)`
break;
case(20):
this.color = `rgba(255, 0, 0, 0.3)`
break;
}

// Check whether the particle is beyond the boundary
if(this.p.x + this.r > ww) {
this.v.x = -Math.abs(this.v.x)
}

if(this.p.x - this.r < 0) {
this.v.x = Math.abs(this.v.x)
}

if(this.p.y + this.r > wh) {
this.v.y = -Math.abs(this.v.y)
}

if(this.p.y - this.r < 0) {
this.v.y = Math.abs(this.v.y)
}
}
}


重力場類別

同樣透過 class 宣告重力場類別,用 Object.assign() 建立物件屬性的方式與粒子類別相同。

重力場物件裡的 value 屬性表示重力場的強度,它是具有方向性的力量(向量),會改變粒子的速度變化。

重力場作用力

基本上,重力場對粒子的作用力(F)重力場和粒子之間的距離次方倍(d^2)反比,換句話說,重力場與粒子之間的距離愈短,力場的作用力愈大;反之,粒子距離重力場愈遠,力場作用力愈小。

簡而言之, value 可以是正值,也可以是負值。當 value正值,它會對粒子施加排斥的力量;當 value負值,它會對粒子施加吸引的力量,就像黑洞一樣把粒子都吸收。

在重力場物件的 affect 方法中計算了重力場 value 值對粒子速度的影響:

  1. const delta = particle.p.sub(this.p) :計算粒子與重力場之間的距離(d)。
  2. const len = this.value / (1 + delta.length) :將力場強度與 d 相除(d + 1 避免為 0),得到力場作用力的單位比率(len)。
  3. const force = delta.unit.mul(len) :立場作用力(F)為 d 的單位向量乘上 len 。
  4. particle.v.move(force.x, force.y) :對粒子速度施加力場的作用力(F)。

在重力場物件的 darw 方法中,我們借用 value 值作為重力場的半徑,記得透過 Math.abs(this.value) 取正值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Force field Class
class Forcefield {
constructor(args) {
const def = {
p: new Vec2(), // Position
value: 100 // The effect and radius of the force field
}
Object.assign(def, args)
Object.assign(this, def)
}

// Draw the force field
draw() {
ctx.save()
ctx.translate(this.p.x, this.p.y)
ctx.beginPath()
ctx.arc(0, 0, Math.sqrt(Math.abs(this.value)), 0, Math.PI * 2)
ctx.fillStyle = 'white'
ctx.fill()
ctx.restore()
}

// The effect of force field on each particle
affect(particle) {
const delta = particle.p.sub(this.p)
const len = this.value / (1 + delta.length) // To avoid that delta.length equals to 0, add 1 to it
const force = delta.unit.mul(len) // The force is inversely proportional to the delta
particle.v.move(force.x, force.y) // Add the force to each particle
}
}


update() 更新資料

每次執行 update 所要執行的任務包括:製造新的粒子更新每個粒子的狀態消滅已經看不到的粒子。簡單來說,就是處理粒子的生命週期

粒子的生命週期

為什麼要消滅已經看不到的粒子呢?要知道,每一個粒子代表的是一筆資料,當資料量愈來愈多,處力運算也會愈來愈慢,效能就會變差,畫面會開始卡頓、延遲。因此,適時刪除不必要的資料來釋放記憶體是必要的。

首先,我們在全域宣告兩個陣列分別儲存粒子和重力場的資料。

在製造新粒子的部分,使用 Array.from() 語法一次製造 5 筆粒子資料,透過 new Particle() 傳入物件作為參數 args 來實體化粒子物件的屬性,並設定我們要的初始屬性,包括位置、速度、半徑以及顏色,搭配 Math.random() 讓粒子狀態更加多元。

透過 forEach() 陣列方法來更新 particles 陣列裡面每一個粒子的資料。

至於消滅粒子的實作,我們傾向不要動到原本的 particles 陣列,先以 particles.slice() 複製一份後,再透過 forEach() 篩選,那既然都把粒子抓出來迭代了,索性一併處理重力場對粒子的影響,再加入判斷式,將小到看不見的粒子從陣列中移除並回傳 const pp = sp.splice(index, 1) ,進而 delete 該筆粒子資料。

⭐ 最後別忘了將篩選後的陣列重新賦值給 particles !

Array.form()slice()forEach()splice() 都是處理陣列的方法,詳細使用方式可以參考〈JavaScript Array 陣列操作方法大全 ( 含 ES6 )〉這篇文章。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Declare an array to contain all particles
let particles = []

// Declare an array to contain all forcefields
let forcefields = []

// Update the status
function update() {
// Create #gcount particles and merge them to the particles[]
particles = particles.concat(Array.from({length: controls.gcount}, (d, i) => {
return new Particle({
p: mousePos.clone(),
v: new Vec2(Math.random() * controls.v - controls.v / 2, Math.random() * controls.v - controls.v / 2),
r: Math.random() * 20,
color: `rgb(255, 241, 119)`
})
}))

// Update each particle's status
particles.forEach(p => p.update())


const sp = particles.slice() // Copy the particles array
sp.forEach((p, index) => {
forcefields.forEach(ff => ff.affect(p)) // Make each force field affect each particle

// If the particle that has r smaller than 0.1, delete it to release memory
if(p.r < 0.5) {
const pp = sp.splice(index, 1)
delete pp
}
})

// Assign sp to the particles[] for the next update
particles = sp
}


draw() 更新畫面

draw 的任務相當單純,就是分別把粒子資料和重力場資料抓出來自己繪圖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function draw() {
// Clear the background of canvas
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, ww, wh);

// ------------------------------------------------- //
// Draw here ---------------------------------------
ctx.save();
particles.forEach(p => p.draw())
forcefields.forEach(f => f.draw())
ctx.restore();
// ------------------------------------------------- //

requestAnimationFrame(draw);
}


dblclick 滑鼠事件製造重力場

製作連點滑鼠左鍵兩下便 new Forcefield() 一個重力場,傳入設定好屬性的物件作為參數,先前在類別中使用的 Object.assign() 會幫我們合併實體化的物件屬性,不再重複說明。

1
2
3
4
5
6
7
8
9
window.addEventListener('dblclick', dblclick);

function dblclick(e) {
mousePos.set(e.x, e.y)
forcefields.push(new Forcefield({
p: mousePos.clone(),
value: controls.value
}))
}


Dat-gui 設定參數

設定 Dat-gui 控制面板,參數的值與級距可以自行調整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Dat-gui Control
const controls = {
value: 100,
gcount: 3, // How many particles are generated each time
ay: -0.6, // The extra increase in y acceleration
fade: 0.96,
v: 5,
clearForce: function() {
forcefields = []
}
};
const gui = new dat.GUI();
gui.add(controls, "value", -100, 100).step(20).onChange(value => {});
gui.add(controls, "gcount", 0, 30).step(1).onChange(value => {})
gui.add(controls, "ay", -1, 1).step(0.01).onChange(value => {})
gui.add(controls, "fade", 0, 1).step(0.01).onChange(value => {})
gui.add(controls, "v", 0, 30).step(0.01).onChange(value => {})
gui.add(controls, "clearForce")


參考資料

  1. 動畫互動網頁特效入門(JS/CANVAS):5-7 粒子特效
把網頁當畫布揮灑的 Canvas 10:極座標 把網頁當畫布揮灑的 Canvas 08:開發架構與模板

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×