NEKOPARTY STUDIO

Godot可交互水面渲染(一):GDShader

字数统计: 2.3k阅读时长: 7 min
2026/02/14
loading

背景

Godot的GDShader虽然简单好用,但是由于过度封装照顾新手,想要创建复杂且效果优化都不错的的Shader实则困难。

登陆Godot Shaders网站寻找相关水面Shader,大多只能做到叠加两层法线扰动,加上根据深度模拟光线吸收,加上根据深度差计算的物体边缘泡沫。如果有更复杂一些的,那就是加上折射,焦散,最多再在里面写一个非常暴力耗时的SSR。

这系列文章介绍了我制作这个可交互水面的完全过程,但是涉及一些Godot比较高级的渲染API知识,例如Compositor和Compute Shader。所以先写出这个用不着这两个东西的第一部分内容,单纯讨论水面本身。本系列文章最后结果如图:

渲染结果

结果包含了水面渲染,水下焦散的低成本解决方案,实时反射,基于浅水方程的水面交互,能够追踪所有Setup过的物体,能够追踪所有Setup过的碰撞体产生水波反弹,且模拟区域跟着角色移动也不会产生Artifact。

众所周知,透明物体的SSR是Godot的一个巨大的问题。在Godot的管线中,透明物体不会出现在Depth Buffer里,因此不会在它上面渲染SSR。而Godot不支持自定义管线,因此我们无法通过将特定透明物体写入额外深度Pass的方式解决它。所以这里使用了Compositor加上一些复杂的奇怪技巧来解决这个事情。

当然,如果你想简单粗暴的解决的话,你可以直接到Godot Shaders上抄个实现SSR的实现装上去,这个方案比起现在这个其实有一定优势,这会在之后说明。不过由于是直接写在Shader里的,你只能全分辨率地去跑这个挺耗时的算法。如果要反射很远处物体的话可能效率上不是很好看。

波纹

为了使得近处水面能够观察到网格的形变,水面采用了普通的ClipMesh做法。就像Heightmap Terrain一样,让这个ClipMesh跟着摄像机(或者玩家)走就可以。

真实的海面波浪并不是像正弦波一样起伏的,它的主要特征应当是波峰尖锐,波谷平坦。所以这里采用一张预计算的Gerstner波的高度图以及法线图。实际效果要比直接Godot内置的Noise好不少。

结果的实现就是同一张Gerstner波,按不同的缩放和运动速度采样两次糊弄一下,最后再加一个简单的Noise做一点细节的法线扰动就行。

结果1

远处的波纹还是有点丑,这种情景常规的抗锯齿也不是很好使,TAA这个地方因为像素少且每帧之间像素变化有点大也不是很好使,可以通过按距离降低一点法线扰动强度来缓解一下。

水面的起伏会导致能看见水面的后表面。

渲染Bug

我们设置render_mode为depth_draw_always来解决这个问题。这样渲染透明像素时候也会记录像素深度用以深度比较,从而解决无法正确遮挡的问题。

解决

注意,如果在上面再叠加别的透明物体的话,有可能导致水面直接不渲染了。因此需要注意使用场景。就像Minecraft基岩版的那个经典渲染问题一样:

(没找到图)

不过说到底这是个取舍问题。可以等真遇到了这种问题再讨论。

着色

采用最简单的Lambert,其实这不是非常重要,水面的光照主要贡献是Specular,在Fragment Shader里面设置一个很小的Roughness的话,Diffuse Light也不会产生多少影响,所以直接不着色也没事。但最好还是简单写一下,因为为了严谨考虑,泡沫还是得被正常着色的。如图所示

着色1

水面主要计算一下光源贡献的高光就可以了。实际做法上并不物理正确,计算一个中心的亮斑,周围零星的反射斑点其实是计算了一个较大的亮斑,然后采样一张斑点图来实现的,并不是真实的细微法线扰动贡献的效果🤗

着色2

水下透射

水下投射我们使用基于深度的查找表方法。这个办法方便定制水下效果,而且简单好做。其思路就是按视线方向计算水体深度(这个后面也会用很多次),然后根据这个深度去采样一个LUT。这个LUT直接用Godot的GradientTexture1D就能定制,非常简单快捷。

Gradient

调这个LUT,就能实现一些花里胡哨的水下颜色分层效果。

Gradient1

Gradient2

至于折射,即根据计算后得到的Normal Map,根据水下深度去扰动SCREEN_UV的采样就行。如果要更进一步,防止水面前方的物体被采样进折射的话,就扰动SCREEN_UV后再检查一次采样点的深度。如果深度不对的话就采样原来位置的像素就行。

最后,将计算完成的水下像素写入EMISSION通道。

因为实际上从水下透射出来的光线应该不被Light函数着色影响。如果写入ALBEDO的话实际上就会被Light函数给着色了。我并不知道有没有什么好方法去实现它,所以我给它塞进EMISSION通道,这样它确实就不会被Light函数影响了。

注意:其实可以直接强制在Light函数将当前Specular和计算结果直接混合。但混合会造成计算出的高光变弱(因为高光是直接往Specular里加算的)。用Diffuse混合也不很好使,因为Diffuse Light会被Albedo给tint掉,也会因为Roughness的变低而强度减弱。因此怎么想都没有很好的办法。

注意:即使这样干了,最后的结果也会再经历一次Tonemapping,导致颜色变得有点不对,这我真的也没招。

注意:为了保证视觉上正确,EMISSION通道写入了内容的部分最好把ALBEDO设置成纯黑色。

焦散

注:我实际上发现这个解法不够全面,因此我自己也在想一个改进版本。

焦散更是个不需要物理正确,只需要看着马马虎虎就行的画面效果。

做法首先是根据深度重建世界坐标,然后用这个世界坐标去采样一个预渲染好的焦散帧序列就可以了。

焦散贴图

不过这个焦散贴图帧数还是有点要求的。实际使用的是一个48帧的序列,每秒45帧播放才看不出来不流畅。因此这个纹理集还是占据空间有点大。但是比起直接在GPU上计算噪声要便宜,也比使用单帧图像+扰动来的效果好不少。

至于色散效果,其实就是根据水面深度偏移一下采样坐标,多采样两次,乘上不同的颜色即可。严谨的来说,其实应该计算水面的垂直深度,但这样又会让本来复杂的Shader更加复杂了,其实也没什么必要。

为了能和水面波纹产生互动,再加一个根据水面法线扰动UV的操作即可。

最后同样还是写入EMISSION通道。🤗

焦散结果

岸边白沫

我们先用一个简单糊弄的方式实现它。其实就是还是使用之前计算的视线方向水体深度,然后在深度比较浅的地方加上白沫就可以。

着色1

不过这个问题是很明显的,白沫会被物体边缘切断。这个大概会在之后的交互与后处理篇尝试解决。

总结

单纯使用GDShader能够完成的部分就只有这些,不考虑那些复杂的视觉效果的话,完成这部分之后水面看上去其实已经还不错了。其实对于水面外观来说,我个人感觉影响最大的还是如何产生合适形状的波纹,只要波纹看上去好看水面差不多就成了。剩下的部分基本都有现成的做法可以参考,我也只是单纯搬运了一些其他的文章而已。

下面一篇会介绍使用Godot比较高级的渲染API去完成一些不太好做的东西,如补全水面SSR,使用浅水方程的交互效果实现等。这需要至少懂一些关于Compute Shader和Compositor的知识。据我所知用Godot引擎的人大多不喜欢用这些东西🤗,这部分文档也比较简略。

CATALOG
  1. 1. 背景
  2. 2. 波纹
  3. 3. 着色
  4. 4. 水下透射
  5. 5. 焦散
  6. 6. 岸边白沫
  7. 7. 总结