角色动画系统
Ant 引擎的角色动画系统还需要完善。
之前我们用 Ant 引擎开发的游戏以机械装置为主,所以并不需要人型角色动画。对于人物角色动作的动画控制,最好有更多的引擎支持。
通常,角色逻辑上的属性和动画表现存在一个映射关系。一个角色,它逻辑上的基本属性可能只有在空间中的坐标。我们编写代码控制它时,只关心它在哪里。但是,在做画面表现时,则需要根据空间坐标这组简单属性,转换为动画播放:如果角色静止不动,就播放 idle 动画,如果正在运动,就播放 walk 动画。
Ant 引擎的角色动画系统还需要完善。
之前我们用 Ant 引擎开发的游戏以机械装置为主,所以并不需要人型角色动画。对于人物角色动作的动画控制,最好有更多的引擎支持。
通常,角色逻辑上的属性和动画表现存在一个映射关系。一个角色,它逻辑上的基本属性可能只有在空间中的坐标。我们编写代码控制它时,只关心它在哪里。但是,在做画面表现时,则需要根据空间坐标这组简单属性,转换为动画播放:如果角色静止不动,就播放 idle 动画,如果正在运动,就播放 walk 动画。
我正在制作的游戏 demo 中,所有对象逻辑上都存在于二维空间,但在 Ant Engine 中通过 3d 渲染方式绘制出来。
我希望有一组简便的 API 方便我控制这些对象的渲染,只是控制它们的位置以及在 Y 轴上的旋转量。Ant Engine 是用场景组件来控制 entity 渲染时的空间状态,但场景节点使用的是 3d 空间的 SRT 即缩放、旋转、位移。而我只需要控制其中的两个坐标轴上的空间位置以及一个旋转轴上的旋转量,直接修改 SRT 太不方便了。而且,使用引擎时,还需要每帧标记被修改过的场景组件对应的 entity ,这也很麻烦。
在 ECS 结构下,最简单的方式是为这些 entity 创建一个额外的组件,里面有 x y r 三个值。通过一个 system 把它们转换到场景节点在 3d 空间下的 SRT 组件中。但如果每帧都全部转换一次显得多余,毕竟大部分 entity 不是每帧都会发生变化的。
我用了一个简单的 Lua 技巧来方便开发,下面便是代码:
这两天着手做游戏 demo 时发现 Ant 的 Asset 管理模块之前还留有一些工作没有完成。
那就是,当游戏程序加载 Asset 后,资源管理模块何时释放它们的问题。在 ant.asset 模块中,我们为每种 asset (以文件后缀名区分)定义了 loader unloader reloader 三个接口,分别处理加载、卸载、重载的工作。
但在实际实现时,几乎都没有实现 unloader 。当时是偷懒,因为我们之前的游戏即使把全部资源都加载到内存,也没多少数据,并不需要动态卸载释放内存。而即使实现了 unloader ,管理器也没有实现很好的策略去调用它。只能靠用户主动调用卸载 api 。事实上,一个个资源文件主动卸载也不实用。
考虑到占用内存最大的 asset 是贴图,我们又对贴图做了一些特殊处理:
所有的贴图都可以用一张空白贴图作为替代。引擎有权在任何时候(通常是内存不足时)主动释放长期未使用的贴图,并换用替代。这个特性也可以很好的适配异步加载过程。
所以,未释放的贴图并不会撑满内存。
最近和公司一个开发团队探讨了一下他们正在开发的游戏中遇到的性能问题,看看应该如何优化。这个游戏的战斗场景想模仿亿万僵尸(They are billions)的场景。在亿万僵尸中,场景中描绘了上万的僵尸潮,但我们这个游戏,超过 500 个僵尸就遇到了性能问题。固然,手机的硬件性能比不上 PC ,但 500 这个数量级还是略低于预期。
对于游戏中大量类似的动画物体,肯定有方法可以优化。我们来看看渲染这些动画可行的优化方向:
常见的方式是把僵尸先预渲染成图片,而动画自然就是多个图片帧。对于亿万僵尸这个游戏来说,它本身就是基于 2D 渲染引擎的,这么做无可厚非。
最近一段时间都在公司内寻找项目可以合作推进 Ant Engine 的使用。我觉得自研引擎的一个重要优势在于我们可以针对具体游戏更好的做性能优化。在目标设备硬件性能允许的范畴内,把画面质量和交互体验做到更好。而同样的优化手段,在通用商业引擎上面做会困难的多,甚至无法顺利完成。
我们用 Ant Engine 制作的第一款游戏 Red Frontier 在一年前是性能完全不达标的。它在 iPhone 8 上甚至都达不到 30fps ,无法流畅游戏。很多性能问题是已知问题,比如我们用 Lua 搭建了整个引擎,一开始只考虑了引擎结构和正确性,把性能搁置在一边待后面再处理。
优化方案是一开始就想好的:借助 lua ecs 框架,把数据结构放在 C 内存中,必要时可以绕过 Lua 代码,直接用 C 代码控制核心数据。我们花了大约 3 个多月的时间将核心渲染系统用 C 重写后,就把性能提高了 1 个数量级以上。这个过程可以说是一直掌握在手中,按计划推进。
但即使可以让游戏运行在 60fps 下,优化的目标也远远没有达到。这是因为对于手机设备来说,用户更容易产生电量焦虑。在固定座位上插着电玩主机或 PC 游戏,玩家不会去想游戏机耗了多少电;即使把 switch 外带玩游戏,也可以一直玩到没电;但用手机不光是用来玩游戏的,如果消耗电量太快,玩家会担心手机等一下会不会无法支付交通费用,不能扫码吃饭……
我甚至一度怀疑,手机并不适合长时间沉浸式的游戏类型。或许放置游戏这类玩一下放一下的游戏类型更合适一些?
ltask 是 Ant engine 的基础设施之一,在对 Ant engine profile 的过程中,我们发现了 ltask 的一些值得提升的地方。
我们希望尽可能的提升游戏帧率,缩短渲染每一帧的的时间。因为 Ant engine 是由很多并行任务构成的,任务调度器的策略会直接影响单帧需要的时间。
ltask 虽然和 skynet 想解决的问题是一样的:管理 m 个线程(任务/服务),让它们运行在 n 个 cpu 核心上。而它们的应用场景不同,ltask 目前用在游戏客户端,它由一两个重负荷任务和若干低负荷任务构成,优化目标是低延迟;而 skynet 主要用在服务器上,由数以千计的类似负荷的任务构成,优化目标是高负载。
虚拟文件系统 (vfs) 是 Ant 引擎的核心模块。在 wiki 上有介绍 ,blog 上也有总结。
最近在按前段时间拟定的思路重构编辑器。在这个过程中对 vfs 有了一些新想法。短期内不打算把工作重心放到重构 vfs 上面,先记录一下。
最早设计 vfs 的时候,是从网络文件系统的角度看待它的。我把它设想为一个类似 git 的组织方式,带版本控制的网络文件系统。所以,很多设计思路都是延续这个而来。但是,经过了这些年的数次重构,我对最初的思路产生了一些怀疑。
其中,最重要的一条:在游戏运行时,游戏程序看到的 vfs 是一个树结构的不变快照。这样,它像 git 一样,就可以用一个 Merkle tree 的 hash 值就可以代表这个快照,也可以方便的通过网络同步它。
为了实现编辑器,我们在这个设计上打了一些补丁,让编辑器可以在运行时动态的修改它。而我今天反思,“不变快照” 这一点是否是多余的?或者并不需要这个约束,也可以用简单的方案实现现在所有的功能。
我在自己研发的游戏引擎上已经工作了 6 年了。在 2017 年底,我写下了对这个新引擎最初的构想 。现在回头来看,当初的想法居然都落实了,只有一点例外:我们中途把编辑器从 IUP 转移到了 ImGUI 上。
2022 年,我们启动了第一个用这个引擎开发的游戏项目,它是一个和日本公司合作的动作游戏。后来,这个项目没有走下去就取消了。之后,因为我们的引擎开发组喜欢 Factorio ,便想用自己的引擎在手机上重现一个 Factorio Like 的游戏,这一干就是一年多。
现在,游戏的技术部分基本完成,可以验证引擎的可用性(功能完整、性能达标),只是游戏性方面还有不少路要走。简单说就是还不太好玩。
从一开始,我就希望以开源模式经营这个游戏引擎,但同时又觉得没有得到验证的东西不适合拿出来。既然引擎已经初步可用,现在就应该迈开这一步了。
我们游戏 UI 基于 RmlUI 的 fork,做了大量的改造。它实际上类似目前的 web 前端技术,使用 CSS 来表示 UI 的布局。所以,我们做的底层工作也都是围绕如何高效实现一套基于 CSS 的 UI 引擎来做的。
一年多前,我写过一篇 blog 介绍了一些优化的工作。
最近,在游戏开发的使用中,我们又发现了一些性能热点,最近在着手优化。这一篇 blog 记录一下其中的一个优化点。
上周五在公司内做了一个技术分享,介绍我们最近五年来自研的游戏引擎,以及最近一年用这个引擎开发的游戏。大约有一百多个同学参加了这次分享会,反响挺不错。因为这些年做的东西挺多,想分享的东西太多,很多细节都只是简单一提,没时间展开。
我谈到,我们的引擎主要专注于给移动设备使用,那么优化的重点并不在于提高单帧渲染的速度,而在于在固定帧率下,一个比较长的时间段内,怎样减少计算的总量,从而降低设备的能耗。当时我举了几个例子,其中有我们已经做了的工作,也有一些还没做但在计划中的工作。
我提了一个问题:如果上一帧某个像素被渲染过,而若有办法知道当前帧不需要重复渲染这个像素,那么减少重复渲染就能减少总的能耗。这个方法要成立,必须让检查某个像素是否需要重复渲染的成本比直接渲染它要低一个数量级。之所以现存的商业引擎都不在这个问题上发力,主要是因为它们并没有优先考虑怎么给移动设备省电,而要做到这样的先决条件(可以廉价的找到不需要重新渲染的像素),需要引擎本身的结构去配合。
提起游戏引擎,特别是商业通用游戏引擎,比如 Unreal 或是 Unity ,给人的第一印象会是它们的可视化编辑器。而在实际开发中,在不同引擎下做游戏开发,影响最大的是引擎层的 API 以及这些 API 带来的模式。
而对于使用自家专有引擎开发出来的游戏,却少见有特别的编辑器。比如以 Mod 丰富见长的 P 社游戏,新系列都使用一个名叫 Clausewitz 的引擎,玩家们在之上创造了大量的 Mod ,却不见有特别的编辑器。Mod 作者多在文本上工作,在游戏本身中调试。游戏程序本身就充当了编辑器:或许只比游戏功能多一个控制台而已。在这类引擎上开发,工作模式还是基于命令行。
我们的游戏引擎是基于虚拟文件系统,可以通过网络把开发机上的目录映射到手机上。这对开发非常方便,开发者只需要在自己的开发机上修改资源,立刻就能反应到手机上。
但当游戏发行(也就是我们正在准备的工作),我们还是需要把所有资源打包,并当版本更新时,一次性的下载更新补丁更好。
之前一直没时间做这方面的工作,直到最近才考虑这个问题。我们到底应该设计一个怎样的补丁更新系统。
我们游戏引擎的 UI 使用的是类似网页的技术,是将 RmlUI fork 出来的自行维护的版本 。目前游戏中大量遇到的一个需求是:把 3d 模型作为 UI 组件使用。这个需求在我经历过的历史项目中都曾遇到过,在不同的游戏引擎中我见过各种解决方案。
最典型的是 RPG 类游戏的人物属性面板。通常需要在面板上显示 3D 人物模型。通常还可以旋转这些模型,让玩家 360 度观看。我们目前的游戏类似 Factorio ,没有 Avatar ,但点开建筑的信息面板时,也需要把建筑的 3D 模型动态展现出来。
最初,我们没去细想 3D 渲染怎么和已有的 RmlUI 结合在一起,直接把模型渲染在 UI 层之上。相当于在 UI 模块外开了个后门。UI 上只需要把位置空出来,等 UI 渲染完后,再叠加 3D 模型上去。但这样做的坏处是很明显的:3D 模型无法和 UI 窗口有一致的层次结构。
后来,我们额外构造了一个 render target ,改造了一点 RmlUI ,让它可以支持一个矩形区容纳这个 rendertarget 的画布 。这样,3D 模型渲染就比较好的和 UI 模块融合在一起。但是需要单独编写 UI 上 3d 元素的相关代码,尤其是管理它 ( rendertarget )的生命期。
最近,我希望在 UI 上增加更多 3d 模型。它们仅仅是用来取代原来的 2D 图片。从 UI 角度看,这些就应该是图片,只不过这些图片并不是文件系统中的图片文件,而是运行时由 3d 渲染模块生成的。如果继续沿用目前的图片方案,我们就多出一些开发期处理这些预渲染图片的维护成本。但是,如果直接使用已有方法的话,那个看起来临时的解决方案又有点不堪重负。
上篇谈了一下我们游戏引擎的虚拟文件系统(vfs)。我觉得这个系统中,游戏资产的管理部分还是个满有意思的设计,值得写一下。
VFS 的设计动机是方便把开发机磁盘上的数据同步到运行设备(通常是手机)中。传统游戏引擎的做法通常是建一个叫做资产仓库的东西,在开发期间不断添加维护这个仓库。需要把游戏部署在运行设备时,再打包上传过去。因为传统游戏引擎在开发期间一般直接在开发机上运行,所以打包上传(从开发机转移游戏资产)并不频繁。
而我们的游戏引擎特别为手机游戏开发而设计,我们不可能直接在手机设备上开发,所以开发机一是和运行机分离的。为了提高开发效率,所以我们设计了 VFS 系统。可以通过网络同步资源仓库。
目前我们游戏用的引擎早在 2018 年就开始了。因为一开始,它就定位为一个主要用于手机平台的游戏引擎,要方便手机开发。因为我们不太可能直接在手机设备上编写代码、制作资源,所以开发机一定是和游戏运行环境分离的。从一开始,我们就设计了一个虚拟文件系统,它可以通过网络,把开发机上的文件系统映射到手机设备上,同时兼有版本管理的功能。这样,才可以做到在开发期间,开发机上所做的大多数修改,都能立刻反映到手机上。
我们的游戏引擎的大部分是用 Lua 开发的,这也意味着文件系统中不光有游戏用的资源素材,还包括了代码本身。甚至包括了虚拟文件系统自身的实现。这个东西比一开始想的要麻烦,我们这几年不断地修改它,直到最近。比如一开始认为最麻烦的自举部分 ,在去年就去掉了,为的就是减少系统的复杂度。
这两天遇到一个任务调度算法引起的性能问题,花了颇多精力排查和解决。问题出在我写的 ltask 这个 lua 多任务库上。ltask 最初是对 skynet 的一些反思中开始的,最初只是想换一种思路实现 skynet :做一个库而不是框架、更少的锁竞争、避免服务因为消息队列堆积而过载……
后来、我们游戏引擎开始尝试基于 ltask 利用手机设备上的多核,渐渐的便完善起来,也发展出和 skynet 不同的部分。它最近两年一直是围绕移动设备客户端程序优化,所以网络部分并非重点,也就不需要像 skynet 那样把网络模块做在框架底层,而是以一个独立服务存在。而网络 IO 、文件 IO 、客户端窗口这些部分又不适合于其它渲染相关的服务混在一起,因为它们需要和操作系统直接打交道,所以我在 ltask 中又分出了独占线程和共享工作线程两种不同的线程,可以把不同的服务绑在不同的线程上。甚至对于 iOS ,还必须让窗口线程运行在主线程上,而不得不在 ltask 里做特殊的支持。
最近发现的这个问题也是游戏客户端特有的,它很能说明用于游戏服务器的 skynet 和用于客户端的 ltask 在实现侧重点上的不同。
上次提到,经过数据分析发现,我们引擎(游戏)目前特效系统占了很大的 CPU 比例。虽然特效的计算已经放在独立线程,不影响帧率,但 CPU 的开销会导致电池消耗,最终会引起手机热量上升,最终让手机降频。所以,当时想了一些牺牲准确性的方案来做优化:即,不在镜头内的特效不运算。让视觉裁剪同时也裁剪掉特效粒子片的计算。
最近做了这方面的重构工作。其中的难点在于:特效模块是第三方的,并不完全贴合我们的引擎设计,而短期内又没有重新实现或改造的计划。
我们的手机游戏引擎一直在跟随着游戏项目的进程不断优化。一开始是因为游戏引擎在手机上帧数达不到要求。得益于 ECS 框架,我们把初期用 Lua 快速开发出来的几个核心 system 用 C 重写后,得到了质的飞跃。
其实这些核心代码总量并不算大。例如在 profile 中表现出来的非常消耗 CPU 的一个场景树更新系统,用 C 重写了也才 200 行代码 ,但在优化前 Lua 版本会消耗超过 1ms 的时间,而用 C 重写后,时间已可以忽略不计。
另外,我们采用了类似 skynet 的 ltask 做多线程框架,把业务尽量拆分到多线程中并行处理,这也极大的减少了每帧的耗时。除了主业务逻辑外,UI 、粒子系统、IO 被分为几个并行线程。且渲染底层的 bgfx 也是按多线程渲染设计的。这些并行流程间只通过少量的消息通讯,所以,并行的总工作量并没有比单线程模型更多。ltask 也可以很方便的调节工作线程的个数,用来更好的适配手机的 CPU 。
从xcode 的调试信息看,在游戏场景丰富时,大约会占用 280% 的 cpu 。换句话说,如果我们采用的是单线程架构,在不删减特性的前提下,做到流畅是相当困难的。
最近在优化我们的 3d engine 。引擎的渲染对象管理层是基于 ECS 框架,且整个引擎基于 Lua 设计和构建。也就是说,渲染部分的数据都可以通过 Lua 读写。但是,对于核心渲染循环,Lua 的性能有限,当需要渲染的对象很多时,之前用 Lua 编写的循环的性能问题就显露出来。
为此,我们很早就设计了 luaecs。把数据放在 C 结构中,并给出 Lua 访问的接口。这样就方便了初期使用 Lua 快速开发,后期针对核心循环用 C 重构优化。今年年初时,我们把渲染核心系统用 C 重构了一遍,基本解决了性能问题。
五一前,我遇到了一个非常难缠的 bug ,前后花了两天时间才把它解决。其刁钻程度,可以列入我职业生涯的前三,非常值得记录一下。
问题发现在节前两天,很多同事都请了假,我也打算好好休息一下,陪孩子玩几天。就在我例行更新游戏项目的仓库后,突然发现程序崩溃了。一开始,我并不以为意。因为我们的游戏是用 Lua 为主开发的,并不需要在更新后重新编译。我大约一周才会构建一次项目。或许这只是因为我太久没有 build 了,所以我随手构建了一下。但问题依然存在,只不过发生的概率偏低,大约启动三次有一次会出问题。这不寻常,因为我已经很久没见过 Segmentation fault 了。
ECS 中,同一个 Entity 是否可以由多个同类型的 Component 构成?在 Unity 中,答案是可以。我们的引擎在设计之初也是可以的。
当时有一个问题:在 Lua 中,如何访问同类型的 Component ?如果有多个同类 Component ,最自然的方式是把它们放在一个数组里。但是、绝大多数情况下我们用不上这个特性,每次访问 Component 都加一次 [1] 或 [0] 的数组索引显得画蛇添足。若单个 Component 不用数组,多个才用数组,写起来又有极大的心智负担。因为这样做,它们就成了两个不同的类型。
后来,我们干脆利用 Lua 的特性,把数组和 Component 本身放在一个 table 中。如果有多个 Component 就把这个数组直接放在第一个 Component 的 table 内。就这样用了一段时间后,最后还是受不了这个脏技巧。等到用 C 编写 luaecs 后,就砍掉了这个特性。
ECS 不是万能灵药。如果需要让相同的 Component 聚合在一起,那么就使用额外的数据结构,或是不只使用一个 world 。这是我们目前实践给出的答案。去年在 luaecs 的 issue 9 也讨论过类似问题。
今天是年前最后一天工作,我想对最近做的一些事情做一些记录。
我们在使用自研引擎开发游戏时,遇到了不少和预期设计有距离的问题,针对问题再反思了原有的设计并做出改进。我认为、凭空设计一个通用游戏引擎是不可能的,必须结合实际项目,做许多针对性的实现。但同时应不断反思,避免过多的技术债。在最近一年,我参与的引擎具体编码工作很少,更多的是站在一个反思者位置,监督代码和设计的演变。
我们的引擎主体框架是基于 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 左右了。
上次谈游戏引擎的虚拟文件系统是去年 10 月了。最近我们又做了一些修改。
我们的虚拟文件系统有两个工作模式。一种模式主要用于编辑器和开发环境,所有的文件名和路径都基于原生文件系统,我们叫它编辑器模式;另一种模式被称为运行时模式,主要用于运行时环境。文件均用类似 git 仓库的形式,用文件内容的 hash 值作文件名,储存在一颗 Merkle tree 上,并通过一个专门的 file server 为运行环境提供资源的更新服务。
在编辑器模式下,并不是直接映射原生文件系统的整棵目录树的,而是增加了一个 mount 配置,可以把很多不同的目录装配在一起。当初这么设计是希望提供一定的灵活度,方便游戏项目可以把引擎提供的基础功能和资源组装进来。
我们游戏引擎的 IO 模块其实一直在修改。最初的版本到现在有四年多了 。
一直没有定稿的一部分原因是因为我们给引擎设定了一个比较高的需求:引擎本身也是可以从网络自更新的。而更新引擎本身必然依赖 IO ,包括 IO 模块自身。而我们引擎又基于一个多线程版本的 Lua 框架 ltask ,ltask 本身也是需要依赖 IO 模块启动的。这些后续的设计要晚于最初 IO 模块的设计,反复重构也就是必然了。
ltask 是一个功能类似 skynet 的库。我主要将它用于客户端环境。由于比 skynet 后设计,所以我在它上面实验了许多新想法。
最近碰到一个需求:我们的游戏引擎中嵌入的第三方 C/C++ 模块以 callback 形式提供了若干接口。在把这些模块嵌入 ltask 的服务中运行时,这些 callback 函数是难以使用全部 ltask 的特性。比如,我们的 IO 操作全部在一个独立服务中,引擎读取文件时,很可能是通过网络异步远程加载数据的。这些第三方模块通常没有考虑异步 IO 操作,都是以同步 IO 方式给出一个读文件的 callback 函数让使用者填写。
那么,怎样才能在这个 C callback 中挂起当前任务,等待 IO 的异步完成呢?
去年设计 luaecs 时遗留了一个问题:如何引用特定 entity。一开始,我认为可以灵活使用 tag 系统来解决大部分问题;或者用户自己构建一个 64bit ID 的 component ,采用二分查找的方法来引用特定 Entity 。
今年初,我发现我们的自己的使用者和外部使用者都无法避免引用特定 Entity ,所以增加了一个非侵入式的引用方案。
最近又围绕它做了一些讨论,让我重新考虑内置一个 Entity 引用的方案。
我们的游戏引擎的 GameUI 使用的 RmlUI 。我的想法是用成熟的 CSS 来描述 UI 的呈现,借鉴 web 前端开发的方法来制作游戏的 UI 。
但我不想嵌入太复杂的 Web 渲染引擎,而且游戏的需求也会有所不同,所以我选择了轻量的 RmlUI 。同时,为了和游戏的开发语言一致,我们使用 Lua 而不是 javascript 来控制 CSS 。
在使用 RmlUI 的过程中,一开始我们尽量和上游保持一致,修复了不少 Bug ,并合并到了上游。后来发现我们有很多需求和上游不同,需要大刀阔斧的做一些改动,所以就 fork 了一个自己的分支。
最重要的两个改动是:第一,完全实现了 Lua Binding ,因为原有的实现非常低效和复杂,很多接口设计不合理。做大规模的接口变化必须破坏向前兼容,这是我们 fork 分支的主要动机。
第二,废弃了 RmlUI 自己实现的排版模块,换成了 Facebook 维护的 Yoga 。
我们引擎中使用的数学库已经修修补补很久了。期间积累了很多想法。最近在对引擎做性能优化,把一些找到的热点系统用 C/C++ 改写,顺便就重构了一下数学库,让它更好的兼顾 Lua API 和 C API 。
上一次对数学库的改进是三年前的事情了。这三年的使用,我们发现,基于一个栈的 DSL 虽然可以减轻 Lua 和 C 之间的通讯成本,但是使用起来并不方便。大部分时候,我们还是倾向更传统的接口:一次数学运算调用一次函数。而高复杂度的数学运算完全可以放在独立的 C 模块中完成。结合 ECS 系统,我们可以在 C side 集中批量处理相同但数量巨大的数学运算。
我们在很早以前就放弃了原来设计的 DSL ,只使用数学库的部分功能。趁这次重构,我打算把这些已经废弃的部分彻底删掉,并重新设计底层的数据结构,让它能更好的适应其核心特性。
我们的游戏场景是由若干场景节点构成的,每个场景节点是一个 Entity 的 Component 。而一个复杂的场景可以在编辑器中生成一个预制件 Prefab,像搭建乐高积木那样堆砌已经做好的部件。关于预制件的设计,之前有过两篇 blog 讨论。分别是:游戏引擎中预制件的设计 和 预制件和对象集的管理 。
就目前的使用经验来看,几乎所有游戏中的 Entity 都是从 Prefab 实例化得来的。一个 Prefab 会实例化出 n 个 Entity ,但这 n 个 Entity 的生命期管理却很麻烦。
最自然的想法是:所有的 Entity 都必须是场景树上的节点(拥有场景组件),当我们删除一个场景节点时,它所有的子孙都一起移除。
目前,我们用 ECS 管理游戏引擎中的对象。当游戏场景大到一定程度,就需要有一个机制来快速筛选出需要渲染的对象子集。换句话说,如果你创建了 100K 个 Entity ,但是只有 1K 个 Entity 需要同时渲染,虽然遍历所有可渲染对象的成本最小是 O(n) ,但这个 n 是 100K 这个数量级,还是 1K 这个数量级,区别还是很大的。
我们的 ECS 系统已经支持了 tag 这个特性,可以利用 visible tag 做主 key 快速筛选可见对象。但当镜头移动时,需要重置这些 tag 又可能有性能问题。重置这些 visible tags 怎样才能避免在 100K 这个数量级的 O(n) 复杂度下工作?
最近这个项目,里面有大量的机械动画:采矿机、抽水泵、发电机、组装机、机械臂、等等。
我发现,我们自研的引擎的动画模块其实是不够用的。
我们在设计引擎的动画模块时,是按过去经常做的 MMORPG 里的需求来设计的。主要是控制 Avatar 的动作:走、跳、跑、攻击、等等。在从制作软件导入动画数据后,引擎需要做的加工主要是动画和动画之间的融合。例如,从走路过渡到跑步。还有在动画中加入一些运行时的控制:例如转头盯着物体、调整脚掌贴合地面。这些是用 IK 模块来实现的。
最近在用自研引擎开发项目时,发现了一些问题。在解决问题的同时,也逐步对之前的设计做了一些调整。一开始只是一些小修复,慢慢的发展成了大规模的代码重构。
最开始源于我重新设计了 ECS 框架。在新设计下,可以用 C/Lua 混合组织数据。为未来优化热点做好准备。我们借此机会重新思考了 ECS 框架下应该如何组织代码的问题。发现一个关键点就是,要尽量去掉系统中对象之间的引用关系。每类对象最好是成组分批的处理业务,每个模块都只做最简单的事情。但同一件事情尽量处理更多的数据、对象。
比如对象的构建和销毁,通常会随着对象构成的复杂度上升而演变为一件越来越复杂的事务。我们之间在设计 ECS 框架时,就设计了大量的机制来正确执行 Entity 的构建流程。一个 Entity 可以由若干 Component 类型动态组合,并非每个 Component 都能独立初始化。有时它们是相关联的。例如 A B C 组合成一个 Entity ,初始化 A 和 B 后,才能根据 A B 的结果初始化 C 。
最近在公司内做了一次两小时的分享,介绍了一下我最近几年对 ECS 模型的一些想法以及最近在项目中的应用心得。
我分享的主题不叫 ECS ,而用了一个更宽泛的名字 Data oriented design 。因为我不想局限在 Entity Component System 这些具体名词上。从 wikipedia 上看 ,DOD 的提出是源于游戏软件对性能的追求, 它主要围绕的都是其数据在内存中的组织形式不同。和 C++ 这类可以直接控制对象内存布局的 OOP 的语言的默认布局相比,DOD/ECS 的数据布局对 CPU Cache 更好友一些,从而可以获得更好的性能。
如果从这个角度看,如果不是用 C/C++ 这些可以直接控制数据内存布局的语言,采用 ECS 的意义很小。
但是,我觉得 ECS 的意义不仅在于此,它的更重要的意义在于在数据层面对业务解耦。从而引导实现者实现内聚度更高的模块。
最近,我们把自研游戏引擎的构建工具从 GNU Make 迁移到了 Ninja 。
迁移动机是这样的:
我为引擎编写了最初的 Makefile ,它可以很好的工作在 MinGW / MacOSX / iOS 平台。把基本框架搭好以后,用起来也比较方便。但是,参与开发的同事一直有用 MSVC 开发的需求,而我们迟迟没有在 Makefile 的框架里增加 MSVC 的支持。用 MSVC 的同事一直在手工维护一个 MSVC 的项目。
渐渐的,同时维护 Makefile 和 MSVC 的工程成了一个负担。
实际上,现在惯用的方法都是用一个高阶的语言去描述项目构建流程,再翻译成不同平台下的构建脚本。即使用 GNU Make ,通常我们也是先用 Make 本身设计一个框架,在这个框架下去描述构建脚本,再让 Make 在不同平台下生成不同的流程。
如果不介意引入新的工具,那么 Autoconf ,CMake ,Premake 都可以解决这个问题。
粒子系统中,势必会引入多种材质。要么按材质分为不同的管理器对象,要么把所有粒子片放在一个管理器下,但增加材质的属性。
如果是前者,即使粒子的其它属性都有共性,也无法一起处理;而后者,则涉及材质分类的问题。
我们不大可能在渲染阶段无视粒子的材质属性,每个粒子片都单独向渲染器提交材质。因为无论是面片粒子,还是模型粒子,都应该批量提交粒子的空间结构数据,然后一次渲染。如果粒子是面片,那么就应该把一组粒子的顶点信息组织在同一个顶点 buffer 中;如果粒子是模型,就应该把每个个体的空间矩阵组织在 Instance Buffer 中。
如果材质属性只是一个 id 或材质对象指针,作为一个属性关联在粒子对象上的话,不同材质的粒子是无序的,怎样的数据结构可以方便管理呢?
这篇接着上一篇 粒子系统的设计。
TL;DR 在花了一整个晚上用 C++ 完成了这一块的功能后,我陷入了自我怀疑中。到底花这么多精力做这么一小块功能有意义么?强调类型安全无非是为了减少与之关联的代码的缺陷,提高质量;但代码不那么浅显易懂却降低了质量。
我们用 C 实现了一个基于 ECS 结构的粒子系统的管理器,代码 psystem_manager.h 在这里。
先来回顾一下设计:在这个粒子系统中,我期望把粒子对象的不同属性分开管理。
即:传统的面向对象的数据结构中,一个对象 particle 可以有很多属性 a,b,c 。通常是用一个结构体(或类)静态定义出来的,这些属性也可以看作是 a b c 组件,它们构成了粒子对象。而在 ECS 结构中,我们在每个时间点,并非去处理一个对象的多个属性,而是处理同一个属性的多个对象。所以,我们最好按属性分类将多个对象的同一属性聚合起来,而不是按对象,把同一对象的不同属性聚合在一起。
这是因为,在处理单个属性时,往往并不关心别的属性。比如,我们在递减生命期,处理生命期结束的对象时,关心的仅仅是生命期这个属性;在处理粒子受到的重力或其它力的影响时,我们只关心当前的加速度和速度;在计算粒子的空间位置时,只关心上一次的位置和瞬间速度;而在渲染时候,无论是生命期、加速度、速度,这些均不关心。
当数据按属性聚合,代码在批量处理数据时,连续内存对 cache 友好,即使属性只有一个字节,也不会因为对齐问题浪费内存。同一属性的数据尺寸完全相同,处理起来更简单。而且粒子对象相互不受影响,我们只是把同一个操作作用在很多组数据上,次序不敏感。非常适合并行处理。
更重要的是,不同类型的粒子需要自由的根据需要组合属性和行为。有的粒子有物理信息参与刚体碰撞运算,有的则只需要显示不需要这个信息;有的粒子有颜色信息,有的不需要有;有的粒子是一个面片,有的却是一个模型,拥有不同的材质。这导致粒子对象包含的信息量是不同的。及时拥有同一属性,作用在上面的行为也可能不同:例如同样是物理形状信息,可能用于刚体碰撞,改变运动轨迹,也可能只是为了触发一下碰撞事件。
在传统的面向对象的方式中,常用多态(C++ 的虚函数)来实现,或者有大量的 if else switch case 。
如果能按组件和行为聚合,那么就能减少大量的分支。每个粒子的功能组合(打开某个特性关闭某个特性)也方便在运行时决定,而不用生成大量的静态类。
这几天在重构引擎中的粒子系统。之前用 lua 做了个原型,这次用 C/C++ 重新实现一次。目前还是基于 CPU 的粒子系统,今后有必要再实现基于 GPU 的版本。
去年写过一篇 blog 也是谈粒子系统的 。 思路大致类似,但这次在数据结构的细节上做了一些专门的设计,有觉得还有点意思,值得写写。
首先,粒子对象本身就是一个集合了多种数据的数据块。我限制了同时最多 64K 个粒子片,这些粒子对象可以放在一块连续内存中,并且可以用 16bit 的 id 进行索引。
今天想谈谈游戏引擎中 Culling 模块。
当场景中的可渲染对象很多,而当前会被渲染的对象相较甚少的时候,我们通常会启用一个 culling 的过程。Culling 会想办法剔除一些当前不必渲染的对象,避免这些对象提交到 GPU ,让 GPU 承担剔除的过程。这样可以减少 CPU 到 GPU 的带宽。
最基本的 Culling 是用相机的视锥体和对象做一个相交测试,如果对象和视锥体不相交,则可判定它不必渲染;复杂的 Culling 还包括遮挡测试,如果一个对象完全被墙体挡住,那么也不必渲染。这篇只谈前者。
很容易得知,Culling 算法的复杂度上限为 O(n) 。即,我们把场景中的每个对象逐一和视锥体做一次相交判断即可。这里的 n 为场景中元素的个数。
当 n 特别大的时候,通过巧妙地设计数据结构,我们则有可能把复杂度降低。但如何做到呢?
在上一篇 blog 中,我谈到了 UI 模块。而 UI 模块中绕不开的一个问题就是怎么实现文字渲染。
和西方文字不同,汉字的数量多达数万。想把所有文字的字模一次性烘培到贴图上未尝不可,但略显浪费。如果游戏只是用有限几种字体倒也不失一种简单明了的方法。但如果使用字体丰富,而多数字体只使用几个汉字,那么就不太妥当了。
我在设计 ejoy2d 的时候实现过一版动态字模的管理。但我觉得略显简陋。最近重新做了一版。
在游戏(包括引擎)开发的过程中,谈及 UI 模块,通常所指有二:
这两者很多时候都是共用的一个模块,比如之前的 Unity 就直接把引擎开发用的 UI 模块扔给开发者开发游戏使用。但很快,开发者就发现,适合工具开发的 UI 模块,在做游戏时就不那么顺手了。所以就有了第三方 UI 插件的流行,以至于最后又倒逼 Unity 官方重新制作游戏 UI 模块。
开发工具面临的需求和游戏场景面临的需求很不一样:
开发工具需要的时候更好的将内部数据以可视化方式呈现出来,供用户浏览和修改,以适应数据驱动的开发。UI 的呈现需要的一致性,风格统一有利于减少学习成本,同时需要清晰的表达复杂的数据结构。有时还需要将内部数据的变化过程同步的动态呈现,给开发者更直观的感受。
游戏 UI 是游戏过程的情感体验的一部分,外观和交互需要根据游戏设计专门化。它往往并不需要表达游戏内部复杂的数据结构,而是将那些数据以额外面对玩家的形式展现出来。玩家通过界面下达的指令也并非直接对数据的修改,而是以指令流的形式传递过去。另外,HUD 也是很大的一个部分,和 UI 对话框在设计上也有很大的不同。
他们两者之间在技术上的共性其实很小,针对这些共性的技术实现可能也只有几百到上千行代码的规模,远少于差异部分需要的代码量。我比较倾向于把这两个东西分开实现。
在 blog 上,我写过好几篇关于场景管理模块的树结构的文章。这些也是我这两年在做游戏引擎中对象管理的思考历程。
通常游戏引擎中会把可渲染对象以树结构储存,这是场景管理模块最常见的作法。顺便说一句,GUI 界面也是用类似的方式。但是,我始终认为,从 gameplay 的层面上来看,游戏逻辑需要关注的对象并不需要用层次结构的方式管理。因为,空间结构上的层次很可能发生变化,从而引起关注的对象的层次路径变化。我们最终关注的那些东西不变,但它们在空间中的位置却会经常改变。
我一直在思考的问题是:为什么一定要用树结构组织可渲染对象?树结构到底带来了什么好处?
最直接的好处是,减少矩阵运算的次数。因为,渲染层最终需要对象在整个世界中的位置,而每个被渲染的部件本身却是逐级组合起来的(为了减少数据重复,我们不能因为一个部件换了个位置,就复制一次),部件只会记录相对整体的一个局部空间变换。如果我们平坦的保存没有可渲染部件,势必在计算它最终被渲染到屏幕时的世界矩阵的时候,需要连乘一长串局部矩阵。而组织成树结构,以一定的次序计算,可以大大减少最终矩阵乘法的数量。
但这一点好处,我认为还没有触及本质。表达空间位置的矩阵,仅仅是可渲染对象的一个属性而已。
层次结构的本质是让属性可以用继承的方式优化储存,并方便批量修改。对于每种属性,会定义一种对应的继承方法。
Unity 推广了预制件 Prefab 这个概念,在此之前,Unreal Engine 里有个类似的东西叫做蓝图 Blueprint 。当然它们不完全是一种东西。我现在自己设计游戏引擎,更喜欢 Unity 中的 Prefab ,但我认为 Blueprint 这个名字其实更贴切一些。
当我们说制作一个 Prefab 的时候,其实制作的是一个预制件的模板。引擎运行时对应的那些数据,其实按照这个模板生产出来的。所以,工具制作出来的 Prefab 其实是一个 template ,所以,它本质上就是一张蓝图。但因为 Unity 深入人心,所以我还是打算基于把这个东西叫预制件。
对于 ECS 系统来说,预制件是一组数据,通常保存在文件中,可以以该资源文件的内容为模板,来构造一组 Entity。注意:预制件作为资源文件时,和贴图、模型数据等这些资源文件是不同的。贴图之类的资源,映射到内存后,就是一个数据块或一个引擎中的 handle ,可以被共享使用。但预制件本身只是一个模板,它用于生产数据,本身不是数据。从这个角度讲,如果把预制件文件当作资源纳入资源管理模块统一管理的话,预制件资源对应的是一个 function (生成器)而不是 table (数据集)。
这篇是对 游戏引擎中的资源生命期管理问题 的延续。
最近对我们游戏引擎的资源模块做了一次重构,大概花了一周的时间,其中核心模块的代码实现花了 2 天。比之前的方案简洁很多。新方案的设计是基于以下原则来实现的:
这篇是对 场景层次结构的管理 的再思考。
最近在重构引擎的场景管理模块。主要动机之一,还是觉得现在已经实现的东西(由于需求不断增加)太复杂了,以至于老在修补其中的 bug 。
经过了几天的思考后,我决定把场景管理中的一部分挪出来,单独作为一个模块。那就是对层次结构的排序。
具体是这样的:
我们的游戏引擎中,有个重要的功能是将一个矩阵分解成 S 缩放,R 旋转,T 位移三个分量。这里 T 直接取矩阵的第四行即可,代价比较高的是 S 和 R 的分解,其中 R 又取决于 S 的提取。
但是,游戏中大量的矩阵中是不包含缩放的,即 S 分量大多是 (1,1,1) 。一旦不用缩放,又可以简化 R 提取的操作。所以我打算对传统算法做一点优化。在提取 S 的时候多加一次判断,看值是否接近 1 。
计算 S 的方法是将矩阵的前三行当作三个 vector 3 分别取 length 。length 其实是取 dot 然后计算 sqrt。由于大多数情况预测 dot 值很可能为 1 ,那么当 dot 接近 1 的时候,就不必再开方了。
去年的时候,我们对正在开发中的游戏引擎做了一点 profile 工作。后来发现,在场景中对象很多的时候,有一处运算占据了 10% 以上的 cpu 时间。当时我的判断是,这处地方值得优化,但并不是工作重点,所以就搁置了。
问题的具体描述是这样的:
我们的引擎每帧会将场景中的对象依次提交到一个渲染队列中,每个可渲染物件,除了自身的网格、材质外,还有它自身的包围盒(通常是 AABB),以及它在世界空间中的矩阵。
我们有一套资源系统,场景中的对象会引用资源系统中的对象,这些资源对象是一个不变量,会被多个场景对象所引用。而资源对象又可以是一个树结构,比如一个模型就可以由若干子模型所构成。提交到最终渲染队列中的是不可再拆分的子模型的信息。
也就是说,在场景管理的层次,对象的数量是远少于提交到渲染队列中的对象数量的。这就是为什么我们渲染每次重建渲染队列,而没有将每帧提交给渲染队列的列表持久化为一个链表并作增减维护的原因。
问题出在提交给渲染队列的每个物件的包围盒上。
ECS 框架中,System 通常是对某种 Component 的集中处理。大部分情况下,一次性对大量对象中的简单的数据结构做简单的处理,比每次对一个对象的复杂数据结构(通常是简单数据结构的组合)做复杂处理,依次处理所有的对象要更好。
凡事总有例外。我们最近就发现有一类情况不适合这样做。那就是骨骼动画的骨骼计算。
骨骼计算会分为若干步骤,有骨骼数据的展开,骨骼姿态的混合,IK 计算等等。一开始,我们遵循着 ECS 的设计原则,把这些步骤分到不同 System 种。例如并非所有的对象都需要做 IK 计算,这样 IK System 就只用遍历一个子集;同样,也并非所有的动画都需要做多个姿态的混合,等等。
经过长时间的思考和实践,最近一个多月,我们的 ECS 框架做了较大的调整。其中一部分工作已经在前一篇消息发布订阅机制中介绍,另一部分工作其实开展的更早,但因为我想多沉淀一段时间再写。到本周基本基本改动完毕,可以总结一下了。
ECS 框架几乎只在游戏开发领域提出,我认为这主要是因为目前只有在游戏领域,周期性的大量对象的状态变换才是主流行为。而在其它人机交互领域,响应外部事件才是主流。这是为何 System 在游戏领域如此重要的原因。
但另一方面,ECS 框架对过去流行的面向对象框架的反思,主要集中在面向数据/数据驱动。这是 Component 概念被如此看重的原因。Component 是行为被拆分出来的对象,重要的是数据本身。对于框架来说,要解决的是更方便的组合、有完善的自省机制,这才能针对数据集本身来编程。因为游戏开发,程序员的工作仅占很少的部分,大部分的工作是策划和美术围绕开发工具给数据添砖加瓦的。
我们在实践 ECS 框架时发现,之所以 ECS 的概念诞生于游戏领域,是因为游戏程序往往都在周期性的处理一批对象,进行运算,根据上个周期的状态得到下个周期的状态。而传统人机交互的应用则是响应型的:即一个外部请求触发一系列的业务运作。
如果你把游戏业务塞到响应型框架中,就会发现,不得不用时间去触发,业务响应的是 timer 。但这种情况下,timer 几乎没有携带任何状态,对单个 timer 的响应,是不可能做成无状态的:它本身就是整个游戏世界对上个状态的迭代。
这种情况下,响应式框架就很低效。
今年上半年的时候,就想把我们游戏引擎中场景层次结构管理模块的设计记录一下。每次想写的时候都在做小调整。直到最近,算法和数据结构才稳定下来。今天做一个记录。
游戏里的场景对象,通常以树结构保存。这是因为,每个对象的空间状态,通常都受上一级的某个对象影响。
从管理角度讲,每个对象最好都能知道它可以影响其它哪些对象;且必须知道它被哪个对象影响。所以,这会用到一个典型的树结构。尤其在做编辑器时,树结构还会直接呈现在编辑界面上。不过,我认为在运行时,从父对象遍历到子对象的需求并不是必要的,需要时可以额外记录。从数据上考虑,父亲记住孩子和孩子记住父亲,是重复了同一种关系信息。如果不需要记住孩子的兄弟次序,那么在核心数据结构中,我们只需要让孩子记住父亲就足够了。
去掉冗余信息可以简化数据结构、减少维护成本、避免犯错误。尤其对于 ECS 架构,我希望所有对象都是平坦的,在场景对象组件上,一个 parent id 可以最少的构造出场景的层次结构出来。
最近我们在开发引擎时遇到一个和操作系统有关的问题,想了个巧妙地方法解决。我感觉挺有意思,值得记录一下。
在 ios 上,如果你的程序没能及时处理系统发过来的消息(比如触摸消息等),系统有机会判定你的程序出了问题,可能主动把进程杀掉。
完全自己编写的应用程序,固然可以把处理消息循环放在最高优先级。即使有大量耗时操作,也可以通过合理的安排代码,不让消息处理延后。但作为引擎,很难阻止使用者阻塞住主线程做一些耗时的操作。所以,通常我们会把窗口消息循环和业务逻辑分离,放到两个不同的线程中。这样,消息处理线程总能及时的处理系统消息。
在 windows 上,允许程序在任何一个线程做窗口消息循环。但在 ios (以及 Mac)上似乎不行。窗口消息循环必须在主线程中运行。
最近我们开发中的游戏引擎在修理资源管理模块中的 bug 时,我提出了一些想法,希望可以简化资源对象的生命期管理。
其实这个模块已经被重构过几次了。我想理一下它的发展轨迹。
最开始,我们不想太考虑资源的生命期问题,全部都不释放。当然,谁都明白,这种策略只适合做 demo ,不可能用在产品中。
因为我们整个引擎的框架是用 lua 搭建,那么,最直接的想法就是利用 lua 自带的 gc 来回收那些不被引用的资源对象。我不太喜欢这个简单粗暴的方法。因为首先, gc 不会太及时,其次 gc 方法触发的时机很难控制,容易干扰正常的运行流程。图形显示模块是时间敏感的,如果因为资源释放占用了 cpu 的话,很容易变成肉眼可查的卡顿。
另一个促使我们认真考虑资源管理模块的设计的原因是,当我们从 demo 过渡到现实世界的大游戏场景时,过多的资源量触发了 bgfx 的一个内部限制:如果你在一个渲染帧内调用了过多资源 api (例如创建新的 buffer texture 等),会超出 bgfx 的多线程渲染内部的一个消息管道上限,直接让程序崩溃。
所以我们不得不比计划提前实现资源的异步加载模块,它属于资源管理模块的一部分,所以也就顺理成章的考虑整个资源管理模块的设计。
我们上周在游戏引擎上面的工作中遇到一些 bug ,涉及到过去的一些设计问题。维持讨论了几天解决该问题的方案。今天终于把最终方案确定了下来,值得做一个记录。
bug 出在游戏资源文件的转换上面。
游戏里用到的资源通常需要一个导入资源库的过程,例如你的原始贴图是一个 png 文件,但是引擎需要的是对应运行平台的压缩格式,windows 上是 dxt ,手机上是 ktx 等等。这个过程,在 Unity 等商业引擎中,是放在资源导入流程中。
我们的引擎把这个转换过程放在虚拟文件系统这个层次。这个设计决策是因为,我感觉统一导入资源是个痛点,用的人通常需要等待导入过程。Unity 用了 cache server 来解决这个痛点,但我认为 cache server 也存在一些设计问题 ,这个会在后面再展开一次。
我更希望转换过程是惰性的,直到最终运行需要的资源才需要转换。
在 ECS 框架中,每个 System 在每次更新时,都遍历一类 Component 依次处理。这对于游戏的大多数场景都适用,因为游戏引擎要处理的对象通常是易变的。对于每个对象单独判断是否应该处理反而有性能负担。
但是,总有一些应用场景下,只对一类 Component 中的一小部分做修改,而没有被修改的对象可以保持上次的数据,而不必重复运算。在需要重算的对象数量远小于总量时,每个更新就很不划算了。
为了减少运算量,我们通常的解决方案是增加一个脏标记,修改时设置,设置它。这样就可以在处理的时候只处理被标记过的对象。常规的脏标记实现方案有两种,一是在 Component 上加一个 bool 字段,遍历的时候跳过没有标记的对象;二是在 Entity 上动态添加一个用于 tag 的 Component ,视作脏标记,遍历后再清除这个 tag 。
不过这两种方案在 lua 实现的 ecs 框架中,使用代价都比较大。
最近一段时间在忙着设计和实现我们游戏引擎用到的数据格式。
在此之前,我们一直在直接使用 lua 描述数据;但最近随着数据类型系统的完善,同事建议设计一种专有数据格式会更好。希望专用格式手写和阅读起来能比 lua 方便,对 diff 更友好,还能更贴近我们的类型系统,同时解析也能更高效一些。lua 的解析器虽然已经效率很高,但是在描述复杂数据结构时,它其实是先生成的构造数据结构的字节码,然后再通常虚拟机运行字节码才构造出最终的数据结构。这样的两步工作会比一趟扫描解析构造要慢一些且消耗更多的内存。
现有的流行数据格式都有一些我们不太喜欢的缺点:
因为需要为我们的 3d engine 添加特效系统的模块,我最近读了一篇文章:Efficient CPU Particle Systems 。文章的作者为很多 MMO / MOBA 游戏设计过粒子系统,其中最有名的是上古卷轴 Online 。所以我认为他的实践很有参考价值。
文章很长,夹杂着设计思路,优化,算法实现,渲染实现。对于我来说,由于过去我做过好几版粒子系统,所以读起来不太费力,很多细节可以直接略过,我今天写一篇 blog 把我认为文章中对我最有参考价值的部分列出来。
我们的 3d engine 的资源仓库使用 Merkle tree 储存在本地文件系统中,我们称呼它为 vfs ,虚拟文件系统,其结构和 git 的仓库非常类似。关于这部分的设计,之前已写过好几篇 blog 了。
现阶段已完成的版本,已经做到把 lua 虚拟机和所有 C/C++ 实现的 lua 库静态编译打包为一个执行文件,可以零配置启动运行,通过网络远程访问一个 vfs 仓库,完成自举更新和运行远程仓库里的项目。
最近在开发的过程中,发现了一点 Merkle tree 的局限性,我做了一些改进。
我认为 ECS 框架针对的问题是传统面向对象框架中,对象数量很多而对象的特性非常繁杂,而针对对象的不同方面 aspect 编写处理逻辑会非常繁杂。每个针对特定的方面执行业务,都需要从众多对象中挑选出能够操作的子集,这样性能低下,且不相关的特性间耦合度很高。
所以 ECS 框架改变了数据组织方式,把同类数据聚合在一起,并用专门的业务处理流程只针对特定数据进行处理。这就是 C 和 S 的概念:Component 就是对象的一个方面 aspect 的数据集,而 System 就是针对特定一个或几个 aspect 处理方法。
那么,Entity 是什么呢?
我认为 Entity 主要解决了三个问题。
我们的 3d engine 项目从 2018 年 1 月底开始,已经过去 10 个月了。比原计划慢,但是进度还可以接受。目前已经大致完成了运行时的基础渲染框架(基于 ecs 模式),整合了 bullet 物理引擎,开发了一个基于网络的虚拟文件系统,可以不依赖本地的资源/代码直接远程运行。另外还开发了一个 lua 的远程交互调试器,可提升 lua 的开发效率。
单从 runtime 角度,引擎的完成度已经较高。但和之前开发 ejoy2d 不同,这次希望把引擎的侧重点放在工具链上。所以虽然有计划开源,但在工具链不成熟的现阶段,暂时还是闭源开发。
目前团队有全职程序 3 名,我个人没有全职加入,但也花了颇多精力在上面。所以在关键节点上,我们已有 4 个人全力开发。
现在想再招聘一名成员,主要想补充工具链,尤其是开发环境/编辑器的开发。让引擎可以在半年内可用于新游戏 demo 的开发。对于这个职位,可以列出下列明确的需求:
我们给游戏引擎设计了一个虚拟文件系统,可以挂接不同的文件系统实现,比如本地文件系统模块,内存文件系统模块,网络文件系统模块。比如前几天谈到的资源仓库,就是一个文件系统模块。
这个虚拟文件系统是用 lua 编写的,这就有了一个小问题:lua 代码本身也是放在虚拟文件系统中的,那么就需要解决自举。这些代码很有可能需要从网络更新(网络文件系统模块),而网络模块也是 lua 编写的,代码同样放在这套文件系统内。
这篇 blog 我想谈谈自举是怎样完成的。
我打算就我们在开发客户端引擎框架时最近遇到的两个问题写两篇 Blog ,这里先谈第一个问题。
我们的框架技术选型是用 Lua 做开发。和很多 C++ 开发背景(现有大部分的游戏客户端引擎使用 C++ 开发)的人的认知不同,我们并不把 Lua 作为一个嵌入式脚本来看待,而是把它当成一种通用语言来设计整个引擎框架。
其实这更接近 HTML5 流行之后,用 javascript 设计游戏引擎框架:虽然 javascript 的虚拟机本身是用 C++ 开发的,但和游戏引擎相关的部分全部用 javascript 实现,直到涉及渲染的部分,又通过 WebGL 回到 C++ 编写的代码中。这里,我只是把 javascript 换成了 Lua 而已。
选择 Lua 有很大成分是因为我的个人偏好,另一部分原因是 Lua 有优秀的和 C/C++ 代码交互的能力。可以方便地把性能热点模块,在设计上做出良好的抽象后,用 C/C++ 编写高性能的模块,交给 Lua 调用。
但和 Javascript 不同,我们在做原生 App 时,和操作系统打交道的部分还是得用操作系统的语言,C/C++/Objective C/Java 等等。Lua 虚拟机还是要通过一个 C 模块实现嵌入到 App 中去,这个工作得我们自己来完成。
让 Lua VM 置入 App 和操作系统打交道的这部分代码显然是平台相关的,Lua 的 C API 固然简洁,但是还是很庞大的。如果每个平台都直接用 Lua C API 控制虚拟机,这些平台相关的代码还是略显繁杂。我认为,把平台相关代码约束到一个足够小的范围,还需要对 Lua C API 再做一次抽象。
去年底,我为我们的 3d engine 设计了资源仓库的结构。
随后交给开发组的一个同学实现,这半年来,一直在使用。最近做了引擎一个小版本的内部验收,我感觉这块东西还有比较大的改进余地。因为资源文件系统目前和开发期资源在线更新部分现在掺杂在一起,而网络更新部分似乎还有些 bug ,偶尔会卡住。我觉得定位 bug 成本较高,不如把这块重新实现一遍,顺便把新的改进想法加进去。
这段时间,我重新思考了资源仓库应该怎样设计更合理。越细想越觉得和 git 要解决的问题基本一致。我们的引擎的一个重要特性就是,在 PC 上开发,在移动设备上运行调试。我们需要频繁的将资源同步到设备上,这其实和 git 的运作方式是类似的。我们重新实现的该模块在本地文件系统上的数据组织结构最终也和 git 仓库差不太多了。
我为 3d engine 项目设计的向量运算库 已经用了一段时间了。在使用过程中也一直在改进 。从一开始,我就考虑过,这个库的设计主要考量是减少 lua 和 C 交互间的开销,提高内聚性。而易用性方面,计划再上面再做封装。这段时间继续在想怎样从更自然的表达形式转换到这个库的逆波兰指令流上。
大致的方向有两个:
其一,实现一个小语言,用字符串输入表达式组。
其二,利用 Lua 已有的语法解析设施,把 lua 的一个函数翻译成对应的数学运算指令流。
两者都可以看成是一种 jit 的过程,在第一次运行的时候,做一些翻译工作,把要进行的数学运算打包成 C 代码可以处理的指令流,或翻译成现有数学库支持的指令流;或者,增加一个 lua 源码的预处理流程,在预处理阶段做好编译工作(aot)。
这个需求可以分解成三个问题:
首先,如何把更易用的的源码对接到 lua 原生代码。
其次,如何转换为正确的数学运算指令流。
最后,为以上过程选择 jit 或 aot 集成。
前段为 3d engine 写的向量运算库小伙伴在用,提了很多意见,所以这段时间一直在改进。
一开始觉得逆波兰表示法的运算表达式不太习惯,觉得需要绕个弯想问题,希望做一个表达式编译的东西,但是用了几天后,又觉得其实不是什么大问题,习惯了就好了。
但心智负担比较大的地方是那个 id 的正负号约定,也就是生命期管理。我想了一下,人为的去管理生命期,有些对象是要长期持有的,有些对象只在当前渲染帧使用,在使用的时候严格区分它们不太现实。
一开始的版本,我需要使用者在计算表达式中用一个 mark 'M' 指令,把一个临时对象转换成一个持久对象,这极大的增加了使用者的负担。尤其是更新一个对象的时候,需要先解除老对象的持久状态,再 mark 新生成的对象。使用的时候需要一直考虑这个对象是不是要更新,用起来太困难了。虽然有强检查,不会把程序弄混乱,但是稍不注意就会报告运行时错(对象 id 失效)。
今天,我做了极大的调整,去掉了之前 mark 语义,增加了引用语义。
如果用纯 lua 来做向量/矩阵运算在性能要求很高的场合通常是不可接受的。但即使封装成 C 库,传统的方法也比较重。若把每个 vector 都封装为 userdata ,有效载荷很低。一个 float vector 4 ,本身只有 16 字节,而 userdata 本身需要额外 40 字节来维护;4 阶 float 矩阵也不过 64 字节。更不用说在向量运算过程中大量产生的临时对象所带来的 gc 负担了。
采用 lightuserdata 在内存额外开销方面会好一点点,但是生命期管理又会成为及其烦心的事。不像 C 中可以使用栈作临时储存,C++ 中有 RAII 。且使用 api 的时候也会变得比较繁琐。
我一度觉得在 lua 层面提供向量运算的基础模块是不是粒度太细了。曾经也想过许多方法来改善这方面。这两天实践了一下想了有一段时间的方案,感觉能初步满意。
上次说到,我们的引擎打算在 PC 上开发,设备上直接调试。如果是按传统的开发方式:运行前将 app 打包上载然后再运行,肯定是无法满足开发需要的。所以必须建立一套资源的同步机制。
目前,我们已经实现了基本的资源文件系统,大致是这样工作的:
所有的资源文件,包括程序代码本身(基于 Lua),都是放在开发 PC 上的。开发环境会缓存每个文件的 md5 值,文件系统将用此 md5 值为标准,默认 md5 相同的文件,其内容也是一致的,暂不考虑 md5 冲突的问题。
在设备上,用设备的本地文件系统做一个 cache ,cache 中分为两个区间,一是资源文件区,按所有资源文件的 md5 值为文件名(按 md5 的 16 进制码的前三字节散列在不同子目录中,防止单个目录文件数量过多)保存。二是目录结构区,每个子目录用一个递增数字 id 做文件名,内容则是可读文件名以及其内容对应的 md5 值或子目录编号。其中 id 0 是资源根目录。
首先,我们在 2011 年底开创的简悦被阿里巴巴文化娱乐集团全资收购了。原来简悦的全套班底转型为阿里大文娱游戏事业群。
当收购的事情尘埃落定,我发现可以从新的视角来看待未来,重新设计制作一款 3d 引擎这件事可以重新启动了。在简悦一直想做而做不了这件事,是因为没有余力,必须优先考虑产品盈利;而对于阿里来说,投入资源来做这样一件短期没有收益,但长远看来却很有意义的事是很自然的。
世面上已经有了很多优秀的 3d 游戏引擎,比如目前最为流行的 Unity 和口碑优异的 Unreal ,还有许多品质精良的开源引擎,再从头做一个又有什么意义?
我是这么看这个问题的。
Unity 和 Unreal 固然优秀,但是它们在设计之初并没有把移动设备作为核心平台来考虑。发展历史悠久,固然细节上的完善是后来者无法比拟的,但也存在很多历史包袱。尤其是移动平台上需要特别考虑内存紧致、节约能耗,更胜过运行的更快、效果更华丽。
另外,就国情而言,我们需要的移动游戏需要有更弹性的资源管理以及更新方案,这一直是 Unity 的弱项。Unity 作为一个闭源引擎,很难让使用者做出根本改进。
我们已经和 Unity 达成了合作,购买了全部源码。现在公司也成立了专门的团队自己维护 Unity 源码对其他产品团队做技术支持。在这种情况下,重新抄一个 Unity 没有意义:有什么需求,我们完全可以在 Unity 源码的基础上做开发。所以我要的是一个全新的东西。