Appearance
三刀之后
背景: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 行数 | 子模块数 | 优点 | 缺点 |
|---|---|---|---|---|
| 维持现状 | 790 | 12 | 稳定,不用动 | 职责混杂,AI 容易踩坑 |
| 再拆 3 个 | ~450 | 15 | scene.ts 变成纯装配器 | 又要动一次手术 |
| 再拆 5 个 | ~180 | 17 | 极致干净 | 边际收益低,找文件麻烦 |
| 全部拆碎 | ~50 | 25+ | 每个文件只有一件事 | 目录爆炸,认知负担更重 |
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() 现在是一条干干净净的装配线——
- MMD runtime
- 六个子系统初始化(一行一个)
- Beat Detector
- ModelManager
- 环境状态
- 点击涟漪
- 自动保存
任何人看这个函数,5 秒内就能理解整个场景的初始化流程。
他靠在椅背上,想:"够了吗?"
450 行,听起来不多不少。
但他知道里面还藏着一批函数——removeModel、focusModel、setModelVisibility、stopVMD、getModelMorphs……
二十多个模型操作函数,占了大约 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 路径"。