Appearance
云层协议
背景:体积云三种方案连败——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 看了他一眼。
"你适合写文档。"
陈眠耸肩。"我只是问了一个产品问题:用户想要什么。"
"你想要什么?"
"我想要天是蓝的,"陈眠说,"云是白的,中间有缝。这三件事,用户能感知到的是第三件。空隙是体验,频率和阈值是手段。"
第六章:云隙协议
cloudCover 和 cloudGap 成为了两个独立参数:
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 spacingPush 成功。CI 通过。
章尾:收编的城邦
本次收编的城邦:ShaderMaterial(Babylon.js 的着色器材质系统)
引入的边界:
- 球体直径必须 ≥
cloudVisibility * 2,否则远处的云被球体几何裁剪 precision highp sampler3D必须显式声明,否则部分 GPU 上着色器编译失败- 抖动必须固定种子,时间闪烁比圆环伪影更难以忍受
- 频率与阈值必须作为独立参数暴露给 UI,不能合并成一个
cloudCover
聚合的代价:球体方案让云层"像云了",但球体本身是一个 hack——摄像机永远在球体内部,一旦摄像机超出球体范围(比如未来的无限平面地形),这个方案就要重写。收编一个城邦,就引入它的脾气。
「联邦的云层,终于像云了——一朵一朵的,之间有蓝天。」
—— MikuMikuAR 开发日志,2026 年 6 月 30 日
教训:频率管大小,阈值管疏密,两件事不能合并成一个旋钮。
但真正重要的,是第三件事。
云的存在,不是因为它本身——而是因为云有缝隙。光要照进来,就必须有云;云要让人看见,就必须有缝。没有缝隙的云是雾,没有云的缝隙是晴空万里,什么都没有。
遮蔽与显现,是同一件事的两面。模块之间的间隙,不是缺陷——是光进来的地方。
这是联邦的云层协议,也是联邦本身的隐喻。