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:
+27
-11
@@ -121,7 +121,7 @@ namespace {
|
||||
std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]");
|
||||
std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]");
|
||||
std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
|
||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgl] [--sdl] (preferred display backend)");
|
||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgpu] [--wasmgl] [--sdl] (preferred display backend)");
|
||||
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
|
||||
}
|
||||
|
||||
@@ -140,7 +140,8 @@ namespace {
|
||||
[[nodiscard]] auto display_backend_from_args(const std::vector<std::string> &args) -> ra3::display::backend {
|
||||
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) return ra3::display::backend::d3d11;
|
||||
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12;
|
||||
if (has_flag(args, "--webgl")) return ra3::display::backend::webgl;
|
||||
if (has_flag(args, "--webgpu")) return ra3::display::backend::webgpu;
|
||||
if (has_flag(args, "--wasmgl")) return ra3::display::backend::wasmgl;
|
||||
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
|
||||
return ra3::display::backend::vulkan;
|
||||
}
|
||||
@@ -149,7 +150,9 @@ namespace {
|
||||
auto executable_dir(const char *argv0) -> std::filesystem::path {
|
||||
std::error_code ec;
|
||||
const auto path = std::filesystem::absolute(argv0, ec);
|
||||
return ec ? std::filesystem::current_path() : path.parent_path();
|
||||
// lexically_normal drops the "." element Emscripten leaves in argv[0]
|
||||
// ("/./openra3.js" -> "/"), which the wasm FS does not collapse itself.
|
||||
return ec ? std::filesystem::current_path() : path.parent_path().lexically_normal();
|
||||
}
|
||||
|
||||
auto read_file(const std::filesystem::path &path) -> std::vector<ra3::core::uint8> {
|
||||
@@ -610,10 +613,12 @@ namespace {
|
||||
* then the others in turn, so an unavailable backend degrades gracefully.
|
||||
* Direct3D is Windows-only; on other hosts those entries fall through.
|
||||
*/
|
||||
inline constexpr std::array<ra3::display::backend, 5> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||
ra3::display::backend::d3d12, ra3::display::backend::webgl,
|
||||
ra3::display::backend::sdl};
|
||||
inline constexpr std::array<std::string_view, 5> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGL", "SDL (software)"};
|
||||
inline constexpr std::array<ra3::display::backend, 6> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||
ra3::display::backend::d3d12, ra3::display::backend::webgpu,
|
||||
ra3::display::backend::wasmgl, ra3::display::backend::sdl};
|
||||
inline constexpr std::array<std::string_view, 6> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGPU", "WebGL (worker)",
|
||||
"SDL (software)"};
|
||||
inline constexpr int renderer_count = static_cast<int>(renderer_backends.size());
|
||||
|
||||
[[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend {
|
||||
return renderer_backends[static_cast<std::size_t>(std::clamp(index, 0, static_cast<int>(renderer_backends.size()) - 1))];
|
||||
@@ -698,7 +703,7 @@ namespace {
|
||||
case 15: return s.fullscreen ? "yes" : "no";
|
||||
case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps";
|
||||
case 17: return "start";
|
||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 4))]};
|
||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, renderer_count - 1))]};
|
||||
default: return "exit";
|
||||
}
|
||||
}
|
||||
@@ -721,7 +726,7 @@ namespace {
|
||||
case 13: s.thumbnail = !s.thumbnail; break;
|
||||
case 15: s.fullscreen = !s.fullscreen; break;
|
||||
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
|
||||
case 18: s.renderer = (s.renderer + delta + 5) % 5; break;
|
||||
case 18: s.renderer = (s.renderer + delta + renderer_count) % renderer_count; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
@@ -1121,8 +1126,9 @@ namespace {
|
||||
if (has_flag(args, "--vulkan")) st.settings.renderer = 0;
|
||||
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1;
|
||||
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2;
|
||||
if (has_flag(args, "--webgl")) st.settings.renderer = 3;
|
||||
if (has_flag(args, "--sdl")) st.settings.renderer = 4;
|
||||
if (has_flag(args, "--webgpu")) st.settings.renderer = 3;
|
||||
if (has_flag(args, "--wasmgl")) st.settings.renderer = 4;
|
||||
if (has_flag(args, "--sdl")) st.settings.renderer = 5;
|
||||
if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value);
|
||||
if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value);
|
||||
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
|
||||
@@ -1259,6 +1265,16 @@ auto main(int argc, char **argv) -> int {
|
||||
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
|
||||
const auto assets = exe_dir / "assets";
|
||||
setup_logging(exe_dir);
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// The wasm build runs on a Web Worker (its runtime's main thread lives
|
||||
// there), where synchronous XHR is legal, so the asset tree is mounted
|
||||
// lazily from a manifest instead of being preloaded: the browser fetches
|
||||
// only the files the engine actually opens. On wasm the executable dir is
|
||||
// "/", so the manifest sits at /assets.manifest.json and the lazy file URLs
|
||||
// are "assets/<rel>" relative to the page.
|
||||
const int mounted = ra3::wasmgl::mount_assets((exe_dir / "assets.manifest.json").string(), assets.string(), "assets/");
|
||||
ender::log::info(std::format("wasm assets: {} files mounted lazily under {}", mounted, assets.string()));
|
||||
#endif
|
||||
|
||||
if (args.empty()) return command_menu({}, assets);
|
||||
|
||||
|
||||
@@ -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