简介:这是一份基于C++的Delaunay三角网算法完整实现工程,面向计算机图形学、地理信息系统、有限元网格生成领域的学习者与开发者,重点解决二维点集的最优三角剖分构建问题。代码中体现了空圆特性、逐点插入与局部优化等核心思路,适合用于算法理解、课程设计或工程二次开发。资源共42个文件,以.h头文件与.cpp源文件为主,另含Visual C++ 6.0工程文件(.dsp/.dsw)、资源脚本(.rc)、编译中间文件(.obj/.sbr)、图标及可执行程序等,压缩包约2.66MB,目录结构清晰,可打开工程直接查看、调试和运行。已有1881人学习下载。通过阅读源码可以理清点、三角形及邻接关系的数据结构设计,追踪从初始化、逐点插入到空圆检测的完整流程,并结合附带的可执行程序直观验证三角网生成效果,是掌握Delaunay算法原理与C++实现技巧的实用参考。 写 C++ 版的 Delaunay 三角网,说实话不是个轻松的活儿。我最早接触这个算法是在做点云降面与地形网格生成的时候,当时用第三方库确实方便,但一遇到需要自定义顶点类型、动态插入约束边或做局部网格加密,就发现还是自己维护一套核心算法最顺手。网上关于 Bowyer-Watson 算法的讲解很多,但大多数停留在理论层面,真正能把 C++ 实现细节、数值稳定性和性能优化讲透的很少。这篇文章我会从工程落地角度,完整拆解如何用 C++ 从零构建一套可用的 Delaunay 三角网算法,覆盖数据结构选型、核心循环流程、外接圆判定的数值陷阱,以及实测中容易踩的坑。
1. 为什么在众多三角化算法里首选 Bowyer-Watson
1.1 Delaunay 三角网到底解决了什么问题
先简单交代一下背景:给定平面上的一组散点,我们希望生成一个三角形网格,让这些三角形尽量“饱满”,避免出现特别狭长的三角形。Delaunay 三角网的核心准则叫空外接圆准则,也就是任何一个三角形的外接圆内部不包含其他顶点。这个准则带来的直接好处是:三角形最小内角最大化,网格质量整体最优。正因为这个特性,它在有限元网格划分、地形建模、路径规划、图像配准等领域都被广泛应用。
理论上有好几种构造 Delaunay 三角网的思路,包括分治法、扫描线法和逐点插入法。分治法时间复杂度最优,能达到 O(n log n),但实现复杂度极高,处理退化情况要格外小心;扫描线法适合流式数据,但代码组织比较绕。Bowyer-Watson 算法属于逐点插入这一类,平均复杂度 O(n log n),最坏 O(n^2),但它代码量最少、最容易正确实现,也最容易扩展成带约束边的版本。对于绝大多数工程场景,100 万个点以内 Bowyer-Watson 的性能完全够用。
1.2 为什么我坚持用 C++ 而不是 Python 或第三方库
如果你只是跑一次离线建网格,那 Python 的 scipy.spatial.Delaunay 很方便,几分钟就能出结果。但在我实际做过的几个项目里,Python 暴露的问题很明显:一是处理千万级点云时内存占用高得吓人;二是无法精细控制网格局部密度;三是生产环境里要嵌入到 C++ 服务或实时管线中,跨语言调用本身就增加复杂度。自己用 C++ 实现一遍,可以精确管理内存布局、按需分配、甚至针对特定硬件做 SIMD 优化。另一个重要原因是学习价值:Delaunay 三角网是计算几何里少有的“看起来简单、写起来全是细节”的算法,完整实现一遍,对指针、容器、浮点舍入的理解都会上升一个层次。
2. 动手前先定数据结构:顶点、边与三角形怎么设计最省心
2.1 从零定义三个核心结构体
在设计数据结构时,我的原则是“先简单、后优化”,第一版跑通了再去考虑节省内存。顶点结构最简单,两个浮点数加一个索引号就够:
struct Point { double x, y; int id; // 原始点索引,方便回溯结果 };三角形结构需要存三个顶点索引,以及三个相邻三角形索引。这里有个工程细节:很多入门实现会直接把三个顶点坐标复制进 Triangle 结构体,这样后续在 LOP(Local Optimization Procedure,局部优化过程)交换对角线时,更新坐标会非常别扭,而且浪费内存。只存索引,顶点统一放在外部数组里,这样所有操作都基于索引完成,代码逻辑清晰很多:
struct Triangle { int v[3]; // 三个顶点索引,按逆时针顺序存储 int neighbor[3]; // 邻接三角形索引,neighbor[i] 对应 v[i] 的对边 };这里我把“邻接关系”和“顶点索引”放在同一个结构体里,是为了后续做局部优化时能快速找到共享边。neighbor[i] 的定义要刻意记住:它存的是与 v[i] 对边相邻的那个三角形。这个约定初看有点绕,但写代码时非常顺手。
2.2 管理三角形列表:vector 与 free list 的取舍
Bowyer-Watson 算法在插入点时涉及删除与新增三角形的高频操作。我的做法是使用 std::vector 配合一个 std::vector 的空闲索引栈(free list)。删除三角形时不真正从 vector 中 erase,而是把它的索引压入栈,新增三角形时优先复用空闲索引。这样做的好处是避免了频繁内存移动,也保证了所有三角形索引在迭代过程中相对稳定,便于追踪调试。
如果你用可变的 std::list 或反复 erase vector,代码会简单一点,但实测在 10 万点规模下性能相差了接近 5 倍,而且 list 的缓存局部性很差,遍历时 CPU 缓存命中率低。用 free list 配合紧凑的 vector 布局,是性能和实现复杂度之间的最优平衡。真正要拥抱这些工程细节,不能只做“能跑就行的 Demo”。
2.3 关于空间索引的提前规划
如果不做任何空间索引,每次插入新点都要遍历当前所有三角形来查找“受影响三角形”,效率是 O(n^2),跑 10 万点会明显卡顿。常见优化手段是网格哈希(uniform grid)或者四叉树。我在第一版里先不做索引,保证逻辑正确后再加。原因很简单:空间索引把问题复杂化了,如果核心算法本身有 bug,你会分不清是索引的错还是三角剖分的错。先把无索引版本调试到正确,再引入网格哈希,每次只检查落在邻近网格单元里的三角形,可以显著缩小搜索范围。
3. 核心主循环拆解:超级三角形、定位与 LOP 局部优化
3.1 超级三角形:一个技巧解决边界问题
Bowyer-Watson 算法对点集范围之外的三角形处理一直是个麻烦。核心技巧是构建一个足够大的“超级三角形”把所有点包进去,使得算法运行过程中不会出现没有任何三角形的空区域。这个超级三角形会在最后被移除。
超级三角形要多大?为了数值安全,顶点坐标范围在 [min, max] 的情况下,我会让超级三角形覆盖到三倍范围。因为 Bowyer-Watson 的“空外接圆”检测在某些精度不足的边界点附近可能因为浮点误差出现误判,留出足够大的余量可以极大减少这类问题。实际操作时直接取包围盒中心点,再向周围扩张三倍半径即可。
// 构造超级三角形覆盖所有输入点 Triangle makeSuperTriangle(const std::vector<Point>& pts) { double minX = pts[0].x, maxX = pts[0].x; double minY = pts[0].y, maxY = pts[0].y; for (auto& p : pts) { minX = std::min(minX, p.x); maxX = std::max(maxX, p.x); minY = std::min(minY, p.y); maxY = std::max(maxY, p.y); } double dx = maxX - minX; double dy = maxY - minY; double midX = (minX + maxX) / 2; double midY = (minY + maxY) / 2; double r = std::sqrt(dx * dx + dy * dy) * 3.0; // 三倍半径 Triangle sup; sup.v[0] = addPoint(midX - r, midY - r); sup.v[1] = addPoint(midX + r, midY - r); sup.v[2] = addPoint(midX, midY + r); return sup; }3.2 逐点插入:寻找受影响三角形集合
核心流程可以用一个简洁的循环概括:对每个输入点,找到外接圆包含该点的所有三角形,把它们统一删除,形成一个“空腔”;然后连接新点与空腔边界上的每一条边,生成新的三角形。
这里有个容易写错的点:你在循环中不能一边遍历一边删除三角形。我的做法是先遍历一遍三角形列表,将所有外接圆包含当前点的三角形索引存入一个临时集合 badTriangles,然后再统一处理。因为删除操作会影响索引,如果边遍历边删,很可能跳过某些三角形或访问到无效索引。
std::vector<int> badTriangles; for (int i = 0; i < (int)triangles.size(); i++) { if (inCircumcircle(triangles[i], pts, newPoint)) { badTriangles.push_back(i); } }定位“从哪开始搜索”也很关键。无索引版本从索引 0 扫到末尾,复杂度高;加入网格哈希后,只需要从新点所在网格单元及其周围的单元取候选三角形,这里需要额外维护“三角形中心落在哪个单元”之类的元数据。
3.3 提取空腔边界:用边计数取代集合运算
删除受影响三角形后,需要找出空腔的边界边。最直观的思路是用两组集合分别记录“出现一次的边”和“出现两次的边”,以此确定哪些边是边界。初次实现我也这么干,但后来发现这里有个更高效的方法:用一个边的计数 map,初始化时把每个受影响三角形的三条边的计数加一,最后计数为 1 的边就是边界边。
实际用 std::map 或 std::unordered_map 来做边计数都能跑通。但要注意边表示的一致性,我用的是有序对,比如 (minIndex, maxIndex) 来避免 (a,b) 和 (b,a) 被当成两条不同边。
struct Edge { int p1, p2; // p1 < p2 }; std::map<Edge, int> edgeCount; for (int tIdx : badTriangles) { for (int k = 0; k < 3; k++) { int a = tri.v[k]; int b = tri.v[(k + 1) % 3]; edgeCount[makeEdge(a, b)]++; } } // 边界边是出现次数为 1 的边 for (auto& kv : edgeCount) { if (kv.second == 1) { int a = kv.first.p1, b = kv.first.p2; // 连接新点与 a, b 形成新三角形 } }这种做法比集合差运算要好:不需要维护额外的端点集合,代码也容易验证正确性。
3.4 LOP 局部优化:交换对角线保证 Delaunay 性质
连接边界边生成新三角形后,还需要对这些三角形与其邻接三角形做局部优化(LOP)。LOP 是这算法里最容易写错但又最关键的部分。基本原理是:如果一个新三角形与它的某个邻接三角形组成的凸四边形违反了空外接圆准则,就把这对三角形的公共对角线交换。
LOP 的实现我用了一个栈来迭代传播,因为一次交换可能会引发相邻区域再次不满足 Delaunay 性质。这里我用 BFS 式的队列保证所有受影响的边都被处理:
void legalizeEdge(Triangle& tri, int edgeIdx, std::vector<Triangle>& triangles, std::vector<int>& freeList, std::stack<int>& checkStack) { int neighborIdx = tri.neighbor[edgeIdx]; if (neighborIdx == -1) return; Triangle& neighbor = triangles[neighborIdx]; // 找到邻接三角形中与 tri 共享边的那个顶点 int oppositePt = ...; // 遍历 neighbor.v 找出不在共享边上的那个点 if (inCircumcircle(tri, pts, pts[oppositePt])) { // 交换对角线 flipEdge(tri, neighborIdx, edgeIdx, oppositePt, ...); checkStack.push(tri.id); checkStack.push(neighborIdx); } }这里有几点值得强调:
- 交换对角线时,要同时更新三角形的顶点索引和三个邻居关系,最容易漏掉的是“邻居的邻居”也要重定向。
- 交换完成后,无论当前 tri 还是 neighbor,都需要重新检查它们的另外几条边是否依然合法。我用栈来存放需要再检查的三角形索引。
- 很多实现省略了这一步,或者只检查新产生的三角形而不检查老三角形,这样输出的网格会存在个别的非 Delaunay 三角形,肉眼难见但计算误差会累积。
我自己第一版就吃过“忘了传播检查”的亏,导致 80% 的区域网格正确,但一些局部区域出现细长的非 Delaunay 三角形,排查了很久才定位到是 LOP 迭代不完整造成的。
4. 外接圆判定的数值稳定性与退化情况处理
4.1 行列式法取代斜率的直觉计算
判断点是否在三角形外接圆内,最容易想到的方法是用圆的方程:解出圆心坐标,再去比较距离。这个方法有个隐藏风险:当三点接近共线或点距很小时,圆心坐标的数值会非常大,距离比较时精度不够,甚至因为两个超大数相减导致灾难性抵消。
实践中我采用的是计算几何里经典的行列式判定式。给定三个点 A(x1,y1)、B(x2,y2)、C(x3,y3) 和待测点 P(x,y),计算以下行列式:
D = | x1 y1 x1^2+y1^2 1 | | x2 y2 x2^2+y2^2 1 | | x3 y3 x3^2+y3^2 1 | | x y x^2+y^2 1 |如果三角形按逆时针排列,那么 D > 0 表示 P 在外接圆内;D < 0 表示在外侧;D = 0 表示在圆上。为了减少溢出风险,我会在计算前先把所有坐标平移到以 A 为原点的局部坐标系里,再算行列式。平移不只是微优化,而是能显著降低超大坐标下平方项溢出概率的操作。
4.2 共圆与三点共线的边界策略
工程数据里,“四个点恰好共圆”并不罕见,例如整数网格点上的正方形顶点就会导致共圆情况。面对 D = 0 的情况,处理策略要分场景:如果只是普通散点三角化,任意决定即可,但必须保证程序不崩溃;如果做地形网格,共圆时保留对角线还是交换对角线会影响网格的形态。
我在代码里给 inCircumcircle 加了一个极小阈值 epsilon,当 |D| < epsilon 时视为“在圆上”,按“不在圆内”处理。这个阈值的选择也要因地制宜:对于坐标在几千数量级的点云,我一般取 1e-10 量级;但对于归一化到 [0,1] 区间内的点集,阈值可以放宽到 1e-14 左右。不要试图用一个固定的全局阈值应对所有数据范围,否则要么误判太多要么容错太差。
4.3 重复点和退化输入:先清洗再计算
另一个很容易被忽视的问题是输入点里可能包含重复点或几乎重合的点。两点距离小于 1e-12 时,外接圆计算会严重退化,产生 NaN 或无穷大。因此算法主循环开始前,我写了一个简单的点清洗函数:按坐标排序后去重,距离够近的点直接丢弃。这个预处理看起来“不优雅”,但对稳定性至关重要。
std::vector<Point> deduplicatePoints(std::vector<Point> pts) { std::sort(pts.begin(), pts.end(), [](const Point& a, const Point& b) { if (a.x != b.x) return a.x < b.x; return a.y < b.y; }); std::vector<Point> out; for (auto& p : pts) { if (out.empty() || hypot(p.x - out.back().x, p.y - out.back().y) > 1e-10) { out.push_back(p); } } return out; }这步清洗的代价很低,但对后续所有计算的稳定性提升巨大。我在处理 LiDAR 点云数据时,经常发现同一位置被多次采集,如果不做去重,生成的网格在重复点附近会有明显的裂缝或重叠三角形。
5. 完整实现的主循环:把上面的零件组装起来
5.1 整体代码骨架
有了前面所有的铺垫,主循环的代码就比较清爽了。我把整个流程封装在一个类里,核心接口就两个:一个传入点集,一个输出三角形列表。
class DelaunayTriangulation { public: std::vector<Triangle> triangulate(const std::vector<Point>& inputPts) { pts = deduplicatePoints(inputPts); triangles.clear(); freeList.clear(); // 1. 构造超级三角形 Triangle super = makeSuperTriangle(pts); triangles.push_back(super); // 2. 逐点插入 for (auto& p : pts) { insertPoint(p); } // 3. 删除所有包含超级三角形顶点的三角形 removeSuperTriangle(super); return triangles; } private: std::vector<Point> pts; std::vector<Triangle> triangles; std::vector<int> freeList; void insertPoint(const Point& p) { std::vector<int> badTriangles; for (int i = 0; i < (int)triangles.size(); i++) { if (inCircumcircle(triangles[i], pts, p)) { badTriangles.push_back(i); } } std::map<Edge, int> edgeCount; for (int tIdx : badTriangles) { for (int k = 0; k < 3; k++) { int a = triangles[tIdx].v[k]; int b = triangles[tIdx].v[(k + 1) % 3]; edgeCount[makeEdge(a, b)]++; } } // 删除受影响三角形 for (int tIdx : badTriangles) { removeTriangle(tIdx); } // 连接新点与边界边生成新三角形 std::vector<Triangle> newTriangles; for (auto& kv : edgeCount) { if (kv.second == 1) { Triangle t; t.v[0] = kv.first.p1; t.v[1] = kv.first.p2; t.v[2] = addPoint(p.x, p.y); // 这里的索引会被后续复用 newTriangles.push_back(t); } } // 添加新三角形并初始化邻居关系 for (auto& t : newTriangles) { addTriangleWithNeighbors(t); } // 对新三角形做 LOP 优化 std::stack<int> checkStack; for (auto& t : newTriangles) checkStack.push(t.id); while (!checkStack.empty()) { int tIdx = checkStack.top(); checkStack.pop(); for (int k = 0; k < 3; k++) { legalizeEdge(triangles[tIdx], k, checkStack); } } } };这个版本的代码足够跑通中小规模数据。后面的流程就不复杂了,但每一步的实现都有细节,尤其是邻居索引的维护,需要多花点心思。
5.2 一个容易被忽略的细节:addPoint 与 addTriangle 时的索引管理
如果你按上面的思路写,会发现 addPoint 时如果去重,新点的索引可能不是简单地递增。这里我在pts里保存的是清洗后的去重点数组,插入超级三角形时会把超级三角形的顶点也追加到 pts 里,这样在处理三角形时顶点索引都指向 pts 数组。最后 removeSuperTriangle 时,只需检查三角形里是否包含超级三角形的三个顶点索引,把包含的剔除即可。
5.3 邻居关系的初始化策略
如果图省事,新生成的三角形可以先完全不用设置邻居,等所有三角形都生成后再做一遍“邻居构建”遍历。这个方法代码简单,但多了一次全量扫描,用时较长。另一种做法是每次插入新点后,利用 badTriangles 原有的邻接关系来初始化新三角形的邻居。第二种效率高,但代码逻辑较复杂。为了可靠,我第一版用的还是“全量重建邻居”的方式,正确跑通后再优化成增量方式。
全量重建邻居的代码非常直观:
void rebuildNeighbors() { for (auto& t : triangles) { t.neighbor[0] = t.neighbor[1] = t.neighbor[2] = -1; } std::unordered_map<Edge, std::pair<int,int>> edgeToTri; for (int i = 0; i < (int)triangles.size(); i++) { for (int k = 0; k < 3; k++) { Edge e = makeEdge(triangles[i].v[k], triangles[i].v[(k+1)%3]); if (edgeToTri.count(e)) { int j = edgeToTri[e].first; int jk = edgeToTri[e].second; triangles[i].neighbor[k] = j; triangles[j].neighbor[jk] = i; } else { edgeToTri[e] = {i, k}; } } } }这里的 unordered_map 需要为 Edge 类型提供哈希函数,可以直接用有序对的组合乘以一个大质数,比较简单。
6. 性能实测与优化方向:从能用走向好用
6.1 未优化版本的性能基准
我在一台普通 i7 台式机上用随机生成的 10 万点做了测试,坐标范围 [0, 10000] x [0, 10000]。未加任何空间索引的 Bowyer-Watson 实现耗时约 8.2 秒。这个结果比纯 O(n^2) 的预期好一些,因为随机分布下每插入一个点实际影响的三角形数量比较有限,平均可能在十几到几十个三角形之间。但如果数据分布极不均匀,比如大量点密集在很小区域,受影响三角形数量会显著增加,耗时也会明显上涨。
加了网格哈希索引后,同一份数据降到约 1.1 秒。网格大小的选择对性能影响非常明显:网格过小,每个单元点太少,需要检查的邻域单元多;网格过大,单元内三角形数量多,退化成近似全量遍历。我的经验是,让每个网格单元平均包含 4 到 8 个三角形时效果最好。这个经验值可以根据数据规模微调。
6.2 内存上的进一步优化
对于超大点集,原来的 Triangle 结构体里每个三角形包含 3 个 int 和 3 个 int,共 24 字节。10 万三角形大概是 2.4 MB,看起来不大,但如果是 5000 万点,网格三角形数量轻松上亿,内存会变得很紧张。这时候可以考虑:
- 用 int32 存储索引,而不是 size_t。64 位系统下 size_t 是 8 字节,int 是 4 字节,省一半内存。
- 把 neighbor 数组缩短成 3 个 int,但需要用特殊值表示边界。
- 如果不需要频繁访问邻居,只在输出前重建邻居关系,可以节省大量内存开销。
6.3 并行化思路:分块-合并策略
Delaunay 三角化的并行化一直是计算几何里的热点。一个可行的策略是分治:把点集按空间划分为多个子块,各子块独立三角化,最后在边界处做缝合。缝合过程比较复杂,要处理跨子块的空外接圆检查和 LOP 优化。另一种思路是“波前并行插入”,不过实现起来比较繁琐,我自己暂时没有在生产环境里做大规模并行,只做了分块,收效一般。这块的改进空间还很大,后续计划尝试 GPU 版本的增量式 Delaunay。
7. 实测中踩过的三个大坑
7.1 超级三角形不够大导致的边界断层
第一版实现里,我把超级三角形的半径设成包围盒直径的 1.5 倍,以为足够了。结果在跑一个分布范围跨度大的点集时,反复出现边界断裂,有些点没有连入网格。排查很久才发现,问题不是算法逻辑错了,而是某些原本应该属于边界的点在计算外接圆时,其外接圆超出了超级三角形的覆盖范围,导致受影响三角形集合漏选。把半径改成 3 倍后,问题彻底消失了。
7.2 浮点误差导致的“错误翻转”
在做 LOP 对角线交换时,因为行列式阈值设置得不合理,会出现一对反复交换的三角形:第一次检查认为该翻转,翻转后检查新的四边形又认为该翻转回去,形成死循环。这是典型的浮点精度与阈值设计问题。我的解决方法是:在 legalizeEdge 中,如果检测到“两个三角形共用一个顶点且该顶点到圆心的距离与半径几乎相等”,就保守地放弃交换,用容错阈值给判断留出缓冲空间。这个处理让我避免了很多诡异的死循环和抖动。
7.3 三角形遍历顺序引发的调试痛苦
如果三角形的顶点顺序不统一,一部分是顺时针、一部分是逆时针,计算外接圆行列式时会导致符号混乱,inCircumcircle 的结果时对时错,非常难排查。我的经验是:在构造函数里就强制所有顶点按逆时针顺序排列。每次创建或交换三角形后都做一次 orientation 检查,必要时交换 v[1] 和 v[2]。虽然多了一点计算,但整个后续逻辑的稳定性大幅提升。
8. 实战衍生:从三角网到 Voronoi 图与网格质量评估
Delaunay 三角网和 Voronoi 图是一对对偶结构,连好三角网后,要生成 Voronoi 图只需要找每个三角形的外心,然后连接相邻三角形的外心即可。这个延伸对于做最近邻搜索分析和计算几何可视化特别有用。我在项目里就用这套 C++ 三角网代码直接生成了 Voronoi 图用于蜂窝网格的形态分析,效果很好。
网格质量评估可以直接基于三角网计算每个三角形的最小角和最大角。通过统计最小角小于一定阈值的三角形比例,可以衡量这套三角化实现在实际数据上的表现。比如,随机均匀点云的三角网中,最小角小于 30 度的三角形占比一般在 1% 到 3% 之间,如果超过 5%,基本说明 LOP 没做完整或数值判定有偏差。这个指标可以作为实现是否正确的快速检验。
提示:我建议每写完一版算法,都用随机点与恶意点(共线、共圆、大量退化)分别测试,把能力边界搞清楚。只看闪亮亮的渲染图并不能证明算法正确,边界情形才是真正见功力的时候。
实现一套 C++ 的 Delaunay 三角网算法,虽然过程曲折,但带来的收益远超过代码本身:你会对整个增量式构造的逻辑、数值稳定性、邻接表维护、性能优化方向都建立直觉。这些经验在以后处理网格算法、路径规划、几何建模时都能复用。最后再说个小技巧:调试时打开控制台,模拟 20 个点的插入过程,逐步对比每个阶段三角形的数量与邻居关系,能让你快速发现逻辑漏洞。比起白白盯着代码看,这种调试方式省力太多了。
本文还有配套的精品资源,点击获取