v0.7.0: SDL-free wasm worker backend (WebGPU + WebGL2) with on-demand assets
The engine now runs on a plain Web Worker (no SDL, no PROXY_TO_PTHREAD: in a pthread Emscripten proxies every filesystem syscall to the main browser thread, where synchronous XHR - and thus FS.createLazyFile - is forbidden). A page/worker pair transfers an OffscreenCanvas and forwards DOM input; assets load lazily from an embedded manifest, so entering a map fetches only that map and its tiles. Presentation: ra3.webgpu (WebGPU via Emscripten's emdawnwebgpu port, WGSL shaders, the default on wasm) and ra3.wasmgl (WebGL2, GLSL ES). The legacy SDL ra3.webgl backend is removed.
This commit is contained in:
+1
-1
@@ -326,7 +326,7 @@ build_wasm:
|
|||||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-wasm
|
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-wasm
|
||||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake -S . -B build/wasm -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release
|
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake -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 cmake --build build/wasm -j
|
||||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm bash -c "mkdir -p artifacts/wasm && cp build/wasm/bin/openra3.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm build/wasm/bin/openra3.data artifacts/wasm/ 2>/dev/null || cp build/wasm/bin/openra3.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm artifacts/wasm/"
|
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm bash -c "mkdir -p artifacts/wasm && cp build/wasm/bin/index.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm build/wasm/bin/openra3.worker.js artifacts/wasm/ && cp build/wasm/bin/openra3.data artifacts/wasm/ 2>/dev/null || true"
|
||||||
artifacts:
|
artifacts:
|
||||||
name: "$CI_PROJECT_NAME-wasm-$CI_COMMIT_SHORT_SHA"
|
name: "$CI_PROJECT_NAME-wasm-$CI_COMMIT_SHORT_SHA"
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
+112
-41
@@ -57,11 +57,9 @@ set(OPENRA3_HAS_SDL3 OFF)
|
|||||||
# (`<root>/<triple>/{include,lib,bin}`); x86_64 unless a toolchain overrides it.
|
# (`<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")
|
set(OPENRA3_SDL3_MINGW_TRIPLE "x86_64-w64-mingw32" CACHE STRING "SDL3 MinGW triple subdirectory")
|
||||||
if(EMSCRIPTEN)
|
if(EMSCRIPTEN)
|
||||||
# Emscripten's SDL3 web port provides the headers and the (static) library
|
# The wasm build runs the engine on a Web Worker and renders into an
|
||||||
# via the `-sUSE_SDL=3` flag, on every target that uses SDL.
|
# OffscreenCanvas (ra3.webgpu / ra3.wasmgl), so it links no SDL: SDL3's
|
||||||
target_compile_options(openra3_sdl3 INTERFACE -sUSE_SDL=3)
|
# Emscripten port is main-thread DOM only and cannot run in a worker.
|
||||||
target_link_options(openra3_sdl3 INTERFACE -sUSE_SDL=3)
|
|
||||||
set(OPENRA3_HAS_SDL3 ON)
|
|
||||||
elseif(OPENRA3_SDL3_ROOT)
|
elseif(OPENRA3_SDL3_ROOT)
|
||||||
target_include_directories(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/include")
|
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")
|
target_link_libraries(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/lib/libSDL3.dll.a")
|
||||||
@@ -169,6 +167,25 @@ function(openra3_embed_glsl out_header)
|
|||||||
file(WRITE "${out_header}" "${content}")
|
file(WRITE "${out_header}" "${content}")
|
||||||
endfunction()
|
endfunction()
|
||||||
|
|
||||||
|
# Embed the WGSL sources for the WebGPU backend as string literals (compiled at
|
||||||
|
# runtime by the browser's WebGPU, like the GLSL/HLSL for the other backends).
|
||||||
|
function(openra3_embed_wgsl out_header)
|
||||||
|
set(content "// Generated by CMake from shaders/*.wgsl.\n")
|
||||||
|
string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n")
|
||||||
|
foreach(src IN LISTS ARGN)
|
||||||
|
if(NOT EXISTS "${src}")
|
||||||
|
message(FATAL_ERROR "Missing WGSL shader ${src}")
|
||||||
|
endif()
|
||||||
|
get_filename_component(base "${src}" NAME_WE)
|
||||||
|
string(MAKE_C_IDENTIFIER "${base}" ident)
|
||||||
|
string(APPEND ident "_wgsl")
|
||||||
|
file(READ "${src}" text)
|
||||||
|
string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3WGSL(${text})RA3WGSL\";\n\n")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND content "} // namespace ra3_shaders\n")
|
||||||
|
file(WRITE "${out_header}" "${content}")
|
||||||
|
endfunction()
|
||||||
|
|
||||||
# --- logging (vendored libenderlog, MIT) -------------------------------------
|
# --- logging (vendored libenderlog, MIT) -------------------------------------
|
||||||
# A standalone C++26 module logger (`import ender.log;`) with a per-run
|
# 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
|
# archiving file sink. libc++ has no <stacktrace>, so on this toolchain the
|
||||||
@@ -309,23 +326,20 @@ else()
|
|||||||
endif()
|
endif()
|
||||||
openra3_target_defaults(ra3_dx)
|
openra3_target_defaults(ra3_dx)
|
||||||
|
|
||||||
# --- WebGL viewer (Emscripten/WebAssembly, or null fallback) ------------------
|
# --- wasm worker viewer (SDL-free OffscreenCanvas + WebGL2, or null fallback) --
|
||||||
# A peer of the Vulkan/Direct3D backends; built only for the wasm target, where
|
# The default wasm backend: the engine runs on a plain Web Worker and renders
|
||||||
# it uses an SDL3 canvas + GLES 3.0 (WebGL2). Elsewhere it is the null stub.
|
# into an OffscreenCanvas transferred from the page, with no SDL. Non-wasm builds
|
||||||
option(OPENRA3_WEBGL "Build the WebGL viewer (Emscripten)" ON)
|
# link the null stub (init fails, so desktop backends are unaffected).
|
||||||
set(OPENRA3_HAS_WEBGL OFF)
|
option(OPENRA3_WASMGL "Build the SDL-free wasm worker viewer (Emscripten)" ON)
|
||||||
if(OPENRA3_WEBGL AND EMSCRIPTEN)
|
set(OPENRA3_HAS_WASMGL OFF)
|
||||||
set(OPENRA3_HAS_WEBGL ON)
|
if(OPENRA3_WASMGL AND EMSCRIPTEN)
|
||||||
|
set(OPENRA3_HAS_WASMGL ON)
|
||||||
endif()
|
endif()
|
||||||
if(OPENRA3_HAS_WEBGL)
|
add_library(ra3_wasmgl STATIC)
|
||||||
message(STATUS "OpenRA3: WebGL viewer enabled")
|
if(OPENRA3_HAS_WASMGL)
|
||||||
else()
|
message(STATUS "OpenRA3: SDL-free wasm worker viewer enabled")
|
||||||
message(STATUS "OpenRA3: WebGL viewer disabled - null backend")
|
|
||||||
endif()
|
|
||||||
add_library(ra3_webgl STATIC)
|
|
||||||
if(OPENRA3_HAS_WEBGL)
|
|
||||||
openra3_embed_glsl(
|
openra3_embed_glsl(
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/generated/glsl_embedded.hpp"
|
"${CMAKE_CURRENT_BINARY_DIR}/generated/wasmgl_glsl_embedded.hpp"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_vert.glsl"
|
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_vert.glsl"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.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_vert.glsl"
|
||||||
@@ -336,28 +350,68 @@ if(OPENRA3_HAS_WEBGL)
|
|||||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
|
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
|
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_frag.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_sources(ra3_wasmgl PUBLIC FILE_SET CXX_MODULES FILES src/wasmgl/ra3.wasmgl.cppm)
|
||||||
target_include_directories(ra3_webgl PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
target_include_directories(ra3_wasmgl 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_link_libraries(ra3_wasmgl PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog)
|
||||||
# Target WebGL2 so GLES3-only entry points (glTexImage3D, VAOs, ...) link.
|
# Target WebGL2 so GLES3-only entry points (glTexImage3D, VAOs, ...) link.
|
||||||
target_link_options(ra3_webgl PUBLIC -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2)
|
target_link_options(ra3_wasmgl PUBLIC -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2)
|
||||||
else()
|
else()
|
||||||
target_sources(ra3_webgl PUBLIC FILE_SET CXX_MODULES FILES src/webgl/ra3.webgl.null.cppm)
|
message(STATUS "OpenRA3: SDL-free wasm worker viewer disabled (non-wasm) - null backend")
|
||||||
target_link_libraries(ra3_webgl PUBLIC ra3_core ra3_render ra3_client)
|
target_sources(ra3_wasmgl PUBLIC FILE_SET CXX_MODULES FILES src/wasmgl/ra3.wasmgl.null.cppm)
|
||||||
|
target_link_libraries(ra3_wasmgl PUBLIC ra3_core ra3_render ra3_client)
|
||||||
endif()
|
endif()
|
||||||
openra3_target_defaults(ra3_webgl)
|
openra3_target_defaults(ra3_wasmgl)
|
||||||
|
|
||||||
|
# --- wasm WebGPU viewer (Dawn emdawnwebgpu, or null fallback) ------------------
|
||||||
|
# A peer of ra3.wasmgl in the same Web Worker; web WebGPU is WGSL-only, so it has
|
||||||
|
# its own WGSL shaders (the Vulkan SPIR-V blobs cannot be used on the web). The
|
||||||
|
# `--use-port=emdawnwebgpu` port is downloaded/cached by Emscripten and linked
|
||||||
|
# into the final module.
|
||||||
|
option(OPENRA3_WEBGPU "Build the WebGPU wasm viewer (Emscripten emdawnwebgpu)" ON)
|
||||||
|
set(OPENRA3_HAS_WEBGPU OFF)
|
||||||
|
if(OPENRA3_WEBGPU AND EMSCRIPTEN)
|
||||||
|
set(OPENRA3_HAS_WEBGPU ON)
|
||||||
|
endif()
|
||||||
|
add_library(ra3_webgpu STATIC)
|
||||||
|
if(OPENRA3_HAS_WEBGPU)
|
||||||
|
message(STATUS "OpenRA3: WebGPU wasm viewer enabled (emdawnwebgpu)")
|
||||||
|
openra3_embed_wgsl(
|
||||||
|
"${CMAKE_CURRENT_BINARY_DIR}/generated/webgpu_wgsl_embedded.hpp"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_scene.wgsl"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_terrain.wgsl"
|
||||||
|
)
|
||||||
|
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_scene.wgsl"
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_terrain.wgsl")
|
||||||
|
target_sources(ra3_webgpu PUBLIC FILE_SET CXX_MODULES FILES src/webgpu/ra3.webgpu.cppm)
|
||||||
|
target_include_directories(ra3_webgpu PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||||
|
# CMake's module dependency scanner does not process `--use-port`, so point
|
||||||
|
# the include path at the (image-baked) port package explicitly.
|
||||||
|
set(OPENRA3_EMDAWNWEBGPU_DIR "$ENV{EMSDK}/upstream/emscripten/cache/ports/emdawnwebgpu/emdawnwebgpu_pkg")
|
||||||
|
target_include_directories(ra3_webgpu SYSTEM PRIVATE
|
||||||
|
"${OPENRA3_EMDAWNWEBGPU_DIR}/webgpu_cpp/include"
|
||||||
|
"${OPENRA3_EMDAWNWEBGPU_DIR}/webgpu/include")
|
||||||
|
target_compile_options(ra3_webgpu PUBLIC --use-port=emdawnwebgpu)
|
||||||
|
target_link_options(ra3_webgpu PUBLIC --use-port=emdawnwebgpu)
|
||||||
|
target_link_libraries(ra3_webgpu PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_wasmgl ra3_enderlog)
|
||||||
|
else()
|
||||||
|
message(STATUS "OpenRA3: WebGPU wasm viewer disabled (non-wasm) - null backend")
|
||||||
|
target_sources(ra3_webgpu PUBLIC FILE_SET CXX_MODULES FILES src/webgpu/ra3.webgpu.null.cppm)
|
||||||
|
target_link_libraries(ra3_webgpu PUBLIC ra3_core ra3_render ra3_client)
|
||||||
|
endif()
|
||||||
|
openra3_target_defaults(ra3_webgpu)
|
||||||
|
|
||||||
# --- display backend selection ------------------------------------------------
|
# --- display backend selection ------------------------------------------------
|
||||||
add_library(ra3_display STATIC)
|
add_library(ra3_display STATIC)
|
||||||
target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm)
|
target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm)
|
||||||
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx ra3_webgl)
|
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx ra3_wasmgl ra3_webgpu)
|
||||||
openra3_target_defaults(ra3_display)
|
openra3_target_defaults(ra3_display)
|
||||||
|
|
||||||
# --- umbrella -----------------------------------------------------------------
|
# --- umbrella -----------------------------------------------------------------
|
||||||
add_library(ra3 STATIC)
|
add_library(ra3 STATIC)
|
||||||
target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
|
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
|
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display
|
||||||
ra3_ui ra3_vulkan ra3_dx ra3_webgl ra3_enderlog)
|
ra3_ui ra3_vulkan ra3_dx ra3_wasmgl ra3_webgpu ra3_enderlog)
|
||||||
openra3_target_defaults(ra3)
|
openra3_target_defaults(ra3)
|
||||||
|
|
||||||
# --- executable ---------------------------------------------------------------
|
# --- executable ---------------------------------------------------------------
|
||||||
@@ -373,19 +427,36 @@ if(WIN32 AND OPENRA3_SDL3_ROOT)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(EMSCRIPTEN)
|
if(EMSCRIPTEN)
|
||||||
# Emit a ready-to-serve openra3.html that loads the module and drives the
|
# Emit openra3.js/.wasm. The engine runs inside apps/web/openra3.worker.js
|
||||||
# #canvas element SDL3 renders into.
|
# (a plain Web Worker), which the page (apps/web/index.html) loads and hands
|
||||||
set_target_properties(openra3 PROPERTIES SUFFIX ".html")
|
# an OffscreenCanvas; so there is no Emscripten HTML shell here - both web
|
||||||
|
# files are copied next to the module.
|
||||||
|
set_target_properties(openra3 PROPERTIES SUFFIX ".js")
|
||||||
target_link_options(openra3 PRIVATE
|
target_link_options(openra3 PRIVATE
|
||||||
"--shell-file=${CMAKE_CURRENT_SOURCE_DIR}/apps/web/shell.html"
|
-sFORCE_FILESYSTEM=1
|
||||||
-sFORCE_FILESYSTEM=1)
|
# index.html input glue calls the exported openra3_* functions via ccall.
|
||||||
# Preload a compact assets directory into the virtual filesystem at /assets,
|
"-sEXPORTED_RUNTIME_METHODS=ccall,cwrap")
|
||||||
# where the runtime looks next to the module. Prefer a per-map set (one map +
|
add_custom_command(TARGET openra3 POST_BUILD
|
||||||
# the tiles it uses, listed by `openra3 textures --map ID`): browsers forbid
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
# synchronous on-demand file reads on the main thread, so bundling a small
|
"${CMAKE_CURRENT_SOURCE_DIR}/apps/web/index.html"
|
||||||
# set is the portable choice.
|
"${CMAKE_CURRENT_SOURCE_DIR}/apps/web/openra3.worker.js"
|
||||||
set(OPENRA3_WEB_ASSETS "" CACHE PATH "Assets directory preloaded into the wasm FS at /assets")
|
"$<TARGET_FILE_DIR:openra3>"
|
||||||
if(OPENRA3_WEB_ASSETS AND EXISTS "${OPENRA3_WEB_ASSETS}")
|
COMMENT "Copying the OpenRA3 web page and worker bootstrap")
|
||||||
|
# On-demand assets: `OPENRA3_WEB_MANIFEST` is a JSON `{"files": [...]}` listing
|
||||||
|
# every asset relative to the assets root (generate it with
|
||||||
|
# scripts/make_web_manifest.py). Only that small manifest is preloaded, to
|
||||||
|
# /assets.manifest.json; at startup the worker registers every listed file as
|
||||||
|
# an Emscripten lazy file, so the browser fetches each asset when the engine
|
||||||
|
# first opens it (serve the tree at <root>/assets, or pass serve.py --assets).
|
||||||
|
set(OPENRA3_WEB_MANIFEST "" CACHE FILEPATH "Assets manifest JSON, embedded as /assets.manifest.json for lazy loading")
|
||||||
|
if(OPENRA3_WEB_MANIFEST AND EXISTS "${OPENRA3_WEB_MANIFEST}")
|
||||||
|
target_link_options(openra3 PRIVATE "--embed-file=${OPENRA3_WEB_MANIFEST}@/assets.manifest.json")
|
||||||
|
endif()
|
||||||
|
# Legacy: preload a whole assets directory into the virtual FS at /assets (the
|
||||||
|
# monolithic path the lazy worker backend replaces). Only used when no manifest
|
||||||
|
# is given, since lazy files cannot shadow a preloaded tree.
|
||||||
|
set(OPENRA3_WEB_ASSETS "" CACHE PATH "Assets directory preloaded into the wasm FS at /assets (legacy)")
|
||||||
|
if(NOT (OPENRA3_WEB_MANIFEST AND EXISTS "${OPENRA3_WEB_MANIFEST}") AND OPENRA3_WEB_ASSETS AND EXISTS "${OPENRA3_WEB_ASSETS}")
|
||||||
target_link_options(openra3 PRIVATE "--preload-file=${OPENRA3_WEB_ASSETS}@/assets")
|
target_link_options(openra3 PRIVATE "--preload-file=${OPENRA3_WEB_ASSETS}@/assets")
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
+11
-1
@@ -1,6 +1,8 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# WebAssembly build environment (Emscripten), producing openra3.html/.js/.wasm.
|
# WebAssembly build environment (Emscripten), producing openra3.js/.wasm plus the
|
||||||
|
# `index.html` page and `openra3.worker.js` bootstrap. The engine runs on a plain
|
||||||
|
# Web Worker (WebGL2 and WebGPU backends), with lazy on-demand assets.
|
||||||
#
|
#
|
||||||
# The emsdk image ships CMake 3.28, whose experimental `import std` support is
|
# 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.
|
# too old for the project, so an upstream CMake is layered on top.
|
||||||
@@ -37,6 +39,14 @@ RUN mkdir -p /opt/cmake \
|
|||||||
| tar -xz -C /opt/cmake --strip-components=1
|
| tar -xz -C /opt/cmake --strip-components=1
|
||||||
ENV PATH="/opt/cmake/bin:${PATH}"
|
ENV PATH="/opt/cmake/bin:${PATH}"
|
||||||
|
|
||||||
|
# Pre-fetch the emdawnwebgpu port into the image. The WebGPU backend needs
|
||||||
|
# <webgpu/webgpu_cpp.h>, and CMake's module dependency scanner does not process
|
||||||
|
# `--use-port`, so a warm cache is required before the project is configured.
|
||||||
|
RUN . /emsdk/emsdk_env.sh \
|
||||||
|
&& printf '#include <webgpu/webgpu_cpp.h>\nint main(){}\n' > /tmp/wgpu_port.cpp \
|
||||||
|
&& em++ -c /tmp/wgpu_port.cpp -o /tmp/wgpu_port.o --use-port=emdawnwebgpu \
|
||||||
|
&& rm -f /tmp/wgpu_port.cpp /tmp/wgpu_port.o
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ extracting ore and fighting until one side is wiped out. The balance is the
|
|||||||
**retail Red Alert 3 balance**, pinned in `ra3.data` from EA's open RA3 XML:
|
**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
|
damage types, `ArmorTemplate` percentages, weapon target masks, build costs and
|
||||||
the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`), **Direct3D 11 / 12**
|
the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`), **Direct3D 11 / 12**
|
||||||
(`ra3.dx`) and **WebGL 2** (`ra3.webgl`, the wasm build) GPU backends with an SDL
|
(`ra3.dx`), **WebGPU** (`ra3.webgpu`, the wasm worker backend) and **WebGL 2**
|
||||||
|
(`ra3.wasmgl`, the SDL-free wasm worker fallback) GPU backends with an SDL
|
||||||
software blit and null fallbacks; the backend is selectable from the in-game menu
|
software blit and null fallbacks; the backend is selectable from the in-game menu
|
||||||
and the software renderer still produces headless images. Terrain tiles
|
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
|
cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a
|
||||||
@@ -96,7 +97,8 @@ built-in test map so the project still builds and runs in CI.
|
|||||||
| `src/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) |
|
| `src/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/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/dx/ra3.dx.*.cppm` | Direct3D 11/12 presentation backend (runtime HLSL; null fallback off Windows) |
|
||||||
| `src/webgl/ra3.webgl.*.cppm` | WebGL2 presentation backend for the wasm build (GLSL ES; null fallback off Emscripten) |
|
| `src/wasmgl/ra3.wasmgl.*.cppm` | SDL-free wasm backend: engine on a Web Worker, WebGL2 on an OffscreenCanvas, lazy assets (null fallback off Emscripten) |
|
||||||
|
| `src/webgpu/ra3.webgpu.*.cppm` | WebGPU wasm backend (Dawn `emdawnwebgpu`, WGSL; the default on wasm, falls back to `ra3.wasmgl`) |
|
||||||
| `third_party/libenderlog/` | vendored C++26 module logger (`import ender.log;`, MIT) with a native stack fallback for libc++ |
|
| `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 |
|
| `src/ra3.cppm` | umbrella module re-exporting the SDK |
|
||||||
| `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI |
|
| `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI |
|
||||||
@@ -141,7 +143,7 @@ scripts/build-windows.sh
|
|||||||
# Windows (arm64) -> build/windows-arm64/bin/openra3.exe + SDL3.dll, .zip (cross)
|
# Windows (arm64) -> build/windows-arm64/bin/openra3.exe + SDL3.dll, .zip (cross)
|
||||||
scripts/build-windows-arm64.sh
|
scripts/build-windows-arm64.sh
|
||||||
|
|
||||||
# WebAssembly -> build/wasm/bin/openra3.html (+ .js/.wasm)
|
# WebAssembly -> build/wasm/bin/index.html (+ openra3.js/.wasm, openra3.worker.js)
|
||||||
scripts/build-wasm.sh
|
scripts/build-wasm.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -183,31 +185,36 @@ runtime, so neither image needs a Vulkan SDK.
|
|||||||
## WebAssembly
|
## WebAssembly
|
||||||
|
|
||||||
OpenRA3 also builds to **WebAssembly** with Emscripten (`scripts/build-wasm.sh`),
|
OpenRA3 also builds to **WebAssembly** with Emscripten (`scripts/build-wasm.sh`),
|
||||||
producing `openra3.html` + `.js` + `.wasm`. The browser gets the same GPU renderer
|
producing `openra3.js` + `.wasm` plus the `index.html` page and
|
||||||
as the desktop builds: a **`ra3.webgl`** backend peers with Vulkan/Direct3D — SDL3
|
`openra3.worker.js` bootstrap. The browser gets the same GPU renderer as the
|
||||||
provides the canvas and input, and GLES 3.0 / WebGL2 runs the 2D blit and the
|
desktop builds: the engine runs on a **Web Worker** (no SDL, no blocking of the
|
||||||
terrain raymarcher (GLSL ES ports of the desktop shaders).
|
page) and draws into an **OffscreenCanvas** handed over by the page. The default
|
||||||
|
wasm backend is **`ra3.webgpu`** (WebGPU, WGSL shaders via Emscripten's
|
||||||
|
`emdawnwebgpu` port); it falls back to **`ra3.wasmgl`** (WebGL2, the GLSL ES
|
||||||
|
ports of the desktop shaders) when WebGPU is unavailable. The page keeps the DOM
|
||||||
|
(input, resize) and forwards events to the worker, so the main thread stays
|
||||||
|
responsive.
|
||||||
|
|
||||||
Browsers forbid synchronous on-demand file reads on the main thread, so the
|
Because the runtime's main thread lives in the worker, its filesystem is local
|
||||||
assets are **preloaded into the module's filesystem** at build time. Bundle a
|
to it and assets load **on demand**: point `OPENRA3_WEB_ASSETS` at the extracted
|
||||||
compact per-map set (one map plus the tiles it uses) rather than the whole dump:
|
asset tree and the build embeds an `assets.manifest.json`; at startup the worker
|
||||||
|
registers every listed file as a lazy file, so the browser fetches a file only
|
||||||
|
when the engine first opens it (entering a map pulls just that map and the tiles
|
||||||
|
it uses, not the whole tree).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. which loose TGAs does the map resolve to?
|
# build (generates the manifest from the extracted assets, embeds it)
|
||||||
openra3 textures --map map_mp_2_feasel1 # prints paths under assets/terrain
|
OPENRA3_WEB_ASSETS=/path/to/assets scripts/build-wasm.sh
|
||||||
|
|
||||||
# 2. stage one map + those tiles (+ maps/map_names.tsv) into a directory, then
|
# serve the build output (Range-capable, COOP/COEP; --assets is the lazy tree)
|
||||||
OPENRA3_WEB_ASSETS=/path/to/that/set scripts/build-wasm.sh
|
python3 apps/web/serve.py --root build/wasm/bin --assets /path/to/assets
|
||||||
|
# open http://localhost:8199/index.html
|
||||||
# 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
|
The engine's frame loops are blocking; the wasm build yields to the worker's
|
||||||
Asyncify (`display::sleep_frame` calls `emscripten_sleep`), so the page repaints
|
event loop via Asyncify (`display::sleep_frame` calls `emscripten_sleep`), so
|
||||||
and handles input. Log records go to `console.log` at their real level (the
|
the page repaints and handles input. Log records go to `console.log` at their
|
||||||
console sink uses stdout on the web instead of stderr). Without
|
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
|
`OPENRA3_WEB_ASSETS` the module still loads and starts, but exits at the menu
|
||||||
because no maps are found.
|
because no maps are found.
|
||||||
|
|
||||||
|
|||||||
+27
-11
@@ -121,7 +121,7 @@ namespace {
|
|||||||
std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]");
|
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(" [--cam-height U] [--fov DEG] [--width W] [--height H]");
|
||||||
std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
|
std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
|
||||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgl] [--sdl] (preferred display backend)");
|
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgpu] [--wasmgl] [--sdl] (preferred display backend)");
|
||||||
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
|
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +140,8 @@ namespace {
|
|||||||
[[nodiscard]] auto display_backend_from_args(const std::vector<std::string> &args) -> ra3::display::backend {
|
[[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, "--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, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12;
|
||||||
if (has_flag(args, "--webgl")) return ra3::display::backend::webgl;
|
if (has_flag(args, "--webgpu")) return ra3::display::backend::webgpu;
|
||||||
|
if (has_flag(args, "--wasmgl")) return ra3::display::backend::wasmgl;
|
||||||
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
|
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
|
||||||
return ra3::display::backend::vulkan;
|
return ra3::display::backend::vulkan;
|
||||||
}
|
}
|
||||||
@@ -149,7 +150,9 @@ namespace {
|
|||||||
auto executable_dir(const char *argv0) -> std::filesystem::path {
|
auto executable_dir(const char *argv0) -> std::filesystem::path {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
const auto path = std::filesystem::absolute(argv0, ec);
|
const auto path = std::filesystem::absolute(argv0, ec);
|
||||||
return ec ? std::filesystem::current_path() : path.parent_path();
|
// lexically_normal drops the "." element Emscripten leaves in argv[0]
|
||||||
|
// ("/./openra3.js" -> "/"), which the wasm FS does not collapse itself.
|
||||||
|
return ec ? std::filesystem::current_path() : path.parent_path().lexically_normal();
|
||||||
}
|
}
|
||||||
|
|
||||||
auto read_file(const std::filesystem::path &path) -> std::vector<ra3::core::uint8> {
|
auto read_file(const std::filesystem::path &path) -> std::vector<ra3::core::uint8> {
|
||||||
@@ -610,10 +613,12 @@ namespace {
|
|||||||
* then the others in turn, so an unavailable backend degrades gracefully.
|
* then the others in turn, so an unavailable backend degrades gracefully.
|
||||||
* Direct3D is Windows-only; on other hosts those entries fall through.
|
* Direct3D is Windows-only; on other hosts those entries fall through.
|
||||||
*/
|
*/
|
||||||
inline constexpr std::array<ra3::display::backend, 5> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
inline constexpr std::array<ra3::display::backend, 6> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||||
ra3::display::backend::d3d12, ra3::display::backend::webgl,
|
ra3::display::backend::d3d12, ra3::display::backend::webgpu,
|
||||||
ra3::display::backend::sdl};
|
ra3::display::backend::wasmgl, ra3::display::backend::sdl};
|
||||||
inline constexpr std::array<std::string_view, 5> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGL", "SDL (software)"};
|
inline constexpr std::array<std::string_view, 6> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGPU", "WebGL (worker)",
|
||||||
|
"SDL (software)"};
|
||||||
|
inline constexpr int renderer_count = static_cast<int>(renderer_backends.size());
|
||||||
|
|
||||||
[[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend {
|
[[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))];
|
return renderer_backends[static_cast<std::size_t>(std::clamp(index, 0, static_cast<int>(renderer_backends.size()) - 1))];
|
||||||
@@ -698,7 +703,7 @@ namespace {
|
|||||||
case 15: return s.fullscreen ? "yes" : "no";
|
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 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps";
|
||||||
case 17: return "start";
|
case 17: return "start";
|
||||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 4))]};
|
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, renderer_count - 1))]};
|
||||||
default: return "exit";
|
default: return "exit";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -721,7 +726,7 @@ namespace {
|
|||||||
case 13: s.thumbnail = !s.thumbnail; break;
|
case 13: s.thumbnail = !s.thumbnail; break;
|
||||||
case 15: s.fullscreen = !s.fullscreen; break;
|
case 15: s.fullscreen = !s.fullscreen; break;
|
||||||
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
|
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
|
||||||
case 18: s.renderer = (s.renderer + delta + 5) % 5; break;
|
case 18: s.renderer = (s.renderer + delta + renderer_count) % renderer_count; break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1121,8 +1126,9 @@ namespace {
|
|||||||
if (has_flag(args, "--vulkan")) st.settings.renderer = 0;
|
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, "--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, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2;
|
||||||
if (has_flag(args, "--webgl")) st.settings.renderer = 3;
|
if (has_flag(args, "--webgpu")) st.settings.renderer = 3;
|
||||||
if (has_flag(args, "--sdl")) st.settings.renderer = 4;
|
if (has_flag(args, "--wasmgl")) st.settings.renderer = 4;
|
||||||
|
if (has_flag(args, "--sdl")) st.settings.renderer = 5;
|
||||||
if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value);
|
if (const auto value = option_value(args, "--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, "--height")) st.settings.height = std::stoi(*value);
|
||||||
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
|
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
|
||||||
@@ -1259,6 +1265,16 @@ auto main(int argc, char **argv) -> int {
|
|||||||
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
|
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
|
||||||
const auto assets = exe_dir / "assets";
|
const auto assets = exe_dir / "assets";
|
||||||
setup_logging(exe_dir);
|
setup_logging(exe_dir);
|
||||||
|
#if defined(__EMSCRIPTEN__)
|
||||||
|
// The wasm build runs on a Web Worker (its runtime's main thread lives
|
||||||
|
// there), where synchronous XHR is legal, so the asset tree is mounted
|
||||||
|
// lazily from a manifest instead of being preloaded: the browser fetches
|
||||||
|
// only the files the engine actually opens. On wasm the executable dir is
|
||||||
|
// "/", so the manifest sits at /assets.manifest.json and the lazy file URLs
|
||||||
|
// are "assets/<rel>" relative to the page.
|
||||||
|
const int mounted = ra3::wasmgl::mount_assets((exe_dir / "assets.manifest.json").string(), assets.string(), "assets/");
|
||||||
|
ender::log::info(std::format("wasm assets: {} files mounted lazily under {}", mounted, assets.string()));
|
||||||
|
#endif
|
||||||
|
|
||||||
if (args.empty()) return command_menu({}, assets);
|
if (args.empty()) return command_menu({}, assets);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!-- OpenRA3 web page. The engine runs inside openra3.worker.js: the page owns the
|
||||||
|
DOM (input, resize) and transfers #canvas to the worker as an OffscreenCanvas,
|
||||||
|
so the engine never blocks the main thread. -->
|
||||||
|
<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; cursor: default; }
|
||||||
|
#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>
|
||||||
|
(function () {
|
||||||
|
var canvas = document.getElementById('canvas');
|
||||||
|
var loading = document.getElementById('loading');
|
||||||
|
// `?nocache` busts the worker, module and wasm fetches (development),
|
||||||
|
// so a rebuild is picked up without clearing the browser cache.
|
||||||
|
var bust = location.search.indexOf('nocache') >= 0 ? Date.now() : 0;
|
||||||
|
var worker = new Worker('openra3.worker.js' + (bust ? '?v=' + bust : ''));
|
||||||
|
|
||||||
|
var ready = false;
|
||||||
|
var pending = []; // input queued until the runtime is up
|
||||||
|
|
||||||
|
function post(message) {
|
||||||
|
if (ready) {
|
||||||
|
worker.postMessage(message);
|
||||||
|
} else {
|
||||||
|
pending.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function backingSize() {
|
||||||
|
var dpr = window.devicePixelRatio || 1;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(window.innerWidth * dpr)),
|
||||||
|
height: Math.max(1, Math.round(window.innerHeight * dpr)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.onmessage = function (event) {
|
||||||
|
var data = event.data || {};
|
||||||
|
if (data.log) { console.log(data.log); return; }
|
||||||
|
if (data.ready) {
|
||||||
|
ready = true;
|
||||||
|
if (loading) loading.remove();
|
||||||
|
for (var i = 0; i < pending.length; i++) worker.postMessage(pending[i]);
|
||||||
|
pending = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof data.openra3Frames === 'number') { window.openra3Frames = data.openra3Frames; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hand the canvas to the worker as an OffscreenCanvas. After this the
|
||||||
|
// page can no longer size or draw it; the worker owns it.
|
||||||
|
var size = backingSize();
|
||||||
|
var offscreen = canvas.transferControlToOffscreen();
|
||||||
|
worker.postMessage({ canvas: offscreen, width: size.width, height: size.height, bust: bust }, [offscreen]);
|
||||||
|
|
||||||
|
// ---- input: the page owns the DOM, the worker owns the game --------
|
||||||
|
window.addEventListener('keydown', function (event) {
|
||||||
|
post({ type: 'key', code: event.code, down: true });
|
||||||
|
if (event.code === 'Tab' || event.code === 'Space' || event.code.indexOf('Arrow') === 0) event.preventDefault();
|
||||||
|
}, false);
|
||||||
|
window.addEventListener('keyup', function (event) {
|
||||||
|
post({ type: 'key', code: event.code, down: false });
|
||||||
|
}, false);
|
||||||
|
window.addEventListener('keypress', function (event) {
|
||||||
|
if (event.charCode) post({ type: 'text', char: event.charCode });
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', function (event) {
|
||||||
|
post({ type: 'mousebutton', x: event.clientX, y: event.clientY, left: event.button === 0 });
|
||||||
|
}, false);
|
||||||
|
window.addEventListener('mousemove', function (event) {
|
||||||
|
post({ type: 'mousemove', x: event.clientX, y: event.clientY, dx: event.movementX, dy: event.movementY,
|
||||||
|
left: (event.buttons & 1) !== 0 });
|
||||||
|
}, false);
|
||||||
|
window.addEventListener('wheel', function (event) {
|
||||||
|
post({ type: 'wheel', deltaY: event.deltaY });
|
||||||
|
}, { passive: true });
|
||||||
|
window.addEventListener('contextmenu', function (event) { event.preventDefault(); });
|
||||||
|
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
var next = backingSize();
|
||||||
|
post({ type: 'resize', width: next.width, height: next.height });
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', function () {
|
||||||
|
if (ready) worker.postMessage({ type: 'quit' });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use strict';
|
||||||
|
// OpenRA3 WebAssembly worker bootstrap.
|
||||||
|
//
|
||||||
|
// The whole Emscripten runtime runs here (not on the page main thread), so the
|
||||||
|
// runtime's filesystem is local to this worker: `FS.createLazyFile` can use
|
||||||
|
// synchronous XHR to fetch assets on demand without blocking the page.
|
||||||
|
//
|
||||||
|
// Protocol with index.html:
|
||||||
|
// page -> worker { canvas, width, height } (once, transfers the OffscreenCanvas)
|
||||||
|
// page -> worker { type: 'key' | 'text' | 'mousebutton' | 'mousemove' | 'wheel' | 'resize' | 'quit', ... }
|
||||||
|
// worker -> page { log } | { ready } | { openra3Frames }
|
||||||
|
//
|
||||||
|
// The Emscripten runtime is started by importScripts('openra3.js') once the
|
||||||
|
// canvas has arrived; main() then runs here and yields via Asyncify.
|
||||||
|
|
||||||
|
// Forward worker warnings/errors (including WebGPU validation messages from the
|
||||||
|
// runtime) to the page, which logs them to the main console.
|
||||||
|
(function () {
|
||||||
|
var origError = console.error.bind(console);
|
||||||
|
var origWarn = console.warn.bind(console);
|
||||||
|
console.error = function () {
|
||||||
|
origError.apply(null, arguments);
|
||||||
|
try { self.postMessage({ log: '[worker error] ' + Array.prototype.join.call(arguments, ' ') }); } catch (e) {}
|
||||||
|
};
|
||||||
|
console.warn = function () {
|
||||||
|
origWarn.apply(null, arguments);
|
||||||
|
try { self.postMessage({ log: '[worker warn] ' + Array.prototype.join.call(arguments, ' ') }); } catch (e) {}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
self.Module = {
|
||||||
|
print: function (text) {
|
||||||
|
self.postMessage({ log: String(text) });
|
||||||
|
},
|
||||||
|
printErr: function (text) {
|
||||||
|
self.postMessage({ log: String(text) });
|
||||||
|
},
|
||||||
|
onRuntimeInitialized: function () {
|
||||||
|
self.postMessage({ ready: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var openra3Started = false;
|
||||||
|
|
||||||
|
self.onmessage = function (event) {
|
||||||
|
var data = event.data;
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
if (!openra3Started) {
|
||||||
|
if (!data.canvas) return;
|
||||||
|
openra3Started = true;
|
||||||
|
var canvas = data.canvas;
|
||||||
|
if (data.width && data.height) {
|
||||||
|
canvas.width = data.width;
|
||||||
|
canvas.height = data.height;
|
||||||
|
}
|
||||||
|
self.Module.canvas = canvas;
|
||||||
|
|
||||||
|
var start = function () {
|
||||||
|
// `?nocache` on the page forces a fresh module + wasm fetch, so a
|
||||||
|
// rebuild is picked up without clearing the browser cache.
|
||||||
|
var bust = data.bust ? ('?v=' + data.bust) : '';
|
||||||
|
if (bust) self.Module.locateFile = function (path) { return path + bust; };
|
||||||
|
try {
|
||||||
|
importScripts('openra3.js' + bust);
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ log: 'OpenRA3 worker failed to start: ' + error });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The WebGPU backend starts from a device created here (`Module.
|
||||||
|
// preinitializedWebGPUDevice`); the WebGL backend ignores it. If WebGPU
|
||||||
|
// is unavailable the runtime falls back to WebGL.
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.gpu) {
|
||||||
|
navigator.gpu.requestAdapter().then(function (adapter) {
|
||||||
|
if (!adapter) { start(); return; }
|
||||||
|
adapter.requestDevice().then(function (device) {
|
||||||
|
self.Module.preinitializedWebGPUDevice = device;
|
||||||
|
start();
|
||||||
|
}).catch(start);
|
||||||
|
}).catch(start);
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input is only meaningful once the runtime has initialized its exports.
|
||||||
|
switch (data.type) {
|
||||||
|
case 'key':
|
||||||
|
if (self.Module.ccall) self.Module.ccall('openra3_key', null, ['string', 'number'], [data.code, data.down ? 1 : 0]);
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
if (self.Module._openra3_text) self.Module._openra3_text(data.char | 0);
|
||||||
|
break;
|
||||||
|
case 'mousebutton':
|
||||||
|
if (self.Module._openra3_mouse_button) self.Module._openra3_mouse_button(data.x, data.y, data.left ? 1 : 0);
|
||||||
|
break;
|
||||||
|
case 'mousemove':
|
||||||
|
if (self.Module._openra3_mouse_move) self.Module._openra3_mouse_move(data.x, data.y, data.dx, data.dy, data.left ? 1 : 0);
|
||||||
|
break;
|
||||||
|
case 'wheel':
|
||||||
|
if (self.Module._openra3_wheel) self.Module._openra3_wheel(data.deltaY);
|
||||||
|
break;
|
||||||
|
case 'resize':
|
||||||
|
if (self.Module._openra3_resize) self.Module._openra3_resize(data.width | 0, data.height | 0);
|
||||||
|
break;
|
||||||
|
case 'quit':
|
||||||
|
if (self.Module._openra3_quit) self.Module._openra3_quit();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
+33
-8
@@ -1,14 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Static file server with HTTP Range support, for the OpenRA3 wasm build.
|
"""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
|
Emscripten's lazy file reads stream assets back as byte ranges, so the server
|
||||||
honour the ``Range`` header -- Python's stdlib ``http.server`` does not.
|
must honour the ``Range`` header -- Python's stdlib ``http.server`` does not.
|
||||||
|
|
||||||
python3 apps/web/serve.py --root build/wasm/bin --port 8199
|
python3 apps/web/serve.py --root build/wasm/bin --port 8199
|
||||||
# then open http://localhost:8199/openra3.html
|
# then open http://localhost:8199/index.html
|
||||||
|
|
||||||
Serve ``--root`` containing ``openra3.html/.js/.wasm``, ``assets.manifest.json``
|
Serve ``--root`` containing ``index.html``, ``openra3.js``, ``openra3.wasm``,
|
||||||
and the extracted ``assets/`` tree (a directory or a junction/symlink to it).
|
``openra3.worker.js`` and ``assets.manifest.json``. The lazy asset tree is
|
||||||
|
mounted from ``--assets`` (its files are then fetched from ``/assets/<rel>`` on
|
||||||
|
demand); without ``--assets`` the tree is expected under ``--root`` itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -22,11 +24,27 @@ _RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
|
|||||||
|
|
||||||
|
|
||||||
class RangeHandler(SimpleHTTPRequestHandler):
|
class RangeHandler(SimpleHTTPRequestHandler):
|
||||||
# SharedArrayBuffer (needed by the pthreads build) requires cross-origin
|
# Directory served for ``/assets/...`` (set in main); None means "under root".
|
||||||
# isolation.
|
assets_dir = None
|
||||||
|
|
||||||
|
def translate_path(self, path):
|
||||||
|
if self.assets_dir:
|
||||||
|
prefix = "/assets/"
|
||||||
|
if path == "/assets" or path.startswith(prefix):
|
||||||
|
rel = path[len(prefix):] if path.startswith(prefix) else ""
|
||||||
|
rel = rel.split("?", 1)[0].split("#", 1)[0]
|
||||||
|
parts = [p for p in rel.split("/") if p and p != ".."]
|
||||||
|
return os.path.join(self.assets_dir, *parts)
|
||||||
|
return super().translate_path(path)
|
||||||
|
|
||||||
|
# Cross-origin isolation (kept for SharedArrayBuffer-style features and the
|
||||||
|
# WebGPU/pthread paths). no-store keeps the dev loop honest: a stale
|
||||||
|
# openra3.js/.wasm in the browser cache hides new builds (the worker's
|
||||||
|
# importScripts especially).
|
||||||
def end_headers(self):
|
def end_headers(self):
|
||||||
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
|
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
|
||||||
|
self.send_header("Cache-Control", "no-store, max-age=0")
|
||||||
super().end_headers()
|
super().end_headers()
|
||||||
|
|
||||||
def send_head(self):
|
def send_head(self):
|
||||||
@@ -86,6 +104,7 @@ class RangeHandler(SimpleHTTPRequestHandler):
|
|||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server")
|
parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server")
|
||||||
parser.add_argument("--root", default="build/wasm/bin", help="directory to serve")
|
parser.add_argument("--root", default="build/wasm/bin", help="directory to serve")
|
||||||
|
parser.add_argument("--assets", default=None, help="directory served for /assets (the lazy asset tree)")
|
||||||
parser.add_argument("--port", type=int, default=8199)
|
parser.add_argument("--port", type=int, default=8199)
|
||||||
parser.add_argument("--bind", default="127.0.0.1")
|
parser.add_argument("--bind", default="127.0.0.1")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -93,9 +112,15 @@ def main() -> int:
|
|||||||
if not os.path.isdir(args.root):
|
if not os.path.isdir(args.root):
|
||||||
print(f"root not found: {args.root}", file=sys.stderr)
|
print(f"root not found: {args.root}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
if args.assets and not os.path.isdir(args.assets):
|
||||||
|
print(f"assets not found: {args.assets}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
RangeHandler.assets_dir = os.path.abspath(args.assets) if args.assets else None
|
||||||
handler = functools.partial(RangeHandler, directory=args.root)
|
handler = functools.partial(RangeHandler, directory=args.root)
|
||||||
server = ThreadingHTTPServer((args.bind, args.port), handler)
|
server = ThreadingHTTPServer((args.bind, args.port), handler)
|
||||||
print(f"serving {args.root} at http://{args.bind}:{args.port}/openra3.html")
|
print(f"serving {args.root} at http://{args.bind}:{args.port}/index.html")
|
||||||
|
if RangeHandler.assets_dir:
|
||||||
|
print(f" /assets -> {RangeHandler.assets_dir}")
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -12,8 +12,17 @@
|
|||||||
# libc++ `std` module must be built with the same setting as its consumers,
|
# libc++ `std` module must be built with the same setting as its consumers,
|
||||||
# or clang rejects the prebuilt module ("configuration mismatch"). The engine
|
# or clang rejects the prebuilt module ("configuration mismatch"). The engine
|
||||||
# throws, so exceptions are enabled everywhere.
|
# throws, so exceptions are enabled everywhere.
|
||||||
# * The SDL3 web port (`-sUSE_SDL=3`) is attached by CMake to the SDL interface
|
# * Asyncify turns `emscripten_sleep` (used by display::sleep_frame on
|
||||||
# target, not here, so only the targets that use SDL pay for it.
|
# Emscripten) into a yield: the engine's frame loops are blocking, and without
|
||||||
|
# yielding the worker freezes and never paints.
|
||||||
|
#
|
||||||
|
# No pthreads: the runtime's main thread runs inside a plain Web Worker started
|
||||||
|
# by `apps/web/openra3.worker.js`, so its virtual filesystem is local to that
|
||||||
|
# worker and `FS.createLazyFile` (synchronous XHR) is legal. Under
|
||||||
|
# `PROXY_TO_PTHREAD` Emscripten proxies every filesystem syscall to the main
|
||||||
|
# browser thread, where synchronous XHR is forbidden, so lazy assets cannot work
|
||||||
|
# there - hence the plain worker. The SDL3 web port is only pulled in by the
|
||||||
|
# legacy main-thread backend (OPENRA3_WASM_SDL).
|
||||||
|
|
||||||
# `import std` support: the gate and the module switch must be in place before
|
# `import std` support: the gate and the module switch must be in place before
|
||||||
# Emscripten.cmake runs and before the compiler is probed.
|
# Emscripten.cmake runs and before the compiler is probed.
|
||||||
@@ -37,7 +46,7 @@ include("${OPENRA3_EMSCRIPTEN_CMAKE}")
|
|||||||
#
|
#
|
||||||
# Asyncify turns `emscripten_sleep` (used by display::sleep_frame on Emscripten)
|
# 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
|
# into a yield to the browser: the engine's frame loops are blocking, and
|
||||||
# without yielding the page freezes and never paints.
|
# without yielding the worker freezes and never paints.
|
||||||
set(CMAKE_CXX_FLAGS_INIT "-fexceptions -sASYNCIFY=1")
|
set(CMAKE_CXX_FLAGS_INIT "-fexceptions -sASYNCIFY=1")
|
||||||
set(CMAKE_C_FLAGS_INIT "-sASYNCIFY=1")
|
set(CMAKE_C_FLAGS_INIT "-sASYNCIFY=1")
|
||||||
set(CMAKE_EXE_LINKER_FLAGS_INIT
|
set(CMAKE_EXE_LINKER_FLAGS_INIT
|
||||||
|
|||||||
+37
-10
@@ -63,8 +63,8 @@ umbrella module re-exports the SDK; applications import `ra3` only.
|
|||||||
| \ v
|
| \ v
|
||||||
| ra3.display (backend pick)
|
| ra3.display (backend pick)
|
||||||
| / | \ \
|
| / | \ \
|
||||||
v ra3.ui ra3.vulkan ra3.dx ra3.webgl
|
v ra3.ui ra3.vulkan ra3.dx ra3.webgpu / ra3.wasmgl
|
||||||
| (SDL3) (Vulkan) (D3D11/12) (WebGL2)
|
| (SDL3) (Vulkan) (D3D11/12) (wasm workers)
|
||||||
---------------------------------------------------------------------------
|
---------------------------------------------------------------------------
|
||||||
v
|
v
|
||||||
L2 simulation ra3.logic ra3.modules ra3.combat ra3.movement
|
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`,
|
The current concrete modules (`ra3.core`, `ra3.logic`, `ra3.data`,
|
||||||
`ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`,
|
`ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`,
|
||||||
`ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgl.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the
|
`ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgpu.*`, `ra3.wasmgl.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the
|
||||||
vendored `ender.log`) are the **seeds** of the
|
vendored `ender.log`) are the **seeds** of the
|
||||||
target modules below. `ra3.skirmish` and `ra3.game` will be absorbed into
|
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.
|
`ra3.ai` / `ra3.match`; new modules are added as their subsystems are recovered.
|
||||||
@@ -595,17 +595,44 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
|
|||||||
- **F5 Null fallback** `[D]`
|
- **F5 Null fallback** `[D]`
|
||||||
- `[x]` non-Windows builds link a stub that fails `init`
|
- `[x]` non-Windows builds link a stub that fails `init`
|
||||||
|
|
||||||
### M18c `ra3.webgl` — WebGL backend `[P]`
|
### M18d `ra3.wasmgl` — SDL-free wasm worker backend `[P]`
|
||||||
|
|
||||||
- **F1 Device & canvas** `[D]`
|
- **F1 Device & canvas** `[D]`
|
||||||
- `[x]` Emscripten-only; SDL3 canvas + GLES 3.0 (WebGL2) via `-sUSE_SDL=3`
|
- `[x]` Emscripten-only; engine runs on a plain Web Worker, WebGL2 on an
|
||||||
|
OffscreenCanvas transferred by the page (no SDL, no `PROXY_TO_PTHREAD`)
|
||||||
|
- `[x]` `specialHTMLTargets['#canvas']` lets `emscripten_webgl_create_context` find
|
||||||
|
the worker's OffscreenCanvas
|
||||||
- **F2 2D image path** `[D]`
|
- **F2 2D image path** `[D]`
|
||||||
- `[x]` BGRA texture + fullscreen triangle (GLSL ES 300), `.bgra` swizzle
|
- `[x]` GLSL ES BGRA texture + fullscreen triangle (`.bgra` swizzle)
|
||||||
- **F3 Terrain presentation** `[D]`
|
- **F3 Terrain presentation** `[D]`
|
||||||
- `[x]` GPU heightfield raymarch (GLSL ES port of `terrain.frag`)
|
- `[x]` GPU heightfield raymarch (GLSL ES), including the `GL_R16`
|
||||||
- **F4 Wasm packaging** `[~]`
|
(`0x822A`) heightmap and `GL_RGBA16` cell textures
|
||||||
- `[x]` `openra3.html/.js/.wasm`, assets preloaded into the FS at `/assets`
|
- **F4 On-demand assets** `[D]`
|
||||||
- `[ ]` async/fetch streaming assets instead of a single preload blob `(v0.6)`
|
- `[x]` `assets.manifest.json` (from `scripts/make_web_manifest.py`) is embedded;
|
||||||
|
the worker registers every file with `FS.createLazyFile`, so only opened files
|
||||||
|
are fetched (a map pulls just its map + tiles)
|
||||||
|
- **F5 Input** `[D]`
|
||||||
|
- `[x]` page owns DOM listeners and posts events to the worker's exported
|
||||||
|
`openra3_*` functions (no DOM callbacks in the worker)
|
||||||
|
- **F6 Null fallback** `[D]`
|
||||||
|
- `[x]` non-Emscripten builds link a stub that fails `init`
|
||||||
|
|
||||||
|
### M18e `ra3.webgpu` — WebGPU wasm backend `[P]`
|
||||||
|
|
||||||
|
- **F1 Device & surface** `[D]`
|
||||||
|
- `[x]` Emscripten `emdawnwebgpu` port (Dawn C++ wrapper); the worker pre-creates
|
||||||
|
the device (`Module.preinitializedWebGPUDevice`) and a surface is configured on
|
||||||
|
the transferred OffscreenCanvas
|
||||||
|
- **F2 WGSL shaders** `[D]`
|
||||||
|
- `[x]` web WebGPU is WGSL-only, so the scene/terrain shaders are WGSL ports
|
||||||
|
(the Vulkan SPIR-V blobs cannot be reused); the uniform layout and terrain data
|
||||||
|
plumbing are shared with Vulkan/WebGL
|
||||||
|
- **F3 Terrain** `[D]`
|
||||||
|
- `[x]` GPU heightfield raymarch in WGSL (R16Uint heightmap, RGBA16Uint cell
|
||||||
|
record, RGBA8 atlas)
|
||||||
|
- **F4 Selection & fallback** `[D]`
|
||||||
|
- `[x]` preferred wasm backend; falls back to `ra3.wasmgl` when no adapter is
|
||||||
|
available
|
||||||
- **F5 Null fallback** `[D]`
|
- **F5 Null fallback** `[D]`
|
||||||
- `[x]` non-Emscripten builds link a stub that fails `init`
|
- `[x]` non-Emscripten builds link a stub that fails `init`
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -1,14 +1,16 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build the WebAssembly target (openra3.html/.js/.wasm) with the Emscripten image.
|
# Build the WebAssembly target (openra3.js/.wasm + worker + page) with the
|
||||||
|
# Emscripten image.
|
||||||
#
|
#
|
||||||
# Browsers forbid synchronous on-demand file reads on the main thread, so assets
|
# The engine runs inside a plain Web Worker and renders into an OffscreenCanvas
|
||||||
# are preloaded into the module's filesystem. Prefer a compact, per-map set: one
|
# with no SDL, so assets load on demand: point OPENRA3_WEB_ASSETS at the
|
||||||
# map plus the tiles it uses:
|
# extracted asset tree and the build generates an assets manifest, embeds it, and
|
||||||
|
# registers every file as an Emscripten lazy file at startup. The browser then
|
||||||
|
# fetches each asset only when the engine opens it.
|
||||||
#
|
#
|
||||||
# OPENRA3_WEB_ASSETS="$(mktemp -d)" \
|
# OPENRA3_WEB_ASSETS=/path/to/assets scripts/build-wasm.sh
|
||||||
# openra3 textures --map map_mp_2_feasel1 # copy these under the set's terrain/
|
|
||||||
#
|
#
|
||||||
# then point OPENRA3_WEB_ASSETS at that directory.
|
# open http://localhost:8199/index.html
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
@@ -19,13 +21,18 @@ docker build --target dev -f "$ROOT/Dockerfile.wasm" -t "$IMAGE" "$ROOT"
|
|||||||
|
|
||||||
mounts=(-v "$ROOT:/work")
|
mounts=(-v "$ROOT:/work")
|
||||||
configure=(-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release)
|
configure=(-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release)
|
||||||
|
serve_assets=()
|
||||||
if [ -n "${OPENRA3_WEB_ASSETS:-}" ]; then
|
if [ -n "${OPENRA3_WEB_ASSETS:-}" ]; then
|
||||||
mounts+=(-v "$OPENRA3_WEB_ASSETS:/assets:ro")
|
mounts+=(-v "$OPENRA3_WEB_ASSETS:/assets:ro")
|
||||||
configure+=(-DOPENRA3_WEB_ASSETS=/assets)
|
manifest="$ROOT/$BUILD_DIR/assets.manifest.json"
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
python3 "$ROOT/scripts/make_web_manifest.py" --root "$OPENRA3_WEB_ASSETS" --out "$manifest"
|
||||||
|
configure+=(-DOPENRA3_WEB_MANIFEST="/work/$BUILD_DIR/assets.manifest.json")
|
||||||
|
serve_assets=(--assets "$OPENRA3_WEB_ASSETS")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja "${configure[@]}"
|
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
|
docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
|
||||||
|
|
||||||
echo "WebAssembly: $ROOT/$BUILD_DIR/bin/openra3.html (+ .js/.wasm/.data)"
|
echo "WebAssembly: $ROOT/$BUILD_DIR/bin/index.html (+ openra3.js/.wasm, openra3.worker.js)"
|
||||||
echo "serve: python3 \"$ROOT/apps/web/serve.py\" --root \"$ROOT/$BUILD_DIR/bin\" --port 8199"
|
echo "serve: python3 \"$ROOT/apps/web/serve.py\" --root \"$ROOT/$BUILD_DIR/bin\" ${serve_assets[*]:-}"
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the wasm lazy-asset manifest.
|
||||||
|
|
||||||
|
The wasm build runs the engine on a worker, where synchronous XHR is legal, so
|
||||||
|
assets are registered with ``FS.createLazyFile`` instead of being preloaded: the
|
||||||
|
browser fetches each file only when the engine first opens it. This script lists
|
||||||
|
every file under the assets root (as POSIX-relative paths) as
|
||||||
|
``{"files": [...]}``; CMake preloads the manifest to ``/assets.manifest.json``.
|
||||||
|
|
||||||
|
scripts/make_web_manifest.py --root /path/to/assets --out assets.manifest.json
|
||||||
|
|
||||||
|
Serve the tree at ``<wasm-root>/assets`` (or use ``serve.py --assets``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Generate the OpenRA3 wasm asset manifest")
|
||||||
|
parser.add_argument("--root", required=True, help="assets root to enumerate")
|
||||||
|
parser.add_argument("--out", required=True, help="manifest JSON to write")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isdir(args.root):
|
||||||
|
print(f"root not found: {args.root}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for dirpath, dirnames, filenames in os.walk(args.root):
|
||||||
|
dirnames.sort()
|
||||||
|
for name in sorted(filenames):
|
||||||
|
rel = os.path.relpath(os.path.join(dirpath, name), args.root).replace(os.sep, "/")
|
||||||
|
files.append(rel)
|
||||||
|
|
||||||
|
with open(args.out, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump({"files": files}, handle, separators=(",", ":"))
|
||||||
|
handle.write("\n")
|
||||||
|
print(f"wrote {args.out} ({len(files)} files)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -130,17 +130,19 @@ float4 PSMain(VSOut input) : SV_Target {
|
|||||||
return float4(sky_color(dir), 1.0);
|
return float4(sky_color(dir), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// March the heightfield (bounded work: the step grows toward the horizon).
|
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||||
|
// ~9000 world units, so marching past ~20000 adds cost without detail, and
|
||||||
|
// a 6% growth rate more than doubles the worst-case iteration count.
|
||||||
float t = CELL * 0.5;
|
float t = CELL * 0.5;
|
||||||
float dt = CELL * 0.5;
|
float dt = CELL * 0.5;
|
||||||
float prev = t;
|
float prev = t;
|
||||||
bool hit = false;
|
bool hit = false;
|
||||||
float hit_t = 0.0;
|
float hit_t = 0.0;
|
||||||
for (int i = 0; i < 512 && t < 60000.0; ++i) {
|
for (int i = 0; i < 256 && t < 20000.0; ++i) {
|
||||||
float3 w = cam_pos + dir * t;
|
float3 w = cam_pos + dir * t;
|
||||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||||
prev = t;
|
prev = t;
|
||||||
dt *= 1.06;
|
dt *= 1.10;
|
||||||
t += dt;
|
t += dt;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -155,7 +157,7 @@ float4 PSMain(VSOut input) : SV_Target {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
prev = t;
|
prev = t;
|
||||||
dt *= 1.06;
|
dt *= 1.10;
|
||||||
t += dt;
|
t += dt;
|
||||||
}
|
}
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
|
|||||||
Binary file not shown.
@@ -116,20 +116,22 @@ void main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// March the heightfield (bounded work: the step grows toward the horizon).
|
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||||
|
// ~9000 world units, so marching past ~20000 adds cost without detail, and
|
||||||
|
// a 6% growth rate more than doubles the worst-case iteration count.
|
||||||
float t = CELL * 0.5;
|
float t = CELL * 0.5;
|
||||||
float dt = CELL * 0.5;
|
float dt = CELL * 0.5;
|
||||||
float prev = t;
|
float prev = t;
|
||||||
bool hit = false;
|
bool hit = false;
|
||||||
float hit_t = 0.0;
|
float hit_t = 0.0;
|
||||||
for (int i = 0; i < 512 && t < 60000.0; ++i) {
|
for (int i = 0; i < 256 && t < 20000.0; ++i) {
|
||||||
vec3 w = cam + dir * t;
|
vec3 w = cam + dir * t;
|
||||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||||
prev = t; dt *= 1.06; t += dt; continue;
|
prev = t; dt *= 1.10; t += dt; continue;
|
||||||
}
|
}
|
||||||
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; }
|
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; }
|
||||||
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; }
|
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; }
|
||||||
prev = t; dt *= 1.06; t += dt;
|
prev = t; dt *= 1.10; t += dt;
|
||||||
}
|
}
|
||||||
if (!hit) { out_color = vec4(sky_color(dir), 1.0); return; }
|
if (!hit) { out_color = vec4(sky_color(dir), 1.0); return; }
|
||||||
|
|
||||||
|
|||||||
@@ -109,16 +109,19 @@ void main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||||
|
// ~9000 world units, so marching past ~20000 adds cost without detail, and
|
||||||
|
// a 6% growth rate more than doubles the worst-case iteration count.
|
||||||
float t = CELL * 0.5;
|
float t = CELL * 0.5;
|
||||||
float dt = CELL * 0.5;
|
float dt = CELL * 0.5;
|
||||||
float prev = t;
|
float prev = t;
|
||||||
bool hit = false;
|
bool hit = false;
|
||||||
float hit_t = 0.0;
|
float hit_t = 0.0;
|
||||||
for (int i = 0; i < 512 && t < 60000.0; ++i) {
|
for (int i = 0; i < 256 && t < 20000.0; ++i) {
|
||||||
vec3 w = cam + dir * t;
|
vec3 w = cam + dir * t;
|
||||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||||
prev = t;
|
prev = t;
|
||||||
dt *= 1.06;
|
dt *= 1.10;
|
||||||
t += dt;
|
t += dt;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -133,7 +136,7 @@ void main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
prev = t;
|
prev = t;
|
||||||
dt *= 1.06;
|
dt *= 1.10;
|
||||||
t += dt;
|
t += dt;
|
||||||
}
|
}
|
||||||
if (!hit) {
|
if (!hit) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// WebGPU counterpart of the 2D image path (the WGSL port of
|
||||||
|
// webgl_scene_vert/frag.glsl and the Vulkan scene shaders).
|
||||||
|
//
|
||||||
|
// Bind group 0: uniform { rect: vec4<f32>; }, the scene texture and a sampler.
|
||||||
|
// WebGPU clip space is +Y up, matching GL, so the vertex flips Y the same way.
|
||||||
|
|
||||||
|
struct SceneUniforms {
|
||||||
|
rect: vec4<f32>, // xy = top-left (0..1), zw = size (0..1)
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> u_scene: SceneUniforms;
|
||||||
|
@group(0) @binding(1) var u_scene_tex: texture_2d<f32>;
|
||||||
|
@group(0) @binding(2) var u_scene_samp: sampler;
|
||||||
|
|
||||||
|
struct SceneOut {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> SceneOut {
|
||||||
|
let p = vec2<f32>(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u));
|
||||||
|
var out: SceneOut;
|
||||||
|
out.uv = (p - u_scene.rect.xy) / u_scene.rect.zw;
|
||||||
|
out.position = vec4<f32>(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: SceneOut) -> @location(0) vec4<f32> {
|
||||||
|
// Only the destination rect is drawn; outside it the previous contents stay.
|
||||||
|
if (in.uv.x < 0.0 || in.uv.x > 1.0 || in.uv.y < 0.0 || in.uv.y > 1.0) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
// The image is 0xAARRGGBB (BGRA in memory); swizzle it back.
|
||||||
|
let c = textureSampleLevel(u_scene_tex, u_scene_samp, in.uv, 0.0);
|
||||||
|
return vec4<f32>(c.b, c.g, c.r, c.a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// WebGPU counterpart of the GPU heightfield raymarcher (the WGSL port of
|
||||||
|
// webgl_terrain_frag.glsl / terrain.frag). The Vulkan push constants become a
|
||||||
|
// uniform buffer of five vec4s, laid out exactly as `ra3::wasmgl::detail::terrain_uniforms`.
|
||||||
|
//
|
||||||
|
// Bind group 0: uniform data[5], the R16 heightmap (u32), the RGBA16 cell record
|
||||||
|
// (vec4<u32>), the RGBA8 tile atlas (array), and an atlas sampler.
|
||||||
|
|
||||||
|
struct TerrainUniforms {
|
||||||
|
data: array<vec4<f32>, 5>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(0) @binding(0) var<uniform> u: TerrainUniforms;
|
||||||
|
@group(0) @binding(1) var u_heightmap: texture_2d<u32>; // R16Uint heights
|
||||||
|
@group(0) @binding(2) var u_celldata: texture_2d<u32>; // RGBA16Uint blend record (texel is vec4<u32>)
|
||||||
|
@group(0) @binding(3) var u_atlas: texture_2d_array<f32>; // RGBA8 tile materials
|
||||||
|
@group(0) @binding(4) var u_atlas_samp: sampler;
|
||||||
|
|
||||||
|
const CELL: f32 = 10.0; // must match ra3::terrain::cell_size
|
||||||
|
|
||||||
|
fn cam_uniform() -> vec4<f32> { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height
|
||||||
|
fn params_uniform() -> vec4<f32> { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water
|
||||||
|
fn sun_uniform() -> vec4<f32> { return u.data[2]; } // xyz=sun dir, w=ambient
|
||||||
|
fn mapinfo_uniform() -> vec4<f32> { return u.data[3]; }// x=W, y=H, z=unused, w=z_scale
|
||||||
|
fn misc_uniform() -> vec4<f32> { return u.data[4]; } // x=time, y=unused, z=cells per repeat, w=aspect
|
||||||
|
|
||||||
|
struct TerrainOut {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
};
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> TerrainOut {
|
||||||
|
let p = vec2<f32>(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u));
|
||||||
|
var out: TerrainOut;
|
||||||
|
out.uv = p;
|
||||||
|
out.position = vec4<f32>(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height_at(cell: vec2<i32>) -> f32 {
|
||||||
|
let limit = vec2<i32>(mapinfo_uniform().xy) - vec2<i32>(1);
|
||||||
|
let c = clamp(cell, vec2<i32>(0), limit);
|
||||||
|
return f32(textureLoad(u_heightmap, c, 0).r) * mapinfo_uniform().w;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn world_height(wx: f32, wy: f32) -> f32 {
|
||||||
|
let world_w = mapinfo_uniform().x * CELL;
|
||||||
|
let world_h = mapinfo_uniform().y * CELL;
|
||||||
|
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) {
|
||||||
|
return -1.0e9;
|
||||||
|
}
|
||||||
|
let c = vec2<i32>(i32(wx / CELL), i32((world_h - wy) / CELL));
|
||||||
|
return height_at(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
||||||
|
let d = normalize(dir);
|
||||||
|
let sun_dir = normalize(sun_uniform().xyz);
|
||||||
|
let t = clamp(d.z, 0.0, 1.0);
|
||||||
|
let horizon = vec3<f32>(0.70, 0.78, 0.85);
|
||||||
|
let zenith = vec3<f32>(0.28, 0.48, 0.80);
|
||||||
|
var col = mix(horizon, zenith, pow(t, 0.6));
|
||||||
|
let sun = max(dot(d, sun_dir), 0.0);
|
||||||
|
col += vec3<f32>(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6;
|
||||||
|
col += vec3<f32>(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18;
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The retail SAGE blend ramp (see terrain.frag).
|
||||||
|
fn blend_factor(direction: u32, flags: u32, f_in: vec2<f32>) -> f32 {
|
||||||
|
var f = f_in;
|
||||||
|
let flipped = (flags & 1u) != 0u;
|
||||||
|
let two_sided = (flags & 2u) != 0u;
|
||||||
|
if (flipped) {
|
||||||
|
if (direction == 1u) {
|
||||||
|
f.x = 1.0 - f.x;
|
||||||
|
} else if (direction == 2u || direction == 4u || direction == 8u) {
|
||||||
|
f.y = 1.0 - f.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (direction == 1u) { return f.x; }
|
||||||
|
if (direction == 2u) { return f.y; }
|
||||||
|
if (direction == 4u) {
|
||||||
|
let s = (1.0 - f.x) + (1.0 - f.y);
|
||||||
|
return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided);
|
||||||
|
}
|
||||||
|
if (direction == 8u) {
|
||||||
|
let s = f.x + (1.0 - f.y);
|
||||||
|
return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided);
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_layer(layer: u32, wx: f32, wy: f32) -> vec3<f32> {
|
||||||
|
let span = max(misc_uniform().z, 1.0);
|
||||||
|
let layer_count = textureNumLayers(u_atlas);
|
||||||
|
let l = f32(min(layer, layer_count - 1u));
|
||||||
|
// The atlas is 0xAARRGGBB (BGRA in memory), uploaded as RGBA8.
|
||||||
|
let c = textureSampleLevel(u_atlas, u_atlas_samp, vec2<f32>(wx, wy) / span, i32(l), 0.0);
|
||||||
|
return vec3<f32>(c.b, c.g, c.r);
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||||
|
let p = cam_uniform();
|
||||||
|
let pitch = clamp(params_uniform().x, 0.15, 1.45);
|
||||||
|
let fov = clamp(params_uniform().y, 0.3, 1.4);
|
||||||
|
let world_w = mapinfo_uniform().x * CELL;
|
||||||
|
let world_h = mapinfo_uniform().y * CELL;
|
||||||
|
|
||||||
|
let cp = cos(pitch);
|
||||||
|
let fwd = vec3<f32>(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
|
||||||
|
let right = normalize(cross(fwd, vec3<f32>(0.0, 0.0, 1.0)));
|
||||||
|
let up = cross(right, fwd);
|
||||||
|
|
||||||
|
var target_z = world_height(p.x, p.y);
|
||||||
|
if (target_z < -1.0e8) { target_z = 0.0; }
|
||||||
|
let dist = p.w / sin(pitch);
|
||||||
|
let cam = vec3<f32>(p.x, p.y, target_z + p.w) - fwd * dist;
|
||||||
|
|
||||||
|
let ndc = vec2<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||||
|
let aspect = misc_uniform().w;
|
||||||
|
let th = tan(fov * 0.5);
|
||||||
|
let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
|
||||||
|
|
||||||
|
if (dir.z >= -1e-4) {
|
||||||
|
return vec4<f32>(sky_color(dir), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||||
|
// ~9000 world units, so marching past ~20000 adds cost without detail.
|
||||||
|
var t = CELL * 0.5;
|
||||||
|
var dt = CELL * 0.5;
|
||||||
|
var prev = t;
|
||||||
|
var hit = false;
|
||||||
|
var hit_t = 0.0;
|
||||||
|
for (var i = 0; i < 256 && t < 20000.0; i = i + 1) {
|
||||||
|
let w = cam + dir * t;
|
||||||
|
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||||
|
prev = t;
|
||||||
|
dt *= 1.10;
|
||||||
|
t += dt;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (params_uniform().w > 0.5 && w.z <= params_uniform().z) {
|
||||||
|
hit = true;
|
||||||
|
hit_t = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (w.z <= world_height(w.x, w.y)) {
|
||||||
|
hit = true;
|
||||||
|
hit_t = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prev = t;
|
||||||
|
dt *= 1.10;
|
||||||
|
t += dt;
|
||||||
|
}
|
||||||
|
if (!hit) {
|
||||||
|
return vec4<f32>(sky_color(dir), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var lo = prev;
|
||||||
|
var hi = hit_t;
|
||||||
|
for (var i = 0; i < 6; i = i + 1) {
|
||||||
|
let mid = 0.5 * (lo + hi);
|
||||||
|
let w = cam + dir * mid;
|
||||||
|
let water = params_uniform().w > 0.5 && w.z <= params_uniform().z;
|
||||||
|
if (water || w.z <= world_height(w.x, w.y)) {
|
||||||
|
hi = mid;
|
||||||
|
} else {
|
||||||
|
lo = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hitpos = cam + dir * hi;
|
||||||
|
|
||||||
|
let sun = normalize(sun_uniform().xyz);
|
||||||
|
let ambient = sun_uniform().w;
|
||||||
|
|
||||||
|
if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) {
|
||||||
|
let time = misc_uniform().x;
|
||||||
|
let q = hitpos.xy * 0.015;
|
||||||
|
let nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
|
||||||
|
let ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
|
||||||
|
let n = normalize(vec3<f32>(nx * 0.06, ny * 0.06, 1.0));
|
||||||
|
let fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
|
||||||
|
let deep = vec3<f32>(0.03, 0.16, 0.28);
|
||||||
|
let refl = sky_color(reflect(dir, n));
|
||||||
|
let lam = max(0.0, dot(n, sun));
|
||||||
|
var water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
|
||||||
|
water += vec3<f32>(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
|
||||||
|
let wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||||
|
water = mix(water, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), wfog);
|
||||||
|
return vec4<f32>(water, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let wx = hitpos.x / CELL;
|
||||||
|
let wy = (world_h - hitpos.y) / CELL;
|
||||||
|
let cx = clamp(i32(wx), 0, i32(mapinfo_uniform().x) - 1);
|
||||||
|
let cy = clamp(i32(wy), 0, i32(mapinfo_uniform().y) - 1);
|
||||||
|
let fx = wx - floor(wx);
|
||||||
|
let fy = wy - floor(wy);
|
||||||
|
|
||||||
|
let record = textureLoad(u_celldata, vec2<i32>(cx, cy), 0);
|
||||||
|
let packed = record.w;
|
||||||
|
let dir1 = packed & 0xFu;
|
||||||
|
let flags1 = (packed >> 4u) & 0x3u;
|
||||||
|
let dir2 = (packed >> 8u) & 0xFu;
|
||||||
|
let flags2 = (packed >> 12u) & 0x3u;
|
||||||
|
|
||||||
|
let frac_uv = vec2<f32>(fx, fy);
|
||||||
|
let c0 = sample_layer(record.x, wx, wy);
|
||||||
|
let c1 = sample_layer(record.y, wx, wy);
|
||||||
|
let c2 = sample_layer(record.z, wx, wy);
|
||||||
|
let f1 = blend_factor(dir1, flags1, frac_uv);
|
||||||
|
let f2 = blend_factor(dir2, flags2, frac_uv);
|
||||||
|
let albedo = mix(mix(c0, c1, f1), c2, f2);
|
||||||
|
|
||||||
|
let hl = world_height(hitpos.x - CELL, hitpos.y);
|
||||||
|
let hr = world_height(hitpos.x + CELL, hitpos.y);
|
||||||
|
let hd = world_height(hitpos.x, hitpos.y - CELL);
|
||||||
|
let hu = world_height(hitpos.x, hitpos.y + CELL);
|
||||||
|
let n = normalize(vec3<f32>(hl - hr, hd - hu, 2.0 * CELL));
|
||||||
|
let lambert = max(0.0, dot(n, sun));
|
||||||
|
var lit = albedo * (ambient + (1.0 - ambient) * lambert);
|
||||||
|
let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||||
|
lit = mix(lit, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), fog);
|
||||||
|
return vec4<f32>(lit, 1.0);
|
||||||
|
}
|
||||||
@@ -9,18 +9,19 @@ import ra3.client;
|
|||||||
import ra3.ui;
|
import ra3.ui;
|
||||||
import ra3.vulkan;
|
import ra3.vulkan;
|
||||||
import ra3.dx;
|
import ra3.dx;
|
||||||
import ra3.webgl;
|
import ra3.wasmgl;
|
||||||
|
import ra3.webgpu;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend selection for the presentation layer.
|
* Backend selection for the presentation layer.
|
||||||
*
|
*
|
||||||
* The app talks only to `ra3::client::display`; this module picks the concrete
|
* 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
|
* backend so no caller has to know which one is in use. The available backends
|
||||||
* are Vulkan, Direct3D 11 and Direct3D 12, WebGL (all GPU terrain, available
|
* are Vulkan, Direct3D 11 and Direct3D 12 (desktop), the WebGPU and SDL-free
|
||||||
* per platform) and the SDL software blit fallback. The caller may name a
|
* WebGL2 wasm worker backends (wasm) and the SDL software blit fallback. The
|
||||||
* preferred backend (the in-game menu exposes this); if it does not start, the
|
* caller may name a preferred backend (the in-game menu exposes this); if it
|
||||||
* others are tried in turn. The GPU terrain path reports failure so the caller
|
* does not start, the others are tried in turn. The GPU terrain path reports
|
||||||
* can fall back to the software renderer.
|
* failure so the caller can fall back to the software renderer.
|
||||||
*/
|
*/
|
||||||
export namespace ra3::display {
|
export namespace ra3::display {
|
||||||
using ra3::client::display_options;
|
using ra3::client::display_options;
|
||||||
@@ -33,12 +34,12 @@ export namespace ra3::display {
|
|||||||
using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
|
using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
|
||||||
|
|
||||||
/** A concrete presentation backend, or `none`. */
|
/** A concrete presentation backend, or `none`. */
|
||||||
enum class backend { none, vulkan, d3d11, d3d12, webgl, sdl };
|
enum class backend { none, vulkan, d3d11, d3d12, wasmgl, webgpu, sdl };
|
||||||
|
|
||||||
/** The backend preferred on this host: WebGL under Emscripten, else Vulkan. */
|
/** The backend preferred on this host: WebGPU under Emscripten (falling back to the WebGL worker), else Vulkan. */
|
||||||
[[nodiscard]] inline constexpr auto default_backend() -> backend {
|
[[nodiscard]] inline constexpr auto default_backend() -> backend {
|
||||||
#if defined(__EMSCRIPTEN__)
|
#if defined(__EMSCRIPTEN__)
|
||||||
return backend::webgl;
|
return backend::webgpu;
|
||||||
#else
|
#else
|
||||||
return backend::vulkan;
|
return backend::vulkan;
|
||||||
#endif
|
#endif
|
||||||
@@ -49,7 +50,8 @@ export namespace ra3::display {
|
|||||||
case backend::vulkan: return "vulkan";
|
case backend::vulkan: return "vulkan";
|
||||||
case backend::d3d11: return "d3d11";
|
case backend::d3d11: return "d3d11";
|
||||||
case backend::d3d12: return "d3d12";
|
case backend::d3d12: return "d3d12";
|
||||||
case backend::webgl: return "webgl";
|
case backend::wasmgl: return "wasmgl";
|
||||||
|
case backend::webgpu: return "webgpu";
|
||||||
case backend::sdl: return "sdl";
|
case backend::sdl: return "sdl";
|
||||||
default: return "none";
|
default: return "none";
|
||||||
}
|
}
|
||||||
@@ -60,21 +62,23 @@ export namespace ra3::display {
|
|||||||
case backend::vulkan: return std::make_unique<ra3::vulkan::vulkan_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::d3d11: return std::make_unique<ra3::dx::d3d11_display>();
|
||||||
case backend::d3d12: return std::make_unique<ra3::dx::d3d12_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::wasmgl: return std::make_unique<ra3::wasmgl::wasmgl_display>();
|
||||||
|
case backend::webgpu: return std::make_unique<ra3::webgpu::webgpu_display>();
|
||||||
case backend::sdl: return std::make_unique<ra3::ui::sdl_display>();
|
case backend::sdl: return std::make_unique<ra3::ui::sdl_display>();
|
||||||
default: return nullptr;
|
default: return nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The order in which backends are attempted for a preferred one. */
|
/** The order in which backends are attempted for a preferred one. */
|
||||||
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 5> {
|
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 6> {
|
||||||
switch (preferred) {
|
switch (preferred) {
|
||||||
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl, backend::sdl};
|
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl};
|
||||||
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgl, backend::sdl};
|
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl};
|
||||||
case backend::webgl: return {backend::webgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
|
case backend::wasmgl: return {backend::wasmgl, backend::webgpu, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
|
||||||
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgl};
|
case backend::webgpu: return {backend::webgpu, backend::wasmgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
|
||||||
|
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl};
|
||||||
case backend::vulkan:
|
case backend::vulkan:
|
||||||
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgl, backend::sdl};
|
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgpu, backend::wasmgl, backend::sdl};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -19,5 +19,6 @@ export import ra3.terrain;
|
|||||||
export import ra3.ui;
|
export import ra3.ui;
|
||||||
export import ra3.vulkan;
|
export import ra3.vulkan;
|
||||||
export import ra3.dx;
|
export import ra3.dx;
|
||||||
export import ra3.webgl;
|
export import ra3.wasmgl;
|
||||||
|
export import ra3.webgpu;
|
||||||
export import ra3.display;
|
export import ra3.display;
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
module;
|
||||||
|
|
||||||
|
// Emscripten/GLES3 headers in the global module fragment (C headers). No SDL:
|
||||||
|
// this backend runs the engine on a Web Worker and renders into an
|
||||||
|
// OffscreenCanvas transferred from the page, so it must not touch the
|
||||||
|
// main-thread-only SDL Emscripten port.
|
||||||
|
#include <GLES3/gl3.h>
|
||||||
|
|
||||||
|
#include <emscripten/em_asm.h>
|
||||||
|
#include <emscripten/emscripten.h>
|
||||||
|
#include <emscripten/html5.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 "wasmgl_glsl_embedded.hpp"
|
||||||
|
|
||||||
|
export module ra3.wasmgl;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export import ra3.core;
|
||||||
|
export import ra3.render;
|
||||||
|
export import ra3.terrain;
|
||||||
|
export import ra3.client;
|
||||||
|
import ender.log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An SDL-free WebGL 2 presentation backend for the wasm build.
|
||||||
|
*
|
||||||
|
* The whole runtime runs on a plain Web Worker (started by
|
||||||
|
* `apps/web/openra3.worker.js`), so `main()` lives there and its virtual
|
||||||
|
* filesystem is local to the worker: `FS.createLazyFile` (synchronous XHR) is
|
||||||
|
* legal and the browser fetches each asset only when the engine opens it.
|
||||||
|
* Note that Emscripten's `PROXY_TO_PTHREAD` cannot be used for this: in a
|
||||||
|
* pthread every filesystem syscall is proxied to the main browser thread, so
|
||||||
|
* the engine would read the main thread's FS, not the worker's lazy one.
|
||||||
|
*
|
||||||
|
* The page transfers its `#canvas` to the worker as an OffscreenCanvas; this
|
||||||
|
* module creates the WebGL2 context on it with `emscripten_webgl_create_context`
|
||||||
|
* and draws the same 2D menu / GPU terrain as the other backends. Input arrives
|
||||||
|
* over the worker message channel (the page owns the DOM listeners) into the
|
||||||
|
* `openra3_*` C functions below, which queue `ra3::render::ui_event`s.
|
||||||
|
*
|
||||||
|
* It reuses the same GL pipeline/texture code as the desktop GPU backends; only
|
||||||
|
* the windowing/context/event layer differs.
|
||||||
|
*/
|
||||||
|
export namespace ra3::wasmgl {
|
||||||
|
class wasmgl_display;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives the input events the worker forwards from the page. Each wasm
|
||||||
|
* backend (WebGL here, WebGPU in `ra3.webgpu`) implements it and registers
|
||||||
|
* itself on init, so the `openra3_*` C exports live in exactly one module.
|
||||||
|
*/
|
||||||
|
class event_sink {
|
||||||
|
public:
|
||||||
|
virtual ~event_sink() = default;
|
||||||
|
virtual void on_key(std::string_view code, bool down) = 0;
|
||||||
|
virtual void on_text(int codepoint) = 0;
|
||||||
|
virtual void on_mouse_button(double x, double y, bool left) = 0;
|
||||||
|
virtual void on_mouse_move(double x, double y, double dx, double dy, bool left) = 0;
|
||||||
|
virtual void on_wheel(double delta_y) = 0;
|
||||||
|
virtual void on_resize(int width, int height) = 0;
|
||||||
|
virtual void on_quit() = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
/** The backend that currently receives worker input. */
|
||||||
|
[[nodiscard]] inline auto sink_slot() -> event_sink *& {
|
||||||
|
static event_sink *sink = nullptr;
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
} // namespace detail
|
||||||
|
|
||||||
|
/** Register (or, with null, clear) the backend that receives input events. */
|
||||||
|
inline auto set_event_sink(event_sink *sink) -> void { detail::sink_slot() = sink; }
|
||||||
|
|
||||||
|
namespace detail {
|
||||||
|
/** Movement keys tracked as "held" for panning (physical codes). */
|
||||||
|
enum class move_key : std::uint8_t { w, s, a, d, arrow_up, arrow_down, arrow_left, arrow_right, count };
|
||||||
|
|
||||||
|
[[nodiscard]] inline auto move_key_index(std::string_view code) -> int {
|
||||||
|
if (code == "KeyW") return static_cast<int>(move_key::w);
|
||||||
|
if (code == "KeyS") return static_cast<int>(move_key::s);
|
||||||
|
if (code == "KeyA") return static_cast<int>(move_key::a);
|
||||||
|
if (code == "KeyD") return static_cast<int>(move_key::d);
|
||||||
|
if (code == "ArrowUp") return static_cast<int>(move_key::arrow_up);
|
||||||
|
if (code == "ArrowDown") return static_cast<int>(move_key::arrow_down);
|
||||||
|
if (code == "ArrowLeft") return static_cast<int>(move_key::arrow_left);
|
||||||
|
if (code == "ArrowRight") return static_cast<int>(move_key::arrow_right);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `ui_key` for an action key, or `none`. */
|
||||||
|
[[nodiscard]] inline auto key_from_code(std::string_view code) -> ra3::render::ui_key {
|
||||||
|
using ra3::render::ui_key;
|
||||||
|
if (code == "ArrowUp") return ui_key::up;
|
||||||
|
if (code == "ArrowDown") return ui_key::down;
|
||||||
|
if (code == "ArrowLeft") return ui_key::left;
|
||||||
|
if (code == "ArrowRight") return ui_key::right;
|
||||||
|
if (code == "PageUp") return ui_key::page_up;
|
||||||
|
if (code == "PageDown") return ui_key::page_down;
|
||||||
|
if (code == "Enter" || code == "NumpadEnter") return ui_key::confirm;
|
||||||
|
if (code == "Escape") return ui_key::cancel;
|
||||||
|
if (code == "Tab") return ui_key::tab;
|
||||||
|
if (code == "Backspace") return ui_key::backspace;
|
||||||
|
return ui_key::none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The single live display (there is only ever one). */
|
||||||
|
[[nodiscard]] inline auto active_slot() -> wasmgl_display *& {
|
||||||
|
static wasmgl_display *instance = nullptr;
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera + terrain uniforms (5 x vec4), matching the other backends. */
|
||||||
|
[[nodiscard]] inline auto terrain_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||||
|
float aspect) -> std::array<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};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read `assets.manifest.json` and register every listed file as an
|
||||||
|
* Emscripten lazy file, so the browser fetches a file only when the
|
||||||
|
* engine first opens it. Runs on the worker, where synchronous XHR
|
||||||
|
* (used by `FS.createLazyFile`) is allowed. Returns the number of files
|
||||||
|
* registered, or -1 on failure. A missing manifest is not an error (the
|
||||||
|
* caller may have preloaded a full asset tree instead).
|
||||||
|
*/
|
||||||
|
[[nodiscard]] inline auto mount_lazy_assets(const std::string &manifest_path, const std::string &assets_root, const std::string &url_prefix)
|
||||||
|
-> int {
|
||||||
|
std::ifstream in(manifest_path, std::ios::binary);
|
||||||
|
if (!in) return 0;
|
||||||
|
const std::string json{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
|
||||||
|
if (json.empty()) return 0;
|
||||||
|
return EM_ASM_INT(
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
var manifest = JSON.parse(UTF8ToString($0));
|
||||||
|
var root = UTF8ToString($1);
|
||||||
|
var prefix = UTF8ToString($2);
|
||||||
|
var files = manifest.files || [];
|
||||||
|
var n = 0;
|
||||||
|
for (var i = 0; i < files.length; ++i) {
|
||||||
|
var rel = files[i];
|
||||||
|
var full = root + '/' + rel;
|
||||||
|
var slash = full.lastIndexOf('/');
|
||||||
|
var dir = full.substring(0, slash);
|
||||||
|
var name = full.substring(slash + 1);
|
||||||
|
FS.mkdirTree(dir);
|
||||||
|
FS.createLazyFile(dir, name, prefix + rel, true, false);
|
||||||
|
++n;
|
||||||
|
}
|
||||||
|
console.log('ra3.wasmgl: mounted ' + n + ' lazy assets under ' + root);
|
||||||
|
return n;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ra3.wasmgl: lazy asset mount failed: ' + e);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
json.c_str(), assets_root.c_str(), url_prefix.c_str());
|
||||||
|
}
|
||||||
|
} // namespace detail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the wasm asset tree lazily (call once, before any asset access).
|
||||||
|
* On non-wasm builds this does nothing.
|
||||||
|
*/
|
||||||
|
inline auto mount_assets(const std::string &manifest_path = "/assets.manifest.json", const std::string &assets_root = "/assets",
|
||||||
|
const std::string &url_prefix = "assets/") -> int {
|
||||||
|
#if defined(__EMSCRIPTEN__)
|
||||||
|
return detail::mount_lazy_assets(manifest_path, assets_root, url_prefix);
|
||||||
|
#else
|
||||||
|
(void) manifest_path;
|
||||||
|
(void) assets_root;
|
||||||
|
(void) url_prefix;
|
||||||
|
return 0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
class wasmgl_display final : public ra3::client::display, public event_sink {
|
||||||
|
public:
|
||||||
|
wasmgl_display() = default;
|
||||||
|
wasmgl_display(const wasmgl_display &) = delete;
|
||||||
|
auto operator=(const wasmgl_display &) -> wasmgl_display & = delete;
|
||||||
|
~wasmgl_display() override { this->shutdown(); }
|
||||||
|
|
||||||
|
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
|
||||||
|
this->fps_limit_ = options.fps_limit;
|
||||||
|
|
||||||
|
// There is no DOM in the worker, so Emscripten's canvas lookup
|
||||||
|
// (`findEventTarget`, a CSS selector) cannot find #canvas. Register
|
||||||
|
// the transferred OffscreenCanvas (Module.canvas) as a special target
|
||||||
|
// so `emscripten_webgl_create_context`/`..._element_size` resolve it.
|
||||||
|
EM_ASM({
|
||||||
|
if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) {
|
||||||
|
specialHTMLTargets['#canvas'] = Module['canvas'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
EmscriptenWebGLContextAttributes attributes;
|
||||||
|
emscripten_webgl_init_context_attributes(&attributes);
|
||||||
|
attributes.majorVersion = 2;
|
||||||
|
attributes.minorVersion = 0;
|
||||||
|
attributes.alpha = false;
|
||||||
|
attributes.depth = true;
|
||||||
|
attributes.stencil = false;
|
||||||
|
attributes.antialias = false;
|
||||||
|
attributes.premultipliedAlpha = false;
|
||||||
|
attributes.preserveDrawingBuffer = false;
|
||||||
|
attributes.powerPreference = EM_WEBGL_POWER_PREFERENCE_HIGH_PERFORMANCE;
|
||||||
|
attributes.proxyContextToMainThread = EMSCRIPTEN_WEBGL_CONTEXT_PROXY_DISALLOW;
|
||||||
|
context_ = emscripten_webgl_create_context("#canvas", &attributes);
|
||||||
|
if (context_ == 0) {
|
||||||
|
ender::log::error("ra3.wasmgl: emscripten_webgl_create_context failed (is #canvas an OffscreenCanvas in this worker?)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (emscripten_webgl_make_context_current(context_) != EMSCRIPTEN_RESULT_SUCCESS) {
|
||||||
|
ender::log::error("ra3.wasmgl: emscripten_webgl_make_context_current failed");
|
||||||
|
this->shutdown();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The page sized the canvas before transfer; fall back to the
|
||||||
|
// requested size if it is missing.
|
||||||
|
const auto [width, height] = this->window_size();
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
emscripten_set_canvas_element_size("#canvas", options.width, options.height);
|
||||||
|
}
|
||||||
|
if (!this->create_scene_pipeline()) {
|
||||||
|
this->shutdown();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
glGenVertexArrays(1, &vao_);
|
||||||
|
glBindVertexArray(vao_);
|
||||||
|
detail::active_slot() = this;
|
||||||
|
detail::sink_slot() = this;
|
||||||
|
ender::log::info(std::format("ra3.wasmgl: WebGL worker backend initialized ({})",
|
||||||
|
reinterpret_cast<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 (changed || scene_tex_ == 0U || frame.width() != scene_w_ || frame.height() != scene_h_) {
|
||||||
|
this->upload_scene(frame);
|
||||||
|
}
|
||||||
|
const auto [window_w, window_h] = this->window_size();
|
||||||
|
if (window_w <= 0 || window_h <= 0) return true;
|
||||||
|
|
||||||
|
glViewport(0, 0, window_w, window_h);
|
||||||
|
glDisable(GL_DEPTH_TEST);
|
||||||
|
glEnable(GL_BLEND);
|
||||||
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||||
|
glClearColor(0.05F, 0.06F, 0.08F, 1.0F);
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT);
|
||||||
|
this->draw_fullscreen(scene_program_, scene_rect_loc_, scene_tex_, dest, window_w, window_h);
|
||||||
|
this->mark_frame();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override {
|
||||||
|
if (events_.empty()) return false;
|
||||||
|
out = events_.front();
|
||||||
|
events_.pop_front();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
|
||||||
|
int w = 0;
|
||||||
|
int h = 0;
|
||||||
|
if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0};
|
||||||
|
return {w, h};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override {
|
||||||
|
const auto held = [this](detail::move_key which) { return held_[static_cast<std::size_t>(which)]; };
|
||||||
|
switch (key) {
|
||||||
|
case ra3::render::ui_key::up: return held(detail::move_key::w) || held(detail::move_key::arrow_up);
|
||||||
|
case ra3::render::ui_key::down: return held(detail::move_key::s) || held(detail::move_key::arrow_down);
|
||||||
|
case ra3::render::ui_key::left: return held(detail::move_key::a) || held(detail::move_key::arrow_left);
|
||||||
|
case ra3::render::ui_key::right: return held(detail::move_key::d) || held(detail::move_key::arrow_right);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
|
||||||
|
|
||||||
|
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||||
|
const ra3::client::terrain_overlay &overlay) -> bool override {
|
||||||
|
if (!terrain_ready_) {
|
||||||
|
if (!this->create_terrain_pipeline()) return false;
|
||||||
|
if (!this->create_terrain_textures(terrain)) return false;
|
||||||
|
terrain_ready_ = true;
|
||||||
|
ender::log::info(std::format("ra3.wasmgl: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
|
||||||
|
}
|
||||||
|
if (overlay.label_changed) this->upload_overlay(label_tex_, overlay.label);
|
||||||
|
if (overlay.minimap_changed) this->upload_overlay(minimap_tex_, overlay.minimap);
|
||||||
|
|
||||||
|
const auto [window_w, window_h] = this->window_size();
|
||||||
|
if (window_w <= 0 || window_h <= 0) return true;
|
||||||
|
const auto aspect = static_cast<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);
|
||||||
|
this->mark_frame();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto shutdown() -> void override {
|
||||||
|
if (detail::active_slot() == this) detail::active_slot() = nullptr;
|
||||||
|
if (detail::sink_slot() == this) detail::sink_slot() = nullptr;
|
||||||
|
if (context_ != 0) {
|
||||||
|
this->destroy_textures();
|
||||||
|
if (scene_program_ != 0U) glDeleteProgram(scene_program_);
|
||||||
|
if (terrain_program_ != 0U) glDeleteProgram(terrain_program_);
|
||||||
|
if (vao_ != 0U) glDeleteVertexArrays(1, &vao_);
|
||||||
|
emscripten_webgl_destroy_context(context_);
|
||||||
|
}
|
||||||
|
context_ = 0;
|
||||||
|
vao_ = 0U;
|
||||||
|
scene_program_ = terrain_program_ = 0U;
|
||||||
|
scene_rect_loc_ = terrain_cam_loc_ = terrain_height_loc_ = terrain_cell_loc_ = terrain_atlas_loc_ = -1;
|
||||||
|
terrain_ready_ = false;
|
||||||
|
scene_w_ = scene_h_ = 0U;
|
||||||
|
events_.clear();
|
||||||
|
held_.fill(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto name() const -> std::string_view override { return "wasmgl"; }
|
||||||
|
|
||||||
|
// ---- worker message entry points (see apps/web/openra3.worker.js) ----
|
||||||
|
|
||||||
|
/** A key down/up; `code` is a DOM `KeyboardEvent.code`. */
|
||||||
|
auto on_key(std::string_view code, bool down) -> void override {
|
||||||
|
if (const int index = detail::move_key_index(code); index >= 0) {
|
||||||
|
held_[static_cast<std::size_t>(index)] = down;
|
||||||
|
}
|
||||||
|
if (!down) return;
|
||||||
|
if (const auto key = detail::key_from_code(code); key != ra3::render::ui_key::none) {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::key;
|
||||||
|
out.key = key;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A printable character (DOM `KeyboardEvent.charCode`). */
|
||||||
|
auto on_text(int codepoint) -> void override {
|
||||||
|
if (codepoint <= 0 || codepoint > 0x7F) return;
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::text;
|
||||||
|
out.character = static_cast<char>(codepoint);
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_mouse_button(double x, double y, bool left) -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::mouse_button;
|
||||||
|
out.x = static_cast<float>(x);
|
||||||
|
out.y = static_cast<float>(y);
|
||||||
|
out.left = left;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::mouse_move;
|
||||||
|
out.x = static_cast<float>(x);
|
||||||
|
out.y = static_cast<float>(y);
|
||||||
|
out.dx = static_cast<float>(dx);
|
||||||
|
out.dy = static_cast<float>(dy);
|
||||||
|
out.left = left;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_wheel(double delta_y) -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::wheel;
|
||||||
|
out.wheel = static_cast<float>(-delta_y); // browser +Y is scroll-down; SDL +Y is scroll-up.
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_quit() -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::quit;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resize the drawing buffer (backing pixels, already DPR-scaled). */
|
||||||
|
auto on_resize(int width, int height) -> void override {
|
||||||
|
if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// ---- GL helpers ------------------------------------------------------
|
||||||
|
|
||||||
|
[[nodiscard]] auto compile(unsigned type, const char *source, std::string_view what) -> GLuint {
|
||||||
|
const GLuint shader = glCreateShader(type);
|
||||||
|
glShaderSource(shader, 1, &source, nullptr);
|
||||||
|
glCompileShader(shader);
|
||||||
|
GLint ok = GL_FALSE;
|
||||||
|
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
|
||||||
|
if (ok == GL_FALSE) {
|
||||||
|
std::array<char, 1024> log{};
|
||||||
|
glGetShaderInfoLog(shader, static_cast<GLsizei>(log.size()), nullptr, log.data());
|
||||||
|
ender::log::error(std::format("ra3.wasmgl: {} shader compile failed: {}", what, log.data()));
|
||||||
|
glDeleteShader(shader);
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto link(const char *vertex, const char *fragment, std::string_view what) -> GLuint {
|
||||||
|
const GLuint vs = this->compile(GL_VERTEX_SHADER, vertex, what);
|
||||||
|
const GLuint fs = this->compile(GL_FRAGMENT_SHADER, fragment, what);
|
||||||
|
if (vs == 0U || fs == 0U) {
|
||||||
|
if (vs != 0U) glDeleteShader(vs);
|
||||||
|
if (fs != 0U) glDeleteShader(fs);
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
const GLuint program = glCreateProgram();
|
||||||
|
glAttachShader(program, vs);
|
||||||
|
glAttachShader(program, fs);
|
||||||
|
glLinkProgram(program);
|
||||||
|
glDeleteShader(vs);
|
||||||
|
glDeleteShader(fs);
|
||||||
|
GLint ok = GL_FALSE;
|
||||||
|
glGetProgramiv(program, GL_LINK_STATUS, &ok);
|
||||||
|
if (ok == GL_FALSE) {
|
||||||
|
std::array<char, 1024> log{};
|
||||||
|
glGetProgramInfoLog(program, static_cast<GLsizei>(log.size()), nullptr, log.data());
|
||||||
|
ender::log::error(std::format("ra3.wasmgl: {} program link failed: {}", what, log.data()));
|
||||||
|
glDeleteProgram(program);
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto create_scene_pipeline() -> bool {
|
||||||
|
scene_program_ = this->link(ra3_shaders::webgl_scene_vert_glsl, ra3_shaders::webgl_scene_frag_glsl, "scene");
|
||||||
|
if (scene_program_ == 0U) return false;
|
||||||
|
scene_rect_loc_ = glGetUniformLocation(scene_program_, "u_rect");
|
||||||
|
glUseProgram(scene_program_);
|
||||||
|
glUniform1i(glGetUniformLocation(scene_program_, "u_scene"), 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto create_terrain_pipeline() -> bool {
|
||||||
|
terrain_program_ = this->link(ra3_shaders::webgl_terrain_vert_glsl, ra3_shaders::webgl_terrain_frag_glsl, "terrain");
|
||||||
|
if (terrain_program_ == 0U) return false;
|
||||||
|
terrain_cam_loc_ = glGetUniformLocation(terrain_program_, "u_data");
|
||||||
|
terrain_height_loc_ = glGetUniformLocation(terrain_program_, "u_heightmap");
|
||||||
|
terrain_cell_loc_ = glGetUniformLocation(terrain_program_, "u_celldata");
|
||||||
|
terrain_atlas_loc_ = glGetUniformLocation(terrain_program_, "u_atlas");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Re)create the 2D texture used by the scene/overlay path. */
|
||||||
|
auto make_texture2d(GLuint &texture, GLint internal, GLenum format, GLenum type, int w, int h, const void *data, bool linear,
|
||||||
|
bool wrap) -> void {
|
||||||
|
if (texture == 0U) glGenTextures(1, &texture);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, texture);
|
||||||
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, type, data);
|
||||||
|
if (const GLenum error = glGetError(); error != GL_NO_ERROR) {
|
||||||
|
ender::log::error(std::format("ra3.wasmgl: texImage2D failed: internal=0x{:X} format=0x{:X} type=0x{:X} size={}x{} err=0x{:X}",
|
||||||
|
static_cast<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish a coarse frame counter to the page for the fps probe. */
|
||||||
|
auto mark_frame() const -> void {
|
||||||
|
++frames_;
|
||||||
|
if (frames_ % 15U == 0U) {
|
||||||
|
EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast<int>(frames_));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto draw_fullscreen(GLuint program, GLint rect_location, GLuint texture, const ra3::render::view_rect &dest, int window_w, int window_h)
|
||||||
|
-> void {
|
||||||
|
glUseProgram(program);
|
||||||
|
glBindVertexArray(vao_);
|
||||||
|
const float rect[4] = {dest.x / static_cast<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context_ = 0;
|
||||||
|
GLuint vao_ = 0U;
|
||||||
|
|
||||||
|
GLuint scene_program_ = 0U;
|
||||||
|
GLint scene_rect_loc_ = -1;
|
||||||
|
GLuint terrain_program_ = 0U;
|
||||||
|
GLint terrain_cam_loc_ = -1;
|
||||||
|
GLint terrain_height_loc_ = -1;
|
||||||
|
GLint terrain_cell_loc_ = -1;
|
||||||
|
GLint terrain_atlas_loc_ = -1;
|
||||||
|
|
||||||
|
GLuint scene_tex_ = 0U;
|
||||||
|
core::uint32 scene_w_ = 0U;
|
||||||
|
core::uint32 scene_h_ = 0U;
|
||||||
|
bool terrain_ready_ = false;
|
||||||
|
GLuint height_tex_ = 0U;
|
||||||
|
GLuint cell_tex_ = 0U;
|
||||||
|
GLuint atlas_tex_ = 0U;
|
||||||
|
GLuint label_tex_ = 0U;
|
||||||
|
GLuint minimap_tex_ = 0U;
|
||||||
|
|
||||||
|
std::deque<ra3::render::ui_event> events_;
|
||||||
|
std::array<bool, static_cast<std::size_t>(detail::move_key::count)> held_{};
|
||||||
|
mutable unsigned frames_ = 0U;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- C API called by apps/web/openra3.worker.js --------------------------
|
||||||
|
|
||||||
|
/** Key down/up: `code` is a DOM KeyboardEvent.code. */
|
||||||
|
inline auto post_key(const char *code, int down) -> void {
|
||||||
|
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_key(code != nullptr ? code : "", down != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Printable character (DOM charCode). */
|
||||||
|
inline auto post_text(int codepoint) -> void {
|
||||||
|
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_text(codepoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline auto post_mouse_button(double x, double y, int left) -> void {
|
||||||
|
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_button(x, y, left != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline auto post_mouse_move(double x, double y, double dx, double dy, int left) -> void {
|
||||||
|
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_move(x, y, dx, dy, left != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline auto post_wheel(double delta_y) -> void {
|
||||||
|
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_wheel(delta_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline auto post_resize(int width, int height) -> void {
|
||||||
|
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_resize(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline auto post_quit() -> void {
|
||||||
|
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_quit();
|
||||||
|
}
|
||||||
|
} // namespace ra3::wasmgl
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
EMSCRIPTEN_KEEPALIVE inline auto openra3_key(const char *code, int down) -> void { ra3::wasmgl::post_key(code, down); }
|
||||||
|
EMSCRIPTEN_KEEPALIVE inline auto openra3_text(int codepoint) -> void { ra3::wasmgl::post_text(codepoint); }
|
||||||
|
EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_button(double x, double y, int left) -> void { ra3::wasmgl::post_mouse_button(x, y, left); }
|
||||||
|
EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_move(double x, double y, double dx, double dy, int left) -> void {
|
||||||
|
ra3::wasmgl::post_mouse_move(x, y, dx, dy, left);
|
||||||
|
}
|
||||||
|
EMSCRIPTEN_KEEPALIVE inline auto openra3_wheel(double delta_y) -> void { ra3::wasmgl::post_wheel(delta_y); }
|
||||||
|
EMSCRIPTEN_KEEPALIVE inline auto openra3_resize(int width, int height) -> void { ra3::wasmgl::post_resize(width, height); }
|
||||||
|
EMSCRIPTEN_KEEPALIVE inline auto openra3_quit() -> void { ra3::wasmgl::post_quit(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export module ra3.wasmgl;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export import ra3.core;
|
||||||
|
import ra3.render;
|
||||||
|
import ra3.client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback wasm-worker WebGL backend for builds without Emscripten. `init` fails
|
||||||
|
* so the caller can select another display; the class name matches the real
|
||||||
|
* `ra3.wasmgl` module so the backend factory in `ra3.display` compiles
|
||||||
|
* unchanged.
|
||||||
|
*/
|
||||||
|
export namespace ra3::wasmgl {
|
||||||
|
/** No-op off Emscripten (there is no Emscripten virtual filesystem). */
|
||||||
|
inline auto mount_assets(const std::string & = "/assets.manifest.json", const std::string & = "/assets",
|
||||||
|
const std::string & = "assets/") -> int {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class wasmgl_display final : public ra3::client::display {
|
||||||
|
public:
|
||||||
|
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
|
||||||
|
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
|
||||||
|
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
|
||||||
|
[[nodiscard]] auto window_size() const -> std::pair<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 "wasmgl(null)"; }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
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,610 @@
|
|||||||
|
module;
|
||||||
|
|
||||||
|
// Dawn's header-only C++ wrapper for the WebGPU C API, provided by Emscripten's
|
||||||
|
// emdawnwebgpu port (`--use-port=emdawnwebgpu`). This backend is wasm-only: on
|
||||||
|
// the web WebGPU accepts WGSL only (SPIR-V is not available), so the shaders are
|
||||||
|
// WGSL ports of the Vulkan/WebGL ones rather than a reuse of the SPIR-V blobs.
|
||||||
|
#include <webgpu/webgpu_cpp.h>
|
||||||
|
|
||||||
|
#include <emscripten/em_asm.h>
|
||||||
|
#include <emscripten/emscripten.h>
|
||||||
|
#include <emscripten/html5.h>
|
||||||
|
|
||||||
|
#include "webgpu_wgsl_embedded.hpp"
|
||||||
|
|
||||||
|
export module ra3.webgpu;
|
||||||
|
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export import ra3.core;
|
||||||
|
export import ra3.render;
|
||||||
|
export import ra3.terrain;
|
||||||
|
export import ra3.client;
|
||||||
|
import ra3.wasmgl;
|
||||||
|
import ender.log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A WebGPU presentation backend for the wasm build, a peer of `ra3.wasmgl`.
|
||||||
|
*
|
||||||
|
* It runs in the same plain Web Worker and draws into the same transferred
|
||||||
|
* OffscreenCanvas, but through WebGPU: a surface is created on `#canvas`, a
|
||||||
|
* swapchain image is acquired each frame and presented. The uniform layout and
|
||||||
|
* the terrain data plumbing are shared with the Vulkan/WebGL backends
|
||||||
|
* (`terrain_uniforms`); only the API calls and the WGSL shaders differ.
|
||||||
|
*
|
||||||
|
* It reuses `ra3.wasmgl`'s `event_sink` so the page/worker input protocol is the
|
||||||
|
* same for both wasm backends.
|
||||||
|
*/
|
||||||
|
export namespace ra3::webgpu {
|
||||||
|
class webgpu_display final : public ra3::client::display, public ra3::wasmgl::event_sink {
|
||||||
|
public:
|
||||||
|
webgpu_display() = default;
|
||||||
|
webgpu_display(const webgpu_display &) = delete;
|
||||||
|
auto operator=(const webgpu_display &) -> webgpu_display & = delete;
|
||||||
|
~webgpu_display() override { this->shutdown(); }
|
||||||
|
|
||||||
|
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
|
||||||
|
this->fps_limit_ = options.fps_limit;
|
||||||
|
wgpu::Device device(emscripten_webgpu_get_device());
|
||||||
|
if (device == nullptr) {
|
||||||
|
ender::log::warn("ra3.webgpu: no preinitialized WebGPU device");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
device_ = std::move(device);
|
||||||
|
queue_ = device_.GetQueue();
|
||||||
|
instance_ = wgpu::CreateInstance();
|
||||||
|
if (instance_ == nullptr) {
|
||||||
|
ender::log::warn("ra3.webgpu: wgpuCreateInstance failed");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
wgpu::EmscriptenSurfaceSourceCanvasHTMLSelector selector;
|
||||||
|
selector.selector = "#canvas";
|
||||||
|
// There is no DOM in the worker, so Emscripten's canvas lookup cannot
|
||||||
|
// find #canvas by CSS selector; register the transferred OffscreenCanvas
|
||||||
|
// (Module.canvas) as a special target for `wgpuInstanceCreateSurface`.
|
||||||
|
EM_ASM({
|
||||||
|
if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) {
|
||||||
|
specialHTMLTargets['#canvas'] = Module['canvas'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wgpu::SurfaceDescriptor surface_descriptor;
|
||||||
|
surface_descriptor.nextInChain = &selector;
|
||||||
|
surface_ = instance_.CreateSurface(&surface_descriptor);
|
||||||
|
if (surface_ == nullptr) {
|
||||||
|
ender::log::warn("ra3.webgpu: CreateSurface failed (is #canvas an OffscreenCanvas?)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this->create_pipelines()) {
|
||||||
|
ender::log::warn("ra3.webgpu: pipeline creation failed");
|
||||||
|
this->shutdown();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto [width, height] = this->window_size();
|
||||||
|
this->configure(width > 0 ? width : options.width, height > 0 ? height : options.height);
|
||||||
|
ra3::wasmgl::set_event_sink(this);
|
||||||
|
ender::log::info("ra3.webgpu: WebGPU backend initialized");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
|
||||||
|
if (frame.empty()) return false;
|
||||||
|
if (changed || !scene_.texture || static_cast<int>(frame.width()) != scene_.width || static_cast<int>(frame.height()) != scene_.height) {
|
||||||
|
this->upload_image(scene_, frame);
|
||||||
|
}
|
||||||
|
const auto [window_w, window_h] = this->window_size();
|
||||||
|
if (window_w <= 0 || window_h <= 0) return true;
|
||||||
|
return this->draw({0.05F, 0.06F, 0.08F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) {
|
||||||
|
this->draw_image(pass, scene_, dest, w, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override {
|
||||||
|
if (events_.empty()) return false;
|
||||||
|
out = events_.front();
|
||||||
|
events_.pop_front();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
|
||||||
|
int w = 0;
|
||||||
|
int h = 0;
|
||||||
|
if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0};
|
||||||
|
return {w, h};
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override {
|
||||||
|
const auto held = [this](ra3::wasmgl::detail::move_key which) { return held_[static_cast<std::size_t>(which)]; };
|
||||||
|
switch (key) {
|
||||||
|
case ra3::render::ui_key::up: return held(ra3::wasmgl::detail::move_key::w) || held(ra3::wasmgl::detail::move_key::arrow_up);
|
||||||
|
case ra3::render::ui_key::down: return held(ra3::wasmgl::detail::move_key::s) || held(ra3::wasmgl::detail::move_key::arrow_down);
|
||||||
|
case ra3::render::ui_key::left: return held(ra3::wasmgl::detail::move_key::a) || held(ra3::wasmgl::detail::move_key::arrow_left);
|
||||||
|
case ra3::render::ui_key::right: return held(ra3::wasmgl::detail::move_key::d) || held(ra3::wasmgl::detail::move_key::arrow_right);
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
|
||||||
|
|
||||||
|
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||||
|
const ra3::client::terrain_overlay &overlay) -> bool override {
|
||||||
|
if (!terrain_ready_) {
|
||||||
|
if (!this->create_terrain(terrain)) return false;
|
||||||
|
terrain_ready_ = true;
|
||||||
|
ender::log::info(std::format("ra3.webgpu: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
|
||||||
|
}
|
||||||
|
if (overlay.label_changed) this->upload_image(label_, overlay.label);
|
||||||
|
if (overlay.minimap_changed) this->upload_image(minimap_, overlay.minimap);
|
||||||
|
|
||||||
|
const auto [window_w, window_h] = this->window_size();
|
||||||
|
if (window_w <= 0 || window_h <= 0) return true;
|
||||||
|
const auto aspect = static_cast<float>(window_w) / static_cast<float>(std::max(1, window_h));
|
||||||
|
const auto uniforms = detail_uniforms(terrain, camera, time_s, aspect);
|
||||||
|
queue_.WriteBuffer(terrain_.ubo, 0, uniforms.data(), uniforms.size() * sizeof(float));
|
||||||
|
|
||||||
|
const float margin = 12.0F;
|
||||||
|
return this->draw({0.45F, 0.55F, 0.70F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) {
|
||||||
|
pass.SetPipeline(terrain_.pipeline);
|
||||||
|
pass.SetBindGroup(0, terrain_.bind_group);
|
||||||
|
pass.Draw(3);
|
||||||
|
this->draw_image(pass, label_, {margin, margin, static_cast<float>(label_.width), static_cast<float>(label_.height)}, w, h);
|
||||||
|
this->draw_image(pass, minimap_, {static_cast<float>(w) - static_cast<float>(minimap_.width) - margin, margin,
|
||||||
|
static_cast<float>(minimap_.width), static_cast<float>(minimap_.height)},
|
||||||
|
w, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
auto shutdown() -> void override {
|
||||||
|
if (ra3::wasmgl::detail::sink_slot() == this) ra3::wasmgl::set_event_sink(nullptr);
|
||||||
|
terrain_ready_ = false;
|
||||||
|
scene_ = {};
|
||||||
|
label_ = {};
|
||||||
|
minimap_ = {};
|
||||||
|
terrain_ = {};
|
||||||
|
surface_ = nullptr;
|
||||||
|
queue_ = nullptr;
|
||||||
|
device_ = nullptr;
|
||||||
|
instance_ = nullptr;
|
||||||
|
events_.clear();
|
||||||
|
held_.fill(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto name() const -> std::string_view override { return "webgpu"; }
|
||||||
|
|
||||||
|
// ---- worker input (ra3.wasmgl::event_sink) ---------------------------
|
||||||
|
|
||||||
|
auto on_key(std::string_view code, bool down) -> void override {
|
||||||
|
if (const int index = ra3::wasmgl::detail::move_key_index(code); index >= 0) {
|
||||||
|
held_[static_cast<std::size_t>(index)] = down;
|
||||||
|
}
|
||||||
|
if (!down) return;
|
||||||
|
if (const auto key = ra3::wasmgl::detail::key_from_code(code); key != ra3::render::ui_key::none) {
|
||||||
|
push_key(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_text(int codepoint) -> void override {
|
||||||
|
if (codepoint <= 0 || codepoint > 0x7F) return;
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::text;
|
||||||
|
out.character = static_cast<char>(codepoint);
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_mouse_button(double x, double y, bool left) -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::mouse_button;
|
||||||
|
out.x = static_cast<float>(x);
|
||||||
|
out.y = static_cast<float>(y);
|
||||||
|
out.left = left;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::mouse_move;
|
||||||
|
out.x = static_cast<float>(x);
|
||||||
|
out.y = static_cast<float>(y);
|
||||||
|
out.dx = static_cast<float>(dx);
|
||||||
|
out.dy = static_cast<float>(dy);
|
||||||
|
out.left = left;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_wheel(double delta_y) -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::wheel;
|
||||||
|
out.wheel = static_cast<float>(-delta_y);
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_quit() -> void override {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::quit;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto on_resize(int width, int height) -> void override {
|
||||||
|
if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
/** A drawn image: its texture, uniform rect buffer and bind group. */
|
||||||
|
struct image_target {
|
||||||
|
wgpu::Texture texture;
|
||||||
|
wgpu::TextureView view;
|
||||||
|
wgpu::Buffer ubo;
|
||||||
|
wgpu::BindGroup bind_group;
|
||||||
|
int width = 0;
|
||||||
|
int height = 0;
|
||||||
|
int tex_width = 0;
|
||||||
|
int tex_height = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct terrain_target {
|
||||||
|
wgpu::RenderPipeline pipeline;
|
||||||
|
wgpu::Buffer ubo;
|
||||||
|
wgpu::Texture height;
|
||||||
|
wgpu::Texture cell;
|
||||||
|
wgpu::Texture atlas;
|
||||||
|
wgpu::TextureView height_view;
|
||||||
|
wgpu::TextureView cell_view;
|
||||||
|
wgpu::TextureView atlas_view;
|
||||||
|
wgpu::BindGroup bind_group;
|
||||||
|
};
|
||||||
|
|
||||||
|
static auto detail_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect)
|
||||||
|
-> std::array<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};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto push_key(ra3::render::ui_key key) -> void {
|
||||||
|
ra3::render::ui_event out;
|
||||||
|
out.type = ra3::render::ui_event_type::key;
|
||||||
|
out.key = key;
|
||||||
|
events_.push_back(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto make_shader(const char *wgsl) -> wgpu::ShaderModule {
|
||||||
|
wgpu::ShaderSourceWGSL source;
|
||||||
|
source.code = wgsl;
|
||||||
|
wgpu::ShaderModuleDescriptor descriptor;
|
||||||
|
descriptor.nextInChain = &source;
|
||||||
|
return device_.CreateShaderModule(&descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto create_pipelines() -> bool {
|
||||||
|
scene_shader_ = this->make_shader(ra3_shaders::webgpu_scene_wgsl);
|
||||||
|
terrain_shader_ = this->make_shader(ra3_shaders::webgpu_terrain_wgsl);
|
||||||
|
if (scene_shader_ == nullptr || terrain_shader_ == nullptr) {
|
||||||
|
ender::log::warn(std::format("ra3.webgpu: shader module creation failed (scene={} terrain={})",
|
||||||
|
scene_shader_ != nullptr, terrain_shader_ != nullptr));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
wgpu::SamplerDescriptor clamp_desc;
|
||||||
|
clamp_desc.addressModeU = wgpu::AddressMode::ClampToEdge;
|
||||||
|
clamp_desc.addressModeV = wgpu::AddressMode::ClampToEdge;
|
||||||
|
clamp_desc.magFilter = wgpu::FilterMode::Linear;
|
||||||
|
clamp_desc.minFilter = wgpu::FilterMode::Linear;
|
||||||
|
clamp_desc.lodMaxClamp = 32.0F;
|
||||||
|
clamp_sampler_ = device_.CreateSampler(&clamp_desc);
|
||||||
|
|
||||||
|
wgpu::SamplerDescriptor repeat_desc = clamp_desc;
|
||||||
|
repeat_desc.addressModeU = wgpu::AddressMode::Repeat;
|
||||||
|
repeat_desc.addressModeV = wgpu::AddressMode::Repeat;
|
||||||
|
repeat_sampler_ = device_.CreateSampler(&repeat_desc);
|
||||||
|
|
||||||
|
// Scene: uniform(rect) + texture + sampler.
|
||||||
|
wgpu::BindGroupLayoutEntry scene_entries[3] = {};
|
||||||
|
scene_entries[0].binding = 0;
|
||||||
|
scene_entries[0].visibility = wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment;
|
||||||
|
scene_entries[0].buffer.type = wgpu::BufferBindingType::Uniform;
|
||||||
|
scene_entries[0].buffer.minBindingSize = 16;
|
||||||
|
scene_entries[1].binding = 1;
|
||||||
|
scene_entries[1].visibility = wgpu::ShaderStage::Fragment;
|
||||||
|
scene_entries[1].texture.sampleType = wgpu::TextureSampleType::Float;
|
||||||
|
scene_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D;
|
||||||
|
scene_entries[2].binding = 2;
|
||||||
|
scene_entries[2].visibility = wgpu::ShaderStage::Fragment;
|
||||||
|
scene_entries[2].sampler.type = wgpu::SamplerBindingType::Filtering;
|
||||||
|
wgpu::BindGroupLayoutDescriptor scene_layout_desc;
|
||||||
|
scene_layout_desc.entryCount = 3;
|
||||||
|
scene_layout_desc.entries = scene_entries;
|
||||||
|
scene_bind_group_layout_ = device_.CreateBindGroupLayout(&scene_layout_desc);
|
||||||
|
|
||||||
|
wgpu::PipelineLayoutDescriptor scene_pipeline_layout_desc;
|
||||||
|
scene_pipeline_layout_desc.bindGroupLayoutCount = 1;
|
||||||
|
scene_pipeline_layout_desc.bindGroupLayouts = &scene_bind_group_layout_;
|
||||||
|
scene_pipeline_layout_ = device_.CreatePipelineLayout(&scene_pipeline_layout_desc);
|
||||||
|
scene_pipeline_ = this->create_pipeline(scene_pipeline_layout_, scene_shader_, ra3_shaders::webgpu_scene_wgsl);
|
||||||
|
if (scene_pipeline_ == nullptr) {
|
||||||
|
ender::log::warn("ra3.webgpu: scene render pipeline creation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terrain: uniform(data[5]) + R16Uint + RGBA16Uint + RGBA8 array + sampler.
|
||||||
|
wgpu::BindGroupLayoutEntry terrain_entries[5] = {};
|
||||||
|
terrain_entries[0].binding = 0;
|
||||||
|
terrain_entries[0].visibility = wgpu::ShaderStage::Fragment;
|
||||||
|
terrain_entries[0].buffer.type = wgpu::BufferBindingType::Uniform;
|
||||||
|
terrain_entries[0].buffer.minBindingSize = 80;
|
||||||
|
terrain_entries[1].binding = 1;
|
||||||
|
terrain_entries[1].visibility = wgpu::ShaderStage::Fragment;
|
||||||
|
terrain_entries[1].texture.sampleType = wgpu::TextureSampleType::Uint;
|
||||||
|
terrain_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D;
|
||||||
|
terrain_entries[2].binding = 2;
|
||||||
|
terrain_entries[2].visibility = wgpu::ShaderStage::Fragment;
|
||||||
|
terrain_entries[2].texture.sampleType = wgpu::TextureSampleType::Uint;
|
||||||
|
terrain_entries[2].texture.viewDimension = wgpu::TextureViewDimension::e2D;
|
||||||
|
terrain_entries[3].binding = 3;
|
||||||
|
terrain_entries[3].visibility = wgpu::ShaderStage::Fragment;
|
||||||
|
terrain_entries[3].texture.sampleType = wgpu::TextureSampleType::Float;
|
||||||
|
terrain_entries[3].texture.viewDimension = wgpu::TextureViewDimension::e2DArray;
|
||||||
|
terrain_entries[4].binding = 4;
|
||||||
|
terrain_entries[4].visibility = wgpu::ShaderStage::Fragment;
|
||||||
|
terrain_entries[4].sampler.type = wgpu::SamplerBindingType::Filtering;
|
||||||
|
wgpu::BindGroupLayoutDescriptor terrain_layout_desc;
|
||||||
|
terrain_layout_desc.entryCount = 5;
|
||||||
|
terrain_layout_desc.entries = terrain_entries;
|
||||||
|
terrain_bind_group_layout_ = device_.CreateBindGroupLayout(&terrain_layout_desc);
|
||||||
|
return scene_pipeline_ != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto create_pipeline(const wgpu::PipelineLayout &layout, const wgpu::ShaderModule &shader, const char *wgsl) -> wgpu::RenderPipeline {
|
||||||
|
(void) wgsl;
|
||||||
|
wgpu::ColorTargetState target;
|
||||||
|
target.format = surface_format_;
|
||||||
|
target.writeMask = wgpu::ColorWriteMask::All;
|
||||||
|
wgpu::FragmentState fragment;
|
||||||
|
fragment.module = shader;
|
||||||
|
fragment.entryPoint = "fs_main";
|
||||||
|
fragment.targetCount = 1;
|
||||||
|
fragment.targets = ⌖
|
||||||
|
wgpu::RenderPipelineDescriptor descriptor;
|
||||||
|
descriptor.layout = layout;
|
||||||
|
descriptor.vertex.module = shader;
|
||||||
|
descriptor.vertex.entryPoint = "vs_main";
|
||||||
|
descriptor.primitive.topology = wgpu::PrimitiveTopology::TriangleList;
|
||||||
|
descriptor.fragment = &fragment;
|
||||||
|
descriptor.multisample.count = 1;
|
||||||
|
return device_.CreateRenderPipeline(&descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto configure(int width, int height) -> void {
|
||||||
|
if (surface_ == nullptr || width <= 0 || height <= 0) return;
|
||||||
|
if (width == surface_w_ && height == surface_h_) return;
|
||||||
|
wgpu::SurfaceConfiguration config;
|
||||||
|
config.device = device_;
|
||||||
|
config.format = surface_format_;
|
||||||
|
config.usage = wgpu::TextureUsage::RenderAttachment;
|
||||||
|
config.width = static_cast<uint32_t>(width);
|
||||||
|
config.height = static_cast<uint32_t>(height);
|
||||||
|
config.presentMode = wgpu::PresentMode::Fifo;
|
||||||
|
surface_.Configure(&config);
|
||||||
|
surface_w_ = width;
|
||||||
|
surface_h_ = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto make_target(image_target &target) -> void {
|
||||||
|
wgpu::BufferDescriptor ubo_desc;
|
||||||
|
ubo_desc.size = 16;
|
||||||
|
ubo_desc.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst;
|
||||||
|
target.ubo = device_.CreateBuffer(&ubo_desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto upload_image(image_target &target, const ra3::render::image &frame) -> void {
|
||||||
|
if (frame.empty()) return;
|
||||||
|
target.width = static_cast<int>(frame.width());
|
||||||
|
target.height = static_cast<int>(frame.height());
|
||||||
|
if (target.texture == nullptr || target.tex_width != target.width || target.tex_height != target.height) {
|
||||||
|
wgpu::TextureDescriptor descriptor;
|
||||||
|
descriptor.size = {frame.width(), frame.height(), 1};
|
||||||
|
descriptor.format = wgpu::TextureFormat::RGBA8Unorm;
|
||||||
|
descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst;
|
||||||
|
descriptor.dimension = wgpu::TextureDimension::e2D;
|
||||||
|
descriptor.mipLevelCount = 1;
|
||||||
|
descriptor.sampleCount = 1;
|
||||||
|
target.texture = device_.CreateTexture(&descriptor);
|
||||||
|
target.view = target.texture.CreateView();
|
||||||
|
target.tex_width = target.width;
|
||||||
|
target.tex_height = target.height;
|
||||||
|
if (!target.ubo) this->make_target(target);
|
||||||
|
target.bind_group = this->make_image_bind_group(target);
|
||||||
|
}
|
||||||
|
wgpu::TexelCopyTextureInfo destination;
|
||||||
|
destination.texture = target.texture;
|
||||||
|
wgpu::TexelCopyBufferLayout layout;
|
||||||
|
layout.bytesPerRow = frame.width() * 4;
|
||||||
|
layout.rowsPerImage = frame.height();
|
||||||
|
wgpu::Extent3D size = {frame.width(), frame.height(), 1};
|
||||||
|
queue_.WriteTexture(&destination, frame.data(), static_cast<size_t>(frame.width()) * frame.height() * 4, &layout, &size);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto make_image_bind_group(image_target &target) -> wgpu::BindGroup {
|
||||||
|
wgpu::BindGroupEntry entries[3] = {};
|
||||||
|
entries[0].binding = 0;
|
||||||
|
entries[0].buffer = target.ubo;
|
||||||
|
entries[0].size = 16;
|
||||||
|
entries[1].binding = 1;
|
||||||
|
entries[1].textureView = target.view;
|
||||||
|
entries[2].binding = 2;
|
||||||
|
entries[2].sampler = clamp_sampler_;
|
||||||
|
wgpu::BindGroupDescriptor descriptor;
|
||||||
|
descriptor.layout = scene_bind_group_layout_;
|
||||||
|
descriptor.entryCount = 3;
|
||||||
|
descriptor.entries = entries;
|
||||||
|
return device_.CreateBindGroup(&descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto draw_image(wgpu::RenderPassEncoder &pass, image_target &target, const ra3::render::view_rect &dest, int window_w, int window_h) -> void {
|
||||||
|
if (target.bind_group == nullptr || window_w <= 0 || window_h <= 0) return;
|
||||||
|
const float rect[4] = {dest.x / static_cast<float>(window_w), dest.y / static_cast<float>(window_h),
|
||||||
|
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(window_h)};
|
||||||
|
queue_.WriteBuffer(target.ubo, 0, rect, sizeof(rect));
|
||||||
|
pass.SetPipeline(scene_pipeline_);
|
||||||
|
pass.SetBindGroup(0, target.bind_group);
|
||||||
|
pass.Draw(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto create_terrain(const ra3::terrain::gpu_terrain &terrain) -> bool {
|
||||||
|
{
|
||||||
|
wgpu::BufferDescriptor descriptor;
|
||||||
|
descriptor.size = 80;
|
||||||
|
descriptor.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst;
|
||||||
|
terrain_.ubo = device_.CreateBuffer(&descriptor);
|
||||||
|
}
|
||||||
|
terrain_.height = this->make_texture(wgpu::TextureFormat::R16Uint, terrain.width, terrain.height, 1);
|
||||||
|
terrain_.cell = this->make_texture(wgpu::TextureFormat::RGBA16Uint, terrain.width, terrain.height, 1);
|
||||||
|
terrain_.atlas = this->make_texture(wgpu::TextureFormat::RGBA8Unorm, terrain.layer_size, terrain.layer_size, terrain.layer_count);
|
||||||
|
terrain_.height_view = terrain_.height.CreateView();
|
||||||
|
terrain_.cell_view = terrain_.cell.CreateView();
|
||||||
|
terrain_.atlas_view = terrain_.atlas.CreateView();
|
||||||
|
|
||||||
|
this->write_texture(terrain_.height, terrain.heights.data(), terrain.width, terrain.height, 1, 2);
|
||||||
|
this->write_texture(terrain_.cell, terrain.cell_data.data(), terrain.width, terrain.height, 1, 8);
|
||||||
|
this->write_texture(terrain_.atlas, terrain.layers.data(), terrain.layer_size, terrain.layer_size, terrain.layer_count, 4);
|
||||||
|
|
||||||
|
wgpu::BindGroupEntry entries[5] = {};
|
||||||
|
entries[0].binding = 0;
|
||||||
|
entries[0].buffer = terrain_.ubo;
|
||||||
|
entries[0].size = 80;
|
||||||
|
entries[1].binding = 1;
|
||||||
|
entries[1].textureView = terrain_.height_view;
|
||||||
|
entries[2].binding = 2;
|
||||||
|
entries[2].textureView = terrain_.cell_view;
|
||||||
|
entries[3].binding = 3;
|
||||||
|
entries[3].textureView = terrain_.atlas_view;
|
||||||
|
entries[4].binding = 4;
|
||||||
|
entries[4].sampler = repeat_sampler_;
|
||||||
|
wgpu::BindGroupDescriptor bind_desc;
|
||||||
|
bind_desc.layout = terrain_bind_group_layout_;
|
||||||
|
bind_desc.entryCount = 5;
|
||||||
|
bind_desc.entries = entries;
|
||||||
|
terrain_.bind_group = device_.CreateBindGroup(&bind_desc);
|
||||||
|
|
||||||
|
terrain_.pipeline = this->create_pipeline(terrain_pipeline_layout(), terrain_shader_, ra3_shaders::webgpu_terrain_wgsl);
|
||||||
|
return terrain_.pipeline != nullptr && terrain_.bind_group != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto terrain_pipeline_layout() -> wgpu::PipelineLayout {
|
||||||
|
if (terrain_pipeline_layout_ == nullptr) {
|
||||||
|
wgpu::PipelineLayoutDescriptor descriptor;
|
||||||
|
descriptor.bindGroupLayoutCount = 1;
|
||||||
|
descriptor.bindGroupLayouts = &terrain_bind_group_layout_;
|
||||||
|
terrain_pipeline_layout_ = device_.CreatePipelineLayout(&descriptor);
|
||||||
|
}
|
||||||
|
return terrain_pipeline_layout_;
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] auto make_texture(wgpu::TextureFormat format, uint32_t width, uint32_t height, uint32_t layers) -> wgpu::Texture {
|
||||||
|
wgpu::TextureDescriptor descriptor;
|
||||||
|
descriptor.size = {width, height, layers};
|
||||||
|
descriptor.format = format;
|
||||||
|
descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst;
|
||||||
|
descriptor.dimension = wgpu::TextureDimension::e2D;
|
||||||
|
descriptor.mipLevelCount = 1;
|
||||||
|
descriptor.sampleCount = 1;
|
||||||
|
return device_.CreateTexture(&descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
auto write_texture(const wgpu::Texture &texture, const void *data, uint32_t width, uint32_t height, uint32_t layers, uint32_t bytes_per_pixel)
|
||||||
|
-> void {
|
||||||
|
wgpu::TexelCopyTextureInfo destination;
|
||||||
|
destination.texture = texture;
|
||||||
|
wgpu::TexelCopyBufferLayout layout;
|
||||||
|
layout.bytesPerRow = width * bytes_per_pixel;
|
||||||
|
layout.rowsPerImage = height;
|
||||||
|
wgpu::Extent3D size = {width, height, layers};
|
||||||
|
queue_.WriteTexture(&destination, data, static_cast<size_t>(width) * height * layers * bytes_per_pixel, &layout, &size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear, run `record`, submit and present. */
|
||||||
|
template<typename Record>
|
||||||
|
[[nodiscard]] auto draw(std::array<float, 4> clear, Record &&record) -> bool {
|
||||||
|
const auto [window_w, window_h] = this->window_size();
|
||||||
|
this->configure(window_w, window_h);
|
||||||
|
wgpu::SurfaceTexture surface_texture;
|
||||||
|
surface_.GetCurrentTexture(&surface_texture);
|
||||||
|
if (surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal &&
|
||||||
|
surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
wgpu::TextureView view = surface_texture.texture.CreateView();
|
||||||
|
wgpu::RenderPassColorAttachment color;
|
||||||
|
color.view = view;
|
||||||
|
color.depthSlice = 0xFFFFFFFFu; // kWGPU_DEPTH_SLICE_UNDEFINED
|
||||||
|
color.loadOp = wgpu::LoadOp::Clear;
|
||||||
|
color.storeOp = wgpu::StoreOp::Store;
|
||||||
|
color.clearValue = {clear[0], clear[1], clear[2], clear[3]};
|
||||||
|
wgpu::RenderPassDescriptor pass_desc;
|
||||||
|
pass_desc.colorAttachmentCount = 1;
|
||||||
|
pass_desc.colorAttachments = &color;
|
||||||
|
wgpu::CommandEncoder encoder = device_.CreateCommandEncoder();
|
||||||
|
wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&pass_desc);
|
||||||
|
record(pass, window_w, window_h);
|
||||||
|
pass.End();
|
||||||
|
wgpu::CommandBuffer commands = encoder.Finish();
|
||||||
|
queue_.Submit(1, &commands);
|
||||||
|
// WebGPU presents implicitly at the end of the frame; the port's
|
||||||
|
// wgpuSurfacePresent is intentionally unsupported.
|
||||||
|
this->mark_frame();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish a coarse frame counter to the page for the fps probe. */
|
||||||
|
auto mark_frame() const -> void {
|
||||||
|
++frames_;
|
||||||
|
if (frames_ % 15U == 0U) {
|
||||||
|
EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast<int>(frames_));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wgpu::Instance instance_;
|
||||||
|
wgpu::Device device_;
|
||||||
|
wgpu::Queue queue_;
|
||||||
|
wgpu::Surface surface_;
|
||||||
|
wgpu::TextureFormat surface_format_ = wgpu::TextureFormat::BGRA8Unorm;
|
||||||
|
int surface_w_ = 0;
|
||||||
|
int surface_h_ = 0;
|
||||||
|
|
||||||
|
wgpu::ShaderModule scene_shader_;
|
||||||
|
wgpu::ShaderModule terrain_shader_;
|
||||||
|
wgpu::BindGroupLayout scene_bind_group_layout_;
|
||||||
|
wgpu::BindGroupLayout terrain_bind_group_layout_;
|
||||||
|
wgpu::PipelineLayout scene_pipeline_layout_;
|
||||||
|
wgpu::PipelineLayout terrain_pipeline_layout_;
|
||||||
|
wgpu::RenderPipeline scene_pipeline_;
|
||||||
|
wgpu::Sampler clamp_sampler_;
|
||||||
|
wgpu::Sampler repeat_sampler_;
|
||||||
|
|
||||||
|
image_target scene_;
|
||||||
|
image_target label_;
|
||||||
|
image_target minimap_;
|
||||||
|
terrain_target terrain_;
|
||||||
|
bool terrain_ready_ = false;
|
||||||
|
|
||||||
|
std::deque<ra3::render::ui_event> events_;
|
||||||
|
std::array<bool, static_cast<std::size_t>(ra3::wasmgl::detail::move_key::count)> held_{};
|
||||||
|
mutable unsigned frames_ = 0U;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
export module ra3.webgl;
|
export module ra3.webgpu;
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
export import ra3.core;
|
export import ra3.core;
|
||||||
import ra3.render;
|
export import ra3.render;
|
||||||
import ra3.client;
|
export import ra3.client;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback WebGL backend for builds without Emscripten. `init` fails so the
|
* Fallback WebGPU backend for builds without Emscripten. `init` fails so the
|
||||||
* caller can select another display; the class name matches the real
|
* caller can select another display; the class name matches the real
|
||||||
* `ra3.webgl` module so the backend factory in `ra3.display` compiles unchanged.
|
* `ra3.webgpu` module so the backend factory in `ra3.display` compiles
|
||||||
|
* unchanged.
|
||||||
*/
|
*/
|
||||||
export namespace ra3::webgl {
|
export namespace ra3::webgpu {
|
||||||
class webgl_display final : public ra3::client::display {
|
class webgpu_display final : public ra3::client::display {
|
||||||
public:
|
public:
|
||||||
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
|
[[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 present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
|
||||||
@@ -20,6 +21,6 @@ export namespace ra3::webgl {
|
|||||||
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
|
[[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; }
|
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
|
||||||
auto shutdown() -> void override {}
|
auto shutdown() -> void override {}
|
||||||
[[nodiscard]] auto name() const -> std::string_view override { return "webgl(null)"; }
|
[[nodiscard]] auto name() const -> std::string_view override { return "webgpu(null)"; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user