在 Day 23 中,我們深入推導了影格獨立的指數阻尼衰減公式,並藉由雙層相機解耦架構,完成了平穩緊湊的電影級鏡頭追蹤。
至此,地宮的骨架、光影與運鏡已經全部就位。但如果你退一步仔細端詳畫面,可能會隱隱覺得缺少了某種「生氣」——空氣太過乾淨清澈,火把燃燒時只有光圈在動,周圍卻沒有木屑劈啪飛濺的火星;地窖深處原本該有的濕潤塵埃與微風流動感,在靜止的畫面中顯得有些呆板。
在 3D 圖形學中,賦予空氣形體與溫度的祕密武器就是粒子系統(Particle System)。許多開發者在實作微粒效果時,常面臨兩大抉擇與困惑:
今天我們將解密 Three.js 中的 THREE.Points 粒子光柵化機制,探討加法混合(Additive Blending)的光學魔法,並親手為地宮注入飛舞的火星餘燼與空氣懸浮微塵!
如果我們想在地宮中灑入 200 顆緩慢飄浮的塵埃,最直覺的想法可能是宣告 200 個 new THREE.Mesh(new THREE.PlaneGeometry(0.1, 0.1), material)。
但這種做法在圖形效能上是極大的浪費:
在底層 WebGL 中,除了常見的 gl.TRIANGLES 繪製模式外,還有一種專門為粒子設計的原生指令——gl.POINTS。
粒子效果的成敗,關鍵往往在於材質的混合模式(Blending Mode)。

在這種模式下,微粒邊緣若帶有半透明漸變,當多顆微粒在暗處重疊時,透明通道的加權計算會互相遮蔽,邊緣容易產生一圈骯髒的灰色髒污。
const emberMat = new THREE.PointsMaterial({
color: new THREE.Color('#ffaa33'),
size: 0.12,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending // 核心:開啟光學加法混合
});
如果每秒鐘都在生成新火星、銷毀舊火星,會觸發我們在 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);
在主渲染迴圈 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;
除了火把周圍的躍動火星,地窖整體空間也需要一層緩慢漂浮的懸浮微塵,營造古老遺跡潮濕封閉的空氣感。
微塵的運動規律與火星截然不同:火星是受熱氣流驅動的向上噴射,而微塵則是受微弱氣流擾動的「布朗運動(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);
在渲染迴圈中,我們讓微塵隨著正弦波極其緩慢地浮動。當主角手持火把走過走廊時:
這種微觀尺度的粒子流動,讓冷硬的 3D 石磚與 2D 像素主角之間產生了一層透明的空氣介質,微縮模型感與空間縱深在此時徹底交融。
【本日結語】
今天我們解密了 GPU 粒子系統在 HD-2D 美學中的地位:
點光柵化效能優勢:捨棄繁重的 Mesh 面片,以原生 THREE.Points 達成單頂點極致光柵化。
加法混合(Additive Blending):利用光的相加特性消滅黑邊,並在粒子交疊時自然擊穿 Bloom 高門檻產生光暈。
記憶體零回收輪替:利用靜態 TypedArray 與常駐物件池,以零 GC 開銷完成生生不息的火星發射。
懸浮微塵氛圍:在全域空間鋪設微流動浮塵,打破虛擬場景的塑料真空感,讓地下城的空氣真正「活」了過來!
在下一篇 Day 25 中,我們將探討圖形渲染中極易被忽視的硬核領域——色彩科學與色調映射(Color Space & Tone Mapping)!為什麼許多 WebGL 專案的畫面總是「灰濛濛一片」或「高光嚴重死白」?我們將深入拆解線性空間、Gamma 校正與 ACESFilmicToneMapping 的光學底層!