ホーム < ゲームつくろー! < Programming TIPs編
その19 補間関数あれこれ
ゲーム制作の中では「補間」があちこちに出てきます。補間とは一般に2つの値の間を媒介変数t(=割合t)で表したものです。例えばキャラクタが地点AからBへ移動する時、時間が経過すると少しずつBへ向かいます。この「向かい方」は補間関数によって色々変化させる事ができます。他にも絵をフェードインさせる時とか、エフェクトを動かす時など、実に様々です。そして、この補間関数によってゲームの面白さのかなりな部分が決まってしまうのです。この章では、そんな補間関数をあれこれ見て行きます。
@ 線形補間
補間で一番単純なのは線形補間です。これは2つの値の間を等速で移動する補間です:
横軸が媒介変数tで、縦軸がそれに対応する値です。グラフを見ると一目瞭然で、線形補間の場合はtと値vは一緒になります。つまり、
です。正に線形(^-^)。この媒介変数tに対するv(t)が「補間関数」です。
で、このv(t)をどう使うかが本章の肝です。t=0の時の値をs、t=0の時の値をeとすると、補間値I(t)はv(t)を用いて一般に次のように計算されます:
この式はv(t)の形がそのまま補間値Iに反映されます。つまり、線形補間の場合ならsからeに向かって等速に値が変わっていく事になります。
A 2次関数補間
補間関数はv(0)=0、v(1)=1となるように関数を選ぶのがコツです。曲線的な補間関数としてまず挙げられるのが2次関数補間。これには次の2種類が考えられます:
青いグラフの方は、t=0からvがゆっくりと上昇します。こういうのを「ease-in」と言います。一方赤いグラフの方はv(1)の時に値が徐々に緩やかになります。これは「ease-out」と呼ばれています。「ease」というのが「緩やかに」という意味なので、何となく区別できるかなと思います。
両方の補間関数はそれぞれ以下のようになります:
上が青い方、下が赤い方です。
B 3次関数補間
ゲームの補間関数として多分一番実用されているのがこの3次関数補間です。補間の種類としては「ease-in, ease-out」となります。つまり、緩やかにスタートして真ん中で一番速くなり、最後もゆっくりとなる補間です。皆さんがお持ちのゲームを見ると、あらゆる所でこのease-in, ease-outな補間が使われているのが分かると思います:
このような関数は方程式を解く事で得る事ができます。まず、関数は3次関数です。式で言うとv(t)=a*t^3 + b*t^2 + c*t + d です。グラフは原点を通っているのでd=0はすぐに分かります。またt=0とt=1で平らになっていますよね。これは微分係数が0だという事です。v(t)をtで微分した式は v'(t)=3a*t^2 + 2b*t^2 + c となりますが、t=0で傾きゼロなので、c=0もわかります。結局上の式は、v(t)=a*t^3 + b*t^2 という事がわかります。t=1の時にv(t)=1なので、1 = a + b。これが1本目の式。2本目の式は微分式でt=0の時にv'(1)=0(傾きゼロ)なので、0 = 3a + 2b。この2本の式を連立方程式で解けば、a=-2、b=3がわかります。つまり上のグラフは、
という事がわかりました。これ、物凄い使います。
C cos補間
ease-in, ease-outなもう一つの補間としてcos補間があります。その名の通りcosカーブを使った補間です:
青い方がcos補間で、補間式は次のようになります:
cosは0〜πの間で1〜-1まで数値が動きます。1からそれを引けば0〜2となるので、それを半分に割れば上のようになるというわけです。
ピンク色の方は3次関数補間です。これを見るとわかるように、両者の間に殆ど違いはありません。3次関数補間の方が若干ease感が無いという感じですが…多分殆どわかりません(^-^;。これなら、普通は計算負荷が軽い3次関数補間を使いますね。
D 補間関数の使い方
ここまで挙げてきた補間関数を実際にどう使うのか?色々な所に使えるのですが、例えばキャラクタをA地点からB地点に10秒かけて移動させたいという例を見てみましょう。
A地点の座標をA(Ax, Ay, Az)という3次元ベクトルで表現します。B地点はB(Bx, By, Bz)です。ゲームはupdateメソッドが毎フレーム呼ばれますので、メソッド内で前回のフレームからの差分を取得します。UnityだとTime.deltaTimeがそれに該当するので楽です。
updateメソッドの中身は例えばこうなります:
void update() {
elapsedTime += Time.deltaTime; // 経過時間
float t = elapsedTime / 10.0f; // 時間を媒介変数に
if ( t > 1.0f )
t = 1.0f; // クランプ
float rate = t * t * ( 3.0f - 2.0f * t ); // 3次関数補間値に変換
Vector3 p = A * ( 1.0f - rate ) + B * rate; // いわゆるLerp
}
elapsedTimeが経過時間で、それを10秒で割り算すれば媒介変数tが算出できます。tの範囲は通常0〜1の間なので、tが1を超えた場合はクランプします。このtをそのまま使えば線形補間になりますが、それだとキャラクタの動きが機械的なので、上の例では3次関数補間にしています。求めたrateでA地点とB地点を線形補間(Lerp)してあげるとAからBへease in, ease outな動きをしてくれます。
補間は本当に良く使うので、上のような3次関数補間などを関数化しておくのも手です。例えば、
Vector3::Cube補間 static Vector3 Vector3::cube(
const Vector3 &A,
const Vector3 &B,
float min,
float max,
float elapsed
) {
elapsedTime += Time.deltaTime; // 経過時間
float t = ( elapsed - min ) / ( max - min ); // 時間を媒介変数に
bool isOut = false;
if ( t < 0.0f ) {
t = 0.0f; isOuter = true;
} else if ( t > 1.0f ) {
t = 1.0f; isOuter = true;
}
float rate = t * t * ( 3.0f - 2.0f * t ); // 3次関数補間値に変換
return A * ( 1.0f - rate ) + B * rate; // いわゆるLerp
}
こういうcube補間メソッドを作っておくと潰しが利きます。
他の補間関数も同じように作れますが、違うのはrateを求めている1行だけです。なので、ここの部分だけ外から呼び出すスタイルにするという実装もありです:
Vector3::lerp補間(補間関数を外部から) static Vector3 Vector3::lerp(
const Vector3 &A,
const Vector3 &B,
float min,
float max,
float elapsed,
float (*func)(float) // 補間関数
) {
elapsedTime += Time.deltaTime; // 経過時間
float t = ( elapsed - min ) / ( max - min ); // 時間を媒介変数に
bool isOut = false;
if ( t < 0.0f ) {
t = 0.0f; isOuter = true;
} else if ( t > 1.0f ) {
t = 1.0f; isOuter = true;
}
float rate = (*func)( t ); // 引数の補間値に変換
return A * ( 1.0f - rate ) + B * rate; // いわゆるLerp
}
第6引数にfuncが追加されています。これは次のように使います:
float cube( float t ) {
return t * t * ( 3.0f - 2.0f * t );
}
int main() {
Vector3 A( 0.0f, 1.0f, 2.0f );
Vector3 B( 3.0f, 5.0f, 7.0f );
float elapsed = 3.0f;
Vector3 pos = Vector3.Lerp( A, B, 0.0f, 10.0f, elapsed, cube );
}
これで2点間を3次関数補間する事が出来ます。線形補間(linear)とか2次関数(easeIn)などを作れば、同じような呼び出しで別の移動を実現できます。これがもう少し進歩すると、いわゆる「Tweener」等の補間ライブラリが作れるわけです。
(2017. 5. 30追記)
E シグモイド補間関数
Bで挙げた3次関数補間は本当にあらゆる所で多用されています。ただ、この関数は曲線の形が完全に固定されているので、例えば「もっと曲がりがきついのが欲しいなぁ」と望んでも3次関数の世界ではもう見つかりません。ではもっと高次の関数で何とかならないかと言うと、これがそう簡単でもありません。そもそも、次数を上げる方向性ではパラメータが増えすぎて微妙な調整が難しいのです。
Bの3次関数補間のようにease in outな曲線(最初ゆっくりで真ん中あたりで最速、終わりでまたゆっくりになる)は「S字曲線」と呼ばれます。実はS字曲線には3次関数以外にもいくつか種類があります。その中で有名なのが「シグモイド曲線」で、次の式で表現されます:
この関数は係数が一つaしかありません。この値を変えてプロットするとこんな感じになります:
aの値が1の時はぱっと見ほぼ直線です(実際はほんのりとカーブしてます)が、値が大きくなるとどんどんS字度合いが強くなっています。しかもすべての曲線が(0,
0.5)を必ず通ります。理由は簡単、x=0を式に代入するとaの値によらずy=0.5になるからですね。そして、その共通点を中心に点対象になっています。これも証明は簡単でx=sとx=-sを代入した式y1とy2を作り、それを足すと答えが綺麗に1になります。これはx=sの時の値y1に対し、x=-sの時の値が1-y1になる事を表します。よって点対象になるわけです。
証明はさて置き、この曲線の性質は正に臨んだものに近いのですが、x=-1やx=1の所が各曲線ともバラバラの値を取っているため、このままではやや使いにくい。そこで、この関数を係数aによらず必ず(x,
y) = (0, 0)、(0.5, 0.5)、(1.0, 1.0)を通るように変形してみましょう。
まずシグモイド曲線の唯一の共通通過点である(0., 0.5)を原点を通るように関数の位置をずらしてみます。これはY軸方向に-1/2だけオフセットするだけなので、式の左辺に1/2を足すと実現できます:
シグモイド曲線を原点対称にするとyの値にスケールを掛けることで曲線を上下方向に拡縮させることができます。この性質を上手く使えば、(x, y) = (-1, -1/2)の所を通すことができるスケールが存在できるはずです。そうすると点対象の性質からそのスケールにより(1, 1/2)も同時に通ることになります。上式の右辺にスケール値sを掛け算し、(1,1/2)を代入して条件に叶うsを算出してみます:
対称的でシンプルなsが出てきました(^-^)。このスケールを掛けたシグモイド曲線の式とグラフはこんな感じです:
(-1,-0.5)、(0,0)そして(1,0.5)を通るS字曲線になりました。
続いてX軸方向に1/2なスケールをかけて(-0.5, -1)、(0.5, 1)を通るようにしてみましょう。これはxの所を2xにするだけです:
先程とグラフの見た目は変わっていませんが、X軸の目盛り幅が先程の半分になっている事に注意して下さい。これでX軸方向の幅が1、Y軸方向の高さが1になりました。補間関数としてほぼ仕上がっています(^-^)。
最後の仕上げはこのグラフをX軸方向に0.5、Y軸方向に0.5だけオフセットしてX軸及びY軸の範囲をそれぞれ0〜1にしてあげます。これはxの所をx-1/2、yの所をy-1/2にします:
できました!これがS字の曲がり具合を調節できるシグモイド補間関数です。試しに係数a=1,2,4,8に対するグラフを描いてみます:
うひょ〜、綺麗にどの場合も(0, 0)〜(0.5, 0.5)〜(1,1)を通っています。もちろん元のシグモイド曲線の性質は受け継いでいるので(0.5, 0.5)で点対象なS字曲線です。aの値を大きくするほどS字の曲がり具合が急激になります。計算コストについては、べき数が異なるexp関数が2箇所あるだけで、後は四則演算ですから言うほど程遅い訳でもありません。重要なのはあるxに対して曲がりの強さを調整した一意のyが場合分けなどせずに単純な計算で求められるという事です。この単純さから、この補間はシェーダ内でも十分使えます!(実はシェーダで調整可能なS字曲線が欲しくてこの記事を追加しました(^-^;)。式をもう少し整理したりスケール部分を事前計算しておくなどすると計算コストを下げる事も出来ます。
注意点としては、この関数は(0, 0)の所でX軸と不連続です。また(1,1)の所ではY=1とも不連続なので扱いにご注意下さい。黄色い線は連続的になっているように見えますが、数学的にはどれだけaが大きくなっても不連続です。またx<0では0未満に、y>0の範囲では1より大きくなります(-s及び1+sに漸近)。性質を理解の上ご使用下さい。
F (0, 0)と(1, 1)で傾き0になる曲がり調整可能なS字曲線
Eで導出したシグモイド曲線を利用したS字補間関数は(0, 0)及び(1, 1)で傾きが0にはなりませんでした。Eでも十分実用性があるのですが、こだわるならば何とかそれらの点で傾きを0にしたい。そこで、Bの3次補間関数と合わせ技を使ってみます。
3次補間関数はx=0及びx=1で傾きは0になるのでした。であれば「シグモイド補間関数で求めた値をXとして、それを3次補間関数に放り込めばいいんじゃね?」と考えました。結果はこちら:
ご機嫌(^-^)。今度は(0, 0)及び(1, 1)で傾きが0になりました。係数aを大きくすれば曲がりが急になる性質もちゃんと入っています。唯一3次補間関数がベースなのでaをどれだけ小さくしても3次関数よりも曲がりを小さくは出来ません。ちなみに、xの範囲を広げるとこんなグラフになるようです:
数学って面白いなぁ…