译者|核子可乐
策划|冬梅
编者按:本文作者是国外一位用 Rust 编程语言开发游戏的开发者,这位作者和他的朋友两人成立了一家小型独立游戏开发工作室,在过去几年中他们致力于开发跨不同引擎的各种游戏。 他们热爱游戏,并在编程和创建各种应用程序(网络或桌面应用程序)方面拥有丰富的经验。他们用 Rust 构建了自己的引擎,称为 Comfy Engine,用于他们的游戏。本文就讲述了他们这三年来使用 Rust 编程语言开发游戏的心路历程。下列内容为 InfoQ 翻译并整理。
免责声明:这篇文章是我个人对于多年来感受与困境的总结,也验证了我听到过的某些被反复强调的所谓“真理”。我使用 Rust 进行了数千个小时的游戏开发,期间也完成过多款游戏作品。本文不是想向大家炫耀我有多牛、有多成功,最主要的目的在于破除“你觉得 Rust 不好用,是因为你的经验还不够”这一广泛存在的荒谬论点。
本文也绝不是科学评估或者严谨的 A/B 研究。我们是一支由两人组成的小型独立游戏开发团队,单纯是想用 Rust 做游戏并赚取持续发展的必要收益。所以如果大家能从投资者那边拿到几无上限的资助、一个项目就要做上好几年,而且并不介意不断开发各种必要系统,那我们的经验对您并不适用。我们的诉求很简单:最多在 3 到 12 个月的周期之内完成游戏的开发和发布,并借此赚取回报。这就是本文的立论基础,跟快乐学习、探索 Rust 没有半毛钱关系。并不是说后一种目标不好,只是我们这里讨论的是能不能用 Rust 养家糊口、能不能在商业层面找到可以自给自足可行路径的问题。
我们已经在 Rust、Godot、Unity 和虚幻引擎上开发过一些游戏,各位没准在 Steam 上游玩过了。我们还从头开始使用简单的渲染器制作出了自己的 2D 游戏引擎。多年以来,我们也在多个项目中使用了 Bevy 和 Macroquad,包括一些相当重要的项目。我本人还有一份全职工作,负责的是后端 Rust 开发。另外需要强调,本文内容并非源自经验浅薄的新手——我们在这三年多时间里已经编写过超 10 万行 Rust 代码。
我写下这篇文章的目的,就是破除一个曾被反复提及、误导了无数新手的荒谬观点。希望在读了这篇文章之后,大家能理解我们为什么要放弃 Rust 作为游戏开发工具。我们不会停止游戏开发,只是不再继续使用 Rust。
如果各位的目标是学习 Rust,能感受到它的优秀之处而且乐于接受技术挑战,那完全没有问题。作为有经验的开发者,我会在文章中把应用场景明确区分开来,而不像很多所谓 Rust 老鸟那样不问你是单纯需要技术演示、还是想认真推出一款游戏,就盲目鼓吹 Rust 语言。我发现整个 Rust 社区的注意力都主要集中在技术身上,反而对游戏开发中“游戏”的部分熟视无睹。举个例子,我曾参加过一场 Rust 游戏开发的线下聚会,结果在提案中居然看到了“有人想在会上展示一款游戏,当否请批示”的纸条……着实给我整无语了。
“你觉得 Rust 不好用,是因为你的经验还不够”
学习 Rust 确实是种有趣的经历,因为人们常常发现“这肯定是个只有我遇到过的特殊问题”其实是正在困扰更多开发者的普遍模式,于是每位学习者都必须调整思路并消化这些“怪癖”才能提高生产力。而且这些问题往往出现在相当基础的层面,比如 &str 和 String 或者.iter() 与.into_iter() 的区别等等。总而言之,我们潜意识里认为应该没区别的事物,在 Rust 这边往往边界森严。
我承认,其中一些属于必要之痛,在积累到足够的经验之后,用户就可以不假思索地预见到潜在问题并提高工作效率。我非常享受用 Rust 编写各种实用程序和 CLI 工具的体验,而且在多数情况下效率也比用 Python 更高。
可话虽如此,Rust 社区中的确存在一股压倒性的力量。每当有人提到自己在使用 Rust 语言时遇到的基础问题,他们就粗暴回应说“你觉得 Rust 不好用,是因为你的经验还不够”。当然,这不仅仅是 Rust 的问题。我们使用 ECS 时有这种现象,在使用 Bevy 时也有这种现象。甚至是在我们使用自己选定的任何框架(无论是响应式方案还是即时模式)制作 GUI 时,也都有类似的困扰。“你觉得 xx 不好用,是因为你的经验还不够”。
多年以来我一直对此深信不疑,也一直在努力学习和尝试。我在多种语言中都遇到过类似的情况,并且发现自己在掌握诀窍之后效率确有提升,慢慢学会了预测语言和类型系统的“脾性”并能有效回避问题。
但我想强调一点,在花掉了大约三年的时间,在 Rust 的整个框架 / 引擎生态系统中编写了超过 10 万行游戏相关代码之后,我发现很多(甚至是大多数)问题仍然存在。所以如果各位不想没完没了地重构代码并将编程视为一种不断挑战自我的趣事,而单纯想要安安静静地用它完成任务,那千万别用 Rust。
最基本的问题就是借用检查器经常选个最让人难受的时机强制进行重构。Rust 的粉丝们觉得这事没问题,因为能让他们“编写出更好的代码”,但我花在这门语言上的时间越多,就越是怀疑这话到底靠不靠谱。好的代码确实是靠不断迭代思路并做出尝试来实现的,虽然借用检查器可以强制进行更多迭代,但并不代表这就是编写代码的理想方式。我经常发现自己被牢牢卡死,根本没办法稍后再做修复——这导致我根本没办法用轻松愉快的心情把脑袋里的灵感丝滑顺畅地表达成代码。
在其他语言中,人们可以在写完代码之后就把它抛在脑后,我觉得这才是实现良好代码的最佳途径。举个例子,我正在编写一个角色控制器,唯一的目标就是用它操纵角色移动和执行操作。完成之后,我就可以开始构建关卡和敌人了。我不需要这个控制器有多好,能起效就足够了。如果有了更好的点子,我当然可以稍后把它删掉再换上个更好的。但在 Rust 中,万事万物之间都有联系,导致我们经常遇到没办法一次只做一件事的情况。于是我们的每一个开发目标都变得极其复杂,并且最终会被编译器重构,哪怕那些一次性代码也是如此。
Rust 擅长大规模重构,但这是为了解决借用检查器自身造成的问题
人们常说 Rust 最大的优势之一就是易于重构。这话没错,而且我也有切身体会,比如可以无所畏惧地重构代码库中的重要部分,不必担心运行起来出什么问题。但事情真这么简单美好吗?
事实上,Rust 也是一种比其他语言更频繁迫使用户进行重构的语言。每当我开发一段时间,就会突然被借用检查器当头棒喝,并意识到“好吧,我添加的这项新功能无法编译,而且除了代码重构之后没有其他解决办法”。
有经验的人们常常会说,“你觉得 Rust 不好用,是因为你的经验还不够”。虽然这话原则上没错,但游戏是种复杂的状态机,其需求一直在变化。用 Rust 编写 CLI 或者服务器 API,跟用它编写独立游戏是完全不同的两种体验。毕竟我们开发游戏的目标是为玩家提供良好体验,而不是一组僵化死板的通用系统,所以必须考虑人们在游玩过程中随时发生的需求变化,特别是那些需要从根本上做出调整的变更。Rust 的高度静态特性与过度检查的倾向,明显有违游戏软件的天然需求。
很多人可能会反驳说,借用检查器和代码重构并不是坏事,它们能有效提高代码的质量。确实,对于那些强调代码的项目来说,Rust 的特性有其积极的一面。但至少在游戏开发这边,多数情况下我需要的不是“高质量的代码”,而是“能早点试玩的游戏”,这样我才能快速测试自己的玩法设计思路。Rust 的很多坚持,其实就是逼着我在“要不要打破流程并花上整整 2 个小时进行重构”和“在客观上让代码质量变得更糟”之间二选其一。我真的快要崩溃了……
这里我还要放句大不敬的话厥词:至少对于独立游戏来说,可维护性根本就是个伪诉求,我们更应该追求迭代速度。其他语言可以更简单地解决眼下的问题,又不必过度牺牲代码质量。而在 Rust 中,我们永远需要选择要不要向函数中添加第 11 个函数,要不要添加另一个 Lazy<AtomicRefCell>,要不要将其放入另一个对象,要不要添加间接(函数指针)并恶化迭代体验,或者干脆花点时间重新设计这部分代码。
间接只能解决部分问题,而且总会以损害开发体验为代价
Rust 所主张、而且特别有效的一种基本解决思路,就是添加一个间接层。我们以 Bevy 事件为典型案例,对于“需要配合 17 个参数来完成任务”这类问题,Bevy 事件都是首选的解决办法。我也很努力地想从好的方面理解这种情况,但 Bevy 确实高度倚重事件、而且总想把所有内容都塞进单一系统。
借用检查器的很多问题,都可以通过间接执行某些操作来解决。或者也可以复制 / 移出某些内容,执行该操作,然后再把其转移回来。又或者将其存储在命令缓冲区内并稍后执行。这通常会在设计模式上引发一些神奇的现象,比如我就发现可以通过提前保留 entity id 并结合命令缓冲区来解决很大一部分问题(例如 hecs 中的 World::reserve,请注意是 &world 而不是 &mut world)。这些模式有时候确实效果不错,而且足以解决种种极其困难的问题。另一个例子则是在 Thunderdome 中提到的 get2_mut,乍看之下没什么道理,但经验多了之后人们发现它能解决很多意想不到的问题。
关于 Rust 那陡峭的学习曲线,我其实不想争论太多,毕竟每种语言都各有特性。但我想提醒各位的是,哪怕是积累到相当丰富的经验之后,Rust 中也仍然存在很多基本问题。
回到正题,虽然前面提到的一些方法可以解决特定问题,但也有不少情况根本无法通过专门的、精心策划的库函数来解决。也正因为如此,很多人才建议直接使用命令缓冲区或者事件队列“将问题延后”,从而切实有效地解决问题。
游戏开发的具体问题在于,我们经常需要关注相互关联的多个事件与特定的时间安排,而且得同时管理大量状态。跨事件屏障进行数据移动,意味着事物的代码逻辑会被割裂成两个部分——就是说哪怕业务逻辑本身仍是一个整体,在认知意义上也应被视为彼此独立。
经常混迹 Rust 社区的朋友肯定知道,那边的老人儿们会说这是件好事,能保证关注点分离并让代码“更干净”等等。在他们看来,Rust 的设计者是最聪明的,所以如果有什么正常功能难以实现——那不是设计有误,而是他们想强迫你采取正确的实现方法……
于是乎,在 C# 中 3 行代码能搞定的功能在 Rust 这边需要 30 行代码,还得一分为二。我给大家举个典型例子:“在迭代当前查询时,我想检查另一功能上的组件并涉及一堆相关系统”(比如生成粒子、播放音频等)。不用问就知道,Rust 社区上的那帮铁粉会说,“这明显是个 Event,所以你不应该内联编写代码”。
可想象一下,在这样的规则下功能实现起来将有多麻烦(以下为 Unity 版代码):
代码语言:javascript复制if (Physics.Raycast(..., out RayHit hit, ...)) {
if (hit.TryGetComponent(out Mob mob)) {
Instantiate(HitPrefab, (mob.transform.position hit.point) / 2).GetComponent<AudioSource>().clip = mob.HitSounds.Choose();
}
}
这只是个相对简单的例子,但这样的需求随时可能出现。特别是在实现新机制或者测试某项新功能时,我们最需要的是可以直接编写,而暂时不想考虑什么可维护性。我们要做的是很简单的东西,只想它在正确的位置上运行。我不需要 MobHitEvent,因为我还打算同时检查 raycast 等其他相关功能。
我也不想检查“Mob 上存不存在 Transform”,因为我是在开发游戏,所以每个 entity 当然都有 transform。但 Rust 不允许我使用.transform,而且一旦我不小心让查询发生了原型重合,由此引发的双重借用就会立刻导致崩溃。
我也不想检查音频源是否存在。我当然可以用.unwrap().unwrap(),但细心的 Rust 会注意到这里没有传递 world。在 Rust 看来,运行场景是全局 world 吗?不是应该用依赖注入将查询写成系统中的中车个参数,并将所有内容都预先安排就绪吗?.Choose 是不是假设存在一个全局随机数生成器?线程呢?
我知道,很多粉丝都会说什么“但这不利于未来扩展”、“后续可能引发崩溃”、“你不能假设全局 world,因为 blabla”、“你没考虑过多人游戏的问题吗”或者“这种代码质量敢用吗”之类……我都知道。但就在各位忙于挑错的同时,我已经完成了功能实现并继续前进。很多代码其实就是一次性的产物,我在编码过程中实际是在考虑当前实现的游戏功能会如何影响玩家体验。我并不在乎“这里应该使用哪种正确的随机生成器”、“能不能假设单线程场景”或者“嵌套查询当中的原型重合该怎么处理”之类的技术问题,而且后续也没有出现编译器错误或者运行时借用检查器崩溃。我只想在傻瓜引擎里用点傻瓜语言,保证自己能在编写代码的时候只考虑游戏逻辑,行吗?
用 ECS 解决错误类型问题
由于 Rust 类型系统和借用检查器的天然特性,ECS 自然而然成了帮且我们解决“如何让某个东西引用其他东西”的方案。遗憾的是,我认为其中存在大量术语混淆,不单是不同的人对其有不同的定义,而且社区中有不少人会把本不属于 ECS 的东西硬安在它头上。下面咱们就做一点明确的区分和阐述。
首先,让我们先聊聊因为各种原因而导致开发者无法实现的东西(为了控制篇幅,这里不做过多的细节区分和讨论):
- 具有实际指针的 pointer-y 数据。这里的问题很简单,如果字符 A 跟随 B,且 B 被删除(并取消分配),则该指针将无效。
- Rc<RefCell<T>>与弱指针相结合。虽然可以实现,但在游戏中性能往往非常重要,而且受内存局部性的影响,这样的资源开销确实会带来可感知的影响。
- Entity 数组的索引。在第一种情况下出现了一个无效指针,这时候如果我们拥有一个索引并删除了一个元素,则该索引可能仍然有效但指向其他内容。
这时出现了一个神奇的解决方案,能帮助我们摆脱所有问题——这就是我个人强烈推荐的 generational arenas——它又小又轻巧,而且能在保持代码库可读性的同时实现既定功能。这种稳定实现既定功能的能力,在 Rust 生态系统中其实相当罕见。
Generational arena 在本质上就是一个数组,只不过我们的 id 不再是一个索引,而是一个(index, generation)元组。该数组本身存储的是(generation, value)元组。为了简单起见,我们可以想象每次在索引处删除某些内容时,只需增加该索引处的生成计数器即可。之后只需要确保对 arena 进行索引时,始终检查提供索引的 generation 是否与数组中的 generation 相匹配。如果该条目被删除,则 slot 将拥有更高的 generation,而索引也将“无效”、就如同该条目不存在一样。这种方法还能解决其他一些非常简单的问题,比如保留一个空闲的 slot 列表,以便在必要时向这里插入以加快操作速度——当然,这些都跟用户无关。
关键在于,这种方式终于让 Rust 这类语言能够完全避开借用检查器,允许我们“使用 arenas 进行手动内存管理”,从而在保证 100% 安全的前提下无需接触任何指针。如果非要说 Rust 有什么让人喜欢的优点,那就是它了。特别是对于像 thunderdome 这样的库,二者确实结合得很好,而且这种数据结构也非常符合语言的设计思路。
有趣的来了。大多数人所认为的 ECS 优势,其实在很大程度上是 generational arenas 的优势。当人们说“ECS 提供了很好的内存局部性”时,他们对 mobs 使用的 Query<Mob, Transform, Health, Weapon>查询,其本质其实相当于 Arena。具体 struct 定义为:
代码语言:javascript复制struct Mob {
typ: MobType,
transform: Transform,
health: Health,
weapon: Weapon
}
当然,这种定义方式并不能体现 ECS 的全部优势。但我想强调的是,我们想在使用 Rust 的同时尽量回避 Rc< RefCell>,并不一定非得靠 ECS——相反,真正能帮上尽快的可能是 generational arena。回到 ECS,我们可以从多种不同的角度理解 ECS 的作用:
ECS 作为动态组合,允许将多个组件组合起来以共同存储、查询和修改,而不必绑定在单一类型中。这里最典型的例子,就是很多人实际上会在 Rust 当中用“state”状态组件来标记 entities(因为也没有其他更好的方法可以实现)。比方说,我们想要查询所有游戏中的 Mobs(小怪),但其中一些可能已经变成了不同的类型。我们可以简单执行 world.insert(entity, MorphedMob),之后再查询 (Mob, MorphedMob)、(Mob, Not) 或者 (Mob, Option),要么就是检查代码中是否存在所述组件。根据不同的 ECS 实现,这些方法的具体操作可能有所区别,但其本质就是在“标记”或者“拆分”entities。
不止如此,除了 Mob 之外,类似的情况也完全可能出现在 Transform、Health、Weapon 或者其他元素之上。比如说原本未装武器的小怪没有 Weapon 组件,但在它拾起武器后我们就需要将其插入 entity。如此一来,我们就能在单独的系统中拆借所有带有武器的小怪。
我还会在动态合成中引入 Unity 的“EC”方法,虽然它并不属于纯粹的“带系统的 ECS”,但在很大程度上确实会使用组件进行合成。而且除了性能问题之外,其最终效果确实非常类似于“纯 ECS”。这里我还想再夸夸 Godot 的节点系统,其中各子节点通常被用作“组件”。虽然这与 ECS 无关,但却与“动态组合”有关,因为它允许在运行时上插入 / 移除节点,进而改变 entities 的行为。
另外应当指出的是,“将组件拆分成尽可能小的形式以最大程度实现复用”也已经成为一种最佳实践。我参与过很多次争论,也有人试图说服我,提醒我最好把 Position 跟 Health 从对象当中剥离出来。而如果不这样做,我的代码最终就会像意大利面那样彼此纠缠、一塌糊涂。
在多次尝试了这些方法之后,我现在已经坚定相信:除非关注极致性能,否则对于绝大多数开发场景,都压根没必要拆分到这个程度。我本人在拆分之外还尝试过所谓“胖组件”方法,而且感觉“胖组件”其实更适合那些需要在大量正在发生的事情上建立特有逻辑的游戏。比如说,将 Health 值建模为泛型机制在简单模拟中倒是可以,但在不同的游戏里,玩家生命值跟敌方生命值其实对应着不同的逻辑。我还经常会想要让不同类型的非玩家 entities 使用不同的逻辑,比如怪物和墙壁各有自己的生命值。实践经验告诉 ,粗暴将其概括为“生命值”反而令代码变得模糊,导致生命值系统中充斥着 if player { ... } else if wall { ... }这样的语句。这样并不好,还不如单独保留所谓“胖”玩家或者墙壁系统。
作为数组的动态结构,受到组件在 ECS 中存储方式的影响,我们可以对 Health 组件进行迭代并使其在内存中排列在彼此相邻的位置上。有些朋友可能不太理解,这意味着我们不再需要使用 Arena,而可以使用:
代码语言:javascript复制struct Mobs {
typs: Arena<MobType>,
transforms: Arena<Transform>,
healths: Arena<Health>,
weapons: Arena<Weapon>,
}
而且同一索引上的值属于同一“entity”。手动执行这类操作非常烦人,而且受我们以往开发经历和使用语言的影响,大家可能总有些时候被迫选择手动操作。但多亏了现代 ECS,我们只需要在元组中写出自己的类型即可轻松实现此功能,再由底层存储机制将正确的内容组合在一起。
我还把这类用例称为 ECS as performance,因为这样做的目的不是“因为我们需要组合”,而是“我们想要更多的内存局部性”。我承认其对应一部分有效应用,但至少对于绝大多数已经发行的独立游戏来说,确实没有任何必要。我之所以强调“已发行”的游戏,是因为反对者当然可以轻松设计出高度复杂、必须借助这种机制的原型,但这既跟玩家体验没啥关系、也不值得本文多费唇舌。
作为 Rust 借用检查器的解决方案,我认为这就是大多数在用 ECS 实现的效果——或者更确切地说,这就是他们选择 ECS 的理由。非要说有什么区别,那就是 ECS 确实非常流行,也是 Rust 的推荐选项,确实能够解决很多问题。反正如果我们只是在传递 struct Entity(u32, u32)的话,由于它既简单又可以直接 Copy,那实在没必要像 Rust 要求的那样纠结什么生命周期。
我之所以把这部分单独开了一节,是因为很多人在使用 ECS 来解决“我该把对象放在哪里”的问题,而不是真的在用它进行组合或者提升性能。这本身并无不妥,只是当人们最终在网上展开争论时,总会有人想强调其他人的办法是错的、认为对方应该以特定方式使用 ECS。别闹了,我发现很多人甚至连别人用 ECS 的原因都没搞懂。
ECS 作为动态创建的 generational arenas,单纯就是为了实现最基本的功能保障而生。换句话说,为了同时实现 storage.get_mut::() 和 storage.get_mut::之类的操作,我要么被迫重新发明一堆古怪的内部可变性,要么就只能选择它。Rust 有这么个特点 :当你按照它的脾气做事时,它就既有趣又漂亮;可一旦你想做些它不喜欢的东西时,情况很快就会变成“我得重新实现自己的 RefCell 来实现这项特定功能”。
我想说的是,虽然 generational arenas 不错,但最大的缺点之一就是必须为我们需要使用的每个 arena 定义一个变量和类型。如果在每个查询中只使用一个组件,当然可以通过 ECS 来解决;但如果不需要完整的原型 ECS,而能按需使用每种类型所对应的 arema,那不是更好?现在当然有很多方法可以做到这一点,但我已经不想继续耗费心神来部分重新发明 Rust 生态系统了。告别 Rust 之后,我现在满心轻松。
ECS 能火是因为 Bevy,我觉得这肯定只是个笑话。但必须承认,凭借其极高的受欢迎程度和包罗万象的方法,Bevy 值得作为 ECS 中的一个单独角度。因为对于大多数引擎 / 框架来说,ECS 是个选项,是人们选择是否要使用的库。但 Bevy 之于游戏则不可或缺,在很多情况下整个游戏就是 ECS。
另外我要专门强调的是,虽然我个人有种种不满,但 Bevy 对于 ECS API 和 ECS 本身的易用性确实做出了巨大的改进。任何见过、或者说用过 specs 之类的人们,都知道 Bevy 在改善 ECS 易用性方面做得有多好、几近几年来的进步有多大。
话虽如此,但我认为这恰恰也是我对 Rust 生态系统在看待 ECS、特别是 Bevy 的方式感到不满的核心原因。ECS 是一种工具,一款非常具体的工具,可以解决非常具体的问题,而且是有成本的。
这里我们稍微岔开话题,再聊聊 Unity。无论其授权、高管层或者商业模式发生了怎样的变动,我们都必须承认 Unity 就是推动独立游戏取得成功的主要驱力之一。从 SteamDB 图表来看,Steam 平台上目前有近 4.4 万款 Unity 游戏;排名第二的是虚拟引擎,有 1.2 万个;其他引擎则远远落后。
近年来一直在关注 Unity 的朋友肯定听说过 Unity DOTS,这本质上就是 Unity 版的“ECS”(以及其他面向数据的东西)。现在,作为 Unity 曾经、当下以及未来的用户,我对此感到非常兴奋。而之所以如此兴奋,就是因为它能与现有游戏对象方法共存。虽然必然涉及很多复杂元素,但从本质上讲,用户们都期待着这样的升级。我们既可以在一款游戏中使用 DOTS 来完成某些效果,也可以像以前那样继续使用标准游戏对象场景树,并把这两种方法顺畅结合在一起。
我不相信 Unity 领域会有人在了解了 DOTS 的意义之后,认为这是个不该存在的糟糕功能。当然,我也不觉得有人会认为 DOTS 就是 Unity 的全部未来,可以直接把游戏对象删除掉,强制要求所有 Unity 作品都转向 DOTS。哪怕不谈可维护性和向下兼容性,这也是非常愚蠢的作法,因为仍然有很多工作流程是天然适合游戏对象机制的。
相信很多用过 Godot 的朋友也会有类似的观点,特别是用过 gdnative 的朋友(例如通过 godot-rust)。虽然节点树可能并非适合一切需求的最佳数据结构,但它们在很多场景下确实非常方便。
说回到 Bevy,我觉得很多人都没意识到“ECS 管一切”的方法涵盖范围是有多广。举个明显的例子,在我看来,其中的一个大昏招就是 Bevy 的 UI 系统——这东西作为痛点存在已经不是一天两天了,特别是加上“我们今年内肯定会开始开发编辑器”之类的承诺。只要稍微看看 Bevy 的 UI 示例,就会发现里头根本就没多少东西;再随便追溯一下源代码,比如一个在悬停和单击时会改变颜色的按钮,原因就不言自明了。实际上,在尝试用 Bevy UI 完成某些重要的开发任务之后,我可以公开这样讲,那难受程度甚至远超大家的想象。因为 ECS 在执行任何跟 UI 相关的操作时,都是无比麻烦且无比痛苦。因此,Bevy 上跟编辑器最接近的方案就是使用 egui 这种第三方 crate。而且不止是 UI,我想说的是坚持把包括 UI 在内这么多东西交给 ECS 处理,实在有点反人类。
Rust 中的 ECS 相当于把其他语言中的普通工具,转化成了一种近乎宗教信仰的必要之物。我们使用某种工具本来应该是因为它更简单、更好用,可现在变成了不用不行。
编程语言社区往往各有倾向。多年以来我曾经先后使用过多种语言,而且发现这些倾向都稻有趣。我能想到的类似于 ECS 之于 Rust 的,也就是 Haskell 了。虽然有点简单粗暴,但我个人的感觉是 Haskell 的整个社区还更成熟一些,人们对其他方法的态度也比较友善,只是将 Haskell 视为“能解决适当问题的有趣工具”。
另一方面,Rust 在表达其偏好时往往偏执得像个叛逆期的青少年。他们的话语斩钉截铁,而且不愿讨论更多细微差别。编程是种近乎玄学的微妙工作,人们往往需要经历一系列权衡、做出很多次优选择才能及时得到结果。Rust 生态系统中盛行的完美主义坚持和对“正确方式”的痴迷,常常让我感觉它是不是引来了很多刚接触编程的人,一听说某种理论就深信不疑。再次强调,我知道这种情况并不适用于所有人,但我认为对 ECS 的整体痴迷恐怕就是这么来的。
泛型系统无法实现有趣的游戏玩法
为了防止前面提到的这种种情况,一种常见的解决方案就是对系统进行全面泛型化。只要以更细粒度的方式划分组件并使用适当 系统,那么所有这些特殊问题肯定都可以被避免,对吧?
除了“泛型系统无法实现有趣的游戏玩法”之外,我也确实拿不出太多有力的反对论据。我在 Rust 游戏开发社区非常活跃,也见过很多由其他人开发的项目,他们提出的建议往往跟正在开发的游戏高度相关。而那些设计出巧妙、具备完全通用操作属性的系统,往往并不是真的在做游戏。编程成了对游戏逻辑的“模拟”,导致“创建一个可以移动的角色”就成了游戏玩法本身,其核心重点往往表现为以下一条或者多条:
- 由程序生成的世界、行星、空间、地下城。
- 基于体素的任何内容,重点关注体素本身、渲染体素、世界大小和性能。
- 交互通用化,即“任何东西都可以与其他任何东西实现 xx”。
- 以尽可能最优方式进行渲染,“做游戏怎么可以不用 draw indirect 呢?”
- 为游戏构建设计出良好的类型和“框架”。
- 构建一套引擎来制作更多类似的后续作品。
- 考虑多人游戏需求。
- 使用大量 GPU 粒子,认为粒子越多则视觉效果越好。
- 写出结构良好的 ECS 和干净的代码。
- 诸如此类……
就技术探索和学习 Rust 而言,这些都是很不错的目标。但我还是想要重申本文开头提到的原则:我并不是想要磨练技术,或者以做游戏的方式来学习 Rust。我的目标就是开发独立商业游戏,在合理的时间把它卖给尽可能多的玩家,保证他们愿意为此付费并靠人气登上 Steam 首页推荐。请别误会,我也不是要为了赚钱而不异一切代价,这篇文章就是从一位认真的游戏开发者角度出发,聊聊除技术之外,Rust 是怎么慢慢消磨掉一位从业者对于游戏、玩法和玩家的关注的。
对技术的热情当然没有错,但我认为大家最好认真想想自己到底是在追求什么目标,特别是要以坦诚的态度看待自己的初衷。有时候,我觉得某些项目其实已经走偏了,把本身的意义扭曲成了能够体现出多少技术意义。这不正常,至少在我这位严肃的游戏开发者看来,这不正常。
现在回到泛型系统。我认为以下几点原则可以创造出优秀的游戏,但却以直接或间接的方式违背了泛型 ECS 方法:
- 大部关卡为手动设计。这与“线性”或者“叙事”无关,只是在强调“如何用引导的方式对玩家行为做出控制”。
- 在各个关卡中精心设计每一项交互。
- 视觉特效并不等于大量粒子,而是在所有游戏系统上运行的时间同步事件(例如,将多个不同 emitters 以手动设计的调度机制触发)。
- 反复对游戏进行测试,对游戏功能进行多轮验证,实验并丢弃不好玩的部分。
- 尽快将游戏交付给玩家,以便进行测试和迭代。毕竟发布的时间越晚,上架后玩家们的热情就越弱。
- 务必要提供独特且难忘的游玩体验。
我知道,读到这里,很多朋友会觉得我想要提那种充满艺术气息的游戏,而不是像《异星工厂》那种极具工程师气质的作品。绝对不是,我喜欢那种系统设定极强、有着强烈代码美感的游戏,我也愿意做一些由编程驱动的东西,毕竟我自己也是个不折不扣的程序员。
我觉得大多数人犯的错误,就是误以为认真设计玩家交互就是在搞艺术创作。不是的,认真设计玩家交互就是游戏开发的本质。游戏开发不是在建立物理模型,不是在设计渲染器,更不是搞什么游戏引擎或者场景树,自然也不是带有数据绑定的响应式 UI。
这方面的正面案例当数《以撒的结合》,这是一款简单到看似简陋的“肉鸽”类游戏,包含数百种升级。这些升级项目会以非常复杂精妙的交互方式改变游戏体验。注意,它没有使用“ 15% 伤害”之类简单粗暴的升级机制,而是提供“让炸弹粘在敌人身上”、“将子弹转换为激光”或者“在每个关卡中杀死的第一个敌人,永远不会在后续关卡中出现”之类的巧妙选项。
乍看之下,用预先准备的泛型系统也可以设计出这样的游戏,但这又是我觉得大多数人在游戏开发中犯的另一个错误。游戏开发不是这样的,我们不可能把自己关在小黑屋里整整一年、在考虑了所有极端情况后构建起一套泛型系统,然后指望着它能体现这样一款优秀游戏的全部需求。不是的,我们只能先用少量机制开发出一个原型再交给大家游玩,看看核心机制是否有趣,之后添加点新设计再去收集反馈。其中一些交互只有在玩了几个小时的早期版本并尝试了不同操作之后,才能凭借对游戏的深入认知而得到。
Rust 语言的设计则完全背离这样的逻辑,任何新型升级都可能迫使我们重构所有系统。很多人可能会说,“那太好了,现在我的代码质量更高了,可以容纳更多功能!!!”好吧,这话看似没错,我也已经听过无数遍了。但我想提醒大家,作为一线游戏开发者,Rust 的这种毛病已经导致我浪费了大量时间,只为给错误问题找个所谓的合理答案。
其他更灵活的语言则允许游戏开发者以一种简单粗暴的方式实现新功能,然后马上让游戏跑起来,看看这种机制是否真的有趣。这样我们就能在短时间内快速迭代。就在 Rust 开发者还在重构的时候,C /C#/Java/JavaScript 开发者已经实现了一大堆新的游戏功能,通过试玩进行了体验,而且更好地认识到自己的作品应该朝着哪个方向发展。
Jonas Tyroller 在他关于游戏设计的视频教程中对此做过解释,我也推荐每位游戏开发者都能认真看看。如果各位不知道自己做的游戏为什么就是不好玩(也包括我自己),那答案很可能就在其中。一款好的游戏不是在实验室环境下硬憋出来的,而是由相应类型的高手玩家反馈出来的。游戏制作者本身也应该是个游戏好手,了解设计的各个方面,而且在拿出最终成果之前也体验过很多失败设计。总而言之,一款优秀的游戏就是在非线性的过程中尝试各种糟糕想法,最终筛选和打磨出来的。
Rust 游戏开发生态纯属炒作的产物
Rust 游戏开发生态还很年轻,我们在社区内交流时,大家也普遍承认这一点。至少到 2024 年,社区成员们已经能够坦然接受自己不够成熟的现实。
但从外部视角来看,情况则完全不同,这主要归功于 Bevy 等项目的出色营销。就在几天之前,Brackeys 发布了他们回归 Godot 进行游戏开发的视频。我第一时间看了视频,并对其中提到的令人惊叹的开源游戏引擎抱有极高期待。到 5:20 左右,视频展示了一张游戏引擎市场的份额图,我非常震惊地看到其中列出了三款 Rust 游戏引擎:Bevy、Arete 和 Ambient。
现在我想要特别澄清这一点。更确切地说,我感觉 Rust 本身已经成了一种符号、一种网络 meme,就跟表情动图那样成了人们站队和调侃的素材。这样不好。
Rust 生态系统的常规运作方式,就是一定要高调宣传那些敢于做出最多承诺、展示最漂亮的网站 / 自述文件、拥有最华丽 gif,最重要的就是更能表现抽象价值观的素材。至于实际可用性如何?那都不重要。其实也有很多人在默默做实事,他们不会承诺那些可能永远无法实现功能,而只是尝试以一种有效的方式解决一个问题,但由于不够“性感”、这些项目几乎从来不会被提及,哪怕是出现之后也只被视为“二等公民”。
这里最典型的例子就是 Macroquad。这是一套非常实用的 2D 游戏库,几乎可以在所有平台上运行,提供非常简单的 API,编译速度极快且几乎没有依赖项,最夸张的就是由一个人构建而成。还有一个附带的库 miniquad,负责在 Windows/Linux/MacOS/Android/iOS 和 WASM 上提供图形抽象。然而,Macroquad 犯下了整个 Rust 生态中最严重的“罪行”之一——使用全书状态,甚至可能不健全。这里我说的是“可能”,而且在较真的人看来这种描述并不准确,因为无论出于何种意图和目的,它都仍然是完全安全的,除非你打算在 OpenGL 场景下使用最低级别的 API。我自己使用 Macroquad 已经快两年,从来没遇到过问题。但就是这么一套出色的库,每当被人提起时招来的都是无情的嘲讽和打击,理由就是它符合 Rust 的价值主张——100% 的安全性和正确性。
第二个例子是 Fyrox,这是一款 3D 游戏引擎,拥有完整的 3D 场景编辑器、动画系统以及制作游戏所需要的一切。这个项目同样由单人制作完成,他还利用该引擎开发了一款完整的 3D 游戏。就个人而言,我没有用过 Fyrox,我承认我自己也被那些漂亮的网站、大量 GitHub stars 和夸张的炒作之词蒙蔽了双眼。Fyrox 最近在 Reddit 上倒是获得了一些关注,但让我难过的是,尽管提供完整的编辑器,但它几乎从未在任何宣传视频中被提及——反倒是 Bevy 总是没完没了地露面、刷存在感。
第三个例子是 godot-rust,属于是 Godot Engine 的 Rust 捆绑包。这个库最犯罪的“罪行”,在于它并不属于纯粹的 Rust 解决方案,而只是指向肮脏 C 引擎的捆绑包。我说的可能有点夸张,但从外部视角来看,Rust 社区基本就是这么个谁也瞧不上的德性。Rust 是最纯净的、是最正确的、也是最安全的;C 则是糟糕的、陈旧的、丑陋的、危险的、复杂的。也正因为如此,我们不会在 Rust 游戏开发中使用 SDL,因为我们有 winit;我们不用 OpenGL,因为我们有 wgpu;我们不用 Box2D 或者 PhysX,因为我们有 Rapier;我们还有用于游戏音频的 kira;我们不用 ImGUI,因为我们有 egui。最重要的是,我们绝对不用 C 编写出来的原有游戏引擎,这将亵渎至高无上的“螃蟹”代码大神!任何想要用 rustup default nightly 加快编译速度的开发者,都必须与“螃蟹”签订这神圣的契约。
如果有人想要认真用 Rust 开发一款游戏,特别是 3D 游戏,那我的第一建议就是使用 Godot 和 godot-rust,因为它们至少提供一切必要的功能、而且是真正能交付作品的成熟引擎。我们这个小组花了一年暗用 Godot 3 开发出了 BITGUN,又用 godot-rust 构建出了 gdnative。虽然这段经历确实痛苦非常,但这并不是捆绑包的错,而是我们一直在想办法以各种动态方式把 GDScript 和 Rust 混合在一起。这是我们第一个、也是最大的 Rust 项目,更是我们选择 Rust 的原因。但我想告诉大家,之后我们用 Rust 制作的每一款游戏都并不是游戏,而成了解决 Rust 语言技术缺陷、生态匮乏或者设计决策难题的实践课,且全程受到语言僵化特性的折磨。我并不是说 GDScript 和 Rust 间的互操作很简单,绝对不是。但至少 Godot 提供了“搁置问题、姑且继续”的选项。我觉得大多数选择纯代码方案的开发者都不重视这一点,特别是在 Rust 生态当中,而这种语言真的在用各种各样的别扭设计毁灭我的创造力。
关于 Ambient,我倒是没有太多想说的。毕竟这是个新项目,而且我自己也没用过。但我也没听说过其他人用过,而它却出现在了 Brackeys 的宣传视频当中。
Arete 几个月前发布了 0.1 版本,但由于其声明非常模糊且为闭源代码,所以在 Rust 社区中激起了比较负面的评价。尽管如此,我还是在很多场合看到外人提过它,主要是因为主创团队比较敢“吹”。
至于 Bevy,我当然相信它作为“主要”Rust 游戏引擎的合理性,至少在项目规模和参与人数上绝对堪称主流。他们成功建立起了庞大的技术社区,虽然我不一定同意他们的承诺和领导层的某些选择,但我也必须承认 Bevy 的确很受欢迎。
这一节想聊的,就是让大家感受到 Rust 社区那种奇怪的状态。Rust 以外的朋友看到这些引擎的营销内容和公告博文很可能会信以为真,但我自己也不止一次相信过他们、听到过似乎极具说服力的话,但后来却发现他们只是擅长鬼扯、在实际功能交付上做得很差。
另外值得一提的 是,Rapier 本身并不是游戏引擎。这是一套广受好评的物理引擎,但有望在物理效果层面成为 Box2D、PhysX 等方案的纯 Rust 替代选项。毕竟 Rapier 是用纯 Rust 编写的,因此享有 WASM 支持的所有优势,速度极快、并行核心而且非常安全……大概是吧。
我对它的判断主要源自 2D 应用,虽然基本功能确实有效,但不少更高级的 API 则从根本上存在问题——例如凸分解会在相对简单的数据上崩溃、删除多体关节时也可能导致崩溃。后面这情况特别有趣,因为这让我怀疑自己难道是第一个尝试删除关节的人?这也不是多罕见或者说多极端的用法吧?但总的来说,我还发现 Rapier 的模拟效果极不稳定,并最终迫使我编写了自己的 2D 物理引擎,而且至少在个人测试中发现在“防止敌人重合”等简单问题上表现更好。
我并不是在宣扬自己的物理效果库,因为它没有接受过全面测试。关键在于,如果 Rust 新手想要一套物理引擎,那社区大概率会向其推荐 Rapier,很多人会说这是一套很棒且大受欢迎的库。它还有个很漂亮的网站,在社区中广为人知。行,我承认可能只是个人问题,但我觉得它不好用、甚至为此自己重搞了一套。
不少 Rust 生态项目都有个共性,那就是用 PUA 的方式让用户感觉是自己的错——他们就不该考虑某个问题,就不该用某种方式构建某种功能。这种感觉就类似于使用 Haskell 并想要实现副作用……“你就不该这么干”。
但奇怪的是不只 Rust,一般让用户有这种感觉的库往往会获得普遍赞扬和认可,这可能是因为大多数生态项目都依赖于炒作,而非项目的真实交付效果。
全局状态很烦人 / 不方便,游戏还是单线程的。
我知道,只要说起“全局状态”,很多人就会马上意识到这是个严重的错误。而这也正是 Rust 社区为项目 / 开发者制定的极其有害且不切实际的规则之一。不同项目之间的需求区别很大,至少在游戏开发这类场景下,我觉得很多人其实都没意识到自己到底在解决什么问题。对全局状态的“仇视”也是有范围的,大多数人并不是要 100% 反对,但我仍然觉得 Rust 社区在这个问题上走错了方向。再次重申,我们要讨论的不是引擎、工具包、库、模拟什么的,我们讨论的是游戏作品。
就一款游戏而言,只有一个音频系统、一个输入系统、一个物理世界、一个 deltaTime、一个渲染器、一个资源加载器。也许在某些极端情况下,不用全局状态可能会稍微谁一些;而且如果正在制作基于物理引擎的多人在线游戏,要求可能也会有所不同。但大多数人开发的要么是 2D 平台跳跃游戏、要么是竖版射击游戏,或者是基于体素的步行模拟游戏。
在经历了多年把所有内容都作为参数注入的“纯净”方法(从 Bevy 0.4 开始一路到 0.10),还尝试过构建自己的引擎,我对这种纯全局的设计深恶痛绝,而且播放声音只能靠 play_sound("beep" )。没错,就是深恶痛绝。
我倒不是要专门针对 Bevy,而是发现整个 Rust 生态很大程度都犯了这个错误,唯一的例外就是 Macroquad。而这里之所以以 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 犯下的最大错误之一。很多 Rust 开发者逐渐意识到(虽然很少有人愿意坦然承认),Bevy 的并行系统模型非常灵活,即使是在跨框架情况下也无法保持一致的顺序(至少我上次尝试的时候是这样)。如果要维持排序,就必须指定一个约束。
这乍看下来似乎合理,但在多次尝试在 Bevy 下开发一款大体量游戏(开发周期达几个月,涉及数万行代码)后,最终情况就是开发者不得不指定一大堆依赖项,因为游戏中的事物往往需要以特定顺序发生,以避免因某些内容先运行在随机造成的丢帧甚至是意外错误。但千万别想着跟社区提这事,因为你马上会被口水吞没。Bevy 的设计在技术层面完全正确,只是在真正将其用于游戏开发时,总会引发这样或者那样的问题。
那现在这种设计肯定也有好处吧?比如说可以并行的部分能让游戏运行得更快?
不好意思,在投入大量工作对系统进行严格排序之后,已经没剩下多少能够并行化的东西了。在实践层面,这相当于是对纯数据驱动系统做并行化,只为换取一点点性能提升。这根本不值得,而且用 raycon 也能轻松实现。
回顾这么多年来的游戏开发历程,我用 Burst/Jobs 在 Unity 中编写的并行代码要比自己在 Rust 中实现的多得多。无论是在 Bevy 中还是在自定义代码当中,我的大部分精力都被耗费在了技术上,导致很少有时间能认真考虑怎么让游戏变得更好玩。不开玩笑,我总是在跟语言作斗争、或者围绕语言特性做设计,至少确保不会因为 Rust 的某些“怪癖”而严重破坏开发体验。
全局状态就是其中的典型。我知道这节已经很长了,但我觉得确实有必要进一步解释。让我们先对问题做出明确定义。Rust 作为一种语言,通常提供以下几种选项:
- static mut,不安全,因此每次使用都需要 unsafe,可能在意外误用时会导致 UB。
- static X:AtomicBool(或 AtomicUsize,或任何其他受支持的类型)……一个不错的解决方案,虽然还是烦人,但至少用起来还行,但仅适用于简单类型。
- static X: Lazy<AtomicRefCell<T>> = Lazy::new(|| AtomicRefCell::new(T::new()))……这对大多数类型来说都是必需的,而且不仅在定义和使用方面烦人,还会由于双重借用而导致运行时潜在崩溃。
- ……当然还有“直接传递,别用全局状态”。
我已经记不清有多少次因为双重借用而意外导致崩溃了,这并不是因为代码“在设计之初就很差劲”,而是因为代码库中的其他部分强制进行了重构。在此过程中,我不得不重构对全局状态的使用,并导致了意外崩溃。
Rust 用户可能会说,归根结底这是因为我的代码出错了,而 Rust 是帮我发现了 bug。所以才说全局状态不好,应该尽量避免。说的有理,这种检查也确实能在一定程度上预防这些 bug。但结合我在使用 C# 等简单全局状态的语言时遇到的实际问题,我想提醒大家,至少在游戏开发这类场景下,代码中其实很少会出现这些问题。
另一方面,使用动态借用检查进行任何操作时,由于双重借用而导致的崩溃真的很容易发生,而且通常是源自不必要的理由。其中一例就是对 ECS 重合的原型进行查询。这里给不熟悉 Rust 的朋友说说,以下代码在 Rust 里其实是有问题的(出于可读性而进行了简化):
代码语言:javascript复制for (entity, mob) in world.query::<&mut Mob>().iter() {
if let Some(hit) = physics.overlap_query(mob.position, 2.0) {
println!("hit a mob: {}", world.get::<&mut Mob>(hit.entity));
}
}
问题在于,我们在两个位置上接触到同一个东西。更简单的例子是通过执行类似的操作来对两个东西进行迭代(同样进行了简化):
代码语言:javascript复制for mob1 in world.query::<&mut Mob>() {
for mob2 in world.query::<&Mob>() {
// ...
}
}
Rust 的规则禁止对同一对象设置两个可变引用,凭借可能导致这种情况的行为均不被允许。在上述情况下,我们会遇到运行时崩溃。某些 ECS 方案可以解决这个问题,例如在 Bevy 当中,当查询不相交时,至少可以进行部分重合,例如 Query<(Mob, Player)> 和 Query<(Mob, Not)>,但这只能解决没有重合的情况。
我在关于全局状态的部分也提过这一点,因为一旦事物变得全局化,那么这种限制将变得特别明显,而且很容易意外导致代码库中的其他部分以某种全局引用方式触发 RefCell。再次强调,Rust 开发者会觉得这没问题,因为能预防潜在 bug!但我还是坚持认为,这并没有帮上什么忙,而且我在使用没有此类限制的语言时也没遇到过由此导致的问题。
再就是线程问题,我认为最大的误区就是 Rust 游戏开发者往往认为游戏跟后端服务是一回事,所有内容都必须异步运行才能保持良好状态。在游戏代码中,内容最终必须被打包在 Mutex或 AtomicRefCell中,从而“避免像 C 编程时那样忘记同步访问可能引发的问题”。但这实际上只是在满足编译器对于线程安全的坚持,哪怕整个代码库中没有一个 thread::spawn。
动态借用检查导致重构后意外崩溃
就在写这篇文章的时候,我刚刚发现了另一个由于 World::query_mut 重合而导致游戏崩溃的问题。我们使用 hecs 已经有快两年了,所以问题的根源绝对不是刚开始使用这个库时那种“我不小心嵌套了两个查询”之类的小问题。相反,该代码中有一部分顶层在运行着执行某些操作的系统,而代码的独立部分则在深层使用 ECS 执行某些简单操作。在经过大规模重构之后,它们最终总会意外重合。
这已经不是我第一次遇到这种情况了,通常的建议解决方案就是“你的代码结构太差,所以才会遇到这些问题。你应该重新并调整设计思路。”我不知道该怎么反驳,因为从道理上讲人家说得对,发生这种情况确实是因为代码库中的某些部分设计不理想。但问题是,最终引发崩溃的是 Rust 的强制重构,其他语言根本不会这样。原型重合又不是犯罪,像 flecs 这样的非 Rust ECS 解决方案对此就非常宽容。
但这个问题并不仅限于 ECS。我们在使用 RefCell也曾反复遇到过同样的情况。其中两个.borrow_mut() 最终重合并导致意外崩溃。
让人难以接受的是,引发崩溃的并不只是因为“代码质量太差”。社区的建议一般是“尽量少借用”,但这本质上还是在强调要以正确方式构建代码。而我做的是游戏开发,又不是服务器开发,不可能总是把所有时间和精力都放在代码组织身上。所以,有时会有一个循环要用到 RefCell 中的某些内容,所以把借用扩展到整个循环又有什么错了?但只要循环稍微大一点,并调用到了内部需要相同 cell 的某个系统(通常会带有一些条件逻辑),就很可能立刻引发问题。支持者会说“应该用间接并通过事件执行有条件操作”,但我们说的是可是散布在整个代码库当中的游戏逻辑,而不只是短短 10 行、20 行简单易读的代码。
在完美的世界中,所有内容都将在每次重构时进行测试,每个分支也都将进行评估,代码流既线性又自上而下——但这样的情况永远不存在。而实际情况是,哪怕我们压根不用 RefCell,也得认真设计它们的函数,以便它们能够传递正确的上下文对象或者仅传递所需的参数。
而这一切,对于独立游戏开发来说根本不现实。对那些可能在几天后就被删除的功能进行重构纯粹是浪费时间,这也使得 RefCell 成为部分借用的理解杜门谢客。否则我们就必须把数据重新组织成不同形态的上下文结构、更改函数参数或者用间接方法把事物区分开来。
上下文对象不够灵活
由于 Rust 对程序员有着一套相对独特的限制要求,所以往往会引发很多独有的问题,而这些问题在其他语言中很可能并没有相应的解决方案。
其中一例就是传递上下文对象。在几乎所有其他语言当中,引入全局状态都不是个大问题,无论是以全局变量还是单例的形式。但出于以上种种原因,Rust 再次把简单问题给复杂化了。
人们提出的第一种解决方案就是“只存储对心生需要的任何内容的引用”,但任何具有一定 Rust 经验的开发者都会意识到,这根本就不可能。借用检查器会要求每个引用字段都跟踪其生命周期,而且由于生命周期会成为泛型并污染该类型的每个使用点,所以我们甚至没办法轻松进行实验。
还有另一个问题,这里我觉得也有必要明确聊聊,毕竟经验不多的 Rust 开发者可能根本没注意到。从表面上看,“我就只使用生命周期”似乎也不会怎样:
代码语言:javascript复制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 对其生命周期还是会严肃对待,强迫我们加以重视。而且实际情况比看起来更糟,因为哪怕发现了问题并非想要摆脱,Rust 也不允许存在未使用的生命周期,于是乎:
struct Foo<'a> { x: &'a i32, }
而在重构代码库时,我们最终希望将其更改为:
struct Foo<'a> { x: i32, }
这样肯定不行,因为会存在一个未使用的生命周期。这看起来不是太大的问题,而且在其他语言中往往同样存在,但问题是 Rust 的生命周期通常需要大量“问题解决”和“调试”过程。比如说我们可能会做各种尝试,对于生命周期就是添加和删除。而删除生命周期对 Rust 来说意味着不再使用,那就必须在所有位置把它全都删掉,进而导致大规模级联重构。多年以来,我曾多次遇到过这样的情况,老实讲,最让人抓狂的就是只想用生命周期迭代的方式完成一项非常简单的变更,最后却被迫更改了 10 个不同的位置。
而且哪怕在其他情况下,我们也没法单纯“存储对某事物的引用”,因为生命周期不允许。
Rust 在这里提供一种替代方案,就是以 Rc或 Arc的方式共享所有权。能行,但往往会激发强烈的反对。所以在使用 Rust 一段时间之后,我意识到最好的办法就是悄悄用、别声张。没必要跟那帮 Rust 铁粉坦白,假装没这回事就好了。
遗憾的是,在很多情况下这种共享所有权也不是什么好办法,比如说出于性能的考虑,有时我们根本无法控制所有权、只能获取引用。
Rust 游戏开发的头号技巧就是,“如果在每帧中自上而下传递引用,那么所有生命周期 / 引用问题都会消失”。没错,这招非常有效,就类似于 React 的自上而下传递 props。唯一的问题就是,现在我们需要把所有内容传递到每一个需要它的函数当中。
这乍看起来并不困难,只要正确设计代码,就不会有任何问题。嗯,很多人都这么说,但我真不知道他们自己写的代码永远正确,还是故意拿这个标准出来恶心别人。反正你懂的……
好在还有个办法,就是创建一个用于传递的 conetxt struct 并包含所有引用。这虽然还是有相应的生命周期,但至少只有一个,实际如下:
struct Context<'a> { player: &'a mut Player, camera: &'a mut Camera, // ... }
这样游戏中的每个函数都能接收一个简单的 c: &mut Context 并获取它需要的内容。这就很棒了,对吧?
但前提就是,我们不能借用任何东西。想象一下,如果我们想要运行一个玩家系统,但同时要维持住镜头内容,这时候 player_system 也需要 c: &mut Context,因为我们希望保持一致并避免将 10 个不同的参数都传递过去。可在这样做的时候:
代码语言:javascript复制let cam = c.camera;
player_system(c);
cam.update();
在触及一个字段时,往往会遇到“无法借用 c,因为其已经被借用”的问题,而且部分借用规则明确提到,在触及某个对象时,其整体都会被借用。
如果 player_system 只接触 c.player 倒是没关系,Rust 不会关心其中的具体内容,它只关心类型。而类型说它想要 c,所以必须得获取 c。这个例子看起来有点蠢,但在那些上下文对象比较大的成规模项目中,我们确实经常遇到想在某个地方使用某些字段的子集、同时又想便捷地将其余字段传递至其他地方的情况。
但 Rust 的开发者当然也不傻,它允许我们执行 player_system(c.player),因为部分借用允许我们借用不相交的字段。
因此,那帮支持借用检查器的开发者就会说,我们是设计了错误的上下文对象,应该将其拆分成多个上下文对象,或者根据字段的用途对字段进行分组,以便发挥部分借用机制。比如说把所有镜头内容都放进同一个字段,再把所有跟玩家相关的内容放进另一字段,之后就可以将该字段传递给 player_system,而非传递整个 c。这样不就解决了?
没那么简单,再次回到文章开头,我说了我只是想开发游戏。我做这事不是想要鼓捣类型系统,也不是为了找到最好的结构组织方式来满足编译器的要求。在重新组织上下文对象时,我在单线程代码的可维护性方面没有任何收获。而且在经历了无数次这种情况后,我可以负责任地讲,在下一次进行游戏测试并收集反馈时,我很可能还得再来一次。
这个问题的实质,就是尽管代码没有变更,但由于业务逻辑发生了变化,所以编译器出于过于严苛的要求而强制重构代码。具体问题可能是没有遵循借用检查器的工作方式,而且只关注类型的正确性。只要我们传递当前正在使用的所有字段,那编译过程就没有问题。也就是说,Rust 强迫我们在传递 7 个不同的参数与随时重构代码结构之间二选其一。这两个选项都很烦人,而且纯属浪费时间。
Rust 没有结构类型系统,也就是所谓“拥有这些字段的类型”,也没有任何其他无需重新定义结构及相关内容就搞定问题的解决方案。它只坚持一点:强迫程序员做“正确”的事。
Rust 的优点
整篇文章看下来,好像我把 Rust 批判得一无是处。但在这一节中,我想列举我从 Rust 中发现的积极因素,它们确实在开发过程中帮了我的忙。
只要能成功编译,代码就是正常运行。这是 Rust 的金字招牌,也打消了我原本对“编译器驱动开发”这个理念半信半疑的态度。迄今为止,Rust 最大的优势就是只要人们能编写出适合的代码,那一切都会顺利运行,该语言也会用种种规则引导用户选择正确的编写方式。
从我个人角度来看,Rust 最大的优势体现在开发 CLI 工具、数据操作和算法上。我花了很多时间来编写“Rust 版的 Python 脚本”,其中包括大多数人经常用到的 Python 或者 Bash 小型实用程序。这既是在实际开发、也是个学习的过程,而且令我惊讶的是这确实有效。我绝对不想在 C 里做同样的尝试。
默认强调性能。说回 C#,这里我们要更细粒度的层面上研究 Rust 跟 C# 的性能区别。比如尝试在两种语言编写同一种特定算法,看能不能提到同样的性能。而且尽管在 C# 那边多下了点心力,但 Rust 仍以 1:1.5 到 2.5 的优势胜出。对于经常跑基准测试的朋友来说,这个结果似乎在预料之中。但在自己亲身经历过之后,我真的对随意编写的 Rust 代码居然如此之快深感震惊。
另外我想指出的是,Unity 的 Burst 编译器大大提高了 C# 的性能。但我并没有充足的 A/B 数据来提供具体结论,只能说是观察到 C# 代码明显跑得更快了。
话虽如此,在使用 Rust 的这些年中,我也一直对其代码的运行效果感到惊喜。我注意到,这一切都基于 Cargo.toml 中的以下内容:
代码语言:javascript复制[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 1
我看到有很多人都询问自己的代码为什么跑得很慢,但结果发现他们是在做 debug build。正如 Rust 在开启优化时速度很快一样,在关闭优化后也会速度大降。这里我使用的是 opt-level = 1 而不是 3,因为我在测试中并没注意到运行性能有什么差异,但在我的测试代码上 3 的编译速度明显更慢。
枚举的实现也很漂亮。每位用过 Rust 的朋友应该都有感受,随着时间推移,我更倾向于使用更动态的结构,而不再选择严格的枚举与模式匹配。但至少在枚举更适合的情况下,其效果确实不错,而且几乎是用过的语言中我最喜欢的实现。
Rust 分析器。我不确定这到底算优点还是缺点,这里姑且放在优点里吧。毕竟如果没有它,我 100% 写不出 Rust 代码。自从 2013 年左右首次接触 Rust 以来,这款工具已经迎来显著改进,在实践层面的效果也是非常非常好。
而之所以考虑把它放进缺点里,是因为它仍然是我用过的最糟糕的语言服务器之一。我知道这是因为 Rust 本身非常复杂,而且我自己的项目也情况特殊(这可能是我的错),所以它的崩溃往往属于个例(我一直在保持更新,但还是会在各种设备 / 项目上崩溃)。但尽管如此,分析器还是非常有用、帮助极大,成为我 Rust 开发经历中不可或缺的好助手。
Traits。虽然我并不造成完全消除继承,但也承认 trait 系统相当棒,而且非常适合 Rust。如果能对孤儿原则稍微放松一点的话,那就更好了。尽管如此,能够用上扩展 traits 是 Rust 语言中最让我开心的感受之一。
写在最后
自 2021 年年中以来,我们基本在所有游戏上都在使用 Rust。BITGUN 最初只是作为 Godot/GDScript 项目,之后我们在 Godot 上遇到了寻路问题(性能和功能都不理想),于是我开始研究替代方案,并相继找到 gdnative 和 godot-rust。这已经不是我第一次接触或者使用 Rust,但却是在游戏开发中第一次严肃使用 Rust——在此之前只在 game-jam-y 项目中用过。
从那时起,Rust 成了我唯一坚持使用的语言。我对构建自己的渲染器 / 框架 / 引擎之类感到莫名兴奋,而 Comfy 的早期版本也由此诞生。接下来又发生了很多事,包括支持 CPU 光线追踪的小游戏 jam,到尝试简单的 2D IK、编写物理引擎、实现行为树、实现以单线程协程为中心的异步执行器,再到构建模拟 NANOVOID 乃至 Unrelaxing Quacks,也就是我们发布的第一款、也是最后一款 Comfy 游戏。Unrelaxing Quacks 才刚刚在 Steam 上架,读到本文的时候大家应该就能玩到了。
这篇文章的感受,主要来自我们在开发 NANOVOID 和 Unrelaxing Quacks 两款游戏时的挣扎历程。毕竟到这个时候,我们已经不像最初开发 BITGUN 时那样缺乏 Rust 使用经验了,所以各种问题才显得尤其难以忍受。在此之前,我们还多次使用过 Bevy——BITGUN 是我们尝试移植的第一款游戏,Unrelaxing Quacks 则是最后一款。在开发 Comfy 的两年时间里,我们重写了渲染器,先是从 OpenGL 到 wgpu,然后又从 wgpu 回到 OpenGL。截至本文撰稿时,我已经拥有 20 年左右的编程经验,最早是从 C 开始,之后尝试过包括 PHP、Java、Ruby、JavaScript、Haskell、Python、Go、C# 在内的各种语言,还在 Steam 上发布过 Unity、虚拟 4 还有 Godot 开发的游戏。我是那种喜欢尝试各种方法的人,乐于积极探索并体验一切。按大多数人的标准来看,我们的游戏可能并不是最好的,但我们已经在自己的能力范围内做了一切尝试,努力找到最优解决方案。
我说这一切,是想让大家知道我们已经为 Rust 付出了足够的努力和耐心,这篇文章绝不是出于无知或者经验不足。联想起每当有人就 Rust 提出问题,总有人半开玩笑地回应说“你觉得 Rust 不好用,是因为你的经验还不够”。并不是,我们反复实验过高度动态和纯静态的方法,试过纯 ECS 也尝试过无 ECS,真的。