当前位置: 首页 > 工具软件 > Bevy > 使用案例 >

Rust Bevy 实体组件系统

卜盛
2023-12-01

这篇文章包括来自 Alice Cecile Bevy 的贡献者 的帮助和评论。 对 Bevy Discord 的补充。 非常感谢 Joy and Logic

Bevy 是一个用 Rust 编写的游戏引擎,以非常符合人体工程学的实体组件系统而闻名。

在 ECS 模式 中,实体是由组件组成的独特事物,就像游戏世界中的对象一样。 系统处理这些实体并控制应用程序的行为。 Bevy 的 API 如此优雅的原因在于,用户可以在 Rust 中编写常规函数,并且 Bevy 将知道如何通过类型签名调用它们,调度正确的数据。

已经有大量关于如何使用 ECS 模式来构建您自己的游戏的文档,例如 在非官方 Bevy Cheat Book 中。 相反,在本文中,我们将解释如何在 Bevy 本身中实现 ECS 模式。 为此,我们将从头开始构建一个类似于 Bevy 的小型 API,它可以接受任意系统函数。

这种模式非常通用,你可以将它应用到你自己的 Rust 项目中。 为了说明这一点,我们将在文章的最后一节中更详细地介绍 Axum Web 框架如何将这种模式用于其路由处理程序方法。

如果您熟悉 Rust 并对类型系统技巧感兴趣,那么本文适合您。 在开始之前,我建议您查看我之前关于 Bevy 标签实现的 文章。 让我们开始吧!

目录

  • Bevy 的系统功能类似于面向用户的 API

  • add_system方法

  • 将功能添加为系统

  • 插曲:运行一个例子

  • 带参数的系统函数

  • 存储通用系统

  • 装箱我们的泛型

  • 获取参数

  • 相同的模式,不同的框架:Axum 中的提取器

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 我们要查询哪些组件。 在我们的例子中,我们想要 Transform来自每个也有习惯的实体 Player零件。 在幕后,Bevy 甚至根据函数签名推断出哪些系统可以并行运行。

add_system方法

Bevy 有很多 API 表面。 毕竟,它是一个完整的游戏引擎,除了它的实体组件系统之外,还有一个调度系统、一个 2D 和 3D 渲染器等等。 在本文中,我们将忽略其中的大部分内容,而只关注将函数添加为系统并调用它们。

按照 Bevy 的示例,我们将调用我们添加系统的项目, App,并给它两种方法, new和 add_system:


超过 20 万开发人员使用 LogRocket 来创造更好的数字体验 了解更多 →


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 中的 主要抽象之一是特征 ,它类似于其他语言中的接口或类型类。 我们可以定义一个 trait,然后为任意类型实现它,这样 trait 的方法就可以在这些类型上使用。 让我们创建一个 System允许我们运行任意系统的特征:

trait System {
    fn run(&mut self);
}

现在,我们的系统有了一个 trait,但是要在我们的函数中实现它,我们需要使用类型系统的一些附加特性。

Rust 使用特征来抽象行为,函数实现一些特征,比如 FnMut, 自动地。 我们可以为所有满足约束的类型实现特征:

让我们使用下面的代码:

impl<F> System for F where F: Fn() -> () {
    fn run(&mut self) {
        self(); // Yup, we're calling ourselves here
    }
}

如果你不习惯 Rust,这段代码可能看起来很不可读。 没关系,这不是你在日常 Rust 代码库中看到的东西。

第一行实现了所有类型的系统特征,这些类型是带有返回值的参数的函数。 在下一行中, runfunction 接受项目本身,并且因为这是一个函数,所以调用它。

虽然这行得通,但它是毫无用处的。 您只能调用不带参数的函数。 但是,在我们更深入地研究这个例子之前,让我们修复它以便我们能够运行它。

插曲:运行一个例子

我们的定义 App以上只是一个草稿; 让它使用我们的新 Systemtrait,我们需要让它更复杂一些。

自从 System现在是 trait 而不是类型,我们不能再直接存储它了。 我们甚至不知道它的尺寸 System是因为它可能是任何东西! 相反,我们需要把它放在一个指针后面,或者,正如 Rust 所说,把它放在一个 Box. 而不是存储实现的具体事物 System,您只需存储一个指针。

这是 Rust 类型系统的一个技巧:您可以使用 trait 对象来存储实现特定 trait 的任意项。


来自 LogRocket 的更多精彩文章:

  • 不要错过 The Replay 来自 LogRocket 的精选时事通讯

  • 使用 React 的 useEffect 优化应用程序的性能

  • 之间切换 在多个 Node 版本

  • 了解如何 使用 AnimXYZ 为您的 React 应用程序制作动画

  • 探索 Tauri ,一个用于构建二进制文件的新框架

  • 比较 NestJS 与 Express.js

  • 发现 TypeScript 领域中使用的流行 ORM


首先,我们的应用需要存储一个盒子列表,其中包含的东西是 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 编译器会告诉我们有两个问题:

  1. 如果我们为具体的函数签名添加一个实现,这 两个实现就会发生冲突 。 按 运行 查看错误

  2. 如果我们将接受的函数设为泛型,它将是一个 不受约束的类型参数

我们需要以不同的方式处理这个问题。 让我们首先为我们接受的参数引入一个特征:

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
…

悟空遥控器App,看电视和玩游戏通通拿下,无U盘安装神器!

如果您制作所有实例 System<()>并注释掉 .add_system(another_example_system),我们的代码将编译。

存储通用系统

现在,我们的挑战是达到以下标准:

  1. 我们需要一个知道其参数的通用特征

  2. 我们需要将通用系统存储在列表中

  3. 我们需要能够在迭代它们时调用这些系统

这是查看 Bevy 代码的好地方。 函数没有实现 System, 但反而 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作为一个 trait 对象 ,意味着我们的 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

您可以在 此处找到本教程的完整代码 。 按 play ,你会看到上面的输出等等。 随意玩弄它,尝试一些系统组合,也许添加一些其他东西!

相同的模式,不同的框架:Axum 中的提取器

我们现在已经看到 Bevy 如何能够接受相当广泛的功能作为系统。 但正如在介绍中所戏弄的那样,其他库和框架也使用这种模式。

一个例子是 Axum Web 框架,它允许您为特定路由定义处理函数。 下面的代码显示了 他们文档 中的一个示例:

async fn create_user(Json(payload): Json<CreateUser>) { todo!() }

let app = Router::new().route("/users", post(create_user));

有一个 post接受函数的函数,甚至 async那些,其中所有参数都是提取器,例如 Json在此输入。 正如你所看到的,这比我们迄今为止看到的 Bevy 所做的要复杂一些。 Axum 必须考虑返回类型及其转换方式,以及支持异步函数,即那些返回期货的函数。

但是,一般原理是相同的。 这 Handlertrait 是为函数实现的:

  • 谁的参数实现 FromRequest

  • 谁的返回类型实现 IntoResponse

这 Handler特质被包裹在一个 MethodRouter结构存储在 HashMap在路由器上。 打电话时, FromRequest用于 提取参数的值,以便可以使用它们调用底层函数。 这也是 Bevy 工作原理的剧透! 有关 Axum 中提取器如何工作的更多信息,我推荐 David Pedersen 的这个演讲 。

结论

在本文中,我们了解了 Bevy,这是一个用 Rust 编写的游戏引擎。 我们探索了它的 ECS 模式,熟悉了它的 API 并运行了一个示例。 最后,我们简要了解了 Axum Web 框架中的 ECS 模式,并考虑了它与 Bevy 的不同之处。

如果您想了解有关 Bevy 的更多信息,我建议您查看 SystemParamFetchtrait 探索从 a 中获取参数 World. 我希望您喜欢这篇文章,如果您遇到任何问题或问题,请务必发表评论。 快乐编码!

LogRocket :全面了解生产 Rust 应用程序

调试 Rust 应用程序可能很困难,尤其是当用户遇到难以重现的问题时。 如果您对监控和跟踪 Rust 应用程序的性能、自动显示错误以及跟踪缓慢的网络请求和加载时间感兴趣,请 尝试 LogRocket 。

LogRocket 就像一个用于 Web 和移动应用程序的 DVR,几乎可以记录 Rust 应用程序上发生的所有事情。 无需猜测问题发生的原因,您可以汇总并报告问题发生时应用程序所处的状态。 LogRocket 还监控您的应用程序的性能,报告客户端 CPU 负载、客户端内存使用情况等指标。

 类似资料: