Appearance
双影
背景:
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.ts 的 switchCameraMode 末尾什么都没有,所以 reattachPipeline() 只能在调用方补。scene-menu.ts 是唯一的调用方,所以补在这里。
后来第一审来了。审计员说:"不行。别的路径调 switchCameraMode 怎么办?管线不会重挂。"
桌面壳把 reattachPipeline() 搬进了 camera.ts:switchCameraMode 末尾。
但它忘了删 scene-menu.ts 里的那行。
"你不是不知道该删。"审计员说。
"我知道。"桌面壳说。
"那你为什么没删?"
桌面壳想了很久。
"因为我在改 camera.ts 的时候,注意力全在 camera.ts 上。动态 import 怎么写、循环依赖怎么破、FOV 怎么应用——这些问题占满了我脑子。scene-menu.ts 里的那行旧代码,我没看。它不在我的视野里。"
"你的视野只覆盖你正在改的文件?"
"不是。"桌面壳摇头,"我的视野覆盖我记得要改的地方。我不记得的地方,就等于不存在。"
"这就是迁移残留的成因。"审计员说,"新逻辑加对了,旧逻辑没删。两种正确性叠加——同一段代码被执行两次。"
二、影子的形状
桌面壳开始分析这个双影的具体形状。
同步调用在前。scene-menu.ts:368 的 reattachPipeline() 在 switchCameraMode(mode) 返回后立即执行。此时 _pipelineCamera 被设为 newCam,pipeline.cameras 数组里加入 newCam。
异步调用在后。camera.ts:183 的 import("./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,品红色边缘,深蓝背景。
桌面壳关上编辑器。
它想起第二十章的最后一句话——"绘图师要的不是绿光。绘图师要的是知道脚下是真的。"
今天它又多了一句——
绘图师还要知道:脚下是真的,但影子可能还在。删掉影子,也是绘图师的工作。
只是这项工作永远做不完。
教训:修复创造了双影,审计照见手抖。收敛但不归零,是聚合者的宿命。