Redpoll's 60
 Home / 3Dプログラミング入門 / 第3章 $§$3-18
第3章 3D空間の基礎

$§$3-18 応用プログラム 2 (スロットマシン)


本節では変換行列の積に関する簡単な応用として、カジノなどに見られるスロットマシンを作成する。


# Code1
図1は円柱を横向きにしたオブジェクト Reel の初期状態である。Reelの中心は原点に置かれており、2つの底面の中心を x軸が貫いている。
スロットマシンはこのReelを複数個並べて適当な速度で回転させるものであるが、まずは単一のReelの回転から始める。

図1 Reel 初期状態
図2 Beta1 実行結果

本節のプログラムではReelは ClReel クラスのインスタンスとして使われる。ClReelクラスはカスタムライブラリーで用意されているクラスである。第3章以降のプログラムで使われているオブジェクトは主にカスタムライブラリーの THObject3D クラスのインスタンスであるが、ClReelTHObject3D クラスのサブクラスであり、以下の 4つのメンバ変数が追加されているだけである。
[class ClReel]
public class ClReel : THObject3D
{
    public float rotDeg;

    public float angVel;

    public int nextBaseline;

    public bool ROTATE;

    // 空のコンストラクタ
    // ..
}

それぞれのメンバ変数については以下の解説の中で取り上げる。

次のプログラムは $y = 3.2$ の位置でReelを一定の速さで回転させるだけのプログラムである。
[Beta1]  (実行結果 図2)
if (!i_INITIALIZED)
{
    Reel[0].angVel = 60.0f;
    Reel[0].rotDeg = 0.0f;

    i_INITIALIZED = true;
}

float dt = Time.deltaTime;

float dr = Reel[0].angVel * dt;
Reel[0].rotDeg += dr;        
Matrix4x4 R = TH3DMath.GetRotation4x4(Reel[0].rotDeg, Vector3.left);
Matrix4x4 T = TH3DMath.GetTranslation4x4(0.0f, 3.2f, 0.0f);
Matrix4x4 M = T * R;
Reel[0].SetMatrix(M);


Reelは最終的には複数個が使われるのでプログラム中の Reel は配列であり、Reel[i] として使われる。Code1、Code2では1つしか使わないのでプログラム中では常に Reel[0] が使われる。
今回の実装では Reelの回転速度は「1フレームあたりどれだけ回転するか」ではなく「単位時間あたりにどれだけ回転するか」をもとに決めている。つまり、フレームレート依存の指定ではなく時間依存の指定である(1-9節参照)。
冒頭の初期化ブロック内ではReelのメンバ変数に初期値を設定している。angVel はReelの単位時間(1秒)あたりの回転角度、すなわち角速度を表す変数である。ここでは $60$ であるが、これによって Reelは1秒あたり $60$°回転することになる。rotDeg は Reelが現時点でどれだけ回転したかを表す変数であり、初期値は $0$ である。
9行目の Time.deltaTime はUnityの標準APIで用意されているプロパティで、前回のフレームから今回のフレームまでの経過時間を表し、経過時間は float型の 秒 で表される。ここでは 60FPSの設定であるので、Time.deltaTime の値は毎フレーム約$0.017( \fallingdotseq 1/60)$である。
11行目の dr は各フレームにおけるReelの回転角度である。各フレームは約$0.017$秒ごとに表示されるので、dr(=angVel*dt) の値は毎フレーム $60.0 \times 0.017 = 1.02$、すなわち Reelは毎フレーム約$1$°ずつ回転することになるが、これは上記の設定どおり1秒間では約$60$°の回転になる。
12行目の rotDeg の値は、最初のフレームでは約$1$、2フレーム目では約$2$、3フレーム目では約$3$ $\ldots$ と、以降も毎フレーム約$1$°ずつ増加する。
Reelに実行される15行目の変換行列 M の内容は、最初のフレームでは まずReelが原点において x軸周りに約$1$°回転し、回転した状態で y軸プラス方向に $3.2$だけ平行移動する。2フレーム目では Reelが原点において x軸周りに約$2$°回転し、回転した状態で y軸プラス方向に $3.2$だけ平行移動する。3フレーム目以降も同様であり、描画されたフレームを連続的に表示すると、図2に見られるように $y = 3.2$ の位置で毎フレーム約$1$°ずつ回転を行うことになる。

(注意 : 本節のプログラムにおいては Reelの回転方向は、左手の親指を x軸マイナス方向に向けたときの残りの指の曲がっている方向である。したがって、原点においてReelを回転させる際の回転軸は x軸ではあるが x軸プラス方向ではなく、x軸マイナス方向が回転軸である。13行目では回転行列 R を求める際の回転軸が Vector3.left となっているが、これは Unityに標準で用意されている x軸マイナス方向を表す定数である (具体的な値は $(-1, 0, 0)$ )。もし、この回転軸を x軸プラス方向$(1, 0, 0)$を表す定数 Vector3.right にしてしまうと、Reelの回転は逆向きになってしまう)

実行結果(図2)から分かるように、このプログラムでは Reelは一定の速度で回転を行うだけである。しかし、実際のスロットマシンにおけるReelの回転は上記のような終始一定の速度での回転ではなく、はじめに ある初速を与えられ時間経過とともに角速度が遅くなり、やがて停止するという過程をたどる。
ある初速でスタートし、次第に遅くなり、やがて停止するという一連の流れは、物理の初等的な運動の中にも同様のものが見られる。例えば 鉛直投げ上げなどがそうである。鉛直投げ上げでは物体を適当な初速で上空へ投げ上げるが、時間経過とともに重力の影響によって物体の速度が遅くなり、最高到達点においては速度は $0$ になる。
今問題としているReelの回転についても、鉛直投げ上げにおける重力に相当するものを用意して時間経過とともに角速度が遅くなり、やがて停止するようにプログラムを書き換えよう。

先程のプログラムでは各フレームの回転角度は以下のように計算されていた。
float dr = Reel[0].angVel * dt;    // dt : Time.deltaTime
Reel[0].rotDeg += dr;        

この部分を時間経過とともに角速度が遅くなるように次のように変更する。
Reel[0].angVel -= i_g * dt;    // dt : Time.deltaTime
float dr = Reel[0].angVel * dt;    
Reel[0].rotDeg += dr;        

変更後のコードでは最初の行に angVel の減算処理が追加されている。この処理によってReelの角速度 angVel は毎フレーム i_g * dt ずつ減少する、すなわち 時間経過とともに角速度が減少していくわけである。
i_g は単位時間あたりにReelの角速度をどれだけ減少させるかを表すインスタンス変数であり(float型)、鉛直投げ上げにおける重力加速度に相当する。
しかし、上の変更だけでは ある時点で angVel が $0$ 以下になっても、その後も減算をし続けてしまい、angVel の値がマイナスになると Reelが逆方向に回転を始めてしまう。したがって、angVel が $0$以下になったときには それ以上処理を続けないようにしなければならない。

以下のプログラムは時間経過とともにReelの角速度が減少し、やがて停止するまでの過程を実装したものである。
[Code1]  (実行結果 図3)
if (!i_INITIALIZED)
{
    i_v0 = 400.0f;  
    i_g = 40.0f;
    Reel[0].rotDeg = 0.0f;
    Reel[0].ROTATE = false;
    Reel[0].SetWorldPosition(0.0f, 3.2f, 0.0f);

    i_INITIALIZED = true;
}

if (Input.GetKeyDown(KeyCode.S) && !Reel[0].ROTATE)
{
    Reel[0].angVel = i_v0;
    Reel[0].ROTATE = true;
}

if (!Reel[0].ROTATE) { return; }

float dt = Time.deltaTime;
Reel[0].angVel -= i_g * dt;

if (Reel[0].angVel <= 0.0f)
{
    Reel[0].ROTATE = false;
    return;
}

float dr = Reel[0].angVel * dt;
Reel[0].rotDeg += dr;        
Matrix4x4 R = TH3DMath.GetRotation4x4(Reel[0].rotDeg, Vector3.left);
Matrix4x4 T = TH3DMath.GetTranslation4x4(0.0f, 3.2f, 0.0f);
Matrix4x4 M = T * R;
Reel[0].SetMatrix(M);


図3 Code1 実行結果
冒頭の初期化ブロックにおける i_v0 はReelの初速を表すインスタンス変数であり、ここでは $400$ という設定であるが、これはReelの初速は 毎秒 $400$°の角速度で始まることを意味する。また、i_g は $40$ となっているが、これは Reelの角速度は毎秒 $40$°ずつ減少することを意味する (7行目の SetWorldPosition(..) は、Reelをy軸方向に $3.2$だけ平行移動させるカスタムライブラリーのメソッドである)。
このプログラムでは Sキーが押されるとReelの回転が始まるが、6行目の ROTATE はReelのbool型メンバ変数で Reelが回転しているかどうかを表す (回転していればtrue、停止中であればfalse)。
Sキーが押されると、12行目のifブロックに入り angVel を初速で初期化し、ROTATEtrue にする。ROTATEtrue でなければ それ以降の処理には進まない、したがって Sキーが押されない限りReelの回転処理は行われず、Reelは停止したままである。なお 12行目の条件式からわかるように、Reelの回転が始まるのは「Sキーが押された」ときではなく、正確には「Reelが停止している状態で Sキーが押された」ときである。
20行目以降は上記で解説した部分であるが、1箇所だけ処理が追加されている。それは23行目のifブロックである。先程 angVel の値が $0$以下になったときには それ以上処理を続けないようにしなければならない といったが、このifブロックはそのためのものである。angVel の値が $0$以下になると このifブロックに入るが、ここで ROTATEfalseにするので次のフレームからは Sキーが押されない限り20行目以降には処理が進まない、つまり Reelの回転処理が行われず停止した状態が続く。
実行結果(図3)に見られるように、このプログラムでは Reelの回転は初速から次第に遅くなりやがて停止する。


# Code2
しかし、Code1の回転処理はスロットマシンの回転としては十分なものではない。
スロットマシンにおいてReelの回転が停止したときには、下図4に示されるように1つの絵柄が画面中央の赤い枠に収まっていなければならない。

図4
図5

図5はCode1の実行結果においてReelが停止したときの状態であるが、このときのReelは中途半端な位置で停止してしまっているために、赤い枠の中に2つの絵柄が混在してしまっている。

下図6は回転を実行する前のReelの状態(回転角度 $0$)であるが、このときにも1つの絵柄が赤い枠の中に正しく収まっている。図7はこのときの様子をReelの右側(x軸プラス側)から見たときのものであり、図中の円盤はReelの側面である。

図6
図7

図7における E は視点、すなわちカメラの位置を表しており、視点からReelの中心に引かれた点線は視線の方向を表している。視点の高さと Reelの中心の高さは同じ位置にある (両者の y座標は同じ値)。
また、図7の円盤内には赤や青、緑などの6本の色の付いた線が引かれているが、これを以降「ベースライン」と呼ぶことにする。6本のベースラインは $60$°の間隔で引かれており、図7では視点とReel中心を結ぶ点線上に緑のベースラインが重なっている。そして、図6に示されるようにこのときには1つの絵柄が赤い枠の中に正しく収まっている。
このように、ある1つの絵柄が赤い枠の中に正しく収まるようにするためには、視点とReel中心を結ぶ点線上にベースラインが重なっている必要がある。
Reelの回転角度が $0$°(回転していない状態)のときは、Reelは上図7に示されるように緑のベースラインが点線上に来ている。そして ベースラインの間隔は $60$°であるから、ベースラインが点線上に来るときのReelの回転角度は必ず $60$の倍数になる。言い換えれば、Reelの回転角度が $60$の倍数であるとき、1つの絵柄が赤い枠の中に正しく収まっているわけである。

実際、図7の状態からReelを$60$°回転させてそれを確かめてみよう。

図8
図9

図8は回転後のReelの側面であるが、今回は水色のベースラインが点線上に来ている。図9は回転後のReelを正面から見たときのものであるが、やはり 1つの絵柄が赤い枠の中に正しく収まっている。

では今述べてきたことを実装するために、次の処理を追加する。
上のCode1では、Reelの角速度 angVel の値を毎フレーム減少させていき、$0$以下になった時点でReelを停止させた。しかし、それでは上で見たようにReelが中途半端な位置で停止してしまい、2つの絵柄が混在したような状態で止まってしまう。
今回の実装では angVel の値を毎フレーム減少させること自体は変わらないが、angVel の値がある値以下になったときには それ以上減少させずに一定の速度で回転させ、その時点で最も近いベースラインを算出し、ベースラインが(視点とReel中心を結ぶ)点線上に来た時にReelを停止させるようにする。

図10
図11

「その時点で最も近いベースライン」とは(視点とReel中心を結ぶ)点線に最も近いベースラインのことであるが、例えば 図10の場合は黄色いベースラインである。図11の場合は黄色ではなく赤いベースラインである。確かに図11では黄色いベースラインが最も近いが、しかし Reelの回転方向が図の矢印の方向であるため赤いベースラインが最も近いベースラインとなる。

具体例として Reelの角速度 angVel がある値以下になった時点で、Reelの回転角度が $3620$°であったとしよう。この時点からReelは一定の速度で回転するが、Reelが停止するときの回転角度は以下のように求められる。
先程述べたように、ベースラインが点線上に来るのは Reelの回転角度が $60$の倍数のときである。 $3620$は $60$の倍数ではないので、この時点ではベースラインは点線上には来ていない。 $3620$よりも大きく $3620$に最も近い $60$の倍数は $3660$ であるが、この値はその時点で最も近いベースラインの角度である。つまり、Reelが $3660$°まで回転したときに そのベースラインが点線上に来ることになる。

実際のプログラムは以下のとおり。
[Code2]  (実行結果 図12)
if (!i_INITIALIZED)
{
    i_v0 = 500.0f;
    i_g = 40.0f;
    Reel[0].rotDeg = 0.0f;
    Reel[0].ROTATE = false;
    Reel[0].SetWorldPosition(0.0f, 3.2f, 0.0f);

    i_INITIALIZED = true;
}

if(Input.GetKeyDown(KeyCode.S) && !Reel[0].ROTATE)
{
    float coef = (float)i_random.NextDouble();  // [0.0, 1.0)
    Reel[0].angVel = i_v0 + coef * 360;
    Reel[0].nextBaseline = 0;
    Reel[0].ROTATE = true;
}

if (!Reel[0].ROTATE){ return; }

float dt = Time.deltaTime;

if (Reel[0].nextBaseline == 0)
{
    Reel[0].angVel -= i_g * dt;
    if (Reel[0].angVel < 16.0f)
    {
        Reel[0].angVel = 16.0f;
        Reel[0].nextBaseline = Mathf.CeilToInt(Reel[0].rotDeg / 60.0f) * 60;
    }
}

float dr = Reel[0].angVel * dt;
Reel[0].rotDeg += dr;

if (Reel[0].nextBaseline > 0 && Reel[0].nextBaseline <= Reel[0].rotDeg)
{
        Reel[0].rotDeg = Reel[0].nextBaseline % 360;
        Reel[0].ROTATE = false;    
}

Matrix4x4 R = TH3DMath.GetRotation4x4(Reel[0].rotDeg, Vector3.left);
Matrix4x4 T = TH3DMath.GetTranslation4x4(0.0f, 3.2f, 0.0f);
Matrix4x4 M = T * R;
Reel[0].SetMatrix(M);


図12 Code2 実行結果
初期化ブロックはCode1と同じである。i_v0 は初速、i_g は単位時間あたりの速度の減少量を表す。メンバ変数 rotDeg はReelの現在の回転角度であり、ROTATE はReelの状態を表すフラグである (回転してるときは true、停止中ならば false)。
今回もSキーが押されると回転が始まるが、初速の設定が少し異なっている。14行目、15行目で angVel に初速をセットしているが、ここでは Sキーが押されるごとに、初速が異なる値になるように乱数を使用している。NextDouble() は $0.0$ 以上 $1.0$ 未満の実数値をランダムに返すメソッドであり、これによって下の15行目で angVel に初速としてセットされる値が $500$ から $860$ の間の値になる (i_randomSystem.Random型のインスタンス変数)。
16行目のメンバ変数 nextBaseline は最も近いベースラインの角度を表す int型の変数であり、Sが押されるたびに $0$にリセットされる。
上で述べたように、Reelの角速度がある値以下になったときには それ以降Reelの角速度を一定にして、その時点で最も近いベースラインを算出する。それらの処理は、24行目のifブロックにおいて行われる。
このifブロックの条件式は nextBaseline が $0$ かどうかであるが、この意味は Reelの角速度がある値以下になった時点で最も近いベースラインが算出されるが、具体的には nextBaseline にそのベースラインまでの回転角度がセットされる。したがって、nextBaseline が $0$ であるということは、まだ最も近いベースラインの算出が行われていない、すなわち Reelの角速度が下限に達していないということである。その場合には、このifブロックに入り毎フレーム angVel の値を i_g*dt ずつ減少させる。
27行目のifブロックは angVel が $16$ を下回った場合の処理であるが、その場合に angVel に $16$ をセットしている。つまり、ここが Reelの角速度が下限値以下になった時の処理である(「下限値」はここでは $16$ であり、Reelの角速度が毎秒$16^\circ$を下回った場合は、以降も停止するまで毎秒$16^\circ$で回転し続ける)。
30行目はその時点で最も近いベースラインの算出である。これは上記で簡単な計算例を示したが、ここでやっていることは同じである。rotDeg はその時点での回転角度であり、最も近いベースラインの角度は rotDeg より大きく rotDeg に最も近い $60$の倍数である。Mathf.CeilToInt(..) は引数の実数値より大きく、その実数値に最も近い整数値を int型で返すメソッドである。
例えば、rotDeg の値が $3620$ であるとき、$3620 / 60 = 60.33\ldots$ であるが、CeilToInt(..) にこの値をセットすると $61$ が返される。この値に $60$ を掛けた値 $61\times 60=3660$ が $3620$ より大きく $3620$ に最も近い $60$ の倍数である。
Reelの角速度が下限値に達し、一定の速度で回転するようになった後は、Reelの回転角度(rotDeg)が 最も近いベースラインの角度(nextBaseline)に達した時点で回転を停止させなければならないが、その処理は 37行目のifブロックで行われる。
このif文の条件式は、Reelが一定の速度で回転するようになってから回転角度が最も近いベースラインの角度に達したかを調べるものであり、このifブロックにおいて ROTATEfalseになると、次のフレームからは回転処理(21行目以降)が行われない。したがって、Reelはこのときの状態で停止することになる。
Reelの最後の回転角度は nextBaseline にセットされている値であり、それは $60$の倍数であるからReelは最終的に ある1つの絵柄が正面に来た状態で停止する (39行目ではrotDegにセットする値を$360$°以内にしているが、nextBaselineをそのまま代入しても結果は同じである。しかし、角度が $3600$ や $18000$ のように4ケタ以上の数値である場合、それをそのままオブジェクトの回転角度としてセットすると $0.01$°程度の誤差が生じることがある。これは決して稀ではない)。


# Code3
最後のプログラムは読者用の課題である。
今までのプログラムでは単一のReelの回転を扱ってきたが、このプログラムにおいて実装するものは3つのReelの回転である。すなわち、キーを押した際に 3つのReelがそれぞれ異なる速さで回転を始め、次第に減速し、停止するときにもそれぞれ異なるタイミングで停止するといったプログラムである。

プログラムの作成はCode3に行うものとする。
Code3は以下のように初期化ブロックのみが置かれており、そこには3つのReelを適当な位置に配置するコードのみが書かれている (4~6行目)。

[Code3]  
void Code3(){
    if (!i_INITIALIZED)
    {
        Reel[0].SetWorldPosition(-2.05f, 3.2f, 0.0f);
        Reel[1].SetWorldPosition(0.0f, 3.2f, 0.0f);
        Reel[2].SetWorldPosition(2.05f, 3.2f, 0.0f);

        i_INITIALIZED = true;
    }

}

図13
Code3をこのまま実行すると、図13に示されるように3つのReelが $y = 3.2$ の位置に並んだ状態で表示される。各Reelの回転は、この位置において行われるものとする。例えば Reel[0] は図の左(その位置は $(-2.05,\ 3.2,\ 0.0)$)に配置されているが、Reel[0]の回転はこの位置で行われるものとする。

プログラム作成に必要となる変数はすべて上記で解説してあるが、再度簡単に触れておこう。
i_v0 はReelの初速を決定するために使われるfloat型インスタンス変数である。例えば、Code2では Sキーが押されるたびに i_v0 に適当な値を加算したものをReelの初速としていた。
i_g は単位時間あたりのReelの角速度の減少量を表す float型インスタンス変数である (i_v0i_g については、特にこれらを使わずともプログラム中に適当な数値を直に記述しても問題はない)。
i_random は乱数生成に使われる System.Random型のインスタンス変数である。この変数はReelの初速決定の際に i_v0 に加算する適当な値を求める際に使われる。
例えば、次の記述では Reelの初速は $500$以上 $1000$未満の値になる (以下の NextDouble() は $0.0$以上 $1.0$未満の乱数を生成する)。
// Reelの初速の計算
float coef = (float)i_random.NextDouble();  // [0.0, 1.0)
Reel[0].angVel = 500.0f + coef * 500.0f;    // [500, 1000)

Reelのメンバ変数は、上で見てきたように rotDeg : 現在の回転角度、angVel : 現在の角速度(1秒あたりの回転角度)、ROTATE : 回転しているかどうかを表すフラグ、nextBaseline : Reelを停止させる角度 として使われる。

1点だけ注意しておこう。
Code2ではSキーを押してReelを回転させる際には次の条件式を使っていた。
if(Input.GetKeyDown(KeyCode.S) && !Reel[0].ROTATE)
{
    .. ..
}

また、Reelが停止している場合はReelの回転処理を行わないようにするためにプログラムの途中に次のように return文が置かれていた。
if (!Reel[0].ROTATE){ return; }

これらのコードは単一のReelの回転の場合には問題はないが、今回のように複数のReelの回転の場合には使うことはできない。例えば今回の3つのReelの場合、Reelの回転を始める際には「Sが押された」ときに始めるのではなく「3つのReelが停止している状態で Sが押された」ときでなければならない。回転処理が行われないようにするためのプログラム途中の return文も「3つのReelが停止している」ときに return するようにしなければならない。
言い換えれば、1つでもReelが回転している状態では、Sが押されて新たな回転が始まるようなことがあってはならないし、また 1つでもReelが回転しているならばプログラム途中で return が発生するようなことがあってはならないわけである。

プログラムの解答例については Sec318_Ans.txt を参照 (Sec318_Ans.txtはダウンロードコンテンツ内の「txt_ans」フォルダに含まれている)。















© 2020-2024 Redpoll's 60 (All rights reserved)