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

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

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

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

我没有在分享会上谈细节,是因为我不大想谈还没有做出来而只是在构想中的东西。但我同时承诺会写一篇 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 ,对于每个物件单独的阴影图(也只是它材质的一部分),很可能下一帧并不需要重新生成,只需要投影它的有限几个物件没有改变即可。整体的(尤其是能耗)成本很有可能比传统方式更小。

文章来源:

Author:云风
link:https://blog.codingnow.com/2023/12/pixelculling.html