今天一起来学习下UE官方在Siggraph 2012上关于UE升级到UE4的一些技术分享,这里给出原文链接。
从UE3升级到UE4,主要做了如下一些改动:
- 将编辑器的Renderer API统一升级到了DX11
- 增加了一些研究性质的内容(DX11, deferred shading,tessellation等)
- 增加了特定平台的专属缓存数据逻辑DDC(Derived Data Cache),允许一次cook,多人使用(降低cook消耗)
下面是一些详情细节。
尝试将Cyril Crassin利用voxel cone tracing(简称VCT)实现间接光照的技术集成到UE4中,这里测试使用的场景是修改过的crytek engine的Sponza场景。
在进入VCT实现细节之前,先来看一下相关的一些概念。VCT方案与Simon Stegmaier等人在05年提出的Volume Ray Casting(简称VRC)技术有很多相似的地方。
VRC通过在GPU(算力高于CPU,且进化速度快于CPU)上投射射线的方式来计算各个点对当前物体的光照加成。
屏幕上的每个像素点接收到的全局光照计算,只投射一条从相机原点到屏幕像素对应点的射线,整个过程通过ray marching实现,ray marching的实际计算出发点为射线与物体(这里的物体指的是当前屏幕像素所从属的物体)对应的volume bounding box的交点,这是因为距离物体过远的地方的光照对物体上全局光照的贡献基本可以忽略,而后面采样所需要的3D贴图(每个物体一张?)也是在volume bounding box范围内计算得到的,之后根据待照明的物件的尺寸自适应的选择在射线上的采样步长(物体尺寸越大,步长越大,保证每个物体采样数目基本一致?),每次采样都需要读取对应的radiance数值与occlusion数值(从3D贴图中采样),当遇到障碍物(即射线已经到头了)或者射线长度达到一定尺寸之后停止marching,之后将marching过程中采样得到的color数值以及occlusion(不透明度)叠加起来用于近似模拟这条射线上的全局光照。
VRC方案的不足在于射线数目较少(即每个像素值采样了到相机位置的单条射线),并不能很好的表示全局光的效果,而VCT方案则是在此基础上做了进一步的改进,使用多条射线来实现对多个方向的采样,不过射线数目越多,消耗越高,硬件不一定吃得消,VCT这里用了一个很取巧的方法,达到了单条射线的计算消耗得到多条射线采样的结果。
VCT的具体做法是将需要采样的多条射线对应的形状用一个椎体框出来(这就是cone的由来),之后根据每个采样点对应的cone的半径计算对应的cubemap 的mipmap的层级,从而通过一次采样,得到多个累加结果,为了保证每次只需要进行一次读取,在marching的过程中需要同时增加步进的尺寸,如下图所示:
将VCT用于全局光计算,其基本思路跟Ray tracing(以下简称RT)方案是很相似的,计算结果由diffuse跟specular两部分组成,其中:
Diffuse部分:
- 根据当前待计算像素对应的法线方向,确定各个cone的方向(以法线为中心呈半球散开)
- 各个cone的开口角度跟cone的数目有关,数目越大,开口角度越小
Specular部分:
- cone方向是根据eye vector沿着法线方向反射计算得到
- cone的开口则跟高光公式中的指数常量有关
这种思路实现的全局光照虽然没有ray tracing方案那样精准,但是其也有着ray tracing方案所不能及的优点:
- 相交检测的计算量大大降低
- 由于是通过3D贴图采样得到,且由于硬件的自带滤波采样算法,因此最终输出的结果不会像ray tracing方案那么噪声斑斑
- 在LOD方面有着天然的优势
《怪物史莱克2》中使用的是Crassin在11年发表的文章中介绍的VCT技术,只考虑一次反射的间接光数据,可以做到快速计算,且也更有利于美术同学把控效果。
但是,实际上从性能的角度考虑,Crassin方案中还有一些可以改进的地方:
- 体素的分辨率还可以进一步降低
- 可以将原始方案中scatter计算模式(计算一个像素,需要进行多次采集)替换成Gather模式(将一次采集应用到多个像素);虚幻这边的实现方案是对靠近光源处的叶子体素进行遍历,在每个体素上,对光照进行一遍gather处理(具体细节有待补充);原始方案则是对每个光源进行处理,将光源发出的光子数据不断往外散射(scatter),并在这个过程中对体素数据进行计算;Gather方案相对于原始方案的优点在于参与计算的体素数目更少了,因此计算消耗得到降低(远离光源的其他体素的数据是如何计算的,这里需要补充)
- 根据像素的特点调整采样算法,加强对采样结果的重用力度(具体方案可能需要先了解完Crassin的具体做法之后再回来补充)
这样一套实施方案其实还有一些额外的优点:
- 可以不需要额外的代价就能够实现带阴影的IBL
- 面光源的软阴影的计算消耗将是固定不变的(不受物件复杂度以及光源数目的影响,毕竟输出的结果是全局光效果,自然包含了各种输入光源的影响,因而无需再添加额外的计算消耗)
前面说的都是VCT的好处,下面一起来看下VCT的缺点跟难处:
- 由于需要对物体进行体素化,当物体比较纤细的时候,体素化可能会有问题(有什么问题?会因为角度的不同,体素化结果会出现变化导致锯齿,同时由于太过纤细可能在体素化过程中被略过导致数据缺失)
- cone的角度选取也很有挑战:如果角度过大会导致数据精确度下降,结果误差较大;如果角度过小,又会导致计算消耗增加,性能下降。
- mipmap需要考虑方向(这里需要补充说明,是计算过程需要考虑方向,还是使用时需要考虑方向?以specular为例,从不同的方向取用同一个volume map的同一个voxel,理论上需要得到不同的结果,但是正常的volume map是无法做到这一点的)
- 从三角面片中完成体素化是一件高消耗的事情,考虑到实时性的要求,整个实现过程就需要一套非常优秀的算法
- 由于整套方案都是运行时使用的,其中包含了大量的内存操作,因此需要一套非常完善的内存管理方案,避免内存问题(高占用,碎片化等)
- 体素化与射线检测都是在GPU上完成的(存疑),而GPU资源是有限的,需要尽可能的降低GPU的消耗
- 需要给出一套可行的稀疏数据结构。3D贴图虽然从理论上是可行的,但是如果考虑到内存占用的话,即使是一张普通的小地图,都有可能因为内存超标而崩溃。
稀疏voxel八叉树结构通过一个映射函数可以实现全局坐标到局部坐标的映射,整个八叉树数据全部存放在GPU上,此外,根据坐标我们可以得到对应node或者leaf的数据,这些数据包含:
- 2x2x2个voxel数据,相当于每个node上的八个顶点数据吧
- 6份的3x3x3个voxel数据,这里没有详细介绍这些数据对应的是什么,但是可以推测一下,6应该对应于一个node的6个面(实际上是6个观察方向,因为由于光照的不可穿透性,在不同的方向上,光照数据可能是不一样的),而3x3x3相当于在2x2x2的基础上对每个维度进行了加一处理,如果2x2x2是单个leaf node的数据,那么3x3x3就是8个leaf node(也可以看成是leaf node的parent node用leaf node来表示)的voxel数据,总结起来看,如果之前的2x2x2的voxel数据是level 1(粗粒度)8个顶点上的voxel数据,那么这里就对应于当前node的6个face上的相邻node在level 0(细粒度)上的数据。
体素光照管线总的来说可以分成如下几步:
- 体素化,这个过程是按需进行的,比如静态物件是在加载的时候触发的,而动态物件则是在发生变化后需要触发这个过程进行更新;最终的结果是将三角面片表示的mesh转化为体素(3D贴图)表示的数据,其中包含了基色、自发光、透明度以及法线等数据。除了这一步之外,剩下的功能逻辑则是每帧都会执行的。
- 光照计算,根据光照信息(光源位置、颜色、光强计算函数以及shadow map信息)对体素进行光照计算,只是这里有个问题是,由于体素是八叉树结构存储的,因此这里的光照是在哪个level(或者说mip)下进行的?如果是在叶子节点上计算,那么计算性能就会是一个问题,而如果在其他level上进行,那么选择哪个level才能保证计算的结果不会出现瑕疵又会是一个问题。从VCT的原文来看,这里的光照应该是发生在叶子节点一层,由于光照计算范围是有限的,且整个计算过程放在GPU上,可以保证很好的并行性,所以整体的计算消耗是可控的?
- 对叶子节点层级进行filtering,不过这里为了解决前面提出的mipmap的方向问题,filtering过程会是directionally dependent(具体过程后面可以扫一下UE的代码做一下分析)
- Finalize,创建redundant data,并将之写入到volume texture,这里的redundant data指的是border上的重叠数据。
上面这张图是整个算法的总纲,下面看下实现细节。
体素化
voxel geometry data的创建
对于一个给定的region而言,在约定了体素化的粒度的情况下,我们可以将这个region的mesh数据写入到八叉树表达的体素结构中。
每个体素包含有2x2x2个材质属性以及法线等信息,至于这里的2x2x2是从何而来还不得而至,根据前面总纲里的内容推测:基色+自发光+透明度应该才7个属性才是。另外一个可能是2x2x2对应的是一个voxel上的8个顶点,每个顶点有一套材质数据,不过如果是这样的话,由于顶点数据是可以被多个voxel共享的,那么这里直接使用2x2x2也不合理?
Region的重新体素化
在如下的一些数据发生变化的时候,我们需要对当前region进行重新体素化
- 几何数据变更
- 材质数据变更
- 分辨率变更(屏幕分辨率还是体素分辨率?感觉不重要,两者应该是正相关的,即任一分辨率上升都会导致另一分辨率的提升),结合后面的描述来看,指的是某个region应该享有的分辨率,比如距离相机越近,其应该享有的分辨率就应该越高(Colors: grey: no voxel data, red: high, yellow: medium, green: low)
少量动态物体下的优化
在场景基本上没有动态物体的情况下,可以做一些优化处理来提升性能:
- 只在需要的时候才进行体素化
- 静态数据跟动态数据进行分离(原文也是这种思想)
整个体素化可以分成两个pass:
PS阶段
在PS阶段,会分别从三个轴来对物体进行光栅化,这个过程会读取材质数据,并将结果写到一个fragment队列供后面的CS进行调用。
这里的一个疑问是,每个轴只进行一次光栅化,那背面的信息是在这个过程中同步保存下来了吗,不然恐怕难以得到正确的体素化结果。
CS阶段
在CS阶段会根据上一步的数据对八叉树结构进行并行化更新,更新后的结果只存储在叶子层。
这种方法的好处有:
- 对于CS阶段来说,可以得到更好的occupancy(这个是针对缓存命中率而言的吗?)
- 可以通过对CS进行重用来节省shader编译的时间,这句话理解起来有点费劲。
总的来说,这种方案的体素化速度还是很快的,只是当场景中存在动态的物件(比如带有动画)时会出现较为明显的锯齿,且当发生大量更新的时候也会造成卡顿。
voxel光照计算
Shading & Radiance
光照计算第一步是根据前面存储在八叉树上的2x2x2套材质属性以及法线等数据输出2x2x2个HDR颜色以及opacity,当然,这个过程是在光照的参与下发生的,整个计算过程的覆盖范围应该与光照的范围有关。
Irradiance & Shade
由于Irradiance是单位表面积接收到的辐射功率,而Radiance则是单位面积单位立体角下的辐射功率,因此这里会通过对照射到每个voxel上的光照进行带有shadow map数据读取的累加来计算Irradiance,当然,这里还需要加上环境光、基色以及自发光等数据。
Filter & Finalize
Filter
完成前述计算之后,我们需要将细粒度的voxel数据转移到高level(粗粒度)的voxel上,这个计算过程的输入数据为此前的2x2x2的HDR color数据、occlusion数据以及法线数据,经过filter处理之后,我们需要输出:
- HDR multiplier,因为后面存储的是LDR数据,为了在使用的时候能够恢复出HDR数据,需要添加一个乘法因子
- 6个方向(前面说过需要生成与观察方向相关的颜色数据),每个面包含3x3x3个LDR color数据
- occlusion数据
Finalize
这一步我们会生成跟方向有关的voxel数据,这里可以参考[Gobbetti05]中的view dependent voxel的相关技术,不过这里我们关注点稍微简单一点,只需要生成跟cone方向有关的voxel数据即可。
而跟方向有关的voxel会使得最终的显示效果有明显的提升,尤其是哪些处于低分辨率区域的物件而言,比如一堵墙最终只光栅化为一个voxel,那么不同方向的采样结果可能会存在明显的光照差别,如果没有这个数据,就会导致效果存在极大瑕疵。
当然,这里采用的方向也是有讲究的,在leaf node层面,可以直接使用voxel的法线进行采样,而在其他层级的node则使用对应轴向(前面提到的6个轴)进行采样。
最后我们可以构建出用于cone trace的function:
- SVOLookupLevel:1. 对八叉树进行遍历,根据输入的参数找到node的local position以及node的索引;2. 对32位的volume texture使用3个三线性滤波采样得到三个方向数据;3. 根据方向对之前的结果进行加权平均(具体可以参考[McTaggart04]的Ambient Cube技术)输出HDRColorAndOcclusion
- SVOConeTrace:1. 会多次调用前面的SVOLookupLevel接口;2. 获取到单个cone中从某个方向过来的所有光照数据
下面来介绍光照采样计算的相关逻辑。
Sampling
Specular Sampling
Specular采样要相对简单,其基本流程为,对于屏幕空间的每个像素,根据相机位置与法线,计算出对应的出射方向(specular采样方向),之后根据specular power计算cone的开口,并据此完成specular数据的采集。
通常来说,单个cone就能够满足需求,因此在计算specular的过程中也可以考虑使用一些较为复杂的BRDF,不过,当power较小的时候(即不够光滑的材质),单个cone采样可能会存在质量问题。
UE这边在实践的过程中,也发现了一些可以提升计算效率的tips:
- 根据Specular Brightness进行过滤,当通过这个参数判断当前像素不具有specular特性时,则跳过计算
- Depth difference,待补充
- Normal difference,待补充
上图右侧的计算管线可以分成如下几步:
- 输入贴图为半分辨率
- X方向上的上采样,包含双线性的上采样与根据Refinement Point Queue进行的Scatter Specular两步
- Y方向上的上采样,基本逻辑同X方向一致
下面来介绍下上采样与Scatter的实现细节。
InterlockedAdd传入三个参数,第一个参数是相加后的数据存储地址,这里地址指向的也是相加之前的数值,第二个参数是需要进行相加的数值,第三个参数则是取出当前原子操作结束后的相加结果,举个例子:InterlockedAdd(A, B, Res);就相当于A=A+B; Res = A。同理,InterlockedMax则是原子Max操作,InterlockedMax(A, B, Res)就相当于A=max(A,B); Res = A。
这里Up-sample是通过Compute Shader完成的,代码片段的含义是:
- ThreadGroup中,每个像素都会对State[STATE_Count]进行加一操作,相加结果存入Pos
- (Pos+63)/64与State[STATE_ThreadGroupCountX]进行max比对,并将结果存入后者指向的位置,目测这里ThreadGroupCountX = 2,即每个Group包含两个Thread
- 将ThreadID存入Color对应的像素,高16位存Y,低16位存X
从这里给出的信息暂时不能给出整体的实现逻辑,待补充
Scatter Pass则是通过DispatchIndirect直接从GPU调用,而这里会使用STATE_ThreadGroupCountX来拉起总计STATE_ThreadGroupCountX * 64个线程,而实际真实的线程数受STATE_Count限制。这里Scatter具体做了什么也没有给出,待补充。
下面来介绍一下更为复杂的Diffuse光照计算。
Diffuse Sampling
整个算法的实现思路跟Jensen02的Final Gathering算法很像,而Diffuse Sampling面临如下的一些问题:
- 要想得到较好的性能,采样点数目就不能太多
- 要想得到较好的质量,采样点数目就不能太少
- 为了减少误差,cone在半球上的分布要讲究策略,这里通过Genetic算法来创建合适的采样点,并使用两套采样点+Reprojection来尝试找到合适的采样模式
- 要尽量避免产生噪声
- 要避免因blur而导致的法线细节的丢失
大部分Diffuse都是低频的,UE这边的实现方案,最终每个像素使用的cone数目大约为4.5。
具体方案细节待补充。
由于diffuse是低频数据,因此可以对相邻像素的diffuse(当具有相近的position(当然包含depth)与法线时)进行重用来降低计算复杂度。
对于屏幕空间中的每个像素(不明白这里的interleaved指的是什么,可能需要学习下[Segovia06] Non-interleaved Deferred Shading of Interleaved Sample Patterns,待补充):
- 计算出对应位置在世界空间中的9个均匀分布的cone方向
- 对9个cone方向进行遍历,对于每个方向进行cone采样(不明白这里的XY是指什么),这里为了得到较好的缓存命中率,所以先direction,后XY
- 将那些处于当前像素点法线方向之下的光照输入剔除掉
- 输出采样后的混合结果
看起来上面的屏幕空间计算并不是一次性完成的,而是将屏幕分割成一个个的tile分块计算(目的是?),这里还需要进行一个组装:
- 将各个小块的输出贴图组装起来
- 根据法线与depth进行加权输出(这里是对谁进行加权了?)
- 使用5x5的filter来填充那些缺失的像素结果(为什么会缺失?)
- 将diffuse结果乘上基色并输出
下面看下输出效果:
这里的物件是一个放大后的蜘蛛头,注意观察最后一副图中,自发光会对环境造成影响。
Shading
下面来看下UE4的Shading逻辑。
下面是经典的延迟渲染管线中,PS对应的相应Buffer的细节,给出了各个Buffer的各个通道对应的具体数据含义:
在Specular的Power编码算法上做了改进,采用了对人类更友好的映射方案:
具体的编码方式上图中的公式有给出,这里就不赘述了,据Epic描述,采用新的编码方式可以为IBL提供一个更大的Power值,且对于其他的Specular Power而言可以获得更高的分辨率(或者说数值精度),在新的编码方式下,一个远处的球体可以得到更为锐利的反射效果。
根据McKesson12的工作,Gaussian Specular在hard surface(坚硬表面,还是平滑表面?)上可以得到更好的抗锯齿效果,这里Epic给出了一套经验拟合算法来逼近Gaussian Specular(不直接使用Gaussian Specular的原因是因为原始算法计算效率太低?)。
这里介绍了面光源的Specular的计算方式,公式还有进一步优化的空间。
这里给出不同光照下的Specular效果比对。
PostProcessing
下面介绍下后处理相关的技术细节。
UE4中的Post Processing Graph有一些新的变化,整个Graph会在每帧重新创建,这个Graph的创建不受用户控制,Graph中各个Pass之间的依赖关系决定了各个Pass的执行顺序,RT是按需创建的,会对RT进行引用计数管理,并通过lazy release进行释放(lazy release是指当引用计数降到0的时候就释放到Pool中?)
Graph中的每个Node有如下的一些信息:
- Node种类有很多,不过每种Node的功能是固定的
- 每个Node负责处理一系列的输入,并给出一系列的输出
- 每个Node会定义好输出贴图的格式
SSAO
这里来介绍下UE中的SSAO算法。
经典的SSAO算法(Kajalin09)中:
- AO是通过后处理计算得到的
- 每次计算只需要获取Z Buffer数据以及3D的采样点(这个采样点是将屏幕空间像素转换到三维空间得到)
- 对于每个采样点,会在屏幕空间根据一个小尺寸的pattern进行少许采样
Epic的算法则是基于2D的点采样完成的,借鉴了HBAO的角度采样点分布算法,并通过GBuffer的Normal数据来提升显示质量,最终的输出效果可以很好的补足Voxel Lighting缺失的高频细节。
对于每个像素而言,每帧中总体需要采样的数目为6个样本对,也就是12个采样点,这些采样点会对半分辨率的ZBuffer进行采样;单看12个采样点数目太少了,为了提升显示质量,这里借用了时间片概念(类似TAA),将采样点每帧旋转一个角度,总共16个角度,这样最终我们就有16x12=192个采样点。
正如前面所说,最终输出的效果还可以通过法线信息来改善。
这里用了两个采样点进行示例,可以看到,对于每个采样点,计算AO需要在左右各采一点,根据Z Buffer读取的位置,可以计算出ange_left跟angle_right,据此可以大致估算一个AO,但是如图D跟E所示,这个angle可能已经落于平面之下了,因此这里通过跟normal进行点乘判断这个采样点是否需要clamp,clamp到跟normal点乘结果为0的方向,最终输出的这个AO称之为AO(per pixel normal)
下面给出这种方式得到的AO效果,可以看到经过法线优化之后,细节明显更为丰富了。
Lens Flare
Lens flares本质上是镜头轴向上失焦(out of focus)时的反射现象,这种现象通常可以通过Image based的方法来实现(下面的步骤看起来不太清晰,后面有更多信息再来补充):
- 通过一个阈值对image进行过滤,并对明亮的部分进行模糊处理(对应于下图中的Bloom)
- 对image进行scale与镜像处理,这个过程会进行多次(对应于下图中的without Lens Blur时的IB Lens Flares效果)
- 最终输出的图像会经过一个软边界mask处理从而得到软化的边界效果(可以看到,越接近屏幕边缘,Flares效果越虚)
Lens/Bokeh Blur算法:
- 对每个明亮程度达到一定数值的低分辨率像素,都绘制一个带贴图的sprite(见下图的六边形图案)
- 通常每个lens反射会使用不同的sprite半径
方法的优点:
- 简单,且有多种质量选项
- 可以用在任何明亮的像素上
缺点: - 美术同学控制力度不足
- 效果可能过于模糊或者存在锯齿
- 有时候性能会不太好(大部分时候性能可以满足需求)
上图给出了Image Based Lens Flares效果的各个步骤的输出效果图。
这里输出了不同类型的明亮效果对应的Lens Flares效果,可以看到是具有一定的程序化自适应能力的。
HDR直方图计算方案,直方图最终将[0, 1]分割成64段,最终输出每段的统计频次,整个过程分成两个pass,第一个pass通过CS完成,整个过程是并行计算的,最终输出的64个数值用16个RGBA表示。
眼睛自适应算法是完全在GPU端完成的,因为其数据不需要读回CPU。整个过程可以分成三步:
- 从上面计算得到的直方图中计算出平均亮度(蓝色线条)
- 这里只考虑明亮区域的数据(比如说亮度>90%的?)
- 当然,这里还需要剔除掉过于明亮的数据(比如非常强烈的自发光,如亮度高于98%)
- 根据平均亮度计算出每个viewport所需要的亮度multiplier
- 这里需要与上一帧的平均亮度(white bar)做平滑过渡
- 同时最终的平均亮度要在用户给定的范围内(如图中的绿色区域)
- 对整个屏幕应用tone mapper(白色曲线)
- 在tone mapping VS中读取前面计算的结果
- 将结果传递给PS插值
Particles
GPU加速粒子方案中,CPU跟GPU的各有分工,CPU负责:
- 粒子的创建生成(可以采用任意复杂的逻辑)
- 通过固定尺寸的buffer实现对粒子占用内存的管理
- 发射器管理(Index Buffer、Draw Call排序等)
GPU则负责:
- 根据牛顿力学(Newtonian)计算移动
- 通过3D读表方法实现粒子的光照计算?
- 如果需要的话,还会在GPU上进行基数排序(Radix Sort)
- 渲染
- 从向量场中添加额外的受力处理
- 根据粒子的参数曲线(美术或者策划同学制作)对粒子的属性进行调整
这里的State-full指的是先进行一步模拟计算,并将这个计算的结果作为输入来计算下一个输出,这种方式可以实现更为复杂的动画效果。
在现有的粒子系统中,远离原点的粒子的精度问题将不再是一个问题(不知道这里是通过什么方式做到的,是多级坐标还是double精度)。
粒子曲线的本质是一个1维的函数,自变量为时间,其函数的走向由美术同学控制,这里的实现细节为:
- 通过多段线(中间区域使用插值)模拟,离散点从贴图采样得到
- 样本数取决于原始曲线的长度与设定的误差大小
- 很多个1D曲线可以合并到一张2D贴图中
这里是关于粒子的向量场的相关内容,向量场的应用需要经过一个从世界坐标到Volume坐标的转换,向量场的参数有Force Scale(对力的一个叠加)以及Velocity Scale(对速度进行加权blend),向量场的作用范围是可以自由设置的,既可以作用于全局,也可以作用于单个粒子系统。
向量场的数据可以直接从Maya导入,目前每个粒子系统支持最多四个向量场的采样,下面给出的是粒子系统的效果展示:
除了上面的内容外,PPT中还附带了一些bonus slides: