不久前玩了神秘海域4和盗贼之海,对游戏中形状生动颜色通透的大海产生了兴趣,于是开始查着资料学习自己动手写一个海洋的水效渲染。这个shader基本是边学边写的,编写过程中我学到了很多新的知识。
目前的效果:
波形
主要参考了GPU Gems的Chapter 1。
Chapter 1. Effective Water Simulation from Physical Models
实时水面模拟与渲染
海面波形是由多个不同频率分量单个波形叠加起来的,相对较为真实的方法是对真实采样的海面波形做FFT得到一个二维频谱,再在应用程序中通过IFFT还原为水波的高度数据。不过在此我还是先使用一种较为简单并且能直观地在GPU中计算的叠加Gerstner波的方法。使用这种波顶点会不仅有高度分量y的变化,同时在xz平面也会有一定的位移,我们在控制xz平面顶点位移大小的同时也控制了波峰是尖还是平缓。
Shader中我使用了12种不同波长,每种波长的分量是6个方向不同的同频率波的叠加,于是最后用72个波的叠加得到了目前的效果。
水面颜色渲染
总体上来讲我们将得到的颜色分为反射分量,折射分量以及加性的高光分量,折射和反射分量的比重用菲涅尔效应的公式计算。这里采用了一种快速菲涅尔方法:
float R_0 = (_AirRefractiveIndex - _WaterRefractiveIndex) / (_AirRefractiveIndex + _WaterRefractiveIndex);
R_0 *= R_0;
return R_0 + (1.0 - R_0) * pow((1.0 - saturate(dot(I, N))), _FresnelPower);
参考链接
反射分量
在应用了波形和法线贴图后,海水的表面就不再是镜面,因此完全正确的反射颜色就不再是镜面反射。当然我们仍然可以计算正确反射的天空盒子颜色,但是由于这里采样的只有天空盒子于是其他物体便不会出现在反射中。然而正确不是首要考虑的因素,在这里我仍然使用了镜面反射的反射贴图。
获取镜面贴图的方式非常直观,直接在当前相机关于水平面的对称位置再生成一个(视线也对称)相机采样即可。注意这里为了只正确反射水面上的物体,我们需要将对生成的对称相机的近端裁剪平面修改为水面。在脚本中使用cam.CalculateObliqueMatrix(clipPlane)
可以得到以clipPlane
(clipPlane
参考系是观察空间)为近端裁剪平面的相机投影矩阵,然后再设置相机投影矩阵为得到的这个矩阵即可。具体运算较为复杂,我参考了斜视锥体深度投影和裁剪这个教程。
得到反射贴图后,首先根据当前fragment的法线xz分量扭曲UV,再根据从vertex shader中传来的世界空间高度值抬高反射贴图的采样点,最后还是能够得到较为不错的效果。当然这只是一种简单的近似效果,一旦波形起伏大了马上就会穿帮。
折射分量
这里我将折射分量(来自水中的光)分为了透明和天空光散射、和直射散射三个部分。
这里我在shader中计算了视深,即水底视深度减去减水面视深度。
float backgroundDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, grabPos));
float surfaceDepth = grabPos.w;
如果要正确得到水底深度,那么我们必须将这个shader的RenderQueue
设置为Transparent
,否则SAMPLE_DEPTH_TEXTURE_PROJ
得到的还是已经写入深度数据的水面视深度,水底也不会出现阴影。
计算得到视深后就可以根据此参数决定透明分量和散射分量的比重。
折射分量-透明
此处我采用了GrabPass { "_WaterBackground" }
的方法直接得到之前已经渲染的背景贴图,同样再根据fragment的法线xz分量扭曲UV即可。同时我们也要对视深度采取扭曲UV采样。但是,仅仅这样做会导致如图的结果:
其原因是我们如果直接对背景采样UV位移,会导致原本位于海面上的不应扭曲的物体背景扭曲到海面上,于是产生了如图的错误。因此我们要计算一次扭曲后采样点的视深度,如果此深度小于0那么这个点就不应该被扭曲。注意此处如果扭曲归零了的话,同时也要把视深度还原为原本的数据。
折射分量-天空光散射
这个颜色就是我们平常能够感受到的“海的颜色”。海水散射的光线来自于四面八方的光线,浅水处海水更浅,深水处海水更深,于是这里我们就要考虑海底的高度值。
起初我直接导出地形的8位高度图放到PS里做处理再导入Unity,但是这样做首先精度非常低,我们需要用到和能观察到的在海平面附近的高度基本只有4-5个量级,并且一旦改变一次地形就要重新做一次贴图,于是我很快就抛弃了这个做法。
取而代之的是另一个自动生成高度的方法。我们只要在地形上方生成一个视线垂直向下正交相机,将其CullingMask设置为仅限地形层,再使用cam.SetReplacementShader
替换成输出深度数据传入GPU即可。
于是根据此高度图我们就能够决定当前位置是浅水还是深水。
后来我在观察和搜索中发现,现实中的浅水散射其实非常明亮。
因此我在shader中将浅水的散射改为了加性,让其略有一些发光的效果。
上图是水深对海水颜色影响的展示。为了看清浅处水的颜色,这里调低了水的清澈度。
折射分量-直射光透射
现实生活中,我们看到的阳光直射下的海浪是通透明亮的,如下图所示。
其原因是来自直射光的透射。为了在Unity中合适地模拟这种效果,我参考了GDC 2011 – Approximating Translucency for a Fast, Cheap and Convincing Subsurface Scattering Look来模拟这种效果。
在原链接PPT中最后的透射分量还乘上了一个Thickness Factor, 很直观的就是说越厚的物体最后得到的透射量越小。PPT中讲到的做法是反转表面法线,烘焙出AO然后再反转颜色储存进贴图中,当然我们这里是行不通的。于是我简单引入了波高模拟了一下效果,越高的波产生越高的透射量。
float4 CalculateSSSColor(float3 lightDirection, float3 worldNormal, float3 viewDir,float waveHeight, float shadowFactor,float F)
{
float lightStrength = sqrt(saturate(lightDirection.y));
float SSSFactor = pow(saturate(dot(viewDir ,lightDirection) )+saturate(dot(worldNormal ,-lightDirection)) ,_DirectTranslucencyPow) * shadowFactor * lightStrength * _EmissionStrength;
return _DirectionalScatteringColor * (SSSFactor + waveHeight * 0.6);
}
高光分量
这个没有太多需要说明的地方,主要就是用到Phong高光公式。
float3 specularColor = pow(max(0.0, dot(reflect(lightDirection, mappedWorldNormal),viewDir)), _Shininess);
问题修复
现在有一个问题是处于直射阳光被遮挡的情况下,水面仍然会发出直射次表面散射光和Phong高光,如下图所示。
首先可以知道,不会被阳光直射到的地方不会产生直射次表面散射光和反射高光,接下来我们就要找到哪些地方不会被阳光直射到,也相当于找到水面的阴影。由于我们已经将shader的
RenderQueue
设置为Transparent
,因此水面并不会写入深度,从而也不会接收到阴影。如果我们将RenderQueue
改变为其他值,那么前面使用_CameraDepthTexture
时又不会读取到背景的深度数据。我实在也没有想到比较好的办法,于是重新生成了一个和主相机参数相同的相机专门获取ShadowMap。这个相机不能拍摄到水面,能拍摄到的有:一个主相机不可见的海平面重合的平面,地形,以及其他生成阴影的物体。这里同样使用了cam.SetReplacementShader
,最后输出的是整张屏幕的阴影图。在shader中我们读取到这张贴图,采取一定的UV扭曲(同样和法线xz分量以及高度相关),采样点为0的地方我们移除次表面散射光和反射高光。在阴影处我同时对 海水的天空光散射做了一定的衰减,模拟阴影的效果。(注:此处可以用CommandBuffer减少一个相机,找个时间重写一下)
另外还可以注意到,由于反射高光是类似于镜面反射的,因此有倒影的地方也不会产生反射高光。只需要将反射相机中的深度值贴图输出给shader,然后shader在读取到深度不为无穷的地方将反射高光去掉即可。
Whitecap泡沫/数据缓存
参考论文是Dupuy, Jonathan, and Eric Bruneton. "Real-time animation and rendering of ocean whitecaps." SIGGRAPH Asia 2012 Technical Briefs. ACM, 2012.
实时水面模拟与渲染(一)
使用雅可比行列式计算当前点的撕扯程度,雅可比行列式越小的地方撕扯程度越大,从而产生泡沫。
目前此shader还是采用的直接叠加Gerstner波生成的波形数据,而计算Whitecap需要用到临近点(世界空间)的水平扰动数据,因此,如果不在更早的生成波形阶段使用缓存数据保存扰动数据(还有法线/Whitecap数据等)的话,只能在水波渲染过程中重新计算,这样显然是毫无效率的。
因此还可以在更早阶段(水面渲染之前)就先计算好位移、法线、Whitecap数据等等。于是可以考虑通过海洋学统计模型生成频谱然后IFFT还原波形得到更为真实的模拟结果。
这个阶段可以放在shader里使用GPU完成。
另外,我们可以观察到水面的泡沫其实是有一个逐渐消散的过程,因此我们可以使用之前帧的Whitecap数据缓存和当前计算的新的Whitecap数据合成最终输出的泡沫数据。
见笔记②
LOD(待完成)
目前采用的网格还是直接从外部导入的固定顶点网格,这样做实际上降低了效率,因为在远处的部分我们根本不需要消耗大量性能去计算细节。目前初步设想是Mesh Tile跟随主相机移动而移动,xz坐标离玩家越近的Tile拥有更高的分辨率,相机越靠近水面离相机最近的Tile拥有越高的分辨率。
另外前面提到了缓存顶点位移、Whitecap等数据,在这里针对不同大小的tile我们可以都使用同样分辨率的缓存以提高性能(可放在一张贴图里)。
(接缝问题是否可见?)
水下效果(待完成)
焦散(待完成)
性能分析/Profiler初识
【unity】使用Profiler进行性能分析
Optimizing graphics rendering in Unity games
打开Profiler首先能看到占据很大一部分Timeline的是WaitForTargetFPS, 也就是说我们现在是打开了垂直同步固定了帧率,CPU需要消耗一段空闲时间加长该帧的时间去达到目标FPS。于是在Project Settings里关闭Vsync,WaitForTargetFPS消失。此时CPU时间是5.2ms,GPU 时间是3.5ms,可见目前影响帧率的主要是CPU。
仔细观察后发现Profiler里竟然有5个Camera.Render,原来是忘了把ShadowMaskCam和HeightMapCam设置非活跃,导致Camera.Render在PlayerLoop里继续被调用,
Disable掉相机之后问题解决,SetPass Calls减少,CPU时间变成了3.6ms左右。
GPU部分中占用最高的显而易见是Render.TransparentGeometry。
FrameDebugger中,我直接用的自带地形组件貌似不能被batch真的会引起非常多的DrawCall,况且这里我还使用了多个相机,每多一个相机就多一大堆的DC去渲染地形,也许最好直接导入预先在其他DCC上做好的地形模型…
Github: https://github.com/techizgit/UnityPlayground