学ぶ ・ 3D と映像の数学

カメラとアニメーション — 指数関数となめらかさ

中継カメラが馬群をなめらかに追いかける仕組みを、微分方程式とその解 (指数関数) で説明します。「毎フレーム 1 割近づける」というよくある書き方が、なぜ画面によって速さが変わってしまうのか、そしてどう直すのか。

このページで学ぶこと
  • 線形補間 (lerp) の意味
  • 「毎フレーム一定の割合で近づく」書き方がフレームレートに依存してしまう理由
  • 微分方程式 x˙=λ(g−x)\dot x = \lambda(g - x) と、その厳密解から導く係数 k=1−e−λΔtk = 1 - e^{-\lambda\Delta t}
  • 半減期と時定数
  • 時間の上限 (0.05 秒) や更新の間引きなど、映像を安定させる小さな工夫

線形補間 (lerp)

2 つの値 xx と gg の間を、割合 kk (0≤k≤10 \le k \le 1) だけ進んだ点は

lerp⁡(x,g,k)=x+k (g−x)=(1−k) x+k g\operatorname{lerp}(x, g, k) = x + k\,(g - x) = (1 - k)\,x + k\,g

です。k=0k = 0 なら xx のまま、k=1k = 1 なら gg に一致します。3D のカメラは毎フレーム、現在の位置 xx を目標の位置 gg (集団の横) へ lerp で少しずつ近づけています。

よくある書き方の問題 — 速さがフレームレートで変わる

いちばん簡単なのは、毎フレーム同じ割合 (たとえば k=0.08k = 0.08) だけ近づける書き方です。nn フレーム後には、残りの距離が

(g−xn)=(1−k)n (g−x0)(g - x_n) = (1 - k)^n\,(g - x_0)

になります。問題は「1 秒が何フレームか」が画面によって違うことです。1 秒後の残りは

  • 30 fps: 0.9230≈0.0820.92^{30} \approx 0.082
  • 60 fps: 0.9260≈0.00670.92^{60} \approx 0.0067
  • 144 fps: 0.92144≈0.00000620.92^{144} \approx 0.0000062

同じプログラムなのに、144Hz のモニターではカメラが 30 fps のときよりはるかに速く動いてしまいます。

時間で考える — 微分方程式

「フレームごと」ではなく「時間ごと」に動きを決めるため、連続的な時間 tt での変化を考えます。

dxdt=λ (g−x)\frac{dx}{dt} = \lambda\,\big(g - x\big)

「目標までの残りに比例した速さで近づく」という式です (λ\lambda は近づく速さの係数、単位は 1/秒)。残り y=g−xy = g - x と置くと dy/dt=−λydy/dt = -\lambda y となり、これは「自分の大きさに比例して減る」量なので、解は指数関数です。

y(t)=y(0) e−λty(t) = y(0)\,e^{-\lambda t}

1 フレーム (Δt\Delta t 秒) の間にこの式どおり動かすには、残りを e−λΔte^{-\lambda \Delta t} 倍にすればよいので、lerp の係数を

k=1−e−λ Δtk = 1 - e^{-\lambda\,\Delta t}

にします。アプリの中継カメラは λ=2.5\lambda = 2.5 です。

const k = 1 - Math.exp(-real * 2.5);
this.camPos.lerp(pos, this.t === 0 ? 1 : k);

フレームレートに依存しない証明

Δt1\Delta t_1 秒と Δt2\Delta t_2 秒の 2 フレームで進んだときの残りは

y⋅e−λΔt1⋅e−λΔt2=y⋅e−λ(Δt1+Δt2)y \cdot e^{-\lambda\Delta t_1} \cdot e^{-\lambda\Delta t_2} = y \cdot e^{-\lambda(\Delta t_1 + \Delta t_2)}

で、「Δt1+Δt2\Delta t_1 + \Delta t_2 秒を 1 フレームで進んだ場合」と完全に同じです。指数関数の eaeb=ea+be^a e^b = e^{a+b} という性質のおかげで、フレームの区切り方 (= フレームレート) に関係なく、時刻 tt での位置が同じになります。

半減期と時定数

残りが半分になるまでの時間 (半減期) は e−λt=1/2e^{-\lambda t} = 1/2 を解いて

t1/2=ln⁡2λ=0.6932.5≈0.28 秒t_{1/2} = \frac{\ln 2}{\lambda} = \frac{0.693}{2.5} \approx 0.28 \text{ 秒}

また 1/λ=0.41/\lambda = 0.4 秒を 時定数 と呼び、この時間で残りが e−1≈37%e^{-1} \approx 37\% になります。中継カメラは「0.3 秒ほどで半分、1 秒で 9 割以上追いつく」速さで動いている、ということです。

さらに深く注視点の係数 1.6k は近似

カメラが見る点 (注視点) は Math.min(1, k * 1.6) で動かしています。これは厳密な 1−e−1.6λΔt1 - e^{-1.6\lambda\Delta t} ではありませんが、Δt\Delta t が小さいとき

1.6 (1−e−λΔt)≈1.6 λΔt≈1−e−1.6λΔt1.6\,(1 - e^{-\lambda\Delta t}) \approx 1.6\,\lambda\Delta t \approx 1 - e^{-1.6\lambda\Delta t}

なので、ほぼ「λ=4\lambda = 4 の指数関数的な追従」になります。注視点を位置より速く動かすのは、画面の中心に馬群を早く収めるためです。

映像を安定させる小さな工夫

1 フレームの時間に上限をつける

const real = Math.min(this.clock.running ? this.clock.getDelta() : 0, 0.05);

ブラウザのタブを裏に回すと、描画が止まり、戻ったときに「前のフレームから 10 秒たった」ことになります。そのまま計算するとレースが一瞬で進み、ゴールの判定も飛んでしまうので、1 フレームの時間を最大 0.05 秒 (20 fps 相当) に制限しています。

脚の速さを走る速さに合わせる

h.act.timeScale = Math.max(0.35, h.v / 15);

駆け足 (Gallop) のアニメーションの再生速度を、馬の速度 vv (m/s) に比例させています (v=15v = 15 で等速)。地面を滑っているように見えない、という見た目のための調整です。

速度の平滑化

フレームごとの速度 (x今−x前)/Δt(x_\text{今} - x_\text{前}) / \Delta t はガタガタするので、指数移動平均 (毎回 2 割だけ新しい値に寄せる) でならしています。こちらはフレームごとの固定係数なので、厳密にはフレームレートに依存しますが、見た目への影響は小さいため簡単な形にしています。

順位表の更新を間引く

左上の順位表 (HTML) は毎フレームではなく、0.12 秒に 1 回 (約 8 回 / 秒) だけ作り直しています。HTML の作り直しは 3D の描画より重いことがあり、人の目には 8 回 / 秒で十分滑らかに見えるからです。

まとめ
  • lerp は x+k(g−x)x + k(g - x)。固定の kk だとフレームレートで速さが変わる
  • x˙=λ(g−x)\dot x = \lambda(g - x) の解は指数関数。1 フレームで k=1−e−λΔtk = 1 - e^{-\lambda\Delta t} にすると、どの画面でも同じ動き
  • eaeb=ea+be^a e^b = e^{a+b} がフレームレート非依存の理由
  • λ=2.5\lambda = 2.5 → 半減期 0.28 秒
  • 1 フレームの時間の上限、脚の速さの同期、速度の平滑化、順位表の間引きで映像を安定させる
練習問題固定係数を直す

あるゲームが 60 fps を前提に毎フレーム k=0.1k = 0.1 で近づけていました。同じ動きをどのフレームレートでも再現するには、λ\lambda をいくつにすればよいですか。

答えを見る

60 fps で 1−e−λ/60=0.11 - e^{-\lambda/60} = 0.1 となる λ\lambda を求めます。e−λ/60=0.9e^{-\lambda/60} = 0.9 より λ=−60ln⁡0.9≈6.32\lambda = -60\ln 0.9 \approx 6.32。以後は k=1−e−6.32 Δtk = 1 - e^{-6.32\,\Delta t} を使います。

練習問題9 割追いつくまで

λ=2.5\lambda = 2.5 のカメラが、目標までの距離の 90% を進むのは何秒後ですか。

答えを見る

e−2.5t=0.1e^{-2.5t} = 0.1 より t=ln⁡10/2.5≈0.92t = \ln 10 / 2.5 \approx 0.92 秒。