D3D9 게임을 코드 수정 없이 브라우저에서 돌리는 OSS를 만들었습니다

blueocean·2026년 3월 4일

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 에서 직접 플레이할 수 있습니다.


구현하면서 힘들었던 것들

Fixed Function Pipeline을 GLSL로 재현

D3D9의 FFP는 WebGL 2.0에 존재하지 않습니다. SetLight, SetMaterial, SetTransform으로 CPU 쪽에서 조립하는 라이팅 모델을 GLSL 셰이더로 직접 다시 만들어야 했습니다. 버텍스 셰이더에서 World/View/Projection 변환, 법선 변환, 포인트 라이트 3개의 디퓨즈 + 스페큘러 계산을 처리하고, 프래그먼트 셰이더에서 텍스처 블렌딩과 최종 색상 합성을 합니다.

전체 작업 중에서 가장 시간이 오래 걸린 부분입니다.

FVF 동적 파싱

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 확장이 데스크톱 브라우저에서 거의 다 지원되기 때문에, 변환 없이 그대로 넘깁니다.

Y축 반전

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 코드 패스로 폴백해야 합니다.

그 외:

  • 포인트 라이트 최대 3개 (디렉셔널 / 스포트라이트 미구현)
  • 정점 버퍼 스트림 0만 지원
  • 렌더 타깃 LockRect(GPU 리드백) 미지원
  • D3DXMatrixInverse는 스텁 (항등 행렬 반환)

다만 2000년대 초반 게임이나 툴은 대부분 FFP를 씁니다. 프로그래머블 셰이더가 보편화된 건 DX9 후반이라, 2005년 이전 프로젝트라면 대부분 커버됩니다.


공개하는 이유

GunZ 포팅 작업을 하면서 이런 래퍼가 이미 있는지 계속 찾아봤는데, 없었습니다. 직접 만들면서 꽤 오래 걸렸고, 아마 지금도 같은 문제로 고생하고 있는 사람이 있을 거라 생각합니다.

오래된 D3D9 프로젝트가 있고 브라우저에서 돌려보고 싶으시다면, 한번 써보시고 어떤지 알려주세요.

GitHub: d3d9-webgl

이슈, PR 환영합니다.

profile
super hacker

0개의 댓글