NEKOPARTY STUDIO

Godot可交互水面渲染(二):交互,高级API

字数统计: 3.3k阅读时长: 12 min
2026/03/02
loading

前文讲解了能够使用GDShader完成的部分,其实对于一个水面来说,GDShader能够做到的东西已经不多了,毕竟它的上限基本到此为止了。但我并不是在诋毁GDShader,毕竟它在快速实现某些效果方面实在是太简便快速了。

本文将讨论如何实现一般的交互,一般的反射,复杂的交互,复杂但是性能很高的反射。前半部分是不需要太多渲染API知识就能完成的,后半部分是相当复杂的实现内容。即使你完全不知道什么是Compositor,什么是Compute Shader,这前两项都还是能操作的。

简单的部分

交互

水面交互的本质就是获取交互后的水面波纹的高度图。

根据生活经验,我们可以大幅度简化这个水面交互的假设:水面会在玩家坐标不停生成向外扩散的圆形波纹。这样这件事的实现就很简单了。

这是一个几乎在Godot任意版本都能完成的实现。你只需要创建一个Subviewport,然后在里面设置一个正交相机,向水面拍摄。然后在需要产生波纹的地方不断发射向外扩散的圆环形粒子即可。这样的粒子用Godot的GPUParticle很好实现。

Subviewport效果

建议单独为粒子开一个Layer。如果你真的很需要节约这点性能的话,你可以直接渲染一个2D的Subviewport。

这样我们就得到了一个表示水面波纹的高度图。下面就可以随便怎么操作。可以用它来形变水面,或者将它转换为法线图叠加上去。

补充小知识:你可以用Godot的GradientTexture2D快速生成一个圆环贴图。

Gradient

高度图转换法线

由于是个常用技巧,因此这里简单说明。根据高度图求法线实际要做的事情就是计算高度图某点的法向,也就是求出这点的“坡度”。所以实际要做的事情就是计算这一点与附近点的高度差,然后根据这个“附近”的距离算出这个坡度。

由于是从这点开始,向平面上的垂直方向取点,所以计算出的结果叉乘一下就能得到实际法线。

举例实现:

1
2
3
4
5
6
float f1 = texture(heightmap, UV).r;
float f2 = texture(heightmap, UV + vec2(0.0, pixel_size)).r;
float f3 = texture(heightmap, UV + vec2(pixel_size, 0.0)).r;
vec3 tangent = normalize(vec3(1.0, 0.0, f2 - f1));
vec3 binormal = normalize(vec3(0.0, 1.0, f3 - f1));
NORMAL_MAP = normalize(cross(binormal, tangent)) * 0.5 + 0.5;

不过这样计算的结果会有条纹或者斑点。这基本是不可避免的。可以通过增加采样点,模糊计算结果或者提升高度图的纹理密度等方法处理掉,不过不可避免的会产生一些性能损耗。

最后能达到这样的结果,总体上其实对于要求不高的项目来说够用了。

简单结果

反射

首先,放好你的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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
uniform int MAX_STEPS = 60; // 最大步进次数
uniform float step_size = 0.1; // 每次步进的长度
uniform float max_distance = 100.0; // 最大反射距离
uniform float bias=3.0;

void fragment() {
vec3 col = vec3(0.0); // 反射采样到的颜色,默认是黑色

// 从深度纹理获取当前像素的深度值
float depth = textureLod(depth_texture, SCREEN_UV, 0.0).r;

// 将屏幕坐标转换为摄像机空间坐标
vec4 camera_space_pos = INV_PROJECTION_MATRIX * vec4(SCREEN_UV * 2.0 - 1.0, depth, 1.0);
vec3 pixel_position = camera_space_pos.xyz / camera_space_pos.w; // 摄像机空间中的像素位置

// 获取法线和粗糙度
vec4 normal_roughness = texture(normal_roughness_texture, SCREEN_UV);
vec3 normal = normalize(normal_roughness.xyz * 2.0 - 1.0); // 法线在[-1, 1]范围内
float roughness = normal_roughness.w;

// 摄像机视线方向
vec3 view_dir = normalize(pixel_position);

// 计算反射方向
vec3 reflect_dir = reflect(view_dir, normal);

// 使用噪声和粗糙度扰动反射方向
vec3 noise = texture(noise_texture, SCREEN_UV/vec2(.1)).rgb*vec3(2.0)-vec3(1.4);
reflect_dir = normalize(mix(reflect_dir, reflect_dir + noise * roughness, roughness));

// 初始化步进,反射方向的步进大小
vec3 ray_pos = pixel_position; // 光线起点为像素位置
vec3 one_step = reflect_dir * step_size; // 每一步的移动距离

for (int i = 0; i < MAX_STEPS; i++) {
ray_pos += one_step; // 沿反射方向步进

// 将摄像机空间坐标转换回屏幕空间
vec4 clip_pos = PROJECTION_MATRIX * vec4(ray_pos, 1.0);
vec2 screen_pos = clip_pos.xy / clip_pos.w * 0.5 + 0.5;

// 检查光线是否越界
if (screen_pos.x < 0.0 || screen_pos.x > 1.0 || screen_pos.y < 0.0 || screen_pos.y > 1.0) {
break; // 光线超出屏幕范围时停止步进
}

// 从深度纹理获取当前步进点的深度
float scene_depth = textureLod(depth_texture, screen_pos, 0.0).r;

// 将深度值转换回摄像机空间
vec4 projected_pos = INV_PROJECTION_MATRIX * vec4(screen_pos * 2.0 - 1.0, scene_depth, 1.0);
vec3 scene_position = projected_pos.xyz / projected_pos.w;

// 如果步进的光线位置比当前深度值靠近摄像机,说明碰撞到了几何体
if (length(scene_position) < length(ray_pos)) {
if(abs(ray_pos.z-scene_position.z)<step_size*bias){//判断是否在反射物体的背面,3是一个bias,用于修复缝隙
vec4 hit_color = texture(screen_texture, screen_pos);
col = hit_color.rgb; // 采样颜色
}
break; // 停止步进
}

// 如果光线步进距离超过最大反射距离,则停止
if (length(ray_pos) > max_distance) {
break;
}
}
if(depth>0.0){
depth=1.0;
}
ALBEDO = texture(screen_texture,SCREEN_UV).xyz+col*vec3(depth)*vec3(1.0-roughness);
}

可以直接把它想办法塞进水面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都能做到如此程度的话,别的引擎想必也不在话下吧。

CATALOG
  1. 1. 简单的部分
    1. 1.1. 交互
    2. 1.2. 高度图转换法线
    3. 1.3. 反射
  2. 2. 困难的部分
    1. 2.1. 水面交互
      1. 2.1.1. 基础
      2. 2.1.2. 交互
      3. 2.1.3. 水波反弹
    2. 2.2. 反射