
在图形学相关的项目里摸爬滚打这几年我最大的感受就是三角形是万物的尽头。不管是做地形渲染、物理碰撞、有限元前处理还是给三维模型生成线框图最后都得落到“把一堆点连成三角形网格”这件事上。今天想聊一个我在C#/.NET环境里反复用了很久的开源库——Triangle.Net。它本质上是计算机几何领域大名鼎鼎的Triangle库Jonathan Richard Shewchuk那套C语言实现的C#移植版专门用来做Delaunay三角剖分和高质量网格生成。如果你正愁怎么在Unity、WPF、ASP.NET Core里处理三角化任务或者手头有散乱点云想快速建网格这篇内容应该能帮你直接上手少踩几个坑。Triangle.Net最吸引我的地方是它功能完整但接口不臃肿核心的Delaunay细化算法、约束边、区域标记都有网格质量参数可控而且NuGet包直接装就能用。接下来我会从“这个库到底能干什么”开始一直讲到下载引入、核心API、实操示例和常见问题排查全程用我自己跑过的代码和踩过的坑来说话。1. 从Delaunay三角剖分说起Triangle.Net在图形学里的角色1.1 为什么图形学离不开三角形生成任何三维模型的底层渲染管线最终提交给GPU的都是三角形顶点数据。三角形是平面凸包中能组成多边形的“最简基元”它在光栅化阶段天然不会出现自交任意三个点总能确定一个平面这对深度测试、法线计算和插值都极其友好。所以图形学里的地形网格、流体表面重建、布料模拟、阴影体积几乎都依赖某个环节先把离散点集连成三角网格。但“把点连成三角形”这件事并不是随便连就完事的。如果连线不当会出现极窄的“瘦长三角形”——这类三角形在光照计算、插值、有限元数值求解时会导致误差放大甚至出现渲染瑕疵。Delaunay三角剖分就是一套规则它要求在给定的点集中每个三角形的外接圆内不包含其他点这样生成的网格能最大化最小角尽量避免瘦长三角形。Triangle.Net干的正是这件事而且它能做的远比“最小角最大化”更多支持在剖分过程中维持多边形边界、控制三角形面积上限、插入Steiner点来提升网格质量甚至能给不同区域打上标记方便后续按区域做材质或物理属性赋值。1.2 Triangle.Net的适用场景与同类库对比我在实际项目中把Triangle.Net用在过几个典型场景一是地形编辑器里用高程点云生成LOD地形网格二是Unity里做流体粒子表面的快速重建粒子位置作为输入点集剖分后再转换为Unity Mesh三是一个测量软件里把多点围成的多边形区域自动填充成连续平面网格输出到DXF文件。这几个场景的共同点是输入是平面点集/带洞多边形输出是带拓扑信息的三角形列表而且对网格质量有要求。市面上能干的库不少但C#生态里真正省心的不多。下面这个表是我个人对比过的结果列几个常见选项库/方案语言主要特点适合场景Triangle.NetC#完整实现Delaunay/约束Delaunay/网格细化API清晰.NET系项目、Unity、WPFCGALC功能极其强大但体积大、学习成本高科研级复杂几何算法需求poly2triC#/C轻量多边形三角化不支持Steiner点细化简单多边形快速三角化手动实现sweep-line任意代码量可控但鲁棒性和边界处理难学习目的或极简单需求如果你不是被.NET栈绑死CGAL确实更强但在C#环境里开发Triangle.Net几乎是不二之选。它底层算法基于Shewchuk的Triangle 1.6版本这套算法的鲁棒性在学术界和工业界都被验证了很多年自适应精度算术、Ruppert的Delaunay细化算法、约束Delaunay剖分这些硬骨头全部封装在了简洁的C#接口后面。2. 下载与项目引入NuGet包和源码两手准备2.1 通过NuGet安装Triangle.Net现在用Triangle.Net最省事的方式就是NuGet。只要你项目目标是.NET Standard 2.0及以上包括.NET Core 3.1、.NET 5/6/7/8、以及Unity通过NuGet for Unity一条命令就能引入dotnet add package Triangle.NET或者用Visual Studio的包管理器控制台Install-Package Triangle.NET装好之后项目引用里会出现TriangleNet这个程序集。版本上我建议直接用最新的稳定版。目前官方仓库的版本策略很清晰2.x是主力命名空间是TriangleNet.*如果你是老项目里见过Triangle.*这种命名空间那是1.x时代的写法迁移到2.x时命名空间和部分API有变化需要耐心改。下面我给出的示例代码都以2.x为准。提示NuGet包名是Triangle.NET注意大写不是Triangle.Net别搜错了。如果搜不到留意一下本地NuGet源是否缓存了旧索引清净的源一般结果列表里会显示修订号选最新稳定版即可。2.2 从GitHub拉源码自行编译如果你需要修改底层算法、调试到三角形级或者希望本地化到像Unity这种不直接支持NuGet的环境直接从源码编译会更灵活。官方仓库地址是github.com/wo80/Triangle.NET当前2.x源码是标准解决方案包含核心库、测试项目和一个示例控制台程序。拉下来之后直接开编基本不会报错老版本可能需要改TargetFramework。你们如果要用在自己的非.Net Core框架里比如传统.NET Framework 4.x项目2.x默认目标中通常已经包含netstandard2.0或net48等目标框架直接用对应版本产物即可。编译命令git clone https://github.com/wo80/Triangle.NET.git cd Triangle.NET dotnet build -c Release编译产物在bin/Release/下拿TriangleNet.dll放进自己项目的引用或者直接项目引用的方式挂进去都行。源码编译的好处是你能直接看算法实现中如何处理大数坐标、如何处理浮点误差这种基本功对一个想深度玩图形学的人非常值钱。2.3 依赖项详解为什么它这么轻Triangle.Net的依赖非常简单运行时核心库底层依赖只有基础数学运算不需要外部几何库。这个设计我很喜欢意味着你哪怕是在一个干净的控制台项目里也能直接用。安装后观察项目依赖2.x版本无论走哪个入口都不会引出一堆传递依赖。对比某些图形库一装拖进来数百MB依赖Triangle.Net对现有项目的侵入性极低。顺带提醒如果你在Unity里用注意Unity的Mono脚本运行时的API兼容性。Triangle.Net的2.x目标栈通常覆盖netstandard2.0而Unity 2018.4以上对netstandard2.0的支持已经很成熟直接放Plugins/Managed目录或通过asmdef引用就行不需要额外做兼容层。3. 核心API解析与第一个三角剖分实战3.1 核心类型和概念速览在写代码之前先把几个核心类型搞清楚。这个库的命名直接反映了图形学数据处理流程TriangleNet.Geometry.Vertex输入顶点主要属性是X、Y可选ID、Label。它是点在整个剖分过程中的基本单位。TriangleNet.Geometry.Polygon最常用的输入几何容器用来装顶点和约束边同时支持包含多个环比如外边界内部洞。TriangleNet.Meshing.QualityOptions网格质量控制参数例如MinimumAngle最小角、MaximumArea最大三角形面积。TriangleNet.Meshing.ConstraintOptions是否强制Delaunay是否使用并发细化是否合并等。TriangleNet.Mesh输出网格核心属性包括Vertices、Triangles、Edges底层还包含维诺图数据。TriangleNet.Topology.Triangle单个三角形可以通过GetVertex(index)取三个顶点GetNeighbor(index)取邻居。一个标准的三角剖分流程就是构造几何数据 → 选择质量/约束参数 → 调用网格生成器 → 遍历结果使用。3.2 三个点的最简实例从输入到输出我们先用一个最朴素的例子跑通整个流程给定三个顶点生成一个三角形然后读出三个顶点坐标打印出来。using System; using TriangleNet.Geometry; using TriangleNet.Meshing; class Program { static void Main() { // 1. 创建多边形对象它是输入的几何容器 var polygon new Polygon(); // 2. 添加三个顶点 polygon.Add(new Vertex(0.0, 0.0)); polygon.Add(new Vertex(1.0, 0.0)); polygon.Add(new Vertex(0.0, 1.0)); // 3. 执行三角剖分扩展方法来自 TriangleNet.Meshing var mesh polygon.Triangulate(); // 4. 遍历输出的三角形 foreach (var triangle in mesh.Triangles) { var v0 triangle.GetVertex(0); var v1 triangle.GetVertex(1); var v2 triangle.GetVertex(2); Console.WriteLine( $Triangle: ({v0.X},{v0.Y}) ({v1.X},{v1.Y}) ({v2.X},{v2.Y})); } } }输出是Triangle: (0,0) (1,0) (0,1)这里有个细节polygon.Triangulate()是TriangleNet.Meshing命名空间里的扩展方法它内部默认帮我们创建了GenericMesher并应用默认约束选项。默认情况下它会把多边形区域之外的多余三角形剔除掉所以三个点组成的恰好就是那一个三角形而不是整个凸包上的所有组合。3.3 带质量参数的通用剖分控制三角形形态真实项目不会只有三个点。假设我们有如下一批点var polygon new Polygon(); polygon.Add(new Vertex(0, 0)); polygon.Add(new Vertex(4, 0)); polygon.Add(new Vertex(4, 4)); polygon.Add(new Vertex(2, 1)); polygon.Add(new Vertex(0, 4));如果直接polygon.Triangulate()得到的是Delaunay三角网格但其中可能出现面积很大的瘦长三角形尤其在边界点分布不均匀时。为了让网格更“好看”我们调用更底层的GenericMesher传入QualityOptionsusing TriangleNet.Meshing; var quality new QualityOptions { MinimumAngle 30.0, // 三角形最小内角单位度 MaximumArea 0.1, // 三角形面积上限 }; var constraint new ConstraintOptions { ConformingDelaunay true, // 保证约束Delaunay性质 }; var mesher new GenericMesher(); var mesh mesher.Triangulate(polygon, constraint, quality);MinimumAngle 30含义是生成的三角形最小角不低于30度这是Shewchuk算法中最经典的参数。当一个三角形的最小角过小比如小于20度在有限元计算中会产生“病态”矩阵渲染中插值边缘也容易出现锯齿感。MaximumArea则直接限制三角形大小让你控制网格密度。加大MinimumAngle会显著增加Steiner点插入数量网格越密、耗时越长我建议日常先设到25~30渲染用途可以放宽有限元计算再收紧。3.4 如何从Mesh中提取渲染/计算所需的顶点数组拿到mesh.Triangles只是拿到了拓扑实际要交给渲染管线时一般需要合并去重后的顶点数组和索引数组。我封装过一个简单的方法你们可以直接参考using System.Collections.Generic; using TriangleNet.Topology; public static (Listdouble vertices, Listint indices) ExtractMeshData(Mesh mesh) { var vertexList new Listdouble(); var indexList new Listint(); var indexMap new Dictionaryint, int(); foreach (var triangle in mesh.Triangles) { for (int i 0; i 3; i) { var vertex triangle.GetVertex(i); if (!indexMap.TryGetValue(vertex.ID, out int index)) { index vertexList.Count / 2; indexMap[vertex.ID] index; vertexList.Add(vertex.X); vertexList.Add(vertex.Y); } indexList.Add(index); } } return (vertexList, indexList); }注意这里的vertex.ID是网格内部生成Vertex时自动分配的索引它和原始输入点列表中顶点的位置不一定一致所以要用字典做映射。很多新手问我“为什么我输入的原始点下标到输出网格里全乱了”原因就在于这里是全新拓扑别按原始列表下标去取坐标。如果你更习惯直接拿mesh.Vertices也能通过遍历它取X/Y但要注意mesh.Vertices是哈希表结构遍历顺序不是输入顺序。4. 进阶使用多边形约束、洞与区域标记4.1 用Polygon表达带洞多边形和边界约束图形学里更常见的需求是给定一个封闭的多边形边界要求三角剖分结果严格保持在边界内部同时保留内部的洞。这在建筑平面图、地图、游戏关卡设计里极其常见。Triangle.Net对这类需求支持得很好Polygon支持多环outer ring holes每个环由首尾相接的Vertex列表构成。看一个带矩形外边界和矩形洞的完整示例using TriangleNet.Geometry; using TriangleNet.Meshing; var polygon new Polygon(); // 外边界一个10x10的正方形 var outer new ListVertex { new Vertex(0, 0), new Vertex(10, 0), new Vertex(10, 10), new Vertex(0, 10), }; polygon.Add(outer, true); // true 表示这是一个边界环 // 洞中心一个4x4的正方形 var hole new ListVertex { new Vertex(3, 3), new Vertex(7, 3), new Vertex(7, 7), new Vertex(3, 7), }; polygon.Add(hole, false); // false 表示这是一个洞 var mesh polygon.Triangulate();这里的关键是polygon.Add(vertexList, isHole)的重载。isHolefalse时这个环会被标记为洞剖分时内部不会被填充三角形。如果你不传第二个参数直接Add默认把所有顶点当成无约束散点剖分结果会包住全部顶点得不到“带洞”的效果。4.2 区域标记让每个三角形知道自己属于哪个区很多有限元前处理场景需要在网格上附加物理属性比如某个区域是空气、某个区域是铜、某个区域需要更细的网格。Triangle.Net提供了RegionPointer机制。在TriangleNet.Geometry命名空间下可以对某个点所在区域进行标记剖分后每个三角形会继承这个标记值存放在Triangle.Label属性里。基本做法using TriangleNet.Geometry; using TriangleNet.Meshing; var polygon new Polygon(); polygon.Add(new Vertex(0, 0)); polygon.Add(new Vertex(10, 0)); polygon.Add(new Vertex(10, 10)); polygon.Add(new Vertex(0, 10)); // 指定一个区域标记为1并限制该区域三角形最大面积为0.5 polygon.Add(new RegionPointer(2.0, 2.0, 1, 0.5)); polygon.Add(new RegionPointer(7.0, 7.0, 2, 0.1)); var mesh polygon.Triangulate(); foreach (var triangle in mesh.Triangles) { Console.WriteLine($区域标记: {triangle.Label}); }RegionPointer的前两个参数是区域内的一个采样点第三个参数是区域ID第四个是面积上限。Triangle.Net会依据这些标记在网格加密阶段对各个区域施加不同密度。这对做自适应网格特别实用——不需要自己手动做局部加密算法把要求交给库就好。4.3 Mesh的拓扑遍历邻居关系与边界提取除了三角形本身Triangle.Net还提供了完整的半边结构邻接信息。每个Triangle有三个邻居顺序与顶点顺序对应GetNeighbor(0)是顶点0对边的邻居。利用这个关系可以提取边界边即邻居为null的边。var boundaryEdges new List(Vertex a, Vertex b)(); foreach (var triangle in mesh.Triangles) { for (int i 0; i 3; i) { if (triangle.GetNeighbor(i) null) { var a triangle.GetVertex(i); var b triangle.GetVertex((i 1) % 3); boundaryEdges.Add((a, b)); } } }这段代码我在写轮廓提取和路径规划模块的时候用过很多次。边界边的方向是有讲究的GetVertex(i)到GetVertex((i1)%3)的方向默认是逆时针外边界或顺时针内边界所以如果你需要统一法线或内外方向还需要结合具体几何数据做方向修正。5. 常见问题与排查技巧实录5.1 输入顶点有重复导致剖分失败Triangle.Net对重复顶点处理不算宽容。如果你直接把一个列表里重复的坐标点喂进去运行时可能会报“Duplicate vertex”或产生退化的零面积三角形。排查方式是在喂入之前做去重容差要参考数据量级var distinctVerts new Dictionary(double, double), Vertex(); foreach (var p in rawPoints) { var key (Math.Round(p.X, 6), Math.Round(p.Y, 6)); if (!distinctVerts.ContainsKey(key)) distinctVerts[key] new Vertex(p.X, p.Y); }我在处理从STL文件解析出来的顶点时经常遇到大量重复点必须走这个去重步骤。注意Math.Round的精度选择要平衡过高容差会误合并本来独立的点过低又去不了重。5.2 约束边自相交或未闭合如果你希望某条边强制保留在剖分结果中需要用polygon.Add(vertex1, vertex2)的方式添加约束边。但如果约束边本身有自相交或者约束边的集合没有正确闭合Triangle.Net不一定能成功生成网格。我踩过一次坑地形建模时人为加了一道山脉约束线结果那根折线自己穿过了一个洞。最后排查是几何有效性检查没过先用库里自带的IsValid思路在外部做一遍检查才是稳妥做法。排查技巧把约束边可视化给出Segments列表检查是否存在相交。Triangle.Net本身也提供了TriangleNet.Geometry.Predicates下的几何谓词方法但警惕的是需要区分2D与3D用法。5.3 大点集性能问题我曾对一个包含8万个顶点的点集跑三角剖分默认设置下耗时大概在几百毫秒量级这在单线程算法里已经算不错了。但如果你的点有几十万量级建议做几件优化使用GenericMesher.Triangulate(polygon, constraint, quality)时QualityOptions里的MaximumArea不要设得过小太小的面积上限会触发大量Steiner点插入时间呈爆炸式增长。考虑先做四叉树降采样再用Triangle.Net生成骨架网格最后把细节顶点插值回去。如果同时剖分多个独立区域尽量分开独立剖分避免把所有点塞进一个Polygon里导致边界处理复杂化。5.4 浮点坐标很大导致剖分异常如果坐标数值达到百万级别比如地理坐标系的投影坐标X500000Y3000000Triangle.Net内部用双精度浮点计算底层的自适应精度算术虽然比普通浮点稳定但过大的坐标跨度仍可能导致输出网格出现微小退化三角形。实践中我的建议是先做坐标归一化计算点集包围盒把坐标平移到原点附近再缩放剖分完成后再逆变换回去。这样对剖分质量和后续数值计算都有好处。5.5 常见错误速查表现象原因解决方案Duplicate vertex异常输入存在重复坐标点构建前做基于容差的去重剖分结果出现NULL邻居边界边正常现象用于提取边界边不是错误结果多了凸包外三角形未把环边界声明为边界用Add(vertices, true)标记外边界Steiner点插入数量异常多最小角度过高或面积上限过小降低MinimumAngle调大MaximumArea输出顶点顺序变化剖分重建了拓扑用vertex.ID做索引映射别用原始列表下标洞没有生成洞三角形洞环和边界环方向不对或isHolefalse确保洞环用Add(list, false)6. 扩展方向与配套工具6.1 从2D网格到3D表面Triangle.Net本质上处理2D平面点集但完全可以用于3D表面网格生成。只要把3D顶点投影到2D参数平面比如取法线方向的主平面或做UV展开剖分完成后再把2D坐标替换回3D坐标即可。我做过一个车载激光扫描点云路面重构的Demo就是先把路面点投影到局部平面用Triangle.Net生成网格再映射回3D空间效果非常扎实。6.2 可视化调试把Triangle.Net输出渲染出来调试三角剖分结果最直观的办法是导出OBJ、PLY或SVG。我自己经常导出成SVG验证2D平面网格using TriangleNet.Tools; var writer new SvgWriter(); using var stream File.Create(output.svg); writer.Write(mesh, stream);SvgWriter是TriangleNet.Tools下现成的工具类几行代码就能把网格、顶点、区域标记都画出来这个对排查约束边、洞、三角形质量问题特别好用。还是那句话看不见的网格出问题会折磨死人先可视化再分析。6.3 与Unity的集成在Unity中使用Triangle.Net时最关键的是把Mesh转成UnityEngine.Mesh。上面的ExtractMeshData方法已经给出了顶点和索引列表再补上法线数组就能直接赋值。注意Unity的三角形绕序是顺时针从正面看而Triangle.Net默认输出是逆时针你需要在渲染时判断是否需要翻转索引方向否则会看到背面剔除导致的“消失面”。我自己总结的转换口诀是先提取后判断正反面不对就把索引的三个元素交换位置。Triangle.Net这个库我用了三四年下来最大的感受就是“稳”。它不是那种功能膨胀的巨型框架而是一把打磨得很锋利的螺丝刀拧紧螺丝就行别指望它帮你盖房子。但正因为它专注在Delaunay三角剖分这一个点上所以在这个领域里几乎无懈可击学术界验证过的算法、清晰简单的API、轻量的依赖这些都让它成为C#图形学工具箱里基本必须常备的一员。最后分享一个小技巧如果你对Triangle.Net生成的网格质量还不满意试试把ConstraintOptions里的ConformingDelaunay设为true同时把QualityOptions.MaximumArea调小这种组合在大多数地形类场景下出来的网格质量都相当能打。还有就是在做平面剖分时尽量不要用绝对值超大的坐标先归一化这个习惯能帮你省掉后面排查浮点异常的无数时间。