Skip to content

在浏览器里造开放世界,第 26 部分:做水的三种方法

作者:Oleg Sidorkin,Cinevva CTO 和联合创始人

刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。

第 25 部分给 avatar 穿上了衣服。这一部分讲水,而且是三个 spike,因为水正是那种地方:一个廉价的捷径和正确的答案在一张截图里看起来一模一样,运动起来却完全不同。Spike 51 用屏幕空间那套办法做反射,那个诱人的办法,然后直直撞上它自带的局限。Spike 52 换成每个已发布游戏实际上都在用的方法。Spike 53 塞进一个做完的水库,看看"做完"离我们现在这儿有多远。三个共用同一个折射层,所以前两个之间唯一变动的变量就是反射怎么算出来的。

用你已经有的那块屏幕做反射

在新标签页打开 Spike 51 ↗ · 看源码

屏幕空间反射复用你已经渲染好的那一帧。对每个水像素,你把视线沿水面反射,让那条反射光线 march 穿过深度 buffer,当光线越过一个记录在案的表面后方时,你就找到了水反射的东西,直接从颜色 buffer 里采样。这个移植逐行照着 three.js 的 SSRNode,那一行又照着 lettier 的 SSR 入门。这个 march 是屏幕空间里的一次 DDA 行走:把光线的起点和终点投影到像素坐标,沿着更长的那条轴步进,每像素一次采样。反射光线在每一步的深度需要透视正确的插值,11/z0+s(1/z11/z0),因为线性插值视空间 Z 干脆就是错的,会在错误的位置产生命中。

有两件事让它能用而不是变成幻灯片。粗 march 被封顶在 64 步,因为一条横跨上千像素投影的长光线否则每个片元就会跑上百次迭代,而百万片元的水面乘以上百次迭代再乘以几次纹理采样就是个 30 fps 的场景。质量控制的是这个上限之内的有效步距,而不是迭代次数。还有,因为一个封顶的粗 march 会留下肉眼可见的阶梯状条带,一个六次迭代的二分细化在最后一次未命中和命中之间二分区间,相当于 64 倍的子步精度,足以让相邻的水片元不再锁定到同一个粗命中位置。最后一个点到线距离检查确认候选确实在反射光线上,而不只是在同一深度,用一个会自动按那个深度上一个像素的视空间宽度缩放的厚度容差,近处更紧,远处更松。

这次 spike 诚实的部分写进了它自己的注释里:SSR 没法反射主相机从没采样过的东西。一棵树的下方、任何在屏幕外的东西、任何被遮挡的东西,全都不存在于 buffer 里,所以都不可能出现在反射里。这就是那个"错误一侧的信息丢失",再多的 march 质量也修不了,而这也正是下一个 spike 存在的原因。

不会撒谎的镜子

在新标签页打开 Spike 52 ↗ · 看源码

平面反射从一个跨水面镜像的相机再渲染一遍场景,渲到一个离屏目标里,然后水的 shader 采样那个目标。这是经典模式,UE5 Water、three.js 自家的 WaterMesh、ABZÛ 和 Sea of Thieves 全都用它,因为它有场景的每一个像素可以取用,包括 SSR 永远看不到的几何体。在 TSL 里它几乎是反高潮的:reflector() 分配出镜像的辅助相机和它的渲染目标,你把它的目标加到 mesh 上让它每帧更新,然后采样它的颜色。等波浪后面来了,反射会通过给 reflector 的 UV node 加一个扰动偏移而晃动,这正是 WaterMesh 用的那一行。

值得记下来的那个 bug 在安全网里,不在镜子里。一个早先的版本把 reflector 的输出和一个程序化天空作为后备做混合,按反射颜色的幅值加权,理由是接近零的反射意味着目标那里什么都没有。但一片暗的树冠阴影也有低幅值,所以这个 clamp 从没到过满强度,那些真正暗的像素就和明亮的天空混在了一起。用户抓到的症状很精确:调试模式下的原始镜子目标显示出完美的暗树,而合成后的渲染却有被冲淡的反射,诊断结论是这个 shader"让暗色消失了"。修法是删除。第一帧之后 reflector 目标就是可靠的,所以根本不需要任何后备。两个 spike 底下共用同一个折射:采样表面后面的场景,重建每个像素在水线下沉了多远,并施加逐通道的 Beer-Lambert 消光,所以红色在几米内就死掉而蓝色还在,配一个天空 mask 让远平面背景不被起雾。Schlick Fresnel 在你直直往水里看时混入折射,在你横着看时混入反射。

做完是什么样

在新标签页打开 Spike 53 ↗ · 看源码

Spike 53 是自建对比买现成的检查。它原样塞进 threejs-water-pro,带它的热带预设、默认海洋 clipmap、相机跟踪和 Rayleigh 天空,并加载库自己 demo 用的那个同一座岛 glTF。重点是看一个手搓的平水 shader 和一套完整海洋系统之间的差距,而这个差距很大:这套有一个浮力系统,在船体下方几个点采样波高,所以船会俯仰和横摇,而不只是上下漂;还有一个尾迹生成器、岸线和水面泡沫,以及一个 mask 通道,压住船体内部的水渲染,这样涟漪不会从甲板渗出来。

集成它浮现出那种你只有用一个库而不是读它 README 才能学到的细节。泡沫纹理在预设里按文件名引用,但加载它们是消费者的活,没有它们时岸线读起来就是一条硬的水线边缘,而不是拍碎的浪。这座岛被摆放成它的水下几何体掉到海床以下,让库的海床 mesh 遮住模型的外圈,看不到平面边缘,这是库设计意图的模式:拿一个 3D 模型来,别去合成地形。还有,渲染器上抗锯齿被故意关掉,改用一遍后处理 FXAA,因为 MSAA 在深度感知的大气雾跑之前就把边缘片元和背景混合了,凡是几何体接触雾的地方都留下一道细细的暗边。在雾之后而不是之前解决锯齿就消除了这道边。它 derisk 的是决策本身:一套生产级海洋是个大而专门的系统,而对那些我们确实需要一套的情形,采用一个有维护的库胜过从零重建尾迹、浮力和泡沫;同时 spike 52 的平面镜 shader 对一个创作者摆在自己世界里的那些更小的内陆水,仍然是对的答案。

本章涉及的技术

屏幕空间反射的水。 一条反射的视线通过 DDA 在屏幕空间里 march 深度 buffer,用透视正确的 1/z 插值、一个硬的步数上限来约束每片元的开销,以及一遍二分细化来去掉封顶 march 留下的条带。一个带深度缩放厚度的点到线确认排除假命中。这个方法的硬性局限是它只能反射主相机已经采样过的几何体,所以屏幕外和错误一侧的表面永远不会出现。见地形材质

平面镜反射。 一个跨水面镜像的辅助相机把场景渲染进一个离屏目标,水的 shader 采样它,给出像素级精确的反射,包括 SSR 看不到的几何体。这是 UE5 Water 和 three.js WaterMesh 用的模式,波浪扰动作为一个偏移加到 reflector 的 UV node 上。一个按幅值加权的天空后备错误地抹掉了暗的反射像素;第一帧之后 reflector 目标就是可靠的,所以移除这个后备就是修法。

Beer-Lambert 深度染色折射。 两个 shader 都采样表面后面的场景,重建每个背景像素在水线下的深度,并施加逐通道的指数消光(红色在几米内死掉,蓝色还在),向一个水雾颜色合成,配一个天空 mask 让远平面不被起雾。Schlick Fresnel 在法向入射时混入折射,在掠射角时混入反射。

采用一个生产级水库。 threejs-water-pro 自带一个海洋 clipmap、Rayleigh 天空、用于船只俯仰和横摇的多点浮力、尾迹、泡沫和一个船体 mask 通道。消费端的细节很重要:泡沫纹理必须显式加载,岛的水下几何体应该掉到海床以下让海床遮住它的边缘,抗锯齿应该作为一遍后处理 FXAA 跑而不是 MSAA,以避免在几何体边缘出现一道暗的雾边。


第 26 部分,共 29 部分。 上一篇:第 25 部分 - 一副骨架,所有装束 下一篇:第 27 部分 - 用噪声造一座岛,让地面看起来像地面 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide