Three.js - 材質與 Shader 進階:使用 GLSL 撰寫 Vertex / Fragment Shader
簡介
在 3D 網頁應用中,預設的 MeshStandardMaterial、MeshPhongMaterial 等 能快速完成大多數需求,但當視覺效果需要突破現有材質的限制時,必須自行編寫 GLSL(OpenGL Shading Language)著色器。透過自訂 Vertex Shader 與 Fragment Shader,我們可以精確控制頂點變形、光照模型、顏色混合以及各種特效,讓作品在表現力上更上一層樓。
本單元將從 GLSL 基礎結構、Three.js 與 ShaderMaterial 的結合,一步步帶領讀者寫出實用的著色器範例。即使你是剛接觸 Three.js 的新手,只要跟著範例操作,也能在短時間內掌握自訂 Shader 的核心概念,並能在專案中靈活運用。
核心概念
1. GLSL 程式的基本框架
GLSL 程式分為 Vertex Shader(頂點階段)與 Fragment Shader(片段階段)。每個階段都有固定的入口與輸出變數,且在 Three.js 中以字串形式傳入 ShaderMaterial。最簡單的範例如下:
// vertex.glsl
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// fragment.glsl
void main() {
gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0); // 橙色
}
projectionMatrix、modelViewMatrix、position為 Three.js 預設注入的 uniform 或 attribute,我們只需要直接使用即可。
2. Three.js 中的 ShaderMaterial
ShaderMaterial 是 Three.js 提供的「裸」材質類型,只保留最基本的渲染流程,所有細節皆由開發者自行決定。建立方式如下:
import * as THREE from 'three';
import vertexShader from './shaders/vertex.glsl';
import fragmentShader from './shaders/fragment.glsl';
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0.0 },
uColor: { value: new THREE.Color(0xff6600) }
}
});
- uniform:全域常數,渲染每個物件時保持不變。
- attribute:每個頂點的資料(如
position、normal),Three.js 會自動傳入。 - varying:在 Vertex → Fragment 之間傳遞的變數。
3. Vertex Shader 與 Fragment Shader 的資料流
| 階段 | 主要任務 | 常用變數 |
|---|---|---|
| Vertex | 位置變換、頂點屬性計算 | position, normal, uv, modelViewMatrix, projectionMatrix |
| Fragment | 像素顏色決定、光照、後期效果 | gl_FragColor, varying(由 Vertex 傳入) |
Tip:在 Vertex 中計算的資訊(如法線、UV)若需要在 Fragment 中使用,必須透過
varying變數傳遞。
4. Uniform、Attribute、Varying 的使用技巧
// vertex.glsl
attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float uTime;
varying vec3 vNormal;
varying vec3 vPos;
void main() {
// 讓模型隨時間上下浮動
vec3 displaced = position + normal * sin(uTime + position.y * 5.0) * 0.1;
vNormal = normal;
vPos = displaced;
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
// fragment.glsl
precision highp float;
uniform vec3 uColor;
varying vec3 vNormal;
varying vec3 vPos;
void main() {
// 簡易 lambert 光照
vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3));
float diff = max(dot(normalize(vNormal), lightDir), 0.0);
gl_FragColor = vec4(uColor * diff, 1.0);
}
precision highp float;必須在 fragment 中宣告,否則部分平台會報錯。attribute在 Three.js 已自動注入,開發者只需要在 GLSL 中宣告即可。
5. 常見的光照模型:自訂 Lambert
雖然 Three.js 已內建光照計算,但在自訂 Shader 時,我們常需要自行實作簡易光照模型。以下示例展示 Lambert 漫反射 的完整流程,並加入 時間動畫 讓光源繞圈。
// fragment.glsl
precision highp float;
uniform vec3 uLightPos; // 動態光源位置
uniform vec3 uColor;
varying vec3 vNormal;
varying vec3 vPos;
void main() {
vec3 lightDir = normalize(uLightPos - vPos);
float diff = max(dot(normalize(vNormal), lightDir), 0.0);
vec3 diffuse = diff * uColor;
gl_FragColor = vec4(diffuse, 1.0);
}
在 JavaScript 中更新光源位置:
function animate(time) {
const t = time * 0.001;
material.uniforms.uLightPos.value.set(
Math.cos(t) * 5,
3.0,
Math.sin(t) * 5
);
material.uniforms.uTime.value = t;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
程式碼範例
以下提供 4 個實用範例,每個範例皆附上說明與關鍵註解,讀者可直接拷貝到專案中測試。
範例 1️⃣ 基本顏色漸層(Vertex 只傳遞位置)
// main.js
import * as THREE from 'three';
import vert from './shaders/grad.vert.glsl';
import frag from './shaders/grad.frag.glsl';
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader: vert,
fragmentShader: frag,
uniforms: {
uTopColor: { value: new THREE.Color(0x0066ff) },
uBottomColor:{ value: new THREE.Color(0xffffff) }
}
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// grad.vert.glsl
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// grad.frag.glsl
precision highp float;
uniform vec3 uTopColor;
uniform vec3 uBottomColor;
void main() {
// 依照 Y 座標在上下兩色之間線性插值
float t = (gl_FragCoord.y / float( resolution.y )); // 需在 JS 中傳入 resolution
vec3 color = mix(uBottomColor, uTopColor, t);
gl_FragColor = vec4(color, 1.0);
}
重點:
mix(a, b, t)為線性插值函式,可快速實作漸層。
範例 2️⃣ 時間驅動的波浪變形
// main.js
const material = new THREE.ShaderMaterial({
vertexShader: waveVert,
fragmentShader: waveFrag,
uniforms: {
uTime: { value: 0 },
uAmplitude: { value: 0.2 },
uFrequency: { value: 4.0 }
},
side: THREE.DoubleSide
});
// wave.vert.glsl
uniform float uTime;
uniform float uAmplitude;
uniform float uFrequency;
varying vec3 vNormal;
varying vec2 vUv;
void main() {
vUv = uv;
vNormal = normal;
// 使用正弦波讓頂點上下浮動
float offset = sin(position.x * uFrequency + uTime) *
cos(position.z * uFrequency + uTime) *
uAmplitude;
vec3 displaced = position + normal * offset;
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
// wave.frag.glsl
precision highp float;
varying vec2 vUv;
void main() {
// 以 UV 為基礎給予淡藍色
gl_FragColor = vec4(0.1, 0.6, 0.9, 1.0);
}
在 animate 迴圈中更新 uTime:
function animate(time) {
material.uniforms.uTime.value = time * 0.001;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
範例 3️⃣ 自訂 Lambert 光照 + 法線貼圖
// main.js
import normalMapImg from './textures/brick_normal.jpg';
const tex = new THREE.TextureLoader().load(normalMapImg);
const material = new THREE.ShaderMaterial({
vertexShader: lambertVert,
fragmentShader: lambertFrag,
uniforms: {
uLightPos: { value: new THREE.Vector3(5, 5, 5) },
uColor: { value: new THREE.Color(0xffffff) },
uNormalMap:{ value: tex }
}
});
// lambert.vert.glsl
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix; // 轉換法線的矩陣
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec2 vUv;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
vec4 worldPos = modelViewMatrix * vec4(position, 1.0);
vWorldPos = worldPos.xyz;
gl_Position = projectionMatrix * worldPos;
}
// lambert.frag.glsl
precision highp float;
uniform vec3 uLightPos;
uniform vec3 uColor;
uniform sampler2D uNormalMap;
varying vec3 vNormal;
varying vec3 vWorldPos;
varying vec2 vUv;
void main() {
// 取樣法線貼圖,並將範圍從 [0,1] 轉為 [-1,1]
vec3 mapNormal = texture2D(uNormalMap, vUv).xyz * 2.0 - 1.0;
// 合併幾何法線與貼圖法線
vec3 normal = normalize(vNormal + mapNormal * 0.5);
vec3 lightDir = normalize(uLightPos - vWorldPos);
float diff = max(dot(normal, lightDir), 0.0);
gl_FragColor = vec4(uColor * diff, 1.0);
}
技巧:
normalMatrix為modelViewMatrix的逆轉置,用來正確變換法線。
範例 4️⃣ 簡易環境映射(Reflection)
// main.js
const envTexture = new THREE.CubeTextureLoader()
.setPath('textures/cube/')
.load(['px.jpg','nx.jpg','py.jpg','ny.jpg','pz.jpg','nz.jpg']);
const material = new THREE.ShaderMaterial({
vertexShader: reflectVert,
fragmentShader: reflectFrag,
uniforms: {
uEnvMap: { value: envTexture },
uCameraPos: { value: camera.position }
}
});
// reflect.vert.glsl
attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
varying vec3 vReflectDir;
varying vec3 vWorldPos;
void main() {
vec4 worldPos = modelViewMatrix * vec4(position, 1.0);
vWorldPos = worldPos.xyz;
vec3 worldNormal = normalize(normalMatrix * normal);
vec3 viewDir = normalize(-worldPos.xyz);
vReflectDir = reflect(viewDir, worldNormal);
gl_Position = projectionMatrix * worldPos;
}
// reflect.frag.glsl
precision highp float;
uniform samplerCube uEnvMap;
uniform vec3 uCameraPos;
varying vec3 vReflectDir;
void main() {
vec4 envColor = textureCube(uEnvMap, normalize(vReflectDir));
gl_FragColor = envColor;
}
此範例展示 CubeMap 的使用方式,僅需在 uniform 中傳入 CubeTexture,即可在 fragment 中利用 textureCube 取得環境顏色。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| Precision 錯誤 | 部分平台(尤其是手機)若未宣告 precision 會報錯 |
在 fragment shader 首行加入 precision highp float; |
| uniform 更新未同步 | 在 requestAnimationFrame 之外改變 uniform,卻忘記呼叫 needsUpdate |
大多數情況不必手動設定,但如果更新 Texture 必須 texture.needsUpdate = true |
| 法線方向相反 | 法線貼圖或自訂變形後未重新正規化 | 使用 normalize(vNormal),或在 vertex 中重新計算法線 |
| Varying 超過上限 | 變數過多會導致「varying limit exceeded」錯誤 | 僅傳遞必要資訊,或使用 gl_FrontFacing 直接在 fragment 判斷正背面 |
| CubeTexture 方向錯誤 | 環境貼圖的六張圖片順序不正確會產生鏡像 | 按照 +X, -X, +Y, -Y, +Z, -Z 的順序載入圖片 |
最佳實踐
- 保持 Shader 簡潔:先寫最小可運行的版本,再逐步加入功能。
- 利用
THREE.ShaderChunk:Three.js 內建許多常用程式碼片段(如packing,bsdfs),可直接#include <common>引入,減少重複撰寫。 - 使用
debugUniform:在開發階段加入uDebug,可在 fragment 中顯示法線、UV、深度等資訊,快速定位問題。 - 預先計算不變值:例如光源方向、常數矩陣等,盡可能放在
uniform,避免在每個頂點或片段中重複計算。 - 適當使用
#define:透過宏定義開關特效,編譯同一套 shader 時可產生不同變體,減少檔案數量。
實際應用場景
| 場景 | 需求 | 可能的 Shader 實作 |
|---|---|---|
| 水面 | 動態波紋、反射、折射 | 結合波浪變形 + CubeMap 反射 + Fresnel 效果 |
| 火焰 / 爆炸 | 時間驅動的噪聲、透明度漸變 | 以 Perlin / Simplex 噪聲產生顏色與透明度,使用 additive blending |
| 金屬材質 | 高光、鏡面反射、粗糙度 | 使用 PBR 公式(Metallic‑Roughness)自行實作,或改寫 MeshStandardMaterial 的 shaderChunk |
| 卡通渲染 (Toon) | 硬邊緣、階梯式光照 | 在 fragment 中根據 dot(normal, lightDir) 產生離散色階,並加入輪廓描邊 |
| 地形 | 大量頂點、LOD、貼圖混合 | 在 vertex 中依高度改變顏色或貼圖權重,使用 gl_InstanceID 或 texture2DArray 進行多層貼圖混合 |
透過自訂 Shader,不僅能實現上述特效,還能在效能上取得最佳化(只計算必要的光照、在 GPU 端完成所有變形),讓 WebGL 應用在手機與桌面端都能流暢運行。
總結
本文從 GLSL 基礎結構、Three.js 的 ShaderMaterial,一步步帶領讀者了解 Vertex / Fragment 資料流與 Uniform / Attribute / Varying 的使用方式。透過四個實作範例,我們展示了 顏色漸層、時間波浪、法線貼圖光照、環境映射 等常見需求,並說明了在開發過程中容易遇到的陷阱與最佳實踐。
掌握了這些概念後,你就能在自己的 Three.js 專案中 自由打造自訂材質與特效,無論是水面、火焰、金屬或卡通渲染,都能以最小的程式碼量達到最佳的視覺效果。未來若需要更高階的功能(如 GPU 皮克斯、Screen‑Space Reflections),只要把本章的思路延伸到更複雜的 GLSL 計算,就能輕鬆上手。
祝你在 3D Web 世界中玩得開心,創造出令人驚艷的視覺體驗!