【游戏场景剔除】剔除算法综述

之前在做场景优化的过程中,看了不少论文和博客阐述不同剔除算法的原理和过程,自己参照着算法去实现了Hiz和软件剔除。一直想写一篇关于剔除算法的综述,来总结常用剔除算法的实现原理和过程。

在游戏运行中,引擎渲染出数以万计的物体,场景复杂度往往是数千万面的级别,同时还需要处理千计盏灯光和数百种材质。因此,如何有效地减少不必要的绘制就显得格外重要。本文将就游戏引擎中用到的各种剔除技术进行概述,会较少涉及细节,感兴趣的同学可以去看文末的参考文献,文献中会有相关剔除算法的原理和具体实现。

我们将分为以下四个方面来介绍:

1.场景剔除工作原理

2.常用剔除算法

3.总结

4.参考文献

一、场景剔除工作原理

对于场景物体的剔除一般分为可见性剔除和遮挡剔除:

1.可见性剔除

可见性剔除通过判断物体与相机的距离(距离剔除)或者是否在相机的视锥体内(视锥体剔除)来对物体进行剔除。

如图所示,不在相机视锥体内部的物体将被剔除不进行渲染。

2.遮挡剔除

遮挡剔除则是在相机可见范围内通过判断物体是否被其他物体遮挡来对物体进行剔除。遮挡剔除有基于整个物体是否被遮挡的剔除(Hiz、硬件遮挡查询等),也有基于像素级别的遮挡查询(Early Z)。

图中蓝色虚线的物体被相机前方的物体遮挡,并将剔除不进行渲染。

二、常用剔除算法

本文将大致介绍以下剔除算法的原理和实现过程:

(1).距离剔除

(2).视锥体剔除

(3).Occluder剔除(软件剔除)

(4).视口剔除

(5).背面剔除

(6).遮挡查询(Occlusion Query)

(7).Early Z Culling

(8).Hiz Culling

(9).PVS

1.距离剔除

剔除阶段:应用程序阶段。

通过物体和相机的距离进行判断物体是否被剔除,原理比较简单,剔除效率也相对较高。在UE4中可以通过物体属性设置剔除的最大距离和最小距离(如下图):

2.视锥体剔除

剔除阶段:应用程序阶段。

即简单的判断一个物体是否位于视锥棱台内。裁剪的依据主要是根据摄像机的视野(field of view)以及近裁减面和远裁剪面的距离,将可视范围外的物体排除出渲染。

上图中1为近裁剪屏幕,2为裁剪截面体,3为远裁剪平面

在实践中,由于模型往往是比较复杂的,很难精确计算它和视锥体的交集,因此一般是用轴对齐包围盒(AABB),有向包围盒(OBB)或者包围球(BSphere)代替模型本身进行相交计算。

视椎体剔除是减少渲染消耗的最有效手段之一,可以在不影响渲染效果的情况下大幅减少渲染涉及到的顶点数和面数。

3.occluder剔除(软件剔除)

剔除阶段:应用程序阶段。

这个方案的思路是,首先利用CPU构造一个低分辨率的Z-Buffer,在Z-Buffer上绘制一些场景中较大的遮挡体:

在构造好的Z-Buffer上,绘制小物体的包围盒,然后执行类似于occlusion query的操作,查询当前物体是否被遮挡:

由于是纯CPU的,集成起来也比较简单,同时不会有GPU stall的问题。缺点是需要美术指定一些大的遮挡体,对CPU性能有一定的消耗。在UE4中通过物体actor的LOD For Occluder设置遮挡体。


4.视口剔除

剔除阶段:投影变换之后屏幕映射之前。

发生在几何阶段(Geometry Stage)后期,投影变换之后屏幕映射之前,是渲染管线的必要一环。只有当图元完全或部分存在于规范立方体内部的时候,才将其返送到光栅化阶段。其中,对于完全位于规范立方体内部的图元,则直接进行下一阶段;完全处于规范立方外部的图元则完全被舍弃;部分处于规范立方体内部图元,则会根据视口进行对应的裁剪,在这一过程中可能会产生新的顶点。通过视口剔除可以将视口外的图元舍弃掉,减小光栅化阶段的消耗。

5.背面剔除

剔除阶段:在光栅化阶段进行。

当我们观察场景中对象时,一般只能以一定角度来观察,那么对象的某些面我们是看不到的,例如你观察一个立方体,最多只能同时看到3个面,有时只能看到1个面,而我们绘制时如果不采取剔除背面的措施,则要绘制6个面,其中包括一些我们根本看不到的面。对于立方体这个面较少的几何对象,性能开销不明显,但是对于复杂的模型,开启背面剔除则能明显改善渲染性能。 背面剔除,就是早点丢弃对观察者来说是背面的片元的一种方法。

剔除的基本原理是先判定多边形的朝向,并和当前的观察方向进行比较。opengl中设置背面剔除相关函数:

glFrontFace(GL_CW); 设置顺时针或者逆时针为正面

glCullFace(GL_BACK); 设置剔除正面或者背面

背面剔除在光栅化阶段进行,执行在Vertex Shader 之后,在Fragment Shader片元着色器之前。

6..遮挡查询(Occlusion Query)

剔除阶段:在深度测试时得到待剔除物体,在应用程序阶段执行。

参考步骤和代码:

developer.download.nvidia.cn

cnblogs.com/mazhenyu/p/

简单来说,occlusion query允许你在绘制命令执行之前,向GPU插入一条查询,并且在绘制结束之后的某个时刻,从GPU将查询结果回读到系统内存里。这条查询命令得到的是某次DrawCall中通过Depth Test的Sample数量,当这个Sample的数量大于0时,就表示当前模型是部分可见的,否则当前模型完全被遮挡。

opengl中实现API接口:

//生成查询物体ID

glGenQueries(GLsizei n, GLuint *ids);

//开始遮挡查询

glBeginQuery(GL_SAMPLES_PASSED, 1);

//结束遮挡查询

glEndQuery(GL_SAMPLES_PASSED);

//根据Sample值param是否大于0判断查询号为id的物体是否被遮挡

glGetQueryObjectiv(GLenum id, GLenum pname, GLint *param);

对于复杂的场景,一个显而易见的优化策略就是用包围盒代替模型本身去做渲染,为了更加精确,我们也可以用多个紧贴的包围盒或者相对原模型更简单的Proxy Mesh去做occlusion query。基于这些API,我们就可以得到一个比较简单的遮挡剔除策略:

  1. 首先为这些物体生成查询对象ID 调用glGenQueries
  2. 调用glBeginQuery开始遮挡查询
  3. 渲染包围体
  4. 调用glEndQuery 结束遮挡查询
  5. 调用glGetQueryObjectiv,根据ID提取遮挡查询的结果,并根据结果绘制相应的物体。
  6. glDeleteQueries 删除ID,回收资源。

Occlusion query的另一个缺点(也是最致命的缺点)是,它需要将查询结果回读到系统内存里,这就意味着VRAM->System RAM的操作,走的是比较慢的PCI-E。

为了解决这个问题,比较常用的的方法是让CPU回读前一帧的occlusion query的结果,用来决定当前帧某个物体是否visible,对于相机运动较快的场景,用上一帧的结果可能会导致出错,但由于一般是用包围盒,本身就是保守的剔除,所以总体来说影响不明显,UE4默认使用的就是这样的遮挡剔除方案

7.Early Z Culling

剔除阶段:在光栅化阶段后,片元shader执行前。

我们知道传统的渲染管线中,深度测试是发生在Pixel/Fragment Shader之后的但是,如果我们仔细想下,在光栅化的时候我们已经知道了每个片断(fragment)的深度,如果这个时候我们可以提前做测试就可以避免后面复杂的Pixel/Fragment Shader计算过程。

提到Early-Z就必须提对应的Late-Z:在图形管线中,逻辑上Depth Test和Stencil Test是发生在Pixel Shader的执行之后的,因为Pixel Depth在Pixel Shader阶段还有可能被修改,所以Pixel Shader->Depth Test的流程顺序就是Late-Z。但由于Pixel Depth修改的需求非常少(基于深度混合的Impostor和某些粒子效果),所以绝大部分情况下,Pixel Depth在Rasterization之后、Pixel Shader执行之前就可以被确定下来,如果我们能够把Depth Test放在Pixel Shader之前,对那些没通过Depth Test的像素不执行Pixel Shader,就能够一定程度上减少SM的压力,这就是Early-Z这个优化策略的初衷,现在已经是GPU的标配了。默认在Pixel Shader里没有修改Depth的操作时,这个优化就会开启。

UE4在Prepass中生成earlyZ Depth,然后在光栅化后执行EarlyZ Culling


8.Hiz Culling

剔除阶段:在几何shader得到待剔除物体,在顶点shader执行。

参考步骤和代码:

github.com/nvpro-sample

Hiz Culling同样是基于GPU但不同于EarlyZ Culling的剔除算法,Hiz Culling使用几何着色器先生成对应物体的包围盒,然后根据物体的包围盒选择对应层级的depth map。利用depth map 对应像素值对包围盒进行剔除,得到物体可见性并作标记。为了避免GPU返回标记到内存而造成时间消耗,通常使用Transform feedback将此数据流式传回到顶点shader中,也就是常使用的2-pass。

具体算法过程如下:

(1)拿到上一帧场景深度buffer,利用深度buffer构造分层深度图像,我们将其称为Hi-Z map。这些分层的深度图是对深度缓冲区进行mip-map得到,其中mip级别i中的每个像素包含mip级别i-1中的对应像素块的最大深度。

(2)将当前待绘制的场景物体分为两个集合:集合1.上一帧已有的物体集合(这里不一定和上一帧已有物体数量相同,有可能上一帧在相机可视范围而当前帧不在等情况)。集合2.当前帧新增的待渲染物体

(3)处理集合1:在构建Hi-Z map后,根据集合1物体的包围盒大小取对应级别的Hi-Z map深度图,并通过比较物体的包围盒深度值和存储在对应深度图深度信息来执行遮挡剔除,通常我们比较包围盒六个顶点深度值与对应位置周围的四个像素的深度值判断物体是否被遮挡。

(4)根据(3)剔除的结果绘制集合1,更新深度buffer

(5)处理集合2:利用新的深度buffer建立mipmap深度图,对集合2进行剔除。

(6)绘制集合2中物体,更新深度buffer。

值得注意的是:我们对剔除的判断是在几何shader中进行,完成物体可见性判断后,利用transform feedback 将可见性数据流传回到顶点shader中,这样可以避免数据从GPU写回到内存。

9.PVS

剔除阶段:应用程序阶段。

像其他剔除方法一样,预计算可视性体积用于实现中小型场景的性能优化,通常用于因为硬件问题而使动态遮挡剔除受到限制的移动平台。预计算可视性体积根据玩家或摄像机的位置,将Actor位置的可视性状态存储在场景中。

由于预计算可视性是在线下生成的,因此可以省去用于硬件遮挡查询的渲染线程时间,但代价是会增加运行时内存和照明构建时间。基于这一点,建议仅在玩家或摄像机可访问区域放置体积来保持可视性剔除。

  标准 PVS分为两步:

  1. 先求解简易模型:减面,枚举模型上每个顶点,找到一个点使得删除该顶点,模型变形最小,不停的寻找并删除影响最小的点直到模型变形超过一定阀值。最终求解出简易场景模型,为第二步计算做准备。

  2. 划分成小的三维格子,在格子里面均匀或随机选取 N个采样点做为摄像机位置,每个采样点 360度全方向做一定数量的射线出去,和场景中的模型判断交点,求解出该采样点的PVS,然后合并格子里N个采样点的结果为该格子的PVS。有离线计算好的,也有实时计算摄像机周围空间未计算格子的,等摄像机移动到那里时已经计算好了,无外乎精度不同。实际绘制时将所在格子的PVS提取出来再做一次视锥剔除就行。

三、总结

本文主要对当前引擎常用的一些剔除算法做了综述。剔除的本质是消耗少量的计算剔除尽可能多的物体,如果场景物体不复杂或者说互相遮挡不多,此时用一些计算复杂的剔除算法反而可能使帧率降低。因此,需要根据不同的情况选择合适的剔除方法,例如对于有大量植被实例场景可以考虑设置距离剔除,场景中有比较大的遮挡物则可以考虑occluder剔除,在手机平台我们可以考虑基于预计算剔除PVS等,通过这些剔除算法来提升游戏场景帧率。

四、参考文献

1.docs.unrealengine.com/e

2.blog.csdn.net/game_feng

3.zhuanlan.zhihu.com/p/48

4.software.intel.com/en-u

5.bazhenovc.github.io/blo

6.developer.nvidia.com/gp

7.gamedev.net/articles/pr

8.gameinstitute.qq.com/co

9.khronos.org/opengl/wiki

10.rastergrid.com/blog/201

11.zhuanlan.zhihu.com/p/47

12.zhihu.com/question/3806

发布于 2019-08-08 00:43