TA实践分享:材质与渲染——水体(Unity+UE)

TA实践分享:材质与渲染——水体(Unity+UE)

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


先看实际效果:

风格化Ocean渲染:

写实Water渲染:


来源:https://www.artstation.com/artwork/GaagkB

水体是我们最常见的材质之一,有海水、河水、湖水、积水……,有清澈的水、浑浊的水,有热带的水和寒带的水,毕竟水占地球表面积约71%,可谓各种各样、随处可见。但水体渲染涵盖了大量渲染难题,透明(最大的bug、性能问题源头之一)、次表面、镜面反射、折射、流体模拟与交互、随机波形等等,所以水体渲染既基础又复杂,下面让我们试着一口一口啃下这根硬骨头。

一、特征分析

下面主要从美术与物理方面分析水体的特征。

1. 波形、波纹和Flow
波形、波纹和Flow(流动)是液体流动性的体现,水在受到扰动时会产生波纹、涌动和流动效果。画画时我们可以通过流畅动态的线条、起伏变化的形状表现水在受到各种力(重力、风力、液体张力、月球引力等)作用后的表现。不同类型的波对于美术塑造画面氛围有着不同的奇效,平静无波给人一种宁静空寂的感觉,波涛汹涌给人一种激情澎湃、惊险刺激之感,水波潺潺给人一种自然活泼的感觉。此外,波浪的传播机制也会给画面带来一种动态的节奏,这也是我们为什么追求变化随机自然的波形,以避免画面节奏重复单调。

在TA的眼中,水波大致被分为波形、波纹和Flow(流动)三种,对应了三种视效差异明显的水流。

波形强调水面整体的起伏


来源:https://zhuanlan.zhihu.com/p/95917609

波纹强调水面细节的涌动


来源:https://www.artstation.com/artwork/DvAm19

Flow强调水向某方向的流动


来源:https://zhuanlan.zhihu.com/p/95917609

2. 反射(镜面、高光)、折射和菲涅尔
当光线从空气或其他介质射入水面时,部分光线以反射的方式从水面反射出来。这种反射遵循光线入射角等于反射角的规律,并且在适当的角度和条件下,会形成镜面反射和高光效果。镜面反射是指水面上出现的明亮、清晰的反射图像,通常呈现为反射的环境、天空、人物等。高光则是指水面上由光线直接反射而产生的强烈闪光部分,给人一种波光粼粼的浪漫梦幻,突出水体的亮度与光线的折射。


来源:https://www.artstation.com/artwork/EVO4Vq

折射是光线经过界面从一种介质进入另一种介质时,由于两种介质的密度不同而导致光线方向改变的现象。当光线从空气射入水中时,由于水的密度较大,光线被偏折,并且在适当的角度和条件下会产生折射现象。通过折射可以表现出水的透明度和质感,特别是随着法线和折射率变化导致的视线动态扭曲会产生一种独特的视觉效果。


来源:https://www.artstation.com/artwork/XnN83l

上图是水面的菲涅尔现象。将反射与折射放在一起分析是因为菲涅尔现象的存在使二者在透明材质中是融合伴生的效果。简言之,光线从空气射入水会产生明显的菲涅尔效应,即入射角(人眼观察水面某点的角度)越大反射越弱,折射越强。关于菲涅尔的原理与应用可以详细阅读下文:
《什么是菲涅尔 (Fresnel Effect)》

3. 焦散
焦散是折射现象的延申,当光线从空气射入水中时,光线会发生折射并产生焦散效应。真实的焦散不仅会产生耀眼的光斑,还因不同颜色的光线在折射时受到不同程度的偏离,出现一种类似彩虹的效果。焦散效果可以增强物体的透明感和真实感,使画面亮部细节更丰富,并且水底朦胧扭曲的焦散可以增强水体的立体感和动态感。


来源:https://www.artstation.com/artwork/4Xrq94

4. 次表面散射
次表面散射(Sub-Surface-Scattering)简称3S,用来描述光线穿过透明/半透明表面时发生散射的照明现象,可以抽象理解为光线在材质内部进行多次散射和从材质表面反射的过程。其成因主要由于物质内部材质分布不规则(例如有杂质)和折射率分布不均匀,次表面散射一般是在半透明的材质上表现明显,如玉石、皮肤、水果等。

上图是BSSRDF(Bidirectional Subsurface Scattering Reflection Distribution Function,双向子面散射反射分布函数)示意图,它用于计算光线与复杂材质相互作用时的散射和反射行为,以实现逼真的次表面散射渲染和光照效果。

上图是漫反射与次表面散射的效果对比示意图,我们可以通过比较总结一下次表面散射的美术特征。

  • 透亮,次表面散射反射使得光线能够从物体内部透射出来,使物体呈现出透亮的效果。
  • 朦胧,物体内的多次散射使得光线能够在物体内部进行扩散和传播,不会出现明显的光束或锐利的反射,这种效果类似于物体被轻薄的云雾包围,给人一种柔和、朦胧的感觉。
  • 分色,次表面散射反射通常会导致光线在物体内部被吸收或散射后改变其颜色。例如,皮肤内的血红蛋白、水果内的果汁等会引起光线的散射和颜色变化。这种色彩的变化为物体增添了生动感和真实感。


来源:https://zhuanlan.zhihu.com/p/95917609

5. 颜色(物质、光照、阴影、深度、远近)
水的颜色要受很多因素的影响,除了基础的光影,自身的溶解物与悬浮物的颜色,环境色的反射与折射外,还受水的透明度、深浅,水面的远近等因素的影响。

不同的物质有不同的颜色,溶解和包含了其他物质的水也会呈现对应的颜色,所以,不包含其他物质的纯水在常规光照下是无色透明的,溶解了泥土的泥水有着和泥土一样的褐黄色,血液是红色可以解释为水中含有大量的血红蛋白。当看到五彩斑斓的湖水我们会感慨自然的鬼斧神工,但《走进自然》之类的科普节目肯定会告诉我们这是因为水中含有各种各样的元素,甚至对人有危险。


多彩的盐湖

光对水颜色的影响可以用基础的PBR光照模型去理解。这里面包含光的颜色、强度、冷暖,光带来的高光、漫反射、镜面反射、折射等,想想海上升明月与海上升旭日在视觉上有什么区别又有什么相似?

水面阴影应该是一种很常见的自然现象,但是你能试着具体形容一下水面上的阴影吗?其实这还是有一定难度的,因为水面的阴影相比一般阴影更为多变,且会隐身。多变是指阴影的强弱与水的透明度有关,清澈的水阴影更弱,浑浊的水阴影更暗,并且不同的波纹会影响阴影的形状。隐身是指生活中我们很少提及水面阴影,更多的是说水面倒影,“倒影”这一词本身就融合了镜面反射与阴影两种自然现象,所以现实中我们一般很难区分水面的反射与阴影,并且水面反射相比阴影更能吸引我们的目光,所以阴影被隐身也是人眼的一种选择。


水面阴影

由于光的散射与折射不同深浅的水会有颜色差异。浅水相对较透明,因为光线在浅水中的传播路径较短,对光的吸收和散射较少。相比之下,深水由于光线在水中传播的距离较长,所以水分子和其中的溶解物质吸收和散射光线的机会就越多,导致光线的强度减弱,颜色变暗。

在浅水中,由于光线的折射角度较小,光线会多次反射和折射,使水看起来更亮、更透明,颜色呈现较浅的外观。深水中,光线的折射角度较大,会经过多次反射和折射后,大部分光线被吸收和散射,使水看起来较暗,颜色呈现较深的外观。

简言之,水的这种深度衰减使水有明暗虚实、颜色渐变的视觉效果,通过还原这种颜色的变化能够反映水的环境气候与清澈度。


来源:https://zhuanlan.zhihu.com/p/95917609

菲涅尔效应与大气散射使远近不同的水会有颜色差异。菲涅尔效应使入射角由大到小(距离由近到远)有折射到反射的渐变,而折射与反射会给人眼带来不同的颜色感知。同时,在更远距离,由于大气散射的存在光线会发生折射、散射和吸收,这些大气作用会使光线中的某些波长被过滤,导致颜色的变化。大气对光线中的低频成分(如红色光)被过滤较多,使水呈现较蓝绿色调。而近处的水受大气层的影响较少,颜色可能更接近原始颜色。并且在更远的距离,水面会融合更多雾的颜色。

这种水平距离的颜色渐变,可以增强画面的空间感,形成色彩透视效果,通过色彩的变化也能丰富画面的色彩节奏。下图展示了水面色调的变化。


来源:https://www.artstation.com/artwork/KJk0X

7. 泡沫(浪花、边缘)

白沫(Foam),在有些文献中也被称为Whitecap或White Water,是一种复杂的现象。即使白沫下方的材质具有其他颜色,白沫也通常看起来是白色的。出现这种现象的原因是因为白沫是由包含空气的流体薄膜组成的。随着每单位体积薄膜的数量增加,所有入射光都被反射而没有任何光穿透到其下方。这种光学现象使泡沫看起来比材质本身更亮,以至于看起来几乎是白色的。[1]


来源:https://www.artstation.com/artwork/0d5XG

我把泡沫简单分为浪花泡沫与边缘泡沫两种,浪花泡沫主要指随着波浪飘动的泡沫,主要分布在浪尖周围;边缘泡沫主要指水面与其他物体交界处产生的泡沫,包括与岸边的交界处,以及与可移动物体(例如玩家)的交界处。泡沫的表现有许多作用,其明亮洁白的颜色在水中较为突出,可以以近似线条的形式描绘出海边蜿蜒曲折的形状,其来回飘荡的节奏可以增强波浪的动势。


来源:https://www.artstation.com/artwork/Vgg024


来源:https://www.artstation.com/artwork/Vgg024

二、性能分析

文章开始就已经打了一个预防针,但水体渲染涵盖了大量渲染难题,这些难题基本对应了水体渲染的性能问题。水波模拟(包含网格面数)、反射和折射(包含半透明混合)计算、粒子特效(水花)、材质实时交互、次表面散射等。

1. 水波模拟
我们在前面把水波分为了波形、波纹和Flow三种形式,其中最大的性能瓶颈是波形,而波纹与Flow多基于预渲染的纹理以简单UV动画的形式实现效果,既不需要复杂的算法也不需要较多顶点数量的模型。

波形渲染性能开销大不大不是一件绝对的事情,因为我们有许多水体波形实现的方法,但大体上波形渲染效果的真实度与性能开销成正比。如果你想要影视级别的水波肯定没有办法实时渲染实现,当然我们也可以用预渲染的方法实现真实细致的水波,但重复、死板的感觉不可避免。水波渲染本质是一种物理模拟,我们可以很容易让一个平面模型动起来,困难点在于如何模拟水面真实的物理波动,所以平衡效果与性能是解决波形模拟的关键。

GAMES101课程有讲解图形学物理模拟的课程,可以看看下面简单的笔记链接:

《GAMES101 闫令琪〈现代计算机图形学入门〉学习笔记P22》

基于顶点动画模拟水的流动(本文暂不讨论粒子碰撞模拟水体的方法)必然要考虑模型顶点的疏密与数量,为了优化模型顶点、面数,水体渲染往往会使用LOD技术和动态曲面细分技术。


动态曲面细分示意图

2. 反射、折射
如果想要实现更准确的反射、折射效果,我们需要额外渲染一次周围的环境并做特殊的采样计算,这将使性能消耗成倍增加。

能够实现水面反射效果的方法并不少,这些方法各有优劣,它们的性能消耗排序大致为:反射探针>平面反射>屏幕空间平面反射>立方体贴图,而它们的效果基本与性能消耗成正比,所以使用何种方案要根据项目具体情况来考虑。下面是关于这几种反射方案的详细分析链接:

《反射探针(Reflection Probe)》
《Unity 实现平面反射(基于 URP)》
《Unity URP实现屏幕空间平面反射(SSPR)》
《详解Cubemap、IBL与球谐光照》

折射效果的实现相较反射要简单些,也并没有太多的方案供抉择。折射效果主要出现在透明材质上,在实时渲染中常用的方法是透明度混合或环境贴图和纹理映射,而要产生水面折射感主要使用纹理映射方法。但透明度混合或环境贴图和纹理映射都存在透明排序的问题,渲染排序容易产生Bug,因此需要格外注意。

3. 次表面散射
次表面散射涉及到对光线在物体内部的路径进行跟踪和采样。此过程对算力和内存要求较高,尤其是在复杂的场景中,需要跟踪大量的光线和进行多次采样、迭代计算,增大了计算量和内存开销。

我们比较常用的次表面散射方案有如下5种,大家可以根据项目的需求,平衡性能与效果,选择合适的方案。

  • 屏幕空间次表面散射SSSSS(5S)(屏幕空间模糊Screen Space Blur)
  • 可分离的次表面散射SSSS(4S)
  • 预积分次表面散射Pre-Integrated
  • 路径追踪次表面散射(光线步进)
  • 快速次表面散射(Fast Subsurface Scattering

《Unity Shader - 屏幕空间次表面散射》
《【详解4S皮肤】可分离次表面散射Separable SSS(含透射项)》
《从零开始的预积分次表面散射》
《光线追踪次表面散射(Ray-Traced Subsurface Scattering)》
《Unity Shader - 伪次表面散射模拟》

4. 粒子特效
为制作一个完整的水体效果,我们有时也会使用粒子特效实现浪花、溅射、垂直水流等水的效果,所以特效的性能优化也是整个水体性能分析的一部分。关于特效的部分作者还在学习中,以防有错漏,故推荐两篇自己学习特效性能相关的笔记,供读者了解阅读。

《技术美术百人计划-美术 4.4 特效优化注意点美术向 笔记》
《技术美术百人计划-美术 4.5 特效规范及拆分实现 笔记》

三、实现方案

从上面的特征分析可以看到水有复杂的美术表现,想要还原一个完美的水很可能是一个吃力不讨好的事情,我们往往根据项目需求和水体材质的重要性对一些细微特征做删减,记住我们想要的是符合现实认知、视觉效果好、性能消耗可接受的水体,平衡正确、美观、性能也是一项重要的能力。

下面我们从一个简单的材质开始一步步实现一个相对完整的水体效果。

水体基础
水最核心、基础的美术特征就是透明、流动、反射。

1.透明
实现透明效果最常用的方法是透明度混合与颜色Lerp。

Unity:需要在Shader中设置:

Tags { "Queue"="Transparent" "RenderType"="Transparent" }

Blend SrcAlpha OneMinusSrcAlpha // 使用透明混合模式

然后将透明度传递给输出颜色的Alpha通道即可。

UE:只需要设置材质的Blend Mode为“Translucent”或“Additive”(具体情况根据您的需求而定),然后将透明度数值连接到材质的透明度控制的“Opacity”接口即可。

此外,我们还可以使用直接使用Lerp函数/节点混合水的颜色与水滴的颜色,这样可以避免使用透明材质带来的性能消耗与Bug。

2.流动
我们可以用法线贴图来模拟水面波纹的效果,想让我们的波纹动起来只需给法线贴图加上UV动画。

Unity:
下面提供了一个实现UV动态偏移的简单函数,用函数实现UV随时间的偏移后就可以直接采样水纹法线了。

float2 Panner(float2 uv, float2 direction, float speed)
{
    return uv + normalize(direction) * speed * _Time.y;
}

UE:

水波法线:

一层水波法线延固定方向平移看上去有些呆板重复,我们可以对水波法线做两次不同的缩放和不同方向的偏移,然后将两次的采样结果做一个混合,这样效果看上去好多啦。

甚至我们可以使用下面的函数/节点对法线做更随机混沌的融合,使我们的水波更为自然。

float3 MotionFourWayChaos(sampler2D tex, float2 uv, float speed, bool unpackNormal)
    {
        float2 uv1 = Panner(uv + float2(0.000, 0.000), float2(0.1,  0.1), speed);
        float2 uv2 = Panner(uv + float2(0.418, 0.355), float2(-0.1, -0.1), speed);
        float2 uv3 = Panner(uv + float2(0.865, 0.148), float2(-0.1,  0.1), speed);
        float2 uv4 = Panner(uv + float2(0.651, 0.752), float2(0.1, -0.1), speed);

        float3 sample1;
        float3 sample2;
        float3 sample3;
        float3 sample4;

        if (unpackNormal)
            {
                sample1 = UnpackNormal(tex2D(tex, uv1)).rgb;
                sample2 = UnpackNormal(tex2D(tex, uv2)).rgb;
                sample3 = UnpackNormal(tex2D(tex, uv3)).rgb;
                sample4 = UnpackNormal(tex2D(tex, uv4)).rgb;

                return normalize(sample1 + sample2 + sample3 + sample4);
            }
        else
            {
                sample1 = tex2D(tex, uv1).rgb;
                sample2 = tex2D(tex, uv2).rgb;
                sample3 = tex2D(tex, uv3).rgb;
                sample4 = tex2D(tex, uv4).rgb;

                return (sample1 + sample2 + sample3 + sample4) / 4.0;
            }
    }

3.反射
反射的实现有简单也有复杂的方案,最简单的方案就是在PBR材质上把光滑度调高一些,这样我们就可以在光滑的水体材质上看到环境的反射,只不过这个反射有不太正确、不太完整、缺少动态等问题,但是这对一个简单的水体材质来说已经勉强够用了。

下面简单梳理一下实现更复杂反射的一些方案,包括:反射探针、平面反射、屏幕空间反射、屏幕空间平面反射和光线追踪等。

反射探针:
反射探针为引擎内置的功能,可以通过下面三个文档详细了解反射探针的功能与用法。

《反射探针(Reflection Probe)》
《反射探针 - Unity 手册》
《反射捕获》

平面反射:
下面分别是平面反射的原理介绍,以及Unity与UE引擎实现平面反射效果的方法。

《Unity 实现平面反射(基于 URP)》
《Planar Reflections for Unity》
《平面反射》

屏幕空间反射:
下面是UE引擎的屏幕空间反射方案介绍。

《屏幕空间反射》

屏幕空间平面反射:
下面分别是SSPR的原理介绍,以及Unity实现屏幕空间平面反射的方法。

《【URP】屏幕空间平面反射(ScreenSpacePlanarReflection)学习笔记》
《UnityURP-MobileScreenSpacePlanarReflection》

光线追踪:
下面是有关UE光线追踪反射的介绍,可以大致了解一下。
https://www.youtube.com/watch?v=quynkx1dTkI

几种镜面反射方案的对比:

  1. CubeMap只能反射Map上的颜色,静态。
  2. 反射探针,适合室内场景、静态场景、并且消耗存储空间。
  3. 平面反射需要以平面为对称面,用第二个相机渲染一张RT,然后用反射矩阵计算反射效果,缺点只适合平面,不太适合移动端。
  4. SSR,屏幕空间,适合延迟管线,不局限平面物体,需要使用光线步进算法性能消耗大,只能反射屏幕空间的信息。屏幕空间的反射需要做模糊处理。
  5. SSPR,适合平面物体,适合移动端,也只能反射屏幕空间信息。用深度图还原屏幕空间3D坐标,以反射平面为对称平面,计算屏幕空间颜色在平面上对应的反射点,只需要知道平面空间颜色的目标坐标位置(Compute Shader计算),在离相机近的平面可能会出现重叠,需要选择远更远的像素。
  6. 光线追踪方案可以真实的反射整个环境空间,但性能消耗极大,并不适合实时渲染。

我们可以通过上面的任意一种方案实现自己的反射效果,只是水面的反射效果会随着水波摇曳、拉伸、模糊,给镜面反射加上扰动、缩放、模糊等处理将会得到更好的效果,大家可以根据自己对水面倒影的理解发挥想象,加上自己喜欢的效果。

上面我们仅仅在实现镜面反射的效果,我们在特征分析中提到的高光与波光粼粼效果也是光线反射的结果。如果你想要实现更正确、好看的效果,可以升级你的材质以实现高光与波光粼粼的视觉效果。

高光(Specular):我们需要根据波形(波形的实现将在下面介绍)重新计算法线朝向,然后用简单的Blinn-Phong光照模型,或者直接把波形法线与前面采样法线贴图的结果融合,以计算高光效果。

下面链接介绍了如何获取顶点动画后模型的正确法线:
https://www.youtube.com/watch?v=P8-oGG2d1RY

波光粼粼:我们可以通过下面的代码实现波光粼粼的闪烁效果。

float3 MotionFourWaySparkle(sampler2D tex, float2 uv, float4 coordinateScale, float speed)
    {
        float2 uv1 = Panner(uv * coordinateScale.x, float2(0.1,  0.1), speed);
        float2 uv2 = Panner(uv * coordinateScale.y, float2(-0.1, -0.1), speed);
        float2 uv3 = Panner(uv * coordinateScale.z, float2(-0.1,  0.1), speed);
        float2 uv4 = Panner(uv * coordinateScale.w, float2(0.1, -0.1), speed);

        float3 sample1 = UnpackNormal(tex2D(tex, uv1)).rgb;
        float3 sample2 = UnpackNormal(tex2D(tex, uv2)).rgb;
        float3 sample3 = UnpackNormal(tex2D(tex, uv3)).rgb;
        float3 sample4 = UnpackNormal(tex2D(tex, uv4)).rgb;

        float3 normalA = float3(sample1.x, sample2.y, 1);
        float3 normalB = float3(sample3.x, sample4.y, 1);

        return normalize(float3((normalA + normalB).xy, (normalA * normalB).z));
    }

float3 sparkly1 = MotionFourWaySparkle(_SparklesNormalMap, worldPosition.xz / _SparkleScale, float4(1,2,3,4), _SparkleSpeed);
float3 sparkly2 = MotionFourWaySparkle(_SparklesNormalMap, worldPosition.xz / _SparkleScale, float4(1,0.5,2.5,2), _SparkleSpeed);
float sparkleMask = dot(sparkly1, sparkly2) * saturate(3.0 * sqrt(saturate(dot(sparkly1.x, sparkly2.x))));
sparkleMask = ceil(saturate(pow(saturate(sparkleMask), _SparkleExponent)));
float3 sparkleColor = _SparkleColor * sparkleMask;

+折射、菲涅尔

1.折射
水的折射在视觉上给人的感觉就是将水下的内容随水波慢慢扭动,所以最简单的方法就是渲染一张水平面以下部分的RenderTexture,然后用水波法线或噪声纹理扰动UV,然后采样这张RenderTexture。


折射弯曲示意图

Unity:
我们在URP管线中勾选渲染OpaqueTexture,然后在Shader中声明_CameraOpaqueTexture后可以直接使用管线已经渲染好的屏幕空间不透明Queue的颜色纹理。在Build-in管线中我们可以通过GrabPass {_CameraOpaqueTexture}渲染我们需要的纹理。

float2 offset = normalWS.xy * _Distortion * _CameraOpaqueTexture_TexelSize.xy;
screenCoord.xy = offset * i.scrPos.z + screenCoord.xy;
float3 fragColor = tex2D(_CameraOpaqueTexture, screenCoord.xy).rgb;

UE:
UE材质提供了两种实现折射的方案:折射率(Index of Refraction)、像素法线偏移(Pixel Normal Offset)。下面提供了使用两种折射方案的操作方法,可以根据需要选择任意一种实现你的水体折射效果。

《使用像素法线偏移实现折射》
《使用折射》

2.菲涅尔
反射与折射的实现都已完成,现在是时候实现菲涅尔部分了,菲涅尔决定了反射或折射的正确显现。

Unity:
《〈Unity Shader 入门精要〉从Bulit-in 到URP (HLSL)Chapter10-Fresnel》

float fresnel = pow(saturate(1 - dot(worldNormal, viewDirWS)), _FresnelPower);
half3 color = lerp(refraction, reflection, saturate(fresnel));

UE:

+波形
关于波形毛星云前辈的这篇文章做了非常细致的分享,本文的大量内容也参考了这篇文章。

《真实感水体渲染技术总结》

这篇文章把波形的实现分为七大类:线性波形叠加法、统计模型法、波动粒子方法、物理方程方法、预渲染方法和其他方法。其中线性波形叠加法、预渲染方法多用于实时渲染的游戏项目;统计模型法、物理方程方法多用于离线渲染的影视项目;波动粒子方法则更适合模拟物体与水的交互效果。

考虑到本文主要面向游戏渲染,所以仅就线性波形叠加法、预渲染方法实践讲解。

线性波形叠加法意如其名,具体实现方法是将不同方向大小的线性波形函数累加以构建水面的波动,如下图。


来源:https://zhuanlan.zhihu.com/p/95917609

常用的两种波形函数为正弦波(Sinusoids Wave)和Gerstner波(Gerstner Wave)。

1.正弦波(Sinusoids Wave)
正弦波法渲染出的水面波形较为平滑、圆润,适合表现平静湖面的水波。

正弦波最基础的算法是在每个时间点上给定X,Y位置用Sine函数算出水面对应的高度(Y值)。下面是是实现正弦波的Shader代码,可以作为参考。

// Returns a simple waveform (to be used as a height offset) from the input wave parameters.
float SimpleWave(float2 position, float2 direction, float wavelength, float amplitude, float speed)
    {
        float x = PI * dot(position, direction) / wavelength;
        float phase = speed * _Time.y;
        return amplitude * sin(x + phase);
        return amplitude * (1 - abs(sin(x + phase)));
    }

float GetWaveHeight(float2 worldPosition)
    {
        float2 dir1 = float2(cos(PI * _Wave1Direction), sin(PI * _Wave1Direction));
        float2 dir2 = float2(cos(PI * _Wave2Direction), sin(PI * _Wave2Direction));
        float wave1 = SimpleWave(worldPosition, dir1, _Wave1Wavelength, _Wave1Amplitude, _Wave1Speed);
        float wave2 = SimpleWave(worldPosition, dir2, _Wave2Wavelength, _Wave2Amplitude, _Wave2Speed);
        return wave1 + wave2;
    }

    // Approximates the normal of the wave at the given world position. The d
    // parameter controls the "sharpness" of the normal.
float3x3 GetWaveTBN(float2 worldPosition, float d)
    {
        float waveHeight = GetWaveHeight(worldPosition);
        float waveHeightDX = GetWaveHeight(worldPosition - float2(d, 0));
        float waveHeightDZ = GetWaveHeight(worldPosition - float2(0, d));

        // Calculate the partial derivatives in the Z and X directions, which
        // are the tangent and binormal vectors respectively.
        float3 tangent = normalize(float3(0, waveHeight - waveHeightDZ, d));
        float3 binormal = normalize(float3(d, waveHeight - waveHeightDX, 0));

        // Cross the results to get the normal vector, and return the TBN matrix.
        // Note that the TBN matrix is orthogonal, i.e. TBN^-1 = TBN^T.
        // We exploit this fact to speed up the inversion process.
        float3 normal = normalize(cross(binormal, tangent));
        return transpose(float3x3(tangent, binormal, normal));
    }

v2f vert (appdata v)
        {
            v2f o;

            o.worldPosition = mul(unity_ObjectToWorld, v.vertex).xyz;
            o.worldPosition.y += GetWaveHeight(o.worldPosition.xz);
            o.vertex = mul(UNITY_MATRIX_VP, float4(o.worldPosition, 1));
        }

float4 frag (v2f i) : SV_Target
        {
            // ------------------- //
            // NORMAL CALCULATIONS //
            // ------------------- //

            // Get the tangent to world matrix.
            float3x3 tangentToWorld = GetWaveTBN(i.worldPosition.xz, 0.01);

            // Sample the wave normal map and calculate the world-space normal for this fragment.
            float3 normalTS = MotionFourWayChaos(_WaveNormalMap, i.worldPosition.xz/_WaveNormalScale, _WaveNormalSpeed, true);
            float3 normalWS = mul(tangentToWorld, normalTS);
        }

源码链接:
https://github.com/ding-yan-qing/unity-stylized-water/tree/main/Assets/Stylized%20Water/Shaders

2.Gerstner波(Gerstner Wave)
Gerstner波是正弦波的进化版,相比正弦波水的波形更为尖锐和有方向感。简言之波峰尖锐,波谷宽阔,适合模拟海面波涛。Gerstner波是目前平衡效果与性能的最佳方案,把Gerstner波与粒子特效水波结合能模拟出3A游戏品质的水体效果。

关于Gerstner波实现相对复杂,受本文篇幅限制不能详细简介,下面提供Unity与UE实现Gerstner波的教程链接,供读者学习参考。

Unity:
https://catlikecoding.com/unity/tutorials/flow/waves/

UE:
https://www.youtube.com/watch?v=BJSMVvZMQ1w

预渲染方法主要包含四种方案:顶点高度位移贴图、FlowMap、离线FFT贴图烘焙和离线流体帧动画烘焙。它们都采用预渲染的方式把水体复杂的运动状态存储在贴图之上,然后在实时渲染的过程中读取贴图的数据直接还原水体的运动状态,省去用复杂算法还原运动状态的过程以提升渲染速度。但是由于贴图存储空间的限制,我们不能还原水无限运动变化的状态,所以预渲染方法有一个共同的缺陷——周期性重复。

3.顶点高度位移贴图
顶点高度位移贴图通过使用纹理数据来修改模型顶点的位置,从而实现形变效果。用顶点高度位移贴图实现波形的基本思路与前面用UV动态偏移法线贴图实现流动效果相似,只不过一个是改变模型的顶点高度,一个是改变模型的法线。

注意在顶点着色器采样贴图需要指定贴图的采样层级,主要由于顶点着色器阶段没有办法使用ddx、ddy函数(它们对于求解贴图层级很关键)。

下面是采样计算位移贴图的简单示例:

void vert(inout appdata_full v, out Input o) {
    float4 displacement = tex2Dlod(_DisplacementTex, float4(v.texcoord.xy, 0, 0));
    v.vertex.xyz += normalize(displacement.rgb - 0.5) * _DisplacementAmount;
}

位移贴图不仅可以模拟水面的波形,还可以实现丰富多样的效果,见下图。


来源:https://www.artstation.com/artwork/gA018


来源:https://www.artstation.com/artwork/gA018

4.FlowMap
FlowMap本质是记录UV位移向量的预处理贴图,你可以理解为FlowMap按照某种预设的规律驱动UV坐标向特定方向循环运动,然后借用UV的运动间接驱动颜色或法线纹理运动。因为驱动UV运动的向量是二维的,只需要贴图RGB中的两个通道存储,剩下的一个通道我们可以用来存储运动强度/速度或者其他数据。


来源:https://zhuanlan.zhihu.com/p/95917609

此外,FlowMap不仅可以用来模仿水流效果,也常被用于火焰、云层、流沙、草地风效等效果。它可以为表面添加动态和流动的细节,增强真实感和视觉效果。

Unity:

//get and uncompress the flow vector for this pixel
float2 flowmap = tex2D( FlowMapS, tex0 ).rg * 2.0f - 1.0f;
float cycleOffset = tex2D( NoiseMapS, tex0 ).r;
float phase0 = cycleOffset * .5f + FlowMapOffset0;
float phase1 = cycleOffset * .5f + FlowMapOffset1;

// Sample normal map.
float3 normalT0 = tex2D(WaveMapS0, ( tex0 * TexScale ) + flowmap * phase0 );
float3 normalT1 = tex2D(WaveMapS1, ( tex0 * TexScale ) + flowmap * phase1 );
float flowLerp = ( abs( HalfCycle - FlowMapOffset0 ) / HalfCycle );
float3 offset = lerp( normalT0, normalT1, flowLerp );

UE:
下图分别为FlowMap MaterialFunction的内部材质蓝图节点,以及用FlowMap MaterialFunction采样流水法线的蓝图节点。


FlowMap Function


来源:https://www.youtube.com/watch?v=9N-pQNPChxU

如果想要更详细的了解FlowMap的制作与使用,可以阅读下面文章链接。

《技术美术百人计划-图形 2.8 flowmap的实现 笔记》

5.离线FFT贴图烘焙

离线FFT贴图烘焙即离线渲染烘焙出FFT(快速傅里叶变换)模拟海洋波浪的一系列Texture,包括高度图、法线图、Flow图、Foam图等。快速傅里叶变换(Fast Fourier Transform,FFT)和快速逆傅里叶变换(Inverse Fast Fourier Transform,IFFT)是一对互逆的算法(即时域与频域的互相转换),可以用于模拟细节丰富、效果逼真的海洋波浪效果,但其算法对于实时渲染来说过于昂贵,所以我们选择将其离线烘焙为一些四方连续、时间循环的贴图,并在运行时进行采样还原效果。这一方法最早由《刺客信条3》团队提出。


来源:https://zhuanlan.zhihu.com/p/95917609

Unity:
Unity实现该方案可以参考下面链接:

《从零开始的水渲之形状Shape[二]》

UE:
UE实现该方案可以参考下面链接:

《虚幻5渲染编程(DCC工具篇---houdini)【Houdini Ocean Simulation To UnrealEngine】》

6.离线流体帧动画烘焙
离线流体帧动画烘焙方案的思路很简单,与我们常见到的序列帧动画的实现并无不同。该方案把水面的波形/波纹看作是连续播放的序列帧动画,不同之处在于其播放的贴图为离线渲染烘焙的法线贴图或者高度图。该方案实现简单,但其动画形式较为重复单调。

下图为离线烘焙的水面涟漪序列帧法线贴图。

Unity:
下面为一个采样播放序列帧法线贴图的函数代码:

float3 SequenceAnimationFrame(sampler2D Tex, float2 uv, int Count, float speed)
{
    float time = floor(_Time.y * speed);
    float row = floor(time / Count) + 1;
    float column = time - row * Count;

    float2 uvOffset = float2(uv.x / Count, uv.y / Count);
    uvOffset += float2(column / Count, -row / Count);

    return UnpackNormal(tex2D(Tex, uvOffset)).rgb;
}

UE:
我们可以直接使用UE提供给我们的FlipBook MaterialFunction。


FlipBook的内部材质节点

+颜色变化

1.次表面散射
前面在性能分析中提到数种实现次表面散射的方案,但考虑到对水体材质的适用性以及性能压力,真正适用水体渲染的次表面散射方案主要是预积分次表面散射与快速次表面散射(Fast Subsurface Scattering)。

因为预积分方法同样适用于实现下面的深度颜色变化,所以我们先尝试快速次表面散射。

快速次表面散射的基本思路是把光源分为两部分:一部分光源可看作不透明物体受到的所有光源照射(我们把它看作图中的L),另一部分光源是前面那部分光源进入半透明物体后所内部散射的光源(我们把它看作是图中的-L)。从图中可以看到受-L光影响的物体,其背光面将会受到-L光柔和的照射。

我们把光源分成了两部分之后,可以采用两种光照模型分别计算两种光源的照射结果。对于L光源我们采用常用的PBR光照模型即可。对与-L光源我们将使用下面的光照模型公式计算,最终把两个光照模型计算的结果相加之后就可以得到我们想要的效果。

上面的次表面散射计算公式其实就是将背光面法线与光源L的半程向量(halfway direction)取负后与摄像机观察方向做点积运算,其计算思路与经典的Blinn-Phong光照模型极为相似。关于快速次表面散射更为详细的分析可以阅读下面链接。
https://www.alanzucconi.com/2017/08/30/fast-subsurface-scattering-1/

下面将分析Unity与UE的具体实现。

Unity:

float3 backLitDir = normalWS * _SSSDistortion + (_MainLightPosition.xyz - float3(0, worldPosition.y, 0));
float backSSS = saturate(dot(viewDirWS, -backLitDir));
float3 sssColor = _SSSColor * pow(backSSS, _SSSPower) * _SSSScale;

UE:

此外,毛星云前辈分享了一种适合与FFT波形组合使用的次表面散射实现方式,下面简短引用,以供读者参考。

经过Crest Ocean System改进的《盗贼之海》的SSS方案可以总结如下:
假设光更有可能在波浪的一侧被水散射与透射。
基于FFT模拟产生的顶点偏移,为波的侧面生成波峰Mask。

根据视角,光源方向和波峰Mask的组合,将深水颜色和次表面散射水体颜色之间进行混合,得到次表面散射颜色。
将位移值(Displacement)除以波长,并用此缩放后的新的位移值计算得出次表面散射项强度。

对应的核心实现代码如下:

float v = abs(i_view.y);
half towardsSun = pow(max(0., dot(i_lightDir, -i_view)),_SubSurfaceSunFallOff);

half3 subsurface = (_SubSurfaceBase + _SubSurfaceSun * towardsSun) *_SubSurfaceColour.rgb * _LightColor0 * shadow;

subsurface *= (1.0 - v * v) * sssIndensity;

col += subsurface;

其中,sssIndensity,即散射强度,由采样位移值计算得出。


图 《Crest Ocean System》中基于次表面散射近似的水体表现


图 《盗贼之海(Sea of Thieves)》中基于次表面散射近似的水体表现

2.深度颜色
水体深度颜色变化主要由光线体积散射衰减造成,抛开各种物理原理去理解其逻辑就是:水体深度对水体颜色的影响。所以不论使用什么方案实现该效果,最核心的功能是获得水的深度并根据深度匹配水的颜色。下面我们将分享基于预积分和指数函数两种方案实现该效果。

我们在前面次表面散射实现中有提到预积分可以用来实现次表面散射效果,但该方案是基于水体的深度值采样LUT纹理,故放在深度颜色实现中讲解。下图是水的深浅对应水的颜色的LUT纹理(我们可以在这个纹理的Alpha通道中加入水的深浅对应的透明度)。


来源:https://zhuanlan.zhihu.com/p/95917609

有了LUT纹理之后我们就可以直接计算水的深度并采样,下面是Unity与UE的具体实现。

Unity:

//屏幕空间采样颜色和深度
float fragDepth = tex2D(_CameraDepthTexture, screenCoord.xy).x;
//海水深度以及与视线距离对颜色的影响
//表现水的清澈程度
float opticalDepth = LinearEyeDepth(fragDepth, _ZBufferParams) - LinearEyeDepth(i.vertex.z, _ZBufferParams);
float4 DepthColor = tex1D(_LUTTex, opticalDepth);

UE:
关于水体深度的详细计算可以看下面的教程,获得水体深度后直接用深度采样LUT纹理即可。
https://www.youtube.com/watch?v=TObymSnTwV0&t=302s

如果我们没有LUT纹理,我们可以选择方案二,用exp()函数处理深度值后用来插值我们在材质面板输入的水面颜色与水底颜色。

上图是exp(x)的坐标图形,其(0,-♾️)对应的(1,0)的缓慢衰减,我们希望通过这个函数实现深度由浅到深的缓慢衰减(符合光线体积散射衰减规律),并将其归一化到(0,1)以供后续用Lerp()进行颜色插值。上面已经获得了水体的深度,下面在Unity与UE中实现该方案的后续计算。

Unity:

float transmittance = exp(-_DepthDensity * abs(opticalDepth));
//用深度造成的体积散射lerp深度颜色与屏幕空间颜色
float3 baseColor = lerp(_DeepColor, fragColor, transmittance);

UE:

3.距离颜色
要实现不同距离的颜色渐变我们可以直接使用菲涅尔数值来插值颜色,因为我们可以把水面看作是法线朝上的平面,菲涅尔代表的视线入射角大小正好与水面距离的远近成负相关,且数值范围在(0,1)区间。下面再Unity与UE中实现这个简单效果。

Unity:

float fresnel = pow(saturate(1 - dot(float3(0, -1, 0), viewDirWS)), _FresnelPower);
baseColor = lerp(baseColor, _FarColor, fresnel);

UE:

4.阴影颜色
我们在前面分析了水面阴影与普通阴影的不同之处,如果想要更好看的水面阴影我们可以用水的透明度控制阴影的颜色深浅,通过阴影UV的处理改变阴影的形状。由于引擎没有在材质/Shader中提供阴影的可编程,仅提供了可配置选项,所以对阴影的深度修改不可避免的要自定义渲染管线。由于此部分难度较高,作者在此处主要抛砖引玉,想要提高阴影效果的读者可以进一步探索尝试。

Unity:

//计算阴影
VertexPositionInputs vertexInput;
vertexInput.positionWS = worldPosition;
float4 shadowCoord = GetShadowCoord(vertexInput);
half shadowAttenutation = MainLightRealtimeShadow(shadowCoord);
//控制阴影颜色
color = lerp(color, _ShadowColor, (1.0 - shadowAttenutation));

UE:
可也参考这位知乎网友分享的在UE中修改阴影渲染的方案。

《UE5.21风格化渲染探索01---阴影获取》

+泡沫
在特征分析中我们把水体泡沫分为浪花泡沫与边缘泡沫,其中边缘泡沫包括静态物体的边缘与动态物体的边缘。两种泡沫的实现方式有所不同,下面我们将依次介绍。

浪花泡沫
我们其实在前文波形的实现中已经提到过一种浪花泡沫方案,即在烘焙离线FFT高度、法线贴图的同时我们也可以制作与之对应的Foam贴图,由于此方案是整体离线预渲染水体的一部分,且材质制作思路就是将烘焙好的贴图进行采样,所以此处不再具体展开实现该方案。

下面将分享比较主流的Saturate高度方案的实现,其思路是将一个制作好的白沫纹理在高于某一高度Ho的顶点上进行颜色混合。用于混合的参数计算公式如下:


来源:https://zhuanlan.zhihu.com/p/95917609

其中,HMAX是泡沫最大时的高度,Ho是基准高度,H是当前高度。

下面分享一下Unity与UE的实现方案。

Unity:

//用自定义方向和波浪高度控制泡沫出现的位置
float2 flowUV = worldPosition.xz * _WiggleUV + frac(float2(0.0, -0.1) * _Time.y * 0.1 * _FlowTime);
float flow = tex2D(_FlowTex, flowUV).r * _DistortionStrength;
float2 foamUV = lerp(worldPosition.xz, worldPosition.xz + float2(flow, flow), 0.5);
half foamTex = tex2D(_FoamTex, foamUV / _FoamTexScale).r;
half foamNoise = tex2D(_FoamNoiseTex, worldPosition.xz / _FoamNoiseScale).r;
float foam = saturate(dot(float3(_FoamDirection.x, 0, _FoamDirection.y), normalWS)) * smoothstep(_FoamDirection.z, _FoamDirection.w, worldPosition.y);
half3 waveFoam = lerp(0, _WaveFoamColor * foamTex, foam * foamNoise);

UE:

现在我们已经获得了一个简单的白沫效果,如果你想继续丰富你的效果,你可以为你的泡沫加上UV扰动、噪音遮罩、序列帧动画等效果,尝试一下,找到你最喜欢的泡沫。

边缘泡沫
目前制作水体边缘泡沫的绝大多数方案的基本思路都是遮罩+噪声+FoamTexture,区别只在于具体制作细节的不同尝试。由于边缘泡沫多分布于岸边较浅区域,所以我们可以直接使用前面计算水深颜色时获得的水深图,制作一个边缘泡沫遮罩。


来源:知乎@Shawoxo

接下来我们可以对遮罩的效果做一些优化,可以看到原始的遮罩边缘生硬不自然,我们可以先将其与一张噪声图做一些混合或者扰动,让边缘更加的随机自然。并且静态的遮罩并不符合自然规律,我们需要给它加上一些运动变化,运动变化的实现方法也是五花八门,我们既可以给刚才的噪声混合、扰动加上一些时间变化,也可以用Sine函数实现环形收敛动画。


用Sine函数实现环形收敛动画

除了对遮罩效果做优化我们也可以像制作浪尖泡沫那样为FoamTexture加上UV扰动、噪音遮罩、序列帧动画等效果。下面来看一下在Unity与UE的具体实现方法吧。

Unity:

//通过UV动画采样Noise,基于屏幕空间深度实现水面边缘检测,再用sine函数实现向内收缩的多层环状遮罩
float2 samplerUV = float2(_Time.y * _WaveControl, 0);
half distortNoise = tex2D(_EdgeFoamNoise, worldPosition.xz / _NoiseScale + samplerUV).r;
float edgeFoamMask = 1 - smoothstep(-1.0, _EdgeFoamDepth, opticalDepth);
edgeFoamMask = saturate(sin((edgeFoamMask - _Time.y * _EdgeSpeed) * PI * _EdgeAmount)) * edgeFoamMask * distortNoise;
float3 edgeFoamColor = lerp(0, _EdgeFoamColor, edgeFoamMask);

UE:

https://www.youtube.com/watch?v=oI_q5g3580I

+焦散
目前在实时渲染中焦散效果的实现大多基于焦散贴图,差别主要在采样方法的不同。

我们可以使用的采样方法包括:

  1. 直接在水面采样渲染焦散,该方法简单、性能节省,但无法贴合凹凸不平的水底,如果水底地形过于崎岖会显得悬浮、不够真实。

  2. 在地形材质中采样渲染焦散,以笔刷画在水底地形上。缺点是会增加地形材质系统的复杂性,且焦散范围固定,不会随水的潮起潮落改变分布,与水体材质效果不够和谐。

  3. 在贴花材质中采样渲染焦散,通过贴画粘贴在水底地形表面。缺点和方法2一样不能随水的流动改变分布,并且大面积使用贴花性能消耗极大,因此只适合小面积水域。

同时,我们还可以通过多种方法使焦散的运动更随机自然,包括多层采样叠加混合和UV噪声扰动等。

此外,前面特征分析中有提到光线折射时不同颜色光波的折射角度有偏差会使焦散产生一种多彩的效果,实现彩色焦散效果我们可以简单地使用彩色焦散贴图,也可以在采样时对RGB颜色通道做细微的偏移。

Unity:
下面实现在水面采样渲染焦散方案。

float2 causticsUV = worldPosition.xz / _CausticsScale;
float3 causticsColor = MotionFourWayChaos(_CausticsTexture, causticsUV, _CausticsSpeed, false);
causticsColor = _CausticsColor * causticsColor * _CausticsContribution;

UE:
UE中以贴花的形式实现焦散效果。

https://www.youtube.com/watch?v=9z6EMsoqLDY

参考
[1] 引用来源:https://zhuanlan.zhihu.com/p/95917609


这是侑虎科技第1659篇文章,感谢作者RE Ding供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/ding-yan-qing-75

再次感谢RE Ding的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)