本文 AI 產出,尚未審核

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); // 橙色
}
  • projectionMatrixmodelViewMatrixposition 為 Three.js 預設注入的 uniformattribute,我們只需要直接使用即可。

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:每個頂點的資料(如 positionnormal),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);
}

技巧normalMatrixmodelViewMatrix 的逆轉置,用來正確變換法線。


範例 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 的順序載入圖片

最佳實踐

  1. 保持 Shader 簡潔:先寫最小可運行的版本,再逐步加入功能。
  2. 利用 THREE.ShaderChunk:Three.js 內建許多常用程式碼片段(如 packing, bsdfs),可直接 #include <common> 引入,減少重複撰寫。
  3. 使用 debug Uniform:在開發階段加入 uDebug,可在 fragment 中顯示法線、UV、深度等資訊,快速定位問題。
  4. 預先計算不變值:例如光源方向、常數矩陣等,盡可能放在 uniform,避免在每個頂點或片段中重複計算。
  5. 適當使用 #define:透過宏定義開關特效,編譯同一套 shader 時可產生不同變體,減少檔案數量。

實際應用場景

場景 需求 可能的 Shader 實作
水面 動態波紋、反射、折射 結合波浪變形 + CubeMap 反射 + Fresnel 效果
火焰 / 爆炸 時間驅動的噪聲、透明度漸變 以 Perlin / Simplex 噪聲產生顏色與透明度,使用 additive blending
金屬材質 高光、鏡面反射、粗糙度 使用 PBR 公式(Metallic‑Roughness)自行實作,或改寫 MeshStandardMaterial 的 shaderChunk
卡通渲染 (Toon) 硬邊緣、階梯式光照 在 fragment 中根據 dot(normal, lightDir) 產生離散色階,並加入輪廓描邊
地形 大量頂點、LOD、貼圖混合 在 vertex 中依高度改變顏色或貼圖權重,使用 gl_InstanceIDtexture2DArray 進行多層貼圖混合

透過自訂 Shader,不僅能實現上述特效,還能在效能上取得最佳化(只計算必要的光照、在 GPU 端完成所有變形),讓 WebGL 應用在手機與桌面端都能流暢運行。


總結

本文從 GLSL 基礎結構Three.js 的 ShaderMaterial,一步步帶領讀者了解 Vertex / Fragment 資料流與 Uniform / Attribute / Varying 的使用方式。透過四個實作範例,我們展示了 顏色漸層、時間波浪、法線貼圖光照、環境映射 等常見需求,並說明了在開發過程中容易遇到的陷阱與最佳實踐。

掌握了這些概念後,你就能在自己的 Three.js 專案中 自由打造自訂材質與特效,無論是水面、火焰、金屬或卡通渲染,都能以最小的程式碼量達到最佳的視覺效果。未來若需要更高階的功能(如 GPU 皮克斯Screen‑Space Reflections),只要把本章的思路延伸到更複雜的 GLSL 計算,就能輕鬆上手。

祝你在 3D Web 世界中玩得開心,創造出令人驚艷的視覺體驗!