简介:Delaunay三角剖分是计算机图形学中经典的三角形网格生成算法,其核心在于构建任一三角形外接圆内不包含其他点的三角网格,从而避免狭长与不规则三角形,提升模型的稳定性和计算效率。这份资源围绕C#语言讲解Delaunay剖分,压缩包内为完整的Visual C++工程实现,面向需要运用三角剖分进行地形建模、3D打印面片生成、游戏物理网格构建或mesh建模算法研究的开发者。压缩包共25个文件,大小仅44KB,包含6个头文件与5个C++源文件,并配有Visual Studio工程配置、MFC界面资源、调试记录与说明文档,可直接编译运行,直观查看剖分效果。目前已有123人浏览学习,资源虽小但流程完整。通过源码可以系统掌握Delaunay剖分的全部关键步骤:从点集准备与去重,到调用AddPoint添加顶点、Build执行剖分,再到遍历三角形、导出OBJ/STL等格式;结合MFC示例,还能学习如何将算法嵌入图形界面,用于课程设计或实际工具开发,具有较高的参考与复用价值。
1. Delaunay不是玄学:网格建模的地基算法
做三维重建、有限元前处理或者游戏地形生成的人,迟早会碰到一个名字:Delaunay。它常被包装成"经典的三角形生成算法"出现在各种源码包里,很多人下载之后跑了一遍demo,看到屏幕上出现一堆三角形就关掉了,觉得不过如此。实际上Delaunay三角剖分解决的是mesh建模里最基础也最头疼的问题——给你一堆散乱的点,怎么连成一张没有重叠、没有细长畸形三角形的面片网络。这个问题的答案直接决定了后续渲染、仿真、碰撞检测是顺滑还是翻车。
我最初接触它是在某个图像处理Demo里做轮廓重建,当时点云只有几千个点,随手调了个库就出结果了,觉得这算法没什么可研究的。后来换到带噪声的真实扫描数据,直接套默认参数,出来的网格乱七八糟。回头把Delaunay的数学性质、适用边界和实现细节捋了一遍才发现,算法本身不难,难的是知道它什么条件下成立、什么条件下需要给数据做预处理。这篇笔记就按这个思路来:先把原理讲透,再给可复现的最小实现,最后把实际建模中的坑一个个拆开。
2. 从散点到三角形:先搞懂Delaunay在优化什么
2.1 空外接圆准则:Delaunay三角剖分的唯一锚点
Delaunay三角剖分的定义可以用一句话概括:对给定点集,生成的每个三角形的外接圆内部不包含任何其他点。这个"空外接圆"准则是整个算法的基石,所有实现、变体和优化策略都围着它转。
为什么这个准则有意义?因为它直接保证了三角形的形态质量。当外接圆内不包含其他点时,三角形会自动避免出现极端狭长的形状。在mesh建模的场景里,三角形越接近等边三角形,后续的插值计算越稳定,有限元仿真的刚度矩阵条件数越好,渲染时的法线插值也越平滑。反过来,如果存在一个极扁的三角形,它的外接圆会非常大,很容易覆盖到其他点,算法就会自动调整边连接方式来消除这种劣质三角形。
常见的实现算法是逐点插入法,思路很直接:维护一个现有的三角形集合,每加入一个新点,找到包含该点的三角形,把它拆成三个新三角形,然后检查这三个三角形是否满足Delaunay准则,不满足就翻转对角线。这个"检查加翻转"的过程叫Legalize,是整个算法收敛性的保证。
import numpy as np class Delaunay2D: def __init__(self, points): self.points = np.asarray(points, dtype=np.float64) # 生成超级三角形,覆盖所有输入点 max_coord = np.max(np.abs(self.points)) * 10 self.super_triangle = np.array([ [-max_coord, -max_coord], [max_coord, -max_coord], [0, max_coord] ]) self.triangles = [tuple(range(len(self.points), len(self.points) + 3))] self.vertices = np.vstack([self.points, self.super_triangle])注意这段代码先把超级三角形塞进顶点列表,它的作用是保证任何新点都能找到一个包含它的三角形用于初始拆分。计算结束后要把和超级三角形相关的三角形全部删掉,这是新手最常遗漏的步骤。max_coord的取值要参考输入点坐标的绝对值范围,取得太小会导致部分真实点落在超级三角形外。
2.2 为什么三维mesh不能直接套二维算法
不少从二维开始学Delaunay的人会天然地认为,把三角形换成四面体、外接圆换成外接球就行。这个方向没错,三维对应的叫Delaunay四面体化,但真正做三维建模时很少有人直接用它来生成表面mesh,原因是输出的是实心四面体填充,而不是表面三角网。
二维Delaunay的输入是平面点集,输出是唯一的三角剖分(在无四点共圆的条件下);三维输入是空间点集,输出是四面体集合,这些四面体填充了整个点集的凸包。但mesh建模通常只要表面,凸包内部的四面体全是多余计算量。更麻烦的是,三维中可能出现"薄四面体"——四个点近似共面时生成的四面体体积趋近于零,这类单元的数值稳定性极差,需要额外的Sliver Removal步骤来处理。
实际工程中常见的路径是:先对点云做表面重建(比如Poisson重建或alpha shape),再把重建出的表面mesh喂给重网格化工具生成高质量的三角形网格。Delaunay在其中的角色是作为重网格化的底层剖分工具,而不是直接从散点跳到表面。理解了这层关系,你就知道为什么网上那些"三维Delaunay建模"的代码包多半会附带一个表面提取或裁剪的模块。
2.3 选型理由:什么场景必须用Delaunay,什么场景该绕开
Delaunay三角剖分不是万能的。我列一个简单的选型对照,按实际使用频率排序:
| 场景 | 是否适合Delaunay | 理由 |
|---|---|---|
| 有限元二维网格生成 | 高度适合 | 最小角最大化保证单元质量 |
| 地形高程模型(TIN) | 高度适合 | 点密度自适应,地形断裂线可约束 |
| 三维点云表面重建 | 部分适合 | 必须配合表面提取,不能直接用四面体 |
| 角色模型手工建模 | 不适合 | 需要流形拓扑和UV展开,Delaunay帮不上忙 |
| 流体仿真网格 | 谨慎 | 边界层需要各向异性网格,标准Delaunay是各向同性的 |
当点集存在明显的密度差异时,Delaunay会生成大量短边三角形,导致网格规模爆炸。此时更合理的手段是先对点云做简化或自适应采样,再走Delaunay流程。"先把数据处理好,再让算法跑"这条经验,在这个领域比其他任何地方都适用。
3. 本地跑通Delaunay三角化:最小Python脚本与参数调法
3.1 Bowyer-Watson增量法的完整实现与逐行解释
Bowyer-Watson是实践中最常用的Delaunay实现思路,它的核心操作比逐点插入法更直接:每加入一个新点,找出所有外接圆包含该点的三角形(这些三角形组成一个"冲突区域"),删掉它们,然后连接新点和冲突区域的边界顶点,形成新的三角形集合。这个算法好写、好调试、支持增量式更新,是源码包里最常见的实现。
import numpy as np def bowyer_watson(points): points = np.asarray(points, dtype=np.float64) n = len(points) # 超级三角形:边长为数据范围的4倍,中心在数据质心 centroid = np.mean(points, axis=0) dx = np.max(points[:, 0]) - np.min(points[:, 0]) dy = np.max(points[:, 1]) - np.min(points[:, 1]) radius = max(dx, dy) * 4 super_tri = np.array([ centroid + [-radius * 2, -radius], centroid + [radius * 2, -radius], centroid + [0, radius * 3] ]) # 初始化:超级三角形是第一个三角形 all_points = np.vstack([points, super_tri]) tris = [tuple(range(n, n + 3))] for i in range(n): p = all_points[i] # 找到所有外接圆包含p点的三角形 bad_tris = [] boundary_edges = [] for tri in tris: v0, v1, v2 = (all_points[j] for j in tri) circum = get_circumcircle(v0, v1, v2) if np.hypot(p[0] - circum[0], p[1] - circum[1]) < circum[2]: bad_tris.append(tri) # 提取冲突区域的边界边(只被一个坏三角形使用的边) edge_count = {} for tri in bad_tris: edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] for e in edges: edge_count[e] = edge_count.get(e, 0) + 1 edge_count[(e[1], e[0])] = edge_count.get((e[1], e[0]), 0) + 1 boundary = [e for e, cnt in edge_count.items() if cnt == 1] # 删除坏三角形,加入新三角形 tris = [t for t in tris if t not in bad_tris] for e in boundary: tris.append((e[0], e[1], i)) # 去除与超级三角形顶点相连的三角形 super_indices = set(range(n, n + 3)) result = [t for t in tris if not (super_indices & set(t))] return result def get_circumcircle(v0, v1, v2): ax, ay = v0 bx, by = v1 cx, cy = v2 d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) if abs(d) < 1e-12: return (0, 0, 1e9) ux = ((ax**2 + ay**2) * (by - cy) + (bx**2 + by**2) * (cy - ay) + (cx**2 + cy**2) * (ay - by)) / d uy = ((ax**2 + ay**2) * (cx - bx) + (bx**2 + by**2) * (ax - cx) + (cx**2 + cy**2) * (bx - ax)) / d r = np.hypot(ax - ux, ay - uy) return (ux, uy, r)这段代码里有几个关键参数直接影响成败。首先是超级三角形的尺寸,radius = max(dx, dy) * 4,这个倍数是经验值。太小了会有真实点落在超级三角形外部导致初始化失败,太大了会让后续外接圆计算出现浮点精度损失。其次是get_circumcircle返回的圆心和半径,这是每次判断的核心,如果点集中存在大量同坐标点,d会趋近于零,这里必须加一个绝对值的判断并返回一个极大半径的空圆,否则除零错误会在算法跑到一半时爆发。
3.2 绘制和验证输出:三步确认三角剖分正确
跑完算法后不能光看三角形数量对不对,要画出来。用Matplotlib可以做快速验证,但更严谨的检查是用Delaunay的数学性质:对每个三角形,取其外接圆,然后检查所有其他点都不在圆内。这个检查是O(n²)的,适用于小规模验证,但它是判断实现正确性的黄金标准。
import matplotlib.pyplot as plt from matplotlib.patches import Polygon def validate_delaunay(points, triangles): """每个三角形外接圆内无其他点,返回True/False和违规三角形索引""" violations = [] for idx, tri in enumerate(triangles): v0, v1, v2 = (points[i] for i in tri) cx, cy, r = get_circumcircle(v0, v1, v2) for j, p in enumerate(points): if j in tri: continue if np.hypot(p[0] - cx, p[1] - cy) < r - 1e-9: violations.append(idx) break return len(violations) == 0, violations # 生成随机点并运行 np.random.seed(42) pts = np.random.rand(50, 2) * 10 tris = bowyer_watson(pts) # 可视化:三角形绘制 + 外接圆调试 fig, ax = plt.subplots(1, 2, figsize=(12, 5)) for tri in tris: poly = Polygon(pts[list(tri)], closed=True, fill=False, edgecolor='steelblue') ax[0].add_patch(poly) ax[0].plot(pts[:, 0], pts[:, 1], 'ro', markersize=3) ax[0].set_title(f'{len(tris)} triangles') ok, bad = validate_delaunay(pts, tris) print(f"Delaunay validation: {ok}, violation count: {len(bad)}")这里的1e-9容差是必须的。浮点运算中外接圆判断的误差一般在1e-12到1e-9之间,如果没有任何容差,所有算法实现都会在验证阶段报出少量违规三角形,这是数值精度问题而不是逻辑错误。另外注意plt.rcParams中图形窗口大小、散点尺寸这类显示参数,它们不影响算法逻辑,但调试时看不清三角形边缘容易误判。
3.3 三个必调参数:超级三角形尺寸、浮点容差和退化点处理
实现层面最影响结果的就是这三个参数。超级三角形尺寸前面说过了,再补充一个细节:如果你的点集是非均匀分布的,使用数据范围乘以固定倍数比使用固定常数稳妥得多。浮点容差在判断外接圆包含关系时设定,1e-9适合坐标在10量级的数据,如果你处理的是大地测量坐标(经纬度或者UTM坐标),数值在1e5到1e6的量级,这个容差要放大到1e-4甚至更大,否则会出现大量误判为共圆的三角形。
退化点指的是两个坐标完全相同的点,或者三个共线的点。Bowyer-Watson算法遇到这类输入会直接崩溃,因为共线点会生成零面积三角形,外接圆计算中的d值为零。处理方式有两种:预处理阶段去重(用np.unique按行去重),或者在算法中跳过零面积三角形。我建议直接去重,因为共线点在Delaunay剖分里本就没有有效信息,保留它们只会浪费计算。
# 简易去重:对坐标取精度后去重 from collections import defaultdict def dedup_points(points, precision=1e-6): seen = {} result = [] for p in points: key = (round(p[0] / precision), round(p[1] / precision)) if key not in seen: seen[key] = True result.append(p) return np.array(result)precision参数的选择要依据你的数据量级。如果点坐标是0到1之间的标准化数据,1e-6是合理的;如果是像素坐标(几百到几千),1e-3足够。取太大,部分在拓扑上其实是不同位置的点会被合并;取太小,去重起不到作用。
4. 从2D到3D:把Delaunay三角网改造成mesh建模的实用路线
4.1 受限Delaunay:给算法加边界约束
自由Delaunay生成的是点集的凸包三角剖分,凹多边形区域的网格会跨过空白区,这不是我们想要的。mesh建模中边界约束是刚需:地形建模必须保留山脊线,有限元建模必须贴合边界轮廓。受限Delaunay三角剖分(CDT)就是为此存在的,它保证某些指定的边必须出现在最终的三角形集合中。
实现受限Delaunay的主流思路是先做标准Delaunay剖分,再把缺失的约束边通过边翻转插入到网格中。这个过程中最麻烦的是约束边与已有三角形边的交叉处理,需要不断翻边直到约束边成为网格的一部分。工程实现上可以使用开源库接口,但如果你要维护自己的代码做深度定制,至少要理解"约束边交叉检测 + 边翻转修复"这两步。
def enforce_constraint(triangles, constraint_edge, points): """将缺失的约束边插入三角网,返回新三角形列表""" v_start, v_end = constraint_edge # 检查约束边是否已存在 for tri in triangles: if v_start in tri and v_end in tri: return triangles # 已存在,直接返回 # 找出与约束边相交的所有三角形 intersecting = [] for tri in triangles: edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] for e in edges: if segments_intersect(points[v_start], points[v_end], points[e[0]], points[e[1]]): intersecting.append(tri) break # 构造多边形空洞,重新三角化 boundary_vertices = extract_boundary(intersecting, v_start, v_end) new_tris = triangulate_polygon(boundary_vertices, points) return [t for t in triangles if t not in intersecting] + new_tris这个函数有两个前置假设:segments_intersect是判断两线段是否相交的几何函数,triangulate_polygon是对多边形做简单三角剖分的函数。真正实现时这两部分各有边界条件,比如共线交点的重复计数、多边形退化等。CDT的处理方式在不同源码包里差异很大,如果你用的是现成接口,建议在喂入前先验证约束边数量是否正确,排错比写库容易得多。
4.2 从四面体到表面网格:alpha shape做表面提取
三维Delaunay四面体化的输出是实心填充网格,要变成mesh建模可用的表面三角形网格,最常用的手段是alpha shape。它的思路是:对每个四面体计算其外接球半径,设定一个阈值参数alpha,删除外接球半径大于alpha的四面体(这些是跨越空洞或缝隙的),剩余四面体的边界表面就是重建结果。alpha值越大,保留的四面体越多,表面越接近凸包;alpha值越小,表面越精细,但可能出现孔洞。
def alpha_shape_surface(tetrahedra, vertices, alpha): """根据alpha值提取表面三角形""" from scipy.spatial import Delaunay # 用科学计算库做3D Delaunay(避免自己实现四面体剖分) tri = Delaunay(vertices) # 计算每个四面体的外接球半径 tet_radii = [] for tet in tri.simplices: tet_vertices = vertices[tet] circumradius = compute_tet_circumradius(tet_vertices) tet_radii.append(circumradius) # 保留外接球半径小于alpha的四面体 keep_tet = [i for i, r in enumerate(tet_radii) if r < alpha] keep_vertices = tri.simplices[keep_tet] # 提取边界三角面:仅被一个保留四面体使用的面 face_count = {} for tet in keep_vertices: faces = [(tet[0], tet[1], tet[2]), (tet[0], tet[1], tet[3]), (tet[0], tet[2], tet[3]), (tet[1], tet[2], tet[3])] for f in faces: sorted_f = tuple(sorted(f)) face_count[sorted_f] = face_count.get(sorted_f, 0) + 1 boundary_faces = [f for f, cnt in face_count.items() if cnt == 1] return boundary_facesalpha参数没有万能值,它直接依赖点云密度和物体尺度。一个可用的经验做法是:统计所有四面体外接球半径的分位数,先取30%分位作为初始alpha,观察提取的表面是否有大面积孔洞,有则调大,出现凸包过拟合则调小。这个分位数经验在多种数据上都比较稳定,适合作为调参起点。
4.3 网格后处理:从原始三角网到可用的mesh模型
如果生成的mesh直接用于仿真或渲染,至少要过三道关卡:法线统一、非流形边修复、孔洞修补。法线统一是最容易被忽略的,因为Delaunay生成的三角形法线方向是随机的,有的朝外有的朝里,渲染时会出现黑面闪烁。统一的思路是从任意一个三角形出发做广度优先遍历,保证相邻三角形的法线与共享边的方向保持一致。
def unify_normals(vertices, triangles): """统一所有三角形法线朝向""" # 预处理:每个三角形三个顶点的排序决定法线方向 n = len(triangles) visited = [False] * n stack = [0] visited[0] = True # 邻接关系:每条边对应的三角形列表 edge_to_tri = defaultdict(list) for i, tri in enumerate(triangles): for e in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]: edge_to_tri[tuple(sorted(e))].append(i) while stack: cur = stack.pop() cur_tris = triangles[cur] for e in [(cur_tris[0], cur_tris[1]), (cur_tris[1], cur_tris[2]), (cur_tris[2], cur_tris[0])]: for nbr in edge_to_tri.get(tuple(sorted(e)), []): if visited[nbr]: continue # 检查共享边上两个三角形的顶点顺序是否相反 # 相反则说明法线一致,否则翻转 nbr_tris = triangles[nbr] idx1 = nbr_tris.index(e[0]) idx2 = nbr_tris.index(e[1]) if (idx2 - idx1) % 3 != 1: triangles[nbr] = (nbr_tris[0], nbr_tris[2], nbr_tris[1]) visited[nbr] = True stack.append(nbr) return triangles这段逻辑的核心在于判断相邻三角形的共享边方向。如果两个三角形在共享边上的顶点顺序相同,说明法线方向相反,需要翻转其中一个。这里遍历时用的栈是无向的,不用考虑访问顺序,但要保证每条边都被检查一次。边界处如果有悬挂边,edge_to_tri中对应边只会出现一次,遍历时不会造成问题。
5. 避坑指南:Delaunay建模中绕不开的5个经典问题
5.1 数值精度翻车:共圆点的无限循环
现象:点集中存在四个或多个点近似共圆,算法陷入无限循环或输出的三角形集合在不同运行间不一致。
原因:浮点数无法精确表示共圆关系,外接圆判断在边界处抖动,导致"包含"关系不稳定。Bowyer-Watson算法中,一个点恰好落在两个三角形外接圆的公共边界上时,两个三角形都可能被判为坏三角形,边界提取时出现自相矛盾的结果。
解决:在get_circumcircle和包含判断中引入容差,同时对外接圆半径判断使用严格小于而不是小于等于。容差的具体值建议取点坐标最大值的1e-9倍,例如坐标在1000量级时,容差取1e-6。这个量级关系在多种数据规模下都验证过。
5.2 边界保持失败:凸包多出来的伪三角形
现象:输入的凹多边形点集,输出网格却把凹陷区域填满了三角形。
原因:Delaunay算法天然生成凸包剖分,它不知道哪些区域是凹的、哪些是空洞,这需要额外的约束信息。
解决:要么用受限Delaunay并指定所有边界边,要么在剖分后用点在多边形内判断来删除外部三角形。二次过滤比直接约束实现起来更快,但生成的网格在边界处的质量不如原生CDT。如果模型对边界质量要求高,建议直接用CDT路线。
5.3 密度差异导致的网格爆炸
现象:点云在某处密集,某处稀疏,生成的网格三角形数量远超预期,小三角形堆积成团。
原因:Delaunay剖分严格尊重输入点的拓扑关系,密集区域的每个点都会参与三角化,局部三角形数量由点密度决定,不会自动合并。
解决:剖分前先做点云简化。常用方式是体素降采样,按空间格子保留每格一个点,格子边长按目标密度反推。降采样后再做Delaunay,三角形数量可减少80%以上。
5.4 三维场景中的薄四面体
现象:三维Delaunay生成的四面体中,存在体积几乎为零的薄四面体,仿真计算时出现负体积报错。
原因:四个点近似共面时,外接球半径远大于四面体的实际尺度,这种单元在数值计算中极其不稳定。
解决:筛选四面体时额外检查体积与表面积比值,比值低于阈值就删除。通常阈值取0.01以下,如果删除过多导致表面出现孔洞,再适当放宽。这个处理在alpha shape表面提取之前做,效果最好。
5.5 法线混乱导致的渲染黑面
现象:导出的mesh模型在渲染器中一半三角形显示正常,一半发黑。
原因:Delaunay生成的三角形顶点顺序没有统一朝向,法线方向随机分布,渲染器把背向法线当作背面剔除。
解决:用前面unify_normals函数统一法线。注意统一朝向必须基于三角形的拓扑连通性,不能基于顶点坐标大小排序,因为模型可能是凹的,坐标排序法会把朝向彻底搞乱。
6. 验证与进阶:让生成的mesh达到可发布质量的两个习惯
Delaunay三角化做完不等于mesh建模结束,我每次拿到新生成网格都要坚持跑两个验证:一个是统计三角形面积分布,看是否存在面积小于中位数十分之一的极端小三角形;另一个是检查每条边被多少个三角形共享,正常流形网格中非边界边应该恰好被两个三角形共享,边界边恰好被一个共享。这两个检查能在建模阶段就暴露拓扑问题,比等到渲染或仿真阶段再去查快得多。
def check_mesh_quality(vertices, triangles): """返回网格质量报告:面积分布与边拓扑统计""" areas = [] for tri in triangles: v0, v1, v2 = vertices[list(tri)] areas.append(0.5 * np.linalg.norm(np.cross(v1 - v0, v2 - v0))) area_arr = np.array(areas) edge_usage = defaultdict(int) for tri in triangles: for e in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]: edge_usage[tuple(sorted(e))] += 1 non_manifold = [e for e, c in edge_usage.items() if c > 2] boundary_edges = [e for e, c in edge_usage.items() if c == 1] report = { 'min_area': np.min(area_arr), 'median_area': np.median(area_arr), 'bad_edge_count': len(non_manifold), 'boundary_edge_count': len(boundary_edges), 'total_triangles': len(triangles) } return report进阶使用层面,一个值得投入的方向是把Delaunay和自适应加密结合起来。具体做法是:先用粗点集生成Delaunay网格,计算每个三角形的误差指标(比如面积、曲率估计),对误差超过阈值的三角形插入新点,重新剖分。这个过程循环迭代,能让网格在曲率大的区域自动加密,在平坦区域保持稀疏。相比直接高密度采样,这个方案的三角形数量减少30%到50%,精度还能维持。实现上只需要在每次迭代后对局部受影响区域重新做Delaunay,不用全局重算,效率也可以接受。
我最初做这个方向时吃过一个亏:拿到点云直接跑Delaunay,输出网格后就提交给下游做仿真,结果对方反馈网格里有一堆零面积三角形导致求解器崩溃。排查了一个下午才发现是点集里混入了重复坐标。从那以后我养成了习惯,任何点集进来第一件事就是去重和可视化检查,算法本身的坑容易查,脏数据造成的坑才最耗时间。去做网格建模的人,十有八九都会经历这个阶段,希望这篇笔记能帮你把这段路走直一点。
本文还有配套的精品资源,点击获取