今天是年前最后一天工作,我想对最近做的一些事情做一些记录。
我们在使用自研引擎开发游戏时,遇到了不少和预期设计有距离的问题,针对问题再反思了原有的设计并做出改进。我认为、凭空设计一个通用游戏引擎是不可能的,必须结合实际项目,做许多针对性的实现。但同时应不断反思,避免过多的技术债。在最近一年,我参与的引擎具体编码工作很少,更多的是站在一个反思者位置,监督代码和设计的演变。
我们的引擎主体框架是基于 Lua 的,受益于 Lua 的动态性,可以很方便的把各个模块粘合在一起。但是、Lua 和 C/C++ 相比,又有两个数量级的性能差异,对于渲染代码而言,若将和 GPU 沟通的 API 完全放在 Lua 的 binding 层,对于对象数量巨大的场合,很容易出现性能问题。我估计、在手机环境这个“巨大” 差不多在 10K 这个数量级吧,而 PC 环境还能支撑到 100K 左右。
Lua 项目的优化无非两条路:使用 jit 技术、把热移到 C side 。正如 Roberto 所言,"Finally, keep in mind that those two options are somewhat incompatible" 这两条路需要的操作往往是互斥的。而我不喜欢 jit 带来的复杂度和不确定性,所以选择了后者。
为了解决大数量对象的批量操作问题,我在 2021 年进入了 luaecs 。这个库现在已经是引擎的核心,专门用来解决 Lua 处理大数据重复事务的性能问题。通过 luaecs ,我们可以把对象的初始化这个繁杂的过程使用 Lua 编写,而每帧都迭代的事务用 C 来实现。
一般来说,如果 10K 是场景可渲染对象的数目的话,那么对于很多游戏场景还是适用的。但如果Lua 调用的图形 API 放在太底层,手机上(以 Apple 的 A9 芯片为下线)一个流畅的场景却很难支撑到 10K 对象。这是因为,每个可渲染对象需要一系列参数传到图形 API 层,设置 VB/IB 渲染状态,尤其是大量的 uniform ,这些 API 差不多会在 10 个调用左右,它们会吃掉一个数量级,最终 Lua 层能流畅处理的数量大约只剩下 1K 左右了。
这是个几年前在搭框架的时候就已被估算出来的限制。我们精力有限,一直没有去解决。毕竟用 Lua 快速开发更有优势,而如果在 PC 上做原型,又对性能不那么敏感。
大约 2022 年 5 月左右,我们开始着手把日常开发迁移到真实的手机平台上,性能问题才彰显,便开始实施一再被推迟的相关重构计划。
主要是把整个材质系统的数据结构搬到 C side ,Lua side 只负责创建和修改材质,渲染则完全放在 C side 。中间的桥梁还是 luaecs 。
一开始是整理 Lua 中的数据结构,让它们更平坦化,这样方便迁移到 C 中。然后我写了一个 C 版本的材质数据结构,主要是管理弹性的 Uniform 。于此同时,根据需要重构了数学库 。这个版本依旧保持了 Lua 和 C 都易于访问的接口。生命期管理还是放在 Lua 里,但 C 已经可以绕开 Lua 接口直接读取其中数据并调用对应的图形 API 。
然后,我们用 Lua 重写了应用材质的 system ,在重写过程中,提取出真正的热点:那些需要反复调用的函数。最后把核心部分用 C 代码重新实现。这个工作断断续续做了几个月。难点在于按 ECS 的思路做拆解,让 C side 的核心 system 只做非常简单,工作量又很大的事情。
上个月还做了最后一次调整,给数据结构增加了一点弹性:因为我们发现一个可渲染对象在不同的渲染流程中会用到不同的材质。比如有正常的渲染流程,也有阴影渲染,还有点选系统。这些固然可以固定在引擎中,但具体游戏还会有特殊的定制要求,例如半透明、沟边、以及各种奇特的视觉效果,都可能需要引入新的 Uniform 。
增加数据结构的弹性在纯 Lua 或纯 C/C++ 中都不是难事。但对于两边都需要访问时,复杂度的增加是让我警惕的。luaecs 这一年的发展中,我也时不时的增加一些花俏的特性,然后又删除,就是不想让结构变得过于复杂。
最终,我发现我们可以利用 Lua 的动态性更好的解决问题。即,在运行期间数据结构都是固定的。一共用几个 Uniform ,它们叫什么名字都是确定的。但是我们可以在启动时,充分利用 Lua 的动态性进行组装。最后在 C Side 看到的还是一个固定大小的数组,但大小本身并不编译到 C 代码中固定下来。
其实,即使不做这些优化,1K 左右的可渲染对象对于很多场合也还是够用的。但我们的游戏却是个例外,这驱使我现在就去解决这一系列性能问题。我们这个仿制 Factorio 的游戏,有着大量的细碎物件:在公路上奔跑的小车,除了车体外,我还希望车斗内不同的货物也能用模型直接表达出来;仓库里的货物也能直观的在场景上看到。
为了尽可能的合并同类可渲染对象,我们拓展了引擎的挂接系统。针对具体项目,小车被特殊实现了:车斗内的货物并不直接挂接在车的节点上。因为空车都是同一模型,而车内的货物却千奇百怪。货物和车平级对待更容易被优化。货物和车之间仅仅是空间上的平移、并不需要涉及复杂的 3D 变换。货栈内的货物也是一个道理。
对于建筑,我希望它们可以像 Factorio 里面那样生动。我们为几乎每个建筑都制作了生产动画。但 Factorio 是基于 2D 制作的,动画仅仅意味着更多的内存占用,在内存中存放更多的序列帧就够了。我们现在基于 3d 制作,动画是基于骨骼系统的。如果有 10K 量级的骨骼动画,在手机上运行也是不小的开销。尤其是带动画的模型很难做 instance 合并。
我考虑过把骨骼动画全部烘培出来,全部当成静态模型渲染。但经过一些(空间开销的)估算,我放弃了这个想法。
在 Factorio 中,工厂会根据生产效率不同让动画以不同的速率播放。本质上,Factorio 是基于内在 tick 逻辑驱动的,每个 tick ,工厂要就在运作,要就停工。而它本身就是帧动画,选择画哪一帧的图就够了。3d 骨骼动画无法简单的按帧驱动。但我们可以换个思路。
其实,玩家所关心的工厂工作状态无非四种,正常工作、缺点停工、原料供应不足停工、产出拥堵停工。这些状态只要用不同颜色的灯带形式体现在模型上就够了。比动画表现更易读。(Factorio 有个 Mod 就是进一步改进这一点的)而动画表现只需要体现全速工作、效率不足、完全停工三种状态。我们不应该局限在 Factorio 已有的思路中,遵循它 2d 表现的逻辑:工作效率越低动画播放越慢。而可以制作做最多三套不同的工作动画:全速、半速、停工。从制作层面,后两种动画可以通过调速直接从第一种生成出来,也可以单独制作。但我们最终保证动画周期是一致的,且第一帧是重合的,可以在一套动画播放完毕后衔接其它任意一套。
而游戏中,只需要统计最近一个动画周期工厂的工作状态,统一播放下一个周期的动画就可以了。虽然动画比实际工作状态滞后一个动画周期,但视觉上是可以接受的。所有相同建筑在同一工作状态下,播放完全一样的动画,也可以简单的把它们 instance 化,一次全部渲染出来。
游戏画面的流畅不仅仅在帧率。如果方法得当,30fps 足够让人感觉流畅。对于我们这款游戏,我认为核心在于两点:1. 玩家在移动浏览场景时要足够流畅。2. 场景中移动物件的移动过程要流畅。
而我们游戏逻辑,其实是跑在一个较低周期下的(以节约手机的运算性能)。以 Factorio 的视角看,在手机上我希望确保 30fps 但 ups 运转在 30 fps 以下。我们的具体玩法和 factorio 不同,应该 10 ups 就够了。也就是说,对于第二点中的移动物体即车辆在行驶时,其实它们的轨迹是离散的。但为了让视觉上保持流畅却不可以让它们在场景中跳动。这就需要做一些额外处理。
当游戏跑起来,场景中会有几十甚至上百的小车在公路上行驶。如果这些额外处理全部交给 Lua ,也有一定的性能开销。不过这可以很方便的在 C side 实现。我们还是允许视觉上的物体位置和逻辑上的位置有一个滞后,在 C 代码中,可以通过 ecs 把这些小车都拣选出来,根据历史和目标位置,插值出每个渲染帧。