0. Index
- Introduction
- Architecture
- Implementation
- Conclusion
- Bibliography
1. Introduction
ゲームプログラミングに投入されるプログラミングメソッドと言えば,オブジェクト指向がほぼ一段落し,デザインパターンやeXtreme Programmingなどが現在面白くなってきているところでしょうか? これらの知識が常識化しつつある中,ゲームプログラミングの解説書にもオブジェクト指向やデザインパターンの解説が添えられるようなりました.ここで敢えてこれら「流行っちゃったもの」の解説を書くのは気が引けます.まずもって既に世に名著はありますし,これらの業界に身を置いていれば身近に優れた先達は数多くいることでしょう.そこで今回は,これから流行る(と言われ続けている)『アスペクト指向プログラミング (Aspect-Oriented Programming)』を取り上げてみます.
AOPは大ざっぱに言って,次のような問題の提起です.すなわち,「ある共通のコード片がアプリケーション全体に点在する」という事実です.従来のコンポーネントソフトウェアが問題領域の分割によってコードをブラックボックスに還元したのに対し,AOPはアスペクトと呼ばれる側面が全ての問題領域に存在しうることに着目します.しばしば例示されるアスペクトとしては,エラー処理,プロファイリング,スレッド管理,セキュリティなどに関するコードがあります.これらのコードを,必要に迫られて何度も「書き足した」経験はありませんか? 思い出してください.これらのコードは,関心事としては明らかに異なるコンポーネントの中に同じ顔をして居座っています.気付いてしまえば後はプログラマとしてのゴーストが囁くはずです.「同じものはまとめよ,異なるものは分離せよ」と.
「関心事の分離 (separation of concerns)」がAOPの原則です.問題領域に直接関係しないアスペクトを分離し,分類し,そして再利用します.さてどんな魔法でこれが実現できるでしょうか? 例えばうんざりする程たくさんのエラーチェックとデバッグメッセージ出力ルーチンをコピー&ペーストするのに費やした時間は,どうすればもっと有意義でクリエイティブな作業に回せたでしょうか? ここでは分離のための手法として,割り込み (interception) を取り上げます.取り上げる理由は,それがまさにその目的のために .NET Framework に存在するからです..NET Framework の特徴の1つである『属性』とともに,2章で詳しく説明する割り込みアーキテクチャも Microsoft Transaction Sarver (MTS) において野心的に導入された技術です.そしてその試みはより洗練された形で .NET Framework に引き継がれました.
.NET Framework において割り込みを利用するためには,対象となるオブジェクトがある特別な基底クラスから派生していることが必要です.これが今回の魔法のタネなのですが,幸いなことにMicrosoft.DirectX.Direct3D.Device はこの条件を満たしています.Microsoft の Managed DirectX 開発チームのこの計らいに感謝しつつ,以下ではこの幸運を具体的な状況設定の元で利用してみることを考えます.さしあたって,簡易プロファイラを作ってみましょう.気になるのは描画の1フレームを時間軸で表したとき,どの時刻にどれぐらいの占有時間で描画命令が分布しているかです.調査のために描画のたびにイベントを作って記録することにします.イベントは描画命令の開始時刻と終了時刻,さらに描画ポリゴン数も記録することにしましょう.時計を見ます.この作業にどれぐらい時間がかかるでしょうか? 全てのソースコードをgrepして,Draw*Primitiveの前後にコードを付け足さないといけないとすれば,それは悲劇です.もしあなたが手がけた馴染み深い描画ライブラリがあって,アプリケーションの描画命令は全てその描画ライブラリを経由しているなら,あなたは描画ライブラリにこのプロファイリング機能を書き込めば変更が最小限に抑えられることに気付くかもしれません.これなら簡単そうです.しかし関心事は分離されません.洗練された描画ライブラリの一角に場違いなよそ者を住まわせることになります.そして運悪く別の描画ライブラリを併用することになったとき―例えばID3DXMeshなど―このプロファイリング機能のコピー&ペーストという仕事が待っています.さあどうするか? それを以下にお見せしましょう.
2. Architecture
この章では .NET Framework の割り込みのメカニズムについて説明します.Windows プログラミングでしばしば用いられる Window Procedure フックや,ハードウェアプログラミングで用いられる割り込みハンドラの概念を知っていれば,メカニズムの理解はそれほど難しくはないかもしれません.指向や思想はどうであれ実装自体は"そのような"もので,ある意味古典的とも言えます.
.NET Framework の割り込みでは,割り込み対象(以下ターゲットと呼ぶことにします)のクラスごとにプロキシオブジェクトと呼ばれるヘルパーオブジェクトを作成します.プロキシオブジェクトには,ターゲットのインスタンスを偽装する透過プロキシ (transparent proxy)と,割り込みハンドラを実装する実プロキシ (real proxy)の2種類が存在し,ふたつのプロキシが協調動作する形で割り込み機構をプログラマに開放します.図1にこの関係を示します.
図1 割り込みのメカニズム
図1左上にある target が指してるのが透過プロキシです.透過プロキシはメソッド Calc が呼び出されると,この呼び出しに関する情報をパックしたコールメッセージを実プロキシに送ります.これに対し実プロキシはコールメッセージから呼び出し情報を調べた上で,メソッドの戻り値に相当するリターンメッセージを作成し送り返します.最終的にリターンメッセージは透過プロキシのメソッドの戻り値として呼び出し元に返されます.この例では返されるのはメソッドの戻り値のみですが,ref や out で参照渡しされた引数があればそれらについても対応します.もっとも簡単な方法は,ターゲット本来のメソッドを用いて適切な出力を得る方法です.ここで実際に実プロキシの割り込みハンドラがどのように実装されるかを見てみましょう.
using System.Runtime.Remoting; using System.Runtime.Remoting.Messaging; using System.Runtime.Remoting.Proxies; /// <summary> /// サンプル実プロキシ /// </summary> public class SampleProxy : RealProxy { /// <summary> /// 透過プロキシを返すファクトリメソッド /// </summary> /// <returns>Targetクラスの透過プロキシ</returns> static public Target CreateTarget() { Target target = new Target(); SampleProxy proxy = new SampleProxy( target, typeof(Target) ); return (Target) proxy.GetTransparentProxy(); } private readonly Target target; public SampleProxy( Target target, Type type) : base(type) { this.target = target; } public override IMessage Invoke(IMessage msg) { IMethodCallMessage call = (IMethodCallMessage) msg; IMethodReturnMessage response = RemotingServices.ExecuteMessage( target, call ); return response; } }
上のコードにあるように,実プロキシを作成するときにはSystem.Runtime.Remoting.Proxies.RealProxyクラスを継承します.割り込みハンドラはInvokeメソッドをオーバーライドすることで実装します.Invoke メソッド内部に現れる2つのメッセージ call と response が,図1で透過プロキシと実プロキシがやりとりしていた情報に相当します.また,今回の例で示した実プロキシの実装では内部にターゲットクラスのインスタンスを保持しています.このインスタンスは実際にメソッド本来の仕事をさせるために用います.リターンメッセージは,このインスタンスとコールメッセージをSystem.Runtime.Remoting.RemotingServicesクラスのExecuteMessageに渡すことで生成できます.ExecuteMessage メソッドはこのインスタンスに対し,本来の Target クラスのメソッドを呼び出します.このとき本来のメソッド呼び出しに渡される引数は,EcecuteMessage メソッドに渡された call から作成されます.同様に,本来のメソッドが返した情報は ExecuteMessage メソッドの戻り値に格納されます.本来のメソッド内部で例外が起きた場合は例外情報が格納されたメッセージが返ります.この作業は手動でも行うことが出来ます.以下に"Calc"メソッドに割り込んだ場合に限り直接出力メッセージを作成するコードを示しておきましょう.上のコードとの違いに注意してください.リフレクションを駆使してこの過程を自動化すると,最終的には EcecuteMessage メソッドに行き着きます.
public override IMessage Invoke(IMessage msg) { IMethodCallMessage call = (IMethodCallMessage) msg; IMethodReturnMessage res; if( call.MethodName == "Calc" ) { int ret = target.Calc( (int)call.InArgs[0], (int)call.InArgs[1] ); res = new ReturnMessage( (object)ret, null, 0, call.LogicalCallContext, call ); } else { res = RemotingServices.ExecuteMessage( target, call ); } return res; }
ExecuteMessage に任せるにせよ自前でReturnMessageクラスのコンストラクタを呼ぶにせよ,ターゲットのメソッド呼び出しに柔軟な干渉が行えることは明らかです.実際にターゲットのメソッドを呼び出すかどうかも自由ですし,渡された引数をそのままターゲットに渡すかも,ターゲットから返された出力をそのまま呼び出し元に返すかも任意です.当然ターゲットのメソッドの前後に,ある関心事に基づくコード片を挿入することも可能でしょう.
このように .NET Framework の割り込みは,関数スタックフレームによるメソッド呼び出しをメッセージ交換という形で再構築した世界で行われます.これはオブジェクト指向でのメソッド呼び出しが本質的にオブジェクト間のメッセージ交換として解釈することが出来るという点からも興味深い実装です.それでいてこの変換部分を .NET Framework ランタイムに任せることが出来るため,非常に簡潔なコードで記述することが可能になっています.
プロファイラの実装に入る前にいくつか補足をしておきます.まず透過プロキシの作成には,実プロキシのGetTransparentProxyメソッドを利用します.サンプルコードではこの作業をファクトリメソッド CreateTarget にまとめています.しかしファクトリメソッドにより透過プロキシを生成するというアプローチには2つ欠点が存在します.まず Target クラスを生成する箇所全てをこのファクトリメソッドで置き換える必要があります.この作業は繁雑なだけでなく,特にターゲットクラスの継承に対して無力です.もう1つの問題は Target クラス内部で this 演算子を用いた場合,透過プロキシではなくターゲットのインスタンスそのものを指すということです.これは場合によっては透過プロキシからターゲットのインスタンスへの生の参照が漏洩する可能性を示しています.つまり this 参照を返しうるメソッドがあった場合,この問題が表面化します.このような欠点はコンテキストの概念を用いることで発展的に解消することが可能ですが,今回の Microsoft.DirectX.Direct3D.Device クラスにはこれらの問題が存在しないため,解説は専門書に譲ります.『Essential .NET』(文章の最後で紹介)の7.4章,7.5章などが参考になるでしょう.
もう一つの補足は,割り込みターゲットとなるクラスの制限に関してです.JITコンパイラによりターゲットクラスのメソッド呼び出しがインライン化されてしまうと,ソースコードの上では期待される割り込みが回避されてしまいます.この問題を避けるために,JITコンパイラがインライン化を抑制するトリガーとなる特別なクラスを使用します.このクラスはSystem.MarshalByRefObjectといい,このクラスから派生したクラスはJITコンパイラのインライン展開対象から外れます.1章で述べた Microsoft.DirectX.Direct3D.Device の満たしていた条件とはまさにこのことです.これをふまえて,最後に上で用いた Target クラスの完全な定義を示しておきます.これらを全て含んだサンプルプログラム[DeviceHookSample01.zip]も置いておきます.
public class Target : MarshalByRefObject { public int Calc( int j, int k ) { return j + k; } }
3. Implementation
いよいよ具体的な実装に入ります.まずは Device クラスの実プロキシを定義しましょう.先ほどのサンプルから変更はあまりありません.Device クラスのコンストラクタを置き換えやすいように同じシグネチャを持つファクトリメソッドを用意しておきましょう.さっそくこのプロキシクラスをウィザードの出力するコードに組み込んでみます.するとあなたは場合によっては問題が発生することに気付くかもしれません.これが具体的に筆者の環境(DirectX9.0b)で問題になったサンプルプログラム[DeviceHookSample02.zip]です.実プロキシは DeviceProxy.cs に DeviceProxy という名前で定義してあります.このコードをデバッガで調べてみると,実プロキシを用いた結果 VertexBuffer のコンストラクタ呼び出しが失敗するようになっていました.以下のように例外情報を出力してみると,どうやら VertexBuffer のコンストラクタが内部で Device.FieldGetter メソッドを呼び出し,このとき FieldGetter メソッドで"リモート処理で型 Microsoft.DirectX.Direct3D.Device のフィールド m_lpUM が見つかりませんでした。"という例外が発生していることが分かりました.
public override IMessage Invoke(IMessage msg) { // Call メッセージ IMethodCallMessage call = (IMethodCallMessage) msg; // Return メッセージ IMethodReturnMessage ret = RemotingServices.ExecuteMessage( target, call ); if( ret.Exception != null ) { string str = string.Format( "メソッド名 : {0}\nメッセージ : {1}", call.MethodName, ret.Exception.Message ); MessageBox.Show( str, "ExecuteMessage" ); } return ret; }
図2 失敗を示すダイアログ
プロキシクラスに置き換えることでアプリケーションが動かなくなるのでは困ります.なんとかこの問題を解決してみましょう.あまり知られていない FieldGetter というメソッドは,System.Object クラスの private メソッドです.これは今回のようなメッセージ指向のオブジェクトアクセスの際にフィールドアクセスに対して用いられるメソッドです.このことから VertexBuffer コンストラクタが内部で Device.m_lpUM というフィールドにアクセスしようとしていることが分かります.プロキシオブジェクトを使用しないときはこのコードが問題なく動いていることから,恐らく Device クラスは m_lpUM というフィールドを持っているのでしょう.しかし何らかの理由で ExecuteMessage メソッドがこのフィールド読みだし要求の扱いに失敗していると考えられます.よって ExecuteMessage よりも巧くリターンメッセージを手動構成できる可能性に賭けてみる価値はありそうです.
まずリターンメッセージを正しく構成できるよう,FieldGetter メソッドの値がどのようにオブジェクトを受け取るかから始めましょう.FieldGetter メソッドのシグネチャは以下のようになっています.
void FieldGetter(string klassName, string fieldName, ref object val);
FieldGetter メソッドは戻り値を持たないので,情報を受け取るためには3つ目の引数を用いるしかなさそうです.これに対応する ReturnMessage のコンストラクタ呼び出しは次のようになるでしょう.
// コールメッセージ IMethodCallMessage call; // 要求されたフィールドへの参照 object retval; // リターンメッセージ IMethodReturnMessage ret; // FieldGetter 用の ReturnMessage ret = new ReturnMessage( typeof(void), new object[]{ null, null, retval }, 3, call.LogicalCallContext, call );
FieldGetter メソッドの戻り値は void 型なので,ReturnMessage クラスのコンストラクタの第1引数には System.Void 構造体を渡す必要があります.しかし C#C# では Void 構造体は直接使用できないため,typeof(void) という記法を用いています.これでリターンメッセージのめどは立ちました.さて次に m_lpUM フィールドへの参照を得る方法を考えましょう.
.NET Framework の世界では,例え public フィールドでなくても,適切な権限さえあればリフレクションを用いて内部フィールドへの参照を得ることが可能です.そしてヘルプに載っていないことから,m_lpUM フィールドは public なフィールドではないと考えられます.ここでは ildasm.exe を用いて直接クラス情報を参照するという方法も考えられますが,遅かれ早かれ値の取得のためにリフレクションを用いたプログラムを書くことになるのは確かです.よって今回は最初からリフレクションを活用した情報の収集を試みます.以下のように FieldInfo を得てクイックウォッチで表示してみました.using System.Reflection; Type type = typeof(Device); FieldInfo info = type.GetField( "m_lpUM", BindingFlags.NonPublic | BindingFlags.Instance);
名前 | 値 | 型 |
[System.Reflection.RuntimeFieldInfo] | {System.Reflection.RuntimeFieldInfo} | System.Reflection.RuntimeFieldInfo |
System.Reflection.MemberInfo | {System.Reflection.RuntimeFieldInfo} | System.Reflection.MemberInfo |
Attributes | Assembly | System.Reflection.FieldAttributes |
FieldHandle | {System.RuntimeFieldHandle} | System.RuntimeFieldHandle |
FieldType | {"IDirect3DDevice9*"} | System.Type |
IsAssembly | true | bool |
IsFamily | false | bool |
IsFamilyAndAssembly | false | bool |
IsFamilyOrAssembly | false | bool |
IsInitOnly | false | bool |
IsLiteral | false | bool |
IsNotSerialized | false | bool |
IsPinvokeImpl | false | bool |
IsPrivate | false | bool |
IsPublic | false | bool |
IsSpecialName | false | bool |
IsStatic | false | bool |
MemberType | Field | System.Reflection.MemberTypes |
クイックウォッチに表示された m_lpUM の FieldInfo の中身
Assembly 属性が付いていることから,C# で言うところの internal フィールドであることが分かりました.また,このフィールドの型は IDirect3DDevice9* 型であることも分かります.この IDirect3DDevice9* という型ですが, Microsoft.DirectX.Direct3D クラス内で private として定義されたポインタ型のようです.さて実際に FieldInfo.GetValueで m_lpUM の中身を取得すると, 返されたのはSystem.Reflection.Pointer クラスのオブジェクトでした.この Pointer クラスは,ポインタ型をマネージドクラスでラップしたものです.試しにこの Pointer 型を FieldGetter の戻り値としてみたところ,今度は VertexBuffer コンストラクタ内部で System.ExecutionEngineException が発生しました.どうやら Pointer 型のままでは VertexBuffer の内部実装に適合しないようです.そこで試しに Pointer 型に格納されている生のポインタをアンボクシングし,IntPtr 型の形で返してみることにしました.この過程ではポインタ型を直接使用する必要があるので,unsafe コードブロック及び /unsafe コンパイルスイッチが必要になります.結果,このコードはうまく動いてくれました.結局筆者の環境で必要だった修正は,この FieldGetter が m_lpUM を要求したときに働くクイックハックだけでした.最終的に Microsoft.DirectX.Direct3D.Device クラスに対する実プロキシは,次のような専用の ExecuteMessage を持てばよいことになります.ここまでをまとめたのがこの「割り込むが何もしない」サンプルプログラム[DeviceHookSample03.zip]です.
public unsafe IMethodReturnMessage ExecuteMessage( IMethodCallMessage call ) { if( call.MethodName == "FieldGetter" && call.InArgCount > 2 && ((string)call.InArgs[1]) == "m_lpUM" ) { Type type = typeof(Device); FieldInfo info = type.GetField( "m_lpUM", BindingFlags.NonPublic | BindingFlags.Instance); object retval = info.GetValue( target ); unsafe { void* rawptr = System.Reflection.Pointer.Unbox( retval ); IntPtr ptr = new IntPtr( rawptr ); return new ReturnMessage( typeof(void), new object[]{null,null,ptr}, 3, call.LogicalCallContext, call ); } } else { return RemotingServices.ExecuteMessage( target, call ); } }
さていよいよプロファイリングを行うコードの作成に入ります.割り込みハンドラ内での具体的な作業は,メソッド実行前後で時間を計ることと,描画メソッドであれば描画するプリミティブの数を記録することです.これは例えば以下のように記述できるでしょう.(完全なコードは後で示すサンプルプログラムを参照してください)
public override IMessage Invoke(IMessage msg) { // Call メッセージ IMethodCallMessage call = (IMethodCallMessage) msg; switch( call.MethodName ) { case "DrawPrimitives": { // TODO: ここで時刻を測定 IMethodReturnMessage ret = ExecuteMessage( call ); // TODO: ここで時刻を測定 // PrimitiveCount の取得 int primCount = (int) call.InArgs[2]; return ret; } case "Present": // TODO: ここで時刻を測定 IMethodReturnMessage ret = ExecuteMessage( call ); // TODO: ここで時刻を測定 return ret; default: return ExecuteMessage( call ); } }
後に示す最終的なサンプルプログラムでは DrawPrimitives,DrawUserPrimitives,DrawIndexedPrimitives,DrawIndexedUserPrimitives の4つのメソッドを監視しています.また,DirectX Graphics の描画命令は非同期実行されるため,非常に短時間で処理が戻ってきます.そこで正確な経過時間の測定のために QueryPerformanceCounter を用いています.極力パフォーマンスに影響を与えないよう,割り込みハンドラ内ではイベントデータをメモリに格納するだけにしています.この溜め込んだデータは,Device.Dispose メソッドが呼ばれたタイミングでテキストファイルに出力するようにしました.形式は単純な CSV です.今回はあくまで .NET Framework の割り込み機能の紹介が目的なので,このあたりは十二分に手を抜いています.必要に応じて UI を整備したり Excel でデータ処理を行ったりするとよいでしょう.こちらが以上をまとめた最終的なサンプルプログラム[DeviceHookSample04.zip]です.実行後,実行バイナリのあるディレクトリにイベントログが出力されます.これもほとんどの実装は DeviceProxy.cs で行われています.ウィザードの出力したプログラムに対し,実プロキシを導入するために必要だったソースコードの変更が d3dapp.cs 内の GraphicsSample.InitializeEnvironment メソッド内のたった一カ所であったことを特に強調しておきます.
// 変更前 device = new Device(graphicsSettings.AdapterOrdinal, graphicsSettings.DevType, windowed ? ourRenderTarget : this , createFlags, presentParams); // 変更後 device = DeviceHook.DeviceProxy.CreateDevice( graphicsSettings.AdapterOrdinal, graphicsSettings.DevType, windowed ? ourRenderTarget : this , createFlags, presentParams);
4. Conclusion
.NET Framework の持つ割り込み機能を用いて,既存のコードに対する最小限の修正で Microsoft.DirectX.Direct3D.Device クラスのメソッドの処理への割り込みが可能なことを見ました.この際,RemotingServices.ExecuteMessage メソッドだけでは問題が発生することがあることを示し,unsafe コードを用いてこの問題が回避することに成功しました.この実装により,Managed DirectX で 3D 描画を司る Device クラスのメソッド呼び出しに,自由に割り込みをかけることが可能になりました.この技術は,DirectX 用のプロファイラ,初心者向けのパラメータチェッカー,自動エラーレポートなど様々なユーティリティー開発に応用することが出来るでしょう.それらのユーティリティーは,オリジナルのソースコードをほとんど変更することなく容易に組み込むことが出来るというメリットを持つでしょう.Device Hook の技術が,ゲームプログラミングの世界に AOP 的手法を持ち込む1つの契機になることを願います.
5. Bibliography
参考 Web 資料
- コードのカプセル化と再利用を推進するアスペクト指向プログラミング[ascii.co.jp]
→ AOP の紹介と COM,.NET それぞれでの実装について紹介されています. - Contexts in .NET - Decouple Components by Injecting Custom Services into
Your Object's Interception -[microsoft.com]
→ Context に関しての詳しい解説があります.日本語版の MSDN Magazine No.36 p.36 に邦訳があります.DVD通販[ascii-store.com] - アスペクト指向プログラミングで、モジュール性を改善する[ibm.co.jp]
→Java での実装 AspectJ の紹介です. - .NET Hook Library[sourceforge.net]
→ バイナリレベルでフックする話です.あまり関連はありません.
参考文献
- Don Box,Chris Sells著 吉松 史彰監訳 『Essential .NET 共通言語ランタイムの本質』ISBN 4-89100-368-5
→本稿のアーキテクチャはほとんどこの本の受け売りです.割り込みを大きく取り上げています.非常に参考になります.Amazonの紹介ページ