v0.6.0: WebAssembly build with a WebGL backend (playable in the browser)
- ra3.webgl: a Web peer of Vulkan/Direct3D (SDL3 canvas + GLES3/WebGL2), with 2D blit and the GPU terrain raymarch; GLSL ES shaders; null fallback off Emscripten. backend::webgl + default_backend() (webgl under Emscripten). - Emscripten toolchain + Dockerfile.wasm + scripts/build-wasm.sh; import std, global -fexceptions, and Asyncify so the blocking frame loop yields to the browser (display::sleep_frame calls emscripten_sleep). - apps/web/shell.html + apps/web/serve.py (Range-capable dev server). - Assets: browsers forbid synchronous on-demand reads on the main thread (and FS.createLazyFile / the WasmFS fetch backend are worker-only; SDL3's Emscripten backend is main-thread DOM only, so the engine cannot run in a worker). The wasm build therefore preloads a compact per-map set via OPENRA3_WEB_ASSETS. - New openra3 textures --map ID lists the loose terrain TGAs a map resolves to (terrain::resolve_texture_files, shared with load_textures_from_dir). - Fixes: webgl heightmap used GL_R16 = 0x8229 (that is R8) -> upload rejected, terrain flattened to water; now 0x822A. Logger uses a stdout console sink on the web (stderr maps to console.error); a GL error check logs bad uploads. - CI: wasm image + build jobs.
This commit is contained in:
@@ -292,3 +292,43 @@ build_debian_arm64:
|
||||
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
|
||||
+98
-8
@@ -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
|
||||
# (`<root>/<triple>/{include,lib,bin}`); x86_64 unless a toolchain overrides it.
|
||||
set(OPENRA3_SDL3_MINGW_TRIPLE "x86_64-w64-mingw32" CACHE STRING "SDL3 MinGW triple subdirectory")
|
||||
if(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()
|
||||
|
||||
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 <stacktrace>, 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)
|
||||
"$<TARGET_FILE_DIR:openra3>")
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+46
-7
@@ -95,7 +95,15 @@ namespace {
|
||||
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
|
||||
namespace log = ender::log;
|
||||
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// The default sink writes to stderr, which the browser maps to
|
||||
// console.error regardless of level; use stdout so INFO/WARN appear at
|
||||
// their real level. There is no file sink on the web.
|
||||
log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
|
||||
(void) exe_dir;
|
||||
#else
|
||||
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
|
||||
#endif
|
||||
log::info("OpenRA3 started");
|
||||
}
|
||||
|
||||
@@ -106,13 +114,14 @@ namespace {
|
||||
std::puts(" openra3 menu (pick a map, then view it)");
|
||||
std::puts(" openra3 menu-preview [--out FILE.bmp] (headless render of the menu)");
|
||||
std::puts(" openra3 maps");
|
||||
std::puts(" openra3 textures --map ID (loose terrain TGAs a map uses)");
|
||||
std::puts(" openra3 skirmish [--map ID] [--frames N] [--seed N]");
|
||||
std::puts(" openra3 render [--map ID] [--frames N] [--seed N] [--zoom Z]");
|
||||
std::puts(" [--out FILE.bmp] [--vulkan] [--no-window] [--no-sim] [--thumbnail]");
|
||||
std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]");
|
||||
std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]");
|
||||
std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
|
||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--sdl] (preferred display backend)");
|
||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgl] [--sdl] (preferred display backend)");
|
||||
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
|
||||
}
|
||||
|
||||
@@ -131,6 +140,7 @@ namespace {
|
||||
[[nodiscard]] auto display_backend_from_args(const std::vector<std::string> &args) -> ra3::display::backend {
|
||||
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) return ra3::display::backend::d3d11;
|
||||
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12;
|
||||
if (has_flag(args, "--webgl")) return ra3::display::backend::webgl;
|
||||
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
|
||||
return ra3::display::backend::vulkan;
|
||||
}
|
||||
@@ -296,6 +306,32 @@ namespace {
|
||||
return ready;
|
||||
}
|
||||
|
||||
/** Print the loose terrain TGA files a map resolves to (for a wasm preload). */
|
||||
auto command_textures(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
|
||||
if (!ensure_assets(assets)) return 1;
|
||||
const auto requested = option_value(args, "--map");
|
||||
if (!requested) {
|
||||
std::puts("usage: openra3 textures --map ID");
|
||||
return 2;
|
||||
}
|
||||
const auto maps = list_asset_maps(assets);
|
||||
const asset_map *picked = nullptr;
|
||||
for (const auto &map: maps) {
|
||||
if (map.id == *requested) picked = ↦
|
||||
}
|
||||
if (picked == nullptr) {
|
||||
std::printf("no map '%s'\n", requested->c_str());
|
||||
return 2;
|
||||
}
|
||||
const auto parsed = ra3::terrain::parse_map(ra3::map::to_ckmp(read_file(picked->file)));
|
||||
const auto files = ra3::terrain::resolve_texture_files(parsed, assets / "terrain");
|
||||
std::printf("map %s: %zu resolved textures\n", picked->id.c_str(), files.size());
|
||||
for (const auto &file: files) {
|
||||
std::printf("%s\n", file.string().c_str());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto command_maps(const std::filesystem::path &assets) -> int {
|
||||
if (!ensure_assets(assets)) return 1;
|
||||
const auto maps = list_asset_maps(assets);
|
||||
@@ -574,9 +610,10 @@ namespace {
|
||||
* then the others in turn, so an unavailable backend degrades gracefully.
|
||||
* Direct3D is Windows-only; on other hosts those entries fall through.
|
||||
*/
|
||||
inline constexpr std::array<ra3::display::backend, 4> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||
ra3::display::backend::d3d12, ra3::display::backend::sdl};
|
||||
inline constexpr std::array<std::string_view, 4> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"};
|
||||
inline constexpr std::array<ra3::display::backend, 5> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||
ra3::display::backend::d3d12, ra3::display::backend::webgl,
|
||||
ra3::display::backend::sdl};
|
||||
inline constexpr std::array<std::string_view, 5> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGL", "SDL (software)"};
|
||||
|
||||
[[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend {
|
||||
return renderer_backends[static_cast<std::size_t>(std::clamp(index, 0, static_cast<int>(renderer_backends.size()) - 1))];
|
||||
@@ -661,7 +698,7 @@ namespace {
|
||||
case 15: return s.fullscreen ? "yes" : "no";
|
||||
case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps";
|
||||
case 17: return "start";
|
||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 3))]};
|
||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 4))]};
|
||||
default: return "exit";
|
||||
}
|
||||
}
|
||||
@@ -684,7 +721,7 @@ namespace {
|
||||
case 13: s.thumbnail = !s.thumbnail; break;
|
||||
case 15: s.fullscreen = !s.fullscreen; break;
|
||||
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
|
||||
case 18: s.renderer = (s.renderer + delta + 4) % 4; break;
|
||||
case 18: s.renderer = (s.renderer + delta + 5) % 5; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
@@ -1084,7 +1121,8 @@ namespace {
|
||||
if (has_flag(args, "--vulkan")) st.settings.renderer = 0;
|
||||
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1;
|
||||
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2;
|
||||
if (has_flag(args, "--sdl")) st.settings.renderer = 3;
|
||||
if (has_flag(args, "--webgl")) st.settings.renderer = 3;
|
||||
if (has_flag(args, "--sdl")) st.settings.renderer = 4;
|
||||
if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value);
|
||||
if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value);
|
||||
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
|
||||
@@ -1243,6 +1281,7 @@ auto main(int argc, char **argv) -> int {
|
||||
if (command == "menu") return command_menu(rest, assets);
|
||||
if (command == "menu-preview") return command_menu_preview(rest, assets);
|
||||
if (command == "maps") return command_maps(assets);
|
||||
if (command == "textures") return command_textures(rest, assets);
|
||||
if (command == "skirmish") return command_skirmish(rest, assets);
|
||||
if (command == "render") return command_render(rest, assets);
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<!-- Emscripten shell for OpenRA3. SDL3 (built for Emscripten) renders into the
|
||||
canvas element below; the SCRIPT placeholder is replaced by the generated
|
||||
loader. Note: the shell preprocessor treats a leading hash as a directive,
|
||||
so do not begin a line with one. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>OpenRA3</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #1a0a08; overflow: hidden; }
|
||||
#canvas { display: block; width: 100vw; height: 100vh; outline: none; }
|
||||
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #ecbe50; font: 16px/1.4 monospace; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas>
|
||||
<div id="loading">Loading OpenRA3…</div>
|
||||
<script>
|
||||
// Emscripten merges this into the generated Module; drop the loading
|
||||
// placeholder once the runtime is up.
|
||||
var Module = {
|
||||
onRuntimeInitialized: function () {
|
||||
var el = document.getElementById('loading');
|
||||
if (el) el.remove();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
{{{ SCRIPT }}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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")
|
||||
+18
-4
@@ -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** `[ ]`
|
||||
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<unsigned>(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) --------------
|
||||
|
||||
@@ -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<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
|
||||
|
||||
/** 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<ra3::vulkan::vulkan_display>();
|
||||
case backend::d3d11: return std::make_unique<ra3::dx::d3d11_display>();
|
||||
case backend::d3d12: return std::make_unique<ra3::dx::d3d12_display>();
|
||||
case backend::webgl: return std::make_unique<ra3::webgl::webgl_display>();
|
||||
case backend::sdl: return std::make_unique<ra3::ui::sdl_display>();
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
/** The order in which backends are attempted for a preferred one. */
|
||||
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 4> {
|
||||
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 5> {
|
||||
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); });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void(float)> &progress = {}) -> texture_set {
|
||||
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::string, std::filesystem::path> {
|
||||
std::unordered_map<std::string, std::filesystem::path> files;
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_directory(dir, 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<char>(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<std::string, std::filesystem::path> &files, std::string name)
|
||||
-> std::filesystem::path {
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast<char>(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<std::filesystem::path> {
|
||||
const auto files = detail::terrain_file_index(dir);
|
||||
std::vector<std::filesystem::path> 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<void(float)> &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<char>(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;
|
||||
}
|
||||
}
|
||||
if (found.empty()) {
|
||||
for (const auto &[stem, path]: files) {
|
||||
if (stem.ends_with(name)) {
|
||||
found = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.empty()) continue;
|
||||
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<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
set.images[i] = ra3::render::decode_tga(raw);
|
||||
} catch (const std::exception &) {
|
||||
}
|
||||
}
|
||||
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
|
||||
}
|
||||
return set;
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
module;
|
||||
|
||||
// Emscripten/GLES3 headers in the global module fragment (C headers).
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <GLES3/gl3.h>
|
||||
|
||||
// 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<float, 20> {
|
||||
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<float>(terrain.width),
|
||||
static_cast<float>(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<const char *>(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<int, int> 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<float>(window_w) / static_cast<float>(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<float>(window_w) - static_cast<float>(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<char, 1024> log{};
|
||||
glGetShaderInfoLog(shader, static_cast<GLsizei>(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<char, 1024> log{};
|
||||
glGetProgramInfoLog(program, static_cast<GLsizei>(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<unsigned>(internal), static_cast<unsigned>(format), static_cast<unsigned>(type), w, h,
|
||||
static_cast<unsigned>(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<int>(frame.width()),
|
||||
static_cast<int>(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<int>(source.width()),
|
||||
static_cast<int>(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<int>(terrain.width),
|
||||
static_cast<int>(terrain.height), terrain.heights.data(), false, false);
|
||||
this->make_texture2d(cell_tex_, GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
|
||||
static_cast<int>(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<GLsizei>(terrain.layer_size), static_cast<GLsizei>(terrain.layer_size),
|
||||
static_cast<GLsizei>(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<float>(window_w), dest.y / static_cast<float>(window_h),
|
||||
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(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<float>(w), static_cast<float>(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;
|
||||
};
|
||||
}
|
||||
@@ -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<int, int> 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)"; }
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -22,7 +22,7 @@ module;
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#elif defined(__unix__) || defined(__APPLE__)
|
||||
#elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__)
|
||||
#include <execinfo.h>
|
||||
#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<int>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U))));
|
||||
char **symbols = ::backtrace_symbols(frames, count);
|
||||
|
||||
Reference in New Issue
Block a user