From 3caf18409b9d7e1a2067bbaf3078619adfbfde24 Mon Sep 17 00:00:00 2001 From: EnderTheCoder Date: Mon, 28 Sep 2026 13:03:37 +0800 Subject: [PATCH] v0.4.0: Direct3D 11/12 backends, in-game renderer selection, per-run logging - ra3.dx: D3D11 and D3D12 presentation backends (runtime HLSL via d3dcompiler); 2D image blit and the GPU heightfield terrain raymarch, with the corner minimap/FPS overlays. Non-Windows builds link a null fallback. - ra3.display: preferred backend plus ordered fallback (Vulkan/D3D11/D3D12/SDL); menu gains a Renderer option and the CLI gains --dx11/--dx12/--sdl, which the render command now honours too. - vendor libenderlog (MIT): every run writes openra3.log next to the exe and archives the previous run's log; records at warn and above carry a call stack (native fallback, since libc++ has no ). - Windows crash reporter writes openra3_crash.log (faulting module + backtrace); D3D/DXGI diagnostics are routed through the logger. --- .gitignore | 1 + CMakeLists.txt | 69 +- README.md | 23 +- VERSION | 2 +- apps/openra3/main.cpp | 172 ++- docs/ARCHITECTURE.md | 27 +- shaders/dx_scene.hlsl | 34 + shaders/dx_terrain.hlsl | 236 ++++ src/client/ra3.client.cppm | 9 + src/display/ra3.display.cppm | 149 ++- src/dx/ra3.dx.cppm | 1392 ++++++++++++++++++++ src/dx/ra3.dx.null.cppm | 37 + src/ra3.cppm | 1 + third_party/libenderlog/LICENSE | 21 + third_party/libenderlog/README.md | 170 +++ third_party/libenderlog/src/ender.log.cppm | 542 ++++++++ 16 files changed, 2815 insertions(+), 70 deletions(-) create mode 100644 shaders/dx_scene.hlsl create mode 100644 shaders/dx_terrain.hlsl create mode 100644 src/dx/ra3.dx.cppm create mode 100644 src/dx/ra3.dx.null.cppm create mode 100644 third_party/libenderlog/LICENSE create mode 100644 third_party/libenderlog/README.md create mode 100644 third_party/libenderlog/src/ender.log.cppm diff --git a/.gitignore b/.gitignore index 04f0ffb..460c802 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ compile_commands.json # local data dumps / RE artifacts re-data/ *.log +*.log.[0-9]* diff --git a/CMakeLists.txt b/CMakeLists.txt index f8dd334..a002059 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,7 +25,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_SCAN_FOR_MODULES ON) -project(OpenRA3 VERSION 0.3.0 LANGUAGES C CXX) +project(OpenRA3 VERSION 0.4.0 LANGUAGES C CXX) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) @@ -112,6 +112,36 @@ function(openra3_embed_spirv out_header) file(WRITE "${out_header}" "${content}") endfunction() +# Embed the HLSL sources as string literals. The Direct3D backends compile them +# at runtime with d3dcompiler_47, so no HLSL compiler is needed at build time +# (the Windows target cross-compiles from Linux). +function(openra3_embed_hlsl out_header) + set(content "// Generated by CMake from shaders/*.hlsl.\n") + string(APPEND content "#pragma once\n\nnamespace ra3_shaders {\n") + foreach(src IN LISTS ARGN) + if(NOT EXISTS "${src}") + message(FATAL_ERROR "Missing HLSL shader ${src}") + endif() + get_filename_component(base "${src}" NAME_WE) + string(MAKE_C_IDENTIFIER "${base}" ident) + string(APPEND ident "_hlsl") + file(READ "${src}" text) + string(APPEND content "inline constexpr const char ${ident}[] = R\"RA3HLSL(${text})RA3HLSL\";\n\n") + endforeach() + string(APPEND content "} // namespace ra3_shaders\n") + file(WRITE "${out_header}" "${content}") +endfunction() + +# --- logging (vendored libenderlog, MIT) ------------------------------------- +# A standalone C++26 module logger (`import ender.log;`) with a per-run +# archiving file sink. libc++ has no , so on this toolchain the +# module records call sites but no stacks; it defaults ENDERLOG_HAS_STACKTRACE +# to 0 without any define. +add_library(ra3_enderlog STATIC) +target_sources(ra3_enderlog PUBLIC FILE_SET CXX_MODULES FILES third_party/libenderlog/src/ender.log.cppm) +target_compile_features(ra3_enderlog PUBLIC cxx_std_26) +openra3_target_defaults(ra3_enderlog) + # --- engine core ------------------------------------------------------------- add_library(ra3_core STATIC) target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm) @@ -210,16 +240,49 @@ else() endif() openra3_target_defaults(ra3_vulkan) +# --- Direct3D viewer (D3D11 + D3D12, or null fallback) ------------------------ +# Direct3D is Windows-only, so the real backends build only for the MinGW target; +# every other platform links the `ra3.dx.null` fallback that fails `init` cleanly. +option(OPENRA3_DX "Build the Direct3D 11/12 viewer" ON) +set(OPENRA3_HAS_DX OFF) +if(OPENRA3_DX AND WIN32 AND OPENRA3_HAS_SDL3) + set(OPENRA3_HAS_DX ON) +endif() +if(OPENRA3_HAS_DX) + message(STATUS "OpenRA3: Direct3D 11/12 viewer enabled (runtime HLSL via d3dcompiler)") +else() + message(STATUS "OpenRA3: Direct3D viewer disabled - offscreen image only") +endif() +add_library(ra3_dx STATIC) +if(OPENRA3_HAS_DX) + openra3_embed_hlsl( + "${CMAKE_CURRENT_BINARY_DIR}/generated/dx_shaders_embedded.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_scene.hlsl" + "${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_terrain.hlsl" + ) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_scene.hlsl" + "${CMAKE_CURRENT_SOURCE_DIR}/shaders/dx_terrain.hlsl") + target_sources(ra3_dx PUBLIC FILE_SET CXX_MODULES FILES src/dx/ra3.dx.cppm) + target_include_directories(ra3_dx PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated") + target_link_libraries(ra3_dx PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_enderlog openra3_sdl3 d3d11 d3d12 dxgi d3dcompiler) +else() + target_sources(ra3_dx PUBLIC FILE_SET CXX_MODULES FILES src/dx/ra3.dx.null.cppm) + target_link_libraries(ra3_dx PUBLIC ra3_core ra3_render ra3_client) +endif() +openra3_target_defaults(ra3_dx) + # --- 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) +target_link_libraries(ra3_display PUBLIC ra3_core ra3_render ra3_terrain ra3_client ra3_ui ra3_vulkan ra3_dx) 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) +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) openra3_target_defaults(ra3) # --- executable --------------------------------------------------------------- diff --git a/README.md b/README.md index 4ffc146..79c727c 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,16 @@ Ghidra. ## Status -OpenRA3 is at **v0.3.0**. The engine compiles and runs headless, and a **minimal +OpenRA3 is at **v0.4.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 is a **Vulkan** backend (`ra3.vulkan`) with a null -fallback; the software renderer still produces headless images. Terrain tiles +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 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 @@ -83,7 +85,7 @@ built-in test map so the project still builds and runs in CI. | `src/core/ra3.core.cppm` | fundamental types, math, strings, random, message stream | | `src/logic/ra3.logic.cppm` | objects, players, teams, spatial partition, game loop | | `src/client/ra3.client.cppm` | `display` abstraction + shared interactive loops + client facade | -| `src/display/ra3.display.cppm` | picks the backend (Vulkan, then SDL) for the app | +| `src/display/ra3.display.cppm` | picks the backend (Vulkan/D3D11/D3D12, 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 | @@ -93,6 +95,8 @@ built-in test map so the project still builds and runs in CI. | `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) | +| `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 | | `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) | @@ -156,6 +160,17 @@ 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. +## Logging + +Every run writes `openra3.log` next to the executable through the vendored +[`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file +sink archives the previous log to `openra3.log.` on open, so +each run gets its own file; the active file rotates at 4 MiB and the last 10 +archives are kept. Records at **`warn` and above** carry a call stack (Windows +`CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no +``). A hard crash also writes `openra3_crash.log` with the faulting +module and a raw backtrace. + ## Running a skirmish ```bash diff --git a/VERSION b/VERSION index 0d91a54..60a2d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 \ No newline at end of file diff --git a/apps/openra3/main.cpp b/apps/openra3/main.cpp index 36ec9dd..dd7c2ed 100644 --- a/apps/openra3/main.cpp +++ b/apps/openra3/main.cpp @@ -1,7 +1,104 @@ +#if defined(_WIN32) +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include +#endif + import std; import ra3; +import ender.log; namespace { +#if defined(_WIN32) + /** Path of the crash report written next to the executable. */ + [[nodiscard]] auto crash_log_path() -> const std::filesystem::path & { + static const auto path = [] { + std::wstring buffer(32768U, L'\0'); + const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + buffer.resize(length); + return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log"; + }(); + return path; + } + + auto crash_write(std::string_view text) -> void { + const HANDLE file = CreateFileW(crash_log_path().wstring().c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) return; + DWORD written = 0; + WriteFile(file, text.data(), static_cast(text.size()), &written, nullptr); + CloseHandle(file); + } + + /** `address` rendered as `module.dll+0xRVA`, or `0x...` when unmapped. */ + auto crash_describe(std::uintptr_t address, char *out, std::size_t size) -> void { + HMODULE module = nullptr; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(address), &module)) { + wchar_t wide[260] = L"?"; + GetModuleFileNameW(module, wide, 260U); + char narrow[260] = "?"; + WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr); + const char *base = std::strrchr(narrow, '\\'); + const auto rva = address - reinterpret_cast(module); + std::snprintf(out, size, "%s+0x%llX", base != nullptr ? base + 1 : narrow, static_cast(rva)); + return; + } + std::snprintf(out, size, "0x%llX", static_cast(address)); + } + + LONG WINAPI openra3_crash_handler(EXCEPTION_POINTERS *info) { + char line[512]; + const auto *record = info->ExceptionRecord; + std::snprintf(line, sizeof(line), "\n=== OpenRA3 crash ===\nmodule=%s\nexception=0x%08lX address=0x%p\n", + "openra3", static_cast(record->ExceptionCode), record->ExceptionAddress); + crash_write(line); + void *frames[48] = {}; + const USHORT count = CaptureStackBackTrace(0U, 48U, frames, nullptr); + for (USHORT i = 0; i < count; ++i) { + char described[320] = {}; + crash_describe(reinterpret_cast(frames[i]), described, sizeof(described)); + std::snprintf(line, sizeof(line), " #%02u %s\n", static_cast(i), described); + crash_write(line); + } + crash_write("=== end ===\n"); + return EXCEPTION_EXECUTE_HANDLER; + } + + auto install_crash_handler() -> void { + SetUnhandledExceptionFilter(&openra3_crash_handler); + std::set_terminate([] { + crash_write("\n=== OpenRA3 std::terminate ===\n"); + if (const auto exception = std::current_exception()) { + try { + std::rethrow_exception(exception); + } catch (const std::exception &error) { + char line[512]; + std::snprintf(line, sizeof(line), "what(): %s\n", error.what()); + crash_write(line); + } catch (...) { + crash_write("what(): (non-std exception)\n"); + } + } + std::abort(); + }); + } +#endif + + /** + * Configure the process logger and start a fresh per-run log file. + * + * `ender::log::file_sink` archives the previous `openra3.log` to a + * timestamped file on open, so every run gets its own log and the previous + * run's log is preserved. + */ + 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}); + log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U}); + log::info("OpenRA3 started"); + } + auto print_usage() -> void { std::puts("OpenRA3 - Red Alert 3 reconstruction"); std::puts("usage:"); @@ -15,6 +112,7 @@ namespace { 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("assets: read from /assets (extracted at build time by the openra3_assets target)"); } @@ -29,6 +127,14 @@ namespace { return std::find(args.begin(), args.end(), name) != args.end(); } + /** Preferred display backend from the command line (default: Vulkan). */ + [[nodiscard]] auto display_backend_from_args(const std::vector &args) -> ra3::display::backend { + if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) return ra3::display::backend::d3d11; + if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12; + if (has_flag(args, "--sdl")) return ra3::display::backend::sdl; + return ra3::display::backend::vulkan; + } + /** Directory the executable lives in. */ auto executable_dir(const char *argv0) -> std::filesystem::path { std::error_code ec; @@ -328,7 +434,14 @@ namespace { options.height = static_cast(height); options.fullscreen = has_flag(args, "--fullscreen"); options.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0; - gpu_shown = ra3::display::run_terrain(options, gpu, camera); + ra3::render::image mini; + if (has_flag(args, "--minimap")) { + terrain::render_options mini_opts; + mini_opts.scale = 1U; + mini_opts.pitch = 1.0F; + mini = render::downscale(terrain::render(terrain, textures, mini_opts), 220U); + } + gpu_shown = ra3::display::run_terrain(options, gpu, camera, mini, display_backend_from_args(args)); if (gpu_shown) return 0; std::printf("GPU terrain unavailable; showing the top-down map instead\n"); } @@ -409,7 +522,7 @@ namespace { viewer.height = static_cast(composed.height()); viewer.fullscreen = has_flag(args, "--fullscreen"); viewer.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0; - shown = ra3::display::run_image(viewer, composed, camera, has_flag(args, "--vulkan")); + shown = ra3::display::run_image(viewer, composed, camera, display_backend_from_args(args)); } if (!shown) { const auto path = out.value_or("openra3_view.bmp"); @@ -448,6 +561,7 @@ namespace { bool thumbnail = false; bool fullscreen = false; int fps_index = 0; ///< 0 = vsync, 1..3 = 30/60/120, 4 = uncapped. + int renderer = 0; ///< Preferred display backend (see renderer_backends). std::string out; }; @@ -455,6 +569,19 @@ namespace { inline constexpr std::array fps_caps{0, 30, 60, 120, -1}; inline constexpr std::array fps_names{"vsync", "30", "60", "120", "uncapped"}; + /** + * Preferred display backends offered by the menu. Each entry is tried first, + * then the others in turn, so an unavailable backend degrades gracefully. + * Direct3D is Windows-only; on other hosts those entries fall through. + */ + inline constexpr std::array renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11, + ra3::display::backend::d3d12, ra3::display::backend::sdl}; + inline constexpr std::array renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"}; + + [[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend { + return renderer_backends[static_cast(std::clamp(index, 0, static_cast(renderer_backends.size()) - 1))]; + } + inline constexpr int menu_width = 1280; inline constexpr int menu_height = 720; @@ -479,7 +606,7 @@ namespace { return buffer; } - [[nodiscard]] auto menu_field_count() -> int { return 19; } + [[nodiscard]] auto menu_field_count() -> int { return 20; } [[nodiscard]] auto menu_field_label(int field) -> std::string_view { switch (field) { @@ -501,6 +628,7 @@ namespace { case 15: return "Fullscreen"; case 16: return "Frame rate"; case 17: return "Play"; + case 18: return "Renderer"; default: return "Quit"; } } @@ -533,6 +661,7 @@ namespace { case 15: return s.fullscreen ? "yes" : "no"; case 16: return std::string{fps_names[static_cast(std::clamp(s.fps_index, 0, 4))]} + " fps"; case 17: return "start"; + case 18: return std::string{renderer_names[static_cast(std::clamp(s.renderer, 0, 3))]}; default: return "exit"; } } @@ -555,6 +684,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; default: break; } } @@ -589,7 +719,7 @@ namespace { L.rows = std::max(1, (L.list_h - L.S(46)) / L.row_h); L.set_x = L.S(650); L.set_w = static_cast(width) - L.set_x - L.S(24); - L.field_row = std::max(1, L.S(27)); + L.field_row = std::max(1, L.S(26)); return L; } @@ -724,9 +854,9 @@ namespace { st.field = field; if (field == 17) { st.action = menu_action::play; - } else if (field == 18) { + } else if (field == 19) { st.action = menu_action::quit; - } else if (field == 0 || field == 13 || field == 15 || field == 16) { + } else if (field == 0 || field == 13 || field == 15 || field == 16 || field == 18) { menu_adjust(field, st.settings, 1); st.dirty = true; } else if (field == 14) { @@ -783,9 +913,9 @@ namespace { case ui_key::confirm: if (st.pane == 0 || st.field == 17) { st.action = menu_action::play; - } else if (st.field == 18) { + } else if (st.field == 19) { st.action = menu_action::quit; - } else if (st.field == 0 || st.field == 13 || st.field == 15 || st.field == 16) { + } else if (st.field == 0 || st.field == 13 || st.field == 15 || st.field == 16 || st.field == 18) { menu_adjust(st.field, st.settings, 1); st.dirty = true; } @@ -951,6 +1081,10 @@ namespace { if (has_flag(args, "--thumbnail")) st.settings.thumbnail = true; if (has_flag(args, "--fullscreen")) st.settings.fullscreen = true; if (has_flag(args, "--3d")) st.settings.mode = 0; + if (has_flag(args, "--vulkan")) st.settings.renderer = 0; + if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1; + if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2; + if (has_flag(args, "--sdl")) st.settings.renderer = 3; 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); @@ -978,7 +1112,9 @@ namespace { options.height = menu_height; options.fullscreen = st.settings.fullscreen; options.fps_limit = fps_caps[static_cast(std::clamp(st.settings.fps_index, 0, 4))]; - auto display = ra3::display::create(options, true); + int applied_renderer = st.settings.renderer; + auto display = ra3::display::create(options, renderer_backend(applied_renderer)); + if (display) ender::log::info(std::format("menu display backend: {}", display->name())); if (!display) { if (!windowed) return console_menu(assets, maps, names, st.settings); return 0; @@ -1006,9 +1142,16 @@ namespace { return 0; } - // Apply fullscreen / frame-rate changes immediately (re-init the window). + // Apply renderer / fullscreen / frame-rate changes (recreate the window). const int want_fps = fps_caps[static_cast(std::clamp(st.settings.fps_index, 0, 4))]; - if (st.settings.fullscreen != options.fullscreen || want_fps != options.fps_limit) { + if (st.settings.renderer != applied_renderer) { + display->shutdown(); + applied_renderer = st.settings.renderer; + options.fullscreen = st.settings.fullscreen; + options.fps_limit = want_fps; + display = ra3::display::create(options, renderer_backend(applied_renderer)); + if (!display) return console_menu(assets, maps, names, st.settings); + } else if (st.settings.fullscreen != options.fullscreen || want_fps != options.fps_limit) { display->shutdown(); options.fullscreen = st.settings.fullscreen; options.fps_limit = want_fps; @@ -1071,8 +1214,13 @@ namespace { } auto main(int argc, char **argv) -> int { +#if defined(_WIN32) + install_crash_handler(); +#endif const std::vector args{argv + 1, argv + argc}; - const auto assets = executable_dir(argc > 0 ? argv[0] : ".") / "assets"; + const auto exe_dir = executable_dir(argc > 0 ? argv[0] : "."); + const auto assets = exe_dir / "assets"; + setup_logging(exe_dir); if (args.empty()) return command_menu({}, assets); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77a785b..2f3a0f8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,9 +62,9 @@ umbrella module re-exports the SDK; applications import `ra3` only. | \ | | \ v | ra3.display (backend pick) - | / \ - v ra3.ui ra3.vulkan - | (SDL3) (Vulkan) + | / | \ + v ra3.ui ra3.vulkan ra3.dx + | (SDL3) (Vulkan) (D3D11/12) --------------------------------------------------------------------------- v L2 simulation ra3.logic ra3.modules ra3.combat ra3.movement @@ -80,7 +80,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.display`, `ra3.game`, `ra3.client`) are the **seeds** of the +`ra3.vulkan.*`, `ra3.dx.*`, `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. @@ -557,7 +558,8 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this. - `[x]` window, streaming-texture blit, input polling - `[ ]` gamepad support `(v0.8)` - **F3 Backend selection (`ra3.display`)** `[D]` - - `[x]` try Vulkan → SDL → report failure for offscreen fallback + - `[x]` preferred backend + ordered fallback (Vulkan / D3D11 / D3D12 / SDL) + - `[x]` in-game Renderer option; report failure for offscreen fallback - **F4 Input mapping** `[~]` - `[x]` keyboard/mouse state, modifier masks - `[ ]` rebindable keybinds, mouse capture, scroll wheel events `(v0.6)` @@ -578,6 +580,21 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this. - **F4 Null fallback** `[D]` - `[x]` report failure when no Vulkan loader is present +### M18b `ra3.dx` — Direct3D 11 / 12 backend `[P]` + +- **F1 Device & swapchain** `[D]` + - `[x]` SDL3 window → HWND, DXGI flip-model swapchain, resize + - `[x]` runtime HLSL via `d3dcompiler_47` (no build-time shader compiler) +- **F2 2D image path** `[D]` + - `[x]` BGRA scene texture + fullscreen-triangle blit (D3D11/D3D12) +- **F3 Terrain presentation** `[D]` + - `[x]` GPU heightfield raymarch (HLSL port of `terrain.frag`), D3D11 and D3D12 +- **F4 Root signatures / PSOs (D3D12)** `[~]` + - `[x]` root constants, descriptor tables, static samplers, barriers + - `[ ]` shared RHI with Vulkan (see M16 F1) `(v0.6)` +- **F5 Null fallback** `[D]` + - `[x]` non-Windows builds link a stub that fails `init` + ### M19 `ra3.audio` — audio `[ ]` - **F1 SFX** `[ ]` diff --git a/shaders/dx_scene.hlsl b/shaders/dx_scene.hlsl new file mode 100644 index 0000000..62f68db --- /dev/null +++ b/shaders/dx_scene.hlsl @@ -0,0 +1,34 @@ +// 2D image blit for the Direct3D backends (the D3D port of scene.vert/scene.frag). +// +// The scene image is drawn as a single fullscreen triangle sampling the +// software-rendered `ra3::render::image`. The constant buffer carries the +// destination rectangle in window-normalized coordinates (y down), so the map +// is letterboxed rather than stretched. D3D clip space has +Y up, so the vertex +// position flips Y relative to the Vulkan shader (which relies on Vulkan's +// +Y-down clip space); the sampled UVs and the image's top-left origin are +// unchanged. + +cbuffer RectCB : register(b0) { + float4 rect; // xy = top-left (0..1), zw = size (0..1) +}; + +Texture2D scene_tex : register(t0); +SamplerState scene_smp : register(s0); + +struct VSOut { + float4 pos : SV_Position; + float2 uv : TEXCOORD0; +}; + +VSOut VSMain(uint vertex_id : SV_VertexID) { + float2 p = float2((vertex_id << 1) & 2, vertex_id & 2); + VSOut o; + o.uv = (p - rect.xy) / rect.zw; + o.pos = float4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0); + return o; +} + +float4 PSMain(VSOut input) : SV_Target { + if (input.uv.x < 0.0 || input.uv.x > 1.0 || input.uv.y < 0.0 || input.uv.y > 1.0) discard; + return scene_tex.Sample(scene_smp, input.uv); +} diff --git a/shaders/dx_terrain.hlsl b/shaders/dx_terrain.hlsl new file mode 100644 index 0000000..f46ac3f --- /dev/null +++ b/shaders/dx_terrain.hlsl @@ -0,0 +1,236 @@ +// GPU heightfield raymarcher for the Direct3D backends (the D3D port of +// terrain.vert/terrain.frag). +// +// Textures: heightmap (R16), a per-cell blend record (R16G16B16A16_UNORM: base +// layer, blend layer, three-way layer, packed direction/flags; unpacked with +// *65535) and a texture array of the tile materials (RGBA8, REPEAT). The +// material is sampled continuously (`uv = cell / span`), as the retail +// `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell +// edge; material boundaries cross-fade with the SAGE blend ramp. +// +// The Vulkan push constants (20 floats) become a constant buffer. + +cbuffer TerrainCB : register(b0) { + float4 cam; // x=target_x, y=target_y, z=yaw, w=height + float4 params; // x=pitch, y=fov, z=water_z, w=has_water + float4 sun; // xyz=sun dir, w=ambient + float4 mapinfo; // x=W, y=H, z=unused, w=z_scale + float4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect +}; + +Texture2D heightmap : register(t0); +Texture2D celldata : register(t1); +Texture2DArray atlas : register(t2); +SamplerState height_smp : register(s0); +SamplerState cell_smp : register(s1); +SamplerState atlas_smp : register(s2); + +static const float CELL = 10.0; // must match ra3::terrain::cell_size + +struct VSOut { + float4 pos : SV_Position; + float2 uv : TEXCOORD0; +}; + +VSOut VSMain(uint vertex_id : SV_VertexID) { + float2 p = float2((vertex_id << 1) & 2, vertex_id & 2); + VSOut o; + o.uv = p; + o.pos = float4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0); + return o; +} + +float height_at(int2 c) { + c = clamp(c, int2(0, 0), int2((int) mapinfo.x - 1, (int) mapinfo.y - 1)); + return heightmap.Load(int3(c, 0)) * 65535.0 * mapinfo.w; +} + +float world_height(float wx, float wy) { + float world_w = mapinfo.x * CELL; + float world_h = mapinfo.y * CELL; + if (wx < 0.0 || wy < 0.0 || wx >= world_w || wy >= world_h) return -1.0e9; + int2 c = int2((int) (wx / CELL), (int) ((world_h - wy) / CELL)); + return height_at(c); +} + +float3 sky_color(float3 dir) { + float3 d = normalize(dir); + float3 sun_dir = normalize(sun.xyz); + float t = clamp(d.z, 0.0, 1.0); + float3 horizon = float3(0.70, 0.78, 0.85); + float3 zenith = float3(0.28, 0.48, 0.80); + float3 col = lerp(horizon, zenith, pow(t, 0.6)); + float s = max(dot(d, sun_dir), 0.0); + col += float3(1.0, 0.95, 0.82) * pow(s, 300.0) * 1.6; // sun disk + col += float3(1.0, 0.90, 0.72) * pow(s, 8.0) * 0.18; // glow + return col; +} + +// The retail SAGE blend ramp: 0 at one edge of the cell, 1 at the opposite. +// Direction: 1 right, 2 top, 4 top-right, 8 top-left; flag bit 0 flips, +// bit 1 marks a two-sided diagonal. +float blend_factor(uint direction, uint flags, float2 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; +} + +// Sample one tile material layer at global cell coordinates. The texture repeats +// every `span` cells with REPEAT addressing, so it never restarts at a cell edge. +float3 sample_layer(uint layer, float wx, float wy) { + float span = max(misc.z, 1.0); + uint lw = 0; + uint lh = 0; + uint layer_count = 0; + atlas.GetDimensions(lw, lh, layer_count); + float l = (float) min(layer, layer_count > 0u ? layer_count - 1u : 0u); + return atlas.Sample(atlas_smp, float3(float2(wx, wy) / span, l)).rgb; +} + +float4 PSMain(VSOut input) : SV_Target { + float4 p = cam; + float pitch = clamp(params.x, 0.15, 1.45); + float fov = clamp(params.y, 0.3, 1.4); + float world_w = mapinfo.x * CELL; + float world_h = mapinfo.y * CELL; + + float cp = cos(pitch); + float3 fwd = float3(cp * sin(p.z), cp * cos(p.z), -sin(pitch)); + float3 right = normalize(cross(fwd, float3(0, 0, 1))); + float3 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); + float3 cam_pos = float3(p.x, p.y, target_z + p.w) - fwd * dist; + + float2 ndc = float2(input.uv.x * 2.0 - 1.0, 1.0 - input.uv.y * 2.0); + float aspect = misc.w; + float th = tan(fov * 0.5); + float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); + + if (dir.z >= -1e-4) { + return float4(sky_color(dir), 1.0); + } + + // March the heightfield (bounded work: the step grows toward the horizon). + 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) { + 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; + t += dt; + continue; + } + if (params.w > 0.5 && w.z <= params.z) { + hit = true; + hit_t = t; + break; + } + if (w.z <= world_height(w.x, w.y)) { + hit = true; + hit_t = t; + break; + } + prev = t; + dt *= 1.06; + t += dt; + } + if (!hit) { + return float4(sky_color(dir), 1.0); + } + + float lo = prev; + float hi = hit_t; + for (int i = 0; i < 6; ++i) { + float mid = 0.5 * (lo + hi); + float3 w = cam_pos + dir * mid; + bool water = params.w > 0.5 && w.z <= params.z; + if (water || w.z <= world_height(w.x, w.y)) { + hi = mid; + } else { + lo = mid; + } + } + float3 hitpos = cam_pos + dir * hi; + + float3 sun_dir = normalize(sun.xyz); + float ambient = sun.w; + + if (params.w > 0.5 && hitpos.z <= params.z + 0.01) { + // Water: animated normal from a procedural wave, sky reflection + fresnel. + float time = misc.x; + float2 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); + float3 n = normalize(float3(nx * 0.06, ny * 0.06, 1.0)); + float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0); + float3 deep = float3(0.03, 0.16, 0.28); + float3 refl = sky_color(reflect(dir, n)); + float lam = max(0.0, dot(n, sun_dir)); + float3 water = lerp(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9)); + water += float3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint + float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); + water = lerp(water, sky_color(float3(dir.x, dir.y, 0.0)), wfog); + return float4(water, 1.0); + } + + // Terrain: read the per-cell blend record, sample the base/blend/three-way + // material layers continuously and ramp between them across the cell. + float wx = hitpos.x / CELL; + float wy = (world_h - hitpos.y) / CELL; + int cx = clamp((int) wx, 0, (int) mapinfo.x - 1); + int cy = clamp((int) wy, 0, (int) mapinfo.y - 1); + float fx = wx - floor(wx); + float fy = wy - floor(wy); + + uint4 record = (uint4) (celldata.Load(int3(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; + + float2 fracUV = float2(fx, fy); + float3 c0 = sample_layer(record.x, wx, wy); + float3 c1 = sample_layer(record.y, wx, wy); + float3 c2 = sample_layer(record.z, wx, wy); + float f1 = blend_factor(dir1, flags1, fracUV); + float f2 = blend_factor(dir2, flags2, fracUV); + float3 albedo = lerp(lerp(c0, c1, f1), c2, f2); + + // Per-pixel normal from the heightfield. + 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); + float3 n = normalize(float3(hl - hr, hd - hu, 2.0 * CELL)); + float lambert = max(0.0, dot(n, sun_dir)); + float3 lit = albedo * (ambient + (1.0 - ambient) * lambert); + // Distance haze toward the horizon so the map edge blends into the sky. + float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); + lit = lerp(lit, sky_color(float3(dir.x, dir.y, 0.0)), fog); + return float4(lit, 1.0); +} diff --git a/src/client/ra3.client.cppm b/src/client/ra3.client.cppm index 40f455f..02b8026 100644 --- a/src/client/ra3.client.cppm +++ b/src/client/ra3.client.cppm @@ -499,6 +499,15 @@ export namespace ra3::client { this->shutdown(); return true; } + + /** GPU terrain viewer with a corner minimap overlay. */ + [[nodiscard]] auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, + const image &minimap) -> bool { + if (!this->init(options)) return false; + (void)this->terrain_loop(terrain, camera, minimap); + this->shutdown(); + return true; + } }; /** diff --git a/src/display/ra3.display.cppm b/src/display/ra3.display.cppm index 4c2ad0f..9ba6dc0 100644 --- a/src/display/ra3.display.cppm +++ b/src/display/ra3.display.cppm @@ -8,14 +8,18 @@ import ra3.terrain; import ra3.client; import ra3.ui; import ra3.vulkan; +import ra3.dx; /** * Backend selection for the presentation layer. * * The app talks only to `ra3::client::display`; this module picks the concrete - * backend (Vulkan when available, otherwise SDL) so no caller has to know which - * one is in use. The GPU terrain path is Vulkan-only and reports failure so the - * caller can fall back to the software renderer. + * 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. */ export namespace ra3::display { using ra3::client::display_options; @@ -27,72 +31,127 @@ export namespace ra3::display { using menu_frame = std::function(const ui_event &, ra3::core::uint32, ra3::core::uint32, bool &)>; using camera_frame = std::function; - /** The backend that actually initialized, or `none`. */ - enum class backend { none, vulkan, sdl }; + /** A concrete presentation backend, or `none`. */ + enum class backend { none, vulkan, d3d11, d3d12, sdl }; - /** - * Create and initialize the preferred display (Vulkan, then SDL), or return - * null when neither starts. The caller owns the display and may reuse it for - * a menu, a loading bar and a viewer in sequence. - */ - [[nodiscard]] inline auto create(const display_options &options, bool prefer_vulkan) -> std::unique_ptr { - const auto try_vulkan = [&]() -> std::unique_ptr { - auto d = std::make_unique(); - if (d->init(options)) return d; - return nullptr; - }; - const auto try_sdl = [&]() -> std::unique_ptr { - auto d = std::make_unique(); - if (d->init(options)) return d; - return nullptr; - }; - if (prefer_vulkan) { - if (auto d = try_vulkan()) return d; - return try_sdl(); + [[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::sdl: return "sdl"; + default: return "none"; + } + } + + [[nodiscard]] inline auto make_backend(backend which) -> std::unique_ptr { + switch (which) { + case backend::vulkan: return std::make_unique(); + case backend::d3d11: return std::make_unique(); + case backend::d3d12: return std::make_unique(); + case backend::sdl: return std::make_unique(); + default: return nullptr; + } + } + + /** The order in which backends are attempted for a preferred one. */ + [[nodiscard]] inline auto backend_order(backend preferred) -> std::array { + 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::vulkan: + default: return {backend::vulkan, backend::d3d11, backend::d3d12, backend::sdl}; } - if (auto d = try_sdl()) return d; - return try_vulkan(); } /** - * Run `action` on the first display that initializes: Vulkan, then SDL. + * Create and initialize the preferred backend, falling back to the others, + * or return null when none starts. The caller owns the display and may reuse + * it for a menu, a loading bar and a viewer in sequence. + */ + [[nodiscard]] inline auto create(const display_options &options, backend preferred) -> std::unique_ptr { + for (const auto which: backend_order(preferred)) { + auto candidate = make_backend(which); + if (candidate && candidate->init(options)) return candidate; + } + return nullptr; + } + + /** Create and initialize the preferred display (Vulkan, then SDL). */ + [[nodiscard]] inline auto create(const display_options &options, bool prefer_vulkan) -> std::unique_ptr { + return create(options, prefer_vulkan ? backend::vulkan : backend::sdl); + } + + /** + * Run `action` on the first backend that initializes. * * `action` must call one of the shared `display` loops; it returns false to * mean "this backend did not start", so the next one is tried. */ template - [[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool { - if (prefer_vulkan) { - if (auto vk = std::make_unique(); action(*vk)) return true; - } - if (auto sdl = std::make_unique(); action(*sdl)) return true; - if (!prefer_vulkan) { - if (auto vk = std::make_unique(); action(*vk)) return true; + [[nodiscard]] auto with_display(backend preferred, Fn &&action) -> bool { + for (const auto which: backend_order(preferred)) { + auto candidate = make_backend(which); + if (candidate && action(*candidate)) return true; } return false; } - /** Interactive menu on the first available backend. */ - [[nodiscard]] inline auto run_menu(const display_options &options, const menu_frame &frame) -> bool { - return with_display(true, [&](ra3::client::display &d) { return d.run_menu(options, frame); }); + /** Run `action` on the first display that initializes: Vulkan, then SDL. */ + template + [[nodiscard]] auto with_display(bool prefer_vulkan, Fn &&action) -> bool { + return with_display(prefer_vulkan ? backend::vulkan : backend::sdl, std::forward(action)); } - /** Pan/zoom image viewer on the first available backend. */ + /** 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 { + return with_display(preferred, [&](ra3::client::display &d) { return d.run_menu(options, frame); }); + } + + /** Pan/zoom image viewer on the preferred (or first available) backend. */ + [[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, backend preferred) -> bool { + return with_display(preferred, [&](ra3::client::display &d) { return d.run_image(options, scene, camera); }); + } + + /** Pan/zoom image viewer on the preferred (or first available) backend. */ [[nodiscard]] inline auto run_image(const display_options &options, const image &scene, view_camera camera, bool prefer_vulkan) -> bool { - return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_image(options, scene, camera); }); + return run_image(options, scene, camera, prefer_vulkan ? backend::vulkan : backend::sdl); } - /** Software 3D camera viewer on the first available backend. */ + /** Software 3D camera viewer on the preferred (or first available) backend. */ [[nodiscard]] inline auto run_camera(const display_options &options, const camera_frame &provider, camera3d camera, bool prefer_vulkan) -> bool { - return with_display(prefer_vulkan, [&](ra3::client::display &d) { return d.run_camera(options, provider, camera); }); + return with_display(prefer_vulkan ? backend::vulkan : backend::sdl, + [&](ra3::client::display &d) { return d.run_camera(options, provider, camera); }); } /** - * GPU terrain viewer. Vulkan only: returns false when Vulkan is unavailable - * so the caller can render offscreen instead. + * GPU terrain viewer on the preferred (or first available) backend. Returns + * false when no GPU backend that offers terrain starts, so the caller can + * render offscreen instead. + */ + [[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera, + backend preferred) -> bool { + return with_display(preferred, [&](ra3::client::display &d) { + if (!d.supports_terrain()) return false; + return d.run_terrain(options, terrain, camera); + }); + } + + /** GPU terrain viewer with a corner minimap overlay. */ + [[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera, + const image &minimap, backend preferred) -> bool { + return with_display(preferred, [&](ra3::client::display &d) { + if (!d.supports_terrain()) return false; + return d.run_terrain(options, terrain, camera, minimap); + }); + } + + /** + * GPU terrain viewer. Vulkan/D3D only: returns false when the preferred GPU + * backend is unavailable so the caller can render offscreen instead. */ [[nodiscard]] inline auto run_terrain(const display_options &options, const ra3::terrain::gpu_terrain &terrain, camera3d camera) -> bool { - auto vk = std::make_unique(); - return vk->run_terrain(options, terrain, camera); + return run_terrain(options, terrain, camera, backend::vulkan); } } diff --git a/src/dx/ra3.dx.cppm b/src/dx/ra3.dx.cppm new file mode 100644 index 0000000..da178c7 --- /dev/null +++ b/src/dx/ra3.dx.cppm @@ -0,0 +1,1392 @@ +module; + +// Windows / Direct3D headers live in the global module fragment: they are +// preprocessor-based and must not be part of the module purview. +#define NOMINMAX +#define WIN32_LEAN_AND_MEAN +#include + +#include +#include +#include +#include + +#include + +#include "dx_shaders_embedded.hpp" + +export module ra3.dx; + +import std; + +export import ra3.core; +import ra3.render; +import ra3.terrain; +import ra3.client; +import ender.log; + +/** + * Direct3D 11 and Direct3D 12 presentation backends for the engine's scene. + * + * Red Alert 3 shipped a Direct3D 9 renderer; these backends reconstruct the + * modern D3D counterparts next to the Vulkan one. Both implement the same + * `ra3::client::display` interface, so the interactive loops (menu, image + * viewer, terrain viewer) are shared and only the low-level primitives are + * backend-specific. HLSL is compiled at runtime through `d3dcompiler_47`, so no + * shader compiler is needed at build time (the build cross-compiles from Linux). + * + * The 2D path draws a fullscreen triangle sampling a software `ra3::render::image`; + * the terrain path ray-marches the heightfield in a pixel shader (the D3D port + * of `terrain.frag`). + */ +export namespace ra3::dx { + namespace detail { + /** A minimal owning COM pointer (no WRL dependency). */ + template + class com_ptr { + public: + com_ptr() = default; + /** Adopt an already-owned reference. */ + explicit com_ptr(T *pointer) noexcept : pointer_(pointer) {} + com_ptr(const com_ptr &) = delete; + auto operator=(const com_ptr &) -> com_ptr & = delete; + com_ptr(com_ptr &&other) noexcept : pointer_(other.pointer_) { other.pointer_ = nullptr; } + auto operator=(com_ptr &&other) noexcept -> com_ptr & { + if (this != &other) { + this->reset(); + pointer_ = other.pointer_; + other.pointer_ = nullptr; + } + return *this; + } + ~com_ptr() { this->reset(); } + + auto reset() -> void { + if (pointer_ != nullptr) { + pointer_->Release(); + pointer_ = nullptr; + } + } + [[nodiscard]] auto get() const -> T * { return pointer_; } + /** Address of the stored pointer without releasing it (for array APIs). */ + [[nodiscard]] auto address() const -> T *const * { return &pointer_; } + /** Address for a `CreateXxx(&out)` call; releases any previous value. */ + [[nodiscard]] auto put() -> T ** { + this->reset(); + return &pointer_; + } + auto operator->() const -> T * { return pointer_; } + explicit operator bool() const { return pointer_ != nullptr; } + + private: + T *pointer_ = nullptr; + }; + + [[nodiscard]] inline auto check(HRESULT result, std::string_view what) -> bool { + if (SUCCEEDED(result)) return true; + ender::log::error(std::format("ra3.dx: {} failed (HRESULT 0x{:08X})", what, static_cast(result))); + return false; + } + + [[nodiscard]] inline auto key_from_sdl(SDL_Keycode key) -> ra3::render::ui_key { + using ra3::render::ui_key; + switch (key) { + case SDLK_UP: return ui_key::up; + case SDLK_DOWN: return ui_key::down; + case SDLK_LEFT: return ui_key::left; + case SDLK_RIGHT: return ui_key::right; + case SDLK_PAGEUP: return ui_key::page_up; + case SDLK_PAGEDOWN: return ui_key::page_down; + case SDLK_RETURN: + case SDLK_KP_ENTER: return ui_key::confirm; + case SDLK_ESCAPE: return ui_key::cancel; + case SDLK_TAB: return ui_key::tab; + case SDLK_BACKSPACE: return ui_key::backspace; + default: return ui_key::none; + } + } + + [[nodiscard]] inline auto poll_event(SDL_Window *window, ra3::render::ui_event &out) -> bool { + (void) window; + SDL_Event event; + while (SDL_PollEvent(&event)) { + switch (event.type) { + case SDL_EVENT_QUIT: + out = {}; + out.type = ra3::render::ui_event_type::quit; + return true; + case SDL_EVENT_KEY_DOWN: + if (const auto key = key_from_sdl(event.key.key); key != ra3::render::ui_key::none) { + out = {}; + out.type = ra3::render::ui_event_type::key; + out.key = key; + return true; + } + break; + case SDL_EVENT_TEXT_INPUT: + if (event.text.text[0] != '\0') { + out = {}; + out.type = ra3::render::ui_event_type::text; + out.character = event.text.text[0]; + return true; + } + break; + case SDL_EVENT_MOUSE_MOTION: + out = {}; + out.type = ra3::render::ui_event_type::mouse_move; + out.x = event.motion.x; + out.y = event.motion.y; + out.dx = event.motion.xrel; + out.dy = event.motion.yrel; + out.left = (event.motion.state & SDL_BUTTON_LMASK) != 0U; + return true; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + out = {}; + out.type = ra3::render::ui_event_type::mouse_button; + out.x = event.button.x; + out.y = event.button.y; + out.left = event.button.button == SDL_BUTTON_LEFT; + return true; + case SDL_EVENT_MOUSE_WHEEL: + out = {}; + out.type = ra3::render::ui_event_type::wheel; + out.wheel = event.wheel.y; + return true; + default: + break; + } + } + return false; + } + + [[nodiscard]] inline auto key_down(ra3::render::ui_key key) -> bool { + const bool *keys = SDL_GetKeyboardState(nullptr); + if (keys == nullptr) return false; + switch (key) { + case ra3::render::ui_key::up: return keys[SDL_SCANCODE_W] || keys[SDL_SCANCODE_UP]; + case ra3::render::ui_key::down: return keys[SDL_SCANCODE_S] || keys[SDL_SCANCODE_DOWN]; + case ra3::render::ui_key::left: return keys[SDL_SCANCODE_A] || keys[SDL_SCANCODE_LEFT]; + case ra3::render::ui_key::right: return keys[SDL_SCANCODE_D] || keys[SDL_SCANCODE_RIGHT]; + default: return false; + } + } + + /** The native window handle D3D renders into. */ + [[nodiscard]] inline auto hwnd_of(SDL_Window *window) -> HWND { + const SDL_PropertiesID properties = SDL_GetWindowProperties(window); + return static_cast(SDL_GetPointerProperty(properties, SDL_PROP_WINDOW_WIN32_HWND_POINTER, nullptr)); + } + + /** Compile one HLSL entry point at runtime (d3dcompiler_47). */ + [[nodiscard]] inline auto compile_hlsl(std::string_view source, const char *entry, const char *target) -> ID3DBlob * { + ID3DBlob *blob = nullptr; + ID3DBlob *errors = nullptr; + const HRESULT result = + D3DCompile(source.data(), source.size(), nullptr, nullptr, nullptr, entry, target, 0U, 0U, &blob, &errors); + if (FAILED(result)) { + ender::log::error(std::format("ra3.dx: D3DCompile({}, {}) failed: {}", entry, target, + errors != nullptr ? static_cast(errors->GetBufferPointer()) : "?")); + if (errors != nullptr) errors->Release(); + if (blob != nullptr) blob->Release(); + return nullptr; + } + if (errors != nullptr) errors->Release(); + return blob; + } + + /** Camera + terrain uniforms shared by both backends (20 floats). */ + [[nodiscard]] inline auto terrain_push(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, + float aspect) -> std::array { + return {camera.target_x, + camera.target_y, + camera.yaw, + camera.height, + camera.pitch, + camera.fov, + terrain.water_z, + terrain.has_water ? 1.0F : 0.0F, + 0.45F, + 0.35F, + 0.82F, + 0.38F, + static_cast(terrain.width), + static_cast(terrain.height), + 0.0F, + terrain.z_scale, + time_s, + 0.0F, + terrain.cell_span, + aspect}; + } + + /** One resource-transition barrier covering all subresources. */ + inline auto barrier(ID3D12GraphicsCommandList *list, ID3D12Resource *resource, D3D12_RESOURCE_STATES before, D3D12_RESOURCE_STATES after) + -> void { + D3D12_RESOURCE_BARRIER barrier{}; + barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + barrier.Flags = D3D12_RESOURCE_BARRIER_FLAG_NONE; + barrier.Transition.pResource = resource; + barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + barrier.Transition.StateBefore = before; + barrier.Transition.StateAfter = after; + list->ResourceBarrier(1U, &barrier); + } + + [[nodiscard]] inline auto make_static_sampler(D3D12_FILTER filter, D3D12_TEXTURE_ADDRESS_MODE address, UINT shader_register) + -> D3D12_STATIC_SAMPLER_DESC { + D3D12_STATIC_SAMPLER_DESC sampler{}; + sampler.Filter = filter; + sampler.AddressU = sampler.AddressV = sampler.AddressW = address; + sampler.MipLODBias = 0.0F; + sampler.MaxAnisotropy = 1U; + sampler.ComparisonFunc = D3D12_COMPARISON_FUNC_ALWAYS; + sampler.BorderColor = D3D12_STATIC_BORDER_COLOR_OPAQUE_BLACK; + sampler.MinLOD = 0.0F; + sampler.MaxLOD = D3D12_FLOAT32_MAX; + sampler.ShaderRegister = shader_register; + sampler.RegisterSpace = 0U; + sampler.ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; + return sampler; + } + + [[nodiscard]] inline auto serialize_root_signature(ID3D12Device *device, const D3D12_ROOT_SIGNATURE_DESC &desc, + com_ptr &out) -> bool { + com_ptr blob; + com_ptr errors; + const HRESULT result = D3D12SerializeRootSignature(&desc, D3D_ROOT_SIGNATURE_VERSION_1, blob.put(), errors.put()); + if (FAILED(result)) { + ender::log::error(std::format("ra3.dx: D3D12SerializeRootSignature failed: {}", + errors ? static_cast(errors->GetBufferPointer()) : "?")); + return false; + } + return check(device->CreateRootSignature(0U, blob->GetBufferPointer(), blob->GetBufferSize(), + __uuidof(ID3D12RootSignature), reinterpret_cast(out.put())), + "CreateRootSignature"); + } + } // namespace detail + + /** + * Direct3D 11 backend: a swapchain, one fullscreen-triangle pipeline for the + * 2D image and a second for the GPU terrain raymarcher. + */ + class d3d11_display final : public ra3::client::display { + public: + d3d11_display() = default; + d3d11_display(const d3d11_display &) = delete; + auto operator=(const d3d11_display &) -> d3d11_display & = delete; + ~d3d11_display() override { this->shutdown(); } + + [[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override { + if (!SDL_Init(SDL_INIT_VIDEO)) return false; + this->fps_limit_ = options.fps_limit; + window_ = SDL_CreateWindow(options.title.c_str(), options.width, options.height, + SDL_WINDOW_RESIZABLE | (options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0)); + if (window_ == nullptr) { + SDL_Quit(); + return false; + } + SDL_RaiseWindow(window_); + hwnd_ = detail::hwnd_of(window_); + if (hwnd_ == nullptr) { + this->shutdown(); + return false; + } + if (!this->create_device()) { + this->shutdown(); + return false; + } + if (!this->create_swapchain()) { + this->shutdown(); + return false; + } + if (!this->create_scene_pipeline()) { + this->shutdown(); + return false; + } + SDL_StartTextInput(window_); + ender::log::info("ra3.dx: Direct3D 11 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 (!this->ensure_swapchain()) return false; + if (changed || !scene_tex_ || frame.width() != scene_w_ || frame.height() != scene_h_) { + if (!this->upload_scene(frame)) return false; + } + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + + this->bind_target(0.05F, 0.06F, 0.08F); + this->bind_scene_pipeline(); + const std::array rect{dest.x / static_cast(window_w), dest.y / static_cast(window_h), + dest.w / static_cast(window_w), dest.h / static_cast(window_h)}; + context_->VSSetConstantBuffers(0U, 1U, scene_cb_.address()); + this->write_cb(scene_cb_, rect); + context_->PSSetShaderResources(0U, 1U, scene_srv_.address()); + context_->Draw(3U, 0U); + return this->present_frame(); + } + + [[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override { return detail::poll_event(window_, out); } + + [[nodiscard]] auto window_size() const -> std::pair override { + int w = 0; + int h = 0; + SDL_GetWindowSizeInPixels(window_, &w, &h); + return {w, h}; + } + + [[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override { return detail::key_down(key); } + + [[nodiscard]] auto supports_terrain() const -> bool override { return true; } + + [[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, + const ra3::client::terrain_overlay &overlay) -> bool override { + if (!terrain_ready_) { + if (!this->create_terrain_pipeline()) return false; + if (!this->create_terrain_textures(terrain)) return false; + terrain_ready_ = true; + ender::log::info(std::format("ra3.dx: Direct3D 11 terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count)); + } + if (!this->ensure_swapchain()) return false; + if (overlay.label_changed) (void) this->upload_overlay(label_tex_, label_srv_, overlay_label_w_, overlay_label_h_, overlay.label); + if (overlay.minimap_changed) (void) this->upload_overlay(minimap_tex_, minimap_srv_, overlay_minimap_w_, overlay_minimap_h_, overlay.minimap); + + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + const auto aspect = static_cast(window_w) / static_cast(std::max(1, window_h)); + + this->bind_target(0.45F, 0.55F, 0.70F); + context_->VSSetShader(terrain_vs_.get(), nullptr, 0U); + context_->PSSetShader(terrain_ps_.get(), nullptr, 0U); + context_->PSSetSamplers(0U, 3U, terrain_samplers_); + context_->PSSetConstantBuffers(0U, 1U, terrain_cb_.address()); + const auto push = detail::terrain_push(terrain, camera, time_s, aspect); + this->write_cb(terrain_cb_, std::span(push)); + ID3D11ShaderResourceView *terrain_views[3] = {height_srv_.get(), cell_srv_.get(), atlas_srv_.get()}; + context_->PSSetShaderResources(0U, 3U, terrain_views); + context_->Draw(3U, 0U); + + // Overlays (FPS label, corner minimap) use the image pipeline. + this->bind_scene_pipeline(); + this->draw_overlay(label_srv_, overlay_label_w_, overlay_label_h_, 12.0F, 12.0F, window_w, window_h); + if (overlay_minimap_w_ > 0U) { + this->draw_overlay(minimap_srv_, overlay_minimap_w_, overlay_minimap_h_, + static_cast(window_w) - static_cast(overlay_minimap_w_) - 12.0F, 12.0F, window_w, window_h); + } + return this->present_frame(); + } + + auto shutdown() -> void override { + if (window_ != nullptr) SDL_StopTextInput(window_); + scene_tex_.reset(); + scene_srv_.reset(); + scene_cb_.reset(); + scene_smp_.reset(); + blend_.reset(); + raster_.reset(); + scene_vs_.reset(); + scene_ps_.reset(); + height_tex_.reset(); + height_srv_.reset(); + cell_tex_.reset(); + cell_srv_.reset(); + atlas_tex_.reset(); + atlas_srv_.reset(); + label_tex_.reset(); + label_srv_.reset(); + minimap_tex_.reset(); + minimap_srv_.reset(); + terrain_cb_.reset(); + terrain_samplers_[0] = terrain_samplers_[1] = terrain_samplers_[2] = nullptr; + height_smp_.reset(); + cell_smp_.reset(); + atlas_smp_.reset(); + terrain_vs_.reset(); + terrain_ps_.reset(); + rtv_.reset(); + swapchain_.reset(); + context_.reset(); + device_.reset(); + if (window_ != nullptr) SDL_DestroyWindow(window_); + SDL_Quit(); + window_ = nullptr; + hwnd_ = nullptr; + scene_w_ = 0; + scene_h_ = 0; + overlay_label_w_ = 0; + overlay_label_h_ = 0; + overlay_minimap_w_ = 0; + overlay_minimap_h_ = 0; + terrain_ready_ = false; + swapchain_w_ = 0; + swapchain_h_ = 0; + } + + [[nodiscard]] auto name() const -> std::string_view override { return "d3d11"; } + + private: + [[nodiscard]] auto create_device() -> bool { + D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0}; + const UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; + HRESULT result = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, levels, 3U, D3D11_SDK_VERSION, device_.put(), + nullptr, context_.put()); + if (FAILED(result)) { + result = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, flags, levels, 3U, D3D11_SDK_VERSION, device_.put(), nullptr, + context_.put()); + } + return detail::check(result, "D3D11CreateDevice"); + } + + [[nodiscard]] auto create_swapchain() -> bool { + const auto [width, height] = this->window_size(); + DXGI_SWAP_CHAIN_DESC desc{}; + desc.BufferCount = 2U; + desc.BufferDesc.Width = static_cast(std::max(1, width)); + desc.BufferDesc.Height = static_cast(std::max(1, height)); + desc.BufferDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + desc.OutputWindow = hwnd_; + desc.SampleDesc.Count = 1U; + desc.Windowed = TRUE; + desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; + + detail::com_ptr dxgi_device; + if (!detail::check(device_->QueryInterface(__uuidof(IDXGIDevice), reinterpret_cast(dxgi_device.put())), + "QueryInterface(IDXGIDevice)")) + return false; + detail::com_ptr adapter; + if (!detail::check(dxgi_device->GetAdapter(adapter.put()), "GetAdapter")) return false; + detail::com_ptr factory; + if (!detail::check(adapter->GetParent(__uuidof(IDXGIFactory1), reinterpret_cast(factory.put())), "GetParent(IDXGIFactory1)")) + return false; + if (!detail::check(factory->CreateSwapChain(device_.get(), &desc, swapchain_.put()), "CreateSwapChain")) return false; + swapchain_w_ = desc.BufferDesc.Width; + swapchain_h_ = desc.BufferDesc.Height; + return this->create_rtv(); + } + + [[nodiscard]] auto create_rtv() -> bool { + detail::com_ptr backbuffer; + if (!detail::check(swapchain_->GetBuffer(0U, __uuidof(ID3D11Texture2D), reinterpret_cast(backbuffer.put())), "GetBuffer")) + return false; + return detail::check(device_->CreateRenderTargetView(backbuffer.get(), nullptr, rtv_.put()), "CreateRenderTargetView"); + } + + /** Resize the swapchain when the window changed; false on failure. */ + [[nodiscard]] auto ensure_swapchain() -> bool { + const auto [width, height] = this->window_size(); + if (width <= 0 || height <= 0) return true; + if (static_cast(width) == swapchain_w_ && static_cast(height) == swapchain_h_) return true; + context_->OMSetRenderTargets(0U, nullptr, nullptr); + rtv_.reset(); + context_->Flush(); + if (!detail::check(swapchain_->ResizeBuffers(0U, static_cast(width), static_cast(height), DXGI_FORMAT_UNKNOWN, 0U), "ResizeBuffers")) + return false; + swapchain_w_ = static_cast(width); + swapchain_h_ = static_cast(height); + return this->create_rtv(); + } + + [[nodiscard]] auto create_scene_pipeline() -> bool { + const std::string_view source{ra3_shaders::dx_scene_hlsl}; + detail::com_ptr vs_blob{detail::compile_hlsl(source, "VSMain", "vs_5_0")}; + detail::com_ptr ps_blob{detail::compile_hlsl(source, "PSMain", "ps_5_0")}; + if (!vs_blob || !ps_blob) return false; + if (!detail::check(device_->CreateVertexShader(vs_blob->GetBufferPointer(), vs_blob->GetBufferSize(), nullptr, scene_vs_.put()), + "CreateVertexShader(scene)")) + return false; + if (!detail::check(device_->CreatePixelShader(ps_blob->GetBufferPointer(), ps_blob->GetBufferSize(), nullptr, scene_ps_.put()), + "CreatePixelShader(scene)")) + return false; + + D3D11_SAMPLER_DESC sampler{}; + sampler.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + sampler.AddressU = sampler.AddressV = sampler.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + sampler.ComparisonFunc = D3D11_COMPARISON_NEVER; + sampler.MaxLOD = D3D11_FLOAT32_MAX; + if (!detail::check(device_->CreateSamplerState(&sampler, scene_smp_.put()), "CreateSamplerState(scene)")) return false; + + D3D11_BUFFER_DESC cb{}; + cb.ByteWidth = 16U; + cb.Usage = D3D11_USAGE_DYNAMIC; + cb.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + cb.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + if (!detail::check(device_->CreateBuffer(&cb, nullptr, scene_cb_.put()), "CreateBuffer(scene cb)")) return false; + + D3D11_BLEND_DESC blend{}; + blend.RenderTarget[0].BlendEnable = TRUE; + blend.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA; + blend.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA; + blend.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD; + blend.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE; + blend.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_INV_SRC_ALPHA; + blend.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD; + blend.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL; + if (!detail::check(device_->CreateBlendState(&blend, blend_.put()), "CreateBlendState")) return false; + + D3D11_RASTERIZER_DESC raster{}; + raster.FillMode = D3D11_FILL_SOLID; + raster.CullMode = D3D11_CULL_NONE; + raster.DepthClipEnable = TRUE; + if (!detail::check(device_->CreateRasterizerState(&raster, raster_.put()), "CreateRasterizerState")) return false; + return true; + } + + [[nodiscard]] auto create_terrain_pipeline() -> bool { + const std::string_view source{ra3_shaders::dx_terrain_hlsl}; + detail::com_ptr vs_blob{detail::compile_hlsl(source, "VSMain", "vs_5_0")}; + detail::com_ptr ps_blob{detail::compile_hlsl(source, "PSMain", "ps_5_0")}; + if (!vs_blob || !ps_blob) return false; + if (!detail::check(device_->CreateVertexShader(vs_blob->GetBufferPointer(), vs_blob->GetBufferSize(), nullptr, terrain_vs_.put()), + "CreateVertexShader(terrain)")) + return false; + if (!detail::check(device_->CreatePixelShader(ps_blob->GetBufferPointer(), ps_blob->GetBufferSize(), nullptr, terrain_ps_.put()), + "CreatePixelShader(terrain)")) + return false; + + D3D11_SAMPLER_DESC point{}; + point.Filter = D3D11_FILTER_MIN_MAG_MIP_POINT; + point.AddressU = point.AddressV = point.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + point.ComparisonFunc = D3D11_COMPARISON_NEVER; + point.MaxLOD = D3D11_FLOAT32_MAX; + D3D11_SAMPLER_DESC wrap = point; + wrap.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + wrap.AddressU = wrap.AddressV = wrap.AddressW = D3D11_TEXTURE_ADDRESS_WRAP; + if (!detail::check(device_->CreateSamplerState(&point, height_smp_.put()), "CreateSamplerState(height)")) return false; + if (!detail::check(device_->CreateSamplerState(&point, cell_smp_.put()), "CreateSamplerState(cell)")) return false; + if (!detail::check(device_->CreateSamplerState(&wrap, atlas_smp_.put()), "CreateSamplerState(atlas)")) return false; + terrain_samplers_[0] = height_smp_.get(); + terrain_samplers_[1] = cell_smp_.get(); + terrain_samplers_[2] = atlas_smp_.get(); + + D3D11_BUFFER_DESC cb{}; + cb.ByteWidth = 80U; + cb.Usage = D3D11_USAGE_DYNAMIC; + cb.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + cb.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; + return detail::check(device_->CreateBuffer(&cb, nullptr, terrain_cb_.put()), "CreateBuffer(terrain cb)"); + } + + /** Create a 2D (or 2D-array) texture with initial data. */ + [[nodiscard]] auto make_texture(detail::com_ptr &texture, detail::com_ptr &view, UINT width, + UINT height, UINT array, DXGI_FORMAT format, const void *data, UINT row_bytes, UINT slice_bytes) -> bool { + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1U; + desc.ArraySize = array; + desc.Format = format; + desc.SampleDesc.Count = 1U; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + + std::vector init; + if (data != nullptr) { + init.resize(array); + for (UINT i = 0; i < array; ++i) { + init[i].pSysMem = static_cast(data) + static_cast(i) * slice_bytes; + init[i].SysMemPitch = row_bytes; + init[i].SysMemSlicePitch = slice_bytes; + } + } + if (!detail::check(device_->CreateTexture2D(&desc, init.empty() ? nullptr : init.data(), texture.put()), "CreateTexture2D")) return false; + return detail::check(device_->CreateShaderResourceView(texture.get(), nullptr, view.put()), "CreateShaderResourceView"); + } + + [[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool { + if (!this->make_texture(height_tex_, height_srv_, terrain.width, terrain.height, 1U, DXGI_FORMAT_R16_UNORM, terrain.heights.data(), + terrain.width * 2U, terrain.width * 2U)) + return false; + if (!this->make_texture(cell_tex_, cell_srv_, terrain.width, terrain.height, 1U, DXGI_FORMAT_R16G16B16A16_UNORM, + terrain.cell_data.data(), terrain.width * 8U, terrain.width * 8U)) + return false; + return this->make_texture(atlas_tex_, atlas_srv_, terrain.layer_size, terrain.layer_size, terrain.layer_count, + DXGI_FORMAT_B8G8R8A8_UNORM, terrain.layers.data(), terrain.layer_size * 4U, + terrain.layer_size * terrain.layer_size * 4U); + } + + /** Upload the software scene image into `scene_tex_`. */ + [[nodiscard]] auto upload_scene(const ra3::render::image &frame) -> bool { + if (!this->make_texture(scene_tex_, scene_srv_, frame.width(), frame.height(), 1U, DXGI_FORMAT_B8G8R8A8_UNORM, nullptr, 0U, 0U)) + return false; + scene_w_ = frame.width(); + scene_h_ = frame.height(); + return this->update_texture(scene_tex_, frame); + } + + /** Refresh an existing texture's pixels. */ + [[nodiscard]] auto update_texture(const detail::com_ptr &texture, const ra3::render::image &frame) -> bool { + D3D11_BOX box{}; + box.left = 0U; + box.top = 0U; + box.front = 0U; + box.right = frame.width(); + box.bottom = frame.height(); + box.back = 1U; + context_->UpdateSubresource(texture.get(), 0U, &box, frame.data(), frame.width() * 4U, 0U); + return true; + } + + [[nodiscard]] auto upload_overlay(detail::com_ptr &texture, detail::com_ptr &view, UINT &out_w, + UINT &out_h, const ra3::render::image &source) -> bool { + if (source.empty()) { + out_w = 0U; + out_h = 0U; + return true; + } + if (!texture || source.width() != out_w || source.height() != out_h) { + if (!this->make_texture(texture, view, source.width(), source.height(), 1U, DXGI_FORMAT_B8G8R8A8_UNORM, nullptr, 0U, 0U)) + return false; + } + out_w = source.width(); + out_h = source.height(); + return this->update_texture(texture, source); + } + + template + auto write_cb(const detail::com_ptr &buffer, const Values &values) -> void { + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (SUCCEEDED(context_->Map(buffer.get(), 0U, D3D11_MAP_WRITE_DISCARD, 0U, &mapped))) { + std::memcpy(mapped.pData, std::data(values), std::size(values) * sizeof(std::remove_cvref_t)); + context_->Unmap(buffer.get(), 0U); + } + } + + auto bind_target(float r, float g, float b) -> void { + const auto [width, height] = this->window_size(); + D3D11_VIEWPORT viewport{0.0F, 0.0F, static_cast(width), static_cast(height), 0.0F, 1.0F}; + context_->OMSetRenderTargets(1U, rtv_.address(), nullptr); + context_->RSSetViewports(1U, &viewport); + const float clear[4] = {r, g, b, 1.0F}; + context_->ClearRenderTargetView(rtv_.get(), clear); + context_->IASetInputLayout(nullptr); + context_->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + context_->OMSetBlendState(blend_.get(), nullptr, 0xFFFFFFFFU); + context_->RSSetState(raster_.get()); + context_->OMSetDepthStencilState(nullptr, 0U); + } + + auto bind_scene_pipeline() -> void { + context_->VSSetShader(scene_vs_.get(), nullptr, 0U); + context_->PSSetShader(scene_ps_.get(), nullptr, 0U); + context_->PSSetSamplers(0U, 1U, scene_smp_.address()); + context_->VSSetConstantBuffers(0U, 1U, scene_cb_.address()); + } + + auto draw_overlay(const detail::com_ptr &view, UINT w, UINT h, float x, float y, int window_w, int window_h) -> void { + if (!view || w == 0U || h == 0U) return; + context_->PSSetShaderResources(0U, 1U, view.address()); + const std::array rect{x / static_cast(window_w), y / static_cast(window_h), + static_cast(w) / static_cast(window_w), + static_cast(h) / static_cast(window_h)}; + this->write_cb(scene_cb_, rect); + context_->Draw(3U, 0U); + } + + [[nodiscard]] auto present_frame() -> bool { + const UINT sync = fps_limit_ == 0 ? 1U : 0U; + const HRESULT result = swapchain_->Present(sync, 0U); + if (result == DXGI_ERROR_DEVICE_REMOVED || result == DXGI_ERROR_DEVICE_RESET) return false; + return true; + } + + SDL_Window *window_ = nullptr; + HWND hwnd_ = nullptr; + detail::com_ptr device_; + detail::com_ptr context_; + detail::com_ptr swapchain_; + detail::com_ptr rtv_; + UINT swapchain_w_ = 0U; + UINT swapchain_h_ = 0U; + + detail::com_ptr scene_vs_; + detail::com_ptr scene_ps_; + detail::com_ptr scene_smp_; + detail::com_ptr scene_cb_; + detail::com_ptr blend_; + detail::com_ptr raster_; + detail::com_ptr scene_tex_; + detail::com_ptr scene_srv_; + UINT scene_w_ = 0U; + UINT scene_h_ = 0U; + + bool terrain_ready_ = false; + detail::com_ptr terrain_vs_; + detail::com_ptr terrain_ps_; + detail::com_ptr height_smp_; + detail::com_ptr cell_smp_; + detail::com_ptr atlas_smp_; + ID3D11SamplerState *terrain_samplers_[3] = {nullptr, nullptr, nullptr}; + detail::com_ptr terrain_cb_; + detail::com_ptr height_tex_; + detail::com_ptr height_srv_; + detail::com_ptr cell_tex_; + detail::com_ptr cell_srv_; + detail::com_ptr atlas_tex_; + detail::com_ptr atlas_srv_; + detail::com_ptr label_tex_; + detail::com_ptr label_srv_; + detail::com_ptr minimap_tex_; + detail::com_ptr minimap_srv_; + UINT overlay_label_w_ = 0U; + UINT overlay_label_h_ = 0U; + UINT overlay_minimap_w_ = 0U; + UINT overlay_minimap_h_ = 0U; + }; + + /** + * Direct3D 12 backend. Same pipelines as D3D11 (HLSL compiled at runtime), + * re-expressed with explicit command lists, descriptor heaps and barriers. + */ + class d3d12_display final : public ra3::client::display { + public: + d3d12_display() = default; + d3d12_display(const d3d12_display &) = delete; + auto operator=(const d3d12_display &) -> d3d12_display & = delete; + ~d3d12_display() override { this->shutdown(); } + + [[nodiscard]] auto init(const ra3::client::display_options &options) -> bool override { + if (!SDL_Init(SDL_INIT_VIDEO)) return false; + this->fps_limit_ = options.fps_limit; + window_ = SDL_CreateWindow(options.title.c_str(), options.width, options.height, + SDL_WINDOW_RESIZABLE | (options.fullscreen ? SDL_WINDOW_FULLSCREEN : 0)); + if (window_ == nullptr) { + SDL_Quit(); + return false; + } + SDL_RaiseWindow(window_); + hwnd_ = detail::hwnd_of(window_); + if (hwnd_ == nullptr) { + this->shutdown(); + return false; + } + if (!this->create_device() || !this->create_command_objects() || !this->create_descriptor_heaps() || !this->create_swapchain() || + !this->create_backbuffers() || !this->create_scene_pipeline()) { + this->shutdown(); + return false; + } + SDL_StartTextInput(window_); + ender::log::info("ra3.dx: Direct3D 12 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 (!this->ensure_swapchain()) return false; + if (changed || !scene_tex_ || frame.width() != scene_w_ || frame.height() != scene_h_) { + if (!this->upload_texture(scene_tex_, srv_scene, frame.width(), frame.height(), 1U, DXGI_FORMAT_B8G8R8A8_UNORM, frame.data(), + frame.width() * 4U, frame.width() * 4U)) + return false; + scene_w_ = frame.width(); + scene_h_ = frame.height(); + } + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + + auto *list = this->begin_frame_commands(); + const UINT backbuffer = swapchain_->GetCurrentBackBufferIndex(); + this->begin_render_pass(list, backbuffer, 0.05F, 0.06F, 0.08F); + list->SetGraphicsRootSignature(scene_root_.get()); + list->SetPipelineState(scene_pso_.get()); + const float rect[4] = {dest.x / static_cast(window_w), dest.y / static_cast(window_h), + dest.w / static_cast(window_w), dest.h / static_cast(window_h)}; + list->SetGraphicsRoot32BitConstants(0U, 4U, rect, 0U); + list->SetGraphicsRootDescriptorTable(1U, this->srv_gpu(srv_scene)); + list->DrawInstanced(3U, 1U, 0U, 0U); + this->end_render_pass(list, backbuffer); + return this->finish_frame(); + } + + [[nodiscard]] auto poll_event(ra3::render::ui_event &out) -> bool override { return detail::poll_event(window_, out); } + + [[nodiscard]] auto window_size() const -> std::pair override { + int w = 0; + int h = 0; + SDL_GetWindowSizeInPixels(window_, &w, &h); + return {w, h}; + } + + [[nodiscard]] auto key_down(ra3::render::ui_key key) const -> bool override { return detail::key_down(key); } + + [[nodiscard]] auto supports_terrain() const -> bool override { return true; } + + [[nodiscard]] auto present_terrain(const ra3::terrain::gpu_terrain &terrain, const ra3::render::camera3d &camera, float time_s, + const ra3::client::terrain_overlay &overlay) -> bool override { + if (!terrain_ready_) { + if (!this->create_terrain_pipeline()) return false; + if (!this->create_terrain_textures(terrain)) return false; + terrain_ready_ = true; + ender::log::info(std::format("ra3.dx: Direct3D 12 terrain ready ({}x{}, {} layers)", terrain.width, terrain.height, terrain.layer_count)); + } + if (!this->ensure_swapchain()) return false; + if (overlay.label_changed && !overlay.label.empty()) { + if (!this->upload_texture(label_tex_, srv_label, overlay.label.width(), overlay.label.height(), 1U, DXGI_FORMAT_B8G8R8A8_UNORM, + overlay.label.data(), overlay.label.width() * 4U, overlay.label.width() * 4U)) + return false; + overlay_label_w_ = overlay.label.width(); + overlay_label_h_ = overlay.label.height(); + } else if (overlay.label_changed) { + overlay_label_w_ = 0U; + overlay_label_h_ = 0U; + } + if (overlay.minimap_changed && !overlay.minimap.empty()) { + if (!this->upload_texture(minimap_tex_, srv_minimap, overlay.minimap.width(), overlay.minimap.height(), 1U, + DXGI_FORMAT_B8G8R8A8_UNORM, overlay.minimap.data(), overlay.minimap.width() * 4U, + overlay.minimap.width() * 4U)) + return false; + overlay_minimap_w_ = overlay.minimap.width(); + overlay_minimap_h_ = overlay.minimap.height(); + } else if (overlay.minimap_changed) { + overlay_minimap_w_ = 0U; + overlay_minimap_h_ = 0U; + } + + const auto [window_w, window_h] = this->window_size(); + if (window_w <= 0 || window_h <= 0) return true; + const auto aspect = static_cast(window_w) / static_cast(std::max(1, window_h)); + + auto *list = this->begin_frame_commands(); + const UINT backbuffer = swapchain_->GetCurrentBackBufferIndex(); + this->begin_render_pass(list, backbuffer, 0.45F, 0.55F, 0.70F); + list->SetGraphicsRootSignature(terrain_root_.get()); + list->SetPipelineState(terrain_pso_.get()); + const auto push = detail::terrain_push(terrain, camera, time_s, aspect); + list->SetGraphicsRoot32BitConstants(0U, 20U, push.data(), 0U); + list->SetGraphicsRootDescriptorTable(1U, this->srv_gpu(srv_height)); + list->DrawInstanced(3U, 1U, 0U, 0U); + + list->SetGraphicsRootSignature(scene_root_.get()); + list->SetPipelineState(scene_pso_.get()); + this->draw_overlay(list, srv_label, overlay_label_w_, overlay_label_h_, 12.0F, 12.0F, window_w, window_h); + if (overlay_minimap_w_ > 0U) { + this->draw_overlay(list, srv_minimap, overlay_minimap_w_, overlay_minimap_h_, + static_cast(window_w) - static_cast(overlay_minimap_w_) - 12.0F, 12.0F, window_w, window_h); + } + this->end_render_pass(list, backbuffer); + return this->finish_frame(); + } + + auto shutdown() -> void override { + if (window_ != nullptr) SDL_StopTextInput(window_); + this->wait_idle(); + for (auto &backbuffer: backbuffers_) backbuffer.reset(); + scene_tex_.reset(); + height_tex_.reset(); + cell_tex_.reset(); + atlas_tex_.reset(); + label_tex_.reset(); + minimap_tex_.reset(); + scene_pso_.reset(); + scene_root_.reset(); + terrain_pso_.reset(); + terrain_root_.reset(); + for (auto &frame: frames_) { + frame.list.reset(); + frame.allocator.reset(); + frame.fence = 0; + } + upload_list_.reset(); + upload_allocator_.reset(); + if (fence_event_ != nullptr) { + CloseHandle(fence_event_); + fence_event_ = nullptr; + } + fence_.reset(); + srv_heap_.reset(); + rtv_heap_.reset(); + swapchain_.reset(); + queue_.reset(); + device_.reset(); + factory_.reset(); + if (window_ != nullptr) SDL_DestroyWindow(window_); + SDL_Quit(); + window_ = nullptr; + hwnd_ = nullptr; + scene_w_ = 0U; + scene_h_ = 0U; + overlay_label_w_ = 0U; + overlay_label_h_ = 0U; + overlay_minimap_w_ = 0U; + overlay_minimap_h_ = 0U; + swapchain_w_ = 0U; + swapchain_h_ = 0U; + fence_value_ = 0U; + frame_index_ = 0U; + terrain_ready_ = false; + } + + [[nodiscard]] auto name() const -> std::string_view override { return "d3d12"; } + + private: + static constexpr UINT frames_in_flight_ = 2U; + static constexpr UINT backbuffer_count_ = 2U; + enum srv_slot : UINT { srv_scene = 0U, srv_label = 1U, srv_minimap = 2U, srv_height = 3U, srv_cell = 4U, srv_atlas = 5U, srv_count = 6U }; + static constexpr DXGI_FORMAT rtv_format = DXGI_FORMAT_B8G8R8A8_UNORM; + + struct frame_resources { + detail::com_ptr allocator; + detail::com_ptr list; + UINT64 fence = 0U; + }; + + [[nodiscard]] auto srv_cpu(UINT index) const -> D3D12_CPU_DESCRIPTOR_HANDLE { + auto handle = srv_heap_->GetCPUDescriptorHandleForHeapStart(); + handle.ptr += static_cast(index) * srv_size_; + return handle; + } + [[nodiscard]] auto srv_gpu(UINT index) const -> D3D12_GPU_DESCRIPTOR_HANDLE { + auto handle = srv_heap_->GetGPUDescriptorHandleForHeapStart(); + handle.ptr += static_cast(index) * srv_size_; + return handle; + } + [[nodiscard]] auto rtv_cpu(UINT index) const -> D3D12_CPU_DESCRIPTOR_HANDLE { + auto handle = rtv_heap_->GetCPUDescriptorHandleForHeapStart(); + handle.ptr += static_cast(index) * rtv_size_; + return handle; + } + + [[nodiscard]] auto create_device() -> bool { + return detail::check(D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, __uuidof(ID3D12Device), + reinterpret_cast(device_.put())), + "D3D12CreateDevice"); + } + + [[nodiscard]] auto create_command_objects() -> bool { + D3D12_COMMAND_QUEUE_DESC queue_desc{}; + queue_desc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; + queue_desc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE; + if (!detail::check(device_->CreateCommandQueue(&queue_desc, __uuidof(ID3D12CommandQueue), reinterpret_cast(queue_.put())), + "CreateCommandQueue")) + return false; + for (auto &frame: frames_) { + if (!detail::check(device_->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, __uuidof(ID3D12CommandAllocator), + reinterpret_cast(frame.allocator.put())), + "CreateCommandAllocator")) + return false; + if (!detail::check(device_->CreateCommandList(0U, D3D12_COMMAND_LIST_TYPE_DIRECT, frame.allocator.get(), nullptr, + __uuidof(ID3D12GraphicsCommandList), reinterpret_cast(frame.list.put())), + "CreateCommandList")) + return false; + frame.list->Close(); + } + if (!detail::check(device_->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, __uuidof(ID3D12CommandAllocator), + reinterpret_cast(upload_allocator_.put())), + "CreateCommandAllocator(upload)")) + return false; + if (!detail::check(device_->CreateCommandList(0U, D3D12_COMMAND_LIST_TYPE_DIRECT, upload_allocator_.get(), nullptr, + __uuidof(ID3D12GraphicsCommandList), reinterpret_cast(upload_list_.put())), + "CreateCommandList(upload)")) + return false; + upload_list_->Close(); + if (!detail::check(device_->CreateFence(0U, D3D12_FENCE_FLAG_NONE, __uuidof(ID3D12Fence), reinterpret_cast(fence_.put())), + "CreateFence")) + return false; + fence_event_ = CreateEvent(nullptr, FALSE, FALSE, nullptr); + return fence_event_ != nullptr; + } + + [[nodiscard]] auto create_descriptor_heaps() -> bool { + D3D12_DESCRIPTOR_HEAP_DESC rtv_desc{}; + rtv_desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV; + rtv_desc.NumDescriptors = backbuffer_count_; + if (!detail::check(device_->CreateDescriptorHeap(&rtv_desc, __uuidof(ID3D12DescriptorHeap), + reinterpret_cast(rtv_heap_.put())), + "CreateDescriptorHeap(rtv)")) + return false; + D3D12_DESCRIPTOR_HEAP_DESC srv_desc{}; + srv_desc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; + srv_desc.NumDescriptors = srv_count; + srv_desc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; + if (!detail::check(device_->CreateDescriptorHeap(&srv_desc, __uuidof(ID3D12DescriptorHeap), + reinterpret_cast(srv_heap_.put())), + "CreateDescriptorHeap(srv)")) + return false; + rtv_size_ = device_->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV); + srv_size_ = device_->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV); + return true; + } + + [[nodiscard]] auto create_swapchain() -> bool { + if (!detail::check(CreateDXGIFactory1(__uuidof(IDXGIFactory4), reinterpret_cast(factory_.put())), "CreateDXGIFactory1")) + return false; + const auto [width, height] = this->window_size(); + DXGI_SWAP_CHAIN_DESC1 desc{}; + desc.Width = static_cast(std::max(1, width)); + desc.Height = static_cast(std::max(1, height)); + desc.Format = rtv_format; + desc.SampleDesc.Count = 1U; + desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + desc.BufferCount = backbuffer_count_; + desc.Scaling = DXGI_SCALING_STRETCH; + desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; + desc.AlphaMode = DXGI_ALPHA_MODE_UNSPECIFIED; + detail::com_ptr swapchain; + if (!detail::check(factory_->CreateSwapChainForHwnd(queue_.get(), hwnd_, &desc, nullptr, nullptr, swapchain.put()), + "CreateSwapChainForHwnd")) + return false; + if (!detail::check(swapchain->QueryInterface(__uuidof(IDXGISwapChain3), reinterpret_cast(swapchain_.put())), + "QueryInterface(IDXGISwapChain3)")) + return false; + swapchain_w_ = desc.Width; + swapchain_h_ = desc.Height; + return true; + } + + [[nodiscard]] auto create_backbuffers() -> bool { + for (UINT i = 0; i < backbuffer_count_; ++i) { + if (!detail::check(swapchain_->GetBuffer(i, __uuidof(ID3D12Resource), reinterpret_cast(backbuffers_[i].put())), + "GetBuffer")) + return false; + const D3D12_CPU_DESCRIPTOR_HANDLE handle = this->rtv_cpu(i); + device_->CreateRenderTargetView(backbuffers_[i].get(), nullptr, handle); + } + return true; + } + + [[nodiscard]] auto ensure_swapchain() -> bool { + const auto [width, height] = this->window_size(); + if (width <= 0 || height <= 0) return true; + if (static_cast(width) == swapchain_w_ && static_cast(height) == swapchain_h_) return true; + this->wait_idle(); + for (auto &backbuffer: backbuffers_) backbuffer.reset(); + if (!detail::check(swapchain_->ResizeBuffers(0U, static_cast(width), static_cast(height), DXGI_FORMAT_UNKNOWN, 0U), + "ResizeBuffers")) + return false; + swapchain_w_ = static_cast(width); + swapchain_h_ = static_cast(height); + return this->create_backbuffers(); + } + + [[nodiscard]] auto create_scene_pipeline() -> bool { + D3D12_ROOT_PARAMETER parameters[2]{}; + parameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS; + parameters[0].Constants.ShaderRegister = 0U; + parameters[0].Constants.RegisterSpace = 0U; + parameters[0].Constants.Num32BitValues = 4U; + parameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_VERTEX; + D3D12_DESCRIPTOR_RANGE range{}; + range.RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SRV; + range.NumDescriptors = 1U; + range.BaseShaderRegister = 0U; + range.RegisterSpace = 0U; + range.OffsetInDescriptorsFromTableStart = 0U; + parameters[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE; + parameters[1].DescriptorTable.NumDescriptorRanges = 1U; + parameters[1].DescriptorTable.pDescriptorRanges = ⦥ + parameters[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; + const D3D12_STATIC_SAMPLER_DESC sampler = detail::make_static_sampler(D3D12_FILTER_MIN_MAG_MIP_LINEAR, D3D12_TEXTURE_ADDRESS_MODE_CLAMP, 0U); + D3D12_ROOT_SIGNATURE_DESC root_desc{}; + root_desc.NumParameters = 2U; + root_desc.pParameters = parameters; + root_desc.NumStaticSamplers = 1U; + root_desc.pStaticSamplers = &sampler; + root_desc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT; + if (!detail::serialize_root_signature(device_.get(), root_desc, scene_root_)) return false; + + const std::string_view source{ra3_shaders::dx_scene_hlsl}; + detail::com_ptr vs_blob{detail::compile_hlsl(source, "VSMain", "vs_5_0")}; + detail::com_ptr ps_blob{detail::compile_hlsl(source, "PSMain", "ps_5_0")}; + if (!vs_blob || !ps_blob) return false; + D3D12_GRAPHICS_PIPELINE_STATE_DESC pso{}; + pso.pRootSignature = scene_root_.get(); + pso.VS = {vs_blob->GetBufferPointer(), vs_blob->GetBufferSize()}; + pso.PS = {ps_blob->GetBufferPointer(), ps_blob->GetBufferSize()}; + pso.RasterizerState.FillMode = D3D12_FILL_MODE_SOLID; + pso.RasterizerState.CullMode = D3D12_CULL_MODE_NONE; + pso.RasterizerState.DepthClipEnable = TRUE; + this->fill_blend_state(pso.BlendState); + pso.DepthStencilState.DepthEnable = FALSE; + pso.DepthStencilState.StencilEnable = FALSE; + pso.SampleMask = UINT_MAX; + pso.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE; + pso.NumRenderTargets = 1U; + pso.RTVFormats[0] = rtv_format; + pso.SampleDesc.Count = 1U; + return detail::check(device_->CreateGraphicsPipelineState(&pso, __uuidof(ID3D12PipelineState), + reinterpret_cast(scene_pso_.put())), + "CreateGraphicsPipelineState(scene)"); + } + + [[nodiscard]] auto create_terrain_pipeline() -> bool { + D3D12_ROOT_PARAMETER parameters[2]{}; + parameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS; + parameters[0].Constants.ShaderRegister = 0U; + parameters[0].Constants.RegisterSpace = 0U; + parameters[0].Constants.Num32BitValues = 20U; + parameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; + D3D12_DESCRIPTOR_RANGE range{}; + range.RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SRV; + range.NumDescriptors = 3U; + range.BaseShaderRegister = 0U; + range.RegisterSpace = 0U; + range.OffsetInDescriptorsFromTableStart = 0U; + parameters[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE; + parameters[1].DescriptorTable.NumDescriptorRanges = 1U; + parameters[1].DescriptorTable.pDescriptorRanges = ⦥ + parameters[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; + const D3D12_STATIC_SAMPLER_DESC samplers[3] = { + detail::make_static_sampler(D3D12_FILTER_MIN_MAG_MIP_POINT, D3D12_TEXTURE_ADDRESS_MODE_CLAMP, 0U), + detail::make_static_sampler(D3D12_FILTER_MIN_MAG_MIP_POINT, D3D12_TEXTURE_ADDRESS_MODE_CLAMP, 1U), + detail::make_static_sampler(D3D12_FILTER_MIN_MAG_MIP_LINEAR, D3D12_TEXTURE_ADDRESS_MODE_WRAP, 2U), + }; + D3D12_ROOT_SIGNATURE_DESC root_desc{}; + root_desc.NumParameters = 2U; + root_desc.pParameters = parameters; + root_desc.NumStaticSamplers = 3U; + root_desc.pStaticSamplers = samplers; + root_desc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT; + if (!detail::serialize_root_signature(device_.get(), root_desc, terrain_root_)) return false; + + const std::string_view source{ra3_shaders::dx_terrain_hlsl}; + detail::com_ptr vs_blob{detail::compile_hlsl(source, "VSMain", "vs_5_0")}; + detail::com_ptr ps_blob{detail::compile_hlsl(source, "PSMain", "ps_5_0")}; + if (!vs_blob || !ps_blob) return false; + D3D12_GRAPHICS_PIPELINE_STATE_DESC pso{}; + pso.pRootSignature = terrain_root_.get(); + pso.VS = {vs_blob->GetBufferPointer(), vs_blob->GetBufferSize()}; + pso.PS = {ps_blob->GetBufferPointer(), ps_blob->GetBufferSize()}; + pso.RasterizerState.FillMode = D3D12_FILL_MODE_SOLID; + pso.RasterizerState.CullMode = D3D12_CULL_MODE_NONE; + pso.RasterizerState.DepthClipEnable = TRUE; + this->fill_blend_state(pso.BlendState); + pso.DepthStencilState.DepthEnable = FALSE; + pso.DepthStencilState.StencilEnable = FALSE; + pso.SampleMask = UINT_MAX; + pso.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE; + pso.NumRenderTargets = 1U; + pso.RTVFormats[0] = rtv_format; + pso.SampleDesc.Count = 1U; + return detail::check(device_->CreateGraphicsPipelineState(&pso, __uuidof(ID3D12PipelineState), + reinterpret_cast(terrain_pso_.put())), + "CreateGraphicsPipelineState(terrain)"); + } + + static auto fill_blend_state(D3D12_BLEND_DESC &blend) -> void { + blend.RenderTarget[0].BlendEnable = TRUE; + blend.RenderTarget[0].SrcBlend = D3D12_BLEND_SRC_ALPHA; + blend.RenderTarget[0].DestBlend = D3D12_BLEND_INV_SRC_ALPHA; + blend.RenderTarget[0].BlendOp = D3D12_BLEND_OP_ADD; + blend.RenderTarget[0].SrcBlendAlpha = D3D12_BLEND_ONE; + blend.RenderTarget[0].DestBlendAlpha = D3D12_BLEND_INV_SRC_ALPHA; + blend.RenderTarget[0].BlendOpAlpha = D3D12_BLEND_OP_ADD; + blend.RenderTarget[0].RenderTargetWriteMask = D3D12_COLOR_WRITE_ENABLE_ALL; + } + + [[nodiscard]] auto create_terrain_textures(const ra3::terrain::gpu_terrain &terrain) -> bool { + if (!this->upload_texture(height_tex_, srv_height, terrain.width, terrain.height, 1U, DXGI_FORMAT_R16_UNORM, terrain.heights.data(), + terrain.width * 2U, terrain.width * 2U)) + return false; + if (!this->upload_texture(cell_tex_, srv_cell, terrain.width, terrain.height, 1U, DXGI_FORMAT_R16G16B16A16_UNORM, + terrain.cell_data.data(), terrain.width * 8U, terrain.width * 8U)) + return false; + return this->upload_texture(atlas_tex_, srv_atlas, terrain.layer_size, terrain.layer_size, terrain.layer_count, + DXGI_FORMAT_B8G8R8A8_UNORM, terrain.layers.data(), terrain.layer_size * 4U, + terrain.layer_size * terrain.layer_size * 4U); + } + + /** Create a committed texture, upload it, and install its SRV in `slot`. */ + [[nodiscard]] auto upload_texture(detail::com_ptr &resource, UINT slot, UINT width, UINT height, UINT array, + DXGI_FORMAT format, const void *data, UINT row_bytes, UINT slice_bytes) -> bool { + this->wait_idle(); + resource.reset(); + D3D12_RESOURCE_DESC desc{}; + desc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D; + desc.Width = width; + desc.Height = height; + desc.DepthOrArraySize = static_cast(array); + desc.MipLevels = 1U; + desc.Format = format; + desc.SampleDesc.Count = 1U; + desc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN; + desc.Flags = D3D12_RESOURCE_FLAG_NONE; + D3D12_HEAP_PROPERTIES heap{}; + heap.Type = D3D12_HEAP_TYPE_DEFAULT; + if (!detail::check(device_->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &desc, D3D12_RESOURCE_STATE_COPY_DEST, nullptr, + __uuidof(ID3D12Resource), reinterpret_cast(resource.put())), + "CreateCommittedResource(texture)")) + return false; + + std::vector layouts(array); + std::vector rows(array); + std::vector row_sizes(array); + UINT64 total = 0U; + device_->GetCopyableFootprints(&desc, 0U, array, 0U, layouts.data(), rows.data(), row_sizes.data(), &total); + + D3D12_RESOURCE_DESC upload_desc{}; + upload_desc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; + upload_desc.Width = total; + upload_desc.Height = 1U; + upload_desc.DepthOrArraySize = 1U; + upload_desc.MipLevels = 1U; + upload_desc.Format = DXGI_FORMAT_UNKNOWN; + upload_desc.SampleDesc.Count = 1U; + upload_desc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; + D3D12_HEAP_PROPERTIES upload_heap{}; + upload_heap.Type = D3D12_HEAP_TYPE_UPLOAD; + detail::com_ptr upload; + if (!detail::check(device_->CreateCommittedResource(&upload_heap, D3D12_HEAP_FLAG_NONE, &upload_desc, + D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, __uuidof(ID3D12Resource), + reinterpret_cast(upload.put())), + "CreateCommittedResource(upload)")) + return false; + void *mapped = nullptr; + const D3D12_RANGE read_range{0U, 0U}; + if (!detail::check(upload->Map(0U, &read_range, &mapped), "Map(upload)")) return false; + for (UINT slice = 0; slice < array; ++slice) { + const auto *source = static_cast(data) + static_cast(slice) * slice_bytes; + auto *dest = static_cast(mapped) + layouts[slice].Offset; + for (UINT row = 0; row < rows[slice]; ++row) { + std::memcpy(dest + static_cast(row) * layouts[slice].Footprint.RowPitch, + source + static_cast(row) * row_bytes, row_bytes); + } + } + upload->Unmap(0U, nullptr); + + upload_allocator_->Reset(); + upload_list_->Reset(upload_allocator_.get(), nullptr); + for (UINT slice = 0; slice < array; ++slice) { + D3D12_TEXTURE_COPY_LOCATION destination{}; + destination.pResource = resource.get(); + destination.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; + destination.SubresourceIndex = slice; + D3D12_TEXTURE_COPY_LOCATION source{}; + source.pResource = upload.get(); + source.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT; + source.PlacedFootprint = layouts[slice]; + upload_list_->CopyTextureRegion(&destination, 0U, 0U, 0U, &source, nullptr); + } + detail::barrier(upload_list_.get(), resource.get(), D3D12_RESOURCE_STATE_COPY_DEST, D3D12_RESOURCE_STATE_PIXEL_SHADER_RESOURCE); + upload_list_->Close(); + ID3D12CommandList *lists[] = {upload_list_.get()}; + queue_->ExecuteCommandLists(1U, lists); + (void) this->signal(); + this->wait_for(fence_value_); + + D3D12_SHADER_RESOURCE_VIEW_DESC view{}; + view.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING; + view.Format = format; + if (array > 1U) { + view.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2DARRAY; + view.Texture2DArray.MostDetailedMip = 0U; + view.Texture2DArray.MipLevels = 1U; + view.Texture2DArray.FirstArraySlice = 0U; + view.Texture2DArray.ArraySize = array; + } else { + view.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D; + view.Texture2D.MostDetailedMip = 0U; + view.Texture2D.MipLevels = 1U; + } + device_->CreateShaderResourceView(resource.get(), &view, this->srv_cpu(slot)); + return true; + } + + [[nodiscard]] auto begin_frame_commands() -> ID3D12GraphicsCommandList * { + auto &frame = frames_[frame_index_]; + this->wait_for(frame.fence); + (void) detail::check(frame.allocator->Reset(), "CommandAllocator::Reset"); + (void) detail::check(frame.list->Reset(frame.allocator.get(), nullptr), "CommandList::Reset"); + ID3D12DescriptorHeap *heaps[] = {srv_heap_.get()}; + frame.list->SetDescriptorHeaps(1U, heaps); + return frame.list.get(); + } + + auto begin_render_pass(ID3D12GraphicsCommandList *list, UINT backbuffer, float r, float g, float b) -> void { + detail::barrier(list, backbuffers_[backbuffer].get(), D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET); + const D3D12_CPU_DESCRIPTOR_HANDLE handle = this->rtv_cpu(backbuffer); + list->OMSetRenderTargets(1U, &handle, FALSE, nullptr); + const float clear[4] = {r, g, b, 1.0F}; + list->ClearRenderTargetView(handle, clear, 0U, nullptr); + const auto [width, height] = this->window_size(); + const D3D12_VIEWPORT viewport{0.0F, 0.0F, static_cast(width), static_cast(height), 0.0F, 1.0F}; + const D3D12_RECT scissor{0, 0, width, height}; + list->RSSetViewports(1U, &viewport); + list->RSSetScissorRects(1U, &scissor); + list->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + } + + auto end_render_pass(ID3D12GraphicsCommandList *list, UINT backbuffer) -> void { + detail::barrier(list, backbuffers_[backbuffer].get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT); + } + + auto draw_overlay(ID3D12GraphicsCommandList *list, UINT slot, UINT w, UINT h, float x, float y, int window_w, int window_h) -> void { + if (w == 0U || h == 0U) return; + const float rect[4] = {x / static_cast(window_w), y / static_cast(window_h), + static_cast(w) / static_cast(window_w), + static_cast(h) / static_cast(window_h)}; + list->SetGraphicsRoot32BitConstants(0U, 4U, rect, 0U); + list->SetGraphicsRootDescriptorTable(1U, this->srv_gpu(slot)); + list->DrawInstanced(3U, 1U, 0U, 0U); + } + + [[nodiscard]] auto finish_frame() -> bool { + auto &frame = frames_[frame_index_]; + (void) detail::check(frame.list->Close(), "CommandList::Close"); + ID3D12CommandList *lists[] = {frame.list.get()}; + queue_->ExecuteCommandLists(1U, lists); + frame.fence = this->signal(); + frame_index_ = (frame_index_ + 1U) % frames_in_flight_; + const UINT sync = fps_limit_ == 0 ? 1U : 0U; + const HRESULT result = swapchain_->Present(sync, 0U); + return result != DXGI_ERROR_DEVICE_REMOVED && result != DXGI_ERROR_DEVICE_RESET; + } + + [[nodiscard]] auto signal() -> UINT64 { + fence_value_ += 1U; + queue_->Signal(fence_.get(), fence_value_); + return fence_value_; + } + + auto wait_for(UINT64 value) -> void { + if (value == 0U || !fence_) return; + if (fence_->GetCompletedValue() < value) { + fence_->SetEventOnCompletion(value, fence_event_); + WaitForSingleObject(fence_event_, INFINITE); + } + } + + auto wait_idle() -> void { this->wait_for(fence_value_); } + + SDL_Window *window_ = nullptr; + HWND hwnd_ = nullptr; + detail::com_ptr factory_; + detail::com_ptr device_; + detail::com_ptr queue_; + detail::com_ptr swapchain_; + detail::com_ptr rtv_heap_; + detail::com_ptr srv_heap_; + detail::com_ptr backbuffers_[backbuffer_count_]; + UINT rtv_size_ = 0U; + UINT srv_size_ = 0U; + UINT swapchain_w_ = 0U; + UINT swapchain_h_ = 0U; + + frame_resources frames_[frames_in_flight_]; + UINT frame_index_ = 0U; + detail::com_ptr upload_allocator_; + detail::com_ptr upload_list_; + detail::com_ptr fence_; + UINT64 fence_value_ = 0U; + HANDLE fence_event_ = nullptr; + + detail::com_ptr scene_root_; + detail::com_ptr scene_pso_; + detail::com_ptr scene_tex_; + UINT scene_w_ = 0U; + UINT scene_h_ = 0U; + + bool terrain_ready_ = false; + detail::com_ptr terrain_root_; + detail::com_ptr terrain_pso_; + detail::com_ptr height_tex_; + detail::com_ptr cell_tex_; + detail::com_ptr atlas_tex_; + detail::com_ptr label_tex_; + detail::com_ptr minimap_tex_; + UINT overlay_label_w_ = 0U; + UINT overlay_label_h_ = 0U; + UINT overlay_minimap_w_ = 0U; + UINT overlay_minimap_h_ = 0U; + }; +} // namespace ra3::dx diff --git a/src/dx/ra3.dx.null.cppm b/src/dx/ra3.dx.null.cppm new file mode 100644 index 0000000..5bc78ee --- /dev/null +++ b/src/dx/ra3.dx.null.cppm @@ -0,0 +1,37 @@ +export module ra3.dx; + +import std; + +export import ra3.core; +import ra3.render; +import ra3.client; + +/** + * Fallback Direct3D backends used when the build has no Windows/D3D (for + * example the Linux development build). `init` fails so the caller can select + * another display; the class names match the real `ra3.dx` module so the + * backend factory in `ra3.display` compiles unchanged. + */ +export namespace ra3::dx { + class d3d11_display final : public ra3::client::display { + public: + [[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; } + [[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; } + [[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; } + [[nodiscard]] auto window_size() const -> std::pair override { return {0, 0}; } + [[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; } + auto shutdown() -> void override {} + [[nodiscard]] auto name() const -> std::string_view override { return "d3d11(null)"; } + }; + + class d3d12_display final : public ra3::client::display { + public: + [[nodiscard]] auto init(const ra3::client::display_options &) -> bool override { return false; } + [[nodiscard]] auto present(const ra3::render::image &, const ra3::render::view_rect &, bool) -> bool override { return false; } + [[nodiscard]] auto poll_event(ra3::render::ui_event &) -> bool override { return false; } + [[nodiscard]] auto window_size() const -> std::pair override { return {0, 0}; } + [[nodiscard]] auto key_down(ra3::render::ui_key) const -> bool override { return false; } + auto shutdown() -> void override {} + [[nodiscard]] auto name() const -> std::string_view override { return "d3d12(null)"; } + }; +} diff --git a/src/ra3.cppm b/src/ra3.cppm index 8ba391e..5df83f5 100644 --- a/src/ra3.cppm +++ b/src/ra3.cppm @@ -18,4 +18,5 @@ export import ra3.render; export import ra3.terrain; export import ra3.ui; export import ra3.vulkan; +export import ra3.dx; export import ra3.display; diff --git a/third_party/libenderlog/LICENSE b/third_party/libenderlog/LICENSE new file mode 100644 index 0000000..59db8f9 --- /dev/null +++ b/third_party/libenderlog/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 EnderTheCoder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/libenderlog/README.md b/third_party/libenderlog/README.md new file mode 100644 index 0000000..8d7f57f --- /dev/null +++ b/third_party/libenderlog/README.md @@ -0,0 +1,170 @@ +# libenderlog + +A standalone C++26 logging library, built as a C++20/26 **module** (`import ender.log;`) +with **`std::stacktrace`** call-stack capture on severe records. + +It was extracted from the `ender-physics` engine, where it started life as +`ender.log`, and turned into a library that has no other dependency — not on the +engine, not on a logging framework. + +## Features + +- **Leveled records.** `trace`, `debug`, `info`, `warn`, `error`, `critical`, + filtered by an atomic `minimum` check that is cheap enough to guard expensive + message construction: `if (log::enabled(log::level::debug)) { ... }`. +- **Source location.** Every record carries the file, line and function of the + caller, taken from `std::source_location` at the call site — exact even in a + stripped release binary, because it is a compile-time constant. +- **Call stacks.** Records at or above `options::stacktrace_from` carry a + formatted `std::stacktrace`. The frames belonging to the library itself are + stripped by symbol, so the first reported frame is the caller regardless of + the optimisation level (the level wrappers get inlined away under `-O`). +- **Pluggable sinks.** A `console_sink` (stderr by default) and a `memory_sink` + (for tests and in-game consoles) ship; `sink` is a small interface. +- **File output with archiving.** `file_sink` writes to a file and, on open, + moves an existing log aside to a timestamped archive, so a run never appends + onto a previous run's log. It can also rotate by size and bound how many + archives are kept. +- **No stacktrace? No problem.** Where `` is missing (libc++, and + therefore every cross target), the module still compiles and records still + carry their call site — they simply have no stack. + +## Requirements + +C++26 modules and `import std;` need a recent toolchain: + +| Requirement | Version | +|---|---| +| Compiler | **GCC 15+** (or Clang with a standard library that provides the `std` module) | +| CMake | **3.30+** (for `CMAKE_EXPERIMENTAL_CXX_IMPORT_STD`) | +| Standard library | libstdc++ for `std::stacktrace` | + +Ubuntu 26.04's default `g++` (GCC 15) and CMake 4 satisfy this, and that is the +release the CI targets and the `.deb` is built for. Ubuntu 24.04 ships GCC 13 and +CMake 3.28 and cannot build `import std;` without extra toolchains, so it is not +supported. + +## Building + +```sh +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +ctest --test-dir build --output-on-failure +``` + +Options: + +| Option | Default | Description | +|---|---|---| +| `ENDERLOG_WERROR` | `OFF` | Treat warnings as errors | +| `ENDERLOG_BACKTRACE_SYMBOLS` | `ON` | Link executables with `-rdynamic` so traces can name frames | +| `ENDERLOG_BUILD_TESTS` | `ON` | Build the test suite | +| `ENDERLOG_BUILD_EXAMPLES` | `ON` | Build the example program | + +Build Debug or RelWithDebInfo when you need to read a trace: frame names come +from debug information, and a release build often reports application frames as +``. + +## Using the library + +### `add_subdirectory` + +```cmake +add_subdirectory(libenderlog) +target_link_libraries(my_app PRIVATE enderlog::enderlog) +``` + +### Installed package + +```cmake +find_package(enderlog REQUIRED) +target_link_libraries(my_app PRIVATE enderlog::enderlog) +``` + +The static archive is installed together with the module interface source +(`ender.log.cppm`) because a module's BMI is compiler-version-specific — the +consumer rebuilds it from the source. + +### In code + +```cpp +import std; +import ender.log; + +namespace log = ender::log; + +auto main() -> int { + log::configure({.minimum = log::level::debug, .stacktrace_from = log::level::warn}); + + log::info(std::format("body {} moved to {:.2f}", 7, 12.35)); // formatted by the caller + log::error("a body left the world"); // carries a stack trace +} +``` + +Example output: + +``` +[11:32:18] ERROR example: a body left the world (examples/main.cpp:8) + #0 simulate_one_step (examples/main.cpp:8) + #1 main (examples/main.cpp:20) + #2 + #3 __libc_start_main + #4 _start +``` + +### Configuration + +```cpp +log::configure({ + .minimum = log::level::debug, // drop everything below this + .stacktrace_from = log::level::error, // capture a stack at/above this + .stacktrace_depth = 16, // max frames kept + .stacktrace_skip = 2, // frames dropped before the caller is found +}); +``` + +`log::current_options()` reads it back, `log::add_sink(...)` adds a destination, +and `log::set_sinks({...})` replaces them. + +### Writing to a file + +```cpp +namespace log = ender::log; + +// Archive any existing enderlog.log to enderlog.log., then start a +// fresh file for this run. Rotate at 64 KiB and keep the last 5 archives. +auto sink = log::add_file_sink("enderlog.log", {.max_file_size = 64 * 1024, .max_archives = 5}); +``` + +- **No appending onto a previous run.** On open, an existing non-empty + `enderlog.log` is renamed to `enderlog.log.` before the new + file is created, so every run gets its own file and the previous run's log is + preserved. A leftover empty file is simply replaced. +- `file_options::max_file_size` (0 disables) rotates the active file mid-run the + same way, and never archives an empty file. `file_options::max_archives` + (0 keeps all) deletes the oldest archives beyond the limit. +- `file_options::flush_each_record` (on by default) flushes after every record so + a crash keeps the tail. +- `add_file_sink` adds the sink to the global logger and returns it; `path()` and + `archives()` expose what it wrote. The `file_sink` class can also be used + directly and installed with `set_sinks`. + +## Packaging + +`cpack` produces a Debian package: + +```sh +cmake -S . -B build -DENDERLOG_DISTRO=ubuntu26.04 +cmake --build build -j +cd build +cpack +# -> libenderlog-dev_0.0.1_amd64_ubuntu26.04.deb +``` + +The package installs the static archive, the module interface source and the +CMake package config. CI builds it for Ubuntu 26.04 and publishes it as a job +artifact. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/third_party/libenderlog/src/ender.log.cppm b/third_party/libenderlog/src/ender.log.cppm new file mode 100644 index 0000000..85000e5 --- /dev/null +++ b/third_party/libenderlog/src/ender.log.cppm @@ -0,0 +1,542 @@ +/** + * Logging with call-stack capture. + * + * Records carry a level, message, source location and — for severe enough + * levels — a formatted `std::stacktrace`. Capturing a trace walks the stack and + * reads debug information, so it is only done when the record's level is at or + * above `options::stacktrace_from`, and the whole call is skipped when the + * level is disabled. + * + * `std::stacktrace` is implemented by libstdc++ only. With GCC the module has + * to link `stdc++exp` (the static library that implements it); the CMake target + * takes care of that. File and line numbers in the trace come from debug + * information, so build with `-g` (Debug or RelWithDebInfo) to see them; symbol + * names work in any build. + */ +module; + +// libc++ (the OpenRA3 toolchain) has no , so this vendored copy adds +// a native fallback for the frames: Windows CaptureStackBackTrace and POSIX +// execinfo. These live in the global module fragment because they are C headers. +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#elif defined(__unix__) || defined(__APPLE__) +#include +#endif + +export module ender.log; + +import std; + +export namespace ender::log { + /** Severity of a record, ordered from most to least verbose. */ + enum class level: std::uint8_t { + trace = 0, + debug, + info, + warn, + error, + critical, + }; + + /** Short upper-case name of a level, for output. */ + [[nodiscard]] inline auto to_string(const level severity) -> std::string_view { + switch (severity) { + case level::trace: return "TRACE"; + case level::debug: return "DEBUG"; + case level::info: return "INFO"; + case level::warn: return "WARN"; + case level::error: return "ERROR"; + case level::critical: return "CRITICAL"; + } + return "?"; + } + + /** Logger configuration. */ + struct options { + /** Records below this level are dropped before anything is built. */ + level minimum{level::info}; + /** Capture a stack trace for records at this level and above. */ + level stacktrace_from{level::error}; + /** Maximum number of frames kept in a captured trace. */ + std::size_t stacktrace_depth{16}; + /** + * Frames to drop from the top of a captured trace. + * + * The default drops `capture_stacktrace` and `emit`, which always exist + * as frames. The level wrappers are inlined away in optimised builds, so + * a fixed count cannot cover them; any leading frame that belongs to + * this module is therefore stripped by name instead, which keeps the + * caller visible whether or not the wrappers were inlined. + */ + std::size_t stacktrace_skip{2}; + }; + + /** One log record. */ + struct record { + level severity{level::info}; + std::string message{}; + std::string file{}; + std::uint32_t line{0}; + std::string function{}; + /** Formatted call stack; empty when it was not captured. */ + std::string stacktrace{}; + std::chrono::system_clock::time_point time{}; + std::thread::id thread{}; + + [[nodiscard]] auto has_stacktrace() const -> bool { return !stacktrace.empty(); } + }; + + namespace detail { + /** + * Render one record as a human-readable block: a header line and, when + * present, the indented stack frames. Shared by the stream sinks. + */ + [[nodiscard]] inline auto format_record(const record &entry) -> std::string { + auto text = std::format("[{:%H:%M:%S}] {:<8} {}", + std::chrono::floor(entry.time), + to_string(entry.severity), + entry.message); + if (!entry.file.empty()) { + text += std::format(" ({}:{})", entry.file, entry.line); + } + text += '\n'; + if (entry.has_stacktrace()) { + text += entry.stacktrace; + } + return text; + } + } + + /** Where records go. */ + class sink { + public: + virtual ~sink() = default; + + /** Receive one record; called with the logger's mutex held. */ + virtual auto write(const record &entry) -> void = 0; + + /** Flush any buffering. */ + virtual auto flush() -> void {} + }; + + /** Writes a human-readable line per record to a stream (stderr by default). */ + class console_sink final: public sink { + public: + explicit console_sink(std::ostream &stream = std::cerr): stream_(&stream) {} + + auto write(const record &entry) -> void override { + *stream_ << detail::format_record(entry); + stream_->flush(); + } + + private: + std::ostream *stream_; + }; + + /** Keeps every record in memory; useful for tests and in-game consoles. */ + class memory_sink final: public sink { + public: + auto write(const record &entry) -> void override { + const auto lock = std::scoped_lock{mutex_}; + records_.push_back(entry); + } + + [[nodiscard]] auto records() const -> std::vector { + const auto lock = std::scoped_lock{mutex_}; + return records_; + } + + [[nodiscard]] auto size() const -> std::size_t { + const auto lock = std::scoped_lock{mutex_}; + return records_.size(); + } + + auto clear() -> void { + const auto lock = std::scoped_lock{mutex_}; + records_.clear(); + } + + private: + mutable std::mutex mutex_; + std::vector records_; + }; + + /** Configuration for `file_sink`. */ + struct file_options { + /** Move an existing log file aside to an archive when the sink opens it. */ + bool archive_on_open{true}; + /** Flush after every record, so the tail survives a crash. */ + bool flush_each_record{true}; + /** Rotate once the active file would grow past this many bytes; 0 disables. */ + std::size_t max_file_size{0}; + /** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */ + std::size_t max_archives{0}; + }; + + /** + * Writes records to a file, archiving the previous one on open. + * + * `path` is the active file. When the sink opens it and the file already + * holds data, that file is renamed to a timestamped archive first, so a run + * never appends onto a previous run's log: every start begins a fresh file + * and the old one is preserved as `.`. The same + * happens mid-run once the active file passes `file_options::max_file_size`. + * `file_options::max_archives` bounds how many archives are kept. + * + * As with every sink, `write` is called with the logger's mutex held, so one + * sink is safe to share; it is not safe for two processes to point at the + * same file. + */ + class file_sink final: public sink { + public: + explicit file_sink(std::filesystem::path path, const file_options options = {}) + : path_(std::move(path)), options_(options) { + if (options_.archive_on_open && std::filesystem::exists(path_)) { + if (std::filesystem::file_size(path_) > 0) { + archive_current(); + } else { + std::filesystem::remove(path_); + } + } + open(); + } + + auto write(const record &entry) -> void override { + const auto block = detail::format_record(entry); + // Rotate before writing, but never rotate an empty file: that would + // archive nothing and lose the record that is about to be written. + if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) { + archive_current(); + open(); + } + stream_ << block; + size_ += block.size(); + if (options_.flush_each_record) stream_.flush(); + } + + auto flush() -> void override { + if (stream_.is_open()) stream_.flush(); + } + + /** The active log file. */ + [[nodiscard]] auto path() const -> const std::filesystem::path & { return path_; } + + /** Archives this sink created, oldest first. */ + [[nodiscard]] auto archives() const -> const std::vector & { return archives_; } + + private: + auto open() -> void { + stream_.clear(); + stream_.open(path_, std::ios::out | std::ios::trunc | std::ios::binary); + size_ = 0; + } + + auto archive_current() -> void { + if (stream_.is_open()) stream_.close(); + const auto stamp = std::format("{:%Y%m%d-%H%M%S}", + std::chrono::floor(std::chrono::system_clock::now())); + auto archive = path_; + archive += "." + stamp; + // Two rotations can land in the same second; disambiguate with a + // counter rather than overwrite the earlier archive. + for (auto counter = 1; std::filesystem::exists(archive); ++counter) { + archive = path_; + archive += std::format(".{}.{}", stamp, counter); + } + std::filesystem::rename(path_, archive); + archives_.push_back(archive); + prune_archives(); + } + + auto prune_archives() -> void { + if (options_.max_archives == 0) return; + while (archives_.size() > options_.max_archives) { + auto ignored = std::error_code{}; + std::filesystem::remove(archives_.front(), ignored); + archives_.erase(archives_.begin()); + } + } + + std::filesystem::path path_; + file_options options_; + std::ofstream stream_; + std::size_t size_{0}; + std::vector archives_; + }; + + /* + * is not portable: libc++ has never implemented it, and only + * libstdc++ provides it here. Where it is missing, records still carry their + * call site through std::source_location - they simply carry no stack, and + * everything below degrades to an empty string rather than the module + * refusing to compile. + * + * CMake decides this and passes it in, rather than the module testing + * `__cpp_lib_stacktrace` itself: feature-test macros come from the standard + * library's headers, and `import std;` does not export them, so probing for + * one here silently reports "absent" even on libstdc++, which has it. + */ +#ifndef ENDERLOG_HAS_STACKTRACE +#define ENDERLOG_HAS_STACKTRACE 0 +#endif + +#if ENDERLOG_HAS_STACKTRACE + /** True when a frame belongs to the logging module itself. */ + [[nodiscard]] inline auto is_logger_frame(const std::stacktrace_entry &entry) -> bool { + if (entry.description().find("ender::log") != std::string::npos) return true; + return entry.source_file().find("ender.log.cppm") != std::string::npos; + } + + /** + * Render a trace as one indented line per frame. + * + * @param skip_logger_frames Drop leading frames belonging to this module, so + * the first reported frame is the caller. This is what makes the + * output stable across optimisation levels: in a release build the + * level wrappers are inlined into the caller, so counting frames + * alone would either over- or under-skip. + */ + [[nodiscard]] inline auto format_stacktrace(const std::stacktrace &trace, + const bool skip_logger_frames = true) -> std::string { + if (trace.empty()) return " \n"; + + auto first = std::size_t{0}; + if (skip_logger_frames) { + while (first < trace.size() && is_logger_frame(trace.at(first))) ++first; + if (first >= trace.size()) first = 0; // never hide the whole trace + } + + auto text = std::string{}; + for (auto index = first; index < trace.size(); ++index) { + const auto &entry = trace.at(index); + auto description = entry.description(); + if (description.empty()) description = ""; + + auto location = std::string{}; + if (!entry.source_file().empty()) { + location = std::format(" ({}:{})", entry.source_file(), entry.source_line()); + } + text += std::format(" #{:<3}{}{}\n", index - first, description, location); + } + return text; + } + + /** Capture and render the current call stack, innermost frame first. */ + [[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16) + -> std::string { + return format_stacktrace(std::stacktrace::current(skip, depth)); + } +#else + /** + * libc++ fallback: capture the current call stack with the platform's own + * backtrace API and render one indented line per frame. + * + * On Windows a frame is reported as `module.dll+0xRVA` (a MinGW release build + * has DWARF, not the PDB symbols dbghelp resolves, so a module+offset is the + * practical answer). On POSIX `backtrace_symbols` is used, which names a + * frame when the executable was linked with `-rdynamic`. + */ +#if defined(_WIN32) + [[nodiscard]] inline auto symbolicate_frame(void *address) -> std::string { + const auto value = reinterpret_cast(address); + HMODULE module = nullptr; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(address), &module)) { + wchar_t wide[260] = L"?"; + GetModuleFileNameW(module, wide, 260U); + char narrow[260] = "?"; + WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr); + const char *base = std::strrchr(narrow, '\\'); + return std::format("{}+0x{:X}", base != nullptr ? base + 1 : narrow, value - reinterpret_cast(module)); + } + return std::format("0x{:X}", value); + } +#endif + + [[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16) -> std::string { + constexpr std::size_t max_frames = 64; +#if defined(_WIN32) + void *frames[max_frames] = {}; + const auto want = static_cast(std::min(max_frames, skip + std::max(depth, 1U))); + const USHORT count = CaptureStackBackTrace(static_cast(skip), want, frames, nullptr); + auto text = std::string{}; + for (USHORT index = 0; index < count; ++index) { + text += std::format(" #{:<3}{}\n", index, symbolicate_frame(frames[index])); + } + return text; +#elif defined(__unix__) || defined(__APPLE__) + void *frames[max_frames] = {}; + const int count = ::backtrace(frames, static_cast(std::min(max_frames, skip + std::max(depth, 1U)))); + char **symbols = ::backtrace_symbols(frames, count); + auto text = std::string{}; + for (int index = static_cast(std::min(skip, static_cast(count))); index < count; ++index) { + text += std::format(" #{:<3}{}\n", index - static_cast(skip), + symbols != nullptr ? symbols[index] : std::format("0x{:X}", reinterpret_cast(frames[index]))); + } + if (symbols != nullptr) std::free(symbols); + return text; +#else + (void) skip; + (void) depth; + return {}; +#endif + } +#endif + + /** + * The process-wide logger. + * + * `enabled` is an atomic read so hot paths can guard expensive message + * construction; everything else takes the mutex. + */ + class logger { + public: + [[nodiscard]] static auto instance() -> logger & { + static logger shared; + return shared; + } + + auto configure(const options &config) -> void { + const auto lock = std::scoped_lock{mutex_}; + options_ = config; + minimum_.store(static_cast(config.minimum), std::memory_order_relaxed); + } + + [[nodiscard]] auto configuration() const -> options { + const auto lock = std::scoped_lock{mutex_}; + return options_; + } + + [[nodiscard]] auto enabled(const level severity) const -> bool { + return static_cast(severity) >= minimum_.load(std::memory_order_relaxed); + } + + auto add_sink(std::shared_ptr destination) -> void { + const auto lock = std::scoped_lock{mutex_}; + sinks_.push_back(std::move(destination)); + } + + auto set_sinks(std::vector> destinations) -> void { + const auto lock = std::scoped_lock{mutex_}; + sinks_ = std::move(destinations); + } + + auto dispatch(const record &entry) -> void { + const auto lock = std::scoped_lock{mutex_}; + for (const auto &destination: sinks_) { + destination->write(entry); + } + } + + private: + logger() { sinks_.push_back(std::make_shared()); } + + mutable std::mutex mutex_; + options options_{}; + std::atomic minimum_{static_cast(options{}.minimum)}; + std::vector> sinks_; + }; + + /** Apply a configuration to the process-wide logger. */ + inline auto configure(const options &config) -> void { logger::instance().configure(config); } + + /** Current configuration of the process-wide logger. */ + [[nodiscard]] inline auto current_options() -> options { return logger::instance().configuration(); } + + /** Route records to an additional sink. */ + inline auto add_sink(std::shared_ptr destination) -> void { + logger::instance().add_sink(std::move(destination)); + } + + /** + * Create a file sink, route records to it, and hand it back. + * + * The previous log at `path` is archived on open, so this never appends onto + * an earlier run. + * + * @return The sink, so the caller can inspect the archives it creates. + */ + inline auto add_file_sink(std::filesystem::path path, const file_options &options = {}) + -> std::shared_ptr { + auto destination = std::make_shared(std::move(path), options); + logger::instance().add_sink(destination); + return destination; + } + + /** Replace every sink. */ + inline auto set_sinks(std::vector> destinations) -> void { + logger::instance().set_sinks(std::move(destinations)); + } + + /** True when a record at this level would be emitted. */ + [[nodiscard]] inline auto enabled(const level severity) -> bool { return logger::instance().enabled(severity); } + + namespace detail { + /** Build and dispatch one record. Not for direct use. */ + inline auto emit(const level severity, std::string message, const std::source_location location) -> void { + auto &target = logger::instance(); + if (!target.enabled(severity)) return; + + const auto config = target.configuration(); + auto entry = record{ + .severity = severity, + .message = std::move(message), + .file = location.file_name(), + .line = static_cast(location.line()), + .function = location.function_name(), + .time = std::chrono::system_clock::now(), + .thread = std::this_thread::get_id(), + }; + if (severity >= config.stacktrace_from) { + entry.stacktrace = capture_stacktrace(config.stacktrace_skip, config.stacktrace_depth); + } + target.dispatch(entry); + } + } + + /** + * Emit a record. + * + * The source location defaults to the call site, so this reports exactly + * where it was called from. + */ + inline auto log(const level severity, + std::string message, + const std::source_location location = std::source_location::current()) -> void { + detail::emit(severity, std::move(message), location); + } + + inline auto trace(std::string message, const std::source_location location = std::source_location::current()) + -> void { + detail::emit(level::trace, std::move(message), location); + } + + inline auto debug(std::string message, const std::source_location location = std::source_location::current()) + -> void { + detail::emit(level::debug, std::move(message), location); + } + + inline auto info(std::string message, const std::source_location location = std::source_location::current()) + -> void { + detail::emit(level::info, std::move(message), location); + } + + inline auto warn(std::string message, const std::source_location location = std::source_location::current()) + -> void { + detail::emit(level::warn, std::move(message), location); + } + + /** Emits at `error`, which captures a call stack by default. */ + inline auto error(std::string message, const std::source_location location = std::source_location::current()) + -> void { + detail::emit(level::error, std::move(message), location); + } + + inline auto critical(std::string message, const std::source_location location = std::source_location::current()) + -> void { + detail::emit(level::critical, std::move(message), location); + } +}