GAMES202笔记:基于SH的GI方案
基于Spherical Harmonics的实时全局照明
本文主要内容:频率与基函数、球面谐波函数、预计算辐射传输、球面均匀采样、构建正交基、SH实现、球面谐波函数的旋转及实现。
1. 频率与基函数
1.1 傅里叶级数展开
任何一个函数可以写成常数和一系列基函数(不同频率sin和cos项)的线性组合,基函数数量越多越接近于原函数的形状:
1.2 频率
在空间上图像信号数值的变化是否剧烈,任何一张图(也就是二维函数)的频率,也就是频域上对应的内容可以用一张频谱表示出来:
频谱最中心处是低频内容,我们可以做一个filtering(滤波),从而去除一系列频率上的内容,我们对这张图用一个低通滤波器,从而把高频的内容去除掉:
卷积:卷积是一个模糊操作,在图上取任意一点,取点的周围一定区域内的像素值进行加权平均并将结果写回这个点,这就是卷积。
在空间域上做卷积等于在函数上做卷积;等于在频域上做原图频谱和卷积核频谱 的乘积操作:
对于任意的product integral(两个函数先乘积在积分),我们将其认为是做了一个卷积操作。
空间域上的两个信号和进行一个卷积,等于在频域上让两个信号相乘;如果两个信号有一个信号是低频的,那么频域上相乘后得到的结果也是低频的,最终相乘在积分的结果也是低频。的可以总结为:积分之后的频率取决于积分前最低的频
1.3 基函数
一个函数可以描绘成其他函数的线性组合,如可以描绘成一系列的函数乘以各自对应的系数最终再相加在一起,这一系列的函数就是基函数。
2. 球谐函数
SH(Spherical Harmonics)是一系列基函数,系列中的每个函数都是二维函数,并且每个二维函数都是定义在球面上的,阶数越高,频率越高。
下图是对SH的可视化,与一维的傅里叶一样,SH也存在不同频率的函数,但不同频率的函数个数也不同,频率越高所含有的基函数越多:
表示的是阶数,通常第阶有个基函数,前阶有个基函数。
越偏白的蓝色地方值越大,越黑的地方值越小;而黄色中则表示偏白的地方表示其绝对值大,偏黑的地方表示绝对值小;蓝色表示正,黄色表示负。
2.1 投影
由于一个函数可以由一系列基函数和系数的线性组合表示,那么怎么确定基函数前面的系数,这就需要通过投影操作:
我们知道函数,通过对应的基函数进行投影操作,从而求出各基函数对应的系数,与以下操作是同一个道理:在空间中想描述一个向量,可以xyz三个坐标来表达,把xyz轴当做三个基函数,把向量投影到xyz轴上,得到三个系数就是三个坐标。
2.2 重建
知道基函数对应的系数,就能用系数和基函数恢复原来的函数。
由于基函数的阶可以是无限个的,越高的阶可恢复的细节就越好,但一方面是因为更多的系数会带来更大的存储压力、计算压力,而描述变化比较平滑的环境漫反射部分,用3阶SH就足够。
对于重建diffuse来说这里的对应环境光照。
2.3 球谐函数的正交性
旋转一个基函数之后,得到的函数就不再是一个基函数(因为基函数有严格的朝向等限制),但是旋转球谐函数等价于同阶基函数的线性组合。这意味着可以简单地进行投影、重建、旋转。
2.4 内容补充
diffuse BRDF类似于一个低通滤波器,使用一些低频信息就可以恢复出原始内容。因为积分之后的频率取决于积分前最低的频率,当diffuse BRDF使用低频信息即可恢复内容时,也就意味着无论光照项是多么复杂,其本应该用多高频的基函数去表示,但我们希望得到的是其与BRDF之积的积分,所以可以使用比较低频的基函数去描述灯光。
前3阶球谐基函数:
3. 预计算辐射传输
PRT(Precomputed Radiance Transfer)思想:将光照切割成Lighting, Light Transport两部分:
首先,因为在Diffuse的情况下,BRDF几乎是一个常数,可以把它提到外面:
而Lighting项可以写成基函数的形式:
同样的,Light transport项也可以写成基函数求和的形式:
、是常数,可以提出来:
因为球谐的正交性,只有的时候,相乘才有值,所以这个函数的复杂度仍是O(n)。
下面将基函数形式的Lighting项带入渲染方程:
对于积分中的部分来说,相当于light transport乘与一个基函数,这就成了lighting transport投影到一个基函数的系数。因为对light transport做了预计算,visibility成为了常量,只能对静止物体进行计算。
一个SH基函数旋转后都可以被同阶的SH基函数线性组合得到,因此可以解决光源旋转问题。
现在将lighting这个球面函数,通过SH的基函数用一堆系数来表示,这些系数排成一行也就是组成了向量,因此光照变成了一个向量:
投影到SH空间:
从SH空间重建:
4. 球面均匀采样
4.1 Hammersley采样
Hammersley这是一种均匀分布的2D随机采样,他将十进制转换成二进制,再将二进制转换到[0,1]之间的小数,这一过程被称作 Radical Inverse。具体表示方法见下图:
Hammersley采样点集合:
是我们的采样点总数,就是Radical Inverse之后得到的小数,代码实现如下:
float RadicalInverse( uint bits )
{
//reverse bit
//高低16位换位置
bits = (bits << 16u) | (bits >> 16u);
//A是5的按位取反
bits = ((bits & 0x55555555) << 1u) | ((bits & 0xAAAAAAAA) >> 1u);
//C是3的按位取反
bits = ((bits & 0x33333333) << 2u) | ((bits & 0xCCCCCCCC) >> 2u);
bits = ((bits & 0x0F0F0F0F) << 4u) | ((bits & 0xF0F0F0F0) >> 4u);
bits = ((bits & 0x00FF00FF) << 8u) | ((bits & 0xFF00FF00) >> 8u);
return float(bits) * 2.3283064365386963e-10;
}
vec2 Hammersley(uint i,uint N)
{
return vec2(float(i) / float(N), RadicalInverse(i))
}
4.2 2D坐标到球坐标的映射函数
对于球面来说,如果用方位角直接拿Hammersley均匀采样,会出现两极密度高,中间密度低的情况:
通过求球面概率密度分布函数的反函数,可以对球面均匀采样的映射函数:
现在,在2D平面坐标做Hammersley均匀采样,然后再映射回球坐标系即可得到均匀的分布:
PS:我们可以很容易的得到
4.3 球坐标系与直角坐标系的转换
直角坐标系转球坐标系:
球坐标系转直角坐标系:
现在我们可以结合2D坐标到球坐标的映射函数写出输入,输出的方法:
vec3 HemisphereSample_uniform(float u, float v)
{
float phi = v * 2.0 * PI;
float cosTheta = 1.0 - u; // float cosTheta = u;//一回事儿
float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
return vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta);
}
注意这里的被改为了,所以是半球空间。球面空间只需要将float cosTheta = 1.0 - u;
改为float cosTheta = 1.0 - 2u;
5. 根据单位法向量构建正交基
根据反射定律和折射定律,我们知道如何求解反射方向和折射方向。用蒙特卡洛方法渲染时,需要生成与法向量或反射方向呈一定概率分布的方向采样:
通过上文中HemisphereSample_uniform
函数可以生成一个直角坐标系下的向量,而且是在正上半球(结合Hammersley
可以生成正上半球的均匀采样向量)。我们可以把它当做是生成在切线空间的,因此我们还需要计算一个正交基,将它转换回世界空间:
将基下的向量与正交基投影,可以得到原向量,下面提供正交基的一种计算方法:
// [ Duff et al. 2017, "Building an Orthonormal Basis, Revisited" ]
float3x3 GetTangentBasis( float3 TangentZ )
{
const float Sign = TangentZ.z >= 0 ? 1 : -1;
const float a = -rcp( Sign + TangentZ.z );
const float b = TangentZ.x * TangentZ.y * a;
float3 TangentX = { 1 + Sign * a * Pow2( TangentZ.x ), Sign * b, -Sign * TangentZ.x };
float3 TangentY = { b, Sign + a * Pow2( TangentZ.y ), -TangentZ.y };
return float3x3( TangentX, TangentY, TangentZ );
}
6. 实现
6.1 编码球面纹理信息
以光照探针所在点为相机位置,记录周围环境到Cubemap:
之后的操作有两种。闫老师提到,滤波后一次采样等于没滤波多次采样。所以我们可以不滤波,直接用随机向量多次采样来计算漫反射;也可以先滤波,然后进行一次采样。下面采用先滤波,在进行一次采样的方法。
6.2 预计算SH系数
投影到SH空间:
6.1.2 滤波环境贴图
float3 PrefilterEnvMap(float3 NormalDir)
{
float3 FilteredColor = 0;
float Weight = 0;
// 根据法线得到正交基
float3x3 TangentToWorld = GetTangentBasis(NormalDir);
const uint NumSamples = 4096;
for( uint i = 0; i < NumSamples; i++ )
{
// 计算二维随机向量
float2 R = Hammersley(i, NumSamples);
// 通过球面映射转换到直角坐标系得到随机向量
float3 E = HemisphereSample_uniform(R.x, R.y);
// 把随机向量转换为世界空间
float3 H = mul(E, TangentToWorld);
// 得到cos项
float NoL = saturate(dot(NormalDir, H));
if( NoL > 0 )
{
FilteredColor += AmbientCubemap.Sample(H, 0).rgb * NoL;
Weight += NoL;
}
}
return FilteredColor / max( Weight, 0.001 );
}
可以用一个低分辨率的Cubemap来存储,因为漫反射频率非常低。
6.1.2 求出Spherical Harmonics
前3阶基函数,可以用计算的结果替代常量计算部分:
float[9] SHcosineLobe(Vector3 normal)
{
float[] sh = new float[9];
float x = normal.x;
float y = normal.y;
float z = normal.z;
sh[0] = 1.0f / 2.0f * Sqrt(1.0f / PI);
sh[1] = Sqrt(3.0f / (4.0f * PI)) * z;
sh[2] = Sqrt(3.0f / (4.0f * PI)) * y;
sh[3] = Sqrt(3.0f / (4.0f * PI)) * x;
sh[4] = 1.0f / 2.0f * Sqrt(15.0f / PI) * x * z;
sh[5] = 1.0f / 2.0f * Sqrt(15.0f / PI) * z * y;
sh[6] = 1.0f / 4.0f * Sqrt(5.0f / PI) * (-x * x - z * z + 2 * y * y);
sh[7] = 1.0f / 2.0f * Sqrt(15.0f / PI) * y * x;
sh[8] = 1.0f / 4.0f * Sqrt(15.0f / PI) * (x * x - z * z);
return sh;
}
计算一次采样的SH结果:
float3[9] GetSHSampleColor(float3 Dir)
{
float3[9] Result;
float3 color = AmbientCubemap.Sample(Dir, 0);
float[9] SHValue = SHcosineLobe(Dir);
for(uint i = 0; i < 9; ++i)
{
Result[i] = SHValue[i] * Color;
}
return Result;
}
下面需要生成很多随机采样方向,计算球谐向量,然后取平均。这里可以用CS的Barrier操作,只分一个线程组,等待组内所有采样都记录下之后,进行平均,类似Mipmap一样一级一级两两平均:
// 采样次数
const THREAD_COUNT = 256;
// 暂存颜色
float3[THREAD_COUNT] CollectedData;
float3[9] GetResult(uint id)
{
// 权重
Weight = 4.0f * PI;
// 计算一个采样方向,类似上文中的半球空间
float3 SampleWay = SphereSample_uniform(Hammersley(id, THREAD_COUNT)).xyz;
// 采样出球谐颜色
float3[9] Col = GetSHSampleColor(SampleWay);
[unroll]
for(uint i = 0; i < 9; ++i)
{
// 把第i项球谐颜色存储
CollectedData[id] = Col[i] * Weight;
uint threadID = THREAD_COUNT;
while(threadID > 0)
{
threadID /= 2;
bool bInside = id < threadID;
float3 data = 0.0f;
// 等当前线程组所有线程都把第i项存到CollectedData中
GroupMemoryBarrierWithGroupSync();
if(isInside)
{
// 取均值
data = CollectedData[id * 2] + CollectedData[id * 2 + 1];
data *= 0.5f;
}
//等待所有可取均值的线程完成
GroupMemoryBarrierWithGroupSync();
if(isInside)
{
// 存储均值
CollectedData[id] = data;
}
}
if(id == 0)
{
// 最终结果
Col[i] = CollectedData[0];
}
}
}
代码中的Weight其实是蒙特卡洛方法中的
因为是球面均匀采样所以PDF是。
现在我们得到了最终的9个SH Color。
6.3 运行时从SH空间重建光照
float3 GetDiffuseSH(float3[9] SHResult, float3 Normal)
{
float[9] Bi = SHcosineLobe(Normal);
float3 Col = 0.0f;
for(uint i = 0; i < 9; ++i)
{
Col += SHResult[i] * Bi[i];
}
return Col;
}
得到效果
7. 球谐的旋转
PRT 的一个问题是如果 lighting 部分是预计算的,那就只适用于静态环境光下的静态物体渲染;环境光或者物体只要有变化,PRT 就不得不进行重新预计算;但得益于 SH 的旋转不变性,我们至少可以让 SH Lighting 适用于动态旋转的情形而不必重新预计算。
环境光旋转和物体旋转在 PRT 渲染中是等价的,只是说看相对于哪个东西来看待旋转而已。
7.1 SH系数旋转的性质
- SH系数的旋转是对SH系数的线性变换
- 对每一阶SH系数的旋转可以分别进行
第一条性质意味着给定SH系数,我们希望将某个旋转作用到它上面,则存在阶方阵使得就正好是我们所需要的新SH系数。
第二条性质意味着我们可以分别处理每一阶的SH系数。比如我们使用了前3阶SH来进行投影,那么就有 对应的1个系数、 对应的3个系数以及 对应的5个系数,加起来共9个SH系数。的SH是个常量函数,不需要处理; 时的3个系数可以用一个3阶方阵进行变换; 时的5个系数则可以用一个5阶方阵进行变换。
也就是说运行时根据光源旋转解出每阶()对应的,就能在不变的情况下得到
7.2 方阵的计算方法
以为例(更高阶可类推):
设是我们希望进行的旋转操作的旋转矩阵, 是求出任意三维方向向量所对应的5个SH值的投影函数,则对任意方向向量 , 应满足:
先用 来变换,再将变换结果投影到SH上,等价于先把 投影到SH上,之后再用 去变换投影结果。
假设随便带入3个方向向量,这个等式都应该成立:
因为和都是列向量,我们可以把这一坨式子写成矩阵形式:
记如果是可逆的,那么我们可以很容易地把找出来:
也就是说,只要选取的值使可逆,就可以解出。
7.3 实现方法
7.3.1 预计算
预计算,选取个方向向量 使得 可逆。求出并存储。
同时存储向量
7.3.2 运行时
- 计算主光源的旋转矩阵,将应用于 ,得到
- 用计算出对应的SH投影值,得到个维列向量,将这些列向量构造成一个阶方阵
- 设待变换的SH系数用维列向量 表示,计算 即可。可以写成来节省一些计算量。
这里就相当于,对于向量,已知,这两个在运行时已经变成了常量,只需要带入变量得到即可。然后根据:
,将与相乘即可得到旋转后的。
7.3.4 伪代码
float3 GetDiffuseSH(float3[9] SHResult, float3 Normal, float3x3 RotationMatrix)
{
// 1. 运行时步骤1
float3 L1_n0 = mul(RotationMatrix, L1N0);
float3 L1_n1 = mul(RotationMatrix, L1N1);
float3 L1_n2 = mul(RotationMatrix, L1N2);
float3 L2_n0 = mul(RotationMatrix, L2N0);
float3 L2_n1 = mul(RotationMatrix, L2N1);
float3 L2_n2 = mul(RotationMatrix, L2N2);
float3 L2_n1 = mul(RotationMatrix, L2N3);
float3 L2_n2 = mul(RotationMatrix, L2N4);
// 2.运行时步骤2
float3 S_L1_n0 = SHcosineLobe_L1(L1_n0);
float3 S_L1_n1 = SHcosineLobe_L1(L1_n1);
float3 S_L1_n2 = SHcosineLobe_L1(L1_n2);
float[5] S_L2_n0 = SHcosineLobe_L2(L2_n0);
float[5] S_L2_n1 = SHcosineLobe_L2(L2_n1);
float[5] S_L2_n2 = SHcosineLobe_L2(L2_n2);
float[5] S_L2_n3 = SHcosineLobe_L2(L2_n3);
float[5] S_L2_n4 = SHcosineLobe_L2(L2_n4);
float[9] S1 = float3x3(
S_L1_n0.x, S_L1_n1.x, S_L1_n2.x,
S_L1_n0.y, S_L1_n1.y, S_L1_n2.y,
S_L1_n0.z, S_L1_n1.z, S_L1_n2.z,
);
float[25] S2 = float[25]{
S_L2_n0[0],S_L2_n1[0],S_L2_n2[0],S_L2_n3[0],S_L2_n4[0],
S_L2_n0[1],S_L2_n1[1],S_L2_n2[1],S_L2_n3[1],S_L2_n4[1],
S_L2_n0[2],S_L2_n1[2],S_L2_n2[2],S_L2_n3[2],S_L2_n4[2],
S_L2_n0[3],S_L2_n1[3],S_L2_n2[3],S_L2_n3[3],S_L2_n4[3],
S_L2_n0[4],S_L2_n1[4],S_L2_n2[4],S_L2_n3[4],S_L2_n4[5],
};
// 3.运行时步骤3
float[9] MR_L1 = mul(S1, A_Inv_L1);
float[25] MR_L2 = mul(S2, A_Inv_L2);
float[9] Bi = SHcosineLobe(Normal);
float L0 = Bi[0];
float3 L1 = float3(Bi[1], Bi[2], Bi[3]);
L1 = mul(MR_L1, L1);
float[5] L2 = float[5]{Bi[4], Bi[5], Bi[6], Bi[7], Bi[8]};
L2 = mul(MR_L2, L2);
return SHResult[0] * L0 +
SHResult[1] * L1[0] + SHResult[2] * L1[1] + SHResult[3] * L1[2] +
SHResult[4] * L2[0] + SHResult[5] * L2[1] + SHResult[6] * L2[2] + SHResult[7] * L2[4] + SHResult[8] * L2[5];
}
8. 游戏引擎中的实现
Unity中的Light Probe,Tetrahedral Tessellations方案。UE4的Indirect Light Cache和VLM均是基于球谐的,只不过在差值方式,存储等方面有区别。这里先不写了,文章有点长
引用
主要来源 GAMES202
截图来自于GAMES 202 PPT和Unity引擎内
http://corysimon.github.io/articles/uniformdistn-on-sphere/
https://www.cnblogs.com/KillerAery/p/15335369.html
https://airguanz.github.io/2018/11/20/SH-PRT.html
https://zhuanlan.zhihu.com/p/51267461
https://zhuanlan.zhihu.com/p/103715075
https://zhuanlan.zhihu.com/p/49746076
https://zhuanlan.zhihu.com/p/362460950
https://zhuanlan.zhihu.com/p/373697912
https://zhuanlan.zhihu.com/p/149217557
https://zhuanlan.zhihu.com/p/351071035