写在前面

赶在年前写一篇文章。之前翻看2015年的SIGGRAPH Course(关于渲染的可以去selfshadow的博客里找到,很全)的时候看到了关于体积云的渲染。这个课程讲述了开发者为游戏《地平线:黎明时分》所开发的动态天气系统,重点讲了里面的云的模拟和渲染,很有参考价值。

其中,云的建模主要使用了raymarching的方法,他们的启发应该和shadertoy有关,但多了更多的程序控制和艺术效果等。可以从上面的图片看出来,效果很好。

SIGGRAPH上的这个演讲讲的主要是3D动态云彩的渲染,适合于端游这样的大型游戏。后来在翻iq的博客的时候,发现偶像在2005年就写过一篇关于2D动态云模拟的文章,里面用到的算法相对来说简单许多,计算量也很少。这篇文章写于十年前,那时候电脑的计算资源非常有限,因此为了提升性能、减少内存占用等目的,提出了很多trick。尽管现在电脑的运算资源好了许多,但这些trick也没有因此退出舞台,而是可以作用到移动平台。这篇文章就是想要介绍一下iq那篇文章里提到的方法。 

2D动态云彩
回想我们现在一般做天空背景的时候是怎么做的。我们首先会准备一个半圆形的天空顶来模拟背景,然后通常会准备几个图片,这些图片中包含了天空背景,例如蓝色的天空和几朵白色的云彩。每个图片作为一层背景,并赋值给一个材质,该材质会对这些图片进行纹理动画,移动每层的纹理来模拟云彩缓慢飘动的效果。这种方法简单有效,因此应用很广泛。
不过有些时候,我们希望游戏的天空背景并不是提前预知好的,或者我们系统实现一个天气系统,可以随着天气变化而动态产生自然的变化。这时候就不能使用提前准备好的图片来模拟云彩和天空了。这篇文章就是想讲一下如何使用程序来动态模拟云彩,尽管本文实现的效果还比较简陋,但相信在程序和美术的共同配合下,有这个需要的朋友可以得到启发,实现出非常漂亮的效果。
本文的计算复杂度很低,往下看之前需要对噪声有一定了解,不了解的可以参见之前的文章【图形学】谈谈噪声。本文最后会实现一个简单的天空模拟,包括天空颜色、星星、飘动的云彩等,同时可以让用户调整云彩的颜色、厚度、尖锐度等。下面的视频显示了一个下雨和晴朗天气下的效果,其中天空部分的模拟使用了本文的方法。

视频链接:https://vimeo.com/153370722

算法实现

其实我们的重点就是云彩的模拟,天空颜色之类的可以是用另外的shader或纹理来实现,例如在上面的视频中我就是使用另一个Pass来渲染天空和星星等效果。我们这里只解释云彩模拟的部分。
云彩的模拟就是使用分形噪声,这张噪声中的值就对应了云彩的厚度。那么怎么能模拟出云彩不规则变化的效果呢?我们可以想当然的想到这需要一张不断变化的二维噪声纹理。在之前的文章【图形学】谈谈噪声中,我们讲到可以使用一张三维噪声纹理来得到平滑变化的二维噪声纹理,其中第三个采样坐标即对应了时间参数。但是,使用3D纹理的代价是要占用大量内存,而我们的目标是要实时并且计算量尽可能小,那么这个方法就不可取了。
现在到了关键的地方了,这就需要使用一个小的trick。我们知道,分形噪声其实是由许多层不同采样大小的噪声(被称为octave)按照一定权重相加后得到的。为了让最后的分形噪声不断变化,我们可以按照不同的速度来移动这些octave层,这样最后得到的分形噪声也就会不断变化。层数不需要太大,本文的实现和iq文中提到的一样,只使用了4个octave。
那么,现在第一步就是先要创建这些octave。 

创建噪声纹理

第一步我们首先需要创建出组成分形噪声的各个octave。和【图形学】谈谈噪声一文有稍许不同的是,由于我们需要对这些纹理进行不断平移,为了实现无缝连接,我们需要让这些噪声纹理是无缝的(seamless)。而要得到无缝的2D噪声纹理,通常的方法是首先创建4D噪声纹理,然后在4D空间下取两个相互正交的圆,在圆上进行采样得到一张2D噪声纹理。原因和算法可参考下面的链接:

  • http://ronvalstar.nl/creating-tileable-noise-maps
  • http://gamedev.stackexchange.com/questions/23625/how-do-you-generate-tileable-perlin-noise

我直接使用了Unity wiki上的代码,它使用4D的Simplex噪声来产生无缝的2D噪声纹理,没采用Perlin噪声的原因是Simplex在高纬度上的计算复杂度要小的多,具体可参见【图形学】谈谈噪声。
这样,我们就得到了4张噪声纹理以及它们按权重相加得到的分形噪声:

在运行时刻,我们只需要按不同的速度来移动这些噪声,这些速度只要不会破坏云彩的模拟效果就行。iq说,频率越高的噪声应该移动的越快,但我觉得似乎反过来效果也没什么问题。Anyway,就按偶像说的做吧。iq还说,它们移动的方向并不是什么关键的问题,下面的代码显示了我采用的运动速度和移动方向:

sampler2D _Octave0;
sampler2D _Octave1;
sampler2D _Octave2;
sampler2D _Octave3;
float4 _Octave0_ST;
float4 _Octave1_ST;
float4 _Octave2_ST;
float4 _Octave3_ST;

v2f vert (appdata v) {
    v2f o;
    o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
    o.uv0.xy = TRANSFORM_TEX(v.texcoord, _Octave0) + _Time.x * 1.0 * _Speed * half2(1.0, 0.0);
    o.uv0.zw = TRANSFORM_TEX(v.texcoord, _Octave1) + _Time.x * 1.5 * _Speed * half2(0.0, 1.0);
    o.uv1.xy = TRANSFORM_TEX(v.texcoord, _Octave2) + _Time.x * 2.0 * _Speed * half2(0.0, -1.0);
    o.uv1.zw = TRANSFORM_TEX(v.texcoord, _Octave3) + _Time.x * 2.5 * _Speed * half2(-1.0, 0.0);
    return o;
}

其中,_Octave0是频率最低的噪声纹理,_Octave3是频率最高的噪声纹理。我们选择在顶点着色器中计算四张纹理的采样坐标,并把它们存储到两个half4类型的寄存器中,传递给片元着色器。
这样一来,我们只需要在片元着色器中按照类似下面的公式来计算分形噪声值fbm即可:

这样得到的fbm就可以用来判断该位置处的云彩厚度。我们希望可以控制云彩的稀疏度和锐利程度,比如之前视频显示的下雨时乌云密布(稀疏度小,锐利程度不高),而晴朗时有种万里无云的感觉(稀疏度大,而且锐利程度高)。这可以使用一个变量作为阈值,所有低于这个阈值的fbm都映射到该阈值,再使用另一个阈值,所有高于这个阈值的fbm都映射到这个阈值,而在两者中间的得以保留,最后再把这部分重新映射到0~1之间。如下图所示(来源:iq的博客):

这个过程实现起来很简单,主要对应的片元着色器代码如下:

float4 n0 = tex2D(_Octave0, i.uv0.xy);
float4 n1 = tex2D(_Octave1, i.uv0.zw);
float4 n2 = tex2D(_Octave2, i.uv1.xy);
float4 n3 = tex2D(_Octave3, i.uv1.zw);

float4 fbm = 0.5 * n0 + 0.25 * n1 + 0.125 * n2 + 0.0625 * n3;
fbm = (clamp(fbm, _Emptiness, _Sharpness) -  _Emptiness)/(_Sharpness - _Emptiness);

有了处理后的fbm,理论上我们现在可以直接使用这个值来混合云彩颜色和天空背景颜色了。我们可以单独把云彩模拟的Pass和背景分离开来,把fbm的值存储到输出颜色的透明通道,而把云彩的颜色存储到输出颜色的RGB通道,这样一来通过混合指令就可以和背景色混合了。
之前的过程大致可以用下面的伪代码表示:

Pass {
    // 渲染天空背景
    ...
}
Pass {
    // 渲染云彩层
    // 开启并设置混合系数,和上一个Pass的结果进行混合
    Blend SrcAlpha OneMinusSrcAlpha

    ...

    fixed4 frag (v2f i) : SV_Target {
        fixed4 col;
        ...
        col.rgb = _CloudColor.rgb;
        col.a = fbm;
    }
}

// 可以渲染多个云彩Pass
Pass {
    // 同上一个Pass,但噪声纹理UV的移动速度和方向有所不同
}

iq指出,但如果直接这么混合的话会让整个效果看起来很平淡(flat),iq把raymarching的思想考虑进来,添加了太阳(或月亮)方向对云彩颜色的影响,让整个效果看起来更加立体。

添加raymarching
这里使用的raymarching思想其实很简单,我们首先假设渲染的云彩层是从地面往天空方向观察得到的,也就是说,我们渲染的云彩像素值对应了模拟云彩的下方的某个点,如下图中的蓝点所示。光线从太阳出发,沿着图中的路径透过云彩到达蓝点,这段在云中走过的路程越长,光线衰减的就越多,因此我们的目的就是想要知道光线在云层中穿过的距离。

这可以使用raymarching来近似得到。我们已知蓝点对应的云彩厚度fbm,现在我们沿着光源方向前进一小步,即一个step到达第一个橙线对应的点,我们可以比较该点对应的云层厚度以及橙线本身的高度,如果厚度大于高度,则说明该点在云层内,否则说明在云层外。我们选择多个采样点,例如在本文的实现中我选择了4个,这其中在云层内的采样点的比例决定了该点的着色值。
那么现在的问题就是得到这些采样点的云层厚度值fbm。我们可以在片元着色器里沿着光源方向移动几个steps,再投影到天空的圆顶上得到它的采样坐标进行采样,这样一来我们有四张噪声纹理,进行四次raymarching steps就需要16次采样操作。一个更有效的方法是把每张噪声纹理的采样统一到一个采样操作中完成,减少采样次数。那么怎么做呢?我们可以在一开始生成噪声纹理时就把各个raymarching step对应的平移后的噪声纹理存储在不同的RGBA通道上,也就是说,真正的噪声值存储在R通道,按光源方向经过一个raymarching step后的噪声存储在G通道,经过两个raymarching steps后的噪声存储在B通道,经过三个raymarching steps后的噪声存储在A通道。这样一来,我们只需要一个tex2D操作就可以得到四个采样点的值。
接下来就是比较云彩的厚度值和采样的高度值。我们使用一个变量ray来存储这些高度值,由于采样距离都是固定的,因此ray也是固定的。我们用fbm减去ray,如果结果小于0,就说明在云层外,反之在云层内。我们使用max操作把结果规约到大于等于0的部分,然后对它们取平均值得到最后的结果。
最后,我们的片元着色器的代码如下:

fixed4 frag (v2f i) : SV_Target {
    fixed4 col = 0;

    float4 n0 = tex2D(_Octave0, i.uv0.xy);
    float4 n1 = tex2D(_Octave1, i.uv0.zw);
    float4 n2 = tex2D(_Octave2, i.uv1.xy);
    float4 n3 = tex2D(_Octave3, i.uv1.zw);

    float4 fbm = 0.5 * n0 + 0.25 * n1 + 0.125 * n2 + 0.0625 * n3;
    fbm = (clamp(fbm, _Emptiness, _Sharpness) -  _Emptiness)/(_Sharpness - _Emptiness);

    fixed4 ray = fixed4(0.0, 0.2, 0.4, 0.6);
    fixed amount = dot(max(fbm - ray, 0), fixed4(0.25, 0.25, 0.25, 0.25));

    col.rgb = amount * _CloudColor.rgb +  2.0 * (1.0 - amount) * 0.4;
    col.a = amount * 1.5;

    return col;
}

上面的amount就是我们的计算结果。在上面我们在计算云彩颜色时添加了灰色的影响等,这些都是根据效果进行的选择,你可以使用其他的计算方法。

代码

理解了上面的方法后代码就不难了。在我的实现里,我编写了一个Editor脚本来生成噪声层,使用了Unity wiki上的代码来生成无缝的噪声。下面是生成对话框,可以选择生成的纹理大小和光源方向:

点击Generate按钮后,会在指定文件夹内产生四张噪声纹理:

由于它们的RGBA都有值因此看起来是有一定颜色的半透明纹理。
我们把这四张噪声纹理赋给一个材质,它使用的Shader包含了两个Pass,一个Pass用于生成天空背景(包括一定的渐变颜色和星星),接下来的Pass是我们讲的云彩层。

天空层完全是为了演示而已,可以替换成自定义的任何背景。
完整的代码可以到这里下载。 

写在最后

本文讲到的方法非常简单,效果有限,如果做手游的有类似需求的可以借鉴本文的方法。
Shadertoy上有许多更加复杂的3D体积云的渲染例子,它们都使用了raymarching来进行建模和渲染,例如Clouds。一开始提到的SIGGRAPH中的方法的主要思想也是利用raymarching。iq的博客里有很多有价值而且算法也不是很难的文章,大家都可以多去看看。

参考链接: