DirectX のコード資産

『誰も居ない』より.

DirectXはCOMでオブジェクト指向で書かれているとは言うものの、そのままではやはり使いにくい部分も多い。これは、"必要となる全てのサービスを最小限のインタフェースで提供する事"がAPIに求められる性質なので、多種多様な便利関数が含まれていないのは仕方が無い。

それを補うためにD3DXやDXUTがあるというのは事実だが、それでも不足は出る。3D用のユーティリティライブラリで満足できる物は恐らく存在しないので、必要に応じてラッパクラスを作る事になる。

今日は、頂点バッファとインデックスバッファのラッパを作っていたのだけど、実は同じ様なものを前にも1度は作った事がある。1度作っても、後になるとどうしても不備が出てきて、根本的な部分を作り直さなければいけないことがよくあるのだ。

未熟なうちは無理に完成されたラッパを作らないほうが良いのかもしれない。しかし、そうは言っても同じ様なコードを何度も書くのはやりたくない。そういう考えの衝突から泥沼に嵌ってきて、事態を打開するために元のコードを書くことを再開する。そういう悪循環から早く逃れたい。

これは自分の経験でもありますが,DirectX 開発を始めてしばらくすると,「ラッパーライブラリ作成」という作業自体に「のめり込みんでしまいやすい」という罠があります.例えば仮想関数憶えたての頃だと「Direct3DOpenGL,自前レンダラ共通のインターフェイスを作るぜ」というパターンとか.
私も昔はそういうフレームワーク作りが楽しかった時期がありましたが,変な話で,熱中している時には見えなかった共通性というものに,そういったフレームワーク作りを冷めた目で見られるようになってから気付くこともあります.当時は何でも C++ の範囲内で考えていたので,言語が思考を規定していたのかもしれません*1
以下は最近私がよく感じる DirectX の共通性ですが,いわゆる DirectX ラッパーの作り直しを繰り返している人は,こういった視点で過去の仕事の資産化が出来ているか見直してみてはどうでしょうか? 私の場合ここしばらく視点が DSL やコードの自動生成方向に向かっているので,そのテイストが強いかもしれませんけど.


リソース I/O

DirectX は昔から一貫して Lock ベースのリソース I/O を採用しています*2.実行バッファ,テクスチャ,頂点バッファ,インデクスバッファと,様々なりソースが Lock ベースで読み書きされます.DirectX のリソース I/O の特徴をいくつか挙げてみます.

  • Lock 時に予めサイズを決める必要がある.
  • パフォーマンス上の理由から,読み取り専用の Lock,書き込み専用の Lock,読み書き用の Lock が区別され,たいていの場合はリソース作成時からフラグが異なる.
  • デバイス側とアプリケーション側でメモリレイアウトの対応をとるためのスキーマが登場する.ピクセルフォーマットや,FVF,Vertex Shader Declaration など.

細かい実装 (フラグや表現) はとにかく,大まかな方向性という意味ではほとんど変化がなかったりするので,「何か前も似たような処理書いたなぁ」度が高くなりやすいです.


オブジェクト・ライフサイクル

分け方は色々あるでしょうが,私は Direct3D のデバイスを次のような状態で区別しています*3

  • 作成前
  • 作成後・BeginScene 前
  • BeginScene 後,EndScene 前
  • リセット可能なロスト状態
  • リセット不能なロスト状態
  • 終了処理前

また Win32 なウィンドウ管理では,次のようなウィンドウ状態を考慮することが多いです.

  • ウィンドウ作成前
  • ウィンドウ・アクティブ状態
  • ウィンドウ・非アクティブ状態
  • ウィンドウ・サイズ変更中
  • ウィンドウ破棄処理前

これらの状態の全組み合わせが必要になるわけではありませんが,それでも DXUT のようなフレームワークでは状態遷移に伴うイベント処理の記述がかなりの量を占めることになります.各イベント処理は単体では大したことはないのですが,高い網羅性を要求すると案外に面倒な作業です.イベントの発火がさらなる状態遷移を誘発する場合やエラー時の遷移過程をきちんと考慮しだすとコード記述量が急激に増加し,C++ 風の継承や多態だけで乗り切るにはややきついという印象があります.
ステート処理のスパゲッティと化した C++C#ソースコードは持っていても必ずしも嬉しいとは言えず,どちらかというとコーディング前のステート遷移図が残っていて助かったと感じることが多々あります.Windows Workflow Foundation (WF) の XOML データなどを保持して,C++C#ソースコードは設計から自動生成された (その場限りの) コードに過ぎないと割り切れるような時代が来るかなぁと,最近若干期待してはいるのですが.今の私なら,DXUT クラスのイベント処理をいちから書き下していると多分途中で飽きるでしょうし.

余談ですが,この「ステートによって同一メソッドの処理内容が変化する」ことは,インターフェイス指向の開発を難しくしている原因のひとつではないでしょうか.あるインターフェイス I をサポートするオブジェクト O について,I の想定するステートよりも多くのステートを O が持っていたとき,O のステートの差違が I で定義されたメソッドの動作から表面化してしまうと困ったことになります.この場合,I のヘルプだけを読んでいても問題は避けられず,結局 O のドキュメントを読むことになります.「それは設計がまずい」という話が出るでしょうが,言われて簡単に避けられるほど単純な問題であれば苦労はしないでしょう.GDNJ のIDisposable スレッドで吉松さんは「IDisposable.Dispose を正しく使うには,各実装クラスのドキュメントを読む必要がある」と主張されていましたが,そうならざるを得ない理由のひとつが,IDisposable が『破棄以前』・『破棄後』というステートに絡むインターフェイスであるためと考えられます.また,ステートは実行時のパラメータなので,コンパイル時の静的検証も難しいものとなります.
さらに,ステートの存在は IntelliSense とも相性が良くありません.IntelliSense を活用した開発スタイルでは,メソッド名と簡単な説明文だけで安易にメソッドが選択されることがありますが,これは呼出し順序が指定されている一連の API 群や,あるステートではこの API を呼び出してはいけないといったルールに対してはひどく脆弱です.
ステートに注目し,Drect3D 開発でよく見る「デバイスが初期化前に呼ばれていたらエラーを返す (NULL チェック)」や「リセット不能なロスト状態で呼ばれていたら復帰を試みる」といった処理をあちこちに散逸するという問題に AOP 的なウィービング技術を適用てみた習作が拙作 orzEngine です.まあ動機の根源には,『Essential .NET (asin:4891003685)』を読んで (当時の) Don Box にあてられていたというか,AOP 技術を使ってみたかったというのがあるわけですけど.それでも印象としては,作成するラッパクラスのメソッド数が一定数を超えてくると,属性による宣言型の実装の方が明らかに楽に記述でき,またメソッドの先頭を見るだけで処理内容が把握しやすいというメリットを感じました.属性を利用して機械的にドキュメントを生成できるというのもポイントです.最近だとエフェクトファイルのアノテーションDirectX 開発者にも割と受け入れられやすくなっているかもしれません.

*1:そして見直してみると例に漏れず [http://www.radiumsoftware.com/0603.html#060330:title=〜Manager と名前が付いてますよ]と

*2:ただし Direct3D10 では若干微調整というか,(ファイルの) メモリマップのアナロジーで Map という概念に置き換えられるでしょう

*3:これも Direct3D10 ではデバイスロストが無くなって,代わりにデバイスが抜かれた状態が追加されることで,若干変化するでしょう

Execute Buffer サンプル

他にも記事を書くにあたって,全然知らなかった実行バッファ時代が気になって,実際に DirectX 2 でポリゴン描画させてみたらちゃんと動いて感動した,とか.

.NET&Windows Vistaへ広がるDirectXの世界 〜 第1回 DirectXの真実 - NyaRuRuが地球にいたころ

Execute Buffer による描画サンプルですが,この辺りに置いておきます.
http://www.dwahan.net/nyaruru/hatena/DirectX2.zip
SDK 付属のサンプルコードを書き直しつつ把握していったものですが,途中で書き直し作業に飽きました.なので公開と.
実行バッファのヘルプなんて持っていないという方は,古い書籍等でがんばって古い DirectX SDK を入手してみてください.DirectX 6.1 のヘルプにはまだ実行バッファについて載っていて,DirectX 7 のヘルプからは消えているようです*1.また,MSDN Subscription からダウンロードできる『Visual C++ 4.2 Enterprise Edition (English) Disc 2 』に,DirectX 2 SDK が含まれているので,こちらを参照するという手もあるでしょう.

*1:ただし DirectX 7.0a の CD には DirectX 6.1 のヘルプも含まれている

How to use SafeHandle in a Resilient Library

SafeHandles や非同期例外については関係についてはこの日記でも以前から何度も取り上げていますが,「BCLTeam's WebLog」に新しいエントリが出ていたので紹介と.

How to use SafeHandle in a Resilient Library
http://blogs.msdn.com/bclteam/archive/2006/06/23/644343.aspx

P/Invoke や COM Interop などで下回りのコードを書く人は原則論として理解しておくべき内容なんですが,それ以前に「簡単に (アンマネージ) API・DLL を呼べます!」と煽っている連中がまず熟読すべきなんでしょうなぁ.呼ぶのは簡単でも動かすのは簡単じゃねーんだぞ,と.



Web で読める日本語資料としては,次の 2 つがおすすめです.

  1. 高可用性 .NET Framework の信頼性機能でコードを実行し続ける
  2. .NET Framework 開発者ガイド 信頼性に関するベスト プラクティス

特に後者については量が多いのでついつい読み流しかもしれませんが,実際網羅性はかなり高いです.

一部のコンテキストでは、SafeHandle は適切ではありません。ReleaseHandle メソッドは、GC ファイナライザ スレッドで実行できるため、特定のスレッドで解放する必要のあるハンドルを SafeHandle にラップする必要はありません。

例えばこれは,以前触れた Direct3D Device オブジェクトの解放を GC スレッドから行えない(id:NyaRuRu:20050526#p6) という問題そのもの.
とりあえず .NET Framework 開発者ガイド 信頼性に関するベスト プラクティス の「コード分析規則」を読みながら,「んっ?」と思った箇所を重点的に調べてみると良いかもしれません.

コード分析規則

SafeHandle を使用して、オペレーティング システム リソースをカプセル化します。HandleRef または IntPtr 型のフィールドは使用しないでください。

こういうのが沢山書いてあります.他にもこういうのは言われないと中々気付かないでしょう.

STA に依存する機能を識別する

COM シングルスレッド アパートメント (STA: Single-Threaded Apartment) を使用するコードを識別します。SQL Server プロセスでは、STA は無効になります。パフォーマンス カウンタやクリップボードなど、CoInitialize に依存する機能は SQL Server 内では無効にする必要があります。

あーでも一部の訳はミスリーディングかも.

Do Not Assume a Managed Thread Is a Win32 Thread – It Is a Fiber
Win32 スレッドはファイバであるため、マネージ スレッドが Win32 スレッドであることを想定しない
SQL Server runs in fiber mode; do not use thread local storage.
SQL Server はファイバ モードで実行します。スレッド ローカル ストレージは使用しないでください。

まあ「んっ?」と思ったら訳の問題だったという可能性もあるということで.

this の寿命

先ほどのHow to use SafeHandle in a Resilient LibraryGC.KeepAliveの話が出ていたので久しぶりに実験と.
関連話題.

以下のようなコードを実行してみます.

using System;
using System.Text;
using System.Threading;
using System.Diagnostics;

public class Test
{
    IntPtr MyHandle = IntPtr.Zero;
    ~Test()
    {
        Trace.WriteLine("Finalize; Release Handle");
    }
    public void Hoge()
    {
        Trace.WriteLine("Get Handle from OS : 123456");
        this.MyHandle = (IntPtr) 123456;
        Fuga(MyHandle);
    }
    public void Fuga(IntPtr handle)
    {
        GC.Collect();
        Thread.Sleep(1000);
        Trace.WriteLine("Use Handle : " + handle);
    }
}
class Program
{
    static void Main(string[] args)
    {
        Trace.Listeners.Add(new ConsoleTraceListener());
        new Test().Hoge();
    }
}

Visual C# 2005 にて Release ビルドしたものを手元の環境で実行してみたところこのように表示されました.

Get Handle from OS : 123456
Finalize; Release Handle
Use Handle : 123456

結果を一言で言えば,「CLR は時としてインスタンスメソッド実行中に delete this を行います」というところですかね.
もうちょっと弄ると,「コンストラクタ実行中にファイナライザが走る」のを見ることができます.

public class Test2
{
    IntPtr MyHandle = IntPtr.Zero;
    ~Test2()
    {
        Trace.WriteLine("Finalize; Release Handle");
    }
    public Test2()
    {
        Trace.WriteLine("Get Handle from OS : 123456");
        this.MyHandle = (IntPtr)123456;
        IntPtr handle = MyHandle;
        GC.Collect();
        Thread.Sleep(1000);
        Trace.WriteLine("Use Handle : " + handle);
    }
}
class Program
{
    static void Main(string[] args)
    {
        Trace.Listeners.Add(new ConsoleTraceListener());
        new Test2();
    }
}
Get Handle from OS : 123456
Finalize; Release Handle
Use Handle : 123456

原因については上の関連話題で紹介されていたLifetime, GC.KeepAlive, handle recycling で言及されていますが,JIT のインライン展開によってメソッド呼出し元の this 参照が消失することがあるためです.ソースコードの上ではインスタンスメソッドの呼出し元が存在しても,そのことによってオブジェクトの存在期間がメソッド全体(つまり,呼出し元に戻る)まで保証されるというわけではない,と.少し調べ直してみたら,この問題「Rico Mariani's Performance Tidbits」の IDisposable に関する議論でも登場してますね.

Win32 I/O キャンセル API と"ゾンビ" プロセス

アプリケーション終了時の障害

Windows アプリケーションは、ユーザーがアプリケーションを閉じたときに (タイトル バーの [X] をクリックした場合など)、正常に終了しないことがあります。このような場合、アプリケーションのウィンドウは閉じますが、よく調べると、アプリケーションのプロセスがプロセス リストに残ったままになっています。このようなプロセスは、"ゾンビ" プロセスと呼ばれます。ゾンビ プロセスは強制終了できません。ゾンビ プロセスによってアプリケーションの再起動ができない場合があり、予期しない動作が行われることもあります。復旧するには、コンピュータの再起動が必要になる場合があります。

マイクロソフトでは、このような動作に関する事例報告を多数受けており、その原因は障害のあるドライバによるものと推測していました。また、根本的な原因を特定し、その解決方法を明確にするために、信頼のできるデータを調査していました。このようなデータを収集するために、特殊な機能を備えた Windows XP のバージョンを開発しました。このバージョンは、カーネル データの収集とレポートを行います。このデータには、スタック プロセスを待機するドライバ要求に関するデータも含まれています。特別なテクニカル ベータ プログラムを通じて、このバージョンを、マイクロソフトの内部ユーザー、および選抜した顧客に対して展開しました。

報告されたレポートを調べると、弊社で推測していた原因だけでなく、終了時の障害やアプリケーションからの応答がないという問題は、Windows やドライバに、ドライバでの作成要求をキャンセルする機能がないということも原因であることが確認されました。このような障害を解決するために、Windows を修正する必要があったのです。

あー,何で今頃になって Win32 I/O キャンセルが大量に追加されたのかと思ったら,そういうわけですか.