比起SIGGRAPH来说,GDC Vault里虽然也有大量游戏开发相关的知识分享,但由于主要是视频为主,因此能被我拿来作为二手知识粗读一遍的文稿类内容相对没那么多;另外,GDC提供的PPT原稿里面是不附带解说稿的,所以其实详细程度是不如看原视频的。在这之中,偶尔还是能翻出一些详略合适的内容(比如之前也读过的《HiFi-Rush》和《赛博朋克2077》)。
ECS是Entity Component System的缩写,虽然从名称看不出其对于内存连续性和并行执行的意义,但它已经是行业公认的代表面向数据编程的设计模式框架。文末会附一篇Games104介绍ECS部分的链接。
本人虽然没有实际参与过ECS模式的内容开发,只是粗略体验过Unity(DOTS)和Unreal(Mass)的示例,但这次难得看到一篇ECS系统在3A游戏中实际应用,出于个人好奇还是带大家一起读一下。
这篇粗读以翻译原文稿的PPT页内容为主, 打星号的部分则是我个人的补充说明。
NorthLight是一个聚焦的、艺术家导向的游戏引擎和工具套件——由Remedy Entertaiment开发,赋权给我们现在和未来开发的游戏。
对于《心灵杀手2》,NorthLight有着多项提升:
GPU驱动的管线
基于SDF的风系统
植被控制
更多可以参见remedy官方提供的链接
在2021年,新的ECS(Entity Component System)物体模组在NL中被启用了。
它替代了之前使用的重度面向对象(OOP)实体组件框架,通过预定义的函数和事件图来连接不同的实体之上的组件。
ECS是一个软件设计方案,它通过分离数据和行为来改进代码的重用度。其中数据被以一个缓存友好的方式存储,以提升性能。
一个缓存友好的数据分布,和NL ECS的易于并行化都能提升性能。
更重要的是,它能使游戏玩法的程序员更易于理解游戏逻辑的执行流程。
它有实体(entities)概念,它们是有唯一性的标识符。
有组件(components)概念,是纯数据类型(的结构),不执行行为。
组件可以被动态增加或移除。
它有系统(system)概念,是由包含特定组件的实体匹配而成的函数功能。
系统访问数据的读写操作定义可以在编译时就进行验证,而这是一个强项。
*之所以可以在编译时验证是一定程度放弃了“动态类型”的,后面看例子就能看出。
环境保存游戏中能被全局访问的数据。
它们直接存在于内存中,而不与任何实体关联。
任何环境类型只以单例的形式存在于游戏中。
*PPT页中可以看到以游戏光标为例的一套写法,包含基本结构、更新位置、绘制。
实体是一个概念上的物体,由被称为组件的数据结构组成——每个组件代表了该物体的一类特定功能。
一个实体由其关联的组件集合来定义。对于实体来说并不存在具体的“物体”概念。
一个实体能被一个32位的整型数标识(相当于ID)。
代码中仍有能表达单一或多个实体,并访问其组件的方式。
*可以看到PPT页中的类似面向过程的语法,以及一些基础的模板样式。
定义链通常以一个特定的访问范围定义作为结尾(如果需要的话),以指明从一个实体或一个集合中读取数据。
*PPT页中的Group和Query都是对应集合类型。
*PPT页中引用了两个实体:可受伤角色实体、伤害来源实体。而系统函数主要内容是基于一组伤害来源(查询)进行定时伤害结算。
我们可以将其注册到不同的游戏循环阶段中——对应系统组的概念:
当注册系统时,添加依赖项来影响系统的执行顺序是可行的。如果没有指定顺序,则系统会按访问需求的顺序执行。
*由于游戏中所有类型的执行逻辑都要放在各种系统函数中集中执行,可想而知这套框架不适合处理逻辑过于复杂并且耦合较重的游戏逻辑。
玩家在游戏中能搜集不同类型的线索(通过交互、拾取、调查实景模型,以及脚本事件)。
游戏中会有多项进行中的调查,玩家可以在它们之间切换。
案件信息的布局是通过一个编辑工具定义的——它是由Remedy的主UI/UX设计师Riho Kroll开发的。它能允许叙事设计师以所见即所得的方式编辑案件。(*What you see is what you get)
案件布局数据会被导出成一个配置文件(lua格式),并用于游戏中。
*简单看看案件板模块,其中有例如:案件板模块、手模块、案件绘制模块、案件道具生成等。而共享模块有:游戏光标模块、摄像机追拍模块、动作模块等。
板上的每个案件是一颗线索条目的“树”。
线索条目是玩家在案件板上操作的主要物体。
当一个线索条目执行了某些动作后(例如:放下、解锁、组合),一个或更多的规则可能被触发。
每个规则可能调用一些其他的动作(放置条目、解锁至手上、移除条目等),以应对特定条件被满足的场合(某些道具被放置、解锁等)。
线索条目可以通过以下方式被放置到板上:被玩家从“手”上放下;通过触发规则;显式通过脚本调用。
案件——一个调查文件夹条目。是所有条目的父节点,被自动放置。
照片——开启一个新调查分支。通过手部操作放置。
线索——拍立得照片、引用、手稿页。由玩家在游戏过程中收集,通过手来放置。当与问题成功组合后能被放置在板上。
推理——当所有线索被与问题组合后,会被自动放置在问题下方。
我们将所有案件(名称、条目集合)、条目描述、状态(放置、解锁、挂载父节点)存储在一个环境中。
这种方案中,为一个线索条目执行任何动作会如下实现(图中)。
这是一个自由C++函数,传入itemId和一个案件板环境的可变的引用作为参数。
找到环境中某条目的一个可变指针。
修改(需要的)状态。
执行动作特有的逻辑。
处理动作特有的规则。
*引用reference和指针pointer是一组C++概念。
这一模块与“真实世界”条目实体一起生效,主要负责:
逻辑被拆分成不同的系统。这是非常必要的,因为计算和应用最终的空间变换不如它看上去那么容易。
更新状态和偏移值,尤其是通过案件板环境设置的“真实世界”实体。
设置条目的XY位置和边界——基于偏移值和案件板边界。
检验条目的叠放,以确认其上下关系:基于层级、条目类型、条目索引、(是否)最后被操作等。
基于叠放信息对深度排序,以计算相关的Z值。
设置条目的最终空间变换值(或直接通过动画的方式)。
*系统执行顺序是通过excuteAfter语法来确保的。
我们定义了一个组件结构以存储所有的itemId和状态。
我们通过环境中存储的条目状态来比较组件的状态,在必要时对组件做修改。
关键的访问是对于条目的实体的,因此系统可以潜在的在一个实体层级上并行执行:对于每个实体,一个系统在其所述的线程中执行。
在更新了条目状态,并读取它的偏移值数据后,我们需要更新它(和板位置相关)的XY位置和边界——把板的限制列入考虑。
为存储这些值,我们特定了一个新的组件用于条目的XY边界。(*提到的ItemBoundaries组件)
这一系统将从条目组件中读取条目的状态,或许网格组件的外延值,并写入ItemBoundaries组件。
*边界处理——基于三个二维向量,中心、最小值、最大值。
在我们更新了条目的中心和边界后,我们可以检测叠放在一起的条目。
这(计算的信息)被用于后续更新条目的深度。
系统的关键访问是(特定的叠放物)实体,但我们也需要分别查询所有其它条目。(*这句的意思可以参照后面的函数体)
如果我们想保持一个实体层级的并行执行,我们需要确保在一次查询访问时不读写同一实体。(*否则就要改串行或者考虑加锁了)
然而,将叠放结果写入一个单独(分开)的组件中也是必要的。
*前面提到的特定单个实体是OverlappedItemsEntity类型的实体,后续需要写入叠放计算结果;查询的是通过ItemBoundariesQuery以访问其它Item实体,并通过itemsHaveOverlap和isItemBelow函数来确定上下层级;计算的结果写入itemsBelow容器中。
在得到所有条目的叠放信息后,我们可以对Z坐标进行排序。
为实现这一点,我们创造了“层”的概念——基于某一条目其下条目的数量。
为创造这些“层”,我们需要读写所有条目数据以处理全部依赖关系。(*读写的是不同的组件类型)
*这里的hasDependencies主要是上下层的依赖关系,简单来看就是通过对overlappdItems.itemBelow的整理和查询进行itemDepth的计算,并最终反应到Z值(层厚度)上。
*分别计算了和板之间相对的变换boardRelativeTransform,以及世界坐标系变换worldTransform——最终实际生效的是世界坐标系变换。可以看到这里有一个动画的分支,后面会提到。
*flush是程序开发上从缓冲区把数据汇入数据流(或进行清理)的一个概念。这个词直译的意思是“冲洗”,但这个场合没有合适的翻译;而且这部分细节后面的代码示例并没有涉及。
*这里的动画只展示了一个比较简单的基于deltaTime的程序动画示例,能实现类弹簧或摆荡的效果。具体的逻辑就是在达到目标变换值前一直tick动画系统。
*引入脚本语言的意义有很多,最容易理解的就有比如:更易于编写、不用等待编译等。这里不展开了。
某些案件板功能需要开放给设计师,在脚本系统中使用。
我们使用Lua作为脚本语言。
我们并不在Lua端反射数据。设计师不需要考虑例如组件、访问定义等这类概念。作为替代,他们操作函数,传入实体ID作为参数。
我们提供了两种从Lua端进行交互的方式:C++的Lua绑定;Lua事件。
我们的Lua脚本执行于一个单线程序列。当它们被执行时,所有组件的状态都是同步过的,并且也没有其它系统在执行。这样,设计师们有不用考虑多线程相关的问题了。
Lua绑定是被组织成Lua模块的C++函数,能允许lua脚本调用C++代码。
一个绑定函数——在C++和Lua虚拟机之间传输数据,并校验输入的参数的有效性。
文档注释——用于生成VS Code中汇集式的函数说明。
类型定义——被Lua的类型校验器用于检测脚本错误。类型定义信息是基于文档注释自动生成的。
*Lua这一侧是调用示例。实际的绑定函数内容在右侧C++部分。
*以第一个函数为例,第一行注释描述了输入和返回的类型,各自就对应了checkString和pushBool调用步骤。
*lua侧在初始化时注册了一个回调函数,而C++侧通过broadcast进行事件广播。
通过ECS框架,gameplay程序员的思维模式被改变了。与处理对象不同,这个框架里我们和数据打交道。
总的来说程序执行看起来很像和数据库交互,因为我们(更关注于)查询和修改数据。
动态增加和移除组件的能力使我们能在运行时轻松地为不同实体开关系统。
系统注册允许我们显式的指定访问同一份数据的不同系统的执行顺序。
为获得更好的性能,更佳的方式就是把数据拆分成不同的组件,而不是放在一起。
我们可以构造一个便利和坚固(robust,直接翻成“鲁棒”也行)脚本交互系统,它不需要设计师关心数据的分布(结构)及并行化逻辑。
以我个人的角度来看,Remedy在开发这个游戏使使用ECS的必要性或许没有那么大。虽然ECS没有太多程序上的弊端,但确实在开发过程中写代码构建复杂世界时不太利于大规模并行开发;总的来说这个游戏可“游玩”的内容确实有限,除了“走路模拟”“情景解密”之外的内容不算多,这么看使用ECS是利大于弊的。
从理念角度来看,ECS更适合数据类型相对有限,但实体数量级相对会比较高的游戏。例如大量单位的即时战略游戏,或是有着大量简单行为单位的割草游戏等。我能想到的非常适合使用ECS的一个类型就是“幸存者like”游戏;而我也确实觉得几乎不可能用ECS来开发一个开放世界游戏。
另外,ECS的介绍资料其实很多,除了我提供的一些链接外有兴趣可以自行搜索。
评论区
共 1 条评论热门最新