2026 年 7 月 29 日
大量依赖程序化生成的游戏通常希望其随机数生成器是确定性的,这样只要给定一个种子,每个系统每次都会为所有人生成相同的游戏元素。不太依赖 RNG 的游戏可能只使用引擎自带的 RNG 就完事了,但对于生成逻辑更复杂的游戏,更常见的做法是为游戏中每个使用 RNG 来确定游戏状态的不同部分各使用一个独立的随机数生成器。
这种方法非常适合解耦不同系统的输出结果,否则如果在整个项目中只使用一个随机数生成器(例如 Unity 自带的 Random.value 或 Random.Range()),各个系统的输出就会相互关联。当你只在所有系统之间共享一个生成器时,每个系统的输出都会取决于到目前为止它被查询了多少次。假如你喜欢某个地图生成结果,可以保存游戏的共享种子,以便以后生成相同的地图。但当所有系统都在查询并改变同一个生成器的状态时,如果在流程中提前多进行一次物品掷骰,就会把生成器推进,导致等你真正进入地图生成阶段时得到不同的结果。酷炫的地图就没了 :(
为游戏中每个不同的系统各自配备一个 RNG 意味着它们的输出可以完全独立,保证即使你几个月没改过地图生成代码,它仍然会生成完全相同的地图,而不会受到你在敌人属性生成上所做工作的影响。
这种方法的唯一问题是「相关 RNG」(correlated RNG),即当你用同一个初始种子去播种所有 RNG 时,它们会按照相同的顺序生成相同的数字,玩家可能会发现并以你不希望的方式加以利用,最终破坏自己的乐趣。
这种问题在游戏中很常见!《最终幻想 14》每个区域的天气选择都是由单一算法决定的,它使用游戏内当前时间作为种子,导致当「天穹街」出现电磁风暴时,「拉科提卡大森林」总是笼罩在迷雾之中。在《FF14》的范围内,相关的天气状态影响不大,但即使是像《杀戮尖塔》这样的 roguelike 也存在这个问题!Jorbs 做了一个很好的视频,详细讲解了这个问题及其后果:
简单来说,如果你为每个系统生成相同的数字序列,玩家就能从一个系统的生成结果推断出其他系统将做出什么选择。如果你有一个系统使用自己的生成器,需要在 3 个选项中连续选择 5 次,玩家可能会注意到:看到选项 1, 3, 1, 1, 2 就意味着生成器的随机值按以下顺序落在这些区间:0.0 - 0.33、0.66 - 1.0、0.0 - 0.33、0.0 - 0.33、0.33 - 0.66。如果另一个系统总是在 12 个选项中做选择,那么在第一个系统看到选项 1 就意味着在第二个系统必然会看到选项 1-3。
那么,如何在保留多个独立且确定性 RNG(都来自同一种子)的好处的同时,解决这个问题呢?有几种方法!以下是其中一些方法的优缺点:
1. 给每个生成器不同的偏移量……?
这可能是最简单的解决方法,但它更像是一种权宜之计而不是系统性方案。每个 RNG 仍然会生成相同的序列,只是把序列的起点推后了一点。偏移量必须足够大,以免重叠;有些 RNG 并不支持很好地跳过;还需要记录哪些生成器使用了哪些偏移量。我个人认为这个方法比它值得付出的代价更麻烦。
2. 用一个专用的 RNG 来生成种子?
这是我最初尝试的解决方案,而且确实有效!你用游戏的单一种子去播种这个「种子生成器」,然后用它输出的值去播种后续所有 RNG。每个 RNG 都能得到自己独特的序列,同时仍然源自同一个初始种子。这个方法的问题在于,每个 RNG 的状态现在与它们的创建顺序绑定了。这在最初设置完成后比较容易控制,但如果之后移除某个生成器,所有后续创建的生成器都会受到影响。
3. 基于哈希的种子!
这是我自己 RNG 系统最终采用的方案,我认为这是目前最好的办法。每次创建新的 RNG 时,都把共享种子与该 RNG 实例专属的唯一标识符一起做哈希。只要你的哈希算法是确定性的(千万别用 C# 的 HASHCODE STRUCT 哈哈),每个生成器都会拥有完全独立于其他生成器或外部系统的唯一序列,而且创建新 RNG 时几乎不需要额外工作。
我把 RNG 系统写成在构造函数里接受一个可选的字符串/整数用于哈希,因此使用起来非常简单:new RNG(gameSeed, "System Name")。这种方法的额外好处是,你可以很容易地通过把传入的种子与另一个 RNG 的输出做哈希,或者与另一个 RNG 已经哈希过的种子再做哈希,来有意地把 RNG 关联起来。如果你还在 RNG 类里保存输入的哈希值,之后就能用它做调试 ID,或者在序列化时让种子变体更易读。世界任你发挥!
更新:发文后大约 6 小时我发现,MegaCrit(《杀戮尖塔》开发者)在《杀戮尖塔 2》中实际上也采用了几乎完全相同的方法来处理相关 RNG,详情可阅读这里!如果你对这个问题的细节和对游戏性的影响感兴趣,强烈建议同时阅读 tckmn 的文章。
给想知道细节又不想读全文的极客的 TLDR问题出在同时使用了 C# 内置的 System.Random 类以及将运行种子与标识符结合的方法上。System.Random 有一个实现特性:线性递增的种子值会产生线性递增的相关输出,这意味着他们把运行种子与标识符哈希相加的做法(seed = runSeed + hash("identifier"))会导致所有生成器以固定的偏移量输出相同的数字。我很同情开发者——如果我在费尽心思防止相关 RNG 之后才发现这个事实,肯定也会抓狂。把运行种子和标识符一起做哈希也能解决这个问题,但改用 xoshiro256** XORShift 算法是更好的选择,因为 XOR shift 算法在游戏场景下更快、更可靠。
我个人使用 Squirrel Eiserloh 在这个精彩的 GDC 演讲中介绍的 Squirrel3 算法,因为它极易理解、易于序列化(两个 uint)、能在任意位置快速跳转,并且保证在重复之前会以随机顺序返回整个无符号整数序列。好了,别再看这些极客内容了,去玩 《杀戮尖塔 2》 缓解一下我们俩共同的痛苦吧。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.