描けた! pic.twitter.com/3h0Jtmlw7U
— 🌌S.Percentage🙀 (@Pctg_x8) September 15, 2020
リポジトリはこちら: https://github.com/Pctg-x8/haskell-d3d12-test
HaskellのFFI
今回はDirectX12に主軸をおいた記事ではないため、DirectX12の初期化の方法などは解説しません。他の記事をご参照ください。
Haskellも実用的なプログラミング言語の例に漏れず、C言語など他の言語とのインターフェイス(FFI)の仕組みを持っています。
Haskellのそれはかなり「低レベル」かつ「なんでもあり」な感じになっています。
HaskellのFFIの詳細については他の記事を参照してもらうとして、大雑把には以下のような形でインターフェイスが提供されています。
基本データ型
Foreign.C.Types
に CInt
や CFloat
などといった形で基本的な型の定義が存在するので、それを使用します。
アーキテクチャのポインタサイズと一致する整数型 (intptr_t
とか)は以下のものが用意されています。これらはどちらも Foreign.Ptr
に定義されています。
-
IntPtr
: 符号付き -
WordPtr
: 符号なし
ポインタ型
ポインタ型は2つの型が用意されています。いずれも Foreign.Ptr
に定義されています。
-
Ptr a
: いわゆる普通のポインタ -
FunPtr a
: 関数ポインタ専用
構造体
構造体については手動でマーシャリング(メモリ上での配置情報と、メモリへの書き込みと読み取り)のコードを書いてあげる必要があります。hsc2hsとかを使えばある程度自動化したりできるみたいですが、今回は使っていません。
マーシャリングは Foreign.Storable
にある Storable
クラスのインスタンスをもって定義します。
class Storable a where
sizeOf :: a -> Int
alignment :: a -> Int
peek :: Ptr a -> IO a
poke :: Ptr a -> a -> IO ()
型からも分かる通り、 peek
が読み取り操作で poke
が書き込み操作です。これらはバリバリ副作用を含むためIOとなっています。
sizeOf
が構造体のサイズ、 alignment
が必要なアラインメント指定です。 sizeOf
alignment
は対象データを引数として取る形をしていますが、大抵は使われないため sizeOf _ = 8
みたいにして書きます。使う際も sizeOf (undefined :: a)
で、大抵の場合は a
のサイズを取得できます。
ちなみに、今回見事にハマったのですが、手書きする場合 sizeOf
はアラインメントを適用したサイズを返す必要があります。
配列
配列を取り扱う方法についてはいろいろありますが、大抵は「[a]
を毎回マーシャリングする」か「StorableArray
を使う」かになります。 [a]
はC言語の配列と内部表現が全然違いますので、そのまま使用することはできません。
StorableArray
は内部表現がそのままC言語の配列のものになるため繰り返し使う際のパフォーマンスに優れますが、呼び出し側での準備が面倒だったりGCをうまく騙したりする必要があります。
参照を渡す
COMインターフェイスの生成関数では、返り値が HRESULT
(エラー値)になっているため、実体を受け取るためにメモリを確保してそのポインタを渡してあげる必要があります。
また、大きな構造体はそのまま関数の引数として渡すことはできないため、ここでも特定のデータへの参照を作り出してそれを渡してあげる必要があります。
未初期化のメモリ領域を確保する場合は、Foreign.Marshal.Alloc
の alloca :: Storable a => (Ptr a -> IO b) -> IO b
を使用します。
メモリの確保と同時に任意のデータをマーシャリングしてほしい場合は、 Foreign.Marshal.Utils
の with :: Storable a => a -> (Ptr a -> IO b) -> IO b
を使用します。
CreateHeap
などの関数から、インターフェイスを得る部分に渡すものの場合は前者、 D3D12_HEAP_DESC
などを渡す場合は後者を使用します。
関数の型からもわかる通り、これらの関数はbracketパターンに即していますのでアクション実行の終了をもってメモリが開放されます。
関数
普通の関数をimportする
foreign import
を使用して、例えば RegisterClassExA
関数を持ってくる場合は以下のような形になります。
foreign import ccall "RegisterClassExA" registerClassExA :: Ptr WNDCLASSEXA -> IO ATOM
registerClassExA
としているところはHaskell側から見える識別子なので、自由に指定できます。
stdcall
じゃなくて ccall
なの?と思われる方もいるかもしれませんが、x86_64の環境ではむしろ stdcall
が指定できません。詳しい理由はちょっと調べてませんが多分これであってます。
関数ポインタを使う
ウィンドウコールバックの指定で関数ポインタを渡す必要があります。その場合は、wrapperと名付けられた特殊な関数を foreign import
で持ってきます。
foreign import ccall "wrapper" makeWndProc :: (HWND -> CUInt -> WPARAM -> LPARAM -> IO LRESULT) -> IO WNDPROC
WNDPROC
で具体的な返り値が隠れていますが、これは FunPtr (HWND -> CUInt -> WPARAM -> LPARAM -> IO LRESULT)
としていますので、具体的な返り値は IO (FunPtr (HWND -> CUInt -> WPARAM -> LPARAM -> IO HRESULT))
となります。
関数シグネチャの部分を適当な型変数 a
とすると、wrapperのimportは以下のような形になります。
foreign import ccall "wrapper" identifier :: a -> IO (FunPtr a)
任意の関数を受け取って、それを関数ポインタに変換しているだけ、というのが読み取れると思います。
関数ポインタを呼ぶ
COMでは仮想関数テーブルから関数ポインタを得てそれを呼ぶ、といった場面が多く出てきます。関数ポインタはHaskell的には関数ではないため、なんとかして関数ポインタから呼べる関数を作り出す必要があります。
これは先述のwrapperと似たようなものをimportすることで実現できます。
foreign import ccall "dynamic" dcallFunction :: FunPtr a -> a
関数ポインタ化がIOアクションなのに対して、こちらはIOではなく純粋関数となっています。
HaskellでCOMインターフェイスをFFIして持ってくる
以上でほとんどの機能はFFIで持ってくることができます。
HaskellでCOMの機能を持ってくる場合は、C言語でのCOMインターフェイスを通して持ってきます。C++では特殊なクラスとして定義されていますが、C言語ならCOMインターフェイスはただの構造体になるため、ひたすら Storable
インスタンスを定義することで持ってくることができます。
一つ例として、 ID3D12CommandQueue
の定義の一部を出します。
data ID3D12CommandQueueVtbl
data ID3D12CommandQueue = ID3D12CommandQueue (Ptr ID3D12CommandQueueVtbl)
instance ComInterface ID3D12CommandQueue where
type VTable ID3D12CommandQueue = ID3D12CommandQueueVtbl
guid _ = GUID 0x0ec870a6 0x5d7e 0x4c22 0x8cfc5baae07616ed
instance Storable ID3D12CommandQueue where
sizeOf _ = 8
alignment _ = 8
peek p = ID3D12CommandQueue <$> peek (castPtr p)
poke p (ID3D12CommandQueue vp) = castPtr p `poke` vp
_VTBL_INDEX_SIGNAL = 14
type PFN_Signal = Ptr ID3D12CommandQueue -> Ptr ID3D12Fence -> Word64 -> IO HRESULT
foreign import ccall "dynamic" dcall_signal :: FunPtr PFN_Signal -> PFN_Signal
signal :: Ptr ID3D12CommandQueue -> Ptr ID3D12Fence -> Word64 -> ComT IO ()
signal this fence value = liftIO (getFunctionPtr _VTBL_INDEX_SIGNAL this >>= \f -> dcall_signal f this fence value) >>= handleHRESULT
ComT
とか handleHRESULT
とかいろいろ見慣れない物がありますが、COMのエラーハンドリング用のMonad Transformer(実態は ComT m a = ExceptT ComError m a
)と、 HRESULT
から ComT
を作り出すヘルパー関数です。
COMインターフェイスの構造は、大まかに以下のような感じになっています。
オブジェクトの先頭に仮想関数テーブルへのポインタが格納されているため、それをオフセットつきでデリファレンスすることで該当メソッドにアクセスすることができます。仮想関数テーブルは、感覚としては関数ポインタの配列のようなものだと思っておけば大丈夫です。
上記例のなかで仮想関数テーブルにアクセスしているのは、 getFunctionPtr _VTBL_INDEX_SIGNAL this
の箇所になります。 getFunctionPtr
は出すまでもない気がしますが、仮想関数テーブルの先頭へのポインタを得て、さらにオフセット付きで peek
している形になっています。これらの関数は大体どのインターフェイスでも同じコードになるので、 ComInterface
のインスタンスをよしなに定義することで自動的に定義されるようにしています。
vtable :: Ptr a -> IO (Ptr (VTable a))
getFunctionPtr :: Int -> Ptr a -> IO (FunPtr f)
vtable = peek . castPtr
getFunctionPtr index this = vtable this >>= peek . flip plusPtr (index * 8)
* 8
しているのは、対象アーキテクチャが64bitだからです。ちゃんとやるなら sizeOf (undefined :: FunPtr a)
とかしてあげた方が良さそう
_VTBL_INDEX_SIGNAL
は 14
であると定義されています。この 14
はどこからきたのかというと、C言語側の struct ID3D12CommandQueueVtbl
における Signal
の定義されている位置になっています。 QueryInterface
を 0
として、目的の関数が構造体定義の何番目に出てくるかをひたすら目視で探します。