• 博客园logo
  • 会员
  • 周边
  • 众包
  • 新闻
  • 博问
  • 闪存
  • 赞助商
  • Chat2DB
    • 搜索
      所有博客
    • 搜索
      当前博客
  • 写随笔 我的博客 短消息 简洁模式
    用户头像
    我的博客 我的园子 账号设置 会员中心 简洁模式 ... 退出登录
    注册 登录
IslandZ
博客园    首页    新随笔    联系   管理    订阅  订阅
【Unity/大气渲染】单次散射的原理和简单实现

11.6

当初写的效果比较一般,只有一个大概的样子,完成度比较低,回过头来有点看不下去

而且查找表,拟合曲线之类的优化也没有讲

重读论文后决定将文章重写。

——————————————————————————————————————

这篇随笔将会简单实现一个基于物理的相对真实的大气渲染效果

如下图,太空中的星球、相对真实的天空盒

 

 

 

如果没有大气,太阳光没有直接照射到的地方将会是一片黑暗

而我们能从太空中看到星球表面泛起的蓝光,日出时的美丽景色,都得于太阳光在大气中的散射

地球的大气中充斥着诸如空气分子,小水滴,尘粒等微小分子

当太阳光遇到这些分子时,会改变辐射方向,我们将这种现象叫做散射

(通常辐射能不会转化为热能,而只是改变方向)

 

 

 

在现实中,散射可能会发生无数次,介于性能,我们只实现一次散射就可以得到一个相对不错的效果

下面是单次散射的简单原理

我们首先思考一下太阳光如何通过大气进入我们的眼睛

假设我们所在的位置为 A点

现在给定一个观察方向,我们看向天空,视线与大气顶部相较于B点,而迎着我们视线方向的太阳光都会进入我们的眼睛

(实际上我们知道,我们的视野不是一条线,而是可以假定为一个视锥体,所以一副画面将会有许多条视线方向充斥着视锥体)

由于我们只计算单次散射,所以只需要计算刚好散射到我们视线方向的太阳光就可以了

但其实!有一件事情

这看似只有一次散射,但散射实际上时时刻刻都在发生,太阳光每向前传播一点就会遇到大气粒子然后发生散射

也就是说光线在这条路径上不断发生着散射,而散射就会造成太阳光向其他方向分散,就可能不会进入我们的眼睛

而单次散射的实际意义是 只计算光的一次转向,两次之后转向的光我们统一按照衰减计算

所以固定方向光线的衰减就是类似的过程,光线在传播过程中不断地被散射,越来越弱

 

因此太阳光进入我们眼睛的整个流程是这样的

 对于给定的视线方向,太阳光沿着固定方向入射,在 CP段 不断被散射衰减,然后与 AB段 形成无数个交点,我们将其成为Pi(i = 1,2....),在 P处 散射一部分光刚好迎着我们的视线方向进入我们的眼睛A点,PA段也不断被散射衰减

 

为什么要首先给定视线方向,其实这是适应shader的编写,因为我们写shader都是对顶点和片元操作

而模型通常是空心的,我们不会直接取到球中的一个点,而是球最外层的一个点,也就是我们的B点

而我们也知道A点(摄像机坐标),所以直接计算视线方向上散射进摄像机的光

 

在这里我会简单说一下shader计算的具体思路,方便理解公式的推导

我们知道太阳光和 AB段 形成了无数个交点 Pi,在每个交点 Pi 上都有一部分光被散射进摄像机

介于性能,我们不会全部计算,而是将 AB段 均分成 N 份,也就是采样 N 次,计算每个采样点散射进摄像机的光

具体思路为,对于 球模型 的一个顶点,我们将其定为 B点,计算 AB段 的长度,除以采样数量 N,得到 步长

我们还可以得到 向量AB,进而得到 方向向量AB,乘以 步长 就可以得到一步前进的向量

从 摄像机A点 开始,循环 N 次,每次计算一个 采样点P,然后乘以 步长 代表这一段的结果

再加上 步长 得到下一个采样点,累加得到一条视线方向最终的散射结果

然后 顶点 / 片元 着色器会遍历顶点输出最终的画面效果

下面是公式的简单推导(不涉及具体证明)

我们首先计算到达 P点 的光强,由于只计算在 P点 的一次转向所以 CP 段只计算光的衰减

设定 C点 的入射光强为 Ic,到达 P点 的光强为 Ip

\(Ip=Ic\cdot T\left(CP\right)\)

T(CP) 为光线从 C点 到 P点 的衰减方程,我们又将其叫做 透射函数

到达 P点 后,设定到达 A点 的光强为 Ia

\(Ia=Ip\cdot S\left(CP,PA\right)\cdot T\left(PA\right)\)

T(PA) 和上面一样,是 PA段 的透射函数

S(CP, PA) 是沿 CP方向 传播的光向 PA 方向散射的散射函数

就是从 C点 到达 P点的光 又有多少刚好散射到 PA 方向

简单地代换一下我们就可以知道从 C点 到 A点 的光强

\(Ia=Ic\cdot S\left(CP,PA\right)\cdot T\left(CP\right)\cdot T\left(PA\right)\)

而我们知道 AB段 上有 N 个 P点

所以对于一个顶点,最终到达 摄像机的光强

\(Ia=Ic\cdot\sum_{i=1}^{N}S\left(CP,PA\right)\cdot T\left(CP\right)\cdot T\left(PA\right)\cdot ds\)

也就是说实际上我们只需要知道透射和散射函数就可以了

于是就轮到解释一些非常简单的物理公式

因为我们知道透射衰减实际上也是散射,我们只需要将所有角度的散射加起来(求球面积分)就可以得到衰减

所以来了解这次涉及到的两种主要散射,瑞利散射(Rayleight)和米氏散射(Mie)

 

瑞利散射,指光线在遇到比自身波长小的粒子时发生的散射,而且会更多地散射波长短的光,比如蓝光,所以我们能够看到地球表面有一层薄薄的蓝光

米氏散射,指光线在遇到和自身波长差不多或大一些的粒子时发生的散射,对所有所有波长的光都会同等散射,并且大部分光都会沿着原来原来的方向传播

所以光路的形成得于米氏散射,比如我们熟悉的丁达尔效应

 

瑞利散射函数公式

\(S\left(λ,θ,h\right)=\frac{\pi^{2}\left(n^{2}-1\right)^{2}}{2}\cdot\frac{ρ\left(h\right)}{D_{0}}\cdot\frac{1}{λ^{4}}\left(1+\cos^{2}θ\right)\)

其中 n 为空气折射系数 1.00029

ρ(h) 为密度比率,在海平面为 1,随高度增加指数下降

D0 为标准大气的分子数密度 2.504*10^25

θ 为偏转角度,如下图

而我们对散射函数进行球面积分,就得到了一点的透射函数

\(β\left(λ,h\right)=\int_{0}^{2\pi}\int_{0}^{\pi}S\left(λ,θ,h\right)\cdot\sinθ\cdot dθ\cdot dφ=\frac{8\pi^{3}\left(n^{2}-1\right)^{2}}{3}\cdot\frac{ρ\left(h\right)}{D_{0}}\cdot\frac{1}{λ^{4}}\)

 

下面我们对透射函数和散射函数进行拆解,这是计划的一部分(为了方便计算和编写)

 我们可以看到透射函数只有两个变量,波长和高度,而高度仅在 ρ(h) 中,我们将 ρ(h) 单独提出来,剩下的就是

\(β\left(λ\right)=\frac{8\pi^{3}\left(n^{2}-1\right)^{2}}{3}\cdot\frac{1}{H_{0}}\cdot\frac{1}{λ^{4}}\)

而对于给定波长的光,β(λ) 是一个常量,也就是说透射函数实际上只与高度相关

ρ(h) 是高度 h 的大气密度 与 海平面大气密度 的比值

而大气密度与高度的曲线如下图

 

 

 (图片来自知乎@爱吃兔子的草)

呈现指数衰减

所以我们可以用一个指数函数来拟合这条曲线

\(ρ\left(h\right)=e^{-\frac{h}{h_{0}}}\)

h0 为均质大气高度,对于瑞利散射,h0 = 5800m,对于米氏散射,h0 = 1200m

 

注意,上面我们得到的是一点的透射函数,并不是T(CP)

因为在 CP段 发生了无数次散射,我们也仿照在 AB段 的做法,将 CP段 分成 N份,进行 N次 采样

设定每一个采样点为 Li (i = 1, 2 ...)

对于第一个采样点 L1

\(I_{l1}=Ic\ -\ Ic\cdotβ_{1}\)

而对于之后每个采样点

\(I_{li}=I_{li-1}\ -\ I_{li-1}\cdotβ_{i}\)

βi 为该点的透射函数

在数学上我们知道

\(\left(e^{x}\right)^{'}=e^{x}\)

对于 |x| 很小,有

\(f\left(x\right)=f\left(0\right)+f^{'}\left(0\right)\cdot x\)

所以有

\(e^{x}=e^{0}+e^{0}x=1+x\)

所以有下面推导

\(I_{l1}=Ic\cdot e^{-β_{1}}\)

\(I_{l2}=I_{1}\cdot e^{-β_{2}}\)

\(......\)

\(I_{li}=I_{li-1}\cdot e^{-β_{i}}\)

最终代换可得

\(I_{pN}=Ic\cdot e^{-\sum_{i=1}^{N}β_{i}}\)

所以 T(CP) 已经出来了

\(T\left(CP\right)=e^{-\sum_{i=1}^{N}β\left(λ,h\right)\cdot ds}\)

而我们知道 β(λ,h) 可以拆分成 β(λ) * β(h)

所以公式可以进一步推导为

\(T\left(CP\right)=e^{-β\left(λ\right)\sum_{i=1}^{N}ρ\left(h\right)\cdot ds}=e^{-β\left(λ\right)\sum_{i=1}^{N}e^{-\frac{h}{h_{0}}}\cdot ds}\)

而下面这一项,通常被称为 光学深度

\(D\left(h\right)=\sum_{i=1}^{N}e^{-\frac{h}{h_{0}}}\cdot ds\)

 

到此为止,我们已经完全知道 T(CP) 怎么求了,T(PA) 同理

 

还剩一项 S(λ, θ, h)

这一项我们同样进行拆解,

\(S\left(λ,θ,h\right)=β\left(λ,h\right)\cdotγ\left(θ\right)\)

其中 β(λ,h) 我们在上面已经推导过了

而 γ(θ) 只需要除一下

\(γ\left(θ\right)=\frac{S\left(λ,θ,h\right)}{β\left(λ,h\right)}=\frac{3}{16\pi}\cdot\left(1+\cos^{2}θ\right)\)

γ(θ) 这一项通常叫做 相位函数,它代表了光线的散射方向

同理 β(λ,h) 代表了光线的散射强度

我们可以发现,咦!?我不是已经知道 视线方向(AB)了,光照方向也知道(CP),对于每个顶点的计算这两个方向是固定的

所以 θ 也是一个定值,γ(θ) 也是一个常数

 

到此为止,我们终于可以把 Ia 等号右边所有函数的外衣都扒下来了

\(Ia=\sum_{i=1}^{N}I_{pi}\cdot ds\)

\(Ia=Ic\cdot\sum_{i=1}^{N}S\left(CP,PA\right)\cdot T\left(CP\right)\cdot T\left(PA\right)\cdot ds\)

\(Ia=Ic\cdot\sum_{i=1}^{N}β\left(λ,h\right)\cdotγ\left(θ\right)\cdot e^{-\sum_{i=c}^{p}β\left(λ,h\right)\cdot ds}\cdot e^{-\sum_{i=p}^{a}β\left(λ,h\right)\cdot ds}\)

\(Ia=Ic\cdotβ\left(λ\right)\cdotγ\left(θ\right)\sum_{i=1}^{N}ρ\left(h\right)\cdot ds\cdot e^{-β\left(λ\right)\cdot\left(\sum_{i=c}^{p}ρ\left(h\right)\cdot ds+\sum_{i=p}^{a}ρ\left(h\right)\cdot ds\right)}\)

其中 ρ(h) 我们已经知道了

(为了方便理解,我将 T(CP) 和 T(PA) 中的求和上下边换成了 端点)

 

 

现在我们理解为什么要将 S(λ, θ, h) β(λ,h) 拆解开了

因为对于 给定光线,给定摄像机位置,也就是给定视线和光线方向的情况下,我们实际上需要计算的变量只有一个,光学深度

也就是只与 高度h 有关,我们计算出的一次光学深度不仅可以用于 S(λ, θ, h),也可以用于 β(λ,h)

 

到此为止,瑞利散射的原理以及公式简单地解释完毕了,而米氏散射其实是一样的原理,唯一不同的是 相位函数 和 均质大气高度(h0)

我们只需要简单地更改函数的不同参数就可以了

 

这里将给出 米氏散射 相位函数的表达式

\(γ\left(θ\right)=\frac{\frac{3}{8\pi}\left(1-g^{2}\right)\cdot\left(1+\cos^{2}θ\right)}{\left(1+g^{2}-2\cdot g\cdot\cosθ\right)^{1.5}\cdot\left(2+g^{2}\right)}\)

g 的取值通常为 -0.75 到 -0.99

而当 g = 0 时,我们神奇地发现,这不就是瑞利散射的相位函数么!?

(实际上相位函数有多个版本,我也没学太明白,只知道 g表示散射的对称性,大于零,表示大部分向后散射,小于零,表示大部分向前散射)

 

好了好了,现在我们已经基本明白太阳光进入眼睛这件事是怎么回事了

下面是代码部分,在注释中进行了一定的解释

  1 Shader "Custom/ProceduralCustom"
  2 {
  3     Properties
  4     {
  5         //不含大气的星球半径
  6         _EarthGroundRadius("Earth Ground Radius", Float) = 6.0
  7         //大气厚度
  8         _AtmosphereThickness("Atmosphere Thickness", Float) = 3.0
  9         //颜色
 10         _RayleightColor("Rayleight Color", Color) = (1, 1, 1, 1)
 11         _MieColor("Mie Color", Color) = (1, 1, 1, 1)
 12         //均质大气高度
 13         _RayleightHomogeneousAtmosphere("Rayleight Homogeneous Atmosphere", Float) = 0.2
 14         _MieHomogeneousAtmosphere("Mie Homogeneous Atmosphere", Float) = 0.1
 15         //亮度
 16         _Brightness("Brightness", Float) = 10
 17         //采样数量
 18         _SampleNum("Sample Number", Int) = 500
 19         _SampleLightNum("Sample Light Number", Int) = 20
 20         //Mie相位函数系数
 21         _g("Mie Phase g", Float) = -0.78
 22         //Mie微调系数
 23         _MieAdjust("Mie Adjust", Float) = 5.1
 24     }
 25     SubShader
 26     {
 27         Tags{"LightMode" = "ForwardBase"}
 28         Pass
 29         {
 30             Cull Off
 31             CGPROGRAM
 32             #pragma vertex vert
 33             #pragma fragment frag
 34             #include "UnityCG.cginc"
 35             #include "Lighting.cginc"
 36             #define PI 3.1415926536
 37             //变量声明
 38             float _EarthGroundRadius;
 39             float _AtmosphereThickness;
 40             float _Brightness;
 41             float4 _RayleightColor;
 42             float4 _MieColor;
 43             float _RayleightHomogeneousAtmosphere;
 44             float _MieHomogeneousAtmosphere;
 45             float _SampleNum;
 46             float _SampleLightNum;
 47             float _g;
 48             float _MieAdjust;
 49             struct a2v
 50             {
 51                 float4 vertex : POSITION;
 52                 float2 texcoord : TEXCOORD0;
 53             };
 54             struct v2f 
 55             {
 56                 float4 pos : SV_POSITION;
 57                 //保存了顶点的世界坐标
 58                 float4 worldPos : TEXCOORD0;
 59                 float2 uv : TEXCOORD1;
 60             };
 61             //得到与大气的交点
 62             float2 GetPoint(float3 Pos, float3 ViewDir, float3 Center, float Radius)
 63             {
 64                 Pos -= Center;
 65                 float a = 1;
 66                 float b = 2.0 * dot(Pos, ViewDir);
 67                 float c = dot(Pos, Pos) - (Radius * Radius);
 68                 float delta = b * b - 4 * a * c;
 69                 if(delta < 0) return -1;
 70                 else 
 71                 {
 72                     float FinDelta = sqrt(delta);
 73                     return float2(-b - FinDelta, -b + FinDelta) / 2 * a;
 74                 }
 75             }
 76             //两种散射的大气密度比率
 77             float RayleightGetAtmoRho(float Height)
 78             {
 79                 return exp(-max(Height, 0) / _RayleightHomogeneousAtmosphere);
 80             }
 81             float MieGetAtmoRho(float Height)
 82             {
 83                 return exp(-max(Height, 0) / _MieHomogeneousAtmosphere);
 84             }
 85             //两种散射的相位函数
 86             float RayleightPhase(float AngleCos)
 87             {
 88                 return (0.1875 / PI) * (1 + AngleCos * AngleCos);
 89             }
 90             float MiePhase(float AngleCos)
 91             {
 92                 float _g2 = _g * _g;
 93                 float upTmp = (1.0 - _g2) * (1 + AngleCos * AngleCos);
 94                 float downTmp1 = 2.0 + _g2;
 95                 float downTmp2 = 1.0 + _g2 - 2.0 * _g * AngleCos;
 96                 float downTmp = pow(downTmp2, 1.5) * downTmp1;
 97                 return (3.0  / 8.0 / PI ) * (upTmp / downTmp);
 98             }
 99             //顶点着色器
100             v2f vert(a2v v)
101             {
102                 v2f o;
103                 o.pos = UnityObjectToClipPos(v.vertex);
104                 o.uv = v.texcoord;
105                 //世界坐标
106                 o.worldPos = mul(unity_ObjectToWorld, v.vertex);
107                 return o;
108             }
109             float4 frag(v2f i) : SV_TARGET0
110             {
111                 //包含大气的星球半径
112                 float AllEarthRadius = _EarthGroundRadius + _AtmosphereThickness;
113                 //星球球心坐标
114                 //float3 EarthCenter = mul(unity_ObjectToWorld, float4(0, 0, 0, 1)).xyz;
115                 float3 EarthCenter = float4(0, 0, 0, 1);
116                 //摄像机位置和视角方向
117                 float3 CameraPos = _WorldSpaceCameraPos;
118                 float3 CameraViewDir = normalize(i.worldPos - CameraPos);
119                 //得到与大气的两个远近交点
120                 //x分量为近点,y分量为远点
121                 float2 PointLength = GetPoint(CameraPos, CameraViewDir, EarthCenter, AllEarthRadius);
122                 //没有交点
123                 if (PointLength.y <= 0 && PointLength.x <= 0) return float4(0.0, 0.0, 0.0, 0.0);
124                 //我们限制正值,这样同时可以用于摄像机在大气内的情况
125                 PointLength.x = max(PointLength.x, 0);
126                 PointLength.y = max(PointLength.y, 0);
127                 //采样点
128                 float3 SamplePoint = CameraPos + CameraViewDir * PointLength.x;
129                 //计算采样距离和采样步长
130                 float SampleLength = PointLength.y - PointLength.x;
131                 float SampleStep = SampleLength / _SampleNum;
132                 float3 SampleStepDir = CameraViewDir * SampleStep;
133                 
134                 float RayleightInScatterSum = 0;
135                 float MieInScatterSum = 0;
136 
137                 float3 Rayleight = float3(0, 0, 0);
138                 float3 Mie = float3(0, 0, 0);
139 
140                 for(int i = 0; i < _SampleNum; i++)
141                 {
142                     float SamplePointHeight = length(SamplePoint) - _EarthGroundRadius;
143                     if(SamplePointHeight < 0) break;
144 
145                     float OneRayleightInScatter = RayleightGetAtmoRho(SamplePointHeight) * SampleStep;
146                     float OneMieInScatter = MieGetAtmoRho(SamplePointHeight) * SampleStep;
147 
148                     RayleightInScatterSum += OneRayleightInScatter;
149                     MieInScatterSum += OneMieInScatter;
150 
151                     //_WorldSpaceLightPos0为光源(仅平行光)在世界空间的位置
152                     float3 LightDir = normalize(_WorldSpaceLightPos0);
153                     float2 LightDirPointLength = GetPoint(SamplePoint, LightDir, EarthCenter, AllEarthRadius);
154 
155                     float SampleLightLength = LightDirPointLength.y;
156                     float SampleLightStep = SampleLightLength / _SampleLightNum;
157                     float3 SampleLightStepDir = LightDir * SampleLightStep;
158                     //用采样点做起点
159                     float3 SampleLightPoint = SamplePoint;
160                     //外散射衰减
161                     //是光线从 大气入射点 到 顶点 期间的衰减结果
162                     float RayleightOutScatterSum = 0;
163                     float MieOutScatterSum = 0;
164 
165                     for(int j = 0; j < _SampleLightNum; j++)
166                     {
167                         float SampleLightPointHeight = length(SampleLightPoint) - _EarthGroundRadius;
168                         RayleightOutScatterSum += RayleightGetAtmoRho(SampleLightPointHeight);
169                         MieOutScatterSum += MieGetAtmoRho(SampleLightPointHeight);
170                         SampleLightPoint += SampleLightStepDir;
171                     }
172                     RayleightOutScatterSum *= SampleLightStep;
173                     MieOutScatterSum *= SampleLightStep;
174                     float3 AllScatter = exp(-((RayleightInScatterSum + RayleightOutScatterSum) * _RayleightColor 
175                                             + (MieInScatterSum + MieOutScatterSum) * _MieColor * _MieAdjust)); 
176                     Rayleight += OneRayleightInScatter * AllScatter;
177                     Mie += OneMieInScatter * AllScatter;
178                     SamplePoint += SampleStepDir;
179                 }
180                 float PointToCameraCos = -dot(CameraViewDir, _WorldSpaceLightPos0);
181                 float3 FinalColor = _LightColor0 * _Brightness * ((Rayleight * RayleightPhase(PointToCameraCos) * _RayleightColor)
182                                                 + (Mie * MiePhase(PointToCameraCos) * _MieColor * _MieAdjust));
183                 //或许需要开一个HDR
184                 return float4(min(FinalColor, float3(1, 1, 1)), 1);
185                 //return float4(FinalColor, 1);
186                 //return float4(0, 0, 0, 1);
187             }
188             ENDCG
189         }
190     }
191     FallBack "Diffuse"
192 }

本次实现的效果没有优化,是实时计算,所以当采样数量很大的时候性能会明显不足,可以去别家大佬的文章中学习一下查找表,拟合函数等优化

参考了很多大佬的现成教程,仅有一些自己的思考,写到此处方便以后复习

参考资料

大气密度与海拔高度的关系 - 知乎 (zhihu.com)

游戏魔法编程:unity实现完整大气散射 - 知乎 (zhihu.com)

在Unity中制作Procedural Sky (Part 1-Atmospheric Scattering) - 知乎 (zhihu.com)

posted on 2023-04-14 12:09  Relolihentai  阅读(784)  评论(2)    收藏  举报
刷新页面返回顶部
博客园  ©  2004-2025
浙公网安备 33010602011771号 浙ICP备2021040463号-3