
中心思想是性能优化:优化天气本身的性能,和利用天气来优化整个场景的性能。同时绕开后处理来实现天色变化(低带宽的gpu架构让手机难以承受屏幕后处理特效)。


1.雾
在手机上实测过新版的Unity自带的距离雾,其实它并不怎么消耗。而且,远处的物体被雾完全盖住了,我们大可以不渲染它们,把摄像机的远裁剪平面直接设置为雾的终止距离,这个距离之后的物体都会被裁剪掉。对性能的提升显而易见。(WeatherControl.cs)
// ...
cam = transform.GetComponent<Camera>();
// ...
RenderSettings.fog = true;
cam.farClipPlane = RenderSettings.fogEndDistance;
// ...
更进一步可以把雾的设置从Exponential改成Linear,因为Exponential模式下会有大量的物体被浓厚的雾盖住,只看得见一点点,但是仍然要渲染。下雨的时候可以用Linear这种浓厚的雾来模拟远处的雨,但是下雪的时候最好还是用Exponential,雪比雨通透。
2.雨
手游中实现雨主流方法就是用片或者用粒子:①在摄像机面前挂一个片,片上是雨的流动纹理(流动UV),好处是可以把雨做的很密,因为不管纹理上的雨画得多密,overdraw都是一整个屏幕,开销并不会增加。坏处是摄像机往下看地面的时候会穿帮;②用粒子系统,好处是可控性高,雨的大小,疏密,方向都可控,低头看也不容易穿帮,坏处是每一个粒子要由CPU单独控制生命周期及运动,CPU负荷高,所以粒子数也加不了太多,显得雨很稀疏。
之前Unity出了个官方demo,Angry Bots,里面的雨的实现类似于粒子系统的思想,但是放弃了对每个雨滴的单独控制,把所有雨滴合并成一个mesh,一整个mesh装着所有的雨滴往下落,

这个装着所有雨滴的大mesh在摄像机周围下落,下落到一定的位置会在上方生成新的mesh,以保证雨丝的连续。

这样加更多的雨滴也不会像粒子系统一样增加CPU负荷了,雨滴可以相对稠密点,现在唯一的限制因素只有overdraw了。
可能有人已经想到了,这样把雨滴集合在一起,放弃对雨滴的单个控制,会造成重复感,以及失去对疏密的控制。解决方法是:我们可以先存好三个雨滴分布不同的mesh,然后在上方产生新的mesh的时候循环取用这三个mesh。对于疏密,我们可以简单地缩放一下mesh (RainBox.cs):
cachedTransform.localScale = new Vector3(rainSparsity, rainSparsity, rainSparsity);
这种集合成一个mesh的方案还有一个缺点,就是不能单独旋转每个雨滴的quad,也就是说不能做Billboard,从特定角度看的时候会显得一部分侧向摄像机的雨丝偏细,但是对视觉效果的实际影响不大,因为它本来的形状就是细长型。
3.雷+天色
雷由声音与画面来体现,声音简单地播放音频即可,而画面则是闪动。难点在于怎样让画面闪动与声音配合,并且让闪动看起来像是雷,而不是天亮了一会。
在着手这些问题之前,我们先得让场景(屏幕)亮起来。
首先,能不用后处理就不用后处理,因为手机GPU和显存之间的带宽低,后处理中对渲染纹理的存储与采样,让性能的损失十分惨痛。那么想让控制屏幕亮度,我们可以在摄像机前挂一个覆盖全屏的片,利用blend(片元混合)的机制来对渲染好的画面进行一些有限的计算与处理。但是,不是什么后处理都能够用挂片来绕过,比如bloom、depth of field等需要对领域像素进行采样的效果,如果不把原先渲染好的画面存储到渲染纹理里面,是无论如何都访问不到周围的像素的。幸运的是,在我们的应用场景中,只是需要改变当前像素的值,不存在领域操作,所以我们可以用挂片的方式来绕过后处理。我把这种挂片的方式称为“单像素后处理(Single Pixel Post Processing)”,当然这里的“后处理”一词只是比喻。
在渲染队列的最后,画一个覆盖全屏的quad,能够改变画面像素的亮度乃至颜色的关键就在于利用GPU里的blend(片元混合)机制。
我采用的混合模式是:Blend One SrcAlpha
实际上发生的运算是:
1 × <Source Color> + <Source Alpha> × <Destination Color>
Source指从quad写入的值,Destination指画面上已经渲染好的当前像素。
在生活中可以观察到:在打雷的瞬间,环境突然快速变亮,摄像设备(摄像机或手机等)来不及适应,来不及自动降低曝光,于是往往会过曝。所以打雷的时候场景往往看起来是大片的青白的,很多细节都被过曝掉了。我们想要模拟这种效果,可以暴力地把Source Color设为很亮的青白色 RGB(0.7, 0.75, 1.0),它和画面上的像素相加后很多地方的颜色值会饱和(超出1),看起来很像过曝,而没有过曝的暗处的颜色则会偏青偏冷:

下雨的时候,环境往往会暗一点(当然是指不打雷的时候啦),这就涉及到天色的控制了。在上面的加粗公式里,画面上的像素被乘以了我们写入的alpha值,所以我们可以简单地通过降低quad写入值的alpha通道。这是alpha为0.5时的场景:

现在我们终于能点亮屏幕了。要看起来像打雷,接下来就是让屏幕闪动起来。
改变加上去的值的大小,也就是调节Source Color的值就可以闪动了。而闪动要和声音配合,有幸的是我得到了这段音频的幅度图:

打雷的那段放大了看是这样的:

想要把音频的幅度图转换成Unity能够识别的形式,要用到Unity的“曲线(Curve)”功能:

然后在程序里,每一帧的时候,得到当前帧的时间,然后根据时间,用AnimationCurve类的Evaluate()函数采样得到此时的Curve值,最后根据这个Curve值去决定加到屏幕上的青白色有多亮。但是仔细看看上面的音频幅度图,发现它并没有这么平滑,但我们也用不着真的把曲线也做出那么多毛刺,只用每帧把Curve值加上一个随机数就可以了,这个随机值很好地模仿了雷电的颤动感。最后值得注意的是,为了更好地组织代码,这里使用Coroutine来实现 (WeatherControl.cs):
//light up whole screen during the lightning
IEnumerator Lightning(float time)
{
yield return new WaitForSeconds(2.375f - 0.2f);
float i = 0f;
float rate = 1f / time;
while (i < 1f)
{
i += Time.deltaTime * rate;
// synchronize the lightning with its sound, and the random value is to make the lightning shiver
float intense = lightningCurve.Evaluate(i) * (1f + Random.Range(-0.2f, 0.4f));
// use this color to control lightning and darken the environment
Color lightningColor = new Color(0.7f * intense, 0.75f * intense, 1.0f * intense, 0.5f);
// spppMat is the material of the whole screen quad, here we change its output color
// by the way, "sppp" stands for "Single Pixel Post Processing"
spppMat.SetColor("_Color", lightningColor);
yield return 0;
}
spppMat.SetColor("_Color", new Color(0f, 0f, 0f, 0.5f));
}
4.雪
雪由两方面体现:雪在空中飘落,和物体被雪覆盖变白。
飘落只能用粒子系统,不能用上面介绍的雨的那种大mesh,因为每个雪花飘落时要各自晃动,雪花之间的相对位置不固定,所以只能用粒子。唯一值得注意的是为避免雪花的形状单调重复,要把Render Mode改成Billboard,然后自己用PS画一个下图这样的纹理(用软笔刷画,边缘不要画死),设置Texture Sheet Animation,让粒子系统生成新粒子时取用下面的四种雪花之一。粒子系统有overdraw的问题,我们需要把每个雪花尽量画大,撑满它的格子(下图中的四分之一区域),然后每个粒子的quad的面积尽量小,就可以尽量减少quad上的边角处的完全透明的部分,这部分没有内容但是仍然要渲染。

物体被雪覆盖变白,但只有法线朝上的部分才挂得住雪。我们可以在vertex shader里计算法线朝上的程度,也就是直接取世界空间的法线的y值(下方代码中的o.snow.z)。
挂在物体上的雪是不均匀的,我们可以在surface shader里采样一个噪声纹理,

值得注意的是,物体和物体,物体和地形之间有相接的地方,这里的雪的分布必须要是连续的,所以采样噪声用的纹理坐标要连续,那么直接用物体的世界坐标就好啦(以下代码中的o.snow.xy)。
本天气系统的主题还是性能优化,盖雪的效果并不追求盖雪的位置绝对精确,所以我们可以把除了噪声纹理采样以外的操作都放到vertex shader里来,法线朝上程度和噪声纹理坐标都只在顶点计算。另外,我们可以把这两个值合并进一个插值寄存器(o.snow),节省不必要的插值运算。
void vert (inout appdata_full v, out Input o) {
UNITY_INITIALIZE_OUTPUT(Input,o);
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal.xyz);
worldNormal = normalize(worldNormal);
o.snow.z = max(worldNormal.y, 0.0);
fixed4 wpos = mul(unity_ObjectToWorld, v.vertex);
o.snow.xy = wpos.xz;
}
void surf (Input IN, inout SurfaceOutput o) {
fixed4 c = tex2D(_MainTex, IN.uv_MainTex);
fixed snowIntense = tex2D(_SnowTex, IN.snow.xy * 0.1).r * IN.snow.z * _SnowHeavy;
fixed3 albedo = lerp(c.rgb, fixed3(1.0, 1.0, 1.0), snowIntense);
o.Albedo = albedo;
o.Alpha = c.a;
}
被雪盖住的效果:
