Rust 开源游戏引擎 Bevy 初探以及移动小球游戏实现

阅读:1372

发布时间:2024年3月17日 21:55

# Rust 开源游戏引擎 Bevy 初探以及移动小球游戏实现 # 0 前言(可略过) 前段时间照常浏览 Rust Weekly 邮件的时候,看到了 Bevy 发布 0.13.0 版本的消息,总觉得这个库似乎在哪儿见过,进去一看,原来是我很早以前就在 github 上 star 了的一款开源游戏引擎。 作为一个曾经把游戏开发当作理想的人(然而现在干的工作和游戏开发一毛钱关系都没),当初刚学 Rust 时,看到 Rust 的种种优点和特性,第一时间就想到 Rust 应该很适合做游戏开发,于是就找了找,果然已经有不少游戏引擎在用 Rust 开发了,Bevy 就是其中 github stars 最多的那个。然而当时因为工作繁忙等原因,一直也没去研究,然后它就和许多我关注过的开源项目一样,被遗忘在了角落…… 这次趁着 Bevy 0.13.0 发版之际,我总算是有时间小小地体验了一把 Bevy ——这个开源的,目前还没有 GUI 编辑器的纯代码开发的游戏引擎。不过,也许是因为 Bevy 还没有到 1.0 阶段,版本之间的差异非常大,又或是其他原因,总之它的官方文档稀烂,于是我只能通过巨量的官方 examples 和官方推荐的一本非官方的 [Cheat Book](https://bevy-cheatbook.github.io/) 来学习 Bevy,整个过程还是稍微有点曲折的。 本文正如标题所说,写的是对 Bevy 的初探,因此本文只是对 Bevy 的一个简单的尝试,以及 Bevy 的一些基础技术原理。未来如果我依然在玩 Bevy,这个系列也许会继续更新更加深入的文章。 # 1 上手 ## 1.1 前期准备 Bevy([https://bevyengine.org/](https://bevyengine.org/))作为一款 Rust 的开源游戏引擎,或者我们也可以简单认为它是一个 Rust 用于开发游戏的框架,我们的程序自然也要用 Rust 进行开发,因此本文假设读者们已经掌握了 Rust 的基本开发能力。 Bevy 是跨平台的,它支持 Windows、MacOS 和 Linux。大家可以根据各自的开发环境,照着官方文档([https://bevyengine.org/learn/quick-start/getting-started/setup/](https://bevyengine.org/learn/quick-start/getting-started/setup/))先安装好所需的依赖和软件。我个人因为有 Windows 和 Ubuntu(gnome)两个 GUI 环境,所以这两个环境的前期准备我都尝试过,目前没有遇到任何问题。 如果前期准备已做好,我们就可以正式开始 Bevy 的旅程了。 ## 1.2 hello world 按照国际惯例,我们先从一个简单的 hello world 程序开始。 首先,我们用 cargo 正常创建一个 Rust 项目,譬如就叫 first-bevy 好了: ```bash cargo new first-bevy ``` 然后,我们需要在项目的 Cargo.toml 中引入 Bevy: ```toml [dependencies] bevy = "0.13" ``` 截止本文撰写时,Bevy 的最新版本是 0.13.1,反正我们写 0.13 就对了。 然后我们在 [main.rs](http://main.rs) 输入以下内容: ```rust use bevy::prelude::*; fn main() { App::new() .add_systems(Startup, hello_world_system) .run(); } fn hello_world_system() { println!("hello world"); } ``` 接着运行这个程序,我们就能在终端上看到熟悉的 hello world 了。 简单解释一下这段代码。首先我们引入了 `bevy::prelude::*`,由于是 *,所以它会引入非常多的 Bevy 常用的一些东西,譬如上面代码中 `main()` 函数里的 `App`,就是由 `prelude` 引入的。 在 `main` 中,我们 new 了一个 `App` 对象,再用它链式调用了 `add_systems` 方法,以及 `run()` 。这里的 `App` 就是 Bevy 引擎程序的总入口,我们开发的程序,或者是游戏,就是一个 App。我们 new 出来的 `App` 对象会为我们的程序添加各种我们需要的系统、资源、组件等等,然后执行 `run()` 运行我们的程序。 `add_systems(Startup, hello_world_system)` ,这个方法向 App 中添加一个系统(System),这个 System 就是我们下面定义的 `hello_world_system` 函数,而它会以 `Startup` 的身份被调度。`Startup` 我们简单理解,就是说 `hello_world_system` 会在 App 初始化时被调度一次,后续整个程序的运行过程中都不再被调度。所以当我们运行程序时,`hello_world_system` 被调度执行了,于是我们看到了 hello world 的输出。类似 `Startup` 这样的调度类型在 Bevy 中被称为 Schedule ,它们还有很多,也有许多更复杂的调用方式,这里我们暂不展开,了解就行。 `hello_world_system` 在这里被当作了一个 System,什么是 System?这个问题涉及到了 Bevy 采用的架构模式,后文会讲。总之,在 Bevy 中,System 就是一个普通的 Rust 函数,它可以没有任何参数,但如果要有参数,则必须是 Bevy 指定的参数类型,否则程序就会编译失败,各位有兴趣的可以试一试。 # 2 ECS 前文提到,`hello_world_system` 被当作一个 System 给添加到了 App 中,是时候解释一下什么是 System 了。 首先,Bevy 是一个基于 ECS 架构的游戏引擎,这个 ECS 是一种架构模式,类似于 MVC 那种,将程序整体分为若干个部分,或若干层。譬如 MVC 就是 Model(模型)、View(视图)和 Controller(控制器),他们各有分工,分别有相应的职责,最后共同构成了一个完整的程序。 而 ECS 则是 Entity(实体)、Component(组件)和 System(系统)。对游戏开发比较熟悉的读者应该对此非常了解,而如果你不熟悉游戏开发,或许这种架构模式你是第一次听说。 简单讲,假设我们有一个游戏,那么 Entity 就是游戏里我们能看到的大部分东西,譬如玩家、NPC、敌人、可交互的场景物品等等,并且每个 Entity 在游戏世界里都是唯一的。 Component 可以理解为数据,譬如玩家的名字、血量、拥有哪些技能、等级、经验值等等。每个 Entity 都会绑定、或关联一组 Component,如我们刚提到了,一个玩家 Entity,拥有上述这些 Component。在游戏中,我们对 Entity 的操作,实际上都是对 Entity 的 Components 进行的操作,Entity 本身通常只是一个标识。 举一个例子来更形象地解释 Entity 和 Component 之间的关系。关系性数据库大家都用过吧?没用过也没关系,Excel 用过吧?我们会有一张表(Table),一张表中会有许多数据,而数据都是按照行、列排列的。通常情况下,每行代表一条数据记录,而列则是代表了这条记录本身真正的数据。Entity 就相当于是一行一行的记录,由于包括 Bevy 在内的许多地方,通常会把 Entity 表示为一个简单的 ID,因此我们可以认为 Entity 就是一个行号,它唯一表示了某一行的记录。而 Component 就是这一行记录里各列的数据。 如有一张玩家表,它的每行都有个行号,然后这张表由名字、血量、等级等列组成,这些就是 Component,也就是这个玩家的数据。 Bevy 中的 Entity 是 Bevy 自己的内部类型,我们能且仅能拿到某个 Entity 的 ID。Component 就可以由开发者自定义了,在 Bevy 中 Component 可以用 struct 或 enum 来表示,只要一个 `struct` 派生了 Bevy prelude 中的 `Component` 特性,它就会被当作是一个 Component: ```rust #[derive(Component)] struct Player { name: String } ``` 最后是 System,这个就简单了,它就是游戏的逻辑代码,用于所有游戏逻辑的实现。前文提到过,Bevy 中的 System 就是一个函数,它需要申明指定的形参,并且会按照添加时指定的 Schedule 被调度。 ECS 实际上不是一个面向对象的架构模型,而是属于一种被称为面向数据(Data Oriented)的开发方法,这个不在本文的讨论范围,就不细展开了。 关于 ECS 的细节,未来我会单独写一篇文章来讨论,这里只是简单为大家介绍这种架构模式,以便于我们理解 Bevy 的代码和行为逻辑。 # 3 图形游戏 好了,看到这里,大家肯定就要说了:你长篇大论了那么多东西,给的不还是一个 hello world 吗?你的承诺呢?你的游戏呢? 各位看官少安毋躁,硬菜马上就到! ## 3.1 游戏代码 随着前文的 hello world 程序,以及我介绍的 ECS 相关理论知识,相信大家已经对 Bevy 有了一定的认识,那么接下来,我将为大家带来一个非常非常简单的,可以勉强称之为游戏的程序了。这个游戏的玩法用一句话就能介绍完:屏幕当中有一个 2D 的实心小球,我们可以用 WSAD 键控制小球移动。代码如下: ```rust use bevy::prelude::*; /// 导入 bevy 库的 sprite 模块中的 Mesh2dHandle 和 MaterialMesh2dBundle 结构体, /// 用于渲染小球 use bevy::sprite::{Mesh2dHandle, MaterialMesh2dBundle}; /// 小球的颜色 const BALL_COLOR: Color = Color::rgb(0.5, 0.5, 1.0); /// 小球每次移动的步长(像素) const MOVE_STEP: f32 = 5.0; /// 定义一个名为 Player 的组件结构体,也就是我们操作的小球。 /// 这种没有内容的结构体 Component,在 Bevy 中被称为 Marker Component, /// 通常用于标记一个 Entity。 #[derive(Component)] struct Player; fn main() { App::new() .add_plugins(DefaultPlugins) // 添加默认插件 .add_systems(Startup, setup) // 添加启动时执行的系统 Startup 和 setup .add_systems(Update, player_move) // 添加更新时执行的系统 Update 和 player_move .run(); } fn setup( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, ) { // 在场景中生成一个 2D 相机 commands.spawn(Camera2dBundle::default()); // 创建一个圆形的网格,并获取其句柄 let mesh = Mesh2dHandle(meshes.add(Circle {radius: 50.})); // 在场景中生成一个小球实体,使用 MaterialMesh2dBundle 包装网格和材质 commands.spawn(( MaterialMesh2dBundle { mesh: mesh, material: materials.add(BALL_COLOR), ..default() }, Player, )); } /// 玩家移动函数,根据按键输入来控制小球的移动 fn player_move( key_input: Res>, mut query: Query<(&Player, &mut Transform)>, ) { // 获取玩家实体的 Player 组件和 Transform 组件的可变引用。 // Transform 就是用来存储小球的形态、位置等数据的 Bevy 原生的 Component。 let (_, mut transform) = query.single_mut(); // 如果按下了 W 键,向上移动小球 if key_input.pressed(KeyCode::KeyW) { transform.translation.y += MOVE_STEP; println!("Up translation: {:?}", transform.translation); } // 如果按下了 S 键,向下移动小球 if key_input.pressed(KeyCode::KeyS) { transform.translation.y -= MOVE_STEP; println!("Down translation: {:?}", transform.translation); } // 如果按下了 D 键,向右移动小球 if key_input.pressed(KeyCode::KeyD) { transform.translation.x += MOVE_STEP; println!("Right translation: {:?}", transform.translation); } // 如果按下了 A 键,向左移动小球 if key_input.pressed(KeyCode::KeyA) { transform.translation.x -= MOVE_STEP; println!("Left translation: {:?}", transform.translation); } } ``` 运行代码后,程序会弹出一个框体,框体的正中间有一个实心的小球,我们通过按下 WSAD 键就能控制小球的移动了。 在我自己的环境中,Windows 没有任何问题,但我的 Ubuntu 运行该程序非常非常卡,暂时不知道什么原因。 嗯,代码不是很长,关键的地方我基本都写满了注释,这里对一些重要的点进行一些简要的补充说明,会有不少新的概念,不涉及底层原理或逻辑细节。 ## 3.2 代码解释 ### 3.2.1 定义部分 首先,除了之前的 `prelude` 外,我额外引入了两个结构体 `Mesh2dHandle, MaterialMesh2dBundle` ,它们都用于生成小球,具体使用到后面 `setup` 中再解释。接下来是两个静态变量 `BALL_COLOR` 和 `MOVE_STEP` ,这两个好理解,球的颜色和每次移动的像素级步长。 然后我们定义了一个名叫 `Player` 的结构体 Component,它没有内容,这种 Component 在 Bevy 中被称为 Marker Component,也就是一个“标记”,一般会用来标记一个 Entity 的身份、状态等信息。其实对于我们这个简单的游戏来说,我们完全可以不需要这个 Component(后面的代码里也能看出来),但为了理解前文说的 ECS 架构,以及出于一种较为规范的做法,我还是把它加进来了。这里我写的是 `struct Player` ,你也可以取个其他名字,Ball 啊,Superman 啊都行。 ### 3.2.2 main 进入 `main` 函数,发现 App 添加的东西比 hello world 程序多了几个。首先是 `add_plugins(DefaultPlugins)` ,这是 Bevy 的插件系统,我们向 App 插入了一个默认插件 `DefaultPlugins` 。我们暂时不需要知道它到底是什么,只需要了解 `DefaultPlugins` 为游戏提供了完整的运行时调用,一个游戏窗体,以及其他乱七八糟的默认功能。 接着是两个 System,Startup 对应 `setup` ,这就是游戏初始化的东西,好理解。第二个是 Update 的 `player_move` ,这个 System 用于控制小球的移动,Update 会在游戏运行时不断被调度,调度间隔为**每帧一次**,所以我们的小球才能在我们按键盘时流畅地移动。多说一句,Bevy 游戏的默认刷新率为 64 Hz。 ### 3.2.3 setup `setup` 函数,也就是 Startup 的 System,会在程序开始时运行一次,用于初始化游戏。`setup` 接受三个可变参数,第一个 `mut commands: Commands` ,Commands 用于向我们游戏的世界(World)插入或移除资源(Resource)、Entity,并可以向已存在 Entity 中插入新的 Components。总之,如果我们想要给游戏世界插入数据,就需要用 Commands。 后两个参数都和我们要生成的小球有关,第一个 `mut meshes: ResMut>` 用于生成网格(Mesh)资产(Asset),后续代码里可以看到,我们生成的是一个 Circle。第二个 `mut materials: ResMut>` 则用于渲染小球的颜色。 `setup` 内的第一行代码是 `commands.spawn(Camera2dBundle::default());` 。因为我们是一个 2D 游戏,因此需要先生成一个 2D 相机以控制和观察我们的 2D 游戏。注意了,尽管代码中没有写,但实际上 `spawn` 函数会生成并返回一个 Entity,并且它接收的参数其实是一堆 Components 的捆绑(Bundle)。 然后是这个: ```rust // 在场景中生成一个小球实体,使用 MaterialMesh2dBundle 包装网格和材质 commands.spawn(( MaterialMesh2dBundle { mesh: mesh, material: materials.add(BALL_COLOR), ..default() }, Player, )); ``` 捆绑(Bundle)的另一个形式是 Tuple,它里面可以放各种 Components,也可以嵌套放其他 Bundle,反正 `spawn` 内部都会给处理掉。 至此,小球已经渲染完毕,并且我们将这个小球和 `Player` 组件关联了起来,让这个小球有了一个 `Player` 的标记,或者是身份。 ### 3.2.4 player_move 小球的运动逻辑就在这个 System 里。 首先它接收两个参数 `key_input: Res>` 和 `mut query: Query<(&Player, &mut Transform)>` 。第一个一眼懂,就是我们的键盘输入;第二个稍微复杂一点,字面上理解,它是一个可变的查询。`Query<(&Player, &mut Transform)>` 说明了这个可变查询每次可以查询一个 Tuple,它由一个 `Player` 引用和一个可变的 `Transform` 引用组成。`Player` 就是我们用于标记的 Component,这个 `Transform` 是什么? 回忆一下前一节,我们渲染小球实体时,`spawn` 里除了 `Player` ,还有一个 `MaterialMesh2dBundle` ,它是 Bevy 自带的 Component Bundle,其完整定义是这样的: ```rust pub struct MaterialMesh2dBundle where M: Material2d, { pub mesh: Mesh2dHandle, pub material: Handle, pub transform: Transform, pub global_transform: GlobalTransform, pub visibility: Visibility, pub inherited_visibility: InheritedVisibility, pub view_visibility: ViewVisibility, } ``` 看到里面的 `pub transform: Transform` 了吧,这个 `Transform` 也是一个 Component,它用于存储小球的位置。所以参数中 `&mut Transform` 之所以是可变引用,就是因为我们会在 `player_move` 里通过改变小球的位置,来控制小球的移动。 接下来的代码就都比较容易理解了,首先是 `let (_, mut transform) = query.single_mut();` ,由于我们的游戏里只有一个 `Player` 的 Entity,所以我们调用 `query.single_mut()` 来获取这一个 Entity 的 Component,获取的结果就是参数中定义的结果。可以看到,我们实际上并不真的需要 `Player` Component(毕竟它也没有实质数据),只是借用 `Player` 来标记这个小球 Entity。 如果我们有不止一个符合要求的 Entity,那就需要使用 `query` 的 `iter()` 、`iter_mut()` 等方法来迭代遍历了。 最后的键盘事件代码就不再解释了,唯一要说明的是,对于 2D 游戏的平面直角坐标系,游戏框体的正中间为原点,坐标是 (0, 0),向右 x 轴递增,向上 y 轴递增,单位是像素。 # 4 结语 作为一篇初探文章,本文涉及到的内容其实稍稍多了一点,但也只是 Bevy 庞大生态中的冰山一角。本文的目的旨在介绍 Bevy,让大家认识 Bevy 是什么,能做什么。 对于 Bevy,目前我也在学习中,有些概念和原理也是一知半解,若文中有任何遗漏或错误,还请各位不吝指出,感谢!