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

2024-05-07 14:41:18 浏览数 (2)

近日,一篇《3 年全职 Rust 游戏开发后的经验教训,以及为什么我们要放弃 Rust》[1] 的文章引爆社区。

说明:本文很长,但值得你一看。本文不是原文的逐字翻译,而是一种解读,以及思考和汇总社区中对此文的评价

众所周知,有多少人爱 Rust 就有多少人恨 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 游戏引擎,它使用 wgpuwinit,使其跨平台,目前支持 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 社区中存在一股压倒性的力量,当有人提到他们在 Rust 语言的基本层面上遇到问题时,答案是“你只是还没理解,我保证一旦你变得足够好,一切都会变得清晰明了”。这不仅仅是在 Rust 中如此,如果你尝试使用 ECS,你会得到同样的回答。如果你尝试使用 Bevy,你也会得到同样的回答。如果你尝试使用任何框架来制作 GUI(无论是响应式解决方案还是即时模式),你也会得到同样的回答”。

这句话的言外之意是指,“你遇到这种问题,是因为你学艺不精。”这在其他语言社区,可能听上去好像不太礼貌。但是在 Rust 社区,这样说是有原因的。

因为 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_entitiesreserve_entity 函数允许在 ECS (Entity Component System) 框架中预分配实体 ID,这种设计可以很好地与 Rust 的生命周期和并发模型协作。只是预留了实体的 ID,并没有立即创建实体。这意味着这些实体在预留阶段不会参与任何查询或世界迭代,直到它们通过如 insertdespawn 等操作显式地转换为“实际”实体。这种延迟初始化的模式有助于减少生命周期冲突,因为它允许更灵活的控制何时将数据(如组件)与实体 ID 关联。
  • get2_mut in `thunderdome`[6] ,可以从同一个集合中一次获取两个可变借用。这在 Rust 基本规则里是违反借用规则的操作,但是这个库用设计模式巧妙实现了。Thunderdome 库的设计灵感来自于 generational-arenaslotmapslab 等库,它是一个 Arena(竞技场)数据结构的实现。你可以理解为 Arean 算是一种类 “GC”的实现。

虽然通过这种设计模式也能解决问题,但这个门槛确实比较高,这也是 Rust 学习曲线高的原因。但是,这并不是作者想要强调的重点。

作者认为,很多时候会遇到无法用专门设计和深思熟虑的库函数解决的情况。这就是为什么很多人会建议用命令缓冲区或事件队列来解决问题,这种方法确实有效。

游戏特别之处在于我们经常关心相互关联的事件、特定的时间点,以及整体上同时管理大量的状态。将数据在事件边界之间传递意味着代码逻辑会突然分成两部分,即使业务逻辑可能是“一个块”,但在认知上必须将其视为两个部分。足够长时间在社区中待过的人都有过这样的经历,被告知这其实是一件好事,关注点分离,代码更加"干净"等等。你看,Rust 的设计非常聪明,如果某件事情做不到,那是因为设计有问题,它只是想强迫你走上正确的道路...对吗?在 C# 中只需要 3 行代码的事情,在 Rust 中突然变成了分散在两个地方的 30 行代码。最典型的例子就是像这样的情况:"当我遍历这个查询时,我想要检查另一个对象上的一个组件,并且触发一系列相关的系统"(生成粒子、播放音频等)。我已经听到有人告诉我,嗯,这显然是一个 Event ,你不应该将那段代码写在一行内。如果你在想“但这不会扩展”或“它可能在后面崩溃”或“你不能假设全球世界因为 XYZ”或“如果是多人游戏怎么办”或“这只是糟糕的代码”...我明白你的意思。但是在你向我解释我错了的时候,我已经完成了我的功能实现并继续前进。我一次性编写代码而不考虑代码本身,当我编写代码时,我在思考我正在实现的游戏功能以及它对玩家的影响。我没有考虑“在这里获取一个随机生成器的正确方法是什么”或“我可以假设这是单线程的吗”或“我是否在嵌套查询中,如果我的原型重叠会怎样”,而且之后我也没有得到编译器错误,也没有运行时借用检查器崩溃。我在一个愚蠢的语言和愚蠢的引擎中使用,并且在编写代码的整个过程中只考虑游戏本身。

作者想表达的其实很简单,就是 Rust 限制了他在游戏开发中的自由发挥,因为他不需要代码质量(前提是使用 Rust)。如果换成其他语言,比如C/Cpp/ Go/Java / Python /Ruby 等他就不会担心这种问题,因为他可以随心所欲。

他说的很有道理,如果你的场景跟他一样,那确实不用 Rust 最好。应该快速用现有的成熟框架和脚本语言推出游戏或产品,验证想法,收获用户,而非和 Rust 编译器做斗争。

延伸学习

hecs 里预留实体 ID 的机制类似于对象池模式;reserve_entitiesreserve_entity 函数提供了一种机制来生成实体 ID,这可以视为一种延迟的工厂模式;

Thunderdome 库中 Index 结构体:

代码语言:javascript复制
/// 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号,以此来避免旧引用意外访问新数据。

代码语言:javascript复制
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 借用检查规则,真正能返回两个可变借用的情况只存在:两个给定的索引指向不同槽位,并且这两个索引都有效时

“ ECS 解决了错误类型的问题”

作者说,由于 Rust 的类型系统和借用检查器的工作方式,ECS 成为了“我们如何让东西引用其他东西”的问题的自然解决方案

但其实,ECS 架构并非 Rust 独创。早在多年前,暴雪《守望先锋》就使用了 ECS 架构[7]只不过 Rust 的类型系统和借用检查器特别适合实现这种架构,所以在 Rust 生态中比较流行 ECS 架构。

ECS(Entity Component System)是一种常用于游戏开发和高性能计算应用的架构模式,它通过将数据(组件)和行为(系统)从实体中分离出来,使得数据处理更为高效、灵活。

在传统的面向对象编程中,对象间常常通过引用或指针相互关联,这会引入复杂的生命周期管理问题和潜在的内存安全风险。ECS 通过以下方式简化了这些问题:

  1. 组件存储:在 ECS 中,组件是独立存储的,并且通常不直接引用其他组件。相反,它们可能包含指向其他实体或组件的标识符(如实体 ID)。这种方法避免了直接引用,简化了生命周期管理,因为组件的添加和删除是独立处理的。
  2. 实体和组件的解耦:实体在 ECS 中通常作为一个轻量级的标识符存在,它本身并不持有数据。所有的数据都是通过组件来表示的,这些组件被组件管理器以一种高效的方式存储和处理。这种解耦确保了在实体生命周期结束时,可以简单地清理其所有组件,而不用担心传统意义上复杂的对象图清理问题。
  3. 系统的独立操作:每个系统都独立操作一组特定的组件,这样的设计减少了对共享数据的需求,降低了复杂度和出错的可能。系统间通信通常通过共享的资源或通过事件来进行,这些机制都可以在 Rust 的安全模型下高效实现。

作者也认同 ECS 架构的优势。他的重点是,他认为社区把 ECS 滥用了

作者列举了三个问题:

  • 具有实际指针的指针型数据。问题很简单,如果字符 A 跟随字符 B,而 B 被删除(并被释放),那么指针将无效。
  • Rc<RefCell<T>> 结合弱指针。虽然这样可能可行,但在游戏中性能很重要,由于内存局部性,这些的开销是非常大的。
  • 对实体数组进行索引。在第一种情况下,我们会得到一个无效的指针,而在这种情况下,如果我们有一个索引并且移除一个元素,索引可能仍然有效,但指向其他东西。

这些问题,作者用前面提到的 Thunderdome 库解决了,并且他非常推荐这个库。

但是他认为,社区里大多数人认为 ECS 的好处实际上是 generational arena (Thunderdome)的好处。当人们说“ECS 给我带来了很好的内存局部性”,但他们只查询类似于 Query<Mob, Transform, Health, Weapon> 的移动对象时,他们实际上做的基本上相当于 Arena<Mob>

代码语言:javascript复制
struct Mob {
  typ: MobType,
  transform: Transform,
  health: Health,
  weapon: Weapon
}

所以,作者认为,有的时候你要分清自己可能仅仅需要一个 generational arena ,而非 ECS 。很多时候人们使用 ECS 是因为它解决了“我应该把我的对象放在哪里”的特定问题,而不是真正使用它进行组合,也不真正需要它的性能。这没有错,只是当这些人最终在互联网上与其他人争论,试图说服其他人他们的做事方式是错误的,并且他们应该按照上述原因使用 ECS 的某种方式,而实际上他们一开始并不需要它时,就会出现问题。

文章讨论了几种不同的看待 ECS 的角度。

  1. ECS 作为动态组合工具

ECS 允许开发者将不同的组件(数据单元)动态地组合到实体(游戏中的对象或角色)上。这种方式非常灵活,可以根据游戏逻辑的需要在运行时添加、移除或修改组件。例如,一个游戏角色(实体)可以具备位置(Transform 组件)、健康状态(Health 组件)、以及武器装备(Weapon 组件)。这种动态的组合使得开发者可以创建复杂且多变的游戏逻辑,同时保持代码的模块性和可维护性。

  1. ECS 作为性能优化工具

在 ECS 中,组件通常按类型存储在紧凑的数组中,这种存储方式称为 "结构化的数组"(也有时借用数据术语称作 "数据的数组",Data-Oriented Design)。这样做可以显著提高内存的局部性,因为相关的数据存储在内存中的位置更加靠近,CPU 在访问这些数据时可以更有效地利用缓存。例如,如果系统需要处理所有实体的健康状态,它可以连续地访问存储所有健康组件的数组,而不是跳转到分散存储的对象中去找健康数据。这种方法尤其适合于需要频繁处理大量数据的场景,如物理模拟或复杂的游戏 AI 计算。

  1. ECS 简化 Rust 借用检查器的应用

ECS 的另一个重要优势是它能够简化 Rust 借用检查器的管理。Rust 的借用检查器确保了内存安全和数据访问的正确性,但在传统的面向对象编程中,管理复杂的对象和生命周期关系可能变得非常困难。使用 ECS,开发者可以通过将数据和行为分离,更容易地符合 Rust 的借用规则,从而简化开发。实体在 ECS 中通常是轻量级的标识符,组件和系统则是独立的,这使得跨系统的数据访问可以在不违反借用规则的情况下进行。

  1. ECS 作为动态创建的 generational arenas

在 ECS 架构中,实体通常由一组组件构成,每个组件都可能存储在一个 generational arena 中。这种结构允许系统以非常高效的方式添加、删除和修改组件,同时确保引用的有效性和安全性。动态创建的 generational arenas 指的是这样一种系统:不仅数据是动态管理的,而且数据容器(即 arenas)本身也可以根据需要动态创建和配置。

  1. ECS 就是 Bevy

这部分是作者开玩笑。因为 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 社区已经采纳了这种对 Rust 相关事物的无情积极性和赞美的观念,完全将自己与外界隔离了开来。真实的游戏世界并不那么友好。Steam 上的玩家并不在乎某个东西是用 Rust 制作的,他们也不在乎它花了多长时间制作,他们也不在乎代码是否开源。他们关心的是看游戏,并在几秒钟内能够判断这是否会是浪费时间,或者是一些潜在有趣的东西。玩家不关心开发者,只是在几秒钟内看游戏是正确和可取的,但至少这让我们保持诚实。这使得游戏只关注游戏本身,而不关注其他任何事情,因为最终,游戏和玩游戏的体验才是最重要的。

这其实跟 Rust 社区没有关系,任何游戏或应用、产品的用户都不会在意它是不是 Rust 实现的。只不过作者作为一名 Rust 开发者,自身在这个圈子里。

代码质量是保证用户体验的一个因素,只要不是过度追求代码质量即可

作者显然也明白,他说他当然同意这个观点,当有人按下播放按钮时游戏崩溃,或当你损坏存档文件并且玩家失去进度时,这绝对是影响玩家体验的。

但他认为所有这些都完全忽略了对玩家来说重要的事情。有很多情况下,人们的进度被清零,但他们仍然会回到游戏中并再次玩它,因为游戏太好了。作为玩家,他已经做过这种事情不止一次。

前提得是 「好游戏」吧?

作者的意思很明确:应该专注于好游戏,而非好代码。因为 Rust 的强制重构,导致他把精力耗费在实现「好代码」上,而非「好游戏」上。

“过程宏不是反射”

游戏开发领域需要很多“动态方法”,Rust 没有这种“脚本语言”能力。尤其是在关卡编辑、工具和调试方面,这变得尤为痛苦。

其实,C/Cpp 也没有啊,所以结合 lua 之类的脚本语言比较常见。我一直在想,社区也许需要一门和 Rust 语法相同且和 Rust 交互的脚本语言。

Rust 中有过程宏。但是作者认为过程宏基本上允许程序员在编译时运行代码,消耗 Rust 的 AST,并生成新的代码。不幸的是,这种方法存在许多问题。

首先,过程宏并没有真正缓存,而是在重新编译时重新运行。这导致你的代码必须分割成多个 crate,这并不总是可行的,而且如果你更加依赖过程宏,编译时间会大大增加。有很多方便的过程宏,比如 profilingfunction 宏,非常有用,但最终无法使用,因为它们破坏了增量构建时间。其次,过程宏非常难以编写,大多数人最终使用非常庞大的辅助 crate,比如 syn ,它是一个非常庞大的 Rust 解析器,会急切地评估应用于它的所有内容。例如,如果你想在宏中注释一个函数并解析它的名称, syn 最终会解析整个函数体。还有一种情况, syn 的作者也是 serde 的作者,这是一个流行的 Rust 序列化库。去年的某个时候,该库在一个补丁版本中开始附带一个二进制文件,拒绝了社区的反对声音。这并不是反对 Rust 的案例,但我觉得应该提到,因为它展示了生态系统的很大一部分是由单个开发者制作的库构建的,这些开发者可能会做出潜在危险的决策。当然,这种情况在任何语言中都可能发生,但在过程宏方面尤为重要,因为生态系统中几乎所有的东西都使用了这个特定作者的库( synserdeanyhowthiserrorquote ,...)。即使忽略上述情况,过程宏的学习曲线非常陡峭,并且它们必须在一个单独的 crate 中定义。这意味着与声明式宏不同,你不能轻松地创建一个新的过程宏,就像创建一个函数一样。相比之下,在 C# 中使用反射非常容易,如果性能不是问题(在使用反射的情况下通常不是问题),它可以是构建工具或调试的一种非常快速和有用的选项。Rust 并没有提供类似的功能,而在去年的 Rust 事件(ThePhd Keynote 事件)中,最后一种编译时反射[10]的方法基本上被取消了。

作者认为 Rust 缺乏像其他语言那样运行时真正的反射,是个缺陷。

不可否认,这确实是 Rust 中的缺陷。否则,Bevy 引擎也不会自己去实现 bevy_reflection 库来解决这个问题。然而,文章作者没有提及 Bevy 这个反射库。

从 Bevy 的资料来看,内置 Reflect trait 实现了序列化、反序列化和动态属性访问。实际上也是基于 Rust Any trait 实现的。

代码语言:javascript复制
// 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,
}

这样就能够动态访问字段:

代码语言:javascript复制
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 的并发借用规则(不能同时有可变和不可变引用)。因此,作者被迫对状态管理进行额外的抽象,例如通过克隆状态来避开借用规则的限制。

代码语言:javascript复制
if let Some(character) = &self.selected_duck {
    character_select_duck_detail(.., character, self);
} else {
    character_select_no_duck(...);
}

作者展示的这部分代码,处理了根据当前选择的 duck 的状态(是否有选中的 duck),动态决定渲染哪个详细信息面板。这里使用了 Rust 的 if let 结构来进行条件判断和解构。

作者在尝试将 selfself 的字段作为参数传递给函数时遇到了问题。因为 Rust 不允许同时可变和不可变地借用同一个对象(self),这迫使开发者必须更细粒度地管理状态的所有权,或使用如克隆这样的方法来绕过这些限制。这种对生命周期和所有权的严格要求,实际上强制了一种对代码组织和数据访问的抽象

在 Rust 中,这种抽象不仅仅是选择,更多时候是编程模型和语言安全特性的必然要求。这保证了程序的安全和可靠,但同时也增加了编程的复杂度,特别是在需要频繁访问和修改共享状态的 UI 编程中。

这种必须的抽象化不仅可能增加代码的复杂性,还可能降低开发效率,因为它迫使开发者花费时间处理语言规则,而非直接解决业务问题。作者通过使用小丑表情(

0 人点赞