-
Notifications
You must be signed in to change notification settings - Fork 395
TechnicalFeatures
几乎所有的游戏引擎的内核都是使用 C/C++ 这样的编译语言实现的。这通常是因为游戏引擎对性能相当敏感。而大多数引擎都会在内核之外再支持一种动态语言,方便应对多变的需求。而 Ant 引擎截然不同,它的内核结构是用 Lua 搭建起来的,C/C++ 实现了不少独立的库嵌入到引擎中。这种结构让引擎更具有弹性,方便使用者定制。而独立的 Lua 库使模块之间有了更明确的边界,当你想理解引擎的某处细节时,可以聚焦到某个很小的强内聚的部分。我们希望引擎的使用者能更好的理解引擎的结构,而不仅仅是使用它。很多设计决定都是为了方便理解、修改和定制。
为什么其它游戏引擎未能采取类似的结构?恐怕核心问题在于性能。
一个纯 Lua 实现的内核会比 C/C++ 内核慢两个数量级,这对于大部分游戏来说是不可接受的。Ant 的早期版本就是这样,在开发小型 Demo 时完全没有问题,但演变为正式的复杂游戏后,一旦在场景中的堆砌成千上万的物件,性能就变得不可接受了。好在我们最终解决了性能问题。我们的游戏,同屏处理多达 4000 个以上的游戏物件,在 iPhone 12 上依然可以将每帧的开销控制到 10ms 以下。而性能分析的报告表明,此刻性能瓶颈落在了 GPU 而不是 CPU 上。这篇文章会介绍一下我们用到了怎样的方法克服基于 Lua 游戏引擎内核的性能问题,打消用户的性能顾虑,放心使用它。
游戏物件最高频的处理在于每帧的渲染过程。引擎需要组织渲染需要的数据结构,每帧提交到 GPU 。如果你希望让游戏运行在 60 fps ,那么每帧的渲染处理过程必须控制在 17ms 之下。如果需要处理的对象在 10K 数量级,那么分配给每个对象的时间是非常少的。面对 GPU 的 API 是 C/C++ ,当内核使用 Lua 组织,且数据以 Lua 的数据结构储存,就需要在毫秒时间内让数据通过数千次的 Lua 和 C 的边界。通过 Lua/C 的边界开销很大,一次 Lua 对 C 函数的调用,大约相当于一次 OS 的系统调用。比普通的 C 函数调用慢数十倍。这是需要优化的重点。
涉及渲染的数据相对游戏逻辑的数据来说并不算多,数据结构也相对固定。并非所有的业务都需要高频处理。如果以类型 Unity 的基于 GameObject 的数据组织方式,恐怕很难兼顾渲染的高效和 Lua 的便利。
为此,我们设计了 LuaECS 。
ECS 可以方便我们更好的解耦,把渲染的核心内聚到很小规模的代码上。还有另一些性能热点,例如空间裁剪等也可以独立处理。LuaECS 解决了一个重要的性能相关的问题:可以把性能敏感的数据放在 C 结构中,变成内存中平坦的结构数组。这样,就能用 C/C++ 编写独立的 System 处理这些数据,而不必通过 Lua/C 的边界 。其代价仅仅只是从 Lua 中访问这些数据时比原生的 Lua 数据结构慢一些,但依旧保留了直接通过 Lua 代码操控它们的能力。我们往往只在初始化和销毁数据时通过 Lua 访问它们。以初始化为例,它只运行一次,不会成为性能瓶颈,但代码量却不少。用 Lua 编写相关代码更为轻松。
Ant 引擎的早期版本,一切都是 Lua 实现的。但我们一开始就设想了后面从 C/C++ 改写性能敏感部分的计划。优化哪里基于 Profile 的结果进行,而不是臆想出来的。当我们逐步的把性能热点找出来,一点点的相关的数据结构从 Lua 移到 C ,同时可以暂时保留 Lua 的初版实现。然后,再编写等价的 C/C++ 实现,最终达成了性能指标,又可以轻松的维持正确性。
另外,我们在 Skynet 有多年的开发经验,它是一个 Actor 框架,可以充分利用处理器的多核能力。在 Ant 引擎中,我们采用了类似 skynet 的 ltask ,它为客户端环境做了颇多优化。这样一个框架,可以方便我们轻松的用 Lua 编写多线程程序。更重要的是,引擎的各个模块被天然拆分到了独立的 Lua 虚拟机中,这让每一块都有极高的内聚性,降低了大项目的复杂性。
IO 、UI 、资源管理、特效、声音、游戏等都被分到了不同服务中,它们是不同的线程不同的虚拟机,整体效率提高了,还减少了单个虚拟机 Lua GC 带来的性能冲击。得益于此,Ant 中大部分资源都是异步按需加载的,内存开销也可以独立控制。