1 目的/动机:
基于Unity Light Probe方案实现的全局间接光照(GI)存在无法流式加载,大尺寸Mesh的照明效果和性能不可兼得,且高度内嵌于Unity引擎,无法灵活修改等缺点。为了克服这些缺点,希望通过探索基于Light Volume (Irradiance Volume)技术的GI方案,来和Unity原生方案进行对比,研究它们在移动端开放世界场景中的可行性以及优缺点。以下内容主要就Light Volume的一些前置技术,既球谐函数,基于球谐的光照信息编解码实现,以及数据压缩方案等方面进行了研究,总结了一些心得和大家分享。
2 绕不过的问题,什么是球谐?
2.1 球谐是一组基函数
使用球谐函数来编码和存储物体表面光照分布是业界主流的且非常成熟高效的做法,Light Volume 也会用球谐系数来存放空间中每个点附近的间接光照分布,那么理解球谐就显得很有必要了,什么是球谐呢?
球谐的全称是“球谐函数”,其本质是一组具有正交完备性的基函数。为了便于理解基函数,我们不妨回想一下大家熟悉的二维或者三维坐标空间,其中的x,y和/或z轴就是人们定义的一组“基函数”,这些轴线(方程)无法通过变换系数(缩放)的方式来彼此表示,在空间上表现为互相垂直,这样的性质有个好处,那就是对于每一个轴线(方程)上的系数,能且只能反映其所在轴上的分量大小,而无法影响其它轴线上的数值,换一种说法是,这个坐标系下的每个数值只能描述一个维度上的特征。这种在表示特征值方面的纯粹性,大大简化了后续运算的复杂度,是用来描述复杂信息的好基础。
建立在x,y和z轴上面的三维空间(或者叫欧氏空间,直角坐标空间)能精确描述三维世界中的任意位置。而当我们需要描述更高维度中具有更多特征和状态的事物时,只用3个轴就完全不够了。此时人们通常会选用具有更多维度的正交函数基来保存信息在各个维度上的特征。回忆一下我们大学时代学习过泰勒展开,或者傅里叶展开。以泰勒展开为例,复杂函数的特征可以利用加总不同阶导函数的方式来逐渐逼近:
多项式中第i个子项对应了函数f的i-1阶导,我们可以把每一个子项视为一个基函数,在泰勒展开的过程中,每多一项,导函数加深一阶,基函数对应增加一个,状态的空间维度自然也提高了一维,并且对于原始信息的表述就变得更加精确了。
泰勒展开是从函数中值定理推导过来的,那么球谐函数是从何而来的呢?我尝试刨去复杂的数学运算和推理,只概述推导球谐的几个重要过程,如果大家有兴趣深入,可以阅读wiki上的球谐完整推导过程。
2.2 从波动方程求解到球谐函数
我们已经知道,球谐函数是一组具有正交完备性的基函数,人们能够用它来描述单位球体表面光波的概率分布,提到光波,自然就引出了推导球谐的第一个方程:麦克斯韦方程组
麦氏方程组描述了空间中电场、磁场、电感和磁感强度的变化规律。我们知道光波的本质是一种电磁波,可以用麦克斯韦方程组推导出的波动方程做一般意义上的描述:
式中v是光速,我们不必理会,f是合并后的E和H,叫电磁场强度方程,它描述了空间中电磁场强度的分布,就是光波的分布。接下来的推导主要围绕求解f展开,球谐函数本身就是在求解和重构f的过程中找到的。
首先将直角坐标系中的方程f转换到球面坐标系中,转换关系如下:
替换了坐标系后,(2)式转化为如下形式:
(4)式是一个偏微分方程,分别对球面半径r
,天顶角theta
以及方位角phi
进行偏导,求解时可以利用分离变量法,巧借代数方法将f重新编排,最终让方程式的一部分只含有一个变量,而剩余的部分则与此变量无关。现在我们的f方程存在3个分量,需要至少2次分离操作来将它们分开。 作为示例,我们第一次先把半径r
分离出来,不妨令:
代入(4)式稍作整理后,可以将含有r
的常微分项与其余项分离到等号两边,且由于两边的变量互不干涉,这2个部分必然等于某个常量,为方便后续计算,不妨设这个常量为:l * (l + 1)
,其中l
为常数:
如此一来,与r
关联的常微分方程被分离出来,可惜我们并不关系这部分的求解:球谐考虑的是单位圆的表面分布,距离r本身是一个固定的常量,无需求解。我们真正关心的是余下的2个部分,既包含了天顶角theta
和方位角phi
的偏微分方程:
这就是球谐方程,它规定了单位球体表面的光照分布边界条件,也是人们对这个方程的深入研究,揭示了球谐基函数的全貌。 好了,我们再次祭出分离变量法,令:
具体过程不再赘述,我们让包含了各自常微分项的部分分处等号两边,并且令他们等于m^2
,其中m
为常数。记住这个常量符号m
,它和之前出现的常量l
一起,定义了球谐基函数的展开。分离结果如下,我们分部得到了只包含theta
的(9)式和只包含phi
的(10)式。
求解这两个微分方程的过程有点复杂,好在我们只需要知道结果即可。对于(9)式解得:
或者用欧拉公式改写为:
注意由于方程φ
是周期函数,以2π
为周期,所以限制了常数m
必须是整数:m = 0,1,2,3...
(10)式的求解结果为:
其中l
是大于m
的自然数,至于等式右半部分人们还专门给了一个名字,叫连带勒让德多项式(associated Legende polynormial),它长这样:
看起来很唬人,但是只要给定多项式的某一项(既给定m
和l
),输入x
就能计算出结果。为了简便起见,不妨令 i = l^2 + l + m
,这样原本的m
和l
就简并为多项式因子i
,前5项的图像输出可以参考下图。不难看出,随着项数i
提升,函数图像的复杂度也在提升,可以理解为更高的项数代表了更高频的信息细节,类似傅里叶展开,通过对若干个多项式的叠加求和,可以模拟逼近任意给定的波函数,可以说勒让德多项式展开奠定了球谐函数作为基函数的基础。
球谐函数关于天顶角和方位角的2个分项已经求解完毕,我们组合这两个分项,再使用因子K
对函数式归一化处理得:
或者代入K
后得:
其中L
属于自然数,而 m = 0,± 1, ± 2, ... ± L
(15)式是能够满足波动方程的通解,这个解本身则是一组基函数,定义在单位球表面(由r=1
,θ
,φ
确定),对m
和L
展开,或者使用i = l^2 + l + m
。
不光如此,人们发现(15)式还是具有规范化正交性的基函数。我们知道,对于一组基函数,当子项i != j
时,这两个基函数子项的乘积积分为0:
而当这两个基函数子项相同,既i == j
时,那么它们的乘积积分值为1:
采用规范化正交基函数进行信号波重建有两个重要优点:
- 其一是在这种基底上进行函数投影能够大量简化计算。 所谓投影可以理解为将空间中一点的光照信息重新编码为若干个基函数的系数c,而求解系数c的过程需要用到基函数的共轭函数。 如下图所示,若将函数f投影到函数基底上,那么函数值可以近似的表示为基底与系数c乘积的叠加:
同时,求解c就变为对原函数f和基底函数B的共轭函数进行卷积和的过程:
如果我们使用的基底函数本身具有正交规范性,那么其共轭函数就是这些基底函数本身。
- 第二个优点是能够提高乘积积分的效率:当我们对2个光照分布函数的乘积进行积分时,可以先将它们转化为各自系数与球谐基函数的乘积和,然后利用(16)式的性质,大量消除掉不同基底函数之间的积分关系,从而将复杂的乘积积分转化为只对相同基底上系数的乘积求和:
可以说正是因为上述这些优点,以及球谐函数本身对球面空间的描述能力,使得它被广泛应用于编码光照分布的计算中。
2.3 球谐编解码的数学定义
在了解了球谐函数的定义和性质后,我们可以大致勾勒一下使用球谐存储和复原光照的流程:
-
Step1:编码光照分布
21
上式定义了一个离散化为N份的球面空间,s
表示空间上分布的一小片区域,可以用 “天顶角” + “方位角” 表示, 也可以由归一化后的直角坐标系方向向量表示。 我们通过对不同方向上的光照强度采样获得g(s)
, 再与球谐基底函数Yi
相乘, 之后以累加求和的方式,来近似(19)式的卷积过程,最终的求和结果被球表面积系数平均过后,就获得了基于Yi
的球谐系数ci
。
-
Step2:解码
22
简单解释一下,(22)中等式左边表示在坐标点x
上,某个给定方向上的光线的辐照度,它近似于等式右边:既空间中各点x'
处光强L(x)
的球谐系数C(L)
与表示点x
和x'
之间的几何关系的函数G(x', x)
所对应的的另一组系数C(G)
相乘,再乘以对应球谐基底后求和的值。
此外值得补充的是,(20)和(21)式之所以成立,是因为人们应用了蒙特卡洛积分估算法:对整个实数集合做积分操作时,可以用若干个随机变量值X
,以及这些随机变量值对应的概率密度函数f(x)
,通过运算去逼近。当随机变量的采样点越多时,则越逼近真实积分操作的结果。
3 Unity如何构建和存放球谐系数?
借用一张网上广为流传的球谐函数图示,Unity采用了L0,L1和L2共3阶,9个球谐系数来编码光照强度。图中蓝灰色代表正值,黄色代表负值,使用的应该是直接坐标系作为绘图空间。此外有一点图示中没有完整体现,随着阶数L的提升,球谐函数值的绝对值也会逐渐减小,反之则绝对值增大。
为了方便计算机运算球谐光照,一般会使用如下(23)所示的球谐基函数,它们是通过将(3)式以及m
和l
的取值代入到(15-2)式中化简计算而来的:
注意到函数中的方位控制变量已经从原先基于球坐标的天顶角theta
和方位角phi
转换到了基于直角坐标系的x
,y
和z
标量。参考系的中心点是(0,0,0)
点,半径r
定义为1。
我们知道Unity在烘焙Lightmap
时,会尝试调用被烘焙MeshRenderer
所附属着色器的Meta Pass
,以获得用户自定义的表面光照纹理。这个Meta Pass
中会由Unity传入一组BRDF光照信息,该信息定义了像素点周围各个角度的光照分布情况。由此我们可以合理推测,UnityEditor的光照烘焙器在填充Light Probe信息时,也会引入类似的入参,基于光照分布信息,向四周一定数量的方向进行颜色采样,而后获得的RGB三个通道的强度值再分别运用公式(21)进行球谐投影,生成对应的球谐系数c
*。每一个光照探针可以存储3个色彩通道,每通道对应9个基函数,共 27个系数,这些系数最终会被传递到C#端,由 SphericalHarmonicsL2
类存放保管起来,我们后续对球谐光照的重建会用到它。
[*备注:]为了简化runtime时解码球谐系数的开销,Unity可能会优化系数c,主要是会将球谐基底(上图所示的9个函数)中的常量部分独立出来,合并到系数c中。
如果场景烘焙了多组光照探针信息,那么我们还得将额外的数据保存成Asset资源,需要时可由Unity从磁盘读取,导入到LightmapSettings.lightProbes
对象中去,从而刷新当前场景使用的光照探针数据。对于这份落地的Asset资源,之前我们讲过它的结构:包含探针坐标,四面体网络结构,球谐系数以及遮挡信息。
附加补充一点,如果用如下代码输出SphericalHarmonicsL2
类中的系数,能够和Asset中保存的系数对应一致。
var probes = LightmapSettings.lightProbes;
int pIdx = 0;
foreach (var p in probes.bakedProbes)
{
StringBuilder sb = new StringBuilder($"probeIndex#{pIdx} = \n");
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 9; j++)
{
sb.Append(p[i, j]);
sb.Append($"[{i},{j}], \n");
}
}
sb.Remove(sb.Length - 2, 2);
Debug.Log(sb.ToString());
pIdx++;
}
下表中第一列是打印输出的C#球谐对象保存的系数,第二列是该探针数据在Asset中保存的顺序,可以发现Asset中的系数是按照R * 9 + G * 9 + B * 9
的方式展平的。
SphericalHarmonicsL2 | Asset | unity shader |
---|---|---|
0.5743529 [0,0] | sh[ 0]: 0.5743529 | half4 unity_SHAr |
0.4652082 [0,1] | sh[ 1]: 0.46520817 | half4 unity_SHAg |
-0.3382154 [0,2] | sh[ 2]: -0.33821544 | half4 unity_SHAb |
0.1952294 [0,3] | sh[ 3]: 0.19522941 | half4 unity_SHBr |
0.06610635 [0,4] | sh[ 4]: 0.06610635 | half4 unity_SHBg |
-0.1144718[0,5] | sh[ 5]: -0.114471786 | half4 unity_SHBb |
-0.001581223 [0,6] | sh[ 6]: -0.0015812232 | half4 unity_SHC |
-0.04802656 [0,7] | sh[ 7]: -0.048026558 | |
-0.0324709 [0,8] | sh[ 8]: -0.0324709 | |
0.5900019 [1,0]] | sh[ 9]: 0.5900019 | |
0.4498399 [1,1] | sh[10]: 0.44983995 | |
-0.3382239 [1,2] | sh[11]: -0.33822387 | |
0.1924354 [1,3] | sh[12]: 0.19243543 | |
0.0672548 [1,4] | sh[13]: 0.0672548 | |
-0.1144729 [1,5] | sh[14]: -0.11447291 | |
-0.002106918 [1,6] | sh[15]: -0.0021069185 | |
-0.04802133 [1,7] | sh[16]: -0.04802133 | |
-0.03398534 [1,8] | sh[17]: -0.033985343 | |
0.6996707 [2,0] | sh[18]: 0.69967073 | |
0.3421385 [2,1] | sh[19]: 0.34213853 | |
-0.338283 [2,2 | sh[20]: -0.33828303 | |
0.1728552 [2,3] | sh[21]: 0.1728552 | |
0.07530314 [2,4] | sh[22]: 0.07530314 | |
-0.1144808 [2,5] | sh[23]: -0.1144808 | |
-0.005791017 [2,6] | sh[24]: -0.0057910173 | |
-0.04798474 [2,7] | sh[25]: -0.047984738 | |
-0.04459865 [2,8] | sh[26]: -0.044598646 |
综上所述,Unity通过光照烘焙投影球谐系数,这些系数可以随场景数据一起存放,也能单独保存成Asset资源,它们之间存在如上表1,2列所示的简单映射关系。
4 利用Unity构建的球谐系数重建光照
Unity在运行时重建球谐光照主要分为两个部分,其一是在CPU端的Native库中处理和打包球谐系数;其二是在GPU端着色器中调用函数重建某个方向上的光照。先从第二部分说起,因为这部分我们可以很方便的看到源码。
首先Unity在shader中定义了一系列向量用于存放CPU端同步过来的球谐系数
// SH lighting environment
half4 unity_SHAr;
half4 unity_SHAg;
half4 unity_SHAb;
half4 unity_SHBr;
half4 unity_SHBg;
half4 unity_SHBb;
half4 unity_SHC;
查看位于UnityShaderVariables.cginc
中的内置函数,可以定位到Unity使用上述向量的方法如下:
// normal should be normalized, w=1.0
half3 SHEvalLinearL0L1 (half4 normal) {
half3 x;
// Linear (L1) + constant (L0) polynomial terms
x.r = dot(unity_SHAr,normal);
x.g = dot(unity_SHAg,normal);
x.b = dot(unity_SHAb,normal);
return x;
}
// normal should be normalized, w=1.0
half3 SHEvalLinearL2 (half4 normal) {
half3 x1, x2;
// 4 of the quadratic (L2) polynomials
half4 vB = normal.xyzz * normal.yzzx;
x1.r = dot(unity_SHBr,vB);
x1.g = dot(unity_SHBg,vB);
x1.b = dot(unity_SHBb,vB);
// Final (5th) quadratic (L2) polynomial
half vC = normal.x * normal.x - normal.y * normal.y;
x2 = unity_SHC.rgb * vC;
return x1 + x2;
}
// normal should be normalized, w=1.0
// output in active color space
half3 ShadeSH9 (half4 normal) {
// Linear + constant polynomial terms
half3 res = SHEvalLinearL0L1(normal);
// Quadratic polynomials
res += SHEvalLinearL2(normal);
if (IsGammaSpace())
res = LinearToGammaSpace(res);
return res;
}
我们不难发现,SHEvalLinearL0L1
用于解码第0和第1阶共4组,12个球谐系数,SHEvalLinearL2
负责解码余下的第2阶,共5组15个球谐系数,最后ShadeSH9
简单合并了前两个函数的返回值。
以SHEvalLinearL0L1
为例,方法的主要逻辑就是3个点乘,2个参数则是球谐系数向量与方向向量(视方向),返回的值代表了其所在颜色通道,沿着normal方向上的光强。要理解其中含义需要结合(23)式的前2阶球谐基底定义,以及(22)式的概念:(23)中l=0
时基底是常数,这个系数被存放在了unity_SHAr
,unity_SHAg
和unity_SHAb
变量的最后一个通道“w
”上,点乘时对应的normal第四位也是常数1.0,所以运算结束后L0的系数会被完整取用;(23)式l=1
的3个基底出现了r,可以默认为1,而式中的x,y,z分类分别代表了直角坐标系中归一化后的方向向量的3个分类。
回到SHEvalLinearL0L1
方法中的点乘环节,由于normal.xyz
保持了原始顺序,那么合理推断unity_SHAr
的前三个变量分别代表了(23)式l=1
部分,m=1
,m=-1
以及m=0
的三个基函数常量部分与其对应红色通道球谐投影系数的乘积。
再来看SHEvalLinearL2
,与L0L1类似的,unity_SHBr
的4个通道应该存储的也是第 4 ~ 7 组红色通道球谐投影系数与对应球谐基函数常数部分的乘积,所不同的是,这个方法很巧妙的利用了 normal.xyzz * normal.yzzx
语句,快速构建了{xy,yz,zz,zx}
共4个中间变量,而这4个变量正巧对应了(23)式l=2
部分,m=-2
,m=-1
,m=0
以及m=1
这4个基函数的系数,如果你好奇为何zz
能契合m=0的变量部分(-xx - yy + 2zz)
,那是因为对于单位球来说,我们有(zz - 1 = - xx - yy)
这条隐含关系可以利用。
通过了解Unity的Shader使用球谐变量的方式,我们可以反推出部分CPU端打包球谐系数的逻辑,通过测试也网上查阅资料,我们可以参考如下方法,自己在C#代码中构建一个LightProbe的7组球谐向量:
List<Vector4> CalculateSHVairentMimicUnity(SphericalHarmonicsL2 sh)
{
List<Vector4> Y = new List<Vector4>();
for (int ic = 0; ic < 3; ++ic)
{
Vector4 coefs = new Vector4();
coefs.x = sh[ic, 3];
coefs.y = sh[ic, 1];
coefs.z = sh[ic, 2];
coefs.w = sh[ic, 0] - sh[ic, 6];
Y.Add(coefs);
}
for (int ic = 0; ic < 3; ++ic)
{
Vector4 coefs = new Vector4();
coefs.x = sh[ic, 4];
coefs.y = sh[ic, 5];
coefs.z = sh[ic, 6] * 3.0f;
coefs.w = sh[ic, 7];
Y.Add(coefs);
}
{
Vector4 coefs = new Vector4();
coefs.x = sh[0, 8];
coefs.y = sh[1, 8];
coefs.z = sh[2, 8];
coefs.w = 1.0f;
Y.Add(coefs);
}
return Y;
}
可以看到,除了最后的unity_SHC
向量外,其余所有向量各自只存储一种颜色通道的4个球谐系数,具体对应关系我不再赘述,这边整理了一份关联了C#、Shader和Asset内Unity球谐系数的对应关系表供大家查阅:
C# | Shader | Asset |
---|---|---|
SH[0, 0] | unity_SHAr.w | sh[0] |
SH[0, 1] | unity_SHAr.y | sh[1] |
SH[0, 2] | unity_SHAr.z | sh[2] |
SH[0, 3] | unity_SHAr.x | sh[3] |
SH[1, 0] | unity_SHAg.w | sh[9] |
SH[1, 1] | unity_SHAg.y | sh[10] |
SH[1, 2] | unity_SHAg.z | sh[11] |
SH[1, 3] | unity_SHAg.x | sh[12] |
SH[2, 0] | unity_SHAb.w | sh[18] |
SH[2, 1] | unity_SHAb.y | sh[19] |
SH[2, 2] | unity_SHAb.z | sh[20] |
SH[2, 3] | unity_SHAb.x | sh[21] |
SH[0, 4] | unity_SHBr.x | sh[4] |
SH[0, 5] | unity_SHBr.y | sh[5] |
SH[0, 6] | unity_SHBr.z | sh[6] |
SH[0, 7] | unity_SHBr.w | sh[7] |
SH[1, 4] | unity_SHBg.x | sh[13] |
SH[1, 5] | unity_SHBg.y | sh[14] |
SH[1, 6] | unity_SHBg.z | sh[15] |
SH[1, 7] | unity_SHBg.w | sh[16] |
SH[2, 4] | unity_SHBb.x | sh[22] |
SH[2, 5] | unity_SHBb.y | sh[23] |
SH[2, 6] | unity_SHBb.z | sh[24] |
SH[2, 7] | unity_SHBb.w | sh[25] |
SH[0, 8] | unity_SHC.x | sh[8] |
SH[1, 8] | unity_SHC.y | sh[17] |
SH[2, 8] | unity_SHC.z | sh[26] |
[备注]本节运行效果参考DEMO_1的(2)
5 基于Unity球谐系数,使用标准球谐基底,手动构建一组新的系数
因为不了解Unity的光照烘焙具体细节,也就无从知晓其球谐系数的真实构成,如果要基于此系数进行深度话定制和修改有如无根之草,无源之水,非常不可考。所以这里尝试使用一般意义上的球谐基底组,可参考(23)式,采用(21)式的方式重新构建一套球谐系数:
private float[,] EvaluateRGB(List<Vector4> src_coefs)
{
float[,] coefs = new float[3, 9];
int w = 20;
int h = 20;
float da = 1.0f / ((float)w * (float)h); //differential of surface area
float addOnW = 1.0f / (float)w * 0.5f;
float addOnH = 1.0f / (float)h * 0.5f;
for (int face = 0; face < 6; ++face)
{
for (int j = 0; j < w; ++j)
{
for (int i = 0; i < h; ++i)
{
float px = (float)i + 0.5f;
float py = (float)j + 0.5f;
float u = 2.0f * ((float)i / (float)w) - 1.0f + addOnW;
float v = 2.0f * ((float)j / (float)h) - 1.0f + addOnH;
var pos = CubeUV2XYZW(u, v, face);
Color col = RebuildColorUnity(pos, src_coefs); //sample from origin data
col = col * da;
var Y = GetBase(pos);
for (int idx = 0; idx < 9; ++idx)
{
coefs[0, idx] += Y[idx] * col.r; //integration
coefs[1, idx] += Y[idx] * col.g;
coefs[2, idx] += Y[idx] * col.b;
}
}
}
}
return coefs;
}
简单解释下,方法入参就是上一节所讲的7个球谐向量,现在在C#里每个向量使用Vector4
表示,返回值是经过重新积分运算后得到的新的球谐系数。方法中的前3个大大的for循环本质是抽象了一个cubemap
的6个面,然后均匀得将每一个面拆分成为 w * h
个小正方面。我们定义每个小正方面中心点的几何坐标与原点(0,0,0)
的连续就是采样时使用的方向,将方向与传入的Unity球谐系数一起使用,模拟shader中的计算方式,可以轻易获得采样后的光照强度,记为col
。接下来重复(23)式的结论,将方向的xyz
传入GetBase
方法,经过简单运算获取L0、L1和L2共3阶9个球谐基函数的值,记为数组Y。最后一个for循环就是对当前朝向的光强进行积分的过程,参考(21)式将采样颜色col
(对应g)和球谐基底Y对应相乘后累加。
通过上述方法生成的球谐系数只是光强在基底上的投影,并不能直接投送到shader里调用ShadeSH9
方法解码。类似Unity的做法,为了简化shader部分的运算量,我们需要在传入shader之前,将系数与基函数的常数因子相乘:
static float fc0 = 1.0f / 2.0f * Mathf.Sqrt(1.0f / Mathf.PI);
static float fc1 = 2.0f / 3.0f * Mathf.Sqrt(3.0f / (4.0f * Mathf.PI));
static float fc2 = 1.0f / 4.0f * 1.0f / 2.0f * Mathf.Sqrt(15.0f / Mathf.PI);
static float fc3 = 1.0f / 4.0f * 1.0f / 4.0f * Mathf.Sqrt(5.0f / Mathf.PI);
static float fc4 = 1.0f / 4.0f * 1.0f / 4.0f * Mathf.Sqrt(15.0f / Mathf.PI);
List<Vector4> CalculateSHVarientNoUnity(float[,] sh)
{
List<Vector4> Y = new List<Vector4>();
for (int ic = 0; ic < 3; ++ic)
{
//each time loops on one color channel
//eg. deal on R channel -> (coef_1, coef_2, coef_3, coef_0)
Vector4 coefs = new Vector4();
coefs.x = fc1 * sh[ic, 1];
coefs.y = fc1 * sh[ic, 2];
coefs.z = fc1 * sh[ic, 3];
coefs.w = fc0 * sh[ic, 0];
Y.Add(coefs);
}
for (int ic = 0; ic < 3; ++ic)
{
Vector4 coefs = new Vector4();
coefs.x = fc2 * sh[ic, 4];
coefs.y = fc2 * sh[ic, 5];
coefs.z = fc3 * sh[ic, 6];
coefs.w = fc2 * sh[ic, 7];
Y.Add(coefs);
}
{
Vector4 coefs = new Vector4();
coefs.x = fc4 * sh[0, 8];
coefs.y = fc4 * sh[1, 8];
coefs.z = fc4 * sh[2, 8];
coefs.w = 1.0f;
Y.Add(coefs);
}
return Y;
}
可以很明显的看到,相对于之前展示的模拟Unity处理球谐系数的方法CalculateSHVairentMimicUnity
,上述方法引入了基于(23)式的球谐基函数常系数fc0
~ fc4
,然后与计算得到的球谐投影作用后被按序记录到了7组向量中,其间并没有任何令人费解的修正操作,完全按照wiki上的球谐公式(23)展开。
[备注]本节运行效果参考DEMO_1的(3)
6 球谐系数的压缩
本节一共尝试了2个级别的压缩方案,第一种比较简单,既只使用L0和L1阶的4组球谐系数编解码光照强度。在使用RGB编码颜色的前提下,共需要 4 * 3 = 12 个球谐系数,假设我们能把每个系数压缩到一个byte
大小,全部12个系数会占满3个uint32
大小的变量。
为了进一步提高压缩比率,可以采用第二种稍微激进点的方案:既保留L0的全部RGB通道的系数,但对于L1阶,只保留光的强度(lightness)分量,压缩后L1可以只用3个byte的球谐系数代表,而全部数据量变为 3 + 3 = 6 byte
,不到2个uint32
的大小。可以想象这种压缩方式必然会丢失大量色彩(Hue)方面的信息,但是许多情况下环境间接光照的主要贡献还是光线的亮度变化,这么想的话,方案二在保留了充分的亮度信息的前提下,将方案一的12 byte
代价直接减半为6 byte
,感觉值得一试。
那么我们来聊一聊方案二的具体实现步骤,为了获得亮度信息,我们首先需要了解基本的色彩编码转换公式,具体可以参考wiki文档,此处不再赘述。有了转换方法RGBConvertToHSV
后,紧接着是以新的色彩表示格式去重新积分一套球谐投影系数,具体方法参考如下:
private float[,] EvaluateHSL(List<Vector4> src_coefs)
{
float[,] coefs = new float[3, 9];
int w = 20;
int h = 20;
float da = 1.0f / ((float)w * (float)h);
float addOnW = 1.0f / (float)w * 0.5f;
float addOnH = 1.0f / (float)h * 0.5f;
for (int face = 0; face < 6; ++face)
{
for (int j = 0; j < w; ++j)
{
for (int i = 0; i < h; ++i)
{
float px = (float)i + 0.5f;
float py = (float)j + 0.5f;
float u = 2.0f * ((float)i / (float)w) - 1.0f + addOnW;
float v = 2.0f * ((float)j / (float)h) - 1.0f + addOnH;
var pos = CubeUV2XYZW(u, v, face);
Color col = RebuildColorUnity(pos, src_coefs);
col = col * da;
float H = 0, S = 0, L = 0;
RGBConvertToHSV(col, ref H, ref S, ref L);
var Y = GetBase(pos);
for (int idx = 0; idx < 9; ++idx)
{
coefs[0, idx] += Y[idx] * H;
coefs[1, idx] += Y[idx] * S;
coefs[2, idx] += Y[idx] * L;
}
}
}
}
return coefs;
}
这个方法与之前提到的EvaluateRGB
之间唯一的区别就是颜色空间变化,在积分前我们将颜色从RGB转换到了HSL,为的是第2到4组基函数里对应于L(lightness)的系数。
当系数提取完毕,我们需要简单打包一下新的球谐向量,此时的返回值只需要一个uint2
类型的向量即可:
Vector2Int CalculateSHL0RGBL1Lightness(float[,] sh_hsl, float[] rgb_L0)
{
Vector2Int target = new Vector2Int();
float _base = 10.0f;
uint d0 = 0;
for (int ic = 0; ic < 3; ++ic)
{
float coef = fc0 * rgb_L0[ic];
coef = coef > 0 ? coef : 0;
coef *= _base;
d0 = d0 | (uint)Mathf.Abs(coef * 255);
d0 = d0 << 8;
}
target.x = (int)d0;
uint d1 = 0;
for (int ic = 0; ic < 3; ++ic)
{
float coef = fc1 * sh_hsl[2, ic + 1];
coef *= _base;
coef = coef * 128 + 128;
coef = coef > 128 ? 128 : coef;
coef = coef < 0 ? 0 : coef;
d1 = d1 | (uint)coef;
d1 = d1 << 8;
}
target.y = (int)d1;
return target;
}
方法CalculateSHL0RGBL1Lightness
分别打包压缩了L0的RGB分量到一个uint32
变量的前3个byte
中,随后压缩了L1的光强系数到第二个uint32
的前3个byte
中。其中使用了一些小技巧可以和大家探讨下:对于L0来说,RGB三个颜色分量都是略微大于0的正值,压缩到一个 8bit
空间中不用担心会出现负值的情况,所以只管乘以255再取整即可,但是由于环境光找颜色普遍暗淡,为了避免压缩引起的误差,可以适当放大原始数值,在方法里就是 coef *= _base
这步处理(其实为了更好的性能,_base
最好是2的幂);而对于L1的投影系数,可以默认这些数值都是非常接近于0的分布在[-1, 1]
区间上的常量,因此不妨将255中的前128用于存放负值,后128用于存放正值,此外为了确保精度,我们同样使用系数_base
对待压缩数值进行放大处理。
解码L0和L1部分的工作当然是在shader内完成,这里放一个我用来测试的Fragment shader代码:
half4 frag(v2f i) : SV_Target
{
// sample the texture
half3 col;
float3 normal = normalize(i.normalWS);
half base = 10;
col.r = ((_SHL0 & 0xFF000000) >> 24) / 255.0 / base;
col.g = ((_SHL0 & 0x00FF0000) >> 16) / 255.0 / base;
col.b = ((_SHL0 & 0x0000FF00) >> 8) / 255.0 / base;
half coef_x = ((int)((_SHL1 & 0xFF000000) >> 24) - 128) / 128.0 / base;
half coef_y = ((int)((_SHL1 & 0x00FF0000) >> 16) - 128) / 128.0 / base;
half coef_z = ((int)((_SHL1 & 0x0000FF00) >> 8) - 128) / 128.0 / base;
half3 L1 = half3(coef_x, coef_y, coef_z);
half lightness = dot(L1, normal.xyz);
half rgb_min = min(min(col.r, col.g), col.b);
half rgb_max = max(max(col.r, col.g), col.b);
half multiplier = lightness * 2.0 / (rgb_max - rgb_min);
multiplier = clamp(multiplier, 0, 10);
col = col * multiplier;
return half4(col, 1.0);
}
值得探讨的是最后对lightness作用到最终球谐光照的处理方式:
multiplier = lightness * 2.0 / (rgb_max + rgb_min)
(24)
参考HSL编码中对L(Lightness)的定义:
既RGB编码中的最大值与最小值之和的一半,那么(24)的意思则是:我们等比放大或缩小L0中记录的 RGB 3个通道,直到缩放后的最大值与最小值之和正好等于2倍的Lightness,那么我们认为经过这个缩放乘子的处理后,解码后的颜色既保持了L0阶底色的特征,又具有L1阶所记录的亮度,是一种相对近似的还原。
当然上述方法的效果还未在大量实践中检测,所以作为保底,我们也得有一套逻辑上更加正确(也更昂贵)的方案:将L0的RGB转换到HSL空间,用L1阶解码得到的新Lightness替换其中的L,随后再反转回RGB空间输出。
[备注]本节方案一运行效果参考DEMO_1的(4),方案二采用(24)式逻辑的运行效果参考DEMO_1的(5)
7 DEMO
说明:
- 无光照环境下,球体使用urp标准Lit shader接收光照探针照射的效果;
- 使用Unity烘焙的球谐系数手动编码Unity_SH向量并在shader中手动解码的效果;
- 使用标注球谐基函数采样编码并在shader中解码的效果;
- 使用RGB空间下L0L1编解码的效果;
- 使用RGB空间下L0, HSL空间下L1,但只取用L分量的编解码效果。