信息化管理系统 | 数字孪生 · 智慧园区 · 数字大屏 | App · 微信 · 小程序 | 元宇宙 · 区块链 · 3D展厅 | 虚拟仿真系统 | 新零售电商

三年全职 Rust 游戏开发,真要放弃 Rust 吗?(7)

“全局状态因错误原因而令人讨厌,游戏是单线程的”

 

对全局状态的整体“厌恶”是一个光谱,大多数人不会完全反对它。但是在讨论游戏开发时,作者认为这是个错误的方向。

在游戏开发中,许多系统(如音频系统、输入系统、物理世界、渲染器等)通常是唯一的,因此使用全局状态是合理且方便的。在这种情况下,全局状态不仅简化了代码结构,还减少了不必要的参数传递,使得代码更易于管理和理解。

在 Comfy 中很多东西利用了全局状态:

  • play_sound("beep") 用于播放一次性音效。如果需要更多控制,可以使用 play_sound_ex(id: &str, params: PlaySoundParams)
  • texture_id("player") 用于创建一个 TextureHandle 来引用一个资源。没有资源服务器来传递,因为最坏的情况下我可以使用路径作为标识符,而且由于路径是唯一的,显然标识符也将是唯一的。
  • draw_sprite(texture, position, ...)draw_circle(position, radius, color) 用于绘制。由于每个非玩具引擎都会批量绘制调用,所以这些引擎不会比将绘制命令推送到某个队列中更多做什么。

作者批评了一种观点,即游戏开发需要像后端服务那样运行在完全异步的环境中以提高性能。他认为,这种假设忽视了游戏开发的实际需求,即许多游戏逻辑本质上是顺序执行的,不需要复杂的并发控制。因此,Rust在这方面的严格规定有时反而制约了游戏的性能优化和开发效率。

 

他认为 Bevy 将所有事情都是异步的并且在线程池上运行,是 Bevy 最大的错误之一。Bevy 的并行系统模型非常灵活,甚至在帧之间也不保持一致的顺序。如果想要保持顺序,就应该指定一个约束条件。

起初这似乎是合理的,但是在作者多次尝试在 Bevy 中制作一个游戏后(数月的开发时间,数万行代码),最终发生的是用户不得不指定大量的依赖关系,因为游戏中的事情往往需要按照特定的顺序发生,以避免某些东西在运行时被随机延迟一帧,或者更糟糕的是,有时候行为会变得奇怪,因为你得到了 AB 而不是 BA。当你提出这个问题时,你会遭到激烈的反驳,因为 Bevy 的做法在技术上是正确的,但是对于实际制作游戏来说,却是一大堆无意义的仪式。

不幸的是,在整理系统所需的所有工作之后,并没有太多可以并行化的余地。实际上,从中获得的一点点好处将等同于使用 rayon 以数据并行化的方式并行化一个纯粹的数据驱动系统。

回顾多年来的游戏开发,我在 Unity 中使用 Burst/Jobs 编写的并行代码比在 Rust 游戏中实现的要多得多,无论是在 Bevy 中还是在自定义代码中,这仅仅是因为大部分游戏开发工作最终都是游戏本身,剩下足够的精力来解决有趣的问题。而在几乎每个 Rust 项目中,我感觉大部分精力都花在与语言的斗争上,或者围绕语言设计事物,或者至少确保我不会因为某些事情以特定方式完成而失去太多开发人员的舒适性,因为 Rust 要求这样做。在游戏代码中,我们不得不将一些东西包装在 Mutex<T>AtomicRefCell<T> 中,不是为了“避免在写 C++ 时遇到的问题”,而是为了满足编译器对于使所有东西都线程安全的全面渴望,即使整个代码库中没有一个 thread::spawn

 

我完全可以理解作者的心情,至此也明白了他为什么要自己造游戏引擎。他使用 Bevy ,就被强制使用异步 Rust ,尽管他的游戏代码里不需要多线程。他想要更简单的方案,但无奈却“屈服于” Rust 和生态的安全规则。

 

“动态借用检查在重构后导致意外崩溃”

他们在使用 RefCell<T> 时一次又一次地遇到这个问题:两个 .borrow_mut() 重叠导致意外崩溃。

事实是,这些问题并不总是因为"糟糕的代码"。人们会说"尽量借用最短的时间"来解决问题,但这并不是免费的。显然,这又取决于代码的结构方式,但我希望我们已经确定了游戏开发不是服务器开发,代码也不总是组织得最优。有时候,一个循环可能需要使用 RefCell 中的某个东西,将借用延伸到整个循环而不仅仅是需要的地方是很有道理的。如果循环足够大并调用可能在内部需要相同单元的系统,通常还带有一些条件逻辑,这可能会立即导致问题。有人可能会再次争论"只需使用间接引用,并通过事件进行条件处理",但这样一来,我们就要在代码库中分散游戏逻辑,而不仅仅是有 20 行明显可读的代码。

首先,这种内部可变性容器对于游戏开发来说,肯定是常用的。因为游戏的状态经常需要在不同的系统和组件之间共享和修改。

在游戏循环中使用 RefCell 时,一个常见的问题是借用的时间可能比实际需要的时间长。如果在循环中获取 RefCell 的借用,并在整个循环中持有它,那么在循环执行期间,任何尝试修改 RefCell 中数据的操作都将违反借用规则,导致运行时错误。这是因为 RefCell 旨在允许临时的、有条件的可变性,而不是在长时间的逻辑流中持续借用。

虽然也有解决这个问题的方法,比如事件驱动模型。但是作者并不想因此而改变,因为它们确实增加了编程复杂性。通过采用更高级的同步工具和设计清晰的事件架构,可以有效地管理这些复杂性,同时保持代码的安全性和高性能。

 

但是对于作者根本不想考虑什么架构设计的场景,只想快速实现功能而不想为此多浪费脑力,那这必然是个缺点了

“上下文对象的灵活性不够”

 

作者指出,在 Rust 中把全局状态作为上下文传递灵活性不足。

一般来说, Rust 传递上下文有两种方案:传引用引用计数(共享所有权)。

传引用会有个问题,就是可能会引起「级联重构」。比如:

代码语言:javascript

复制

struct Thing<'a>
 x: &'a i32
}

如果我们现在想要一个 fn foo(t: &Thing) ..., Thing 是泛型的生命周期,所以这必须变成 fn foo<'a>(t: &Thing<'a>) 或更糟。如果我们尝试将 Thing 存储在另一个结构体中,现在我们得到的是:

代码语言:javascript

复制

struct Potato<'a>,
 size: f32,
 thing: Thing<'a>,
}

虽然 Potato 可能并不真正关心 Thing ,但在 Rust 中,生命周期是非常严肃的,我们不能忽视它们。事实上,情况比看起来更糟糕,因为假设你最终选择了这条路,并尝试使用生命周期来解决问题。

当你随后修改了 Thing,去掉了 'a,你不得不也得修改 Potato。这就是级联重构。作者表示,这是最令他恼火的事情之一,尝试对生命周期进行非常简单的更改,却被迫在每次更改时更改 10 个不同的地方。

另外一个问题是,从上至下会引发借用检查问题。

代码语言:javascript

复制

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

但是,当您想运行一个玩家系统,但同时也想保留相机:

代码语言:javascript

复制

let cam = c.camera;

player_system(c);

cam.update();

你会得到一个借用检查错误:“无法借用 c ,因为它已经被借用”。

当然,这有很多解决办法。但是,作者并不想用这些办法。

我不是为了享受类型系统和找出最佳的结构组织方式以让编译器满意而制作游戏。

而另一个共享所有权的方法,作者也认为只是一个糟糕的解决方案,可能是出于性能原因,但有时你只能控制引用而无法控制所有权。

 

Rust 的优点

 

尽管文章的其他部分作者对 Rust 表达了一些批评,但他也给出了 Rust 的优点(否则他也不会使用三年):

  1. “编译即正确”:Rust 的编译器在代码正确性上提供了极大的保证。作者经验表明,只要代码能通过 Rust 的编译,那么代码在运行时通常能按预期工作。这种“编译器驱动开发”的方式在 Rust 中表现尤为突出,有助于开发者避免许多常见的编程错误。
  2. 命令行工具和数据处理:Rust 在构建命令行工具、处理数据和算法实现等方面表现出色。作者通过将通常使用 Python 或 Bash 完成的任务用 Rust 来实现,发现 Rust 不仅可以胜任,而且经常带来意外的好处。
  3. 默认高性能:与 C# 相比,Rust 在性能方面具有明显优势。尽管 C# 的性能可以通过优化得到提升,但在作者的对比测试中,Rust 的性能通常更优。这一点在具体算法实现的性能对比中尤为明显,Rust 代码通常能自然而然地达到更高的执行效率。
  4. 枚举和模式匹配:Rust 中的枚举(Enums)和模式匹配是作者特别欣赏的功能。在适用的场景下,枚举和模式匹配为代码提供了清晰的结构和强类型的安全性,这在作者使用过的语言中是最喜欢的实现之一。
  5. Rust Analyzer:Rust Analyzer 是一个极其有用的工具,尽管有时会遇到问题,但它显著改善了 Rust 的编码体验。从 Rust 的早期版本到现在,语言周边的工具已经有了长足的进步,极大地促进了开发效率。
  6. trait:虽然 Rust 不支持传统的面向对象继承,但其特质(trait)系统提供了一种灵活且强大的方法来实现接口和行为的多态。trait 系统适合 Rust 的设计哲学,尽管存在孤儿规则的限制,但扩展特性仍是 Rust 中作者最喜欢的特性之一。

 

如有侵权请通知删除,谢谢!

本文转自;三年全职 Rust 游戏开发,真要放弃 Rust 吗?-腾讯云开发者社区-腾讯云 (tencent.com)