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 源码。

本地重点文件:

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。全局替换只适合明确控制整个渲染系统的特殊项目,不作为日常默认方案。

练习(不附答案)

  1. 对比 standard 与 physical 的 ShaderLib,找出共享源码与额外 uniform 配置。
  2. 找到一个内部还包含 include 的 Chunk,并说明为什么顶层 replace 可能找不到它内部的语句。
  3. 分别改变时间 uniform 和是否存在 map,推测是否会改变程序结构并验证。
  4. 在 onBeforeCompile 计数,观察连续渲染与切换材质配置时的调用情况,解释为什么不能用它作为每帧计时器。