游戏开发CES模式浅析
CES设计模式从提出至今已经在诸如《守望先锋》等很多大型游戏中得到了有效的应用。在应对游戏迅速递增的复杂需求和设计上有着相对而言较为灵活的优势。
在这里写这篇文章只是做个总结和浅显的论述,有不对的请拍砖。另外,谈CES模式并无抛弃OOP思想的意思,只是说明CES在动态构造游戏对象,以及迭代时应对后续需求上的优良表现。诸多优秀的引擎,底层一样是基于OOP思想构建的。
CES就是component-entity-system三个单词的缩写,顾名思义,游戏世界中所有的对象都是由组件-实体-系统三者构成。其中,实体指向组件(为什么用指向这两个字,稍后会说明),组件只包含纯粹数据(例如伤害、坐标、速度等等信息),通过系统根据每个实体指向的组件数据,进行状态更新。
和传统的OOP思想不同,OOP其中的组合思想是“我有什么”,而CES的思想是基于“有什么是我的”。举个例子来说明的话,你戴着眼镜,那么在OOP面对对象的思想看来,这副眼镜成为你的属性,也就是“你有这副眼镜”,而从CES模式的角度来看,眼镜本身就是存在的,你戴着只是表示你给这副眼镜贴了标签,即便你不戴上,它依旧是存在的。理解这点是非常重要的。
为什么说传统的OOP在应对复杂日益复杂的需求时,会使后期代码结构和工程可能变的很“麻烦”呢?
参照下图:
传统的继承模式在游戏中的常规使用方式(图片来源GameDev)
游戏中有很多对象实体,通过简单的多态集成的方式,很容易造成类出现爆炸的情况。随着新涌现的实体和新的属性,类似上图,一颗静态的敌人(EvilTree)很难通过原有的关系继承出来。而且一旦基类产生变动,一个细小的疏忽,也可能带来难以估量的影响。
为了有效的解决这个问题,开发人员想到,通过组合的方式而不是集成的方式来解决问题。如果有接触过设计模式的同学,肯定对组合有印象。直接能联想到的是如下这种代码方式和结构:
// 坐标
struct Position {
float x_, y_, z_;
};
// 速度
struct Velocity {
float speed_;
};
// 角色
class Role {
Position position_;
Velocity velocity_;
};
// 树
class Tree {
Position position_;
};
通过这种组合的方式(角色包含了坐标和速度、树无法移动,只包含坐标)。但实际上这并没有脱离OOP的思想,只是把Is-A换成了Has-A,从继承变成了组合。即便按照这种方式,有时候也难以取得令人满意的效果,比如,希望游戏中“有一棵可以移动的树”这种需求,那么对原有的代码结构又要产生变动,而且后续的需求变化又会出现上述的爆炸式增长的类。这是非常重要的一点,也是容易混淆和无法区别的一点。在此特地阐述。那么CES到底是怎样来实现教轻松应对这种变化的需求呢?
CES的解决之道,先看下图:
CES的组合模式(图片来源GameDev)
很多童鞋看了这张图,可能会说,和Has-A没什么不一样啊。乍一看确实一样的,不过CES和OOP中Has-A最大的不同在于,你在上图所看到的Position,AI,Sprite等等数据,是独立于Tree,Enemy,Player等对象存在的,而这些对象只是保留了对例如Position,AI等数据的索引。这么做有什么好处呢?比如说上面的“一颗移动的树”这种需求,我们无需新增任何的对象,只需要新建一个Entity并赋予它指向Velocity这个数据即可!参照下面的简易代码:
class EntityManager; //实体管理器,负责分配实体ID,为指定ID分配组件
// 组件基类
class BaseComponent {
protected:
static std::size_t family_counter_;
};
// 所有的组件都从这个类继承
template <typename Derived>
class Component : public BaseComponent {
private:
friend class EntityManager;
public:
// 返回该类型组件的ID(用于索引,非常重要)
static std::size_t Family(void) {
static std::size_t family_ = family_counter_++;
return family_;
}
};
// 实体类
class Entity {
private:
friend class EntityManager;
EntityManager *manager_; // 实体管理器对象
std::size_t id_; // 实体ID(用于索引,非常重要)
public:
Entity(EntityManager *manager, std::size_t id) : manager_(manager), id_(id) {}
inline std::size_t id(void) const { return id_; }
};
// 实体对象管理器,用于持有所有的实体ID以及组件数据,以及它们之间的关联
class EntityManager {
public:
// 组件标记类型,对应于每个实体,用于快速查找ID为x的Entity是否包含ID为y的组件
typedef std::bitset<1024> ComponentMask;
// 针对某个组件ID为x的组件对象列表,用于快速获取组件
typedef std::vector<BaseComponent> ComponentPool;
// 创建实体,如果存在闲置的位置就用该位置创建,否则ID递增
Entity Create(void) {
if (free_list_.empty()) {
std::size_t id = free_list_.back();
free_list_.pop_back();
return Entity(this, id);
}
else {
std::size_t id = index_counter_++;
return Entity(this, id);
}
}
// 根据ID销毁一个实体以及它所关联的组件(这里并不是真正的销毁,而是回收以及重置标记)
Entity Destroy(std::size_t id) {
for () {
Remove<X>(id)
}
free_list_.push_back(id);
entity_component_mask_.reset(id);
}
// 为指定id的Entity分配C类型的组件(只做标记,以及用参数设置组件数据,以下相同)
template <typename C, typename ...Args>
Component<C> Assign(std::size_t entity_id, Args &&...args) {
entity_component_mask_[entity_id].set(Component<C>::Family());
Component<C> &c = component_pools_[Component<C>::Family()][entity_id];
c.set(std::forward<Args>(args)...);
}
// 移除指定ID的Entity的某个C类型的组件属性
template <typename C>
void Remove(std::size_t entity_id) {
entity_component_mask_[entity_id].reset(Component<C>::Family());
}
// 查询指定ID的Entity对象是否存在C属性
template <typename C>
void HasComponent(std::size_t entity_id) {
return entity_component_mask_[entity_id].test(Component<C>::Family());
}
private:
// 实体Entity的ID记录
std::size_t index_counter_;
// 闲置实体ID槽位
std::vector<std::size_t> free_list_;
// 每个单元对应一个ID的Entity对象,用于标记该ID的对象是否拥有某个组件
std::vector<ComponentMask> entity_component_mask_;
// 每个单元对应一类ID的组件,每个单元列表一一对应所有的Entity
std::vector<ComponentPool> component_pools_;};
声明一下,上述只是写了个表达思路的伪代码,并不能真正的跑起来,为了简略起见,也没有做优化上的处理。从上面我们可以看到,EntityManager(实体组件管理器)持有所有的实体ID以及组件数据,类似每个实体对应一组所有类型的组件数据,再通过entity_component_mask_这个队列来标记,某个ID为X的Entity,是否包含某个ID为Y的Component。这样,就能很好的满足我们随意构造对象的需求了。
例如:
// 定义了3个Component对象,分别表示坐标、速度、纹理
class Position : public Component < Position > {
...
};
class Velocity : public Component < Velocity > {
...
};
class Sprite : public Component < Sprite > {
...
};
// 创建一棵树Entity对象,赋予坐标和纹理Component
Entity tree = entity_manager.Create();
entity_manager.Assign<Positon>(tree.id(), 1.0f, 1.0f, 1.0f);
entity_manager.Assign<Sprite>(tree.id(), textureA);
// 创建一个用户Entity对象,赋予坐标和纹理以及移动速度Component
Entity player = entity_manager.Create();
entity_manager.Assign<Positon>(player.id(), 2.0f, 1.0f, 1.0f);
entity_manager.Assign<Velocity>(player.id(), 2.0f);
entity_manager.Assign<Sprite>(tree.id(), textureB);
// 解决“一棵移动的树”的Entity对象,赋予坐标纹理以及速度Component
Entity enemy_tree = entity_manager.Create();
entity_manager.Assign<Positon>(player.id(), 2.0f, 1.0f, 1.0f);
entity_manager.Assign<Velocity>(player.id(), 2.0f);
entity_manager.Assign<Sprite>(tree.id(), textureC);
如上面代码所示,对于新对象的创建,我们不需要再定义新的类了!!!多么方便,而且,如果有一天,产品的需求更改,这棵“移动的树”还要在加上AI的属性的话,那么我们只需要再Assign一个AI Component就可以了,完全不需要重新定义类型(PS:当然现有的Component无法满足的情况下,比如要另外一种形式的AI Component,那么还是需要新建一个基础的AI Component的)。
好了,到目前为止,我们只差System,就差不多完成对CES模式的讲解了。那么,系统在整个模式中起什么作用呢?如前文所说,负责更新整个游戏实体Entity的状态。比如说,实体Entity如何表现移动呢?我们做一个MoveSystem,然后在游戏的每一帧获取所有包含Position和Velocity的实体对象(Entity),然后按照计算规则重新计算Position,不就更新了实体Entity的坐标了么?那么,比如说一个对象的停止呢,比如player对象?很简单,删除Velocity即可,那么下一次MoveSystem获取包含Position && Velocity的实体对象的时候,将获取不到这个已经不包含Velocity的player对象了。那么,当然也不会再去计算更新player的坐标了。
废话不多,上伪代码:
// 系统管理器
class SystemManager;
// 系统基础类
class BaseSystem {
public:
static std::size_t family_counter_;
virtual void Update(float elapsed_time) = 0;
};
// 所有的子系统都继承自该类
template <typename Derived>
class System : public BaseSystem {
public:
private:
friend class SystemManager;
// 获取系统ID
static Family family(void) {
static Family family_ = family_counter_++;
return family_;
}
};
// 系统管理器
class SystemManager {
private:
// 用于存储所有的系统,通过系统ID索引
std::unordered_map<std::size_t, std::shared_ptr<BaseSystem>> systems_;
public:
// 添加一个类型为S的系统
template <typename S>
void AddSystem(std::shared_ptr<S> system) {
systems_.insert(std::make_pair(System<S>::family(), system));
}
// 添加一个类型为S的系统
template <typename S, typename ...Args>
std::shared_ptr<S> AddSystem(Args &&...args) {
std::shared_ptr<S> s = std::make_shared<S>(std::forward<Args>(args)...);
AddSystem(s);
return s;
}
// 获取一个类型为S的系统
template <typename S>
std::shared_ptr<S> GetSystem(void) {
std::unordered_map<std::size_t, std::shared_ptr<BaseSystem>>::iterator it = systems_.find(System<S>::family());
assert(it != systems_.end());
return it == systems_.end() ? std::shared_ptr<S>() : std::shared_ptr<S>(std::static_pointer_cast<S>(it->second));
}
// 更新一个类型为S的系统
template <typename S>
void Update(float elapsed_time) {
std::shared_ptr<S> s = GetSystem<S>();
s->Update(elapsed_time);
}
// 更新所有的系统
void UpdateAll(float elapsed_time) {
for (std::pair<std::size_t, std::shared_ptr<BaseSystem>> system : systems_) {
system.second->Update(elapsed_time);
}
}
};
上面的代码很简单,就不过多解释了,重点说明一下,派生的子系统以及如何使用吧。
上代码:
// 移动系统
class MoveSystem : public System < MoveSystem > {
public:
// 每一帧获取所有包含速度和坐标的实体,然后更新坐标数据
virtual void Update(float elapse_time) {
for (auto entity : entities) {
if (entity_manager_.HasComponent<Positon>(entity.id()) && entity_manager_.HasComponent<Velocity>(entity.id())) {
Position &positon = entity_manager_.GetComponent<Positon>(entity.id());
Velocity &velocity = entity_manager_.GetComponent<Velocity>(entity.id());
postion.x += velocity.x;
postion.y += velocity.y;
postion.z += velocity.z;
}
}
}
};
// 渲染系统
class RenderSystem : public System < RenderSystem > {
public:
RenderSystem(RenderTarget *render_target) : render_target_(render_target) {}
// 每一帧获取包含纹理的实体,获取纹理组件进行渲染
virtual void Update(float elapse_time) {
for (auto entity : entities) {
if (entity_manager_.HasComponent<Sprite>(entity.id())) {
render_target_->Draw(entity_manager_.GetComponent<Sprite>(entity.id()));
}
}
}
};
// 添加移动系统
system_manager_.AddSystem<MoveSystem>();
// 添加渲染系统
system_manager_.AddSystem<RenderSystem>(&render_target);
// 在游戏每一帧更新
system_manager_.UpdateAll(SystemTick());
结合刚刚上面描述的实体(Entity)和组件(Component),再加上这个移动和渲染系统(System)的简单例子,相信已经对CES模式有比较清晰的认识了吧,也能够意识到它灵活的处理手段。
例如,服务器需要和客户端保持相同逻辑,但是又不需要渲染的时候,我们只需要将上面示例代码的
// 添加渲染系统
system_manager_.AddSystem<RenderSystem>(&render_target);
这一句去掉就可以了,这样就不会发生渲染调用了。是不是很方便?
题外话,虽然优化的主题不在本篇文章范围内,但还是随口提一句。CES模式的挑战在于过滤满足条件的实体以及组件的时间开销上,满足拥有多个组件的实体是比较简单的,尤其是例如碰撞检测系统,这种需要获取每个包含例如“不可穿越”属性的实体,再两两进行碰撞检测,实体组件多的时候,会产生巨大的开销。这也是应对开发的一个有意思的挑战!