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