前言

Three.js 是一個基於 WebGL 的 JavaScript 3D 圖形庫,讓我們可以在瀏覽器中建立和渲染 3D 場景,而不需要直接操作底層的 WebGL API。

這篇文章將透過一個完整的互動範例,帶你認識 Three.js 的核心概念:場景、相機、渲染器、幾何體、材質、光源和動畫循環。最終我們會做出一個有彩色光影、環繞動畫和粒子效果的 3D 場景。

👉 點此查看完成品 Demo

核心概念

一個最基本的 Three.js 應用由以下元素組成:

元素類別說明
SceneTHREE.Scene場景,所有 3D 物件的容器
CameraTHREE.PerspectiveCamera相機,決定從什麼角度看場景
RendererTHREE.WebGLRenderer渲染器,將場景畫到 Canvas 上
MeshTHREE.Mesh網格物件 = 幾何體 + 材質
LightTHREE.Light光源,影響物件的明暗和顏色

它們的關係可以這樣理解:Scene 是舞台,Camera 是觀眾的眼睛,Renderer 是把舞台拍成畫面的攝影師,Mesh 是舞台上的演員,Light 是燈光師。

環境準備

這個範例使用 CDN 載入 Three.js,不需要安裝任何套件。建立一個 HTML 檔案,透過 Import Map 引入模組:

<script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
    }
  }
</script>

<script type="module">
  import * as THREE from "three";
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
</script>

建立場景基礎

場景、相機、渲染器

每個 Three.js 應用都從這三個元素開始:

// 建立場景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
scene.fog = new THREE.FogExp2(0x111122, 0.035);

// 建立透視相機
const camera = new THREE.PerspectiveCamera(
  60, // 視野角度(FOV)
  window.innerWidth / window.innerHeight, // 長寬比
  0.1, // 近裁切面
  1000, // 遠裁切面
);
camera.position.set(0, 3, 8);

// 建立渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

讓我們逐一說明每個設定的作用:

背景與霧效

scene.background 是一個純色平面背景,不是 3D 環境。FogExp2 是指數霧效,讓距離相機越遠的物件越模糊、逐漸融入背景色。霧色要和背景色一致,否則物件淡出時的顏色會和背景不搭。0.035 是霧的密度,越大霧越濃。

類型說明效果
Fog(color, near, far)線性霧在 near 到 far 之間均勻淡出
FogExp2(color, density)指數霧根據距離指數衰減,更自然

透視相機參數

PerspectiveCamera 的四個參數定義了相機的視錐體(可視範圍):

參數意義
FOV60視野角度(度數),越大看到越廣,人眼大約 60-75 度
aspectwindow.innerWidth / window.innerHeight長寬比,跟著視窗大小走
near0.1近裁切面,比這更近的物件不渲染
far1000遠裁切面,比這更遠的物件不渲染

camera.position.set(0, 3, 8)初始視角——水平置中、往上抬高 3 單位、往後退 8 單位。使用者開始拖曳互動後,OrbitControls 會接管相機位置。

渲染器設定

  • antialias: true:開啟抗鋸齒,讓 3D 物件的邊緣更平滑,避免斜線出現階梯狀鋸齒。只能在建立渲染器時設定,之後無法動態切換
  • setPixelRatio(Math.min(window.devicePixelRatio, 2)):設定像素密度比。Retina 螢幕的 devicePixelRatio 是 2(1 CSS px = 4 實際 px),部分手機甚至是 3。限制最大為 2 是因為 3 的畫質提升肉眼幾乎看不出差別,但 GPU 負擔會暴增(9 倍 vs 4 倍)
  • shadowMap.enabled = true:開啟全域陰影計算。這是陰影鏈條的第一環,後面還需要光源和物件各自設定才會生效

軌道控制器

OrbitControls 讓使用者可以用滑鼠拖曳旋轉、滾輪縮放、右鍵平移:

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 啟用阻尼,讓旋轉有慣性
controls.dampingFactor = 0.05;
controls.maxPolarAngle = Math.PI / 2 + 0.1; // 限制不能翻到地板下面

enableDamping 開啟後,滑鼠放開時場景會有慣性地繼續滑動再慢慢停住。dampingFactor 控制煞車力道:

dampingFactor效果
0.01很滑,放開後轉很久才停
0.05適中,手感舒服
0.2幾乎馬上停,比較生硬

⚠️ 阻尼需要搭配動畫循環裡的 controls.update() 才會生效。

加入光源

光源是讓 3D 場景有立體感的關鍵。我們使用三種光源搭配:

// 環境光:均勻照亮所有物件
const ambientLight = new THREE.AmbientLight(0x404060, 0.5);
scene.add(ambientLight);

// 平行光:模擬太陽光,可以投射陰影
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
directionalLight.position.set(5, 10, 5);
directionalLight.castShadow = true;
scene.add(directionalLight);

// 彩色點光源:營造氣氛
const pointLight1 = new THREE.PointLight(0xff6b6b, 2, 20); // 紅色
pointLight1.position.set(-3, 3, 3);
scene.add(pointLight1);

const pointLight2 = new THREE.PointLight(0x4ecdc4, 2, 20); // 青色
pointLight2.position.set(3, 3, -3);
scene.add(pointLight2);
光源類型特點適用場景
AmbientLight均勻照亮,無方向基礎照明,避免全黑
DirectionalLight平行光線,可投射陰影模擬太陽光
PointLight從一個點向四面八方發光燈泡、火焰效果

陰影機制

directionalLight.castShadow = true 讓這盞燈投射陰影。陰影需要完整的鏈條才會生效,任何一環斷掉就不會有陰影:

renderer.shadowMap.enabled = true    全域開關
          
light.castShadow = true              光源產生陰影
          
mesh.castShadow = true               物件投射陰影
          
mesh.receiveShadow = true            物件接收陰影顯示影子

在我們的 Demo 裡,只有平行光設了 castShadow,兩盞彩色點光源沒有——因為每多一盞投射陰影的光源,GPU 負擔就會增加,一盞平行光的陰影已經夠給場景立體感了。

⚠️ AmbientLight 無法投射陰影,因為它沒有方向,不存在遮擋關係。

建立 3D 物件

地板

const floorGeometry = new THREE.PlaneGeometry(50, 50);
const floorMaterial = new THREE.MeshStandardMaterial({
  color: 0x222233,
  roughness: 0.8,
  metalness: 0.2,
});
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2; // 平面預設是直立的,旋轉 90 度變成地板
floor.position.y = -1.5;
floor.receiveShadow = true; // 接收陰影
scene.add(floor);

PlaneGeometry(50, 50) 建立一個 50 × 50 單位的平面。它預設是直立面對相機的(像一面牆),所以需要 rotation.x = -Math.PI / 2 繞 X 軸轉 -90 度讓它躺平。設成 50 × 50 是為了讓地板夠大,搭配霧效讓邊緣自然消失。

MeshStandardMaterial 是基於物理渲染(PBR)的材質,roughnessmetalness 控制表面質感:

roughnessmetalness看起來像
00光滑塑膠(如撞球)
01拋光金屬(如鏡面不鏽鋼)
10粗糙石頭(如混凝土)
0.80.2略粗糙、微金屬感(地板用的)

receiveShadow = true 讓地板接收陰影——但前提是陰影鏈條的其他環節都有設定,否則即使設了也收不到影子。

中央主體:二十面體

Mesh = Geometry + Material,這是 Three.js 最核心的概念:

const icoGeometry = new THREE.IcosahedronGeometry(1.5, 0);
const icoMaterial = new THREE.MeshStandardMaterial({
  color: 0x6c5ce7,
  roughness: 0.2, // 越小越光滑
  metalness: 0.8, // 越大越像金屬
  flatShading: true, // 平面著色,展現多面體的稜角
});
const icosahedron = new THREE.Mesh(icoGeometry, icoMaterial);
icosahedron.position.y = 1.5;
icosahedron.castShadow = true; // 投射陰影
scene.add(icosahedron);

Three.js 還有很多內建幾何體可以直接使用:

幾何體說明範例
PlaneGeometry平面地板、牆壁
BoxGeometry立方體方塊、建築
SphereGeometry球體地球、星球
IcosahedronGeometry二十面體Demo 裡的主體
CylinderGeometry圓柱體柱子、罐子
TorusGeometry甜甜圈環形物件
ConeGeometry圓錐體樹、箭頭

環繞小方塊

用迴圈建立多個小方塊,等距排列成一個圓環:

const cubes = [];
const cubeCount = 12;
const cubeColors = [0xff6b6b, 0x4ecdc4, 0xffe66d, 0xa29bfe, 0xfd79a8, 0x00cec9];

for (let i = 0; i < cubeCount; i++) {
  const geometry = new THREE.BoxGeometry(0.4, 0.4, 0.4);
  const material = new THREE.MeshStandardMaterial({
    color: cubeColors[i % cubeColors.length],
    roughness: 0.3,
    metalness: 0.6,
  });
  const cube = new THREE.Mesh(geometry, material);
  cube.castShadow = true;
  scene.add(cube);
  cubes.push({
    mesh: cube,
    angle: (i / cubeCount) * Math.PI * 2, // 均勻分布在圓上
    radius: 3.5,
    speed: 0.3 + Math.random() * 0.2,
    yOffset: Math.sin((i / cubeCount) * Math.PI * 2) * 0.5,
  });
}

粒子系統

使用 BufferGeometry 手動設置頂點位置,搭配 PointsMaterial 建立粒子效果:

particleCount = 500 代表場景中總共有 500 個粒子點。每個粒子需要 3 個數值(x, y, z)來定位,所以 Float32Array 的長度是 500 × 3 = 1500

const particleCount = 500;
const particleGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);

for (let i = 0; i < particleCount; i++) {
  positions[i * 3] = (Math.random() - 0.5) * 30; // x
  positions[i * 3 + 1] = Math.random() * 15; // y
  positions[i * 3 + 2] = (Math.random() - 0.5) * 30; // z
}

particleGeometry.setAttribute(
  "position",
  new THREE.BufferAttribute(positions, 3),
);

const particleMaterial = new THREE.PointsMaterial({
  color: 0xaaaaff,
  size: 0.05,
  transparent: true,
  opacity: 0.8,
});

const particles = new THREE.Points(particleGeometry, particleMaterial);
scene.add(particles);

動畫循環

requestAnimationFrame 讓瀏覽器在每一幀呼叫我們的動畫函數:

const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);
  const elapsed = clock.getElapsedTime();

  // 旋轉二十面體
  icosahedron.rotation.x = elapsed * 0.3;
  icosahedron.rotation.y = elapsed * 0.5;
  icosahedron.position.y = 1.5 + Math.sin(elapsed * 0.8) * 0.3; // 浮動

  // 環繞小方塊
  cubes.forEach((cubeData) => {
    cubeData.angle += cubeData.speed * 0.01;
    cubeData.mesh.position.x = Math.cos(cubeData.angle) * cubeData.radius;
    cubeData.mesh.position.z = Math.sin(cubeData.angle) * cubeData.radius;
    cubeData.mesh.position.y =
      1.5 + Math.sin(elapsed * 1.5 + cubeData.angle) * cubeData.yOffset;
    cubeData.mesh.rotation.x = elapsed * 0.8;
    cubeData.mesh.rotation.y = elapsed * 1.2;
  });

  // 點光源繞行
  pointLight1.position.x = Math.sin(elapsed * 0.5) * 5;
  pointLight1.position.z = Math.cos(elapsed * 0.5) * 5;

  // 粒子緩慢上升
  const pos = particles.geometry.attributes.position.array;
  for (let i = 0; i < particleCount; i++) {
    pos[i * 3 + 1] += 0.005;
    if (pos[i * 3 + 1] > 15) pos[i * 3 + 1] = 0;
  }
  particles.geometry.attributes.position.needsUpdate = true;

  controls.update();
  renderer.render(scene, camera);
}

animate();

粒子循環上升的原理

粒子動畫的核心邏輯很簡單:每幀把每個粒子的 y 值 +0.005(往上飄),超過 15 就歸零(瞬移回底部重新開始)。因為 500 個粒子的初始 y 值是隨機分布在 0 ~ 15 之間,所以每個粒子到頂的時間不同,視覺上就像不斷有新粒子從底部冒出來。

needsUpdate 的作用

needsUpdate = true 告訴 Three.js:「我剛剛在 JavaScript 端手動改了陣列資料,請在下一幀重新上傳到 GPU。」因為 BufferGeometry 的頂點資料存在 GPU 記憶體中,Three.js 預設不會每幀重新上傳。如果忘了設這行,粒子會凍結不動。

⚠️ 只有手動修改 BufferAttribute 陣列時才需要 needsUpdate。像二十面體和小方塊是透過修改 positionrotation 屬性,Three.js 會自動處理。

動畫關鍵技巧

  1. 使用 Clock.getElapsedTime() 取得經過時間,確保動畫速度不受幀率影響
  2. Math.sin()Math.cos() 做出平滑的週期性運動
  3. 每幀結束記得呼叫 controls.update() 讓阻尼生效
  4. 手動修改 BufferAttribute 後要設定 needsUpdate = true

RWD 處理

視窗大小改變時,需要同步更新相機和渲染器:

window.addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

總結

透過這個範例,我們學到了 Three.js 的核心概念:

  • 🎬 場景基礎:Scene、Camera、Renderer 三者缺一不可
  • 💡 光源搭配:環境光 + 平行光 + 點光源,營造豐富的光影效果
  • 🧊 Mesh = Geometry + Material:幾何體決定形狀,材質決定外觀
  • 🎞️ 動畫循環:用 requestAnimationFrame 搭配 Clock 驅動每一幀
  • 🖱️ 互動控制:OrbitControls 讓使用者可以自由探索場景
  • 粒子效果:用 BufferGeometry 手動建立頂點,實現大量粒子

Three.js 的生態系非常豐富,還有 GLTF 模型載入、Shader 材質、物理引擎、後處理效果等進階功能值得探索。

參考資料