CSS3 のベジエ曲線のアニメーションを実装してみたい

CSS3 の ease-in や ease-out は結構綺麗なので、あの動きを実装してみようかなーと思ったわけです。


CSS3 のベジエ曲線は3次曲線なので4点が必要ですが、最初の点と最後の点は固定値なので、指定するのは2番目と3番目の位置だけです。
Ceaser - CSS Easing Animation Tool - Matthew Lein がすごく分かりやすい。


で、3次のベジエ曲線の計算式は
x=(1-t)^3x_1+3(1-t)^2tx_2+3(1-t)t^2x_3+t^3x_4
y=(1-t)^3y_1+3(1-t)^2ty_2+3(1-t)t^2y_3+t^3y_4
(0 \leq t \leq 1)
らしいです。
で、CSS3 では(x_1,y_1)=(0,0)(x_4,y_4)=(1,1)というのは固定で、x_2,y_2,x_3,y_3 の範囲も決まっているので、
x=3(1-t)^2tx_2+3(1-t)t^2x_3+t^3
y=3(1-t)^2ty_2+3(1-t)t^2y_3+t^3
(0 \leq t,x_2,y_2,x_3,y_3 \leq 1)
となります。
ただこの式を直接使えばいいかというとそうではなくて、CSS3 ではX軸を時間軸として、Y軸を結果の軸として使っているので、x から t を求め、その求めた t から y を求める必要があります。


ということで x=3(1-t)^2tx_2+3(1-t)t^2x_3+t^3 を3次方程式として求めて t を出せばいいかなと思い、ちょっと解の公式をググってみると何かいろいろ難しい人の名前ががさごそと出てくるのでちょっと逃げました。
まあでもそういうのは Wolfram|Alpha に解いてもらえればいいやとやってみたらかなりひどいことになったのでこれも断念。


もう少し簡単な方法は無いかなーと考えてみたところ、なぜかは知らないですが、上記の CSS3 のテストサイトをやった限りだと、Xが増えるとYも増えるようなグラフになっているようです。
ということは二分探索が使えるんじゃないかと思い、結局こんなコードになりました。

float bezier(float x, float x1, float y1, float x2, float y2) {
    // ±eps の範囲内に収まったら抜ける
    static const float eps = 0.01f;

    float down = 0.0f;
    float up = 1.0f;

    float t;
    while (true) {
        t = (down + up) / 2;
        float px = 3*(1-t)*(1-t)*t*x1 + 3*(1-t)*t*t*x2 + t*t*t;
        if (px < x-eps) {
            down = t;
        } else if (px > x+eps) {
            up = t;
        } else {
            break;
        }
    }

    return 3*(1-t)*(1-t)*t*y1 + 3*(1-t)*t*t*y2 + t*t*t;
}

ただブラウザの CSS3 がこんなしょぼい実装をしてるとは思えないからきっと何かもっとうまい方法があるに違いないので誰か教えてください!

追記

ブラウザのソース直接見ればいいじゃない!ということに気がついてしまったので、Firefox5.0 のソースを見てみました。

// content/smil/nsSMILKeySpline.cpp

double
nsSMILKeySpline::GetTForX(double aX) const
{
  // Find interval where t lies
  double intervalStart = 0.0;
  const double* currentSample = &mSampleValues[1];
  const double* const lastSample = &mSampleValues[kSplineTableSize - 1];
  for (; currentSample != lastSample && *currentSample <= aX;
        ++currentSample) {
    intervalStart += kSampleStepSize;
  }
  --currentSample; // t now lies between *currentSample and *currentSample+1

  // Interpolate to provide an initial guess for t
  double dist = (aX - *currentSample) /
                (*(currentSample+1) - *currentSample);
  double guessForT = intervalStart + dist * kSampleStepSize;

  // Check the slope to see what strategy to use. If the slope is too small
  // Newton-Raphson iteration won't converge on a root so we use bisection
  // instead.
  double initialSlope = GetSlope(guessForT, mX1, mX2);
  if (initialSlope >= NEWTON_MIN_SLOPE) {
    return NewtonRaphsonIterate(aX, guessForT);
  } else if (initialSlope == 0.0) {
    return guessForT;
  } else {
    return BinarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize);
  }
}

事前にいくつか(10分割程度)の t でサンプリングしておいた配列から t の範囲を絞り込んで、適当な予想値 guessForT を求めて*1、その guessForT における接線の傾きを GetSlope で求めて*2、その傾きが一定以上*3だったらニュートン法で求めて*4、そうじゃなければ(傾きが余りにも小さいとニュートン法ではなかなか収束しないので)二分探索で求めてる*5
いろいろ頑張ってるんだなーと思った次第というか、二分探索でやるのも結構間違いじゃないということが分かって一安心でした(大抵はニュートン法の方に入るでしょうけど)*6

*1:グラフが真横に移動している場合にはこの guessForT と等しくなるように計算しているっぽいので、求めようとしている t は guessForT 以下になる

*2:GetSlope はベジエ曲線の式を t微分した式で計算してるっぽい

*3:NEWTON_MIN_SLOPE は 0.02 だった

*4:関数の中で最大4回しかループしないようになっている

*5:関数の中で最大10回しかループしないようになっている

*6:多分上記の傾きの値やニュートン法の最大ループ回数などは、二分探索を何回も繰り返して得たほぼ正確な値と速度を比較しつつ求められたものだと思います