v0.7.0: SDL-free wasm worker backend (WebGPU + WebGL2) with on-demand assets

The engine now runs on a plain Web Worker (no SDL, no PROXY_TO_PTHREAD: in a pthread Emscripten proxies every filesystem syscall to the main browser thread, where synchronous XHR - and thus FS.createLazyFile - is forbidden). A page/worker pair transfers an OffscreenCanvas and forwards DOM input; assets load lazily from an embedded manifest, so entering a map fetches only that map and its tiles.

Presentation: ra3.webgpu (WebGPU via Emscripten's emdawnwebgpu port, WGSL shaders, the default on wasm) and ra3.wasmgl (WebGL2, GLSL ES). The legacy SDL ra3.webgl backend is removed.
This commit is contained in:
EnderTheCoder
2026-09-29 12:08:07 +08:00
parent 70d4beca4f
commit 889ce945f2
27 changed files with 2161 additions and 639 deletions
+101
View File
@@ -0,0 +1,101 @@
<!doctype html>
<!-- OpenRA3 web page. The engine runs inside openra3.worker.js: the page owns the
DOM (input, resize) and transfers #canvas to the worker as an OffscreenCanvas,
so the engine never blocks the main thread. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>OpenRA3</title>
<style>
html, body { margin: 0; height: 100%; background: #1a0a08; overflow: hidden; }
#canvas { display: block; width: 100vw; height: 100vh; outline: none; cursor: default; }
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #ecbe50; font: 16px/1.4 monospace; pointer-events: none; }
</style>
</head>
<body>
<canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas>
<div id="loading">Loading OpenRA3…</div>
<script>
(function () {
var canvas = document.getElementById('canvas');
var loading = document.getElementById('loading');
// `?nocache` busts the worker, module and wasm fetches (development),
// so a rebuild is picked up without clearing the browser cache.
var bust = location.search.indexOf('nocache') >= 0 ? Date.now() : 0;
var worker = new Worker('openra3.worker.js' + (bust ? '?v=' + bust : ''));
var ready = false;
var pending = []; // input queued until the runtime is up
function post(message) {
if (ready) {
worker.postMessage(message);
} else {
pending.push(message);
}
}
function backingSize() {
var dpr = window.devicePixelRatio || 1;
return {
width: Math.max(1, Math.round(window.innerWidth * dpr)),
height: Math.max(1, Math.round(window.innerHeight * dpr)),
};
}
worker.onmessage = function (event) {
var data = event.data || {};
if (data.log) { console.log(data.log); return; }
if (data.ready) {
ready = true;
if (loading) loading.remove();
for (var i = 0; i < pending.length; i++) worker.postMessage(pending[i]);
pending = [];
return;
}
if (typeof data.openra3Frames === 'number') { window.openra3Frames = data.openra3Frames; }
};
// Hand the canvas to the worker as an OffscreenCanvas. After this the
// page can no longer size or draw it; the worker owns it.
var size = backingSize();
var offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen, width: size.width, height: size.height, bust: bust }, [offscreen]);
// ---- input: the page owns the DOM, the worker owns the game --------
window.addEventListener('keydown', function (event) {
post({ type: 'key', code: event.code, down: true });
if (event.code === 'Tab' || event.code === 'Space' || event.code.indexOf('Arrow') === 0) event.preventDefault();
}, false);
window.addEventListener('keyup', function (event) {
post({ type: 'key', code: event.code, down: false });
}, false);
window.addEventListener('keypress', function (event) {
if (event.charCode) post({ type: 'text', char: event.charCode });
}, false);
canvas.addEventListener('mousedown', function (event) {
post({ type: 'mousebutton', x: event.clientX, y: event.clientY, left: event.button === 0 });
}, false);
window.addEventListener('mousemove', function (event) {
post({ type: 'mousemove', x: event.clientX, y: event.clientY, dx: event.movementX, dy: event.movementY,
left: (event.buttons & 1) !== 0 });
}, false);
window.addEventListener('wheel', function (event) {
post({ type: 'wheel', deltaY: event.deltaY });
}, { passive: true });
window.addEventListener('contextmenu', function (event) { event.preventDefault(); });
window.addEventListener('resize', function () {
var next = backingSize();
post({ type: 'resize', width: next.width, height: next.height });
});
window.addEventListener('beforeunload', function () {
if (ready) worker.postMessage({ type: 'quit' });
});
})();
</script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
'use strict';
// OpenRA3 WebAssembly worker bootstrap.
//
// The whole Emscripten runtime runs here (not on the page main thread), so the
// runtime's filesystem is local to this worker: `FS.createLazyFile` can use
// synchronous XHR to fetch assets on demand without blocking the page.
//
// Protocol with index.html:
// page -> worker { canvas, width, height } (once, transfers the OffscreenCanvas)
// page -> worker { type: 'key' | 'text' | 'mousebutton' | 'mousemove' | 'wheel' | 'resize' | 'quit', ... }
// worker -> page { log } | { ready } | { openra3Frames }
//
// The Emscripten runtime is started by importScripts('openra3.js') once the
// canvas has arrived; main() then runs here and yields via Asyncify.
// Forward worker warnings/errors (including WebGPU validation messages from the
// runtime) to the page, which logs them to the main console.
(function () {
var origError = console.error.bind(console);
var origWarn = console.warn.bind(console);
console.error = function () {
origError.apply(null, arguments);
try { self.postMessage({ log: '[worker error] ' + Array.prototype.join.call(arguments, ' ') }); } catch (e) {}
};
console.warn = function () {
origWarn.apply(null, arguments);
try { self.postMessage({ log: '[worker warn] ' + Array.prototype.join.call(arguments, ' ') }); } catch (e) {}
};
})();
self.Module = {
print: function (text) {
self.postMessage({ log: String(text) });
},
printErr: function (text) {
self.postMessage({ log: String(text) });
},
onRuntimeInitialized: function () {
self.postMessage({ ready: true });
},
};
var openra3Started = false;
self.onmessage = function (event) {
var data = event.data;
if (!data) return;
if (!openra3Started) {
if (!data.canvas) return;
openra3Started = true;
var canvas = data.canvas;
if (data.width && data.height) {
canvas.width = data.width;
canvas.height = data.height;
}
self.Module.canvas = canvas;
var start = function () {
// `?nocache` on the page forces a fresh module + wasm fetch, so a
// rebuild is picked up without clearing the browser cache.
var bust = data.bust ? ('?v=' + data.bust) : '';
if (bust) self.Module.locateFile = function (path) { return path + bust; };
try {
importScripts('openra3.js' + bust);
} catch (error) {
self.postMessage({ log: 'OpenRA3 worker failed to start: ' + error });
}
};
// The WebGPU backend starts from a device created here (`Module.
// preinitializedWebGPUDevice`); the WebGL backend ignores it. If WebGPU
// is unavailable the runtime falls back to WebGL.
if (typeof navigator !== 'undefined' && navigator.gpu) {
navigator.gpu.requestAdapter().then(function (adapter) {
if (!adapter) { start(); return; }
adapter.requestDevice().then(function (device) {
self.Module.preinitializedWebGPUDevice = device;
start();
}).catch(start);
}).catch(start);
} else {
start();
}
return;
}
// Input is only meaningful once the runtime has initialized its exports.
switch (data.type) {
case 'key':
if (self.Module.ccall) self.Module.ccall('openra3_key', null, ['string', 'number'], [data.code, data.down ? 1 : 0]);
break;
case 'text':
if (self.Module._openra3_text) self.Module._openra3_text(data.char | 0);
break;
case 'mousebutton':
if (self.Module._openra3_mouse_button) self.Module._openra3_mouse_button(data.x, data.y, data.left ? 1 : 0);
break;
case 'mousemove':
if (self.Module._openra3_mouse_move) self.Module._openra3_mouse_move(data.x, data.y, data.dx, data.dy, data.left ? 1 : 0);
break;
case 'wheel':
if (self.Module._openra3_wheel) self.Module._openra3_wheel(data.deltaY);
break;
case 'resize':
if (self.Module._openra3_resize) self.Module._openra3_resize(data.width | 0, data.height | 0);
break;
case 'quit':
if (self.Module._openra3_quit) self.Module._openra3_quit();
break;
default:
break;
}
};
+33 -8
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env python3
"""Static file server with HTTP Range support, for the OpenRA3 wasm build.
Emscripten's lazy MEMFS reads assets back as byte ranges, so the server must
honour the ``Range`` header -- Python's stdlib ``http.server`` does not.
Emscripten's lazy file reads stream assets back as byte ranges, so the server
must honour the ``Range`` header -- Python's stdlib ``http.server`` does not.
python3 apps/web/serve.py --root build/wasm/bin --port 8199
# then open http://localhost:8199/openra3.html
# then open http://localhost:8199/index.html
Serve ``--root`` containing ``openra3.html/.js/.wasm``, ``assets.manifest.json``
and the extracted ``assets/`` tree (a directory or a junction/symlink to it).
Serve ``--root`` containing ``index.html``, ``openra3.js``, ``openra3.wasm``,
``openra3.worker.js`` and ``assets.manifest.json``. The lazy asset tree is
mounted from ``--assets`` (its files are then fetched from ``/assets/<rel>`` on
demand); without ``--assets`` the tree is expected under ``--root`` itself.
"""
import argparse
@@ -22,11 +24,27 @@ _RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
class RangeHandler(SimpleHTTPRequestHandler):
# SharedArrayBuffer (needed by the pthreads build) requires cross-origin
# isolation.
# Directory served for ``/assets/...`` (set in main); None means "under root".
assets_dir = None
def translate_path(self, path):
if self.assets_dir:
prefix = "/assets/"
if path == "/assets" or path.startswith(prefix):
rel = path[len(prefix):] if path.startswith(prefix) else ""
rel = rel.split("?", 1)[0].split("#", 1)[0]
parts = [p for p in rel.split("/") if p and p != ".."]
return os.path.join(self.assets_dir, *parts)
return super().translate_path(path)
# Cross-origin isolation (kept for SharedArrayBuffer-style features and the
# WebGPU/pthread paths). no-store keeps the dev loop honest: a stale
# openra3.js/.wasm in the browser cache hides new builds (the worker's
# importScripts especially).
def end_headers(self):
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
self.send_header("Cache-Control", "no-store, max-age=0")
super().end_headers()
def send_head(self):
@@ -86,6 +104,7 @@ class RangeHandler(SimpleHTTPRequestHandler):
def main() -> int:
parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server")
parser.add_argument("--root", default="build/wasm/bin", help="directory to serve")
parser.add_argument("--assets", default=None, help="directory served for /assets (the lazy asset tree)")
parser.add_argument("--port", type=int, default=8199)
parser.add_argument("--bind", default="127.0.0.1")
args = parser.parse_args()
@@ -93,9 +112,15 @@ def main() -> int:
if not os.path.isdir(args.root):
print(f"root not found: {args.root}", file=sys.stderr)
return 1
if args.assets and not os.path.isdir(args.assets):
print(f"assets not found: {args.assets}", file=sys.stderr)
return 1
RangeHandler.assets_dir = os.path.abspath(args.assets) if args.assets else None
handler = functools.partial(RangeHandler, directory=args.root)
server = ThreadingHTTPServer((args.bind, args.port), handler)
print(f"serving {args.root} at http://{args.bind}:{args.port}/openra3.html")
print(f"serving {args.root} at http://{args.bind}:{args.port}/index.html")
if RangeHandler.assets_dir:
print(f" /assets -> {RangeHandler.assets_dir}")
try:
server.serve_forever()
except KeyboardInterrupt:
-33
View File
@@ -1,33 +0,0 @@
<!doctype html>
<!-- Emscripten shell for OpenRA3. SDL3 (built for Emscripten) renders into the
canvas element below; the SCRIPT placeholder is replaced by the generated
loader. Note: the shell preprocessor treats a leading hash as a directive,
so do not begin a line with one. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>OpenRA3</title>
<style>
html, body { margin: 0; height: 100%; background: #1a0a08; overflow: hidden; }
#canvas { display: block; width: 100vw; height: 100vh; outline: none; }
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: #ecbe50; font: 16px/1.4 monospace; pointer-events: none; }
</style>
</head>
<body>
<canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas>
<div id="loading">Loading OpenRA3…</div>
<script>
// Emscripten merges this into the generated Module; drop the loading
// placeholder once the runtime is up.
var Module = {
onRuntimeInitialized: function () {
var el = document.getElementById('loading');
if (el) el.remove();
}
};
</script>
{{{ SCRIPT }}}
</body>
</html>