资讯动态

Unity自相交多边形最小环提取算法

发布时间:2026/10/1 13:07:49 来源:尧图企业网站定制
1. 这个算法到底在解决什么问题——从Unity画图场景说起我在做一款面向工业图纸标注的Unity2D工具时遇到一个看似简单却卡了整整三天的问题用户用鼠标自由绘制一个多边形轮廓比如画一个“8”字形、一个带内凹的“U”形或者更复杂的自交结构比如把一条线反复穿过自己画出的区域这时候系统需要自动识别出所有不重叠的、最小的封闭环——也就是数学上说的“面域”face或“有向环”oriented cycle。不是简单地按绘制顺序切一刀而是要真正理解图形的拓扑结构哪个环是外边界哪个是内孔哪个是独立岛屿哪个是嵌套中的嵌套。你可能觉得“多边形填充”不就是调用Unity的Graphics.DrawMesh或者CanvasRenderer的事但那是渲染层。而我们这里谈的是几何逻辑层在用户还没点击“确认”之前就要实时分析笔迹数据判断是否构成有效闭合区域是否产生自交自交后到底生成了几个独立面片。这一步决定了后续能否正确执行布尔运算如差集、并集、能否导出符合DXF标准的面域数据、能否为每个面片单独绑定材质或物理碰撞体。它不是炫技而是工业级2D工具的底层生存能力。举个具体例子用户在Unity Scene视图中拖拽鼠标画了一个“∞”符号无穷大符号。它的顶点序列是A→B→C→D→E→F→A其中线段BC和DE在中间交叉。如果直接按原始顶点顺序连成一个PolygonUnity的PolygonCollider2D会报错或生成不可预测的碰撞形状如果交给第三方库比如ClipperLib它能切出两个独立的椭圆但代价是引入非托管DLL、破坏纯C#约束、且无法与Unity的Transform系统深度联动。而我们要的是一个完全运行在Mono/.NET Standard 2.0环境下的、零外部依赖的、可调试可定制的轻量级算法——它必须能跑在Unity 2019.4的IL2CPP后端不能有反射、不能用dynamic、不能触发GC暴增。所以“自相交多边形的最小环提取”本质不是画图功能而是在GUI交互层与几何计算层之间架设一座纯C#的桥梁。它把用户肉眼看到的“乱线”翻译成引擎能理解的、结构清晰的“面集合”。这个过程不涉及任何UI控件渲染也不依赖Unity的Gizmos或OnDrawGizmos它只处理Vector2[]数组和ListLoop对象。正因如此它才能无缝嵌入到我们那个基于Unity GUI MVVM的轻量框架里View层捕获鼠标事件生成顶点流ViewModel层调用这个算法进行拓扑解析再通过INotifyPropertyChanged通知View更新高亮区域——整个链条干净、解耦、可单元测试。提示很多开发者一看到“自交多边形”就本能想到CGAL、Boost.Geometry或.NET版的NetTopologySuite。这些库确实强大但它们的设计哲学是“通用GIS”而Unity2D绘图场景的核心诉求是确定性、低延迟、可预测内存占用。一个工业图纸工具在1000个顶点的多边形上做一次环提取必须控制在3ms内完成且不能触发一次Full GC。这是通用库无法保证的硬指标。2. 为什么不能直接用射线法或奇偶规则——拓扑认知的底层鸿沟刚接手这个需求时我第一反应是“不就是判断点在多边形内吗用射线法Ray Casting或者奇偶规则Even-Odd Rule不就完了”——这是绝大多数Unity新手在写2D碰撞或区域高亮时的标准解法。但很快我就发现这种思路从根上就错了。射线法解决的是点-面关系判定而我们要解决的是线-线关系重构。前者输入是“一个点”输出是“真/假”后者输入是“一组有序线段”输出是“多个有向环的顶点列表”。举个反例画一个标准的五角星☆。它的顶点按绘制顺序是V0→V1→V2→V3→V4→V0但实际几何结构包含一个大的五边形外环和一个内部的小五边形孔洞。如果你用射线法对中心点做判定结果是“在内部”但这完全没告诉我们这个“内部”是由哪几条边围成的那5个尖角是不是独立的三角形自交点在哪里如何把原始10个顶点五角星有10个顶点重新组织成6个环1个外环5个内三角射线法对此毫无回答能力。更致命的是射线法在数值精度上极其脆弱。Unity的Vector2是单精度浮点当两条线段几乎平行、交点靠近端点时LineIntersects函数返回的交点坐标可能偏差0.0001单位。这个误差在渲染层可以忽略但在拓扑重建中会导致环的首尾无法闭合——你算出来的“最小环”最后一条边的终点和第一条边的起点差了0.0001系统就会认为这不是闭合环直接丢弃。我实测过在缩放100倍的图纸上1单位1mm这种误差出现概率高达17%。所以我们必须换一套语言用图论Graph Theory来建模用平面图Planar Graph来表达用欧拉公式Eulers Formula来验证。把每条原始线段看作图的一条边把所有端点和自交点看作图的顶点然后在这个图上寻找所有“面”face。这才是数学上严谨的解法。而Unity的Vector2虽然精度有限但它的和Equals方法在比较两个已知由同一算法生成的点时是完全可靠的——因为我们控制了所有交点的计算路径避免了不同函数间的精度漂移。具体怎么构建这个图第一步不是找交点而是预处理顶点序列。原始鼠标轨迹是连续的Vector2[]但我们需要把它拆成一系列不相交的线段segment。这里有个关键经验不要用暴力O(n²)两两检测而是用扫描线算法Sweep Line Algorithm的思想做空间分区。我把整个绘图区域划分为16×16的网格Grid Cell每条线段只和它所在格子及相邻8个格子内的线段做相交检测。实测下来对于500个顶点的复杂图形检测时间从1200ms降到47ms且漏检率为0——因为自交必然发生在局部邻域内全局穷举是反模式。注意Unity的Physics2D.GetRaycastNonAlloc或Collider2D.OverlapPoint等物理API表面看能快速判断线段关系但它们底层调用的是Box2D的Broadphase会引入额外开销和不可控的浮点舍入。我们的算法必须完全脱离Physics2D模块才能保证在无物理世界的纯GUI模式下正常工作。3. 核心算法拆解从交点生成到环遍历的四步闭环这个“最小环提取”算法我把它拆解为四个严格串行、不可跳过的步骤。每一步都对应一个明确的数学目标也都有容易踩坑的细节。下面我用一个真实案例全程演示用户画了一个“数字8”形状顶点序列为[ A(0,0), B(2,2), C(0,4), D(-2,2), E(0,0) ]其中线段AB与CD相交于P线段BC与DE相交于Q。3.1 步骤一鲁棒的交点计算与顶点扩充目标把原始n个顶点的多边形扩充为一个包含所有自交点的新顶点集使任意两条边要么不相交要么交于端点。难点不在公式而在数值稳定性。Unity的Vector2没有内置的精确交点计算网上流传的LineIntersection函数大多用行列式求解但在平行线或近似平行线时会因除零或极小分母导致NaN。我的方案是统一用参数化线段表示 距离投影法。每条线段用起点S、方向向量D、长度L表示D E - S,L D.magnitude。两条线段S1D1和S2D2的交点本质是求解参数t1和t2使得S1 t1 * D1 S2 t2 * D2整理为矩阵形式[D1, -D2] * [t1; t2] S2 - S1。但直接求逆矩阵风险高。我的做法是先计算D1和D2的叉积cross D1.x * D2.y - D1.y * D2.x。如果|cross| 1e-6f视为平行跳过平行线段不可能产生有效交点除非共线共线情况单独处理否则用克莱姆法则求解t1 ((S2 - S1).x * D2.y - (S2 - S1).y * D2.x) / cross; t2 ((S2 - S1).x * D1.y - (S2 - S1).y * D1.x) / cross;关键来了t1和t2必须严格在[0,1]区间内才认为是有效交点。但浮点误差会让t1.0000001被拒绝。我的补丁是定义const float EPS 1e-5f然后用t1 Mathf.Clamp01(t1)再判断|t1 - Mathf.Clamp01(t1)| EPS——不这样还是不行。最终方案是计算交点P S1 t1 * D1后再反向计算该点到两条线段的距离只有当两个距离都 EPS时才接受这个交点。实测这个双重校验让误报率降为0。对“数字8”案例我们得到两个交点P和Q。现在原始5个顶点变成7个A, P, B, Q, C, D, E。但注意P和Q不是简单插入而是分裂原有线段AB被P分成A-P和P-BCD被P分成C-P和P-DBC被Q分成B-Q和Q-CDE被Q分成D-Q和Q-E。最终我们得到8条不相交的线段。3.2 步骤二构建半边结构Half-Edge图目标建立一个能表达“边-邻面”关系的有向图为后续环遍历提供拓扑基础。为什么不用简单邻接表因为邻接表只能告诉你“哪些顶点相连”但无法区分“这条边属于哪个面的顺时针边界”和“哪条边是它的逆时针镜像”。而半边结构Half-Edge是计算几何领域的黄金标准每条物理线段被拆成两条方向相反的半边每条半边记录自己的起点、终点、下一条半边next、对应的孪生半边twin、所属面face。实现细节我定义了三个核心类public struct HalfEdge { public int origin; // 起点索引 public int target; // 终点索引 public int next; // 同一面内下一条半边索引 public int twin; // 孪生半边索引 public int face; // 所属面索引-1表示未分配 } public class PlanarGraph { public ListVector2 vertices; // 所有顶点含交点 public ListHalfEdge halfEdges; public Listint faces; // 每个面的起始半边索引 }构建过程对每条不相交线段如A-P创建两条半边h1(originA, targetP) 和 h2(originP, targetA)并互设twin。然后对每个顶点收集所有以它为起点的半边按极角排序用Mathf.Atan2(dy, dx)计算角度这样就能保证绕顶点逆时针顺序的半边是连续的。排序后把每个顶点的半边链按顺序连接h1.next h2, h2.next h3... 最后一条指向第一条形成围绕顶点的“星形”。对“数字8”我们最终得到16条半边8条线段×2构成一个包含3个面的图面0是左环A-P-Q-D-A面1是右环P-B-Q-C-P面2是外部无限面。注意外部面也是面只是面积为无穷大我们在后续过滤时会排除它。3.3 步骤三面遍历与环提取目标从半边图中找出所有有界bounded的面并提取其顶点环。算法本质是深度优先搜索DFS但不是搜顶点而是搜半边。规则很简单从任意一条未访问的半边开始沿着next指针走直到回到起点这就构成一个面。记录这个面的所有顶点然后标记这些半边为已访问再找下一条未访问半边。但这里有个陷阱无限面outer face也会被遍历出来。如何区分数学上有界面的有向面积signed area为正逆时针无限面为负顺时针。所以我给每个面计算signedArea 0.5f * sum((x_i * y_{i1} - x_{i1} * y_i))。如果signedArea 0则是有效面环如果 0则是外部面丢弃。对“数字8”我们得到两个正面积面左环顶点[A,P,Q,D]右环顶点[P,B,Q,C]。但注意这两个环共享边P-Q和Q-P这正是半边结构的优势——它天然支持共享边无需复制数据。3.4 步骤四环的最小化与嵌套关系判定目标把提取出的面环按“最小”原则排序并建立父子嵌套关系用于后续布尔运算。什么是“最小环”不是面积最小而是不被其他环完全包含的环。比如画一个圆套一个圆外圆和内圆都是面但外圆包含内圆所以内圆是“最小环”外圆不是。判定包含关系用经典的点在多边形内算法但这里我们用更高效的方法取每个环的质心centroid然后对每个环检查其质心是否在其他所有环内部。如果质心只在自己内部那它就是最小环如果还在另一个环内部那它就是子环。实操技巧为了避免重复计算我先对所有环按面积从小到大排序然后用一个bool[] isMinimal数组标记。对第i个环只检查比它面积小的前i-1个环是否包含它的质心。这样时间复杂度从O(n²)降到O(n²/2)。对“数字8”两个环面积相近质心互不在对方内部所以都是最小环。而如果画一个“回”字形我们会得到4个环最外框、第一内框、第二内框、中心实心块。经过判定只有中心实心块和第一、第二内框之间的环隙即“口”字形是最小环最外框被标记为非最小。提示这一步的输出就是MVVM ViewModel层真正需要的数据结构ListLoop其中Loop包含Vector2[] vertices、float area、int parentIndex-1表示顶层。View层拿到这个列表就能用Graphics.DrawPoly逐个高亮或生成PolygonCollider2D组件。4. 在Unity GUI中的实操集成从鼠标事件到环高亮的完整链路算法再漂亮不落地就是空中楼阁。下面我把这个最小环提取真正嵌入到Unity的GUI事件流中展示一个可运行的、零依赖的完整链路。整个过程不使用OnGUI已废弃而是基于EventSystem和GraphicRaycaster的现代UGUI方案但核心几何计算完全独立。4.1 View层鼠标轨迹采集与去抖动在Canvas下的空Image组件上挂载脚本public class DrawingView : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler { private ListVector2 _rawPoints new ListVector2(); private Vector2 _lastPoint; private const float MIN_DISTANCE_SQUARED 4f; // 防止抖动距离小于2像素不记录 public void OnBeginDrag(PointerEventData eventData) { _rawPoints.Clear(); _lastPoint eventData.position; _rawPoints.Add(_lastPoint); } public void OnDrag(PointerEventData eventData) { var current eventData.position; if ((current - _lastPoint).sqrMagnitude MIN_DISTANCE_SQUARED) { _rawPoints.Add(current); _lastPoint current; } } public void OnEndDrag(PointerEventData eventData) { if (_rawPoints.Count 3) return; // 至少3点才构成多边形 // 触发MVVM命令 DrawingViewModel.Instance.ExecuteDrawCommand(_rawPoints.ToArray()); } }关键点MIN_DISTANCE_SQUARED设为4即2像素不是凭感觉。我实测过鼠标在1080p屏幕上移动人类手抖的典型幅度是1.2~1.8像素设为4能滤掉99%的抖动又不会丢失细节。如果用Time.deltaTime做时间间隔过滤反而会丢失快速绘制的锐角。4.2 ViewModel层命令执行与环计算DrawingViewModel是典型的MVVM模式public class DrawingViewModel : MonoBehaviour { public static DrawingViewModel Instance; private ListLoop _currentLoops new ListLoop(); public IReadOnlyListLoop CurrentLoops _currentLoops; private void Awake() { Instance this; } public void ExecuteDrawCommand(Vector2[] rawPoints) { // 1. 转换为世界坐标适配Canvas缩放 var worldPoints rawPoints.Select(p Camera.main.ScreenToWorldPoint(new Vector3(p.x, p.y, Camera.main.nearClipPlane)) ).ToArray(); // 2. 执行最小环提取算法 var loops MinimalCycleExtractor.Extract(worldPoints); // 3. 更新ObservableCollection供View Binding _currentLoops loops.ToList(); OnPropertyChanged(nameof(CurrentLoops)); } }这里MinimalCycleExtractor.Extract就是前面讲的四步算法封装。注意ScreenToWorldPoint的z值必须用Camera.main.nearClipPlane而不是0——因为UGUI的Canvas默认是Screen Space - Overlayz0在屏幕平面但我们的绘图逻辑假设所有点都在z0的世界平面所以必须用近裁剪面深度来保证转换一致性。4.3 View层环的实时渲染与交互在同一个Canvas下挂载一个LoopRenderer组件它监听CurrentLoops变化public class LoopRenderer : MonoBehaviour { private Graphic _graphic; private readonly ListUIVertex _vertices new ListUIVertex(); private void Start() { _graphic GetComponentGraphic(); DrawingViewModel.Instance.PropertyChanged OnViewModelChanged; } private void OnViewModelChanged(object sender, PropertyChangedEventArgs e) { if (e.PropertyName nameof(DrawingViewModel.CurrentLoops)) { _graphic.SetAllDirty(); // 触发Rebuild } } protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); var loops DrawingViewModel.Instance.CurrentLoops; foreach (var loop in loops) { // 为每个环生成三角形扇Triangle Fan if (loop.Vertices.Length 3) continue; var center loop.Vertices.Average(v v); // 质心作为扇心 _vertices.Clear(); // 添加质心 _vertices.Add(CreateVertex(center, Color.green)); // 添加环上所有顶点 foreach (var v in loop.Vertices) { _vertices.Add(CreateVertex(v, Color.green)); } // 生成三角形索引0,1,2), (0,2,3), (0,3,4)... for (int i 1; i _vertices.Count - 1; i) { vh.AddUIVertexTriangle( _vertices[0].position, _vertices[i].position, _vertices[i 1].position ); } } } private UIVertex CreateVertex(Vector2 pos, Color color) { var v UIVertex.simpleVert; v.position pos; v.color color; return v; } }这个渲染器用VertexHelper直接操作顶点比Image.fillAmount或Mask方案更灵活能支持任意多边形。而且它完全不依赖Sprite或Texture纯代码生成内存占用可控。4.4 性能实测与优化锚点在i5-8250U GTX1050的机器上对不同复杂度图形的实测数据顶点数自交点数环提取耗时内存分配5030.8ms12KB200124.2ms48KB5004718.7ms132KB所有测试均在Unity 2021.3.25f1 IL2CPP Release模式下完成。关键优化点对象池化HalfEdge数组、Listint等中间容器全部预分配并复用避免GC定点数替代浮点对精度要求不高的环节如极角排序用int存储angle * 1000避免Mathf.Atan2的开销提前终止在交点检测中一旦发现某条线段与其他线段交点数超过10个立即标记为“高复杂度”切换到简化模式如合并近似共线点。注意这个算法在Unity WebGL平台同样可用但需关闭IL2CPP的Enable Exception Handling选项否则try-catch在WebGL上开销巨大。我实测开启后500顶点图形耗时从18.7ms飙升到124ms。5. 常见坑与避坑指南那些文档里不会写的实战教训这个算法我前后迭代了7个版本踩过的坑足够写一本小册子。下面分享3个最痛、最隐蔽、网上绝对搜不到答案的坑全是血泪经验。5.1 坑一共线三点导致的“伪自交”误判现象用户画一条直线比如从(0,0)到(10,0)再到(20,0)算法却报告有1个自交点。查了半天发现是线段A-B和B-C在B点“相交”了——但B是公共端点这根本不是自交而是退化情况。原因交点计算时t1或t2等于0或1被当作有效交点。但数学上端点相交不产生新面只是顶点重合。解决方案在交点计算后增加共线性校验。对三条点A、B、C计算叉积Vector2.Cross(B-A, C-A)如果|cross| EPS则三点共线。此时如果B在线段A-C上用点积判断则B是中间点应合并顶点而不是添加交点。我专门写了一个MergeCollinearPoints函数在顶点扩充前预处理所有共线序列把10个共线点压缩成2个端点。这一步让“直线绘图”的性能提升300%因为省去了90%的无效交点计算。5.2 坑二浮点误差累积导致的环首尾不闭合现象算法输出的环顶点数组最后一个点和第一个点坐标差0.00001Vector2.Distance(loop.Vertices.Last(), loop.Vertices.First()) 0.0001导致PolygonCollider2D初始化失败报错“Polygon must be closed”。原因每一步计算交点、质心、面积都引入微小误差多次累加后超出容忍阈值。解决方案强制闭合Forced Closure。在环提取完成后对每个环执行var first loop.Vertices[0]; var last loop.Vertices[^1]; if (Vector2.Distance(first, last) 1e-5f) { // 不是简单设lastfirst而是用插值平滑过渡 var delta first - last; for (int i 0; i loop.Vertices.Length; i) { loop.Vertices[i] delta * (i / (float)(loop.Vertices.Length - 1)); } }这个插值方案比粗暴覆盖更优它把误差均匀分布到所有顶点上避免在某个角上突然跳变影响后续的贝塞尔平滑或物理碰撞。5.3 坑三Unity Canvas RenderMode切换引发的坐标系错乱现象在Screen Space - Camera模式下绘图正常切换到World Space模式后环位置完全偏移。原因Camera.main.ScreenToWorldPoint在World SpaceCanvas下返回的是相对于Canvas Rect的位置而不是世界坐标。官方文档对此语焉不详。解决方案统一用Canvas坐标系。在Start()中获取RectTransform的worldToLocalMatrix然后所有坐标转换都走这个矩阵private Matrix4x4 _canvasToWorld; private void Start() { var canvas GetComponentInParentCanvas(); _canvasToWorld canvas.worldCamera.worldToCameraMatrix.inverse * canvas.worldCamera.projectionMatrix.inverse * canvas.GetComponentRectTransform().localToWorldMatrix; } // 在ExecuteDrawCommand中 var worldPoints rawPoints.Select(p _canvasToWorld.MultiplyPoint3x4(new Vector3(p.x, p.y, 0)) ).ToArray();这个矩阵链是唯一能100%准确转换的方式。我试过RectTransformUtility.WorldToScreenPoint它在某些Canvas缩放组合下会失效。最后再分享一个小技巧在算法调试阶段把所有交点、半边、面都用Debug.DrawLine画出来颜色编码红色交点、蓝色半边、绿色面这样一眼就能看出拓扑错误。但记住Debug.DrawLine只在Scene视图显示Game视图看不到所以一定要开着Scene窗口调试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑