【Siggraph2009】Light Propagation Volumes in CryEngine 3

今天介绍的是CryEngine3在Siggraph2009上分享的Light Propagation Volumes(LPV)动态全局光计算方案,这个方案至今依然被UE所使用,原文链接在参考部分给出。因为原文实在是太长了,这里输出的内容此次只会包含正文主体即LPV最相关的部分。

总结

LPV是一种实时计算的间接光方案,其实现总共分为四个步骤,分别是:

  1. RSM的生成,用作Secondary Light Source(Virtual Point Light),为了提升处理效率,生成的RSM需要进行一次Intensity-awared的降采样
  2. 根据RSM完成Light Propagation Volume的注入,即将每个VPL数据导入到LPV中的每个Cell中,并以SH系数来表示
  3. LPV的扩散跟传播,在注入之后将光照向着相邻Cell传播,从而做到任何一个Cell都是有数据的,这个过程需要进行多次迭代
  4. LPV的使用,将生成好的LPV 3D贴图用于光照计算

正文

LPV是动态光源下的间接光实时渲染方案,所谓的间接光指的是光源发射出来的光线经过多次(至少两次)反弹之后进入相机的部分。

光源发出的光线直接进入相机的叫自发光,这一部分计算比较简单,将光源沿着相机方向的radiance乘上衰减距离即可。

光源发出的光线打在场景上,之后经过反射进入相机的部分称为直接光照,这一部分的计算也比较简单,通常可以在实时渲染中完成。

相对于自发光部分与直接光照部分,间接光照部分是最为复杂的,因为计算消耗过高,通常只会放在离线的时候计算,将结果缓存下来,在运行时读取缓存来得到间接光效果,但是这种方式求得的间接光是静态的,无法得到动态光源的间接光效果。而实际上,间接光照中最重要的,贡献比例最大的是经过两次反射后进入相机的部分,而这里的LPV方案则是针对这一部分间接光提出的实时计算方案。

LPV算法主要包含四步,如下图所示:

  1. 生成Reflective Shadow Map(RSM),RSM的每个像素都可以看成是一个Virtual Point Light(VPL),而这里的VPL可以看成是原始光源的二级光源,即光源发出的光线打在场景中,被光线打中的位置都可以看成是一个新的点光源。

  2. 完成Radiance的注入,实现Radiance Volume Texture的初始化。将上一步中的每个VPL根据其世界坐标换算到Radiance Volume Texture中每个voxel对应的cell上,并将VPL的radiance换算成当前cell的SH系数存储在Radiance Volume Texture中。

  3. 完成Radiance的Propagation(传播),实现光照在Radiance Volume Texture的扩散传播。在上一步VPL注入后完成了Radiance Volume Texture初始化的基础上,使用相邻Cell的Radiance数据完成对当前Cell的Radiance的传递处理(这个过程其实就可以理解成光线的二次反射了,第一次反射对应的就是光线打在场景中生成RSM、VPL),这个过程是通过多次迭代完成的,每次迭代计算对六个方向的相邻Cell对当前Cell的影响与贡献,而每个相邻Cell对当前Cell的影响,可以归结为其对当前Cell上五个面的贡献(刨除与相邻Cell重叠的面,推测这么做的原因是,我们这里计算相邻Cell的贡献,实际上是计算相邻Cell发出的光从当前Cell的各个面的出射部分,而与相邻Cell共享的面显然是只有输入而没有输出的)。

  4. 使用Radiance Volume Texture完成场景的间接光照明。这里除了将light propagation volume按照经典的SH Irradiance volume方式添加到场景光照之外,还有很多其他的应用方式。沿着采样点的法线上半球面与cosine lobe的积分这个过程可以在运行时通过SH系数点乘来完成,从而实现从输入Radiance数据到Irradiance数据的转换,而后者则是Diffuse Reflection所需要的。

下面给出各个步骤的具体实现逻辑。

1. RSM生成

使用RSM的目的是为了得到各个光源的secondary light信息,虽然也有其他的方式可以得到这个信息,但是RSM是这些方式中最简单且高效的一种。

这里的RSM数据使用的是【RSM】Reflective Shadow Map中的方案,即包含了depth、normal、position以及flux数据。这种数据结构由于实现后续的secondary VPL的降分辨率聚类操作,同时对于后面的光照注入而言,也会十分方便。

为了得到相对准确的信息,直接生成的RSM分辨率会比较高,直接拿来用性能可能扛不住,这里的做法是对其进行一次下采样,这个采样并不是直接使用线性混合完成的,而是intensity-aware的,下面给出具体的实现逻辑代码,为了便于理解,在上面添加了注释,给出各个步骤的具体作用跟思路:

// Get The Light Propagation Cell Index According to The Texel's Position
half3 GetGridCell(const in half2 texCoord, const in float fDepth)
{
    // calc grid cell pos
    float4 texelPos = float4(texCoord * half2(2.h, -2.h) - half2(1.h, -1.h), fDepth, 1.f);
    float4 homogWorldPos = mul(g_invRSMMatrix, texelPos);
    return GetGridPos(homogWorldPos);
}

half GetTexelLum(const in RSMTexel texel)
{
    return Luminance(texel.vColor) * max(0.h, dot(texel.vNormal, g_lightDir.xyz));
}

// The DownSampling Pass Used To Improve Later Injection Pass's Performance
// The DownSampling Is Intensity-awared, Which Includes 2 Steps
// 1. Get The Brightest texel in The Filtering Range
// 2. Blend All The Texels in The Filtering Range with The Weight Related to Distance to The Brightest Texel.
RSMTexelOut IVDownsampleRSMPS(in IVDownsampleRSMPsIn In)
{
    // choose the brightest texel
    half3 vChosenGridCell = 0;
    {
        half fMaxLum = 0;
        for(int i=0;i<2;i++)
        {
            for(int j=0;j<2;j++)
            {
                half2 vTexCoords = In.texCoord + half2(i, j) * g_vSrcRSMSize.zw;
                RSMTexel texel = FetchRSM(vTexCoords);
                half fCurTexLum = GetTexelLum(texel);
                if(fCurTexLum > fMaxLum)
                {
                    vChosenGridCell = GetGridCell(vTexCoords, texel.fDepth);
                    fMaxLum = fCurTexLum;
                }
            }
        }
    }

    // fliter
    RSMTexel cRes = (RSMTexel)0;
    half nSamples = 0;
    for(int i=0;i<2;i++)
    {
        for(int j=0;j<2;j++)
        {
            half2 vTexCoords = In.texCoord + half2(i, j) * g_vSrcRSMSize.zw;
            RSMTexel texel = FetchRSM(vTexCoords);
            half3 vTexelGridCell = GetGridCell(vTexCoords, texel.fDepth);
            half3 dGrid = vTexelGridCell - vChosenGridCell;
            if(dot(dGrid, dGrid) < 3)
            {
                cRes.fDepth += texel.fDepth;
                cRes.vColor += texel.vColor;
                cRes.vNormal += texel.vNormal;
                nSamples++;
            }
        }
    }
    // normalize
    if(nSamples > 0)
    {
        cRes.fDepth /= nSamples;
        cRes.vColor /= 4;
        cRes.vNormal /= nSamples;
    }
    // output
    return cRes;
}

简单解释下,这里的intensity-awared的降采样算法的实现逻辑,就是以2x2作为一个filter range,将range中的每个像素投影到propagation volume的cell中,统计这几个像素对应的最大亮度的Cell,记录下来,之后以其他像素对应的Cell到此Cell的距离平方作为是否参与混合的条件,需要注意,最后颜色是除以4而非参与混合的样本数的(四个像素的面积,只有三个像素是有效的,当然要除以4,仔细一想也算合理)。

2. Light Propagation Volume的注入

Light Propagation Volume也称为Radiance Volume,是一张3D贴图,贴图中的每个voxel存储的是对应位置的Radiance Field(既然是Radiance,就表明是考虑了立体角的,也就是各个输出方向具有各自特有数值),而这个Field是通过SH的方式表达的。

那么每个Cell的SH系数要怎么求取呢?这里首先就是要先将RSM中每个像素表示的Secondary Light转换成SH系数表达的Radiance Field,之后将这个Field按照位置关系注入到对应的Cell中。

将VPL的Field注入到Volume Texture中是借用Point Based Rendering(PBR,跟常用的Physically based rendering不是同一个概念)实现的,每个VPL就相当于PBR中的一个Surfel,但是PBR中的Surfel要十分密集才能得到较为精确的结果,而这里RSM分辨率有限,因此结果是十分不连续的,因此需要做一些改动。由于这里的每个Surfel(可以理解成RSM中的每个像素作为一个采样点)相对于Cell的尺寸是十分小的,这里就没有必要像传统PBR一样,计算每个Surfel的位置跟朝向来累计出最终的渲染光照场,而是直接通过加权的方式将各个Surfel的贡献累计起来。

下面给出的代码是以方向光为标准进行的,其他光源的实现逻辑也是类似的,只是不同的是需要注意透视变换。此外,前面的RSM生成以及这一步的Injection都是针对每个光源进行一次的,且这两个过程消耗都十分的低,因此不会有性能问题,后续的步骤虽然消耗高,但是都是在这一步的基础上一次性的完成的,不再需要对每个光源都进行一次。

由于间接光本身的低频特性,这里只需要用二阶SH就能得到很好的模拟。如前面所说,这里需要将Surel(RSM中的每个像素)转换为以法线作为上方向的半球SH Lobe,这个过程有如下两步:
1. 首先,需要计算得到每个VPL所在位置的法线的SH系数向量(我们知道SH是用于表示球状信号分布的工具,也就是对于球面上的每个点或者说每个方向都有一个数值,这里对法线求取SH,实际上是否也就意味着除了法线所对应的方向是有数值的之外,其他方向上的数值都是0?但是从下面的计算某个Cone的ZH系数来看,这将angle=0传入,得到的ZH系数都是0,是不是意味着并不存在这样的SH投影?所以这里的法线的SH系数向量实际上是指法线方向上的cone的系数向量?从后面推断来看这个猜测应该是合理)。理论上来说,每个SH基函数的系数可以通过将信号与基函数积分得到,当然,实际上不是积分,而是求和,另外对于单个方向或者一个Cone而言,这个积分可以给出解析解,就没有必要通过数值方法求取了,各个基函数给出如下:

实际上法线本身就是一个方向,可以看成是一个ZH函数之后经过一个角度旋转得到的SH函数,也就是说,我们得到ZH函数之后,还需要对计算得到的系数还需要经过归一化,才能得到一个hemispherical lobe,归一化逻辑代码给出如下:

// Rotate ZH Coefficients with Direction
half4 SHRotate(const in half3 vcDir, const in half2 vZHCoeffs)
{
    // compute sine and cosine of thetta angle
    // beware of singularity when both x and y are 0 (no need to rotate at all)
    half2 theta12_cs = normalize(vcDir.xy);
    // compute sine and cosine of phi angle
    half2 phi12_cs;
    phi12_cs.x = sqrt(1.h - vcDir.z * vcDir.z);
    phi12_cs.y = vcDir.z;
    half4 vResult;
    // The first band is rotation-independent
    vResult.x = vZHCoeffs.x;
    // rotating the second band of SH
    vResult.y = vZHCoeffs.y * phi12_cs.x * theta12_cs.y;
    vResult.z = -vZHCoeffs.y * phi12_cs.y;
    vResult.w = vZHCoeffs.y * phi12_cs.x * theta12_cs.x;
    return vResult;
}

// Get The SH Coefficients of A Cone, Which Could Be Represented By ZH Coefficients
// If Its Direction Aligns with Up Vector
// Or We Need To Rotate It with Its Direction
half4 SHProjectCone(const in half3 vcDir, uniform half angle)
{
    static const half2 vZHCoeffs = half2(
    .5h * (1.h - cos(angle)), // 1/2 (1 - Cos[\[Alpha]])
    0.75h * sin(angle) * sin(angle)); // 3/4 Sin[\[Alpha]]^2
    return SHRotate(vcDir, vZHCoeffs);
}

// Get The SH Coefficients of A Direction, In Fact, It's A Cone Rather Than A Direction
// Assuming Angle Approximate 60 degrees
half4 SHProjectCone(const in half3 vcDir)
{
    static const half2 vZHCoeffs = half2(
        .25h, // 1/4
        .5h); // 1/2
    return SHRotate(vcDir, vZHCoeffs);
}

2. 在得到SH系数之后,还需要对其进行一个缩放,目的是考虑上VPL Surfel的贡献,这个缩放系数包含四个部分:

  1. 光源的强度
  2. VPL的Albedo颜色
  3. Surfel的强度
  4. Surfel的权重

其中:

而对应的是Surfel的贡献权重,这个权重需要考虑到RSM的面积以及Volume Cell的面积,而每个RSM中的每个Surfel覆盖的面积可以用如下公式来计算:

这个公式中t是RSM中的所有像素的数目,而则是整个RSM覆盖的面积,则是与光源方向垂直的Propagation Volume的切面的面积,这里为了简单处理,就假设这两个面积是相等。

而Volume中的每个Cell的覆盖面积可以通过如下公式计算:

最终的权重则可以通过如下公式计算得到:

也就是说,RSM的覆盖面积跟Propagation Volume的范围刚好重合的话,权重就跟Cell以及RSM单个像素的覆盖面积无关了。

上面给出的是单个Surfel转换为SH系数的方法,下面的代码给出了如何完成从RSM到Volume的注入,简单来说就是使用一个VS,每个顶点对应一个RSM的像素,完成相关数值的抽取,之后在PS中完成SH系数的计算与写入。

#define NORMAL_DEPTH_BIAS 0.25
#define LIGHT_DEPTH_BIAS 0.25
IVColorMapPsIn IVColorMapInjectionVS(const in IVColorMapVsIn In)
{
    IVColorMapPsIn Out;
    // get texture coords by vertex ID
    float2 texelPos = In.texelPos;
    Out.texCoord = texelPos;
    half2 screenPos = texelPos * float2(2, -2) - float2(1, -1);
    // sample depth and normal data with vertex shader texture look-up
    float depth = tex2Dlod(DepthVertexSampler, float4(Out.texCoord, 0, 0)).r;
    Out.normal = tex2Dlod(NormalVertexSampler, float4(Out.texCoord, 0, 0)).rgb * 2 - 1;
    // get world space position of the texel in the colored shadow map
    float4 homogGridPos = mul(g_injectionMatrix, float4(screenPos, depth, 1));
    float3 gridPos = homogGridPos.xyz/homogGridPos.w;
    // calc dir from original placement of pixel to this cell
    half3 gridSpaceNormal = normalize(TransformToGridSpace(Out.normal)) / g_GridSize.xyz;
    half3 alignedGridPos = gridPos;
    // shift injecting radiance towards the normal direction of the surfel
    alignedGridPos += gridSpaceNormal * NORMAL_DEPTH_BIAS;
    // shift injecting radiance toward the light direction
    alignedGridPos += g_dirToLightGridSpace.xyz * LIGHT_DEPTH_BIAS;
    // align depth of the texel to integer slice value
    alignedGridPos.z = floor(alignedGridPos.z * g_gridSize.z) / g_gridSize.z;
    Out.position = IVScreenPos(alignedGridPos);
    if(!IsPointInGrid(alignedGridPos))
    Out.position.xy = -2;
    return Out;
}

PS的代码原文中没有给出,这里借用参考文章中[3]的源码进行阐述:

// https://github.com/mafian89/Light-Propagation-Volumes/blob/master/shaders/lightInject.frag and
// https://github.com/djbozkosz/Light-Propagation-Volumes/blob/master/data/shaders/lpvInjection.cs seem
// to use the same coefficients, which differ from the RSM paper. Due to completeness of their code, I will stick to their solutions.
/*Spherical harmonics coefficients – precomputed*/


#define SH_C0 0.282094792f // 1 / (2sqrt(pi))
#define SH_C1 0.488602512f // sqrt(3/pi) / 2

/*Cosine lobe coeff*/
#define SH_cosLobe_C0 0.886226925f // sqrt(pi)/2
#define SH_cosLobe_C1 1.02332671f // sqrt(pi/3)
#define PI 3.1415926f

struct PS_IN 
{
    float4 screenPosition : SV_POSITION;
    float3 normal : WORLD_NORMAL;
    float3 flux : LIGHT_FLUX;
    uint depthIndex : SV_RenderTargetArrayIndex;
};

struct PS_OUT 
{
    float4 redSH : SV_Target0;
    float4 greenSH : SV_Target1;
    float4 blueSH : SV_Target2;
};

// SH Coefficents of Cosine
// see reference paper [4]
float4 dirToCosineLobe(float3 dir) 
{
    //dir = normalize(dir);
    return float4(SH_cosLobe_C0, -SH_cosLobe_C1 * dir.y, SH_cosLobe_C1 * dir.z, -SH_cosLobe_C1 * dir.x);
}

float4 dirToSH(float3 dir) 
{
    return float4(SH_C0, -SH_C1 * dir.y, SH_C1 * dir.z, -SH_C1 * dir.x);
}

PS_OUT main(PS_IN input)
{
    PS_OUT output;

    const static float surfelWeight = 0.015;
        // PI is the normalization factor, see reference paper [4]
    float4 coeffs = (dirToCosineLobe(input.normal) / PI) * surfelWeight;
    output.redSH = coeffs * input.flux.r;
    output.greenSH = coeffs * input.flux.g;
    output.blueSH = coeffs * input.flux.b;

    return output;
}

从代码可以看到,将VPL转换成SH系数,就是将VPL看成是随着角度成cos衰减的光源,得到cosine lobe的SH系数,之后按照VPL的法线进行旋转即可。

3. Light Propagation Volume的传播与扩散

上图给出了单个Cell向着相邻的六个Cell进行扩散的示意图,这里使用的是Scattering的策略,即将当前Cell的数据向着周围散射,但是这种方式就会出现一个问题是,某个Cell可能会同时被周围六个Cell对应的线程进行写入,从而存在执行低效的问题,因此更为常用的方式是如下图绿色区块对应的Gathering策略:

Gathering的伪码给出如下:

for_each cell
  for i from directions
    incoming_radiance_dir = get_radiance_over_face(cell.adjacent_cell[i], directions[i])
    cell.radiance += incoming_radiance_dir

由于在经过Injection阶段之后,每个Cell中的Radiance分布在运行时是不清楚的,因为Injection的时候是没有考虑RSM在Cell中的具体位置的,只知道这个VPL是在Cell中,因此这里只能将每个Cell看成是一个Cube光源,之后计算这个Cube光源对相邻Cell的每个face的输出Radiance来求得相邻Cell的平均Radiance。

为了得到可靠的效果,Propagation会经历几次迭代,每次迭代会使用之前的Volume数据从相邻的6个Cell中获取其Radiance数据,并将这个Radiance数据扩散到除了非共有面之外的其他五个面上。

在每轮迭代中,每个Cell的Radiance收集工作可以大致分成如下两步:

  1. 从相邻的Cell中读取Radiance数据,并将之投射到当前Cell的Face上面,计算从这个Face输出的Radiance的积分
  2. 将每个Face上的多个Radiance结果累加到一起,并将多个Face的Radiance总和累加到一起,作为从相邻Cell输入到当前Cell的Radiance。

迭代公式可以表示成:

公式中的P表示的是Propagation操作符,这个公式的意思就是,当前Cell的Radiance就是从相邻Cell的上一轮迭代结果经过扩散得到。

由于单次迭代得到的结果还比较差,因此通常会需要进行多轮迭代来优化效果,下图给出了多次迭代的效果图:

下面是Propagation的示意代码:

void IVPropagateDir(inout SHSpectralCoeffs pixelCoeffs,
const in IVSimulationPsIn In,
const in half3 nOffset)
{
    // get adjacent cell's SH coeffs
    SHSpectralCoeffs sampleCoeffs = SHSampleGridWithoutFiltering(In.gridPos, nOffset);
    // generate function for incoming direction from adjacent cell
    SHCoeffs shIncomingDirFunction = Cone90Degree(-nOffset);
    // integrate incoming radiance with this function
    half3 incidentLuminance = max(0, SHDot(sampleCoeffs, shIncomingDirFunction));
    // add it to the result
    pixelCoeffs = SHAdd(pixelCoeffs, SHScale(shIncomingDirFunction, incidentLuminance));
}

SHSpectralCoeffs IVSimulatePS(const in IVSimulationPsIn In)
{
    SHSpectralCoeffs pixelCoeffs = (SHSpectralCoeffs)0;
    // 6-point axial gathering stencil "cross"
    IVPropagateDir(pixelCoeffs, In, half3( 1, 0, 0));
    IVPropagateDir(pixelCoeffs, In, half3(-1, 0, 0));
    IVPropagateDir(pixelCoeffs, In, half3( 0, 1, 0));
    IVPropagateDir(pixelCoeffs, In, half3( 0, -1, 0));
    IVPropagateDir(pixelCoeffs, In, half3( 0, 0, 1));
    IVPropagateDir(pixelCoeffs, In, half3( 0, 0, -1));
    return pixelCoeffs;
}

由于原文给出的片段过于简介,作为参考,这里从EricPolman的博客中借用了其实现逻辑:

  1. 整个过程是放在CS中完成的
  2. 中间包含两个嵌套的for循环,外循环中针对的是6个相邻的Cell,内循环针对的是每个相邻Cell对当前Cell的5个Face(准确来说是4个,正对着相邻Cell的Face是放在循环之外的)
  3. 对于每个face而言
    3.1 首先是计算当前所处理的相邻Cell到对应Face的方向的SH系数,这个系数是用于跟相邻Cell的SH系数进行点乘以得到对应方向的Radiance的(相当于计算相邻Cell的VPL在对应方向上的光照强度)
    3.2 这个Radiance需要经过对应的固体角的加成,作为入射光源的Irradiance
    3.3 之后乘上reprojDirectionCosineLobeSH(考虑了Cosine Lobe衰减的SH系数)的SH系数,作为当前Face贡献给当前Cell的Irradiance。

cR += sideFaceSubtendedSolidAngle * dot(rCoeffsNeighbour, evalDirectionSH) * reprojDirectionCosineLobeSH;

#define LPV_DIM 32
#define LPV_DIMH 16
#define LPV_CELL_SIZE 4.0

int3 getGridPos(float3 worldPos)
{
    return (worldPos / LPV_CELL_SIZE) + int3(LPV_DIMH, LPV_DIMH, LPV_DIMH);
}

// https://github.com/mafian89/Light-Propagation-Volumes/blob/master/shaders/lightInject.frag and
// https://github.com/djbozkosz/Light-Propagation-Volumes/blob/master/data/shaders/lpvInjection.cs seem
// to use the same coefficients, which differ from the RSM paper. Due to completeness of their code, I will stick to their solutions.
/*Spherical harmonics coefficients – precomputed*/
#define SH_c0 0.282094792f // 1 / 2sqrt(pi)
#define SH_c1 0.488602512f // sqrt(3/pi) / 2

/*Cosine lobe coeff*/
#define SH_cosLobe_c0 0.886226925f // sqrt(pi)/2
#define SH_cosLobe_c1 1.02332671f // sqrt(pi/3)
#define Pi 3.1415926f

// SH Coefficents of Cosine
// see reference paper [4]
float4 dirToCosineLobe(float3 dir) 
{
    //dir = normalize(dir);
    return float4(SH_cosLobe_c0, -SH_cosLobe_c1 * dir.y, SH_cosLobe_c1 * dir.z, -SH_cosLobe_c1 * dir.x);
}

float4 dirToSH(float3 dir) 
{
    return float4(SH_c0, -SH_c1 * dir.y, SH_c1 * dir.z, -SH_c1 * dir.x);
}

// End of common.hlsl.inc

RWTexture3D lpvR : register(u0);
RWTexture3D lpvG : register(u1);
RWTexture3D lpvB : register(u2);

static const float3 directions[] =
{ 
    float3(0,0,1), 
    float3(0,0,-1), 
    float3(1,0,0), 
    float3(-1,0,0) , 
    float3(0,1,0), 
    float3(0,-1,0)
};

// With a lot of help from: http://blog.blackhc.net/2010/07/light-propagation-volumes/
// This is a fully functioning LPV implementation

// right up
float2 side[4] = 
{ 
    float2(1.0, 0.0), 
    float2(0.0, 1.0), 
    float2(-1.0, 0.0), 
    float2(0.0, -1.0) 
};

// orientation = [ right | up | forward ] = [ x | y | z ]
float3 getEvalSideDirection(uint index, float3x3 orientation) 
{
        // 5 = 1^2 + 2^2,side face center - neighbor cell center
    const float smallComponent = 0.4472135; // 1 / sqrt(5)
    const float bigComponent = 0.894427; // 2 / sqrt(5)
    
    const float2 s = side[index];
    // *either* x = 0 or y = 0
    return mul(orientation, float3(s.x * smallComponent, s.y * smallComponent, bigComponent));
}

float3 getReprojSideDirection(uint index, float3x3 orientation) 
{
    const float2 s = side[index];
    return mul(orientation, float3(s.x, s.y, 0));
}

// orientation = [ right | up | forward ] = [ x | y | z ]
float3x3 neighbourOrientations[6] = 
{
    // Z+
    float3x3(1, 0, 0,0, 1, 0,0, 0, 1),
    // Z-
    float3x3(-1, 0, 0,0, 1, 0,0, 0, -1),
    // X+
    float3x3(0, 0, 1,0, 1, 0,-1, 0, 0
    ),
    // X-
    float3x3(0, 0, -1,0, 1, 0,1, 0, 0),
    // Y+
    float3x3(1, 0, 0,0, 0, 1,0, -1, 0),
    // Y-
    float3x3(1, 0, 0,0, 0, -1,0, 1, 0)
};

[numthreads(16, 2, 1)]
void main(uint3 dispatchThreadID: SV_DispatchThreadID, uint3 groupThreadID : SV_GroupThreadID)
{
    uint3 cellIndex = dispatchThreadID.xyz;

    // contribution
    float4 cR = (float4)0;
    float4 cG = (float4)0;
    float4 cB = (float4)0;

    for (uint neighbour = 0; neighbour < 6; ++neighbour)
    {
        float3x3 orientation = neighbourOrientations[neighbour];
        // TODO: transpose all orientation matrices and use row indexing instead? ie int3( orientation[2] )
        float3 mainDirection = mul(orientation, float3(0, 0, 1));

        uint3 neighbourIndex = cellIndex – directions[neighbour];
        float4 rCoeffsNeighbour = lpvR[neighbourIndex];
        float4 gCoeffsNeighbour = lpvG[neighbourIndex];
        float4 bCoeffsNeighbour = lpvB[neighbourIndex];

        const float directFaceSubtendedSolidAngle = 0.4006696846f / Pi / 2;
        const float sideFaceSubtendedSolidAngle = 0.4234413544f / Pi / 3;

        for (uint sideFace = 0; sideFace < 4; ++sideFace)
        {
            float3 evalDirection = getEvalSideDirection(sideFace, orientation);
            float3 reprojDirection = getReprojSideDirection(sideFace, orientation);

            float4 reprojDirectionCosineLobeSH = dirToCosineLobe(reprojDirection);
            float4 evalDirectionSH = dirToSH(evalDirection);

            cR += sideFaceSubtendedSolidAngle * dot(rCoeffsNeighbour, evalDirectionSH) * reprojDirectionCosineLobeSH;
            cG += sideFaceSubtendedSolidAngle * dot(gCoeffsNeighbour, evalDirectionSH) * reprojDirectionCosineLobeSH;
            cB += sideFaceSubtendedSolidAngle * dot(bCoeffsNeighbour, evalDirectionSH) * reprojDirectionCosineLobeSH;
        }

        float3 curDir = directions[neighbour];
        float4 curCosLobe = dirToCosineLobe(curDir);
        float4 curDirSH = dirToSH(curDir);

        int3 neighbourCellIndex = (int3)cellIndex + (int3)curDir;

        cR += directFaceSubtendedSolidAngle * max(0.0f, dot(rCoeffsNeighbour, curDirSH)) * curCosLobe;
        cG += directFaceSubtendedSolidAngle * max(0.0f, dot(gCoeffsNeighbour, curDirSH)) * curCosLobe;
        cB += directFaceSubtendedSolidAngle * max(0.0f, dot(bCoeffsNeighbour, curDirSH)) * curCosLobe;
    }

    lpvR[dispatchThreadID.xyz] += cR;
    lpvG[dispatchThreadID.xyz] += cG;
    lpvB[dispatchThreadID.xyz] += cB;
}

4. Shading & Lighting逻辑

这一节介绍的是在渲染中如何使用Volume Texture来得到间接光效果的具体细节,在前向渲染中,对于每个像素而言,可以根据像素的位置获取对应位置的Voxel数据来添加间接光照,在延迟渲染中,则可以直接绘制一个volume primitive,之后在PS中计算每个像素的间接光照并将这部分添加到light accumulation buffer中,由于CryEngine使用的是延迟管线,因此这里介绍主要以后者作为蓝本。

这里需要注意的是,硬件如果直接支持Volume Texture,那么算法是会有较大性能提升的,因为这里省去了手动计算的三线性插值的计算消耗,此外还可以利用硬件的Cache机制提升效率。

另外,还需要注意的是,如果直接使用硬件的三线性插值混合,得到的结果是会有bleeding的,这里有一些方法是可以规避这个问题的。

其他

CryEngine的原文中,除了上述LPV的基本框架之外,还介绍了很多其他的内容,包括LPV的多种不同的应用、LPV用于光滑反射以及LPV的问题及规避方法等内容,这里由于时间关系就不做展开了,各位有兴趣的请移步原文。

参考

[1] Light Propagation Volumes in CryEngine 3
[2] 【论文复现】Cascaded Light Propagation Volumes for Real-Time Indirect Illumination
[3] Light Propagation Volumes
[4] Light Propagation Volumes - Annotations By Andreas Kirsch

你可能感兴趣的:(【Siggraph2009】Light Propagation Volumes in CryEngine 3)