近日,一篇《3 年全职 Rust 游戏开发后的经验教训,以及为什么我们要放弃 Rust》[1] 的文章引爆社区。
=============================================================
“
说明:本文很长,但值得你一看。本文不是原文的逐字翻译,而是一种解读,以及思考和汇总社区中对此文的评价。
众所周知,有多少人爱 Rust 就有多少人恨 Rust。
在网上看到了一个两年前的评论,这件事好像也印证了他的说法,他是不是会偷笑自己的「神预言」 呢?
然而,先上结论:
只要认真看完这篇文章就会发现,该文其实是标题党,他们应该只是想寻求帮助,并不是真的想放弃 Rust,他只是放弃了用 Rust 开发游戏,Rust 游戏引擎应该还会维护。
文章作者罗列了 Rust 在游戏开发领域的局限性,这对 Rust 语言技术选型有很大参考意义。
作者强调,对于技术热情没有错,但认为人们应该非常谨慎地考虑自己的实际目标,并且最重要的是要诚实地说明自己为什么要做自己正在做的事情。而不是只是为了技术本身而做这个。
另外,需要说明的是,本文讨论的游戏开发范畴是:由个人或小团队制作且预算和时间表相对紧张的独立游戏。
这篇文章的作者是一个名为 LogLogGame 的游戏团队成员之一,该团队成员只有两人,严格来说算是独立开发者。
他们用 Rust 开发了游戏引擎 darthdeus/comfy[2] 。说实话,如果不是这篇文章,我还不知道有这个引擎 (我从 2018 年开始基本每天都观察 Rust 在各个生态领域的工具和应用,这个仓库我昨天点过去竟然没有 star 过)。我认为他们并不是想真正放弃 Rust ,而是确实遇到困难了,想寻求帮助。可能是之前的社区宣传并不到位,所以想以这篇文章来吸引社区注意力。并非我揣摩作者的动机,他们在文章里也说明了,写这篇文章的目的之一,也是为了筹集资金来支持他们继续研发。去 Comfy 的仓库看看,今天还在更新。
事实上,文章除了标题里包含了“Leving Rust” 之外,整篇内容完全没有提到过他要放弃 Rust。他只是说以后不用 Rust 写游戏了,也许游戏引擎还是会维护的。
另外,他们在文章结尾还宣传了自己的新游戏:「《Unrelaxing Quacks》[3]是一款幸存者游戏,但速度很快。并且赞叹:“多亏了 Rust,让它成功地拥有了大量的敌人和抛射物,同时还能保持出色的性能”」。毕竟,这也是他们花一年多开发的心血。
我很好奇,这三年,他们是如何采用 Rust 呢? 文章的总结思考部分给出了答案,所以总结来说就是:
他们花了一年时间用 godot-rust 来实现了第一款上架 steam 的独立游戏 BITGUN[4] ,
然后他们沉迷于用 Rust 实现游戏引擎 Comfy 并完善,大约又花了一年
实现最新游戏《Unrelaxing Quacks》花了一年,可想而知,在实现这个游戏的过程肯定也进一步完善了 Comfy 引擎。因为 Comfy 是八个月之前才开源的。
“
Comfy 是一个使用 Rust 构建的有趣的 2D 游戏引擎,它使用
wgpu
和winit
,使其跨平台,目前支持 Windows、Linux、MacOS 和 WASM。受到 macroquad、Raylib、Love2D 等许多其他引擎的启发,它被设计为能够正常工作并满足大多数常见用例。但 API 尚不稳定,可能会发生重大变化。如果您想在游戏中使用 comfy,可能需要深入源代码并可能手动调整一些内容。
这让我想到了 Rust 另一个游戏引擎是 Bevy ,先不比较两个引擎自身的优劣,先从两个引擎的商业模式来看。
LogLogGame 独立游戏团队是为了实现自己的游戏,而去实现了自己的游戏引擎。然而他们还得靠自己的游戏来支持自己的研发,重心其实还在游戏上。
Bevy ,是专注于开源游戏引擎,让广大独立开发者去使用,在反馈中发展。
我感觉 LogLogGame 想做的太多了,如果他们一开始就基于 godot-rust 或 bevy 来实现他们的游戏,而不把精力放到自己实现游戏引擎上面,状况会不会好点呢?这也不一定(文章后面给出了原因),但或许能给我们一些启示,找准自己的专注力方向可能更好一些。
也许作者也意识到了这个问题,他在文章里表示,将不会再用 Rust 开发游戏,但是新游戏发布以后,还会做 Comfy 引擎的渲染器迁移工作(如果我没有理解错的话)。
“
作者原话:I do however plan to resume working on this after the release, and considering basic things already work there, I'm hopeful the port shouldn't end up being too complicated.
所以,我也认同。如果确实是预算和时间表都相对紧张的独立游戏开发,那使用自己擅长且成熟的工具快速完成游戏必然是第一需求,而不是把有限的精力用在和开发工具的磨合上。
作者罗列了他在三年 Rust 游戏开发中总结的几条教训,我认为非常有见地。这几条教训也适合给想在生产环境引入 Rust 的团队作为技术选型参考。
作者提到了一个他在社区里遇到的问题,就是当他遇到各种问题时,Rust 社区很多人都会说:“一旦你精通 Rust,所有这些问题都会消失”。
作者提到,“这句话好像是在 Rust 社区中存在一股压倒性的力量,当有人提到他们在 Rust 语言的基本层面上遇到问题时,答案是“你只是还没理解,我保证一旦你变得足够好,一切都会变得清晰明了”。这不仅仅是在 Rust 中如此,如果你尝试使用 ECS
,你会得到同样的回答。如果你尝试使用 Bevy
,你也会得到同样的回答。如果你尝试使用任何框架来制作 GUI
(无论是响应式解决方案还是即时模式),你也会得到同样的回答”。
这句话的言外之意是指,“你遇到这种问题,是因为你学艺不精。”这在其他语言社区,可能听上去好像不太礼貌。但是在 Rust 社区,这样说是有原因的。
因为 Rust 编译器类型检查和所有权借用检查等机制的存在,会强迫开发者在遇到这类问题时,去反思自己的代码架构。Rust 不像其他语言那般让开发者随心所欲,这是一种限制。所以开发者经常可能会遇到「编译器强制重构」的时刻。
编译器强制重构,对于提升代码质量和系统安全来说,是一个优点。这个优点使得 Rust 非常适合开发大型的、安全关键的、想长期稳定发展的软件,比如基础设施类软件,以及一些想长期稳定发展的大型应用。这意味着,开发者必须得对他写的每一行代码负责。
但是对于小型游戏来说,特点是,“代码写完即扔“,因为以后还可以写个更好的,不会长期维护。只要功能达到要求就行了,玩家能玩就行了,代码质量就算是一坨翔也无所谓。所以,Rust 编译器强制重构的特性,在这种场景下,对开发者来说就很难受了。因为你明明知道“那坨翔”后面没啥用,你还不得不重构它。
所以,这其实是个技术选型问题,而非一个语言之争问题。
self
造成的问题”所以,综上所述, Rust 最大的优势之一是易于重构。这是大家都认可的优势。
然而事物总是蕴含两面性的。语言特性好不好,得结合具体应用场景。这就是选型的本质。
应用和游戏有很大的不同。引用网友一句话,“游戏引擎需要管理极大量的状态和状态变化(这是需求,不是设计)”。
作者也说了,“游戏作为复杂的状态机存在一个根本性问题,要求经常变化。在 Rust 中编写 CLI 或服务器 API 与编写独立游戏完全不同。假设目标是为玩家构建良好的体验,而不是一组惰性的通用系统,需求可能会在人们玩游戏后的每一天都发生变化,你会意识到一些事情需要从根本上改变。Rust 静态和过度检查的特性直接与此相抗衡”。
很多时候,我们用 Rust 编写应用代码时,如果遇到借用检查问题,就说明我们的代码中存在「悬垂指针」的风险。这时候确实是需要重构或修复。这是 Rust 的安全保证。
然而,作者说,他就是“不想要好的代码”,他只想要“更快的游戏”,“更快的测试他的想法”。但是编译器借用检查强迫他重构代码。作者认为,对于独立游戏来说,可维护性并不是一个正确的价值观。因为独立游戏开发者应该追求的是游戏迭代的速度。而其他编程语言可以更轻松地解决这类问题,而不必牺牲代码质量。
我虽然认同他这个观点,但是独立游戏也分种类吧。如果是那种区块链游戏呢?涉及金钱利益的场景,代码质量真的没关系吗?
作者说,Rust 非常喜欢并且经常有效的一种基本解决方案是添加一层间接性。
我认为这不应该算是 Rust 特有的吧?不是有句计算机名言吗 :“计算机科学中的每个问题都可以用一间接层解决”。
Rust 借用检查器的许多问题可以通过间接地做一些事情来简单地解决。比如通过 Copy/Move
某些内容,或者通过将其存储在命令缓冲区中,然后稍后执行。
作者举了两个例子用来说明 Rust 为了解决这类问题引入了一些有趣的设计模式:
World::reserve in `hecs`[5],hecs
库中的 reserve_entities
和 reserve_entity
函数允许在 ECS (Entity Component System) 框架中预分配实体 ID,这种设计可以很好地与 Rust 的生命周期和并发模型协作。只是预留了实体的 ID,并没有立即创建实体。这意味着这些实体在预留阶段不会参与任何查询或世界迭代,直到它们通过如 insert
或 despawn
等操作显式地转换为“实际”实体。这种延迟初始化的模式有助于减少生命周期冲突,因为它允许更灵活的控制何时将数据(如组件)与实体 ID 关联。
get2_mut in `thunderdome`[6] ,可以从同一个集合中一次获取两个可变借用。这在 Rust 基本规则里是违反借用规则的操作,但是这个库用设计模式巧妙实现了。Thunderdome
库的设计灵感来自于 generational-arena
、slotmap
和 slab
等库,它是一个 Arena(竞技场)数据结构的实现。你可以理解为 Arean 算是一种类 “GC”的实现。
虽然通过这种设计模式也能解决问题,但这个门槛确实比较高,这也是 Rust 学习曲线高的原因。但是,这并不是作者想要强调的重点。
作者认为,很多时候会遇到无法用专门设计和深思熟虑的库函数解决的情况。这就是为什么很多人会建议用命令缓冲区或事件队列来解决问题,这种方法确实有效。
“
游戏特别之处在于我们经常关心相互关联的事件、特定的时间点,以及整体上同时管理大量的状态。将数据在事件边界之间传递意味着代码逻辑会突然分成两部分,即使业务逻辑可能是“一个块”,但在认知上必须将其视为两个部分。足够长时间在社区中待过的人都有过这样的经历,被告知这其实是一件好事,关注点分离,代码更加"干净"等等。你看,Rust 的设计非常聪明,如果某件事情做不到,那是因为设计有问题,它只是想强迫你走上正确的道路...对吗?在 C# 中只需要 3 行代码的事情,在 Rust 中突然变成了分散在两个地方的 30 行代码。最典型的例子就是像这样的情况:"当我遍历这个查询时,我想要检查另一个对象上的一个组件,并且触发一系列相关的系统"(生成粒子、播放音频等)。我已经听到有人告诉我,嗯,这显然是一个
Event
,你不应该将那段代码写在一行内。如果你在想“但这不会扩展”或“它可能在后面崩溃”或“你不能假设全球世界因为 XYZ”或“如果是多人游戏怎么办”或“这只是糟糕的代码”...我明白你的意思。但是在你向我解释我错了的时候,我已经完成了我的功能实现并继续前进。我一次性编写代码而不考虑代码本身,当我编写代码时,我在思考我正在实现的游戏功能以及它对玩家的影响。我没有考虑“在这里获取一个随机生成器的正确方法是什么”或“我可以假设这是单线程的吗”或“我是否在嵌套查询中,如果我的原型重叠会怎样”,而且之后我也没有得到编译器错误,也没有运行时借用检查器崩溃。我在一个愚蠢的语言和愚蠢的引擎中使用,并且在编写代码的整个过程中只考虑游戏本身。
作者想表达的其实很简单,就是 Rust 限制了他在游戏开发中的自由发挥,因为他不需要代码质量(前提是使用 Rust)。如果换成其他语言,比如C/Cpp/ Go/Java / Python /Ruby 等他就不会担心这种问题,因为他可以随心所欲。
他说的很有道理,如果你的场景跟他一样,那确实不用 Rust 最好。应该快速用现有的成熟框架和脚本语言推出游戏或产品,验证想法,收获用户,而非和 Rust 编译器做斗争。
hecs
里预留实体 ID 的机制类似于对象池模式;reserve_entities
和 reserve_entity
函数提供了一种机制来生成实体 ID,这可以视为一种延迟的工厂模式;
Thunderdome
库中 Index
结构体:
``/// Index type for [`Arena`] that has a generation attached to it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Index { pub(crate) slot: u32, pub(crate) generation: Generation, } ``
slot
(槽位)用于索引内部的数组,而 generation
(世代)用于验证引用的有效性。当一个元素被移除后,其 slot
可能被新的元素重用,但是新元素会有一个递增的 generation
号,以此来避免旧引用意外访问新数据。
`pub fn get2_mut(&mut self, index1: Index, index2: Index) -> (Option<&mut T>, Option<&mut T>) { // 首先检查两个索引是否相同,如果相同则抛出 panic,因为不能从同一个资源获取两个可变引用 if index1 == index2 { panic!("Arena::get2_mut is called with two identical indices"); } // 处理索引指向相同槽位但属于不同世代的情况 if index1.slot == index2.slot { // 借用检查器的限制使我们必须两次访问存储以获取正确的返回值 // 如果第一个索引有效,则返回第一个元素的可变引用和 None if self.get(index1).is_some() { return (self.get_mut(index1), None); } else { // 如果第一个索引无效,则返回 None 和第二个元素的可变引用 return (None, self.get_mut(index2)); } } // 如果索引指向不同的槽位,则可以安全地分割存储来获取两个独立的可变引用 let (entry1, entry2) = if index1.slot > index2.slot { // 如果 index1 的槽位大于 index2,则先分割这部分,确保每部分分别被独立借用 let (slice1, slice2) = self.storage.split_at_mut(index1.slot as usize); (slice2.get_mut(0), slice1.get_mut(index2.slot as usize)) } else { // 如果 index2 的槽位大于 index1,则先分割这部分 let (slice1, slice2) = self.storage.split_at_mut(index2.slot as usize); (slice1.get_mut(index1.slot as usize), slice2.get_mut(0)) }; // 通过索引的世代号来获取每个槽位的有效值 // 只有当索引中的世代号与存储中对应槽位的世代号匹配时,引用才被视为有效 ( entry1.and_then(|e| e.get_value_mut(index1.generation)), entry2.and_then(|e| e.get_value_mut(index2.generation)), ) } `
这个方法的主要步骤是:
检查索引是否相同
处理相同 slot 但不同 generation 的索引
分割存储来安全获取引用
通过 generation 号验证实体的有效性
此方法中的设计模式主要是:
安全分割:通过 split_at_mut
安全地分割存储区,从而允许同时独立地访问两部分数据。
条件验证:通过验证索引的 generation
号来确保数据的有效性和一致性。
所以本质上还是没有违反 Rust 借用检查规则,真正能返回两个可变借用的情况只存在:两个给定的索引指向不同槽位,并且这两个索引都有效时。
作者说,由于 Rust 的类型系统和借用检查器的工作方式,ECS 成为了“我们如何让东西引用其他东西”的问题的自然解决方案。
但其实,ECS 架构并非 Rust 独创。早在多年前,暴雪《守望先锋》就使用了 ECS 架构[7]。只不过 Rust 的类型系统和借用检查器特别适合实现这种架构,所以在 Rust 生态中比较流行 ECS 架构。
ECS(Entity Component System)是一种常用于游戏开发和高性能计算应用的架构模式,它通过将数据(组件)和行为(系统)从实体中分离出来,使得数据处理更为高效、灵活。
在传统的面向对象编程中,对象间常常通过引用或指针相互关联,这会引入复杂的生命周期管理问题和潜在的内存安全风险。ECS 通过以下方式简化了这些问题:
组件存储:在 ECS 中,组件是独立存储的,并且通常不直接引用其他组件。相反,它们可能包含指向其他实体或组件的标识符(如实体 ID)。这种方法避免了直接引用,简化了生命周期管理,因为组件的添加和删除是独立处理的。
实体和组件的解耦:实体在 ECS 中通常作为一个轻量级的标识符存在,它本身并不持有数据。所有的数据都是通过组件来表示的,这些组件被组件管理器以一种高效的方式存储和处理。这种解耦确保了在实体生命周期结束时,可以简单地清理其所有组件,而不用担心传统意义上复杂的对象图清理问题。
系统的独立操作:每个系统都独立操作一组特定的组件,这样的设计减少了对共享数据的需求,降低了复杂度和出错的可能。系统间通信通常通过共享的资源或通过事件来进行,这些机制都可以在 Rust 的安全模型下高效实现。
作者也认同 ECS 架构的优势。他的重点是,他认为社区把 ECS 滥用了。
作者列举了三个问题:
具有实际指针的指针型数据。问题很简单,如果字符 A 跟随字符 B,而 B 被删除(并被释放),那么指针将无效。
Rc<RefCell<T>>
结合弱指针。虽然这样可能可行,但在游戏中性能很重要,由于内存局部性,这些的开销是非常大的。
对实体数组进行索引。在第一种情况下,我们会得到一个无效的指针,而在这种情况下,如果我们有一个索引并且移除一个元素,索引可能仍然有效,但指向其他东西。
这些问题,作者用前面提到的 Thunderdome
库解决了,并且他非常推荐这个库。
但是他认为,社区里大多数人认为 ECS 的好处实际上是 generational arena (Thunderdome)的好处。当人们说“ECS 给我带来了很好的内存局部性”,但他们只查询类似于 Query<Mob, Transform, Health, Weapon>
的移动对象时,他们实际上做的基本上相当于 Arena<Mob>
:
`struct Mob { typ: MobType, transform: Transform, health: Health, weapon: Weapon } `
所以,作者认为,有的时候你要分清自己可能仅仅需要一个 generational arena ,而非 ECS 。很多时候人们使用 ECS 是因为它解决了“我应该把我的对象放在哪里”的特定问题,而不是真正使用它进行组合,也不真正需要它的性能。这没有错,只是当这些人最终在互联网上与其他人争论,试图说服其他人他们的做事方式是错误的,并且他们应该按照上述原因使用 ECS 的某种方式,而实际上他们一开始并不需要它时,就会出现问题。
文章讨论了几种不同的看待 ECS 的角度。
ECS 允许开发者将不同的组件(数据单元)动态地组合到实体(游戏中的对象或角色)上。这种方式非常灵活,可以根据游戏逻辑的需要在运行时添加、移除或修改组件。例如,一个游戏角色(实体)可以具备位置(Transform 组件)、健康状态(Health 组件)、以及武器装备(Weapon 组件)。这种动态的组合使得开发者可以创建复杂且多变的游戏逻辑,同时保持代码的模块性和可维护性。
在 ECS 中,组件通常按类型存储在紧凑的数组中,这种存储方式称为 "结构化的数组"(也有时借用数据术语称作 "数据的数组",Data-Oriented Design)。这样做可以显著提高内存的局部性,因为相关的数据存储在内存中的位置更加靠近,CPU 在访问这些数据时可以更有效地利用缓存。例如,如果系统需要处理所有实体的健康状态,它可以连续地访问存储所有健康组件的数组,而不是跳转到分散存储的对象中去找健康数据。这种方法尤其适合于需要频繁处理大量数据的场景,如物理模拟或复杂的游戏 AI 计算。
ECS 的另一个重要优势是它能够简化 Rust 借用检查器的管理。Rust 的借用检查器确保了内存安全和数据访问的正确性,但在传统的面向对象编程中,管理复杂的对象和生命周期关系可能变得非常困难。使用 ECS,开发者可以通过将数据和行为分离,更容易地符合 Rust 的借用规则,从而简化开发。实体在 ECS 中通常是轻量级的标识符,组件和系统则是独立的,这使得跨系统的数据访问可以在不违反借用规则的情况下进行。
在 ECS 架构中,实体通常由一组组件构成,每个组件都可能存储在一个 generational arena 中。这种结构允许系统以非常高效的方式添加、删除和修改组件,同时确保引用的有效性和安全性。动态创建的 generational arenas 指的是这样一种系统:不仅数据是动态管理的,而且数据容器(即 arenas)本身也可以根据需要动态创建和配置。
这部分是作者开玩笑。因为 Bevy 在 Rust 社区算是 ECS 的代表,而且 Bevy 与 ECS 绑定很深,包括 UI 都用 Bevy。但作者也指出,虽然他可能在很多事情上持不同意见,但很难否认 Bevy 对 ECS API 和 ECS 本身的人体工效学改进。
另外作者也提到了 Unity DOTS,它本质上是他们的“ECS”(以及其他面向数据的东西)。作者认为在 Unity 领域中不会找到一个人会认为 DOTS 是一个不应该存在的糟糕功能;但也不认为有人认为 DOTS 就是未来的全部,游戏对象应该从存在中被抹去,所有的 Unity 都应该转移到 DOTS。
那些使用过 Godot 的人可能会看到一个类似的观点。特别是那些使用 gdnative
(例如通过 godot-rust
)的人,尽管节点树可能不是适用于所有情况的最佳数据结构,但对于许多事情来说,它们确实非常方便。
但是 Rust 社区里,作者认为 Bevy 的 “ECS everything” 理念,给开发者带来了不便。一个明显的例子,也是作者认为 Bevy 的一个重大失败点,是 Bevy 的 UI 系统,这已经是一个痛点了一段时间,特别是与“我们今年一定会开始开发编辑器!”这类承诺相结合。
“
ECS 在 Rust 社区从在其他语言中视为的工具变成了几乎是一种“宗教信仰”:应该使用它,因为它是纯粹和正确的,因为这样做是正确的方式。
Rust 常常感觉就像与青少年讨论他们对任何事情的偏好一样。他们表达的往往是非常强烈的观点,而不是很多细微差别。编程是一项非常微妙的活动,人们经常不得不做出次优选择以及及时地得到结果。在 Rust 生态系统中,完美主义和对“正确方式”的迷恋常常让我觉得这门语言吸引了那些对编程较新且容易受影响的人。
其实在 Rust 社区 ECS 并不仅仅用于实现游戏,比如,在可视化系统 rerun[8] 中也应用了 ECS 架构。
因此,不要把 ECS 当作万能灵药。
作者罗列了他认为能够创造出好游戏的一些因素:
大多数关卡流程应该是手工设计的。这并不意味着“线性”或“故事”,但确实意味着“对玩家何时看到什么有很多控制”。
在各个关卡中精心设计的个性互动。
VFX 不是基于有很多相同的粒子,而是时间同步的事件(例如,多个不同的发射器按手工设计的时间表触发)在所有游戏系统中工作。
通过多次迭代的游戏玩法测试、实验和丢弃不起作用的内容。
尽快将游戏发布给玩家,以便对其进行测试和迭代。如果没人看到它,当它发布时,没人关心的机会就越大。
独特而难忘的体验。
作者认为,游戏开发的本质不是建造物理模拟,不是建造渲染器,不是构建游戏引擎,不是设计场景树,也不是设计具有数据绑定的反应式UI,而是仔细思考玩家互动并设计它们。
这里的一个好的游戏示例是《以撒的结合》,这是一个非常简单的肉鸽(roguelike)游戏,拥有数百种可以以复杂、互动和深入的方式修改游戏的升级。这是一个拥有许多系统相互作用的游戏,但它也完全不是通用的。这不是一个拥有500种“+15%伤害”的升级的游戏,而是许多升级是“炸弹粘在敌人身上”或“你射出激光而不是弹药”或“你每级杀死的第一个敌人将不再出现”。
你不是通过在地下室里坐一年,思考所有边缘情况并建立一个通用系统,然后 PCG所有升级来制作一个好游戏。你是通过构建一些简单机制的原型并让人们玩它,看看核心内容是否有效,然后再添加更多东西让人们再玩一次。其中一些互动必须通过在游戏中玩了许多小时、尝试了许多不同的事情后对游戏的深入了解来发现。
《Thronefall》作者 Jonas Tyroller 在他关于游戏设计的视频[9]中非常好地解释了这一点:“一个好的游戏不是在实验室中精心设计的,而是由一个精通该类型的大师级玩家兼开发者制作的,他了解设计的每个方面,并在达到最终设计之前尝试过许多失败的尝试。一个好的游戏是通过摒弃许多糟糕的想法,通过非线性的过程来制作的”。
“
1人开发、Steam好评97%,这款“超简化”RTS塔防游戏赢得满堂彩!由GrizzlyGames 工作室发布的独立游戏《Thronefall》,自2023年8月2日抢先体验版上线Steam后,游戏最高在线人数曾一度达到6723,近期同时在线人数依然维持在3000以上。游戏发布至今已有八千多条玩家评测,其中高达97%为好评,足以见得玩家对《Thronefall》的高度认可。
一个更灵活的语言会允许游戏开发者立即以一种粗糙的方式实现新功能,然后玩游戏,测试它并查看这个功能是否真正有趣,可能在短时间内做这些迭代。而当Rust开发者完成他们的重构时,C++/C#/Java/JavaScript
开发者已经实现了许多不同的游戏玩法功能,玩了很多游戏并尝试了所有这些功能,对他们的游戏应该朝哪个方向发展有了更好的理解。
作者认为,Rust 社区已经采纳了这种对 Rust 相关事物的无情积极性和赞美的观念,完全将自己与外界隔离了开来。真实的游戏世界并不那么友好。Steam 上的玩家并不在乎某个东西是用 Rust 制作的,他们也不在乎它花了多长时间制作,他们也不在乎代码是否开源。他们关心的是看游戏,并在几秒钟内能够判断这是否会是浪费时间,或者是一些潜在有趣的东西。玩家不关心开发者,只是在几秒钟内看游戏是正确和可取的,但至少这让我们保持诚实。这使得游戏只关注游戏本身,而不关注其他任何事情,因为最终,游戏和玩游戏的体验才是最重要的。
这其实跟 Rust 社区没有关系,任何游戏或应用、产品的用户都不会在意它是不是 Rust 实现的。只不过作者作为一名 Rust 开发者,自身在这个圈子里。
代码质量是保证用户体验的一个因素,只要不是过度追求代码质量即可。
作者显然也明白,他说他当然同意这个观点,当有人按下播放按钮时游戏崩溃,或当你损坏存档文件并且玩家失去进度时,这绝对是影响玩家体验的。
但他认为所有这些都完全忽略了对玩家来说重要的事情。有很多情况下,人们的进度被清零,但他们仍然会回到游戏中并再次玩它,因为游戏太好了。作为玩家,他已经做过这种事情不止一次。
“
前提得是 「好游戏」吧?
作者的意思很明确:应该专注于好游戏,而非好代码。因为 Rust 的强制重构,导致他把精力耗费在实现「好代码」上,而非「好游戏」上。
游戏开发领域需要很多“动态方法”,Rust 没有这种“脚本语言”能力。尤其是在关卡编辑、工具和调试方面,这变得尤为痛苦。
“
其实,C/Cpp 也没有啊,所以结合 lua 之类的脚本语言比较常见。我一直在想,社区也许需要一门和 Rust 语法相同且和 Rust 交互的脚本语言。
Rust 中有过程宏。但是作者认为过程宏基本上允许程序员在编译时运行代码,消耗 Rust 的 AST,并生成新的代码。不幸的是,这种方法存在许多问题。
“
首先,过程宏并没有真正缓存,而是在重新编译时重新运行。这导致你的代码必须分割成多个 crate,这并不总是可行的,而且如果你更加依赖过程宏,编译时间会大大增加。有很多方便的过程宏,比如
profiling
的function
宏,非常有用,但最终无法使用,因为它们破坏了增量构建时间。其次,过程宏非常难以编写,大多数人最终使用非常庞大的辅助 crate,比如syn
,它是一个非常庞大的 Rust 解析器,会急切地评估应用于它的所有内容。例如,如果你想在宏中注释一个函数并解析它的名称,syn
最终会解析整个函数体。还有一种情况,syn
的作者也是serde
的作者,这是一个流行的 Rust 序列化库。去年的某个时候,该库在一个补丁版本中开始附带一个二进制文件,拒绝了社区的反对声音。这并不是反对 Rust 的案例,但我觉得应该提到,因为它展示了生态系统的很大一部分是由单个开发者制作的库构建的,这些开发者可能会做出潜在危险的决策。当然,这种情况在任何语言中都可能发生,但在过程宏方面尤为重要,因为生态系统中几乎所有的东西都使用了这个特定作者的库(syn
,serde
,anyhow
,thiserror
,quote
,...)。即使忽略上述情况,过程宏的学习曲线非常陡峭,并且它们必须在一个单独的 crate 中定义。这意味着与声明式宏不同,你不能轻松地创建一个新的过程宏,就像创建一个函数一样。相比之下,在 C# 中使用反射非常容易,如果性能不是问题(在使用反射的情况下通常不是问题),它可以是构建工具或调试的一种非常快速和有用的选项。Rust 并没有提供类似的功能,而在去年的 Rust 事件(ThePhd Keynote 事件)中,最后一种编译时反射[10]的方法基本上被取消了。
作者认为 Rust 缺乏像其他语言那样运行时真正的反射,是个缺陷。
不可否认,这确实是 Rust 中的缺陷。否则,Bevy 引擎也不会自己去实现 bevy_reflection
库来解决这个问题。然而,文章作者没有提及 Bevy 这个反射库。
从 Bevy 的资料来看,内置 Reflect
trait 实现了序列化、反序列化和动态属性访问。实际上也是基于 Rust Any
trait 实现的。
``// Deriving `Reflect` implements the relevant reflection traits. In this case, it implements the // `Reflect` trait and the `Struct` trait `derive(Reflect)` assumes that all fields also implement // Reflect. #[derive(Reflect)] pub struct Foo { a: usize, nested: Bar, #[reflect(ignore)] _ignored: NonReflectedValue, } #[derive(Component, Reflect, Default)] #[reflect(Component)] // this tells the reflect derive to also reflect component behaviors struct ComponentA { pub x: f32, pub y: f32, } ``
这样就能够动态访问字段:
`fn some_system() { let mut value = Foo { a: 1, _ignored: NonReflectedValue { _a: 10 }, nested: Bar { b: 8 }, }; // You can set field values like this. The type must match exactly or this will fail. *value.get_field_mut("a").unwrap() = 2usize; assert_eq!(value.a, 2); assert_eq!(*value.get_field::<usize>("a").unwrap(), 2); // You can also get the &dyn Reflect value of a field like this let field = value.field("a").unwrap(); // you can downcast Reflect values like this: assert_eq!(*field.downcast_ref::<usize>().unwrap(), 2); } `
bevy_reflection
也是经历了好几个版本的迭代改进。Bevy 做的好的一面就是其社区维护的不错,每年都会进行反思,并且举办 bevy 游戏 jam 比赛,其实一个游戏引擎的发展也离不开用户的反馈。
也许是因为 Bevy 社区确实做的很好,所以大家才夸 Bevy,Bevy 在 Rust 社区才有一定影响力,甚至 Rust 编译器代码中都包含了为 Bevy 而特别编写的代码。
但是介于作者并不认可 Bevy ECS Everything 的理念,所以我想他也不会去用 bevy_reflection
库。
作者强烈推荐每个游戏开发者观看 Tomorrow Corporation Tech Demo[11] 这个视频,以了解热重载、可逆调试和游戏开发的整体工具。
Tomorrow Corporation 的团队所做的事情:
构建了自己的编程语言、代码编辑器、游戏引擎、调试器和游戏。
在整个堆栈中构建了热重载的支持。
可逆的时间旅行调试,具有可以在游戏状态之间切换的时间线。
其他...只需观看视频 :) 保证你不会后悔
作者也知道在现有平台(如.NET)或本地语言(如 C++或 Rust)中构建类似的功能几乎是不可能的,但他也不同意因为它很难并且不会百分之百地工作,我们就不应该追求这些东西的观点。
Unity 选择 C#
语言不是没有原因的,因为 C#
支持热重载。在 Unity 中,现在还有一个专门为 Unity 定制的自定义实现 hotreload.net
。作者表示,这是他回到 Unity 开发游戏而放弃 Rust 的首要原因。也是他们选择 Unity 而不是 Godot 或 UE5 的原因。(目前 Godot 不支持.NET
热重载,UE 仍然只有 blueprints 和 C++。)
Rust 语言层面确实不支持“热重载”,但是生态库中有提供一些方案,比如 hot-lib-reloader-rs[12] ,是基于 libloading
的一个方案,可以和 Bevy 配合使用。另外还可以通过使用 lua
语言来实现热加载,比如 `yazi`[13]。
但是,这使用起来肯定不如语言级支持更加方便,再加上 Rust ABI 不稳定,所以动态库的这种方案也不是很通用。
作者坦言,他尝试过 hot-lib-reloader
,但是发现它远非完美,即使对于仅重新加载函数的非常简单的用例也是如此。他曾经遇到过许多随机的问题,最终放弃了,因为它带来的麻烦比节省的麻烦还多。即使这个 crate 没有任何问题,它也无法解决随机调整事物的问题,因为它仍然需要计划和远见,这会减少潜在的创造性使用。
在很多编程语言中,尤其是那些动态类型的语言,抽象层级可以根据程序员的偏好来调整。程序员可以选择在高层次抽象或者尽可能接近底层操作,这取决于他们对控制的需求或对性能的关注。然而,Rust 语言的设计强制要求开发者进行一定程度的安全抽象,特别是在处理可变状态和共享状态时。
作者提到的 UI 编程场景中(使用 egui-rs
),他本希望直接将状态传递到需要的地方,以简化代码和减少开发复杂度。然而,由于 Rust 的借用规则,这种直接的方法会引起编译器错误,因为它违反了 Rust 的并发借用规则(不能同时有可变和不可变引用)。因此,作者被迫对状态管理进行额外的抽象,例如通过克隆状态来避开借用规则的限制。
`if let Some(character) = &self.selected_duck { character_select_duck_detail(.., character, self); } else { character_select_no_duck(...); } `
作者展示的这部分代码,处理了根据当前选择的 duck
的状态(是否有选中的 duck
),动态决定渲染哪个详细信息面板。这里使用了 Rust 的 if let
结构来进行条件判断和解构。
作者在尝试将 self
和 self
的字段作为参数传递给函数时遇到了问题。因为 Rust 不允许同时可变和不可变地借用同一个对象(self
),这迫使开发者必须更细粒度地管理状态的所有权,或使用如克隆这样的方法来绕过这些限制。这种对生命周期和所有权的严格要求,实际上强制了一种对代码组织和数据访问的抽象。
在 Rust 中,这种抽象不仅仅是选择,更多时候是编程模型和语言安全特性的必然要求。这保证了程序的安全和可靠,但同时也增加了编程的复杂度,特别是在需要频繁访问和修改共享状态的 UI 编程中。
这种必须的抽象化不仅可能增加代码的复杂性,还可能降低开发效率,因为它迫使开发者花费时间处理语言规则,而非直接解决业务问题。作者通过使用小丑表情(🤡)来表达对这种情况的幽默感和一些讽刺意味,强调虽然 Rust 在很多方面表现出色,但在某些日常开发任务中,它的严格性可能会导致效率低下。
怎么说呢,世界上没有完美的事物,这或许就是 Rust 语言内存安全保证的黑暗面 吧。
作者认为游戏 UI 是一些需要高度风格化和视觉化的东西,构建游戏 UI 最困难的部分是定制 UI 的外观和体验。
作者用了 egui
构建了大部分的 UI,但感觉它在很多方面都不是最佳选择。
“
而
egui
的作者创业在做多模态可视化框架rerun
。
Rust GUI 生态确实很糟糕。
Rust 中有许多 GUI 库,采用了许多不同的方法。有些是现有 GUI 库的绑定,有些是即时模式,有些是响应式的,甚至还有保留模式。有些尝试使用 flexbox,而其他一些在基本层面上并不真正处理布局。
在作者看来,游戏 GUI 并不太关心数据更新得有多快,也不关心是否具有响应式重新渲染、数据绑定或最花哨的声明式布局描述方式。
“
相反,我想要一个非常漂亮的 GUI,有许多自定义精灵、动画、矢量形状、粒子、特效、闪光等等。当点击按钮时,我希望它摇晃;当悬停在文本上时,我希望它动画化;我希望能够使用自定义着色器并用噪声纹理扭曲它。当选择一个角色框时,我希望粒子飞来飞去。
在 Rust 生态系统中还没有一个单一的解决方案将其目标定为“擅长制作游戏 GUI”。
孤儿规则,简单来说,就是「你要实现的 trait 和 类型必须有一个在本地定义」。目前在 Rust 中是强制的。
孤儿规则的存在是合理的,因为可以避免各种 trait 实现的冲突。
但作者认为,在有些场景,应该允许「关闭孤儿规则」。游戏引擎和框架就是一个很好的例子,使用 Macroquad
或 Comfy
这样的库的人并不需要在他们的代码库中遵守孤立规则。对于"框架型"的库来说,能够在不分叉的情况下扩展现有功能,并为最终用户提供更统一的体验将非常有益。
Rust 的编译时间整体情况有所改善,至少在 Linux 上是如此。在 Windows 上增量构建仍然明显较慢,以至于迫使作者转移到了 Linux(3-5 倍的差异)。但是在购买了一台新的高端台式机之后,构建他们的 10k 行代码库只需要几秒钟。
作为一个很好的例子, comfy-ldtk
的存在仅仅是为了包装一个单独的文件,并确保 serde
的单态化发生在一个单独的 crate 中。这可能看起来是一个琐碎的细节,这导致了增量时间从 Linux 上的 2 秒增加到了 10 秒。对于 1600 行结构定义来说,这是一个相当巨大的差异。
作者觉得有些人意识到,如果他们的编译时间是 0.5 秒而不是 30 秒,他们的游戏会有多么精致。GUI 之类的东西本质上是需要不断调整的,除了 godot-rust
的用户之外,其他人都将不得不多次重启游戏以使界面看起来好看。如果你对此表达异议,那么就请给作者提供一个使用+30 秒增量构建时间构建的非常精致的 GUI 的例子。
对于 GUI 开发这确实是个问题,但不代表所有情况都是个问题。
作者认为 Bevy 在市场营销方面比较出色。
“
就在几天前,Brackeys 发布了他们关于回归游戏开发并使用 Godot 进行开发的视频。当我观看这个视频并开始听到所有那些令人惊叹的开源游戏引擎时,我已经有了一种感觉。在大约 5:20 的时候,视频中展示了一张游戏引擎市场地图的图片,我只能说看到了三个 Rust 游戏引擎,而且特别是哪三个:Bevy、Arete 和 Ambient。现在我想要明确一点,这篇博文并不是对任何特定项目的攻击,我理解这些项目并不对其他人在他们的视频中做的事情负责。但与此同时,这已经成为 Rust 世界中的一个主题,甚至可以说是一个模因,我觉得应该谈谈这个问题。
Rust 生态系统通常的运作方式是,无论该项目的可用性如何,只要能够做出最多的承诺、展示最好的网站/自述文件、拥有最炫目的 GIF 图像,并且最重要的是能够吸引正确的抽象价值观,就会受到广泛赞扬。然后还有其他一些项目通常处于低调状态,因为它们不够吸引人,也没有承诺无法实现的功能,而只是试图以一种有效的方式完成某个任务,这些项目几乎从不被提及,或者当提及时被视为次等选择。
为此,作者举了三个例子:
Macroquad
,它是一个非常实用的 2D 游戏库,可以在几乎所有平台上运行,具有非常简单的 API,编译速度非常快,几乎没有依赖,并且是由一个人构建的。还有一个配套的库 miniquad
,在 Windows/Linux/MacOS/Android/iOS 和 WASM 上提供了一个图形抽象层。然而,Macroquad 在 Rust 生态系统中犯了一个最严重的错误,那就是使用全局状态,甚至可能是 unsafe 的。每当有人提到它时,这个问题将永远被提及,因为它不符合 Rust 的最终价值,即 100%的安全性和正确性。
Fyrox
,它是一个具有实际完整 3D 场景编辑器、动画系统和似乎满足制作游戏所需的一切的 3D 游戏引擎。这个项目也是由一个人完成的,这个人还在该引擎中制作一个完整的 3D 游戏。尽管它拥有一个完整的编辑器,而 Bevy 多年来一直在反复承诺这一点(但没有提供),它不如 Bevy 懂得营销,没有那么多 star 。
godot-rust
,它 Godot 引擎的 Rust 绑定。这个库最严重的问题是它不是一个纯 Rust 的解决方案,而只是对一个“肮脏”的 C++ 引擎的绑定。有点夸张,但那些从外部看 Rust 的人可能会惊讶地发现这有时是多么接近现实。Rust 是纯净的,Rust 是正确的,Rust 是安全的。C++ 是糟糕、陈旧、丑陋、不安全和复杂的。这就是为什么在 Rust 游戏开发中,我们不使用 SDL,我们有 winit
,我们不使用 OpenGL,我们有 wgpu
,我们不使用 Box2D 或 PhysX,我们有 rapier
,我们有 kira
用于游戏音频,我们不使用 Dear ImGUI,我们有 egui
,最重要的是,我们肯定不能使用一个用 C++ 编写的现有游戏引擎。那将违反每个使用 rustup default nightly
以获得更快编译时间的人在许可证中达成的神圣螃蟹法典(同样禁止我们正式使用由 Rust 基金会认可的标志(商标))。
“
如果有人真的想在 Rust 中制作一个真正的游戏,尤其是 3D 游戏,我的第一推荐是使用 Godot 和
godot-rust
,因为至少它们有机会提供他们所需的所有功能,因为他们可以依靠一个真正的引擎来帮助他们交付。我们花了一年时间使用 Godot 3 和 gdnative 以及godot-rust
来构建 BITGUN,虽然这个过程在很多方面都很痛苦,但这并不是绑定的错,而是因为我们试图以各种可能和动态的方式混合大量的 GDScript 和 Rust。这是我们的第一个也是最大的 Rust 项目,也是我们走上 Rust 之路的原因,最终我要说,我们之后用 Rust 制作的每个游戏都不再像一个游戏,仅仅是因为我们花了很多时间来解决与 Rust 语言、生态系统的某些部分或者某些设计决策相关的无关技术问题,这些问题由于语言的严格性而难以解决。我不会说 GDScript 和 Rust 的互操作很容易,它绝对不是。但至少有一个“只需做事情并继续前进”的选项提供了 Godot。我觉得大多数只尝试纯代码解决方案的人并不重视这一点,尤其是在 Rust 中,语言可能会以许多不方便的方式阻碍创造力。就 Bevy 而言,我确实相信它被展示为“主要”的 Rust 游戏引擎在很大程度上是合理的,如果有什么原因,那是因为项目的规模和参与人数。他们成功建立了一个非常庞大的社区,虽然我可能不同意他们的承诺和领导层的一些选择,但我不能否认 Bevy 很受欢迎。
不得不说,作者讲的这一点,确实也是事实。作为 Rust 社区的深受 Rust 语言文化影响的我,也非常认同作者说到的这一点:想要纯 Rust 实现的一切。可能我也是一个纯粹主义者。
但我觉得纯粹主义者没什么毛病,这个世界上每个人都有自己的喜好。我们可以选择自己喜欢用的,但没有去强迫别人也去用自己喜欢的,就没什么问题。我们选择自己喜欢用的库和框架,不是原罪吧?
Bevy 作为一个开源商业项目,构建自己的社区,培养自己的生态,营销有毛病吗?作者可能是认为 Bevy 过度营销了,一直承诺的东西没有实现。但这里面真的没有夹杂个人情绪?
Rust / bevy 真多过度营销了吗?你怎么看呢?
对全局状态的整体“厌恶”是一个光谱,大多数人不会完全反对它。但是在讨论游戏开发时,作者认为这是个错误的方向。
在游戏开发中,许多系统(如音频系统、输入系统、物理世界、渲染器等)通常是唯一的,因此使用全局状态是合理且方便的。在这种情况下,全局状态不仅简化了代码结构,还减少了不必要的参数传递,使得代码更易于管理和理解。
在 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 传递上下文有两种方案:传引用 和 引用计数(共享所有权)。
传引用会有个问题,就是可能会引起「级联重构」。比如:
`struct Thing<'a> x: &'a i32 } `
如果我们现在想要一个 fn foo(t: &Thing)
..., Thing
是泛型的生命周期,所以这必须变成 fn foo<'a>(t: &Thing<'a>)
或更糟。如果我们尝试将 Thing
存储在另一个结构体中,现在我们得到的是:
`struct Potato<'a>, size: f32, thing: Thing<'a>, } `
虽然 Potato
可能并不真正关心 Thing
,但在 Rust 中,生命周期是非常严肃的,我们不能忽视它们。事实上,情况比看起来更糟糕,因为假设你最终选择了这条路,并尝试使用生命周期来解决问题。
当你随后修改了 Thing
,去掉了 'a
,你不得不也得修改 Potato
。这就是级联重构。作者表示,这是最令他恼火的事情之一,尝试对生命周期进行非常简单的更改,却被迫在每次更改时更改 10 个不同的地方。
另外一个问题是,从上至下会引发借用检查问题。
`struct Context<'a> { player: &'a mut Player, camera: &'a mut Camera, // ... } `
但是,当您想运行一个玩家系统,但同时也想保留相机:
`let cam = c.camera; player_system(c); cam.update(); `
你会得到一个借用检查错误:“无法借用 c
,因为它已经被借用”。
当然,这有很多解决办法。但是,作者并不想用这些办法。
“
我不是为了享受类型系统和找出最佳的结构组织方式以让编译器满意而制作游戏。
而另一个共享所有权的方法,作者也认为只是一个糟糕的解决方案,可能是出于性能原因,但有时你只能控制引用而无法控制所有权。
尽管文章的其他部分作者对 Rust 表达了一些批评,但他也给出了 Rust 的优点(否则他也不会使用三年):
“编译即正确”:Rust 的编译器在代码正确性上提供了极大的保证。作者经验表明,只要代码能通过 Rust 的编译,那么代码在运行时通常能按预期工作。这种“编译器驱动开发”的方式在 Rust 中表现尤为突出,有助于开发者避免许多常见的编程错误。
命令行工具和数据处理:Rust 在构建命令行工具、处理数据和算法实现等方面表现出色。作者通过将通常使用 Python 或 Bash 完成的任务用 Rust 来实现,发现 Rust 不仅可以胜任,而且经常带来意外的好处。
默认高性能:与 C# 相比,Rust 在性能方面具有明显优势。尽管 C# 的性能可以通过优化得到提升,但在作者的对比测试中,Rust 的性能通常更优。这一点在具体算法实现的性能对比中尤为明显,Rust 代码通常能自然而然地达到更高的执行效率。
枚举和模式匹配:Rust 中的枚举(Enums)和模式匹配是作者特别欣赏的功能。在适用的场景下,枚举和模式匹配为代码提供了清晰的结构和强类型的安全性,这在作者使用过的语言中是最喜欢的实现之一。
Rust Analyzer:Rust Analyzer 是一个极其有用的工具,尽管有时会遇到问题,但它显著改善了 Rust 的编码体验。从 Rust 的早期版本到现在,语言周边的工具已经有了长足的进步,极大地促进了开发效率。
trait:虽然 Rust 不支持传统的面向对象继承,但其特质(trait)系统提供了一种灵活且强大的方法来实现接口和行为的多态。trait 系统适合 Rust 的设计哲学,尽管存在孤儿规则的限制,但扩展特性仍是 Rust 中作者最喜欢的特性之一。
Rust 语言团队联合 Leader JoshTriplett[14] 对此回应:
“
首先,非常感谢你花时间写下这篇帖子。离开Rust的人通常不会写下他们遇到的问题,这对我们来说是一个巨大的问题,因为这意味着我们大多只能听到那些问题不足以驱使他们离开的人的声音。非常感谢你,真的,感谢你愿意详细解释你遇到的问题。
我也非常同情和遗憾地听到你好像多次被告知问题是因为你没有正确使用Rust,或者因为使用了
Arc
或类似的东西而被羞辱,或者其他任何让你感到有罪的时候。人们不应该这样做,我很难过还有人这么做。(相关地:你能否说明一下你在哪些场合遇到过这种居高临下的态度?我在我常去的Rust圈子里没有看到这种情况,但这显然是在发生,我经常看到有关这方面的抱怨,我希望这种情况不会发生。我们有时会尝试提供一些官方信息来阻止这种居高临下的态度,但也许我们还能做更多。)
我笔记本电脑上有一个“保持冷静并调用Clone”的贴纸,对于
Arc
及类似的东西也是如此,特别是当你试图优化原型设计速度和迭代速度时。快速修补让事情运行起来是没问题的。你在这里提到的许多问题确实是Rust语言或生态系统库中常见模式的实际问题。例如,孤儿规则绝对是一个问题。它以多种方式影响生态系统的规模。这意味着如果你有一个提供特性的库A和一个提供类型的库B,要么A必须为B添加可选支持,要么B必须为A添加可选支持,或者有人必须通过新类型包装来解决这个问题。通常,较不受欢迎的库最终会为更受欢迎的库添加可选支持。这是为什么非常难以为serde编写替代品的一个原因:你必须让目前提供可选serde支持的每个库也为你的库提供可选支持。
在其他生态系统中,你要么在你的应用中添加快速且脏的支持,要么你编写(并可能发布)一个实现A和B一起使用的支持的A-B库。这在Rust中也应该是可能的。
对此有几个潜在的语言解决方案。最简单的,可能相对容易并且会帮助许多应用的,将是“一个类型只能有一个特性的实现”,如果有多于一个的实现,则给出编译器错误。
一个稍微复杂一点的规则是“允许相同的实现并视为单一实现”。这将与某种“独立派生”的机制结合使用时非常方便,这种机制将在任何使用它的地方生成相同的实现。
嘿,看,我们已经来到了这里另一个非常合理的抱怨,即宏系统与拥有某种反射的对比。我们应该提供足够的支持来实现一个独立的
derive Trait for Type
机制。它不必是完美的,对许多有用的目的来说已经足够好。这里的其他一些问题也可能有解决方案,我们值得尝试去弄清楚解决它们需要什么。无论如何,再次感谢你写下这些。我打算带着我的语言帽子试图解决其中的一些问题,并鼓励其他人阅读这篇文章。
来自资深游戏开发的评论:
“
我作为一名拥有超过 12 年经验的游戏开发者的观点是,“长此以往”更为有利。作者希望在短期内优化他们的努力,而我希望在长期内优化我的成功。这是一个滑动的尺度,当然,但作者的观点与我的完全相反。
他们希望快速迭代和“设置并忘记”式的编码,以测试一时冲动的想法是否可行,就像原型制作一样。我希望确保我编写的代码尽可能少地出现错误,包括合理处理边界情况和错误条件。对于前者,像 Lua 这样的语言已经足够好了,许多游戏开发者出于这个原因使用它。对于后者,像 Rust 这样的语言已经足够好了,许多关注长期可维护性的工程师对它很感兴趣。
我曾经用 JavaScript、Python 和 Lua 编写过游戏,通常都是以同样的随意态度,随便拼凑一些东西,也许以后再回头看看。这对于立即满足某种需求来说非常好。但是,如果我需要负责修复那些代码中的错误,这就成了我生活中的灾难。如果你能让维护成为别人的问题,那就是完美的自私开发策略。(编辑:这是我关于自己的不必要的评论。它不是针对任何其他人的投射或指向。)回顾我以前项目中的所有混乱代码,它们实际上是无法触及的。Lua 和其他语言不适合修复错误而不破坏其他东西。
另一方面,我欣赏 Rust 的限制。这种语言让你很难自己给自己找麻烦。它强制你思考可变性。因为如果你不考虑它,那就是你刚刚引入的一个错误。这是 Rust 会禁止的错误。Rust 要求你处理边界情况。这样当出现错误或者做出错误假设时,你的代码不会盲目地继续执行。
在直接批评所写的内容时,我对于“现在只是解决问题,以后再修复”和“快速高效的代码”之间的认知失调感非常强烈。(编辑:认知失调是正常的!我也有罪。我喜欢动物,但我吃肉。某种程度的认知失调是无法避免的。)选择 Rust 是因为你希望游戏在相对较慢的硬件上运行得很快,但你期望“快速的代码”应该是免费的,你可以忽略像复制或克隆与指针之间的细节(包括静态、堆分配和引用计数指针变体)。
“更好的代码”和“更快的游戏”之间的连续是基于你的短期和长期目标来导航的。也许对你来说 Lua 是最合适的选择?也许是 JVM 或 CLR。也许是一个网页浏览器。在目前所有可用的选项中,对我来说是 Rust。垃圾回收不在考虑范围内。而且因为我有“长远/现在”的心态,我相信将来会有比 Rust 更适合我的东西。
另一个例子是他们特别指出一些问题是“self 造成的”,并且后来认为全局状态比使用 bevy 的 ECS 实现更容易。从某种角度来看,这可能是真的,但它忽略了全局状态不可避免地导致的所有错误。通常,可变别名错误(如
macroquad
中提到的不完全性)或更一般的问题(如 The Problem With Single-threaded Shared Mutability - In Pursuit of Laziness (manishearth.github.io)中所述)都是全局状态的问题。但真正的问题是在“全局状态与 ECS 状态”之间划定界限是完全人为的(甚至是自我造成的)限制。一个游戏可以同时使用全局状态和 ECS,这不是一个选择的问题。这并不意味着它会变得容易。事实上,共享可变状态很困难,无论是本地还是全局,无论实现语言是什么。
他们绝对正确,Rust 不仅仅是一个“完成任务的工具”。它是一个能够以高性能正确完成任务的工具。还有很多其他语言可以“完成任务”,但这往往会以正确性、性能或两者兼而有之的代价为代价。
大多数 AAA 级游戏花费数年(甚至十年或更长时间)进行开发。如果这不是为了长期发展,我不知道还有什么是。
我可以理解这个观点对独立游戏来说是有效的,如果盲目地应用的话。但我相信大多数独立游戏开发者不只是每个月写一个游戏。这些游戏很大程度上都不会取得任何成功。而且,如果其中一个游戏确实取得了成功,那么他们就需要修复错误。支持他们无法访问的硬件配置。按照社区的期望提供新内容。等等。对我来说,这听起来很像长期维护。
“
如果您真的不关心安全性(尤其是如果您已经到了更喜欢使用允许多个可变借用的“不安全”语言的程度),在 Bevy 中您总是可以使用
get_unchecked
逃生通道。在“随便我想做什么”的游戏开发环境中,我认为这样做没有问题。而且,当在“安全”的代码库的更大背景下完成时,您可以同时兼顾两者。
“
作为一个长期从事游戏开发但是新手 Rust 开发者,这篇文章真是太棒了,肯定能为我节省一些时间和挫折。我仍然想尝试一下,但是知道了可以期待什么(你讨论的一些问题是我已经有的担忧的确认)。
我刚刚购买了《Unrelaxing Quacks》来表示感谢(也祝贺发布!)
“
这是一篇很好的文章。他在很多方面都是正确的。
我已经用 Rust 写了一个多年的元宇宙客户端。可以与 Second Life 和 Open Simulator 服务器一起使用。这是一些[视频](https://video.hardlimit.com/w/7usCE3v2RrWK6nuoSr4NHJ\[15\])。代码有大约 45,000 行的安全 Rust。
在 Rust 中从事严肃的 3D 游戏开发的人很少。有 Veloren,还有我的东西,可能还有其他一些。没有大型、受欢迎的游戏。我本以为现在应该有一些 AAA 级别的游戏是用 Rust 写的。但这还没有发生,而且可能不会发生,原因就是作者提到的。
他说得对,重构的痛苦和程序不同部分之间的互连困难是真实存在的。某些变化往往需要进行大量的工作。如果与服务器通信的客户端需要与 2D GUI 进行通信,它必须排队一个事件。
渲染情况几乎可以接受,但堆栈还没有完成和可靠。2D GUI 系统较弱,每个对话框需要太多的代码。
我倾向于同意“异步污染”问题。"异步"系统是为那些需要运行非常大的 Web 服务器,有大量客户端发送请求的人进行优化的。我一直在抵制它渗入那些实际上不需要它的领域。
我对编译时间的困扰比他少,因为元宇宙客户端没有内置的“游戏性”。元宇宙客户端更像是一个 3D 网络浏览器,而不是一个游戏。所有的对象和它们的行为都来自服务器。我可以在实时世界中编辑我的世界的一部分。如果某个东西的颜色、行为或模型需要改变,那不需要客户端重新编译。
使用 C#和 Unity 解决同一个问题的人进展得更快。
一个回复:我不太相信任何 AAA 级别的游戏会用 Rust 来编写(可以自行插入“因为 Rust 的游戏开发生态系统不成熟”或“因为 AAA 游戏开发越来越保守和风险规避”),但我很好奇你为什么会有这种想法。C++ 在 1985 年就可用了,直到千禧年之交,在 Quake 3 的推动下(受益于 C++98 的新特性),才在游戏开发中变得流行起来。
社区中其他人的看法,这里摘录一些代表性回复:
“
我们使用 Rust 编写了一些流水线工具,包括 CLI 工具和在 DCC 中使用 Python 封装 Rust,文章的主要观点与我的经验相符。
当语言的限制与你所做的工作相符时,Rust 是很棒的选择,但它与游戏引擎中经常出现的实际安全但无法证明安全的模式不太兼容。
虽然我们可以绕过这些限制并生产出一个好的产品,在某些情况下甚至是一个“更好”的产品,但这些好处与我们的痛点不相符。我们没有赚更多的钱,没有更快的迭代,没有更快的交付,我们的维护成本也没有降低,我们只是用“困难的方式”做出了一些东西。
“
我在尝试过所有流行的引擎和许多不太流行的引擎之后,离发布我的第一个游戏比以往任何时候都更近了。主要原因是因为我可以使用 Rust 编程语言,并且喜欢这门语言,仅仅是与它一起工作就给了我继续前进的动力。我理解它的缺点和不足之处,但使用你喜欢的技术也是保持动力的重要因素。我使用 Bevy 引擎,虽然它是一个年轻的引擎,但非常有趣。
“
我对这里关于 ECS 的一些说法非常不同意,但我认为我理解作者的观点。
作者从“性能至上”的角度批评了 ECS,不幸的是,这往往被吹捧为采用 ECS 的原因(因此许多 ECS 库都是围绕这一点设计的,导致有时候 API 非常痛苦)。无论如何,作者指出他们经常在系统中写“if (player) ... else if (wall)”,我认为这真的凸显了对 ECS 的理解不足 - 在我看来,这很容易成为重构为多个系统的候选方案,但作者似乎固守着一个系统对应一个组件的思维方式。无论如何,我实际上很喜欢 Rust,但在很大程度上同意 - 我认为编写正确的代码和编写快速的代码是完全不同的事情,而 Rust 主要关注前者。
我是 ECS 的坚定支持者,但承认在 Rust 游戏开发社区中的交流并不多。
我个人的理念是组件应该非常细粒度地拆分,但系统不应该。我认为 ECS 对我来说最大的卖点就是系统将这些组件粘合在一起,形成逻辑上有意义的组合 - 你可能有一个 Player 组件、一个 Enemy 组件和一个 Health 组件,但是你可能会有 PlayerHealth 和 EnemyHealth 系统。(因为如果你只考虑有像...一个 PlayerSystem、一个 EnemySystem 和一个 HealthSystem 这样的东西,那我觉得你就失去了 ECS 的所有好处,还不如坚持 Unity 风格的“臃肿组件”,只是我猜你在 Rust 中不能真的这样做...)
“
我不得不说我同意。我希望我不同意,因为我真的很喜欢用 Rust 编程,但是(独立)游戏开发主要不是编程,而 Rust 真的妨碍了这项工作。
“
这归结为系统编程和游戏编程之间的差异。
大多数引擎使用 C++作为核心,并使用额外的“游戏脚本语言”。在 Unity 中,C#可以完成比 Lua 或 GDScript 更重的工作。
有些人希望 Rust 能够胜任“游戏脚本语言”的工作,但他们会感到失望。Rust 实际上是一种专注于大型项目的系统语言。
我认为没有任何一种语言可以适应所有人的需求。
我有很多相同的挫折,我不得不说我并没有发现 Rust 对于游戏开发是我想要的魔法子弹,但我因为其他原因而坚持使用它。成熟的 Rust 引擎需要与其他东西进行快速原型设计、游戏设计师工作的集成(填补 GDScript、虚幻蓝图或其他东西的角色)。
就个人而言,如果人们只想在底层引擎上进行大量工作,我会推荐他们只关注 Rust 用于游戏开发。
“
也许与此无关,但作为一个外部人来看,读到这么多自信地说“Rust 绝对不是游戏开发的正确工具(暗示作者选择它是愚蠢的)”的评论,然后又有这么多自信地说“Rust 绝对是游戏开发的正确工具(暗示作者只是没有足够聪明地使用它)”的评论,真的让人感到不适。
“
这里提到的问题是否意味着 Rust 永远不适合游戏开发?我不太相信这一点,因为他们对 macroquad 等方面有积极的评价。可能更重要的是,我希望 Rust 在这方面变得更好,因为我完全可以想象在未来的某个时刻用我最喜欢的语言来实现一个小游戏,我会很喜欢这样做。
“
嗯,是的,这是 Rust 方面的选择。C++ 只是让你这样做,它可以工作,直到它不能工作为止。
我认为 ECS 已经被过度推崇了,Fyrox 比 bevy 更进一步,因为它们避免了架构上的冒险。在这一点上,你是百分之百正确的。
但是生命周期等等,那只是在等待崩溃的预防措施。有很多关于最后一刻的临时修补的故事,只为了让某个东西稳定运行到足够发货的程度。
借用检查器不会丢弃无效的代码,它会丢弃无法证明符合有效性的代码。这是一个微妙但重要的区别。
“
我认为 https://crates.io/crates/qcell 是一个非常棒且经常被忽视的解决方案,用于解决借用检查器的问题。其主要优势是能够拥有长期存在的对单元的引用,同时具有短期可变借用和潜在的许多相同借用者的单元。https://crates.io/crates/qcontext 是在此基础上构建的,以提供具有零成本内部可变性的静态内容。
“
关于 C++ 的说法:“它给了你一根绳子,让你用它上吊”是正确的,这是相反的立场。你可以随心所欲地使用它,但它允许开发者在某个时候有所顾虑并做任何他们想做的事情。接受风险,你必须知道何时可以或可以弯曲规则,甚至如何将风险降到最低。
在 Rust 中,如果编译器完全强制执行规则,你就没有办法这样做。所以有几次我觉得代码正确性被放在开发者判断之上,让你只有一个选择。消除了对正确或错误做什么的自由选择,只有一种方法来做事情。
我来自嵌入式世界,不能将硬件接口创建为单例让我非常恼火。而且,你需要绕过各种障碍来创建一个单例,比如使用库,这让我更加烦恼。
更多评论可以参考:
Reddit Rust 频道[16]
Hacker News[17]
在我看来,这是一位来自「不追求代码质量,只要求快速迭代,验证功能的独立游戏开发者」对 Rust 的抱怨。但他的抱怨总归是合情合理的,确实说在了一些点子上,也许他确实是“踩在”了 Rust 语言的应用边界。
对于那些想要快速验证想法,且需要根据业务或用户/玩家反馈快速变化,且一次性的不需要维护的项目产品,也许用 Rust 确实是不合适的。这个世界并不是所有项目都需要严格的强制的安全和代码质量。
感谢阅读。
参考资料
[1]
《3 年全职 Rust 游戏开发后的经验教训,以及为什么我们要放弃 Rust》: https://loglog.games/blog/leaving-rust-gamedev/
[2]
darthdeus/comfy: https://github.com/darthdeus/comfy
[3]
《Unrelaxing Quacks》: https://store.steampowered.com/app/2331980/Unrelaxing\_Quacks/
[4]
BITGUN: https://store.steampowered.com/app/1673940/BITGUN/
[5]
World::reserve in hecs
: https://docs.rs/hecs/latest/hecs/struct.World.html#method.reserve\_entity
[6]
get2_mut in thunderdome
: https://docs.rs/thunderdome/latest/thunderdome/struct.Arena.html#method.get2\_mut
[7]
暴雪《守望先锋》就使用了 ECS 架构: https://johnyoung404.github.io/2019/06/27/ECS%E6%9E%B6%E6%9E%84%E7%AE%80%E4%BB%8B/
[8]
rerun: https://www.rerun.io
[9]
游戏设计的视频: https://www.youtube.com/watch?v=o5K0uqhxgsE
[10]
编译时反射: https://soasis.org/posts/a-mirror-for-rust-a-plan-for-generic-compile-time-introspection-in-rust/
[11]
Tomorrow Corporation Tech Demo: https://www.youtube.com/watch?v=72y2EC5fkcE
[12]
hot-lib-reloader-rs: https://github.com/rksm/hot-lib-reloader-rs
[13]
yazi
: https://github.com/sxyazi/yazi
[14]
JoshTriplett: https://www.reddit.com/user/JoshTriplett/
[15]
视频: https://video.hardlimit.com/w/7usCE3v2RrWK6nuoSr4NHJ
[16]
Reddit Rust 频道: https://www.reddit.com/r/rust/comments/1cdqdsi/lessons\_learned\_after\_3\_years\_of\_fulltime\_rust/
[17]
Hacker News: https://news.ycombinator.com/item?id=40172033