GAMES202笔记:基于SH的GI方案

基于Spherical Harmonics的实时全局照明
本文主要内容:频率与基函数、球面谐波函数、预计算辐射传输、球面均匀采样、构建正交基、SH实现、球面谐波函数的旋转及实现。

1. 频率与基函数

1.1 傅里叶级数展开

任何一个函数可以写成常数和一系列基函数(不同频率sin和cos项)的线性组合,基函数数量越多越接近于原函数的形状:

在这里插入图片描述

f(x)=A2+2Acos(tω)π2Acos(3tω)3π+2Acos(5tω)5π2Acos(7tω)7π+

1.2 频率

在空间上图像信号数值的变化是否剧烈,任何一张图(也就是二维函数)的频率,也就是频域上对应的内容可以用一张频谱表示出来:

在这里插入图片描述

频谱最中心处是低频内容,我们可以做一个filtering(滤波),从而去除一系列频率上的内容,我们对这张图用一个低通滤波器,从而把高频的内容去除掉:

在这里插入图片描述

卷积:卷积是一个模糊操作,在图上取任意一点,取点的周围一定区域内的像素值进行加权平均并将结果写回这个点,这就是卷积。
在空间域上做卷积等于在函数上做卷积;等于在频域上做原图频谱和卷积核频谱 的乘积操作:

在这里插入图片描述

对于任意的product integral(两个函数先乘积在积分),我们将其认为是做了一个卷积操作。

Ωf(x)g(x)dx

空间域上的两个信号f(x)g(x)进行一个卷积,等于在频域上让两个信号相乘;如果两个信号有一个信号是低频的,那么频域上相乘后得到的结果也是低频的,最终相乘在积分的结果也是低频。的可以总结为:积分之后的频率取决于积分前最低的频

1.3 基函数

f(x)=iciBi(x)

一个函数可以描绘成其他函数的线性组合,如f(x)可以描绘成一系列的Bi函数乘以各自对应的系数最终再相加在一起,这一系列的函数Bi就是基函数。

2. 球谐函数

SH(Spherical Harmonics)是一系列基函数,系列中的每个函数都是二维函数,并且每个二维函数都是定义在球面上的,阶数越高,频率越高。

在这里插入图片描述

下图是对SH的可视化,与一维的傅里叶一样,SH也存在不同频率的函数,但不同频率的函数个数也不同,频率越高所含有的基函数越多:

在这里插入图片描述

l表示的是阶数,通常第l阶有2l+1个基函数,前n阶有n2个基函数。
越偏白的蓝色地方值越大,越黑的地方值越小;而黄色中则表示偏白的地方表示其绝对值大,偏黑的地方表示绝对值小;蓝色表示正,黄色表示负。

2.1 投影

由于一个函数f(ω)可以由一系列基函数和系数的线性组合表示,那么怎么确定基函数前面的系数,这就需要通过投影操作:

ci=Ωf(ω)Bi(ω)dω

我们知道函数f(ω),通过对应的基函数Bi(ω)进行投影操作,从而求出各基函数对应的系数Ci,与以下操作是同一个道理:在空间中想描述一个向量,可以xyz三个坐标来表达,把xyz轴当做三个基函数,把向量投影到xyz轴上,得到三个系数就是三个坐标。

2.2 重建

知道基函数对应的系数,就能用系数和基函数恢复原来的函数。
由于基函数的阶可以是无限个的,越高的阶可恢复的细节就越好,但一方面是因为更多的系数会带来更大的存储压力、计算压力,而描述变化比较平滑的环境漫反射部分,用3阶SH就足够。

ci=Ωf(ω)Bi(ω)dω

对于重建diffuse来说这里的f(ω)对应环境光照。

2.3 球谐函数的正交性

ΩBi(i)Bj(i)di=1(i=j)ΩBi(i)Bj(i)di=0(ij)

旋转一个基函数之后,得到的函数就不再是一个基函数(因为基函数有严格的朝向等限制),但是旋转球谐函数等价于同阶基函数的线性组合。这意味着可以简单地进行投影、重建、旋转。

2.4 内容补充

diffuse BRDF类似于一个低通滤波器,使用一些低频信息就可以恢复出原始内容。因为积分之后的频率取决于积分前最低的频率,当diffuse BRDF使用低频信息即可恢复内容时,也就意味着无论光照项是多么复杂,其本应该用多高频的基函数去表示,但我们希望得到的是其与BRDF之积的积分,所以可以使用比较低频的基函数去描述灯光。
前3阶球谐基函数:

Y00(θ,φ)=121πY11(θ,φ)=1232πsinθeiφY10(θ,φ)=123πcosθY11(θ,φ)=1232πsinθeiφY22(θ,φ)=14152πsin2θe2iφY21(θ,φ)=12152πsinθcosθeiφY20(θ,φ)=145π(3cos2θ1)Y21(θ,φ)=12152πsinθcosθeiφY22(θ,φ)=14152πsin2θe2iφ

3. 预计算辐射传输

PRT(Precomputed Radiance Transfer)思想:将光照切割成Lighting, Light Transport两部分:

L(0)=ΩL(i)lighting V(i)ρ(i,0)max(0,ni)light transport di

首先,因为在Diffuse的情况下,BRDF几乎是一个常数,可以把它提到外面:

L(0)=ρΩL(i)V(i)max(0,ni)di

而Lighting项可以写成基函数的形式:

L(i)liBi(i)

同样的,Light transport项也可以写成基函数求和的形式:

T(i)lqBq(i)

lilq是常数,可以提出来:

L(0)=iqlilqΩ+Bi(i)Bq(i)di

因为球谐的正交性,只有i=q的时候,Bi(i)Bq(i)相乘才有值,所以这个函数的复杂度仍是O(n)。
下面将基函数形式的Lighting项带入渲染方程:

L(0)ρliΩBi(i)V(i)max(0,ni)diL(0)ρliTi

对于积分中的部分来说,相当于light transport乘与一个基函数,这就成了lighting transport投影到一个基函数的系数。因为对light transport做了预计算,visibility成为了常量,只能对静止物体进行计算。
一个SH基函数旋转后都可以被同阶的SH基函数线性组合得到,因此可以解决光源旋转问题。
现在将lighting这个球面函数,通过SH的基函数用一堆系数来表示,这些系数排成一行也就是组成了向量,因此光照变成了一个向量:

在这里插入图片描述

投影到SH空间:

li=ΩL(i)Bi(i)di

从SH空间重建:

L(i)liBi(i)

4. 球面均匀采样

4.1 Hammersley采样

Hammersley这是一种均匀分布的2D随机采样,他将十进制转换成二进制,再将二进制转换到[0,1]之间的小数,这一过程被称作 Radical Inverse。具体表示方法见下图:

在这里插入图片描述

Hammersley采样点集合:

pi=(xi,yi)=(i/N,ϕ(i))

N是我们的采样点总数,ϕ(i)就是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均匀采样,会出现两极密度高,中间密度低的情况:

在这里插入图片描述

通过求球面概率密度分布函数的反函数,可以对球面均匀采样的映射函数:

{θ=arccos(12ξx)ϕ=2πξy,ξx,ξy Uniform [0,1]

现在,在2D平面坐标(ξx,ξy)做Hammersley均匀采样,然后再映射回球坐标系即可得到均匀的分布:

在这里插入图片描述

PS:我们可以很容易的得到 cosθ=12ξx

4.3 球坐标系与直角坐标系的转换

直角坐标系(x,y,z)转球坐标系(r,θ,φ)

r=x2+y2+z2θ=arccoszrφ=arctanyx

球坐标系(r,θ,φ)转直角坐标系(x,y,z)

x=rsinθcosφy=rsinθsinφz=rcosθ

现在我们可以结合2D坐标到球坐标的映射函数写出输入(ξx,ξy),输出(x,y,z)的方法:

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);
}

注意这里的12ξx被改为了1ξx,所以是半球空间。球面空间只需要将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系数li

投影L(i)到SH空间:

li=ΩL(i)Bi(i)di

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其实是蒙特卡洛方法中的1p(Xi)

f(x)dx=1Ni=1Nf(Xi)p(Xi)

因为是球面均匀采样所以PDF是14π
现在我们得到了最终的9个SH Color。

6.3 运行时从SH空间重建光照

L(i)liBi(i)

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系数t=(t0,,tk1),我们希望将某个旋转R作用到它上面,则存在k阶方阵MR使得MRt就正好是我们所需要的新SH系数。

第二条性质意味着我们可以分别处理每一阶的SH系数。比如我们使用了前3阶SH来进行投影,那么就有l=0 对应的1个系数、 l=1 对应的3个系数以及l=2 对应的5个系数,加起来共9个SH系数。l=0的SH是个常量函数,不需要处理; l=1时的3个系数可以用一个3阶方阵进行变换;l=2 时的5个系数则可以用一个5阶方阵进行变换。

也就是说运行时根据光源旋转解出每阶(i)对应的MR,就能在li不变的情况下得到Li

7.2 方阵MR的计算方法

l=1为例(更高阶可类推):
R是我们希望进行的旋转操作的旋转矩阵, P是求出任意三维方向向量所对应的5个SH值的投影函数,则对任意方向向量 xMR应满足:

MRP(x)=P(Rx)

先用R 来变换x,再将变换结果投影到SH上,等价于先把 x 投影到SH上,之后再用 R去变换投影结果。
假设随便带入3个方向向量n0,n1,n2,这个等式都应该成立:

{MRP(n0)=P(Rn0)MRP(n1)=P(Rn1)MRP(n2)=P(Rn2)

因为P(ni)P(Rni)都是列向量,我们可以把这一坨式子写成矩阵形式:

MR[P(n0),P(n1),P(n2)]=[P(Rn0),P(Rn1),P(Rn2)]

A=[P(n0),P(n1),P(n2)]如果A是可逆的,那么我们可以很容易地把MR找出来:

MR=[P(Rn0),P(Rn1),P(Rn2)]A1

也就是说,只要n0,n1,n2选取的值使A可逆,就可以解出MR

7.3 实现方法

7.3.1 预计算

预计算A1,选取2l+1个方向向量 n0,,n2l使得 A=[P(n0),,P(n2l)] 可逆。求出A1并存储。

同时存储向量 n0,...,n2l

7.3.2 运行时

  1. 计算主光源的旋转矩阵R,将R应用于 n0,,n2l,得到n0,,n2l
  2. P计算出n0,,n2l对应的SH投影值,得到2l+12l+1维列向量,将这些列向量构造成一个2l+1阶方阵S
  3. 设待变换的SH系数用2l+1维列向量 x 表示,计算SA1x 即可。可以写成S(A1x)来节省一些计算量。

这里就相当于,对于向量n0,...,n2l,已知A1,这两个在运行时已经变成了常量,只需要带入变量R得到MR即可。然后根据:
MRP(x)=P(Rx),将MRP(x)相乘即可得到旋转后的Li

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

posted @   Miria  阅读(1357)  评论(1编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
点击右上角即可分享
微信分享提示