Banner
大气散射渲染实战

一 、综述

大气散射是对现实世界大气现象的描述,其影响包括不限于白天和傍晚天际线的大气颜色变化,丁达尔现象的产生,人眼对于地球大气层的视觉感受。而对于追求“真实”的CG领域来说 大气散射则显得尤为重要,决定了大气层的真实与否。我们通常用 ray-march,path-tracing 方法来研究大气散射「光线在大气中是如何传输的」。

当前系统实现了一套基于物理的高级大气散射渲染管线,参考UE 引擎 [Hillaire 2020] 方案。 [Hillaire 2020] 是在 [Bruneton08] 基础上的创新,这篇论文最后达到的效果为:

  • 在保证效果path tracing的GT接近的情况下,比以前的预计算LUT,实时采样LUT [Bruneton08]的效率只稍微慢一点;
  • 有一定的LUT要素,但是LUT完全可以实时计算(大气参数可以实时修改,而且不是分帧更新之类的伪实时,可以做到一帧搞定);
  • 在iPhone6s上可以在每帧1ms以内搞定(包括了LUT计算);
  • 做了一些近似假设,从而可以用数值方法计算所有高阶散射之和;而不是像之前的方法只计算一定的阶数就结束。在大气稀薄的情况下(高阶散射更加重要)效果提升非常明显。

二、物理模型与散射理论

光线在大气中是如何传输

我们可以从光线传输方程、宏观几何模型微观介质属性三个维度来快速回顾下**基于物理的大气散射模型 **更详细的理论基础详见链接文章。

Volume Scattering 体积散射理论

光线传输方程与积分 (Light Transport)

构建光路

我们首先来讨论光线在大气中是如何**传输**的。如下图所示,即使一条从摄像机出发的光线没有直接命中太阳,它也能为当前像素带来颜色的贡献。考虑最简单的 单次散射 光路就能很快想明白为何天空不是纯黑色的,因为本来不应该经过视线的光由于空气粒子的反弹进入了视线,形成光路。

S-T-S 透射 ->散射 -> 透射                       散射(Scattering) 透射(Transmittance)

光线在空气中主要会发生两种物理现象:散射和透射。散射是指一束光和大气层中的微粒发生碰撞之后,众多粒子各奔东西四散而逃的物理现象。我们把逃逸到视线方向进而产生颜色贡献的光照能量记作

透射(Transmittance)描述的是光在介质中穿行所造成的能量衰减,记作

考虑上面提到的单次散射。这样一条简单的光路由 “透射-散射-透射” 三部分组成,它对于当前着色像素的贡献是 ,具体的计算步骤如下__ :

太阳光首先经过 衰减,剩余 能量

在某点发生散射,向视线方向散射出能量

随后在到达视线的路途中发生 衰减

最终剩余的能量为

这只是 “视线-大气层” 连线中某点散射形成的一条光路。事实上在大气层内太阳光可以被看做是平行光,因此需要对视线方向做积分来重建无数条光路的总和:

我们已经说明了光线在大气中是如何传输,但是并没有构建关于散射S透射T的具体表达函数,所以我们要进一步地探究散射透射的背后的物理模型。

具体到物理模型,当我们计算大气最终颜色时,实际上是在求解光线传输方程(基于 Beer-Lambert 定律)。

透射率 (Transmittance)

透射率 描述光线从点 到点 剩余的透光度/能量比例,通过积分路径上的 。这正是我们 Transmittance LUT 中预计算并缓存的值:

散射(Scattering)

Scattering/:在某点x上,各个方向的入射光朝着某方向v出射散射强度之和;

在大气渲染中,我们通过 “散射系数” 和 “相位函数” 来描述光线散射的现象。散射系数描述了光线和空气分子发生一次反弹之后逃逸到各个方向的(失去的)能量 总和

剩下的能量并非完全进入我们的视线,而是会四下逃窜。因此相位函数表述了反弹后剩下的能量有多少能够逃到指定的方向上。相位函数和 BRDF 一样在定义域上积分等于 1.0,这保证了能量守恒:

将散射系数和相位函数简单相乘就能得到在某点发生一次散射之后,逃逸到某个方向上的能量大小:

Extinction : 消光系数 /湮灭系数 ,描述光在传播中能量的损耗,来自散射(光被弹走)和吸收(光变热能)

描述发生吸收或者散射事件的概率,与发生位置的气体浓度、性质有关。

Absorption Coefficient 吸收系数

Scattering Coefficient 散射系数

Phase Function ,:相位函数,描述光子在散射后朝着各个方向出射的概率,单位为 。论文中的分别代表了Rayleigh散射Mie散射各向同性散射的Phase Function;

大气层并非完全均匀,大气分子的密度随着高度的增加而减少。越少的分子数目意味着发生散射的概率越低。此外大气对红、黄、蓝三种波长的光有着不同的散射量。因此散射系数通常是波长和高度的函数:

对于空气这种介质,它的密度通常随着海拔的增高而降低。密度的高低反应在数值上就是散射概率的减少,散射概率的减少对应着散射后剩余能量的增加。因此可以用海平面(Y=0)处的散射系数和海平面 h 处的高度密度衰减函数来描述任意高度的散射系数:

单次内散射积分 (Single In-Scattering)

In-Scattering,最终进入我们相机像素的能量,是视线路径上无数个受光点将太阳光散射向相机的能量总和:

某点上朝着某方向到达另一点的散射强度,比Scattering多乘了Transmittance

相机位置

简单来说:视线透射率 × 散射相函数 × 太阳光透射率

多重散射补偿 (Multi-Scattering)

Luminance,:在路径上对, In-Scattering 做积分得到的光照强度,同时也是相机实际感受到的强度。

此外,中的下标n表示了属于第几阶散射。

仅仅计算单次散射会导致天空背光面和底部能量严重丢失(死黑)。在我们的架构中,我们通过预计算 Multi-Scattering LUT,利用基于球面随机采样的积分方法,将二阶及以上的散射能量补回系统,从而实现能量守恒和极其柔和的全局天光过渡。


宏观几何与介质密度模型

我们在物理上将大气层抽象为一个包裹着星球的非均匀介质球壳。

  • 几何定义:星球具有一定的半径 (如地球约为 6360 km),大气层具有厚度 (约为 60 km)。我们所有的积分计算都建立在这个以地心为原点的球坐标系中。
  • 密度衰减模型:大气不是均匀的,受重力影响,越靠近地表密度越高。我们采用指数衰减模型来描述介质密度 随海拔高度 的变化:

  • 这里的 标高(Scalar Height),表示密度衰减到海平面密度 (约 36.8%)时的高度。不同的介质拥有不同的标高。

微观物理介质属性

光线穿过大气时,会与三种主要介质发生交互,空气分子 [瑞利散射] 气溶胶粒子 [米氏散射]臭氧;在特定球形层内,每种介质的散射比例 S 散射系数 (Scattering)相位函数( Phase Function 决定,同时考虑了气溶胶的吸收,为简化暂忽略折射率随高度变化引起的光线弯曲。

1. 瑞利散射 (Rayleigh Scattering)

这是由空气分子(氧气、氮气)引起的散射。其颗粒尺寸远小于可见光波长。

  • 波长依赖性:瑞利散射的强度与光波长的四次方成反比()。由于蓝光波长短,被散射的概率远高于红光,这就是白天天空呈现蓝色的根本原因
  • 相函数:瑞利相函数是前后对称的,光子向前后散射的概率大致相同。

  • 标高:分子集中在大气层,标高 设定为 8000 m。

2. 米氏散射 (Mie Scattering)

由大气中的气溶胶、灰尘、水滴(即雾霾)引起,颗粒尺寸与可见光波长接近。

  • 波长独立性:米氏散射对波长几乎没有偏好,因此散射出的光是白色的(导致天空发白、起雾)。
  • 相函数与双 HG 模型:米氏颗粒具有极其强烈的前向散射特性。为了精确拟合太阳周围极其明亮的日晕(Halo)并保留一定的后向散射,我们在系统中引入了更高级的双 Henyey-Greenstein (Double HG) 相函数,而不是传统的单 HG 模型:

  • 这里 控制强烈的前向日晕, 补偿后向散射。
  • 标高:气溶胶集中在地表对流层,标高 设定为 1200 m。

3. 臭氧吸收 (Ozone Absorption)

臭氧不参与散射,但会强烈吸收光能

  • 视觉贡献:它主要吸收黄绿光。在日出日落时分,光线穿透大气路径极长,黄绿光被彻底吸收,留下的红蓝光混合,赋予了天顶和地影区迷人的紫色调。
  • 密度分布:臭氧集中在平流层,因此我们不使用指数衰减,而是使用一个中心高度在 25000 m 左右的线性帐篷分布(Tent Distribution)

VolumetricScattering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#ifndef  VOLUMETRIC_SCATTERING_INCLUDED
#define VOLUMETRIC_SCATTERING_INCLUDED
//Volumetric Scattering

static const float INV_4PI = 1.0 / (4.0 * PI);
static const float EPS_DENOM = 1e-6; // 可调:越小越接近物理,但稳定性弱

struct AtmosphereParameter
{
// 地理/高度
float SeaLevel;
float PlanetRadius;
// 光照
float AtmosphereHeight;
float SunLightIntensity;

float3 SunLightColor;
float SunDiskAngle;

// Rayleigh
float RayleighScatteringScale;
float RayleighScatteringScalarHeight;
// Mie
float MieScatteringScale;
float MieAnisotropy;

float MieScatteringScalarHeight;
// Ozone
float OzoneAbsorptionScale;
float OzoneLevelCenterHeight;
float OzoneLevelWidth;

// Double Henyey-Greenstein Phase Function Parameters
float4 MiePhaseParams; //xyz: g1, g2, w1
};

//========================= Scattering Coefficient =========================

float ExpHeight(float h, float H) { return exp(-h * rcp(H)); }

float3 RayleighCoefficient(in AtmosphereParameter param, float h)
{
return float3(5.802e-6, 13.558e-6, 33.1e-6) * ExpHeight(h, param.RayleighScatteringScalarHeight);
}

float3 MieCoefficient(in AtmosphereParameter param, float h)
{
return (3.996e-6).xxx * ExpHeight(h, param.MieScatteringScalarHeight);
}

//======================Phase Functions======================
//-----------Isotropic Phase Function-----------
float Phase_Isotropic() // No angular dependence
{
return INV_4PI;
}


//---------------HG Henyey-Greenstein Phase Function--------------

float Phase_HG(float cosTheta, float g)
{
//HG_Phase = 1/(4π) * (1 - g²) / (1 + g² - 2gcosθ)^(3/2)

float g2 = g * g;

float denom = 1.0 + g2 - 2.0 * g * cosTheta;
denom = max(denom, EPS_DENOM); // 避免浮点炸裂
float denom_32 = denom * sqrt(denom); // denom^(1.5)

return INV_4PI * (1.0 - g2) * rcp(denom_32);
}

float Phase_SingleHG(float cosTheta, float g)
{
g = clamp(g, -0.99999, 0.99999);
return Phase_HG(cosTheta, g);
}

float Phase_DoubleHG(float cosTheta, float g1, float g2, float w1)
{
float w2 = 1.0 - w1;
return w1 * Phase_SingleHG(cosTheta, g1) + w2 * Phase_SingleHG(cosTheta, g2);
}

float Phase_DoubleHG(float cosTheta, float3 params)
{
return Phase_DoubleHG(cosTheta, params.x, params.y, params.z);
}

//---------Rayleigh Phase Function------------
float Phase_Rayleigh(float cosTheta)
{
return (3.0 / (16.0 * PI)) * (1.0 + cosTheta * cosTheta);
}

//-----------Mie Phase Function--------------
float Phase_Mie(float cosTheta, float g)
{
// Mie_phase(cosTheta,g) = 3/(8π) * (1 - g²)/(2 + g²) * (1 + cos²θ) / (1 + g² - 2gcosθ)^(3/2)
// = A * B * C * (1/D)

const float A = 3.0 / (8.0 * PI);

float g2 = g * g;
float B = (1.0 - g2) * rcp(2.0 + g2);

float C = 1.0 + cosTheta * cosTheta;

float denom = 1.0 + g2 - 2.0 * g * cosTheta;
denom = max(denom, EPS_DENOM); // 避免浮点炸裂
float D = denom * sqrt(denom); // denom^(1.5)

return A * B * C * rcp(D);
}

//========================= Scattering Functions ========================

float3 Scattering(in AtmosphereParameter param, float3 p, float3 inDir, float3 outDir)
{
float cosTheta = dot(inDir, outDir);

float h = length(p) - param.PlanetRadius;

float3 rayleigh = RayleighCoefficient(param, h) * Phase_Rayleigh(cosTheta);
// float3 mie = MieCoefficient(param, h) * Phase_Mie(cosTheta, param.MieAnisotropy);
// Alternatively, using Double Henyey-Greenstein Phase Function
// 使用双HG相函数 g1=0.76, g2=-0.4, w1=0.9
float3 mie = MieCoefficient(param, h) * Phase_DoubleHG(cosTheta, param.MiePhaseParams.xyz);

return rayleigh + mie;
}

#endif

Transmittance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
//===================== Transmittance ============================

float3 MieAbsorption(in AtmosphereParameter param, float h)
{
const float3 sigma = (4.4e-6).xxx;
return sigma * ExpHeight(h, param.MieScatteringScalarHeight);
}

float3 OzoneAbsorption(in AtmosphereParameter param, float h)
{
const float3 sigma_lambda = (float3(0.650f, 1.881f, 0.085f)) * 1e-6;
float center = param.OzoneLevelCenterHeight;
float width = param.OzoneLevelWidth;
float rho = max(0.0, (1.0 - (abs(h - center) * rcp(width))));
return sigma_lambda * rho;
}

float3 ExtinctionCoefficient(in AtmosphereParameter param, float h)
{
float3 scattering = RayleighCoefficient(param, h) + MieCoefficient(param, h); // scattering
float3 absorption = OzoneAbsorption(param, h) + MieAbsorption(param, h); // absorption
float3 extinction = scattering + absorption; // extinction
return extinction; // extinction
}

float3 Transmittance(in AtmosphereParameter param, float3 p, float3 dir, float distance)
{
const int N_SAMPLE = 32;

float ds = distance * rcp(float(N_SAMPLE));
float3 sum = 0.0;
p = p + (dir * ds) * 0.5; // mid-point rule 对湮灭系数的估计是通过采样路径的中点值来代表

for (int i = 0; i < N_SAMPLE; i++)
{
float h = length(p) - param.PlanetRadius;

float3 extinction = ExtinctionCoefficient(param, h);

sum += extinction * ds;
p += dir * ds;
}
return exp(-sum);
}

float3 Transmittance(in AtmosphereParameter param, float3 p1, float3 p2)
{
return Transmittance(param, p1, normalize(p2 - p1), length(p2 - p1));
}

三、4-LUTs 架构与数据流

大气渲染的组成

具体应用到游戏渲染里,我们一般会把大气渲染分为两个部分

  1. 天空背景的渲染,即skybox
  2. 大气透视的渲染,也是我们俗称的大气雾效

在渲染的时候需要考虑光线入射方向s上的遮挡(阴影),通常使用传统的shadow map就可以了。这样我们就可以得到god ray效果。

首先来考虑单次散射的情况。

Sky Background 天空背景

[Rendering] 基于物理的大气渲染可以直接拿来渲染天空盒。其中,第0级散射可以理解成对“sun disk(太阳圆盘)”的渲染。

Aerial Perspective 大气透视

大气透视(aerial perspective)是渲染场景里物体距离摄像机远近的很重要的效果,也是所谓的雾效。与渲染天空盒略微不同的是,我们还需要考虑反射地表颜色等。

于是一共需要考虑 2 类光照:

  • 地表颜色经过路径衰减后的光照
  • 路径上由于其他点内散射到相机路径上的光照

图示 考虑了在A点、沿着观察路径AB接收到的光照。这部分光照可以分为两个部分:一是B点反射的地表颜色Rb经过路径AB衰减后的光照,也就是第0级反射,二是路径AB上由于散射贡献的光照:

就是我们之前推导的T(AB)

则是之前推导的多级散射模型

Precomputed LUTs

大气散射渲染数学上是一个多重积分求解问题,为了将 甚至更高复杂度的多重积分降维到实时渲染可接受的程度,我们将计算结果预计分至四张纹理中:

查找表名称 物理意义 存储内容
Transmittance 透射率 记录不同高度、角度光线穿过大气的剩余比例。
Multi-Scattering 多重散射 补偿单次散射丢失的能量,防止背光处死黑。 50 倍渲染效果
Sky View 天空颜色 结合单次与多重散射,存储相机看到的最终天空色。
Aerial Perspective 空气透视 3D Froxel 结构,存储场景深度对应的雾效颜色与透射率。

SkyView 结合预积分Lut的生成过程

之后利用 Transmittance,Multi-Scattering 预积分,再 Ray March 得到最终的 SkyView LUT 。

从 不同渲染坐标到物理空间的映射 是这套实现中值得关注的部分

星球空间

基于物理的大气散射渲染中,我们首先需要考虑真实世界与游戏世界坐标系的转换:

  • 世界空间 (World Space):Unity 默认空间。
  • 星球中心空间 (Planet-Centric Space):所有物理积分的基础空间。
    • 变换逻辑:假设星球中心位于 或通过偏移量计算。在 Compute Shader 中,我们需要将相机高度转换为相对地心的距离
    • 高度计算r = WorldPos.y - SeaLevel + PlanetRadius

另外,在本项目中需要频繁的计算 相机与大气层是否相交 (用于早期退出),代码实现如下:

RayIntersectSphere
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
float RayIntersectSphere(float3 center, float radius, float3 rayStart, float3 rayDir)
{
// L = vector from ray start to sphere center
float3 L = center - rayStart;
// tca = projection of L onto rayDir
float tca = dot(L, rayDir);
// d2 = squared distance from sphere center to ray
float d2 = dot(L, L) - tca * tca;
float radius2 = radius * radius;
// no intersection
if (d2 > radius2) return -1.0;
// thc = distance from closest approach to intersection point
float thc = sqrt(radius2 - d2);
// compute distances along ray
float t0 = tca - thc;
float t1 = tca + thc;
// choose nearest positive intersection
if (t0 > 0.0) return t0;
if (t1 >= 0.0) return t1;
//避免 self-shadow / self-intersection
// const float EPS = 1e-4;
// if (t0 > EPS) return t0;
// if (t1 > EPS) return t1;
// both intersections are behind ray
return -1.0;
}

Transmittance LUT

为了在有限的贴图分辨率下获得更高的精度,系统在 Helper.hlsl 中实现了精细的参数映射:

  • 映射目标:将 映射到 UV。
  • 天顶角余弦

UvToTransmittanceLutParams
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void UvToTransmittanceLutParams(float bottomRadius, float topRadius, float2 uv, out float mu, out float r)
{
float x_mu = uv.x;
float x_r = uv.y;
float H = sqrt(max(0.0f, topRadius * topRadius - bottomRadius * bottomRadius));

float rho = H * x_r;
float rho2 = rho * rho;
r = sqrt(max(0.0f, rho2 + bottomRadius * bottomRadius));

float d_min = topRadius - r;
float d_max = rho + H;
float d = d_min + x_mu * (d_max - d_min);

float denom = 2.0 * r * d + 1e-6; // 防止除零
float mu_raw = (H*H - rho2 - d*d) *rcp(denom); // 公式处理 d==0 时也稳定

mu = saturate(mu_raw * 0.5 + 0.5) * 2.0 - 1.0; // 将 mu 映射回 [-1, 1]
}

float2 GetTransmittanceLutUV(float bottomRadius, float topRadius, float mu, float r)
{
float H =sqrt(topRadius *topRadius -bottomRadius *bottomRadius);

float rho2 = r*r - bottomRadius*bottomRadius;
float rho = sqrt(max(rho2, 0.0));

float discriminant = r * r *(mu * mu -1.0f) + topRadius * topRadius;
float d =max(0.0f,(-r *mu +sqrt(discriminant)));

float d_min = topRadius - r;
float d_max = rho + H;

float x_mu = saturate((d - d_min) * rcp(d_max - d_min));
float x_r = saturate(rho * rcp(H));

return float2( x_mu, x_r );
}

GLSL (展开查看 36 行完整代码)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
[numthreads(8, 8, 1)]
void CSTransmittanceLUT (uint3 id : SV_DispatchThreadID)
{
// 检查线程是否在纹理范围内
if (id.x >= _Width || id.y >= _Height)
return;

// 计算 UV 坐标 [0, 1]
// 在像素中心采样
float2 uv = (float2(id.xy) + 0.5) / float2(_Width, _Height);

// 获取大气参数
AtmosphereParameter param = GetAtmosphereParameter();

float bottomRadius = param.PlanetRadius;
float topRadius = param.PlanetRadius + param.AtmosphereHeight;

// 计算当前 UV 对应的 cos_theta (mu) 和 height (r)
float cos_theta = 0.0;
float r = 0.0;
UvToTransmittanceLutParams(bottomRadius, topRadius, uv, cos_theta, r);

// 构建射线
float sin_theta = sqrt(saturate(1.0 - cos_theta * cos_theta));
float3 viewDir = float3(sin_theta, cos_theta, 0);
float3 eyePos = float3(0, r, 0);

// 光线和大气层外边界求交
float dis = RayIntersectSphere(float3(0, 0, 0), topRadius, eyePos, viewDir);

// 如果没有交点(不应该发生),返回白色(无吸收)
if (dis < 0.0)
{
TransmittanceLUT[id.xy] = float4(1, 1, 1, 1);
return;
}

float3 hitPoint = eyePos + viewDir * dis;

// Raymarch 计算 Transmittance
float3 transmittance = Transmittance(param, eyePos, hitPoint);

// 写入结果
// Alpha 通道设为 1(不透明)
TransmittanceLUT[id.xy] = float4(transmittance, 1.0);
}

Multi-Scattering LUT

[Hillaire 2020]论文作者在计算Multiple Scattering时,认为高阶散射非常低频,从此出发使用了一些简化,包括:

  1. 大于等于2阶的散射,将Rayleigh散射和Mie散射视为为各向同性的散射
  2. 计算某点的大于2阶的Scattering时,认为该点周围任意一点的Illuminance与其相同

第一点,对于1阶散射,我们认为Rayleigh、Mie散射是正常的,但是在之后的阶数,我们认为相位函数对于任何方向都是相同的值 。于是我们计算Multiple Scattering LUT时,LUT的维数就少了2维。

第二点,在计算某点的Scattering时,空间中任意一点的某一阶的Scattering都视作和该点相同。因为参与计算的点需要与该点有视线链接 [Line of sight],从整个地球的视角来看确实是Neighboring points。这一条近似看上去是非常粗略的,但是看起来是有效的。

实际流程中,Multiple Scattering LUT的计算可以一个pass输出到一张32*32的2D LUT完成。只需要2D的原因是因为上述简化中我们使用了各向同性Phase Function,不需要存储相机跟光照方向的关系,只需要存储不同高度、不同光照角度的情况即可。

在pass中,论文中提到,首先计算,这里的是在LUT当前像素代表的点上,对整个球面做 Phase Function * Luminance的积分得到,记为 。具体计算方式公式为如下:

其中计算部分的为地面的反射光,为阳光到达x点的照度。这里的是一个单位值的阳光照度,实际的照度数值是在最后乘上去的。

转移函数

第二步,在上述计算完毕后,计算当前像素的Transfer Function[ 不是真的Function,对于当前计算的像素来说是一个固定的数值 ],可以将转换为更高阶的Scattering。论文中直接给出了Transfer Function的计算方法:

使用 ,有:

这里可能会对的计算稍微感到迷惑。其实这是在上述1、2点简化的情况下,将原本的计算散射的式子做了化简的结果。我们可以简单地推导一下。首先是传统的的计算方法:

根据简化1、2,我们认为在计算当前像素时,在任意一点都是固定的数值,式可以改写为:

,再代回去(6),两边同除,将替换为,得:

可以发现,相比(2)式,(7)式中省去了地面反射。作者表示确实是忽略了多重散射照射到地面形成反射的贡献,不过保留的话也没办法化简出这么一个 。好在这一部分贡献并不是很多,对最终结果影响不大。

多重散射结果

有了,我们就可以计算任意了。接下去就是论文独到的地方。

根据数学公式,因为小于1.0,我们可以直接得到无穷阶数的之和:

最后,就可以将写入二维的LUT中保存了。之后采样这张LUT,就可以得到2阶及以上阶数的散射之和。

TransmittanceToTopOfAtmosphere
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
float3 TransmittanceToTopOfAtmosphere(in AtmosphereParameter param, float3 p, float3 dir, Texture2D transmittanceLut, SamplerState lutSampler)
{
// 计算光线与大气层顶的交点
float bottomRadius = param.PlanetRadius;
float topRadius = param.PlanetRadius + param.AtmosphereHeight;

float3 upVector = normalize(p); // 假设大气层是球形的,以地心为中心
float cosTheta = dot(dir, upVector);
float r = length(p);

float2 uv = GetTransmittanceLutUV(bottomRadius, topRadius, cosTheta, r);

float t = RayIntersectSphere(float3(0, 0, 0), topRadius, p, dir);
if (t < 0.0)
{
// 光线未穿过大气层,返回全透射
return float3(1.0, 1.0, 1.0);
}

return transmittanceLut.SampleLevel(lutSampler, uv, 0).rgb;
}

float3 IntegralMultiScattering(in AtmosphereParameter param, float3 samplePoint, float3 lightDir, Texture2D transmittanceLut, SamplerState lutSampler)
{
const int N_DIRECTION = 64;
const int N_SAMPLE = 32;
float3 RandomSphereSamples[64] = {
float3(-0.7838, -0.620933, 0.00996137),
float3(0.106751, 0.965982, 0.235549),
float3(-0.215177, -0.687115, -0.693954),
float3(0.318002, 0.0640084, -0.945927),
float3(0.357396, 0.555673, 0.750664),
float3(0.866397, -0.19756, 0.458613),
float3(0.130216, 0.232736, -0.963783),
float3(-0.00174431, 0.376657, 0.926351),
float3(0.663478, 0.704806, -0.251089),
float3(0.0327851, 0.110534, -0.993331),
float3(0.0561973, 0.0234288, 0.998145),
float3(0.0905264, -0.169771, 0.981317),
float3(0.26694, 0.95222, -0.148393),
float3(-0.812874, -0.559051, -0.163393),
float3(-0.323378, -0.25855, -0.910263),
float3(-0.1333, 0.591356, -0.795317),
float3(0.480876, 0.408711, 0.775702),
float3(-0.332263, -0.533895, -0.777533),
float3(-0.0392473, -0.704457, -0.708661),
float3(0.427015, 0.239811, 0.871865),
float3(-0.416624, -0.563856, 0.713085),
float3(0.12793, 0.334479, -0.933679),
float3(-0.0343373, -0.160593, -0.986423),
float3(0.580614, 0.0692947, 0.811225),
float3(-0.459187, 0.43944, 0.772036),
float3(0.215474, -0.539436, -0.81399),
float3(-0.378969, -0.31988, -0.868366),
float3(-0.279978, -0.0109692, 0.959944),
float3(0.692547, 0.690058, 0.210234),
float3(0.53227, -0.123044, -0.837585),
float3(-0.772313, -0.283334, -0.568555),
float3(-0.0311218, 0.995988, -0.0838977),
float3(-0.366931, -0.276531, -0.888196),
float3(0.488778, 0.367878, -0.791051),
float3(-0.885561, -0.453445, 0.100842),
float3(0.71656, 0.443635, 0.538265),
float3(0.645383, -0.152576, -0.748466),
float3(-0.171259, 0.91907, 0.354939),
float3(-0.0031122, 0.9457, 0.325026),
float3(0.731503, 0.623089, -0.276881),
float3(-0.91466, 0.186904, 0.358419),
float3(0.15595, 0.828193, -0.538309),
float3(0.175396, 0.584732, 0.792038),
float3(-0.0838381, -0.943461, 0.320707),
float3(0.305876, 0.727604, 0.614029),
float3(0.754642, -0.197903, -0.62558),
float3(0.217255, -0.0177771, -0.975953),
float3(0.140412, -0.844826, 0.516287),
float3(-0.549042, 0.574859, -0.606705),
float3(0.570057, 0.17459, 0.802841),
float3(-0.0330304, 0.775077, 0.631003),
float3(-0.938091, 0.138937, 0.317304),
float3(0.483197, -0.726405, -0.48873),
float3(0.485263, 0.52926, 0.695991),
float3(0.224189, 0.742282, -0.631472),
float3(-0.322429, 0.662214, -0.676396),
float3(0.625577, -0.12711, 0.769738),
float3(-0.714032, -0.584461, -0.385439),
float3(-0.0652053, -0.892579, -0.446151),
float3(0.408421, -0.912487, 0.0236566),
float3(0.0900381, 0.319983, 0.943135),
float3(-0.708553, 0.483646, 0.513847),
float3(0.803855, -0.0902273, 0.587942),
float3(-0.0555802, -0.374602, -0.925519),
};
// const float uniform_phase = 1.0 / (4.0 * PI);
const float sphereSolidAngle = 4.0 * PI / float(N_DIRECTION);

float3 G_2 = float3(0, 0, 0);
float3 f_ms = float3(0, 0, 0);

for (int i = 0; i < N_DIRECTION; i++)
{
// 光线和大气层求交
float3 viewDir = RandomSphereSamples[i];
float dis = RayIntersectSphere(float3(0, 0, 0), param.PlanetRadius + param.AtmosphereHeight, samplePoint, viewDir);
float d = RayIntersectSphere(float3(0, 0, 0), param.PlanetRadius, samplePoint, viewDir);
if (d > 0) dis = min(dis, d);
float ds = dis * rcp(float(N_SAMPLE));

float3 p = samplePoint + (viewDir * ds) * 0.5;
float3 opticalDepth = float3(0, 0, 0);

for (int j = 0; j < N_SAMPLE; j++)
{
float h = length(p) - param.PlanetRadius;

float3 sigma_s = RayleighCoefficient(param, h) + MieCoefficient(param, h); // scattering
float3 sigma_a = OzoneAbsorption(param, h) + MieAbsorption(param, h); // absorption
float3 sigma_t = sigma_s + sigma_a; // extinction
opticalDepth += sigma_t * ds;

float3 t1 = TransmittanceToTopOfAtmosphere(param, p, lightDir, transmittanceLut, lutSampler);
float3 s = Scattering(param, p, lightDir, viewDir);
float3 t2 = exp(-opticalDepth);

// 用 1.0 代替太阳光颜色, 该变量在后续的计算中乘上去
G_2 += t1 * s * t2 * Phase_Isotropic() * ds * 1.0;
f_ms += t2 * sigma_s * Phase_Isotropic() * ds;

p += viewDir * ds;
}
}

G_2 *= sphereSolidAngle;
f_ms *= sphereSolidAngle;
return G_2 * (1.0 / (1.0 - f_ms));
}

[numthreads(8, 8, 1)]
void CSMultiScatteringLUT (uint3 id : SV_DispatchThreadID)
{
// 检查线程是否在纹理范围内
if (id.x >= _Width || id.y >= _Height)
return;

// 计算 UV 坐标 [0, 1]
// 在像素中心采样
float2 uv = (float2(id.xy) + 0.5) / float2(_Width, _Height);

AtmosphereParameter param = GetAtmosphereParameter();

float mu_s = uv.x * 2.0 - 1.0; //天顶角
float r = uv.y * param.AtmosphereHeight + param.PlanetRadius;

float cos_theta = mu_s;
float sin_theta = sqrt(1.0 - cos_theta * cos_theta);
float3 lightDir = float3(sin_theta, cos_theta, 0);
float3 p = float3(0, r, 0);

float3 multiScattering = IntegralMultiScattering(param, p, lightDir, TransmittanceLUT_ReadOnly, sampler_LinearClamp);

// 写入结果
// Alpha 通道设为 1(不透明)
MultiScatteringLUT[id.xy] = float4(multiScattering, 1.0);

//Debug 放大多次散射结果50倍以便观察
// MultiScatteringLUT[id.xy] = float4(multiScattering *50.0, 1.0);

}

Sky View LUT

在实际的渲染流程中,首先根据相机位置渲染至一张低分辨率的Sky-View LUT上,后期再合成到 SkyBox 上。

Sky-View LUT中包含了当前相机位置接收到的各个角度的Luminance。计算时根据像素对应的视线方向直接做Raymarch得到结果。

The Sky-View LUT during daytime. The sun direction canbe seen on the left side, where Mie scattering happens

坐标映射

Sky View LUT 使用极坐标映射来存储从相机视角出发的全天空颜色:

  • UV 到视线方向 (ViewDir)
    • theta (天顶角):通过 (1.0 - uv.y) * PI 映射。
    • phi (方位角):通过 (uv.x * 2 - 1) * PI 映射。
  • 视线方向到 UV:利用 atan2asin 将 3D 方向反解回 2D UV 坐标。

Sky View LUT Coordinate Transformation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
float3 UVToViewDir(float2 uv)
{
float theta = (1.0 - uv.y) * PI;
float phi = (uv.x * 2 - 1) * PI;

float x = sin(theta) * cos(phi);
float z = sin(theta) * sin(phi);
float y = cos(theta);

return float3(x, y, z);
}

float2 ViewDirToUV(float3 v)
{
float2 uv = float2(atan2(v.z, v.x), asin(v.y));
uv /= float2(2.0 * PI, PI);
uv += float2(0.5, 0.5);

return uv;
}

作者观察到高频视觉信息特征在地平线附近更为显著,为了更精准地呈现这些特征 [天空中大部分的散射现象是低频的,除了靠近地平线部分会变得高频],在计算纹理坐标时对维度进行了非线性变换,使得靠近地平线的纹理像素得到了更密集的压缩。具体采用了简单的二次曲线:

$$ v=0.5+0.5sign(l)\sqrt{\frac{\lvert l \rvert}{\pi /2}} ,with \space l \in[-\pi/2,\pi/2] $$

Figure 5: The non-linear parameterization of the Sky-View LUThelps to concentrate texel details at the horizon, where it visuallymatters.

在论文中的测试,PC上只需要200*100的分辨率效果就足够。另外太阳本身是不会渲染在图里的,因为属于高频特征。会在后面再合成上去。

需要注意的是,这里跟 [Bruneton08] 已经不一样了,[Bruneton08] 中不会有任何实时的Raymarch,都是LUT查找搞定;而这篇论文中,不管怎么样,在这一步实时Raymarch都是少不了的(其他的Raymarch步骤可以看情况跳过),这也是相比老方法会稍微慢一点的原因。

MultiScattering
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
float3 GetMultiScattering(in AtmosphereParameter param, float3 p, float3 lightDir, Texture2D _lut, SamplerState _sampler)
{
float h =length(p) -param.PlanetRadius;

float3 sigma_s = RayleighCoefficient(param, h) + MieCoefficient(param, h);

// 读取多重散射查找表
// 太阳天顶角余弦
float cosSunZenithAngle = dot(normalize(p), lightDir);
float2 uv = float2(cosSunZenithAngle * 0.5 + 0.5, h / param.AtmosphereHeight);
float3 G_ALL = _lut.SampleLevel(_sampler, uv, 0).rgb;

return G_ALL * sigma_s;
}

// 计算天空颜色
float3 CaculateSkyView(
in AtmosphereParameter param, float3 eyePos, float3 viewDir, float3 lightDir, float maxDis,
Texture2D _transmittanceLut, Texture2D _multiScatteringLut, SamplerState _sampler)
{
const int N_SAMPLE = 32;
float3 color = float3(0, 0, 0);

// 光线和大气层, 星球求交
float dis = RayIntersectSphere(float3(0,0,0), param.PlanetRadius + param.AtmosphereHeight, eyePos, viewDir);
float d = RayIntersectSphere(float3(0,0,0), param.PlanetRadius, eyePos, viewDir);
if(dis < 0) return color;
if(d > 0) dis = min(dis, d);
if(maxDis > 0) dis = min(dis, maxDis); // 带最长距离 maxDis 限制, 方便 aerial perspective lut 部分复用代码

float ds = dis / float(N_SAMPLE);
float3 p = eyePos + (viewDir * ds) * 0.5;
float3 sunLuminance = param.SunLightColor * param.SunLightIntensity;
float3 opticalDepth = float3(0, 0, 0);

for(int i=0; i<N_SAMPLE; i++)
{
// 积累沿途的湮灭系数
float h = length(p) - param.PlanetRadius;
float3 extinction = RayleighCoefficient(param, h) + MieCoefficient(param, h) + // scattering
OzoneAbsorption(param, h) + MieAbsorption(param, h); // absorption
opticalDepth += extinction * ds;

float3 t1 = TransmittanceToTopOfAtmosphere(param, p, lightDir, _transmittanceLut, _sampler);
float3 s = Scattering(param, p, lightDir, viewDir);
float3 t2 = exp(-opticalDepth);

// 单次散射
float3 inScattering = t1 * s * t2 * ds * sunLuminance;
color += inScattering;

// 多重散射
float3 multiScattering = GetMultiScattering(param, p, lightDir, _multiScatteringLut, _sampler);
color += multiScattering * t2 * ds * sunLuminance;

p += viewDir * ds;
}

return color;
}


//Single + Multi Scattering
[numthreads(8, 8, 1)]
void CSSkyViewLUT (uint3 id : SV_DispatchThreadID)
{
// 检查线程是否在纹理范围内
if (id.x >= _Width || id.y >= _Height)
return;

// 计算 UV 坐标 [0, 1]
// 在像素中心采样
float2 uv = (float2(id.xy) + 0.5) / float2(_Width, _Height);
AtmosphereParameter param = GetAtmosphereParameter();
// 计算视线方向
float3 viewDir = UVToViewDir(uv);
float3 lightDir = _MainLightDirection;

float h = _WorldSpaceCameraPos.y - param.SeaLevel + param.PlanetRadius;
float3 eyePos = float3(0, h, 0);

float3 skyColor = CaculateSkyView(
param, eyePos, viewDir, lightDir, -1.0f,
TransmittanceLUT_ReadOnly, MultiScatteringLUT_ReadOnly, sampler_LinearClamp
);
SkyViewLUT[id.xy] = float4(skyColor, 1.0);
}

  1. Aerial Perspective LUT

除了背景的大气,相机和远处物体之间的空气透射也是画面的重要组成部分。

具体的算法思路如下:

  1. 分割Camera Frustum(和Cluster Rendering中的分割一样),计算每个格子到Camera方向的In-ScatteringTransmittance,保存在Volume Texture中;
  2. 在Volume Texture的z轴方向上根据Transmittance累加In-Scattering,使得每一个单元格保存的是该单元格到Camera的Luminance;
  3. 在Opaque渲染之后,做一次Post Processing,采样上述的Volume Texture,对场景中的物体添加Aerial Perspective;
  4. Transparent物体渲染时在VS中采样Volume Texture添加Aerial Perspective。

AerialPerspectiveLUT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Aerial Perspective LUT
[numthreads(8, 8, 1)]
void CSAerialPerspectiveLUT (uint3 id : SV_DispatchThreadID)
{
// 检查线程是否在纹理范围内
if (id.x >= _Width || id.y >= _Height)
return;

// 计算 UV 坐标 [0, 1]
// 在像素中心采样
float3 uv = float3((float2(id.xy) + 0.5) / float2(_Width, _Height),0.0);
AtmosphereParameter param = GetAtmosphereParameter();

float4 color = float4(0, 0, 0, 1);

uv.x *= _AerialPerspectiveVoxelSize.x * _AerialPerspectiveVoxelSize.z; // X * Z
uv.z = int(uv.x / _AerialPerspectiveVoxelSize.z) / _AerialPerspectiveVoxelSize.x;
uv.x = fmod(uv.x, _AerialPerspectiveVoxelSize.z) / _AerialPerspectiveVoxelSize.x;
uv.xyz += 0.5 / _AerialPerspectiveVoxelSize.xyz;

float aspect = _ScreenParams.x / _ScreenParams.y;
float3 viewDir = - normalize(mul(unity_CameraToWorld, float4(
(uv.x * 2.0 - 1.0) * 1.0,
(uv.y * 2.0 - 1.0) / aspect,
1.0, 0.0
)).xyz);

float3 lightDir = _MainLightDirection;

float h = _WorldSpaceCameraPos.y - param.SeaLevel + param.PlanetRadius;
float3 eyePos = float3(0, h, 0);

float maxDis = uv.z * _AerialPerspectiveDistance;

// inScattering
color.rgb = CaculateSkyView(
param, eyePos, viewDir, lightDir, maxDis,
TransmittanceLUT_ReadOnly, MultiScatteringLUT_ReadOnly, sampler_LinearClamp
);

// transmittance
float3 voxelPos = eyePos + viewDir * maxDis;
float3 t1 = TransmittanceToTopOfAtmosphere(param, eyePos, viewDir, TransmittanceLUT_ReadOnly, sampler_LinearClamp);
float3 t2 = TransmittanceToTopOfAtmosphere(param, voxelPos, viewDir, TransmittanceLUT_ReadOnly, sampler_LinearClamp);
float3 t = t1 / t2;
color.a = dot(t, float3(1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0));

AerialPerspectiveLUT[id.xy] = color;

}

四、渲染实现与性能优化策略

我们在 URP 管线中通过 ScriptableRendererFeature (Atmosphere.cs) 进行了无缝集成,将复杂的体积积分运算解耦到 Compute Shader (Atmosphere.compute) 中,最终通过四个低分辨率的 LUT (Look-Up Table) 实现了高性能的全天候天空与空气透视渲染。这套系统为后续整合体积云、体积光等提供了极其统一的物理光照基础。在工程实践中,通过 LutDebugView 提供了直观的运行时调试 Debug 功能,允许我们实时观察四张查找表的生成状态。

渲染调度

分帧更新 (Time-Slicing/Amortization)

Atmosphere.cs中,我们错开了不同 LUT 的更新频率。例如,透射率和多重散射由于变化极其缓慢,设定为每 16 帧更新一次;空气透视 LUT 每 6 帧更新;Sky View LUT 每 4 帧更新;而极其耗时的环境光 (GI) 则每 120 帧刷新。这在保证视觉连贯性的同时,大幅压榨了 GPU 算力。

RenderFeature 调度实现如下

Atmosphere.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

namespace Core.Rendering.RenderFeature
{
public class Atmosphere : ScriptableRendererFeature
{
[System.Serializable]
public class Settings
{
[Header("General Settings")] public string m_profilerTag = "Atmosphere";
public RenderPassEvent lutGenerationEvent = RenderPassEvent.BeforeRenderingShadows;

public ComputeShader computeShader;
public AtmosphereSettings atmosphereSettings;

[Space(10)] public bool generateTransAndMultiScatterOnce = false;

[Header("Aerial Perspective Effect Settings")] [Tooltip("启用Aerial Perspective后处理效果")]
public bool enableAerialPerspectiveEffect = true;

public Material aerialPerspectiveMaterial;

[Tooltip("Aerial Perspective效果在渲染管线中的位置")]
public RenderPassEvent aerialPerspectiveEvent = RenderPassEvent.BeforeRenderingPostProcessing;

[Header("LUT Dimensions")] [HideInInspector]
public Vector2Int transmittanceLUTSize = new Vector2Int(256, 64);

[HideInInspector] public Vector2Int multiScatteringLUTSize = new Vector2Int(32, 32);
[HideInInspector] public Vector2Int skyViewLUTSize = new Vector2Int(256, 128);
[HideInInspector] public Vector2Int aerialPerspectiveLUTSize = new Vector2Int(1024, 32);

}

// LUT生成Pass
class AtmosphereLUTGenerationPass : ScriptableRenderPass
{
private Settings m_Settings;

// RTHandles for LUTs
private RTHandle m_TransmittanceLUT;
private RTHandle m_MultiScatteringLUT;
private RTHandle m_SkyViewLUT;
private RTHandle m_AerialPerspectiveLUT;

// Compute Shader Kernels
private int m_KernelTransmittance;
private int m_KernelMultiScattering;
private int m_KernelSkyView;
private int m_KernelAerialPerspective;

// Shader Property IDs
private static readonly int TransmittanceLUTID = Shader.PropertyToID("TransmittanceLUT");
private static readonly int MultiScatteringLUTID = Shader.PropertyToID("MultiScatteringLUT");
private static readonly int TransmittanceLUT_ReadOnlyID = Shader.PropertyToID("TransmittanceLUT_ReadOnly");
private static readonly int MultiScatteringLUT_ReadOnlyID = Shader.PropertyToID("MultiScatteringLUT_ReadOnly");
private static readonly int SkyViewLUTID = Shader.PropertyToID("SkyViewLUT");
private static readonly int AerialPerspectiveLUTID = Shader.PropertyToID("AerialPerspectiveLUT");

private static readonly int TransmittanceLutGlobalID = Shader.PropertyToID("_transmittanceLut");
private static readonly int MultiScatteringLutGlobalID = Shader.PropertyToID("_multiScatteringLut");
private static readonly int SkyViewLutGlobalID = Shader.PropertyToID("_skyViewLut");
private static readonly int AerialPerspectiveLutGlobalID = Shader.PropertyToID("_aerialPerspectiveLut");

private static readonly int inSkyViewLutID =Shader.PropertyToID("inSkyViewLut");
private static readonly int inSkyIrradianceID =Shader.PropertyToID("inSkyIrradiance");
private static readonly int inTransmittanceLutID =Shader.PropertyToID("inTransmittanceLut");
private static readonly int inFroxelScatterID =Shader.PropertyToID("inFroxelScatter");

private static readonly int WidthID = Shader.PropertyToID("_Width");
private static readonly int HeightID = Shader.PropertyToID("_Height");

// Atmosphere Parameters
private static readonly int SeaLevelID = Shader.PropertyToID("_SeaLevel");
private static readonly int PlanetRadiusID = Shader.PropertyToID("_PlanetRadius");
private static readonly int AtmosphereHeightID = Shader.PropertyToID("_AtmosphereHeight");
private static readonly int SunLightIntensityID = Shader.PropertyToID("_SunLightIntensity");
private static readonly int SunLightColorID = Shader.PropertyToID("_SunLightColor");
private static readonly int SunDiskAngleID = Shader.PropertyToID("_SunDiskAngle");
private static readonly int RayleighScaleID = Shader.PropertyToID("_RayleighScatteringScale");
private static readonly int RayleighScaleHeightID = Shader.PropertyToID("_RayleighScatteringScalarHeight");
private static readonly int MieScaleID = Shader.PropertyToID("_MieScatteringScale");
private static readonly int MieAnisotropyID = Shader.PropertyToID("_MieAnisotropy");
private static readonly int MieScaleHeightID = Shader.PropertyToID("_MieScatteringScalarHeight");
private static readonly int OzoneAbsorptionScaleID = Shader.PropertyToID("_OzoneAbsorptionScale");
private static readonly int OzoneCenterHeightID = Shader.PropertyToID("_OzoneLevelCenterHeight");
private static readonly int OzoneWidthID = Shader.PropertyToID("_OzoneLevelWidth");
private static readonly int AerialPerspectiveDistanceID = Shader.PropertyToID("_AerialPerspectiveDistance");
private static readonly int AerialPerspectiveVoxelSizeID = Shader.PropertyToID("_AerialPerspectiveVoxelSize");
private static readonly int MiePhaseParamsID = Shader.PropertyToID("_MiePhaseParams");

private static readonly int WorldSpaceCameraPosID = Shader.PropertyToID("_WorldSpaceCameraPos");
private static readonly int ScreenParamsID = Shader.PropertyToID("_ScreenParams");
private static readonly int CameraToWorldID = Shader.PropertyToID("unity_CameraToWorld");
private static readonly int MainLightDirectionID = Shader.PropertyToID("_MainLightDirection");

private bool m_IsInitialized = false;
private int m_FrameCount = 0;

public AtmosphereLUTGenerationPass(Settings settings)
{
m_Settings = settings;
}

private void InitializeLUTs()
{
if (m_Settings.computeShader == null)
{
Debug.LogError("AtmosphereRender: Compute Shader is not assigned!");
return;
}

// 检查RTHandles是否需要重新分配(可能在视图切换时失效)
bool needsReinitialization = !m_IsInitialized;

if (m_IsInitialized)
{
// 验证现有RTHandles是否仍然有效
if (m_TransmittanceLUT == null || m_MultiScatteringLUT == null ||
m_SkyViewLUT == null || m_AerialPerspectiveLUT == null)
{
needsReinitialization = true;
m_IsInitialized = false;
}
}

if (!needsReinitialization)
return;

// Find kernels
m_KernelTransmittance = m_Settings.computeShader.FindKernel("CSTransmittanceLUT");
m_KernelMultiScattering = m_Settings.computeShader.FindKernel("CSMultiScatteringLUT");
m_KernelSkyView = m_Settings.computeShader.FindKernel("CSSkyViewLUT");
m_KernelAerialPerspective = m_Settings.computeShader.FindKernel("CSAerialPerspectiveLUT");

// 创建/分配 LUT 渲染目标,使用 Settings 中的尺寸
var skySize = m_Settings.skyViewLUTSize;
RenderTextureDescriptor skyViewDesc = new RenderTextureDescriptor(skySize.x, skySize.y, RenderTextureFormat.ARGBFloat, 0)
{
useMipMap = false,
autoGenerateMips = false,
depthBufferBits = 0,
enableRandomWrite = true
};

var transSize = m_Settings.transmittanceLUTSize;
RenderTextureDescriptor transmittanceDesc = new RenderTextureDescriptor(transSize.x, transSize.y, RenderTextureFormat.ARGBFloat, 0)
{
useMipMap = false,
autoGenerateMips = false,
depthBufferBits = 0,
enableRandomWrite = true
};

var multiSize = m_Settings.multiScatteringLUTSize;
RenderTextureDescriptor multiScatteringDesc = new RenderTextureDescriptor(multiSize.x, multiSize.y, RenderTextureFormat.ARGBFloat, 0)
{
useMipMap = false,
autoGenerateMips = false,
depthBufferBits = 0,
enableRandomWrite = true
};

var aerialSize = m_Settings.aerialPerspectiveLUTSize;
RenderTextureDescriptor aerialPerspectiveDesc = new RenderTextureDescriptor(aerialSize.x, aerialSize.y, RenderTextureFormat.ARGBFloat, 0)
{
useMipMap = false,
autoGenerateMips = false,
depthBufferBits = 0,
enableRandomWrite = true
};

RenderingUtils.ReAllocateIfNeeded(ref m_SkyViewLUT, skyViewDesc, FilterMode.Bilinear, TextureWrapMode.Clamp, name: "_SkyViewLut");
RenderingUtils.ReAllocateIfNeeded(ref m_TransmittanceLUT, transmittanceDesc, FilterMode.Bilinear, TextureWrapMode.Clamp,
name: "_TransmittanceLut");
RenderingUtils.ReAllocateIfNeeded(ref m_MultiScatteringLUT, multiScatteringDesc, FilterMode.Bilinear, TextureWrapMode.Clamp,
name: "_MultiScatteringLut");
RenderingUtils.ReAllocateIfNeeded(ref m_AerialPerspectiveLUT, aerialPerspectiveDesc, FilterMode.Bilinear, TextureWrapMode.Clamp,
name: "_AerialPerspectiveLut");

m_IsInitialized = true;
m_FrameCount = 0; // 重置帧计数,强制重新生成所有LUTs
}

private void SetComputeShaderParameters(CommandBuffer cmd, ref RenderingData renderingData)
{
var cs = m_Settings.computeShader;
var param = m_Settings.atmosphereSettings;

if (cs == null || param == null)
return;

cmd.SetComputeFloatParam(cs, SeaLevelID, param.SeaLevel);
cmd.SetComputeFloatParam(cs, PlanetRadiusID, param.PlanetRadius);
cmd.SetComputeFloatParam(cs, AtmosphereHeightID, param.AtmosphereHeight);
cmd.SetComputeFloatParam(cs, SunLightIntensityID, param.SunLightIntensity);
cmd.SetComputeVectorParam(cs, SunLightColorID, param.SunLightColor);
cmd.SetComputeFloatParam(cs, SunDiskAngleID, param.SunDiskAngle);
cmd.SetComputeFloatParam(cs, RayleighScaleID, param.RayleighScatteringScale);
cmd.SetComputeFloatParam(cs, RayleighScaleHeightID, param.RayleighScatteringScalarHeight);
cmd.SetComputeFloatParam(cs, MieScaleID, param.MieScatteringScale);
cmd.SetComputeFloatParam(cs, MieAnisotropyID, param.MieAnisotropy);
cmd.SetComputeFloatParam(cs, MieScaleHeightID, param.MieScatteringScalarHeight);
cmd.SetComputeFloatParam(cs, OzoneAbsorptionScaleID, param.OzoneAbsorptionScale);
cmd.SetComputeFloatParam(cs, OzoneCenterHeightID, param.OzoneLevelCenterHeight);
cmd.SetComputeFloatParam(cs, OzoneWidthID, param.OzoneLevelWidth);
cmd.SetComputeFloatParam(cs, AerialPerspectiveDistanceID, param.AerialPerspectiveDistance);
cmd.SetComputeVectorParam(cs, AerialPerspectiveVoxelSizeID, param.AerialPerspectiveVoxelSize);
cmd.SetComputeVectorParam(cs, MiePhaseParamsID, param.MiePhaseParams);

// --- 新增:传递相机和光照数据 ---
Camera camera = renderingData.cameraData.camera;

// 传递相机位置
cmd.SetComputeVectorParam(cs, WorldSpaceCameraPosID, camera.transform.position);

// 传递屏幕参数: x = width, y = height, z = 1 + 1/width, w = 1 + 1/height
cmd.SetComputeVectorParam(cs, ScreenParamsID, new Vector4(camera.pixelWidth, camera.pixelHeight, 1.0f + 1.0f / camera.pixelWidth, 1.0f + 1.0f / camera.pixelHeight));

// 传递相机矩阵
cmd.SetComputeMatrixParam(cs, CameraToWorldID, camera.cameraToWorldMatrix);

// 传递主光源方向 (URP)
Vector3 mainLightDir = Vector3.down; // 默认值
if (renderingData.lightData.mainLightIndex != -1)
{
VisibleLight mainLight = renderingData.lightData.visibleLights[renderingData.lightData.mainLightIndex];
// URP 光照方向是从物体指向光源,通过矩阵的第2列取反获取前向向量
mainLightDir = -mainLight.localToWorldMatrix.GetColumn(2);
}
cmd.SetComputeVectorParam(cs, MainLightDirectionID, mainLightDir);
// --------------------------------

Shader.SetGlobalFloat(AerialPerspectiveDistanceID,param.AerialPerspectiveDistance);
Shader.SetGlobalVector(AerialPerspectiveVoxelSizeID,param.AerialPerspectiveVoxelSize);
}

private void DispatchComputeShader(CommandBuffer cmd, int kernel, RTHandle target)
{
var cs = m_Settings.computeShader;

int width = target.rt.width;
int height = target.rt.height;

cmd.SetComputeIntParam(cs, WidthID, width);
cmd.SetComputeIntParam(cs, HeightID, height);

int threadGroupsX = Mathf.CeilToInt(width / 8.0f);
int threadGroupsY = Mathf.CeilToInt(height / 8.0f);

cmd.DispatchCompute(cs, kernel, threadGroupsX, threadGroupsY, 1);
}

private void GenerateTransmittanceAndMultiScatteringLUT(CommandBuffer cmd, ref RenderingData renderingData)
{
var cs = m_Settings.computeShader;

SetComputeShaderParameters(cmd,ref renderingData);

// Generate Transmittance LUT
cmd.SetComputeTextureParam(cs, m_KernelTransmittance, TransmittanceLUTID, m_TransmittanceLUT);
DispatchComputeShader(cmd, m_KernelTransmittance, m_TransmittanceLUT);

// Generate MultiScattering LUT
cmd.SetComputeTextureParam(cs, m_KernelMultiScattering, TransmittanceLUT_ReadOnlyID, m_TransmittanceLUT);
cmd.SetComputeTextureParam(cs, m_KernelMultiScattering, MultiScatteringLUTID, m_MultiScatteringLUT);
DispatchComputeShader(cmd, m_KernelMultiScattering, m_MultiScatteringLUT);
}

private void GenerateSkyViewLUT(CommandBuffer cmd, ref RenderingData renderingData)
{
var cs = m_Settings.computeShader;

SetComputeShaderParameters(cmd,ref renderingData);

cmd.SetComputeTextureParam(cs, m_KernelSkyView, TransmittanceLUT_ReadOnlyID, m_TransmittanceLUT);
cmd.SetComputeTextureParam(cs, m_KernelSkyView, MultiScatteringLUT_ReadOnlyID, m_MultiScatteringLUT);
cmd.SetComputeTextureParam(cs, m_KernelSkyView, SkyViewLUTID, m_SkyViewLUT);
DispatchComputeShader(cmd, m_KernelSkyView, m_SkyViewLUT);
}

private void GenerateAerialPerspectiveLUT(CommandBuffer cmd,ref RenderingData renderingData)
{
var cs = m_Settings.computeShader;

SetComputeShaderParameters(cmd,ref renderingData);

cmd.SetComputeTextureParam(cs, m_KernelAerialPerspective, TransmittanceLUT_ReadOnlyID, m_TransmittanceLUT);
cmd.SetComputeTextureParam(cs, m_KernelAerialPerspective, MultiScatteringLUT_ReadOnlyID, m_MultiScatteringLUT);
cmd.SetComputeTextureParam(cs, m_KernelAerialPerspective, AerialPerspectiveLUTID, m_AerialPerspectiveLUT);
DispatchComputeShader(cmd, m_KernelAerialPerspective, m_AerialPerspectiveLUT);
}

private void GenerateAllLUTs(CommandBuffer cmd, ref RenderingData renderingData)
{
GenerateTransmittanceAndMultiScatteringLUT(cmd,ref renderingData);
GenerateSkyViewLUT(cmd,ref renderingData);
GenerateAerialPerspectiveLUT(cmd,ref renderingData);
}

public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
InitializeLUTs();

// 如果是Game视图相机,且之前的帧是Scene视图,重置帧计数以强制重新生成LUTs
if (!renderingData.cameraData.isSceneViewCamera)
{
// 检查RTHandle是否仍然有效,如果无效则重置
if (m_IsInitialized && (m_AerialPerspectiveLUT == null || !m_AerialPerspectiveLUT.rt.IsCreated()))
{
m_FrameCount = 0;
}
}
}

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
// 跳过Scene视图的渲染
if (renderingData.cameraData.isSceneViewCamera)
return;

if (!m_IsInitialized)
return;

CommandBuffer cmd = CommandBufferPool.Get();
using (new ProfilingScope(cmd, new ProfilingSampler(m_Settings.m_profilerTag)))
{
cmd.BeginSample(m_Settings.m_profilerTag);
// Initial generation or full refresh
// 当m_FrameCount为0时,说明是第一次渲染或刚从Scene视图切换回来
if (m_FrameCount == 0)
{
GenerateAllLUTs(cmd,ref renderingData);
}

// Auto-refresh logic based on frame count
int frame = Time.frameCount;

if (frame % 120 == 0)
{
// Update Global Illumination every 120 frames
DynamicGI.UpdateEnvironment();
m_FrameCount = 1; // Reset frame count after GI update;
Debug.Log("Global Illumination Updated");
}
else if (frame % 16 == 0 && !m_Settings.generateTransAndMultiScatterOnce)
{
// Update Transmittance & MultiScattering every 16 frames
GenerateTransmittanceAndMultiScatteringLUT(cmd,ref renderingData);
Debug.Log("Transmittance & MultiScattering LUTs Updated");
}
else if (frame % 6 == 0)
{
// Update Aerial Perspective every 6 frames
GenerateAerialPerspectiveLUT(cmd,ref renderingData);
Debug.Log("Aerial Perspective LUT Updated");
}
else if (frame % 4 == 0)
{
// Update Sky View every 4 frames
GenerateSkyViewLUT(cmd,ref renderingData);
Debug.Log("Sky View LUT Updated ");
}

// Set global textures
cmd.SetGlobalTexture(TransmittanceLutGlobalID, m_TransmittanceLUT);
cmd.SetGlobalTexture(MultiScatteringLutGlobalID, m_MultiScatteringLUT);
cmd.SetGlobalTexture(SkyViewLutGlobalID, m_SkyViewLUT);
cmd.SetGlobalTexture(AerialPerspectiveLutGlobalID, m_AerialPerspectiveLUT);

cmd.SetGlobalTexture(inSkyViewLutID,m_SkyViewLUT);
cmd.SetGlobalTexture(inSkyIrradianceID,m_MultiScatteringLUT);
cmd.SetGlobalTexture(inTransmittanceLutID,m_TransmittanceLUT);
cmd.SetGlobalTexture(inFroxelScatterID,m_AerialPerspectiveLUT);

cmd.SetGlobalFloat(SunDiskAngleID, m_Settings.atmosphereSettings.SunDiskAngle);
cmd.SetGlobalFloat(SunLightIntensityID, m_Settings.atmosphereSettings.SunLightIntensity);
cmd.SetGlobalColor(SunLightColorID, m_Settings.atmosphereSettings.SunLightColor);
cmd.EndSample(m_Settings.m_profilerTag);
}

context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);

m_FrameCount++;
}

public override void OnCameraCleanup(CommandBuffer cmd)
{
}

public void Dispose()
{
m_TransmittanceLUT?.Release();
m_MultiScatteringLUT?.Release();
m_SkyViewLUT?.Release();
m_AerialPerspectiveLUT?.Release();

m_IsInitialized = false;
}
}

// Aerial Perspective应用Pass
class AerialPerspectivePass : ScriptableRenderPass
{
private Material m_AerialPerspectiveMaterial;
private RTHandle m_TempColorTarget;
private RTHandle m_CameraColorTarget;
private string m_ProfilerTag;

public AerialPerspectivePass(Material material, string profilerTag)
{
m_AerialPerspectiveMaterial = material;
m_ProfilerTag = profilerTag;
}

public void Setup(RTHandle cameraColorTarget)
{
m_CameraColorTarget = cameraColorTarget;
}

public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
RenderTextureDescriptor descriptor = renderingData.cameraData.cameraTargetDescriptor;
descriptor.depthBufferBits = 0;

RenderingUtils.ReAllocateIfNeeded(ref m_TempColorTarget, descriptor, FilterMode.Bilinear,
TextureWrapMode.Clamp, name: "_TempAerialPerspective");
}

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (m_AerialPerspectiveMaterial == null)
{
Debug.LogError("Aerial Perspective Material is null");
return;
}

// 确保RTHandle已正确初始化
if (m_CameraColorTarget == null || m_TempColorTarget == null)
{
return;
}

CommandBuffer cmd = CommandBufferPool.Get();
using (new ProfilingScope(cmd, new ProfilingSampler(m_ProfilerTag)))
{
// 使用Blitter进行正确的RTHandle Blit操作
Blitter.BlitCameraTexture(cmd, m_CameraColorTarget, m_TempColorTarget, m_AerialPerspectiveMaterial, 0);
Blitter.BlitCameraTexture(cmd, m_TempColorTarget, m_CameraColorTarget);
}

context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}

public override void OnCameraCleanup(CommandBuffer cmd)
{
// 重置相机颜色目标引用,避免在视图切换时保留过期引用
m_CameraColorTarget = null;
}

public void Dispose()
{
m_TempColorTarget?.Release();
}
}

public Settings settings = new Settings();
private AtmosphereLUTGenerationPass m_LUTGenerationPass;
private AerialPerspectivePass m_AerialPerspectivePass;

public override void Create()
{
// 创建LUT生成Pass
m_LUTGenerationPass = new AtmosphereLUTGenerationPass(settings);
m_LUTGenerationPass.renderPassEvent = settings.lutGenerationEvent;

// 创建Aerial Perspective应用Pass(如果启用)
if (settings.enableAerialPerspectiveEffect && settings.aerialPerspectiveMaterial != null)
{
m_AerialPerspectivePass = new AerialPerspectivePass(
settings.aerialPerspectiveMaterial,
"Aerial Perspective Effect"
);
m_AerialPerspectivePass.renderPassEvent = settings.aerialPerspectiveEvent;
}
}

public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
{
// 设置Aerial Perspective应用Pass的渲染目标
if (settings.enableAerialPerspectiveEffect && m_AerialPerspectivePass != null)
{
if (renderer != null && renderer.cameraColorTargetHandle != null)
{
m_AerialPerspectivePass.Setup(renderer.cameraColorTargetHandle);
}
}
}

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
// 添加LUT生成Pass
if (settings.computeShader != null && settings.atmosphereSettings != null)
{
if (!renderingData.cameraData.isSceneViewCamera)
{
renderer.EnqueuePass(m_LUTGenerationPass);
}
}
else
{
Debug.LogWarning("AtmosphereRender: Missing compute shader or atmosphere settings!");
}

// 添加Aerial Perspective应用Pass(如果启用)
if (settings.enableAerialPerspectiveEffect)
{
if (settings.aerialPerspectiveMaterial == null)
{
Debug.LogWarning("AtmosphereRender: Aerial Perspective is enabled but material is missing!");
}
else if (m_AerialPerspectivePass != null)
{
renderer.EnqueuePass(m_AerialPerspectivePass);
}
}
}

protected override void Dispose(bool disposing)
{
m_LUTGenerationPass?.Dispose();
m_AerialPerspectivePass?.Dispose();
}
}
}

天空盒渲染

Skybox.shader 并不直接进行积分,而是通过 ViewDirToUV 采样预生成的 Sky View LUT。

星空与月亮

在天空颜色基础上,根据太阳位置(SunDirToDayTime)混合星空贴图,并利用相位计算渲染月亮圆盘。

  • 需要关注的是采样月亮圆盘 在整个 SkyBox 中的贴图拉伸情况。
CSkybox.shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
Shader "Zerls/Atmosphere/Skybox"
{
Properties
{
_SourceHdrTexture ("Source HDR Texture", 2D) = "white" {}

[Header(Star Field)]
[Toggle(STARFIELD_ON)] _EnableStarfield ("Enable Starfield", Float) = 1
_StarfieldTex ("Starfield Cubemap", Cube) = "black" {}
_StarfieldIntensity ("Starfield Intensity", Float) = 1.0
_StarfieldRotationY ("Starfield Rotation Y", Range(0, 360)) = 0
_StarfieldRotationZ ("Starfield Rotation Z", Range(0, 360)) = 0
_StarTwinkleSpeed ("Star Twinkle Speed", Float) = 0.5
_StarTwinkleAmount ("Star Twinkle Amount", Range(0, 1)) = 0.1

[Header(Moon)]
_MoonTex ("Moon Texture", 2D) = "black" {}
_MoonSize ("Moon Size", Range(0.5, 100)) = 2.0
_MoonIntensity ("Moon Intensity", Float) = 1.0
[HDR]_MoonColor ("Moon Color", Color) = (1, 1, 1, 1)
// [Toggle] _UseMoonDirection ("Use Moon Light Direction", Float) = 0
_MoonDirectionOffset ("Manual Moon Direction Offset", Vector) = (0.0, 0.0, 0.0, 0)

}
SubShader
{
Tags
{
"RenderType"="Background" "Queue"="Background" "RenderPipeline" = "UniversalPipeline" "PreviewType"="Skybox"
}
Cull Off ZWrite Off ZTest LEqual

Pass
{
Name "Skybox"

HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 4.5

#pragma shader_feature_local_fragment _ STARFIELD_ON

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Helper.hlsl"
#include "Scattering.hlsl"
#include "Atmosphere.hlsl"

CBUFFER_START(PerMaterial)
float _StarfieldIntensity;
float _StarfieldRotationY;
float _StarfieldRotationZ;
float _StarTwinkleSpeed;
float _StarTwinkleAmount;


float _MoonSize;
float _MoonIntensity;
float4 _MoonColor;
// float _UseMoonDirection;
float3 _MoonDirectionOffset;
CBUFFER_END

TEXTURECUBE(_StarfieldTex);
SAMPLER(sampler_StarfieldTex);
TEXTURE2D(_MoonTex);
SAMPLER(sampler_MoonTex);

struct Attributes
{
float4 positionOS : POSITION;
};

struct Varyings
{
float4 positionCS : SV_POSITION;
float3 positionWS : TEXCOORD0;
};

Varyings vert(Attributes input)
{
Varyings output;
VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
output.positionCS = vertexInput.positionCS;
output.positionWS = vertexInput.positionWS;
return output;
}

TEXTURE2D(_skyViewLut);
// SAMPLER(sampler_LinearClamp);
TEXTURE2D(_transmittanceLut);
TEXTURE2D(_SourceHdrTexture);

float3 GetSunDisk(in AtmosphereParameter param, float3 eyePos, float3 viewDir, float3 lightDir, float transmittance)
{
// 计算入射光照
float cosine_theta = dot(viewDir, -lightDir);
float theta = acos(cosine_theta) * (180.0 / PI);
float3 sunLuminance = param.SunLightColor * param.SunLightIntensity;

// 计算衰减
sunLuminance *= transmittance;

if (theta < param.SunDiskAngle) return sunLuminance;
return float3(0, 0, 0);
}


// 渲染月亮
float3 GetMoonDisk
( AtmosphereParameter param,
float3 eyePos,
float3 viewDir,
float3 moonDir,
float transmittance)
{
moonDir = normalize(moonDir + _MoonDirectionOffset);
// 计算视线与月亮方向的夹角
float cosAngle = dot(viewDir, moonDir);
float angle = acos(saturate(cosAngle));

// 月亮角度大小(弧度)
float moonAngularSize = (_MoonSize * 0.5) * (PI / 180.0);

if (angle > moonAngularSize)
return float3(0, 0, 0);

// 计算UV坐标
float2 moonUV = float2(0.5, 0.5);
if (angle < moonAngularSize)
{
// 创建月亮的局部坐标系
float3 right = normalize(cross(moonDir, float3(0, 1, 0)));
if (length(right) < 0.001)
right = normalize(cross(moonDir, float3(0, 0, 1)));
float3 up = normalize(cross(right, moonDir));

// 投影到月亮平面
float3 localDir = viewDir - moonDir * cosAngle;
float x = - dot(localDir, right);
float y = dot(localDir, up);

// 转换为UV
moonUV = float2(x, y) *rcp (moonAngularSize * 2.0) + 0.5;
}

// 采样月亮纹理
float4 moonSample = SAMPLE_TEXTURE2D(_MoonTex, sampler_MoonTex, moonUV);


// 月亮颜色
float3 moonColor = moonSample.rgb * _MoonColor.rgb * _MoonIntensity * transmittance;

// 使用alpha进行边缘混合
float edgeFade = 1.0 - smoothstep(moonAngularSize * 0.9, moonAngularSize, angle);
moonColor *= moonSample.a * edgeFade;

return moonColor;
}


// 旋转向量(用于星空旋转)
float3 RotateAroundY(float3 vec, float angle)
{
float rad = radians(angle);
float cosA = cos(rad);
float sinA = sin(rad);

float3x3 rotMatrix = float3x3(
cosA, 0, sinA,
0, 1, 0,
-sinA, 0, cosA
);

return mul(rotMatrix, vec);
}
float3 RotateAroundX(float3 vec, float angle)
{
float rad = radians(angle);
float cosA = cos(rad);
float sinA = sin(rad);

float3x3 rotMatrix = float3x3(
1, 0, 0,
0, cosA, -sinA,
0, sinA, cosA
);

return mul(rotMatrix, vec);
}
float3 RotateAroundZ(float3 vec, float angle)
{
float rad = radians(angle);
float cosA = cos(rad);
float sinA = sin(rad);

float3x3 rotMatrix = float3x3(
cosA, -sinA, 0,
sinA, cosA, 0,
0, 0, 1
);

return mul(rotMatrix, vec);
}

// sunDir.y = 1 → 太阳在头顶(正午)
// sunDir.y = 0 → 地平线(晨昏)、
// sunDir.y < 0 → 太阳在地下(夜晚)

// 昼夜:太阳在地下最强,头顶最弱
float NightFactor(float3 sunDir)
{
// sunDir.y: [-1, 1]
// 映射成:正午0 → 晨昏0.5 → 午夜1
float h = saturate(-sunDir.y);
// 加个 2 次方让夜晚过渡更柔
return h * h;
}

float SunDirToDayTime(float3 sunDir)
{
float sunY = sunDir.y;
// sunY ∈ [-1,1]
// angle: 0(正午) → π(午夜)
float angle = acos(saturate(sunY));

// 映射到 [0,1]
float t = angle / PI; // 0=正午, 1=午夜


t =frac(t);

return t;
}

// 根据昼夜时间获取星空强度
//TODO: 与当前摄像机高度也有关系,需要处理
float GetStarIntensity(float dayTime )
{

// ±6 小时窗口
const float halfWindow = 6.0 / 12.0;

float d = abs(dayTime ); // 正午 = 0.0
float noonFade = saturate(d / halfWindow);

return noonFade;
}


float3 GetStarfieldColor(in AtmosphereParameter param, float3 viewDir)
{
float3 starColor =float3(0.0,0.0,0.0);
// 旋转星空
float3 rotatedViewDir =RotateAroundY(viewDir, _StarfieldRotationY);

rotatedViewDir =RotateAroundZ(rotatedViewDir, _StarfieldRotationZ);
// 采样星空贴图
starColor = SAMPLE_TEXTURECUBE(_StarfieldTex, sampler_LinearClamp, rotatedViewDir).rgb;

// 添加闪烁效果
float twinkle = (sin(_Time.y * _StarTwinkleSpeed + dot(rotatedViewDir, float3(12.9898, 78.233, 45.164)) * 43758.5453) + 1.0) * 0.5;
twinkle = lerp(1.0 - _StarTwinkleAmount, 1.0 + _StarTwinkleAmount, twinkle);
starColor *= twinkle;

return starColor * _StarfieldIntensity;
}

float4 frag(Varyings input) : SV_Target
{
AtmosphereParameter param = GetAtmosphereParameter();

float4 color = float4(0, 0, 0, 1);
float3 viewDir = normalize(input.positionWS);

Light mainLight = GetMainLight();
float3 lightDir = mainLight.direction;


float h = max(1.0, _WorldSpaceCameraPos.y - param.SeaLevel) + param.PlanetRadius;
float3 eyePos = float3(0, h, 0);

float dayTime = SunDirToDayTime(lightDir);

// 判断光线是否被星球阻挡 disToPlanet >= 0 表示被星球阻挡
float disToPlanet = RayIntersectSphere(float3(0, 0, 0), param.PlanetRadius, eyePos, viewDir);
if (disToPlanet < 0)
{
// 计算大气透射率(如果在大气内)
float3 transmittance = float3(1, 1, 1);
float distToAtmosphere = RayIntersectSphere(float3(0, 0, 0), param.PlanetRadius + param.AtmosphereHeight, eyePos, viewDir);
if (distToAtmosphere >= 0)
{
transmittance = TransmittanceToTopOfAtmosphere(param, eyePos, viewDir, _transmittanceLut, sampler_LinearClamp);
}

color.rgb += GetMoonDisk(param, eyePos, viewDir, -lightDir, transmittance);
color.rgb += GetSunDisk(param, eyePos, viewDir, -lightDir, transmittance);
}

#if STARFIELD_ON
color.rgb += GetStarfieldColor(param, viewDir) *2.0 * GetStarIntensity(dayTime);
#endif
color.rgb += SAMPLE_TEXTURE2D(_skyViewLut, sampler_LinearClamp, ViewDirToUV(viewDir)).rgb;

return color;
}
ENDHLSL
}
}
CustomEditor "Zerls.BasicShaderGUI"
}

空气透射

这里实现了对不透明物体的后处理方式添加Aerial Perspective

  • 半透明物体待实现
Aerial Perspective
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//这里 按照 SizeZ 平铺直接展开为 2D 的纹理采样实现,后续修改为  Compute Shader + Texture3D
float4 frag(Varyings input) : SV_Target
{
float2 uv = input.texcoord;
float3 sceneColor = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, uv).rgb;

// 天空 mask
float sceneRawDepth = SampleSceneDepth(uv);
#if UNITY_REVERSED_Z
if (sceneRawDepth == 0.0f) return float4(sceneColor, 1.0);
#else
if(sceneRawDepth == 1.0f) return float4(sceneColor, 1.0);
#endif

// 世界坐标计算
float3 worldPos = GetFragmentWorldPos(input.texcoord).xyz;
float3 eyePos = _WorldSpaceCameraPos.xyz;
float dis = length(worldPos - eyePos);
float3 viewDir = normalize(worldPos - eyePos);

// 体素 slice 计算
float dis01 = saturate(dis / _AerialPerspectiveDistance);
float dis0Z = dis01 * (_AerialPerspectiveVoxelSize.z - 1); // [0 ~ SizeZ-1]
float slice = floor(dis0Z);
float nextSlice = min(slice + 1, _AerialPerspectiveVoxelSize.z - 1);
float lerpFactor = dis0Z - floor(dis0Z);

uv.x /= _AerialPerspectiveVoxelSize.x;

// 采样 AerialPerspectiveVoxel
float2 uv1 = float2(uv.x + slice / _AerialPerspectiveVoxelSize.z, uv.y);
float2 uv2 = float2(uv.x + nextSlice / _AerialPerspectiveVoxelSize.z, uv.y);

float4 data1 = SAMPLE_TEXTURE2D(_aerialPerspectiveLut, sampler_LinearClamp, uv1);
float4 data2 = SAMPLE_TEXTURE2D(_aerialPerspectiveLut, sampler_LinearClamp, uv2);
float4 data = lerp(data1, data2, lerpFactor);

float3 inScattering = data.xyz;
float transmittance = data.w;

return float4(sceneColor * transmittance + inScattering, 1.0);
}

五、总结与后续迭代优化

总结来说,这套物理理论不仅是对天空色彩的数学还原,更是构建现代化游戏环境的基架。我们将这套庞大的积分运算通过 4张 LUT(Transmittance, Multi-Scattering, Sky View, Aerial Perspective)进行巧妙降维。这使得我们的渲染管线能够在极低的运行时代价下,为我们复杂的游戏场景,提供绝对物理正确的光照与深度遮挡(体积雾)基准。

代码仓库如下:

📦
SkyRendering/AtmosphericScattering
大气渲染

因为时间与精力的限制,后续我们可以考虑在以下几个方向进行深入优化:

  1. RenderGraph 现代化重构: Unity 6 的核心是 RenderGraph。我们目前的 ScriptableRenderPass 仍在使用传统的 RTHandle 手动管理内存。将其重构为 RenderGraph API,利用其自动的资源生命周期管理和 Pass 裁剪,能更好地应对未来复杂的依赖关系(如与体积云、大面积草海的交互)。
  2. 体积阴影 (Volumetric Shadows) 的接入: 目前的系统在计算大气散射时并没有考虑地形或云层的遮挡。我们可以在生成 Sky View LUT 和 Aerial Perspective LUT 的 Raymarching 循环中,加入对 Directional Light Shadow Map(或级联阴影 CSM)的采样,从而实现物理正确的上帝光 (God Rays) 和云隙光 (Crepuscular Rays)。
  3. 与体积云 (Volumetric Clouds) 的深度耦合** [ 已完成 ]:** 空气透视系统应当包裹体积云。在渲染云层时,我们需要使用 Transmittance LUT 来计算阳光穿透大气的衰减,并在云层最终渲染阶段,采样 Aerial Perspective LUT 让云层融入远处的雾气中,形成“环境系统”的完美闭环。