空间填充曲线

对于一个\(2^n\times2^n\)的二维网格由于地址空间都是一维的(例如0x00000xffff),其实际在计算机中会将多维压缩成一维,可以形式化表示为

\[ \begin{align} M_{\text{2D}}&:(x,y)\to v \\ M_{\text{2D}}^{-1}&:v\to (x,y) \\ \end{align} \]

对于\(M_{\text{2D}}(x,y)\)也可记作\(\text{encode}(x,y)\)\(M^{-1}_{\text{2D}}(v)\)\(decode(v)\)

Linear Curve

一种最为简单的存储方式就是逐行地或逐列地存储二维数据,对于一个指定大小的二维方块(\(w\times h\)),从0开始的坐标\((x,y)\) 对应的一维地址为(一行包含\(w\)个元素) \[ v = x \times w + y \] 同理,将一维坐标\((v)\)转换二维方块坐标也很简单,只需要对行/列进行整除和取余操作即可 \[ \begin{align} x &= \lfloor v / w \rfloor \\ y &= v - x \times w \end{align} \] 上面这种方式在一般情况下足够使用,但是对于纹理/体素等数据的读取时,通常会利用到二维/三维坐标的局部性(即当我们访问坐标\((x,y)\)时,我们有很大概率将访问该坐标周围的坐标),而使用线性存储方式则会丢失这种局部性。

例如对于一个\(16 \times 16\)的方块中坐标\((4,4)\)和坐标\((6,6)\),其二维曼哈顿距离为 \[ (6-4)+(6-4) = 4 \] 而经过线性存储映射后距离为 \[ (6*16+6)-(4*16+4)=38 \] ,可以观察到经过一维映射后两个数据距离相差较远。

下图展示了 linear curve 的示意图

直接说距离似乎难以理解这种差距,考虑一个实际读取场景,假设CPU的cache一次可以存放16个数据,当我们读取纹理中\((4,4)\)位置的数据后,下一个准备读取其周围\((6,6)\)的数据,由于\((6,6)\)的数据在内存中地址距离\((4,4)\)数据地址较远,无法一次加载到cache中,即出现cache miss,这样我们就只能从内存中重新加载数据,无法利用高速缓存,影响数据读取性能。

针对这个问题,可以使用Hilbert曲线或者Morton曲线这类特殊的映射方式进行坐标映射

Hilbert Curve

每一个\(2^n \times 2^n\)的方块,都可以对应一个\(n\)阶的曲线,下图给出了1阶Hilbert Curve曲线的示意图

对于\(2\times2\)的方块(x轴向左,y轴向下),曲线是一个U字形,二维坐标顺序为: \[ (0,0) \to (0,1) \to (1,1) \to (1,0) \] 对应的一维坐标顺序(二进制形式)为: \[ 00_2\to01_2\to10_2\to11_2 \]

下面给出2阶Hilbert Curve曲线示意图

对于\(4\times4\)的方块,将其转换为4个\(2\times 2\)的小方块,然后观察每一个小方块的形状和朝向,会发现这几个小方块的形状完全一致(都是U型),只是朝向不同。块与块之间会额外添加直线连接,同样可以写出二维坐标和一维坐标的映射:

二维坐标 \[ \begin{align} (0,0)\to(1,0)\to(1,1)\to(0,1)\\ \to(0,2)\to(0,3)\to(1,3)\to(1,2)\\ \to(2,2)\to(2,3)\to(3,3)\to(3,2)\\ \to(3,1)\to(2,1)\to(2,0)\to(3,0) \end{align} \] 一维坐标 \[ \begin{align} 0000_2\to0001_2\to0010_2\to0011_2\\ \to0100_2\to0101_2\to0110_2\to0111_2\\ \to1000_2\to1001_2\to1010_2\to1011_2\\ \to1100_2\to1101_2\to1110_2\to1111_2\\ \end{align} \] 从二维坐标中可以发现,每一个\(2 \times 2\)子方块都是一组,可以写出四个方块的中心: \[ (0,0) \to (0,2) \to (2,2) \to (2,0) \] 此处可以观察到,四个子方块的中心实际上就是一阶Hilbert曲线的放大两倍的结果

然后将每个子方块的坐标减去方块中心,可以得到如下四组坐标: \[ \begin{align} (0,0)\to(1,0)\to(1,1)\to(0,1)\\ (0,0)\to(0,1)\to(1,1)\to(1,0)\\ (0,0)\to(0,1)\to(1,1)\to(1,0)\\ (1,1)\to(0,1)\to(0,0)\to(1,0) \end{align} \] 其中第1组和第4组的分别是一阶Hilbert曲线沿\(y=x\)\(y=1-x\)直线翻转的结果,为便于表示,使用\(F\)\(FR\)表示(\(FR\)表示沿\(y=x\)翻转后再旋转\(180\degree\),由此可得二阶Hilbert曲线构造过程:

$$

$$

下面给出3阶Hilbert Curve曲线示意图

根据1、2和3阶的Hilbert Curve示意图,我们已经可以大概看出规律,对于每一个\(2^n\times 2^n\)的方块,其下一层(即4个\(2^{n-1}{\times}2^{n-1}\)块)也是Hilbert Curve,其存在自相似性。

Morton Z Curve

Morton曲线(也称为Z-order曲线)是一种通过交织坐标位的简单方式来实现空间填充的曲线。其基本思想是将二维坐标的二进制表示交替合并,形成一维索引。

对于二维坐标\((x, y)\),假设\(x\)\(y\)的二进制表示分别为\(x_b\)\(y_b\),则Morton编码可以通过将\(x_b\)\(y_b\)的位交替排列得到。例如,对于坐标\((1, 2)\)

  • \(1\)的二进制:\(01_2\)
  • \(2\)的二进制:\(10_2\)

交替排列:\(0\)(x的最高位), \(1\)(y的最高位), \(1\)(x的最低位), \(0\)(y的最低位) → \(0110_2 = 6\)

因此,\((1, 2)\)的Morton编码为\(6\)

编码函数可以递归实现:

1
2
3
4
5
6
7
8
9
10
def morton_encode(x, y):
z = 0
i = 0
while x > 0 or y > 0:
z |= (x & 1) << (2 * i)
z |= (y & 1) << (2 * i + 1)
x >>= 1
y >>= 1
i += 1
return z

解码函数则相反,将交替的位分离:

1
2
3
4
5
6
7
8
9
10
def morton_decode(z):
x = y = 0
i = 0
while z > 0:
x |= (z & 1) << i
z >>= 1
y |= (z & 1) << i
z >>= 1
i += 1
return x, y

Morton曲线的优点是实现简单,局部性较好,但不如Hilbert曲线在高维空间中的局部性强。下图展示了Morton曲线的示意图:

代码实现

Python可视化

以下是使用Python、NumPy和Matplotlib实现的3种遍历方式的可视化代码。我们将生成一个\(4 \times 4\)的网格,并展示线性遍历、Morton遍历和Hilbert遍历的顺序。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation

# 线性遍历:按行遍历
def linear_traversal(n):
order = []
for i in range(n):
for j in range(n):
order.append((i, j))
return order

# Morton编码
def morton_encode(x, y):
z = 0
i = 0
while x > 0 or y > 0:
z |= (x & 1) << (2 * i)
z |= (y & 1) << (2 * i + 1)
x >>= 1
y >>= 1
i += 1
return z

# Morton遍历:按Morton编码排序
def morton_traversal(n):
points = [(i, j) for i in range(n) for j in range(n)]
points.sort(key=lambda p: morton_encode(p[0], p[1]))
return points

# Hilbert编码(简化版,对于2^n网格)
def hilbert_encode(x, y, n):
d = 0
s = n // 2
while s > 0:
rx = 1 if (x & s) > 0 else 0
ry = 1 if (y & s) > 0 else 0
d += s * s * ((3 * rx) ^ ry)
if ry == 0:
if rx == 1:
x = n - 1 - x
y = n - 1 - y
x, y = y, x
s //= 2
return d

# Hilbert遍历:按Hilbert编码排序
def hilbert_traversal(n):
points = [(i, j) for i in range(n) for j in range(n)]
points.sort(key=lambda p: hilbert_encode(p[0], p[1], n))
return points

# 可视化函数
def visualize_traversal(order, title):
fig, ax = plt.subplots()
ax.set_xlim(-0.5, 3.5)
ax.set_ylim(-0.5, 3.5)
ax.set_aspect('equal')
ax.set_title(title)
ax.grid(True)

# 绘制网格
for i in range(4):
for j in range(4):
ax.add_patch(plt.Rectangle((i-0.5, j-0.5), 1, 1, fill=False))

# 动画:逐步显示遍历顺序
points = []
def update(frame):
if frame < len(order):
x, y = order[frame]
points.append(ax.plot(x, y, 'ro', markersize=10)[0])
return points

ani = FuncAnimation(fig, update, frames=len(order)+1, interval=500, repeat=False)
plt.show()

# 示例
n = 4
linear_order = linear_traversal(n)
morton_order = morton_traversal(n)
hilbert_order = hilbert_traversal(n)

visualize_traversal(linear_order, "Linear Traversal")
visualize_traversal(morton_order, "Morton Z-Order Traversal")
visualize_traversal(hilbert_order, "Hilbert Curve Traversal")

C++实现

以下是C++版本的实现,包括线性、Morton和Hilbert的编码/解码函数。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
#include <iostream>
#include <vector>
#include <tuple>
#include <algorithm>

// 线性编码:v = x * w + y
uint32_t linear_encode(uint32_t x, uint32_t y, uint32_t w) {
return x * w + y;
}

// 线性解码
std::tuple<uint32_t, uint32_t> linear_decode(uint32_t v, uint32_t w) {
uint32_t x = v / w;
uint32_t y = v % w;
return {x, y};
}

// Morton编码:交织x和y的位
uint32_t morton_encode(uint32_t x, uint32_t y) {
uint32_t z = 0;
for (int i = 0; i < 16; ++i) { // 假设32位,处理16位
z |= (x & (1u << i)) << i;
z |= (y & (1u << i)) << (i + 1);
}
return z;
}

// Morton解码:分离交织的位
std::tuple<uint32_t, uint32_t> morton_decode(uint32_t z) {
uint32_t x = 0, y = 0;
for (int i = 0; i < 16; ++i) {
x |= (z & (1u << (2 * i))) >> i;
y |= (z & (1u << (2 * i + 1))) >> (i + 1);
}
return {x, y};
}

// Hilbert编码(简化递归实现)
uint32_t hilbert_encode(uint32_t x, uint32_t y, uint32_t n) {
uint32_t d = 0;
uint32_t s = n / 2;
while (s > 0) {
uint32_t rx = (x & s) > 0 ? 1 : 0;
uint32_t ry = (y & s) > 0 ? 1 : 0;
d += s * s * ((3 * rx) ^ ry);
if (ry == 0) {
if (rx == 1) {
x = n - 1 - x;
y = n - 1 - y;
}
std::swap(x, y);
}
s /= 2;
}
return d;
}

// Hilbert解码(逆过程,较为复杂,此处省略,实际应用中可查表或使用库)

// 遍历函数:生成顺序
std::vector<std::tuple<uint32_t, uint32_t>> generate_order(uint32_t n, const std::string& type) {
std::vector<std::tuple<uint32_t, uint32_t>> points;
for (uint32_t i = 0; i < n; ++i) {
for (uint32_t j = 0; j < n; ++j) {
points.emplace_back(i, j);
}
}
if (type == "morton") {
std::sort(points.begin(), points.end(), [](const auto& a, const auto& b) {
auto [x1, y1] = a;
auto [x2, y2] = b;
return morton_encode(x1, y1) < morton_encode(x2, y2);
});
} else if (type == "hilbert") {
std::sort(points.begin(), points.end(), [n](const auto& a, const auto& b) {
auto [x1, y1] = a;
auto [x2, y2] = b;
return hilbert_encode(x1, y1, n) < hilbert_encode(x2, y2, n);
});
} // linear无需排序
return points;
}

int main() {
uint32_t n = 4;
auto linear = generate_order(n, "linear");
auto morton = generate_order(n, "morton");
auto hilbert = generate_order(n, "hilbert");

// 输出示例
std::cout << "Linear order:\n";
for (auto [x, y] : linear) {
std::cout << "(" << x << ", " << y << ") ";
}
std::cout << "\n";

// 类似输出morton和hilbert
return 0;
}

后记

对于三维或者更高维度的数据存储,都可以采用Hilbert或者Morton方式进行存储,映射方式同二维映射类似(稍微复杂一点),这样在访问中存在空间局部性的应用较为友好,可以提高数据读取性能。