Skip to content
云风 edited this page Jan 23, 2024 · 6 revisions

基于 ECS 的引擎

Entity-Component-System (ECS) 是近些年游戏业内流行的架构,通常与之对应的是 Object-Oriented Programming (OOP) 。但到底什么是 ECS ?基于 ECS 的引擎应该是怎样的?并没有定论。Ant 引擎在设计时并没有拘泥于任何一种已有的 ECS 架构。很多 ECS 框架都针对 C++ 架构,主要提倡的是 Data-oriented Design (DoD) ,即围绕数据设计。强调的是数据在内存中的物理布局。Ant 虽然包含有大量的 C/C++ 代码,但它本质上还是一个基于 Lua 的引擎,所以和基于 C/C++ 的引擎面对的问题有很大的差异。

ECS 是我们在设计引擎时的一种技术选择,但并不意味着使用引擎的人必须接受 ECS 的开发模式。你可以选择用 ECS 模式开发你的游戏,也可以不用,游戏使用怎样的模式和引擎关系不大。即使游戏也采用 ECS 模式,比如我们用引擎开发的第一款 Factorio like 游戏,游戏业务中单独创建了一个 world ,和引擎渲染用的 world 是隔离的。我们并没有通过扩展引擎层面的 Entity 的 Component 来表达游戏业务中的数据,也没有给引擎的 world 添加 System 中来完成驱动游戏业务。最终渲染归渲染、游戏是游戏。游戏的 ecs world 中通过引擎的 Entity id 来引用渲染层的对象;当然你也可以在游戏中采用 OOP 模式,一样使用这些 entity id 。

我们也不排斥游戏直接去扩展引擎 ECS 架构中的 Component 类型,添加新的 System ,那都可以看作是对引擎的扩展。到底该怎么做,是用户自己的选择。当然,必须先了解 Ant 的 ECS 架构是怎样的,这是本篇文章的目的。

LuaECS

LuaECS 是在 Ant 开发过程中,专门为它实现的一个子模块。当我们发现它可以解决 Lua 开发中的许多通用问题后,便以一个独立子模块的形式维护。事实上,它也被单独用在了好几个其它项目中。

LuaECS 是 Ant 的 ECS 框架的基础。它名字中带有 ECS ,但它并没有包含 S 的部分。它仅仅是数据的表达,只包含了 Entity 和 Component 。在 LuaECS 看来,ECS 更像是一种内存数据库。以更合理的方式把数据组织在内存中,业务逻辑用使用数据库的方式把每次操作需要访问的数据筛选出来。所以,LuaECS 的核心 API 也叫做 select ,贴近关系型数据库 select 的语义。

LuaECS 对于 Ant 最重要的意义是:数据、它是引擎的核心,被组织在结构简单的 C 结构数组中,Lua 可以访问它,C 也可以直接访问它。我们可以用 Lua 编写引擎的核心逻辑,在 ECS 模式下,这些逻辑分属一个个 System 。当原型搭建完成,如果发现纯 Lua 代码有性能问题,经过 profile ,我们找到真正的性能热点,然后可以用 C/C++ 重写它。对于 LuaECS 管理的数据,C 代码可以直接访问,没有额外的开销。

得益于 Lua 的动态性,我们将数据放在 Lua 原生数据结构 table 中,和放在 LuaECS 的内存数据库中,代码是几乎相同的。只要我们遵循 LuaECS 的模式,可以很方便的在 C 内存结构和 Lua 原生数据结构间切换;操作这些数据的代码也可以方便的用 C/C++ 代替 Lua 初期编写的原型。

在引擎和游戏的开发过程中,我们做过很多次这样的优化:Lua 编写原型、找到热点、把 Lua Table 替换为 LuaECS 的 C 组件 、改写 Lua 编写的 System 原型为 C/C++ 的实现。事实证明,只需要做非常少的工作,就可以提升数以百倍的性能;同时极大的保留 Lua 动态性给开发带来的好处。

Lua 提供了三类基本组件:

  1. C Component :需要定义它的数据结构。Ant 的构建脚本会把这些 Lua 代码描述的数据结构转换为 C 语言的 .h 文件,在 C/C++ 代码中可以直接访问这些数据结构。
  2. Lua Component : 任意的 Lua 对象都可以是 Entity 的 Component 。它可以是一个 lua table ,在 table 中可以储存任意复杂的结构,而 LuaECS 并不关心其细节。
  3. Tag : 这是一种特殊的 Component ,它没有值,只用于 select 筛选。

C 组件能以零开销在 C/C++ 编写的 System 中访问,但在 Lua 中比原生的 Table 低效一些;Lua 组件在 Lua 中使用最为自由,但如果想在 C/C++ 代码中操控它们,性能受限于 Lua 的 C API ,比 C 原生结构慢得多。

另外,如果组件只是一个整数,它其实是一个 C 组件,无论在 Lua 还是 C 中都可以高效访问。

我们不用一开始就设计好哪些数据储存在 C 组件中,哪些放在 Lua 组件内。随着引擎的演化,经常会变动。对于用户,我们不提倡直接用 LuaECS 的 api 访问引擎数据,而是提供独立的 API ,它们会隔离内部实现的细节。

至于 tag ,在 Ant 中被重度使用。例如,一个用于空间裁剪的系统,它负责 select 出需要被裁剪的 Entity ,经过一番运算,把需要渲染的 Entity 添加上 visible 的 tag 。其它系统就可以通过 select 这个 tag 得到裁剪的结果。系统和系统之间并不直接通讯,而是改变着引擎中 world 的状态,这些状态往往就是通过添加或去除 tag 完成的。所以,特定的系统是用 Lua 还是 C 实现并不重要。无论是 Lua 还是 C 都有对应的 luaECS api 完成对内存数据库的筛选 (select) 操作。

ECS 框架

Ant 的 ECS 框架有许多独有概念。

首先是 Feature (特性) ,这是最终用户必须关心的概念。ECS 框架依赖很多琐碎的东西搭建起来,例如 Component 的类型定义、System 的实现等等。当用户想使用一个引擎功能时,引擎需要按需加载诸多相关的这些琐碎之物的实现。实现者把它们打包为一个 Feature ,使用者便不必关心细节,而只需要使用 import_feature() 引用这个特性 ,就加载了对应的一组琐碎之物 。

每个 Package 可以定义一个特性及若干子特性,包的名字就是主特性名。如果包含有子特性,可用 pkgname|featurename 指代它。特性的定义应该放在包的根目录下的 package.ecs 文件中。它是主入口。如果一个特性非常复杂,可以拆分到不同的 .ecs 文件中,用 import "name.ecs" 导入。.ecs 文件的语法是基于 Lua 语法的一个 DSL 。它描述了 ECS 框架所需的各种类型定义:system component pipeline policy feature ,这些概念对使用者未必每个都重要,但关心 Ant 结构的人需要了解,这些后面会分别介绍。

关于 import_feature

我们在一个 package.ecs 中定义个特性,如果这个特性还依赖引擎导入另一个特性,可以在定义中写上 import_feature "featurename"。那么,在加载这个特性时,就会同时加载这个依赖。引擎会正确处理依赖链。

我们也可以在 ECS 的系统运行过程中动态的导入一个特性:调用 world:import_feature(name) 这个方法即可。

每个游戏启动时,必须声明它所使用的特性(不必包括依赖项)。如果查看 test/simple 这个游戏示例的话,就会发现在启动脚本 main.lua 发现这样一段代码:

import_package "ant.window".start {
    feature = {
        "ant.test.simple",
        "ant.render",
        "ant.animation",
        "ant.camera|camera_controller",
        "ant.shadow_bounding|scene_bounding",
        "ant.imgui",
        "ant.pipeline",
        "ant.sky|sky",
    },
}

这段代码引入了 ant.window 这个包,并调用了包里的 start 方法。这个方法需要传入所需的特性列表,然后创建了一个默认的 世界 。在这个特性列表中,类似 ant.render 的特性名就是指在 ant.render 包里定义的主特性,查看 /pkg/ant.render/package.ecs 可以找到这个特性的定义。

ant.test.simple 则是游戏的主逻辑,它也是一个特性,可以在 /pkg/ant.test.simple/package.ecs 找到其定义。这个特性里面定义了和游戏具体业务有关的 system 。

ant.camera|camera_controller 这样的特性名称指的是 ant.camera 这个包内定义的子特性 camera_controllerant.camera 这个包的主特性并不会默认依赖 ant.camera|camera_controller 这个子特性,只有这样明确的写明特性的完整名字才行。

component

Component (组件),即 LuaECS 需要的组件类型。在 package.ecs 中定义 component "name" 可以定义出一个 LuaECS 的组件。只有名字的组件是一个 tag 。也可以用过 .type 添加具体的数据类型。lua 类型最为灵活,表示这个组件可以是任何 Lua 的原生类型:table , string 等等。还可以将 .type 定义为 int, bool 等,具体有支持哪些类型,可以参考 luaECS 的文档。这些值类型本质上是 C 组件,可以在 C/C++ 实现的系统中高效访问。

当 .type 为 c 时,表示它是一个 C 组件,并可以随后用 .field 定义具体的数据结构。例如,在 render_object.ecs 中就定义了这样一个 C 组件:

component "render_object"
    .type "c"
    .field "worldmat:userdata|math_t"

    --materials
    .field "rm_idx:dword"

    --visible
    .field "visible_idx:int"
    .field "cull_idx:int"

    --mesh
    .field "vb_start:dword"
    .field "vb_num:dword"
    .field "vb_handle:dword"

    .field "vb2_start:dword"
    .field "vb2_num:dword"
    .field "vb2_handle:dword"

    .field "ib_start:dword"
    .field "ib_num:dword"
    .field "ib_handle:dword"

    .field "render_layer:dword"
    .field "discard_flags:byte"

    .implement "render_system/render_object.lua"

它定义了一个复杂的 C 组件的结构,Ant 的编译系统会利用这个定义生成一个 C 的 .h 文件供 C/C++ 代码使用。生成出以下代码放在 clibs/ecs/ecs/component.hpp 中。

struct render_object {
	math_t worldmat;
	uint32_t rm_idx;
	int32_t visible_idx;
	int32_t cull_idx;
	uint32_t vb_start;
	uint32_t vb_num;
	uint32_t vb_handle;
	uint32_t vb2_start;
	uint32_t vb2_num;
	uint32_t vb2_handle;
	uint32_t ib_start;
	uint32_t ib_num;
	uint32_t ib_handle;
	uint32_t render_layer;
	uint8_t discard_flags;
};

组件会有构造方法等方法,.implement 告诉了引擎,这些方法的实现放在哪个 lua 源文件中。

policy

policy (策略),是组件的集合。因为组件的单元太小,实现一个功能时往往不会把所有的数据塞在单个组件中。而实现不同的特性,会有重叠的组件。所以我们使用 policy 来表达这种组合关系。

例如在 ant.animation 特性中定义了 slot 这个 policy

policy "slot"
    .include_policy "ant.scene|scene_object"
    .component "slot"
    .component "animation"

表示 slot 这个 policy 是在 ant.scene|scene_object 的基础上增加了 slot 和 animation 两个组件。

policy 的定义决定了 Entity 的构造时,需要为它构建出哪些组件,在构造时一般必须指定这些组件的初始值。

但某些组件的初始值可以由构造函数产生,不需要构造 entity 时外部提供。这时,可以把相应组件声明为 .component_opt 。

例如在 ant.animation 中还定义了一个 skinning 的 policy

policy "skinning"
    .include_policy "ant.scene|scene_object"
    .component_opt "skinning"

当构造一个带有 skinning 这个 policy 的 entity 时,就不需要在构造时给出 skinning 组件的初始数据。

system

system (系统),是 ECS 中数据处理的单元。在 .ecs 定义中,我们用 system 预定义系统的名字,并用 .implement 制定该系统的代码实现在哪个 Lua 源文件中。

例如,在 ant.animation 这个特性中,就定义了一个叫做 skinning_system 的系统。

system "skinning_system"
    .implement "skinning.lua"

这表示,我们定义了一个叫 skinning_system 的系统,它的实现放在同一个包内的 skinning.lua 中。

用 C/C++ 实现的系统比较特殊。例如在 ant.scene 中就定义了这样一个系统:

system "scenespace_system"
    .implement ":system.scene"

.implement 的冒号前缀表示,scenespace_system 这个系统的实现在 C 模块 system.scene 中。

pipeline

pipeline (管线),是 Ant 的 ECS 架构中比较特别的概念。它定义了 system 到底应该以怎样的次序工作。每个管线是一个树结构,即管线内可以包含子管线。在 ant.pipeline 这个特性中,有一个示例。

比如,它定义了一个叫做 update 的管线:

pipeline "update"
    .stage "timer"
    .stage "start_frame"
    .stage "data_changed"
    .stage  "widget"
    .pipeline "sky"
    .pipeline "animation"
    .pipeline "motion_sample"
    .pipeline "scene"
    .pipeline "camera"
    .pipeline "collider"
    .pipeline "render"
    .pipeline "select"
    .pipeline "ui"
    .stage "frame_update"
    .stage "end_frame"
    .stage "final"

框架在主动触发 update 管线时(通常是一帧一次),应该依次处理这些流程:先运行 timer ,再运行 start_frame ,依次类推……

每个流程叫做 stage ,可以看作是一个字符串占位符。在 system (系统)的实现中,会将一些函数填入对应的占位符中。每个系统都可以实现多个 stage ,在同一个 stage 下可以拥有不同系统填入的函数,当有多个函数时,它们的调用次序是不确定的。如果你需要保证两段执行流程的先后次序,就必须把它们明确放在不同的 stage 下。

即:system 本身不关心执行流的次序,pipeline 规划它们。

不同的游戏可以共享同一个特性,但可以有不同的 pipeline 。在不同的 pipeline 下,同一个特性表现出的行为可能不同。你可以通过 pipeline 屏蔽引用的某个特性中指定的执行流程;调整执行次序;一个特性也可以实现多个不同的执行流程,让不同的 pipeline 选择其一使用。

feature

.ecs 文件中可以再用 feature 申明一个子特性。并用 .import 指定子特性的定义文件,即另一个 .ecs 文件。

加载这个主特性,并不会一起加载其中定义的子特性。子特性需用 feature_name|subfeature 为名显式导入。

Clone this wiki locally