Three.js – 材質與 Shader 進階
主題:基礎 Shader 動畫
簡介
在 WebGL 的世界裡,Shader 是掌控像素與頂點最直接的工具**。**雖然 Three.js 已經為我們封裝了大量的內建材質,但當需要「時間」或「互動」的效果時,單純使用 MeshStandardMaterial 已經捉不住細膩的變化。透過自訂 Shader,我們可以在每一幀 (frame) 直接操控顏色、形狀、透明度等屬性,從而實作出波浪、火焰、漸層過渡等動態視覺。
本單元將從最簡單的 Vertex Shader 與 Fragment Shader 入手,示範如何把 時間 (uniform u_time) 注入 Shader,讓幾何體在畫面上「活」起來。文章適合已具備 Three.js 基礎(場景、相機、渲染器)且想深入了解 Shader 動畫的讀者,從概念、程式碼到實務最佳化,提供完整的學習路徑。
核心概念
1. Shader 的組成與運作流程
| 類型 | 作用 | 常見變數 |
|---|---|---|
| Vertex Shader | 處理每個頂點的座標、法線、UV 等資訊,決定最終投影到螢幕的 3D 位置 | position, normal, uv, modelViewMatrix, projectionMatrix |
| Fragment Shader | 處理每個像素的顏色與透明度,最終決定畫面上看到的顏色 | gl_FragColor, vUv, uniforms |
Three.js 會先執行 Vertex Shader,產生 varying(在兩個階段之間傳遞的資料),再由 Fragment Shader 進行每個像素的著色。
重點:若想在畫面上產生動畫,最常見的做法是把 時間 當作
uniform傳入 Shader,然後在每一幀更新該 uniform 的值。
2. 建立自訂 ShaderMaterial
// 基本的 ShaderMaterial 範例
const material = new THREE.ShaderMaterial({
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv; // 把 UV 傳給 fragment
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */`
uniform float u_time; // 時間 uniform
varying vec2 vUv;
void main() {
// 讓顏色隨時間在紅與藍之間變化
vec3 color = 0.5 + 0.5 * sin(u_time + vUv.xyx * 3.1415);
gl_FragColor = vec4(color, 1.0);
}
`,
uniforms: {
u_time: { value: 0.0 } // 初始時間為 0
}
});
vertexShader與fragmentShader必須使用 GLSL 語法。uniforms以 JavaScript 物件傳入,Three.js 會自動把值同步到 GPU。
3. 把時間注入 Shader
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
material.uniforms.u_time.value = clock.getElapsedTime(); // 更新 uniform
renderer.render(scene, camera);
}
animate();
THREE.Clock 提供高精度的時間計算,getElapsedTime() 會回傳自 clock.start() 起的秒數。將此值寫入 u_time 後,Shader 內的 sin(u_time)、cos(u_time) 等函式即可產生連續變化。
4. 常見的動畫手法
| 手法 | 目的 | 範例片段 |
|---|---|---|
| 波浪變形 (Vertex) | 讓平面或曲面隨時間起伏 | position.z += sin(position.x * 5.0 + u_time) * 0.2; |
| 顏色漸變 (Fragment) | 依 UV 或距離改變顏色 | vec3 col = mix(vec3(1.0,0.0,0.0), vec3(0.0,0.0,1.0), vUv.x); |
| 噪聲扭曲 (Fragment) | 產生雲朵、火焰等自然感 | 使用 noise 函式或 SimplexNoise 產生隨機值 |
下面將提供 4 個完整範例,展示上述手法的實作方式。
程式碼範例
範例 1:簡易時間驅動的顏色漸變
// 建立一個平面幾何體
const geometry = new THREE.PlaneGeometry(4, 4, 1, 1);
const material = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float u_time;
varying vec2 vUv;
void main() {
// 讓紅、綠、藍三色隨時間交替
vec3 color = 0.5 + 0.5 * sin(u_time + vUv.xyx * 6.2831);
gl_FragColor = vec4(color, 1.0);
}
`,
uniforms: { u_time: { value: 0 } }
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 動畫迴圈
const clock = new THREE.Clock();
function render() {
requestAnimationFrame(render);
material.uniforms.u_time.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
render();
說明:
vUv.xyx把 UV 的 x、y、x 排列成向量,配合sin產生三色同步變化。
範例 2:頂點波浪(Vertex)
const geometry = new THREE.PlaneGeometry(6, 6, 128, 128); // 高細分讓波浪更平滑
const material = new THREE.ShaderMaterial({
vertexShader: `
uniform float u_time;
varying vec2 vUv;
void main() {
vUv = uv;
// 把 X、Y 位置乘上頻率,加入時間產生波浪
vec3 pos = position;
float freq = 3.0;
float amp = 0.3;
pos.z += sin(pos.x * freq + u_time) * amp;
pos.z += cos(pos.y * freq + u_time) * amp;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
void main() {
// 依高度給予藍色調
float shade = smoothstep(0.0, 0.6, gl_FragCoord.z);
gl_FragColor = vec4(0.0, 0.4 + shade * 0.4, 0.8, 1.0);
}
`,
uniforms: { u_time: { value: 0 } },
side: THREE.DoubleSide
});
scene.add(new THREE.Mesh(geometry, material));
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
material.uniforms.u_time.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
animate();
技巧:使用較高的細分 (
widthSegments,heightSegments) 才能讓波浪不會出現「鋸齒」或「平面」的感覺。
範例 3:噪聲色彩扭曲(Fragment)
// 引入 SimplexNoise (可從 CDN 取得)
import { SimplexNoise } from 'https://cdn.jsdelivr.net/npm/simplex-noise@3.0.0/+esm';
const simplex = new SimplexNoise();
const geometry = new THREE.SphereGeometry(2, 64, 64);
const material = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float u_time;
varying vec2 vUv;
// 內建的噪聲函式(簡化版)
float noise(vec2 p) {
return ${simplex.noise2D.toString()}(p);
}
void main() {
// 依時間與座標產生漸變噪聲
float n = noise(vUv * 5.0 + u_time * 0.5);
vec3 col = mix(vec3(0.2, 0.0, 0.5), vec3(1.0, 0.8, 0.2), smoothstep(-0.2, 0.2, n));
gl_FragColor = vec4(col, 1.0);
}
`,
uniforms: { u_time: { value: 0 } }
});
scene.add(new THREE.Mesh(geometry, material));
const clock = new THREE.Clock();
function render() {
requestAnimationFrame(render);
material.uniforms.u_time.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
render();
說明:此範例透過
SimplexNoise產生雲朵般的顏色變化,適合製作火焰、煙霧等自然效果。
範例 4:UV 方向的條紋掃描(Fragment)
const geometry = new THREE.PlaneGeometry(5, 5);
const material = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float u_time;
varying vec2 vUv;
void main() {
// 產生每 0.2 單位的條紋,隨時間向右移動
float stripe = step(0.5, fract(vUv.x * 5.0 + u_time));
vec3 color = mix(vec3(0.0,0.0,0.0), vec3(1.0,0.8,0.2), stripe);
gl_FragColor = vec4(color, 1.0);
}
`,
uniforms: { u_time: { value: 0 } }
});
scene.add(new THREE.Mesh(geometry, material));
const clock = new THREE.Clock();
function loop() {
requestAnimationFrame(loop);
material.uniforms.u_time.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
loop();
技巧:
fract取得小數部分,step用於產生二元(0/1)條紋,簡易實作「掃描線」效果。
常見陷阱與最佳實踐
Uniform 更新頻率
- 陷阱:在每一幀都重新建立
uniforms物件會導致 GPU 重新編譯 Shader,嚴重拖慢效能。 - 做法:只在初始化時建立一次,之後只改變
uniforms.xxx.value。
- 陷阱:在每一幀都重新建立
Precision(精度)問題
- 在 fragment shader 中若未指定
precision mediump float;,在部分行動裝置會出現渲染錯誤。 - 建議在 shader 開頭加入:
precision mediump float; precision mediump int;
- 在 fragment shader 中若未指定
過度細分的幾何體
- 陷阱:為了波浪而把平面細分到
512x512,會讓 CPU/GPU 記憶體飆升。 - 最佳實踐:根據需求選擇適當的細分,或改用 GPU Instancing + Vertex Texture Fetch(如果需要更高頻率的變形)。
- 陷阱:為了波浪而把平面細分到
時間精度與循環
clock.getElapsedTime()會隨著程式執行時間無限累加,長時間運行會產生浮點精度誤差。- 常見做法:使用
mod(u_time, 2.0 * PI)把時間限制在一個週期內,或自行重設clock.start()。
避免硬編碼常數
- 把頻率、振幅等參數寫成
uniform,讓程式在執行時可調整,提升可重用性。
- 把頻率、振幅等參數寫成
實際應用場景
| 場景 | 可能的 Shader 動畫 | 為何適合使用自訂 Shader |
|---|---|---|
| 音樂視覺化 | 依音量或頻譜驅動波形、光柱 | 時間+音訊資料的結合需要高頻更新,GPU 計算更流暢 |
| 互動式 UI 效果 | 按鈕點擊時的漸變、光環擴散 | 只改變 uniform 即可完成,不必重建 Mesh |
| 遊戲特效 | 火焰、煙霧、波浪海面 | 需要大量像素運算,Shader 能在單帧內完成 |
| 資料可視化 | 時間序列的熱圖、流向圖 | 用顏色或形狀變化直接映射資料,提升直觀感受 |
| AR/VR 環境 | 立體空間中的光束、環繞動畫 | VR 需要高 FPS,Shader 可減少 CPU 負擔 |
總結
- Shader 是 Three.js 中最靈活的渲染工具,透過
uniform把時間、音訊或使用者輸入傳入 GPU,即可在 Vertex 或 Fragment 階段產生各式動畫。 - 建立
ShaderMaterial時,務必把 precision、uniform 與 varying 設計好,避免在每幀重新編譯。 - 本文提供四個實作範例:簡易顏色漸變、頂點波浪、噪聲扭曲與條紋掃描,示範了從最基礎到稍微進階的技巧。
- 在實務開發中,根據需求選擇適當的細分度、把可調參數抽成
uniform,並注意時間精度與效能瓶頸,即可在 WebGL 上打造流暢且富有表現力的動畫。
掌握了 基礎 Shader 動畫,你就能在 Three.js 專案中加入更具吸引力的視覺效果,從簡單的 UI 交互到複雜的遊戲特效,都能以最小的成本達成。祝你玩得開心,創作出令人驚豔的 3D 動畫!