ホーム < ゲームつくろー! < DirectX技術編

その48 スクリーン座標でワールド空間の地面を指す


 マウスカーソルを用いる3DのRPGなどでは、カーソルで当たり前のように宝箱を指すことができます。でも良く考えてみると不思議です。マウスカーソルの座標は2次元のスクリーン座標(クライアント座標)です。それに対して宝箱は3次元のワールド空間座標にあります。座標系がぜんぜん違うのに遠近感も考慮して正しく指す事ができる。裏ではいったい何をしているのでしょうか?秘密はスクリーン座標からワールド座標を求めると言う、通常とは逆方向の変換にあります。

 この章では、スクリーン座標をワールド座標に変換する方法について見ていき、スクリーンに映っている床をワールド座標レベルで指し示す方法について検討してみましょう。



@ 決め手は「逆行列」

 ワールド座標に点Pがあったとします。その点を何らかの行列Mによって移動してみます。DirectXの場合、行列は点に対して右側に掛けますから、移動後の点の座標Qは次のような式になります:

この計算で点Pは点Qに移動します。では、逆に点Qを点Pにするにはどうしたら良いでしょうか?

 これには行列が持つ素敵な性質を利用します。行列M(正しくは正則行列)にあるうまい行列を掛け算すると、それが単位行列になる事があります。単位行列とは対角線が全部1で他は全部ゼロという行列の事です。単位行列をどのような行列に掛け算しても、結果は元の行列と同じになります。定数で言うと1と同じ性質を持っているわけです。そこで、、行列Mを単位行列にするうまい行列があるとして、上の式の両辺に右から掛けてみます:

M-1がその上手い行列で、Iはその結果得られた単位行列です。結果として、点Qを点Pに変換する式が出来ました。この式を成り立たせるうまい行列の事を「逆行列」と言います。

 ある行列の逆行列を得る方法はちゃんとありますが、実は凄い面倒です。しかし、Direct3DXにはうれしい事に4×4行列の逆行列を算出してくれるD3DXMatrixInverse関数が用意されています。ありがたく使わせてもらいましょう。



A ワールド空間の点の行き先

 @の基礎知識を使って、スクリーン座標をワールド座標に変換する方法を考えてみます。

 ワールド空間にある点P(Wx,Wy,Wz,1.0f)にビュー行列を掛けると、点Pはカメラの前に来るように変換されます(カメラ空間への変換)。さらにその点に射影変換行列を掛け算すると、カメラが見ている範囲にある点を上下左右ぎゅーーっと凝縮して、(-1,-1,0,1)〜(1,1,1,1)という直方体の領域内に納めてしまいます。この領域内にある点は描画可能点として扱われます。

 IDirect3DDevice9を使っていると、この行列は必ず出てきます。ところが、実は裏ではこの後にもう1つ「ビューポート行列」という行列が掛けられています。ビューポート行列は、射影変換によって小さな箱に収められてしまった点を、スクリーンの大きさに引き伸ばす役目をしています。例えば、スクリーンが640×480ピクセルだったとしましょう。この時、射影空間の左下にある(-1,-1,0,1)という点はスクリーンの(0,480,0,1)に、カメラも右上端にある(1,1,1,1)は(640,0,0,1)に変換されます。移動後はスクリーン座標なのでY軸が反転しています。よってY座標が上下逆さまになる点に注意してください。

 ビューポート行列はDirectXのマニュアルに記載されていますが、簡易化すると以下のようになります:

Scrx、Scryはスクリーンの幅と高さです。射影変換されたSxとSyにこの行列が掛け算されるとスクリーン座標に変換されます。

 ワールド空間にある点は実に3つの行列を掛けて、はるばるスクリーン座標にまで到達しているわけです。式で書きますと、

となります。sはスクリーン座標、wはワールド空間にある点の座標、Vはビュー、Prjは射影変換そしてVpはビューポート行列です。



B 逆行列でワールドへ戻そう

 上の式からワールド座標wを抽出します。今回は行列が3つもありますが、少しずつ逆行列を掛けていけば大丈夫です。まず、Vpを消してみます。そのためにはVpの逆行列を右から掛けるのでした。

右辺からVpが消えました。次はPrjの逆行列を掛け算します。同様に右辺からPrjが消えますので、さらにVの逆行列もかけます。最終的に、wを抽出する式はこうなります:

こうすると、スクリーン座標をワールド空間座標に戻す事ができます。



C スクリーン座標は超長い線の先にある点を見たもの

 ところでスクリーン座標は2Dですから、Z成分が見えません。でも、ワールド空間は3DなのでZ座標がしっかりとあります。これは「スクリーン座標だけではワールド空間の1点を決められない」ことを意味しています。2次元の座標で3次元の点は指せないわけです。実世界でもそうですが、スクリーン座標の向こうにはなが〜い直線が延びています。スクリーン座標の一点はその直線の先でぶつかった何らかの点を見ている状態です。

 この直線にZ成分を加えると、ワールド座標のどこか1点をピンポイントで指し示す事が出来ます。ではそのZ成分は何か?これは「射影空間の範囲にあるZ値」です。カメラ空間にある「見える点」を射影変換すると、そのZ座標は0〜1の間に収まります。ビューポート変換を掛けてもZ値はそのままスルーされますので、スクリーン座標のZ値も0〜1に収まっています。

 そこで、スクリーン座標にZ座標を復活させて、スクリーン座標の向こうにある長い直線(線分)を算出してみます。まずあるスクリーン座標(Sx, Sy)のZ座標を0として、(Sx,Sy,0)としておきます。この点をワールド空間に戻すと、Z成分は「カメラが捉えられる最も近い点のZ値」となります。一方、z=1として同じ逆変換をすると、今度は「カメラが捉えられる最遠点」となります。この2点を結ぶとスクリーンの1点が見つめているなが〜〜〜い直線となります。この直線が地面に当たる場所。そこが、今回私たちが求めたい点です。


D 平面と直線の交点

 超長い直線というのはちょっと曖昧な表現ですから、きちっと「点と方向ベクトル」で表しておきます。直線上の1点は最近点P、方向ベクトルは最近点から最遠点に向かうベクトルvとします。交わる地面はXZ平面とします。平面と線分の交点を求める方法は、○×でまだ記事にしていませんでした(^-^;。ゴメンナサイ。そこで、今ここでやっておきます。

 平面は法線と平面上の一点で定義されます。直線は先程申した通りです。直線が平面と平行でなければ、いつかどこかで必ず交わります。平面の法線(標準化しておきます)をN、平面上の一点をP0としておきます。また、求める交点をQとしておきましょう。

 点Pから平面までの距離は、ベクトルPP0と法線の内積で求まります。これは、内積の持つ性質です(あるベクトルをもう一方の標準化ベクトルに射影した時の長さ)。一方ベクトルvも同様に法線に射影すると、射影長が得られます。前者をLp0、後者をLvとすると、ベクトルvをLp0/Lv倍すると、ちょうど点Pから平面に到達する長さとなります:

 上の図だと射影長Lp0の方がLvよりも長いので、Lp0/Lv(1.5くらいかな)をベクトルvに掛けると、ちょうどQに当たるのが分かると思います。内積には符号がありますが、今回の場合符号はうまく整合性が取れますので絶対値をつけなくても大丈夫です。最終的に、点Qの位置を求めるには次の式を利用します:


 スクリーン座標を貫く超長い直線と、XZ平面(法線はY軸)との交点は、この式から直ぐに求めることができます。スクリーン座標から逆変換した最近点をWn(点P)、最遠点をWf、平面の法線Nを(0,1,0)、平面上の点P0は原点Oで良いでしょう。これよりベクトルv=Wf-Wn、ベクトルPP0=O-Wnになります。これを上の式に代入すると、スクリーンから見た平面の1点Qを求める式はこうなります:



E 床と交差しない問題

 さて、めでたく床との交差式は導けましたが、ちょっと問題も残っています。Direct3Dのカメラ(ビュー行列と射影変換行列)の性質上、実はスクリーン上の点を延々とどこまで伸ばしても床と交差しない状況が生まれることがあります。どういうことか、以下の図をご覧くさい:


 これは床を真横から見ている状態です。青いスクリーンがカメラの前にあります。カメラは下を向いて床の大部分を写していますが、視野角が大きいと上の図の点線より上の範囲ではスクリーンに床が映りません。無いものは取得できないわけですが、Bの式を使うとカメラの後ろの座標が示されてしまいます。床との交差判定としては合っていますが、それは私たちが欲しい点ではありません。

 床が映らないのはWf-Wnで算出される方向ベクトルのY成分が0以上になった時です。この時は特別計算をする必要があります。と言っても交わらないため、妥協案として床ではなくて「遠く向こうにある壁」を指すようにするのが良いかなと思います。Direct3Dで言う遠く向こうというのは、Z値が1である地点の事で、ここではWfそのものです。床と交わらない時はWfとすることで、スクリーンに見えるどの場所でも一応値が算出されるようになります。



F 公開、スクリーン座標点と地面の交点算出関数

 今回の理屈を基にしたスクリーン座標点と地面との交点(ワールド空間)を算出する関数を公開します:

スクリーン座標をワールド座標に変換( CalcScreenToWorld関数 )
// スクリーン座標をワールド座標に変換
D3DXVECTOR3* CalcScreenToWorld(
   D3DXVECTOR3* pout,
   int Sx,  // スクリーンX座標
   int Sy,  // スクリーンY座標
   float fZ,  // 射影空間でのZ値(0〜1)
   int Screen_w,
   int Screen_h,
   D3DXMATRIX* View,
   D3DXMATRIX* Prj
) {
   // 各行列の逆行列を算出
   D3DXMATRIX InvView, InvPrj, VP, InvViewport;
   D3DXMatrixInverse( &InvView, NULL, View );
   D3DXMatrixInverse( &InvPrj, NULL, Prj );
   D3DXMatrixIdentity( &VP );
   VP._11 = Screen_w/2.0f; VP._22 = -Screen_h/2.0f;
   VP._41 = Screen_w/2.0f; VP._42 = Screen_h/2.0f;
   D3DXMatrixInverse( &InvViewport, NULL, &VP );

   // 逆変換
   D3DXMATRIX tmp = InvViewport * InvPrj * InvView;
   D3DXVec3TransformCoord( pout, &D3DXVECTOR3(Sx,Sy,fZ), &tmp );

   return pout;
}
スクリーン座標とXZ平面のワールド座標交点算出( CalcScreenToXZ関数 )
// XZ平面とスクリーン座標の交点算出関数
D3DXVECTOR3* CalcScreenToXZ(
   D3DXVECTOR3* pout,
   int Sx,
   int Sy,
   int Screen_w,
   int Screen_h,
   D3DXMATRIX* View,
   D3DXMATRIX* Prj
) {
   D3DXVECTOR3 nearpos;
   D3DXVECTOR3 farpos;
   D3DXVECTOR3 ray;
   CalcScreenToWorld( &nearpos, Sx, Sy, 0.0f, Screen_w, Screen_h, View, Prj );
   CalcScreenToWorld( &farpos, Sx, Sy, 1.0f, Screen_w, Screen_h, View, Prj );
   ray = farpos - nearpos;
   D3DXVec3Normalize( &ray, &ray );

   // 床との交差が起きている場合は交点を
   // 起きていない場合は遠くの壁との交点を出力
   if( ray.y <= 0 ) {
      // 床交点
      float Lray = D3DXVec3Dot( &ray, &D3DXVECTOR3(0,1,0) );
      float LP0 = D3DXVec3Dot( &(-nearpos), &D3DXVECTOR3(0,1,0) );
      *pout = nearpos + (LP0/Lray)*ray;
   }
   else {
      *pout = farpos;
   }

   return pout;
}

 最初の関数はスクリーン座標をワールド座標に変換する関数です。Z値(0.0f〜1.0fの範囲)があるのが特徴です。このZ値は射影空間のZ値であることに注意してください。下の関数はスクリーン座標とワールド空間のXZ平面との交点を算出する関数です。スクリーン座標が床と交差しない場合は遠くにある壁(Z値1.0の壁)の座標が返されます。


 上の関数はスクリーンからワールド空間にある点を示す一つの方法として広く応用できると思います。下の関数はその適用例の1つでもありますが、レイとオブジェクトの交差判定を行なえば、ワールド空間にあるオブジェクトをスクリーン上から指し示す事も可能だと思いますので、色々カスタマイズしてみてください。