样条曲线

在 3D 游戏开发和角色动画中,如何让一个物体、摄像机,或者角色的肢体摆脱生硬的直线运动,划出一条丝滑、自然的弧线?

答案就是:样条曲线,Spline。


什么是样条曲线?

简单来说,样条曲线(Spline)是由多项式分段定义出来的曲线。

所谓多项式,形式上并不神秘:就是一个变量使用不同的指数进行计算,然后把结果加总。比如一个典型的三次多项式:

h_{00}(t) = 2t^3 - 3t^2 + 1

这里的 t 是一个插值参数。为了方便动画系统处理,我们经常把它归一化到:

t \in [0, 1]

t=0 时表示曲线的起点,当 t=1 时表示曲线的终点,中间的值表示“走到这段曲线的百分之多少”。

在图形学中,最常登场的样条曲线大致有三类:

  • B-样条(B-splines)
  • 贝塞尔曲线(Bezier)
  • 埃尔米特样条(Hermite splines)

它们的底层都是多项式,只是控制方式不同。

贝塞尔曲线很适合做美术编辑和路径设计,因为它的控制点非常直观,也可以用 De Casteljau 算法通过几何方式构造。B-样条适合做更平滑、更稳定的多段曲线。本文的主角是工业动画系统里非常常见的立方埃尔米特样条(Cubic Hermite Spline)。

为什么叫 Cubic?因为曲线公式里 t 的最高次数是 3,也就是 t^3。这个三次项给曲线提供了足够的弯曲自由度:它既能控制位置,又能控制端点处的速度方向。


埃尔米特样条

一段立方埃尔米特样条由 4 个参数决定:

  • 起点 v_0
  • 终点 v_1
  • 起点切线 m_0
  • 终点切线 m_1

可以把它想象成这样:

  • v_0v_1 决定曲线从哪里来、到哪里去。
  • m_0 决定曲线离开起点时的方向和速度。
  • m_1 决定曲线到达终点时的方向和速度。

立方埃尔米特样条的公式是:

p(t) = (2t^3 - 3t^2 + 1)v_0 + (t^3 - 2t^2 + t)m_0 + (-2t^3 + 3t^2)v_1 + (t^3 - t^2)m_1

为了看得更清楚,我们把四个多项式单独命名:

h_{00}(t) = 2t^3 - 3t^2 + 1

h_{10}(t) = t^3 - 2t^2 + t

h_{01}(t) = -2t^3 + 3t^2

h_{11}(t) = t^3 - t^2

于是公式可以写成:

p(t) = h_{00}(t)v_0 + h_{10}(t)m_0 + h_{01}(t)v_1 + h_{11}(t)m_1

这四个函数就叫 Hermite 基函数。

这些基函数到底在干什么?它们不是随便凑出来的,而是为了同时满足 4 个约束:

p(0) = v_0

p(1) = v_1

p'(0) = m_0

p'(1) = m_1

也就是说:

  • 曲线必须经过起点。
  • 曲线必须经过终点。
  • 曲线在起点的导数必须等于起点切线。
  • 曲线在终点的导数必须等于终点切线。

C1 连续

立方埃尔米特样条很容易做到 C1 连续。

设第一段 Hermite 曲线为:

p_A(t)=Hermite(v_0, m_0, v_1, m_s, t)

第二段 Hermite 曲线为:

p_B(t)=Hermite(v_1, m_s, v_2, m_2, t)

两段曲线共享:连接点 v_1 和连接处切线 m_s。即连接点位置和导数一样,就自然是 C1 连续。


可视化

为了更方便观察,我们用 Python 实现同样的逻辑,并使用 Matplotlib 画 3D 曲线。

  1. def hermite(v0, m0, v1, m1, t):
  2.     """Cubic Hermite interpolation, matching the C++ GLM formula."""
  3.     # Hermite 曲线使用 4 个输入控制形状:
  4.     # v0: 起点位置,v1: 终点位置,m0: 起点切线,m1: 终点切线。
  5.     # t 是插值进度,范围通常是 [0, 1]。
  6.     t2 = t * t
  7.     t3 = t2 * t
  8.  
  9.     # 四个三次 Hermite 基函数。
  10.     # h00/h01 负责让曲线经过端点 v0/v1;
  11.     # h10/h11 负责让端点导数等于切线 m0/m1。
  12.     h00 = 2.0 * t3 - 3.0 * t2 + 1.0
  13.     h10 = t3 - 2.0 * t2 + t
  14.     h01 = -2.0 * t3 + 3.0 * t2
  15.     h11 = t3 - t2
  16.  
  17.     return h00 * v0 + h10 * m0 + h01 * v1 + h11 * m1