13个版本
0.0.13 | 2024年8月7日 |
---|---|
0.0.12 | 2024年8月6日 |
#146 在 算法 中
799 每月下载次数
160KB
2.5K SLoC
bit_gossip
bit_gossip,以其实现技术命名,是一个简单的路径查找库,用于计算无权无向图中所有节点对的最短路径。
计算完成后,您可以在几乎恒定的时间内检索到任意两个节点之间的最短路径;我指的是少于一微秒!
如果您游戏的
- 节点/瓦片数量适中(约1000个),并且
- 有数百或数千个实体需要找到移动目标的路径。
如果您有一个大量节点(>10,000个)的静态地图,您可以在加载阶段使用此库计算所有路径。
此外,计算速度足够快,不仅可以用于静态地图,还可以用于游戏中动态变化的地图。
- 在现代CPU上计算100个节点的所有路径只需几百微秒。
- 在现代CPU上计算1000个节点的所有路径需要不到50毫秒。
- 在现代CPU上计算10000个节点的所有路径需要不到几秒钟。
有关更多详细信息,请参阅基准测试。
目录
点击展开
基本用法
小图
对于节点数少于128的小图,请使用Graph16、Graph32、Graph64或Graph128。
在GraphN中,如Graph16
,N代表图可以包含的节点数。
0 -- 1 -- 2 -- 3
| | |
4 -- 5 -- 6 -- 7
| | |
8 -- 9 -- 10 - 11
use bit_gossip::Graph16;
// Initialize a builder with 12 nodes
let mut builder = Graph16::builder(12);
// Connect the nodes
for i in 0..12u8 {
if i % 4 != 3 {
builder.connect(i, i + 1);
}
if i < 8 {
builder.connect(i, i + 4);
}
}
builder.disconnect(1, 5);
builder.disconnect(5, 9);
// Build the graph
let graph = builder.build();
// Check the shortest path from 0 to 9
assert_eq!(graph.neighbor_to(0, 9), Some(4));
assert_eq!(graph.neighbor_to(4, 9), Some(8));
assert_eq!(graph.neighbor_to(8, 9), Some(9));
// Both 1 and 4 can reach 11 in the shortest path.
assert_eq!(graph.neighbors_to(0, 11).collect::<Vec<_>>(), vec![1, 4]);
// Get the path from 0 to 5
assert_eq!(graph.path_to(0, 5).collect::<Vec<_>>(), vec![0, 4, 5]);
大图
对于节点数超过128的图,请使用Graph,它可以包含任意数量的节点。
如果环境支持多线程,Graph
将并行处理路径以加快计算速度。
在这个示例中,让我们创建一个100x100的网格图。
use bit_gossip::Graph;
// Initialize a builder with 10000 nodes
let mut builder = Graph::builder(10000);
// Connect the nodes
for y in 0..100u16 {
for x in 0..100 {
let node = y * 100 + x;
if x < 99 {
builder.connect(node, node + 1);
}
if y < 99 {
builder.connect(node, node + 100);
}
}
}
// Build the graph
// This may take a few seconds
let graph = builder.build();
// Check the shortest path from 0 to 9900
// This is fast
let mut curr = 0;
let dest = 9900;
let mut hops = 0;
while curr != dest {
let prev = curr;
curr = graph.neighbor_to(curr, dest).unwrap();
println!("{prev} -> {curr}");
hops += 1;
if curr == dest {
println!("we've reached node '{dest}' in {hops} hops!");
break;
}
}
更新图
您不能直接更新图,但可以将图转换回构建器,更新节点数并添加/删除边,然后再次构建图。
我尚未测试图的调整大小功能,因此如果您发现任何错误,请提交问题。
use bit_gossip::Graph;
// Initialize a builder with 10000 nodes
let mut builder = Graph::builder(10000);
... build graph
// Build the graph
// This may take a few seconds
let graph = builder.build();
... do some work
let mut builder = graph.into_builder();
// Resize the graph to 5000 nodes
builder.resize(5000);
// add/remove edges
builder.disconnect(0, 1);
builder.connect(0, 3000);
...
/// Build the graph again
let graph = builder.build();
图类型
-
Graph16、Graph32、Graph64、Graph128:使用原始数据类型进行位存储,效率非常高。
- 在
GraphN
中,N代表图可以包含的节点数。 - 这些类型用于小于128个节点的固定节点大小。
- 在
-
Graph:支持任意节点大小,并针对并行处理进行了优化。
SeqGraph
和ParaGraph
的枚举。如果环境支持多线程,则使用ParaGraph
;否则,使用SeqGraph
。
-
SeqGraph:根据目标架构使用
Vec<u32>
或Vec<u64>
来存储位,通常比原始数据类型慢大约3倍。 -
ParaGraph:使用
Vec<AtomicU32>
或Vec<AtomicU64>
进行位存储,这比原始数据类型慢,但得益于并行处理,随着节点数的增加而变得更加高效。
对于小于128个节点的固定节点大小,请优先使用基于原始数据类型的图类型。
库公开了一个泛型Graph
类型,它根据环境自动选择并行或顺序版本,也可以手动选择。
工作原理
图表示
点击展开
图中的每条边存储N位信息,其中N是图中的节点数。
在边a->b
中,第n位表示从节点a
到节点n
在此边中是否存在最短路径。
因此,图可以存储图中所有节点对之间的所有最短路径,共存储NxM位,其中M是图中的边数。
一旦构建了图,您就可以在几乎恒定的时间内检索任意两个节点之间的最短路径,只需检查边中的位即可。
构建图
点击展开
我们将通过一个简单的图进行说明。
0 -- 1 -- 2
| |
3 -- 4
|
5
一开始,所有边的位都是未设置的。每条边都将有如下的位 [ ][ ][ ][ ][ ]
。
[ ][ ][ ][ ][ ][ ] // 0 -> 1
[ ][ ][ ][ ][ ][ ] // 0 -> 3
[ ][ ][ ][ ][ ][ ] // 1 -> 2
[ ][ ][ ][ ][ ][ ] // 1 -> 4
[ ][ ][ ][ ][ ][ ] // 3 -> 4
[ ][ ][ ][ ][ ][ ] // 3 -> 5
第一次迭代
让我们从节点 0
开始。
对于边 0->1
,我们将位设置为 [ ][ ][ ][ ][1][ ]
,因为这条边是到 1
的最短路径的一部分。
[ ][ ][ ][ ][1][ ] // 0 -> 1
对于 0->3
,我们将位设置为 [ ][ ][1][ ][ ][ ]
,因为这条边是到 3
的最短路径的一部分。
[ ][ ][1][ ][ ][ ] // 0 -> 3
接下来,我们移动到节点 1
。
对于边 1->0
,我们可以简单地翻转 0->1
的位。
[ ][ ][ ][ ][1][ ] // 0 -> 1
flips to
[ ][ ][ ][ ][0][ ] // 1 -> 0
我们将位 0 设置为 1。
[ ][ ][ ][ ][0][1] // 1 -> 0
flips to
[ ][ ][ ][ ][1][0] // 0 -> 1
从节点 1
的角度来看,边 1->0
是到 0
的最短路径,并且距离 1
更远。有道理,对吧?
现在,到 1->2
,我们将位 2 设置为 1
,因为这条边是到节点 2
的最短路径。
[ ][ ][ ][1][ ][ ] // 1 -> 2
现在,我们移动到节点 2
。
对于 2->1
,再次翻转 1->2
,我们将位 1 设置为 1
。
[ ][ ][ ][0][1][ ] // 2 -> 1
我们重复这个过程,直到处理完所有节点,最终得到边中以下位,以矩阵形式表示
[ ][ ][ ][ ][1][0] // 0 -> 1
[ ][ ][1][ ][ ][0] // 0 -> 3
[ ][ ][ ][1][0][ ] // 1 -> 2
[ ][1][ ][ ][0][ ] // 1 -> 4
[ ][1][0][ ][ ][ ] // 3 -> 4
[1][ ][0][ ][ ][ ] // 3 -> 5
你在这里看到了什么模式?每条边都有两个位被设置。
第二次迭代
现在,每条边将开始与相邻边共享其位;这就是为什么这个算法被称为 bit_gossip
的原因。
注意:只有在相邻的边还没有设置位的情况下,我们才共享这个位。
我们从节点 0
的边开始。
[ ][ ][ ][ ][1][0] // 0 -> 1
[ ][ ][1][ ][ ][0] // 0 -> 3
0->1
是到 1
的最短路径,这意味着从 0
出发的所有其他边都不能是到 1
的最短路径。如果它们是,它们的位 1 已经被设置。
所以,我们将所有其他相邻边的位 1 设置为 0。
[ ][ ][ ][ ][1][0] // 0 -> 1
[ ][ ][1][ ][0][0] // 0 -> 3
与 0->3
相同。因为我们知道 0->3
是到 3
的最短路径,所有其他相邻边都不能是到 3
的最短路径。因此,我们将所有其他相邻边的位 3 设置为 0。
[ ][ ][0][ ][1][0] // 0 -> 1
[ ][ ][1][ ][0][0] // 0 -> 3
现在,让我们转到节点 1
的边。我翻转了边 0->1
为 1->0
以便于可视化。
节点 1
[ ][ ][1][ ][0][1] // 1 -> 0
[ ][ ][ ][1][0][ ] // 1 -> 2
[ ][1][ ][ ][0][ ] // 1 -> 4
因为 1->0
是到 0
的最短路径,我们将所有相邻边的位 0 设置为 0
。
[ ][ ][1][ ][0][1] // 1 -> 0
[ ][ ][ ][1][0][0] // 1 -> 2
[ ][1][ ][ ][0][0] // 1 -> 4
因为 1->2
是到 2
的最短路径,我们将所有相邻边的位 2 设置为 0
。
[ ][ ][1][0][0][1] // 1 -> 0
[ ][ ][ ][1][0][0] // 1 -> 2
[ ][1][ ][0][0][0] // 1 -> 4
对于 1->4
也是如此。
[ ][0][1][0][0][1] // 1 -> 0
[ ][0][ ][1][0][0] // 1 -> 2
[ ][1][ ][0][0][0] // 1 -> 4
我们对其他节点重复这个过程。
节点 3
[ ][ ][0][ ][1][1] // 3 -> 0
[ ][1][0][ ][ ][ ] // 3 -> 4
[1][ ][0][ ][ ][ ] // 3 -> 5
变为
[0][0][0][ ][1][1] // 3 -> 0
[0][1][0][ ][ ][0] // 3 -> 4
[1][0][0][ ][ ][0] // 3 -> 5
节点 4
[ ][0][ ][1][1][1] // 4 -> 1
[1][0][1][ ][ ][1] // 4 -> 3
变为
[ ][0][0][1][1][1] // 4 -> 1
[1][0][1][ ][0][1] // 4 -> 3
在八卦会话之后,边矩阵看起来是这样的
[ ][1][0][1][1][0] // 0 -> 1
[1][1][1][ ][0][0] // 0 -> 3
[ ][0][ ][1][0][0] // 1 -> 2
[ ][1][1][0][0][0] // 1 -> 4
[0][1][0][ ][1][0] // 3 -> 4
[1][0][0][ ][ ][0] // 3 -> 5
看看最短路径信息是如何像野火一样传播的?就像八卦一样!
因此,命名为 bit_gossip
。
我们重复这个过程,直到以下两种情况之一发生:
- 所有边的所有位都被设置,或者
- 迭代中没有设置任何位。
就这样!
由于所有操作都是位操作,计算非常快。
此外,“每个节点的八卦”与其他节点无关,因此我们可以并行化计算,我们在 Graph
类型中这样做。
路径检索
点击展开
所有迭代完成后,矩阵将看起来像这样
0 -- 1 -- 2
| |
3 -- 4
|
5
[0][1][0][1][1][0] // 0 -> 1
[1][1][1][0][0][0] // 0 -> 3
[0][0][0][1][0][0] // 1 -> 2
[1][1][1][0][0][0] // 1 -> 4
[0][1][0][0][1][0] // 3 -> 4
[1][0][0][0][0][0] // 3 -> 5
现在,我们如何使用这些数据来检索两个节点之间的最短路径呢?
假设我们想要找到节点 2
和节点 5
之间的最短路径。
- 检查
2
在位5
的边。
将 1->2
翻转到查看 2->1
的位。
[1][1][1][0][1][1] // 2 -> 1
我们看到位5被设置,因此我们移动到节点 1
。
- 检查节点
1
在位5
的边。
[1][0][1][0][0][1] // 1 -> 0
[0][0][0][1][0][0] // 1 -> 2
[1][1][1][0][0][0] // 1 -> 4
我们看到 1->0
和 1->4
的位5都被设置;在这种情况下,我们可以移动到 0
或 4
。两者都将给出相同的最短路径。
让我们移动到 4
。
[0][0][0][1][1][1] // 4 -> 1
[1][0][1][1][0][1] // 4 -> 3
我们看到位5对于 4->3
被设置,因此我们移动到节点 3
。
- 检查节点
3
在位5
的边。
[0][0][0][1][1][1] // 3 -> 0
[0][1][0][0][1][0] // 3 -> 4
[1][0][0][0][0][0] // 3 -> 5
我们看到位5对于 3->5
被设置,因此我们移动到节点 5
。
- 我们已达到节点
5
。
因此,节点 2
和节点 5
之间的最短路径是 2 -> 1 -> 4 -> 3 -> 5
。
太棒了!
你可能认为找到路径需要做很多工作,但事实上,这非常快。
在实际游戏中,我们不需要检索到目的地的完整路径。
我们只需要知道,“对于当前节点的边,哪一条是到目的地节点的最短路径?”
我们到达下一个节点后,重复此过程。
如果目的地改变,我们再次简单地提问,“下一个应该去哪个邻居?”
基准测试
下面的基准测试展示了不同图大小和类型的计算时间。它们用于突出各种图表示的性能特征,而不是提供绝对指标。
请注意,不同类型的图将花费不同的时间;例如,迷宫图比瓷砖网格花费的时间略多。
机器规格: Apple M3 Pro,12核CPU,18GB RAM
基准测试是在每个节点与其四个邻居相连的瓷砖网格上进行的。
在这里,n
表示节点数,e
表示边数,(WxH)
表示网格维度。对于尺寸为 WxH
的网格,有 W*H
个节点和 2*W*H - W - H
条边。
处理时间
节点,边,网格尺寸 | Graph16 | Graph32 | Graph64 | Graph128 | SeqGraph | ParaGraph |
---|---|---|---|---|---|---|
16n, 24e (4x4) | ~15µs | ~15us | ~15us | ~15us | ~45us | ~500us |
32n, 52e (4x8) | ~45us | ~40us | ~48us | ~150us | ~1ms | |
64n, 112e (8x8) | ~120us | ~140us | ~450us | ~1.5ms | ||
128n, 232e (8x16) | ~390us | ~1.5ms | ~2.5ms | |||
256n, 480e (16x16) | ~5ms | ~4ms | ||||
512n, 976e (16x32) | ~18ms | ~10ms | ||||
1024n, 1984e (32x32) | ~55ms | ~20ms | ||||
2048n, 4000e (32x64) | ~200ms | ~64ms | ||||
2500n, 4900e (50x50) | ~400ms | ~100ms | ||||
4900n, 9660e (70x70) | ~1.70s | ~300ms | ||||
7225n, 14280e (85x85) | ~3.6s | ~690ms | ||||
10000n, 19800e (100x100) | ~6.8s | ~1.3s | ||||
20000n, 39700e (100x200) | ~29s | ~6.3s | ||||
40000n, 79600e (200x200) | ~140s | ~27s | ||||
102400n, 204160e (320x320) | ~991s |
内存使用
以下是不同图类型基于节点数的理论内存需求。
计算边位内存使用的函数为 n * m / 8
字节,其中 n
是数据类型的位数,m
是边的数量。
计算节点邻居数据内存使用的函数为
- 对于
GraphN
,n * N / 8
字节,其中N
是数据类型的位数,n
是节点的数量。 - 对于
SeqGraph
和ParaGraph
,对于小于 65536 个节点的图,使用u16
为 nodeID,占用e * 2 * 2
字节;对于超过 65536 个节点的图,使用u32
为 nodeID,占用e * 4 * 2
字节,其中e
是边的数量。
下表中的值是边位和节点邻居数据内存使用的总和。
它不考虑原子、哈希表或向量结构等内存开销。
因此,实际内存使用量将远高于下表所示值。
以下图表显示内存使用量(字节)B
。
节点,边,网格尺寸 | Graph16 | Graph32 | Graph64 | Graph128 | SeqGraph | ParaGraph |
---|---|---|---|---|---|---|
16n, 24e (4x4) | ~80 B | ~160 B | ~320 B | ~640 B | ~288 B | ~288 B |
32n, 52e (4x8) | ~336 B | ~672 B | ~1.4 KB | ~624 B | ~624 B | |
64n, 112e (8x8) | ~1.4 KB | ~2.8 KB | ~1.34 KB | ~1.34 KB | ||
128n, 232e (8x16) | ~5.75 KB | ~4.63 KB | ~4.63 KB | |||
256n, 480e (16x16) | ~17.3 KB | ~17.3 KB | ||||
512n, 976e (16x32) | ~66.3 KB | ~66.3 KB | ||||
1024n, 1984e (32x32) | ~261.9 KB | ~261.9 KB | ||||
2048n, 4000e (32x64) | ~1 MB | ~1 MB | ||||
2500n, 4900e (50x50) | ~1.56 MB | ~1.56 MB | ||||
4900n, 9660e (70x70) | ~6.0 MB | ~6.0 MB | ||||
7225n, 14280e (85x85) | ~12.9 MB | ~12.9 MB | ||||
10000n, 19800e (100x100) | ~24.9 MB | ~24.9 MB | ||||
20000n, 39700e (100x200) | ~99.4 MB | ~99.4 MB | ||||
40000n, 79600e (200x200) | ~398 MB | ~398 MB | ||||
102400n, 204160e (320x320) | ~2.61 GB |
特性
- parallel:使用 Rayon 启用并行性;此功能默认启用。
示例
我已经使用 bevy 创建了一个简单的迷宫游戏来比较 bit_gossip
和 astar
。
游戏将创建一个 80x80 的网格迷宫。
您可以使用箭头键移动“玩家”(绿色点)。
每次您按下 <space>
键时,都会生成 200 个额外的敌人(红色点),追逐玩家。
当玩家和敌人碰撞时,敌人将被移除。
注意:对于 bit_gossip
,首先使用 80x80 的网格迷宫构建图需要几秒钟,敌人在构建完成前不会移动。构建完成后,将在控制台记录 graph built in ...s
。
您可以使用以下命令运行 bit_gossip
版本:
cargo run --release -p maze
您可以使用以下命令运行 astar
版本:
cargo run --release -p astar_maze
这只是为了展示如何在游戏中使用 bit_gossip
。
对我来说,大约有1000个敌人时,我注意到在移动玩家时 astar
版本开始出现延迟。然而,bit_gossip
版本无论敌人数量多少都不会出现延迟。
我将敌人数量增加到超过50000个,性能没有任何下降。
尽管如此,astar
即使有1000个敌人追逐玩家也能表现得如此出色,这意味着对于大多数游戏来说,astar
足够了。
您可以通过进入 examples/maze/src/main.rs 来更改大小,这是 bit_gossip
版本,以及 examples/astar_maze/src/main.rs 来更改大小,这是 astar
版本。
通过更改 MazePlugin::new(50, 50)
中的值来更改迷宫网格的宽度和高度。
Astar记录
https://github.com/user-attachments/assets/b1ddf4da-c885-4b86-b114-a2cce7ff7e98
Bit Gossip记录
https://github.com/user-attachments/assets/be9378f4-e3c8-452b-b94c-900b7a94b45a
大图优化
分区
将图划分为更小的图可以帮助提高内存使用和计算时间。
使用 astar,我认为这被称为分层路径查找?
如果您有超过10000个节点,您肯定应该考虑将图分区。
更好地划分地图
与其将瓦片制成图,不如考虑将您的地图划分为房间和门。
房间是一个空间,其中任何点都可以通过简单的直线连接到其他点。
门是连接两个房间的边。
这样,您只需找到从房间到房间的路径,然后可以简单地移动到房间内的更具体点。
我个人认为,这种方法比分区图更好。
未来计划
在开发算法时,我发现了一些关于算法的有趣点,我可以进一步探索以找到更多优化算法的方法。
只有一个邻居的节点
当一个节点只有一个邻居时,该邻居是到达所有其他节点的最短路径。
此外,如果该邻居只有两个邻居,那么该节点唯一的另一个邻居是到达所有其他节点的最短路径。
这可以成为迷宫状图的良好优化点。
信息Delta/Flow
我还不知道该叫什么,但这与图的局部更新有关。
比如说,在图构建完成后,我们想要删除一条边。
我们如何更新图而不需要重建整个图?应该更新哪些节点?
我有一个想法,但我还没有深入探索,那就是将图视为信息流。
由于删除该边而丢失的信息是通过该边才能到达的节点。
lost bits = (merged bits of all other edges) ^ (bits of the edge to be removed)
这有道理吗?
因此,对于节点的邻居及其邻居,我们只需取消设置由删除的边唯一设置的位。
而且,对于任何受影响的节点,如果它们的邻居对于丢失的任何位都有最短路径,我们就不传播它。
看起来不难... 但我需要更深入地思考,还有它能节省多少计算量?
GPU/SIMD加速
我不是GPU或SIMD的专家,但也许这有可能?
寻找悬挂节点
也应该很容易找到所有其他节点都无法到达的节点。
在一个节点中,合并所有边的位,如果某些位仍然是0,那么该节点是可达的。
远离目的地
找到一条离开目的地的路径也非常简单。
这与找到通向目的地的路径正好相反。
找到一个位设置为0的边,然后移动到该节点。这不确定是否是离目的地最远的路径,但至少你不会比最短路径更快地接近目的地。
我基于哪篇论文?
简答:没有。
当然,我尝试在游戏开发社区中寻找预计算路径查找,但我能找到的唯一类似概念是2003年写的;Richard "superpig" Fine发表了一篇文章,它使用矩阵来存储所有节点对之间的最短路径,尽管他没有详细说明如何实际计算矩阵。
即使在我开始这个项目之后,我也不知道有一个叫做所有对最短路径(APSP)的庞大数学领域,专门解决这类问题。
直到我完成了初始实现,我才突然想到,这可能是一个已知问题,有既定的解决方案,只是我没有足够深入地去寻找。
所以,然后,我开始研究图论,并了解了APSP,以及这个主题的论文。我学习了Floyd-Warshall和Johnson的算法,并找到了一些关于APSP的论文。
与Floyd-Warshall和Johnson的有些相似之处,但据我所知,实现方式完全不同。
我试图阅读这个主题的论文,但要么
- 它们被付费墙挡住了,或者
- 我根本无法理解它们;伪代码怎么可能比我的实际代码更难读懂?
- 而且它们都没有实际代码实现或现实生活基准。
所以,在某一点上,我完全失去了兴趣,放弃了阅读论文;我决定自己太笨,不适合学术生活。
长答:当然,也没有。
据我所知,这个实现是原创的,尽管可能有类似的概念;我还抓取了github仓库,以寻找任何类似的概念或实现,但没有找到。
完全有可能有人已经就这种实现写过论文,但我没有找到;如果是这样,请通过[email protected]给我发邮件
如果你也能用简单的话解释一下论文的内容,我将不胜感激,因为我确实想了解他们在说什么。
话虽如此,以下是我希望能够访问或能够理解的论文
- 关于无权无向图中的所有对最短路径问题
- 解决所有对最短路径问题的原始最优方法:Dhouib-matrix-ALL-SPP - ScienceDirect
- 在多GPU集群上为大型图提供可扩展的所有对最短路径
- 在o(mn)时间内解决无权无向图的所有对最短路径 | 第十七届ACM-SIAM离散算法年会论文集
如果有人能解释一下这些论文的内容,请通过[email protected]给我发邮件
依赖关系
~250–590KB
~11K SLoC