Skip to main content

第 4 节:Material 与 Program 系统——Shader 的编译、缓存与管理

Material 是 Three.js 对"GPU 计算逻辑"的封装。理解 Material,就理解了 Three.js 怎么把 GLSL 源码组织成可复用的 Shader,以及怎么避免重复编译。


🏗️ Material 的双重身份

Material (JS 侧)
├── 1. 用户接口:color / map / opacity / transparent ...
├── 2. 状态描述:告诉 GPU 这次绘制要用什么 Shader、什么状态
└── 3. 缓存标识:version / uuid / parameters → 生成 ProgramCache 的 key

Material 既是数据(属性),也是控制(决定 Shader)。

1. 三个关键字段

// 简化源码:src/materials/Material.js
class Material {
constructor() {
this.uuid = MathUtils.generateUUID(); // 全局唯一
this.version = 0; // 修改标记
this.needsUpdate = false; // 触发重编译
}
}
  • version:每次修改材质参数,Three.js 内部 version++
  • needsUpdate = true 会让下一帧重新走 WebGLPrograms.acquireProgram() 流程。

2. onBeforeCompile:用户修改 Shader 的钩子

const material = new THREE.MeshStandardMaterial({ color: 0xff0000 });
material.onBeforeCompile = (shader) => {
// 注入自定义代码
shader.fragmentShader = shader.fragmentShader.replace(
'#include <output_fragment>',
`
diffuseColor.rgb = vec3(1.0, 0.0, 0.0); // 强制变红
#include <output_fragment>
`
);
};

这是 Three.js 最大的"魔法"——你不需要写完整 Shader,只需要在 ShaderChunkinclude 锚点处插入代码


🧩 ShaderChunk 拼接:搭积木式 Shader

Three.js 把常用 GLSL 片段拆成可复用"积木":

src/renderers/shaders/ShaderChunk/
├── common.glsl.js
├── color_pars_fragment.glsl.js
├── color_fragment.glsl.js
├── lights_pars_begin.glsl.js
├── lights_phong_fragment.glsl.js
└── ... 100+ 个

一个完整的 MeshStandardMaterial Shader 长这样(伪代码):

// 顶点着色器(拼接后)
varying vec3 vViewPosition;
#include <common>
#include <uv_pars_vertex>
#include <displacementmap_pars_vertex>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <normal_pars_vertex>
#include <morphtarget_pars_vertex>
#include <skinning_pars_vertex>
#include <shadowmap_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
void main() {
#include <uv_vertex>
#include <color_vertex>
#include <morphcolor_vertex>
#include <beginnormal_vertex>
#include <morphnormal_vertex>
#include <skinbase_vertex>
#include <skinnormal_vertex>
#include <defaultnormal_vertex>
#include <normal_vertex>
#include <begin_vertex>
#include <morphtarget_vertex>
#include <skinning_vertex>
#include <displacementmap_vertex>
#include <project_vertex>
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
vViewPosition = - mvPosition.xyz;
#include <worldpos_vertex>
#include <shadowmap_vertex>
#include <fog_vertex>
}

优势:避免代码重复,新增功能只需插入一段 include

#define 条件编译

// 是否需要贴图、阴影、雾、UV……
#define USE_MAP
#define USE_SHADOWMAP
#define USE_FOG
#define USE_UV

#ifdef USE_MAP
uniform sampler2D map;
#endif

WebGLPrograms.getParameters() 扫描所有材质参数,生成一段 #define 列表,然后拼到 Shader 头部。


💾 Program 缓存:避免重复编译

Shader 编译是非常昂贵的操作(可能耗时数百毫秒)。Three.js 用一个全局 Map 做缓存:

// 简化源码:src/renderers/webgl/WebGLPrograms.js
class WebGLPrograms {
constructor(renderer, capabilities) {
this.programs = []; // 缓存池
}

acquireProgram(parameters, cacheKey) {
let program;
for (let p = 0; p < this.programs.length; p++) {
const preexisting = this.programs[p];
if (preexisting.cacheKey === cacheKey) {
program = preexisting;
program.usedTimes++;
break;
}
}
if (program === undefined) {
program = new WebGLProgram(renderer, cacheKey, parameters);
this.programs.push(program);
}
return program;
}
}

cacheKey 怎么生成?

// 简化版
const cacheKey = [
parameters.shaderID,
parameters.precision,
parameters.useFog ? 'fog' : '',
parameters.useMap ? 'map' : '',
parameters.useShadowMap ? 'sm' : '',
// ...几十个布尔标志
parameters.customVertexShaderID,
parameters.customFragmentShaderID,
].join();

任何会影响编译产物的参数都会进入 cacheKey。

性能陷阱

onBeforeCompile 注入代码后,Three.js 会生成 customVertexShaderID自动纳入 cacheKey——这意味着你有两个看起来一样的材质,但 onBeforeCompile 注入内容不同,它们会分别编译一份 Shader。滥用会导致 Shader 数量爆炸。


🔄 WebGLUniforms:自动同步整个材质树

着色器编译完后,还有一堆 Uniform 要上传。Three.js 抽象成统一的 setValue 接口:

// 简化源码:src/renderers/webgl/WebGLUniforms.js
const setValue = (gl, v) => {
const cache = this.cache;
if (cache[0] === v) return; // 值未变,跳过上传
gl.uniform1f(this.addr, v);
cache[0] = v;
};

不同类型的 Uniform 对应不同的 setValue 实现:

Uniform 类型API
uniform floatgl.uniform1f
uniform vec3gl.uniform3f
uniform mat4gl.uniformMatrix4fv
uniform sampler2Dgl.uniform1i + 纹理单元分配

Material 改了 → version++ → uniforms 自动比对 → 必要时上传。开发者完全不用关心。


🎯 Mini 实现

class MiniMaterial {
constructor(params = {}) {
this.color = params.color || [1, 1, 1];
this.useMap = !!params.map;
this.version = 0;
this.needsUpdate = false;
}

// 生成 cacheKey(简化版)
getCacheKey() {
return `${this.color.join(',')}|${this.useMap}`;
}

// 生成 Shader 字符串
buildFragmentShader() {
let code = `
precision mediump float;
uniform vec3 uColor;
`;
if (this.useMap) code += `uniform sampler2D uMap;\n`;
code += `
void main() {
vec4 col = vec4(uColor, 1.0);
${this.useMap ? 'col *= texture2D(uMap, vUv);' : ''}
gl_FragColor = col;
}
`;
return code;
}
}

🆚 OGL 对比

维度Three.jsOGL
材质抽象30+ 种 Material 类(MeshBasic/MeshLambert/...)1 个 Material,配置项作为参数
Shader 拼接ShaderChunk 拼积木完整传入 VS/FS 字符串
缓存粒度cacheKey 包含几十个标志简单的字符串 hash
学习成本高(需要理解 chunk 系统)低(直接写 GLSL)
适用场景复杂场景、需要复用快速原型、单一场景

🎓 深度思考

  1. 为什么 Three.js 不让用户直接传 Shader 字符串?
    因为 uniforms 同步、缓存去重、onBeforeCompile 钩子等机制都需要"统一拦截"。如果用户写裸 Shader,引擎就无从插手。

  2. Program 缓存会不会内存泄漏?
    不会——WebGLPrograms.releaseProgram()material.dispose() 时调用,引用计数归零后真正删除。

  3. Three.js 的 Shader 是怎么支持 GLSL ES 1.0 和 3.0 双版本的?
    编译时根据 WebGL2RenderingContext 是否存在,决定输出 #version 300 es 还是默认 100。


📚 延伸阅读