Appearance
打包之战
背景:构建 84 秒(3171 模块),环境按钮响应 2 秒——联邦在等待中慢性死亡。 过程:Babylon.js 外部化(74s→1s)+ 环境系统懒评估(perlin 缓存/梯度缓存/重建跳过,2s→20ms)。
人的耐心是 100 毫秒。这是 HCI 领域一个粗糙但靠谱的经验法则。任何超过 100 毫秒的操作,大脑就会标记为"卡"。
联邦的构建时间 84 秒。联邦的按钮响应 2 秒。
两个问题,同一个星期。
一、漫长的下午
npm run build。
然后去倒杯水。回来看到"3171 modules transformed"。
再倒一杯。还在打包。
再倒一杯。终于结束了——84 秒。那是 2026 年六月某天的下午,水喝了两公升,什么都没推到生产。
联邦的议会成员们开始觉得不对劲。
"我们没必要每次 Build 都等一座巴比伦城从零搭建吧?"
Babylon-mmd 城邦——联邦最早的加盟者,负责所有 PMX 渲染和 VMD 播放——它整座城邦的文件数量是联邦自身代码的 25 倍。每次 build,Rollup 都要遍历六千八百千字节的 Babylon.js,解析它的三千个模块,理解它们的依存关系,然后全部打包成一块。
这不是构建,是搬家。
与此同时,坐在议会大厅隔壁的环境委员会也有怨言:
"我点一下 '水面开关',等了 2 秒才出效果。而且第二次点,还是 2 秒。"
议会调出了执行日志。setEnvState 调用 → applyEnvState → createWater → disposeWater → new WaterMaterial → 重建。createClouds → perlin2 噪声循环 × 五十万次。createParticleEmitter → 销毁并重建 GPU 粒子系统。
一次用户点击"切换天空模式",联邦做的不是"换一张天空贴图"——而是拆掉整个环境、重建整个环境,包括所有无关的子系统。
"我们为什么要做这些事?"
"因为没有记录上一次的状态。"
"那我们为什么没有记录?"
"因为……这个架构就是这么写的。"
沉默。
联邦的代码里没有"偷懒"这个选项——每次看到 setEnvState 就全量重建,不管被改变的只是一个颜色值还是一整片云。
这不是 bug,这是设计惯性。
二、议会的两个委员会
两个问题并行推进了。
构建委员会
"Externalize。"议会首席在白板上写了一个词。
"什么意思?"
"不打包。把 Babylon.js 从联邦的 build 中移除,它不再是 bundle 的一部分。用户点开应用时,由外部 script 标签加载巴比伦城的完整 UMD 文件。"
议会成员交换了复杂的眼神——"不打包"三个字在建筑学里几乎是异端。打包不是基本功吗?不压缩就算了,连打包都不打?
"听我说完。现在每次构建都要跑 3171 个模块。其中 3000 个属于巴比伦城。如果我们把巴比伦城排除在外,联邦只需要打包自己的 127 个模块。"
"那 Babylon.js 怎么办?"
"用户在加载页面时,从 /lib/babylon.js 引入。8.3 MB,一次性加载,之后不再变动。"
"可那 8.3 MB 不就是当初我们放弃 CDN 回到本地打包的原因吗?"
"那是两个问题。"议会划了一条竖线,"加载时:8.3 MB 下载一次,可接受。构建时:8.3 MB 的代码每次都被 Rollup 重新解析一遍——不可接受。"
方案落定:
vite.config.ts中external排除@babylonjs/core和@babylonjs/materials- 生产构建输出
iife格式(因为 ES 模块格式下output.globals不生效) - 开发模式通过
transformIndexHtml插件自动移除 UMD script 标签——开发时由 Vite 直接从 node_modules 的 ES Module 源文件 serve index.html保留双 script 标签——生产时加载,开发时被移除
npm run build。
1 秒。
127 个模块。
议会看着终端输出,没有说话。3171 变成了 127,不是一个渐进的优化,是一个断崖——就像从一架需要四台发动机的战略运输机变成了单人滑翔翼。
运行委员会
"按钮 2 秒。"运行委员会主席用一个词启动了会议。
它打开 scene-env-impl.ts,浏览环境系统的核心函数。
第一眼就很清楚地看到了问题——perlin2,云的噪声生成函数,每次被调用时都创建一个 256 元素的排列数组,Fisher-Yates 打乱,concat 成 512 长度的 perm 表。这一切在每一次像素噪声计算中重复——createClouds 需要计算 512×512 个像素,每个像素调两次 perlin2——五十万次调用中每一次都重新创建排列数组。
"它为什么不缓存排列?"
"因为排列数组写在函数体里。"
"那不是写错了,那是……没写错但没想过这个问题。"
同样的故事在每一个角落重复。buildGradientTexture 每次调用时重新绘制 256×256 的 Canvas,编码为 PNG,生成 Texture。参数变了——重建是对的。参数沒变——重建是浪费。
createWater 每次都调用 disposeWater() 再创建新的 WaterMaterial,新的细分网格,新的渲染管线的注册流程。即使水面已经存在,即使只是调了一下透明度。
createParticleEmitter 每次都销毁 GPU 粒子系统再重建——粒子完全没变,只是用户调了个不相关的滑块。
"这不是 bug,是设计缺陷。"运行委员会主席的措辞很克制,"applyEnvState 没有状态意识。它每次被调用时都认为世界是被从头创建的。"
修复方案:
- Perlin 噪声常量化——
_perlinPerm作为模块级常量,只生成一次。perlin2不再需要分配和打乱数组,仅做噪声计算。512×512 云的生成从 1.5 秒降到约 20 毫秒。 - 梯度纹理缓存——
_gradientCache以参数哈希为键缓存已生成的Texture。天空切换从每次重绘 Canvas 变为直接返回缓存纹理。约 200 毫秒 → 0 毫秒。 - 重建跳过——
createWater、createClouds、createParticleEmitter、applyGround在资源已存在时跳过销毁重建,直接更新参数。
按钮响应时间:2 秒 → 约 20 毫秒。
三、两场战斗的同一面旗帜
构建委员会和运行委员会在周五的联席会议上同时交出了成果。
一个是 3171 到 127 的断崖,一个是 2 秒到 20 毫秒的陡降。技术上毫无关联——一个动了 Vite 配置和 HTML 结构,一个动了 Perlin 排列和 Map 缓存。但它们指向了同一个认知:
联邦之前不知道什么叫"不做"。
打包——不做——外部化,让巴比伦城独自运行。重建——不做——跳过,让环境系统只在需要时行动。
"聚合"的原始含义是"把更多东西收进来"。但联邦在这一章学会了反向动作:把东西推到外面,也是一种聚合。 不是在内部管理所有城邦的脾气,而是让城邦在自己的围墙内自治,联邦只记录它们提供的 API。
这不是分裂。这是行政边界的成熟。
四、散会后
议会收拾文件时,环境委员会的主席小声说了一句:
"其实我最喜欢的改动不是 2 到 20 毫秒。"
"是什么?"
"buildGradientTexture 的缓存键。用了七个参数:颜色 × 9 个分量、亮度、太阳角度、是否开启星星。把这个组合成一个字符串,作为 Map 的键,查找命中就直接返回之前画好的纹理。"
它停顿了一下。
"我第一次觉得联邦的代码……还记得自己做过什么。"
散会了。终端窗口里是 1.08 秒的构建输出,按钮不再等两秒才有回应。联邦没学会新技能,但它学会了"不做"。
教训:聚合不是什么都收进来,而是知道什么可以推出去。