本文 AI 產出,尚未審核

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 粒子系統,只需要:

  1. BufferGeometry:儲存每顆粒子的屬性(位置、顏色、大小等)
  2. 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 會導致渲染失敗。 - 合併屬性(如把 velocityacceleration 放入同一 vec4)。
- 使用 InstancedBufferAttribute 取代大量獨立 attribute。
深度排序與半透明 粒子通常使用半透明混合,若啟用深度測試會產生錯誤排序。 - depthWrite: false + transparent: true,或使用 前向渲染(pre‑multiply alpha)
- 若需要排序,可在 CPU 端對粒子做 距離排序,但成本較高。
浮點精度問題 在大型場景(座標 > 10⁴)使用 float 會產生 jitter。 - 使用 相對座標(相對於相機或局部原點)或 雙精度模擬(在 CPU 保存高精度,再傳給 GPU)。
渲染次數過多 每帧若頻繁切換 RenderTarget、改變 Uniform,會增加 GPU 管線開銷。 - 批次化(Batch)所有粒子渲染於同一 PointsInstancedMesh
- 只在需要時更新 Uniform(如時間、外部力量)。
不支援 Float Texture 部分舊版手機瀏覽器不支援 OES_texture_float,導致 GPGPU 無法運作。 - 使用 THREE.HalfFloatTypeTHREE.UnsignedByteType 作為 fallback。
- 在程式啟動時偵測支援度,若不支援則改用 CPU 計算。

最佳實踐總結

  1. 最小化 attribute 數量:只保留必須的屬性,其他可在 Shader 中推導。
  2. 使用 Instancing:大量相同模型的粒子(如雪花、葉子)建議改用 InstancedBufferGeometry
  3. Ping‑Pong GPGPU:需要交互式或物理模擬時,採用離屏渲染 + 兩個 RenderTarget。
  4. 適當的混合模式THREE.AdditiveBlending 適合光暈、火焰;THREE.NormalBlending + preMultiplyAlpha 適合透明紋理。
  5. 測試跨平台:在桌面、行動裝置、不同瀏覽器上測試,確保 floatinstancingrenderTarget 均可正常運作。

實際應用場景

場景 為何適合 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 中實作相對直接:

  1. THREE.Points + BufferGeometry 為最基礎的點雲渲染。
  2. 自訂 Shader 讓每顆粒子在 Vertex 階段自行計算位置、大小與顏色。
  3. InstancedBufferGeometry 提供「同形狀多實例」的高效渲染方式,適合精靈或小型模型。
  4. Ping‑Pong RenderTarget(GPGPU)則讓粒子模擬完整在 GPU 上完成,支援複雜的物理交互。

在開發過程中,記得控制屬性數量、避免深度排序問題、適時使用 Instancing,並且在不同平台上測試 Float Texture 與 Instancing 的支援度,才能確保效能與相容性。

掌握了上述概念與範例後,你就能在 Three.js 中打造從 雨滴雨幕火焰煙霧動態流體 的各式粒子特效,為你的 Web 3D 作品注入生動且高效的視覺表現。祝開發順利,玩得開心!