Skip to content

实体(Entity):标识符的本质

当我们谈论 ECS(Entity-Component-System)时,“E”即代表 Entity(实体)。对于习惯了面向对象编程(OOP)的开发者来说,实体是最容易被误解的概念。在 OOP 的世界里,实体往往被等同于“游戏对象(GameObject)”——一个包含了各种属性和方法的庞大实例。

但在 ECS 架构下,实体绝对不是对象(Object)

在纯粹的 ECS 设计中,实体(Entity)没有任何数据(比如没有坐标、没有血量),也没有任何逻辑(不能调用 entity.DoSomething())。

实体,仅仅是一个全局唯一的无符号整数(Unsigned Integer ID)。

在 C++ 或 C# 中,它的底层定义通常就像这样简单:

// 实体只是一个类型别名或一层极薄的包装
public struct Entity {
public uint Id;
}

当你“创建”一个实体时,ECS 框架在底层并没有为你分配一块用于存储对象数据的堆内存,它仅仅是向你返回了一个当前可用的整数编码(例如 1001)。

你可以将实体看作是关系型数据库(如 MySQL)表中的主键(Primary Key)。主键本身并不存储用户的姓名或年龄,它只是一个索引,用来在不同的数据表中查询属于该用户的具体信息。

既然实体身上不挂载任何数据,那是如何表示一个“兽人”或一辆“汽车”的呢?

在 ECS 中,数据被拆分到了组件(Component)中(下一章会详细阐述)。实体的作用,就是将内存中各处离散的组件概念上“黏合”在一起。

例如,当我们说“实体 1001 是一辆汽车”时,在底层发生的实际情况是:

  • 坐标组件数组中,索引关联到 1001 的位置存有 (x, y, z)
  • 速度组件数组中,索引关联到 1001 的位置存有 (vx, vy, vz)
  • 渲染组件数组中,索引关联到 1001 的位置存有汽车轮胎的模型引用。

实体本身是一层极轻的抽象,它向外界提供了一个统一的标识,用来查询所有“附加”在它身上的数据。

由于在大型游戏或模拟中,实体的创建和销毁极为频繁(例如机枪射出的子弹)。如果 ID 一直递增,不仅可能导致整数溢出,还会导致底层数组的索引变得极其庞大和稀疏,浪费内存空间。

因此,所有成熟的 ECS 框架(包括 EnTT, Unity DOTS 的底层机制)都会复用实体 ID。当一个实体被销毁后,它的 ID 值会被回收到一个对象池中,等待下一次创建时复用。

但这带来了一个致命的风险:悬空引用(Dangling Reference)

假设逻辑 A 记录了玩家的追踪导弹锁定目标为实体 1005。在此期间,实体 1005(一架敌机)被击毁了。框架回收了 1005 这个 ID。接着,系统立刻生成了一只小鸟,并分配了刚刚空闲的 ID 1005。此时,追踪导弹依然锁闭着 1005,结果却飞向了一只无辜的小鸟。

为了解决这个问题,ECS 实体通常不仅仅包含索引值,还会包含一个代(Generation)或版本号(Version)。一个 32 位的实体 ID 经常被拆分为两部分:

  • Index(如低 20 位):用于作为底层数组查询数据的绝对索引。
  • Generation(如高 12 位):每次该 Index 被复用时,Generation 就加 1。
// 一种常见的 Entity 内部结构拆分
public struct Entity {
public uint id; // 32位无符号整数
public uint Index => id & 0x000FFFFFu; // 取低 20 位
public uint Generation => (id & 0xFFF00000u) >> 20; // 取高 12 位
}

有了代(Generation),判断一个实体是否仍然存活就变得极度安全且廉价: 当通过保存的 Entity(Index: 5, Generation: 1) 去系统查询时,系统只需对比当前框架记录中 Index 5 的最新 Generation 还是不是 1 即可。如果最新 Generation 是 2(说明被复用过),则意味着原本想要引用的实体早已销毁,当前实体已无效。

在深入理解 ECS 时,第一步就是将对“对象”的执念转化为对“数据和标签”的认知:

  • 实体不包含游戏对象的状态。
  • 实体的创建和销毁不再意味着昂贵的堆内存分配与垃圾回收(GC),仅仅是数字的递增与回收。
  • 实体的本质,是一组组件集合的寻址凭证。

理解了实体的“虚无”,我们就可以去看看真正承载“物质”的容器:组件(Component)