4 Commits
Author SHA1 Message Date
EnderTheCoder 8047e80ed6 v0.8.0: map roads/sidewalks, per-vertex depth bias, retail camera controls
- ra3.map: capture the object RoadType; ra3.models pairs the map's
  Start/End road objects and drapes a flat textured ribbon along each
  segment (tessellated to the relief).
- Ground decals (roads/sidewalks) get a per-vertex screen-space depth
  offset (scene::vertex.bias) applied in the object shader and the
  software rasteriser, so they no longer z-fight or clip into the
  terrain at top-down angles.
- Tactical view aligned with retail (SAGE LookAtTranslator): middle-drag
  orbits yaw/pitch, a middle click resets angle/pitch/zoom, arrow keys
  pan, wheel zooms within the retail zoom range; WASD pan removed and
  left-drag no longer rotates.
- ui_event gains middle/right/released buttons; Vulkan + SDL map them.
- docs: roads, depth bias and camera controls.
2026-09-29 20:37:24 +08:00
EnderTheCoder 70f35d8382 render map objects: decode compiled W3DMesh art, skin and draw over terrain
Read the buildings and props a map places from its compiled art (the
uncompressed worldbuilder stream + embedded DDS textures):

- ra3.models: BAB asset-stream parser (lazy slices), W3DMesh + D3DHierarchy
  decode, DDS (DXT1/3/5 + uncompressed) decode, per-mesh bone remap and
  bind-pose single-joint skinning, flattened to one world-space triangle soup.
- ra3.map: parse the ObjectsList chunk into (type, x, y, z, angle).
- ra3.terrain: render3d rasterises the scene over the raymarched terrain with
  a z-buffer; gpu_terrain carries the scene for the GPU backends.
- ra3.vulkan: second pipeline + depth attachment, terrain.frag writes
  gl_FragDepth, and a small depth bias keeps ground decals from z-fighting.
- objects.fx shaders (compiled to SPIR-V), embedded like scene/terrain.

Only opaque parts are drawn: FX-light billboards (DefaultW3D.fx / BasicW3D.fx)
and damage-fill shells (BuildingsGenericDamageFill.fx) are skipped, since the
latter paint the wrecked interior (e.g. orange CBBuilding_Wood) over the shell.

Ground-decal meshes with no diffuse role and the Road templates themselves are
still not drawn.
2026-09-29 18:08:11 +08:00
EnderTheCoder 6e5df350c7 fix ci: use the Debian apt mirror for the Debian images
debian_image/debian_arm64_image got the Ubuntu TUNA mirror, so apt-get update 404'd on deb.debian.org paths and the images never built (red since v0.5.0). Add DEBIAN_APT_MIRROR and pass it to both debian builds.
2026-09-29 12:16:31 +08:00
EnderTheCoder 889ce945f2 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.
2026-09-29 12:08:07 +08:00
41 changed files with 4403 additions and 700 deletions
+6 -3
View File
@@ -8,6 +8,9 @@ variables:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
# apt mirror used inside both images (override per pipeline if needed) # apt mirror used inside both images (override per pipeline if needed)
APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/ubuntu" APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/ubuntu"
# Debian images need the Debian (not Ubuntu) mirror path: the two repos live
# under /debian and /debian-security on the same host.
DEBIAN_APT_MIRROR: "http://mirrors.tuna.tsinghua.edu.cn/debian"
# Images are shared through Harbor (plain HTTP), never as artifacts. # Images are shared through Harbor (plain HTTP), never as artifacts.
HARBOR_HOST: "192.168.1.11:9090" HARBOR_HOST: "192.168.1.11:9090"
HARBOR_PROJECT: "openra3" HARBOR_PROJECT: "openra3"
@@ -178,7 +181,7 @@ debian_image:
retry docker build \ retry docker build \
--cache-from type=registry,ref=$IMAGE:cache-debian,insecure=true \ --cache-from type=registry,ref=$IMAGE:cache-debian,insecure=true \
--cache-to type=registry,ref=$IMAGE:cache-debian,mode=max,insecure=true \ --cache-to type=registry,ref=$IMAGE:cache-debian,mode=max,insecure=true \
--build-arg APT_MIRROR=$APT_MIRROR \ --build-arg APT_MIRROR=$DEBIAN_APT_MIRROR \
--target dev \ --target dev \
-t $IMAGE:$CI_COMMIT_SHORT_SHA-debian \ -t $IMAGE:$CI_COMMIT_SHORT_SHA-debian \
-f Dockerfile.debian . -f Dockerfile.debian .
@@ -197,7 +200,7 @@ debian_arm64_image:
retry docker build \ retry docker build \
--cache-from type=registry,ref=$IMAGE:cache-debian-arm64,insecure=true \ --cache-from type=registry,ref=$IMAGE:cache-debian-arm64,insecure=true \
--cache-to type=registry,ref=$IMAGE:cache-debian-arm64,mode=max,insecure=true \ --cache-to type=registry,ref=$IMAGE:cache-debian-arm64,mode=max,insecure=true \
--build-arg APT_MIRROR=$APT_MIRROR \ --build-arg APT_MIRROR=$DEBIAN_APT_MIRROR \
--target dev \ --target dev \
-t $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 \ -t $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 \
-f Dockerfile.debian-arm64 . -f Dockerfile.debian-arm64 .
@@ -326,7 +329,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:
+128 -44
View File
@@ -26,7 +26,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_SCAN_FOR_MODULES ON) set(CMAKE_CXX_SCAN_FOR_MODULES ON)
project(OpenRA3 VERSION 0.6.0 LANGUAGES C CXX) project(OpenRA3 VERSION 0.8.0 LANGUAGES C CXX)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
@@ -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
@@ -238,6 +255,15 @@ target_sources(ra3_terrain PUBLIC FILE_SET CXX_MODULES FILES src/terrain/ra3.ter
target_link_libraries(ra3_terrain PUBLIC ra3_core ra3_fs ra3_render) target_link_libraries(ra3_terrain PUBLIC ra3_core ra3_fs ra3_render)
openra3_target_defaults(ra3_terrain) openra3_target_defaults(ra3_terrain)
# --- map static art (compiled W3D meshes) ------------------------------------
add_library(ra3_models STATIC)
target_sources(ra3_models PUBLIC FILE_SET CXX_MODULES FILES src/models/ra3.models.cppm)
target_link_libraries(ra3_models PUBLIC ra3_core ra3_fs ra3_render)
openra3_target_defaults(ra3_models)
# A map's terrain carries the objects placed on it (props/buildings).
target_link_libraries(ra3_terrain PUBLIC ra3_models)
# The presentation facade imports render + terrain (for the terrain capability). # The presentation facade imports render + terrain (for the terrain capability).
target_link_libraries(ra3_client PUBLIC ra3_render ra3_terrain) target_link_libraries(ra3_client PUBLIC ra3_render ra3_terrain)
@@ -261,13 +287,17 @@ if(OPENRA3_HAS_VULKAN AND OPENRA3_HAS_SDL3)
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.frag.spv"
) )
# The blobs are read at configure time, so re-run CMake when they change. # The blobs are read at configure time, so re-run CMake when they change.
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.vert.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv" "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv") "${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/terrain.frag.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.vert.spv"
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/object.frag.spv")
target_sources(ra3_vulkan PUBLIC FILE_SET CXX_MODULES FILES src/vulkan/ra3.vulkan.sdl.cppm) target_sources(ra3_vulkan PUBLIC FILE_SET CXX_MODULES FILES src/vulkan/ra3.vulkan.sdl.cppm)
target_include_directories(ra3_vulkan PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") target_include_directories(ra3_vulkan PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(ra3_vulkan PUBLIC ra3_core ra3_render ra3_terrain ra3_client openra3_sdl3 ra3_volk) target_link_libraries(ra3_vulkan PUBLIC ra3_core ra3_render ra3_terrain ra3_client openra3_sdl3 ra3_volk)
@@ -309,23 +339,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 +363,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_models 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 +440,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
View File
@@ -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 . .
+52 -27
View File
@@ -18,19 +18,23 @@ Ghidra.
## Status ## Status
OpenRA3 is at **v0.6.0**. The engine compiles and runs headless, and a **minimal OpenRA3 is at **v0.8.0**. The engine compiles and runs headless, and a **minimal
skirmish** is playable: it reads a real multiplayer map out of your install, skirmish** is playable: it reads a real multiplayer map out of your install,
recovers the player start waypoints, and simulates two sides building a base, recovers the player start waypoints, and simulates two sides building a base,
extracting ore and fighting until one side is wiped out. The balance is the 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
gutter-padded atlas), so material boundaries are smooth instead of a grid of gutter-padded atlas), so material boundaries are smooth instead of a grid of
hard lines. An in-window **menu** lists the maps by their localized name and hard lines; the **buildings and props a map places** are decoded from the retail
compiled `W3DMesh` art (the uncompressed `worldbuilder.bin` stream and its
embedded DDS textures) and drawn with a depth test over the terrain. An
in-window **menu** lists the maps by their localized name and
exposes every render/skirmish option for tweaking before launch. The whole tree exposes every render/skirmish option for tweaking before launch. The whole tree
builds for **Linux** (clang + libc++) and cross-compiles to **Windows** builds for **Linux** (clang + libc++) and cross-compiles to **Windows**
(`openra3.exe` + `SDL3.dll`) with llvm-mingw — both using C++26 modules and (`openra3.exe` + `SDL3.dll`) with llvm-mingw — both using C++26 modules and
@@ -92,11 +96,13 @@ built-in test map so the project still builds and runs in CI.
| `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints, `gamestrings.csf` display names | | `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints, `gamestrings.csf` display names |
| `src/skirmish/ra3.skirmish.cppm` | base building, economy, AI, combat, win condition | | `src/skirmish/ra3.skirmish.cppm` | base building, economy, AI, combat, win condition |
| `src/terrain/ra3.terrain.cppm` | `CkMp` terrain: `HeightMapData`, `BlendTileData` (tiles + blends), `Terrain.big` tiles | | `src/terrain/ra3.terrain.cppm` | `CkMp` terrain: `HeightMapData`, `BlendTileData` (tiles + blends), `Terrain.big` tiles |
| `src/models/ra3.models.cppm` | compiled `W3DMesh` art (BAB stream + DDS textures) for the objects a map places |
| `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text | | `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing, bitmap-font text |
| `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 +147,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 +189,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.
@@ -283,9 +294,23 @@ textures from `Data\Terrain.big` (RefPack + TGA), and rasterises the map with an
elevation shade. Each cell cross-fades into its blend neighbour with the same elevation shade. Each cell cross-fades into its blend neighbour with the same
linear ramp the retail `Terrain.fx` uses, so material transitions are smooth; on linear ramp the retail `Terrain.fx` uses, so material transitions are smooth; on
the GPU path the tile atlas is padded with a replicated gutter so filtering the GPU path the tile atlas is padded with a replicated gutter so filtering
never bleeds between tiles. Match state is overlaid (start markers in yellow, never bleeds between tiles. It also draws the **buildings and props the map
player 0 in blue, player 1 in red). `--thumbnail` uses the old `<map>_art.tga` places**: the `ObjectsList` chunk is decoded into `(type, position, angle)`
overview instead. instances, the type resolves to its compiled `W3DMesh` parts in the map's art
stream (`Data\WBData.big`'s uncompressed `worldbuilder.bin`), the embedded DDS
textures are decoded, bind-pose skinning places each mesh's bone-space vertices
(`W3DHierarchy`), and the scene is flattened into one world-space triangle soup.
The terrain raymarcher writes a depth value so the model pass depth-tests
against the relief; the GPU path draws it in a second pipeline (shared camera
basis, with a small depth bias so ground decals do not z-fight) and the software
path rasterises it with a z-buffer. Match state is
overlaid (start markers in yellow, player 0 in blue, player 1 in red).
`--thumbnail` uses the old `<map>_art.tga` overview instead.
> **Roads / sidewalks** are drawn too: the map stores each as a
> `RoadType::Start`/`End` pair of objects, and `render` emits a flat textured
> ribbon along each segment, lifted onto the terrain with a small depth bias so
> it does not clip into the ground.
**Offscreen image** (works anywhere, no display needed): **Offscreen image** (works anywhere, no display needed):
+1 -1
View File
@@ -1 +1 @@
0.6.0 0.8.0
+103 -13
View File
@@ -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> {
@@ -197,6 +200,79 @@ namespace {
return std::nullopt; return std::nullopt;
} }
/** A map's compiled art stream (`map.manifest` + `map.bin`). */
struct model_stream_paths {
std::filesystem::path manifest;
std::filesystem::path bin;
};
/**
* Locate the compiled art stream that holds a map's buildings/props.
*
* Retail bakes the shared static art (the props a map places) into
* `Data\WBData.big`'s uncompressed `worldbuilder.bin`, so that stream is
* preferred; the per-map `map.bin` (which links to it) is the fallback.
*/
auto find_map_stream(const std::filesystem::path &root, std::string_view id) -> std::optional<model_stream_paths> {
std::error_code ec;
const auto consider = [&](const std::filesystem::path &dir) -> std::optional<model_stream_paths> {
const auto manifest = dir / "map.manifest";
const auto bin = dir / "map.bin";
if (std::filesystem::exists(manifest, ec) && std::filesystem::exists(bin, ec)) return model_stream_paths{manifest, bin};
return std::nullopt;
};
// A `worldbuilder` stream (the full prop art) takes precedence.
for (const auto &dir: {root / "models", root / "raw" / "WBData" / "data", root}) {
const auto manifest = dir / "worldbuilder.manifest";
const auto bin = dir / "worldbuilder.bin";
if (std::filesystem::exists(manifest, ec) && std::filesystem::exists(bin, ec)) return model_stream_paths{manifest, bin};
}
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (entry.is_regular_file() && entry.path().filename() == "worldbuilder.bin") {
const auto manifest = entry.path().parent_path() / "worldbuilder.manifest";
if (std::filesystem::exists(manifest, ec)) return model_stream_paths{manifest, entry.path()};
}
}
if (auto found = consider(root / "models" / id); found) return found;
if (auto found = consider(root / "maps" / id); found) return found;
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (!entry.is_directory() || entry.path().filename().string() != id) continue;
if (auto found = consider(entry.path()); found) return found;
}
return std::nullopt;
}
/** Build the map's building/prop scene from its compiled art stream. */
auto build_object_scene(const std::filesystem::path &assets, std::string_view id, const ra3::terrain::map_data &terrain,
const ra3::terrain::render_options &options, std::span<const ra3::core::uint8> ckmp) -> ra3::models::scene {
ra3::models::scene scene;
try {
const auto paths = find_map_stream(assets, id);
if (!paths) {
std::puts("objects: no compiled art stream found");
return scene;
}
const auto stream = ra3::models::asset_stream::load_files(paths->manifest, paths->bin);
std::vector<ra3::models::placement> placements;
for (const auto &object: ra3::map::parse_objects(ckmp)) {
placements.push_back({object.type, object.x, object.y, object.z, object.angle, object.scale, object.road_type});
}
const auto world_w = terrain.world_width();
const auto world_h = terrain.world_height();
scene = ra3::models::build_scene(stream, placements, [&](float x, float y) -> float {
if (x < 0.0F || y < 0.0F || x >= world_w || y >= world_h) return 0.0F;
const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size));
const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size));
return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale;
});
std::printf("objects: %zu placed, %zu missing, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing,
scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str());
} catch (const std::exception &error) {
std::printf("objects: failed to build scene (%s)\n", error.what());
}
return scene;
}
/** Write `<assets>/maps/map_names.tsv` from the install's localized string table. */ /** Write `<assets>/maps/map_names.tsv` from the install's localized string table. */
auto write_map_names(const std::filesystem::path &data_dir, const std::filesystem::path &assets) -> bool { auto write_map_names(const std::filesystem::path &data_dir, const std::filesystem::path &assets) -> bool {
try { try {
@@ -462,8 +538,9 @@ namespace {
const bool offscreen = option_value(args, "--out").has_value() || has_flag(args, "--no-window"); const bool offscreen = option_value(args, "--out").has_value() || has_flag(args, "--no-window");
bool gpu_shown = false; bool gpu_shown = false;
const auto objects = build_object_scene(assets, picked->id, terrain, terrain_options, bytes);
if (!offscreen) { if (!offscreen) {
const auto gpu = terrain::build_gpu_terrain(terrain, textures, terrain_options); const auto gpu = terrain::build_gpu_terrain(terrain, textures, terrain_options, objects);
ra3::client::display_options options; ra3::client::display_options options;
options.title = scene.title; options.title = scene.title;
options.width = static_cast<int>(width); options.width = static_cast<int>(width);
@@ -483,7 +560,7 @@ namespace {
} }
if (offscreen) { if (offscreen) {
// Offscreen: the software raymarcher produces the 3D image. // Offscreen: the software raymarcher produces the 3D image.
composed = terrain::render3d(terrain, textures, camera, width, height, terrain_options); composed = terrain::render3d(terrain, textures, camera, width, height, terrain_options, &objects);
perspective = true; perspective = true;
} else { } else {
// Windowed without a GPU: the cheap top-down raster. // Windowed without a GPU: the cheap top-down raster.
@@ -610,10 +687,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 +777,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 +800,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 +1200,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 +1339,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);
+101
View File
@@ -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>
+114
View File
@@ -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
View File
@@ -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:
-33
View File
@@ -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 -3
View File
@@ -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
+50 -19
View File
@@ -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
@@ -79,8 +79,8 @@ 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.models`, `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.
@@ -206,10 +206,11 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[ ]` DDS / DXT compressed textures `(v0.5)` - `[ ]` DDS / DXT compressed textures `(v0.5)`
- `[ ]` atlas + mip generation, gutter padding (GPU) `[~]` - `[ ]` atlas + mip generation, gutter padding (GPU) `[~]`
- `[ ]` async upload / streaming `(v0.6)` - `[ ]` async upload / streaming `(v0.6)`
- **F2 Models** `[ ]` `!!` - **F2 Models** `[~]`
- `[ ]` W3D container parse (chunks, hierarchy, meshes) `(v0.6)` - `[x]` compiled `W3DMesh` decode (vertex buffer + D3D9 declaration + triangle list) from the BAB static/worldbuilder stream
- `[ ]` materials, shaders, texture references - `[x]` embedded DDS textures (DXT1/3/5 + uncompressed RGB) → ARGB
- `[ ]` LOD sets, collision meshes - `[x]` `W3DHierarchy` decode + static bind-pose skinning (bone-space vertices)
- `[ ]` W3D container/hierarchy animation, LOD sets, collision meshes `!!` `(v0.6)`
- **F3 Animation** `[ ]` `!!` - **F3 Animation** `[ ]` `!!`
- `[ ]` W3D animation chunks, bone poses `(v0.6)` - `[ ]` W3D animation chunks, bone poses `(v0.6)`
- `[ ]` blend trees / transition animations - `[ ]` blend trees / transition animations
@@ -533,8 +534,10 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- **F3 Terrain render** `[~]` - **F3 Terrain render** `[~]`
- `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas - `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas
- `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)` - `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)`
- **F4 Model render** `[ ]` `!!` - **F4 Model render** `[~]`
- `[ ]` W3D draw, skinning, materials, team colors `(v0.6)` - `[x]` static map-object triangle soup, world-space, depth-tested over the terrain (Vulkan + software)
- `[x]` bind-pose skinning (bone-space vertices) + ground-decal depth bias
- `[ ]` animated W3D draw, materials, team colors `!!` `(v0.6)`
- `[ ]` shadows, decals, ground marks `(v0.6)` - `[ ]` shadows, decals, ground marks `(v0.6)`
- **F5 VFX** `[ ]` - **F5 VFX** `[ ]`
- `[ ]` particle systems, beams, muzzle flashes, explosions `(v0.6)` - `[ ]` particle systems, beams, muzzle flashes, explosions `(v0.6)`
@@ -575,8 +578,9 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[ ]` formal RHI integration (see M16 F1) `(v0.6)` - `[ ]` formal RHI integration (see M16 F1) `(v0.6)`
- **F2 Terrain presentation** `[D]` - **F2 Terrain presentation** `[D]`
- `[x]` GPU heightfield raymarch, mipmapped atlas, gutter, retail blend ramp - `[x]` GPU heightfield raymarch, mipmapped atlas, gutter, retail blend ramp
- **F3 Materials & pipelines** `[ ]` - **F3 Materials & pipelines** `[~]`
- `[ ]` model/particle/HUD pipelines `(v0.6)` - `[x]` static-model pipeline (vertex/index buffers, texture array, depth test)
- `[ ]` particle/HUD pipelines `(v0.6)`
- **F4 Null fallback** `[D]` - **F4 Null fallback** `[D]`
- `[x]` report failure when no Vulkan loader is present - `[x]` report failure when no Vulkan loader is present
@@ -595,17 +599,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`
+144
View File
@@ -212,6 +212,146 @@ per-cell restart is the remaining source of grid lines. Switching the atlas
from 64 px blocks to continuous 32 px regions (or the Morton 8x8 layout) is the from 64 px blocks to continuous 32 px regions (or the Morton 8x8 layout) is the
next step, pending an art correlation check. next step, pending an art correlation check.
### Compiled art (BinaryAssetBuilder) and map objects
Retail RA3 ships no `.w3x`/`.w3d` files: BinaryAssetBuilder bakes every model,
texture and script into a *binary asset stream* — a `.manifest` index plus a
`.bin` of relocatable instance data (and optional `.relo`/`.imp` fixups). The
layout (little-endian) is:
```
ManifestHeader (48 B) isBigEndian u8, isLinked u8, version u16,
streamChecksum, allTypesHash, assetCount u32,
totalInstanceDataSize, maxInstance/maxRelocation/
maxImportsChunkSize, assetReferenceBufferSize,
referenceManifestNameBufferSize, assetNameBufferSize,
sourceFileNameBufferSize
AssetEntry (48 B) * count
typeId, instanceId, typeHash, instanceHash,
assetReferenceOffset i32, assetReferenceCount i32,
nameOffset i32, sourceFileNameOffset i32,
instanceDataSize i32, relocationDataSize i32,
importsDataSize i32, tokenized u32
then the reference / referenced-name / asset-name / source-name buffers
```
Asset names are `Type:Instance` (e.g. `W3DMesh:BB_GRASS02`). Instance pointers
are stored as offsets from the start of the instance data (which begins at byte
4 of `.bin`, after the stream checksum), so a slice is readable without the
relocation stream.
Each multiplayer map carries its own stream (`data\maps\official\<id>\map.bin`)
but it is **linked**: only map-specific assets (the terrain texture atlas,
scripts, `GameMap`) have data; the rendered props are imported and therefore
have `instanceDataSize == 0`. The complete prop art (meshes + textures) lives in
`Data\WBData.big`'s `data\worldbuilder.bin`, which is **uncompressed** (first
four bytes are the stream checksum, not `10 FB`), so `ra3.models` reads the
1.1 GB stream lazily — the manifest is parsed and only the needed instance
slices are read.
`W3DMesh` compiled layout (offsets from the instance start):
```
+4 vertexBufferPtr +52 triangleCount +56 triangleItemPtr
+60 shaderNameLength +64 shaderNamePtr
vertexBuffer: +0 numVertices, +4 stride, +8 elementDataPtr,
+12 declarationBytes, +16 declarationPtr
declaration: text "p0:00:3f32 n0:0C:3f32 t0:1C:2f32" (D3D9 usage:index:offset:type)
triangles: triangleCount * { u32 indexCount, u32 indexPtr } (24 B each), u32 indices
```
The diffuse texture is found through the mesh's `FXShaderConstant`s
(`+76` count, `+80` items): a texture-valued constant (TypeId `0xA59096A6`)
names its role (`DiffuseTexture`, `NormalMap`, `SpecMap`) and points at a
1-based index into the mesh's cross-asset references, which resolve by
`(typeId, instanceId)`. The `Texture` instance embeds a standard DDS file (at
`u32@+4`, or scan for `"DDS "`); `ra3.models::decode_dds` decodes DXT1/3/5 and
uncompressed 16/24/32-bit.
**Vertices are stored in bone space, not object space.** A mesh whose vertex
declaration carries blend data (`i0:..:4u8 w0:..:4u8n`, e.g. buildings and
vehicles) must be skinned; props without it (`BB_GRASS02`, `IF_STREETSEGMENT01`)
are already in object space. The skeleton is a `W3DHierarchy` asset named after
the mesh's instance prefix (`W3DMesh:FI_STRUCTURE_02.NEWSKIN_CIV01` →
`W3DHierarchy:FI_STRUCTURE_02`). Compiled layout:
```
W3DHierarchy: u32 pad, u32 boneCount, u32 headerBytes, then boneCount records
100 B each:
u32 nameHash, i32 parent (-1 = root), f32 translation[3],
f32 quaternion[4] (x, y, z, w), f32 matrix[12]
```
The default (bind) pose is rebuilt by composing each bone's local
translation/quaternion down the parent chain, then `skinnedPos = Σ wᵢ ·
(Rᵢ·p + Tᵢ)` (and the normal by the rotation only). `ra3.models` does this before
placing the mesh at the map object's `(x, y, angle)`.
The compiled shader (below) binds **one joint per vertex** — `WorldBones` holds
64 bones as 2 `float4` each (quaternion `c[128+2j]`, translation `c[129+2j]`) —
so the skin is rigid: `blendindices.x` selects the joint, remapped through the
mesh's per-model **bone table** (vertex-descriptor `+0x14` = bone count,
`+0x18` = `u16` bone indices into the `W3DHierarchy`). Applying the raw blend
index without that remap tears models apart (`FI_BUILDING01`'s table is
`[0,14,15,16,17,18]`, not `[0..5]`).
The map objects that lie flat on the ground (sidewalks, deck pieces) are
coplanar with the terrain; retail biases their depth in the shader so they do
not z-fight. The object pass reproduces that with a small negative depth bias
(Vulkan `depthBiasConstant/SlopeFactor`, and a half-unit bias in the software
rasteriser).
Meshes whose material has no diffuse texture (`DefaultW3D.fx`, `BasicW3D.fx` —
`FXLIGHTS`/ambient helper billboards) are not opaque geometry and are skipped;
drawing them fills the frame with garbage triangles. Likewise the
`BuildingsGenericDamageFill.fx` **damage-fill** sub-meshes are skipped: they are
the wrecked-interior shell (e.g. `CBBuilding_Wood`, an orange plank texture) that
retail only reveals through damage holes, but our opaque pass would paint it over
the main shell and tint whole buildings warm.
### Official shader behaviour (`Shaders.big` → `*.fxo`)
The compiled D3D9 effects in `Data\Shaders.big` name their parameters, so the
model pipeline is recoverable. `buildingsgeneric.fxo` (`BuildingsGeneric.fx`)
vertex stage: skinning (above), `World`/`ViewProjection`, and vertex color
`c0` multiplied into the lit color
(`(Ambient·AmbientColor + Σ DLᵢ.Color·max(N·DLᵢ,0)) · DiffuseColor · vertexColor`).
Pixel stage samples `DiffuseTexture`/`NormalMap`/`SpecMap`/`DamagedTexture`/
`CloudTexture` (all at **UV0**, except `DamagedTexture` at `v0.wz` = transposed
UV1), then `final.rgb *= TintColor` and `*= ShroudTexture.rgb`. `DiffuseVelocity`
is not used for static structures. So the diffuse texture is UV0 and is tinted by
vertex color and `TintColor`; `basicw3d.fxo` instead modulates a single
macro/lightmap with `(vertexColor + additive) * diffuse * 2` and has no normal
map. (Recovered by disassembling the embedded `vs_3_0`/`ps_3_0` bytecode.)
The map's `ObjectsList` chunk is a list of nested `Object` assets —
`Coord3D`, Z `angle`, `RoadType` u32, a `u16`-prefixed type-name and a property
list whose keys index the shared name table (`ra3.map::parse_objects`). Each
type resolves to the `W3DMesh` parts whose instance name equals it or starts
with `<type>.`.
### Roads / sidewalks (`Road` assets)
The flat sidewalk and road decals are not meshes: the map places them as
**consecutive pairs** of objects — a `RoadType::Start` (`2`) and a
`RoadType::End` (`4`) at the segment's two ends (`Angled` `8`, `TightCurve`
`64` and `EndCap` `128` are curve/cap flags; `BridgeStart/End` `16/32` are
bridges). The road's type-name (`IslandFortressSidewalk01`, ...) resolves to a
compiled `Road` asset in the same art stream:
```
Road: u32 version, u32 pad, u32 textureCount, f32 roadWidth, f32 pad,
f32 ???, then textureCount texture references (diffuse, normal)
```
`ra3.models` pairs the objects in file order, emits a flat quad ribbon of
`roadWidth` along each segment (overlapping the joins by half a width), sampled
with the diffuse texture, and lifts it onto the terrain. Retail gives roads a
small depth offset toward the screen so they do not clip into the ground; the
object pass reproduces that with the same negative depth bias used for the other
ground decals (`depthBiasConstant/SlopeFactor` on Vulkan, a half-unit bias in the
software rasteriser).
### Map display names ### Map display names
The skirmish map list labels live in `Data\English.big`'s The skirmish map list labels live in `Data\English.big`'s
@@ -227,5 +367,9 @@ values are UTF-16 code units whose low byte is XORed with `0xFF`
- `MPPositionList` layout (per-player starts for maps without waypoints). - `MPPositionList` layout (per-player starts for maps without waypoints).
- Cliff textures and the `CliffTextureMapping` UV remap (`CliffTextures` is - Cliff textures and the `CliffTextureMapping` UV remap (`CliffTextures` is
parsed but not yet drawn). parsed but not yet drawn).
- The `Road` network mesher (the map's sidewalk/road objects reference `Road`
templates, not `W3DMesh` assets).
- W3D container/hierarchy assembly and animation (props are drawn as their
static mesh parts; skinned/animated in-game models are not).
- Compiled asset blobs (`global.bin`, `static.*.bin`) and the `.manifest` - Compiled asset blobs (`global.bin`, `static.*.bin`) and the `.manifest`
schema used to deserialise them. schema used to deserialise them.
+17 -10
View File
@@ -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[*]:-}"
+46
View File
@@ -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())
+2 -2
View File
@@ -25,9 +25,9 @@ compile() {
fi fi
} }
for name in scene terrain; do for name in scene terrain object; do
compile "$DIR/$name.vert" "$OUT/$name.vert.spv" compile "$DIR/$name.vert" "$OUT/$name.vert.spv"
compile "$DIR/$name.frag" "$OUT/$name.frag.spv" compile "$DIR/$name.frag" "$OUT/$name.frag.spv"
done done
echo "wrote $OUT/{scene,terrain}.{vert,frag}.spv" echo "wrote $OUT/{scene,terrain,object}.{vert,frag}.spv"
+6 -4
View File
@@ -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.
Binary file not shown.
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
#version 450
// Static-map model fragment stage: sample the shared texture array and apply
// the same directional sun the terrain uses. Cut-out props (trees, fences)
// carry an alpha mask in their diffuse texture; discard the transparent texels
// so the ground shows through.
layout(binding = 0) uniform sampler2DArray atlas;
layout(push_constant) uniform Push {
vec4 cam_pos;
vec4 fwd;
vec4 right;
vec4 up;
vec4 sun;
} pc;
layout(location = 0) in vec2 in_uv;
layout(location = 1) in vec3 in_normal;
layout(location = 2) flat in float in_layer;
layout(location = 0) out vec4 out_color;
void main() {
int layers = textureSize(atlas, 0).z;
int layer = clamp(int(in_layer + 0.5), 0, layers - 1);
vec4 tex = texture(atlas, vec3(in_uv, float(layer)));
if (tex.a < 0.5) discard;
vec3 n = normalize(in_normal);
if (!gl_FrontFacing) n = -n;
float lambert = max(0.0, dot(n, normalize(pc.sun.xyz)));
float ambient = pc.sun.w;
vec3 lit = tex.rgb * (ambient + (1.0 - ambient) * lambert);
out_color = vec4(lit, 1.0);
}
+45
View File
@@ -0,0 +1,45 @@
#version 450
// Static-map model vertex stage. Draws the world-space triangle soup built by
// ra3::models (buildings and props the map places) against the exact camera the
// terrain raymarcher uses, so both passes share one projection and depth test.
//
// The camera is passed as its orthonormal basis so the same math as
// terrain.frag applies: ndc.x = dot(r, right) / (a * tan(fov/2) * aspect),
// ndc.y = -dot(r, up) / (a * tan(fov/2)), depth = (a - NEAR) / (FAR - NEAR)
// with `a = dot(r, fwd)` the view-space depth.
layout(location = 0) in vec3 in_pos;
layout(location = 1) in vec3 in_normal;
layout(location = 2) in vec2 in_uv;
layout(location = 3) in float in_layer;
layout(location = 4) in float in_bias;
layout(push_constant) uniform Push {
vec4 cam_pos; // xyz = eye position
vec4 fwd; // xyz = forward
vec4 right; // xyz = right, w = tan(fov / 2)
vec4 up; // xyz = up, w = tan(fov / 2) * aspect
vec4 sun; // xyz = sun direction, w = ambient
} pc;
layout(location = 0) out vec2 out_uv;
layout(location = 1) out vec3 out_normal;
layout(location = 2) flat out float out_layer;
const float NEAR = 10.0;
const float FAR = 60000.0;
void main() {
vec3 r = in_pos - pc.cam_pos.xyz;
float a = dot(r, pc.fwd.xyz);
float b = dot(r, pc.right.xyz);
float c = dot(r, pc.up.xyz);
float th = pc.right.w;
float th_aspect = pc.up.w;
float depth = clamp((a - in_bias - NEAR) / (FAR - NEAR), 0.0, 1.0);
gl_Position = vec4(b / th_aspect, -c / th, depth * a, a);
out_uv = in_uv;
out_normal = in_normal;
out_layer = in_layer;
}
+17 -5
View File
@@ -25,6 +25,11 @@ layout(location = 0) out vec4 out_color;
const float CELL = 10.0; // must match ra3::terrain::cell_size const float CELL = 10.0; // must match ra3::terrain::cell_size
// Must match object.vert: the shared projection writes the view-space depth
// into gl_FragDepth so the static-map models depth-test against the terrain.
const float NEAR = 10.0;
const float FAR = 60000.0;
float height_at(ivec2 c) { float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1); c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w; return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w;
@@ -112,26 +117,29 @@ void main() {
vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
gl_FragDepth = 1.0;
out_color = vec4(sky_color(dir), 1.0); out_color = vec4(sky_color(dir), 1.0);
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) { gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; }
float lo = prev, hi = hit_t; float lo = prev, hi = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 6; ++i) {
@@ -142,6 +150,10 @@ void main() {
} }
vec3 hitpos = cam + dir * hi; vec3 hitpos = cam + dir * hi;
// View-space depth of the hit (project onto the forward axis), matching the
// projection object.vert applies to the model vertices.
gl_FragDepth = clamp((dot(hitpos - cam, fwd) - NEAR) / (FAR - NEAR), 0.0, 1.0);
vec3 sun = normalize(pc.sun.xyz); vec3 sun = normalize(pc.sun.xyz);
float ambient = pc.sun.w; float ambient = pc.sun.w;
+6 -3
View File
@@ -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) {
+38
View File
@@ -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);
}
+229
View File
@@ -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);
}
+17 -3
View File
@@ -370,6 +370,8 @@ export namespace ra3::client {
bool running = true; bool running = true;
bool presented = false; bool presented = false;
const auto default_camera = camera;
bool middle_dragged = false;
while (running) { while (running) {
ui_event event; ui_event event;
float drag_x = 0.0F; float drag_x = 0.0F;
@@ -388,13 +390,23 @@ export namespace ra3::client {
} else if (event.type == ui_event_type::mouse_move) { } else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x; mouse_x = event.x;
mouse_y = event.y; mouse_y = event.y;
if (event.left) { // Retail RA3: the middle button orbits the camera; the
// left button is selection only, not camera rotation.
if (event.middle) {
drag_x += event.dx; drag_x += event.dx;
drag_y += event.dy; drag_y += event.dy;
if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
} }
} else if (event.type == ui_event_type::wheel) { } else if (event.type == ui_event_type::wheel) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height); camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
camera_moved = true; camera_moved = true;
} else if (event.type == ui_event_type::mouse_button && event.middle && event.released) {
if (!middle_dragged) {
camera.yaw = default_camera.yaw;
camera.pitch = default_camera.pitch;
camera.height = default_camera.height;
camera_moved = true;
}
} }
} }
if (!running) break; if (!running) break;
@@ -404,8 +416,10 @@ export namespace ra3::client {
last = now; last = now;
if (drag_x != 0.0F || drag_y != 0.0F) { if (drag_x != 0.0F || drag_y != 0.0F) {
camera.yaw -= drag_x * 0.005F; // SAGE LookAtTranslator: middle-drag rotates yaw (X) and
camera.pitch = std::clamp(camera.pitch + drag_y * 0.004F, 0.15F, 1.45F); // pitch (Y) at 0.01 rad per pixel; pitch is clamped to +-36 deg.
camera.yaw -= drag_x * 0.01F;
camera.pitch = std::clamp(camera.pitch + drag_y * 0.01F, 0.15F, 1.45F);
camera_moved = true; camera_moved = true;
} }
+21 -17
View File
@@ -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};
} }
} }
+135 -1
View File
@@ -19,8 +19,9 @@ export import ra3.fs;
export namespace ra3::map { export namespace ra3::map {
using ra3::core::coord3d; using ra3::core::coord3d;
using ra3::core::real; using ra3::core::real;
using ra3::core::uint32;
using ra3::core::uint8; using ra3::core::uint8;
using ra3::core::uint16;
using ra3::core::uint32;
using ra3::core::usize; using ra3::core::usize;
/** A player start location extracted from a map's waypoints. */ /** A player start location extracted from a map's waypoints. */
@@ -202,6 +203,139 @@ export namespace ra3::map {
return starts; return starts;
} }
/**
* One object the map places on the ground: a building, a prop, a lamp, a
* tree — anything in the `ObjectsList` chunk.
*
* `type` is the SAGE `ThingTemplate` name (e.g. `BB_GRASS02`,
* `AlliedBarracks`); the per-map compiled art stream resolves it to the
* `W3DMesh` assets that draw it. `angle` is the Z rotation in radians.
*/
struct map_object {
std::string type;
real x = 0.0F;
real y = 0.0F;
real z = 0.0F;
real angle = 0.0F;
real scale = 1.0F;
uint32 road_type = 0U; ///< SAGE `RoadType` flags; 0 for a non-road object.
};
namespace detail {
struct ckmp_chunk {
std::string name;
uint16 version = 0;
usize offset = 0;
usize size = 0;
};
/** Parse the `CkMp` chunk tree and its `index -> name` table. */
[[nodiscard]] inline auto parse_ckmp_chunks(std::span<const uint8> data) -> std::pair<std::vector<std::string>, std::vector<ckmp_chunk>> {
if (data.size() < 8U || std::memcmp(data.data(), "CkMp", 4) != 0) return {};
usize pos = 4;
const auto read_u32 = [&](usize at) {
return static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U) | (static_cast<uint32>(data[at + 2U]) << 16U) |
(static_cast<uint32>(data[at + 3U]) << 24U);
};
const auto read_u16 = [&](usize at) { return static_cast<uint16>(static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U)); };
const auto count = read_u32(pos);
pos += 4;
std::vector<std::string> names(count + 1U);
for (uint32 i = count; i >= 1U && pos < data.size(); --i) {
const auto len = data[pos++];
if (pos + len + 4U > data.size()) break;
names[i] = std::string{reinterpret_cast<const char *>(data.data() + pos), len};
pos += len + 4U;
}
std::vector<ckmp_chunk> chunks;
while (pos + 10U <= data.size()) {
const auto index = read_u32(pos);
const auto version = read_u16(pos + 4U);
const auto size = read_u32(pos + 6U);
pos += 10U;
if (index >= names.size() || pos + size > data.size()) break;
chunks.push_back({names[index], version, pos, size});
pos += size;
}
return {std::move(names), std::move(chunks)};
}
}
/**
* Every object the map places (the `ObjectsList` chunk).
*
* Layout (OpenSAGE `Data/Map/{ObjectsList,MapObject,AssetProperty}.cs`):
* the chunk is a list of nested `Object` assets, each a `Coord3D`, a Z
* `angle`, a `RoadType`, a `u16`-prefixed type-name and a property list
* whose keys index the shared name table.
*
* @return The objects in chunk order; empty when the map has no object list.
*/
[[nodiscard]] inline auto parse_objects(std::span<const uint8> ckmp) -> std::vector<map_object> {
const auto [names, chunks] = detail::parse_ckmp_chunks(ckmp);
(void) names;
std::vector<map_object> objects;
const auto read_u16 = [&](usize at) { return static_cast<uint16>(static_cast<uint32>(ckmp[at]) | (static_cast<uint32>(ckmp[at + 1U]) << 8U)); };
const auto read_u32 = [&](usize at) {
return static_cast<uint32>(ckmp[at]) | (static_cast<uint32>(ckmp[at + 1U]) << 8U) | (static_cast<uint32>(ckmp[at + 2U]) << 16U) |
(static_cast<uint32>(ckmp[at + 3U]) << 24U);
};
const auto read_f32 = [&](usize at) {
const auto bits = read_u32(at);
real value = 0.0F;
std::memcpy(&value, &bits, sizeof(value));
return value;
};
for (const auto &chunk: chunks) {
if (chunk.name != "ObjectsList") continue;
usize p = chunk.offset;
const auto end = chunk.offset + chunk.size;
while (p + 6U <= end) {
p += 4U; // asset index (always `Object`)
p += 2U; // asset version
const auto asset_size = read_u32(p);
p += 4U;
if (asset_size < 24U || p + asset_size > end) break;
const auto asset_end = p + asset_size;
map_object object;
object.x = read_f32(p);
object.y = read_f32(p + 4U);
object.z = read_f32(p + 8U);
object.angle = read_f32(p + 12U);
object.road_type = read_u32(p + 16U);
p += 20U; // Coord3D + angle + road type
const auto name_len = read_u16(p);
p += 2U;
if (p + name_len > asset_end) break;
object.type = std::string{reinterpret_cast<const char *>(ckmp.data() + p), name_len};
p += name_len;
const auto property_count = static_cast<uint32>(read_u16(p));
p += 2U;
for (uint32 i = 0; i < property_count && p + 4U <= asset_end; ++i) {
const auto type = ckmp[p++];
p += 3U; // property name index (u24) into the shared name table
usize value_size = 0;
if (type == 0U) {
value_size = 1U; // boolean
} else if (type == 1U || type == 2U) {
value_size = 4U; // integer / real
} else if (p + 2U <= asset_end) {
const auto len = read_u16(p);
value_size = 2U + static_cast<usize>(len) * ((type == 4U) ? 2U : 1U); // ascii/unicode/unknown
}
p += value_size;
if (p > asset_end) break;
}
objects.push_back(std::move(object));
p = asset_end;
}
}
return objects;
}
/** Map id -> localized display name, keyed by lowercased id. */ /** Map id -> localized display name, keyed by lowercased id. */
struct map_name_table { struct map_name_table {
std::unordered_map<std::string, std::string> names; std::unordered_map<std::string, std::string> names;
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -16,8 +16,10 @@ export import ra3.map;
export import ra3.skirmish; export import ra3.skirmish;
export import ra3.render; export import ra3.render;
export import ra3.terrain; export import ra3.terrain;
export import ra3.models;
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;
+7 -2
View File
@@ -277,6 +277,9 @@ export namespace ra3::render {
float dy = 0.0F; float dy = 0.0F;
float wheel = 0.0F; float wheel = 0.0F;
bool left = false; bool left = false;
bool middle = false;
bool right = false;
bool released = false; ///< mouse_button: true for a button-up event.
}; };
/** A warm red/gold loading screen with a progress bar (0..1). */ /** A warm red/gold loading screen with a progress bar (0..1). */
@@ -675,7 +678,9 @@ export namespace ra3::render {
float pitch = 1.02F; ///< Radians above the horizon (~58 degrees down). float pitch = 1.02F; ///< Radians above the horizon (~58 degrees down).
float height = 420.0F; ///< Camera height above the target's ground. float height = 420.0F; ///< Camera height above the target's ground.
float fov = 0.85F; ///< Vertical field of view, radians. float fov = 0.85F; ///< Vertical field of view, radians.
float min_height = 120.0F; // Zoom range mirrors the retail TacticalView (zoom 0.2..1.3 around the
float max_height = 1600.0F; // default height): closer/farther than that is clamped.
float min_height = 320.0F;
float max_height = 2100.0F;
}; };
} }
+163 -6
View File
@@ -5,6 +5,7 @@ import std;
export import ra3.core; export import ra3.core;
export import ra3.fs; export import ra3.fs;
export import ra3.render; export import ra3.render;
export import ra3.models;
/** /**
* The map's real terrain, read from the compiled `CkMp` chunk tree. * The map's real terrain, read from the compiled `CkMp` chunk tree.
@@ -481,6 +482,7 @@ export namespace ra3::terrain {
float z_scale = 0.0390625F; float z_scale = 0.0390625F;
bool has_water = false; bool has_water = false;
float water_z = 0.0F; float water_z = 0.0F;
ra3::models::scene objects; ///< Buildings and props placed on the map (world-space triangle soup).
}; };
/** /**
@@ -492,14 +494,15 @@ export namespace ra3::terrain {
* base texture layer, the blend (and three-way) secondary layer and the * base texture layer, the blend (and three-way) secondary layer and the
* packed blend direction/flags, which the shader ramps across the cell. * packed blend direction/flags, which the shader ramps across the cell.
*/ */
[[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options = {}, [[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options,
const std::function<void(float)> &progress = {}) -> gpu_terrain { const ra3::models::scene &objects, const std::function<void(float)> &progress = {}) -> gpu_terrain {
gpu_terrain out; gpu_terrain out;
out.width = map.width; out.width = map.width;
out.height = map.height; out.height = map.height;
out.z_scale = options.z_scale; out.z_scale = options.z_scale;
out.has_water = map.has_water; out.has_water = map.has_water;
out.water_z = map.water_plane_z; out.water_z = map.water_plane_z;
out.objects = objects;
// Cell index -> texture layer. // Cell index -> texture layer.
const auto layer_of = [&](uint32 cell_index) -> uint16 { const auto layer_of = [&](uint32 cell_index) -> uint16 {
@@ -566,6 +569,12 @@ export namespace ra3::terrain {
return out; return out;
} }
/** Terrain without any placed objects. */
[[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options = {},
const std::function<void(float)> &progress = {}) -> gpu_terrain {
return build_gpu_terrain(map, set, options, ra3::models::scene{}, progress);
}
namespace detail { namespace detail {
/** /**
* The source texture a tile cell maps to. The texture is sampled * The source texture a tile cell maps to. The texture is sampled
@@ -721,6 +730,146 @@ export namespace ra3::terrain {
const auto c2 = f2 > 0.0F ? sample_layer(layers.three, wx, wy) : c0; const auto c2 = f2 > 0.0F ? sample_layer(layers.three, wx, wy) : c0;
return mix_color(mix_color(c0, c1, f1), c2, f2); return mix_color(mix_color(c0, c1, f1), c2, f2);
} }
/**
* Rasterise the map's building/prop scene over an already ray-marched
* terrain image, depth-testing against it.
*
* The camera basis is the one `render3d` used, so the two passes agree;
* `zbuf` holds the terrain's view-space depth per pixel (large where the
* ray hit nothing). Triangles are z-tested and perspective-correct.
*/
inline auto rasterize_objects(image &hi, std::vector<float> &zbuf, const ra3::models::scene &scene, const std::array<float, 3> &cam,
const std::array<float, 3> &f, const std::array<float, 3> &r, const std::array<float, 3> &u, float tan_half,
float aspect) -> void {
const auto rw = static_cast<int>(hi.width());
const auto rh = static_cast<int>(hi.height());
if (rw <= 0 || rh <= 0) return;
const auto dot3 = [](const std::array<float, 3> &a, const std::array<float, 3> &b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; };
float sun[3] = {0.45F, 0.35F, 0.82F};
const auto sl = std::sqrt(sun[0] * sun[0] + sun[1] * sun[1] + sun[2] * sun[2]);
sun[0] /= sl;
sun[1] /= sl;
sun[2] /= sl;
constexpr float ambient = 0.38F;
constexpr float near_plane = 10.0F;
const auto sample = [&](uint32 layer, float tu, float tv) -> uint32 {
if (layer >= scene.textures.size() || scene.textures[layer].empty()) return argb(140, 140, 140);
const auto &img = scene.textures[layer];
const auto wrap = [](float x) { return x - std::floor(x); };
const auto sx = std::min(img.width() - 1U, static_cast<uint32>(wrap(tu) * static_cast<float>(img.width())));
const auto sy = std::min(img.height() - 1U, static_cast<uint32>(wrap(tv) * static_cast<float>(img.height())));
return img.data()[static_cast<usize>(sy) * img.width() + sx];
};
// Average colour per texture, used when a triangle covers fewer pixels
// than texels (minification) — a cheap mip-0-away fallback that keeps
// distant props from shimmering.
std::vector<uint32> average(scene.textures.size(), argb(140, 140, 140));
for (usize i = 0; i < scene.textures.size(); ++i) {
const auto &img = scene.textures[i];
if (img.empty()) continue;
usize r = 0;
usize g = 0;
usize b = 0;
for (usize p = 0; p < static_cast<usize>(img.width()) * img.height(); ++p) {
r += (img.data()[p] >> 16U) & 0xFFU;
g += (img.data()[p] >> 8U) & 0xFFU;
b += img.data()[p] & 0xFFU;
}
const auto n = static_cast<usize>(img.width()) * img.height();
average[i] = argb(static_cast<uint8>(r / n), static_cast<uint8>(g / n), static_cast<uint8>(b / n));
}
struct projected {
float sx = 0.0F;
float sy = 0.0F;
float inv_a = 0.0F; ///< 1 / view-space depth
};
const auto project = [&](const ra3::models::vertex &v, projected &out) -> bool {
const std::array<float, 3> rel{v.x - cam[0], v.y - cam[1], v.z - cam[2]};
const auto a = dot3(rel, f);
if (a <= near_plane) return false;
const auto ndc_x = (dot3(rel, r) / a) / (tan_half * aspect);
const auto ndc_y = (dot3(rel, u) / a) / tan_half;
out.sx = (ndc_x * 0.5F + 0.5F) * static_cast<float>(rw);
out.sy = (0.5F - ndc_y * 0.5F) * static_cast<float>(rh);
out.inv_a = 1.0F / a;
return true;
};
for (usize t = 0; t + 2U < scene.indices.size(); t += 3U) {
const auto &v0 = scene.vertices[scene.indices[t]];
const auto &v1 = scene.vertices[scene.indices[t + 1U]];
const auto &v2 = scene.vertices[scene.indices[t + 2U]];
projected p0, p1, p2;
if (!project(v0, p0) || !project(v1, p1) || !project(v2, p2)) continue;
const auto area = (p1.sx - p0.sx) * (p2.sy - p0.sy) - (p1.sy - p0.sy) * (p2.sx - p0.sx);
if (std::abs(area) < 1.0e-6F) continue;
const auto sign = area < 0.0F ? -1.0F : 1.0F;
// Texture footprint: if the triangle covers more texels than
// pixels it is minified, so fall back to the texture average.
const auto uv_area = std::abs((v1.u - v0.u) * (v2.v - v0.v) - (v2.u - v0.u) * (v1.v - v0.v));
const auto texel_footprint = uv_area * static_cast<float>(scene.texture_size) * static_cast<float>(scene.texture_size);
const auto minified = texel_footprint > 2.0F * std::abs(area);
const auto flat_layer = static_cast<uint32>(v0.layer + 0.5F);
const auto flat_color = flat_layer < average.size() ? average[flat_layer] : argb(140, 140, 140);
const auto min_x = std::max(0, static_cast<int>(std::floor(std::min({p0.sx, p1.sx, p2.sx}))));
const auto max_x = std::min(rw - 1, static_cast<int>(std::ceil(std::max({p0.sx, p1.sx, p2.sx}))));
const auto min_y = std::max(0, static_cast<int>(std::floor(std::min({p0.sy, p1.sy, p2.sy}))));
const auto max_y = std::min(rh - 1, static_cast<int>(std::ceil(std::max({p0.sy, p1.sy, p2.sy}))));
for (int y = min_y; y <= max_y; ++y) {
for (int x = min_x; x <= max_x; ++x) {
const auto px = static_cast<float>(x) + 0.5F;
const auto py = static_cast<float>(y) + 0.5F;
auto w0 = ((p1.sx - p0.sx) * (py - p0.sy) - (p1.sy - p0.sy) * (px - p0.sx)) * sign;
auto w1 = ((p2.sx - p1.sx) * (py - p1.sy) - (p2.sy - p1.sy) * (px - p1.sx)) * sign;
auto w2 = ((p0.sx - p2.sx) * (py - p2.sy) - (p0.sy - p2.sy) * (px - p2.sx)) * sign;
if (w0 < 0.0F || w1 < 0.0F || w2 < 0.0F) continue;
const auto sum = w0 + w1 + w2;
if (sum <= 0.0F) continue;
w0 /= sum;
w1 /= sum;
w2 /= sum;
// Perspective-correct depth and attributes.
const auto inv_a = w0 * p0.inv_a + w1 * p1.inv_a + w2 * p2.inv_a;
const auto depth = 1.0F / inv_a;
const auto pixel = static_cast<usize>(y) * static_cast<usize>(rw) + static_cast<usize>(x);
// Ground decals carry a per-vertex bias toward the camera
// (the retail shader's screen-space depth offset); it
// keeps roads/sidewalks from z-fighting the terrain.
const auto bias = w0 * v0.bias + w1 * v1.bias + w2 * v2.bias;
if (depth - bias - 1.0F >= zbuf[pixel]) continue;
const auto tu = (w0 * v0.u * p0.inv_a + w1 * v1.u * p1.inv_a + w2 * v2.u * p2.inv_a) * depth;
const auto tv = (w0 * v0.v * p0.inv_a + w1 * v1.v * p1.inv_a + w2 * v2.v * p2.inv_a) * depth;
float nx = w0 * v0.nx + w1 * v1.nx + w2 * v2.nx;
float ny = w0 * v0.ny + w1 * v1.ny + w2 * v2.ny;
float nz = w0 * v0.nz + w1 * v1.nz + w2 * v2.nz;
const auto nl = std::sqrt(nx * nx + ny * ny + nz * nz);
if (nl > 1.0e-6F) {
nx /= nl;
ny /= nl;
nz /= nl;
}
const auto lambert = std::max(0.0F, std::abs(nx * sun[0] + ny * sun[1] + nz * sun[2]));
const auto shade = ambient + (1.0F - ambient) * lambert;
const auto texel = minified ? flat_color : sample(flat_layer, tu, tv);
const auto mod = [&](uint32 shift) {
return static_cast<uint8>(std::clamp(static_cast<float>((texel >> shift) & 0xFFU) * shade, 0.0F, 255.0F));
};
hi.data()[pixel] = argb(mod(16U), mod(8U), mod(0U));
zbuf[pixel] = depth;
}
}
}
}
} }
/** /**
@@ -851,7 +1000,7 @@ export namespace ra3::terrain {
* a transformed 2D image. * a transformed 2D image.
*/ */
[[nodiscard]] inline auto render3d(const map_data &map, const texture_set &set, const camera3d &camera, uint32 out_w, uint32 out_h, [[nodiscard]] inline auto render3d(const map_data &map, const texture_set &set, const camera3d &camera, uint32 out_w, uint32 out_h,
const render_options &options = {}) -> image { const render_options &options = {}, const ra3::models::scene *objects = nullptr) -> image {
if (!map.valid) throw terrain_error("terrain not parsed"); if (!map.valid) throw terrain_error("terrain not parsed");
out_w = std::max(1U, out_w); out_w = std::max(1U, out_w);
out_h = std::max(1U, out_h); out_h = std::max(1U, out_h);
@@ -968,6 +1117,7 @@ export namespace ra3::terrain {
}; };
image hi(rw, rh, argb(0, 0, 0)); image hi(rw, rh, argb(0, 0, 0));
std::vector<float> zbuf(static_cast<usize>(rw) * rh, 1.0e30F);
for (uint32 py = 0; py < rh; ++py) { for (uint32 py = 0; py < rh; ++py) {
const auto ndc_y = 1.0F - 2.0F * (static_cast<float>(py) + 0.5F) / static_cast<float>(rh); const auto ndc_y = 1.0F - 2.0F * (static_cast<float>(py) + 0.5F) / static_cast<float>(rh);
for (uint32 px = 0; px < rw; ++px) { for (uint32 px = 0; px < rw; ++px) {
@@ -979,10 +1129,11 @@ export namespace ra3::terrain {
dx /= dlen; dx /= dlen;
dy /= dlen; dy /= dlen;
dz /= dlen; dz /= dlen;
const auto pixel = static_cast<usize>(py) * rw + px;
if (dz >= -1.0e-4F) { if (dz >= -1.0e-4F) {
const auto t = std::clamp(std::abs(dz) * 2.0F, 0.0F, 1.0F); const auto t = std::clamp(std::abs(dz) * 2.0F, 0.0F, 1.0F);
hi.data()[static_cast<usize>(py) * rw + px] = hi.data()[pixel] =
argb(static_cast<uint8>(120.0F + 60.0F * t), static_cast<uint8>(150.0F + 45.0F * t), static_cast<uint8>(190.0F + 40.0F * t)); argb(static_cast<uint8>(120.0F + 60.0F * t), static_cast<uint8>(150.0F + 45.0F * t), static_cast<uint8>(190.0F + 40.0F * t));
continue; continue;
} }
@@ -1017,7 +1168,7 @@ export namespace ra3::terrain {
t += dt; t += dt;
} }
if (!hit) { if (!hit) {
hi.data()[static_cast<usize>(py) * rw + px] = argb(150, 170, 200); hi.data()[pixel] = argb(150, 170, 200);
continue; continue;
} }
@@ -1035,10 +1186,16 @@ export namespace ra3::terrain {
lo = mid; lo = mid;
} }
} }
hi.data()[static_cast<usize>(py) * rw + px] = hit_color(cam_x + dx * up, cam_y + dy * up, cam_z + dz * up); hi.data()[pixel] = hit_color(cam_x + dx * up, cam_y + dy * up, cam_z + dz * up);
// View-space depth of the hit, for the object pass below.
zbuf[pixel] = up * (dx * fx + dy * fy + dz * fz);
} }
} }
if (objects != nullptr && !objects->empty()) {
detail::rasterize_objects(hi, zbuf, *objects, {cam_x, cam_y, cam_z}, {fx, fy, fz}, {rx, ry, rz}, {ux, uy, uz}, tan_half, aspect);
}
if (ss == 1U) return hi; if (ss == 1U) return hi;
image out(out_w, out_h, argb(0, 0, 0)); image out(out_w, out_h, argb(0, 0, 0));
for (uint32 y = 0; y < out_h; ++y) { for (uint32 y = 0; y < out_h; ++y) {
+18 -4
View File
@@ -101,6 +101,8 @@ export namespace ra3::ui {
out.dx = event.motion.xrel; out.dx = event.motion.xrel;
out.dy = event.motion.yrel; out.dy = event.motion.yrel;
out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U; out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U;
out.middle = (event.motion.state & SDL_BUTTON_MMASK) != 0U;
out.right = (event.motion.state & SDL_BUTTON_RMASK) != 0U;
return true; return true;
case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_DOWN:
out = {}; out = {};
@@ -108,6 +110,18 @@ export namespace ra3::ui {
out.x = event.button.x; out.x = event.button.x;
out.y = event.button.y; out.y = event.button.y;
out.left = event.button.button == SDL_BUTTON_LEFT; out.left = event.button.button == SDL_BUTTON_LEFT;
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
out.right = event.button.button == SDL_BUTTON_RIGHT;
return true;
case SDL_EVENT_MOUSE_BUTTON_UP:
out = {};
out.type = ra3::render::ui_event_type::mouse_button;
out.x = event.button.x;
out.y = event.button.y;
out.released = true;
out.left = event.button.button == SDL_BUTTON_LEFT;
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
out.right = event.button.button == SDL_BUTTON_RIGHT;
return true; return true;
case SDL_EVENT_MOUSE_WHEEL: case SDL_EVENT_MOUSE_WHEEL:
out = {}; out = {};
@@ -132,10 +146,10 @@ export namespace ra3::ui {
const bool *keys = SDL_GetKeyboardState(nullptr); const bool *keys = SDL_GetKeyboardState(nullptr);
if (keys == nullptr) return false; if (keys == nullptr) return false;
switch (key) { switch (key) {
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP]; case ra3::render::ui_key::up: return keys[SDL_SCANCODE_UP];
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN]; case ra3::render::ui_key::down: return keys[SDL_SCANCODE_DOWN];
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT]; case ra3::render::ui_key::left: return keys[SDL_SCANCODE_LEFT];
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT]; case ra3::render::ui_key::right: return keys[SDL_SCANCODE_RIGHT];
default: return false; default: return false;
} }
} }
+392 -21
View File
@@ -92,6 +92,7 @@ export namespace ra3::vulkan {
if (!this->create_device()) return false; if (!this->create_device()) return false;
if (!this->create_swapchain()) return false; if (!this->create_swapchain()) return false;
if (!this->create_render_pass()) return false; if (!this->create_render_pass()) return false;
if (!this->create_framebuffers()) return false;
if (!this->create_pipeline()) return false; if (!this->create_pipeline()) return false;
if (!this->create_commands()) return false; if (!this->create_commands()) return false;
if (!this->create_sync()) return false; if (!this->create_sync()) return false;
@@ -294,6 +295,8 @@ export namespace ra3::vulkan {
out.dx = event.motion.xrel; out.dx = event.motion.xrel;
out.dy = event.motion.yrel; out.dy = event.motion.yrel;
out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U; out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U;
out.middle = (event.motion.state & SDL_BUTTON_MMASK) != 0U;
out.right = (event.motion.state & SDL_BUTTON_RMASK) != 0U;
return true; return true;
case SDL_EVENT_MOUSE_BUTTON_DOWN: case SDL_EVENT_MOUSE_BUTTON_DOWN:
out = {}; out = {};
@@ -301,6 +304,18 @@ export namespace ra3::vulkan {
out.x = event.button.x; out.x = event.button.x;
out.y = event.button.y; out.y = event.button.y;
out.left = event.button.button == SDL_BUTTON_LEFT; out.left = event.button.button == SDL_BUTTON_LEFT;
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
out.right = event.button.button == SDL_BUTTON_RIGHT;
return true;
case SDL_EVENT_MOUSE_BUTTON_UP:
out = {};
out.type = ra3::render::ui_event_type::mouse_button;
out.x = event.button.x;
out.y = event.button.y;
out.released = true;
out.left = event.button.button == SDL_BUTTON_LEFT;
out.middle = event.button.button == SDL_BUTTON_MIDDLE;
out.right = event.button.button == SDL_BUTTON_RIGHT;
return true; return true;
case SDL_EVENT_MOUSE_WHEEL: case SDL_EVENT_MOUSE_WHEEL:
out = {}; out = {};
@@ -325,10 +340,10 @@ export namespace ra3::vulkan {
const bool *keys = SDL_GetKeyboardState(nullptr); const bool *keys = SDL_GetKeyboardState(nullptr);
if (keys == nullptr) return false; if (keys == nullptr) return false;
switch (key) { switch (key) {
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP]; case ra3::render::ui_key::up: return keys[SDL_SCANCODE_UP];
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN]; case ra3::render::ui_key::down: return keys[SDL_SCANCODE_DOWN];
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT]; case ra3::render::ui_key::left: return keys[SDL_SCANCODE_LEFT];
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT]; case ra3::render::ui_key::right: return keys[SDL_SCANCODE_RIGHT];
default: return false; default: return false;
} }
} }
@@ -341,6 +356,12 @@ export namespace ra3::vulkan {
if (!this->create_terrain_pipeline()) return false; if (!this->create_terrain_pipeline()) return false;
if (!this->create_terrain_images(terrain)) return false; if (!this->create_terrain_images(terrain)) return false;
if (!this->create_terrain_descriptors()) return false; if (!this->create_terrain_descriptors()) return false;
if (!terrain.objects.empty()) {
if (!this->create_object_pipeline()) return false;
if (!this->create_object_buffers(terrain)) return false;
if (!this->create_object_descriptors()) return false;
objects_ready_ = true;
}
terrain_ready_ = true; terrain_ready_ = true;
} }
if (overlay.label_changed) this->update_overlay(overlay_label_, overlay_label_set_, overlay_label_w_, overlay_label_h_, overlay.label); if (overlay.label_changed) this->update_overlay(overlay_label_, overlay_label_set_, overlay_label_w_, overlay_label_h_, overlay.label);
@@ -669,6 +690,11 @@ export namespace ra3::vulkan {
VkPipelineMultisampleStateCreateInfo multisample{}; VkPipelineMultisampleStateCreateInfo multisample{};
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkPipelineDepthStencilStateCreateInfo depth_stencil{};
depth_stencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depth_stencil.depthTestEnable = VK_TRUE;
depth_stencil.depthWriteEnable = VK_TRUE;
depth_stencil.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
VkPipelineColorBlendAttachmentState blend_attachment{}; VkPipelineColorBlendAttachmentState blend_attachment{};
blend_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; blend_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
VkPipelineColorBlendStateCreateInfo blend{}; VkPipelineColorBlendStateCreateInfo blend{};
@@ -690,6 +716,7 @@ export namespace ra3::vulkan {
pipeline_info.pViewportState = &viewport; pipeline_info.pViewportState = &viewport;
pipeline_info.pRasterizationState = &raster; pipeline_info.pRasterizationState = &raster;
pipeline_info.pMultisampleState = &multisample; pipeline_info.pMultisampleState = &multisample;
pipeline_info.pDepthStencilState = &depth_stencil;
pipeline_info.pColorBlendState = &blend; pipeline_info.pColorBlendState = &blend;
pipeline_info.pDynamicState = &dynamic; pipeline_info.pDynamicState = &dynamic;
pipeline_info.layout = terrain_pipeline_layout_; pipeline_info.layout = terrain_pipeline_layout_;
@@ -737,6 +764,198 @@ export namespace ra3::vulkan {
return true; return true;
} }
/** Host-visible vertex/index buffer, filled once (the map's geometry is static). */
[[nodiscard]] auto make_host_buffer(const void *data, VkDeviceSize bytes, VkBufferUsageFlags usage, VkBuffer &buffer,
VkDeviceMemory &memory) -> bool {
VkBufferCreateInfo info{};
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
info.size = bytes;
info.usage = usage;
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
if (!detail::check(vkCreateBuffer(device_, &info, nullptr, &buffer), "vkCreateBuffer(object)")) return false;
VkMemoryRequirements requirements{};
vkGetBufferMemoryRequirements(device_, buffer, &requirements);
VkMemoryAllocateInfo allocate{};
allocate.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocate.allocationSize = requirements.size;
allocate.memoryTypeIndex = this->memory_type(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
if (!detail::check(vkAllocateMemory(device_, &allocate, nullptr, &memory), "vkAllocateMemory(object)")) return false;
if (!detail::check(vkBindBufferMemory(device_, buffer, memory, 0), "vkBindBufferMemory(object)")) return false;
void *mapped = nullptr;
if (!detail::check(vkMapMemory(device_, memory, 0, bytes, 0, &mapped), "vkMapMemory(object)")) return false;
std::memcpy(mapped, data, static_cast<std::size_t>(bytes));
vkUnmapMemory(device_, memory);
return true;
}
/** Upload the static-map geometry and its texture array. */
[[nodiscard]] auto create_object_buffers(const ra3::terrain::gpu_terrain &terrain) -> bool {
const auto &scene = terrain.objects;
if (scene.empty()) return true;
const auto vertex_bytes = static_cast<VkDeviceSize>(scene.vertices.size()) * sizeof(ra3::models::vertex);
const auto index_bytes = static_cast<VkDeviceSize>(scene.indices.size()) * sizeof(uint32_t);
if (!this->make_host_buffer(scene.vertices.data(), vertex_bytes, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, object_vertex_buffer_, object_vertex_memory_)) return false;
if (!this->make_host_buffer(scene.indices.data(), index_bytes, VK_BUFFER_USAGE_INDEX_BUFFER_BIT, object_index_buffer_, object_index_memory_)) return false;
object_index_count_ = static_cast<uint32_t>(scene.indices.size());
const auto size = std::max(1U, scene.texture_size);
const auto layers = std::max<size_t>(1U, scene.textures.size());
std::vector<uint32_t> pixels(static_cast<size_t>(size) * size * layers, 0xFFFFFFFFU);
for (size_t layer = 0; layer < scene.textures.size(); ++layer) {
const auto &texture = scene.textures[layer];
if (texture.empty()) continue;
// The scene already resized every texture to `texture_size`.
for (uint32_t y = 0; y < std::min(size, texture.height()); ++y) {
for (uint32_t x = 0; x < std::min(size, texture.width()); ++x) {
pixels[(static_cast<size_t>(layer) * size + y) * size + x] = texture.data()[static_cast<size_t>(y) * texture.width() + x];
}
}
}
return this->make_gpu_image(object_texture_, size, size, VK_FORMAT_B8G8R8A8_UNORM, pixels.data(),
static_cast<VkDeviceSize>(pixels.size()) * 4U, this->mip_count(size), VK_FILTER_LINEAR, VK_SAMPLER_MIPMAP_MODE_LINEAR,
static_cast<uint32_t>(layers), VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE);
}
/** Full mip chain length for a square texture. */
[[nodiscard]] static auto mip_count(uint32_t size) -> uint32_t {
uint32_t mips = 1U;
while (size > 1U) {
size >>= 1U;
++mips;
}
return mips;
}
[[nodiscard]] auto create_object_pipeline() -> bool {
VkPushConstantRange push_range{};
push_range.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT;
push_range.size = sizeof(float) * 20U;
VkPipelineLayoutCreateInfo layout_info{};
layout_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
layout_info.setLayoutCount = 1U;
layout_info.pSetLayouts = &descriptor_layout_; // reused: one combined image sampler
layout_info.pushConstantRangeCount = 1U;
layout_info.pPushConstantRanges = &push_range;
if (!detail::check(vkCreatePipelineLayout(device_, &layout_info, nullptr, &object_pipeline_layout_), "vkCreatePipelineLayout(object)")) return false;
VkShaderModule vertex = VK_NULL_HANDLE;
VkShaderModule fragment = VK_NULL_HANDLE;
if (!this->make_shader_module(ra3_shaders::object_vert_spv, ra3_shaders::object_vert_spv_words, vertex)) return false;
if (!this->make_shader_module(ra3_shaders::object_frag_spv, ra3_shaders::object_frag_spv_words, fragment)) return false;
VkPipelineShaderStageCreateInfo stages[2]{};
stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
stages[0].module = vertex;
stages[0].pName = "main";
stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
stages[1].module = fragment;
stages[1].pName = "main";
VkVertexInputBindingDescription binding{};
binding.binding = 0U;
binding.stride = sizeof(ra3::models::vertex);
binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
VkVertexInputAttributeDescription attributes[5]{};
attributes[0] = {0U, 0U, VK_FORMAT_R32G32B32_SFLOAT, 0U};
attributes[1] = {1U, 0U, VK_FORMAT_R32G32B32_SFLOAT, 12U};
attributes[2] = {2U, 0U, VK_FORMAT_R32G32_SFLOAT, 24U};
attributes[3] = {3U, 0U, VK_FORMAT_R32_SFLOAT, 32U};
attributes[4] = {4U, 0U, VK_FORMAT_R32_SFLOAT, 36U};
static_assert(sizeof(ra3::models::vertex) == 40U, "object vertex layout changed");
VkPipelineVertexInputStateCreateInfo vertex_input{};
vertex_input.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
vertex_input.vertexBindingDescriptionCount = 1U;
vertex_input.pVertexBindingDescriptions = &binding;
vertex_input.vertexAttributeDescriptionCount = 5U;
vertex_input.pVertexAttributeDescriptions = attributes;
VkPipelineInputAssemblyStateCreateInfo assembly{};
assembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
VkPipelineViewportStateCreateInfo viewport{};
viewport.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
viewport.viewportCount = 1U;
viewport.scissorCount = 1U;
VkPipelineRasterizationStateCreateInfo raster{};
raster.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
raster.polygonMode = VK_POLYGON_MODE_FILL;
raster.cullMode = VK_CULL_MODE_NONE;
raster.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
raster.lineWidth = 1.0F;
// Ground decals (sidewalks/roads/deck pieces) sit exactly on the
// terrain; a small negative depth bias keeps them from z-fighting.
raster.depthBiasEnable = VK_TRUE;
raster.depthBiasConstantFactor = -4.0F;
raster.depthBiasSlopeFactor = -4.0F;
VkPipelineMultisampleStateCreateInfo multisample{};
multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkPipelineDepthStencilStateCreateInfo depth_stencil{};
depth_stencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
depth_stencil.depthTestEnable = VK_TRUE;
depth_stencil.depthWriteEnable = VK_TRUE;
depth_stencil.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
VkPipelineColorBlendAttachmentState blend_attachment{};
blend_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
blend_attachment.blendEnable = VK_FALSE;
VkPipelineColorBlendStateCreateInfo blend{};
blend.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
blend.attachmentCount = 1U;
blend.pAttachments = &blend_attachment;
const VkDynamicState dynamic_states[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
VkPipelineDynamicStateCreateInfo dynamic{};
dynamic.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
dynamic.dynamicStateCount = 2U;
dynamic.pDynamicStates = dynamic_states;
VkGraphicsPipelineCreateInfo pipeline_info{};
pipeline_info.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
pipeline_info.stageCount = 2U;
pipeline_info.pStages = stages;
pipeline_info.pVertexInputState = &vertex_input;
pipeline_info.pInputAssemblyState = &assembly;
pipeline_info.pViewportState = &viewport;
pipeline_info.pRasterizationState = &raster;
pipeline_info.pMultisampleState = &multisample;
pipeline_info.pDepthStencilState = &depth_stencil;
pipeline_info.pColorBlendState = &blend;
pipeline_info.pDynamicState = &dynamic;
pipeline_info.layout = object_pipeline_layout_;
pipeline_info.renderPass = render_pass_;
pipeline_info.subpass = 0U;
const auto created = detail::check(vkCreateGraphicsPipelines(device_, VK_NULL_HANDLE, 1U, &pipeline_info, nullptr, &object_pipeline_),
"vkCreateGraphicsPipelines(object)");
vkDestroyShaderModule(device_, vertex, nullptr);
vkDestroyShaderModule(device_, fragment, nullptr);
return created;
}
[[nodiscard]] auto create_object_descriptors() -> bool {
if (object_texture_.view == VK_NULL_HANDLE) return false;
VkDescriptorSetAllocateInfo set_info{};
set_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
set_info.descriptorPool = descriptor_pool_;
set_info.descriptorSetCount = 1U;
set_info.pSetLayouts = &descriptor_layout_;
if (!detail::check(vkAllocateDescriptorSets(device_, &set_info, &object_set_), "vkAllocateDescriptorSets(object)")) return false;
VkDescriptorImageInfo info{};
info.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
info.imageView = object_texture_.view;
info.sampler = object_texture_.sampler;
VkWriteDescriptorSet write{};
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
write.dstSet = object_set_;
write.dstBinding = 0U;
write.descriptorCount = 1U;
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
write.pImageInfo = &info;
vkUpdateDescriptorSets(device_, 1U, &write, 0U, nullptr);
return true;
}
[[nodiscard]] auto draw_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect) -> bool { [[nodiscard]] auto draw_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect) -> bool {
vkWaitForFences(device_, 1U, &in_flight_[current_frame_], VK_TRUE, UINT64_MAX); vkWaitForFences(device_, 1U, &in_flight_[current_frame_], VK_TRUE, UINT64_MAX);
@@ -754,13 +973,16 @@ export namespace ra3::vulkan {
VkClearValue clear{}; VkClearValue clear{};
clear.color = {{0.45F, 0.55F, 0.70F, 1.0F}}; clear.color = {{0.45F, 0.55F, 0.70F, 1.0F}};
VkClearValue depth_clear{};
depth_clear.depthStencil = {1.0F, 0U};
const VkClearValue clears[2] = {clear, depth_clear};
VkRenderPassBeginInfo render_pass_begin{}; VkRenderPassBeginInfo render_pass_begin{};
render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
render_pass_begin.renderPass = render_pass_; render_pass_begin.renderPass = render_pass_;
render_pass_begin.framebuffer = framebuffers_[image_index]; render_pass_begin.framebuffer = framebuffers_[image_index];
render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_}; render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_};
render_pass_begin.clearValueCount = 1U; render_pass_begin.clearValueCount = 2U;
render_pass_begin.pClearValues = &clear; render_pass_begin.pClearValues = clears;
vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE); vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE);
VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F}; VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F};
@@ -793,6 +1015,49 @@ export namespace ra3::vulkan {
vkCmdPushConstants(cmd, terrain_pipeline_layout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0U, sizeof(push), push); vkCmdPushConstants(cmd, terrain_pipeline_layout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0U, sizeof(push), push);
vkCmdDraw(cmd, 3U, 1U, 0U, 0U); vkCmdDraw(cmd, 3U, 1U, 0U, 0U);
// Static-map models (buildings/props) depth-test against the terrain.
if (objects_ready_ && object_index_count_ > 0U) {
const auto z_scale = terrain.z_scale;
const auto world_w = static_cast<float>(terrain.width) * 10.0F;
const auto world_h = static_cast<float>(terrain.height) * 10.0F;
const auto sample = [&](float wx, float wy) -> float {
if (wx < 0.0F || wy < 0.0F || wx >= world_w || wy >= world_h) return -1.0e9F;
const auto cx = std::min(terrain.width - 1U, static_cast<uint32_t>(wx / 10.0F));
const auto cy = std::min(terrain.height - 1U, static_cast<uint32_t>((world_h - wy) / 10.0F));
return static_cast<float>(terrain.heights[static_cast<size_t>(cy) * terrain.width + cx]) * z_scale;
};
float target_z = sample(camera.target_x, camera.target_y);
if (target_z < -1.0e8F) target_z = 0.0F;
const auto pitch = std::clamp(camera.pitch, 0.15F, 1.45F);
const auto fov = std::clamp(camera.fov, 0.3F, 1.4F);
const auto cp = std::cos(pitch);
const float fwd[3] = {cp * std::sin(camera.yaw), cp * std::cos(camera.yaw), -std::sin(pitch)};
float right[3] = {fwd[1], -fwd[0], 0.0F};
const auto rl = std::sqrt(right[0] * right[0] + right[1] * right[1]);
right[0] /= rl;
right[1] /= rl;
float up[3] = {right[1] * fwd[2], -right[0] * fwd[2], right[0] * fwd[1] - right[1] * fwd[0]};
const auto ul = std::sqrt(up[0] * up[0] + up[1] * up[1] + up[2] * up[2]);
up[0] /= ul;
up[1] /= ul;
up[2] /= ul;
const auto dist = camera.height / std::sin(pitch);
const float cam[3] = {camera.target_x - fwd[0] * dist, camera.target_y - fwd[1] * dist, target_z + camera.height - fwd[2] * dist};
const auto th = std::tan(fov * 0.5F);
const float obj_push[20] = {cam[0], cam[1], cam[2], 0.0F,
fwd[0], fwd[1], fwd[2], 0.0F,
right[0], right[1], right[2], th,
up[0], up[1], up[2], th * aspect,
0.45F, 0.35F, 0.82F, 0.38F};
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object_pipeline_);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, object_pipeline_layout_, 0U, 1U, &object_set_, 0U, nullptr);
vkCmdPushConstants(cmd, object_pipeline_layout_, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0U, sizeof(obj_push), obj_push);
const VkDeviceSize offset = 0U;
vkCmdBindVertexBuffers(cmd, 0U, 1U, &object_vertex_buffer_, &offset);
vkCmdBindIndexBuffer(cmd, object_index_buffer_, 0U, VK_INDEX_TYPE_UINT32);
vkCmdDrawIndexed(cmd, object_index_count_, 1U, 0U, 0, 0U);
}
// Overlays (top-left FPS label, bottom-right minimap) use the image // Overlays (top-left FPS label, bottom-right minimap) use the image
// pipeline with alpha blending, drawn into the same render pass. // pipeline with alpha blending, drawn into the same render pass.
const auto draw_overlay = [&](const gpu_image &overlay, VkDescriptorSet set, uint32_t w, uint32_t h, float x, float y) { const auto draw_overlay = [&](const gpu_image &overlay, VkDescriptorSet set, uint32_t w, uint32_t h, float x, float y) {
@@ -861,6 +1126,27 @@ export namespace ra3::vulkan {
terrain_set_ = VK_NULL_HANDLE; terrain_set_ = VK_NULL_HANDLE;
overlay_label_set_ = VK_NULL_HANDLE; overlay_label_set_ = VK_NULL_HANDLE;
overlay_minimap_set_ = VK_NULL_HANDLE; overlay_minimap_set_ = VK_NULL_HANDLE;
if (object_vertex_buffer_ != VK_NULL_HANDLE) vkDestroyBuffer(device_, object_vertex_buffer_, nullptr);
if (object_index_buffer_ != VK_NULL_HANDLE) vkDestroyBuffer(device_, object_index_buffer_, nullptr);
if (object_vertex_memory_ != VK_NULL_HANDLE) vkFreeMemory(device_, object_vertex_memory_, nullptr);
if (object_index_memory_ != VK_NULL_HANDLE) vkFreeMemory(device_, object_index_memory_, nullptr);
if (object_texture_.sampler != VK_NULL_HANDLE) vkDestroySampler(device_, object_texture_.sampler, nullptr);
if (object_texture_.view != VK_NULL_HANDLE) vkDestroyImageView(device_, object_texture_.view, nullptr);
if (object_texture_.image != VK_NULL_HANDLE) vkDestroyImage(device_, object_texture_.image, nullptr);
if (object_texture_.memory != VK_NULL_HANDLE) vkFreeMemory(device_, object_texture_.memory, nullptr);
if (object_pipeline_ != VK_NULL_HANDLE) vkDestroyPipeline(device_, object_pipeline_, nullptr);
if (object_pipeline_layout_ != VK_NULL_HANDLE) vkDestroyPipelineLayout(device_, object_pipeline_layout_, nullptr);
object_vertex_buffer_ = VK_NULL_HANDLE;
object_index_buffer_ = VK_NULL_HANDLE;
object_vertex_memory_ = VK_NULL_HANDLE;
object_index_memory_ = VK_NULL_HANDLE;
object_texture_ = {};
object_pipeline_ = VK_NULL_HANDLE;
object_pipeline_layout_ = VK_NULL_HANDLE;
object_set_ = VK_NULL_HANDLE;
object_index_count_ = 0U;
objects_ready_ = false;
} }
@@ -1009,23 +1295,78 @@ export namespace ra3::vulkan {
} }
framebuffers_.resize(actual); framebuffers_.resize(actual);
for (uint32_t i = 0; i < actual; ++i) { return true;
VkImageView attachments[] = {swapchain_views_[i]}; }
/** Create one color+depth framebuffer per swapchain image (needs `render_pass_`). */
[[nodiscard]] auto create_framebuffers() -> bool {
this->create_depth_resources();
for (size_t i = 0; i < framebuffers_.size(); ++i) {
VkImageView attachments[] = {swapchain_views_[i], depth_views_[i]};
VkFramebufferCreateInfo framebuffer_info{}; VkFramebufferCreateInfo framebuffer_info{};
framebuffer_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; framebuffer_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
framebuffer_info.renderPass = render_pass_; framebuffer_info.renderPass = render_pass_;
framebuffer_info.attachmentCount = 1U; framebuffer_info.attachmentCount = 2U;
framebuffer_info.pAttachments = attachments; framebuffer_info.pAttachments = attachments;
framebuffer_info.width = extent.width; framebuffer_info.width = swapchain_extent_.width;
framebuffer_info.height = extent.height; framebuffer_info.height = swapchain_extent_.height;
framebuffer_info.layers = 1U; framebuffer_info.layers = 1U;
if (!detail::check(vkCreateFramebuffer(device_, &framebuffer_info, nullptr, &framebuffers_[i]), "vkCreateFramebuffer")) return false; if (!detail::check(vkCreateFramebuffer(device_, &framebuffer_info, nullptr, &framebuffers_[i]), "vkCreateFramebuffer")) return false;
} }
return true; return true;
} }
/** Allocate a depth image + view per swapchain image (static-map models depth-test against the terrain). */
auto create_depth_resources() -> void {
this->destroy_depth_resources();
depth_images_.resize(swapchain_images_.size());
depth_memories_.resize(swapchain_images_.size());
depth_views_.resize(swapchain_images_.size());
for (size_t i = 0; i < swapchain_images_.size(); ++i) {
VkImageCreateInfo image_info{};
image_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
image_info.imageType = VK_IMAGE_TYPE_2D;
image_info.extent = {swapchain_extent_.width, swapchain_extent_.height, 1U};
image_info.mipLevels = 1U;
image_info.arrayLayers = 1U;
image_info.format = VK_FORMAT_D32_SFLOAT;
image_info.tiling = VK_IMAGE_TILING_OPTIMAL;
image_info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
image_info.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT;
image_info.samples = VK_SAMPLE_COUNT_1_BIT;
image_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
if (vkCreateImage(device_, &image_info, nullptr, &depth_images_[i]) != VK_SUCCESS) return;
VkMemoryRequirements requirements{};
vkGetImageMemoryRequirements(device_, depth_images_[i], &requirements);
VkMemoryAllocateInfo allocate{};
allocate.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocate.allocationSize = requirements.size;
allocate.memoryTypeIndex = this->memory_type(requirements.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
if (vkAllocateMemory(device_, &allocate, nullptr, &depth_memories_[i]) != VK_SUCCESS) return;
vkBindImageMemory(device_, depth_images_[i], depth_memories_[i], 0);
VkImageViewCreateInfo view_info{};
view_info.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
view_info.image = depth_images_[i];
view_info.viewType = VK_IMAGE_VIEW_TYPE_2D;
view_info.format = VK_FORMAT_D32_SFLOAT;
view_info.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0U, 1U, 0U, 1U};
if (vkCreateImageView(device_, &view_info, nullptr, &depth_views_[i]) != VK_SUCCESS) return;
}
}
auto destroy_depth_resources() -> void {
if (device_ == VK_NULL_HANDLE) return;
for (const auto view: depth_views_) vkDestroyImageView(device_, view, nullptr);
for (const auto image: depth_images_) vkDestroyImage(device_, image, nullptr);
for (const auto memory: depth_memories_) vkFreeMemory(device_, memory, nullptr);
depth_views_.clear();
depth_images_.clear();
depth_memories_.clear();
}
auto cleanup_swapchain() -> void { auto cleanup_swapchain() -> void {
for (const auto framebuffer: framebuffers_) vkDestroyFramebuffer(device_, framebuffer, nullptr); for (const auto framebuffer: framebuffers_) vkDestroyFramebuffer(device_, framebuffer, nullptr);
this->destroy_depth_resources();
for (const auto view: swapchain_views_) vkDestroyImageView(device_, view, nullptr); for (const auto view: swapchain_views_) vkDestroyImageView(device_, view, nullptr);
framebuffers_.clear(); framebuffers_.clear();
swapchain_views_.clear(); swapchain_views_.clear();
@@ -1041,7 +1382,7 @@ export namespace ra3::vulkan {
if (width == 0 || height == 0) return false; if (width == 0 || height == 0) return false;
vkDeviceWaitIdle(device_); vkDeviceWaitIdle(device_);
this->cleanup_swapchain(); this->cleanup_swapchain();
return this->create_swapchain(); return this->create_swapchain() && this->create_framebuffers();
} }
// ---- pipeline -------------------------------------------------------- // ---- pipeline --------------------------------------------------------
@@ -1058,22 +1399,36 @@ export namespace ra3::vulkan {
color.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; color.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
VkAttachmentReference color_ref{0U, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; VkAttachmentReference color_ref{0U, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
VkAttachmentDescription depth{};
depth.format = VK_FORMAT_D32_SFLOAT;
depth.samples = VK_SAMPLE_COUNT_1_BIT;
depth.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
depth.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depth.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE;
depth.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depth.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
depth.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
VkAttachmentReference depth_ref{1U, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL};
VkSubpassDescription subpass{}; VkSubpassDescription subpass{};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1U; subpass.colorAttachmentCount = 1U;
subpass.pColorAttachments = &color_ref; subpass.pColorAttachments = &color_ref;
subpass.pDepthStencilAttachment = &depth_ref;
VkSubpassDependency dependency{}; VkSubpassDependency dependency{};
dependency.srcSubpass = VK_SUBPASS_EXTERNAL; dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0U; dependency.dstSubpass = 0U;
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT;
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
const VkAttachmentDescription attachments[] = {color, depth};
VkRenderPassCreateInfo render_pass_info{}; VkRenderPassCreateInfo render_pass_info{};
render_pass_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; render_pass_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
render_pass_info.attachmentCount = 1U; render_pass_info.attachmentCount = 2U;
render_pass_info.pAttachments = &color; render_pass_info.pAttachments = attachments;
render_pass_info.subpassCount = 1U; render_pass_info.subpassCount = 1U;
render_pass_info.pSubpasses = &subpass; render_pass_info.pSubpasses = &subpass;
render_pass_info.dependencyCount = 1U; render_pass_info.dependencyCount = 1U;
@@ -1211,10 +1566,10 @@ export namespace ra3::vulkan {
command_buffers_.resize(frames_in_flight); command_buffers_.resize(frames_in_flight);
if (!detail::check(vkAllocateCommandBuffers(device_, &allocate, command_buffers_.data()), "vkAllocateCommandBuffers")) return false; if (!detail::check(vkAllocateCommandBuffers(device_, &allocate, command_buffers_.data()), "vkAllocateCommandBuffers")) return false;
VkDescriptorPoolSize pool_size{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 4U}; VkDescriptorPoolSize pool_size{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 8U};
VkDescriptorPoolCreateInfo descriptor_pool_info{}; VkDescriptorPoolCreateInfo descriptor_pool_info{};
descriptor_pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; descriptor_pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
descriptor_pool_info.maxSets = 4U; descriptor_pool_info.maxSets = 8U;
descriptor_pool_info.poolSizeCount = 1U; descriptor_pool_info.poolSizeCount = 1U;
descriptor_pool_info.pPoolSizes = &pool_size; descriptor_pool_info.pPoolSizes = &pool_size;
if (!detail::check(vkCreateDescriptorPool(device_, &descriptor_pool_info, nullptr, &descriptor_pool_), "vkCreateDescriptorPool")) return false; if (!detail::check(vkCreateDescriptorPool(device_, &descriptor_pool_info, nullptr, &descriptor_pool_), "vkCreateDescriptorPool")) return false;
@@ -1307,13 +1662,16 @@ export namespace ra3::vulkan {
VkClearValue clear{}; VkClearValue clear{};
clear.color = {{0.05F, 0.06F, 0.08F, 1.0F}}; clear.color = {{0.05F, 0.06F, 0.08F, 1.0F}};
VkClearValue depth_clear{};
depth_clear.depthStencil = {1.0F, 0U};
const VkClearValue clears[2] = {clear, depth_clear};
VkRenderPassBeginInfo render_pass_begin{}; VkRenderPassBeginInfo render_pass_begin{};
render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
render_pass_begin.renderPass = render_pass_; render_pass_begin.renderPass = render_pass_;
render_pass_begin.framebuffer = framebuffers_[image_index]; render_pass_begin.framebuffer = framebuffers_[image_index];
render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_}; render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_};
render_pass_begin.clearValueCount = 1U; render_pass_begin.clearValueCount = 2U;
render_pass_begin.pClearValues = &clear; render_pass_begin.pClearValues = clears;
vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE); vkCmdBeginRenderPass(cmd, &render_pass_begin, VK_SUBPASS_CONTENTS_INLINE);
VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F}; VkViewport viewport{0.0F, 0.0F, static_cast<float>(swapchain_extent_.width), static_cast<float>(swapchain_extent_.height), 0.0F, 1.0F};
@@ -1433,6 +1791,9 @@ export namespace ra3::vulkan {
std::vector<VkImage> swapchain_images_; std::vector<VkImage> swapchain_images_;
std::vector<VkImageView> swapchain_views_; std::vector<VkImageView> swapchain_views_;
std::vector<VkFramebuffer> framebuffers_; std::vector<VkFramebuffer> framebuffers_;
std::vector<VkImage> depth_images_;
std::vector<VkDeviceMemory> depth_memories_;
std::vector<VkImageView> depth_views_;
VkRenderPass render_pass_ = VK_NULL_HANDLE; VkRenderPass render_pass_ = VK_NULL_HANDLE;
VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE; VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE;
VkPipelineLayout pipeline_layout_ = VK_NULL_HANDLE; VkPipelineLayout pipeline_layout_ = VK_NULL_HANDLE;
@@ -1464,6 +1825,16 @@ export namespace ra3::vulkan {
VkPipeline terrain_pipeline_ = VK_NULL_HANDLE; VkPipeline terrain_pipeline_ = VK_NULL_HANDLE;
VkDescriptorPool terrain_pool_ = VK_NULL_HANDLE; VkDescriptorPool terrain_pool_ = VK_NULL_HANDLE;
VkDescriptorSet terrain_set_ = VK_NULL_HANDLE; VkDescriptorSet terrain_set_ = VK_NULL_HANDLE;
VkBuffer object_vertex_buffer_ = VK_NULL_HANDLE;
VkDeviceMemory object_vertex_memory_ = VK_NULL_HANDLE;
VkBuffer object_index_buffer_ = VK_NULL_HANDLE;
VkDeviceMemory object_index_memory_ = VK_NULL_HANDLE;
uint32_t object_index_count_ = 0U;
gpu_image object_texture_;
VkPipelineLayout object_pipeline_layout_ = VK_NULL_HANDLE;
VkPipeline object_pipeline_ = VK_NULL_HANDLE;
VkDescriptorSet object_set_ = VK_NULL_HANDLE;
bool objects_ready_ = false;
std::vector<VkSemaphore> image_available_; std::vector<VkSemaphore> image_available_;
std::vector<VkSemaphore> render_finished_; std::vector<VkSemaphore> render_finished_;
std::vector<VkFence> in_flight_; std::vector<VkFence> in_flight_;
+661
View File
@@ -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(); }
}
+32
View File
@@ -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)"; }
};
}
-461
View File
@@ -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;
};
}
+610
View File
@@ -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 = &target;
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)"; }
}; };
} }
+78
View File
@@ -222,6 +222,84 @@ auto main() -> int {
check(names.lookup("map_mp_2_feasel4") == "Battlebase Beta", "CSF map names decode (byte-XOR 0xFF)"); check(names.lookup("map_mp_2_feasel4") == "Battlebase Beta", "CSF map names decode (byte-XOR 0xFF)");
check(names.lookup("unknown_map") == "unknown_map", "an unknown id falls back to itself"); check(names.lookup("unknown_map") == "unknown_map", "an unknown id falls back to itself");
// Map objects (`ObjectsList`): decode a synthetic one-object chunk.
const auto put16 = [](std::vector<core::uint8> &out, std::uint16_t v) {
out.push_back(static_cast<core::uint8>(v));
out.push_back(static_cast<core::uint8>(v >> 8U));
};
const auto put32 = [](std::vector<core::uint8> &out, std::uint32_t v) {
for (int i = 0; i < 4; ++i) out.push_back(static_cast<core::uint8>(v >> (8 * i)));
};
const auto putf = [&put32](std::vector<core::uint8> &out, float f) {
std::uint32_t bits = 0;
std::memcpy(&bits, &f, sizeof(bits));
put32(out, bits);
};
std::vector<core::uint8> object_data;
putf(object_data, 100.0F);
putf(object_data, 200.0F);
putf(object_data, 0.0F);
putf(object_data, 0.5F);
put32(object_data, 0U); // road type
const std::string object_type = "BB_TEST";
put16(object_data, static_cast<std::uint16_t>(object_type.size()));
object_data.insert(object_data.end(), object_type.begin(), object_type.end());
put16(object_data, 0U); // no properties
std::vector<core::uint8> object_asset;
put32(object_asset, 1U); // asset index
put16(object_asset, 1U); // version
put32(object_asset, static_cast<std::uint32_t>(object_data.size()));
object_asset.insert(object_asset.end(), object_data.begin(), object_data.end());
std::vector<core::uint8> ckmp;
ckmp.insert(ckmp.end(), {'C', 'k', 'M', 'p'});
put32(ckmp, 1U); // one asset name
const std::string chunk_name = "ObjectsList";
ckmp.push_back(static_cast<core::uint8>(chunk_name.size()));
ckmp.insert(ckmp.end(), chunk_name.begin(), chunk_name.end());
put32(ckmp, 1U); // name index
put32(ckmp, 1U); // chunk index
put16(ckmp, 3U); // chunk version
put32(ckmp, static_cast<std::uint32_t>(object_asset.size()));
ckmp.insert(ckmp.end(), object_asset.begin(), object_asset.end());
const auto objects = map::parse_objects(ckmp);
check(objects.size() == 1U, "ObjectsList parses one object");
check(!objects.empty() && objects[0].type == "BB_TEST", "object type-name decodes");
check(!objects.empty() && objects[0].x == 100.0F && objects[0].y == 200.0F && objects[0].angle == 0.5F, "object position/angle decode");
// DDS: a 1x1 uncompressed RGB32 image decodes with correct channels.
std::vector<core::uint8> dds(128U, 0U);
dds[0] = 'D';
dds[1] = 'D';
dds[2] = 'S';
dds[3] = ' ';
const auto put_dds = [&dds](std::size_t off, std::uint32_t v) {
for (int i = 0; i < 4; ++i) dds[off + static_cast<std::size_t>(i)] = static_cast<core::uint8>(v >> (8 * i));
};
put_dds(4U, 124U);
put_dds(8U, 0x1U); // flags: DDSD_CAPS|... (unused)
put_dds(12U, 1U); // height
put_dds(16U, 1U); // width
put_dds(76U, 32U); // pixel format size
put_dds(80U, 0x40U); // DDPF_RGB
put_dds(84U, 0U); // no fourcc
put_dds(88U, 32U); // bits per pixel
put_dds(92U, 0x00FF0000U);
put_dds(96U, 0x0000FF00U);
put_dds(100U, 0x000000FFU);
put_dds(104U, 0xFF000000U);
dds.push_back(0x00U); // B
dds.push_back(0x00U); // G
dds.push_back(0xFFU); // R
dds.push_back(0xFFU); // A
const auto decoded_dds = models::decode_dds(dds);
check(decoded_dds.width() == 1U && decoded_dds.height() == 1U, "DDS dimensions decode");
check(!decoded_dds.empty() && decoded_dds.data()[0] == render::argb(255, 0, 0), "DDS RGB32 channels map to ARGB");
check(models::decode_dds(std::vector<core::uint8>{1U, 2U, 3U}).empty(), "a non-DDS payload yields no image");
if (failures == 0) { if (failures == 0) {
std::puts("ra3_tests: OK"); std::puts("ra3_tests: OK");
} }