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:
@@ -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>
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user