D3D9依存のゲームをそのままブラウザに移植するOSSを作った
Direct3D 9に依存したWindowsのC++プロジェクト、ブラウザに持っていきたいと思ったことはありませんか?
2000年代のゲームエンジン、社内ツール、昔書いたビジュアライザ。動かすにはWindows環境が必要で、今となっては誰かに見せるのも一苦労。
そのコード資産を、できることなら書き直さずにブラウザで動かしたい——そういう需要はニッチだけど確実に存在すると思っています。(ほとんどの人はないと思うけど笑)
このたび、そのための変換レイヤーをOSSとして公開しました。
d3d9-webgl — Direct3D 9 Fixed Function Pipeline を WebGL 2.0 として実装したEmscripten向けラッパーです。

何ができるのか

D3D9のAPIをそのままWebGL 2.0で実装したヘッダーと .cpp ファイルのセットです。Emscriptenビルドにこのラッパーを差し込むだけで、既存のD3D9コードはそのままコンパイルが通り、ブラウザ上で動作します。
// このD3D9コードが、書き換えなしにブラウザで動く
IDirect3D9* d3d = Direct3DCreate9(D3D_SDK_VERSION);
D3DPRESENT_PARAMETERS pp = {};
pp.BackBufferWidth = 1024;
pp.BackBufferHeight = 768;
IDirect3DDevice9* device;
d3d->CreateDevice(0, D3DDEVTYPE_HAL, nullptr,
D3DCREATE_HARDWARE_VERTEXPROCESSING,
&pp, &device);
device->SetTransform(D3DTS_WORLD, &matWorld);
device->SetTransform(D3DTS_VIEW, &matView);
device->SetTransform(D3DTS_PROJECTION, &matProj);
device->SetTexture(0, texture);
device->DrawIndexedPrimitive(D3DPT_TRIANGLELIST, 0, 0, numVerts, 0, numTris);
実用例 (というかこのために作ってついでに OSS 化した)
実戦での動作検証として、2003年のオンラインアクションゲーム「GunZ: The Duel」のレンダリングコードを一行も変えず ほぼ変えずにブラウザ移植できています。gunz.sigr.io で実際にプレイできます。GunZの移植詳細はまた別の記事で書こうと思いますが、「本当にコード変更ゼロ ほぼせずに動いた」という証拠として先に挙げておきます。

なぜこのアプローチなのか
Emscriptenを使えばC++コードはそのままWebAssemblyに変換できます。ではなぜD3D9プロジェクトのWasm移植が難しいかというと、d3d9.h を include した時点でビルドが止まるからです。D3D9はWindowsのDirectX SDKにしか存在せず、Linux/WebにはAPIが存在しません。
移植の選択肢を整理するとこうなります。
| アプローチ | 内容 |
|---|---|
| レンダリングコードをWebGL / Three.js で書き直す | 工数が莫大。大型プロジェクトでは現実的でない |
| D3D9をOpenGLに書き直してEmscriptenのGL→WebGL変換を使う | 書き直しは避けられない |
| D3D9 APIを同じシグネチャでWebGLとして実装する | 既存コードはそのまま |
「インターフェースはD3D9、中身はWebGL」——これが今回取ったアプローチです。IDirect3D9、IDirect3DDevice9、IDirect3DTexture9といったインターフェースをすべて自前で実装し、メソッド呼び出しをそのままWebGL 2.0のAPIに変換します。既存コードからは本物のD3D9と区別がつかないので、コンパイルも実行もそのまま通る、という仕組みです。
実装で工夫した点
Fixed Function PipelineをGLSLで手書きした
Direct3D 9のFixed Function Pipeline(FFP)はWebGL 2.0には存在しません。SetLight、SetMaterial、SetTransform によってCPU側で組み立てるライティングモデルを、GPU上のGLSLシェーダーとして再現する必要があります。
頂点シェーダーではWorld/View/Projection変換・法線変換・ポイントライトによるディフューズ+スペキュラ計算を実装し、フラグメントシェーダーではテクスチャブレンドと最終的な色合成を行っています。DirectXの仕様書を参照しながら SetLight で渡されたパラメータをuniformとしてシェーダーに渡す形です。
FVF(Flexible Vertex Format)を動的に解析する
D3D9の頂点バッファはFVFというビットフラグで頂点レイアウトを表現します。
// 位置 + 法線 + 頂点カラー + UV1セット
DWORD fvf = D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_DIFFUSE | D3DFVF_TEX1;
このフラグを実行時に解析して glVertexAttribPointer のストライドとオフセットを動的に組み立てています。固定のシェーダーに対してFVFに応じた属性バインディングを切り替えることで、様々な頂点フォーマットを透過的に扱えます。
テクスチャフォーマットの変換
D3D9ではBGRAのバイトオーダーが標準ですが、WebGLはRGBAを期待します。A8R8G8BHのようなフォーマットはアップロード時にスウィズルで対応し、R5G6B5やA4R4G4B4のような16bitフォーマットはRGBA8に展開してからWebGLに渡しています。
DXT1/DXT3/DXT5の圧縮テクスチャはS3TC拡張(WEBGL_compressed_texture_s3tc)がほぼすべてのデスクトップブラウザで使用可能なため、変換なしにそのまま渡せます。
Y軸反転の場合分け
D3D9は左上を原点としてY軸が下向き、OpenGLは左下を原点としてY軸が上向きです。この差は最終的なスクリーン描画では問題になりませんが、Render-to-Texture(FBO)を使う場合に影響します。
SetRenderTarget でFBOに描画する際はY方向が反転するため、StretchRect でスクリーンにブリットするときに補正が必要です。一方で直接スクリーンに描くパスには補正が不要なので、ターゲットに応じて場合分けしています。地味に見落としやすく、最初は一部UIが上下反転するバグになっていました。
クリップ平面を discard でエミュレート
WebGLにはD3D9の SetClipPlane / D3DRS_CLIPPLANEENABLE に相当するハードウェアクリップ平面がありません。gl_ClipDistance はWebGL 2.0では使用できないため、フラグメントシェーダー内でクリップ平面との距離を計算し、負なら discard するアプローチで対応しています。
ステートキャッシュで WebGL API コールを削減
D3D9アプリケーションは毎フレーム大量の SetRenderState / SetTexture / SetSamplerState を呼び出します。WebGLへの変換をそのままスルーすると不要なGL呼び出しが増えてパフォーマンスが落ちるため、テクスチャバインディング・シェーダープログラム・サンプラーステート・ビューポート・シザーはすべてキャッシュして差分があるときだけGLを呼ぶようにしています。
使い方
5つのファイルをプロジェクトにコピーするだけです。
d3d9.h — D3D9 型定義・インターフェース
d3d9.cpp — WebGL 2.0 実装本体(約3,400行)
d3dx9math.h — D3DX 数学ライブラリ(ベクトル・行列・クォータニオン)
d3dx9.h — D3DX スタブ(ID3DXLine、ID3DXFont)
windows_compat.h — Windows API スタブ
CMakeの場合はこれだけです。
add_executable(my_app main.cpp d3d9.cpp)
target_link_options(my_app PRIVATE
-sUSE_WEBGL2=1
-sFULL_ES3=1
-sWASM=1
-sALLOW_MEMORY_GROWTH=1
)
あとは emcmake cmake → emmake make でビルドすれば、既存のD3D9コードがブラウザで動きます。
制限
正直に書きます。
FFPのみ対応です。 HLSLのバーテックスシェーダー・ピクセルシェーダーを使っているプロジェクトはそのままでは動きません。ラッパーは VertexShaderVersion = 0 を返すので、シェーダーを持つアプリケーションはFFPのコードパスにフォールバックする必要があります。
その他の制限:
- ポイントライト最大3つ(ディレクショナル・スポットライト未対応)
- 頂点バッファはストリーム0のみ
- レンダーターゲットへの
LockRect(GPUリードバック)は未対応 -
D3DXMatrixInverseはスタブ(恒等行列を返す)
2000年代前半のゲームやツールのほとんどはFFPを使っています。シェーダーが普及し始めたのはDX9世代の後期なので、その時期以前のプロジェクトならほぼカバーできるはずです。
同じ悩みを持つ人に届いてほしい
自分がGunZ移植でD3D9ラッパーを必死に書いていたとき、同じものが既に存在していれば——と何度も思いました。このOSSが「昔のD3D9プロジェクトをブラウザで動かしたい」という人の助けになれば嬉しいです。
フィードバック・PRもお待ちしています!
Discussion