☰
把散乱顶点变成干净三角形——Earcut深入解析与避坑指南
2026/10/15 15:49:20 网站建设 项目流程

简介:这是一份基于耳切法(Ear Clipping)的多边形三角化C++实现资源,面向需要将无序顶点转换为三角面片索引的图形学、GIS与游戏开发者,可直接集成到现有C++项目。工程在经典耳切算法基础上引入Z阶曲线散列优化,并参考Martin Held的FIST与David Eberly的耳切法思路,支持孔洞、扭曲、退化及自相交多边形输入,虽不保证所有畸形输入均得正确剖分,但对道路区域等地理形状数据有较高可用性。资源共33个文件,以C++头文件与源文件(h/c/hpp/cpp)为主,配套CSV顶点/索引样本、Visual Studio工程文件(sln/vcxproj)及说明文档,压缩包大小4.29MB。目录内含earcut.hpp核心实现、libtess2对比模块、fixtures测试用例以及道路区域等多组CSV地形数据,可直接编译运行观察不同输入下的三角化效果。目前已有454人学习,适合希望深入理解耳切法原理并获取可运行参考实现的开发者。

1. 把无序顶点变成干净三角形:Earcut-Triangulation 值得下

把一堆毫无顺序的二维点变成一组漂亮的三角形网格,是很多图形工具一开始就得过的一关。我拿到《Earcut-Triangulation.zip》这套资源时,第一感觉就是它把耳切法这条老路走通了:输入无序顶点,输出三角形顶点索引,不用手动排序,也不用担心凹多边形把网格切穿。适合做建模前处理、网格生成、以及给 GIS 面数据补拓扑的人。下面把原理、调用方式和我踩过的几个坑一次说清。

2. 从无序到有序:耳切法前置处理到底做了什么

2.1 耳切法切的是“多边形”,不是“点集”

耳切法的核心操作很简单:沿着多边形边界走,找到当前顶点和它左右相邻点组成的三角形,如果这个三角形内部没有其他顶点,且当前顶点是凸的,就把它当作一只“耳朵”剪掉。剪完之后少一个顶点,对剩下的多边形重新找耳朵,重复到只剩最后三个顶点为止。

这个过程的输入必须是一个简单多边形,也就是顶点顺序已经确定、边不交叉、不重叠的多边形。算法本身并不关心这个多边形是凸的还是凹的,凹多边形一样能切,只是找耳朵时要多判一次“三角形内部是否有其他点”。复杂度的上限大约是 O(n²),因为每个耳朵都要扫一遍剩余顶点做包含性判断。

所以真实项目里遇到的第一道坎,不是耳切法本身,而是“我怎么知道这些点谁是邻居”。标准的 Earcut 实现会要求你传入一个按边界顺序排好的顶点数组,比如一个五边形的顶点,你得按照走一圈的顺序传进去,否则算法眼里这些边的连接关系全是错的,切出来的三角形必然交叉或漏空。

2.2 无序顶点进入耳切法之前要过一道“排序关”

我拆这份资源时,最先找的就是它对无序顶点的处理。因为标题里明确写了“无序顶点”,说明它内部一定有一套预处理逻辑,不会把烂摊子直接扔给核心三角化函数。

一种很常见的预处理是按极角排序:先算出点集的几何中心,然后把所有点按照相对这个中心的极角从小到大排一遍。这样排出来的结果是一个星形多边形,也就是所有点都能从中心看到,连线不会自交。之后把这个有序数组交给耳切法,三角形自然就出来了。

但这套逻辑有一个天然边界:它对凸点集效果很好,对凹点集就玄学。因为几何中心可能落在凹多边形外面,按极角排序会把一些内部凹点排到错误的边界位置,导致三角形整体面积变化、边界轮廓被拉坏。我在实际项目里就遇到过,一组城区建筑轮廓点用了极角排序后,屋檐拐角被切掉,原本凹进去的墙线被割成一条直线。

所以这份资源里如果包含的是“预处理 + 耳切法”的完整流程,它大概率用了比极角排序更稳的方法,比如凸包分解、最近邻边界重建,或者先做一次 Delaunay 近邻判断。你在使用前应该先确认它面对凹点集时是直接失败,还是能通过参数切换降级到凸包模式。

2.3 一个极角排序的参考实现,看清边界在哪

为了让你对“无序顶点预处理”这件事有直观感受,我写一段最常见的极角排序代码,这也是很多轻量实现会用的做法。

function sortByAngle(points) { // 计算几何中心 const cx = points.reduce((sum, p) => sum + p[0], 0) / points.length; const cy = points.reduce((sum, p) => sum + p[1], 0) / points.length; const withAngles = points.map((p) => { const angle = Math.atan2(p[1] - cy, p[0] - cx); return { point: p, angle: angle }; }); withAngles.sort((a, b) => a.angle - b.angle); return withAngles.map((item) => item.point); }

这段代码表达的意思很简单:先把所有点的 x 和 y 分别求和取平均,得到质心;再用atan2计算每个点相对质心的方位角;最后按方位角从小到大排序。

逻辑本身没有大问题,但它的边界条件很值得注意。第一,质心可能落在点云外部,导致角度排序不稳定。第二,这个排序保证的是“从质心观察时不遮挡”,不保证“边界是真实轮廓”。如果你的输入是一组凹多边形的顶点,这种排序会产生一个与自己相交的假多边形,耳切法切出来的结果会出现反转三角形和空洞重叠。

我一般会把这种排序当作最后兜底,而不是默认方案。如果点集来自真实地理坐标或建筑轮廓,我会先用凸包算法检测凹点密度,凹点占比高就放弃极角排序,改用轮廓追踪。

3. 获取顶点索引:调用流程与返回数据怎么解析

3.1 数据读取与压平

这份资源里的核心函数,我推测遵循了常见耳切法库的接口约定:传入一个扁平数组,也就是把所有点的 x、y 交替放进同一层数组,例如[x0, y0, x1, y1, ...]。如果你手里的原始数据是一组二维坐标对,第一步需要把它压平。

const fs = require('fs'); // 读取一个包含二维点数组的 JSON 文件 const rawPoints = JSON.parse(fs.readFileSync('points.json', 'utf-8')); // 压平为 [x0, y0, x1, y1, ...] 的格式 const flatCoords = []; for (const p of rawPoints) { flatCoords.push(p[0]); // x flatCoords.push(p[1]); // y } console.log('压平后的长度:', flatCoords.length);

这里有一点容易绕进去:耳切法的索引输出是按“顶点序号”来的,不是按“数组下标”来的。如果步长是 2,那么数组索引 0 和 1 对应顶点 0,索引 2 和 3 对应顶点 1。所以你一定得先把坐标对压平,再交给算法,否则它会把 x 当作 y,导致三角形拧成麻花。

我一般会在这个阶段顺手做两件小事:去重和数值检查。原因是后面三角化时,重复点会造成零面积三角形,NaN 会造成整个索引数组错位。与其在输出端排查,不如在入口就堵住。

3.2 earcut 核心调用:三个参数的含义

资源里的三角化函数通常会接受三到四个参数:顶点数组、孔洞数组、维度数(一般是 2)。孔洞数组是一个可选参数,用来标注哪些顶点索引是多边形内部孔洞的起始点。如果没有孔洞,直接传null就好。

// 假设 flatCoords 已经压平 const holes = null; // 没有孔洞 const dim = 2; // 二维坐标 // 调用三角化,得到顶点索引数组 const triangleIndices = earcut(flatCoords, holes, dim); console.log(triangleIndices); // 输出类似 [0, 1, 2, 2, 3, 0, ...]

输出的triangleIndices是一个数字数组,每三个数字构成一个三角形。比如[0, 1, 2]表示由第 0、第 1、第 2 个顶点组成的三角形。索引指向的是压平数组里的顶点序号,而不是坐标本身。

我第一次用的时候犯过错,以为返回的是坐标,想要直接从结果里拿三角形三个顶点的位置。后来发现要从索引去查flatCoords里的值,比如顶点 1 的坐标是flatCoords[2]和flatCoords[3]。这个换算关系搞错,后面所有面积计算和可视化都会全错。

dim这个参数很关键。如果你传入的是三维坐标但只做二维三角化,那么dim应该还是 2,算法只取 x 和 y,忽略 z。但如果你的数组里掺了别的字段,比如[x, y, value],就得把dim设为 3,否则读取错位。

3.3 解析三角形索引并校验连通性

拿到索引数组之后,一般需要做一个后处理:把它拆成一个个三角形,按组输出,方便渲染或者算面积。

function indicesToTriangles(indices) { const triangles = []; for (let i = 0; i < indices.length; i += 3) { triangles.push([indices[i], indices[i + 1], indices[i + 2]]); } return triangles; } const triangles = indicesToTriangles(triangleIndices); console.log('三角形数量:', triangles.length); // 打印前三个三角形 console.log(triangles.slice(0, 3));

逻辑说明:因为索引数组是按三个一组排列的,所以只需要每三个切一刀,得到一个三角形数组。每个三角形内部是三个顶点序号,顺序可能是顺时针也可能是逆时针,这取决于输入顶点顺序。

我通常在解析完之后会马上做一次同一条边被两个三角形共享的检查。方法很直接:把所有三角形的边排序成(minIndex, maxIndex),然后统计每个边出现次数。正常无孔洞的多边形,内部边会出现两次,边界边只出现一次。如果出现三次,说明有三重覆盖,三角化结果不干净。这个问题在你用无序顶点输入时特别常见,往往是预处理阶段制造了自交边。

4. 避坑手册:五个让三角化翻车的细节

4.1 重复点导致死循环或 NaN 索引

现象:三角化函数返回的索引数组里出现undefined,或者跑完结果只有两个三角形,再或者程序直接死循环。

原因:输入数据里有两个顶点坐标完全相同。耳切法在判断“三角形内部是否有点”时,会拿这两个重复点去做叉积计算,结果得到一个零向量,方向判断完全失效,算法认为这个点不构成合法耳朵,然后反复在同一个凸点附近打转。

解决:在进入三角化之前,用距离阈值做一次去重。不要用精确相等去比较浮点数,建议把坐标放大到整数域,或者用相对距离判断。我的习惯是保留第一个点,删掉后面所有距离小于1e-6的点,并把重复点的索引重新映射到保留的那个点上。

4.2 输入顶点方向不一致

现象:输出的三角形有些是正面,有些是反面,渲染时出现“阴阳面”。

原因:输入多边形可能一部分顶点是逆时针排列,另一部分是顺时针排列。耳切法对方向没有强制统一要求,但混着来会导致耳朵判定时凸凹关系错乱。一个明明应该是凹的顶点,因为三角形法线方向翻转,会被误判成凸点。

解决:先计算整个多边形的有符号面积。面积为正说明当前排序是逆时针,面积为负则是顺时针。统一乘以一个方向系数,把负面积的正负号调整一致。这一步要在预处理阶段做,不能等三角化完再修复。

4.3 共线点触发异常细长三角形

现象:结果里出现扁平到几乎成一条线的三角形,面积接近零但不等于零。

原因:三个顶点落在地图上同一条直线上,叉积结果刚好逼近浮点精度边界。耳切法的“凸点”判定标准是叉积大于零,如果叉积本身是个极小正数,它会把这种退化三角形当成合法耳朵剪掉。

解决:把凸点判定从单一叉积改成带 epsilon 的双重判断。比如cross > 1e-10才算凸点,小于这个阈值但不为负的,不剪耳朵,继续等待。如果你用的是现成的 earcut 实现,可以看它是否暴露了容差参数,没有的话就把输入点坐标缩放一下,让距离拉大,减少浮点误差影响。

4.4 孔洞数据处理错误

现象:最后生成的三角形把孔洞区域也填满了,整个面片变成实心。

原因:孔洞数组没有传进去,或者传进去的索引位置不对。耳切法处理孔洞的方式是把孔洞和外部轮廓用一条桥接边连起来,桥接边的位置由孔洞起始索引决定。如果索引指向的区域不对,桥接边会把外壳切成奇怪的两半。

解决:确认你的外部多边形顶点先排完,孔洞顶点排在后面。然后把每个孔洞在整体顶点数组中的起始索引写进holes数组。这里最容易犯的错是把孔洞在子数组里的下标当成全局下标。我在一个项目里就是把孔洞的局部索引 0 直接传进去,结果 earcut 把外部轮廓上的点当作桥接点,切出来的三角形全部乱套。后来统一改成“全局顶点数组偏移 + 局部下标”的计算方式才解决。

4.5 无序顶点数量过大时预处理会变得很慢

现象:给到一万多个无规则排列的点,三角化本身很快,预处理却跑了十几秒。

原因:某些无序预处理方案用了 O(n²) 的启发式,比如两两比较所有点距离去重建边界。在点少时看不出来,点一多就爆炸。耳切法核心是 O(n²),但通常靠空间局部性可以接受;预处理如果也是 O(n²),两个叠加就非常痛苦。

解决:用网格分桶或者 k-d 树加速近邻搜索。资源里如果内置了加速结构,调用的时候会多一个 hash 参数或网格大小参数。没有的话,我建议先下采样到 5000 点以内,预处理好再补点。血泪经验是千万别写一版 O(n²) 的边界追踪就上线,测试数据不报警,真实数据必翻车。

5. 进阶用法:用面积校验和 SVG 输出验证三角化结果

拿到顶点索引之后,最可靠的自检方法就是面积校验:计算输入多边形的总面积,再计算所有输出三角形的面积总和,两者差超过阈值就说明三角化有误。这个方法尤其适合无序顶点输入,因为预处理一旦出错,面积偏差会非常明显。

我用一段 Python 脚本做校验,先把索引还原成坐标,然后逐三角形算面积。

def polygon_area(coords): # coords 是 [(x0, y0), (x1, y1), ...] 顶点坐标列表 area = 0.0 n = len(coords) for i in range(n): x1, y1 = coords[i] x2, y2 = coords[(i + 1) % n] area += x1 * y2 - y1 * x2 return abs(area) * 0.5 def triangles_area(tri_indices, coords): total = 0.0 for i in range(0, len(tri_indices), 3): a = coords[tri_indices[i]] b = coords[tri_indices[i + 1]] c = coords[tri_indices[i + 2]] total += abs( (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1]) ) * 0.5 return total

这段校验代码能同时检查两个东西:一是三角形索引有没有越界,如果索引超出坐标列表长度,coords[tri_indices[i]]直接抛异常;二是面积和是否一致,如果有自交三角形,面积对不上的概率极大。

我自己每次都把面积差值容忍度设在 1e-6 量级,超过这个数值就说明预处理可能改变了边界。为了更直观,我还会输出一个 SVG 文件,把所有三角形填充成半透明色,肉眼扫一遍就能看出哪里有翻转。

从那以后,我每次接入无序顶点三角化,都强制走一遍面积校验加索引越界检查,这个习惯帮我挑出了很多数据源里藏着的问题——重复点、孔洞格式错位、凹点被错误排序,几乎都能被它揪出来。希望帮到你。

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

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

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

立即咨询