LoginSignup
5
4

More than 3 years have passed since last update.

HaskellでもDirectX12したい!

Last updated at Posted at 2020-09-22

リポジトリはこちら: https://github.com/Pctg-x8/haskell-d3d12-test

HaskellのFFI

今回はDirectX12に主軸をおいた記事ではないため、DirectX12の初期化の方法などは解説しません。他の記事をご参照ください。

Haskellも実用的なプログラミング言語の例に漏れず、C言語など他の言語とのインターフェイス(FFI)の仕組みを持っています。
Haskellのそれはかなり「低レベル」かつ「なんでもあり」な感じになっています。
HaskellのFFIの詳細については他の記事を参照してもらうとして、大雑把には以下のような形でインターフェイスが提供されています。

基本データ型

Foreign.C.TypesCIntCFloat などといった形で基本的な型の定義が存在するので、それを使用します。

アーキテクチャのポインタサイズと一致する整数型 (intptr_t とか)は以下のものが用意されています。これらはどちらも Foreign.Ptr に定義されています。

  • IntPtr: 符号付き
  • WordPtr: 符号なし

ポインタ型

ポインタ型は2つの型が用意されています。いずれも Foreign.Ptr に定義されています。

  • Ptr a: いわゆる普通のポインタ
  • FunPtr a: 関数ポインタ専用

構造体

構造体については手動でマーシャリング(メモリ上での配置情報と、メモリへの書き込みと読み取り)のコードを書いてあげる必要があります。hsc2hsとかを使えばある程度自動化したりできるみたいですが、今回は使っていません。
マーシャリングは Foreign.Storable にある Storable クラスのインスタンスをもって定義します。

Foreign.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.Allocalloca :: Storable a => (Ptr a -> IO b) -> IO b を使用します。
メモリの確保と同時に任意のデータをマーシャリングしてほしい場合は、 Foreign.Marshal.Utilswith :: 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 の定義の一部を出します。

Windows.Direct3D12.CommandQueue
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インターフェイスの構造は、大まかに以下のような感じになっています。

COMStructPtr.png

オブジェクトの先頭に仮想関数テーブルへのポインタが格納されているため、それをオフセットつきでデリファレンスすることで該当メソッドにアクセスすることができます。仮想関数テーブルは、感覚としては関数ポインタの配列のようなものだと思っておけば大丈夫です。
上記例のなかで仮想関数テーブルにアクセスしているのは、 getFunctionPtr _VTBL_INDEX_SIGNAL this の箇所になります。 getFunctionPtr は出すまでもない気がしますが、仮想関数テーブルの先頭へのポインタを得て、さらにオフセット付きで peek している形になっています。これらの関数は大体どのインターフェイスでも同じコードになるので、 ComInterface のインスタンスをよしなに定義することで自動的に定義されるようにしています。

Windows.ComBaseの一部
  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_SIGNAL14 であると定義されています。この 14 はどこからきたのかというと、C言語側の struct ID3D12CommandQueueVtbl における Signal の定義されている位置になっています。 QueryInterface0 として、目的の関数が構造体定義の何番目に出てくるかをひたすら目視で探します。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4