16 · 缓存、重编译与 customProgramCacheKey

返回系列目录

GPU 程序是编译和链接后的顶点、片元着色器组合。它可以被多个材质复用,但复用程序不等于必须共享业务 uniform 状态。Three.js 为程序建立缓存,避免每个外观相似的对象都重新编译一份。

缓存键描述的是程序结构

r185 WebGLPrograms.js 组合材质 shader 标识、defines、各类功能参数和 material.customProgramCacheKey() 等信息。并非只看你写的那一段 GLSL。

Material 默认 customProgramCacheKey() 返回 this.onBeforeCompile.toString()。这可以区分一些不同的函数文本,却不能捕获相同函数文本背后不同的闭包值:

function installMode(material, mode) {
  material.onBeforeCompile = shader => {
    // 不同 material 的回调文本相同,但 mode 可能不同。
    // 如果此处根据 mode 注入不同 GLSL,仅靠默认键不可靠。
  };
  material.customProgramCacheKey = () => `demo/mode/v1/${mode}`;
}

正确规则是:可能产生不同程序代码的自定义结构必须被缓存身份区分;只改变运行时数据的值不应改变缓存身份。

可切换结构的完整模式

import { replaceOnce } from './patch-utils.js';

export function installColorMode(material) {
  let mode = 'tint';
  const uniforms = { uDemoAmount: { value: 0.4 } };

  material.onBeforeCompile = shader => {
    Object.assign(shader.uniforms, uniforms);
    shader.fragmentShader = replaceOnce(
      shader.fragmentShader,
      '#include <common>',
      '#include <common>\nuniform float uDemoAmount;',
      'mode declarations'
    );
    const operation = mode === 'tint'
      ? 'diffuseColor.rgb *= mix(vec3(1.0), vec3(1.0, 0.3, 0.1), uDemoAmount);'
      : 'diffuseColor.rgb = mix(diffuseColor.rgb, vec3(dot(diffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722))), uDemoAmount);';
    shader.fragmentShader = replaceOnce(
      shader.fragmentShader,
      '#include <color_fragment>',
      `#include <color_fragment>\n${operation}`,
      'mode color'
    );
  };
  material.customProgramCacheKey = () => `demo/color-mode/v1/${mode}`;
  material.needsUpdate = true;

  return {
    uniforms,
    setMode(next) {
      if (next !== 'tint' && next !== 'grayscale') throw new Error('Unknown mode');
      if (next === mode) return;
      mode = next;
      material.needsUpdate = true;
    }
  };
}

这是独占补丁安装器;已有其他 onBeforeCompile 时需采用第 18 章组合方案。更新 uDemoAmount.value 不重编译;调用 setMode 改变结构时,缓存键读到新的 mode,needsUpdate 通知渲染器重新评估程序。也可以将模式写成 uniform 分支来避免变体,代价是两种逻辑都进入程序,需要按实际复杂度取舍。

needsUpdate 并不是“清空缓存”

对材质设置 needsUpdate = true 会增加材质版本,下一次渲染触发必要的程序更新检查。它不意味着每次都要生成新的 GPU 程序:如果键对应的程序已有缓存,仍可复用。

反过来,只改变自定义键的闭包状态而不通知已使用材质更新,也不是可靠的切换流程。把“新结构”和“通知重新评估”作为一次原子业务操作。

变化 常规处理
时间、颜色、幅度 uniform 值 更新 .value
同类型自定义 sampler 更换纹理 更新 .value,管理纹理资源
修改纹理像素 texture.needsUpdate = true
自定义代码中的公式结构 更新自定义缓存签名,material.needsUpdate = true
修改自定义 define 更新 define,material.needsUpdate = true
内置贴图由无变有、开启新的材质功能 使用材质 API,按该功能要求更新材质;首次使用前配置最清晰

内置一些 Physical 属性 setter 会在功能启用状态跨越零时增加材质版本;不要推断所有普通属性赋值都有同样行为。源码与官方属性约定比“一律设置”或“一律不设置”更可靠。

预编译、状态共享与释放

可以在模型、灯光和环境已准备好后用 renderer.compileAsync(scene, camera) 预热当前场景;它不能保证未来所有阴影、灯光或功能变体都已编译。动画时间仍是 uniform,不应该为每个时间点预编译。

把 Date.now()、随机数或 material UUID 放入缓存键通常会破坏共享。把颜色值放进去也会为本可统一的程序生成多余版本。缓存签名建议使用稳定的效果名、源码版本、有限的结构选项。

停止使用材质时调用 material.dispose(),渲染器据此释放关联资源引用;仍被其他材质使用的程序可以继续存在。不要通过每帧 dispose/recreate 来模拟 uniform 更新。renderer.info.programs 可辅助观察程序数量,但数量还受场景灯光和内置变体影响,不是单独评判补丁正确性的指标。

源码核对

核对 Material.js 的 customProgramCacheKey()、needsUpdate setter,WebGLPrograms.js 的 getProgramCacheKey(),以及 WebGLRenderer.js 取得和保存材质程序的逻辑。关键事实是自定义代码必须主动提供可靠身份,而不是期待缓存系统分析闭包或理解 GLSL 的业务含义。

练习(暂不提供答案)

  1. 创建两个同源码、不同颜色 uniform 的材质,观察程序数量与各自颜色是否独立。
  2. 构造闭包 mode 不同但回调文本相同的情况,说明应把哪些信息加入缓存键。
  3. 将上面的两种颜色模式改成 uniform 选择,比较程序变体数和着色器结构。
  4. 设计一个质量设置接口,使拖动强度滑杆不会产生新程序,切换算法档位才会改变签名。