NOTE这一部分内容相对抽象,但贝塞尔曲线在 UI、字体、动画里随处可见——学完之后就明白为什么 Photoshop 钢笔工具那么直觉。重点在贝塞尔 + B 样条 + 网格半边结构这三块,曲率/切向量这些微分几何量在 Part 1 §4.1 已经推过,这里只做必要回顾并交叉引用。
目录
- 参数曲线基础 — 参数化优势 / 常见曲线族(与 Part 1 §4.1 互补)
- 贝塞尔曲线 — 伯恩斯坦基、de Casteljau 算法及其正确性证明(canonical 位置)
- B 样条与 NURBS — Cox-de Boor 递推、有理扩展
- 网格处理 — 半边结构、QEM 简化、Loop 细分、拉普拉斯平滑
十二、参数曲线基础
TIP本章与 Part 1 §4.1 的边界:切向量 / 曲率 / 弧长这些微分几何定义已在 Part 1 推过。本章只快速复用,重点放在”为什么参数曲线在图形学里好用”以及常见曲线族的参数形式。
12.1 为什么选参数形式
12.1.1 三种曲线表示法的对比
| 表示法 | 形式 | 优点 | 缺点 |
|---|---|---|---|
| 显式 | 求值直接 | 无法表示垂线/闭合曲线;多值函数不适用 | |
| 隐式 | 判断”点是否在曲线上”很容易 | 求曲线上的点要解方程;不自然支持参数化采样 | |
| 参数 | 采样、动画、微分几何计算统一;支持任意拓扑 | 一个曲线可以有无数种参数化 |
图形学几乎全用参数形式:动画里 直接当时间轴,光栅化时均匀或自适应地在 上采样,导数直接拿来做切向量/速度。
12.1.2 常见曲线的参数方程(速查)
直线(两点线性插值):
- 切向量:(常向量)
- 曲率:
圆(参数化成三角函数):
- ,
- 三维中用两个正交单位向量 张成平面即可推广
椭圆:
- 长轴端点曲率 ,短轴端点 ——长轴端点”更尖”
螺旋线(3D):
NOTE曲率公式回顾(Part 1 §4.1.2 已推):。平面曲线可简化为 。
12.1.3 弧长参数化
弧长函数:
以 为参数时 ,于是 ,几何公式变得非常干净。但对多数曲线(贝塞尔/样条), 无解析反函数,实际工程里要靠数值查表实现”按弧长匀速走”。
十三、贝塞尔曲线(canonical)
WARNING本章是全系列贝塞尔曲线的规范位置。Part 1 §4.1 和 Part 6 里再提到贝塞尔时,都回链这里。
13.1 伯恩斯坦基函数
13.1.1 定义
次伯恩斯坦基函数:
其中二项式系数 。
13.1.2 五条核心性质(带证明)
性质 1 — 非负性: 在 上。
证:三个因子 在该区间上均非负。
性质 2 — 单位分解:。
证:二项式定理直接给出 。
几何意义:性质 1 + 2 说明 是一组凸权重,这是贝塞尔曲线凸包性的根本。
性质 3 — 对称性:。
证:。
性质 4 — 端点值:,。
这直接给出贝塞尔曲线必过首尾控制点。
性质 5 — 递推关系(de Casteljau 的数学基础):
证:(帕斯卡恒等式),代入展开。
13.1.3 概率视角
恰好是” 次独立伯努利试验、单次成功概率为 、恰好成功 次”的概率。这解释了为什么 的最大值在 处——和二项分布的众数一致。
13.2 贝塞尔曲线的定义
次贝塞尔曲线由 个控制点 定义:
常用次数的展开形式:
- 1 次(直线):
- 2 次:
- 3 次(图形学主力):
13.2.1 朴素实现(三次特化)
GAMES101 Assignment 4 风格的直接展开:
cv::Point2f naive_bezier_cubic(const std::vector<cv::Point2f>& P, float t) { float u = 1.0f - t; float b0 = u * u * u; float b1 = 3.0f * u * u * t; float b2 = 3.0f * u * t * t; float b3 = t * t * t; return b0 * P[0] + b1 * P[1] + b2 * P[2] + b3 * P[3];}WARNING朴素形式随 增大数值稳定性下降( 和 数量级差很大)。实用实现请优先用下一节的 de Casteljau 算法。
13.3 de Casteljau 算法
13.3.1 算法思想
de Casteljau 通过递归线性插值在金字塔结构上把点 算出来。它在数值上比伯恩斯坦展开稳定得多,并且几何直观。
迭代定义:
最终结果 。
13.3.2 正确性证明
📌 de Casteljau = 贝塞尔定义(归纳证明)
命题:。
取 即得 。
归纳基: 时 ,平凡成立。
归纳步:设对 成立。由递推式:
代入归纳假设:
把第二项换元 ,合并同类项后,利用帕斯卡恒等式 :
13.3.3 三次贝塞尔的金字塔结构
| 层级 | 点 | 表达式 |
|---|---|---|
| 0 | 原控制点 | |
| 1 | 相邻对的 | |
| 2 | 层 1 相邻对的 lerp | |
| 3 | 最终曲线上的点 |
13.3.4 通用实现
// 就地迭代版本,最实用,O(n²) 时间、O(n) 额外空间cv::Point2f de_casteljau(std::vector<cv::Point2f> P, float t) { int n = static_cast<int>(P.size()); for (int level = 1; level < n; ++level) { for (int i = 0; i + level < n; ++i) { P[i] = (1.0f - t) * P[i] + t * P[i + 1]; } } return P[0];}
// 教学用递归版本,清晰但有栈开销cv::Point2f de_casteljau_recursive(const std::vector<cv::Point2f>& P, float t) { if (P.size() == 1) return P[0]; std::vector<cv::Point2f> next(P.size() - 1); for (size_t i = 0; i + 1 < P.size(); ++i) { next[i] = (1.0f - t) * P[i] + t * P[i + 1]; } return de_casteljau_recursive(next, t);}13.4 贝塞尔曲线的几何性质
端点插值:,。
端点切向量:,。
这给出了设计控制点时的直观规则:要让曲线在端点有特定切线方向,只需让相邻控制点沿目标方向排布。
凸包性:曲线始终位于控制多边形的凸包内(性质 1 + 2 的直接推论)。
仿射不变性:对控制点做仿射变换 = 对曲线做同一仿射变换。这是贝塞尔曲线可以和 MVP 链自然配合的关键(先变换控制点再采样,与先采样再变换等价)。
变分递减性:任一直线与贝塞尔曲线的交点数不超过其与控制多边形的交点数——曲线”比控制多边形更平滑”的精确刻画。
13.5 贝塞尔曲线的导数
贝塞尔曲线的导数本身是一条低一次的贝塞尔曲线,其控制点为差分:
代码实现:
cv::Point2f bezier_derivative(const std::vector<cv::Point2f>& P, float t) { int n = static_cast<int>(P.size()) - 1; std::vector<cv::Point2f> D(n); for (int i = 0; i < n; ++i) { D[i] = static_cast<float>(n) * (P[i + 1] - P[i]); } return de_casteljau(D, t);}13.6 自适应细分
朴素地按 均匀采样浪费在曲线接近直线的段上;自适应细分按曲率/平直度动态调整步长。
中点平直度判据:折半采样、检查中点到首尾连线的距离:
// 基于平直度递归细分void adaptive_bezier(const std::vector<cv::Point2f>& P, float t0, float t1, cv::Mat& canvas, float tol = 0.5f, int depth = 0) { if (depth > 12) return; // 防止病态情况下无限递归 float tm = 0.5f * (t0 + t1); cv::Point2f p0 = de_casteljau(P, t0); cv::Point2f pm = de_casteljau(P, tm); cv::Point2f p1 = de_casteljau(P, t1); cv::Point2f line_mid = 0.5f * (p0 + p1); if (cv::norm(pm - line_mid) > tol) { adaptive_bezier(P, t0, tm, canvas, tol, depth + 1); adaptive_bezier(P, tm, t1, canvas, tol, depth + 1); } else { cv::line(canvas, p0, p1, cv::Scalar(0, 255, 0), 2); }}十四、B 样条与 NURBS
14.1 为什么需要 B 样条
贝塞尔的两个短板:
- 全局控制:动一个控制点,整条曲线都变——对交互建模不友好。
- 高次数值问题:次数随控制点数增长,既费算又不稳。
B 样条的解决方案:
- 分段多项式,每段只受局部几个控制点影响 → 局部支撑性
- 次数 与控制点数 解耦
- 在节点连接处自动保持 连续
14.2 样条函数的定义
设节点序列 , 次样条函数 是:
- 在每段 上是次数不超过 的多项式;
- 在整个定义域上具有 连续性(即前 阶导数在节点处连续)。
14.3 Cox-de Boor 递推公式
14.3.1 B 样条基函数
0 次(阶梯函数):
高次递推(Cox-de Boor,与 de Casteljau 形式相似):
WARNING遇到重节点时分母可能为 0,约定 。工程实现一定要显式处理。
14.3.2 基函数的五条性质
- 非负性:
- 局部支撑: 当 —— 这是 B 样条局部控制性的根源
- 单位分解: 在基函数定义域内
- 节点处连续性:(普通节点);重节点会降低连续性
- 在 内恰好有一个最大值
14.4 B 样条曲线
14.4.1 定义
其中 个控制点,节点向量长度 (约束:)。
14.4.2 节点向量类型
| 类型 | 定义 | 特点 |
|---|---|---|
| 均匀 | 节点等距 | 基函数形状相同,平移得到 |
| 开放(clamped) | 首尾节点各重复 次 | 曲线在端点与控制多边形相切,实际建模最常用 |
| 非均匀 | 节点间距任意 | 可以精细控制局部形状 |
14.4.3 核心性质
- 局部控制性:移动 只影响 那一段
- 凸包性:曲线位于相邻 个控制点的局部凸包内
- 仿射不变性
- 变分递减性
14.4.4 参考实现
class BSpline {public: std::vector<Eigen::Vector3f> P; // 控制点 std::vector<float> U; // 节点向量 int p; // 次数
// Cox-de Boor,递归实现(教学用) float N(int i, int k, float t) const { if (k == 0) return (U[i] <= t && t < U[i + 1]) ? 1.0f : 0.0f; float left = 0.0f, right = 0.0f; float d1 = U[i + k] - U[i]; float d2 = U[i + k + 1] - U[i + 1]; if (d1 > 1e-8f) left = (t - U[i]) / d1 * N(i, k - 1, t); if (d2 > 1e-8f) right = (U[i + k + 1] - t) / d2 * N(i + 1, k - 1, t); return left + right; }
Eigen::Vector3f evaluate(float t) const { Eigen::Vector3f c = Eigen::Vector3f::Zero(); for (int i = 0; i < static_cast<int>(P.size()); ++i) { c += N(i, p, t) * P[i]; } return c; }};TIP生产级实现会用 de Boor 算法(类比 de Casteljau,只在受影响的 个控制点上做 层 lerp),比直接求基函数快得多。
14.5 NURBS:有理 B 样条
14.5.1 动机
B 样条不能精确表示圆(以及其他二次曲线)。NURBS 通过引入控制点权重解决这一问题,CAD/工业建模里几乎全用 NURBS。
14.5.2 定义
定义有理基函数:
则 ,与 B 样条形式完全一致。
14.5.3 齐次坐标视角
把每个带权控制点升维为 ,则 NURBS 正是 4D 空间 B 样条的透视除法投影:
这个视角让 NURBS 自然与齐次坐标系统(Part 1 §1.2.3)和投影变换(Part 1 §3.1)串在一起。
14.5.4 NURBS 的三大优势
- 精确表示二次曲线:圆、椭圆、抛物线、双曲线都能精确表示(有理样条的代数闭合性)
- 权重局部控制形状:增大 把曲线向 拉近
- 投影不变性:透视投影下 NURBS 仍是 NURBS——渲染管线不会破坏它的数学结构
14.5.5 参考实现
class NURBS : public BSpline {public: std::vector<float> w; // 权重,与 P 一一对应
Eigen::Vector3f evaluate(float t) const { Eigen::Vector3f num = Eigen::Vector3f::Zero(); float den = 0.0f; for (int i = 0; i < static_cast<int>(P.size()); ++i) { float b = N(i, p, t); num += w[i] * b * P[i]; den += w[i] * b; } return num / den; }};14.6 贝塞尔曲面
14.6.1 张量积定义
双参数曲面由 的控制点网格定义:
14.6.2 核心几何性质
- 四角点插值: 等四个角
- 边界是贝塞尔曲线:如 只由一整行/一整列控制点决定
- 凸包性、仿射不变性 同曲线情形
14.6.3 偏导数与法向量
曲面法向量由两偏导的叉积给出:
NOTE做光照时用的是单位法向量 。如果曲面被非均匀缩放,还要按 Part 1 §2.3 的逆转置方式变换。
14.6.4 曲面细分为三角网格
void tessellate_bezier_surface(const BezierSurface& s, int nu, int nv, std::vector<Eigen::Vector3f>& out_verts, std::vector<Eigen::Vector3f>& out_normals, std::vector<std::array<int, 3>>& out_tris) { for (int i = 0; i <= nu; ++i) { for (int j = 0; j <= nv; ++j) { float u = static_cast<float>(i) / nu; float v = static_cast<float>(j) / nv; out_verts.push_back(s.evaluate(u, v)); out_normals.push_back(s.normal(u, v)); } } for (int i = 0; i < nu; ++i) { for (int j = 0; j < nv; ++j) { int i00 = i * (nv + 1) + j; int i10 = i00 + (nv + 1); int i01 = i00 + 1; int i11 = i10 + 1; out_tris.push_back({i00, i01, i10}); out_tris.push_back({i01, i11, i10}); } }}十五、网格几何处理
15.1 半边数据结构(Half-Edge)
15.1.1 为什么需要半边
三角面 + 顶点列表(OBJ 格式那种)查询邻接关系是 的——找”和顶点 相邻的所有面”要扫全表。半边结构用 均摊完成所有拓扑查询,是网格处理算法的标配。
15.1.2 核心约定
- 每条无向边拆成两个有向半边,互为孪生(twin)
- 每个半边指向一个顶点(其
to),属于一条边界循环(围绕一个面) - 循环方向约定:从面外看逆时针
15.1.3 数据结构
struct HalfEdge { int to_vertex; // 指向的顶点索引 int face; // 所属面(边界半边为 -1) int next; // 同一面循环中的下一条半边 int prev; // 同一面循环中的上一条半边 int twin; // 对偶半边};
struct Vertex { Eigen::Vector3f position; Eigen::Vector3f normal; int halfedge; // 任意一条"从该顶点出发"的半边};
struct Face { int halfedge; // 该面任意一条边界半边 Eigen::Vector3f normal;};15.1.4 典型 均摊查询
// 遍历 v 的所有一环邻居顶点(单环遍历)std::vector<int> one_ring_vertices(const HalfEdgeMesh& mesh, int v) { std::vector<int> ring; int start = mesh.vertices[v].halfedge; int he = start; do { int twin = mesh.halfedges[he].twin; ring.push_back(mesh.halfedges[twin].to_vertex); // 注意:twin.to 才是 v 的邻居 he = mesh.halfedges[twin].next; // 绕 v 转到下一条出边 } while (he != start); return ring;}WARNING上述循环假设 是内部顶点。如果 在边界上,按”孪生-next”走会掉出边界,需要特殊处理(起点选边界半边,循环退出条件改成
twin == -1)。
15.2 三角形质量度量
网格质量直接影响后续的细分、模拟稳定性、纹理映射效果。下面三个指标最常用。
15.2.1 等周比
- :三角形面积;:周长
- 等边三角形时 (最优)
- 退化三角形
15.2.2 最小角度
最小角越接近 越好;小于 的三角形叫做 sliver(针状三角形),在有限元/光照计算里会引起数值问题。
15.2.3 长宽比
(其中 为半周长)。
15.2.4 实现
struct TriQuality { static float isoperimetric(const Eigen::Vector3f& a, const Eigen::Vector3f& b, const Eigen::Vector3f& c) { float len_ab = (b - a).norm(); float len_bc = (c - b).norm(); float len_ca = (a - c).norm(); float P = len_ab + len_bc + len_ca; if (P < 1e-8f) return 0.0f; float A = 0.5f * (b - a).cross(c - a).norm(); return 4.0f * std::sqrt(3.0f) * A / (P * P); }
static float min_angle_rad(const Eigen::Vector3f& a, const Eigen::Vector3f& b, const Eigen::Vector3f& c) { auto ang = [](const Eigen::Vector3f& u, const Eigen::Vector3f& v) { float cos_t = std::clamp(u.normalized().dot(v.normalized()), -1.0f, 1.0f); return std::acos(cos_t); }; float A = ang(b - a, c - a); float B = ang(a - b, c - b); float C = ang(a - c, b - c); return std::min({A, B, C}); }};15.3 QEM 网格简化(Quadric Error Metrics)
Garland & Heckbert 1997 年提出的经典算法。核心思想:每次折叠最不重要的一条边、累积误差用”到邻接平面距离的平方和”度量。
15.3.1 平面方程与二次形
平面 写成齐次形式 ,要求 (单位法向量)。
点 到该平面的有符号距离 = ,距离平方 = 。
定义该平面的基本二次矩阵:
15.3.2 顶点的二次误差矩阵
顶点 的误差矩阵是邻接面对应二次矩阵之和:
对任意候选位置 ,误差 。
15.3.3 边折叠:最优新顶点位置
折叠边 时:
最小化 关于前三个分量,令偏导为零:
若左侧矩阵奇异(退化情形),退而求其次:从 三个候选中取误差最小者。
15.3.4 算法骨架
// 伪代码骨架,完整实现需要堆管理 + 拓扑有效性检查void qem_simplify(HalfEdgeMesh& mesh, int target_faces) { // 1. 为每个面算平面方程 -> K_pi // 2. 每个顶点累积 Q_v = ∑ K_pi // 3. 每条边算最优折叠位置 + 对应代价 Δ(p*) std::priority_queue<EdgeCost> heap; // 最小堆 for (auto& edge : mesh.edges()) { auto [pos, cost] = solve_optimal_collapse(mesh, edge); heap.push({edge.id, pos, cost}); } // 4. 反复取最小代价的边折叠,直到达到目标面数 while (mesh.face_count() > target_faces && !heap.empty()) { auto top = heap.top(); heap.pop(); if (!mesh.is_valid_collapse(top.edge)) continue; // 惰性删除 mesh.collapse_edge(top.edge, top.position); // 更新受影响顶点的 Q_v 和邻接边的代价 update_affected_edges(mesh, heap, top); }}工程上要处理的陷阱:(1) 折叠后不能产生拓扑退化(翻面、非流形边);(2) 堆里的边项要惰性失效(用版本号或 is_valid 标志),不要真的做删除;(3) 边界边需要额外加一个”虚拟垂直墙面”保护边界形状。
15.4 Loop 细分
15.4.1 算法概览
针对三角网格的逼近型细分(不插值原顶点,但收敛到光滑极限曲面)。每次细分:
- 每条边取新中点 → 新边顶点
- 旧顶点按加权平均移动到新位置
- 每个老三角形分成 4 个新三角形
15.4.2 边中点的权重
对内部边 ,设其两相邻三角形的另一顶点为 :
边界边(只有一个相邻三角形)退化为:
15.4.3 旧顶点的加权平均
度数(价)为 的内部顶点 的新位置:
边界顶点(开放网格)则用 , 为边界上的两邻居。
📌 系数为何如此设计
Loop 本人通过分析细分矩阵的特征值给出了 ——选择让主次特征值满足 ,,使极限曲面在普通顶点处 、在奇异顶点(价不为 6 的顶点)处 。细节见 Loop 1987 硕论。
15.4.4 参考实现
void loop_subdivide(HalfEdgeMesh& mesh) { std::vector<Eigen::Vector3f> new_pos(mesh.vertex_count()); // 1. 旧顶点新位置 for (int v = 0; v < mesh.vertex_count(); ++v) { auto ring = one_ring_vertices(mesh, v); int n = static_cast<int>(ring.size()); float beta = (n == 3) ? 3.0f / 16.0f : 3.0f / (8.0f * n); Eigen::Vector3f sum_neighbors = Eigen::Vector3f::Zero(); for (int nb : ring) sum_neighbors += mesh.vertices[nb].position; new_pos[v] = (1.0f - n * beta) * mesh.vertices[v].position + beta * sum_neighbors; } // 2. 每条边的新顶点位置 std::vector<Eigen::Vector3f> edge_pos(mesh.edge_count()); for (int e = 0; e < mesh.edge_count(); ++e) { auto [v1, v2] = mesh.edge_vertices(e); auto [v3, v4] = mesh.edge_opposite_vertices(e); // 两相邻三角形的另一点 edge_pos[e] = 0.375f * (mesh.vertices[v1].position + mesh.vertices[v2].position) + 0.125f * (mesh.vertices[v3].position + mesh.vertices[v4].position); } // 3. 重建拓扑:每个老三角形 -> 4 个新三角形 mesh.rebuild_with_new_positions_and_edge_vertices(new_pos, edge_pos);}15.5 拉普拉斯平滑
最简单的去噪 / 光顺算法。每次把顶点往邻居的重心方向拉一点:
void laplacian_smooth(HalfEdgeMesh& mesh, float lambda = 0.5f, int iters = 10) { std::vector<Eigen::Vector3f> next(mesh.vertex_count()); for (int k = 0; k < iters; ++k) { for (int v = 0; v < mesh.vertex_count(); ++v) { if (mesh.is_boundary_vertex(v)) { next[v] = mesh.vertices[v].position; // 边界保持不动 continue; } auto ring = one_ring_vertices(mesh, v); Eigen::Vector3f centroid = Eigen::Vector3f::Zero(); for (int nb : ring) centroid += mesh.vertices[nb].position; centroid /= static_cast<float>(ring.size()); next[v] = mesh.vertices[v].position + lambda * (centroid - mesh.vertices[v].position); } for (int v = 0; v < mesh.vertex_count(); ++v) { mesh.vertices[v].position = next[v]; } }}WARNING朴素拉普拉斯平滑会让网格整体收缩(极限情况下塌成一点)。实用替代:Taubin λ/μ 滤波,两步交替使用正负步长,既平滑又保体积。更进阶的做法是双边保特征滤波。
小结
| 主题 | 工具 | 关键性质 |
|---|---|---|
| 参数曲线 | 、导数、曲率 | 图形学里曲线的通用语言;微分几何已在 Part 1 §4.1 打底 |
| 贝塞尔 | 伯恩斯坦基、de Casteljau | 端点插值、凸包性、仿射不变性;三次最常用 |
| B 样条 | Cox-de Boor 递推、分段多项式 | 局部控制性、 连续;次数与控制点数解耦 |
| NURBS | 有理权重 + B 样条 | 精确表示二次曲线;CAD/工业建模标配 |
| 半边结构 | HalfEdge / Vertex / Face 三元结构 | 均摊拓扑查询,后续所有网格算法的基础 |
| QEM 简化 | 二次误差矩阵 + 最小堆 | ”距离平方和”作为几何误差的代数化 |
| Loop 细分 | 边中点加权 + 旧顶点加权 | 逼近型,收敛到光滑极限曲面 |
| 拉普拉斯平滑 | 邻居重心拉近 | 会收缩,实用时配合 Taubin λ/μ |
下一部分进入 Part 4:光线追踪与全局光照——那里才是渲染方程、BRDF 完整体系、路径追踪收敛性的正式出场。