1. 从两个方向理解几何处理:细分与简化
几何处理里有一对方向完全相反、但经常被放在一起讨论的操作:曲面细分和曲面简化。前者是把一个粗糙的低模变成光滑的高模,后者是把一个高模压成轻量的低模。它们解决的是同一个核心矛盾——模型精度和计算开销之间的平衡。
我做三维相关项目这些年,踩过最多的坑基本都集中在这两个环节。细分做过头,面数爆炸,渲染直接卡死;简化做太狠,模型棱角全丢,法线还乱掉。所以这篇就把这两个操作从原理到实操完整拆一遍,包括算法选型、参数计算、代码实现和排查经验。不管你是刚接触图形学课程的学生,还是需要在实际项目里处理网格的开发者,应该都能找到能直接用的东西。
先明确一下这两个概念到底在干什么。一个三角网格由顶点、边、面三部分组成,细分就是在现有网格基础上按规则插入新顶点、重新连接拓扑,让表面更细腻;简化则是反过来,合并顶点、塌缩边,用更少的面去逼近原来的形状。两者共享同一套网格数据结构(半边结构、邻接表之类),所以放在一起学效率最高。
2. 曲面细分:原理、算法与实操
2.1 为什么需要细分,它到底解决什么问题
最直接的场景:你从建模软件里导出一个低面数的模型,直接渲染出来是一块一块的平面,光照上去有明显的棱。这时候有两种做法,一种是直接加高模,但高模文件大、传输慢、显存吃紧;另一种就是保留低模,在渲染管线里做细分,运行时动态生成光滑表面。
细分还有第二个用途:让网格拓扑更规整。很多扫描或者程序生成的网格三角形大小不一、形状畸形,直接做后续处理(比如形变、碰撞)会出问题。细分可以把这些不规则区域重新划分,得到更均匀的三角形分布。
第三个用途是配合位移贴图。低模细分之后顶点密度上来了,再叠加位移贴图,就能在不增加美术工作量的前提下做出表面细节。这个在游戏和影视里非常常见。
所以细分不是单纯地"加面",它是一套可控的、有数学保证的网格加密手段。理解这一点,后面的算法选择才有依据。
2.2 细分算法的两大流派:逼近型与插值型
细分算法按新顶点是否保留在原曲面上,分成两大类。
逼近型(Approximating):新生成的极限曲面不经过原始顶点,原始顶点会被"拉"向内部。典型代表是Catmull-Clark算法。它的好处是曲面天然光滑(C2连续,除了奇异点附近),缺点是原始形状会有轻微收缩,如果你要求模型必须精确经过某些控制点,就不合适。
插值型(Interpolating):极限曲面精确经过所有原始顶点。典型代表是Loop算法(针对三角网格)和Butterfly算法。好处是形状保真,缺点是曲面光滑度略差,而且容易出现"鼓包"或者自交。
选哪个,取决于你的需求。做角色模型、需要光滑过渡的,用 Catmull-Clark;做地形、需要精确控制点的,用插值型。我个人的经验是,大部分游戏和影视场景用 Catmull-Clark 就够了,因为视觉上更讨喜,收缩问题可以通过预先补偿解决。
2.3 Catmull-Clark 细分:一步步拆开看
Catmull-Clark 是最经典的细分方案,虽然它最初是为四边形网格设计的,但对任意多边形网格都能处理。它的每一轮细分分三步走。
第一步:生成面点(Face Point)。对每个面,取它所有顶点的平均位置,作为这个面的新顶点。
F = (V1 + V2 + ... + Vn) / n第二步:生成边点(Edge Point)。对每条边,取它两个端点和相邻两个面点的平均。
E = (V1 + V2 + F1 + F2) / 4如果是边界边(只有一个相邻面),则简化为两端点的平均:
E = (V1 + V2) / 2第三步:更新原始顶点(Vertex Point)。对每个原始顶点,根据它的价数 n(连接的边数)计算新位置。
V' = (F_avg + 2 * R_avg + (n - 3) * V) / n其中 F_avg 是相邻面点的平均,R_avg 是相邻边中点的平均。这个公式是 Catmull-Clark 的核心,它保证了极限曲面的光滑性。
一轮细分之后,每个 n 边形面会变成 n 个四边形面。所以如果你从三角形网格开始,第一轮之后全变成四边形,之后每轮面数乘以 4。
注意:Catmull-Clark 对三角形网格也能用,但第一轮之后拓扑会变成混合四边形,如果你后续依赖纯三角网格,需要额外做三角化。
2.4 Loop 细分:三角网格的专用方案
如果你的网格全是三角形,Loop 细分更合适。它由 Charles Loop 在 1987 年提出,同样是逼近型,但专门针对三角网格。
Loop 细分的规则:
边点:对内部边,取两端点和两个对面顶点的加权平均。
E = (3/8) * (V1 + V2) + (1/8) * (V3 + V4)其中 V3、V4 是共享这条边的两个三角形的对面顶点。
顶点更新:对价为 n 的顶点,用下面的权重更新。
V' = (1 - n * beta) * V + beta * sum(neighbors)beta 的取值有个经典公式:
beta = (1/n) * (5/8 - (3/8 + (1/4) * cos(2*pi/n))^2)n=3 时 beta 约等于 0.1875,n=6 时 beta 约等于 0.0625。n=6 是规则顶点,细分后形状保持得最好。
Loop 细分每轮把每个三角形变成 4 个小三角形,面数乘以 4。它的极限曲面在规则区域是 C2 连续,在奇异点附近是 C1。
2.5 实操:用 Python 实现一轮 Catmull-Clark
光看公式容易晕,直接上代码。下面是一个简化版的一轮 Catmull-Clark 实现,用半边结构的思想组织数据,但为了可读性用了字典。
import numpy as np def catmull_clark_once(vertices, faces): """ vertices: (N, 3) 顶点坐标 faces: list of list,每个面是顶点索引列表 返回细分后的 vertices, faces """ vertices = np.array(vertices, dtype=float) n_verts = len(vertices) # 1. 计算面点 face_points = [] for f in faces: face_points.append(vertices[f].mean(axis=0)) face_points = np.array(face_points) # 2. 建立边到相邻面的映射 edge_faces = {} for fi, f in enumerate(faces): n = len(f) for i in range(n): v1, v2 = f[i], f[(i + 1) % n] key = (min(v1, v2), max(v1, v2)) edge_faces.setdefault(key, []).append(fi) # 3. 计算边点 edge_points = {} for (v1, v2), adj_faces in edge_faces.items(): if len(adj_faces) == 2: f1, f2 = adj_faces p = (vertices[v1] + vertices[v2] + face_points[f1] + face_points[f2]) / 4.0 else: p = (vertices[v1] + vertices[v2]) / 2.0 edge_points[(v1, v2)] = p # 4. 更新原始顶点 # 统计每个顶点的相邻面、相邻边中点 vert_faces = {i: [] for i in range(n_verts)} vert_edges = {i: [] for i in range(n_verts)} for fi, f in enumerate(faces): for v in f: vert_faces[v].append(fi) for (v1, v2) in edge_faces: vert_edges[v1].append((v1, v2)) vert_edges[v2].append((v1, v2)) new_orig = np.zeros_like(vertices) for v in range(n_verts): n = len(vert_edges[v]) if n == 0: new_orig[v] = vertices[v] continue F = np.mean([face_points[fi] for fi in vert_faces[v]], axis=0) R = np.mean([(vertices[e[0]] + vertices[e[1]]) / 2.0 for e in vert_edges[v]], axis=0) new_orig[v] = (F + 2 * R + (n - 3) * vertices[v]) / n # 5. 组装新网格 new_vertices = [] # 先放更新后的原始顶点 for v in range(n_verts): new_vertices.append(new_orig[v]) # 再放边点 edge_index = {} for key in edge_points: edge_index[key] = len(new_vertices) new_vertices.append(edge_points[key]) # 最后放面点 face_index = {} for fi in range(len(faces)): face_index[fi] = len(new_vertices) new_vertices.append(face_points[fi]) # 6. 重新连接:每个 n 边形面变成 n 个四边形 new_faces = [] for fi, f in enumerate(faces): n = len(f) for i in range(n): v_cur = f[i] v_next = f[(i + 1) % n] v_prev = f[(i - 1) % n] e_next = edge_index[(min(v_cur, v_next), max(v_cur, v_next))] e_prev = edge_index[(min(v_prev, v_cur), max(v_prev, v_cur))] new_faces.append([v_cur, e_next, face_index[fi], e_prev]) return np.array(new_vertices), new_faces这段代码可以直接跑,输入一个立方体的 8 个顶点和 6 个四边形面,跑一轮就能看到形状开始变圆。跑三轮以上,基本就是一个光滑的球状体了。
实操心得:第一轮细分之后,所有面都变成四边形。如果你需要继续细分,直接递归调用即可。但要注意,面数是指数增长的,立方体 6 个面,一轮变 24 个,两轮 96 个,三轮 384 个,四轮 1536 个。实际项目里通常控制在 2 到 3 轮。
2.6 细分中的边界处理与常见坑
边界边和边界顶点是细分里最容易出问题的地方。上面代码里对边界边用了简单的平均,但边界顶点的更新规则需要单独处理,否则边界会收缩或者扭曲。
标准做法是:边界顶点只根据它相邻的两条边界边来更新,用类似曲线细分(比如 Chaikin 算法)的规则。具体来说,边界顶点的新位置是:
V' = (1/8) * V_prev + (3/4) * V + (1/8) * V_next其中 V_prev 和 V_next 是沿边界方向相邻的两个顶点。这个规则能保证边界曲线光滑且不收缩。
另一个坑是非流形边(被三个以上面共享的边)。Catmull-Clark 的标准公式假设每条边最多两个相邻面,遇到非流形边会算错。处理办法是先把非流形结构拆开,或者对这类边退化成简单平均。
还有一个常见问题是法线更新。细分之后顶点位置变了,法线必须重新计算。如果你用的是顶点法线的平均,记得按面积加权,否则小三角形会主导法线方向,导致光照出现奇怪的斑块。
3. 曲面简化:从高模到低模的取舍
3.1 简化的目标与评价标准
简化不是随便删面,它有一个明确的优化目标:在给定面数预算下,让简化后的网格尽可能接近原始网格。这个"接近"怎么衡量,就是各种算法拉开差距的地方。
常用的误差度量有两种。一种是几何误差,用简化后网格上的点到原始网格的最近距离(Hausdorff 距离)来衡量。另一种是属性误差,包括法线偏差、UV 偏差、颜色偏差。实际项目里,几何误差是基础,但视觉上属性误差往往更明显。
简化的评价标准可以总结成三条:面数降下来了、形状没怎么变、拓扑没坏掉。第三条经常被忽略,但很重要。有些简化算法会把网格搞出孔洞、自交或者翻转的法线,这种模型后续没法用。
3.2 边塌缩:最主流的简化策略
边塌缩(Edge Collapse)是工业界用得最多的简化方法。它的操作很直观:选一条边,把它的两个端点合并成一个新顶点,所有连接到这两个端点的面重新连接到新顶点上。一次塌缩减少一个顶点、两条面(对于三角网格)。
整个简化过程就是不断重复"选边、塌缩"这个动作,直到达到目标面数。关键在于怎么选边。选得不好,模型会变形严重;选得好,面数降一半视觉上都看不出来。
最经典的选边策略是QEM(Quadric Error Metrics,二次误差度量),由 Garland 和 Heckbert 在 1997 年提出。它的核心思想是:对每个顶点维护一个二次误差矩阵 Q,表示这个顶点到它周围所有平面的距离平方和。塌缩一条边时,新顶点的误差是两边 Q 矩阵之和作用在新位置上的结果。选误差最小的边先塌缩。
QEM 的数学形式:
Q(v) = sum over planes (p) of (p^T * v)^2其中 p 是平面方程 (a, b, c, d),满足 a^2 + b^2 + c^2 = 1。把每个平面的贡献写成 4x4 矩阵 Kp:
Kp = [a*a, a*b, a*c, a*d] [a*b, b*b, b*c, b*d] [a*c, b*c, c*c, c*d] [a*d, b*d, c*d, d*d]顶点的 Q 矩阵就是所有相邻平面 Kp 的和。塌缩边 (v1, v2) 时,新顶点的 Q 是 Q1 + Q2,误差是 v^T * (Q1 + Q2) * v。最优的新顶点位置可以通过求解 Q 的梯度为零得到,如果矩阵不可逆,就退而求其次在 v1、v2 和中点里选误差最小的。
3.3 QEM 简化的完整实现
下面是一个可运行的 QEM 简化实现,用 Python 写,依赖 numpy。为了控制篇幅,只保留核心逻辑。
import numpy as np import heapq def compute_quadric(vertices, faces): """为每个顶点计算 Q 矩阵""" n = len(vertices) Q = [np.zeros((4, 4)) for _ in range(n)] for f in faces: v0, v1, v2 = vertices[f[0]], vertices[f[1]], vertices[f[2]] normal = np.cross(v1 - v0, v2 - v0) norm = np.linalg.norm(normal) if norm < 1e-12: continue normal /= norm d = -np.dot(normal, v0) a, b, c = normal Kp = np.array([ [a*a, a*b, a*c, a*d], [a*b, b*b, b*c, b*d], [a*c, b*c, c*c, c*d], [a*d, b*d, c*d, d*d] ]) for vi in f: Q[vi] += Kp return Q def optimal_position(Q): """求解 Q 矩阵对应的最优顶点位置""" A = Q[:3, :3] b = -Q[:3, 3] try: v = np.linalg.solve(A, b) if np.all(np.isfinite(v)): return v except np.linalg.LinAlgError: pass return None def edge_cost(v1, v2, Q1, Q2): """计算塌缩边的误差""" Q = Q1 + Q2 v_opt = optimal_position(Q) if v_opt is not None: vh = np.append(v_opt, 1.0) cost = vh @ Q @ vh return cost, v_opt # 退化情况:在端点和中点里选 candidates = [v1, v2, (v1 + v2) / 2.0] best_cost = float('inf') best_pos = None for c in candidates: ch = np.append(c, 1.0) cost = ch @ Q @ ch if cost < best_cost: best_cost = cost best_pos = c return best_cost, best_pos def simplify_qem(vertices, faces, target_faces): """QEM 简化主流程""" vertices = np.array(vertices, dtype=float) faces = [list(f) for f in faces] Q = compute_quadric(vertices, faces) # 建立顶点到面的映射 vert_faces = {} for fi, f in enumerate(faces): for v in f: vert_faces.setdefault(v, set()).add(fi) # 建立边集合 edges = set() for f in faces: n = len(f) for i in range(n): v1, v2 = f[i], f[(i + 1) % n] edges.add((min(v1, v2), max(v1, v2))) # 优先队列 heap = [] for (v1, v2) in edges: cost, pos = edge_cost(vertices[v1], vertices[v2], Q[v1], Q[v2]) heapq.heappush(heap, (cost, v1, v2, pos)) alive = [True] * len(vertices) vertex_map = list(range(len(vertices))) # 并查集式的映射 current_faces = len(faces) def find(v): while vertex_map[v] != v: vertex_map[v] = vertex_map[vertex_map[v]] v = vertex_map[v] return v while heap and current_faces > target_faces: cost, v1, v2, pos = heapq.heappop(heap) r1, r2 = find(v1), find(v2) if r1 == r2 or not alive[r1] or not alive[r2]: continue # 合并 r2 到 r1 vertex_map[r2] = r1 alive[r2] = False vertices[r1] = pos Q[r1] = Q[r1] + Q[r2] # 更新受影响的面 affected = vert_faces.get(r2, set()) | vert_faces.get(r1, set()) removed = 0 for fi in list(affected): f = faces[fi] new_f = [r1 if find(v) == r2 or find(v) == r1 else find(v) for v in f] # 去重 dedup = [] for v in new_f: if not dedup or dedup[-1] != v: dedup.append(v) if len(dedup) > 1 and dedup[0] == dedup[-1]: dedup.pop() if len(dedup) < 3: removed += 1 faces[fi] = None else: faces[fi] = dedup current_faces -= removed # 重新计算受影响边的代价 for fi in affected: if faces[fi] is None: continue f = faces[fi] n = len(f) for i in range(n): a, b = find(f[i]), find(f[(i + 1) % n]) if a == b: continue key = (min(a, b), max(a, b)) c, p = edge_cost(vertices[a], vertices[b], Q[a], Q[b]) heapq.heappush(heap, (c, a, b, p)) # 整理输出 faces = [f for f in faces if f is not None] used = sorted(set(v for f in faces for v in f)) remap = {old: new for new, old in enumerate(used)} new_vertices = vertices[used] new_faces = [[remap[v] for v in f] for f in faces] return new_vertices, new_faces这段代码跑一个几千面的模型,降到一半面数,通常几秒钟能出结果。实际工程里会用 C++ 重写,加上空间加速结构,处理百万面模型也没问题。
注意:QEM 的优先队列里会有大量过期条目(因为顶点合并后旧边的代价失效了),代码里用 find 和 alive 做了过滤。这是标准做法,不要试图在每次合并后清理整个队列,代价太高。
3.4 简化中的拓扑保护与属性保持
纯几何的 QEM 有个问题:它只看几何误差,不管拓扑。结果就是可能把薄壁结构的两面塌缩到一起,或者把边界塌缩掉,导致模型出现孔洞。
解决办法是加拓扑约束。常见的约束包括:
- 不允许塌缩边界边(或者给边界边加很高的惩罚权重)
- 不允许产生非流形边
- 不允许翻转法线(塌缩后相邻面的法线方向变化超过阈值就拒绝)
- 不允许产生退化三角形(面积小于阈值)
这些约束在代码里就是几个 if 判断,但能极大提升简化结果的质量。我个人的经验是,边界保护是必须的,其他约束看模型类型决定。
属性保持方面,如果模型有 UV 或者顶点颜色,QEM 需要扩展成多维形式。做法是把 UV 坐标也当成几何坐标的一部分,在计算平面时同时考虑位置和 UV。这样塌缩时 UV 的误差也会被最小化。不过要注意,UV 的尺度通常和几何尺度不一样,需要先归一化,否则其中一个会主导误差。
3.5 其他简化方法简述
除了边塌缩,还有几种简化思路值得了解。
顶点聚类(Vertex Clustering):把空间划分成网格,落在同一个格子里的顶点合并成一个。速度极快,但质量一般,容易出现锯齿状的边界。适合做 LOD 的粗层级。
顶点删除(Vertex Decimation):逐个删除顶点,然后对留下的孔洞重新三角化。适合有明确特征保留需求的场景,但重新三角化比较麻烦。
区域合并(Face Merging):把近似共面的相邻面合并成一个大面。适合建筑、机械这类平面多的模型,对有机模型效果不好。
实际项目里,边塌缩 + QEM 是默认选择,其他方法在特定场景下作为补充。
4. 细分与简化的配合使用
4.1 什么时候该细分,什么时候该简化
这两个操作不是对立的,而是同一套工具链里的两个环节。典型的工作流是这样的:
- 美术做一个中等面数的模型
- 需要高精度渲染时,运行时细分 1 到 2 轮
- 需要远距离渲染时,用简化生成 LOD 层级
- 根据摄像机距离动态切换
所以细分和简化经常出现在同一个项目里,只是作用在不同的阶段。
判断标准很简单:如果模型面数低于目标精度,就细分;如果高于目标精度,就简化。但要注意,细分增加的面是"均匀"的,而简化减少的面是"有选择性"的。所以如果你的模型某些区域需要高精度、某些区域不需要,直接简化比先细分再简化更高效。
4.2 细分后简化的陷阱
有人会想:我先细分得到一个光滑模型,再简化它,是不是能得到一个又光滑又轻量的模型?
理论上可以,但实操里有坑。细分后的模型顶点分布很均匀,QEM 简化时误差度量会倾向于均匀地删面,结果就是光滑度下降得很快。而且细分引入的新顶点位置是计算出来的,它们的 Q 矩阵可能不够准确,导致简化时误差估计偏差。
更合理的做法是:在原始模型上直接简化,保留原始顶点的 Q 矩阵信息。如果确实需要光滑效果,可以在简化后的模型上再做细分,或者用置换贴图来补充细节。
4.3 动态 LOD 中的实际参数
做动态 LOD 时,细分和简化的参数需要配合调整。下面是一个实际项目里用过的参数表,供参考。
| 距离范围 | 细分轮数 | 目标面数比例 | 说明 |
|---|---|---|---|
| 0-5 米 | 2 | 100% | 近景,全精度 |
| 5-15 米 | 1 | 60% | 中景,适度简化 |
| 15-30 米 | 0 | 30% | 远景,大幅简化 |
| 30 米以上 | 0 | 10% | 极远,最低精度 |
这个表不是固定的,要根据模型复杂度、屏幕分辨率和性能预算调整。关键原则是:距离越远,简化越狠,细分越少。因为远处模型在屏幕上占的像素少,细节看不出来。
实操心得:切换 LOD 层级时,如果两个层级的顶点位置差异太大,会出现明显的"跳变"。解决办法是在切换时做一个短时间的顶点插值过渡,或者让相邻层级的简化结果在边界处保持一致。这个在角色模型上尤其重要,处理不好角色会"抖"。
5. 常见问题与排查实录
5.1 细分相关问题速查
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 细分后模型收缩明显 | 逼近型算法的固有特性 | 对比细分前后包围盒 | 预先放大原始模型补偿,或改用插值型 |
| 边界出现锯齿或扭曲 | 边界顶点更新规则错误 | 检查边界边是否被当作内部边处理 | 单独实现边界规则 |
| 细分后法线方向错乱 | 法线未重新计算或加权错误 | 可视化法线 | 按面积加权重新计算 |
| 面数爆炸导致卡顿 | 细分轮数过多 | 统计面数增长 | 控制轮数,或用自适应细分 |
| 奇异点附近出现尖刺 | 高价顶点更新公式不稳定 | 检查顶点价数分布 | 限制价数,或对奇异点特殊处理 |
5.2 简化相关问题速查
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 简化后出现孔洞 | 边界边被塌缩 | 检查边界保护逻辑 | 禁止塌缩边界边 |
| 模型出现自交 | 薄壁结构两面塌缩到一起 | 可视化检查 | 加拓扑约束,检测自交 |
| 简化后形状偏差大 | QEM 权重不合理 | 对比 Hausdorff 距离 | 调整 Q 矩阵,加属性权重 |
| 简化速度慢 | 优先队列过期条目太多 | 统计队列大小 | 用惰性删除,定期重建队列 |
| 简化后 UV 错乱 | UV 未参与误差度量 | 检查 UV 是否被更新 | 把 UV 纳入 Q 矩阵计算 |
5.3 几个踩过的坑
坑一:细分后顶点顺序变了,导致动画出错。如果你的模型有骨骼绑定,细分会插入新顶点,这些新顶点需要分配骨骼权重。常见做法是用原始顶点的权重插值,但插值方式要和细分规则一致,否则动画会扭曲。我见过有人直接用最近邻,结果关节处出现明显的撕裂。
坑二:简化时忽略了硬边。模型上的硬边(比如立方体的棱)如果被简化掉,会变成圆角,视觉上完全变样。解决办法是在 QEM 里给硬边加惩罚权重,或者在简化前标记硬边,简化时保护它们。判断硬边的方法是比较相邻面的法线夹角,超过阈值(比如 30 度)就认为是硬边。
坑三:细分和简化的坐标系不一致。如果模型经过了非均匀缩放,细分和简化的误差度量会失真。比如 X 轴缩放 10 倍,Y 轴不变,那么 X 方向的误差会被放大 100 倍。解决办法是在操作前把模型变换到均匀坐标系,操作完再变换回去。
坑四:浮点数精度问题。大规模简化时,顶点位置反复计算,浮点误差会累积。表现是简化到后面,模型出现细小的裂缝或者重叠。解决办法是用双精度浮点,或者在每次塌缩后对顶点位置做一次量化对齐。
6. 工具与库的选择建议
如果你不想从零实现,可以直接用现成的库。下面几个是我用过觉得靠谱的。
OpenMesh:C++ 库,提供了半边数据结构和基本的细分、简化接口。文档一般,但代码质量高,适合嵌入到自己的项目里。
CGAL:功能极其全面,包含各种细分和简化算法,还有拓扑修复工具。缺点是编译慢、学习曲线陡,适合对正确性要求极高的场景。
libigl:C++ 头文件库,接口简洁,和 Eigen 配合得很好。细分和简化都有实现,适合快速原型。
OpenSubdiv:专注细分,支持 Catmull-Clark 和 Loop,有 CPU 和 GPU 实现。如果你只需要细分,这个是最专业的选择。
MeshLab:图形界面工具,内置了 QEM 简化和多种细分算法。适合做实验和快速验证,不适合集成到代码里。
选哪个取决于你的需求。做研究或者需要深度定制,自己实现;做产品需要稳定可靠,用成熟库。我个人的习惯是,核心算法自己实现一遍理解原理,实际项目用成熟库保证效率。
7. 性能优化的一些经验
细分和简化的性能瓶颈不一样。细分主要是内存带宽受限,因为要频繁读写顶点和面数据;简化主要是计算受限,因为要反复计算误差和更新优先队列。
细分的优化方向:用 SoA(Structure of Arrays)布局代替 AoS,提高缓存命中率;用并行计算处理面点和边点的生成,因为它们是独立的;对于 GPU 细分,用曲面细分着色器(Tessellation Shader)直接在渲染管线里做。
简化的优化方向:用空间划分(比如八叉树)加速最近邻查询;用更紧凑的数据结构存储 Q 矩阵(对称矩阵只需要 10 个浮点数);优先队列用配对堆或者斐波那契堆代替二叉堆,降低更新代价。
还有一个通用技巧:分块处理。把大模型切成小块,每块独立细分或简化,最后合并。这样能控制内存峰值,也方便并行。但要注意块边界处的连续性,需要在边界留一圈重叠区域,处理完再裁剪。
8. 一些延伸方向
细分和简化只是几何处理的入口,往下还有很多可以挖的东西。
自适应细分:根据曲率或者屏幕空间误差,只在需要的地方细分。这样能在相同面数下获得更好的视觉效果。实现难点是处理不同细分层级之间的过渡,常用的方案是"红绿三角"剖分。
保特征简化:除了硬边,还有特征线、特征点需要保护。可以用特征检测算法先提取这些结构,然后在简化时给它们加约束。
基于机器学习的简化:用神经网络预测哪些边应该优先塌缩,或者直接生成简化后的网格。这个方向最近几年比较热,但工业界还没大规模应用。
细分曲面的精确求值:不实际生成细分网格,而是直接在极限曲面上采样。这样能避免面数爆炸,适合做高质量渲染。OpenSubdiv 里有实现。
这些方向每一个都够写一篇长文,这里就不展开了。如果你对某个方向感兴趣,可以顺着关键词去查论文,细分领域经典论文不多,翻一遍就能把脉络理清。
我个人在实际操作中的体会是,细分和简化看起来是数学问题,但真正决定成败的往往是工程细节。公式背得再熟,边界处理没做好,结果就是不能用。所以建议大家在学原理的同时,一定要动手跑代码,用真实的模型去试,踩过坑之后理解才会深刻。另外,不要迷信某一种算法,QEM 不是万能的,Catmull-Clark 也不是所有场景都合适,根据你的模型特点和性能预算去选,必要时组合使用,才是务实的做法。