前言
Three.js 是一個基於 WebGL 的 JavaScript 3D 圖形庫,讓我們可以在瀏覽器中建立和渲染 3D 場景,而不需要直接操作底層的 WebGL API。
這篇文章將透過一個完整的互動範例,帶你認識 Three.js 的核心概念:場景、相機、渲染器、幾何體、材質、光源和動畫循環。最終我們會做出一個有彩色光影、環繞動畫和粒子效果的 3D 場景。
核心概念
一個最基本的 Three.js 應用由以下元素組成:
| 元素 | 類別 | 說明 |
|---|---|---|
| Scene | THREE.Scene | 場景,所有 3D 物件的容器 |
| Camera | THREE.PerspectiveCamera | 相機,決定從什麼角度看場景 |
| Renderer | THREE.WebGLRenderer | 渲染器,將場景畫到 Canvas 上 |
| Mesh | THREE.Mesh | 網格物件 = 幾何體 + 材質 |
| Light | THREE.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 的四個參數定義了相機的視錐體(可視範圍):
| 參數 | 值 | 意義 |
|---|---|---|
| FOV | 60 | 視野角度(度數),越大看到越廣,人眼大約 60-75 度 |
| aspect | window.innerWidth / window.innerHeight | 長寬比,跟著視窗大小走 |
| near | 0.1 | 近裁切面,比這更近的物件不渲染 |
| far | 1000 | 遠裁切面,比這更遠的物件不渲染 |
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)的材質,roughness 和 metalness 控制表面質感:
| roughness | metalness | 看起來像 |
|---|---|---|
0 | 0 | 光滑塑膠(如撞球) |
0 | 1 | 拋光金屬(如鏡面不鏽鋼) |
1 | 0 | 粗糙石頭(如混凝土) |
0.8 | 0.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。像二十面體和小方塊是透過修改position和rotation屬性,Three.js 會自動處理。
動畫關鍵技巧
- 使用
Clock.getElapsedTime()取得經過時間,確保動畫速度不受幀率影響 - 用
Math.sin()和Math.cos()做出平滑的週期性運動 - 每幀結束記得呼叫
controls.update()讓阻尼生效 - 手動修改
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 材質、物理引擎、後處理效果等進階功能值得探索。