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
+112 -41
View File
@@ -57,11 +57,9 @@ set(OPENRA3_HAS_SDL3 OFF)
# (`<root>/<triple>/{include,lib,bin}`); x86_64 unless a toolchain overrides it.
set(OPENRA3_SDL3_MINGW_TRIPLE "x86_64-w64-mingw32" CACHE STRING "SDL3 MinGW triple subdirectory")
if(EMSCRIPTEN)
# Emscripten's SDL3 web port provides the headers and the (static) library
# via the `-sUSE_SDL=3` flag, on every target that uses SDL.
target_compile_options(openra3_sdl3 INTERFACE -sUSE_SDL=3)
target_link_options(openra3_sdl3 INTERFACE -sUSE_SDL=3)
set(OPENRA3_HAS_SDL3 ON)
# The wasm build runs the engine on a Web Worker and renders into an
# OffscreenCanvas (ra3.webgpu / ra3.wasmgl), so it links no SDL: SDL3's
# Emscripten port is main-thread DOM only and cannot run in a worker.
elseif(OPENRA3_SDL3_ROOT)
target_include_directories(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/include")
target_link_libraries(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/lib/libSDL3.dll.a")
@@ -169,6 +167,25 @@ function(openra3_embed_glsl out_header)
file(WRITE "${out_header}" "${content}")
endfunction()
# Embed the WGSL sources for the WebGPU backend as string literals (compiled at
# runtime by the browser's WebGPU, like the GLSL/HLSL for the other backends).
function(openra3_embed_wgsl out_header)
set(content "// Generated by CMake from shaders/*.wgsl.\n")
string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n")
foreach(src IN LISTS ARGN)
if(NOT EXISTS "${src}")
message(FATAL_ERROR "Missing WGSL shader ${src}")
endif()
get_filename_component(base "${src}" NAME_WE)
string(MAKE_C_IDENTIFIER "${base}" ident)
string(APPEND ident "_wgsl")
file(READ "${src}" text)
string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3WGSL(${text})RA3WGSL\";\n\n")
endforeach()
string(APPEND content "} // namespace ra3_shaders\n")
file(WRITE "${out_header}" "${content}")
endfunction()
# --- logging (vendored libenderlog, MIT) -------------------------------------
# A standalone C++26 module logger (`import ender.log;`) with a per-run
# archiving file sink. libc++ has no <stacktrace>, so on this toolchain the
@@ -309,23 +326,20 @@ else()
endif()
openra3_target_defaults(ra3_dx)
# --- WebGL viewer (Emscripten/WebAssembly, or null fallback) ------------------
# A peer of the Vulkan/Direct3D backends; built only for the wasm target, where
# it uses an SDL3 canvas + GLES 3.0 (WebGL2). Elsewhere it is the null stub.
option(OPENRA3_WEBGL "Build the WebGL viewer (Emscripten)" ON)
set(OPENRA3_HAS_WEBGL OFF)
if(OPENRA3_WEBGL AND EMSCRIPTEN)
set(OPENRA3_HAS_WEBGL ON)
# --- wasm worker viewer (SDL-free OffscreenCanvas + WebGL2, or null fallback) --
# The default wasm backend: the engine runs on a plain Web Worker and renders
# into an OffscreenCanvas transferred from the page, with no SDL. Non-wasm builds
# link the null stub (init fails, so desktop backends are unaffected).
option(OPENRA3_WASMGL "Build the SDL-free wasm worker viewer (Emscripten)" ON)
set(OPENRA3_HAS_WASMGL OFF)
if(OPENRA3_WASMGL AND EMSCRIPTEN)
set(OPENRA3_HAS_WASMGL ON)
endif()
if(OPENRA3_HAS_WEBGL)
message(STATUS "OpenRA3: WebGL viewer enabled")
else()
message(STATUS "OpenRA3: WebGL viewer disabled - null backend")
endif()
add_library(ra3_webgl STATIC)
if(OPENRA3_HAS_WEBGL)
add_library(ra3_wasmgl STATIC)
if(OPENRA3_HAS_WASMGL)
message(STATUS "OpenRA3: SDL-free wasm worker viewer enabled")
openra3_embed_glsl(
"${CMAKE_CURRENT_BINARY_DIR}/generated/glsl_embedded.hpp"
"${CMAKE_CURRENT_BINARY_DIR}/generated/wasmgl_glsl_embedded.hpp"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_vert.glsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
@@ -336,28 +350,68 @@ if(OPENRA3_HAS_WEBGL)
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_frag.glsl")
target_sources(ra3_webgl PUBLIC FILE_SET CXX_MODULES FILES src/webgl/ra3.webgl.cppm)
target_include_directories(ra3_webgl PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(ra3_webgl PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog openra3_sdl3)
target_sources(ra3_wasmgl PUBLIC FILE_SET CXX_MODULES FILES src/wasmgl/ra3.wasmgl.cppm)
target_include_directories(ra3_wasmgl PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(ra3_wasmgl PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog)
# Target WebGL2 so GLES3-only entry points (glTexImage3D, VAOs, ...) link.
target_link_options(ra3_webgl PUBLIC -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2)
target_link_options(ra3_wasmgl PUBLIC -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2)
else()
target_sources(ra3_webgl PUBLIC FILE_SET CXX_MODULES FILES src/webgl/ra3.webgl.null.cppm)
target_link_libraries(ra3_webgl PUBLIC ra3_core ra3_render ra3_client)
message(STATUS "OpenRA3: SDL-free wasm worker viewer disabled (non-wasm) - null backend")
target_sources(ra3_wasmgl PUBLIC FILE_SET CXX_MODULES FILES src/wasmgl/ra3.wasmgl.null.cppm)
target_link_libraries(ra3_wasmgl PUBLIC ra3_core ra3_render ra3_client)
endif()
openra3_target_defaults(ra3_webgl)
openra3_target_defaults(ra3_wasmgl)
# --- wasm WebGPU viewer (Dawn emdawnwebgpu, or null fallback) ------------------
# A peer of ra3.wasmgl in the same Web Worker; web WebGPU is WGSL-only, so it has
# its own WGSL shaders (the Vulkan SPIR-V blobs cannot be used on the web). The
# `--use-port=emdawnwebgpu` port is downloaded/cached by Emscripten and linked
# into the final module.
option(OPENRA3_WEBGPU "Build the WebGPU wasm viewer (Emscripten emdawnwebgpu)" ON)
set(OPENRA3_HAS_WEBGPU OFF)
if(OPENRA3_WEBGPU AND EMSCRIPTEN)
set(OPENRA3_HAS_WEBGPU ON)
endif()
add_library(ra3_webgpu STATIC)
if(OPENRA3_HAS_WEBGPU)
message(STATUS "OpenRA3: WebGPU wasm viewer enabled (emdawnwebgpu)")
openra3_embed_wgsl(
"${CMAKE_CURRENT_BINARY_DIR}/generated/webgpu_wgsl_embedded.hpp"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_scene.wgsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_terrain.wgsl"
)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_scene.wgsl"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_terrain.wgsl")
target_sources(ra3_webgpu PUBLIC FILE_SET CXX_MODULES FILES src/webgpu/ra3.webgpu.cppm)
target_include_directories(ra3_webgpu PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
# CMake's module dependency scanner does not process `--use-port`, so point
# the include path at the (image-baked) port package explicitly.
set(OPENRA3_EMDAWNWEBGPU_DIR "$ENV{EMSDK}/upstream/emscripten/cache/ports/emdawnwebgpu/emdawnwebgpu_pkg")
target_include_directories(ra3_webgpu SYSTEM PRIVATE
"${OPENRA3_EMDAWNWEBGPU_DIR}/webgpu_cpp/include"
"${OPENRA3_EMDAWNWEBGPU_DIR}/webgpu/include")
target_compile_options(ra3_webgpu PUBLIC --use-port=emdawnwebgpu)
target_link_options(ra3_webgpu PUBLIC --use-port=emdawnwebgpu)
target_link_libraries(ra3_webgpu PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_wasmgl ra3_enderlog)
else()
message(STATUS "OpenRA3: WebGPU wasm viewer disabled (non-wasm) - null backend")
target_sources(ra3_webgpu PUBLIC FILE_SET CXX_MODULES FILES src/webgpu/ra3.webgpu.null.cppm)
target_link_libraries(ra3_webgpu PUBLIC ra3_core ra3_render ra3_client)
endif()
openra3_target_defaults(ra3_webgpu)
# --- display backend selection ------------------------------------------------
add_library(ra3_display STATIC)
target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm)
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx ra3_webgl)
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx ra3_wasmgl ra3_webgpu)
openra3_target_defaults(ra3_display)
# --- umbrella -----------------------------------------------------------------
add_library(ra3 STATIC)
target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display
ra3_ui ra3_vulkan ra3_dx ra3_webgl ra3_enderlog)
ra3_ui ra3_vulkan ra3_dx ra3_wasmgl ra3_webgpu ra3_enderlog)
openra3_target_defaults(ra3)
# --- executable ---------------------------------------------------------------
@@ -373,19 +427,36 @@ if(WIN32 AND OPENRA3_SDL3_ROOT)
endif()
if(EMSCRIPTEN)
# Emit a ready-to-serve openra3.html that loads the module and drives the
# #canvas element SDL3 renders into.
set_target_properties(openra3 PROPERTIES SUFFIX ".html")
# Emit openra3.js/.wasm. The engine runs inside apps/web/openra3.worker.js
# (a plain Web Worker), which the page (apps/web/index.html) loads and hands
# an OffscreenCanvas; so there is no Emscripten HTML shell here - both web
# files are copied next to the module.
set_target_properties(openra3 PROPERTIES SUFFIX ".js")
target_link_options(openra3 PRIVATE
"--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/apps/web/shell.html"
-sFORCE_FILESYSTEM=1)
# Preload a compact assets directory into the virtual filesystem at /assets,
# where the runtime looks next to the module. Prefer a per-map set (one map +
# the tiles it uses, listed by `openra3 textures --map ID`): browsers forbid
# synchronous on-demand file reads on the main thread, so bundling a small
# set is the portable choice.
set(OPENRA3_WEB_ASSETS "" CACHE PATH "Assets directory preloaded into the wasm FS at /assets")
if(OPENRA3_WEB_ASSETS AND EXISTS "${OPENRA3_WEB_ASSETS}")
-sFORCE_FILESYSTEM=1
# index.html input glue calls the exported openra3_* functions via ccall.
"-sEXPORTED_RUNTIME_METHODS=ccall,cwrap")
add_custom_command(TARGET openra3 POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_CURRENT_SOURCE_DIR}/apps/web/index.html"
"${CMAKE_CURRENT_SOURCE_DIR}/apps/web/openra3.worker.js"
"$<TARGET_FILE_DIR:openra3>"
COMMENT "Copying the OpenRA3 web page and worker bootstrap")
# On-demand assets: `OPENRA3_WEB_MANIFEST` is a JSON `{"files": [...]}` listing
# every asset relative to the assets root (generate it with
# scripts/make_web_manifest.py). Only that small manifest is preloaded, to
# /assets.manifest.json; at startup the worker registers every listed file as
# an Emscripten lazy file, so the browser fetches each asset when the engine
# first opens it (serve the tree at <root>/assets, or pass serve.py --assets).
set(OPENRA3_WEB_MANIFEST "" CACHE FILEPATH "Assets manifest JSON, embedded as /assets.manifest.json for lazy loading")
if(OPENRA3_WEB_MANIFEST AND EXISTS "${OPENRA3_WEB_MANIFEST}")
target_link_options(openra3 PRIVATE "--embed-file=${OPENRA3_WEB_MANIFEST}@/assets.manifest.json")
endif()
# Legacy: preload a whole assets directory into the virtual FS at /assets (the
# monolithic path the lazy worker backend replaces). Only used when no manifest
# is given, since lazy files cannot shadow a preloaded tree.
set(OPENRA3_WEB_ASSETS "" CACHE PATH "Assets directory preloaded into the wasm FS at /assets (legacy)")
if(NOT (OPENRA3_WEB_MANIFEST AND EXISTS "${OPENRA3_WEB_MANIFEST}") AND OPENRA3_WEB_ASSETS AND EXISTS "${OPENRA3_WEB_ASSETS}")
target_link_options(openra3 PRIVATE "--preload-file=${OPENRA3_WEB_ASSETS}@/assets")
endif()
endif()