0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

cubic-bezier ビジュアルエディタを 500 行 vanilla JS で書いた — 二分探索による逆関数とオーバーシュート曲線

0
Posted at

transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1)手で書ける人はほぼいない。普通は cubic-bezier.com などのビジュアルエディタで操作してコピペしてくる。今回はその種類のエディタを 500 行 vanilla JS で実装した。ピンク (P1) と緑 (P2) のハンドルをドラッグすると、SVG の曲線が更新され、隣のボックスが即その easing で動き、CSS が表示される。実装する中で 「ブラウザはどうやって easing を計算しているのか」 — cubic-bezier に閉形式の逆関数がない問題と二分探索による解 がそのまま教材になった。

🌐 Demo: https://sen.ltd/portfolio/bezier-editor/
📦 GitHub: https://github.com/sen-ltd/bezier-editor

Screenshot

cubic-bezier の数学

CSS の cubic-bezier(x1, y1, x2, y2)4 つの制御点 で定義される曲線:

  • P0 = (0, 0) ← 固定 (アニメ開始)
  • P1 = (x1, y1) ← ユーザ操作
  • P2 = (x2, y2) ← ユーザ操作
  • P3 = (1, 1) ← 固定 (アニメ終了)

パラメトリックなフォームは:

B(t) = (1-t)³ P0 + 3(1-t)² t P1 + 3(1-t) t² P2 + t³ P3

x 軸と y 軸の式を分離して書くと:

export function bezierX(t, x1, x2) {
  const u = 1 - t;
  return 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t;
}

export function bezierY(t, y1, y2) {
  const u = 1 - t;
  return 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t;
}

シンプル。だが 問題はここから:

CSS の仕様: 「時間 x 経ったとき、進捗 y はどれくらい?」

アニメーションが動くときブラウザがやっていることは:

  1. 現在の経過時間を正規化 → x ∈ [0, 1] (0 = 開始、1 = 終了)
  2. その x に対する y を計算: それが「進捗 %」になる

ところがパラメトリック表現は t を入れると (x, y) ペアが出る 形。x を入れて y を求めるには、「bezierX(t) = x になる t」 を逆引きする必要がある。

三次方程式に閉形式の逆関数がない

bezierX(t) = x は t の三次方程式:

t³ + 3(x2 - x1) t² + 3 x1 t - x = 0

…の形になる (整理は割愛)。三次方程式には Cardano の閉形式解が一応あるが、CSS で扱う x1, x2 ∈ [0,1] の範囲で数値的に安定 に解くのが大変。ブラウザは閉形式を使わず 二分探索 (binary search) で近似する:

export function solveT(x, x1, x2, iters = 20) {
  if (x <= 0) return 0;
  if (x >= 1) return 1;
  let lo = 0, hi = 1;
  for (let i = 0; i < iters; i++) {
    const t = (lo + hi) / 2;
    const fx = bezierX(t, x1, x2);
    if (fx < x) lo = t;
    else hi = t;
  }
  return (lo + hi) / 2;
}

20 イテレーションで 2^-20 ≈ 10^-6 精度。CSS easing の使用範囲では十分。Chromium / WebKit の実装は Newton 法と二分探索のハイブリッドで初期収束を加速しているが、一行で書ける近似 としては二分探索で良い。

「時間 x → t → 進捗 y」 を 1 つにまとめると:

export function easeAt(x, x1, y1, x2, y2) {
  const t = solveT(x, x1, x2);
  return bezierY(t, y1, y2);
}

これがブラウザが各アニメーションフレームで内部的に計算している関数。

オーバーシュート: y の範囲制約がない

CSS Easing Functions Level 1 仕様には:

The x-coordinates must be in the range [0, 1] or the function is invalid. The y-coordinates may have any value.

x は [0, 1] にクランプ、y はクランプしない。これが back-out / spring の overshoot 表現を可能にしている:

animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);

y1 = 1.56 は「目標位置を 56% 通り過ぎてから戻る」動き。Material Design や iOS の spring がこの族。

実装側でもこの非対称性を反映する:

export function normalize(x1, y1, x2, y2) {
  return [
    Math.max(0, Math.min(1, x1)),  // x はクランプ
    y1,                            // y はそのまま
    Math.max(0, Math.min(1, x2)),
    y2,
  ];
}

UI 側ではドラッグハンドルが y 方向に canvas の上下に飛び出せる ように描画領域を広めに取る (今回の実装は y ∈ [-0.33, 1.33] くらいを表示)。

SVG レンダリング

曲線本体は polyline 1 つ:

export function samples(x1, y1, x2, y2, n = 80) {
  const pts = [];
  for (let i = 0; i <= n; i++) {
    const t = i / n;
    pts.push({ x: bezierX(t, x1, x2), y: bezierY(t, y1, y2) });
  }
  return pts;
}

80 サンプルで <polyline points="...">。ハンドル線 (P0-P1, P3-P2) は dashed line で。ドラッグ可能な点は SVG <circle>pointerdown / pointermove で実装、setPointerCapture を使うとマウスが SVG 外に出ても drag が継続する。

座標変換は単純な線形マップ:

function xToPx(x) { return x * SVG_W; }
function yToPx(y) { return SVG_H - y * SVG_H; }
function pxToX(px) { return px / SVG_W; }
function pxToY(py) { return 1 - py / SVG_H; }

SVG の y 軸は下向きなので符号反転が必要。

CSS keyword のフィードバック

ease, ease-in, ease-out, ease-in-out, linear特定の cubic-bezier 値の別名:

export const NAMED = {
  "ease":          [0.25, 0.1, 0.25, 1.0],
  "ease-in":       [0.42, 0,    1,    1],
  "ease-out":      [0,    0,    0.58, 1],
  "ease-in-out":   [0.42, 0,    0.58, 1],
  "linear":        [0,    0,    1,    1],
};

current quad がこのどれかに一致したら、ユーザに キーワード形式も表示 する:

ease  /* cubic-bezier(0.25, 0.1, 0.25, 1) */

ease ってどんな曲線?」を逆方向に学べるツールになる。一致判定はトレラン 0.01 の浮動小数点比較で。

バグだったテスト: 「linear の cubic-bezier(0,0,1,1) は bezierX(t) = t ではない」

実装中に踏んだ罠がこれ。cubic-bezier(0, 0, 1, 1) は CSS keyword の linear の定義値 (MDN にもそう書いてある)。だが パラメトリック表現としては y = x の直線ではない:

bezierX(t, 0, 1) = 3(1-t)t² · 1 + t³ = t²(3 - 2t)  // smoothstep

つまり bezierX(0.25, 0, 1) = 0.0625 × 2.5 = 0.156...0.25 ではない

ただし bezierY も同じ式で bezierX = bezierYeaseAt(x) の出力としては y = x の identity になる (x → t → y で同じ式を 2 回通すので戻る)。

テスト書き直し:

test("solveT(x) inverts bezierX: bezierX(solveT(x), …) ≈ x", () => {
  for (const x of [0.1, 0.3, 0.5, 0.7, 0.9]) {
    const t = solveT(x, 0, 1);
    assert.ok(Math.abs(bezierX(t, 0, 1) - x) < 1e-4);
  }
});

test("linear is identity", () => {
  for (const x of [0, 0.25, 0.5, 0.75, 1]) {
    assert.ok(Math.abs(easeAt(x, 0, 0, 1, 1) - x) < 1e-4);
  }
});

「中間のパラメータ t が線形」≠「最終出力 y が x に対して線形」 を test で記録すると、後で誰かが「linear の bezierX(0.5) は 0.5 だろう?」と勘違いするのを防げる。

12 種類のプリセット

export const PRESETS = [
  { label: "ease",            quad: [0.25, 0.1, 0.25, 1.0] },
  { label: "ease-in-back",    quad: [0.6, -0.28, 0.735, 0.045] },
  { label: "ease-out-back",   quad: [0.34, 1.56, 0.64, 1] },     // ← Material spring
  { label: "ease-in-out-back",quad: [0.68, -0.55, 0.27, 1.55] },
  { label: "spring",          quad: [0.5, 1.8, 0.5, 1] },
  // ... 12 種
];

back 系がオーバーシュートを生むので、ボタンを 1 クリックして preview が「目標を通り過ぎてから戻る」のを見ると、y > 1 の意味が体感できる。

アーキテクチャ

bezier.js   ← bezierX, bezierY, solveT, easeAt, samples, normalize, toCSS, matchNamed (DOM 非依存、18 tests)
presets.js  ← 12 種のプリセット quad
app.js      ← SVG 描画 + pointer event + animation glue

bezier.js には documentwindow も登場しない。Node テストで bezier 数学を完全に検証してから、UI 層は SVG 描画とイベント処理だけを担当する。

まとめ

  • cubic-bezier は 「時間 x → 進捗 y」 のマッピング、内部は bezierX(t) = x二分探索による逆引き
  • 三次方程式に閉形式の逆関数はあるが、数値的に安定 な二分探索 (20 iter で十分) が実装の定番
  • x は [0, 1] にクランプ、y はクランプしない のが overshoot easing の根拠
  • linear キーワード = cubic-bezier(0, 0, 1, 1) はパラメトリックには smoothstep だが、easeAt(x) = x の identity を保つ
  • SVG 描画は <polyline> 1 つ、ハンドルは <circle> + pointer events、setPointerCapture で外への drag を拾う

リポジトリ: https://github.com/sen-ltd/bezier-editor

このツールは弊社の OSS ポートフォリオ #253 として作成しました。CSS animation 系では #244 css-animation-designer と組合せで使うと「キーフレームを書く + イージング曲線を作る」の両方が完結します。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?