黑神话:索贝尔序列 - 随机敌人生成 | ebp 黑神话:索贝尔序列 - 随机敌人生成 | ebp 黑神话:索贝尔序列 - 随机敌人生成 | ebp
文章

黑神话:索贝尔序列 - 随机敌人生成

深入探讨索贝尔序列实现游戏中低差异分布的随机敌人生成

黑神话:索贝尔序列 - 随机敌人生成

本文内容基于Unreal Engine 5.6.0

如果我犯了错误,请在下面评论并帮助未来的读者!本文中文翻译由AI机翻,可能不够准确或产生一定的阅读困难。

在 Unreal 中,Sobol.h 中有一个 FSobol 类,实现了这种拟随机序列供我们直接使用,因此我们不必自己编写矩阵。它还支持格雷码顺序评估。

一切的起源

我在一位优秀开发者的敌人生成器代码中看到了这段代码:Skylake-Official Github,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//Generate the i-th Sobol number in dimension d
float AAFPEnemySpawnerActor::Sobol(uint32 d, uint32 i) {
	const uint32 Matrix[8 * 32] = {
        2147483648, 1073741824, 536870912, 268435456, 134217728, 67108864, 33554432, 16777216, 8388608, 4194304, 2097152, 1048576, 524288, 262144, 131072, 65536, 32768, 16384, 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1,
        2147483648, 3221225472, 2684354560, 4026531840, 2281701376, 3422552064, 2852126720, 4278190080, 2155872256, 3233808384, 2694840320, 4042260480, 2290614272, 3435921408, 2863267840, 4294901760, 2147516416, 3221274624, 2684395520, 4026593280, 2281736192, 3422604288, 2852170240, 4278255360, 2155905152, 3233857728, 2694881440, 4042322160, 2290649224, 3435973836, 2863311530, 4294967295,
        2147483648, 3221225472, 1610612736, 2415919104, 3892314112, 1543503872, 2382364672, 3305111552, 1753219072, 2629828608, 3999268864, 1435500544, 2154299392, 3231449088, 1626210304, 2421489664, 3900735488, 1556135936, 2388680704, 3314585600, 1751705600, 2627492864, 4008611328, 1431684352, 2147543168, 3221249216, 1610649184, 2415969680, 3892340840, 1543543964, 2382425838, 3305133397,
        2147483648, 3221225472, 536870912, 1342177280, 4160749568, 1946157056, 2717908992, 2466250752, 3632267264, 624951296, 1507852288, 3872391168, 2013790208, 3020685312, 2181169152, 3271884800, 546275328, 1363623936, 4226424832, 1977167872, 2693105664, 2437829632, 3689389568, 635137280, 1484783744, 3846176960, 2044723232, 3067084880, 2148008184, 3222012020, 537002146, 1342505107,
        2147483648, 1073741824, 536870912, 2952790016, 4160749568, 3690987520, 2046820352, 2634022912, 1518338048, 801112064, 2707423232, 4038066176, 3666345984, 1875116032, 2170683392, 1085997056, 579305472, 3016343552, 4217741312, 3719483392, 2013407232, 2617981952, 1510979072, 755882752, 2726789248, 4090085440, 3680870432, 1840435376, 2147625208, 1074478300, 537900666, 2953698205,
        2147483648, 1073741824, 1610612736, 805306368, 2818572288, 335544320, 2113929216, 3472883712, 2290089984, 3829399552, 3059744768, 1127219200, 3089629184, 4199809024, 3567124480, 1891565568, 394297344, 3988799488, 920674304, 4193267712, 2950604800, 3977188352, 3250028032, 129093376, 2231568512, 2963678272, 4281226848, 432124720, 803643432, 1633613396, 2672665246, 3170194367,
        2147483648, 3221225472, 2684354560, 3489660928, 1476395008, 2483027968, 1040187392, 3808428032, 3196059648, 599785472, 505413632, 4077912064, 1182269440, 1736704000, 2017853440, 2221342720, 3329785856, 2810494976, 3628507136, 1416089600, 2658719744, 864310272, 3863387648, 3076993792, 553150080, 272922560, 4167467040, 1148698640, 1719673080, 2009075780, 2149644390, 3222291575,
        2147483648, 1073741824, 2684354560, 1342177280, 2281701376, 1946157056, 436207616, 2566914048, 2625634304, 3208642560, 2720006144, 2098200576, 111673344, 2354315264, 3464626176, 4027383808, 2886631424, 3770826752, 1691164672, 3357462528, 1993345024, 3752330240, 873073152, 2870150400, 1700563072, 87021376, 1097028000, 1222351248, 1560027592, 2977959924, 23268898, 437609937
	};
	uint32 result = 0;
	uint32 offset = d * 32;
	for (uint32 j = 0; i; i >>= 1, j++)
		if (i & 1)
			result ^= Matrix[j + offset];
	return float(result) * (1.0f / float(0xFFFFFFFFU));
}

//Generate 2D sobol coordinates
FVector2D AAFPEnemySpawnerActor::SobolVec2D(uint32 i)
{
	float u = Sobol(1, i ^ (i >> 1));
	float v = Sobol(2, i ^ (i >> 1));
	return FVector2D(u, v);
}

提交信息说这是一个”低差异随机生成器”,可以按照”受控的加权模式”生成敌人。这些魔数来自所谓的”Sobol 序列”,还有一个奇怪的 i ^ (i >> 1) 让我很困扰:

当时我完全不知道这是什么东西。

经过深入研究数学论文、线性反馈移位寄存器(LFSR)、伽罗瓦域和本原多项式,我终于弄明白了。所以这是我尝试向未来的自己(以及任何遇到这个魔法的人)解释它的过程。

第 1 部分:核心问题 - 为什么纯随机很糟糕

假设你想在玩家周围生成 16 个敌人。你有以下选择:

选项 1:纯随机

1
2
3
4
5
for (int i = 0; i < 16; i++) {
    float x = RandomFloat(0, 1);
    float y = RandomFloat(0, 1);
    SpawnEnemy(x, y);
}

问题: 随机数会聚集。你可能会在同一个角落生成 3 个敌人,而其他地方则有大片空白区域。玩家会注意到这一点。这让人感觉不公平,看起来也很糟糕。

选项 2:网格

1
2
3
4
5
for (int i = 0; i < 16; i++) {
    float x = (i % 4) / 4.0f;
    float y = (i / 4) / 4.0f;
    SpawnEnemy(x, y);
}

问题: 太明显了。玩家会立即看出规律。这让人感觉很人工,可预测。

选项 3:Sobol 序列

给你的点:

  • 分布均匀(无聚集)
  • 看起来随机(无明显规律)
  • 是确定性的(可重现,用于回放)
  • 有效填充空间

这被称为拟随机低差异采样。

第 2 部分:基础 - 范德科普特序列(1D)

在处理 2D 问题之前,让我们先了解如何在一条线上均匀分布点。

朴素方法:直接除以计数

点:0.0, 0.0625, 0.125, 0.1875, 0.25, 0.3125…

这会按顺序添加点,总是在开始处聚集。

范德科普特的洞察(1935):反转位!

1
2
3
4
5
6
7
索引 0: 0000 → 反转 → 0000 → 0.0000 = 0.000
索引 1: 0001 → 反转 → 1000 → 0.1000 = 0.500  (一分为二!)
索引 2: 0010 → 反转 → 0100 → 0.0100 = 0.250  (填充左侧间隙!)
索引 3: 0011 → 反转 → 1100 → 0.1100 = 0.750  (填充右侧间隙!)
索引 4: 0100 → 反转 → 0010 → 0.0010 = 0.125
索引 5: 0101 → 反转 → 1010 → 0.1010 = 0.625
...

这太棒了,真是天才!顺便说一句,以防未来的我忘记了算法,计算浮点数的二进制小数就是 2^(-1)、2^(-2) 等一直到最右边的最低有效位(LSB)的总和

为什么有效:

当你正常计数(0, 1, 2, 3…)时,位从右到左(LSB 到 MSB)变化。当你反转位并将它们视为分数时:

  • 最高有效位变成最低有效位(控制 0.5)
  • 下一位控制 0.25
  • 下一位控制 0.125

这意味着每个新点总是将最大的剩余间隙一分为二。这在 1D 中是可证明最优的!(证明材料在最后的附录中)

第 3 部分:问题 - 扩展到 2D

朴素尝试:对 X 和 Y 都使用位反转

1
2
float x = BitReverse(i);
float y = BitReverse(i);  // 同样的函数!

灾难性失败: 所有点都在对角线 y=x 上!

如果 X 和 Y 使用相同的变换,它们就完全相关。你在 2D 空间中得到一条 1D 线。

稍微好一点的尝试:使用不同的位子集

1
2
float x = BitReverse(i & 0xF);       // 低 4 位
float y = BitReverse((i >> 4) & 0xF); // 高 4 位

这消除了完全相关性,但仍然会创建可见的模式,因为两个维度都使用相同的简单结构。

我们需要的是: 每个维度必须使用不同的数学结构,并且可证明是独立的。

第 4 部分:Sobol 登场(1967)- 突破

苏联数学家 Ilya Sobol 有一个绝妙的想法:如果我们使用不同的数学配方生成每个维度会怎样?

魔法配方:本原多项式

将本原多项式想象成特殊的数学公式,它们生成具有完美数学结构的”最大随机外观”的位模式。

你不需要理解它们如何工作就可以使用 Sobol 序列(目前) - 只需要知道:

  1. 每个多项式生成唯一的位模式序列
  2. 不同的多项式生成不相关的(独立的)序列
  3. 有数千个这样的多项式可用

例如:

  • 维度 1(X): 使用配方 #1(x⁵ + x² + 1)
  • 维度 2(Y): 使用配方 #2(x⁵ + x⁴ + x³ + x² + 1)
  • 维度 3(Z): 使用配方 #3(x⁵ + x⁴ + x² + x + 1)
  • 等等

每个”配方”产生不同的数字模式,当组合时,在多维空间中创建均匀分布的点。

想了解这些配方如何工作? 请参阅第 11 部分,了解本原多项式和 LFSR 的完整技术解释。现在,只需相信它们有效!

第 5 部分:格雷码 - 缺失的拼图

再看一下代码:

1
float u = Sobol(1, i ^ (i >> 1));  // 这是什么?

这个 i ^ (i >> 1) 就是格雷码

什么是格雷码?

一种重新排序整数的方法,使得连续的数字只相差一位:

1
2
3
4
5
6
7
8
9
常规:  格雷码:
   0   →  0000  (0)
   1   →  0001  (1)  ← 只有第 0 位变化
   2   →  0011  (3)  ← 只有第 1 位变化
   3   →  0010  (2)  ← 只有第 0 位变化
   4   →  0110  (6)  ← 只有第 2 位变化
   5   →  0111  (7)  ← 只有第 0 位变化
   6   →  0101  (5)  ← 只有第 1 位变化
   7   →  0100  (4)  ← 只有第 0 位变化

为什么 Sobol 需要这个?

因为每次位翻转对应于添加或删除一个方向向量。这创建了一个结构化的”在空间中行走”,其中每一步都恰好改变一个预先计算的量。

没有格雷码,多个位会同时改变,导致大的不可预测的跳跃。

第 6 部分:算法 - 逐步解析

让我们追踪调用 SobolVec2D(2) 时实际发生的事情:

步骤 1:将索引转换为格雷码

1
2
i = 2
Gray = 2 ^ (2 >> 1) = 2 ^ 1 = 3(二进制:0011)

步骤 2:检查哪些位被设置

1
2
Gray = 0011
位 0 和 1 被设置

步骤 3:对相应的方向数进行异或运算(XOR)

对于维度 1:

1
2
3
4
5
6
7
Direction[0] = 2147483648  (二进制:10000000000000000000000000000000)
Direction[1] = 3221225472  (二进制:11000000000000000000000000000000)

result = Direction[0] XOR Direction[1]
       = 10000000000000000000000000000000
    XOR  11000000000000000000000000000000
       = 01000000000000000000000000000000

步骤 4:归一化到 [0, 1]

1
u = 1073741824 / 4294967295 = 0.25

步骤 5:对维度 2 重复(使用不同的方向数)

1
v = 0.25(这个例子中恰好相同)

结果

点 (0.25, 0.25)

关键洞察: 格雷码告诉你选择哪些方向数,然后你按位对它们进行异或运算以获得坐标。

第 7 部分:为什么是 XOR?(自逆性质)

XOR 有一个神奇的性质:它是自己的逆运算

1
A XOR B XOR B = A

这意味着:

  • 对某物进行 XOR 可以添加它
  • 再次对它进行 XOR 可以删除它(同样的操作!)

与加法比较:

1
A + B - B = A  (需要不同的操作)

为什么这对 Sobol 很重要:

当格雷码将一位从 1→0 翻转时,我们需要”删除”那个方向向量。使用 XOR,我们只需… 再次 XOR 它。添加和删除使用相同的操作!

1
2
3
步骤 1:Gray = 001 → XOR Direction[0] → result = 10000000
步骤 2:Gray = 011 → XOR Direction[1] → result = 11000000  (添加)
步骤 3:Gray = 010 → XOR Direction[0] → result = 01000000  (删除!)

这就是为什么格雷码 + XOR 是完美的配对:

  • 格雷码: “一次改变一件事”
  • XOR: “添加和删除使用相同的操作”

一起:增量式、可逆、结构化的导航。

第 8 部分:历史背景

这些魔法不是凭空出现的:

  • 1935 - 范德科普特: 1D 的位反转
    • → 可证明最优的 1D 分布
  • 1960 - Halton: 使用不同的质数基扩展到多维
    • → 有效,但维度之间存在相关性问题
  • 1967 - Sobol: 使用本原多项式而不是质数
    • → 每个维度获得数学上独立的结构
    • → 这是突破

Sobol 没有发现本原多项式(自 1800 年代以来就用于纠错码)。他没有发明格雷码(自 1950 年代以来就用于轴编码器)。他以新颖的方式结合了现有的数学工具

魔法不在于单个部分。它在于认识到:

  • 本原多项式生成独立的位模式
  • 格雷码实现增量更新
  • XOR 实现可逆操作
  • 它们一起创建了可证明的低差异序列

第 9 部分:游戏开发者的实用技巧

何时使用 Sobol 序列:

适用于:

  • 敌人生成(如原始代码中)
  • 粒子系统发射点
  • 程序纹理采样
  • 蒙特卡罗渲染
  • 任何需要”随机但均匀分布”点的时候

不适用于:

  • 简单的随机事件(抛硬币、掷骰子)
  • 当需要聚集时
  • 高频更新(计算成本很重要)

实现说明:

Matrix 值是常量: 它们几十年前就使用本原多项式预先计算了。你可以从参考实现(如顶部的代码)中复制它们。(或者直接调用Unreal的FSobol

维度限制: 硬编码的 Matrix 通常支持 8-10 个维度。要更多,你需要生成额外的方向数。

索引很重要: 始终按顺序递增索引(0, 1, 2, 3…)。不要跳过,否则你会失去低差异特性,因为前置位的结果会成为下一个步进的输入。

重置: 如果重新开始生成,从索引 0 重新开始。序列是确定性的。

常见陷阱:

1
2
3
4
5
6
7
8
9
// 错误 - 相同的索引产生相同的点
for (int i = 0; i < count; i++) {
    Spawn(SobolVec2D(0));  // 总是 (0,0)!
}

// 正确 - 递增索引
for (int i = 0; i < count; i++) {
    Spawn(SobolVec2D(i));
}
1
2
3
4
5
// 错误 - 随机索引破坏序列
for (int i = 0; i < count; i++) {
    int randomIndex = rand();
    Spawn(SobolVec2D(randomIndex));  // 只是随机,不是拟随机!
}

深入探讨部分 - 给好奇者

下面的部分深入探讨 Sobol 序列背后的数学机制。如果你只是想有效地使用它们,可以跳到结论

对于那些想了解为什么这有效以及如何生成魔数的人,请继续阅读!


第 11 部分:本原多项式和 LFSR - 数学机制

还记得第 4 部分中我们说每个维度使用不同的”数学配方”(本原多项式)吗?现在让我们了解这些配方到底是什么以及它们如何工作。

什么是本原多项式?

GF(2) = “2 阶伽罗瓦域” = “二进制算术中加法是 XOR (算术乘法是 AND)”的花哨术语

1
2
3
4
5
0 + 0 = 0
0 + 1 = 1
1 + 1 = 0(因为 XOR)
0 × 1 = 0(AND)
1 × 1 = 1(AND)

本原多项式 = 一个特殊的多项式,在二进制算术中生成最大长度序列。

把它想象成创建”看起来最随机”的位模式的配方,实际上具有深刻的数学结构。

例子:x³ + x + 1

这是一个本原多项式。当你在线性反馈移位寄存器(LFSR)中使用它时,它会循环遍历所有 7 个可能的 3 位状态(不包括 000):

1
001 → 100 → 010 → 101 → 110 → 111 → 011 → (重复)

那是 2³ - 1 = 7 个状态。最大可能!

LFSR 实际如何工作?

LFSR(线性反馈移位寄存器) 是一个移位寄存器,其输入位是其先前状态的线性函数。把它想象成一排位:

  1. 每个时钟周期移位一个位置
  2. 通过对特定位置进行 XOR 来反馈计算新位

对于本原多项式 x³ + x + 1,这是 LFSR 电路:

1
2
3
4
5
6
7
8
9
┌────────────────────────────┐
│                            │
│  ┌───┐  ┌───┐  ┌───┐       │
└─►│ S₂│─►│ S₁│─►│ S₀│──────►│ 输出
   └───┘  └─┬─┘  └─┬─┘       │
            │      │         │
            └──XOR─┘         │
                │            │
                └────────────┘

组件:

  • S₂, S₁, S₀ = 三个 1 位存储单元(”寄存器”)
  • 抽头在位置 1 和 0(S₁ 和 S₀),由多项式系数 x¹ 和 x⁰ 确定
  • 反馈 = S₁ ⊕ S₀

多项式 → 抽头位置:

多项式 x³ + x + 1 告诉我们:

  • → 阶数 3,所以我们需要 3 位
  • → 在位置 1 抽头(从右边开始,从 0 计数)
  • x⁰(+1) → 在位置 0 抽头

抽头规则: 对位置 1 和 0 进行 XOR,反馈到最左边的位置

逐步:生成序列

让我们从状态 001 开始追踪:

1
初始状态:[S₂ S₁ S₀] = [0 0 1]

时钟周期 1:

1
2
3
4
5
6
7
8
9
10
11
12
当前状态:[0 0 1]

反馈计算:
  new_bit = S₁ ⊕ S₀     (在 x¹ 和 x⁰ 处抽头)
          = 0 ⊕ 1
          = 1

右移 + 插入反馈:
  [0 0 1] → 移位 → [? 0 0]
          → 插入 1 → [1 0 0]

下一个状态:[1 0 0]

时钟周期 2:

1
2
3
4
5
6
当前状态:[1 0 0]

反馈:
  new_bit = S₁ ⊕ S₀ = 0 ⊕ 0 = 0

下一个状态:[0 1 0]

时钟周期 3: [0 1 0] → 反馈 = 1 ⊕ 0 = 1 → [1 0 1]

时钟周期 4: [1 0 1] → 反馈 = 0 ⊕ 1 = 1 → [1 1 0]

时钟周期 5: [1 1 0] → 反馈 = 1 ⊕ 0 = 1 → [1 1 1]

时钟周期 6: [1 1 1] → 反馈 = 1 ⊕ 1 = 0 → [0 1 1]

时钟周期 7: [0 1 1] → 反馈 = 1 ⊕ 1 = 0 → [0 0 1] ← 回到开始!

完整周期

1
001 → 100 → 010 → 101 → 110 → 111 → 011 → (001) ...

那正好是 7 个状态 = 2³ - 1 = 所有可能的非零 3 位状态!

为什么排除 000? 因为 000 ⊕ 000 = 000 永远。全零状态是一个”死状态”,永远无法逃脱。

为什么本原多项式是特殊的

关键性质: 本原多项式生成最大长度序列(m 序列)。

对于阶数 n:

  • 总可能状态:2^n
  • 非零状态:2^n - 1(不包括全零)
  • 本原多项式在重复之前循环遍历所有 2^n - 1 个状态

为什么 x³ + x + 1 是本原的:

  1. 它是不可约的(不能在 GF(2) 上分解)
  2. 周期恰好是 2³ - 1 = 7(我们刚刚证明了这一点!)
  3. 它满足数学测试:它除 x^7 - 1 但不除任何更小的 x^k - 1

为什么 x⁴ + 1 不是本原的?

因为它可以分解:x⁴ + 1 = (x + 1)⁴ 在 GF(2) 中

因为它有因子,所以不能生成最大长度序列。让我们看看实际会发生什么:

x⁴ + 1 的 LFSR:

1
2
3
4
5
6
只在位置 0 抽头(因为 x⁴ + x⁰)
状态:[S₃ S₂ S₁ S₀]
反馈 = S₀(只是复制最后一位!)

从 0001 开始:
0001 → 1000 → 0100 → 0010 → 0001  (周期 = 4,不是 15!)

它只循环遍历 4 个状态,不是最大 15 = 2⁴ - 1。这就是为什么它不是本原的 - 它不能到达所有可能的状态。

左移 vs 右移:方向重要吗?

简短回答: 不重要!方向无关紧要 - 你只是以相反的顺序遍历相同的周期。

x⁴ + x + 1 的例子:

右移 LFSR:

1
2
0001 → 1000 → 0100 → 0010 → 1001 → 1100 → 0110 → 1011
→ 0101 → 1010 → 1101 → 1110 → 1111 → 0111 → 0011 → (0001)

左移 LFSR(相同的多项式,相反的方向):

1
2
0001 → 0011 → 0111 → 1111 → 1110 → 1101 → 1010 → 0101
→ 1011 → 0110 → 1100 → 1001 → 0010 → 0100 → 1000 → (0001)

注意:相同的 15 个状态,只是顺序相反!

方向的选择纯粹是一个约定。不同的实现偏好不同的方向:

  • 右移: 在数字设计教科书中更常见
  • 左移: 在软件实现中经常使用(位移操作感觉更自然)

两者都到达所有 15 个状态,并且具有完全相同的周期。你只是绕着同一个循环走,只是顺时针和逆时针。

二进制空间 vs 十进制空间:秩序 vs 混沌

这就是有趣的地方!让我们看看 x⁴ + x + 1 序列的十进制值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
二进制    十进制
0001  →     1
1000  →     8
0100  →     4
0010  →     2
1001  →     9
1100  →    12
0110  →     6
1011  →    11
0101  →     5
1010  →    10
1101  →    13
1110  →    14
1111  →    15
0111  →     7
0011  →     3

在十进制中: 1, 8, 4, 2, 9, 12, 6, 11, 5, 10, 13, 14, 15, 7, 3

这看起来完全随机!没有明显的模式。

但在二进制空间中? LFSR 正在执行非常结构化的行走:

  • 每一步都翻转一个或两个特定的位(基于 XOR 反馈)
  • 位模式最大程度地分散
  • 序列系统地探索整个二进制状态空间

为什么这对 Sobol 序列很重要

这是 Sobol 序列背后的关键洞察

  1. 在二进制空间中: LFSR 生成高度结构化、均匀分布的位模式
  2. 在十进制/几何空间中: 这些模式看起来随机且分布良好
  3. 投影到 [0,1]: 方向数(缩放的 LFSR 输出)创建低差异序列

所以当你使用 Sobol 生成敌人时:

  • 数学看到: 32D 二进制超立方体中精心编排的行走
  • 玩家看到: “随机”但分布良好的敌人位置
  • 开发者看到: 只是有效的魔数™

十进制空间中的混沌实际上是一个特征,而不是错误 - 这就是使点看起来随机,同时在底层保持完美数学结构的原因!

第 10 部分:超立方体视角(令人费解的部分)

这就是变得奇怪的地方,但请坚持下去。

XOR 没有几何意义

当你对两个数字进行 XOR 时,你不是在添加向量。你不是在空间中移动。你是在 32 维二进制空间中操作,其中每一位都是一个单独的轴。

1
10000000 XOR 10100000 = 00100000

这不是”组合这些方向”。它是”在 32D 超立方体中翻转特定的位开/关”。

投影技巧

  1. 你导航一个 32D 二进制超立方体(一次沿一个轴走一步,感谢格雷码)
  2. 超立方体中的每个位置都是一个 32 位整数
  3. 你通过除以 2³² - 1 将其投影到 [0, 1]
  4. 这种投影,令人惊讶地,产生均匀分布的点

为什么这有效?

本原多项式确保当你在 32D 超立方体中行走时,投影的 1D 坐标系统地探索整个 [0, 1] 区间,没有聚集或模式。

这就像 3D 螺旋投影到 2D 时看起来随机,但它实际上在 3D 中是完全结构化的。同样的想法,但 32D → 1D。

“方向数”这个误称

它们不是几何方向。它们是 32D 二进制空间中的位模式。这个术语被保留下来是因为它听起来直观,但在技术上是不准确的。

第 12 部分:综合 - 所有部分如何组合在一起

现在我们已经探索了 LFSR、本原多项式和超立方体视角,这是所有东西如何连接的:

完整图景

在表面上(第 6 部分):

  • 格雷码告诉你要对哪些方向数进行 XOR
  • 每个维度使用不同的方向数
  • 结果归一化到 [0, 1]

在引擎盖下(第 11-10 部分):

  • 方向数来自通过 LFSR 的本原多项式
  • 每个多项式在 GF(2) 中生成最大长度序列
  • 不同的多项式 = 不相关的序列 = 独立的维度
  • 你在 32D 二进制超立方体中行走,一次翻转一位
  • 从超立方体 → [0,1] 的投影创建低差异特性

关键洞察

不要从几何上思考。 从组合的角度思考:

你不是”在空间中移动” - 你是选择和组合预先计算的位模式。[0,1] 中的均匀分布是以下因素的涌现特性:

  1. 最大长度序列(来自本原多项式)
  2. 单位转换(来自格雷码)
  3. 可逆组合(来自 XOR)

“魔法”在于这种数学结构,当投影到连续空间时,产生可证明最优的点分布。

为什么这对游戏开发很重要

理解这一点让你可以:

  • 自信地调试: 知道为什么改变索引顺序会破坏一切
  • 正确扩展: 使用 Joe & Kuo 多项式生成你自己的维度
  • 智能优化: 缓存格雷码转换,预先计算 XOR
  • 清晰解释: “这不是随机的,它是通过二进制空间的确定性行走”

第 13 部分:揭开魔法矩阵的神秘面纱 - 这些数字从哪里来?

你可能会想:”好的,我理解如何使用 Matrix,但这些特定的数字从哪里来?有人只是编造它们吗?”

不!它们是使用递推关系从本原多项式系统地生成的。让我展示给你看。

Bratley-Fox 算法(1988)

这是生成 Sobol 方向数的标准算法:

1
2
输入:阶数为 s 的本原多项式,系数为 a
输出:一个维度的方向数

步骤 1:初始化前 s 个方向整数

这些被称为 m[1]m[2]、…、m[s],必须是:

  • 奇数
  • m[i] < 2^i

简单初始化:只使用全 1

1
2
3
4
m[1] = 1
m[2] = 1
m[3] = 1
...

(生产实现使用更复杂的值以获得更好的分布)

步骤 2:应用递推关系

对于具有二进制表示 a 的本原多项式,计算剩余值:

1
m[i] = 2*a₁*m[i-1] ⊕ 4*a₂*m[i-2] ⊕ ... ⊕ 2^s*m[i-s] ⊕ m[i-s]

其中 ⊕ 是 XOR,aⱼ 是 a 的第 j 位。

步骤 3:转换为方向数

方向数是:

1
v[i] = m[i] * 2^(32-i)

这将 m[i] 移位到占据 32 位整数的最高有效位。

具体例子:x³ + x + 1

让我们为本原多项式 x³ + x + 1 生成方向数:

设置:

  • 阶数 s = 3
  • 二进制表示:a = 1011(x³、x²、x¹、x⁰ 的位)
  • 系数:a₂ = 0(无 x² 项),a₁ = 1(有 x¹ 项)

步骤 1:初始化

1
2
3
m[1] = 1
m[2] = 1
m[3] = 1

步骤 2:对 m[4] 应用递推关系

对于 x³ + x + 1(系数:a₂=0, a₁=1):

1
2
3
4
m[4] = 2*1*m[3] ⊕ 4*0*m[2] ⊕ 8*m[1] ⊕ m[1]
     = 2*1 ⊕ 0 ⊕ 8*1 ⊕ 1
     = 2 ⊕ 8 ⊕ 1
     = 11

继续(注意:a₂=0,所以 4*a₂*m[i-2] 项等于 0,可以省略):

1
2
3
4
m[5] = 2*1*m[4] ⊕ 4*0*m[3] ⊕ 8*m[2] ⊕ m[2] = 22 ⊕ 0 ⊕ 8 ⊕ 1 = 31
m[6] = 2*1*m[5] ⊕ 4*0*m[4] ⊕ 8*m[3] ⊕ m[3] = 62 ⊕ 0 ⊕ 8 ⊕ 1 = 55
m[7] = 2*1*m[6] ⊕ 4*0*m[5] ⊕ 8*m[4] ⊕ m[4] = 110 ⊕ 0 ⊕ 88 ⊕ 11 = 61
...

步骤 3:转换为方向数

1
2
3
4
5
v[0] = m[1] << 31 = 1 << 31 = 2147483648 = 0b10000000000000000000000000000000
v[1] = m[2] << 30 = 1 << 30 = 1073741824 = 0b01000000000000000000000000000000
v[2] = m[3] << 29 = 1 << 29 = 536870912  = 0b00100000000000000000000000000000
v[3] = m[4] << 28 = 11 << 28 = 2952790016
...

这些就是你的方向数!

不同的多项式 = 不同的维度

Sobol 多维独立性的关键是对每个维度使用不同的本原多项式:

维度 多项式 二进制表示
0 x² + x + 1 0b111
1 x³ + x + 1 0b1011
2 x⁴ + x + 1 0b10011
3 x⁵ + x² + 1 0b100101
4 x⁶ + x + 1 0b1000011

每个多项式通过递推关系生成自己的方向数集。这些集合在数学上是独立的,确保维度之间没有相关性。

为什么要预先计算?

方向数是:

  • 确定性的 - 相同的多项式总是给出相同的数字
  • 生成成本高 - 需要仔细实现
  • 重复使用 - 每个 Sobol 样本都访问它们

所以它们计算一次(离线,几十年前)并硬编码为常量。你代码中的 Matrix 是为 8 个维度运行此算法的结果,每个 32 位。

生成你自己的 Matrix

如果你需要更多维度或想验证数字,这是简化的 Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def generate_sobol_direction_numbers(s, a, num_bits=32):
    """
    生成 Sobol 方向数。

    参数:
        s: 本原多项式的阶数
        a: 系数作为二进制数(例如,x^3+x+1 为 0b101)
        num_bits: 生成多少个方向数
    """
    m = [0] * (num_bits + 1)  # 1-indexed

    # 初始化前 s 个值(通常全为 1)
    for i in range(1, s + 1):
        m[i] = 1

    # 递推关系
    for i in range(s + 1, num_bits + 1):
        m[i] = m[i - s]
        for j in range(1, s):
            if a & (1 << (s - 1 - j)):
                m[i] ^= (1 << j) * m[i - j]
        m[i] ^= (1 << s) * m[i - s]

    # 转换为方向数
    return [m[i] << (32 - i) for i in range(1, num_bits + 1)]

生产 vs 简单实现

重要警告: 上面的简化算法使用 m[i] = 1 进行初始化。生产实现(如你游戏代码中的那个)使用精心选择的初始值,提供更好的低差异特性。

Matrix 中的”魔法”不仅仅是递推关系 - 它也是由数学家优化的特定初始化值。这些值发表在论文中(Bratley & Fox 1988,Joe & Kuo 2008)并被广泛重用。

在哪里获得官方值

除非你知道自己在做什么,否则不要生成自己的!使用已发布的表:

  • Joe & Kuo 数据库: 标准参考,最多 21,201 个维度
  • Numerical Recipes: 包含常见维度的表

  • 开源实现:
    • GSL(GNU Scientific Library)
    • scipy.stats.qmc.Sobol(Python)
    • 各种 C++ 库

结论

那些”魔数”根本不是魔法。它们是:

  • 通过递推关系从本原多项式生成的
  • 用精心选择的初始值优化的
  • 由研究人员预先计算和发布的
  • 在所有实现中用作常量

你站在几十年数学研究的肩膀上。你代码中的 Matrix 代表了 1960 年代至 2000 年代数学家数百小时的优化和验证工作。

自信地使用它 - 但也要尊重那些看似随机的数字背后的复杂性!

第 14 部分:数学基础

在本节内容中,我的数学知识已经不足以验证以下证明,整个推导由 AI 起草,因此我强烈建议对此持保留态度,并通过适当的数学来源进行验证。我仍然将其包含在这里以供参考,万一未来的我获得所需的足以验证它的数学知识呢。

如果你想要正式的数学证明,说明为什么范德科普特和 Sobol 序列如此有效,这是差异理论的基础。

1. 基数反转函数(正式定义)

范德科普特序列正式使用基数反转函数 φ_b(n) 定义:

对于具有 b 进制表示的整数 n:

1
n = Σ(i=0 to k) aᵢ · bⁱ  其中 0 ≤ aᵢ < b

基数反转是:

1
φ_b(n) = Σ(i=0 to k) aᵢ · b⁽⁻ⁱ⁻¹⁾

对于二进制(b=2),这正好是位反转:

1
2
n = aₖ·2ᵏ + aₖ₋₁·2ᵏ⁻¹ + ... + a₁·2 + a₀
φ₂(n) = a₀·2⁻¹ + a₁·2⁻² + ... + aₖ·2⁻⁽ᵏ⁺¹⁾

2. 差异 - 正式度量

星差异 D*_N 衡量 N 个点分布的均匀程度:

1
D*_N = sup_{0≤x≤1} |A([0,x), N)/N - x|

其中:

  • A([0,x), N) = [0, x) 中的点数
  • x = 如果完全均匀的预期比例

下界(已证明): 对于任何序列,D*_N ≥ C·log(N)/N

这是理论最小值 - 你不能做得比 O(log N / N) 更好。

3. 范德科普特达到最优差异

定理(范德科普特,1935):

对于基数为 b 的范德科普特序列:

1
D*_N ≤ (1/2 + (b-1)/(2b)) · log_b(N)/N + O(1/N)

对于二进制(b=2):

1
D*_N ≤ (3/4) · log₂(N)/N + O(1/N)

这渐近地匹配下界 → 可证明最优

4. 为什么特别是位反转?

证明依赖于分析二进制区间中的分布 [k/2^m, (k+1)/2^m)。

关键引理: 对于范德科普特序列,在任何长度为 2^(-m) 的二进制区间中:

  • 预期点:N/2^m
  • 实际点:最多相差 O(log N)

为什么? 因为:

  1. 位位置 i 控制 2^(-i) 尺度的细分
    • 当位 i 翻转时,它向分数添加/删除 2^(-i-1)
    • 这对应于在该尺度上细分
  2. 位以分层顺序翻转
    • 位 0 每步翻转 → 最细细分
    • 位 k 每 2^k 步翻转 → 最粗细分
  3. 反转创建分层
    • 反转映射:”翻转频率” → “细分尺度”
    • 低频率(粗)翻转控制大跳跃(0.5)
    • 高频率(细)翻转控制小跳跃(2^(-k))

正式陈述:

对于范德科普特生成的 N 个点,二进制区间 [k·2^(-m), (k+1)·2^(-m)) 中的点数是:

1
A_{m,k} = N/2^m ± O(log N)

O(log N) 误差是不可避免的(由下界证明),但范德科普特达到了这个界。

5. 证明草图

定理: 范德科普特序列的差异 D*_N = O(log N / N)。

证明大纲:

  1. 将 [0,1) 划分为 2^(-m) 尺度的二进制区间:
    1
    
    I_k = [k/2^m, (k+1)/2^m) 对于 k = 0, 1, ..., 2^m - 1
    
  2. 计算每个区间中的点:
    • 具有 N 个点的范德科普特:每个区间获得 N/2^m ± ε 个点
    • ε 误差来自边界效应
  3. 关键洞察: 当你在范德科普特中生成点 n 时:
    • 它在 2 进制中的位置是:n = Σ aᵢ·2ⁱ
    • 反转的位置是:φ₂(n) = Σ aᵢ·2⁽⁻ⁱ⁻¹⁾
    • 系数 aᵢ 确定在 2^(-i) 尺度上的哪个二进制子区间
  4. 计数论证:
    • 点 0 到 2^m - 1 在 2^(-m) 尺度上恰好击中每个二进制区间一次
    • 点 2^m 到 2^(m+1) - 1 再次击中每个区间
    • 对于一般 N,”余数”点最多导致 log(N) 误差
  5. 界定差异:
    1
    2
    3
    4
    5
    6
    
    对于任何 x ∈ [0,1),选择二进制近似 k/2^m,使 |x - k/2^m| < 2^(-m)
    
    |A([0,x), N)/N - x| ≤ |A([0, k/2^m), N)/N - k/2^m| + 2^(-m)
                        ≤ O(log N / N) + 1/2^m
    
    选择 m = log₂(N) → 差异 = O(log N / N)
    
  6. 最优性: 匹配下界 → 不能做得更好!

关键数学洞察

位反转有效的原因不是几何的 - 它是数论的

b 进制数字反转在 b 进制二进制有理数中创建模 1 的等分布。

更正式地说:

序列 {φ_b(n)} 在 [0,1) 中是等分布的,差异受每个尺度上数字贡献总和的限制,按 base^(-position) 加权。

分层位翻转确保:

  • 在探索细尺度(小位)之前探索粗尺度(大位)
  • 每个尺度都对差异界贡献加法
  • 总差异总和为 O(log N / N)

在哪里找到完整证明

完整的严格证明使用:

  • Weyl 的等分布准则(傅里叶分析方法)
  • Erdős-Turán 不等式(将等分布转换为差异界)
  • 二进制分区论证(组合计数)

请参阅下面的参考资料部分以获取详细来源。

TL;DR: 数学证明是差异理论。范德科普特证明位反转序列达到 O(log N / N) 的理论最小差异,这被证明是无法打败的。”间隙分裂”的直觉是正确的,但正式证明使用测度论和基数 2 表示的数论性质。

结论

Sobol 序列是那些”易于使用,难以理解”的算法之一。实现只有约 10 行代码,但底层的数学机制很深。

你不需要理解伽罗瓦域或本原多项式就可以使用 Sobol 序列。但如果你像我一样,不能忍受在不理解的情况下使用”魔法”,希望这有所帮助。

关键要点:

  • 拟随机 ≠ 随机 - 它是看起来随机的结构化采样
  • 格雷码 + XOR 是增量更新的完美配对
  • 本原多项式 在维度之间提供数学独立性
  • 在超立方体中思考, 而不是几何空间
  • Matrix 值是预先计算的 - 你只是查找它们并进行 XOR

现在去生成一些均匀分布的敌人吧!

参考文献和进一步阅读

主要来源

  1. Weyl, H. (1916). “Ueber die Gleichverteilung von Zahlen mod. Eins”(”关于模 1 的数字均匀分布”)。Mathematische Annalen,77(3),313-352。
    • 关于等分布理论的基础论文
  2. van der Corput, J.G. (1935). “Verteilungsfunktionen I-II。” Proceedings of the Koninklijke Nederlandse Akademie van Wetenschappen,38,813-821,1058-1066。
    • 引入了基数反转函数和位反转序列
  3. Halton, J.H. (1960). “关于评估多维积分时某些拟随机点序列的效率。” Numerische Mathematik,2,84-90。
    • 使用不同的质数基将范德科普特扩展到多维
  4. Sobol, I.M. (1967). “立方体中点的分布和积分的近似评估”(俄语)。Zhurnal Vychislitel’noi Matematiki i Matematicheskoi Fiziki,7,784-802。
    • 英文翻译:USSR Computational Mathematics and Mathematical Physics,7,86-112。
    • 引入基于本原多项式序列的突破性论文
  5. Bratley, P. and Fox, B.L. (1988). “算法 659:实现 Sobol 的拟随机序列生成器。” ACM Transactions on Mathematical Software,14(1),88-100。
    • 生成最多 40 个维度的 Sobol 方向数的标准算法
  6. Joe, S. and Kuo, F.Y. (2008). “用更好的二维投影构造 Sobol 序列。” SIAM Journal on Scientific Computing,30,2635-2654。

教科书和综合参考

  1. Kuipers, L. and Niederreiter, H. (1974). 序列的均匀分布。John Wiley & Sons,纽约。
    • 重印版:Dover Publications(2006)
    • 关于均匀分布理论的权威参考
    • 第 2 章,定理 3.1 证明了基数反转序列的差异界
  2. Motwani, R. and Raghavan, P. (1995). 随机化算法。Cambridge University Press,纽约。
    • 第 5.5 节涵盖拟随机序列,具有可访问的证明
    • 计算机科学家的优秀入门
  3. Dick, J. and Pillichshammer, F. (2010). 数字网络和序列:差异理论和拟蒙特卡罗积分。Cambridge University Press。
    • 第 4 章提供了 Sobol 序列的现代处理
    • 全面覆盖当代拟蒙特卡罗方法

技术资源

  1. Živković, M. “本原二进制多项式表。” Mathematics of Computation
  2. Partow.net 本原多项式列表

实现参考

  1. Joe, S. and Kuo, F.Y. (2003). “关于算法 659 的评论:实现 Sobol 的拟随机序列生成器。” ACM Transactions on Mathematical Software,29(1),49-57。
    • 将 Bratley & Fox 扩展到 1111 个维度
  2. SciPy 实现: scipy.stats.qmc.Sobol
    • 使用 Joe & Kuo 参数的生产质量 Python 实现
  3. GNU Scientific Library (GSL): Sobol 序列生成器
    • C/C++ 实现,带文档

补充阅读

  1. Dick, J. and Pillichshammer, F. (2014). “从范德科普特到拟蒙特卡罗规则的现代序列构造。” Indagationes Mathematicae,26(5),760-822。

代码来源

  1. Skylake-Official AFPEnemySpawner
    • GitHub 存储库:AFPEnemySpawner.cpp
    • 启发这次深入探讨的原始 Unreal Engine 实现

注意: 上面引用的数学证明使用以下技术:

  • Weyl 的等分布准则(傅里叶分析)
  • Erdős-Turán 不等式(差异界)
  • 二进制分区论证(组合计数)

对于对完整严格证明感兴趣的读者,从 Kuipers & Niederreiter(1974)或更易访问的 Motwani & Raghavan(1995)开始。

本文由作者按照 CC BY 4.0 进行授权