使用Rust进行游戏开发的正确姿势

使用Rust进行游戏开发的正确姿势

该文来自于RustConf 2018闭幕演讲文稿,但并非是对文章的全文翻译,只是我在阅读过程中的梳理和总结。

该演讲嘉宾是Starbound的首席程序员,Chucklefish的技术主管,专注游戏开发。

他认为Rust非常适合做面向数据(Data-Oriented)的设计,因此非常适合做游戏开发,而且不仅仅是游戏开发。在可以预计的未来,他将继续使用Rust进行游戏开发。

本文介绍了游戏开发中使用Rust进行OO设计的各种弊端,并且通过示例逐渐给出了ECS架构的思想。

文章很长,耐心阅读。

前置知识

ECS,即 Entity-Component-System(实体-组件-系统) 的缩写,其模式遵循组合优于继承原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent组件实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。

实体组件是一个一对多的关系,实体拥有怎样的能力,完全是取决于其拥有哪些组件,通过动态添加或删除组件,可以在(游戏)运行时改变实体的行为。

ECS架构图

以上来源: 游戏开发中的ECS 架构概述


一、如何从零开始使用Rust构建游戏?

简单来说:「并不难,面向数据设计,可以使用ECS」。忘记OO设计吧,面向数据,一切可能会更简单。

怎么做一个游戏呢?

作者给出了一些建议:

  • 不要制作自己的引擎
  • 总是做一个原型,并准备好随时扔掉它
  • 你的第一个游戏应该很简单,只做一些简单的事,然后短时间内发布
  • 现在很多游戏引擎不支持Rust,但作者确信这只是一个时间问题,而且将会有一个Unity或UE竞争对手迟早会使用Rust

二、过去如何制作游戏?

在过去,游戏主要是以数据导向的方式被设计出来。空间太小,不能有太多抽象。如果用Rust给NES写一个游戏,那比C难多了(不是不可能)。SNES时代的游戏基本都用汇编编写。在这个时代,内存中每个位置都很精贵。不存在太多的隐藏数据,游戏的结构或多或少都是一个巨型的静态全局结构来包含游戏中的所有状态。偶尔会有函数制作,但不可能有vtable。

你可以用Rust按这种古老的游戏架构来实现一个游戏。然而整个系统到处充斥着可见的可变。你可以用100%的safe rust来编写代码,但是无法安全地使用指针。对于经典的Mario 64(作者举的一个例子,古老时代的一个流行的3D游戏,该游戏使用了entities形式)需要使用指针来代替将索引存储到数组中。作者不推荐使用索引代替指针这种方式的游戏架构。请使用ECS架构进行数据驱动的开发吧。

三、过多的面向对象

游戏在表面上看似非常适合OO设计,但实际上,使用Rust很难对一些游戏中的「对象」进行OO表达。可以通过struct来进行OO式封装,但是会看到很多重复的结构。

作者使用C++代码演示了使用OO设计一个小型的游戏原型,来说明OO设计和Rust借用检查产生的问题。最终结论就是使用Rust进行游戏开发,OO设计根本没有帮助,应该使用ECS架构。

在游戏中思考对象和数据类型,实际上是有害的。因为大多数的行为其实都没有附加到任何数据之上。

四、ECS介绍

type usize = EntityIndex;

struct Physics {
    position: Vector2<f32>,
    velocity: Vector2<f32>,
    mass: f32,
}

struct HumanoidAnimationState { ... }
struct HumanoidItem { ... }

struct HumanoidState {
    animation_state: HumanoidAnimationState,
    left_hand_item: HumanoidItem,
    right_hand_item: HumanoidItem,
    aim_position: Vector2<f32>,
}

struct Player {
    physics: Physics,
    humanoid: HumanoidState,

    health: f32,
    focused_entity: EntityIndex,
    food_level: f32,
    admin: bool,

    ...
}

enum MonsterAnimationState { ... }
struct DamageRegion { ... }

struct Monster {
    physics: Physics,
    animation_state: MonsterAnimationState,

    health: f32,
    current_target: EntityIndex,
    damage_region: DamageRegion,

    ...
}

struct NpcBehavior { ... }

struct Npc {
    physics: Physics,
    humanoid: HumanoidState,

    health: f32,
    behavior: NpcBehavior,

    ...
}

enum Entity {
    Player(Player),
    Monster(Monster),
    Npc(Npc),
}

struct Assets { ... }

struct GameState {
    assets: Assets,

    entities: Vec<Option<Entity>>,
    players: Vec<EntityIndex>,

    ...
}

fn main() {
    let mut game_state = initial_game_state();

    loop {
        let input_state = capture_input_state();

        player_control_system(&mut game_state, &input_state);
        npc_behavior_system(&mut game_state);
        monster_behavior_system(&mut game_state);

        physics_system(&mut game_state);

        // ... lots more systems

        render_system(&mut game);
        audio_system(&mut game);

        wait_vsync();
    }
}

上面代码中,比较重要的结构有GameState和Entity。从代码中看得出来,GameState是对「游戏世界」中资源、角色和其他实体的抽象。相比较OO设计,面向数据设计更像是把OO设计的抽象给「拍扁」了(个人观点),利用trait更适合数据的组合。

把「游戏世界」中各种角色(玩家、怪物、NPC)看作是实体。 GameState中entities为Vec<Option<Entity>>类型,这表示将实体至于数组中进行管理,与玩家players相对应Vec<EntityIndex>,每个实体Enity对应一个EntityIndex索引,为了避免数据混乱,使用Option<Entity>,因为玩家在游戏世界并非经常碰到实体中的角色,所以需要None来填充。

上面的代码也把物理系统physics_system和其他各种系统分离了出来,比如player_control_system、npc_behavior_system、monster_behavior_system等。

这样,只需要在loop循环中遍历各个实体,更改physics_system系统中相关的参数即可进行修改。但是目前仍然有问题,比如实体中三种角色都依赖了physics_system,这样在添加新的实体角色时,很多系统都可能做出相应的改变,耦合性高。

type usize = EntityIndex;

// All the different types of fields that an Entity can have, grouped somewhat
// logically...

struct Physics {
    position: Vector2<f32>,
    velocity: Vector2<f32>,
    mass: f32,
}

struct HumanoidAnimationState { ... }

struct HumanoidItem { ... }

enum MonsterAnimationState { ... }

struct DamageRegion { ... }

struct NpcBehavior { ... }

struct HumanoidState {
    animation_state: HumanoidAnimationState,
    left_hand_item: HumanoidItem,
    right_hand_item: HumanoidItem,
    aim_position: Vector2<f32>,
}

struct PlayerState {
    focused_entity: EntityIndex,
    food_level: f32,
    admin: bool,
}

struct MonsterState {
    current_target: EntityIndex,
    animation_state: MonsterAnimationState,
}

struct NpcState {
    behavior: NpcBehavior,
}

// An entity is a collection of all the possible entity fields, we let every one
// of them be optional.  In this case, we have lost some type safety, because
// this can express more invalid states than our previous example, some of these
// combinations probably don't make sense.  Also, maybe right now it doesn't
// make sense for an entity to be missing a position, so all entities have to
// have physics even though it's optional here.
struct Entity {
    physics: Option<Physics>,
    health: Option<f32>,
    humanoid: Option<HumanoidState>,
    player: Option<PlayerState>,
    monster: Option<MonsterState>,
    npc: Option<NpcState>,

    ...
}

struct Assets { ... }

struct GameState {
    assets: Assets,

    entities: Vec<Option<Entity>>,
    players: Vec<EntityIndex>,

    ...
}

fn main() {
    let mut game_state = initial_game_state();

    loop {
        let input_state = capture_input_state();

        player_control_system(&mut game_state, &input_state);
        npc_behavior_system(&mut game_state);
        monster_behavior_system(&mut game_state);

        physics_system(&mut game_state);

        // ... lots more systems

        render_system(&mut game);
        audio_system(&mut game);

        wait_vsync();
    }
}

该代码是重构后的版本,将之前的Entity枚举改成了现在的Entity结构体。这其实是一种冗余设计,将physics、health等字段引入实体,降低了耦合。顺着这个思路,可以继续重构。

// ...

struct Health(f32);

struct Hunger {
    food_level: f32,
}

struct PlayerState {
    focused_entity: EntityIndex,
    admin: bool,
}

struct Entity {
    physics: Option<Physics>,
    huamnoid_animation: Option<HumanoidAnimation>,
    humanoid_items: Option<HumanoidItems>,
    monster_animation: Option<MonsterAnimation>,
    npc_behavior: Option<NpcBehavior>,
    health: Option<Health>,
    hunger: Option<Hunger>,
    player: Option<PlayerState>,

    ...
}

比如引入饥饿状态。这样可以表达饥饿的玩家,也可以表达饥饿的NPC。甚至可以让怪物来作一些人形的动画。感觉有点像数据库设计,每一行数据都代表一个实体。

所以,实体可以由这样一组或者多组数据组成,这其实和组件的概念类似。

接下来再做一次改变:

struct PhysicsComponent { ... }
struct HumanoidAnimationComponent { ... }
struct HumanoidItemsComponent { ... }
struct MonsterAnimationComponent { ... }
struct NpcBehaviorComponent { ... }
struct AggressionComponent { ... }
struct HealthComponent { ... }
struct HungerComponent { ... }
struct PlayerComponent { ... }

type EntityIndex = usize;
struct Assets { ... }

struct GameState {
    assets: Assets,

    // All of these component vecs must be the same length, which is the current
    // number of entities.
    physics_components: Vec<Option<PhysicsComponent>>,
    humanoid_animation_components: Vec<Option<HumanoidAnimationComponent>>,
    humanoid_items_components: Vec<Option<HumanoidItemsComponent>>,
    monster_animation_components: Vec<Option<MonsterAnimationComponent>>,
    npc_behavior_components: Vec<Option<NpcBehaviorComponent>>,
    aggression_components: Vec<Option<AggressionComponent>>,
    health_components: Vec<Option<HealthComponent>>,
    hunger_components: Vec<Option<HungerComponents>>,
    player_components: Vec<Option<PlayerComponents>>,

    players: Vec<EntityIndex>,

    ...
}

这是从结构数组数组结构的转换。转换为数组结构通常是ECS引入真正关注的事情。

对于现在使用到的EntityIndex,需要解决和删除相关的问题。从Vec中删除某个实体,很简单,但是下一个分配的实体会使用相同的索引。这就好像在使用像wasm那样的线性内存一样。如果在删除实体之前,不存在未完成的“索引引用”,这很好,但是如果存在这样的引用怎么办?这和内存不安全的错误很相似,可能引用一个不合法的实体。

那么该如何管理这些实体?那么就得用分代索引了。

五、分代索引

我现在知道为什么昨天stevel写了一个indexlist了,使用了分代索引模式基于Vector来建立链表,确保引用的正确性。原来是受这个启发。

分代索引模式,可能不被大众所知。

// You can use other types that usize / u64 if these are too large
#[derive(Eq, PartialEq, etc...)]
pub struct GenerationalIndex {
    index: usize,
    generation: u64,
}

impl GenerationalIndex {
    pub index(&self) -> usize { ... }
}

struct AllocatorEntry {
    is_live: bool,
    generation: u64,
}

pub struct GenerationalIndexAllocator {
    entries: Vec<AllocatorEntry>,
    free: Vec<usize>,
}

impl GenerationalIndexAllocator {
    pub fn allocate(&mut self) -> GenerationalIndex { ... }

    // Returns true if the index was allocated before and is now deallocated
    pub fn deallocate(&mut self, index: GenerationalIndex) -> bool { ... }

    pub fn is_live(&self, index: GenerationalIndex) -> bool { ... }
}

这种分代索引跟GC算法中的分代垃圾回收非常神似。在GC算法中,为对象标记了新生代和老年代。

它的工作原理是这样,分配一个索引并获得一个具有真实索引0的GenerationalIndex,也就是设置该结构体字段generation为0。如果删除该索引,它将进入一个free的索引池(GenerationalIndexAllocator)。所以,下次分配一个索引可能用到另一个具有真实索引(index)为0的GenerationalIndex。但至关重要的是,这一代现在将是1。GenerationalIndex永远不会被重复使用,因为generation总是递增,但“真实索引(index)”不会。这样,你可以使用快速索引到Vec而不必担心上面说的那种非法引用的情况。

作者还推荐了一个crate,叫slotmap,使用的就是这种分代索引。分代索引模式也可以解决自我借用(self borrowing)和循环引用的问题。

那么使用分代索引来重构上面的ECS代码:

struct ArrayEntry<T> {
    value: T,
    generation: u64,
}

// An associative array from GenerationalIndex to some Value T.
pub struct GenerationalIndexArray<T>(Vec<Option<ArrayEntry<T>>>);

impl<T> GenerationalIndexArray<T> {
    // Set the value for some generational index.  May overwrite past generation
    // values.
    pub fn set(&mut self, index: GenerationalIndex, value: T) { ... }

    // Gets the value for some generational index, the generation must match.
    pub fn get(&self, index: GenerationalIndex) -> Option<&T> { ... }
    pub fn get_mut(&mut self, index: GenerationalIndex) -> Option<&mut T> { ... }
}

struct PhysicsComponent { ... }
struct HumanoidAnimationComponent { ... }
struct HumanoidItemsComponent { ... }
struct MonsterAnimationComponent { ... }
struct NpcBehaviorComponent { ... }
struct AggressionComponent { ... }
struct HealthComponent { ... }
struct HungerComponent { ... }
struct PlayerComponent { ... }

// We're dropping the index or id suffix, because there is no other "Entity"
// type to get confused with.  Don't forget though, this doesn't "contain"
// anything, it's just a sort of index or id or handle or whatever you want to
// call it.
type Entity = GenerationalIndex;

// Map of Entity to some type T
type EntityMap<T> = GenerationalIndexArray<T>;

struct GameState {
    assets: Assets,

    entity_allocoator: GenerationalIndexAllocator,

    physics_components: EntityMap<PhysicsComponent>,
    humanoid_animation_components: EntityMap<HumanoidAnimationComponent>,
    humanoid_items_components: EntityMap<HumanoidItemsComponent>,
    monster_animation_components: EntityMap<MonsterAnimationComponent>,
    npc_behavior_components: EntityMap<NpcBehaviorComponent>,
    aggression_components: EntityMap<AggressionComponent>,
    health_components: EntityMap<HealthComponent>,
    hunger_components: EntityMap<HungerComponents>,
    player_components: EntityMap<PlayerComponents>,

    players: Vec<Entity>,

    ...
}

这就得到了一个完整的ECS架构的代码。

六、动态类型

现在仍然没有解决的问题是,现在改变GameState里内部的任何内容在理论上都可能影响到每个系统。那么是否可以通过动态类型来改变它呢?
当然这是可选操作。但是为了理解ECS这是必须的。为此,现在需要一个AnyMap之类的东西。有一个叫mopa的crate做的很好。

pub struct AnyMap { ... }

impl AnyMap {
    pub fn insert<T>(&mut self, t: T) { ... }
    pub fn get<T>(&mut self) -> Option<&T> { ... }
    pub fn get_mut<T>(&mut self) -> Option<&mut T> { ... }
}

需要一个容器,AnyMap,可以容纳任意类型。那么如何用它存储组件?类似于这样:

struct PhysicsComponent { ... }
struct HumanoidAnimationComponent { ... }
struct HumanoidItemsComponent { ... }
struct MonsterAnimationComponent { ... }
struct NpcBehaviorComponent { ... }
struct AggressionComponent { ... }
struct HealthComponent { ... }
struct HungerComponent { ... }
struct PlayerComponent { ... }

type Entity = GenerationalIndex;
type EntityMap<T> = GenerationalIndexArray<T>;

struct GameState {
    assets: Assets,

    entity_allocoator: GenerationalIndexAllocator,
    // We're assuming that this will contain only types of the pattern
    // `EntityMap<T>`.  This is dynamic, so the type system stops being helpful
    // here, you could use `mopa` crate to make this somewhat better.
    entity_components: AnyMap,

    players: Vec<Entity>,

    ...
}

现在,GameState将不再存储具体的实体类型,而是动态的实体集合,称之为资源。因此,将GameState的名字改一下:

type Entity = GenerationalIndex;
type EntityMap<T> = GenerationalIndexArray<T>;

struct ECS {
    entity_allocoator: GenerationalIndexAllocator,
    // Full of types like `EntityMap<T>`.
    entity_components: AnyMap,

    resources: AnyMap,
}

改成了ECS。于是得到了一个常见的ECS数据结构。使用动态类型,可以添加新组件而不会“干扰”其他系统,因为无需导入新模块。对于资源也是如此,可以向模型添加新数据类型,而不会“干扰”现有系统。

七、注册表(registry)模式

注册表模式是作者自定义的一种模式。在使用上面ECS这种数据结构的时候,首先需要将用到的组件类型注册到AnyMap或其他相似结构中,使用未注册的组件类型会报错。但是并不能直接将「注册」绑定到ECS上,而是制作注册表。

pub struct ComponentRegistry { ... }

impl ComponentRegistry {
    // Registers a component, components must implement a special trait to allow
    // e.g. loading from a JSON config.
    pub fn register_component<T: Component>(&mut self) { ... }

    // Sets up entries for all registered components to the given ECS
    pub fn setup_ecs(&self, ecs: &mut ECS) { ... }

    // Loads a given entity into the given ECS, loading all the components from
    // the given config
    pub fn load_entity(&self, config: Json, ecs: &mut ECS) -> Entity { ... }
}

pub struct ResourceRegistry { ... }

impl ResourceRegistry {
    // The Resource trait provides loading from JSON and other things.
    pub fn register_resource<T: Resource>(&mut self) { ... }

    // Sets up entries for all registered resources to the given ECS
    pub fn setup_ecs(&self, ecs: &mut ECS) { ... }

    // Adds a resource to the given ECS by loading from the given config.
    pub fn load_resource(&self, config: Json, ecs: &mut ECS) { ... }
}

// When we add a component to our project, there are two steps.  First, add the
// component somewhere as a Rust module, THEN add it to this list here.  For
// added convenience, this function could go in the lib.rs which contains the
// component modules themselves.  If you were very fancy, you could have some
// kind of "plugin architecture" for this as well, grouping related components /
// resources together into "plugins".
fn load_component_registry() -> ComponentRegistry {
    let mut component_registry = ComponentRegistry::new();

    component_registry.register::<PhysicsComponent>();
    component_registry.register::<PlayerComponent>();
    ...
}

// Ditto
fn load_resource_registry() -> ResourceRegistry { ... }

pub struct Registry {
    pub components: ComponentRegistry,
    pub resources: ResourceRegistry,
}

lazy_static! {
    pub static ref REGISTRY: Registry = Registry {
        components: load_component_registry(),
        resources: load_resource_registry(),
    };
}

这种模式相当有用,而且还可以支持从配置文件中添加新的组件。到目前为止,已经勾勒出了一个「真正的ECS游戏引擎」的草图。但是本文几乎没有谈到过函数或系统,这和文章的主题有关系。当然,可以通过添加一个SystemRegistry来配合它,为主循环添加函数。

作者在最后谈到了一点,「ECS是游戏的SQL」。这跟我之前的感觉是一致的。ECS定义了一种SQL,定义了数据的架构,加载它,在其上运行查询并更新它。其中每个查询可能必须在不超过几微秒的时间内运行。

八、总结

ECS架构是面向数据设计的一种结果。对于Rust来说相当适合,尤其是使用Rust进行游戏开发。但不应该只限于游戏开发。总之,Rust很棒。

编辑于 2018-09-16

文章被以下专栏收录