ホーム < ゲームつくろー! < 新・ゲーム制作技術編
その3 スクリーン座標にテクスチャの一部を2D描画する仕組み
現在のゲームの花形は3D描画です。でも、3D描画だけでゲームは作れません。画面内を構成している情報はほぼすべて2D描画ですし、メニュー等は2Dの方が主体です。3Dを使わないゲームも沢山あるわけで、その中ではすべてが2D描画です。3Dは無くてもゲームは作れるけど、2D無しには無理。そう、2D描画はゲーム制作に「必須」なんです。
DirectXなどの3Dアーキテクチャは3D描画用に作られているため、2D描画へのサポートはごくわずか…(:_;)。必須なのにあんまりな扱い…。それも正しく使わないとうまく描画してくれません。そこでこの章では2D描画のしくみをしっかりとまとめてみました。最終的にテクスチャの一部を矩形に切り取って画面の好きな場所に描画する仕組みを整えます。
@ DirectXにおける「2D描画」とは?
DirectXにはスクリーンに直接絵を描画する仕組みがありません。3D描画用に特化したエンジンなので2D描画は逆に苦手なんです。でも、最終的にスクリーンに点を穿つという事はしているため出来ない事はありません。
DirectXは「ポリゴン」に「テクスチャ」を貼り付ける事で初めて画面に絵を出せます。2D描画の場合もこれは同じで、「2D描画用の矩形なポリゴン(板ポリゴン)」を作り、それにテクスチャを貼れば2D描画ができます。でも、これだけだと曖昧な所だらけです。ちょっとずつ曖昧を無くしていきましょう。その曖昧を無くすために、「逆方向のアプローチ」をしていきたいと思います。
みなさん「頂点以外行列を一切設定しないで描画」した事がありますでしょうか?描画デバイスに頂点を渡すだけ。ワールド変換行列もビュー行列も射影変換行列も設定しない。この超初期状態でもDirectXはポリゴンを描画してくれます。描画デバイスに行列を一つも設定しない場合、すべての行列は「単位行列」になっています。要は何もしないで設定した頂点を素通りさせるわけです。では、次のような頂点で作られるポリゴンを描画デバイスに渡して(IDirect3DDevice9::DrawPrimitiveUPメソッド)描画してみます:
float vtx[9] = {
0.0f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f,
0.5f, 0.0f, 0.0f,
};
これは次のような小さなポリゴンです:
本当に何も行列を設定せずに頂点だけを描画デバイスに渡してポリゴンを描画すると次のようになります:
float vtx[9] = {
0.0f, 0.0f, 0.0f,
0.0f, 0.5f, 0.0f,
0.5f, 0.0f, 0.0f,
};
// メッセージ ループ
do{
if( PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) ){ DispatchMessage(&msg);}
else{
// Direct3Dの処理
g_pD3DDev->Clear( 0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0,0,255), 1.0f, 0 );
g_pD3DDev->BeginScene();
g_pD3DDev->SetFVF( D3DFVF_XYZ );
g_pD3DDev->DrawPrimitiveUP( D3DPT_TRIANGLELIST, 1, vtx, sizeof(float) * 3 );
g_pD3DDev->EndScene();
g_pD3DDev->Present( NULL, NULL, NULL, NULL );
}
}while(msg.message != WM_QUIT);
いやぁ、奥深い。色々な事がわかります。まず、スクリーンのど真ん中が頂点を定義した座標系の原点と一致している事がわかります。そして、(0.0f, 0.5f)という頂点1は、スクリーンの上から高さ丁度4分の1の所に位置し、(0.5f, 0.0f)の頂点2はスクリーンの右から4分の1の所に位置しています。つまり、「スクリーンの左上は頂点座標では(-1.0f,1.0f)、右下は(1.0f, -1.0f)である」という事がわかりますね。頂点座標は、実はスクリーン上の座標を比率で表していたんです!
先ほどは頂点番号0,1,2の順(右回り)で作ったポリゴンでした。今度は0,2,1の左回り順で作ったポリゴンを渡してみると…:
奥深い!全く同じ位置にあるはずのポリゴンが描画されません。ここから「右回り順に作ったポリゴンが表示される」事がわかりました。
先ほどのポリゴン、横長に伸びています。これはスクリーンが長方形だからです。できれば、スクリーンの形に依存せずに世界の上下の長さの比率が一緒になった方が人に優しいですよね。ではどうするか?横長であるならば、横方向に縮めちゃえばいいんです。つまり「スケール変換」をします。上のウィンドウの大きさは(1010, 489)です(やべ、めんどくさ(^-^;)。縦方向を基準とするならば、横方向は489/1010だけ縮めます。このスケール変換行列は、実はワールド、ビュー、射影変換のどこに設定しても構いません。ただ、ワールド変換行列は個々のポリゴンに設定する物ですし、ビュー行列はカメラの姿勢と関連するものです。スクリーンレベルの伸縮は射影変換行列に設定します。という事で、IDirect3DDevice9::SetTransformメソッドに次のように設定してみましょう:
float w = 1010;
float h = 489;
// 射影変換行列
D3DXMATRIX proj(
h/w , 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f
);
dev->SetTransform( D3DTS_PROJECTION, &proj );
これで射影変換行列としてスケール変換が入りました。この状態で描画すると…:
※ちょっとスクリーンサイズを小さくしました(^-^;
お〜〜見た目に縦横の長さの比率が揃いました。コード中のwとhにスクリーン(クライアントサイズ)の幅と高さを正しく入れれば、どんなスクリーンでも縦横比は大丈夫です。ただですねぇ…下の2枚のウィンドウをご覧ください:
左側は(300, 200)、右側は(300, 600)というクライアントサイズです。ポリゴンの見た目の縦横比は問題ないのですが、ポリゴン自体の大きさが違っています。先ほど縦方向(Y軸方向)を基準にしたため、ウィンドウを縦に伸ばすと三角形が引き延ばされて大きくなってしまうんです。2D描画をするのであれば、ポリゴンは「ピクセル単位」で扱いたいわけです。つまり、ウィンドウの大きさに限らず見た目の大きさも同じにしたいという事です。
見た目の絶対的な大きさは、ポリゴンの頂点座標で指定するのが自然です。先ほどのポリゴンは大きさわずか0.5の小さな小さなポリゴンです。これを以下のピクセル単位に変更してみます:
float vtx[9] = {
0.0f, 0.0f, 0.0f,
0.0f, 150.0f, 0.0f,
150.0f, 0.0f, 0.0f,
};
縦横を150ピクセルだとしました。今スクリーンは縦方向が-1.0f〜1.0fという非常に小さい範囲でしかないため、このまま描画してもポリゴンを拡大し過ぎて画面中真っ暗です。どうするか?そう、先ほどと同様にスケール変換をかけるんです。スクリーンの縦方向は今長さ2.0fですよね。スクリーンの縦ピクセル数はhです。よってピクセル数をスクリーンの長さに変換するには、[ピクセル]×(2/h)ですよね。例えばスクリーンが縦600ピクセル(=h)だとして縦150ピクセルならば、150*2/600 = 0.5fと、縦の長さ2.0fに対して4分の1の長さ(比率)になりましたよね。では、横方向はどうなるか?縦と同様に2/wのスケールを掛けるとピクセル数がやはり比率に変換されます。これは横方向の長さが考慮されているので、スクリーンの形に非依存です。
という事で、先ほどの射影変換行列は次のように変わりました:
float w = 1010;
float h = 489;
// 射影変換行列
D3DXMATRIX proj(
2/w , 0.0f, 0.0f, 0.0f,
0.0f, 2/h , 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f
);
dev->SetTransform( D3DTS_PROJECTION, &proj );
これで先に挙げた2つのサイズの違うウィンドウでそれぞれ描画してみます:
トレビアーン!左のウィンドウは(300, 200)でポリゴンは横150ピクセル設定だったので、見た目正しいです。右のウィンドウは(300, 600)で、高さ方向のポリゴンの長さが4分の1になってますよね。つまり「射影変換行列として(2/w, 2/h)というスケール変換行列を使うと、頂点座標が画面のピクセル単位になる!」という事が導けました。
これでポリゴンに直接ピクセル値を刻めるようになりました。しかし、スクリーンの座標系は「左上を原点(0.0)として右方向がX軸、下方向がY軸」です。上のウィンドウは「真ん中が原点で上方向がY軸」なので、座標系がずれていまます。
Y軸の方向を反転させるのは簡単です。原点を対象にプラスをマイナスに、マイナスをプラスにすれば良いのですから、Y座標にマイナスを掛ければいいんです。先ほどの射影変換行列のYスケール部分(m22)をマイナスに変更します:
// 射影変換行列
D3DXMATRIX proj(
2/w , 0.0f, 0.0f, 0.0f,
0.0f, -2/h , 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f
);
dev->SetTransform( D3DTS_PROJECTION, &proj );
これだけで上下が反転します。さ、描画してみましょう:
!!!
ポリゴンがいなくなりました…。なぜでしょうか?よ〜く考えてみて下さい。Y成分にマイナスを掛けたという事は、頂点が次のようになったわけです:
頂点の順番は0,1,2…左回りじゃーん!っというわけです。色々な変換をして「最終的に右回り」なポリゴンを表として描画するDirectXの小粋な罠です。これを回避するには「最初からスクリーン座標系でポリゴンを右回りで作る」という考え方をします。
上のポリゴンは3D座標系、つまりY軸が上にあるという座標系です。頂点の座標はスクリーン座標の値なのに、系は3D座標。ここに食い違いがあります。なので座標系もスクリーン座標にしてしまいます。上のポリゴンのように下向きな三角形をスクリーン座標系で表現すると、頂点1のY成分は「+150.0f」のはずです。その座標系で頂点を右回り設定するとつぎのようになります:
float vtx[9] = {
0.0f, 0.0f, 0.0f, // 0
150.0f, 0.0f, 0.0f, // 2
0.0f, 150.0f, 0.0f, // 1
};
これで先ほどのYスケールがマイナスな射影変換行列を設定して描画すると…:
わ〜い、上のポリゴンの設定通り下向きの三角形が原点(画面中央)を基点として描画されました〜〜。残すは原点をスクリーンの左上に移動させる変換だけです。
先ほどまでの射影変換行列を施すと、ポリゴンは(-1.0f, -1.0f)〜(1.0f, 1.0f)というスクリーン比率の中に収まります。という事は、射影変換行列を行った後の頂点座標を左に1、上に1だけずらせば、原点は左上に移動しますね。スクリーン基準だと、x+=-1.0f、y+=1.0fだけオフセットするという事です。射影変換行列で変換した後に1.0fな平行移動行列を施す…つまり、先ほどの射影変換行列に平行移動行列を右から掛け算すればいいのです!その式は次のようになります:
左がスクリーンスケールに変換する射影変換行列、右がその後に原点移動のため1だけオフセットする行列です。この合成射影変換行列を使うと…:
やりました〜、ちゃんと左上原点でスクリーン座標ベースな頂点設定が反映されたポリゴンが出ています!数学って素敵(^-^)。
という事でここまでを踏まえて2D描画なポリゴンを描くのに必要な項目を列挙すると次のようになります:
・ ポリゴンはスクリーン座標(左上原点でX軸右方向、Y軸下方向)で右回り順で設定
・ 射影変換行列はスクリーンスケール変換と平行移動を加味した特殊な行列を使用
この2点で上のような2D描画なポリゴンをとりあえず描けます。
ところで「Z成分はどうなった」んでしょうか?今Z成分は何も考えてきませんでした。つまり、Z成分は頂点座標に設定した値がそのまま反映されています。スクリーン座標系は2次元のように感じますが、実はZ成分もありまして、「Z成分が-1.0f〜1.0fの間にあれば描画」と決まっています。つまり、先の頂点座標のZ成分を2.0fなどと設定すると描画されなくなります。またZ成分の値が0.0fだと最前面、1.0fだと最後尾に描画されます。ここから「Z成分を使えば描画の前後関係をコントロールできる」のもわかります。
A 四角ポリゴンと半透明とシェーダ
@で三角ポリゴンを2D描画できました。これを四角ポリゴンにするのはとっても簡単。三角形を2つ繋げればいいんです。三角形を繋げる方法として、DirectX9には「三角形ストリップ」「三角形ファン」「三角形リスト」という3種類の繋げ方が用意されています。ストリップは短冊状に、ファンは扇形のように、そしてリストは三角形個別に繋げます。ここは話がそれてしまうので説明を割愛します。
ファンはちょっと面度なので省くとしまして、三角形ストリップでもリストでも四角ポリゴンは作れます。では両者の違いはというと頂点の設定数に違いが出ます。三角形ストリップは4点で四角ポリゴンを作れますが、リストは三角形を個別設定するため6点が必要になります。よって、ここでは三角形ストリップで四角ポリゴンを作りましょう。三角形ストリップで作る時の頂点座標は次の順番で設定します:
最初の1枚目は右回り、次は左回りになるように頂点を配置するのが三角形ストリップのコツです。上の設定がスクリーン座標ベースになっている事に注意して下さい。コードで書くとこんな感じです:
float vtx[] = {
0.0f, 0.0f, 0.0f, // 0
150.0f, 0.0f, 0.0f, // 1
0.0f, 150.0f, 0.0f, // 2
150.0f, 150.0f, 0.0f, // 3
};
ポリゴンはこれであっさり描画されます。
さて、2D描画で絶対に必要なのが「透過処理」です。絵全体を半透明にしたり、キャラクタ絵の所だけ表示させるのに必要なあれです。これ、いわゆる「固定機能」でもできるのですが、実は何だか凄く面倒。そこで、ここは思いきってシェーダに切り替えてしまいましょう。大丈夫、超簡単ですから(^-^)。と言うか、シェーダにしないとこの後に待っている「テクスチャのUV切り出し」もウルトラ面倒になっちゃうんです。
シェーダは色の塗り方を自分たちで制御してしまうプログラムです。DirectXの場合はHLSLという専用のシェーダ言語で描きます。DirectX9の場合は頂点シェーダとピクセルシェーダという2種類のコードを書けば、その通りに描画されます。
細かい事は抜きにして、まずは下のシェーダコード(sprite.fx)と描画コード(main.cpp)をテキストファイルに保存して、実際に読み込んで描画させてみましょう:
sprite.fx float4x4 proj;
struct VS_IN {
float3 pos : POSITION;
};
struct VS_OUT {
float4 pos : POSITION;
};
// 頂点シェーダ
VS_OUT vs_main( VS_IN In ) {
VS_OUT Out = (VS_OUT)0;
Out.pos = mul( float4(In.pos, 1.0f), proj );
return Out;
}
// ピクセルシェーダ
float4 ps_main(VS_OUT In) : COLOR0 {
return float4(1.0f, 0.0f, 0.0f, 1.0f);
}
technique Tech {
pass p0 {
VertexShader = compile vs_2_0 vs_main();
PixelShader = compile ps_2_0 ps_main();
}
}
main.cpp // 初期化サンプルプログラム
#pragma comment(lib, "dxguid.lib")
#pragma comment(lib, "d3d9.lib")
#pragma comment(lib, "d3dx9.lib")
#include <windows.h>
#include <tchar.h>
#include <d3d9.h>
#include <d3dx9.h>
TCHAR gName[100] = _T("2D描画サンプルプログラム");
struct Vtx {
float x, y, z;
};
LRESULT CALLBACK WndProc(HWND hWnd, UINT mes, WPARAM wParam, LPARAM lParam){
if(mes == WM_DESTROY) {PostQuitMessage(0); return 0;}
return DefWindowProc(hWnd, mes, wParam, lParam);
}
int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
// アプリケーションの初期化
MSG msg; HWND hWnd;
WNDCLASSEX wcex ={sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW, WndProc, 0, 0, hInstance, NULL, NULL, (HBRUSH)(COLOR_WINDOW+1), NULL, (TCHAR*)gName, NULL};
if(!RegisterClassEx(&wcex))
return 0;
float w = 1010.0f;
float h = 489.0f;
RECT r = {0, 0, (int)w, (int)h};
::AdjustWindowRect( &r, WS_OVERLAPPEDWINDOW, FALSE );
float cw = (float)(r.right - r.left);
float ch = (float)(r.bottom - r.top);
if(!(hWnd = CreateWindow(gName, gName, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, 0, (int)cw, (int)ch, NULL, NULL, hInstance, NULL)))
return 0;
// Direct3Dの初期化
LPDIRECT3D9 g_pD3D;
LPDIRECT3DDEVICE9 dev;
if( !(g_pD3D = Direct3DCreate9( D3D_SDK_VERSION )) ) return 0;
D3DPRESENT_PARAMETERS d3dpp = {(int)w,(int)h, D3DFMT_UNKNOWN, 0, D3DMULTISAMPLE_NONE, 0, D3DSWAPEFFECT_DISCARD, NULL, TRUE, 0, D3DFMT_UNKNOWN, 0, 0};
if( FAILED( g_pD3D->CreateDevice( D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, D3DCREATE_HARDWARE_VERTEXPROCESSING, &d3dpp, &dev ) ) ) {
g_pD3D->Release();
return 0;
}
// スクリーン座標系を想定したローカル座標
Vtx vtx[4] = {
{ 0.0f, 0.0f, 0.0f},
{ 150.0f, 0.0f, 0.0f},
{ 0.0f, 150.0f, 0.0f},
{ 150.0f, 150.0f, 0.0f}
};
// 2D描画用射影変換行列
D3DXMATRIX proj(
2/w , 0.0f, 0.0f, 0.0f,
0.0f, -2/h , 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 0.0f, 1.0f
);
dev->SetTransform( D3DTS_PROJECTION, &proj );
// シェーダ作成
ID3DXEffect *effect = 0;
ID3DXBuffer *error = 0;
if ( FAILED( D3DXCreateEffectFromFile( dev, "sprite.fx", 0, 0, 0, 0, &effect, &error) ) ) {
OutputDebugStringA( (const char*)error->GetBufferPointer());
return 0;
}
// 頂点宣言作成
IDirect3DVertexDeclaration9 *decl = 0;
D3DVERTEXELEMENT9 elems[] = {
{0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0},
D3DDECL_END()
};
dev->CreateVertexDeclaration( elems, &decl );
ShowWindow(hWnd, nCmdShow);
// メッセージ ループ
do{
if( PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) ){ DispatchMessage(&msg);}
else{
// Direct3Dの処理
dev->Clear( 0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0,0,255), 1.0f, 0 );
dev->BeginScene();
// シェーダ設定
UINT numPass = 0;
effect->SetTechnique( "Tech" );
effect->Begin(&numPass, 0);
effect->BeginPass(0);
effect->SetMatrix( "proj", &proj );
dev->SetVertexDeclaration( decl );
dev->DrawPrimitiveUP( D3DPT_TRIANGLESTRIP, 2, vtx, sizeof(Vtx) );
effect->EndPass();
effect->End();
dev->EndScene();
dev->Present( NULL, NULL, NULL, NULL );
}
}while(msg.message != WM_QUIT);
decl->Release();
effect->Release();
dev->Release();
g_pD3D->Release();
return 0;
}
空のWin32アプリケーションを立ち上げて、main.cppを追加し、プロジェクトがあるフォルダにsprite.fxファイルを置くと次のような描画がなされます:
色ついた〜(^-^)/。
これは設定したsprite.fxシェーダで描画されています。板ポリゴンがスクリーンの原点から縦横150ピクセルで描画されています。先ほどまでは真っ黒だったポリゴンになぜ色が付いたのか?それはシェーダを見ると分かります。まず、描画が走り始めると頂点シェーダ(vs_main)にやってきます。頂点シェーダの中では、
VS_OUT Out = (VS_OUT)0;
と出力する構造体を0初期化しています。これはお決まりの書き方として覚えておくだけでOK。次にシェーダに渡された射影変換行列で頂点の座標を変換しています:
Out.pos = mul( float4(In.pos, 1.0f), proj );
mul関数は掛け算関数で引数通しを掛け算します。第1引数にはここではローカル空間(スクリーン座標と仮定した)にある頂点座標であるIn.posを入れています。ただIn.posはXYZの3成分(float3型)でして、このままだと第2引数の4×4行列であるproj(float4x4)と掛け算できません。そのためIn.posをfloat4型(floatが4つ並んだベクトル)に変換しています。最後の1.0fはいわゆるW成分です。
入力されたローカル座標に2D描画用の射影変換を掛け算するとOut.posには変換された座標が格納されます。頂点シェーダはここでおしまい。この変換情報が描画パイプラインを通り、ポリゴンの描画位置が決定され、塗りつぶすピクセルが内部で列挙されます。そして、その1点1点についてピクセルシェーダが呼ばれます。
今回のピクセルシェーダはわずか1行のコードです:
return float4(1.0f, 0.0f, 0.0f, 1.0f);
ポリゴンが占めるすべての領域を赤色(RGBA=(1.0f, 0.0f, 0.0f, 1.0f))で塗りなさい、としています。ピクセルシェーダから返す色が、そのまま塗られるんです。
色塗りのコードはここまでで終わりです。次にあるtechniqueというのはC++側にシェーダの情報を伝える部分です。passというのはどの頂点シェーダとピクセルシェーダを走らすかを記述する部分で、ほとんどコードにあるような書き方で固定です(シェーダバージョンは適宜変えます)。
と言う事で、シェーダなんてこんなもんです。記述のコツさえ掴めてしまえば超簡単(^-^)。
さて、このシェーダを読み込んで描画させるには「ID3DXEffect」というオブジェクトを作ります。main.cppのコードにその作り方の典型例があります:
// シェーダ作成
ID3DXEffect *effect = 0;
ID3DXBuffer *error = 0;
if ( FAILED( D3DXCreateEffectFromFile( dev, "sprite.fx", 0, 0, 0, 0, &effect, &error) ) ) {
OutputDebugStringA( (const char*)error->GetBufferPointer());
return 0;
}
デフォルト設定で良い場合はこのコードでほとんど固定です。シェーダコードが間違っているとif文の中に入ります。嬉しい事にここでシェーダを動的にコンパイルしているため、コンパイラエラーを文字列として吐き出してくれます。シェーダ読み込みに失敗した場合はそのエラー文字列を見てどこが間違ったのか判断できます。
エフェクトが読み込めたら描画させる部分はOK。ただ、シェーダを使う段階でいわゆる「固定機能描画」は使えなくなるので、頂点フォーマットを固定機能に伝えるIDirect3DDevice9::SetFVFメソッドは意味を成さなくなります。シェーダに頂点のフォーマット情報を伝えるにはIDirect3DVertexDeclaration9オブジェクトを作る必要があります:
// 頂点宣言作成
IDirect3DVertexDeclaration9 *decl = 0;
D3DVERTEXELEMENT9 elems[] = {
{0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0},
D3DDECL_END()
};
dev->CreateVertexDeclaration( elems, &decl );
頂点フォーマット自体はD3DVERTEXELEMENT9構造体で定義します。今は頂点座標だけなので3つ目にfloat3型を表す「D3DDECLTYPE_FLOAT3」と5つ目に座標を表すD3DDECLUSAGE_POSITIONを設定します。D3DDECL_END()は終端記号で必ず入れないと次のCreateVertexDeclarationメソッドが失敗します。このメソッドが成功すれば頂点宣言オブジェクト(IDirect3DVertexDeclaration9)が得られます。
後は頂点宣言と頂点情報を描画デバイスに教えてあげて、シェーダを通して描画するだけです。描画の部分は次のようになっています:
// Direct3Dの処理
dev->Clear( 0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0,0,255), 1.0f, 0 );
dev->BeginScene();
// シェーダ設定
UINT numPass = 0;
effect->SetTechnique( "Tech" );
effect->Begin(&numPass, 0);
effect->BeginPass(0);
effect->SetMatrix( "proj", &proj );
dev->SetVertexDeclaration( decl );
dev->DrawPrimitiveUP( D3DPT_TRIANGLESTRIP, 2, vtx, sizeof(Vtx) );
effect->EndPass();
effect->End();
dev->EndScene();
dev->Present( NULL, NULL, NULL, NULL );
1フレームの描画の開始はバックバッファのクリアから始まっています。BeginSceneで描画開始。続いてシェーダに定義したtechniqueを指定します。一つのシェーダの中には複数のtechniqueを入れる事ができるのでこの指定が必要です。次にシェーダ描画を開始するID3DXEffect::Beginメソッドを呼び出しています。ここの第1引数には指定のtechnique内に含まれるpassの数が返ってきます。シェーダはパスを連続で呼び出す事で複数回点を穿つ事ができるためパスの含まれている数を得る必要があります。ただ今回はパスが一つだと分かっているのでBeginPassメソッドに番号0を直接入れています(本当はforループで回す)。BeginPassメソッドの後で、SetMatrixメソッドで射影変換行列をシェーダに渡しています。これは名前さえ正しく指定すればどんな行列でも渡せます。今回のシェーダは射影変換行列だけ使うので定数設定はこれだけです。後は頂点宣言を描画デバイスに教えて(SetVertexDeclarationメソッド)、DrawPrimitiveUPメソッドでシステムメモリにある頂点をそのまま描画に流しています。その後にID3DXEffect::EndPassメソッド及びEndメソッドでエフェクト自体を閉じてシェーダによる1回の描画が終了します。
呼び出し部分はBegin〜Endがいくつか重なるため難しく見えてしまいますが、構造は至極簡単です。シェーダに定数を渡して自分でそれを計算する処理を書くため、DirectXというか3D描画の仕組みを良く知る必要はありますが、2D描画に関して言えばシェーダはとても簡単です。
さてさて、しばらくシェーダの話になってしまいましたが、今やりたいのは「2D板ポリゴンの半透明処理」です。透過処理をするにはレンダーステートをアルファブレンドモードにする必要があります。これは実はシェーダにも記述できます。アルファブレンドモードな記述はpass内に次のように書きます:
sprite.fx technique Tech {
pass p0 {
VertexShader = compile vs_2_0 vs_main();
PixelShader = compile ps_2_0 ps_main();
AlphaBlendEnable = true;
SrcBlend = SRCALPHA;
DestBlend = INVSRCALPHA;
}
}
これら太文字の記述は「エフェクトステート」と呼ばれ、詳しくはこちらのMSDNにあります(エフェクトステート)。これでアルファブレンドが可能になりますので、ピクセルシェーダの戻り値の色のアルファ値を例えば0.35fくらいにしてみましょう:
sprite.fx // ピクセルシェーダ
float4 ps_main(VS_OUT In) : COLOR0 {
return float4(1.0f, 0.0f, 0.0f, 0.35f);
}
これで板ポリゴンを描画するとこんな感じになります:
薄すぎた… orz
で、でも、ちゃんとポリゴンが背景とαブレンドされたのはわかりますよね、ね(^-^;。
という事で、ポリゴンの半透明化はシェーダを使うと凄く簡単にできてしまいます。こうなると次は、テクスチャの絵を貼りたくなるわけです。
B テクスチャを貼ろう
板ポリゴンにテクスチャを貼り付けるには、板ポリゴンを構成する頂点のUV値でテクスチャ内の切り取りたい範囲を指定します。これ、固定機能描画でも出来なくは無いのですが、凄い面倒なんです。でも、シェーダを使えば相当楽に出来ます(^-^)。
まず板ポリゴンにテクスチャをそのまんま貼り付けてみましょう。シェーダ側で貼り付けたいテクスチャを受け取るには以下の太文字部分を追加します:
sprite.fx float4x4 proj;
texture tex;
sampler smp = sampler_state {
texture = <tex>;
};
struct VS_IN {
float3 pos : POSITION;
float2 uv : TEXCOORD0;
};
struct VS_OUT {
float4 pos : POSITION;
float2 uv : TEXCOORD0;
};
// 頂点シェーダ
VS_OUT vs_main( VS_IN In ) {
VS_OUT Out = (VS_OUT)0;
Out.pos = mul( float4(In.pos, 1.0f), proj );
Out.uv = In.uv;
return Out;
}
// ピクセルシェーダ
float4 ps_main(VS_OUT In) : COLOR0 {
return tex2D( smp, In.uv );
}
technique Tech {
pass p0 {
VertexShader = compile vs_2_0 vs_main();
PixelShader = compile ps_2_0 ps_main();
AlphaBlendEnable = true;
SrcBlend = SRCALPHA;
DestBlend = INVSRCALPHA;
}
}
太文字の所はすべてテクスチャの色を取得する為のコードです。textureというのはその名の通りテクスチャを受け取る変数です。次のsamplerというのはテクスチャからどうやって色を取り出すか設定する変数で、上の例だとtex変数から色を取る事を示しています。ここは他にも色々設定できますが今はこのままで。頂点シェーダの引数には新しくUV値が加わります。UV値にはTEXCOORDというセマンティクス(タイプ名)を付けます。
頂点シェーダ内では頂点に記載されているUV値をそのまま出力します。今回はテクスチャをそのまま貼り付けるのでUV値を特に加工しないためです。続いてピクセルシェーダでは頂点シェーダから渡されたUV値を使ってテクスチャから色を抽出します。一番簡単に抽出するのがtex2D関数で、引数のサンプラーに設定されているテクスチャから第2引数のUV値に該当する色を取り出します。
シェーダ側の更新はこれでOK。メインのプログラムでもテクスチャを作りシェーダに渡す部分を追加します:
main.cpp // スクリーン座標系を想定したローカル座標
Vtx vtx[4] = {
{ 0.0f, 0.0f, 0.0f, 0.0f, 0.0f}, // UV追加
{ 150.0f, 0.0f, 0.0f, 1.0f, 0.0f},
{ 0.0f, 150.0f, 0.0f, 0.0f, 1.0f},
{ 150.0f, 150.0f, 0.0f, 1.0f, 1.0f}
};
// テクスチャ
IDirect3DTexture9 *tex = 0;
D3DXCreateTextureFromFile( dev, "test.bmp", &tex);
// 2D描画用射影変換行列
D3DXMATRIX proj(
2/w , 0.0f, 0.0f, 0.0f,
0.0f, -2/h , 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 0.0f, 1.0f
);
dev->SetTransform( D3DTS_PROJECTION, &proj );
// シェーダ作成
ID3DXEffect *effect = 0;
ID3DXBuffer *error = 0;
if ( FAILED( D3DXCreateEffectFromFile( dev, "sprite.fx", 0, 0, 0, 0, &effect, &error) ) ) {
OutputDebugStringA( (const char*)error->GetBufferPointer());
return 0;
}
// 頂点宣言作成
IDirect3DVertexDeclaration9 *decl = 0;
D3DVERTEXELEMENT9 elems[] = {
{0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0},
{0, sizeof(float) * 3, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0},
D3DDECL_END()
};
dev->CreateVertexDeclaration( elems, &decl );
ShowWindow(hWnd, nCmdShow);
// メッセージ ループ
do{
if( PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) ){ DispatchMessage(&msg);}
else{
// Direct3Dの処理
dev->Clear( 0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0,0,255), 1.0f, 0 );
dev->BeginScene();
// シェーダ設定
UINT numPass = 0;
effect->SetTechnique( "Tech" );
effect->Begin(&numPass, 0);
effect->BeginPass(0);
effect->SetMatrix( "proj", &proj );
effect->SetTexture( "tex", tex );
dev->SetVertexDeclaration( decl );
dev->DrawPrimitiveUP( D3DPT_TRIANGLESTRIP, 2, vtx, sizeof(Vtx) );
effect->EndPass();
effect->End();
dev->EndScene();
dev->Present( NULL, NULL, NULL, NULL );
}
}while(msg.message != WM_QUIT);
メインの部分では頂点の中にUV値を追加しました。図にするとこんな感じです:
テクスチャは適当な物を今は作って下さい。頂点にUVを追加したので、頂点宣言にもそれを記述してあげます。2つ目にUV値までのオフセットを入れてあげるのを忘れずにです。後は描画時にシェーダに追加したtexture変数(tex)へテクスチャを直接渡してあげるだけです。
これらの追加で板ポリゴンにはあっさりとテクスチャが描画されます:
わーい。
まぁここまでは簡単。本番はこれからで、テクスチャの一部分を切り取って板ポリに貼る、いわゆる「UV切り取り」を次に作ってみます。
C UV切り取り
2D描画をする上で欠かせないのに固定機能だと激しくめんどくさいのが「UV切り取り」です。これはテクスチャの一部分を切り取って板ポリに貼る事を指します。テクスチャの一部分というのは次のようにUVで指定します:
テストとして僕のTwitterアイコンの絵の顔の部分を切り取ってみます。顔の左上は丁度UV=(0.2f, 0.2f)でした。矩形幅もUVスケールでWH=(0.55f, 0.55f)です。
一番単純にこのUV値を指定するには、頂点座標のUV値をそのままごりっと書き換えます。ただ、それをしてしまうと例えばパラパラアニメや1枚のテクスチャに沢山のフォント文字を詰め込んだ場合などで、頂点座標を毎度書き換える事になってしまいます。これはめっちゃ遅いのです。そこでシェーダ内でUV値を変更する手に出ます。シェーダを使った意義がここでようやく発揮されます(^-^)。
板ポリゴンはあくまでも(0.0f, 0.0f)〜(1.0f, 1.0f)というフルスケールなUV値のままにしておきます。シェーダの変数にUVの左上座標(uv_left, uv_top)とUV幅高(uv_width, uv_height)をそれぞれ指定してもらうとしましょう。この時、入力されてきたUV値を上の範囲に変換するには次のような計算をします:
sprite.fx float uv_left;
float uv_top;
float uv_width;
float uv_height;
// 頂点シェーダ
VS_OUT vs_main( VS_IN In ) {
VS_OUT Out = (VS_OUT)0;
Out.pos = mul( float4(In.pos, 1.0f), proj );
Out.uv = In.uv * float2(uv_width, uv_height) + float2(uv_left, uv_top);
return Out;
}
まず入力されてきた値を幅と高さでスケーリングしてしまいます。そして左上位置までオフセットします。これだけで指定の範囲を切り取った事になるわけです。処理は頂点シェーダ内だけですから高速です。
メインのプログラム側ではUV切り取り用の4つの変数に実際に値を入れます。これは描画部分だけなのでそのコード部分を抜き出します:
main.cpp // Direct3Dの処理
dev->Clear( 0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0,0,255), 1.0f, 0 );
dev->BeginScene();
// シェーダ設定
UINT numPass = 0;
effect->SetTechnique( "Tech" );
effect->Begin(&numPass, 0);
effect->BeginPass(0);
effect->SetMatrix( "proj", &proj );
effect->SetTexture( "tex", tex );
effect->SetFloat( "uv_left" , 0.20f );
effect->SetFloat( "uv_top" , 0.20f );
effect->SetFloat( "uv_width" , 0.55f );
effect->SetFloat( "uv_height", 0.55f );
dev->SetVertexDeclaration( decl );
dev->DrawPrimitiveUP( D3DPT_TRIANGLESTRIP, 2, vtx, sizeof(Vtx) );
effect->EndPass();
effect->End();
先に計っておいた通りに渡しているだけです。これで実際に描画するとこうなりました:
Bの最後で出力した板ポリゴンと同じ大きさですが、テクスチャが切り取られているので顔の部分が大きく表示されています…何だか恥ずい(笑)。このようにシェーダを用いればUV切り取りも楽々です(^-^)。
D 全体透過と部分透過の融合
さて、Aで透過処理をやりました。あれはポリゴン全体を半透明にしたので全ピクセルに等しく透過が掛かっていた状態です。これを「全体透過」と名付けておきます。一方で、テクスチャ自体にα情報が入っている事がゲームではほとんどです。これを区別の為「部分透過」と呼ぶ事にしましょう。
ゲームでは、例えばキャラクタのバストアップ画像をフェードイン(透明から不透明へじわ〜っと出す事)する事が非常に良くあります。この処理自体は全体透過の機能を使うのが簡単です。2D描画なシェーダにもその機能は必須と言えるでしょう。実装は極めて簡単です。
色の透過具合を決めているのはピクセルシェーダです。Aの例の全体透過の場合、α値を0.35fと直打ちしていました。一方テクスチャを貼った場合、色はテクスチャから取得していました。テクスチャにα情報が入っている場合、テクスチャから取った色のα値が0〜1の間で取られます。という事は、この部分透過のα値に全体透過のα値を掛け算すればいいんです。シェーダの実装はこんな感じに更新されます:
sprite.fx float alpha;
// ピクセルシェーダ
float4 ps_main(VS_OUT In) : COLOR0 {
float4 color = tex2D( smp, In.uv );
color.a *= alpha;
return color;
}
太文字部分が追加されました。取得したcolorのα値に全体αを掛け算しているだけです。たったこれだけの事なのですが、極めて有用性の高い機能追加になります。実際にやってみましょう。
先ほどの僕の似顔絵にα成分を付けくわえてバストアップ部分だけ抜き出しtest.pngとして保存しました。次にメインプログラムでシェーダのalpha値を0.15f〜1.0fまで徐々に上げてみた結果がこちら:
左が全体透過α=0.15f、右がα=1.0fです。元絵の背景部分は全透明になっています。残った色が付いた部分に全体透過値が掛けられているのでこのようにうまい事透過処理ができています。
さ、もう少し。次は、移動や回転、スケール変換です。これが無いと好きな所に好きなように配置できないですし、表現も乏しくなってしまいます。
E 移動と回転とスケール変換と
板ポリを指定した頂点座標(スクリーン座標系)で配置する事ができるようになりました。ただ、例えば板ポリを動かしたいとか、回転させたいなど動く板ポリを表現しようと思うと、今のままですと各頂点座標を再度計算しなおして書き込む事になってしまいます。もちろんそんな事はしません。
今板ポリの頂点はローカル座標(スクリーン座標系)にあります。そして、その頂点位置の計算は頂点シェーダ内で行っています。という事は、シェーダに移動や回転の情報を入れてあげれば頂点シェーダ内でそういう計算ができてしまいます。頂点座標を直接書き換える必要は無いわけです。
回転、移動、そしてスケールを一挙に変換する行列はワールド変換行列です。そこでシェーダ内にワールド変換行列(world)を追加し、入力されてきた頂点座標に掛け算してしまいます。その後で射影変換行列に掛け算すればこれら移動系変換が実現できます:
sprite.fx float4x4 world;
// 頂点シェーダ
VS_OUT vs_main( VS_IN In ) {
VS_OUT Out = (VS_OUT)0;
Out.pos = mul( float4(In.pos, 1.0f), world );
Out.pos = mul( Out.pos, proj );
Out.uv = In.uv * float2(uv_width, uv_height) + float2(uv_left, uv_top);
return Out;
}
単純に考えると確かにこれで終わりです。しかし、今板ポリの左上頂点が原点にある事を思い出して下さい。回転は原点を中心に行われます。よってこのままですと板ポリゴンは左上頂点を中心にくるくると回ってしまいます。そういう回転の使い方はまずしません。スケール変換も同様で、左上位置を原点としてスケールアップしてしまいます。「どの点を中心として回転やスケール変換するのか?」という事を考えないといけないのです。
回転やスケール変換の中心点となる点を「ピボット」と言います。入力されてきたワールド変換行列をこのピボット座標を原点として施すには、一番最初に入力された頂点座標をピボット座標分だけ原点方向に戻し(=ピボット座標を原点へ移動させる)、ワールド変換行列を施し、またピボット分だけ元に戻すという変換を頂点シェーダ内で行います。単なるオフセットなので難しい事は全然ありません。こんな感じです:
sprite.fx float pivot_x;
float pivot_y;
// 頂点シェーダ
VS_OUT vs_main( VS_IN In ) {
VS_OUT Out = (VS_OUT)0;
Out.pos = float4(In.pos, 1.0f) - float4( pivot_x, pivot_y, 0.0f, 0.0f );
Out.pos = mul( Out.pos, world );
Out.pos += float4( pivot_x, pivot_y, 0.0f, 0.0f );
Out.pos = mul( Out.pos, proj );
Out.uv = In.uv * float2(uv_width, uv_height) + float2(uv_left, uv_top);
return Out;
}
最初に入力頂点座標からピボット分だけ引き算しています。これによってピボット位置が原点に移動します。次にその原点(=ピボット)を中心にワールド変換行列を施します。最後に最初ずらした分のピボット位置まで位置を戻します。こうする事で結果的には元のピボット位置を中心に座標変換した事になるんです。
実際にピボット位置をずらした板ポリを30度回転させた連続画像はこちらになります:
見やすいように境界線とピボット位置を描き込んだ画像です。どうでしょうか、ちゃんとピボット位置を中心に30度回転していますよね。一番右のが元の画像の中心(75.0f, 75.0f)にピボット位置を置いて回転させた場合で、多分これが扱いやすい点の一つかなと思います。
もちろん、元の頂点座標を原点を中心とした位置に設定するのもありです。この場合、デフォルトでピボット位置が画像(ポリゴン)の中心になるため、回転やスケール変換も自然になります。ただ、ポリゴン中心の板ポリは位置合わせし辛いという欠点も持っています。この辺りは「板ポリゴンのアラインメント」と深く関わってきます。
さて、これでスクリーン上を仮定したワールド行列での自由配置(変換)とUV切り出しができるご機嫌な2D描画が可能になりました。後はこれをいかに使いやすく、またパフォーマンスを維持するか、つまり板ポリクラスの設計のお話になります。
F スプライトクラス
これまで構築してきた事を踏まえ、ここからは2D描画ができるスプライトクラスを作ってみます。
スプライトクラスはある1枚のテクスチャを描画するシンプルなクラスです。ただし、登録されたテクスチャのUV切り出しとピボット位置の指定ができます。インターフェイスは極力シンプルに、そしてここまで上げてきためんどくさーい部分は全部隠ぺいします。
クラス名はSpriteクラスにします。実装はDirectX9に依存して今は良いかなと思います。インターフェイスは、
・ Sprite( int screenWidth, int screenHeight )
・ sataic void begin_first( IDirect3DDevice9* dev )
・ static void end_last()
・ void setScreenSize( int w, int h )
・ void setSize( int w, int h )
・ void setPivot( float x, float y )
・ void getPivot( float *x, float *y )
・ void setPos( float x, float y )
・ void getPos( float *x. float *y )
・ void setRotate( float deg )
・ float getRotate()
・ void setScale( float sx, float sy )
・ void getScale( float *sx, float sy )
・ void setTexture( IDirect3DTexture9 *tex, bool isResize )
・ IDirect3DTexture9 *getTexture()
・ void setUV( float left, float top, float width, float height )
・ void getUV_LT( float *left, float *top )
・ void getUV_WH( float *width, float *height )
・ void setRGB( float r, float g, float b )
・ void getRGB( float *r, float *g, float *b )
・ void setAlpha( float a )
・ float getAlpha()
・ void setPriority( float z )
・ float getPriority()
・ void setActivity( bool isActive )
・ bool getActivity();
・ void draw()
・ static void drawAll();
という感じでしょうか。ほとんどがゲッターとセッターなので扱いは簡単です(^-^)。
使い方は、まずSpriteオブジェクトを作ります。コンストラクタにはスクリーンの大きさを渡します。次にsetTextureメソッドでテクスチャを渡し、第2引数をtrueにして板ポリゴンのサイズも更新してもらいます。ここでdrawメソッドを呼ぶとデフォルト位置(左上隅原点)でテクスチャサイズの絵が直ちに描画されます。超簡単(^-^)。
テクスチャの一部を切り出したい時にはsetUVメソッドに左上隅座標とUV座標での幅高を指定します。透過度はsetAlphaメソッド、色味はsetRGBメソッドで指定します。姿勢はsetPos、setRotate、setScaleメソッドでそれぞれ設定。その原点はsetPivotメソッドで指定します。直感的ですよね。
各変数を格納・取得するだけのゲッター、セッターの説明は省略しますが、ポイントとなるメソッドについては以下から見て行きましょう。
○ setSizeメソッド
ポリゴンのサイズを設定するメソッドです。こんな感じの実装になります:
// 板ポリサイズ指定
void setSize( int w, int h ) {
polyW = w;
polyH = h;
}
「単なるセッターやんけ?」と思われるかもしれません。その通りです(^-^;。ただ、「じゃぁ実際の頂点はどこで作ってるの?」という話なんです。先ほどまでの解説では頂点バッファに板ポリゴンの大きさをそのまま記述していました。でも、こうすると板ポリゴンの枚数分だけ頂点バッファが必要になります。例えば板ポリゴンが何百枚もどばーっと発生するパーティクルなどを想像してみて下さい。もし1スプライトに1頂点バッファを作成していたら、新しい板ポリゴンが必要になる度にロックが発生します。これ大致命的ボトルネックになるわけです(^-^;。
そこで、Spriteクラスでは「単位サイズポリゴン」を使い回す事によって頂点バッファを共通化してしまいます。単位サイズポリゴンは次のように定義します:
// x, y, z, u, v
float commonVtx[] = {
0.0f, 0.0f, 0.0f, 0.0f, 0.0f, // 0
1.0f, 0.0f, 0.0f, 1.0f, 0.0f, // 1
0.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 2
1.0f, 1.0f, 0.0f, 1.0f, 1.0f // 3
};
左上隅を原点とした幅高1のポリゴンで、UVも目いっぱい広げています。この単位サイズポリゴンを頂点バッファに格納しておけば、任意の大きさのポリゴンをスケーリングによって作成する事ができます。もちろんスケール計算はシェーダ内で行います。
共通の頂点バッファはスプライトを使う前にSprite::begin_firstメソッドを一度呼び出す事で作成する事にしましょう:
Sprite::begin_first // 共通頂点バッファ作成
void Sprite::begin_first( IDirect3DDevice9* dev ) {
if (buf == 0) {
float commonVtx[] = {
0.0f, 0.0f, 0.0f, 0.0f, 0.0f, // 0
1.0f, 0.0f, 0.0f, 1.0f, 0.0f, // 1
0.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 2
1.0f, 1.0f, 0.0f, 1.0f, 1.0f, // 3
};
dev->CreateVertexBuffer( sizeof(commonVtx), 0, 0, D3DPOOL_MANAGED, &buf, 0 );
float *p = 0;
if (buf) {
buf->Lock( 0, 0, (void**)&p, 0 );
memcpy( p, commonVtx, sizeof(commonVtx) );
buf->Unlock();
}
}
}
IDirect3DDevice9::CreateVertexBufferで頂点バッファをVRAM上に作ります。第3引数には通常D3DFVFマクロを指定するのですが、シェーダはD3DFVFを使わないので0指定します。これでSpriteが共通で扱える頂点バッファを作れました。このバッファの後片付けはend_lastメソッドの呼び出しで行います:
Sprite::end_last // 後片付け
void Sprite::end_last() {
if (buf)
buf->Release();
}
これにより、stSizeメソッドで逐一頂点バッファを作る事は無くなったため、setSizeメソッドは単なるセッターで良くなった、というのがここのお話の結末です(^-^)。
○ setTextureメソッド
setTextureメソッドはその名の通りテクスチャを保持するメソッドです。テクスチャ自体は単なるポインタなので特にどうという事はありませんが、このメソッドには第2引数としてisResizeというフラグを渡すようにしてあります。このフラグがtrueだったら板ポリゴンの大きさを設定されたテクスチャの大きさで作り直します。setSizeメソッドでは自分でテクスチャサイズを得て設定する必要がありましたが、これを使えばテクスチャを設定するだけで板ポリゴンの大きさが適宜変わるので便利というわけです。
実装はこんな感じになります:
Sprite::setTexture // テクスチャ設定
void Sprite::setTexture( IDirect3DTexture9 *tex, bool isResize ) {
if (tex)
tex->AddRef();
if (this->tex)
this->tex->Release();
this->tex = tex;
if (isResize == true) {
D3DSURFACE_DESC desc;
tex->GetLevelDesc( 0, &desc );
setSize( desc.Width, desc.Height );
}
}
引数のtexが有効だったらそのAddRefメソッドを呼び出してテクスチャの参照カウンタを増やしておきます。次に自分が今保持しているテクスチャがあればそれを解放して、新たに代入しなおしています。こうする事で設定されたテクスチャが勝手にいなくなる事が無くなります。isResizeフラグがtrueの時はテクスチャ情報をIDirect3DTexture9::GetLevelDescメソッドで取得し、そこから得られる幅と高さをsetSizeメソッドに渡します。
○ drawメソッド
drawメソッドはその名の通りスプライトを画面に描画します。実装のほとんどはシェーダに対して値を渡すだけです。なんですが、実はちょっと工夫したい所があるんです。Spriteクラスで使うシェーダは全部同じものです。各SpriteがID3DXEffectオブジェクトを都度作成するのはこれまた多大な無駄なんです。よって、先の頂点バッファと同様に共通のID3DXEffectオブジェクトを使い回します。もう一つ、シェーダで描画する時にtechniqueを設定してpassを設定して云々とやりました。この設定にも実は少し時間がかかります。どうせなら「最初にtechniqueを設定してpassを設定して、後は描画対象のスプライトを全部まとめてどばーっと描画」したいのです。これを実現するにはdrawメソッドの呼び出しで描画せずに、単に「自分自身を描画リストに積む」というだけにしてしまいます。そして、本当の描画はSpriteクラスのstaticなメソッドであるdrawAllメソッドの呼び出しで行うようにします。
このコンセプトに従うとdrawメソッドは次のように大変に単純なメソッドになります:
Sprite::draw // 描画リストに積む
void Sprite::draw() {
drawObjectList.push_back( this );
}
実際の描画はdrawAllメソッドでいっぺんに行ってしまいます:
Sprite::drawAll // 描画リストを一気に描画
void Sprite::drawAll( IDirect3DDevice9 *dev ) {
if (buf == 0 || effect == 0 || decl == 0)
return; // 描画不可
// 頂点バッファ・頂点宣言設定
dev->SetStreamSource( 0, buf, 0, sizeof(float) * 5 );
dev->SetVertexDeclaration( decl );
// 2D描画用射影変換行列
D3DXMATRIX proj;
D3DXMatrixIdentity( &proj );
proj._41 = -1.0f;
proj._42 = 1.0f;
// シェーダ開始
UINT numPass = 0;
effect->SetTechnique( "Tech" );
effect->Begin(&numPass, 0);
effect->BeginPass(0);
// 描画リストに登録されているスプライトを一気に描画する
std::list<Sprite*>::iterator it = drawObjectList.begin();
for (; it != drawObjectList.end(); it++) {
Sprite* sp = (*it);
if (sp->bActivity == false)
continue;
// 射影変換行列作成
proj._11 = 2.0f / sp->scW;
proj._22 = -2.0f / sp->scH;
// ワールド変換行列作成
D3DXMATRIX world, scale, rot;
D3DXMatrixScaling( &world, (float)sp->polyW, (float)sp->polyH, 1.0f ); // ポリゴンサイズに
D3DXMatrixScaling( &scale, sp->scaleX, sp->scaleY, 1.0f ); // ローカルスケール
D3DXMatrixRotationZ( &rot, sp->rad ); // 回転
world._41 = -sp->pivotX; // ピボット分オフセット
world._42 = -sp->pivotY;
world = world * scale * rot;
world._41 += sp->posX + sp->pivotX; // ピボット分オフセット
world._42 += sp->posY + sp->pivotY;
// エフェクトへ
effect->SetMatrix( "world", &world );
effect->SetMatrix( "proj", &proj );
effect->SetTexture( "tex", sp->tex );
effect->SetFloat( "uv_left" , sp->uvLeft );
effect->SetFloat( "uv_top" , sp->uvTop );
effect->SetFloat( "uv_width" , sp->uvW );
effect->SetFloat( "uv_height", sp->uvH );
effect->SetFloat( "alpha" , sp->alpha );
effect->CommitChanges();
dev->DrawPrimitive( D3DPT_TRIANGLESTRIP, 0, 2 );
}
effect->EndPass();
effect->End();
}
このメソッドはstaticメソッドなのでクラスメンバ(staticな変数)しかアクセスできません。最初に共有で使い回す頂点バッファ、頂点宣言をデバイスに教えます。次にエフェクトを通して描画を開始するためにtechniqueを指定します。その後は描画リストに積まれているスプライトを一つずつ描画していきます。forループ内では主にワールド変換行列の作成に費やしています。今共通板ポリゴンは左上を原点とする幅高1.0fな単位矩形です。最初にそれをポリゴンサイズ(polyX, polyY)になるようスケール変換をかけています。これで指定のポリゴンサイズになったので、次にピボットが原点に来るようにピボット分だけマイナスオフセットします。後は設定してあるローカルスケール値、ローカル回転を掛け、最後に位置移動と主に先ほどずらしたピボット分だけ元に戻します。これでワールド変換行列は完成です。
エフェクトに各種定数を渡していますが、ピボットの計算をもうしてしまったのでEのシェーダで行っていたピボット計算は無くなります(元のシンプルな変換になります)。ID3DXEffect::CommitChangesメソッドはシェーダ定数を変更した時に呼ばなければならないメソッドです。これを呼ばないと設定値が反映されません。最後にDrawPrimitiveでポリゴンを描画しています。
シェーダはこんな感じです:
sprite.fx float4x4 proj;
float4x4 world;
texture tex;
float uv_left;
float uv_top;
float uv_width;
float uv_height;
float alpha;
sampler smp = sampler_state {
texture = <tex>;
};
struct VS_IN {
float3 pos : POSITION;
float2 uv : TEXCOORD0;
};
struct VS_OUT {
float4 pos : POSITION;
float2 uv : TEXCOORD0;
};
// 頂点シェーダ
VS_OUT vs_main( VS_IN In ) {
VS_OUT Out = (VS_OUT)0;
Out.pos = mul( float4(In.pos, 1.0f), world );
Out.pos = mul( Out.pos, proj );
Out.uv = In.uv * float2(uv_width, uv_height) + float2(uv_left, uv_top);
return Out;
}
// ピクセルシェーダ
float4 ps_main(VS_OUT In) : COLOR0 {
float4 color = tex2D( smp, In.uv );
color.a *= alpha;
return color;
}
technique Tech {
pass p0 {
VertexShader = compile vs_2_0 vs_main();
PixelShader = compile ps_2_0 ps_main();
AlphaBlendEnable = true;
SrcBlend = SRCALPHA;
DestBlend = INVSRCALPHA;
}
}
本当にシンプルになりました(^-^;。
このシェーダからID3DXEffectオブジェクトを作りますが、それはbegin_firstメソッドで行い共通化します。細かな実装についてはサンプルプログラムのSprite::begin_firstメソッドをご覧下さい。
おおよそのポイントはこのくらいです。実用例につきましてもサンプルを見ればすぐにわかります(^-^)。
G ドットバイドット表示を実現するために
2D描画はここまでの理屈とクラスの実装、後はサンプルコードに挙げてあるSpriteクラスを使えば出来ます。ただ、実は気を付けなければならない事が残っています。それが「ドットバイドット表示」です。
ドットバイドット表示とは、テクスチャの1ピクセルがウィンドウの1ピクセルと完全に一致する事でテクスチャがぼやける事無く、また変に欠ける事無く綺麗に描画される事を指します。DirectXは本来3D描画に特化したエンジンです。そのため「テクスチャ補間」を普通に行ってくれます。3Dモデルに貼ったテクスチャが綺麗に見えるのはそのためです。ところが、その補間機能が事2D描画では邪魔になってしまうんです。正しい設定を行わないといらない補間が入り、滲んでぼやけた描画になってしまいます。
ドットバイドット表示をするためには「べき乗テクスチャ」が必須です。べき乗テクスチャとは1辺の長さが2のべき乗(2,4,8,16,32,64,128,256,512,1024,2048)ピクセルなテクスチャの事です。それ以外の中途半端なサイズのテクスチャを使った場合、ドットバイドット描画は保障できません。なぜ、べき乗テクスチャが必要なのでしょうか?
ポリゴンはテクスチャに対しその頂点に設定されているUV値に該当する位置にあるピクセルの色で自身を塗りつぶそうとします。UV値は一般に0.0f〜1.0fの間の浮動小数点です。例えばポリゴンの幅高がそれぞれ128.0fだとしたら、簡単に言えばテクスチャを縦横128分割して一番近い色を拾ってきます。ここで「128分割」という2のべき乗の数字であるのがポイントです。浮動小数点(float)の「1.0f」という数値は、2のべき乗の数値で割ると誤差無しで完全に小数点を表現できるんです。実際に1.0fを2,4,8,16...と2のべき乗で割った結果はこんな風になります:
割る数 指数部 仮数部 1 01111111 000 0000 0000 0000 0000 0000 2 01111110 4 01111101 8 01111100 16 01111011 32 01111010 64 01111001 128 01111000 256 01110111
float型の指数部は8bitの整数として扱われます。仮数部は23bitの固定小数点です。これをご覧いただければ一目瞭然なんですが、仮数部がすべて0になっています。つまり、整数部だけで表現ができている、言い換えれば「誤差が無い」という事を表しています。誤差が付きものの浮動小数点においてこの性質は大変に稀有なんです。これだけではありません。例えば最下段の1/256を今度は2倍、3倍、4倍と整数倍してみます:
倍率 指数部 仮数部 1 01110111 000 0000 0000 0000 0000 0000 2 01111000 000 0000 0000 0000 0000 0000 3 01111000 100 0000 0000 0000 0000 0000 4 01111001 000 0000 0000 0000 0000 0000 5 01111001 010 0000 0000 0000 0000 0000 6 01111001 100 0000 0000 0000 0000 0000 7 01111001 110 0000 0000 0000 0000 0000 8 01111010 000 0000 0000 0000 0000 0000 9 01111010 001 0000 0000 0000 0000 0000 10 01111010 010 0000 0000 0000 0000 0000
仮数部にループ的な数値が並んでいないというのは誤差が無い(10進数で表した小数点を2進数で完全に表現できる)事を表しています。どの倍率でも何の問題も無くしっかり誤差なく表現されています。つまり、べき乗の数値を使うと、割ったり掛けたり足したりという操作を行っても浮動小数点特有の誤差が生じないんです(※特定の範囲において)。
という事は、128という大きさの板ポリゴンに128ピクセル四方のテクスチャを貼ると、UV値に誤差が無いため完全に同じ間隔でテクスチャ上のピクセルを拾ってきてくれます。そのため、ちょっとした誤差で隣のピクセルの色を拾ってきてしまう…という抽出エラーが起こらなくなります。べき乗テクスチャとそれとぴったり同じ大きさのポリゴンを使う事がドットバイドット表示の最低限の条件です。
という事で、例えばマップチップを作ったり、フォント文字を大きなテクスチャにまとめて詰める場合も、テクスチャは2のべき乗サイズでなければ綺麗に表示できません。逆に2のべき乗サイズのテクスチャならば、その解像度UV単位で矩形を指定する限りは絵がどの範囲にあっても綺麗にUV切り取りができます。別に32ピクセル単位に絵を並べなけれなならないとか、そういう事はありません。
2D描画ができればゲームを作る事がもうできます(^-^)。別に3Dじゃなければいけない理由はありませんし、ゲーム制作に集中したいのであれば2Dを用いた方がずっと早くゲームを作れます。ライブラリを作るのも大切ですが、それよりもゲームを作れるプログラマになるのがもっと大切。そのためにも2D描画でゲーム制作の鍛錬を積むのがゲームプログラマとしてのより近道かなと思います。
今回考えてきたSpriteクラスはサンプルプログラムで公開致しますのでご自由にお使い下さい(^-^)