☰
Earcut三角剖分实战:从多边形到WebGL索引缓冲的避坑指南
2026/9/26 11:49:33 网站建设 项目流程

简介:Earcut-Triangulation.zip 是一份面向图形学、GIS 与游戏开发者的多边形三角化算法实现资源,核心采用耳切法(Ear Clipping),并借助 z 阶曲线散列进行优化,可处理孔洞、扭曲多边形、退化及自相交等复杂情形,适用于地图轮廓、地理形状等实用数据的顶点索引提取。资源包共 33 个文件,约 4.29MB,以 C/C++ 源码为主体,包含 10 个 .h 头文件、7 个 .c 源文件、4 个 .hpp 与 2 个 .cpp,另有 4 个 csv 测试数据、2 个 txt 说明及 Visual Studio 工程文件(.sln、.vcxproj、.filters),结构完整,便于直接编译调试。目前已有 454 人学习下载。读者可从中获得耳切法三角化的完整实现代码、顶点索引生成流程、与 libtess2 的对比参考,以及多组几何测试数据,适合用于算法学习、工程移植与性能验证。

1. Earcut-Triangulation.zip 里到底装了什么:从多边形到三角网格的那一步

拿到一个叫 Earcut-Triangulation.zip 的压缩包,多数人第一反应是「这不就是个三角剖分工具」。但真正在项目里被它救过场的人都清楚,它解决的是一个非常具体的痛点:你手里有一串首尾相连的二维多边形顶点,可能是地图边界、建筑轮廓、字体字形,也可能是用户随手画的选区,而 GPU 只认三角形。Earcut 就是干这件事的——把任意简单多边形(允许带洞)快速拆成一堆三角形,输出的是顶点索引三元组,而不是坐标本身。它最常被 Mapbox GL、Three.js、Cesium 这类渲染管线当作底层几何预处理。这个压缩包通常包含核心算法实现、若干语言绑定或移植版本、测试用例和示例数据。适合谁?做 GIS 可视化、CAD 轻量预览、Canvas/WebGL 图形编辑、游戏地形裁剪的工程师。如果你正被「多边形填充渲染出来有裂缝」或者「带洞多边形三角化后洞被填死」折磨,这里的东西值得花一个下午跑通。

2. 三角剖分选型:为什么是 Earcut 而不是别人

2.1 耳切法到底切的是什么

Earcut 的名字来自「ear clipping」,耳切法。核心思想很朴素:一个简单多边形至少有两个「耳」——也就是由连续三个顶点构成、且对角线完全落在多边形内部的三角形。算法反复找一个耳,切掉它,把剩下的多边形继续切,直到只剩一个三角形。听起来简单,但工程上要处理三件事:顶点凹凸性判断、对角线是否与边相交、以及带洞时如何把洞「桥接」进外轮廓。Earcut 的高明之处在于它不追求数学上最优雅的剖分,而是用一套基于链表和哈希的迭代结构,把平均复杂度压到接近线性,同时用 z-order 曲线做空间加速。这就是为什么它能在浏览器里对几万个点的多边形做到毫秒级响应。

2.2 和 Delaunay、单调多边形剖分的取舍

很多人第一反应是用 Delaunay 三角剖分,因为它「质量高」。但 Delaunay 面向的是点集,不是多边形边界。你要先把多边形离散成点,剖完再筛掉外部三角形,步骤多、依赖 robust 几何谓词,遇到近共线点容易翻车。单调多边形剖分虽然理论 O(n log n) 且三角形质量可控,但实现复杂,带洞处理更麻烦。Earcut 的定位很明确:只要拓扑正确、速度够快,不追求最小角最大化。对于渲染填充、碰撞体近似、阴影体生成这类场景,三角形质量差一点完全能接受。我一般会这样选:需要物理仿真或有限元网格,走 Triangle 或 CGAL;需要实时渲染和交互,Earcut 是默认答案。

2.3 压缩包内典型结构与最小验证路径

一个典型的 Earcut-Triangulation.zip 解压后常见这几类内容:src/下是核心实现(可能是 C++ 原版、JS 移植、Python 绑定),test/下是单元测试和 fixture 数据,bench/或perf/放性能对比脚本,example/或demo/给最小可运行示例。不要一上来就读源码,先跑通最小示例。以 JS 版本为例,最小验证代码通常长这样:

// 引入 earcut,注意不同发行版导出方式不同 const earcut = require('earcut'); // 顶点数组:扁平化存储 [x0,y0, x1,y1, ...] const vertices = [10,0, 0,50, 60,60, 70,20]; // 洞的索引:每个洞的起始顶点在 vertices 中的下标 const holeIndices = []; // 维度:2 表示二维 const dim = 2; const triangles = earcut(vertices, holeIndices, dim); console.log(triangles); // [1,0,3, 3,2,1] 之类的索引三元组

逻辑说明:vertices是扁平数组,不是[[x,y],...],这是 Earcut 系列 API 的统一约定,为了减少 GC 压力。holeIndices里每个数字表示一个洞的第一个顶点在扁平数组中的顶点序号,不是坐标下标。dim默认 2,如果传 3 则按 xyz 读取但只取前两维做剖分。返回的triangles是索引数组,每三个一组,指向vertices中的顶点。参数怎么改:如果你的多边形有自相交,Earcut 不会报错,会给出错误拓扑,所以前置清洗很关键;如果顶点数超过 8 万,考虑分块或换 WASM 版本。

3. 把 Earcut 接进你的渲染管线:从数据准备到索引缓冲

3.1 顶点数据清洗与洞的桥接预处理

Earcut 对输入有隐含要求:外轮廓逆时针、洞顺时针,或者反过来,但必须一致。实际项目里拿到的数据往往方向混乱。我一般会先做三件事:去重(相邻点距离小于 epsilon 的合并)、定向(用带符号面积判断,统一外逆内顺)、闭合(首尾点重复的去掉尾部)。洞的桥接 Earcut 内部会做,但前提是洞完全在外轮廓内部且不相交。如果洞和外轮廓有接触,算法会退化。下面这段 Python 预处理脚本可以直接抄:

import numpy as np def signed_area(ring): # ring: Nx2 array x, y = ring[:,0], ring[:,1] return 0.5 * np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y) def clean_ring(ring, eps=1e-8): # 去重相邻点 keep = [0] for i in range(1, len(ring)): if np.linalg.norm(ring[i] - ring[keep[-1]]) > eps: keep.append(i) ring = ring[keep] # 去掉首尾重复 if np.linalg.norm(ring[0] - ring[-1]) < eps: ring = ring[:-1] return ring def orient(ring, ccw=True): area = signed_area(ring) if (area > 0) != ccw: return ring[::-1] return ring

逻辑说明:signed_area用鞋带公式算带符号面积,正为逆时针。clean_ring做距离阈值去重,eps默认 1e-8,坐标量级大时要相应放大。orient强制方向。参数说明:eps太大会吃掉真实短边,太小去不掉浮点噪声,建议按数据 bounding box 对角线长度的 1e-6 取值。

3.2 生成索引缓冲并喂给 WebGL

拿到triangles索引后,下一步是把它变成 GPU 能用的ELEMENT_ARRAY_BUFFER。这里有个血泪经验:Earcut 返回的索引是相对于你传入的扁平顶点数组的,如果你在剖分后又对顶点做了变换或合并,索引会错位。正确顺序是:清洗 → 剖分 → 用返回索引构建顶点缓冲 → 上传。下面是一个最小 WebGL 片段:

// 假设 vertices 已清洗,triangles 来自 earcut const vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); const ibo = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); // 注意:索引可能是普通数组,需转 Uint16 或 Uint32 const IndexArray = vertices.length / 2 > 65535 ? Uint32Array : Uint16Array; gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new IndexArray(triangles), gl.STATIC_DRAW); gl.drawElements(gl.TRIANGLES, triangles.length, gl.UNSIGNED_SHORT, 0);

逻辑说明:顶点数超过 65535 时必须用UNSIGNED_INT索引,否则会截断。drawElements的 count 是索引个数,不是三角形个数。参数说明:STATIC_DRAW适合不常变的几何,动态编辑场景用DYNAMIC_DRAW。如果渲染出来有黑边或裂缝,先检查顶点是否做了devicePixelRatio缩放而索引没同步。

3.3 带洞多边形的索引偏移计算

带洞时最容易翻车的是holeIndices的计算。很多人以为传的是坐标数组下标,其实是顶点序号。假设你有外轮廓 4 个点、洞 3 个点,扁平数组长度 14,洞的第一个点在第 5 个顶点位置,那么holeIndices = [4](从 0 开始数)。下面这个函数帮你从分离的环列表构建正确输入:

def build_earcut_input(outer, holes): # outer, holes: list of Nx2 arrays all_pts = [outer] + holes flat = [] hole_idx = [] count = 0 for i, ring in enumerate(all_pts): if i > 0: hole_idx.append(count) for p in ring: flat.extend([p[0], p[1]]) count += 1 return flat, hole_idx

逻辑说明:count累计顶点数,每遇到一个新洞就记录当前累计值作为洞起始序号。参数说明:outer和holes必须已经定向和清洗。如果洞的顺序和外轮廓方向不一致,Earcut 可能静默失败,返回空数组或错误三角形。

4. 避坑与排查:Earcut 在生产环境里的五个翻车现场

4.1 现象:剖分结果为空数组,控制台无报错

原因:多边形自相交,或者洞完全在外轮廓外部,或者顶点方向不一致导致算法找不到耳。Earcut 设计上不抛异常,只返回空或部分结果。解决:先用signed_area检查方向,再用线段相交检测筛自相交。我一般会加一个断言:if (triangles.length === 0 && vertices.length >= 6) throw new Error('triangulation failed'),把静默失败变成显式失败。

4.2 现象:渲染出来洞被填死,或者洞的位置偏移

原因:holeIndices传成了坐标下标而不是顶点序号,或者洞的顶点没有连续存放。解决:用 3.3 的build_earcut_input重新构建输入。另外注意,如果洞和外轮廓共享顶点,Earcut 会认为它们相交,需要先做微小偏移或拆分。

4.3 现象:大坐标(如经纬度)下剖分结果抖动或错误

原因:浮点精度。经纬度量级下,1e-8的 epsilon 完全不够,耳判断会误判。解决:剖分前把坐标平移到局部原点,缩放到位移量级在 1e3 以内,剖完再变换回去。这是 GIS 场景的标配操作,别偷懒。

4.4 现象:顶点数超过 65535 后 WebGL 渲染花屏

原因:索引用了Uint16Array截断。解决:动态判断顶点数,超过阈值用Uint32Array,并确认OES_element_index_uint扩展可用。如果目标设备不支持,只能分块剖分。

4.5 现象:性能在几万点后急剧下降

原因:Earcut 虽然平均线性,但最坏情况仍是 O(n²),尤其是高度凹多边形。解决:先用凸包或网格切分把大多边形拆成小块,再分别剖分。或者换 WASM 版本,通常有 3 到 5 倍提升。别在 JS 主线程剖分十万级顶点,会卡死 UI。

5. 进阶:用 Earcut 做动态编辑与三角网质量验证

5.1 动态拖点后的增量重剖分策略

交互式编辑器里,用户拖动一个顶点就全量重剖分,几万点会卡。我的做法是:把多边形按顶点索引分块,只重剖受影响块和相邻块,其余复用旧索引。Earcut 本身不支持增量,但你可以用「脏矩形」思路包一层。具体是维护一个顶点到三角形的反向索引,拖点时标记相关三角形为脏,只对脏区域对应的子多边形重新调用 Earcut。代价是接缝处可能出现 T-junction,需要额外做边匹配。对于顶点数低于 5000 的场景,全量重剖分其实够快,别过早优化。

5.2 三角网质量检查:面积、朝向与重叠

剖分完不能直接信。我习惯跑三个检查:总面积是否等于多边形面积(误差 1e-6 内)、所有三角形朝向是否一致(叉积符号相同)、任意两个三角形是否重叠(用 AABB 粗筛加精确相交)。下面是一个快速验证脚本:

def validate(vertices, triangles): # vertices: flat list, triangles: index list pts = np.array(vertices).reshape(-1, 2) tris = np.array(triangles).reshape(-1, 3) total = 0.0 signs = [] for t in tris: a, b, c = pts[t[0]], pts[t[1]], pts[t[2]] cross = np.cross(b - a, c - a) total += abs(cross) / 2 signs.append(np.sign(cross)) # 检查朝向一致性 if len(set(signs)) > 1: print('朝向不一致,可能存在自相交或方向错误') # 和多边形面积对比 poly_area = abs(signed_area(pts)) if abs(total - poly_area) > 1e-6 * poly_area: print(f'面积不匹配: {total} vs {poly_area}') return total

逻辑说明:cross的符号代表三角形朝向,total累加面积。参数说明:面积误差阈值按多边形尺度调整,经纬度数据要先投影到平面。这个检查能抓住 90% 的剖分错误,建议放进 CI。

5.3 一个我常犯的错误

早期我总以为 Earcut 返回的三角形顺序有保证,直接拿第一个三角形做拾取,结果在带洞多边形上拾取到了洞里的区域。后来才明白,Earcut 只保证拓扑正确,不保证三角形顺序或空间局部性。现在我会在剖分后按三角形质心做一次 z-order 排序,拾取时先做粗筛。这个习惯帮我省了很多调试时间。希望帮到你。

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

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

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

立即咨询