Skip to content

调光器的最小档位

背景:时间流转开启后,太阳每移动 0.01° 都触发一次完整光照重算(天空色/方向光/阴影/雾),每秒 60 次,大部分算出来和上一次几乎一样。 过程:加入 0.5° 阈值——变化小于阈值则跳过重算,累积到阈值再触发。省了 99% 的无用计算,用户察觉不到任何区别。


环境之城有一台巨大的调光器。

太阳每移动一点点——哪怕只是 0.01°——调光器就"咔哒"一声,重新计算一遍光照参数:天空色、方向光颜色、方向光强度、雾浓度、阴影参数……

一套算下来,十几项参数,几十个赋值。

太阳每秒移动 0.1°。 每秒触发一次光照重算。 每分钟六十次。 每小时三千六百次。

"有必要吗?"外交官问。


人眼的阈值

"你觉得人眼能分辨多大的亮度变化?"外交官问。

"不知道……1%?"AI 同行者猜。

"差不多,"外交官点头,"韦伯-费希纳定律——人对亮度的感知是对数的。大概 1% 到 2% 的变化,人才能察觉到。"

"那太阳角变化 0.1°,亮度变化多少?"

"远小于 1%,"外交官说,"日出日落的时候变化快一点,但正午的时候,太阳在头顶上,移动 0.5°,天空色几乎看不出区别。"

"那每帧都重算……就是白算?"

"大部分是白算,"外交官确认,"算出来的结果和上一次几乎一样,用户也察觉不到区别。但 CPU 花了时间。"

他翻出 scene-env-bridge.ts 里的 tickTimeOfDay 函数:

typescript
// 旧代码:每次时间变化都调用
setEnvState({ sunAngle: newAngle });

"每次时间往前走一点,就调一次 setEnvStatesetEnvState 里面要走一遍完整的光照推导——天空色、方向光、阴影、雾……全套。"

"全套要多久?"

"几毫秒吧,不多,"外交官说,"但几毫秒也是时间。60 帧的话,每帧 16ms 预算。花 2ms 在光照重算上,就是八分之一的预算没了。"

"而且大多数时候,算出来和上次差不多。"

"对,"外交官说,"所以我们加一个档位——变化够大了才算,不够大就不算。"


0.5° 是怎么来的

"加阈值我懂,"AI 说,"但为什么是 0.5°?不是 1°,不是 0.1°?"

"好问题,"外交官说,"0.5° 是拍脑袋拍的,但拍得有道理。"

他在纸上画了个太阳轨迹:

      ↗ 日出(0°)
     /
    *  正午(90°)
     \
      ↘ 日落(180°)

"我们来算一笔账:

  • 时间流转默认速度:1 分钟游戏时间 / 1 秒现实时间
  • 1 分钟 = 太阳移动 0.25°(24 小时 = 180°,1 分钟 = 180/(24×60) = 0.125°?不对,让我重新算……")

外交官停下来,拿笔算了一会儿:

"24 小时太阳走一圈 360°。1 小时 15°。1 分钟 0.25°。对,1 分钟游戏时间 = 太阳移动 0.25°。

如果阈值设 0.5°,那就是每 2 分钟游戏时间重算一次光照。现实中就是每 2 秒重算一次。"

"2 秒一次,用户能察觉到吗?"

"察觉不到,"外交官摇头,"0.5° 的太阳角变化,对应的天空色变化微乎其微。尤其是正午前后,太阳在头顶,天空色几乎不变。日出日落的时候变化快一点,但 0.5° 也很小。"

"那为什么不设 1°?更省。"

"太省了也不行,"外交官说,"设太大的话,用户会感觉到'跳变'——天空色突然变了一下,而不是连续变化。0.5° 是一个平衡点——既足够频繁,用户感觉不到跳变;又足够稀疏,省下大把 CPU 时间。"

"经验值?"

"经验值,"外交官笑了,"但可以调。以后如果有人觉得'天空变化有点突兀',就调到 0.3°。如果觉得'还是太费 CPU',就调到 0.7°。0.5° 是起点,不是终点。"


实现:三行代码

改了多少代码?

三行。

typescript
if (Math.abs(newAngle - lastAppliedAngle) < 0.5) {
    return; // 变化太小,跳过重算
}
lastAppliedAngle = newAngle;

"就这么简单?"AI 问。

"就这么简单,"外交官说,"但是——"

"但是什么?"

"但是有一个坑,"外交官表情严肃起来,"你注意到没有,我们比较的是 newAngle - lastAppliedAngle,不是 newAngle - prevAngle。"

"有什么区别?"

"区别大了,"外交官说,"如果每次变化 0.3°,你跟上次比——0.3° 小于 0.5°,跳过。再下次又变化 0.3°,跟上次比还是 0.3°,又跳过。再下次……永远跳过?"

"啊!"AI 反应过来,"每次都小于阈值,但累积起来可能很大!"

"对,"外交官点头,"所以比较的基准不是'上一帧的角度',而是'上一次重算时的角度'。每次重算之后,把 lastAppliedAngle 更新成当前角度。这样累积的变化总会超过阈值的——0.3° + 0.3° = 0.6° > 0.5°,第二次就触发了。"

"累积到阈值就触发。"

"对,累积触发,"外交官说,"这是阈值优化的标准模式。跟'上次应用的值'比,不是跟'上一帧的值'比。不然变化慢的时候,永远到不了阈值。"


为什么不用 lerp

"等等,"AI 又想到一个方案,"既然变化太小,为什么不用插值?比如每帧都 lerp 一点,慢慢过渡到目标值。这样既连续,又不用每帧重算。"

"好想法,"外交官说,"但有两个问题。"

他伸出两根手指:

第一:光照参数是推导出来的,不是线性的。 "天空色、方向光颜色、阴影强度——这些参数和太阳角不是线性关系。日出的时候变化快,正午的时候变化慢。用线性插值的话,会出现'中间色不对'的问题。"

"那……用非线性插值?"

"可以,但更复杂。你得存两个状态(上次的和下次的),然后在中间插值。而且你怎么知道'下次'是什么时候?阈值设 0.5°,那每次到 0.5° 才算一次新的目标值,然后从旧值插值到新值?"

"听起来有点绕。"

"是绕,"外交官笑了,"为了这点性能,引入这么复杂的机制,不值当。0.5° 已经足够小了,用户察觉不到跳变。直接跳就行,不用插值。"

第二:跳变用户真的察觉不到。 "0.5° 的太阳角变化,在正午时分,天空色的变化大概是百分之零点几。人眼根本分辨不出来。既然分辨不出来,跳变就是个伪问题。"

"那为什么还要设 0.5°?直接设 5° 不行吗?"

"5° 就能看出来了,"外交官摇头,"尤其是日出日落的时候,5° 的变化很明显。0.5° 是经过测试的——刚好在感知阈值以下。"


看不见的节省

改完之后,外交官测了一下。

"省了多少?"AI 问。

"时间流转开启的时候,光照重算从每帧一次,变成每 2 秒一次,"外交官说,"省了大概 99% 的光照推导计算。"

"99%?这么多?"

"对,因为 60 帧 × 2 秒 = 120 帧才算一次。1/120 ≈ 0.8%,所以省了 99%。"

"但用户看不出区别?"

"看不出,"外交官确认,"变化太小了。0.5° 的太阳角变化,相当于把时间往前拨了 2 分钟——你盯着天空看 2 分钟,能看出天空色变了吗?"

"……好像不能。"

"不能吧,"外交官笑了,"所以这笔账很划算——用户什么都没失去,CPU 省下了一大笔。"


阈值的艺术

第五颗黄石子落进"已处理"的堆里。

"这一章的优化,代码最少,"AI 说,"三行代码。"

"代码少,不代表思考少,"外交官说,"设阈值是一种艺术——

  • 设大了,用户察觉得到跳变,体验下降。
  • 设小了,省的 CPU 不够多,优化没意义。
  • 还要注意比较基准——是跟'上次应用的值'比,不是跟'上一帧的值'比。不然累积不触发。"

他拿起那颗黄石子:

"性能优化很多时候不是什么惊天动地的大重构。就是这些小小的阈值判断——三行代码,一个变量,省了 99% 的无用计算。"

窗外的天空慢慢暗下来——时间流转在运行。 但没有人知道,调光器已经悄悄地从"每秒咔哒一次"变成了"每两秒咔哒一次"。

天空还是那个天空。 只是 CPU,偷偷喘了口气。


附录:阈值优化原则

原则说明
感知阈值以人的感知极限为上限。用户察觉不到的变化,就不用每帧算
累积触发跟"上次应用的值"比,不是跟"上一帧的值"比。否则慢变化永远不触发
留有余地阈值设在感知阈值的 1/2 到 1/3。留安全余量,保证没人能察觉
可配置硬编码的阈值要写注释说明理由,方便以后调整
验证改完要对比——改之前什么样,改之后什么样,确认真的看不出区别

教训:不是所有计算都是有意义的。有的计算,算出来的结果和上次几乎一样,用户也察觉不到区别,但 CPU 还是花了时间。找到这些计算,给它们加个阈值——几行代码,几十倍的收益。