今天来学习下《杀戮地带-暗影坠落》(以下简称《暗影》)在GDC2014年上的技术分享,下面是原文链接。
本文内容主要包括《暗影》开发过程中的重要里程碑,追寻下一代图形效果的过程介绍以及一些塑造了最终效果的一些关键技术的实施细节介绍:
《暗影》项目开始于2011年,下面是早期规划的一些目标:
由于《暗影》瞄准的全新的硬件平台,因此项目开发早期充满着摸索与艰辛:
下面是第一个demo的效果(提供基础的游戏交互体验,场景中只包含一些主要元素):
过了两个月推出第二个demo,主区域基本完好,没有添加光照跟特效:
又过了三个月,第三个demo,物体表现基本接近最终效果,已经添加了主要的特效与光照,还加了反射效果。
第四个demo,开始质量上的迭代过程(第二轮美术方向迭代),在美化与优化之间打转。这个过程中为了方便直观看到表现的变化,Guerrilla工作室开发了一个自动统计工具,定时跑图并统计场景中的一些相关信息(内存,性能等)。
《暗影》发布demo,添加了体积雾
上面展示的是单个场景的开发节点,实际上还有很多其他场景也在同步开发当中,下面来从asset的角度来对下一代图形效果的含义进行解释,这里给出《杀戮地带2》跟两部《杀戮地带3》的尺寸对比:
Killzone 2 -> Corinth River (1st level)
Killzone 3 -> Pyrrhus Outskirts (2nd level)
Killzone SF -> Pyrrhus Deep (8th level)
场景尺寸上,《暗影》要比上一代《杀戮地带3》打上10~100倍。这种急剧扩展对于开发而言带来了不少问题:
- 原有的引擎不再适用于如此大尺寸的开放场景
- 尺寸过大会导致浮点精度的问题
- 大量的几何物体与复杂的光照
- 每个level section包含一万到两万五千个几何物体实例,实例不等于DP
- 200个静态光源与200个烘焙光源以及其他数百个动态光源(粒子,爆炸,开枪火光等)
- 复杂的模型
- 顶点与像素细节丰富程度上升到了4倍
- 每个角色身上的面片数大概是4万左右
- 使用的材质也变得更为复杂(占用贴图数为6~12张)
为了应对上述转变,这里就需要一个专为PS4所开发的第四代引擎,这个引擎应该要具备如下特征:
下面对这些特征的具体细节进行详细介绍。
光照开发过程中学到的非常重要的一课是,千万不要对光照,反射以及半透物件分开进行处理,跟光照相关的这些特征与信息实际上都是相辅相成的,多个看似独立的特征应该要具有一致的表现,否则会显得非常的别扭。
这个图是《暗影》为材质调优而单独创建的“Shader Zoo”场景的截图。
在开发过程中认识到,PBR系统实际上存在不少的局限性,比如,由于菲涅尔效应的存在,会使得物件变得比较shiny,然而很多美术同学希望在部分材质上实现全diffuse光照效果,或者其他的比如只反射50%光照的需求,这就需要对模型进行反复的调整,对于使用人员来说是一个不小的挑战。
开发过程中发现面光源效果非常好,因此考虑将所有的光源都用面光源来代替,这种做法还有其他的好处:
- 可以通过调高cos指数的数值以及强度来模拟大面积的高光效果
- 可以避免其他光照类型在日夜循环中的不连贯表现(breaks,断裂,为什么会有这种表现?)
这个场景中的大部分光照都是来自于实时反射与cubemap。为了保持光照的一致性,Guerrilla将反光结果做得跟直接光照结果高度一致。下面再来看下间接光的一些实施细节。
这个视频对比了间接光与lighting only(直接光加间接光)之间的效果差异,可以看到间接光对于《暗影》有着重要的贡献,尤其是在一些HDR管线中,一些较暗场景要想拥有较好的视觉效果就必须要依赖于间接光。
Guerrilla此前的项目中,光照的做法是,对于动态物件,直接分配一个probe,而静态物件则直接使用lightmap(lightmap难用且存在较大浪费,因为需要考虑padding,UV Shells之间的空白区域等)
期望结果是一张干净整洁的贴图,而实际上由于存在着众多小尺寸贴图,考虑padding以及贴图之间的缝隙,整个贴图看起来非常混乱(对于计算机而言不混乱就行了,有什么关系?)。
最终Guerrilla决定在《暗影》项目中直接使用light-probe cloud来对所有的物体进行光照计算,这样就不用的单独对动态物件跟静态物件进行分开处理了,实现逻辑是清晰了不少。light probe是在延迟pass的PS中使用的,最终给出的效果在所有物体上(不论大小动静)都是一致的。
这里是light-probe的debug view(debug view中没有给出AO数据)
这里是这套系统的实施细节,根据重要程度不同,light-probe覆盖的区域也有所变化。
看下侧视图,light-probe一般放置在几何物体附近的空白区域。
probe的放置会按照四面体的方式来进行组织(方便进行插值,可以允许probe放置在任意位置),不过在现在的情况下,系统可能会生成一些非常长的四面体,这就会导致插值精度的下降。
这里的解决方案是,在一些找不到第四个顶点或者第四个顶点距离较远的地方放置一些额外的probe,这些probe数量通常比较少,这种做法能够有效的减轻前面的问题,同时也能够为那些远离了静态物体的动态物体提供精度更高的光照信息(因为主要的light-probe都是依托静态物件放置的),下面来看下直观的效果展示:
一个静态场景内部
将之进行voxelize
靠墙放置主要的light probes
填充辅助probes,下面看下另外一个场景的表现:
下面介绍light-probe方案的实施细节,要想在PS中获取light-probe数据进行光照计算,就需要找到像素所对应的四面体。这里的做法是,将四面体分割成一个个稀疏均匀网格(sparse uniform grid),之后再这个基础上构建一个BSP树,这样就可以实现快速查找了。
开发中发现四面体可能会与墙面发生穿插,从而导致漏光问题。图中蓝色的线条表示的是一种解决方案:occlusion split plane(遮挡分割平面)。在需要的时候存储遮挡分割平面信息(最多三个),通过对这个数据进行查询来忽略墙面另一边的light probe的作用;图中棕色折线表示的是另一种解决方案:对于一些复杂的几何物体,这里会需要存储triangular occlusion shadow maps(每个四面体最多四个),其基本思想跟shadow map相似,通过判断当前像素到probe的距离与shadow map上的数值来判断当前像素是否会受到probe的光照影响。
由于这套系统开发时间过晚,导致并没有完全取代原始的lightmap方案。目前只用于动态物体上在gameplay区域之外的地方。
下面看下粒子系统上的光照方案,在粒子系统上施加光照会使得场景看起来更为生动真实。
大多数的项目都没有考虑粒子系统的光照问题,而部分项目则是直接在前向渲染中对粒子系统进行光照计算,但这种做法有不少的问题,其中一个限制就是由于大量的overdraw,有限的shadow maps会使得渲染速度非常慢。
Guerrilla这边希望粒子系统的光照效果能够跟环境实现良好的匹配,当然,性能是不可忽略的因素。
《暗影》的粒子光照是按照普通物体相同的延迟渲染方式进行计算的。这里增加了一张额外的小尺寸G-buffer用于存储每个粒子所对应的多个参考点(reference points)数据,参考点数目可变,这个是通过一个LOD来进行控制,LOD切换方案由美术同学主导,可以用于实现一套稳定的效果表现。由于每个粒子代表的是一个volume,而非一个面片,因此法线数据实际上应该要朝外弯曲。
这个视频给出了实施效果以及对应的G-buffer数据变化,每级LOD中的8x8或者4x4点数据会在内存中紧挨着存放(方便cache?)。
延迟粒子光照是集成到普通光照pass中的,在on-screen light之后计算,还可以对shadow map数据进行重用。
雾气或者灰尘在平时渲染的时候效果不是很明显,看起来就像是透明的一样,但是在强光作用下的可见性就会变化非常显著。
粒子系统光照计算框架没什么特别的,跟其他物体的光照计算一致,这就避免了单独一套处理逻辑而导致的效果不一致问题,方便扩展。缺点就在于G-buffer尺寸限制了所能支持的粒子的数目,在粒子间进行光照插值可能会存在较为明显的痕迹。
给出带光照与不带光照粒子系统的效果对比,粒子系统上的柔软光照还能模拟一种散射效果。接下来看看阴影优化相关的内容:
导致阴影渲染高消耗的原因有很多,除了下面列举的这些之外:阴影渲染时需要对很多细小物件进行渲染,以及当光照方向跟地平线夹角很小时,会导致每个cascade都塞满了物件——这两个也是重要的原因:
下面先给出局部光源的优化方案:
对于静态物体跟静态光照而言,这里的做法是在离线的时候创建shadow proxy,之后运行时使用proxy来替代物体进行shadow map渲染。
为了提升shadow 渲染的效率,这里还会通过减面方法(light volume剔除,back face剔除,遮挡剔除等)移除那些不可能产生阴影的面片,大概能做到60~80%的优化力度(猜测这个方法就是用来进行proxy创建的)。
为了节省Draw Call,这里应该是通过某种合批渲染方法,将对应于同一个light的所有物件一次性绘制了。
动态物件会在静态物件shadow map的基础上进行渲染。
上面两图分别是为spot light制作的proxy的过程说明,前者是原始模型,后者是优化后的proxy模型。
由于太阳光覆盖面积大,覆盖物件多,因此此前对局部光源的优化方法不能直接用在太阳光上。这里采用的是一种混合优化方法:
- 烘焙阴影贴图
- Proxy delta mesh(含义暂时不明,推测跟前面局部光源的shadow proxy类似)
这里给了个视频展示,分别介绍了太阳光阴影的实现方案的各个部分:
- 使用小尺寸的shadow map来对整个场景进行覆盖,这个shadow map渲染时的分辨率为16k x 16k,不过后面会降分辨率输出(降低存储空间,内存,同时得到软影效果),shadow map主要用于捕获大尺寸细节
- Signed Distance Field(SDF,存储每个点到最近的阴影边缘的距离)用于维持阴影边缘细节
- 跟局部光源一样的Shadows Proxy Mesh,不过这里的Mesh只包含阴影贴图中所忽略的面片数据(阴影贴图忽略了哪些面片?)
这里给出了这个方案的一些优劣对比,实践证明,单独使用shadow map跟SDF的效果就已经足够好了,而在后面两个cascade中甚至只需要shadow map就已经足够。
下面看下体积光的实现方案,先来看一个视频:
因为体积光对于《暗影》效果的影响非常显著,因此这里基本上所有的光源都会支持体积光。基本上所有的光源类型都会支持ray march方法,通过调整参数可以很好的实现消耗与质量之间的平衡。
这个算法是按照类似于延迟光照的方式执行的,在屏幕空间完成某个光源的光照计算之后,使用ray marching shader在对这个光源的体积光shading进行计算,将结果存储到volumetric buffer中。
为了降低消耗,体积光渲染是在半分辨率buffer中计算的,且划定了marching范围,并不会对ray上的所有点进行采样。
这里有一个实现上的技巧,即ray marching的step尺寸是不固定的,而是按照一个随机算法生成的,这种做法有如下的好处:
- 感知上的质量会更高
- 加上双边模糊之后可以移除dither pattern(抖动纹样)
这种做法有点类似于PCF中的随机采样点,如上图所示,同样的采样点数,将采样点打散看起来有助于增加每条射线上的采样点数目。
这里给出了不同处理方案的效果对比,可以看到通过一系列的手段,可以使用8步采样达到近似128步采样的效果。
单纯的体积光效果会比较单调,《暗影》这边决定通过粒子系统来为之进行增色。粒子系统完全由美术同学把控,且能够跟物理,角色移动以及风力等产生交互。
其实现方式为,将粒子系统渲染到一个3D的Scattering Amount Buffer中,这个Buffer是在相机空间中定义的,平面分辨率为屏幕分辨率的1/8,深度采用16个切片来实现。这个Buffer中存储的数据在raymarch的时候会对采样点的volumetric强度进行影响。
这里来看下这个buffer的直观展示效果。
这里给出了添加了粒子系统(右边)跟没有添加粒子系统(左边)的效果对比,影响还是很卓著的。
不过将体积光跟其他的半透效果组合起来其实还挺艰难的,需要考虑到单独的buffer(不知道说的是不是前面的Scattering Amount Buffer还是其他的buffer),且没有可以比对的depth数据等(比如上图就是depth问题导致的效果异常)。
这里的效果问题也是因为深度原因导致。
在实际开发中发现,使用2D的体积光强度buffer跟透明物件很难匹配起来,这也是前面放出的两个效果异常的原因。因此这里的做法是仿照Scattering Amount Buffer,直接使用3D的体积光强度Buffer来进行数据存储,不过这里的平面分辨率是屏幕分辨率的一半,里面存储的是raymarching的结果,每次执行一个step的raymarching,都会将结果存储到这个buffer中对应的depth切片上,到后面渲染透明物件(比如烟雾)的时候,就可以通过查询这个buffer获取到从相机到这个透明物体上位置之间有多少可见的volumetrics,之后将这个结果添加到透明物体颜色上,效果上看起来就像是透明物体实际上是位于体积效果之后一样。
详情参考GPU Pro5中的实现细节。
当前的渲染方法其实还是比较粗糙的,每帧需要花费大量的时间来对反射、体积效果进行绘制,用完就扔了,等到下一帧又从头开始渲染。这里给出的一个新的想法是使用Reprojection来提升渲染速度或者渲染质量。
Reprojection的基本思想是,连续多帧渲染结果是非常相似的,因此可以考虑在当前渲染帧对前面渲染帧的结果进行重用。
《暗影》这边对Reprojection的期望是用之来提升渲染质量(就像是TAA一样),从而在较少的采样点的情况下得到较高的渲染质量。
对上一帧数据重用需要进行筛选,避免Reprojection到错误的数据上:
- 需要根据颜色与深度相似性来判定
- 并根据相似性来对两帧的结果进行混合
具体实现上还有一个小窍门,那就是每一帧会来回切换一套step offset(一共两套),这样做的好处就是看起来像是增加了采样点的数目。
这里给出的是使用4个采样点来进行raymarching的结果,通过Reprojection处理之后,噪声得到了大大的降低。
除了对体积光效果起到质量优化之外,对于其他的后处理(这些后处理通常是在低分辨率下渲染的,如Bloom,SSAO,Godrays,Exposure measurement(这个是啥))的质量也有一定程度的提升(移动更为平滑)。
这里给出了Bloom跟AO的优化结果对比。
灰尘跟雨效是通过粒子系统来实现的,在normal pass渲染完成后,通过后处理的方式添加到G-buffer的。其实施效果能够跟脚步或者枪击产生交互。为了将雨点限制在户外,会通过一张类似于shadow map的从上到下的贴图来进行判断,实现细节可以参考这篇文章。
这里给出了灰尘交互的实施效果,在后处理渲染中,会使用user channel来对贴花decal周边的灰尘进行擦除。
雨效主要通过对specular进行修改来实现,注意查看albedo的亮度会被稍微减弱(PBR,能量守恒)
雨滴位置是通过一张3D texture来给出的。这个贴图中的每张depth切片表示的是不同阶段的雨效——从清晰的强雨到表示逐渐淡出雨效的较为模糊的圆点。
下面来看一下《暗影》的反射效果实现方案。
《暗影》反射系统由三个组件组成,按照下面顺序逐次取用数据,当上一级获取数据失败时,就会自动fallback到下一级:
- 实时Raytrace反射系统
- 用于表示位于不同距离的动态物件的反射
- 局部静态cubemap反射
- 用于表示局部反射
- 静态背景cubemap反射
- 用于表现远景的反射
这里给出的是静态局部cubemap的反射效果。为了优化性能,这里使用了tiled renderer,每个tile会提取出影响到这个tile的所有cubemap list,之后在渲染的时候就可以忽略那些无干涉的cubemaps了。
每个局部cubemap对应于一个zone,这个是由美术同学手动摆放的。整个渲染流程包含两个pass,每个section大概需要花费一个小时(离线进行):
- 关闭反射,normal render pass(作为下一轮绘制的输入贴图)
- 开启反射,进行第二轮render
这里还会通过mipmap filtering来实现对BRDF specular cone的模拟,详情参考Siggraph 2012年的 Local Image-based Lighting With Parallax-corrected Cubemap 文章。
反射的光泽计算与两个因素有关:
- 表面粗糙度,参考上图中的左边跟中间的小图,可以看到,这个因素会影响到反射椎体的aperture(孔洞)
- 反射射线的长度:长度越长,椎体最终的半径越大。
最终输出的光泽度会用于计算cubemap的mip层级。
实时反射渲染使用的是屏幕空间的算法(其实就是屏幕空间反射,SSR)。总共可以分为三个阶段:
- 对需要反射的像素进行raytracing,在屏幕空间贴图中找到对应的反射color
- 对反射结果进行filtering跟reprojection处理,用于计算光泽,并遮盖错误跟孔洞
- 将实时反射结果跟cubemap相结合,用于处理那些实时反射缺失的部分
实时反射输出贴图采用的是半分辨率,实施上有如下细节可供参考:
- 每个反射贴图上的像素对应于2x2个屏幕空间像素,每帧都会从这四个像素中取用不同的位置的像素
- 经过4帧之后正好完全覆盖全分辨率屏幕贴图
这里给出最终的效果展示,忽略由于水面动画导致的高光闪烁。
这里给出Raytracing实现平面反射的细节:
- 根据反射向量的方向在屏幕空间按照常量step(step尺寸受表面粗糙度以及反射方向影响)进行raymarching
- 同时,为了避免出现大面积区域获取不到反射信息的情况,还特意增加了一层处理,即允许射线从部分物件(比如武器)下方穿过
- 为了提升表现的稳定性,这里会在raymarching的时候选取最近的两个点,并在其中进行插值
- 提升稳定性的另一个策略,就是利用上一帧的数据来对本帧反射结果进行补充
接下来,从ray-trace的输出结果中生成mipmap chain,生成的过程会采样跟前面cubemap一样的filtering策略以实现对BRDF的逼近与模拟。为了提升准确率,这里还会使用mask来对那些空缺的像素进行屏蔽,避免mipmap输出错误结果。
之后构建对应的反射buffer,在使用的时候,会需要根据gloss来计算mipmap层级。
为了提升反射结果的稳定性,前面说到,会使用上一帧的结果通过Reprojection来对本帧的反射结果进行补充。其实现过程跟TAA类似:
- 选取的结果会根据颜色是否相近来进行剔除
- 比对时会考虑本帧反射结果周边像素的颜色范围
- 详情参考Siggraph上的文章:‘Real-Time Global Illumination and Reflections in Dust 514’ - Hugh Malan
ray-trace反射buffer生成之后,可能还依然存在部分漏洞,之后就是通过前面提到的cubemap来对这些漏洞进行填充。
下面来介绍下《暗影》中的抗锯齿技术方案。
第一套方案框架是以MSAA为基础,在上面添加了TAA Reprojection。
第二套方案则是TAA+FXAA,其稳定性要优于第一套方案。TAA会将多帧结果填充到history buffer中(大概16帧),填充的时候会根据颜色是否相近进行剔除。在开发的时候还尝试过使用subpixel抖动来获取更多的采样点,不过后面发现结果不太稳定就舍弃了。
这套方案存在的一个问题是,即随着不断的累加,数值会发生空间上的传播,从而导致最终的结果显得有点模糊,而这个现象是MSAA所看不到的。
这里给出的解决方案是通过反复的补偿与修正方法来消除,基本思路如图所示:
- 将history buffer Reprojection到本帧上
- 之后再将结果Reprojection回上一帧
- 最终history pixel reprojection的结果就等于二者的加权平均。
详情参考:‘An Unconditionally Stable MacCormack Method’
效果对比。
下面来介绍下场景中多个角色的渲染方案。
单个玩家,1080P,目标是30FPS。多个玩家,1080P,60FPS(因为单玩家,不需要这么快的响应,而多玩家如果响应慢了,可能会被玩家慢)。项目组不希望降低显示质量,怎么办呢,又没有时间对assets进行优化,只能通过一些技术手段来完成。
这里依然准备借助reprojection来实现:将纵向分辨率降低一半,奇偶帧交替渲染一半像素,之后通过reprojection来模拟出全分辨率效果。从结果上来看,很难分辨出来质量有下降,但是性能上确有80%的提升。
为了保证质量足够高,这里一共缓存了两帧数据,reprojection依然需要验证颜色一致性。
在实际计算的时候有两种情况,第一种情况当前帧渲染结果可用,那么此时就直接使用这个结果;第二种情况是当前帧渲染结果不可用,那么就需要通过reprojection来计算得到。
这里reprojection是针对当前第N帧跟N-2帧的,通过比对颜色相似性,决定结果是否可用。而如果两者结果很相似,那么N-1帧结果大概率是可用的(为什么这么复杂)
在使用的时候,会在内存中保存下全分辨率的history buffer以提升reprojection的稳定性,reprojection需要保证结果的可靠性(即只有结果可靠才会采用,那么不可靠的时候怎么办呢,直接从相邻像素插值得到?):
- 移动是稳定的可预测的
- 颜色相似度要够高
SP-Single Player,MP - MultiPlayer。
下面介绍bounus slide上的一些技术。
力场的模拟采用的是美术同学驱动的基本框架,这样在效果上的表现力会更强一点。
影响面片的因素包括如下几个方面:
- 风力
- 爆炸
- 涡流
这些因素的影响方式有如下几种:
- 静态放置在场景中
- 附加到角色或者其他道具上
力场影响的asset包括布料,粒子系统以及shader实现。。
早期的力场系统是直接集成在植被上(采用的是Position Based Dynamics方法),这些效果包括植被摇动以及掉落物体对粒子的推动效果等。
最终的力场方案实际上依赖的就是大量的计算:每帧大概需要对6万个采样点进行计算。
这里又两种查询(Query),显式(explicit)以及隐式(implicit)。
显式查询用于返回给定点的力场结果,多用于布料或者粒子模拟。
隐式查询针对的则是缓存在角色周边网格中的力场。网格有多重规格,数据可以直接在shader中获取,多用于树叶水面等系统。
这里给出一个力场cascade的可视化展示。
这里还用力场来对背景人群进行驱动,这些人群都是适用sprite制作的。
《killzone 3》使用的是根据boundingbox进行的贴图streaming,不过对于建筑内部没啥用。