likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 5

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 好了:

cargo new first-bevy

然后,我们需要在项目的 Cargo.toml 中引入 Bevy:

[dependencies]
bevy = "0.13"

截止本文撰写时,Bevy 的最新版本是 0.13.1,反正我们写 0.13 就对了。

然后我们在 main.rs 输入以下内容:

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:

#[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 键控制小球移动。代码如下:

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_COLORMOVE_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)。

然后是这个:

// 在场景中生成一个小球实体,使用 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,其完整定义是这样的:

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,那就需要使用 queryiter()iter_mut() 等方法来迭代遍历了。

最后的键盘事件代码就不再解释了,唯一要说明的是,对于 2D 游戏的平面直角坐标系,游戏框体的正中间为原点,坐标是 (0, 0),向右 x 轴递增,向上 y 轴递增,单位是像素。

4 结语

作为一篇初探文章,本文涉及到的内容其实稍稍多了一点,但也只是 Bevy 庞大生态中的冰山一角。本文的目的旨在介绍 Bevy,让大家认识 Bevy 是什么,能做什么。

对于 Bevy,目前我也在学习中,有些概念和原理也是一知半解,若文中有任何遗漏或错误,还请各位不吝指出,感谢!