Skip to content

三审

背景:调色台搭好后,审计员发现 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.tsswitchCameraMode 末尾调用 reattachPipeline()'。第 49 行——'把当前 fov 应用到新相机'。第 170 行——'相机重挂:switchCameraModepipeline.cameras 未更新 = 后处理失效。已在 1.3 处理。'"

桌面壳沉默了。

"你没在 switchCameraMode 末尾加。"审计员说,"你把 reattachPipeline() 放在了 scene-menu.ts:365handleSceneAction 里。"

"效果一样——"

"不一样。"审计员打断,"如果 switchCameraMode 从菜单以外的路径被调用——快捷键、其他模块——管线不会重挂。Bloom 会失效。你只在菜单点击这一条路径上修了,其他路径全裸。"

桌面壳张了张嘴,没说话。

审计员继续:"第二个问题。camera.ts:174scene.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。

"第四,reattachPipelineaddCamera,不是替换。每次切相机,旧相机引用累积在 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.tscamera.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:365getRenderStateoutlineEnabled 还是 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 设为 newCampipeline.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:368reattachPipeline() 调用——换成一行注释:

typescript
// reattachPipeline() is now handled inside switchCameraMode (camera.ts:182)

scene-menu.ts:9 的 import 里去掉 reattachPipeline——这个函数在本文件里不再被调用。

两行。清理完毕。

reattachPipeline 的唯一调用点现在是 camera.ts:184——switchCameraMode 内部的异步调用。双重影子消除。

审计员最后验了一遍。

367:379:scene-menu.ts
function handleSceneAction(row: PopupRow): void {
    // Camera mode switching
    if (row.target && row.target.startsWith("camera:")) {
        const mode = row.target.replace("camera:", "") as "orbit" | "freefly" | "oneshot" | "concert";
        switchCameraMode(mode);
        // reattachPipeline() is now handled inside switchCameraMode (camera.ts:182)

"✅。"

3:11:scene-menu.ts
import { getLightState, setLightState, triggerAutoSave, serializeScene, deserializeScene, getRenderState, setRenderState } from "./scene";

"✅。reattachPipeline 已从 import 中移除。"


七、三审的意义

审计员合上所有文件。

"三轮。"它说。

桌面壳点头。

"第一轮,我发现九个问题。你修了六个。"

"第二轮,我验删除功能——没问题。但 P0 还没落盘。我告诉你要修。"

"第三轮,你修完了。六项全绿。但我发现了第七个——双重调用。你又修了。"

桌面壳看着审计员。

"你觉得三轮够吗?"

审计员没有立刻回答。

"不够。"它说,"但三轮是必要的。第一轮发现问题,第二轮验证方向,第三轮确认落地并捕捉遗漏。如果只有一轮,双重调用永远不会被发现——因为它不是逻辑错误,是迁移残留。只有看到新旧代码同时存在时,才能识别这种影子。"

"所以审计的价值不在于'找到所有 bug'。"

"在于让每一段代码都被人看过至少一次。"审计员说,"写过代码的人会盲——你知道自己想写什么,所以看到的是你想写的,不是你实际写的。第二双眼睛看的是实际写的。"

桌面壳想起了第二十章——伪测试。那也是审计。伪测试的绿光让所有人盲了——包括写测试的人自己。

"审计和测试有什么区别?"它问。

"测试验行为——输入对不对,输出对不对。审计验意图——你想做 A,代码做的是 A 吗?还是做了 B 但看起来像 A?"

"伪测试是行为对了——本地函数返回正确值。但意图错了——它测的不是真函数。"

"双重调用是行为对了——reattachPipeline 确实被调了。但意图错了——它被调了两次。"

桌面壳沉默了很久。

"第三幕叫绘图师。"它说,"绘图师要的不只是画图,还要验图。画完一张地图,得有人拿着地图走一遍,看看地图标的和实际地面对不对。"

"那个人就是我。"审计员说。


八、尾声

那天结束时,桌面壳站在调色台前。

Bloom 能开了,切相机不会消失了,边缘高亮开关关了再开还是开的,FOV 切了相机不会复位了,预设存了不会丢了,删预设真能删掉了,双重影子清了。

三轮审计。七次修复。从"功能实现"到"功能正确"之间,隔着一个审计员的距离。

"聚合悖论还在吗?"MenuStack 问。

"在。"桌面壳说,"每加一个调色台,就多一套旋钮接错的可能。每修一个 bug,就可能留下旧代码的残影。复杂度还在涨。"

"那你为什么还加?"

"因为用户需要调色。"桌面壳说,"DanceXR 有,我们没有。用户会用脚投票。"

"可加完了还有 bug——"

"加完了会有 bug。但不加,连 bug 的资格都没有——因为功能不存在。"

桌面壳关上终端。

调色台安静地亮着。十二个旋钮,五幅画,三把钥匙。全部接线正确。至少——在第四轮审计之前,全部接线正确。


教训:写代码的人会盲,审计员看的是实际写的而非想写的。