Win/Android/iOS,Unity3D
使用粒子系统的实现方法1,效果是最自然的,可控程度也是最高的,但最大的问题是性能,基本要到10k个粒子才能表现出小雨,大雨就更不用说了。
而用ATI Toy Shop Demo2的方法,本质上就是加了一个全屏贴图滚动的后处理,他们用了4层不同Tiling的贴图模拟雨的层次纵深,这种方法的好处就是性能与雨滴的数量无关,但缺点也比较明显,一个是要做出好的效果比较麻烦且不直观,另一点是后处理的效果永远是“平行于”屏幕的,在有俯仰视视角的游戏里就会出现雨水平行于地面或天空的问题。
本文实现方法是在ATI Toy Shop Demo的方法之上加以改进。主要思路参考天刀的文章3。
最主要其实就是解决两个问题,雨是怎么“下”的,雨的纵深感怎么表现。简而言之就是,使用一个双锥(double cone)模型4,做贴图滚动;用深度图还原雨滴的位置做剔除。
以下代码只作参考
for (int k = 0; k < 3; ++k)
{
depthLayer = depthLayers[k] * depthRange[k] + baseDepth; // 逐像素深度
if ((viewDepth > depthLayer))
{
float3 worldPos = GetWorldPosFromViewZ(screenUV01, depthLayer);
float4 projPos = GetVerticalColliderCamProjPos(worldPos);
half collisionDepth = GetVerticalColliderCamPixel01Depth(projPos.xy);
clip(collisionDepth - projPos.z);
baseDepth += depthLayer;
alpha += colorLayer[k];
}
else return fixed4(_Color.rgb, alpha);
}
正如上面说的,使用全屏后处理(full-screen quad)的方式会有视角问题,这个可以通过使用双锥代替。使用双锥,在俯视和仰视的时候就会有雨水向远处收拢的效果,再配合调节顶点色(两端暗中间亮)就可以实现雨水消失在灭点的感觉。
既然我们做天气系统的目的是为了增强环境气氛,那么最好的肯定是能与玩家产生交互。为此,我实现了一个相对运动的效果。在飞行模拟器文章5中给出了一种依赖实际数据分析的贴图Scrolling和Tiling的公式来模拟相对运动,但我认为,让美术能更直观地调节效果的参数应该会更有实际意义,便没有采用。
对这种方法,要模拟相对运动,将角色的运动分解为水平运动和垂直运动,水平相对运动体现在双锥的倾斜上,垂直相对运动体现在贴图的滚动速度上,雨水收尾速度一般是4~9m/s,所以可以用这个作为默认值,再加上角色的垂直相对运动速度即可。通过这种方法可以实现角色向前奔跑时,雨水迎面打来,角色跳跃时雨水下落加快,角色下落时和雨水相对静止,让玩家更能身临其境。
在这里有一点小技巧,一个是在一些高度变化频繁的地形上移动时,如小石阶,雨水会抖动;一个是一般第三人称相机的运动一般会有一个平滑首尾,人物移动也会有一个加速过程,这时双锥会缓慢倾斜至最终状态,效果很突兀。这两个问题都可以通过加入一个阈值,只有超过阈值才应用这种相对运动的效果即可。
在Toy Shop里,用了4层不同深度位移的贴图来模拟雨的纵深感。每一层中的每个雨滴都有不同的深度,本质上是通过遮挡关系来表现的,所以能控制雨滴的深度对效果的提升是巨大的。我的做法是,直接用雨水的albedo贴图来做逐雨滴的深度贴图,这是一种省功夫的做法,要实现更好的效果,最好还是用一张专门的深度图。
这里有一个问题,我用的是Remember Me中的贴图(没有美术功底,只能用别人的。。),而贴图是已经经过运动模糊处理的,所以如果直接用一个关于颜色的函数来作为雨水的深度,在水平深度剔除时会出现雨水的细节被剔除掉。所幸,一般雨的alpha值较小,不易察觉。
这种雨的实现方法的精髓就在于如何实现水平遮挡和垂直遮挡了。用双锥的方法本质上还是后处理,只是将full-screen quad换成了double cone,他依然是全屏的,完全透明的。我们可以为每一层雨水指定一个深度范围,通过逐雨滴深度图和屏幕空间坐标,来还原雨水的世界坐标。通过主相机视角的深度图来和还原出的世界坐标做深度剔除。深度图做碰撞检测,通过类似于平行光投影的方法,假定雨都是从角色上方某一个高度的平面下落的,计算雨滴在这个矩形里的投影坐标,可以判断是否被屋檐一类的物体遮挡。
我们的游戏是可以在一定范围内让玩家调整FOV的,这会导致一个问题,现实中的雨是没办法“靠近看”的,同一时刻你在当前位置和100米以外看到的雨滴的大小应该不会有太大差别的,而FOV改变的时候我们看到的雨滴的大小是会变化的。
这个问题可以直接修改裁剪矩阵的一行一列和二行二列数值解决。
// vertex = UnityObjectToClipPos(i.vertex);
float4x4 projMat = UNITY_MATRIX_P;
float rcpAspect = projMat._m00 / projMat._m11;
projMat._m11 = (projMat._m11 > 0 ? 2.1445 : -2.1445);// 2.1445==cot25, 固定FOV, 避免贴图缩放
projMat._m00 = projMat._m11 * rcpAspect;
vertex = mul(projMat, mul(unity_MatrixMV, float4(i.vertex.xyz, 1.0)));
在现实中,肉眼会用亮度来判断雨的远近,因此我在代码里做了一个小的改进,就是将最靠近相机那一层雨的alpha值取平方,让近处的雨看起来更亮,这对效果的提升也是很明显的
alpha += colorLayer[k] * (k > 0 ? 1 : colorLayer[k]);
还有一种提升效果的思路,刚开始实现功能的时候,参考了Remember Me6的文章,也参考了明日之后7的实现,都发现两者在UV变换上动了手脚。一开始两者的做法都有点费解的。把效果实现一遍,再看一下游戏里的效果,最后按我的理解,他们或是为了让每一层雨有单独的方向,形成交错的感觉;或是为了形成周期性摆动,模拟风吹。
·Remember Me里的变换,UV绕中心周期性旋转
// 实际上就是uv乘以一个二维旋转矩阵
float2 SinT = sin(Time.xx * 2.0f * Pi / speed.xy) * scale.xy;
// rotate and scale UV
float4 Cosines = float4(cos(SinT), sin(SinT));
float2 CenteredUV = UV - float2(0.5f, 0.5f);
float4 RotatedUV = float4(dot(Cosines.xz*float2(1,-1), CenteredUV)
, dot(Cosines.zx, CenteredUV)
, dot(Cosines.yw*float2(1,-1), CenteredUV)
, dot(Cosines.wy, CenteredUV) ) + 0.5f);
float4 UVLayer12 = ScalesLayer12 * RotatedUV.xyzw;
·通过GPA抓帧明日之后,顶点着色器里的UV变换
// 实际上就是做了一个 uv.x’ = Auv.x + B * uv.y + C的线性变换
float _local_2 = dot(_local_1, _uv_scale_offset[0].xyz);
float _local_3 = dot(_local_1, _uv_scale_offset[1].xyz);
float2 _local_4 = vec2_ctor(_local_2, _local_3);
(_v_texture0 = _local_4);
因为本文介绍的天气是要应用在移动端的因此性能优化尤其重要。目前的做法不可谓不昂贵,增加一个全屏Overdraw,需要两张深度图,采样9次。。。在没有优化之前,在低端机GPUBound条件下实测单帧耗时由33ms上升到57ms,掉了十几帧。而经优化和机型效果划分后,在低端机(Snapdragon430,Adreno505)单帧耗时34ms,高端机(Snapdragon660,Adreno512)单帧耗时37ms。
采用的优化手段如下:
这是最简单暴力的优化手段,可以将下雨效果渲染到一张分辨率较低的RenderTexture上,然后再升采样Blit到Default FBO/主相机上即可。降采样主要带来的代价是雨滴会变模糊,但其实本来我们用的贴图就是运动模糊后的效果,所以影响其实并不明显,而且也可以通过提高贴图尺寸改善。
通过降低分辨率(总像素降到1280*720的40%)和配合调整Tiling,耗时得到极大的减少。
应用以下的优化后耗时可以减少2~4ms。
和CPU代码优化类似,一个常用的优化手段是手动展开循环,一般来说,展开较小的循环体会带来性能提升,编译器会自动帮你展开循环体较小的循环。但至于这样做是否真的带来性能提升,最好还是实际测一下数据比较好。
// Unroll Loop性能较好
bool notcull = true;
int k = 0;
{
// if-else改?:性能更换
depthLayer = (1 - depthLayers[k]) * depthRange[k] + baseDepth; // 逐像素深度
srcAlpha = colorLayer[k];
{
float3 worldPos = GetWorldPosFromViewZ(screenUV01, depthLayer); // 世界坐标
float4 projPos = GetRainColliderCamProjPos(worldPos); // 垂直投影坐标
half collisionDepth = GetRainColliderCamPixel01Depth(projPos.xy); // 垂直深度
fixed clipped = ClipCond(collisionDepth, projPos.z); // 垂直遮挡
output = AlphaBlend(alpha, srcAlpha * (1 - clipped)); // 正片叠加
}
bool choose = (viewDepth > depthLayer);
alpha = choose ? output * output : alpha;
baseDepth += choose ? depthLayer : 0;
notcull = choose ? true : false;
}
k = 1;
{
depthLayer = (1 - depthLayers[k]) * depthRange[k] + baseDepth;
srcAlpha = colorLayer[k];
{
float3 worldPos = GetWorldPosFromViewZ(screenUV01, depthLayer);
float4 projPos = GetRainColliderCamProjPos(worldPos);
half collisionDepth = GetRainColliderCamPixel01Depth(projPos.xy);
fixed clipped = ClipCond(collisionDepth, projPos.z);
output = AlphaBlend(alpha, srcAlpha * (1 - clipped));
}
bool choose = (viewDepth > depthLayer) && notcull;
alpha = (choose) ? output : alpha;
baseDepth += (choose) ? depthLayer : 0;
notcull = (choose) ? true : false;
}
k = 2;
{
depthLayer = (1 - depthLayers[k]) * depthRange[k] + baseDepth;
srcAlpha = colorLayer[k];
{
float3 worldPos = GetWorldPosFromViewZ(screenUV01, depthLayer);
float4 projPos = GetRainColliderCamProjPos(worldPos);
half collisionDepth = GetRainColliderCamPixel01Depth(projPos.xy);
fixed clipped = ClipCond(collisionDepth, projPos.z);
output = AlphaBlend(alpha, srcAlpha * (1 - clipped));
}
alpha = ((viewDepth > depthLayer) && notcull) ? output : alpha;
}
return fixed4(_Color.rgb, alpha * _Color.a * i.color.r * i.color.r); // 顶点色衰减
我们知道,if这一类条件分支在GPU执行时大部分情况不会带来性能提升的(但大佬说有特例),相反是会严重影响性能的。GPU实际上的做法是分出一半的线程来执行if为true的运算,另一半执行if为false的运算(没有则等待true的运算完毕),也就是说,就这if这段代码而言,性能下降最多可达50%。一个可行的方法是用条件运算符?:来代替if-else。
考虑到clip对early-test开启的影响,我们可以用step代替clip
一些效果,无论再怎么优化总会有个极限。为了让低端机也有一个可以接受的效果,我再高低端基使用了不同的Shader LOD和配套策略。低端机削减了两个Layer,另外减少参与深度图渲染的物件,甚至是不再渲染深度图。当雨覆盖的距离不大的时候,其实水平遮挡不会很明显。而垂直遮挡则可以用射线检测的方法来代替。高端机也削减了一个Layer。
在实现这个下雨效果时,要经常跟深度图打交道,而深度图的计算和兼容问题也是一直被大佬们认为是dirty work。踩的坑,假设你是自己渲染深度图的话,如果希望深度图默认值表示“最远”的地方,Unity的Standalone(D3D)下数值是0,OGL下数值是1,所以要么保证ClearColor在两个平台下是不一样的,要么在写深度的Shader里先做转化,统一用0表示最近,1表示最远。
一般主相机的远裁剪面会设置得比较远,而用于渲染主相机视角深度图,出于性能考虑,精度较小(R8),这时就需要另一个远近裁剪面不一样的相机做渲染以提高深度图精度利用率。在计算过程中需要用到观察空间的线性深度,这时由于远近裁剪面不一样,不能用Unity提供的LineaEyeDepth,可以仿照它写一个自定义远近裁剪面的函数。(这里只讨论D3D11及之后的版本)
inline float CustomPerspectiveLinearEyeDepth(float near, float far, float z) // z ∈[0,1], 深度图采样结果
{
#if !defined(UNITY_REVERSED_Z)
float halfNearInv = 0.5 / near;
float halfFarIn = 0.5 / far;
return 1 / max(0.00001, (halfNearInv + halfFarIn - (z * 2 - 1) * (halfNearInv - halfFarIn)));
#else
float x = -1 + far / near;
float y = 1;
float rcpFar = 1 / far;
float4 zBufferParams = float4(x, y, x * rcpFar, y * rcpFar);
return 1.0 / (zBufferParams.z * z + zBufferParams.w);
#endif
}
正如上面所说,两个相机的近裁剪面距离是不一样的,在深度图渲染里近裁剪面后面的部分只能是默认颜色,它要么表示最近要么表示最远。如果默认是最近,那这部分当然就没问题了,但如果默认最远的话,这里的雨就不会被遮挡。可以通过将深度相机近裁剪面距离改成小于等于主相机的近裁剪面即可,也可以在穿帮不明显的前提下不做修改。
这篇文章3中列出的代码,还有一个问题,就是它的遮挡逻辑实际是不完善的。
if(viewDepth > depthLayer)
这个if是实现水平方向上的遮挡的,譬如不远处有堵墙,那就应该看不到更远处的雨。
clip(collisionDepth - lightPos.z);
这个clip是实现垂直方向上的遮挡的。在这个基础上,我们想要实现的效果是,如果头顶有屋檐,那么近处的雨会被挡住,但依然可以看到远处的雨。但按照文章中的代码,如果第1层雨在水平方向上被遮挡了,第2层雨依然有可能被渲染,这明显是不符合现实的,效果上会出现分层现象。
正确的逻辑应该是,如果上一层雨没有通过if判断,之后的雨都应该不再被渲染。
else return fixed4(_Color.rgb, alpha);
Nvidia D3D10 Samples, Rain ↩︎
ATI Toy Shop Demo ↩︎
天涯明月刀端游 ↩︎ ↩︎
Neon ↩︎
Microsoft Flight Simulator ↩︎
Remember Me ↩︎
明日之后手游 ↩︎