Seaside Laboratory

Posts

ソフトウェアスプライトのススメ

「DirectDraw を窓から投げ捨てろ!」という話。

まず、DirectDraw とは何ぞやということで、@IT の「DirectX の真実」から記事を引用。

DirectDraw は、ゲームを強く意識した機能を持つ API セットだ。DirectDraw は MS-DOS ベースのゲーム開発者を Windows の世界に引き込むべく、GDI をバイパスしたハードウェアによる画像の矩形転送やパレット管理、フルスクリーン・モードによるモニタへの直接出力やモニタの垂直回帰中に完了する高速な画面切り替えをハードウェア非依存という形で再構築したものといえる。

DirectDraw は低速な GDI の欠点を補うべく登場した API ではあるが、余計な処理をしないことでオーバーヘッドを低減している面もあり、限定された条件下でしか動作しない制約の厳しいものになっている。

例えば、DirectDraw のハードウェア能力を表す DDCAPS 構造体の解説は、

dwFXCaps
ドライバ固有の拡大およびエフェクト能力。

DDFXCAPS_BLTMIRRORLEFTRIGHT
ブリット処理で左右のミラーリングをサポートする。

といった調子で、左右反転という基本的な処理でさえサポートされない可能性があることを暗に示している。ただ、ビデオカードのサポートがあたりまえなのか、裏で HEL が動いているのかわからないが、左右反転が機能しないという状況になったことはない。

結局のところ、DirectDraw がやってくれるのは単純な矩形転送のみなので、スプライトの回転やアルファブレンドのようなエフェクトを使いたくなったら、DirectDraw に頼らない転送ルーチンをガリガリと書くしかない。

ソフトウェアで転送を行うとハードウェアのアクセラレーションを受けられなくなってしまうが、幸い近年の PC は高性能なので十分な速度を出すことができる。苦労して DirectDraw を使ったところで速度面でのアドバンテージがあまりないという状況は、現在の環境が DirectDraw 登場時の想定から大きく外れてしまった、ということなのだろう。

DirectDraw を使い続けていくうちに不満がたまってきたことと、古い API なので今後のサポートが期待できないという部分も含め、自作ゲームライブラリの描画処理を全てソフトウェア転送に切り替えることにした。

切り替え時に色々とつまづくポイントがあったので、備忘録として書いておく。

移行概要

DirectDraw には画面のフルスクリーン化といった描画以外の機能も含まれているので、DirectDraw を使ったコードが全てなくなるわけではない。

要件は、

  • スプライト単体で 256 色使用可能。
  • パレットの切り替えに対応。

となっているので、システム構成は以下のようになった。括弧の中に書かれているのはビット数。

バッファ
プライマリースクリーン IDirectDrawSurface7 (16/32) HWND (16/24/32)
バックスクリーン IDirectDrawSurface7 (16/32) HBITMAP (32)
オフスクリーン IDirectDrawSurface7 (16/32) BYTE 配列 (8) + パレット

バックスクリーンのビット数は、

  • デスクトップ画面と同じビット数にすることで転送時の無駄な変換を抑制する。
  • ピクセルサイズが 4 バイトになり、アドレッシングの効率が良い。

という理由により、24 ではなく 32 で作成する。

ビットマップ作成 API

バックスクリーンの実体はビットマップなので、ビットマップを作成する API を探してみたが、似たようなものがいくつもあって紛らわしい。

API 内容
CreateBitmap モノクロビットマップを作成
CreateDIBitmap DIB から DDB を作成
CreateDIBSection DIB を作成

DIB が必要なので CreateDIBSection を使う。

ビットマップのビット数上限

ビットマップの色数は BITMAPINFOHEADER の biBitCount で指定を行うが、ここに 24 ビットを超える値を設定しても正常に動作するのか不安がある。

IT 用語辞典に、

通常使用する範囲では人間の目にはほとんど色の違いがわからないほど自然な表現が可能なため、コンピュータや録画・撮影機器、表示機器、および画像データや動画データは、24 ビットカラーを取り扱える最大の色数とするものが多い。

と書かれているように、通常は 24 ビットあれば十分なのである。もちろんアルファ値も含めた 32 ビット表現があることは知っているが、それを API に指定できるかどうかは別の話。

リファレンスには、

biBitCount
ピクセルあたりのビット数を指定する。

としか書かれておらずまったく参考にならない。

MSDN の日本語リファレンスはクソなので、英語版を見てみると指定できる値と詳細な解説が書かれていて、32 を指定しても問題ないということが分かった。

32 ビットカラーのピクセルフォーマット

32 ビットでビットマップを作成するとどんなピクセルフォーマットになるのか。当然のことながら日本語版にはろくな情報が書かれていないので英語版を参照する。

Each DWORD in the bitmap array represents the relative intensities of blue, green, and red for a pixel. The value for blue is in the least significant 8 bits, followed by 8 bits each for green and red. The high byte in each DWORD is not used.

ビットマップ配列内の各 DWORD は、ピクセルの青、緑、赤の相対強度を表しています。青の値は最下位の 8 ビットで、8 ビットずつ緑、赤と続きます。各 DWORD の高位バイトは使用されません。

いわゆる XRGB という並び。ARGB ではないので注意。

ビットマップの走査方向

ビットマップファイルを扱ったことがある人なら知っているかもしれないが、ビットマップは基本的に逆ラスターなので画像は上下が反転した状態で格納されている。CreateDIBSection で作成したビットマップも例外ではない。

リファレンスには、

非圧縮 RGB ビットマップで biHeight の値が正の場合、ビットマップは左下隅に原点を持つボトムアップ DIB である。biHeight の値が負の場合、ビットマップは左上隅に原点を持つトップダウン DIB である。

と書かれているので、負の高さを指定して走査方向を反転しておくとプログラムからの扱いが楽になる。

以前、ビットマップファイルを扱ったときは、他のアプリケーションで読めなくなる可能性を考慮して負の値を使わないようにしていたが、内部で完結する API であれば必ず動作するので遠慮する必要はない。

オフスクリーンのパレットフォーマット

パレットを RGBQUAD のような形式で持っているとピクセルを転送する度に、

// XRGB フォーマットに変換
DWORD dwColor = (rgbquad.rgbRed << 16) | (rgbquad.rgbGreen << 8) | rgbBlue;

のようなカラー変換処理が必要になって効率が悪い。

無駄な計算をしなくて済むよう、パレットを XRGB に展開したルックアップテーブルを作成しておき、転送の際にはそれを使って変換を行う。

カラーキー付の転送と高速化

スプライトの透過処理を「透過色なら転送を行わない」といった条件分岐で実装してしまうと、分岐予測ミスのペナルティによって速度が低下してしまうので、条件分岐を使わない方式で実装を行う。

まず、RGB 値の上位ビットに 1 ビットの透過フラグを持たせた、アルファチャンネル値が 0 ~ 1 の ARGB フォーマットでカラーを保持しておく。アルファチャンネルは一般的に 0 が透明で 255 が不透明らしいので、0 を透明、1 を不透明とした。

この ARGB もどきに以下のようなビット敷衍化を行うと、

DWORD dwMask = 0x1000000 - (dwColor >> 24);

透明のときは全ビットが 0、不透明のときは全ビットが 1 のマスクを生成できる。

転送の準備が揃ったら、ビットの AND と OR 演算によるスプライト合成を行うだけ。以下は、実際に使用している転送コード。

// lpdwDestBits は転送先のスクリーン
*lpdwDestBits = (*lpdwDestBits & (~dwMask)) | (dwColor & dwMask);

バックスクリーンからプライマリーへの転送

ウィンドウが保持している HWND からデバイスコンテキストを取得し、そこへバックスクリーンの HBITMAP を転送する。ウィンドウサイズの変更に対応できるよう、伸張しながら転送が行える API が必要になるが、ビットマップ作成 API の時と同様、似たものが用意されている。

API 内容
StretchBlt デバイスコンテキストからデバイスコンテキストへコピー
StretchDIBits DIB からデバイスコンテキストへコピー

バックスクリーンには DIB があり、そこからデバイスコンテキストを作ることもできるので、どちらの API も問題なく使うことができる。となると、焦点は速度面で優位性があるかどうか。

試しに画面を 4 倍に拡大しながら転送するベンチマークを行ったところ、StretchDIBits より StretchBlt の方が 1.8 倍程速かった。「最近の環境だと速度に大きな違いなし」と書かれた記事もあったので、この速度差は意外。

プライマリーへの転送に GDI を使うメリット

DirectDrawSurface 使用時はクライアント領域の左上座標を ClientToScreen で取得し、そこへ Blt するという手間が必要だったが、ウィンドウに Blt する場合は適切な座標へ描画してくれるので何も考えなくてよい。

また、GDI による転送はピクセルフォーマットの変換が自動的に行われるので、画面の色数が何であろうと適切に処理される。試してはいないが、理論上は 8 ビットカラーの画面でも (減色は発生するものの) 正常に描画が行われる、はず。

プライマリーだけ DirectDrawSurface にしたときの問題点 (1)

DirectDrawSurface には GetDC メソッドが用意されているので、当初はプライマリーだけは切り替えを行わず、そのまま使い続けていたが、バックスクリーンからプライマリーへ転送する際、GetDC 後の ReleaseDC で謎のクラッシュが発生した。

リファレンスを眺めていたら DDSCAPS の項目でそれらしきフラグが見つかった。

DDSCAPS_OWNDC
このサーフェスは、長時間にわたってデバイスコンテキスト (DC) と関連を持つ。

CreateSurface 時にこのフラグを指定するとクラッシュは起きなくなったが、リファレンスの説明が少なすぎてこれが正しい解決方法だったのか不明。

プライマリーだけ DirectDrawSurface にしたときの問題点 (2)

DirectDrawClipper はサーフェイス間の Blt を前提としているのか、GetDC 経由で Blt を行うとクリッピングが行われない。

ウィンドウをタスクバーに重ねると、本来裏へ隠れるはずの画面がタスクバーの上に描画されてしまったり、他のウィンドウを重ねたときの挙動が若干あやしい。

間にバックサーフェイスを挟んで転送することも考えたが、無駄な転送が増えてしまうので素直にウィンドウへ描画した方がよい。