Rust 游戏开发:Bevy、Macroquad 等引擎入门
很多人学 Rust,是因为想做一款游戏。这个理由很棒!游戏需要速度,需要谨慎地使用内存,还需要代码别在打 Boss 打到一半的时候崩溃,而这些正好都是 Rust 擅长的事。
这一页会如实告诉你现状:大家已经在用 Rust 做什么,该选哪个引擎或库,以及几乎所有游戏都会用到的两个概念:游戏循环和 ECS。然后你会看到用 Bevy 和 Macroquad 写的第一个程序。
哪些示例能在哪里运行? 游戏循环、迷你 ECS 世界和练习题都是普通的 Rust 代码,所以带有“动手试一试”按钮,可以在 Rust Playground 里运行。Bevy 和 Macroquad 会打开一个窗口,所以这两个示例要在你自己的计算机上用 cargo run 运行。Macroquad 示例在写这一页时实际编译过(也编译过 Web 版本);Bevy 示例参照了 Bevy 的官方示例。
Rust 游戏开发的真实现状
Section titled “Rust 游戏开发的真实现状”社区网站 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,那这里会非常好玩。🦀
选哪个引擎或库?
Section titled “选哪个引擎或库?”工具分两类。引擎会给你一整套结构:游戏对象、场景和资源怎么组织。库(或者叫框架)只提供窗口、绘图、声音和输入,其余部分由你自己组织。
| 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。
几乎每个游戏都是一个大循环,一遍又一遍地运行,直到你退出:
- 读取输入(键盘、鼠标、手柄)。
- 更新游戏:移动物体、检测碰撞、计算分数。
- 绘制新的画面到屏幕上。
循环一次叫做一帧(frame)。在每秒 60 帧(FPS)的情况下,每帧大约有 16 毫秒。但每一帧花的时间并不一样:计算机可能正忙,或者某一帧需要加载文件。
如果你的游戏让玩家“每帧移动 2 个单位”,那它在快的计算机上就跑得快,在慢的计算机上就跑得慢。一个常用的解决办法是固定时间步长(fixed timestep):游戏逻辑总是按相同的步长前进(这里是 10 ms),每一帧经过了多少时间,就执行能装下的那么多步。剩下的时间存进一个累加器(accumulator),留到下一帧。
const STEP_MS: u32 = 10; // 游戏逻辑每次固定前进 10 msconst 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 示例用的就是它。很多引擎两种都用:动画用增量时间,物理用固定步长。
ECS 是什么:实体、组件、系统
Section titled “ECS 是什么:实体、组件、系统”在很多语言里,你会写一个 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 应用
Section titled “你的第一个 Bevy 应用”Bevy(英文)是最知名的 Rust 引擎,里面的一切都是 ECS。0.20 版本在 2026 年 10 月发布。Bevy 需要最新的稳定版 Rust,所以先更新,再创建项目:
rustup updatecargo new my_bevy_gamecd my_bevy_gamecargo 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 编译(和运行)得更快
Section titled “让 Bevy 编译(和运行)得更快”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 重新链接进你的程序:
cargo run --features bevy/dynamic_linkingBevy 指南里有两个提醒:在 Windows 上,dynamic_linking 必须配合上面的 opt-level 设置才能用。而且发布游戏时不要带上它:否则游戏旁边还得附带一个额外的库文件。发布版本请不开这个 feature 来构建。指南里还介绍了怎么换用更快的链接器(lld 或 mold),在 Linux 上效果很明显。
一个 Macroquad 窗口
Section titled “一个 Macroquad 窗口”Macroquad(英文)的感觉完全不同。没有 ECS,也没有 App 构建器:游戏循环你自己写,用 draw_circle 这样简单的函数来绘图。它的依赖很少,所以全新构建只要几秒,而不是几分钟。
cargo new move_the_ballcd move_the_ballcargo 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)。
网页上的游戏(WebAssembly)
Section titled “网页上的游戏(WebAssembly)”Macroquad 和 Bevy 都能把你的游戏构建成 WebAssembly,在浏览器里运行。如果你还不熟悉 Wasm,先读一读 Rust 与 WebAssembly。第一步总是添加 Wasm 编译目标:
rustup target add wasm32-unknown-unknownMacroquad 让这件事非常简单。一条命令就能构建出 .wasm 文件:
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 项目):
cargo install wasm-bindgen-clicargo build --release --target wasm32-unknown-unknownwasm-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 开始。先做完一个,下一个再做得稍微大一点。
1. 用枚举表示游戏状态
Section titled “1. 用枚举表示游戏状态”每个游戏都有几个画面:菜单、游戏本身、暂停画面、“游戏结束”。写一个枚举 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 -> MenuMenu + Enter -> PlayingPlaying + Escape -> PausedPaused + PlayerDied -> PausedPaused + Escape -> PlayingPlaying + PlayerDied -> GameOverGameOver + 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
- Are We Game Yet?(英文):社区整理的 Rust 游戏引擎、物理/音频/网络库,以及用 Rust 做的游戏的总览。
- Bevy Quick Start: Setup(英文):Bevy 官方安装指南,包含加快编译的设置,以及发布版和 Wasm 的 profile。
- Game development in Rust with Macroquad(英文):一本免费的在线书,用 Macroquad 一步步做出一个完整的太空射击游戏。
- The godot-rust book(英文):怎样用 gdext 以 Rust 编写 Godot 4 的游戏逻辑。
- Game Loop — Game Programming Patterns(英文):对游戏循环、增量时间和固定时间步长亲切而详细的讲解。