Compare commits
5
Commits
v0.5.0
...
8047e80ed6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8047e80ed6 | ||
|
|
70f35d8382 | ||
|
|
6e5df350c7 | ||
|
|
889ce945f2 | ||
|
|
70d4beca4f |
+45
-2
@@ -8,6 +8,9 @@ variables:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
# apt mirror used inside both images (override per pipeline if needed)
|
||||
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.
|
||||
HARBOR_HOST: "192.168.1.11:9090"
|
||||
HARBOR_PROJECT: "openra3"
|
||||
@@ -178,7 +181,7 @@ debian_image:
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-debian,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 \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-debian \
|
||||
-f Dockerfile.debian .
|
||||
@@ -197,7 +200,7 @@ debian_arm64_image:
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-debian-arm64,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 \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-debian-arm64 \
|
||||
-f Dockerfile.debian-arm64 .
|
||||
@@ -292,3 +295,43 @@ build_debian_arm64:
|
||||
paths:
|
||||
- artifacts/debian-arm64/
|
||||
expire_in: 7 days
|
||||
# --- WebAssembly (Emscripten) -------------------------------------------------
|
||||
wasm_image:
|
||||
stage: image
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- |
|
||||
retry docker build \
|
||||
--cache-from type=registry,ref=$IMAGE:cache-wasm,insecure=true \
|
||||
--cache-to type=registry,ref=$IMAGE:cache-wasm,mode=max,insecure=true \
|
||||
--build-arg APT_MIRROR=$APT_MIRROR \
|
||||
--target dev \
|
||||
-t $IMAGE:$CI_COMMIT_SHORT_SHA-wasm \
|
||||
-f Dockerfile.wasm .
|
||||
- retry docker push $IMAGE:$CI_COMMIT_SHORT_SHA-wasm
|
||||
|
||||
build_wasm:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
services: *dind
|
||||
tags:
|
||||
- docker
|
||||
needs:
|
||||
- wasm_image
|
||||
before_script: *retry
|
||||
script:
|
||||
- retry sh -c 'echo "$HARBOR_PASSWORD" | docker login "$HARBOR_HOST" -u "$HARBOR_USERNAME" --password-stdin'
|
||||
- retry docker pull $IMAGE:$CI_COMMIT_SHORT_SHA-wasm
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake -S . -B build/wasm -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm cmake --build build/wasm -j
|
||||
- docker run --rm -v "$(pwd):/work" -w /work $IMAGE:$CI_COMMIT_SHORT_SHA-wasm bash -c "mkdir -p artifacts/wasm && cp build/wasm/bin/index.html build/wasm/bin/openra3.js build/wasm/bin/openra3.wasm build/wasm/bin/openra3.worker.js artifacts/wasm/ && cp build/wasm/bin/openra3.data artifacts/wasm/ 2>/dev/null || true"
|
||||
artifacts:
|
||||
name: "$CI_PROJECT_NAME-wasm-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- artifacts/wasm/
|
||||
expire_in: 7 days
|
||||
+187
-13
@@ -7,8 +7,9 @@ set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
|
||||
|
||||
# Path to the standard-library module description. A cross toolchain overrides
|
||||
# this before the compiler is probed; this default targets the Linux LLVM
|
||||
# package.
|
||||
if(NOT CMAKE_CXX_STDLIB_MODULES_JSON)
|
||||
# package. Skipped for Emscripten, whose toolchain supplies its own sysroot
|
||||
# modules.json.
|
||||
if(NOT CMAKE_CXX_STDLIB_MODULES_JSON AND NOT DEFINED ENV{EMSDK})
|
||||
set(CMAKE_CXX_STDLIB_MODULES_JSON "/usr/lib/llvm-21/lib/libc++.modules.json")
|
||||
endif()
|
||||
|
||||
@@ -25,7 +26,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_CXX_SCAN_FOR_MODULES ON)
|
||||
|
||||
project(OpenRA3 VERSION 0.5.0 LANGUAGES C CXX)
|
||||
project(OpenRA3 VERSION 0.8.0 LANGUAGES C CXX)
|
||||
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
@@ -55,7 +56,11 @@ set(OPENRA3_HAS_SDL3 OFF)
|
||||
# The triple-named subdirectory inside a MinGW SDL3 development package
|
||||
# (`<root>/<triple>/{include,lib,bin}`); x86_64 unless a toolchain overrides it.
|
||||
set(OPENRA3_SDL3_MINGW_TRIPLE "x86_64-w64-mingw32" CACHE STRING "SDL3 MinGW triple subdirectory")
|
||||
if(OPENRA3_SDL3_ROOT)
|
||||
if(EMSCRIPTEN)
|
||||
# The wasm build runs the engine on a Web Worker and renders into an
|
||||
# OffscreenCanvas (ra3.webgpu / ra3.wasmgl), so it links no SDL: SDL3's
|
||||
# Emscripten port is main-thread DOM only and cannot run in a worker.
|
||||
elseif(OPENRA3_SDL3_ROOT)
|
||||
target_include_directories(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/include")
|
||||
target_link_libraries(openra3_sdl3 INTERFACE "${OPENRA3_SDL3_ROOT}/${OPENRA3_SDL3_MINGW_TRIPLE}/lib/libSDL3.dll.a")
|
||||
set(OPENRA3_HAS_SDL3 ON)
|
||||
@@ -78,9 +83,15 @@ endif()
|
||||
# --- Vulkan: vendored volk + headers, so no Vulkan SDK is needed ---------------
|
||||
# The loader is resolved at runtime (volk dlopen/LoadLibrary), which lets the
|
||||
# same source build the Vulkan backend on Linux and on the MinGW Windows target.
|
||||
option(OPENRA3_VULKAN "Build the Vulkan viewer" ON)
|
||||
# WebAssembly has no Vulkan, so the viewer is off there (the null backend).
|
||||
if(EMSCRIPTEN)
|
||||
set(OPENRA3_VULKAN_DEFAULT OFF)
|
||||
else()
|
||||
set(OPENRA3_VULKAN_DEFAULT ON)
|
||||
endif()
|
||||
option(OPENRA3_VULKAN "Build the Vulkan viewer" ${OPENRA3_VULKAN_DEFAULT})
|
||||
set(OPENRA3_HAS_VULKAN OFF)
|
||||
if(OPENRA3_VULKAN AND OPENRA3_HAS_SDL3)
|
||||
if(OPENRA3_VULKAN AND OPENRA3_HAS_SDL3 AND NOT EMSCRIPTEN)
|
||||
set(OPENRA3_HAS_VULKAN ON)
|
||||
endif()
|
||||
if(OPENRA3_HAS_VULKAN)
|
||||
@@ -89,9 +100,11 @@ else()
|
||||
message(STATUS "OpenRA3: Vulkan viewer disabled - offscreen image only")
|
||||
endif()
|
||||
|
||||
add_library(ra3_volk STATIC third_party/volk/volk.c)
|
||||
target_include_directories(ra3_volk PUBLIC third_party/volk third_party/vulkan/include)
|
||||
target_compile_definitions(ra3_volk PUBLIC VK_NO_PROTOTYPES)
|
||||
if(NOT EMSCRIPTEN)
|
||||
add_library(ra3_volk STATIC third_party/volk/volk.c)
|
||||
target_include_directories(ra3_volk PUBLIC third_party/volk third_party/vulkan/include)
|
||||
target_compile_definitions(ra3_volk PUBLIC VK_NO_PROTOTYPES)
|
||||
endif()
|
||||
|
||||
# Embed the committed SPIR-V blobs into a generated header at configure time.
|
||||
# Regenerate with shaders/compile.sh after editing a .vert/.frag.
|
||||
@@ -135,6 +148,44 @@ function(openra3_embed_hlsl out_header)
|
||||
file(WRITE "${out_header}" "${content}")
|
||||
endfunction()
|
||||
|
||||
# Embed the GLSL ES sources for the WebGL backend as string literals (compiled
|
||||
# at runtime by the browser's GL, like the HLSL for Direct3D).
|
||||
function(openra3_embed_glsl out_header)
|
||||
set(content "// Generated by CMake from shaders/*.glsl.\n")
|
||||
string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n")
|
||||
foreach(src IN LISTS ARGN)
|
||||
if(NOT EXISTS "${src}")
|
||||
message(FATAL_ERROR "Missing GLSL shader ${src}")
|
||||
endif()
|
||||
get_filename_component(base "${src}" NAME_WE)
|
||||
string(MAKE_C_IDENTIFIER "${base}" ident)
|
||||
string(APPEND ident "_glsl")
|
||||
file(READ "${src}" text)
|
||||
string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3GLSL(${text})RA3GLSL\";\n\n")
|
||||
endforeach()
|
||||
string(APPEND content "} // namespace ra3_shaders\n")
|
||||
file(WRITE "${out_header}" "${content}")
|
||||
endfunction()
|
||||
|
||||
# Embed the WGSL sources for the WebGPU backend as string literals (compiled at
|
||||
# runtime by the browser's WebGPU, like the GLSL/HLSL for the other backends).
|
||||
function(openra3_embed_wgsl out_header)
|
||||
set(content "// Generated by CMake from shaders/*.wgsl.\n")
|
||||
string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n")
|
||||
foreach(src IN LISTS ARGN)
|
||||
if(NOT EXISTS "${src}")
|
||||
message(FATAL_ERROR "Missing WGSL shader ${src}")
|
||||
endif()
|
||||
get_filename_component(base "${src}" NAME_WE)
|
||||
string(MAKE_C_IDENTIFIER "${base}" ident)
|
||||
string(APPEND ident "_wgsl")
|
||||
file(READ "${src}" text)
|
||||
string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3WGSL(${text})RA3WGSL\";\n\n")
|
||||
endforeach()
|
||||
string(APPEND content "} // namespace ra3_shaders\n")
|
||||
file(WRITE "${out_header}" "${content}")
|
||||
endfunction()
|
||||
|
||||
# --- logging (vendored libenderlog, MIT) -------------------------------------
|
||||
# A standalone C++26 module logger (`import ender.log;`) with a per-run
|
||||
# archiving file sink. libc++ has no <stacktrace>, so on this toolchain the
|
||||
@@ -204,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)
|
||||
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).
|
||||
target_link_libraries(ra3_client PUBLIC ra3_render ra3_terrain)
|
||||
|
||||
@@ -227,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/terrain.vert.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.
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/generated/scene.vert.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.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_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)
|
||||
@@ -275,17 +339,92 @@ else()
|
||||
endif()
|
||||
openra3_target_defaults(ra3_dx)
|
||||
|
||||
# --- wasm worker viewer (SDL-free OffscreenCanvas + WebGL2, or null fallback) --
|
||||
# The default wasm backend: the engine runs on a plain Web Worker and renders
|
||||
# into an OffscreenCanvas transferred from the page, with no SDL. Non-wasm builds
|
||||
# link the null stub (init fails, so desktop backends are unaffected).
|
||||
option(OPENRA3_WASMGL "Build the SDL-free wasm worker viewer (Emscripten)" ON)
|
||||
set(OPENRA3_HAS_WASMGL OFF)
|
||||
if(OPENRA3_WASMGL AND EMSCRIPTEN)
|
||||
set(OPENRA3_HAS_WASMGL ON)
|
||||
endif()
|
||||
add_library(ra3_wasmgl STATIC)
|
||||
if(OPENRA3_HAS_WASMGL)
|
||||
message(STATUS "OpenRA3: SDL-free wasm worker viewer enabled")
|
||||
openra3_embed_glsl(
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/generated/wasmgl_glsl_embedded.hpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_frag.glsl"
|
||||
)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_scene_frag.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_vert.glsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgl_terrain_frag.glsl")
|
||||
target_sources(ra3_wasmgl PUBLIC FILE_SET CXX_MODULES FILES src/wasmgl/ra3.wasmgl.cppm)
|
||||
target_include_directories(ra3_wasmgl PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
target_link_libraries(ra3_wasmgl PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog)
|
||||
# Target WebGL2 so GLES3-only entry points (glTexImage3D, VAOs, ...) link.
|
||||
target_link_options(ra3_wasmgl PUBLIC -sMIN_WEBGL_VERSION=2 -sMAX_WEBGL_VERSION=2)
|
||||
else()
|
||||
message(STATUS "OpenRA3: SDL-free wasm worker viewer disabled (non-wasm) - null backend")
|
||||
target_sources(ra3_wasmgl PUBLIC FILE_SET CXX_MODULES FILES src/wasmgl/ra3.wasmgl.null.cppm)
|
||||
target_link_libraries(ra3_wasmgl PUBLIC ra3_core ra3_render ra3_client)
|
||||
endif()
|
||||
openra3_target_defaults(ra3_wasmgl)
|
||||
|
||||
# --- wasm WebGPU viewer (Dawn emdawnwebgpu, or null fallback) ------------------
|
||||
# A peer of ra3.wasmgl in the same Web Worker; web WebGPU is WGSL-only, so it has
|
||||
# its own WGSL shaders (the Vulkan SPIR-V blobs cannot be used on the web). The
|
||||
# `--use-port=emdawnwebgpu` port is downloaded/cached by Emscripten and linked
|
||||
# into the final module.
|
||||
option(OPENRA3_WEBGPU "Build the WebGPU wasm viewer (Emscripten emdawnwebgpu)" ON)
|
||||
set(OPENRA3_HAS_WEBGPU OFF)
|
||||
if(OPENRA3_WEBGPU AND EMSCRIPTEN)
|
||||
set(OPENRA3_HAS_WEBGPU ON)
|
||||
endif()
|
||||
add_library(ra3_webgpu STATIC)
|
||||
if(OPENRA3_HAS_WEBGPU)
|
||||
message(STATUS "OpenRA3: WebGPU wasm viewer enabled (emdawnwebgpu)")
|
||||
openra3_embed_wgsl(
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/generated/webgpu_wgsl_embedded.hpp"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_scene.wgsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_terrain.wgsl"
|
||||
)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_scene.wgsl"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/shaders/webgpu_terrain.wgsl")
|
||||
target_sources(ra3_webgpu PUBLIC FILE_SET CXX_MODULES FILES src/webgpu/ra3.webgpu.cppm)
|
||||
target_include_directories(ra3_webgpu PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
# CMake's module dependency scanner does not process `--use-port`, so point
|
||||
# the include path at the (image-baked) port package explicitly.
|
||||
set(OPENRA3_EMDAWNWEBGPU_DIR "$ENV{EMSDK}/upstream/emscripten/cache/ports/emdawnwebgpu/emdawnwebgpu_pkg")
|
||||
target_include_directories(ra3_webgpu SYSTEM PRIVATE
|
||||
"${OPENRA3_EMDAWNWEBGPU_DIR}/webgpu_cpp/include"
|
||||
"${OPENRA3_EMDAWNWEBGPU_DIR}/webgpu/include")
|
||||
target_compile_options(ra3_webgpu PUBLIC --use-port=emdawnwebgpu)
|
||||
target_link_options(ra3_webgpu PUBLIC --use-port=emdawnwebgpu)
|
||||
target_link_libraries(ra3_webgpu PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_wasmgl ra3_enderlog)
|
||||
else()
|
||||
message(STATUS "OpenRA3: WebGPU wasm viewer disabled (non-wasm) - null backend")
|
||||
target_sources(ra3_webgpu PUBLIC FILE_SET CXX_MODULES FILES src/webgpu/ra3.webgpu.null.cppm)
|
||||
target_link_libraries(ra3_webgpu PUBLIC ra3_core ra3_render ra3_client)
|
||||
endif()
|
||||
openra3_target_defaults(ra3_webgpu)
|
||||
|
||||
# --- display backend selection ------------------------------------------------
|
||||
add_library(ra3_display STATIC)
|
||||
target_sources(ra3_display PUBLIC FILE_SET CXX_MODULES FILES src/display/ra3.display.cppm)
|
||||
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx)
|
||||
target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx ra3_wasmgl ra3_webgpu)
|
||||
openra3_target_defaults(ra3_display)
|
||||
|
||||
# --- umbrella -----------------------------------------------------------------
|
||||
add_library(ra3 STATIC)
|
||||
target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm)
|
||||
target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_data ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_terrain ra3_display
|
||||
ra3_ui ra3_vulkan ra3_dx ra3_enderlog)
|
||||
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_wasmgl ra3_webgpu ra3_enderlog)
|
||||
openra3_target_defaults(ra3)
|
||||
|
||||
# --- executable ---------------------------------------------------------------
|
||||
@@ -300,6 +439,41 @@ if(WIN32 AND OPENRA3_SDL3_ROOT)
|
||||
"$<TARGET_FILE_DIR:openra3>")
|
||||
endif()
|
||||
|
||||
if(EMSCRIPTEN)
|
||||
# Emit openra3.js/.wasm. The engine runs inside apps/web/openra3.worker.js
|
||||
# (a plain Web Worker), which the page (apps/web/index.html) loads and hands
|
||||
# an OffscreenCanvas; so there is no Emscripten HTML shell here - both web
|
||||
# files are copied next to the module.
|
||||
set_target_properties(openra3 PROPERTIES SUFFIX ".js")
|
||||
target_link_options(openra3 PRIVATE
|
||||
-sFORCE_FILESYSTEM=1
|
||||
# index.html input glue calls the exported openra3_* functions via ccall.
|
||||
"-sEXPORTED_RUNTIME_METHODS=ccall,cwrap")
|
||||
add_custom_command(TARGET openra3 POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/apps/web/index.html"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/apps/web/openra3.worker.js"
|
||||
"$<TARGET_FILE_DIR:openra3>"
|
||||
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")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# --- packaging (.deb / .msi / portable archives) -----------------------------
|
||||
# CPack produces the release artifacts in one shot:
|
||||
# Linux -> DEB (dependencies derived from the binary with dpkg-shlibdeps)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# WebAssembly build environment (Emscripten), producing openra3.js/.wasm plus the
|
||||
# `index.html` page and `openra3.worker.js` bootstrap. The engine runs on a plain
|
||||
# Web Worker (WebGL2 and WebGPU backends), with lazy on-demand assets.
|
||||
#
|
||||
# The emsdk image ships CMake 3.28, whose experimental `import std` support is
|
||||
# too old for the project, so an upstream CMake is layered on top.
|
||||
|
||||
FROM emscripten/emsdk:6.0.10 AS dev
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG APT_MIRROR=""
|
||||
RUN if [ -n "$APT_MIRROR" ]; then \
|
||||
sed -i -e "s|http://archive.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://security.ubuntu.com/ubuntu|${APT_MIRROR}|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|${APT_MIRROR}|g" \
|
||||
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=5 update && break; \
|
||||
echo "apt-get update failed (attempt $attempt), retrying"; \
|
||||
sleep 5; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=5 install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
ninja-build \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Upstream CMake, because the emsdk image's 3.28 cannot build the project.
|
||||
ARG CMAKE_VERSION=4.2.3
|
||||
RUN mkdir -p /opt/cmake \
|
||||
&& curl -fL "https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz" \
|
||||
| tar -xz -C /opt/cmake --strip-components=1
|
||||
ENV PATH="/opt/cmake/bin:${PATH}"
|
||||
|
||||
# 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
|
||||
COPY . .
|
||||
|
||||
RUN cmake -S . -B build/wasm -G Ninja \
|
||||
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
&& cmake --build build/wasm -j
|
||||
@@ -18,19 +18,23 @@ Ghidra.
|
||||
|
||||
## Status
|
||||
|
||||
OpenRA3 is at **v0.5.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,
|
||||
recovers the player start waypoints, and simulates two sides building a base,
|
||||
extracting ore and fighting until one side is wiped out. The balance is the
|
||||
**retail Red Alert 3 balance**, pinned in `ra3.data` from EA's open RA3 XML:
|
||||
damage types, `ArmorTemplate` percentages, weapon target masks, build costs and
|
||||
the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`) and **Direct3D 11 / 12**
|
||||
(`ra3.dx`) GPU backends with an SDL software blit and null fallbacks; the
|
||||
backend is selectable from the in-game menu and the software renderer still
|
||||
produces headless images. Terrain tiles
|
||||
the ore economy. Presentation offers **Vulkan** (`ra3.vulkan`), **Direct3D 11 / 12**
|
||||
(`ra3.dx`), **WebGPU** (`ra3.webgpu`, the wasm worker backend) and **WebGL 2**
|
||||
(`ra3.wasmgl`, the SDL-free wasm worker fallback) GPU backends with an SDL
|
||||
software blit and null fallbacks; the backend is selectable from the in-game menu
|
||||
and the software renderer still produces headless images. Terrain tiles
|
||||
cross-fade the way the retail `Terrain.fx` does (a per-cell blend ramp plus a
|
||||
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
|
||||
builds for **Linux** (clang + libc++) and cross-compiles to **Windows**
|
||||
(`openra3.exe` + `SDL3.dll`) with llvm-mingw — both using C++26 modules and
|
||||
@@ -85,17 +89,20 @@ built-in test map so the project still builds and runs in CI.
|
||||
| `src/core/ra3.core.cppm` | fundamental types, math, strings, random, message stream |
|
||||
| `src/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop |
|
||||
| `src/client/ra3.client.cppm` | `display` abstraction + shared interactive loops + client facade |
|
||||
| `src/display/ra3.display.cppm` | picks the backend (Vulkan/D3D11/D3D12, then SDL) for the app |
|
||||
| `src/display/ra3.display.cppm` | picks the backend (Vulkan/D3D11/D3D12/WebGL, then SDL) for the app |
|
||||
| `src/game/ra3.game.cppm` | RA3 sides, player templates, skirmish defaults |
|
||||
| `src/data/ra3.data.cppm` | retail RA3 balance: damage types, armour, weapons, units, economy |
|
||||
| `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator |
|
||||
| `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/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/ui/ra3.ui.*.cppm` | SDL3 window viewer and menu (null backend when SDL3 is absent) |
|
||||
| `src/vulkan/ra3.vulkan.*.cppm` | Vulkan presentation backend and menu (null fallback without a loader) |
|
||||
| `src/dx/ra3.dx.*.cppm` | Direct3D 11/12 presentation backend (runtime HLSL; null fallback off Windows) |
|
||||
| `src/wasmgl/ra3.wasmgl.*.cppm` | SDL-free wasm backend: engine on a Web Worker, WebGL2 on an OffscreenCanvas, lazy assets (null fallback off Emscripten) |
|
||||
| `src/webgpu/ra3.webgpu.*.cppm` | WebGPU wasm backend (Dawn `emdawnwebgpu`, WGSL; the default on wasm, falls back to `ra3.wasmgl`) |
|
||||
| `third_party/libenderlog/` | vendored C++26 module logger (`import ender.log;`, MIT) with a native stack fallback for libc++ |
|
||||
| `src/ra3.cppm` | umbrella module re-exporting the SDK |
|
||||
| `apps/openra3/main.cpp` | `menu` / `maps` / `skirmish` / `render` CLI |
|
||||
@@ -139,6 +146,9 @@ scripts/build-windows.sh
|
||||
|
||||
# Windows (arm64) -> build/windows-arm64/bin/openra3.exe + SDL3.dll, .zip (cross)
|
||||
scripts/build-windows-arm64.sh
|
||||
|
||||
# WebAssembly -> build/wasm/bin/index.html (+ openra3.js/.wasm, openra3.worker.js)
|
||||
scripts/build-wasm.sh
|
||||
```
|
||||
|
||||
Every target is cross-built from x86_64 Linux in its own image: llvm-mingw for
|
||||
@@ -176,6 +186,42 @@ openra3.exe render --game-dir "C:\Red Alert 3" --vulkan
|
||||
Vulkan is provided by **vendored volk + headers** (`third_party/`), resolved at
|
||||
runtime, so neither image needs a Vulkan SDK.
|
||||
|
||||
## WebAssembly
|
||||
|
||||
OpenRA3 also builds to **WebAssembly** with Emscripten (`scripts/build-wasm.sh`),
|
||||
producing `openra3.js` + `.wasm` plus the `index.html` page and
|
||||
`openra3.worker.js` bootstrap. The browser gets the same GPU renderer as the
|
||||
desktop builds: the engine runs on a **Web Worker** (no SDL, no blocking of the
|
||||
page) and draws into an **OffscreenCanvas** handed over by the page. The default
|
||||
wasm backend is **`ra3.webgpu`** (WebGPU, WGSL shaders via Emscripten's
|
||||
`emdawnwebgpu` port); it falls back to **`ra3.wasmgl`** (WebGL2, the GLSL ES
|
||||
ports of the desktop shaders) when WebGPU is unavailable. The page keeps the DOM
|
||||
(input, resize) and forwards events to the worker, so the main thread stays
|
||||
responsive.
|
||||
|
||||
Because the runtime's main thread lives in the worker, its filesystem is local
|
||||
to it and assets load **on demand**: point `OPENRA3_WEB_ASSETS` at the extracted
|
||||
asset tree and the build embeds an `assets.manifest.json`; at startup the worker
|
||||
registers every listed file as a lazy file, so the browser fetches a file only
|
||||
when the engine first opens it (entering a map pulls just that map and the tiles
|
||||
it uses, not the whole tree).
|
||||
|
||||
```bash
|
||||
# build (generates the manifest from the extracted assets, embeds it)
|
||||
OPENRA3_WEB_ASSETS=/path/to/assets scripts/build-wasm.sh
|
||||
|
||||
# serve the build output (Range-capable, COOP/COEP; --assets is the lazy tree)
|
||||
python3 apps/web/serve.py --root build/wasm/bin --assets /path/to/assets
|
||||
# open http://localhost:8199/index.html
|
||||
```
|
||||
|
||||
The engine's frame loops are blocking; the wasm build yields to the worker's
|
||||
event loop via Asyncify (`display::sleep_frame` calls `emscripten_sleep`), so
|
||||
the page repaints and handles input. Log records go to `console.log` at their
|
||||
real level (the console sink uses stdout on the web instead of stderr). Without
|
||||
`OPENRA3_WEB_ASSETS` the module still loads and starts, but exits at the menu
|
||||
because no maps are found.
|
||||
|
||||
## Packages
|
||||
|
||||
CPack produces the release artifacts; the Docker images and CI run it for every
|
||||
@@ -248,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
|
||||
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
|
||||
never bleeds between tiles. 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.
|
||||
never bleeds between tiles. It also draws the **buildings and props the map
|
||||
places**: the `ObjectsList` chunk is decoded into `(type, position, angle)`
|
||||
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):
|
||||
|
||||
|
||||
+139
-10
@@ -95,7 +95,15 @@ namespace {
|
||||
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
|
||||
namespace log = ender::log;
|
||||
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// The default sink writes to stderr, which the browser maps to
|
||||
// console.error regardless of level; use stdout so INFO/WARN appear at
|
||||
// their real level. There is no file sink on the web.
|
||||
log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
|
||||
(void) exe_dir;
|
||||
#else
|
||||
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
|
||||
#endif
|
||||
log::info("OpenRA3 started");
|
||||
}
|
||||
|
||||
@@ -106,13 +114,14 @@ namespace {
|
||||
std::puts(" openra3 menu (pick a map, then view it)");
|
||||
std::puts(" openra3 menu-preview [--out FILE.bmp] (headless render of the menu)");
|
||||
std::puts(" openra3 maps");
|
||||
std::puts(" openra3 textures --map ID (loose terrain TGAs a map uses)");
|
||||
std::puts(" openra3 skirmish [--map ID] [--frames N] [--seed N]");
|
||||
std::puts(" openra3 render [--map ID] [--frames N] [--seed N] [--zoom Z]");
|
||||
std::puts(" [--out FILE.bmp] [--vulkan] [--no-window] [--no-sim] [--thumbnail]");
|
||||
std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]");
|
||||
std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]");
|
||||
std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
|
||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--sdl] (preferred display backend)");
|
||||
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgpu] [--wasmgl] [--sdl] (preferred display backend)");
|
||||
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
|
||||
}
|
||||
|
||||
@@ -131,6 +140,8 @@ namespace {
|
||||
[[nodiscard]] auto display_backend_from_args(const std::vector<std::string> &args) -> ra3::display::backend {
|
||||
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) return ra3::display::backend::d3d11;
|
||||
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12;
|
||||
if (has_flag(args, "--webgpu")) return ra3::display::backend::webgpu;
|
||||
if (has_flag(args, "--wasmgl")) return ra3::display::backend::wasmgl;
|
||||
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
|
||||
return ra3::display::backend::vulkan;
|
||||
}
|
||||
@@ -139,7 +150,9 @@ namespace {
|
||||
auto executable_dir(const char *argv0) -> std::filesystem::path {
|
||||
std::error_code ec;
|
||||
const auto path = std::filesystem::absolute(argv0, ec);
|
||||
return ec ? std::filesystem::current_path() : path.parent_path();
|
||||
// lexically_normal drops the "." element Emscripten leaves in argv[0]
|
||||
// ("/./openra3.js" -> "/"), which the wasm FS does not collapse itself.
|
||||
return ec ? std::filesystem::current_path() : path.parent_path().lexically_normal();
|
||||
}
|
||||
|
||||
auto read_file(const std::filesystem::path &path) -> std::vector<ra3::core::uint8> {
|
||||
@@ -187,6 +200,79 @@ namespace {
|
||||
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. */
|
||||
auto write_map_names(const std::filesystem::path &data_dir, const std::filesystem::path &assets) -> bool {
|
||||
try {
|
||||
@@ -296,6 +382,32 @@ namespace {
|
||||
return ready;
|
||||
}
|
||||
|
||||
/** Print the loose terrain TGA files a map resolves to (for a wasm preload). */
|
||||
auto command_textures(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
|
||||
if (!ensure_assets(assets)) return 1;
|
||||
const auto requested = option_value(args, "--map");
|
||||
if (!requested) {
|
||||
std::puts("usage: openra3 textures --map ID");
|
||||
return 2;
|
||||
}
|
||||
const auto maps = list_asset_maps(assets);
|
||||
const asset_map *picked = nullptr;
|
||||
for (const auto &map: maps) {
|
||||
if (map.id == *requested) picked = ↦
|
||||
}
|
||||
if (picked == nullptr) {
|
||||
std::printf("no map '%s'\n", requested->c_str());
|
||||
return 2;
|
||||
}
|
||||
const auto parsed = ra3::terrain::parse_map(ra3::map::to_ckmp(read_file(picked->file)));
|
||||
const auto files = ra3::terrain::resolve_texture_files(parsed, assets / "terrain");
|
||||
std::printf("map %s: %zu resolved textures\n", picked->id.c_str(), files.size());
|
||||
for (const auto &file: files) {
|
||||
std::printf("%s\n", file.string().c_str());
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
auto command_maps(const std::filesystem::path &assets) -> int {
|
||||
if (!ensure_assets(assets)) return 1;
|
||||
const auto maps = list_asset_maps(assets);
|
||||
@@ -426,8 +538,9 @@ namespace {
|
||||
|
||||
const bool offscreen = option_value(args, "--out").has_value() || has_flag(args, "--no-window");
|
||||
bool gpu_shown = false;
|
||||
const auto objects = build_object_scene(assets, picked->id, terrain, terrain_options, bytes);
|
||||
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;
|
||||
options.title = scene.title;
|
||||
options.width = static_cast<int>(width);
|
||||
@@ -447,7 +560,7 @@ namespace {
|
||||
}
|
||||
if (offscreen) {
|
||||
// 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;
|
||||
} else {
|
||||
// Windowed without a GPU: the cheap top-down raster.
|
||||
@@ -574,9 +687,12 @@ namespace {
|
||||
* then the others in turn, so an unavailable backend degrades gracefully.
|
||||
* Direct3D is Windows-only; on other hosts those entries fall through.
|
||||
*/
|
||||
inline constexpr std::array<ra3::display::backend, 4> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||
ra3::display::backend::d3d12, ra3::display::backend::sdl};
|
||||
inline constexpr std::array<std::string_view, 4> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"};
|
||||
inline constexpr std::array<ra3::display::backend, 6> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
||||
ra3::display::backend::d3d12, ra3::display::backend::webgpu,
|
||||
ra3::display::backend::wasmgl, ra3::display::backend::sdl};
|
||||
inline constexpr std::array<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 {
|
||||
return renderer_backends[static_cast<std::size_t>(std::clamp(index, 0, static_cast<int>(renderer_backends.size()) - 1))];
|
||||
@@ -661,7 +777,7 @@ namespace {
|
||||
case 15: return s.fullscreen ? "yes" : "no";
|
||||
case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps";
|
||||
case 17: return "start";
|
||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, 3))]};
|
||||
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, renderer_count - 1))]};
|
||||
default: return "exit";
|
||||
}
|
||||
}
|
||||
@@ -684,7 +800,7 @@ namespace {
|
||||
case 13: s.thumbnail = !s.thumbnail; break;
|
||||
case 15: s.fullscreen = !s.fullscreen; break;
|
||||
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
|
||||
case 18: s.renderer = (s.renderer + delta + 4) % 4; break;
|
||||
case 18: s.renderer = (s.renderer + delta + renderer_count) % renderer_count; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
@@ -1084,7 +1200,9 @@ namespace {
|
||||
if (has_flag(args, "--vulkan")) st.settings.renderer = 0;
|
||||
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1;
|
||||
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2;
|
||||
if (has_flag(args, "--sdl")) st.settings.renderer = 3;
|
||||
if (has_flag(args, "--webgpu")) st.settings.renderer = 3;
|
||||
if (has_flag(args, "--wasmgl")) st.settings.renderer = 4;
|
||||
if (has_flag(args, "--sdl")) st.settings.renderer = 5;
|
||||
if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value);
|
||||
if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value);
|
||||
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
|
||||
@@ -1221,6 +1339,16 @@ auto main(int argc, char **argv) -> int {
|
||||
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
|
||||
const auto assets = exe_dir / "assets";
|
||||
setup_logging(exe_dir);
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// The wasm build runs on a Web Worker (its runtime's main thread lives
|
||||
// there), where synchronous XHR is legal, so the asset tree is mounted
|
||||
// lazily from a manifest instead of being preloaded: the browser fetches
|
||||
// only the files the engine actually opens. On wasm the executable dir is
|
||||
// "/", so the manifest sits at /assets.manifest.json and the lazy file URLs
|
||||
// are "assets/<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);
|
||||
|
||||
@@ -1243,6 +1371,7 @@ auto main(int argc, char **argv) -> int {
|
||||
if (command == "menu") return command_menu(rest, assets);
|
||||
if (command == "menu-preview") return command_menu_preview(rest, assets);
|
||||
if (command == "maps") return command_maps(assets);
|
||||
if (command == "textures") return command_textures(rest, assets);
|
||||
if (command == "skirmish") return command_skirmish(rest, assets);
|
||||
if (command == "render") return command_render(rest, assets);
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<!doctype html>
|
||||
<!-- OpenRA3 web page. The engine runs inside openra3.worker.js: the page owns the
|
||||
DOM (input, resize) and transfers #canvas to the worker as an OffscreenCanvas,
|
||||
so the engine never blocks the main thread. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>OpenRA3</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #1a0a08; overflow: hidden; }
|
||||
#canvas { display: block; width: 100vw; height: 100vh; outline: none; cursor: default; }
|
||||
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #ecbe50; font: 16px/1.4 monospace; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas>
|
||||
<div id="loading">Loading OpenRA3…</div>
|
||||
<script>
|
||||
(function () {
|
||||
var canvas = document.getElementById('canvas');
|
||||
var loading = document.getElementById('loading');
|
||||
// `?nocache` busts the worker, module and wasm fetches (development),
|
||||
// so a rebuild is picked up without clearing the browser cache.
|
||||
var bust = location.search.indexOf('nocache') >= 0 ? Date.now() : 0;
|
||||
var worker = new Worker('openra3.worker.js' + (bust ? '?v=' + bust : ''));
|
||||
|
||||
var ready = false;
|
||||
var pending = []; // input queued until the runtime is up
|
||||
|
||||
function post(message) {
|
||||
if (ready) {
|
||||
worker.postMessage(message);
|
||||
} else {
|
||||
pending.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function backingSize() {
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
return {
|
||||
width: Math.max(1, Math.round(window.innerWidth * dpr)),
|
||||
height: Math.max(1, Math.round(window.innerHeight * dpr)),
|
||||
};
|
||||
}
|
||||
|
||||
worker.onmessage = function (event) {
|
||||
var data = event.data || {};
|
||||
if (data.log) { console.log(data.log); return; }
|
||||
if (data.ready) {
|
||||
ready = true;
|
||||
if (loading) loading.remove();
|
||||
for (var i = 0; i < pending.length; i++) worker.postMessage(pending[i]);
|
||||
pending = [];
|
||||
return;
|
||||
}
|
||||
if (typeof data.openra3Frames === 'number') { window.openra3Frames = data.openra3Frames; }
|
||||
};
|
||||
|
||||
// Hand the canvas to the worker as an OffscreenCanvas. After this the
|
||||
// page can no longer size or draw it; the worker owns it.
|
||||
var size = backingSize();
|
||||
var offscreen = canvas.transferControlToOffscreen();
|
||||
worker.postMessage({ canvas: offscreen, width: size.width, height: size.height, bust: bust }, [offscreen]);
|
||||
|
||||
// ---- input: the page owns the DOM, the worker owns the game --------
|
||||
window.addEventListener('keydown', function (event) {
|
||||
post({ type: 'key', code: event.code, down: true });
|
||||
if (event.code === 'Tab' || event.code === 'Space' || event.code.indexOf('Arrow') === 0) event.preventDefault();
|
||||
}, false);
|
||||
window.addEventListener('keyup', function (event) {
|
||||
post({ type: 'key', code: event.code, down: false });
|
||||
}, false);
|
||||
window.addEventListener('keypress', function (event) {
|
||||
if (event.charCode) post({ type: 'text', char: event.charCode });
|
||||
}, false);
|
||||
|
||||
canvas.addEventListener('mousedown', function (event) {
|
||||
post({ type: 'mousebutton', x: event.clientX, y: event.clientY, left: event.button === 0 });
|
||||
}, false);
|
||||
window.addEventListener('mousemove', function (event) {
|
||||
post({ type: 'mousemove', x: event.clientX, y: event.clientY, dx: event.movementX, dy: event.movementY,
|
||||
left: (event.buttons & 1) !== 0 });
|
||||
}, false);
|
||||
window.addEventListener('wheel', function (event) {
|
||||
post({ type: 'wheel', deltaY: event.deltaY });
|
||||
}, { passive: true });
|
||||
window.addEventListener('contextmenu', function (event) { event.preventDefault(); });
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
var next = backingSize();
|
||||
post({ type: 'resize', width: next.width, height: next.height });
|
||||
});
|
||||
window.addEventListener('beforeunload', function () {
|
||||
if (ready) worker.postMessage({ type: 'quit' });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
// OpenRA3 WebAssembly worker bootstrap.
|
||||
//
|
||||
// The whole Emscripten runtime runs here (not on the page main thread), so the
|
||||
// runtime's filesystem is local to this worker: `FS.createLazyFile` can use
|
||||
// synchronous XHR to fetch assets on demand without blocking the page.
|
||||
//
|
||||
// Protocol with index.html:
|
||||
// page -> worker { canvas, width, height } (once, transfers the OffscreenCanvas)
|
||||
// page -> worker { type: 'key' | 'text' | 'mousebutton' | 'mousemove' | 'wheel' | 'resize' | 'quit', ... }
|
||||
// worker -> page { log } | { ready } | { openra3Frames }
|
||||
//
|
||||
// The Emscripten runtime is started by importScripts('openra3.js') once the
|
||||
// canvas has arrived; main() then runs here and yields via Asyncify.
|
||||
|
||||
// Forward worker warnings/errors (including WebGPU validation messages from the
|
||||
// runtime) to the page, which logs them to the main console.
|
||||
(function () {
|
||||
var origError = console.error.bind(console);
|
||||
var origWarn = console.warn.bind(console);
|
||||
console.error = function () {
|
||||
origError.apply(null, arguments);
|
||||
try { self.postMessage({ log: '[worker error] ' + Array.prototype.join.call(arguments, ' ') }); } catch (e) {}
|
||||
};
|
||||
console.warn = function () {
|
||||
origWarn.apply(null, arguments);
|
||||
try { self.postMessage({ log: '[worker warn] ' + Array.prototype.join.call(arguments, ' ') }); } catch (e) {}
|
||||
};
|
||||
})();
|
||||
|
||||
self.Module = {
|
||||
print: function (text) {
|
||||
self.postMessage({ log: String(text) });
|
||||
},
|
||||
printErr: function (text) {
|
||||
self.postMessage({ log: String(text) });
|
||||
},
|
||||
onRuntimeInitialized: function () {
|
||||
self.postMessage({ ready: true });
|
||||
},
|
||||
};
|
||||
|
||||
var openra3Started = false;
|
||||
|
||||
self.onmessage = function (event) {
|
||||
var data = event.data;
|
||||
if (!data) return;
|
||||
|
||||
if (!openra3Started) {
|
||||
if (!data.canvas) return;
|
||||
openra3Started = true;
|
||||
var canvas = data.canvas;
|
||||
if (data.width && data.height) {
|
||||
canvas.width = data.width;
|
||||
canvas.height = data.height;
|
||||
}
|
||||
self.Module.canvas = canvas;
|
||||
|
||||
var start = function () {
|
||||
// `?nocache` on the page forces a fresh module + wasm fetch, so a
|
||||
// rebuild is picked up without clearing the browser cache.
|
||||
var bust = data.bust ? ('?v=' + data.bust) : '';
|
||||
if (bust) self.Module.locateFile = function (path) { return path + bust; };
|
||||
try {
|
||||
importScripts('openra3.js' + bust);
|
||||
} catch (error) {
|
||||
self.postMessage({ log: 'OpenRA3 worker failed to start: ' + error });
|
||||
}
|
||||
};
|
||||
|
||||
// The WebGPU backend starts from a device created here (`Module.
|
||||
// preinitializedWebGPUDevice`); the WebGL backend ignores it. If WebGPU
|
||||
// is unavailable the runtime falls back to WebGL.
|
||||
if (typeof navigator !== 'undefined' && navigator.gpu) {
|
||||
navigator.gpu.requestAdapter().then(function (adapter) {
|
||||
if (!adapter) { start(); return; }
|
||||
adapter.requestDevice().then(function (device) {
|
||||
self.Module.preinitializedWebGPUDevice = device;
|
||||
start();
|
||||
}).catch(start);
|
||||
}).catch(start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Input is only meaningful once the runtime has initialized its exports.
|
||||
switch (data.type) {
|
||||
case 'key':
|
||||
if (self.Module.ccall) self.Module.ccall('openra3_key', null, ['string', 'number'], [data.code, data.down ? 1 : 0]);
|
||||
break;
|
||||
case 'text':
|
||||
if (self.Module._openra3_text) self.Module._openra3_text(data.char | 0);
|
||||
break;
|
||||
case 'mousebutton':
|
||||
if (self.Module._openra3_mouse_button) self.Module._openra3_mouse_button(data.x, data.y, data.left ? 1 : 0);
|
||||
break;
|
||||
case 'mousemove':
|
||||
if (self.Module._openra3_mouse_move) self.Module._openra3_mouse_move(data.x, data.y, data.dx, data.dy, data.left ? 1 : 0);
|
||||
break;
|
||||
case 'wheel':
|
||||
if (self.Module._openra3_wheel) self.Module._openra3_wheel(data.deltaY);
|
||||
break;
|
||||
case 'resize':
|
||||
if (self.Module._openra3_resize) self.Module._openra3_resize(data.width | 0, data.height | 0);
|
||||
break;
|
||||
case 'quit':
|
||||
if (self.Module._openra3_quit) self.Module._openra3_quit();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static file server with HTTP Range support, for the OpenRA3 wasm build.
|
||||
|
||||
Emscripten's lazy file reads stream assets back as byte ranges, so the server
|
||||
must honour the ``Range`` header -- Python's stdlib ``http.server`` does not.
|
||||
|
||||
python3 apps/web/serve.py --root build/wasm/bin --port 8199
|
||||
# then open http://localhost:8199/index.html
|
||||
|
||||
Serve ``--root`` containing ``index.html``, ``openra3.js``, ``openra3.wasm``,
|
||||
``openra3.worker.js`` and ``assets.manifest.json``. The lazy asset tree is
|
||||
mounted from ``--assets`` (its files are then fetched from ``/assets/<rel>`` on
|
||||
demand); without ``--assets`` the tree is expected under ``--root`` itself.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
_RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
|
||||
|
||||
|
||||
class RangeHandler(SimpleHTTPRequestHandler):
|
||||
# Directory served for ``/assets/...`` (set in main); None means "under root".
|
||||
assets_dir = None
|
||||
|
||||
def translate_path(self, path):
|
||||
if self.assets_dir:
|
||||
prefix = "/assets/"
|
||||
if path == "/assets" or path.startswith(prefix):
|
||||
rel = path[len(prefix):] if path.startswith(prefix) else ""
|
||||
rel = rel.split("?", 1)[0].split("#", 1)[0]
|
||||
parts = [p for p in rel.split("/") if p and p != ".."]
|
||||
return os.path.join(self.assets_dir, *parts)
|
||||
return super().translate_path(path)
|
||||
|
||||
# Cross-origin isolation (kept for SharedArrayBuffer-style features and the
|
||||
# WebGPU/pthread paths). no-store keeps the dev loop honest: a stale
|
||||
# openra3.js/.wasm in the browser cache hides new builds (the worker's
|
||||
# importScripts especially).
|
||||
def end_headers(self):
|
||||
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
||||
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
|
||||
self.send_header("Cache-Control", "no-store, max-age=0")
|
||||
super().end_headers()
|
||||
|
||||
def send_head(self):
|
||||
header = self.headers.get("Range")
|
||||
if not header:
|
||||
return super().send_head()
|
||||
|
||||
path = self.translate_path(self.path)
|
||||
if os.path.isdir(path):
|
||||
return super().send_head()
|
||||
try:
|
||||
handle = open(path, "rb")
|
||||
except OSError:
|
||||
self.send_error(404, "File not found")
|
||||
return None
|
||||
|
||||
match = _RANGE.match(header.strip())
|
||||
if not match:
|
||||
handle.close()
|
||||
return super().send_head()
|
||||
|
||||
size = os.fstat(handle.fileno()).st_size
|
||||
start = int(match.group(1)) if match.group(1) else 0
|
||||
end = int(match.group(2)) if match.group(2) else size - 1
|
||||
if start > end or start >= size:
|
||||
handle.close()
|
||||
self.send_error(416, "Requested Range Not Satisfiable")
|
||||
return None
|
||||
end = min(end, size - 1)
|
||||
|
||||
self.send_response(206)
|
||||
self.send_header("Content-Type", self.guess_type(path))
|
||||
self.send_header("Accept-Ranges", "bytes")
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
||||
self.send_header("Content-Length", str(end - start + 1))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
handle.seek(start)
|
||||
self._remaining = end - start + 1
|
||||
return handle
|
||||
|
||||
def copyfile(self, source, outputfile):
|
||||
remaining = getattr(self, "_remaining", None)
|
||||
if remaining is None:
|
||||
return super().copyfile(source, outputfile)
|
||||
try:
|
||||
while remaining > 0:
|
||||
chunk = source.read(min(65536, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
outputfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
finally:
|
||||
self._remaining = None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server")
|
||||
parser.add_argument("--root", default="build/wasm/bin", help="directory to serve")
|
||||
parser.add_argument("--assets", default=None, help="directory served for /assets (the lazy asset tree)")
|
||||
parser.add_argument("--port", type=int, default=8199)
|
||||
parser.add_argument("--bind", default="127.0.0.1")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.root):
|
||||
print(f"root not found: {args.root}", file=sys.stderr)
|
||||
return 1
|
||||
if args.assets and not os.path.isdir(args.assets):
|
||||
print(f"assets not found: {args.assets}", file=sys.stderr)
|
||||
return 1
|
||||
RangeHandler.assets_dir = os.path.abspath(args.assets) if args.assets else None
|
||||
handler = functools.partial(RangeHandler, directory=args.root)
|
||||
server = ThreadingHTTPServer((args.bind, args.port), handler)
|
||||
print(f"serving {args.root} at http://{args.bind}:{args.port}/index.html")
|
||||
if RangeHandler.assets_dir:
|
||||
print(f" /assets -> {RangeHandler.assets_dir}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
# WebAssembly cross-build via Emscripten.
|
||||
#
|
||||
# Includes Emscripten's own platform toolchain (found through $EMSDK, set by the
|
||||
# emsdk image / `emsdk_env.sh`) and then layers the project's requirements:
|
||||
#
|
||||
# * C++26 `import std;`. Emscripten ships the libc++ `std` module source and a
|
||||
# modules.json in its sysroot; its toolchain wires that up, but only if
|
||||
# `CMAKE_CXX_MODULE_STD` is on and no std-modules JSON has been set yet, so
|
||||
# both are arranged before Emscripten.cmake is included and before the
|
||||
# compiler is probed.
|
||||
# * Global `-fexceptions`. Emscripten disables exceptions by default, and the
|
||||
# libc++ `std` module must be built with the same setting as its consumers,
|
||||
# or clang rejects the prebuilt module ("configuration mismatch"). The engine
|
||||
# throws, so exceptions are enabled everywhere.
|
||||
# * Asyncify turns `emscripten_sleep` (used by display::sleep_frame on
|
||||
# Emscripten) into a yield: the engine's frame loops are blocking, and without
|
||||
# yielding the worker freezes and never paints.
|
||||
#
|
||||
# No pthreads: the runtime's main thread runs inside a plain Web Worker started
|
||||
# by `apps/web/openra3.worker.js`, so its virtual filesystem is local to that
|
||||
# worker and `FS.createLazyFile` (synchronous XHR) is legal. Under
|
||||
# `PROXY_TO_PTHREAD` Emscripten proxies every filesystem syscall to the main
|
||||
# browser thread, where synchronous XHR is forbidden, so lazy assets cannot work
|
||||
# there - hence the plain worker. The SDL3 web port is only pulled in by the
|
||||
# legacy main-thread backend (OPENRA3_WASM_SDL).
|
||||
|
||||
# `import std` support: the gate and the module switch must be in place before
|
||||
# Emscripten.cmake runs and before the compiler is probed.
|
||||
set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "d0edc3af-4c50-42ea-a356-e2862fe7a444")
|
||||
set(CMAKE_CXX_MODULE_STD ON)
|
||||
# Let Emscripten.cmake point CMake at its own sysroot modules.json.
|
||||
unset(CMAKE_CXX_STDLIB_MODULES_JSON)
|
||||
|
||||
if(DEFINED ENV{EMSDK})
|
||||
set(OPENRA3_EMSCRIPTEN_CMAKE "$ENV{EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake")
|
||||
elseif(DEFINED ENV{EMSCRIPTEN_ROOT})
|
||||
set(OPENRA3_EMSCRIPTEN_CMAKE "$ENV{EMSCRIPTEN_ROOT}/cmake/Modules/Platform/Emscripten.cmake")
|
||||
endif()
|
||||
if(NOT EXISTS "${OPENRA3_EMSCRIPTEN_CMAKE}")
|
||||
message(FATAL_ERROR "Emscripten.cmake not found (set EMSDK); looked at '${OPENRA3_EMSCRIPTEN_CMAKE}'")
|
||||
endif()
|
||||
include("${OPENRA3_EMSCRIPTEN_CMAKE}")
|
||||
|
||||
# Exceptions are used by the engine; keep them on globally so the std module and
|
||||
# its consumers agree.
|
||||
#
|
||||
# Asyncify turns `emscripten_sleep` (used by display::sleep_frame on Emscripten)
|
||||
# into a yield to the browser: the engine's frame loops are blocking, and
|
||||
# without yielding the worker freezes and never paints.
|
||||
set(CMAKE_CXX_FLAGS_INIT "-fexceptions -sASYNCIFY=1")
|
||||
set(CMAKE_C_FLAGS_INIT "-sASYNCIFY=1")
|
||||
set(CMAKE_EXE_LINKER_FLAGS_INIT
|
||||
"-fexceptions -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY=1")
|
||||
set(CMAKE_SHARED_LINKER_FLAGS_INIT
|
||||
"-fexceptions -sDISABLE_EXCEPTION_CATCHING=0 -sALLOW_MEMORY_GROWTH=1 -sASYNCIFY=1")
|
||||
+58
-13
@@ -62,9 +62,9 @@ umbrella module re-exports the SDK; applications import `ra3` only.
|
||||
| \ |
|
||||
| \ v
|
||||
| ra3.display (backend pick)
|
||||
| / | \
|
||||
v ra3.ui ra3.vulkan ra3.dx
|
||||
| (SDL3) (Vulkan) (D3D11/12)
|
||||
| / | \ \
|
||||
v ra3.ui ra3.vulkan ra3.dx ra3.webgpu / ra3.wasmgl
|
||||
| (SDL3) (Vulkan) (D3D11/12) (wasm workers)
|
||||
---------------------------------------------------------------------------
|
||||
v
|
||||
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`,
|
||||
`ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.render`, `ra3.ui.*`,
|
||||
`ra3.vulkan.*`, `ra3.dx.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the
|
||||
`ra3.skirmish`, `ra3.fs`, `ra3.map`, `ra3.terrain`, `ra3.models`, `ra3.render`, `ra3.ui.*`,
|
||||
`ra3.vulkan.*`, `ra3.dx.*`, `ra3.webgpu.*`, `ra3.wasmgl.*`, `ra3.display`, `ra3.game`, `ra3.client`, and the
|
||||
vendored `ender.log`) are the **seeds** of the
|
||||
target modules below. `ra3.skirmish` and `ra3.game` will be absorbed into
|
||||
`ra3.ai` / `ra3.match`; new modules are added as their subsystems are recovered.
|
||||
@@ -206,10 +206,11 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
|
||||
- `[ ]` DDS / DXT compressed textures `(v0.5)`
|
||||
- `[ ]` atlas + mip generation, gutter padding (GPU) `[~]`
|
||||
- `[ ]` async upload / streaming `(v0.6)`
|
||||
- **F2 Models** `[ ]` `!!`
|
||||
- `[ ]` W3D container parse (chunks, hierarchy, meshes) `(v0.6)`
|
||||
- `[ ]` materials, shaders, texture references
|
||||
- `[ ]` LOD sets, collision meshes
|
||||
- **F2 Models** `[~]`
|
||||
- `[x]` compiled `W3DMesh` decode (vertex buffer + D3D9 declaration + triangle list) from the BAB static/worldbuilder stream
|
||||
- `[x]` embedded DDS textures (DXT1/3/5 + uncompressed RGB) → ARGB
|
||||
- `[x]` `W3DHierarchy` decode + static bind-pose skinning (bone-space vertices)
|
||||
- `[ ]` W3D container/hierarchy animation, LOD sets, collision meshes `!!` `(v0.6)`
|
||||
- **F3 Animation** `[ ]` `!!`
|
||||
- `[ ]` W3D animation chunks, bone poses `(v0.6)`
|
||||
- `[ ]` blend trees / transition animations
|
||||
@@ -533,8 +534,10 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
|
||||
- **F3 Terrain render** `[~]`
|
||||
- `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas
|
||||
- `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)`
|
||||
- **F4 Model render** `[ ]` `!!`
|
||||
- `[ ]` W3D draw, skinning, materials, team colors `(v0.6)`
|
||||
- **F4 Model render** `[~]`
|
||||
- `[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)`
|
||||
- **F5 VFX** `[ ]`
|
||||
- `[ ]` 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)`
|
||||
- **F2 Terrain presentation** `[D]`
|
||||
- `[x]` GPU heightfield raymarch, mipmapped atlas, gutter, retail blend ramp
|
||||
- **F3 Materials & pipelines** `[ ]`
|
||||
- `[ ]` model/particle/HUD pipelines `(v0.6)`
|
||||
- **F3 Materials & pipelines** `[~]`
|
||||
- `[x]` static-model pipeline (vertex/index buffers, texture array, depth test)
|
||||
- `[ ]` particle/HUD pipelines `(v0.6)`
|
||||
- **F4 Null fallback** `[D]`
|
||||
- `[x]` report failure when no Vulkan loader is present
|
||||
|
||||
@@ -595,6 +599,47 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
|
||||
- **F5 Null fallback** `[D]`
|
||||
- `[x]` non-Windows builds link a stub that fails `init`
|
||||
|
||||
### M18d `ra3.wasmgl` — SDL-free wasm worker backend `[P]`
|
||||
|
||||
- **F1 Device & canvas** `[D]`
|
||||
- `[x]` Emscripten-only; engine runs on a plain Web Worker, WebGL2 on an
|
||||
OffscreenCanvas transferred by the page (no SDL, no `PROXY_TO_PTHREAD`)
|
||||
- `[x]` `specialHTMLTargets['#canvas']` lets `emscripten_webgl_create_context` find
|
||||
the worker's OffscreenCanvas
|
||||
- **F2 2D image path** `[D]`
|
||||
- `[x]` GLSL ES BGRA texture + fullscreen triangle (`.bgra` swizzle)
|
||||
- **F3 Terrain presentation** `[D]`
|
||||
- `[x]` GPU heightfield raymarch (GLSL ES), including the `GL_R16`
|
||||
(`0x822A`) heightmap and `GL_RGBA16` cell textures
|
||||
- **F4 On-demand assets** `[D]`
|
||||
- `[x]` `assets.manifest.json` (from `scripts/make_web_manifest.py`) is embedded;
|
||||
the worker registers every file with `FS.createLazyFile`, so only opened files
|
||||
are fetched (a map pulls just its map + tiles)
|
||||
- **F5 Input** `[D]`
|
||||
- `[x]` page owns DOM listeners and posts events to the worker's exported
|
||||
`openra3_*` functions (no DOM callbacks in the worker)
|
||||
- **F6 Null fallback** `[D]`
|
||||
- `[x]` non-Emscripten builds link a stub that fails `init`
|
||||
|
||||
### M18e `ra3.webgpu` — WebGPU wasm backend `[P]`
|
||||
|
||||
- **F1 Device & surface** `[D]`
|
||||
- `[x]` Emscripten `emdawnwebgpu` port (Dawn C++ wrapper); the worker pre-creates
|
||||
the device (`Module.preinitializedWebGPUDevice`) and a surface is configured on
|
||||
the transferred OffscreenCanvas
|
||||
- **F2 WGSL shaders** `[D]`
|
||||
- `[x]` web WebGPU is WGSL-only, so the scene/terrain shaders are WGSL ports
|
||||
(the Vulkan SPIR-V blobs cannot be reused); the uniform layout and terrain data
|
||||
plumbing are shared with Vulkan/WebGL
|
||||
- **F3 Terrain** `[D]`
|
||||
- `[x]` GPU heightfield raymarch in WGSL (R16Uint heightmap, RGBA16Uint cell
|
||||
record, RGBA8 atlas)
|
||||
- **F4 Selection & fallback** `[D]`
|
||||
- `[x]` preferred wasm backend; falls back to `ra3.wasmgl` when no adapter is
|
||||
available
|
||||
- **F5 Null fallback** `[D]`
|
||||
- `[x]` non-Emscripten builds link a stub that fails `init`
|
||||
|
||||
### M19 `ra3.audio` — audio `[ ]`
|
||||
|
||||
- **F1 SFX** `[ ]`
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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).
|
||||
- Cliff textures and the `CliffTextureMapping` UV remap (`CliffTextures` is
|
||||
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`
|
||||
schema used to deserialise them.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the WebAssembly target (openra3.js/.wasm + worker + page) with the
|
||||
# Emscripten image.
|
||||
#
|
||||
# The engine runs inside a plain Web Worker and renders into an OffscreenCanvas
|
||||
# with no SDL, so assets load on demand: point OPENRA3_WEB_ASSETS at the
|
||||
# extracted asset tree and the build generates an assets manifest, embeds it, and
|
||||
# registers every file as an Emscripten lazy file at startup. The browser then
|
||||
# fetches each asset only when the engine opens it.
|
||||
#
|
||||
# OPENRA3_WEB_ASSETS=/path/to/assets scripts/build-wasm.sh
|
||||
#
|
||||
# open http://localhost:8199/index.html
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
IMAGE="${OPENRA3_WASM_IMAGE:-openra3-wasm:local}"
|
||||
BUILD_DIR="${1:-build/wasm}"
|
||||
|
||||
docker build --target dev -f "$ROOT/Dockerfile.wasm" -t "$IMAGE" "$ROOT"
|
||||
|
||||
mounts=(-v "$ROOT:/work")
|
||||
configure=(-DCMAKE_TOOLCHAIN_FILE=cmake/toolchains/emscripten-wasm.cmake -DCMAKE_BUILD_TYPE=Release)
|
||||
serve_assets=()
|
||||
if [ -n "${OPENRA3_WEB_ASSETS:-}" ]; then
|
||||
mounts+=(-v "$OPENRA3_WEB_ASSETS:/assets:ro")
|
||||
manifest="$ROOT/$BUILD_DIR/assets.manifest.json"
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
python3 "$ROOT/scripts/make_web_manifest.py" --root "$OPENRA3_WEB_ASSETS" --out "$manifest"
|
||||
configure+=(-DOPENRA3_WEB_MANIFEST="/work/$BUILD_DIR/assets.manifest.json")
|
||||
serve_assets=(--assets "$OPENRA3_WEB_ASSETS")
|
||||
fi
|
||||
|
||||
docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake -S . -B "$BUILD_DIR" -G Ninja "${configure[@]}"
|
||||
docker run --rm "${mounts[@]}" -w /work "$IMAGE" cmake --build "$BUILD_DIR" -j
|
||||
|
||||
echo "WebAssembly: $ROOT/$BUILD_DIR/bin/index.html (+ openra3.js/.wasm, openra3.worker.js)"
|
||||
echo "serve: python3 \"$ROOT/apps/web/serve.py\" --root \"$ROOT/$BUILD_DIR/bin\" ${serve_assets[*]:-}"
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the wasm lazy-asset manifest.
|
||||
|
||||
The wasm build runs the engine on a worker, where synchronous XHR is legal, so
|
||||
assets are registered with ``FS.createLazyFile`` instead of being preloaded: the
|
||||
browser fetches each file only when the engine first opens it. This script lists
|
||||
every file under the assets root (as POSIX-relative paths) as
|
||||
``{"files": [...]}``; CMake preloads the manifest to ``/assets.manifest.json``.
|
||||
|
||||
scripts/make_web_manifest.py --root /path/to/assets --out assets.manifest.json
|
||||
|
||||
Serve the tree at ``<wasm-root>/assets`` (or use ``serve.py --assets``).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Generate the OpenRA3 wasm asset manifest")
|
||||
parser.add_argument("--root", required=True, help="assets root to enumerate")
|
||||
parser.add_argument("--out", required=True, help="manifest JSON to write")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.root):
|
||||
print(f"root not found: {args.root}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
files = []
|
||||
for dirpath, dirnames, filenames in os.walk(args.root):
|
||||
dirnames.sort()
|
||||
for name in sorted(filenames):
|
||||
rel = os.path.relpath(os.path.join(dirpath, name), args.root).replace(os.sep, "/")
|
||||
files.append(rel)
|
||||
|
||||
with open(args.out, "w", encoding="utf-8") as handle:
|
||||
json.dump({"files": files}, handle, separators=(",", ":"))
|
||||
handle.write("\n")
|
||||
print(f"wrote {args.out} ({len(files)} files)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+2
-2
@@ -25,9 +25,9 @@ compile() {
|
||||
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.frag" "$OUT/$name.frag.spv"
|
||||
done
|
||||
|
||||
echo "wrote $OUT/{scene,terrain}.{vert,frag}.spv"
|
||||
echo "wrote $OUT/{scene,terrain,object}.{vert,frag}.spv"
|
||||
|
||||
@@ -130,17 +130,19 @@ float4 PSMain(VSOut input) : SV_Target {
|
||||
return float4(sky_color(dir), 1.0);
|
||||
}
|
||||
|
||||
// March the heightfield (bounded work: the step grows toward the horizon).
|
||||
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||
// ~9000 world units, so marching past ~20000 adds cost without detail, and
|
||||
// a 6% growth rate more than doubles the worst-case iteration count.
|
||||
float t = CELL * 0.5;
|
||||
float dt = CELL * 0.5;
|
||||
float prev = t;
|
||||
bool hit = false;
|
||||
float hit_t = 0.0;
|
||||
for (int i = 0; i < 512 && t < 60000.0; ++i) {
|
||||
for (int i = 0; i < 256 && t < 20000.0; ++i) {
|
||||
float3 w = cam_pos + dir * t;
|
||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||
prev = t;
|
||||
dt *= 1.06;
|
||||
dt *= 1.10;
|
||||
t += dt;
|
||||
continue;
|
||||
}
|
||||
@@ -155,7 +157,7 @@ float4 PSMain(VSOut input) : SV_Target {
|
||||
break;
|
||||
}
|
||||
prev = t;
|
||||
dt *= 1.06;
|
||||
dt *= 1.10;
|
||||
t += dt;
|
||||
}
|
||||
if (!hit) {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -25,6 +25,11 @@ layout(location = 0) out vec4 out_color;
|
||||
|
||||
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) {
|
||||
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
|
||||
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);
|
||||
|
||||
if (dir.z >= -1e-4) {
|
||||
gl_FragDepth = 1.0;
|
||||
out_color = vec4(sky_color(dir), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// March the heightfield (bounded work: the step grows toward the horizon).
|
||||
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||
// ~9000 world units, so marching past ~20000 adds cost without detail, and
|
||||
// a 6% growth rate more than doubles the worst-case iteration count.
|
||||
float t = CELL * 0.5;
|
||||
float dt = CELL * 0.5;
|
||||
float prev = t;
|
||||
bool hit = false;
|
||||
float hit_t = 0.0;
|
||||
for (int i = 0; i < 512 && t < 60000.0; ++i) {
|
||||
for (int i = 0; i < 256 && t < 20000.0; ++i) {
|
||||
vec3 w = cam + dir * t;
|
||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||
prev = t; dt *= 1.06; t += dt; continue;
|
||||
prev = t; dt *= 1.10; t += dt; continue;
|
||||
}
|
||||
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; }
|
||||
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; }
|
||||
prev = t; dt *= 1.06; t += dt;
|
||||
prev = t; dt *= 1.10; t += dt;
|
||||
}
|
||||
if (!hit) { out_color = vec4(sky_color(dir), 1.0); return; }
|
||||
if (!hit) { gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; }
|
||||
|
||||
float lo = prev, hi = hit_t;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
@@ -142,6 +150,10 @@ void main() {
|
||||
}
|
||||
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);
|
||||
float ambient = pc.sun.w;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#version 300 es
|
||||
// Samples the software-rendered scene image (the WebGL port of scene.frag).
|
||||
precision highp float;
|
||||
precision highp sampler2D;
|
||||
|
||||
uniform sampler2D u_scene;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 frag_color;
|
||||
|
||||
void main() {
|
||||
if (v_uv.x < 0.0 || v_uv.x > 1.0 || v_uv.y < 0.0 || v_uv.y > 1.0) discard;
|
||||
// The engine stores 0xAARRGGBB (BGRA in memory), uploaded as GL_RGBA.
|
||||
frag_color = texture(u_scene, v_uv).bgra;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#version 300 es
|
||||
// Fullscreen triangle for the 2D image path (the WebGL port of scene.vert).
|
||||
// WebGL clip space is +Y up, so the vertex position flips Y relative to the
|
||||
// Vulkan shader; the sampled UVs and the image's top-left origin are unchanged.
|
||||
precision highp float;
|
||||
|
||||
uniform vec4 u_rect; // xy = top-left (0..1), zw = size (0..1)
|
||||
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
v_uv = (p - u_rect.xy) / u_rect.zw;
|
||||
gl_Position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
#version 300 es
|
||||
// GPU heightfield raymarcher for the real RA3 terrain (the WebGL port of
|
||||
// terrain.frag). The Vulkan push constants become a set of vec4 uniforms.
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
precision highp sampler2D;
|
||||
precision highp sampler2DArray;
|
||||
|
||||
uniform sampler2D u_heightmap; // R16 heights
|
||||
uniform sampler2D u_celldata; // per-cell blend record (RGBA16)
|
||||
uniform sampler2DArray u_atlas; // tile material array (RGBA8)
|
||||
|
||||
// One contiguous array so the host can upload all five vec4s with a single
|
||||
// glUniform4fv; the names keep the shader body identical to terrain.frag.
|
||||
uniform vec4 u_data[5];
|
||||
#define u_cam u_data[0] // x=target_x, y=target_y, z=yaw, w=height
|
||||
#define u_params u_data[1] // x=pitch, y=fov, z=water_z, w=has_water
|
||||
#define u_sun u_data[2] // xyz=sun dir, w=ambient
|
||||
#define u_mapinfo u_data[3] // x=W, y=H, z=unused, w=z_scale
|
||||
#define u_misc u_data[4] // x=time, y=unused, z=cells per texture repeat, w=aspect
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 frag_color;
|
||||
|
||||
const float CELL = 10.0; // must match ra3::terrain::cell_size
|
||||
|
||||
float height_at(ivec2 c) {
|
||||
c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1);
|
||||
return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w;
|
||||
}
|
||||
|
||||
float world_height(float wx, float wy) {
|
||||
float world_w = u_mapinfo.x * CELL;
|
||||
float world_h = u_mapinfo.y * CELL;
|
||||
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) return -1.0e9;
|
||||
ivec2 c = ivec2(int(wx / CELL), int((world_h - wy) / CELL));
|
||||
return height_at(c);
|
||||
}
|
||||
|
||||
vec3 sky_color(vec3 dir) {
|
||||
vec3 d = normalize(dir);
|
||||
vec3 sun_dir = normalize(u_sun.xyz);
|
||||
float t = clamp(d.z, 0.0, 1.0);
|
||||
vec3 horizon = vec3(0.70, 0.78, 0.85);
|
||||
vec3 zenith = vec3(0.28, 0.48, 0.80);
|
||||
vec3 col = mix(horizon, zenith, pow(t, 0.6));
|
||||
float sun = max(dot(d, sun_dir), 0.0);
|
||||
col += vec3(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6; // sun disk
|
||||
col += vec3(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18; // glow
|
||||
return col;
|
||||
}
|
||||
|
||||
// The retail SAGE blend ramp (see terrain.frag).
|
||||
float blend_factor(uint direction, uint flags, vec2 f) {
|
||||
bool flipped = (flags & 1u) != 0u;
|
||||
bool two_sided = (flags & 2u) != 0u;
|
||||
if (flipped) {
|
||||
if (direction == 1u) {
|
||||
f.x = 1.0 - f.x;
|
||||
} else if (direction == 2u || direction == 4u || direction == 8u) {
|
||||
f.y = 1.0 - f.y;
|
||||
}
|
||||
}
|
||||
if (direction == 1u) return f.x;
|
||||
if (direction == 2u) return f.y;
|
||||
if (direction == 4u) {
|
||||
float s = (1.0 - f.x) + (1.0 - f.y);
|
||||
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
|
||||
}
|
||||
if (direction == 8u) {
|
||||
float s = f.x + (1.0 - f.y);
|
||||
return two_sided ? 1.0 - clamp(s - 1.0, 0.0, 1.0) : clamp(1.0 - s, 0.0, 1.0);
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
vec3 sample_layer(uint layer, float wx, float wy) {
|
||||
float span = max(u_misc.z, 1.0);
|
||||
int layer_count = textureSize(u_atlas, 0).z;
|
||||
float l = float(min(layer, uint(layer_count - 1)));
|
||||
// The atlas is 0xAARRGGBB (BGRA in memory), uploaded as GL_RGBA.
|
||||
return texture(u_atlas, vec3(vec2(wx, wy) / span, l)).bgr;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 p = u_cam;
|
||||
float pitch = clamp(u_params.x, 0.15, 1.45);
|
||||
float fov = clamp(u_params.y, 0.3, 1.4);
|
||||
float world_w = u_mapinfo.x * CELL;
|
||||
float world_h = u_mapinfo.y * CELL;
|
||||
|
||||
float cp = cos(pitch);
|
||||
vec3 fwd = vec3(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
|
||||
vec3 right = normalize(cross(fwd, vec3(0, 0, 1)));
|
||||
vec3 up = cross(right, fwd);
|
||||
|
||||
float target_z = world_height(p.x, p.y);
|
||||
if (target_z < -1.0e8) target_z = 0.0;
|
||||
float dist = p.w / sin(pitch);
|
||||
vec3 cam = vec3(p.x, p.y, target_z + p.w) - fwd * dist;
|
||||
|
||||
vec2 ndc = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0);
|
||||
float aspect = u_misc.w;
|
||||
float th = tan(fov * 0.5);
|
||||
vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
|
||||
|
||||
if (dir.z >= -1e-4) {
|
||||
frag_color = vec4(sky_color(dir), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||
// ~9000 world units, so marching past ~20000 adds cost without detail, and
|
||||
// a 6% growth rate more than doubles the worst-case iteration count.
|
||||
float t = CELL * 0.5;
|
||||
float dt = CELL * 0.5;
|
||||
float prev = t;
|
||||
bool hit = false;
|
||||
float hit_t = 0.0;
|
||||
for (int i = 0; i < 256 && t < 20000.0; ++i) {
|
||||
vec3 w = cam + dir * t;
|
||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||
prev = t;
|
||||
dt *= 1.10;
|
||||
t += dt;
|
||||
continue;
|
||||
}
|
||||
if (u_params.w > 0.5 && w.z <= u_params.z) {
|
||||
hit = true;
|
||||
hit_t = t;
|
||||
break;
|
||||
}
|
||||
if (w.z <= world_height(w.x, w.y)) {
|
||||
hit = true;
|
||||
hit_t = t;
|
||||
break;
|
||||
}
|
||||
prev = t;
|
||||
dt *= 1.10;
|
||||
t += dt;
|
||||
}
|
||||
if (!hit) {
|
||||
frag_color = vec4(sky_color(dir), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
float lo = prev;
|
||||
float hi = hit_t;
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
float mid = 0.5 * (lo + hi);
|
||||
vec3 w = cam + dir * mid;
|
||||
bool water = u_params.w > 0.5 && w.z <= u_params.z;
|
||||
if (water || w.z <= world_height(w.x, w.y)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
vec3 hitpos = cam + dir * hi;
|
||||
|
||||
vec3 sun = normalize(u_sun.xyz);
|
||||
float ambient = u_sun.w;
|
||||
|
||||
if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) {
|
||||
float time = u_misc.x;
|
||||
vec2 q = hitpos.xy * 0.015;
|
||||
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
|
||||
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
|
||||
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
|
||||
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
|
||||
vec3 deep = vec3(0.03, 0.16, 0.28);
|
||||
vec3 refl = sky_color(reflect(dir, n));
|
||||
float lam = max(0.0, dot(n, sun));
|
||||
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
|
||||
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
|
||||
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
|
||||
frag_color = vec4(water, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
float wx = hitpos.x / CELL;
|
||||
float wy = (world_h - hitpos.y) / CELL;
|
||||
int cx = clamp(int(wx), 0, int(u_mapinfo.x) - 1);
|
||||
int cy = clamp(int(wy), 0, int(u_mapinfo.y) - 1);
|
||||
float fx = wx - floor(wx);
|
||||
float fy = wy - floor(wy);
|
||||
|
||||
uvec4 record = uvec4(texelFetch(u_celldata, ivec2(cx, cy), 0) * 65535.0 + 0.5);
|
||||
uint packed = record.w;
|
||||
uint dir1 = packed & 0xFu;
|
||||
uint flags1 = (packed >> 4u) & 0x3u;
|
||||
uint dir2 = (packed >> 8u) & 0xFu;
|
||||
uint flags2 = (packed >> 12u) & 0x3u;
|
||||
|
||||
vec2 fracUV = vec2(fx, fy);
|
||||
vec3 c0 = sample_layer(record.x, wx, wy);
|
||||
vec3 c1 = sample_layer(record.y, wx, wy);
|
||||
vec3 c2 = sample_layer(record.z, wx, wy);
|
||||
float f1 = blend_factor(dir1, flags1, fracUV);
|
||||
float f2 = blend_factor(dir2, flags2, fracUV);
|
||||
vec3 albedo = mix(mix(c0, c1, f1), c2, f2);
|
||||
|
||||
float hl = world_height(hitpos.x - CELL, hitpos.y);
|
||||
float hr = world_height(hitpos.x + CELL, hitpos.y);
|
||||
float hd = world_height(hitpos.x, hitpos.y - CELL);
|
||||
float hu = world_height(hitpos.x, hitpos.y + CELL);
|
||||
vec3 n = normalize(vec3(hl - hr, hd - hu, 2.0 * CELL));
|
||||
float lambert = max(0.0, dot(n, sun));
|
||||
vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
|
||||
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
|
||||
frag_color = vec4(lit, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#version 300 es
|
||||
// Fullscreen triangle for the terrain raymarcher (the WebGL port of terrain.vert).
|
||||
precision highp float;
|
||||
|
||||
out vec2 v_uv;
|
||||
|
||||
void main() {
|
||||
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
||||
v_uv = p;
|
||||
gl_Position = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// WebGPU counterpart of the 2D image path (the WGSL port of
|
||||
// webgl_scene_vert/frag.glsl and the Vulkan scene shaders).
|
||||
//
|
||||
// Bind group 0: uniform { rect: vec4<f32>; }, the scene texture and a sampler.
|
||||
// WebGPU clip space is +Y up, matching GL, so the vertex flips Y the same way.
|
||||
|
||||
struct SceneUniforms {
|
||||
rect: vec4<f32>, // xy = top-left (0..1), zw = size (0..1)
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u_scene: SceneUniforms;
|
||||
@group(0) @binding(1) var u_scene_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var u_scene_samp: sampler;
|
||||
|
||||
struct SceneOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> SceneOut {
|
||||
let p = vec2<f32>(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u));
|
||||
var out: SceneOut;
|
||||
out.uv = (p - u_scene.rect.xy) / u_scene.rect.zw;
|
||||
out.position = vec4<f32>(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: SceneOut) -> @location(0) vec4<f32> {
|
||||
// Only the destination rect is drawn; outside it the previous contents stay.
|
||||
if (in.uv.x < 0.0 || in.uv.x > 1.0 || in.uv.y < 0.0 || in.uv.y > 1.0) {
|
||||
discard;
|
||||
}
|
||||
// The image is 0xAARRGGBB (BGRA in memory); swizzle it back.
|
||||
let c = textureSampleLevel(u_scene_tex, u_scene_samp, in.uv, 0.0);
|
||||
return vec4<f32>(c.b, c.g, c.r, c.a);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// WebGPU counterpart of the GPU heightfield raymarcher (the WGSL port of
|
||||
// webgl_terrain_frag.glsl / terrain.frag). The Vulkan push constants become a
|
||||
// uniform buffer of five vec4s, laid out exactly as `ra3::wasmgl::detail::terrain_uniforms`.
|
||||
//
|
||||
// Bind group 0: uniform data[5], the R16 heightmap (u32), the RGBA16 cell record
|
||||
// (vec4<u32>), the RGBA8 tile atlas (array), and an atlas sampler.
|
||||
|
||||
struct TerrainUniforms {
|
||||
data: array<vec4<f32>, 5>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: TerrainUniforms;
|
||||
@group(0) @binding(1) var u_heightmap: texture_2d<u32>; // R16Uint heights
|
||||
@group(0) @binding(2) var u_celldata: texture_2d<u32>; // RGBA16Uint blend record (texel is vec4<u32>)
|
||||
@group(0) @binding(3) var u_atlas: texture_2d_array<f32>; // RGBA8 tile materials
|
||||
@group(0) @binding(4) var u_atlas_samp: sampler;
|
||||
|
||||
const CELL: f32 = 10.0; // must match ra3::terrain::cell_size
|
||||
|
||||
fn cam_uniform() -> vec4<f32> { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height
|
||||
fn params_uniform() -> vec4<f32> { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water
|
||||
fn sun_uniform() -> vec4<f32> { return u.data[2]; } // xyz=sun dir, w=ambient
|
||||
fn mapinfo_uniform() -> vec4<f32> { return u.data[3]; }// x=W, y=H, z=unused, w=z_scale
|
||||
fn misc_uniform() -> vec4<f32> { return u.data[4]; } // x=time, y=unused, z=cells per repeat, w=aspect
|
||||
|
||||
struct TerrainOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> TerrainOut {
|
||||
let p = vec2<f32>(f32((vertex_index << 1u) & 2u), f32(vertex_index & 2u));
|
||||
var out: TerrainOut;
|
||||
out.uv = p;
|
||||
out.position = vec4<f32>(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
fn height_at(cell: vec2<i32>) -> f32 {
|
||||
let limit = vec2<i32>(mapinfo_uniform().xy) - vec2<i32>(1);
|
||||
let c = clamp(cell, vec2<i32>(0), limit);
|
||||
return f32(textureLoad(u_heightmap, c, 0).r) * mapinfo_uniform().w;
|
||||
}
|
||||
|
||||
fn world_height(wx: f32, wy: f32) -> f32 {
|
||||
let world_w = mapinfo_uniform().x * CELL;
|
||||
let world_h = mapinfo_uniform().y * CELL;
|
||||
if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) {
|
||||
return -1.0e9;
|
||||
}
|
||||
let c = vec2<i32>(i32(wx / CELL), i32((world_h - wy) / CELL));
|
||||
return height_at(c);
|
||||
}
|
||||
|
||||
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
||||
let d = normalize(dir);
|
||||
let sun_dir = normalize(sun_uniform().xyz);
|
||||
let t = clamp(d.z, 0.0, 1.0);
|
||||
let horizon = vec3<f32>(0.70, 0.78, 0.85);
|
||||
let zenith = vec3<f32>(0.28, 0.48, 0.80);
|
||||
var col = mix(horizon, zenith, pow(t, 0.6));
|
||||
let sun = max(dot(d, sun_dir), 0.0);
|
||||
col += vec3<f32>(1.0, 0.95, 0.82) * pow(sun, 300.0) * 1.6;
|
||||
col += vec3<f32>(1.0, 0.90, 0.72) * pow(sun, 8.0) * 0.18;
|
||||
return col;
|
||||
}
|
||||
|
||||
// The retail SAGE blend ramp (see terrain.frag).
|
||||
fn blend_factor(direction: u32, flags: u32, f_in: vec2<f32>) -> f32 {
|
||||
var f = f_in;
|
||||
let flipped = (flags & 1u) != 0u;
|
||||
let two_sided = (flags & 2u) != 0u;
|
||||
if (flipped) {
|
||||
if (direction == 1u) {
|
||||
f.x = 1.0 - f.x;
|
||||
} else if (direction == 2u || direction == 4u || direction == 8u) {
|
||||
f.y = 1.0 - f.y;
|
||||
}
|
||||
}
|
||||
if (direction == 1u) { return f.x; }
|
||||
if (direction == 2u) { return f.y; }
|
||||
if (direction == 4u) {
|
||||
let s = (1.0 - f.x) + (1.0 - f.y);
|
||||
return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided);
|
||||
}
|
||||
if (direction == 8u) {
|
||||
let s = f.x + (1.0 - f.y);
|
||||
return select(clamp(1.0 - s, 0.0, 1.0), 1.0 - clamp(s - 1.0, 0.0, 1.0), two_sided);
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
fn sample_layer(layer: u32, wx: f32, wy: f32) -> vec3<f32> {
|
||||
let span = max(misc_uniform().z, 1.0);
|
||||
let layer_count = textureNumLayers(u_atlas);
|
||||
let l = f32(min(layer, layer_count - 1u));
|
||||
// The atlas is 0xAARRGGBB (BGRA in memory), uploaded as RGBA8.
|
||||
let c = textureSampleLevel(u_atlas, u_atlas_samp, vec2<f32>(wx, wy) / span, i32(l), 0.0);
|
||||
return vec3<f32>(c.b, c.g, c.r);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
let p = cam_uniform();
|
||||
let pitch = clamp(params_uniform().x, 0.15, 1.45);
|
||||
let fov = clamp(params_uniform().y, 0.3, 1.4);
|
||||
let world_w = mapinfo_uniform().x * CELL;
|
||||
let world_h = mapinfo_uniform().y * CELL;
|
||||
|
||||
let cp = cos(pitch);
|
||||
let fwd = vec3<f32>(cp * sin(p.z), cp * cos(p.z), -sin(pitch));
|
||||
let right = normalize(cross(fwd, vec3<f32>(0.0, 0.0, 1.0)));
|
||||
let up = cross(right, fwd);
|
||||
|
||||
var target_z = world_height(p.x, p.y);
|
||||
if (target_z < -1.0e8) { target_z = 0.0; }
|
||||
let dist = p.w / sin(pitch);
|
||||
let cam = vec3<f32>(p.x, p.y, target_z + p.w) - fwd * dist;
|
||||
|
||||
let ndc = vec2<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let aspect = misc_uniform().w;
|
||||
let th = tan(fov * 0.5);
|
||||
let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
|
||||
|
||||
if (dir.z >= -1e-4) {
|
||||
return vec4<f32>(sky_color(dir), 1.0);
|
||||
}
|
||||
|
||||
// March the heightfield. The step grows quickly: the map diagonal is only
|
||||
// ~9000 world units, so marching past ~20000 adds cost without detail.
|
||||
var t = CELL * 0.5;
|
||||
var dt = CELL * 0.5;
|
||||
var prev = t;
|
||||
var hit = false;
|
||||
var hit_t = 0.0;
|
||||
for (var i = 0; i < 256 && t < 20000.0; i = i + 1) {
|
||||
let w = cam + dir * t;
|
||||
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) {
|
||||
prev = t;
|
||||
dt *= 1.10;
|
||||
t += dt;
|
||||
continue;
|
||||
}
|
||||
if (params_uniform().w > 0.5 && w.z <= params_uniform().z) {
|
||||
hit = true;
|
||||
hit_t = t;
|
||||
break;
|
||||
}
|
||||
if (w.z <= world_height(w.x, w.y)) {
|
||||
hit = true;
|
||||
hit_t = t;
|
||||
break;
|
||||
}
|
||||
prev = t;
|
||||
dt *= 1.10;
|
||||
t += dt;
|
||||
}
|
||||
if (!hit) {
|
||||
return vec4<f32>(sky_color(dir), 1.0);
|
||||
}
|
||||
|
||||
var lo = prev;
|
||||
var hi = hit_t;
|
||||
for (var i = 0; i < 6; i = i + 1) {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
let w = cam + dir * mid;
|
||||
let water = params_uniform().w > 0.5 && w.z <= params_uniform().z;
|
||||
if (water || w.z <= world_height(w.x, w.y)) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
let hitpos = cam + dir * hi;
|
||||
|
||||
let sun = normalize(sun_uniform().xyz);
|
||||
let ambient = sun_uniform().w;
|
||||
|
||||
if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) {
|
||||
let time = misc_uniform().x;
|
||||
let q = hitpos.xy * 0.015;
|
||||
let nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
|
||||
let ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
|
||||
let n = normalize(vec3<f32>(nx * 0.06, ny * 0.06, 1.0));
|
||||
let fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
|
||||
let deep = vec3<f32>(0.03, 0.16, 0.28);
|
||||
let refl = sky_color(reflect(dir, n));
|
||||
let lam = max(0.0, dot(n, sun));
|
||||
var water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
|
||||
water += vec3<f32>(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
|
||||
let wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
water = mix(water, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), wfog);
|
||||
return vec4<f32>(water, 1.0);
|
||||
}
|
||||
|
||||
let wx = hitpos.x / CELL;
|
||||
let wy = (world_h - hitpos.y) / CELL;
|
||||
let cx = clamp(i32(wx), 0, i32(mapinfo_uniform().x) - 1);
|
||||
let cy = clamp(i32(wy), 0, i32(mapinfo_uniform().y) - 1);
|
||||
let fx = wx - floor(wx);
|
||||
let fy = wy - floor(wy);
|
||||
|
||||
let record = textureLoad(u_celldata, vec2<i32>(cx, cy), 0);
|
||||
let packed = record.w;
|
||||
let dir1 = packed & 0xFu;
|
||||
let flags1 = (packed >> 4u) & 0x3u;
|
||||
let dir2 = (packed >> 8u) & 0xFu;
|
||||
let flags2 = (packed >> 12u) & 0x3u;
|
||||
|
||||
let frac_uv = vec2<f32>(fx, fy);
|
||||
let c0 = sample_layer(record.x, wx, wy);
|
||||
let c1 = sample_layer(record.y, wx, wy);
|
||||
let c2 = sample_layer(record.z, wx, wy);
|
||||
let f1 = blend_factor(dir1, flags1, frac_uv);
|
||||
let f2 = blend_factor(dir2, flags2, frac_uv);
|
||||
let albedo = mix(mix(c0, c1, f1), c2, f2);
|
||||
|
||||
let hl = world_height(hitpos.x - CELL, hitpos.y);
|
||||
let hr = world_height(hitpos.x + CELL, hitpos.y);
|
||||
let hd = world_height(hitpos.x, hitpos.y - CELL);
|
||||
let hu = world_height(hitpos.x, hitpos.y + CELL);
|
||||
let n = normalize(vec3<f32>(hl - hr, hd - hu, 2.0 * CELL));
|
||||
let lambert = max(0.0, dot(n, sun));
|
||||
var lit = albedo * (ambient + (1.0 - ambient) * lambert);
|
||||
let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
|
||||
lit = mix(lit, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), fog);
|
||||
return vec4<f32>(lit, 1.0);
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
module;
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// Yields to the browser (requires -sASYNCIFY); see display::sleep_frame.
|
||||
extern "C" void emscripten_sleep(unsigned int ms);
|
||||
#endif
|
||||
|
||||
export module ra3.client;
|
||||
|
||||
import std;
|
||||
@@ -85,12 +92,19 @@ export namespace ra3::client {
|
||||
|
||||
/** Throttle an idle frame according to the configured limit. */
|
||||
auto sleep_frame() const -> void {
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// The frame loops are blocking; on the web the only way to let the
|
||||
// browser paint and handle input is to yield (Asyncify sleep).
|
||||
const int fps = fps_limit_ > 0 ? fps_limit_ : 60;
|
||||
emscripten_sleep(static_cast<unsigned>(std::max(1, 1000 / fps)));
|
||||
#else
|
||||
if (fps_limit_ > 0) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(1, 1000 / fps_limit_)));
|
||||
} else if (fps_limit_ < 0) {
|
||||
std::this_thread::yield();
|
||||
}
|
||||
// fps_limit_ == 0: vertical sync already blocks in present().
|
||||
#endif
|
||||
}
|
||||
|
||||
// ---- shared interactive loops (assume `init` succeeded) --------------
|
||||
@@ -356,6 +370,8 @@ export namespace ra3::client {
|
||||
|
||||
bool running = true;
|
||||
bool presented = false;
|
||||
const auto default_camera = camera;
|
||||
bool middle_dragged = false;
|
||||
while (running) {
|
||||
ui_event event;
|
||||
float drag_x = 0.0F;
|
||||
@@ -374,13 +390,23 @@ export namespace ra3::client {
|
||||
} else if (event.type == ui_event_type::mouse_move) {
|
||||
mouse_x = event.x;
|
||||
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_y += event.dy;
|
||||
if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
|
||||
}
|
||||
} 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_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;
|
||||
@@ -390,8 +416,10 @@ export namespace ra3::client {
|
||||
last = now;
|
||||
|
||||
if (drag_x != 0.0F || drag_y != 0.0F) {
|
||||
camera.yaw -= drag_x * 0.005F;
|
||||
camera.pitch = std::clamp(camera.pitch + drag_y * 0.004F, 0.15F, 1.45F);
|
||||
// SAGE LookAtTranslator: middle-drag rotates yaw (X) and
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,19 @@ import ra3.client;
|
||||
import ra3.ui;
|
||||
import ra3.vulkan;
|
||||
import ra3.dx;
|
||||
import ra3.wasmgl;
|
||||
import ra3.webgpu;
|
||||
|
||||
/**
|
||||
* Backend selection for the presentation layer.
|
||||
*
|
||||
* The app talks only to `ra3::client::display`; this module picks the concrete
|
||||
* backend so no caller has to know which one is in use. The available backends
|
||||
* are Vulkan, Direct3D 11 and Direct3D 12 (both GPU terrain), and the SDL
|
||||
* software blit fallback. The caller may name a preferred backend (the in-game
|
||||
* menu exposes this); if it does not start, the others are tried in turn. The
|
||||
* GPU terrain path is Vulkan/D3D-only and reports failure so the caller can fall
|
||||
* back to the software renderer.
|
||||
* are Vulkan, Direct3D 11 and Direct3D 12 (desktop), the WebGPU and SDL-free
|
||||
* WebGL2 wasm worker backends (wasm) and the SDL software blit fallback. The
|
||||
* caller may name a preferred backend (the in-game menu exposes this); if it
|
||||
* does not start, the others are tried in turn. The GPU terrain path reports
|
||||
* failure so the caller can fall back to the software renderer.
|
||||
*/
|
||||
export namespace ra3::display {
|
||||
using ra3::client::display_options;
|
||||
@@ -32,13 +34,24 @@ export namespace ra3::display {
|
||||
using camera_frame = std::function<image(const camera3d &, ra3::core::uint32, ra3::core::uint32)>;
|
||||
|
||||
/** A concrete presentation backend, or `none`. */
|
||||
enum class backend { none, vulkan, d3d11, d3d12, sdl };
|
||||
enum class backend { none, vulkan, d3d11, d3d12, wasmgl, webgpu, sdl };
|
||||
|
||||
/** The backend preferred on this host: WebGPU under Emscripten (falling back to the WebGL worker), else Vulkan. */
|
||||
[[nodiscard]] inline constexpr auto default_backend() -> backend {
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
return backend::webgpu;
|
||||
#else
|
||||
return backend::vulkan;
|
||||
#endif
|
||||
}
|
||||
|
||||
[[nodiscard]] inline auto backend_name(backend which) -> std::string_view {
|
||||
switch (which) {
|
||||
case backend::vulkan: return "vulkan";
|
||||
case backend::d3d11: return "d3d11";
|
||||
case backend::d3d12: return "d3d12";
|
||||
case backend::wasmgl: return "wasmgl";
|
||||
case backend::webgpu: return "webgpu";
|
||||
case backend::sdl: return "sdl";
|
||||
default: return "none";
|
||||
}
|
||||
@@ -49,19 +62,23 @@ export namespace ra3::display {
|
||||
case backend::vulkan: return std::make_unique<ra3::vulkan::vulkan_display>();
|
||||
case backend::d3d11: return std::make_unique<ra3::dx::d3d11_display>();
|
||||
case backend::d3d12: return std::make_unique<ra3::dx::d3d12_display>();
|
||||
case backend::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>();
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
/** The order in which backends are attempted for a preferred one. */
|
||||
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 4> {
|
||||
[[nodiscard]] inline auto backend_order(backend preferred) -> std::array<backend, 6> {
|
||||
switch (preferred) {
|
||||
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::sdl};
|
||||
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::sdl};
|
||||
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan};
|
||||
case backend::d3d11: return {backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl};
|
||||
case backend::d3d12: return {backend::d3d12, backend::d3d11, backend::vulkan, backend::webgpu, backend::wasmgl, backend::sdl};
|
||||
case backend::wasmgl: return {backend::wasmgl, backend::webgpu, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
|
||||
case backend::webgpu: return {backend::webgpu, backend::wasmgl, backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
|
||||
case backend::sdl: return {backend::sdl, backend::d3d11, backend::d3d12, backend::vulkan, backend::webgpu, backend::wasmgl};
|
||||
case backend::vulkan:
|
||||
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl};
|
||||
default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::webgpu, backend::wasmgl, backend::sdl};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +122,7 @@ export namespace ra3::display {
|
||||
}
|
||||
|
||||
/** Interactive menu on the preferred (or first available) backend. */
|
||||
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame, backend preferred = backend::vulkan) -> bool {
|
||||
[[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame, backend preferred = default_backend()) -> bool {
|
||||
return with_display(preferred, [&](ra3::client::display &d) { return d.run_menu(options, frame); });
|
||||
}
|
||||
|
||||
|
||||
+135
-1
@@ -19,8 +19,9 @@ export import ra3.fs;
|
||||
export namespace ra3::map {
|
||||
using ra3::core::coord3d;
|
||||
using ra3::core::real;
|
||||
using ra3::core::uint32;
|
||||
using ra3::core::uint8;
|
||||
using ra3::core::uint16;
|
||||
using ra3::core::uint32;
|
||||
using ra3::core::usize;
|
||||
|
||||
/** A player start location extracted from a map's waypoints. */
|
||||
@@ -202,6 +203,139 @@ export namespace ra3::map {
|
||||
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. */
|
||||
struct map_name_table {
|
||||
std::unordered_map<std::string, std::string> names;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,10 @@ export import ra3.map;
|
||||
export import ra3.skirmish;
|
||||
export import ra3.render;
|
||||
export import ra3.terrain;
|
||||
export import ra3.models;
|
||||
export import ra3.ui;
|
||||
export import ra3.vulkan;
|
||||
export import ra3.dx;
|
||||
export import ra3.wasmgl;
|
||||
export import ra3.webgpu;
|
||||
export import ra3.display;
|
||||
|
||||
@@ -277,6 +277,9 @@ export namespace ra3::render {
|
||||
float dy = 0.0F;
|
||||
float wheel = 0.0F;
|
||||
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). */
|
||||
@@ -675,7 +678,9 @@ export namespace ra3::render {
|
||||
float pitch = 1.02F; ///< Radians above the horizon (~58 degrees down).
|
||||
float height = 420.0F; ///< Camera height above the target's ground.
|
||||
float fov = 0.85F; ///< Vertical field of view, radians.
|
||||
float min_height = 120.0F;
|
||||
float max_height = 1600.0F;
|
||||
// Zoom range mirrors the retail TacticalView (zoom 0.2..1.3 around the
|
||||
// default height): closer/farther than that is clamped.
|
||||
float min_height = 320.0F;
|
||||
float max_height = 2100.0F;
|
||||
};
|
||||
}
|
||||
|
||||
+208
-29
@@ -5,6 +5,7 @@ import std;
|
||||
export import ra3.core;
|
||||
export import ra3.fs;
|
||||
export import ra3.render;
|
||||
export import ra3.models;
|
||||
|
||||
/**
|
||||
* The map's real terrain, read from the compiled `CkMp` chunk tree.
|
||||
@@ -394,12 +395,13 @@ export namespace ra3::terrain {
|
||||
uint32 supersample = 2U; ///< Render at Nx and box-downsample (antialiasing).
|
||||
};
|
||||
|
||||
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
|
||||
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
|
||||
const std::function<void(float)> &progress = {}) -> texture_set {
|
||||
namespace detail {
|
||||
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */
|
||||
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir)
|
||||
-> std::unordered_map<std::string, std::filesystem::path> {
|
||||
std::unordered_map<std::string, std::filesystem::path> files;
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_directory(dir, ec)) {
|
||||
if (!std::filesystem::is_directory(dir, ec)) return files;
|
||||
// Recursive: our own extract writes flat `terrain/*.tga`, ra3tools
|
||||
// writes nested `.../art/terrain/*.tga`. Only index `terrain` dirs
|
||||
// so a full asset dump does not pull in every unrelated TGA.
|
||||
@@ -409,38 +411,59 @@ export namespace ra3::terrain {
|
||||
auto parent = path.parent_path().filename().string();
|
||||
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
if (parent != "terrain") continue;
|
||||
auto stem = detail::tga_stem(path.filename().string());
|
||||
auto stem = tga_stem(path.filename().string());
|
||||
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
|
||||
files.try_emplace(stem, path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Match one map texture name against the index (see `load_textures_from_dir`). */
|
||||
[[nodiscard]] inline auto match_terrain_file(const std::unordered_map<std::string, std::filesystem::path> &files, std::string name)
|
||||
-> std::filesystem::path {
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) {
|
||||
if (const auto it = files.find(candidate); it != files.end()) return it->second;
|
||||
}
|
||||
for (const auto &[stem, path]: files) {
|
||||
if (stem.ends_with(name)) return path;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* The loose `*.tga` files `map` resolves to under `dir`, de-duplicated.
|
||||
*
|
||||
* Used to stage just the tiles a single map needs (e.g. the wasm preload).
|
||||
*/
|
||||
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
|
||||
const auto files = detail::terrain_file_index(dir);
|
||||
std::vector<std::filesystem::path> resolved;
|
||||
for (const auto &texture: map.textures) {
|
||||
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found));
|
||||
}
|
||||
std::sort(resolved.begin(), resolved.end());
|
||||
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
|
||||
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
|
||||
const std::function<void(float)> &progress = {}) -> texture_set {
|
||||
const auto files = detail::terrain_file_index(dir);
|
||||
texture_set set;
|
||||
set.images.resize(map.textures.size());
|
||||
for (usize i = 0; i < map.textures.size(); ++i) {
|
||||
auto name = map.textures[i].name;
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
|
||||
std::filesystem::path found;
|
||||
for (const auto &candidate: {name, "t" + name, "tmisc_" + name}) {
|
||||
if (const auto it = files.find(candidate); it != files.end()) {
|
||||
found = it->second;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found.empty()) {
|
||||
for (const auto &[stem, path]: files) {
|
||||
if (stem.ends_with(name)) {
|
||||
found = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.empty()) continue;
|
||||
const auto found = detail::match_terrain_file(files, map.textures[i].name);
|
||||
if (!found.empty()) {
|
||||
try {
|
||||
std::ifstream in(found, std::ios::binary);
|
||||
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
set.images[i] = ra3::render::decode_tga(raw);
|
||||
} catch (const std::exception &) {
|
||||
}
|
||||
}
|
||||
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
|
||||
}
|
||||
return set;
|
||||
@@ -459,6 +482,7 @@ export namespace ra3::terrain {
|
||||
float z_scale = 0.0390625F;
|
||||
bool has_water = false;
|
||||
float water_z = 0.0F;
|
||||
ra3::models::scene objects; ///< Buildings and props placed on the map (world-space triangle soup).
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -470,14 +494,15 @@ export namespace ra3::terrain {
|
||||
* base texture layer, the blend (and three-way) secondary layer and the
|
||||
* 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 = {},
|
||||
const std::function<void(float)> &progress = {}) -> gpu_terrain {
|
||||
[[nodiscard]] inline auto build_gpu_terrain(const map_data &map, const texture_set &set, const render_options &options,
|
||||
const ra3::models::scene &objects, const std::function<void(float)> &progress = {}) -> gpu_terrain {
|
||||
gpu_terrain out;
|
||||
out.width = map.width;
|
||||
out.height = map.height;
|
||||
out.z_scale = options.z_scale;
|
||||
out.has_water = map.has_water;
|
||||
out.water_z = map.water_plane_z;
|
||||
out.objects = objects;
|
||||
|
||||
// Cell index -> texture layer.
|
||||
const auto layer_of = [&](uint32 cell_index) -> uint16 {
|
||||
@@ -544,6 +569,12 @@ export namespace ra3::terrain {
|
||||
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 {
|
||||
/**
|
||||
* The source texture a tile cell maps to. The texture is sampled
|
||||
@@ -699,6 +730,146 @@ export namespace ra3::terrain {
|
||||
const auto c2 = f2 > 0.0F ? sample_layer(layers.three, wx, wy) : c0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -829,7 +1000,7 @@ export namespace ra3::terrain {
|
||||
* 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,
|
||||
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");
|
||||
out_w = std::max(1U, out_w);
|
||||
out_h = std::max(1U, out_h);
|
||||
@@ -946,6 +1117,7 @@ export namespace ra3::terrain {
|
||||
};
|
||||
|
||||
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) {
|
||||
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) {
|
||||
@@ -957,10 +1129,11 @@ export namespace ra3::terrain {
|
||||
dx /= dlen;
|
||||
dy /= dlen;
|
||||
dz /= dlen;
|
||||
const auto pixel = static_cast<usize>(py) * rw + px;
|
||||
|
||||
if (dz >= -1.0e-4F) {
|
||||
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));
|
||||
continue;
|
||||
}
|
||||
@@ -995,7 +1168,7 @@ export namespace ra3::terrain {
|
||||
t += dt;
|
||||
}
|
||||
if (!hit) {
|
||||
hi.data()[static_cast<usize>(py) * rw + px] = argb(150, 170, 200);
|
||||
hi.data()[pixel] = argb(150, 170, 200);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1013,10 +1186,16 @@ export namespace ra3::terrain {
|
||||
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;
|
||||
image out(out_w, out_h, argb(0, 0, 0));
|
||||
for (uint32 y = 0; y < out_h; ++y) {
|
||||
|
||||
+18
-4
@@ -101,6 +101,8 @@ export namespace ra3::ui {
|
||||
out.dx = event.motion.xrel;
|
||||
out.dy = event.motion.yrel;
|
||||
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;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
out = {};
|
||||
@@ -108,6 +110,18 @@ export namespace ra3::ui {
|
||||
out.x = event.button.x;
|
||||
out.y = event.button.y;
|
||||
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;
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
out = {};
|
||||
@@ -132,10 +146,10 @@ export namespace ra3::ui {
|
||||
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];
|
||||
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_UP];
|
||||
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_DOWN];
|
||||
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_LEFT];
|
||||
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_RIGHT];
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
+392
-21
@@ -92,6 +92,7 @@ export namespace ra3::vulkan {
|
||||
if (!this->create_device()) return false;
|
||||
if (!this->create_swapchain()) return false;
|
||||
if (!this->create_render_pass()) return false;
|
||||
if (!this->create_framebuffers()) return false;
|
||||
if (!this->create_pipeline()) return false;
|
||||
if (!this->create_commands()) return false;
|
||||
if (!this->create_sync()) return false;
|
||||
@@ -294,6 +295,8 @@ export namespace ra3::vulkan {
|
||||
out.dx = event.motion.xrel;
|
||||
out.dy = event.motion.yrel;
|
||||
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;
|
||||
case SDL_EVENT_MOUSE_BUTTON_DOWN:
|
||||
out = {};
|
||||
@@ -301,6 +304,18 @@ export namespace ra3::vulkan {
|
||||
out.x = event.button.x;
|
||||
out.y = event.button.y;
|
||||
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;
|
||||
case SDL_EVENT_MOUSE_WHEEL:
|
||||
out = {};
|
||||
@@ -325,10 +340,10 @@ export namespace ra3::vulkan {
|
||||
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];
|
||||
case ra3::render::ui_key::up: return keys[SDL_SCANCODE_UP];
|
||||
case ra3::render::ui_key::down: return keys[SDL_SCANCODE_DOWN];
|
||||
case ra3::render::ui_key::left: return keys[SDL_SCANCODE_LEFT];
|
||||
case ra3::render::ui_key::right: return keys[SDL_SCANCODE_RIGHT];
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
@@ -341,6 +356,12 @@ export namespace ra3::vulkan {
|
||||
if (!this->create_terrain_pipeline()) return false;
|
||||
if (!this->create_terrain_images(terrain)) 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;
|
||||
}
|
||||
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{};
|
||||
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;
|
||||
VkPipelineColorBlendStateCreateInfo blend{};
|
||||
@@ -690,6 +716,7 @@ export namespace ra3::vulkan {
|
||||
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 = terrain_pipeline_layout_;
|
||||
@@ -737,6 +764,198 @@ export namespace ra3::vulkan {
|
||||
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 {
|
||||
vkWaitForFences(device_, 1U, &in_flight_[current_frame_], VK_TRUE, UINT64_MAX);
|
||||
|
||||
@@ -754,13 +973,16 @@ export namespace ra3::vulkan {
|
||||
|
||||
VkClearValue clear{};
|
||||
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{};
|
||||
render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
|
||||
render_pass_begin.renderPass = render_pass_;
|
||||
render_pass_begin.framebuffer = framebuffers_[image_index];
|
||||
render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_};
|
||||
render_pass_begin.clearValueCount = 1U;
|
||||
render_pass_begin.pClearValues = &clear;
|
||||
render_pass_begin.clearValueCount = 2U;
|
||||
render_pass_begin.pClearValues = clears;
|
||||
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};
|
||||
@@ -793,6 +1015,49 @@ export namespace ra3::vulkan {
|
||||
vkCmdPushConstants(cmd, terrain_pipeline_layout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0U, sizeof(push), push);
|
||||
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
|
||||
// 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) {
|
||||
@@ -861,6 +1126,27 @@ export namespace ra3::vulkan {
|
||||
terrain_set_ = VK_NULL_HANDLE;
|
||||
overlay_label_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);
|
||||
for (uint32_t i = 0; i < actual; ++i) {
|
||||
VkImageView attachments[] = {swapchain_views_[i]};
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 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{};
|
||||
framebuffer_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
|
||||
framebuffer_info.renderPass = render_pass_;
|
||||
framebuffer_info.attachmentCount = 1U;
|
||||
framebuffer_info.attachmentCount = 2U;
|
||||
framebuffer_info.pAttachments = attachments;
|
||||
framebuffer_info.width = extent.width;
|
||||
framebuffer_info.height = extent.height;
|
||||
framebuffer_info.width = swapchain_extent_.width;
|
||||
framebuffer_info.height = swapchain_extent_.height;
|
||||
framebuffer_info.layers = 1U;
|
||||
if (!detail::check(vkCreateFramebuffer(device_, &framebuffer_info, nullptr, &framebuffers_[i]), "vkCreateFramebuffer")) return false;
|
||||
}
|
||||
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 {
|
||||
for (const auto framebuffer: framebuffers_) vkDestroyFramebuffer(device_, framebuffer, nullptr);
|
||||
this->destroy_depth_resources();
|
||||
for (const auto view: swapchain_views_) vkDestroyImageView(device_, view, nullptr);
|
||||
framebuffers_.clear();
|
||||
swapchain_views_.clear();
|
||||
@@ -1041,7 +1382,7 @@ export namespace ra3::vulkan {
|
||||
if (width == 0 || height == 0) return false;
|
||||
vkDeviceWaitIdle(device_);
|
||||
this->cleanup_swapchain();
|
||||
return this->create_swapchain();
|
||||
return this->create_swapchain() && this->create_framebuffers();
|
||||
}
|
||||
|
||||
// ---- pipeline --------------------------------------------------------
|
||||
@@ -1058,22 +1399,36 @@ export namespace ra3::vulkan {
|
||||
color.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
|
||||
|
||||
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{};
|
||||
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
|
||||
subpass.colorAttachmentCount = 1U;
|
||||
subpass.pColorAttachments = &color_ref;
|
||||
subpass.pDepthStencilAttachment = &depth_ref;
|
||||
|
||||
VkSubpassDependency dependency{};
|
||||
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
|
||||
dependency.dstSubpass = 0U;
|
||||
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
||||
dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
||||
dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_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 | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_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{};
|
||||
render_pass_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO;
|
||||
render_pass_info.attachmentCount = 1U;
|
||||
render_pass_info.pAttachments = &color;
|
||||
render_pass_info.attachmentCount = 2U;
|
||||
render_pass_info.pAttachments = attachments;
|
||||
render_pass_info.subpassCount = 1U;
|
||||
render_pass_info.pSubpasses = &subpass;
|
||||
render_pass_info.dependencyCount = 1U;
|
||||
@@ -1211,10 +1566,10 @@ export namespace ra3::vulkan {
|
||||
command_buffers_.resize(frames_in_flight);
|
||||
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{};
|
||||
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.pPoolSizes = &pool_size;
|
||||
if (!detail::check(vkCreateDescriptorPool(device_, &descriptor_pool_info, nullptr, &descriptor_pool_), "vkCreateDescriptorPool")) return false;
|
||||
@@ -1307,13 +1662,16 @@ export namespace ra3::vulkan {
|
||||
|
||||
VkClearValue clear{};
|
||||
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{};
|
||||
render_pass_begin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
|
||||
render_pass_begin.renderPass = render_pass_;
|
||||
render_pass_begin.framebuffer = framebuffers_[image_index];
|
||||
render_pass_begin.renderArea = {{0U, 0U}, swapchain_extent_};
|
||||
render_pass_begin.clearValueCount = 1U;
|
||||
render_pass_begin.pClearValues = &clear;
|
||||
render_pass_begin.clearValueCount = 2U;
|
||||
render_pass_begin.pClearValues = clears;
|
||||
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};
|
||||
@@ -1433,6 +1791,9 @@ export namespace ra3::vulkan {
|
||||
std::vector<VkImage> swapchain_images_;
|
||||
std::vector<VkImageView> swapchain_views_;
|
||||
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;
|
||||
VkDescriptorSetLayout descriptor_layout_ = VK_NULL_HANDLE;
|
||||
VkPipelineLayout pipeline_layout_ = VK_NULL_HANDLE;
|
||||
@@ -1464,6 +1825,16 @@ export namespace ra3::vulkan {
|
||||
VkPipeline terrain_pipeline_ = VK_NULL_HANDLE;
|
||||
VkDescriptorPool terrain_pool_ = 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> render_finished_;
|
||||
std::vector<VkFence> in_flight_;
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
module;
|
||||
|
||||
// Emscripten/GLES3 headers in the global module fragment (C headers). No SDL:
|
||||
// this backend runs the engine on a Web Worker and renders into an
|
||||
// OffscreenCanvas transferred from the page, so it must not touch the
|
||||
// main-thread-only SDL Emscripten port.
|
||||
#include <GLES3/gl3.h>
|
||||
|
||||
#include <emscripten/em_asm.h>
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
|
||||
// Sized normalized texture formats used by the terrain textures; core in
|
||||
// WebGL2/GLES3 but missing from some GLES3 headers.
|
||||
#ifndef GL_R16
|
||||
#define GL_R16 0x822A
|
||||
#endif
|
||||
#ifndef GL_RGBA16
|
||||
#define GL_RGBA16 0x805B
|
||||
#endif
|
||||
|
||||
#include "wasmgl_glsl_embedded.hpp"
|
||||
|
||||
export module ra3.wasmgl;
|
||||
|
||||
import std;
|
||||
|
||||
export import ra3.core;
|
||||
export import ra3.render;
|
||||
export import ra3.terrain;
|
||||
export import ra3.client;
|
||||
import ender.log;
|
||||
|
||||
/**
|
||||
* An SDL-free WebGL 2 presentation backend for the wasm build.
|
||||
*
|
||||
* The whole runtime runs on a plain Web Worker (started by
|
||||
* `apps/web/openra3.worker.js`), so `main()` lives there and its virtual
|
||||
* filesystem is local to the worker: `FS.createLazyFile` (synchronous XHR) is
|
||||
* legal and the browser fetches each asset only when the engine opens it.
|
||||
* Note that Emscripten's `PROXY_TO_PTHREAD` cannot be used for this: in a
|
||||
* pthread every filesystem syscall is proxied to the main browser thread, so
|
||||
* the engine would read the main thread's FS, not the worker's lazy one.
|
||||
*
|
||||
* The page transfers its `#canvas` to the worker as an OffscreenCanvas; this
|
||||
* module creates the WebGL2 context on it with `emscripten_webgl_create_context`
|
||||
* and draws the same 2D menu / GPU terrain as the other backends. Input arrives
|
||||
* over the worker message channel (the page owns the DOM listeners) into the
|
||||
* `openra3_*` C functions below, which queue `ra3::render::ui_event`s.
|
||||
*
|
||||
* It reuses the same GL pipeline/texture code as the desktop GPU backends; only
|
||||
* the windowing/context/event layer differs.
|
||||
*/
|
||||
export namespace ra3::wasmgl {
|
||||
class wasmgl_display;
|
||||
|
||||
/**
|
||||
* Receives the input events the worker forwards from the page. Each wasm
|
||||
* backend (WebGL here, WebGPU in `ra3.webgpu`) implements it and registers
|
||||
* itself on init, so the `openra3_*` C exports live in exactly one module.
|
||||
*/
|
||||
class event_sink {
|
||||
public:
|
||||
virtual ~event_sink() = default;
|
||||
virtual void on_key(std::string_view code, bool down) = 0;
|
||||
virtual void on_text(int codepoint) = 0;
|
||||
virtual void on_mouse_button(double x, double y, bool left) = 0;
|
||||
virtual void on_mouse_move(double x, double y, double dx, double dy, bool left) = 0;
|
||||
virtual void on_wheel(double delta_y) = 0;
|
||||
virtual void on_resize(int width, int height) = 0;
|
||||
virtual void on_quit() = 0;
|
||||
};
|
||||
|
||||
namespace detail {
|
||||
/** The backend that currently receives worker input. */
|
||||
[[nodiscard]] inline auto sink_slot() -> event_sink *& {
|
||||
static event_sink *sink = nullptr;
|
||||
return sink;
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
/** Register (or, with null, clear) the backend that receives input events. */
|
||||
inline auto set_event_sink(event_sink *sink) -> void { detail::sink_slot() = sink; }
|
||||
|
||||
namespace detail {
|
||||
/** Movement keys tracked as "held" for panning (physical codes). */
|
||||
enum class move_key : std::uint8_t { w, s, a, d, arrow_up, arrow_down, arrow_left, arrow_right, count };
|
||||
|
||||
[[nodiscard]] inline auto move_key_index(std::string_view code) -> int {
|
||||
if (code == "KeyW") return static_cast<int>(move_key::w);
|
||||
if (code == "KeyS") return static_cast<int>(move_key::s);
|
||||
if (code == "KeyA") return static_cast<int>(move_key::a);
|
||||
if (code == "KeyD") return static_cast<int>(move_key::d);
|
||||
if (code == "ArrowUp") return static_cast<int>(move_key::arrow_up);
|
||||
if (code == "ArrowDown") return static_cast<int>(move_key::arrow_down);
|
||||
if (code == "ArrowLeft") return static_cast<int>(move_key::arrow_left);
|
||||
if (code == "ArrowRight") return static_cast<int>(move_key::arrow_right);
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** The `ui_key` for an action key, or `none`. */
|
||||
[[nodiscard]] inline auto key_from_code(std::string_view code) -> ra3::render::ui_key {
|
||||
using ra3::render::ui_key;
|
||||
if (code == "ArrowUp") return ui_key::up;
|
||||
if (code == "ArrowDown") return ui_key::down;
|
||||
if (code == "ArrowLeft") return ui_key::left;
|
||||
if (code == "ArrowRight") return ui_key::right;
|
||||
if (code == "PageUp") return ui_key::page_up;
|
||||
if (code == "PageDown") return ui_key::page_down;
|
||||
if (code == "Enter" || code == "NumpadEnter") return ui_key::confirm;
|
||||
if (code == "Escape") return ui_key::cancel;
|
||||
if (code == "Tab") return ui_key::tab;
|
||||
if (code == "Backspace") return ui_key::backspace;
|
||||
return ui_key::none;
|
||||
}
|
||||
|
||||
/** The single live display (there is only ever one). */
|
||||
[[nodiscard]] inline auto active_slot() -> wasmgl_display *& {
|
||||
static wasmgl_display *instance = nullptr;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Camera + terrain uniforms (5 x vec4), matching the other backends. */
|
||||
[[nodiscard]] inline auto terrain_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||
float aspect) -> std::array<float, 20> {
|
||||
return {camera.target_x,
|
||||
camera.target_y,
|
||||
camera.yaw,
|
||||
camera.height,
|
||||
camera.pitch,
|
||||
camera.fov,
|
||||
terrain.water_z,
|
||||
terrain.has_water ? 1.0F : 0.0F,
|
||||
0.45F,
|
||||
0.35F,
|
||||
0.82F,
|
||||
0.38F,
|
||||
static_cast<float>(terrain.width),
|
||||
static_cast<float>(terrain.height),
|
||||
0.0F,
|
||||
terrain.z_scale,
|
||||
time_s,
|
||||
0.0F,
|
||||
terrain.cell_span,
|
||||
aspect};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `assets.manifest.json` and register every listed file as an
|
||||
* Emscripten lazy file, so the browser fetches a file only when the
|
||||
* engine first opens it. Runs on the worker, where synchronous XHR
|
||||
* (used by `FS.createLazyFile`) is allowed. Returns the number of files
|
||||
* registered, or -1 on failure. A missing manifest is not an error (the
|
||||
* caller may have preloaded a full asset tree instead).
|
||||
*/
|
||||
[[nodiscard]] inline auto mount_lazy_assets(const std::string &manifest_path, const std::string &assets_root, const std::string &url_prefix)
|
||||
-> int {
|
||||
std::ifstream in(manifest_path, std::ios::binary);
|
||||
if (!in) return 0;
|
||||
const std::string json{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
|
||||
if (json.empty()) return 0;
|
||||
return EM_ASM_INT(
|
||||
{
|
||||
try {
|
||||
var manifest = JSON.parse(UTF8ToString($0));
|
||||
var root = UTF8ToString($1);
|
||||
var prefix = UTF8ToString($2);
|
||||
var files = manifest.files || [];
|
||||
var n = 0;
|
||||
for (var i = 0; i < files.length; ++i) {
|
||||
var rel = files[i];
|
||||
var full = root + '/' + rel;
|
||||
var slash = full.lastIndexOf('/');
|
||||
var dir = full.substring(0, slash);
|
||||
var name = full.substring(slash + 1);
|
||||
FS.mkdirTree(dir);
|
||||
FS.createLazyFile(dir, name, prefix + rel, true, false);
|
||||
++n;
|
||||
}
|
||||
console.log('ra3.wasmgl: mounted ' + n + ' lazy assets under ' + root);
|
||||
return n;
|
||||
} catch (e) {
|
||||
console.error('ra3.wasmgl: lazy asset mount failed: ' + e);
|
||||
return -1;
|
||||
}
|
||||
},
|
||||
json.c_str(), assets_root.c_str(), url_prefix.c_str());
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
/**
|
||||
* Mount the wasm asset tree lazily (call once, before any asset access).
|
||||
* On non-wasm builds this does nothing.
|
||||
*/
|
||||
inline auto mount_assets(const std::string &manifest_path = "/assets.manifest.json", const std::string &assets_root = "/assets",
|
||||
const std::string &url_prefix = "assets/") -> int {
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
return detail::mount_lazy_assets(manifest_path, assets_root, url_prefix);
|
||||
#else
|
||||
(void) manifest_path;
|
||||
(void) assets_root;
|
||||
(void) url_prefix;
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
class wasmgl_display final : public ra3::client::display, public event_sink {
|
||||
public:
|
||||
wasmgl_display() = default;
|
||||
wasmgl_display(const wasmgl_display &) = delete;
|
||||
auto operator=(const wasmgl_display &) -> wasmgl_display & = delete;
|
||||
~wasmgl_display() override { this->shutdown(); }
|
||||
|
||||
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
|
||||
this->fps_limit_ = options.fps_limit;
|
||||
|
||||
// There is no DOM in the worker, so Emscripten's canvas lookup
|
||||
// (`findEventTarget`, a CSS selector) cannot find #canvas. Register
|
||||
// the transferred OffscreenCanvas (Module.canvas) as a special target
|
||||
// so `emscripten_webgl_create_context`/`..._element_size` resolve it.
|
||||
EM_ASM({
|
||||
if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) {
|
||||
specialHTMLTargets['#canvas'] = Module['canvas'];
|
||||
}
|
||||
});
|
||||
|
||||
EmscriptenWebGLContextAttributes attributes;
|
||||
emscripten_webgl_init_context_attributes(&attributes);
|
||||
attributes.majorVersion = 2;
|
||||
attributes.minorVersion = 0;
|
||||
attributes.alpha = false;
|
||||
attributes.depth = true;
|
||||
attributes.stencil = false;
|
||||
attributes.antialias = false;
|
||||
attributes.premultipliedAlpha = false;
|
||||
attributes.preserveDrawingBuffer = false;
|
||||
attributes.powerPreference = EM_WEBGL_POWER_PREFERENCE_HIGH_PERFORMANCE;
|
||||
attributes.proxyContextToMainThread = EMSCRIPTEN_WEBGL_CONTEXT_PROXY_DISALLOW;
|
||||
context_ = emscripten_webgl_create_context("#canvas", &attributes);
|
||||
if (context_ == 0) {
|
||||
ender::log::error("ra3.wasmgl: emscripten_webgl_create_context failed (is #canvas an OffscreenCanvas in this worker?)");
|
||||
return false;
|
||||
}
|
||||
if (emscripten_webgl_make_context_current(context_) != EMSCRIPTEN_RESULT_SUCCESS) {
|
||||
ender::log::error("ra3.wasmgl: emscripten_webgl_make_context_current failed");
|
||||
this->shutdown();
|
||||
return false;
|
||||
}
|
||||
// The page sized the canvas before transfer; fall back to the
|
||||
// requested size if it is missing.
|
||||
const auto [width, height] = this->window_size();
|
||||
if (width <= 0 || height <= 0) {
|
||||
emscripten_set_canvas_element_size("#canvas", options.width, options.height);
|
||||
}
|
||||
if (!this->create_scene_pipeline()) {
|
||||
this->shutdown();
|
||||
return false;
|
||||
}
|
||||
glGenVertexArrays(1, &vao_);
|
||||
glBindVertexArray(vao_);
|
||||
detail::active_slot() = this;
|
||||
detail::sink_slot() = this;
|
||||
ender::log::info(std::format("ra3.wasmgl: WebGL worker backend initialized ({})",
|
||||
reinterpret_cast<const char *>(glGetString(GL_VERSION))));
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
|
||||
if (frame.empty()) return false;
|
||||
if (changed || scene_tex_ == 0U || frame.width() != scene_w_ || frame.height() != scene_h_) {
|
||||
this->upload_scene(frame);
|
||||
}
|
||||
const auto [window_w, window_h] = this->window_size();
|
||||
if (window_w <= 0 || window_h <= 0) return true;
|
||||
|
||||
glViewport(0, 0, window_w, window_h);
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
glClearColor(0.05F, 0.06F, 0.08F, 1.0F);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
this->draw_fullscreen(scene_program_, scene_rect_loc_, scene_tex_, dest, window_w, window_h);
|
||||
this->mark_frame();
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override {
|
||||
if (events_.empty()) return false;
|
||||
out = events_.front();
|
||||
events_.pop_front();
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0};
|
||||
return {w, h};
|
||||
}
|
||||
|
||||
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override {
|
||||
const auto held = [this](detail::move_key which) { return held_[static_cast<std::size_t>(which)]; };
|
||||
switch (key) {
|
||||
case ra3::render::ui_key::up: return held(detail::move_key::w) || held(detail::move_key::arrow_up);
|
||||
case ra3::render::ui_key::down: return held(detail::move_key::s) || held(detail::move_key::arrow_down);
|
||||
case ra3::render::ui_key::left: return held(detail::move_key::a) || held(detail::move_key::arrow_left);
|
||||
case ra3::render::ui_key::right: return held(detail::move_key::d) || held(detail::move_key::arrow_right);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
|
||||
|
||||
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||
const ra3::client::terrain_overlay &overlay) -> bool override {
|
||||
if (!terrain_ready_) {
|
||||
if (!this->create_terrain_pipeline()) return false;
|
||||
if (!this->create_terrain_textures(terrain)) return false;
|
||||
terrain_ready_ = true;
|
||||
ender::log::info(std::format("ra3.wasmgl: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
|
||||
}
|
||||
if (overlay.label_changed) this->upload_overlay(label_tex_, overlay.label);
|
||||
if (overlay.minimap_changed) this->upload_overlay(minimap_tex_, overlay.minimap);
|
||||
|
||||
const auto [window_w, window_h] = this->window_size();
|
||||
if (window_w <= 0 || window_h <= 0) return true;
|
||||
const auto aspect = static_cast<float>(window_w) / static_cast<float>(std::max(1, window_h));
|
||||
|
||||
glViewport(0, 0, window_w, window_h);
|
||||
glDisable(GL_DEPTH_TEST);
|
||||
glEnable(GL_BLEND);
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
glClearColor(0.45F, 0.55F, 0.70F, 1.0F);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
glBindVertexArray(vao_);
|
||||
|
||||
glUseProgram(terrain_program_);
|
||||
const auto uniforms = detail::terrain_uniforms(terrain, camera, time_s, aspect);
|
||||
glUniform4fv(terrain_cam_loc_, 5, uniforms.data());
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, height_tex_);
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, cell_tex_);
|
||||
glActiveTexture(GL_TEXTURE2);
|
||||
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
|
||||
glUniform1i(terrain_height_loc_, 0);
|
||||
glUniform1i(terrain_cell_loc_, 1);
|
||||
glUniform1i(terrain_atlas_loc_, 2);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
|
||||
// Overlays (FPS label, corner minimap) use the image program.
|
||||
const float margin = 12.0F;
|
||||
this->draw_overlay(label_tex_, overlay.label.width(), overlay.label.height(), margin, margin, window_w, window_h);
|
||||
this->draw_overlay(minimap_tex_, overlay.minimap.width(), overlay.minimap.height(),
|
||||
static_cast<float>(window_w) - static_cast<float>(overlay.minimap.width()) - margin, margin, window_w, window_h);
|
||||
this->mark_frame();
|
||||
return true;
|
||||
}
|
||||
|
||||
auto shutdown() -> void override {
|
||||
if (detail::active_slot() == this) detail::active_slot() = nullptr;
|
||||
if (detail::sink_slot() == this) detail::sink_slot() = nullptr;
|
||||
if (context_ != 0) {
|
||||
this->destroy_textures();
|
||||
if (scene_program_ != 0U) glDeleteProgram(scene_program_);
|
||||
if (terrain_program_ != 0U) glDeleteProgram(terrain_program_);
|
||||
if (vao_ != 0U) glDeleteVertexArrays(1, &vao_);
|
||||
emscripten_webgl_destroy_context(context_);
|
||||
}
|
||||
context_ = 0;
|
||||
vao_ = 0U;
|
||||
scene_program_ = terrain_program_ = 0U;
|
||||
scene_rect_loc_ = terrain_cam_loc_ = terrain_height_loc_ = terrain_cell_loc_ = terrain_atlas_loc_ = -1;
|
||||
terrain_ready_ = false;
|
||||
scene_w_ = scene_h_ = 0U;
|
||||
events_.clear();
|
||||
held_.fill(false);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto name() const -> std::string_view override { return "wasmgl"; }
|
||||
|
||||
// ---- worker message entry points (see apps/web/openra3.worker.js) ----
|
||||
|
||||
/** A key down/up; `code` is a DOM `KeyboardEvent.code`. */
|
||||
auto on_key(std::string_view code, bool down) -> void override {
|
||||
if (const int index = detail::move_key_index(code); index >= 0) {
|
||||
held_[static_cast<std::size_t>(index)] = down;
|
||||
}
|
||||
if (!down) return;
|
||||
if (const auto key = detail::key_from_code(code); key != ra3::render::ui_key::none) {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::key;
|
||||
out.key = key;
|
||||
events_.push_back(out);
|
||||
}
|
||||
}
|
||||
|
||||
/** A printable character (DOM `KeyboardEvent.charCode`). */
|
||||
auto on_text(int codepoint) -> void override {
|
||||
if (codepoint <= 0 || codepoint > 0x7F) return;
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::text;
|
||||
out.character = static_cast<char>(codepoint);
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_mouse_button(double x, double y, bool left) -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::mouse_button;
|
||||
out.x = static_cast<float>(x);
|
||||
out.y = static_cast<float>(y);
|
||||
out.left = left;
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::mouse_move;
|
||||
out.x = static_cast<float>(x);
|
||||
out.y = static_cast<float>(y);
|
||||
out.dx = static_cast<float>(dx);
|
||||
out.dy = static_cast<float>(dy);
|
||||
out.left = left;
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_wheel(double delta_y) -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::wheel;
|
||||
out.wheel = static_cast<float>(-delta_y); // browser +Y is scroll-down; SDL +Y is scroll-up.
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_quit() -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::quit;
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
/** Resize the drawing buffer (backing pixels, already DPR-scaled). */
|
||||
auto on_resize(int width, int height) -> void override {
|
||||
if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height);
|
||||
}
|
||||
|
||||
private:
|
||||
// ---- GL helpers ------------------------------------------------------
|
||||
|
||||
[[nodiscard]] auto compile(unsigned type, const char *source, std::string_view what) -> GLuint {
|
||||
const GLuint shader = glCreateShader(type);
|
||||
glShaderSource(shader, 1, &source, nullptr);
|
||||
glCompileShader(shader);
|
||||
GLint ok = GL_FALSE;
|
||||
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
|
||||
if (ok == GL_FALSE) {
|
||||
std::array<char, 1024> log{};
|
||||
glGetShaderInfoLog(shader, static_cast<GLsizei>(log.size()), nullptr, log.data());
|
||||
ender::log::error(std::format("ra3.wasmgl: {} shader compile failed: {}", what, log.data()));
|
||||
glDeleteShader(shader);
|
||||
return 0U;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto link(const char *vertex, const char *fragment, std::string_view what) -> GLuint {
|
||||
const GLuint vs = this->compile(GL_VERTEX_SHADER, vertex, what);
|
||||
const GLuint fs = this->compile(GL_FRAGMENT_SHADER, fragment, what);
|
||||
if (vs == 0U || fs == 0U) {
|
||||
if (vs != 0U) glDeleteShader(vs);
|
||||
if (fs != 0U) glDeleteShader(fs);
|
||||
return 0U;
|
||||
}
|
||||
const GLuint program = glCreateProgram();
|
||||
glAttachShader(program, vs);
|
||||
glAttachShader(program, fs);
|
||||
glLinkProgram(program);
|
||||
glDeleteShader(vs);
|
||||
glDeleteShader(fs);
|
||||
GLint ok = GL_FALSE;
|
||||
glGetProgramiv(program, GL_LINK_STATUS, &ok);
|
||||
if (ok == GL_FALSE) {
|
||||
std::array<char, 1024> log{};
|
||||
glGetProgramInfoLog(program, static_cast<GLsizei>(log.size()), nullptr, log.data());
|
||||
ender::log::error(std::format("ra3.wasmgl: {} program link failed: {}", what, log.data()));
|
||||
glDeleteProgram(program);
|
||||
return 0U;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_scene_pipeline() -> bool {
|
||||
scene_program_ = this->link(ra3_shaders::webgl_scene_vert_glsl, ra3_shaders::webgl_scene_frag_glsl, "scene");
|
||||
if (scene_program_ == 0U) return false;
|
||||
scene_rect_loc_ = glGetUniformLocation(scene_program_, "u_rect");
|
||||
glUseProgram(scene_program_);
|
||||
glUniform1i(glGetUniformLocation(scene_program_, "u_scene"), 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_terrain_pipeline() -> bool {
|
||||
terrain_program_ = this->link(ra3_shaders::webgl_terrain_vert_glsl, ra3_shaders::webgl_terrain_frag_glsl, "terrain");
|
||||
if (terrain_program_ == 0U) return false;
|
||||
terrain_cam_loc_ = glGetUniformLocation(terrain_program_, "u_data");
|
||||
terrain_height_loc_ = glGetUniformLocation(terrain_program_, "u_heightmap");
|
||||
terrain_cell_loc_ = glGetUniformLocation(terrain_program_, "u_celldata");
|
||||
terrain_atlas_loc_ = glGetUniformLocation(terrain_program_, "u_atlas");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** (Re)create the 2D texture used by the scene/overlay path. */
|
||||
auto make_texture2d(GLuint &texture, GLint internal, GLenum format, GLenum type, int w, int h, const void *data, bool linear,
|
||||
bool wrap) -> void {
|
||||
if (texture == 0U) glGenTextures(1, &texture);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, internal, w, h, 0, format, type, data);
|
||||
if (const GLenum error = glGetError(); error != GL_NO_ERROR) {
|
||||
ender::log::error(std::format("ra3.wasmgl: texImage2D failed: internal=0x{:X} format=0x{:X} type=0x{:X} size={}x{} err=0x{:X}",
|
||||
static_cast<unsigned>(internal), static_cast<unsigned>(format), static_cast<unsigned>(type), w, h,
|
||||
static_cast<unsigned>(error)));
|
||||
}
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, linear ? GL_LINEAR : GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, linear ? GL_LINEAR : GL_NEAREST);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap ? GL_REPEAT : GL_CLAMP_TO_EDGE);
|
||||
}
|
||||
|
||||
auto upload_scene(const ra3::render::image &frame) -> void {
|
||||
// The image is 0xAARRGGBB (BGRA in memory); store it as RGBA and let
|
||||
// the shader swizzle `.bgra`.
|
||||
this->make_texture2d(scene_tex_, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(frame.width()),
|
||||
static_cast<int>(frame.height()), frame.data(), true, false);
|
||||
scene_w_ = frame.width();
|
||||
scene_h_ = frame.height();
|
||||
}
|
||||
|
||||
auto upload_overlay(GLuint &texture, const ra3::render::image &source) -> void {
|
||||
if (source.empty()) return;
|
||||
this->make_texture2d(texture, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, static_cast<int>(source.width()),
|
||||
static_cast<int>(source.height()), source.data(), true, false);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool {
|
||||
this->make_texture2d(height_tex_, GL_R16, GL_RED, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
|
||||
static_cast<int>(terrain.height), terrain.heights.data(), false, false);
|
||||
this->make_texture2d(cell_tex_, GL_RGBA16, GL_RGBA, GL_UNSIGNED_SHORT, static_cast<int>(terrain.width),
|
||||
static_cast<int>(terrain.height), terrain.cell_data.data(), false, false);
|
||||
if (atlas_tex_ == 0U) glGenTextures(1, &atlas_tex_);
|
||||
glBindTexture(GL_TEXTURE_2D_ARRAY, atlas_tex_);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
glTexImage3D(GL_TEXTURE_2D_ARRAY, 0, GL_RGBA8, static_cast<GLsizei>(terrain.layer_size), static_cast<GLsizei>(terrain.layer_size),
|
||||
static_cast<GLsizei>(terrain.layer_count), 0, GL_RGBA, GL_UNSIGNED_BYTE, terrain.layers.data());
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT);
|
||||
glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_REPEAT);
|
||||
return true;
|
||||
}
|
||||
|
||||
auto destroy_textures() -> void {
|
||||
for (GLuint *texture: {&scene_tex_, &height_tex_, &cell_tex_, &atlas_tex_, &label_tex_, &minimap_tex_}) {
|
||||
if (*texture != 0U) glDeleteTextures(1, texture);
|
||||
*texture = 0U;
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a coarse frame counter to the page for the fps probe. */
|
||||
auto mark_frame() const -> void {
|
||||
++frames_;
|
||||
if (frames_ % 15U == 0U) {
|
||||
EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast<int>(frames_));
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_fullscreen(GLuint program, GLint rect_location, GLuint texture, const ra3::render::view_rect &dest, int window_w, int window_h)
|
||||
-> void {
|
||||
glUseProgram(program);
|
||||
glBindVertexArray(vao_);
|
||||
const float rect[4] = {dest.x / static_cast<float>(window_w), dest.y / static_cast<float>(window_h),
|
||||
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(window_h)};
|
||||
glUniform4fv(rect_location, 1, rect);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, texture);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
auto draw_overlay(GLuint texture, core::uint32 w, core::uint32 h, float x, float y, int window_w, int window_h) -> void {
|
||||
if (texture == 0U || w == 0U || h == 0U) return;
|
||||
const ra3::render::view_rect rect{x, y, static_cast<float>(w), static_cast<float>(h)};
|
||||
this->draw_fullscreen(scene_program_, scene_rect_loc_, texture, rect, window_w, window_h);
|
||||
}
|
||||
|
||||
EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context_ = 0;
|
||||
GLuint vao_ = 0U;
|
||||
|
||||
GLuint scene_program_ = 0U;
|
||||
GLint scene_rect_loc_ = -1;
|
||||
GLuint terrain_program_ = 0U;
|
||||
GLint terrain_cam_loc_ = -1;
|
||||
GLint terrain_height_loc_ = -1;
|
||||
GLint terrain_cell_loc_ = -1;
|
||||
GLint terrain_atlas_loc_ = -1;
|
||||
|
||||
GLuint scene_tex_ = 0U;
|
||||
core::uint32 scene_w_ = 0U;
|
||||
core::uint32 scene_h_ = 0U;
|
||||
bool terrain_ready_ = false;
|
||||
GLuint height_tex_ = 0U;
|
||||
GLuint cell_tex_ = 0U;
|
||||
GLuint atlas_tex_ = 0U;
|
||||
GLuint label_tex_ = 0U;
|
||||
GLuint minimap_tex_ = 0U;
|
||||
|
||||
std::deque<ra3::render::ui_event> events_;
|
||||
std::array<bool, static_cast<std::size_t>(detail::move_key::count)> held_{};
|
||||
mutable unsigned frames_ = 0U;
|
||||
};
|
||||
|
||||
// ---- C API called by apps/web/openra3.worker.js --------------------------
|
||||
|
||||
/** Key down/up: `code` is a DOM KeyboardEvent.code. */
|
||||
inline auto post_key(const char *code, int down) -> void {
|
||||
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_key(code != nullptr ? code : "", down != 0);
|
||||
}
|
||||
|
||||
/** Printable character (DOM charCode). */
|
||||
inline auto post_text(int codepoint) -> void {
|
||||
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_text(codepoint);
|
||||
}
|
||||
|
||||
inline auto post_mouse_button(double x, double y, int left) -> void {
|
||||
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_button(x, y, left != 0);
|
||||
}
|
||||
|
||||
inline auto post_mouse_move(double x, double y, double dx, double dy, int left) -> void {
|
||||
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_mouse_move(x, y, dx, dy, left != 0);
|
||||
}
|
||||
|
||||
inline auto post_wheel(double delta_y) -> void {
|
||||
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_wheel(delta_y);
|
||||
}
|
||||
|
||||
inline auto post_resize(int width, int height) -> void {
|
||||
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_resize(width, height);
|
||||
}
|
||||
|
||||
inline auto post_quit() -> void {
|
||||
if (auto *sink = detail::sink_slot(); sink != nullptr) sink->on_quit();
|
||||
}
|
||||
} // namespace ra3::wasmgl
|
||||
|
||||
extern "C" {
|
||||
EMSCRIPTEN_KEEPALIVE inline auto openra3_key(const char *code, int down) -> void { ra3::wasmgl::post_key(code, down); }
|
||||
EMSCRIPTEN_KEEPALIVE inline auto openra3_text(int codepoint) -> void { ra3::wasmgl::post_text(codepoint); }
|
||||
EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_button(double x, double y, int left) -> void { ra3::wasmgl::post_mouse_button(x, y, left); }
|
||||
EMSCRIPTEN_KEEPALIVE inline auto openra3_mouse_move(double x, double y, double dx, double dy, int left) -> void {
|
||||
ra3::wasmgl::post_mouse_move(x, y, dx, dy, left);
|
||||
}
|
||||
EMSCRIPTEN_KEEPALIVE inline auto openra3_wheel(double delta_y) -> void { ra3::wasmgl::post_wheel(delta_y); }
|
||||
EMSCRIPTEN_KEEPALIVE inline auto openra3_resize(int width, int height) -> void { ra3::wasmgl::post_resize(width, height); }
|
||||
EMSCRIPTEN_KEEPALIVE inline auto openra3_quit() -> void { ra3::wasmgl::post_quit(); }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export module ra3.wasmgl;
|
||||
|
||||
import std;
|
||||
|
||||
export import ra3.core;
|
||||
import ra3.render;
|
||||
import ra3.client;
|
||||
|
||||
/**
|
||||
* Fallback wasm-worker WebGL backend for builds without Emscripten. `init` fails
|
||||
* so the caller can select another display; the class name matches the real
|
||||
* `ra3.wasmgl` module so the backend factory in `ra3.display` compiles
|
||||
* unchanged.
|
||||
*/
|
||||
export namespace ra3::wasmgl {
|
||||
/** No-op off Emscripten (there is no Emscripten virtual filesystem). */
|
||||
inline auto mount_assets(const std::string & = "/assets.manifest.json", const std::string & = "/assets",
|
||||
const std::string & = "assets/") -> int {
|
||||
return 0;
|
||||
}
|
||||
|
||||
class wasmgl_display final : public ra3::client::display {
|
||||
public:
|
||||
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
|
||||
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
|
||||
[[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; }
|
||||
[[nodiscard]] auto window_size() const -> std::pair<int, int> override { return {0, 0}; }
|
||||
[[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; }
|
||||
auto shutdown() -> void override {}
|
||||
[[nodiscard]] auto name() const -> std::string_view override { return "wasmgl(null)"; }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
module;
|
||||
|
||||
// Dawn's header-only C++ wrapper for the WebGPU C API, provided by Emscripten's
|
||||
// emdawnwebgpu port (`--use-port=emdawnwebgpu`). This backend is wasm-only: on
|
||||
// the web WebGPU accepts WGSL only (SPIR-V is not available), so the shaders are
|
||||
// WGSL ports of the Vulkan/WebGL ones rather than a reuse of the SPIR-V blobs.
|
||||
#include <webgpu/webgpu_cpp.h>
|
||||
|
||||
#include <emscripten/em_asm.h>
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
|
||||
#include "webgpu_wgsl_embedded.hpp"
|
||||
|
||||
export module ra3.webgpu;
|
||||
|
||||
import std;
|
||||
|
||||
export import ra3.core;
|
||||
export import ra3.render;
|
||||
export import ra3.terrain;
|
||||
export import ra3.client;
|
||||
import ra3.wasmgl;
|
||||
import ender.log;
|
||||
|
||||
/**
|
||||
* A WebGPU presentation backend for the wasm build, a peer of `ra3.wasmgl`.
|
||||
*
|
||||
* It runs in the same plain Web Worker and draws into the same transferred
|
||||
* OffscreenCanvas, but through WebGPU: a surface is created on `#canvas`, a
|
||||
* swapchain image is acquired each frame and presented. The uniform layout and
|
||||
* the terrain data plumbing are shared with the Vulkan/WebGL backends
|
||||
* (`terrain_uniforms`); only the API calls and the WGSL shaders differ.
|
||||
*
|
||||
* It reuses `ra3.wasmgl`'s `event_sink` so the page/worker input protocol is the
|
||||
* same for both wasm backends.
|
||||
*/
|
||||
export namespace ra3::webgpu {
|
||||
class webgpu_display final : public ra3::client::display, public ra3::wasmgl::event_sink {
|
||||
public:
|
||||
webgpu_display() = default;
|
||||
webgpu_display(const webgpu_display &) = delete;
|
||||
auto operator=(const webgpu_display &) -> webgpu_display & = delete;
|
||||
~webgpu_display() override { this->shutdown(); }
|
||||
|
||||
[[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override {
|
||||
this->fps_limit_ = options.fps_limit;
|
||||
wgpu::Device device(emscripten_webgpu_get_device());
|
||||
if (device == nullptr) {
|
||||
ender::log::warn("ra3.webgpu: no preinitialized WebGPU device");
|
||||
return false;
|
||||
}
|
||||
device_ = std::move(device);
|
||||
queue_ = device_.GetQueue();
|
||||
instance_ = wgpu::CreateInstance();
|
||||
if (instance_ == nullptr) {
|
||||
ender::log::warn("ra3.webgpu: wgpuCreateInstance failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
wgpu::EmscriptenSurfaceSourceCanvasHTMLSelector selector;
|
||||
selector.selector = "#canvas";
|
||||
// There is no DOM in the worker, so Emscripten's canvas lookup cannot
|
||||
// find #canvas by CSS selector; register the transferred OffscreenCanvas
|
||||
// (Module.canvas) as a special target for `wgpuInstanceCreateSurface`.
|
||||
EM_ASM({
|
||||
if (typeof specialHTMLTargets !== 'undefined' && Module['canvas']) {
|
||||
specialHTMLTargets['#canvas'] = Module['canvas'];
|
||||
}
|
||||
});
|
||||
wgpu::SurfaceDescriptor surface_descriptor;
|
||||
surface_descriptor.nextInChain = &selector;
|
||||
surface_ = instance_.CreateSurface(&surface_descriptor);
|
||||
if (surface_ == nullptr) {
|
||||
ender::log::warn("ra3.webgpu: CreateSurface failed (is #canvas an OffscreenCanvas?)");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this->create_pipelines()) {
|
||||
ender::log::warn("ra3.webgpu: pipeline creation failed");
|
||||
this->shutdown();
|
||||
return false;
|
||||
}
|
||||
const auto [width, height] = this->window_size();
|
||||
this->configure(width > 0 ? width : options.width, height > 0 ? height : options.height);
|
||||
ra3::wasmgl::set_event_sink(this);
|
||||
ender::log::info("ra3.webgpu: WebGPU backend initialized");
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto present(const ra3::render::image &frame, const ra3::render::view_rect &dest, bool changed) -> bool override {
|
||||
if (frame.empty()) return false;
|
||||
if (changed || !scene_.texture || static_cast<int>(frame.width()) != scene_.width || static_cast<int>(frame.height()) != scene_.height) {
|
||||
this->upload_image(scene_, frame);
|
||||
}
|
||||
const auto [window_w, window_h] = this->window_size();
|
||||
if (window_w <= 0 || window_h <= 0) return true;
|
||||
return this->draw({0.05F, 0.06F, 0.08F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) {
|
||||
this->draw_image(pass, scene_, dest, w, h);
|
||||
});
|
||||
}
|
||||
|
||||
[[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override {
|
||||
if (events_.empty()) return false;
|
||||
out = events_.front();
|
||||
events_.pop_front();
|
||||
return true;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto window_size() const -> std::pair<int, int> override {
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
if (emscripten_get_canvas_element_size("#canvas", &w, &h) != EMSCRIPTEN_RESULT_SUCCESS) return {0, 0};
|
||||
return {w, h};
|
||||
}
|
||||
|
||||
[[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override {
|
||||
const auto held = [this](ra3::wasmgl::detail::move_key which) { return held_[static_cast<std::size_t>(which)]; };
|
||||
switch (key) {
|
||||
case ra3::render::ui_key::up: return held(ra3::wasmgl::detail::move_key::w) || held(ra3::wasmgl::detail::move_key::arrow_up);
|
||||
case ra3::render::ui_key::down: return held(ra3::wasmgl::detail::move_key::s) || held(ra3::wasmgl::detail::move_key::arrow_down);
|
||||
case ra3::render::ui_key::left: return held(ra3::wasmgl::detail::move_key::a) || held(ra3::wasmgl::detail::move_key::arrow_left);
|
||||
case ra3::render::ui_key::right: return held(ra3::wasmgl::detail::move_key::d) || held(ra3::wasmgl::detail::move_key::arrow_right);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto supports_terrain() const -> bool override { return true; }
|
||||
|
||||
[[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s,
|
||||
const ra3::client::terrain_overlay &overlay) -> bool override {
|
||||
if (!terrain_ready_) {
|
||||
if (!this->create_terrain(terrain)) return false;
|
||||
terrain_ready_ = true;
|
||||
ender::log::info(std::format("ra3.webgpu: terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count));
|
||||
}
|
||||
if (overlay.label_changed) this->upload_image(label_, overlay.label);
|
||||
if (overlay.minimap_changed) this->upload_image(minimap_, overlay.minimap);
|
||||
|
||||
const auto [window_w, window_h] = this->window_size();
|
||||
if (window_w <= 0 || window_h <= 0) return true;
|
||||
const auto aspect = static_cast<float>(window_w) / static_cast<float>(std::max(1, window_h));
|
||||
const auto uniforms = detail_uniforms(terrain, camera, time_s, aspect);
|
||||
queue_.WriteBuffer(terrain_.ubo, 0, uniforms.data(), uniforms.size() * sizeof(float));
|
||||
|
||||
const float margin = 12.0F;
|
||||
return this->draw({0.45F, 0.55F, 0.70F, 1.0F}, [&](wgpu::RenderPassEncoder &pass, int w, int h) {
|
||||
pass.SetPipeline(terrain_.pipeline);
|
||||
pass.SetBindGroup(0, terrain_.bind_group);
|
||||
pass.Draw(3);
|
||||
this->draw_image(pass, label_, {margin, margin, static_cast<float>(label_.width), static_cast<float>(label_.height)}, w, h);
|
||||
this->draw_image(pass, minimap_, {static_cast<float>(w) - static_cast<float>(minimap_.width) - margin, margin,
|
||||
static_cast<float>(minimap_.width), static_cast<float>(minimap_.height)},
|
||||
w, h);
|
||||
});
|
||||
}
|
||||
|
||||
auto shutdown() -> void override {
|
||||
if (ra3::wasmgl::detail::sink_slot() == this) ra3::wasmgl::set_event_sink(nullptr);
|
||||
terrain_ready_ = false;
|
||||
scene_ = {};
|
||||
label_ = {};
|
||||
minimap_ = {};
|
||||
terrain_ = {};
|
||||
surface_ = nullptr;
|
||||
queue_ = nullptr;
|
||||
device_ = nullptr;
|
||||
instance_ = nullptr;
|
||||
events_.clear();
|
||||
held_.fill(false);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto name() const -> std::string_view override { return "webgpu"; }
|
||||
|
||||
// ---- worker input (ra3.wasmgl::event_sink) ---------------------------
|
||||
|
||||
auto on_key(std::string_view code, bool down) -> void override {
|
||||
if (const int index = ra3::wasmgl::detail::move_key_index(code); index >= 0) {
|
||||
held_[static_cast<std::size_t>(index)] = down;
|
||||
}
|
||||
if (!down) return;
|
||||
if (const auto key = ra3::wasmgl::detail::key_from_code(code); key != ra3::render::ui_key::none) {
|
||||
push_key(key);
|
||||
}
|
||||
}
|
||||
|
||||
auto on_text(int codepoint) -> void override {
|
||||
if (codepoint <= 0 || codepoint > 0x7F) return;
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::text;
|
||||
out.character = static_cast<char>(codepoint);
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_mouse_button(double x, double y, bool left) -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::mouse_button;
|
||||
out.x = static_cast<float>(x);
|
||||
out.y = static_cast<float>(y);
|
||||
out.left = left;
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_mouse_move(double x, double y, double dx, double dy, bool left) -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::mouse_move;
|
||||
out.x = static_cast<float>(x);
|
||||
out.y = static_cast<float>(y);
|
||||
out.dx = static_cast<float>(dx);
|
||||
out.dy = static_cast<float>(dy);
|
||||
out.left = left;
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_wheel(double delta_y) -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::wheel;
|
||||
out.wheel = static_cast<float>(-delta_y);
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_quit() -> void override {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::quit;
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
auto on_resize(int width, int height) -> void override {
|
||||
if (width > 0 && height > 0) emscripten_set_canvas_element_size("#canvas", width, height);
|
||||
}
|
||||
|
||||
private:
|
||||
/** A drawn image: its texture, uniform rect buffer and bind group. */
|
||||
struct image_target {
|
||||
wgpu::Texture texture;
|
||||
wgpu::TextureView view;
|
||||
wgpu::Buffer ubo;
|
||||
wgpu::BindGroup bind_group;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int tex_width = 0;
|
||||
int tex_height = 0;
|
||||
};
|
||||
|
||||
struct terrain_target {
|
||||
wgpu::RenderPipeline pipeline;
|
||||
wgpu::Buffer ubo;
|
||||
wgpu::Texture height;
|
||||
wgpu::Texture cell;
|
||||
wgpu::Texture atlas;
|
||||
wgpu::TextureView height_view;
|
||||
wgpu::TextureView cell_view;
|
||||
wgpu::TextureView atlas_view;
|
||||
wgpu::BindGroup bind_group;
|
||||
};
|
||||
|
||||
static auto detail_uniforms(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, float aspect)
|
||||
-> std::array<float, 20> {
|
||||
return {camera.target_x,
|
||||
camera.target_y,
|
||||
camera.yaw,
|
||||
camera.height,
|
||||
camera.pitch,
|
||||
camera.fov,
|
||||
terrain.water_z,
|
||||
terrain.has_water ? 1.0F : 0.0F,
|
||||
0.45F,
|
||||
0.35F,
|
||||
0.82F,
|
||||
0.38F,
|
||||
static_cast<float>(terrain.width),
|
||||
static_cast<float>(terrain.height),
|
||||
0.0F,
|
||||
terrain.z_scale,
|
||||
time_s,
|
||||
0.0F,
|
||||
terrain.cell_span,
|
||||
aspect};
|
||||
}
|
||||
|
||||
auto push_key(ra3::render::ui_key key) -> void {
|
||||
ra3::render::ui_event out;
|
||||
out.type = ra3::render::ui_event_type::key;
|
||||
out.key = key;
|
||||
events_.push_back(out);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto make_shader(const char *wgsl) -> wgpu::ShaderModule {
|
||||
wgpu::ShaderSourceWGSL source;
|
||||
source.code = wgsl;
|
||||
wgpu::ShaderModuleDescriptor descriptor;
|
||||
descriptor.nextInChain = &source;
|
||||
return device_.CreateShaderModule(&descriptor);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_pipelines() -> bool {
|
||||
scene_shader_ = this->make_shader(ra3_shaders::webgpu_scene_wgsl);
|
||||
terrain_shader_ = this->make_shader(ra3_shaders::webgpu_terrain_wgsl);
|
||||
if (scene_shader_ == nullptr || terrain_shader_ == nullptr) {
|
||||
ender::log::warn(std::format("ra3.webgpu: shader module creation failed (scene={} terrain={})",
|
||||
scene_shader_ != nullptr, terrain_shader_ != nullptr));
|
||||
return false;
|
||||
}
|
||||
|
||||
wgpu::SamplerDescriptor clamp_desc;
|
||||
clamp_desc.addressModeU = wgpu::AddressMode::ClampToEdge;
|
||||
clamp_desc.addressModeV = wgpu::AddressMode::ClampToEdge;
|
||||
clamp_desc.magFilter = wgpu::FilterMode::Linear;
|
||||
clamp_desc.minFilter = wgpu::FilterMode::Linear;
|
||||
clamp_desc.lodMaxClamp = 32.0F;
|
||||
clamp_sampler_ = device_.CreateSampler(&clamp_desc);
|
||||
|
||||
wgpu::SamplerDescriptor repeat_desc = clamp_desc;
|
||||
repeat_desc.addressModeU = wgpu::AddressMode::Repeat;
|
||||
repeat_desc.addressModeV = wgpu::AddressMode::Repeat;
|
||||
repeat_sampler_ = device_.CreateSampler(&repeat_desc);
|
||||
|
||||
// Scene: uniform(rect) + texture + sampler.
|
||||
wgpu::BindGroupLayoutEntry scene_entries[3] = {};
|
||||
scene_entries[0].binding = 0;
|
||||
scene_entries[0].visibility = wgpu::ShaderStage::Vertex | wgpu::ShaderStage::Fragment;
|
||||
scene_entries[0].buffer.type = wgpu::BufferBindingType::Uniform;
|
||||
scene_entries[0].buffer.minBindingSize = 16;
|
||||
scene_entries[1].binding = 1;
|
||||
scene_entries[1].visibility = wgpu::ShaderStage::Fragment;
|
||||
scene_entries[1].texture.sampleType = wgpu::TextureSampleType::Float;
|
||||
scene_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D;
|
||||
scene_entries[2].binding = 2;
|
||||
scene_entries[2].visibility = wgpu::ShaderStage::Fragment;
|
||||
scene_entries[2].sampler.type = wgpu::SamplerBindingType::Filtering;
|
||||
wgpu::BindGroupLayoutDescriptor scene_layout_desc;
|
||||
scene_layout_desc.entryCount = 3;
|
||||
scene_layout_desc.entries = scene_entries;
|
||||
scene_bind_group_layout_ = device_.CreateBindGroupLayout(&scene_layout_desc);
|
||||
|
||||
wgpu::PipelineLayoutDescriptor scene_pipeline_layout_desc;
|
||||
scene_pipeline_layout_desc.bindGroupLayoutCount = 1;
|
||||
scene_pipeline_layout_desc.bindGroupLayouts = &scene_bind_group_layout_;
|
||||
scene_pipeline_layout_ = device_.CreatePipelineLayout(&scene_pipeline_layout_desc);
|
||||
scene_pipeline_ = this->create_pipeline(scene_pipeline_layout_, scene_shader_, ra3_shaders::webgpu_scene_wgsl);
|
||||
if (scene_pipeline_ == nullptr) {
|
||||
ender::log::warn("ra3.webgpu: scene render pipeline creation failed");
|
||||
}
|
||||
|
||||
// Terrain: uniform(data[5]) + R16Uint + RGBA16Uint + RGBA8 array + sampler.
|
||||
wgpu::BindGroupLayoutEntry terrain_entries[5] = {};
|
||||
terrain_entries[0].binding = 0;
|
||||
terrain_entries[0].visibility = wgpu::ShaderStage::Fragment;
|
||||
terrain_entries[0].buffer.type = wgpu::BufferBindingType::Uniform;
|
||||
terrain_entries[0].buffer.minBindingSize = 80;
|
||||
terrain_entries[1].binding = 1;
|
||||
terrain_entries[1].visibility = wgpu::ShaderStage::Fragment;
|
||||
terrain_entries[1].texture.sampleType = wgpu::TextureSampleType::Uint;
|
||||
terrain_entries[1].texture.viewDimension = wgpu::TextureViewDimension::e2D;
|
||||
terrain_entries[2].binding = 2;
|
||||
terrain_entries[2].visibility = wgpu::ShaderStage::Fragment;
|
||||
terrain_entries[2].texture.sampleType = wgpu::TextureSampleType::Uint;
|
||||
terrain_entries[2].texture.viewDimension = wgpu::TextureViewDimension::e2D;
|
||||
terrain_entries[3].binding = 3;
|
||||
terrain_entries[3].visibility = wgpu::ShaderStage::Fragment;
|
||||
terrain_entries[3].texture.sampleType = wgpu::TextureSampleType::Float;
|
||||
terrain_entries[3].texture.viewDimension = wgpu::TextureViewDimension::e2DArray;
|
||||
terrain_entries[4].binding = 4;
|
||||
terrain_entries[4].visibility = wgpu::ShaderStage::Fragment;
|
||||
terrain_entries[4].sampler.type = wgpu::SamplerBindingType::Filtering;
|
||||
wgpu::BindGroupLayoutDescriptor terrain_layout_desc;
|
||||
terrain_layout_desc.entryCount = 5;
|
||||
terrain_layout_desc.entries = terrain_entries;
|
||||
terrain_bind_group_layout_ = device_.CreateBindGroupLayout(&terrain_layout_desc);
|
||||
return scene_pipeline_ != nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_pipeline(const wgpu::PipelineLayout &layout, const wgpu::ShaderModule &shader, const char *wgsl) -> wgpu::RenderPipeline {
|
||||
(void) wgsl;
|
||||
wgpu::ColorTargetState target;
|
||||
target.format = surface_format_;
|
||||
target.writeMask = wgpu::ColorWriteMask::All;
|
||||
wgpu::FragmentState fragment;
|
||||
fragment.module = shader;
|
||||
fragment.entryPoint = "fs_main";
|
||||
fragment.targetCount = 1;
|
||||
fragment.targets = ⌖
|
||||
wgpu::RenderPipelineDescriptor descriptor;
|
||||
descriptor.layout = layout;
|
||||
descriptor.vertex.module = shader;
|
||||
descriptor.vertex.entryPoint = "vs_main";
|
||||
descriptor.primitive.topology = wgpu::PrimitiveTopology::TriangleList;
|
||||
descriptor.fragment = &fragment;
|
||||
descriptor.multisample.count = 1;
|
||||
return device_.CreateRenderPipeline(&descriptor);
|
||||
}
|
||||
|
||||
auto configure(int width, int height) -> void {
|
||||
if (surface_ == nullptr || width <= 0 || height <= 0) return;
|
||||
if (width == surface_w_ && height == surface_h_) return;
|
||||
wgpu::SurfaceConfiguration config;
|
||||
config.device = device_;
|
||||
config.format = surface_format_;
|
||||
config.usage = wgpu::TextureUsage::RenderAttachment;
|
||||
config.width = static_cast<uint32_t>(width);
|
||||
config.height = static_cast<uint32_t>(height);
|
||||
config.presentMode = wgpu::PresentMode::Fifo;
|
||||
surface_.Configure(&config);
|
||||
surface_w_ = width;
|
||||
surface_h_ = height;
|
||||
}
|
||||
|
||||
auto make_target(image_target &target) -> void {
|
||||
wgpu::BufferDescriptor ubo_desc;
|
||||
ubo_desc.size = 16;
|
||||
ubo_desc.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst;
|
||||
target.ubo = device_.CreateBuffer(&ubo_desc);
|
||||
}
|
||||
|
||||
auto upload_image(image_target &target, const ra3::render::image &frame) -> void {
|
||||
if (frame.empty()) return;
|
||||
target.width = static_cast<int>(frame.width());
|
||||
target.height = static_cast<int>(frame.height());
|
||||
if (target.texture == nullptr || target.tex_width != target.width || target.tex_height != target.height) {
|
||||
wgpu::TextureDescriptor descriptor;
|
||||
descriptor.size = {frame.width(), frame.height(), 1};
|
||||
descriptor.format = wgpu::TextureFormat::RGBA8Unorm;
|
||||
descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst;
|
||||
descriptor.dimension = wgpu::TextureDimension::e2D;
|
||||
descriptor.mipLevelCount = 1;
|
||||
descriptor.sampleCount = 1;
|
||||
target.texture = device_.CreateTexture(&descriptor);
|
||||
target.view = target.texture.CreateView();
|
||||
target.tex_width = target.width;
|
||||
target.tex_height = target.height;
|
||||
if (!target.ubo) this->make_target(target);
|
||||
target.bind_group = this->make_image_bind_group(target);
|
||||
}
|
||||
wgpu::TexelCopyTextureInfo destination;
|
||||
destination.texture = target.texture;
|
||||
wgpu::TexelCopyBufferLayout layout;
|
||||
layout.bytesPerRow = frame.width() * 4;
|
||||
layout.rowsPerImage = frame.height();
|
||||
wgpu::Extent3D size = {frame.width(), frame.height(), 1};
|
||||
queue_.WriteTexture(&destination, frame.data(), static_cast<size_t>(frame.width()) * frame.height() * 4, &layout, &size);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto make_image_bind_group(image_target &target) -> wgpu::BindGroup {
|
||||
wgpu::BindGroupEntry entries[3] = {};
|
||||
entries[0].binding = 0;
|
||||
entries[0].buffer = target.ubo;
|
||||
entries[0].size = 16;
|
||||
entries[1].binding = 1;
|
||||
entries[1].textureView = target.view;
|
||||
entries[2].binding = 2;
|
||||
entries[2].sampler = clamp_sampler_;
|
||||
wgpu::BindGroupDescriptor descriptor;
|
||||
descriptor.layout = scene_bind_group_layout_;
|
||||
descriptor.entryCount = 3;
|
||||
descriptor.entries = entries;
|
||||
return device_.CreateBindGroup(&descriptor);
|
||||
}
|
||||
|
||||
auto draw_image(wgpu::RenderPassEncoder &pass, image_target &target, const ra3::render::view_rect &dest, int window_w, int window_h) -> void {
|
||||
if (target.bind_group == nullptr || window_w <= 0 || window_h <= 0) return;
|
||||
const float rect[4] = {dest.x / static_cast<float>(window_w), dest.y / static_cast<float>(window_h),
|
||||
dest.w / static_cast<float>(window_w), dest.h / static_cast<float>(window_h)};
|
||||
queue_.WriteBuffer(target.ubo, 0, rect, sizeof(rect));
|
||||
pass.SetPipeline(scene_pipeline_);
|
||||
pass.SetBindGroup(0, target.bind_group);
|
||||
pass.Draw(3);
|
||||
}
|
||||
|
||||
[[nodiscard]] auto create_terrain(const ra3::terrain::gpu_terrain &terrain) -> bool {
|
||||
{
|
||||
wgpu::BufferDescriptor descriptor;
|
||||
descriptor.size = 80;
|
||||
descriptor.usage = wgpu::BufferUsage::Uniform | wgpu::BufferUsage::CopyDst;
|
||||
terrain_.ubo = device_.CreateBuffer(&descriptor);
|
||||
}
|
||||
terrain_.height = this->make_texture(wgpu::TextureFormat::R16Uint, terrain.width, terrain.height, 1);
|
||||
terrain_.cell = this->make_texture(wgpu::TextureFormat::RGBA16Uint, terrain.width, terrain.height, 1);
|
||||
terrain_.atlas = this->make_texture(wgpu::TextureFormat::RGBA8Unorm, terrain.layer_size, terrain.layer_size, terrain.layer_count);
|
||||
terrain_.height_view = terrain_.height.CreateView();
|
||||
terrain_.cell_view = terrain_.cell.CreateView();
|
||||
terrain_.atlas_view = terrain_.atlas.CreateView();
|
||||
|
||||
this->write_texture(terrain_.height, terrain.heights.data(), terrain.width, terrain.height, 1, 2);
|
||||
this->write_texture(terrain_.cell, terrain.cell_data.data(), terrain.width, terrain.height, 1, 8);
|
||||
this->write_texture(terrain_.atlas, terrain.layers.data(), terrain.layer_size, terrain.layer_size, terrain.layer_count, 4);
|
||||
|
||||
wgpu::BindGroupEntry entries[5] = {};
|
||||
entries[0].binding = 0;
|
||||
entries[0].buffer = terrain_.ubo;
|
||||
entries[0].size = 80;
|
||||
entries[1].binding = 1;
|
||||
entries[1].textureView = terrain_.height_view;
|
||||
entries[2].binding = 2;
|
||||
entries[2].textureView = terrain_.cell_view;
|
||||
entries[3].binding = 3;
|
||||
entries[3].textureView = terrain_.atlas_view;
|
||||
entries[4].binding = 4;
|
||||
entries[4].sampler = repeat_sampler_;
|
||||
wgpu::BindGroupDescriptor bind_desc;
|
||||
bind_desc.layout = terrain_bind_group_layout_;
|
||||
bind_desc.entryCount = 5;
|
||||
bind_desc.entries = entries;
|
||||
terrain_.bind_group = device_.CreateBindGroup(&bind_desc);
|
||||
|
||||
terrain_.pipeline = this->create_pipeline(terrain_pipeline_layout(), terrain_shader_, ra3_shaders::webgpu_terrain_wgsl);
|
||||
return terrain_.pipeline != nullptr && terrain_.bind_group != nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto terrain_pipeline_layout() -> wgpu::PipelineLayout {
|
||||
if (terrain_pipeline_layout_ == nullptr) {
|
||||
wgpu::PipelineLayoutDescriptor descriptor;
|
||||
descriptor.bindGroupLayoutCount = 1;
|
||||
descriptor.bindGroupLayouts = &terrain_bind_group_layout_;
|
||||
terrain_pipeline_layout_ = device_.CreatePipelineLayout(&descriptor);
|
||||
}
|
||||
return terrain_pipeline_layout_;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto make_texture(wgpu::TextureFormat format, uint32_t width, uint32_t height, uint32_t layers) -> wgpu::Texture {
|
||||
wgpu::TextureDescriptor descriptor;
|
||||
descriptor.size = {width, height, layers};
|
||||
descriptor.format = format;
|
||||
descriptor.usage = wgpu::TextureUsage::TextureBinding | wgpu::TextureUsage::CopyDst;
|
||||
descriptor.dimension = wgpu::TextureDimension::e2D;
|
||||
descriptor.mipLevelCount = 1;
|
||||
descriptor.sampleCount = 1;
|
||||
return device_.CreateTexture(&descriptor);
|
||||
}
|
||||
|
||||
auto write_texture(const wgpu::Texture &texture, const void *data, uint32_t width, uint32_t height, uint32_t layers, uint32_t bytes_per_pixel)
|
||||
-> void {
|
||||
wgpu::TexelCopyTextureInfo destination;
|
||||
destination.texture = texture;
|
||||
wgpu::TexelCopyBufferLayout layout;
|
||||
layout.bytesPerRow = width * bytes_per_pixel;
|
||||
layout.rowsPerImage = height;
|
||||
wgpu::Extent3D size = {width, height, layers};
|
||||
queue_.WriteTexture(&destination, data, static_cast<size_t>(width) * height * layers * bytes_per_pixel, &layout, &size);
|
||||
}
|
||||
|
||||
/** Clear, run `record`, submit and present. */
|
||||
template<typename Record>
|
||||
[[nodiscard]] auto draw(std::array<float, 4> clear, Record &&record) -> bool {
|
||||
const auto [window_w, window_h] = this->window_size();
|
||||
this->configure(window_w, window_h);
|
||||
wgpu::SurfaceTexture surface_texture;
|
||||
surface_.GetCurrentTexture(&surface_texture);
|
||||
if (surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessOptimal &&
|
||||
surface_texture.status != wgpu::SurfaceGetCurrentTextureStatus::SuccessSuboptimal) {
|
||||
return false;
|
||||
}
|
||||
wgpu::TextureView view = surface_texture.texture.CreateView();
|
||||
wgpu::RenderPassColorAttachment color;
|
||||
color.view = view;
|
||||
color.depthSlice = 0xFFFFFFFFu; // kWGPU_DEPTH_SLICE_UNDEFINED
|
||||
color.loadOp = wgpu::LoadOp::Clear;
|
||||
color.storeOp = wgpu::StoreOp::Store;
|
||||
color.clearValue = {clear[0], clear[1], clear[2], clear[3]};
|
||||
wgpu::RenderPassDescriptor pass_desc;
|
||||
pass_desc.colorAttachmentCount = 1;
|
||||
pass_desc.colorAttachments = &color;
|
||||
wgpu::CommandEncoder encoder = device_.CreateCommandEncoder();
|
||||
wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&pass_desc);
|
||||
record(pass, window_w, window_h);
|
||||
pass.End();
|
||||
wgpu::CommandBuffer commands = encoder.Finish();
|
||||
queue_.Submit(1, &commands);
|
||||
// WebGPU presents implicitly at the end of the frame; the port's
|
||||
// wgpuSurfacePresent is intentionally unsupported.
|
||||
this->mark_frame();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Publish a coarse frame counter to the page for the fps probe. */
|
||||
auto mark_frame() const -> void {
|
||||
++frames_;
|
||||
if (frames_ % 15U == 0U) {
|
||||
EM_ASM({ self.postMessage({ openra3Frames: $0 }); }, static_cast<int>(frames_));
|
||||
}
|
||||
}
|
||||
|
||||
wgpu::Instance instance_;
|
||||
wgpu::Device device_;
|
||||
wgpu::Queue queue_;
|
||||
wgpu::Surface surface_;
|
||||
wgpu::TextureFormat surface_format_ = wgpu::TextureFormat::BGRA8Unorm;
|
||||
int surface_w_ = 0;
|
||||
int surface_h_ = 0;
|
||||
|
||||
wgpu::ShaderModule scene_shader_;
|
||||
wgpu::ShaderModule terrain_shader_;
|
||||
wgpu::BindGroupLayout scene_bind_group_layout_;
|
||||
wgpu::BindGroupLayout terrain_bind_group_layout_;
|
||||
wgpu::PipelineLayout scene_pipeline_layout_;
|
||||
wgpu::PipelineLayout terrain_pipeline_layout_;
|
||||
wgpu::RenderPipeline scene_pipeline_;
|
||||
wgpu::Sampler clamp_sampler_;
|
||||
wgpu::Sampler repeat_sampler_;
|
||||
|
||||
image_target scene_;
|
||||
image_target label_;
|
||||
image_target minimap_;
|
||||
terrain_target terrain_;
|
||||
bool terrain_ready_ = false;
|
||||
|
||||
std::deque<ra3::render::ui_event> events_;
|
||||
std::array<bool, static_cast<std::size_t>(ra3::wasmgl::detail::move_key::count)> held_{};
|
||||
mutable unsigned frames_ = 0U;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export module ra3.webgpu;
|
||||
|
||||
import std;
|
||||
|
||||
export import ra3.core;
|
||||
export import ra3.render;
|
||||
export import ra3.client;
|
||||
|
||||
/**
|
||||
* Fallback WebGPU backend for builds without Emscripten. `init` fails so the
|
||||
* caller can select another display; the class name matches the real
|
||||
* `ra3.webgpu` module so the backend factory in `ra3.display` compiles
|
||||
* unchanged.
|
||||
*/
|
||||
export namespace ra3::webgpu {
|
||||
class webgpu_display final : public ra3::client::display {
|
||||
public:
|
||||
[[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; }
|
||||
[[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; }
|
||||
[[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 "webgpu(null)"; }
|
||||
};
|
||||
}
|
||||
@@ -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("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) {
|
||||
std::puts("ra3_tests: OK");
|
||||
}
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ module;
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#elif defined(__unix__) || defined(__APPLE__)
|
||||
#elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__)
|
||||
#include <execinfo.h>
|
||||
#endif
|
||||
|
||||
@@ -367,7 +367,7 @@ export namespace ender::log {
|
||||
text += std::format(" #{:<3}{}\n", index, symbolicate_frame(frames[index]));
|
||||
}
|
||||
return text;
|
||||
#elif defined(__unix__) || defined(__APPLE__)
|
||||
#elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__)
|
||||
void *frames[max_frames] = {};
|
||||
const int count = ::backtrace(frames, static_cast<int>(std::min(max_frames, skip + std::max<std::size_t>(depth, 1U))));
|
||||
char **symbols = ::backtrace_symbols(frames, count);
|
||||
|
||||
Reference in New Issue
Block a user