【Unity Shader】2D动态云彩
写在前面
赶在年前撰写这篇文章。此前翻阅2015年的SIGGRAPH Course(关于渲染的内容可在selfshadow的博客中找到,资料十分齐全)时,我留意到了关于体积云渲染的部分。该课程详细介绍了开发者为游戏《地平线:黎明时分》所开发的动态天气系统,尤其着重讲解了其中云的模拟与渲染,极具参考价值。
在云的建模方面,主要运用了raymarching方法。其灵感或许源自shadertoy,但在此基础上增加了更多的程序控制和艺术效果。从相关图片中可以看出,渲染效果十分出色。
SIGGRAPH上的演讲主要聚焦于3D动态云彩的渲染,适用于端游这类大型游戏。后来在浏览iq的博客时,我发现他早在2005年就撰写了一篇关于2D动态云模拟的文章。文中所采用的算法相对简单,计算量也较小。这篇文章创作于十年前,当时电脑的计算资源极为有限,为了提升性能、减少内存占用,文中提出了许多实用的技巧(tricks)。尽管如今电脑的运算资源有了显著提升,但这些技巧在移动平台上依然具有应用价值。本文将详细介绍iq文章中提及的方法。
2D动态云彩
传统天空背景实现方式
在日常开发中,我们通常采用以下方式制作天空背景:首先准备一个半圆形的天空顶来模拟背景,然后准备几张包含天空背景(如蓝色天空和几朵白色云彩)的图片。将每张图片作为一层背景,并赋予一个材质,该材质会对图片进行纹理动画,通过移动每层的纹理来模拟云彩缓慢飘动的效果。这种方法简单有效,应用十分广泛。
动态模拟云彩的需求
然而,在某些情况下,我们希望游戏的天空背景并非预先设定好的,或者希望实现一个能够随天气变化而动态产生自然变化的天气系统。此时,使用预先准备好的图片来模拟云彩和天空就不再适用。本文旨在介绍如何使用程序动态模拟云彩,尽管本文实现的效果相对简陋,但相信在程序和美术的共同协作下,有相关需求的开发者能够从中获得启发,实现出更为精美的效果。
本文实现内容概述
本文的计算复杂度较低。在阅读下文之前,读者需要对噪声有一定的了解,若不熟悉相关内容,可以参考之前的文章【图形学】谈谈噪声。本文最终将实现一个简单的天空模拟,包括天空颜色、星星以及飘动的云彩等,同时允许用户调整云彩的颜色、厚度、尖锐度等参数。下面的视频展示了下雨和晴朗天气下的效果,其中天空部分的模拟采用了本文介绍的方法。 视频链接
算法实现
核心内容聚焦
实际上,本文的重点在于云彩的模拟,天空颜色等效果可以通过其他shader或纹理来实现。例如,在上述视频中,我使用了另一个Pass来渲染天空和星星等效果。接下来,我们将重点解释云彩模拟的部分。
云彩模拟原理
云彩的模拟主要运用分形噪声,噪声中的值对应着云彩的厚度。为了模拟云彩不规则变化的效果,我们需要一张不断变化的二维噪声纹理。在之前的文章【图形学】谈谈噪声中,我们提到可以使用一张三维噪声纹理来获得平滑变化的二维噪声纹理,其中第三个采样坐标对应时间参数。然而,使用3D纹理会占用大量内存,而我们的目标是实现实时渲染且尽可能降低计算量,因此这种方法并不适用。
关键技巧——移动分形噪声层
这里需要运用一个小技巧。分形噪声是由许多层不同采样大小的噪声(被称为octave)按照一定权重相加得到的。为了使最终的分形噪声不断变化,我们可以以不同的速度移动这些octave层,这样得到的分形噪声也会随之不断变化。在本文的实现中,我们和iq文中提到的一样,仅使用了4个octave。
创建噪声纹理
无缝噪声纹理的需求
第一步,我们需要创建组成分形噪声的各个octave。与【图形学】谈谈噪声一文略有不同的是,由于我们需要对这些纹理进行不断平移,为了实现无缝连接,我们要求这些噪声纹理是无缝的(seamless)。
无缝2D噪声纹理的生成方法
要得到无缝的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张噪声纹理以及它们按权重相加得到的分形噪声。在运行时,我们需要以不同的速度移动这些噪声纹理。频率越高的噪声纹理移动速度越快(不过实际上反过来效果也并无明显差异)。以下是相关代码示例:
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:
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 可以用于判断该位置处的云彩厚度。为了控制云彩的稀疏度和锐利程度,我们可以使用两个阈值。所有低于 _Emptiness 阈值的 fbm 值都映射到该阈值,所有高于 _Sharpness 阈值的 fbm 值都映射到这个阈值,而处于两者之间的值则保留,最后将这部分重新映射到0~1之间。
云彩与背景的混合
理论上,我们可以直接使用处理后的 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的移动速度和方向有所不同
}
添加raymarching
raymarching原理
直接混合云彩和背景会使整个效果显得平淡。为了让效果更加立体,我们引入raymarching思想,考虑太阳(或月亮)方向对云彩颜色的影响。
假设我们从地面往天空方向观察云彩,渲染的云彩像素值对应模拟云彩下方的某个点。光线从太阳出发,透过云彩到达该点,在云中走过的路程越长,光线衰减越严重。我们的目标是计算光线在云层中穿过的距离,这可以通过raymarching来近似实现。
采样点的选择与云层厚度判断
我们已知该点对应的云彩厚度 fbm,沿着光源方向前进一小步(一个step)到达第一个采样点,比较该点对应的云层厚度和采样点的高度。如果厚度大于高度,则该点在云层内;否则在云层外。在本文的实现中,我们选择了4个采样点,云层内采样点的比例决定了该点的着色值。
减少采样次数的技巧
为了得到这些采样点的云层厚度值 fbm,我们可以在片元着色器中沿着光源方向移动几个steps,再投影到天空的圆顶上得到采样坐标进行采样。但这样会导致采样次数较多,一个更有效的方法是在生成噪声纹理时,将各个raymarching step对应的平移后的噪声纹理存储在不同的RGBA通道上。这样,我们只需要一次 tex2D 操作就可以得到四个采样点的值。
片元着色器代码实现
以下是添加raymarching后的片元着色器代码:
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设置
我们将这四张噪声纹理赋给一个材质,该材质使用的Shader包含两个Pass。一个Pass用于生成天空背景(包括一定的渐变颜色和星星),另一个Pass用于渲染云彩层。需要注意的是,天空层仅用于演示,可替换为自定义的任何背景。
完整的代码可以点击此处下载。
写在最后
本文介绍的方法相对简单,效果也有一定的局限性。对于手游开发者,如果有类似需求,可以借鉴本文的方法。
Shadertoy上有许多更为复杂的3D体积云渲染例子,它们大多使用raymarching进行建模和渲染,例如Clouds。文章开头提到的SIGGRAPH中的方法也主要基于raymarching。此外,iq的博客中有很多有价值且算法难度适中的文章,建议大家多去阅读。