← Back to projects
Playable · Chrome · v1.0

OpenMW · Web

A full 3D game engine, the open-source Morrowind engine, cross-compiled to WebAssembly and playable in the browser.

  • 60 FPS0.61 ms/frame, in-browser
  • 1.5 GBgrowable WASM heap
  • 20+native deps cross-compiled
  • 0.52.0OpenMW, core gameplay intact
Screenshot 1 The real Morrowind title screen in a browser tab at morrowind.virtastic.app, theme music playing.
Screenshot 2 Seyda Neen rendered live by the engine: water, fog, a silt strider, and the full 3D world on WebGL2.
Screenshot 3 Inside the prison ship: dynamic lighting and NPCs, the opening of the actual game running client-side.

OpenMW · Web is the build I am proudest of on the systems side. It takes a full native 3D game engine, the open-source reimplementation of Morrowind, and makes it run multithreaded in a browser tab, with nothing installed and no server doing the work. Every assumption the engine makes about the machine underneath had to be rebuilt on the web platform.

The whole engine, in a browser tab

This is the real OpenMW, tracking upstream 0.52.0 (base commit bc1d9c9), compiled to WebAssembly with Emscripten 6.0.1 and run multithreaded on WebGL2. It is not a reimplementation and not a streaming service: the C++ engine executes locally, reads game data straight off your disk, and keeps your saves in the browser. It holds 60 FPS (0.61 ms per frame) on the title screen and 44 to 57 FPS out in the world, with combat, magic, saves, AI, audio, and fast travel all verified working. A native game engine assumes a real operating system underneath it: threads, a desktop-OpenGL GPU, a blocking filesystem, and audio and video codecs. The browser gives you none of those, so each assumption had to be rebuilt on the web platform.

The Emscripten link is where it is won or lost

The final link is a hand-written script, not left to CMake, because the flags are unforgiving. The heap starts at 1.5 GB and is allowed to grow, to hold decompressed archives, textures, and terrain. Threads come from -pthread with a pool of eight workers. Rendering is pinned to WebGL2 (MAX and MIN_WEBGL_VERSION both 2, FULL_ES3 on), so there is no desktop-GL path to fall back on. The whole stack uses legacy WebAssembly exceptions; adding link-time optimization miscompiles the boot, so it stays off. The runtime is kept alive after main() returns and the engine object is heap-allocated so it survives the stack unwind, because there is no while-true loop: the browser calls a custom pump function once per animation frame, and the game clock is driven externally by that frame rate.

A synchronous engine on an asynchronous web

This is the hardest problem in the port. OpenMW reads its archive files, one to two gigabytes of BSAs, with synchronous read() calls on the main thread, and modern browsers forbid synchronous binary fetches there. The fix is a synchronous-over-asynchronous filesystem: the main thread posts a request to a worker and then spin-waits on a generation counter in a SharedArrayBuffer, because it is not allowed to call Atomics.wait on the main thread. The worker fetches the bytes in 2 MB chunks, over HTTP range requests for hosted data or through the File System Access API for a folder you pick, and writes them into the shared buffer. A 48-entry LRU cache keeps about 96 MB of recent chunks resident, so revisits are instant. The custom filesystem nodes even force mmap to fail so the engine is guaranteed to take the streaming read() path instead of trying to map a two-gigabyte archive into a 32-bit address space. Bring-your-own Morrowind is never uploaded or copied; it is sliced off disk on demand.

Threads that would otherwise freeze the tab

The build enables real pthreads over SharedArrayBuffer, but naive threading deadlocks the browser. When an OpenSceneGraph worker and the main thread both miss the shared object cache, one takes the shared-state mutex and the other waits on a futex, and because everything ultimately runs on a single JavaScript event loop, that wait starves the whole tab into a hard freeze. So the asset work queue defaults to running inline on the main thread at controlled points rather than on background workers. On the web, threads earn their keep coordinating I/O, not parallelizing compute, so this costs nothing in practice and buys a frame loop that never stalls.

No fixed-function pipeline, so rewrite the rendering

WebGL2 is GLES3 core: no glBegin and glEnd, no glLightf, no GL_QUADS, and no client-side vertex arrays. Every one of those had to go. Vertex and array buffers are forced on so nothing draws from client memory. MyGUI, the in-game UI, drew through fixed-function client arrays and was rewritten to resolve OSG vertex-attribute aliases and upload through glVertexAttribPointer. Water was built from GL_QUADS and is now indexed triangles. Fog, lighting, alpha test, and clip-plane state are marked invalid so OpenSceneGraph skips the calls entirely instead of raising a GL error on every single frame.

Patching OpenSceneGraph for the browser

OpenSceneGraph 3.6.5 was never meant to run in a browser, so the port carries a patch touching 15 of its files across three dozen conditional blocks. The load-bearing fix is in the framebuffer code: WebGL2 requires a packed depth-stencil buffer to be bound as a single depth-stencil attachment, not as separate depth and stencil, and that one change un-blanks every render-to-texture camera at once, the character preview, the local map, and the water reflections, all of which rendered black before it. Depth-only shadow framebuffers must explicitly disable color output or every draw raises an error. Texture compression is force-enabled because WebGL2 exposes S3TC under a different name, keeping DXT textures four times smaller in VRAM. And multisample resolve calls the raw glBlitFramebuffer symbol directly, because Emscripten hands back a null pointer for it.

Bugs that only exist in the browser

Getting it to boot was the start; getting it to look and feel right meant chasing a whole class of bugs that only appear under WebGL2 and ANGLE. Shadows swam as the camera rotated until they were rebuilt on a fixed world-up basis, with power-of-two texel snapping and a forced orthographic projection. Equipping a weapon froze the game for seconds until the mesh recompile was moved off the frame path. Water reflection and refraction are clipped through a shader discard because GLES has no clip planes. The Bink intro video froze on its first frame, and again at its end, until the external clock was re-anchored, teardown was deferred, and a ten-second stalled-video failsafe was added. An audio thread that called back into the main thread deadlocked until audio was pumped inline every frame. A blocking message-box loop that wedged the tab was made non-blocking. And the frame pump itself is wrapped so a single thrown exception can no longer freeze the tab at a permanent, lying 60 FPS. Every one of these worked on the desktop and broke only in the browser.

Satisfying an X11 that is not there

osgViewer is built with the X11 windowing backend, and its windowing interface has a static constructor that calls XSetErrorHandler before main() even runs. Under legacy WebAssembly exceptions, an unresolved symbol at that point is an instant, unrecoverable trap, so the module would not even load. The build supplies 48 signature-exact X11 no-op stubs, about 430 lines, that exist purely to satisfy the linker. The real window and input come from SDL2, through the Emscripten canvas, to the WebGL2 context; the X11 layer is a ghost that only needs to link.

Twenty native libraries, cross-compiled to WebAssembly

Underneath the engine sits a stack of native C and C++ libraries, every one cross-compiled to static WebAssembly with threads enabled: OpenSceneGraph 3.6.5, Bullet in double precision, Boost, MyGUI, Lua 5.4, FFmpeg built with the Bink decoder for the intro movie, LZ4, and RecastNavigation for AI pathfinding, with SDL2, FreeType, HarfBuzz, and the image and audio codecs coming from Emscripten ports. Even ICU has to be the thread-safe build, or its symbols mismatch the rest of the pthreads-enabled stack and the link fails.

Performance, and how it is delivered

On a modern desktop it holds 60 FPS at the menu (0.61 ms per frame) and 44 to 57 FPS out in the world, rendering at native device pixels so it stays crisp on Retina displays, with 4x MSAA (8x exceeds what ANGLE sustains without stutter). Saves, settings, and keybindings persist through the browser IndexedDB filesystem and survive reloads and tab closes. The first load streams the game assets once and caches them, so later boots are fast, and a themed loading screen shows real download and mount progress on the way in.

Why desktop Chrome only

The build targets desktop Chrome and Chromium because it depends on features that, in practice, only they provide together reliably: SharedArrayBuffer with WebAssembly threads, which requires the page to be cross-origin isolated with COOP and COEP headers on a secure origin; WebGL2 and GLES3 through ANGLE; EXT_clip_control for the reverse-Z depth buffer; and the File System Access API for the bring-your-own-Morrowind folder picker. Several of the GLES workarounds are gated specifically to Chrome ANGLE behavior, so Firefox and Safari are out of scope by design rather than by neglect.

Stack

  • C++
  • Emscripten 6.0.1
  • OpenSceneGraph 3.6.5
  • WebGL2 / GLES3
  • SharedArrayBuffer
  • pthreads
  • SDL2
  • Bullet
  • MyGUI
  • RecastNavigation
  • FFmpeg
  • Lua 5.4