一. 正弦波
出于运行效率的考量,游戏中使用的“水面模拟”技术并不是真正的流体动力学计算,实际上大多是使用数学公式模拟水体的视觉效果。其中一种方法就是波形叠加法,通过叠加不同的随机周期函数来表现波涛起伏的水体表面。
说到周期函数,可以使用简单的正弦波来表达水波的模型,首先复习一下初高中知识,下面是标准的正弦函数:
而一般应用的正弦型函数公式为:
其中相关的数学变量如下:
: 振幅/波幅,其大小影响波峰的高度
:该变量影响波的周期(或者说波长),关系式为:
:初相,整个函数图像的水平偏移量
在应用中,我们使用如下函数求顶点的 y 轴坐标,其中输入为顶点的水平坐标 (x,z) 和时间 t:
其中,代表顶点的水平位置,是时间,是振幅,是水平运动方向(二维向量),为频率(影响周期),为初相。
仅仅使用一个正弦波是无法展示此起彼伏的水面的,于是可以将很多个不同周期,不同振幅的正弦函数叠加在一起,得到一个关于水平面位置和时间的高度函数:
在顶点着色器中,计算上述高度函数, 修改原始顶点的y坐标即可实现网格的几何波动,每一个顶点的坐标可表示为:
除此之外,还需要确定每一个顶点的法线,通过求副法线和切线的叉积求得法线。
其中副法线 和切线 向量分别为 和 方向的偏导数。
法线为:
函数加和的导数是各个函数的导数的加和, 为多个 的和,那么的 方向的偏导数为:
同理,的 方向的偏导数为:
求得每一个顶点的坐标和法线后,运行效果如下:
但是,一个较为真实的水面,需要表现出较尖的浪头和较宽的浪槽。《GPU Gems》中有提到对正弦波进行函数变化,来模拟这样的效果:
其中sinx的范围大小是[-1,1],加1除以2后缩放到[0,1]区间,再做幂运算,会使函数值变得更小且更加接近0,而且距离0越小的值减小得越多,这样做就能产生波峰尖、波谷宽的形状。
下面是标准sinx函数和不同k值下的函数图像,为了方便对比,我将变换后的sinx函数范围缩放到[-1,1]:
实际应用中的高度函数则为:
函数对于x的偏导数为(对z的同理):
最终效果如下,浪头更加聚拢了些,不过还是有些“圆润”:
水面产生波纹的原因大体分为两种:
第一种是由风吹动而产生的方向波,常用于较大的水体,波的方向是风向的一定范围内的随机的值,即直到波生命周期结束前的每个都是常数;
第二种是其它物体接触水面而产生的圆形波,其方向必须再每个顶点计算。例如瀑布,比较适合小的池塘或湖面。
圆形波的中心点在某些限定的范围内随机,设为中心点,方向为中心点到顶点的规范化向量:
顶点到中心点的距离为我们的计算依据:
这里的 也就是振幅可以是关于距离 的函数,用于模拟波强随距离衰减的效果。
效果如下:
二. Gerstner波
正如之前所说,真实波浪的波峰较尖,波谷较宽,使用正弦函数的简单变形可以勉强的实现,但依然看起来有些圆润。为了更加有效地模拟和控制波地陡度,使用Gerstner波能有效解决这个问题。Gerstner波和正弦/余弦波的差别,用一幅简单的图就能说明:
选择Gerstner波,是因为它们具有一种经常被忽略的性质:它们把每个顶点推向浪尖,最密的细分出现在尖形浪头处,这恰是所需要的让波浪变得更加真实的要素:
Gerstner波的函数为:
其中, 是控制波陡度的参数。当 为0时,Gerstner波则退化成了正弦波;
除此之外,当 时, Gerstner波的波峰达到最尖锐的程度;
和之前一样,求导后得到切线空间的基础向量:
其中:
虽然暂时不清楚是怎么来的,但书中有提到,当 也就是 的加和大于1时,法线的第二个分量在峰顶变为负值,造成波的自我循环......只要使 的加和总是小于等于1,可以避免这种情况。
于是可以设 作为暴露的控制陡度的参数,其范围是0~1:
效果如下:
贴上代码:
Shader "Custom/GerstnerWave"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_Q("波峰抖动", Range(0,1.1)) = 0.8
_L("波长", Range(200,1000)) = 500
_A("振幅", Range(0,500)) = 150
}
CGINCLUDE
#include "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
float _Q;
fixed _L;
fixed _A;
struct a2v{
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
float3 normal: NORMAL;
};
struct v2f{
float4 pos : SV_POSITION;
float2 uv: TEXCOORD0;
fixed3 worldNormal : NORMAL;
};
fixed3 CaculatePos(fixed A, fixed omiga, fixed fai, fixed t, fixed2 dir, float3 p)
{
dir = normalize(dir);
fixed y = A * sin( omiga * (p.x * dir.x + p.z * dir.y) + t * fai);
fixed x = _Q * A * dir.x * cos(omiga * (p.x * dir.x + p.z * dir.y) + t * fai );
fixed z = _Q * A * dir.y * cos(omiga * (p.x * dir.x + p.z * dir.y) + t * fai );
fixed3 pos = float3(x, y ,z);
return pos;
}
fixed3 CaculateNormal(fixed A, fixed omiga, fixed fai, fixed t, fixed2 dir, float3 p)
{
dir = normalize(dir);
fixed S = sin(omiga * (p.x*dir.x + p.z*dir.y) + t * fai);
fixed C = cos(omiga * (p.x*dir.x + p.z*dir.y) + t * fai);
fixed WA = omiga * A;
fixed x = -dir.x * WA * C;
fixed y = -_Q * WA * S;
fixed z = -dir.y * WA * C;
fixed3 normal = fixed3(x, y, z);
return normal;
}
v2f vert(a2v v){
v2f o;
float3 worldPos = mul(unity_ObjectToWorld, v.vertex);
float3 disPos = float3(0,0,0);
float omiga = 1 / _L;
disPos += CaculatePos(_A/8, omiga*2, 1, _Time.x * 80, fixed2(-1,-1), worldPos);
disPos += CaculatePos(_A, omiga, 1, _Time.x *60, fixed2(2,-0.5), worldPos);
disPos += CaculatePos(_A/2, omiga/4, 1, _Time.x * 40, fixed2(1,2), worldPos);
disPos += CaculatePos(2*_A, omiga/2, 1, _Time.x * 20, fixed2(1,-1), worldPos);
fixed3 n = fixed3(0,1,0);
n += CaculateNormal(_A/8, omiga*2, 1, _Time.x * 80, fixed2(-1,-1), worldPos);
n += CaculateNormal(_A, omiga, 1, _Time.x *60, fixed2(2,-0.5), worldPos);
n += CaculateNormal(_A/2, omiga/4, 1, _Time.x * 40, fixed2(1,2), worldPos);
n += CaculateNormal(2*_A, omiga/2, 1, _Time.x * 20, fixed2(1,-1), worldPos);
v.vertex.xyz = mul(unity_WorldToObject, float4(worldPos + disPos, 1));
o.pos = UnityObjectToClipPos(v.vertex);
o.worldNormal = n;
o.uv = v.texcoord.xy;
return o;
}
fixed4 frag(v2f i) : SV_Target{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(_WorldSpaceLightPos0.xyz);
fixed3 lambert = 0.5 * max(0, dot(worldNormal, worldLightDir)) + 0.5;
fixed3 diffuse = lambert * _Color.xyz * _LightColor0.xyz;
return fixed4(diffuse * _Color.rgb, _Color.w);
}
ENDCG
SubShader
{
Tags {"Queue" = "Transparent" "RenderType" = "Transparent" "IgnoreProjector" = "True"}
pass{
Cull off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
FallBack off
}
三. 波的参数选择
波长:对波长相似的波进行叠加可以凸显水面活力。选择中等的波长,然后从它的 1/2 至 2 倍之间产生任意波长;
波速:通过公式计算,文章中给出了一个模拟现实情况的波长与频率的关系公式: ;
振幅:由美工对中等波长指定对应的振幅,获得波长与振幅的比例。其它任何尺寸的波,都可以通过该比例来求振幅;
方向:不依赖于其它参数,自由指定。
四. 纹理波
用网格来模拟水面对三角面的个数有一定要求。于是对于性能要求较高的的水面渲染一般用凹凸贴图来替代。
大致的方法是,把十几个个不同频率、波长和振幅的波存在几张256x256的凹凸贴图的多个通道里,读取这些凹凸贴图,让这些贴图纹理朝不同方向移动。
波长和速度
纹理贴图要求可以平铺平铺(tiling),所以在纹理中的正弦波至少要重复一个周期,即最大波长不能超过贴图的宽度。同时波长不能太短,建议波长的范围为4~32个纹素(接近4个文素,波会变为锯齿状)。
振幅和精度
同波的参数选择中提到的一样,保持每个振幅与波长的比为常数 。
因为不需要关心水波的高度,只需要构建出法线图。参照之前法线的求解方式:
输入的水平坐标(x,z)替换为纹理贴图的uv坐标(u,v):
那么问题来了,这样不算简单的运算直接放在片元着色器(Fragment Shader)中也太暴力了蛤......
优化方法是,以u方向的偏导数为例,其中 这一部分可以看成常量;
其次是cos及其内部的计算,书中给出了一种类似纹理查找图的方式(想起了以前写过的LUT):
在顶点着色器(Vertex Shader)中计算 ,将得到的值先映射到0~1内,
在片元着色器中,作为纹理查找图的u坐标传入,得到的结果。
这种方式是把复杂的公式根据变量的范围直接算出结果然后存在一张纹理图上,以后就可以不用公式计算,直接把变量当作uv从纹理图上取结果。当然这种方法也有局限性,就是输入的值范围必须是0到1,因为uv的范围就是0到1。
同样为了表现“较尖的浪头和较宽的浪槽”的效果,之前的式子有:
将上式替换填入查找表, 作为cos作为括号外的常数项即可。
深度的使用
水的高度是计算得来的,所以y值没有用。于是这个分量可以存储水体底部的高度,水面高度减去水体底部高度,得到水深度。水的深度值有如下作用:
1.控制水的透明度,深度越大水越不透明;
2.控制水的颜色,深度越大颜色越深;
3.控制波浪的汹涌程度(即几何波的振幅),使波浪涌向浅滩海岸时逐渐平息。
重载
使用顶点颜色的RGB通道:
1.R通道控制透明度;
2.G通道控制反射强度;
3.B通道控制菲涅尔反射(Fresnel Reflection)强度。
边长的过滤
构建水面波浪网格的最短波长取决于网格细分的程度,网格顶点的间距起码要小于最短波长的 1/2,否则会失真,如下图a,b所示。有两个解决办法:
1. 预先决定最短的波长,细分网格确保所有的边都比最短波长短;
2. 考察顶点周围的边长,滤掉那些比网格短太多的波长。
纹理坐标
uv坐标通常由顶点坐标经过比例缩放计算和得到,不需要特别指定。
然而某些情况下是需要的。例如弯曲河道里流动的水。
这种情况下,就需要给顶点指定纹理坐标。且凹凸贴图的法线值需要从纹理空间转到切线空间进行旋转,再转换到世界空间。
参考文章
https://developer.nvidia.com/gpugems/GPUGems/gpugems_ch01.html
https://zhuanlan.zhihu.com/p/23378778
https://zhuanlan.zhihu.com/p/31670275
http://johnhany.net/2014/02/water-rendering-with-gerstner-wave/