☰
C#实现Delaunay三角剖分:从点集到三角网格的完整指南
2026/10/22 8:18:18 网站建设 项目流程

简介:Delaunay三角剖分是构建三角网格的经典算法,能够避免狭长与不规则三角形,在三维建模、地形生成和物理碰撞中有着广泛应用。这套示例工程围绕算法落地展开,面向图形学学习者以及需要快速生成高质量网格的开发者。压缩包共25个文件,以.h头文件与.cpp源码为核心,辅以.dsp/.dsw工程配置、.rc资源脚本、位图图标及ReadMe说明,整体仅44KB,轻量且便于研读。目前已有123人学习/下载,适合用最短时间理解三角剖分从点集到网格的完整过程。工程内置基于MFC的testDelaunay界面,将点集准备、三角化调用、三角形遍历与结果绘制串成完整链路,可在Visual Studio中直接打开编译并观察输出。读者既能获得一套可改用的Delaunay实现模板,也能借此掌握mesh建模中的关键程序结构,用于地形或曲面生成等后续开发。

1. Delaunay是经典的三角形生成算法:从一份老工程看mesh建模的第一课

Delaunay是经典的三角形生成算法,名字听起来像是纯数学问题,真正在mesh建模里用过一遍的人会同意:它的难点从来不在算法本身,而在输入数据预处理和输出网格验证。这份资源是一个VC6时代的MFC示例工程,文件清单里除了testDelaunay的App、Doc、View骨架,还有debug.txt和ReadMe,整体结构非常典型。拆完源码你会发现,核心剖分逻辑并不神秘,真正让人翻车的全是点集重复、共线、边界约束这些工程细节。C#开发者完全可以把这套思路平移过去,用现成开源库在两三百行内复现同样质量的三角网。适合正在做地形建模、3D打印切片或Unity程序化生成的人参考。

2. 剖分原理与工程骨架:空圆准则怎么落到testDelaunay源码里

拿到一份老工程,先别急着改代码,把原理和结构对齐再动手。这一章先讲清楚Delaunay剖分为什么能生成“最正”的三角形,再看testDelaunay的MFC骨架里算法数据是怎么组织的。

2.1 空圆定理:为什么Delaunay三角形天生“最正”

Delaunay三角剖分的定义听起来像绕口令:对任意一个三角形,其外接圆内部不包含点集中的任何其他顶点。满足这个条件的三角剖分就是Delaunay三角剖分。这个“空圆”条件听起来只是几何性质,但它直接导出了一个更直观的结论:在所有可能的三角剖分中,Delaunay剖分使三角形的最小内角最大化。也就是说,它尽最大努力避免狭长三角形——这正是mesh建模最需要的品质。狭长三角形在有限元计算里会导致数值不稳定,在3D打印切片里会产生奇怪的支撑结构,在碰撞检测里会带来不必要的计算开销。

空圆准则还有一个工程上的好处:它是局部可验证的。这意味着你不需要全局检查整个网格,只需要对每条边做一次局部判断,就能确认结果是否满足Delaunay性质。这就是后面第6章要讲的“空圆检验”的理论基础。理解了这一点,你就知道为什么这个算法能成为mesh建模的默认选择,而不是什么玄学。

2.2 testDelaunay源码结构:MFC的Doc/View怎么装下算法数据

从文件清单看,这份资源是一个典型的VC6单文档MFC工程。testDelaunay.cpp是应用程序类的入口,负责初始化和消息循环;testDelaunayDoc.h和testDelaunayDoc.cpp是文档类,按MFC的惯例,算法相关的数据通常在这里维护,比如点集容器、生成的三角形列表;testDelaunayView.h和testDelaunayView.cpp是视图类,负责在窗口里绘制这些三角形,OnDraw函数里大概率放着遍历三角形并画线的代码。

我拆这类工程有个固定步骤:先打开Doc类的头文件,看有没有点集和三角形集合的成员变量;再打开View类看渲染逻辑,确认数据流向。MainFrm.cpp是主框架窗口的定制代码,一般放停靠面板或工具栏配置,和算法本身关系不大。StdAfx.h是预编译头文件,里面往往能看到工程依赖的基础库。debug.txt这种文件通常是调试输出留下的,如果里面记录了点数或耗时的日志,对理解测试数据规模会有帮助。

这种Doc/View分工和C#里MVVM的Model/ViewModel关系很像:Doc负责数据,View负责呈现。平移思路的时候,你不需要保留MFC框架,只需要把Doc里的数据结构对应到C#的模型类,把View里的绘图逻辑对应到WPF的Canvas或Unity的Mesh渲染即可。

2.3 从C++到C#的平移:先抓住哪几个类

看完testDelaunay的工程结构后,下一步是识别哪些代码值得平移。按经验,MFC工程里的Delaunay算法一般由三部分组成:输入点集的存储结构、三角剖分的核心计算逻辑、结果网格的输出与渲染。在C#里,第一部分对应List 或Polygon类,第二部分对应Triangle.NET库的Mesh和Triangulate方法,第三部分对应OBJ/STL导出或Unity的Mesh赋值。

我在实际操作中不会把C++代码逐行翻译成C#。原因很简单:Triangle.NET这类库已经对剖分过程做了充分优化,自己重复造轮子大概率性能更差、bug更多。真正值得从C++源码里学习的,是输入数据的预处理逻辑——原工程为了保证剖分成功,可能做了哪些过滤和修正。这部分代码往往藏在Doc类的加载函数里,而不是剖分函数本身。如果你找到的是递归或迭代构建三角形的核心函数,可以只看思路,不必照搬。

3. 在C#里接过Delaunay:从点集到三角网的六步实现

这一章直接给可复现的C#代码。我会按摘要里提到的流程来组织:准备点集、创建三角网对象、添加顶点、执行剖分、访问三角形、输出结果。每一步后面都有参数说明和容易出错的地方。

3.1 选库:Triangle.NET还是自己写

先解决依赖问题。在C#生态里,Triangle.NET是使用最广的开源Delaunay库,它对输入点集做了比较完备的容错,支持带洞多边形约束,还能输出三角形质量统计。另一个选择是Unity的Triangulator组件,但它是Unity专用的,不适合WPF或控制台项目。

在一个WPF项目里引入Triangle.NET,直接在NuGet包管理器里执行:

dotnet add package Triangle.NET

或者用Visual Studio的NuGet界面搜索Triangle.NET,安装最新的稳定版。需要注意,这个库的API在不同版本之间略有差异,老版本用TriangleNet.Geometry命名空间,新版本基本一致,但个别方法签名可能有调整。安装后先编译一次,确认你引用的版本能跑通下面的代码再继续。

3.2 点集清洗与多边形构造

第一步是把零散坐标点变成Triangle.NET能识别的输入结构。常见做法是使用Polygon类,它可以同时容纳顶点和边界约束。

using TriangleNet; using TriangleNet.Geometry; // 原始点集坐标,可能包含重复项 var rawPoints = new List<(double x, double y)> { (0, 0), (1, 0), (1, 1), (0, 1), (0.5, 0.5), // 内部点 (0, 0) // 故意放一个重复点 }; // 去重清洗:HashSet能同时消除完全重复的点 var seen = new HashSet<(double x, double y)>(); var polygon = new Polygon(); foreach (var p in rawPoints) { if (seen.Add(p)) { polygon.Add(new Vertex(p.x, p.y, 0)); } }

这段代码里的HashSet<(double x, double y)>用来做精确去重。注意这里是按坐标值完全相等去重,如果两个点差1e-12这么小的距离,HashSet不会认为它们相同,但三角形的边长已经有量级差距,需要在第5章避坑里单独处理。polygon.Add(new Vertex(...))创建的是一个二维带Z值的顶点,Z目前设为0,在纯Delaunay剖分里用不到,但后面要导成三维OBJ时可以直接用上。

关于坐标范围,如果你从高程数据或GIS数据加载点集,最好先做一个归一化。比如把所有点的坐标减去最小点的坐标,让数据落在相对小的范围,这样能减少浮点舍入误差带来的影响。

3.3 执行剖分并遍历三角形

点集放进Polygon后,下一步就是实例化Mesh并执行剖分。

var mesh = new Mesh(); mesh.Triangulate(polygon); // 遍历生成的三角形 int triangleCount = 0; foreach (var tri in mesh.Triangles) { var v0 = tri.GetVertex(2); var v1 = tri.GetVertex(1); var v2 = tri.GetVertex(0); // 这里可以拿到三个顶点的坐标 double area = Math.Abs( (v1.X - v0.X) * (v2.Y - v0.Y) - (v1.Y - v0.Y) * (v2.X - v0.X) ) / 2.0; if (area > 1e-6) // 过滤退化三角形 { triangleCount++; } } Console.WriteLine($"生成的三角形数量: {triangleCount}");

mesh.Triangulate(polygon)是执行剖分的关键调用,传入的Polygon对象包含了所有顶点信息。mesh.Triangles返回的是一个三角形集合,每个Triangle对象通过GetVertex(index)方法按索引访问顶点。这里我特意用0、1、2而不是用数组下标,是为了提醒你自己确认顶点顺序——Triangle.NET默认是逆时针顺序,但如果你处理的是带洞多边形或经过裁剪的网格,最好打印一次坐标做实际验证。

三角形面积的计算用了叉积公式,这既是过滤退化三角形的手段,也是后面验证网格质量的基础。阈值1e-6不是固定的,如果你的数据本身尺度很小,比如微米级的点云,阈值要相应缩小几个量级。

3.4 导出OBJ格式:把三角形写进文件

剖分结果要真正用于mesh建模,就得导出成通用格式。OBJ是最容易调试的文本格式,每行一个顶点v,每个三角形由f开头的顶点索引引用。

using System.Text; var sb = new StringBuilder(); var indexMap = new Dictionary<int, int>(); int objIndex = 1; // 先写所有顶点 foreach (var v in mesh.Vertices) { sb.AppendLine($"v {v.X} {v.Y} 0"); indexMap[v.ID] = objIndex++; } // 再写三角形面片 foreach (var tri in mesh.Triangles) { int id0 = indexMap[tri.GetVertex(0).ID]; int id1 = indexMap[tri.GetVertex(1).ID]; int id2 = indexMap[tri.GetVertex(2).ID]; sb.AppendLine($"f {id0} {id1} {id2}"); } File.WriteAllText("output.obj", sb.ToString());

OBJ文件的顶点索引从1开始计数,所以objIndex初始化为1,而Triangle.NET内部顶点的ID是从0开始的整数,必须建立一个映射字典。这里容易踩坑的是:直接用mesh.Vertices的枚举顺序和ID并不一定相同,所以indexMap必须通过v.ID作为键,不能想当然地按枚举顺序递增,否则导出的三角形顶点索引会乱,在Blender里一打开就是一堆错位的线框。

如果想导出STL格式,逻辑类似:把每个三角形的三个顶点按逆时针顺序写进去,加上由顶点坐标计算的法向量。STL是二进制或文本格式,文本格式可以直接复用上面的顶点循环,只是结构小改一下。生产环境中我一般直接导出OBJ做快速预览,确认没问题后再转STL给切片软件用。

4. 从三角网到mesh建模:地形、边界与大规模点集的落地配置

代码能跑通只是第一步,真实场景里点集来源五花八门:地形高程数据、3D扫描点云、随机散点,每种情况都有各自的处理要求。这一章讲落地配置。

4.1 地形高程接入:二维剖分怎么带上Z值

Delaunay剖分本身是二维算法,地形建模通常的做法是把经纬度或平面坐标作为XY,高程作为Z,先做二维剖分再给顶点赋Z。代码实现时,可以在加入Vertex时就设置Z值:

var vertex = new Vertex(x, y, elevation); polygon.Add(vertex);

剖分完成后再遍历所有三角形,把Z值用到渲染或导出中。在普通场景下这招够用,但有一个坑:如果地形高差极大,Z值的量级远超XY平面尺度,会直观导致三维网格看起来“拉伸变形”。这不是剖分错误,而是视觉比例问题,导出到三维软件时调整缩放比例即可。

另一个真实环境常见的情况是:输入地形数据本身是规则网格,比如DEM数据按等间距采样。直接用规则网格的每个点做Delaunay剖分会生成大量冗余三角形,而且对角线方向选择在视觉上会产生“条带”。处理密集规则网格时,我会先用四叉树或均匀网格抽稀,把点密度控制在需要的精度范围内,再做剖分。这样既减少三角形数量,又避免狭长三角形象征性地集中在某一条对角线上。

4.2 带洞多边形与边界约束

很多建模任务不是“给一堆点生成三角网”这么简单,而是要在某个区域内剖分、挖掉几个洞。Triangle.NET的Polygon支持边界约束,你可以把外轮廓和洞轮廓都加进去。

// 外轮廓:一个矩形 var outer = new List<Vertex> { new Vertex(0, 0, 0), new Vertex(10, 0, 0), new Vertex(10, 10, 0), new Vertex(0, 10, 0) }; polygon.Add(outer); // 内洞:一个圆形区域,用8个点近似 var hole = new List<Vertex>(); for (int i = 0; i < 8; i++) { double angle = 2.0 * Math.PI * i / 8.0; hole.Add(new Vertex(5 + 2.0 * Math.Cos(angle), 5 + 2.0 * Math.Sin(angle), 0)); } polygon.Add(hole, true); // true表示这是个洞

第二个参数true是关键:它告诉Triangle.NET这组顶点围成的区域是洞而不是实体区域,剖分时会把洞内部的三角形全部剔除。值得留意的是,边界顶点的顺序至少要大致保持顺时针或逆时针一致,如果顺序混乱,库会尝试自动修复,但修复结果不一定是你想要的边界形状。在加入边界前,我一般先自己检查多边形顶点的绕向,用鞋带公式算面积,正负号判断是否是预期方向。

如果你的输入只是散点云,没有明确的边界信息,又希望生成一个凸包或非凸包边界约束的网格,方法就不同了。一种常用做法是先算凸包,把凸包顶点作为边界,再用边界内的其他点做剖分,这样能避免生成跨越凹陷区域的三角形。Triangle.NET没有直接暴露凸包接口,但自己实现Gift Wrapping也就几十行,边界控制会灵活很多。

4.3 大规模点集的性能参数:quadtree和octree什么时候用

Delaunay剖分的核心开销在“逐点插入”阶段,每次插入新点都要找到它所在的三角形并更新周围三角形。当点数到达几十万级时,暴力的线性查找会让时间急剧上升,这时空间划分就有意义了。

一个经验配置是这样:点数在1万以下,占用每秒百万级操作的普通台式机,直接剖分通常没压力;点数到5万以上,要看点和点的分布是否均匀。均匀分布的点集影响小,聚类分布(比如一个区域特别密集、另一个区域几乎空白)会让插入查找跳变明显。后者建议先用四叉树或均匀网格对点集做装箱,把每个cell里的点个数限制在一个合理范围。

点数量级空间划分策略预期剖分耗时(参考)
< 1万不需要,直接剖分毫秒到百毫秒级
1万 ~ 10万均匀网格,每格子 50~200 点百毫秒到秒级
> 10万四叉树或R-tree索引秒级到十几秒

这个表格不是精确基准,而是帮你判断什么时候才需要引入空间划分机制。如果只是地图上一块几百平方米的地块高程,点量几千个,花时间做四叉树反而浪费。真正需要四叉树的场景是城市级地形或大范围点云扫描数据。另外时时提醒自己:Triangle.NET本身也做了优化,但它的内部优化侧重网格质量而不是超大规模数据处理,所以在超过百万点的时候,先抽稀永远是第一选择。

5. 避坑与排查:Delaunay剖分最常踩的五个翻车现场

这一章从实际调试经历里挑出五个高频问题,按“现象→原因→解决”的方式记录,每条都能直接对照自查。

5.1 重复点导致Build异常

现象:调用Triangulate时抛出异常,提示顶点索引冲突或不满足输入约束;有时不报错但生成的网格里出现面积为0的三角形。

原因:输入点集中包含完全相同的坐标。当两个顶点坐标一致时,Triangle.NET在插入过程中会丢失局部拓扑信息,导致后续三角形遍历出现异常。

解决:在构造Polygon之前做一次HashSet去重。这个方法对精确重复完全有效,但注意浮点精度:两个点坐标在十进制下看着相同、二进制浮点里可能差一点。建议在去重前先把坐标统一round到合理精度,比如保留6位小数。

5.2 共线点产生退化三角形

现象:生成的三角形面积接近0,或者导出的OBJ在三维软件里出现多余的暗面或闪面。

原因:多个点恰好落在同一条直线上。理论上Delaunay剖分能处理共线,但浮点计算会放大共线误差,使三角形面积变成极小但非零的值。这种退化三角形在渲染时会导致法线不确定。

解决:在遍历三角形时加入面积过滤,把面积过小的三角形剔除。我的做法是计算三角形面积并设置一个动态阈值——阈值设定为包围盒面积乘以1e-8,避免绝对值阈值在不同尺度下误杀正常的三角形。同时,在输入阶段可以对点集做一个微小的抖动(jitter),让共线的点位置错开一点,这虽然是“歪门邪道”,但对某些算法实现有效。

5.3 大坐标值触发浮点精度问题

现象:坐标值在几十万或几百万量级时,三角形之间出现细小的裂缝,或者导出后网格边界不对齐。

原因:浮点数在数值较大时精度下降,两个相距很远的点坐标经过计算后,有效位数被舍去,原本重合的边变成差之毫厘的分离状态。

解决:先平移坐标。把点集整体减去最小坐标值,让所有数据落到一个相对小的范围内,剖分完成后再把坐标平移回去。对地形数据这种操作非常常见,而且能同时提升剖分性能和数值稳定性。如果需要更高精度,也可以用decimal类型做预处理,但Triangle.NET内部使用double,所以最终还是要回到double世界来。

5.4 三角形顶点顺序不一致

现象:OBJ导出后在Blender里法线方向错乱,有的面朝上、有的面朝下,网格看起来像被揉皱了。

原因:Triangle.NET对三角形集合的枚举顺序并不保证都是统一的顺时针或逆时针,尤其是带洞约束的网格,洞边界的三角形可能以相反方向输出。

解决:导出前做一次统一绕向处理。对每个三角形,用叉积计算法向量Z分量,如果是负值就交换后两个顶点索引。绕向一致对3D打印和光照计算是刚需,不能跳过这一点。这个处理放在第6章的验证流程之前,每次导出前都跑一遍。

5.5 MFC老工程在现代环境编译失败

现象:在Visual Studio 2022里打开testDelaunay.dsw或testDelaunay.dsp,提示无法识别工程格式,或者能打开但编译报错一堆关于MFC头文件的错误。

原因:这是一份VC6时代的工程文件,工程格式本身和VS2002之后的新格式不兼容,MFC框架头文件路径也已经变了。而且工程里一些旧式宏在当前的Windows SDK里已经废弃或行为变化。

解决:不要试图直接迁移整个工程。正确做法是在VS里新建一个MFC单文档应用,然后把Doc和View两个类里和算法逻辑相关的代码手工复制过去,工程文件全新生成。或者干脆放弃MFC,按第3章的思路直接把算法平移成C#类库,这样反而更省时间。debug.txt和ReadMe.txt可以留着作为参考资料,但工程本身的调试优先级很低。

6. 验证三角网质量:空圆检验、角度统计与局部翻边

剖分出来的网格看着像模像样,不代表它真的满足Delaunay性质。生产环境中,我每次生成网格后都会跑一遍空圆检验和角度统计,这两项能让大多数问题浮出水面。

空圆检验的原理很简单:对网格中的每一条内部边,找到它相邻的两个三角形,取其中一个三角形的第三个顶点,验证它不在对面那条边对应三角形的外接圆内。如果落入外接圆内,说明这条边不满足Delaunay条件,需要翻边修正。

// 检查一条边是否满足Delaunay空圆性质 bool IsDelaunayEdge(Vertex a, Vertex b, Vertex c, Vertex d) { // 计算由a,b,c三个点确定的外接圆,判断d是否在圆内 // 返回值true表示满足空圆性质 double ax = a.X - b.X, ay = a.Y - b.Y; double bx = c.X - b.X, by = c.Y - b.Y; double d1 = ax * ax + ay * ay; double d2 = bx * bx + by * by; double det = ax * by - ay * bx; // 对非退化情况做确定性判断 if (Math.Abs(det) < 1e-12) return true; double ux = (by * d1 - ay * d2) / (2.0 * det); double uy = (bx * d2 - ax * d1) / (2.0 * det); double radiusSq = ux * ux + uy * uy; double dx = d.X - (b.X + ux); double dy = d.Y - (b.Y + uy); return (dx * dx + dy * dy) > radiusSq; // 在圆外才算通过 }

这段代码用圆心坐标偏移的方式来检验第四点是否落入外接圆内。det接近0表示三点近似共线,此时外接圆半径趋向无穷大,按Delaunay定义这些点在凸包边界上,直接算作通过。得到检验结果后,角度统计就简单了:遍历所有三角形,计算每个内角,统计最小角。如果出现小于5度的角,这个网格无论是做渲染还是做仿真,效果都不会好。

对于检验不通过的边,需要进行局部翻边操作。做法是在两个相邻三角形共用的四边形中交换对角线,把当前不合格的边换成另一条对角线,然后重新检验。这个操作每次只影响局部两个三角形,结合一个栈就能实现增量式修复,这也是很多Delaunay算法内部修正的核心机制。

从那次在某个地形可视化项目里翻车——导出网格后物理引擎穿透严重,跑完角度统计才发现一大批狭长三角形集中在采样稀疏区域——之后,我每次剖分完都强制走一遍空圆检验和角度统计,不通过就回头检查输入数据,而不是直接在网格上打补丁。这个习惯帮我省下了大量排查时间,希望你也能用上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询