v0.6.0: WebAssembly build with a WebGL backend (playable in the browser)

- ra3.webgl: a Web peer of Vulkan/Direct3D (SDL3 canvas + GLES3/WebGL2), with
  2D blit and the GPU terrain raymarch; GLSL ES shaders; null fallback off
  Emscripten. backend::webgl + default_backend() (webgl under Emscripten).
- Emscripten toolchain + Dockerfile.wasm + scripts/build-wasm.sh; import std,
  global -fexceptions, and Asyncify so the blocking frame loop yields to the
  browser (display::sleep_frame calls emscripten_sleep).
- apps/web/shell.html + apps/web/serve.py (Range-capable dev server).
- Assets: browsers forbid synchronous on-demand reads on the main thread (and
  FS.createLazyFile / the WasmFS fetch backend are worker-only; SDL3's Emscripten
  backend is main-thread DOM only, so the engine cannot run in a worker). The
  wasm build therefore preloads a compact per-map set via OPENRA3_WEB_ASSETS.
- New openra3 textures --map ID lists the loose terrain TGAs a map resolves to
  (terrain::resolve_texture_files, shared with load_textures_from_dir).
- Fixes: webgl heightmap used GL_R16 = 0x8229 (that is R8) -> upload rejected,
  terrain flattened to water; now 0x822A. Logger uses a stdout console sink on
  the web (stderr maps to console.error); a GL error check logs bad uploads.
- CI: wasm image + build jobs.
This commit is contained in:
EnderTheCoder
2026-09-28 22:57:19 +08:00
parent d0ae4c76ba
commit 70d4beca4f
22 changed files with 1341 additions and 72 deletions
+46 -7
View File
@@ -95,7 +95,15 @@ namespace {
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
#if defined(__EMSCRIPTEN__)
// The default sink writes to stderr, which the browser maps to
// console.error regardless of level; use stdout so INFO/WARN appear at
// their real level. There is no file sink on the web.
log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
(void) exe_dir;
#else
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
#endif
log::info("OpenRA3 started");
}
@@ -106,13 +114,14 @@ namespace {
std::puts(" openra3 menu (pick a map, then view it)");
std::puts(" openra3 menu-preview [--out FILE.bmp] (headless render of the menu)");
std::puts(" openra3 maps");
std::puts(" openra3 textures --map ID (loose terrain TGAs a map uses)");
std::puts(" openra3 skirmish [--map ID] [--frames N] [--seed N]");
std::puts(" openra3 render [--map ID] [--frames N] [--seed N] [--zoom Z]");
std::puts(" [--out FILE.bmp] [--vulkan] [--no-window] [--no-sim] [--thumbnail]");
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] [--sdl] (preferred display backend)");
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgl] [--sdl] (preferred display backend)");
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
}
@@ -131,6 +140,7 @@ 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, "--sdl")) return ra3::display::backend::sdl;
return ra3::display::backend::vulkan;
}
@@ -296,6 +306,32 @@ namespace {
return ready;
}
/** Print the loose terrain TGA files a map resolves to (for a wasm preload). */
auto command_textures(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto requested = option_value(args, "--map");
if (!requested) {
std::puts("usage: openra3 textures --map ID");
return 2;
}
const auto maps = list_asset_maps(assets);
const asset_map *picked = nullptr;
for (const auto &map: maps) {
if (map.id == *requested) picked = &map;
}
if (picked == nullptr) {
std::printf("no map '%s'\n", requested->c_str());
return 2;
}
const auto parsed = ra3::terrain::parse_map(ra3::map::to_ckmp(read_file(picked->file)));
const auto files = ra3::terrain::resolve_texture_files(parsed, assets / "terrain");
std::printf("map %s: %zu resolved textures\n", picked->id.c_str(), files.size());
for (const auto &file: files) {
std::printf("%s\n", file.string().c_str());
}
return 0;
}
auto command_maps(const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
@@ -574,9 +610,10 @@ 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, 4> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
ra3::display::backend::d3d12, ra3::display::backend::sdl};
inline constexpr std::array<std::string_view, 4> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"};
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)"};
[[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))];
@@ -661,7 +698,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, 3))]};
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 4))]};
default: return "exit";
}
}
@@ -684,7 +721,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 + 4) % 4; break;
case 18: s.renderer = (s.renderer + delta + 5) % 5; break;
default: break;
}
}
@@ -1084,7 +1121,8 @@ 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, "--sdl")) st.settings.renderer = 3;
if (has_flag(args, "--webgl")) st.settings.renderer = 3;
if (has_flag(args, "--sdl")) st.settings.renderer = 4;
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);
@@ -1243,6 +1281,7 @@ auto main(int argc, char **argv) -> int {
if (command == "menu") return command_menu(rest, assets);
if (command == "menu-preview") return command_menu_preview(rest, assets);
if (command == "maps") return command_maps(assets);
if (command == "textures") return command_textures(rest, assets);
if (command == "skirmish") return command_skirmish(rest, assets);
if (command == "render") return command_render(rest, assets);
+107
View File
@@ -0,0 +1,107 @@
#!/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.
python3 apps/web/serve.py --root build/wasm/bin --port 8199
# then open http://localhost:8199/openra3.html
Serve ``--root`` containing ``openra3.html/.js/.wasm``, ``assets.manifest.json``
and the extracted ``assets/`` tree (a directory or a junction/symlink to it).
"""
import argparse
import functools
import os
import re
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
_RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
class RangeHandler(SimpleHTTPRequestHandler):
# SharedArrayBuffer (needed by the pthreads build) requires cross-origin
# isolation.
def end_headers(self):
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
super().end_headers()
def send_head(self):
header = self.headers.get("Range")
if not header:
return super().send_head()
path = self.translate_path(self.path)
if os.path.isdir(path):
return super().send_head()
try:
handle = open(path, "rb")
except OSError:
self.send_error(404, "File not found")
return None
match = _RANGE.match(header.strip())
if not match:
handle.close()
return super().send_head()
size = os.fstat(handle.fileno()).st_size
start = int(match.group(1)) if match.group(1) else 0
end = int(match.group(2)) if match.group(2) else size - 1
if start > end or start >= size:
handle.close()
self.send_error(416, "Requested Range Not Satisfiable")
return None
end = min(end, size - 1)
self.send_response(206)
self.send_header("Content-Type", self.guess_type(path))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Content-Length", str(end - start + 1))
self.send_header("Cache-Control", "no-store")
self.end_headers()
handle.seek(start)
self._remaining = end - start + 1
return handle
def copyfile(self, source, outputfile):
remaining = getattr(self, "_remaining", None)
if remaining is None:
return super().copyfile(source, outputfile)
try:
while remaining > 0:
chunk = source.read(min(65536, remaining))
if not chunk:
break
outputfile.write(chunk)
remaining -= len(chunk)
finally:
self._remaining = None
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("--port", type=int, default=8199)
parser.add_argument("--bind", default="127.0.0.1")
args = parser.parse_args()
if not os.path.isdir(args.root):
print(f"root not found: {args.root}", file=sys.stderr)
return 1
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")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
+33
View File
@@ -0,0 +1,33 @@
<!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>