简介:《计算机图形学》教学课件以自由曲线与曲面为核心,系统讲解参数矢量方程、矢函数求导、自然参数方程、曲率与法矢量等数学基础,并梳理从Ferguson参数三次曲线到Bezier、B样条及NURBS的表示方法发展脉络,适合高校学生与图形学初学者用于课程学习或复习巩固。资源为单个PDF文件,大小约18.96MB,内容覆盖曲线曲面分类、标准与拟合造型、曲面切矢法矢等关键知识点,配合课件中的公式推导与图示说明,便于读者独立阅读和对照教材深入理解。该课件已吸引557人学习下载,作为兼顾理论推导与历史演进的入门级教学资料,可帮助使用者快速建立计算机图形学中曲线曲面表示的整体框架,并为后续三维建模、CAD/CAE应用打下基础。
1. 自由曲线曲面:CAD 建模绕不开的数学底子
拿到一份汽车外形点云数据,直接用折线连起来,曲面会像刀片一样割手;用标准方程硬套,又根本套不出一个水滴形车身的截面。真正让工程模型变光顺、可调整、能进入数控加工的,是自由曲线曲面这套参数化数学工具。这份《计算机图形学》教学课件正落在第六章“自由曲线曲面”上:从 1963 年 Ferguson 的参数三次曲线,到 70 年代 Bezier 和 de Boor 的 B 样条,再到 80 年代定型 NURBS,把六十年的表示法演进按数学脉络讲清楚了。对做 CAD/CAE、游戏建模、点云后处理或科学可视化的开发者来说,读它不只是补图形学理论,更是搞懂“控制点怎么摆、节点向量怎么设、连续性怎么验”这件事的起点。
2. 参数化表示与矢函数求导:自由曲线的第一块基石
2.1 为什么标准方程不够用
课件里把曲线曲面分成两类:标准曲线曲面(圆、椭圆、抛物线、球)可以用数学方程直接描述;拟合曲线曲面不行,只能靠离散数据点构造。设计场景里大量遇到的是后者——风洞实验测得的压力分布点、三维扫描仪采到的车身型面点、地质等值线图上的高程点,全是离散数据。
标准方程的一个问题是表达能力的边界:函数关系y = f(x)天然要求一个 x 对应一个 y,而车身侧面轮廓、螺旋桨叶片截面这类形状在投影面上往往是多值的(一个 x 对应好几个 y)。另一个问题是局部控制:改一个数据点,整条曲线都要重新计算,这在交互式设计里完全不可用。
课件给出的解法是引入单参数矢函数:
C(u) = [x(u), y(u), z(u)], u ∈ [0, 1]这条式子的含义很直接:不把曲线看作 x 到 y 的映射,而是看作一个动点在空间里随参数 u 运动留下的轨迹。x(u)、y(u)、z(u) 各自是 u 的标量函数,只要把 u 从 0 到 1 连续取值,就能得到曲线上每一帧的位置。参数化之后,曲线在某个点的“走向”不再依赖坐标轴方向,而是由导矢C'(u)决定。
我一般会把 u 的区间统一归一化到 [0,1],原因后面参数表示一节会提到:区间 [a,b] 到 [0,1] 的仿射变换只引入一个缩放因子,不影响曲线形状,却能让后续的矩阵运算和程序接口统一。
2.2 矢函数求导与切矢:算“往哪走”而不是“在哪儿”
课件里推导了一个关键操作:参数 u 增加 Δu 时,位置矢量从 C(u) 移到 C(u+Δu),割线方向是[C(u+Δu) - C(u)] / Δu;当 Δu 趋于 0,这个方向就把割线转成切线,得到一阶导矢C'(u)。
工程上这个操作的意义是:给定任意参数曲线,想求曲线上某一点的切线方向,不是去解方程,而是对三个分量分别求导,再合成矢量。下面用 SymPy 演示一个空间曲线的切矢计算:
import sympy as sp u = sp.symbols('u', real=True) # 定义一条三次空间曲线 C(u) = [u^2, u^3, sin(u)] Cu = sp.Matrix([u**2, u**3, sp.sin(u)]) # 一阶导矢:切矢方向 dCdu = sp.diff(Cu, u) dCdu_simplified = sp.simplify(dCdu) print("位置矢量 C(u):", Cu.T) print("切矢 C'(u):", dCdu_simplified.T)代码逻辑:sp.diff对矩阵的每个分量独立求导,返回的是导矢的符号表达式。以 u=0.5 为例,位置矢量是 (0.25, 0.125, 0.479),切矢是 (1.0, 0.75, 0.878),把切矢归一化后就能得到该点的单位切线。
这里要区分一个常见误用:切矢C'(u)的方向随参数化方式改变。同一段曲线,用弧长参数化和用均匀参数化得到的切矢模长不同,但方向一致。课件在 2.3 节引入自然参数方程,正是为了解决这个模长不确定的问题。
2.3 自然参数方程与弧长:让切矢变成单位矢量
自然参数方程的核心是用弧长 s 代替任意参数 u:C = C(s)。课件给的弧长微分公式是:
(ds)^2 = (dx)^2 + (dy)^2 + (dz)^2引入参数 u 后改写为ds/du = |C'(u)|,即弧长对参数的变化率等于切矢的模长。这个式子给出了从任意参数化转换到自然参数化的桥梁:先算出整条曲线的弧长函数 s(u),再反解 u(s)。
在纯数值程序里,我不会真的去解析求反函数,而是直接做累计弦长近似。对离散点列,弧长的数值近似就是相邻点欧氏距离的累加:
import numpy as np # 离散型值点:每行一个三维坐标 pts = np.array([ [0.0, 0.0, 0.0], [0.2, 0.4, 0.1], [0.5, 0.8, 0.3], [0.9, 1.2, 0.6], [1.4, 1.5, 0.9] ]) # 相邻点距离:numpy.linalg.norm 对每个行向量求模 seg_lens = np.linalg.norm(np.diff(pts, axis=0), axis=1) # 累计得到弧长参数 s,首点 s=0 s = np.concatenate([[0.0], np.cumsum(seg_lens)]) print("累计弧长:", s) print("总弧长:", s[-1])参数说明:np.diff(pts, axis=0)计算相邻点差值,axis=1让norm对每个差值向量取模得到一段微弧长;np.cumsum做累计求和,结果 s 近似代表了“到第 i 个点为止的弧长”。这段代码在课件里对应的就是“用弧长积分公式或累计弦长公式计算 s”这句话。
把 s 归一化后重新作为参数对曲线插值,切矢模长就稳定了。这个操作在后续曲率计算里极其重要,因为曲率公式κ = |C''(s)|只在自然参数下成立。
3. 曲率、法矢量与连续性:判断曲面光不顺的三把尺子
3.1 Frenet 标架:切矢、主法矢、副法矢
有了自然参数方程 C(s),课件推导出第一条重要性质:单位切矢T(s) = C'(s)的模长为 1,对两边平方求导得到T(s) · T'(s) = 0,说明T'(s)垂直于 T(s)。于是存在标量函数 κ(s) 与单位矢量 N(s),满足:
T'(s) = κ(s) · N(s)κ(s) 是曲率,N(s) 是主法矢(指向曲线凹入方向),再由B(s) = T(s) × N(s)定义副法矢。这三者构成曲线在每一点的 Frenet 活动标架,是后续曲面切平面、法矢计算的基础。
工程上,主法矢的方向直接决定了曲线弯曲朝向哪一侧。在车身 A 柱设计中,主法矢方向要平滑过渡,否则光照下会出现高光断层。副法矢 B(s) 则常用于判断曲线是否发生扭转——如果 B 的方向出现跳变,说明曲线在空间里发生了尖点式的屈曲。
对离散数据点,无法直接求二阶导,常见做法是用三点外接圆估计曲率:
import numpy as np def curvature_from_three_points(p0, p1, p2): """ 通过三点外接圆半径估算曲率。 p0, p1, p2: 相邻的三个数据点,形状为 (3,) """ # 径向向量 v1 = p0 - p1 v2 = p2 - p1 # 叉积模 = |v1|*|v2|*sin(夹角),对应外接圆面积二倍 cross_norm = np.linalg.norm(np.cross(v1, v2)) # 三边边长 a = np.linalg.norm(v1) b = np.linalg.norm(v2) c = np.linalg.norm(p2 - p0) # 外接圆半径 R = abc / (4 * 面积),曲率 = 1/R if cross_norm < 1e-12: return 0.0 # 三点共线,曲率为 0 radius = (a * b * c) / (2.0 * cross_norm) return 1.0 / radius # 测试:取三点构成一个半径约 1 的圆弧 p0 = np.array([1.0, 0.0, 0.0]) p1 = np.array([0.0, 1.0, 0.0]) p2 = np.array([-1.0, 0.0, 0.0]) kappa = curvature_from_three_points(p0, p1, p2) print("估算曲率:", kappa) # 约 1.0参数说明:np.cross计算三维叉积,模长是平行四边形面积的两倍,对应外接圆面积公式里的“2×面积”项。1e-12是共线容差,实际数据里要根据点云尺度调整。这段代码直接把课件里ρ(s) = 1/κ(s)的符号定义转成了可运行的数值计算。
3.2 参数连续性与几何连续性:C 连续和 G 连续差在哪
课件明确区分了两种连续性:参数连续性(C 阶数)和几何连续性(G 阶数)。多段曲线拼接时,这是判断“接缝处是否平滑”的核心标准。
| 连续性 | 数学条件 | 工程含义 | 常见问题 |
|---|---|---|---|
| C0 / G0 | 端点位置重合 | 曲线不断开 | 端点坐标误差 |
| C1 / G1 | 切矢方向一致(G1 不要求模长相等) | 切线方向没折角 | 速度突变但方向平滑 |
| C2 / G2 | 二阶导矢方向一致(G2 对应曲率连续) | 曲率不跳变,高光连续 | 曲率梳断层、反光撕裂 |
| Cn | n 阶导矢连续 | 更高阶光滑 | 计算成本随 n 上升 |
关键区别:C1 要求C1'(u)和C2'(u)在连接点完全相等,包括模长;G1 只要求方向平行。类比理解,G1 连续是两条路在路口方向一致,但车速可以不同;C1 连续要求到达路口的瞬间速度矢量也完全一样。对 CAD 曲面来说,G2 是外观质量的入场券,C2 是进一步做动力学仿真时的数学保险。
3.3 用曲率梳做可视化验证
曲率光不顺、高光断裂这类问题,单看控制点网格基本看不出来。我常用的验证手段是画曲率梳(curvature comb):在曲线每个采样点沿法矢方向画一条长度等于该点曲率 k 倍的短线,曲率变化剧烈的地方,梳齿长度会突然变长或反向。
import matplotlib.pyplot as plt # 沿用上面的三点曲率估计函数,对整条曲线逐点计算 # sample_x, sample_y: 曲线采样点坐标 # normals: 每点的法矢(由切矢旋转 90 度得到) # curvatures: 每点曲率 # 绘制曲率梳:短线的长度 = 曲率 * 缩放系数 scale = 10.0 for i in range(len(sample_x)): dx = normals[i][0] * curvatures[i] * scale dy = normals[i][1] * curvatures[i] * scale plt.plot([sample_x[i], sample_x[i] + dx], [sample_y[i], sample_y[i] + dy], color='steelblue', linewidth=0.8)逻辑说明:曲率梳本质是把标量曲率映射为可视化长度。缩放系数scale需要按曲线整体尺度调整:模型尺寸大(如船体)时取大值,小零件取小值,否则梳齿要么缩成一团、要么穿出画面。曲率梳在连接点上的齿长突变,基本就对应 G2 连续被破坏。
4. 从 Ferguson 到 NURBS:六十年自由曲线表示法演进
4.1 三代表示法与各自的痛点
课件里有一段“曲线曲面数学描述的发展”时间线,这是全篇信息密度最高的部分之一。把这些年表拆开看,本质是三个问题在推动演进:如何用尽量少的控制参数描述复杂形状、如何让局部修改不波及其他区域、如何统一表示自由曲线与圆锥曲线。
| 年代 | 提出者/机构 | 方法 | 核心思想 | 代价或局限 |
|---|---|---|---|---|
| 1963 | Ferguson(波音) | 参数三次曲线 | 用两个端点位置与切矢定义曲线 | 中间形状不可控 |
| 1964 | Coons(MIT) | 双三次曲面片 | 四条边界曲线定义曲面 | 跨界连续性难保证 |
| 1964 | Schoenberg(宾大) | 参数样条曲线 | 分段多项式拼接 | 数学复杂,缺乏直观控制手段 |
| 1971 | Bezier(雷诺) | 控制多边形 | 曲线被控制点“拉拽” | 全局性:动一点全曲线变 |
| 1972 | de Boor(威斯康星) | B 样条标准算法 | 局部支撑、节点向量分段 | 节点向量设置不直观 |
| 1980s | Piegl / Tiller | NURBS | 非均匀有理 B 样条,带权重 | 参数维度多,调权重大 |
从博文读者角度看,这个演进刚好回答了“为什么工业软件里的曲线叫 NURBS 而不是 Bezier”:Bezier 全局拖动的问题在生产流程里不可接受——设计师只想微调车头一个控制点,结果车尾也跟着动了。B 样条引入节点向量,把曲线分成多段,每段只受邻近控制点影响,局部支撑特性才让它成为几何内核的现实选择。
4.2 Bezier 与 de Casteljau:先跑通最简单版本
不管最终用不用 Bezier,我建议先实现一遍 de Casteljau 递推。它逻辑最短,却能快速验证你对控制点影响的直觉:
import numpy as np def de_casteljau(points, u): """ points: 控制点列表,如 [P0, P1, P2, P3] u: 参数值,范围 [0, 1] 返回曲线上参数 u 处的点 """ pts = [np.array(p, dtype=float) for p in points] # 逐层线性插值:每轮减少一个点 while len(pts) > 1: pts = [(1.0 - u) * pts[i] + u * pts[i + 1] for i in range(len(pts) - 1)] return pts[0] # 一条三次 Bezier 曲线,四个控制点 ctrl = [(0, 0), (1, 3), (3, -1), (4, 2)] # 采样 50 个参数点 samples = [de_casteljau(ctrl, u) for u in np.linspace(0, 1, 50)]代码说明:外层while每次迭代减少一个控制点,当只剩一个点时就是曲线上位置。线性插值系数1-u和u对应课件里的区间仿射变换思想。把 samples 画出来能直观看到:曲线起点在 P0、终点在 P3,中间点 P1、P2 像磁铁一样把曲线往自己方向拉——这就是“控制多边形定义曲线”的直观解释。
Bezier 全局性问题在于 Bernstein 基函数在整个 [0,1] 区间上非零。移动 P1 会改变所有参数 u 处的基函数值,也就是整条曲线都受影响。
4.3 B 样条:局部支撑才是 B 样条存在的理由
B 样条的核心改进是把参数域用节点向量(knot vector)分段,每段是一个低次多项式。基于递推定义:
def bspline_basis(i, k, u, knots): """ 计算第 i 个 k 次 B 样条基函数在参数 u 处的值。 knots: 节点向量,如 [0, 0, 0, 1, 2, 3, 3, 3] """ if k == 0: # 0 次基函数:在节点区间内取 1,否则取 0 return 1.0 if knots[i] <= u < knots[i + 1] else 0.0 # 递推:两项加权求和 # 前一项的分子分母;分母为 0 时该项约定为 0 coeff1 = 0.0 denom1 = knots[i + k] - knots[i] if denom1 > 0: coeff1 = (u - knots[i]) / denom1 * bspline_basis(i, k - 1, u, knots) coeff2 = 0.0 denom2 = knots[i + k + 1] - knots[i + 1] if denom2 > 0: coeff2 = (knots[i + k + 1] - u) / denom2 * bspline_basis(i + 1, k - 1, u, knots) return coeff1 + coeff2 # 测试:三次均匀 B 样条,节点向量采用 clamped 形式 knots = [0, 0, 0, 0, 1, 2, 3, 3, 3, 3] # 求第 2 个基函数在 u=1.5 处的值 value = bspline_basis(2, 3, 1.5, knots) print("N_2,3(1.5) =", value)参数说明:k是次数(3 为三次),i是节点区间索引。递推式里分母为 0(区间长度为零)时该项直接取 0,这是 B 样条处理重复节点的标准约定。了解这个原因能避免写代码时除零崩溃。
节点向量设计是 B 样条的实际难点。clamped 形式(首尾节点重复 k+1 次)让曲线通过首尾控制点,是工程最常用的方式;均匀节点向量则让曲线不经过首尾点,适合做近似拟合。
4.4 NURBS:加一个权重解决圆锥曲线的精确表示
B 样条仍受限于多项式,无法精确表示圆弧、椭圆。NURBS 的做法是对每个控制点配一个权重 w,曲线定义为:
C(u) = Σ (N_i,k(u) · w_i · P_i) / Σ (N_i,k(u) · w_i)工程收益很直接:同一套数据结构可以表示直线段、圆弧、自由曲面,不用像早期 CAD 那样在解析几何和自由格式之间来回切换。权重 w_i 调大的效果是把曲线往控制点 P_i 方向拉,调小则推远。CAD 软件里常见的“权重/丰满度”滑块,内部改的就是这个 w。
课件里提到 80 年代 Piegl 和 Tiller 的研究,本质上就是把此前分散在数值分析领域的方法标准化。现在 NURBS 已经是 STEP 交换格式、OpenGL 内部求交器、常见几何内核里的标准表达。对应用开发者来说,掌握 Bezier 和 B 样条递推这两组代码,再理解权重与节点向量两个参数维度,就足够阅读 NURBS 接口的文档了。
5. 插值、逼近与拟合:把离散点变成光顺曲线的落地实践
5.1 先分清三个概念:型值点、控制点与设计方法
课件第 19 页把“插值、逼近、拟合”放在一起做区分,这组概念在实际工程里太关键了:
- 型值点:测量或计算得到的、描述形状的离散数据点,曲线不一定通过它。
- 控制点:用于控制或调整形状的点,曲线不一定经过它。
- 插值设计:构造的曲线严格通过每个型值点。
- 逼近设计:构造的曲线近似接近型值点,但不一定通过。
- 拟合:插值或逼近方法的统称,目标是满足特定设计要求。
一个常见项目场景是:三坐标测量机采回几百个点,要建一条光滑的特征线。如果直接插值全部点,噪声会变成锯齿;如果只用少量控制点做逼近,又担心丢掉真实形状特征。我一般先做平滑预处理,再用样条插值,最后用曲率梳验证——三步走。
5.2 用 scipy 做三次样条插值
最直接可用的是CubicSpline,它默认在每段使用三次多项式,保证 C2 连续:
from scipy.interpolate import CubicSpline import numpy as np # 离散型值点(例如从 PDF 里的实验数据表格提取) t = np.array([0.0, 0.4, 0.8, 1.4, 2.1, 3.0]) # 可以是弧长参数 x = np.array([0.0, 0.6, 1.5, 2.4, 3.2, 4.0]) y = np.array([0.0, 0.3, 0.9, 1.2, 1.1, 0.8]) # 构造三次样条插值 cs_x = CubicSpline(t, x, bc_type='natural') cs_y = CubicSpline(t, y, bc_type='natural') # 重新采样,生成光顺曲线点列 t_dense = np.linspace(t[0], t[-1], 200) curve_x = cs_x(t_dense) curve_y = cs_y(t_dense)参数说明:bc_type控制边界条件,'natural'将端点二阶导置为零,曲线在两端自然趋于直线,最适合数据本身没有明确端点切矢的场景;'clamped'需要指定端点导数值,适合已知进出方向(比如汽车保险杠两端有明确的拔模方向);'periodic'用于闭合轮廓。CubicSpline已经保证了 C2 连续,可以直接拿去给数控加工路径用。
5.3 细分数、噪声与过冲:从曲率梳上看真实问题
样条插值不是点越多越好。数据点带测量噪声时,强制通过每一个点会把噪声当作真实特征放大,结果曲线出现额外波浪。一个典型体验是:扫描点云采得很密,插值后曲率梳像梳齿错位一样乱跳,零件表面反而更粗糙。
处理这类问题,常见做法是在插值前对数据做 Savitzky-Golay 平滑:
from scipy.signal import savgol_filter x_smooth = savgol_filter(x, window_length=5, polyorder=2) y_smooth = savgol_filter(y, window_length=5, polyorder=2) # 再用平滑后的点做插值 cs_x = CubicSpline(t, x_smooth, bc_type='natural')参数说明:window_length是滑动窗口大小,必须是奇数,数值越大平滑越强;polyorder是拟合多项式阶数,一般取 2~3,过大会保留噪声。窗口大小取数据点数的 1/5 到 1/10 比较安全。平滑之后再插值,结合曲率梳检查,很少出现失控过冲。
5.4 最后一步:验证而不是目测
目测曲线“看上去挺顺”在工程交付里没有说服力。收尾验证我会做三件事:
一、回代误差检查。把原始型值点带入插值后的曲线,计算最大偏差:
orig_x_restored = cs_x(t) max_err = np.max(np.abs(orig_x_restored - x))对插值型设计,这个值应该接近浮点精度;对逼近型设计,需要控制在公差范围内。
二、曲率梳输出为 SVG 或 PDF 叠加到曲线图上,交给下游工程师评审。评审标准就一条:连接点处梳齿长度是否发生跳变。
三、检查端点切矢是否与相邻曲面对齐。把cs_x.derivative()(t[-1])和cs_y.derivative()(t[-1])合成切矢,与相邻曲面边界切矢做叉积,叉积模小于容差即视为 G1 满足——这个操作直接对应课件里“切线方向连续”的几何连续性定义。
本文还有配套的精品资源,点击获取