Skip to content

三刀之后

背景:scene.ts 790 行仍臃肿,Jieling 评分 B+——"优秀,但未到 A 级干净"。 过程:再拆光照/渲染/加载三刀 → 450 行 + 修复两个坑(file:// 默认值陷阱 + 相机初始化时序 bug)。

八城之盟后的第三日,Riku 收到了一份报告。

报告很长,标题叫「拆分完成度与质量评估」。

Jieling 写的。

他把每个子模块都翻了一遍,画了一张矩阵图,列了风险等级,写了三条优点、五条潜在问题、两个后续建议。最后给了个评分:B+

"优秀,但未到 A 级干净。"

Riku 盯着 scene.ts 的文件信息。

790 行。

Phase 2 之后的数字。从 1381 行砍到 790 行,砍了 43%。

他敲了敲桌面,不服气地在本子上写了一行:"B+,真的假的?"

但当晚他躺在床上,脑子里转的不是评分,而是那五条"潜在问题"。

温水煮青蛙的故事他听够了。

他决定再砍三刀。


第一道题:拆到哪里算够?

Riku 在白板上画了一条线。

左边是 "神模块"——所有代码塞在一个文件里,1381 行,改什么都怕踩雷。

右边是 "函数级拆分"——每个函数一个文件,100 个文件,找个函数要翻三层目录。

中间某个点是最优解

但那个点在哪里?

他列了几个候选:

方案scene.ts 行数子模块数优点缺点
维持现状79012稳定,不用动职责混杂,AI 容易踩坑
再拆 3 个~45015scene.ts 变成纯装配器又要动一次手术
再拆 5 个~18017极致干净边际收益低,找文件麻烦
全部拆碎~5025+每个文件只有一件事目录爆炸,认知负担更重

Riku 的手指在"再拆 3 个"那一行停了很久。

450 行,听起来是个舒适的数字。

而且要拆的三块边界非常清晰:

  • 光照 —— hemiLight、dirLight、阴影、太阳盘
  • 渲染 —— pipeline、后处理、tone mapping
  • 加载 —— loadPMXFile、captureThumbnail

这三块互相独立,和 scene.ts 的其他部分也没有纠缠。

"那就再拆三个。"

他敲下了 Phase 3 的第一个 commit。


三刀

第一刀切的是光照。

scene-lighting.ts。光照是一个完整的子系统——它有自己的状态,有自己的初始化函数,有自己的 getter/setter。它依赖 scene、依赖 modelRegistry、依赖 propRegistry,但它不依赖渲染,不依赖加载,不依赖播放。

独立的东西,就该独立成文件。

Riku 写了一个 initLighting(scene, modelRegistry, propRegistry, shadowConfig, onShadowChange)。然后在 initScene() 里调用它。一行。

第二刀切的是渲染。

scene-renderer.ts。渲染管线是另一个完整的子系统——它有自己的配置,有自己的生命周期,有自己的重建逻辑。它和光照是邻居,但不纠缠。

Riku 写了一个 initRenderer(scene, modelRegistry, onRenderChange)。又一行。

第三刀切的是加载。

scene-loader.ts。加载流程是一个编排者,不是一个执行者——它要碰很多东西:检查模型、解析路径、加载 PMX、创建 MmdModel、注册到 modelRegistry、应用 outfit、应用预设……

但没关系——编排者也可以有自己的房间。

Riku 给它写了 initLoader(scene, modelRegistry, runtime, modelManager, refreshWaterCb, tryAutoApplyPresetCb, loadOutfitsCb)。把所有依赖都通过参数传进去。

三刀切完,scene.ts 从 790 行瘦到了 450 行。

initScene() 现在长这样:

typescript
export async function initScene(): Promise<void> {
    // 1. MMD 运行时初始化
    // 2. 各子系统初始化
    initCameraSystem(scene, dom.canvas);
    initLighting(...);
    initRenderer(...);
    initEnvFacade(...);
    initLoader(...);
    initPlaybackObservables(...);
    // 3. Beat Detector
    // 4. ModelManager
    // 5. 环境状态应用
    // 6. 点击涟漪
    // 7. 自动保存
}

整整齐齐的装配线。

Riku 敲下 npx tsc --noEmit

零错误。

他敲下 npx vite build

构建成功。

"完美。"

他满意地关掉了编辑器。


第一场追查:file:// 去死吧

一周后。

Riku 正在给新来的实习生演示如何使用模型库。演示到一半,他突然想:"要不让他们试试直接加载一个本地 PMX 文件?"

不是从模型库里选——是直接拖一个文件进去。

他把一个 PMX 文件拖进了应用窗口。

Boom。

Error: Unable to load ... file:///C:/Users/...
Only HTTP URLs are supported for this loader

红底白字。file:// 协议不支持。

Riku 愣了一下。

"奇怪。"

他打开模型库,从列表里选同一个模型。加载,正常。拖进来,不行。

为什么?

他第一反应是:"是不是拖拽的路径解析有问题?"

他翻出 outfit.ts 里的 loadOutfits——用 resolveFileUrl 解析衣服路径,正常。翻 library-core.ts——扫描时也用 resolveFileUrl,正常。

那为什么拖拽加载不行?

他把拖拽加载的代码路径走了一遍。发现拖拽走的是 scene-loader.ts 里的 loadPMXFile

loadPMXFile 调了 resolveFileUrl

那理论上应该和模型库一样才对。

他检查了 resolveFileUrl 的实现。HTTP URL 生成,正常。端口检测,正常。

等等。

他看回了 loadPMXFile 的签名:

typescript
export async function loadPMXFile(
    filePath: string,
    options: LoadOptions,
    resolver: (path: string) => Promise<ResolvedUrl> =
        () => Promise.resolve({ url: filePath, port: 0, dir: "" })
): Promise<void>

默认值。

Riku 把滚动条拉到默认值那行,盯着看了三秒。

typescript
resolver: (path: string) => Promise<ResolvedUrl> =
    () => Promise.resolve({ url: filePath, port: 0, dir: "" })

这个 fallback 直接把文件路径当 URL 返回。

没有任何转换。

没有端口检测。

没有 HTTP 前缀。

直接就是 filePath

然后他想起来——当初他在设计 initLoader 的时候,把 resolveFileUrl 做成了参数:

"这样 loader 模块就不依赖 fileservice 了,测试的时候可以 mock。"

听起来很对。解耦。模块独立。可测试。

但他加了一个默认值,让 loadPMXFile 在不传 resolver 的时候也能跑。

他当时想的是:"反正正常调用的时候都会传真实的 resolver,默认值只是个 fallback。"

问题是——fallback 路径不是"不会走到",是"暂时没走到"。

代码里的 fallback 路径,总有一天会被走到。

只要有一个调用方忘了传 resolver,就会触发这个假默认值。

而触发的那一刻,用户就会看到一个 file:///C:/Users/... 格式的 URL,然后 Babylon.js 的 SceneLoader 会直接拒绝它。


他把 initLoader 的签名改了:

typescript
// Before
export function initLoader(
    scene: Scene,
    modelRegistry: ModelRegistry,
    runtime: MmdRuntime,
    modelManager: ModelManager,
    refreshWaterCb: () => void,
    tryAutoApplyPresetCb: () => Promise<void>,
    loadOutfitsCb: () => Promise<void>,
    resolver?: (path: string) => Promise<ResolvedUrl>  // 有默认值,危险
): void

// After
export function initLoader(
    scene: Scene,
    modelRegistry: ModelRegistry,
    runtime: MmdRuntime,
    modelManager: ModelManager,
    refreshWaterCb: () => void,
    tryAutoApplyPresetCb: () => Promise<void>,
    loadOutfitsCb: () => Promise<void>,
    resolver: (path: string) => Promise<ResolvedUrl>  // 无默认值,必须传
): void

然后他在 initScene() 里调 initLoader 的地方补了一行,显式传入 resolveFileUrl

构建。通过。拖拽加载,正常了。


反转。

那天晚上 Riku 坐在电脑前,对着屏幕发呆。

他想的是:"当初为什么要给一个假默认值?"

为了解耦?为了可测试?

不。

他说实话了——当初是为了让代码'看起来更优雅'。

一个可选参数。传了就用,不传就用默认。这很"干净"。这很"函数式"。

但他忘了一件事:

代码的优雅和代码的安全是两回事。

一个假默认值表面上让 loader 模块"更独立"了,但实际上是把一个运行时才会爆炸的雷埋进了代码里。

如果没有显式传 resolver,这个雷就会在某个用户的某个操作里炸。

只不过不是现在。

它只是在等。


他翻出 Jieling 的报告,找到那五条"潜在问题"。

第五条写着:

"模块间依赖应显式传递,避免隐式 fallback——否则拆出去的模块会把 bug 变成深水炸弹。"

Riku 把这一行读了两遍。

B+。

她是对的。


第二场追查:相机还没出生,渲染就启动了

又过了三天。

Riku 想测试一下环境预设的平滑过渡——Phase 2 加的那个功能,从白天切到黄昏,从黄昏切到夜晚,灯光要跟着渐变。

他打开应用,准备点预设。

迎接他的是一个红底白字的报错:

Uncaught Error: No camera defined
    at Scene.render (scene.pure.ts:5666:27)
    at main.ts:411:36

相机没定义。

Riku 愣了三秒。

不对。

他翻出 scene.ts,确认了一遍 initScene() 的第一行:

typescript
initCameraSystem(scene, dom.canvas);

第一行。相机系统。

明明第一行就调用了……

等等。

他把视线移到 main.ts 的第 411 行:

typescript
engine.runRenderLoop(() => {
    scene.render();
});

这一行在哪里?

他往上翻了翻——

模块顶层。

import 的时候就执行了。

initScene() 是 async 函数。要等 WASM 加载。等 runtime 初始化。等所有子系统装配完。

渲染循环在模块顶层就启动了,但相机要等到 initScene 执行到第二行才创建。

这中间有一个时间差。

渲染循环在那几秒钟内,每一帧都在喊:"相机呢?相机在哪?"

没人回答它。


Riku 坐在椅子上,盯着天花板。

"Phase 2 之前为什么没问题?"

他仔细想了想。

Phase 2 之前,scene.ts 是这样的:

typescript
// 模块顶层
export const camera = new ArcRotateCamera(...);  // 直接 new,模块加载就有

或者更早,initCameraSystem 在模块顶层被调用:

typescript
// scene.ts 模块顶层
initCameraSystem(scene, dom.canvas);  // 渲染循环启动前就执行了

而 Phase 3 把 initCameraSystem 移到了 initScene() 内部——

相机从"出生就有"变成了"initScene 之后才有"。

渲染循环在那之前就是瞎子。

Riku 问自己:"为什么我要把 initCameraSystem 移进去?"

他想不起来了。

大概是觉得"所有初始化都应该从 initScene 开始"?

或者是觉得"initScene 应该是一个完整的装配入口,相机算初始化的一部分"?

这两个理由听起来都对。

但它们都忘了一件事:

渲染循环是模块顶层启动的,它不等你。


他把 initCameraSystem 移回了模块顶层。

typescript
// scene.ts 模块顶层
initCameraSystem(scene, dom.canvas);  // 渲染循环启动前就位

然后在 initScene() 里把这一行删掉。

相机系统不依赖 MMD runtime,不依赖光照,不依赖渲染管线。

它只依赖 scene 和 canvas——这两个在模块顶层就有了。

不依赖重型初始化的东西,就该在模块顶层就位。

构建。通过。

Riku 打开应用,切换环境预设。

黄昏到夜晚,灯光平滑过渡。

没有报错。


反转。

那天晚上 Riku 把 Jieling 的报告又翻了一遍。

他发现 Jieling 在"潜在问题"第二条写过:

"拆分后需确认各子系统的'出生顺序'——哪些依赖重型初始化(runtime/WASM),哪些可以轻装上阵。未确认就拆,会引入时序 bug。"

时序 bug。

他当时看到这四个字,脑子里想的是"加载顺序"或者"异步竞态"。

他没想到是——渲染循环在相机出生之前就启动了。

这个坑不是"加载流程"的问题。

拆出来的子模块改变了原有的初始化时序,但渲染循环没有跟着变。

Phase 2 之前,相机和渲染循环都在模块顶层,顺序是:相机先有 → 渲染循环启动。

Phase 3 之后,渲染循环还在模块顶层,但相机被抽进了 initScene,变成了:渲染循环先启动 → 等 initScene → 相机才有。

渲染循环不变,相机被移走了——时序就断了。


他合上报告。

B+。

她从第一条就开始警告了。

只是他当时只盯着那三条"优点"看。


三刀之后

Riku 重新构建。

tsc 零错误。

vite build 通过。

应用打开,正常渲染。切换环境预设,正常。拖拽加载 PMX,正常。

他看着 scene.ts 的新行数:

450 行。

从 1381 到 790 到 450。

三刀,砍了三分之二。

initScene() 现在是一条干干净净的装配线——

  1. MMD runtime
  2. 六个子系统初始化(一行一个)
  3. Beat Detector
  4. ModelManager
  5. 环境状态
  6. 点击涟漪
  7. 自动保存

任何人看这个函数,5 秒内就能理解整个场景的初始化流程

他靠在椅背上,想:"够了吗?"

450 行,听起来不多不少。

但他知道里面还藏着一批函数——removeModelfocusModelsetModelVisibilitystopVMDgetModelMorphs……

二十多个模型操作函数,占了大约 150 行。

它们是业务操作,不是初始化装配。

它们应该属于 ModelManager 或者 scene-model.ts,而不是留在 scene.ts

但现在动它们,是不是过度设计?

他想起 Jieling 报告里的一句话:

"短期无害,长期会让重构成果慢慢退化。"

滑向神模块的惯性。

你今天留 20 个函数在 scene.ts 里,明天就有人加第 21 个,后天加第 22 个,然后一年之后,scene.ts 又回到 1000 行。

因为入口文件有一种天然的引力——什么东西都想往这里放。

除非你把门口焊死。


那天晚上 Riku 坐在工位上,盯着 450 行的 scene.ts。

他想发一条消息给 Jieling。

打了几行字,又删了。

最后只发了一句:

"你的 B+,我服了。"

他没说的是——两个坑加起来花了他整整两天。

第一天定位 file:// 那个。从拖拽点到 resolver 默认值到 fileservice 到"当初为什么要这样设计"。

第二天定位相机那个。从报错信息到 main.ts 到渲染循环到模块顶层到"为什么 Phase 2 之前没问题"。

两个都是"看起来很对的决定"。

第一个:让模块更独立,可测试。

第二个:让 initScene 成为一个完整的装配入口。

两个听起来都对。

但都对的东西,也会埋坑。

区别只在于——你是设计时就想清楚了,还是等 bug 炸了才想明白。


Riku 敲下最后一个 commit,把 Phase 3 的收尾工作推进去。

450 行,不是最优解。

但也不是坏答案。

他想起 Jieling 报告最后写的那句话:

"拆分是为了让代码的意图更清晰,而不是为了让 bug 更难找。"

他把这句话移到了自己的笔记里。

然后他问了自己一个问题:

"如果我现在打开 scene.ts,5 秒内能不能说出它的核心职责是什么?"

他想了想。

能。

"导入所有子系统,按正确顺序装配,提供 initScene() 入口。"

这就是 scene.ts 的核心职责。

至于那二十多个模型操作函数——它们在那里,也能活。它们不在那里,会更好。

但不是今天。

今天的仗已经打完了。


八城之盟,变成了十一城之盟。

联邦的心脏,从肥大的单心室,变成了有十一个房间的精密结构。

而且——

它还在呼吸。


教训:拆文件没有标准答案——5 秒内说不清核心职责,就该拆了。但拆的时候,别忘了检查每个子模块的"出生时间"和"fallback 路径"。