Bevy是一个用Rust编写的游戏引擎,它以具有非常人性化的实体组件系统而闻名。
在ECS模式中,实体是由组件组成的独特事物,就像游戏世界中的对象。系统处理这些实体并控制应用程序的行为。Bevy的API之所以如此优雅,是因为用户可以在Rust中编写常规函数,而Bevy会知道如何通过其类型签名来调用它们,分派正确的数据。
关于如何使用ECS模式来构建你自己的游戏,已经有了大量的文档,比如在非官方的Bevy骗局书中。相反,在这篇文章中,我们将解释如何在Bevy本身实现ECS模式。为此,我们将从头开始建立一个小型的、类似Bevy的API,接受任意的系统函数。
这个模式非常通用,你可以把它应用于你自己的Rust项目。为了说明这一点,我们将在文章的最后一节详细介绍Axum网络框架如何将这种模式用于其路由处理方法。
如果你熟悉Rust并且对类型系统的技巧感兴趣,那么这篇文章就是为你准备的。在我们开始之前,我建议你先看看我之前关于Bevy的标签实现的文章。让我们开始吧!
Bevy的系统功能像一个面向用户的API
首先,让我们学习一下如何使用Bevy的API,这样我们就可以从它向后工作,自己重新创建它。下面的代码显示了一个小型的Bevy应用,其中有一个系统的例子:
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins) // includes rendering and keyboard input
.add_system(move_player) // this is ours
// in a real game you'd add more systems to e.g. spawn a player
.run();
}
#[derive(Component)]
struct Player;
/// Move player when user presses space
fn move_player(
// Fetches a resource registered with the `App`
keyboard: Res<Input<KeyCode>>,
// Queries the ECS for entities
mut player: Query<(&mut Transform,), With<Player>>,
) {
if !keyboard.just_pressed(KeyCode::Space) { return; }
if let Ok(player) = player.get_single_mut() {
// destructure the `(&mut Transform,)` type from above to access transform
let (mut player_position,) = player;
player_position.translation.x += 1.0;
}
}
在上面的代码中,我们可以把一个普通的Rust函数传给add_system ,而Bevy知道该如何处理它。更妙的是,我们可以用我们的函数参数来告诉Bevy我们想查询哪些组件。在我们的例子中,我们希望从每一个同时拥有自定义Player 组件的实体中获得Transform 。在幕后,Bevy甚至会根据函数签名推断出哪些系统可以并行运行。
add_system 方法
Bevy有很多的API面。毕竟,它是一个完整的游戏引擎,除了实体组件系统之外,还有调度系统、2D和3D渲染器等等。在这篇文章中,我们将忽略其中的大部分内容,而只是专注于将函数作为系统添加并调用它们。
按照Bevy的例子,我们将把我们添加系统的项目称为App ,并给它两个方法,new 和add_system:
struct App {
systems: Vec<System>,
}
impl App {
fn new() -> App {
App { systems: Vec::new() }
}
fn add_system(&mut self, system: System) {
self.systems.push(system);
}
}
struct System; // What is this?
然而,这就导致了第一个问题。什么是系统?在Bevy中,我们可以直接用一个有一些有用参数的函数来调用方法,但我们如何在自己的代码中做到这一点?
添加函数作为系统
Rust中的一个主要抽象是traits,它类似于其他语言中的接口或类型类。我们可以定义一个trait,然后为任意的类型实现它,这样trait的方法就可以在这些类型上使用。让我们创建一个System trait,允许我们运行任意的系统:
trait System {
fn run(&mut self);
}
现在,我们有了一个系统的特质,但是为了在我们的函数中实现它,我们需要使用类型系统的一些额外功能。
Rust使用特质来抽象行为,而函数实现了一些特质,例如 FnMut,自动实现。我们可以为所有满足约束条件的类型实现特质。
让我们使用下面的代码:
impl<F> System for F where F: Fn() -> () {
fn run(&mut self) {
self(); // Yup, we're calling ourselves here
}
}
如果你不习惯Rust,这段代码可能看起来相当难读。没关系,这不是你在日常Rust代码库中看到的东西。
第一行实现了所有类型的系统特性,这些类型是带有参数的函数,会返回一些东西。在接下来的一行中,run 函数接收了项目本身,并且因为那是一个函数,所以调用它。
虽然这样做是可行的,但它是相当无用的。你只能调用一个没有参数的函数。但是,在我们深入研究这个例子之前,让我们先把它修好,这样我们就能够运行它了。
插曲:运行一个例子
我们上面对App 的定义只是一个快速的草稿;为了让它使用我们新的System 特质,我们需要让它变得更复杂一些。
由于System 现在是一个特质而不是一个类型,我们不能再直接存储它。我们甚至不能知道System 是什么尺寸,因为它可能是任何东西!相反,我们需要把它放在一个指针后面,或者像Rust所说的那样,把它放在一个Box 。你不需要存储实现System 的具体东西,而只是存储一个指针。
这就是Rust类型系统的一个技巧:你可以使用特质对象来存储实现特定特质的任意项目。
首先,我们的应用程序需要存储一个盒子的列表,这些盒子包含的东西是System 。实际上,它看起来像下面的代码:
struct App {
systems: Vec<Box<dyn System>>,
}
现在,我们的add_system 方法也需要接受任何实现了System 特质的东西,将其放入该列表。现在,参数类型是通用的。我们用S 作为任何实现System 的占位符,由于Rust希望我们确保它在整个程序中都是有效的,所以还要求我们添加'static 。
既然如此,让我们添加一个方法来实际运行这个应用程序:
impl App {
fn new() -> App { // same as before
App { systems: Vec::new() }
}
fn add_system<S: System + 'static>(mut self, system: S) -> Self {
self.systems.push(Box::new(system));
self
}
fn run(&mut self) {
for system in &mut self.systems {
system.run();
}
}
}
有了这个,我们现在可以写一个小例子,如下:
fn main() {
App::new()
.add_system(example_system)
.run();
}
fn example_system() {
println!("foo");
}
你可以玩一玩到目前为止的全部代码。现在,让我们回去重新审视一下更复杂的系统函数的问题。
带参数的系统函数
让我们把下面这个函数变成一个有效的System:
fn another_example_system(q: Query<Position>) {}
// Use this to fetch entities
struct Query<T> { output: T }
// The position of an entity in 2D space
struct Position { x: f32, y: f32 }
看似简单的选择是为System ,增加一个带参数的函数的另一个实现。但是,遗憾的是,Rust编译器会告诉我们,有两个问题:
我们需要以不同的方式来处理这个问题。让我们首先为我们接受的参数引入一个特质:
trait SystemParam {}
impl<T> SystemParam for Query<T> {}
为了区分不同的System 实现,我们可以添加类型参数,成为其签名的一部分:
trait System<Params> {
fn run(&mut self);
}
impl<F> System<()> for F where F: Fn() -> () {
// ^^ this is "unit", a tuple with no items
fn run(&mut self) {
self();
}
}
impl<F, P1: SystemParam> System<(P1,)> for F where F: Fn(P1) -> () {
// ^ this comma makes this a tuple with one item
fn run(&mut self) {
eprintln!("totally calling a function here");
}
}
但是现在,问题变成了,在所有我们接受System 的地方,我们都需要添加这个类型参数。而且,更糟糕的是,当我们试图存储Box<dyn System> ,我们也必须在那里添加一个:
error[E0107]: missing generics for trait `System`
--> src/main.rs:23:26
|
23 | systems: Vec<Box<dyn System>>,
| ^^^^^^ expected 1 generic argument
…
error[E0107]: missing generics for trait `System`
--> src/main.rs:31:42
|
31 | fn add_system(mut self, system: impl System + 'static) -> Self {
| ^^^^^^ expected 1 generic argument
…
如果你让所有的实例System<()> ,并注释掉.add_system(another_example_system) ,我们的代码就可以编译了。
存储通用系统
现在,我们的挑战是要达到以下标准:
- 我们需要有一个知道其参数的泛型特质
- 我们需要将泛型系统存储在一个列表中
- 我们需要在迭代这些系统的时候能够调用它们
这是看Bevy代码的好地方。函数并不实现 [System](https://docs.rs/bevy/0.8.0/bevy/ecs/system/trait.System.html),而是 SystemParamFunction.此外。 add_system并不接受一个impl System ,而是一个impl IntoSystemDescriptor,它又使用一个 IntoSystem特质。
FunctionSystem,一个结构,将实现System 。
让我们从中得到启发,让我们的System 特质再次变得简单。我们先前的代码继续作为一个新的特质,称为SystemParamFunction 。我们还将引入一个IntoSystem 特质,我们的add_system 函数将接受它:
trait IntoSystem<Params> {
type Output: System;
fn into_system(self) -> Self::Output;
}
我们用一个关联类型来定义这个转换将输出什么样的System 类型。
这个转换特质仍然输出一个具体的系统,但那是什么呢?神奇的地方来了。我们添加一个FunctionSystem 结构,它将实现System ,我们将添加一个IntoSystem 的实现来创建它:
/// A wrapper around functions that are systems
struct FunctionSystem<F, Params: SystemParam> {
/// The system function
system: F,
// TODO: Do stuff with params
params: core::marker::PhantomData<Params>,
}
/// Convert any function with only system params into a system
impl<F, Params: SystemParam + 'static> IntoSystem<Params> for F
where
F: SystemParamFunction<Params> + 'static,
{
type System = FunctionSystem<F, Params>;
fn into_system(self) -> Self::System {
FunctionSystem {
system: self,
params: PhantomData,
}
}
}
/// Function with only system params
trait SystemParamFunction<Params: SystemParam>: 'static {
fn run(&mut self);
}
SystemParamFunction 是我们在上一章中称为 的通用特质。正如你所看到的,我们还没有对函数参数做任何处理。我们只是把它们保留下来,这样所有的东西都是通用的,然后把它们存储在System PhantomData类型中。
为了实现IntoSystem 中的约束,即它的输出必须是一个System ,我们现在在我们的新类型上实现特质:
/// Make our function wrapper be a System
impl<F, Params: SystemParam> System for FunctionSystem<F, Params>
where
F: SystemParamFunction<Params> + 'static,
{
fn run(&mut self) {
SystemParamFunction::run(&mut self.system);
}
}
这样一来,我们就差不多准备好了!让我们更新一下我们的add_system 函数,然后我们就可以看到这一切是如何工作的:
impl App {
fn add_system<F: IntoSystem<Params>, Params: SystemParam>(mut self, function: F) -> Self {
self.systems.push(Box::new(function.into_system()));
self
}
}
我们的函数现在接受一切实现了IntoSystem ,其类型参数为SystemParam 。
为了接受有一个以上参数的系统,我们可以在作为系统参数本身的项目的图元上实现SystemParam:
impl SystemParam for () {} // sure, a tuple with no elements counts
impl<T1: SystemParam> SystemParam for (T1,) {} // remember the comma!
impl<T1: SystemParam, T2: SystemParam> SystemParam for (T1, T2) {} // A real two-ple
但是,我们现在要存储什么呢?实际上,我们要做的是和前面一样的事情:
struct App {
systems: Vec<Box<dyn System>>,
}
让我们来探讨一下为什么我们的代码可以工作。
封闭我们的泛型
诀窍在于,我们现在将一个通用的FunctionSystem 作为一个特质对象来存储,这意味着我们的Box<dyn System> 是一个胖指针。它既指向内存中的FunctionSystem ,也指向该类型实例中与System 特质有关的一切查询表。
当使用泛型函数和数据类型时,编译器将对它们进行单态化处理,为实际使用的类型生成代码。因此,如果你在三个不同的具体类型中使用同一个泛型函数,它将被编译三次。
现在,我们已经满足了所有三个标准。我们已经为泛型函数实现了我们的特质,我们存储了一个泛型System 盒子,并且我们仍然对它调用了run 。
获取参数
遗憾的是,我们的代码还不能工作。我们没有办法获取参数并使用它们来调用系统函数。但这并不重要。在run 的实现中,我们可以直接打印一行,而不是调用函数。这样,我们就可以证明它编译和运行了什么。
其结果看起来有点像下面的代码:
fn main() {
App::new()
.add_system(example_system)
.add_system(another_example_system)
.add_system(complex_example_system)
.run();
}
fn example_system() {
println!("foo");
}
fn another_example_system(_q: Query<&Position>) {
println!("bar");
}
fn complex_example_system(_q: Query<&Position>, _r: ()) {
println!("baz");
}
Compiling playground v0.0.1 (/playground)
Finished dev [unoptimized + debuginfo] target(s) in 0.64s
Running `target/debug/playground`
foo
TODO: fetching params
TODO: fetching params
你可以在这里找到本教程的完整代码。按下播放键,你会看到上面的输出和更多。请自由发挥,尝试一些系统的组合,也许还可以添加一些其他的东西。
同样的模式,不同的框架。Axum中的提取器
我们现在已经看到Bevy如何接受相当广泛的函数作为系统。但正如介绍中所提到的,其他库和框架也使用这种模式。
一个例子是Axum网络框架,它允许你为特定的路由定义处理函数。下面的代码显示了他们文档中的一个例子:
async fn create_user(Json(payload): Json<CreateUser>) { todo!() }
let app = Router::new().route("/users", post(create_user));
有一个post 函数,它接受函数,甚至是async ,其中所有的参数都是提取器,比如这里的Json 类型。正如你所看到的,这比我们到目前为止看到的Bevy所做的要棘手一些。Axum必须考虑到返回类型和如何转换,以及支持异步函数,即那些返回期货的函数。
然而,一般的原则是相同的。Handler 特质是为函数实现的:
- 谁的参数实现了
[FromRequest](https://docs.rs/axum/0.5.13/axum/extract/trait.FromRequest.html) - 谁的返回类型实现
IntoResponse
Handler 特质被包装成一个 MethodRouter结构中,存储在路由器上的HashMap 。当调用时,FromRequest 被用来提取参数的值,这样就可以用它们来调用底层函数。这也是对Bevy工作方式的一种破坏!关于Axum中提取器如何工作的更多信息,我推荐David Pedersen的这个演讲。
结论
在这篇文章中,我们看了一下Bevy,一个用Rust编写的游戏引擎。我们探索了它的ECS模式,熟悉了它的API并运行了一个例子。最后,我们简单看了一下Axum网络框架中的ECS模式,考虑它与Bevy的区别。
如果你想了解更多关于Bevy的信息,我建议你查看一下 SystemParamFetch特质来探索从World 。 我希望你喜欢这篇文章,如果你遇到任何问题,请务必留下评论。编码愉快!