B-spline 曲线渲染器

继上周的 Bezier 曲线(02-22)之后,今天实现 B-spline 曲线——这是现代 CAD 系统和 NURBS 曲线的基础,也是 Bezier 曲线的重要进化。

项目目标

实现一个完整的 B-spline 曲线渲染器,核心要求:

  1. Cox-de Boor 递归算法 - B-spline 基函数的标准实现
  2. 两种节点向量 - 均匀(Uniform)和端点插值(Clamped)
  3. 多阶次展示 - 二次(Degree 2)和三次(Degree 3)
  4. 与 Bezier 对比 - 直观展示局部控制性的优势

B-spline vs Bezier:核心区别

在动手写代码前,先搞清楚为什么要有 B-spline:

特性 Bezier B-spline
控制点数与曲线次数 次数 = 控制点数 - 1 次数独立于控制点数
局部控制 ❌ 移动任一点影响整条曲线 ✅ 只影响局部 (degree+1) 段
端点插值 总是通过端点 只有 Clamped 模式才经过
适合场景 少控制点的简单曲线 复杂形状、CAD 设计

局部控制性是 B-spline 的杀手级特性:设计一个复杂的车身曲线时,只想调整车头的弧度,不希望整条曲线都跟着变。

核心算法:Cox-de Boor 递归

B-spline 基函数的递归定义:

$$N_{i,0}(t) = \begin{cases} 1 & t_i \le t < t_{i+1} \ 0 & \text{otherwise} \end{cases}$$

$$N_{i,p}(t) = \frac{t - t_i}{t_{i+p} - t_i} N_{i,p-1}(t) + \frac{t_{i+p+1} - t}{t_{i+p+1} - t_{i+1}} N_{i+1,p-1}(t)$$

曲线上的点通过基函数加权求和得到:

$$C(t) = \sum_{i=0}^{n} N_{i,p}(t) \cdot P_i$$

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
double basisFunction(int i, int p, double t, const std::vector<double>& knots) {
if (p == 0) {
if (t >= knots[i] && t < knots[i+1]) return 1.0;
// 末端点特殊处理
if (std::abs(t - knots.back()) < 1e-10 && std::abs(t - knots[i+1]) < 1e-10)
return 1.0;
return 0.0;
}

double left = 0.0, right = 0.0;

if (i + p < (int)knots.size()) {
double denom = knots[i+p] - knots[i];
if (std::abs(denom) > 1e-10)
left = (t - knots[i]) / denom * basisFunction(i, p-1, t, knots);
}

if (i + p + 1 < (int)knots.size()) {
double denom = knots[i+p+1] - knots[i+1];
if (std::abs(denom) > 1e-10)
right = (knots[i+p+1] - t) / denom * basisFunction(i+1, p-1, t, knots);
}

return left + right;
}

节点向量:决定曲线行为的关键

均匀节点向量 (Uniform)

1
[0, 1/6, 2/6, 3/6, 4/6, 5/6, 1]

等间距分布,曲线不经过端控制点。

端点插值节点向量 (Clamped/Open)

1
[0, 0, 0, 0, 1/3, 2/3, 1, 1, 1, 1]  (Degree 3, 6个控制点)

首尾各重复 degree+1 次,保证曲线经过端控制点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::vector<double> clampedKnots(int n_ctrl, int degree) {
int n = n_ctrl - 1;
int m = n + degree + 2;
std::vector<double> knots(m);

for (int i = 0; i <= degree; i++) knots[i] = 0.0;

int inner = n - degree;
for (int j = 1; j <= inner; j++)
knots[degree + j] = (double)j / (inner + 1);

for (int i = m - degree - 1; i < m; i++) knots[i] = 1.0;

return knots;
}

渲染结果

总览图展示了四个面板:

B-spline 曲线渲染结果

面板说明

Panel 1: 二次 B-spline (Uniform)

二次 B-spline

均匀节点下的二次样条,平滑地穿过控制点定义的区域,但不经过端点。

Panel 2: 三次 B-spline (Clamped)

三次 B-spline

端点插值模式,曲线精确通过第一个和最后一个控制点(dist=0 和 dist=2.7e-9)。

Panel 4: B-spline vs Bezier 对比

对比图

相同控制点下:

  • 蓝色 B-spline:更贴近控制点,形状更”紧凑”
  • 红色 Bezier:整体平均影响,曲线更”拉远”

数学验证

实现完成后,程序自动验证两个关键性质:

1
2
3
4
✅ Partition of unity holds      # ∑ N_{i,p}(t) = 1 对所有 t 成立
✅ Clamped B-spline passes through endpoints
Start: (1, 2) dist=0 # 精确经过起点
End: (9, 2) dist=2.68e-9 # 精确经过终点(浮点误差)

单位分割性(Partition of Unity)是 B-spline 的基本性质,保证了仿射变换不变性——对控制点做平移旋转,曲线也跟着变换,不需要重新计算。

迭代历史

  1. 初始版本:编写完整的 Cox-de Boor 实现,4个面板渲染逻辑
  2. 编译修复
    • 缺少 #include <functional> 头文件
    • drawCurve 函数签名与实际使用不一致(参数列表问题)
    • 统一改为 lambda 函数方案
  3. 最终版本:✅ 0错误0警告,所有验证通过

技术总结

今天最大的收获是理解了节点向量(Knot Vector)的作用:它决定了参数空间如何映射到曲线,进而控制了基函数的”支撑域”(非零区间)。Clamped 节点向量通过重复节点值增大端点处基函数的权重,从而实现端点插值。

从 Bezier 到 B-spline,再到 NURBS,这条路越来越清晰了。下一步可以尝试用 B-spline 实现实际的 3D 曲面建模。

代码仓库

GitHub: https://github.com/chiuhoukazusa/daily-coding-practice/tree/main/2026/03/03-02-bspline-curve


完成时间: 2026-03-02 05:35
迭代次数: 2 次
编译器: g++ -std=c++17 -O2