本文 AI 產出,尚未審核

Three.js – 材質與 Shader 進階

主題:ShaderMaterialRawShaderMaterial


簡介

在 WebGL 與 Three.js 的開發過程中,自訂 Shader 是實現各種特殊視覺效果的關鍵。雖然 Three.js 內建的 MeshStandardMaterialMeshPhongMaterial 等已經涵蓋了大多數常見需求,但當你需要 完全掌控渲染流程、或是想要實作獨特的光照、噪波、後期處理時,就必須直接編寫 GLSL 程式碼。

ShaderMaterialRawShaderMaterial 正是為此而設計的兩種材質類型:

  • ShaderMaterial:在 Three.js 的渲染管線上提供一層抽象,會自動注入常用的 uniform、attribute 以及內建的變數(如 modelViewMatrixprojectionMatrix)。適合想保留 Three.js 便利性,同時加入自訂片段或頂點程式碼的開發者。

  • RawShaderMaterial:完全裸露的 GLSL 程式碼,不會自動加入任何預設變數。你必須自行處理所有矩陣、光照、骨骼等資訊。這讓程式碼更接近原生 WebGL,也更具彈性,但相對需要更多的基礎知識。

本篇文章將深入說明兩者的差異、使用時機、常見陷阱與最佳實踐,並提供多個可直接運行的範例,協助你在實務專案中快速上手。


核心概念

1. ShaderMaterial 的工作原理

ShaderMaterial 會在 Three.js 內部自動完成以下幾件事:

項目 內容 為何重要
內建 Uniform modelViewMatrixprojectionMatrixnormalMatrixcameraPosition 讓你在 GLSL 中直接使用模型、視圖、投影資訊,不必自行傳遞。
內建 Attribute positionnormaluvcolor 自動綁定幾何體的屬性,省去繁雜的 gl.bindBuffer 工作。
自動編譯 當材質屬性改變時,Three.js 會重新編譯 shader 確保程式碼與參數同步,減少手動管理的錯誤。
支援 definesextensions 可在 material 設定 definesextensions,影響編譯結果 方便在不同平台或需求下切換功能(例如 GL_OES_standard_derivatives)。

基本範例:彩色立方體

import * as THREE from 'three';

// 1. 建立場景與相機
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(2, 2, 4);
camera.lookAt(0, 0, 0);

// 2. 建立渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 3. 定義頂點與片段 shader
const vertexShader = `
    varying vec3 vPosition;
    void main() {
        vPosition = position;                 // 直接使用內建 attribute
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
`;

const fragmentShader = `
    varying vec3 vPosition;
    void main() {
        // 依據模型座標的 X/Y/Z 產生漸層顏色
        gl_FragColor = vec4(abs(vPosition), 1.0);
    }
`;

// 4. 建立 ShaderMaterial
const material = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader,
    // 若需要自訂 uniform,可在此加入
    // uniforms: { time: { value: 0 } }
});

// 5. 建立幾何體與 Mesh
const geometry = new THREE.BoxGeometry(1, 1, 1);
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 6. 動畫迴圈
function animate(time) {
    requestAnimationFrame(animate);
    cube.rotation.x = time * 0.001;
    cube.rotation.y = time * 0.0015;
    renderer.render(scene, camera);
}
animate();

重點:在 ShaderMaterial 中,你仍然可以使用 uniformsdefinestransparentdepthWrite 等常見屬性,且 Three.js 會自動把 modelViewMatrixprojectionMatrix 等矩陣注入 shader。


2. RawShaderMaterial:從零開始的自由度

RawShaderMaterial不做任何自動注入,因此你必須自行提供所有必需的變數與程式碼。這樣的好處是:

  • 完全掌控 vertex 與 fragment 的輸入/輸出,適合 全自訂渲染流程(例如 G‑Buffer、延遲渲染)。
  • 可以直接使用 GLSL ES 1.0 標準,不受 Three.js 的預設限制。
  • 更容易與其他 WebGL 框架或原生程式碼共用 shader。

然而,失去自動注入也意味著 必須自行傳遞矩陣,而且若忘記某個必要的 uniform,渲染結果會出現「全黑」或「錯誤的座標」等問題。

基本範例:手動傳遞矩陣的旋轉平面

import * as THREE from 'three';

// ---------------------------------------------------
// 1. 基本設定(同上)
// ---------------------------------------------------
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10);
camera.position.set(0, 0, 2);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// ---------------------------------------------------
// 2. 自訂 Uniform(手動傳遞矩陣)
// ---------------------------------------------------
const uniforms = {
    uModelViewMatrix: { value: new THREE.Matrix4() },
    uProjectionMatrix: { value: new THREE.Matrix4() },
    uTime: { value: 0.0 }
};

// ---------------------------------------------------
// 3. RawShaderMaterial 的 shader 程式碼
// ---------------------------------------------------
const vertexShader = `
    attribute vec3 position;
    uniform mat4 uModelViewMatrix;
    uniform mat4 uProjectionMatrix;
    void main() {
        gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(position, 1.0);
    }
`;

const fragmentShader = `
    precision mediump float;
    uniform float uTime;
    void main() {
        // 產生隨時間變化的顏色
        vec3 col = 0.5 + 0.5 * cos(uTime + vec3(0.0, 2.0, 4.0));
        gl_FragColor = vec4(col, 1.0);
    }
`;

const material = new THREE.RawShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms,
    // 必須自行指定 attribute 名稱,否則會找不到
    // 這裡不需要額外設定,Three.js 會自動把 geometry 的 attribute 以相同名稱綁定
});

// ---------------------------------------------------
// 4. 建立幾何體(單純平面)
// ---------------------------------------------------
const geometry = new THREE.PlaneGeometry(1.5, 1.5);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// ---------------------------------------------------
// 5. 動畫迴圈:更新矩陣與時間 uniform
// ---------------------------------------------------
function animate(time) {
    requestAnimationFrame(animate);
    uniforms.uTime.value = time * 0.001;

    // 手動計算 modelViewMatrix(等同於 camera.matrixWorldInverse * mesh.matrixWorld)
    mesh.updateMatrixWorld(); // 確保 world matrix 正確
    const modelView = new THREE.Matrix4()
        .multiplyMatrices(camera.matrixWorldInverse, mesh.matrixWorld);
    uniforms.uModelViewMatrix.value.copy(modelView);
    uniforms.uProjectionMatrix.value.copy(camera.projectionMatrix);

    renderer.render(scene, camera);
}
animate();

技巧RawShaderMaterial 中的 attribute 必須與幾何體的屬性名稱完全相同(例如 positionnormaluv),否則會在編譯時拋出錯誤。


3. 何時選擇 ShaderMaterial,何時使用 RawShaderMaterial

條件 建議使用 說明
需要快速開發、只改寫片段或小幅調整 ShaderMaterial 只要在已有的 Three.js 渲染管線上加點小技巧,省去手動傳遞矩陣的麻煩。
要實作完整的自訂光照模型、延遲渲染或 G‑Buffer RawShaderMaterial 必須自行控制所有輸入,避免 Three.js 預設的變數干擾。
想與其他 WebGL 程式碼共用同一套 shader RawShaderMaterial 完全裸露的程式碼最容易搬移。
需要使用 Three.js 內建的 lightsshadowMap ShaderMaterial(結合 lights: true Three.js 會自動注入光照相關 uniform。
想在 shader 中直接使用 #include <...> 片段 ShaderMaterial Three.js 內建的 ShaderChunk 只能在 ShaderMaterial 中使用。

4. 程式碼範例彙總

以下提供 五個實用範例,涵蓋不同需求與技巧。

4.1 變色球體(使用 ShaderMaterial + uniforms

const sphereGeo = new THREE.SphereGeometry(0.8, 64, 64);
const sphereMat = new THREE.ShaderMaterial({
    uniforms: {
        uColor: { value: new THREE.Color(0xff0000) },
        uTime:  { value: 0 }
    },
    vertexShader: `
        varying vec3 vNormal;
        void main() {
            vNormal = normalMatrix * normal;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
        }
    `,
    fragmentShader: `
        uniform vec3 uColor;
        uniform float uTime;
        varying vec3 vNormal;
        void main() {
            // 使用法線與時間產生漸變光澤
            float intensity = dot(normalize(vNormal), vec3(0.0,0.0,1.0));
            vec3 col = uColor * (0.5 + 0.5 * sin(uTime + intensity*5.0));
            gl_FragColor = vec4(col, 1.0);
        }
    `
});
scene.add(new THREE.Mesh(sphereGeo, sphereMat));

說明uniforms 允許在程式外部動態更新顏色與時間,適合製作動畫效果。

4.2 2D 條紋噪波(RawShaderMaterial 完全自訂)

const plane = new THREE.PlaneGeometry(2, 2);
const rawMat = new THREE.RawShaderMaterial({
    uniforms: {
        uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) },
        uTime:       { value: 0 }
    },
    vertexShader: `
        attribute vec3 position;
        void main() {
            gl_Position = vec4(position, 1.0); // 直接使用 clip space
        }
    `,
    fragmentShader: `
        precision mediump float;
        uniform vec2 uResolution;
        uniform float uTime;
        // Simple 2D noise based on sine waves
        float noise(vec2 p) {
            return sin(p.x*10.0 + uTime) * sin(p.y*10.0 + uTime);
        }
        void main() {
            vec2 uv = gl_FragCoord.xy / uResolution;
            float n = noise(uv * 5.0);
            gl_FragColor = vec4(vec3(0.5 + 0.5 * n), 1.0);
        }
    `
});
scene.add(new THREE.Mesh(plane, rawMat));

重點:使用 gl_FragCoord 直接取得螢幕座標,避免任何矩陣運算。

4.3 透明貼圖 + Alpha 測試(ShaderMaterial + alphaTest

const tex = new THREE.TextureLoader().load('leaf.png');
const leafMat = new THREE.ShaderMaterial({
    uniforms: { map: { value: tex } },
    vertexShader: `
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
        }
    `,
    fragmentShader: `
        uniform sampler2D map;
        varying vec2 vUv;
        void main() {
            vec4 col = texture2D(map, vUv);
            // 只保留 alpha 超過 0.2 的像素
            if(col.a < 0.2) discard;
            gl_FragColor = col;
        }
    `,
    transparent: true,   // 讓 Three.js 正確排序
    alphaTest: 0.2        // 也可以使用 material.alphaTest
});
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(1,1), leafMat));

技巧transparent:true + alphaTest 能同時支援 半透明硬剪裁,在渲染樹葉、紙張等素材時非常有用。

4.4 多光源自訂 Phong(ShaderMaterial + lights:true

const phongMat = new THREE.ShaderMaterial({
    lights: true,          // 讓 Three.js 注入光照 uniform
    vertexShader: `
        varying vec3 vNormal;
        varying vec3 vViewPosition;
        void main() {
            vNormal = normalize(normalMatrix * normal);
            vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
            vViewPosition = -mvPosition.xyz;
            gl_Position = projectionMatrix * mvPosition;
        }
    `,
    fragmentShader: `
        uniform vec3 diffuse;
        uniform vec3 specular;
        uniform float shininess;
        varying vec3 vNormal;
        varying vec3 vViewPosition;

        // Three.js 注入的光源資訊
        struct DirectionalLight {
            vec3 direction;
            vec3 color;
        };
        uniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];

        void main() {
            vec3 normal = normalize(vNormal);
            vec3 viewDir = normalize(vViewPosition);
            vec3 totalDiffuse = vec3(0.0);
            vec3 totalSpecular = vec3(0.0);

            for(int i=0; i<NUM_DIR_LIGHTS; i++) {
                vec3 lightDir = normalize(directionalLights[i].direction);
                // Diffuse
                float diffFactor = max(dot(normal, lightDir), 0.0);
                totalDiffuse += directionalLights[i].color * diffFactor;
                // Specular
                vec3 reflectDir = reflect(-lightDir, normal);
                float specFactor = pow(max(dot(viewDir, reflectDir), 0.0), shininess);
                totalSpecular += directionalLights[i].color * specFactor;
            }

            vec3 finalColor = diffuse * totalDiffuse + specular * totalSpecular;
            gl_FragColor = vec4(finalColor, 1.0);
        }
    `,
    uniforms: {
        diffuse:  { value: new THREE.Color(0x156289) },
        specular: { value: new THREE.Color(0x111111) },
        shininess:{ value: 30 }
    }
});
scene.add(new THREE.Mesh(new THREE.BoxGeometry(1,1,1), phongMat));

說明:只要把 lights: true 設為 true,Three.js 會自動把 NUM_DIR_LIGHTSdirectionalLights 等資料注入,讓自訂 Phong 變得相當簡潔。

4.5 後處理濾鏡(RawShaderMaterial + RenderTarget)

// 1. 建立 RenderTarget
const renderTarget = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight);

// 2. 渲染場景到 RenderTarget
renderer.setRenderTarget(renderTarget);
renderer.render(scene, camera);
renderer.setRenderTarget(null); // 回到螢幕

// 3. 使用 RawShaderMaterial 把 RenderTarget 畫面當作貼圖做後處理
const postMat = new THREE.RawShaderMaterial({
    uniforms: {
        uTexture: { value: renderTarget.texture },
        uResolution: { value: new THREE.Vector2(window.innerWidth, window.innerHeight) }
    },
    vertexShader: `
        attribute vec3 position;
        attribute vec2 uv;
        varying vec2 vUv;
        void main() {
            vUv = uv;
            gl_Position = vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        precision mediump float;
        uniform sampler2D uTexture;
        uniform vec2 uResolution;
        varying vec2 vUv;
        void main() {
            vec4 col = texture2D(uTexture, vUv);
            // 簡單的亮度增強
            col.rgb = pow(col.rgb, vec3(0.8));
            gl_FragColor = col;
        }
    `
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), postMat);
scene.add(quad);
renderer.render(scene, camera);

應用:在需要 多階段渲染(如 Bloom、HDR Tone Mapping)時,RawShaderMaterial 提供最直接的方式讀取前一階段的 framebuffer。


常見陷阱與最佳實踐

陷阱 可能的症狀 解決方案或最佳實踐
忘記更新 Uniform 物件靜止、顏色不變 在動畫迴圈中 uniforms.xxx.value = newValue,或使用 material.needsUpdate = true(僅在改變 shader 程式碼時)。
矩陣傳遞不正確(RawShaderMaterial) 物件出現在螢幕外、縮放異常 確保 modelViewMatrix = camera.matrixWorldInverse * mesh.matrixWorldprojectionMatrix 直接取自相機。
precision 未宣告(在 fragment shader) Chrome/Firefox 報錯 precision qualifier missing 在 fragment shader 開頭加入 precision mediump float;(或 highp
過度使用 discard 大量片段被剔除導致 GPU 低效 盡量使用 alphaTestpre‑multiply alpha,只有在必要時才 discard
忽略 transparent 設定 透明物件渲染順序錯亂 若需要半透明,必須把 transparent: true 設為 true,並盡量使用 深度排序renderer.sortObjects = true)。
Shader 編譯失敗訊息不清 console 只顯示 GLSL: compilation error 在 Chrome 開發者工具的 Sources → WebGL 中檢視完整錯誤訊息,或使用 console.error(material.program.getErrorLog())(Three.js r155+)。
忘記為 RawShaderMaterial 指定 attribute 名稱 attribute aPosition not found 確認 attribute vec3 position; 與幾何體的 position attribute 名稱一致。

最佳實踐

  1. 先用 ShaderMaterial 原型:在不確定需求時,先用 ShaderMaterial 撰寫,確保渲染結果正確後,再視需求遷移至 RawShaderMaterial
  2. 將常用程式碼抽成 ShaderChunk:即使是自訂 shader,也可利用 Three.js 的 THREE.ShaderChunk 共享常見函式(如 packing.glslcommon.glsl),提升維護性。
  3. 使用 uniforms 物件集中管理:將所有可變參數放在同一個 uniforms 物件裡,方便在動畫迴圈或 UI 控制面板中即時調整。
  4. 啟用 GPUProfiler:開發階段使用 Chrome 的 WebGL GPU Profiler,觀察 shader 執行時間,避免過度複雜的運算導致掉幀。
  5. 兼容性測試:在手機、平板與桌面瀏覽器上測試,特別是 highp 可能在舊版手機上不支援,必要時降級為 mediump

實際應用場景

場景 為什麼需要自訂 Shader 推薦使用
特殊光照模型(例如 Cartoony、Cel‑Shading) 內建 PBR 無法直接達成卡通渲染 ShaderMaterial(配合 lights:true
GPU 粒子系統(大量點雲、煙火) 需要在 vertex shader 中直接計算位置與大小 RawShaderMaterial(避免額外的 uniform)
後處理濾鏡(Bloom、FXAA、色彩分級) 需要讀取前一階段 framebuffer,並在 fragment 中做運算 RawShaderMaterial + RenderTarget
多階段延遲渲染(G‑Buffer) 必須分離 albedo、normal、depth 等資訊 RawShaderMaterial(完整掌控輸出)
動態貼圖生成(Procedural Noise、SDF) GLSL 中即時產生圖案,避免載入大量紋理 ShaderMaterial(若仍需要相機資訊)或 RawShaderMaterial(純 2D)

總結

  • ShaderMaterial 保留了 Three.js 的便利性,自動注入矩陣、光照與常用變數,適合大多數需要自訂片段或小幅改寫頂點的情境。
  • RawShaderMaterial 拋棄所有自動注入,讓開發者可以從零開始編寫 GLSL,對於 延遲渲染、後處理、GPU 粒子 等高階需求尤為重要。
  • 兩者的選擇並非二選一,而是 根據需求與開發階段 靈活切換:先用 ShaderMaterial 快速驗證概念,必要時再切換至 RawShaderMaterial 取得更高的彈性與效能。

掌握這兩種材質的差異與使用技巧,將讓你在 Three.js 中的視覺表現不再受限,無論是 卡通渲染、實時噪波、還是高階的後處理管線,都能得心應手。祝你玩得開心,創造出令人驚豔的 Web 3D 作品!