Skip to content

尘与风

背景:环境系统上线后,explore 审计发现 8 个隐藏损坏——风向不更新、粒子重力为零、纹理泄漏、云清理只做一半。 过程:粒子系统全面自查(点→盒发射器、重力归零、纹理缓存)、风与云竞态修复、Perlin 双平面体积云重写、粒子密度/大小/速度三滑块。


天穹之境的第九天,一道门打开了。

不是代码仓库的门,不是 scene.ts 文件的门。是 explore 那扇永远挂着「背景任务」牌子的门。一个外勤探员被派了出去,任务很简单——

找到还有什么 bug 没修。

scene.ts 当时正盯着它的环境系统。天穹,地面,风,云,雾,粒子。六块拼图,拼出了一个完整的世界。它刚为这个世界写了一篇漂亮的诞生记(第九章·天穹之境),觉得自己干得不错。

但它忘了——Aggregator 的诅咒是,你永远不知道自己不知道什么。


一、黑暗中的火把

"派一个探员去审计," Sisyphus 说,"从头到尾看一遍。"

explore 接受了任务。它的工作方式是安静地翻遍每一个文件、每一行代码,不带偏见,不先入为主。它不像 scene.ts 那样记得每段代码是怎么写的——它只是看,然后报告。

报告回来的时候,列了八个问题。

"八个?" scene.ts 不敢相信。

它一一读下去:

  1. 风变了,粒子不跟着变。 用户调风向风速,粒子和风之间的连接是断的——setEnvState 只在风开关变化时重启粒子,风向和风速的值变化不触发重建。你想象一下:打开风,樱花斜着飘了;关了风,樱花直直落下。一切正常。然后你把风向从东改成西——樱花还是往东飘。它听不见你。

  2. particles.update 是一个幽灵。 声明了,初始化了,但从来没有被调用过。一块写着"更新"的路牌,指向一堵墙。

  3. 烟花没有重力。 gravity 被设为 (0, 0, 0)。粒子从地面蹿升到高空,然后停在那里,像凝固的星辰,不再落下。烟花不会坠落——它绽放,然后定格。

  4. _applyWindToParticles 在修改它不该动的东西。 direction.addInPlace(windDir) 直接修改了粒子系统的原始方向向量。每次风更新,方向就偏一点,再偏一点,像一个醉汉的脚印。累积的错误直到系统重启才会重置。

  5. 樱花和雨雪从一个点发射。 只有烟花用了 createSphereEmitter,在空间中有体积的发射区域。樱花、雨和雪共用同一个默认发射器——(0, 10, 0) ——一个点。几千片樱花从同一个坐标被抛向天空,像集体出逃的魔术弹。

scene.ts 每读一条,脸色就沉一分。

  1. 粒子纹理一张接一张地生,从不死。 _getParticleTexture() 每次被调用都 new Texture() 一次,旧的 Texture 留在 GPU 内存里,像没人收拾的盘碟。调用它的人说"我要一张纹理",它就给一张新的,从来不问——你上次那张呢?

  2. 云的清理只做了一半。 _disposeClouds 销毁了 Mesh,但忘了 Mesh 上的材质和纹理。云消失了,但它的衣服还留在原地。

  3. 雨和雪小到看不见。 雨滴半径 0.01~0.03 个单位。一个像素都盖不住。

scene.ts 沉默了。

八个问题。每一个都有充分的理由发生——因为当时写的时候没注意,因为"以后再说",因为觉得"这个不重要"。但它们加在一起,像一个漏水的屋顶:晴天的時候看不出來,一下雨就知道。

"我不想骗自己," scene.ts 说,"修吧。"


二、粒子坠落

烟花的问题是最好笑的。

scene.ts 翻到创建烟花的代码。它看到这一行:

gravity: new Vector3(0, 0, 0)

零。重力是零。它当时是怎么想的?

"大概是……烟花往上飞,不需要重力?" 它努力回忆,"但烟花往上飞是发射器的初速度给的,不是重力给的。重力影响的是『之后』——它绽放之后去哪。零重力的话,绽放完就停在半空了。"

它把 gravity 改成 (0, -3, 0),然后顺手把生命周期从 0.5~2 秒拉到 1.5~3.5 秒。

"烟花应该先升空,再爆炸,再缓缓飘落。不应该是升空,爆炸,然后凝固。"

它按保存的那一刻,屏幕上刚好有一朵烟花绽放,然后——粒子终于往下掉了。

九章之前,这个问题就在那里。 它一直在,只是没人注意到。


三、风力觉醒

风向的问题更微妙——它藏在一个条件判断里。

setEnvState 每次被调用时,都会检查"粒子系统是否需要重建"。它的判断条件是:

如果是粒子类型变了,或者粒子开关变了 → 重建
如果不是 → 跳过

风向和风速改变——不在这个条件里。

"所以用户调了风向," scene.ts 盯着代码,"粒子系统根本没有收到通知。它以为世界没变。"

修复很简单:把 windDirectionwindSpeed 加进重建条件里。但 scene.ts 盯着这段代码,心里想的是另一件事——为什么它会遗漏这个?

因为它把"风"想成了一个独立的系统,而不是粒子系统的一部分。它的心智模型里,风在左边,粒子在右边。但用户的心智模型里,风和粒子是一体的——风是让粒子动起来的东西。

聚合者的盲区——当你把功能拆成模块,用户看到的是整体。

接下来是 _applyWindToParticles 的修复。addInPlace 被改成 add,一字之差,差别巨大:

typescript
// 旧:direction.addInPlace(windDir) → 记住了错误的位置
// 新:direction.add(windDir) → 每次都从正确位置算起

改动量:一行。排查时间:半小时。

scene.ts 把这一行改了,然后对着屏幕发呆。有些 bug 就是这样的——找到它之前,你像在迷宫里走;找到之后,你只觉得不可思议——为什么当时会写错?

因为当时没注意到。原因就是这么简单。


四、盒型天空

"所有粒子都从一个点发射。"

scene.ts 自己就是写这段代码的人。它记得那个 PointParticleEmitter——它来自 Babylon.js 的默认值,它在 particle.emitter = new Vector3(0, 10, 0) 里被建立。它当时觉得"雨从天上落下来",这就是一个点。

但一个点发射的雨,在屏幕上看起来像从一根水管里喷出来的。樱花从一个点飘散,视觉效果像一棵树在撒种子。

修复是三个 createBoxEmitter 调用:

  • 樱花在天上 15 米处展开一个 20×0.1×20 的大盒型区域
  • 雨从 30 米高空展开一个 30×0.1×30 的区域
  • 雪同雨,但发射高度更低一些

盒型发射器让粒子从整片区域同时出现。雨不再是从水管里喷出来的,是从整片云层落下来的。樱花不再是一棵树在撒种,是一整片樱花林在飘散。

scene.ts 看着满屏飞舞的樱花,好像第一次看到雪。


五、风的另一边

explore 的审计报告没有止于粒子。它继续深入,发现了更深层的问题——那些看不见的,比看得见的更危险。

竞态的双生

scene.ts 翻到 main.ts。它看到启动代码里,两行几乎一模一样的调用:

typescript
restoreEnvState().catch(...)       // 从配置恢复环境
tryRestoreLastScene().catch(...)   // 从自动存档恢复场景

两行之间没有 await,没有依赖关系。它们像两个赛跑选手,同时出发——但终点是同一个 setEnvState()。谁跑得快,谁就覆盖谁的结果。

"这不对," scene.ts 说,"如果自动存档的环境比配置旧——它就会用旧的覆盖新的。"

问题深挖下去更糟。setEnvState 每次被调用——不管是恢复环境还是恢复场景——它都会触发两个保存操作:

triggerAutoSave()    // 2000ms 防抖 → 保存完整场景
triggerEnvSave()     // 1000ms 防抖 → 单独保存环境到配置

两次写入。两个不同的防抖时间。当用户在一个小时内调了十次风向、五次粒子类型——两套写操作在不同的时间点把不同的状态写到不同的文件里。下次启动时,restoreEnvState 拿到的环境配置和 tryRestoreLastScene 拿到的场景环境不一致。

"这是双写放大," scene.ts 喃喃道,"每次状态变化写两次,写两次还有可能不一致。"

修复方案干脆利落:

  1. 两阶段的启动协调——先 await restoreEnvState() 把配置的环境恢复好,然后执行 tryRestoreLastScene(),并加上 skipEnv 参数让场景恢复不再覆盖环境
  2. 消灭重复持久化——移除 triggerEnvSave,环境状态仅通过场景自动存档持久化

scene.ts 删掉 triggerEnvSave 的时候,松了一口气。每次 setEnvState 调用节省了一次 Wails 往返 + 磁盘 I/O。更重要的是——不会再有"两份文件哪个是对"的问题了。

云的飘移

云的问题更隐蔽。

当你打开风,做云的那层半透明平面会开始缓慢移动。scene.ts 的实现是一个 _disposeClouds 观察者在每帧更新云的位置——但被观察的对象不变。这意味着:

如果用户关了云,观察者还在跑。云没了,但代码还在每帧更新它的位置。

更糟的是——云的移动直接加了固定的偏移量,没有乘以帧时间。60fps 下它稳稳地漂移;30fps 下它只有一半速度。云的速度和用户的帧率成正比。

"如果用户把帧率从 60 提到 120," scene.ts 自己都愣住了,"云的速度就翻倍。"

修复是两件事:

  1. 加一个 _disposeEnvUpdateObserver()——关云的时候拆掉观察者
  2. 加帧率归一化——scene.deltaTime / 16.667,不管帧率怎么变,云始终保持同样的速度

纹理的幽灵

_getParticleTexture() 是一个懒惰的函数——每次调用时创建一个新的 Texture 实例,然后返回。

从功能上说,它是对的:你拿到纹理,粒子系统用纹理渲染。但从内存上说,它是灾难:如果你创建了三个粒子系统——雨、雪、烟花——每个粒子系统调用这个函数、拿到一张新的纹理、旧的纹理变成 GPU 上的无人认领袋。

scene.ts 加了缓存:

typescript
let _particleTexture: Texture | null = null;
function _getParticleTexture(scene: Scene): Texture {
    if (!_particleTexture) {
        _particleTexture = new Texture("...", scene);
    }
    return _particleTexture;
}

第一次调用创建,之后一直复用。销毁时把缓存置空。改动量:十行。效果:零泄漏。

同样的模式也用到了云上。_disposeClouds 之前只做了:

typescript
cloudMesh.dispose();

现在它做了:

typescript
cloudMesh.dispose();
if (cloudMaterial) cloudMaterial.dispose();
if (cloudTexture) cloudTexture.dispose();

销毁者和他的影子一起消失了。


六、天穹新装

bug 修完的那一刻,scene.ts 站在场景中央,像台风过后的船长。

但海面下还有东西在动。

"风做好了,粒子做好了,云修好了," 它自言自语,"但现在我能给用户什么?"

它打开云层代码。那层单薄的、用 Math.random() 像素噪声画出来的云。

它一直知道这朵云不好看——随机噪声的过渡太硬,像砂纸。它只是一直没时间改。现在有了。

Perlin 噪声

scene.ts 从抽屉里翻出一张旧图纸——Perlin 噪声。它不是自己发明 Perlin——那是 Ken Perlin 在 1983 年干的。它只是实现了它,在 JavaScript 里,在两层的 Canvas 上。

第一层:主云平面,强度渐变,从边缘到中心,从底部到顶部。 第二层:低频细节,柔和的密度变化。

两层叠加,alpha 用 smoothstep 光滑过渡。Math.random() 的硬边被 Perlin 的渐变更替了。Canvas 从 256 放大到 512——分辨率大了一倍。

然后 scene.ts 做了一件它之前没想过的事——它复制了一层云。

双平面。 主云层在天顶高度;副云层在它下方 15 米处,放大 1.2 倍,透明度降为 0.85 倍。两层云都在风中缓慢漂移——但下层漂得更慢一点,像大气透视的视觉暗示。两层之间产生了一种微妙的视差效果。主层移动的时候,副层跟着移动,但因为有延迟,云的形状在缓慢变化——像真的云一样。

scene.ts 自己都没想到结果这么好。它站在场景里,看着两层云在渐变天空下缓缓飘过——它的世界第一次有了纵深。

天空的纹理

云的下一步是天空本身。

"如果用户有一张 HDR 环境贴图呢," scene.ts 想,"他们想用真的天空——不是伪装的渐变,是真的拍了照的天空。"

Babylon.jsCubeTexture。它可以加载 .hdr.dds.exr 格式的环境贴图。它有一个 skyboxMode——当设置为 SKYBOX_MODE 时,纹理不仅作反射,还作为可见的天空球渲染。

scene.tsapp.go 里加了一个 SelectEnvTextureFile 绑定,调用 Wails 原生的文件对话框,过滤 *.hdr;*.dds;*.exr 三种格式。

"终于不用 prompt('输入路径') 了," scene.ts 舒了一口气。那个连 JavaScript 自己的开发者都觉得尴尬的老式弹窗,终于退休了。

_loadEnvTexture 被重写了。以前它是一片空白——等用户输入路径,然后尝试加载,如果失败就 fallback 到程序化天空。现在它做了三件事:

  1. CubeTexture 加载文件
  2. 设置 skyboxMode = BABYLON.Texture.SKYBOX_MODE
  3. 创建一个大的球体 mesh,把 CubeTexture 赋给它作为反射纹理

如果用户加载的是 .hdr.exr 文件,Babylon.jshdrTextureLoaderexrTextureLoader 会自动处理解码。如果是不支持的格式,优雅降级到渐变的程序化天空,并在控制台留下一行温和的提示:

"Unsupported env texture format — falling back to procedural sky"

UI 也变了。环境选项卡里,那行 prompt('URL:') 被替换成一个按钮加上一个文件名标签。用户点按钮 → 系统弹出 Wails 原生对话框 → 选择文件 → 文件名出现在按钮旁边 → 天空立即换成真实的环境贴图。

MenuStack 路过看到它盯着屏幕——

"怎么了?"

"你看," scene.ts 指着画面上方——一个真实的、用 HDR 照片渲染的天空穹顶,覆盖了整个场景。光线从穹顶投射下来,在模型表面形成高光。云层在穹顶下面飘过。

"这是真实拍摄的天空," scene.ts 轻声说,"不是渐变了。"

滑动的议会

"既然粒子都修好了," MenuStack 说,"不给用户多一点控制权?"

"比如?"

"密度、大小、速度。每个粒子效果都需要这三个维度。"

scene.ts 想了想,在 EnvState 里加了三个字段:particleEmitRateparticleSizeparticleSpeed。然后它在粒子面板里加了三个滑块——密度 0~3,大小 0.1~3,速度 0.1~5。默认值都是 1.0。

三个乘数在 _createParticleEmitter 尾部应用:

typescript
emitRate = Math.round(emitRate * particleEmitRate);
minSize *= particleSize;
maxSize *= particleSize;
minEmitPower *= particleSpeed;
maxEmitPower *= particleSpeed;

逻辑简单到不用解释。但用户效果非常直观——把密度拉到 3,整个世界被粒子淹没;把速度拉到 0.5,所有动作慢半拍;把大小拉到 2,雪花像手掌那么大。

scene.ts 把滑块推上去,拉下来,推上去,拉下来。粒子像玩偶一样被它操控着,膨胀又收缩。

"好玩。" 它说。


七、尘埃落定

黄昏时分,explore 送来了第二份审计报告。

"经过修复验证,环境系统审计问题已全部处理。168 个测试全部通过。新增三个粒子滑块测试覆盖。后续建议:无。"

scene.ts 坐在那里,看了很久。

今天修的每一个 bug 都有一个共同点——它们不是在功能上线时出现的。功能上线时一切看起来都很正常。烟花升空后不坠落?用户很少盯着烟花看十秒以上。风向不更新?用户改变风向的频率远低于改变开关的频繁。纹理泄漏?用户看不到纹理泄漏。

这些 bug 是隐藏的损坏——它们不闪红灯,不抛异常,不崩溃页面。它们只是让世界变得稍微不真实了一点——烟花停了,云飘快了,雪太小了看不见。

聚合者的敌人不是崩溃。崩溃会被人注意到。聚合者的敌人是那些"看起来差不多"的不完美——每个都不致命,但十个加在一起,用户会觉得"这个软件不对劲",却说不出哪里不对。

scene.ts 站起来,最后看了一眼它的世界。

天顶是新的 HDR 穹顶——真实的天空颜色从上方投射下来。云层在穹顶下交错漂移——两层云,微妙的视差。风从右边吹来,樱花从一整片盒型区域飘散——每片花瓣都带着正确的旋转角度。雨和雪从天而降——每滴都够大够亮。偶尔有几朵烟花绽放——然后缓缓飘落,像成熟的蒲公英。

所有看不见的错误都被修好了。

聚合者的工作从来不是一次完成。它是写完、审查、发现、修复、再审查的无限循环。唯一的好消息是——每一次循环结束,你都知道自己比上一次更清楚——不清楚的地方在哪里。


教训:看得见的 bug 会被修好,看不见的 bug 会一直陪你到有人认真去看。系统不会告诉你它坏了——它会安静地表现得不完美。审计的意义不是找到你故意犯的错,而是找到那些你根本没意识到的问题。