clearwater:一个 HTML 文件里的实时浅海,把焦散和镜头衍射都算了出来

先说结论:一个 400 KB 的 HTML 文件,打开就是一片会晃的浅海。没有构建步骤、没有 npm install、没有 Three.js、没有外部贴图——把 index.html 从磁盘双击打开,或者扔到任意静态托管上,海水就在你浏览器里动起来了。水面下的石头、穿过水面的光斑、太阳照到水面的星芒,全都在这个文件里现算。

项目地址:github.com/Aureliengmz/clearwater,MIT 协议,2026 年 9 月 23 日建仓,几天冲到 473 星。在线体验在 aureliengmz.github.io/clearwater——进去之后拖拽可以转头,点一下水面会激起一圈涟漪。作者是 Aurélien,来自 Lumaris。

这类「单文件 WebGL 玩具」在 GitHub 上一抓一大把,绝大多数是几分钟的 demo。Clearwater 不一样的地方在于:它是按物理量搭的,而不是按看起来像调的。下面把它拆开看。

一、海面不是噪声,是海况谱

很多水面是拿几张法线贴图滚一滚,或者叠几层 Perlin 噪声。Clearwater 用的是海洋学的标准做法——把海面表示成一个频率域的高度场,再用 FFT 变换回空间域。文件里 Ocean spectrum (FFT) 那一段就是干这个的:

const N = 256, LOGN = 8;
const L = 4.6;              // patch size (m)
const DEPTH = 1.6;          // mean depth (m)
const TARGET_SLOPE = 0.078; // RMS slope

L 是这块海面贴片代表的实际尺寸(4.6 米),N=256 是频谱分辨率,DEPTH 是平均水深,TARGET_SLOPE 是均方根坡度——调它就是在调「海面有多碎」。作者参考的是 Jerry Tessendorf 那篇经典论文《Simulating Ocean Water》。

关键是这个能量谱 P 不是随手拍一个 1/k 幂律,而是把三段叠起来:一个主峰(bump,对应 0.62 米左右的主浪)、一段高频尾巴(tail,负责碎浪的细纹理)、再加一个独立的长涌(swell,1.6 米波长)。方向性靠 spread 算:逆着主风向的分量被压到 0.35 倍,顺风方向接近满值。这套写法让海面既有大浪的走势,又有脚下的碎光。

然后它做了一个很有意思的取舍。按物理公式,深水波的角频率是 ω=√(gk),其中 g 是重力加速度、k 是波数;再加一点表面张力项。但作者把它量化到了 1/60 Hz 的整数倍:

float w = sqrt(9.81*kl + 7.4e-5*kl*kl*kl);
// gentle dispersion quantisation keeps the loop seamless over 60 s
float w0 = 6.28318530718/60.0; w = floor(w/w0)*w0;

为什么要这么干?因为所有波的周期都取整成 60 秒的约数之后,整片海的动画每 60 秒精确回到初始状态。这意味着你可以用一段 60 秒的循环来录它,接缝完全看不出来。这种「牺牲一点点色散精度,换一个无缝循环」的小心思,是那种自己真的拿去录视频的人才会想出来的。

二、涟漪是自己解出来的波动方程

点一下水面那圈涟漪,不是播个动画。它是在 256×256 的网格上,实时数值积分二维波动方程(Interactive ripples 段)。每一步拿四个邻居的高度取平均,和中心点做差,推出速度,再推回高度:

float avg = 0.25*(左.高度 + 右.高度 + 上.高度 + 下.高度);
float v = c.g + (avg - c.r)*0.9;
v *= 0.9955;            // 速度阻尼
float h = c.r + v;
h *= 0.9985;            // 高度阻尼

0.9 是波速相关项,两个阻尼系数(0.9955 / 0.9985)让水面慢慢平静下来。这个模拟区域只有 7 米见方(RSIZE = 7.0),并且会跟着镜头走——每次更新前把场按镜头位移平移一下(uShift),所以你在哪,涟漪就在哪。边缘处做了渐隐(smoothstep 到 0.06),让局部的涟漪场平滑地融进开放水面,不会露出硬边。

另外它还很节省:ripActive 计数「距离上次扰动过了几帧」,水面完全平静时就干脆跳过计算。一个 demo 能做到该省的地方省,说明作者是真在乎帧率。

三、焦散:光是怎么在水底画出那张网的

水下石头表面那些游动的亮线,是焦散(caustics)——水面像一堆随机的小透镜,把平行的阳光重新聚焦,在底部叠出一层光的网格。这是整份代码里最漂亮的一段,做法也很硬核:逐 GPU 射线追踪 + 用雅可比行列式算聚焦强度。

它铺了一张 256×256 的网格,每条网格线代表一束太阳光,从水面折射进水里打到海底,算出落点。然后关键的一步——用 dFdx / dFdy 求出「落点相对原始位置的面积缩放率」:

vec2 a = dFdx(vSrc), b = dFdy(vSrc);
float area = abs(a.x*b.y - a.y*b.x);   // 雅可比行列式
float I = min(area*uNorm, 40.0);       // 能量守恒:面积被压得越小就越亮

这就是能量守恒的直接体现:一束光落在水面上,被折射压缩到一个更小的面积里,那个地方的亮度就按比例上升。网格扭曲得越厉害的地方(也就是水面曲率最大的地方),亮线就越细越亮——物理上完全正确,不需要任何手工贴图。这个思路源自 Evan Wallace 那篇著名的《WebGL Water》。

更细的一层是色散。真实的水对不同颜色的折射率不一样,所以焦散的边缘会带一圈淡淡的彩虹。Clearwater 对红/绿/蓝三个通道分别用了三个折射率,并画出三个稍微错开的网格:

const IORS = [1.3315, 1.3335, 1.3365];  // R / G / B 三通道

三个网格用加法混合叠在一起(blendFunc(gl.ONE, gl.ONE)),剩下没对齐的残差就是那几道彩色边缘。这个数值也取得很讲究——三个值之间只差 0.005 左右,出来的效果是「隐约有点彩」,而不是那种一眼假的镭射光。

四、海底:石头、沙子,和一层「水膜」

海底贴在 Pebbles 段。这里也有讲究。石头贴图只有一张,但它用两张交错的采样做了一个「虚拟滚动」——按噪声决定当前像素更像 A 石头还是 B 石头,在两者之间插值。这样即使贴图本身会重复,接缝也被打散了,不会看出明显的平铺格子。

再叠上大尺度的变化:sand 沙子会按区域优先填满石头之间的缝,big 做出被太阳晒白/水草发暗的斑块,还有一层 weed 模拟水草的偏绿色调。最后整块底被压到 pow(alb, 1.2) 再乘 0.6——这是把亮度从 sRGB 近似转进线性空间,顺便降低整体反射率。

水里还有一层悬浮微粒(suspended specks):在三个不同的深度上撒一些被阳光照亮的微小粒子,越深越暗(exp(-SIG_T.g*tt*2.0))。就是这些小点,让水看起来「有体积」,而不是一张贴在地板上的玻璃。这种细节通常出现在讲真实感的离线渲染里,出现在一个单 HTML 文件里,只能说作者挺较真。

五、太阳照到水面的星芒,是镜头的衍射图样

最让我意外的是高光那一块(Lens diffraction glare 段)。水面上的太阳反光会炸出一颗六角星芒,代码里的注释写得很清楚:

每个太阳光斑周围的星芒,是相机光圈衍射图样(也就是它的傅里叶变换),对波长积分后尖角会带上淡淡的彩虹色。

它不是画了一个星形贴图去贴,而是真的对整张高光图每帧做一次 FFT,在频率域里乘上光圈的衍射核,再变换回来。这么做的好处写在了注释里:开销和画面里有几个光斑完全无关——一百个光斑和零个光斑的计算量是一样的,因为卷积是在频率域一次性做完的。这已经是正经的「镜头物理」了。

高光本身用的是 Beckmann 分布而不是常见的 GGX,注释解释了原因:Beckmann 没有 GGX 那条长尾,光斑更干脆、更贴近真实的太阳闪点。而且半宽度还会被「坡度方差」撑开(LEAN Mapping 的思路,来自 Marc Olano 与 Dan Baker)——水面越碎,光斑越大越糊,这正是远处水面看起来一片blingbling的原因。

六、连天空和远山都是算出来的

背景里那条深色的远山轮廓,也不是贴图。sky() 函数里有一个 ridge(),把四个正弦波叠起来当作海角的轮廓线,然后用 fbm 噪声在轮廓上下分别刷出「松树树冠」和「浅色石灰岩」,再按朝向太阳的角度做背光压暗,最后用约 2 公里空气透视把它推远、变灰。换句话说,地平线后面的那片陆地,是这个函数按极坐标一角一角算出来的。

天空本身也分了天顶/地平线的渐变、太阳附近的瑞利散射(三层不同次幂的 pow(max(mu,0.), n) 叠加),以及远处水面上的雾。水面、天空、地面用的是一套统一的光照模型(太阳辐照度、体积吸收系数 SIG_A / SIG_S、单次散射相位函数 Henyey-Greenstein),所以整幅画面的颜色是自洽的——这也是它看起来不像 demo 的原因。

七、它的收敛策略:图像低分辨,几何高精度

实时渲染里,分辨率就是帧率。Clearwater 的做法很果断:焦散纹理给到 1024×1024,但最终画面用自适应分辨率渲染,帧率掉了就缩。高成本的部分(焦散、FFT)跑在固定分辨率上,廉价的最终合成按需降档——这是很成熟的取舍。

它还有一套给截图用的 URL 参数,看得出作者是在拿它当素材库在用:?debug 显示帧率/分辨率/画质档位,?t=5 把时间冻结在第 5 秒(方便截图,不用担心波纹一闪而过),?yaw=0.5&pitch=-0.4 指定初始镜头朝向,?view=caus 直接看原始焦散纹理,?noglare 关掉衍射星芒。想研究它是怎么算的,?view=caus 是最快的入口。

跑起来只有一步

# 直接打开,或者:
git clone https://github.com/Aureliengmz/clearwater.git
# 然后双击 index.html

唯一的硬性要求是 WebGL2 带浮点渲染目标(EXT_color_buffer_float)。这在 2026 年的桌面浏览器上基本是标配,老一点的机器或者某些移动端会跑不起来——它需要浮点纹理来做 FFT 和波动方程模拟,这点没有妥协余地。

还有一个细节挺有意思:海底那张石头贴图不是外部文件,而是以 base64 直接内联在 HTML 末尾一个带 pebbles-texture 标识的脚本块里。旁边配了一个 tools/make_pebbles.py(依赖 numpy / scipy / pillow),想换海底石头,跑一下脚本、把新的 base64 粘回去就行。连「素材怎么生成」这件事都一起交给你了。

写在最后

Clearwater 属于那种「看起来是个玩具,翻开全是工程」的项目。它把一串本来分别出现在论文和商品级引擎里的东西——Tessendorf 的海洋谱 FFT、波动方程涟漪、雅可比行列式焦散、三通道色散、孔径衍射星芒、LEAN Mapping——压进了一个没有依赖、没有构建、只有 400 KB 的 HTML 文件。

如果你在做 WebGL 或者图形方向的开发,这个仓库很适合当教科书翻:九个 /* ---- 分区 ---- */ 注释把代码切得清清楚楚,每个参数旁边都写了自己在控制什么。README 里那份「代码地图」直接告诉你去哪一行改哪个量。这种把读者当同行对待的文档,比一堆截图值钱得多。

封面使用项目自带的官方演示截图(media/landscape.png)——一片真实的浅海,也是这个项目最好的名片。

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享