Appearance
八城之盟
背景: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、Physics | library.ts |
| 光照与渲染状态 | DirectionalLight、Pipeline | scene-menu.ts |
| 程序化动作 | BeatDetector、Audio | scene-menu.ts |
| 口型同步 | morph、Audio | scene-menu.ts |
| 道具系统 | ImportMeshAsync、ShadowGenerator | — |
| 环境桥接 | env-lighting、scene-env.ts | scene.ts 内部 |
| 序列化/反序列化 | modelRegistry、camera | main.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.ts 和 scene-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。最微妙的一块——不是环境系统本身,是环境与场景之间的翻译官。
envAutoLink、sunAngle、timeOfDay、applyEnvPreset、setEnvState、gravity——它们的共同特征是:一只脚在场景里,一只脚在环境里。
放在 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 在这里,createClouds 和 disposeClouds 在这里。
最独立的一块——几乎只依赖 _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、回调注册表。但每扇门都有门框,每一次进出都有迹可循。
这不是分裂。
这是组织。
当城邦太多、议会太挤的时候,不要强行把它们塞进一个房间。给它们各自的房间,然后把门做好。
门外的人看不到门内的复杂。 门内的人不用忍受门外的拥挤。
这就是「八城之盟」的意义。
教训:拆分不是分裂,是给每个城邦自己的房间。