5 个版本
0.2.4 | 2023 年 6 月 26 日 |
---|---|
0.2.3 | 2023 年 6 月 14 日 |
0.2.2 | 2023 年 6 月 14 日 |
0.2.1 | 2023 年 6 月 13 日 |
0.2.0 | 2023 年 6 月 10 日 |
#448 在 游戏开发 中
每月 30 次下载
350KB
7.5K SLoC
用 Rust 编写的“小而强大”的游戏引擎
一个简短的故事
从前,有一位开发者,他经常看到关于一种叫做 Rust 的新语言的好消息。在好奇心达到顶点后,他通读了《Rust 程序设计语言》这本书。他的大脑海绵吸收了美味的知识,他决定需要做一些东西来测试他所学的知识。以前,他曾经制作过游戏,想在业余时间做更多。他的脑细胞扭动了一下,说:“为什么不试试用 Rust 编写一个游戏呢?”
当他随意浏览可用于实现此类项目的技术选项时,他的脑细胞扭动得更厉害,说:“为什么不试试用 Rust 编写一个游戏 引擎 呢?”这激发了他的兴趣。这是全新的,他从未做过这样的事情。他总是使用其他人制作的工具。他认为,这将是一项真正的挑战。
因此,他开始制作一个游戏引擎。对于架构,他借鉴了他对 Unity 的熟悉程度,并按照他们的架构模式了引擎:游戏中的对象由 行为 组成并由其驱动。这些行为包含特定关注点的逻辑和数据。这些行为定义了生命周期方法,引擎的内部机制会在适当的时候调用这些方法。他鼓起勇气,摩擦两个脑细胞以确保它们充分预热,然后开始编码。
几个小时和几天的时间他辛勤劳作,编译器因为他的新手错误而斥责他。他一个接一个地解决了编译器错误,参考《Rust 程序设计语言》书籍,并深入挖掘 Stack Overflow 和 Reddit 提供的知识宝库。经过多次敲击键盘和挠头,他看着自己的工作,发现它... 丑陋。
与C#这样的面向对象语言不同,Rust并不是面向对象的。它不喜欢Rust试图做的事情,因此不得不做一些非常不规矩的事情,才能让编译器允许他的代码通过其挑剔的眼睛。虽然有时会感到沮丧,但他仍然喜欢编译器对正确性的无懈可击的坚持。这迫使他以不同的方式思考,挑战他自己和其他经验丰富的开发者反复看到的模式。Rust不仅告诉他自己在做坏事,而且非常具体地说明了为什么这是错误的。
他用这个引擎制作了一个演示游戏,尽管应用程序代码编写起来很愉快,但他知道底层的东西还有很多改进的空间。
通过对话和研究,他偶然发现了一个在游戏引擎中使用的架构模式,称为ECS(实体-组件-系统)。实际上,他注意到他最初研究的现有的Rust引擎声称遵循这种架构。他深入研究ECS,发现实际上关于如何具体实现这种架构的详细说明非常少。他也不想仅仅阅读现有实现的源代码,担心这些实现可能会过度影响他的工作。他并不想仅仅制作一个更完整解决方案的讽刺品,这不会锻炼他的思维。他决定接受一个新的挑战:根据这种模糊的、通用的架构描述,重写他的小型游戏引擎。他知道他能做到。他拥有这项技术。
他检查了他的主要分支,用代码v0.1.0
标记,然后创建了一个新的分支:overhaul-architecture
。通过几次胜利的键盘敲击,他删除了项目中的所有文件。废弃了。全部都废弃。从头开始。
重新振作起来,他再次开始了实施。他铺设了引擎的基础,让测试驱动开发引导他的路径。由于他更有经验,并且这个新的架构更关注组合和关联而不是继承和共享所有权,编译器的抱怨比以前少得多。他不再试图强迫Rust按照他的规则行事,而是思考如何按照它们的规则行事。随着每一个新添加的功能,他都挑战自己以不同的方式思考。
经过更多小时的工作和一个重新制作的演示游戏,他看着自己的成果,这次他看到了他喜欢它。架构非常模块化。甚至游戏的内核系统,如渲染器、UI和性能分析,都像插件一样自然地嵌入到主要流程中。结构的简单性非常迷人,并使任何添加的方法都变得清晰。他将这个版本命名为v0.2.0
并沉浸于用它制作游戏的喜悦之中。
欢迎来到托马斯,那个能行的小游戏引擎。
简述
托马斯是一个根据ECS(实体-组件-系统)原则架构的Rust游戏引擎。你可以在这里找到一些关于概念的基础文档。这个项目是为了练习Rust而制作的。虽然它打算在所做的工作中表现得很好,但它并不试图成为一个拥有巨大工具包的宏伟引擎,每个人都应该使用。话虽如此,托马斯目前不支持音频,所有渲染都在终端中进行。想想原始的矮人要塞。这是部分原因是因为终端让我花更少的时间研究2D和3D渲染技术,给了我更多的时间专注于我实际上制作这个项目的原因:练习我的Rust技能和深入研究ECS。
在我们深入探讨之前,我想向Sander Mertens致敬,他是Flecs的创造者。正如我在我的小故事中提到的,我避免了查看具体的实现示例,但我发现Mertens的博客文章和文档对理解ECS的一些细节非常有帮助。
与其他ECS解决方案的关键区别
虽然你可能已经看到了其他ECS解决方案,如Bevy和Flecs,但Thomas有一些关键的不同之处。
系统不仅仅是函数
ECS的一个原则是所有游戏逻辑都在系统中发生。系统可以通过对引擎提供的表示游戏世界的内存内部机制运行查询,来访问并知道要操作哪些组件。由于查询与系统紧密相关,Thomas认为查询与系统本质上是紧密相连的,所以你将在Thomas中看到的System
实际上是包含其Query
以及操作这些Query
结果的函数的struct
。这本质上将相关信息放在一起。
查询在运行时评估
虽然这句话脱离上下文似乎很显然,但它与Bevy这样的解决方案形成对比。虽然我对Bevy的类型处理印象深刻,但它的查询是通过类型构建的,这意味着它只能使用编译时可用信息。虽然我们在运行时评估查询会失去一些编译时类型安全性,但我们把获取数据的责任明确地放在了查询的肩上,这包括根据条件过滤潜在匹配项。如果所有查询都是在编译时,你就必须查询比实际需要的更多的信息,然后让你的系统通过它关心的信息来过滤这些结果。
例如,如果我想要编写一个渲染系统,该系统将渲染所有当前在屏幕上的实体?这个系统将需要所有带有某些Renderer
组件和某些Transform
或Position
组件的实体,但只有当这些位置数据组件将它们放置在当前屏幕上的世界位置时。你可以在Thomas中使用Query::has_where
来这样细化你的查询。
赋予查询过滤的能力将责任放在了正确的位置,并让System
更多地关注其逻辑,而不是决定要实际操作哪些结果。虽然乍一看我们似乎牺牲了一些类型安全性,但Thomas鼓励你将系统和查询放在同一位置,这使得意外误用查询结果并导致运行时错误变得困难。
一个简单的例子
在Thomas中,一切从创建一个Game
开始。你可以在游戏实例上链接许多事物,最终在开始主游戏循环之前使用start
。由于Thomas使用ECS,你的游戏基本上只是一个与实体关联的Component
集合,你可以使用System
来操纵这些Component
上的数据。
这个描述和以下示例是ECS环境中预期的极端简化版本。要了解更多关于Thomas中游戏可能的样子,请查看我制作的演示这里。它是一个像太空侵略者一样的简单游戏。
开始游戏
use thomas::{Game, GameOptions, Renderer, TerminalRendererOptions, Dimensions2d};
Game::new(GameOptions {
// You can always press Ctrl+C to stop the game.
press_escape_to_quit: false,
// A value of 0 here indicates an uncapped framerate.
max_frame_rate: 30
}).start(Renderer::Terminal(TerminalRendererOptions {
screen_resolution: Dimensions2d::new(10, 30),
include_default_camera: true,
}));
如果您运行它,您将在终端中得到一个空白屏幕,什么都不会发生。这是因为我们没有在游戏中添加任何东西!我们可以通过向世界添加一个实体来改变这一点,比如代表我们玩家的实体。由于我们的玩家是从游戏开始就应该存在于游戏中的东西,我们可以将玩家添加到世界中作为 init
事件的一部分。我们将通过向 init
事件添加一个 System
来做到这一点。所有系统都是添加到引擎内部使用的内部事件,以控制系统调用的流程。尽管有更多的事件可用,但大多数应用程序代码将使用 init
、update
和 cleanup
事件。
init
系统在主游戏循环开始之前恰好运行 一次。
之后,游戏会在每一帧运行所有 update
系统当主游戏循环收到 GameCommand::Quit
命令时退出,此时会调用所有 cleanup
系统然后进程最终结束。
cleanup
应用于撤销游戏可能引起的任何系统副作用,或者用于退出时做一些事情,比如保存玩家的进度。请注意,如果引擎崩溃,cleanup
不会运行。
将玩家添加到世界
use thomas::{Game, GameOptions, Renderer, TerminalRendererOptions, Dimensions2d, System, GameCommand, Layer, IntCoords2d, TerminalRenderer, TerminalTransform};
Game::new(GameOptions {
press_escape_to_quit: false,
max_frame_rate: 30
})
.add_init_system(System::new(vec![], |_, commands| {
commands.borrow_mut().issue(GameCommand::AddEntity(vec![
Box::new(TerminalRenderer {
display: 'A',
layer: Layer::base(),
foreground_color: None
background_color: None,
}),
Box::new(TerminalTransform {
coords: IntCoords2d::new(1, 2),
}),
]));
}))
.start(Renderer::Terminal(TerminalRendererOptions {
screen_resolution: Dimensions2d::new(10, 30),
include_default_camera: true,
}));
这将为 init
事件添加一个新的 System
。函数 System::new
的第一个参数是我们希望为此系统运行以访问世界中的某些组件的 Query
列表。由于我们不需要访问任何现有组件,所以我们只给它一个空向量。
System::new()
的第二个参数是一个函数,它接受两个参数。我们不需要使用这个系统的第一个参数,所以我们现在不讨论它。
第二个参数提供了对游戏命令队列的访问。当我们想要执行修改世界状态的操作,而不是修改世界中已存在的组件的状态(如添加/修改/销毁实体)时,我们可以发出一个命令来执行该更改。
如您所见,我们使用 GameCommand::AddEntity
来将新的实体放入世界。因为实体在逻辑上只是一个组件的集合,所以我们给 AddEntity
的唯一东西是 Vec<Box<dyn Component>>
。我们向玩家添加 TerminalRenderer
和 TerminalTransform
组件,因为这些是渲染实体在屏幕上所需的组件。渲染器必须知道你的实体在哪里以及它的外观,以便绘制它!
为了避免任何混淆,请记住,由于我们发出一个命令来创建实体,它不是同步完成的。使用 commands.issue()
,你只是在队列中添加一个命令。实际上游戏世界中没有任何改变,直到引擎处理该命令。命令在事件触发之后处理,所以你可以放心,任何发出的命令都会在它们被发出的一帧中处理。
运行后,你应该会看到你的玩家坐在屏幕左上角稍微远离的位置。虽然这很酷,但不够互动,游戏应该是有互动性的!在我们这个小示例的最后部分,让我们向 update
事件添加一个 System
来处理用户输入,以便移动我们的玩家。
处理用户输入以移动玩家
use thomas::{Game, GameOptions, Renderer, TerminalRendererOptions, Dimensions2d, System, GameCommand, Layer, IntCoords2d, Query, Keycode, TerminalCamera, TerminalRenderer, TerminalTransform, Input};
Game::new(GameOptions {
press_escape_to_quit: false,
max_frame_rate: 30
})
.add_init_system(System::new(vec![], |_, commands| {
commands.borrow_mut().issue(GameCommand::AddEntity(vec![
Box::new(TerminalRenderer {
display: 'A',
layer: Layer::base(),
foreground_color: None
background_color: None,
}),
Box::new(TerminalTransform {
coords: IntCoords2d::new(1, 2),
}),
]));
}))
.add_update_system(System::new(vec![
Query::new().has::<TerminalTransform>().has_no::<TerminalCamera>(),
Query::new().has::<Input>(),
], |results, _| {
if let [movables_results, input_results, ..] = &results[..] {
let input = input_results.get_only::<Input>();
for movable_result in movables_results {
let mut transform = movable_result.components().get_mut::<TerminalTransform>();
if input.is_key_down(&Keycode::A) {
transform.coords += IntCoords2d::left();
} else if input.is_key_down(&Keycode::D) {
transform.coords += IntCoords2d::right();
} else if input.is_key_down(&Keycode::W) {
transform.coords += IntCoords2d::down();
} else if input.is_key_down(&Keycode::S) {
transform.coords += IntCoords2d::up();
}
}
}
}))
.start(Renderer::Terminal(TerminalRendererOptions {
screen_resolution: Dimensions2d::new(10, 30),
include_default_camera: true,
}));
哇,看看这个!让我们来分析一下。
vec![
Query::new().has::<TerminalTransform>().has_no::<TerminalCamera>(),
Query::new().has::<Input>(),
]
这定义了我们的查询。这个 System
需要访问世界中的变换,以便我们可以移动它们。现在,你可以忽略 has_no
子句。它是一种排除具有指定组件的实体从查询匹配的方法。
我们的系统还需要访问用户的输入。 Input
是由 Thomas 注入到世界中的一个组件(它还注入了 Time
)。无论何时你需要读取用户的输入,你都可以查询 Input
,并且可以确信你的查询总会得到结果。
现在我们的系统有了将要处理的数据,让我们看看它的逻辑
|results, _| {
if let [movables_results, input_results, ..] = &results[..] [
let input = input_results.get_only::<Input>();
for movable_result in movables_results {
let mut transform = movable_result.get_mut::<TerminalTransform>();
if input.is_key_down(&Keycode::A) {
transform.coords += IntCoords2d::left();
} else if input.is_key_down(&Keycode::D) {
transform.coords += IntCoords2d::right();
} else if input.is_key_down(&Keycode::W) {
transform.coords += IntCoords2d::down();
} else if input.is_key_down(&Keycode::S) {
transform.coords += IntCoords2d::up();
}
}
]
}
现在我们可以看到 System
函数的第一个参数!它是一个 Vec<QueryResultList>
。向量中的每一项代表查询的结果。从我们的解构命名中可以看出,查询的结果保证与我们的查询定义的顺序相同。虽然你可以根据自己的喜好以任何方式获取查询的结果,但我喜欢使用这种解构模式。它使得获取结果的代码简洁且易于阅读。
继续看,我们看到这一行
let input = input_results.get_only::<Input>();
QueryResultList::get_only
是一个方便的方法,非常适合总是返回 exactly one 个结果的查询。在我们的例子中, Input
类似于一个服务。在任何给定的时间,它只应在游戏中有一个实例,并且始终存在。为了省去一些不必要的循环或 find
操作来获取 Input
实例,我们可以直接 get
到唯一的 only
结果,并由 Thomas 从结果匹配的组件列表中提取 Input
。
接下来,我们有
for movable_result in movables_results
在这种情况下,我们的 movables_results
是一个 Vec<QueryResultList>
,其中我们 不 保证总是只有一个结果。当前的查询会产生世界中的所有 TerminalTransform
(只要实体没有 TerminalCamera
)。虽然我们的玩家目前是唯一会匹配此查询的对象,但随着我们的游戏发展并添加更多如NPC之类的对象,这种情况不太可能持续太久。因此,这个 System
应该操作查询的所有结果。
在QueryResultList
中的每个元素都是一个QueryResult
。一个QueryResult
代表一个符合查询中指定约束的单个实体。该QueryResult
还包含了对该实体上我们通过Query
请求的组件实例的引用。在我们的例子中,我们正在遍历世界以查找所有具有TerminalTransform
组件的实体,因此每个结果将代表一个不同的实体,并且每个结果都将有那个实体的TerminalTransform
引用可供使用。要了解这是如何展开的,请尝试在我们的init
系统中添加另一个具有TerminalTransform
组件的实体。
接下来,我们有一行
let mut transform = movable_result.get_mut::<TerminalTransform>();
我们想要获取这个QueryResult
上的TerminalTransform
组件,并且因为我们打算根据用户输入来移动它,所以我们将不得不修改在TerminalTransform
上找到的coords
。因此,我们想要从这个movable_result
中获取一个对TerminalTransform
组件的可变引用。如果我们只想从组件中读取数据,我们可能会使用get
。
请注意,如果请求的组件在结果中不存在,那么get
和get_mut
都将导致panic。在我们的例子中,我们正在遍历所有匹配项,所以这只会发生在我们搞错了,试图从我们从未查询过的结果中提取组件的情况下!例如,如果我们尝试
let renderer = movable_result.get::<TerminalRenderer>();
应用程序会panic。虽然在我们特定的案例中,匹配的实体也会有一个TerminalRenderer
,但我们没有查询该组件,所以它不在我们的结果中。这是一个需要注意的重要细节:当你的查询产生匹配时,它只提供你请求的组件。它不给你访问该特定实体上所有组件的权限。
如果你不喜欢get
和get_mut
的确定性,你可以使用它们的更安全的替代方案:try_get
和try_get_mut
。然而,应该注意的是,从get
和get_mut
中产生的panic表明了你的Query
或System
中存在问题,这应该得到纠正。如果你从这些方法中收到panic,你正在尝试操作你没有查询的东西。在这种情况下,你应该更新你的Query
以包含组件,或者纠正System
以只操作它查询的东西。由于这种揭示不正确系统/查询的倾向,你通常应该更喜欢使用get
和get_mut
。
最后,System
逻辑的核心
if input.is_key_down(&Keycode::A) {
transform.coords += IntCoords2d::left();
} else if input.is_key_down(&Keycode::D) {
transform.coords += IntCoords2d::right();
} else if input.is_key_down(&Keycode::W) {
transform.coords += IntCoords2d::down();
} else if input.is_key_down(&Keycode::S) {
transform.coords += IntCoords2d::up();
}
这很简单,我相信你可以看出这里发生了什么,所以我们只讨论可能不太明显的地方。
is_key_down
告诉我们提供的键是否在本帧按下。这意味着它只会在它被按下的那个帧返回true
。目前,我们的用户必须持续按住移动键才能移动。尝试将其更改为is_key_pressed
以查看其行为有何不同!
你可能不明白为什么按W键会使我们向下
移动,而按S键会使我们向上
移动。在终端中,原点位于左上角。坐标从那里开始增加。因此,在终端中,如果你想向屏幕底部移动,实际上是在坐标上向上移动。向屏幕顶部移动则是向下移动。
顺便说一下,Thomas支持一个TerminalCamera
,因为它有一个TerminalTransform
组件,所以屏幕位置不总是与世界位置相同。实际上,当你说include_default_camera: true
时,你已经告诉Thomas添加默认相机。默认相机位于原点,可以完全查看屏幕。目前,Thomas只支持一个相机,并且必须将其标记为主相机。渲染器要渲染任何内容,必须在世界上包含这样一个相机。包括默认相机为您设置好了这些。
为什么不尝试通过移动相机而不是玩家来进行实验呢?我们之前忽略的has_no
子句正是阻止我们的系统移动相机的原因!尝试调整查询,使其排除玩家的TerminalTransform
并只获取相机的TerminalTransform
。
这就是全部了!这些都是Thomas的基本知识,但引擎还提供了许多其他功能,可以帮助你快速轻松地制作游戏。例如,我们这里的简单游戏有几个问题
- 我们目前将所有逻辑都放入我们的
Game
创建中。随着游戏的增长,这个文件将变得几乎无法阅读!使用SystemsGenerator
将逻辑分解成更易于组织的单独单元是可以实现的。 - 我们的运动系统目前对所有具有
TerminalTransform
(且没有TerminalCamera
)的实体操作。在真实游戏中,这不太可能是我们想要的。我们可能只想让玩家移动。你可以尝试调整查询以确保只有玩家被匹配,或者你可以创建自己的自定义Component
,使其行为像一个标记,以便更容易编写仅匹配玩家实体的查询。你还可以考虑使用Identity
组件。 - 如果我们将运动
System
更改为使用is_key_pressed
来让玩家手指休息,一开始一切可能看起来都很好,直到你更改游戏的max_frame_rate
。你可能会注意到我们的移动是帧率依赖的。啊!这是什么,黑暗灵魂?我们可能需要创建一个自定义的Component
,它跟踪一个Timer
,这样我们就可以控制对玩家应用平移的速度。这可能是一个包含在自定义Player
组件中的有用信息。
要查看所有这些技术和更多(如UI和碰撞)的实际操作示例,请查看我之前提到的演示!这是一个受太空侵略者启发的简单游戏。
依赖关系
~3–33MB
~438K SLoC