球谐光照(spherical harmonic lighting)解析

发表于2018-03-18
评论1 1.45w浏览
球谐光照实际上就是将周围的环境光采样成几个系数,然后渲染的时候用这几个系数来对光照进行还原,这种过程可以看做是对周围环境光的简化,从而简化计算过程。为了让大家对球谐光照更加熟悉,下面就和大家介绍下。

1. 球谐光照

球谐光照在现代游戏图形渲染领域应用很广,用于快速的模拟复杂的实时光照,例如unity中的light probe以及一些不重要的实时光源,可以用球谐光照快速的计算。球谐光照的优点是运行时的计算量与光源的数量无关,如果参数足够却可以较好的模拟实时的光照结果。

球谐光照的原理不仅涉及图形学,概率论,信号分析,微积分等大量复杂数学公式,这里对这个球谐光照的背景和应用做个最简单的理解,原谅我这里对数学公式的阐述不够严谨。

2.拟合思想和球面调谐spherical harmonic

正交基底函数
了解信号处理中时域到频域转换的知道,有个叫做傅里叶变换的东西,即任何函数f(x),我们都可以把他写成一组三角函数乘上系数之后的合,即F(x) = Σ c * f(x),这里面f(x)即是一组正交的基地函数,这些基地函数是正交的意味着他们之间互相积分永远是0,就跟空间3维坐标系下使用的x,y,z三个分量一样,他们的任意组合即可以表示任意复杂的函数F(x),因为f(x)是无限多的,所以只要我们取足够多的基地函数,求和出来的结果总是和F(x)相拟合的,这给我们提供了一种计算复杂的函数F(X)的思路,即找到能够拟合它的这组正交基f(x)的每个的参数c,从而曲线救国算出F(X)。那们怎么计算这些正交基的参数c呢,我们通过概率论的思想,预先通过正确的函数计算算出一堆F(X)的值,即一堆采样值,然后绘制一个基于采样值的F(X)的曲线,然后用曲线分别与每个基地函数f(x)相乘后算积分,即得到了c(积分就是在一个轴上的投影,通过F(X)往f(x)上投影就得到了f(x)对F(X)的贡献量)。我们有了这些参数之后,就可以对任意的x求出F(X)了。

回顾一下这个过程,为了求F(x),而F(x) = Σ c * f(x),我们选定了一组基底函数f(x)逼近它,但是不知道c,我们采样一堆正确的x到F(X)的值绘制曲线,同每个f(x)相乘积分,求出c,这样通过Σ c * f(x)就可以求出任意x的函数值。

这是一个用有限的采样点去估计所有定义域上的值的方法,用有限估计无限的思想,只要我们的采样越多,我们选定的基底函数越多,我们拟合的准确度就越高,一般这些基底函数会有一些权重的排序,一般权重在前面的就是比较重要的基底函数,也被称为低频成分,它定了一个F(X)的基调,通常第一个基底函数模拟的就是一个平均值,越后面的就是高频部分,它展现了这个函数F(X)的一些噪声部分。

球面调谐
ok,我们有了正交基底函数的概念,那么就可以拓展到球面调谐函数了。上面的F(X)是在直线x轴上的 ,而渲染中的函数分布一般是基于球面的(下面会提到),球面函数一般可以表述为F(θ,φ),连个参数是一个规范化球在两个轴的夹角。如何向上面F(X)确定它的正交基底呢。球面函数的也有例如傅里叶展开一样的东西,叫做勒让德多项式(Legendre Polynomials),勒让德多项式里的基地函数分为几组,每组有一个index,其中index越高,频率越高,每个组不是单独的一个基底,第n组有(n+1)²  - 1个基地函数组成,用y(l,m)代表第l组里面的第m个基底,所有的基底都是正交的,利用勒让德多项式可以得到球面函数的基底的定义是
其中l是基底band的index,m是这组band中的index, θ φ是球面点的参数,k是个都放,P是勒让德多项式。

上面的球面调谐函数是一组组的正交基底,用于模拟球面上的函数,可以简单认为是球面函数的傅里叶展开项。

这里有图可以直观的看出不同组的球面调谐基底是球面图像
band 0里各个位置是一样的,可以认为是个平均值,而后面的band越高,越表示了高频信息。

我们可以只选用l0的一个基地,那就是得到原球面函数的一个平均值,我们也可以进一步选择l0到l1的4个基底,这样就增加了一些对原函数的拟合。

而这张图展示了左面的这三种球面函数,选用不同的band 对原函数的拟合情况。

3.球谐光照

有了球谐函数的知识,有可以很好的理解球谐光照的计算。

在光照计算中,很多种计算是球面公式,一个经典的全局光照的光照公式是

首先基于物理的模型中我们认为从一个表面点反射到眼睛的不是一条光线,而是一小块球面的光锥。该点的颜色即是这小块球面的光锥的光子能量。

其中L代表从物体表面X点反射过来的w方向的光,Le代表这个点在w方向的自发光,后面是在这个点附近的这个小球面上的积分,积分的内容有四项,fr代表光线从入射方向经过这点之后的传递分布,即这点的BRDF,L代表在光线追踪过程中在光路上对点x有影响的另一个表面x1处传递过来的光线,G代表传递过来光线的那个点x1和x之间的几何关系(决定它传递过来能量的多少),V代表对于X来说X1是否是可视的(代表遮挡关系)。

也就是说对于物体表面的一个点,这个点反射过来的光线是一个小的球面区域内的自发光,光线追踪过程中的其他入射光线的能量,BRDF,遮挡关系和几何关系决定的。

这里面对于gi的计算是一个典型的球面函数。我们很难实时的根据这个公式去计算一个点的光照,因为这个太复杂了,但是通过球面调谐函数,我们可以通过找到几组简单的基底函数,来尽量拟合这个过程。

例如在光照条件固定的情况下,我们可以对每个空间点附近的一个球面区域去真实的用公式计算一些采样点的值,然后按照前面章节用光照公式的采样曲线同选定的几组球谐函数正交基底积分算出每个正交基底的参数,最后利用这些正交基底,即可以求出空间点球面上任意一个位置的光照,这就是光照探头的基本思想,即在光照固定的情况下先用真实光照方程采样,降低维度,用一组少量参数去计算任意位置的光照。

4.Unity中的球谐光照应用

unity中至少在光照探头和前向渲染的大量顶点光照这两个地方上使用了球谐光照的技术。

光照探头:
unity的光照探头在烘焙的时候为每个探头点附近采样光照值,然后计算每个点的球谐函数基底系数,用于运行时对于动态物体计算当前点的烘焙时的全局光照。

前向渲染中的实时的顶点光照:
前向渲染中光源数量太多,会降低运行效率。unity的正向渲染严格控制了光照的运算数量,具体的规则是,最亮的那盏直线光一定是像素光,其他标记了important的光源在数量不超过settimng里面pixel count的情况下是像素光,否则是顶点光,unity对于第一盏最亮的直线光在第一个bass pass 计算,并计算阴影,然后选择4盏顶点光在也在第一个pass同时计算,对于其他的像素光每个多加一个额外的add pass,对于再剩下的那些顶点光则按照球谐光照的方式在一个bass pass计算。这里面可以认为超过了一定限制的光最后都变成了球谐光照 。可以认为只要你设置了pixel count的限制,你打再多的光也不会把性能拖垮,因为最终他们会转变为球谐光照来伪实现实时光照。这里面的球谐光照的做法可以认为是当场景光源每次变化时将重新在场景上采样一个大的球面,然后计算球谐基底系数,因为这时候采样已经完全忽略了光源的位置,所以在unity中过多的实时的位置光将失去位置信息。

我们可以简单看一下unity的shader,指出它对球谐光照的运算。
对于unity内置shader的vertex shader中有一个VertexGIForward函数,里面处理了发生在顶点时的光照,它只发生在base pass里面。
inline half4 VertexGIForward(VertexInput v, float3 posWorld, half3 normalWorld)
{
    half4 ambientOrLightmapUV = 0;
    // Static lightmaps
    #ifdef LIGHTMAP_ON
        ambientOrLightmapUV.xy = v.uv1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
        ambientOrLightmapUV.zw = 0;
    // Sample light probe for Dynamic objects only (no static or dynamic lightmaps)
    #elif UNITY_SHOULD_SAMPLE_SH
        #ifdef VERTEXLIGHT_ON
            // Approximated illumination from non-important point lights
            ambientOrLightmapUV.rgb = Shade4PointLights (
                unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
                unity_LightColor[0].rgb, unity_LightColor[1].rgb, unity_LightColor[2].rgb, unity_LightColor[3].rgb,
                unity_4LightAtten0, posWorld, normalWorld);
        #endif
        ambientOrLightmapUV.rgb = ShadeSHPerVertex (normalWorld, ambientOrLightmapUV.rgb);
    #endif
    #ifdef DYNAMICLIGHTMAP_ON
        ambientOrLightmapUV.zw = v.uv2.xy * unity_DynamicLightmapST.xy + unity_DynamicLightmapST.zw;
    #endif
    return ambientOrLightmapUV;
}

这里面Shade4PointLights 是同时处理四盏顶点光,后面的ShadeSHPerVertex 则是计算顶点的球谐光照
half3 ShadeSHPerVertex (half3 normal, half3 ambient)
{
    #if UNITY_SAMPLE_FULL_SH_PER_PIXEL
        // Completely per-pixel
        // nothing to do here
    #elif (SHADER_TARGET < 30) || UNITY_STANDARD_SIMPLE
        // Completely per-vertex
        ambient += max(half3(0,0,0), ShadeSH9 (half4(normal, 1.0)));
    #else
        // L2 per-vertex, L0..L1 & gamma-correction per-pixel
        // NOTE: SH data is always in Linear AND calculation is split between vertex & pixel
        // Convert ambient to Linear and do final gamma-correction at the end (per-pixel)
        #ifdef UNITY_COLORSPACE_GAMMA
            ambient = GammaToLinearSpace (ambient);
        #endif
        ambient += SHEvalLinearL2 (half4(normal, 1.0));     // no max since this is only L2 contribution
    #endif
    return ambient;
}

再看其中具体球谐计算的几个函数
// 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);
#   ifdef UNITY_COLORSPACE_GAMMA
        res = LinearToGammaSpace (res);
#   endif
    return res;
}

这里根据不同的情况会选择不同数量的球谐基底,其中ShadeSH9 是选择了全部的L0-L2的三组共9个系数,而在unity的实现中每组系数需要3个参数,这样全部的sh9就需要27 各参数,unity将其封装在7个rgba的color中传到shader里,这7个rgba在unity的shade中分别用unity_SHAr unity_SHAg unity_SHAb unity_SHBr unity_SHBg unity_SHBb unity_SHC来表示
而上面的SHEvalLinearL0L1 表示只用L0L1的四组基底的球谐函数,half3 SHEvalLinearL2 表示只选用L2的五组基底的球谐函数。

在某些高品质的情况下会开启UNITY_SAMPLE_FULL_SH_PER_PIXEL这个宏,这时候球谐光照计算将像素级别的在base pass里处理。

上面还要说明的是,unity传递给shader的这组基底系数,是综合了当前点的bake的光照探头(包括了ambient)+超过限制的实时顶点光照的,所以一次在base pass里面的球谐光照计算就可以快速的算出这个动态物体表面的的大量的实时点光和烘焙全局光。

利用unity的球谐光照机制以及上面的unity_SHAr unity_SHAg unity_SHAb unity_SHBr unity_SHBg unity_SHBb unity_SHC这组shader的参数可以解决一些问题,例如unity默认不去支持不同光照探头处的物体的instance,这里面我们就可以通过自己拿到这组值最为perinstancedata 传递给shader。

在script中如何拿到某个位置的球谐函数基底参数?通过unity的接口LightProbes.GetInterpolatedProbe即可以拿到场景某处的这组9*3的L0-L2的基底系数,他是一个结构体SphericalHarmonicsL2,访问它的3*9的数组可以把他封装成7个rgba的color。他还有动态增加光源的一些接口

参考论文
http://silviojemma.com/public/papers/lighting/spherical-harmonic-lighting.pdf
来自:http://blog.csdn.net/leonwei/article/details/78269765

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引