简介:基于VB.NET实现的非均匀B样条(NURBS)曲线示例工程,面向计算机图形学与CAD建模方向的开发者,解决自定义曲线生成与控制点编辑问题。源码覆盖控制点、权重、Knot向量定义,以及曲线求值、节点插入和逆算等关键算法,适合用作二维、三维建模工具或教学演示的基础。压缩包总共59个文件,其中包括13个VB语言源程序、13个动态链接库、5个可执行程序、4个XML配置文件以及项目结构、窗体资源、调试符号等辅助内容;源码内包含NL_B_Spline系列类、窗体界面与二维绘制模块,相关文件齐备,整体大小仅213KB,结构紧凑清晰。已有184人学习浏览。通过该工程可以直观理解非均匀B样条的基函数计算与节点规律,为后续扩展NURBS曲面或对接图形渲染引擎提供可复用代码,并便于二次开发。
1. 非均匀B样条曲线:为什么VB.NET反而是高效的落地选择
非均匀B样条曲线在CAD/CAM和测量数据拟合里是绕不开的基础算法,但大多数教程默认你用C++或C#来写。我第一次在项目中需要把非均匀B样条曲线做成交互式桌面工具时,选了VB.NET和WinForms,这个选择被同事质疑过,实际推进却发现:GDI+绘图、控件数据绑定、数学类库的配合非常自然,曲线重绘和鼠标拖拽控制点几乎不需要额外框架。这套源码就是这样一个完整的桌面工具,你输入一组控制点坐标,程序自动生成节点向量、递推基函数、反算控制点,并在画布上实时绘制曲线。适合正在做样条拟合、刀具路径预览或二维曲线编辑器的人,也适合想把B样条底层搞懂再封装成自己工具的人。
2. 算法底座与工程结构:节点向量、基函数与模块划分
2.1 非均匀B样条和均匀B样条的分水岭:节点向量
一条p次B样条曲线定义为:C(u)等于N(i,p)(u)与P(i)的加权和,P(i)是控制点,N(i,p)(u)是p次基函数,u的取值范围由节点向量决定。节点向量就是非均匀和均匀的分界点。均匀样条的节点是[0,1,2,3,...]这样等距排列,节点间隔完全相等;非均匀样条的节点间隔不一致,而且通常首尾各保留p加1个重复节点,让曲线强制经过首尾控制点。
为什么强调非均匀?因为等距节点在处理分布不均匀的控制点时会出现“削峰”现象,控制点密集区域的细节被平滑掉。用累积弦长重新分配节点后,每个节点区间长度和实际数据点之间的距离成正比,曲线才更贴合真实形状。这就是“非均匀”三个字的核心价值:节点向量变成了控制曲线形状的第二个旋钮。
Public Function BuildChordKnots(controlPoints As List(Of PointF), degree As Integer) As Double() Dim n As Integer = controlPoints.Count - 1 Dim m As Integer = n + degree + 1 Dim knots(m) As Double For i As Integer = 0 To degree knots(i) = 0.0 knots(m - i) = 1.0 Next Dim totalLen As Double = 0.0 For i As Integer = 1 To n totalLen += EuclideanDistance(controlPoints(i - 1), controlPoints(i)) Next Dim cum As Double = 0.0 Dim innerIndex As Integer = degree + 1 For i As Integer = 1 To n - degree cum += EuclideanDistance(controlPoints(i - 1), controlPoints(i)) knots(innerIndex) = cum / totalLen innerIndex += 1 Next Return knots End Function这里有几个参数要解释清楚:controlPoints是控制点列表,degree是B样条次数。n是控制点最大下标,m是节点向量末下标,节点向量总长度是m加1,满足m等于n加degree加1这个基本约束。外层循环把首尾各degree加1个节点分别设为0和1,内层循环则从第一个到第n-degree个分段距离累加出中间节点,最后节点值落在0到1之间。这样生成的节点向量中,内部节点间距与控制点的实际距离成正比,也就是累积弦长参数化。
2.2 基函数递推:Cox-de Boor递归公式
基函数是所有B样条计算的地基。B样条的基函数用Cox-de Boor公式递推定义:零次基函数在节点区间内取1,其他为0;高次基函数由两个低次基函数线性组合得到。这个递归形式写起来很直观,也最适合和教材公式对照学习。
Public Function Basis(i As Integer, k As Integer, u As Double, knots As Double()) As Double If k = 0 Then If u >= knots(i) AndAlso u < knots(i + 1) Then Return 1.0 Else Return 0.0 End If Else Dim coef1 As Double = 0.0 Dim coef2 As Double = 0.0 If knots(i + k) - knots(i) > 0 Then coef1 = (u - knots(i)) / (knots(i + k) - knots(i)) * Basis(i, k - 1, u, knots) End If If knots(i + k + 1) - knots(i + 1) > 0 Then coef2 = (knots(i + k + 1) - u) / (knots(i + k + 1) - knots(i + 1)) * Basis(i + 1, k - 1, u, knots) End If Return coef1 + coef2 End If End Function参数i是基函数序号,k是剩余次数,u是参数值。零次情况判断u是否落在第i个节点区间内,注意右边界是开区间,这是为了保证每个u只属于一个区间。高次情况里两个分母为0的项会被跳过,因为节点重复时对应系数在数学上按0处理。这个边界处理是几乎所有B样条越界bug的来源,后面避坑章节会专门讲。
当然递归的缺点也明显:一次曲线点计算要触发大量函数调用。项目里的BSplineCurve类实际使用的是De Boor递推算法,比直接累加基函数更稳定高效。这里保留递归版本,主要是为了单步调试和理解数学定义。
2.3 工程目录结构:算法层和UI层分离
拿到源码包后先看目录,整体结构是这样的:
BSplineLab/ ├─ BSplineLab.sln └─ BSplineLab/ ├─ App.config ├─ MainForm.vb ├─ MainForm.Designer.vb ├─ KurveMath/ │ ├─ KnotVectorBuilder.vb │ ├─ BasisFunction.vb │ ├─ BSplineCurve.vb │ └─ ControlPointSolver.vb └─ UI/ └─ CurvePanel.vbKurveMath是纯算法命名空间,不引用任何WinForms类型,这是这套源码最值得保留的设计。KnotVectorBuilder负责生成节点向量,BasisFunction提供基函数递归计算,BSplineCurve封装FindSpan和De Boor求值,ControlPointSolver负责反算控制点。UI目录下的CurvePanel是自定义控件,专门处理鼠标路由和画布绘制。MainForm只做菜单、参数面板和状态栏。
这个分离带来的直接好处是:算法层可以单独做单元测试,也可以整体搬到控制台或类库项目而不碰UI代码。后面二次开发章节会进一步说明怎么把这一层拆出来。
3. 核心代码拆解:从控制点到曲线点的完整计算链路
3.1 FindSpan与De Boor递推:曲线点的标准求法
计算B样条曲线上任意参数u对应的点,第一步是在节点向量中定位u落在哪个节点区间,这个定位动作叫FindSpan。项目里用二分搜索实现,边界条件处理是重点。
Public Function FindSpan(n As Integer, degree As Integer, u As Double, knots As Double()) As Integer If u >= knots(n + 1) Then Return n If u <= knots(degree) Then Return degree Dim low As Integer = degree Dim high As Integer = n + 1 Dim mid As Integer = (low + high) \ 2 While u < knots(mid) OrElse u >= knots(mid + 1) If u < knots(mid) Then high = mid Else low = mid End If mid = (low + high) \ 2 End While Return mid End Functionn是控制点最大下标,degree是次数。当u等于最大节点值时直接返回n,这个判断不能省,否则后面访问knots(mid+1)时可能越界。找到区间后,De Boor算法从degree加1个相邻控制点开始,逐层做线性插值,每降一阶点数少一个,最后剩下一个点就是曲线点。
Public Function ComputePoint(controlPoints As PointF(), degree As Integer, knots As Double(), u As Double) As PointF Dim n As Integer = controlPoints.Length - 1 Dim span As Integer = FindSpan(n, degree, u, knots) Dim d(degree) As PointF For j As Integer = 0 To degree d(j) = controlPoints(span - degree + j) Next For r As Integer = 1 To degree For j As Integer = degree To r Step -1 Dim idx As Integer = span - degree + j Dim denominator As Double = knots(idx + degree - r + 1) - knots(idx) Dim alpha As Double = 0.0 If denominator <> 0 Then alpha = (u - knots(idx)) / denominator d(j).X = alpha * d(j).X + (1 - alpha) * d(j - 1).X d(j).Y = alpha * d(j).Y + (1 - alpha) * d(j - 1).Y Next Next Return d(degree) End Functionalpha是一个0到1之间的比例系数,表示当前点在该层插值中的位置。内层循环必须从degree向下到r反过来更新,因为后一个点的计算要依赖前一轮的值,正序会覆盖掉中间结果。这个反向更新是De Boor实现里最容易写错的地方,一旦顺序错,曲线会出现奇怪的突变。
3.2 控制点反算:插值模式下的线性方程组求解
很多实际场景拿到的不是控制点,而是一组“希望曲线穿过”的型值点。比如在界面上手绘一条折线,要求B样条拟合后经过折线顶点。此时需要反算控制点。
原理是让C(u(i))等于Q(i),也就是把每个型值点代入B样条方程,得到一个以控制点为未知数的线性方程组。系数矩阵第i行就是各个基函数在参数u(i)处的取值。项目里用弦长参数化生成节点向量,再为每个数据点分配参数值,然后解方程组。
Public Function FitControlPoints(dataPoints As PointF(), degree As Integer, knots As Double()) As PointF() Dim n As Integer = dataPoints.Length - 1 Dim mat(n, n) As Double Dim rhsX(n) As Double Dim rhsY(n) As Double For i As Integer = 0 To n Dim u As Double = GetChordParam(i, dataPoints) For j As Integer = 0 To n mat(i, j) = BasisFunction.Basis(j, degree, u, knots) Next rhsX(i) = dataPoints(i).X rhsY(i) = dataPoints(i).Y Next Dim solver As New LinearSolver(mat, rhsX, rhsY) solver.Solve() Return solver.GetResult() End FunctiondataPoints是型值点数组,degree是次数,knots是已经构建好的节点向量。GetChordParam根据数据点在弦长中的累计比例返回参数u。这里用的是稠密矩阵解算,因为非均匀节点会让系数矩阵失去严格三对角结构;如果控制点数量上千,实际工程会改成带状矩阵存储和带状高斯消元,内存占用能从O(n平方)降到O(n乘degree)。
3.3 实时绘制:Paint事件和鼠标操作的配合
曲线交互是这套源码体验的核心。CurvePanel重写了OnPaint,每次参数变化都触发刷新,绘制逻辑分两层:先画控制点和连线,再按固定采样步长取曲线点画折线。
Private Sub CurvePanel_Paint(sender As Object, e As PaintEventArgs) Handles CurvePanel.Paint Dim g As Graphics = e.Graphics g.SmoothingMode = Drawing2D.SmoothingMode.AntiAlias For i As Integer = 0 To controlPoints.Count - 1 g.FillEllipse(Brushes.Red, controlPoints(i).X - 3, controlPoints(i).Y - 3, 6, 6) Next Dim stepCount As Integer = 200 Dim pts As New List(Of PointF)() For i As Integer = 0 To stepCount Dim u As Double = knots(degree) + (knots(n + 1) - knots(degree)) * i / stepCount pts.Add(curve.ComputePoint(controlPoints.ToArray(), degree, knots, u)) Next g.DrawLines(Pens.Blue, pts.ToArray()) End SubstepCount取200是默认值,高次数或尖锐曲线建议增到400。采样点之间用直线连接,因为采样密度足够时视觉上就是光滑曲线。鼠标事件上,点击添加控制点、按住拖动控制点、右键删除控制点。拖动时只更新控制点坐标,不重建节点向量,除非用户勾选了“按控制点实时重设节点向量”——这个交互细节直接决定了拖动手感,后面避坑部分会展开。
4. 编译运行与界面操作:从源码到一条能拖动的曲线
4.1 编译环境与命令行构建
运行环境要求Windows 10或更高版本,Visual Studio 2022,安装“NET 桌面开发”工作负载。打开BSplineLab.sln后直接按F5就能跑起来。如果没有装完整IDE,也可以用命令行构建,但需要先打开“开发者命令提示符”:
msbuild BSplineLab.sln /p:Configuration=Debug /p:Platform="Any CPU"Debug配置输出在bin\Debug目录下,Release构建则用/p:Configuration=Release,生成的exe可以单独复制到其他机器运行。第一次编译如果报缺少引用,通常是因为KurveMath文件夹下的.vb文件没有包含进项目,右键项目选择“添加现有项”补进来即可。
4.2 界面操作要点
界面布局分三块:左侧是CurvePanel画布,右侧是参数面板,顶部是工具条。工具条上有两个互斥模式:添加模式,点击画布空白处新增控制点;选择模式,点击控制点后按住拖动,右键删除当前点。参数面板里有三个核心控件:次数TrackBar范围从1到5,节点分布ComboBox提供clamped和chord两种模式,采样数NumericUpDown控制曲线平滑度。
建议上手顺序是:先切到添加模式,随意点四个点,看到一条默认的二次B样条曲线出现;然后切到选择模式,拖动其中一个控制点,观察曲线局部变化;接着把次数从2调到3,对比C1连续和C2连续的区别;最后切换节点分布方式,感受均匀节点与累积弦长节点的差异。
4.3 参数选择和效果对比
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 次数=1 | 调试用 | 曲线退化为控制点折线 |
| 次数=2 | 常用 | 曲线平滑且对噪声不敏感 |
| 次数=3 | 工程标准 | C2连续,CAD最常见 |
| 次数>=5 | 谨慎 | 容易出现振荡和数值不稳定 |
| clamped | 默认 | 首尾节点重复,曲线过首尾控制点 |
| chord | 非均匀数据 | 节点距离按控制点连线长度分配 |
| 采样数 | 200~400 | 越大曲线越平滑,性能仍可接受 |
次数和节点分布是相互影响的。次数太高时,控制点数量必须足够多,否则方程组欠定,反算出来的控制点会剧烈摆动。采样数只影响显示精度,不影响曲线数学定义,所以调高采样数只是让曲线看起来更连接平滑,不会改变真实曲线位置。这里有个经验:实际项目里90%的情况用三次B样条加chord节点分布就够了。
5. 避坑与常见问题排查:运行这套源码前必须知道的5个细节
5.1 曲线首尾没有经过控制点
现象:明明选了clamped模式,曲线首尾却没有贴住第一个和最后一个控制点,而是缩进去一小截。
原因:clamped模式的本质是节点向量首尾各重复degree加1个节点。如果重复度不够,或者代码里只用0和1各加一次,曲线首尾就不会经过控制点。这种错误在视觉上非常隐蔽,因为偏差通常不大。
解决:打印节点向量数组,检查前degree加1个值是否都是0,后degree加1个值是否都是1。项目里的BuildChordKnots已经保证了这一点,但如果你在二次开发时手工改节点向量,这个检查必须保留。
5.2 参数u等于1.0时数组越界
现象:程序运行到最后一段曲线时报IndexOutOfRangeException,每次采样到最后一个点必崩。
原因:基函数定义里节点区间是左闭右开,即u在[knots(i), knots(i+1))内取1。当u正好等于最大节点值1.0时,它不属于任何一个左闭右开区间,FindSpan如果不做边界处理,就会试图访问knots(n+2)之类的越界下标。
解决:在FindSpan入口加特判,u大于等于knots(n+1)直接返回n。同时在采样循环里,把最后一个采样值写成knots(n+1)减去一个极小量,比如1e-7,从根源上避免u落在边界上。
5.3 拖动控制点时曲线乱跳
现象:在画布上拖动一个控制点,整个曲线像面条一样剧烈摆动,松手后才稳定下来。
原因:这是交互设计问题。如果每次拖动都调用累积弦长重新生成节点向量,那么控制点位置一变,节点向量也跟着变,等于每一帧都换了一套参数域。曲线点的密度分布全变了,视觉上就像乱跳。
解决:默认情况下,拖动只更新控制点坐标数组,不重建节点向量。只有在增加或删除控制点时,才重构节点向量。项目里把“重建节点”和“更新控制点”这两个操作拆成了两个方法,改代码时一定不要图省事合并成一次调用。
5.4 次数过高导致曲线震荡
现象:把degree调到6或7,曲线开始像示波器一样上下摆动,插值模式下甚至解不出控制点。
原因:高次B样条基函数的数值稳定性随次数指数下降,舍入误差被放大。同时,如果控制点数量比degree加1大不了多少,方程组近似病态,反算结果自然不可用。
解决:UI层限制次数最大为5,并在插值模式里校验数据点数量必须大于degree加1,否则弹提示。这个限制不是偷懒,是工程上经过验证的安全阈值。
5.5 画布上曲线上下颠倒
现象:数据点的Y坐标越大,曲线反而画得越低,整个图形像被翻转过。
原因:数学坐标系里Y轴向上,而Windows窗体屏幕坐标系的Y轴向下。直接拿世界坐标点去GDI+绘制,自然上下颠倒。
解决:CurvePanel里统一走一个世界坐标转屏幕坐标的函数,把所有点先变换再画。修改这个函数就能整体翻转、平移或缩放,不要在Paint事件里到处散落坐标变换代码,否则后续维护会很痛苦。
提示:如果遇到“无法找到类型或命名空间”的编译错误,先检查KurveMath文件夹里的.vb文件有没有包含进项目文件列表。VB.NET项目默认只编译工程里显式列出的文件,手动复制目录时很容易漏掉,这是最常见的首次编译失败原因。
6. 二次开发改造思路:把算法层抽成类库并扩展到NURBS
这套源码最大的改造价值在于算法层和UI层已经分离。我的建议是:先把KurveMath四个类抽成独立类库项目,目标框架选netstandard2.0,这样WinForms、WPF、控制台甚至WebAPI都能引用。抽库时注意把BasisFunction里的递归改成迭代或加缓存,否则服务端高频调用性能会翻车。
第二步值得做的扩展是NURBS,也就是给每个控制点加一个权重w。普通非均匀B样条没有权重,曲线点计算公式是C(u)等于基函数加权控制点;NURBS在此基础上多了一个分母,分子是所有带权控制点之和,分母是基函数与权重乘积之和。体积上只多了一次权重累加和一次除法,但能力上多出了精确表示圆弧、椭圆等解析曲线的可能性。
Public Function ComputeRationalPoint(controlPoints As PointF(), weights As Double(), degree As Integer, knots As Double(), u As Double) As PointF Dim pt As PointF = ComputePoint(controlPoints, degree, knots, u) Dim w As Double = 0.0 For j As Integer = 0 To controlPoints.Length - 1 w += BasisFunction.Basis(j, degree, u, knots) * weights(j) Next pt.X /= w pt.Y /= w Return pt End Function验证NURBS改造是否成功的方法很直接:取一个圆弧截面,输入5个控制点和对应权重,生成的曲线在100个采样点上到圆心的距离误差应该低于0.1像素。误差超标就检查权重数组的归一化逻辑,这是NURBS里最常见的问题来源。
这套源码我后来反复改过多次,最大的教训不是算法本身,而是边界条件。从那以后我每次入手样条源码,第一件事就是打印节点向量,把首尾重复区间画成竖线对着看,再跑一遍反算验证,然后才碰UI。希望帮到你。
本文还有配套的精品资源,点击获取