ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenMesh三角网格模型补洞算法解析与实现

OpenMesh三角网格模型补洞算法解析与实现

简介:基于OpenMesh的三角网格补洞算法源代码,面向三维建模、计算机图形学与逆向工程领域的开发人员。该算法针对三维模型表面缺失区域,覆盖孔洞检测、边界边分析、邻接关系处理、填充策略选择(如最近点插值、曲面法线插值)、新面生成、拓扑更新及后处理优化等完整流程,可用于破损模型修复、扫描数据补全、游戏与影视模型清理等场景。资源共66个文件,包含C++源文件与头文件、可执行程序、OpenGL演示示例、PLY格式模型样本、Qt界面文件及Visual Studio工程配置等,压缩包大小33.62MB,代码结构清晰,便于直接编译运行与二次开发。包内还配有相关课件PPT,以及OpenGL带洞模型查看器,帮助理解补洞原理与实现细节。已有2768人学习下载,适合需要掌握网格修复算法或进行三维模型后处理的工程师与研究者参考使用。

1. 补洞不只是把洞填上:基于OpenMesh三角网格模型做补洞前要认清的三件事

拿到一个带洞的三维扫描模型,第一反应往往是把洞直接三角化。但真正做过一次基于OpenMesh三角网格模型的补洞算法源代码实现后,你会发现难点不在“填”,而在“填得自然”:新面片是否和周围曲率一致,边界顶点要不要动,洞内部要不要加辅助顶点,这些决策直接决定补丁是视觉无痕还是一眼假。补洞算法在OpenMesh上跑起来不算难,难的是你对自己模型的洞有准确认知。这篇文章从边界提取、孔洞排序、补洞选型到完整代码落地,把一套可复现的补洞流程讲透。适合正在做三维重建、模型修复、逆向工程的开发者,也适合不想直接调库、想自己掌握补洞原理的人。

2. 边界提取与孔洞识别:OpenMesh里把“洞”找干净的完整步骤

2.1 半边结构与边界边判定:先搞懂OpenMesh怎么表示“洞”

OpenMesh采用半边数据结构,每条无向边被拆成两条方向相反的半边。正常情况下,每条半边都有一个对偶半边(opposite halfedge)。如果一个半边的对偶半边不存在,或者它所在的面为空,那么这条边就是边界边。所有边界边首尾相连,就围成了孔洞。

在补洞之前,你得先把这些边界边找出来。OpenMesh提供了现成的查询接口:

bool is_boundary = mesh.is_boundary(edge_handle); // 按边查 bool is_boundary_he = mesh.is_boundary(he); // 按半边查

但实际算法中更建议遍历所有半边来做判定,因为一个网格可能同时存在多个独立孔洞,你需要把所有边界半边收集起来,再按照连通性分组。代码上可以这样收集:

std::set<OpenMesh::HalfedgeHandle> boundary_he_set; for (const auto& he : mesh.halfedges()) { if (mesh.is_boundary(he)) { boundary_he_set.insert(he); } }

这里用std::set而不是std::vector,原因是后续要不断从集合中取出和删除元素,set的查找和删除都是对数复杂度,模型面片几十万时也不至于拖慢整体流程。另外要注意,OpenMesh 中is_boundary判定的是“该半边没有所属面”,而不是“该边只被一个面使用”。对于标准流形网格,这俩是等价的;但如果网格本身存在非流形边,仅靠这个判定会漏掉一些特殊情况,这一点第5章会专门讲。

2.2 从边界边到闭合孔洞:生长算法与环排序

收集到所有边界半边后,下一步是把它们组织成首尾相连的环。一个简单模型可能有多个洞,你必须分别输出每个洞的边界顶点序列,因为后续三角化算法依赖这个顶点顺序。

常见做法是:从一个边界半边出发,沿着边界环走到回到起点。这里的“走”不能简单地取next_halfedge,因为只有半边所属的面存在时,next_halfedge才表示绕面的下一个边。边界半边没有面,你需要先取它的对偶半边,再通过对偶半边进入邻近的内部面,然后沿着面走到下一条边,再判断这条边是否也是边界。

这里我用一个更直观的基于顶点的方法来遍历边界环:从当前顶点出发,找到从该顶点引出的一条边界半边,走到它的终点,该终点就是环上的下一个顶点。代码实现如下:

std::vector<std::vector<OpenMesh::VertexHandle>> find_all_holes(MyMesh& mesh) { std::set<OpenMesh::HalfedgeHandle> boundary_set; for (const auto& he : mesh.halfedges()) { if (mesh.is_boundary(he)) { boundary_set.insert(he); } } std::vector<std::vector<OpenMesh::VertexHandle>> loops; while (!boundary_set.empty()) { OpenMesh::HalfedgeHandle start = *boundary_set.begin(); OpenMesh::VertexHandle start_v = mesh.to_vertex_handle(start); OpenMesh::VertexHandle v = start_v; std::vector<OpenMesh::VertexHandle> loop; do { // 从当前顶点引出的任意半边 OpenMesh::HalfedgeHandle he = mesh.halfedge_handle(v); if (!mesh.is_boundary(he)) { he = mesh.opposite_halfedge(he); } // 找到从 v 出发的边界半边,并走到下一个顶点 v = mesh.to_vertex_handle(he); boundary_set.erase(he); loop.push_back(v); } while (v != start_v); loops.push_back(loop); } return loops; }

这段代码有几个关键点需要解释。halfedge_handle(v)返回以 v 为出射顶点的任意一条半边,如果它恰好不是边界半边,就取它的对偶半边,这样一定能得到以 v 为起点的一条边界半边。to_vertex_handle(he)得到的是这条边界半边的终点,也就是边界环上的下一个顶点。每次把用过的边界半边从集合中删除,下一个洞就不会再遍历到它。

参数说明:这个函数假设网格是流形网格,每个边界顶点恰好只属于一个边界环。如果你的模型存在两个洞在同一个顶点相接的情况,这段代码会漏掉其中一条边界。工程上我一般会先用OpenMesh的delete_isolated_vertices或者简单的重复顶点清理做一遍预处理,再跑环提取。

2.3 识别孔洞的C++代码:接口设计与输出结构

把上面的逻辑封装成方便调用的接口,我一般会定义这样的结构体:

struct HoleInfo { std::vector<OpenMesh::VertexHandle> boundary_vertices; std::vector<OpenMesh::HalfedgeHandle> boundary_halfedges; OpenMesh::Vec3f center; float area; }; std::vector<HoleInfo> extract_holes(MyMesh& mesh) { // 内部实现见上一节 }

不仅要返回顶点序列,还要返回边界半边序列。因为补洞时需要知道哪些边界边已经存在,防止新面片插入时破坏原有拓扑。中心点坐标和面积用于后续算法选型:如果洞面积占整个模型的比例很小,可以用简单方案;如果洞很大,则必须用更柔和的平滑策略。

这里有一个容易被忽略的细节:OpenMesh 的add_face在添加三角形时,会检查该三角形的边是否已经存在,并尝试把新的半边与已有半边配对。如果你不把边界半边信息记录下来,后面三角化时很可能会因为重复使用已有边而产生非流形情况。所以提取环节要把boundary_halfedges一并保存,后续插入新面片前先判断边的状态。

3. 最小曲面还是平面补丁:补洞算法选型与参数权衡

3.1 三种主流补洞思路:平面三角化、最小曲面迭代、泊松隐式

补洞算法从实现复杂度上可以分成三个梯队。

第一梯队是平面三角化。把孔洞边界顶点按顺序投影到某个平面上,然后用耳切法或Delaunay三角化直接补三角形。优点是实现简单、速度快,几行代码就能出效果;缺点是完全没有考虑周围网格的曲率,补出来的洞是一个平板,和网格相接处有明显折痕。

第二梯队是最小曲面迭代。先做平面三角化,然后把新增的内部顶点作为未知量,用Laplacian平滑迭代更新它们的位置。简单理解:每个内部顶点在每次迭代中移动到其相邻顶点的平均值位置。多次迭代后,补丁会形成一个近似极小曲面,边界连续性比平板好很多。这个方案不需要求解大型稀疏线性系统,代码量适中,是工程中最实用的方案。

第三梯队是隐式曲面重建。用泊松方程或径向基函数在整个网格上重建隐式曲面,再抽取等值面得到补丁。这种方法对复杂孔洞效果自然,但实现成本高,牵涉到八叉树、稀疏矩阵求解,做不好还会引入全局形变。除非你的孔洞特别大、形状特别怪,否则我不建议在源代码层面硬刚泊松。

下表是三种方案的对比:

方案实现成本补片质量对网格曲率敏感度适用场景
平面三角化低较差不敏感小洞、平面区域
最小曲面迭代中中等偏上弱敏感扫描模型常见孔洞
隐式曲面重建高好中大洞、复杂拓扑

3.2 为什么我优先选最小曲面:边界平滑性与法向连续性

实际处理三维扫描网格时,洞往往出现在曲率变化较大的区域,比如面部、机械零件的转角处。平面补丁在这里会立刻暴露问题:补丁平面和周围曲面形成明显夹角,法向过界时出现高光断裂。

最小曲面迭代的好处是,它不会强行补出一个平面,而是让补丁内部的顶点在边界顶点约束下自然“张紧”。边界顶点来自原网格,它们的位置和法向携带了周围曲率信息;内部顶点通过多次Laplacian迭代,会逐步逼近一个面积最小且曲率变化平缓的曲面。虽然它不保证C1连续,但对大多数视觉修复和后续网格平滑来说已经足够。

另外,最小曲面迭代的另一个优势是可控性强。迭代次数和松弛因子是两个直观参数,你可以根据洞的大小和边界质量调整。不像隐式曲面法,参数不收敛时用户完全不知道从哪里下手。

当然,最小曲面迭代也有软肋:如果边界顶点本身有噪声,补丁会被噪声带着走。所以我一般在迭代前对边界顶点做一次轻量平滑,或者把边界顶点权重加大,让它尽量保持原始位置。

3.3 关键参数:迭代次数、松弛因子、顶点权重

既然选定最小曲面路线,参数就要精细控制。下面是我常用的默认参数:

参数默认值范围作用
迭代次数205 ~ 50决定补丁收敛程度
松弛因子0.50.1 ~ 0.8控制每步更新的步长
边界权重10.01.0 ~ 100.0边界顶点对拉普拉斯方程的贡献权重

迭代次数不是越多越好。迭代次数过多,补丁会过度收缩,尤其是边界曲率较大的洞,补丁可能出现向内塌陷。松弛因子过大容易导致顶点位置震荡,过小则收敛太慢。工程上我一般先跑10次看效果,如果补丁表面仍有明显凹凸,再逐步增加迭代次数。

权重设置的细节是:在Laplacian更新时,不是所有邻居都一视同仁。对于内部顶点,所有邻居都是内部顶点或边界顶点;对于紧邻边界的内部顶点,边界顶点的影响应该更大,否则补丁会在边界附近“漂浮”。所以我在实现时会给边界邻居额外乘一个权重系数。

4. 基于OpenMesh的补洞算法源代码:从三角化到平滑的完整实现

4.1 孔洞三角化的生成:耳切法与约束边处理

有了边界顶点序列,第一步是把孔洞的多边形区域三角化。耳切法(Ear Clipping)是最直接的方法:在边界多边形中反复寻找“耳朵”(一个由相邻三个顶点组成的三角形,且三角形内部不包含其他顶点),切掉这个耳朵,直到剩下最后一个三角形。

实现时要注意耳朵的判断标准,不只是角度小于180度,还要确保耳朵三角形内部没有其他边界顶点。否则切出来的三角形会交叉。下面是一个简化的耳切法实现:

bool is_ear(const MyMesh& mesh, const std::vector<OpenMesh::VertexHandle>& poly, size_t i) { size_t n = poly.size(); size_t prev = (i + n - 1) % n; size_t next = (i + 1) % n; OpenMesh::Vec3f p0 = mesh.point(poly[prev]); OpenMesh::Vec3f p1 = mesh.point(poly[i]); OpenMesh::Vec3f p2 = mesh.point(poly[next]); // 检查角度是否小于180度 if (dot(p1 - p0, p2 - p1) < 0) { return false; } // 检查三角形内部是否有其他顶点 for (size_t j = 0; j < n; ++j) { if (j == prev || j == i || j == next) continue; OpenMesh::Vec3f q = mesh.point(poly[j]); if (point_in_triangle(q, p0, p1, p2)) { return false; } } return true; } std::vector<std::array<OpenMesh::VertexHandle, 3>> triangulate_hole( MyMesh& mesh, std::vector<OpenMesh::VertexHandle> poly) { std::vector<std::array<OpenMesh::VertexHandle, 3>> triangles; size_t guard = 0; while (poly.size() > 3 && guard < poly.size() * poly.size()) { bool clipped = false; for (size_t i = 0; i < poly.size(); ++i) { if (is_ear(mesh, poly, i)) { size_t prev = (i + poly.size() - 1) % poly.size(); size_t next = (i + 1) % poly.size(); triangles.push_back({poly[prev], poly[i], poly[next]}); poly.erase(poly.begin() + i); clipped = true; break; } } if (!clipped) { // 退化为简单扇面三角化,避免死循环 for (size_t i = 1; i + 1 < poly.size(); ++i) { triangles.push_back({poly[0], poly[i], poly[i + 1]}); } break; } guard++; } if (poly.size() == 3) { triangles.push_back({poly[0], poly[1], poly[2]}); } return triangles; }

is_ear内部用dot判断夹角,用point_in_triangle检查顶点是否在候选三角形内部。这段代码中我用了一个guard计数器,防止由于浮点误差或自交多边形导致耳切死循环。真正做工程时,guard的退出分支会把多边形退化成扇形三角化,虽然质量差一些,但至少不会卡死程序。

耳切法还有一个约束:生成的三角形不能跨越原有网格的边界边。比如一个洞的边界多边形是凹的,耳切法可能会产生一条对角线,落在网格外面。OpenMesh 的add_face会自动检查边是否已有,但如果你传入了错误的顶点顺序,add_face可能抛出异常或生成错误拓扑。所以三角化后的三个顶点顺序必须是从边界环顺序中截取的连续三段,才能保证三角形法向一致。

4.2 新增顶点的Laplacian平滑:代码实现与参数

三角化只提供了拓扑结构,新增顶点的位置默认还是边界多边形的插值,这会让补丁看起来像一个“凹坑”或“凸包”。接下来要用Laplacian平滑把这些新顶点拉成均匀曲面。

平滑的思路是:对每个新增的内部顶点,用它的邻接顶点坐标的加权平均来更新。迭代多次后,补丁表面的高频凹凸会被抹平。代码实现如下:

void laplacian_smooth(MyMesh& mesh, const std::vector<OpenMesh::VertexHandle>& inner_vertices, int iterations = 20, float lambda = 0.5f, float boundary_weight = 10.0f) { std::unordered_set<OpenMesh::VertexHandle> inner_set(inner_vertices.begin(), inner_vertices.end()); std::unordered_set<OpenMesh::VertexHandle> boundary_set; for (auto vh : inner_vertices) { for (auto nv : mesh.vv_range(vh)) { if (inner_set.find(nv) == inner_set.end()) { boundary_set.insert(nv); } } } for (int iter = 0; iter < iterations; ++iter) { std::map<OpenMesh::VertexHandle, OpenMesh::Vec3f> new_pos; for (auto vh : inner_vertices) { OpenMesh::Vec3f sum(0.0f, 0.0f, 0.0f); float total_weight = 0.0f; for (auto nv : mesh.vv_range(vh)) { float w = 1.0f; if (boundary_set.find(nv) != boundary_set.end()) { w = boundary_weight; } sum += mesh.point(nv) * w; total_weight += w; } if (total_weight > 0.0f) { OpenMesh::Vec3f avg = sum / total_weight; new_pos[vh] = mesh.point(vh) * (1.0f - lambda) + avg * lambda; } } for (const auto& [vh, pos] : new_pos) { mesh.set_point(vh, pos); } } }

这个实现里有一个容易忽略的点:new_pos在每轮迭代中统一更新,而不是边遍历边更新。如果你在遍历过程中直接修改顶点坐标,后面的顶点会使用已经更新过的邻居位置,导致平滑方向不一致,产生抖动。工程上这叫“Jacobi迭代”和“Gauss-Seidel迭代”的区别,补洞场景下Jacobi更稳定。

参数说明:lambda是松弛因子,0.5 表示每次把顶点往邻居平均位置移动一半。boundary_weight用于加大边界顶点对紧邻内部顶点的影响。你可以根据洞的大小调整:边界越复杂,权重越大。迭代次数建议 10 到 30,再多会有收缩风险。

4.3 把补丁合并回原网格:halfedge更新与法向重算

生成三角形并平滑完顶点后,最后一步是真正把这些三角形添加到OpenMesh中,并重新计算法向。OpenMesh的add_face会自动处理半边配对,但如果你在一个已经存在的面上重复添加边,它不会报错,而是产生新的连接关系,这往往会让网格出现非流形边。

推荐的合并流程如下:

void fill_hole(MyMesh& mesh, const std::vector<OpenMesh::VertexHandle>& boundary_vertices) { // 1. 三角化 auto triangles = triangulate_hole(mesh, boundary_vertices); // 2. 添加新顶点(如果边界顶点不够密,可以先在重心处插入顶点) std::vector<OpenMesh::VertexHandle> inner_vertices; for (const auto& tri : triangles) { std::vector<OpenMesh::VertexHandle> face_vhandles = {tri[0], tri[1], tri[2]}; // 检查三个顶点是否构成合法面 bool valid = true; for (int i = 0; i < 3; ++i) { OpenMesh::HalfedgeHandle he = mesh.find_halfedge(face_vhandles[i], face_vhandles[(i+1)%3]); if (he.is_valid() && mesh.is_boundary(he)) { // 边界边可以复用 } } mesh.add_face(face_vhandles); } // 3. 收集真正新增的内部顶点(不在原网格顶点集合中) // 这里通过顶点是否被任何面引用判断 // 4. 平滑 // laplacian_smooth(mesh, inner_vertices, 20, 0.5f, 10.0f); // 5. 更新法向 mesh.update_normals(); }

这段代码中find_halfedge用来判断顶点对之间是否已有边。如果已有边且是边界边,那么add_face会正确配对;如果已有边但不是边界边,说明补丁可能与周围面重叠,需跳过该三角形。实际项目中我会在add_face前用mesh.is_boundary(he)做前置校验,避免OpenMesh内部断言崩溃。

法向重算是必做步骤。OpenMesh的update_normals()会重新计算所有面和顶点的法向,但它是基于当前面片的绕序。如果你的三角形绕序和周围网格不一致,法向会朝向反面。所以三角化时,你的tri[0], tri[1], tri[2]顺序必须跟随边界环方向,这一点在耳切法里通过取poly[prev], poly[i], poly[next]来保证——prev到i再到next的绕向,和边界环的绕向是一致的。

5. 避坑指南:补洞翻车现场与排查方法

5.1 边界环方向不一致导致补丁扭曲

现象:补丁三角形画出来后,法向朝里或朝外混乱,平滑后表面出现“麻花扭转”。

原因:OpenMesh中对边界半边的方向没有统一约定,不同来源的网格可能内部三角面绕序不同。你的折痕边界环提取代码在有的模型上按逆时针走,有的模型上按顺时针走。如果后续耳切法强行按照一种方向切,就会在拐角处生成扭曲三角形。

解决:在提取边界环后,统一校验环的方向。简单做法是计算环的面积有向投影,例如在XY平面上的投影面积。如果面积为负,反转顶点序列。代码片段:

float signed_area = 0.0f; for (size_t i = 0; i < loop.size(); ++i) { auto p1 = mesh.point(loop[i]); auto p2 = mesh.point(loop[(i+1)%loop.size()]); signed_area += p1[0]*p2[1] - p2[0]*p1[1]; } if (signed_area < 0) { std::reverse(loop.begin(), loop.end()); }

这是补洞最值得提前做的一步。方向统一后,三角化和法向问题至少减少一半。

5.2 洞太大时三角面片退化与翻转

现象:洞的边界顶点数量很多但分布不均匀,耳切法生成了大量细长三角形,平滑后表面有褶皱。

原因:边界顶点在模型上原本是采样点,密度可能相差很大。耳切法只保证不重叠,不保证形状质量。一旦出现非常尖锐的三角形,Laplacian平滑会把周围点拉偏,导致补丁翻转。

解决:对大洞采用“先粗分再细分”的策略。先只取边界环的一个子集做粗三角化,比如每隔一个顶点取一个,形成一个大洞;然后对生成的内部三角形再在重心处插入新顶点。这样三角形形状会均匀很多。另一种做法是三角化后计算每个三角形的纵横比(最长边除以最短边对应的高),把超过阈值的三角形边中点插入顶点,重新局部三角化。阈值通常设在 3.0 到 5.0。

5.3 法向计算错误造成补丁黑面

现象:补丁区域在渲染时一片黑,光照不连续,看得到明显的颜色断层。

原因:add_face时顶点绕序和周围面不一致,导致补丁法向反了;或者调用了update_normals之前没有先调用request_face_normals()。在OpenMesh中,法向计算需要预先申请属性空间,否则计算结果无效。

解决:在创建Mesh后至少调用一次:

mesh.request_face_normals(); mesh.request_vertex_normals();

补完洞后调用mesh.update_normals()。如果绕序反了,可以遍历补丁面,检查法向与相邻面法向的夹角,若夹角超过90度就反转该面的绕序。工程上也可以直接用OpenMesh的recalc_face_normals,但它不会自动判断方向。

5.4 非流形边界的处理策略

现象:遍历边界环时死循环,或者同一个边界顶点出现在两个不同的洞里,补洞后网格出现“两个洞共享一条边”的怪异结构。

原因:原始网格存在非流形边,即一条边被三个或更多面共享。这样的边在OpenMesh中既不是严格的边界边,也不是内部边,is_boundary的判定会不稳定。

解决:补洞前先用mesh.clean()或自己写代码删除非流形边。一个常用策略是统计每条边关联的面片数,如果大于2,就把这条边拆掉,只保留其中两个面,其余面断开。注意这种操作会改变网格拓扑,需要在数据备份后处理。另外,不要用is_boundary作为唯一判断条件,可以加上mesh.valence(vh)统计顶点邻接边数,过度复杂的顶点要先处理。

6. 验证补洞效果的三个量化指标与实现技巧

补洞做完不能只看一眼觉得“差不多”,尤其是要交给下游流程时,必须有量化指标把关。我平时会检查三个东西:边界法向偏差、补丁曲率连续性、几何面积合理度。

第一个指标是边界法向偏差。取补丁边界上的每个顶点,计算它在原网格中的相邻面法向,再计算补丁内第一圈三角形的面法向。两者夹角越大,说明补丁和周围网格的连续性越差,视觉上就越明显。通常夹角均值超过25度就该返工。

第二个指标是Laplacian残差。对补丁内部顶点计算point - avg(neighbors)的模长,然后统计均值和方差。残差越小,补丁表面越平滑。如果残差分布出现局部尖峰,说明那个位置有异常顶点,需要局部平滑。

第三个指标是补丁面积和原始洞开口面积的比例。如果补丁面积远大于边界包围区域面积,说明补丁向外膨胀了;如果远小于,说明向内收缩了。对真实扫描模型,这个比值一般在0.8到1.2之间。

计算这些指标的代码可以这样写:

float calc_boundary_normal_error(MyMesh& mesh, const std::vector<OpenMesh::VertexHandle>& boundary_vertices) { float error_sum = 0.0f; int count = 0; for (auto vh : boundary_vertices) { OpenMesh::Vec3f orig_normal = mesh.normal(vh); for (auto he : mesh.voh_range(vh)) { if (!mesh.is_boundary(he)) { auto fh = mesh.face_handle(he); error_sum += acos(clamp(dot(orig_normal, mesh.normal(fh)), -1.0f, 1.0f)); count++; } } } return count > 0 ? error_sum / count : 0.0f; }

这段代码先取边界顶点的原网格法向(已经通过update_normals计算过),再遍历该顶点出发的所有非边界半边,取这些半边所属面的法向,计算两者夹角。夹角用acos(dot(...))得到弧度,如果模型有大量边界顶点,可以转成角度来看。

我自己的习惯是用这三项指标做一个打分:边界角度均值小于15度,曲率残差方差小于0.005,面积比在0.9到1.1之间,才敢把模型交给下游。这三个阈值是我在多次模型修复里试出来的经验值,不同场景可以放宽或收紧,但你至少要有一套量化标准,不然补洞优化无从谈起。

最后给一个实战技巧:在平滑补丁顶点时,不要覆盖原始边界顶点的位置坐标。很多初学OpenMesh的人会把整个网格的顶点都放进Laplacian更新列表里,结果补丁边界也跟着漂移,原本干净的边缘变得毛糙。我在代码里总会把边界顶点标记为fixed,平滑循环里跳过它们。这个小习惯让我少踩很多坑。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进