前言

本文主要是参考GPU Gems1第一章,基于物理的水体模拟,主要内容是列出了自己在学习海水模拟时的一些感悟以及踩到的一些坑点,本文基于Unity 引擎以及unity的shaderlab来实现;

一、水体模拟渲染的基础理论

我们都知道,想要基于物理来达到真是渲染的目的,就不可以缺少几个要素,灯光、材质(纹理和着色器)、摄像机等;本文重点关注的是基于GPU,也就是在shader中计算模型的表面高度,做出网格变形动画,来模拟海水的运动,而不关心灯光以及海水纹理等内容;

海水,给我们的最大印象当然就是海浪了,所以,我们的模拟也要关注于这一点,最简单的模拟高度的做法,当然就是最简单的简谐函数了,之后我们会选择一个正弦波进行一次实操;

但是仅仅是正弦波并不足以满足我们的需求,即使我们还可以对其进行扩展,发展出一些更好的变体,但是这也是基于经验而非基于物理的模拟,所以Gerstner波出现了,它能够为我们提供更有效的波形,我们使用Gerstner波的最大特性就是:它们通过将顶点移向每个波峰而形成更尖的波峰,这一点,稍后可以从它的公式看出;

二、简谐波的实现

这里我们使用sin波来实现一个简单的模拟效果,我们模拟主要有两个部分,一个是模型的顶点高度,另一个是顶点的法线;

下面截图来自Gems书籍:

unity shadergraph 海面 unity海水渲染_着色器

需要注意的是,这里的H即我们要求解的高度值,而D(x,y) 则代表了方向,它也是一个公式,例如D(x,y)=x+y,代表方向为(1,-1),其实D(x,y)就是一个二元函数,它将(x,y)映射到一个作用域上,然后应用于H(),得到一个基于(x,y)的高度公式;D(x,y)方向的选择也是非常重要的,之后会专门一小节介绍这里;

如果想要有一个随机且动态的变化,就必须在一个约束范围内生成随机参数传入到着色器中,所以需要有不同组的参数来完成一些淡入淡出操作;在为着色器传参的时候,我采用了数组类型,但是DX11并不支持数组类型,所以需要使用-force-opengl启动参数来打开unity,在随后给出的案例,也必须使用如此方式,否则会无法生效,具体可参考:应用程序加启动参数的方法

unity shadergraph 海面 unity海水渲染_正弦波_02

法线的计算 

法线的计算部分,需要多一点高等数学的知识,不过也比较简单;

unity shadergraph 海面 unity海水渲染_归一化_03

 这里使用的就是偏导数的知识点,来分别求出在x,y方向的切线向量,随后再根据向量的叉积来求解法向量;

unity shadergraph 海面 unity海水渲染_归一化_04

unity shadergraph 海面 unity海水渲染_归一化_05

 根据这两个公式,我们就求出了每个顶点的法线方程,如果我们使用的是多个正弦波,则直接像计算高度一样相加即可;

注意:向量的叉积使用的是右手定则,而我们在unity中使用的都是左手坐标系,因此通过x,y得到的法线并不能直接应用,而应该取反方向才可以;

如下是我们生成一个正弦波的代码,我们输入一个顶点和波形参数,然后将高度和法线值存储在一个float4中返回;

之后在顶点着色器中(我们的计算是基于顶点的,所以需要在顶点着色器中完成,而不是片元着色器),我们将顶点坐标赋值给y,然后将法线取反方向后转换为世界坐标,这里不需要进行归一化,一般来说向量的归一化操作都是在片元着色器中做的

float4 GenSingleSinWave(float3 vertex, float wave[4]){
            float4 finalWave;
            float peak = wave[0];
            float dir = wave[1];
            float amp = wave[2];
            float speed = wave[3];

            // 计算高度
            finalWave.x = peak*sin(amp * (dir * vertex.x + sqrt(1 - dir * dir) * vertex.z) + speed * _Time.y);

            // 计算法线
            finalWave.y = -peak*cos(amp * (dir * vertex.x + sqrt(1 - dir * dir) * vertex.z) + speed * _Time.y) * amp * dir;
            finalWave.z = -peak*cos(amp * (dir * vertex.x + sqrt(1 - dir * dir) * vertex.z) + speed * _Time.y) * amp * sqrt(1  - dir * dir);
            finalWave.w = 1;
            return finalWave;
        }

        v2f vert(a2v v){
            v2f o;
            float4 wave = GenSinWave(v.vertex);
            v.vertex.y = wave.x;
            float3 normal = -wave.yzw;
            o.pos = UnityObjectToClipPos(v.vertex);
            o.uv = TRANSFORM_TEX(v.texcoord, _WaterTex);
            o.worldNormal = mul(normal, (float3x3)unity_WorldToObject);
            return o;
        }

下面是一个简单效果的截图: 

unity shadergraph 海面 unity海水渲染_着色器_06

正弦波不足之处

可以感觉到,虽然达到了一个预期的随机波浪的效果,但是和真实的波浪差距很大,真实的波浪有什么特点呢,更陡峭的波峰和更宽的波谷,所以我们需要让波峰更宽,让波的坡度更陡;这里我们使用一个变体来解决,

我们知道一个sin波是属于-1到1的,所以我们可以先将其变换到[0,1]区间内,然后使用一个凹函数(只需要在[0,1]区间是下凹的函数)来对其进行再一次变换即可,最先想到的便是指数函数了,这种利用函数的凹凸性变换来改变数据的手法在很多技术上都有应用,如颜色空间中的SRGB空间,图像美白效果也可以使用凸函数来提高图像亮度等等;

unity shadergraph 海面 unity海水渲染_正弦波_07

波的方向

在波的方向上,我们最常见到的就是两种波,定向波和圆波;

在之前的案例中,用到的就是定向波,即所有的波形方向固定,与定点无关;

而圆形波,它的方向则是从波中心到顶点的归一化向量,需要注意的是,虽然我们这里采用的归一化向量,但是如果想要形成一个圆形波,那么D(x,y)就是从顶点到圆心的距离;

unity shadergraph 海面 unity海水渲染_着色器_08

三、Gesrtner波

几乎当前所有的游戏中的海水模拟,使用的都是Gesrtner波形;

公式如下:可以看出该波的高度函数并没有什么变化,与正弦波中的高度函数相同,主要便在于(x,y)的变化,顶点(x,y)将会向着波峰位置移动,从而产生我们需要的波峰更陡峭,波谷更宽;

unity shadergraph 海面 unity海水渲染_归一化_09

参考

Nvidia-GPUGems-Water Simulation;https://developer.nvidia.com/gpugems/gpugems/part-i-natural-effects/chapter-1-effective-water-simulation-physical-models

OpenGL版本的水体模拟:https://hehao98.github.io/posts/2018/03/ocean-1/

水体模拟技术介绍:https://zhuanlan.zhihu.com/p/21573239

腾讯游戏学院-Siggraph中海洋的研究学习:https://gameinstitute.qq.com/community/detail/132794

知乎-Shader相册第6期 --- 实时水面模拟与渲染(一):https://zhuanlan.zhihu.com/p/31670275

知乎-FFT海面模拟:https://zhuanlan.zhihu.com/p/64414956

FlowMap水模拟:https://mtnphil.wordpress.com/2012/08/25/water-flow-shader/