ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用Triangle.Net在C#中实现Delaunay三角剖分与网格生成

用Triangle.Net在C#中实现Delaunay三角剖分与网格生成 做有限元前处理、GIS 多边形简化或者图形学里任何需要把复杂区域拆成基本图元的活儿最终都会落到同一件事上拆三角形。三角形是整个几何世界最容易处理的面片绝大多数渲染管线、物理引擎、网格生成器都以它作为基础单位。而Triangle.Net这个库正是把图形学里经典的 Triangle 算法用 C# 完整实现了一遍让 .NET 开发者不需要自己写 Delaunay 三角剖分直接拿到稳定、可控制的三角形网格。这篇文章我用自己的实际经历来写从 NuGet 下载、工程接入到第一次跑通三角剖分、把生成的三角形数据用到绘图和仿真建模里整个过程踩过的坑也会一并列出来。不管你只是想在 WPF 里画一个多边形填充还是要给有限元前处理准备网格数据这篇都能给你一条能直接走通的路。1. 先说清楚Triangle.Net 到底解决什么问题1.1 从 Shewchuk 的 Triangle 到 C# 移植很多做图形学的人知道Triangle这是 Jonathan Richard Shewchuk 在加州大学伯克利分校写的一个命令行三角剖分程序广泛用于有限元网格生成、计算机图形学里的网格细化和地理信息系统。它实现了带约束的 Delaunay 三角剖分CDT支持加密点、边界约束、质量优化是学术界和工业界公认的经典参考实现。缺点是它原本是 C 写的接口比较原始融入现代 .NET 工程有点费劲。Triangle.Net做的事情简单直接把核心算法用 C# 重写并封装成类库同时提供Polygon、Vertex、Triangle这些面向对象的类型让 .NET 开发者用几行代码就能完成一次完整的 Delaunay 三角剖分。它的 API 设计得非常自然基本上就是“创建一个多边形把顶点加进去然后调用 Triangulate 方法”。我在一个 .NET 6 的 WPF 项目里直接引用没有遇到跨平台调用或者内存释放的问题比调用原生 C 库省心太多。1.2 和手工拆三角形相比为什么不能自己写有人可能会想三角形而已我自己遍历顶点连线不就行了这个想法在凸多边形上勉强成立一旦遇到凹多边形、带孔区域、内部需要加密的约束边手工连线的结果通常是大量细长三角形也就是图形学里常说的“退化三角形”。这种三角形面积趋近于零在光照计算里会产生错误法线在有限元求解里会造成病态矩阵几乎没法用。Triangle.Net的底层基于 Bowyer-Watson 算法插入点并通过 Lawson 翻转保证每个三角形的外接圆内不包含其他点这就是 Delaunay 条件。加上约束边处理和 Steiner 点插入能在保证边界一致的前提下让三角形尽量接近等边。等边三角形是什么概念就是最“圆润”的三角形面积误差小数值计算稳定。这些都是手写连线给不了的质量保证也是为什么只要涉及任意多边形剖分我第一反应都是找现成的三角化库而不是自己折腾。2. 下载与工程接入走通第一条 Hello 路径2.1 从 NuGet 拿包而不是从网上找 DLLTriangle.Net的获取方式我是强烈建议走 NuGet 的。打开 Visual Studio 的“管理 NuGet 程序包”搜索Triangle.NET直接安装最新版即可。用命令行也是一样dotnet add package Triangle.NET安装完成后项目文件里会出现类似这样的引用PackageReference IncludeTriangle.NET Version0.9.1 /这里有个细节我要多说一句网上很多老教程会让你去某论坛下载一个编译好的 DLL 放到项目里这种做法非常不建议。DLL 版本不透明没法追踪依赖而且你根本不知道它是不是和官方源码一致。NuGet 上的包由作者直接发布版本清晰后续升级也方便。我自己早期吃过这个亏下了一个不明来源的 DLL里面 API 和文档对不上排查了半天才发现是版本问题。从那以后凡是 .NET 生态的库我一律先找 NuGet。2.2 一个最小的 C# 控制台项目配置新建一个控制台项目目标框架随意我测试用的是net6.0net8.0也兼容。安装完包之后先引入三个核心命名空间using TriangleNet.Geometry; using TriangleNet.Meshing; using TriangleNet.Tools;TriangleNet.Geometry提供Vertex、Polygon等几何对象TriangleNet.Meshing提供网格生成和QualityOptionsTriangleNet.Tools里面有一些统计和 IO 工具。第一个 Hello 程序我建议就做一个最简单的三角形剖分把结果打印出来确认引用没问题var polygon new Polygon(); polygon.Add(new Vertex(0, 0)); polygon.Add(new Vertex(10, 0)); polygon.Add(new Vertex(5, 8)); var mesh polygon.Triangulate(); Console.WriteLine($三角形数量: {mesh.Triangles.Count}); foreach (var triangle in mesh.Triangles) { Console.WriteLine(triangle.ToString()); }运行之后如果能看到三条边的输出说明库已经能正常工作了。这个“最小可运行版本”很重要它能帮你把环境问题隔离在最外层。每次引入新库我先跑一个 20 行的测试确认底层没问题再往上加业务逻辑。2.3 验证引用是否真的生效如果运行报错多半是目标框架不匹配或者项目被设置成了客户端框架。我之前遇到过.NET Framework 4.6.1引用了针对 .NET Standard 2.0 编译的包结果部分 API 可用部分不可用的情况。建议直接把项目切到 .NET 6 以上省去兼容性问题。另外如果是在 Unity 这类环境中使用需要确认运行时对 .NET Standard 2.1 的支持否则三角剖分的泛型接口可能绑定不上。我的经验是验证通过的标准不是“编译不报错”而是“能在每一帧或者实时逻辑里稳定创建网格而不产生 GC 压力”。Triangle.Net在设计上是支持重复使用的它提供了Mesh类的缓存机制这个我们后面在进阶部分详细讲。3. 核心 API 结构Geometry、Mesh 与 Triangulate 的关系3.1 完成一次三角剖分的最小对象Triangle.Net的 API 表面上看很简单但它的对象模型有清晰的层次。要完成一次三角剖分你最少需要接触四类对象Vertex包含 X、Y 坐标以及可选的边界标记 Z实际上就是二维点。Polygon顶点集合和孔洞边界的容器描述你要剖分的区域。ConstraintOptions控制边界约束比如是否要把边界边视为不可分割的约束。QualityOptions控制三角形质量比如最小角度、最大面积。典型调用链是这样的var polygon new Polygon(); // 添加外边界 polygon.Add(new Vertex(0, 0)); polygon.Add(new Vertex(100, 0)); polygon.Add(new Vertex(100, 80)); polygon.Add(new Vertex(0, 80)); // 添加一个内部孔 var hole new ListVertex { new Vertex(30, 20), new Vertex(70, 20), new Vertex(70, 60), new Vertex(30, 60) }; polygon.Add(new Contour(hole), true); var quality new QualityOptions { MinimumAngle 25, MaximumArea 10.0 }; var mesh polygon.Triangulate(quality);这里Contour的第二个参数isHole传true表示这是一个孔洞。Triangle.Net在处理带孔多边形时有一个关键逻辑它在内部会根据点集的环绕方向判断哪些区域是实体、哪些区域是空气。所以如果你发现三角剖分后孔洞被填满了第一个要检查的就是 Contour 的顶点顺序和isHole参数。3.2 QualityOptions 与 ConstraintOptions 的常见取舍QualityOptions是控制网格质量的核心也是让Triangle.Net区别于简单凸多边形拆分器的关键。最常用的两个参数是MinimumAngle和MaximumArea。MinimumAngle指定三角形最小角度默认值通常设为 20 到 30 度之间。这个值设得越大三角形越接近等边三角形但计算量也越大。我自己做结构有限元的时候常用 25 度兼顾精度和速度。如果你只是做可视化不需要参与数值求解直接把MinimumAngle设成 15 度都行生成速度会快很多。MaximumArea则是面积上限它决定了网格的细分程度。面积上限越小网格越密适合需要精细表达曲率变化的区域。实际使用时我会根据整体模型尺寸来定如果整个多边形区域有 10000 平方单位我通常先设 50 到 100 做粗剖看看边界和孔洞有没有问题再逐步减小面积上限。ConstraintOptions里有几个布尔选项最常用的是ConformingDelaunay。默认情况下Triangle.Net会尽量保持约束边完整但不保证生成的三角形一定满足 Delaunay 条件。开启ConformingDelaunay后算法会插入额外的 Steiner 点让所有三角形的外接圆内不包含其他顶点。这个选项会让网格更漂亮但点数会显著增加。如果不是要做精确的科学计算可以不开节省时间。3.3 Mesh 对象不只是三角形的集合很多人以为mesh.Triangles返回的就是最终结果实际上Mesh类还维护了节点表、边表和相邻关系。通过mesh.Vertices可以拿到所有参与剖分的顶点注意这里既包括你输入的原始边界点也可能包括算法自动插入的 Steiner 点。mesh.Triangles返回的Triangle对象内部持有三个顶点的索引可以通过GetVertex(0)、GetVertex(1)、GetVertex(2)取到实际坐标。这个结构设计上的好处是当你需要做有限元组装时可以直接通过边的邻接关系遍历相邻单元当你需要做渲染时可以直接把Triangle转成顶点数组。我在做 WPF 绘制时通常是先把Mesh转成自己的VertexBuffer避免每次绘制都去遍历 Triangle 内部的接口属性性能会好很多。4. 第一个实际生成多边形轮廓转三角网格的完整代码4.1 用 Polygon 描述带孔边界下面我给你一个可以直接抄走的完整示例这个例子生成了一个带矩形孔的 L 形区域然后输出所有三角形。L 形本身是凹多边形手工拆分很容易出错交给Triangle.Net就很干净。using TriangleNet.Geometry; using TriangleNet.Meshing; var polygon new Polygon(); // L 形外边界注意逆时针方向 polygon.Add(new Vertex(0, 0)); polygon.Add(new Vertex(100, 0)); polygon.Add(new Vertex(100, 60)); polygon.Add(new Vertex(60, 60)); polygon.Add(new Vertex(60, 100)); polygon.Add(new Vertex(0, 100)); // 内部圆角或方孔 var holeVertices new ListVertex { new Vertex(20, 20), new Vertex(45, 20), new Vertex(45, 35), new Vertex(20, 35) }; polygon.Add(new Contour(holeVertices), true); var quality new QualityOptions { MinimumAngle 20, MaximumArea 25.0 }; var mesh polygon.Triangulate(quality); Console.WriteLine($顶点数: {mesh.Vertices.Count}, 三角形数: {mesh.Triangles.Count});这里 L 形外边界用逆时针方向添加孔边界内部也用一套方向库会自动处理内外区域的标记。我在第一次使用时搞反了这个方向结果孔洞区域被当成实体多出来一大片三角形。后来排查发现Contour的方向虽然不影响剖分是否报错但会影响Region标记的判断逻辑所以还是养成统一方向的习惯比较好。4.2 三角剖分后的顶点遍历与三角形提取剖分完成后遍历三角形是高频操作。下面这个函数把Mesh转成三个独立的顶点列表方便直接用于 WPF 的StreamGeometry或者 OpenGL 的顶点缓冲public static List(double X, double Y)[] ExtractTriangles(Mesh mesh) { var result new List(double, double)[](); foreach (var tri in mesh.Triangles) { var v0 tri.GetVertex(0); var v1 tri.GetVertex(1); var v2 tri.GetVertex(2); result.Add(new[] { (v0.X, v0.Y), (v1.X, v1.Y), (v2.X, v2.Y) }); } return result; }这里我用了 C# 的元组语法方便快速取值。要注意GetVertex返回的是Vertex对象的引用不要长期保存多个引用同时又对Mesh做修改否则可能会拿到已失效的坐标数据。我自己在多次切换 QualityOptions 重新剖分时遇到过引用错乱建议每次剖分完立即把数据提取到自己定义的 DTO 对象里。4.3 三角形面积的交叉检验生成三角形之后我最常做的一个验证是总面积对比。原始多边形的面积是可以通过鞋带公式精确计算的剖分后所有三角形面积之和应当等于它。如果存在缝隙或重叠面积和就会对不上。鞋带公式很简单public static double PolygonArea(ListVertex vertices) { double sum 0; for (int i 0; i vertices.Count; i) { var v1 vertices[i]; var v2 vertices[(i 1) % vertices.Count]; sum v1.X * v2.Y - v2.X * v1.Y; } return Math.Abs(sum / 2.0); }三角形面积用叉积计算public static double TriangleArea((double X, double Y) a, (double X, double Y) b, (double X, double Y) c) { return Math.Abs((b.X - a.X) * (c.Y - a.Y) - (c.X - a.X) * (b.Y - a.Y)) / 2.0; }我第一次做 L 形带孔剖分时算出来的总面积和手工计算结果差了一个孔的面积就是因为isHole标志忘了传true。这个交叉验证方法直到今天都是我最信任的网格自检手段比肉眼盯着图形看靠谱得多。5. 进阶使用从网格数据到图形学实践5.1 基于三角形重心与法向量的可视化准备拿到三角形网格后图形学里最常见的需求是计算法向量和重心。渲染三角形面片需要一个统一的方向常见的约定是逆时针方向为正面。计算法向量用的是两条边的叉积本质上就是“三角形法则”和“平行四边形法则”在三维空间里的直接延伸。假设三角形的三个顶点是 A、B、C那么法向量 N 计算公式为N (B - A) × (C - A)代码实现public static (double X, double Y, double Z) ComputeNormal( (double X, double Y) a, (double X, double Y) b, (double X, double Y) c) { double abx b.X - a.X; double aby b.Y - a.Y; double acx c.X - a.X; double acy c.Y - a.Y; // 二维平面内法向量只有 z 分量正负代表方向 double z abx * acy - aby * acx; return (0, 0, z); }对于纯二维多边形法向量只会指向 Z 轴正方向或负方向这个正负号其实就是判断三角形绕序的关键。如果你把Triangle.Net生成的三角形直接扔给渲染管线发现正面被剔除了多半就是这里的 z 值符号和管线预期反了转一下顶点顺序即可解决。做一个三维地形或者曲面网格时三角形有各自的法向量这时就别用二维叉积了要升级成三维叉积。原理相同不过多了一个 Z 分量参与计算。5.2 用三角形网格做面积统计和边界识别Mesh对象里每个Triangle的顶点都带有Boundary标记属性。默认情况下如果某个边落在多边形外边界上它的两个端点会被标记为边界点。这个信息非常有用尤其是做有限元模型时你要区分哪些节点施加载荷、哪些节点施加约束。遍历边界点的方法foreach (var vertex in mesh.Vertices) { if (vertex.Type VertexType.Boundary) { Console.WriteLine($边界点: ({vertex.X}, {vertex.Y})); } }我实际做过一个项目需要把不规则的湖泊边界转成三角形网格用于水流仿真。如果没有边界标记我很难自动识别哪些三角形靠近岸线需要手动框选。Triangle.Net的边界点标记让整个流程自动化了我只用对所有边界点做一次空间排序就能提取出完整的岸线轮廓线。5.3 联想到 HFSS 天线建模和 Android 绘图的三角化需求值得多说一句的是Triangle.Net的用处远不止 WPF 绘图和有限元前处理。我在网上看到有人问 HFSS 天线建模时怎么快速把任意二维轮廓三角化这其实是同一类问题电磁仿真软件里的电流贴片、边界条件标记很多都需要三角形面片作为基础单元。你可以用Triangle.Net生成网格然后把顶点和三角形索引导出成 HFSS 能读的格式省去在软件里手动拉伸的步骤。Android Kotlin 开发里也经常遇到类似需求比如自定义 View 里画一个模糊箭头或者任意形状的阴影区域你需要先把这个区域拆成三角形才能走Canvas.drawVertices或者 GPU 渲染管线。虽然 Triangle.Net 本身不是 JVM 库但同样的思路可以直接替换为 Kotlin 版 Delaunay 库理解了这套三角剖分的逻辑迁移到哪个平台都不是难事。网格生成的核心永远是一样的输入点集和约束输出三角形索引数组。6. 避坑经验这类库最容易踩的五个坑6.1 重复点和近乎重合点Triangle.Net内部使用了一个顶点哈希表但如果两个点的坐标完全一样有可能被视为同一个点也有可能产生零长度边。我在一个 GIS 项目里导入坐标数据原始数据里有好几组重复点直接导致剖分后出现退化三角形计算面积时出现 NaN。解决办法很简单在添加Vertex之前做一次去重坐标精度保留到毫米级别即可。对浮点数去重不能直接用Equals要设定一个容差比如 1e-8var uniquePoints points .GroupBy(p (Math.Round(p.X, 6), Math.Round(p.Y, 6))) .Select(g g.First()) .ToList();6.2 单位与坐标尺度Triangle.Net的坐标直接参与角度计算和面积计算坐标系的原点、单位、比例都必须统一。比如你把经纬度坐标 WGS84 直接丢进去经度 116 度、纬度 40 度这个坐标的数值量级本身没问题但按度为单位计算出来的面积毫无意义而且角度约束可能因为纵横比差异导致质量优化失效。我建议在进库之前先把坐标转换到以米为单位的投影坐标或者在一个局部坐标系里工作。如果只是做图形学可视化在单位上保持 1:1 即可关键是不要混合使用不同坐标系。还有一个小技巧如果坐标值非常大比如到百万级可以先做一次平移变换把所有点减掉一个基准点这样能有效改善浮点精度。6.3 退化三角形与最小角就算你用QualityOptions设置了最小角也不能完全保证不出现尖锐三角形。约束边非常复杂时算法最多是尽量优化无法做到每个三角形都完美。我自己在剖分一个带有大量小孔的机械零件截面时设置了MinimumAngle 30结果还是出现了几个角度只有 12 度的三角形原因是孔洞之间的狭窄通道限制了 Steiner 点的插入空间。对这种极端几何我的建议是放宽最小角到 20 度同时调小最大面积让狭窄区域也能有足够的顶点密度。如果项目对三角形质量要求极高可以在剖分后再跑一遍质量统计检查最小角、最大角分布var statistic new TriangleNet.Tools.Statistic(mesh); Console.WriteLine($最小角: {statistic.MinimumAngle}, 最大角: {statistic.MaximumAngle});6.4 边界标记混乱Vertex的第三个参数是区域标记很多初学者以为它是随便填的其实它会在剖分过程中影响Region的归属。如果你有多个独立区域需要区分每个区域的外边界和内部顶点要使用一致的标记。我在做多材料有限元模型时给每个材料区域分配了不同的标记数值方便后续判断单元属于哪个材料组这在实际工程中非常好用。但要注意Vertex的标记和边界的物理类型固定约束、自由边界要区分开不要让一个数值承担两种含义。否则后处理时很容易把约束位置和材料编号混淆排查起来非常痛苦。6.5 版本 API 差异Triangle.Net早期版本和当前版本的 API 有一点差异。老版本里有GenericMesh、Triangulator这些类型新版本统一到了Mesh和polygon.Triangulate()扩展方法。如果你在网上下载到一个老教程里面的代码让你new GenericMesh()那大概率编译不过去。我的建议是直接看 NuGet 里最新稳定版的 XML 文档或者在 IDE 里右键转到定义确认当前版本的 API。不要再去看 5 年前的博客。7. 最后一点个人体会我在多个项目里用过Triangle.Net,从最开始只是想在 WPF 里画一个任意多边形的填充到后来做结构计算网格生成它一直没让我失望。唯一一次绕不开的情况是需要处理三维表面网格那个场景Triangle.Net帮不上忙需要把三维曲面参数化到二维平面后再做三角剖分整个过程反而是Triangle.Net的逻辑最容易理解也最容易调试。如果你正在做类似的事我建议第一次跑通后先别急着接业务逻辑而是花十分钟把生成的网格画出来看一眼三角形的形状分布。网格质量好不好比三角形数量多不多重要得多。调试三角剖分没有直觉辅助是很痛苦的但当你真正看到一个复杂的多边形被一张干净的三角形网覆盖住的时候就会明白这类库的价值有多大。
返回列表