前言

如果你曾經看過那些在網頁上飄散的火花、煙霧、或是爆炸特效,背後幾乎都離不開一個核心概念——粒子系統(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; } }

這裡有幾個設計上的重點值得注意:

  1. applyForce 方法:我們不直接修改加速度,而是透過外力疊加。這讓我們可以同時施加多種力(重力、風力、阻力等)。
  2. 每幀重置加速度this.acc.mult(0) 確保力不會無限累積。物理上來說,力是即時施加的,不是持續的。
  3. 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) 讓粒子逐漸減速
  • 邊界碰撞:粒子碰到地面和牆壁會反彈
  • 生命週期:粒子逐漸淡出並被移除
  • 拖尾效果:半透明背景讓粒子留下軌跡
  • 暖色系色彩:隨機的紅橙色調,像火焰一樣

效能小提醒

當粒子數量很多時(超過幾千顆),效能會開始下降。幾個常見的優化手段:

  1. 限制粒子上限:在 emit() 前檢查 this.particles.length < MAX_PARTICLES
  2. 使用物件池:不用 splice 移除,而是把死亡粒子標記為「可重用」,下次發射時直接重置屬性
  3. 減少繪製成本:用方形(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 頻道的粒子系統系列教學