👻

D3D9依存のゲームをそのままブラウザに移植するOSSを作った

に公開

Direct3D 9に依存したWindowsのC++プロジェクト、ブラウザに持っていきたいと思ったことはありませんか?

2000年代のゲームエンジン、社内ツール、昔書いたビジュアライザ。動かすにはWindows環境が必要で、今となっては誰かに見せるのも一苦労。

そのコード資産を、できることなら書き直さずにブラウザで動かしたい——そういう需要はニッチだけど確実に存在すると思っています。(ほとんどの人はないと思うけど笑)

このたび、そのための変換レイヤーをOSSとして公開しました。

d3d9-webgl — Direct3D 9 Fixed Function Pipeline を WebGL 2.0 として実装したEmscripten向けラッパーです。

https://github.com/LostMyCode/d3d9-webgl

何ができるのか

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の移植詳細はまた別の記事で書こうと思いますが、「本当にコード変更ゼロ ほぼせずに動いた」という証拠として先に挙げておきます。

https://gunz.sigr.io/

なぜこのアプローチなのか

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」——これが今回取ったアプローチです。IDirect3D9IDirect3DDevice9IDirect3DTexture9といったインターフェースをすべて自前で実装し、メソッド呼び出しをそのままWebGL 2.0のAPIに変換します。既存コードからは本物のD3D9と区別がつかないので、コンパイルも実行もそのまま通る、という仕組みです。

実装で工夫した点

Fixed Function PipelineをGLSLで手書きした

Direct3D 9のFixed Function Pipeline(FFP)はWebGL 2.0には存在しません。SetLightSetMaterialSetTransform によって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 cmakeemmake make でビルドすれば、既存のD3D9コードがブラウザで動きます。

制限

正直に書きます。

FFPのみ対応です。 HLSLのバーテックスシェーダー・ピクセルシェーダーを使っているプロジェクトはそのままでは動きません。ラッパーは VertexShaderVersion = 0 を返すので、シェーダーを持つアプリケーションはFFPのコードパスにフォールバックする必要があります。

その他の制限:

  • ポイントライト最大3つ(ディレクショナル・スポットライト未対応)
  • 頂点バッファはストリーム0のみ
  • レンダーターゲットへの LockRect(GPUリードバック)は未対応
  • D3DXMatrixInverse はスタブ(恒等行列を返す)

2000年代前半のゲームやツールのほとんどはFFPを使っています。シェーダーが普及し始めたのはDX9世代の後期なので、その時期以前のプロジェクトならほぼカバーできるはずです。

同じ悩みを持つ人に届いてほしい

自分がGunZ移植でD3D9ラッパーを必死に書いていたとき、同じものが既に存在していれば——と何度も思いました。このOSSが「昔のD3D9プロジェクトをブラウザで動かしたい」という人の助けになれば嬉しいです。

フィードバック・PRもお待ちしています!

https://github.com/LostMyCode/d3d9-webgl

Discussion