« December 2022 | Main | February 2023 »

January 20, 2023

最近开发中解决的一些性能问题

今天是年前最后一天工作,我想对最近做的一些事情做一些记录。

我们在使用自研引擎开发游戏时,遇到了不少和预期设计有距离的问题,针对问题再反思了原有的设计并做出改进。我认为、凭空设计一个通用游戏引擎是不可能的,必须结合实际项目,做许多针对性的实现。但同时应不断反思,避免过多的技术债。在最近一年,我参与的引擎具体编码工作很少,更多的是站在一个反思者位置,监督代码和设计的演变。

我们的引擎主体框架是基于 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 把这些小车都拣选出来,根据历史和目标位置,插值出每个渲染帧。

January 10, 2023

虚拟文件系统的 mod 机制

上次谈游戏引擎的虚拟文件系统是去年 10 月了。最近我们又做了一些修改。

我们的虚拟文件系统有两个工作模式。一种模式主要用于编辑器和开发环境,所有的文件名和路径都基于原生文件系统,我们叫它编辑器模式;另一种模式被称为运行时模式,主要用于运行时环境。文件均用类似 git 仓库的形式,用文件内容的 hash 值作文件名,储存在一颗 Merkle tree 上,并通过一个专门的 file server 为运行环境提供资源的更新服务。

在编辑器模式下,并不是直接映射原生文件系统的整棵目录树的,而是增加了一个 mount 配置,可以把很多不同的目录装配在一起。当初这么设计是希望提供一定的灵活度,方便游戏项目可以把引擎提供的基础功能和资源组装进来。

最近做了一些改动。把这个 mount 机制改成了 mod 机制。

mod 机制在很多游戏中都能见到。即,可以指定一个 mod 目录,这个目录下有着和游戏主目录相同的结构。游戏运行的时候,这个目录下的文件有着比游戏主目录下同名文件更高的优先级。游戏程序想读取某个文件时,会优先读取 mod 目录下的文件,如果文件在 mod 目录下不存在,才会查找游戏的主目录。

mod 目录通常可以有多个,可以定义优先级次序,一级级查找。

Paradox 的游戏都支持 mod ,玩家可以方便的用这种方式扩展游戏。我就维护着群星的一个非官方汉化 mod 。我最近玩的较多的 factorio 和 rimworld 都采用类似的机制。相信还有更多游戏也是。我记得我第一见到类似的东西是在 Quake 中,它的补丁文件就是一个个用 zip 打包的相同结构的文件目录。每次发行新的补丁,只需要把新增和改变的文件打包放在游戏目录下。

我们公司的项目也是如此,比如现在正在运营的三国志战略版也使用了这个方案。

我最近觉得,这种 mod 方案比更灵活的 mount 机制(可以随意拼装不同的目录结构)更容易维护,更方便非技术人员使用。如果这个机制直接做在引擎的底层,应该在虚拟文件抽象层就支持好。

一个最显而易见的好处是方便开发人员的开发调试。比如美术想修改一下美术资源看看效果,他就不用破坏本地仓库,把修改版本放在 mod 目录就够了。如果修改本地仓库,便会面临同步的问题。对策划也是如此,很多配置表格的修改都是暂时的,不希望提交。有专门的 mod 目录,甚至几个 mod 目录,比学会用 git 切换分支更容易。

有 mod 机制后,引擎也方便提供更多的默认选项。我们可以把最常见的配置都写好直接放在引擎的主干中。还可以把一些扩展功能放在独立子仓库中,以插件形式提供。这些插件子仓库有和引擎相同的目录结构,插件功能可能包含一些着色器、脚本、原本需要配置在引擎目录结构下的不同目录中。

具体游戏项目只是对引擎提供的默认项目的一个 mod ,把需要修改的文件改写好即可。做新的游戏,就是对已有的游戏打个 mod ,这对开发人员来说也比较容易理解。

或者应该这么说:游戏引擎游戏引擎要做的事情不应该是提供零件让开发者组装成游戏;而是应该做好游戏,让开发者去制作 mod 。