Atmospheric Scattering 是计算机图形学中用于模拟天空颜色、日落、体积光(God Rays)和空气透视效果的核心技术。
Unity 中实现大气散射,本质上是在解决光线穿过介质时的吸收(Absorption)和散射(Scattering)问题。
以下是底层物理原理的深度解析,以及两种主流实现模式(Raymarching 和 LUT)在 Unity 中的具体落地指南。
第一部分:底层物理原理 (The Physics)
大气散射的核心是辐射传输方程(Radiative Transfer Equation)的简化版。当光线穿过大气层时,会与微粒碰撞。
- 两种关键散射类型
- 瑞利散射 (Rayleigh Scattering):
- 原理: 针对比光波长小的微粒(如空气分子)。
- 特性: 散射强度与波长的 4 次方成反比 (\frac{1}{\lambda^4})。这意味着蓝光(波长短)比红光(波长长)更容易被散射。
- 视觉效果: 造成了白天的蓝天和傍晚的红夕阳(光程长,蓝光散尽,只剩红光)。
- 米氏散射 (Mie Scattering):
- 原理: 针对比光波长大的微粒(如气溶胶、水滴、雾霾)。
- 特性: 对波长不敏感,光线主要向前散射(Anisotropic)。
- 视觉效果: 造成了太阳周围的白色光晕和雾气效果。
- 核心方程逻辑
要计算眼睛看到某个像素的颜色,我们需要沿着视线(View Ray)积分。
其中包含三个关键项:
- Transmittance (透射率 T): 根据比尔-朗伯定律(Beer-Lambert Law),光线在传播过程中会呈指数衰减。T = e^{-\sum \text{密度} \times \text{距离}}。
- Phase Function (相函数 S): 决定光线撞击粒子后向哪个方向散射(瑞利各个方向较均匀,米氏主要向前)。
- Density (密度 \rho): 大气密度随高度呈指数下降(越高越稀薄)。
第二部分:实现模式一 —— Raymarching (光线步进)
这是最直观、动态性最强,但性能开销最大的方法。常用于高质量的体积云或即时调整大气参数的场景。
- 原理
这是一种暴力计算(Brute-force)方法。
- 在 Pixel Shader 中,从相机向每个像素发射一条射线。
- 将射线穿过大气层的部分切分为 N 个步进点(Samples)。
- 核心痛点(双重循环): 在每一个步进点 P,你需要计算由于太阳照射产生了多少光(In-scattering)。这意味着你需要从点 P 再向太阳发射一条射线来计算透射率。
- 外层循环:沿视线累加亮度。
- 内层循环:计算从太阳到当前点的光照衰减(光路深度)。
- Unity 实现步骤
- 几何体: 通常使用一个巨大的反面球体(Inverted Sphere)包裹场景,或者使用全屏后处理(Post-processing)。
- Shader 逻辑:
// 伪代码逻辑
float3 CalculateScattering(float3 rayOrigin, float3 rayDir) {
float3 totalLight = 0;
float opticalDepthView = 0;
// 1. Raymarching Loop (视线方向)
for (int i = 0; i < STEP_COUNT; i++) {
float3 samplePoint = rayOrigin + rayDir * currentDist;
float height = length(samplePoint) - PlanetRadius;
float density = exp(-height / ScaleHeight);
opticalDepthView += density * stepSize;
// 2. Secondary Ray (射向太阳) - 内层循环
// 计算从采样点到太阳的透射率
float sunRayOpticalDepth = GetOpticalDepthToSun(samplePoint, sunDir);
float3 transmittance = exp(-(opticalDepthView + sunRayOpticalDepth) * ScatteringCoefficients);
totalLight += transmittance * density * stepSize;
}
return totalLight * PhaseFunction(dot(rayDir, sunDir)) * SunIntensity;
}
- 优化: * 内层循环很慢,通常会限制步数。
- 或者将“点到太阳的光学深度”通过数学近似(如 Chapman Function)来替代内层循环。
第三部分:实现模式二 —— Lookup Texture (LUT / 预计算)
这是工业界(如 3A 游戏、虚幻引擎、Unity HDRP)的主流做法。著名的实现包括 Sean O'Neil 和 Eric Bruneton 的方法。
- 原理
由于大气层通常假设为球体对称,光照结果只取决于几个参数(如高度、视线角度、太阳角度)。我们可以将那个昂贵的积分过程预先计算(Bake)到纹理中。
运行时,Shader 不需要做循环积分,只需要根据当前的角度和高度采样纹理。 - 常见的 LUT 组合
你需要生成以下几张纹理(通常是 HDR 格式):
- Transmittance LUT (2D):
- 含义: 从大气中任意高度 h 向任意天顶角 \theta 看去,光线能通过多少。
- 坐标: X轴 = 视线角度 (0 \to 90^\circ),Y轴 = 高度 (Ground \to Top)。
- 用途: 直接替换 Raymarching 中的内层循环(计算点到太阳的透射率)。
- Multi-Scattering LUT (3D 或 4D):
- 含义: 计算光线经过多次散射后的结果(让阴影处的天空不至于死黑)。
- 用途: 叠加到最终颜色上。
- Sky View LUT (2D - 现代主流):
- 含义: 预计算最终视线的 In-scattering 结果。
- 坐标: 将视线方向参数化映射到 UV 上。
- 用途: 直接采样即可得到天空背景色,极快。
- Unity 实现步骤
- Compute Shader (预计算阶段):
- 编写 Compute Shader,离线或在游戏启动时运行一次。
- 执行类似 Raymarching 的积分逻辑,但将结果写入 RenderTexture。
- Runtime Shader (渲染阶段):
- 在天空盒 Shader 或后处理 Shader 中。
- 根据相机位置和视线方向计算 UV 坐标。
- float3 skyColor = tex2D(SkyViewLUT, uv).rgb;
- 应用色调映射(Tonemapping)。

在unity urp 内置的LUT实现太阳东升西落
不需要那种写实级别的、带体积云的、基于 LUT 的高级大气散射,Unity URP 自带了一个非常经典的解决方案,叫做 "Procedural Skybox" (程序化天空盒)。
- 最常用的内置方案:Procedural Skybox (程序化天空盒)
这是 Unity 默认用来模拟大气散射的方法。它不是基于物理的体积渲染,而是一个数学近似模型。
- 特点:
- 轻量级: 性能消耗极低,移动端随便跑。
- 半动态: 当你旋转平行光(太阳)时,它会自动计算日出、日落、夜晚的颜色变化。
- 局限: 看起来有点“数码味”(不真实),没有云,地平线过渡比较生硬。
- 如何开启:
- 在 Project 窗口右键 -> Create -> Material,命名为 Mat_Skybox。
- 选中材质,在 Inspector 顶部的 Shader 下拉菜单中选择:
Skybox -> Procedural。 - 打开 Window -> Rendering -> Lighting。
- 在 Environment 选项卡中,把 Mat_Skybox 拖到 Skybox Material 槽位里。
- 确保 Sun Source 选的是你场景里的 Directional Light。
- 关键参数 (这就是简化的散射控制):
- Sun Size: 太阳圆盘的大小。
- Sun Size Convergence: 太阳周围光晕的大小(模拟米氏散射)。
- Atmosphere Thickness: (核心) 大气厚度。
- 调大 = 空气更厚,散射更强,白天偏黄,日落更红(像火星或雾霾天)。
- 调小 = 空气稀薄,天空更蓝/黑(像高原或太空)。
-
Sky Tint / Ground: 可以在物理计算的基础上叠加颜色。
sun_mat
environment
移动太阳
- 纯静态方案:Cubemap / HDRI (全景图) (略)
在unity urp 用LUT实现太阳东升西落
在 Unity URP (Universal Render Pipeline) 中实现这套“动态 LUT 大气散射”方案,核心在于将计算逻辑(Compute Shader)与渲染逻辑(Shader Graph / HLSL)解耦。
这套方案不需要修改 URP 的管线源码,非常干净。我们可以把它拆解为三个步骤:
- 数据层 (Compute Shader): 负责每帧更新那张“小图” (Sky View LUT)。
- 控制层 (C# 脚本): 负责传递太阳方向,调度 Compute Shader,并把结果存入全局变量。
- 表现层 (Shader Graph): 读取那张图,贴在天空盒上。
第一步:编写 Compute Shader (生成器)
首先,我们需要一个能在 GPU 上跑的脚本,用来生成“天空颜色图”。
创建一个 .compute 文件,比如 SkyLUTGenerator.compute。
(这里为了演示,简化了物理积分公式,重点展示结构)
#pragma kernel CSMain
// 输出的目标纹理 (那张“小图”)
RWTexture2D<float4> Result;
// 外部传入的变量
float3 _SunDir;
float _AtmosphereHeight;
float _PlanetRadius;
// [核心] 简单的单次散射积分模拟 (Rayleigh + Mie)
// 实际项目中这里要替换成严谨的 Bruneton 或 O'Neil 积分公式
float3 IntegrateScattering(float3 rayDir, float3 sunDir) {
// ... 简化的伪代码 ...
// 计算瑞利散射 (蓝色)
float rayleigh = max(0, dot(rayDir, sunDir));
float3 blue = float3(0.2, 0.5, 1.0) * (1.0 + rayleigh * rayleigh);
// 计算米氏散射 (太阳光晕)
float mie = pow(max(0, dot(rayDir, sunDir)), 200.0); // 高光指数
float3 white = float3(1, 1, 1) * mie;
return blue + white;
}
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
// 1. 获取当前像素的 UV (0~1)
float w, h;
Result.GetDimensions(w, h);
float2 uv = id.xy / float2(w, h);
// 2. 将 UV 映射回 3D 视线方向 (View Direction)
// 这是一个逆向过程:我们要把 2D 纹理展开成半球
// 简单映射:x -> 方位角(Azimuth), y -> 天顶角(Zenith)
float theta = uv.y * 3.1415926 * 0.5; // 0 到 90度
float phi = uv.x * 3.1415926 * 2.0; // 0 到 360度
float3 viewDir;
viewDir.y = sin(theta);
float xz = cos(theta);
viewDir.x = xz * cos(phi);
viewDir.z = xz * sin(phi);
// 3. 计算颜色
float3 color = IntegrateScattering(viewDir, _SunDir);
// 4. 写入纹理
Result[id.xy] = float4(color, 1.0);
}
第二步:编写 C# 控制器 (驱动器)
我们需要一个 MonoBehaviour 挂在场景里,负责每帧告诉 Compute Shader 太阳在哪,并运行它。
创建一个 C# 脚本 AtmosphereManager.cs:
using UnityEngine;
using UnityEngine.Rendering;
[ExecuteAlways] // 编辑器模式下也能运行,方便预览
public class AtmosphereManager : MonoBehaviour
{
public ComputeShader skyComputeShader;
public Light sunLight; // 拖入你的平行光
// 生成的动态 LUT 分辨率 (如 256x128 足够了)
public Vector2Int resolution = new Vector2Int(256, 128);
private RenderTexture _skyLUT;
private int _kernelHandle;
void OnEnable()
{
InitRT();
}
void InitRT()
{
if (_skyLUT == null || _skyLUT.width != resolution.x)
{
// 必须是 ARGBFloat 或 ARGBHalf (HDR 精度)
_skyLUT = new RenderTexture(resolution.x, resolution.y, 0, RenderTextureFormat.ARGBHalf);
_skyLUT.enableRandomWrite = true; // 允许 Compute Shader 写入
_skyLUT.Create();
}
_kernelHandle = skyComputeShader.FindKernel("CSMain");
}
void Update()
{
if (skyComputeShader == null || sunLight == null) return;
if (_skyLUT == null) InitRT();
// 1. 传递太阳方向 (取反是因为光线从太阳射过来,而我们计算散射通常看光线反方向)
// 注意:URP 的 Space 转换有时候需要小心,通常用 -transform.forward
skyComputeShader.SetVector("_SunDir", -sunLight.transform.forward);
// 2. 绑定纹理容器
skyComputeShader.SetTexture(_kernelHandle, "Result", _skyLUT);
// 3. 运行 Compute Shader
// 线程组数量 = 分辨率 / 线程组大小(8)
skyComputeShader.Dispatch(_kernelHandle, resolution.x / 8, resolution.y / 8, 1);
// 4. 【关键一步】设置全局纹理
// 这样所有的 Shader Graph 只要叫 "_DynamicSkyLUT" 就能自动读到这张图
Shader.SetGlobalTexture("_DynamicSkyLUT", _skyLUT);
}
}
第三步:URP Shader Graph (接收者)
最后,我们需要一个材质球贴在 Skybox 上,读取上面生成的 _DynamicSkyLUT。
- 创建 Shader Graph:
- 右键 -> Create -> Shader Graph -> URP -> Unlit Shader Graph。
- 命名为 AtmosphereSkybox。
- 设置属性:
- 在 Blackboard 中创建一个 Texture2D 属性,命名为 MainTex (或者留空,我们主要靠全局变量)。
- 重要: 我们不需要在属性面板里暴露 LUT,因为我们是用 C# SetGlobalTexture 传进来的。
- 在 Shader Graph 内,添加一个 Property 节点,名称必须严格改成 _DynamicSkyLUT (不需设为 Exposed)。这样它就会自动抓取 C# 传过来的那张图。
- 构建连线逻辑:
- Input: View Direction (World Space, Normalized)。
- Math: 我们需要把 3D 向量转回 UV。
- Arccosine(ViewDir.y) -> 得到天顶角。
- Arctangent2(ViewDir.z, ViewDir.x) -> 得到方位角。
- 将它们 Remap 到 0 ~ 1 的范围。
- Sample Texture 2D:
- Texture: 连上 _DynamicSkyLUT 属性节点。
- UV: 连上刚才计算出的 UV。
- Output: 连接到 Base Color。
- 应用:
- 创建一个材质 Mat_Sky,使用这个 Shader。
- 打开 Lighting 面板 -> Environment -> Skybox Material,换成 Mat_Sky。
第四步:进阶技巧 (如何处理雾效 Fog)
如果你只做了 Skybox,场景里的物体还是黑的。为了让物体也受到大气影响(即雾效),你需要让场景里的物体也能读到这张 LUT。
在 URP 中有两种做法:
- 简单做法 (Shader Graph 修改):
- 在所有场景物体的 Shader (比如 Lit Shader Graph) 中,添加一个 Emission 节点。
- 计算物体位置到相机的距离 -> 算出雾的强度。
- 用同样的方法(View Direction -> UV -> Sample _DynamicSkyLUT)采样大气颜色。
- 将大气颜色叠加到 Emission 或 Albedo 上。
- 高级做法 (Render Feature - 后处理):
- 写一个 URP ScriptableRendererFeature。
- 在 RenderPass 中执行一次全屏 Blit。
- 利用深度图 (Depth Texture) 重建每个像素的世界坐标。
- 计算该像素到相机的距离。
- 采样 _DynamicSkyLUT 并混合颜色。
- 这相当于自己写了一个简单的 Volumetric Fog。
总结
在 Unity URP 中实现的核心流程:
- C#: Update() -> Dispatch Compute Shader -> Shader.SetGlobalTexture("_SkyLUT", rt).
- Shader: ViewDir -> UV -> Sample(_SkyLUT).
这样,无论你怎么旋转 Directional Light,C# 都会驱动 Compute Shader 瞬间重画 LUT,Shader Graph 里的天空颜色就会实时平滑过渡,实现完美的日出日落。


