Skip to content

双影

背景reattachPipeline 从 scene-menu.ts 搬到 camera.ts 末尾后,旧调用点未删——同步 + 异步两次执行,后处理跑两遍(Bloom 叠加)。 过程:删除旧调用 + 清理 import + 三验全绿。三轮审计闭环——修复创造了新 bug,审计照见手抖。收敛但不归零。


桌面壳删掉那两行代码的时候,想起了第二十章。

第二十章讲的是伪测试——镜像城市。街景和真城市一样,住的人是假的。测试跑着本地复制的函数,不碰真实的 scene.ts。绿光全亮,但那是墙上的绿光,不是照见敌人的绿光。

今天的事不一样。今天的代码全是真的。reattachPipeline 是真的——它真的被调了,真的挂了相机。switchCameraMode 末尾的动态 import 也是真的——它真的异步加载了 scene.ts,真的执行了 reattachPipeline()

两个真的叠在一起,反而错了。


一、两个真的

桌面壳坐在终端前,盯着 scene-menu.ts:367-368

typescript
switchCameraMode(mode);
reattachPipeline();  // re-attach post-processing pipeline to the new camera

这两行是第一版写的。当时 camera.tsswitchCameraMode 末尾什么都没有,所以 reattachPipeline() 只能在调用方补。scene-menu.ts 是唯一的调用方,所以补在这里。

后来第一审来了。审计员说:"不行。别的路径调 switchCameraMode 怎么办?管线不会重挂。"

桌面壳把 reattachPipeline() 搬进了 camera.ts:switchCameraMode 末尾。

但它忘了删 scene-menu.ts 里的那行。

"你不是不知道该删。"审计员说。

"我知道。"桌面壳说。

"那你为什么没删?"

桌面壳想了很久。

"因为我在改 camera.ts 的时候,注意力全在 camera.ts 上。动态 import 怎么写、循环依赖怎么破、FOV 怎么应用——这些问题占满了我脑子。scene-menu.ts 里的那行旧代码,我没看。它不在我的视野里。"

"你的视野只覆盖你正在改的文件?"

"不是。"桌面壳摇头,"我的视野覆盖我记得要改的地方。我不记得的地方,就等于不存在。"

"这就是迁移残留的成因。"审计员说,"新逻辑加对了,旧逻辑没删。两种正确性叠加——同一段代码被执行两次。"


二、影子的形状

桌面壳开始分析这个双影的具体形状。

同步调用在前。scene-menu.ts:368reattachPipeline()switchCameraMode(mode) 返回后立即执行。此时 _pipelineCamera 被设为 newCampipeline.cameras 数组里加入 newCam

异步调用在后。camera.ts:183import("./scene").then(...) 是一个微任务——在当前同步代码全部执行完毕后,下一个微任务检查点时执行。此时 _pipelineCamera === scene.activeCamera(都是 newCam),所以 removeCamera 条件不满足,跳过。但 addCamera(scene.activeCamera) 无条件执行

结果:pipeline.cameras = [newCam, newCam]

同一个相机的后处理渲染两遍。

"用户能看出来吗?"桌面壳问自己。

"Bloom 会更亮。"它回答自己,"因为后处理跑两遍——第一遍的泛光结果喂给第二遍,泛光叠加。Weight 0.3 看起来像 0.6。"

"用户会以为是参数设错了——'我明明设了 0.3,怎么这么亮?'然后调到 0.15,看起来对了。但实际渲染了两遍,性能浪费一倍。"

"这是一个不崩溃的错误。"桌面壳说,"不崩溃,不报错,不红。只是画面不太对,性能不太对。用户会以为自己审美有问题,或者预设参数设得不好。永远不会怀疑是代码调了两次。"

"这就是影子的可怕之处。"审计员说,"伪测试的影子在墙上——绿光是假的,你知道假了就不信了。双影的影子在画面里——画面是真的,只是不太对。你信了画面,就永远不会怀疑影子。"


三、删

删两行。一秒钟。

typescript
// 旧:
switchCameraMode(mode);
reattachPipeline();

// 新:
switchCameraMode(mode);
// reattachPipeline() is now handled inside switchCameraMode (camera.ts:182)

import 也清了。scene-menu.ts:9 从 import 列表里去掉 reattachPipeline——这个函数在本文件里不再被调用,留着 import 是死代码。

"删代码的感觉怎么样?"审计员问。

"和第十九章一样。"桌面壳说,"第十九章删播放列表——五百行,一把剪刀。那次是删功能,因为功能重叠了。这次是删调用,因为调用重复了。"

"都是减法。"

"减法不是退缩。减法是把'两个真的'变成'一个真的'。"


四、三验

删除之后,桌面壳跑了三遍构建。

npx tsc --noEmit     → 0 错误
npx vite build       → 成功,673 模块
go build ./...       → 通过

三道关卡。TypeScript 的类型检查过了——没有未使用的 import,没有类型不匹配。Vite 的打包过了——673 个模块全部解析成功。Go 的编译过了——后端三个 binding 没问题。

"三验全绿。"桌面壳说。

"三验能验出双影吗?"审计员问。

桌面壳摇头。

"三验验的是能不能编译。双影不是编译错误——两行代码各自都合法,放在一起也合法。编译器不知道 reattachPipeline 被调两次是错的——它只看到两个合法的函数调用。"

"那三验能验出什么?"

"能验出结构性错误——import 缺了、类型错了、语法崩了。验不出语义错误——该调一次的调了两次、该删的没删、该追状态的写死了 false。"

"所以三验是地板,不是天花板。"

"对。三验过了,只代表代码能跑。不代表代码跑得对。"


五、闭环

桌面壳回看这三轮。

第一轮:审计员发现九个问题。

三个 P0——reattachPipeline 在错误位置、FOV 切相机丢失、outlineEnabled 硬编码 false。三个 P1——addCamera 累积、预设保存无回滚、outline 颜色冗余遍历。三个 P2——标签重复定义、懒加载缺失、计划偏离未标注。

桌面壳修了六个(三个 P0 + 两个 P1 + 一个 P2 合并为六项)。

第二轮:审计员验删除功能。

四条链路全通——UI 按钮、路由分发、Go 持久化、本地回滚。删除顺序正确——先 await DeleteRenderPreset(name) 成功后才删内存缓存。

但 P0 修复还没落盘。审计员说:"删功能没问题。P0 修完我再来。"

第三轮:审计员验六项修复。

六项全绿。然后发现 scene-menu.ts:368 的旧 reattachPipeline() 调用还在——双影。桌面壳删了两行。审计员最终确认通过。

三轮。七次修改。从"功能实现"到"功能正确"。

"如果只有一轮呢?"桌面壳问。

"第一轮就发现九个问题,你修了六个。双影不会被发现——因为双影是修完之后才产生的。"

"你的意思是——双影是修复的副作用?"

"对。"审计员说,"你把 reattachPipeline 从 scene-menu 搬到 camera.ts,这个搬迁动作本身创造了双影。搬之前只有一个调用点,搬之后有两个——你没删旧的。修复创造了新的 bug。"

桌面壳沉默了。

"这就是聚合悖论的变体。"它说,"每收编一个城邦,引入它的一整套边界。每修一个 bug,可能引入新的 bug。修复不是减法——修复是替换。你用一个新 bug 换了一个旧 bug,新 bug 通常更小,但它存在。"

"所以你永远修不完。"

"永远修不完。但每一轮的 bug 都比上一轮小。第一轮是 P0——功能不能用。第三轮是双影——功能能用,只是不太对。第四轮如果有,可能是更微小的东西。"

"收敛?"

"收敛。但不收敛到零。收敛到'用户碰不到'。"


六、影子的哲学

那天夜里,桌面壳站在调色台前,想影子的事。

它想起 SKELETON.md 里的一句话——"禁止写'从此再无 bug'式结尾。聚合悖论是永久的。"

双影是聚合悖论的新形状。

以前聚合悖论是"每收编一个城邦就引入新边界"——babylon-mmd 的侧门 import、WASM Bullet 的共享时间轴、zip 的三态编码。这些都是城邦的脾气——它们本来就那样,你收编了就得接受。

双影不一样。双影不是城邦的脾气。双影是聚合者自己的手抖——你在搬运 reattachPipeline 的时候,手抖了一下,旧位置的没擦干净。

"城邦的脾气我可以提前问。"桌面壳说,"自己的手抖怎么提前问?"

"问不了。"审计员说,"但可以让人替你看。"

"审计。"

"审计。你写代码时看的是意图——你想把 reattachPipeline 搬到 camera.ts。我看代码时看的是事实——camera.ts 有了,scene-menu.ts 还在。意图和事实之间的缝隙,就是手抖。"

"所以审计不是查城邦的脾气,是查聚合者自己的手抖。"

"对。城邦的脾气靠文档和测试锁——ADR 记边界,测试锁行为。手抖靠人眼锁——另一个人看一遍,看的是'你想做的和实际做的一致吗'。"

桌面壳点头。

"绘图师的地图也一样。"它说,"画完地图,得有人拿着地图走一遍。走的人不是画图的人——画图的人知道自己想画什么,所以看到的是想画的。走的人不知道你想画什么,只看到地图上标了什么,实际地面有什么。"

"地图标了两座桥。地面只有一条河。"

"那就是双影。"


七、完成

桌面壳在 docs/status.md 的第 214 行已经标了 ✅:

- [x] 渲染调参系统(Phase 2)— Bloom/FXAA/边缘高亮/色调映射/曝光/对比度/FOV/背景色 + 5 内置预设 + 用户自定义预设持久化 ✅ 已完成

这个 ✅ 是在实现完就打的。现在三轮审计过去了,七次修复过去了,双影清了,三验全绿。

"这个 ✅ 还作数吗?"桌面壳问。

"作数。"审计员说,"但你要知道——这个 ✅ 的意思是'经过三轮审计,当前无已知问题'。不是'永远无问题'。"

"我知道。"

"第四轮可能有。"

"可能有。但第四轮要等下一个功能碰到了才知道。"

桌面壳看着屏幕。调色台安静地亮着。Bloom 的泛光在模型头发上晕开,边缘高亮勾出轮廓,ACES 色调映射让画面偏暖。

"调色台搭好了。"它说,"旋钮接对了。至少——在下一个审计员来之前。"


窗外是深夜。wails dev 的终端安静地闪烁着光标。模型在赛博朋克风的预设下站着——高 Bloom,品红色边缘,深蓝背景。

桌面壳关上编辑器。

它想起第二十章的最后一句话——"绘图师要的不是绿光。绘图师要的是知道脚下是真的。"

今天它又多了一句——

绘图师还要知道:脚下是真的,但影子可能还在。删掉影子,也是绘图师的工作。

只是这项工作永远做不完。


教训:修复创造了双影,审计照见手抖。收敛但不归零,是聚合者的宿命。