跳转到内容

Rust 游戏开发:Bevy、Macroquad 等引擎入门

很多人学 Rust,是因为想做一款游戏。这个理由很棒!游戏需要速度,需要谨慎地使用内存,还需要代码别在打 Boss 打到一半的时候崩溃,而这些正好都是 Rust 擅长的事。

这一页会如实告诉你现状:大家已经在用 Rust 做什么,该选哪个引擎或库,以及几乎所有游戏都会用到的两个概念:游戏循环和 ECS。然后你会看到用 Bevy 和 Macroquad 写的第一个程序。

哪些示例能在哪里运行? 游戏循环、迷你 ECS 世界和练习题都是普通的 Rust 代码,所以带有“动手试一试”按钮,可以在 Rust Playground 里运行。Bevy 和 Macroquad 会打开一个窗口,所以这两个示例要在你自己的计算机上用 cargo run 运行。Macroquad 示例在写这一页时实际编译过(也编译过 Web 版本);Bevy 示例参照了 Bevy 的官方示例。

社区网站 Are We Game Yet?(英文)一直在追踪 Rust 的游戏生态。它对自己网站名这个问题的回答是:“Almost. We have the blocks, bring your own glue.”(快了。积木我们都有,胶水请自带。)这个总结很中肯。

现在的优势:

  • 真的有游戏上线。 Steam 上的城堡建造休闲游戏 Tiny Glade,以及开源的体素 RPG Veloren,都被 Are We Game Yet 列为 Rust 游戏。
  • 有一个强大的引擎。 Bevy 社区庞大、活跃,每隔几个月就发布一个新版本。
  • 积木很多。 Are We Game Yet 列出了物理、音频、输入、网络、UI 等各方面的 crate。
  • 你的游戏可以在网页上运行。 Rust 能编译成 WebAssembly,所以很多 Rust 游戏也能在浏览器里玩。

还比较难的地方:

  • 还没有“Rust 版的 Unity”。 大多数 Rust 引擎以代码为主。可视化编辑器是有的(Fyrox 自带一个,Godot 也可以配合 Rust 使用),但它们比大型商业工具年轻得多。
  • 变化很快。 Bevy 还没到 1.0,每次发布都有不兼容的改动。去年的教程现在可能已经编译不过了。
  • 编译时间长。 大型引擎第一次构建要好几分钟。下面会介绍加快编译的技巧。

如果你的目标是明年和一群美术一起发布一款商业 3D 游戏,Unity、Unreal 或 Godot 更稳妥。如果你想学习,想做 2D 游戏、参加 Game Jam、写工具或模拟程序,或者你就是喜欢 Rust,那这里会非常好玩。🦀

工具分两类。引擎会给你一整套结构:游戏对象、场景和资源怎么组织。库(或者叫框架)只提供窗口、绘图、声音和输入,其余部分由你自己组织。

crate 是什么 适合 需要知道的
bevy(0.20) 围绕 ECS 构建的完整 2D/3D 引擎 较大的项目、3D、了解现代引擎的工作方式 最知名的 Rust 引擎。每次发布都有不兼容改动;第一次构建很慢
macroquad(0.4) 受 raylib 启发的简单 2D 库 第一款游戏、Game Jam、网页游戏 依赖极少,构建很快。你在循环里调用绘图函数
ggez(0.10) 受 LÖVE 启发的轻量 2D 框架 结构清晰的 2D 游戏 你要实现带有 update 和 draw 的 EventHandler trait
fyrox(1.0) 带场景编辑器的 2D/3D 引擎 想要像 Godot 或 Unity 那样有编辑器的人 2026 年发布了 1.0 版本。社区比 Bevy 小
raylib(6.0) C 库 raylib 的 Rust 绑定 在 C 里用过 raylib 的人 需要 CMake 和 C 构建工具,因为它要编译那个 C 库(见 FFI)
godot(0.5,“gdext”) Godot 4 引擎的 Rust 绑定 用 Godot 编辑器,游戏逻辑用 Rust 写 你写的 Rust 类会被 Godot 作为 GDExtension 加载

一个简单的原则: 第一款 2D 游戏从 Macroquad 开始。想要完整的引擎、也愿意学 ECS,就选 Bevy。想要用编辑器可视化地摆放东西,就选 Godot + gdext 或者 Fyrox。

几乎每个游戏都是一个大循环,一遍又一遍地运行,直到你退出:

  1. 读取输入(键盘、鼠标、手柄)。
  2. 更新游戏:移动物体、检测碰撞、计算分数。
  3. 绘制新的画面到屏幕上。

循环一次叫做一帧(frame)。在每秒 60 帧(FPS)的情况下,每帧大约有 16 毫秒。但每一帧花的时间并不一样:计算机可能正忙,或者某一帧需要加载文件。

如果你的游戏让玩家“每帧移动 2 个单位”,那它在快的计算机上就跑得快,在慢的计算机上就跑得慢。一个常用的解决办法是固定时间步长(fixed timestep):游戏逻辑总是按相同的步长前进(这里是 10 ms),每一帧经过了多少时间,就执行能装下的那么多步。剩下的时间存进一个累加器(accumulator),留到下一帧。

const STEP_MS: u32 = 10; // 游戏逻辑每次固定前进 10 ms
const SPEED: i32 = 2; // 每次更新玩家移动 2 个单位
fn main() {
// 每一帧实际花了多长时间。真实的游戏会用时钟来测量。
let frame_times = [16, 16, 45, 5, 16, 33];
let mut accumulator = 0; // 已经过去、但还没模拟的时间
let mut position = 0;
for (frame, elapsed) in frame_times.into_iter().enumerate() {
accumulator += elapsed;
// 现有的时间能装下多少个固定步长,就执行多少次
let mut updates = 0;
while accumulator >= STEP_MS {
position += SPEED; // update():游戏逻辑
accumulator -= STEP_MS;
updates += 1;
}
// render():不管发生了什么,每帧绘制一次
println!("第 {frame} 帧:耗时 {elapsed:>2} ms,更新 {updates} 次,玩家位置 {position:>2},剩余 {accumulator} ms");
}
}

输出:

第 0 帧:耗时 16 ms,更新 1 次,玩家位置 2,剩余 6 ms
第 1 帧:耗时 16 ms,更新 2 次,玩家位置 6,剩余 2 ms
第 2 帧:耗时 45 ms,更新 4 次,玩家位置 14,剩余 7 ms
第 3 帧:耗时 5 ms,更新 1 次,玩家位置 16,剩余 2 ms
第 4 帧:耗时 16 ms,更新 1 次,玩家位置 18,剩余 8 ms
第 5 帧:耗时 33 ms,更新 4 次,玩家位置 26,剩余 1 ms
▶ 动手试一试

看第 2 帧:它很慢(45 ms),所以游戏执行了 4 次更新来追上进度。一共过去了 131 ms,游戏执行了 13 次 10 ms 的更新。不管时间怎么被切成一帧一帧,玩家最后都停在 26。这让物理模拟很稳定,在每台计算机上都一样,这对回放和联机游戏也很重要。有一个危险:如果某一帧非常慢,游戏要执行很多次更新来追赶,结果下一帧也变慢了。所以真实的游戏循环还会限制每帧最多执行几次更新(比如 Bevy 的游戏时钟默认每帧最多前进 250 ms)。

另一种常见的做法是增量时间(delta time):测量距离上一帧过去了多少秒,再把速度乘上它(x += speed * dt)。这种做法更简单,下面的 Bevy 和 Macroquad 示例用的就是它。很多引擎两种都用:动画用增量时间,物理用固定步长。

在很多语言里,你会写一个 Player 类,再写一个继承自 Character 的 Enemy 类,依此类推。这很快就会变得一团糟:如果有一个敌人同时还能被捡起来,怎么办?大多数 Rust 游戏引擎改用 ECS(Entity Component System,实体组件系统):

  • 实体(entity)只是一个 ID,就像一个编号。它本身没有数据,也没有行为。
  • 组件(component)是挂在实体上的一份普通数据:Position、Velocity、Health。在 Rust 里,它们就是普通的结构体或枚举。
  • 系统(system)是一个函数,对所有拥有特定组件的实体运行。比如移动系统,会处理所有同时拥有 Position 和 Velocity 的东西。

一棵树有 Position,但没有 Velocity,所以移动系统会跳过它。想让树动起来?给它一个 Velocity 就行,不用修改任何类。数据和行为是分开的,这一点也和 Rust 的借用规则很合拍。

下面是用普通 Rust 写的一个迷你 ECS 世界。每种组件类型存放在自己的 vector 里,实体 ID 就是下标。None 表示“这个实体没有这个组件”(见 Option)。

struct Position { x: i32, y: i32 }
struct Velocity { dx: i32, dy: i32 }
// 实体只是一个 ID:所有组件列表里的下标
type Entity = usize;
#[derive(Default)]
struct World {
names: Vec<&'static str>,
positions: Vec<Option<Position>>,
velocities: Vec<Option<Velocity>>,
}
impl World {
fn spawn(&mut self, name: &'static str, pos: Option<Position>, vel: Option<Velocity>) -> Entity {
self.names.push(name);
self.positions.push(pos);
self.velocities.push(vel);
self.names.len() - 1
}
}
// 一个系统:处理所有同时拥有 Position 和 Velocity 的实体
fn movement_system(world: &mut World) {
for (pos, vel) in world.positions.iter_mut().zip(&world.velocities) {
if let (Some(pos), Some(vel)) = (pos, vel) {
pos.x += vel.dx;
pos.y += vel.dy;
}
}
}
// 另一个系统:打印所有拥有 Position 的实体
fn print_system(world: &World, tick: u32) {
println!("tick {tick}:");
for (id, pos) in world.positions.iter().enumerate() {
if let Some(pos) = pos {
println!(" #{id} {:<6} at ({}, {})", world.names[id], pos.x, pos.y);
}
}
}
fn main() {
let mut world = World::default();
world.spawn("player", Some(Position { x: 0, y: 0 }), Some(Velocity { dx: 1, dy: 0 }));
world.spawn("tree", Some(Position { x: 5, y: 2 }), None); // 没有 Velocity:永远不动
world.spawn("arrow", Some(Position { x: 0, y: 3 }), Some(Velocity { dx: 3, dy: -1 }));
for tick in 1..=2 {
movement_system(&mut world);
print_system(&world, tick);
}
}

输出:

tick 1:
#0 player at (1, 0)
#1 tree at (5, 2)
#2 arrow at (3, 2)
tick 2:
#0 player at (2, 0)
#1 tree at (5, 2)
#2 arrow at (6, 1)
▶ 动手试一试

树一直待在 (5, 2),因为它没有 Velocity。没有人写过“树不会动”这条规则,这是从数据里自然得出的结果。(实体名称保留了英文,因为 {:<6} 按字符宽度对齐,中文名称会把对齐弄乱。)真正的 ECS 库比这个快得多、聪明得多(它们会以 CPU 能快速读取的方式存放组件,还会并行运行系统),但思路是一样的。

Bevy(英文)是最知名的 Rust 引擎,里面的一切都是 ECS。0.20 版本在 2026 年 10 月发布。Bevy 需要最新的稳定版 Rust,所以先更新,再创建项目:

Terminal window
rustup update
cargo new my_bevy_game
cd my_bevy_game
cargo add bevy

在 Linux 上,你还需要装几个系统库(声音、输入、图形)。Bevy 的安装指南(英文)按发行版列出了它们。在 Windows 上需要 Visual Studio C++ 生成工具,在 macOS 上需要 Xcode 命令行工具。

这个程序基于 Bevy 官方的 2d/move_sprite 示例。它会画一个左右滑动的橙色方块。(官方示例加载的是一张图片;这里用的是纯色方块,所以你不需要任何图片文件。)

use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins) // 窗口、渲染、输入、音频……
.add_systems(Startup, setup) // 启动时运行一次
.add_systems(Update, sprite_movement) // 每帧都运行
.run();
}
// 一个组件:这个实体朝哪个方向移动
#[derive(Component)]
enum Direction {
Left,
Right,
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
// 生成一个带有三个组件的实体
commands.spawn((
Sprite::from_color(Color::srgb(1.0, 0.5, 0.2), Vec2::new(50.0, 50.0)),
Transform::from_xyz(0.0, 0.0, 0.0),
Direction::Right,
));
}
// 一个系统:对所有同时拥有 Direction 和 Transform 的实体运行
fn sprite_movement(time: Res<Time>, mut sprite_position: Query<(&mut Direction, &mut Transform)>) {
for (mut logo, mut transform) in &mut sprite_position {
match *logo {
Direction::Right => transform.translation.x += 150. * time.delta_secs(),
Direction::Left => transform.translation.x -= 150. * time.delta_secs(),
}
if transform.translation.x > 200. {
*logo = Direction::Left;
} else if transform.translation.x < -200. {
*logo = Direction::Right;
}
}
}

和上面的迷你 ECS 世界对比一下。#[derive(Component)] 把一个普通的枚举变成组件。commands.spawn((...)) 用一个组件元组创建实体。Query<(&mut Direction, &mut Transform)> 向 Bevy 要“所有同时拥有这两个组件的实体”,和我们的 movement_system 用 zip 做的事一样。time.delta_secs() 就是游戏循环一节里讲的增量时间,所以方块在任何计算机上都是每秒移动 150 像素。

用 cargo run 运行它。第一次构建要编译几百个 crate,可能要花好几分钟,耐心等一等。之后重新构建就快多了。

Bevy 的安装指南推荐了两件事。第一,把下面这段加到 Cargo.toml 里。它会优化 Bevy 和其他依赖,同时让你自己的代码依然编译得很快:

[dependencies]
bevy = "0.20"
# 在 dev profile 中开启少量优化。
[profile.dev]
opt-level = 1
# 在 dev profile 中为依赖开启大量优化。
[profile.dev.package."*"]
opt-level = 3

如果不加,游戏的 debug 构建可能会非常慢:帧率很低,加载时间很长。依赖只编译一次,所以优化的代价只在第一次构建时付出。(profile 的讲解见性能优化。)

第二,开发期间打开 dynamic_linking 这个 feature。这样 Bevy 会被构建成一个单独的共享库,每次修改后,Cargo 就不用把整个 Bevy 重新链接进你的程序:

Terminal window
cargo run --features bevy/dynamic_linking

Bevy 指南里有两个提醒:在 Windows 上,dynamic_linking 必须配合上面的 opt-level 设置才能用。而且发布游戏时不要带上它:否则游戏旁边还得附带一个额外的库文件。发布版本请不开这个 feature 来构建。指南里还介绍了怎么换用更快的链接器(lld 或 mold),在 Linux 上效果很明显。

Macroquad(英文)的感觉完全不同。没有 ECS,也没有 App 构建器:游戏循环你自己写,用 draw_circle 这样简单的函数来绘图。它的依赖很少,所以全新构建只要几秒,而不是几分钟。

Terminal window
cargo new move_the_ball
cd move_the_ball
cargo add macroquad
[dependencies]
macroquad = "0.4"

这个小游戏会显示一个球,你可以用方向键移动它:

use macroquad::prelude::*;
#[macroquad::main("Move the Ball")]
async fn main() {
let mut player = vec2(screen_width() / 2.0, screen_height() / 2.0);
let speed = 250.0; // 每秒移动的像素数
loop {
// 1. 输入:方向键移动玩家
let dt = get_frame_time(); // 距离上一帧的秒数
if is_key_down(KeyCode::Right) {
player.x += speed * dt;
}
if is_key_down(KeyCode::Left) {
player.x -= speed * dt;
}
if is_key_down(KeyCode::Down) {
player.y += speed * dt;
}
if is_key_down(KeyCode::Up) {
player.y -= speed * dt;
}
if is_key_pressed(KeyCode::Escape) {
break;
}
// 2. 让玩家留在屏幕内
player.x = player.x.clamp(0.0, screen_width());
player.y = player.y.clamp(0.0, screen_height());
// 3. 绘制这一帧的所有内容
clear_background(DARKBLUE);
draw_circle(player.x, player.y, 20.0, ORANGE);
draw_text("Arrow keys to move, Esc to quit", 20.0, 30.0, 28.0, WHITE);
next_frame().await
}
}

你能看到游戏循环的三个步骤:输入、更新、绘制。#[macroquad::main("Move the Ball")] 会打开一个以此为标题的窗口。main 是 async 的,但你不需要 Tokio,也不需要懂异步:next_frame().await 的意思只是“显示这一帧,然后等待下一帧”。屏幕上的提示文字保留了英文,因为 Macroquad 内置的默认字体不支持中文;想显示中文,需要用 load_ttf_font 加载你自己的字体。在 Linux 上,Macroquad 的 README 列出了几个需要先安装的系统包(Ubuntu 上是 pkg-config libx11-dev libxi-dev libgl1-mesa-dev libasound2-dev)。

Macroquad 和 Bevy 都能把你的游戏构建成 WebAssembly,在浏览器里运行。如果你还不熟悉 Wasm,先读一读 Rust 与 WebAssembly。第一步总是添加 Wasm 编译目标:

Terminal window
rustup target add wasm32-unknown-unknown

Macroquad 让这件事非常简单。一条命令就能构建出 .wasm 文件:

Terminal window
cargo build --release --target wasm32-unknown-unknown

文件会生成在 target/wasm32-unknown-unknown/release/move_the_ball.wasm(上面那个示例大约 600 KB)。Macroquad 的 README 给出了一个简短的 index.html,它通过一个小小的 JavaScript 文件 mq_js_bundle.js 来加载 Wasm。把这两个文件放进同一个文件夹,再用任意本地 Web 服务器提供访问,比如 basic-http-server .(用 cargo install basic-http-server 安装)。直接双击 HTML 文件通常不行,因为浏览器会阻止从磁盘直接加载 .wasm 文件。

Bevy 用 wasm-bindgen 生成 JavaScript 胶水代码。下面的步骤改编自 Bevy 的示例 README(针对上面的 my_bevy_game 项目):

Terminal window
cargo install wasm-bindgen-cli
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --out-dir web --target web target/wasm32-unknown-unknown/release/my_bevy_game.wasm

这会在 web 文件夹里生成一个 .js 文件和一个 _bg.wasm 文件;再写一个小小的 HTML 页面导入这个 .js 文件,就能启动游戏。Bevy 的安装指南还建议用一个 opt-level = "s" 的 wasm-release profile 来减小下载体积。网页版是分享 Game Jam 作品的好办法:别人点一下就能玩,什么都不用装。

每帧移动固定的距离。 每帧 x += 5.0,意味着游戏在 120 FPS 下比在 60 FPS 下快一倍。要乘上增量时间(x += speed * dt),或者使用固定时间步长。

照着旧的 Bevy 教程做。 Bevy 每次发布都会修改 API,所以一篇针对 0.12 的博客文章,在 0.20 下往往编译不过。先看看教程用的是哪个版本,优先参考你所用版本的官方示例。每次发布,Bevy 官网上也都有一份迁移指南。

debug 构建很慢。 如果你的 Bevy 游戏只有每秒几帧,很可能是忘了在 Cargo.toml 里加 opt-level 设置。

一开始就做得太大。 不管用哪种语言,开放世界 RPG 都是很难的第一个项目。从 Pong、贪吃蛇、打砖块或 Flappy Bird 开始。先做完一个,下一个再做得稍微大一点。

每个游戏都有几个画面:菜单、游戏本身、暂停画面、“游戏结束”。写一个枚举 GameState,包含 Menu、Playing、Paused 和 GameOver,再写一个枚举 Input,包含 Enter、Escape 和 PlayerDied。编写 fn next_state(state: GameState, input: Input) -> GameState:在菜单按 Enter 开始游戏,按 Escape 暂停和继续,玩家死亡就结束游戏,在“游戏结束”画面按 Enter 回到菜单。其他输入什么都不改变。给它一组固定的输入,并打印每一次变化。提示:对元组 (state, input) 使用 match,最后用 (same, _) => same 收尾。

查看答案
#[derive(Debug, Clone, Copy)]
enum GameState {
Menu,
Playing,
Paused,
GameOver,
}
#[derive(Debug, Clone, Copy)]
enum Input {
Enter,
Escape,
PlayerDied,
}
fn next_state(state: GameState, input: Input) -> GameState {
match (state, input) {
(GameState::Menu, Input::Enter) => GameState::Playing,
(GameState::Playing, Input::Escape) => GameState::Paused,
(GameState::Paused, Input::Escape) => GameState::Playing,
(GameState::Playing, Input::PlayerDied) => GameState::GameOver,
(GameState::GameOver, Input::Enter) => GameState::Menu,
// 其他情况:忽略这个输入,保持原状态
(same, _) => same,
}
}
fn main() {
use Input::*; // 这样就能直接写 Enter,而不用写 Input::Enter
let inputs = [Escape, Enter, Escape, PlayerDied, Escape, PlayerDied, Enter];
let mut state = GameState::Menu;
for input in inputs {
let new_state = next_state(state, input);
println!("{state:?} + {input:?} -> {new_state:?}");
state = new_state;
}
}

输出:

Menu + Escape -> Menu
Menu + Enter -> Playing
Playing + Escape -> Paused
Paused + PlayerDied -> Paused
Paused + Escape -> Playing
Playing + PlayerDied -> GameOver
GameOver + Enter -> Menu
▶ 动手试一试

对元组做匹配,可以把所有允许的状态变化列在同一个地方,而最后一个分支让其他输入都变得无害。暂停中的玩家不会死,因为 (Paused, PlayerDied) 落进了 (same, _)。Bevy 也内置了同样的思路,叫做 States。

  • Rust 是做游戏的一个真实选项:“快了”,有强大的引擎(Bevy)、简单的库(Macroquad、ggez)、带编辑器的选择(Fyrox、Godot + gdext),以及大量积木。
  • 每个游戏都运行着一个循环:输入、更新、绘制。使用增量时间或固定时间步长,让速度不受帧率影响。
  • ECS 把游戏拆成实体(ID)、组件(普通数据)和系统(对拥有特定组件的实体运行的函数)。
  • Bevy 从头到尾都是 ECS。记得加上 opt-level 的 profile 设置,dynamic_linking 只在开发时使用。
  • 做 2D 游戏,Macroquad 上手最快:循环自己写,用简单的函数绘图。
  • 两者都能通过 wasm32-unknown-unknown 目标构建出浏览器版本。

小测验

1. 在 ECS 中,实体是什么?

2. 为什么固定时间步长能让游戏在每台计算机上表现一致?

3. 发布完成的游戏时,不应该带上哪个 Bevy 设置?

4. 你想做第一个小型 2D 游戏,而且希望构建时间很短。这一页推荐哪个 crate?

得分: 0 / 4