« November 2023 | Main | January 2024 »

December 25, 2023

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

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

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

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

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

December 12, 2023

游戏引擎中的可视化编辑器

提起游戏引擎,特别是商业通用游戏引擎,比如 Unreal 或是 Unity ,给人的第一印象会是它们的可视化编辑器。而在实际开发中,在不同引擎下做游戏开发,影响最大的是引擎层的 API 以及这些 API 带来的模式。

而对于使用自家专有引擎开发出来的游戏,却少见有特别的编辑器。比如以 Mod 丰富见长的 P 社游戏,新系列都使用一个名叫 Clausewitz 的引擎,玩家们在之上创造了大量的 Mod ,却不见有特别的编辑器。Mod 作者多在文本上工作,在游戏本身中调试。游戏程序本身就充当了编辑器:或许只比游戏功能多一个控制台而已。在这类引擎上开发,工作模式还是基于命令行。

游戏引擎中的那个编辑器无疑是引擎开发中耗工时最多的部分。我们自己研发游戏游戏已有 5 年 ,其中编辑器完全重做了两次,目前第二个人维护着第三版。在这几年里,我一直在思考:游戏引擎到底需要一个怎样的编辑器、它应该用来解决怎样的问题。

在 20 多年前,我写风魂的时候,它受 Allegro 的影响最多。当时,只要封装出 API ,解决图形、声音、键盘、鼠标、系统窗口等的底层调用就解决了游戏开发中最难的部分。后来,到 2001 年开发大话西游,我根据游戏的需求为引擎写了几个小工具,用来编辑场景、2D 动画等等,支撑了游戏开发。那些工具是为游戏定制的,同样定制的还有一些对应的程序模块。我认为不属于引擎的范畴。

后来,各大游戏公司纷纷转向了商业游戏引擎,家酿引擎越来越少了。我在 2005 年时开发 3D 引擎时,也受那些商业引擎的影响,觉得游戏引擎必须要有一个大而全的编辑器。如果缺少这个,似乎没有人愿意用它开发游戏。2017 年底,我重启游戏引擎计划时,依旧觉得,开发一个编辑器非常重要,否则游戏引擎很难吸引开发者。

但是,别的引擎有什么,我们就应该做什么。这绝对不是一个好主意。因为复制似是而非的特性并不能真正解决问题。我们首先需要理解问题。一个功能丰富的游戏引擎编辑器看起来是为了减少编码 (low code ?) ,降低游戏开发的难度,让不太会写程序的人充分发挥他们的创意。

但这并不符合我们自己的需求。因为,我们项目组的所有人都有丰富的编程经验,code 并不是难事,不用会、少用 code 做同样的事反而增加了开发难度。对于一个软件项目来说,开发者必须是第一用户,Dogfooding (吃自己的狗粮) 对软件开发尤为重要。在软件开发这么多年中,我学到的最重要的一点就是:如果一个特性你不常用到,那么就应该立刻从代码中删除,直到以后用到了再加回来。所以,我们编辑器的第一次完全重构就是因为抛弃了复刻一个 Unity 编辑器的想法。我们做引擎绝对不能因为别人有什么而做什么,用户也不会因为这个引擎也有同样的功能而选择它。如果我们自己开发游戏不会用 low code 的模式开发,那么我们就不应该做一个以减少编码为目的的编辑器。

那么,是不是意味着我们的游戏引擎不需要一个丰富的可视化编辑器?只需要把 API 设计好,可以方便的用代码构建游戏就够了?

有一段时间,我们在引擎开发上是这样的:用简单的几十行代码就可以搭出一个小 demo ,测试或展示某个特性。但这会让引擎停留在渲染层上,离做游戏还很远。最糟糕的是,这些 demo 代码中充斥着 magic number :摄像机的角度、灯光的参数、硬编码的文件名…… 不可忽视的是:游戏中大量的内容是以数据形式表达的,而不是代码。数据最终呈现的是画面效果,它们需要根据视觉效果调整。一个可以快速启动的程序能够改善调整这些数据的体验;但通过文本编辑器修改这些数值绝不是高效的方法。所以、我们需要可视化编辑器。

游戏引擎的编辑器:是一种用来产生游戏数据的可视化工具。这些数据如果可以用更成熟的工具产生,那么就不必将功能集成在编辑器中。例如,我们并不需要引擎的编辑器做 3d 建模,也不需要有笔刷像 photoshop 那样绘制贴图;同样,集成一个代码编辑器编辑脚本的意义也不大。它最重要的作用在于把代码逻辑和数据分离。一个好的编辑器可以产生出数据,然后引擎的代码只需要读入这些数据就可以创建出游戏中的实体。

同时,引擎的 API 应该为之简化。在缺乏编辑器时,引擎 API 层提供的大量 API 都是用来让代码可以正确的构造游戏实体。这些 API 反应了各种数据是如何控制每个细节的。但有了编辑器创作好的数据后,数据已经代码运行之前就组织成应有的复杂结构,所以,引擎只需要提供单个 API 加载这些数据就够了。所谓编辑器,编辑器的就是某种预制件(prefab),预制了最终运行时的数据结构。游戏的大部分数据是用于视觉表现的,所以需要一个可视化的手段编辑和呈现。

编辑器产生的数据是引擎运行时的输入。这些数据应该是易读的,但不必是易于(用文本编辑器)编辑的。我们设计了一个专有格式 来描述游戏引擎中的大部分数据。尽量把代码逻辑(用 lua 编写)和数据分离。引擎提供的 API 中很少有特别细节的控制接口,所以,脱离编辑器制作一个游戏 demo 是很难的。因为开发者缺乏直接控制构建数据的 API ,难以硬编码摄像机位置、灯光信息、角色的空间状态在代码里,最佳的途径是加载一个编辑器编辑出来的预制件。这样,也促进了我们吃自己的狗粮。避免编辑器成为游戏引擎的一个边缘子项目。当然,因为编辑器产生的数据还是文本的,硬去手写一个预制件也不算太困难。

在搭建了最终的编辑器框架后,我们更多的是在实际游戏项目开发中遇到问题,就顺手给编辑器加一点功能。例如,编辑一个摄像机运镜的轨迹、写太阳一天的日夜循环模拟,方便美术调整光照变化的参数,等等。这些功能都是为实际游戏服务的,并没有打算做成通用引擎的一部分。

鉴于我们现在开发的游戏中并没有使用物理特性,前两个月便把曾经集成好的物理模块又去掉了。同时去掉的当然还有相应的编辑器功能。而预制件的动画及特效的时间轴编辑则在不断完善,因为美术总有一些需求,原来是在外部工具(例如 blender )中编辑好再想办法把数据导入引擎,慢慢的却发现在我们自己的编辑器中编辑有时更方便。只维护现在游戏项目用得到的特性,我想这才是一个好的状态。