Direct X8.0 :バックミラー


~レースゲーでよくある後ろを見るあれ~




■はじめに

今回は、バックミラーを作ってみました(画面では、前にいるクマがバックミラーに移っていますが、そこは適当に補正して下さい)。
私の勤めている会社では、レースゲームも作っていますが、今回はそれらの技術は全く見ていません(おい、勉強のために見とけよ!)。

今回のソースは、次のものです。

内容は次のとおりになっています。

main.h基本的な定数など。今回も出番無し。
main.cpp描画に関係しないシステム的な部分。変更が無いので、出番無し。
draw.h描画の各関数の定義。特に意味無いので出番無し。
draw.cppメインの描画部分。主にここが説明される。
vs.vshオブジェクト(車)表示用の頂点シェーダープログラム。平行光源ライト。

あと、モデルとして、nsx.xと、実行ファイルの MyBase.exe 及び、 VC++ でコンパイルするためのプロジェクトファイル MyBase.dsw MyBase.dsp が入っています。

■基本方針

どのような手順でバックミラーを作るのか説明します。

基本的には、通常の描画とバックミラー用の描画を2回行います。
最初に、普通に描画をしてから、表示領域を切り変えて、バックミラーの中を描画します。
その時に、zバッファ(深度バッファ)が残っていると、深度値が前に描いた後ろになった場合に描画されないので、 一度深度バッファをクリアします。

DirectX には、描画領域を変更するための関数 SetViewport が用意されています。
バックミラーを描画する時には、この関数を呼び出し描画領域を変更してから描画します。
また、現在の描画領域を所得する関数 GetViewport も同時に用意されています。
これは、最初の領域を保存しておいて、バックミラーの描画が終了した後、元の状態に戻すため等に使います。
そのための構造体や関数の使い方は、次のとおりになります。

typedef struct _D3DVIEWPORT8 {
    DWORD       X, Y;           // 描画範囲の左上
    DWORD       Width, Height;  // 描画範囲の大きさ
    float       MinZ, MaxZ;     // 描画後の深度値の最低値を最大値
} D3DVIEWPORT8;

D3DVIEWPORT8 viewData = { 0, 0, width, height, 0.0f, 1.0f };

lpD3DDev->SetViewport(&viewData);
lpD3DDev->GetViewport(&viewData);

描画領域のデータ用の構造体 D3DVIEWPORT8 に、MinZ, MaxZ があります。
この値を共に 0.0fにすれば、描画する時に最前面に描画してくれるのですが、Zバッファが効かない描画になるので、 今回は通常に深度値を書き込むようにしました (MinZ=0.0f, MaxZ=1.0f)。

さて、バックミラーは鏡です。従って左右反転して表示されなければなりません。
『左右反転して描画』なんてコマンドは用意されないので、自分で何とかしなくてはなりません。
左右反転とは、文字通り右のものが左になることです。
視線方向をZ軸、上向きをY軸とすると、横軸はX軸になります。
従って、視線をZ軸にした世界(ビュー座標系)で、xを-xにする変換をかませれば、左右反転が表現されます。
プログラム的には、ワールド行列とビュー行列の間にX軸を反転させる行列をかまします。
すると、元の世界が左右反転されてから視線に入るので、鏡の世界が表現されたことになります。

ここで注意しなければならないことは、鏡の世界では回転が反対になる(右周りが左回りになる)ということです。
何をいいたいのかというと、表裏判定のカリングが反対になるということです。
両面を描画している時は問題が無いのですが、片面しか描画しない場合は、カリングのモードを逆にしなくてはなりません。

以上で説明は終わりです。次に実際のソースを見ましょう。

■実際のプログラム

今回は、レンダリングを2回行います。
1回目は、普通のレンダリングで、2回目はバックミラーのレンダリングです。

const DWORD	WIN_W  = 400;
const DWORD	WIN_H  =  80;
const DWORD	WIN_X  = (WIDTH - WIN_W)/2;
const DWORD	WIN_Y  =  30;

void Render(LPDIRECT3DDEVICE8 lpD3DDEV)
{
    if(NULL == pMeshVB) return;
    
    D3DVIEWPORT8 viewport={WIN_X, WIN_Y, WIN_W, WIN_H, 0.0f, 1.0f};
    D3DVIEWPORT8 viewport_bak;
    lpD3DDEV->GetViewport(&viewport_bak);        //    元のビューポート矩形を取っておく

    for(int n = 0; n < 2; n++){
        D3DXMATRIX mWorld, mView, mProj, m;

        D3DXVECTOR3 eye    = D3DXVECTOR3(0.0f, 10.0f, 30.0f);
        D3DXVECTOR3 lookAt = D3DXVECTOR3(0.0f,  3.0f,  0.0f);
        D3DXVECTOR3 up     = D3DXVECTOR3(0.0f,  1.0f,  0.0f);
        if(0 == n){
            // 通常表示
            D3DXMatrixLookAtLH(&mView, &eye, &lookAt, &up);
            D3DXMatrixPerspectiveFovLH(&mProj
                ,60.0f*PI/180.0f                        // 視野角
                ,(float)WIDTH/(float)HEIGHT             // アスペクト比
                ,0.01f,100.0f                           // 最近接距離,最遠方距離
                );
        }else{
            // バックミラー表示
            lpD3DDEV->SetViewport(&viewport);
            lpD3DDEV->Clear(0,NULL,D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER,D3DCOLOR_XRGB(0,0x40, 0x40),1.0f,0);
            lpD3DDEV->SetRenderState(D3DRS_CULLMODE, D3DCULL_CW);
            
            eye.z   = -30.0f;                            // 窓で表示するときは、反対から見る
            D3DXMatrixLookAtLH(&mView, &eye, &lookAt, &up);
            D3DXMatrixIdentity(&m);
            m(0,0)=-1.0f;                                // x軸に関して鏡像を取る
            mView = m * mView;
            D3DXMatrixPerspectiveFovLH(&mProj
                ,20.0f*PI/180.0f                        // 視野角
                ,(float)WIN_W/(float)WIN_H              // アスペクト比
                ,0.01f,100.0f                           // 最近接距離,最遠方距離
                );
        }
        D3DXMatrixRotationY( &mWorld, timeGetTime()/1000.0f );

        m = mWorld * mView * mProj;
        D3DXMatrixTranspose( &m ,  &m);
        lpD3DDEV->SetVertexShaderConstant(0,&m, 4);

        D3DXVECTOR4 lightDir(1.0f, 1.0f, 0.5f, 0.0f);
        D3DXVec4Normalize(&lightDir, &lightDir);
        D3DXMatrixInverse( &m,  NULL, &mWorld);
        D3DXVec4Transform(&lightDir, &lightDir, &m);
        lightDir[3] = 0.3f;// 環境光の強さ
        lpD3DDEV->SetVertexShaderConstant(13, &lightDir, 1);

        lpD3DDEV->SetVertexShader(hVertexShader);

        //メッシュの描画
        lpD3DDEV->SetStreamSource(0, pMeshVB, sizeof(D3D_CUSTOMVERTEX));
        lpD3DDEV->SetIndices(pMeshIndex,0);
        for(DWORD i=0;i//色をセット
            D3DXVECTOR4 vl;
            vl.x = pMeshMaterials[i].Diffuse.r;
            vl.y = pMeshMaterials[i].Diffuse.g;
            vl.z = pMeshMaterials[i].Diffuse.b;
            lpD3DDEV->SetVertexShaderConstant(14, &vl, 1);

            lpD3DDEV->SetTexture(0,pMeshTextures[i]);
            lpD3DDEV->DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 
                                            pSubsetTable[i].VertexStart,
                                            pSubsetTable[i].VertexCount,
                                            pSubsetTable[i].FaceStart * 3,
                                            pSubsetTable[i].FaceCount);
        }
    }
    
    lpD3DDEV->SetViewport(&viewport_bak);            // ビューポート矩形を戻す
    lpD3DDEV->SetRenderState(D3DRS_CULLMODE, D3DCULL_CCW);
    lpD3DDEV->SetTexture(0, NULL);
}

あまり重要でない部分は省略しました。
1回目と2回目のレンダリングの違いは、下のソースの黄色の部分と青緑色の部分です。
1回目は、今までと同じレンダリングを行います。
2回目は、ビューポートを切り替えた後に、Zバッファをクリアするための画面消去を行います。
次に、鏡の世界は左右反転していて、右回りが左回りになるので、カリングのためのポリゴンの表裏反転を逆転します。
また、視線の覗く方向(eye.z)を反対にしました。これで、前から後ろを移した画面になります。
さらに、鏡の世界ということで、YZ平面に関して鏡像反転をビュー行列の直前に施します。 これで、カメラに反対向きに写って、表示されます。
最後に、この後の描画のために、ビューポートを元に戻して起きます。

■モデル表示(おまけ)

モデル表示です。
普通に表示しますが、今回はicemanさんに触発されて高速化に挑戦してみました(でも、6命令まで届かない・・・)。

今回の工夫は法線ベクトルのw成分を使ったことです(Masaさんのおっしゃられていた方法かな?)。
法線ベクトルのw成分に1.0fを入れておきます(普通は0が多いかな?)。
すると、dp4 命令を使った時にw成分が残るので、この成分を方向に依存しない環境光に用いることができます。
欠点は、平行光源と環境光の色に同じ色を使わなければいけないことです。
【後日談】suzunaさんから、D3DVSDT_FLOAT3 にすると、w成分に自動的に1.0fを入れてくれるので、そちらを使ったほうが手軽だそうです。

typedef struct {
    float x,y,z;
    float nx,ny,nz,nw;
    float tu0,tv0;
}D3D_CUSTOMVERTEX;
#define D3DFVF_CUSTOMVERTEX (D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_TEX1)

DWORD dwDecl[] = {
    D3DVSD_STREAM(0),
    D3DVSD_REG(D3DVSDE_POSITION, D3DVSDT_FLOAT3 ),            //D3DVSDE_POSITION,  0
    D3DVSD_REG(D3DVSDE_NORMAL,   D3DVSDT_FLOAT4 ),            //D3DVSDE_NORMAL,    3
    D3DVSD_REG(D3DVSDE_TEXCOORD0,D3DVSDT_FLOAT2 ),            //D3DVSDE_TEXCOORD0, 7
    D3DVSD_END()
};

実際の頂点シェーダーは次の7命令になります。

; c0-3   -- world + ビュー + 透視変換行列
; c13    -- ライトのベクトル (w成分は環境光の強さ)
; c14    -- ライトの色(メッシュの色)
;
; v0	頂点の座標値
; v3	法線ベクトル (w成分は1.0f)
; v7	テクスチャ座標

vs.1.0

;座標変換
dp4 oPos.x,	v0,	  c0
dp4 oPos.y,	v0,	  c1
dp4 oPos.z,	v0,	  c2
dp4 oPos.w,	v0,	  c3

; ((l,n) + l.w)*c14 (平行光源のライティング)
dp4 r0.w,   v3,   c13
mul oD0,    r0.w, c14

; テクスチャーを張る
mov oT0,    v7

dp4 を使って、法線と光の向きから光の強さを求めた後、ライトやメッシュの色を乗せます。
実際には、下の設定部分で分かるように、メッシュの色をそのまま乗せているので、白色光が当っていることになります。
環境光の強さは0.3fです(平行光源が面に垂直に当った時との比)。

また、今回は、ライトの方向にワールド行列の逆行列を掛けました。
これは、ローカル座標で光源計算を行う事に相当します。
頂点シェーダーで、毎頂点ワールド座標での法線ベクトルを用いる(数千回程度?)よりも、 ライトをローカル座標に変換するほうが(1回のみ)、計算量ははるかに少ないです。
以下の部分が頂点シェーダーの固定レジスタを代入する部分です。

m = mWorld * mView * mProj;
D3DXMatrixTranspose( &m ,  &m);
lpD3DDEV->SetVertexShaderConstant(0,&m, 4);

D3DXVECTOR4 lightDir(1.0f, 1.0f, 0.5f, 0.0f);
D3DXVec4Normalize(&lightDir, &lightDir);
D3DXMatrixInverse( &m,  NULL, &mWorld);
D3DXVec4Transform(&lightDir, &lightDir, &m);
lightDir[3] = 0.3f;// 環境光の強さ
lpD3DDEV->SetVertexShaderConstant(13, &lightDir, 1);

//色をセット
D3DXVECTOR4 vl;
vl.x = pMeshMaterials[i].Diffuse.r;
vl.  = pMeshMaterials[i].Diffuse.g;
vl.z = pMeshMaterials[i].Diffuse.b;
lpD3DDEV->SetVertexShaderConstant(14, &vl, 1);

■モデル表示(テクスチャーによるライティング)

BBS で、suzunaさんや、Kanoさんicemanさんにコメントをいただきました。
上記の方法では、内積の値が負になると環境光の成分を消してしまうので、最終的に黒い部分ができてしまいます。
ということで、テクスチャーによるライティングをしました。

ファイルの中身は、

vs.vshオブジェクト表示用の頂点シェーダープログラム。平行光源ライト。
draw.cppテクスチャーライティング用に一部修正。
main.h基本的な定数など。今回も出番無し。
main.cpp描画に関係しないシステム的な部分。fps表示を追加。
draw.h描画の各関数の定義。特に意味無いので出番無し。
font.hfps 表示用。既出。
font.cppfps 表示用。既出。
normal_transform.vshおまけ。前回のライトの計算をワールド座標で行う。

と、実行ファイルやXfile、プロジェクトファイルおよびライティング用のテクスチャーlight.bmpが入っています。
light.bmp は

に、なっています。
環境光を緑、ディフューズ光を赤にするために、左(u=0)が緑、右(u=1)を赤にしました。

頂点シェーダープログラムは、次になります。
トゥーンと同じように、マルチテクスチャーで光源用のテクスチャーを張ります。

vs.1.0

dp4 oPos.x,   v0,   c0    ; 座標変換
dp4 oPos.y,   v0,   c1
dp4 oPos.z,   v0,   c2
dp4 oPos.w,   v0,   c3

dp4 oT0.x,    v3,   c13   ; l dot n (ライティング)
mov oT1,      v7          ; テクスチャーを張る

やっと、6命令という非常に短いプログラムになりました。

描画部分はツゥーンと同じような設定になります。違いを黄色で塗りました。

void Render(LPDIRECT3DDEVICE8 lpD3DDEV)
{
    if(NULL == pMeshVB) return;
    
    D3DVIEWPORT8 viewport={WIN_X, WIN_Y, WIN_W, WIN_H, 0.0f, 0.0f};
    D3DVIEWPORT8 viewport_bak;
    lpD3DDEV->GetViewport(&viewport_bak);        //    元のビューポート矩形を取っておく

    for(int n = 0; n < 2; n++){
        D3DXMATRIX mWorld, mView, mProj, m;

        D3DXMatrixRotationY( &mWorld, timeGetTime()/1000.0f );
        
        D3DXVECTOR3 eye, lookAt, up;
        eye.x    = 0.0f; eye.y      = 10.0f; eye.z    = 30.0f;
        lookAt.x = 0.0f; lookAt.y   =  3.0f; lookAt.z =  0.0f;
        up.x     = 0.0f; up.y       =  1.0f; up.z     =  0.0f;
        if(1 == n){
            // 窓で表示するときは、反対から見る
            eye.z   = -30.0f;
            D3DXMatrixLookAtLH(&mView, &eye, &lookAt, &up);
            D3DXMatrixIdentity(&m);
            m(0,0)=-1.0f;
            mView = m * mView;
            D3DXMatrixPerspectiveFovLH(&mProj, 20.0f*PI/180.0f ,(float)WIN_W/(float)WIN_H ,0.01f ,100.0f);
        }else{
            D3DXMatrixLookAtLH(&mView, &eye, &lookAt, &up);
            D3DXMatrixPerspectiveFovLH(&mProj, 20.0f*PI/180.0f ,(float)WIDTH/(float)HEIGHT ,0.01f ,100.0f);
        }
        m = mWorld * mView * mProj;
        D3DXMatrixTranspose( &m ,  &m);
        lpD3DDEV->SetVertexShaderConstant(0,&m, 4);
        D3DXVECTOR4 lightDir(1.0f, 1.0f, 0.5f, 0.0f);
        D3DXVec4Normalize(&lightDir, &lightDir);
        D3DXMatrixInverse( &m,  NULL, &mWorld);
        D3DXVec4Transform(&lightDir, &lightDir, &m);
        lpD3DDEV->SetVertexShaderConstant(13, &lightDir, 1);

        lpD3DDEV->SetTextureStageState(0,D3DTSS_ADDRESSU,    D3DTADDRESS_CLAMP);
        lpD3DDEV->SetTextureStageState(0,D3DTSS_COLOROP,      D3DTOP_SELECTARG1);
        lpD3DDEV->SetTextureStageState(0,D3DTSS_COLORARG1,    D3DTA_TEXTURE);
        lpD3DDEV->SetTextureStageState(0,D3DTSS_MAGFILTER,    D3DTEXF_LINEAR);
        lpD3DDEV->SetTextureStageState(0,D3DTSS_MINFILTER,    D3DTEXF_LINEAR);
        lpD3DDEV->SetTextureStageState(1,D3DTSS_COLOROP,D3DTOP_MODULATE);
        lpD3DDEV->SetTextureStageState(1,D3DTSS_COLORARG1,D3DTA_TEXTURE);
        lpD3DDEV->SetTextureStageState(1,D3DTSS_COLORARG2,D3DTA_CURRENT);
        lpD3DDEV->SetTextureStageState(1,D3DTSS_MAGFILTER,D3DTEXF_LINEAR);
        lpD3DDEV->SetTextureStageState(1,D3DTSS_MINFILTER,D3DTEXF_LINEAR);
        
        lpD3DDEV->SetVertexShader(hVertexShader);
        lpD3DDEV->SetTexture(0,pTexture);

        //メッシュの描画
        lpD3DDEV->SetStreamSource(0, pMeshVB, sizeof(D3D_CUSTOMVERTEX));
        lpD3DDEV->SetIndices(pMeshIndex,0);
        for(DWORD i=0;ilpD3DDEV->SetTexture(1,pMeshTextures[i]);
            lpD3DDEV->DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 
                                            pSubsetTable[i].VertexStart,
                                            pSubsetTable[i].VertexCount,
                                            pSubsetTable[i].FaceStart * 3,
                                            pSubsetTable[i].FaceCount);
        }
        if(n==0){
            frame_back_draw(lpD3DDEV);
            lpD3DDEV->SetViewport(&viewport);            //    新しいビューポート矩形を設定する
            lpD3DDEV->SetRenderState(D3DRS_CULLMODE, D3DCULL_CW);
        }else{
            lpD3DDEV->SetViewport(&viewport_bak);        // ビューポート矩形を戻す
            lpD3DDEV->SetRenderState(D3DRS_CULLMODE, D3DCULL_CCW);
        }
    }
    
    lpD3DDEV->SetTexture(0, NULL);
    lpD3DDEV->SetTexture(1, NULL);
}

テクスチャーステージ0はテクスチャー(ライティング)のみを使用して、テクスチャーステージ1はテクスチャー(メッシュ)とテクスチャーステージ0の結果を合成する設定にしました。
メッシュのテクスチャーを後回しにしたので、テクスチャーがないモデルでも正常に動作します。

今回は、ライトの w 成分を 0.0f にしました。
内積の値が負のときに同じ(環境光の)色になるように、このようにしましたが、ライトの成分を

l = (0.5*lx, 0.5*ly, 0.5*lz, 0.5)              ((lx, ly, lz)は正規化された方向ベクトル)

にすると、法線とライトのベクトルの内積が -1.0f~1.0f のときに、dp4 の結果は 0.0f~1.0f になるので、 内積が負の場合にもライトに表情をつけることができます。
今回使わなかった理由は、環境光+平行光源のモデルを用いたので、内積が負のときに同じ値となり、テクスチャーの半分の色が一色になってもったいないからです。
それよりも、同じテクスチャーサイズでもグラデーションに高い解像度を与えられる w = 0.0f を採用しました。
w = 0.5f 以外にするのは、テクスチャー位置の全体にずらすことに対応するので、動きをつけたり、テクスチャーを効率よく使用するために使えそうです。

実際のパフォーマンスですが、テクスチャーライティングの場合は 322fpsで、もともとのプログラムの場合は 326fps 出ていました。

トップに置いてあるプログラムのFPS

1%ほどと、小さいですが確実にテクスチャーライティングの方がパフォーマンスが悪かったでした。
また、計算量がより多い場合として、(normal_transform.vsh を使って) ワールド座標で光源計算した場合には 326fps と、ローカル座標で光源計算したときと同じ結果が出ました。
実験したのが Celeron 450MHz Dual の非力なマシンなので、そちらがボトルネックになっていることが考えられますが、

命令数が少ない=実行速度が速い

とはいえないようです。

■最後に

今回は、バックミラーを作ってみました。
適当に作ったので、きれいにまとめられていません。 皆さんは、ビュー行列や描画範囲をクラス化したりして、 いい感じのオブジェクト等にまとめてくださいね。




もどる

imagire@gmail.com