Skip to content

云层协议

背景:体积云三种方案连败——PostProcess 全屏白、ShaderMaterial 球体雾气球、固定步长导致同心圆环伪影。 过程:抖动打破相干性 → 固定种子消除闪烁 → cloudGap 参数分离"频率管大小,阈值管疏密"。凌晨三点,云终于像云了。


楔子

凌晨一点四十七分。

MikuMikuAR 联邦的第七十二次架构会议,在所有人都不情愿的深夜中开始了。会议室的光线调到最低档,屏幕上只有一块裸露的灰色球体。

球体上什么都没有。

"这是在渲染云层?"首席产品经理陈眠打了个哈欠。

"这是渲染失败。"Riku 把控制台的日志拍到大屏幕上,全屏都是红色的 isReady: false

"三个方案。死了一个,一个半死不活,第三个还没生出来。"

他顿了顿。

"我们今天晚上就在这儿,把云层协议写到能看为止。"


第一章:后处理陷阱

如果时间倒回六个小时,Riku 还信心满满。

"PostProcess,"他在白板上画了一条横线,"全屏四边形,片段着色器里走 Ray Marching。Vite 不干涉,Babylon 不报错,Wails 不崩溃。完美。"

他花了十五分钟写完着色器。一百二十行 FBM 噪声,Henyey-Greenstein 相位函数,双散射近似。编译。构建。运行。

屏幕全白了。

不是云的那种白——是"你的应用死了"的那种白。

"alpha 混合没开。"他冷静地加上 alphaMode = 0。构建。运行。

全白。

"手动混合。"他在着色器里采样 textureSampler,手动计算 scene.rgb * (1-alpha) + cloud.rgb * alpha。构建。运行。

还是全白。而且这次连按钮都被白色淹没了,UI 像是沉入乳白色海洋的沉船残骸。

"别试了,"测试部的老林端着咖啡走进来,"PostProcess 路径废弃。DefaultRenderingPipeline 加 WebView2 的兼容坑,你挖不完的。"

Riku 盯着屏幕看了十秒,删掉了 PostProcess 的整个文件。

"联邦宪法第一条,"他说,"止损要及时。"

陈眠点点头,在笔记本上记了一笔。六个字。他想。用户也会遇到类似的问题——明明投入了很多,但继续下去的成本比回头还高。


第二章:球体内部的宇宙

下一个方案来得很快——ShaderMaterial + 球体。

"用 MeshBuilder.CreateSphere,直径随相机远平面自动放大,双面渲染,摄像机在球体内部看云层。"

"你确定?"陈眠问。

"确定。球体内表面当画布,Ray Marching 的方向从摄像机指向球面顶点。物理上是对的。"

十分钟后,控制台输出了一行好消息:

[VolCloud] ShaderMaterial isReady: true

然后他们看到了一个半透明的、均匀的灰色球壳。

"这不是云,"老林说,"这是一个雾气球。"

"我知道这不是云。"

"看起来像你用一个灰色马克笔在天空画了个圈。"

"我说了我知道!"

Riku 深吸一口气。十分钟后,世界矩阵修正完毕。十分钟后,距离上限改为可配置参数。

云层终于出现了——但是一整块巨大的、覆盖整个天空的白色棉被。

"太大了。"陈眠说。

Riku 把噪声频率从 0.006 提到 0.015。云团缩小了一点,但球体本身像一个巨大的月亮挂在视野里,怎么调都是错的。

"问题不是频率,"他突然明白了,"是球体本身在渲染。低密度区域的 alpha 太低,但片段没有 discard,整个球体都是可见的。"

他加了一行:

glsl
if (gl_FragColor.a < 0.008) discard;

球体消失了。云朵留了下来。

会议室安静了两秒。然后老林吹了声口哨。

陈眠看了眼屏幕边缘残留的一圈淡淡的边界线,没有说话。


第三章:三十二道圆环

云有了,但陈眠的头歪向一边。

"你看看头顶。"

Riku 抬头。屏幕上,以摄像机正上方为原点,三十二个极细的灰色圆环正在缓缓扩散——像把石子扔进湖里,但湖面结了冰,波纹冻在了那一帧。

"……什么鬼。"

他把 cloudCover 从 0.3 拉到 0.8。圆环纹丝不动。

"厚度 30 以下会出现,500 以上就看不见了。"陈眠已经站起来凑到屏幕前了,"不是云,是……采样网格?"

Riku 打开着色器,盯着 raymarch 循环看了三十秒。

glsl
for (int i = 0; i < CLOUD_MAX_STEPS; i++) {
    float t = dt * float(i);
    // ...
}

固定步长。固定步长!每一步的起点是 dt * i,所有像素的采样点都落在以摄像机为原点的同心球壳上。噪声是连续的,但采样点是离散的、相干的——这就是圆环的来源。

"要打破相干性,"他说,"给每个像素一个随机偏移。"

陈眠歪头看着那些圆环。"如果用户看到这些,会以为我们在故意画靶心吧。"

"那比他们看不到任何东西要好。至少这是可调试的。"


第四章:闪烁的云

他在 main() 里加了每行像素的随机抖动:

glsl
float jitter = hash13(vec3(gl_FragCoord.xy, time * 60.0));
float startT = jitter * dt;

编译。运行。

圆环没了。但云层在抖——不是风吹的那种飘,是每一帧都在细微地闪烁,像信号不好的老电视。

" jitter 值每帧都在变,"陈眠皱眉,"时间依赖的随机,等于每帧重新随机一次。"

Riku 盯着屏幕。闪烁的节奏和帧率完全同步——60fps 就是每秒 60 次重新随机。

"固定种子。"他把 time * 60.0 改成 1.0

编译。运行。

闪烁停了。圆环也没了。但云层变得……糊了。

"你为了灭闪烁把噪声拉满了,"陈眠说,"每像素的抖动方向完全不同,等于在给噪声做模糊。"

"那就是步数和步长的问题了。"Riku 把步长从 5.0 改回 8.0,步数从 360 砍到 200,"采样密度降下来,性能回去,清晰度回来。"

构建。运行。

云层清晰了。帧率也回来了。

"好了,"Riku 靠回椅背,"圆环、闪烁、性能,三个问题一起灭了。"

老林在旁边记了一笔。

"记什么?"

"记一条新宪法:抖动用固定种子,时间变化交给 wind,不要交给 jitter。"

"这句话我要写进设计文档,"陈眠说,"不是给开发看的,是给未来的用户——如果他们要调这里面的参数,这是最重要的原则。"


第五章:棉被与云隙

云是好云。但太密了。

"像一床棉花被,盖在整个天空上。"陈眠比划了一下,"我想要的是一朵一朵的,之间有蓝天。"

Riku 调了 cloudCover。从 0.5 拉到 0.2。变化有,但不够——云变薄了,空隙没有明显变大。

"问题在哪儿?"

他打开 getDensity 函数,逐行检查。

glsl
float n1 = fbm(p * 0.005 * cloudScale);
float n2 = fbm(p * 0.015 * cloudScale + 33.0);
float n3 = fbm(p * 0.05 * cloudScale + 66.0);

"频率太低,云朵太大,覆盖范围太广,调 cover 只是在把整片云一起调淡……"

"那你调高频率?"

"调高频率会让云朵变小。你不想要更小的云,你想要的是——同样的云朵大小,但之间有更大的空隙。"

陈眠想了想。

"那就是两件事。频率管云朵大小,阈值管疏密。分开调。"

Riku 看了他一眼。

"你适合写文档。"

陈眠耸肩。"我只是问了一个产品问题:用户想要什么。"

"你想要什么?"

"我想要天是蓝的,"陈眠说,"云是白的,中间有缝。这三件事,用户能感知到的是第三件。空隙是体验,频率和阈值是手段。"


第六章:云隙协议

cloudCovercloudGap 成为了两个独立参数:

  • cloudCover:云量,控制阈值 threshold = 1.0 - cloudCover,越低空隙越大
  • cloudGap:云隙,对着色器里的噪声输出做对比度拉伸 pow(n, 1 + cloudGap * 3.0),把中值推向 0 或 1,空隙被强行拉大,但云朵的空间频率完全不变

"这就是云层协议,"Riku 说,"频率管大小,阈值管疏密,对比度管间隙。三个旋钮,各管各的。"

"如果以后有人把这三个拧混了怎么办?"

"那就是下一轮调试的内容。"

陈眠看了眼时间。凌晨三点十二分。

"提交吧。"

Riku 在提交信息里打了两行:

feat: volumetric cloud — ring artifact fixed via fixed-seed jitter,
  cloudGap param added for controllable spacing

Push 成功。CI 通过。


章尾:收编的城邦

本次收编的城邦ShaderMaterial(Babylon.js 的着色器材质系统)

引入的边界

  • 球体直径必须 ≥ cloudVisibility * 2,否则远处的云被球体几何裁剪
  • precision highp sampler3D 必须显式声明,否则部分 GPU 上着色器编译失败
  • 抖动必须固定种子,时间闪烁比圆环伪影更难以忍受
  • 频率与阈值必须作为独立参数暴露给 UI,不能合并成一个 cloudCover

聚合的代价:球体方案让云层"像云了",但球体本身是一个 hack——摄像机永远在球体内部,一旦摄像机超出球体范围(比如未来的无限平面地形),这个方案就要重写。收编一个城邦,就引入它的脾气。


「联邦的云层,终于像云了——一朵一朵的,之间有蓝天。」

—— MikuMikuAR 开发日志,2026 年 6 月 30 日


教训:频率管大小,阈值管疏密,两件事不能合并成一个旋钮。

但真正重要的,是第三件事。

云的存在,不是因为它本身——而是因为云有缝隙。光要照进来,就必须有云;云要让人看见,就必须有缝。没有缝隙的云是雾,没有云的缝隙是晴空万里,什么都没有。

遮蔽与显现,是同一件事的两面。模块之间的间隙,不是缺陷——是光进来的地方。

这是联邦的云层协议,也是联邦本身的隐喻。