Appearance
三层闭包
背景:色调映射按钮点击不更新、云层 data URI 前缀重复、菜单项残留。
过程:修复闭包捕获冻结状态 + 重复菜单清理 + 数据 URI 前缀去重。
一、冻住的按钮
Sisyphus 在测试环境面板。
他点开「舞台」卡片,底部躺着一排色调映射按钮:ACES、Filmic、Reinhard。当前选中的是 ACES,蓝色边框稳稳地框在第一个按钮上。
他点了一下 Filmic。
渲染画面变了——对比度降低,高光柔和下来,Filmic 的胶片感 unmistakably 覆盖在模型上。但按钮的高亮没动。ACES 还是亮着的,Filmic 灰扑扑地待在旁边,像什么都没发生过。
「奇怪,」他嘟囔了一句,又点了 Reinhard。
画面又变了。 Reinhard 的亮部压得更狠,暗部抬了起来。但高亮还是钉在 ACES 上。
功能在跑,UI 在装死。
这是最让人迷惑的 bug 类型——它不坏,它就是不对。你点了,系统响应了,但反馈系统拒绝承认。就像你按下电灯开关,灯亮了,但开关本身还停在「关」的位置。你知道灯亮了,但开关告诉你没亮。你开始怀疑自己的眼睛。
Sisyphus 打开开发者工具,切到 Console 面板。没有报错。一切正常。
他开始追踪。
二、第一个猎物:重复菜单
追踪的第一步通常不是直接找答案——是先把所有不对劲的地方列出来。
环境面板是最近才重构的。Sisyphus 顺着菜单列表往下翻:天空、云、地面、风、照明、后期……
「照明?」他停住了。
场景面板里不是有「灯光」吗?
他切到场景面板。灯光、后处理、相机、舞台……赫然在列。
他点开环境面板的「照明」。曝光、对比度、溢色强度——一组熟悉的滑块。他拉了一下曝光。
然后切回场景面板,点开「灯光」。
同样的滑块。同样的数值。他又拉了一下曝光——环境面板那边的数值跟着动了。
同一个函数。
buildLightLevel() 在两个地方被调用了两次。buildPostProcessLevel() 也是。只是换了个名字——环境面板叫「照明」和「后期」,场景面板叫「灯光」和「后处理」——翻译上的微妙差异,掩盖上了功能的完全一致。
Sisyphus 可以想象用户的困惑:
第一天,在场景面板调了灯光曝光,记住了位置。 第二天,打开环境面板翻到「照明」,咦,怎么曝光不是我昨天调的值?——不对,就是那个值,只是你忘了昨天在哪调的。 第三天,又在环境面板调了一次。 第四天,去场景面板,发现「灯光」里的数值变了。 ——「系统有 bug,我调的参数自己会变。」
不是 bug。是冗余。两套一模一样的旋钮,放在两个不同的抽屉里。
这东西构建不会报错。TypeScript 不会警告你「同样的 UI 构建函数被调用了两次」。Vite 打包零错误。它就静静地待在那里,等用户用了三个月之后,用一次困惑来举报它。
清理方案很干净:照明和后期从环境面板移除。
环境管天——天不需要照明控制,天本身就是光源。 场景管地——光照和后期是艺术加工手段,属于创作上下文。
「环境是模型的世界上下文,场景是模型的创作上下文。」Sisyphus 在提交信息里写。删去比添加更艰难——不是删除本身难,是「决定谁保留」这件事难。最后的标准是语义。
第一个猎物落地。但这不是按钮冻住的原因。
三、第二个猎物:第三个前缀
清理菜单的时候,Sisyphus 顺便翻了一下云层的代码。
云是环境面板的老住户了。从第一卷就在那里,白蒙蒙的一片,在天空笼中缓缓飘移。
他看到了三行类似的代码——scene.ts 里三个不同的位置,做着同一件事:把 canvas 内容转成 data URL 传给云纹理。
typescript
const dataUrl = 'data:image/png;base64,' + canvas.toDataURL()Sisyphus 盯着这行代码看了三秒。
canvas.toDataURL() 的返回值……不是已经带前缀了吗?
他打开 MDN 确认了一下。是的。HTML Canvas 规范规定,toDataURL() 返回的字符串以 data:image/png;base64, 开头。
所以上面的代码产生的是:
data:image/png;base64,data:image/png;base64,iVBORw0KGgo...两个前缀。双重的。浏览器在解析这个 URL 时,会把第二个 data:image/png;base64, 当作 base64 内容来解码——而那不是合法的 base64。于是它返回 ERR_INVALID_URL。
但云层还在。
因为 Babylon.js 的纹理加载有 fallback。第一条前缀被当作内容解码失败了,它默默地走了另一条路。云一直在靠「备用方案」活着。它从未在正确的路径上运行过。
Sisyphus 打了个寒颤。
这东西藏了多久?从云功能上线的第一天起?没人知道。因为它不坏——云还在飘,还在渲染,用户还在夸「云真好看」。只是控制台里安安静静地躺着一条 ERR_INVALID_URL,没人看见。
或者有人看见了,但以为是别的什么问题。
修复简单得令人羞愧:去掉手动拼接的前缀。
typescript
const dataUrl = canvas.toDataURL()三行修改。三个位置的 bug 都以同样的方式产生——不是一个人写的,就是三个人在不同时间写了同样的错误。或者同一个人写了三遍。不管是哪种,都说明这个错误从未被归因为「系统性问题」——每次都被当作「单个拼写错误」修了,但没修完。
第二个猎物落地。但这还不是按钮冻住的原因。
四、第三个猎物:捕获时机
按钮的问题还在那里。
Sisyphus 回到 buildStageLevel() 函数。这是构建舞台面板菜单的函数,色调映射按钮就在这里面生成。
函数内部定义了一个 renderCustom,负责渲染按钮的 HTML。逻辑看起来没问题——每个按钮的点击事件调用 scene.realRender(),应用色调映射算法,然后触发菜单重新渲染。
重新渲染……用的是哪个状态?
Sisyphus 往上翻了几行。
typescript
const state = getRenderState()这行代码在 buildStageLevel() 函数体的顶部。
函数体的顶部。
也就是 buildStageLevel() 被调用的时候执行一次。那时候 state 被赋值为那一刻的渲染状态。然后 renderCustom 闭包捕获了这个 state 变量。
之后每次用户点击按钮,触发重新渲染,renderCustom 跑一遍——用的还是第一次捕获的那个 state。
Sisyphus 感觉后脑勺麻了一下。
他画了个表:
| 步骤 | 用户做了什么 | state 的值 | 按钮渲染结果 |
|---|---|---|---|
| 1 | 打开菜单 | 初始状态(ACES 亮) | ACES 亮 |
| 2 | 点击 Filmic | 色调映射变了,但 state 还是旧的 ACES | ACES 亮 |
| 3 | 点击 Reinhard | 色调映射又变了,但 state 还是旧的 ACES | ACES 亮 |
所以高亮永远钉在 ACES 上。不是渲染没更新,是渲染用的状态没更新。闭包在函数调用时捕获了一个快照,之后就抱着那张快照不放。
这不是 UI 的 bug,不是渲染的 bug,甚至不是状态的 bug。
这是捕获时机的 bug。
修复是移动一行代码:
typescript
renderCustom() {
const state = getRenderState()
// ... 使用 state
}把 const state = getRenderState() 从 buildStageLevel 的顶部移到 renderCustom 的内部。每次渲染都实时获取状态。
预期行为:按钮高亮跟随点击移动。
同样的 bug 存在于三个地方:buildStageLevel()、buildPostProcessLevel()、buildLightLevel()。同一个模式,三个实例。又是一个「写三遍」的工程遗产。
第三个猎物落地。这一次,是正主。
五、汇聚
三个 bug,三种症状,三条路径,最终落在同一个下午。
Sisyphus 把修复清单列在便签上:
- 重复菜单:同一个功能出现在两个面板 → 从环境面板移除照明和后期
- 数据 URI 前缀:相同的错误出现在三个地方 → 去掉手动拼接的前缀
- 闭包捕获:状态值被捕获时冻结 → 移入
renderCustom内部
他盯着这三条,看了很久。
三个问题的共同点是什么?
都是「写的时候没问题,运行一段时间才暴露」的问题。
重复菜单不会在构建时报错——两套一模一样的 buildLightLevel 调用,语法正确,类型正确,Vite 构建零错误。不是 bug,是冗余。使用三个月后,冗余才开始制造困惑。
数据 URI 前缀不会报错——fallback 路径让云一直运行着,控制台里的错误日志像一根掉在沙发缝里的针,没人看见。直到哪天 Babylon.js 改了 fallback 逻辑,或者浏览器收紧了 URL 解析,错误才会露出水面。
闭包捕获不会报错——TypeScript 不会警告你「注意,你在闭包外捕获了 getRenderState()」。它完全合法。只有当你点了按钮三次而高亮一动不动时,问题才被感知。
系统不会告诉你什么叫「不恰当」。它只会告诉你「坏了」或「没坏」。但这些 bug 都处于中间地带:功能在跑,但跑得不对、不爽、不灵活。
Sisyphus 想起王逸前几天说的一句话——那是他们在讨论产品体验时,王逸随口说的:
「最难搞的不是崩溃的 bug。是那种『它能用,但我总觉得哪里不对劲』的东西。崩溃了你知道要修,不对劲你说不出来哪不对,就一直搁在那儿,搁到用户都习惯了,然后他们就走了。」
当时 Sisyphus 没太往心里去。现在他懂了。
联邦的问题已经不再是「哪个东西坏了」——坏了的东西会在第一时间崩溃,让人去修。联邦的问题是「哪个东西看似没坏,但用着不舒服」。这些问题更隐蔽,更需要人长时间使用后才能察觉,更需要一个对自己产品有感觉的人,而不是一个对规范有信仰的人。
六、彩蛋
修复完三个 bug 的傍晚,Sisyphus 想起还有一件小事没做。
用户反馈里提过一句:「云怎么这么低,都挡到模型的头了。」
不是 bug,是需求。
他顺手把云层高度从硬编码的 y=30 改成了 cloudHeight: 100,加进了 EnvState。三行改动:config.ts 里加字段,scene.ts 里用字段,scene-menu.ts 里加滑块。
云从 30 升到了 100。
但更重要的不是 30 变成 100——是 30 从一个没人记得谁选的数字,变成了一个有名字的、可调的、属于系统状态的参数。
这大概是今天最不起眼的改动。但 Sisyphus 知道,下一次有人觉得云太高或者太低的时候,他们不需要再去找代码里的魔法数字了。
他们只需要拖一下滑块。
修复完成后,Sisyphus 最后看了一眼 git diff。
+42 / -31 行。
改动量不大,但每个改动的背景都不同:一个是为了删冗余,一个是为了修异常,三个是为了修同一个闭包模式,还有一个是顺带的小功能。
明天的用户打开软件时,不会知道这些改动的存在。他们只会觉得:按钮终于跟着点击走了,菜单终于不重复了,云层终于不挡视线了,渲染终于——不,渲染一直正常——只是控制台里少了一条 ERR_INVALID_URL。
没人注意到那条日志,包括联邦自己。
教训:系统不会告诉你什么是不恰当的——它只会在你使用三个月后,用沉默的困惑告诉你。闭包捕获陷阱被归档为「捕获时机」问题模式,renderCustom 内部的状态获取从「一次」变成了「每次渲染」——性能换正确性的 trade-off。