简介:计算几何中,Delaunay三角剖分是一种将点集转化为高质量三角网格的核心技术,其空圆性质保证了三角形最小角最大化,避免畸形单元。增量算法通过逐点插入、定位与边翻转逐步构建剖分,原理直观且易于在C++工程中落地。相比CGAL等重型依赖,自研实现可显著减小打包体积与编译开销,适合地形生成、网格生成、路径规划等场景。从空圆判定、三角形数据结构选型、递归翻转逻辑,到浮点误差与共圆退化等典型坑点,本文给出可直接使用的C++实现要点与验证方法,帮助开发者避开常见的崩溃与死循环陷阱。
1. 增量 Delaunay 三角剖分:C++ 实现里最容易被低估的几何算法
我最早接触 Delaunay 三角剖分是在一个地形生成的业余项目里,当时直接调了 CGAL,跑起来是爽,但工程打包时那个体积和编译时间直接把我的开发机干沉默了。后来换成了自己用 C++ 实现增量 Delaunay 算法,才发现这东西的原理并不复杂,真正坑人的是数据结构设计和退化情况处理。这篇笔记把我拆过的这套实现完整写出来,从空圆性质到边的翻转,再到浮点误差和共圆退化,代码都是可以直接抄进工程里的级别。适合那些想在 C++ 项目里引入三角剖分、但不想背 CGAL 这种重依赖的人,也适合想搞懂维诺图或网格生成底层逻辑的读者。你可以边看边把代码敲一遍,半小时内能做出一版能吃数据的剖分器。
2. 为什么选增量算法:从空圆法则到 C++ 数据结构的取舍
2.1 Delaunay 三角化的核心判定条件
Delaunay 三角剖分不是唯一的一堆三角形,它指的是在点集上构建三角网格时,让所有三角形都满足空圆性质:任意一个三角形的外接圆内部,不包含点集中的任何其他点。满足这个条件的三角形集合,会让最小角最大化,避免出现极扁的三角形,这也是它在网格生成、插值、路径规划里被广泛使用的原因。
算法层面实现 Delaunay 剖分有分治法、扫描线法和增量法。分治法的复杂度理论最好,是 O(n log n),但代码实现量很大,递归和合并逻辑容易写出边界 bug;扫描线法需要维护一个复杂的前沿结构,不如增量法直观。增量法最坏情况是 O(n²),但平均情况表现不错,而且实现思路极其直白——每次插入一个点,找到包含它的三角形,然后通过局部翻转恢复 Delaunay 性质。
// 空圆判定的核心:判断点 d 是否落在三角形 abc 的外接圆内 bool inCircle(const Point2D& a, const Point2D& b, const Point2D& c, const Point2D& d) { // 使用行列式方式避免浮点误差累积 double ax_ = a.x - d.x, ay_ = a.y - d.y; double bx_ = b.x - d.x, by_ = b.y - d.y; double cx_ = c.x - d.x, cy_ = c.y - d.y; double det = (ax_ * ax_ + ay_ * ay_) * (bx_ * cy_ - cx_ * by_) - (bx_ * bx_ + by_ * by_) * (ax_ * cy_ - cx_ * ay_) + (cx_ * cx_ + cy_ * cy_) * (ax_ * by_ - bx_ * ay_); // 正值表示 d 在外接圆内部(假设 a,b,c 逆时针) return det > 1e-12; // 容差值,后面避坑部分会细说 }这段代码用的是行列式形式的 inCircle 测试,比先求外接圆圆心再比较距离的方式要稳定得多。原因在于它直接操作坐标差值,避免了开方和除法带来的精度损失,同时把三个点的叉积组织成一种对平移不敏感的表达式,也就是把 d 点作为坐标系原点,降低数值量级。返回值处的1e-12是容差阈值,这个值怎么定非常有讲究,我后面的避坑章节会专门写。
2.2 三种主流实现路径的对比
实现 Delaunay 增量剖分,业界常见的路径有三条:写半边数据结构(Half-Edge)来做局部拓扑操作、使用基于索引的三角面列表(像 OBJ 格式那样)、或者直接上第三方库。我做这版时一开始用的三角面列表,翻转边的代码写得挺痛苦,因为共享边的查找需要反复比对顶点索引,性能消耗很大。
struct Triangle { int v[3]; // 三个顶点索引,按逆时针存储 int t[3]; // 三个邻接三角形索引,t[i] 对应 v[i]v[(i+1)%3] 对面的三角形 };后来换成了这个“轻量半边”思路:不搞复杂的双向链表,只用Triangle结构体里的邻接索引数组来表达相邻关系。每个三角形记录三个邻居,索引规则是t[i]存储的是与边v[i] -> v[(i+1)%3]共享的三角形。这个设计比纯三角面列表好用很多,因为翻转边的时候只需要修改最多四个三角形的邻接关系,不需要全表扫描。代价是你需要维护数组的紧密性,删除三角形时用尾元素填充空位,然后更新所有指向被移动元素的索引。
2.3 为什么用向量容器而不是指针
实现几何算法时,很多人习惯用new和delete动态分配三角形,但频繁插入删除时内存碎片严重,而且 Debug 模式下指针追踪非常折磨人。我在这版实现里全程用std::vector<Triangle>加 “空闲索引复用”的技巧,删除三角形时不是重建对象,而是标记死亡再回收。
std::vector<Triangle> triPool; // 三角形对象池 std::vector<int> freeList; // 空闲的索引位置 int allocTriangle() { if (!freeList.empty()) { int idx = freeList.back(); freeList.pop_back(); return idx; } triPool.push_back(Triangle()); return static_cast<int>(triPool.size()) - 1; } void freeTriangle(int idx) { freeList.push_back(idx); }这种对象池方案的直接好处是:三角形在内存中是连续排列的,CPU 缓存友好度远高于指针链表;同时因为存的是索引而不是地址,拷贝向量时所有引用还能继续用。注意点在于,任何对triPool的push_back都可能触发整体搬迁,所以你在外部千万不要保存指向Triangle的裸指针,必须始终用整数索引去访问。我早期在这上面翻过一次车,后面避坑章里会展开说。
3. 增量插入的核心流程:定位、剖分、翻转边
3.1 用重心坐标快速定位所在三角形
增量算法的第一步是找到新插入点落在哪个三角形内部。最简单粗暴的做法是遍历现有三角形,做点在三角形内部的测试,复杂度 O(n) 一次插入。对于几万个点,这个方案的累计开销能到几亿次叉积运算,即使 C++ 也扛不住。所以这版实现我用了带记忆的爬山定位法,也就是从上次插入的点所在的三角形开始,沿重心方向跨边搜索。
int locateTriangle(const Point2D& p, int startTri) { int tri = startTri; int guard = 0; while (guard++ < triPool.size()) { // 防止死循环的哨兵 Triangle& t = triPool[tri]; bool inside = true; for (int i = 0; i < 3; ++i) { Point2D a = pts[t.v[i]]; Point2D b = pts[t.v[(i + 1) % 3]]; // 跨边测试:p 必须在边 a->b 的左侧(逆时针三角形) double cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); if (cross < 0) { // p 在这条边的右侧,说明需要跨过这条边找相邻三角形 tri = t.t[i]; inside = false; break; } } if (inside) return tri; } return -1; // 理论上不会走到这里 }这段代码里guard计数器很关键。跨边搜索虽然平均收敛很快,但遇到浮点误差或者退化三角形时可能出现来回跨越的震荡,没有这个哨兵,你的程序会直接死循环。跨边判定用叉积符号,叉积为零意味着点在边上,这时候我倾向于直接认为点在三角形内,因为后续插入逻辑能处理边界情况。搜索的起点选择对性能影响很大,我一般维护一个lastLocateTri变量,每次都从它继续,实测对随机分布的点效果非常接近 O(n log n)。
3.2 在三角形内部插入点的三种边界情况
点定位完成后,插入路径取决于新点落点的类型,常见是三种:点落在某个三角形内部、点落在两个三角形的共享边上、点正好与已有顶点重合。最后一种直接丢弃,因为重复点会破坏 Delaunay 性质。前两种的切割逻辑大同小异,但边上的插入多一步相邻三角形的拆分。
// 点落在三角形 tri 内部:一分为三 int splitTriangleByPoint(int tri, int newPtIdx) { Triangle old = triPool[tri]; int t0 = allocTriangle(); // 新三角形 A-B-P int t1 = allocTriangle(); // 新三角形 B-C-P int t2 = allocTriangle(); // 新三角形 C-A-P triPool[tri] = { old.v[0], old.v[1], newPtIdx }; // 设置邻接关系,注意保持逆时针顺序 triPool[t0] = { old.v[1], old.v[2], newPtIdx }; triPool[t1] = { old.v[2], old.v[0], newPtIdx }; triPool[tri].t[0] = t0; triPool[tri].t[1] = t2; triPool[tri].t[2] = t1; triPool[t0].t[0] = t1; triPool[t0].t[1] = old.t[1]; triPool[t0].t[2] = tri; triPool[t1].t[0] = t2; triPool[t1].t[1] = old.t[2]; triPool[t1].t[2] = t0; triPool[t2].t[0] = tri; triPool[t2].t[1] = old.t[0]; triPool[t2].t[2] = t1; // 把原三角形在邻居里的引用替换掉 replaceNeighbor(old.t[0], tri, t0); replaceNeighbor(old.t[1], tri, t1); replaceNeighbor(old.t[2], tri, t2); return tri; }切割过程的本质是把一个三角形替换成三个,新产生的三条边分别与原有邻居相接。这里最繁琐的是邻接索引的维护,我建议用纸笔画一下拓扑关系再写代码,别直接凭感觉敲。replaceNeighbor这个辅助函数的作用是:当某个邻居三角形里记录的邻接索引还指向旧三角形时,把它改成新三角形的索引,别忘了这个细节,漏掉的话后面边翻转时会出现诡异的错乱。
3.3 递归边翻转的终止逻辑
插入新点后,局部可能不再满足空圆性质,需要用边翻转来修复,这是整个算法真正的灵魂。经典的方法是 Lawson 算法:把与新点相邻的所有边放进队列,逐条检查对面两个顶点是否落入外接圆,如果落入则翻转这条边。
void flipEdge(int triA, int edgeIdxA, std::queue<std::pair<int,int>>& queue) { Triangle a = triPool[triA]; int triB = a.t[edgeIdxA]; Triangle b = triPool[triB]; // 找到 triB 中与 triA 共享边的位置 int edgeIdxB = -1; for (int i = 0; i < 3; ++i) { if (b.t[i] == triA) { edgeIdxB = i; break; } } if (edgeIdxB == -1) return; // 一致性检查,防呆 // a 的共享边是 v[edgeIdxA] -> v[(edgeIdxA+1)%3] int ap = a.v[edgeIdxA]; int aq = a.v[(edgeIdxA + 1) % 3]; // 翻转后,共享边变为 b 的对顶点与 a 的对顶点的连线 int oppA = a.v[(edgeIdxA + 2) % 3]; // a 中不含共享边的顶点 int oppB = b.v[(edgeIdxB + 2) % 3]; // b 中不含共享边的顶点 // 构建两个新三角形 Triangle newA = { oppA, oppB, ap }; Triangle newB = { oppA, oppB, aq }; // ... 邻接更新的代码较长,此处省略,核心是重建 4 个三角形的邻接关系 }翻转的条件判断是inCircle(ap, aq, oppA, oppB) > 0,也就是原本共享边对面的两个顶点中,有一个落入了对方三角形的外接圆。这个过程需要递归进行,因为翻转后产生的新边可能又破坏了邻居的 Delaunay 性质。我一般用std::queue把所有可能受影响的边入队,每次翻转后把新生成的四条边再入队。队列是广度优先,逻辑简单;栈则是深度优先,代码一样但可能在极端情况下压栈过深。工程上我用队列多一点,不用栈的原因很简单——不想调系统栈大小。
3.4 超三角形的处理技巧
增量法需要一个初始的“大三角形”把所有点罩住,这个三角形被称为 super-triangle。实现时有个细节:它的顶点坐标不能只取包围盒的边界点,而是要取足够远的值。如果你用点集的包围盒四角,那么后续插入的点可能在边界处与超三角形产生极扁的三角形,inCircle 测试的数值误差在这里会被放大。
我常用的做法是把包围盒对角线长度记为L,然后取三个顶点为(cx - 20L, cy - L)、(cx + 20L, cy - L)、(cx, cy + 20L),这样超三角形是个高瘦形状,比等边或扁平形状更不容易干扰内部剖分。整个算法结束时,把所有含有超三角形顶点的三角形全部丢弃,剩下的就是真正的剖分结果。注意这里不能用“删除”这个词误导自己,本质是把这些三角形的索引丢到 freeList 里复用。
// 删除所有关联超三角形顶点的三角形 void removeSuperTriangle(int superA, int superB, int superC) { for (int i = 0; i < (int)triPool.size(); ++i) { Triangle& t = triPool[i]; if (t.v[0] == superA || t.v[0] == superB || t.v[0] == superC || t.v[1] == superA || t.v[1] == superB || t.v[1] == superC || t.v[2] == superA || t.v[2] == superB || t.v[2] == superC) { freeTriangle(i); } } }遍历删除时的性能开销是 O(n),但注意一个细节:如果你在遍历的同时通过 freeList 回收,可能会把后续要访问的索引覆盖掉。所以我一般先用一个std::vector<int> deadList收集要删除的索引,遍历结束后统一回收。而回收时,我以前踩过一个坑:直接 freeTriangle 后,triPool 里还有残留数据,如果后续定位算法访问到了这些“空位”,会使用到脏数据。所以最好在 freeTriangle 里把Triangle的成员重置成 -1,形成双重保险。
4. 避坑指南:C++ 实现 Delaunay 的五个典型翻车现场
4.1 浮点误差导致 inCircle 判错
现象:点明明在外接圆边界上,结果一会儿判定为在圆内,一会儿判定为在圆外,导致翻转边时出现死循环或漏翻,最终生成的三角形有重叠。
原因:几何坐标是小数时,行列式运算的结果带有累计误差,恰好处于临界值附近时,符号不稳定。这是所有几何算法的经典痛点,业内称之为“几何健壮性”问题。
解决:我给 inCircle 加了一个容差带,绝对值小于1e-12时直接视为共圆,不做翻转。但这又带来另一个坑:容差太大可能把明显在圆内的点误判为共圆,导致剖分结果不是严格 Delaunay。我的经验是,容差值应该和坐标量级挂钩,坐标在[-1000, 1000]区间时1e-12合适,如果坐标范围超过百万级别,需要把容差调大到1e-8甚至更大。另一种解决办法是使用扩展精度,但项目实战中调容差比换类型好用得多。
4.2 邻接索引在 vector 扩容后失效
现象:程序运行到某个规模后突然开始随机崩溃,或者三角形数据错乱,Debug 模式下报指针越界。
原因:std::vector在扩容时需要把旧数据复制到新内存,所有指向元素的指针和引用都会失效,但索引不会。我在早期版本里到处写了Triangle* t = &triPool[i],插入新三角形导致扩容后,这些指针全部变成了悬垂指针。
解决:强制自己在整个代码工程里只用整数索引,不保存任何三角形对象的地址。如果需要频繁访问同一个三角形,每次重新从triPool[i]取引用,别贪图那一点性能。从那以后我每次写完几何代码都会做一次代码审查,专门找Triangle*和Triangle&的写法,见到就改成int索引。这个习惯帮我躲过了很多隐蔽的崩溃。
4.3 cross 测试遇到点在三角形边上时的归属歧义
现象:插入点恰好在两个三角形的共享边上时,定位算法可能随机找到其中一个三角形,导致后续切割逻辑产生退化三角形,面积为 0。
原因:跨边测试里cross < 0才跨边,如果 cross 恰好等于 0(共线),会被当作在三角形内部处理。如果这个点其实在一条边的延长线上,就会出问题。
解决:在 point-in-triangle 测试里增加一个“点在边上”的分支判断。如果检测到点落在某条边上,直接定位到该边所对应的相邻三角形对,执行边分裂逻辑,而不是三角形内部切割。算法复杂度稍微增加一点,但能彻底避免面积为 0 的三角形。另外要注意浮点比较的equality用abs(diff) < eps来判断,千万不要直接== 0,因为计算出来的 cross 在理论为 0 时,实际上可能是1e-16这样的残差。
4.4 超三角形的距离选得不够远
现象:剖分完成后,内部靠近边界区域的三角形出现明显畸形,部分三角形跨越了原本的凸包边界。
原因:超三角形顶点不够远,导致这些三角形在空圆测试中产生了边界约束,使得原本应该作为凸包边的线段被翻转掉了。
解决:把系数从 20 倍提升到 100 倍。还有一个系统性排查技巧:剖分结束后检查所有三角形是否包含超级三角形的顶点,如果包含的数量大于理论值,说明超三角形的大小不合适,需要调大。这个检查逻辑可以写进单元测试里,每次跑完数据就自动验证一遍。
TEST_CASE("super triangle far enough") { // 随机生成 1000 个点,跑完剖分后检查 auto tris = runTriangulation(points); int count = 0; for (auto& t : tris) { if (containsSuperVertex(t)) count++; } REQUIRE(count == 0); // 必须一个都不剩 }4.5 大量共线点的死循环陷阱
现象:输入数据里有大量共线的点,比如一条直线上的几十个采样点,程序卡死,CPU 占用拉满。
原因:共线点会让叉积结果反复在 0 附近震荡,跨边定位算法在两个三角形之间来回跳,永远找不到“内部”。
解决:在插入循环里加入一个全局最大迭代次数的检查,超出后直接抛出异常。这是我前几年做点云处理时得到的血泪教训,当时的点云是扫描仪采的,点全部落在几条扫描线上,结果现场演示时程序卡死,场面极其尴尬。另外,如果预判到输入数据可能有大量共线点,摸底时可以先把点按照坐标排序后去重,把严格共线的点做合并处理,但要注意排序算法要支持向量的字典序比较,否则行为不符合预期。
5. 验证与进阶:如何确认剖分结果是对的低成本方法
算法写完之后,最容易犯的错误不是代码报错,而是代码不报错,但输出结果是错的。我自己做验证时用三条手段:第一条是人工可视化,第二条是拓扑一致性检查,第三条是随机对拍验证。
可视化这一步,我推荐把三角形直接输出成.obj格式文件,用 MeshLab 之类的免费工具查看。.obj 格式足够简单,不需要引入 OpenGL 或 Qt,而且大部分图形工具都能识别。我一般在 debug 构建里加一个exportOBJ(path)的函数,每次跑完数据都导出一份看几眼。
void exportOBJ(const std::string& path) { std::ofstream ofs(path); // 先输出所有顶点 for (const auto& pt : pts) { ofs << "v " << pt.x << " " << pt.y << " 0.0\n"; } // 再输出三角形面(注意 OBJ 的索引从 1 开始) for (const auto& t : triPool) { if (t.v[0] == -1) continue; // 跳过已回收的三角形 ofs << "f " << (t.v[0] + 1) << " " << (t.v[1] + 1) << " " << (t.v[2] + 1) << "\n"; } }拓扑一致性检查是更硬核的手段,核心要求有三个:第一,每条边最多被两个三角形共享;第二,每个三角形的三个顶点互不相同,且面积大于某个极小阈值;第三,全部三角形的总面积等于点集凸包的面积。第三点尤其重要,因为如果面积对不上,说明剖分结果要么有洞,要么有重叠三角形。我写过一个 200 行的校验函数,每次剖分完自动执行,跑不过就直接断言失败。
对拍验证的方法是这样的:先用这版增量实现跑一遍随机点集,再用暴力法验证每个三角形是否满足空圆性质。暴力法就三重循环遍历所有点,判断是否落在某个三角形的外接圆内。对小规模点集(比如 100 个点),暴力法虽然复杂度 O(n⁴),但本身是基准逻辑,不容易写错,很适合当作 gold standard 来核对。写对拍脚本时注意要处理浮点误差,最好用同一种 inCircle 函数来判断,否则会因为精度差异导致误报。
最后一个进阶技巧:我一般会在释放内存之前,把剖分结果序列化成自定义的紧凑二进制格式做缓存。因为大多数时候同一份点集会被多次重复剖分,与其每次重新跑算法,不如直接读缓存。缓存格式用文件头记录点集数量和一个 hash 值来校验点集是否变化,再用数组存三角形索引,这样加载比算法快至少一个数量级。从那以后我每次写几何相关代码都强制走一遍“可视化确认 + 拓扑校验 + 缓存加速”的流程,不再盲目自信地看一遍数字就认为算法对了。希望这篇笔记帮你在实现 Delaunay 三角剖分的路上少走几段弯路,直接拿到能用的版本。
本文还有配套的精品资源,点击获取