02 · Three.js 如何组装着色器
1. 从 Material 到 GPU 程序
Material 保存颜色、贴图、roughness、透明度等配置,但不是一份独立手写 GLSL 文件。WebGLRenderer 根据材质类型、几何体、灯光和渲染状态生成程序参数,再选取 ShaderLib 模板。
材质 + 对象 + 灯光 + 渲染器状态
↓
WebGLPrograms:选择 ShaderLib、生成宏与缓存参数
↓
onBeforeCompile:修改尚未展开的源码、添加 uniforms
↓
WebGLProgram:展开 include、替换特定计数、循环展开、添加前缀
↓
编译 vertex/fragment shader,链接 GPU program
这里有一个关键细节:r185 的自定义缓存键在进入 onBeforeCompile 之前已参与程序查找。不能等回调执行时才临时决定缓存身份;产生不同代码的配置必须提前反映在 customProgramCacheKey() 中。
2. ShaderLib 是骨架,ShaderChunk 是模块
可在安装依赖后的模块中读取:
import * as THREE from 'three';
console.log(THREE.ShaderLib.standard.vertexShader);
console.log(THREE.ShaderLib.phong.fragmentShader);
console.log(THREE.ShaderChunk.begin_vertex);
begin_vertex 的核心职责是从 attribute position 创建可变的局部位置 transformed。ShaderLib 主体中的 #include <begin_vertex> 是模块引用,不是 JavaScript import。
ShaderChunk 也可以引用其他 Chunk,Three.js 在组装时递归展开。因此 shader.fragmentShader 中可能找不到某段实际执行的代码:它此时还藏在一个 include 后面。仅打印回调里的字符串,看到的还不是完整 GPU 源码。
本地重点文件:
node_modules/three/src/renderers/shaders/ShaderLib/meshphong.glsl.jsnode_modules/three/src/renderers/shaders/ShaderLib/meshphysical.glsl.jsnode_modules/three/src/renderers/shaders/ShaderChunk/node_modules/three/src/renderers/webgl/WebGLPrograms.jsnode_modules/three/src/renderers/webgl/WebGLProgram.js
Standard 和 Physical 共享物理着色模板,Physical 通过额外 defines 与参数启用扩展。不是看到文件名 meshphysical,就认定它只服务 MeshPhysicalMaterial。
3. defines 让相同模板生成不同程序
有颜色贴图时,相关 USE_MAP 路径会被启用;没有颜色贴图时,相同模板中很多代码被预处理移除。法线贴图、蒙皮、实例化、清漆、透射等同理。
#ifdef USE_MAP
vec4 sampledDiffuseColor = texture2D(map, vMapUv);
diffuseColor *= sampledDiffuseColor;
#endif
这解释了为什么直接使用 vMapUv 可能只在某些材质上编译成功:没有对应宏时,声明并不存在。自定义纹理不应靠强行设置内置宏来“借出”变量,优先声明自己的一组 sampler 和 UV varying。
普通 uniform 数值改变通常不改变程序结构;增加数组长度、改变 GLSL 声明、启用新的编译路径则可能需要新程序。运行时状态与编译期配置应分开管理。
4. 回调不是每帧执行函数
onBeforeCompile 在 renderer 为材质获取需要处理的程序变体时参与工作,不是动画更新函数。已有变体命中缓存时可能不会再次调用;灯光、材质开关或对象特征变化也可能引入新的变体。
const uniforms = { uDemoTime: { value: 0 } };
material.onBeforeCompile = (shader) => {
shader.uniforms.uDemoTime = uniforms.uDemoTime;
// 在这里修改源码,不在这里推进动画时间。
};
// 在渲染循环中更新 uniforms.uDemoTime.value。
上面 material 是调用者创建的内置材质。稳定的 uniform 描述对象可被不同变体引用;保存单个 lastShader 再更新它,会把状态管理错误地绑定到最近一次回调对象。
5. 回调源码与最终源码的差别
除 include 展开之外,r185 还会添加精度声明、内置 attribute/uniform、defines 和 GLSL 3 兼容前缀。内置材质里的 varying、texture2D、gl_FragColor 会经过兼容处理;不要在字符串开头自行再插入 #version,也不要重复声明 renderer 已提供的内置矩阵和 attributes。
本系列使用与模板一致的 GLSL 写法。若把片段迁移到 RawShaderMaterial 或其他渲染器,必须重新审视这些前提。
6. 为什么不直接修改全局 ShaderChunk
THREE.ShaderChunk.some_chunk = ... 会修改模块注册表,可能影响所有后续编译且引用该模块的材质。已经编译的程序不一定随之重建,因此同一个应用可能出现新旧程序混合。
局部定制应改当前回调中的 shader 字符串;若要读取并修改某个 Chunk,先复制其字符串,修改副本后只展开到当前 shader。全局替换只适合明确控制整个渲染系统的特殊项目,不作为日常默认方案。
练习(不附答案)
- 对比 standard 与 physical 的 ShaderLib,找出共享源码与额外 uniform 配置。
- 找到一个内部还包含 include 的 Chunk,并说明为什么顶层 replace 可能找不到它内部的语句。
- 分别改变时间 uniform 和是否存在 map,推测是否会改变程序结构并验证。
- 在 onBeforeCompile 计数,观察连续渲染与切换材质配置时的调用情况,解释为什么不能用它作为每帧计时器。