今天介绍的是CryEngine3在Siggraph2009上分享的Light Propagation Volumes(LPV)动态全局光计算方案,这个方案至今依然被UE所使用,原文链接在参考部分给出。因为原文实在是太长了,这里输出的内容此次只会包含正文主体即LPV最相关的部分。
总结
LPV是一种实时计算的间接光方案,其实现总共分为四个步骤,分别是:
- RSM的生成,用作Secondary Light Source(Virtual Point Light),为了提升处理效率,生成的RSM需要进行一次Intensity-awared的降采样
- 根据RSM完成Light Propagation Volume的注入,即将每个VPL数据导入到LPV中的每个Cell中,并以SH系数来表示
- LPV的扩散跟传播,在注入之后将光照向着相邻Cell传播,从而做到任何一个Cell都是有数据的,这个过程需要进行多次迭代
- LPV的使用,将生成好的LPV 3D贴图用于光照计算
正文
LPV是动态光源下的间接光实时渲染方案,所谓的间接光指的是光源发射出来的光线经过多次(至少两次)反弹之后进入相机的部分。
光源发出的光线直接进入相机的叫自发光,这一部分计算比较简单,将光源沿着相机方向的radiance乘上衰减距离即可。
光源发出的光线打在场景上,之后经过反射进入相机的部分称为直接光照,这一部分的计算也比较简单,通常可以在实时渲染中完成。
相对于自发光部分与直接光照部分,间接光照部分是最为复杂的,因为计算消耗过高,通常只会放在离线的时候计算,将结果缓存下来,在运行时读取缓存来得到间接光效果,但是这种方式求得的间接光是静态的,无法得到动态光源的间接光效果。而实际上,间接光照中最重要的,贡献比例最大的是经过两次反射后进入相机的部分,而这里的LPV方案则是针对这一部分间接光提出的实时计算方案。
LPV算法主要包含四步,如下图所示:
生成Reflective Shadow Map(RSM),RSM的每个像素都可以看成是一个Virtual Point Light(VPL),而这里的VPL可以看成是原始光源的二级光源,即光源发出的光线打在场景中,被光线打中的位置都可以看成是一个新的点光源。
完成Radiance的注入,实现Radiance Volume Texture的初始化。将上一步中的每个VPL根据其世界坐标换算到Radiance Volume Texture中每个voxel对应的cell上,并将VPL的radiance换算成当前cell的SH系数存储在Radiance Volume Texture中。
完成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共享的面显然是只有输入而没有输出的)。
使用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的贡献,这个缩放系数包含四个部分:
- 光源的强度
- VPL的Albedo颜色
- Surfel的强度
- 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收集工作可以大致分成如下两步:
- 从相邻的Cell中读取Radiance数据,并将之投射到当前Cell的Face上面,计算从这个Face输出的Radiance的积分
- 将每个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的博客中借用了其实现逻辑:
- 整个过程是放在CS中完成的
- 中间包含两个嵌套的for循环,外循环中针对的是6个相邻的Cell,内循环针对的是每个相邻Cell对当前Cell的5个Face(准确来说是4个,正对着相邻Cell的Face是放在循环之外的)
- 对于每个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