系列介绍
本系列是读ECS深入浅出——EnTT作者skypjack系列的笔记,并在C#中从头实现一遍,因此会包含较多的个人理解,如有疏漏,还望各位批评指正 ο(=•ω<=)ρ⌒☆
本系列将以一章理论(Theory),一章实践(Practicle)的节奏更新,实践章中一般会包含在实现过程中踩的坑与具体步骤。偶尔会在其中插入一章进阶(Extra),即较难理解的技巧向章节。
本章主要讲了ECS的理论基础
何为ECS?
ECS与OOP的区别
要弄清楚ECS是什么,不妨先明白它与传统架构OOP的差异。先来想象一个游戏场景:
在你面前有一块石头,一棵大树和在树荫下读书的一个人
那么,在ECS和OOP中,这个场景都将如何拆解呢?这里就可以引出两种拆解方式:水平分割与竖直分割
在传统的OOP架构中,拆解后的结构看起来像是这样的:
不难看出,OOP看起来像是把每个游戏对象竖直分隔开,成为独立的个体,并依赖继承关系来实现代码复用,也就是常说的类,每个GameObject(下文缩写为GO)都有自己独立的属性,彼此独立。
但,当GO间的继承关系变得复杂时,其本符合生活习惯的思维方式反而会变成一种障碍,各种复杂的多重继承更是令人脑袋晕晕。
那么,ECS的水平分割看起来又是什么样的呢?
可见,在ECS中,并没有很清晰的“GO”概念,取而代之的是一个个Component。不同的逻辑由相应组件的System实现,互不干扰,降低了耦合性。
换句话讲,OOP中,描述物体的方式是“这个物体是什么”,而在ECS中,描述物体的方式是有什么,是进一步的抽象。
在这种实现方式下,不同的GO之间的界限被抹除,只留下紧密排列的Component,System也不需要关心自己到底在操作哪个"对象",而只需要关注眼前的数据就好了,既不用引入无关数据,也不会干扰其他系统,可谓一举两得。
为何ECS?
ECS带来的最大便利,就是极致的速度
试想一下,如果你面前摆放着一些已完成的“乐高作品”,现在,要求你把所有的黄色2x2砖块都做上某种标记,这无疑是困难且不可能完成的。
但是如果是一些独立且排列整齐的积木块呢?在这种情况下,找出所有黄色积木块就非常简单了,只需要找出黄色积木块所在的组,依次标记就好啦 (o゜▽゜)o☆(如图)

乐高成品对应的即是OOP模式中的GO,以单个对象为单位,整体操作;而散落排布的积木块即是ECS中的Component,以单个组件为单位,精确而高效。
虽然传统的OOP在绝大多数情况下都没什么大问题,依旧正常运转,但是当加载的GO数量极大时,性能开销与缓存未命中也将指数爆炸。
这时,天空一声巨响,ECS闪亮登场,其准确简洁的数据结构几乎是为这种场景量身定制,效率提升十分显著。
不过,在绝大多数场景中,ECS与OOP的表现并没有明显区别。我们在ECS中获得的最大便利实际上是一种编程范式,或者说让代码更好维护的编程思维。
总之,再怎么说,学学ECS总是没什么坏处的嘛 ~( ̄▽ ̄)~*
万丈高楼平地起
从OOP到ECS
万事开头难,不妨先试试一种不那么激进的方式——让GO成为Component的承载者,而不是数据与方法的承载者,示例如下:
abstract class GameObject
{
/// 实体Id
int Id { set; get; }
/// 实体所承载的Components
List<Component> Components { set; get; } = [];
/// Component的类型,无序,用于加速判断
HashSet<Type> ComponentTypes { set; get; } = [];
/// 获取T类型的Component(示例)
Component? GetComponent<T>()
{
return Components.Find(c => c.GetType() is T);
}
bool Has<T>()
{
return ComponentTypes.Contains(typeof(T));
}
}而当我们想操作Component时,System可以像这样写:
void MySystem(List<GameObject> objects){
foreach(GameObject obj in objects){
if(obj.Has<MyComponent>()){
component = obj.GetComponent<MyComponent>();
// 干点啥 ¯\_(ツ)_/¯
}
}
}这种方法对于习惯OOP的人来说很容易接受,写起来也很舒服,且易于维护,但基本没有解决OOP的痛点,还可能导致更多性能问题。总而言之,这种方法只是一个由OOP到ECS过渡态的“杂交版”,只能起到思维上的过渡,并不推荐实际应用。
对象补完计划——何为Entity?
接下来,我们所要做的就是彻底消灭GO,取而代之,我们将称呼“石头”“大树”“人”这一类东西为Entity。
具体实现这一目的的方法,便是视Entity为Index,消灭GO之间的界限,只把实体看作对Component的索引,就已经足矣,我愿称之为「對象補完計画」(什)
化GO为Entity后,具体实现应该像这样:
namespace Entity{
public struct Entity(int id)
{
// 其实Entity还应有version属性来实现复用
// version的实现会在后面的章节(大约PR3?)完成
int Id { get; } = id;
}
}没错,正如你所见,一个纯粹的Index。Entity仅仅作为Component的Index,而不含任何功能,所有Entity的属性都别无二致,仅由Id组成。
天地阔,且荡漾——何为Component?
什么?你问Component怎么实现?
Component当然是Component!正如其名称本身,任何组成一个事物的重要要素都可以成为一个Component,它可以是任何东西,一个Vector3,一个int,只是标签而内部什么都没有,甚至Entity也可以视为Component!如何将事物拆解为一个个Component全看你的喜好~
比如:
struct Vector3(double x, double y, double z)
{
// 常规三维向量,例如3D速度、坐标...
public double X = x;
public double Y = y;
public double Z = z;
}
struct FireResistance{} // 是否抗火,此处仅作为标签使用
// 只需判断该Entity有没有此组件即可知晓能否抗火
enum FoodType{
Juice,
Alcohol,
Fruits,
Vegetables,
Meat
//......
} // 食物类型
struct Food(FoodType type, float hunger, float satiety){
// 更复杂些的
public FoodType Type = type;
public float Hunger = hunger;
public float Satiety = satiety;
}提示
当然,以上实现只是个例子,记住:Component只是数据,不用携带除了数据以外的任何东西,它甚至连Id也不需要,而只是纯粹的,美好的数据。(什么奇怪的形容啊喂)
那么,不同的Component是如何绑定到Entity身上的呢?
俯察品类之盛——何为Context?
Pool
如图,Entity和Component都以一定的顺序排列的数组中,Entity永远位于第Id项,Component同理,该种数组能使Entity与Component相互链接;而不同组件的数组又由不同组件的System掌管,如此,任意种Component,个Entity,都可以通过个长度为的数组实现将Entity作为Index访问Component。优雅而高效~
这就是另一个C++ ECS框架——entityx的实现方式,我们命名为Pool,而EnTT的实现方式(稀疏集SparseSet)我们将在TH2中详细介绍,此处先来实现较为简单的Pool。
首先,我们先来明确一下Pool到底是干什么的,其职责有三:
- 对Component的增删查改
- 内存管理(建立内存连续的数组)
- Component复用
但是,多数情况下,C#的GC的内存分配本就几乎一定是连续的,所以第二点在这里并不是主要目标 如果一定要手动实现,Span<T>或许是个不错的选择
大段的代码各位大概率不愿意看,因此在这里先大致总结一下Pool的实现思路,其实也并不困难。
- 存放:使用
List<C?>来存储,分为components(活跃组件)与freeComponents(死亡组件) - 增删查改:略,但只有
Remove和Add时需要与freeComponents数组产生关联值得一提
具体实现如下:
namespace Enlinium.Context
{
using Enlinium.Entity;
public abstract class AbstractPool
{
public ulong ComponentType;
public abstract bool AddTo(int Id);
public abstract bool Remove(int Id);
public abstract void Clear();
}
/// <summary>
/// 存放某种Component的Pool,以Entity.Id为索引
/// </summary>
/// <typeparam name="C">该Pool存放的Component类型</typeparam>
public class Pool<C> : AbstractPool where C : new()
{
protected List<C?[]> components = [];
protected List<C?> freeComponents = [];
public Pool(){
ComponentType = ComponentFamily.Type<C>();
// 此处的ComponentFamily.Type马上会提到
}
/// <summary>
/// 在Id处添加一个新建的component
/// </summary>
/// <param name="Id">要新建组件的Id</param>
/// <returns>添加成功则true,否则false</returns>
public override bool AddTo(int Id)
{
if (components[Id] is not null)
{
// 若此处已有对象,则不能添加
return false;
}
if (Id > components.Count * (int)Consts.ChunkSize)
{
// 若超过了List上限,则先扩容再插入
components.AddRange(
Enumerable.Repeat<C?[]>(
(C?[])Enumerable.Repeat<C?>(default, (int)Consts.ChunkSize),
Id / (int)Consts.ChunkSize - components.Count)
);
}
// 若有空闲对象则挑出来最后一个,没有就新建
components[Id / (int)Consts.ChunkSize][Id % (int)Consts.ChunkSize] = freeComponents.Count > 0 ? freeComponents[^1] : new();
freeComponents.RemoveAt(freeComponents.Count - 1);
return true;
}
/// <summary>
/// 移除Id处的component,并存入freeComponents中
/// </summary>
/// <param name="Id">要移除组件的Id</param>
/// <returns>若移除成功则true,否则false</returns>
public override bool Remove(int Id)
{
if (Id >= components.Count * (int)Consts.ChunkSize || components[Id] is null)
{
// 若此处没有对象,则不能移除
return false;
}
else
{
freeComponents.Add(components[Id / (int)Consts.ChunkSize][Id % (int)Consts.ChunkSize]);
components[Id / (int)Consts.ChunkSize][Id % (int)Consts.ChunkSize] = default;
return true;
}
}
/// <summary>
/// 获取Id处的component
/// </summary>
/// <param name="Id">要获取组件的Id</param>
/// <returns>该Id处的component,若没有则返回null</returns>
public C? Get(int Id) => components[Id / (int)Consts.ChunkSize][Id % (int)Consts.ChunkSize];
/// <summary>
/// 清空所有component
/// </summary>
public override void Clear()
{
components.Clear();
freeComponents.Clear();
}
// 以上所有方法只写了一个实现,其余重载略。
}
}namespace Entity{
public class Entity(int id)
{
/// 实体Id
int Id { get; } = id;
}
}Context
有了存储Components的容器Pool,接下来我们需要Context来更加方便地管理Entity与Component之间的关系——毕竟没有谁喜欢拿着完全没有封装的底层自嗨。
其需要实现Entity的创建与复用、Component数组内部的获取等等,也就是统领全局的“领导”角色。
Context的职责有二:
- Entity的创建与复用(复用部分PR3再说)
- Pools的管理
- Components的类型统一签名
提示
Component的签名是什么鬼?
简而言之,当你编写了一个HealthComponent时,你势必不想在其中添加诸如构造函数之类的繁琐之事,那么我们该如何让编写者既能省去编写类似方法的麻烦,又能实现对应方法呢?
答案就是CodeAnalysis,换句话说,就是“代码生成器”,当生成器检测到我们编写了HealthComponent时,就能自动生成一个Health类,其中就包含了为我们写好的HealthComponent的生成器等等,这样,我们既能享受到方便快捷,简洁明了的编写体验,又能方便地进行Component构造,优化代码风格,一举两得(o゜▽゜)o☆
总而言之,Context的职责总结成图表就是这样的:
同样,先来梳理一下实现思路:
- Pool管理:将每个Component对应的Pool储存在
List\<Pool>中,并实现获取Component的接口,实现类型安全 - Component签名:使用C#的
CodeAnalysis实现自动生成对应类型及枚举名,方便类型比较
暂时鸽了,有空再写,先去学UnoCSS了=w=
接下来?
下一章TH2: Entity放在哪——SparseSet 稀疏集 其实PR1的内容在这章已经展示的差不多了,PR1需要做的主要就是汇总并完善一下各种示例(
ECS-TH1初识,结束~ο(=•ω<=)ρ⌒☆

