Appearance
调光器的最小档位
背景:时间流转开启后,太阳每移动 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 });"每次时间往前走一点,就调一次 setEnvState。setEnvState 里面要走一遍完整的光照推导——天空色、方向光、阴影、雾……全套。"
"全套要多久?"
"几毫秒吧,不多,"外交官说,"但几毫秒也是时间。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 还是花了时间。找到这些计算,给它们加个阈值——几行代码,几十倍的收益。