TOD的云层渲染和Flare遮挡

Posted by 恶毒的狗 on April 16, 2020

关于TOD

前项目在模仿塞尔达的昼夜变换效果时用过 Time Of Day 这个Unity插件。

本文记录一下 TOD 的云层渲染方式,以及如何让 TOD 的云层遮挡太阳光的 Flare

TOD渲染顺序

以上图的白天为例,TOD的主要渲染顺序如下:

  1. 绘制太阳 (Background+30)
  2. 绘制大气层 (Background+50)
  3. 绘制云层 (Geometry+530)

其中,太阳和大气层都是实体渲染,按照实体渲染从近往远的优化策略,这里 RenderQueue 放在 Background 似乎不太合理,我们游戏中会把他们的渲染顺序调整到 Geometry 的后面。

关于太阳和大气层的渲染,特别是大气层的渲染,内容还不少,日后慢慢写,本文主要是关于云层渲染的。

TOD的云层渲染

大气层的模型是一个球面,而云层的模型是一个半球面,因为云层不会出现在地平线之下:

云层渲染的基本原理比较简单,就是根据一张密度图计算云层的形状,贴在这个半球面上,然后和大气层做Alpha混合。

作者在这个插件的主页说这个云层是 Semi-volumetric 的,从代码上看,这里的半体积其实就是在 yz 两个方向计算云的密度,这样计算很快,虽然不是真正的 体积云,但是效果不错,移动设备也能跑得动。

云层的密度图

云层的形状主要根据作者提供的一张密度图来计算,密度图的4个通道分别是不同频率的噪声图,如下:

在介绍密度计算之前,我们首先需要处理好半球面上的点到密度图的UV映射问题。

云层的UV计算

根据 TOD 的实现机制,摄像机刚好位于球面的中心,我们可以用模型空间下 归一化viewDir 来映射密度图的纹理坐标。

这里 viewDir 计算方式如下:

1
o.viewDir  = normalize(v.vertex.xyz);

考虑下面这种最简单的映射方式:用 viewDir 的水平面 XZ坐标 直接映射纹理的 UV

可以看到,云层越接近水平线,拉伸的就越厉害,因为均匀变化的水平坐标XZ对应的球面跨度变化是不均匀的。

要得到正确的结果,我们可以参考 这篇文章 来做球面点到纹理二维坐标的转换,不过作者用了一个计算量更小的方式:

1
2
3
4
5
inline float3 CloudPosition(float3 viewDir, float3 offset)
{
    float mult = 1.0 / lerp(0.1, 1.0, viewDir.y);
    return (float3(viewDir.x * mult + offset.x, 0, viewDir.z * mult + offset.z)) / TOD_CloudSize;
}

这里就是把 viewDir.y 考虑进去,这个值越小越接近水平线,XZ 平面上的跨度就越大,效果如下:

云层的密度计算

正确计算UV后,我们就可以在像素着色器计算密度了。

这里先不考虑N层噪声的叠加,我们看一下一层噪声的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inline half3 CloudLayerDensity(sampler2D densityTex, float4 uv, float3 viewDir)
{
    half3 density = 0;
    half4 n = tex2D(densityTex, uv.xy);

    // Density when marching in up direction
    density.y += n.r;

    // Density when marching in view direction
    density.z += n.r;

    // Coverage
    density.yz = (density.yz - TOD_CloudCoverage) * half2(TOD_CloudAttenuation, TOD_CloudDensity);

    // Opacity
    density.x = saturate(density.z);

    return density;
}

这里 yz 两个方向的密度都取密度图的R通道,TOD_CloudCoverage 用于裁剪云朵的范围,TOD_CloudDensity 可以控制云朵边缘的透明程度,TOD_CloudAttenuation 可以控制云朵的颜色衰减。

云层的颜色计算

得到密度后,云层的颜色计算代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
inline half4 CloudLayerColor(sampler2D densityTex, float4 uv, float4 color, float3 viewDir, float3 lightDir, float3 lightCol)
{
    half3 density = CloudLayerDensity(densityTex, uv, viewDir);

    half4 res = 0;
    res.a = density.x;
    res.rgb = 1.0 - density.y;

    res *= color;

    return res;
}

代码很简单,不啰嗦。

云层的渲染分级

上面的代码只是云层最基础的计算,TOD 针对云层的渲染质量分了三级:

  • TOD_CloudQualityType.Low
    • 一层噪声
  • TOD_CloudQualityType.Medium
    • 四层噪声叠加
  • TOD_CloudQualityType.High
    • 四层噪声叠加
    • 米氏散射叠加

这里就不贴代码了,有兴趣的话可以去支持一下作者。

Flare遮挡

下面回到项目中实际遇到的问题,如下图所示,太阳其实已经被云层遮挡了,但是 Flare 还是显示了出来:

要解决这个问题其实也简单,首先我们需要定义自己的 LensFlare 的渲染,如下图:

然后,在像素着色器部分,我们需要计算出太阳所在位置的云密度,进而计算出 影衰减 并应用到 Flare 上,主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
half4 frag (v2f i) : SV_Target
{
    half4 texColor = tex2D(_FlareTexture, i.uv);

    float3 skyPos = normalize(mul((float3x3)TOD_World2Sky, TOD_SunWorldPos));
    float4 cloudUV = CloudUV(skyPos);
    float cloudShadowAtten = TOD_CloudOpacity * CloudShadow(TOD_CloudTexture, cloudUV);
    cloudShadowAtten = 1 - cloudShadowAtten;

    texColor.rgb = texColor.rgb * cloudShadowAtten;
    return texColor * i.color;
}

最后放一张动图:

好了,拜拜。