前言
如果你曾經看過那些在網頁上飄散的火花、煙霧、或是爆炸特效,背後幾乎都離不開一個核心概念——粒子系統(Particle System)。粒子系統是創意程式設計中最經典、也最令人著迷的主題之一。它的美在於:每一顆粒子的行為都很簡單,但當成百上千顆粒子同時運作,就會湧現出令人驚嘆的複雜視覺效果。
我第一次在 p5.js 裡寫出粒子系統的時候,看著那些小點在螢幕上因為重力而弧線落下、碰到邊界反彈、然後逐漸消逝,心裡想的是:「這就是用程式碼創造生命的感覺吧。」
這篇文章會帶你從零開始,一步步建構一個完整的粒子系統。我們會涵蓋 Particle 類別設計、重力加速度、生命週期管理、基礎碰撞偵測,最後整合成一個完整的互動範例。
Particle 類別設計
粒子系統的核心就是「粒子」本身。在物件導向的思維裡,我們會把每一顆粒子封裝成一個類別。每顆粒子至少需要以下屬性:
- 位置(position):粒子在畫布上的座標
- 速度(velocity):粒子的移動方向與速率
- 加速度(acceleration):外力對粒子的影響(例如重力)
- 生命值(lifespan):粒子從誕生到消亡的倒數計時
來看最基本的 Particle 類別:
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.vel = createVector(random(-2, 2), random(-4, -1));
this.acc = createVector(0, 0);
this.lifespan = 255;
this.size = random(4, 8);
}
applyForce(force) {
this.acc.add(force);
}
update() {
this.vel.add(this.acc);
this.pos.add(this.vel);
this.acc.mult(0); // 每幀重置加速度
this.lifespan -= 3;
}
display() {
noStroke();
fill(255, this.lifespan);
ellipse(this.pos.x, this.pos.y, this.size);
}
isDead() {
return this.lifespan <= 0;
}
}
這裡有幾個設計上的重點值得注意:
applyForce方法:我們不直接修改加速度,而是透過外力疊加。這讓我們可以同時施加多種力(重力、風力、阻力等)。- 每幀重置加速度:
this.acc.mult(0)確保力不會無限累積。物理上來說,力是即時施加的,不是持續的。 - lifespan 與透明度綁定:lifespan 從 255 遞減到 0,剛好對應 p5.js 的 alpha 值範圍,粒子會自然淡出。
重力加速度
重力是粒子系統中最常見的外力。在真實世界裡,重力加速度約為 9.8 m/s²,但在螢幕上的模擬世界裡,我們需要調整這個數值以符合視覺效果。
let gravity = createVector(0, 0.1);
為什麼是 0.1 而不是 9.8?因為我們的座標單位是像素,時間單位是幀。如果設成 9.8,粒子一幀就飛出畫面了。通常 0.05 到 0.2 之間的值會給出不錯的效果。
你也可以加入風力,讓粒子有水平方向的飄移:
let wind = createVector(0.05, 0);
在 draw() 迴圈裡,我們對每顆粒子施加這些力:
function draw() {
background(30);
let gravity = createVector(0, 0.1);
let wind = createVector(0.02, 0);
for (let particle of particles) {
particle.applyForce(gravity);
particle.applyForce(wind);
particle.update();
particle.display();
}
}
這種「施力 → 更新 → 顯示」的流程,就是粒子系統的心跳節奏。
粒子生命週期
粒子不能永遠活著(否則記憶體會爆掉),所以我們需要管理它們的生命週期。這裡有個經典的設計模式——粒子發射器(Emitter):
class Emitter {
constructor(x, y) {
this.origin = createVector(x, y);
this.particles = [];
}
emit(count) {
for (let i = 0; i < count; i++) {
this.particles.push(new Particle(this.origin.x, this.origin.y));
}
}
update() {
// 每幀發射新粒子
this.emit(2);
// 反向遍歷,安全移除死亡粒子
for (let i = this.particles.length - 1; i >= 0; i--) {
let p = this.particles[i];
let gravity = createVector(0, 0.1);
p.applyForce(gravity);
p.update();
if (p.isDead()) {
this.particles.splice(i, 1);
}
}
}
display() {
for (let p of this.particles) {
p.display();
}
}
}
有個重要的細節:反向遍歷(從後往前)。當我們從陣列中移除元素時,如果正向遍歷,移除元素後索引會錯位,導致某些粒子被跳過。反向遍歷可以完美避免這個問題。
碰撞偵測基礎
最簡單的碰撞偵測是邊界碰撞——讓粒子碰到畫布邊緣時反彈:
checkEdges() {
if (this.pos.x > width || this.pos.x < 0) {
this.vel.x *= -0.8; // 反彈並損失能量
}
if (this.pos.y > height) {
this.pos.y = height;
this.vel.y *= -0.6; // 地面反彈,損失更多能量
}
}
注意那個 -0.8 和 -0.6:乘以負數讓速度反向(反彈),而絕對值小於 1 表示每次碰撞都會損失一些動能。這就是非彈性碰撞,讓粒子的行為更自然——就像球掉在地上會越彈越低一樣。
對於粒子之間的碰撞,最簡單的做法是距離檢測:
checkCollision(other) {
let d = dist(this.pos.x, this.pos.y, other.pos.x, other.pos.y);
let minDist = (this.size + other.size) / 2;
if (d < minDist) {
// 簡單的速度交換
let tempVel = this.vel.copy();
this.vel = other.vel.copy();
other.vel = tempVel;
// 把粒子推開,避免重疊
let overlap = minDist - d;
let direction = p5.Vector.sub(this.pos, other.pos).normalize();
this.pos.add(p5.Vector.mult(direction, overlap / 2));
other.pos.sub(p5.Vector.mult(direction, overlap / 2));
}
}
這裡的碰撞處理是簡化版的,真正的彈性碰撞需要考慮質量和動量守恆(我們在下一篇會深入討論)。但對於視覺效果來說,這個簡單版本已經夠用了。
完整粒子系統範例
讓我們把所有東西整合在一起,做一個跟隨滑鼠的互動粒子系統:
let emitter;
function setup() {
createCanvas(800, 600);
emitter = new Emitter(width / 2, height / 2);
}
function draw() {
background(30, 30, 40, 25); // 半透明背景,製造拖尾效果
// 發射器跟隨滑鼠
emitter.origin.set(mouseX, mouseY);
emitter.update();
emitter.display();
// 顯示粒子數量
fill(200);
noStroke();
textSize(14);
text('Particles: ' + emitter.particles.length, 20, 30);
}
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.vel = p5.Vector.random2D().mult(random(1, 4));
this.acc = createVector(0, 0);
this.lifespan = 255;
this.size = random(3, 10);
this.color = color(
random(200, 255),
random(50, 150),
random(20, 80),
255
);
}
applyForce(force) {
this.acc.add(force);
}
update() {
this.vel.add(this.acc);
this.pos.add(this.vel);
this.acc.mult(0);
this.lifespan -= 2.5;
this.vel.mult(0.98); // 空氣阻力
}
display() {
noStroke();
let c = this.color;
fill(red(c), green(c), blue(c), this.lifespan);
ellipse(this.pos.x, this.pos.y, this.size);
}
isDead() {
return this.lifespan <= 0;
}
checkEdges() {
if (this.pos.y > height) {
this.pos.y = height;
this.vel.y *= -0.6;
}
if (this.pos.x < 0 || this.pos.x > width) {
this.vel.x *= -0.8;
}
}
}
class Emitter {
constructor(x, y) {
this.origin = createVector(x, y);
this.particles = [];
}
emit(count) {
for (let i = 0; i < count; i++) {
this.particles.push(new Particle(this.origin.x, this.origin.y));
}
}
update() {
this.emit(3);
let gravity = createVector(0, 0.08);
for (let i = this.particles.length - 1; i >= 0; i--) {
let p = this.particles[i];
p.applyForce(gravity);
p.update();
p.checkEdges();
if (p.isDead()) {
this.particles.splice(i, 1);
}
}
}
display() {
for (let p of this.particles) {
p.display();
}
}
}
這個範例包含了我們討論的所有元素:
- 重力:粒子會自然下落
- 空氣阻力:
this.vel.mult(0.98)讓粒子逐漸減速 - 邊界碰撞:粒子碰到地面和牆壁會反彈
- 生命週期:粒子逐漸淡出並被移除
- 拖尾效果:半透明背景讓粒子留下軌跡
- 暖色系色彩:隨機的紅橙色調,像火焰一樣
效能小提醒
當粒子數量很多時(超過幾千顆),效能會開始下降。幾個常見的優化手段:
- 限制粒子上限:在
emit()前檢查this.particles.length < MAX_PARTICLES - 使用物件池:不用
splice移除,而是把死亡粒子標記為「可重用」,下次發射時直接重置屬性 - 減少繪製成本:用方形(
rect)代替圓形(ellipse),因為圓形需要更多計算
// 物件池示範
emit(count) {
for (let i = 0; i < count; i++) {
let recycled = this.particles.find(p => p.isDead());
if (recycled) {
recycled.reset(this.origin.x, this.origin.y);
} else if (this.particles.length < 1000) {
this.particles.push(new Particle(this.origin.x, this.origin.y));
}
}
}
小結
粒子系統是創意程式設計的瑞士刀——學會了這一招,你可以做出煙火、下雨、下雪、火焰、星空、灰塵……幾乎所有的自然現象都可以用粒子來模擬。
這篇文章我們建立了基礎框架:Particle 類別、力的施加機制、生命週期管理、邊界碰撞。在下一篇文章裡,我們會深入探討更真實的物理模擬——彈性碰撞和簡諧運動。
延伸閱讀
- Daniel Shiffman《The Nature of Code》第四章 — 粒子系統的經典教材
- p5.js 官方範例:Particle System
- Coding Train YouTube 頻道的粒子系統系列教學