Rust开发游戏三年后吐槽:上下文不灵活


这篇文章是一位游戏开发者关于他们使用 Rust 进行游戏开发的经历和决定停止使用 Rust 的详细阐述。文章中提到了他们对 Rust 语言和其社区的看法,以及他们为什么认为 Rust 不适合他们的游戏开发需求。

以下是文章的一些关键点:

  1. Rust 学习曲线和生产力:作者指出,尽管 Rust 社区经常告诉人们随着经验的增长,语言中的问题将会消失,但作者通过多年的使用和超过10万行代码的编写后发现,许多问题并没有因为经验丰富而消失。
  2. 借用检查器(Borrow Checker):Rust 的借用检查器会在最不方便的时候强制进行代码重构。作者认为这与编写好代码的自然流程相冲突,因为好代码是通过迭代和尝试不同的方法来完成的。
  3. 重构和迭代速度:作者强调,游戏开发是一个不断变化的复杂状态机,需求经常变化。Rust 的静态和过度检查的特性与快速迭代和游戏测试的需求相冲突。
  4. 间接性(Indirection):Rust 语言倾向于通过增加间接层来解决问题,但这通常会以牺牲开发者的舒适性为代价。
  5. ECS(Entity Component System):作者讨论了 ECS 在 Rust 中的使用,指出很多人使用 ECS 是因为它解决了 Rust 借用检查器的问题,而不一定需要 ECS 的性能优势。
  6. 泛型系统和游戏玩法:作者认为,过度的泛型系统会导致游戏玩法变得无聊,因为它们缺乏特定的游戏体验。
  7. 全局状态(Global State):作者批评了 Rust 社区对全局状态的厌恶,认为对于游戏开发来说,全局状态是有用且方便的。
  8. GUI 开发:文章指出 Rust 生态系统中缺乏优秀的游戏 GUI 解决方案。
  9. 编译时间:尽管 Rust 的编译时间有所改善,但作者指出,当涉及到过程宏(procedural macros)时,编译时间仍然是一个问题。
  10. 生态系统和炒作:作者批评了 Rust 游戏开发生态系统,认为它更多是建立在炒作而不是实际发布的项目上。
  11. 积极的方面:尽管文章主要关注了 Rust 的缺点,但作者也提到了一些积极的方面,如 Rust 的性能、枚举的实现、Rust 分析器和特质(traits)系统。
  12. 未来计划:作者提到他们计划将他们自己开发的 Comfy 游戏引擎的渲染器移植到 Macroquad,这是一个更为实用和维护良好的库。

文章最后,作者还提到了他们的新游戏《Unrelaxing Quacks》,这是一个快节奏的生存游戏,他们利用 Rust 能够实现大量敌人和投射物的同时保持高性能。

这篇文章是作者个人对 Rust 在游戏开发中应用的深入反思和经验分享,提供了对 Rust 语言和社区的批判性看法,并且基于他们的开发经验提出了一些建设性的批评和建议。


banq注:以上是国产大模型kimi的总结,但是没有指出本文的真正关键点:


由于 Rust 对程序员有一套相对独特的限制,它最终会产生许多自找的问题,而这些问题的解决方案在其他语言中不一定经常出现。

其中一个例子就是会被传递的上下文对象
在几乎所有其他语言中,以全局变量或单子 Singleton 单例模式 的形式引入全局状态都不是什么大问题。遗憾的是,由于上述种种原因,Rust 使得这种做法变得更加困难。

人们首先会想到的解决方案是 "只需存储对任何需要的引用就可以了",但任何使用过几天 Rust 的人都会意识到这是不可能的。借用检查器需要跟踪每个引用字段的生命周期,而由于生命周期会成为泛型,毒害类型的每个使用点,因此这根本无法轻易进行实验。

在许多情况下我们也不能只是“存储对某物的引用”,因为生命周期是行不通的。

注意:生命周期 其实是 上下文 问题。

Rust 提供的一种替代方法是共享所有权,即 Rc<T> 或 Arc<T>。

  • 这当然也行得通,但却很不受欢迎。
  • 在使用 Rust 一段时间后,我意识到的一件事是,使用这些方法实际上可以让人少走弯路,尽管这需要你不再告诉你的 Rust 朋友你写的代码,或者至少把它隐藏起来,假装它不存在。

遗憾的是,在很多情况下,共享所有权并不是一个好的解决方案,这可能是出于性能方面的考虑,但有时你根本无法控制所有权,只能获得一个引用。

Rust 游戏开发中的第一大诀窍就是 "如果你在每一帧都自上而下地传递引用,那么所有的生命周期/引用问题都会消失"。这实际上非常有效,与 React 自上而下传递道具的做法类似。

只有一个问题,那就是现在你需要把所有东西都传递给每个需要的函数。

起初,这似乎很明显也很简单,只要正确设计代码就不会有任何问题。哈哈,至少很多人都会这么说,特别是 "如果你有这样的问题,说明你的代码很丑/很烂/很差/很细条",或者 "你不应该这么做",你知道,这些都是老生常谈。

幸运的是,我们有一个实际的解决方案,那就是创建一个上下文结构体,该结构体会被传递并包含所有这些引用。这个结构体有一个生命周期,但只有一个,最终看起来像这样:
 

struct Context<'a> {
  player: &'a mut Player,
  camera: &'a mut Camera,
  // ...
}

这样,游戏中的每个函数都可以接受一个简单的 c: &mut Context,并获得所需的内容。

很棒,对吧?

但是,前提限制是:只要你不借用任何东西:
想象一下,你既想运行一个玩家系统,又想保留摄像机(两个上下文都在借用同一个东西):

  • 玩家系统player_system 就像游戏中的所有东西一样,需要 c: &mut Context,因为你希望保持一致,避免传递 10 个不同的参数。
  • 但当你在摄像机上下文尝试这样做时

let cam = c.camera;

player_system(c);

cam.update();

你会得到 "不能借用 c,因为它已经被借用过了 "这样的错误,因为我们已经接触了一个字段,而部分借用规则规定,如果你触碰了一个东西,整个东西都会被借用。

幸运的是,Rust 并不完全是个傻瓜,它允许我们使用 player_system(c.player),因为部分借用允许我们借用不相关的字段。

这时,借用检查程序的维护者就会说,你只是设计错了上下文对象,你应该把它拆分成多个上下文对象,或者根据使用情况对字段进行分组,以便利用部分借用。也许所有摄像机的内容都在一个字段中,所有播放器的内容都在另一个字段中,然后我们只需将该字段传入 player_system 而不是整个 c,这样大家就都满意了,对吗?

不幸的是,这属于本文试图解决的首要问题,即我想要做的是开发我的游戏。我做游戏的目的不是为了享受类型系统带来的乐趣,也不是为了找出组织结构的最佳方法来让编译器满意

重组上下文对象对我的单线程代码的可维护性没有任何好处

我已经做过很多次这样的事情了,我非常确定,下一次当我进行游戏测试并得到新的游戏建议时,我可能不得不再次更改设计。

banq注:上下文感知能力的缺失是很多程序员知识储备中缺少的基础知识。

这里的问题是,代码被修改并不是因为业务逻辑发生了变化,而是因为编译器对基本正确的东西不满意。它可能不符合借用检查器的工作方式,因为它只查看类型,但它是正确的,因为如果我们传递我们正在使用的所有字段,它就可以编译得很好。Rust 让我们在传递 7 个不同的参数或在任何时候重构我们的结构之间做出选择,而这两种选择都是令人讨厌和浪费时间的。

Rust 并没有一个结构类型系统,我们可以说 "一个拥有这些字段的类型",或者任何其他解决这个问题的方法,而无需重新定义结构和所有使用它的东西。它只是迫使程序员去做 "正确 "的事情。

banq注:Rust语言设计者没有搞清楚:普通类型 与 上下文类型 区别