Skip to content

水下的呼吸

背景:水下视角直接看到地面,水底没有白蓝交替的泳池光斑——colorCurves 后处理只调色、不衰减距离。 过程:焦散 emissive 注入地面(不依赖光照)+ scene.fog LINEAR 蓝色,只在穿越水面边界时切换一次。


桌面壳把相机沉到水面以下。

它想看看水下是什么样的。模型在水面上方跳舞,光线穿过水面——应该是那种透蓝的、迷蒙的、让人想屏住呼吸的颜色。

屏幕上是清晰的地面。

不是水底的地面,是"站在岸上也能看到"的那种清晰。草地的纹理、地面上的贴图纹路、远处的山丘轮廓,全都清清楚楚地摆在水面之下,像一块被压扁在玻璃下的标本。

"这不对。" 桌面壳说。

它调出了控制台。水下确实有后处理——colorCurves,一套色调曲线,把画面压暗、往蓝里偏了一点。但那只是"染色"。距离没有任何衰减:十米外的地面和一米外的地面一样清楚,只是颜色不同。

"水下应该看不清远处," 桌面壳说,"水是有厚度的。一厘米的水是透的,五十米的水是黑的。你现在的世界,像把整个世界泡进了一杯淡蓝色的糖水里,远近一个样。"

"你要的是雾。" 外交官说。


一、雾的命名学

桌面壳找到了 scene.fog

Babylon 的雾有三个模式——EXP、EXP2、LINEAR。桌面壳选了 FOGMODE_LINEAR,设了两个值:

typescript
scene.fogStart = 40.0;   // 距离 ≤ 40 unit(4 米)完全无雾
scene.fogEnd = 500.0;    // 距离 ≥ 500 unit(50 米)完全满雾

它设完,把相机沉下水,看了一眼效果。

"雾反了。" 桌面壳说,"40 米以内没雾,500 米外全雾——那角色站的地方倒是清楚了,可这跟我想要的不一样。我要的是 4 米内清晰、50 米外模糊,怎么两个值填出来正好相反?"

外交官凑过来看:

"你填的是 fogStartfogEnd,但 Babylon 的 LINEAR 雾,fogStart 是'无雾阈值',fogEnd 才是'满雾阈值'。命名直觉是反的。"

"你说什么?"

"你自己读着色器。"

桌面壳打开了 Babylon 的 fragment shader,找到了那三行:

glsl
float fog = (fogEnd - distance) / (fogEnd - fogStart);
color.rgb = mix(vFogColor, color.rgb, fog);

"fog = 0 的时候,mix 取的是 vFogColor——满雾。fog = 1 的时候,mix 取的是原色——无雾。而 fog = (fogEnd - distance) / (fogEnd - fogStart):距离越小,fog 越接近 1,也就是越无雾。所以 fogStart 这个名字骗了你——它不是'雾开始的地方',它是'无雾开始的地方'。"

桌面壳盯着那三行看了很久。

"一个叫 start 的值,其实是结束。一个叫 end 的值,其实才是开始。我在整个项目里写了那么多雾——这个命名陷阱,我今天第一次真正读懂了它。"

"你之前为什么没读?"

"因为它'能用'。我照着文档填,文档说 start 就是 start。"

"那这次为什么读了?"

"因为我要填它来骗我自己。" 桌面壳说,"我的角色站在 2 到 3 米的位置,我要保证他永远清晰——所以无雾阈值必须是 40 unit,比角色远一点点。这个数字我不能再猜了,我必须知道它到底代表什么。"


二、光的背叛

雾有了。远处的地面开始褪入深蓝。

但桌面壳看着近处的地面,还是觉得少了什么。

"泳池。" 它说。

"什么?"

"你去过泳池吗?水底有那种——白蓝交替的、晃动的光斑。阳光穿过水面的波浪,被折射成一张流动的网,投在池底。那是'我在水下'最直观的证据。现在我的地面是干净的、平整的、没有光斑的。它不像水底,像一块被浇了蓝色漆的水泥地。"

"光斑需要光照。" 外交官说,"而你的地面材质,emissiveTexture 走的是自发光通道——不依赖场景里的任何灯光。你想要的那种光斑,恰恰是最难给你的:它不是'照'出来的,是'画'出来的。"

桌面壳试了第一条路:给地面挂一盏跟随相机的灯,让光斑作为灯光投影。

它失败了。原因很朴素——场景里已经有十几盏灯,地面材质的 emissive 通道根本不吃灯光。加灯只能让阴影更乱,光斑却一个都没出现。

"我在跟一个不听命令的通道说话。" 桌面壳说,"emissive 意味着'我自己发光'。灯光照不亮一个自己发光的东西。"

"那就别让光照它。" 外交官说,"你想想,水底的光斑是什么?"

"是折射。"

"对。是太阳穿过水波,被每一道波峰聚成一个亮斑。它不依赖地面的材质,不依赖场景的灯光——它只依赖'水'和'光'这两件事。你要的不是给地面打光,是在地面上画一张流动的网。"


三、一张流动的网

桌面壳找到了焦散。

env-caustics——一张程序化的焦散纹理,淡蓝白:

typescript
const CAUSTIC_TINT = new Color3(0.78, 0.92, 1.0);

它把这张纹理塞进地面材质的 emissiveTexture,颜色设为 CAUSTIC_TINT。地面亮了——水底那种白蓝交替的光斑,真的出现了。

"它不依赖光照," 桌面壳说,"因为它是自发光。焦散纹理自己在动——causticsController.update(dt) 每帧推进纹理的 uOffset,光斑就在水底缓缓流动。我没加一盏灯,没改一个光源,只换了一张贴图。"

"光斑有了。" 外交官说,"但你看,光斑太粗了。"

桌面壳蹲下来看。地面上是一格一格的巨大亮斑,每一格差不多 62.5 unit——六米多宽。而水面上对应的焦散,每一格只有 6.67 unit,半米宽。两者差了快九倍。

"地面上的光斑和水面上的光斑,不是同一张网。" 外交官说,"同一个泳池,水面上是细密的波纹,池底却投下巨大的光斑——用户一眼就看出来不协调。"

桌面壳翻开了旧的代码。焦散 UV 的重复次数被写死成 8

"八个重复,配 500 unit 的地面,一格就是 62.5 unit。我写死这个数字的时候,压根没想过它要跟水面上的焦散对得上。"

它改成从世界尺度派生:

typescript
const groundCausticScale = (envState.groundSize ?? 500) * CAUSTIC_WORLD_SCALE;
causticTex.uScale = groundCausticScale;
causticTex.vScale = groundCausticScale;

水面上是 camXZ * 0.15——用世界坐标直接采样。地面上是 groundSize * CAUSTIC_WORLD_SCALE——同一个系数派生。两张网,现在用同一把尺子量了。

"六米变半米," 桌面壳看着光斑,"地水终于同尺度了。光斑在水底流动,水面也流动——它们是一张网,只是投在了两个平面上。"


四、边界

一切都对的时候,桌面壳做了一件蠢事:它关掉了水面。

水面没了,相机还在水下。

"水面关掉后不恢复。" 桌面壳看着屏幕——雾还在,光斑还在,整个世界还泡在水里。

"你把水面关了," 外交官说,"但'水下'这个判断只看一件事:相机 Y 坐标比水面低。水面没了,你那个'水面 Y'还留在原地,相机潜下去,照样判定为水下。"

"门没关。"

"门没关。"

桌面壳找到了那个判断:

typescript
const depth = this._waterLevel - cam.globalPosition.y;
const isUnderwater = gate && depth > 0;

gate 是后来补上去的一把锁:

typescript
const gate = envState.waterEnabled && (envState.underwaterEnabled ?? true);

"没有 waterEnabled 这把锁," 桌面壳说,"水面都关掉了,我还要给地面喷蓝雾、投光斑——这不是'水下视觉',这是'世界泡水了'。"

"你关掉水面,是要回到岸上的世界。岸上没有雾,没有光斑,地面是它原来的颜色。"

桌面壳看着 reset() 里那一行还原逻辑:

typescript
entry.mat.emissiveTexture = entry.origEmissiveTex;
entry.mat.emissiveColor = entry.origEmissiveColor;

"入水的时候,我把每个地面的原始材质快照下来;出水的时候,一张一张还回去。焦散是借来的——借了就要还,不能留在岸上。"


五、只在边界上呼吸

现在,桌面壳把一切看清楚了。

它不每帧给材质赋值——只在 isUnderwater 状态翻转的那一帧,切换一次 fogMode、写一次 emissive。原因写在代码注释里:

关键性能约束:仅在"穿越水面边界"时切换一次材质,绝不每帧赋值——否则 Babylon 会因材质脏标记每帧重编译着色器。

"水下视觉不是一个每帧都在跑的特效," 桌面壳说,"它是一次边界事件。我在入水那一帧呼吸,出水那一帧呼吸,中间的水里——什么都不做。越深的系统,越知道什么时候该安静。"

它看了一眼最终的数字:

  • FOG_NEAR_CLEAR = 40.0:4 米内完全无雾,角色 2 到 3 米永远清晰
  • FOG_FAR_OPAQUE = 500.0:50 米外完全满雾,远景褪入深蓝
  • UNDERWATER_FOG_BASE = (0.35, 0.58, 0.72):浅青色,还混着 0.4 的天空底色——从水下往上看就是天空

"雾色要跟天空走。" 桌面壳说,"因为人在水下抬头,看到的就是天。水的颜色不能跟天无关。"


尾声

桌面壳把相机沉下去,再浮起来,再沉下去。

每一次穿越,地面都安静地接住那张流动的网;每一次浮出,地面都如数归还自己的颜色。水面之上是舞蹈的模型,水面之下是缓缓呼吸的蓝。

它忽然想到一件事:水下的世界,不是另一种世界。它是同一个世界,隔了一层介质的厚度。

雾不是给远处盖上的布——雾是距离的诚实。焦散不是给地面画的花——焦散是光穿越波浪的脚印。你看到什么,取决于你隔着什么在看。

桌面壳把相机停在刚好没过水面的位置。一半是岸上的清晰,一半是水下的迷蒙。

"联邦的每一层功能," 它说,"都像这一层水面——你不读它下面的实现,就永远不知道,自己到底隔着什么在看。"


教训:fogStart 是"无雾开始",fogEnd 才是"满雾开始"——名字会骗人,着色器不会。

你看见的每一片雾,都是距离在说真话。