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(bevy-cheatbook.github.io/) 来学习 Bevy,整个过程还是稍微有点曲折的。
本文正如标题所说,写的是对 Bevy 的初探,因此本文只是对 Bevy 的一个简单的尝试,以及 Bevy 的一些基础技术原理。未来如果我依然在玩 Bevy,这个系列也许会继续更新更加深入的文章。
1 上手
1.1 前期准备
Bevy(bevyengine.org/)作为一款 Rust 的开源游戏引擎,或者我们也可以简单认为它是一个 Rust 用于开发游戏的框架,我们的程序自然也要用 Rust 进行开发,因此本文假设读者们已经掌握了 Rust 的基本开发能力。
Bevy 是跨平台的,它支持 Windows、MacOS 和 Linux。大家可以根据各自的开发环境,照着官方文档(bevyengine.org/learn/quick...)先安装好所需的依赖和软件。我个人因为有 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 输入以下内容:
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<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
// 在场景中生成一个 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<ButtonInput<KeyCode>>,
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<Assets<Mesh>>
用于生成网格(Mesh)资产(Asset),后续代码里可以看到,我们生成的是一个 Circle。第二个 mut materials: ResMut<Assets<ColorMaterial>>
则用于渲染小球的颜色。
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<ButtonInput<KeyCode>>
和 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<M>
where
M: Material2d,
{
pub mesh: Mesh2dHandle,
pub material: Handle<M>,
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,目前我也在学习中,有些概念和原理也是一知半解,若文中有任何遗漏或错误,还请各位不吝指出,感谢!