ゲームプログラミング/3Dグラフィック/平面スクリーン投影のプログラム例

本科目『ゲームプログラミング』は、ゲームクリエイターのための教材ではなくプログラマーのための教材です。
よって、単にゲーム用の3D-CGを作るだけなら、本ページの方法は不要です。また、ゲームで3D描画をしたいなら、Windowsの場合ならDirectXなど既存の3Dエンジンがあります。
しかし本ページでは、「今は自分は3Dエンジンを作れないが、将来的には自分で3Dエンジンを作りたいなあ」とか思ってるような人を念頭においています(ただし、本頁ページの水準では、到底ですが商用の3Dエンジンの質には及びません)。

高校3年や大学1年理系では、回転行列を習う場合が多いので、ついつい回転のプログラムを早く書きたいと、読者は思いがちであろう。
しかし、さきに回転のプログラムを書いてしまうと、コード中の式が複雑になるので、デバッグがしづらくなる。
回転のプログラムを書くのは、後回しにしよう。
まず、Windowsプログラミングで画像を表示したい場合、Windowa API の機能を使う。
Visual C++ でWindows API を使う場合、
という節が最初から用意されているので、そこに書き込めばいい。
さて、私たちは3Dグラフィックを表示したいのであった。
まず、
のような位置関係にある場合を、とにかく表示しよう。
コードは例として、下記のようになるだろう。
ピンク色の□がウィンドウに表示されただろうか?
もし、位置がウィンドウ中央でなければ、変数 int hisyatai_onViewX = 200; int hisyatai_onViewY = 150; の数値を適切な値にいじって、真ん中あたりに来るようにしてほしい。(上記コード例では、パソコンの画面が小さい場合などの小さめのウィンドウの場合でも表示できるように、実際の市販のパソコンでのウィンドウの初期設定よりも、小さめの座標位置に表示するようにしてある。)

さて、まず私たちは、キーボードの右ボタン(→)を押したら、カメラが右方向に移動するようにしよう。
つまり、→ボタンを押したら、下図のようにカメラが右にカニ歩きで移動するとする。
このようにカメラが右にカニ歩きした場合、被写体は視界の中では左のほうに移動して見えるハズである。

このような処理は、下記のように
のように実装できる。
このように、カメラの向きが一定のままで 位置が平行移動するだけなら、三角関数はまだ不要である。(カメラの向き変更などを処理するときに、ようやく三角関数を使う。)
私たちの目的はけっして数学的な技巧をアピールすることではなく、あくまで役立つプログラムを書くことなので、三角関数を使わなくても実装できる処理なら、まだ三角関数を使う必要は無い。

のように、被写体が2つ、奥行き方向になるとしよう。さきほどの例と同様に、カメラがカニ歩きしたとしよう。


すると、カメラ位置の視界のなかでは、視界内での、被写体映像の左方向への移動量が違っているハズである。
そして、その移動量は、奥にある被写体Aのほうが、小さいハズである(たとえば電車に乗って風景をながめるとき、遠くの風景ほどゆっくり移動するように見える。)
移動量には、平面投影の公式を使えばいい。
実装は簡単で、 case WM_PAINT: の節を、下記のように、上記の公式を使って書き換えればいい。
もし、いちいち「hisyatai1_Z」とか「hisyatai2_Z」のように変数を被写体の個数だけ変数宣言するのがイヤならば、配列や構造体配列などを使って宣言するプログラムに書き換えればいい。
なお、あらかじめグローバル変数領域で「hisyatai1_Z」とか「hisyatai2_Z」を宣言しておく必要がある(数値が必要なら初期値などの数値も代入しておく)。
なお、上記コード例では、奥側の被写体を少しうすい青色、手前側の被写体を少しうすいピンク色にしてある。
奥側の被写体のほうが、サイズを大きくしてある。
このように、もしカメラの移動パターンがカニ歩きだけなら、けっこう簡単に3Dプログラムを作れる。

カメラが被写体に近づいた場合、投影面もカメラと一緒に近づく。
なので、カメラと投影面の距離はつねに一定である。
なので相対的には、カメラが被写体に近づいたときは、相対的に言い換えれば、被写体がカメラに近づいたともいえる。
こう相対的に考えたほうが、投影面の位置を固定したまま(このときカメラ位置も固定したまま)で済むので、計算がラクである。

私たちは結局、図のような投影でのスクリーン上での座標を計算することになる。
これをxについて解けば、
図では簡略化のためxとzの2次元だけにしたが、yについても同様の計算法で求められる。
なので同様にしてyを求め、
xとyをまとめて、
行列でまとめれば
となる。
カメラから見て、投影された点がどこにあるかが視界内の情報で重要であるので、上式の第1項の xc と yc とを移項して、

もし計算の単純化のため、もしカメラ位置を0とすれば、
となり、冒頭の節で紹介した投影面上での拡大縮小率の式になる。
なお、xb,ybが変数の場合にはzbも変数になることに注意せよ。(zsにつられて変数と解釈すると、プログラムすべきコードが思いつきづらくなる。)
あるいは、式を
のように書き換えて区別でもしよう。
上記の計算をもとに、プログラムを書きなおして1点ずつ投影位置を計算するプログラムに書き換えると、だいたい、下記のような感じになる。
もし線分ABがスクリーンに平行なら(あくまで平行な場合だけに限る)、カメラをx座標をどんなにカニ歩きで動かしても、表示位置が左右に変わるだけで、大きさは一定のままである(中学レベルの相似計算で証明できる)。
裏を返せば、線分ABを平行にセットして、カメラをカニ歩きで左右に動かしてみたときに、もし線分ABの大きさが変わってしまえば、計算をミスってる。
このあとに回転処理を導入すると式が複雑になってくるので、まだ式の簡単な今のうちに、構造体を導入したコードにして構造化しておこう。
将来的に被写体の点の数が何十個や何百個にもなるので、配列を用いて効率化する必要がある。なので、ついでに構造体をもちいて、関連する変数もひとまとめにしてしまおう。
構造体配列にしておこう。(『C言語/構造体・共用体#構造体の配列』)
まず、冒頭のグローバル変数の領域に、下記のように構造体の宣言および構造体変数の宣言をしておこう。
そしてさらに、エントリポイント wWinMain の「// TODO: ここにコードを挿入してください。」に、構造体変数の初期値を導入しておこう。構造体変数の初期値の代入は、グローバル領域では不可能な場合があるので、 wWinMain 側で代入することになる。
そして、 case WM_PAINT: は上記の構造体を使えば下記のように書き換わる。

デバッグのために、上面図が必要です。上空から見下ろした視点でのカメラ位置と、カメラ向きと、被写体の位置とを図示した、上面図(じょうめんず、top view)が必要です。
今度の私たちの自作3Dゲームプログラミングでこのあと回転機能などが加わり、動かし方が複雑になってきたりすると、上面図などの補助図の無い状態でデバッグを確認するのが、だんだん困難になってきます(あるいは、事実上は不可能か)。
上下方向をy成分とすれば、 つまり、(x,z)成分を表示すればいいわけです。
私たちの作る必要のある機能は単に、
これらを、作成するアプリのウィンドウの空いてる場所にも表示すればイイだけです。
これらのプログラムの作成は簡単で、まずプログラムで矢印の作図プログラムでも書いておき、その矢印を、カメラ現在位置の変更にともなって矢印を移動させたり、カメラの向き変更にともなって、矢印の向きも同じ向きの同じ角度だけ回転させるプログラムを作ればいいだけです。
下記のような感じのコードを、WM_PAINT: 節に加えればいいだけです。
「上面図」とも言いますが、日本のゲーム業界的には和製英語で「マッパー」と言ったほうが、通じやすいかもしれません。なお、「マッパー」とは元々はRPGゲーム『女神転生』シリーズの魔法の名称で、3Dダンジョンの探検中に、プレイヤーに等角図でダンジョンの地図(マップ)と、現在地とを見せる画像を表示する機能のことです(もともと、そういうマップ補助を使える魔法の名前だった)。
どうせ私たちの自作ゲーム完成後にも、地図(マップ)の機能での現在地表示など流用するだろうから、とりあえず「マッパー」と呼んでおきましょう。
ちなみに古い1980年代のファミコン時代のウィザードリィや女神転生などの3Dダンジョンでの3D表示のアルゴリズムは、三角関数などを用いていない方式で、擬似3D(ぎじスリーディー)という手法ですので、私たちが今学ぼうとしている3Dプログラミングとは違う方式です。これはこれで、処理の負担軽減などには必要ですが、とりあえず、この節では擬似3Dについての解説は省略します。
なお、もしもフライトシミュレート的なゲームを作るなら、きっと上面図に加えて、さらに側面図の表示機能なども必要になるかもしれません。
なお、等角図(英: isometric projection)とは上空から斜め45度の下方を見下ろした投影法であり、上面図とは違います。等角図は、和製英語でクォータービューです。


今までの説明では、説明の単純化のために被写体の個数を1個に限ってきたが、実際にデバッグする場合には、被写体を位置を変えて2個にしてみて、カメラを移動してみて、正しく見えるかを確認してみよう。
いちばん簡単な方法は、平行な2個の被写体で、x方向の位置を離してみて、
みたいに配置したとき、
たとえばカメラを右に移動して、
被写体1と被写体2のあいだのx位置にカメラがあれば、当然に、2つの被写体は離れて見えるハズである。

いっぽう、カメラを左に移動して、
みたいな、一直線上に2個の被写体とカメラが配置される状態になった時は、当然、手前の被写体2によって、奥の被写体は隠れるハズである。
あなたの自作したプログラムがそうなるか、確認してみよう。

もっとカメラを左にしてみて、さらにカメラを前方にすすめると
今度は、さっきまで隠れていた被写体1が、すこし見えるようになる。

上記のような配置例の暗黙の前提として、被写体の色をそれぞれ別個の色にしておいて、見て区別できるようにしておこう。大きさも、多少は違えておくと、区別しやすいかもしれない。
なお、コンピュータ処理では先に描画した画像は、あとから描画した画像によって上書きされる。なので、上記のカメラ例の配置の場合には、奥にある被写体1のほうを先に描画する必要がある。

ここまでの説明を、コードにすると、下記のようになるでしょう。まだ回転は導入してない状態です。

このほかグローバル変数に必要な構造体および構造体変数の定義をしておいてください。また、 wWinMain 節などに、それぞれの構造体変数の初期値を代入しておいてください。

回転をさせるには、カメラを右方向にある角度だけ回転させた場合、視界内での被写体は逆方向である左方向に回転することになるので、つまり視界内のすべての被写体のすべての点はすべてカメラを中心に逆方向である左方向に公転することになる。
計算の簡単化のために、カメラの向きを変えた場合の視界の計算法では、カメラの代わりに被写体を逆方向に公転させるアルゴリズムに置き換えたほうが、アルゴリズムが簡単に設計できる。
ベクトル計算などを用いてカメラの向き角度と被写体の角度との差し引きの計算アルゴリズムを考えるよりも、カメラの向きを固定して代わりに被写体を逆方向に公転させるほうが計算がラクである。

投影してから公転した結果と、公転してから公転した結果とでは、計算結果が違ってしまう。(証明は、たとえば2次元に簡略化して、作図すれば分かる。)
(数学の理論としては、大学1年くらいで習う行列の理論に対応する。行列の積(かけ算のこと)の計算順序の交換は一般に、交換すると計算結果が変わってしまう。行列AとBで、積ABと積BAは異なる行列である。普遍的な場合の、幾何学的な操作に対応する行列の順序交換の証明のレベルは、大学1~2年のレベルになるので、このwikiの当ページでは証明を省略する。直交行列などの交換の問題になる。)
必ず先に被写体を公転させてから、あとからスクリーンに投影すること。
プログラムでアルゴリズムを書くときも先に公転させてから、あとから投影させることになる。

y軸を中心に回転させるとき、座標である点の位置の変わる座標軸成分は、x座標成分とz座標成分である。

数学の理論により回転行列の公式は
R = ( cos  θ − sin  θ sin  θ cos  θ ) {\displaystyle R={\begin{pmatrix}\cos \theta &-\sin \theta \\\sin \theta &\cos \theta \end{pmatrix}}}
である(『高等学校数学C/行列#回転行列』)。
実際にこの公式を3D-CGの回転計算で使うときは、原点を中心とした回転の公式
の形で使うだろう。
または、
の形で使うだろう。

y軸を中心にした回転(公転)の場合、x軸成分とz軸成分とが移動するので、式は、移動後の(x,z)座標の位置は
になる。

さらに、回転(公転)の中心になる座標は、カメラ位置の座標なので、カメラ位置の(x,y,z)座標成分をそれぞれ xc, yc ,zc とすると、移動後の(x,z)座標の位置は
である。

しかし視界を決めるのは、投影面上の座標のカメラからの相対的な位置であるので、視界は
の式で決まる。

さて、私たちは初期のカメラの向きを、画面中で真上の向き(↑)にしている場合、このときは初期値を x = x 0 {\displaystyle x=x_{0}} となり、カメラ位置xcについて、 ( x 0 − x c ) = 0 {\displaystyle (x_{0}-x_{c})=0} になるので、したがって、この場合には、回転の公式は
となる。
さて、もしz軸の向きを画面の下向きにとれば、カメラの向きが初期値で上向きの場合には
である。なので、
が、このような場合(z軸の向きが画面下向き。カメラが上向きの場合)での回転の公式になる。
なお、べつにプログラム中の変換式をこの ( x 0 − x c ) = 0 {\displaystyle (x_{0}-x_{c})=0} の場合の公式に置き換えなくても構わない。回転行列の公式を使えば、条件が上述のとおりなら、結果的にこの場合と同じ結果になるということである。
上面図の回転のプログラムは、(抜粋すると)下記のようになる。WM_PAINT 節の適切な場所に、下記のように書く。
なお、あらかじめグローバル変数で回転角を宣言しておく必要がある。C言語の角度計算ではラジアン単位なので、角度は実数型で宣言する必要がある。
なので double kaitenKaku; のように宣言する必要がある。
また、この回転角を増減するためのキーボード入力が必要なので、 case WM_KEYDOWN: に、たとえば下記のようなコードを追加する必要がある。(原理的にはマウス入力でもいいが、教えるのがメンドウくさい。)

なので、上下左右のキー入力コードとあわせると、下記のようになる。

さて、上面図の回転のテストに成功したら、同じ方法で、視界中の被写体も公転させればいい。視界を回転させるためには、カメラの自転ではなく、被写体すべての点を公転させたほうが(人間プログラマー側の検証のための)計算がラクである。
視界の回転のコード例は、下記のようになる。

被写体の投影の式でも、式
で、 係数の分母で「point_zahyou_list[1].z_zahyou - Camera_z」というふうに、回転前の座標位置 point_zahyou_list[1].z_zahyou を参照しているが、けっしてこれはミスではなく、平面投影では、このように回転前の座標値を係数に代入しなければならない。
(人間の目に近いと思われる)円柱投影や球面投影では、回転前の座標値を参照して投影するのは不合理だが、しかし平面投影では、むしろ、このように回転前の座標値を係数に代入しないと不合理になってしまう(試せばワカル)。理由を簡単に言うと、拡大率が平面投影では、カメラからの距離ではなく、スクリーンからの距離で決まるので、拡大率を一定するためにはスクリーンからの距離を一定にする必要がある。
なお、このプログラムだとカメラの反対側にある被写体も描画してしまう。なので、それら反対側の描画をカットする機能を、今後のプログラミングで追加する必要がある。
描画カットの方法は簡単で、単に被写体の回転後のz座標がマイナスになる場合には、描画しないように、if文で記述すればいい。
描画のrectangle命令を、下記のように if 文で書き換えればいい。つまり、描画する場合の条件として、回転後のz座標 カメラ座標 の数値がプラスの場合にだけ描画するようにプログラミングすればいい。
この方法でも、カメラが被写体に移りこんだ場合も正しく描画できる(理由は後述する)。式変形をして内積計算に変形もできる。
では、なぜこのように被写体の裏側にカメラの回りこんだ場合ですらも、上記のif判定式によってカメラが被写体の裏側に回り込んだ場合でも、正しく判定できるのか。
その理由は、不等式
を式変形すると、
となり、さらに変形して
となり、最終的にベクトルの内積の形に変形できて、
と式変形できる。2項目は0で掛け算しているので、2項目の積は0である。
最後の4番目の式は、これはベクトルの内積であり、
要するに、ベクトルの内積の符号の正負を使って、描画するか否かを判定しているだけにすぎない。

平面投影の特徴として、回転体の拡大縮小比率が不正確である。
たとえば、構図A
と、構図B
とでは、被写体がスクリーンにより近くにある構図Bのほうが、大きく見えてしまう。
なぜなら、(中学レベルの)相似比によって拡大縮小率が決まるので、つまりスクリーンとの距離によって拡大縮小率が決まるからである。
現実の人間の視界では、どう考えても、カメラから遠くにある被写体は小さく見えるハズであるが、しかし、平面投影では(カメラとの距離ではなく)、スクリーンとの距離によって拡大縮小率が決まる。
このように、平面投影では、実際の人間の視界とは異なる。
しかし、それでも私たちは、あえて平面投影を使うべきである。球面投影に対応するPC映像デバイスは無いし、円柱投影では縦横比が不正確になるからである(そのため、円柱投影は視界内にある回転体や球体・円盤などを描写するのが難しい)。

なお、平面投影を補正しようとしても無駄、あるいは補正にすらも欠点がある。
たとえば容易に思いつく案としては、「カメラ前方の視界中心にある被写体を、中心に近いほど拡大表示させよう」とするアルゴリズムの導入だろうが、これには下記のような欠点がある。

欠点とは、もし2個以上の被写体が近くどうしで並びあってる場合に、もし中心側の被写体を拡大したとすると、中心に近い側の被写体によって、その隣の被写体が隠される現象が発生しうる(現実の人間の視界では、そんな現象は起こりえない)。
結局、どうあがいても、平面投影では、どこかの描写が不正確になる。
ならば、視界の端っこに、不正確な部分を寄せたほうがイイ。好都合なことに、平面投影では、視界の中心ちかくは比較的に正確である。平面投影で不正確になるのは、視界の端っこである。
なので、平面投影のアルゴリズムは、あまり余計にイジラナイでいるほうが安全だし、管理もラクである。

地図の投影法の問題と同じで、本来は人間の二つの目による視界は球面投影に近いと考えられるので、たとえば地球儀を地図に置き換えたら原理的に不正確になるように、球面投影の視界を平面投影あるいは円柱投影に置き換えたら、どうあがいても不正確になる。
なので、あまり視野角を大きくしすぎないほうがイイ。
どうしても大きな視野が必要な場合は、対策として例えば望遠鏡のように、遠くから眺めるようプログラムにして、視野角そのものは広げないようにしたほうがイイだろう。