关于TOD
前项目在模仿塞尔达的昼夜变换效果时用过 Time Of Day 这个Unity插件。
本文记录一下 TOD 的云层渲染方式,以及如何让 TOD 的云层遮挡太阳光的 Flare。
TOD渲染顺序
以上图的白天为例,TOD的主要渲染顺序如下:
- 绘制太阳 (Background+30)
- 绘制大气层 (Background+50)
- 绘制云层 (Geometry+530)
其中,太阳和大气层都是实体渲染,按照实体渲染从近往远的优化策略,这里 RenderQueue 放在 Background 似乎不太合理,我们游戏中会把他们的渲染顺序调整到 Geometry 的后面。
关于太阳和大气层的渲染,特别是大气层的渲染,内容还不少,日后慢慢写,本文主要是关于云层渲染的。
TOD的云层渲染
大气层的模型是一个球面,而云层的模型是一个半球面,因为云层不会出现在地平线之下:
云层渲染的基本原理比较简单,就是根据一张密度图计算云层的形状,贴在这个半球面上,然后和大气层做Alpha混合。
作者在这个插件的主页说这个云层是 Semi-volumetric 的,从代码上看,这里的半体积其实就是在 y 和 z 两个方向计算云的密度,这样计算很快,虽然不是真正的 体积云,但是效果不错,移动设备也能跑得动。
云层的密度图
云层的形状主要根据作者提供的一张密度图来计算,密度图的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;
}
这里 y 和 z 两个方向的密度都取密度图的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;
}
最后放一张动图:
好了,拜拜。