iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Modern Web

Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統系列 第 24 篇

Day 24:技術底層精講(五)!點亮空氣的生命力:THREE.Points 粒子架構、加法混合光學與懸浮微塵系統

  • 分享至 

  • xImage
  •  

在 Day 23 中,我們深入推導了影格獨立的指數阻尼衰減公式,並藉由雙層相機解耦架構,完成了平穩緊湊的電影級鏡頭追蹤。

至此,地宮的骨架、光影與運鏡已經全部就位。但如果你退一步仔細端詳畫面,可能會隱隱覺得缺少了某種「生氣」——空氣太過乾淨清澈,火把燃燒時只有光圈在動,周圍卻沒有木屑劈啪飛濺的火星;地窖深處原本該有的濕潤塵埃與微風流動感,在靜止的畫面中顯得有些呆板。

在 3D 圖形學中,賦予空氣形體與溫度的祕密武器就是粒子系統(Particle System)。許多開發者在實作微粒效果時,常面臨兩大抉擇與困惑:

  1. 為什麼不用一般的平面網格(Plane)來做火花?
  2. 為什麼粒子疊在一起時常有難看的黑邊,或者總是灰濛濛的?
  3. 如何在每幀更新上百顆粒子的情況下,依然維持零記憶體垃圾(Zero GC)與 60 FPS?

今天我們將解密 Three.js 中的 THREE.Points 粒子光柵化機制,探討加法混合(Additive Blending)的光學魔法,並親手為地宮注入飛舞的火星餘燼與空氣懸浮微塵!

一、為什麼不用 Mesh 做粒子?GPU 原生「點光柵化」的極致效能

如果我們想在地宮中灑入 200 顆緩慢飄浮的塵埃,最直覺的想法可能是宣告 200 個 new THREE.Mesh(new THREE.PlaneGeometry(0.1, 0.1), material)。

但這種做法在圖形效能上是極大的浪費:

平面網格(Mesh)的開銷

  • 每個面片由 2 個三角形、4 個頂點組成。
  • 200 顆微塵 = 800 個頂點資料 + 1,200 個頂點索引(Index Buffer)。
  • 顯卡頂點著色器必須為這 800 個頂點分別執行座標矩陣變換。
  • CPU 每一幀需要遍歷更新 200 個 Mesh 物件的 Transform 矩陣,造成沉重的調度負擔。

THREE.Points 的黑魔法:一個頂點就是一顆粒子

在底層 WebGL 中,除了常見的 gl.TRIANGLES 繪製模式外,還有一種專門為粒子設計的原生指令——gl.POINTS。

THREE.Points 正是基於此原理建構:

  • 一個粒子 = 一個 3D 頂點:200 顆微塵只需要剛好 200 個 3D 座標點,頂點運算量瞬間驟降為原本的四分之一。
  • GPU 硬體放大(Point Size):在頂點著色器中,GPU 會直接將該單一頂點投射為螢幕上的方形圖元,並依據設定的 size 屬性直接展開(gl_PointSize = size)。
  • 永遠面向相機:因為它是由 GPU 光柵化硬體直接在螢幕像素空間畫出來的方形色塊,天生就具備「零成本 Billboard 看板」的特性,完全不需要任何旋轉矩陣運算!

二、加法混合(Additive Blending)的光學魔法

粒子效果的成敗,關鍵往往在於材質的混合模式(Blending Mode)。

  1. 傳統正常混合(Normal Blending)的致命黑邊
    Three.js 預設的材質混合模式是 THREE.NormalBlending,其數學本質是 Alpha 透明度覆蓋:

image
在這種模式下,微粒邊緣若帶有半透明漸變,當多顆微粒在暗處重疊時,透明通道的加權計算會互相遮蔽,邊緣容易產生一圈骯髒的灰色髒污。

  1. 光與光的相加:加法混合
    在真實物理世界中,火焰噴濺出的火星本身是「光源」,光線疊加在任何物體上只會越來越亮,絕不可能變暗。
    我們在火星與微塵材質中開啟了加法混合(THREE.AdditiveBlending):
    image

    黑底自動隱形:

    純黑色的像素(RGB = 0, 0, 0)加在任何背景上依然是原色,因此粒子貼圖周圍完全不需要複雜的 Alpha 裁剪,邊緣自然消融在黑暗中。

    光能量物理堆疊:

    當兩顆橘黃色的火星在空中重疊時,色彩數值直接相加,重疊的交匯點會瞬間飆升至亮金白色,完美重現高溫火光的燃燒感。
const emberMat = new THREE.PointsMaterial({
  color: new THREE.Color('#ffaa33'),
  size: 0.12,
  transparent: true,
  opacity: 0.9,
  blending: THREE.AdditiveBlending // 核心:開啟光學加法混合
});
  1. 與 UnrealBloomPass 形成化學反應
    在 Day 18 中,我們將全域輝光門檻(threshold)卡死在極高的 0.98,防止像素主角被誤曝發光。
    加法混合在這裡發揮了絕妙的作用:單顆火星的亮度約為 0.85,正常飄動時維持清晰的像素微粒輪廓;一旦 2~3 顆火星靠近火把核心,數值相加瞬間突破 1.0,直接擊穿了 Bloom 門檻,在空中激發出柔美的橘金色光暈!

三、隨身火把飛舞火星(Torch Embers)的生命週期演算法

如果每秒鐘都在生成新火星、銷毀舊火星,會觸發我們在 Day 22 提到的 V8 垃圾回收(GC)痛點。

我們採用「靜態緩衝區循環輪替(Circular Buffer Recycling)」架構,只配置 18 顆火星的記憶體容器,並在 CPU 端重複覆寫生命週期:


// 1. 初始化火星粒子緩衝區 (只配置一次,永久複用)
const emberCount = 18;
const emberPositions = new Float32Array(emberCount * 3);
const emberVelocities = [];

for (let i = 0; i < emberCount; i++) {
  // 初始空間隨機微擾
  emberPositions[i * 3 + 0] = (Math.random() - 0.5) * 0.15;
  emberPositions[i * 3 + 1] = Math.random() * 0.5;
  emberPositions[i * 3 + 2] = (Math.random() - 0.5) * 0.15;

  emberVelocities.push({
    vx: (Math.random() - 0.5) * 0.25, // 隨微風水平擾動
    vy: 0.8 + Math.random() * 0.6,     // 熱氣流向上衝升速度
    vz: (Math.random() - 0.5) * 0.25,
    life: Math.random()               // 隨機錯開每顆粒子的初始年齡
  });
}

const emberGeo = new THREE.BufferGeometry();
emberGeo.setAttribute('position', new THREE.BufferAttribute(emberPositions, 3));
const emberParticles = new THREE.Points(emberGeo, emberMat);

// 精準掛載至主角手中的火焰核心高度 (y = 2.82)
emberParticles.position.set(torchOffsetX, torchOffsetY, torchOffsetZ);
playerGroup.add(emberParticles);

零 GC 的每幀輪替推進

在主渲染迴圈 animate() 中,我們直接修改 TypedArray 緩衝區內的數值:

// 每幀更新火星粒子物理
const posAttr = emberGeo.attributes.position;

for (let i = 0; i < emberCount; i++) {
  const v = emberVelocities[i];
  
  // 根據速度向量向上推進
  posAttr.setX(i, posAttr.getX(i) + v.vx * dt);
  posAttr.setY(i, posAttr.getY(i) + v.vy * dt);
  posAttr.setZ(i, posAttr.getZ(i) + v.vz * dt);

  // 推進生命週期 (0.0 ~ 1.0)
  v.life += dt * 1.5;

  // 核心:生命週期結束時,重置回火焰底部,嚴禁 new 新物件!
  if (v.life > 1.0) {
    v.life = 0;
    posAttr.setXYZ(
      i, 
      (Math.random() - 0.5) * 0.1, 
      0, 
      (Math.random() - 0.5) * 0.1
    );
  }
}

// 標記頂點緩衝區已被修改,通知 GPU 在下一幀同步資料
posAttr.needsUpdate = true;

// 粒子發射器錨點隨火把動態換手
emberParticles.position.x = playerLight.position.x;

四、全域地宮空氣懸浮微塵(Atmospheric Dust Motes)

除了火把周圍的躍動火星,地窖整體空間也需要一層緩慢漂浮的懸浮微塵,營造古老遺跡潮濕封閉的空氣感。

微塵的運動規律與火星截然不同:火星是受熱氣流驅動的向上噴射,而微塵則是受微弱氣流擾動的「布朗運動(Brownian Motion)」。

// 全域懸浮微塵發射器
const dustCount = 120;
const dustPositions = new Float32Array(dustCount * 3);

// 在 30x30 的地宮空間中隨機灑布
for (let i = 0; i < dustCount * 3; i += 3) {
  dustPositions[i + 0] = (Math.random() - 0.5) * 30;
  dustPositions[i + 1] = Math.random() * 4.0; // 飄浮高度 0 ~ 4 公尺
  dustPositions[i + 2] = (Math.random() - 0.5) * 30;
}

const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3));

const dustMat = new THREE.PointsMaterial({
  color: new THREE.Color('#8899bb'), // 清冷帶灰的微藍色調
  size: 0.08,                        // 微小點狀,不搶角色風采
  transparent: true,
  opacity: 0.45,
  blending: THREE.AdditiveBlending
});

const dustParticles = new THREE.Points(dustGeo, dustMat);
scene.add(dustParticles);

微塵與光線的化學反應

在渲染迴圈中,我們讓微塵隨著正弦波極其緩慢地浮動。當主角手持火把走過走廊時:

  • 身處黑暗角落的微塵僅反射微弱的月光底色,若隱若現。
  • 當火把靠近時,1850K 的暖金火光照亮微塵,微塵在火光邊界閃爍出微小的高光顆粒,隨後隱入後處理移軸景深的散景之中。

這種微觀尺度的粒子流動,讓冷硬的 3D 石磚與 2D 像素主角之間產生了一層透明的空氣介質,微縮模型感與空間縱深在此時徹底交融。

【本日結語】
今天我們解密了 GPU 粒子系統在 HD-2D 美學中的地位:

  • 點光柵化效能優勢:捨棄繁重的 Mesh 面片,以原生 THREE.Points 達成單頂點極致光柵化。

  • 加法混合(Additive Blending):利用光的相加特性消滅黑邊,並在粒子交疊時自然擊穿 Bloom 高門檻產生光暈。

  • 記憶體零回收輪替:利用靜態 TypedArray 與常駐物件池,以零 GC 開銷完成生生不息的火星發射。

  • 懸浮微塵氛圍:在全域空間鋪設微流動浮塵,打破虛擬場景的塑料真空感,讓地下城的空氣真正「活」了過來!

在下一篇 Day 25 中,我們將探討圖形渲染中極易被忽視的硬核領域——色彩科學與色調映射(Color Space & Tone Mapping)!為什麼許多 WebGL 專案的畫面總是「灰濛濛一片」或「高光嚴重死白」?我們將深入拆解線性空間、Gamma 校正與 ACESFilmicToneMapping 的光學底層!


上一篇
Day 23:技術底層精講(四)!電影級運鏡手感:告別幀率綁定 lerp,指數阻尼衰減(Exponential Decay)與雙層相機架構
下一篇
Day 25:技術底層精講(六)!色彩科學與色調映射:為什麼 WebGL 畫面總是「太灰」或「死白」?ACESFilmic 與 OutputPass 解密
系列文
Three.js × WebGL 實戰:打造 HD-2D 像素地下城與即時光影系統 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言