13 · 自定义 Uniform 的声明、注入与更新
本章面向 Three.js r185 的 WebGLRenderer。目标是让一个参数从业务状态稳定地到达 GPU,而不是把 onBeforeCompile 当作每帧更新函数。
从 JavaScript 到片元颜色
uniform 表示一次 draw call 中各次着色器调用共享的输入。时间适合 uniform;每个顶点的权重适合 attribute;从顶点传给片元的连续量通常使用 varying。uniform 本身不会随时间变化,JavaScript 必须更新它。
数据链路是:业务状态 → { value } 容器 → shader.uniforms → WebGLRenderer 上传 → GLSL uniform 声明 → 计算结果。只增加 JavaScript 字段不会生成 GLSL 声明;只声明 GLSL 变量也不会自动绑定业务对象。
下面导出一个可以放进普通 Three.js 场景的材质工厂。replaceOnce 来自本系列公共工具,参数依次为源码、唯一锚点、替换文本、错误标签。
import * as THREE from 'three';
import { replaceOnce } from './patch-utils.js';
export function createPulseMaterial() {
// 在编译之前创建,首次渲染前也可以更新它们。
const uniforms = {
uDemoTime: { value: 0 },
uDemoPulseStrength: { value: 0.35 },
uDemoPulseColor: { value: new THREE.Color('#e8795f') }
};
const material = new THREE.MeshStandardMaterial({ roughness: 0.55 });
material.onBeforeCompile = shader => {
for (const name of Object.keys(uniforms)) {
if (Object.prototype.hasOwnProperty.call(shader.uniforms, name)) {
throw new Error(`Uniform collision: ${name}`);
}
shader.uniforms[name] = uniforms[name];
}
shader.fragmentShader = replaceOnce(
shader.fragmentShader,
'#include <common>',
`#include <common>
uniform float uDemoTime;
uniform float uDemoPulseStrength;
uniform vec3 uDemoPulseColor;`,
'pulse declarations'
);
shader.fragmentShader = replaceOnce(
shader.fragmentShader,
'#include <color_fragment>',
`#include <color_fragment>
float demoPulse = 0.5 + 0.5 * sin(uDemoTime * 2.0);
diffuseColor.rgb = mix(diffuseColor.rgb, uDemoPulseColor,
clamp(demoPulse * uDemoPulseStrength, 0.0, 1.0));`,
'pulse color'
);
};
material.customProgramCacheKey = () => 'demo/pulse/v1';
return { material, uniforms };
}
color_fragment 后的 diffuseColor 已乘过基础贴图和顶点颜色,后面还会进入标准光照计算。这里改变的是表面基础颜色,亮部、暗部和环境反射仍由内置材质处理。如果改成给最终输出直接涂色,会得到不同的明暗关系。
使用时创建一次,在既有动画循环中更新:
const pulse = createPulseMaterial();
mesh.material = pulse.material;
// 在 renderer.render(scene, camera) 之前调用。
function updatePulse(elapsedSeconds) {
pulse.uniforms.uDemoTime.value = elapsedSeconds;
}
增大 uDemoPulseStrength 会让基础色更接近目标色;改变 sin 中的频率会改变脉冲周期。仅改变强度和时间无需 material.needsUpdate。
为什么保留容器,而不是保存 shader
onBeforeCompile 在程序准备阶段调用,不保证每帧调用,也不保证整个材质生命周期只调用一次。灯光、材质功能和渲染条件的变化可能引入不同程序变体。把 shader 保存到变量后更新最新实例,容易遗漏其他变体,还需要防御“尚未编译”。
上面的每次回调都把相同 { value } 容器绑定到相应 uniforms 表中,因而变体读取的是同一业务状态。关键是共享容器引用:
// 正确:原来注入的容器仍在,改变它承载的值。
pulse.uniforms.uDemoTime.value = 3;
// 不应这样替换:已编译实例仍然引用旧容器。
// pulse.uniforms.uDemoTime = { value: 3 };
把 { value: state.time } 注入进去后再改 state.time 也不会自动同步:数字按值赋值,它们不是响应式绑定。
命名与作用域
给 uniform、varying、函数和局部变量分别使用一致前缀,例如 uDemoWaterTime、vDemoWaterHeight、demoWaterHeight()。不要覆盖 time 这种过于通用的名字,更不要重声明内置的 diffuse、normalMatrix、cameraPosition。GLSL 区分大小写,但利用大小写差别隐藏冲突会降低可维护性。
uniform 声明位于 main() 外。顶点和片元阶段都要使用同一参数时,两边都需要声明兼容的类型,JavaScript 只需注入一个同名容器。跨阶段同名 uniform 的类型必须一致,否则程序链接会失败。
GPU 编译器会移除未使用变量。如果你声明并注入了 uDemoTime 却没有让它影响可见输出,调试器中可能没有对应活跃 uniform;这不是注入失败。
源码核对与适用范围
可阅读 node_modules/three/src/renderers/WebGLRenderer.js 中准备程序与 onBeforeCompile 的调用位置,以及 webgl/WebGLUniforms.js 的 upload()。内置材质的 uniforms 上传受渲染器刷新条件控制;本章采用每材质稳定状态、每帧渲染前更新的模式。多个对象共享一个材质并在 onBeforeRender 中临时改值,需要另行设计,不能把 ShaderMaterial 的 uniformsNeedUpdate 用法照搬过来,见第 17 章。
练习(暂不提供答案)
- 把脉冲改成从原色到目标色再返回,要求频率以“每秒次数”输入,写出包含
2π的公式。 - 分别在
map_fragment和color_fragment后插入颜色混合,给模型添加顶点颜色,观察并解释差异。 - 故意替换 uniform 容器,再与修改
.value对比,记录什么条件下可观察到问题。 - 为同一效果增加一个顶点阶段参数,要求顶点和片元使用同一个稳定 uniform 容器,并避免名字冲突。