Dagor ECS
基本概念
Entity: 实体是根据模板创建的一组组件,每个组件都有一个初始化器(数据集)。需要注意的是,实体不是一个容器;组件并不存储在实体 “内部”。
EntityId: 这是实体的标识符,作为弱引用,重用周期(生成)有限。
Component: 本质上,组件是一个纯数据类,没有任何代码和行为描述,而是包含在 ECS 系统中。组件可以用面向对象编程(OOP)的方式编写,就像传统的编程方式一样(例如,有类但没有多态性的 C 语言),但其方法只能从接收相同类型组件作为参数的 ECS 系统中调用。组件可包括复制构造函数、比较运算符或/和赋值运算符,但至少必须有构造函数和析构函数,以及序列化和跟踪变化所需的附加元素。
模板: 创建实体的唯一方法是指定一个完全由数据驱动的模板,其中列出实体的所有组件(它们可能被分配给一些默认值)。可以提供一个可选的初始化器来覆盖实体中的这些默认值。
原型: 一个特定的组件列表定义了一个原型。共享同一原型的实体的所有组件都以最佳方式存储在内存中(数组结构,SoA)。同一原型可对应多个模板。与原型直接交互是不可能的,因为它们是框架实体,但了解模板对应的原型是非常有用的。
ComponentTypeManager: 该对象管理非 PoD(旧数据)类型的生命周期(即创建和销毁),如 visual_effect、animchar、human_phys_actor 等。这是一个函数表,其中实现了构造函数、析构函数、复制构造函数、比较运算符、赋值运算符、移动运算符、replicateCompare(比较 + 赋值)。 只有构造函数和析构函数是必不可少的。在创建带有初始化器或带有数据的模板的组件时需要复制构造函数,但在模板不包含数据时(只有描述)则不需要。序列化和跟踪更改需要比较/赋值操作符。因此,当不需要序列化和跟踪时,可以省略操作符。
Important
在 99.9% 的情况下,不需要实现 ComponentTypeManager。您只需确定指定的数据类型是否可重置(即是否有其他东西包含指向它的指针),并使用标准宏 ECS_DECLARE_BOXED_TYPE(或 ECS_DECLARE_RELOCATABLE_TYPE)声明它。可重置类型速度更快,并能确保更好的本地性。如果数据类型(例如 fixed_vector)存储了指向自身的指针(或者类型的大小大于 65536 字节,但愿这种情况永远不会发生),则不能使用该类型。
系统: 纯函数,可与预定义的组件列表配合使用:
onUpdate. 每帧调用的纯函数。从本质上讲,它是 BroadCast Event Immediate,但针对该事件(阶段)有大量 ES 监听器的情况进行了优化。onEvent. 通常与特定实体一起工作(事件被发送到特定实体)。不过,也可能有 BroadCast 事件。onEvent不仅接收组件图元,还接收类型化的事件。事件可以定期推迟(事实上也会推迟),但所有相关系统都会同时处理它。performQuery. 纯函数,在另一个函数内部调用(见下文)。BroadCast Query 和onUpdate都是查询 + 一个参数(事件/阶段)。
ChildComponent: 不属于实体的组件。初始化器(用于创建)或对象(表格)或数组中的 “子 ”组件。
Query: 一种快速便捷的方法,用于获取具有指定属性(组件)的所有必要实体。也就是说,类似于 Get_All_Humans 或 Get_All_Humans_With_Transform这样的查询。事实上,简单的系统会对阶段/事件的所有查询结果执行功能。
EntityManager: 这代表了 ECS 背景下的 “整个世界”。
实体原型的突变: (实体组件的变化)。只有通过reCreateEntity(重新创建实体)才能对原型进行更改,它允许通过添加旧模板中缺失的组件和删除不必要的组件过渡到新模板。如果新模板对应的是同一原型,则操作为空(无变化)。
跟踪/复制: Dagpr ECS 可自动跟踪更改并触发事件和/或通过网络复制对象。为实现这一功能,实体组件需要在模板中进行适当标记。非 PoD 类型必须实现比较和赋值操作符。
replicateCompare(from, to)有一个 “优化” 变体(默认情况下是 if(!(from == to)) {from = to; return true; } else return false;)。如果数据类型相当复杂和庞大,但该类型对象的所有变化都通过 “set ”和 “get”(可变)进行了安全封装,则可以使用生成/哈希逻辑进行更优化的比较。如果生成没有改变,那么数据也不会改变。例如:ecs::Objects, ecs::Array。
复制: 使用 “最终一致 ”模式复制更改,这意味着并非所有更改都能按照它们出现在服务器上的确切顺序被客户端 “看到”(有些更改甚至可能被遗漏),但所有更改最终都会被客户端接收到。这样做的目的是,如果包含复制内容的数据包丢失,而相关组件已经更改,则不会重新发送 “过时 ”的数据。
特征
组件是无代码的: 组件不包含任何代码。它们不是 OOP 对象,因为封装、继承和多态性与 ECS(实体-组件-系统)原则相抵触。除了 getter 和数学运算符之外,方法也不存在。(更多详情,请参阅此 视频)。
系统(
onUpdate/onEvent)无数据: 系统不存储或修改数据,包括全局或静态变量(配置数据除外)。 系统只在预先注册的预定义数据上运行。如果实体缺少所需的组件,系统将不会对其进行处理。 反之,如果实体包含所需的组件,系统就会对其进行处理。不过,系统仍然不知道实体可能包含的任何其他组件,也无法与它们交互。在与其他实体的组件一起工作时存在一个例外,这些组件必须在注册时明确声明。系统负责把世界从一个一致的状态转换到另一个一致的状态,不能依赖其他系统的存在或行为。
实体的延迟创建和销毁: 实体以延迟方式创建和销毁,但不迟于更新周期结束。
延迟 onEvent 执行:
onEvent的执行被延迟,延迟帧数未知。系统(
onUpdate)同时执行: 所有系统(onUpdate)同时执行。系统的执行顺序(前/后)在 ES 注册时指定,并受拓扑排序的影响。组件的最佳内存存储: 组件数据以最佳方式存储在内存中(数组结构,SoA),盒装组件除外。盒装组件在标准盒装创建器模板中进行了优化,允许按顺序分配内存,但仍可能出现碎片。
使用多个任务/查询的复杂系统: 您可以编写由多个任务/查询组成的系统。为此,包装系统被定义为不接受任何组件,并在其中描述查询(任何所需的数量和类型)。
简单系统和查询的多次调用: 每个简单系统(和每个查询)都可以并将会被多次调用–符合要求的每个原型的每个数据块都会被调用一次。
并行性: 任何
onUpdate或广播onEvent的执行都可以并行化。这可以发生在每个系统中(在多个线程中执行单个系统的代码,典型的 SIMD),也可以通过同时执行多个系统来实现。由于 System(
onUpdate)/onEvent在注册过程中明确声明了哪些组件是只读组件,哪些是读写组件,因此实体管理器可以准确地确定任何一组系统是否存在读写冲突,从而在不调用其他 ECS 相关代码的情况下允许并行执行。不过,目前还不支持这种并行性。在单个系统内执行 SIMD 是可能的,但极其危险且不可取,除非你完全了解自己在做什么。在基于 *daNetGame *的游戏中,在声明期间的查询中指定为
`template<typename Callable> void animchar_update_ecs_query(Callable ECS_CAN_PARALLEL_FOR(c, 4));
而 ES 为
void animchar_update_es(const UpdateStageInfo &, EntityId eid, Callable ECS_CAN_PARALLEL_FOR(c, 4));
或在
es_order中为`es_name {mt{stage_name:i=quant_size;}}其中,
stage_name可以是es_act、es_before_render或其他,quant_size是最小工作 “量子”(即需要处理的最小图元数)。这个量子大小取决于系统:它越大越好(开关越少),但如果系统只需要处理 10 个图元,而量子大小是 10,就不会有并行性。一般来说,好的量子大小约为 “预计元组数/16”(因此 4 个线程中的每个线程会得到 4 个工作块)。但是,如果工作块在时间上不均衡(动画可能非常简单,也可能非常复杂),则最好保持较小的量子,如 4 个元素。Caution
除了少量孤立、缓慢的代码外,不要使用并行性。
基于预算的延迟
onEvent执行: 根据可用资源(“预算”),任何onEvent的执行都可以延迟任意帧数。不幸的是,预算不是以毫秒为单位(因为测量毫秒很慢),而是以事件为单位。因此,单个onEvent不会造成重大延迟是至关重要的。
直接操作
直接操作组件: 只有在一个条件下才允许直接操作组件,即不能在
onUpdate/onEvent内进行操作。如果它确实发生在
onUpdate/onEvent中(例如,触发区正在检查是否有人进入),则必须明确声明(通过查询)。这样的系统不能完全并行化(如果它可能(重新)写入整个世界),也不能与特定的其他系统并行化(如果它明确声明了写入的组件),或者只能在自身内部并行化(如果它从整个世界读取)。
避免获取/设置: 一般来说,建议避免使用
get/set方法。
实体状态
在编写网络代码时,尤其是在通过 get/set 操作(应尽量避免)处理外部实体 ID(不包括在查询/es 中)时,了解实体在其生命周期中可能存在于四种主要状态之一至关重要:
不存在: 实体不存在。
存在但为空(又称分配的句柄): 实体存在,但没有任何组件。当存在对实体的引用,但服务器尚未创建实体时,客户端就会出现这种情况。
存在且为空,但排队等待加载: 实体存在且为空,但排队等待加载。这种情况发生在异步创建实体时。
存在并已创建: 实体存在,所有必要组件都已存在并加载。
对于每一种状态,都有相应的检查(根据 ECS 版本的不同可能会有所不同,但总是存在的)。
还有一些更奇特的状态,比如一个实体已经创建/加载,但缺少一些组件,因为该实体是通过再造创建的复合体(例如,“base+foo+bar”,其中 “foo ”尚未添加)。
为避免出现问题,请使用 getNullable/getNullableRW代替 get/getRW(在开发版中,它会在这种情况下断言,但在发布版中仍会返回对空内存的引用)。完全为空的实体将返回一个 “空 ”模板名称。
传统(OOP)
如果你有一个 OOP 对象,想把它变成一个组件,这本质上并不违反 ECS 范式。不过,所有代码(对象方法)只能在更新时调用。
从本质上讲,这只是将多个 onUpdate/onEvent 的共享代码移到一个共同的位置。
不过,建议的做法是将这些常用代码提取到一个单独的共享库中,而不是将其作为一个类方法,以便与 OOP 明确区分开来。如果 OOP 对象是多态的,则根本不应使用这种方法(最好也不要这样做)。
方法上的差异
假设你想创建触发器(例如,进入触发器时会发生一些事情)。之前你有一个球形触发器,现在你还需要一个立方体触发器。
OOP 方法:
class IShapedTrigger {
virtual bool isInside(const Point3 &p) = 0;
}
class BoxTrigger : public IShapedTrigger {
bool isInside(const Point3&) override;
}
class SphereTrigger : public IShapedTrigger {
bool isInside(const Point3&) override;
}
// Update code:
IShapedTrigger *trigger;
foreach (unit) {
if(trigger->isInside(unit.pos)) {
do_something();
}
}
ECS 方法:
void sphere_trigger_es(int trigger, const Point3 &sphere_c, const float sphere_r) {
performQuery([&](Unit &unit) {
if (length(sphere_c - unit.pos) < sphere_r) {
sendEvent(IN_TRIGGER, Event(unit, trigger));
}
});
}
void box_trigger_es(int trigger, const Point3 &box_0, const Point3 &box_1) {
performQuery([&](Unit &unit) {
if (unit.pos & BBox3(box_0, box_1)) {
sendEvent(IN_TRIGGER, Event(unit, trigger));
}
});
}
onEvent(int trigger, Unit &unit) {
do_something();
}
如您所见,这两种方法都是可行的,但
延迟执行:
doo_something可以在 ECS 中延迟执行,这不是问题,而是有益的。脱钩:
onEvent系统不知道是什么触发了事件。它可能是一个球形或立方体区域,甚至是一个单位的特殊能力。系统并不关心,也不需要知道。这促进了解耦。调试和序列化: 整个过程可以完全图形化调试、序列化,并作为事件及其结果通过网络发送。这样,就可以将必要的计算(如图形效果)传输到客户端,而无需使用
if语句。可扩展性: IShapeArea 接口可根据需要进行广泛扩展。如果需要,一个单元可同时位于多个区域。
代码少: 虽然看起来代码量相似,但 OOP 方法缺少成员(如盒、球)、实际函数实现和实例创建代码。ECS 版本包含所有必要的代码(不包括 codegen 生成的代码)。
性能
典型实体特征: 在大多数游戏世界中,典型实体至少有 5-10 个组件,有时甚至有几百个,消耗数百字节到数十千字节的内存。
组件特性: 组件(成员)通常是多种多样的,很少有一个方法会同时与所有组件交互。因此,在缓存效率和分支预测方面,OOP 通常会产生非常不理想的模式。
实体中的多态性: 现实世界中的实体经常表现出某种形式的多态性,这意味着多个实体对语义相似的数据执行相同的行为。
ECS 与 OOP 的比较: ECS 提供了比经典 OOP 更有效的实体及其数据访问(通过数据鸭型),但它在性能方面如何呢?
DagorECS - 面向数据且快速: Dagor ECS 采用面向数据的方法设计,具有高速和出色的缓存定位功能。
实体创建
框架开销: 实体创建过程中框架的开销可以忽略不计。
批量创建: 创建许多实体(即使有初始化器)通常比创建一个
vector<Entity>并逐个添加要快。它比一次性创建所有实体的速度稍慢(约 40%)(这一速度在实际任务中是无法实现的,因为它代表了一个上限),比使用vector<unique_ptr<Entity>>的速度稍低(约 30%)(正如我们将看到的那样,后者会带来显著的性能损失)。
以下是创建 30,000 个实体的一些测量结果,每个实体由 15 个 POD 组件组成,实体总大小为 516 字节(跟踪一个组件,不跟踪表示不跟踪组件):
struct Entity {
TMatrix transform = {TMatrix::IDENT};
int iv = 10, ic2 = 10;
Point3 p = {1,0,0};
TMatrix d[9] = {TMatrix::IDENT};
Point3 v = {1,0,0};
int ivCopy = 10;
};
创建 30,000 个实体的时间:*
- daECS create (no tracked): 6788 us
- grow vector create: 10088 us
- best possible (single allocation) create: 4768 us
批量创建的平均时间: 每个实体 0.22 微秒。这是同步创建时间,没有任何事件系统(ES)捕获这些实体的创建事件。
结论: daECS 的实体创建速度非常快!不需要实现产卵池或其他类似的复杂功能。
数据处理/框架更新
为了评估这一点,我们使用同一个实体和一个微不足道的运动学更新:pos += dt * vel。
标注为 “最佳可能 ”的时间代表利用两个并行阵列(非实体)的数据导向设计可实现的最大速度。
使用 “冷 ”高速缓存:
- daECS update: 49.45 us
- vector<Entity>, inline: 460.20 us
- vector<Entity*>, inline: 502.40 us
- vector<Entity*>, virtual update: 683.45 us
- best possible, inline: 44.55 us
使用 “热 ”缓存:
- daECS update: 35.7 us
- vector<Entity>, inline: 299.8 us
- vector<Entity*>, inline: 346.0 us
- vector<Entity*>, virtual update: 561.8 us
- best possible: 34.7 us
性能总结: 尽管 ECS 框架具有极大的便利性,但它比处理任何形式的 OOP 实体都要快 10 倍以上(尤其是比具有多态性的 OOP 更快)。
最佳速度: 使用 “冷 ”缓存时,最佳速度不会超过 10%,而使用 “热 ”缓存时,最佳速度几乎相同。
与其他框架的比较: 与另一个著名的面向数据的框架 Unity 2018 ECS 的比较:我们有一个 Unity 算法的精确实现,daECS 的性能大约快 4-8 倍。
结论: 使用 daECS 工作的速度快得令人难以置信!
代码生成
我们有一个 codegen(代码生成)工具,可以使用处理一个实体(组件元组)的 “lambda ”自动生成系统注册的标准化绑定。
这是一个 Python 脚本,用于解析文件,识别包含以下模式的函数:
*_es*_es_event_handler*_ecs_query
然后用相应的名称注册系统。例如,water_es(UpdateStageInfoAct) 和water_es(UpdateStageInfoRender) 或water_es_event_handler(Event1)/water_es_event_handler(Event2)这样的函数会被注册为两个独立的系统,并拥有各自的组件集。
codegen 可识别类型化的 on_update 函数(其中第一个参数是具有指定类型的舞台)和类型化的 event_handler 函数。
参数绑定:
严格的名称绑定:** 所有参数都严格按照名称绑定。名称不能省略!如果行为需要名称,但不需要数据本身,请使用
ECS_REQUIRE(type name)。对于
ECS_REQUIRE中列出的参数,您可以使用ecs::auto_type(这不是一个实际的类型,但可以避免不必要的包含等)。可选参数: 这些参数要么有默认值(如
float water_wind = 1.0f),要么通过指针传递(如float *water_wind)。如果参数是可选的,那么缺少该参数的实体将使用默认参数(如果是指针,则使用nullptr)进行处理。隐式组件: 类型为
ecs::EntityId的eid组件会被隐式添加到所有模板中。
代码优势:
所有生成的代码都是人类可读的,这意味着您可以审查甚至手动编写代码(但不建议这样做)。Codegen使代码更易于阅读,减少了人工操作(以及随之而来的错误),并能更快地重构选定的 ECS 框架 API。
ECS 指令:
ECS_TRACK(name1,name2): 系统将 “跟踪 ”组件name1和name2的更改。ECS_BEFORE(name1, name2) ECS_AFTER(name3): 系统将在name1和name2之前执行,但在name3之后执行。nameX可以指其他系统的名称或es_order中列出的同步点。任何未指定在__first_sync_point之前运行的系统将始终在它之后运行。ecs_no_order: 该系统的执行顺序并不重要。ECS_TAG(render, sound): 只有程序配置了这些标签(例如,server用于服务器端,gameClient用于客户端等),系统才会运行。ECS_ON_EVENT(on_appear): 这是ECS_ON_EVENT(ecs::EventEntityCreated,ecs::EventComponentsAppear)的快捷方式 -ECS_ON_EVENT(on_disappear): 这是ECS_ON_EVENT(ecs::EventEntityDestroyed,ecs::EventComponentsDisappear)的快捷方式。ECS_require、ECS_require_not: 这些用于 codegen 的宏应直接放在 ES 函数之前。例如ECS_REQUIRE(int someName) ECS_REQUIRE_NOT(ecs::auto_type SomeAbsentName) void foo_es(float hp) { // 该函数将仅接收具有 `someName` 组件(类型为 `int` 的实体的 `hp` 数据。) // 但没有 `SomeAbsentName` 组件(类型无关)的实体。 }
Important
ECS_REQUIRE通过 annotate 属性工作,但遗憾的是,该属性不能注解字面形式,只能注解名称(参数或函数的名称)。因此,以下方法将不起作用:
void foo(int some_component = 1 ECS_REQUIRE(ecs::Tag some_tag)) // This won't work
不过,这也是可行的:
ECS_REQUIRE(ecs::Tag some_tag)) void foo(int some_component = 1)
ECS_ON_EVENT(EventName, EventName2, ...): 如果处理程序正文需要对不同的事件执行相同的操作,请使用ECS_ON_EVENT,以避免复制粘贴。
ECS 核心事件:
单播事件:
EventEntityCreated: 在实体完全创建并加载后发送。只发送一次。EventEntityRecreated: 与前一个事件相同,但可能会发送多次。该事件发生在调用 reCreateEntity 之后。EventComponentsDisappear: 如果该 ES 不再适用(即组件列表不再匹配),则在重新创建时调用该事件。EventComponentsAppear: 如果 ES 开始应用(即组件列表匹配),则在重新创建时调用事件。EventEntityDestroyed: 在实体被摧毁前发送。EventComponentChanged: 现有组件更改后发送。Important
这是一个独特的优化事件。只有当实体系统(ES)需要被修改的组件时,它才会被触发。与其他事件不同的是,即使 ES 与组件列表相匹配,也不会收到该事件,除非修改的组件包含在 ES 的组件列表中。
广播事件:
EventEntityManagerEsOrderSet: 在设定 ES 订单后发送。EventEntityManagerBeforeClear,EventEntityManagerAfterClear: 在所有场景(所有实体)销毁前后发送。
代码示例
rect
{
pos:p2 = 0,0
rectSize:p2 = 0,0
color:c=255,255,255,255
}
brick
{
_extends:t="rect"
brick.pos:ip2 = 0,0
}
pad
{
_extends:t="rect"
isPad:b=yes
}
ball
{
pos:p2 = 600,700
vel:p2 = 330,-550
radius:r = 5
color:c=25,255,255,255
}
视频讲座
安东-尤丁采夫(Anton Yudintsev)关于 ECS 的视频讲座: