Games are the hardest test of desktop-to-web conversion. A real-time 3D engine touches everything at
once: the GPU, the audio device, threads, the filesystem, input. If the port stutters anywhere, you see
it instantly. That is exactly why we picked OpenMW, the open-source reimplementation of The Elder
Scrolls III: Morrowind, as one of our proof ports.
It runs in a browser tab today at morrowind.virtastic.app, and it is our most polished
conversion. Here is how the port works, the parts that fought back, and what a game engine proves about
modernizing serious software.
How it was done
OpenMW is a heavyweight C++ engine with a heavyweight dependency stack: OpenSceneGraph for rendering,
Bullet physics, MyGUI, FFmpeg for audio and video decoding, Boost, Lua, Recast navigation. All of it is
cross-compiled to WebAssembly with Emscripten, with threads and native-speed exceptions enabled.
Exception handling is worth a footnote of its own. The engine, every dependency, and the final link all
have to agree on the same exception model, and when they disagree you get link errors that point at
nothing useful. Everything here is built against native WebAssembly exceptions. Getting that consistent
across the whole tree cost real time before a single frame rendered.
The interesting engineering is at the seams, where desktop assumptions meet browser reality:
- OpenGL became WebGL2. The renderer's shaders were ported to GLES/WebGL2 semantics, and character animation moved to GPU skinning. OpenSceneGraph was the single hardest dependency in the stack and it earned its own patch series.
- The filesystem became a streaming virtual filesystem. A desktop engine assumes gigabytes of assets sitting on disk. In the browser, assets stream in on demand and the Cache API keeps them local for the next session, so the second visit starts fast and the first one doesn't demand a giant download up front.
- State became persistent browser storage. Saves, settings and keybindings live in IndexedDB, synced in the background and on tab close. Close the browser mid-quest and tomorrow the tab reopens where you left it.
- Desktop-only APIs got stubbed at the boundary. X11 and friends are no-ops behind the same kind of guarded patches we use in every port. Upstream code untouched, browser divergences small and reviewable.
GPU skinning, per-pixel lighting and real-time shadows, all running client-side in a tab.
The bugs that only exist on the web
Most of what broke was WebGL2 and OpenGL ES being far stricter than desktop GL, while OpenSceneGraph
quietly assumed desktop behaviour underneath.
The world was too dark. Interiors and exteriors came out badly underlit, because of how the sun and
lighting parameters were reaching the shaders. Flattening them into plain uniforms the GLES path
actually accepts brought the world back to proper brightness.
Equipping a weapon froze the game. Native OpenMW hands work to background threads. Block the main
thread in a browser and you don't stall a worker, you freeze the entire tab. A work queue was waiting on
a result that could never arrive, so that path now runs inline.
Shadows crawled. Shadows shimmered and swam as the camera turned. It looked like a precision problem
and it wasn't: the cause was the cascaded shadow map's cascade transitions. We collapsed to a single
cascade with a 16384 map, which keeps the shadow texel grid locked in world space by construction.
We verified that mechanically rather than by eye. An instrumented 360-degree rotation measured the texel
size per cascade at every yaw and it never moved: 0.000244 at the near cascade, 0.000488 and 0.000977
further out, constant through a full turn. A grid that never shifts relative to the world cannot crawl.
Stable shadows and real-time water reflections, the two things that fought hardest.
Faces turned into cones. Morph-based facial animation was disabled on the web because it fired
spikes out of characters' heads. The cause was how the morph geometry's vertex arrays were laid out.
Putting all of them on one dedicated vertex buffer, the way the skinned geometry already did, fixed it,
and faces went back on.
Post-processing wedged the shader compiler. The modern post stack, bloom included, refused to link.
Several separate GLES incompatibilities were stacked on top of each other: a fragment output binding call
that WebGL rejects and that was silently wedging program linking, struct uniforms that had to be
flattened by hand, float render targets that had to be requested explicitly, and, in the bloom shader,
a file-scope global initialised from a runtime value. That last one is illegal in GLSL ES and killed the
link every time.
Antialiasing: the fix we were proud of, and then mostly couldn't use
This is the most honest story in the port, so it gets its own section.
We got hardware MSAA working, and it was a real fight. Two stacked bugs in OpenSceneGraph's GLES path
had it broken. First, the per-colour-attachment resolve blit was wrapped in a preprocessor guard that
compiled it out entirely on GLES, while OpenMW was attaching its resolve target in a way that path never
handled. Second, even after that, the blit function OSG wanted to call is a null pointer under
Emscripten, so the whole resolve had been gated off. We folded the colour resolve into the main blit and
called the WebGL2 blit directly. MSAA rendered clean and antialiased at 60fps with a clean GL error
state. Verified.
And then we mostly couldn't use it. MSAA falls apart under post-processing on WebGL2, and the modern
pipeline runs post-processing by default. So the antialiasing you actually get from the Options dropdown
is SSAA: the scene renders at a higher resolution and scales back down. It is cheap on the GPU and hungry
on memory, hungry enough that at full retina resolution the supersampled buffers ran the tab out of
memory during boot until we capped the effective pixel count. The dropdown offers Off, 1.5x and 2x, and
it stays under the ceiling.
Both paths are genuinely in there. MSAA works and is verified. SSAA is what most people will actually
run, because it survives the pipeline we ship.
Then we didn't stop at "it runs"
Being that deep in the renderer already, we spent the remaining headroom making it look better than it
did in 2002:
- Radial and distance fog with sky blending, so the horizon reads as atmosphere instead of a hard wall.
- Per-pixel lighting across the world, in place of the original per-vertex lighting.
- Alpha-to-coverage on foliage, so leaves have clean edges instead of harsh cutouts.
- Anisotropic filtering up to 16x, so ground textures stay sharp at grazing angles.
- Real-time water reflections. Reflection buffers cannot be multisampled on GLES, so rather than ship a jagged reflection we blur it in the shader.
Distance fog, sky blending and shader-blurred water reflections on the Bitter Coast.
Where it landed
Steady 60fps across the world, including a full walk through Balmora with no hitching. The engine is CPU
bound on draw submission rather than GPU bound, which is a good problem: it means the headroom went into
the rendering work above. Memory holds at about 1.5GB in Balmora, the densest town in the game, and does
not grow. Saves persist, mods load, gamepads work through the same SDL input path the native build uses.
One honest limitation: this is a Chromium target. We are not chasing Firefox or Safari parity right now.
"Bring your own data", the pattern that matters most
Morrowind's game data is commercial, so the port ships a free example world plus a launcher that lets you
point the engine at your own copy of the game using the browser's File System Access API. The engine
reads your files directly from your disk.
Two doors: a free example world, or your own install read straight off disk. Nothing uploads.
Read that again from a business angle: a browser application working against local, sensitive data, with
nothing uploaded anywhere. The compute runs client-side and the data never leaves the machine. That is
precisely how a browser-based engineering or analysis tool can handle confidential project files. You get
the web app's convenience without the "our files on someone's cloud" objection.
What the browser version does better
- Distribution is a link. No installer, no GPU-driver roulette, no "works on my machine." Send a URL and it runs, including on machines where users cannot install anything.
- Updates are invisible. Engine fixes land for every player on next load. Nobody is stranded on an old build.
- Progress follows the browser profile. Saves survive without a launcher account or manual save management.
Why a game proves the business case
Nobody's line-of-business app is harder on a browser than a real-time 3D engine. If WebGL2 rendering,
physics, streamed assets, audio and persistent state hold up in a tab, a forms-and-documents desktop
application is comfortably within reach. A Qt-based professional tool sits in between, which is exactly
where our FreeCAD work picked up the same patterns: the
shader work, the virtual filesystem, the persistence layer. That one is still in preview. This one you
can play right now.
Try it
Play it in your browser (desktop Chromium works best), read the source on
GitHub, and if you have a desktop application you'd like to see running in a tab,
book a meeting.




Top comments (0)