ホーム < ゲームつくろー! < DirectX技術編
その67 改・高速フォント文字
DirectX技術編その5に「高速フォント文字」という記事を書きました。Windows APIにはGetGlyphOutline関数という指定のフォント文字のモノクロBMPを出力してくれるありがたい関数があります。このBMPのモノクロな点をDirectXのテクスチャにRGBAなカラーとして直接穿ち、フォント文字テクスチャを作ってしまおうという内容でした。
この記事の方法で確かに綺麗にフォントが描画されます。しかし、特定のフォント文字の時に描画されないというご報告を頂きました。調べてみると、フォントを描画する時の基点となるフォント原点よりも左側にドットがあるフォント文字(例えばMS
Pゴシックの「j」など)の描画に失敗してしまいます。実際に「j」をWordで描画するとこんな感じです:
これ、「j」の曲がった先が明らかに隣のフォント内に侵食していますよね。この曲がった部分がフォント原点よりも実は左にあるのです。先の章はこの状況を想定していないためにうまく描画できなかったんです。しかも、先の章のプログラムではこれを解消できない…これはまずい…。
そこで原点回帰、フォント文字描画について改めて考え直す事にしました。当時の記事を全面改正です(^-^)。
@ フォント文字描画の意義が少し変わりました
ゲームには文字表示が必須です。DirectXで文字を出したい時にはID3DXFontを使うのが楽です。このインターフェイスは指定のフォント文字をワープロのように描画する事に特化していますが、あまり細かい事が出来ません。例えば文字の間隔を開けたり1文字ずつの透過度を微妙に変化させるなどの「文字単位操作」、3D空間に配置させて自由に動かすなどの「3D空間操作」などは簡単には出来ません。
また、今は実は凄い改善されたのですが、一昔前ID3DXFontはとにかく「遅い」と言われていました。文字をある程度沢山画面に出すと、凄いCPU/GPUパワーを持っていかれていたんです。改善前の章に「高速」と付けたのは、それを改善したいがためでした。ただ、現在のID3DXFontはフォント文字を大きなテクスチャにストックして無駄なフォント絵の再生成とテクスチャ切り替えを抑える仕組みが入っていて、速度面が大幅に改善されています。そのため、本章は「高速化」としてのアドバンテージは実は殆ど無くなってしまったのですが(^-^;、文字単位で細かな操作や微調整をしたい、描画を面白くしたいという面では十分な情報価値があります。そういう観点で以下をご覧頂ければなと思います。
A フォントテクスチャを作ろう
この章でやる事は大きく2つに大別されます:
1. フォントサイズきっちりのフォントテクスチャを作る
2. フォントテクスチャを適切に配置して文字列を構成する
この節で説明するのは1です。最終的に例えば以下のような小さなテクスチャが作られます:
これらはいずれも小さいテクスチャを拡大表示したものです。この例だと幅8ピクセル高さ9ピクセルととても小さなテクスチャです。上の"a"はTimes New Romanのフォントサイズ12で、17階調のアンチエイリアスが入っています。これをOSにインストールされているTimes New RomanのTrueTypeフォント情報から自前で直接作るのはとても大変。でもWindows APIには上のビットマップ情報をこの通りに作ってくれるGetGlyphOutline関数という素晴らしい関数があります。
GetGlyphOutline関数は現在ウィンドウに設定されているフォント(HFONT)を元に、指定した文字のフォントビットマップを返してくれます。ちなみに、ビットマップと言うと絵のファイル(.bmp)を想像してしまいますが、プログラムで言うビットマップというのは「ピクセルの色情報がずらっと並んだメモリブロック」の事をを指します。
GetGlyphOutline関数を使うには予めフォントハンドルをデバイスコンテキストに設定する必要があります。典型的なコードはこんな感じです:
// フォントハンドルの生成
int fontSize = 32;
int fontWeight = 500;
LOGFONT lf = {
fontSize, 0, 0, 0, fontWeight, 0, 0, 0,
SHIFTJIS_CHARSET, OUT_TT_ONLY_PRECIS, CLIP_DEFAULT_PRECIS,
PROOF_QUALITY, DEFAULT_PITCH | FF_MODERN,
"MS P明朝"
};
HFONT hFont = CreateFontIndirectA(&lf);
// 現在のウィンドウに適用
// デバイスにフォントを持たせないとGetGlyphOutline関数はエラーとなる
HDC hdc = GetDC(NULL);
HFONT oldFont = (HFONT)SelectObject(hdc, hFont);
// ----- ここでGetGlyphOutline関数からビットマップ取得 -----
...
GetGlyphOutline(...);
...
// ---------------------------------------------------------
// デバイスコンテキストとフォントハンドルはもういらないので解放
SelectObject(hdc, oldFont);
ReleaseDC(NULL, hdc);
フォントハンドルの作り方は本章の趣旨とちょっと外れるので詳しくは説明しませんが、フォントサイズ、フォントウェイト(太さ)、キャラクターセット、そしてフォントの種類(MS P明朝など)を上のように設定すればOKです。より詳しくはGoogle先生にお尋ね下さい。
上のコードの「ここで〜」以下の範囲でGetGlyphOutline関数を使います。この関数の定義を見てみましょう:
GetGlyphOutline関数 DWORD GetGlyphOutline(
HDC hdc,
UINT uChar,
UINT uFormat,
LPGLYPHMETRICS lpgm,
DWORD cbBuffer,
LPVOID pvBuffer,
CONST MAT2 *lpmat2
);
hdcはフォントが設定してあるデバイスコンテキストハンドルです。
uCharは表示したいフォント文字をUnicodeで設定します。
uFormatは取得したい情報やフォント文字の解像度をフラグで設定します。項目はいくつかあるのですが、今の目的であるフォントビットマップを取得したいのであれば、
・ GGO_BITMAP(2階調ビットマップ)
・ GGO_GRAY2_BITMAP(5階調ビットマップ)
・ GGO_GRAY4_BITMAP(17階調ビットマップ)
・ GGO_GRAY8_BITMAP(65階調ビットマップ)
のどれかのフラグを指定します。下のフラグ程アンチエイリアスがより綺麗なフォントになりますが、その分生成コストが増えます。
lpgmに作成したフォントビットマップに関する情報がGLYPHMETRICS構造体として返ります。この構造体が超大切です!
フォントビットマップはpvBufferに渡したメモリブロックに返されます。cbBufferはそのブロックサイズを渡します。
lpmat2はフォント文字を回転させたい場合などに特殊な行列情報を渡します。NULLは受け付けないので必ず渡す必要があります。変換無しでそのままで良いのであれば何も考えずに、
CONST MAT2 mat = {{0,1},{0,0},{0,0},{0,1}};
という配列を作って渡せばOK。しかもここ、何か動作が不安定で壊れているっぽいので下手に触らず上の値を与えるのが吉です(^-^;。
フォントビットマップの値が返って来るpvBufferに渡すメモリブロックのサイズ(cbBuffer)を知る必要があります。これを得るにはcbBufferに0を指定し、pvBufferをNULLにするとこの関数の戻り値に必要なメモリブロックサイズが戻って来ます:
GLYPHMETRICS GM;
CONST MAT2 mat = {{0,1},{0,0},{0,0},{0,1}};
DWORD size = GetGlyphOutlineW(hdc, code, GGO_GRAY4_BITMAP, &GM, 0, NULL, &Mat);
返されたサイズ分のメモリを確保し、再度GetGlyphOutline関数を呼べばフォントビットマップがpvBufferに返されます:
BYTE *pFontBMP = new BYTE[size];
GetGlyphOutlineW(hdc, code, GGO_GRAY4_BITMAP, &GM, size, pFontBMP, &Mat);
先ほどの"a"、実際に取得したメモリブロックはこんな感じの数値の塊でした:
00 00 00 02 01 00 00 00
00 03 0e 10 10 0c 00 00
00 0c 0b 00 01 0e 04 00
00 08 02 00 05 10 04 00
00 01 09 0e 0e 0e 04 00
01 0d 0c 03 00 0c 04 00
04 10 02 00 00 0c 04 00
03 10 08 04 08 10 04 00
00 05 0c 0c 06 09 07 00
各数値は16進数です。フラグにGGO_GRAY4_BITMAPを指定したので数値は0〜16(10進数)まであり、確かに17段階のモノクロな階調になっています。0が真っ黒、1が真っ白です。上は横幅が8で改行を入れていますが、実際は一つのメモリブロックに数値が並んでいるだけです。このメモリブロックの各数値を0〜255にスケールアップさせたグレースケールドットを打ったのが冒頭の"a"というわけです。このフォントビットマップ(0〜255スケール)をDirectXのテクスチャに穿てばOKというわけ。
ところで、このフォントビットマップの幅はどうやって知るのか?それはGetGlyphOutline関数が返すGLYPHMETRICS構造体から得る事ができます。この構造体は次のような構成になっています:
GetGlyphOutline関数 typedef struct _GLYPHMETRICS {
UINT gmBlackBoxX;
UINT gmBlackBoxY;
POINT gmptGlyphOrigin;
short gmCellIncX;
short gmCellIncY;
}
gmBlackBoxXとgmBlackBoxYはフォントをきっちり隙間なく収める長方形の箱(ブラックボックス)の幅と高さです。gmBlackBoxXは上のフォントビットマップの横幅ではない事に注意して下さい。
gmptGlyphOriginはブラックボックスの左上座標を原点からの相対位置として示してくれます。
gmCellIncXは次の文字の原点位置までの横方向の距離を示してくれます。固定ピッチフォントだと多分同じ値になるのですが、プロポーショナルフォントだとこの値はフォントごとに変わります。
gmCellIncYは次の行のベースラインまでの距離なのですが、ここは使われていないのか0が返ってきます。
この構造体が返す値について図示するとこんな感じです:
この図の青で囲まれた四角、これがブラックボックスです(青ですけど(^-^;)。この位置関係については次の節で説明します。今欲しいのはこのブラックボックスの大きさであるgmBlackBoxXとgmBlackBoxYです。これらは本当にフォントサイズギリギリの大きさになっています。
先ほどの説明で、gmBlackBoxXがフォントビットマップの幅ではないと言いました。実際冒頭のTimes New Romanの"a"のブラックボックスのサイズはこうなっていました:
gmBlackBoxX gmBlackBoxY 7 9
フォントビットマップとして返されたメモリブロックでの横幅は8です。この微妙な違いはビットマップの制約である「4バイトアライン」が原因です。ビットマップは横一列のバイト数(ピッチ:pitch)が4バイト単位でないといけないという制約があります。"a"の横幅が7ピクセル7バイトだとしても、このアラインメントにより横一列8バイトのビットマップが返されてしまうんです。
gmBlackBoxXから4バイトアラインなビットマップフォントの横幅を得るには次のような計算をします:
int fontWidth = (gmBlackBoxX + 3) / 4 * 4
上の式から得た横幅と高さを持った空テクスチャを作ってみましょう:
// テクスチャ作成
LPDIRECT3DTEXTURE9 pTex;
int fontWidth = (GM.gmBlackBoxX + 3) / 4 * 4;
int fontHeight = GM.gmBlackBoxY;
dev->CreateTexture( fontWidth, fontHeight, 1, 0, D3DFMT_A8R8G8B8, D3DPOOL_MANAGED, &pTex, NULL );
ミップマップレベルは1、フォーマットはαが入った32bit整数テクスチャ、管理はMANAGEDです。MANAGEDなテクスチャは「テクスチャのロック」ができます。これが重要です。テクスチャをロックしないとフォントビットマップの点の情報をテクスチャに書き込めません。
では最終段階。テクスチャを実際にロックしてフォントビットマップのモノクロ情報をテクスチャに書き込んでいきます。
// テクスチャにフォントビットマップ情報を書き込み
D3DLOCKED_RECT lockedRect;
pTex->LockRect( 0, &lockedRect, NULL, 0); // ロック
int grad = 16; // 17階調の最大値
DWORD *pTexBuf = (DWORD*)lockedRect.pBits; // テクスチャメモリへのポインタ
for (int y = 0; y < fontHeight; y++ ) {
for (int x = 0; x < fontWidth; x++ ) {
DWORD alpha = pMono[y * fontWidth + x] * 255 / grad;
pTexBuf[y * fontWidth + x] = (alpha << 24) | 0x00ffffff;
}
}
pTex->UnlockRect( 0 );
まずテクスチャをロックし、テクスチャメモリへのポインタを取得します。次に1列目から順番に色を穿っていきます。alphaにはモノクロフォントビットマップの値を0〜255にスケールアップした値が格納されます。gradは今は16(17階調の最大値)になっていますが、GetGlyphOutline関数は他に2、5、65階調のフォントビットマップも返せます。解像度に応じてgradの数も変わります。pTexBufはDWORD*型なのでARGBの4バイトの大きさで数字を格納しています。RGBの所は全部0xffになっているので真っ白なテクスチャでαの数値のみでフォントを抜き出しているのが分かると思います。
これでフォントテクスチャができあがりました。
B フォントテクスチャの位置合わせと文字列構成
Aで作ったフォントテクスチャ。これは4バイトアラインになってはいますが実質フォントサイズぴったりのテクスチャです。なので例えばピリオド「.」などは本当にちっさいテクスチャとして作成されます。よって、何も考えずに等間隔でそれらのフォントテクスチャを並べるとこんな感じになってしまいます:
全部上付けになってしまいますし、文字列としても決して綺麗ではありません。等幅フォントならまだしも各文字でフォントの幅が異なるプロポーショナルフォントなどでは致命的にアウトなわけです。フォントテクスチャを作れたとしても、そのフォントを綺麗に並べられなければフォント描画は完成しないのです!
「フォントの位置」を定めるには、まず「ベースライン」という横方向のラインを想定しなければなりません。さらに、そのベースライン上に「原点(基点)」というフォントの位置の基準となる点が置かれます:
このベースラインと原点があると、フォントの位置決めができるようになります。
Aで出てきた図をここで再掲載します。GetGlyphOutline関数が返すGLYPHMETRICS構造体の情報からフォントの位置は次のように定める事ができます:
ポイントはgmptGlyphOriginです。これはPOINT型の構造体で、原点を基点としたブラックボックスの左上座標を表します。例えば上の「M」であればgmptGlyphOrigin.x = 0、gmptGlyphOrigin.y = 20といった感じです。Y座標は上方向が正なのに注意して下さい。X座標は原点の左側(マイナス側)にも来る事もあります(冒頭の"j"は正にそれです)。
ベースラインと原点、そしてgmptGlyphOriginの情報があれば、フォント文字1文字の描画位置を決定できます。では次の文字の原点はどこになるか?これはgmCellIncXに入っています。上の図の"p"に注目して下さい。"p"の原点は真ん中の緑です、そしてpの次の原点位置はpの原点にgmCellIncXを足した位置となります。こうする事で、プロポーショナルフォントも美しく配置する事ができるようになるわけです。実際この節の最初で示した残念なアルファベット文字列をこれら情報を用いて正しい位置に配置し直すとこうなります:
美しい〜!
さて、配置はこれで何とかなりそうです。ただ、DirectXは3D描画に特化したエンジンなため、テクスチャを描画するには「ポリゴンに貼り付ける」しかありません。では、そのポリゴンをスクリーンの好きな位置に置くにはどうしたら良いか?これが意外と面倒なのです。
C 2Dフォント板ポリゴンの作り方
DirectXのポリゴンには3Dローカル座標定義のポリゴン(D3DFVF_XYZ)と座標計算済み頂点(D3DFVF_XYZRHW)という2種類があります。3Dローカル定義のポリゴンはローカル座標にポリゴン座標を定義し、ワールド変換、ビュー変換、射影変換という3つの変換を経てスクリーン座標を計算する方法です。中々に大変…。一方座標変換済み頂点はそういう座標変換を一気に飛ばし、直接スクリーン座標でポリゴンを作成できます。「おーじゃぁそれでいいじゃない」と言いたくなるのですが、ちょっと待った。座標計算済み頂点には「頂点を動かすのが苦手」という弱点があるんです。
座標計算済み頂点は例えば次のように頂点バッファ上にスクリーン座標を直接指定して定義します:
float x = 250.0f; // スクリーンX座標
float y = 100.0f; // スクリーンY座標
float w = 200.0f / 2; // テクスチャ幅
float h = 160.0f / 2; // テクスチャ高
Vtx vtx[4] = {
{x - w, y - h, 0.0f, 1.0f, 0.0f, 1.0f},
{x - w, y + h, 0.0f, 1.0f, 0.0f, 0.0f},
{x + w, y - h, 0.0f, 1.0f, 1.0f, 1.0f},
{x + w, y + h, 0.0f, 1.0f, 1.0f, 0.0f},
};
Vtx *p = 0;
pVertexBuffer->Lock(0, 0, (void**)&p, 0);
memcpy( p, vtx, sizeof(vtx) );
pVertexBuffer->Unlock();
後は描画デバイスにフォントテクスチャを設定して描画するとあっさりフォントテクスチャが描画できます。「お〜簡単、なんだこれでいいじゃない」というのはある意味正しい判断です。でも、もしこのフォントを動的に動かしたいとなった時、頂点バッファにある頂点座標を直接書き換える事になります。ここが問題なんです。
頂点バッファの値を書き換えるには上にあるように「頂点のロック」が必要になります。しかし、この頂点ロックはとんでもなく時間のかかる処理なんです。書き込みを安全にしメモリを保護するため、あらゆる描画プロセスが止まってしまいます。高々数100回のロックで1フレーム分の処理時間を費やしてしまう事だってあります。そのためゲーム制作では「頂点のロック回数は必要最低限もしくは一度ロックしたらもう二度とロックしない」という強い制約が必要になるんです。
ロック回数を最低限にするため、頂点バッファを使い回すという手も考えられます。しかし、フォントテクスチャはフォントの種類によって幅と高さが異なるためそもそも使い回しがあまりできません。結局、動的にフォントを作らなければならない場合、座標変換済み頂点を選択する理由はあまりありません。という事で、動的にフォントを作る時には3Dローカル座標定義の頂点座標を選択した方が自由度が上がります。
3Dローカル座標定義のフォント用板ポリゴンは、実は次のような「単位板ポリゴン」をただ1枚だけ作れば使い回せます:
Vtx vtx[4] = {
{0.0f, -1.0f, 1.0f, 0.0f, 1.0f},
{0.0f, 0.0f, 1.0f, 0.0f, 0.0f},
{1.0f, -1.0f, 1.0f, 1.0f, 1.0f},
{1.0f, 0.0f, 1.0f, 1.0f, 0.0f},
};
Vtx *p = 0;
pVertexBuffer->Lock(0, 0, (void**)&p, o);
memcpy( p, vtx, sizeof(vtx) );
pVertexBuffer->Unlock();
ローカル座標に左上が原点で幅高が1.0fなポリゴンが作られています。左上原点なのはフォントテクスチャのオフセットが左上ベースだからです。こういう「単位ポリゴン」が頂点バッファにただ一枚作られます。アプリケーションが動いている間で、フォントの頂点作成はただの1度だけ。ロック回数は1回で済みます。どのようなフォントにも対応できるのは、座標計算済み頂点と違い、3Dローカル座標定義のポリゴンが描画の際にその頂点座標を変化させる「座標変換」を施せるからです。
例えば、MSP明朝でサイズ40、太さ500の「あ」は、幅が33で高さが34なフォントテクスチャになり、原点からのオフセット位置は(1, 32)になります。上の単位テクスチャをこの幅高にするにはスケール変換行列を使えばいいんです:
D3DXMATRIX scale(
33.0f, 0.0f, 0.0f, 0.0f,
0.0f, 34.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 1.0f
);
この行列を単位板ポリゴンの頂点座標に掛け算すれば望みの幅高になります。ただ、ポリゴンの左上はまだ原点です。この原点をオフセット位置(1, 32)にずらすにはオフセット行列を使います:
D3DXMATRIX offset(
1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
1.0f, 32.0f, 0.0f, 1.0f
);
これでテクスチャの描画位置が(1, 32)にずれます。ここまでがローカル空間でのフォント頂点作成作業。これをワールドに置きます。置きたいワールド空間での原点位置を(ox, oy)とするなら、さらにオフセット行列が掛けられます:
D3DXMATRIX pos(
1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
ox - 0.5f, oy + 0.5f, 0.0f, 1.0f
);
ここで「0.5f」に注目!この微妙なずらしは何なのか?これはDirectXが持つ「ラスタライズルール」によるボケ画像を回避する方法です。これを入れないとフォントのような細い絵はボケボケになってしまいます!
置きたい位置(ox, oy)ですが、この位置指定は「正射影行列」が鍵を握ります。正射影行列とは遠近感を持たずに世界を指定の大きさの直方体に切り取る特殊な行列です。例えば以下のような行列です:
D3DXMATRIX ortho(
2.0f / screenWidth, 0.0f, 0.0f, 0.0f,
0.0f, 2.0f / screenHeight, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f / (zf - zn), 0.0f,
0.0f, 0.0f, -zn / (zf - zn), 1.0f
);
この行列、良く見ると実は単なるスケール変換行列なんですね。縦横幅をスクリーンサイズの半分に、奥行きをnearZからfarZまでの距離分だけ縮める行列です。つまり、例えば1280×760というスクリーンサイズを想定している場合、X = -640〜+640, Y = -380〜+380の範囲にあるものは奥行き感無く描画されるというわけです。フォントの置く位置(ox, oy)もこの範囲であればOKです。スクリーン座標とはちょっと違いますが、実はこちらの方が動かしたりする時に便利だったりします。
と言う事で、ローカル空間に置いたフォントポリゴンは、ワールド空間へのオフセット行列と上の正射影行列を通すことでスクリーン上に置く事ができるようになるわけです。
ところで「ビュー行列はどうなるの?」と思うかもしれませんが、ビュー行列は単位行列でリセットします。そうすると、難しい事を考えることなくフォントポリゴンを先の範囲に置く事で素直に描画されます。
これ以上の説明はもうさすがに瑣末的になってしまいますので、詳しくはサンプルプログラムをご参照頂ければと思います。フォント描画、面倒ですが必須です。是非クラス化してより自由に使い回せるようにしてみて下さい。