概念と概念を合成する概念


はじめに

この記事はCCS Advent Calendar 2016の2日目の記事です。


概念と概念を合成する概念

いわゆる線形補間ってやつです。
とにかくいろんな場所に出てくる。ゲーム制作とかにも応用できるよ。
(解説プログラムはC++, Gifアニメーション作成用の環境はTonyu2です。Tonyu2かわいいよTonyu2)


出オチ

😀 I have a $A$.
😀 I have a $B$.
😀 Ugh...
😀 $C = \alpha A + (1 - \alpha)B$


基本

概念 $A, B$ を実ベクトル空間 $V$ の元とする。
$\alpha$ を $0\leq \alpha\leq1$ を満たす実数とする。
概念 $A$ と概念 $B$ を、 $\alpha:(1-\alpha)$ の比で合成した概念 $C \in V$ は

$$C = \alpha A + (1 - \alpha) B$$

のように書ける。


実ベクトル空間?

$\alpha A$ や $(1 - \alpha) B$、これらの足し算の計算を矛盾なく行うために必要です。
定義は線形代数の本を当たるか、ググりましょう。


具体例

概念が1次元座標の場合

1次元の座標 $x_1, x_2 \in \mathbb{R}$ がある。
$x_1$ と $x_2$ を $\alpha:(1 - \alpha)$ で合成した座標、
つまり $x_1$ と $x_2$ を $(1 - \alpha):\alpha$ で内分する座標$x_\alpha \in \mathbb{R}$は

$$x_\alpha = \alpha x_1 + (1 - \alpha)x_2$$

となる。


プログラムでは、

double LinearInterpolation(double x1, double x2, double alpha) {
    return alpha * x1 + (1 - alpha) * x2;
}

のようになります。


補足: なぜ成り立つのか

高校で習った内分の公式を思い出しましょう。
座標 $x_1$ と座標 $x_2$ を $m:n$ に内分する点 $x_3$は

$$x_3 = \frac{n x_1 + m x_2}{m + n}$$

と書けます。ここで、 $m + n = 1$ とすると、

$$x_3 = \frac{n x_1 + (1 - n) x_2}{1} = nx_1 + (1 - n)x_2$$

となります。 $n$ を $\alpha$ に変えて終わりです。


概念が角度, HP/MP値, お金……の場合

1次元座標と同じです。
要するに(実数の)スカラー値は、実ベクトル空間の元なので合成できます。
(数学では実数ですが、プログラム上では離散的な数値になります。その点は気をつけてください。)


概念が2次元座標の場合

2次元の座標$x_1, x_2 \in \mathbb{R}^2$ がある。
$x_1$ と $x_2$ を $\alpha:(1 - \alpha)$ で合成した座標、
つまり $x_1$ と $x_2$ を $(1 - \alpha):\alpha$ で内分する座標$x_\alpha \in \mathbb{R}^2$は

$$x_\alpha = \alpha x_1 + (1 - \alpha)x_2$$

となる。


プログラムでは、

Vector2D LinearInterpolation(Vector2D x1, Vector2D x2, double alpha) {
    return alpha * x1 + (1 - alpha) * x2;
}

となります。
ただし、Vector2Dクラスに関わるオペランドは適切にオーバーライドされているものとします。


概念がn次元座標、n次元速度ベクトル, 画素の色…の場合

OK.

C++であればテンプレート機能を使って、

template<typename T>
T LinearInterpolation(T x1, T x2, double alpha) {
    return alpha * x1 + (1 - alpha) * x2;
}

としてしまうと便利だと思います。


応用例

ここからが面白いところです。ゲームに応用してみましょう。


簡易型イーズアウト

今のフレームのパラメータを$p_{now}$、
最終的に近づいて欲しい目標パラメータを$p_{target}$ として、
次のフレームのパラメータ$p_{next}$ を

$$p_{next} = \alpha p_{now} + (1 - \alpha) p_{target}$$

と決める。


ポイントは、合成する片方を現在のパラメータにしたところです。
これにより、$p_{target}$ に段々と近づいていくに連れて、
近づく速度が落ちていくアニメーションになります。


また、$\alpha$ を変えると動きが変わります。


近づいていく先を変えたいときは、$p_{target}$を変えるだけでOKなので、
イージング関数を用いる方法と比べて管理も行いやすくなっています。


この方法には、Exponential smoothingという名前がついています。


「動き方」の合成

フレーム $t$ における動き方Aでの位置を $x_A(t)$ 、動き方Bでの位置を $x_B(t)$ とする。
この動き同士を $\alpha:(1-\alpha)$ の比率で合成した動き方 $x_C(t)$ は、

$$x_C(t) = \alpha x_A(t) + (1 - \alpha) x_B(t)$$

となる。


  • 動き方A (円)

  • 動き方B (アステロイド曲線)

  • 動き方C (合成後)


3つ以上の概念の合成

$A, B, C, D$をある実ベクトル空間の元とする。
$\alpha$, $\beta$ を $0 \leq \alpha \leq 1$, $0 \leq \beta \leq 1$, $\alpha + \beta \leq 1$を満たす実数とする。
$A, B, C$ を $\alpha:\beta:1 - \alpha - \beta$ の比率で合成してDを作りたい場合、

$$D = \alpha A + \beta B + (1 - \alpha - \beta)C$$

とすれば良い。
4つ以上合成したい場合も同様。


Gif作成用Tonyu2コード

  • $\alpha$ ごとの動き比較Gif
Main.tonyu
sx = 100;
tx = 400;

new Test {x:sx, y:$screenHeight / 2 - 50, p:4, a:0.85, sx:sx, tx:tx};
new Test {x:sx, y:$screenHeight / 2, p:5, a:0.90, sx:sx, tx:tx};
new Test {x:sx, y:$screenHeight / 2 + 50, p:6, a:0.95, sx:sx, tx:tx};

while(1) {
    drawLine(sx, $screenHeight / 2 - 100, sx, $screenHeight / 2 + 100, color(255, 255, 255));
    drawLine(tx, $screenHeight / 2 - 100, tx, $screenHeight / 2 + 100, color(255, 255, 255));

    drawText(sx - 90, $screenHeight / 2 - 55 , "alpha = 0.85");
    drawText(sx - 90, $screenHeight / 2 - 5 , "alpha = 0.90");
    drawText(sx - 90, $screenHeight / 2 + 45 , "alpha = 0.95");

    drawText(sx - 15, $screenHeight / 2 + 110, "x = 100");
    drawText(tx - 15, $screenHeight / 2 + 110, "x = 400");

    update();
}
Test.tonyu
count = 0;
while(1) {
    if (count % 120 == 0) x = sx;
    if (count % 120 > 10) x = a * x + (1 - a) * tx;
    count++;
    update();   
}

  • 行き先の変更Gif
Main.tonyu
new Test{x:0, y:$screenHeight/2, p:4};
Test.tonyu
tx = rnd($screenWidth - 32) + 16;
a = 0.95;
count = 0;
while(1) {
    if (count % 120 == 0) {
        tx = rnd($screenWidth - 32) + 16;
    }
    x = a * x + (1 - a) * tx;
    drawText(30, $screenHeight / 2 + 32, "x: " + x);
    drawText(30, $screenHeight / 2 + 48, "target_x: " + tx);
    count++;
    update();
}

  • 動きの合成Gif
Main.tonyu
new Test{x:0, y:0, p:4};
Test.tonyu
t = 0;
r = 200;
cx = $screenWidth / 2;
cy = $screenHeight / 2;

function move1(t) {
    var t;
    return {x: cx + r * cos(3 * t), y: cy + r * sin(3 * t)};
}

function move2(t) {
    var t;
    return {x: cx + r * cos(3 * t)* cos(3 * t)* cos(3 * t), y: cy + r * sin(3 * t)* sin(3 * t)* sin(3 * t)};
}

while(1) {
    a = (sin(t / 2) + 1) / 2;
    m1 = move1(t);
    m2 = move2(t);
    x = a * m1.x + (1 - a) * m2.x;
    y = a * m1.y + (1 - a) * m2.y;
    drawText(0, 0, "alpha: " + a);
    t++;
    update();   
}

まとめ

以上です。
ゲームに限らず、あらゆる場面に出てくる概念なので押さえておいて損はないでしょう。

次は @kanimanju_ さんの記事です。よろしくお願いします。


参考文献

Exponential smoothing: https://en.wikipedia.org/wiki/Exponential_smoothing