🎯 第三课:哈希表与空间编码——浮点数怎么变成哈希键,邻域怎么查
为什么排第三?
第二课我们知道了 Triangle 的顶点是 float——但 float 不能直接比相等(0.1 ≠ 0.10000001)。那项目怎么知道”这两个三角形共享一个顶点”?怎么知道”这条边属于哪几个三角形”?

答案就是:空间哈希编码 + 边哈希表。

这是几何→索引的桥梁。没有它,BVH 的邻域查询就做不了,曲率就算不出来,聚类就跑不动——整条链断掉。

用你自己的话说:”自定义哈希/空间编码是架构设计的经典案例”(学习路径规划时说的),而理解”为什么这么设计”比”怎么写”重要一百倍。

费曼学习法 Step 1:用大白话解释它是什么
想象一个巨大的衣帽间:

几十万个三角形,每个三角形 3 条边、3 个顶点
你要回答两个问题:
“这个坐标点上,有哪些三角形?” → 顶点哈希表
“这条边,被哪几个三角形共享?” → 边哈希表
顶点哈希表 = 你把衣帽间按”格子”划分,每个格子有编号,同一格子里的东西一定在附近

边哈希表 = 你给每条边一个”身份证号”,同一身份证号的人查出来就是共享这条边的三角形

费曼学习法 Step 2:逐个拆解核心知识点
📦 A. 空间哈希编码 vertexKey()——把 float 变成 int64_t
int64_t vertexKey(float x, float y, float z) {
int ix = (int)(x * 10000); // 量化:精度0.0001
int iy = (int)(y * 10000);
int iz = (int)(z * 10000);
return ((int64_t)(ix & 0xFFFFF) << 40) |
((int64_t)(iy & 0xFFFFF) << 20) |
((int64_t)(iz & 0xFFFFF));
}
A1. 为什么要量化?——浮点精度问题的终极解决方案

第二课留下的坑现在填:

float a = 0.1f + 0.2f; // ≈ 0.30000001192
float b = 0.3f; // ≈ 0.30000001192
// 运气好的话 a == b,但换一组数就不一定了
在哈希表里,键必须能精确比较。0.30000001 和 0.30000002 如果直接当键,就是两个不同的键——但它们在物理上代表同一个位置。

量化 = 把连续的浮点数离散化到网格上:×10000 再取整,精度 0.0001mm,两个点在这个精度内就被视为”同一个位置”。

原始坐标: (1.23456, 2.34567, 3.45678)
×10000: (12345, 23456, 34567)
取整后: (12345, 23456, 34567) ← 精确的整数,可以当哈希键
A2. 位拼接——把三个整数压成一个 int64_t

ix = 12345, iy = 23456, iz = 34567

(ix & 0xFFFFF) = 12345 ← 取低20位
(iy & 0xFFFFF) = 23456
(iz & 0xFFFFF) = 34567

最终 key:
| ix (20位) | iy (20位) | iz (20位) |
| 0000000011000000111001 | 0000000101101110000000 | 0000001000011100000111 |
←────────── 64位 ──────────────────────────────────→
为什么 20 位/轴?

20 位 = 2²⁰ = 1,048,576 个格
精度 0.0001mm × 1,048,576 ≈ 104mm 的坐标范围
3D 打印零件一般不超过 100mm,刚好覆盖
3 × 20 = 60 位,加上符号位,64 位 int64_t 刚好装下
如果零件更大呢? 精度就得降——比如 ×1000(0.001mm 精度)就能覆盖 1040mm。这就是精度与范围的 trade-off,不是代码问题,是业务问题。

AI坑点: AI 通常直接用 to_string(x) + “,” + to_string(y) + “,” + to_string(z) 当键。能用吗?能。但字符串哈希比整数哈希慢 10 倍以上,而且内存占用大得多(一个键从 8 字节变成 30+ 字节)。百万顶点级别下,这就是”能用”和”好用”的差距。

📦 B. VertexInfo——顶点上的”档案袋”
struct VertexInfo {
float normal[3]; // 该顶点处累积法线
float area; // 关联三角形面积之和
int degree; // 关联三角形数量(度)
};
大白话: 顶点哈希表的值,不是简单的”有/没有”,而是一份档案——这个顶点周围长什么样、有多大、连着几个三角形。

B1. 为什么需要累积法线?

一个顶点通常被多个三角形共享。每个三角形有自己的法线,但顶点处的”真实法线”应该是周围法线的加权平均。degree 记录了共享顶点的三角形数量,最后做平均用。

这就是”曲率”的起点——顶点法线跟周围三角形法线的偏差程度,就是曲率的直观度量。

B2. area 有什么用?

后续聚类算法需要按面积加权——大面积的三角形比小面积的更有代表性。VertexInfo 提前把面积攒好,后面用的时候 O(1) 查询,不用重新算。

📦 C. EdgeKey 与半边结构简化版——“谁跟我共享这条边?”
struct EdgeKey {
int v0, v1; // 顶点索引

// 构造时保证 v0 < v1(无向边)
EdgeKey(int a, int b) {
    v0 = min(a, b);
    v1 = max(a, b);
}

};
C1. 为什么 v0 < v1?——无向边的标准化

三角形 A 的边是顶点 (3, 7),三角形 B 的边是顶点 (7, 3)——它们是同一条边,但如果直接存,哈希表里就是两个不同的键。

min/max 标准化:强制把小的索引放前面,这样不管从哪个三角形看,同一条边都是 (3, 7),哈希键一致。

三角形A的边: (7, 3) → 标准化为 (3, 7) ✓
三角形B的边: (3, 7) → 标准化为 (3, 7) ✓
同一把钥匙,开同一扇门
C2. 边哈希表的值——共享该边的三角形列表

unordered_map<EdgeKey, vector, EdgeKeyHash> edge_to_triangles;
查一下边 (3, 7),得到 [5, 12]——意思是第 5 号和第 12 号三角形共享这条边。

这为什么叫”半边结构的简化版”?

完整半边结构 本项目的简化版
存储内容 每个半边有:起始顶点、对偶半边、下一个半边、所属面 每条边有:两个顶点 + 共享三角形列表
能回答的问题 遍历一个顶点的所有邻接面、邻接边 哪些三角形共享这条边
内存 6条指针/边 2个int + 1个vector/边
适用场景 通用网格编辑 只需邻域查询的分析场景
项目只做分析(不编辑网格),不需要完整的半边结构,简化版够用且更省内存。这就是”架构能力”——知道什么时候够用就行(理念 1.3:技术够用就可以)。

📦 D. EdgeKeyHash——自定义哈希函数
struct EdgeKeyHash {
size_t operator()(const EdgeKey& k) const {
return hash()(k.v0) ^ (hash()(k.v1) << 1);
}
};
D1. 为什么不能直接用默认哈希?

unordered_map 的默认哈希只支持基本类型。EdgeKey 是自定义结构体,编译器不知道怎么哈希它,必须你告诉它。

D2. 这个哈希函数做了什么?

hash(v0) ^ (hash(v1) << 1)
把 v0 哈希成一个数
把 v1 哈希成一个数,左移1位
两者异或
为什么左移1位? 如果两个数一样,hash(x) ^ hash(x) = 0——所有 v0==v1 的边都会冲突到同一个桶。左移1位打破这种对称性。

AI坑点: AI 写自定义哈希时经常直接 return v0 + v1 或 return v0 ^ v1。前者冲突率极高(1+4=5, 2+3=5),后者在 v0==v1 时全为0。项目用的 hash ^ (hash << 1) 是标准做法,AI 不一定知道。

📦 E. 项目中的其他哈希表一览
哈希表 键 值 用途
顶点哈希表 int64_t(空间编码) VertexInfo 顶点邻域信息
边哈希表 EdgeKey vector 边→三角形映射
三角形索引表 Triangle* int 指针→索引反查
工具注册表 string ToolDefinition AI Agent 的工具查找
技能注册表 string unique_ptr 技能名→技能实例
聚类成员集 int — DFS 时判断”是否已访问”
注意一个细节:工具注册表用的是 map(红黑树)而不是 unordered_map(哈希表)。为什么?因为工具列表需要按名称排序展示给用户,map 天然有序。这是”选择数据结构要看业务需求”的又一个例子。

费曼学习法 Step 3:三个哈希表的协作全景
STL 文件读入 N 个 Triangle


┌─────────────────────────────────────────────┐
│ 第一步:建顶点哈希表 │
│ │
│ for each triangle: │
│ key = vertexKey(v1.x, v1.y, v1.z) │
│ vertex_map[key].normal += tri.normal │
│ vertex_map[key].area += tri.area │
│ vertex_map[key].degree += 1 │
│ (v2, v3 同理) │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ 第二步:建边哈希表 │
│ │
│ for each triangle i: │
│ edge_map[EdgeKey(v1,v2)].push_back(i) │
│ edge_map[EdgeKey(v2,v3)].push_back(i) │
│ edge_map[EdgeKey(v3,v1)].push_back(i) │
└─────────────────────────────────────────────┘


┌─────────────────────────────────────────────┐
│ 第三步:用两个表回答邻域查询 │
│ │
│ Q: 顶点P的邻居三角形有哪些? │
│ A: vertex_map[vertexKey(P)].degree │
│ │
│ Q: 哪些三角形跟三角形i共享边? │
│ A: edge_map[EdgeKey(i的每条边)] │
│ │
│ → 这就是曲率计算和聚类所需的邻域信息! │
└─────────────────────────────────────────────┘
费曼学习法 Step 4:识别你的知识缺口
# 自检问题 如果你答不出
1 vertexKey 中 & 0xFFFFF 的作用是什么?如果 ix 超过 2²⁰ 会怎样? 重新理解位拼接与溢出
2 量化精度 0.0001mm,如果两个顶点距离 0.00009mm,它们会被视为同一个吗?这是问题吗? 重新理解量化的 trade-off
3 为什么 EdgeKey 要 min/max 标准化?不标准化会怎样? 重新理解无向边
4 hash(v0) ^ (hash(v1) << 1) 中,如果不左移1位直接 hash(v0) ^ hash(v1),什么时候会出问题? 重新理解哈希冲突
5 项目为什么用简化版半边结构而不是完整的?什么场景下必须用完整的? 重新理解”够用就行”的设计决策
6 工具注册表为什么用 map 而不是 unordered_map?性能差多少?在什么情况下这个差距可以忽略? 重新理解数据结构选择与业务需求
费曼学习法 Step 5:用你的话复述
试着这样解释:

“3D 模型有几十万个顶点,都是 float 坐标,float 不能直接比相等。项目把每个坐标乘以 10000 再取整,变成精确的整数,然后把 x/y/z 三个整数各取 20 位拼成一个 64 位整数——这就是空间哈希编码。同一位置的顶点算出同一个 key,查哈希表就能得到这个顶点的法线、面积、度数等邻域信息。

边也是同样的思路:一条边用两个顶点索引表示,强制小的在前大的在后(无向边标准化),再通过自定义哈希函数映射。查边哈希表就能知道’哪些三角形共享这条边’。

这两个表就是整个项目的邻域查询引擎——曲率计算需要顶点邻域,聚类需要边邻域,没有这两个表,后面所有算法都跑不动。而它们的设计哲学是:不追求理论最优(完整半边结构),够用就行。”

📌 本课与你的 AI 时代理念的映射
你的理念 本课体现
1.5 AI代码有坑 AI 不会写空间哈希编码,只会用字符串拼 key——10倍性能差距;AI 写自定义哈希容易冲突
1.3 技术够用就可以 简化版半边结构 vs 完整版——项目只做分析不需要编辑,简化版够用且省内存
1.4 架构能力 精度 0.0001mm vs 坐标范围 104mm 的 trade-off——这不是代码问题,是业务架构决策
1.2 技术是切入点 哈希函数的数学细节是技术,但”为什么需要这个哈希表”是业务理解——邻域查询驱动了整个分析链
1.1 出题人 空间编码方案是你定义的——定义编码精度就是定义”什么算同一个点”,这是出题人的权力