From 70d4beca4fa7d711d271469a44e8f155243c92d7 Mon Sep 17 00:00:00 2001 From: EnderTheCoder Date: Mon, 28 Sep 2026 22:57:19 +0800 Subject: [PATCH] v0.6.0: WebAssembly build with a WebGL backend (playable in the browser) - ra3.webgl: a Web peer of Vulkan/Direct3D (SDL3 canvas + GLES3/WebGL2), with 2D blit and the GPU terrain raymarch; GLSL ES shaders; null fallback off Emscripten. backend::webgl + default_backend() (webgl under Emscripten). - Emscripten toolchain + Dockerfile.wasm + scripts/build-wasm.sh; import std, global -fexceptions, and Asyncify so the blocking frame loop yields to the browser (display::sleep_frame calls emscripten_sleep). - apps/web/shell.html + apps/web/serve.py (Range-capable dev server). - Assets: browsers forbid synchronous on-demand reads on the main thread (and FS.createLazyFile / the WasmFS fetch backend are worker-only; SDL3's Emscripten backend is main-thread DOM only, so the engine cannot run in a worker). The wasm build therefore preloads a compact per-map set via OPENRA3_WEB_ASSETS. - New openra3 textures --map ID lists the loose terrain TGAs a map resolves to (terrain::resolve_texture_files, shared with load_textures_from_dir). - Fixes: webgl heightmap used GL_R16 = 0x8229 (that is R8) -> upload rejected, terrain flattened to water; now 0x822A. Logger uses a stdout console sink on the web (stderr maps to console.error); a GL error check logs bad uploads. - CI: wasm image + build jobs. --- .gitlab-ci.yml | 40 ++ CMakeLists.txt | 112 ++++- Dockerfile.wasm | 46 ++ README.md | 47 ++- VERSION | 2 +- apps/openra3/main.cpp | 53 ++- apps/web/serve.py | 107 +++++ apps/web/shell.html | 33 ++ cmake/toolchains/emscripten-wasm.cmake | 46 ++ docs/ARCHITECTURE.md | 22 +- scripts/build-wasm.sh | 31 ++ shaders/webgl_scene_frag.glsl | 15 + shaders/webgl_scene_vert.glsl | 15 + shaders/webgl_terrain_frag.glsl | 211 ++++++++++ shaders/webgl_terrain_vert.glsl | 11 + src/client/ra3.client.cppm | 14 + src/display/ra3.display.cppm | 37 +- src/ra3.cppm | 1 + src/terrain/ra3.terrain.cppm | 80 ++-- src/webgl/ra3.webgl.cppm | 461 +++++++++++++++++++++ src/webgl/ra3.webgl.null.cppm | 25 ++ third_party/libenderlog/src/ender.log.cppm | 4 +- 22 files changed, 1341 insertions(+), 72 deletions(-) create mode 100644 Dockerfile.wasm create mode 100644 apps/web/serve.py create mode 100644 apps/web/shell.html create mode 100644 cmake/toolchains/emscripten-wasm.cmake create mode 100644 scripts/build-wasm.sh create mode 100644 shaders/webgl_scene_frag.glsl create mode 100644 shaders/webgl_scene_vert.glsl create mode 100644 shaders/webgl_terrain_frag.glsl create mode 100644 shaders/webgl_terrain_vert.glsl create mode 100644 src/webgl/ra3.webgl.cppm create mode 100644 src/webgl/ra3.webgl.null.cppm diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 68a8fc9..88bd14f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -291,4 +291,44 @@ build_debian_arm64: name: "$CI_PROJECT_NAME-debian-arm64-$CI_COMMIT_SHORT_SHA" paths: - artifacts/debian-arm64/ + expire_in: 7 days +# --- WebAssembly (Emscripten) ------------------------------------------------- +wasm_image: + stage: image + image: docker:latest + services: *dind + tags: + - docker + before_script: *retry + script: + - retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin' + - | + retry docker build \ + --cache-from type=registry,ref=$IMAGE:cache-wasm,insecure=true \ + --cache-to type=registry,ref=$IMAGE:cache-wasm,mode=max,insecure=true \ + --build-arg APT_MIRROR=$APT_MIRROR \ + --target dev \ + -t $IMAGE:$CI_COMMIT_SHORT_SHA-wasm \ + -f Dockerfile.wasm . + - retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-wasm + +build_wasm: + stage: build + image: docker:latest + services: *dind + tags: + - docker + needs: + - wasm_image + before_script: *retry + script: + - retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin' + - 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/" + artifacts: + name: "$CI_PROJECT_NAME-wasm-$CI_COMMIT_SHORT_SHA" + paths: + - artifacts/wasm/ expire_in: 7 days \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 3b8ae86..2a2a6a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,8 +7,9 @@ set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444") # Path to the standard-library module description. A cross toolchain overrides # this before the compiler is probed; this default targets the Linux LLVM -# package. -if(NOT CMAKE_CXX_STDLIB_MODULES_JSON) +# package. Skipped for Emscripten, whose toolchain supplies its own sysroot +# modules.json. +if(NOT CMAKE_CXX_STDLIB_MODULES_JSON AND NOT DEFINED ENV{EMSDK}) set(CMAKE_CXX_STDLIB_MODULES_JSON "/usr/lib/llvm-21/lib/libc++.modules.json") endif() @@ -25,7 +26,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_SCAN_FOR_MODULES ON) -project(OpenRA3 VERSION 0.5.0 LANGUAGES C CXX) +project(OpenRA3 VERSION 0.6.0 LANGUAGES C CXX) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) @@ -55,7 +56,13 @@ set(OPENRA3_HAS_SDL3 OFF) # The triple-named subdirectory inside a MinGW SDL3 development package # (`//{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(OPENRA3_SDL3_ROOT) +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) +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") set(OPENRA3_HAS_SDL3 ON) @@ -78,9 +85,15 @@ endif() # --- Vulkan: vendored volk + headers, so no Vulkan SDK is needed --------------- # The loader is resolved at runtime (volk dlopen/LoadLibrary), which lets the # same source build the Vulkan backend on Linux and on the MinGW Windows target. -option(OPENRA3_VULKAN "Build the Vulkan viewer" ON) +# WebAssembly has no Vulkan, so the viewer is off there (the null backend). +if(EMSCRIPTEN) + set(OPENRA3_VULKAN_DEFAULT OFF) +else() + set(OPENRA3_VULKAN_DEFAULT ON) +endif() +option(OPENRA3_VULKAN "Build the Vulkan viewer" ${OPENRA3_VULKAN_DEFAULT}) set(OPENRA3_HAS_VULKAN OFF) -if(OPENRA3_VULKAN AND OPENRA3_HAS_SDL3) +if(OPENRA3_VULKAN AND OPENRA3_HAS_SDL3 AND NOT EMSCRIPTEN) set(OPENRA3_HAS_VULKAN ON) endif() if(OPENRA3_HAS_VULKAN) @@ -89,9 +102,11 @@ else() message(STATUS "OpenRA3: Vulkan viewer disabled - offscreen image only") endif() -add_library(ra3_volk STATIC third_party/volk/volk.c) -target_include_directories(ra3_volk PUBLIC third_party/volk third_party/vulkan/include) -target_compile_definitions(ra3_volk PUBLIC VK_NO_PROTOTYPES) +if(NOT EMSCRIPTEN) + add_library(ra3_volk STATIC third_party/volk/volk.c) + target_include_directories(ra3_volk PUBLIC third_party/volk third_party/vulkan/include) + target_compile_definitions(ra3_volk PUBLIC VK_NO_PROTOTYPES) +endif() # Embed the committed SPIR-V blobs into a generated header at configure time. # Regenerate with shaders/compile.sh after editing a .vert/.frag. @@ -135,6 +150,25 @@ function(openra3_embed_hlsl out_header) file(WRITE "${out_header}" "${content}") endfunction() +# Embed the GLSL ES sources for the WebGL backend as string literals (compiled +# at runtime by the browser's GL, like the HLSL for Direct3D). +function(openra3_embed_glsl out_header) + set(content "// Generated by CMake from shaders/*.glsl.\n") + string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n") + foreach(src IN LISTS ARGN) + if(NOT EXISTS "${src}") + message(FATAL_ERROR "Missing GLSL shader ${src}") + endif() + get_filename_component(base "${src}" NAME_WE) + string(MAKE_C_IDENTIFIER "${base}" ident) + string(APPEND ident "_glsl") + file(READ "${src}" text) + string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3GLSL(${text})RA3GLSL\";\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 @@ -275,17 +309,55 @@ 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) +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) + openra3_embed_glsl( + "${CMAKE_CURRENT_BINARY_DIR}/generated/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" + "${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_frag.glsl" + ) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${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" + "${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 WebGL2 so GLES3-only entry points (glTexImage3D, VAOs, ...) link. + target_link_options(ra3_webgl 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) +endif() +openra3_target_defaults(ra3_webgl) + # --- 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) +target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx ra3_webgl) 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_enderlog) + ra3_ui ra3_vulkan ra3_dx ra3_webgl ra3_enderlog) openra3_target_defaults(ra3) # --- executable --------------------------------------------------------------- @@ -300,6 +372,24 @@ 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") + 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}") + target_link_options(openra3 PRIVATE "--preload-file=${OPENRA3_WEB_ASSETS}@/assets") + endif() +endif() + # --- packaging (.deb / .msi / portable archives) ----------------------------- # CPack produces the release artifacts in one shot: # Linux -> DEB (dependencies derived from the binary with dpkg-shlibdeps) diff --git a/Dockerfile.wasm b/Dockerfile.wasm new file mode 100644 index 0000000..391f8b5 --- /dev/null +++ b/Dockerfile.wasm @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 + +# WebAssembly build environment (Emscripten), producing openra3.html/.js/.wasm. +# +# 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. + +FROM emscripten/emsdk:6.0.10 AS dev + +ENV DEBIAN_FRONTEND=noninteractive + +ARG APT_MIRROR="" +RUN if [ -n "$APT_MIRROR" ]; then \ + sed -i -e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \ + -e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \ + -e "s|http://ports.ubuntu.com/ubuntu-ports|${APT_MIRROR}|g" \ + /etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \ + fi + +RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \ + --mount=type=cache,target=/var/cache/apt,sharing=locked \ + for attempt in 1 2 3 4 5; do \ + apt-get -o Acquire::Retries=5 update && break; \ + echo "apt-get update failed (attempt $attempt), retrying"; \ + sleep 5; \ + done \ + && apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ + ca-certificates \ + curl \ + ninja-build \ + && rm -rf /var/lib/apt/lists/* + +# Upstream CMake, because the emsdk image's 3.28 cannot build the project. +ARG CMAKE_VERSION=4.2.3 +RUN mkdir -p /opt/cmake \ + && curl -fL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \ + | tar -xz -C /opt/cmake --strip-components=1 +ENV PATH="/opt/cmake/bin:${PATH}" + +WORKDIR /work +COPY . . + +RUN cmake -S . -B build/wasm -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake \ + -DCMAKE_BUILD_TYPE=Release \ + && cmake --build build/wasm -j diff --git a/README.md b/README.md index c41773d..cda86e6 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,16 @@ Ghidra. ## Status -OpenRA3 is at **v0.5.0**. The engine compiles and runs headless, and a **minimal +OpenRA3 is at **v0.6.0**. The engine compiles and runs headless, and a **minimal skirmish** is playable: it reads a real multiplayer map out of your install, recovers the player start waypoints, and simulates two sides building a base, 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`) and **Direct3D 11 / 12** -(`ra3.dx`) 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 +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 +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 gutter-padded atlas), so material boundaries are smooth instead of a grid of hard lines. An in-window **menu** lists the maps by their localized name and @@ -85,7 +85,7 @@ built-in test map so the project still builds and runs in CI. | `src/core/ra3.core.cppm` | fundamental types, math, strings, random, message stream | | `src/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop | | `src/client/ra3.client.cppm` | `display` abstraction + shared interactive loops + client facade | -| `src/display/ra3.display.cppm` | picks the backend (Vulkan/D3D11/D3D12, then SDL) for the app | +| `src/display/ra3.display.cppm` | picks the backend (Vulkan/D3D11/D3D12/WebGL, then SDL) for the app | | `src/game/ra3.game.cppm` | RA3 sides, player templates, skirmish defaults | | `src/data/ra3.data.cppm` | retail RA3 balance: damage types, armour, weapons, units, economy | | `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator | @@ -96,6 +96,7 @@ 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) | | `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 | @@ -139,6 +140,9 @@ 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) +scripts/build-wasm.sh ``` Every target is cross-built from x86_64 Linux in its own image: llvm-mingw for @@ -176,6 +180,37 @@ openra3.exe render --game-dir "C:\Red Alert 3" --vulkan Vulkan is provided by **vendored volk + headers** (`third_party/`), resolved at 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). + +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: + +```bash +# 1. which loose TGAs does the map resolve to? +openra3 textures --map map_mp_2_feasel1 # prints paths under assets/terrain + +# 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 +``` + +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 +`OPENRA3_WEB_ASSETS` the module still loads and starts, but exits at the menu +because no maps are found. + ## Packages CPack produces the release artifacts; the Docker images and CI run it for every diff --git a/VERSION b/VERSION index 79a2734..09a3acf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 \ No newline at end of file +0.6.0 \ No newline at end of file diff --git a/apps/openra3/main.cpp b/apps/openra3/main.cpp index dd7c2ed..570daa9 100644 --- a/apps/openra3/main.cpp +++ b/apps/openra3/main.cpp @@ -95,7 +95,15 @@ namespace { auto setup_logging(const std::filesystem::path &exe_dir) -> void { namespace log = ender::log; log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn}); +#if defined(__EMSCRIPTEN__) + // The default sink writes to stderr, which the browser maps to + // console.error regardless of level; use stdout so INFO/WARN appear at + // their real level. There is no file sink on the web. + log::set_sinks({std::make_shared(std::cout)}); + (void) exe_dir; +#else log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U}); +#endif log::info("OpenRA3 started"); } @@ -106,13 +114,14 @@ namespace { std::puts(" openra3 menu (pick a map, then view it)"); std::puts(" openra3 menu-preview [--out FILE.bmp] (headless render of the menu)"); std::puts(" openra3 maps"); + std::puts(" openra3 textures --map ID (loose terrain TGAs a map uses)"); std::puts(" openra3 skirmish [--map ID] [--frames N] [--seed N]"); std::puts(" openra3 render [--map ID] [--frames N] [--seed N] [--zoom Z]"); std::puts(" [--out FILE.bmp] [--vulkan] [--no-window] [--no-sim] [--thumbnail]"); std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]"); std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]"); std::puts(" [--fullscreen] [--fps N] (0 = vsync)"); - std::puts(" [--vulkan] [--dx11] [--dx12] [--sdl] (preferred display backend)"); + std::puts(" [--vulkan] [--dx11] [--dx12] [--webgl] [--sdl] (preferred display backend)"); std::puts("assets: read from /assets (extracted at build time by the openra3_assets target)"); } @@ -131,6 +140,7 @@ 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, "--sdl")) return ra3::display::backend::sdl; return ra3::display::backend::vulkan; } @@ -296,6 +306,32 @@ namespace { return ready; } + /** Print the loose terrain TGA files a map resolves to (for a wasm preload). */ + auto command_textures(const std::vector &args, const std::filesystem::path &assets) -> int { + if (!ensure_assets(assets)) return 1; + const auto requested = option_value(args, "--map"); + if (!requested) { + std::puts("usage: openra3 textures --map ID"); + return 2; + } + const auto maps = list_asset_maps(assets); + const asset_map *picked = nullptr; + for (const auto &map: maps) { + if (map.id == *requested) picked = ↦ + } + if (picked == nullptr) { + std::printf("no map '%s'\n", requested->c_str()); + return 2; + } + const auto parsed = ra3::terrain::parse_map(ra3::map::to_ckmp(read_file(picked->file))); + const auto files = ra3::terrain::resolve_texture_files(parsed, assets / "terrain"); + std::printf("map %s: %zu resolved textures\n", picked->id.c_str(), files.size()); + for (const auto &file: files) { + std::printf("%s\n", file.string().c_str()); + } + return 0; + } + auto command_maps(const std::filesystem::path &assets) -> int { if (!ensure_assets(assets)) return 1; const auto maps = list_asset_maps(assets); @@ -574,9 +610,10 @@ namespace { * then the others in turn, so an unavailable backend degrades gracefully. * Direct3D is Windows-only; on other hosts those entries fall through. */ - inline constexpr std::array renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11, - ra3::display::backend::d3d12, ra3::display::backend::sdl}; - inline constexpr std::array renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"}; + 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)"}; [[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))]; @@ -661,7 +698,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, 3))]}; + case 18: return std::string{renderer_names[static_cast(std::clamp(s.renderer, 0, 4))]}; default: return "exit"; } } @@ -684,7 +721,7 @@ namespace { case 13: s.thumbnail = !s.thumbnail; break; case 15: s.fullscreen = !s.fullscreen; break; case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break; - case 18: s.renderer = (s.renderer + delta + 4) % 4; break; + case 18: s.renderer = (s.renderer + delta + 5) % 5; break; default: break; } } @@ -1084,7 +1121,8 @@ namespace { if (has_flag(args, "--vulkan")) st.settings.renderer = 0; if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1; if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2; - if (has_flag(args, "--sdl")) st.settings.renderer = 3; + if (has_flag(args, "--webgl")) st.settings.renderer = 3; + if (has_flag(args, "--sdl")) st.settings.renderer = 4; if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value); if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value); if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value); @@ -1243,6 +1281,7 @@ auto main(int argc, char **argv) -> int { if (command == "menu") return command_menu(rest, assets); if (command == "menu-preview") return command_menu_preview(rest, assets); if (command == "maps") return command_maps(assets); + if (command == "textures") return command_textures(rest, assets); if (command == "skirmish") return command_skirmish(rest, assets); if (command == "render") return command_render(rest, assets); diff --git a/apps/web/serve.py b/apps/web/serve.py new file mode 100644 index 0000000..ba8dc8c --- /dev/null +++ b/apps/web/serve.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Static file server with HTTP Range support, for the OpenRA3 wasm build. + +Emscripten's lazy MEMFS reads assets back as byte ranges, so the server must +honour the ``Range`` header -- Python's stdlib ``http.server`` does not. + + python3 apps/web/serve.py --root build/wasm/bin --port 8199 + # then open http://localhost:8199/openra3.html + +Serve ``--root`` containing ``openra3.html/.js/.wasm``, ``assets.manifest.json`` +and the extracted ``assets/`` tree (a directory or a junction/symlink to it). +""" + +import argparse +import functools +import os +import re +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + +_RANGE = re.compile(r"bytes=(\d*)-(\d*)$") + + +class RangeHandler(SimpleHTTPRequestHandler): + # SharedArrayBuffer (needed by the pthreads build) requires cross-origin + # isolation. + def end_headers(self): + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + super().end_headers() + + def send_head(self): + header = self.headers.get("Range") + if not header: + return super().send_head() + + path = self.translate_path(self.path) + if os.path.isdir(path): + return super().send_head() + try: + handle = open(path, "rb") + except OSError: + self.send_error(404, "File not found") + return None + + match = _RANGE.match(header.strip()) + if not match: + handle.close() + return super().send_head() + + size = os.fstat(handle.fileno()).st_size + start = int(match.group(1)) if match.group(1) else 0 + end = int(match.group(2)) if match.group(2) else size - 1 + if start > end or start >= size: + handle.close() + self.send_error(416, "Requested Range Not Satisfiable") + return None + end = min(end, size - 1) + + self.send_response(206) + self.send_header("Content-Type", self.guess_type(path)) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Range", f"bytes {start}-{end}/{size}") + self.send_header("Content-Length", str(end - start + 1)) + self.send_header("Cache-Control", "no-store") + self.end_headers() + handle.seek(start) + self._remaining = end - start + 1 + return handle + + def copyfile(self, source, outputfile): + remaining = getattr(self, "_remaining", None) + if remaining is None: + return super().copyfile(source, outputfile) + try: + while remaining > 0: + chunk = source.read(min(65536, remaining)) + if not chunk: + break + outputfile.write(chunk) + remaining -= len(chunk) + finally: + self._remaining = None + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server") + parser.add_argument("--root", default="build/wasm/bin", help="directory to serve") + parser.add_argument("--port", type=int, default=8199) + parser.add_argument("--bind", default="127.0.0.1") + args = parser.parse_args() + + if not os.path.isdir(args.root): + print(f"root not found: {args.root}", file=sys.stderr) + return 1 + handler = functools.partial(RangeHandler, directory=args.root) + server = ThreadingHTTPServer((args.bind, args.port), handler) + print(f"serving {args.root} at http://{args.bind}:{args.port}/openra3.html") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/web/shell.html b/apps/web/shell.html new file mode 100644 index 0000000..9c1c4e3 --- /dev/null +++ b/apps/web/shell.html @@ -0,0 +1,33 @@ + + + + + + + OpenRA3 + + + + +
Loading OpenRA3…
+ + {{{ SCRIPT }}} + + diff --git a/cmake/toolchains/emscripten-wasm.cmake b/cmake/toolchains/emscripten-wasm.cmake new file mode 100644 index 0000000..eb0437b --- /dev/null +++ b/cmake/toolchains/emscripten-wasm.cmake @@ -0,0 +1,46 @@ +# WebAssembly cross-build via Emscripten. +# +# Includes Emscripten's own platform toolchain (found through $EMSDK, set by the +# emsdk image / `emsdk_env.sh`) and then layers the project's requirements: +# +# * C++26 `import std;`. Emscripten ships the libc++ `std` module source and a +# modules.json in its sysroot; its toolchain wires that up, but only if +# `CMAKE_CXX_MODULE_STD` is on and no std-modules JSON has been set yet, so +# both are arranged before Emscripten.cmake is included and before the +# compiler is probed. +# * Global `-fexceptions`. Emscripten disables exceptions by default, and the +# 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. + +# `import std` support: the gate and the module switch must be in place before +# Emscripten.cmake runs and before the compiler is probed. +set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444") +set(CMAKE_CXX_MODULE_STD ON) +# Let Emscripten.cmake point CMake at its own sysroot modules.json. +unset(CMAKE_CXX_STDLIB_MODULES_JSON) + +if(DEFINED ENV{EMSDK}) + set(OPENRA3_EMSCRIPTEN_CMAKE "$ENV{EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake") +elseif(DEFINED ENV{EMSCRIPTEN_ROOT}) + set(OPENRA3_EMSCRIPTEN_CMAKE "$ENV{EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake") +endif() +if(NOT EXISTS "${OPENRA3_EMSCRIPTEN_CMAKE}") + message(FATAL_ERROR "Emscripten.cmake not found (set EMSDK); looked at '${OPENRA3_EMSCRIPTEN_CMAKE}'") +endif() +include("${OPENRA3_EMSCRIPTEN_CMAKE}") + +# Exceptions are used by the engine; keep them on globally so the std module and +# its consumers agree. +# +# 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. +set(CMAKE_CXX_FLAGS_INIT "-fexceptions -sASYNCIFY=1") +set(CMAKE_C_FLAGS_INIT "-sASYNCIFY=1") +set(CMAKE_EXE_LINKER_FLAGS_INIT + "-fexceptions -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY=1") +set(CMAKE_SHARED_LINKER_FLAGS_INIT + "-fexceptions -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY=1") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2f3a0f8..21632e0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,9 +62,9 @@ umbrella module re-exports the SDK; applications import `ra3` only. | \ | | \ v | ra3.display (backend pick) - | / | \ - v ra3.ui ra3.vulkan ra3.dx - | (SDL3) (Vulkan) (D3D11/12) + | / | \ \ + v ra3.ui ra3.vulkan ra3.dx ra3.webgl + | (SDL3) (Vulkan) (D3D11/12) (WebGL2) --------------------------------------------------------------------------- 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.display`, `ra3.game`, `ra3.client`, and the +`ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgl.*`, `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,6 +595,20 @@ 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]` + +- **F1 Device & canvas** `[D]` + - `[x]` Emscripten-only; SDL3 canvas + GLES 3.0 (WebGL2) via `-sUSE_SDL=3` +- **F2 2D image path** `[D]` + - `[x]` BGRA texture + fullscreen triangle (GLSL ES 300), `.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)` +- **F5 Null fallback** `[D]` + - `[x]` non-Emscripten builds link a stub that fails `init` + ### M19 `ra3.audio` — audio `[ ]` - **F1 SFX** `[ ]` diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100644 index 0000000..a6151cd --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Build the WebAssembly target (openra3.html/.js/.wasm) 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: +# +# OPENRA3_WEB_ASSETS="$(mktemp -d)" \ +# openra3 textures --map map_mp_2_feasel1 # copy these under the set's terrain/ +# +# then point OPENRA3_WEB_ASSETS at that directory. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IMAGE="${OPENRA3_WASM_IMAGE:-openra3-wasm:local}" +BUILD_DIR="${1:-build/wasm}" + +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) +if [ -n "${OPENRA3_WEB_ASSETS:-}" ]; then + mounts+=(-v "$OPENRA3_WEB_ASSETS:/assets:ro") + configure+=(-DOPENRA3_WEB_ASSETS=/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" diff --git a/shaders/webgl_scene_frag.glsl b/shaders/webgl_scene_frag.glsl new file mode 100644 index 0000000..82b2c67 --- /dev/null +++ b/shaders/webgl_scene_frag.glsl @@ -0,0 +1,15 @@ +#version 300 es +// Samples the software-rendered scene image (the WebGL port of scene.frag). +precision highp float; +precision highp sampler2D; + +uniform sampler2D u_scene; + +in vec2 v_uv; +out vec4 frag_color; + +void main() { + if (v_uv.x < 0.0 || v_uv.x > 1.0 || v_uv.y < 0.0 || v_uv.y > 1.0) discard; + // The engine stores 0xAARRGGBB (BGRA in memory), uploaded as GL_RGBA. + frag_color = texture(u_scene, v_uv).bgra; +} diff --git a/shaders/webgl_scene_vert.glsl b/shaders/webgl_scene_vert.glsl new file mode 100644 index 0000000..c1240d3 --- /dev/null +++ b/shaders/webgl_scene_vert.glsl @@ -0,0 +1,15 @@ +#version 300 es +// Fullscreen triangle for the 2D image path (the WebGL port of scene.vert). +// WebGL clip space is +Y up, so the vertex position flips Y relative to the +// Vulkan shader; the sampled UVs and the image's top-left origin are unchanged. +precision highp float; + +uniform vec4 u_rect; // xy = top-left (0..1), zw = size (0..1) + +out vec2 v_uv; + +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + v_uv = (p - u_rect.xy) / u_rect.zw; + gl_Position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0); +} diff --git a/shaders/webgl_terrain_frag.glsl b/shaders/webgl_terrain_frag.glsl new file mode 100644 index 0000000..a8d8f22 --- /dev/null +++ b/shaders/webgl_terrain_frag.glsl @@ -0,0 +1,211 @@ +#version 300 es +// GPU heightfield raymarcher for the real RA3 terrain (the WebGL port of +// terrain.frag). The Vulkan push constants become a set of vec4 uniforms. +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp sampler2DArray; + +uniform sampler2D u_heightmap; // R16 heights +uniform sampler2D u_celldata; // per-cell blend record (RGBA16) +uniform sampler2DArray u_atlas; // tile material array (RGBA8) + +// One contiguous array so the host can upload all five vec4s with a single +// glUniform4fv; the names keep the shader body identical to terrain.frag. +uniform vec4 u_data[5]; +#define u_cam u_data[0] // x=target_x, y=target_y, z=yaw, w=height +#define u_params u_data[1] // x=pitch, y=fov, z=water_z, w=has_water +#define u_sun u_data[2] // xyz=sun dir, w=ambient +#define u_mapinfo u_data[3] // x=W, y=H, z=unused, w=z_scale +#define u_misc u_data[4] // x=time, y=unused, z=cells per texture repeat, w=aspect + +in vec2 v_uv; +out vec4 frag_color; + +const float CELL = 10.0; // must match ra3::terrain::cell_size + +float height_at(ivec2 c) { + c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1); + return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w; +} + +float world_height(float wx, float wy) { + float world_w = u_mapinfo.x * CELL; + float world_h = u_mapinfo.y * CELL; + if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) return -1.0e9; + ivec2 c = ivec2(int(wx / CELL), int((world_h - wy) / CELL)); + return height_at(c); +} + +vec3 sky_color(vec3 dir) { + vec3 d = normalize(dir); + vec3 sun_dir = normalize(u_sun.xyz); + float t = clamp(d.z, 0.0, 1.0); + vec3 horizon = vec3(0.70, 0.78, 0.85); + vec3 zenith = vec3(0.28, 0.48, 0.80); + vec3 col = mix(horizon, zenith, pow(t, 0.6)); + float sun = max(dot(d, sun_dir), 0.0); + col += vec3(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6; // sun disk + col += vec3(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18; // glow + return col; +} + +// The retail SAGE blend ramp (see terrain.frag). +float blend_factor(uint direction, uint flags, vec2 f) { + bool flipped = (flags & 1u) != 0u; + bool 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) { + float s = (1.0 - f.x) + (1.0 - f.y); + return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0); + } + if (direction == 8u) { + float s = f.x + (1.0 - f.y); + return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0); + } + return 0.0; +} + +vec3 sample_layer(uint layer, float wx, float wy) { + float span = max(u_misc.z, 1.0); + int layer_count = textureSize(u_atlas, 0).z; + float l = float(min(layer, uint(layer_count - 1))); + // The atlas is 0xAARRGGBB (BGRA in memory), uploaded as GL_RGBA. + return texture(u_atlas, vec3(vec2(wx, wy) / span, l)).bgr; +} + +void main() { + vec4 p = u_cam; + float pitch = clamp(u_params.x, 0.15, 1.45); + float fov = clamp(u_params.y, 0.3, 1.4); + float world_w = u_mapinfo.x * CELL; + float world_h = u_mapinfo.y * CELL; + + float cp = cos(pitch); + vec3 fwd = vec3(cp * sin(p.z), cp * cos(p.z), -sin(pitch)); + vec3 right = normalize(cross(fwd, vec3(0, 0, 1))); + vec3 up = cross(right, fwd); + + float target_z = world_height(p.x, p.y); + if (target_z < -1.0e8) target_z = 0.0; + float dist = p.w / sin(pitch); + vec3 cam = vec3(p.x, p.y, target_z + p.w) - fwd * dist; + + vec2 ndc = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0); + float aspect = u_misc.w; + float th = tan(fov * 0.5); + vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); + + if (dir.z >= -1e-4) { + frag_color = vec4(sky_color(dir), 1.0); + return; + } + + 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) { + 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; + } + if (u_params.w > 0.5 && w.z <= u_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; + } + if (!hit) { + frag_color = vec4(sky_color(dir), 1.0); + return; + } + + float lo = prev; + float hi = hit_t; + for (int i = 0; i < 6; ++i) { + float mid = 0.5 * (lo + hi); + vec3 w = cam + dir * mid; + bool water = u_params.w > 0.5 && w.z <= u_params.z; + if (water || w.z <= world_height(w.x, w.y)) { + hi = mid; + } else { + lo = mid; + } + } + vec3 hitpos = cam + dir * hi; + + vec3 sun = normalize(u_sun.xyz); + float ambient = u_sun.w; + + if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) { + float time = u_misc.x; + vec2 q = hitpos.xy * 0.015; + float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3); + float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9); + vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0)); + float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0); + vec3 deep = vec3(0.03, 0.16, 0.28); + vec3 refl = sky_color(reflect(dir, n)); + float lam = max(0.0, dot(n, sun)); + vec3 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; + float 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); + frag_color = vec4(water, 1.0); + return; + } + + float wx = hitpos.x / CELL; + float wy = (world_h - hitpos.y) / CELL; + int cx = clamp(int(wx), 0, int(u_mapinfo.x) - 1); + int cy = clamp(int(wy), 0, int(u_mapinfo.y) - 1); + float fx = wx - floor(wx); + float fy = wy - floor(wy); + + uvec4 record = uvec4(texelFetch(u_celldata, ivec2(cx, cy), 0) * 65535.0 + 0.5); + uint packed = record.w; + uint dir1 = packed & 0xFu; + uint flags1 = (packed >> 4u) & 0x3u; + uint dir2 = (packed >> 8u) & 0xFu; + uint flags2 = (packed >> 12u) & 0x3u; + + vec2 fracUV = vec2(fx, fy); + vec3 c0 = sample_layer(record.x, wx, wy); + vec3 c1 = sample_layer(record.y, wx, wy); + vec3 c2 = sample_layer(record.z, wx, wy); + float f1 = blend_factor(dir1, flags1, fracUV); + float f2 = blend_factor(dir2, flags2, fracUV); + vec3 albedo = mix(mix(c0, c1, f1), c2, f2); + + float hl = world_height(hitpos.x - CELL, hitpos.y); + float hr = world_height(hitpos.x + CELL, hitpos.y); + float hd = world_height(hitpos.x, hitpos.y - CELL); + float hu = world_height(hitpos.x, hitpos.y + CELL); + vec3 n = normalize(vec3(hl - hr, hd - hu, 2.0 * CELL)); + float lambert = max(0.0, dot(n, sun)); + vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert); + float 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); + frag_color = vec4(lit, 1.0); +} diff --git a/shaders/webgl_terrain_vert.glsl b/shaders/webgl_terrain_vert.glsl new file mode 100644 index 0000000..70da87b --- /dev/null +++ b/shaders/webgl_terrain_vert.glsl @@ -0,0 +1,11 @@ +#version 300 es +// Fullscreen triangle for the terrain raymarcher (the WebGL port of terrain.vert). +precision highp float; + +out vec2 v_uv; + +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + v_uv = p; + gl_Position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0); +} diff --git a/src/client/ra3.client.cppm b/src/client/ra3.client.cppm index 02b8026..1570b64 100644 --- a/src/client/ra3.client.cppm +++ b/src/client/ra3.client.cppm @@ -1,3 +1,10 @@ +module; + +#if defined(__EMSCRIPTEN__) +// Yields to the browser (requires -sASYNCIFY); see display::sleep_frame. +extern "C" void emscripten_sleep(unsigned int ms); +#endif + export module ra3.client; import std; @@ -85,12 +92,19 @@ export namespace ra3::client { /** Throttle an idle frame according to the configured limit. */ auto sleep_frame() const -> void { +#if defined(__EMSCRIPTEN__) + // The frame loops are blocking; on the web the only way to let the + // browser paint and handle input is to yield (Asyncify sleep). + const int fps = fps_limit_ > 0 ? fps_limit_ : 60; + emscripten_sleep(static_cast(std::max(1, 1000 / fps))); +#else if (fps_limit_ > 0) { std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, 1000 / fps_limit_))); } else if (fps_limit_ < 0) { std::this_thread::yield(); } // fps_limit_ == 0: vertical sync already blocks in present(). +#endif } // ---- shared interactive loops (assume `init` succeeded) -------------- diff --git a/src/display/ra3.display.cppm b/src/display/ra3.display.cppm index 9ba6dc0..6aec41d 100644 --- a/src/display/ra3.display.cppm +++ b/src/display/ra3.display.cppm @@ -9,17 +9,18 @@ import ra3.client; import ra3.ui; import ra3.vulkan; import ra3.dx; +import ra3.webgl; /** * 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 (both GPU terrain), 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 is Vulkan/D3D-only and reports failure so the caller can fall - * back to the software renderer. + * 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. */ export namespace ra3::display { using ra3::client::display_options; @@ -32,13 +33,23 @@ export namespace ra3::display { using camera_frame = std::function; /** A concrete presentation backend, or `none`. */ - enum class backend { none, vulkan, d3d11, d3d12, sdl }; + enum class backend { none, vulkan, d3d11, d3d12, webgl, sdl }; + + /** The backend preferred on this host: WebGL under Emscripten, else Vulkan. */ + [[nodiscard]] inline constexpr auto default_backend() -> backend { +#if defined(__EMSCRIPTEN__) + return backend::webgl; +#else + return backend::vulkan; +#endif + } [[nodiscard]] inline auto backend_name(backend which) -> std::string_view { switch (which) { case backend::vulkan: return "vulkan"; case backend::d3d11: return "d3d11"; case backend::d3d12: return "d3d12"; + case backend::webgl: return "webgl"; case backend::sdl: return "sdl"; default: return "none"; } @@ -49,19 +60,21 @@ 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::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::sdl}; - case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::sdl}; - case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan}; + 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::vulkan: - default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl}; + default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgl, backend::sdl}; } } @@ -105,7 +118,7 @@ export namespace ra3::display { } /** Interactive menu on the preferred (or first available) backend. */ - [[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame, backend preferred = backend::vulkan) -> bool { + [[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame, backend preferred = default_backend()) -> bool { return with_display(preferred, [&](ra3::client::display &d) { return d.run_menu(options, frame); }); } diff --git a/src/ra3.cppm b/src/ra3.cppm index 5df83f5..a1d5071 100644 --- a/src/ra3.cppm +++ b/src/ra3.cppm @@ -19,4 +19,5 @@ export import ra3.terrain; export import ra3.ui; export import ra3.vulkan; export import ra3.dx; +export import ra3.webgl; export import ra3.display; diff --git a/src/terrain/ra3.terrain.cppm b/src/terrain/ra3.terrain.cppm index 582bf29..9562d00 100644 --- a/src/terrain/ra3.terrain.cppm +++ b/src/terrain/ra3.terrain.cppm @@ -394,12 +394,13 @@ export namespace ra3::terrain { uint32 supersample = 2U; ///< Render at Nx and box-downsample (antialiasing). }; - /** Load tile textures from a directory of loose `*.tga` files (extracted assets). */ - [[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir, - const std::function &progress = {}) -> texture_set { - std::unordered_map files; - std::error_code ec; - if (std::filesystem::is_directory(dir, ec)) { + namespace detail { + /** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */ + [[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir) + -> std::unordered_map { + std::unordered_map files; + std::error_code ec; + if (!std::filesystem::is_directory(dir, ec)) return files; // Recursive: our own extract writes flat `terrain/*.tga`, ra3tools // writes nested `.../art/terrain/*.tga`. Only index `terrain` dirs // so a full asset dump does not pull in every unrelated TGA. @@ -409,38 +410,59 @@ export namespace ra3::terrain { auto parent = path.parent_path().filename().string(); std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); if (parent != "terrain") continue; - auto stem = detail::tga_stem(path.filename().string()); + auto stem = tga_stem(path.filename().string()); if (stem.size() > 4U && stem.ends_with("_nrm")) continue; files.try_emplace(stem, path); } + return files; } + + /** Match one map texture name against the index (see `load_textures_from_dir`). */ + [[nodiscard]] inline auto match_terrain_file(const std::unordered_map &files, std::string name) + -> std::filesystem::path { + std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) { + if (const auto it = files.find(candidate); it != files.end()) return it->second; + } + for (const auto &[stem, path]: files) { + if (stem.ends_with(name)) return path; + } + return {}; + } + } // namespace detail + + /** + * The loose `*.tga` files `map` resolves to under `dir`, de-duplicated. + * + * Used to stage just the tiles a single map needs (e.g. the wasm preload). + */ + [[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector { + const auto files = detail::terrain_file_index(dir); + std::vector resolved; + for (const auto &texture: map.textures) { + if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found)); + } + std::sort(resolved.begin(), resolved.end()); + resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end()); + return resolved; + } + + /** Load tile textures from a directory of loose `*.tga` files (extracted assets). */ + [[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir, + const std::function &progress = {}) -> texture_set { + const auto files = detail::terrain_file_index(dir); texture_set set; set.images.resize(map.textures.size()); for (usize i = 0; i < map.textures.size(); ++i) { - auto name = map.textures[i].name; - std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); - std::filesystem::path found; - for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) { - if (const auto it = files.find(candidate); it != files.end()) { - found = it->second; - break; + const auto found = detail::match_terrain_file(files, map.textures[i].name); + if (!found.empty()) { + try { + std::ifstream in(found, std::ios::binary); + std::vector raw((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + set.images[i] = ra3::render::decode_tga(raw); + } catch (const std::exception &) { } } - if (found.empty()) { - for (const auto &[stem, path]: files) { - if (stem.ends_with(name)) { - found = path; - break; - } - } - } - if (found.empty()) continue; - try { - std::ifstream in(found, std::ios::binary); - std::vector raw((std::istreambuf_iterator(in)), std::istreambuf_iterator()); - set.images[i] = ra3::render::decode_tga(raw); - } catch (const std::exception &) { - } if (progress && !map.textures.empty()) progress(static_cast(i + 1U) / static_cast(map.textures.size())); } return set; diff --git a/src/webgl/ra3.webgl.cppm b/src/webgl/ra3.webgl.cppm new file mode 100644 index 0000000..5b5c09e --- /dev/null +++ b/src/webgl/ra3.webgl.cppm @@ -0,0 +1,461 @@ +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/webgl/ra3.webgl.null.cppm b/src/webgl/ra3.webgl.null.cppm new file mode 100644 index 0000000..8a64531 --- /dev/null +++ b/src/webgl/ra3.webgl.null.cppm @@ -0,0 +1,25 @@ +export module ra3.webgl; + +import std; + +export import ra3.core; +import ra3.render; +import ra3.client; + +/** + * Fallback WebGL 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. + */ +export namespace ra3::webgl { + class webgl_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 "webgl(null)"; } + }; +} diff --git a/third_party/libenderlog/src/ender.log.cppm b/third_party/libenderlog/src/ender.log.cppm index 85000e5..f842953 100644 --- a/third_party/libenderlog/src/ender.log.cppm +++ b/third_party/libenderlog/src/ender.log.cppm @@ -22,7 +22,7 @@ module; #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include -#elif defined(__unix__) || defined(__APPLE__) +#elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__) #include #endif @@ -367,7 +367,7 @@ export namespace ender::log { text += std::format(" #{:<3}{}\n", index, symbolicate_frame(frames[index])); } return text; -#elif defined(__unix__) || defined(__APPLE__) +#elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__) void *frames[max_frames] = {}; const int count = ::backtrace(frames, static_cast(std::min(max_frames, skip + std::max(depth, 1U)))); char **symbols = ::backtrace_symbols(frames, count);