diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 88bd14f..22e4b05 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -326,7 +326,7 @@ build_wasm: - retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-wasm - docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake -S . -B build/wasm -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release - docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake --build build/wasm -j - - docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm bash -c "mkdir -p artifacts/wasm && cp build/wasm/bin/openra3.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm build/wasm/bin/openra3.data artifacts/wasm/ 2>/dev/null || cp build/wasm/bin/openra3.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm artifacts/wasm/" + - docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm bash -c "mkdir -p artifacts/wasm && cp build/wasm/bin/index.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm build/wasm/bin/openra3.worker.js artifacts/wasm/ && cp build/wasm/bin/openra3.data artifacts/wasm/ 2>/dev/null || true" artifacts: name: "$CI_PROJECT_NAME-wasm-$CI_COMMIT_SHORT_SHA" paths: diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a2a6a6..0452a38 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -57,11 +57,9 @@ set(OPENRA3_HAS_SDL3 OFF) # (`//{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 , 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" + "$" + 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 /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() diff --git a/Dockerfile.wasm b/Dockerfile.wasm index 391f8b5..9199cd7 100644 --- a/Dockerfile.wasm +++ b/Dockerfile.wasm @@ -1,6 +1,8 @@ # syntax=docker/dockerfile:1 -# WebAssembly build environment (Emscripten), producing openra3.html/.js/.wasm. +# WebAssembly build environment (Emscripten), producing openra3.js/.wasm plus the +# `index.html` page and `openra3.worker.js` bootstrap. The engine runs on a plain +# Web Worker (WebGL2 and WebGPU backends), with lazy on-demand assets. # # The emsdk image ships CMake 3.28, whose experimental `import std` support is # too old for the project, so an upstream CMake is layered on top. @@ -37,6 +39,14 @@ RUN mkdir -p /opt/cmake \ | tar -xz -C /opt/cmake --strip-components=1 ENV PATH="/opt/cmake/bin:${PATH}" +# Pre-fetch the emdawnwebgpu port into the image. The WebGPU backend needs +# , and CMake's module dependency scanner does not process +# `--use-port`, so a warm cache is required before the project is configured. +RUN . /emsdk/emsdk_env.sh \ + && printf '#include \nint main(){}\n' > /tmp/wgpu_port.cpp \ + && em++ -c /tmp/wgpu_port.cpp -o /tmp/wgpu_port.o --use-port=emdawnwebgpu \ + && rm -f /tmp/wgpu_port.cpp /tmp/wgpu_port.o + WORKDIR /work COPY . . diff --git a/README.md b/README.md index cda86e6..50e23fe 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ extracting ore and fighting until one side is wiped out. The balance is the **retail Red Alert 3 balance**, pinned in `ra3.data` from EA's open RA3 XML: damage types, `ArmorTemplate` percentages, weapon target masks, build costs and the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`), **Direct3D 11 / 12** -(`ra3.dx`) and **WebGL 2** (`ra3.webgl`, the wasm build) GPU backends with an SDL +(`ra3.dx`), **WebGPU** (`ra3.webgpu`, the wasm worker backend) and **WebGL 2** +(`ra3.wasmgl`, the SDL-free wasm worker fallback) GPU backends with an SDL software blit and null fallbacks; the backend is selectable from the in-game menu and the software renderer still produces headless images. Terrain tiles cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a @@ -96,7 +97,8 @@ built-in test map so the project still builds and runs in CI. | `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) | | `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) | | `src/dx/ra3.dx.*.cppm` | Direct3D 11/12 presentation backend (runtime HLSL; null fallback off Windows) | -| `src/webgl/ra3.webgl.*.cppm` | WebGL2 presentation backend for the wasm build (GLSL ES; null fallback off Emscripten) | +| `src/wasmgl/ra3.wasmgl.*.cppm` | SDL-free wasm backend: engine on a Web Worker, WebGL2 on an OffscreenCanvas, lazy assets (null fallback off Emscripten) | +| `src/webgpu/ra3.webgpu.*.cppm` | WebGPU wasm backend (Dawn `emdawnwebgpu`, WGSL; the default on wasm, falls back to `ra3.wasmgl`) | | `third_party/libenderlog/` | vendored C++26 module logger (`import ender.log;`, MIT) with a native stack fallback for libc++ | | `src/ra3.cppm` | umbrella module re-exporting the SDK | | `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI | @@ -141,7 +143,7 @@ scripts/build-windows.sh # Windows (arm64) -> build/windows-arm64/bin/openra3.exe + SDL3.dll, .zip (cross) scripts/build-windows-arm64.sh -# WebAssembly -> build/wasm/bin/openra3.html (+ .js/.wasm) +# WebAssembly -> build/wasm/bin/index.html (+ openra3.js/.wasm, openra3.worker.js) scripts/build-wasm.sh ``` @@ -183,31 +185,36 @@ runtime, so neither image needs a Vulkan SDK. ## WebAssembly OpenRA3 also builds to **WebAssembly** with Emscripten (`scripts/build-wasm.sh`), -producing `openra3.html` + `.js` + `.wasm`. The browser gets the same GPU renderer -as the desktop builds: a **`ra3.webgl`** backend peers with Vulkan/Direct3D — SDL3 -provides the canvas and input, and GLES 3.0 / WebGL2 runs the 2D blit and the -terrain raymarcher (GLSL ES ports of the desktop shaders). +producing `openra3.js` + `.wasm` plus the `index.html` page and +`openra3.worker.js` bootstrap. The browser gets the same GPU renderer as the +desktop builds: the engine runs on a **Web Worker** (no SDL, no blocking of the +page) and draws into an **OffscreenCanvas** handed over by the page. The default +wasm backend is **`ra3.webgpu`** (WebGPU, WGSL shaders via Emscripten's +`emdawnwebgpu` port); it falls back to **`ra3.wasmgl`** (WebGL2, the GLSL ES +ports of the desktop shaders) when WebGPU is unavailable. The page keeps the DOM +(input, resize) and forwards events to the worker, so the main thread stays +responsive. -Browsers forbid synchronous on-demand file reads on the main thread, so the -assets are **preloaded into the module's filesystem** at build time. Bundle a -compact per-map set (one map plus the tiles it uses) rather than the whole dump: +Because the runtime's main thread lives in the worker, its filesystem is local +to it and assets load **on demand**: point `OPENRA3_WEB_ASSETS` at the extracted +asset tree and the build embeds an `assets.manifest.json`; at startup the worker +registers every listed file as a lazy file, so the browser fetches a file only +when the engine first opens it (entering a map pulls just that map and the tiles +it uses, not the whole tree). ```bash -# 1. which loose TGAs does the map resolve to? -openra3 textures --map map_mp_2_feasel1 # prints paths under assets/terrain +# build (generates the manifest from the extracted assets, embeds it) +OPENRA3_WEB_ASSETS=/path/to/assets scripts/build-wasm.sh -# 2. stage one map + those tiles (+ maps/map_names.tsv) into a directory, then -OPENRA3_WEB_ASSETS=/path/to/that/set scripts/build-wasm.sh - -# 3. serve the build output (a Range-capable server is included) -python3 apps/web/serve.py --root build/wasm/bin --port 8199 -# open http://localhost:8199/openra3.html +# serve the build output (Range-capable, COOP/COEP; --assets is the lazy tree) +python3 apps/web/serve.py --root build/wasm/bin --assets /path/to/assets +# open http://localhost:8199/index.html ``` -The engine's frame loops are blocking; the wasm build yields to the browser via -Asyncify (`display::sleep_frame` calls `emscripten_sleep`), so the page repaints -and handles input. Log records go to `console.log` at their real level (the -console sink uses stdout on the web instead of stderr). Without +The engine's frame loops are blocking; the wasm build yields to the worker's +event loop via Asyncify (`display::sleep_frame` calls `emscripten_sleep`), so +the page repaints and handles input. Log records go to `console.log` at their +real level (the console sink uses stdout on the web instead of stderr). Without `OPENRA3_WEB_ASSETS` the module still loads and starts, but exits at the menu because no maps are found. diff --git a/VERSION b/VERSION index 09a3acf..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 \ No newline at end of file +0.7.0 diff --git a/apps/openra3/main.cpp b/apps/openra3/main.cpp index 570daa9..ec3e9c2 100644 --- a/apps/openra3/main.cpp +++ b/apps/openra3/main.cpp @@ -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 /assets (extracted at build time by the openra3_assets target)"); } @@ -140,7 +140,8 @@ namespace { [[nodiscard]] auto display_backend_from_args(const std::vector &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 { @@ -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 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 renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGL", "SDL (software)"}; + inline constexpr std::array 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 renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGPU", "WebGL (worker)", + "SDL (software)"}; + inline constexpr int renderer_count = static_cast(renderer_backends.size()); [[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend { return renderer_backends[static_cast(std::clamp(index, 0, static_cast(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::clamp(s.fps_index, 0, 4))]} + " fps"; case 17: return "start"; - case 18: return std::string{renderer_names[static_cast(std::clamp(s.renderer, 0, 4))]}; + case 18: return std::string{renderer_names[static_cast(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/" 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); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..25f7339 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,101 @@ + + + + + + + OpenRA3 + + + + +
Loading OpenRA3…
+ + + diff --git a/apps/web/openra3.worker.js b/apps/web/openra3.worker.js new file mode 100644 index 0000000..d517be0 --- /dev/null +++ b/apps/web/openra3.worker.js @@ -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; + } +}; diff --git a/apps/web/serve.py b/apps/web/serve.py index ba8dc8c..5efe57f 100644 --- a/apps/web/serve.py +++ b/apps/web/serve.py @@ -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/`` 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: diff --git a/apps/web/shell.html b/apps/web/shell.html deleted file mode 100644 index 9c1c4e3..0000000 --- a/apps/web/shell.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - OpenRA3 - - - - -
Loading OpenRA3…
- - {{{ SCRIPT }}} - - diff --git a/cmake/toolchains/emscripten-wasm.cmake b/cmake/toolchains/emscripten-wasm.cmake index eb0437b..fd8d134 100644 --- a/cmake/toolchains/emscripten-wasm.cmake +++ b/cmake/toolchains/emscripten-wasm.cmake @@ -12,8 +12,17 @@ # libc++ `std` module must be built with the same setting as its consumers, # or clang rejects the prebuilt module ("configuration mismatch"). The engine # throws, so exceptions are enabled everywhere. -# * The SDL3 web port (`-sUSE_SDL=3`) is attached by CMake to the SDL interface -# target, not here, so only the targets that use SDL pay for it. +# * Asyncify turns `emscripten_sleep` (used by display::sleep_frame on +# Emscripten) into a yield: the engine's frame loops are blocking, and without +# yielding the worker freezes and never paints. +# +# No pthreads: the runtime's main thread runs inside a plain Web Worker started +# by `apps/web/openra3.worker.js`, so its virtual filesystem is local to that +# worker and `FS.createLazyFile` (synchronous XHR) is legal. Under +# `PROXY_TO_PTHREAD` Emscripten proxies every filesystem syscall to the main +# browser thread, where synchronous XHR is forbidden, so lazy assets cannot work +# there - hence the plain worker. The SDL3 web port is only pulled in by the +# legacy main-thread backend (OPENRA3_WASM_SDL). # `import std` support: the gate and the module switch must be in place before # Emscripten.cmake runs and before the compiler is probed. @@ -37,7 +46,7 @@ include("${OPENRA3_EMSCRIPTEN_CMAKE}") # # Asyncify turns `emscripten_sleep` (used by display::sleep_frame on Emscripten) # into a yield to the browser: the engine's frame loops are blocking, and -# without yielding the page freezes and never paints. +# without yielding the worker freezes and never paints. set(CMAKE_CXX_FLAGS_INIT "-fexceptions -sASYNCIFY=1") set(CMAKE_C_FLAGS_INIT "-sASYNCIFY=1") set(CMAKE_EXE_LINKER_FLAGS_INIT diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21632e0..eca8083 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,8 +63,8 @@ umbrella module re-exports the SDK; applications import `ra3` only. | \ v | ra3.display (backend pick) | / | \ \ - v ra3.ui ra3.vulkan ra3.dx ra3.webgl - | (SDL3) (Vulkan) (D3D11/12) (WebGL2) + v ra3.ui ra3.vulkan ra3.dx ra3.webgpu / ra3.wasmgl + | (SDL3) (Vulkan) (D3D11/12) (wasm workers) --------------------------------------------------------------------------- v L2 simulation ra3.logic ra3.modules ra3.combat ra3.movement @@ -80,7 +80,7 @@ umbrella module re-exports the SDK; applications import `ra3` only. The current concrete modules (`ra3.core`, `ra3.logic`, `ra3.data`, `ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`, -`ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgl.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the +`ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgpu.*`, `ra3.wasmgl.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the vendored `ender.log`) are the **seeds** of the target modules below. `ra3.skirmish` and `ra3.game` will be absorbed into `ra3.ai` / `ra3.match`; new modules are added as their subsystems are recovered. @@ -595,17 +595,44 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this. - **F5 Null fallback** `[D]` - `[x]` non-Windows builds link a stub that fails `init` -### M18c `ra3.webgl` — WebGL backend `[P]` +### M18d `ra3.wasmgl` — SDL-free wasm worker backend `[P]` - **F1 Device & canvas** `[D]` - - `[x]` Emscripten-only; SDL3 canvas + GLES 3.0 (WebGL2) via `-sUSE_SDL=3` + - `[x]` Emscripten-only; engine runs on a plain Web Worker, WebGL2 on an + OffscreenCanvas transferred by the page (no SDL, no `PROXY_TO_PTHREAD`) + - `[x]` `specialHTMLTargets['#canvas']` lets `emscripten_webgl_create_context` find + the worker's OffscreenCanvas - **F2 2D image path** `[D]` - - `[x]` BGRA texture + fullscreen triangle (GLSL ES 300), `.bgra` swizzle + - `[x]` GLSL ES BGRA texture + fullscreen triangle (`.bgra` swizzle) - **F3 Terrain presentation** `[D]` - - `[x]` GPU heightfield raymarch (GLSL ES port of `terrain.frag`) -- **F4 Wasm packaging** `[~]` - - `[x]` `openra3.html/.js/.wasm`, assets preloaded into the FS at `/assets` - - `[ ]` async/fetch streaming assets instead of a single preload blob `(v0.6)` + - `[x]` GPU heightfield raymarch (GLSL ES), including the `GL_R16` + (`0x822A`) heightmap and `GL_RGBA16` cell textures +- **F4 On-demand assets** `[D]` + - `[x]` `assets.manifest.json` (from `scripts/make_web_manifest.py`) is embedded; + the worker registers every file with `FS.createLazyFile`, so only opened files + are fetched (a map pulls just its map + tiles) +- **F5 Input** `[D]` + - `[x]` page owns DOM listeners and posts events to the worker's exported + `openra3_*` functions (no DOM callbacks in the worker) +- **F6 Null fallback** `[D]` + - `[x]` non-Emscripten builds link a stub that fails `init` + +### M18e `ra3.webgpu` — WebGPU wasm backend `[P]` + +- **F1 Device & surface** `[D]` + - `[x]` Emscripten `emdawnwebgpu` port (Dawn C++ wrapper); the worker pre-creates + the device (`Module.preinitializedWebGPUDevice`) and a surface is configured on + the transferred OffscreenCanvas +- **F2 WGSL shaders** `[D]` + - `[x]` web WebGPU is WGSL-only, so the scene/terrain shaders are WGSL ports + (the Vulkan SPIR-V blobs cannot be reused); the uniform layout and terrain data + plumbing are shared with Vulkan/WebGL +- **F3 Terrain** `[D]` + - `[x]` GPU heightfield raymarch in WGSL (R16Uint heightmap, RGBA16Uint cell + record, RGBA8 atlas) +- **F4 Selection & fallback** `[D]` + - `[x]` preferred wasm backend; falls back to `ra3.wasmgl` when no adapter is + available - **F5 Null fallback** `[D]` - `[x]` non-Emscripten builds link a stub that fails `init` diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index a6151cd..0992e76 100644 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash -# Build the WebAssembly target (openra3.html/.js/.wasm) with the Emscripten image. +# Build the WebAssembly target (openra3.js/.wasm + worker + page) with the +# Emscripten image. # -# Browsers forbid synchronous on-demand file reads on the main thread, so assets -# are preloaded into the module's filesystem. Prefer a compact, per-map set: one -# map plus the tiles it uses: +# The engine runs inside a plain Web Worker and renders into an OffscreenCanvas +# with no SDL, so assets load on demand: point OPENRA3_WEB_ASSETS at the +# extracted asset tree and the build generates an assets manifest, embeds it, and +# registers every file as an Emscripten lazy file at startup. The browser then +# fetches each asset only when the engine opens it. # -# OPENRA3_WEB_ASSETS="$(mktemp -d)" \ -# openra3 textures --map map_mp_2_feasel1 # copy these under the set's terrain/ +# OPENRA3_WEB_ASSETS=/path/to/assets scripts/build-wasm.sh # -# then point OPENRA3_WEB_ASSETS at that directory. +# open http://localhost:8199/index.html set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -19,13 +21,18 @@ docker build --target dev -f "$ROOT/Dockerfile.wasm" -t "$IMAGE" "$ROOT" mounts=(-v "$ROOT:/work") configure=(-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release) +serve_assets=() if [ -n "${OPENRA3_WEB_ASSETS:-}" ]; then mounts+=(-v "$OPENRA3_WEB_ASSETS:/assets:ro") - configure+=(-DOPENRA3_WEB_ASSETS=/assets) + manifest="$ROOT/$BUILD_DIR/assets.manifest.json" + mkdir -p "$(dirname "$manifest")" + python3 "$ROOT/scripts/make_web_manifest.py" --root "$OPENRA3_WEB_ASSETS" --out "$manifest" + configure+=(-DOPENRA3_WEB_MANIFEST="/work/$BUILD_DIR/assets.manifest.json") + serve_assets=(--assets "$OPENRA3_WEB_ASSETS") fi docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja "${configure[@]}" docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j -echo "WebAssembly: $ROOT/$BUILD_DIR/bin/openra3.html (+ .js/.wasm/.data)" -echo "serve: python3 \"$ROOT/apps/web/serve.py\" --root \"$ROOT/$BUILD_DIR/bin\" --port 8199" +echo "WebAssembly: $ROOT/$BUILD_DIR/bin/index.html (+ openra3.js/.wasm, openra3.worker.js)" +echo "serve: python3 \"$ROOT/apps/web/serve.py\" --root \"$ROOT/$BUILD_DIR/bin\" ${serve_assets[*]:-}" diff --git a/scripts/make_web_manifest.py b/scripts/make_web_manifest.py new file mode 100644 index 0000000..b70d026 --- /dev/null +++ b/scripts/make_web_manifest.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Generate the wasm lazy-asset manifest. + +The wasm build runs the engine on a worker, where synchronous XHR is legal, so +assets are registered with ``FS.createLazyFile`` instead of being preloaded: the +browser fetches each file only when the engine first opens it. This script lists +every file under the assets root (as POSIX-relative paths) as +``{"files": [...]}``; CMake preloads the manifest to ``/assets.manifest.json``. + + scripts/make_web_manifest.py --root /path/to/assets --out assets.manifest.json + +Serve the tree at ``/assets`` (or use ``serve.py --assets``). +""" + +import argparse +import json +import os +import sys + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate the OpenRA3 wasm asset manifest") + parser.add_argument("--root", required=True, help="assets root to enumerate") + parser.add_argument("--out", required=True, help="manifest JSON to write") + args = parser.parse_args() + + if not os.path.isdir(args.root): + print(f"root not found: {args.root}", file=sys.stderr) + return 1 + + files = [] + for dirpath, dirnames, filenames in os.walk(args.root): + dirnames.sort() + for name in sorted(filenames): + rel = os.path.relpath(os.path.join(dirpath, name), args.root).replace(os.sep, "/") + files.append(rel) + + with open(args.out, "w", encoding="utf-8") as handle: + json.dump({"files": files}, handle, separators=(",", ":")) + handle.write("\n") + print(f"wrote {args.out} ({len(files)} files)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shaders/dx_terrain.hlsl b/shaders/dx_terrain.hlsl index f46ac3f..b600b9a 100644 --- a/shaders/dx_terrain.hlsl +++ b/shaders/dx_terrain.hlsl @@ -130,17 +130,19 @@ float4 PSMain(VSOut input) : SV_Target { return float4(sky_color(dir), 1.0); } - // March the heightfield (bounded work: the step grows toward the horizon). + // March the heightfield. The step grows quickly: the map diagonal is only + // ~9000 world units, so marching past ~20000 adds cost without detail, and + // a 6% growth rate more than doubles the worst-case iteration count. float t = CELL * 0.5; float dt = CELL * 0.5; float prev = t; bool hit = false; float hit_t = 0.0; - for (int i = 0; i < 512 && t < 60000.0; ++i) { + for (int i = 0; i < 256 && t < 20000.0; ++i) { float3 w = cam_pos + dir * t; if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { prev = t; - dt *= 1.06; + dt *= 1.10; t += dt; continue; } @@ -155,7 +157,7 @@ float4 PSMain(VSOut input) : SV_Target { break; } prev = t; - dt *= 1.06; + dt *= 1.10; t += dt; } if (!hit) { diff --git a/shaders/generated/terrain.frag.spv b/shaders/generated/terrain.frag.spv index 6a15d0b..1a5c5a2 100644 Binary files a/shaders/generated/terrain.frag.spv and b/shaders/generated/terrain.frag.spv differ diff --git a/shaders/terrain.frag b/shaders/terrain.frag index 873b0f0..176afce 100644 --- a/shaders/terrain.frag +++ b/shaders/terrain.frag @@ -116,20 +116,22 @@ void main() { return; } - // March the heightfield (bounded work: the step grows toward the horizon). + // March the heightfield. The step grows quickly: the map diagonal is only + // ~9000 world units, so marching past ~20000 adds cost without detail, and + // a 6% growth rate more than doubles the worst-case iteration count. float t = CELL * 0.5; float dt = CELL * 0.5; float prev = t; bool hit = false; float hit_t = 0.0; - for (int i = 0; i < 512 && t < 60000.0; ++i) { + for (int i = 0; i < 256 && t < 20000.0; ++i) { vec3 w = cam + dir * t; if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { - prev = t; dt *= 1.06; t += dt; continue; + prev = t; dt *= 1.10; t += dt; continue; } if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; } if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; } - prev = t; dt *= 1.06; t += dt; + prev = t; dt *= 1.10; t += dt; } if (!hit) { out_color = vec4(sky_color(dir), 1.0); return; } diff --git a/shaders/webgl_terrain_frag.glsl b/shaders/webgl_terrain_frag.glsl index a8d8f22..732339d 100644 --- a/shaders/webgl_terrain_frag.glsl +++ b/shaders/webgl_terrain_frag.glsl @@ -109,16 +109,19 @@ void main() { return; } + // March the heightfield. The step grows quickly: the map diagonal is only + // ~9000 world units, so marching past ~20000 adds cost without detail, and + // a 6% growth rate more than doubles the worst-case iteration count. float t = CELL * 0.5; float dt = CELL * 0.5; float prev = t; bool hit = false; float hit_t = 0.0; - for (int i = 0; i < 512 && t < 60000.0; ++i) { + for (int i = 0; i < 256 && t < 20000.0; ++i) { vec3 w = cam + dir * t; if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { prev = t; - dt *= 1.06; + dt *= 1.10; t += dt; continue; } @@ -133,7 +136,7 @@ void main() { break; } prev = t; - dt *= 1.06; + dt *= 1.10; t += dt; } if (!hit) { diff --git a/shaders/webgpu_scene.wgsl b/shaders/webgpu_scene.wgsl new file mode 100644 index 0000000..65371cb --- /dev/null +++ b/shaders/webgpu_scene.wgsl @@ -0,0 +1,38 @@ +// WebGPU counterpart of the 2D image path (the WGSL port of +// webgl_scene_vert/frag.glsl and the Vulkan scene shaders). +// +// Bind group 0: uniform { rect: vec4; }, the scene texture and a sampler. +// WebGPU clip space is +Y up, matching GL, so the vertex flips Y the same way. + +struct SceneUniforms { + rect: vec4, // xy = top-left (0..1), zw = size (0..1) +}; + +@group(0) @binding(0) var u_scene: SceneUniforms; +@group(0) @binding(1) var u_scene_tex: texture_2d; +@group(0) @binding(2) var u_scene_samp: sampler; + +struct SceneOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> SceneOut { + let p = vec2(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u)); + var out: SceneOut; + out.uv = (p - u_scene.rect.xy) / u_scene.rect.zw; + out.position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0); + return out; +} + +@fragment +fn fs_main(in: SceneOut) -> @location(0) vec4 { + // Only the destination rect is drawn; outside it the previous contents stay. + if (in.uv.x < 0.0 || in.uv.x > 1.0 || in.uv.y < 0.0 || in.uv.y > 1.0) { + discard; + } + // The image is 0xAARRGGBB (BGRA in memory); swizzle it back. + let c = textureSampleLevel(u_scene_tex, u_scene_samp, in.uv, 0.0); + return vec4(c.b, c.g, c.r, c.a); +} diff --git a/shaders/webgpu_terrain.wgsl b/shaders/webgpu_terrain.wgsl new file mode 100644 index 0000000..3fa072a --- /dev/null +++ b/shaders/webgpu_terrain.wgsl @@ -0,0 +1,229 @@ +// WebGPU counterpart of the GPU heightfield raymarcher (the WGSL port of +// webgl_terrain_frag.glsl / terrain.frag). The Vulkan push constants become a +// uniform buffer of five vec4s, laid out exactly as `ra3::wasmgl::detail::terrain_uniforms`. +// +// Bind group 0: uniform data[5], the R16 heightmap (u32), the RGBA16 cell record +// (vec4), the RGBA8 tile atlas (array), and an atlas sampler. + +struct TerrainUniforms { + data: array, 5>, +}; + +@group(0) @binding(0) var u: TerrainUniforms; +@group(0) @binding(1) var u_heightmap: texture_2d; // R16Uint heights +@group(0) @binding(2) var u_celldata: texture_2d; // RGBA16Uint blend record (texel is vec4) +@group(0) @binding(3) var u_atlas: texture_2d_array; // RGBA8 tile materials +@group(0) @binding(4) var u_atlas_samp: sampler; + +const CELL: f32 = 10.0; // must match ra3::terrain::cell_size + +fn cam_uniform() -> vec4 { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height +fn params_uniform() -> vec4 { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water +fn sun_uniform() -> vec4 { return u.data[2]; } // xyz=sun dir, w=ambient +fn mapinfo_uniform() -> vec4 { return u.data[3]; }// x=W, y=H, z=unused, w=z_scale +fn misc_uniform() -> vec4 { return u.data[4]; } // x=time, y=unused, z=cells per repeat, w=aspect + +struct TerrainOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> TerrainOut { + let p = vec2(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u)); + var out: TerrainOut; + out.uv = p; + out.position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0); + return out; +} + +fn height_at(cell: vec2) -> f32 { + let limit = vec2(mapinfo_uniform().xy) - vec2(1); + let c = clamp(cell, vec2(0), limit); + return f32(textureLoad(u_heightmap, c, 0).r) * mapinfo_uniform().w; +} + +fn world_height(wx: f32, wy: f32) -> f32 { + let world_w = mapinfo_uniform().x * CELL; + let world_h = mapinfo_uniform().y * CELL; + if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) { + return -1.0e9; + } + let c = vec2(i32(wx / CELL), i32((world_h - wy) / CELL)); + return height_at(c); +} + +fn sky_color(dir: vec3) -> vec3 { + let d = normalize(dir); + let sun_dir = normalize(sun_uniform().xyz); + let t = clamp(d.z, 0.0, 1.0); + let horizon = vec3(0.70, 0.78, 0.85); + let zenith = vec3(0.28, 0.48, 0.80); + var col = mix(horizon, zenith, pow(t, 0.6)); + let sun = max(dot(d, sun_dir), 0.0); + col += vec3(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6; + col += vec3(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18; + return col; +} + +// The retail SAGE blend ramp (see terrain.frag). +fn blend_factor(direction: u32, flags: u32, f_in: vec2) -> f32 { + var f = f_in; + let flipped = (flags & 1u) != 0u; + let two_sided = (flags & 2u) != 0u; + if (flipped) { + if (direction == 1u) { + f.x = 1.0 - f.x; + } else if (direction == 2u || direction == 4u || direction == 8u) { + f.y = 1.0 - f.y; + } + } + if (direction == 1u) { return f.x; } + if (direction == 2u) { return f.y; } + if (direction == 4u) { + let s = (1.0 - f.x) + (1.0 - f.y); + return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided); + } + if (direction == 8u) { + let s = f.x + (1.0 - f.y); + return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided); + } + return 0.0; +} + +fn sample_layer(layer: u32, wx: f32, wy: f32) -> vec3 { + let span = max(misc_uniform().z, 1.0); + let layer_count = textureNumLayers(u_atlas); + let l = f32(min(layer, layer_count - 1u)); + // The atlas is 0xAARRGGBB (BGRA in memory), uploaded as RGBA8. + let c = textureSampleLevel(u_atlas, u_atlas_samp, vec2(wx, wy) / span, i32(l), 0.0); + return vec3(c.b, c.g, c.r); +} + +@fragment +fn fs_main(in: TerrainOut) -> @location(0) vec4 { + let p = cam_uniform(); + let pitch = clamp(params_uniform().x, 0.15, 1.45); + let fov = clamp(params_uniform().y, 0.3, 1.4); + let world_w = mapinfo_uniform().x * CELL; + let world_h = mapinfo_uniform().y * CELL; + + let cp = cos(pitch); + let fwd = vec3(cp * sin(p.z), cp * cos(p.z), -sin(pitch)); + let right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))); + let up = cross(right, fwd); + + var target_z = world_height(p.x, p.y); + if (target_z < -1.0e8) { target_z = 0.0; } + let dist = p.w / sin(pitch); + let cam = vec3(p.x, p.y, target_z + p.w) - fwd * dist; + + let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0); + let aspect = misc_uniform().w; + let th = tan(fov * 0.5); + let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); + + if (dir.z >= -1e-4) { + return vec4(sky_color(dir), 1.0); + } + + // March the heightfield. The step grows quickly: the map diagonal is only + // ~9000 world units, so marching past ~20000 adds cost without detail. + var t = CELL * 0.5; + var dt = CELL * 0.5; + var prev = t; + var hit = false; + var hit_t = 0.0; + for (var i = 0; i < 256 && t < 20000.0; i = i + 1) { + let w = cam + dir * t; + if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { + prev = t; + dt *= 1.10; + t += dt; + continue; + } + if (params_uniform().w > 0.5 && w.z <= params_uniform().z) { + hit = true; + hit_t = t; + break; + } + if (w.z <= world_height(w.x, w.y)) { + hit = true; + hit_t = t; + break; + } + prev = t; + dt *= 1.10; + t += dt; + } + if (!hit) { + return vec4(sky_color(dir), 1.0); + } + + var lo = prev; + var hi = hit_t; + for (var i = 0; i < 6; i = i + 1) { + let mid = 0.5 * (lo + hi); + let w = cam + dir * mid; + let water = params_uniform().w > 0.5 && w.z <= params_uniform().z; + if (water || w.z <= world_height(w.x, w.y)) { + hi = mid; + } else { + lo = mid; + } + } + let hitpos = cam + dir * hi; + + let sun = normalize(sun_uniform().xyz); + let ambient = sun_uniform().w; + + if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) { + let time = misc_uniform().x; + let q = hitpos.xy * 0.015; + let nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3); + let ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9); + let n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0)); + let fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0); + let deep = vec3(0.03, 0.16, 0.28); + let refl = sky_color(reflect(dir, n)); + let lam = max(0.0, dot(n, sun)); + var water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9)); + water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; + let wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); + water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog); + return vec4(water, 1.0); + } + + let wx = hitpos.x / CELL; + let wy = (world_h - hitpos.y) / CELL; + let cx = clamp(i32(wx), 0, i32(mapinfo_uniform().x) - 1); + let cy = clamp(i32(wy), 0, i32(mapinfo_uniform().y) - 1); + let fx = wx - floor(wx); + let fy = wy - floor(wy); + + let record = textureLoad(u_celldata, vec2(cx, cy), 0); + let packed = record.w; + let dir1 = packed & 0xFu; + let flags1 = (packed >> 4u) & 0x3u; + let dir2 = (packed >> 8u) & 0xFu; + let flags2 = (packed >> 12u) & 0x3u; + + let frac_uv = vec2(fx, fy); + let c0 = sample_layer(record.x, wx, wy); + let c1 = sample_layer(record.y, wx, wy); + let c2 = sample_layer(record.z, wx, wy); + let f1 = blend_factor(dir1, flags1, frac_uv); + let f2 = blend_factor(dir2, flags2, frac_uv); + let albedo = mix(mix(c0, c1, f1), c2, f2); + + let hl = world_height(hitpos.x - CELL, hitpos.y); + let hr = world_height(hitpos.x + CELL, hitpos.y); + let hd = world_height(hitpos.x, hitpos.y - CELL); + let hu = world_height(hitpos.x, hitpos.y + CELL); + let n = normalize(vec3(hl - hr, hd - hu, 2.0 * CELL)); + let lambert = max(0.0, dot(n, sun)); + var lit = albedo * (ambient + (1.0 - ambient) * lambert); + let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); + lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog); + return vec4(lit, 1.0); +} diff --git a/src/display/ra3.display.cppm b/src/display/ra3.display.cppm index 6aec41d..3d4b43a 100644 --- a/src/display/ra3.display.cppm +++ b/src/display/ra3.display.cppm @@ -9,18 +9,19 @@ import ra3.client; import ra3.ui; import ra3.vulkan; import ra3.dx; -import ra3.webgl; +import ra3.wasmgl; +import ra3.webgpu; /** * Backend selection for the presentation layer. * * The app talks only to `ra3::client::display`; this module picks the concrete * backend so no caller has to know which one is in use. The available backends - * are Vulkan, Direct3D 11 and Direct3D 12, WebGL (all GPU terrain, available - * per platform) and the SDL software blit fallback. The caller may name a - * preferred backend (the in-game menu exposes this); if it does not start, the - * others are tried in turn. The GPU terrain path reports failure so the caller - * can fall back to the software renderer. + * are Vulkan, Direct3D 11 and Direct3D 12 (desktop), the WebGPU and SDL-free + * WebGL2 wasm worker backends (wasm) and the SDL software blit fallback. The + * caller may name a preferred backend (the in-game menu exposes this); if it + * does not start, the others are tried in turn. The GPU terrain path reports + * failure so the caller can fall back to the software renderer. */ export namespace ra3::display { using ra3::client::display_options; @@ -33,12 +34,12 @@ export namespace ra3::display { using camera_frame = std::function; /** A concrete presentation backend, or `none`. */ - enum class backend { none, vulkan, d3d11, d3d12, webgl, sdl }; + enum class backend { none, vulkan, d3d11, d3d12, wasmgl, webgpu, sdl }; - /** The backend preferred on this host: WebGL under Emscripten, else Vulkan. */ + /** The backend preferred on this host: WebGPU under Emscripten (falling back to the WebGL worker), else Vulkan. */ [[nodiscard]] inline constexpr auto default_backend() -> backend { #if defined(__EMSCRIPTEN__) - return backend::webgl; + return backend::webgpu; #else return backend::vulkan; #endif @@ -49,7 +50,8 @@ export namespace ra3::display { case backend::vulkan: return "vulkan"; case backend::d3d11: return "d3d11"; case backend::d3d12: return "d3d12"; - case backend::webgl: return "webgl"; + case backend::wasmgl: return "wasmgl"; + case backend::webgpu: return "webgpu"; case backend::sdl: return "sdl"; default: return "none"; } @@ -60,21 +62,23 @@ export namespace ra3::display { case backend::vulkan: return std::make_unique(); case backend::d3d11: return std::make_unique(); case backend::d3d12: return std::make_unique(); - case backend::webgl: return std::make_unique(); + case backend::wasmgl: return std::make_unique(); + case backend::webgpu: return std::make_unique(); case backend::sdl: return std::make_unique(); default: return nullptr; } } /** The order in which backends are attempted for a preferred one. */ - [[nodiscard]] inline auto backend_order(backend preferred) -> std::array { + [[nodiscard]] inline auto backend_order(backend preferred) -> std::array { switch (preferred) { - case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl, backend::sdl}; - case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgl, backend::sdl}; - case backend::webgl: return {backend::webgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl}; - case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl}; + case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl}; + case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl}; + case backend::wasmgl: return {backend::wasmgl, backend::webgpu, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl}; + case backend::webgpu: return {backend::webgpu, backend::wasmgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl}; + case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl}; case backend::vulkan: - default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgl, backend::sdl}; + default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgpu, backend::wasmgl, backend::sdl}; } } diff --git a/src/ra3.cppm b/src/ra3.cppm index a1d5071..f2e024e 100644 --- a/src/ra3.cppm +++ b/src/ra3.cppm @@ -19,5 +19,6 @@ export import ra3.terrain; export import ra3.ui; export import ra3.vulkan; export import ra3.dx; -export import ra3.webgl; +export import ra3.wasmgl; +export import ra3.webgpu; export import ra3.display; diff --git a/src/wasmgl/ra3.wasmgl.cppm b/src/wasmgl/ra3.wasmgl.cppm new file mode 100644 index 0000000..632d774 --- /dev/null +++ b/src/wasmgl/ra3.wasmgl.cppm @@ -0,0 +1,661 @@ +module; + +// Emscripten/GLES3 headers in the global module fragment (C headers). No SDL: +// this backend runs the engine on a Web Worker and renders into an +// OffscreenCanvas transferred from the page, so it must not touch the +// main-thread-only SDL Emscripten port. +#include + +#include +#include +#include + +// Sized normalized texture formats used by the terrain textures; core in +// WebGL2/GLES3 but missing from some GLES3 headers. +#ifndef GL_R16 +#define GL_R16 0x822A +#endif +#ifndef GL_RGBA16 +#define GL_RGBA16 0x805B +#endif + +#include "wasmgl_glsl_embedded.hpp" + +export module ra3.wasmgl; + +import std; + +export import ra3.core; +export import ra3.render; +export import ra3.terrain; +export import ra3.client; +import ender.log; + +/** + * An SDL-free WebGL 2 presentation backend for the wasm build. + * + * The whole runtime runs on a plain Web Worker (started by + * `apps/web/openra3.worker.js`), so `main()` lives there and its virtual + * filesystem is local to the worker: `FS.createLazyFile` (synchronous XHR) is + * legal and the browser fetches each asset only when the engine opens it. + * Note that Emscripten's `PROXY_TO_PTHREAD` cannot be used for this: in a + * pthread every filesystem syscall is proxied to the main browser thread, so + * the engine would read the main thread's FS, not the worker's lazy one. + * + * The page transfers its `#canvas` to the worker as an OffscreenCanvas; this + * module creates the WebGL2 context on it with `emscripten_webgl_create_context` + * and draws the same 2D menu / GPU terrain as the other backends. Input arrives + * over the worker message channel (the page owns the DOM listeners) into the + * `openra3_*` C functions below, which queue `ra3::render::ui_event`s. + * + * It reuses the same GL pipeline/texture code as the desktop GPU backends; only + * the windowing/context/event layer differs. + */ +export namespace ra3::wasmgl { + class wasmgl_display; + + /** + * Receives the input events the worker forwards from the page. Each wasm + * backend (WebGL here, WebGPU in `ra3.webgpu`) implements it and registers + * itself on init, so the `openra3_*` C exports live in exactly one module. + */ + class event_sink { + public: + virtual ~event_sink() = default; + virtual void on_key(std::string_view code, bool down) = 0; + virtual void on_text(int codepoint) = 0; + virtual void on_mouse_button(double x, double y, bool left) = 0; + virtual void on_mouse_move(double x, double y, double dx, double dy, bool left) = 0; + virtual void on_wheel(double delta_y) = 0; + virtual void on_resize(int width, int height) = 0; + virtual void on_quit() = 0; + }; + + namespace detail { + /** The backend that currently receives worker input. */ + [[nodiscard]] inline auto sink_slot() -> event_sink *& { + static event_sink *sink = nullptr; + return sink; + } + } // namespace detail + + /** Register (or, with null, clear) the backend that receives input events. */ + inline auto set_event_sink(event_sink *sink) -> void { detail::sink_slot() = sink; } + + namespace detail { + /** Movement keys tracked as "held" for panning (physical codes). */ + enum class move_key : std::uint8_t { w, s, a, d, arrow_up, arrow_down, arrow_left, arrow_right, count }; + + [[nodiscard]] inline auto move_key_index(std::string_view code) -> int { + if (code == "KeyW") return static_cast(move_key::w); + if (code == "KeyS") return static_cast(move_key::s); + if (code == "KeyA") return static_cast(move_key::a); + if (code == "KeyD") return static_cast(move_key::d); + if (code == "ArrowUp") return static_cast(move_key::arrow_up); + if (code == "ArrowDown") return static_cast(move_key::arrow_down); + if (code == "ArrowLeft") return static_cast(move_key::arrow_left); + if (code == "ArrowRight") return static_cast(move_key::arrow_right); + return -1; + } + + /** The `ui_key` for an action key, or `none`. */ + [[nodiscard]] inline auto key_from_code(std::string_view code) -> ra3::render::ui_key { + using ra3::render::ui_key; + if (code == "ArrowUp") return ui_key::up; + if (code == "ArrowDown") return ui_key::down; + if (code == "ArrowLeft") return ui_key::left; + if (code == "ArrowRight") return ui_key::right; + if (code == "PageUp") return ui_key::page_up; + if (code == "PageDown") return ui_key::page_down; + if (code == "Enter" || code == "NumpadEnter") return ui_key::confirm; + if (code == "Escape") return ui_key::cancel; + if (code == "Tab") return ui_key::tab; + if (code == "Backspace") return ui_key::backspace; + return ui_key::none; + } + + /** The single live display (there is only ever one). */ + [[nodiscard]] inline auto active_slot() -> wasmgl_display *& { + static wasmgl_display *instance = nullptr; + return instance; + } + + /** Camera + terrain uniforms (5 x vec4), matching the other backends. */ + [[nodiscard]] inline auto terrain_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, + float aspect) -> std::array { + return {camera.target_x, + camera.target_y, + camera.yaw, + camera.height, + camera.pitch, + camera.fov, + terrain.water_z, + terrain.has_water ? 1.0F : 0.0F, + 0.45F, + 0.35F, + 0.82F, + 0.38F, + static_cast(terrain.width), + static_cast(terrain.height), + 0.0F, + terrain.z_scale, + time_s, + 0.0F, + terrain.cell_span, + aspect}; + } + + /** + * Read `assets.manifest.json` and register every listed file as an + * Emscripten lazy file, so the browser fetches a file only when the + * engine first opens it. Runs on the worker, where synchronous XHR + * (used by `FS.createLazyFile`) is allowed. Returns the number of files + * registered, or -1 on failure. A missing manifest is not an error (the + * caller may have preloaded a full asset tree instead). + */ + [[nodiscard]] inline auto mount_lazy_assets(const std::string &manifest_path, const std::string &assets_root, const std::string &url_prefix) + -> int { + std::ifstream in(manifest_path, std::ios::binary); + if (!in) return 0; + const std::string json{std::istreambuf_iterator(in), std::istreambuf_iterator()}; + if (json.empty()) return 0; + return EM_ASM_INT( + { + try { + var manifest = JSON.parse(UTF8ToString($0)); + var root = UTF8ToString($1); + var prefix = UTF8ToString($2); + var files = manifest.files || []; + var n = 0; + for (var i = 0; i < files.length; ++i) { + var rel = files[i]; + var full = root + '/' + rel; + var slash = full.lastIndexOf('/'); + var dir = full.substring(0, slash); + var name = full.substring(slash + 1); + FS.mkdirTree(dir); + FS.createLazyFile(dir, name, prefix + rel, true, false); + ++n; + } + console.log('ra3.wasmgl: mounted ' + n + ' lazy assets under ' + root); + return n; + } catch (e) { + console.error('ra3.wasmgl: lazy asset mount failed: ' + e); + return -1; + } + }, + json.c_str(), assets_root.c_str(), url_prefix.c_str()); + } + } // namespace detail + + /** + * Mount the wasm asset tree lazily (call once, before any asset access). + * On non-wasm builds this does nothing. + */ + inline auto mount_assets(const std::string &manifest_path = "/assets.manifest.json", const std::string &assets_root = "/assets", + const std::string &url_prefix = "assets/") -> int { +#if defined(__EMSCRIPTEN__) + return detail::mount_lazy_assets(manifest_path, assets_root, url_prefix); +#else + (void) manifest_path; + (void) assets_root; + (void) url_prefix; + return 0; +#endif + } + + class wasmgl_display final : public ra3::client::display, public event_sink { + public: + wasmgl_display() = default; + wasmgl_display(const wasmgl_display &) = delete; + auto operator=(const wasmgl_display &) -> wasmgl_display & = delete; + ~wasmgl_display() override { this->shutdown(); } + + [[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override { + this->fps_limit_ = options.fps_limit; + + // There is no DOM in the worker, so Emscripten's canvas lookup + // (`findEventTarget`, a CSS selector) cannot find #canvas. Register + // the transferred OffscreenCanvas (Module.canvas) as a special target + // so `emscripten_webgl_create_context`/`..._element_size` resolve it. + EM_ASM({ + if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) { + specialHTMLTargets['#canvas'] = Module['canvas']; + } + }); + + EmscriptenWebGLContextAttributes attributes; + emscripten_webgl_init_context_attributes(&attributes); + attributes.majorVersion = 2; + attributes.minorVersion = 0; + attributes.alpha = false; + attributes.depth = true; + attributes.stencil = false; + attributes.antialias = false; + attributes.premultipliedAlpha = false; + attributes.preserveDrawingBuffer = false; + attributes.powerPreference = EM_WEBGL_POWER_PREFERENCE_HIGH_PERFORMANCE; + attributes.proxyContextToMainThread = EMSCRIPTEN_WEBGL_CONTEXT_PROXY_DISALLOW; + context_ = emscripten_webgl_create_context("#canvas", &attributes); + if (context_ == 0) { + ender::log::error("ra3.wasmgl: emscripten_webgl_create_context failed (is #canvas an OffscreenCanvas in this worker?)"); + return false; + } + if (emscripten_webgl_make_context_current(context_) != EMSCRIPTEN_RESULT_SUCCESS) { + ender::log::error("ra3.wasmgl: emscripten_webgl_make_context_current failed"); + this->shutdown(); + return false; + } + // The page sized the canvas before transfer; fall back to the + // requested size if it is missing. + const auto [width, height] = this->window_size(); + if (width <= 0 || height <= 0) { + emscripten_set_canvas_element_size("#canvas", options.width, options.height); + } + if (!this->create_scene_pipeline()) { + this->shutdown(); + return false; + } + glGenVertexArrays(1, &vao_); + glBindVertexArray(vao_); + detail::active_slot() = this; + detail::sink_slot() = this; + ender::log::info(std::format("ra3.wasmgl: WebGL worker backend initialized ({})", + reinterpret_cast(glGetString(GL_VERSION)))); + return true; + } + + [[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override { + if (frame.empty()) return false; + if (changed || scene_tex_ == 0U || frame.width() != scene_w_ || frame.height() != scene_h_) { + this->upload_scene(frame); + } + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + + glViewport(0, 0, window_w, window_h); + glDisable(GL_DEPTH_TEST); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glClearColor(0.05F, 0.06F, 0.08F, 1.0F); + glClear(GL_COLOR_BUFFER_BIT); + this->draw_fullscreen(scene_program_, scene_rect_loc_, scene_tex_, dest, window_w, window_h); + this->mark_frame(); + return true; + } + + [[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override { + if (events_.empty()) return false; + out = events_.front(); + events_.pop_front(); + return true; + } + + [[nodiscard]] auto window_size() const -> std::pair override { + int w = 0; + int h = 0; + if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0}; + return {w, h}; + } + + [[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override { + const auto held = [this](detail::move_key which) { return held_[static_cast(which)]; }; + switch (key) { + case ra3::render::ui_key::up: return held(detail::move_key::w) || held(detail::move_key::arrow_up); + case ra3::render::ui_key::down: return held(detail::move_key::s) || held(detail::move_key::arrow_down); + case ra3::render::ui_key::left: return held(detail::move_key::a) || held(detail::move_key::arrow_left); + case ra3::render::ui_key::right: return held(detail::move_key::d) || held(detail::move_key::arrow_right); + default: return false; + } + } + + [[nodiscard]] auto supports_terrain() const -> bool override { return true; } + + [[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, + const ra3::client::terrain_overlay &overlay) -> bool override { + if (!terrain_ready_) { + if (!this->create_terrain_pipeline()) return false; + if (!this->create_terrain_textures(terrain)) return false; + terrain_ready_ = true; + ender::log::info(std::format("ra3.wasmgl: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count)); + } + if (overlay.label_changed) this->upload_overlay(label_tex_, overlay.label); + if (overlay.minimap_changed) this->upload_overlay(minimap_tex_, overlay.minimap); + + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + const auto aspect = static_cast(window_w) / static_cast(std::max(1, window_h)); + + glViewport(0, 0, window_w, window_h); + glDisable(GL_DEPTH_TEST); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glClearColor(0.45F, 0.55F, 0.70F, 1.0F); + glClear(GL_COLOR_BUFFER_BIT); + glBindVertexArray(vao_); + + glUseProgram(terrain_program_); + const auto uniforms = detail::terrain_uniforms(terrain, camera, time_s, aspect); + glUniform4fv(terrain_cam_loc_, 5, uniforms.data()); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, height_tex_); + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, cell_tex_); + glActiveTexture(GL_TEXTURE2); + glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_); + glUniform1i(terrain_height_loc_, 0); + glUniform1i(terrain_cell_loc_, 1); + glUniform1i(terrain_atlas_loc_, 2); + glDrawArrays(GL_TRIANGLES, 0, 3); + + // Overlays (FPS label, corner minimap) use the image program. + const float margin = 12.0F; + this->draw_overlay(label_tex_, overlay.label.width(), overlay.label.height(), margin, margin, window_w, window_h); + this->draw_overlay(minimap_tex_, overlay.minimap.width(), overlay.minimap.height(), + static_cast(window_w) - static_cast(overlay.minimap.width()) - margin, margin, window_w, window_h); + this->mark_frame(); + return true; + } + + auto shutdown() -> void override { + if (detail::active_slot() == this) detail::active_slot() = nullptr; + if (detail::sink_slot() == this) detail::sink_slot() = nullptr; + if (context_ != 0) { + this->destroy_textures(); + if (scene_program_ != 0U) glDeleteProgram(scene_program_); + if (terrain_program_ != 0U) glDeleteProgram(terrain_program_); + if (vao_ != 0U) glDeleteVertexArrays(1, &vao_); + emscripten_webgl_destroy_context(context_); + } + context_ = 0; + vao_ = 0U; + scene_program_ = terrain_program_ = 0U; + scene_rect_loc_ = terrain_cam_loc_ = terrain_height_loc_ = terrain_cell_loc_ = terrain_atlas_loc_ = -1; + terrain_ready_ = false; + scene_w_ = scene_h_ = 0U; + events_.clear(); + held_.fill(false); + } + + [[nodiscard]] auto name() const -> std::string_view override { return "wasmgl"; } + + // ---- worker message entry points (see apps/web/openra3.worker.js) ---- + + /** A key down/up; `code` is a DOM `KeyboardEvent.code`. */ + auto on_key(std::string_view code, bool down) -> void override { + if (const int index = detail::move_key_index(code); index >= 0) { + held_[static_cast(index)] = down; + } + if (!down) return; + if (const auto key = detail::key_from_code(code); key != ra3::render::ui_key::none) { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::key; + out.key = key; + events_.push_back(out); + } + } + + /** A printable character (DOM `KeyboardEvent.charCode`). */ + auto on_text(int codepoint) -> void override { + if (codepoint <= 0 || codepoint > 0x7F) return; + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::text; + out.character = static_cast(codepoint); + events_.push_back(out); + } + + auto on_mouse_button(double x, double y, bool left) -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::mouse_button; + out.x = static_cast(x); + out.y = static_cast(y); + out.left = left; + events_.push_back(out); + } + + auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::mouse_move; + out.x = static_cast(x); + out.y = static_cast(y); + out.dx = static_cast(dx); + out.dy = static_cast(dy); + out.left = left; + events_.push_back(out); + } + + auto on_wheel(double delta_y) -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::wheel; + out.wheel = static_cast(-delta_y); // browser +Y is scroll-down; SDL +Y is scroll-up. + events_.push_back(out); + } + + auto on_quit() -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::quit; + events_.push_back(out); + } + + /** Resize the drawing buffer (backing pixels, already DPR-scaled). */ + auto on_resize(int width, int height) -> void override { + if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height); + } + + private: + // ---- GL helpers ------------------------------------------------------ + + [[nodiscard]] auto compile(unsigned type, const char *source, std::string_view what) -> GLuint { + const GLuint shader = glCreateShader(type); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint ok = GL_FALSE; + glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); + if (ok == GL_FALSE) { + std::array log{}; + glGetShaderInfoLog(shader, static_cast(log.size()), nullptr, log.data()); + ender::log::error(std::format("ra3.wasmgl: {} shader compile failed: {}", what, log.data())); + glDeleteShader(shader); + return 0U; + } + return shader; + } + + [[nodiscard]] auto link(const char *vertex, const char *fragment, std::string_view what) -> GLuint { + const GLuint vs = this->compile(GL_VERTEX_SHADER, vertex, what); + const GLuint fs = this->compile(GL_FRAGMENT_SHADER, fragment, what); + if (vs == 0U || fs == 0U) { + if (vs != 0U) glDeleteShader(vs); + if (fs != 0U) glDeleteShader(fs); + return 0U; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, vs); + glAttachShader(program, fs); + glLinkProgram(program); + glDeleteShader(vs); + glDeleteShader(fs); + GLint ok = GL_FALSE; + glGetProgramiv(program, GL_LINK_STATUS, &ok); + if (ok == GL_FALSE) { + std::array log{}; + glGetProgramInfoLog(program, static_cast(log.size()), nullptr, log.data()); + ender::log::error(std::format("ra3.wasmgl: {} program link failed: {}", what, log.data())); + glDeleteProgram(program); + return 0U; + } + return program; + } + + [[nodiscard]] auto create_scene_pipeline() -> bool { + scene_program_ = this->link(ra3_shaders::webgl_scene_vert_glsl, ra3_shaders::webgl_scene_frag_glsl, "scene"); + if (scene_program_ == 0U) return false; + scene_rect_loc_ = glGetUniformLocation(scene_program_, "u_rect"); + glUseProgram(scene_program_); + glUniform1i(glGetUniformLocation(scene_program_, "u_scene"), 0); + return true; + } + + [[nodiscard]] auto create_terrain_pipeline() -> bool { + terrain_program_ = this->link(ra3_shaders::webgl_terrain_vert_glsl, ra3_shaders::webgl_terrain_frag_glsl, "terrain"); + if (terrain_program_ == 0U) return false; + terrain_cam_loc_ = glGetUniformLocation(terrain_program_, "u_data"); + terrain_height_loc_ = glGetUniformLocation(terrain_program_, "u_heightmap"); + terrain_cell_loc_ = glGetUniformLocation(terrain_program_, "u_celldata"); + terrain_atlas_loc_ = glGetUniformLocation(terrain_program_, "u_atlas"); + return true; + } + + /** (Re)create the 2D texture used by the scene/overlay path. */ + auto make_texture2d(GLuint &texture, GLint internal, GLenum format, GLenum type, int w, int h, const void *data, bool linear, + bool wrap) -> void { + if (texture == 0U) glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, type, data); + if (const GLenum error = glGetError(); error != GL_NO_ERROR) { + ender::log::error(std::format("ra3.wasmgl: texImage2D failed: internal=0x{:X} format=0x{:X} type=0x{:X} size={}x{} err=0x{:X}", + static_cast(internal), static_cast(format), static_cast(type), w, h, + static_cast(error))); + } + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE); + } + + auto upload_scene(const ra3::render::image &frame) -> void { + // The image is 0xAARRGGBB (BGRA in memory); store it as RGBA and let + // the shader swizzle `.bgra`. + this->make_texture2d(scene_tex_, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast(frame.width()), + static_cast(frame.height()), frame.data(), true, false); + scene_w_ = frame.width(); + scene_h_ = frame.height(); + } + + auto upload_overlay(GLuint &texture, const ra3::render::image &source) -> void { + if (source.empty()) return; + this->make_texture2d(texture, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast(source.width()), + static_cast(source.height()), source.data(), true, false); + } + + [[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool { + this->make_texture2d(height_tex_, GL_R16, GL_RED, GL_UNSIGNED_SHORT, static_cast(terrain.width), + static_cast(terrain.height), terrain.heights.data(), false, false); + this->make_texture2d(cell_tex_, GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, static_cast(terrain.width), + static_cast(terrain.height), terrain.cell_data.data(), false, false); + if (atlas_tex_ == 0U) glGenTextures(1, &atlas_tex_); + glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, static_cast(terrain.layer_size), static_cast(terrain.layer_size), + static_cast(terrain.layer_count), 0, GL_RGBA, GL_UNSIGNED_BYTE, terrain.layers.data()); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_REPEAT); + return true; + } + + auto destroy_textures() -> void { + for (GLuint *texture: {&scene_tex_, &height_tex_, &cell_tex_, &atlas_tex_, &label_tex_, &minimap_tex_}) { + if (*texture != 0U) glDeleteTextures(1, texture); + *texture = 0U; + } + } + + /** Publish a coarse frame counter to the page for the fps probe. */ + auto mark_frame() const -> void { + ++frames_; + if (frames_ % 15U == 0U) { + EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast(frames_)); + } + } + + auto draw_fullscreen(GLuint program, GLint rect_location, GLuint texture, const ra3::render::view_rect &dest, int window_w, int window_h) + -> void { + glUseProgram(program); + glBindVertexArray(vao_); + const float rect[4] = {dest.x / static_cast(window_w), dest.y / static_cast(window_h), + dest.w / static_cast(window_w), dest.h / static_cast(window_h)}; + glUniform4fv(rect_location, 1, rect); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glDrawArrays(GL_TRIANGLES, 0, 3); + } + + auto draw_overlay(GLuint texture, core::uint32 w, core::uint32 h, float x, float y, int window_w, int window_h) -> void { + if (texture == 0U || w == 0U || h == 0U) return; + const ra3::render::view_rect rect{x, y, static_cast(w), static_cast(h)}; + this->draw_fullscreen(scene_program_, scene_rect_loc_, texture, rect, window_w, window_h); + } + + EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context_ = 0; + GLuint vao_ = 0U; + + GLuint scene_program_ = 0U; + GLint scene_rect_loc_ = -1; + GLuint terrain_program_ = 0U; + GLint terrain_cam_loc_ = -1; + GLint terrain_height_loc_ = -1; + GLint terrain_cell_loc_ = -1; + GLint terrain_atlas_loc_ = -1; + + GLuint scene_tex_ = 0U; + core::uint32 scene_w_ = 0U; + core::uint32 scene_h_ = 0U; + bool terrain_ready_ = false; + GLuint height_tex_ = 0U; + GLuint cell_tex_ = 0U; + GLuint atlas_tex_ = 0U; + GLuint label_tex_ = 0U; + GLuint minimap_tex_ = 0U; + + std::deque events_; + std::array(detail::move_key::count)> held_{}; + mutable unsigned frames_ = 0U; + }; + + // ---- C API called by apps/web/openra3.worker.js -------------------------- + + /** Key down/up: `code` is a DOM KeyboardEvent.code. */ + inline auto post_key(const char *code, int down) -> void { + if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_key(code != nullptr ? code : "", down != 0); + } + + /** Printable character (DOM charCode). */ + inline auto post_text(int codepoint) -> void { + if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_text(codepoint); + } + + inline auto post_mouse_button(double x, double y, int left) -> void { + if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_button(x, y, left != 0); + } + + inline auto post_mouse_move(double x, double y, double dx, double dy, int left) -> void { + if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_move(x, y, dx, dy, left != 0); + } + + inline auto post_wheel(double delta_y) -> void { + if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_wheel(delta_y); + } + + inline auto post_resize(int width, int height) -> void { + if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_resize(width, height); + } + + inline auto post_quit() -> void { + if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_quit(); + } +} // namespace ra3::wasmgl + +extern "C" { +EMSCRIPTEN_KEEPALIVE inline auto openra3_key(const char *code, int down) -> void { ra3::wasmgl::post_key(code, down); } +EMSCRIPTEN_KEEPALIVE inline auto openra3_text(int codepoint) -> void { ra3::wasmgl::post_text(codepoint); } +EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_button(double x, double y, int left) -> void { ra3::wasmgl::post_mouse_button(x, y, left); } +EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_move(double x, double y, double dx, double dy, int left) -> void { + ra3::wasmgl::post_mouse_move(x, y, dx, dy, left); +} +EMSCRIPTEN_KEEPALIVE inline auto openra3_wheel(double delta_y) -> void { ra3::wasmgl::post_wheel(delta_y); } +EMSCRIPTEN_KEEPALIVE inline auto openra3_resize(int width, int height) -> void { ra3::wasmgl::post_resize(width, height); } +EMSCRIPTEN_KEEPALIVE inline auto openra3_quit() -> void { ra3::wasmgl::post_quit(); } +} diff --git a/src/wasmgl/ra3.wasmgl.null.cppm b/src/wasmgl/ra3.wasmgl.null.cppm new file mode 100644 index 0000000..8aac388 --- /dev/null +++ b/src/wasmgl/ra3.wasmgl.null.cppm @@ -0,0 +1,32 @@ +export module ra3.wasmgl; + +import std; + +export import ra3.core; +import ra3.render; +import ra3.client; + +/** + * Fallback wasm-worker WebGL backend for builds without Emscripten. `init` fails + * so the caller can select another display; the class name matches the real + * `ra3.wasmgl` module so the backend factory in `ra3.display` compiles + * unchanged. + */ +export namespace ra3::wasmgl { + /** No-op off Emscripten (there is no Emscripten virtual filesystem). */ + inline auto mount_assets(const std::string & = "/assets.manifest.json", const std::string & = "/assets", + const std::string & = "assets/") -> int { + return 0; + } + + class wasmgl_display final : public ra3::client::display { + public: + [[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; } + [[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; } + [[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; } + [[nodiscard]] auto window_size() const -> std::pair override { return {0, 0}; } + [[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; } + auto shutdown() -> void override {} + [[nodiscard]] auto name() const -> std::string_view override { return "wasmgl(null)"; } + }; +} diff --git a/src/webgl/ra3.webgl.cppm b/src/webgl/ra3.webgl.cppm deleted file mode 100644 index 5b5c09e..0000000 --- a/src/webgl/ra3.webgl.cppm +++ /dev/null @@ -1,461 +0,0 @@ -module; - -// Emscripten/GLES3 headers in the global module fragment (C headers). -#include - -#include - -// Sized normalized texture formats used by the terrain textures; core in -// WebGL2/GLES3 but missing from some GLES3 headers. -#ifndef GL_R16 -#define GL_R16 0x822A -#endif -#ifndef GL_RGBA16 -#define GL_RGBA16 0x805B -#endif - -#include "glsl_embedded.hpp" - -export module ra3.webgl; - -import std; - -export import ra3.core; -import ra3.render; -import ra3.terrain; -import ra3.client; -import ender.log; - -/** - * A WebGL (GLES 3.0) presentation backend, a peer of the Vulkan and Direct3D - * backends. It owns a GL context on an SDL3 canvas and implements the same - * `ra3::client::display` primitives, including the GPU heightfield terrain, so - * the web build gets the same renderer as the desktop ones rather than the - * software SDL blit. - * - * The 2D path draws a fullscreen triangle sampling a software `ra3::render::image`; - * the terrain path ray-marches the heightfield in a fragment shader (the GLSL ES - * port of `terrain.frag`). Textures carry the engine's 0xAARRGGBB pixels, so the - * shaders swizzle `.bgra`. - */ -export namespace ra3::webgl { - namespace detail { - [[nodiscard]] inline auto key_from_sdl(SDL_Keycode key) -> ra3::render::ui_key { - using ra3::render::ui_key; - switch (key) { - case SDLK_UP: return ui_key::up; - case SDLK_DOWN: return ui_key::down; - case SDLK_LEFT: return ui_key::left; - case SDLK_RIGHT: return ui_key::right; - case SDLK_PAGEUP: return ui_key::page_up; - case SDLK_PAGEDOWN: return ui_key::page_down; - case SDLK_RETURN: - case SDLK_KP_ENTER: return ui_key::confirm; - case SDLK_ESCAPE: return ui_key::cancel; - case SDLK_TAB: return ui_key::tab; - case SDLK_BACKSPACE: return ui_key::backspace; - default: return ui_key::none; - } - } - - [[nodiscard]] inline auto poll_event(SDL_Window *window, ra3::render::ui_event &out) -> bool { - (void) window; - SDL_Event event; - while (SDL_PollEvent(&event)) { - switch (event.type) { - case SDL_EVENT_QUIT: - out = {}; - out.type = ra3::render::ui_event_type::quit; - return true; - case SDL_EVENT_KEY_DOWN: - if (const auto key = key_from_sdl(event.key.key); key != ra3::render::ui_key::none) { - out = {}; - out.type = ra3::render::ui_event_type::key; - out.key = key; - return true; - } - break; - case SDL_EVENT_TEXT_INPUT: - if (event.text.text[0] != '\0') { - out = {}; - out.type = ra3::render::ui_event_type::text; - out.character = event.text.text[0]; - return true; - } - break; - case SDL_EVENT_MOUSE_MOTION: - out = {}; - out.type = ra3::render::ui_event_type::mouse_move; - out.x = event.motion.x; - out.y = event.motion.y; - out.dx = event.motion.xrel; - out.dy = event.motion.yrel; - out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U; - return true; - case SDL_EVENT_MOUSE_BUTTON_DOWN: - out = {}; - out.type = ra3::render::ui_event_type::mouse_button; - out.x = event.button.x; - out.y = event.button.y; - out.left = event.button.button == SDL_BUTTON_LEFT; - return true; - case SDL_EVENT_MOUSE_WHEEL: - out = {}; - out.type = ra3::render::ui_event_type::wheel; - out.wheel = event.wheel.y; - return true; - default: - break; - } - } - return false; - } - - [[nodiscard]] inline auto key_down(ra3::render::ui_key key) -> bool { - const bool *keys = SDL_GetKeyboardState(nullptr); - if (keys == nullptr) return false; - switch (key) { - case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP]; - case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN]; - case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT]; - case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT]; - default: return false; - } - } - - /** Camera + terrain uniforms (5 x vec4), matching the other backends. */ - [[nodiscard]] inline auto terrain_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, - float aspect) -> std::array { - return {camera.target_x, - camera.target_y, - camera.yaw, - camera.height, - camera.pitch, - camera.fov, - terrain.water_z, - terrain.has_water ? 1.0F : 0.0F, - 0.45F, - 0.35F, - 0.82F, - 0.38F, - static_cast(terrain.width), - static_cast(terrain.height), - 0.0F, - terrain.z_scale, - time_s, - 0.0F, - terrain.cell_span, - aspect}; - } - } // namespace detail - - class webgl_display final : public ra3::client::display { - public: - webgl_display() = default; - webgl_display(const webgl_display &) = delete; - auto operator=(const webgl_display &) -> webgl_display & = delete; - ~webgl_display() override { this->shutdown(); } - - [[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override { - if (!SDL_Init(SDL_INIT_VIDEO)) return false; - this->fps_limit_ = options.fps_limit; - SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0); - window_ = SDL_CreateWindow(options.title.c_str(), options.width, options.height, - SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | (options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0)); - if (window_ == nullptr) { - SDL_Quit(); - return false; - } - SDL_RaiseWindow(window_); - context_ = SDL_GL_CreateContext(window_); - if (context_ == nullptr) { - ender::log::error(std::format("ra3.webgl: SDL_GL_CreateContext failed: {}", SDL_GetError())); - this->shutdown(); - return false; - } - SDL_GL_SetSwapInterval(options.fps_limit == 0 ? 1 : 0); - if (!this->create_scene_pipeline()) { - this->shutdown(); - return false; - } - glGenVertexArrays(1, &vao_); - glBindVertexArray(vao_); - SDL_StartTextInput(window_); - ender::log::info(std::format("ra3.webgl: WebGL backend initialized ({})", reinterpret_cast(glGetString(GL_VERSION)))); - return true; - } - - [[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override { - if (frame.empty()) return false; - if (!this->ensure_size()) return false; - if (changed || scene_tex_ == 0U || frame.width() != scene_w_ || frame.height() != scene_h_) { - this->upload_scene(frame); - } - const auto [window_w, window_h] = this->window_size(); - if (window_w <= 0 || window_h <= 0) return true; - - glViewport(0, 0, window_w, window_h); - glDisable(GL_DEPTH_TEST); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glClearColor(0.05F, 0.06F, 0.08F, 1.0F); - glClear(GL_COLOR_BUFFER_BIT); - this->draw_fullscreen(scene_program_, scene_rect_loc_, scene_tex_, dest, window_w, window_h); - SDL_GL_SwapWindow(window_); - return true; - } - - [[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override { return detail::poll_event(window_, out); } - - [[nodiscard]] auto window_size() const -> std::pair override { - int w = 0; - int h = 0; - SDL_GetWindowSizeInPixels(window_, &w, &h); - return {w, h}; - } - - [[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override { return detail::key_down(key); } - - [[nodiscard]] auto supports_terrain() const -> bool override { return true; } - - [[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, - const ra3::client::terrain_overlay &overlay) -> bool override { - if (!terrain_ready_) { - if (!this->create_terrain_pipeline()) return false; - if (!this->create_terrain_textures(terrain)) return false; - terrain_ready_ = true; - ender::log::info(std::format("ra3.webgl: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count)); - } - if (!this->ensure_size()) return false; - if (overlay.label_changed) this->upload_overlay(label_tex_, overlay.label); - if (overlay.minimap_changed) this->upload_overlay(minimap_tex_, overlay.minimap); - - const auto [window_w, window_h] = this->window_size(); - if (window_w <= 0 || window_h <= 0) return true; - const auto aspect = static_cast(window_w) / static_cast(std::max(1, window_h)); - - glViewport(0, 0, window_w, window_h); - glDisable(GL_DEPTH_TEST); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glClearColor(0.45F, 0.55F, 0.70F, 1.0F); - glClear(GL_COLOR_BUFFER_BIT); - glBindVertexArray(vao_); - - glUseProgram(terrain_program_); - const auto uniforms = detail::terrain_uniforms(terrain, camera, time_s, aspect); - glUniform4fv(terrain_cam_loc_, 5, uniforms.data()); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, height_tex_); - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, cell_tex_); - glActiveTexture(GL_TEXTURE2); - glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_); - glUniform1i(terrain_height_loc_, 0); - glUniform1i(terrain_cell_loc_, 1); - glUniform1i(terrain_atlas_loc_, 2); - glDrawArrays(GL_TRIANGLES, 0, 3); - - // Overlays (FPS label, corner minimap) use the image program. - const float margin = 12.0F; - this->draw_overlay(label_tex_, overlay.label.width(), overlay.label.height(), margin, margin, window_w, window_h); - this->draw_overlay(minimap_tex_, overlay.minimap.width(), overlay.minimap.height(), - static_cast(window_w) - static_cast(overlay.minimap.width()) - margin, margin, window_w, window_h); - SDL_GL_SwapWindow(window_); - return true; - } - - auto shutdown() -> void override { - if (context_ != nullptr) { - this->destroy_textures(); - if (scene_program_ != 0U) glDeleteProgram(scene_program_); - if (terrain_program_ != 0U) glDeleteProgram(terrain_program_); - if (vao_ != 0U) glDeleteVertexArrays(1, &vao_); - SDL_GL_DestroyContext(context_); - } - if (window_ != nullptr) SDL_DestroyWindow(window_); - SDL_Quit(); - window_ = nullptr; - context_ = nullptr; - vao_ = 0U; - scene_program_ = terrain_program_ = 0U; - terrain_ready_ = false; - scene_w_ = scene_h_ = 0U; - swapchain_w_ = swapchain_h_ = 0; - } - - [[nodiscard]] auto name() const -> std::string_view override { return "webgl"; } - - private: - [[nodiscard]] auto compile(unsigned type, const char *source, std::string_view what) -> GLuint { - const GLuint shader = glCreateShader(type); - glShaderSource(shader, 1, &source, nullptr); - glCompileShader(shader); - GLint ok = GL_FALSE; - glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); - if (ok == GL_FALSE) { - std::array log{}; - glGetShaderInfoLog(shader, static_cast(log.size()), nullptr, log.data()); - ender::log::error(std::format("ra3.webgl: {} shader compile failed: {}", what, log.data())); - glDeleteShader(shader); - return 0U; - } - return shader; - } - - [[nodiscard]] auto link(const char *vertex, const char *fragment, std::string_view what) -> GLuint { - const GLuint vs = this->compile(GL_VERTEX_SHADER, vertex, what); - const GLuint fs = this->compile(GL_FRAGMENT_SHADER, fragment, what); - if (vs == 0U || fs == 0U) { - if (vs != 0U) glDeleteShader(vs); - if (fs != 0U) glDeleteShader(fs); - return 0U; - } - const GLuint program = glCreateProgram(); - glAttachShader(program, vs); - glAttachShader(program, fs); - glLinkProgram(program); - glDeleteShader(vs); - glDeleteShader(fs); - GLint ok = GL_FALSE; - glGetProgramiv(program, GL_LINK_STATUS, &ok); - if (ok == GL_FALSE) { - std::array log{}; - glGetProgramInfoLog(program, static_cast(log.size()), nullptr, log.data()); - ender::log::error(std::format("ra3.webgl: {} program link failed: {}", what, log.data())); - glDeleteProgram(program); - return 0U; - } - return program; - } - - [[nodiscard]] auto create_scene_pipeline() -> bool { - scene_program_ = this->link(ra3_shaders::webgl_scene_vert_glsl, ra3_shaders::webgl_scene_frag_glsl, "scene"); - if (scene_program_ == 0U) return false; - scene_rect_loc_ = glGetUniformLocation(scene_program_, "u_rect"); - glUseProgram(scene_program_); - glUniform1i(glGetUniformLocation(scene_program_, "u_scene"), 0); - return true; - } - - [[nodiscard]] auto create_terrain_pipeline() -> bool { - terrain_program_ = this->link(ra3_shaders::webgl_terrain_vert_glsl, ra3_shaders::webgl_terrain_frag_glsl, "terrain"); - if (terrain_program_ == 0U) return false; - terrain_cam_loc_ = glGetUniformLocation(terrain_program_, "u_data"); - terrain_height_loc_ = glGetUniformLocation(terrain_program_, "u_heightmap"); - terrain_cell_loc_ = glGetUniformLocation(terrain_program_, "u_celldata"); - terrain_atlas_loc_ = glGetUniformLocation(terrain_program_, "u_atlas"); - return true; - } - - /** (Re)create the 2D texture used by the scene/overlay path. */ - auto make_texture2d(GLuint &texture, GLint internal, GLenum format, GLenum type, int w, int h, const void *data, bool linear, - bool wrap) -> void { - if (texture == 0U) glGenTextures(1, &texture); - glBindTexture(GL_TEXTURE_2D, texture); - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, type, data); - if (const GLenum error = glGetError(); error != GL_NO_ERROR) { - ender::log::error(std::format("ra3.webgl: texImage2D failed: internal=0x{:X} format=0x{:X} type=0x{:X} size={}x{} err=0x{:X}", - static_cast(internal), static_cast(format), static_cast(type), w, h, - static_cast(error))); - } - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE); - } - - auto upload_scene(const ra3::render::image &frame) -> void { - // The image is 0xAARRGGBB (BGRA in memory); store it as RGBA and let - // the shader swizzle `.bgra`. - this->make_texture2d(scene_tex_, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast(frame.width()), - static_cast(frame.height()), frame.data(), true, false); - scene_w_ = frame.width(); - scene_h_ = frame.height(); - } - - auto upload_overlay(GLuint &texture, const ra3::render::image &source) -> void { - if (source.empty()) return; - this->make_texture2d(texture, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast(source.width()), - static_cast(source.height()), source.data(), true, false); - } - - [[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool { - this->make_texture2d(height_tex_, GL_R16, GL_RED, GL_UNSIGNED_SHORT, static_cast(terrain.width), - static_cast(terrain.height), terrain.heights.data(), false, false); - this->make_texture2d(cell_tex_, GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, static_cast(terrain.width), - static_cast(terrain.height), terrain.cell_data.data(), false, false); - if (atlas_tex_ == 0U) glGenTextures(1, &atlas_tex_); - glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_); - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, static_cast(terrain.layer_size), static_cast(terrain.layer_size), - static_cast(terrain.layer_count), 0, GL_RGBA, GL_UNSIGNED_BYTE, terrain.layers.data()); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_REPEAT); - return true; - } - - auto destroy_textures() -> void { - for (GLuint *texture: {&scene_tex_, &height_tex_, &cell_tex_, &atlas_tex_, &label_tex_, &minimap_tex_}) { - if (*texture != 0U) glDeleteTextures(1, texture); - *texture = 0U; - } - } - - /** Resize the backing store when the canvas changed; false on failure. */ - [[nodiscard]] auto ensure_size() -> bool { - const auto [width, height] = this->window_size(); - if (width <= 0 || height <= 0) return true; - swapchain_w_ = width; - swapchain_h_ = height; - return true; - } - - auto draw_fullscreen(GLuint program, GLint rect_location, GLuint texture, const ra3::render::view_rect &dest, int window_w, int window_h) - -> void { - glUseProgram(program); - glBindVertexArray(vao_); - const float rect[4] = {dest.x / static_cast(window_w), dest.y / static_cast(window_h), - dest.w / static_cast(window_w), dest.h / static_cast(window_h)}; - glUniform4fv(rect_location, 1, rect); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, texture); - glDrawArrays(GL_TRIANGLES, 0, 3); - } - - auto draw_overlay(GLuint texture, core::uint32 w, core::uint32 h, float x, float y, int window_w, int window_h) -> void { - if (texture == 0U || w == 0U || h == 0U) return; - const ra3::render::view_rect rect{x, y, static_cast(w), static_cast(h)}; - this->draw_fullscreen(scene_program_, scene_rect_loc_, texture, rect, window_w, window_h); - } - - SDL_Window *window_ = nullptr; - SDL_GLContext context_ = nullptr; - GLuint vao_ = 0U; - - GLuint scene_program_ = 0U; - GLint scene_rect_loc_ = -1; - GLuint terrain_program_ = 0U; - GLint terrain_cam_loc_ = -1; - GLint terrain_height_loc_ = -1; - GLint terrain_cell_loc_ = -1; - GLint terrain_atlas_loc_ = -1; - - GLuint scene_tex_ = 0U; - core::uint32 scene_w_ = 0U; - core::uint32 scene_h_ = 0U; - bool terrain_ready_ = false; - GLuint height_tex_ = 0U; - GLuint cell_tex_ = 0U; - GLuint atlas_tex_ = 0U; - GLuint label_tex_ = 0U; - GLuint minimap_tex_ = 0U; - int swapchain_w_ = 0; - int swapchain_h_ = 0; - }; -} diff --git a/src/webgpu/ra3.webgpu.cppm b/src/webgpu/ra3.webgpu.cppm new file mode 100644 index 0000000..debfaba --- /dev/null +++ b/src/webgpu/ra3.webgpu.cppm @@ -0,0 +1,610 @@ +module; + +// Dawn's header-only C++ wrapper for the WebGPU C API, provided by Emscripten's +// emdawnwebgpu port (`--use-port=emdawnwebgpu`). This backend is wasm-only: on +// the web WebGPU accepts WGSL only (SPIR-V is not available), so the shaders are +// WGSL ports of the Vulkan/WebGL ones rather than a reuse of the SPIR-V blobs. +#include + +#include +#include +#include + +#include "webgpu_wgsl_embedded.hpp" + +export module ra3.webgpu; + +import std; + +export import ra3.core; +export import ra3.render; +export import ra3.terrain; +export import ra3.client; +import ra3.wasmgl; +import ender.log; + +/** + * A WebGPU presentation backend for the wasm build, a peer of `ra3.wasmgl`. + * + * It runs in the same plain Web Worker and draws into the same transferred + * OffscreenCanvas, but through WebGPU: a surface is created on `#canvas`, a + * swapchain image is acquired each frame and presented. The uniform layout and + * the terrain data plumbing are shared with the Vulkan/WebGL backends + * (`terrain_uniforms`); only the API calls and the WGSL shaders differ. + * + * It reuses `ra3.wasmgl`'s `event_sink` so the page/worker input protocol is the + * same for both wasm backends. + */ +export namespace ra3::webgpu { + class webgpu_display final : public ra3::client::display, public ra3::wasmgl::event_sink { + public: + webgpu_display() = default; + webgpu_display(const webgpu_display &) = delete; + auto operator=(const webgpu_display &) -> webgpu_display & = delete; + ~webgpu_display() override { this->shutdown(); } + + [[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override { + this->fps_limit_ = options.fps_limit; + wgpu::Device device(emscripten_webgpu_get_device()); + if (device == nullptr) { + ender::log::warn("ra3.webgpu: no preinitialized WebGPU device"); + return false; + } + device_ = std::move(device); + queue_ = device_.GetQueue(); + instance_ = wgpu::CreateInstance(); + if (instance_ == nullptr) { + ender::log::warn("ra3.webgpu: wgpuCreateInstance failed"); + return false; + } + + wgpu::EmscriptenSurfaceSourceCanvasHTMLSelector selector; + selector.selector = "#canvas"; + // There is no DOM in the worker, so Emscripten's canvas lookup cannot + // find #canvas by CSS selector; register the transferred OffscreenCanvas + // (Module.canvas) as a special target for `wgpuInstanceCreateSurface`. + EM_ASM({ + if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) { + specialHTMLTargets['#canvas'] = Module['canvas']; + } + }); + wgpu::SurfaceDescriptor surface_descriptor; + surface_descriptor.nextInChain = &selector; + surface_ = instance_.CreateSurface(&surface_descriptor); + if (surface_ == nullptr) { + ender::log::warn("ra3.webgpu: CreateSurface failed (is #canvas an OffscreenCanvas?)"); + return false; + } + + if (!this->create_pipelines()) { + ender::log::warn("ra3.webgpu: pipeline creation failed"); + this->shutdown(); + return false; + } + const auto [width, height] = this->window_size(); + this->configure(width > 0 ? width : options.width, height > 0 ? height : options.height); + ra3::wasmgl::set_event_sink(this); + ender::log::info("ra3.webgpu: WebGPU backend initialized"); + return true; + } + + [[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override { + if (frame.empty()) return false; + if (changed || !scene_.texture || static_cast(frame.width()) != scene_.width || static_cast(frame.height()) != scene_.height) { + this->upload_image(scene_, frame); + } + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + return this->draw({0.05F, 0.06F, 0.08F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) { + this->draw_image(pass, scene_, dest, w, h); + }); + } + + [[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override { + if (events_.empty()) return false; + out = events_.front(); + events_.pop_front(); + return true; + } + + [[nodiscard]] auto window_size() const -> std::pair override { + int w = 0; + int h = 0; + if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0}; + return {w, h}; + } + + [[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override { + const auto held = [this](ra3::wasmgl::detail::move_key which) { return held_[static_cast(which)]; }; + switch (key) { + case ra3::render::ui_key::up: return held(ra3::wasmgl::detail::move_key::w) || held(ra3::wasmgl::detail::move_key::arrow_up); + case ra3::render::ui_key::down: return held(ra3::wasmgl::detail::move_key::s) || held(ra3::wasmgl::detail::move_key::arrow_down); + case ra3::render::ui_key::left: return held(ra3::wasmgl::detail::move_key::a) || held(ra3::wasmgl::detail::move_key::arrow_left); + case ra3::render::ui_key::right: return held(ra3::wasmgl::detail::move_key::d) || held(ra3::wasmgl::detail::move_key::arrow_right); + default: return false; + } + } + + [[nodiscard]] auto supports_terrain() const -> bool override { return true; } + + [[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, + const ra3::client::terrain_overlay &overlay) -> bool override { + if (!terrain_ready_) { + if (!this->create_terrain(terrain)) return false; + terrain_ready_ = true; + ender::log::info(std::format("ra3.webgpu: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count)); + } + if (overlay.label_changed) this->upload_image(label_, overlay.label); + if (overlay.minimap_changed) this->upload_image(minimap_, overlay.minimap); + + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + const auto aspect = static_cast(window_w) / static_cast(std::max(1, window_h)); + const auto uniforms = detail_uniforms(terrain, camera, time_s, aspect); + queue_.WriteBuffer(terrain_.ubo, 0, uniforms.data(), uniforms.size() * sizeof(float)); + + const float margin = 12.0F; + return this->draw({0.45F, 0.55F, 0.70F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) { + pass.SetPipeline(terrain_.pipeline); + pass.SetBindGroup(0, terrain_.bind_group); + pass.Draw(3); + this->draw_image(pass, label_, {margin, margin, static_cast(label_.width), static_cast(label_.height)}, w, h); + this->draw_image(pass, minimap_, {static_cast(w) - static_cast(minimap_.width) - margin, margin, + static_cast(minimap_.width), static_cast(minimap_.height)}, + w, h); + }); + } + + auto shutdown() -> void override { + if (ra3::wasmgl::detail::sink_slot() == this) ra3::wasmgl::set_event_sink(nullptr); + terrain_ready_ = false; + scene_ = {}; + label_ = {}; + minimap_ = {}; + terrain_ = {}; + surface_ = nullptr; + queue_ = nullptr; + device_ = nullptr; + instance_ = nullptr; + events_.clear(); + held_.fill(false); + } + + [[nodiscard]] auto name() const -> std::string_view override { return "webgpu"; } + + // ---- worker input (ra3.wasmgl::event_sink) --------------------------- + + auto on_key(std::string_view code, bool down) -> void override { + if (const int index = ra3::wasmgl::detail::move_key_index(code); index >= 0) { + held_[static_cast(index)] = down; + } + if (!down) return; + if (const auto key = ra3::wasmgl::detail::key_from_code(code); key != ra3::render::ui_key::none) { + push_key(key); + } + } + + auto on_text(int codepoint) -> void override { + if (codepoint <= 0 || codepoint > 0x7F) return; + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::text; + out.character = static_cast(codepoint); + events_.push_back(out); + } + + auto on_mouse_button(double x, double y, bool left) -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::mouse_button; + out.x = static_cast(x); + out.y = static_cast(y); + out.left = left; + events_.push_back(out); + } + + auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::mouse_move; + out.x = static_cast(x); + out.y = static_cast(y); + out.dx = static_cast(dx); + out.dy = static_cast(dy); + out.left = left; + events_.push_back(out); + } + + auto on_wheel(double delta_y) -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::wheel; + out.wheel = static_cast(-delta_y); + events_.push_back(out); + } + + auto on_quit() -> void override { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::quit; + events_.push_back(out); + } + + auto on_resize(int width, int height) -> void override { + if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height); + } + + private: + /** A drawn image: its texture, uniform rect buffer and bind group. */ + struct image_target { + wgpu::Texture texture; + wgpu::TextureView view; + wgpu::Buffer ubo; + wgpu::BindGroup bind_group; + int width = 0; + int height = 0; + int tex_width = 0; + int tex_height = 0; + }; + + struct terrain_target { + wgpu::RenderPipeline pipeline; + wgpu::Buffer ubo; + wgpu::Texture height; + wgpu::Texture cell; + wgpu::Texture atlas; + wgpu::TextureView height_view; + wgpu::TextureView cell_view; + wgpu::TextureView atlas_view; + wgpu::BindGroup bind_group; + }; + + static auto detail_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect) + -> std::array { + return {camera.target_x, + camera.target_y, + camera.yaw, + camera.height, + camera.pitch, + camera.fov, + terrain.water_z, + terrain.has_water ? 1.0F : 0.0F, + 0.45F, + 0.35F, + 0.82F, + 0.38F, + static_cast(terrain.width), + static_cast(terrain.height), + 0.0F, + terrain.z_scale, + time_s, + 0.0F, + terrain.cell_span, + aspect}; + } + + auto push_key(ra3::render::ui_key key) -> void { + ra3::render::ui_event out; + out.type = ra3::render::ui_event_type::key; + out.key = key; + events_.push_back(out); + } + + [[nodiscard]] auto make_shader(const char *wgsl) -> wgpu::ShaderModule { + wgpu::ShaderSourceWGSL source; + source.code = wgsl; + wgpu::ShaderModuleDescriptor descriptor; + descriptor.nextInChain = &source; + return device_.CreateShaderModule(&descriptor); + } + + [[nodiscard]] auto create_pipelines() -> bool { + scene_shader_ = this->make_shader(ra3_shaders::webgpu_scene_wgsl); + terrain_shader_ = this->make_shader(ra3_shaders::webgpu_terrain_wgsl); + if (scene_shader_ == nullptr || terrain_shader_ == nullptr) { + ender::log::warn(std::format("ra3.webgpu: shader module creation failed (scene={} terrain={})", + scene_shader_ != nullptr, terrain_shader_ != nullptr)); + return false; + } + + wgpu::SamplerDescriptor clamp_desc; + clamp_desc.addressModeU = wgpu::AddressMode::ClampToEdge; + clamp_desc.addressModeV = wgpu::AddressMode::ClampToEdge; + clamp_desc.magFilter = wgpu::FilterMode::Linear; + clamp_desc.minFilter = wgpu::FilterMode::Linear; + clamp_desc.lodMaxClamp = 32.0F; + clamp_sampler_ = device_.CreateSampler(&clamp_desc); + + wgpu::SamplerDescriptor repeat_desc = clamp_desc; + repeat_desc.addressModeU = wgpu::AddressMode::Repeat; + repeat_desc.addressModeV = wgpu::AddressMode::Repeat; + repeat_sampler_ = device_.CreateSampler(&repeat_desc); + + // Scene: uniform(rect) + texture + sampler. + wgpu::BindGroupLayoutEntry scene_entries[3] = {}; + scene_entries[0].binding = 0; + scene_entries[0].visibility = wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment; + scene_entries[0].buffer.type = wgpu::BufferBindingType::Uniform; + scene_entries[0].buffer.minBindingSize = 16; + scene_entries[1].binding = 1; + scene_entries[1].visibility = wgpu::ShaderStage::Fragment; + scene_entries[1].texture.sampleType = wgpu::TextureSampleType::Float; + scene_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D; + scene_entries[2].binding = 2; + scene_entries[2].visibility = wgpu::ShaderStage::Fragment; + scene_entries[2].sampler.type = wgpu::SamplerBindingType::Filtering; + wgpu::BindGroupLayoutDescriptor scene_layout_desc; + scene_layout_desc.entryCount = 3; + scene_layout_desc.entries = scene_entries; + scene_bind_group_layout_ = device_.CreateBindGroupLayout(&scene_layout_desc); + + wgpu::PipelineLayoutDescriptor scene_pipeline_layout_desc; + scene_pipeline_layout_desc.bindGroupLayoutCount = 1; + scene_pipeline_layout_desc.bindGroupLayouts = &scene_bind_group_layout_; + scene_pipeline_layout_ = device_.CreatePipelineLayout(&scene_pipeline_layout_desc); + scene_pipeline_ = this->create_pipeline(scene_pipeline_layout_, scene_shader_, ra3_shaders::webgpu_scene_wgsl); + if (scene_pipeline_ == nullptr) { + ender::log::warn("ra3.webgpu: scene render pipeline creation failed"); + } + + // Terrain: uniform(data[5]) + R16Uint + RGBA16Uint + RGBA8 array + sampler. + wgpu::BindGroupLayoutEntry terrain_entries[5] = {}; + terrain_entries[0].binding = 0; + terrain_entries[0].visibility = wgpu::ShaderStage::Fragment; + terrain_entries[0].buffer.type = wgpu::BufferBindingType::Uniform; + terrain_entries[0].buffer.minBindingSize = 80; + terrain_entries[1].binding = 1; + terrain_entries[1].visibility = wgpu::ShaderStage::Fragment; + terrain_entries[1].texture.sampleType = wgpu::TextureSampleType::Uint; + terrain_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D; + terrain_entries[2].binding = 2; + terrain_entries[2].visibility = wgpu::ShaderStage::Fragment; + terrain_entries[2].texture.sampleType = wgpu::TextureSampleType::Uint; + terrain_entries[2].texture.viewDimension = wgpu::TextureViewDimension::e2D; + terrain_entries[3].binding = 3; + terrain_entries[3].visibility = wgpu::ShaderStage::Fragment; + terrain_entries[3].texture.sampleType = wgpu::TextureSampleType::Float; + terrain_entries[3].texture.viewDimension = wgpu::TextureViewDimension::e2DArray; + terrain_entries[4].binding = 4; + terrain_entries[4].visibility = wgpu::ShaderStage::Fragment; + terrain_entries[4].sampler.type = wgpu::SamplerBindingType::Filtering; + wgpu::BindGroupLayoutDescriptor terrain_layout_desc; + terrain_layout_desc.entryCount = 5; + terrain_layout_desc.entries = terrain_entries; + terrain_bind_group_layout_ = device_.CreateBindGroupLayout(&terrain_layout_desc); + return scene_pipeline_ != nullptr; + } + + [[nodiscard]] auto create_pipeline(const wgpu::PipelineLayout &layout, const wgpu::ShaderModule &shader, const char *wgsl) -> wgpu::RenderPipeline { + (void) wgsl; + wgpu::ColorTargetState target; + target.format = surface_format_; + target.writeMask = wgpu::ColorWriteMask::All; + wgpu::FragmentState fragment; + fragment.module = shader; + fragment.entryPoint = "fs_main"; + fragment.targetCount = 1; + fragment.targets = ⌖ + wgpu::RenderPipelineDescriptor descriptor; + descriptor.layout = layout; + descriptor.vertex.module = shader; + descriptor.vertex.entryPoint = "vs_main"; + descriptor.primitive.topology = wgpu::PrimitiveTopology::TriangleList; + descriptor.fragment = &fragment; + descriptor.multisample.count = 1; + return device_.CreateRenderPipeline(&descriptor); + } + + auto configure(int width, int height) -> void { + if (surface_ == nullptr || width <= 0 || height <= 0) return; + if (width == surface_w_ && height == surface_h_) return; + wgpu::SurfaceConfiguration config; + config.device = device_; + config.format = surface_format_; + config.usage = wgpu::TextureUsage::RenderAttachment; + config.width = static_cast(width); + config.height = static_cast(height); + config.presentMode = wgpu::PresentMode::Fifo; + surface_.Configure(&config); + surface_w_ = width; + surface_h_ = height; + } + + auto make_target(image_target &target) -> void { + wgpu::BufferDescriptor ubo_desc; + ubo_desc.size = 16; + ubo_desc.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst; + target.ubo = device_.CreateBuffer(&ubo_desc); + } + + auto upload_image(image_target &target, const ra3::render::image &frame) -> void { + if (frame.empty()) return; + target.width = static_cast(frame.width()); + target.height = static_cast(frame.height()); + if (target.texture == nullptr || target.tex_width != target.width || target.tex_height != target.height) { + wgpu::TextureDescriptor descriptor; + descriptor.size = {frame.width(), frame.height(), 1}; + descriptor.format = wgpu::TextureFormat::RGBA8Unorm; + descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst; + descriptor.dimension = wgpu::TextureDimension::e2D; + descriptor.mipLevelCount = 1; + descriptor.sampleCount = 1; + target.texture = device_.CreateTexture(&descriptor); + target.view = target.texture.CreateView(); + target.tex_width = target.width; + target.tex_height = target.height; + if (!target.ubo) this->make_target(target); + target.bind_group = this->make_image_bind_group(target); + } + wgpu::TexelCopyTextureInfo destination; + destination.texture = target.texture; + wgpu::TexelCopyBufferLayout layout; + layout.bytesPerRow = frame.width() * 4; + layout.rowsPerImage = frame.height(); + wgpu::Extent3D size = {frame.width(), frame.height(), 1}; + queue_.WriteTexture(&destination, frame.data(), static_cast(frame.width()) * frame.height() * 4, &layout, &size); + } + + [[nodiscard]] auto make_image_bind_group(image_target &target) -> wgpu::BindGroup { + wgpu::BindGroupEntry entries[3] = {}; + entries[0].binding = 0; + entries[0].buffer = target.ubo; + entries[0].size = 16; + entries[1].binding = 1; + entries[1].textureView = target.view; + entries[2].binding = 2; + entries[2].sampler = clamp_sampler_; + wgpu::BindGroupDescriptor descriptor; + descriptor.layout = scene_bind_group_layout_; + descriptor.entryCount = 3; + descriptor.entries = entries; + return device_.CreateBindGroup(&descriptor); + } + + auto draw_image(wgpu::RenderPassEncoder &pass, image_target &target, const ra3::render::view_rect &dest, int window_w, int window_h) -> void { + if (target.bind_group == nullptr || window_w <= 0 || window_h <= 0) return; + const float rect[4] = {dest.x / static_cast(window_w), dest.y / static_cast(window_h), + dest.w / static_cast(window_w), dest.h / static_cast(window_h)}; + queue_.WriteBuffer(target.ubo, 0, rect, sizeof(rect)); + pass.SetPipeline(scene_pipeline_); + pass.SetBindGroup(0, target.bind_group); + pass.Draw(3); + } + + [[nodiscard]] auto create_terrain(const ra3::terrain::gpu_terrain &terrain) -> bool { + { + wgpu::BufferDescriptor descriptor; + descriptor.size = 80; + descriptor.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst; + terrain_.ubo = device_.CreateBuffer(&descriptor); + } + terrain_.height = this->make_texture(wgpu::TextureFormat::R16Uint, terrain.width, terrain.height, 1); + terrain_.cell = this->make_texture(wgpu::TextureFormat::RGBA16Uint, terrain.width, terrain.height, 1); + terrain_.atlas = this->make_texture(wgpu::TextureFormat::RGBA8Unorm, terrain.layer_size, terrain.layer_size, terrain.layer_count); + terrain_.height_view = terrain_.height.CreateView(); + terrain_.cell_view = terrain_.cell.CreateView(); + terrain_.atlas_view = terrain_.atlas.CreateView(); + + this->write_texture(terrain_.height, terrain.heights.data(), terrain.width, terrain.height, 1, 2); + this->write_texture(terrain_.cell, terrain.cell_data.data(), terrain.width, terrain.height, 1, 8); + this->write_texture(terrain_.atlas, terrain.layers.data(), terrain.layer_size, terrain.layer_size, terrain.layer_count, 4); + + wgpu::BindGroupEntry entries[5] = {}; + entries[0].binding = 0; + entries[0].buffer = terrain_.ubo; + entries[0].size = 80; + entries[1].binding = 1; + entries[1].textureView = terrain_.height_view; + entries[2].binding = 2; + entries[2].textureView = terrain_.cell_view; + entries[3].binding = 3; + entries[3].textureView = terrain_.atlas_view; + entries[4].binding = 4; + entries[4].sampler = repeat_sampler_; + wgpu::BindGroupDescriptor bind_desc; + bind_desc.layout = terrain_bind_group_layout_; + bind_desc.entryCount = 5; + bind_desc.entries = entries; + terrain_.bind_group = device_.CreateBindGroup(&bind_desc); + + terrain_.pipeline = this->create_pipeline(terrain_pipeline_layout(), terrain_shader_, ra3_shaders::webgpu_terrain_wgsl); + return terrain_.pipeline != nullptr && terrain_.bind_group != nullptr; + } + + [[nodiscard]] auto terrain_pipeline_layout() -> wgpu::PipelineLayout { + if (terrain_pipeline_layout_ == nullptr) { + wgpu::PipelineLayoutDescriptor descriptor; + descriptor.bindGroupLayoutCount = 1; + descriptor.bindGroupLayouts = &terrain_bind_group_layout_; + terrain_pipeline_layout_ = device_.CreatePipelineLayout(&descriptor); + } + return terrain_pipeline_layout_; + } + + [[nodiscard]] auto make_texture(wgpu::TextureFormat format, uint32_t width, uint32_t height, uint32_t layers) -> wgpu::Texture { + wgpu::TextureDescriptor descriptor; + descriptor.size = {width, height, layers}; + descriptor.format = format; + descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst; + descriptor.dimension = wgpu::TextureDimension::e2D; + descriptor.mipLevelCount = 1; + descriptor.sampleCount = 1; + return device_.CreateTexture(&descriptor); + } + + auto write_texture(const wgpu::Texture &texture, const void *data, uint32_t width, uint32_t height, uint32_t layers, uint32_t bytes_per_pixel) + -> void { + wgpu::TexelCopyTextureInfo destination; + destination.texture = texture; + wgpu::TexelCopyBufferLayout layout; + layout.bytesPerRow = width * bytes_per_pixel; + layout.rowsPerImage = height; + wgpu::Extent3D size = {width, height, layers}; + queue_.WriteTexture(&destination, data, static_cast(width) * height * layers * bytes_per_pixel, &layout, &size); + } + + /** Clear, run `record`, submit and present. */ + template + [[nodiscard]] auto draw(std::array clear, Record &&record) -> bool { + const auto [window_w, window_h] = this->window_size(); + this->configure(window_w, window_h); + wgpu::SurfaceTexture surface_texture; + surface_.GetCurrentTexture(&surface_texture); + if (surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal && + surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) { + return false; + } + wgpu::TextureView view = surface_texture.texture.CreateView(); + wgpu::RenderPassColorAttachment color; + color.view = view; + color.depthSlice = 0xFFFFFFFFu; // kWGPU_DEPTH_SLICE_UNDEFINED + color.loadOp = wgpu::LoadOp::Clear; + color.storeOp = wgpu::StoreOp::Store; + color.clearValue = {clear[0], clear[1], clear[2], clear[3]}; + wgpu::RenderPassDescriptor pass_desc; + pass_desc.colorAttachmentCount = 1; + pass_desc.colorAttachments = &color; + wgpu::CommandEncoder encoder = device_.CreateCommandEncoder(); + wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&pass_desc); + record(pass, window_w, window_h); + pass.End(); + wgpu::CommandBuffer commands = encoder.Finish(); + queue_.Submit(1, &commands); + // WebGPU presents implicitly at the end of the frame; the port's + // wgpuSurfacePresent is intentionally unsupported. + this->mark_frame(); + return true; + } + + /** Publish a coarse frame counter to the page for the fps probe. */ + auto mark_frame() const -> void { + ++frames_; + if (frames_ % 15U == 0U) { + EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast(frames_)); + } + } + + wgpu::Instance instance_; + wgpu::Device device_; + wgpu::Queue queue_; + wgpu::Surface surface_; + wgpu::TextureFormat surface_format_ = wgpu::TextureFormat::BGRA8Unorm; + int surface_w_ = 0; + int surface_h_ = 0; + + wgpu::ShaderModule scene_shader_; + wgpu::ShaderModule terrain_shader_; + wgpu::BindGroupLayout scene_bind_group_layout_; + wgpu::BindGroupLayout terrain_bind_group_layout_; + wgpu::PipelineLayout scene_pipeline_layout_; + wgpu::PipelineLayout terrain_pipeline_layout_; + wgpu::RenderPipeline scene_pipeline_; + wgpu::Sampler clamp_sampler_; + wgpu::Sampler repeat_sampler_; + + image_target scene_; + image_target label_; + image_target minimap_; + terrain_target terrain_; + bool terrain_ready_ = false; + + std::deque events_; + std::array(ra3::wasmgl::detail::move_key::count)> held_{}; + mutable unsigned frames_ = 0U; + }; +} diff --git a/src/webgl/ra3.webgl.null.cppm b/src/webgpu/ra3.webgpu.null.cppm similarity index 67% rename from src/webgl/ra3.webgl.null.cppm rename to src/webgpu/ra3.webgpu.null.cppm index 8a64531..f36e55f 100644 --- a/src/webgl/ra3.webgl.null.cppm +++ b/src/webgpu/ra3.webgpu.null.cppm @@ -1,18 +1,19 @@ -export module ra3.webgl; +export module ra3.webgpu; import std; export import ra3.core; -import ra3.render; -import ra3.client; +export import ra3.render; +export import ra3.client; /** - * Fallback WebGL backend for builds without Emscripten. `init` fails so the + * Fallback WebGPU backend for builds without Emscripten. `init` fails so the * caller can select another display; the class name matches the real - * `ra3.webgl` module so the backend factory in `ra3.display` compiles unchanged. + * `ra3.webgpu` module so the backend factory in `ra3.display` compiles + * unchanged. */ -export namespace ra3::webgl { - class webgl_display final : public ra3::client::display { +export namespace ra3::webgpu { + class webgpu_display final : public ra3::client::display { public: [[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; } [[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; } @@ -20,6 +21,6 @@ export namespace ra3::webgl { [[nodiscard]] auto window_size() const -> std::pair override { return {0, 0}; } [[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; } auto shutdown() -> void override {} - [[nodiscard]] auto name() const -> std::string_view override { return "webgl(null)"; } + [[nodiscard]] auto name() const -> std::string_view override { return "webgpu(null)"; } }; }