前回の記事で、メインループからプレイヤーを自由自在に動かせるようになりました。
Playerですが、キャラはずっと正面を向いたままです。方向に応じた画像表示に入れ替えたら、それなりに見えるのは分かりますが、例えば自動車のように、各方向にアナログ的に回転させて表示しようと思ったら、360度全ての方向の画像を持つわけにもいきません。そこで今回は行列演算を用いて、画像を任意に加工して描画する手法について説明していきたいと思います。


  • 行列演算の基礎知識
数学的な説明はここでは一切いたしません。理由は計算そのものは C# が全て勝手にやってくれるためです。そのため、ここでは行列をどう扱うかだけに主眼をおいて説明していきます。

行列は英語で書いたほうが個人的にはわかりやすいです。英語だと Matrix(マトリクス)です。2x2 とか 3x3 とか 4x4 とか縦横に数字が並んでいる多次元配列のような構造をしています。これの各要素に予め座標を加工したい計算式を入れておいて、最後に座標を掛けるだけで、表面上はいとも簡単に座標の加工ができます。重要なのは行列を一度作ってしまえば、あとは、その行列を何度でも使って座標変換が出来る事です。大量の座標変換にはとても向いています。

大量の座標が存在する代表的な例が立体物です。立体物は多数の頂点から形成されています。頂点はすなわち座標です。3Dモデルを回転させたり拡大縮小させたりという加工は、とても行列演算向きなのです。

行列に回転の計算式を入れたモノを回転行列と呼んだりします。この回転行列は例えば 2x2 の行列であれば

float cos = MathF.Cos(angle); float sin = MathF.Sin(angle); Matrix mtx = { cos, -sin, sin, cos };

こんな感じで作るのですが、回転行列は誰が書いても同じになります。だから、C# では最初から回転などの行列演算はシステムに組み込まれています。だから、計算式そのものは覚える必要がないんですね。
※ このサンプルはイメージなので実際にはエラーになると思います…

また、行列は複数の計算式を同時に入れ込むことが出来ます。例えば、回転・拡大・移動というような処理も、ひとつひとつ個別に計算する必要はなくて、それら全ての計算を含む一つの行列を作っておけば、後はそれに座標を掛けるだけで、何度でも同じ計算をすることが出来ます。

そして、これが一番重要なのですが、この計算は順番がとても大事なのです。回転・拡大・移動を例にします。最初に下を向いているとして、以下のような動作は同じになるでしょうか。
  • 右に45度回転して、2倍に拡大して、3 進む
右に45度回転して、2倍に拡大して、3 進む
  • 2倍に拡大して、3 進んで、右に45度回転する
2倍に拡大して、3 進んで、右に45度回転する
最終的な身体の向きと大きさは今回は同じでしたが、到達した位置は全く異なっているのが分かりますでしょうか。このように全く結果は異なりますので、ちゃんと順番を考えて指定するようにしましょう。


  • 移動させる準備
今回はよりアナログ的な動きをさせようと思いますので、少し準備が多くなります。まず、表示のために自分の位置を用意します。また、移動方向と移動速度も付けます。おまけで移動速度が上がったら、その速度に応じて巨大化させてみます。これらの設定に必要な情報を const 定数として事前に定義しておきます。
プロパティ
IsPlayerMove は移動速度があれば true、停止していれば false を返す汎用プロパティとしてみました。このような使い方をしておけば、停止と移動の判定を共通化出来ますので、バグの発生要因を減らすことが出来ます。
プロパティの初期化
プロパティが多くなってしまったので、これらを初期化する専用のメソッドも用意します。このメソッドは MainForm が作られた直後に呼び出します。
プロパティの初期化を呼び出す
※いつも飲んでるいつものアミノ酸。私はもうリピート45回です。今日はちょっと疲れたなーって感じた日は、飲んでおくと翌日の回復度が違います。ハードな筋トレではもはや必需品ですねー

  • 移動処理
移動に関しては、基本構造は前回の説明と大差ありません。少々、入力を多くした程度です。キャラの操作系はとりあえず以下のように決めておきました。

[↑]  アクセル [↓]  ブレーキ [←][→] ハンドル [Space] 反転 [Home] 停止

まずはアクセルです。

// アクセル if (plSpeed < maxSpeed && GetKeyState((int)VK.UP) < 0) { if (!IsPlayerMove) // 停止状態なら { plSpeed = minSpeed; // 初速を与える } else { plSpeed *= 1.01f; // 1% 加速 if (maxSpeed < plSpeed) // 最大速度を超えたら { plSpeed = maxSpeed; // 制限する } } }

やってる事は単純ですね。上カーソルが押されていたら、停止状態なら初速を与えて、移動中なら 1% 加速させて、最大速度を超えていたら制限しているという、まんま処理通りの説明です^^; なお、最大速度未満だけ処理をするようにしていますが、これは判定しなくても動作に支障はありません。

続いてブレーキです。

// ブレーキ if (plSpeed > 0 && GetKeyState((int)VK.DOWN) < 0) { plSpeed *= 0.99f; // 1% 減速 if (plSpeed < minSpeed) // 最低速度を下回ったら { plSpeed = 0.0f; // 停止させる } }

これまた簡単で、下カーソルが押されていたら、1% 減速して、最低速度を下回ったら速度をゼロにしています。

停止処理は物凄く単純です。

// 停止 if (GetKeyState((int)VK.HOME) < 0) { plSpeed = DEFAULT_SPEED; // 速度 plAngle = DEFAULT_DIRECTION; // 向き }

もはや説明もいらないとは思いますが、Home キーが押されていたら、速度と角度を初期値に戻しています。

方向反転では、スペースの連続入力を止める処理を入れています。

// リバース if (GetKeyState((int)VK.SPACE) < 0) { if (!isPushSpace) { plAngle += 180f; // 向きを180度変更 } isPushSpace = true; } else { isPushSpace = false; }

スペースが押された時に、前回も押されてたら反転処理はしないという流れです。そして、スペースが離されていたら、現在は押されていないという状態に変更しています。移動方向の反転は、180度を加算することで実現しています。まあ、% 360 としても良かったんですが、一周の範囲をハズレても、計算的には問題ないので放置しています。

最後にハンドルの処理です。

// 回転方向を求める(単位角度) float fRotate = 0.0f + (GetKeyState((int)VK.RIGHT) < 0 ? unitDirect : 0.0f) // 右回転 - (GetKeyState((int)VK.LEFT) < 0 ? unitDirect : 0.0f); // 左回転
今回は三項演算子を使ってみました。右が押されていたら切れ角を加算、左が押されていたら切れ角を減算しています。さて、実際の移動ですが、この角度を単純にプレイヤーの向きに反映すると、実は少し問題が出ます。そのため、私は下記のような実装としています。

// 回転角を移動速度で確定する plAngle += fRotate * (IsPlayerMove ? plSpeed : stopSpeed);

この計算では、角度に速度を掛けてから、自分の角度を修正しています。これは、移動先の角度は速度に比例するためです。1m進むと1度曲がる場合、2m進めば2度曲がりますよね。だから、現在の速度を掛けるだけで、どのような移動速度でも旋回半径は同じになるというわけです。速度がゼロの場合は、速度ゼロ時専用の回転速度を固定値として与えるようにしています。

// 移動する plLocate = new( plLocate.X + MathF.Cos(Deg2Rad * plAngle) * plSpeed, plLocate.Y + MathF.Sin(Deg2Rad * plAngle) * plSpeed);

移動は前回の内容と同じ。ちなみに以下のようにも書けます。

// 移動する plLocate += new SizeF( MathF.Cos(Deg2Rad * plAngle) * plSpeed, MathF.Sin(Deg2Rad * plAngle) * plSpeed);

位置に関してはこのままだと画面外に出てしまうので、出ないように制限かけます。これまた前回と内容は同じですねー

// 範囲外は引き戻す if (plLocate.X < 0) { plLocate.X = 0; } if (plLocate.Y < 0) { plLocate.Y = 0; } if (plLocate.X >= SCR_WIDTH) { plLocate.X = SCR_WIDTH - 1; } if (plLocate.Y >= SCR_HEIGHT) { plLocate.Y = SCR_HEIGHT - 1; }

最後に動いているのなら、アニメカウンタを更新して表示パターンを切り替えます。

// 移動速度がある程度あればアニメさせる if (IsPlayerMove) { ++cntPlayer; }

長々と説明してきましたが、ここまでは、前回のベクトル移動と基本的には大差ありません。
※ピリッと少し辛め。まあ私が辛さに弱いせいもあるかもですが、単に辛いだけじゃなくてとても美味しいんです。手軽に作れるのも魅力。小腹が空いたらレンジでチンしてご飯にぶっかけてハフハフ食べられます!

  • 行列演算で描画する
さて、ここまでの処理でやったことは、移動のために現在の速度、キャラの向き、現在の位置を更新しただけです。この情報に則り、画面にキャラを表示していきます。先にも説明した通り、順番が大事です。向きの回転と拡大の順番とどちらでも構いませんが、移動だけは最終最後にします。そうしないと表示原点がズレるので正しく回転や拡大を処理できなくなります。

C# で描画する対象に対して行列演算を行うのは、実は全てメソッドが用意されているので、それを呼び出すだけで出来てしまいます。行列を管理してるクラスは Matrix です。まずは行列オブジェクトを生成します。

Matrix mtx = new();

最初に回転を指定します。

mtx.Rotate(plAngle - DEFAULT_DIRECTION);

Rotate で角度を指定します。単位はラジアン(1周が2π)ではなくてデグリー(1周が360度)です。角度は右側(アナログ時計で3時の方向)が 0 です。そして、角度の方向は右回りです。用意した画像は下向きですので、現在の角度 plAngle から DEFAULT_DIRECTION を引いた角度を指定しています。

float fScale = BASE_SCALE + (IsPlayerMove ? plSpeed : minSpeed) * 0.125f; mtx.Scale(fScale, fScale, MatrixOrder.Append);

次に拡大です。Scale で拡大率を指定します。1.0f で等倍となります。2.0f で 2倍の拡大、0.5f で 1/2 半分サイズとなります。今回は速度に合わせて良い感じになるよう拡大率 fScale を計算して引数として与えています。Scale の最初の引数が X方向の拡大率、次が Y方向の拡大率です。最後の MatrixOrder.Append は行列順序の追加を指定します。これはそれまでの行列演算式に加えていくという指定です。

mtx.Translate(plLocate.X, plLocate.Y, MatrixOrder.Append);

最後に位置の指定です。Translate で XY座標を指定します。追加指定の MatrixOrder.Append も忘れず指定します。これで変換行列は完成しました。あとは、画像の頂点を行列に掛けます。C# では、画像の変形は自由頂点ではなく変形の平行四辺形になります。そのため与える頂点は、左上、右上、左下の3箇所です。そこで、画像の中心を原点として、3箇所の頂点を PointF 配列に格納します。

PointF[] vertexs = { new(-bmp.Width / 2, -bmp.Height / 2), new(+bmp.Width / 2, -bmp.Height / 2), new(-bmp.Width / 2, +bmp.Height / 2), };

その頂点配列 vertexs と行列をまとめて掛け算します。

mtx.TransformPoints(vertexs);

これで行列に対して頂点配列を全て乗算します。結果は配列に上書きです。この頂点配列情報に則って、DrawImage で画面に表示します。

RectangleF rcSrc = new(0, 0, bmp.Width, bmp.Height); g.DrawImage( bmp, vertexs, rcSrc, GraphicsUnit.Pixel);



  • グラフィックオブジェクトの行列演算で描画する
個別に Matrix クラスに依る明示的な行列演算を説明してきましたが、実は C# では描画オブジェクトである Graphics にも、行列演算がそのまま内包されています。こちらも解説します。

最初に初期化をします。

g.ResetTransform();

ResetTransform() が Graphics オブジェクト内の行列演算の初期化指定となります。Graphics.FromImage で Graphics オブジェクトを作った直後なので、不要とは思ったのですが、念のために記述している程度です。

float fScale = BASE_SCALE + (IsPlayerMove ? plSpeed : minSpeed) * 0.125f; g.RotateTransform(plAngle - DEFAULT_DIRECTION); g.ScaleTransform(fScale, fScale, MatrixOrder.Append); g.TranslateTransform(plLocate.X, plLocate.Y, MatrixOrder.Append);

Matrix 行列クラスのときと使い方は全く同じです。メソッド名がちょっとだけ違う事にご注意ください。位置の指定ですが、DrawImage で位置を指定すれば良いと思ったかもですが、行列変換の指定をした場合は、DrawImage の dest は変換前の座標系の指定になりますので、表示位置の指定ではなくなります。そのため、この TranslateTransform 指定が必須となります。
※ 名前ぐらい合わせて欲しかった…

Rectangle rcDst = new(-bmp.Width / 2, -bmp.Height / 2, bmp.Width, bmp.Height); g.DrawImage( bmp, rcDst, 0, 0, bmp.Width, bmp.Height, GraphicsUnit.Pixel);

rcDst は 0,0 の原点が画像の中心に来るよう、左上の位置を画像の幅と高さの半分としています。この rcDst を例えば

Rectangle rcDst = new(-bmp.Width, -bmp.Height, bmp.Width * 2, bmp.Height * 2);
このように書き換えると、最初から画像サイズが 2倍で表示されます。これは変換元となる画像の四隅(頂点)位置が最初から 2倍に引き伸ばされて指定されたためです。通常は ScaleTransform 側の指定で良いと思いますが、こういう動作なんだよと覚えておくとよいかと思います。
※手が汚れたときとか家の中で汚れた場所を見つけた時とか、サッと取り出してサッとひと拭き。手軽に除菌できるのが嬉しいですー

  • 色置換で描画する
ちょっとオマケとして、停止状態ではキャラが明滅するようにしてみます。実現方法としては色置換です。白色を任意の位置に置き換えます。まずどういう色に置き換えるかの仕様を考えます。プログラム内にはバックバッファの切替を管理する drawCounter という変数が、メインループ毎に +1 されているのでこれを利用します。明滅ですが、パッパッと切り替わると安っぽいので、私はよくサインカーブを利用します。

Color newColor = Color.FromArgb( red: (int)(MathF.Sin(drawCounter / 5f) * 127 + 128), green: 64, blue: 255);

これで赤成分がサインカーブに沿って滑らかに色変化していきます。カウンタ値を 5f で割っているのは色変化速度が速すぎて忙しなかったので調整しています。Sinの結果は -1 と +1 を交互に変化します。なので、127倍することで -127 から +127 が得られますので、そこに +128 とすることで 1 ~ 255 が赤成分として得られます。

さて色置換ですが、DrawImage に ImageAttributes オブジェクトを引数として渡すことになります。この ImageAttributes は置き換えマップ(Remap)として ColorMap 配列を必要とします。ColorMap は置換元の色である OldColor と置き換える色の NewColor をメンバに持ちます。なので OldColor に白色を、NewColor には変更後の色を指定します。

using ImageAttributes ia = new(); ColorMap[] cmaps = new ColorMap[] { new ColorMap()}; cmaps[0].OldColor = Color.White; cmaps[0].NewColor = IsPlayerMove ? Color.White : newColor; ia.SetRemapTable(cmaps);
NewColor には IsPlayerMove が true、つまりは動いていたら元の色と同じ色を指定しています。最後に SetRemapTable で色書き換えテーブル(配列)を受け渡せば、色置換の準備は完了です。実際に描画する DrawImage ですが、

g.DrawImage( bmp, vertexs, new RectangleF(0, 0, bmp.Width, bmp.Height), GraphicsUnit.Pixel, ia);

このように引数の最後にImageAttributesオブジェクトである ia を指定すれば OK です。この ia が指定できる形式は、あまり数は多くないため、インテリセンスの選択肢から判断してください。
インテリセンスの示す選択肢の内容
今回の説明は以上です。この結果、次のような動作をする処理が出来上がります。


Matrix を使う方法でも Graphics オブジェクトを使う方法でも、処理の内容は変わりません。個人的には Matrix を使うほうがしっくりきますが、これはもう個人の好みで良いかと思います。今回も例によってダウンロードサンプルを付けておきますので、気になった人は落として確認してください。

2023.11.01.1200
※ USBメモリでは納められない大容量のデータを手軽に持ち運べる優れもの。2.5"HDD だからかなり小さいです。仕事場と自宅のデータの行き来にも便利かも。ただ、社内機密文書の持ち出しとかは犯罪ですのでダメっすよー(苦笑