游戏引擎开发实践(大气渲染)

截屏2025-11-07 19.17.27

截屏2025-11-07 18.15.57

其实OpenGL渲染器已经完成的单次散射的大气渲染,但是对原理理解不透彻,这次准备更完善一点,并且实现整套的多重散射大气渲染,参考UE的实现,以及几篇论文

Precomputed Atmospheric Scattering

Eric Bruneton, Fabrice Neyret在2008年发表的论文,在2017年有了一个示例代码

Transmittance 透射率

基本概念:透射是可乘的

  • 对于 三点共线 $p, q, r$(顺序为 $p \to q \to r$)
  • 从 p 到 r 的透射率 = 从 p 到 q 的透射 × 从 q 到 r 的透射
$$ T(p,r) = T(p,q) \cdot T(q,r) $$
  • 这是光线传输的乘法性质,因为大气衰减是指数衰减,连续段可以相乘。

处理大气边界

  • 对于点对 $p, q$
  • 将从 $p$ 出发到 $q$ 的射线延长,找到它 与大气上下边界的最近交点 $i$
  • 透射可以表示为:
$$ T(p,q) = \frac{T(p,i)}{T(q,i)} $$
  • 特殊情况:如果 $[p,q]$ 与地面相交 → 透射为 0(光被阻挡)
  • 直观理解: 你只需要知道从大气内部某点到大气边界的透射,再通过除法就能得到任意点对的透射。

透射率的预计算

img

离地心的距离 r 和天顶角的余弦$\mu = \cos\theta$这两个参数,就可以描述从任意点出发沿着任意方向到大气层的路径上的传输衰减。

所以预计算的是摄像机在任意高度,不同抬头角度看向大气层的边界的投射率

// TODO: 知乎https://zhuanlan.zhihu.com/p/595576594上对坐标到纹理的重映射方法与论文不一样

单次散射

光路即使没有经过太阳,太阳也对这条光路有贡献,因为本来不应该经过视线的光由于空气粒子的反弹进入了视线,形成光路

img

光线在空气中主要会发生两种物理现象:散射和透射。

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

    img

  • 透射(Transmittance)描述的是光在介质中穿行所造成的能量衰减,记作 :$I = T(I_0)$

img

所以单次散射的流程:

  1. T1透射 (衰减)
  2. S点散射
  3. T2透射 (衰减)

img

事实上在大气层内太阳光可以被看做是平行光,因此需要对视线方向做积分来重建无数条光路的总和:

img

散射理论

解决散射系数和相位函数是什么

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

img

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

img

理解就是:1. 散射系数决定有多少能量会进行散射 2. 相位函数决定这些散射的能量有多少会沿着$\theta$方向进行散射(注意散射的能量才是渲染用的,而不是剩下的)

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

$S = \sigma \cdot \text{phase}(\theta)$

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

$\sigma(\lambda, h)$

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

$\sigma(\lambda, h) = \sigma(\lambda, 0)\rho(h)$

单次散射预计算

这篇论文使用的是4D表,属于是把计算结果全部打表了

单次散射函数依赖 4 个参数:

$$ (r, \mu, \mu_s, \nu) $$
  • $r$:观察点到地心的距离
  • $\mu = \cos(\theta_v)$:视线方向与地心方向余弦
  • $\mu_s = \cos(\theta_s)$:太阳方向与地心方向余弦
  • $\nu = \cos(\theta_{vs})$:视线方向与太阳方向余弦

多重散射

二次散射的计算需要一次散射,三次需要二次,所以一层一层往上算来得到多重散射效果。

截屏2025-11-07 21.37.36

太阳光可能散射两次才散射到ViewDir

img

沿着ViewDir上某一点的计算不仅要计算来自太阳的散射,而是一个球面积分(也就是说任意方向都可能散射到这个点),对于球面上任何一点的贡献计算又是一层积分运算(球面积分的每一个采样方向,都要逐步进行 raymarch 到大气层边缘。如下图最右边蓝色线条)。所有2次多重散射就需要3重积分来求解

img

设:

  • $L^{(1)}(p, \omega)$:点 p 向方向 ω 的单次散射光。
  • $L^{(2)}(p, \omega)$:点 p 向方向 ω 的二次散射光。
  • $L^{(3)}(p, \omega)$:点 p 向方向 ω 的三次散射光。

那么:

$$ L^{(2)}(p, \omega) = \int_{q, \omega'} L^{(1)}(q, \omega') \cdot f_s(\omega', \omega) $$

以此类推:

$$ L^{(n)}(p, \omega) = \int_{q, \omega'} L^{(n-1)}(q, \omega') \cdot f_s(\omega', \omega) $$

所以:

计算第 n 阶散射,必须先有第 n−1 阶的结果。

有点懂,但没完全懂….

A Scalable and Production Ready Sky and Atmosphere Rendering Technique

2020年Epic发表介绍大气渲染的论文

多重散射的优化

这篇论文主要对多重散射需要一层一层计算迭代来实现的情况做了简化。作者通过做出一些对结果影响不大的简化假设,大幅度地降低了Multiple Scattering计算的消耗,并且使用数值方法计算无穷阶Scattering之和变为可能。

论文提到的Sky-View LUT就是最终渲染结果存储的纹理

TODO: 在靠近地平线部分大气散射会变得高频,因此UV的映射不是线性,而是靠近地平线部分会分配更多像素

大气透视查找表

除了天空,远处被大气覆盖的物体也要受到大气的影响。远处的物体对相机的贡献主要来自于两点,物体自身的颜色乘以着色点到相机的 transmittance,以及相机到着色点这段路径中间受到的大气散射。对应下图两条不同颜色的路径:

img

  1. 分割Camera Frustum(和Cluster Rendering中的分割一样),计算每个格子到Camera方向的In-Scattering和Transmittance,保存在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。

引擎实践

Transmittance Lut

用ComputerShader预计算一张2D表,记录每个高度,每个天顶角到大气外圈的透射率。

流程是把UV坐标映射到X:不同天顶角余弦值(-1,1),Y:不同高度(0,1)用得到的数据计算透射率后保存

 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
#version 450 core
#ifdef COMPUTE_SHADER
#define LOCAL_SIZE 8
#include "include/SkyCommon.glslh"

layout(rgba32f, binding = 0) uniform writeonly image2D transmittanceLut;


layout(local_size_x = LOCAL_SIZE, local_size_y = LOCAL_SIZE, local_size_z = 1) in;
void main()
{
    ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);

	AtmosphereParameter Atmosphere;  // TODO: AtmosphereUniform
    Atmosphere.PlanetRadius = 6360000.0;
    Atmosphere.AtmosphereHeight = 60000.0;
    Atmosphere.RayleighScatteringScalarHeight = 8000.0;
    Atmosphere.MieScatteringScalarHeight = 1200.0;
    Atmosphere.OzoneLevelCenterHeight = 25000.0;
    Atmosphere.OzoneLevelWidth = 15000.0;


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


    ivec2 lutSize = imageSize(transmittanceLut);
    float viewHeight;
    float viewZenithCosAngle;
    vec2 uv = vec2(texelCoord) / vec2(lutSize);
	float r = 0.0;

    UvToTransmittanceLutParams(bottomRadius, topRadius, uv, viewZenithCosAngle, r);
	float sin_theta = sqrt(1.0 - viewZenithCosAngle * viewZenithCosAngle);
	vec3 CameraPosition = vec3(0.0, r, 0.0);
	vec3 viewDir = vec3(sin_theta, viewZenithCosAngle, 0);

	float dis = RayIntersectSphere(vec3(0,0,0), topRadius, CameraPosition, viewDir);
	vec3 hitPoint = CameraPosition + viewDir * dis;
	vec3 color = Transmittance(Atmosphere, CameraPosition, hitPoint);
    imageStore(transmittanceLut, texelCoord, vec4(color, 1.0));
}

#endif
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// UV转LUT参数(高度、天顶角的余弦值)
void UvToTransmittanceLutParams(float bottomRadius, float topRadius, vec2 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;
    r = sqrt(max(0.0f, rho * rho + bottomRadius * bottomRadius));

    float d_min = topRadius - r;
    float d_max = rho + H;
    float d = d_min + x_mu * (d_max - d_min);
    mu = d == 0.0f ? 1.0f : (H * H - rho * rho - d * d) / (2.0f * r * d);
    mu = clamp(mu, -1.0f, 1.0f);
}

最终生成的lut如下,在Vulkan中,0,0在右上角,所以和参考博客生成的lutY轴反转了

image-20251108211434231

SkyView Lut

用一张2D(球面坐标)纹理存储不同ViewDir下的大气渲染结果

 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
#version 450 core
#ifdef COMPUTE_SHADER
#define LOCAL_SIZE 8
#include "include/SkyCommon.glslh"
// 二维 UV 坐标当作球面坐标 (θ, φ) 映射到单位球方向向量。 一个技巧:用2D球面来存储3D的方向向量了
vec3 UVToViewDir(vec2 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 vec3(x, y, z);
}
layout(rgba32f, binding = 0) uniform writeonly image2D SkyViewLut;
layout(binding = 1) uniform sampler2D u_TransmittanceLut;
layout(binding = 2) uniform sampler2D u_MultiScatteringLut;
layout(std140, set = 0, binding = 3) uniform SceneData
{
	DirectionalLight DirectionalLights;
	float EnvironmentMapIntensity;
} u_Scene;
layout(set = 0,binding = 4) uniform CameraDataUniform {
    mat4 view;
    mat4 proj;
	mat4 viewProj;
	float width;
	float height;
	float Near;
	float Far;
	vec3 CameraPosition;
} u_CameraData;
layout(local_size_x = LOCAL_SIZE, local_size_y = LOCAL_SIZE, local_size_z = 1) in;
void main()
{
    ivec2 texelCoord = ivec2(gl_GlobalInvocationID.xy);

	AtmosphereParameter Atmosphere = BuildAtmosphereParameter();

	ivec2 lutSize = imageSize(SkyViewLut);

	vec2 uv = vec2(texelCoord) / vec2(lutSize);
    vec3 viewDir = UVToViewDir(uv);
	vec3 lightDir = normalize(-u_Scene.DirectionalLights.Direction);
	vec3 cameraPosition = normalize(u_CameraData.CameraPosition);

	float h = cameraPosition.y - Atmosphere.SeaLevel + Atmosphere.PlanetRadius;
	vec3 eyePos = vec3(0, h, 0);

	vec3 color = GetSkyView(Atmosphere, eyePos, viewDir, lightDir, -1.0f,u_TransmittanceLut, u_MultiScatteringLut);

    imageStore(SkyViewLut, texelCoord, vec4(color, 1.0));

}

#endif
 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
vec3 GetSkyView(
    in AtmosphereParameter param, vec3 eyePos, vec3 viewDir, vec3 lightDir, float maxDis, 
    sampler2D _transmittanceLut, sampler2D _multiScatteringLut)
{
    const int N_SAMPLE = 32;
    vec3 color = vec3(0, 0, 0);

    // 光线和大气层, 星球求交
    float dis = RayIntersectSphere(vec3(0,0,0), param.PlanetRadius + param.AtmosphereHeight, eyePos, viewDir);
    float d = RayIntersectSphere(vec3(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);
    vec3 p = eyePos + (viewDir * ds) * 0.5;
    vec3 sunLuminance = param.SunLightColor * param.SunLightIntensity;
    vec3 opticalDepth = vec3(0, 0, 0);

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

        vec3 t1 = TransmittanceToAtmosphere(param, p, lightDir, _transmittanceLut);
        vec3 s  = Scattering(param, p, lightDir, viewDir);
        vec3 t2 = exp(-opticalDepth);
        
        // 单次散射
        vec3 inScattering = t1 * s * t2 * ds * sunLuminance;
        color += inScattering;

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

        p += viewDir * ds;
    }

    return color;
}
📚 文章数: 72 ✍️ 总字数: 245.55K