Three.js 粒子系統與特效 – GPU 粒子系統概念
簡介
在 3D 網頁應用中,粒子系統是製作火焰、煙霧、雨滴、星塵等特效的關鍵技術。傳統上,我們會在 CPU 端逐一更新每顆粒子的座標、速度、生命週期,再把結果送給 GPU 繪製。隨著粒子數量從千顆升到幾十萬甚至上百萬,CPU 的計算成本會急速飆升,成為效能瓶頸。
GPU 粒子系統(GPU‑based particle system)則把「更新」與「渲染」的工作全部交給圖形處理器。利用著色器(Shader)在每個渲染帧中直接在 GPU 上完成物理運算,從而實現 成千上萬粒子 的即時模擬,而不會對主程式產生顯著負擔。
本文將以 Three.js 為例,說明 GPU 粒子系統的核心概念、實作方式、常見陷阱與最佳實踐,並提供可直接跑起來的程式碼範例,協助你從入門走向實務應用。
核心概念
1. 為什麼要使用 GPU 粒子
| 項目 | CPU 粒子系統 | GPU 粒子系統 |
|---|---|---|
| 計算位置/速度 | 在 JavaScript 中逐粒子迴圈 | 在 Vertex/Fragment Shader 中平行運算 |
| 最大粒子數 | 幾千 ~ 一萬顆(受限於 JavaScript 效能) | 數十萬 ~ 上百萬顆(受限於 GPU 記憶體) |
| 記憶體搬移 | 每帧需把更新後的資料傳回 GPU | 只需在 GPU 內部「Ping‑Pong」更新,CPU 幾乎不參與 |
| 效果表現 | 受限於 CPU 計算速度 | 可使用更複雜的物理、噪聲、流體等效果 |
重點:GPU 粒子系統的核心在於「把資料保留在 GPU 端」並利用 Shader 直接操作,這也是現代遊戲與視覺特效的主流做法。
2. 基本構件:THREE.Points + BufferGeometry
最簡單的 GPU 粒子系統,只需要:
BufferGeometry:儲存每顆粒子的屬性(位置、顏色、大小等)PointsMaterial或自訂 ShaderMaterial:在 Vertex Shader 中讀取屬性,決定最終畫面
範例 1:最基礎的點雲(不含自訂 Shader)
// 建立一千顆隨機分布的粒子
const COUNT = 1000;
// 建立緩衝區
const positions = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
positions[i * 3 + 0] = (Math.random() - 0.5) * 20; // x
positions[i * 3 + 1] = (Math.random() - 0.5) * 20; // y
positions[i * 3 + 2] = (Math.random() - 0.5) * 20; // z
}
// BufferGeometry + PointsMaterial
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const material = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.2,
sizeAttenuation: true, // 隨距離縮放
});
const points = new THREE.Points(geometry, material);
scene.add(points);
說明:此範例僅在 CPU 端產生一次隨機位置,之後不再變動。若要讓粒子移動,就需要在 Vertex Shader 中加入運算或在 CPU 每帧更新
position緩衝區。
3. 自訂 Vertex Shader:在 GPU 上更新位置
為了讓粒子「自行」運動,我們在 Vertex Shader 中加入 時間 (uniform)、速度 (attribute) 等資訊,讓每個頂點自行計算新座標。
範例 2:簡易的「噴射」粒子
// --- JavaScript 部分 ---
const COUNT = 2000;
// 位置 (初始在原點) + 速度向量
const positions = new Float32Array(COUNT * 3);
const velocities = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
// 初始位置全為 (0,0,0)
positions[i * 3 + 0] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
// 隨機速度向量
const speed = 2 + Math.random() * 3;
const phi = Math.random() * Math.PI * 2;
const theta = Math.random() * Math.PI;
velocities[i * 3 + 0] = speed * Math.sin(theta) * Math.cos(phi);
velocities[i * 3 + 1] = speed * Math.sin(theta) * Math.sin(phi);
velocities[i * 3 + 2] = speed * Math.cos(theta);
}
// 建立 BufferGeometry
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('velocity', new THREE.BufferAttribute(velocities, 3));
// 自訂 ShaderMaterial
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uSize: { value: 0.15 },
uColor: { value: new THREE.Color(0xffaa33) },
},
vertexShader: `
attribute vec3 velocity;
uniform float uTime;
uniform float uSize;
void main() {
// 根據時間累加位移
vec3 newPos = position + velocity * uTime;
// 簡單的重力加速度
newPos.y -= 0.5 * 9.8 * uTime * uTime;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
gl_PointSize = uSize * (1.0 / -mvPosition.z); // 視角衰減
}
`,
fragmentShader: `
uniform vec3 uColor;
void main() {
// 圓形點形狀
float dist = length(gl_PointCoord - vec2(0.5));
if (dist > 0.5) discard;
gl_FragColor = vec4(uColor, 1.0 - dist);
}
`,
transparent: true,
depthWrite: false,
});
const points = new THREE.Points(geometry, material);
scene.add(points);
// --- 在渲染循環中更新時間 uniform ---
function animate(delta) {
material.uniforms.uTime.value += delta; // delta 為秒數
renderer.render(scene, camera);
}
關鍵:
velocity被儲存為 attribute,在 GPU 中直接取用。uTime為全局時間,所有粒子共享同一時間參數,使得運算全程在 GPU 完成。gl_PointSize依照相機遠近自動縮放,避免遠近粒子大小不一致的視覺問題。
4. InstancedBufferGeometry:大量相同形狀的粒子
若每顆粒子不只是點,而是小型 Mesh(如四邊形、球體),仍然可以透過 Instancing 渲染。InstancedBufferGeometry 允許我們一次送出 N 個實例,每個實例透過自訂屬性(如偏移、旋轉、縮放)在 Vertex Shader 中分別定位。
範例 3:使用四邊形 (Plane) 作為粒子精靈
// --- JavaScript ---
const COUNT = 5000;
// 基本平面幾何 (一個四邊形)
const baseGeometry = new THREE.PlaneBufferGeometry(1, 1);
// Instanced BufferGeometry
const geometry = new THREE.InstancedBufferGeometry();
geometry.index = baseGeometry.index;
geometry.attributes.position = baseGeometry.attributes.position;
geometry.attributes.uv = baseGeometry.attributes.uv;
// 每個實例的偏移 (instanceOffset) 與大小 (instanceScale)
const offsets = new Float32Array(COUNT * 3);
const scales = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) {
offsets[i * 3 + 0] = (Math.random() - 0.5) * 30;
offsets[i * 3 + 1] = (Math.random() - 0.5) * 30;
offsets[i * 3 + 2] = (Math.random() - 0.5) * 30;
scales[i] = 0.5 + Math.random(); // 大小 0.5 ~ 1.5
}
geometry.setAttribute('instanceOffset', new THREE.InstancedBufferAttribute(offsets, 3));
geometry.setAttribute('instanceScale', new THREE.InstancedBufferAttribute(scales, 1));
// ShaderMaterial
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uMap: { value: new THREE.TextureLoader().load('particle.png') },
},
vertexShader: `
attribute vec3 instanceOffset;
attribute float instanceScale;
uniform float uTime;
varying vec2 vUv;
void main() {
// 讓每個粒子在 Y 軸上做簡單擺動
vec3 pos = position * instanceScale;
pos += instanceOffset;
pos.y += sin(uTime + instanceOffset.x) * 2.0;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
vUv = uv;
}
`,
fragmentShader: `
uniform sampler2D uMap;
varying vec2 vUv;
void main() {
vec4 tex = texture2D(uMap, vUv);
if (tex.a < 0.1) discard;
gl_FragColor = tex;
}
`,
transparent: true,
depthWrite: false,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 渲染循環
function animate(delta) {
material.uniforms.uTime.value += delta;
renderer.render(scene, camera);
}
說明:
InstancedBufferAttribute讓每個實例擁有獨立的偏移與縮放。- 只需一次
drawElementsInstanced呼叫即可渲染 COUNT 個平面,GPU 端負擔極低。- 這種方式適合 精靈粒子(Sprite)或小型 3D 模型(如小星星、雪花)。
5. Ping‑Pong Render Target:真正的 GPU 計算(GPGPU)
有時粒子需要更複雜的交互(如流體、碰撞、吸引力),僅靠 Vertex Shader 的簡單運算不足。此時可使用 RenderTarget(離屏緩衝)搭配 Fragment Shader 進行「資料回寫」——也就是所謂的 Ping‑Pong 技術。
範例 4:使用兩個 FloatTexture 交替更新粒子位置與速度
// --- 前置設定 ---
const PARTICLE_COUNT = 1024; // 32x32 texture
const texSize = Math.sqrt(PARTICLE_COUNT);
const rtOptions = {
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
type: THREE.FloatType,
format: THREE.RGBAFormat,
depthBuffer: false,
stencilBuffer: false,
};
// 建立兩個 RenderTarget 作為 ping-pong
const rtPosition1 = new THREE.WebGLRenderTarget(texSize, texSize, rtOptions);
const rtPosition2 = new THREE.WebGLRenderTarget(texSize, texSize, rtOptions);
let curPosRT = rtPosition1, nxtPosRT = rtPosition2;
// 初始資料 (隨機位置、速度)
const initData = new Float32Array(texSize * texSize * 4);
for (let i = 0; i < PARTICLE_COUNT; i++) {
const stride = i * 4;
initData[stride + 0] = (Math.random() - 0.5) * 20; // x
initData[stride + 1] = (Math.random() - 0.5) * 20; // y
initData[stride + 2] = (Math.random() - 0.5) * 20; // z
initData[stride + 3] = 1.0; // w (可用作生命)
}
const initTexture = new THREE.DataTexture(initData, texSize, texSize, THREE.RGBAFormat, THREE.FloatType);
initTexture.needsUpdate = true;
// 把初始資料寫入 RenderTarget
renderer.setRenderTarget(rtPosition1);
renderer.clear();
renderer.copyTextureToTexture(new THREE.Vector2(0, 0), initTexture, rtPosition1.texture);
renderer.setRenderTarget(null);
// 模擬 Shader (Fragment) - 計算新位置
const simMaterial = new THREE.ShaderMaterial({
uniforms: {
uPosTex: { value: null }, // 當前位置貼圖
uDelta: { value: 0.016 }, // 時間步長
uGravity: { value: new THREE.Vector3(0, -9.8, 0) },
},
vertexShader: `
void main() { gl_Position = vec4(position, 1.0); }
`,
fragmentShader: `
uniform sampler2D uPosTex;
uniform float uDelta;
uniform vec3 uGravity;
void main() {
vec2 uv = gl_FragCoord.xy / resolution.xy;
vec4 data = texture2D(uPosTex, uv);
vec3 pos = data.xyz;
vec3 vel = data.w * vec3(0.0, 1.0, 0.0); // 以 w 為速度大小 (示範用)
// 基本重力 + 簡單阻尼
vel += uGravity * uDelta;
vel *= 0.99;
pos += vel * uDelta;
// 回到範圍內(簡單邊界)
if (pos.y < -10.0) {
pos.y = -10.0;
vel.y = -vel.y * 0.8;
}
gl_FragColor = vec4(pos, length(vel)); // w 重新寫入速度大小
}
`,
depthWrite: false,
depthTest: false,
});
// 渲染粒子 (Points) 使用位置貼圖
const particleGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(PARTICLE_COUNT * 3);
particleGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
// 取樣貼圖的 UV
const uvs = new Float32Array(PARTICLE_COUNT * 2);
let p = 0;
for (let y = 0; y < texSize; y++) {
for (let x = 0; x < texSize; x++) {
uvs[p++] = x / texSize + 0.5 / texSize;
uvs[p++] = y / texSize + 0.5 / texSize;
}
}
particleGeometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
const particleMaterial = new THREE.ShaderMaterial({
uniforms: {
uPosTex: { value: null },
uSize: { value: 0.2 },
},
vertexShader: `
uniform sampler2D uPosTex;
uniform float uSize;
varying float vLife;
void main() {
vec4 posData = texture2D(uPosTex, uv);
vec3 pos = posData.xyz;
vLife = posData.w; // 生命或速度大小
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * (1.0 / -mvPosition.z);
}
`,
fragmentShader: `
varying float vLife;
void main() {
float alpha = smoothstep(0.0, 1.0, vLife);
gl_FragColor = vec4(1.0, 0.6, 0.2, alpha);
}
`,
transparent: true,
depthWrite: false,
});
const particleMesh = new THREE.Points(particleGeometry, particleMaterial);
scene.add(particleMesh);
// --- 動畫迴圈 ---
function animate(delta) {
// 1. 模擬步驟 (Ping‑Pong)
simMaterial.uniforms.uPosTex.value = curPosRT.texture;
simMaterial.uniforms.uDelta.value = delta;
renderer.setRenderTarget(nxtPosRT);
renderer.render(new THREE.Mesh(new THREE.PlaneBufferGeometry(2, 2), simMaterial), new THREE.Camera());
renderer.setRenderTarget(null);
// 2. 把新的貼圖給粒子渲染
particleMaterial.uniforms.uPosTex.value = nxtPosRT.texture;
// 3. 交換 RT
const tmp = curPosRT;
curPosRT = nxtPosRT;
nxtPosRT = tmp;
// 最後渲染場景
renderer.render(scene, camera);
}
要點:
- RenderTarget 作為「GPU 記憶體」保存每顆粒子的狀態。
- 每幀先用 Fragment Shader 計算新位置(模擬),再把結果貼回
Points的頂點著色器取樣。- 透過 Ping‑Pong(交替使用兩個 RenderTarget)避免同時讀寫同一貼圖的限制。
- 此方法可實作 流體、群聚、磁場 等高階特效,且完全在 GPU 上完成。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 / Best Practice |
|---|---|---|
| GPU 記憶體超載 | 每顆粒子若儲存過多屬性(位置、速度、加速度、顏色、尺寸),會快速耗盡顯卡的 VRAM。 | - 僅保留必要屬性,其他資訊可在 Shader 中動態計算(如噪聲)。 - 使用 THREE.FloatType 而非 THREE.UnsignedByteType 只在需要高精度時使用。 |
| 頂點屬性上限 | 大部分 WebGL2 裝置的 頂點屬性上限 為 16~32 個。過多 attribute 會導致渲染失敗。 | - 合併屬性(如把 velocity、acceleration 放入同一 vec4)。- 使用 InstancedBufferAttribute 取代大量獨立 attribute。 |
| 深度排序與半透明 | 粒子通常使用半透明混合,若啟用深度測試會產生錯誤排序。 | - depthWrite: false + transparent: true,或使用 前向渲染(pre‑multiply alpha)。- 若需要排序,可在 CPU 端對粒子做 距離排序,但成本較高。 |
| 浮點精度問題 | 在大型場景(座標 > 10⁴)使用 float 會產生 jitter。 |
- 使用 相對座標(相對於相機或局部原點)或 雙精度模擬(在 CPU 保存高精度,再傳給 GPU)。 |
| 渲染次數過多 | 每帧若頻繁切換 RenderTarget、改變 Uniform,會增加 GPU 管線開銷。 | - 批次化(Batch)所有粒子渲染於同一 Points 或 InstancedMesh。- 只在需要時更新 Uniform(如時間、外部力量)。 |
| 不支援 Float Texture | 部分舊版手機瀏覽器不支援 OES_texture_float,導致 GPGPU 無法運作。 |
- 使用 THREE.HalfFloatType 或 THREE.UnsignedByteType 作為 fallback。- 在程式啟動時偵測支援度,若不支援則改用 CPU 計算。 |
最佳實踐總結
- 最小化 attribute 數量:只保留必須的屬性,其他可在 Shader 中推導。
- 使用 Instancing:大量相同模型的粒子(如雪花、葉子)建議改用
InstancedBufferGeometry。 - Ping‑Pong GPGPU:需要交互式或物理模擬時,採用離屏渲染 + 兩個 RenderTarget。
- 適當的混合模式:
THREE.AdditiveBlending適合光暈、火焰;THREE.NormalBlending+preMultiplyAlpha適合透明紋理。 - 測試跨平台:在桌面、行動裝置、不同瀏覽器上測試,確保
float、instancing、renderTarget均可正常運作。
實際應用場景
| 場景 | 為何適合 GPU 粒子 | 可能的實作方式 |
|---|---|---|
| 火焰與煙霧 | 需要大量半透明、隨機變化的點,且受風向、重力影響。 | 使用 Ping‑Pong 計算速度與密度,並以 Additive Blending 渲染。 |
| 雨、雪 | 數千至數萬顆獨立的降水粒子,且大部分僅需簡單位移。 | InstancedBufferGeometry + 低頻率的 CPU 更新(如風向)。 |
| 星塵 / 宇宙背景 | 靜態或緩慢變化的點雲,常用於遠景。 | THREE.Points + 只在載入時生成一次,無需每帧更新。 |
| 流體模擬 | 粒子之間相互作用、湍流、邊界碰撞。 | 完整的 GPGPU(Ping‑Pong)方案,配合 Navier‑Stokes 或 SPH 演算法。 |
| 音樂視覺化 | 音頻頻譜驅動粒子大小、顏色、生成速率。 | 在 CPU 端取音頻資料,將參數傳給 Shader Uniform,GPU 負責渲染。 |
| 互動式 UI 效果(如按鈕點擊時的爆炸) | 短暫且高密度的粒子效果,需要即時回應。 | 使用 THREE.Points + Instancing,在點擊事件觸發時動態寫入位置緩衝區。 |
案例示範:在 Three.js 官方範例
webgl_custom_attributes_points2.html中,就展示了如何以Float32Array動態改變點的大小與顏色;而webgl_gpgpu_birds.html則是利用 GPU 計算 實作鳥群的群聚行為,概念與本篇的 Ping‑Pong 範例相同,只是把「位置」與「方向」分別存入兩個貼圖。
總結
GPU 粒子系統是 將大量平行運算交給圖形硬體 的典型案例,在 Three.js 中實作相對直接:
THREE.Points+BufferGeometry為最基礎的點雲渲染。- 自訂 Shader 讓每顆粒子在 Vertex 階段自行計算位置、大小與顏色。
- InstancedBufferGeometry 提供「同形狀多實例」的高效渲染方式,適合精靈或小型模型。
- Ping‑Pong RenderTarget(GPGPU)則讓粒子模擬完整在 GPU 上完成,支援複雜的物理交互。
在開發過程中,記得控制屬性數量、避免深度排序問題、適時使用 Instancing,並且在不同平台上測試 Float Texture 與 Instancing 的支援度,才能確保效能與相容性。
掌握了上述概念與範例後,你就能在 Three.js 中打造從 雨滴雨幕、火焰煙霧 到 動態流體 的各式粒子特效,為你的 Web 3D 作品注入生動且高效的視覺表現。祝開發順利,玩得開心!