前文讲解了能够使用GDShader完成的部分,其实对于一个水面来说,GDShader能够做到的东西已经不多了,毕竟它的上限基本到此为止了。但我并不是在诋毁GDShader,毕竟它在快速实现某些效果方面实在是太简便快速了。
本文将讨论如何实现一般的交互,一般的反射,复杂的交互,复杂但是性能很高的反射。前半部分是不需要太多渲染API知识就能完成的,后半部分是相当复杂的实现内容。即使你完全不知道什么是Compositor,什么是Compute Shader,这前两项都还是能操作的。
简单的部分
交互
水面交互的本质就是获取交互后的水面波纹的高度图。
根据生活经验,我们可以大幅度简化这个水面交互的假设:水面会在玩家坐标不停生成向外扩散的圆形波纹。这样这件事的实现就很简单了。
这是一个几乎在Godot任意版本都能完成的实现。你只需要创建一个Subviewport,然后在里面设置一个正交相机,向水面拍摄。然后在需要产生波纹的地方不断发射向外扩散的圆环形粒子即可。这样的粒子用Godot的GPUParticle很好实现。

建议单独为粒子开一个Layer。如果你真的很需要节约这点性能的话,你可以直接渲染一个2D的Subviewport。
这样我们就得到了一个表示水面波纹的高度图。下面就可以随便怎么操作。可以用它来形变水面,或者将它转换为法线图叠加上去。
补充小知识:你可以用Godot的GradientTexture2D快速生成一个圆环贴图。

高度图转换法线
由于是个常用技巧,因此这里简单说明。根据高度图求法线实际要做的事情就是计算高度图某点的法向,也就是求出这点的“坡度”。所以实际要做的事情就是计算这一点与附近点的高度差,然后根据这个“附近”的距离算出这个坡度。
由于是从这点开始,向平面上的垂直方向取点,所以计算出的结果叉乘一下就能得到实际法线。
举例实现:
1 | float f1 = texture(heightmap, UV).r; |
不过这样计算的结果会有条纹或者斑点。这基本是不可避免的。可以通过增加采样点,模糊计算结果或者提升高度图的纹理密度等方法处理掉,不过不可避免的会产生一些性能损耗。
最后能达到这样的结果,总体上其实对于要求不高的项目来说够用了。

反射
首先,放好你的Reflection Probe。如果你真的觉得精确的反射不是非常有必要的话,好好放反射探针肯定是够你用了。
其次,这里有一个实现不难,但是性能不好的方法。我们现在有屏幕的Color Buffer(也就是SCREEN TEXUTRE),也有Depth Buffer(也就是DEPTH TEXTURE),还有我们自己Shader里最后得到的水面法线,这样我们就直接在GDShader里面写一个SSR。
SSR原理也很简单:
要计算某个像素的反射颜色,我们直接从摄像机发射一条射线射中这个像素,根据法线计算出反射光线的方向,然后在空间中步进这个光线。RayMarch获得的最终像素就是我们要的像素。
所以你只需要在GDShader的片段着色器里加上SSR实现就行了。不过很明显这是个效率精度不可兼得的问题,你无法兼顾RayMarch的步数,每步长度以及性能。
如果你RayMarch步数多,长度短,那精度和范围都很好,不过显然非常Expensive。步数少,长度短的话,稍远的场景就无法被反射。步数少,长度长的话,近处的精度就会极差非常容易露馅。
为了解决这个问题,可以使用可变步长的SSR。近处可以步长短一点,远处步长长一点,从而弥补这个性能与精度的平衡问题。
不过毕竟是GDShader实现的,你最多也就只能做到这样了。一个严重问题是全分辨率的SSR确实是个并不便宜的算法,但你在GDShader里只能全分辨率的去跑这个SSR。总体上是个非常不划算的东西。
提供一个GodotShader网站上的参考实现:
1 | uniform int MAX_STEPS = 60; // 最大步进次数 |
可以直接把它想办法塞进水面Shader里就可以。
困难的部分
水面交互
基础
这部分涉及Godot底层的渲染API,是仅仅使用GDShader无法完成的部分。
Godot从4.2之后支持了Compute Shader,终于能够让我们能够使用GPU去做点自己想干的事情。这里我们使用Compute Shader完成水面模拟,来获得更好的效果。
首先介绍浅水方程:
浅水方程是流体方程在很水面面积远大于厚度情况下的一种近似。虽然我是本科力学专业出身,但我流体力学学的完全是一坨所以我就不展开解释或者推导了。考虑到游戏内的水面大都是没有边界的大范围水面,因此它成为了一个模拟逼真水面好办法。就算不是也不影响用,游戏视觉效果这东西只要看着像就可以了😋
直接写出我们需要的公式(感谢此文章 腾讯游戏学堂写的):
$$
h(t) = \text{Damping} \cdot \left( h(t-1) + \beta \left( h(t-1) - h(t-2) \right) + \alpha \left( h_N(t-1) - 4h(t-1) \right) \right)
$$
其中,β是粘度系数, α是SWE常数, hN(t-1)指的是从周围四个点在(t-1)时刻采样的水面高度。Damping是我们人为设置的一个值,用于加快计算收敛。
这个公式非常的简洁,只需要按照这个写个Compute Shader就可以了。
这样这件事情就基本完成了,非常简单。
交互
交互同样要需要一个Subviewport,不过不是直接获得高度图,而是获取交互点的Texture。
当然,你可以跳过这个步骤。如果你不需要非常精确的交互的话,你只要把交互点的坐标传进Compute Shader,然后给交互点的水面一个初始值就行了,Compute Shader就会自己开始计算扩散。
但为了精确计算的话,我们需要寻找与水面相交的物体切面。我其实没有什么好办法,这里我的方案是这样的:放置一个拍摄水面的正交相机,将裁切距离设置在水面附近。然后使用这个相机渲染物体(简化过的)的背面就可以了。
但这样只能检测相交的物体,实际上我们需要检测的是哪些物体在运动,不动的物体是不会产生波纹的。所以这里的做法是比较这一帧和上一帧Subviewport拍摄的画面区别,不同的地方就是动了。把这些像素找出来,传给Compute Shader,让它在这些地方产生高度,就解决了这个问题。
(当然,你得控制好分辨率)
水波反弹
如果水波碰到障碍物能够反弹的话,结果会变得更惊艳。
这个做法其实很简单,如果检测到碰到障碍物的话,高度保持不变就可以,这样水波就会反向传播。(听起来就很合理)至于做法依然用一个正交相机拍摄可以产生碰撞的障碍物背面,传递给Compute Shader让它自己判断就可以。
到此为止就总算勉强完成了这个交互系统了,剩下的工程问题大家自己打磨就可以😋
反射
由于水面一般都可以近似认为是一个平面,因此我们可以使用一种快得多的后处理反射方法,那就是屏幕空间平面反射。
比起SSR这种使用RayMarch的版本,屏幕空间平面反射不需要做任何RayMarch,本身就要快得多。它的做法是对屏幕上的每一个像素,计算它关于平面的反射像素位置,然后在这个位置上写入对应的像素就行。由于不需要RayMarch,这个方法可以无限距离的产生反射。
这也就是它为啥不能直接使用GDShader实现,因为它的出发点是屏幕上的每一个像素,而不是水面上的某个像素。所以水面上的像素着色器是没法这样操作的,它只能从水面上的像素出发去做计算。
(当然,你可以用那种GDShader做全屏后处理的方法来做)
当然了,如果直接实现的话也会有一些不那么方便的问题,比如会产生Y-Fighting,因为可能存在两个像素反射后的位置在相同像素上,导致这个像素被反复写入狂闪。解决方法是维护一个反射后的深度图,之后绘制第二次反射,第二次反射的时候比较像素深度与反射后的深度图,如果不通过就直接舍弃,这样就能解决这个问题。
不过还是会有一些问题,比如反射有洞之类的,和SSR一样想点办法补洞就可以了。
获取反射图之后,你可以直接合成到Color Buffer里,当然这不是推荐的办法。因为水面实际是有法线扰动的,不会产生这样规规矩矩地产生反射。但我们是基于平面的假设完成这个算法的。所以我们有另一条路径:
我们创建一个TextureRT2D,然后Compositor负责将结果写入这个TextureRT2D,然后让水面采样这个TextureRT2D,这样水面就能自行操作这个反射图了,根据法线信息什么的进行扰动就可以。Compositor的另一个好处是可以降采样渲染反射图,反正扰动之后也看不太出来,能进一步节约时间。
这样,你就完成了一个功能基本完备的水面😎

演示视频:【Godot实现的 可交互动态焦散大面积水面】 https://www.bilibili.com/video/BV1yBfxBbEeP/?share_source=copy_web&vd_source=d8fa2f67adb957d0c015e5706616e497
既然Godot都能做到如此程度的话,别的引擎想必也不在话下吧。