| name | threejs |
| description | Three.js 3D 視覺化最佳實踐指南(r183+)。當需要建立 3D 場景、設定 renderer/camera/lights、使用 OrbitControls、做 raycasting 互動、效能優化、或整合 Astro 時使用。 |
Three.js Best Practices Guide (r183+)
版本資訊
- 最新穩定版:r183(2025-02-20)
- 月更新節奏,npm 週下載量 ~270 萬
- WebGPU 自 r171 起 production-ready,透過
three/webgpu 匯入自動 fallback WebGL2
Import 策略
import { Scene, PerspectiveCamera, WebGLRenderer } from 'three';
import * as THREE from 'three/webgpu';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { Timer } from 'three/addons/misc/Timer.js';
注意:盡量 import 個別 class 而非 import * as THREE,減少 bundle size。
Scene 基本設定
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
75,
container.clientWidth / container.clientHeight,
0.1,
1000
);
camera.position.set(0, 1.5, 5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
container.appendChild(renderer.domElement);
Animation:使用 Timer(Clock 已棄用)
import { Timer } from 'three/addons/misc/Timer.js';
const timer = new Timer();
timer.connect(document);
function animate(timestamp: number) {
requestAnimationFrame(animate);
timer.update(timestamp);
const delta = timer.getDelta();
mesh.rotation.y += 1.0 * delta;
controls.update();
renderer.render(scene, camera);
}
requestAnimationFrame(animate);
為何用 Timer 而非 Clock:
getDelta() 同一 frame 多次呼叫回傳一致值(Clock 不會)
- 自動處理 Page Visibility(切 tab 回來不會有巨大 delta)
- 支援
setTimescale() 做慢動作/快轉
OrbitControls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.minDistance = 2;
controls.maxDistance = 20;
controls.maxPolarAngle = Math.PI / 2;
controls.target.set(0, 1, 0);
controls.update();
清除:controls.dispose() 會移除所有 event listeners。
Raycasting(Hover / Click 互動)
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hoveredObject: THREE.Object3D | null = null;
function onPointerMove(event: PointerEvent) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}
function checkHover() {
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(interactiveObjects);
if (intersects.length > 0) {
const obj = intersects[0].object;
if (hoveredObject !== obj) {
if (hoveredObject) (hoveredObject as THREE.Mesh).material.emissive?.setHex(0x000000);
hoveredObject = obj;
(obj as THREE.Mesh).material.emissive?.setHex(0x333333);
renderer.domElement.style.cursor = 'pointer';
}
} else if (hoveredObject) {
(hoveredObject as THREE.Mesh).material.emissive?.setHex(0x000000);
hoveredObject = null;
renderer.domElement.style.cursor = 'default';
}
}
renderer.domElement.addEventListener('pointermove', onPointerMove);
注意:搭配 OrbitControls 時,區分 click vs drag — 比較 pointerdown/pointerup 的距離(閾值 ~3px)。
Responsive Design
const resizeObserver = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
});
resizeObserver.observe(container);
島嶼式元件用 ResizeObserver(非 window.resize)。
Memory Management / Disposal
function dispose(object: THREE.Object3D) {
object.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
mesh.geometry.dispose();
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach(mat => {
Object.values(mat).forEach(val => {
if (val && typeof val === 'object' && 'dispose' in val) (val as any).dispose();
});
mat.dispose();
});
}
});
}
renderer.dispose();
controls.dispose();
規則:JS GC 不會釋放 GPU 資源,必須手動 .dispose()。
效能優化
| 策略 | 說明 |
|---|
| Draw calls < 100 | 用 InstancedMesh(重複 geometry)或 BatchedMesh(不同 geometry) |
| Pixel ratio 上限 | Desktop: 2, Mobile: 1.5 |
| 關閉不需要的功能 | { stencil: false, depth: false, alpha: false } |
| Tab 隱藏暫停渲染 | Timer 自動處理,或用 Page Visibility API |
| 使用 LOD | THREE.LOD 依距離切換細節 |
| 壓縮模型 | Draco 壓縮 geometry(減 90-95%)、KTX2 壓縮 texture(減 ~10x 記憶體) |
| 偏好 env map | 環境貼圖比動態光源效能好 |
檢查效能:renderer.info.render.calls、stats-gl
WebGL 偵測與 Fallback
import WebGL from 'three/addons/capabilities/WebGL.js';
if (WebGL.isWebGL2Available()) {
initScene();
} else {
const warning = WebGL.getWebGL2ErrorMessage();
container.appendChild(warning);
}
WebGPURenderer 自帶 WebGL2 fallback,不需要額外偵測。
Astro 整合
---
// src/components/ThreeScene.astro
---
<div id="three-canvas" class="w-full h-screen"></div>
<script>
import { initScene } from '../lib/three/scene';
const container = document.getElementById('three-canvas')!;
const cleanup = initScene(container);
// Astro View Transitions cleanup
document.addEventListener('astro:before-swap', cleanup);
</script>
export function initScene(container: HTMLElement): () => void {
let animationId: number;
function animate() {
animationId = requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
return () => {
cancelAnimationFrame(animationId);
renderer.dispose();
controls.dispose();
};
}
要點:
- 用 vanilla TS +
<script> tag(Astro 處理為 module)
- Hook
astro:before-swap 做 cleanup
- 用
ResizeObserver 而非 window.resize
Mobile / Touch
- OrbitControls 原生支援觸控(一指旋轉、二指縮放/平移)
- 用
pointer events(統一 mouse + touch)
- CSS:canvas 加
touch-action: none 防止瀏覽器手勢
- DPR 上限 1.5
- 考慮降低 geometry/texture 品質
常見陷阱
- 未 dispose GPU 資源 → 記憶體洩漏
- 用 Clock 而非 Timer → 同 frame 多次
getDelta() 返回 ~0
- 未 cap pixel ratio → Retina 裝置 4x 像素
- 改 camera 屬性後忘記
updateProjectionMatrix()
- Raycast 整個 scene → 只傳互動物件陣列
- 未加
touch-action: none → 手機滑動卡頓
- OrbitControls click/drag 混淆 → 追蹤 pointer 移動距離