发布于 

GDC 粗读丨看看《心灵杀手2》中的面向数据编程——ECS

前言

比起SIGGRAPH来说,GDC Vault里虽然也有大量游戏开发相关的知识分享,但由于主要是视频为主,因此能被我拿来作为二手知识粗读一遍的文稿类内容相对没那么多;另外,GDC提供的PPT原稿里面是不附带解说稿的,所以其实详细程度是不如看原视频的。在这之中,偶尔还是能翻出一些详略合适的内容(比如之前也读过的《HiFi-Rush》和《赛博朋克2077》)。

ECS是Entity Component System的缩写,虽然从名称看不出其对于内存连续性并行执行的意义,但它已经是行业公认的代表面向数据编程的设计模式框架。文末会附一篇Games104介绍ECS部分的链接。

本人虽然没有实际参与过ECS模式的内容开发,只是粗略体验过Unity(DOTS)和Unreal(Mass)的示例,但这次难得看到一篇ECS系统在3A游戏中实际应用,出于个人好奇还是带大家一起读一下。

这篇粗读以翻译原文稿的PPT页内容为主, 打星号的部分则是我个人的补充说明。

演讲人个人履历
(演讲人个人履历)

1 系统框架概述

NorthLight引擎
(NorthLight引擎)

NorthLight是一个聚焦的、艺术家导向的游戏引擎和工具套件——由Remedy Entertaiment开发,赋权给我们现在和未来开发的游戏。

在创造工业化导向的游戏视觉方面不断进行技术演进:

  • 《量子破碎》中顶尖的面部动画
  • 《控制》中(Remedy产品)首次使用的硬件光线追踪

对于《心灵杀手2》,NorthLight有着多项提升:

  • GPU驱动的管线
  • 基于SDF的风系统
  • 植被控制
  • 更多可以参见remedy官方提供的链接
NL引擎中的新ECS框架
(NL引擎中的新ECS框架)
  • 在2021年,新的ECS(Entity Component System)物体模组在NL中被启用了。
  • 它替代了之前使用的重度面向对象(OOP)实体组件框架,通过预定义的函数和事件图来连接不同的实体之上的组件。
  • ECS是一个软件设计方案,它通过分离数据和行为来改进代码的重用度。其中数据被以一个缓存友好的方式存储,以提升性能。
  • 一个缓存友好的数据分布,和NL ECS的易于并行化都能提升性能。
  • 更重要的是,它能使游戏玩法的程序员更易于理解游戏逻辑的执行流程。
NL引擎中的ECS
(NL引擎中的ECS)

NL引擎的ECS有如下特点:

  • 它有实体(entities)概念,它们是有唯一性的标识符。
  • 组件(components)概念,是纯数据类型(的结构),不执行行为。
  • 组件可以被动态增加或移除。
  • 它有系统(system)概念,是由包含特定组件的实体匹配而成的函数功能。
  • 系统访问数据的读写操作定义可以在编译时就进行验证,而这是一个强项。

*之所以可以在编译时验证是一定程度放弃了“动态类型”的,后面看例子就能看出。

ECS框架概览——组件
(ECS框架概览——组件)
  • 组件是纯数据结构,用来表达一个状态。
  • 它们不应包含任何行为,而只有状态数据。
  • 如果你需要提供一个公用接口以处理组件数据(以某种方式),可以采用一个自由函数传入这个组件(作为参数)的方式,而不是作为组件的函数方法(method)。

*PPT页中可以看到血量计算函数的具体写法。

ECS框架概览——环境
(ECS框架概览——环境)
  • 环境保存游戏中能被全局访问的数据。
  • 它们直接存在于内存中,而不与任何实体关联。
  • 任何环境类型只以单例的形式存在于游戏中。

*PPT页中可以看到以游戏光标为例的一套写法,包含基本结构、更新位置、绘制。

ECS框架概览——实体
(ECS框架概览——实体)
  • 实体是一个概念上的物体,由被称为组件的数据结构组成——每个组件代表了该物体的一类特定功能。
  • 一个实体由其关联的组件集合来定义。对于实体来说并不存在具体的“物体”概念。
  • 一个实体能被一个32位的整型数标识(相当于ID)。
  • 代码中仍有能表达单一或多个实体,并访问其组件的方式。
ECS框架概览——访问定义
(ECS框架概览——访问定义)
  • 访问定义是一个模板化的数据结构,它允许定义一次数据访问(的内容)。
  • 它是用于使ECS系统理解你的依赖项的。

*可以看到PPT页中的类似面向过程的语法,以及一些基础的模板样式。

ECS框架概览——访问的类型
(ECS框架概览——访问的类型)

定义链通常以一个特定的访问范围定义作为结尾(如果需要的话),以指明从一个实体或一个集合中读取数据。

*PPT页中的Group和Query都是对应集合类型。

ECS框架概览——系统
(ECS框架概览——系统)
  • 一个ECS中的系统是一个自由函数,它能接收一些输入、进行处理并产生一些输出给组件。
  • 系统也能添加或移除组件。
  • 系统使用访问环境作为参数。
ECS框架概览——系统(示例)
(ECS框架概览——系统(示例))

*PPT页中引用了两个实体:可受伤角色实体、伤害来源实体。而系统函数主要内容是基于一组伤害来源(查询)进行定时伤害结算。

ECS框架概览——系统注册
(ECS框架概览——系统注册)

为使一个系统生效,我们需要将其注册到游戏循环中。

我们可以将其注册到不同的游戏循环阶段中——对应系统组的概念:

  • 可变更新
  • 固定更新
  • 固定更新前的可变更新

当注册系统时,添加依赖项来影响系统的执行顺序是可行的。如果没有指定顺序,则系统会按访问需求的顺序执行。

ECS框架概览——系统注册(示例)
(ECS框架概览——系统注册(示例))

*这里update模块的细节作者没有给出。

*由于游戏中所有类型的执行逻辑都要放在各种系统函数中集中执行,可想而知这套框架不适合处理逻辑过于复杂并且耦合较重的游戏逻辑。

2 案例解析——THE CASE BOARD

案件板
(案件板)
  • (女主角)Saga搜集证据的串联图板。
  • 她能通过放置线索,并将其与游戏中获得的其它信息组合来推进调查进度。
  • 它主要的功能是帮助玩家梳理故事线,并更加投入其中。
  • 某些游戏中的事件只能被案件板中线索的组合来触发。
案件板的需求
(案件板的需求)

玩家在游戏中能搜集不同类型的线索(通过交互、拾取、调查实景模型,以及脚本事件)。

案件板的需求
(案件板的需求)
  • 玩家通过拖动操作来将线索放置在板上,并与其它线索组合。
  • 部分线索会依赖案件板自身的事件触发来被放置或解锁。
案件板的需求:调查
(案件板的需求:调查)

游戏中会有多项进行中的调查,玩家可以在它们之间切换。

案件板的需求:编辑整理案件
(案件板的需求:编辑整理案件)

案件信息的布局是通过一个编辑工具定义的——它是由Remedy的主UI/UX设计师Riho Kroll开发的。它能允许叙事设计师以所见即所得的方式编辑案件。(*What you see is what you get)

案件布局数据会被导出成一个配置文件(lua格式),并用于游戏中。

案件板的架构
(案件板的架构)
  • 代码以一组模块作为组织结构。
  • 模块至少由一个头文件和一个.cpp文件。(*C++语言)
  • 每个模块有一个或多个系统。
  • 对于案件板的模块,其中有一组模块,各自以应对特定的功能特性。
  • 也有一组与其它游玩系统共享的模块。

*简单看看案件板模块,其中有例如:案件板模块、手模块、案件绘制模块、案件道具生成等。而共享模块有:游戏光标模块、摄像机追拍模块、动作模块等。

案件板元素:线索条目
(案件板元素:线索条目)
  • 板上的每个案件是一颗线索条目的“树”。
  • 线索条目是玩家在案件板上操作的主要物体。
  • 当一个线索条目执行了某些动作后(例如:放下、解锁、组合),一个或更多的规则可能被触发。
  • 每个规则可能调用一些其他的动作(放置条目、解锁至手上、移除条目等),以应对特定条件被满足的场合(某些道具被放置、解锁等)。
  • 线索条目可以通过以下方式被放置到板上:被玩家从“手”上放下;通过触发规则;显式通过脚本调用。
案件板元素:线索条目
(案件板元素:线索条目)

线索条目可能有如下类型:

  • 案件——一个调查文件夹条目。是所有条目的父节点,被自动放置。
  • 照片——开启一个新调查分支。通过手部操作放置。
  • 线索——拍立得照片、引用、手稿页。由玩家在游戏过程中收集,通过手来放置。当与问题成功组合后能被放置在板上。
  • 推理——当所有线索被与问题组合后,会被自动放置在问题下方。
案件板环境
(案件板环境)

我们将所有案件(名称、条目集合)、条目描述、状态(放置、解锁、挂载父节点)存储在一个环境中。

从以下角度来看这(种设计)很有用:

  • 很便于从配置文件中加载数据。
  • 这是一个同步点。当我们想修改当前案件板的逻辑状态时,特别是从脚本调用时,我们都需要通过案件板环境。
  • 非常便于加载案件板的状态。
案件板:通过线索条目执行动作
(案件板:通过线索条目执行动作)

这种方案中,为一个线索条目执行任何动作会如下实现(图中)。

这是一个自由C++函数,传入itemId和一个案件板环境的可变的引用作为参数。

在这个函数中我们将执行如下步骤:

  • 找到环境中某条目的一个可变指针
  • 修改(需要的)状态。
  • 执行动作特有的逻辑。
  • 处理动作特有的规则。

*引用reference和指针pointer是一组C++概念。

案件板模块
(案件板模块)

这一模块与“真实世界”条目实体一起生效,主要负责:

  • 更新条目状态。
  • 更新板上的条目的空间变换。

逻辑被拆分成不同的系统。这是非常必要的,因为计算和应用最终的空间变换不如它看上去那么容易。

这也使我们能:

  • 避免锁定非必须的数据,以至于阻碍其它游戏系统的执行(例如空间变换)。
  • 在可行时并行执行系统。
  • 确保特定的逻辑应用到所有需要的实体上——在执行下一步骤前。
案件板:计算空间变换
(案件板:计算空间变换)
  • 在(开发中)的很长时间里,线索条目是能通过光标在板上随意放置的。
  • 当移动一个条目时,所有子条目需要保持其基于父条目的偏移值(一起移动)。
  • 条目可以叠放并堆叠起来。
  • 并且,它们的位置不能超过板的边界。
案件板:变换系统
(案件板:变换系统)

整套位置机制被拆分为多个系统,逐个执行:

  1. 更新状态和偏移值,尤其是通过案件板环境设置的“真实世界”实体。
  2. 设置条目的XY位置和边界——基于偏移值和案件板边界。
  3. 检验条目的叠放,以确认其上下关系:基于层级、条目类型、条目索引、(是否)最后被操作等。
  4. 基于叠放信息对深度排序,以计算相关的Z值。
  5. 设置条目的最终空间变换值(或直接通过动画的方式)。
案件板模块
(案件板模块)

*系统执行顺序是通过excuteAfter语法来确保的。

案件板模块:状态更新系统
(案件板模块:状态更新系统)
  • 我们定义了一个组件结构以存储所有的itemId和状态。
  • 我们通过环境中存储的条目状态来比较组件的状态,在必要时对组件做修改。
  • 关键的访问是对于条目的实体的,因此系统可以潜在的在一个实体层级上并行执行:对于每个实体,一个系统在其所述的线程中执行。
案件板模块:状态更新系统
(案件板模块:状态更新系统)

*案件板item的具体数据结构及状态函数。

案件板模块:更新边界
(案件板模块:更新边界)
  • 在更新了条目状态,并读取它的偏移值数据后,我们需要更新它(和板位置相关)的XY位置和边界——把板的限制列入考虑。
  • 为存储这些值,我们特定了一个新的组件用于条目的XY边界。(*提到的ItemBoundaries组件)
  • 这一系统将从条目组件中读取条目的状态,或许网格组件的外延值,并写入ItemBoundaries组件。
案件板模块:更新边界
(案件板模块:更新边界)

*边界处理——基于三个二维向量,中心、最小值、最大值。

案件板模块:检验叠放
(案件板模块:检验叠放)
  • 在我们更新了条目的中心和边界后,我们可以检测叠放在一起的条目。
  • 这(计算的信息)被用于后续更新条目的深度。
  • 系统的关键访问是(特定的叠放物)实体,但我们也需要分别查询所有其它条目。(*这句的意思可以参照后面的函数体)
  • 如果我们想保持一个实体层级的并行执行,我们需要确保在一次查询访问时不读写同一实体。(*否则就要改串行或者考虑加锁了)
  • 然而,将叠放结果写入一个单独(分开)的组件中也是必要的。
案件板模块:检验叠放
(案件板模块:检验叠放)

*前面提到的特定单个实体是OverlappedItemsEntity类型的实体,后续需要写入叠放计算结果;查询的是通过ItemBoundariesQuery以访问其它Item实体,并通过itemsHaveOverlap和isItemBelow函数来确定上下层级;计算的结果写入itemsBelow容器中。

案件板模块:更新深度
(案件板模块:更新深度)
  • 在得到所有条目的叠放信息后,我们可以对Z坐标进行排序。
  • 为实现这一点,我们创造了“层”的概念——基于某一条目其下条目的数量。
  • 为创造这些“层”,我们需要读写所有条目数据以处理全部依赖关系。(*读写的是不同的组件类型)
案件板模块:更新深度
(案件板模块:更新深度)

*这里的hasDependencies主要是上下层的依赖关系,简单来看就是通过对overlappdItems.itemBelow的整理和查询进行itemDepth的计算,并最终反应到Z值(层厚度)上。

案件板模块:更新空间变换
(案件板模块:更新空间变换)
  • 最终,在我们计算完XY和Z值后,我们为条目设置实际的空间变换。
  • 将这一逻辑放在一个独立的系统中,使我们能仅在实际需要时“锁定”变化组件,而不会阻止其它系统在一帧中的早些阶段来与其交互。
案件板模块:更新空间变换
(案件板模块:更新空间变换)

*分别计算了和板之间相对的变换boardRelativeTransform,以及世界坐标系变换worldTransform——最终实际生效的是世界坐标系变换。可以看到这里有一个动画的分支,后面会提到。

案件板模块:动画条目
(案件板模块:动画条目)
  • 实体的动画(弹簧运动)是被一个不同的(可以用于其它游玩特性的)系统来处理的。
  • 为一个条目增加动画,只用为其增加一个运动组件。
  • 当动画结束时,我们移除这一组件。
  • 我们使用一个特定的辅助物体——指令缓冲来注册这些组件的布局,并在每一帧的最后被flush。

*flush是程序开发上从缓冲区把数据汇入数据流(或进行清理)的一个概念。这个词直译的意思是“冲洗”,但这个场合没有合适的翻译;而且这部分细节后面的代码示例并没有涉及。

案件板模块:动画条目
(案件板模块:动画条目)

*这里的动画只展示了一个比较简单的基于deltaTime的程序动画示例,能实现类弹簧或摆荡的效果。具体的逻辑就是在达到目标变换值前一直tick动画系统。

3 脚本调用

*引入脚本语言的意义有很多,最容易理解的就有比如:更易于编写、不用等待编译等。这里不展开了。

Lua脚本
(Lua脚本)
  • 某些案件板功能需要开放给设计师,在脚本系统中使用。
  • 我们使用Lua作为脚本语言。
  • 我们并不在Lua端反射数据。设计师不需要考虑例如组件、访问定义等这类概念。作为替代,他们操作函数,传入实体ID作为参数。
  • 我们提供了两种从Lua端进行交互的方式:C++的Lua绑定;Lua事件。
  • 我们的Lua脚本执行于一个单线程序列。当它们被执行时,所有组件的状态都是同步过的,并且也没有其它系统在执行。这样,设计师们有不用考虑多线程相关的问题了。
脚本:Lua绑定
(脚本:Lua绑定)

Lua绑定是被组织成Lua模块的C++函数,能允许lua脚本调用C++代码。

每个Lua绑定由三部分组成:

  1. 一个绑定函数——在C++和Lua虚拟机之间传输数据,并校验输入的参数的有效性。
  2. 文档注释——用于生成VS Code中汇集式的函数说明。
  3. 类型定义——被Lua的类型校验器用于检测脚本错误。类型定义信息是基于文档注释自动生成的。

一个绑定函数的主要结构(步骤)是:

  • 从Lua参数堆栈获取参数。
  • 检验参数有效性,对于无效的类型(例如函数希望输入一个string,但传入了number)或值(例如超出范围的值),在必要时进行报错。
  • 调用C++函数或类方法以执行实际的逻辑。
  • 将返回值和返回值的数量推入Lua堆栈。

*Lua这一侧是调用示例。实际的绑定函数内容在右侧C++部分。

*以第一个函数为例,第一行注释描述了输入和返回的类型,各自就对应了checkString和pushBool调用步骤。

脚本:Lua事件
(脚本:Lua事件)
  • Lua事件是C++向Lua脚本通信的方式。
  • 我们使用环境(*这里还是ECS概念)LuaEvents来收集一帧中的事件。
  • 收集的事件会在下一帧开始被派遣调度。
  • 每个发送了Lua事件的系统都需要是单线程的,因为需要对LuaEvents环境进行数据写入。
脚本:Lua事件
(脚本:Lua事件)

*lua侧在初始化时注册了一个回调函数,而C++侧通过broadcast进行事件广播。

总结
(总结)
  • 通过ECS框架,gameplay程序员的思维模式被改变了。与处理对象不同,这个框架里我们和数据打交道。
  • 总的来说程序执行看起来很像和数据库交互,因为我们(更关注于)查询和修改数据。
  • 动态增加和移除组件的能力使我们能在运行时轻松地为不同实体开关系统。
  • 系统注册允许我们显式的指定访问同一份数据的不同系统的执行顺序。
  • 为获得更好的性能,更佳的方式就是把数据拆分成不同的组件,而不是放在一起。
  • 我们可以构造一个便利和坚固(robust,直接翻成“鲁棒”也行)脚本交互系统,它不需要设计师关心数据的分布(结构)及并行化逻辑。

结语

以我个人的角度来看,Remedy在开发这个游戏使使用ECS的必要性或许没有那么大。虽然ECS没有太多程序上的弊端,但确实在开发过程中写代码构建复杂世界时不太利于大规模并行开发;总的来说这个游戏可“游玩”的内容确实有限,除了“走路模拟”“情景解密”之外的内容不算多,这么看使用ECS是利大于弊的。

从理念角度来看,ECS更适合数据类型相对有限,但实体数量级相对会比较高的游戏。例如大量单位的即时战略游戏,或是有着大量简单行为单位的割草游戏等。我能想到的非常适合使用ECS的一个类型就是“幸存者like”游戏;而我也确实觉得几乎不可能用ECS来开发一个开放世界游戏。

另外,ECS的介绍资料其实很多,除了我提供的一些链接外有兴趣可以自行搜索。

最后是资料链接:

B站Games104中讲解面向数据编程的部分

“ECS in practice The case board of Alan Wake 2” 的PPT地址