DEV Community

Cover image for d3d9-webgl — Run Legacy D3D9 Code in the Browser Without Rewriting It
not a pro
not a pro

Posted on

d3d9-webgl — Run Legacy D3D9 Code in the Browser Without Rewriting It

I ported a 2003 online game to the browser. The game was written in C++ with Direct3D 9. Emscripten handles the C++-to-Wasm part fine, but the moment you #include <d3d9.h>, the build dies — that header only ships with the Windows DirectX SDK.

The obvious options all sucked:

Approach Problem
Rewrite the renderer in WebGL / Three.js You're basically rewriting the game
Convert D3D9 calls to OpenGL one by one Still a massive rewrite
Re-implement the D3D9 API itself, backed by WebGL The existing code doesn't need to change

So I went with option 3 and built d3d9-webgl: a header + source file set that implements D3D9 interfaces on top of WebGL 2.0. You drop it into an Emscripten project, and your D3D9 code compiles and runs in the browser as-is.

Does it actually work?

The game I was porting is GunZ: The Duel (2003). The rendering code required almost no changes. You can play it at gunz.sigr.io.

How it works

IDirect3D9, IDirect3DDevice9, IDirect3DTexture9 — all the COM interfaces are re-implemented. When your app calls IDirect3DDevice9::DrawPrimitive(), the wrapper translates it to glDrawArrays() internally. The application can't tell the difference.

The hard parts

Rebuilding the Fixed Function Pipeline in GLSL

D3D9's FFP — SetLight, SetMaterial, SetTransform — doesn't exist in WebGL 2.0. I had to write GLSL shaders that replicate the entire lighting model: World/View/Projection transforms, normal transforms, per-vertex diffuse + specular for up to 3 point lights in the vertex shader, and texture blending + color composition in the fragment shader.

This was the most time-consuming part of the whole project.

Parsing FVF at runtime

D3D9 vertex buffers use Flexible Vertex Format (FVF) — bit flags that describe the vertex layout. The wrapper parses these at runtime to set up glVertexAttribPointer with the right stride and offset.

// Position + Normal + Vertex Color + 1 UV set
DWORD fvf = D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_DIFFUSE | D3DFVF_TEX1;
Enter fullscreen mode Exit fullscreen mode

Texture format mismatches

D3D9 uses BGRA; WebGL wants RGBA. A8R8G8B8 gets swizzled on upload. 16-bit formats like R5G6B5 and A4R4G4B4 are expanded to RGBA8.

DXT1/DXT3/DXT5 compressed textures are passed straight through — the WEBGL_compressed_texture_s3tc extension covers this on pretty much every desktop browser.

Y-axis flip

D3D9: top-left origin, Y goes down. OpenGL: bottom-left origin, Y goes up. When rendering to an FBO via SetRenderTarget, the image ends up flipped. The wrapper compensates during StretchRect blit to screen. Direct screen rendering doesn't need the fix.

Clip planes via discard

WebGL has no hardware clip planes. The fragment shader calculates clip-plane distance and discards fragments on the wrong side. Simple, but one of those things you don't think about until everything clips wrong.

State caching

D3D9 apps call SetRenderState / SetTexture / SetSamplerState hundreds of times per frame, and most of those calls set the same value that's already set. The wrapper caches everything — texture bindings, shader programs, sampler states, viewport, scissor — and only issues a GL call when something actually changes.

Usage

Copy five files into your project:

  • d3d9.h — D3D9 type definitions & interfaces
  • d3d9.cpp — WebGL 2.0 implementation (~3,400 lines)
  • d3dx9math.h — D3DX math library
  • d3dx9.h — D3DX stubs
  • windows_compat.h — Windows API stubs

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
)
Enter fullscreen mode Exit fullscreen mode
emcmake cmake .
emmake make
Enter fullscreen mode Exit fullscreen mode

That's it.

Limitations

FFP only. HLSL vertex/pixel shaders are not supported. The wrapper reports VertexShaderVersion = 0, so applications with shader code paths need to fall back to FFP.

Other limitations:

  • Max 3 point lights (no directional or spotlights)
  • Vertex buffer stream 0 only
  • No LockRect on render targets (no GPU readback)
  • D3DXMatrixInverse is stubbed (returns identity)

In practice, most games and tools from the early 2000s used FFP anyway. Programmable shaders only became common in the later DX9 era, so anything before ~2005 is likely covered.

Why I'm releasing this

While building the GunZ port, I kept searching for something like this. It didn't exist. I spent a lot of time writing this wrapper, and I figured someone else out there is probably stuck on the same problem right now.

If you have an old D3D9 codebase and you've been curious about running it in a browser — try it out and let me know how it goes.

GitHub: d3d9-webgl

GitHub logo LostMyCode / d3d9-webgl

Direct3D 9 Fixed-Function Pipeline → WebGL 2.0 wrapper for Emscripten/WASM

d3d9-webgl

A Direct3D 9 Fixed-Function Pipeline implementation targeting WebGL 2.0 via Emscripten/WebAssembly.

Drop-in D3D9 headers and a single .cpp file that translates D3D9 API calls to WebGL — enabling legacy D3D9 applications to run in the browser without rewriting their rendering code.













01 — Rotating Cube
Textures, VB/IB, DrawIndexedPrimitive

02 — FFP Lighting
3 Point Lights, Materials, DrawPrimitiveUP

✨ Features

  • Full FFP Emulation — Per-vertex lighting (3 point lights), materials, texture stage states, transform matrices
  • All Draw PathsDrawIndexedPrimitive, DrawIndexedPrimitiveUP, DrawPrimitiveUP, DrawPrimitive
  • FVF Parsing — Automatic vertex layout from D3DFVF_XYZ, D3DFVF_XYZRHW, D3DFVF_NORMAL, D3DFVF_DIFFUSE, D3DFVF_TEX1TEX8
  • Texture Formats — DXT1/3/5 (via S3TC extension), A8R8G8B8, X8R8G8B8, R5G6B5, A4R4G4B4, A1R5G5B5, R8G8B8
  • Render States — Alpha blending, alpha test, depth test, stencil operations, culling, scissor test, color write mask
  • Texture Stage States — Stage 0 color/alpha ops (MODULATE, MODULATE2X, SELECTARG), Stage 1 lightmap blending (MODULATE…




Issues and PRs welcome.

Top comments (0)