貝茲、曲線與路徑

貝茲曲線是一種自然幾何形狀的數學近似。我們用它們來以最少的資訊,高度靈活地表示一條曲線。

不同於更抽象的數學概念,貝茲曲線是專為工業設計而創造的。它們是圖形軟體產業中常用的工具。

它們依賴於 插值 (我們在前一篇文章中已介紹),結合多個步驟來建立平滑的曲線。為了更好理解貝茲曲線的運作方式,讓我們從最簡單的形式──二次貝茲曲線開始。

二次貝茲曲線

取三個點,這是二次貝茲曲線所需的最小數量:

../../_images/bezier_quadratic_points.png

要在這三個點之間繪製一條曲線,我們首先對由這三個點形成的兩個線段的端點進行插值(範圍從 0 到 1)。這樣當我們將 t 從 0 變到 1,就會得到兩個沿線段移動的點。

func _quadratic_bezier(p0: Vector2, p1: Vector2, p2: Vector2, t: float):
    var q0 = p0.lerp(p1, t)
    var q1 = p1.lerp(p2, t)

然後將 q0q1 做插值,得到一個隨著曲線移動的點 r

var r = q0.lerp(q1, t)
return r

這種曲線稱為 二次貝茲曲線

../../_images/bezier_quadratic_points2.gif

(圖片來源:維基百科)

三次貝茲曲線

在前述例子的基礎上,若以四個點進行插值,我們便能取得更多控制。

../../_images/bezier_cubic_points.png

首先我們使用一個帶有四個參數的函式,分別輸入四個點 p0p1p2p3

func _cubic_bezier(p0: Vector2, p1: Vector2, p2: Vector2, p3: Vector2, t: float):

接著對每一對點進行線性插值,將其減少為三個點:

var q0 = p0.lerp(p1, t)
var q1 = p1.lerp(p2, t)
var q2 = p2.lerp(p3, t)

然後將這三個點進一步縮減為兩個點:

var r0 = q0.lerp(q1, t)
var r1 = q1.lerp(q2, t)

再縮減為一個點:

var s = r0.lerp(r1, t)
return s

以下是完整函式:

func _cubic_bezier(p0: Vector2, p1: Vector2, p2: Vector2, p3: Vector2, t: float):
    var q0 = p0.lerp(p1, t)
    var q1 = p1.lerp(p2, t)
    var q2 = p2.lerp(p3, t)

    var r0 = q0.lerp(q1, t)
    var r1 = q1.lerp(q2, t)

    var s = r0.lerp(r1, t)
    return s

結果會是一條在四個點之間平滑插值的曲線:

../../_images/bezier_cubic_points.gif

(圖片來源:維基百科)

備註

三次貝茲插值在 3D 中的用法也相同,只需將 Vector2 換成 Vector3 即可。

新增控制點

在三次貝茲的基礎上,我們可以藉由改變其中兩個點的方式,來自由調整曲線的形狀。我們不直接儲存 p0p1p2p3,而是這樣規劃:

  • point0 = p0:第一個點,起點

  • control0 = p1 - p0:第一個控制點的相對向量

  • control1 = p3 - p2:第二個控制點的相對向量

  • point1 = p3:第二個點,終點

這樣我們就有了兩個點及兩個作為相對向量的控制點。如果你曾用過圖形或動畫軟體,這應該看起來很熟悉:

../../_images/bezier_cubic_handles.png

這就是圖形軟體向使用者呈現貝茲曲線的方式,也是它們在 Godot 裡運作與顯示的方式。

Curve2D、Curve3D、Path 與 Path2D

有兩種物件可以存放曲線:Curve3DCurve2D (分別對應 3D 與 2D)。

這些曲線類別可以包含多個點,允許建立較長的路徑。也能將它們設為節點:Path3DPath2D (分別對應 3D 與 2D):

../../_images/bezier_path_2d.png

但它們的使用方式未必直覺,下方將說明貝茲曲線最常見的使用情境。

取樣評估

只對曲線進行取樣評估是一種做法,但多數情境下實用性有限。貝茲曲線的一大缺點是:如果你從 t = 0 以固定速度走到 t = 1,實際插值的速度*並不*會均勻。速度會受 p0p1p2p3 之間距離的插值影響,數學上沒有簡單方式能以固定速度遍歷曲線。

我們來看個範例,以下是偽程式碼:

var t = 0.0

func _process(delta):
    t += delta
    position = _cubic_bezier(p0, p1, p2, p3, t)
../../_images/bezier_interpolation_speed.gif

正如你所見,即便 t 以固定速率遞增,圓的移動速度(像素/秒)仍會變化。這讓貝茲曲線在實際應用時不容易直接拿來用。

繪製

繪製貝茲曲線(或基於曲線的物件)是很常見的用途,但同樣不簡單。大多數情況下,貝茲曲線都必須轉換為一系列線段。不過這麼做時通常會遇到如何避免產生過多線段的困難。

原因在於曲線某些區段(特別是轉角處)可能需要大量點,而其他區段則不需要:

../../_images/bezier_point_amount.png

另外,如果兩個控制點都是 0, 0 (記得它們是相對向量),那貝茲曲線就變成一條直線(此時產生過多點就很浪費資源)。

在繪製貝茲曲線之前,通常要先進行 細分。這通常透過遞迴或分治法分割曲線,直到曲率小於某個門檻為止。

Curve 類別透過 Curve2D.tessellate() 函式(可選遞迴階段數 stages 與角度容差 tolerance 參數)來提供細分功能。如此一來,基於曲線繪圖就更容易了。

遍歷

最後一個常見用途是沿曲線遍歷。但如前所述,若要以固定速度遍歷曲線,會有困難。

為了簡化操作,需要先將曲線 烘焙 為等距的點。如此一來就能用常規插值(甚至可進一步用三次插值)進行近似。只需使用 Curve3D.sample_baked() 搭配 Curve2D.get_baked_length() 方法。首次呼叫任一方法時,曲線會在內部自動烘焙。

固定速度遍歷曲線,可以用以下偽程式碼實現:

var t = 0.0

func _process(delta):
    t += delta
    position = curve.sample_baked(t * curve.get_baked_length(), true)

如此輸出就能以固定速度移動:

../../_images/bezier_interpolation_baked.gif