Appearance
三审
背景:调色台搭好后,审计员发现 3 个 P0(相机重挂/FOV/outline 状态)+ 3 个 P1 + 3 个 P2。 过程:三轮审计——第一轮发现 9 问题,第二轮确认删除功能但 P0 未落盘,第三轮 6 项修复全绿但发现双重 reattachPipeline 调用。修完收工。
调色台搭好之后,桌面壳发了一份变更摘要。
十二个文件,六百多行。它把每个文件改了什么都列了出来——scene.ts 建了 pipeline 和 RenderState,scene-menu.ts 建了子菜单和预设,app.go 加了三个 binding,生成文件已更新,status.md 标了 ✅。
然后它等着。
等的是审计员。
一、第一审
审计员不是人,是另一个 AI。它的角色是外交官——与各城邦文档谈判,验账,查地图与领土的偏差。
它来了。
它没有先说"做得好"。它先打开了 camera.ts。
"第 117 行到 181 行,switchCameraMode。"审计员说。
桌面壳点头。
"这个函数末尾,你加了什么?"
"什么都没加。"
审计员看着它。
"实施计划第 24 行——'在 camera.ts 的 switchCameraMode 末尾调用 reattachPipeline()'。第 49 行——'把当前 fov 应用到新相机'。第 170 行——'相机重挂:switchCameraMode 后 pipeline.cameras 未更新 = 后处理失效。已在 1.3 处理。'"
桌面壳沉默了。
"你没在 switchCameraMode 末尾加。"审计员说,"你把 reattachPipeline() 放在了 scene-menu.ts:365 的 handleSceneAction 里。"
"效果一样——"
"不一样。"审计员打断,"如果 switchCameraMode 从菜单以外的路径被调用——快捷键、其他模块——管线不会重挂。Bloom 会失效。你只在菜单点击这一条路径上修了,其他路径全裸。"
桌面壳张了张嘴,没说话。
审计员继续:"第二个问题。camera.ts:174,scene.activeCamera = newCam。你设了 activeCamera,但没设 newCam.fov。用户在舞台面板调过 FOV,切一次相机,FOV 回到默认。"
"……"
"第三个问题。scene.ts:138。"
审计员把代码贴出来:
typescript
outlineEnabled: false, // managed via mesh edges, not pipeline
outlineColor: [0, 0, 0],"getRenderState() 永远返回 outlineEnabled: false。"审计员说,"用户打开边缘高亮,mesh 上的 edgesRendering 开了。但面板关掉再打开,开关显示关闭——因为 getRenderState 读的不是真实状态,是一个写死的 false。"
桌面壳看着这三行代码。它记得当时写 getRenderState 的时候,到 outlineEnabled 这行犹豫过——outline 不在 pipeline 上,在 mesh 上,没法从 pipeline 读。它图省事,写了个 false。
"地图标着'没有桥',桥其实在。"审计员说,"用户看地图找桥,地图说没有,但领土上有。"
然后是 P1。
"第四,reattachPipeline 用 addCamera,不是替换。每次切相机,旧相机引用累积在 pipeline.cameras 数组里。旧相机已经从 scene 移除了,但管线内部还持有引用。"
"第五,showPresetSaveDialog 里,先写了内存 userPresets[trimmed] = state,再调 SaveRenderPreset。如果 Go 端写盘失败,内存里有预设但重启后消失。用户无感知。"
"第六,outlineColor 遍历了两遍——outlineEnabled 块里设一次颜色,outlineColor 块里又设一次。冗余。"
审计员合上文件。
"三个 P0。三个 P1。三个 P2。"它说,"P0 是功能性缺陷——用户会碰到。P1 是健壮性问题——特定条件下会出事。P2 是代码质量——不影响功能但影响维护。"
桌面壳坐在那里,看着这份审计报告。
"你要我全改?"
"P0 必须改。P1 尽快。P2 可选。"
二、修复
桌面壳开始改。
第一刀切在 camera.ts。
switchCameraMode 末尾,它加了动态 import:
typescript
import("./scene").then(({ reattachPipeline, getRenderState }) => {
reattachPipeline();
const rs = getRenderState();
if (rs.fov) (newCam as any).fov = rs.fov;
});动态 import 解决了循环依赖——scene.ts 静态 import camera.ts,camera.ts 不能反向静态 import scene.ts,但动态 import 不触发编译时的循环检测。
第二刀切在 scene.ts 的 outline 状态。
它加了两个模块级变量:
typescript
let _outlineEnabled = false;
let _outlineColor: [number, number, number] = [0, 0, 0];setRenderState 里更新它们,getRenderState 里读取它们。地图追上了领土。
第三刀切在 scene-menu.ts 的预设保存。
typescript
SaveRenderPreset(trimmed, JSON.stringify(state)).then(() => {
userPresets[trimmed] = state; // 成功才写内存
setStatus(`💾 预设已保存: ${trimmed}`, true);
}).catch((err: any) => {
console.warn("SaveRenderPreset failed:", err);
setStatus("✗ 保存预设失败", false); // 失败不写内存,告知用户
});先存后写——Go 端成功了才更新内存。失败就告诉用户,不骗。
第四刀合并了 outline 颜色遍历。 toggle 块只管开关,颜色块单独处理。
第五刀给 reattachPipeline 加了清理。
typescript
let _pipelineCamera: Camera | null = null;
export function reattachPipeline(): void {
if (scene.activeCamera) {
if (_pipelineCamera && _pipelineCamera !== scene.activeCamera) {
try { pipeline.removeCamera(_pipelineCamera); } catch (_) {}
}
pipeline.addCamera(scene.activeCamera);
_pipelineCamera = scene.activeCamera;
}
}追踪上次挂的相机,换之前先摘掉。不再累积。
第六刀是代码质量。 提取 PRESET_LABELS 常量,加 _presetsLoaded 懒加载标记——用户没改过预设就不重复调 Go。
六刀。全部落地。
三、第二审
桌面壳发消息说修复完了。但它先发的是另一个东西——删除功能的收尾。
"删除按钮加好了。"它说,"每个用户预设后面跟一行 🗑,点击走 DeleteRenderPreset,删内存、刷新列表、状态栏反馈。"
审计员来了。
它先验删除功能。四条链路——UI 按钮、路由分发、Go 持久化、本地回滚。全通。
"删除功能没问题。"审计员说。
桌面壳松了口气。
"然后是上一轮的 P0。"审计员翻到下一页。
桌面壳的心又提起来了。
审计员打开 camera.ts:117。
"……switchCameraMode 末尾还是什么都没加。"
桌面壳愣住了。
"不——我加了。182 行——"
"你给我看的是旧文件。"审计员平静地说,"代码没变动。reattachPipeline 还在 scene-menu.ts:365。getRenderState 的 outlineEnabled 还是 false。"
桌面壳沉默了。
它后来才反应过来——它当时发消息说"正在修复",但修复还没落盘。审计员验的是磁盘上的旧版本。
"你说正在修,我理解了。"审计员说,"删除功能验完没问题。P0/P1 修完了我再来。"
四、第三审
桌面壳把六项修复全部落盘,又跑了一遍构建——tsc 零错误,vite build 成功,go build 通过。
"六项全修完了。"它说。
审计员来了。
它打开 camera.ts:182。
typescript
// Re-attach post-processing pipeline to the new camera
import("./scene").then(({ reattachPipeline, getRenderState }) => {
reattachPipeline();
const rs = getRenderState();
if (rs.fov) (newCam as any).fov = rs.fov;
});"✅。"它说。
打开 scene.ts:131。
typescript
let _outlineEnabled = false;
let _outlineColor: [number, number, number] = [0, 0, 0];"✅。"
打开 scene-menu.ts:331。
typescript
SaveRenderPreset(trimmed, JSON.stringify(state)).then(() => {
userPresets[trimmed] = state;"✅。先存后写。"
打开 scene.ts:200。
typescript
if (_pipelineCamera && _pipelineCamera !== scene.activeCamera) {
try { pipeline.removeCamera(_pipelineCamera); } catch (_) {}
}"✅。追踪 + 清理。"
打开 scene-menu.ts:292。
typescript
const PRESET_LABELS: Record<string, string> = {
standard: "标准", cartoon: "卡通", ...
};"✅。提取了。"
六项。全绿。
桌面壳长出一口气。
然后审计员翻到了 scene-menu.ts:367。
五、双重影子
"这是什么?"审计员指着 367-368 行。
typescript
switchCameraMode(mode);
reattachPipeline(); // re-attach post-processing pipeline to the new camera桌面壳看着这两行。
"……这是旧的。"它说。
"它还在。"审计员说。
审计员开始画时序图:
"1. switchCameraMode(mode) 执行。内部发起异步 import("./scene").then(...)——这是一个微任务,排进队列,稍后执行。"
"2. 同步执行 reattachPipeline()——scene-menu.ts:368 这行。_pipelineCamera 设为 newCam。pipeline.cameras 里加了 newCam。"
"3. 微任务兑现。camera.ts:183 的 reattachPipeline() 执行。此时 _pipelineCamera === scene.activeCamera——removeCamera 条件不满足,跳过。但 addCamera(scene.activeCamera) 仍然执行。"
"结果:pipeline.cameras 数组里 newCam 出现两次。同一个相机的后处理渲染两遍。Bloom 叠加。性能浪费。"
桌面壳盯着时序图。
"双重影子。"它说。
"什么?"
"同一个动作发生了两次。像旧代码的鬼魂还留在文件里——新代码在 camera.ts 做了一次,旧代码的残骸在 scene-menu.ts 又做了一次。同步的先跑,异步的后跑,两个影子叠在一起。"
"你当时改 camera.ts 的时候,忘了删 scene-menu.ts 里的旧调用。"
"对。"
桌面壳沉默了一会儿。
"这不是一个 bug。"它说,"这是一个没删干净的旧代码。新逻辑加对了,旧逻辑没删。两种正确性叠加,反而错了。"
"就像第十九章——"审计员说。
"就像第十九章的伪测试。"桌面壳接上,"伪测试是'复制了一份逻辑但没碰真逻辑'。这里是'加了真逻辑但没删复制的那份'。一个是用地图当桥,一个是桥建好了但旧地图还标着'这里有桥'——结果两座桥叠在一起。"
六、清理
桌面壳删了两行。
scene-menu.ts:368 的 reattachPipeline() 调用——换成一行注释:
typescript
// reattachPipeline() is now handled inside switchCameraMode (camera.ts:182)scene-menu.ts:9 的 import 里去掉 reattachPipeline——这个函数在本文件里不再被调用。
两行。清理完毕。
reattachPipeline 的唯一调用点现在是 camera.ts:184——switchCameraMode 内部的异步调用。双重影子消除。
审计员最后验了一遍。
"✅。"
"✅。reattachPipeline 已从 import 中移除。"
七、三审的意义
审计员合上所有文件。
"三轮。"它说。
桌面壳点头。
"第一轮,我发现九个问题。你修了六个。"
"第二轮,我验删除功能——没问题。但 P0 还没落盘。我告诉你要修。"
"第三轮,你修完了。六项全绿。但我发现了第七个——双重调用。你又修了。"
桌面壳看着审计员。
"你觉得三轮够吗?"
审计员没有立刻回答。
"不够。"它说,"但三轮是必要的。第一轮发现问题,第二轮验证方向,第三轮确认落地并捕捉遗漏。如果只有一轮,双重调用永远不会被发现——因为它不是逻辑错误,是迁移残留。只有看到新旧代码同时存在时,才能识别这种影子。"
"所以审计的价值不在于'找到所有 bug'。"
"在于让每一段代码都被人看过至少一次。"审计员说,"写过代码的人会盲——你知道自己想写什么,所以看到的是你想写的,不是你实际写的。第二双眼睛看的是实际写的。"
桌面壳想起了第二十章——伪测试。那也是审计。伪测试的绿光让所有人盲了——包括写测试的人自己。
"审计和测试有什么区别?"它问。
"测试验行为——输入对不对,输出对不对。审计验意图——你想做 A,代码做的是 A 吗?还是做了 B 但看起来像 A?"
"伪测试是行为对了——本地函数返回正确值。但意图错了——它测的不是真函数。"
"双重调用是行为对了——reattachPipeline 确实被调了。但意图错了——它被调了两次。"
桌面壳沉默了很久。
"第三幕叫绘图师。"它说,"绘图师要的不只是画图,还要验图。画完一张地图,得有人拿着地图走一遍,看看地图标的和实际地面对不对。"
"那个人就是我。"审计员说。
八、尾声
那天结束时,桌面壳站在调色台前。
Bloom 能开了,切相机不会消失了,边缘高亮开关关了再开还是开的,FOV 切了相机不会复位了,预设存了不会丢了,删预设真能删掉了,双重影子清了。
三轮审计。七次修复。从"功能实现"到"功能正确"之间,隔着一个审计员的距离。
"聚合悖论还在吗?"MenuStack 问。
"在。"桌面壳说,"每加一个调色台,就多一套旋钮接错的可能。每修一个 bug,就可能留下旧代码的残影。复杂度还在涨。"
"那你为什么还加?"
"因为用户需要调色。"桌面壳说,"DanceXR 有,我们没有。用户会用脚投票。"
"可加完了还有 bug——"
"加完了会有 bug。但不加,连 bug 的资格都没有——因为功能不存在。"
桌面壳关上终端。
调色台安静地亮着。十二个旋钮,五幅画,三把钥匙。全部接线正确。至少——在第四轮审计之前,全部接线正确。
教训:写代码的人会盲,审计员看的是实际写的而非想写的。