<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Virtastic</title>
    <description>The latest articles on DEV Community by Virtastic (@virtastic).</description>
    <link>https://dev.to/virtastic</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4047369%2F5503bba3-c48c-4ba7-9d18-e43326610553.png</url>
      <title>DEV Community: Virtastic</title>
      <link>https://dev.to/virtastic</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/virtastic"/>
    <language>en</language>
    <item>
      <title>Porting FreeCAD to wasm64: what broke after the compile succeeded</title>
      <dc:creator>Virtastic</dc:creator>
      <pubDate>Mon, 21 Sep 2026 16:18:17 +0000</pubDate>
      <link>https://dev.to/virtastic/porting-freecad-to-wasm64-what-broke-after-the-compile-succeeded-5g52</link>
      <guid>https://dev.to/virtastic/porting-freecad-to-wasm64-what-broke-after-the-compile-succeeded-5g52</guid>
      <description>&lt;p&gt;&lt;a href="https://virtastic.app/blog/freecad-web-1-0/" rel="noopener noreferrer"&gt;freecad-web 1.0&lt;/a&gt; is FreeCAD 1.1.3 compiled to &lt;code&gt;wasm64-emscripten&lt;/code&gt; and running in your browser: Qt 6.11, OCCT 7.8.1, Coin3D 4.0.3, VTK 9.3.1, CPython 3.13.3, PySide6, numpy, matplotlib, IfcOpenShell, with Gmsh and CalculiX as separate wasm modules.&lt;/p&gt;

&lt;p&gt;Getting it to compile and link took a few weeks. Getting it to &lt;em&gt;behave like desktop FreeCAD&lt;/em&gt; took the other two and a half months, and almost none of that went into compile errors. It went into things that compiled cleanly, linked cleanly, passed a scripted test, and did nothing when a person clicked. This post is about those.&lt;/p&gt;

&lt;p&gt;One rule came out of it and now sits at the top of the project's working agreements: a scripted test through the Python bridge is a weaker claim than a real mouse click, and a return value can be the failure. Most of what follows is that rule being learned the expensive way. Every number here is from &lt;a href="https://github.com/Virtastic/freecad-web/blob/main/BUILD-WEH.md" rel="noopener noreferrer"&gt;BUILD-WEH.md&lt;/a&gt; or &lt;code&gt;MANUAL-QA.md&lt;/code&gt; in the repository, and the commit hashes are real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;p&gt;185 MB of wasm, 293 MB of preloaded file system, 1.4 MB of JS glue. Wasm exceptions in the new &lt;code&gt;try_table&lt;/code&gt; encoding, JSPI for suspending into JavaScript, pthreads with a pool of 16, a heap that starts at 1 GiB and can grow to 16 GiB, which is where V8 caps 64-bit memory. Coin3D is a fixed-function OpenGL renderer, so it runs on emscripten's legacy GL emulation on top of WebGL 2. Nothing is dynamically loaded: every Python extension is in the inittab and on the link line, and if it is missing from either it builds fine and fails to import.&lt;/p&gt;

&lt;p&gt;The server is nginx serving static files with brotli. The browser fetches about 115 MB on the first visit and nothing on the second. That second part took longer than it should have.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Only promising exports may suspend
&lt;/h2&gt;

&lt;p&gt;JSPI lets a wasm export be marked &lt;em&gt;promising&lt;/em&gt;: calls into it can suspend on a JavaScript promise and resume later. FreeCAD needs this constantly, because a modal dialog is a nested event loop and a nested event loop in a browser is a suspension.&lt;/p&gt;

&lt;p&gt;Every dialog opened when driven from Python through our promising export. Not one opened from a real mouse click. The gate said "0 page errors" and the harness reported success, because the harness was driving Python.&lt;/p&gt;

&lt;p&gt;The mechanism, once we stopped theorising and read &lt;code&gt;qstdweb.cpp:743-751&lt;/code&gt;: Qt's DOM event listener enters wasm through an embind call that is not a promising export. &lt;code&gt;QDialog::exec&lt;/code&gt; called from that path tried to suspend and threw &lt;code&gt;SuspendError: trying to suspend without WebAssembly.promising&lt;/code&gt;, which Qt swallowed. The identical C++ from a promising path was fine.&lt;/p&gt;

&lt;p&gt;The fix is one promising export, &lt;code&gt;fcweb_dispatch_event&lt;/code&gt;, and a drop-in replacement for the listener class that routes real DOM events through it. Drag and drop in the tree had the same disease (&lt;code&gt;QDrag::exec&lt;/code&gt;) and the same cure.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. JSPI switches the wasm stack, not the C stack
&lt;/h2&gt;

&lt;p&gt;When a promising call suspends, JSPI saves the wasm execution stack. It knows nothing about the &lt;em&gt;shadow&lt;/em&gt; stack that C code keeps in linear memory for locals whose address is taken. The shadow stack pointer is a global, and a second promising activation that starts while the first is suspended happily runs on top of the first activation's frames.&lt;/p&gt;

&lt;p&gt;The symptom: a Gmsh mesh from the FEM workbench wrote its result correctly and then CPython died with &lt;code&gt;Fatal Python error: Executing a cache&lt;/code&gt;. The trace showed Qt's main loop had resumed 15 times inside one gmsh call, each time over the suspended Python call's frames.&lt;/p&gt;

&lt;p&gt;The fix (&lt;code&gt;466d90c&lt;/code&gt;) gives every promising export a private malloc'd stack, from a pool, for the duration of the call.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The keyboard never entered Qt
&lt;/h2&gt;

&lt;p&gt;Typing into a text field worked perfectly. Delete, Ctrl+Z, Escape and every shortcut did nothing, silently.&lt;/p&gt;

&lt;p&gt;Qt for WebAssembly focuses a hidden DOM input element only when a text widget has focus. With anything else focused, &lt;code&gt;document.activeElement&lt;/code&gt; is the body, and keydown never reaches Qt at all. This was found with an application-wide Qt event filter that logged every key event: it logged the letters and nothing else.&lt;/p&gt;

&lt;p&gt;The fix forwards trusted key events to Qt's canvas, with &lt;code&gt;isTrusted&lt;/code&gt; as the loop guard and skipped when a text field has focus, so that typing &lt;code&gt;abc123&lt;/code&gt; still yields exactly &lt;code&gt;abc123&lt;/code&gt;. The test is per focus target, with real key events, because a bridge-driven test cannot see this.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The compositor recorded the frame and never presented it
&lt;/h2&gt;

&lt;p&gt;The threaded Qt build showed a black viewport. Single-threaded, identical C++, showed the model. Ten theories were written down and disproved before the measurement that mattered: instrument the GL glue and count draws and blits that reach the default framebuffer. On the threaded build, zero. Ever. Qt recorded the compose into an offscreen target and the present pass never happened.&lt;/p&gt;

&lt;p&gt;A bisect on FreeCAD 1.0.0 with nothing but &lt;code&gt;-feature-thread&lt;/code&gt; toggled turned 9,485 distinct colours in the viewport into one. The fix (&lt;code&gt;691e7bd&lt;/code&gt;) has the page perform the final present pass itself each animation frame. Threading stays on.&lt;/p&gt;

&lt;p&gt;Closely related: Qt's RHI draws on Coin's GL context, and emscripten's emulation state is global across contexts. Every external GL state reset needs a symmetric reassert, or Coin's lazy element cache decides nothing changed and skips it. "Missing" geometry was unoccluded geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The 2 MB ceiling in immediate mode
&lt;/h2&gt;

&lt;p&gt;An 18 MB STL threw from &lt;code&gt;getTempVertexBuffer&lt;/code&gt;. Coin had emitted one &lt;code&gt;glBegin&lt;/code&gt; of 153,600 vertices, 4.3 MB of them, against emscripten's &lt;code&gt;GL.MAX_TEMP_BUFFER_SIZE&lt;/code&gt; of 2 MB. Before the throw, typed-array stores past the end were being silently discarded, so smaller meshes were being truncated without anyone noticing.&lt;/p&gt;

&lt;p&gt;The buffer now grows on demand (&lt;code&gt;8a2f343&lt;/code&gt;). The obvious alternative, switching Coin to VBOs, was measured and rejected: a 626-solid STEP file never became responsive within 600 seconds with VBOs on, because VBO-on routed Mesh nodes onto emulation code that had never run.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Display lists had never worked
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;glGenLists&lt;/code&gt; returns 0 in emscripten's legacy GL emulation. Coin treats 0 as "no display list" and re-traverses the node every frame. It had been doing that since the first link. A profile of a BIM drag showed 63% of the frame in scene traversal.&lt;/p&gt;

&lt;p&gt;The fix (&lt;code&gt;c741277&lt;/code&gt;) records every GL import between &lt;code&gt;glNewList&lt;/code&gt; and &lt;code&gt;glEndList&lt;/code&gt; in JS, snapshots the client-side arrays, and replays on &lt;code&gt;glCallList&lt;/code&gt;. 1,769 replayed operations take 9.6 ms; 7 pixels of 2.6 million differ from the direct path. BIMExample went from 39.6 to 50.3 fps.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Qt timers starve, and Chrome clamps the rest to 4 ms
&lt;/h2&gt;

&lt;p&gt;Two timer problems, one after the other.&lt;/p&gt;

&lt;p&gt;First: once any promising activation calls &lt;code&gt;processEvents&lt;/code&gt;, it overwrites the single resume slot the main loop was parked on, and &lt;code&gt;onTimer&lt;/code&gt; is a no-op under asyncify. A 1 second &lt;code&gt;QTimer&lt;/code&gt; ticked five times and died. The page now pumps Qt once per native timer wake.&lt;/p&gt;

&lt;p&gt;Second, after that: Chrome clamps nested &lt;code&gt;setTimeout(0)&lt;/code&gt; to 4 ms, and Qt parks on one per &lt;code&gt;processEvents&lt;/code&gt;. The main thread was 61% idle in a profile while frames took 16.7 ms. Zero-delay Qt timers moved to a &lt;code&gt;MessageChannel&lt;/code&gt; (&lt;code&gt;273a785&lt;/code&gt;), and BIMExample went to 6.9 ms per frame. Desktop is 13.3 on the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Python shipped with no bytecode
&lt;/h2&gt;

&lt;p&gt;1,486 &lt;code&gt;.py&lt;/code&gt; files under &lt;code&gt;Mod&lt;/code&gt;, 0 &lt;code&gt;.pyc&lt;/code&gt;. Every boot compiled Draft from source: 1.6 seconds for &lt;code&gt;import Draft&lt;/code&gt; against 2 ms warm. The packager stamps files with the unpack time, so timestamp-validated bytecode never validates. Unchecked-hash bytecode compiled between install and link (&lt;code&gt;13cc5f3&lt;/code&gt;) took the import to 0.36 s and a 42 MB assembly open from 71 s to 28 s.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. wasm64 was mostly fine, except where it was not
&lt;/h2&gt;

&lt;p&gt;Moving from wasm32 to wasm64 grew the binary 12.7% and removed the memory ceiling. Three things bit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pointers are BigInts.&lt;/strong&gt; The post-link GL patches used &lt;code&gt;&amp;gt;&amp;gt; 2&lt;/code&gt; to turn a byte pointer into a heap index; BigInt will not shift by a Number, so it becomes &lt;code&gt;/ 4&lt;/code&gt;. Only 4 of the 48 patch anchors carry a heap index, but each was a silent wrong answer until found.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;glShaderSource&lt;/code&gt; reads its &lt;code&gt;GLint *length&lt;/code&gt; array at pointer width&lt;/strong&gt; (&lt;code&gt;d3aa482&lt;/code&gt;). Correct by accident on wasm32, every Qt shader arrived truncated on wasm64 with "Missing main()". The entire widget layer vanished as soon as a 3D view existed, while Coin, which is fixed-function and has no shaders, kept drawing. Every gate that photographed the viewport passed. The gate now scans for Qt shader compile failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IfcOpenShell's schema registry took the renderer to 8 GB and Chrome killed it&lt;/strong&gt; (&lt;code&gt;5eadc88&lt;/code&gt;). Not the heap and not TurboFan: V8 lazily compiling three generated functions of 1.0 to 1.4 MB each, in a 116 MB code section with pointers twice as wide. &lt;code&gt;-Oz -fno-inline&lt;/code&gt; on the generated schema files only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. The engine was never cached
&lt;/h2&gt;

&lt;p&gt;Every visit downloaded the engine again: 113 MB and two to three minutes, &lt;code&gt;immutable&lt;/code&gt; and a one-year max-age notwithstanding. Chrome's disk cache will not retain entries of that size. Cache Storage will, and reads a 152 MB entry back in 111 ms. Cold boot went from 171 s to 23 s and a return visit from 115 s to 8 s with 0 bytes fetched.&lt;/p&gt;

&lt;p&gt;A related trap: the JS, wasm and data files are one artifact and are served immutable-for-a-year with md5-stamped names. A returning visitor with a cached JS and a fresh wasm gets a dead boot, and &lt;em&gt;only&lt;/em&gt; returning visitors do, which is why one gate deliberately reuses a browser profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Things that only a person can see
&lt;/h2&gt;

&lt;p&gt;Two classes of defect that no harness catches, and that now live on the manual QA pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A modal sitting mid-canvas with a console reporting zero errors. The stale-cache boot failure was exactly this. When a harness says everything is fine and nothing works, take a screenshot.&lt;/li&gt;
&lt;li&gt;Chromium's native file picker, which no script can answer, and a native drag that Chrome refuses to begin from synthesised input. The drop pipeline is verified by simulation; only a hand proves the gesture starts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we changed upstream, and what we did not
&lt;/h2&gt;

&lt;p&gt;Every delta lives as a patch in &lt;code&gt;patches/&lt;/code&gt;: 8,506 lines across 18 files, 7,162 of them against FreeCAD itself. The rule was to adapt upstream code rather than replace it. A guard on &lt;code&gt;sys.platform == "emscripten"&lt;/code&gt; around a subprocess call keeps the feature and removes the crash; a reimplementation of the feature is a parity bug waiting to diverge. The blocking dialog module exists so that a Python-triggered dialog still returns the user's real choice rather than a default.&lt;/p&gt;

&lt;p&gt;Things the desktop does that this build knowingly does not, all on the front page: Firefox and Safari (no JSPI yet), multi-threaded CalculiX, and an OpenSCAD binary for CSG evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, for the record
&lt;/h2&gt;

&lt;p&gt;On an idle machine with an RTX 4080, dev tree of 2026-09-13: EngineBlock opens in 0.3 s and drags at 37.8 fps; BIMExample opens in 7.1 s and drags at 58.2 fps; the 42 MB a2plus assembly opens in 20.2 s and drags at 25.5 fps. Against desktop FreeCAD 1.1.3 on the same machine, the web build is faster on Draft-heavy files (ArchDetail by 5 to 7 times), 2 to 5 times behind on BIMExample, and within 2x on open times everywhere. The per-frame floor is about 20 ms.&lt;/p&gt;

&lt;p&gt;Everything above, with the measurement that found it and the upstream line where the mechanism lives, is in &lt;a href="https://github.com/Virtastic/freecad-web/blob/main/BUILD-WEH.md" rel="noopener noreferrer"&gt;BUILD-WEH.md&lt;/a&gt;. The harnesses that produced the numbers are in &lt;code&gt;scratchpad/&lt;/code&gt;. If you are porting a large Qt or Python application to wasm and hit something that looks like one of these, that file is probably the fastest hour you will spend.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://virtastic.app/blog/freecad-web-1-0/" rel="noopener noreferrer"&gt;The release post&lt;/a&gt; has what the result looks like from the user's side. &lt;a href="https://virtastic.app/go/freecad?utm_source=devto&amp;amp;utm_medium=post&amp;amp;utm_campaign=freecad-1-0" rel="noopener noreferrer"&gt;Try it&lt;/a&gt; or read the &lt;a href="https://github.com/Virtastic/freecad-web" rel="noopener noreferrer"&gt;source&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://virtastic.app/blog/freecad-web-wasm64-runtime/" rel="noopener noreferrer"&gt;virtastic.app&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Jagged Alliance 2, running in your browser</title>
      <dc:creator>Virtastic</dc:creator>
      <pubDate>Mon, 10 Aug 2026 15:39:50 +0000</pubDate>
      <link>https://dev.to/virtastic/jagged-alliance-2-running-in-your-browser-556g</link>
      <guid>https://dev.to/virtastic/jagged-alliance-2-running-in-your-browser-556g</guid>
      <description>&lt;p&gt;If you own Jagged Alliance 2, you can now play it in a browser. Point&lt;br&gt;
&lt;a href="https://ja2.virtastic.app" rel="noopener noreferrer"&gt;ja2.virtastic.app&lt;/a&gt; at the folder your copy already lives in and it runs: the full&lt;br&gt;
campaign, full tactical combat, mouse and keyboard, sound intact. Nothing to install, and your&lt;br&gt;
game files never leave your machine.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/h1NLDfVc3o8"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's built on
&lt;/h2&gt;

&lt;p&gt;This is &lt;a href="https://github.com/ja2-stracciatella/ja2-stracciatella" rel="noopener noreferrer"&gt;Stracciatella&lt;/a&gt;, the open-source&lt;br&gt;
reimplementation of the Jagged Alliance 2 engine. The same relationship as our Morrowind port to&lt;br&gt;
OpenMW: a community project already rebuilt the engine from scratch, and our job was getting that&lt;br&gt;
engine to run in a browser instead of on a desktop. Full credit to the Stracciatella team. We&lt;br&gt;
recompiled their engine to WebAssembly; the engine itself is theirs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1c2c9zwoxyad506dejzu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1c2c9zwoxyad506dejzu.webp" alt="The ja2-web launcher in a browser, offering to load your own Jagged Alliance 2 data folder" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The launcher. You point it at the Data folder your copy already lives in, and the files are read locally.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it took
&lt;/h2&gt;

&lt;p&gt;The shape of the problem was familiar from OpenMW: an engine written for a desktop event loop,&lt;br&gt;
a desktop filesystem, and desktop audio, none of which a browser gives you for free. Sound became&lt;br&gt;
Web Audio. The UI and mouse-driven tactical combat run exactly as they do on desktop, translated&lt;br&gt;
onto browser input events. And like OpenMW, it's a "bring your own data" port: Jagged Alliance 2's&lt;br&gt;
game data is commercial, so the launcher points at your own legally-owned copy and reads it&lt;br&gt;
straight off disk. Nothing is uploaded anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfsz20l0ojsvtko34g16.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmfsz20l0ojsvtko34g16.webp" alt="The A.I.M. mercenary hiring site running inside the game, in a browser" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A.I.M., the in-game mercenary hiring site, complete with its 1999 web design. All of it runs client-side.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One detail worth calling out: it keeps running in a background tab. Browsers throttle inactive&lt;br&gt;
tabs hard, which is normally the right call, and normally invisible unless your game depends on a&lt;br&gt;
turn timer or an AI that needs to keep thinking while you've alt-tabbed away. Jagged Alliance 2&lt;br&gt;
does. The port accounts for that, so switching tabs mid-mission doesn't leave the game stuck or&lt;br&gt;
desynced when you come back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg2li20gzqw8wr5u41st.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg2li20gzqw8wr5u41st.webp" alt="The Jagged Alliance 2 title screen rendered in a browser" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The original title screen, unchanged, in a browser.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ja2.virtastic.app" rel="noopener noreferrer"&gt;Play it in your browser&lt;/a&gt; if you own the game, read the source on&lt;br&gt;
&lt;a href="https://github.com/Virtastic/ja2-web" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and if you hit a bug, &lt;a href="https://virtastic.app/contact" rel="noopener noreferrer"&gt;tell us&lt;/a&gt; or drop&lt;br&gt;
it in &lt;a href="https://discord.gg/PzFfDkbSue" rel="noopener noreferrer"&gt;the Discord&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This is our second full release after &lt;a href="https://virtastic.app/blog/openmw-morrowind-engine-in-the-browser/" rel="noopener noreferrer"&gt;OpenMW&lt;/a&gt;.&lt;br&gt;
More are on the way.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webassembly</category>
      <category>gamedev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>OpenMW in the browser: porting a 3D engine</title>
      <dc:creator>Virtastic</dc:creator>
      <pubDate>Sat, 25 Jul 2026 23:27:17 +0000</pubDate>
      <link>https://dev.to/virtastic/openmw-in-the-browser-porting-a-3d-engine-544l</link>
      <guid>https://dev.to/virtastic/openmw-in-the-browser-porting-a-3d-engine-544l</guid>
      <description>&lt;p&gt;Games are the hardest test of desktop-to-web conversion. A real-time 3D engine touches everything at&lt;br&gt;
once: the GPU, the audio device, threads, the filesystem, input. If the port stutters anywhere, you see&lt;br&gt;
it instantly. That is exactly why we picked OpenMW, the open-source reimplementation of The Elder&lt;br&gt;
Scrolls III: Morrowind, as one of our proof ports.&lt;/p&gt;

&lt;p&gt;It runs in a browser tab today at &lt;a href="https://morrowind.virtastic.app" rel="noopener noreferrer"&gt;morrowind.virtastic.app&lt;/a&gt;, and it is our most polished&lt;br&gt;
conversion. Here is how the port works, the parts that fought back, and what a game engine proves about&lt;br&gt;
modernizing serious software.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/iqZ20MtdD4I"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  How it was done
&lt;/h2&gt;

&lt;p&gt;OpenMW is a heavyweight C++ engine with a heavyweight dependency stack: OpenSceneGraph for rendering,&lt;br&gt;
Bullet physics, MyGUI, FFmpeg for audio and video decoding, Boost, Lua, Recast navigation. All of it is&lt;br&gt;
cross-compiled to WebAssembly with Emscripten, with threads and native-speed exceptions enabled.&lt;/p&gt;

&lt;p&gt;Exception handling is worth a footnote of its own. The engine, every dependency, and the final link all&lt;br&gt;
have to agree on the same exception model, and when they disagree you get link errors that point at&lt;br&gt;
nothing useful. Everything here is built against native WebAssembly exceptions. Getting that consistent&lt;br&gt;
across the whole tree cost real time before a single frame rendered.&lt;/p&gt;

&lt;p&gt;The interesting engineering is at the seams, where desktop assumptions meet browser reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenGL became WebGL2.&lt;/strong&gt; The renderer's shaders were ported to GLES/WebGL2 semantics, and character
animation moved to &lt;strong&gt;GPU skinning&lt;/strong&gt;. OpenSceneGraph was the single hardest dependency in the stack and
it earned its own patch series.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The filesystem became a streaming virtual filesystem.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State became persistent browser storage.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop-only APIs got stubbed at the boundary.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0aqeocpupvf2v3sw0hl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff0aqeocpupvf2v3sw0hl.webp" alt="Third-person gameplay: the player character walks up stone steps into the town of Seyda Neen, rendered live in a browser tab" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;GPU skinning, per-pixel lighting and real-time shadows, all running client-side in a tab.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs that only exist on the web
&lt;/h2&gt;

&lt;p&gt;Most of what broke was WebGL2 and OpenGL ES being far stricter than desktop GL, while OpenSceneGraph&lt;br&gt;
quietly assumed desktop behaviour underneath.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The world was too dark.&lt;/strong&gt; Interiors and exteriors came out badly underlit, because of how the sun and&lt;br&gt;
lighting parameters were reaching the shaders. Flattening them into plain uniforms the GLES path&lt;br&gt;
actually accepts brought the world back to proper brightness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Equipping a weapon froze the game.&lt;/strong&gt; Native OpenMW hands work to background threads. Block the main&lt;br&gt;
thread in a browser and you don't stall a worker, you freeze the entire tab. A work queue was waiting on&lt;br&gt;
a result that could never arrive, so that path now runs inline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shadows crawled.&lt;/strong&gt; Shadows shimmered and swam as the camera turned. It looked like a precision problem&lt;br&gt;
and it wasn't: the cause was the cascaded shadow map's cascade transitions. We collapsed to a single&lt;br&gt;
cascade with a 16384 map, which keeps the shadow texel grid locked in world space by construction.&lt;/p&gt;

&lt;p&gt;We verified that mechanically rather than by eye. An instrumented 360-degree rotation measured the texel&lt;br&gt;
size per cascade at every yaw and it never moved: 0.000244 at the near cascade, 0.000488 and 0.000977&lt;br&gt;
further out, constant through a full turn. A grid that never shifts relative to the world cannot crawl.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot32tbc97w41bcaxhgn0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fot32tbc97w41bcaxhgn0.webp" alt="Real-time shadows and reflective water rendered by OpenMW inside a browser tab" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Stable shadows and real-time water reflections, the two things that fought hardest.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faces turned into cones.&lt;/strong&gt; Morph-based facial animation was disabled on the web because it fired&lt;br&gt;
spikes out of characters' heads. The cause was how the morph geometry's vertex arrays were laid out.&lt;br&gt;
Putting all of them on one dedicated vertex buffer, the way the skinned geometry already did, fixed it,&lt;br&gt;
and faces went back on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post-processing wedged the shader compiler.&lt;/strong&gt; The modern post stack, bloom included, refused to link.&lt;br&gt;
Several separate GLES incompatibilities were stacked on top of each other: a fragment output binding call&lt;br&gt;
that WebGL rejects and that was silently wedging program linking, struct uniforms that had to be&lt;br&gt;
flattened by hand, float render targets that had to be requested explicitly, and, in the bloom shader,&lt;br&gt;
a file-scope global initialised from a runtime value. That last one is illegal in GLSL ES and killed the&lt;br&gt;
link every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Antialiasing: the fix we were proud of, and then mostly couldn't use
&lt;/h2&gt;

&lt;p&gt;This is the most honest story in the port, so it gets its own section.&lt;/p&gt;

&lt;p&gt;We got hardware MSAA working, and it was a real fight. Two stacked bugs in OpenSceneGraph's GLES path&lt;br&gt;
had it broken. First, the per-colour-attachment resolve blit was wrapped in a preprocessor guard that&lt;br&gt;
compiled it out entirely on GLES, while OpenMW was attaching its resolve target in a way that path never&lt;br&gt;
handled. Second, even after that, the blit function OSG wanted to call is a null pointer under&lt;br&gt;
Emscripten, so the whole resolve had been gated off. We folded the colour resolve into the main blit and&lt;br&gt;
called the WebGL2 blit directly. MSAA rendered clean and antialiased at 60fps with a clean GL error&lt;br&gt;
state. Verified.&lt;/p&gt;

&lt;p&gt;And then we mostly couldn't use it. &lt;strong&gt;MSAA falls apart under post-processing on WebGL2&lt;/strong&gt;, and the modern&lt;br&gt;
pipeline runs post-processing by default. So the antialiasing you actually get from the Options dropdown&lt;br&gt;
is SSAA: the scene renders at a higher resolution and scales back down. It is cheap on the GPU and hungry&lt;br&gt;
on memory, hungry enough that at full retina resolution the supersampled buffers ran the tab out of&lt;br&gt;
memory during boot until we capped the effective pixel count. The dropdown offers Off, 1.5x and 2x, and&lt;br&gt;
it stays under the ceiling.&lt;/p&gt;

&lt;p&gt;Both paths are genuinely in there. MSAA works and is verified. SSAA is what most people will actually&lt;br&gt;
run, because it survives the pipeline we ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then we didn't stop at "it runs"
&lt;/h2&gt;

&lt;p&gt;Being that deep in the renderer already, we spent the remaining headroom making it look better than it&lt;br&gt;
did in 2002:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Radial and distance fog with sky blending, so the horizon reads as atmosphere instead of a hard wall.&lt;/li&gt;
&lt;li&gt;Per-pixel lighting across the world, in place of the original per-vertex lighting.&lt;/li&gt;
&lt;li&gt;Alpha-to-coverage on foliage, so leaves have clean edges instead of harsh cutouts.&lt;/li&gt;
&lt;li&gt;Anisotropic filtering up to 16x, so ground textures stay sharp at grazing angles.&lt;/li&gt;
&lt;li&gt;Real-time water reflections. Reflection buffers cannot be multisampled on GLES, so rather than ship a
jagged reflection we blur it in the shader.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fofqpfuwa41faieqeif3m.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fofqpfuwa41faieqeif3m.webp" alt="The Bitter Coast rendered in a browser tab: water reflections, distance fog and a silt strider on the horizon" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Distance fog, sky blending and shader-blurred water reflections on the Bitter Coast.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;Steady 60fps across the world, including a full walk through Balmora with no hitching. The engine is CPU&lt;br&gt;
bound on draw submission rather than GPU bound, which is a good problem: it means the headroom went into&lt;br&gt;
the rendering work above. Memory holds at about 1.5GB in Balmora, the densest town in the game, and does&lt;br&gt;
not grow. Saves persist, mods load, gamepads work through the same SDL input path the native build uses.&lt;/p&gt;

&lt;p&gt;One honest limitation: this is a Chromium target. We are not chasing Firefox or Safari parity right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Bring your own data", the pattern that matters most
&lt;/h2&gt;

&lt;p&gt;Morrowind's game data is commercial, so the port ships a free example world plus a launcher that lets you&lt;br&gt;
point the engine at &lt;strong&gt;your own copy of the game&lt;/strong&gt; using the browser's File System Access API. The engine&lt;br&gt;
reads your files directly from your disk.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzx1g77uvji0n279pilkd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzx1g77uvji0n279pilkd.webp" alt="The openmw-web launcher offering a free example world or loading your own Morrowind data files from disk" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Two doors: a free example world, or your own install read straight off disk. Nothing uploads.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read that again from a business angle: a browser application working against local, sensitive data, with&lt;br&gt;
nothing uploaded anywhere. The compute runs client-side and the data never leaves the machine. That is&lt;br&gt;
precisely how a browser-based engineering or analysis tool can handle confidential project files. You get&lt;br&gt;
the web app's convenience without the "our files on someone's cloud" objection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the browser version does better
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distribution is a link.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates are invisible.&lt;/strong&gt; Engine fixes land for every player on next load. Nobody is stranded on an
old build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progress follows the browser profile.&lt;/strong&gt; Saves survive without a launcher account or manual save
management.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a game proves the business case
&lt;/h2&gt;

&lt;p&gt;Nobody's line-of-business app is harder on a browser than a real-time 3D engine. If WebGL2 rendering,&lt;br&gt;
physics, streamed assets, audio and persistent state hold up in a tab, a forms-and-documents desktop&lt;br&gt;
application is comfortably within reach. A Qt-based professional tool sits in between, which is exactly&lt;br&gt;
where our &lt;a href="https://virtastic.app/blog/freecad-full-qt-gui-in-webassembly" rel="noopener noreferrer"&gt;FreeCAD&lt;/a&gt; work picked up the same patterns: the&lt;br&gt;
shader work, the virtual filesystem, the persistence layer. That one is still in preview. This one you&lt;br&gt;
can play right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://morrowind.virtastic.app" rel="noopener noreferrer"&gt;Play it in your browser&lt;/a&gt; (desktop Chromium works best), read the source on&lt;br&gt;
&lt;a href="https://github.com/Virtastic/openmw-web" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and if you have a desktop application you'd like to see running in a tab,&lt;br&gt;
&lt;a href="https://virtastic.app/contact" rel="noopener noreferrer"&gt;book a meeting&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webassembly</category>
      <category>cpp</category>
      <category>gamedev</category>
    </item>
  </channel>
</rss>
