今天分享的是雪崩工作室在Siggraph2012上关于《正当防卫2》的大世界相关技术细节,原文链接在参考部分有给出。
总结
这个分享主要包含了如下的一些内容:
- 大地图下浮点精度问题的解决方案 - 调整运算顺序,避免精度损失的相关操作,转换到局部坐标(Rebasing)
- 远视距下的渲染逻辑 - reversed z,distant light + landmark
- 其他浮点运算上的精度保全trick - 避免直接对矩阵求逆
- 阴影处理策略 - 通过离散化range变化避免抖动,通过将相机的移动snap到整数来避免抖动
- 对顶点的压缩处理 - 缩减顶点属性,修正顶点属性的表达类型(通过将数据转换为局部数据来缩减位数),缩减tangent space vector表达尺寸等
- 其他优化方案 - particle trimming,顶点shader中的instance cull策略, renderstates排序策略, BFBC剔除方案,shader指令优化等
正文
《正当防卫2》的地图尺寸是32 km宽,物件坐标使用的是单精度浮点数(实际上在4km左右,就可能会出现抖动等问题)
这里是大尺寸地图下可能遭遇的一些问题:
- 浮点精度不足导致的效果异常:画面抖动、动画异常等
- 视距过大,而深度精度不足导致的深度竞争问题
- 视距过大,阴影完全覆盖内存消耗过高的问题等
- 内存消耗等问题
- 其他性能问题
视距是50Km,在这个视距下很难做到将所有物件都加载进来,《正当防卫2》的做法是在远景位置只渲染标志性物件跟灯光,这样可以使得效果看起来更真实,这个灯光不但在晚上是开启的,在白天也同样是打开的。
这里还做了很多其他的工作来使得整个虚拟世界更为逼真。
远距灯光方案的具体实现细节给出如下:
- 在离线的时候对场景中的灯光进行处理,将之塞入到一个Vertex buffer中
- 不过考虑到view frustum的裁剪以及vertex buffer中顶点的精度,这里不会将整个场景的灯光放到一个buffer中,而是会进行一些拆分处理
- 灯光vertex buffer后面会被当成自发光sprite进行绘制,消耗很低,但是效果很好。
这里给出一张效果图,远景的城市是完全没有加载的,只通过灯光跟标志性建筑就能够得到很好的直观感受了。
上面截图中的bug是当depth为1.0时转换后的fixed_zs中的depth部分是0,这是因为浮点数向上取整的时候正好碰到整数的溢出。
这里需要注意,浮点数的精度是会随着数值的增加而下降的,是一个非线性的精度,在原点位置精度最高。对一个会不断上涨的浮点数表示的参数而言,可能会导致运行的结果跟设计的预期不一致,除了坐标之外,还有时间,比如一些动画效果会随着时间的增长变得奇怪(PPT中举例的水面模拟动画就会出现这种问题)。
比较好的解决方案是定时对timer进行重置或者直接使用整数。
前面说过,用单精度浮点数在4km位置就会出现异常,实际上是考虑了计算过程中的精度损失的原因,实际上在8km到16km这个区间,浮点数的精度依然可以维持在mm级,但是如果算上计算过程中的精度损失,就很难达到这个水平了。
浮点运算的特征是:
- 误差是会累计的,经过几次计算后,mm精度可能就变成了cm精度了
- 相对于乘除而言,对于两个不同数量级的数值的加减其导致的误差更为严重,比如将一个很大的数值减去一个较小的数值再加上这个数值,其结果跟原始的大数相比可能已经有十分大的差距了。
一个解决方案是,缩短计算的操作数,一方面可以降低消耗,另一方面也可以提升结果精度;对于加减而言,则是通过调整运算顺序,尽可能的缩小操作数之间的阶差。
这里举了一个例子,最早的时候,计算最终的position输出是使用计算过一遍的world_pos来进行的,而这个参数本身就是一个很大的数值,是会造成精度损失的,后面又进行了一遍矩阵运算,使得累计误差变大了。
一个优化方案是使用最开始的position进行一次矩阵变换来得到最终结果,不过这里有一个问题是,world矩阵跟view矩阵相乘可能本身就会损失一些精度,这里在使用的时候要注意。
一个更好的方案是将坐标转换到一个局部空间,在局部空间中进行最终position的计算输出,而这个局部空间需要确保没有大数据的参与,最直观的就是先将物件转换到以相机为原点的view space,这样后续的运算就都是在相机空间中进行,就不会有大坐标导致的精度误差。
此外,直接对一个矩阵进行求逆这个过程对精度的损失是很大的,因此更好的方案是直接使用原始参数计算出单个的逆矩阵,之后将两个逆矩阵相乘。
如上图所示,直接计算逆矩阵有两种可靠的方法:
- 对于一些简单的变换,如translation跟rotation可以直接通过将操作数翻转并调整矩阵变换顺序来求得
- 对于projection 矩阵的逆矩阵就稍微麻烦一点,上图给出的是通过Gauss-Jordan消除这种解析方法来求得对应的逆矩阵
《正当防卫2》的视距有50km,基本上覆盖了整张地图,这里的一个问题是,depth buffer使用的浮点数是24位的,前面说过32位的浮点数在8到16km范围就已经十分吃力了,因此传统的做法的一个问题就是会导致深度竞争,在深度上相近的两个物体可能会因为相机的旋转出现明显的穿插抖动。
解决这个问题的一个做法是使用reversed z,这个方案的具体做法在此前的投影矩阵与Reversed Z有详细叙述,这里就不展开了。使用reversed z的好处是可以将更多的精度分配给靠近相机的区域,而远离相机的区域则使用较低的精度,不过这些地方由于离得远所以即使有问题也不会太明显。
虽然reversed z不能完全消除深度竞争问题,在竞争激烈的区域依然会有穿插问题发生,但是据PPT描述,在这种模式下的穿插是稳定的,不会随着相机旋转而前后闪烁,从而避免这种问题过于明显。
此外,即使使用的是顶点数据来存储深度数值,使用reversed z依然是有帮助的,因为除了最后的存储之外,前面计算过程中也会需要用到浮点数,在这些过程中的精度损失是可以通过reversed z来降低的。
PS3的硬件没有原生的深度格式,因此需要手工完成相关格式的解码。不过由于D24S8以及D24FS8能跟硬件的RGBA8很好的契合起来,因此解码起来也比较简单,这里借鉴意义不大,就不做展开了。
这里给出具体的解码实现。
阴影绘制使用的是三级的CSM,不过每级阴影贴图覆盖的范围不是固定的,而是会随着相机的拉升而变化,比如在地面上覆盖范围较小,在天空的时候覆盖范围则比较大,这里各级阴影贴图覆盖的范围不是按照PSSM的方式自动使用对数划分计算的,而是由美术同学手动调节的,其效果据说比PSSM方案要好。
为了保证阴影效果的稳定,这里需要对投影矩阵进行优化,使得每次移动都是按照整数个texel进行(从而保证阴影贴图在相机移动的时候就相当于将贴图在XY方向上平移,从而保证阴影效果的稳定,避免抖动),不过这种方式在阴影贴图覆盖的范围发生变化的时候就不再适用,为了避免覆盖范围频繁变化导致的抖动,这里将覆盖范围的变化从连续变成了离散的,即只有当前后两个range覆盖范围相差7%(不知道这个数值怎么得来的)才会真正进行range变化,虽然在变化的一瞬间会出现阴影的跳变,但是不仔细观察是看不出来的。
为了保证大小range下的阴影绘制消耗基本上是恒定的,这里添加了screen size culling策略,将一些在阴影贴图上占据像素较少的物件剔除掉。
此外,由于XBox360的显存比PS3要小,但是其算力要更高,因此这里做了一个优化就是将32位的浮点数转换为16位,从而以1ms的时间消耗节省6MB的显存。
这里是对内存优化的一些经验策略:
- 根据对每一帧的仔细分析,发现有些rendertarget的使用时间范围是不相交的,因此可以考虑将这些RenderTarget放到同一块显存里。此外,在XBox 360里面,由于贴图是放在显存里,而RenderTarget则是放在EDRAM的,因此在进行RenderTarget的切换的时候,实际上不需要创建第二张RenderTarget(意思是共用同一张,只是绑定到不同的texture?)
- 对于一些颜色比较一致的贴图,可以考虑将这些贴图的颜色转换为亮度存储到DXT1压缩的某张贴图的某个通道,之后通过将其他颜色信息存储在顶点色中,通过这种方式降低存储消耗。
这里展示了带顶点色的顶点属性结构。
这里是优化后的结构,将两个uv合并到一个float4中,并将一些不必要的属性更改为byte4,以实现结构存储空间的优化,其他的float属性要怎么办呢?(为啥要执着于干掉所有的float?)
这里的第一个想法是换成half,但是half float继承了float的所有特点,即精度是对数分布的,但是这种分布规律对于顶点坐标以及uv坐标而言是没有意义的,因此这里最终选择的是转换成整型的short,不过由于部分模型的顶点坐标分布可能不是完全对称的,希望实现[-1, 1]之外的其他分布范围的数值坐标,这里的做法是添加bias跟scale,这两个数值都是可以通过对模型的boundingbox进行处理来得到。
这种做法的一个问题是,多个不同的模型如果叠加到一起,且每个模型都有自己的boundingbox的话,就会导致在交界处存在数值的不一致,表现为不应该重叠大叠在一起(问题不大)了,不应该分开的则存在裂缝(这个问题很明显),解决这个问题的方式很简单,就是将这些叠在一起的模型放在一起考虑,使用一个大的boundingbox将这些物件包起来,虽然这么做会导致误差进一步上升,但是结果却可以保持一致性。
接着,由于tangent数值的三个向量中有一个是可以通过其他两个推导出来的,因此这里可以只存储其中的两个,干掉最后一个;剩下的两个ubyte4可以将之放到一个float2中,这种做法虽然没有减少内存消耗,但是减少了一个顶点属性,因此减轻了计算消耗(不知道是不是减轻了顶点插值计算的消耗?),可以得到更好的性能表现。
这里再进一步,将每个tangent向量表示成代表经纬度的两个参数,解码则需要较重的三角函数计算,但是这种计算在GPU上,放在VS中其实并不是那么高,其次在一些硬件上,这个计算过程是放在一条单独的线上进行的,在进行这类计算的时候,硬件还可以同步进行其他的代数运算,因此这里的消耗就基本上被掩盖了,但是通过这种方式可以减少一个float的消耗。
另外一种精简的tangent space表达方式是quaternion,同样是使用4个byte就能完成三个vector的表示,不过实践证明这种方式解码消耗比前面一种方式要更高一点。
由于tangent spae解码之后需要进行世界空间变换,而这个变换需要用到9个向量的乘法,但是如果将这个变换转换到quaternion空间来进行,就能够使得上面的第二种方法消耗要低于前面的第一种方法。
使用quaternion变换的限制在于我们只能使用正交基,即不支持skew变换,但是这种限制在光照计算中基本上不是什么问题。
这里是最终的顶点属性结构,除了前面的优化策略之外,这里还在考虑一下其他的想法,比如将颜色表示成R5G6B5,并将之塞入到position的alpha通道中(因为这个通道的short本身就是没有使用的)从而节省下4个byte同时还干掉了一个顶点属性,以及将小尺寸模型的坐标表示成R11G11B10,从而将位置尺寸从3个short变成4个byte,这些想法都没有经过验证,但是应该具有一定的可行性。
这里给出了《正当防卫2》中对粒子以及公告板的优化方案,对相应的公告板进行裁剪,只保留有效区域,从而可以得到接近两倍的计算效率,具体细节可以参考“Graphics Gems for Games: Findings From Avalanche Studios”。
为了提升渲染效率,通常会将多个静态模型合并成一个drawcall,但是这种合并就没有办法做到对单个的模型进行剔除处理。
这里的做法是在VS中进行相关的剔除,包括叶子、粒子、公告板等多种几何体都可以通过这种方式来完成。
最开始的剔除方式是将对应的顶点设置到float4(0, 0, 0, 0)或者float4(-1, -1, -1, -1)上,但是后来发现,如果某个三角片中的某个顶点被设置到这些数值,而其他两个顶点维持不变的话,会导致面片被拉伸得很大(比如占据半个屏幕),但是又不可见,因此会奇怪的发现,为啥性能变差了。
经过考虑,后面选择的剔除方式是将顶点的z值设置为-w(reversed z)或者2*w(非reversed z),这样得到的结果就相当于将此顶点放到远平面之后了,而即使出现前面那种其他两个顶点没有被剔除的情况,在表现上与性能上都不会有太大问题。
早年使用DX9的时候,CPU性能较差,Draw Call提交比较成问题,比如在2003年的时候推荐的Draw Call数是300左右。但是随着CPU速度的提升,API的升级,这个问题已经不那么严重了。
DX10/DX11提升了API的实现效率,同时还改进了drive model,比如D3D运行时会直接将drawcall转发到user mode driver中,整个过程不超过5条X86指令,之后就从user mode driver跳转回应用代码(而非D3D代码),在DX11上,还可以借助多线程的功能来提升性能。
在上述工作流下,单线程中每帧可以提交16000个drawcall,可以满足绝大部分的项目需求了,因此DrawCall数已经不再是瓶颈。
存在性能风险的是每个物件实例与render相关的处理逻辑,如裁剪、排序、坐标变换以及其他逻辑等,因此现在的首要任务就是减少物件实例数目。
虽然话是这样说,但实际上减少drawcall依然是有意义的,drawcall过多对不同的硬件影响不同,这里说到XBox360对Drawcall还比较敏感。
上图中,红色圆圈中的是同一物件的不同实例,可以考虑使用实例化来进行渲染,但是如果将这些物件放到一个boundingbox中,会导致这个box过大从而影响裁剪效率(裁剪不干净,导致额外消耗)。
绿色圆圈中表示的多个不同物体实际上更适合放到一个boundingbox中,但是这几个物件本身不是同一个物体的多个实例。
这里给出的方案是将多个物体通过一个drawcall来完成绘制,这些物体自带不同的transform,且这个过程不涉及到顶点buffer的拷贝,效率是可以得到保证的,具体实现方案可以参考“Graphics Gems for Games: Findings From Avalanche Studios”。
《正当防卫2》中的renderstates排序策略是脱胎于Ericson的方案的,不过做了一些改动。
首先,其渲染方案是有严格排序的,大致是先绘制不透明的,再绘制半透的,再绘制后处理,最后进行UI绘制,在上面的同类型绘制中,对于不同的材质也是有排序的,大致是先地形,再角色,再粒子,再云层,再植被等。因此在类型+材质上不需要浪费一些bits,从而可以将bits分配给那些更需要的功能上。
也就是说,现有的渲染逻辑已经按照类型+材质分成了两层,这两层中的每一个pass都有一个对应的drawlist,而排序则是发生在每个drawlist中的。之所以分成多个drawlist而非放在一个全局的drawlist,是有如下的两层考虑:
- 可以缩小排序的计算消耗
- 对于不同的drawlist,可以使用不同的排序策略,为每个bit赋予不同的意义,而这种做法经验证是十分有用的。这里的一个例子就是,地形的绘制需要考虑对HiZ的优化,而角色的绘制则不需要进行相关考虑,因此绘制方式就可以不一样,从而影响到排序逻辑。
将renderstates编码到sort-id中的一个好处是,渲染时候所需要的很多信息都可以从sort-id中解码出来,而不需要重新再从物件中获取,大大简化了流程,提升了计算效率。
《正当防卫2》的剔除方法叫做“BFBC”,这里的暴力剔除的含义是,为场景中的每个物体分配一个occluder box,这个box是美术同学手动摆放的,之后,这些box并没有构成层级结构,而是直接放在一个数组中,通过SIMD的剔除方式来进行剔除。
虽然没有层级结构,但是其性能并没有比层级结构的剔除方案差,主要有如下一些原因:
- 层级结构在剔除的时候的分支处理逻辑会增加消耗
- 层级结构在一些动态场景中的维护会是一个比较高消耗的过程。
- 层级结构的处理对于缓存是不友好的
美术同学对occluder的摆放也是讲究策略的,通常会放在一些重要的战略位置,比如大的建筑、山脉、以及其他大尺寸的物件等。这里判断某个物件有没有被其他box遮挡是通过两个box之间的包含关系来判断的,而没有考虑将两个遮挡体box的union作为一个新的遮挡体来进行被遮挡体是否完全被遮挡的判断,因为union的做法会增加复杂度,实际效果并不好。
这种剔除策略就对美术同学的经验带来了挑战,如上图右侧的示例,浅灰色的两个box代表两个建筑,后面的深灰色的box代表被遮挡物,如果单独进行两个建筑box对后面box的遮挡剔除的话,会发现任何一个box都没有完全挡住后面的box,这就导致结果显示后面的box未被遮挡,需要绘制。
但是实际上,由于两个建筑box之间的亲密无间,后面的box是被遮挡的,因此一个较好的遮挡体box摆放策略应该是如右侧图所示,将红色遮挡体往右拉伸到右边绿色遮挡体的边界上,这样就能完全将后面的box剔除掉了,同样,红色box的下边界也可以往下延伸一段距离,目的是挡住后面其他区域的物件,比如最常见的水面patch。
《正当防卫2》中的数据在磁盘中的存放是经过优化的,通过GB尺寸的大文件来存储数据,这样做的好处是,任意时刻只需要打开少数几个文件handle就可以了。
大文件的不足之处在于,如果数据存放的位置相差很远,就需要通过seek来定位,会导致消耗增加,因此这里需要将关联的数据存到一起,一次性读取出来,同时这里还会将具有相同优先级的加载request按照数据关联性进行排序,避免seek的次数。
除了前面的内容之外,《正当防卫2》还做了一些其他的事情来提升质量与性能:
- 增加了一种动态分辨率机制,当性能不行的时候就自动降分辨率,从而维持30fps的表现,在实际使用的时候质量下降并不明显。
- shader会编译成不同LOD的二进制文件,这边新增了一种shader performance script机制,在每次提交代码进行review的时候,这个脚本都会对编译后的shader跟之前改动前的shader进行比对,输出新增的指令数以及寄存器使用情况,从而实现对shader变化的精细掌控
- Tombola compiler,用于查找获得最佳性能的shader的随机种子,这个是跟《正当防卫2》本身shader编译方式相关的机制,没太多借鉴意义,这里就不展开了。
下面是一些附加slides。
这里提到了shader编写中需要注意的一些事项,比如尽可能的使用效率更高的MAD来代替ADD+MUL指令等。
还要一些其他的优化策略:
- 将一些矩阵操作前后的线性操作(比如加减一个vector)跟矩阵合并到一起,避免两次计算
- 深度数据在视线方向上是非线性的,但是很少有人知道深度数据在屏幕空间上是线性的(即将屏幕空间上属于同一个平面上的两个像素进行连线,连线上的其他像素的深度值是可以通过线性插值计算得到的)
- 减少泛型逻辑,只有在需要抽象需要考虑通用化的时候再对代码进行优化,而应该避免过早的抽象导致的额外消耗。
其他的一些项目开发的考虑:
- 在设计的时候就要考虑性能,且持续的进行profile,避免性能的过大影响,这里举了一个例子,如果当前游戏帧率是10fps,如果有人提交了一个功能导致帧率下降为9.5fps,这种变化可能是难以被发现的,但是如果当前帧率是30fps或者60fps,这个时候这个提交就会导致帧率下降为26fps以及46fps,这种下降就会很明显,可以及时的发现问题出在哪个地方,避免后续追踪问题导致的开发效率下降。关于这个性能优化的重要性,如果有人引用Knuth的上述语句来反驳的话,就将全文贴出来好好教育教育他。
- 增加代码review机制,当提交的功能较为重要的时候一定要做好review,因为地基不正后面的上层建筑就会不稳,导致后续纠错的代价不断增加。当然,这里并不是说不允许一些hack的代码,但是即使有这类的逻辑,也可以通过review将之告知更多的同学,让大家对这个问题有所认识,后面有人有更好的方法就可以尽早修正。
网友评论