« December 2023 | Main | February 2024 »

January 17, 2024

Ant Engine 开源

我在自己研发的游戏引擎上已经工作了 6 年了。在 2017 年底,我写下了对这个新引擎最初的构想 。现在回头来看,当初的想法居然都落实了,只有一点例外:我们中途把编辑器从 IUP 转移到了 ImGUI 上。

2022 年,我们启动了第一个用这个引擎开发的游戏项目,它是一个和日本公司合作的动作游戏。后来,这个项目没有走下去就取消了。之后,因为我们的引擎开发组喜欢 Factorio ,便想用自己的引擎在手机上重现一个 Factorio Like 的游戏,这一干就是一年多。

现在,游戏的技术部分基本完成,可以验证引擎的可用性(功能完整、性能达标),只是游戏性方面还有不少路要走。简单说就是还不太好玩。

从一开始,我就希望以开源模式经营这个游戏引擎,但同时又觉得没有得到验证的东西不适合拿出来。既然引擎已经初步可用,现在就应该迈开这一步了。

毕竟引擎是公司的资产,原来我是自己创业公司的老板,但现在公司已经被阿里收购。开源并不是我一个人可以做的决定。最近一段时间,我争取到了公司的支持。甚至,公司不仅同意我将整个引擎项目开源,还愿意把我们正在制作的游戏的代码及其美术资产也捐赠给这个开源项目。这可以方便引擎开源后潜在的用户去理解引擎。

关于这个开源引擎,有太多的东西可以写,我这里先摘录一些我在向公司申请开源时的报告中的一些内容。文字上有一些吹嘘之词,也有一些未经核实的臆断,但总体上可以反应我的看法,一家之言,姑且听之:

为什么需要一个新的游戏引擎?

目前市场上有两大成熟的游戏引擎:Unity 和 Unreal 。为什么有商业游戏引擎,却依然选择自研游戏引擎?Unreal 目前开放了源代码,而 Unity 我们公司也采购了其源代码。所以,掌握源码并不是主要原因。

我们公司赖以生存的网络游戏项目,和 Unity/Unreal 这些游戏引擎所面对的游戏项目,有很大的本质不同。即,几乎所有的网络游戏产品,都处于长期维护状态,并不以创意多产为主的。商业游戏引擎,它给产品带来的便利在于可以速成游戏原型,而在长期维护方面并无太多优势。一旦产品进入稳定期,更多的是需要在产品上慢慢维护和打磨,或是更换非程序资源以较小代价制作衍生产品。针对特定产品,拥有一个易于维护的游戏引擎,成本是逐步下降的。无论是 Unity 还是 Unreal ,它们的维护成本都极其巨大。我们甚至已经放弃了 Unreal 的维护工作,专注于 Unity 的维护,但依然成本高昂。对 Unity 增加项目需要的特性、性能优化、相对一个按项目定制的引擎来说要考虑的问题更繁杂。

维护 Unity ,不光是我们公司独有问题。国内各大游戏公司都花了极大的人力做这件事。最流行的游戏:王者荣耀、原神等都声称对 Unity 做了极大的改造,几乎全部更换了其核心组件。这也是自然的选择:一个全功能的游戏引擎,必然包含了大量单个游戏所不需要的部分,这些都会成为长期的负担,而游戏运营时间越长,它所需要的引擎功能越单一,维护人员需要聚焦在特别需要的功能或优化上,对原版引擎做改造以适应。

Unity 原本并不为我们这类游戏网络所设计,它的资源包打包更新、针对移动设备的优化、和脚本语言(Lua)的整合,从来都是各家国内游戏公司所面临的痛点。国内大厂几乎都购买了 Unity 源码,但至今只听说层出不穷的补丁方法,而未见有对这些改造彻底的方案(包括我公司)。拥有源码的访问和修改权限,并不能解决这类问题。

为什么 ANT 引擎可以胜出?

开源界最近些年也有一些开源引擎,例如 Godot 。但是,我们的优势在于,更理解我们的游戏项目到底要什么,并可以依据我们的理念掌控引擎的发展方向,而不是像 Godot 那样,企图复刻一个开源版的 Unity 。我们从 2000 年开始开发网络游戏,几个大的成功项目均使用自研的引擎代码:例如西游系列、陌陌争霸、三国志战略版。只是它们均为 2D 项目(随着项目发展加入少许 3D 技术),这是因为 3d 技术的门槛要高的多,需要更多的积淀。

业内,游戏引擎技术每隔几年就有新的想法涌现,然后对已有的引擎做大的改造。开始越早的引擎,历史包袱越多,改造就越困难。例如 Unity 推行的 DOTS 技术就是为了解决它低下的性能问题,但好些年了也未能彻底推翻旧框架。后来的引擎可以轻装上阵。我们的开发团队即有长年(超过 20 年)的技术积累,又能不受老架构的束缚。ANT 本身也经历了 6 年的开发,踩了太多的坑,交了许多的学费,这样才获得了技术上的自信。

维护良好的引擎能随着公司的发展获得越来越大的成本效益。自研引擎的弊端在于开启阶段的风险(很可能无法按质完成),初始投入巨大;而这一步,ANT 已经迈过去了。

为什么要选择开源模式?

游戏引擎属于产业的基础设施。游戏产业早就过了通过秘密掌握更好的基础设施以获得竞争优势的时代。基础设施最适合开源。例如操作系统领域,几乎所有提供公众服务的系统都跑在开源内核上。越是基础设施,越需要更好的性能,更高质量的实现。增加开发人员对提高质量并无收益,它需要的是领域专业人员。而领域专业人员在整个世界范围都是稀缺品,并非靠招聘就能解决,一个好的开源项目更容易吸引到这样的人才加入。

同类别的开源项目也有竞争,竞争的是用户和高质量的开发人员。而开源游戏引擎(尤其是 3D )因为门槛很高,竞争并不激烈, 在国内圈子甚至是空白。所以现在做此开源项目有先发优势。一个良好的开源项目长期运营,必须凝聚人和项目,要持续有人用、有人持续开发。一旦人捆绑上去,项目就会像滚雪球一样壮大。我们并不需要掌控技术本身的秘密来获得技术壁垒,掌握项目的主导性就可以获益良多。开源项目的良性发展会不断促进项目本身的质量,远超闭源项目。项目的高质量是极其珍贵的。

Skynet 就是一个极好的正面案例。

Skynet 是一个为网络游戏服务的基础框架。 2012 年 7 月开始编写第一行代码,同年 8 月就以开源模式维持到今天。它为我们公司所有的游戏项目提供了高质量的底层保障,有很多 Bug 甚至是未在我们自己的项目上出现之前就被解决掉的。这得益于它有大量的公司外用户。因为它一直以开源形式开发和发展,吸引了大量的用户。现在在 github 上拥有了 12.4K Star 4.1K Fork 以及 123 个 contributors ,大多数 contributor 不是我们自己公司的同事;部分 Contridutor 后来加入了我们公司。

因为 skynet 被各个公司的项目采用,它的应用面远超我们一家公司的应用场合。这使得 skynet 的发展过程中,会非常仔细的考虑各种边角,保持它的精简,避免它演变为屎山。这么多年没有演化为屎山又反过来给它带来更多的用户。目前 skynet 不光是用于游戏服务器领域,在视频网站、路由器管理方面,也有不少应用。

从搜索引擎的结果看, Skynet 在国内网络游戏圈子,已经是众多中小公司的选择。在招聘网站上,很多游戏公司招聘都会有限考虑有 skynet 经验的候选人。从这个角度,它也帮我们节省了巨大的人力成本。我们因 skynet 树立的技术品牌,吸引了高质量的人才;新加入的员工在进入公司之前就用丰富的 skynet 经验,可以更快的融入工作。

对于程序员来说,也更希望在职业生涯中使用一项活的技术,而不是换了公司,换了行业,就放弃了过去的积累。为开源项目工作可以获得更大的成就感,成就感对顶尖的程序员来说至关重要。

ANT 引擎的特色

ANT 专注于移动平台,在移动平台上,不光要保持帧率更要节省能耗。这会导致引擎结构设计上有所配合,这些是我们在设计时时刻考虑的方面。而且,ANT 区别于 Unity ,它尽可能的让开发工作随时保持在真实移动设备上运行,这对在移动设备上做出更好的交互体验,意义重大。

ANT 基于 Lua 开发,有极低的理解成本,和极高的动态可定制性。以往基于 Lua 这类动态语言开发的基础设施中,最难解决是性能问题,而性能恰巧又已被 ANT 解决的相当不错。ANT 在手机上有极好的性能,我们用它开发的游戏,在及其复杂的场景下,iPhone 12 上依然可以小于每帧 10ms 的速度渲染。对于同等复杂度的场景,Unity 实现同等的效率将非常困难。

因为有几十年的网游维护经验,我们深知资源打包更新这些对项目的重要性。ANT 在这方面做了彻底的支持,方便客户端更新。方便很多美术、策划一起共同创作。

ANT 一开始就设计成易于定制的,不用的特性可以轻松的去掉、渲染管线可以轻松改写。甚至可以方便拓展到非游戏应用中。例如腾讯的 QQ 客户端就集成了 Unreal 引擎,而这项集成至少会增加 App 数十兆的体积;而 ANT 引擎本身只需要链接 1M 的二进制模块。

ANT 引擎的现状

已经开发了 6 年,使用这个引擎开发的游戏有一年历史,技术部分已经全部完成(游戏体验部分还需要调整),性能方面超出预期。

目前只有 iOS 版本完全可用。引擎可以在 Windows 上运行,但缺乏一些和 PC 相关的支持(比如接入 Steam ,更好的键盘鼠标支持)。Mac 版本略有不足,Android 版本尚未完成。

引擎缺少引导性的文档,需要在开源发布前编写一些基本的指导。

目前的游戏代码并不是很好的引擎示例,因为它跟随着引擎最近几次重构,残留了许多不太好的实践。而内置例子只用于测试,还相当不完善。我们还需要制作一个更轻量的 Demo 展示引擎特性。


今天,我已经将 Ant Engine 的私有仓库公开。感兴趣的同学可以自由访问。但这个项目尚未正式发布,该有的文档和实例都很不完善。这是我们正在开发中的游戏使用的引擎,所以至少它是可用的,同时也可能随时被修改。

我会在过年前多写一些文章介绍引擎的结构和设计思路,这也是 skynet 开源初期我所做的事情。等年后,再将我们的游戏代码仓库也开放出来。

January 10, 2024

style 表的结构化访问

我们游戏 UI 基于 RmlUI 的 fork,做了大量的改造。它实际上类似目前的 web 前端技术,使用 CSS 来表示 UI 的布局。所以,我们做的底层工作也都是围绕如何高效实现一套基于 CSS 的 UI 引擎来做的。

一年多前,我写过一篇 blog 介绍了一些优化的工作

最近,在游戏开发的使用中,我们又发现了一些性能热点,最近在着手优化。这一篇 blog 记录一下其中的一个优化点。

按目前引擎的抽象,每个 style 其实是一组 attrib 的列表。而单个 attrib 则是一个 k/v 对。k 虽然使用上是一个字符串,但实际上会被转换为 [0,127] 的数字 id 。也就是说,引擎只支持一百来个不同的 key 。就目前 RmlUI 定义的 css 规范来说,够用了。

而 value ,也是一个字符串。但对于不同的 key 有不同的结构。对 stylecache 模块来说,它并不关心其结构,全部视为字符串。这个字符串根据不同的 key ,可能是布尔量、数字、字符串、也可以是一组数字、甚至是一个字典这类复杂结构。

RmlUI 是用 C++ 编写的,我们把 style 从字符串取出时,转换为一个 C++ 可以方便访问的类。过去,这是通过序列化和反序列化进行的。即,当我们把 attrib 从 style 中取出时,将 attrib 通过反序列化变成一个 C++ 对象。这个对象可以方便的被 C++ 代码使用。

目前,这个反序列化过程发现有潜在的性能问题,值得优化一下。大致有两个方向:

其一,直接把 C++ 对象映射为连续内存,然后将内存视为字符串被 stylecache 管理起来。这样,就省去了反序列化的过程,也无需在每次访问 attrib 的数据时构造新的 C++ 对象(这个对象可以视为数据块的访问器)。

这一步我们已经差不多做好了,的确提升了不少性能。

但是复杂结构,比如字典对象,就很难被映射到连续内存块中。这是因为,复杂数据结构需要额外的索引信息。这些信息在 C++ 语境下,很可能是一些指针。指针是很难被复制和移动的,它难以被视为字符串。

所以,我考虑了第二个方向:不拒绝额外的访问器对象,同时对访问器做缓存。

如果我们把一个复杂的数据结构看成有两种形态,一是一个字符串;二是一个 C++ 对象;两者的信息是完全等价的,当它们都是不变量时,后者就是前者的访问器。

如果 C++ 对象可以通过字符串调用反序列化接口创建出来,又可以调用序列化接口变成一个字符串,那么就可以在两个心态间自由转换。通过这种转换,我们可以简化信息本身的生命期管理。

字符串的优势是可以自由的制作副本,计算 hash ,去重,比较等等;劣势是难以访问内部的数据子结构。

所以,我们对外的接口提到 attrib 时均可以 C++ 对象的形式提供数据,这样可以方便用户数据其内部数据结构。但,当我们需要在内部储存一个 attrib ,则可以以序列化后的字符串形式保存下来,同时 cache 一个对应的 C++ 访问器对象的内部 handle。这个 handle 可以较小,例如,当我们使用一个 16bit handle 时,内部其实最多 cache 64K 个访问器。因为访问器和字符串数据可以自由转换,当 cache 失效时,随时都能重建。

注意,这里没有提到访问器对象的生命期管理。实际上,我们需要调用者提供一个销毁方法,这样才能在 cache 满后销毁掉不用的访问器。但用户完全可以额外给访问器增加引用计数的管理。它的好处是,当通过接口取得一个 attrib 的访问器后,可以通过增加引用来持有它,下次把这个对象再重新传回 stylecache 模块(同时减一次引用)。持有和复用特定 attrib 的成本就是 O(1) 的了。

即:如果从外部传入一个新的 attrib ,stylecache 模块先把它序列化为字符串,持有这个字符串,这样就无需和外部商定其生命期如何管理(信息被复制了一次)。

如果从 stylecache 获取一个 attrib ,得到的是一个生命期仅延续到下一次 api 调用前的访问器,用户不需要关心它的生命期。但接下来如何立刻将访问器指针传回 stylecache 模块,则有可能在内部 cache 中找到,这样就可以跳过序列化流程。零拷贝。


大致是这样的,用户先定义三个接口:

typedef void * accessor_t;  // 访问器,可以是一个 C++ 对象

accessor_t (*create)(const char *, size_t);  // 从字符串构造访问器
void (*release)(accessor_t);  // 销毁访问器
size_t (*serialize)(accessor_t, char buf[], size_t buf_sz);  // 将访问器序列化为字符串

style 层面的接口需要交换 attrib 数据时,都采用访问器类型。所有的接口都约定为:对于输入参数,调用者自己负责输入访问器的生命期;对于输出参数,返回的访问器对象的生命期至少可以维持到下一个 api 调用。这样即没有增加生命期管理的成本,又可以额外提供一个 C++ 访问器对象供用户使用。


我写这篇 blog 是因为觉得以上方案有一定的通用性,值得记录一下:

在以往的 C++ 方案中,如果你想将一个 C++ 对象视为一个基础类型,常见的方案是给它定义一大堆接口:增加引用、减少引用、复制、计算 hash 、对象比较……

这样,这个对象才能被传递、复制、储存在容器中,以及作为字典的 key ,做类似字符串 intern 这样的去重(即相同的值在系统中只保留一份)。

我觉得传统方案过于复杂,需要额外编写大量的代码,且在生命期管理上很容易犯错。而这篇提到的方法,只需要为对象实现序列化、反序列化以及销毁三个接口就够了。而且减少了生命期管理的复杂度。