Skip to content

三层闭包

背景:色调映射按钮点击不更新、云层 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 还是旧的 ACESACES 亮
3点击 Reinhard色调映射又变了,但 state 还是旧的 ACESACES 亮

所以高亮永远钉在 ACES 上。不是渲染没更新,是渲染用的状态没更新。闭包在函数调用时捕获了一个快照,之后就抱着那张快照不放。

这不是 UI 的 bug,不是渲染的 bug,甚至不是状态的 bug。

这是捕获时机的 bug。

修复是移动一行代码:

typescript
renderCustom() {
  const state = getRenderState()
  // ... 使用 state
}

const state = getRenderState()buildStageLevel 的顶部移到 renderCustom 的内部。每次渲染都实时获取状态。

预期行为:按钮高亮跟随点击移动。

同样的 bug 存在于三个地方:buildStageLevel()buildPostProcessLevel()buildLightLevel()。同一个模式,三个实例。又是一个「写三遍」的工程遗产。

第三个猎物落地。这一次,是正主。


五、汇聚

三个 bug,三种症状,三条路径,最终落在同一个下午。

Sisyphus 把修复清单列在便签上:

  1. 重复菜单:同一个功能出现在两个面板 → 从环境面板移除照明和后期
  2. 数据 URI 前缀:相同的错误出现在三个地方 → 去掉手动拼接的前缀
  3. 闭包捕获:状态值被捕获时冻结 → 移入 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。