n 次のベジエ曲線は、n+1 個の制御点 \(P_0, P_1, \dots, P_n \in \mathbb{R}^2\) を用いて、 Bernstein 基底関数 \(B_i^n(t)\) の線形結合として定義されます。
実装上は Bernstein 展開を直接使わず、数値的に安定な de Casteljau のアルゴリズムで評価します。 これは隣り合う点を \(t\) の割合で線形補間する操作を、1点になるまで繰り返すだけの手続きです。
function bezierPoint(pts, t) {
// pts.length 個の点を、1点になるまで繰り返し線形補間する
let arr = pts;
while (arr.length > 1) {
arr = arr.slice(0, -1).map((p, i) => lerp(p, arr[i+1], t));
}
return arr[0];
}
ここで重要なのは、パラメータ \(t\) は曲線上の「弧長」とは無関係だという点です。 \(t\) を等間隔(\(0, 0.1, 0.2, \dots\))に取っても、曲線上の実際の移動距離は制御点の配置によって大きく変わり、 曲率が大きい区間ではゆっくり、緩やかな区間では速く進みます。これが「等速アニメーション」や 「曲線上に等間隔で点を並べる」処理をそのまま \(t\) で行うと不自然になる理由です。
曲線 \(C(t) = (x(t), y(t))\) の始点 \(t=0\) から \(t\) までの弧長は、速度ベクトル \(C'(t)\) の大きさを 区間 \([0, t]\) で積分したものです。
この積分の中身 \(\|C'(u)\|\) は「その瞬間の速さ」であり、これを 0 から t まで足し合わせる(積分する)ことで 「実際に移動した距離」が得られます。\(t=1\) のときの \(s(1)\) が曲線全体の長さ \(L\) です。
s(t) を計算するには \(x'(u), y'(u)\) が必要です。ベジエ曲線の嬉しい性質として、 導関数もまたベジエ曲線になることが知られています。n 次ベジエ曲線 \(C(t)=\sum_{i=0}^n B_i^n(t) P_i\) を t で微分すると:
Bernstein 基底の微分公式 \(\dfrac{d}{dt}B_i^n(t) = n\bigl(B_{i-1}^{n-1}(t) - B_i^{n-1}(t)\bigr)\) を用いると、
\[ C'(t) = \sum_{i=0}^{n} n\bigl(B_{i-1}^{n-1}(t) - B_i^{n-1}(t)\bigr) P_i = n \sum_{i=0}^{n-1} B_i^{n-1}(t)\,(P_{i+1} - P_i) \]つまり C′(t) は n−1 次のベジエ曲線であり、その制御点は
で与えられます。3次曲線(n=3, 4制御点)なら、導関数は2次曲線(3つの「速度制御点」)になります。
これはコード上では次のように実装されます(デモの derivativeControlPoints() に対応)。
function derivativeControlPoints(pts) {
const n = pts.length - 1; // 元の曲線の次数
return pts.slice(0, -1).map((p, i) => ({
x: n * (pts[i+1].x - p.x),
y: n * (pts[i+1].y - p.y)
})); // 次数 n-1 のベジエ曲線の制御点
}
弧長の被積分関数は \(\sqrt{x'(t)^2+y'(t)^2}\) ですが、平方根はベジエ曲線として閉じた形にならないため、 先に \(w(t) = x'(t)^2 + y'(t)^2 = |C'(t)|^2\) を考えます。
\(x'(t)\) と \(y'(t)\) はいずれも次数 \(n-1\) の多項式(=次数 n−1 の Bernstein 基底の線形結合)です。 2つの次数 \(n-1\) の多項式の積は、一般に次数 \(2(n-1)\) の多項式になります。 Bernstein 基底同士の積も次数が加算される性質(\(B_i^{p}(t)\,B_j^{q}(t)\) は次数 \(p+q\) の Bernstein 基底の線形結合に展開できる)があるため、
\[ w(t) = x'(t)^2 + y'(t)^2 \]は、次数 \(2(n-1)\) のベジエ関数として厳密に表現できます。
| 元の曲線の次数 n | 制御点数 | C′(t) の次数 | w(t) の次数 2(n−1) |
|---|---|---|---|
| 2(2次) | 3 | 1 | 2 |
| 3(3次) | 4 | 2 | 4 |
| 4(4次) | 5 | 3 | 6 |
実装では w(t) を明示的な多項式係数として展開する必要はなく、\(C'(t)\) を de Casteljau で評価して得た ベクトル \((x'(t), y'(t))\) から \(w(t) = x'(t)^2 + y'(t)^2\) を直接計算すれば十分です (数学的に「次数 \(2(n-1)\) のベジエ関数である」という事実は、後述の数値積分の精度や サンプリング密度を見積もる際の目安になります)。
\(w(t)\) が求まれば、速さは \(\|C'(t)\| = \sqrt{w(t)}\) です。この平方根が入るため \(s(t)=\int_0^t \sqrt{w(u)}\,du\) は一般に閉じた初等関数では書けず、数値積分に頼ります。
区間 \([0,1]\) を \(M\) 等分し(\(t_i = i/M\))、速さ \(v_i = \sqrt{w(t_i)}\) を計算します。 台形則により累積和を取ることで s(t) のテーブルを構築します。
\[ s(t_0) = 0, \qquad s(t_i) = s(t_{i-1}) + \frac{v_{i-1}+v_i}{2}\cdot \Delta t \quad (\Delta t = 1/M) \]こうして得られる \(\{(t_i, s(t_i))\}_{i=0}^{M}\) のテーブルが弧長パラメータ化の土台になります。 \(s(t)\) は \(w(t)\ge 0\) より広義単調増加なので、後で述べる逆写像(s → t)が一意に定まります。
「弧長に関して一様な間隔で点を配置する」には、目標弧長 \(s^* \in [0, L]\) を与えたときに 対応する \(t^*\) (\(s(t^*) = s^*\))を求める必要があります。これは \(s(t)\) の逆関数 \(t = s^{-1}(s^*)\) を求める操作です。
N 個の等弧長点が欲しい場合は、\(s^*_k = \dfrac{k}{N}L \;(k=0,\dots,N)\) をこの逆写像に通すだけです。
アニメーションで「一定速度」を実現する場合も同様に、時刻 \(\tau\) に対して
\(s^*(\tau) = v \cdot \tau \pmod L\)(\(v\):ピクセル/秒)を計算し、対応する \(t\) から
\(C(t)\) を描画すればよく、これは前述のデモの tForArcLength() がまさに行っている処理です。
この弧長パラメータ化があると、次のようなことが自然に実現できます。
装飾点、ノード、フォントのグリフ、道路標識、鎖の輪など、曲線に沿って「見た目の間隔が揃った」オブジェクトを並べられます。t を等間隔にしただけでは曲率の高い部分で点が密集してしまいます。
キャラクターやカメラをパスに沿って動かす際、t を時間に比例させると速度にムラが出ます。s(t) の逆写像を使えば、見た目の速度を一定(または任意のイージング)に制御できます。
レーザーカッターやCNC加工では、工具の送り速度を一定に保つために、パスをパラメータ t ではなく弧長で制御する必要があります。
SVG の textPath や地図上のラベル配置などで、文字同士の間隔を「見た目上」均等にするために弧長ベースの配置が使われます。
| 項目 | 内容 |
|---|---|
| テーブル解像度 M | 大きいほど s(t) の精度は上がるが計算・メモリコストが増える。曲率変化が急な曲線では M を増やすか、区間適応的にサンプリングする。 |
| 数値積分法 | 台形則は実装が簡単で十分な精度が出ることが多いが、より高精度が必要ならシンプソン則やガウス求積を使う。 |
| 逆写像の精度 | 線形補間だけではテーブルの粗さに応じた誤差が残る。精密なアニメーションが必要な場合は Newton 法で仕上げる。 |
| 制御点変更時の再計算 | 制御点をドラッグするたびに導関数の制御点・s(t) テーブルを再構築する必要がある(本デモの buildArcTable())。 |
| 高次曲線でのコスト | 次数 n が上がると w(t) の次数も 2(n−1) に伸び、де Casteljau の評価コストが増える。制御点数が多い場合はセグメント分割(区分的3次ベジエ)を検討する。 |