自从上次写了《用实时反射Shader增强画面颜值》 后,不少开发者开始尝试用它来渲染水面,但效果都差强人意。
这是因为,水面除了反射,还有许多细节需要考虑。
在此之前,也有不少开发者提到过水面渲染的需求,也有不少开发者分享了一些关于水面渲染的 Shader,但更多集中在卡通着色方向。
水面渲染在3D项目中的需求是非常大的,毕竟地球表面水面占了约 70.8%,很难避开水面效果。
近期引擎团队的 youyou 大佬也分享了一个水面渲染效果,包含 平面反射、FSR、SSAO、TAA 等诸多实时渲染技术。
但该 DEMO 基于 Cocos Creator 延迟渲染管线,对项目和设备要求较高,所以麒麟子专门准备了这个独立的水面效果分享,希望能够对大家有所帮助。
水面渲染技术非常多,不同段位的产品,对水面的要求不同。
毛星云的 《真实感水体渲染技术总结》 这篇文章中,通过对一些3A大作的水面渲染进行分析,列出了非常多的技术要点,有兴趣的朋友可以拜读。
水面渲染技术从简单到复杂来排序,可以分为以下三类:
平面着色 顶点动画 流体模拟本文实现的是 基于平面着色 的水面效果,虽然它并非高端效果,但却是大部分 3D 项目中采用的方案。
基于平面着色的水面渲染主要涉及以下几个部分:
反射折射水深效果水岸柔边动态天空盒法线图与光照岸边浪花 由于时间关系,法线图与光照 与 岸边浪花 暂未实现。
标准的渲染流程如下所示:
可以看出,如果要实现所有效果,至少需要绘制场景 4 次。
由于这里的深度图只是和折射搭配使用,8 位精度足够用了,我们可以考虑借用折射图中的 Alpha 通道来存储深度信息。
优化后的流程图如下:
麒麟子写的另一篇文章 《用实时反射Shader增强画面颜值》 中已经完整地剖析了实时反射相关原理,在此就不再敷述,有需要了解的读者可去麒麟子的媒体号上查阅。
这里主要讲一讲本 DEMO 中的实现步骤。
步骤1、 使用代码新建一个 RenderTexture。
步骤2、 创建一个节点,添加摄像机组件,并将 clearFlags、clearColor、visibility 属性与主摄像机同步。
步骤3、 设置反射摄像机的渲染优先级,确保比主摄像机先渲染。
步骤4、 将新创建的 RenderTexture 赋值给此摄像机的 targetTexture 属性。
以上步骤的代码在 WaterPlane.ts 中,如下图所示:
步骤5、 在 lateUpdate 中同步主摄像机参数。
步骤6、 在 lateUpdate 中根据实时反射原理,动态计算摄像机关于主摄像机的镜像位置和旋转。
最终,渲染得到的 RenderTexture 如下:
麒麟小贴士:
所有物体的材质,需要加入自定义裁剪面,裁剪掉水面以下的部分。
可以明显看到,上图中绿色物体的倒影,水面以下的部分是被裁剪掉了的。
折射渲染的原理非常简单:
渲染水平面以下的部分到 RenderTexture在水面渲染阶段使用噪声图进行扰动,以模拟出水面折射效果折射渲染的流程与反射渲染大致相同,只有两个细小的差别:
用于折射渲染的摄像机所有参数均与主摄像保持一致即可折射渲染阶段,物体被裁剪掉的是水面以上的部分下面我们来看看,本 DEMO 中关于折射的实现步骤。
步骤1、 使用代码新建一个 RenderTexture。
步骤2、 创建一个节点,添加摄像机组件,并将 clearFlags、clearColor、visibility 属性与主摄像机同步。
步骤3、 设置反射摄像机的渲染优先级,确保比主摄像机先渲染。
步骤4、 将新创建的 RenderTexture 赋值给此摄像机的 targetTexture 属性。
以上步骤的代码在 WaterPlane.ts 中,如下图所示:
麒麟小贴士: 注意红色线框部分,本 DEMO 中折射贴图的 Alpha 通道用于标记深度信息,所以需要确保 Alpha 通道的值为 255。
步骤5、 在 lateUpdate 中同步主摄像机参数、位置、旋转等信息。
最终,渲染得到的 RenderTexture 如下:
水面渲染主要利用了投影纹理技术,将顶点的投影坐标转化为UV,对折射和反射贴图进行采样。
由于使用了折射贴图,我们的水面材质不需要开启 Alpha 混合。
步骤1、 根据投影坐标计算出屏幕UV。如下所示:
vec2 screenUV = v_screenPos.xy / v_screenPos.w * 0.5 + 0.5;
步骤2、 采样折射贴图,可以得到如下渲染效果:
左边为正常渲染效果,右边为标记了折射内容的效果
步骤3、 使用噪声图对折射进行扰动,可得到如下效果:
步骤1、 与折射渲染一样,根据投影坐标计算出屏幕UV。
步骤2、 采样反射贴图,可以得到如下渲染效果:
步骤3、 使用噪声图对反射进行扰动,可得到如下效果:
菲涅尔的计算公式从玉兔的边缘光教程开始,到实时反射等场合,已经出现过很多次了。下面是核心代码:
折射可以视为水体本色,利用菲涅尔因子与反射内容混合,即可实现一个带折射和反射的水体效果。
伪代码如下:
finalColor = mix(refractionColor,reflectionColor,fresnel)
最终可以得到如下显示效果:
完整代码代码请查看项目中的 effect-water.effect 文件。
从上面的动画中可以看出,虽然折射和反射效果都有了。但画风有些奇怪,完全没有水面的感觉。
这是水面没有深浅效果导致的。
我们来看看,如何获取深度信息,并根据深度信息实现水深效果。
从上图中,我们可以清晰地看到,靠近岸边的海水的颜色比远处海水的颜色透明得多。
产生这种现象的主要原因,就是 基于视线方向的水体厚度 不同。
什么叫基于视线方向的水体厚度,请看下图:
我们通常说的水体深度,是指在忽略视线因素的情况,水面到水底的高度差。
在不追究细节的情况下,我们可以简单地使用高度差来作为水的深度。
一种可能的伪代码如下:
depth = clamp((g_waterLevel - v_position.y) * depthScale,0.0,1.0);
其中 depthScale 是我们的深度缩放因子,可以用来调节比例尺问题,以及水体能见度线性衰减速率。
而基于视线方向的水体厚度,是指视线方向与水平面和水底交点的距离差。 即图中 点 P1 到 点 P2 的距离。
下面我们来推导一下,使用 基于视线方向水体的厚度 来作为深度因子的公式。
许多朋友第一反应是解直线方程,但用空间向量的特性来求解会更容易。
为方便对照理解,再贴一次上面的图:
设观察方向为 viewDir,厚度为 depth 则有:
P1 + viewDir * depth = P2
分拆为分量运算可得:
P1.x + viewDir.x * depth = P2.x
P1.y + viewDir.y * depth = P2.y
P1.z + viewDir.z * depth = P2.z
可推导出:
depth = (P2.y - P1.y) / viewDir.y
由此可得如下计算公式:
vec3 viewDir = normalize(v_position.xyz - cc_cameraPos.xyz);
float depth = (v_position.y - g_waterLevel) / viewDir.y
depth = clamp(depth * depthScale,0.0,1.0);
比起直接使用水体深度来说,多了一次求 viewDir 单位向量的运算,以及一次除以 viewDir.y 运算。
在非极端情况下,多出的这一点纯逻辑运算在 GPU 上是可以忽略不计的,可以放心使用。
将上述公式添加到渲染对象的 Shader 中,并在折射渲染阶段启用,将结果存入 Alpha 通道即可。
项目中的 Shader 代码如下图所示:
最终得到的深度信息如下:
有了上面的深度信息,我们只需要在计算出折射颜色后,再用深度信息与水底颜色混合即可。Shader 代码如下图所示:
由于水体的可见度是非线性的,所以对 diffDepth 使用了 pow 函数,这个 power 参数默认是 2.0。
最终可以得到如下效果:
当我们把摄像机拉近,观察水面与物体交接处的时候,可以明显看到一条清晰的边界。
这条边界在反射越强的时候越明显,使我们的水面效果大打折扣。
好在我们已经有了深度信息,可以根据深度来判断出哪里靠近岸边,并修改菲涅尔因子,使反射越靠近岸边的时候越弱即可。
核心代码如下:
最终可以实现在全反射的情况下,水面与岸边依然平滑过渡。效果如下图所示:
再来一张远视角的图:
为了增强氛围感,DEMO 中使用了动态天空盒。
这是一个特别简单的高效的动态天空盒方案,仅使用了一个双层纹理混合的半球模型,调节两张纹理的水平方向流动速度即可。
所有效果参数均可调节,如下图所示:
本DEMO可从 Cocos 资源商城获取:
配套视频: