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 章。

练习(暂不提供答案)

  1. 把脉冲改成从原色到目标色再返回,要求频率以“每秒次数”输入,写出包含 2π 的公式。
  2. 分别在 map_fragment 和 color_fragment 后插入颜色混合,给模型添加顶点颜色,观察并解释差异。
  3. 故意替换 uniform 容器,再与修改 .value 对比,记录什么条件下可观察到问题。
  4. 为同一效果增加一个顶点阶段参数,要求顶点和片元使用同一个稳定 uniform 容器,并避免名字冲突。