凌晨两点半,我盯着屏幕上那条曲线,它在第三和第四个控制点之间走出一条诡异的弧度,像是被风吹歪的棉线。问题的根源后来发现很简单:我在循环里把控制点数组的引用当成了值传递,迭代过程里修改了原始顶点。这种能让人崩溃一整晚的 bug,在 Games102 的作业里几乎每隔几天就出现一次。
Games102 是 GAMES 系列课程里的《几何建模与处理》,和面向渲染入门的 Games101 不同,这门课把重点放在了几何数据表示、曲线曲面、网格和点云处理上。作业也不是写一个光栅化器或者路径追踪器,而是从零实现各种几何算法。听起来不如渲染炫酷,但实际做完一轮之后,我对“一个数学公式如何变成内存里的操作”这件事的理解,比过去看十篇博客都深。
这篇是“作业上”的经验总结,覆盖我从零开始做完前半程作业的全部过程:环境准备、核心算法实现、结果验证,以及几段完整的踩坑排查记录。适合正在写 Games102 作业的人,也适合想自学几何处理、但不确定从哪开始动手的读者。
1. 这门课的作业为什么值得死磕
1.1 “几何建模与处理”到底在训练什么
Games102 的核心内容不是某个渲染算法,而是几何对象的表示、生成、分析和编辑。简单说,就是解决这么几个问题:三维模型在计算机里长什么样;怎么用数学语言描述一条光滑曲线、一张曲面;怎么把一个高精度模型变轻;怎么把一张纹理贴到三维模型上不扭曲。
这些问题的共同点是:数学公式密集,但课程不会帮你把公式翻译成代码。作业就是逼你完成这个翻译过程。前半程的作业通常会覆盖到:
- 曲线部分:Bézier 曲线、B-spline 或 NURBS 的查值和插值;
- 曲面部分:张量积曲面、隐式曲面的采样和网格化;
- 网格基础:网格数据结构的构建与遍历,可能还会涉及网格简化或细分。
我这一轮作业的第一项,就是实现 Bézier 曲线的 de Casteljau 算法。看起来是几行递归插值,实际上涉及控制点保存方式、细分步数、迭代中间量存储、可视化输出一系列问题。等你把所有环节串起来,才发现这不是“写一个算法”,而是“写一个能被人看到结果的完整工具链”。
1.2 前半程作业的难度梯度不是线性的
很多人以为作业是按每周内容线性递进的,实际上难度在曲线到网格之间会跳变。前一两周你还在处理一维参数曲线,切换去写网格数据结构时,马上要面对半边结构、邻接关系、边界条件这些完全不同的思维方式。
举一个例子。曲线代码里,你的对象是 std::vector<Vector2d>,控制点、采样点都往里面塞,逻辑核心是循环里的线性插值。到了网格作业,数据变成顶点表、面表、半边表,一个顶点的所有邻居要靠指针关系串起来,边界边没有“对面”半边,访问之前必须先判空。很多同学第一个网格遍历程序跑起来后直接崩溃,问题几乎都出在没处理边界。
建议把前半程当作三个独立的小项目来安排时间:曲线一个、曲面一个、网格一个。每个项目内部按“数学推导 -> 数据结构设计 -> 裸实现 -> 可视化验证”的顺序推进。不要试图一次性做完所有作业,几何算法一旦堆积到一定规模,查错成本会指数上升。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手写算法之前,先把环境和数据管线理顺
2.1 语言、数学库与可视化工具的选型
关于语言,课程虽然不强制,但 C++ 依然是主流选择。我见过有同学用 Python + numpy 复现曲线算法,验证数学原理确实快,但到了网格编辑这种大量指针操作的场景,会非常别扭。我的建议是:C++ 写主体,Python 做快速数学验证。
数学库我推荐 Eigen,3.4 版本。它在 CMake 里是 header-only 的,省去了很多链接麻烦,矩阵运算、线性方程求解、几何变换都能直接拿过来用。作业里涉及最小二乘拟合时,Eigen 的 LDLT、HouseholderQR 这些分解器能起到关键作用。如果你用的是官方模板框架,通常已经集成了 Eigen,但要注意版本不能换来换去。
可视化这块,我强烈建议引入 Polyscope。它不是给作业“锦上添花”,而是解决问题的核心工具。Polyscope 可以直接注册 std::vector<Eigen::Vector3d> 作为点云,注册顶点和面索引作为网格,还有内置的曲线绘制接口。相比每次打开 MeshLab 加载离线模型,Polyscope 可以做到“改完参数立刻看结果”,调试效率高一个量级。
环境选型可以参考下表:
| 用途 | 推荐方案 | 说明 |
|---|---|---|
| 编程语言 | C++17 | 兼顾性能和工程性,模板容器好用 |
| 数学库 | Eigen 3.4 | 头文件库,矩阵与求解一条龙 |
| 网格/IO | libigl(仅用 core 部分) | 读写 OBJ、半边遍历可复用 |
| 可视化 | Polyscope | 实时查看点云、网格、曲线 |
| 离线检查 | MeshLab | 最终输出 OBJ 后的外观检查 |
2.2 CMake 和依赖配置里最容易翻车的几个点
环境配置看起来简单,实际操作中翻车率极高。先说 CMake 本身。Eigen 是 header-only,所以你不需要 target_link_libraries 它,只需要把 include 目录加进去:
cmake复制cmake_minimum_required(VERSION 3.16)
project(games102_homework)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Eigen3 REQUIRED)
find_package(glfw3 REQUIRED)
add_executable(hw01 hw01.cpp)
target_include_directories(hw01 PRIVATE ${EIGEN3_INCLUDE_DIR})
target_link_libraries(hw01 PRIVATE glfw polyscope)
这里有一个非常容易忽略的点:find_package(Eigen3 REQUIRED) 提供的变量名是 EIGEN3_INCLUDE_DIR,不是 EIGEN_INCLUDE_DIR,少写一个 3 会导致整整两个小时的“找不到头文件”。
如果你直接 use libigl,建议不要拉 master 分支,而是锁定一个 release tag。我遇到过 libigl 的一次更新把 igl::readOBJ 的签名改了,导致整个编译失败。锁版本看起来保守,但在作业这种时间敏感的项目里,版本稳定比新特性重要得多。
另外,CMAKE_BUILD_TYPE 一定不要默认空着。Debug 模式下 Eigen 的 VectorXd 会带边界检查,性能差很多,但至少能帮你发现越界访问。等算法基本正确后再切 Release,你会发现同样一个网格简化过程,速度可能是 Debug 的十几倍。如果你用的是 Visual Studio 的 CMake 集成,还要额外确认生成器是 x64,默认的 Win32 配置会让 Eigen 的可变长类型出现内存对齐问题。
2.3 一个可靠的调试工作流
我比较推荐“三步验证”的调试流程,而不是上来就断点单步:
第一步,准备一个小规模数据。曲线作业就用 3 到 4 个控制点,网格作业就用一个二十面体或者非常低分辨率的 Bunny。规模小,预期结果才能算清楚。
第二步,在算法核心循环里打印关键量。不要打印整个数组,而是打印特定几步的中间结果。比如 de Casteljau 算法里,打印第 k 轮迭代后每个插值点的坐标,和手算结果逐项对比。
第三步,把输出结果导出成 OBJ 或 CSV 文件,用外部工具检查。MeshLab 可以快速看出网格是否破面、面片绕序是否一致,CSV 文件可以用 Python 脚本做数值误差分析。
这套流程一开始可能觉得繁琐,但能帮你把“算法问题”和“实现问题”分离。很多 bug 在第二步就发现了,根本不用走到第三步。
3. 从数学公式到内存布局:曲线和网格代码的核心逻辑
3.1 Bézier 曲线的 de Casteljau 算法:教科书公式与实际循环的差距
Bézier 曲线的数学定义是伯恩斯坦多项式,但实际工程实现几乎都用 de Casteljau 算法。它做的事情很直观:给定 n 个控制点,在参数 t 处反复做线性插值,每轮减少一个点,最后剩下的点就是曲线上的采样点。
我第一版实现里,最蠢的写法是这样的:
cpp复制std::vector<Vector2d> control_points = {...};
for (double t = 0.0; t <= 1.0; t += step) {
std::vector<Vector2d> tmp = control_points;
for (int level = 0; level < n; ++level) {
for (int i = 0; i < n - level - 1; ++i) {
tmp[i] = (1.0 - t) * tmp[i] + t * tmp[i + 1];
}
}
curve_points.push_back(tmp[0]);
}
这个实现有一个关键细节:必须把 control_points 拷贝到 tmp,因为每一层级都在原地更新。我一开始直接拿 control_points 更新,导致第二轮循环里用的是已经被污染的数据,曲线形状完全不对。
除了这个,最常见的 bug 是用整数做插值系数。如果你写 (1 - t) * tmp[i] + t * tmp[i+1] 而 t 是 int,那么 t 只能是 0 或者 1,中间全是直线。正确做法是显式使用 double 类型,并且 t 通过 i * step 计算,避免累计误差。
还有一个容易被视觉掩盖的问题:曲线段数量。如果你在 [0,1] 均匀采样 100 个点,在控制点数量很多时,曲线在两端的曲率变化区域会比较稀疏。业务上仔细观察端点处是否贴合控制多边形,如果出现多边形折角,就说明采样密度不够。
3.2 B-spline 基函数递归实现的几个细节
到了 B-spline,事情比 Bézier 复杂一个等级。B-spline 不直接对所有控制点做全局插值,而是通过节点向量把参数域切成若干区间,每个区间只由局部几个控制点决定。它的核心是 Cox-de Boor 递推公式:
code复制N_{i,0}(u) = 1 若 u_i <= u < u_{i+1},否则 0
N_{i,k}(u) = (u - u_i) / (u_{i+k} - u_i) * N_{i,k-1}(u)
+ (u_{i+k+1} - u) / (u_{i+k+1} - u_{i+1}) * N_{i+1,k-1}(u)
代码实现里要注意三个点:
第一,节点区间判断。末端的最后一个非零基函数区间右端点要包含,通常用 if (u >= knots[i] && u < knots[i+1]),但在参数域最后一点时需要特殊处理,让最后一个区间是闭区间。我见过很多实现把最后一个区间的端点丢掉,导致曲线末端缺少一点。
第二,除零保护。当节点区间长度为 0 时,分母是 0,需要直接让那一项为 0。很多同学用 if (denom == 0.0) 判断,浮点下这个条件可能不成立,稳妥的做法是判断 denom < 1e-12。
第三,基函数在每个参数值处之和必须为 1。这是一个极好的自检条件。如果你在每个参数采样点把所有权重加一遍,发现和不是 1,说明递归实现里有分支漏算。
一个实用的检查脚本思路:对每个参数 u,计算所有基函数值并求和,误差阈值设为 1e-8。如果超了,说明代码有 bug。这个检查应该写进测试函数,而不是每次人工看输出。
3.3 网格数据结构:半边结构的本质是“不重复存储”
写网格算法,最关键的是数据结构。三角形网格最朴素的做法是三个顶点存一个面,但这样做“找到一个边的邻居面”非常麻烦,需要全局搜索。作业里处理网格简化时,要求高效遍历邻接信息,半边结构就成了标配。
半边结构本质上是把一个无向边拆成两条方向相反的“半边”,每条半边记录自己的起点、所属面、下一条半边、对面的半边。这样从一个面出发,可以顺着半边环遍历所有顶点;从一个顶点出发,也可以通过所有以它为起点的半边找邻居。
实现时最容易搞混的字段是 next 和 twin。我一开始把两者混用,导致遍历面的时候死循环。一个口诀:twin 是“穿过边到另一侧”,next 是“在本面上绕到下一个边”。两者配合起来,邻接查询就是几个指针跳转。
边界处理是另一个大头。网格外轮廓处的边只有一条半边,没有 twin,所有涉及 twin 的访问都要判空。用一个具体的例子:如果要从一个顶点遍历所有相邻顶点,代码是这样:
cpp复制Halfedge* h = vertex->halfedge;
do {
Vertex* neighbor = h->twin->vertex;
// 处理 neighbor
h = h->twin->next;
} while (h != vertex->halfedge);
如果 h->twin 为空,程序直接崩溃。正确做法是先检查 h->twin != nullptr,再访问。很多网格简化程序跑到一半崩溃,根因不是算法本身,而是边界半边的空指针。排查的时候不要盯着算法逻辑看,先检查所有 twin 访问。
3.4 顺手讲一下曲线拟合的最小二乘实现
作业里如果有曲线逼近或拟合部分,通常需要解最小二乘。流程是:在参数域采样若干点,用基函数构造设计矩阵 A,然后解 A^T A x = A^T b。
我见过很多同学直接写成 x = (A.transpose() * A).inverse() * A.transpose() * b,能用,但数值上不推荐。矩阵维度稍微大一点、控制点分布不均匀时,法方程会病态,求逆会把误差放大。更稳的做法是直接用 Eigen 的 QR 分解:
cpp复制Eigen::ColPivHouseholderQR<MatrixXd> solver(A);
VectorXd x = solver.solve(b);
如果遇到的问题仍然是法方程病态,可以考虑加一个小量正则项,相当于解:
code复制(A^T A + lambda * I) x = A^T b
这个技巧在实际三角网格参数化里也经常用到,数值稳定性比“纯解方程”好很多。作业里如果出现拟合结果在两端剧烈震荡,八成不是算法思路错了,而是求解方式太脆弱。
4. 可视化验证:几何算法的“测试用例”长什么样
4.1 为什么不崩溃不等于算对了
写几何算法最危险的心理状态就是“程序能跑,结果看起来差不多”。曲线和网格这类数据,很多错误在视觉上是不明显的。比如 B-spline 基函数有个分支写错了,曲线可能只是中间微微偏离。比如面片绕序乱了,有些软件会自动修正显示,你看不出来。
所以要建立自己的验证体系。曲线算法的验证分三档:第一档是数学性质,比如基函数归一化、端点插值误差;第二档是几何性质,比如曲线的凸包性在 Bézier 里应该成立;第三档才是视觉检查,看整体形状是否合理。三档都过了,才叫正确。
网格算法的验证也一样。简化前后模型的体积变化、每个顶点的法向一致性、非流形边数量,这些都是硬指标。我习惯在代码里加一个 validate_mesh 函数,专门统计反转面数量、孤立顶点数量、非流形边数量,任何一次操作后都跑一遍。这个步骤帮我抓住了好几次索引更新遗漏。
4.2 用颜色和标记做几何调试
可视化调试的核心思想是“把数据编码成颜色”。Polyscope 支持给网格顶点或面片注册标量颜色,利用这一点可以做很多事情。
调试曲面采样时,我想看采样点是否均匀,就把每个采样点到最近邻的距离作为标量字段映射到颜色上,不均匀的地方一眼就能看出来。调试面片绕序时,我把每个面的法向点乘结果(比如面法向和某个固定方向)映射成颜色,反转面会呈现出完全不同的颜色。这个方法比在 MeshLab 里手动旋转检查高效得多。
调试参数化作业时,可以把 UV 坐标的 U 值作为颜色标在三维网格上。如果出现颜色跳变的边界,说明参数化在接缝处不连续,通常需要调整边界圈选择或者缝合逻辑。
4.3 数值层面的校验项
数值检查是可视化之外非常扎实的验证方式。列出我常用的几个检查项:
| 检查对象 | 检查方法 | 合理误差 |
|---|---|---|
| B-spline 基函数 | 每个参数点所有基函数之和 = 1 | 1e-8 |
| 曲线端点 | 端点是否精确插值控制点 | 1e-6 |
| 网格简化 | 简化前后总体积变化率 | 小于 1% |
| 网格简化 | 反转面数量 | 0 |
| 网格简化 | 非流形边数量 | 0 |
| 参数化 | 每个三角形的 Jacobian 行列式 > 0 | 0 |
这些检查写成断言,进到 CI 或者每次运行脚本里。不要相信“看一眼就行”,因为人的眼睛非常容易被错误的颜色映射欺骗。数值断言虽然枯燥,但它是唯一能证明“算法正确”的手段。
5. 作业前半程的真实踩坑记录和完整排查链路
5.1 曲线端点不收敛,问题出在采样区间
有一次我发现 Bézier 曲线起点不在第一个控制点上,而是稍微偏移了一点。一开始怀疑 de Casteljau 算法写错,反复检查迭代逻辑没有发现错误。然后我把前几轮插值点坐标打印出来,发现 t=0 时确实得到了第一个控制点,但程序里采样循环是从 t = step 开始的,而不是 t = 0。
这个 bug 很隐蔽,因为 for (double t = 0; t < 1; t += step) 在你的步长足够小时,肉眼根本看不出起点少了一个。但如果你有“端点必须通过控制点”的验证逻辑,就会立刻暴露。
排查链路总结:端点误差超标 -> 打印首末采样点 -> 发现首点缺失 -> 检查循环边界 -> 修复为 t = 0 包含在内。解决之后,我再也不信任“看起来对”,所有曲线作业都加了端点断言。
5.2 Release 模式闪退,Eigen 内存对齐的锅
网格简化程序 Debug 模式一切正常,一编译成 Release 就随机崩溃,而且崩的位置每次都不同。这种问题最烦人,我花了大半天定位。
排查过程是这样的:先在 Debug 模式下打开断言,没有任何输出;然后看崩溃调用栈,发现崩在 Eigen::Vector3d 的拷贝构造里。我再检查整个工程,发现有一个类用 #pragma pack 控制内存对齐,里面包含了 Eigen::Vector3d 成员。这个操作破坏了 Eigen 对对齐的要求,Debug 模式下运气好没崩,Release 模式下优化变了布局,立刻出错。
解决办法是取消手动 pack,或者对包含 Eigen 类型的成员使用 EIGEN_MAKE_ALIGNED_OPERATOR_NEW。这条坑给所有用 Eigen 的人提个醒:Eigen 的可变长类型对内存对齐非常敏感,不要试图自己做字节级布局。
5.3 网格简化后大量反转面,绕序与邻接更新
网格简化作业里,我用了经典的 QEM 边折叠。跑完后用 MeshLab 打开,模型上出现很多黑色区域,不用想,这就是反转面。
一开始我以为是最短边选择逻辑有问题,但通过日志发现每次折叠的二次误差都在下降。后来把每个折叠前后的面法向都输出,发现某些面在折叠后法向突然翻转。根本原因是:边折叠后邻接面的顶点索引更新不到位,某个三角形的顶点顺序从逆时针变成了顺时针,法向就反了。
修复思路是:折叠一条边之前,先把所有受影响的面备份,折叠后再统一验证这些面的绕序;如果发现法向翻转,说明邻接更新里缺失了某个引用,回到半边结构里逐个检查顶点指针。
这个坑让我养成了一个习惯:在网格操作之前,备份所有涉及的面;操作之后,立刻跑一遍“反转面数量”的统计函数。简单粗暴,但非常有效。
5.4 排查几何算法问题的通用思路
经历过这几个坑之后,我总结出一套排查几何代码问题的思路:
第一,先隔离问题。确定是数学问题还是工程问题。数学问题体现在所有结果偏差一致,工程问题往往伴随崩溃、随机错误或局部异常。
第二,缩小数据规模。用 3 个控制点、20 个顶点的极小数据,自己手算一遍期望输出,和程序输出对比。
第三,增加中间量可见性。不要只打印最终结果,把循环里每一轮的关键中间量都打出来。几何算法迭代次数不多,打印量完全可控。
第四,建立自动化校验。把归一化条件、端点误差、反转面数量这些写成函数,在每次修改代码后自动跑一遍。麻烦一次,受益后续。
第五,如果长时间定位不到问题,回头检查数据结构本身。很多时候不是算法算错,而是数据结构在某个边界条件下没有维护好。网格程序尤其如此,索引好几天,结构对就解决一半。
做完“作业上”的这半个月里,我最深的体会是:几何算法作业考察的早就不只是数学能力,而是一个人的工程耐心和验证意识。程序不崩溃只是起点,真正的完成标准是各种数值约束全部通过,可视化结果经得起推敲。
一个很实用的小经验分享:我把自己写的这些校验函数统一放到一个 geometry_validation.h 头文件里,后半程的曲面和参数化作业仍然在复用。每次新写一个算法,先跑一遍所有基础校验,再做功能迭代,省下了大量排查时间。如果你正在赶作业,也建议花一晚上把基础校验工具补齐,这笔时间的回报率非常高。
