03 · 第一次 onBeforeCompile 注入
1. 目标:动态颜色仍然接受光照
让球体在原色与珊瑚色之间缓慢变化,保留 MeshStandardMaterial 的明暗和高光。我们修改的是光照前的基础颜色,而不是最终屏幕像素。
下面是完整入口代码,配套文件的复制和运行方法见 README。createDemo() 不添加灯光,所以本例明确创建方向光和半球光。
import { createDemo } from './runtime.js';
import { replaceOnce } from './patch-utils.js';
const { THREE, scene, onFrame, dispose } = createDemo();
const state = {
uDemoTime: { value: 0 },
uDemoTint: { value: new THREE.Color(0xe47b62) }
};
const material = new THREE.MeshStandardMaterial({
color: 0x3f7997,
roughness: 0.35,
metalness: 0.15
});
material.onBeforeCompile = (shader) => {
if (shader.uniforms.uDemoTime || shader.uniforms.uDemoTint) {
throw new Error('demo-color: uniform name already exists');
}
shader.uniforms.uDemoTime = state.uDemoTime;
shader.uniforms.uDemoTint = state.uDemoTint;
shader.fragmentShader = replaceOnce(
shader.fragmentShader,
'#include <common>',
`#include <common>
uniform float uDemoTime;
uniform vec3 uDemoTint;`,
'demo-color declarations'
);
shader.fragmentShader = replaceOnce(
shader.fragmentShader,
'#include <color_fragment>',
`#include <color_fragment>
float demoWeight = 0.5 + 0.5 * sin(uDemoTime);
diffuseColor.rgb = mix(diffuseColor.rgb, uDemoTint, demoWeight);`,
'demo-color base color'
);
};
material.customProgramCacheKey = () => 'tutorial/demo-color/v1';
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1.5, 48, 32), material);
scene.add(mesh);
scene.add(new THREE.HemisphereLight(0xc5e0ff, 0x43342c, 1.5));
const light = new THREE.DirectionalLight(0xffffff, 3);
light.position.set(3, 5, 4);
scene.add(light);
onFrame((dt, elapsed) => {
state.uDemoTime.value = elapsed;
});
if (import.meta.hot) import.meta.hot.dispose(dispose);
2. 为什么选择这两个锚点
common 位于 main 之外,适合在保留公共函数后添加 uniform 声明。声明写进 main 内部会违反 uniform 的全局声明规则;只往 shader.uniforms 添加值也不会自动创建 GLSL 变量。
color_fragment 位于 main 内,并在 map_fragment 之后。保留它,再修改 diffuseColor.rgb,意味着内置贴图、顶点颜色已经计算好,随后由本例的 mix 覆盖一部分。这种选择是设计决定:权重达到 1 时,基础颜色完全由 tint 替代。
若希望保留所有贴图纹理,只做染色,可以改成 diffuseColor.rgb *= mix(vec3(1.0), uDemoTint, demoWeight);。前一种偏向“替换颜色”,后一种偏向“乘色滤镜”,结果不相同。
3. uniform 是稳定的描述对象
state.uDemoTime 是 { value: 0 },回调把它的引用交给 shader.uniforms。每帧改 .value,下一次正常渲染即可使用新值,无须重新替换源码,也无须设置 material.needsUpdate。
如果写成 shader.uniforms.uDemoTime = { value: state.time },其中 state.time 是普通数字,那么回调时只复制了当时的值,之后修改 state.time 不会自动同步。引用语义与 GLSL 上传机制要分开理解。
Three.Color 在默认颜色管理设置下可以把十六进制输入转换为工作颜色空间。把 CSS/sRGB 数字直接写成 GLSL 常量时不会自动发生这种转换,后面的纹理与颜色章节会进一步说明。
4. 为什么自定义缓存键不带时间
补丁源码对所有时间都相同,所以键固定为一个明确的代码版本字符串。时间、颜色等运行时值不应产生新的 GPU 程序。
若以后有一个 JavaScript 选项控制是否注入扫描逻辑,该选项改变了源码,则需要纳入缓存键,并在切换时通知材质重新评估程序。只写固定键而产生不同代码,会导致复用错误。
5. 如何判断示例正确
球体应缓慢变色,但高光位置和球面明暗仍由灯光决定。暂停时间时,颜色应该稳定;移动灯光时,光照应该变化。不要把“RGB 不等于 tint 的原始数值”误判为失败,因为这里仍有反射、曝光和输出转换。
若完全黑色,先检查灯光和镜头;若不变色,检查回调是否安装在实际使用的材质上、是否在首次使用后安装却没设置 needsUpdate;若报变量未声明,检查声明与使用是否注入了同一个 shader 阶段。
练习(不附答案)
- 将完整替换色改为乘色,说明两者在存在颜色贴图时的差别。
- 添加 float uniform 控制变色速度,保持缓存键不变并验证程序数量没有持续增长。
- 故意移除 GLSL uniform 声明,读取编译错误并定位原因。
- 将效果改为改变 emissive,解释光线很暗时与修改 diffuseColor 的区别。