Rust 游戏引擎搭建 - 5. ECS 搭建(一) Resource

566 阅读3分钟

这篇文章写得很好: 漫谈Entity Component System (ECS) - 知乎 (zhihu.com)

因此就不再对 ECS 架构做更多的描述了. 快乐摸鱼摸鱼快乐!

插叙

简单过一下这篇文章依赖的内容.

线程安全类型擦除共享指针

参考 Rust 游戏引擎搭建 - 4. 类型擦除指针 - 掘金 (juejin.cn). 用读写锁在线程之间分享指针.

use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};

pub type ArcRawPtr<T> = Arc<RwLock<Box<T>>>;
pub type ReadPtr<'a, T> = RwLockReadGuard<'a, Box<T>>;
pub type WritePtr<'a, T> = RwLockWriteGuard<'a, Box<T>>;

/// Type erased async shared pointer. Recover type info available.
#[derive(Debug, Clone)]
pub struct ArcPtr {
    type_id: TypeId,
    raw_ptr: ArcRawPtr<dyn Any + Send + Sync>,
}

采用了 parking_lot 库的读写锁. 实现思路大同小异, 节约篇幅起见, 我要说出那句万恶的话了: 这部分留给读者作为练习.

当前的项目结构

目前项目主要有三个 lib crates:

  1. base 库: 这部分放置一些常用数据结构和工具函数. 比如上面提到的类型擦除指针.
  2. ecs 库: 这一篇章重点搭建的内容.
  3. render 库: 渲染库, 之后会把 wgpu 的 demo 整合到这个库里来测试一下.

Resource 需求说明

类似 bevy 引擎, resource 是在 world 层面共享数据的一种手段. 这里可以理解为某种意义上的全局变量.

功能描述

要求具有下面几个功能点:

  1. 线程安全, 引用计数.
  2. 可以通过类型名访问, 一个类型只能拥有一个对应的 resource.
  3. 具有 init 和 init_with 两种初始化方式.
    1. init 要求给出的类型具有 Default trait, 当 resource 已经存在时就不会再执行了, 是幂等的操作.
    2. init_with 不要求 Default trait, 但必须给出一个 resource 类型的实例, 同上,当 resource 已经存在时就不会再执行了.
  4. 可以 insert resource, 无论是否初始化, 都会把给出的实例插入到 world 中. 如果 world 中原本有一个实例, 则返回这个实例.
  5. 读、写 resource, 判断 resource 是否存在.

区分 init 和 init_with 方法在于给出一个幂等的初始化方法, 允许重复初始化.
init_with 这种方法由于给出 resource 后不一定能插入进去, 可能会让只读了代码片段的人感到迷惑 (比如后续读取出的值与插入时给出的值不同), 因此不是很建议.
总之 init, init_with, insert 按需取用才是好文明.

Example 测试

use super::*;

struct Integer(i32);
impl Default for Integer {
    fn default() -> Self { Self(13) }
}

#[test]
fn resource_test() {
    let world = World::new()
        .init_resource::<Integer>();

    let num = world.read_resource::<Integer>().unwrap();
    assert_eq!(num.0, 13);
    drop(num);

    let mut num = world.write_resource::<Integer>().unwrap();
    num.0 += 1;
    assert_eq!(num.0, 14);
}

实现细节

首先是 world 的定义:

pub struct World {
    resource: HashMap<TypeId, ArcPtr>,
}

ArcPtr 就是上面提到的线程安全类型擦除共享指针

不是很复杂, 就是用类型 ID 来访问擦除了类型的共享指针. 那么基于 HashMap, 有了简单的 curd:

// impl World { ...

pub fn insert_resource<T: 'static + Send + Sync>(&mut self, value: T) -> Option<ArcPtr> {
    let ptr = ArcPtr::new(value);
    self.resource.insert(TypeId::of::<T>(), ptr)
}

pub fn read_resource<T: 'static + Send + Sync>(&self) -> Option<ReadPtr<T>> {
    let ptr = self.resource.get(&TypeId::of::<T>())?;
    Some(ptr.read::<T>().unwrap())
}

/// Writing a resource doesn't need a mut reference of the world, since the data race is handled by RwLock.
pub fn write_resource<T: 'static + Send + Sync>(&self) -> Option<WritePtr<T>> {
    let ptr = self.resource.get(&TypeId::of::<T>())?;
    Some(ptr.write::<T>().unwrap())
}

pub fn have_resource<T: 'static + Send + Sync>(&self) -> bool {
    self.resource.contains_key(&TypeId::of::<T>())
}

如注释所说, 读写 resource 不需要 world 的可变引用, 因为可变性带来的数据竞争已经由内部的读写锁保证了.

但是插入新 resource 必须要持有可变引用, 一方面这是因为插入 resource 的 HashMap 没有读写锁, 另一方面也是为了避免 resource 的滥用. 出于个人品味我不太喜欢全局变量, 这里的 resource 我更希望把他作为初始化时的配置表, 因此有意识地限制其功能.

最后基于 curd, 给出初始化方法:

// impl World { ...

pub fn init_resource<T: 'static + Send + Sync + Default>(self) -> Self {
    self.init_resource_with(T::default())
}

pub fn init_resource_with<T: 'static + Send + Sync>(mut self, value: T) -> Self {
    if self.have_resource::<T>() { return self; }
    self.insert_resource(value);
    self
}

后续看情况是否需要追加从 map 中删除 resource 的方法.