这篇文章写得很好: 漫谈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:
- base 库: 这部分放置一些常用数据结构和工具函数. 比如上面提到的类型擦除指针.
- ecs 库: 这一篇章重点搭建的内容.
- render 库: 渲染库, 之后会把 wgpu 的 demo 整合到这个库里来测试一下.
Resource 需求说明
类似 bevy 引擎, resource 是在 world 层面共享数据的一种手段. 这里可以理解为某种意义上的全局变量.
功能描述
要求具有下面几个功能点:
- 线程安全, 引用计数.
- 可以通过类型名访问, 一个类型只能拥有一个对应的 resource.
- 具有 init 和 init_with 两种初始化方式.
- init 要求给出的类型具有 Default trait, 当 resource 已经存在时就不会再执行了, 是幂等的操作.
- init_with 不要求 Default trait, 但必须给出一个 resource 类型的实例, 同上,当 resource 已经存在时就不会再执行了.
- 可以 insert resource, 无论是否初始化, 都会把给出的实例插入到 world 中. 如果 world 中原本有一个实例, 则返回这个实例.
- 读、写 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 的方法.