搬砖笔记 (1) —— ECS 架构
打工人搬砖入门第一课 —— ECS 架构。
ECS (Entity-Component-System) 即实体-组件-系统,是一种软件架构模式,遵循组合优于继承 (Composition Over Inheritance) 原则,主要用于游戏开发。在 ECS 架构中,场景内的每个对象(如敌人、子弹、车辆等)都是一个实体 (Entity),每个实体都由一个或多个包含数据(状态)的组件 (Component) 组成,架构允许灵活地定义、添加和删除实体,避免了难以理解、维护和扩展的继承层次造成的模糊性问题。
组合优于继承
要了解 ECS 架构的概念以及为什么要使用这种模式,首先要从组合优于继承原则讲起。
面向对象程序设计 (OOP) 中的组合优于继承是指类应该倾向于通过组合,即包含实现所需功能的其他类的实例,而非从基类继承来实现多态行为和代码复用。
定义表示系统所具有行为的接口 (Interface) 是实现组合优于继承的基础,接口实现多态行为,而实现指定接口的类根据需要被添加到业务领域类 (Business Domain Classes) 中。通常情况下,业务领域类都是基类,无需任何继承,系统行为的替代实现通过另一个实现所需行为的接口来完成。包含对接口引用的类可以将对该接口实现的选择推迟到运行时。
以 C++ 为例,分别采用继承和组合的方式实现一个简单的系统,可以很容易地看出使用组合而非继承带来的好处。
继承
1 | class Object { |
以上定义了系统的两个接口,此时,可定义实现对应接口的业务领域类,如:
- 类
Player
:是Visible
且Movable
,即可见且可移动 - 类
Building
:是Visible
但不是Movable
,即可见但不可移动
C++ 虽然支持多重继承,但十分复杂,且使用多重继承是一个具有很大风险的行为,可能导致钻石问题 (Diamond Problem)。
若要避免使用多重继承,则需要额外定义如 VisibleAndMovable
、VisibleButNotMovable
等这样的组合接口。但是,当接口很多时,此类组合将会导致大量重复的代码,难以维护和扩展。
组合
1 | class VisibilityDelegate { |
利用以上定义的几个接口的组合,同样可以实现之前的系统:
1 | class Object { |
优点
赋予设计更高的灵活性
通过组件组合类比寻找类的共性并建立继承关系更自然、简单
组合一个对象的能力比扩展一个对象本身更好
It is better to compose what an object can do (HAS-A) than extend what it is (IS-A)
可以适应未来需求的变化,避免继承模型在需求变化时需要对业务领域类进行彻底重组
组合关系更加灵活,可在运行时改变;继承关系是静态的,许多语言需要重新编译
缺点
- 继承模型中,派生类只需要实现(覆盖)于基类方法具有不同行为的方法;而 ECS 架构中,由单个组件提供的方法可能必须在派生类型中实现,即使只是转发方法
ECS 的基本概念
2007 年,制作 Operation Flashpoint: Dragon Rising 的团队尝试了 ECS 架构。此后,Adam Martin 写了一篇关于 ECS 设计的详细说明,包括核心术语和概念定义,特别提出了系统作为一等元素、实体作为 ID、组件作为原始数据、代码存储在系统而非组件或实体中等观点。
2015 年,苹果公司推出了用于 iOS、macOS 和 tvOS 的游戏开发框架 GameplayKit,其中包含了 ECS 架构的实现。
ECS 架构如上图所示,下面分别介绍实体、组件和系统的概念。
实体 (Entity)
实体是一个抽象的概念,用来表示通用的对象,是一系列表示该实体所具有能力的组件的集合。在代码实现中,实体通常使用一个整型 ID 来表示,该实体所具有的所有组件都会被这个 ID 标记,且在运行过程中,可以动态地为实体添加或删除组件。
样例:
Player(Sprite, Position, Velocity, Health)
Tree(Sprite, Position)
组件 (Component)
组件是对象某一个特性的原始数据(状态),拥有某个组件即表示实体具有这个特性或能力。在代码中通常使用结构体、类或关联数组实现。
样例:
Position(x, y)
Velocity(x, y)
Health(value)
系统 (System)
系统是一个连续运行的、对拥有与该系统相同组件的每个实体执行全局操作的工具,通常只有行为(方法),不包含状态(数据)。
样例:
MoveSystem(Position, Velocity)
RenderSystem(Sprite, Position)
实战 —— EntityX
创建组件
1 | struct Position { |
给实体添加组件
1 | entity.assign<Position>(1.0, 2.0); |
获取拥有特定组件的实体
函数式:
1 | entities.each<Position, Direction>([](Entity entity, Position &position, |
迭代器方式:
1 | ComponentHandle<Position> position; |
获取实体的某个组件
1 | ComponentHandle<Position> position = entity.component<Position>(); |