Seaside Laboratory

Post

GDI から Direct2D への移行

Windows Vista 以降で使われるようになった DWM (Desktop Window Manager) の影響で、GDI で描画したアニメーションがカクついたり、フレームが飛ぶようになってしまった。この現象については「デスクトップの裏側 (2)」という記事の説明が分かり易い。

例えばアプリケーションが毎秒 120 回ものシーンレンダリングを行っても、
画面更新が毎秒 60 回であれば DWM は自動的に半分の画像を捨ててしまいます。
この場合、毎秒 120 回のレンダリングは明らかに無駄になるため、
場合によっては DWM の影響をプログラム側で考慮する必要があるでしょう。

DWM による同期処理によってティアリングは発生しなくなったが、その代償として描画の反映タイミングが犠牲になってしまっている。

この問題を回避するために GDI 以外の描画手段を、ということで Direct2D を使うことにした。

Direct2D のバージョン

Direct2D は紹介ページに、

Direct2D は Direct3D 10.1 API を使用して構築されたユーザーモードライブラリです。

と書かれているように、Direct3D と密接な関係にあるので、Direct2D のバージョンが上がるとベースとなる Direct3D のバージョンも変化する。

Direct2D Direct3D ヘッダー
1.0 10.1 d2d1.h
1.1 11.1 d2d1_1.h
1.2 11.2 d2d1_2.h
1.3 11.3 d2d1_3.h

今回はバージョン 1.0 を使用するが、要件は、

Windows Vista with Service Pack 2 (SP2) および Platform Update for Windows Vista

となっているので、Direct2D を使ったアプリケーションは Windows XP で動かすことができない。

主なインターフェイス

Direct2D のサンプルプログラムには様々なインターフェイスが登場するが、ウィンドウへの単純な描画だけであれば以下の 3 つを把握しておけばよい。

インターフェイス 用途
ID2D1Factory Direct2D の起点となるインターフェイス。
ID2D1HwndRenderTarget ウィンドウへの描画を行う。
ID2D1Bitmap レンダーターゲットへ描画する際に使用するビットマップ。

あえて DirectDraw 的な表現をするのであれば、プライマリサーフェイスに相当するのが ID2D1HwndRenderTarget、バックサーフェイスに相当するのが ID2D1Bitmap になる。

ファクトリーの作成

D2D1CreateFactory 関数を使って ID2D1Factory を作成する。

ID2D1Factory* pD2DFactory;
HRESULT hr = D2D1CreateFactory( D2D1_FACTORY_TYPE_SINGLE_THREADED, &pD2DFactory );

DirectX のオブジェクト作成といえば COM API の CoCreateInstance 関数を呼び出すのが通例だったが、新しいインターフェイスはヘルパー関数を使うことが多い。

レンダーターゲットの作成

ファクトリーを使って ID2D1HwndRenderTarget を作成する。

レンダーターゲットの種類は複数あるが、

インターフェイス 説明
ID2D1BitmapRenderTarget CreateCompatibleRenderTarget メソッドによって作成された中間テクスチャにレンダリングします。
ID2D1DCRenderTarget GDI デバイス コンテキストに対して描画コマンドを発行します。
ID2D1GdiInteropRenderTarget GDI 描画コマンドを受け入れることができるデバイスコンテキストへのアクセスを提供します。
ID2D1HwndRenderTarget 描画命令をウィンドウにレンダリングします。

今回はウィンドウへの描画なので ID2D1HwndRenderTarget を使用する。

ID2D1HwndRenderTarget* pRenderTarget;
HRESULT hr = pD2DFactory->CreateHwndRenderTarget
(
    // D2D1_RENDER_TARGET_PROPERTIES 構造体
    D2D1::RenderTargetProperties(),
    // D2D1_HWND_RENDER_TARGET_PROPERTIES 構造体
    D2D1::HwndRenderTargetProperties
    (
        // 描画先のウィンドウハンドル
        hWnd,
        // レンダーターゲットのサイズ
        D2D1::SizeU( width, height )
    ),
    &pRenderTarget
);

Direct2D で使われている構造体は名前空間 D2D1 にあるヘルパー関数を使うと楽に作成することができる。標準的な設定がデフォルト引数として指定されているので、特に変わった設定がなければ引数を少し指定するだけで呼べてしまうことも多い。

ビットマップの作成

レンダーターゲットを使って ID2D1Bitmap を作成する。

レンダーターゲットには DirectDraw でいう Lock に相当する低レベルなメソッドが用意されていないので、一旦ビットマップ上に画面イメージを構築してからレンダーターゲットに転送する、という手順を踏む必要がある。

ID2D1Bitmap* pBitmap;
HRESULT hr = pRenderTarget->CreateBitmap
(
    // ビットマップのサイズ
    D2D1::SizeU( width, height ),
    // D2D1_BITMAP_PROPERTIES 構造体
    D2D1::BitmapProperties
    (
        // D2D1_PIXEL_FORMAT 構造体
        D2D1::PixelFormat
        (
            // ピクセルフォーマット
            DXGI_FORMAT_B8G8R8A8_UNORM,
            // アルファ値の扱い
            D2D1_ALPHA_MODE_IGNORE
        )
    ),
    &pBitmap
);

元が GDI ベースのプログラムだったので、ピクセルフォーマットには DIB と同じ DXGI_FORMAT_B8G8R8A8_UNORM を指定した。アルファチャンネルを使わないので気持ちとしては B8G8R8X8 を指定したいところだが、このフォーマットをサポートしない環境があったので、B8G8R8A8 にしておいた方が無難。

末尾にある UNORM が何を意味しているのかわからなかったが、MSDN を調べてみると、

符号なし正規化整数です。リソースでは符号なし整数として解釈され、
シェーダーでは、[0, 1] の範囲の符号なし正規化浮動小数点値として解釈されます。
すべての桁が 0 の場合は 0.0f にマップされ、すべての桁が 1 の場合は 1.0f にマップされます。
0.0f ~ 1.0f の均等な間隔の一連の浮動小数点値が表されます。
たとえば、2 ビットの UNORM は、0.0f、1/3、2/3、および 1.0f を表します。

「符号なし正規化整数」を英語にしたときの「Unsigned NORMalized integer」を略したものらしい。

ビットマップへの転送

ビットマップもレンダーターゲットと同じように直接書き込むことができないので、CopyFromMemory メソッドを使ってメモリ上に構築した画面イメージを取り込む。

pBitmap->CopyFromMemory
(
    // コピー先座標
    nullptr,
    // イメージへのポインタ
    reinterpret_cast< const void* >(lpBits),
    // ピッチ
    uPitch
);

ピッチにはビットマップを扱う際によく出てくるパディングを含んだ 1 ライン当たりのサイズを指定する。

srcData に格納されたソースビットマップのストライド (ピッチ)。
ストライドはスキャンライン (メモリ内の 1 行のピクセル数) のバイト数です。
ストライドは、"ピクセル幅 * ピクセルあたりのバイト数 + メモリパディング" という数式を使用して計算できます。

ピクセルが 32 ビットの場合は自動的に 4 バイトアラインメントに収まるのでパディングについて考慮する必要はない。

レンダーターゲットへの描画

ビットマップの準備が出来たらレンダーターゲットに渡して描画を行う。

pRenderTarget->BeginDraw();
pRenderTarget->DrawBitmap
(
    // レンダリングするビットマップ
    pBitmap,
    // 描画される領域のサイズと位置
    lprect,
    // 不透明度
    1.0f,
    // 補間モード
    D2D1_BITMAP_INTERPOLATION_MODE_NEAREST_NEIGHBOR
);
pRenderTarget->EndDraw();

デフォルトの補間モードは D2D1_BITMAP_INTERPOLATION_MODE_LINEAR になっているので、何も指定しないと拡大縮小時にぼやけてしまう。

ウィンドウの表示状況をチェック

MSDN のサンプルでは BeginDraw の前に CheckWindowState を呼び出し、戻り値に D2D1_WINDOW_STATE_OCCLUDED が含まれているかチェックしている。

D2D1_WINDOW_STATE 列挙型の説明には、

定数 意味
D2D1_WINDOW_STATE_NONE ウィンドウの表示は妨げられていません。
D2D1_WINDOW_STATE_OCCLUDED ウィンドウの表示は妨げられています。

と書かれているのでクライアント領域が隠れてしまった時のことを指しているのかと思い、ウィンドウを重ねたり、画面外に出したりしたが反応がない。色々と試した結果、唯一反応したのはクライアント領域のサイズがゼロになった時。存在しない領域への描画は処理できない、という意味だと勝手に解釈することにした。

CheckWindowState の挙動はやや特殊で、

前回 EndDraw を呼び出したときにウィンドウの表示が妨げられた場合、
次にレンダーターゲットで CheckWindowState を呼び出すと、
現在のウィンドウの状態に関係なく D2D1_WINDOW_STATE_OCCLUDED が返されます。
CheckWindowState を使用して現在のウィンドウの状態を確認するには、
EndDraw が呼び出されるたびに CheckWindowState を呼び出し、その戻り値を無視する必要があります。
これにより、次に CheckWindowState 状態を呼び出すと、実際のウィンドウの状態が返されます。

最新の状態を取得するには空呼び出しをして更新しておく必要がある。

レンダーターゲットの再作成

DirectDraw を使っていた時に、「サーフェイスの状態を IsLost でチェックして、消失していたら Restore で復元する。」という処理を書いたが、レンダーターゲットにも似た様な概念がある。

プログラムを実行している間に、使用中のグラフィックスデバイスが利用できなくなることがあります。
たとえば、ディスプレイの解像度を変更したり、ディスプレイアダプターを取り外したりすると、デバイスが消失する可能性があります。
デバイスが消失すると、そのデバイスに対応付けられていたすべてのデバイス依存リソースと共に、レンダーターゲットも無効になります。
Direct2D は EndDraw メソッドの戻り値として D2DERR_RECREATE_TARGET エラーコードを返し、デバイスの消失を通知します。
このエラーコードを受け取った場合は、レンダーターゲットとデバイス依存リソースをすべて再作成する必要があります。

ただ、ディスプレイの電源を切ったり、解像度を変更したり、スリープしてから復帰させてもエラーコードは返ってこなかった。もしかしたら WM_PAINT のタイミングで描画をしていないのが関係しているのかもしれない。

画面の更新タイミング

MSDN のプログラミングガイドには画面の更新タイミングに関する記述がなかったので、アプリケーション側が描画した内容を DWM が適当なタイミングで反映しているものだと勝手に思い込んでいたが、プログラムの動作を調べてみると、リフレッシュレートに合わせた同期待ちが発生していた。

リファレンスを端から読み直してみたら D2D1_HWND_RENDER_TARGET_PROPERTIES 構造体に presentOptions という設定項目があった。

定数 意味
D2D1_PRESENT_OPTIONS_NONE レンダーターゲットは、表示が更新されるまで待機し、表示時にフレームを破棄します。
D2D1_PRESENT_OPTIONS_RETAIN_CONTENTS レンダーターゲットは表示時にフレームを破棄しません。
D2D1_PRESENT_OPTIONS_IMMEDIATELY レンダーターゲットは、表示が更新されるまで待機しません。

デフォルトは D2D1_PRESENT_OPTIONS_NONE になっているので、何も指定しないと多くの環境ではリフレッシュレートに合わせて秒間 60 フレーム描画されることになる。

D2D1_PRESENT_OPTIONS_IMMEDIATELY を指定すると同期待ちが発生せず、即時に制御が戻ってきた。アプリケーションが要求するフレームレートとディスプレイのリフレッシュレートが合わない場合は、非同期にしてタイマーによる待機を行うしかなさそう。

DWM を考慮したタイマー処理

描画タイミングをタイマーで制御する場合、以下のような処理で待機を行っていることが多いと思う。

// 処理時間は前回のフレーム終了時間から現在の時間を引いて求める
DWORD dwProcessTime = dwLastTime - timeGetTime();
// 待機時間は 1 フレーム当たりの時間から処理時間を引いたもの
Sleep( dwFrameTime - dwProcessTime );
// フレームが終了した時間を記録
dwLastTime = timeGetTime();

この方式は DWM による画面更新と相性が悪い。

説明がややこしくなるので 1 フレームが 17ms という前提で話を進める。Sleep はあまり精度が高くないので待機時間が 18 ~ 19ms になってしまうフレームもしばしば発生するが、DWM による更新は規則正しく 17ms 間隔で行われるので、時間が経過すればするほどアプリケーションと DWM のタイミングがずれてしまう。踏切のランプと音が時間とともにずれていくのを想像すると分かりやすいかもしれない。

リフレッシュレートが絶対時間なのに対し、タイマーを相対時間で扱っているのが問題なので、タイマーも絶対時間で処理すればいい、ということになる。

// フレーム終了時間は前回のフレーム終了時間に 1 フレーム当たりの時間を加算したもの
DWORD dwEndTime = dwLastTime + dwFrameTime;
// 終了時間まで待つ
Sleep( dwEndTime - timeGetTime() );
// フレームが終了した時間を記録
dwLastTime = dwEndTime;

ポイントはフレームの終了時間に実測値ではなく、計算で求めた理論値を入れているところ。

相対方式は良くも悪くも発生した誤差をなかったものとして処理しているが、絶対方式は誤差が発生したら次のフレームでツケを払わなければならない。例えばあるフレームで 19ms かかってしまった場合、次のフレームは超過した 2ms を差し引いた 15ms になる。

相対方式に比べてフレーム時間の安定性は低くなるが、DWM のタイミングとズレて画面がカクつくよりは良いのではないだろうか。