2003년에 나온 온라인 게임을 브라우저로 포팅하는 작업을 하고 있었습니다. 게임은 C++로 작성되어 있고 렌더링은 Direct3D 9을 사용합니다. Emscripten으로 C++ → Wasm 변환까지는 되는데, #include <d3d9.h> 순간 빌드가 멈춥니다. D3D9 헤더는 Windows DirectX SDK에만 있으니까요.
이 문제를 해결하려고 만든 게 d3d9-webgl 입니다. D3D9 API를 WebGL 2.0 위에 재구현한 헤더 + 소스 파일 세트로, Emscripten 프로젝트에 넣으면 기존 D3D9 코드가 그대로 컴파일되고 브라우저에서 동작합니다. MIT 라이선스입니다.
https://github.com/LostMyCode/d3d9-webgl

D3D9 프로젝트를 웹으로 옮기는 방법은 세 가지 정도입니다:
| 접근 방식 | 현실 |
|---|---|
| 렌더링을 WebGL/Three.js로 전부 새로 작성 | 사실상 게임을 다시 만드는 것 |
| D3D9 호출을 OpenGL로 하나씩 변환 | 이것도 결국 대규모 재작성 |
| D3D9 인터페이스 자체를 WebGL 백엔드로 구현 | 기존 코드를 안 건드려도 됨 |
d3d9-webgl은 세 번째 방법입니다. IDirect3D9, IDirect3DDevice9, IDirect3DTexture9 등의 인터페이스를 전부 구현해서, 메서드 호출이 내부적으로 WebGL 2.0 API로 변환됩니다. 애플리케이션 쪽에서는 진짜 D3D9인지 구분할 수 없습니다.
포팅 대상이었던 게임은 GunZ: The Duel (2003)입니다. 렌더링 코드를 거의 안 바꾸고 브라우저에서 돌아갑니다. gunz.sigr.io 에서 직접 플레이할 수 있습니다.

D3D9의 FFP는 WebGL 2.0에 존재하지 않습니다. SetLight, SetMaterial, SetTransform으로 CPU 쪽에서 조립하는 라이팅 모델을 GLSL 셰이더로 직접 다시 만들어야 했습니다. 버텍스 셰이더에서 World/View/Projection 변환, 법선 변환, 포인트 라이트 3개의 디퓨즈 + 스페큘러 계산을 처리하고, 프래그먼트 셰이더에서 텍스처 블렌딩과 최종 색상 합성을 합니다.
전체 작업 중에서 가장 시간이 오래 걸린 부분입니다.
D3D9 정점 버퍼는 FVF(Flexible Vertex Format)라는 비트 플래그로 정점 레이아웃을 표현합니다. 이 플래그를 런타임에 해석해서 glVertexAttribPointer의 stride와 offset을 그때그때 계산합니다.
// 위치 + 법선 + 정점 색상 + UV 1세트
DWORD fvf = D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_DIFFUSE | D3DFVF_TEX1;
D3D9은 BGRA가 기본이고 WebGL은 RGBA를 기대합니다. A8R8G8B8은 업로드할 때 스위즐링하고, R5G6B5이나 A4R4G4B4 같은 16비트 포맷은 RGBA8로 확장합니다.
DXT1/DXT3/DXT5 압축 텍스처는 WEBGL_compressed_texture_s3tc 확장이 데스크톱 브라우저에서 거의 다 지원되기 때문에, 변환 없이 그대로 넘깁니다.
D3D9은 좌상단 원점에 Y↓, OpenGL은 좌하단 원점에 Y↑. SetRenderTarget으로 FBO에 그리면 상하가 뒤집히기 때문에, StretchRect로 화면에 블릿할 때 보정해야 합니다. 화면에 직접 그리는 경우에는 상관없습니다.
WebGL에는 D3D9의 SetClipPlane / D3DRS_CLIPPLANEENABLE에 해당하는 하드웨어 클립 평면이 없습니다. 프래그먼트 셰이더에서 클립 평면까지의 거리를 계산하고, 음수면 discard합니다. 단순한 방법이지만, 이걸 안 하면 클리핑이 전부 깨집니다.
D3D9 앱은 매 프레임 SetRenderState / SetTexture / SetSamplerState를 수백 번 호출하는데, 대부분은 이미 설정된 값과 같은 값을 다시 설정하는 겁니다. 텍스처 바인딩, 셰이더 프로그램, 샘플러 스테이트, 뷰포트, 시저를 전부 캐싱해서 실제로 바뀔 때만 GL 호출을 합니다.

프로젝트에 파일 5개를 복사합니다:
d3d9.h — D3D9 타입 정의 및 인터페이스d3d9.cpp — WebGL 2.0 구현 본체 (약 3,400줄)d3dx9math.h — D3DX 수학 라이브러리d3dx9.h — D3DX 스텁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
이걸로 끝입니다.
FFP 전용입니다. HLSL 버텍스/픽셀 셰이더를 쓰는 프로젝트는 그대로 안 됩니다. 래퍼가 VertexShaderVersion = 0을 반환하기 때문에, 셰이더가 있는 앱은 FFP 코드 패스로 폴백해야 합니다.
그 외:
LockRect(GPU 리드백) 미지원D3DXMatrixInverse는 스텁 (항등 행렬 반환)다만 2000년대 초반 게임이나 툴은 대부분 FFP를 씁니다. 프로그래머블 셰이더가 보편화된 건 DX9 후반이라, 2005년 이전 프로젝트라면 대부분 커버됩니다.
GunZ 포팅 작업을 하면서 이런 래퍼가 이미 있는지 계속 찾아봤는데, 없었습니다. 직접 만들면서 꽤 오래 걸렸고, 아마 지금도 같은 문제로 고생하고 있는 사람이 있을 거라 생각합니다.
오래된 D3D9 프로젝트가 있고 브라우저에서 돌려보고 싶으시다면, 한번 써보시고 어떤지 알려주세요.
GitHub: d3d9-webgl
이슈, PR 환영합니다.