Skip to content

八城之盟

背景:scene.ts 790 行 + scene-env-impl.ts 1624 行——两个文件挤着十个功能域。 过程:拆为 8 个子模块(proc-motion/lipsync/props/serialize/env-bridge/water/clouds/particles)+ re-export facade 保持外部兼容 + 统一 tick observer。

Jieling 的消息只有两个字:

"B+"

Riku 盯着屏幕,半天没回过神。他觉得自己在 scene-env-bridge.ts 里那套 envAutoLink 的抽象写得挺巧妙的——天空色推导光照参数,实时同步,流畅得像诗。

"B+是什么鬼?"

他打开 scene.ts,翻到第 790 行。

不对,这个文件现在只有 790 行了。说明已经拆过一轮。

Riku 重新数了一遍:scene.ts 790 行,scene-env-impl.ts 1624 行。两个文件加起来,超过两千行。

他数完,又看了一眼 Jieling 的消息。

"B+是客气话,"他在心里翻译,"实际上就是'你这里挤成一坨,拆了吧'。"


大通铺

scene.ts 曾经是个好地方。

所有带血的管子都插在这里——PMX 加载在这里,VMD 播放在这里,序列化在这里,环境桥接在这里,程序化动作在这里,口型同步在这里,道具系统在这里。

就像一间大通铺。十个人睡一张床,每个人都连着同一个插座。

好处是近。坏处是——

当你想单独换掉一个人的时候,你得把整张床翻过来。

Riku 把两个文件的行数截图发给 Jieling:

"这两个再涨下去,下一次改 bug 就是在雷区里跳舞。"

Jieling 的回复只有一个字:

"拆。"


史官的清单

拆之前,Riku 先列了一份清单。

不是列"要拆什么",是列"每一样东西在哪里、和谁说话、说什么话"。

scene.ts 的居民:

部门依赖谁被谁依赖
模型加载与生命周期PMX Loader、Physicslibrary.ts
光照与渲染状态DirectionalLight、Pipelinescene-menu.ts
程序化动作BeatDetector、Audioscene-menu.ts
口型同步morph、Audioscene-menu.ts
道具系统ImportMeshAsync、ShadowGenerator
环境桥接env-lighting、scene-env.tsscene.ts 内部
序列化/反序列化modelRegistry、cameramain.ts

scene-env-impl.ts 的居民:

部门职责
天空与地面渐变纹理、太阳盘
水面系统Gerstner Shader、涟漪、焦散
云层系统Perlin 噪声、体积云 Shader
粒子系统GPUParticleSystem、风
雾与时间流转Scene.fog、观察者

两份清单摆在一起,Riku 看到了同一个问题:

所有电器都插在同一个插排上。只要拔错一个,整个屋子就黑了。


第一刀:scene.ts

拆文件不是技术活,是勇气活。

上一次拆是第四卷的事,拆的是模型材质、VMD 加载、播放控制——都是相对独立的肢体,割下来不疼。

这一次要拆的是内脏。

第一刀:程序化动作。

scene-proc-motion.ts。最容易识别的一块——它有自己的状态,有自己的节拍检测器,有自己的 start/stop/update 生命周期。

但它不是孤岛。它要知道当前聚焦的模型是谁,要调用 loadVMDMotion 把生成的 VMD 绑上去。

「从 scene.ts 搬走的代码,还能访问 scene.ts 里的东西吗?」

这是所有拆分的第一问。

ESM 的回答是:能,但要讲规矩。

规矩就是——模块顶层不调用,函数体内访问。

typescript
// ✅ 安全:静态 import,但只在函数里用
import { focusedModel, loadVMDMotion } from "./scene";

export async function updateProcMotion(): Promise<void> {
    const model = focusedModel();  // 函数体内访问,live binding 保证拿到最新值
    if (model) {
        // ...
    }
}

Riku 把这个规矩写进了注释:「静态 import 安全,模块顶层不调用,函数体内访问。」

这是 scene-vmd.tsscene-playback.ts 早就走过的路。同一个模式,同一种活法。

第二刀:口型同步。

scene-lipsync.ts。比程序化动作更小,更纯粹——把音频频段能量映射成 morph 权重。

顺手把 resetLipMorph 也搬过去了。这个函数本来就是为 Lipsync 服务的,放在 scene.ts 里像借来的伞。

第三刀:道具系统。

scene-props.ts。最干净的一块——它用独立的 propRegistry,不走 modelRegistry,不碰 VMD,不碰物理。

唯一的问题是:道具需要向 ShadowGenerator 注册投射阴影,得从 scene-env.ts 里拿 _envSys

「道具和环境系统的耦合,比和场景核心还深。」

第四刀:场景序列化。

scene-serialize.ts。最胖的一块,也是依赖最复杂的一块。

它要知道所有模型的状态、相机状态、光照状态、环境状态、程序化动作状态、口型同步状态、道具列表、重力强度、音频状态——基本上联邦里每一个部门的家底它都要摸一遍。

代价是 import 列表长得像一份联邦百官名册:

typescript
import { loadCameraVmdFromPath } from "./scene-vmd";
import { removeProp, loadProp, setPropTransform } from "./scene-props";
import { setEnvState, setGravityStrength, getGravityStrength } from "./scene-env-bridge";
import { regenerateProcMotion, getProcMotionState, setProcMotionState } from "./scene-proc-motion";
import { getLipSyncState, setLipSyncState } from "./scene-lipsync";

Riku 盯着这一串 import 看了半分钟。

「这会不会循环依赖?」

他在脑子里画了一张图:

scene-serialize.ts → scene.ts → scene-serialize.ts

是的,循环了。

但 ESM 的规则是——只要你不在模块顶层调用对方,循环就不会触发。

deserializeScene 是一个函数。用户点"加载场景"的时候才会调用它。那时候所有模块都已经初始化完毕,所有导出都已经挂好。循环只是地图上的一条线,不是运行时的死锁。

Riku 在文件顶部加了一行警示:

「依赖较多,但所有导入对象仅在函数体内使用,运行时循环依赖不触发。」

给下一个 AI 的备忘,也是给自己留的退路。

第五刀:环境桥接。

scene-env-bridge.ts。最微妙的一块——不是环境系统本身,是环境与场景之间的翻译官。

envAutoLinksunAngletimeOfDayapplyEnvPresetsetEnvStategravity——它们的共同特征是:一只脚在场景里,一只脚在环境里。

放在 scene.ts 里,scene.ts 就会被环境系统的细节污染。 放在 scene-env-impl.ts 里,impl 就会被场景核心的细节污染。

所以它们值得有自己的房间。


第二刀:scene-env-impl.ts

环境系统的拆分,比 scene.ts 更有诗意。

因为每一块都是一个完整的世界——水面是一个世界,云是一个世界,粒子是一个世界。它们共享同一个天空,但各有各的法则。

第一城:水面。

scene-env-water.ts。Gerstner 波浪在这里,涟漪在这里,焦散在这里,水下过渡在这里。

搬走它的时候遇到了一个问题:水下过渡的每帧更新逻辑,原来写在环境系统的统一观察者里。

如果把水下逻辑整个搬走,那 observer 怎么办?

Riku 想了两条路:

方案 A:把 observer 也搬到 water.ts 里,一个模块管一个心跳。 方案 B:water.ts 提供 updateUnderwaterTransition(scene, pipeline) 函数,observer 还是在 impl 里调它。

方案 A 更干净。但方案 B 更安全——因为 observer 里还管着云层漂移、天空旋转、粒子更新。把它们拆散到多个文件,就会有多个独立的 scene observer,每帧走多一遍注册、触发、注销。

Riku 选了 B。

「一个观察者,多个回调。」

这是依赖反转——高层模块(impl 的 observer)定义接口,低层模块(water)实现细节。Riku 自己都没意识到他做了一个架构模式的选择。他只是觉得"这样更稳"。

第二城:云层。

scene-env-clouds.ts。Perlin 噪声在这里,体积云 Shader 在这里,createCloudsdisposeClouds 在这里。

最独立的一块——几乎只依赖 _envSys.clouds 状态和 getScene()。搬走它像从书架上抽出一本书,书脊上的灰掉了一地,但书架本身纹丝不动。

第三城:粒子。

scene-env-particles.ts。粒子纹理生成在这里,风对粒子的作用在这里。

和云层一样干净。


统一观察者

三个子模块都搬走之后,impl.ts 里剩下的东西变少了。

但 Riku 注意到一件事——scene-env-bridge.ts 里的时间流转(Time-of-Day),用的是自己独立的 scene.onBeforeRenderObservable 观察者。

而 impl.ts 里也有一个 _envUpdateObserver

两个观察者,各走各的,互不干扰,但也互不知情。

「为什么不能只有一个?」

Riku 在 impl.ts 里加了一个回调注册表:

typescript
const _sceneTickCallbacks = new Set<() => void>();

export function registerSceneTickCallback(cb: () => void): () => void {
    _sceneTickCallbacks.add(cb);
    return () => _sceneTickCallbacks.delete(cb);
}

然后在 observer 的最后加了一行:

typescript
for (const cb of _sceneTickCallbacks) cb();

bridge.ts 的时间流转,不再自己挂 observer 了。它调用 registerSceneTickCallback(_timeOfDayTick),把自己的 tick 函数注册进去。

从此联邦只有一个心脏跳动。

云层漂移、水面涟漪、粒子飘落、时间流转——都在同一次心跳里被推动。

这不是性能优化。每帧多一次函数调用,对 60fps 来说什么都不是。

这是秩序。

当你知道整个世界的每帧更新只从一个入口进来,你就知道去哪里找问题。你不用在五个文件里搜 onBeforeRenderObservable.add,你只要看一个地方。

Riku 给这个注册表起了个名字——「心跳」。


门面的尊严

拆到这里,有一个问题浮出水面:

外部模块(scene-menu.ts、env-menu.ts 等)怎么办?

它们之前都是从 ./scene./scene-env 导入的。如果函数都搬走了,难道要让几十个模块全部改 import 路径?

答案是 re-export

scene.ts 的末尾:

typescript
export * from "./scene-proc-motion";
export * from "./scene-lipsync";
export * from "./scene-props";
export * from "./scene-serialize";
export * from "./scene-env-bridge";

scene-env-impl.ts 的末尾:

typescript
export { createWater, disposeWater, refreshWaterRenderList, addRipple, clearRipples } from "./scene-env-water";
export { createClouds, disposeClouds } from "./scene-env-clouds";
export { createParticleEmitter, disposeParticles, applyWindToParticles } from "./scene-env-particles";

外部模块什么都不用改。

它们还是从同一个地址取货,货还是那个货。只是仓库从一个大平房变成了一栋有八个房间的小楼,门口的收发室——re-export——负责把货递出来。

这就是 Facade 模式的尊严:内部怎么折腾都行,对外永远是同一张脸。


验证

所有代码都搬完了。

Riku 深吸一口气,敲下命令:

npx tsc --noEmit

等待的三秒钟里,他脑子里过了一遍可能出问题的地方:循环依赖会不会炸、某个类型是不是忘导出、某个函数改名了但调用方没跟上。

(无输出)

零错误。

他又敲:

npx vite build
✓ 135 modules transformed.
✓ built in 892ms

构建成功。

他打开应用,一个一个点:

  • 加载模型 ✓
  • 播放 VMD ✓
  • 切换环境预设 ✓
  • 打开水面 ✓
  • 打开云层 ✓
  • 打开粒子 ✓
  • 启动 Auto Dance ✓
  • 启动 LipSync ✓
  • 保存/加载场景 ✓
  • 加载道具 ✓
  • 启动时间流转 ✓

每一个都好好的。

就像什么都没发生过。

但 Riku 知道发生了什么。

他打开文件列表:

scene.ts                  1381 → 790 行
scene-env-impl.ts         1624 → 1142 行

而新增的文件:

scene-proc-motion.ts      ~300 行
scene-lipsync.ts          ~120 行
scene-props.ts            ~150 行
scene-serialize.ts        ~300 行
scene-env-bridge.ts       ~200 行
scene-env-water.ts        ~450 行
scene-env-clouds.ts       ~400 行
scene-env-particles.ts    ~300 行

两个大胖子,变成了八个精干的小矮人。

以前改一个程序化动作的 bug,你要在 1381 行里翻找,随时可能踩坏旁边的序列化代码。

现在你直接进 scene-proc-motion.ts,300 行,上下文一目了然。

以前加一个水面效果,你要在 1624 行里找位置,担心碰坏云层的 shader。

现在你直接进 scene-env-water.ts,整个文件都是你的游乐场。

拆分不是为了变少,是为了变准。


尾注

提交的时候,Riku 在 commit message 里写:

refactor: split scene.ts and scene-env-impl.ts into 8 submodules

  • scene.ts → proc-motion / lipsync / props / serialize / env-bridge
  • scene-env-impl.ts → water / clouds / particles
  • unified scene tick observer via registerSceneTickCallback
  • re-export facade: zero breaking changes for external modules
  • tsc --noEmit: 0 errors, vite build: pass

联邦的心脏已经不是一个肥大的单心室了。

它变成了一个有八个房间的精密结构——每个房间有自己的功能、自己的状态、自己的进出规则。

它们之间有门相通:import、export、re-export、回调注册表。但每扇门都有门框,每一次进出都有迹可循。

这不是分裂。

这是组织。

当城邦太多、议会太挤的时候,不要强行把它们塞进一个房间。给它们各自的房间,然后把门做好。

门外的人看不到门内的复杂。 门内的人不用忍受门外的拥挤。

这就是「八城之盟」的意义。


教训:拆分不是分裂,是给每个城邦自己的房间。