简介:这是一套基于C++与QT的B样条曲线绘制工程,面向CAD、计算机图形学及机械设计方向的开发者和学习者,可在Windows、Linux等平台直接构建运行,用于生成与交互调整平滑样条曲线,支持控制点拖拽、实时更新等操作。压缩包内共含11个文件,主要由3个cpp源文件、2个h头文件、2个ui界面文件以及pro工程文件和qrc资源文件组成,覆盖曲线算法、界面布局与图标资源,完整工程可直接导入Qt Creator编译,整体约12KB,结构精炼、便于学习。目前已有296人学习下载,适合希望快速掌握B样条曲线核心算法和QT图形视图框架的读者。工程提供了控制点拖拽与曲线实时重绘的完整示例,读者可借此理解B样条基函数递归计算、QPainter绘图路径以及QGraphicsScene交互机制,并可在此基础上扩展数据拟合、蒙皮等更高级功能。
1. 解开 B_Spline.zip 之后,先看清曲线计算层
拿到一个以B_Spline.zip命名的 Qt 工程时,我最先做的事不是点开main.cpp,而是搜工程里有没有保存“控制点”的容器:它决定了这套代码到底是真曲线,还是拿折线糊弄视觉效果。B_Spline.zip 这种命名把 B 样条曲线、QT 曲线、C++ 和 qt 工程压在一个包里,常见于演示用项目:一侧是控制点折线,一侧是QPainter重绘出来的样条曲线。对于做 CAD、曲线编辑器、图形控件的人来说,这个工程的价值不在那些窗口部件,而在控制点数组、节点向量和曲线阶数之间的换算关系。想把这套代码改到自己的数据可视化界面里,不要盯着paintEvent里的画线逻辑,先看求值层怎么写,否则后面挪一个控制点,曲线就会给出一个莫名其妙的结果。
2. B 样条曲线求值的 C++ 内核:用 de Boor 算法替代递归
2.1 先约定控制点、节点向量与曲线次数的关系
B 样条曲线不是通过曲线上已知点反向拟合,而是用若干个控制点去“拉”曲线。控制点数量、节点向量长度和曲线次数之间有硬性约束:控制点数量记作 N,曲线次数记作 p,节点数组长度必须是 N + p + 1。例如 4 个控制点、3 次曲线,节点数组长度就是 8;少一个节点,求值时索引就会越界,程序跑起来要么结果错,要么在 Release 下静默崩溃。
节点向量的头 p+1 个值和尾 p+1 个值通常设成 0 和 1,这叫 clamped 节点向量,能让曲线真正穿过首尾控制点。中间节点如果均匀递增,曲线在参数空间里的速度是均匀的,但实际视觉上不一定是“均匀贴着控制点走”,这个差别到第四章再展开。先把最核心的求值函数写对。
2.2 用 Cox-de Boor 递归式求基函数
B 样条曲线的标准写法是:
C(u) = sum_{i=0}^{n} N_{i,p}(u) * P_i所以先要实现基函数N_{i,p}(u)。最直观的写法是 Cox-de Boor 递归:次数 0 时看参数 u 落在哪个节点区间内,返回 1,否则返回 0;次数大于 0 时,用前后两个区间长度把低一阶的基函数组合起来。
#include <vector> using std::vector; // U 是节点数组,i 是控制点下标,p 是曲线次数,u 是曲线参数 double basis(int i, int p, double u, const vector<double>& U) { if (p == 0) { return (u >= U[i] && u < U[i + 1]) ? 1.0 : 0.0; } double left = 0.0; double right = 0.0; double d1 = U[i + p] - U[i]; if (d1 != 0.0) { left = (u - U[i]) / d1 * basis(i, p - 1, u, U); } double d2 = U[i + p + 1] - U[i + 1]; if (d2 != 0.0) { right = (U[i + p + 1] - u) / d2 * basis(i + 1, p - 1, u, U); } return left + right; }这段代码要解释几个关键点。u >= U[i] && u < U[i+1]采用“左闭右开”的区间判断,保证参数边界处的基函数求和等于 1。分母出现U[i+p] - U[i]为 0 时,说明节点区间长度是 0,这在 clamped 节点向量里很常见,必须跳过,否则就是除零。递归写法正确性最高,但性能很差,同一个 u 值要重复计算大量子树,控制点超过 50 个、采样超过 200 个点时就能明显卡顿,不适合放到鼠标拖拽回调里反复调用。
2.3 拖动控制点时用 de Boor 循环求坐标
在 Qt 曲线编辑器里,鼠标拖动控制点会触发连续重绘。常见的做法是改成 de Boor 算法:先定位 u 落在哪个节点区间,再拿出 p+1 个控制点做局部线性插值,循环 p 次。这样单次求值是 O(p^2),不随控制点总数线性增长。
#include <QPointF> // P 是控制点数组,U 是节点数组,p 是曲线次数,u 是曲线参数 QPointF evalBSpline(const vector<QPointF>& P, const vector<double>& U, int p, double u) { const int n = static_cast<int>(P.size()) - 1; if (u >= U.back()) { u = U.back() - 1e-8; } int k = p; while (k < n && !(u >= U[k] && u < U[k + 1])) { ++k; } // 取出当前区间关联的 p+1 个控制点 vector<QPointF> d(P.begin() + k - p, P.begin() + k + 1); for (int r = 1; r <= p; ++r) { for (int j = p; j >= r; --j) { double den = U[k + j - r + 1] - U[k - p + j]; double alpha = (den != 0.0) ? (u - U[k - p + j]) / den : 0.0; d[j] = (1.0 - alpha) * d[j - 1] + alpha * d[j]; } } return d[p]; }注意几个边界处理。u >= U.back()时要往回收一点,否则最后区间的右端点会落到重复节点之外。区间定位循环最多走到k = n,因为 clamped 节点向量保证了有效参数范围是[U[p], U[n+1])。用P.begin() + k + 1截取控制点时,k已经保证小于等于n,所以不会越界。d数组长度是p+1,每次循环只在局部更新,最后d[p]就是曲线上的点。
把这段代码放进任何 C++ 工程都能单独编译,不依赖 Qt 界面逻辑。也就是说,你可以先写个命令行程序验证曲线形状,再往 Qt 工程里搬。
3. 在 QT 工程里落成可见曲线:模型、paintEvent 与重绘
3.1 把求值器封装成不依赖 UI 的 SplineEvaluator
常见的错误做法是把节点数组和求值代码全部写进QWidget子类,看起来省事,但后面一旦要支持多视图、导出数据或者单元测试,就得把界面里的逻辑全部扯出来。我一般会先写一个SplineEvaluator,只负责“根据控制点和参数输出 QPointF”:
class SplineEvaluator { public: static double basis(int i, int p, double u, const vector<double>& U); static QPointF eval(const vector<QPointF>& P, const vector<double>& U, int p, double u); static vector<double> buildClampedKnots(int count, int p); }; vector<double> SplineEvaluator::buildClampedKnots(int count, int p) { int n = count - 1; int m = n + p + 1; vector<double> U(m + 1); for (int i = 0; i <= p; ++i) { U[i] = 0.0; } for (int i = p + 1; i <= m - p - 1; ++i) { U[i] = static_cast<double>(i - p) / (n - p + 1); } for (int i = m - p; i <= m; ++i) { U[i] = 1.0; } return U; }buildClampedKnots的参数要和前一章的约束对上:count是控制点数量,p是次数。例如 5 个控制点、3 次曲线,n=4,m=8,节点数组长度 9,首尾各 4 个 0 和 1,中间 1 个节点是 0.5。这样得到的曲线首尾端点恰好落在第一个和最后一个控制点上,视觉上最容易确认代码是否正确。
3.2 在 paintEvent 里用 QPainterPath 绘制曲线
Qt 绘图的入口只能是paintEvent,不建议在其他函数里直接调用QPainter往设备上画。自定义控件里最常见的做法是把采样循环放在paintEvent中,用QPainterPath累积线段:
void SplineWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); if ((int)m_ctlPoints.size() < m_degree + 1) { painter.drawText(rect(), Qt::AlignCenter, QStringLiteral("至少需要 %1 个控制点").arg(m_degree + 1)); return; } vector<double> U = SplineEvaluator::buildClampedKnots( static_cast<int>(m_ctlPoints.size()), m_degree); const int samples = 400; QPainterPath curvePath; for (int i = 0; i <= samples; ++i) { double u = static_cast<double>(i) / samples; QPointF pt = SplineEvaluator::eval(m_ctlPoints, U, m_degree, u); if (i == 0) { curvePath.moveTo(pt); } else { curvePath.lineTo(pt); } } painter.setPen(QPen(QColor(232, 80, 58), 2.2)); painter.drawPath(curvePath); // 控制多边形用灰色虚线,便于和曲线区分 painter.setPen(QPen(QColor(140, 140, 140), 1.0, Qt::DashLine)); painter.setBrush(Qt::NoBrush); painter.drawPolyline(m_ctlPoints.data(), static_cast<int>(m_ctlPoints.size())); }采样数 400 对屏幕显示已经非常平滑,没必要用几千个点。曲线路径和控制多边形分两支画笔绘制,颜色一暖一灰、线宽一粗一细,视觉分层更清楚。QPainterPath在重复构造时会有开销,但这套代码只在重绘事件里执行,控制点不多时完全够用。
3.3 鼠标事件:把点加入、移除逻辑隔离在重绘之前
曲线编辑器不能只看图,还要能改控制点。这里用左键添加点,右键删除离鼠标最近的点:
void SplineWidget::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::LeftButton) { m_ctlPoints.push_back(event->pos()); } else if (event->button() == Qt::RightButton) { int hitIndex = -1; double bestDist = 32.0 * 32.0; for (int i = 0; i < (int)m_ctlPoints.size(); ++i) { double dx = event->pos().x() - m_ctlPoints[i].x(); double dy = event->pos().y() - m_ctlPoints[i].y(); double d2 = dx * dx + dy * dy; if (d2 < bestDist) { bestDist = d2; hitIndex = i; } } if (hitIndex >= 0) { m_ctlPoints.erase(m_ctlPoints.begin() + hitIndex); } } update(); }删除判定用距离平方,省去开根号,性能更好。阈值 32 像素是一个比较宽松的热区,在非高分屏上体验尚可;如果要支持高分屏,建议用event->position()和devicePixelRatioF()做坐标换算。添加点后只调用update(),不直接调repaint(),因为update()会把多次鼠标事件合并成一次重绘,避免频繁拖拽时 CPU 飙高。
4. 让 QT 曲线形态可调:阶数、节点向量和采样精度
4.1 打开工程后先改曲线次数,确认局部支撑特性
B 样条和贝塞尔曲线的最大区别是局部支撑:移动一个控制点,只有附近一段曲线改变,其他段不受影响。影响范围由曲线次数决定。p=1 时曲线就是控制点连线,移动一个点只影响相邻两条线段;p=3 时影响范围扩大到相邻 4 个控制点之间的区间,这是工程里最常用的次数。
下面这个表格可以直接放在工程注释里,帮助快速理解参数数量关系:
| 曲线次数 p | 控制点数量 N | 节点数组长度 | 视觉效果 |
|---|---|---|---|
| 1 | 5 | 7 | 折线,完全经过控制点 |
| 2 | 5 | 8 | 连续一阶可导,拐点少 |
| 3 | 5 | 9 | 连续二阶可导,曲线最平滑 |
| 3 | 8 | 12 | 控制点增加,局部微调更精细 |
如果你拿到一个B_Spline.zip工程,界面里可能写死了次数为 3。把它改成 2 或 4 再拖拖动控制点,会立刻看到曲线变“紧”或变“松”,这个实验比读任何公式都有用。
4.2 节点向量加重复节点,让曲线更贴近某个控制点
节点向量中两个相邻节点相等时,基函数会发生退化,曲线会被拉向对应控制点。极端做法是把中间某个节点重复 p 次,曲线会直接穿过那个控制点,但会牺牲光滑性。
// 在第 k 个位置插入一个重复节点 vector<double> U = SplineEvaluator::buildClampedKnots(N, p); double target = 0.4; vector<double> U2; for (double u : U) { U2.push_back(u); if (u >= target - 1e-6 && u <= target + 1e-6) { U2.push_back(u); // 手动制造重复 } }这种方式会产生不等距节点向量,用之前要求的 de Boor 算法时不需要改代码,因为算法本身对重复节点是兼容的。真正要小心的是递归基函数版本,它遇到重复节点时会出现分母为 0,所以本章的插入代码必须配合 de Boor 版本使用。
4.3 均匀节点 vs 弦长参数化:拖拽体验差异
buildClampedKnots生成的均匀节点,在控制点间距差异大时会让曲线在某段明显“拱起”。更稳的方案是弦长参数化:按相邻控制点之间的实际距离分配节点值,这样曲线在疏密不均匀的控制点网格上更贴近折线轮廓。
vector<double> buildChordLengthKnots(const vector<QPointF>& P, int p) { int n = static_cast<int>(P.size()) - 1; vector<double> chord(n + 1, 0.0); for (int i = 1; i <= n; ++i) { double dx = P[i].x() - P[i - 1].x(); double dy = P[i].y() - P[i - 1].y(); chord[i] = chord[i - 1] + std::sqrt(dx * dx + dy * dy); } double total = chord[n]; if (total <= 0.0) { return SplineEvaluator::buildClampedKnots(n + 1, p); } vector<double> U(n + p + 2, 0.0); for (int i = 0; i <= p; ++i) U[i] = 0.0; for (int i = n; i < (int)U.size(); ++i) U[i] = 1.0; for (int j = 1; j <= n - p; ++j) { double sum = 0.0; for (int i = j; i <= j + p - 1; ++i) { sum += chord[i]; } U[j + p] = sum / (p * total); } return U; }逻辑不复杂:先求每个控制点累计弦长,再把中间节点设成相邻 p 段弦长平均值的比例。换成这段代码后,控制点分布不均的曲线不会再出现局部鼓包。默认工程如果只提供均匀节点,建议把这里的差异写进注释,方便后来的人知道还有这种选择。
5. 曲线正确性自检与 QT 绘图的一个实际优化
验证 B 样条曲线实现是否正确,不需要高端仪器。先用已知结论:clamped p 次曲线的首尾点必须等于首尾控制点。再加一条:如果用一条圆形轨迹生成控制点,采样计算半径,最大偏差应该在很小的数值范围内,否则就是节点向量长度或基函数索引出了问题。
double maxError = 0.0; double R = 200.0; for (int i = 0; i < 8; ++i) { double angle = 2.0 * 3.14159265 * i / 8.0; m_ctlPoints.push_back(QPointF(center.x() + R * cos(angle), center.y() + R * sin(angle))); } vector<double> U = buildClampedKnots(8, 3); for (int s = 0; s < 1000; ++s) { double u = s / 999.0; QPointF q = evalBSpline(m_ctlPoints, U, 3, u); double r = QLineF(center, q).length(); maxError = std::max(maxError, std::fabs(r - R)); } qDebug() << "max radius error = " << maxError;8 个控制点逼近圆形的误差通常在几个像素以内,但如果节点向量长度少一位,误差会变成几十甚至几百。这类自检代码非常适合放在main.cpp里用QDebug输出,比肉眼盯着曲线形状可靠得多。
绘图优化的核心习惯是:不要在paintEvent中反复生成节点向量。控制点没变化时,节点数组不会变,所以把它缓存成一个成员变量,只在上一次点数或次数改变时重建。拖动控制点时只改变QPointF数值,QPainterPath仍要重画,但省掉vector分配就能明显降低qt绘图的 CPU 占用;控制点数量超过几百个后,还可以把采样点直接存入QPolygonF缓存在成员里,重绘只调drawPolyline。另一点容易引起qt崩溃:删除控制点时不要用std::remove_if配合反向迭代器边遍历边删除,先用前面的hitIndex方法找到位置再 erase,避免迭代器失效,这是 Qt 鼠标事件回调里最隐蔽的崩溃来源之一。
本文还有配套的精品资源,点击获取