Godot 中的随机性是被操控的!以下是我们的修复方法
Randomness in Godot is rigged! Here's how we fixed it
15 行 GDScript 即可验证 Godot 4 微秒种子碰撞是否影响项目,文中同时给出单例绕行与 OS CSPRNG 替换的完整路径。

在开发Bobium Brawlers(一款我们在 Godot 4 中制作的卡牌骰子竞技场格斗游戏)时,我们遇到了一个 bug,起初感觉完全不可能,结果发现它就明晃晃地藏在引擎内部。这篇文章讲述了我们如何发现它、如何用十五行 GDScript 复现它,以及在此期间我们采取了哪些应对措施。
那股气味
《Bobium Brawlers》非常依赖重掷机制。玩家重掷骰子、重掷战利品、重掷起手牌。在早期测试阶段,我们在测试反馈中反复听到几乎相同的评价,只是措辞略有不同:"重掷机制像是被操纵过",骰子感觉"卡住了",骰子"连续两次掷出了相同结果"。
我们一度忽略了它。人类在感知随机性方面出了名地差;小的连续序列在毫无意义时也会让人觉得有意义。随机游走聚集、赌徒谬误等等。我们都读过那些论文。
但 Piero 不信。在他的一场对局中,他掷出了一组极其罕见的点数组合,当他重新掷出所有骰子时,又得到了完全相同的组合,顺序也一模一样。连续两次出现这样组合的概率大约只有二十亿分之一。我们开始去查了。
最小复现
我们代码中的模式看起来是这样的,其变体几乎出现在我们见过的每一个 Godot 项目中:
func reroll_dice() -> Array[int]:
var rng = RandomNumberGenerator.new()
var dice: Array[int] = []
for i in 5:
dice.append(rng.randi_range(1, 6))
return dice每次调用都会创建一个新的 RandomNumberGenerator。它在构造函数中自动随机化,所以我们一直把每个实例当作独立的来处理。事实证明,事实并非如此。
以下是精简后的实验。将其放入任意 Godot 4 项目中,作为某个 Node 的 _ready():
const TRIALS := 5000
const DICE := 5
func _ready() -> void:
var rolls: Array[PackedInt32Array] = []
for t in TRIALS:
var rng := RandomNumberGenerator.new()
var r := PackedInt32Array(); r.resize(DICE)
for d in DICE:
r[d] = rng.randi_range(1, 6)
rolls.append(r)
var ties := 0
for t in range(1, TRIALS):
if rolls[t] == rolls[t - 1]:
ties += 1
print("Identical consecutive 5d6 sequences: %d / %d" % [ties, TRIALS - 1])
print("Expected for a fair RNG: ~%.2f" % ((TRIALS - 1) / pow(6.0, DICE)))一个公平的随机数生成器应打印大约 0.64。在 Godot 4.6.2 上我们测得 2168。这超出了预期四个数量级。在一个更紧凑的纯 C++ 复现中(无 GDScript 每次迭代的开销),该比率攀升至 ~96%,几乎每一对连续值都逐字节相同。
实际发生了什么
相关代码位于 core/math/random_pcg.cpp 中:
void RandomPCG::randomize() {
seed(((uint64_t)OS::get_singleton()->get_unix_time()
+ OS::get_singleton()->get_ticks_usec())
* pcg.state + PCG_DEFAULT_INC_64);
}PCG 是一个完全合格的生成器,那部分并不是问题所在。问题在于熵源。两次调用之间唯一会变化的是 gettimeofday 的微秒字段。在现代硬件上构造一个 RandomNumberGenerator 所需时间远不到一微秒,因此连续的 RandomNumberGenerator.new() 调用经常会读取到 相同 的时间戳,最终得到 相同 的种子。
我们进行了验证:在我们硬件上连续 5000 次构造时间采样中,仅出现了 143 个唯一时间戳值,最多有 37 次构造共享同一个时间戳。碰撞正是由此产生的——既不是来自 PCG,也不是来自有偏采样,仅仅是因为微秒级粒度太粗,无法区分前后连续的调用。
顺便说一下,在将种子输入 PCG 之前对其进行哈希处理并不会有帮助。
相同输入的哈希结果就是相同的输出。修复必须在更上游进行:从操作系统 CSPRNG 中获取随机数(Linux 上为 getrandom(2),
Windows 上为 BCryptGenRandom,macOS / BSD 上为 getentropy,
Web 上为 crypto.getRandomValues),这正是所有现代
语言标准库所做的事情。
当前游戏代码中最快捷的变通方案
如果你需要尽快交付且不想深究此事,最省事的 修复方式是保留一个 RNG 并反复使用它。这同时也是大多数语言的默认 建议,你通常会实例化一个 生成器,并在需要随机数的对象的生命周期内从中提取数值, 所以你并没有做任何奇怪的事情:
@onready var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
func reroll_dice() -> Array[int]:
var dice: Array[int] = []
for i in 5:
dice.append(_rng.randi_range(1, 6))
return dicePCG 的流在跨调用之间表现出色;该 bug 仅存在于种子生成环节。 复用单个 RNG 可以完全绕过这个问题,而且速度也略快,因为跳过了每次调用的构造过程。
这就是今天绕过该 bug 的一行修复,但 bug 本身仍然留在引擎里。任何在紧密循环中创建新的
RandomNumberGenerator.new() 的代码,无论在你的项目中还是在你引入的任何附加组件里,都会不断触发它。而实际上你需要逐实例 RNG 也有其正当理由,
这些理由会被这个变通方案悄悄排除在外。我们将在下一节中逐一讨论这两点。
还有一个值得一提的坑:全局 randi() 和 randf() 函数在不传入 RNG 实例的情况下调用时,使用的是 Math::default_rand,它在引擎初始化时用硬编码常量作为种子,且不会自动随机化。如果你希望这些全局函数在不同次游戏启动之间有所变化,请在启动时调用一次 randomize()。(我们把我们的 Main.gd 改为在第一帧时执行此操作。)
那我们为什么仍然需要每个实例独立的 RNG
"直接复用同一个 RNG"可以作为一种临时方案,但它悄无声息地排除了一个真正的
design 选项。以 Bobium Brawlers 中的骰子为例:每颗骰子拥有自己的
RandomNumberGenerator,以每颗骰子的唯一标识符作为种子。当玩家
重新投掷第 3 号骰子时,只有那颗骰子的随机数流会推进。各骰子彼此
独立,也独立于同一帧内其他系统恰好以何种顺序消耗
随机数——粒子特效、AI 平局裁决、环境音效,
统统如此。如果使用单一的全局 RNG,重新投掷第 3 号骰子的结果就会取决于
某个粒子特效是否早了一微秒从该流中取值。
回放不再具有确定性。两个客户端之间的网络同步会出现漂移。
针对某一颗骰子的单元测试会从一条被其他骰子持续推进的流中取值。
一切都变得不稳定,而且极难调试。
你可以基于一个全局随机数生成器,通过手动派生子种子并在各处传递它们来实现此功能,但这属于"用脚本手动去做引擎本可以内部一次性完成的事"的范畴。每实例随机数生成器才是这里的自然设计,而且 Godot 文档对此已有描述("多个实例,每个拥有自己的种子和状态")。种子赋值的 bug 是唯一让这一自然设计尚未能正常工作的原因。
同样的模式出现在所有需要解耦随机性的游戏中:
- 并行运行 Procgen。分块世界要求块 (5, 7) 无论是在块 (5, 8) 之前还是之后生成,都产出相同的内容。自然的设计是从
world_seed XOR chunk_id派生每个块的随机数生成器种子,并让各块并行运行。共享随机数生成器则会导致顺序生成,且输出依赖处理顺序。 - 联网或回放驱动的游戏。每个实体从一个可推导的种子(实体 id 加上运行种子)计算其随机结果。两个客户端可以就相同的结果达成一致,而无需就所有其他实体的执行顺序达成全局共识。
- 掉落表绑定位置而非时间。打开箱子 A 始终获得相同的掉落物,无论玩家先前打开了哪些箱子。
只需从
(run_seed, chest_id)为每个箱子创建一个全新的 RNG 并丢弃即可。
这三个方案在使用每个属主独立 RNG 时都很自然,但在单一的共享 RNG 上则会变成尴尬的样板代码。
等待上游补丁期间的即插即用修复方案
如果你想恢复按实例设计而不必等待上游补丁,我们写了一个小型 GDExtension,其行为与
RandomNumberGenerator 兼容,但每次构造时都从操作系统 CSPRNG 获取种子。
热循环内使用相同的 var rng = ...new(),不折腾,无相关性。
仓库:github.com/atelico/better-godot-rng
# before
var rng = RandomNumberGenerator.new()
# after
var rng = BetterRng.new()相同的方法名、相同的返回类型、相同的含义。内部使用 PCG64-MCG(与 Godot 同族,状态宽度加倍),通过 getrandom Rust crate 进行播种。发布版本包含为 Linux、macOS(通用版)和 Windows 预构建的 .so / .dylib / .dll 文件;将它们复制到项目的 addons/ 文件夹中即可完成。
我们以每次调用 BetterRng.new() 的方式重新运行了相同的 5000 次试验实验:连续 5d6 出现 0 次平局,统计上与一个正确播种的 RNG 被复用 5000 次不可区分。代码异味已消除。
上游修复
我们还在 Godot 引擎上提交了一个 issue 和一个 PR,从根源修复此问题。在 random_pcg.cpp 中做一处小改动,通过平台原生 API 从操作系统 CSPRNG 获取种子。
如果该修复被合入,上述 GDExtension 对于使用包含此修复的 版本的项目而言将不再必要。在此期间,我们将继续维护该扩展, 同时也服务于希望在不升级 Godot 版本的情况下进行反向移植的项目。
要点
几件我们会记住的事:
- 当许多测试者都说出同样的模糊感受时,要相信他们。 "感觉被操纵了"单独来看并不是一个有用的 bug 报告,但三份独立的报告都指向同样的模糊现象,这本身就是"这里确实有问题"的强先验。
- 种子的熵位于种子质量的上游。PCG 系列具有优异的统计特性,Godot 在算法层面正确地使用了它。如果种子来源是退化的,所有这些都白费了。白化、哈希以及巧妙的输出函数都无法修正不良输入。
- 微秒级时钟是粗糙的。一个个位纳秒级的函数 (对象构造)在微秒分辨率下采样会产生严重混叠。 这在随机数生成器之外的很多地方都会出现,比如请求 ID、 日志时间戳,任何隐含“每次调用唯一”假设的地方。
Godot 是一款出色的引擎,其背后的团队打造了一个我们 每天真正乐于在其中工作的工具。这类 bug 之所以能存活下来, 是因为周围的一切都在正确工作。PCG 很扎实,绑定很干净, API 符合你的预期。唯一的薄弱点在于 seeder 中的一行代码, 而现在我们知道了这个问题,它是可以修复的。
如果你在项目中遇到了同样的代码坏味道,上面的实验只需一分钟即可运行,并能明确告诉你是否受影响。如果你确实受影响,解决方案只需改一行代码。
来源:Atelico · AI 游戏开发博客 · atelico.studio