15 · 循环次数、defines 与编译期常量

返回系列目录

“GLSL 循环次数不能改变”不是准确的规则。需要分清:GLSL 版本允许什么、哪些表达式必须是编译期常量、Three.js 的源码预处理支持什么,以及目标 GPU 执行是否划算。本系列 r185 WebGLRenderer 使用 WebGL 2,不能直接照搬早期 WebGL 1 教程中的所有循环限制。

运行时参数和程序结构

时间、强度、阈值、当前有效数据数量适合 uniform。数组容量、是否编译某个昂贵模块和代码展开规模通常属于程序结构。

uniform int uDemoActiveCount;
uniform float uDemoStrength;
// 不合法的容量设计:uniform 不是常量表达式。
// uniform vec3 uDemoCenters[uDemoActiveCount];

GPU 在一次绘制之前获得 uniform,它并非“编译时就知道的数字”。JavaScript 将它设为 4,也不会使这个声明变为编译期常量。

固定容量、动态有效数量

下面示例可注入片元全局声明及 main() 中有合适空间坐标的位置。demoPosition 和 centers 必须处于同一个空间;例如两者都是物体局部坐标。

#define DEMO_MAX_SOURCES 8
uniform int uDemoActiveCount;
uniform vec3 uDemoCenters[DEMO_MAX_SOURCES];
uniform float uDemoRadii[DEMO_MAX_SOURCES];

float demoHeat(vec3 demoPosition) {
  float heat = 0.0;
  for (int i = 0; i < DEMO_MAX_SOURCES; i++) {
    if (i >= uDemoActiveCount) break;
    float radius = max(uDemoRadii[i], 0.0001);
    float distanceToSource = length(demoPosition - uDemoCenters[i]);
    heat += 1.0 - smoothstep(0.0, radius, distanceToSource);
  }
  return clamp(heat, 0.0, 1.0);
}

JavaScript 将 activeCount 限制在 0~8,并提供完整容量的数组。数量变化不需要重编译。WebGL 2 也支持符合语言规则的动态循环边界,例如 i < uDemoActiveCount;固定上限的价值是明确最坏工作量与数据容量,并便于性能降级,而不是宣称动态边界一律非法。

此处 break 的条件来自 uniform,同一次 draw call 的片元具有相同数量,通常不像逐片元不同条件那样造成同批执行分歧。但循环内部纹理采样、实际像素数量和设备编译器仍会影响性能,不能只看源代码中的分支数量。

sampler 数组的动态索引有额外语言限制;“普通 float/vec 数组可动态索引”不意味着所有 opaque 类型都同样自由。多纹理选择可用固定分支、纹理数组等方案,需结合 WebGL 2 的具体类型与采样规则设计。

使用 material.defines 生成结构变体

material.defines = {
  ...material.defines,
  DEMO_MAX_SOURCES: 8
};

如果采用这个方案,删除前一示例的手写 #define,让声明只由一个来源负责。保留已有 defines,尤其 Physical 材质的内置标记,不要为了自己的一个宏覆盖整个对象。

首次渲染前设置即可。已使用的材质需要改容量时:

material.defines.DEMO_MAX_SOURCES = 16;
// 同时准备容量匹配的 uniform 数据,再触发程序选择。
material.needsUpdate = true;

defines 参与 Three.js 程序缓存键。容量变化可能生成新程序,因此不要把随帧变化的数值拼进 define。常用的质量档位可以是 4、8、16,而不是用户拖动滑杆时生成数百个容量变体。

Three.js 的循环展开不是 GLSL 编译器循环

r185 WebGLProgram.js 的 unrollLoopPattern 是 JavaScript 正则预处理。它识别形如以下结构的文本:

float demoSum = 0.0;
#pragma unroll_loop_start
for (int i = 0; i < 4; i++) {
  demoSum += uDemoWeights[i];
}
#pragma unroll_loop_end

其中索引名是 i,上下界必须匹配该版本所支持的数字文本形式。普通自定义宏由 GPU 预处理器处理,不会自动在 Three.js 的正则展开之前变成数字。内置灯光计数有专门的前置替换步骤,不能据此推断任意宏都可展开。

展开器会替换数组索引中的 [i],并支持 UNROLLED_LOOP_INDEX 占位符。它不是完整 GLSL 解析器:复杂嵌套括号、依赖真实循环作用域的代码需特别谨慎。展开后已经没有对应循环,不能保留仅对循环合法的 break 或 continue。本章的动态有效数量循环不要直接加 pragma。

通常让驱动编译器自行优化循环即可。显式展开会增加源码和程序体积,可能提高寄存器压力;是否更快需要测量,尤其是移动设备。

类型变化与无效更新

uDemoActiveCount.value = 3 更新整数值,不改变 uniform int 声明。把它改成 Vector3 是错误的数据契约,不会转换变量类型。若确实要由标量升级为数组,应升级补丁源码和缓存版本,并更新数据结构,而不是试图用 needsUpdate 修复类型不一致。

练习(暂不提供答案)

  1. 实现 4、8、16 三个容量档位及运行时 count,说明切换档位与增减有效源各自的更新步骤。
  2. 比较固定上限加 break 与动态边界循环,在目标设备上记录相同画面下的性能。
  3. 将一个简单权重求和循环展开,查看最终源码;再解释为何不能在展开内容中保留 break。
  4. 设计一个输入验证函数,拒绝负数、非整数及超出数组容量的 activeCount。