« 游戏引擎中的可视化编辑器 | 返回首页 | style 表的结构化访问 »

避免帧间不变像素的重复渲染

上周五在公司内做了一个技术分享,介绍我们最近五年来自研的游戏引擎,以及最近一年用这个引擎开发的游戏。大约有一百多个同学参加了这次分享会,反响挺不错。因为这些年做的东西挺多,想分享的东西太多,很多细节都只是简单一提,没时间展开。

我谈到,我们的引擎主要专注于给移动设备使用,那么优化的重点并不在于提高单帧渲染的速度,而在于在固定帧率下,一个比较长的时间段内,怎样减少计算的总量,从而降低设备的能耗。当时我举了几个例子,其中有我们已经做了的工作,也有一些还没做但在计划中的工作。

我提了一个问题:如果上一帧某个像素被渲染过,而若有办法知道当前帧不需要重复渲染这个像素,那么减少重复渲染就能减少总的能耗。这个方法要成立,必须让检查某个像素是否需要重复渲染的成本比直接渲染它要低一个数量级。之所以现存的商业引擎都不在这个问题上发力,主要是因为它们并没有优先考虑怎么给移动设备省电,而要做到这样的先决条件(可以廉价的找到不需要重新渲染的像素),需要引擎本身的结构去配合。

我没有在分享会上谈细节,是因为我不大想谈还没有做出来而只是在构想中的东西。但我同时承诺会写一篇 blog 展开说一下,便有了现在这篇。

我们尚未去实现这个想法是因为目前引擎的性能已经被优化到比较满意的程度,而完善游戏本身要重要得多。对于只有 3,4 个人的小团队来说,必须推迟一些不重要的工作。

我们的引擎虽然是用 Lua 编写的,但性能瓶颈目前在 GPU 而不是 CPU 上 。比如,开启 PreZ 这个流程,先把几何信息提交到显卡,减少部分重复的像素着色器的运算,就能明显的看出性能的提高。

PreZ 是一个非常流行的优化方法:我们先把需要绘制的对象写入 Z-Buffer ,这样就可以得到当前帧和屏幕对应的每个像素的 Z 值。然后在后续的渲染中,只要没有半透明的像素,都可以先和这个计算好的 Z 相比较,如果它将被后续像素覆盖,那么就不必运行它对应的像素着色器。

PreZ 的算法很简单,就是把场景多遍历并渲染一次即可得到所需的 Z-Buffer 。那么,有没有什么廉价的方法可以得到一张蒙版图,让当前帧相对上一帧并没有改变的像素都在蒙版上标记出来呢?我们可以想象,如果光照情况在帧间没有发生改变(这很常见),摄像机也无变化(除了 fps ,这也很常见),其实每一帧不变的像素其实占有很大的比例。即使是 fps ,摄像机也不是全程逐帧运动的。从节能角度看,我们减少了一个时间段内的大量像素着色器的重复运算,就将是个非常成功的优化。

如何用廉价的方法得到这样一个蒙版:蒙版上的 1 表示这帧不需要绘制的像素,0 表示需要绘制的。这个蒙版不需要生成的非常精确,任何本该是 1 的地方变成 0 都是可以接受的,反之则会导致 bug 。每帧结束后,不要清除 backbuffer ,而在绘制阶段,每个绘图指令都带上这个蒙版(就好比带上 PreZ 生成的 Z-Buffer 一样)就可以渲染出正确的结果。

为了简化问题,我们可以先不考虑阴影(后面再谈有阴影的问题)。那么,每个绘图指令都是对屏幕空间指定像素的直接修改。最终,屏幕上每个像素都被若干绘图指令修改了多次。正如 PreZ 可以帮助快速剔除特定像素上的多个不必要的绘制过程而只保留正确的那一个那样,我们也可以用类似的方法来在每一帧的开头生成蒙版。

为了表述方便,我们把绘图指令分为两类,红色和黑色。红色表示,这是一条上帧没有出现过的绘图指令;黑色表示,这条绘图指令上一帧出现过。这个信息,只要引擎合理的设计,是很容易知道的:任何一条绘图指令,它都能知道其参数是否和上一帧相比发生了变化。如果我们把红黑两色绘制到屏幕上,那么,任何一个像素只要出现过至少一次红色,它最终就是需要绘制的,而如果全为黑色,它就很可能可以保留上次的绘制结果。

有什么简单的途径可以知道某个像素在什么时候没有红色却也需要重新绘制呢?答案是实际绘制上这个位置的黑色的次数。只要次数完全相同,就能保证这个像素一定和上一帧完全相同。

这个算法很容易实现。我们每帧将 buffer 清零。对于黑色绘图指令,在光栅化时把对应的像素加 1 ;而红色绘图指令则加一个极大数。最终,我们比较 buffer 和上一帧 buffer ,将极大值以及和上一帧不同的值设置为蒙版上的 1 ,就得到了变化蒙版。

20 多年前,我在风魂这个 2D Engine 中实现过类似算法。在西游系列的游戏中运用,性能比同时期的 2D 图形引擎好不少,就是因为它可以剔除很多当前帧不必要重复渲染的像素。当然,当时我是在 CPU 中实现的这个算法,而今天改到 GPU 中去做也不算麻烦。

同样,除了 GPU 层面,我们还可以在 CPU 也运用类似算法,减少一些多余的图形指令。只需要把 backbuffer 按比例缩小(比如每个轴缩小 64 倍),得到一个粗略的网格。然后把每个绘图指令涉及的 mesh 投影这个 buffer 上的 AABB 矩形计算出来,用同样的方法把绘图指令记录在网格的每个格子上。最终,我们可以剔除掉那些当前帧和上一帧完全相同的格子。这些格子可以用来得到一个更粗粒度的蒙版,同时剔除掉对应的绘图指令。


阴影怎么办?

我倾向于为每个可以接收阴影的物件单独生成一张独立的较小的阴影图,而不是像传统方式那样,将所有的场景物体渲染去一张非常大的单一阴影图上。

初看这个独立阴影图的想法会觉得性能无论上时间还是空间上都难以接受。因为如果有 n 个物体需要接收阴影,有 m 个物体会投射阴影,那么就需要绘制 n * m 次,并生成 n 张阴影图。

但实际上,单独的阴影图会比流行的 CSM 等算法更简单,也更容易提高单个物体阴影的精度。而大多数情况下,只要场景上的物件大小不是差距很大,且分布均匀的话(第三人称视角的游戏中非常常见),每个物体只会接收其周围很少几个物体的投影。而一个物体受哪些物体的投影影响这个信息,在帧间通常变化很少,所以筛选过程并不需要每帧全部重新计算。所以,生成 n 张阴影图的成本远远不到 O (n * m) ,应该可以优化到 O(n Log m) 左右。

如果再考虑做以上相同的帧间 cache ,对于每个物件单独的阴影图(也只是它材质的一部分),很可能下一帧并不需要重新生成,只需要投影它的有限几个物件没有改变即可。整体的(尤其是能耗)成本很有可能比传统方式更小。

Comments

感觉结合Visibility Buffer的渲染管线可能可以做到,Visibility Buffer Pass做完后每个像素的渲染信息都有了,不过整个渲染管线也是GPU-Driven管线了。

感觉形而上学,最关键是优先UI线程的时间片分配,对后台线程的时间片分配进行劣后处理直到UI的渲染处理与后台线程同步

@bbb

经常有变化 != 每帧有变化

这个是针对移动设备的优化,优化的目的不是提高帧率,而是省电。即使每 3 帧中节省了其中 1 帧一半的 GPU 开销,或者运行 10 分钟,其中有 20 秒镜头静止,也达到了省电的目的。

这个优化与游戏类型关系很大,如果经常有视角变化,或全屏特效等就不行了

"同样的指令,绘制顺序不同"

如果 A 和 B 顺序不同,那么,从上层逻辑看来,要么 A 是红色的要么 B 是红色的。

如果同为黑色,就是引擎上层的 bug 。

这里的实现取决于上层如何管理绘制。以我们的引擎为例,每个绘制可以归属于一个 view ,不同的 view 决定了相互次序。如果 A 和 B 在不同帧属于相同的 view ,那么它们一定有相同的次序;如果改变了 view ,有可能改变次序,但从引擎角度,改变了 view 的那个绘制指令一定是红色的。

换句话说,从引擎上层看,每个对象最终以什么次序绘制是由对象的若干参数决定的,参数的变化是引擎自己能感知的。有些参数传入了驱动层,有些只决定对绘制指令的排序。

同样的指令,绘制顺序不同也会导致最终像素不一致,所以只对比黑色出现次数貌似不行

现在很流行的dlss、fsr等都是很好的解决方案,比如fsr2.0需要一张motion vector纹理,通过该纹理就能知道哪些像素没有移动,从而减少性能开销

flash的脏矩阵就是这个

如果采用延迟渲染的管线,就完全不需要 preZ ,因为 G-Buffer 包含了有等价的信息。

我们现在的光照采用的是 Cluster forward shading , prez 是有意义的。

Tiled base 的优化只在一个 tile 真的和上一帧完全相同才会被硬件把绘图指令剔除掉,如果这个 Tile 哪怕只有一个像素会变化,或者虽然实际没变化,但因为硬件无法确定,都会变得无效。

PreZ 也好,或是我这篇讲的方法也好,都是以像素为单位,而不是以 Tile 为单位进行的,所以有区别。

https://zhuanlan.zhihu.com/p/565820105
奇怪,有什么打断了延迟优化吗🤔prepass在hsr下应该完全没有意义。fpk似乎好一点,lrz限制最多样子。

开关 PreZ 在我们游戏上测试过,实际可以降低能耗。(因为提升帧率没有意义,所有优化都以一段时间的总电池消耗为准)

https://zhuanlan.zhihu.com/p/345065791

阴影可以用Virtual Shadow Maps方案🤔另外,手机上好像不建议专门prez。如果没有其它需求,对现在的移动gpu是负优化样子,gpu都已支持某种延迟ps。

@怪物公司

这个和手机上基于 Tile 的优化有点区别。手机芯片是想办法去了解某个 Tile 的每条绘图指令是否和上一帧有所区别。这是底层芯片能够做到的:只要一个 Tile 上的至少一条绘图指令不一样(脏了)就需要执行这个 Tile 上的所有绘图指令(通过计算 CRC),反之,就可以忽略整个 Tile 相关绘图指令。芯片在底层做此优化时,可以和上层完全无关。这更像是这篇 blog 中后半段描述的算法。

这个优化对于手机 app 上一般出现的静态画面非常有效,而对游戏效果甚微。

但如果我们可以结合引擎架构,掌握更多的信息,就可以做到每个像素的帧间重复剔除了。每个像素的性能节省都会是有意义的。

Transaction Elimination (TE) is a key bandwidth saving feature of the Mali GPU architecture which allows for significant energy savings on a System on Chip (SoC). When performing TE, the GPU compares the current frame buffer with the previously rendered frame and performs a partial update only to the particular parts of it that have been modified, thus significantly reducing the amount of data that need to be transmitted per frame to external memory. The comparison is done on a per tile basis, using a Cyclic Redundancy Check (CRC) signature to determine if the tile has been modified. Tiles with the same CRC signature are identical; therefore eliminating them has no impact to the resulting image quality. TE can be used by every application for all frame buffer formats supported by the GPU, irrespective of the frame buffer precision requirements.

实际中与复杂度、效果特性有关。或许是目前手机因功耗、电池原因用不起3a中常见技术🤔

先码再看

我在2018年深入研究了苹果的4.7寸的那个手机(苹果7,后来叫se,se2,se3),电池是1800Mah,这个手机这么小的电池,但是使用的续航力和图形效果并不比pro版本差,主要的核心问题就是显示bmp或者jpg图的时候,通过jpg图计算出一种3秒的ts流,显示屏渲染高清图片的时候,尤其是切换的时候,不会把整个屏幕瞬间的刷新,而是一种平滑的方式来渲染一个流(3秒TS流),这样,平均显示100张高清图,比同样条件的安卓机省电25%。

Post a comment

非这个主题相关的留言请到:留言本