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 次下载

MIT 许可证

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组件和某些TransformPosition组件的实体,但只有当这些位置数据组件将它们放置在当前屏幕上的世界位置时。你可以在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 来做到这一点。所有系统都是添加到引擎内部使用的内部事件,以控制系统调用的流程。尽管有更多的事件可用,但大多数应用程序代码将使用 initupdatecleanup 事件。

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>>。我们向玩家添加 TerminalRendererTerminalTransform 组件,因为这些是渲染实体在屏幕上所需的组件。渲染器必须知道你的实体在哪里以及它的外观,以便绘制它!

为了避免任何混淆,请记住,由于我们发出一个命令来创建实体,它不是同步完成的。使用 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

请注意,如果请求的组件在结果中不存在,那么getget_mut都将导致panic。在我们的例子中,我们正在遍历所有匹配项,所以这只会发生在我们搞错了,试图从我们从未查询过的结果中提取组件的情况下!例如,如果我们尝试

let renderer = movable_result.get::<TerminalRenderer>();

应用程序会panic。虽然在我们特定的案例中,匹配的实体也会有一个TerminalRenderer,但我们没有查询该组件,所以它不在我们的结果中。这是一个需要注意的重要细节:当你的查询产生匹配时,它只提供你请求的组件。它给你访问该特定实体上所有组件的权限。

如果你不喜欢getget_mut的确定性,你可以使用它们的更安全的替代方案:try_gettry_get_mut。然而,应该注意的是,从getget_mut中产生的panic表明了你的QuerySystem中存在问题,这应该得到纠正。如果你从这些方法中收到panic,你正在尝试操作你没有查询的东西。在这种情况下,你应该更新你的Query以包含组件,或者纠正System以只操作它查询的东西。由于这种揭示不正确系统/查询的倾向,你通常应该更喜欢使用getget_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的基本知识,但引擎还提供了许多其他功能,可以帮助你快速轻松地制作游戏。例如,我们这里的简单游戏有几个问题

  1. 我们目前将所有逻辑都放入我们的Game创建中。随着游戏的增长,这个文件将变得几乎无法阅读!使用SystemsGenerator将逻辑分解成更易于组织的单独单元是可以实现的。
  2. 我们的运动系统目前对所有具有TerminalTransform(且没有TerminalCamera)的实体操作。在真实游戏中,这不太可能是我们想要的。我们可能只想让玩家移动。你可以尝试调整查询以确保只有玩家被匹配,或者你可以创建自己的自定义Component,使其行为像一个标记,以便更容易编写仅匹配玩家实体的查询。你还可以考虑使用Identity组件。
  3. 如果我们将运动System更改为使用is_key_pressed来让玩家手指休息,一开始一切可能看起来都很好,直到你更改游戏的max_frame_rate。你可能会注意到我们的移动是帧率依赖的。啊!这是什么,黑暗灵魂?我们可能需要创建一个自定义的Component,它跟踪一个Timer,这样我们就可以控制对玩家应用平移的速度。这可能是一个包含在自定义Player组件中的有用信息。

要查看所有这些技术和更多(如UI和碰撞)的实际操作示例,请查看我之前提到的演示!这是一个受太空侵略者启发的简单游戏。

依赖关系

~3–33MB
~438K SLoC