diff --git a/.gitignore b/.gitignore index 8e047dfe..1ece92da 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ ingest/ # Local texture dumps / extracted art should never be committed assets/textures/ +node_modules/ diff --git a/BUILD_INSTRUCTIONS.md b/BUILD_INSTRUCTIONS.md index e6f74b19..bd1a09a7 100644 --- a/BUILD_INSTRUCTIONS.md +++ b/BUILD_INSTRUCTIONS.md @@ -1,146 +1,88 @@ -# Build Instructions +# WoWee Build Instructions -This project builds as a native C++ client for WoW 3.3.5a in online mode. +This document provides platform-specific build instructions for WoWee. -## 1. Install Dependencies +--- -### Ubuntu / Debian +## 🐧 Linux (Ubuntu / Debian) + +### Install Dependencies ```bash sudo apt update -sudo apt install -y \ - cmake build-essential pkg-config git \ - libsdl2-dev libglew-dev libglm-dev \ - libssl-dev zlib1g-dev \ - libavformat-dev libavcodec-dev libswscale-dev libavutil-dev \ - libstorm-dev +sudo apt install -y build-essential cmake pkg-config git libsdl2-dev libglew-dev libglm-dev libssl-dev zlib1g-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libunicorn-dev libstorm-dev ``` -If `libstorm-dev` is unavailable in your distro repos, build StormLib from source: +--- + +## 🐧 Linux (Arch) + +### Install Dependencies ```bash -cd /tmp -git clone https://github.com/ladislav-zezula/StormLib.git -cd StormLib -mkdir build && cd build -cmake .. -make -j"$(nproc)" -sudo make install -sudo ldconfig +sudo pacman -S --needed base-devel cmake pkgconf git sdl2 glew glm openssl zlib ffmpeg unicorn stormlib ``` -### Fedora +--- + +## 🐧 Linux (All Distros) + +### Clone Repository + +Always clone with submodules: ```bash -sudo dnf install -y \ - cmake gcc-c++ make pkg-config git \ - SDL2-devel glew-devel glm-devel \ - openssl-devel zlib-devel \ - ffmpeg-devel \ - StormLib-devel +git clone --recurse-submodules https://github.com/Kelsidavis/WoWee.git +cd WoWee ``` -### Arch +If you already cloned without submodules: ```bash -sudo pacman -S --needed \ - cmake base-devel pkgconf git \ - sdl2 glew glm openssl zlib ffmpeg stormlib +git submodule update --init --recursive ``` -## 2. Clone + Prepare - -```bash -git clone https://github.com/Kelsidavis/WoWee.git -cd wowee -git clone https://github.com/ocornut/imgui.git extern/imgui -``` - -## 3. Configure + Build +### Build ```bash cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j"$(nproc)" ``` -Binary output: +--- -```text -build/bin/wowee +## πŸͺŸ Windows (Visual Studio 2022) + +### Install + +- Visual Studio 2022 +- Desktop development with C++ +- CMake tools for Windows + +### Clone + +```powershell +git clone --recurse-submodules https://github.com/Kelsidavis/WoWee.git +cd WoWee ``` -## 4. Provide WoW Data (Extract + Manifest) +### Build -Wowee loads assets from an extracted loose-file tree indexed by `manifest.json` (it does not read MPQs at runtime). +Open the folder in Visual Studio (it will detect CMake automatically) +or build from Developer PowerShell: -### Option A: Extract into `./Data/` (recommended) - -Run: - -```bash -# WotLK 3.3.5a example -./extract_assets.sh /path/to/WoW/Data wotlk -``` - -The output includes: - -```text -Data/ - manifest.json - interface/ - sound/ - world/ - expansions/ -``` - -### Option B: Use an existing extracted data tree - -Point wowee at your extracted `Data/` directory: - -```bash -export WOW_DATA_PATH=/path/to/extracted/Data -``` - -## 5. Run - -```bash -./build/bin/wowee -``` - -## 6. Local AzerothCore (Optional) - -If you are using a local AzerothCore Docker stack, start it first and then connect from the client realm screen. - -See: - -- `docs/server-setup.md` - -## Troubleshooting - -### `StormLib` not found - -Install distro package or build from source (section 1). - -### `ImGui` missing - -Ensure `extern/imgui` exists: - -```bash -git clone https://github.com/ocornut/imgui.git extern/imgui -``` - -### Data not found at runtime - -Verify `Data/manifest.json` exists (or re-run `./extract_assets.sh ...`), or set: - -```bash -export WOW_DATA_PATH=/path/to/extracted/Data -``` - -### Clean rebuild - -```bash -rm -rf build +```powershell cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build -j"$(nproc)" +cmake --build build --config Release ``` + +--- + +## ⚠️ Notes + +- Case matters on Linux (`WoWee` not `wowee`). +- Always use `--recurse-submodules` when cloning. +- If you encounter missing headers for ImGui, run: + ```bash + git submodule update --init --recursive + ``` diff --git a/CMakeLists.txt b/CMakeLists.txt index b6157ea2..ff956973 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # Options option(BUILD_SHARED_LIBS "Build shared libraries" OFF) option(WOWEE_BUILD_TESTS "Build tests" OFF) +option(WOWEE_ENABLE_ASAN "Enable AddressSanitizer (Debug builds)" OFF) # Opcode registry generation/validation find_package(Python3 COMPONENTS Interpreter QUIET) @@ -34,8 +35,13 @@ endif() # Find required packages find_package(SDL2 REQUIRED) -find_package(OpenGL REQUIRED) -find_package(GLEW REQUIRED) +find_package(Vulkan REQUIRED) +# GL/GLEW kept temporarily for unconverted sub-renderers during Vulkan migration. +# These files compile against GL types but their code is never called β€” the Vulkan +# path is the only active rendering backend. Remove in Phase 7 when all renderers +# are converted and grep confirms zero GL references. +find_package(OpenGL QUIET) +find_package(GLEW QUIET) find_package(OpenSSL REQUIRED) find_package(Threads REQUIRED) find_package(ZLIB REQUIRED) @@ -45,7 +51,7 @@ else() find_package(PkgConfig REQUIRED) endif() if(PkgConfig_FOUND) - pkg_check_modules(FFMPEG REQUIRED libavformat libavcodec libswscale libavutil) + pkg_check_modules(FFMPEG libavformat libavcodec libswscale libavutil) else() # Fallback for MSVC/vcpkg β€” find FFmpeg libraries manually find_path(FFMPEG_INCLUDE_DIRS libavformat/avformat.h) @@ -54,9 +60,15 @@ else() find_library(AVUTIL_LIB NAMES avutil) find_library(SWSCALE_LIB NAMES swscale) set(FFMPEG_LIBRARIES ${AVFORMAT_LIB} ${AVCODEC_LIB} ${AVUTIL_LIB} ${SWSCALE_LIB}) - if(NOT AVFORMAT_LIB) - message(FATAL_ERROR "FFmpeg not found. On Windows install via MSYS2: mingw-w64-x86_64-ffmpeg") - endif() +endif() +if(FFMPEG_INCLUDE_DIRS AND AVFORMAT_LIB) + set(HAVE_FFMPEG TRUE) + message(STATUS "Found FFmpeg: ${AVFORMAT_LIB}") +elseif(FFMPEG_FOUND) + set(HAVE_FFMPEG TRUE) +else() + set(HAVE_FFMPEG FALSE) + message(WARNING "FFmpeg not found β€” video_player will be disabled. Install via vcpkg: ffmpeg:x64-windows") endif() # Unicorn Engine (x86 emulator for cross-platform Warden module execution) @@ -77,13 +89,68 @@ if(NOT glm_FOUND) message(STATUS "GLM not found, will use system includes or download") endif() # GLM GTX extensions (quaternion, norm, etc.) require this flag on newer GLM versions -add_compile_definitions(GLM_ENABLE_EXPERIMENTAL) +add_compile_definitions(GLM_ENABLE_EXPERIMENTAL GLM_FORCE_DEPTH_ZERO_TO_ONE) +if(WIN32) + add_compile_definitions(NOMINMAX _CRT_SECURE_NO_WARNINGS) +endif() + +# SPIR-V shader compilation via glslc +find_program(GLSLC glslc HINTS ${Vulkan_GLSLC_EXECUTABLE} "$ENV{VULKAN_SDK}/bin") +if(GLSLC) + message(STATUS "Found glslc: ${GLSLC}") +else() + message(WARNING "glslc not found. Install the Vulkan SDK or vulkan-tools package.") + message(WARNING "Shaders will not be compiled to SPIR-V.") +endif() + +# Function to compile GLSL shaders to SPIR-V +function(compile_shaders TARGET_NAME) + set(SHADER_DIR ${CMAKE_CURRENT_SOURCE_DIR}/assets/shaders) + set(SPV_DIR ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/assets/shaders) + file(MAKE_DIRECTORY ${SPV_DIR}) + + file(GLOB GLSL_SOURCES "${SHADER_DIR}/*.glsl") + set(SPV_OUTPUTS) + + foreach(GLSL_FILE ${GLSL_SOURCES}) + get_filename_component(FILE_NAME ${GLSL_FILE} NAME) + # e.g. skybox.vert.glsl -> skybox.vert.spv + string(REGEX REPLACE "\\.glsl$" ".spv" SPV_NAME ${FILE_NAME}) + set(SPV_FILE ${SPV_DIR}/${SPV_NAME}) + + # Determine shader stage from filename + if(FILE_NAME MATCHES "\\.vert\\.glsl$") + set(SHADER_STAGE vertex) + elseif(FILE_NAME MATCHES "\\.frag\\.glsl$") + set(SHADER_STAGE fragment) + elseif(FILE_NAME MATCHES "\\.comp\\.glsl$") + set(SHADER_STAGE compute) + elseif(FILE_NAME MATCHES "\\.geom\\.glsl$") + set(SHADER_STAGE geometry) + else() + message(WARNING "Cannot determine shader stage for: ${FILE_NAME}") + continue() + endif() + + add_custom_command( + OUTPUT ${SPV_FILE} + COMMAND ${GLSLC} -fshader-stage=${SHADER_STAGE} -O ${GLSL_FILE} -o ${SPV_FILE} + DEPENDS ${GLSL_FILE} + COMMENT "Compiling SPIR-V: ${FILE_NAME} -> ${SPV_NAME}" + VERBATIM + ) + list(APPEND SPV_OUTPUTS ${SPV_FILE}) + endforeach() + + add_custom_target(${TARGET_NAME}_shaders ALL DEPENDS ${SPV_OUTPUTS}) + add_dependencies(${TARGET_NAME} ${TARGET_NAME}_shaders) +endfunction() # StormLib for MPQ extraction tool (not needed for main executable) find_library(STORMLIB_LIBRARY NAMES StormLib stormlib storm) find_path(STORMLIB_INCLUDE_DIR StormLib.h PATH_SUFFIXES StormLib) -# Include ImGui as a static library (we'll add the sources) +# Include ImGui as a static library (Vulkan backend) set(IMGUI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/extern/imgui) if(EXISTS ${IMGUI_DIR}) add_library(imgui STATIC @@ -93,19 +160,30 @@ if(EXISTS ${IMGUI_DIR}) ${IMGUI_DIR}/imgui_widgets.cpp ${IMGUI_DIR}/imgui_demo.cpp ${IMGUI_DIR}/backends/imgui_impl_sdl2.cpp - ${IMGUI_DIR}/backends/imgui_impl_opengl3.cpp + ${IMGUI_DIR}/backends/imgui_impl_vulkan.cpp ) target_include_directories(imgui PUBLIC ${IMGUI_DIR} ${IMGUI_DIR}/backends ) - target_link_libraries(imgui PUBLIC SDL2::SDL2 OpenGL::GL ${CMAKE_DL_LIBS}) - target_compile_definitions(imgui PUBLIC IMGUI_IMPL_OPENGL_LOADER_GLEW) + target_link_libraries(imgui PUBLIC SDL2::SDL2 Vulkan::Vulkan ${CMAKE_DL_LIBS}) else() message(WARNING "ImGui not found in extern/imgui. Clone it with:") message(WARNING " git clone https://github.com/ocornut/imgui.git extern/imgui") endif() +# vk-bootstrap (Vulkan device/instance setup) +set(VK_BOOTSTRAP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/extern/vk-bootstrap) +if(EXISTS ${VK_BOOTSTRAP_DIR}) + add_library(vk-bootstrap STATIC + ${VK_BOOTSTRAP_DIR}/src/VkBootstrap.cpp + ) + target_include_directories(vk-bootstrap PUBLIC ${VK_BOOTSTRAP_DIR}/src) + target_link_libraries(vk-bootstrap PUBLIC Vulkan::Vulkan) +else() + message(FATAL_ERROR "vk-bootstrap not found in extern/vk-bootstrap") +endif() + # Source files set(WOWEE_SOURCES # Core @@ -180,6 +258,15 @@ set(WOWEE_SOURCES src/pipeline/terrain_mesh.cpp + # Rendering (Vulkan infrastructure) + src/rendering/vk_context.cpp + src/rendering/vk_utils.cpp + src/rendering/vk_shader.cpp + src/rendering/vk_texture.cpp + src/rendering/vk_buffer.cpp + src/rendering/vk_pipeline.cpp + src/rendering/vk_render_target.cpp + # Rendering src/rendering/renderer.cpp src/rendering/shader.cpp @@ -215,7 +302,7 @@ set(WOWEE_SOURCES src/rendering/levelup_effect.cpp src/rendering/charge_effect.cpp src/rendering/loading_screen.cpp - src/rendering/video_player.cpp + $<$:${CMAKE_CURRENT_SOURCE_DIR}/src/rendering/video_player.cpp> # UI src/ui/ui_manager.cpp @@ -287,6 +374,13 @@ set(WOWEE_HEADERS include/pipeline/dbc_loader.hpp include/pipeline/terrain_mesh.hpp + include/rendering/vk_context.hpp + include/rendering/vk_utils.hpp + include/rendering/vk_shader.hpp + include/rendering/vk_texture.hpp + include/rendering/vk_buffer.hpp + include/rendering/vk_pipeline.hpp + include/rendering/vk_render_target.hpp include/rendering/renderer.hpp include/rendering/shader.hpp include/rendering/texture.hpp @@ -343,19 +437,26 @@ if(TARGET opcodes-generate) add_dependencies(wowee opcodes-generate) endif() +# Compile GLSL shaders to SPIR-V +if(GLSLC) + compile_shaders(wowee) +endif() + # Include directories target_include_directories(wowee PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/extern - ${FFMPEG_INCLUDE_DIRS} + ${CMAKE_CURRENT_SOURCE_DIR}/extern/vk-bootstrap/src ) +if(HAVE_FFMPEG) + target_include_directories(wowee PRIVATE ${FFMPEG_INCLUDE_DIRS}) +endif() # Link libraries target_link_libraries(wowee PRIVATE SDL2::SDL2 - OpenGL::GL - GLEW::GLEW + Vulkan::Vulkan OpenSSL::SSL OpenSSL::Crypto Threads::Threads @@ -363,9 +464,20 @@ target_link_libraries(wowee PRIVATE ${CMAKE_DL_LIBS} ) -target_link_libraries(wowee PRIVATE ${FFMPEG_LIBRARIES}) -if (FFMPEG_LIBRARY_DIRS) - target_link_directories(wowee PRIVATE ${FFMPEG_LIBRARY_DIRS}) +# GL/GLEW linked temporarily for unconverted sub-renderers (removed in Phase 7) +if(TARGET OpenGL::GL) + target_link_libraries(wowee PRIVATE OpenGL::GL) +endif() +if(TARGET GLEW::GLEW) + target_link_libraries(wowee PRIVATE GLEW::GLEW) +endif() + +if(HAVE_FFMPEG) + target_compile_definitions(wowee PRIVATE HAVE_FFMPEG) + target_link_libraries(wowee PRIVATE ${FFMPEG_LIBRARIES}) + if(FFMPEG_LIBRARY_DIRS) + target_link_directories(wowee PRIVATE ${FFMPEG_LIBRARY_DIRS}) + endif() endif() # Platform-specific libraries @@ -385,6 +497,11 @@ if(TARGET imgui) target_link_libraries(wowee PRIVATE imgui) endif() +# Link vk-bootstrap +if(TARGET vk-bootstrap) + target_link_libraries(wowee PRIVATE vk-bootstrap) +endif() + # Link Unicorn if available if(HAVE_UNICORN) target_link_libraries(wowee PRIVATE ${UNICORN_LIBRARY}) @@ -406,6 +523,47 @@ else() target_compile_options(wowee PRIVATE -Wall -Wextra -Wpedantic) endif() +# Debug build flags +if(MSVC) + # /ZI β€” Edit-and-Continue debug info (works with hot-reload in VS 2022) + # /RTC1 β€” stack-frame and uninitialised-variable runtime checks (Debug only) + # /sdl β€” additional SDL security checks + # /Od β€” disable optimisation so stepping matches source lines exactly + target_compile_options(wowee PRIVATE + $<$:/ZI /RTC1 /sdl /Od> + ) + # Ensure the linker emits a .pdb alongside the .exe for every config + target_link_options(wowee PRIVATE + $<$:/DEBUG:FULL> + $<$:/DEBUG:FASTLINK> + ) +else() + # -g3 β€” maximum DWARF debug info (includes macro definitions) + # -Og β€” optimise for debugging (better than -O0, keeps most frames) + # -fno-omit-frame-pointer β€” preserve frame pointers so stack traces are clean + target_compile_options(wowee PRIVATE + $<$:-g3 -Og -fno-omit-frame-pointer> + $<$:-g -fno-omit-frame-pointer> + ) +endif() + +# AddressSanitizer β€” catch buffer overflows, use-after-free, etc. +# Enable with: cmake ... -DWOWEE_ENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=Debug +if(WOWEE_ENABLE_ASAN) + if(MSVC) + target_compile_options(wowee PRIVATE /fsanitize=address) + # ASAN on MSVC requires the dynamic CRT (/MD or /MDd) + target_compile_options(wowee PRIVATE + $<$:/MDd> + $<$:/MD> + ) + else() + target_compile_options(wowee PRIVATE -fsanitize=address -fno-omit-frame-pointer) + target_link_options(wowee PRIVATE -fsanitize=address) + endif() + message(STATUS "AddressSanitizer: ENABLED") +endif() + # Release build optimizations include(CheckIPOSupported) check_ipo_supported(RESULT _ipo_supported OUTPUT _ipo_error) @@ -420,9 +578,27 @@ if(NOT MSVC) target_compile_options(wowee PRIVATE $<$:-fvisibility=hidden -fvisibility-inlines-hidden>) endif() -# Copy assets to build directory -file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/assets - DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}) +# Copy assets next to the executable (runs every build, not just configure). +# Uses $ so MSVC multi-config generators place assets +# in bin/Debug/ or bin/Release/ alongside the exe, not the common bin/ parent. +add_custom_command(TARGET wowee POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/assets + $/assets + COMMENT "Syncing assets to $/assets" +) + +# On Windows, SDL 2.28+ uses LoadLibraryExW with LOAD_LIBRARY_SEARCH_DEFAULT_DIRS +# which does NOT include System32. Copy vulkan-1.dll into the output directory so +# SDL_Vulkan_LoadLibrary can locate it without needing a full system PATH search. +if(WIN32) + add_custom_command(TARGET wowee POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$ENV{SystemRoot}/System32/vulkan-1.dll" + "$/vulkan-1.dll" + COMMENT "Copying vulkan-1.dll to output directory" + ) +endif() # Install targets install(TARGETS wowee @@ -482,6 +658,9 @@ if(STORMLIB_LIBRARY AND STORMLIB_INCLUDE_DIR) ZLIB::ZLIB Threads::Threads ) + if(WIN32) + target_link_libraries(asset_extract PRIVATE wininet bz2) + endif() set_target_properties(asset_extract PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin ) @@ -575,6 +754,7 @@ message(STATUS " Build Type: ${CMAKE_BUILD_TYPE}") message(STATUS " SDL2: ${SDL2_VERSION}") message(STATUS " OpenSSL: ${OPENSSL_VERSION}") message(STATUS " ImGui: ${IMGUI_DIR}") +message(STATUS " ASAN: ${WOWEE_ENABLE_ASAN}") message(STATUS "") # ---- CPack packaging ---- @@ -628,7 +808,7 @@ chmod +x /usr/local/bin/wowee set(CPACK_DEBIAN_PACKAGE_MAINTAINER "Wowee") set(CPACK_DEBIAN_PACKAGE_SECTION "games") set(CPACK_DEBIAN_PACKAGE_DEPENDS - "libsdl2-2.0-0, libglew2.2 | libglew2.1, libssl3, zlib1g") + "libsdl2-2.0-0, libvulkan1, libssl3, zlib1g") set(CPACK_DEBIAN_PACKAGE_CONTROL_EXTRA "${CMAKE_CURRENT_BINARY_DIR}/packaging/postinst;${CMAKE_CURRENT_BINARY_DIR}/packaging/prerm") if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64") diff --git a/README.md b/README.md index 43aa4de5..debe3d16 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,23 @@ Wowee Logo

-A native C++ World of Warcraft client with a custom OpenGL renderer. +A native C++ World of Warcraft client with a custom Vulkan renderer. [![Sponsor](https://img.shields.io/github/sponsors/Kelsidavis?label=Sponsor&logo=GitHub)](https://github.com/sponsors/Kelsidavis) +[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/SDqjA79B) [![Watch the video](https://img.youtube.com/vi/Pd9JuYYxu0o/maxresdefault.jpg)](https://youtu.be/Pd9JuYYxu0o) [![Watch the video](https://img.youtube.com/vi/J4NXegzqWSQ/maxresdefault.jpg)](https://youtu.be/J4NXegzqWSQ) -Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. All three expansions are broadly functional with roughly even support. +Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. > **Legal Disclaimer**: This is an educational/research project. It does not include any Blizzard Entertainment assets, data files, or proprietary code. World of Warcraft and all related assets are the property of Blizzard Entertainment, Inc. This project is not affiliated with or endorsed by Blizzard Entertainment. Users are responsible for supplying their own legally obtained game data files and for ensuring compliance with all applicable laws in their jurisdiction. ## Status & Direction (2026-02-18) -- **Compatibility**: **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a** are all broadly supported via expansion profiles and per-expansion packet parsers (`src/game/packet_parsers_classic.cpp`, `src/game/packet_parsers_tbc.cpp`). All three expansions are roughly on par β€” no single one is significantly more complete than the others. -- **Tested against**: AzerothCore, TrinityCore, and ChromieCraft. +- **Compatibility**: **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a** are all supported via expansion profiles and per-expansion packet parsers (`src/game/packet_parsers_classic.cpp`, `src/game/packet_parsers_tbc.cpp`). All three expansions are roughly on par β€” no single one is significantly more complete than the others. +- **Tested against**: AzerothCore, TrinityCore, and Mangos. - **Current focus**: protocol correctness across server variants, visual accuracy (M2/WMO edge cases, equipment textures), and multi-expansion coverage. - **Warden**: Full module execution via Unicorn Engine CPU emulation. Decrypts (RC4β†’RSAβ†’zlib), parses and relocates the PE module, executes via x86 emulation with Windows API interception. Module cache at `~/.local/share/wowee/warden_cache/`. @@ -27,18 +28,10 @@ Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. All three ### Rendering Engine - **Terrain** -- Multi-tile streaming with async loading, texture splatting (4 layers), frustum culling -- **Water** -- Animated surfaces, reflections, refractions, Fresnel effect -- **Sky System** -- WoW-accurate DBC-driven lighting with skybox authority - - **Skybox** -- Camera-locked celestial sphere (M2 model support, gradient fallback) - - **Celestial Bodies** -- Sun (lighting-driven), White Lady + Blue Child (Azeroth's two moons) - - **Moon Phases** -- Game time-driven deterministic phases when server time is available (fallback: local cycling for development) - - **Stars** -- Baked into skybox assets (procedural fallback for development/debug only) - **Atmosphere** -- Procedural clouds (FBM noise), lens flare with chromatic aberration, cloud/fog star occlusion -- **Weather** -- Rain and snow particle systems (2000 particles, camera-relative) - **Characters** -- Skeletal animation with GPU vertex skinning (256 bones), race-aware textures - **Buildings** -- WMO renderer with multi-material batches, frustum culling, 160-unit distance culling - **Particles** -- M2 particle emitters with WotLK struct parsing, billboarded glow effects -- **Post-Processing** -- HDR, tonemapping, shadow mapping (2048x2048) ### Asset Pipeline - Extracted loose-file **`Data/`** tree indexed by **`manifest.json`** (fast lookup + caching) @@ -71,21 +64,26 @@ Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. All three ```bash # Ubuntu/Debian -sudo apt install libsdl2-dev libglew-dev libglm-dev \ - libssl-dev cmake build-essential \ - libunicorn-dev \ # for Warden module execution - libstorm-dev # for asset_extract +sudo apt install libsdl2-dev libglm-dev libssl-dev \ + libvulkan-dev vulkan-tools glslc \ + libavformat-dev libavcodec-dev libswscale-dev libavutil-dev \ + zlib1g-dev cmake build-essential libx11-dev \ + libunicorn-dev \ # optional: Warden module execution + libstorm-dev # optional: asset_extract tool # Fedora -sudo dnf install SDL2-devel glew-devel glm-devel \ - openssl-devel cmake gcc-c++ \ - unicorn-devel \ # for Warden module execution - StormLib-devel # for asset_extract +sudo dnf install SDL2-devel glm-devel openssl-devel \ + vulkan-devel vulkan-tools glslc \ + ffmpeg-devel zlib-devel cmake gcc-c++ libX11-devel \ + unicorn-devel \ # optional: Warden module execution + StormLib-devel # optional: asset_extract tool # Arch -sudo pacman -S sdl2 glew glm openssl cmake base-devel \ - unicorn \ # for Warden module execution - stormlib # for asset_extract +sudo pacman -S sdl2 glm openssl \ + vulkan-devel vulkan-tools shaderc \ + ffmpeg zlib cmake base-devel libx11 \ + unicorn # optional: Warden module execution + # StormLib: install from AUR for asset_extract tool ``` ### Container build @@ -208,15 +206,12 @@ make -j$(nproc) ## Technical Details -- **Graphics**: OpenGL 3.3 Core, GLSL 330, forward rendering with post-processing -- **Performance**: 60 FPS (vsync), ~50k triangles/frame, ~30 draw calls, <10% GPU - **Platform**: Linux (primary), C++20, CMake 3.15+ -- **Dependencies**: SDL2, OpenGL/GLEW, GLM, OpenSSL, ImGui, FFmpeg, Unicorn Engine (StormLib for asset extraction tooling) +- **Dependencies**: SDL2, Vulkan, GLM, OpenSSL, ImGui, FFmpeg, Unicorn Engine (StormLib for asset extraction tooling) - **Architecture**: Modular design with clear separation (core, rendering, networking, game logic, asset pipeline, UI, audio) - **Networking**: Non-blocking TCP, SRP6a authentication, RC4 encryption, WoW 3.3.5a protocol - **Asset Loading**: Extracted loose-file tree + `manifest.json` indexing, async terrain streaming, overlay layers - **Sky System**: WoW-accurate DBC-driven architecture - - **Skybox Authority**: Stars baked into M2 sky dome models (not procedurally generated) - **Lore-Accurate Moons**: White Lady (30-day cycle) + Blue Child (27-day cycle) - **Deterministic Phases**: Computed from server game time when available (fallback: local time/dev cycling) - **Camera-Locked**: Sky dome uses rotation-only transform (translation ignored) @@ -238,8 +233,4 @@ This project does not include any Blizzard Entertainment proprietary data, asset ## Known Issues -### Water Rendering -- **Stormwind Canal Overflow**: Canal water surfaces extend spatially beyond their intended boundaries, causing water to appear in tunnels, buildings, and the park. This is due to oversized water mesh extents in the WoW data files. - - **Current Workaround**: Water heights are lowered by 1 unit in Stormwind (tiles 28-50, 28-52) for surfaces above 94 units, with a 20-unit exclusion zone around the moonwell (-8755.9, 1108.9, 96.1). This hides most problem water while keeping canals and the moonwell functional. - - **Limitation**: Some park water may still be visible. The workaround uses hardcoded coordinates and height thresholds rather than fixing the root cause. - - **Proper Fix**: Would require trimming water surface meshes to actual boundaries in ADT/WMO data, or implementing spatial clipping at render time. +MANY issues this is actively under development diff --git a/assets/krayonload.png b/assets/krayonload.png new file mode 100644 index 00000000..4d580744 Binary files /dev/null and b/assets/krayonload.png differ diff --git a/assets/krayonsignin.png b/assets/krayonsignin.png new file mode 100644 index 00000000..f0d3bfa4 Binary files /dev/null and b/assets/krayonsignin.png differ diff --git a/assets/loading1.jpeg b/assets/loading1.jpeg deleted file mode 100755 index 79cae0dc..00000000 Binary files a/assets/loading1.jpeg and /dev/null differ diff --git a/assets/loading2.jpeg b/assets/loading2.jpeg deleted file mode 100755 index 2403b28a..00000000 Binary files a/assets/loading2.jpeg and /dev/null differ diff --git a/assets/shaders/basic.frag.glsl b/assets/shaders/basic.frag.glsl new file mode 100644 index 00000000..f6a07030 --- /dev/null +++ b/assets/shaders/basic.frag.glsl @@ -0,0 +1,49 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(set = 1, binding = 1) uniform BasicMaterial { + vec4 color; + vec3 lightPos; + int useTexture; +}; + +layout(location = 0) in vec3 FragPos; +layout(location = 1) in vec3 Normal; +layout(location = 2) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +void main() { + vec3 ambient = 0.3 * vec3(1.0); + vec3 norm = normalize(Normal); + vec3 lightDir2 = normalize(lightPos - FragPos); + float diff = max(dot(norm, lightDir2), 0.0); + vec3 diffuse = diff * vec3(1.0); + + vec3 viewDir2 = normalize(viewPos.xyz - FragPos); + vec3 reflectDir = reflect(-lightDir2, norm); + float spec = pow(max(dot(viewDir2, reflectDir), 0.0), 32.0); + vec3 specular = 0.5 * spec * vec3(1.0); + + vec3 result = ambient + diffuse + specular; + + if (useTexture != 0) { + outColor = texture(uTexture, TexCoord) * vec4(result, 1.0); + } else { + outColor = color * vec4(result, 1.0); + } +} diff --git a/assets/shaders/basic.frag.spv b/assets/shaders/basic.frag.spv new file mode 100644 index 00000000..e58cf2d6 Binary files /dev/null and b/assets/shaders/basic.frag.spv differ diff --git a/assets/shaders/basic.vert.glsl b/assets/shaders/basic.vert.glsl new file mode 100644 index 00000000..2817fb8e --- /dev/null +++ b/assets/shaders/basic.vert.glsl @@ -0,0 +1,34 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; +} push; + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; + +layout(location = 0) out vec3 FragPos; +layout(location = 1) out vec3 Normal; +layout(location = 2) out vec2 TexCoord; + +void main() { + vec4 worldPos = push.model * vec4(aPosition, 1.0); + FragPos = worldPos.xyz; + Normal = mat3(push.model) * aNormal; + TexCoord = aTexCoord; + gl_Position = projection * view * worldPos; +} diff --git a/assets/shaders/basic.vert.spv b/assets/shaders/basic.vert.spv new file mode 100644 index 00000000..f3b4b7b2 Binary files /dev/null and b/assets/shaders/basic.vert.spv differ diff --git a/assets/shaders/celestial.frag.glsl b/assets/shaders/celestial.frag.glsl new file mode 100644 index 00000000..8c450c46 --- /dev/null +++ b/assets/shaders/celestial.frag.glsl @@ -0,0 +1,64 @@ +#version 450 + +layout(push_constant) uniform Push { + mat4 model; + vec4 celestialColor; // xyz = color, w = unused + float intensity; + float moonPhase; + float animTime; +} push; + +layout(location = 0) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +float valueNoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = fract(sin(dot(i, vec2(127.1, 311.7))) * 43758.5453); + float b = fract(sin(dot(i + vec2(1.0, 0.0), vec2(127.1, 311.7))) * 43758.5453); + float c = fract(sin(dot(i + vec2(0.0, 1.0), vec2(127.1, 311.7))) * 43758.5453); + float d = fract(sin(dot(i + vec2(1.0, 1.0), vec2(127.1, 311.7))) * 43758.5453); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +void main() { + vec2 uv = TexCoord - 0.5; + float dist = length(uv); + + // Hard circular cutoff β€” nothing beyond radius 0.35 + if (dist > 0.35) discard; + + // Hard disc with smooth edge + float disc = smoothstep(0.35, 0.28, dist); + + // Soft glow confined within cutoff radius + float glow = exp(-dist * dist * 40.0) * 0.5; + + // Combine disc and glow + float alpha = max(disc, glow) * push.intensity; + + // Smooth fade to zero at cutoff boundary + float edgeFade = 1.0 - smoothstep(0.25, 0.35, dist); + alpha *= edgeFade; + + vec3 color = push.celestialColor.rgb; + + // Animated haze/turbulence overlay for the sun disc + if (push.intensity > 0.5) { + float noise = valueNoise(uv * 8.0 + vec2(push.animTime * 0.3, push.animTime * 0.2)); + float noise2 = valueNoise(uv * 16.0 - vec2(push.animTime * 0.5, push.animTime * 0.15)); + float turbulence = (noise * 0.6 + noise2 * 0.4) * disc; + color += vec3(turbulence * 0.3, turbulence * 0.15, 0.0); + } + + // Moon phase shadow (only applied when intensity < 0.5, i.e. for moons) + float phaseX = uv.x * 2.0 + push.moonPhase; + float phaseShadow = smoothstep(-0.1, 0.1, phaseX); + alpha *= mix(phaseShadow, 1.0, step(0.5, push.intensity)); + + if (alpha < 0.01) discard; + // Pre-multiply for additive blending: RGB is the light contribution + outColor = vec4(color * alpha, alpha); +} diff --git a/assets/shaders/celestial.frag.spv b/assets/shaders/celestial.frag.spv new file mode 100644 index 00000000..9fce6c5e Binary files /dev/null and b/assets/shaders/celestial.frag.spv differ diff --git a/assets/shaders/celestial.vert.glsl b/assets/shaders/celestial.vert.glsl new file mode 100644 index 00000000..f71b9ba0 --- /dev/null +++ b/assets/shaders/celestial.vert.glsl @@ -0,0 +1,34 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; + vec4 celestialColor; // xyz = color, w = unused + float intensity; + float moonPhase; + float animTime; +} push; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec2 aTexCoord; + +layout(location = 0) out vec2 TexCoord; + +void main() { + TexCoord = aTexCoord; + // Sky object: remove camera translation so celestial bodies are at infinite distance + mat4 rotView = mat4(mat3(view)); + gl_Position = projection * rotView * push.model * vec4(aPos, 1.0); +} diff --git a/assets/shaders/celestial.vert.spv b/assets/shaders/celestial.vert.spv new file mode 100644 index 00000000..3ab1ecdd Binary files /dev/null and b/assets/shaders/celestial.vert.spv differ diff --git a/assets/shaders/character.frag.glsl b/assets/shaders/character.frag.glsl new file mode 100644 index 00000000..b28fa314 --- /dev/null +++ b/assets/shaders/character.frag.glsl @@ -0,0 +1,187 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(set = 1, binding = 1) uniform CharMaterial { + float opacity; + int alphaTest; + int colorKeyBlack; + int unlit; + float emissiveBoost; + vec3 emissiveTint; + float specularIntensity; + int enableNormalMap; + int enablePOM; + float pomScale; + int pomMaxSamples; + float heightMapVariance; + float normalMapStrength; +}; + +layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap; + +layout(set = 0, binding = 1) uniform sampler2DShadow uShadowMap; + +layout(location = 0) in vec3 FragPos; +layout(location = 1) in vec3 Normal; +layout(location = 2) in vec2 TexCoord; +layout(location = 3) in vec3 Tangent; +layout(location = 4) in vec3 Bitangent; + +layout(location = 0) out vec4 outColor; + +const float SHADOW_TEXEL = 1.0 / 4096.0; + +float sampleShadowPCF(sampler2DShadow smap, vec3 coords) { + float shadow = 0.0; + for (int x = -1; x <= 1; ++x) { + for (int y = -1; y <= 1; ++y) { + shadow += texture(smap, vec3(coords.xy + vec2(x, y) * SHADOW_TEXEL, coords.z)); + } + } + return shadow / 9.0; +} + +// LOD factor from screen-space UV derivatives +float computeLodFactor() { + vec2 dx = dFdx(TexCoord); + vec2 dy = dFdy(TexCoord); + float texelDensity = max(dot(dx, dx), dot(dy, dy)); + return smoothstep(0.0001, 0.005, texelDensity); +} + +// Parallax Occlusion Mapping with angle-adaptive sampling +vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float lodFactor) { + float VdotN = abs(viewDirTS.z); + + if (VdotN < 0.15) return uv; + + float angleFactor = clamp(VdotN, 0.15, 1.0); + int maxS = pomMaxSamples; + int minS = max(maxS / 4, 4); + int numSamples = int(mix(float(minS), float(maxS), angleFactor)); + numSamples = int(mix(float(minS), float(numSamples), 1.0 - lodFactor)); + + float layerDepth = 1.0 / float(numSamples); + float currentLayerDepth = 0.0; + + vec2 P = viewDirTS.xy / max(VdotN, 0.15) * pomScale; + float maxOffset = pomScale * 3.0; + P = clamp(P, vec2(-maxOffset), vec2(maxOffset)); + vec2 deltaUV = P / float(numSamples); + + vec2 currentUV = uv; + float currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + + for (int i = 0; i < 64; i++) { + if (i >= numSamples || currentLayerDepth >= currentDepthMapValue) break; + currentUV -= deltaUV; + currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + currentLayerDepth += layerDepth; + } + + vec2 prevUV = currentUV + deltaUV; + float afterDepth = currentDepthMapValue - currentLayerDepth; + float beforeDepth = (1.0 - texture(uNormalHeightMap, prevUV).a) - currentLayerDepth + layerDepth; + float weight = afterDepth / (afterDepth - beforeDepth + 0.0001); + vec2 result = mix(currentUV, prevUV, weight); + + float fadeFactor = smoothstep(0.15, 0.35, VdotN); + return mix(uv, result, fadeFactor); +} + +void main() { + float lodFactor = computeLodFactor(); + + vec3 vertexNormal = normalize(Normal); + if (!gl_FrontFacing) vertexNormal = -vertexNormal; + + vec2 finalUV = TexCoord; + + // Build TBN matrix + vec3 T = normalize(Tangent); + vec3 B = normalize(Bitangent); + vec3 N = vertexNormal; + mat3 TBN = mat3(T, B, N); + + if (enablePOM != 0 && heightMapVariance > 0.001 && lodFactor < 0.99) { + mat3 TBN_inv = transpose(TBN); + vec3 viewDirWorld = normalize(viewPos.xyz - FragPos); + vec3 viewDirTS = TBN_inv * viewDirWorld; + finalUV = parallaxOcclusionMap(TexCoord, viewDirTS, lodFactor); + } + + vec4 texColor = texture(uTexture, finalUV); + + if (alphaTest != 0 && texColor.a < 0.5) discard; + if (colorKeyBlack != 0) { + float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); + float ck = smoothstep(0.12, 0.30, lum); + texColor.a *= ck; + if (texColor.a < 0.01) discard; + } + + // Compute normal (with normal mapping if enabled) + vec3 norm = vertexNormal; + if (enableNormalMap != 0 && lodFactor < 0.99 && normalMapStrength > 0.001) { + vec3 mapNormal = texture(uNormalHeightMap, finalUV).rgb * 2.0 - 1.0; + mapNormal.xy *= normalMapStrength; + mapNormal = normalize(mapNormal); + vec3 worldNormal = normalize(TBN * mapNormal); + if (!gl_FrontFacing) worldNormal = -worldNormal; + float blendFactor = max(lodFactor, 1.0 - normalMapStrength); + norm = normalize(mix(worldNormal, vertexNormal, blendFactor)); + } + + vec3 result; + + if (unlit != 0) { + vec3 warm = emissiveTint * emissiveBoost; + result = texColor.rgb * (1.0 + warm); + } else { + vec3 ldir = normalize(-lightDir.xyz); + float diff = max(dot(norm, ldir), 0.0); + + vec3 viewDir = normalize(viewPos.xyz - FragPos); + vec3 halfDir = normalize(ldir + viewDir); + float spec = pow(max(dot(norm, halfDir), 0.0), 32.0) * specularIntensity; + + float shadow = 1.0; + if (shadowParams.x > 0.5) { + float normalOffset = SHADOW_TEXEL * 2.0 * (1.0 - abs(dot(norm, ldir))); + vec3 biasedPos = FragPos + norm * normalOffset; + vec4 lsPos = lightSpaceMatrix * vec4(biasedPos, 1.0); + vec3 proj = lsPos.xyz / lsPos.w; + proj.xy = proj.xy * 0.5 + 0.5; + if (proj.x >= 0.0 && proj.x <= 1.0 && + proj.y >= 0.0 && proj.y <= 1.0 && + proj.z >= 0.0 && proj.z <= 1.0) { + float bias = max(0.0005 * (1.0 - dot(norm, ldir)), 0.00005); + shadow = sampleShadowPCF(uShadowMap, vec3(proj.xy, proj.z - bias)); + } + shadow = mix(1.0, shadow, shadowParams.y); + } + + result = ambientColor.rgb * texColor.rgb + + shadow * (diff * lightColor.rgb * texColor.rgb + spec * lightColor.rgb); + } + + float dist = length(viewPos.xyz - FragPos); + float fogFactor = clamp((fogParams.y - dist) / (fogParams.y - fogParams.x), 0.0, 1.0); + result = mix(fogColor.rgb, result, fogFactor); + + outColor = vec4(result, texColor.a * opacity); +} diff --git a/assets/shaders/character.frag.spv b/assets/shaders/character.frag.spv new file mode 100644 index 00000000..99c68edf Binary files /dev/null and b/assets/shaders/character.frag.spv differ diff --git a/assets/shaders/character.vert.glsl b/assets/shaders/character.vert.glsl new file mode 100644 index 00000000..52e4ee65 --- /dev/null +++ b/assets/shaders/character.vert.glsl @@ -0,0 +1,63 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; +} push; + +layout(set = 2, binding = 0) readonly buffer BoneSSBO { + mat4 bones[]; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec4 aBoneWeights; +layout(location = 2) in ivec4 aBoneIndices; +layout(location = 3) in vec3 aNormal; +layout(location = 4) in vec2 aTexCoord; +layout(location = 5) in vec4 aTangent; + +layout(location = 0) out vec3 FragPos; +layout(location = 1) out vec3 Normal; +layout(location = 2) out vec2 TexCoord; +layout(location = 3) out vec3 Tangent; +layout(location = 4) out vec3 Bitangent; + +void main() { + mat4 skinMat = bones[aBoneIndices.x] * aBoneWeights.x + + bones[aBoneIndices.y] * aBoneWeights.y + + bones[aBoneIndices.z] * aBoneWeights.z + + bones[aBoneIndices.w] * aBoneWeights.w; + + vec4 skinnedPos = skinMat * vec4(aPos, 1.0); + vec3 skinnedNorm = mat3(skinMat) * aNormal; + vec3 skinnedTan = mat3(skinMat) * aTangent.xyz; + + vec4 worldPos = push.model * skinnedPos; + mat3 modelMat3 = mat3(push.model); + FragPos = worldPos.xyz; + Normal = modelMat3 * skinnedNorm; + TexCoord = aTexCoord; + + // Gram-Schmidt re-orthogonalize tangent w.r.t. normal + vec3 N = normalize(Normal); + vec3 T = normalize(modelMat3 * skinnedTan); + T = normalize(T - dot(T, N) * N); + vec3 B = cross(N, T) * aTangent.w; + + Tangent = T; + Bitangent = B; + + gl_Position = projection * view * worldPos; +} diff --git a/assets/shaders/character.vert.spv b/assets/shaders/character.vert.spv new file mode 100644 index 00000000..cbe4916d Binary files /dev/null and b/assets/shaders/character.vert.spv differ diff --git a/assets/shaders/character_shadow.frag.glsl b/assets/shaders/character_shadow.frag.glsl new file mode 100644 index 00000000..c37cbd20 --- /dev/null +++ b/assets/shaders/character_shadow.frag.glsl @@ -0,0 +1,19 @@ +#version 450 + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(set = 1, binding = 1) uniform ShadowParams { + int alphaTest; + int colorKeyBlack; +}; + +layout(location = 0) in vec2 TexCoord; + +void main() { + vec4 texColor = texture(uTexture, TexCoord); + if (alphaTest != 0 && texColor.a < 0.5) discard; + if (colorKeyBlack != 0) { + float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); + if (lum < 0.12) discard; + } +} diff --git a/assets/shaders/character_shadow.frag.spv b/assets/shaders/character_shadow.frag.spv new file mode 100644 index 00000000..d792a329 Binary files /dev/null and b/assets/shaders/character_shadow.frag.spv differ diff --git a/assets/shaders/character_shadow.vert.glsl b/assets/shaders/character_shadow.vert.glsl new file mode 100644 index 00000000..b48a48d9 --- /dev/null +++ b/assets/shaders/character_shadow.vert.glsl @@ -0,0 +1,27 @@ +#version 450 + +layout(push_constant) uniform Push { + mat4 lightSpaceMatrix; + mat4 model; +} push; + +layout(set = 2, binding = 0) readonly buffer BoneSSBO { + mat4 bones[]; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec4 aBoneWeights; +layout(location = 2) in ivec4 aBoneIndices; +layout(location = 3) in vec2 aTexCoord; + +layout(location = 0) out vec2 TexCoord; + +void main() { + mat4 skinMat = bones[aBoneIndices.x] * aBoneWeights.x + + bones[aBoneIndices.y] * aBoneWeights.y + + bones[aBoneIndices.z] * aBoneWeights.z + + bones[aBoneIndices.w] * aBoneWeights.w; + vec4 skinnedPos = skinMat * vec4(aPos, 1.0); + TexCoord = aTexCoord; + gl_Position = push.lightSpaceMatrix * push.model * skinnedPos; +} diff --git a/assets/shaders/character_shadow.vert.spv b/assets/shaders/character_shadow.vert.spv new file mode 100644 index 00000000..4a5beee2 Binary files /dev/null and b/assets/shaders/character_shadow.vert.spv differ diff --git a/assets/shaders/charge_dust.frag.glsl b/assets/shaders/charge_dust.frag.glsl new file mode 100644 index 00000000..0c078d51 --- /dev/null +++ b/assets/shaders/charge_dust.frag.glsl @@ -0,0 +1,13 @@ +#version 450 + +layout(location = 0) in float vAlpha; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float alpha = smoothstep(0.5, 0.1, dist) * vAlpha * 0.45; + outColor = vec4(0.65, 0.55, 0.40, alpha); +} diff --git a/assets/shaders/charge_dust.frag.spv b/assets/shaders/charge_dust.frag.spv new file mode 100644 index 00000000..a8f78d8f Binary files /dev/null and b/assets/shaders/charge_dust.frag.spv differ diff --git a/assets/shaders/charge_dust.vert.glsl b/assets/shaders/charge_dust.vert.glsl new file mode 100644 index 00000000..25e0dea8 --- /dev/null +++ b/assets/shaders/charge_dust.vert.glsl @@ -0,0 +1,26 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in float aSize; +layout(location = 2) in float aAlpha; + +layout(location = 0) out float vAlpha; + +void main() { + gl_PointSize = aSize; + vAlpha = aAlpha; + gl_Position = projection * view * vec4(aPos, 1.0); +} diff --git a/assets/shaders/charge_dust.vert.spv b/assets/shaders/charge_dust.vert.spv new file mode 100644 index 00000000..2b84488c Binary files /dev/null and b/assets/shaders/charge_dust.vert.spv differ diff --git a/assets/shaders/charge_ribbon.frag.glsl b/assets/shaders/charge_ribbon.frag.glsl new file mode 100644 index 00000000..417feed1 --- /dev/null +++ b/assets/shaders/charge_ribbon.frag.glsl @@ -0,0 +1,16 @@ +#version 450 + +layout(location = 0) in float vAlpha; +layout(location = 1) in float vHeat; +layout(location = 2) in float vHeight; + +layout(location = 0) out vec4 outColor; + +void main() { + vec3 top = vec3(1.0, 0.2, 0.0); + vec3 mid = vec3(1.0, 0.5, 0.0); + vec3 color = mix(mid, top, vHeight); + color = mix(color, vec3(1.0, 0.8, 0.3), vHeat * 0.5); + float alpha = vAlpha * smoothstep(0.0, 0.3, vHeight); + outColor = vec4(color, alpha); +} diff --git a/assets/shaders/charge_ribbon.frag.spv b/assets/shaders/charge_ribbon.frag.spv new file mode 100644 index 00000000..0063155e Binary files /dev/null and b/assets/shaders/charge_ribbon.frag.spv differ diff --git a/assets/shaders/charge_ribbon.vert.glsl b/assets/shaders/charge_ribbon.vert.glsl new file mode 100644 index 00000000..d50f947c --- /dev/null +++ b/assets/shaders/charge_ribbon.vert.glsl @@ -0,0 +1,30 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in float aAlpha; +layout(location = 2) in float aHeat; +layout(location = 3) in float aHeight; + +layout(location = 0) out float vAlpha; +layout(location = 1) out float vHeat; +layout(location = 2) out float vHeight; + +void main() { + vAlpha = aAlpha; + vHeat = aHeat; + vHeight = aHeight; + gl_Position = projection * view * vec4(aPos, 1.0); +} diff --git a/assets/shaders/charge_ribbon.vert.spv b/assets/shaders/charge_ribbon.vert.spv new file mode 100644 index 00000000..d037bf55 Binary files /dev/null and b/assets/shaders/charge_ribbon.vert.spv differ diff --git a/assets/shaders/clouds.frag.glsl b/assets/shaders/clouds.frag.glsl new file mode 100644 index 00000000..1e443a2f --- /dev/null +++ b/assets/shaders/clouds.frag.glsl @@ -0,0 +1,104 @@ +#version 450 + +layout(push_constant) uniform Push { + vec4 cloudColor; // xyz = DBC-derived base cloud color, w = unused + vec4 sunDirDensity; // xyz = sun direction, w = density + vec4 windAndLight; // x = windOffset, y = sunIntensity, z = ambient, w = unused +} push; + +layout(location = 0) in vec3 vWorldDir; + +layout(location = 0) out vec4 outColor; + +// --- Gradient noise (smoother than hash-based) --- +vec2 hash2(vec2 p) { + p = vec2(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3))); + return fract(sin(p) * 43758.5453); +} + +float gradientNoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + + // Quintic interpolation for smoother results + vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + + float a = dot(hash2(i + vec2(0.0, 0.0)) * 2.0 - 1.0, f - vec2(0.0, 0.0)); + float b = dot(hash2(i + vec2(1.0, 0.0)) * 2.0 - 1.0, f - vec2(1.0, 0.0)); + float c = dot(hash2(i + vec2(0.0, 1.0)) * 2.0 - 1.0, f - vec2(0.0, 1.0)); + float d = dot(hash2(i + vec2(1.0, 1.0)) * 2.0 - 1.0, f - vec2(1.0, 1.0)); + + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) * 0.5 + 0.5; +} + +float fbm(vec2 p) { + float val = 0.0; + float amp = 0.5; + for (int i = 0; i < 6; i++) { + val += amp * gradientNoise(p); + p *= 2.0; + amp *= 0.5; + } + return val; +} + +void main() { + vec3 dir = normalize(vWorldDir); + float altitude = dir.z; + if (altitude < 0.0) discard; + + vec3 sunDir = push.sunDirDensity.xyz; + float density = push.sunDirDensity.w; + float windOffset = push.windAndLight.x; + float sunIntensity = push.windAndLight.y; + float ambient = push.windAndLight.z; + + vec2 uv = dir.xy / (altitude + 0.001); + uv += windOffset; + + // --- 6-octave FBM for cloud shape --- + float cloud1 = fbm(uv * 0.8); + float cloud2 = fbm(uv * 1.6 + 5.0); + float cloud = cloud1 * 0.7 + cloud2 * 0.3; + + // Coverage control: base coverage with detail erosion + float baseCoverage = smoothstep(0.30, 0.55, cloud); + float detailErosion = gradientNoise(uv * 4.0); + cloud = baseCoverage * smoothstep(0.2, 0.5, detailErosion); + cloud *= density; + + // Horizon fade + float horizonFade = smoothstep(0.0, 0.15, altitude); + cloud *= horizonFade; + + if (cloud < 0.01) discard; + + // --- Sun lighting on clouds --- + // Sun dot product for view-relative brightness + float sunDot = max(dot(vec3(0.0, 0.0, 1.0), sunDir), 0.0); + + // Self-shadowing: sample noise offset toward sun direction, darken if occluded + float lightSample = fbm((uv + sunDir.xy * 0.05) * 0.8); + float shadow = smoothstep(0.3, 0.7, lightSample); + + // Base lit color: mix dark (shadow) and bright (sunlit) based on shadow and sun + vec3 baseColor = push.cloudColor.rgb; + vec3 shadowColor = baseColor * (ambient * 0.8); + vec3 litColor = baseColor * (ambient + sunIntensity * 0.6); + vec3 cloudRgb = mix(shadowColor, litColor, shadow * sunDot); + + // Add ambient fill so clouds aren't too dark + cloudRgb = mix(baseColor * ambient, cloudRgb, 0.7 + 0.3 * sunIntensity); + + // --- Silver lining effect at cloud edges --- + float edgeLight = smoothstep(0.0, 0.3, cloud) * (1.0 - smoothstep(0.3, 0.8, cloud)); + cloudRgb += vec3(1.0, 0.95, 0.9) * edgeLight * sunDot * sunIntensity * 0.4; + + // --- Edge softness for alpha --- + float edgeSoftness = smoothstep(0.0, 0.3, cloud); + float alpha = cloud * edgeSoftness; + + if (alpha < 0.01) discard; + outColor = vec4(cloudRgb, alpha); +} diff --git a/assets/shaders/clouds.frag.spv b/assets/shaders/clouds.frag.spv new file mode 100644 index 00000000..70b318db Binary files /dev/null and b/assets/shaders/clouds.frag.spv differ diff --git a/assets/shaders/clouds.vert.glsl b/assets/shaders/clouds.vert.glsl new file mode 100644 index 00000000..5ddd612e --- /dev/null +++ b/assets/shaders/clouds.vert.glsl @@ -0,0 +1,25 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; + +layout(location = 0) out vec3 vWorldDir; + +void main() { + vWorldDir = aPos; + mat4 rotView = mat4(mat3(view)); + vec4 pos = projection * rotView * vec4(aPos, 1.0); + gl_Position = pos.xyww; +} diff --git a/assets/shaders/clouds.vert.spv b/assets/shaders/clouds.vert.spv new file mode 100644 index 00000000..cf1b62d9 Binary files /dev/null and b/assets/shaders/clouds.vert.spv differ diff --git a/assets/shaders/lens_flare.frag.glsl b/assets/shaders/lens_flare.frag.glsl new file mode 100644 index 00000000..98b99b5c --- /dev/null +++ b/assets/shaders/lens_flare.frag.glsl @@ -0,0 +1,22 @@ +#version 450 + +layout(push_constant) uniform Push { + vec2 position; + float size; + float aspectRatio; + vec4 color; // rgb + brightness in w +} push; + +layout(location = 0) in vec2 UV; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 center = UV - 0.5; + float dist = length(center); + float alpha = smoothstep(0.5, 0.0, dist); + float glow = exp(-dist * dist * 8.0) * 0.5; + alpha = max(alpha, glow) * push.color.w; + if (alpha < 0.01) discard; + outColor = vec4(push.color.rgb, alpha); +} diff --git a/assets/shaders/lens_flare.frag.spv b/assets/shaders/lens_flare.frag.spv new file mode 100644 index 00000000..1b19ee3a Binary files /dev/null and b/assets/shaders/lens_flare.frag.spv differ diff --git a/assets/shaders/lens_flare.vert.glsl b/assets/shaders/lens_flare.vert.glsl new file mode 100644 index 00000000..75dc772a --- /dev/null +++ b/assets/shaders/lens_flare.vert.glsl @@ -0,0 +1,19 @@ +#version 450 + +layout(push_constant) uniform Push { + vec2 position; + float size; + float aspectRatio; +} push; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; + +layout(location = 0) out vec2 UV; + +void main() { + UV = aUV; + vec2 scaled = aPos * push.size; + scaled.x /= push.aspectRatio; + gl_Position = vec4(scaled + push.position, 0.0, 1.0); +} diff --git a/assets/shaders/lens_flare.vert.spv b/assets/shaders/lens_flare.vert.spv new file mode 100644 index 00000000..f1b0dd91 Binary files /dev/null and b/assets/shaders/lens_flare.vert.spv differ diff --git a/assets/shaders/lightning_bolt.frag.glsl b/assets/shaders/lightning_bolt.frag.glsl new file mode 100644 index 00000000..4f63bfc1 --- /dev/null +++ b/assets/shaders/lightning_bolt.frag.glsl @@ -0,0 +1,10 @@ +#version 450 + +layout(location = 0) in float vBrightness; + +layout(location = 0) out vec4 outColor; + +void main() { + vec3 color = mix(vec3(0.6, 0.8, 1.0), vec3(1.0), vBrightness * 0.5); + outColor = vec4(color, vBrightness); +} diff --git a/assets/shaders/lightning_bolt.frag.spv b/assets/shaders/lightning_bolt.frag.spv new file mode 100644 index 00000000..0093886c Binary files /dev/null and b/assets/shaders/lightning_bolt.frag.spv differ diff --git a/assets/shaders/lightning_bolt.vert.glsl b/assets/shaders/lightning_bolt.vert.glsl new file mode 100644 index 00000000..7cc2d045 --- /dev/null +++ b/assets/shaders/lightning_bolt.vert.glsl @@ -0,0 +1,27 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + float brightness; +} push; + +layout(location = 0) in vec3 aPos; + +layout(location = 0) out float vBrightness; + +void main() { + vBrightness = push.brightness; + gl_Position = projection * view * vec4(aPos, 1.0); +} diff --git a/assets/shaders/lightning_bolt.vert.spv b/assets/shaders/lightning_bolt.vert.spv new file mode 100644 index 00000000..6dc9f545 Binary files /dev/null and b/assets/shaders/lightning_bolt.vert.spv differ diff --git a/assets/shaders/lightning_flash.frag.glsl b/assets/shaders/lightning_flash.frag.glsl new file mode 100644 index 00000000..01ebcdea --- /dev/null +++ b/assets/shaders/lightning_flash.frag.glsl @@ -0,0 +1,11 @@ +#version 450 + +layout(push_constant) uniform Push { + float intensity; +} push; + +layout(location = 0) out vec4 outColor; + +void main() { + outColor = vec4(1.0, 1.0, 1.0, push.intensity * 0.6); +} diff --git a/assets/shaders/lightning_flash.frag.spv b/assets/shaders/lightning_flash.frag.spv new file mode 100644 index 00000000..048485a7 Binary files /dev/null and b/assets/shaders/lightning_flash.frag.spv differ diff --git a/assets/shaders/lightning_flash.vert.glsl b/assets/shaders/lightning_flash.vert.glsl new file mode 100644 index 00000000..b748774b --- /dev/null +++ b/assets/shaders/lightning_flash.vert.glsl @@ -0,0 +1,7 @@ +#version 450 + +layout(location = 0) in vec2 aPos; + +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +} diff --git a/assets/shaders/lightning_flash.vert.spv b/assets/shaders/lightning_flash.vert.spv new file mode 100644 index 00000000..00341bee Binary files /dev/null and b/assets/shaders/lightning_flash.vert.spv differ diff --git a/assets/shaders/m2.frag.glsl b/assets/shaders/m2.frag.glsl new file mode 100644 index 00000000..36b0fc2c --- /dev/null +++ b/assets/shaders/m2.frag.glsl @@ -0,0 +1,190 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(set = 1, binding = 2) uniform M2Material { + int hasTexture; + int alphaTest; + int colorKeyBlack; + float colorKeyThreshold; + int unlit; + int blendMode; + float fadeAlpha; + float interiorDarken; + float specularIntensity; +}; + +layout(set = 0, binding = 1) uniform sampler2DShadow uShadowMap; + +layout(location = 0) in vec3 FragPos; +layout(location = 1) in vec3 Normal; +layout(location = 2) in vec2 TexCoord; +layout(location = 3) flat in vec3 InstanceOrigin; +layout(location = 4) in float ModelHeight; + +layout(location = 0) out vec4 outColor; + +const float SHADOW_TEXEL = 1.0 / 4096.0; + +float sampleShadowPCF(sampler2DShadow smap, vec3 coords) { + float shadow = 0.0; + for (int x = -1; x <= 1; ++x) { + for (int y = -1; y <= 1; ++y) { + shadow += texture(smap, vec3(coords.xy + vec2(x, y) * SHADOW_TEXEL, coords.z)); + } + } + return shadow / 9.0; +} + +// 4x4 Bayer dither matrix (normalized to 0..1) +float bayerDither4x4(ivec2 p) { + int idx = (p.x & 3) + (p.y & 3) * 4; + float m[16] = float[16]( + 0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0, + 12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0, + 3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0, + 15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0 + ); + return m[idx]; +} + +void main() { + vec4 texColor = hasTexture != 0 ? texture(uTexture, TexCoord) : vec4(1.0); + + bool isFoliage = (alphaTest == 2); + + // Fix DXT fringe: transparent edge texels have garbage (black) RGB. + // At low alpha the original RGB is untrustworthy β€” replace with the + // averaged color from nearby opaque texels (high mip). The lower + // the alpha the more we distrust the original color. + if (alphaTest != 0 && texColor.a > 0.01 && texColor.a < 1.0) { + vec3 mipColor = textureLod(uTexture, TexCoord, 4.0).rgb; + // trust = 0 at alpha 0, trust = 1 at alpha ~0.9 + float trust = smoothstep(0.0, 0.9, texColor.a); + texColor.rgb = mix(mipColor, texColor.rgb, trust); + } + + float alphaCutoff = 0.5; + if (alphaTest == 2) { + alphaCutoff = 0.4; + } else if (alphaTest == 3) { + alphaCutoff = 0.25; + } else if (alphaTest != 0) { + alphaCutoff = 0.4; + } + if (alphaTest != 0 && texColor.a < alphaCutoff) { + discard; + } + if (colorKeyBlack != 0) { + float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); + if (lum < colorKeyThreshold) discard; + } + if (blendMode == 1 && texColor.a < 0.004) discard; + + // Per-instance color variation (foliage only) + if (isFoliage) { + float hash = fract(sin(dot(InstanceOrigin.xy, vec2(127.1, 311.7))) * 43758.5453); + float hueShiftR = 1.0 + (hash - 0.5) * 0.16; // Β±8% red + float hueShiftB = 1.0 + (fract(hash * 7.13) - 0.5) * 0.16; // Β±8% blue + float brightness = 0.85 + hash * 0.30; // 85–115% + texColor.rgb *= vec3(hueShiftR, 1.0, hueShiftB) * brightness; + } + + vec3 norm = normalize(Normal); + bool foliageTwoSided = (alphaTest == 2); + if (!foliageTwoSided && !gl_FrontFacing) norm = -norm; + + // Detail normal perturbation (foliage only) β€” UV-based only so wind doesn't cause flicker + if (isFoliage) { + float nx = sin(TexCoord.x * 12.0 + TexCoord.y * 5.3) * 0.10; + float ny = sin(TexCoord.y * 14.0 + TexCoord.x * 4.7) * 0.10; + norm = normalize(norm + vec3(nx, ny, 0.0)); + } + + vec3 ldir = normalize(-lightDir.xyz); + float nDotL = dot(norm, ldir); + float diff = foliageTwoSided ? abs(nDotL) : max(nDotL, 0.0); + + vec3 result; + if (unlit != 0) { + result = texColor.rgb; + } else { + vec3 viewDir = normalize(viewPos.xyz - FragPos); + + float spec = 0.0; + float shadow = 1.0; + if (!isFoliage) { + vec3 halfDir = normalize(ldir + viewDir); + spec = pow(max(dot(norm, halfDir), 0.0), 32.0) * specularIntensity; + } + + if (shadowParams.x > 0.5) { + float normalOffset = SHADOW_TEXEL * 2.0 * (1.0 - abs(dot(norm, ldir))); + vec3 biasedPos = FragPos + norm * normalOffset; + vec4 lsPos = lightSpaceMatrix * vec4(biasedPos, 1.0); + vec3 proj = lsPos.xyz / lsPos.w; + proj.xy = proj.xy * 0.5 + 0.5; + if (proj.x >= 0.0 && proj.x <= 1.0 && + proj.y >= 0.0 && proj.y <= 1.0 && + proj.z >= 0.0 && proj.z <= 1.0) { + float bias = max(0.0005 * (1.0 - abs(dot(norm, ldir))), 0.00005); + shadow = sampleShadowPCF(uShadowMap, vec3(proj.xy, proj.z - bias)); + } + shadow = mix(1.0, shadow, shadowParams.y); + } + + // Leaf subsurface scattering (foliage only) β€” uses stable normal, no FragPos dependency + vec3 sss = vec3(0.0); + if (isFoliage) { + float backLit = max(-nDotL, 0.0); + float viewDotLight = max(dot(viewDir, -ldir), 0.0); + float sssAmount = backLit * pow(viewDotLight, 4.0) * 0.35 * texColor.a; + sss = sssAmount * vec3(1.0, 0.9, 0.5) * lightColor.rgb; + } + + result = ambientColor.rgb * texColor.rgb + + shadow * (diff * lightColor.rgb * texColor.rgb + spec * lightColor.rgb) + + sss; + + if (interiorDarken > 0.0) { + result *= mix(1.0, 0.5, interiorDarken); + } + } + + // Canopy ambient occlusion (foliage only) + if (isFoliage) { + float normalizedHeight = clamp(ModelHeight / 18.0, 0.0, 1.0); + float aoFactor = mix(0.55, 1.0, smoothstep(0.0, 0.6, normalizedHeight)); + result *= aoFactor; + } + + float dist = length(viewPos.xyz - FragPos); + float fogFactor = clamp((fogParams.y - dist) / (fogParams.y - fogParams.x), 0.0, 1.0); + result = mix(fogColor.rgb, result, fogFactor); + + float outAlpha = texColor.a * fadeAlpha; + // Cutout materials should not remain partially transparent after discard, + // otherwise foliage cards look view-dependent. + if (alphaTest != 0 || colorKeyBlack != 0) { + outAlpha = fadeAlpha; + } + // Foliage cutout should stay opaque after alpha discard to avoid + // view-angle translucency artifacts. + if (alphaTest == 2 || alphaTest == 3) { + outAlpha = 1.0 * fadeAlpha; + } + outColor = vec4(result, outAlpha); +} diff --git a/assets/shaders/m2.frag.spv b/assets/shaders/m2.frag.spv new file mode 100644 index 00000000..7deda700 Binary files /dev/null and b/assets/shaders/m2.frag.spv differ diff --git a/assets/shaders/m2.vert.glsl b/assets/shaders/m2.vert.glsl new file mode 100644 index 00000000..7b0f9451 --- /dev/null +++ b/assets/shaders/m2.vert.glsl @@ -0,0 +1,91 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; + vec2 uvOffset; + int texCoordSet; + int useBones; + int isFoliage; +} push; + +layout(set = 2, binding = 0) readonly buffer BoneSSBO { + mat4 bones[]; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; +layout(location = 3) in vec4 aBoneWeights; +layout(location = 4) in vec4 aBoneIndicesF; +layout(location = 5) in vec2 aTexCoord2; + +layout(location = 0) out vec3 FragPos; +layout(location = 1) out vec3 Normal; +layout(location = 2) out vec2 TexCoord; +layout(location = 3) flat out vec3 InstanceOrigin; +layout(location = 4) out float ModelHeight; + +void main() { + vec4 pos = vec4(aPos, 1.0); + vec4 norm = vec4(aNormal, 0.0); + + if (push.useBones != 0) { + ivec4 bi = ivec4(aBoneIndicesF); + mat4 skinMat = bones[bi.x] * aBoneWeights.x + + bones[bi.y] * aBoneWeights.y + + bones[bi.z] * aBoneWeights.z + + bones[bi.w] * aBoneWeights.w; + pos = skinMat * pos; + norm = skinMat * norm; + } + + // Wind animation for foliage + if (push.isFoliage != 0) { + float windTime = fogParams.z; + vec3 worldRef = push.model[3].xyz; + float heightFactor = clamp(pos.z / 20.0, 0.0, 1.0); + heightFactor *= heightFactor; // quadratic β€” base stays grounded + + // Layer 1: Trunk sway β€” slow, large amplitude + float trunkPhase = windTime * 0.8 + dot(worldRef.xy, vec2(0.1, 0.13)); + float trunkSwayX = sin(trunkPhase) * 0.35 * heightFactor; + float trunkSwayY = cos(trunkPhase * 0.7) * 0.25 * heightFactor; + + // Layer 2: Branch sway β€” medium frequency, per-branch phase + float branchPhase = windTime * 1.7 + dot(worldRef.xy, vec2(0.37, 0.71)); + float branchSwayX = sin(branchPhase + pos.y * 0.4) * 0.15 * heightFactor; + float branchSwayY = cos(branchPhase * 1.1 + pos.x * 0.3) * 0.12 * heightFactor; + + // Layer 3: Leaf flutter β€” fast, small amplitude, per-vertex + float leafPhase = windTime * 4.5 + dot(aPos, vec3(1.7, 2.3, 0.9)); + float leafFlutterX = sin(leafPhase) * 0.06 * heightFactor; + float leafFlutterY = cos(leafPhase * 1.3) * 0.05 * heightFactor; + + pos.x += trunkSwayX + branchSwayX + leafFlutterX; + pos.y += trunkSwayY + branchSwayY + leafFlutterY; + } + + vec4 worldPos = push.model * pos; + FragPos = worldPos.xyz; + Normal = mat3(push.model) * norm.xyz; + + TexCoord = (push.texCoordSet == 1 ? aTexCoord2 : aTexCoord) + push.uvOffset; + + InstanceOrigin = push.model[3].xyz; + ModelHeight = pos.z; + + gl_Position = projection * view * worldPos; +} diff --git a/assets/shaders/m2.vert.spv b/assets/shaders/m2.vert.spv new file mode 100644 index 00000000..9d5411d3 Binary files /dev/null and b/assets/shaders/m2.vert.spv differ diff --git a/assets/shaders/m2_particle.frag.glsl b/assets/shaders/m2_particle.frag.glsl new file mode 100644 index 00000000..f91a3fb7 --- /dev/null +++ b/assets/shaders/m2_particle.frag.glsl @@ -0,0 +1,30 @@ +#version 450 + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(push_constant) uniform Push { + vec2 tileCount; + int alphaKey; +} push; + +layout(location = 0) in vec4 vColor; +layout(location = 1) in float vTile; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord; + float tile = floor(vTile); + float tx = mod(tile, push.tileCount.x); + float ty = floor(tile / push.tileCount.x); + vec2 uv = (vec2(tx, ty) + p) / push.tileCount; + vec4 texColor = texture(uTexture, uv); + + if (push.alphaKey != 0) { + float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); + if (lum < 0.05) discard; + } + + float edge = smoothstep(0.5, 0.4, length(p - 0.5)); + outColor = texColor * vColor * vec4(vec3(1.0), edge); +} diff --git a/assets/shaders/m2_particle.frag.spv b/assets/shaders/m2_particle.frag.spv new file mode 100644 index 00000000..ee8899bc Binary files /dev/null and b/assets/shaders/m2_particle.frag.spv differ diff --git a/assets/shaders/m2_particle.vert.glsl b/assets/shaders/m2_particle.vert.glsl new file mode 100644 index 00000000..61f4d141 --- /dev/null +++ b/assets/shaders/m2_particle.vert.glsl @@ -0,0 +1,31 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec4 aColor; +layout(location = 2) in float aSize; +layout(location = 3) in float aTile; + +layout(location = 0) out vec4 vColor; +layout(location = 1) out float vTile; + +void main() { + vec4 viewPos4 = view * vec4(aPos, 1.0); + float dist = -viewPos4.z; + gl_PointSize = clamp(aSize * 500.0 / max(dist, 1.0), 1.0, 128.0); + vColor = aColor; + vTile = aTile; + gl_Position = projection * viewPos4; +} diff --git a/assets/shaders/m2_particle.vert.spv b/assets/shaders/m2_particle.vert.spv new file mode 100644 index 00000000..eee59cf8 Binary files /dev/null and b/assets/shaders/m2_particle.vert.spv differ diff --git a/assets/shaders/m2_smoke.frag.glsl b/assets/shaders/m2_smoke.frag.glsl new file mode 100644 index 00000000..a9b150ec --- /dev/null +++ b/assets/shaders/m2_smoke.frag.glsl @@ -0,0 +1,25 @@ +#version 450 + +layout(location = 0) in float vLifeRatio; +layout(location = 1) in float vIsSpark; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + + if (vIsSpark > 0.5) { + float glow = smoothstep(0.5, 0.0, dist); + float life = 1.0 - vLifeRatio; + vec3 color = mix(vec3(1.0, 0.6, 0.1), vec3(1.0, 0.2, 0.0), vLifeRatio); + outColor = vec4(color * glow, glow * life); + } else { + float edge = smoothstep(0.5, 0.3, dist); + float fadeIn = smoothstep(0.0, 0.2, vLifeRatio); + float fadeOut = 1.0 - smoothstep(0.6, 1.0, vLifeRatio); + float alpha = edge * fadeIn * fadeOut * 0.4; + outColor = vec4(vec3(0.5), alpha); + } +} diff --git a/assets/shaders/m2_smoke.frag.spv b/assets/shaders/m2_smoke.frag.spv new file mode 100644 index 00000000..b6ff6c1b Binary files /dev/null and b/assets/shaders/m2_smoke.frag.spv differ diff --git a/assets/shaders/m2_smoke.vert.glsl b/assets/shaders/m2_smoke.vert.glsl new file mode 100644 index 00000000..6eed1ea0 --- /dev/null +++ b/assets/shaders/m2_smoke.vert.glsl @@ -0,0 +1,36 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + float screenHeight; +} push; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in float aLifeRatio; +layout(location = 2) in float aSize; +layout(location = 3) in float aIsSpark; + +layout(location = 0) out float vLifeRatio; +layout(location = 1) out float vIsSpark; + +void main() { + vec4 viewPos4 = view * vec4(aPos, 1.0); + float dist = -viewPos4.z; + float scale = aIsSpark > 0.5 ? 0.12 : 0.3; + gl_PointSize = clamp(aSize * scale * push.screenHeight / max(dist, 1.0), 1.0, 64.0); + vLifeRatio = aLifeRatio; + vIsSpark = aIsSpark; + gl_Position = projection * viewPos4; +} diff --git a/assets/shaders/m2_smoke.vert.spv b/assets/shaders/m2_smoke.vert.spv new file mode 100644 index 00000000..a50b5168 Binary files /dev/null and b/assets/shaders/m2_smoke.vert.spv differ diff --git a/assets/shaders/minimap_display.frag.glsl b/assets/shaders/minimap_display.frag.glsl new file mode 100644 index 00000000..ff0477ae --- /dev/null +++ b/assets/shaders/minimap_display.frag.glsl @@ -0,0 +1,68 @@ +#version 450 + +layout(set = 0, binding = 0) uniform sampler2D uComposite; + +layout(push_constant) uniform Push { + vec4 rect; + vec2 playerUV; + float rotation; + float arrowRotation; + float zoomRadius; + int squareShape; + float opacity; +} push; + +layout(location = 0) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +float cross2d(vec2 a, vec2 b) { + return a.x * b.y - a.y * b.x; +} + +bool pointInTriangle(vec2 p, vec2 a, vec2 b, vec2 c) { + float d1 = cross2d(b - a, p - a); + float d2 = cross2d(c - b, p - b); + float d3 = cross2d(a - c, p - c); + bool hasNeg = (d1 < 0.0) || (d2 < 0.0) || (d3 < 0.0); + bool hasPos = (d1 > 0.0) || (d2 > 0.0) || (d3 > 0.0); + return !(hasNeg && hasPos); +} + +void main() { + vec2 center = TexCoord - 0.5; + float dist = length(center); + + if (push.squareShape == 0) { + if (dist > 0.5) discard; + } + + float cs = cos(push.rotation); + float sn = sin(push.rotation); + vec2 rotated = vec2(center.x * cs - center.y * sn, center.x * sn + center.y * cs); + vec2 mapUV = push.playerUV + rotated * push.zoomRadius * 2.0; + + vec4 mapColor = texture(uComposite, mapUV); + + // Player arrow + float acs = cos(push.arrowRotation); + float asn = sin(push.arrowRotation); + vec2 ac = center; + vec2 arrowPos = vec2(ac.x * acs - ac.y * asn, ac.x * asn + ac.y * acs); + + vec2 tip = vec2(0.0, -0.04); + vec2 left = vec2(-0.02, 0.02); + vec2 right = vec2(0.02, 0.02); + + if (pointInTriangle(arrowPos, tip, left, right)) { + mapColor = vec4(1.0, 0.8, 0.0, 1.0); + } + + // Dark border ring + float border = smoothstep(0.48, 0.5, dist); + if (push.squareShape == 0) { + mapColor.rgb *= 1.0 - border * 0.7; + } + + outColor = vec4(mapColor.rgb, mapColor.a * push.opacity); +} diff --git a/assets/shaders/minimap_display.frag.spv b/assets/shaders/minimap_display.frag.spv new file mode 100644 index 00000000..5fd812cd Binary files /dev/null and b/assets/shaders/minimap_display.frag.spv differ diff --git a/assets/shaders/minimap_display.vert.glsl b/assets/shaders/minimap_display.vert.glsl new file mode 100644 index 00000000..4c03e80c --- /dev/null +++ b/assets/shaders/minimap_display.vert.glsl @@ -0,0 +1,16 @@ +#version 450 + +layout(push_constant) uniform Push { + vec4 rect; // x, y, w, h in 0..1 screen space +} push; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; + +layout(location = 0) out vec2 TexCoord; + +void main() { + TexCoord = aUV; + vec2 screenPos = push.rect.xy + aPos * push.rect.zw; + gl_Position = vec4(screenPos * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/assets/shaders/minimap_display.vert.spv b/assets/shaders/minimap_display.vert.spv new file mode 100644 index 00000000..6bab4d92 Binary files /dev/null and b/assets/shaders/minimap_display.vert.spv differ diff --git a/assets/shaders/minimap_tile.frag.glsl b/assets/shaders/minimap_tile.frag.glsl new file mode 100644 index 00000000..04d04c65 --- /dev/null +++ b/assets/shaders/minimap_tile.frag.glsl @@ -0,0 +1,11 @@ +#version 450 + +layout(set = 0, binding = 0) uniform sampler2D uTileTexture; + +layout(location = 0) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +void main() { + outColor = texture(uTileTexture, vec2(TexCoord.y, TexCoord.x)); +} diff --git a/assets/shaders/minimap_tile.frag.spv b/assets/shaders/minimap_tile.frag.spv new file mode 100644 index 00000000..37106faf Binary files /dev/null and b/assets/shaders/minimap_tile.frag.spv differ diff --git a/assets/shaders/minimap_tile.vert.glsl b/assets/shaders/minimap_tile.vert.glsl new file mode 100644 index 00000000..44c41d34 --- /dev/null +++ b/assets/shaders/minimap_tile.vert.glsl @@ -0,0 +1,17 @@ +#version 450 + +layout(push_constant) uniform Push { + vec2 gridOffset; +} push; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; + +layout(location = 0) out vec2 TexCoord; + +void main() { + TexCoord = aUV; + vec2 pos = (aPos + push.gridOffset) / 3.0; + pos = pos * 2.0 - 1.0; + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/assets/shaders/minimap_tile.vert.spv b/assets/shaders/minimap_tile.vert.spv new file mode 100644 index 00000000..bb53f1f0 Binary files /dev/null and b/assets/shaders/minimap_tile.vert.spv differ diff --git a/assets/shaders/mount_dust.frag.glsl b/assets/shaders/mount_dust.frag.glsl new file mode 100644 index 00000000..699e8d20 --- /dev/null +++ b/assets/shaders/mount_dust.frag.glsl @@ -0,0 +1,13 @@ +#version 450 + +layout(location = 0) in float vAlpha; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float alpha = smoothstep(0.5, 0.1, dist) * vAlpha * 0.4; + outColor = vec4(0.7, 0.65, 0.55, alpha); +} diff --git a/assets/shaders/mount_dust.frag.spv b/assets/shaders/mount_dust.frag.spv new file mode 100644 index 00000000..c68b479f Binary files /dev/null and b/assets/shaders/mount_dust.frag.spv differ diff --git a/assets/shaders/mount_dust.vert.glsl b/assets/shaders/mount_dust.vert.glsl new file mode 100644 index 00000000..25e0dea8 --- /dev/null +++ b/assets/shaders/mount_dust.vert.glsl @@ -0,0 +1,26 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in float aSize; +layout(location = 2) in float aAlpha; + +layout(location = 0) out float vAlpha; + +void main() { + gl_PointSize = aSize; + vAlpha = aAlpha; + gl_Position = projection * view * vec4(aPos, 1.0); +} diff --git a/assets/shaders/mount_dust.vert.spv b/assets/shaders/mount_dust.vert.spv new file mode 100644 index 00000000..2b84488c Binary files /dev/null and b/assets/shaders/mount_dust.vert.spv differ diff --git a/assets/shaders/overlay.frag.glsl b/assets/shaders/overlay.frag.glsl new file mode 100644 index 00000000..2dd4116d --- /dev/null +++ b/assets/shaders/overlay.frag.glsl @@ -0,0 +1,14 @@ +#version 450 + +// Full-screen color overlay (e.g. underwater tint). +// Uses postprocess.vert.glsl as vertex shader (fullscreen triangle, no vertex input). + +layout(push_constant) uniform Push { + vec4 color; // rgb = tint color, a = opacity +} push; + +layout(location = 0) out vec4 outColor; + +void main() { + outColor = push.color; +} diff --git a/assets/shaders/overlay.frag.spv b/assets/shaders/overlay.frag.spv new file mode 100644 index 00000000..da839bab Binary files /dev/null and b/assets/shaders/overlay.frag.spv differ diff --git a/assets/shaders/postprocess.frag.glsl b/assets/shaders/postprocess.frag.glsl new file mode 100644 index 00000000..ad47ac8a --- /dev/null +++ b/assets/shaders/postprocess.frag.glsl @@ -0,0 +1,20 @@ +#version 450 + +layout(set = 0, binding = 0) uniform sampler2D uScene; + +layout(location = 0) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +void main() { + vec3 hdr = texture(uScene, TexCoord).rgb; + // Shoulder tone map + vec3 mapped = hdr; + for (int i = 0; i < 3; i++) { + if (mapped[i] > 0.9) { + float excess = mapped[i] - 0.9; + mapped[i] = 0.9 + 0.1 * excess / (excess + 0.1); + } + } + outColor = vec4(mapped, 1.0); +} diff --git a/assets/shaders/postprocess.frag.spv b/assets/shaders/postprocess.frag.spv new file mode 100644 index 00000000..20dfedd1 Binary files /dev/null and b/assets/shaders/postprocess.frag.spv differ diff --git a/assets/shaders/postprocess.vert.glsl b/assets/shaders/postprocess.vert.glsl new file mode 100644 index 00000000..aa78b1b5 --- /dev/null +++ b/assets/shaders/postprocess.vert.glsl @@ -0,0 +1,10 @@ +#version 450 + +layout(location = 0) out vec2 TexCoord; + +void main() { + // Fullscreen triangle trick: 3 vertices, no vertex buffer + TexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(TexCoord * 2.0 - 1.0, 0.0, 1.0); + TexCoord.y = 1.0 - TexCoord.y; // flip Y for Vulkan +} diff --git a/assets/shaders/postprocess.vert.spv b/assets/shaders/postprocess.vert.spv new file mode 100644 index 00000000..afc10472 Binary files /dev/null and b/assets/shaders/postprocess.vert.spv differ diff --git a/assets/shaders/quest_marker.frag.glsl b/assets/shaders/quest_marker.frag.glsl new file mode 100644 index 00000000..020b625d --- /dev/null +++ b/assets/shaders/quest_marker.frag.glsl @@ -0,0 +1,18 @@ +#version 450 + +layout(set = 1, binding = 0) uniform sampler2D markerTexture; + +layout(push_constant) uniform Push { + mat4 model; + float alpha; +} push; + +layout(location = 0) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +void main() { + vec4 texColor = texture(markerTexture, TexCoord); + if (texColor.a < 0.1) discard; + outColor = vec4(texColor.rgb, texColor.a * push.alpha); +} diff --git a/assets/shaders/quest_marker.frag.spv b/assets/shaders/quest_marker.frag.spv new file mode 100644 index 00000000..e947d04c Binary files /dev/null and b/assets/shaders/quest_marker.frag.spv differ diff --git a/assets/shaders/quest_marker.vert.glsl b/assets/shaders/quest_marker.vert.glsl new file mode 100644 index 00000000..8525181a --- /dev/null +++ b/assets/shaders/quest_marker.vert.glsl @@ -0,0 +1,28 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; +} push; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec2 aTexCoord; + +layout(location = 0) out vec2 TexCoord; + +void main() { + TexCoord = aTexCoord; + gl_Position = projection * view * push.model * vec4(aPos, 1.0); +} diff --git a/assets/shaders/quest_marker.vert.spv b/assets/shaders/quest_marker.vert.spv new file mode 100644 index 00000000..f414a916 Binary files /dev/null and b/assets/shaders/quest_marker.vert.spv differ diff --git a/assets/shaders/selection_circle.frag.glsl b/assets/shaders/selection_circle.frag.glsl new file mode 100644 index 00000000..dbddfaff --- /dev/null +++ b/assets/shaders/selection_circle.frag.glsl @@ -0,0 +1,19 @@ +#version 450 + +layout(push_constant) uniform Push { + mat4 mvp; + vec4 color; +} push; + +layout(location = 0) in vec2 vLocalPos; + +layout(location = 0) out vec4 outColor; + +void main() { + float r = length(vLocalPos); + float ring = smoothstep(0.93, 0.97, r) * smoothstep(1.0, 0.97, r); + float inward = (1.0 - smoothstep(0.0, 0.93, r)) * 0.15; + float alpha = max(ring, inward); + if (alpha < 0.01) discard; + outColor = vec4(push.color.rgb, alpha); +} diff --git a/assets/shaders/selection_circle.frag.spv b/assets/shaders/selection_circle.frag.spv new file mode 100644 index 00000000..0cafe9ad Binary files /dev/null and b/assets/shaders/selection_circle.frag.spv differ diff --git a/assets/shaders/selection_circle.vert.glsl b/assets/shaders/selection_circle.vert.glsl new file mode 100644 index 00000000..1edd1b2e --- /dev/null +++ b/assets/shaders/selection_circle.vert.glsl @@ -0,0 +1,14 @@ +#version 450 + +layout(push_constant) uniform Push { + mat4 mvp; +} push; + +layout(location = 0) in vec3 aPos; + +layout(location = 0) out vec2 vLocalPos; + +void main() { + vLocalPos = aPos.xz; + gl_Position = push.mvp * vec4(aPos, 1.0); +} diff --git a/assets/shaders/selection_circle.vert.spv b/assets/shaders/selection_circle.vert.spv new file mode 100644 index 00000000..185d8cc9 Binary files /dev/null and b/assets/shaders/selection_circle.vert.spv differ diff --git a/assets/shaders/shadow.frag.glsl b/assets/shaders/shadow.frag.glsl new file mode 100644 index 00000000..986c5166 --- /dev/null +++ b/assets/shaders/shadow.frag.glsl @@ -0,0 +1,22 @@ +#version 450 + +layout(set = 0, binding = 0) uniform sampler2D uTexture; + +layout(set = 0, binding = 1) uniform ShadowParams { + int useBones; + int useTexture; + int alphaTest; + int foliageSway; + float windTime; + float foliageMotionDamp; +}; + +layout(location = 0) in vec2 TexCoord; +layout(location = 1) in vec3 WorldPos; + +void main() { + if (useTexture != 0) { + vec4 texColor = textureLod(uTexture, TexCoord, 0.0); + if (alphaTest != 0 && texColor.a < 0.5) discard; + } +} diff --git a/assets/shaders/shadow.frag.spv b/assets/shaders/shadow.frag.spv new file mode 100644 index 00000000..b14825fc Binary files /dev/null and b/assets/shaders/shadow.frag.spv differ diff --git a/assets/shaders/shadow.vert.glsl b/assets/shaders/shadow.vert.glsl new file mode 100644 index 00000000..f8c64618 --- /dev/null +++ b/assets/shaders/shadow.vert.glsl @@ -0,0 +1,57 @@ +#version 450 + +layout(push_constant) uniform Push { + mat4 lightSpaceMatrix; + mat4 model; +} push; + +layout(set = 0, binding = 1) uniform ShadowParams { + int useBones; + int useTexture; + int alphaTest; + int foliageSway; + float windTime; + float foliageMotionDamp; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec2 aTexCoord; +layout(location = 2) in vec4 aBoneWeights; +layout(location = 3) in vec4 aBoneIndicesF; + +layout(location = 0) out vec2 TexCoord; +layout(location = 1) out vec3 WorldPos; + +void main() { + vec4 pos = vec4(aPos, 1.0); + + // Wind vertex displacement for foliage (matches m2.vert.glsl) + if (foliageSway != 0) { + vec3 worldRef = push.model[3].xyz; + float heightFactor = clamp(pos.z / 20.0, 0.0, 1.0); + heightFactor *= heightFactor; + + // Layer 1: Trunk sway + float trunkPhase = windTime * 0.8 + dot(worldRef.xy, vec2(0.1, 0.13)); + float trunkSwayX = sin(trunkPhase) * 0.35 * heightFactor; + float trunkSwayY = cos(trunkPhase * 0.7) * 0.25 * heightFactor; + + // Layer 2: Branch sway + float branchPhase = windTime * 1.7 + dot(worldRef.xy, vec2(0.37, 0.71)); + float branchSwayX = sin(branchPhase + pos.y * 0.4) * 0.15 * heightFactor; + float branchSwayY = cos(branchPhase * 1.1 + pos.x * 0.3) * 0.12 * heightFactor; + + // Layer 3: Leaf flutter + float leafPhase = windTime * 4.5 + dot(aPos, vec3(1.7, 2.3, 0.9)); + float leafFlutterX = sin(leafPhase) * 0.06 * heightFactor; + float leafFlutterY = cos(leafPhase * 1.3) * 0.05 * heightFactor; + + pos.x += trunkSwayX + branchSwayX + leafFlutterX; + pos.y += trunkSwayY + branchSwayY + leafFlutterY; + } + + vec4 worldPos = push.model * pos; + WorldPos = worldPos.xyz; + TexCoord = aTexCoord; + gl_Position = push.lightSpaceMatrix * worldPos; +} diff --git a/assets/shaders/shadow.vert.spv b/assets/shaders/shadow.vert.spv new file mode 100644 index 00000000..3399c4cc Binary files /dev/null and b/assets/shaders/shadow.vert.spv differ diff --git a/assets/shaders/skybox.frag.glsl b/assets/shaders/skybox.frag.glsl new file mode 100644 index 00000000..3c4709ee --- /dev/null +++ b/assets/shaders/skybox.frag.glsl @@ -0,0 +1,97 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + vec4 zenithColor; // DBC skyTopColor + vec4 midColor; // DBC skyMiddleColor + vec4 horizonColor; // DBC skyBand1Color + vec4 fogColorPush; // DBC skyBand2Color + vec4 sunDirAndTime; // xyz = sun direction, w = timeOfDay +} push; + +layout(location = 0) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +void main() { + // Reconstruct world-space ray direction from screen position. + float ndcX = TexCoord.x * 2.0 - 1.0; + float ndcY = -(TexCoord.y * 2.0 - 1.0); + + vec3 viewDir = vec3(ndcX / projection[0][0], + ndcY / abs(projection[1][1]), + -1.0); + + mat3 invViewRot = transpose(mat3(view)); + vec3 worldDir = normalize(invViewRot * viewDir); + + vec3 sunDir = push.sunDirAndTime.xyz; + float timeOfDay = push.sunDirAndTime.w; + + // Elevation: +1 = zenith, 0 = horizon, -1 = nadir + float elev = worldDir.z; + float elevClamped = clamp(elev, 0.0, 1.0); + + // --- 3-band sky gradient using DBC colors --- + // Zenith dominates upper sky, mid color fills the middle, + // horizon band at the bottom with a thin fog fringe. + vec3 sky; + if (elevClamped > 0.4) { + // Upper sky: mid -> zenith + float t = (elevClamped - 0.4) / 0.6; + sky = mix(push.midColor.rgb, push.zenithColor.rgb, t); + } else if (elevClamped > 0.05) { + // Lower sky: horizon -> mid (wide band) + float t = (elevClamped - 0.05) / 0.35; + sky = mix(push.horizonColor.rgb, push.midColor.rgb, t); + } else { + // Thin fog fringe right at horizon + float t = elevClamped / 0.05; + sky = mix(push.fogColorPush.rgb, push.horizonColor.rgb, t); + } + + // --- Below-horizon darkening (nadir) --- + if (elev < 0.0) { + float nadirFade = clamp(-elev * 3.0, 0.0, 1.0); + vec3 nadirColor = push.fogColorPush.rgb * 0.3; + sky = mix(push.fogColorPush.rgb, nadirColor, nadirFade); + } + + // --- Rayleigh-like scattering (subtle warm glow near sun) --- + float sunDot = max(dot(worldDir, sunDir), 0.0); + float sunAboveHorizon = clamp(sunDir.z, 0.0, 1.0); + + float rayleighStrength = pow(1.0 - elevClamped, 3.0) * 0.15; + vec3 scatterColor = mix(vec3(0.8, 0.45, 0.15), vec3(0.3, 0.5, 1.0), elevClamped); + sky += scatterColor * rayleighStrength * sunDot * sunAboveHorizon; + + // --- Mie-like forward scatter (sun disk glow) --- + float mieSharp = pow(sunDot, 64.0) * 0.4; + float mieSoft = pow(sunDot, 8.0) * 0.1; + vec3 sunGlowColor = mix(vec3(1.0, 0.85, 0.55), vec3(1.0, 1.0, 0.95), elevClamped); + sky += sunGlowColor * (mieSharp + mieSoft) * sunAboveHorizon; + + // --- Subtle horizon haze --- + float hazeDensity = exp(-elevClamped * 12.0) * 0.06; + sky += push.horizonColor.rgb * hazeDensity * sunAboveHorizon; + + // --- Night: slight moonlight tint --- + if (sunDir.z < 0.0) { + float moonlight = clamp(-sunDir.z * 0.5, 0.0, 0.15); + sky += vec3(0.02, 0.03, 0.08) * moonlight; + } + + outColor = vec4(sky, 1.0); +} diff --git a/assets/shaders/skybox.frag.spv b/assets/shaders/skybox.frag.spv new file mode 100644 index 00000000..29865049 Binary files /dev/null and b/assets/shaders/skybox.frag.spv differ diff --git a/assets/shaders/skybox.vert.glsl b/assets/shaders/skybox.vert.glsl new file mode 100644 index 00000000..d1df7d5a --- /dev/null +++ b/assets/shaders/skybox.vert.glsl @@ -0,0 +1,12 @@ +#version 450 + +// Fullscreen triangle sky β€” no vertex buffer, no mesh. +// Draws 3 vertices covering the entire screen, depth forced to 1.0 (far plane). + +layout(location = 0) out vec2 TexCoord; + +void main() { + // Produces triangle covering NDC [-1,1]Β² with depth = 1.0 (far) + TexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + gl_Position = vec4(TexCoord * 2.0 - 1.0, 1.0, 1.0); +} diff --git a/assets/shaders/skybox.vert.spv b/assets/shaders/skybox.vert.spv new file mode 100644 index 00000000..bc7f0006 Binary files /dev/null and b/assets/shaders/skybox.vert.spv differ diff --git a/assets/shaders/starfield.frag.glsl b/assets/shaders/starfield.frag.glsl new file mode 100644 index 00000000..0927c923 --- /dev/null +++ b/assets/shaders/starfield.frag.glsl @@ -0,0 +1,13 @@ +#version 450 + +layout(location = 0) in float vBrightness; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float alpha = vBrightness * smoothstep(0.5, 0.2, dist); + outColor = vec4(vec3(0.9, 0.95, 1.0) * vBrightness, alpha); +} diff --git a/assets/shaders/starfield.frag.spv b/assets/shaders/starfield.frag.spv new file mode 100644 index 00000000..d572951f Binary files /dev/null and b/assets/shaders/starfield.frag.spv differ diff --git a/assets/shaders/starfield.vert.glsl b/assets/shaders/starfield.vert.glsl new file mode 100644 index 00000000..cb6f67c9 --- /dev/null +++ b/assets/shaders/starfield.vert.glsl @@ -0,0 +1,33 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + float time; + float intensity; +} push; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in float aBrightness; +layout(location = 2) in float aTwinklePhase; + +layout(location = 0) out float vBrightness; + +void main() { + mat4 rotView = mat4(mat3(view)); + float twinkle = 0.7 + 0.3 * sin(push.time * 1.5 + aTwinklePhase); + vBrightness = aBrightness * twinkle * push.intensity; + gl_PointSize = mix(2.0, 4.0, aBrightness); + gl_Position = projection * rotView * vec4(aPos, 1.0); +} diff --git a/assets/shaders/starfield.vert.spv b/assets/shaders/starfield.vert.spv new file mode 100644 index 00000000..1f5bc30c Binary files /dev/null and b/assets/shaders/starfield.vert.spv differ diff --git a/assets/shaders/swim_bubble.frag.glsl b/assets/shaders/swim_bubble.frag.glsl new file mode 100644 index 00000000..6270eb51 --- /dev/null +++ b/assets/shaders/swim_bubble.frag.glsl @@ -0,0 +1,15 @@ +#version 450 + +layout(location = 0) in float vAlpha; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float ring = smoothstep(0.5, 0.4, dist) - smoothstep(0.38, 0.28, dist); + float highlight = smoothstep(0.3, 0.1, length(p - vec2(-0.15, 0.15))) * 0.5; + float alpha = (ring + highlight) * vAlpha; + outColor = vec4(0.8, 0.9, 1.0, alpha); +} diff --git a/assets/shaders/swim_bubble.frag.spv b/assets/shaders/swim_bubble.frag.spv new file mode 100644 index 00000000..a0374c36 Binary files /dev/null and b/assets/shaders/swim_bubble.frag.spv differ diff --git a/assets/shaders/swim_bubble.vert.glsl b/assets/shaders/swim_bubble.vert.glsl new file mode 100644 index 00000000..25e0dea8 --- /dev/null +++ b/assets/shaders/swim_bubble.vert.glsl @@ -0,0 +1,26 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in float aSize; +layout(location = 2) in float aAlpha; + +layout(location = 0) out float vAlpha; + +void main() { + gl_PointSize = aSize; + vAlpha = aAlpha; + gl_Position = projection * view * vec4(aPos, 1.0); +} diff --git a/assets/shaders/swim_bubble.vert.spv b/assets/shaders/swim_bubble.vert.spv new file mode 100644 index 00000000..2b84488c Binary files /dev/null and b/assets/shaders/swim_bubble.vert.spv differ diff --git a/assets/shaders/swim_insect.frag.glsl b/assets/shaders/swim_insect.frag.glsl new file mode 100644 index 00000000..06ab430a --- /dev/null +++ b/assets/shaders/swim_insect.frag.glsl @@ -0,0 +1,14 @@ +#version 450 + +layout(location = 0) in float vAlpha; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float alpha = smoothstep(0.5, 0.05, dist) * vAlpha; + // Dark brown/black insect color + outColor = vec4(0.12, 0.08, 0.05, alpha); +} diff --git a/assets/shaders/swim_insect.frag.spv b/assets/shaders/swim_insect.frag.spv new file mode 100644 index 00000000..6e849c37 Binary files /dev/null and b/assets/shaders/swim_insect.frag.spv differ diff --git a/assets/shaders/swim_ripple.frag.glsl b/assets/shaders/swim_ripple.frag.glsl new file mode 100644 index 00000000..43219998 --- /dev/null +++ b/assets/shaders/swim_ripple.frag.glsl @@ -0,0 +1,13 @@ +#version 450 + +layout(location = 0) in float vAlpha; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float alpha = smoothstep(0.5, 0.1, dist) * vAlpha; + outColor = vec4(0.85, 0.92, 1.0, alpha); +} diff --git a/assets/shaders/swim_ripple.frag.spv b/assets/shaders/swim_ripple.frag.spv new file mode 100644 index 00000000..84a69295 Binary files /dev/null and b/assets/shaders/swim_ripple.frag.spv differ diff --git a/assets/shaders/swim_ripple.vert.glsl b/assets/shaders/swim_ripple.vert.glsl new file mode 100644 index 00000000..25e0dea8 --- /dev/null +++ b/assets/shaders/swim_ripple.vert.glsl @@ -0,0 +1,26 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in float aSize; +layout(location = 2) in float aAlpha; + +layout(location = 0) out float vAlpha; + +void main() { + gl_PointSize = aSize; + vAlpha = aAlpha; + gl_Position = projection * view * vec4(aPos, 1.0); +} diff --git a/assets/shaders/swim_ripple.vert.spv b/assets/shaders/swim_ripple.vert.spv new file mode 100644 index 00000000..2b84488c Binary files /dev/null and b/assets/shaders/swim_ripple.vert.spv differ diff --git a/assets/shaders/terrain.frag.glsl b/assets/shaders/terrain.frag.glsl new file mode 100644 index 00000000..0a424090 --- /dev/null +++ b/assets/shaders/terrain.frag.glsl @@ -0,0 +1,132 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(set = 1, binding = 0) uniform sampler2D uBaseTexture; +layout(set = 1, binding = 1) uniform sampler2D uLayer1Texture; +layout(set = 1, binding = 2) uniform sampler2D uLayer2Texture; +layout(set = 1, binding = 3) uniform sampler2D uLayer3Texture; +layout(set = 1, binding = 4) uniform sampler2D uLayer1Alpha; +layout(set = 1, binding = 5) uniform sampler2D uLayer2Alpha; +layout(set = 1, binding = 6) uniform sampler2D uLayer3Alpha; + +layout(set = 1, binding = 7) uniform TerrainParams { + int layerCount; + int hasLayer1; + int hasLayer2; + int hasLayer3; +}; + +layout(set = 0, binding = 1) uniform sampler2DShadow uShadowMap; + +layout(location = 0) in vec3 FragPos; +layout(location = 1) in vec3 Normal; +layout(location = 2) in vec2 TexCoord; +layout(location = 3) in vec2 LayerUV; + +layout(location = 0) out vec4 outColor; + +const float SHADOW_TEXEL = 1.0 / 4096.0; + +float sampleShadowPCF(sampler2DShadow smap, vec3 coords) { + float shadow = 0.0; + for (int x = -1; x <= 1; ++x) { + for (int y = -1; y <= 1; ++y) { + shadow += texture(smap, vec3(coords.xy + vec2(x, y) * SHADOW_TEXEL, coords.z)); + } + } + return shadow / 9.0; +} + +float sampleAlpha(sampler2D tex, vec2 uv) { + vec2 edge = min(uv, 1.0 - uv); + float border = min(edge.x, edge.y); + float doBlur = step(border, 2.0 / 64.0); + if (doBlur < 0.5) { + return texture(tex, uv).r; + } + vec2 texel = vec2(1.0 / 64.0); + float a = 0.0; + a += texture(tex, uv + vec2(-texel.x, 0.0)).r; + a += texture(tex, uv + vec2(texel.x, 0.0)).r; + a += texture(tex, uv + vec2(0.0, -texel.y)).r; + a += texture(tex, uv + vec2(0.0, texel.y)).r; + return a * 0.25; +} + +void main() { + vec4 baseColor = texture(uBaseTexture, TexCoord); + + // WoW terrain: layers are blended sequentially, each on top of the previous result. + // Alpha=1 means the layer fully covers everything below; alpha=0 means invisible. + vec4 finalColor = baseColor; + if (hasLayer1 != 0) { + float a1 = sampleAlpha(uLayer1Alpha, LayerUV); + finalColor = mix(finalColor, texture(uLayer1Texture, TexCoord), a1); + } + if (hasLayer2 != 0) { + float a2 = sampleAlpha(uLayer2Alpha, LayerUV); + finalColor = mix(finalColor, texture(uLayer2Texture, TexCoord), a2); + } + if (hasLayer3 != 0) { + float a3 = sampleAlpha(uLayer3Alpha, LayerUV); + finalColor = mix(finalColor, texture(uLayer3Texture, TexCoord), a3); + } + + vec3 norm = normalize(Normal); + + // Derivative-based normal mapping: perturb vertex normal using texture detail. + // Fade out with distance β€” looks noisy/harsh beyond ~100 units. + float fragDist = length(viewPos.xyz - FragPos); + float bumpFade = 1.0 - smoothstep(50.0, 125.0, fragDist); + if (bumpFade > 0.001) { + float lum = dot(finalColor.rgb, vec3(0.299, 0.587, 0.114)); + float dLdx = dFdx(lum); + float dLdy = dFdy(lum); + vec3 dpdx = dFdx(FragPos); + vec3 dpdy = dFdy(FragPos); + float bumpStrength = 9.0 * bumpFade; + vec3 perturbation = (dLdx * cross(norm, dpdy) + dLdy * cross(dpdx, norm)) * bumpStrength; + vec3 candidate = norm - perturbation; + float len2 = dot(candidate, candidate); + norm = (len2 > 1e-8) ? candidate * inversesqrt(len2) : norm; + } + + vec3 lightDir2 = normalize(-lightDir.xyz); + vec3 ambient = ambientColor.rgb * finalColor.rgb; + float diff = max(abs(dot(norm, lightDir2)), 0.2); + vec3 diffuse = diff * lightColor.rgb * finalColor.rgb; + + float shadow = 1.0; + if (shadowParams.x > 0.5) { + vec3 ldir = normalize(-lightDir.xyz); + float normalOffset = SHADOW_TEXEL * 2.0 * (1.0 - abs(dot(norm, ldir))); + vec3 biasedPos = FragPos + norm * normalOffset; + vec4 lsPos = lightSpaceMatrix * vec4(biasedPos, 1.0); + vec3 proj = lsPos.xyz / lsPos.w; + proj.xy = proj.xy * 0.5 + 0.5; + if (proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0 && proj.z <= 1.0) { + float bias = 0.0002; + shadow = sampleShadowPCF(uShadowMap, vec3(proj.xy, proj.z - bias)); + shadow = mix(1.0, shadow, shadowParams.y); + } + } + + vec3 result = ambient + shadow * diffuse; + + float fogFactor = clamp((fogParams.y - fragDist) / (fogParams.y - fogParams.x), 0.0, 1.0); + result = mix(fogColor.rgb, result, fogFactor); + + outColor = vec4(result, 1.0); +} diff --git a/assets/shaders/terrain.frag.spv b/assets/shaders/terrain.frag.spv new file mode 100644 index 00000000..d8f3d684 Binary files /dev/null and b/assets/shaders/terrain.frag.spv differ diff --git a/assets/shaders/terrain.vert.glsl b/assets/shaders/terrain.vert.glsl new file mode 100644 index 00000000..d05ba494 --- /dev/null +++ b/assets/shaders/terrain.vert.glsl @@ -0,0 +1,37 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; +} push; + +layout(location = 0) in vec3 aPosition; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; +layout(location = 3) in vec2 aLayerUV; + +layout(location = 0) out vec3 FragPos; +layout(location = 1) out vec3 Normal; +layout(location = 2) out vec2 TexCoord; +layout(location = 3) out vec2 LayerUV; + +void main() { + vec4 worldPos = push.model * vec4(aPosition, 1.0); + FragPos = worldPos.xyz; + Normal = aNormal; + TexCoord = aTexCoord; + LayerUV = aLayerUV; + gl_Position = projection * view * worldPos; +} diff --git a/assets/shaders/terrain.vert.spv b/assets/shaders/terrain.vert.spv new file mode 100644 index 00000000..a21d68a5 Binary files /dev/null and b/assets/shaders/terrain.vert.spv differ diff --git a/assets/shaders/water.frag.glsl b/assets/shaders/water.frag.glsl new file mode 100644 index 00000000..ecd7ee1d --- /dev/null +++ b/assets/shaders/water.frag.glsl @@ -0,0 +1,364 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; + float waveAmp; + float waveFreq; + float waveSpeed; + float liquidBasicType; +} push; + +layout(set = 1, binding = 0) uniform WaterMaterial { + vec4 waterColor; + float waterAlpha; + float shimmerStrength; + float alphaScale; +}; + +layout(set = 2, binding = 0) uniform sampler2D SceneColor; +layout(set = 2, binding = 1) uniform sampler2D SceneDepth; +layout(set = 2, binding = 2) uniform sampler2D ReflectionColor; +layout(set = 2, binding = 3) uniform ReflectionData { + mat4 reflViewProj; +}; + +layout(location = 0) in vec3 FragPos; +layout(location = 1) in vec3 Normal; +layout(location = 2) in vec2 TexCoord; +layout(location = 3) in float WaveOffset; +layout(location = 4) in vec2 ScreenUV; + +layout(location = 0) out vec4 outColor; + +// ============================================================ +// Dual-scroll detail normals (multi-octave ripple overlay) +// ============================================================ +vec3 dualScrollWaveNormal(vec2 p, float time) { + vec2 d1 = normalize(vec2(0.86, 0.51)); + vec2 d2 = normalize(vec2(-0.47, 0.88)); + vec2 d3 = normalize(vec2(0.32, -0.95)); + float f1 = 0.19, f2 = 0.43, f3 = 0.72; + float s1 = 0.95, s2 = 1.73, s3 = 2.40; + float a1 = 0.22, a2 = 0.10, a3 = 0.05; + + vec2 p1 = p + d1 * (time * s1 * 4.0); + vec2 p2 = p + d2 * (time * s2 * 4.0); + vec2 p3 = p + d3 * (time * s3 * 4.0); + + float c1 = cos(dot(p1, d1) * f1); + float c2 = cos(dot(p2, d2) * f2); + float c3 = cos(dot(p3, d3) * f3); + + float dHx = c1 * d1.x * f1 * a1 + c2 * d2.x * f2 * a2 + c3 * d3.x * f3 * a3; + float dHy = c1 * d1.y * f1 * a1 + c2 * d2.y * f2 * a2 + c3 * d3.y * f3 * a3; + + return normalize(vec3(-dHx, -dHy, 1.0)); +} + +// ============================================================ +// GGX/Cook-Torrance BRDF +// ============================================================ +float DistributionGGX(vec3 N, vec3 H, float roughness) { + float a = roughness * roughness; + float a2 = a * a; + float NdotH = max(dot(N, H), 0.0); + float NdotH2 = NdotH * NdotH; + float denom = NdotH2 * (a2 - 1.0) + 1.0; + return a2 / (3.14159265 * denom * denom + 1e-7); +} + +float GeometrySmith(float NdotV, float NdotL, float roughness) { + float r = roughness + 1.0; + float k = (r * r) / 8.0; + float ggx1 = NdotV / (NdotV * (1.0 - k) + k); + float ggx2 = NdotL / (NdotL * (1.0 - k) + k); + return ggx1 * ggx2; +} + +vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) { + return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); +} + +// ============================================================ +// Linearize depth +// ============================================================ +float linearizeDepth(float d, float near, float far) { + return near * far / (far - d * (far - near)); +} + +// ============================================================ +// Noise functions for foam +// ============================================================ +float hash21(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float hash22x(vec2 p) { + return fract(sin(dot(p, vec2(269.5, 183.3))) * 43758.5453); +} + +float noiseValue(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbmNoise(vec2 p, float time) { + float v = 0.0; + v += noiseValue(p * 3.0 + time * 0.3) * 0.5; + v += noiseValue(p * 6.0 - time * 0.5) * 0.25; + v += noiseValue(p * 12.0 + time * 0.7) * 0.125; + return v; +} + +// Voronoi-like cellular noise for foam particles +// jitter parameter controls how much cell points deviate from grid centers +// (0.0 = regular grid, 1.0 = fully random within cell) +float cellularFoam(vec2 p, float jitter) { + vec2 i = floor(p); + vec2 f = fract(p); + float minDist = 1.0; + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 neighbor = vec2(float(x), float(y)); + vec2 cellId = i + neighbor; + // Jittered cell point β€” higher jitter = more irregular placement + vec2 point = vec2(hash21(cellId), hash22x(cellId)) * jitter + + vec2(0.5) * (1.0 - jitter); + float d = length(neighbor + point - f); + minDist = min(minDist, d); + } + } + return minDist; +} +float cellularFoam(vec2 p) { return cellularFoam(p, 1.0); } + +void main() { + float time = fogParams.z; + float basicType = push.liquidBasicType; + + vec2 screenUV = gl_FragCoord.xy / vec2(textureSize(SceneColor, 0)); + + // --- Normal computation --- + vec3 meshNorm = normalize(Normal); + vec3 detailNorm = dualScrollWaveNormal(FragPos.xy, time); + vec3 norm = normalize(mix(meshNorm, detailNorm, 0.55)); + + // Player interaction ripple normal perturbation + vec2 playerPos = vec2(shadowParams.z, shadowParams.w); + float rippleStrength = fogParams.w; + float d = length(FragPos.xy - playerPos); + float rippleEnv = rippleStrength * exp(-d * 0.12); + if (rippleEnv > 0.001) { + vec2 radialDir = (FragPos.xy - playerPos) / max(d, 0.01); + float dHdr = rippleEnv * 0.12 * (-0.12 * sin(d * 2.5 - time * 6.0) + 2.5 * cos(d * 2.5 - time * 6.0)); + norm = normalize(norm + vec3(-radialDir * dHdr, 0.0)); + } + + vec3 viewDir = normalize(viewPos.xyz - FragPos); + vec3 ldir = normalize(-lightDir.xyz); + float NdotV = max(dot(norm, viewDir), 0.001); + float NdotL = max(dot(norm, ldir), 0.0); + + float dist = length(viewPos.xyz - FragPos); + + // --- Schlick Fresnel --- + const vec3 F0 = vec3(0.02); + float fresnel = F0.x + (1.0 - F0.x) * pow(1.0 - NdotV, 5.0); + + // ============================================================ + // Refraction (screen-space from scene history) + // ============================================================ + vec2 refractOffset = norm.xy * (0.02 + 0.03 * fresnel); + vec2 refractUV = clamp(screenUV + refractOffset, vec2(0.001), vec2(0.999)); + vec3 sceneRefract = texture(SceneColor, refractUV).rgb; + + float sceneDepth = texture(SceneDepth, refractUV).r; + + float near = 0.05; + float far = 30000.0; + float sceneLinDepth = linearizeDepth(sceneDepth, near, far); + float waterLinDepth = linearizeDepth(gl_FragCoord.z, near, far); + float depthDiff = max(sceneLinDepth - waterLinDepth, 0.0); + + // Convert screen-space depth difference to approximate vertical water depth. + // depthDiff is along the view ray; multiply by the vertical component of + // the view direction so grazing angles don't falsely trigger shoreline foam + // on occluding geometry (bridges, posts) that isn't at the waterline. + float verticalFactor = abs(viewDir.z); // 1.0 looking straight down, ~0 at grazing + float verticalDepth = depthDiff * max(verticalFactor, 0.05); + + // ============================================================ + // Beer-Lambert absorption + // ============================================================ + vec3 absorptionCoeff = vec3(0.46, 0.09, 0.06); + if (basicType > 0.5 && basicType < 1.5) { + absorptionCoeff = vec3(0.35, 0.06, 0.04); + } + vec3 absorbed = exp(-absorptionCoeff * verticalDepth); + + // Underwater blue fog β€” geometry below the waterline fades to a blue haze + // with depth, masking occlusion edge artifacts and giving a natural look. + vec3 underwaterFogColor = waterColor.rgb * 0.5 + vec3(0.04, 0.10, 0.20); + float underwaterFogFade = 1.0 - exp(-verticalDepth * 0.35); + vec3 foggedScene = mix(sceneRefract, underwaterFogColor, underwaterFogFade); + + vec3 shallowColor = waterColor.rgb * 1.2; + vec3 deepColor = waterColor.rgb * vec3(0.3, 0.5, 0.7); + float depthFade = 1.0 - exp(-verticalDepth * 0.15); + vec3 waterBody = mix(shallowColor, deepColor, depthFade); + + vec3 refractedColor = mix(foggedScene * absorbed, waterBody, depthFade * 0.7); + + if (verticalDepth < 0.01) { + float opticalDepth = 1.0 - exp(-dist * 0.004); + refractedColor = mix(foggedScene, waterBody, opticalDepth * 0.6); + } + + vec3 litBase = waterBody * (ambientColor.rgb * 0.7 + NdotL * lightColor.rgb * 0.5); + refractedColor = mix(refractedColor, litBase, clamp(depthFade * 0.3, 0.0, 0.5)); + + // ============================================================ + // Planar reflection β€” subtle, not mirror-like + // ============================================================ + // reflWeight starts at 0; only contributes where we have valid reflection data + float reflAmount = 0.0; + vec3 envReflect = vec3(0.0); + + vec4 reflClip = reflViewProj * vec4(FragPos, 1.0); + if (reflClip.w > 0.1) { + vec2 reflUV = reflClip.xy / reflClip.w * 0.5 + 0.5; + reflUV.y = 1.0 - reflUV.y; + reflUV += norm.xy * 0.015; + + // Wide fade so there's no visible boundary β€” fully gone well inside the edge + float edgeFade = smoothstep(0.0, 0.15, reflUV.x) * smoothstep(1.0, 0.85, reflUV.x) + * smoothstep(0.0, 0.15, reflUV.y) * smoothstep(1.0, 0.85, reflUV.y); + + reflUV = clamp(reflUV, vec2(0.002), vec2(0.998)); + vec3 texReflect = texture(ReflectionColor, reflUV).rgb; + + float reflBrightness = dot(texReflect, vec3(0.299, 0.587, 0.114)); + float reflValidity = smoothstep(0.002, 0.05, reflBrightness) * edgeFade; + + envReflect = texReflect * 0.5; + reflAmount = reflValidity * 0.4; + } + + // ============================================================ + // GGX Specular + // ============================================================ + float roughness = 0.18; + vec3 halfDir = normalize(ldir + viewDir); + float D = DistributionGGX(norm, halfDir, roughness); + float G = GeometrySmith(NdotV, NdotL, roughness); + vec3 F = fresnelSchlickRoughness(max(dot(halfDir, viewDir), 0.0), F0, roughness); + vec3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 0.001) * lightColor.rgb * NdotL; + specular = min(specular, vec3(2.0)); + + // Noise-based sparkle + float sparkleNoise = fbmNoise(FragPos.xy * 4.0 + time * 0.5, time * 1.5); + float sparkle = pow(max(sparkleNoise - 0.55, 0.0) / 0.45, 3.0) * shimmerStrength * 0.10; + specular += sparkle * lightColor.rgb; + + // ============================================================ + // Subsurface scattering + // ============================================================ + float sssBase = pow(max(dot(viewDir, -ldir), 0.0), 4.0); + float sss = sssBase * max(0.0, WaveOffset * 3.0) * 0.25; + vec3 sssColor = vec3(0.05, 0.55, 0.35) * sss * lightColor.rgb; + + // ============================================================ + // Combine β€” reflection only where valid, no dark fallback + // ============================================================ + // reflAmount is 0 where no valid reflection data exists β€” no dark arc + float reflectWeight = clamp(fresnel * reflAmount, 0.0, 0.30); + vec3 color = mix(refractedColor, envReflect, reflectWeight); + color += specular + sssColor; + + float crest = smoothstep(0.5, 1.0, WaveOffset) * 0.04; + color += vec3(crest); + + // ============================================================ + // Shoreline foam β€” scattered particles, not smooth bands + // Only on terrain water (waveAmp > 0); WMO water (canals, indoor) + // has waveAmp == 0 and should not show shoreline interaction. + // ============================================================ + if (basicType < 1.5 && verticalDepth > 0.01 && push.waveAmp > 0.0) { + float foamDepthMask = 1.0 - smoothstep(0.0, 1.8, verticalDepth); + + // Warp UV coords with noise to break up cellular regularity + vec2 warpOffset = vec2( + noiseValue(FragPos.xy * 2.5 + time * 0.08) - 0.5, + noiseValue(FragPos.xy * 2.5 + vec2(37.0) + time * 0.06) - 0.5 + ) * 1.6; + vec2 foamUV = FragPos.xy + warpOffset; + + // Fine scattered particles + float cells1 = cellularFoam(foamUV * 14.0 + time * vec2(0.15, 0.08)); + float foam1 = (1.0 - smoothstep(0.0, 0.12, cells1)) * 0.45; + + // Tiny spray dots + float cells2 = cellularFoam(foamUV * 28.0 + time * vec2(-0.12, 0.22)); + float foam2 = (1.0 - smoothstep(0.0, 0.07, cells2)) * 0.3; + + // Micro specks + float cells3 = cellularFoam(foamUV * 50.0 + time * vec2(0.25, -0.1)); + float foam3 = (1.0 - smoothstep(0.0, 0.05, cells3)) * 0.18; + + // Noise breakup for clumping + float noiseMask = noiseValue(FragPos.xy * 3.0 + time * 0.15); + float foam = (foam1 + foam2 + foam3) * foamDepthMask * smoothstep(0.3, 0.6, noiseMask); + + foam *= smoothstep(0.0, 0.1, verticalDepth); + // Bluer foam tint instead of near-white + color = mix(color, vec3(0.68, 0.78, 0.88), clamp(foam, 0.0, 0.40)); + } + + // ============================================================ + // Wave crest foam (ocean only) β€” particle-based + // ============================================================ + if (basicType > 0.5 && basicType < 1.5 && push.waveAmp > 0.0) { + float crestMask = smoothstep(0.5, 1.0, WaveOffset); + vec2 crestWarp = vec2( + noiseValue(FragPos.xy * 1.8 + time * 0.1) - 0.5, + noiseValue(FragPos.xy * 1.8 + vec2(53.0) + time * 0.07) - 0.5 + ) * 2.0; + float crestCells = cellularFoam((FragPos.xy + crestWarp) * 6.0 + time * vec2(0.12, 0.08)); + float crestFoam = (1.0 - smoothstep(0.0, 0.18, crestCells)) * crestMask; + float crestNoise = noiseValue(FragPos.xy * 3.0 - time * 0.3); + crestFoam *= smoothstep(0.3, 0.6, crestNoise); + color = mix(color, vec3(0.68, 0.78, 0.88), crestFoam * 0.30); + } + + // ============================================================ + // Alpha and fog + // ============================================================ + float baseAlpha = mix(waterAlpha, min(1.0, waterAlpha * 1.5), depthFade); + float alpha = mix(baseAlpha, min(1.0, baseAlpha * 1.3), fresnel) * alphaScale; + alpha *= smoothstep(1600.0, 350.0, dist); + alpha = clamp(alpha, 0.15, 0.92); + + float fogFactor = clamp((fogParams.y - dist) / (fogParams.y - fogParams.x), 0.0, 1.0); + color = mix(fogColor.rgb, color, fogFactor); + + outColor = vec4(color, alpha); +} diff --git a/assets/shaders/water.frag.spv b/assets/shaders/water.frag.spv new file mode 100644 index 00000000..5f1d56b1 Binary files /dev/null and b/assets/shaders/water.frag.spv differ diff --git a/assets/shaders/water.vert.glsl b/assets/shaders/water.vert.glsl new file mode 100644 index 00000000..6eb0e796 --- /dev/null +++ b/assets/shaders/water.vert.glsl @@ -0,0 +1,166 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; + float waveAmp; + float waveFreq; + float waveSpeed; + float liquidBasicType; // 0=water, 1=ocean, 2=magma, 3=slime +} push; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec2 aTexCoord; + +layout(location = 0) out vec3 FragPos; +layout(location = 1) out vec3 Normal; +layout(location = 2) out vec2 TexCoord; +layout(location = 3) out float WaveOffset; +layout(location = 4) out vec2 ScreenUV; + +// --- Gerstner wave --- +// Coordinate system: X,Y = horizontal plane, Z = up (height) +// displacement.xy = horizontal, displacement.z = vertical +struct GerstnerResult { + vec3 displacement; + vec3 tangent; // along X + vec3 binormal; // along Y + float waveHeight; // raw wave height for foam +}; + +GerstnerResult evaluateGerstnerWaves(vec2 pos, float time, float amp, float freq, float spd, float basicType) { + GerstnerResult r; + r.displacement = vec3(0.0); + r.tangent = vec3(1.0, 0.0, 0.0); + r.binormal = vec3(0.0, 1.0, 0.0); + r.waveHeight = 0.0; + + // Magma/slime: simple slow undulation + if (basicType >= 1.5) { + float wave = sin(pos.x * freq * 0.5 + time * spd * 0.3) * 0.4 + + sin(pos.y * freq * 0.3 + time * spd * 0.5) * 0.3; + r.displacement.z = wave * amp * 0.5; + float dx = cos(pos.x * freq * 0.5 + time * spd * 0.3) * freq * 0.5 * amp * 0.5 * 0.4; + float dy = cos(pos.y * freq * 0.3 + time * spd * 0.5) * freq * 0.3 * amp * 0.5 * 0.3; + r.tangent = vec3(1.0, 0.0, dx); + r.binormal = vec3(0.0, 1.0, dy); + r.waveHeight = wave; + return r; + } + + // 6 wave directions for more chaotic, natural-looking water + // Spread across many angles to avoid visible patterns + vec2 dirs[6] = vec2[6]( + normalize(vec2(0.86, 0.51)), + normalize(vec2(-0.47, 0.88)), + normalize(vec2(0.32, -0.95)), + normalize(vec2(-0.93, -0.37)), + normalize(vec2(0.67, -0.29)), + normalize(vec2(-0.15, 0.74)) + ); + float amps[6]; + float freqs[6]; + float spds_arr[6]; + float steepness[6]; + + if (basicType > 0.5) { + // Ocean: broader range of wave scales for realistic chop + amps[0] = amp * 1.0; amps[1] = amp * 0.55; amps[2] = amp * 0.30; + amps[3] = amp * 0.18; amps[4] = amp * 0.10; amps[5] = amp * 0.06; + freqs[0] = freq * 0.7; freqs[1] = freq * 1.3; freqs[2] = freq * 2.1; + freqs[3] = freq * 3.4; freqs[4] = freq * 5.0; freqs[5] = freq * 7.5; + spds_arr[0] = spd * 0.8; spds_arr[1] = spd * 1.0; spds_arr[2] = spd * 1.3; + spds_arr[3] = spd * 1.6; spds_arr[4] = spd * 2.0; spds_arr[5] = spd * 2.5; + steepness[0] = 0.35; steepness[1] = 0.30; steepness[2] = 0.25; + steepness[3] = 0.20; steepness[4] = 0.15; steepness[5] = 0.10; + } else { + // Inland water: gentle but multi-scale ripples + amps[0] = amp * 0.5; amps[1] = amp * 0.25; amps[2] = amp * 0.15; + amps[3] = amp * 0.08; amps[4] = amp * 0.05; amps[5] = amp * 0.03; + freqs[0] = freq * 1.0; freqs[1] = freq * 1.8; freqs[2] = freq * 3.0; + freqs[3] = freq * 4.5; freqs[4] = freq * 7.0; freqs[5] = freq * 10.0; + spds_arr[0] = spd * 0.6; spds_arr[1] = spd * 0.9; spds_arr[2] = spd * 1.2; + spds_arr[3] = spd * 1.5; spds_arr[4] = spd * 1.9; spds_arr[5] = spd * 2.3; + steepness[0] = 0.20; steepness[1] = 0.18; steepness[2] = 0.15; + steepness[3] = 0.12; steepness[4] = 0.10; steepness[5] = 0.08; + } + + float totalWave = 0.0; + for (int i = 0; i < 6; i++) { + float w = freqs[i]; + float A = amps[i]; + float phi = spds_arr[i] * w; // phase speed + float Q = steepness[i] / (w * A * 6.0); + Q = clamp(Q, 0.0, 1.0); + + float phase = w * dot(dirs[i], pos) + phi * time; + float s = sin(phase); + float c = cos(phase); + + // Gerstner displacement: xy = horizontal, z = vertical (up) + r.displacement.x += Q * A * dirs[i].x * c; + r.displacement.y += Q * A * dirs[i].y * c; + r.displacement.z += A * s; + + // Tangent/binormal accumulation for analytical normal + float WA = w * A; + r.tangent.x -= Q * dirs[i].x * dirs[i].x * WA * s; + r.tangent.y -= Q * dirs[i].x * dirs[i].y * WA * s; + r.tangent.z += dirs[i].x * WA * c; + + r.binormal.x -= Q * dirs[i].x * dirs[i].y * WA * s; + r.binormal.y -= Q * dirs[i].y * dirs[i].y * WA * s; + r.binormal.z += dirs[i].y * WA * c; + + totalWave += A * s; + } + + r.waveHeight = totalWave; + return r; +} + +void main() { + float time = fogParams.z; + vec4 worldPos = push.model * vec4(aPos, 1.0); + + // Evaluate Gerstner waves using X,Y horizontal plane + GerstnerResult waves = evaluateGerstnerWaves( + vec2(worldPos.x, worldPos.y), time, + push.waveAmp, push.waveFreq, push.waveSpeed, push.liquidBasicType + ); + + // Apply displacement: xy = horizontal, z = vertical (up) + worldPos.x += waves.displacement.x; + worldPos.y += waves.displacement.y; + worldPos.z += waves.displacement.z; + WaveOffset = waves.waveHeight; // raw wave height for fragment shader foam + + // Player interaction ripples β€” concentric waves emanating from player position + vec2 playerPos = vec2(shadowParams.z, shadowParams.w); + float rippleStrength = fogParams.w; + float d = length(worldPos.xy - playerPos); + float ripple = rippleStrength * 0.12 * exp(-d * 0.12) * sin(d * 2.5 - time * 6.0); + worldPos.z += ripple; + + // Analytical normal from Gerstner tangent/binormal (cross product gives Z-up normal) + Normal = normalize(cross(waves.binormal, waves.tangent)); + + FragPos = worldPos.xyz; + TexCoord = aTexCoord; + vec4 clipPos = projection * view * worldPos; + gl_Position = clipPos; + vec2 ndc = clipPos.xy / max(clipPos.w, 1e-5); + ScreenUV = ndc * 0.5 + 0.5; +} diff --git a/assets/shaders/water.vert.spv b/assets/shaders/water.vert.spv new file mode 100644 index 00000000..3f585319 Binary files /dev/null and b/assets/shaders/water.vert.spv differ diff --git a/assets/shaders/weather.frag.glsl b/assets/shaders/weather.frag.glsl new file mode 100644 index 00000000..f1d4be21 --- /dev/null +++ b/assets/shaders/weather.frag.glsl @@ -0,0 +1,19 @@ +#version 450 + +layout(push_constant) uniform Push { + float particleSize; + float pad0; + float pad1; + float pad2; + vec4 particleColor; +} push; + +layout(location = 0) out vec4 outColor; + +void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float dist = length(p); + if (dist > 0.5) discard; + float alpha = push.particleColor.a * smoothstep(0.5, 0.2, dist); + outColor = vec4(push.particleColor.rgb, alpha); +} diff --git a/assets/shaders/weather.frag.spv b/assets/shaders/weather.frag.spv new file mode 100644 index 00000000..5d71905f Binary files /dev/null and b/assets/shaders/weather.frag.spv differ diff --git a/assets/shaders/weather.vert.glsl b/assets/shaders/weather.vert.glsl new file mode 100644 index 00000000..68dd7e73 --- /dev/null +++ b/assets/shaders/weather.vert.glsl @@ -0,0 +1,25 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + float particleSize; +} push; + +layout(location = 0) in vec3 aPos; + +void main() { + gl_PointSize = push.particleSize; + gl_Position = projection * view * vec4(aPos, 1.0); +} diff --git a/assets/shaders/weather.vert.spv b/assets/shaders/weather.vert.spv new file mode 100644 index 00000000..4977f00a Binary files /dev/null and b/assets/shaders/weather.vert.spv differ diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl new file mode 100644 index 00000000..dbd55436 --- /dev/null +++ b/assets/shaders/wmo.frag.glsl @@ -0,0 +1,226 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(set = 1, binding = 1) uniform WMOMaterial { + int hasTexture; + int alphaTest; + int unlit; + int isInterior; + float specularIntensity; + int isWindow; + int enableNormalMap; + int enablePOM; + float pomScale; + int pomMaxSamples; + float heightMapVariance; + float normalMapStrength; +}; + +layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap; + +layout(set = 0, binding = 1) uniform sampler2DShadow uShadowMap; + +layout(location = 0) in vec3 FragPos; +layout(location = 1) in vec3 Normal; +layout(location = 2) in vec2 TexCoord; +layout(location = 3) in vec4 VertColor; +layout(location = 4) in vec3 Tangent; +layout(location = 5) in vec3 Bitangent; + +layout(location = 0) out vec4 outColor; + +const float SHADOW_TEXEL = 1.0 / 4096.0; + +float sampleShadowPCF(sampler2DShadow smap, vec3 coords) { + float shadow = 0.0; + for (int x = -1; x <= 1; ++x) { + for (int y = -1; y <= 1; ++y) { + shadow += texture(smap, vec3(coords.xy + vec2(x, y) * SHADOW_TEXEL, coords.z)); + } + } + return shadow / 9.0; +} + +// LOD factor from screen-space UV derivatives +float computeLodFactor() { + vec2 dx = dFdx(TexCoord); + vec2 dy = dFdy(TexCoord); + float texelDensity = max(dot(dx, dx), dot(dy, dy)); + // Low density = close/head-on = full detail (0) + // High density = far/steep = vertex normals only (1) + return smoothstep(0.0001, 0.005, texelDensity); +} + +// Parallax Occlusion Mapping with angle-adaptive sampling +vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float lodFactor) { + float VdotN = abs(viewDirTS.z); // 1=head-on, 0=grazing + + // Fade out POM at grazing angles to avoid distortion + if (VdotN < 0.15) return uv; + + float angleFactor = clamp(VdotN, 0.15, 1.0); + int maxS = pomMaxSamples; + int minS = max(maxS / 4, 4); + int numSamples = int(mix(float(minS), float(maxS), angleFactor)); + numSamples = int(mix(float(minS), float(numSamples), 1.0 - lodFactor)); + + float layerDepth = 1.0 / float(numSamples); + float currentLayerDepth = 0.0; + + // Direction to shift UV per layer β€” clamp denominator to prevent explosion at grazing angles + vec2 P = viewDirTS.xy / max(VdotN, 0.15) * pomScale; + // Hard-clamp total UV offset to prevent texture swimming + float maxOffset = pomScale * 3.0; + P = clamp(P, vec2(-maxOffset), vec2(maxOffset)); + vec2 deltaUV = P / float(numSamples); + + vec2 currentUV = uv; + float currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + + // Ray march through layers + for (int i = 0; i < 64; i++) { + if (i >= numSamples || currentLayerDepth >= currentDepthMapValue) break; + currentUV -= deltaUV; + currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + currentLayerDepth += layerDepth; + } + + // Interpolate between last two layers for smooth result + vec2 prevUV = currentUV + deltaUV; + float afterDepth = currentDepthMapValue - currentLayerDepth; + float beforeDepth = (1.0 - texture(uNormalHeightMap, prevUV).a) - currentLayerDepth + layerDepth; + float weight = afterDepth / (afterDepth - beforeDepth + 0.0001); + vec2 result = mix(currentUV, prevUV, weight); + + // Fade toward original UV at grazing angles for smooth transition + float fadeFactor = smoothstep(0.15, 0.35, VdotN); + return mix(uv, result, fadeFactor); +} + +void main() { + float lodFactor = computeLodFactor(); + + vec3 vertexNormal = normalize(Normal); + if (!gl_FrontFacing) vertexNormal = -vertexNormal; + + // Compute final UV (with POM if enabled) + vec2 finalUV = TexCoord; + + // Build TBN matrix + vec3 T = normalize(Tangent); + vec3 B = normalize(Bitangent); + vec3 N = vertexNormal; + mat3 TBN = mat3(T, B, N); + + if (enablePOM != 0 && heightMapVariance > 0.001 && lodFactor < 0.99) { + mat3 TBN_inv = transpose(TBN); + vec3 viewDirWorld = normalize(viewPos.xyz - FragPos); + vec3 viewDirTS = TBN_inv * viewDirWorld; + finalUV = parallaxOcclusionMap(TexCoord, viewDirTS, lodFactor); + } + + vec4 texColor = hasTexture != 0 ? texture(uTexture, finalUV) : vec4(1.0); + if (alphaTest != 0 && texColor.a < 0.5) discard; + + // Compute normal (with normal mapping if enabled) + vec3 norm = vertexNormal; + if (enableNormalMap != 0 && lodFactor < 0.99 && normalMapStrength > 0.001) { + vec3 mapNormal = texture(uNormalHeightMap, finalUV).rgb * 2.0 - 1.0; + // Scale XY by strength to control effect intensity + mapNormal.xy *= normalMapStrength; + mapNormal = normalize(mapNormal); + vec3 worldNormal = normalize(TBN * mapNormal); + if (!gl_FrontFacing) worldNormal = -worldNormal; + // Blend: strength + LOD both contribute to fade toward vertex normal + float blendFactor = max(lodFactor, 1.0 - normalMapStrength); + norm = normalize(mix(worldNormal, vertexNormal, blendFactor)); + } + + vec3 result; + + // Sample shadow map for all non-window WMO surfaces + float shadow = 1.0; + if (shadowParams.x > 0.5) { + vec3 ldir = normalize(-lightDir.xyz); + float normalOffset = SHADOW_TEXEL * 2.0 * (1.0 - abs(dot(norm, ldir))); + vec3 biasedPos = FragPos + norm * normalOffset; + vec4 lsPos = lightSpaceMatrix * vec4(biasedPos, 1.0); + vec3 proj = lsPos.xyz / lsPos.w; + proj.xy = proj.xy * 0.5 + 0.5; + if (proj.x >= 0.0 && proj.x <= 1.0 && + proj.y >= 0.0 && proj.y <= 1.0 && + proj.z >= 0.0 && proj.z <= 1.0) { + float bias = max(0.0005 * (1.0 - dot(norm, ldir)), 0.00005); + shadow = sampleShadowPCF(uShadowMap, vec3(proj.xy, proj.z - bias)); + } + shadow = mix(1.0, shadow, shadowParams.y); + } + + if (unlit != 0) { + result = texColor.rgb * shadow; + } else if (isInterior != 0) { + vec3 mocv = max(VertColor.rgb, vec3(0.5)); + result = texColor.rgb * mocv * shadow; + } else { + vec3 ldir = normalize(-lightDir.xyz); + float diff = max(dot(norm, ldir), 0.0); + + vec3 viewDir = normalize(viewPos.xyz - FragPos); + vec3 halfDir = normalize(ldir + viewDir); + float spec = pow(max(dot(norm, halfDir), 0.0), 32.0) * specularIntensity; + + result = ambientColor.rgb * texColor.rgb + + shadow * (diff * lightColor.rgb * texColor.rgb + spec * lightColor.rgb); + + result *= max(VertColor.rgb, vec3(0.5)); + } + + float dist = length(viewPos.xyz - FragPos); + float fogFactor = clamp((fogParams.y - dist) / (fogParams.y - fogParams.x), 0.0, 1.0); + result = mix(fogColor.rgb, result, fogFactor); + + float alpha = texColor.a; + + // Window glass: opaque but simulates dark tinted glass with reflections. + if (isWindow != 0) { + vec3 viewDir = normalize(viewPos.xyz - FragPos); + float NdotV = abs(dot(norm, viewDir)); + float fresnel = 0.08 + 0.92 * pow(1.0 - NdotV, 4.0); + + vec3 ldir = normalize(-lightDir.xyz); + vec3 reflectDir = reflect(-viewDir, norm); + float sunGlint = pow(max(dot(reflectDir, ldir), 0.0), 32.0); + + float baseBrightness = mix(0.3, 0.9, sunGlint); + vec3 glass = result * baseBrightness; + + vec3 reflectTint = mix(ambientColor.rgb * 1.2, vec3(0.6, 0.75, 1.0), 0.6); + glass = mix(glass, reflectTint, fresnel * 0.8); + + vec3 halfDir = normalize(ldir + viewDir); + float spec = pow(max(dot(norm, halfDir), 0.0), 256.0); + glass += spec * lightColor.rgb * 0.8; + + float specBroad = pow(max(dot(norm, halfDir), 0.0), 12.0); + glass += specBroad * lightColor.rgb * 0.12; + + result = glass; + alpha = mix(0.4, 0.95, NdotV); + } + + outColor = vec4(result, alpha); +} diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv new file mode 100644 index 00000000..a7b9ef94 Binary files /dev/null and b/assets/shaders/wmo.frag.spv differ diff --git a/assets/shaders/wmo.vert.glsl b/assets/shaders/wmo.vert.glsl new file mode 100644 index 00000000..737b30aa --- /dev/null +++ b/assets/shaders/wmo.vert.glsl @@ -0,0 +1,53 @@ +#version 450 + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(push_constant) uniform Push { + mat4 model; +} push; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTexCoord; +layout(location = 3) in vec4 aColor; +layout(location = 4) in vec4 aTangent; + +layout(location = 0) out vec3 FragPos; +layout(location = 1) out vec3 Normal; +layout(location = 2) out vec2 TexCoord; +layout(location = 3) out vec4 VertColor; +layout(location = 4) out vec3 Tangent; +layout(location = 5) out vec3 Bitangent; + +void main() { + vec4 worldPos = push.model * vec4(aPos, 1.0); + FragPos = worldPos.xyz; + + mat3 normalMatrix = mat3(push.model); + Normal = normalMatrix * aNormal; + TexCoord = aTexCoord; + VertColor = aColor; + + // Compute TBN basis vectors for normal mapping + vec3 T = normalize(normalMatrix * aTangent.xyz); + vec3 N = normalize(Normal); + // Gram-Schmidt re-orthogonalize + T = normalize(T - dot(T, N) * N); + vec3 B = cross(N, T) * aTangent.w; + + Tangent = T; + Bitangent = B; + + gl_Position = projection * view * worldPos; +} diff --git a/assets/shaders/wmo.vert.spv b/assets/shaders/wmo.vert.spv new file mode 100644 index 00000000..95428d7e Binary files /dev/null and b/assets/shaders/wmo.vert.spv differ diff --git a/assets/shaders/wmo_occlusion.frag.glsl b/assets/shaders/wmo_occlusion.frag.glsl new file mode 100644 index 00000000..2f58b7d6 --- /dev/null +++ b/assets/shaders/wmo_occlusion.frag.glsl @@ -0,0 +1,5 @@ +#version 450 + +void main() { + // depth-only pass, no color output +} diff --git a/assets/shaders/wmo_occlusion.frag.spv b/assets/shaders/wmo_occlusion.frag.spv new file mode 100644 index 00000000..f73da9ea Binary files /dev/null and b/assets/shaders/wmo_occlusion.frag.spv differ diff --git a/assets/shaders/wmo_occlusion.vert.glsl b/assets/shaders/wmo_occlusion.vert.glsl new file mode 100644 index 00000000..1b4bdd58 --- /dev/null +++ b/assets/shaders/wmo_occlusion.vert.glsl @@ -0,0 +1,11 @@ +#version 450 + +layout(push_constant) uniform Push { + mat4 mvp; +} push; + +layout(location = 0) in vec3 aPos; + +void main() { + gl_Position = push.mvp * vec4(aPos, 1.0); +} diff --git a/assets/shaders/wmo_occlusion.vert.spv b/assets/shaders/wmo_occlusion.vert.spv new file mode 100644 index 00000000..c6ec2444 Binary files /dev/null and b/assets/shaders/wmo_occlusion.vert.spv differ diff --git a/assets/shaders/world_map.frag.glsl b/assets/shaders/world_map.frag.glsl new file mode 100644 index 00000000..d4884b1e --- /dev/null +++ b/assets/shaders/world_map.frag.glsl @@ -0,0 +1,11 @@ +#version 450 + +layout(set = 0, binding = 0) uniform sampler2D uTileTexture; + +layout(location = 0) in vec2 TexCoord; + +layout(location = 0) out vec4 outColor; + +void main() { + outColor = texture(uTileTexture, TexCoord); +} diff --git a/assets/shaders/world_map.frag.spv b/assets/shaders/world_map.frag.spv new file mode 100644 index 00000000..5209ba44 Binary files /dev/null and b/assets/shaders/world_map.frag.spv differ diff --git a/assets/shaders/world_map.vert.glsl b/assets/shaders/world_map.vert.glsl new file mode 100644 index 00000000..bfb4ac17 --- /dev/null +++ b/assets/shaders/world_map.vert.glsl @@ -0,0 +1,19 @@ +#version 450 + +layout(push_constant) uniform Push { + vec2 gridOffset; + float gridCols; + float gridRows; +} push; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aUV; + +layout(location = 0) out vec2 TexCoord; + +void main() { + TexCoord = aUV; + vec2 pos = (aPos + push.gridOffset) / vec2(push.gridCols, push.gridRows); + pos = pos * 2.0 - 1.0; + gl_Position = vec4(pos, 0.0, 1.0); +} diff --git a/assets/shaders/world_map.vert.spv b/assets/shaders/world_map.vert.spv new file mode 100644 index 00000000..8ff81cc5 Binary files /dev/null and b/assets/shaders/world_map.vert.spv differ diff --git a/assets/startscreen.mp4 b/assets/startscreen.mp4 deleted file mode 100755 index d58bb228..00000000 Binary files a/assets/startscreen.mp4 and /dev/null differ diff --git a/include/audio/activity_sound_manager.hpp b/include/audio/activity_sound_manager.hpp index 471f785c..764565ea 100644 --- a/include/audio/activity_sound_manager.hpp +++ b/include/audio/activity_sound_manager.hpp @@ -27,6 +27,7 @@ public: void playLanding(FootstepSurface surface, bool hardLanding); void setSwimmingState(bool swimming, bool moving); void setCharacterVoiceProfile(const std::string& modelName); + void setCharacterVoiceProfile(const std::string& raceFolder, const std::string& raceBase, bool male); void playWaterEnter(); void playWaterExit(); void playMeleeSwing(); diff --git a/include/core/logger.hpp b/include/core/logger.hpp index 65f38040..4ba4e3ce 100644 --- a/include/core/logger.hpp +++ b/include/core/logger.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include namespace wowee { namespace core { @@ -30,29 +32,35 @@ public: void log(LogLevel level, const std::string& message); void setLogLevel(LogLevel level); + bool shouldLog(LogLevel level) const; template void debug(Args&&... args) { + if (!shouldLog(LogLevel::DEBUG)) return; log(LogLevel::DEBUG, format(std::forward(args)...)); } template void info(Args&&... args) { + if (!shouldLog(LogLevel::INFO)) return; log(LogLevel::INFO, format(std::forward(args)...)); } template void warning(Args&&... args) { + if (!shouldLog(LogLevel::WARNING)) return; log(LogLevel::WARNING, format(std::forward(args)...)); } template void error(Args&&... args) { + if (!shouldLog(LogLevel::ERROR)) return; log(LogLevel::ERROR, format(std::forward(args)...)); } template void fatal(Args&&... args) { + if (!shouldLog(LogLevel::FATAL)) return; log(LogLevel::FATAL, format(std::forward(args)...)); } @@ -69,10 +77,13 @@ private: return oss.str(); } - LogLevel minLevel = LogLevel::INFO; // Changed from DEBUG to reduce log spam + std::atomic minLevel_{static_cast(LogLevel::INFO)}; std::mutex mutex; std::ofstream fileStream; bool fileReady = false; + bool echoToStdout_ = true; + std::chrono::steady_clock::time_point lastFlushTime_{}; + uint32_t flushIntervalMs_ = 250; void ensureFile(); }; diff --git a/include/core/window.hpp b/include/core/window.hpp index 3da55977..dc95e2c5 100644 --- a/include/core/window.hpp +++ b/include/core/window.hpp @@ -3,8 +3,11 @@ #include #include #include +#include namespace wowee { +namespace rendering { class VkContext; } + namespace core { struct WindowConfig { @@ -27,7 +30,7 @@ public: bool initialize(); void shutdown(); - void swapBuffers(); + void swapBuffers() {} // No-op: Vulkan presents in Renderer::endFrame() void pollEvents(); bool shouldClose() const { return shouldCloseFlag; } @@ -44,12 +47,14 @@ public: void applyResolution(int w, int h); SDL_Window* getSDLWindow() const { return window; } - SDL_GLContext getGLContext() const { return glContext; } + + // Vulkan context access + rendering::VkContext* getVkContext() const { return vkContext.get(); } private: WindowConfig config; SDL_Window* window = nullptr; - SDL_GLContext glContext = nullptr; + std::unique_ptr vkContext; int width; int height; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ad993511..d0364020 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -593,6 +593,9 @@ public: using GameObjectDespawnCallback = std::function; void setGameObjectDespawnCallback(GameObjectDespawnCallback cb) { gameObjectDespawnCallback_ = std::move(cb); } + using GameObjectCustomAnimCallback = std::function; + void setGameObjectCustomAnimCallback(GameObjectCustomAnimCallback cb) { gameObjectCustomAnimCallback_ = std::move(cb); } + // Faction hostility map (populated from FactionTemplate.dbc by Application) void setFactionHostileMap(std::unordered_map map) { factionHostileMap_ = std::move(map); } @@ -1401,6 +1404,7 @@ private: GameObjectSpawnCallback gameObjectSpawnCallback_; GameObjectMoveCallback gameObjectMoveCallback_; GameObjectDespawnCallback gameObjectDespawnCallback_; + GameObjectCustomAnimCallback gameObjectCustomAnimCallback_; // Transport tracking struct TransportAttachment { diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 42ad5b5f..edd97e8c 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -353,6 +353,7 @@ class TurtlePacketParsers : public ClassicPacketParsers { public: uint8_t movementFlags2Size() const override { return 0; } bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; + bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; }; /** diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index a57650e9..cf0e7ea9 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -180,7 +180,7 @@ inline const char* getSpellCastResultString(uint8_t result, int powerType = -1) case 91: return "Only mounted"; case 92: return "Only nighttime"; case 93: return "Only outdoors"; - case 94: return "Only shapeshift"; + case 94: return "Requires correct stance/form"; case 95: return "Only stealthed"; case 96: return "Only underwater"; case 97: return "Out of range"; diff --git a/include/network/packet.hpp b/include/network/packet.hpp index 4171fbad..fbfb85bf 100644 --- a/include/network/packet.hpp +++ b/include/network/packet.hpp @@ -12,6 +12,7 @@ public: Packet() = default; explicit Packet(uint16_t opcode); Packet(uint16_t opcode, const std::vector& data); + Packet(uint16_t opcode, std::vector&& data); void writeUInt8(uint8_t value); void writeUInt16(uint16_t value); diff --git a/include/network/world_socket.hpp b/include/network/world_socket.hpp index eee99bda..1d8f1d00 100644 --- a/include/network/world_socket.hpp +++ b/include/network/world_socket.hpp @@ -91,6 +91,13 @@ private: // Receive buffer std::vector receiveBuffer; + size_t receiveReadOffset_ = 0; + // Optional reused packet queue (feature-gated) to reduce per-update allocations. + std::vector parsedPacketsScratch_; + + // Runtime-gated network optimization toggles (default off). + bool useFastRecvAppend_ = false; + bool useParseScratchQueue_ = false; // Track how many header bytes have been decrypted (0-4) // This prevents re-decrypting the same header when waiting for more data diff --git a/include/pipeline/wmo_loader.hpp b/include/pipeline/wmo_loader.hpp index 718042c8..fecb287d 100644 --- a/include/pipeline/wmo_loader.hpp +++ b/include/pipeline/wmo_loader.hpp @@ -216,6 +216,7 @@ struct WMOModel { // Group names std::vector groupNames; + std::vector groupNameRaw; // Raw MOGN chunk for offset-based name lookup bool isValid() const { return nGroups > 0 && !groups.empty(); diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 400383b1..835627bc 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -27,6 +27,8 @@ public: float getMouseSensitivity() const { return mouseSensitivity; } void setInvertMouse(bool invert) { invertMouse = invert; } bool isInvertMouse() const { return invertMouse; } + void setExtendedZoom(bool extended) { extendedZoom_ = extended; } + bool isExtendedZoom() const { return extendedZoom_; } void setEnabled(bool enabled) { this->enabled = enabled; } void setTerrainManager(TerrainManager* tm) { terrainManager = tm; } void setWMORenderer(WMORenderer* wmo) { wmoRenderer = wmo; } @@ -137,7 +139,9 @@ private: float collisionDistance = 10.0f; // Max allowed by collision bool externalFollow_ = false; static constexpr float MIN_DISTANCE = 0.5f; // Minimum zoom (first-person threshold) - static constexpr float MAX_DISTANCE = 50.0f; // Maximum zoom out + static constexpr float MAX_DISTANCE_NORMAL = 22.0f; // Default max zoom out + static constexpr float MAX_DISTANCE_EXTENDED = 50.0f; // Extended max zoom out + bool extendedZoom_ = false; static constexpr float ZOOM_SMOOTH_SPEED = 15.0f; // How fast zoom eases static constexpr float CAM_SMOOTH_SPEED = 20.0f; // How fast camera position smooths static constexpr float PIVOT_HEIGHT = 1.8f; // Pivot at head height diff --git a/include/rendering/celestial.hpp b/include/rendering/celestial.hpp index 27e78ffb..26abd6c6 100644 --- a/include/rendering/celestial.hpp +++ b/include/rendering/celestial.hpp @@ -1,142 +1,133 @@ #pragma once -#include #include +#include +#include namespace wowee { namespace rendering { -class Shader; -class Camera; +class VkContext; /** - * Celestial body renderer + * Celestial body renderer (Vulkan) * * Renders sun and moon that move across the sky based on time of day. * Sun rises at dawn, sets at dusk. Moon is visible at night. + * + * Pipeline layout: + * set 0 = perFrameLayout (camera UBO β€” view, projection, etc.) + * push = CelestialPush (mat4 model + vec4 celestialColor + float intensity + * + float moonPhase + float animTime = 96 bytes) */ class Celestial { public: Celestial(); ~Celestial(); - bool initialize(); + /** + * Initialize the renderer. + * @param ctx Vulkan context + * @param perFrameLayout Descriptor set layout for set 0 (camera UBO) + */ + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); + void recreatePipelines(); /** - * Render celestial bodies (sun and moon) - * @param camera Camera for view matrix - * @param timeOfDay Time of day in hours (0-24) - * @param sunDir Optional sun direction from lighting system (normalized) - * @param sunColor Optional sun color from lighting system - * @param gameTime Optional server game time in seconds (for deterministic moon phases) + * Render celestial bodies (sun and moons). + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0, camera UBO) + * @param timeOfDay Time of day in hours (0-24) + * @param sunDir Optional sun direction from lighting system (normalized) + * @param sunColor Optional sun colour from lighting system + * @param gameTime Optional server game time in seconds (deterministic moon phases) */ - void render(const Camera& camera, float timeOfDay, - const glm::vec3* sunDir = nullptr, + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, + float timeOfDay, + const glm::vec3* sunDir = nullptr, const glm::vec3* sunColor = nullptr, float gameTime = -1.0f); /** - * Enable/disable celestial rendering - */ - void setEnabled(bool enabled) { renderingEnabled = enabled; } - bool isEnabled() const { return renderingEnabled; } - - /** - * Update celestial bodies (for moon phase cycling) + * Update celestial bodies (moon phase cycling, haze timer). */ void update(float deltaTime); - /** - * Set White Lady phase (primary moon, 0.0 = new, 0.5 = full, 1.0 = new) - */ + // --- Enable / disable --- + void setEnabled(bool enabled) { renderingEnabled_ = enabled; } + bool isEnabled() const { return renderingEnabled_; } + + // --- Moon phases --- + /** Set White Lady phase (primary moon, 0 = new, 0.5 = full, 1 = new). */ void setMoonPhase(float phase); float getMoonPhase() const { return whiteLadyPhase_; } - /** - * Set Blue Child phase (secondary moon, 0.0 = new, 0.5 = full, 1.0 = new) - */ + /** Set Blue Child phase (secondary moon, 0 = new, 0.5 = full, 1 = new). */ void setBlueChildPhase(float phase); float getBlueChildPhase() const { return blueChildPhase_; } - /** - * Enable/disable automatic moon phase cycling - */ - void setMoonPhaseCycling(bool enabled) { moonPhaseCycling = enabled; } - bool isMoonPhaseCycling() const { return moonPhaseCycling; } + void setMoonPhaseCycling(bool enabled) { moonPhaseCycling_ = enabled; } + bool isMoonPhaseCycling() const { return moonPhaseCycling_; } - /** - * Enable/disable two-moon rendering (White Lady + Blue Child) - */ + /** Enable / disable two-moon rendering (White Lady + Blue Child). */ void setDualMoonMode(bool enabled) { dualMoonMode_ = enabled; } bool isDualMoonMode() const { return dualMoonMode_; } - /** - * Get sun position in world space - */ + // --- Positional / colour queries (unchanged from GL version) --- glm::vec3 getSunPosition(float timeOfDay) const; - - /** - * Get moon position in world space - */ glm::vec3 getMoonPosition(float timeOfDay) const; - - /** - * Get sun color (changes with time of day) - */ glm::vec3 getSunColor(float timeOfDay) const; - - /** - * Get sun intensity (0-1, fades at dawn/dusk) - */ - float getSunIntensity(float timeOfDay) const; + float getSunIntensity(float timeOfDay) const; private: - void createCelestialQuad(); - void destroyCelestialQuad(); + // Push constant block β€” MUST match celestial.vert.glsl / celestial.frag.glsl + struct CelestialPush { + glm::mat4 model; // 64 bytes + glm::vec4 celestialColor; // 16 bytes (xyz = colour, w unused) + float intensity; // 4 bytes + float moonPhase; // 4 bytes + float animTime; // 4 bytes + float _pad; // 4 bytes (round to 16-byte boundary = 96 bytes total) + }; + static_assert(sizeof(CelestialPush) == 96, "CelestialPush size mismatch"); - void renderSun(const Camera& camera, float timeOfDay, - const glm::vec3* sunDir = nullptr, - const glm::vec3* sunColor = nullptr); - void renderMoon(const Camera& camera, float timeOfDay); // White Lady (primary) - void renderBlueChild(const Camera& camera, float timeOfDay); // Blue Child (secondary) + void createQuad(); + void destroyQuad(); + + void renderSun(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, + float timeOfDay, + const glm::vec3* sunDir, const glm::vec3* sunColor); + void renderMoon(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, float timeOfDay); + void renderBlueChild(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, float timeOfDay); float calculateCelestialAngle(float timeOfDay, float riseTime, float setTime) const; - - /** - * Compute moon phase from game time (deterministic) - * @param gameTime Server game time in seconds - * @param cycleDays Lunar cycle length in game days - * @return Phase 0.0-1.0 (0=new, 0.5=full, 1.0=new) - */ float computePhaseFromGameTime(float gameTime, float cycleDays) const; + void updatePhasesFromGameTime(float gameTime); - /** - * Update moon phases from game time (server-driven) - */ - void updatePhasesFromGameTime(float gameTime); + // Vulkan objects + VkContext* vkCtx_ = nullptr; + VkPipeline pipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + VkBuffer vertexBuffer_ = VK_NULL_HANDLE; + VmaAllocation vertexAlloc_ = VK_NULL_HANDLE; + VkBuffer indexBuffer_ = VK_NULL_HANDLE; + VmaAllocation indexAlloc_ = VK_NULL_HANDLE; - std::unique_ptr celestialShader; - - uint32_t vao = 0; - uint32_t vbo = 0; - uint32_t ebo = 0; - - bool renderingEnabled = true; + bool renderingEnabled_ = true; // Moon phase system (two moons in Azeroth lore) - float whiteLadyPhase_ = 0.5f; // 0.0-1.0 (0=new, 0.5=full) - primary moon - float blueChildPhase_ = 0.25f; // 0.0-1.0 (0=new, 0.5=full) - secondary moon - bool moonPhaseCycling = true; - float moonPhaseTimer = 0.0f; // Fallback for deltaTime mode (development) - float sunHazeTimer_ = 0.0f; // Always-running timer for sun haze animation - bool dualMoonMode_ = true; // Default: render both moons (Azeroth-specific) + float whiteLadyPhase_ = 0.5f; // 0-1, 0=new, 0.5=full + float blueChildPhase_ = 0.25f; // 0-1 + bool moonPhaseCycling_ = true; + float moonPhaseTimer_ = 0.0f; // Fallback deltaTime mode + float sunHazeTimer_ = 0.0f; // Always-running haze animation timer + bool dualMoonMode_ = true; - // WoW lunar cycle constants (in game days) - // WoW day = 24 real minutes, so these are ~realistic game-world cycles - static constexpr float WHITE_LADY_CYCLE_DAYS = 30.0f; // ~12 real hours for full cycle - static constexpr float BLUE_CHILD_CYCLE_DAYS = 27.0f; // ~10.8 real hours (slightly faster) - static constexpr float MOON_CYCLE_DURATION = 240.0f; // Fallback: 4 minutes (deltaTime mode) + // WoW lunar cycle constants (game days; 1 game day = 24 real minutes) + static constexpr float WHITE_LADY_CYCLE_DAYS = 30.0f; + static constexpr float BLUE_CHILD_CYCLE_DAYS = 27.0f; + static constexpr float MOON_CYCLE_DURATION = 240.0f; // Fallback: 4 minutes }; } // namespace rendering diff --git a/include/rendering/character_preview.hpp b/include/rendering/character_preview.hpp index 14ca587f..7ff5352c 100644 --- a/include/rendering/character_preview.hpp +++ b/include/rendering/character_preview.hpp @@ -1,7 +1,8 @@ #pragma once #include "game/character.hpp" -#include +#include +#include #include #include #include @@ -13,6 +14,9 @@ namespace rendering { class CharacterRenderer; class Camera; +class VkContext; +class VkTexture; +class VkRenderTarget; class CharacterPreview { public: @@ -34,7 +38,15 @@ public: void render(); void rotate(float yawDelta); - GLuint getTextureId() const { return colorTexture_; } + // Off-screen composite pass β€” call from Renderer::beginFrame() before main render pass + void compositePass(VkCommandBuffer cmd, uint32_t frameIndex); + + // Mark that the preview needs compositing this frame (call from UI each frame) + void requestComposite() { compositeRequested_ = true; } + + // Returns the ImGui texture handle. Returns VK_NULL_HANDLE until the first + // compositePass has run (image is in UNDEFINED layout before that). + VkDescriptorSet getTextureId() const { return compositeRendered_ ? imguiTextureId_ : VK_NULL_HANDLE; } int getWidth() const { return fboWidth_; } int getHeight() const { return fboHeight_; } @@ -48,19 +60,36 @@ private: void destroyFBO(); pipeline::AssetManager* assetManager_ = nullptr; + VkContext* vkCtx_ = nullptr; std::unique_ptr charRenderer_; std::unique_ptr camera_; - GLuint fbo_ = 0; - GLuint colorTexture_ = 0; - GLuint depthRenderbuffer_ = 0; + // Off-screen render target (color + depth) + std::unique_ptr renderTarget_; + + // Per-frame UBO for preview camera/lighting (double-buffered) + static constexpr uint32_t MAX_FRAMES = 2; + VkDescriptorPool previewDescPool_ = VK_NULL_HANDLE; + VkBuffer previewUBO_[MAX_FRAMES] = {}; + VmaAllocation previewUBOAlloc_[MAX_FRAMES] = {}; + void* previewUBOMapped_[MAX_FRAMES] = {}; + VkDescriptorSet previewPerFrameSet_[MAX_FRAMES] = {}; + + // Dummy 1x1 white texture for shadow map placeholder + std::unique_ptr dummyWhiteTex_; + + // ImGui texture handle for displaying the preview (VkDescriptorSet in Vulkan backend) + VkDescriptorSet imguiTextureId_ = VK_NULL_HANDLE; + static constexpr int fboWidth_ = 400; static constexpr int fboHeight_ = 500; static constexpr uint32_t PREVIEW_MODEL_ID = 9999; uint32_t instanceId_ = 0; bool modelLoaded_ = false; - float modelYaw_ = 180.0f; + bool compositeRequested_ = false; + bool compositeRendered_ = false; // True after first successful compositePass + float modelYaw_ = 90.0f; // Cached info from loadCharacter() for later recompositing. game::Race race_ = game::Race::HUMAN; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 30001846..9ee98a7d 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -1,22 +1,25 @@ #pragma once #include "pipeline/m2_loader.hpp" -#include +#include +#include #include #include #include #include #include #include +#include +#include namespace wowee { namespace pipeline { class AssetManager; } namespace rendering { // Forward declarations -class Shader; -class Texture; class Camera; +class VkContext; +class VkTexture; // Weapon attached to a character instance at a bone attachment point struct WeaponAttachment { @@ -33,7 +36,7 @@ struct WeaponAttachment { * Features: * - Skeletal animation with bone transformations * - Keyframe interpolation (linear position/scale, slerp rotation) - * - Vertex skinning (GPU-accelerated) + * - Vertex skinning (GPU-accelerated via bone SSBO) * - Texture loading from BLP via AssetManager */ class CharacterRenderer { @@ -41,7 +44,9 @@ public: CharacterRenderer(); ~CharacterRenderer(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, pipeline::AssetManager* am, + VkRenderPass renderPassOverride = VK_NULL_HANDLE, + VkSampleCountFlagBits msaaSamples = VK_SAMPLE_COUNT_1_BIT); void shutdown(); void setAssetManager(pipeline::AssetManager* am) { assetManager = am; } @@ -56,8 +61,11 @@ public: void update(float deltaTime, const glm::vec3& cameraPos = glm::vec3(0.0f)); - void render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); - void renderShadow(const glm::mat4& lightSpaceMatrix); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); + void recreatePipelines(); + bool initializeShadow(VkRenderPass shadowRenderPass); + void renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, + const glm::vec3& shadowCenter = glm::vec3(0), float shadowRadius = 1e9f); void setInstancePosition(uint32_t instanceId, const glm::vec3& position); void setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation); @@ -65,8 +73,8 @@ public: void startFadeIn(uint32_t instanceId, float durationSeconds); const pipeline::M2Model* getModelData(uint32_t modelId) const; void setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets); - void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId); - void setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, GLuint textureId); + void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture); + void setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, VkTexture* texture); void clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot); void setInstanceVisible(uint32_t instanceId, bool visible); void removeInstance(uint32_t instanceId); @@ -88,45 +96,38 @@ public: /** Detach a weapon from the given attachment point. */ void detachWeapon(uint32_t charInstanceId, uint32_t attachmentId); - /** Get the world-space transform of an attachment point on an instance. - * Used for mount seats, weapon positions, etc. - * @param instanceId The character/mount instance - * @param attachmentId The attachment point ID (0=Mount, 1=RightHand, 2=LeftHand, etc.) - * @param outTransform The resulting world-space transform matrix - * @return true if attachment found and matrix computed - */ + /** Get the world-space transform of an attachment point on an instance. */ bool getAttachmentTransform(uint32_t instanceId, uint32_t attachmentId, glm::mat4& outTransform); size_t getInstanceCount() const { return instances.size(); } - void setFog(const glm::vec3& color, float start, float end) { - fogColor = color; fogStart = start; fogEnd = end; - } + // Normal mapping / POM settings + void setNormalMappingEnabled(bool enabled) { normalMappingEnabled_ = enabled; } + void setNormalMapStrength(float strength) { normalMapStrength_ = strength; } + void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; } + void setPOMQuality(int quality) { pomQuality_ = quality; } - void setLighting(const float lightDirIn[3], const float lightColorIn[3], - const float ambientColorIn[3]) { - lightDir = glm::vec3(lightDirIn[0], lightDirIn[1], lightDirIn[2]); - lightColor = glm::vec3(lightColorIn[0], lightColorIn[1], lightColorIn[2]); - ambientColor = glm::vec3(ambientColorIn[0], ambientColorIn[1], ambientColorIn[2]); - } - - void setShadowMap(GLuint depthTex, const glm::mat4& lightSpace) { - shadowDepthTex = depthTex; lightSpaceMatrix = lightSpace; shadowEnabled = true; - } - void clearShadowMap() { shadowEnabled = false; } + // Fog/lighting/shadow are now in per-frame UBO β€” keep stubs for callers that haven't been updated + void setFog(const glm::vec3&, float, float) {} + void setLighting(const float[3], const float[3], const float[3]) {} + void setShadowMap(VkTexture*, const glm::mat4&) {} + void clearShadowMap() {} private: // GPU representation of M2 model struct M2ModelGPU { - uint32_t vao = 0; - uint32_t vbo = 0; - uint32_t ebo = 0; + VkBuffer vertexBuffer = VK_NULL_HANDLE; + VmaAllocation vertexAlloc = VK_NULL_HANDLE; + VkBuffer indexBuffer = VK_NULL_HANDLE; + VmaAllocation indexAlloc = VK_NULL_HANDLE; + uint32_t indexCount = 0; + uint32_t vertexCount = 0; pipeline::M2Model data; // Original model data std::vector bindPose; // Inverse bind pose matrices // Textures loaded from BLP (indexed by texture array position) - std::vector textureIds; + std::vector textureIds; }; // Character instance @@ -151,11 +152,11 @@ private: // Empty = render all (for non-character models) std::unordered_set activeGeosets; - // Per-geoset-group texture overrides (group β†’ GL texture ID) - std::unordered_map groupTextureOverrides; + // Per-geoset-group texture overrides (group β†’ VkTexture*) + std::unordered_map groupTextureOverrides; - // Per-texture-slot overrides (slot β†’ GL texture ID) - std::unordered_map textureSlotOverrides; + // Per-texture-slot overrides (slot β†’ VkTexture*) + std::unordered_map textureSlotOverrides; // Weapon attachments (weapons parented to this instance's bones) std::vector weaponAttachments; @@ -175,6 +176,12 @@ private: // Override model matrix (used for weapon instances positioned by parent bone) bool hasOverrideModelMatrix = false; glm::mat4 overrideModelMatrix{1.0f}; + + // Per-instance bone SSBO (double-buffered per frame) + VkBuffer boneBuffer[2] = {}; + VmaAllocation boneAlloc[2] = {}; + void* boneMapped[2] = {}; + VkDescriptorSet boneSet[2] = {}; }; void setupModelBuffers(M2ModelGPU& gpuModel); @@ -183,6 +190,8 @@ private: void calculateBoneMatrices(CharacterInstance& instance); glm::mat4 getBoneTransform(const pipeline::M2Bone& bone, float time, int sequenceIndex); glm::mat4 getModelMatrix(const CharacterInstance& instance) const; + void destroyModelGPU(M2ModelGPU& gpuModel); + void destroyInstanceBones(CharacterInstance& inst); // Keyframe interpolation helpers static int findKeyframeIndex(const std::vector& timestamps, float time); @@ -194,84 +203,107 @@ private: public: /** * Build a composited character skin texture by alpha-blending overlay - * layers (e.g. underwear) onto a base skin BLP. Each overlay is placed - * at the correct CharComponentTextureSections region based on its - * filename (pelvis, torso, etc.). Returns the resulting GL texture ID. + * layers onto a base skin BLP. Returns the resulting VkTexture*. */ - GLuint compositeTextures(const std::vector& layerPaths); + VkTexture* compositeTextures(const std::vector& layerPaths); /** * Build a composited character skin with explicit region-based equipment overlays. - * @param basePath Body skin texture path - * @param baseLayers Underwear overlay paths (placed by filename keyword) - * @param regionLayers Pairs of (region_index, blp_path) for equipment textures - * @return GL texture ID of the composited result */ - GLuint compositeWithRegions(const std::string& basePath, + VkTexture* compositeWithRegions(const std::string& basePath, const std::vector& baseLayers, const std::vector>& regionLayers); /** Clear the composite texture cache (forces re-compositing on next call). */ void clearCompositeCache(); - /** Load a BLP texture from MPQ and return the GL texture ID (cached). */ - GLuint loadTexture(const std::string& path); - GLuint getTransparentTexture() const { return transparentTexture; } + /** Load a BLP texture from MPQ and return VkTexture* (cached). */ + VkTexture* loadTexture(const std::string& path); + VkTexture* getTransparentTexture() const { return transparentTexture_.get(); } - /** Replace a loaded model's texture at the given slot with a new GL texture. */ - void setModelTexture(uint32_t modelId, uint32_t textureSlot, GLuint textureId); + /** Replace a loaded model's texture at the given slot. */ + void setModelTexture(uint32_t modelId, uint32_t textureSlot, VkTexture* texture); /** Reset a model's texture slot back to white fallback. */ void resetModelTexture(uint32_t modelId, uint32_t textureSlot); private: - std::unique_ptr characterShader; - GLuint shadowCasterProgram = 0; + VkContext* vkCtx_ = nullptr; + VkRenderPass renderPassOverride_ = VK_NULL_HANDLE; + VkSampleCountFlagBits msaaSamplesOverride_ = VK_SAMPLE_COUNT_1_BIT; pipeline::AssetManager* assetManager = nullptr; - // Fog parameters - glm::vec3 fogColor = glm::vec3(0.5f, 0.6f, 0.7f); - float fogStart = 400.0f; - float fogEnd = 1200.0f; + // Vulkan pipelines (one per blend mode) + VkPipeline opaquePipeline_ = VK_NULL_HANDLE; + VkPipeline alphaTestPipeline_ = VK_NULL_HANDLE; + VkPipeline alphaPipeline_ = VK_NULL_HANDLE; + VkPipeline additivePipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; - // Lighting parameters - glm::vec3 lightDir = glm::vec3(0.0f, -1.0f, 0.3f); - glm::vec3 lightColor = glm::vec3(1.5f, 1.4f, 1.3f); - glm::vec3 ambientColor = glm::vec3(0.4f, 0.4f, 0.45f); + // Descriptor set layouts + VkDescriptorSetLayout perFrameLayout_ = VK_NULL_HANDLE; // set 0 (owned by Renderer) + VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1 + VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2 - // Shadow mapping - GLuint shadowDepthTex = 0; - glm::mat4 lightSpaceMatrix = glm::mat4(1.0f); - bool shadowEnabled = false; + // Descriptor pool + VkDescriptorPool materialDescPools_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE}; + VkDescriptorPool boneDescPool_ = VK_NULL_HANDLE; + uint32_t lastMaterialPoolResetFrame_ = 0xFFFFFFFFu; + std::vector> transientMaterialUbos_[2]; // Texture cache struct TextureCacheEntry { - GLuint id = 0; + std::unique_ptr texture; + std::unique_ptr normalHeightMap; + float heightMapVariance = 0.0f; size_t approxBytes = 0; uint64_t lastUse = 0; bool hasAlpha = false; bool colorKeyBlack = false; }; std::unordered_map textureCache; - std::unordered_map textureHasAlphaById_; - std::unordered_map textureColorKeyBlackById_; - std::unordered_map compositeCache_; // key β†’ GPU texture for reuse - std::unordered_set failedTextureCache_; // negative cache for missing textures + std::unordered_map textureHasAlphaByPtr_; + std::unordered_map textureColorKeyBlackByPtr_; + std::unordered_map compositeCache_; // key β†’ texture for reuse + std::unordered_set failedTextureCache_; // negative cache for budget exhaustion + std::unordered_set loggedTextureLoadFails_; // dedup warning logs size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; - size_t textureCacheBudgetBytes_ = 1024ull * 1024 * 1024; // Default, overridden at init - GLuint whiteTexture = 0; - GLuint transparentTexture = 0; + size_t textureCacheBudgetBytes_ = 1024ull * 1024 * 1024; + uint32_t textureBudgetRejectWarnings_ = 0; + std::unique_ptr whiteTexture_; + std::unique_ptr transparentTexture_; + std::unique_ptr flatNormalTexture_; std::unordered_map models; std::unordered_map instances; uint32_t nextInstanceId = 1; - // Maximum bones supported (GPU uniform limit) - // WoW character models can have 210+ bones; GPU reports 4096 components (~256 mat4) + // Normal map generation (same algorithm as WMO renderer) + std::unique_ptr generateNormalHeightMap( + const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance); + + // Normal mapping / POM settings + bool normalMappingEnabled_ = true; + float normalMapStrength_ = 0.8f; + bool pomEnabled_ = true; + int pomQuality_ = 1; // 0=Low(16), 1=Medium(32), 2=High(64) + + // Maximum bones supported static constexpr int MAX_BONES = 240; + uint32_t numAnimThreads_ = 1; + std::vector> animFutures_; + + // Shadow pipeline resources + VkPipeline shadowPipeline_ = VK_NULL_HANDLE; + VkPipelineLayout shadowPipelineLayout_ = VK_NULL_HANDLE; + VkDescriptorSetLayout shadowParamsLayout_ = VK_NULL_HANDLE; + VkDescriptorPool shadowParamsPool_ = VK_NULL_HANDLE; + VkDescriptorSet shadowParamsSet_ = VK_NULL_HANDLE; + VkBuffer shadowParamsUBO_ = VK_NULL_HANDLE; + VmaAllocation shadowParamsAlloc_ = VK_NULL_HANDLE; }; } // namespace rendering diff --git a/include/rendering/charge_effect.hpp b/include/rendering/charge_effect.hpp index 9319a601..4154df4d 100644 --- a/include/rendering/charge_effect.hpp +++ b/include/rendering/charge_effect.hpp @@ -1,8 +1,8 @@ #pragma once -#include +#include +#include #include -#include #include #include #include @@ -12,7 +12,7 @@ namespace pipeline { class AssetManager; } namespace rendering { class Camera; -class Shader; +class VkContext; class M2Renderer; /// Renders a red-orange ribbon streak trailing behind the warrior during Charge, @@ -22,8 +22,9 @@ public: ChargeEffect(); ~ChargeEffect(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); + void recreatePipelines(); /// Try to load M2 spell models (Charge_Caster.m2, etc.) void tryLoadM2Models(M2Renderer* m2Renderer, pipeline::AssetManager* assets); @@ -41,7 +42,7 @@ public: void triggerImpact(const glm::vec3& position); void update(float deltaTime); - void render(const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); bool isActive() const { return emitting_ || !trail_.empty() || !dustPuffs_.empty(); } @@ -59,10 +60,17 @@ private: static constexpr float TRAIL_SPAWN_DIST = 0.4f; // Min distance between trail points std::deque trail_; - GLuint ribbonVao_ = 0; - GLuint ribbonVbo_ = 0; - std::unique_ptr ribbonShader_; - std::vector ribbonVerts_; // pos(3) + alpha(1) + heat(1) = 5 floats per vert + // Vulkan objects + VkContext* vkCtx_ = nullptr; + + // Ribbon pipeline + dynamic buffer + VkPipeline ribbonPipeline_ = VK_NULL_HANDLE; + VkPipelineLayout ribbonPipelineLayout_ = VK_NULL_HANDLE; + ::VkBuffer ribbonDynamicVB_ = VK_NULL_HANDLE; + VmaAllocation ribbonDynamicVBAlloc_ = VK_NULL_HANDLE; + VmaAllocationInfo ribbonDynamicVBAllocInfo_{}; + VkDeviceSize ribbonDynamicVBSize_ = 0; + std::vector ribbonVerts_; // pos(3) + alpha(1) + heat(1) + height(1) = 6 floats per vert // --- Dust puffs (small point sprites at feet) --- struct DustPuff { @@ -77,9 +85,13 @@ private: static constexpr int MAX_DUST = 80; std::vector dustPuffs_; - GLuint dustVao_ = 0; - GLuint dustVbo_ = 0; - std::unique_ptr dustShader_; + // Dust pipeline + dynamic buffer + VkPipeline dustPipeline_ = VK_NULL_HANDLE; + VkPipelineLayout dustPipelineLayout_ = VK_NULL_HANDLE; + ::VkBuffer dustDynamicVB_ = VK_NULL_HANDLE; + VmaAllocation dustDynamicVBAlloc_ = VK_NULL_HANDLE; + VmaAllocationInfo dustDynamicVBAllocInfo_{}; + VkDeviceSize dustDynamicVBSize_ = 0; std::vector dustVerts_; bool emitting_ = false; diff --git a/include/rendering/clouds.hpp b/include/rendering/clouds.hpp index 39f8dcd6..800b5c0f 100644 --- a/include/rendering/clouds.hpp +++ b/include/rendering/clouds.hpp @@ -1,94 +1,96 @@ #pragma once -#include #include -#include +#include +#include #include namespace wowee { namespace rendering { -class Camera; -class Shader; +class VkContext; +struct SkyParams; /** - * @brief Renders procedural animated clouds on a sky dome + * Procedural cloud renderer (Vulkan) * - * Features: - * - Procedural cloud generation using multiple noise layers - * - Two cloud layers at different altitudes - * - Animated wind movement - * - Time-of-day color tinting (orange at sunrise/sunset) - * - Transparency and soft edges + * Renders animated procedural clouds on a sky hemisphere using FBM noise. + * Sun-lit edges, self-shadowing, and DBC-driven cloud colors for realistic appearance. + * + * Pipeline layout: + * set 0 = perFrameLayout (camera UBO β€” view, projection, etc.) + * push = CloudPush (3 x vec4 = 48 bytes) */ class Clouds { public: Clouds(); ~Clouds(); - /** - * @brief Initialize cloud system (generate mesh and shaders) - * @return true if initialization succeeded - */ - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); + void shutdown(); + void recreatePipelines(); /** - * @brief Render clouds - * @param camera The camera to render from - * @param timeOfDay Current time (0-24 hours) + * Render clouds using DBC-driven colors and sun lighting. + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0, camera UBO) + * @param params Sky parameters with DBC colors and sun direction */ - void render(const Camera& camera, float timeOfDay); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const SkyParams& params); /** - * @brief Update cloud animation - * @param deltaTime Time since last frame + * Update cloud animation (wind drift). */ void update(float deltaTime); - /** - * @brief Enable or disable cloud rendering - */ - void setEnabled(bool enabled) { this->enabled = enabled; } - bool isEnabled() const { return enabled; } + // --- Enable / disable --- + void setEnabled(bool enabled) { enabled_ = enabled; } + bool isEnabled() const { return enabled_; } - /** - * @brief Set cloud density (0.0 = clear, 1.0 = overcast) - */ + // --- Cloud parameters --- void setDensity(float density); - float getDensity() const { return density; } + float getDensity() const { return density_; } - /** - * @brief Set wind speed multiplier - */ - void setWindSpeed(float speed) { windSpeed = speed; } - float getWindSpeed() const { return windSpeed; } + void setWindSpeed(float speed) { windSpeed_ = speed; } + float getWindSpeed() const { return windSpeed_; } private: + // Push constant block β€” must match clouds.frag.glsl + struct CloudPush { + glm::vec4 cloudColor; // xyz = DBC-derived base cloud color, w = unused + glm::vec4 sunDirDensity; // xyz = sun direction, w = density + glm::vec4 windAndLight; // x = windOffset, y = sunIntensity, z = ambient, w = unused + }; + static_assert(sizeof(CloudPush) == 48, "CloudPush size mismatch"); + void generateMesh(); - void cleanup(); - glm::vec3 getCloudColor(float timeOfDay) const; + void createBuffers(); + void destroyBuffers(); - // OpenGL objects - GLuint vao = 0; - GLuint vbo = 0; - GLuint ebo = 0; - std::unique_ptr shader; + // Vulkan objects + VkContext* vkCtx_ = nullptr; + VkPipeline pipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + VkBuffer vertexBuffer_ = VK_NULL_HANDLE; + VmaAllocation vertexAlloc_ = VK_NULL_HANDLE; + VkBuffer indexBuffer_ = VK_NULL_HANDLE; + VmaAllocation indexAlloc_ = VK_NULL_HANDLE; - // Mesh data - std::vector vertices; - std::vector indices; - int triangleCount = 0; + // Mesh data (CPU side, used during initialization only) + std::vector vertices_; + std::vector indices_; + int indexCount_ = 0; // Cloud parameters - bool enabled = true; - float density = 0.5f; // Cloud coverage - float windSpeed = 1.0f; - float windOffset = 0.0f; // Accumulated wind movement + bool enabled_ = true; + float density_ = 0.35f; + float windSpeed_ = 1.0f; + float windOffset_ = 0.0f; // Mesh generation parameters - static constexpr int SEGMENTS = 32; // Horizontal segments - static constexpr int RINGS = 8; // Vertical rings (only upper hemisphere) - static constexpr float RADIUS = 900.0f; // Slightly smaller than skybox + static constexpr int SEGMENTS = 32; + static constexpr int RINGS = 8; + static constexpr float RADIUS = 900.0f; }; } // namespace rendering diff --git a/include/rendering/lens_flare.hpp b/include/rendering/lens_flare.hpp index 4b390bcb..f4dac6fd 100644 --- a/include/rendering/lens_flare.hpp +++ b/include/rendering/lens_flare.hpp @@ -1,15 +1,15 @@ #pragma once -#include +#include +#include #include -#include #include namespace wowee { namespace rendering { class Camera; -class Shader; +class VkContext; /** * @brief Renders lens flare effect when looking at the sun @@ -28,17 +28,32 @@ public: /** * @brief Initialize lens flare system + * @param ctx Vulkan context + * @param perFrameLayout Per-frame descriptor set layout (unused, kept for API consistency) * @return true if initialization succeeded */ - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); + + /** + * @brief Destroy Vulkan resources + */ + void shutdown(); + + void recreatePipelines(); /** * @brief Render lens flare effect + * @param cmd Command buffer to record into * @param camera The camera to render from * @param sunPosition World-space sun position * @param timeOfDay Current time (0-24 hours) + * @param fogDensity Fog density 0-1 (attenuates flare) + * @param cloudDensity Cloud density 0-1 (attenuates flare) + * @param weatherIntensity Weather intensity 0-1 (rain/snow attenuates flare) */ - void render(const Camera& camera, const glm::vec3& sunPosition, float timeOfDay); + void render(VkCommandBuffer cmd, const Camera& camera, const glm::vec3& sunPosition, + float timeOfDay, float fogDensity = 0.0f, float cloudDensity = 0.0f, + float weatherIntensity = 0.0f); /** * @brief Enable or disable lens flare rendering @@ -60,15 +75,24 @@ private: float brightness; // Brightness multiplier }; + struct FlarePushConstants { + glm::vec2 position; // Screen-space position (-1 to 1) + float size; // Size in screen space + float aspectRatio; // Viewport aspect ratio + glm::vec4 colorBrightness; // RGB color + brightness in w + }; + void generateFlareElements(); - void cleanup(); float calculateSunVisibility(const Camera& camera, const glm::vec3& sunPosition) const; glm::vec2 worldToScreen(const Camera& camera, const glm::vec3& worldPos) const; - // OpenGL objects - GLuint vao = 0; - GLuint vbo = 0; - std::unique_ptr shader; + VkContext* vkCtx = nullptr; + + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + + VkBuffer vertexBuffer = VK_NULL_HANDLE; + VmaAllocation vertexAlloc = VK_NULL_HANDLE; // Flare elements std::vector flareElements; diff --git a/include/rendering/lighting_manager.hpp b/include/rendering/lighting_manager.hpp index f6b23627..11c079a5 100644 --- a/include/rendering/lighting_manager.hpp +++ b/include/rendering/lighting_manager.hpp @@ -28,7 +28,7 @@ struct LightingParams { glm::vec3 skyBand1Color{0.9f, 0.95f, 1.0f}; // Sky band 1 glm::vec3 skyBand2Color{1.0f, 0.98f, 0.9f}; // Sky band 2 - float cloudDensity = 1.0f; // Cloud density/opacity + float cloudDensity = 0.3f; // Cloud density/opacity float horizonGlow = 0.3f; // Horizon glow intensity }; diff --git a/include/rendering/lightning.hpp b/include/rendering/lightning.hpp index f4e94cfb..33152192 100644 --- a/include/rendering/lightning.hpp +++ b/include/rendering/lightning.hpp @@ -1,15 +1,15 @@ #pragma once +#include +#include #include -#include #include namespace wowee { namespace rendering { -// Forward declarations -class Shader; class Camera; +class VkContext; /** * Lightning system for thunder storm effects @@ -26,11 +26,12 @@ public: Lightning(); ~Lightning(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); + void recreatePipelines(); void update(float deltaTime, const Camera& camera); - void render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); // Control void setEnabled(bool enabled); @@ -68,8 +69,8 @@ private: void updateFlash(float deltaTime); void spawnRandomStrike(const glm::vec3& cameraPos); - void renderBolts(const glm::mat4& viewProj); - void renderFlash(); + void renderBolts(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + void renderFlash(VkCommandBuffer cmd); bool enabled = true; float intensity = 0.5f; // Strike frequency multiplier @@ -82,13 +83,22 @@ private: std::vector bolts; Flash flash; - // Rendering - std::unique_ptr boltShader; - std::unique_ptr flashShader; - unsigned int boltVAO = 0; - unsigned int boltVBO = 0; - unsigned int flashVAO = 0; - unsigned int flashVBO = 0; + // Vulkan objects + VkContext* vkCtx = nullptr; + + // Bolt pipeline + dynamic buffer + VkPipeline boltPipeline = VK_NULL_HANDLE; + VkPipelineLayout boltPipelineLayout = VK_NULL_HANDLE; + ::VkBuffer boltDynamicVB = VK_NULL_HANDLE; + VmaAllocation boltDynamicVBAlloc = VK_NULL_HANDLE; + VmaAllocationInfo boltDynamicVBAllocInfo{}; + VkDeviceSize boltDynamicVBSize = 0; + + // Flash pipeline + static quad buffer + VkPipeline flashPipeline = VK_NULL_HANDLE; + VkPipelineLayout flashPipelineLayout = VK_NULL_HANDLE; + ::VkBuffer flashQuadVB = VK_NULL_HANDLE; + VmaAllocation flashQuadVBAlloc = VK_NULL_HANDLE; // Configuration static constexpr int MAX_BOLTS = 3; diff --git a/include/rendering/loading_screen.hpp b/include/rendering/loading_screen.hpp index 3d5272bb..52f0ea08 100644 --- a/include/rendering/loading_screen.hpp +++ b/include/rendering/loading_screen.hpp @@ -1,12 +1,14 @@ #pragma once -#include +#include #include #include namespace wowee { namespace rendering { +class VkContext; + class LoadingScreen { public: LoadingScreen(); @@ -15,32 +17,28 @@ public: bool initialize(); void shutdown(); - // Select a random loading screen image void selectRandomImage(); - // Render the loading screen with progress bar and status text + // Render the loading screen with progress bar and status text (pure ImGui) void render(); - // Update loading progress (0.0 to 1.0) void setProgress(float progress) { loadProgress = progress; } - - // Set loading status text void setStatus(const std::string& status) { statusText = status; } + // Must be set before initialize() for Vulkan texture upload + void setVkContext(VkContext* ctx) { vkCtx = ctx; } + private: bool loadImage(const std::string& path); - void createQuad(); - void createBarQuad(); - GLuint textureId = 0; - GLuint vao = 0; - GLuint vbo = 0; - GLuint shaderId = 0; + VkContext* vkCtx = nullptr; - // Progress bar GL objects - GLuint barVao = 0; - GLuint barVbo = 0; - GLuint barShaderId = 0; + // Vulkan texture for background image + VkImage bgImage = VK_NULL_HANDLE; + VkDeviceMemory bgMemory = VK_NULL_HANDLE; + VkImageView bgImageView = VK_NULL_HANDLE; + VkSampler bgSampler = VK_NULL_HANDLE; + VkDescriptorSet bgDescriptorSet = VK_NULL_HANDLE; // ImGui texture handle std::vector imagePaths; int currentImageIndex = 0; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index c6c3a32c..c2a663b5 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -1,7 +1,8 @@ #pragma once #include "pipeline/m2_loader.hpp" -#include +#include +#include #include #include #include @@ -20,15 +21,20 @@ namespace pipeline { namespace rendering { -class Shader; class Camera; +class VkContext; +class VkTexture; /** * GPU representation of an M2 model */ struct M2ModelGPU { struct BatchGPU { - GLuint texture = 0; + VkTexture* texture = nullptr; // from cache, NOT owned + VkDescriptorSet materialSet = VK_NULL_HANDLE; // set 1 + ::VkBuffer materialUBO = VK_NULL_HANDLE; + VmaAllocation materialUBOAlloc = VK_NULL_HANDLE; + void* materialUBOMapped = nullptr; // cached mapped pointer (avoids per-frame vmaGetAllocationInfo) uint32_t indexStart = 0; // offset in indices (not bytes) uint32_t indexCount = 0; bool hasAlpha = false; @@ -47,9 +53,10 @@ struct M2ModelGPU { float glowSize = 1.0f; // Approx radius of batch geometry }; - GLuint vao = 0; - GLuint vbo = 0; - GLuint ebo = 0; + ::VkBuffer vertexBuffer = VK_NULL_HANDLE; + VmaAllocation vertexAlloc = VK_NULL_HANDLE; + ::VkBuffer indexBuffer = VK_NULL_HANDLE; + VmaAllocation indexAlloc = VK_NULL_HANDLE; uint32_t indexCount = 0; uint32_t vertexCount = 0; std::vector batches; @@ -69,6 +76,8 @@ struct M2ModelGPU { bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision) bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path) + bool isWaterVegetation = false; // Cattails, reeds, kelp etc. near water (insect spawning) + bool isFireflyEffect = false; // Firefly/fireflies M2 (exempt from particle dampeners) // Collision mesh with spatial grid (from M2 bounding geometry) struct CollisionMesh { @@ -105,18 +114,22 @@ struct M2ModelGPU { bool isSpellEffect = false; // True for spell effect models (skip particle dampeners) bool disableAnimation = false; // Keep foliage/tree doodads visually stable bool shadowWindFoliage = false; // Apply wind sway in shadow pass for foliage/tree cards + bool isFoliageLike = false; // Model name matches foliage/tree/bush/grass etc (precomputed) + bool isElvenLike = false; // Model name matches elf/elven/quel (precomputed) + bool isLanternLike = false; // Model name matches lantern/lamp/light (precomputed) + bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed) bool hasTextureAnimation = false; // True if any batch has UV animation // Particle emitter data (kept from M2Model) std::vector particleEmitters; - std::vector particleTextures; // Resolved GL textures per emitter + std::vector particleTextures; // Resolved Vulkan textures per emitter // Texture transform data for UV animation std::vector textureTransforms; std::vector textureTransformLookup; std::vector idleVariationIndices; // Sequence indices for idle variations (animId 0) - bool isValid() const { return vao != 0 && indexCount > 0; } + bool isValid() const { return vertexBuffer != VK_NULL_HANDLE && indexCount > 0; } }; /** @@ -164,6 +177,12 @@ struct M2Instance { // Frame-skip optimization (update distant animations less frequently) uint8_t frameSkipCounter = 0; + // Per-instance bone SSBO (double-buffered) + ::VkBuffer boneBuffer[2] = {}; + VmaAllocation boneAlloc[2] = {}; + void* boneMapped[2] = {}; + VkDescriptorSet boneSet[2] = {}; + void updateModelMatrix(); }; @@ -180,8 +199,29 @@ struct SmokeParticle { uint32_t instanceId = 0; }; +// M2 material UBO β€” matches M2Material in m2.frag.glsl (set 1, binding 2) +struct M2MaterialUBO { + int32_t hasTexture; + int32_t alphaTest; + int32_t colorKeyBlack; + float colorKeyThreshold; + int32_t unlit; + int32_t blendMode; + float fadeAlpha; + float interiorDarken; + float specularIntensity; +}; + +// M2 params UBO β€” matches M2Params in m2.vert.glsl (set 1, binding 1) +struct M2ParamsUBO { + float uvOffsetX; + float uvOffsetY; + int32_t texCoordSet; + int32_t useBones; +}; + /** - * M2 Model Renderer + * M2 Model Renderer (Vulkan) * * Handles rendering of M2 models (doodads like trees, rocks, bushes) */ @@ -190,137 +230,59 @@ public: M2Renderer(); ~M2Renderer(); - bool initialize(pipeline::AssetManager* assets); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* assets); void shutdown(); - /** - * Check if a model is already loaded - * @param modelId ID to check - * @return True if model is loaded - */ bool hasModel(uint32_t modelId) const; - - /** - * Load an M2 model to GPU - * @param model Parsed M2 model data - * @param modelId Unique ID for this model - * @return True if successful - */ bool loadModel(const pipeline::M2Model& model, uint32_t modelId); - /** - * Create an instance of a loaded model - * @param modelId ID of the loaded model - * @param position World position - * @param rotation Rotation in degrees (x, y, z) - * @param scale Scale factor (1.0 = normal) - * @return Instance ID - */ uint32_t createInstance(uint32_t modelId, const glm::vec3& position, const glm::vec3& rotation = glm::vec3(0.0f), float scale = 1.0f); - - /** - * Create an instance with a pre-computed model matrix - * Used for WMO doodads where the full transform is computed externally - */ uint32_t createInstanceWithMatrix(uint32_t modelId, const glm::mat4& modelMatrix, const glm::vec3& position); - /** - * Update animation state for all instances - * @param deltaTime Time since last frame - * @param cameraPos Camera world position (for frustum-culling bones) - * @param viewProjection Combined view*projection matrix - */ void update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection); /** - * Render all visible instances + * Render all visible instances (Vulkan) */ - void render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); + + /** + * Initialize shadow pipeline (Phase 7) + */ + bool initializeShadow(VkRenderPass shadowRenderPass); /** * Render depth-only pass for shadow casting */ - void renderShadow(GLuint shadowShaderProgram, const glm::vec3& shadowCenter, float halfExtent); + void renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, float globalTime = 0.0f, + const glm::vec3& shadowCenter = glm::vec3(0), float shadowRadius = 1e9f); /** - * Render smoke particles (call after render()) + * Render M2 particle emitters (point sprites) */ - void renderSmokeParticles(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); + void renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); /** - * Render M2 particle emitter particles (call after renderSmokeParticles()) + * Render smoke particles from chimneys etc. */ - void renderM2Particles(const glm::mat4& view, const glm::mat4& proj); + void renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); - /** - * Update the world position of an existing instance (e.g., for transports) - * @param instanceId Instance ID returned by createInstance() - * @param position New world position - */ void setInstancePosition(uint32_t instanceId, const glm::vec3& position); - - /** - * Update the full transform of an existing instance (e.g., for WMO doodads following parent WMO) - * @param instanceId Instance ID returned by createInstance() - * @param transform New world transform matrix - */ void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform); - - /** - * Remove a specific instance by ID - * @param instanceId Instance ID returned by createInstance() - */ + void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen); void removeInstance(uint32_t instanceId); - /** - * Remove multiple instances with one spatial-index rebuild. - */ void removeInstances(const std::vector& instanceIds); - - /** - * Clear all models and instances - */ void clear(); - - /** - * Remove models that have no instances referencing them - * Call periodically to free GPU memory - */ void cleanupUnusedModels(); - /** - * Check collision with M2 objects and adjust position - * @param from Starting position - * @param to Desired position - * @param adjustedPos Output adjusted position - * @param playerRadius Collision radius of player - * @return true if collision occurred - */ bool checkCollision(const glm::vec3& from, const glm::vec3& to, glm::vec3& adjustedPos, float playerRadius = 0.5f) const; - - /** - * Approximate top surface height for standing/jumping on doodads. - * @param glX World X - * @param glY World Y - * @param glZ Query/reference Z (used to ignore unreachable tops) - */ std::optional getFloorHeight(float glX, float glY, float glZ, float* outNormalZ = nullptr) const; - - /** - * Raycast against M2 bounding boxes for camera collision - * @param origin Ray origin (e.g., character head position) - * @param direction Ray direction (normalized) - * @param maxDistance Maximum ray distance to check - * @return Distance to first intersection, or maxDistance if no hit - */ float raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& direction, float maxDistance) const; - - /** - * Limit expensive collision/raycast queries to objects near a focus point. - */ void setCollisionFocus(const glm::vec3& worldPos, float radius); void clearCollisionFocus(); @@ -328,6 +290,8 @@ public: double getQueryTimeMs() const { return queryTimeMs; } uint32_t getQueryCallCount() const { return queryCallCount; } + void recreatePipelines(); + // Stats bool isInitialized() const { return initialized_; } uint32_t getModelCount() const { return static_cast(models.size()); } @@ -335,69 +299,99 @@ public: uint32_t getTotalTriangleCount() const; uint32_t getDrawCallCount() const { return lastDrawCallCount; } - void setFog(const glm::vec3& color, float start, float end) { - fogColor = color; fogStart = start; fogEnd = end; - } - - void setLighting(const float lightDirIn[3], const float lightColorIn[3], - const float ambientColorIn[3]) { - lightDir = glm::vec3(lightDirIn[0], lightDirIn[1], lightDirIn[2]); - lightColor = glm::vec3(lightColorIn[0], lightColorIn[1], lightColorIn[2]); - ambientColor = glm::vec3(ambientColorIn[0], ambientColorIn[1], ambientColorIn[2]); - } - - void setShadowMap(GLuint depthTex, const glm::mat4& lightSpace) { - shadowDepthTex = depthTex; lightSpaceMatrix = lightSpace; shadowEnabled = true; - } - void clearShadowMap() { shadowEnabled = false; } + // Lighting/fog/shadow are now in per-frame UBO; these are no-ops for API compat + void setFog(const glm::vec3& /*color*/, float /*start*/, float /*end*/) {} + void setLighting(const float /*lightDirIn*/[3], const float /*lightColorIn*/[3], + const float /*ambientColorIn*/[3]) {} + void setShadowMap(uint32_t /*depthTex*/, const glm::mat4& /*lightSpace*/) {} + void clearShadowMap() {} void setInsideInterior(bool inside) { insideInterior = inside; } void setOnTaxi(bool onTaxi) { onTaxi_ = onTaxi; } + std::vector getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const; + private: bool initialized_ = false; bool insideInterior = false; bool onTaxi_ = false; pipeline::AssetManager* assetManager = nullptr; - std::unique_ptr shader; + + // Vulkan context + VkContext* vkCtx_ = nullptr; + + // Vulkan pipelines (one per blend mode) + VkPipeline opaquePipeline_ = VK_NULL_HANDLE; // blend mode 0 + VkPipeline alphaTestPipeline_ = VK_NULL_HANDLE; // blend mode 1 + VkPipeline alphaPipeline_ = VK_NULL_HANDLE; // blend mode 2 + VkPipeline additivePipeline_ = VK_NULL_HANDLE; // blend mode 3+ + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + + // Shadow rendering (Phase 7) + VkPipeline shadowPipeline_ = VK_NULL_HANDLE; + VkPipelineLayout shadowPipelineLayout_ = VK_NULL_HANDLE; + VkDescriptorSetLayout shadowParamsLayout_ = VK_NULL_HANDLE; + VkDescriptorPool shadowParamsPool_ = VK_NULL_HANDLE; + VkDescriptorSet shadowParamsSet_ = VK_NULL_HANDLE; + ::VkBuffer shadowParamsUBO_ = VK_NULL_HANDLE; + VmaAllocation shadowParamsAlloc_ = VK_NULL_HANDLE; + // Per-frame pool for foliage shadow texture descriptor sets + VkDescriptorPool shadowTexPool_ = VK_NULL_HANDLE; + + // Particle pipelines + VkPipeline particlePipeline_ = VK_NULL_HANDLE; // M2 emitter particles + VkPipeline particleAdditivePipeline_ = VK_NULL_HANDLE; // Additive particle blend + VkPipelineLayout particlePipelineLayout_ = VK_NULL_HANDLE; + VkPipeline smokePipeline_ = VK_NULL_HANDLE; // Smoke particles + VkPipelineLayout smokePipelineLayout_ = VK_NULL_HANDLE; + + // Descriptor set layouts + VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1 + VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2 + VkDescriptorSetLayout particleTexLayout_ = VK_NULL_HANDLE; // particle set 1 (texture only) + + // Descriptor pools + VkDescriptorPool materialDescPool_ = VK_NULL_HANDLE; + VkDescriptorPool boneDescPool_ = VK_NULL_HANDLE; + static constexpr uint32_t MAX_MATERIAL_SETS = 8192; + static constexpr uint32_t MAX_BONE_SETS = 2048; + + // Dynamic particle buffers + ::VkBuffer smokeVB_ = VK_NULL_HANDLE; + VmaAllocation smokeVBAlloc_ = VK_NULL_HANDLE; + void* smokeVBMapped_ = nullptr; + ::VkBuffer m2ParticleVB_ = VK_NULL_HANDLE; + VmaAllocation m2ParticleVBAlloc_ = VK_NULL_HANDLE; + void* m2ParticleVBMapped_ = nullptr; std::unordered_map models; std::vector instances; uint32_t nextInstanceId = 1; uint32_t lastDrawCallCount = 0; + size_t modelCacheLimit_ = 6000; + uint32_t modelLimitRejectWarnings_ = 0; - GLuint loadTexture(const std::string& path, uint32_t texFlags = 0); + VkTexture* loadTexture(const std::string& path, uint32_t texFlags = 0); struct TextureCacheEntry { - GLuint id = 0; + std::unique_ptr texture; size_t approxBytes = 0; uint64_t lastUse = 0; bool hasAlpha = true; bool colorKeyBlack = false; }; std::unordered_map textureCache; - std::unordered_map textureHasAlphaById_; - std::unordered_map textureColorKeyBlackById_; + std::unordered_map textureHasAlphaByPtr_; + std::unordered_map textureColorKeyBlackByPtr_; size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; - size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; // Default, overridden at init - GLuint whiteTexture = 0; - GLuint glowTexture = 0; // Soft radial gradient for glow sprites - - // Lighting uniforms - glm::vec3 lightDir = glm::vec3(0.5f, 0.5f, 1.0f); - glm::vec3 lightColor = glm::vec3(1.5f, 1.4f, 1.3f); - glm::vec3 ambientColor = glm::vec3(0.4f, 0.4f, 0.45f); - - // Fog parameters - glm::vec3 fogColor = glm::vec3(0.5f, 0.6f, 0.7f); - float fogStart = 400.0f; - float fogEnd = 1200.0f; - - // Shadow mapping - GLuint shadowDepthTex = 0; - glm::mat4 lightSpaceMatrix = glm::mat4(1.0f); - bool shadowEnabled = false; + size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; + std::unordered_set failedTextureCache_; + std::unordered_set loggedTextureLoadFails_; + uint32_t textureBudgetRejectWarnings_ = 0; + std::unique_ptr whiteTexture_; + std::unique_ptr glowTexture_; + VkDescriptorSet glowTexDescSet_ = VK_NULL_HANDLE; // cached glow texture descriptor (allocated once) // Optional query-space culling for collision/raycast hot paths. bool collisionFocusEnabled = false; @@ -458,17 +452,11 @@ private: // Smoke particle system std::vector smokeParticles; - GLuint smokeVAO = 0; - GLuint smokeVBO = 0; - std::unique_ptr smokeShader; static constexpr int MAX_SMOKE_PARTICLES = 1000; float smokeEmitAccum = 0.0f; std::mt19937 smokeRng{42}; // M2 particle emitter system - GLuint m2ParticleShader_ = 0; - GLuint m2ParticleVAO_ = 0; - GLuint m2ParticleVBO_ = 0; static constexpr size_t MAX_M2_PARTICLES = 4000; std::mt19937 particleRng_{123}; @@ -486,6 +474,15 @@ private: glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio); void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt); void updateParticles(M2Instance& inst, float dt); + + // Helper to allocate descriptor sets + VkDescriptorSet allocateMaterialSet(); + VkDescriptorSet allocateBoneSet(); + + // Helper to destroy model GPU resources + void destroyModelGPU(M2ModelGPU& model); + // Helper to destroy instance bone buffers + void destroyInstanceBones(M2Instance& inst); }; } // namespace rendering diff --git a/include/rendering/minimap.hpp b/include/rendering/minimap.hpp index bd34d338..ca7c5345 100644 --- a/include/rendering/minimap.hpp +++ b/include/rendering/minimap.hpp @@ -1,6 +1,7 @@ #pragma once -#include +#include +#include #include #include #include @@ -12,22 +13,30 @@ namespace wowee { namespace pipeline { class AssetManager; } namespace rendering { -class Shader; class Camera; +class VkContext; +class VkTexture; +class VkRenderTarget; class Minimap { public: Minimap(); ~Minimap(); - bool initialize(int size = 200); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, int size = 200); void shutdown(); + void recreatePipelines(); void setAssetManager(pipeline::AssetManager* am) { assetManager = am; } void setMapName(const std::string& name); - void render(const Camera& playerCamera, const glm::vec3& centerWorldPos, - int screenWidth, int screenHeight); + /// Off-screen composite pass β€” call BEFORE the main render pass begins. + void compositePass(VkCommandBuffer cmd, const glm::vec3& centerWorldPos); + + /// Display quad β€” call INSIDE the main render pass. + void render(VkCommandBuffer cmd, const Camera& playerCamera, + const glm::vec3& centerWorldPos, int screenWidth, int screenHeight, + float playerOrientation = 0.0f, bool hasPlayerOrientation = false); void setEnabled(bool enabled) { this->enabled = enabled; } bool isEnabled() const { return enabled; } @@ -44,18 +53,18 @@ public: void zoomIn() { viewRadius = std::max(100.0f, viewRadius - 50.0f); } void zoomOut() { viewRadius = std::min(800.0f, viewRadius + 50.0f); } + void setOpacity(float opacity) { opacity_ = opacity; } + // Public accessors for WorldMap - GLuint getOrLoadTileTexture(int tileX, int tileY); + VkTexture* getOrLoadTileTexture(int tileX, int tileY); void ensureTRSParsed() { if (!trsParsed) parseTRS(); } - GLuint getTileQuadVAO() const { return tileQuadVAO; } const std::string& getMapName() const { return mapName; } private: void parseTRS(); - void compositeTilesToFBO(const glm::vec3& centerWorldPos); - void renderQuad(const Camera& playerCamera, const glm::vec3& centerWorldPos, - int screenWidth, int screenHeight); + void updateTileDescriptors(uint32_t frameIdx, int centerTileX, int centerTileY); + VkContext* vkCtx = nullptr; pipeline::AssetManager* assetManager = nullptr; std::string mapName = "Azeroth"; @@ -63,31 +72,40 @@ private: std::unordered_map trsLookup; bool trsParsed = false; - // Tile texture cache: hash β†’ GL texture ID - std::unordered_map tileTextureCache; - GLuint noDataTexture = 0; // dark fallback for missing tiles + // Tile texture cache: hash β†’ VkTexture + std::unordered_map> tileTextureCache; + std::unique_ptr noDataTexture; - // Composite FBO (3x3 tiles = 768x768) - GLuint compositeFBO = 0; - GLuint compositeTexture = 0; + // Composite render target (3x3 tiles = 768x768) + std::unique_ptr compositeTarget; static constexpr int TILE_PX = 256; static constexpr int COMPOSITE_PX = TILE_PX * 3; // 768 - // Tile compositing quad - GLuint tileQuadVAO = 0; - GLuint tileQuadVBO = 0; - std::unique_ptr tileShader; + // Shared quad vertex buffer (6 verts, pos2 + uv2 = 16 bytes/vert) + ::VkBuffer quadVB = VK_NULL_HANDLE; + VmaAllocation quadVBAlloc = VK_NULL_HANDLE; - // Screen quad - GLuint quadVAO = 0; - GLuint quadVBO = 0; - std::unique_ptr quadShader; + // Descriptor resources (shared layout: 1 combined image sampler at binding 0) + VkDescriptorSetLayout samplerSetLayout = VK_NULL_HANDLE; + VkDescriptorPool descPool = VK_NULL_HANDLE; + static constexpr uint32_t MAX_DESC_SETS = 24; + + // Tile composite pipeline (renders into VkRenderTarget) + VkPipeline tilePipeline = VK_NULL_HANDLE; + VkPipelineLayout tilePipelineLayout = VK_NULL_HANDLE; + VkDescriptorSet tileDescSets[2][9] = {}; // [frameInFlight][tileSlot] + + // Display pipeline (renders into main render pass) + VkPipeline displayPipeline = VK_NULL_HANDLE; + VkPipelineLayout displayPipelineLayout = VK_NULL_HANDLE; + VkDescriptorSet displayDescSet = VK_NULL_HANDLE; int mapSize = 200; - float viewRadius = 400.0f; // world units visible in minimap radius + float viewRadius = 400.0f; bool enabled = true; bool rotateWithCamera = false; bool squareShape = false; + float opacity_ = 1.0f; // Throttling float updateIntervalSec = 0.25f; diff --git a/include/rendering/mount_dust.hpp b/include/rendering/mount_dust.hpp index fa729fa9..de0cce60 100644 --- a/include/rendering/mount_dust.hpp +++ b/include/rendering/mount_dust.hpp @@ -1,29 +1,30 @@ #pragma once -#include +#include +#include #include -#include #include namespace wowee { namespace rendering { class Camera; -class Shader; +class VkContext; class MountDust { public: MountDust(); ~MountDust(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); + void recreatePipelines(); // Spawn dust particles at mount feet when moving on ground void spawnDust(const glm::vec3& position, const glm::vec3& velocity, bool isMoving); void update(float deltaTime); - void render(const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); private: struct Particle { @@ -38,11 +39,18 @@ private: static constexpr int MAX_DUST_PARTICLES = 300; std::vector particles; - GLuint vao = 0; - GLuint vbo = 0; - std::unique_ptr shader; - std::vector vertexData; + // Vulkan objects + VkContext* vkCtx = nullptr; + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + // Dynamic mapped buffer for particle vertex data (updated every frame) + ::VkBuffer dynamicVB = VK_NULL_HANDLE; + VmaAllocation dynamicVBAlloc = VK_NULL_HANDLE; + VmaAllocationInfo dynamicVBAllocInfo{}; + VkDeviceSize dynamicVBSize = 0; + + std::vector vertexData; float spawnAccum = 0.0f; }; diff --git a/include/rendering/quest_marker_renderer.hpp b/include/rendering/quest_marker_renderer.hpp index 47e4e044..2d6a73d3 100644 --- a/include/rendering/quest_marker_renderer.hpp +++ b/include/rendering/quest_marker_renderer.hpp @@ -1,15 +1,20 @@ #pragma once #include +#include +#include #include #include #include +#include "rendering/vk_texture.hpp" + namespace wowee { namespace pipeline { class AssetManager; } namespace rendering { class Camera; +class VkContext; /** * Renders quest markers as billboarded sprites above NPCs @@ -20,8 +25,9 @@ public: QuestMarkerRenderer(); ~QuestMarkerRenderer(); - bool initialize(pipeline::AssetManager* assetManager); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, pipeline::AssetManager* assetManager); void shutdown(); + void recreatePipelines(); /** * Add or update a quest marker at a position @@ -44,8 +50,11 @@ public: /** * Render all quest markers (call after world rendering, before UI) + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0, contains camera UBO) + * @param camera Camera for billboard calculation (CPU-side view matrix) */ - void render(const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); private: struct Marker { @@ -55,16 +64,29 @@ private: }; std::unordered_map markers_; - - // OpenGL resources - uint32_t vao_ = 0; - uint32_t vbo_ = 0; - uint32_t shaderProgram_ = 0; - uint32_t textures_[3] = {0, 0, 0}; // available, turnin, incomplete + + // Vulkan context + VkContext* vkCtx_ = nullptr; + + // Pipeline + VkPipeline pipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + + // Descriptor resources for per-material texture (set 1) + VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; + VkDescriptorPool descriptorPool_ = VK_NULL_HANDLE; + VkDescriptorSet texDescSets_[3] = {VK_NULL_HANDLE, VK_NULL_HANDLE, VK_NULL_HANDLE}; + + // Textures: available, turnin, incomplete + VkTexture textures_[3]; + + // Quad vertex buffer + VkBuffer quadVB_ = VK_NULL_HANDLE; + VmaAllocation quadVBAlloc_ = VK_NULL_HANDLE; void createQuad(); void loadTextures(pipeline::AssetManager* assetManager); - void createShader(); + void createDescriptorResources(); }; } // namespace rendering diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index fdd38747..4f163e89 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -5,9 +5,14 @@ #include #include #include +#include +#include +#include "rendering/vk_frame_data.hpp" +#include "rendering/sky_system.hpp" namespace wowee { namespace core { class Window; } +namespace rendering { class VkContext; } namespace game { class World; class ZoneManager; class GameHandler; } namespace audio { class MusicManager; class FootstepManager; class ActivitySoundManager; class MountSoundManager; class NpcVoiceManager; class AmbientSoundManager; class UiSoundManager; class CombatSoundManager; class SpellSoundManager; class MovementSoundManager; enum class FootstepSurface : uint8_t; enum class VoiceType; } namespace pipeline { class AssetManager; } @@ -28,7 +33,6 @@ class Clouds; class LensFlare; class Weather; class LightingManager; -class SkySystem; class SwimEffects; class MountDust; class LevelUpEffect; @@ -37,7 +41,9 @@ class CharacterRenderer; class WMORenderer; class M2Renderer; class Minimap; +class WorldMap; class QuestMarkerRenderer; +class CharacterPreview; class Shader; class Renderer { @@ -101,19 +107,23 @@ public: TerrainManager* getTerrainManager() const { return terrainManager.get(); } PerformanceHUD* getPerformanceHUD() { return performanceHUD.get(); } WaterRenderer* getWaterRenderer() const { return waterRenderer.get(); } - Skybox* getSkybox() const { return skybox.get(); } - Celestial* getCelestial() const { return celestial.get(); } - StarField* getStarField() const { return starField.get(); } - Clouds* getClouds() const { return clouds.get(); } - LensFlare* getLensFlare() const { return lensFlare.get(); } + Skybox* getSkybox() const { return skySystem ? skySystem->getSkybox() : nullptr; } + Celestial* getCelestial() const { return skySystem ? skySystem->getCelestial() : nullptr; } + StarField* getStarField() const { return skySystem ? skySystem->getStarField() : nullptr; } + Clouds* getClouds() const { return skySystem ? skySystem->getClouds() : nullptr; } + LensFlare* getLensFlare() const { return skySystem ? skySystem->getLensFlare() : nullptr; } Weather* getWeather() const { return weather.get(); } CharacterRenderer* getCharacterRenderer() const { return characterRenderer.get(); } WMORenderer* getWMORenderer() const { return wmoRenderer.get(); } M2Renderer* getM2Renderer() const { return m2Renderer.get(); } Minimap* getMinimap() const { return minimap.get(); } + WorldMap* getWorldMap() const { return worldMap.get(); } QuestMarkerRenderer* getQuestMarkerRenderer() const { return questMarkerRenderer.get(); } SkySystem* getSkySystem() const { return skySystem.get(); } const std::string& getCurrentZoneName() const { return currentZoneName; } + VkContext* getVkContext() const { return vkCtx; } + VkDescriptorSetLayout getPerFrameSetLayout() const { return perFrameSetLayout; } + VkRenderPass getShadowRenderPass() const { return shadowRenderPass; } // Third-person character follow void setCharacterFollow(uint32_t instanceId); @@ -202,6 +212,7 @@ private: std::unique_ptr wmoRenderer; std::unique_ptr m2Renderer; std::unique_ptr minimap; + std::unique_ptr worldMap; std::unique_ptr questMarkerRenderer; std::unique_ptr musicManager; std::unique_ptr footstepManager; @@ -214,45 +225,35 @@ private: std::unique_ptr spellSoundManager; std::unique_ptr movementSoundManager; std::unique_ptr zoneManager; - std::unique_ptr underwaterOverlayShader; - uint32_t underwaterOverlayVAO = 0; - uint32_t underwaterOverlayVBO = 0; - - // Post-process FBO pipeline (HDR MSAA β†’ resolve β†’ tonemap) - uint32_t sceneFBO = 0; // MSAA render target - uint32_t sceneColorRBO = 0; // GL_RGBA16F multisampled renderbuffer - uint32_t sceneDepthRBO = 0; // GL_DEPTH_COMPONENT24 multisampled renderbuffer - uint32_t resolveFBO = 0; // Non-MSAA resolve target - uint32_t resolveColorTex = 0; // GL_RGBA16F resolved texture (sampled by post-process) - uint32_t resolveDepthTex = 0; // GL_DEPTH_COMPONENT24 resolved texture (for future SSAO) - uint32_t screenQuadVAO = 0; - uint32_t screenQuadVBO = 0; - std::unique_ptr postProcessShader; - int fbWidth = 0, fbHeight = 0; - - void initPostProcess(int w, int h); - void resizePostProcess(int w, int h); - void shutdownPostProcess(); - - // Shadow mapping - static constexpr int SHADOW_MAP_SIZE = 2048; - uint32_t shadowFBO = 0; - uint32_t shadowDepthTex = 0; - uint32_t shadowShaderProgram = 0; + // Shadow mapping (Vulkan) + static constexpr uint32_t SHADOW_MAP_SIZE = 4096; + VkImage shadowDepthImage = VK_NULL_HANDLE; + VmaAllocation shadowDepthAlloc = VK_NULL_HANDLE; + VkImageView shadowDepthView = VK_NULL_HANDLE; + VkSampler shadowSampler = VK_NULL_HANDLE; + VkRenderPass shadowRenderPass = VK_NULL_HANDLE; + VkFramebuffer shadowFramebuffer = VK_NULL_HANDLE; + VkImageLayout shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; glm::mat4 lightSpaceMatrix = glm::mat4(1.0f); glm::vec3 shadowCenter = glm::vec3(0.0f); bool shadowCenterInitialized = false; bool shadowsEnabled = true; - int shadowPostMoveFrames_ = 0; // transition marker for movement->idle shadow recenter + public: + // Character preview registration (for off-screen composite pass) + void registerPreview(CharacterPreview* preview); + void unregisterPreview(CharacterPreview* preview); + void setShadowsEnabled(bool enabled) { shadowsEnabled = enabled; } bool areShadowsEnabled() const { return shadowsEnabled; } + void setMsaaSamples(VkSampleCountFlagBits samples); private: - void initShadowMap(); + void applyMsaaChange(); + VkSampleCountFlagBits pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT; + bool msaaChangePending_ = false; void renderShadowPass(); - uint32_t compileShadowShader(); glm::mat4 computeLightSpaceMatrix(); pipeline::AssetManager* cachedAssetManager = nullptr; @@ -289,10 +290,13 @@ private: const glm::vec3* targetPosition = nullptr; bool inCombat_ = false; - // Selection circle rendering - uint32_t selCircleVAO = 0; - uint32_t selCircleVBO = 0; - uint32_t selCircleShader = 0; + // Selection circle rendering (Vulkan) + VkPipeline selCirclePipeline = VK_NULL_HANDLE; + VkPipelineLayout selCirclePipelineLayout = VK_NULL_HANDLE; + ::VkBuffer selCircleVertBuf = VK_NULL_HANDLE; + VmaAllocation selCircleVertAlloc = VK_NULL_HANDLE; + ::VkBuffer selCircleIdxBuf = VK_NULL_HANDLE; + VmaAllocation selCircleIdxAlloc = VK_NULL_HANDLE; int selCircleVertCount = 0; void initSelectionCircle(); void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection); @@ -301,6 +305,12 @@ private: float selCircleRadius = 1.5f; bool selCircleVisible = false; + // Fullscreen color overlay (underwater tint) + VkPipeline overlayPipeline = VK_NULL_HANDLE; + VkPipelineLayout overlayPipelineLayout = VK_NULL_HANDLE; + void initOverlayPipeline(); + void renderOverlay(const glm::vec4& color); + // Footstep event tracking (animation-driven) uint32_t footstepLastAnimationId = 0; float footstepLastNormTime = 0.0f; @@ -360,6 +370,37 @@ private: bool taxiFlight_ = false; bool taxiAnimsLogged_ = false; + // Vulkan frame state + VkContext* vkCtx = nullptr; + VkCommandBuffer currentCmd = VK_NULL_HANDLE; + uint32_t currentImageIndex = 0; + + // Per-frame UBO + descriptors (set 0) + static constexpr uint32_t MAX_FRAMES = 2; + VkDescriptorSetLayout perFrameSetLayout = VK_NULL_HANDLE; + VkDescriptorPool sceneDescriptorPool = VK_NULL_HANDLE; + VkDescriptorSet perFrameDescSets[MAX_FRAMES] = {}; + VkBuffer perFrameUBOs[MAX_FRAMES] = {}; + VmaAllocation perFrameUBOAllocs[MAX_FRAMES] = {}; + void* perFrameUBOMapped[MAX_FRAMES] = {}; + GPUPerFrameData currentFrameData{}; + float globalTime = 0.0f; + + // Per-frame reflection UBO (mirrors camera for planar reflections) + VkBuffer reflPerFrameUBO = VK_NULL_HANDLE; + VmaAllocation reflPerFrameUBOAlloc = VK_NULL_HANDLE; + void* reflPerFrameUBOMapped = nullptr; + VkDescriptorSet reflPerFrameDescSet = VK_NULL_HANDLE; + + bool createPerFrameResources(); + void destroyPerFrameResources(); + void updatePerFrameUBO(); + void setupWater1xPass(); + void renderReflectionPass(); + + // Active character previews for off-screen rendering + std::vector activePreviews_; + bool terrainEnabled = true; bool terrainLoaded = false; diff --git a/include/rendering/sky_system.hpp b/include/rendering/sky_system.hpp index 4ead334e..6b3d9c47 100644 --- a/include/rendering/sky_system.hpp +++ b/include/rendering/sky_system.hpp @@ -2,11 +2,13 @@ #include #include +#include namespace wowee { namespace rendering { class Camera; +class VkContext; class Skybox; class Celestial; class StarField; @@ -29,9 +31,10 @@ struct SkyParams { glm::vec3 skyBand2Color{1.0f, 0.98f, 0.9f}; // Atmospheric effects - float cloudDensity = 0.0f; // 0-1 - float fogDensity = 0.0f; // 0-1 - float horizonGlow = 0.3f; // 0-1 + float cloudDensity = 0.0f; // 0-1 + float fogDensity = 0.0f; // 0-1 + float horizonGlow = 0.3f; // 0-1 + float weatherIntensity = 0.0f; // 0-1 (rain/snow intensity, attenuates lens flare) // Time float timeOfDay = 12.0f; // 0-24 hours @@ -59,9 +62,11 @@ public: ~SkySystem(); /** - * Initialize sky system components + * Initialize sky system components. + * @param ctx Vulkan context (required for Vulkan renderers) + * @param perFrameLayout Descriptor set layout for set 0 (camera UBO) */ - bool initialize(); + bool initialize(VkContext* ctx = nullptr, VkDescriptorSetLayout perFrameLayout = VK_NULL_HANDLE); void shutdown(); /** @@ -70,11 +75,14 @@ public: void update(float deltaTime); /** - * Render complete sky - * @param camera Camera for view/projection - * @param params Sky parameters from lighting system + * Render complete sky. + * @param cmd Active Vulkan command buffer + * @param perFrameSet Per-frame descriptor set (set 0, camera UBO) + * @param camera Camera for legacy sub-renderers (lens flare, etc.) + * @param params Sky parameters from lighting system */ - void render(const Camera& camera, const SkyParams& params); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, + const Camera& camera, const SkyParams& params); /** * Enable/disable procedural stars (DEBUG/FALLBACK) @@ -109,21 +117,21 @@ public: float getBlueChildPhase() const; // Component accessors (for direct control if needed) - Skybox* getSkybox() const { return skybox_.get(); } + Skybox* getSkybox() const { return skybox_.get(); } Celestial* getCelestial() const { return celestial_.get(); } StarField* getStarField() const { return starField_.get(); } - Clouds* getClouds() const { return clouds_.get(); } + Clouds* getClouds() const { return clouds_.get(); } LensFlare* getLensFlare() const { return lensFlare_.get(); } private: - std::unique_ptr skybox_; // Authoritative sky (gradient now, M2 models later) - std::unique_ptr celestial_; // Sun + 2 moons - std::unique_ptr starField_; // Fallback procedural stars - std::unique_ptr clouds_; // Cloud layer - std::unique_ptr lensFlare_; // Sun lens flare + std::unique_ptr skybox_; // Authoritative sky + std::unique_ptr celestial_; // Sun + 2 moons + std::unique_ptr starField_; // Fallback procedural stars + std::unique_ptr clouds_; // Cloud layer + std::unique_ptr lensFlare_; // Sun lens flare - bool proceduralStarsEnabled_ = false; // Default: OFF (skybox is authoritative) - bool debugSkyMode_ = false; // Force procedural stars for debugging + bool proceduralStarsEnabled_ = false; + bool debugSkyMode_ = false; bool initialized_ = false; }; diff --git a/include/rendering/skybox.hpp b/include/rendering/skybox.hpp index 19f19905..51e098cd 100644 --- a/include/rendering/skybox.hpp +++ b/include/rendering/skybox.hpp @@ -2,33 +2,41 @@ #include #include +#include +#include namespace wowee { namespace rendering { -class Shader; -class Camera; +class VkContext; +struct SkyParams; /** * Skybox renderer * - * Renders an atmospheric sky dome with gradient colors. - * The sky uses a dome/sphere approach for realistic appearance. + * Renders an atmospheric sky gradient using a fullscreen triangle. + * No vertex buffer: 3 vertices cover the entire screen via gl_VertexIndex. + * World-space ray direction is reconstructed from the inverse view+projection. + * + * Sky colors are driven by DBC data (Light.dbc / LightIntBand.dbc) via SkyParams, + * with Rayleigh/Mie atmospheric scattering for realistic appearance. */ class Skybox { public: Skybox(); ~Skybox(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); + void recreatePipelines(); /** - * Render the skybox - * @param camera Camera for view matrix (position is ignored for skybox) - * @param timeOfDay Time of day in hours (0-24), affects sky color + * Render the skybox using DBC-driven sky colors. + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0, contains camera UBO) + * @param params Sky parameters with DBC colors and sun direction */ - void render(const Camera& camera, float timeOfDay = 12.0f); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const SkyParams& params); /** * Enable/disable skybox rendering @@ -54,24 +62,11 @@ public: */ void update(float deltaTime); - /** - * Get horizon color for fog (public for fog system) - */ - glm::vec3 getHorizonColor(float time) const; - private: - void createSkyDome(); - void destroySkyDome(); + VkContext* vkCtx = nullptr; - glm::vec3 getSkyColor(float altitude, float time) const; - glm::vec3 getZenithColor(float time) const; - - std::unique_ptr skyShader; - - uint32_t vao = 0; - uint32_t vbo = 0; - uint32_t ebo = 0; - int indexCount = 0; + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; float timeOfDay = 12.0f; // Default: noon float timeSpeed = 1.0f; // 1.0 = 1 hour per real second diff --git a/include/rendering/starfield.hpp b/include/rendering/starfield.hpp index 36aa94bf..8ac1e655 100644 --- a/include/rendering/starfield.hpp +++ b/include/rendering/starfield.hpp @@ -1,14 +1,14 @@ #pragma once -#include #include #include +#include +#include namespace wowee { namespace rendering { -class Shader; -class Camera; +class VkContext; /** * Star field renderer @@ -21,17 +21,19 @@ public: StarField(); ~StarField(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); + void recreatePipelines(); /** * Render the star field - * @param camera Camera for view matrix - * @param timeOfDay Time of day in hours (0-24) + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0, contains camera UBO) + * @param timeOfDay Time of day in hours (0-24) * @param cloudDensity Optional cloud density from lighting (0-1, reduces star visibility) - * @param fogDensity Optional fog density from lighting (reduces star visibility) + * @param fogDensity Optional fog density from lighting (reduces star visibility) */ - void render(const Camera& camera, float timeOfDay, + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, float timeOfDay, float cloudDensity = 0.0f, float fogDensity = 0.0f); /** @@ -57,8 +59,6 @@ private: float getStarIntensity(float timeOfDay) const; - std::unique_ptr starShader; - struct Star { glm::vec3 position; float brightness; // 0.3 to 1.0 @@ -68,8 +68,13 @@ private: std::vector stars; int starCount = 1000; - uint32_t vao = 0; - uint32_t vbo = 0; + VkContext* vkCtx = nullptr; + + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + + VkBuffer vertexBuffer = VK_NULL_HANDLE; + VmaAllocation vertexAlloc = VK_NULL_HANDLE; float twinkleTime = 0.0f; bool renderingEnabled = true; diff --git a/include/rendering/swim_effects.hpp b/include/rendering/swim_effects.hpp index 1d5d0569..20d63176 100644 --- a/include/rendering/swim_effects.hpp +++ b/include/rendering/swim_effects.hpp @@ -1,8 +1,8 @@ #pragma once -#include +#include +#include #include -#include #include namespace wowee { @@ -11,18 +11,22 @@ namespace rendering { class Camera; class CameraController; class WaterRenderer; -class Shader; +class M2Renderer; +class VkContext; class SwimEffects { public: SwimEffects(); ~SwimEffects(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); + void recreatePipelines(); void update(const Camera& camera, const CameraController& cc, const WaterRenderer& water, float deltaTime); - void render(const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + void spawnFootSplash(const glm::vec3& footPos, float waterH); + void setM2Renderer(M2Renderer* renderer) { m2Renderer = renderer; } private: struct Particle { @@ -34,25 +38,66 @@ private: float alpha; }; + struct InsectParticle { + glm::vec3 position; + glm::vec3 orbitCenter; // vegetation position to orbit around + float lifetime; + float maxLifetime; + float size; + float alpha; + float phase; // random phase offset for erratic motion + float orbitRadius; + float orbitSpeed; + float heightOffset; // height above plant + }; + static constexpr int MAX_RIPPLE_PARTICLES = 200; static constexpr int MAX_BUBBLE_PARTICLES = 150; + static constexpr int MAX_INSECT_PARTICLES = 50; std::vector ripples; std::vector bubbles; + std::vector insects; - GLuint rippleVAO = 0, rippleVBO = 0; - GLuint bubbleVAO = 0, bubbleVBO = 0; - std::unique_ptr rippleShader; - std::unique_ptr bubbleShader; + // Vulkan objects + VkContext* vkCtx = nullptr; + M2Renderer* m2Renderer = nullptr; + + // Ripple pipeline + dynamic buffer + VkPipeline ripplePipeline = VK_NULL_HANDLE; + VkPipelineLayout ripplePipelineLayout = VK_NULL_HANDLE; + ::VkBuffer rippleDynamicVB = VK_NULL_HANDLE; + VmaAllocation rippleDynamicVBAlloc = VK_NULL_HANDLE; + VmaAllocationInfo rippleDynamicVBAllocInfo{}; + VkDeviceSize rippleDynamicVBSize = 0; + + // Bubble pipeline + dynamic buffer + VkPipeline bubblePipeline = VK_NULL_HANDLE; + VkPipelineLayout bubblePipelineLayout = VK_NULL_HANDLE; + ::VkBuffer bubbleDynamicVB = VK_NULL_HANDLE; + VmaAllocation bubbleDynamicVBAlloc = VK_NULL_HANDLE; + VmaAllocationInfo bubbleDynamicVBAllocInfo{}; + VkDeviceSize bubbleDynamicVBSize = 0; + + // Insect pipeline + dynamic buffer + VkPipeline insectPipeline = VK_NULL_HANDLE; + VkPipelineLayout insectPipelineLayout = VK_NULL_HANDLE; + ::VkBuffer insectDynamicVB = VK_NULL_HANDLE; + VmaAllocation insectDynamicVBAlloc = VK_NULL_HANDLE; + VmaAllocationInfo insectDynamicVBAllocInfo{}; + VkDeviceSize insectDynamicVBSize = 0; std::vector rippleVertexData; std::vector bubbleVertexData; + std::vector insectVertexData; float rippleSpawnAccum = 0.0f; float bubbleSpawnAccum = 0.0f; + float insectSpawnAccum = 0.0f; void spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH); void spawnBubble(const glm::vec3& pos, float waterH); + void spawnInsect(const glm::vec3& vegPos); }; } // namespace rendering diff --git a/include/rendering/terrain_renderer.hpp b/include/rendering/terrain_renderer.hpp index 3fb285b1..91279e9c 100644 --- a/include/rendering/terrain_renderer.hpp +++ b/include/rendering/terrain_renderer.hpp @@ -2,13 +2,13 @@ #include "pipeline/terrain_mesh.hpp" #include "pipeline/blp_loader.hpp" -#include "rendering/shader.hpp" -#include "rendering/texture.hpp" #include "rendering/camera.hpp" -#include +#include +#include #include #include #include +#include #include namespace wowee { @@ -18,21 +18,35 @@ namespace pipeline { class AssetManager; } namespace rendering { +class VkContext; +class VkTexture; class Frustum; /** - * GPU-side terrain chunk data + * GPU-side terrain chunk data (Vulkan) */ struct TerrainChunkGPU { - GLuint vao = 0; // Vertex array object - GLuint vbo = 0; // Vertex buffer - GLuint ibo = 0; // Index buffer - uint32_t indexCount = 0; // Number of indices to draw + ::VkBuffer vertexBuffer = VK_NULL_HANDLE; + VmaAllocation vertexAlloc = VK_NULL_HANDLE; + ::VkBuffer indexBuffer = VK_NULL_HANDLE; + VmaAllocation indexAlloc = VK_NULL_HANDLE; + uint32_t indexCount = 0; - // Texture IDs for this chunk - GLuint baseTexture = 0; - std::vector layerTextures; - std::vector alphaTextures; + // Material descriptor set (set 1: 7 samplers + params UBO) + VkDescriptorSet materialSet = VK_NULL_HANDLE; + + // Per-chunk params UBO (hasLayer1/2/3) + ::VkBuffer paramsUBO = VK_NULL_HANDLE; + VmaAllocation paramsAlloc = VK_NULL_HANDLE; + + // Texture handles (owned by cache, NOT destroyed per-chunk) + VkTexture* baseTexture = nullptr; + VkTexture* layerTextures[3] = {nullptr, nullptr, nullptr}; + VkTexture* alphaTextures[3] = {nullptr, nullptr, nullptr}; + int layerCount = 0; + + // Per-chunk alpha textures (owned by this chunk, destroyed on removal) + std::vector> ownedAlphaTextures; // World position for culling float worldX = 0.0f; @@ -46,13 +60,11 @@ struct TerrainChunkGPU { float boundingSphereRadius = 0.0f; glm::vec3 boundingSphereCenter = glm::vec3(0.0f); - bool isValid() const { return vao != 0 && vbo != 0 && ibo != 0; } + bool isValid() const { return vertexBuffer != VK_NULL_HANDLE && indexBuffer != VK_NULL_HANDLE; } }; /** - * Terrain renderer - * - * Handles uploading terrain meshes to GPU and rendering them + * Terrain renderer (Vulkan) */ class TerrainRenderer { public: @@ -61,150 +73,97 @@ public: /** * Initialize terrain renderer + * @param ctx Vulkan context + * @param perFrameLayout Descriptor set layout for set 0 (per-frame UBO) * @param assetManager Asset manager for loading textures */ - bool initialize(pipeline::AssetManager* assetManager); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* assetManager); - /** - * Shutdown and cleanup GPU resources - */ void shutdown(); - /** - * Load terrain mesh and upload to GPU - * @param mesh Terrain mesh to load - * @param texturePaths Texture file paths from ADT - * @param tileX Tile X coordinate for tracking ownership (-1 = untracked) - * @param tileY Tile Y coordinate for tracking ownership (-1 = untracked) - */ bool loadTerrain(const pipeline::TerrainMesh& mesh, const std::vector& texturePaths, int tileX = -1, int tileY = -1); - /** - * Remove all chunks belonging to a specific tile - * @param tileX Tile X coordinate - * @param tileY Tile Y coordinate - */ void removeTile(int tileX, int tileY); - /** - * Upload pre-loaded BLP textures to the GL texture cache. - * Called before loadTerrain() so texture loading avoids file I/O. - */ void uploadPreloadedTextures(const std::unordered_map& textures); /** - * Render loaded terrain - * @param camera Camera for view/projection matrices + * Render terrain + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0) + * @param camera Camera for frustum culling */ - void render(const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); /** - * Clear all loaded terrain + * Render terrain into shadow depth map (Phase 6 stub) */ + void renderShadow(VkCommandBuffer cmd, const glm::vec3& shadowCenter, float halfExtent); + void clear(); - /** - * Set lighting parameters - */ - void setLighting(const float lightDir[3], const float lightColor[3], - const float ambientColor[3]); + void recreatePipelines(); - /** - * Set fog parameters - */ - void setFog(const float fogColor[3], float fogStart, float fogEnd); - - /** - * Enable/disable wireframe rendering - */ void setWireframe(bool enabled) { wireframe = enabled; } - - /** - * Enable/disable frustum culling - */ void setFrustumCulling(bool enabled) { frustumCullingEnabled = enabled; } - - /** - * Enable/disable distance fog - */ void setFogEnabled(bool enabled) { fogEnabled = enabled; } bool isFogEnabled() const { return fogEnabled; } - /** - * Render terrain geometry into shadow depth map - */ - void renderShadow(GLuint shaderProgram, const glm::vec3& shadowCenter, float halfExtent); + // Shadow mapping stubs (Phase 6) + void setShadowMap(VkDescriptorImageInfo /*depthInfo*/, const glm::mat4& /*lightSpaceMat*/) {} + void clearShadowMap() {} - /** - * Set shadow map for receiving shadows - */ - void setShadowMap(GLuint depthTex, const glm::mat4& lightSpaceMat) { - shadowDepthTex = depthTex; lightSpaceMatrix = lightSpaceMat; shadowEnabled = true; - } - void clearShadowMap() { shadowEnabled = false; } - - /** - * Get statistics - */ int getChunkCount() const { return static_cast(chunks.size()); } int getRenderedChunkCount() const { return renderedChunks; } int getCulledChunkCount() const { return culledChunks; } int getTriangleCount() const; private: - /** - * Upload single chunk to GPU - */ TerrainChunkGPU uploadChunk(const pipeline::ChunkMesh& chunk); - - /** - * Load texture from asset manager - */ - GLuint loadTexture(const std::string& path); - - /** - * Create alpha texture from raw alpha data - */ - GLuint createAlphaTexture(const std::vector& alphaData); - - /** - * Check if chunk is in view frustum - */ + VkTexture* loadTexture(const std::string& path); + VkTexture* createAlphaTexture(const std::vector& alphaData); bool isChunkVisible(const TerrainChunkGPU& chunk, const Frustum& frustum); - - /** - * Calculate bounding sphere for chunk - */ void calculateBoundingSphere(TerrainChunkGPU& chunk, const pipeline::ChunkMesh& meshChunk); + VkDescriptorSet allocateMaterialSet(); + void writeMaterialDescriptors(VkDescriptorSet set, const TerrainChunkGPU& chunk); + void destroyChunkGPU(TerrainChunkGPU& chunk); + VkContext* vkCtx = nullptr; pipeline::AssetManager* assetManager = nullptr; - std::unique_ptr shader; + + // Pipeline + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipeline wireframePipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + VkDescriptorSetLayout materialSetLayout = VK_NULL_HANDLE; + + // Descriptor pool for material sets + VkDescriptorPool materialDescPool = VK_NULL_HANDLE; + static constexpr uint32_t MAX_MATERIAL_SETS = 16384; // Loaded terrain chunks std::vector chunks; - // Texture cache (path -> GL texture ID) + // Texture cache (path -> VkTexture) struct TextureCacheEntry { - GLuint id = 0; + std::unique_ptr texture; size_t approxBytes = 0; uint64_t lastUse = 0; }; std::unordered_map textureCache; size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; - size_t textureCacheBudgetBytes_ = 4096ull * 1024 * 1024; // Default, overridden at init + size_t textureCacheBudgetBytes_ = 4096ull * 1024 * 1024; + std::unordered_set failedTextureCache_; + std::unordered_set loggedTextureLoadFails_; + uint32_t textureBudgetRejectWarnings_ = 0; - // Lighting parameters - float lightDir[3] = {-0.5f, -1.0f, -0.5f}; - float lightColor[3] = {1.0f, 1.0f, 0.9f}; - float ambientColor[3] = {0.3f, 0.3f, 0.35f}; - - // Fog parameters - float fogColor[3] = {0.5f, 0.6f, 0.7f}; - float fogStart = 400.0f; - float fogEnd = 800.0f; + // Fallback textures + std::unique_ptr whiteTexture; + std::unique_ptr opaqueAlphaTexture; // Rendering state bool wireframe = false; @@ -212,16 +171,6 @@ private: bool fogEnabled = true; int renderedChunks = 0; int culledChunks = 0; - - // Default white texture (fallback) - GLuint whiteTexture = 0; - // Opaque alpha fallback for missing/invalid layer alpha maps - GLuint opaqueAlphaTexture = 0; - - // Shadow mapping (receiving) - GLuint shadowDepthTex = 0; - glm::mat4 lightSpaceMatrix = glm::mat4(1.0f); - bool shadowEnabled = false; }; } // namespace rendering diff --git a/include/rendering/vk_buffer.hpp b/include/rendering/vk_buffer.hpp new file mode 100644 index 00000000..6cb0ba54 --- /dev/null +++ b/include/rendering/vk_buffer.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "rendering/vk_utils.hpp" +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +class VkContext; + +// RAII wrapper for a Vulkan buffer with VMA allocation. +// Supports vertex, index, uniform, and storage buffer usage. +class VkBuffer { +public: + VkBuffer() = default; + ~VkBuffer(); + + VkBuffer(const VkBuffer&) = delete; + VkBuffer& operator=(const VkBuffer&) = delete; + VkBuffer(VkBuffer&& other) noexcept; + VkBuffer& operator=(VkBuffer&& other) noexcept; + + // Create a GPU-local buffer and upload data via staging + bool uploadToGPU(VkContext& ctx, const void* data, VkDeviceSize size, + VkBufferUsageFlags usage); + + // Create a host-visible buffer (for uniform/dynamic data updated each frame) + bool createMapped(VmaAllocator allocator, VkDeviceSize size, + VkBufferUsageFlags usage); + + // Update mapped buffer contents (only valid for mapped buffers) + void updateMapped(const void* data, VkDeviceSize size, VkDeviceSize offset = 0); + + void destroy(); + + ::VkBuffer getBuffer() const { return buf_.buffer; } + VkDeviceSize getSize() const { return size_; } + void* getMappedData() const { return buf_.info.pMappedData; } + bool isValid() const { return buf_.buffer != VK_NULL_HANDLE; } + + // Descriptor info for uniform/storage buffer binding + VkDescriptorBufferInfo descriptorInfo(VkDeviceSize offset = 0, + VkDeviceSize range = VK_WHOLE_SIZE) const; + +private: + AllocatedBuffer buf_{}; + VmaAllocator allocator_ = VK_NULL_HANDLE; + VkDeviceSize size_ = 0; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/vk_context.hpp b/include/rendering/vk_context.hpp new file mode 100644 index 00000000..4f405073 --- /dev/null +++ b/include/rendering/vk_context.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2; + +struct FrameData { + VkCommandPool commandPool = VK_NULL_HANDLE; + VkCommandBuffer commandBuffer = VK_NULL_HANDLE; + VkSemaphore imageAvailableSemaphore = VK_NULL_HANDLE; + VkSemaphore renderFinishedSemaphore = VK_NULL_HANDLE; + VkFence inFlightFence = VK_NULL_HANDLE; +}; + +class VkContext { +public: + VkContext() = default; + ~VkContext(); + + VkContext(const VkContext&) = delete; + VkContext& operator=(const VkContext&) = delete; + + bool initialize(SDL_Window* window); + void shutdown(); + + // Swapchain management + bool recreateSwapchain(int width, int height); + + // Frame operations + VkCommandBuffer beginFrame(uint32_t& imageIndex); + void endFrame(VkCommandBuffer cmd, uint32_t imageIndex); + + // Single-time command buffer helpers + VkCommandBuffer beginSingleTimeCommands(); + void endSingleTimeCommands(VkCommandBuffer cmd); + + // Immediate submit for one-off GPU work (descriptor pool creation, etc.) + void immediateSubmit(std::function&& function); + + // Accessors + VkInstance getInstance() const { return instance; } + VkPhysicalDevice getPhysicalDevice() const { return physicalDevice; } + VkDevice getDevice() const { return device; } + VkQueue getGraphicsQueue() const { return graphicsQueue; } + uint32_t getGraphicsQueueFamily() const { return graphicsQueueFamily; } + VmaAllocator getAllocator() const { return allocator; } + VkSurfaceKHR getSurface() const { return surface; } + + VkSwapchainKHR getSwapchain() const { return swapchain; } + VkFormat getSwapchainFormat() const { return swapchainFormat; } + VkExtent2D getSwapchainExtent() const { return swapchainExtent; } + const std::vector& getSwapchainImageViews() const { return swapchainImageViews; } + const std::vector& getSwapchainImages() const { return swapchainImages; } + uint32_t getSwapchainImageCount() const { return static_cast(swapchainImages.size()); } + + uint32_t getCurrentFrame() const { return currentFrame; } + const FrameData& getCurrentFrameData() const { return frames[currentFrame]; } + + // For ImGui + VkRenderPass getImGuiRenderPass() const { return imguiRenderPass; } + VkDescriptorPool getImGuiDescriptorPool() const { return imguiDescriptorPool; } + const std::vector& getSwapchainFramebuffers() const { return swapchainFramebuffers; } + + bool isSwapchainDirty() const { return swapchainDirty; } + void markSwapchainDirty() { swapchainDirty = true; } + + // MSAA + VkSampleCountFlagBits getMsaaSamples() const { return msaaSamples_; } + void setMsaaSamples(VkSampleCountFlagBits samples); + VkSampleCountFlagBits getMaxUsableSampleCount() const; + VkImage getDepthImage() const { return depthImage; } + VkImage getDepthCopySourceImage() const { + return (depthResolveImage != VK_NULL_HANDLE) ? depthResolveImage : depthImage; + } + bool isDepthCopySourceMsaa() const { + return (depthResolveImage == VK_NULL_HANDLE) && (msaaSamples_ > VK_SAMPLE_COUNT_1_BIT); + } + VkFormat getDepthFormat() const { return depthFormat; } + VkImageView getDepthResolveImageView() const { return depthResolveImageView; } + VkImageView getDepthImageView() const { return depthImageView; } + + // UI texture upload: creates a Vulkan texture from RGBA data and returns + // a VkDescriptorSet suitable for use as ImTextureID. + // The caller does NOT need to free the result β€” resources are tracked and + // cleaned up when the VkContext is destroyed. + VkDescriptorSet uploadImGuiTexture(const uint8_t* rgba, int width, int height); + +private: + bool createInstance(SDL_Window* window); + bool createSurface(SDL_Window* window); + bool selectPhysicalDevice(); + bool createLogicalDevice(); + bool createAllocator(); + bool createSwapchain(int width, int height); + void destroySwapchain(); + bool createCommandPools(); + bool createSyncObjects(); + bool createImGuiResources(); + void destroyImGuiResources(); + + // vk-bootstrap objects (kept alive for swapchain recreation etc.) + vkb::Instance vkbInstance_; + vkb::PhysicalDevice vkbPhysicalDevice_; + + VkInstance instance = VK_NULL_HANDLE; + VkDebugUtilsMessengerEXT debugMessenger = VK_NULL_HANDLE; + VkSurfaceKHR surface = VK_NULL_HANDLE; + VkPhysicalDevice physicalDevice = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + VmaAllocator allocator = VK_NULL_HANDLE; + + VkQueue graphicsQueue = VK_NULL_HANDLE; + VkQueue presentQueue = VK_NULL_HANDLE; + uint32_t graphicsQueueFamily = 0; + uint32_t presentQueueFamily = 0; + + // Swapchain + VkSwapchainKHR swapchain = VK_NULL_HANDLE; + VkFormat swapchainFormat = VK_FORMAT_UNDEFINED; + VkExtent2D swapchainExtent = {0, 0}; + std::vector swapchainImages; + std::vector swapchainImageViews; + std::vector swapchainFramebuffers; + bool swapchainDirty = false; + + // Per-frame resources + FrameData frames[MAX_FRAMES_IN_FLIGHT]; + uint32_t currentFrame = 0; + + // Immediate submit resources + VkCommandPool immCommandPool = VK_NULL_HANDLE; + VkFence immFence = VK_NULL_HANDLE; + + // Depth buffer (shared across all framebuffers) + VkImage depthImage = VK_NULL_HANDLE; + VkImageView depthImageView = VK_NULL_HANDLE; + VmaAllocation depthAllocation = VK_NULL_HANDLE; + VkFormat depthFormat = VK_FORMAT_D32_SFLOAT; + + bool createDepthBuffer(); + void destroyDepthBuffer(); + + // MSAA resources + VkSampleCountFlagBits msaaSamples_ = VK_SAMPLE_COUNT_1_BIT; + VkImage msaaColorImage_ = VK_NULL_HANDLE; + VkImageView msaaColorView_ = VK_NULL_HANDLE; + VmaAllocation msaaColorAllocation_ = VK_NULL_HANDLE; + + bool createMsaaColorImage(); + void destroyMsaaColorImage(); + bool createDepthResolveImage(); + void destroyDepthResolveImage(); + + // MSAA depth resolve support (for sampling/copying resolved depth) + bool depthResolveSupported_ = false; + VkResolveModeFlagBits depthResolveMode_ = VK_RESOLVE_MODE_NONE; + VkImage depthResolveImage = VK_NULL_HANDLE; + VkImageView depthResolveImageView = VK_NULL_HANDLE; + VmaAllocation depthResolveAllocation = VK_NULL_HANDLE; + + // ImGui resources + VkRenderPass imguiRenderPass = VK_NULL_HANDLE; + VkDescriptorPool imguiDescriptorPool = VK_NULL_HANDLE; + + // Shared sampler for UI textures (created on first uploadImGuiTexture call) + VkSampler uiTextureSampler_ = VK_NULL_HANDLE; + + // Tracked UI textures for cleanup + struct UiTexture { + VkImage image; + VkDeviceMemory memory; + VkImageView view; + }; + std::vector uiTextures_; + +#ifndef NDEBUG + bool enableValidation = true; +#else + bool enableValidation = false; +#endif +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/vk_frame_data.hpp b/include/rendering/vk_frame_data.hpp new file mode 100644 index 00000000..595b76ac --- /dev/null +++ b/include/rendering/vk_frame_data.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace wowee { +namespace rendering { + +// Must match the PerFrame UBO layout in all shaders (std140 alignment) +struct GPUPerFrameData { + glm::mat4 view; + glm::mat4 projection; + glm::mat4 lightSpaceMatrix; + glm::vec4 lightDir; // xyz = direction, w = unused + glm::vec4 lightColor; // xyz = color, w = unused + glm::vec4 ambientColor; // xyz = color, w = unused + glm::vec4 viewPos; // xyz = camera pos, w = unused + glm::vec4 fogColor; // xyz = color, w = unused + glm::vec4 fogParams; // x = fogStart, y = fogEnd, z = time, w = unused + glm::vec4 shadowParams; // x = enabled(0/1), y = strength, z = unused, w = unused +}; + +// Push constants for the model matrix (most common case) +struct GPUPushConstants { + glm::mat4 model; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/vk_pipeline.hpp b/include/rendering/vk_pipeline.hpp new file mode 100644 index 00000000..ea0a3e10 --- /dev/null +++ b/include/rendering/vk_pipeline.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace rendering { + +// Builder pattern for VkGraphicsPipeline creation. +// Usage: +// auto pipeline = PipelineBuilder() +// .setShaders(vertStage, fragStage) +// .setVertexInput(bindings, attributes) +// .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) +// .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_BACK_BIT) +// .setDepthTest(true, true, VK_COMPARE_OP_LESS) +// .setColorBlendAttachment(PipelineBuilder::blendAlpha()) +// .setLayout(pipelineLayout) +// .setRenderPass(renderPass) +// .build(device); + +class PipelineBuilder { +public: + PipelineBuilder(); + + // Shader stages + PipelineBuilder& setShaders(VkPipelineShaderStageCreateInfo vert, + VkPipelineShaderStageCreateInfo frag); + + // Vertex input + PipelineBuilder& setVertexInput( + const std::vector& bindings, + const std::vector& attributes); + + // No vertex input (fullscreen quad generated in vertex shader) + PipelineBuilder& setNoVertexInput(); + + // Input assembly + PipelineBuilder& setTopology(VkPrimitiveTopology topology, + VkBool32 primitiveRestart = VK_FALSE); + + // Rasterization + PipelineBuilder& setRasterization(VkPolygonMode polygonMode, + VkCullModeFlags cullMode, + VkFrontFace frontFace = VK_FRONT_FACE_CLOCKWISE); + + // Depth test/write + PipelineBuilder& setDepthTest(bool enable, bool writeEnable, + VkCompareOp compareOp = VK_COMPARE_OP_LESS); + + // No depth test (default) + PipelineBuilder& setNoDepthTest(); + + // Depth bias (for shadow maps) + PipelineBuilder& setDepthBias(float constantFactor, float slopeFactor); + + // Color blend attachment + PipelineBuilder& setColorBlendAttachment( + VkPipelineColorBlendAttachmentState blendState); + + // No color attachment (depth-only pass) + PipelineBuilder& setNoColorAttachment(); + + // Multisampling + PipelineBuilder& setMultisample(VkSampleCountFlagBits samples); + PipelineBuilder& setAlphaToCoverage(bool enable); + + // Pipeline layout + PipelineBuilder& setLayout(VkPipelineLayout layout); + + // Render pass + PipelineBuilder& setRenderPass(VkRenderPass renderPass, uint32_t subpass = 0); + + // Dynamic state + PipelineBuilder& setDynamicStates(const std::vector& states); + + // Build the pipeline + VkPipeline build(VkDevice device) const; + + // Common blend states + static VkPipelineColorBlendAttachmentState blendDisabled(); + static VkPipelineColorBlendAttachmentState blendAlpha(); + static VkPipelineColorBlendAttachmentState blendPremultiplied(); + static VkPipelineColorBlendAttachmentState blendAdditive(); + +private: + std::vector shaderStages_; + std::vector vertexBindings_; + std::vector vertexAttributes_; + VkPrimitiveTopology topology_ = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkBool32 primitiveRestart_ = VK_FALSE; + VkPolygonMode polygonMode_ = VK_POLYGON_MODE_FILL; + VkCullModeFlags cullMode_ = VK_CULL_MODE_NONE; + VkFrontFace frontFace_ = VK_FRONT_FACE_CLOCKWISE; + bool depthTestEnable_ = false; + bool depthWriteEnable_ = false; + VkCompareOp depthCompareOp_ = VK_COMPARE_OP_LESS; + bool depthBiasEnable_ = false; + float depthBiasConstant_ = 0.0f; + float depthBiasSlope_ = 0.0f; + VkSampleCountFlagBits msaaSamples_ = VK_SAMPLE_COUNT_1_BIT; + bool alphaToCoverage_ = false; + std::vector colorBlendAttachments_; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + VkRenderPass renderPass_ = VK_NULL_HANDLE; + uint32_t subpass_ = 0; + std::vector dynamicStates_; +}; + +// Helper to create a pipeline layout from descriptor set layouts and push constant ranges +VkPipelineLayout createPipelineLayout(VkDevice device, + const std::vector& setLayouts, + const std::vector& pushConstants = {}); + +// Helper to create a descriptor set layout from bindings +VkDescriptorSetLayout createDescriptorSetLayout(VkDevice device, + const std::vector& bindings); + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/vk_render_target.hpp b/include/rendering/vk_render_target.hpp new file mode 100644 index 00000000..ffa1cd4f --- /dev/null +++ b/include/rendering/vk_render_target.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "rendering/vk_utils.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { + +class VkContext; + +/** + * Off-screen render target encapsulating VkRenderPass + VkFramebuffer + color VkImage. + * Used for minimap compositing, world map compositing, and other off-screen passes. + * Supports optional MSAA with automatic resolve. + */ +class VkRenderTarget { +public: + VkRenderTarget() = default; + ~VkRenderTarget(); + + VkRenderTarget(const VkRenderTarget&) = delete; + VkRenderTarget& operator=(const VkRenderTarget&) = delete; + + /** + * Create the render target with given dimensions and format. + * Creates: color image, image view, sampler, render pass, framebuffer. + * When withDepth is true, also creates a D32_SFLOAT depth attachment. + * When msaaSamples > 1, creates multisampled images and a resolve attachment. + */ + bool create(VkContext& ctx, uint32_t width, uint32_t height, + VkFormat format = VK_FORMAT_R8G8B8A8_UNORM, bool withDepth = false, + VkSampleCountFlagBits msaaSamples = VK_SAMPLE_COUNT_1_BIT); + + /** + * Destroy all Vulkan resources. + */ + void destroy(VkDevice device, VmaAllocator allocator); + + /** + * Begin the off-screen render pass (clears to given color). + * Must be called outside any other active render pass. + */ + void beginPass(VkCommandBuffer cmd, + const VkClearColorValue& clear = {{0.0f, 0.0f, 0.0f, 1.0f}}); + + /** + * End the off-screen render pass. + * After this, the color image is in SHADER_READ_ONLY_OPTIMAL layout. + */ + void endPass(VkCommandBuffer cmd); + + // Accessors - always return the resolved (single-sample) image for reading + VkImage getColorImage() const { return resolveImage_.image ? resolveImage_.image : colorImage_.image; } + VkImageView getColorImageView() const { return resolveImage_.imageView ? resolveImage_.imageView : colorImage_.imageView; } + VkSampler getSampler() const { return sampler_; } + VkRenderPass getRenderPass() const { return renderPass_; } + VkExtent2D getExtent() const { return { colorImage_.extent.width, colorImage_.extent.height }; } + VkFormat getFormat() const { return colorImage_.format; } + bool isValid() const { return framebuffer_ != VK_NULL_HANDLE; } + VkSampleCountFlagBits getSampleCount() const { return msaaSamples_; } + + /** + * Descriptor info for binding the color attachment as a texture in a shader. + */ + VkDescriptorImageInfo descriptorInfo() const; + +private: + AllocatedImage colorImage_{}; // MSAA color (or single-sample if no MSAA) + AllocatedImage resolveImage_{}; // Single-sample resolve target (only when MSAA) + AllocatedImage depthImage_{}; + bool hasDepth_ = false; + VkSampleCountFlagBits msaaSamples_ = VK_SAMPLE_COUNT_1_BIT; + VkSampler sampler_ = VK_NULL_HANDLE; + VkRenderPass renderPass_ = VK_NULL_HANDLE; + VkFramebuffer framebuffer_ = VK_NULL_HANDLE; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/vk_shader.hpp b/include/rendering/vk_shader.hpp new file mode 100644 index 00000000..cd8fc839 --- /dev/null +++ b/include/rendering/vk_shader.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace rendering { + +class VkShaderModule { +public: + VkShaderModule() = default; + ~VkShaderModule(); + + VkShaderModule(const VkShaderModule&) = delete; + VkShaderModule& operator=(const VkShaderModule&) = delete; + VkShaderModule(VkShaderModule&& other) noexcept; + VkShaderModule& operator=(VkShaderModule&& other) noexcept; + + // Load a SPIR-V file from disk + bool loadFromFile(VkDevice device, const std::string& path); + + // Load from raw SPIR-V bytes + bool loadFromMemory(VkDevice device, const uint32_t* code, size_t sizeBytes); + + void destroy(); + + ::VkShaderModule getModule() const { return module_; } + bool isValid() const { return module_ != VK_NULL_HANDLE; } + + // Create a VkPipelineShaderStageCreateInfo for this module + VkPipelineShaderStageCreateInfo stageInfo(VkShaderStageFlagBits stage, + const char* entryPoint = "main") const; + +private: + VkDevice device_ = VK_NULL_HANDLE; + ::VkShaderModule module_ = VK_NULL_HANDLE; +}; + +// Convenience: load a shader stage directly from a .spv file +VkPipelineShaderStageCreateInfo loadShaderStage(VkDevice device, + const std::string& path, VkShaderStageFlagBits stage); + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/vk_texture.hpp b/include/rendering/vk_texture.hpp new file mode 100644 index 00000000..83167d9d --- /dev/null +++ b/include/rendering/vk_texture.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include "rendering/vk_utils.hpp" +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +class VkContext; + +class VkTexture { +public: + VkTexture() = default; + ~VkTexture(); + + VkTexture(const VkTexture&) = delete; + VkTexture& operator=(const VkTexture&) = delete; + VkTexture(VkTexture&& other) noexcept; + VkTexture& operator=(VkTexture&& other) noexcept; + + // Upload RGBA8 pixel data to GPU + bool upload(VkContext& ctx, const uint8_t* pixels, uint32_t width, uint32_t height, + VkFormat format = VK_FORMAT_R8G8B8A8_UNORM, bool generateMips = true); + + // Upload with pre-existing mip data (array of mip levels) + bool uploadMips(VkContext& ctx, const uint8_t* const* mipData, const uint32_t* mipSizes, + uint32_t mipCount, uint32_t width, uint32_t height, + VkFormat format = VK_FORMAT_R8G8B8A8_UNORM); + + // Create a depth/stencil texture (no upload) + bool createDepth(VkContext& ctx, uint32_t width, uint32_t height, + VkFormat format = VK_FORMAT_D32_SFLOAT); + + // Create sampler with specified filtering + bool createSampler(VkDevice device, + VkFilter minFilter = VK_FILTER_LINEAR, + VkFilter magFilter = VK_FILTER_LINEAR, + VkSamplerAddressMode addressMode = VK_SAMPLER_ADDRESS_MODE_REPEAT, + float maxAnisotropy = 16.0f); + + // Overload with separate S/T address modes + bool createSampler(VkDevice device, + VkFilter filter, + VkSamplerAddressMode addressModeU, + VkSamplerAddressMode addressModeV, + float maxAnisotropy = 16.0f); + + // Create a comparison sampler (for shadow mapping) + bool createShadowSampler(VkDevice device); + + void destroy(VkDevice device, VmaAllocator allocator); + + VkImage getImage() const { return image_.image; } + VkImageView getImageView() const { return image_.imageView; } + VkSampler getSampler() const { return sampler_; } + uint32_t getWidth() const { return image_.extent.width; } + uint32_t getHeight() const { return image_.extent.height; } + VkFormat getFormat() const { return image_.format; } + uint32_t getMipLevels() const { return mipLevels_; } + bool isValid() const { return image_.image != VK_NULL_HANDLE; } + + // Write descriptor info for binding + VkDescriptorImageInfo descriptorInfo(VkImageLayout layout = + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) const; + +private: + void generateMipmaps(VkContext& ctx, VkFormat format, uint32_t width, uint32_t height); + + AllocatedImage image_{}; + VkSampler sampler_ = VK_NULL_HANDLE; + uint32_t mipLevels_ = 1; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/vk_utils.hpp b/include/rendering/vk_utils.hpp new file mode 100644 index 00000000..e64104f9 --- /dev/null +++ b/include/rendering/vk_utils.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include + +namespace wowee { +namespace rendering { + +class VkContext; + +struct AllocatedBuffer { + VkBuffer buffer = VK_NULL_HANDLE; + VmaAllocation allocation = VK_NULL_HANDLE; + VmaAllocationInfo info{}; +}; + +struct AllocatedImage { + VkImage image = VK_NULL_HANDLE; + VmaAllocation allocation = VK_NULL_HANDLE; + VkImageView imageView = VK_NULL_HANDLE; + VkExtent2D extent{}; + VkFormat format = VK_FORMAT_UNDEFINED; +}; + +// Buffer creation +AllocatedBuffer createBuffer(VmaAllocator allocator, VkDeviceSize size, + VkBufferUsageFlags usage, VmaMemoryUsage memoryUsage); + +void destroyBuffer(VmaAllocator allocator, AllocatedBuffer& buffer); + +// Image creation +AllocatedImage createImage(VkDevice device, VmaAllocator allocator, + uint32_t width, uint32_t height, VkFormat format, + VkImageUsageFlags usage, VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT, + uint32_t mipLevels = 1); + +void destroyImage(VkDevice device, VmaAllocator allocator, AllocatedImage& image); + +// Image layout transitions +void transitionImageLayout(VkCommandBuffer cmd, VkImage image, + VkImageLayout oldLayout, VkImageLayout newLayout, + VkPipelineStageFlags srcStage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VkPipelineStageFlags dstStage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT); + +// Staging upload helper β€” copies CPU data to a GPU-local buffer +AllocatedBuffer uploadBuffer(VkContext& ctx, const void* data, VkDeviceSize size, + VkBufferUsageFlags usage); + +// Check VkResult and log on failure +inline bool vkCheck(VkResult result, const char* msg) { + if (result != VK_SUCCESS) { + // Caller should log the message + return false; + } + return true; +} + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/water_renderer.hpp b/include/rendering/water_renderer.hpp index 653e0742..af255ca5 100644 --- a/include/rendering/water_renderer.hpp +++ b/include/rendering/water_renderer.hpp @@ -4,6 +4,9 @@ #include #include #include +#include +#include +#include #include namespace wowee { @@ -16,127 +19,121 @@ namespace pipeline { namespace rendering { class Camera; -class Shader; +class VkContext; /** * Water surface for a single map chunk */ struct WaterSurface { - glm::vec3 position; // World position - glm::vec3 origin; // Mesh origin (world) - glm::vec3 stepX; // Mesh X step vector in world space - glm::vec3 stepY; // Mesh Y step vector in world space - float minHeight; // Minimum water height - float maxHeight; // Maximum water height - uint16_t liquidType; // LiquidType.dbc ID (WotLK) + glm::vec3 position; + glm::vec3 origin; + glm::vec3 stepX; + glm::vec3 stepY; + float minHeight; + float maxHeight; + uint16_t liquidType; - // Owning tile coordinates (for per-tile removal) int tileX = -1, tileY = -1; - - // Owning WMO instance ID (for WMO liquid removal, 0 = terrain water) uint32_t wmoId = 0; - // Water layer dimensions within chunk (0-7 offset, 1-8 size) uint8_t xOffset = 0; uint8_t yOffset = 0; - uint8_t width = 8; // Width in tiles (1-8) - uint8_t height = 8; // Height in tiles (1-8) + uint8_t width = 8; + uint8_t height = 8; - // Height map for water surface ((width+1) x (height+1) vertices) std::vector heights; - - // Render mask (which tiles have water) std::vector mask; - // Render data - uint32_t vao = 0; - uint32_t vbo = 0; - uint32_t ebo = 0; + // Vulkan render data + ::VkBuffer vertexBuffer = VK_NULL_HANDLE; + VmaAllocation vertexAlloc = VK_NULL_HANDLE; + ::VkBuffer indexBuffer = VK_NULL_HANDLE; + VmaAllocation indexAlloc = VK_NULL_HANDLE; int indexCount = 0; + // Per-surface material UBO + ::VkBuffer materialUBO = VK_NULL_HANDLE; + VmaAllocation materialAlloc = VK_NULL_HANDLE; + + // Material descriptor set (set 1) + VkDescriptorSet materialSet = VK_NULL_HANDLE; + bool hasHeightData() const { return !heights.empty(); } }; /** - * Water renderer - * - * Renders water surfaces with transparency and animation. - * Supports multiple liquid types (water, ocean, magma, slime). + * Water renderer (Vulkan) with planar reflections, Gerstner waves, + * GGX specular, shoreline foam, and subsurface scattering. */ class WaterRenderer { public: WaterRenderer(); ~WaterRenderer(); - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); void shutdown(); - /** - * Load water surfaces from ADT terrain - * @param terrain The ADT terrain data - * @param append If true, add to existing water instead of replacing - * @param tileX Tile X coordinate for tracking ownership (-1 = untracked) - * @param tileY Tile Y coordinate for tracking ownership (-1 = untracked) - */ void loadFromTerrain(const pipeline::ADTTerrain& terrain, bool append = false, int tileX = -1, int tileY = -1); - /** - * Load water surface from WMO liquid data - * @param liquid WMO liquid data from MLIQ chunk - * @param modelMatrix WMO instance model matrix for transforming to world space - * @param wmoId WMO instance ID for tracking ownership - */ void loadFromWMO(const pipeline::WMOLiquid& liquid, const glm::mat4& modelMatrix, uint32_t wmoId); - - /** - * Remove all water surfaces belonging to a specific WMO instance - * @param wmoId WMO instance ID - */ void removeWMO(uint32_t wmoId); - - /** - * Remove all water surfaces belonging to a specific tile - * @param tileX Tile X coordinate - * @param tileY Tile Y coordinate - */ void removeTile(int tileX, int tileY); - - /** - * Clear all water surfaces - */ void clear(); - /** - * Render all water surfaces - */ - void render(const Camera& camera, float time); + void recreatePipelines(); + + // Separate 1x pass for MSAA mode β€” water rendered after MSAA resolve + bool createWater1xPass(VkFormat colorFormat, VkFormat depthFormat); + void createWater1xFramebuffers(const std::vector& swapViews, + VkImageView depthView, VkExtent2D extent); + void destroyWater1xResources(); + bool beginWater1xPass(VkCommandBuffer cmd, uint32_t imageIndex, VkExtent2D extent); + void endWater1xPass(VkCommandBuffer cmd); + bool hasWater1xPass() const { return water1xRenderPass != VK_NULL_HANDLE; } + VkRenderPass getWater1xRenderPass() const { return water1xRenderPass; } + + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, float time, bool use1x = false); + void captureSceneHistory(VkCommandBuffer cmd, + VkImage srcColorImage, + VkImage srcDepthImage, + VkExtent2D srcExtent, + bool srcDepthIsMsaa); + + // --- Planar reflection pass --- + // Call sequence: beginReflectionPass β†’ [render scene] β†’ endReflectionPass + bool beginReflectionPass(VkCommandBuffer cmd); + void endReflectionPass(VkCommandBuffer cmd); + + // Get the dominant water height near a position (for reflection plane) + std::optional getDominantWaterHeight(const glm::vec3& cameraPos) const; + + // Compute reflected view matrix for a given water height + static glm::mat4 computeReflectedView(const Camera& camera, float waterHeight); + // Compute oblique clip projection to clip below-water geometry in reflection + static glm::mat4 computeObliqueProjection(const glm::mat4& proj, const glm::mat4& view, float waterHeight); + + // Update the reflection UBO with reflected viewProj matrix + void updateReflectionUBO(const glm::mat4& reflViewProj); + + VkRenderPass getReflectionRenderPass() const { return reflectionRenderPass; } + VkExtent2D getReflectionExtent() const { return {REFLECTION_WIDTH, REFLECTION_HEIGHT}; } + bool hasReflectionPass() const { return reflectionRenderPass != VK_NULL_HANDLE; } + bool hasSurfaces() const { return !surfaces.empty(); } - /** - * Enable/disable water rendering - */ void setEnabled(bool enabled) { renderingEnabled = enabled; } bool isEnabled() const { return renderingEnabled; } - /** - * Query the water height at a given world position. - * Returns the highest water surface height at that XY, or nullopt if no water. - */ std::optional getWaterHeightAt(float glX, float glY) const; + /// Like getWaterHeightAt but only returns water surfaces whose height is + /// close to the query Z (within maxAbove units above). Avoids false + /// underwater detection from elevated WMO water far above the camera. + std::optional getNearestWaterHeightAt(float glX, float glY, float queryZ, float maxAbove = 15.0f) const; std::optional getWaterTypeAt(float glX, float glY) const; + bool isWmoWaterAt(float glX, float glY) const; - /** - * Get water surface count - */ int getSurfaceCount() const { return static_cast(surfaces.size()); } - /** - * Set fog parameters - */ - void setFog(const glm::vec3& color, float start, float end) { - fogColor = color; fogStart = start; fogEnd = end; - } - private: void createWaterMesh(WaterSurface& surface); void destroyWaterMesh(WaterSurface& surface); @@ -144,14 +141,64 @@ private: glm::vec4 getLiquidColor(uint16_t liquidType) const; float getLiquidAlpha(uint16_t liquidType) const; - std::unique_ptr waterShader; + void updateMaterialUBO(WaterSurface& surface); + VkDescriptorSet allocateMaterialSet(); + void createSceneHistoryResources(VkExtent2D extent, VkFormat colorFormat, VkFormat depthFormat); + void destroySceneHistoryResources(); + + // Reflection pass resources + void createReflectionResources(); + void destroyReflectionResources(); + + VkContext* vkCtx = nullptr; + + // Pipeline + VkPipeline waterPipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + VkDescriptorSetLayout materialSetLayout = VK_NULL_HANDLE; + VkDescriptorPool materialDescPool = VK_NULL_HANDLE; + VkDescriptorSetLayout sceneSetLayout = VK_NULL_HANDLE; + VkDescriptorPool sceneDescPool = VK_NULL_HANDLE; + VkDescriptorSet sceneSet = VK_NULL_HANDLE; + static constexpr uint32_t MAX_WATER_SETS = 2048; + + VkSampler sceneColorSampler = VK_NULL_HANDLE; + VkSampler sceneDepthSampler = VK_NULL_HANDLE; + VkImage sceneColorImage = VK_NULL_HANDLE; + VmaAllocation sceneColorAlloc = VK_NULL_HANDLE; + VkImageView sceneColorView = VK_NULL_HANDLE; + VkImage sceneDepthImage = VK_NULL_HANDLE; + VmaAllocation sceneDepthAlloc = VK_NULL_HANDLE; + VkImageView sceneDepthView = VK_NULL_HANDLE; + VkExtent2D sceneHistoryExtent = {0, 0}; + bool sceneHistoryReady = false; + + // Planar reflection resources + static constexpr uint32_t REFLECTION_WIDTH = 512; + static constexpr uint32_t REFLECTION_HEIGHT = 512; + VkRenderPass reflectionRenderPass = VK_NULL_HANDLE; + VkFramebuffer reflectionFramebuffer = VK_NULL_HANDLE; + VkImage reflectionColorImage = VK_NULL_HANDLE; + VmaAllocation reflectionColorAlloc = VK_NULL_HANDLE; + VkImageView reflectionColorView = VK_NULL_HANDLE; + VkImage reflectionDepthImage = VK_NULL_HANDLE; + VmaAllocation reflectionDepthAlloc = VK_NULL_HANDLE; + VkImageView reflectionDepthView = VK_NULL_HANDLE; + VkSampler reflectionSampler = VK_NULL_HANDLE; + VkImageLayout reflectionColorLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + // Reflection UBO (mat4 reflViewProj) + ::VkBuffer reflectionUBO = VK_NULL_HANDLE; + VmaAllocation reflectionUBOAlloc = VK_NULL_HANDLE; + void* reflectionUBOMapped = nullptr; + + // Separate 1x water pass (used when MSAA is active) + VkRenderPass water1xRenderPass = VK_NULL_HANDLE; + VkPipeline water1xPipeline = VK_NULL_HANDLE; + std::vector water1xFramebuffers; + std::vector surfaces; bool renderingEnabled = true; - - // Fog parameters - glm::vec3 fogColor = glm::vec3(0.5f, 0.6f, 0.7f); - float fogStart = 800.0f; // Match WMO renderer fog settings - float fogEnd = 1500.0f; }; } // namespace rendering diff --git a/include/rendering/weather.hpp b/include/rendering/weather.hpp index 08a78694..b92c963d 100644 --- a/include/rendering/weather.hpp +++ b/include/rendering/weather.hpp @@ -1,15 +1,16 @@ #pragma once -#include +#include +#include #include -#include #include +#include namespace wowee { namespace rendering { class Camera; -class Shader; +class VkContext; /** * @brief Weather particle system for rain and snow @@ -20,7 +21,7 @@ class Shader; * - Particle recycling for efficiency * - Camera-relative positioning (follows player) * - Adjustable intensity (light, medium, heavy) - * - GPU instanced rendering + * - Vulkan point-sprite rendering */ class Weather { public: @@ -35,9 +36,12 @@ public: /** * @brief Initialize weather system + * @param ctx Vulkan context + * @param perFrameLayout Descriptor set layout for the per-frame UBO (set 0) * @return true if initialization succeeded */ - bool initialize(); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout); + void recreatePipelines(); /** * @brief Update weather particles @@ -48,9 +52,10 @@ public: /** * @brief Render weather particles - * @param camera Camera for rendering + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0, contains camera UBO) */ - void render(const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); /** * @brief Set weather type @@ -75,6 +80,40 @@ public: */ int getParticleCount() const; + /** + * @brief Zone weather configuration + * Provides default weather per zone for single-player mode. + * When connected to a server, SMSG_WEATHER overrides these. + */ + struct ZoneWeather { + Type type = Type::NONE; + float minIntensity = 0.0f; // Min intensity (varies over time) + float maxIntensity = 0.0f; // Max intensity + float probability = 0.0f; // Chance of weather being active (0-1) + }; + + /** + * @brief Set weather for a zone (used for zone-based weather configuration) + */ + void setZoneWeather(uint32_t zoneId, Type type, float minIntensity, float maxIntensity, float probability); + + /** + * @brief Update weather based on current zone (single-player mode) + * @param zoneId Current zone ID + * @param deltaTime Time since last frame + */ + void updateZoneWeather(uint32_t zoneId, float deltaTime); + + /** + * @brief Initialize default zone weather table + */ + void initializeZoneWeatherDefaults(); + + /** + * @brief Clean up Vulkan resources + */ + void shutdown(); + private: struct Particle { glm::vec3 position; @@ -83,15 +122,20 @@ private: float maxLifetime; }; - void cleanup(); void resetParticles(const Camera& camera); void updateParticle(Particle& particle, const Camera& camera, float deltaTime); glm::vec3 getRandomPosition(const glm::vec3& center) const; - // OpenGL objects - GLuint vao = 0; - GLuint vbo = 0; // Instance buffer - std::unique_ptr shader; + // Vulkan objects + VkContext* vkCtx = nullptr; + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + + // Dynamic mapped buffer for particle positions (updated every frame) + ::VkBuffer dynamicVB = VK_NULL_HANDLE; + VmaAllocation dynamicVBAlloc = VK_NULL_HANDLE; + VmaAllocationInfo dynamicVBAllocInfo{}; + VkDeviceSize dynamicVBSize = 0; // Particles std::vector particles; @@ -106,6 +150,15 @@ private: static constexpr int MAX_PARTICLES = 2000; static constexpr float SPAWN_VOLUME_SIZE = 100.0f; // Size of spawn area around camera static constexpr float SPAWN_HEIGHT = 80.0f; // Height above camera to spawn + + // Zone-based weather + std::unordered_map zoneWeatherTable_; + uint32_t currentWeatherZone_ = 0; + float zoneWeatherTimer_ = 0.0f; // Time accumulator for weather cycling + float zoneWeatherCycleDuration_ = 0.0f; // Current cycle length + bool zoneWeatherActive_ = false; // Is zone weather currently active? + float targetIntensity_ = 0.0f; // Target intensity for smooth transitions + bool zoneWeatherInitialized_ = false; }; } // namespace rendering diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 82295ca1..9d4a62a6 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -1,6 +1,7 @@ #pragma once -#include +#include +#include #include #include #include @@ -8,6 +9,7 @@ #include #include #include +#include namespace wowee { namespace pipeline { @@ -19,12 +21,13 @@ namespace pipeline { namespace rendering { class Camera; -class Shader; class Frustum; class M2Renderer; +class VkContext; +class VkTexture; /** - * WMO (World Model Object) Renderer + * WMO (World Model Object) Renderer (Vulkan) * * Renders buildings, dungeons, and large structures from WMO files. * Features: @@ -32,7 +35,6 @@ class M2Renderer; * - Batched rendering per group * - Frustum culling * - Portal visibility (future) - * - Dynamic lighting support (future) */ class WMORenderer { public: @@ -40,10 +42,13 @@ public: ~WMORenderer(); /** - * Initialize renderer and create shaders + * Initialize renderer (Vulkan) + * @param ctx Vulkan context + * @param perFrameLayout Descriptor set layout for set 0 (per-frame UBO) * @param assetManager Asset manager for loading textures (optional) */ - bool initialize(pipeline::AssetManager* assetManager = nullptr); + bool initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* assetManager = nullptr); /** * Cleanup GPU resources @@ -132,16 +137,28 @@ public: void clearInstances(); /** - * Render all WMO instances - * @param camera Camera for view/projection matrices - * @param view View matrix - * @param projection Projection matrix + * Render all WMO instances (Vulkan) + * @param cmd Command buffer to record into + * @param perFrameSet Per-frame descriptor set (set 0) + * @param camera Camera for frustum culling */ - void render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); + + /** + * Initialize shadow pipeline (Phase 7) + */ + bool initializeShadow(VkRenderPass shadowRenderPass); + + /** + * Render depth-only for shadow casting + */ + void renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, + const glm::vec3& shadowCenter = glm::vec3(0), float shadowRadius = 1e9f); /** * Get number of loaded models */ + void recreatePipelines(); bool isInitialized() const { return initialized_; } uint32_t getModelCount() const { return loadedModels.size(); } @@ -166,6 +183,18 @@ public: */ uint32_t getDrawCallCount() const { return lastDrawCalls; } + /** + * Normal mapping / Parallax Occlusion Mapping settings + */ + void setNormalMappingEnabled(bool enabled) { normalMappingEnabled_ = enabled; materialSettingsDirty_ = true; } + void setNormalMapStrength(float s) { normalMapStrength_ = s; materialSettingsDirty_ = true; } + void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; materialSettingsDirty_ = true; } + void setPOMQuality(int q) { pomQuality_ = q; materialSettingsDirty_ = true; } + bool isNormalMappingEnabled() const { return normalMappingEnabled_; } + float getNormalMapStrength() const { return normalMapStrength_; } + bool isPOMEnabled() const { return pomEnabled_; } + int getPOMQuality() const { return pomQuality_; } + /** * Enable/disable wireframe rendering */ @@ -204,32 +233,22 @@ public: uint32_t getDistanceCulledGroups() const { return lastDistanceCulledGroups; } /** - * Enable/disable GPU occlusion query culling + * Enable/disable GPU occlusion query culling (stubbed in Vulkan) */ - void setOcclusionCulling(bool enabled) { occlusionCulling = enabled; } - bool isOcclusionCullingEnabled() const { return occlusionCulling; } + void setOcclusionCulling(bool /*enabled*/) { /* stubbed */ } + bool isOcclusionCullingEnabled() const { return false; } /** * Get number of groups culled by occlusion queries last frame */ - uint32_t getOcclusionCulledGroups() const { return lastOcclusionCulledGroups; } + uint32_t getOcclusionCulledGroups() const { return 0; } - void setFog(const glm::vec3& color, float start, float end) { - fogColor = color; fogStart = start; fogEnd = end; - } - - void setLighting(const float lightDir[3], const float lightColor[3], - const float ambientColor[3]); - - void setShadowMap(GLuint depthTex, const glm::mat4& lightSpace) { - shadowDepthTex = depthTex; lightSpaceMatrix = lightSpace; shadowEnabled = true; - } - void clearShadowMap() { shadowEnabled = false; } - - /** - * Render depth-only for shadow casting (reuses VAOs) - */ - void renderShadow(const glm::mat4& lightView, const glm::mat4& lightProj, Shader& shadowShader); + // Lighting/fog/shadow are now in the per-frame UBO; these are no-ops for API compat + void setFog(const glm::vec3& /*color*/, float /*start*/, float /*end*/) {} + void setLighting(const float /*lightDir*/[3], const float /*lightColor*/[3], + const float /*ambientColor*/[3]) {} + void setShadowMap(uint32_t /*depthTex*/, const glm::mat4& /*lightSpace*/) {} + void clearShadowMap() {} /** * Get floor height at a GL position via ray-triangle intersection. @@ -297,13 +316,30 @@ public: void precomputeFloorCache(); private: + // WMO material UBO β€” matches WMOMaterial in wmo.frag.glsl + struct WMOMaterialUBO { + int32_t hasTexture; // 0 + int32_t alphaTest; // 4 + int32_t unlit; // 8 + int32_t isInterior; // 12 + float specularIntensity; // 16 + int32_t isWindow; // 20 + int32_t enableNormalMap; // 24 + int32_t enablePOM; // 28 + float pomScale; // 32 (height scale) + int32_t pomMaxSamples; // 36 (max ray-march steps) + float heightMapVariance; // 40 (low variance = skip POM) + float normalMapStrength; // 44 (0=flat, 1=full, 2=exaggerated) + }; // 48 bytes total + /** * WMO group GPU resources */ struct GroupResources { - GLuint vao = 0; - GLuint vbo = 0; - GLuint ebo = 0; + ::VkBuffer vertexBuffer = VK_NULL_HANDLE; + VmaAllocation vertexAlloc = VK_NULL_HANDLE; + ::VkBuffer indexBuffer = VK_NULL_HANDLE; + VmaAllocation indexAlloc = VK_NULL_HANDLE; uint32_t indexCount = 0; uint32_t vertexCount = 0; glm::vec3 boundingBoxMin; @@ -311,6 +347,7 @@ private: uint32_t groupFlags = 0; bool allUntextured = false; // True if ALL batches use fallback white texture (collision/placeholder group) + bool isLOD = false; // Distance-only group (skip when camera is close) // Material batches (start index, count, material ID) struct Batch { @@ -322,13 +359,20 @@ private: // Pre-merged batches for efficient rendering (computed at load time) struct MergedBatch { - GLuint texId; - bool hasTexture; - bool alphaTest; + VkTexture* texture = nullptr; // from cache, NOT owned + VkTexture* normalHeightMap = nullptr; // generated from diffuse, NOT owned + float heightMapVariance = 0.0f; // variance of height map (low = flat texture) + VkDescriptorSet materialSet = VK_NULL_HANDLE; // set 1 + ::VkBuffer materialUBO = VK_NULL_HANDLE; + VmaAllocation materialUBOAlloc = VK_NULL_HANDLE; + bool hasTexture = false; + bool alphaTest = false; bool unlit = false; - uint32_t blendMode = 0; - std::vector counts; - std::vector offsets; + bool isTransparent = false; // blendMode >= 2 + bool isWindow = false; // F_SIDN or F_WINDOW material + // For multi-draw: store index ranges + struct DrawRange { uint32_t firstIndex; uint32_t indexCount; }; + std::vector draws; }; std::vector mergedBatches; @@ -401,7 +445,8 @@ private: std::vector doodadTemplates; // Texture handles for this model (indexed by texture path order) - std::vector textures; + std::vector textures; // non-owning, from cache + std::vector textureNames; // lowercase texture paths (parallel to textures) // Material texture indices (materialId -> texture index) std::vector materialTextureIndices; @@ -458,13 +503,6 @@ private: */ bool createGroupResources(const pipeline::WMOGroup& group, GroupResources& resources, uint32_t groupFlags = 0); - /** - * Render a single group - */ - void renderGroup(const GroupResources& group, const ModelData& model, - const glm::mat4& modelMatrix, - const glm::mat4& view, const glm::mat4& projection); - /** * Check if group is visible in frustum */ @@ -479,11 +517,6 @@ private: /** * Get visible groups via portal traversal - * @param model The WMO model data - * @param cameraLocalPos Camera position in model space - * @param frustum Frustum for portal visibility testing - * @param modelMatrix Transform for world-space frustum test - * @param outVisibleGroups Output set of visible group indices */ void getVisibleGroupsViaPortals(const ModelData& model, const glm::vec3& cameraLocalPos, @@ -502,23 +535,27 @@ private: /** * Load a texture from path */ - GLuint loadTexture(const std::string& path); + VkTexture* loadTexture(const std::string& path); /** - * Initialize occlusion query resources (bbox VAO, shader) + * Generate normal+height map from diffuse RGBA8 pixels + * @param pixels RGBA8 pixel data + * @param width Texture width + * @param height Texture height + * @param outVariance Receives height map variance (for POM threshold) + * @return Generated VkTexture (RGBA8: RGB=normal, A=height) */ - void initOcclusionResources(); + std::unique_ptr generateNormalHeightMap(const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance); /** - * Run occlusion query pre-pass for an instance + * Allocate a material descriptor set from the pool */ - void runOcclusionQueries(const WMOInstance& instance, const ModelData& model, - const glm::mat4& view, const glm::mat4& projection); + VkDescriptorSet allocateMaterialSet(); /** - * Check if a group passed occlusion test (uses previous frame results) + * Destroy GPU resources for a single group */ - bool isGroupOccluded(uint32_t instanceId, uint32_t groupIndex) const; + void destroyGroupGPU(GroupResources& group); struct GridCell { int x; @@ -541,8 +578,8 @@ private: void rebuildSpatialIndex(); void gatherCandidates(const glm::vec3& queryMin, const glm::vec3& queryMax, std::vector& outIndices) const; - // Shader - std::unique_ptr shader; + // Vulkan context + VkContext* vkCtx_ = nullptr; // Asset manager for loading textures pipeline::AssetManager* assetManager = nullptr; @@ -553,9 +590,34 @@ private: // Current map name for zone-specific floor cache std::string mapName_; - // Texture cache (path -> texture ID) + // Vulkan pipelines + VkPipeline opaquePipeline_ = VK_NULL_HANDLE; + VkPipeline transparentPipeline_ = VK_NULL_HANDLE; + VkPipeline glassPipeline_ = VK_NULL_HANDLE; // alpha blend + depth write (windows) + VkPipeline wireframePipeline_ = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout_ = VK_NULL_HANDLE; + + // Shadow rendering (Phase 7) + VkPipeline shadowPipeline_ = VK_NULL_HANDLE; + VkPipelineLayout shadowPipelineLayout_ = VK_NULL_HANDLE; + VkDescriptorSetLayout shadowParamsLayout_ = VK_NULL_HANDLE; + VkDescriptorPool shadowParamsPool_ = VK_NULL_HANDLE; + VkDescriptorSet shadowParamsSet_ = VK_NULL_HANDLE; + ::VkBuffer shadowParamsUBO_ = VK_NULL_HANDLE; + VmaAllocation shadowParamsAlloc_ = VK_NULL_HANDLE; + + // Descriptor set layouts + VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; + + // Descriptor pool for material sets + VkDescriptorPool materialDescPool_ = VK_NULL_HANDLE; + static constexpr uint32_t MAX_MATERIAL_SETS = 8192; + + // Texture cache (path -> VkTexture) struct TextureCacheEntry { - GLuint id = 0; + std::unique_ptr texture; + std::unique_ptr normalHeightMap; // generated normal+height from diffuse + float heightMapVariance = 0.0f; // variance of generated height map size_t approxBytes = 0; uint64_t lastUse = 0; }; @@ -563,12 +625,20 @@ private: size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; // Default, overridden at init + std::unordered_set failedTextureCache_; + std::unordered_set loggedTextureLoadFails_; + uint32_t textureBudgetRejectWarnings_ = 0; // Default white texture - GLuint whiteTexture = 0; + std::unique_ptr whiteTexture_; + + // Flat normal placeholder (128,128,255,128) = up-pointing normal, mid-height + std::unique_ptr flatNormalTexture_; // Loaded models (modelId -> ModelData) std::unordered_map loadedModels; + size_t modelCacheLimit_ = 4000; + uint32_t modelLimitRejectWarnings_ = 0; // Active instances std::vector instances; @@ -576,43 +646,23 @@ private: bool initialized_ = false; + // Normal mapping / POM settings + bool normalMappingEnabled_ = true; // on by default + float normalMapStrength_ = 0.8f; // 0.0 = flat, 1.0 = full, 2.0 = exaggerated + bool pomEnabled_ = true; // on by default + int pomQuality_ = 1; // 0=Low(16), 1=Medium(32), 2=High(64) + bool materialSettingsDirty_ = false; // rebuild UBOs when settings change + // Rendering state bool wireframeMode = false; bool frustumCulling = true; bool portalCulling = false; // Disabled by default - needs debugging bool distanceCulling = false; // Disabled - causes ground to disappear - bool occlusionCulling = false; // GPU occlusion queries - disabled, adds overhead float maxGroupDistance = 500.0f; float maxGroupDistanceSq = 250000.0f; // maxGroupDistance^2 uint32_t lastDrawCalls = 0; mutable uint32_t lastPortalCulledGroups = 0; mutable uint32_t lastDistanceCulledGroups = 0; - mutable uint32_t lastOcclusionCulledGroups = 0; - - // Occlusion query resources - GLuint bboxVao = 0; - GLuint bboxVbo = 0; - std::unique_ptr occlusionShader; - // Query objects per (instance, group) - reused each frame - // Key: (instanceId << 16) | groupIndex - mutable std::unordered_map occlusionQueries; - // Results from previous frame (1 frame latency to avoid GPU stalls) - mutable std::unordered_map occlusionResults; - - // Fog parameters - glm::vec3 fogColor = glm::vec3(0.5f, 0.6f, 0.7f); - float fogStart = 3000.0f; // Increased to allow clearer visibility at distance - float fogEnd = 4000.0f; // Increased to match extended view distance - - // Lighting parameters - float lightDir[3] = {-0.3f, -0.7f, -0.6f}; - float lightColor[3] = {1.5f, 1.4f, 1.3f}; - float ambientColor[3] = {0.55f, 0.55f, 0.6f}; - - // Shadow mapping - GLuint shadowDepthTex = 0; - glm::mat4 lightSpaceMatrix = glm::mat4(1.0f); - bool shadowEnabled = false; // Optional query-space culling for collision/raycast hot paths. bool collisionFocusEnabled = false; @@ -636,8 +686,8 @@ private: std::vector visibleGroups; // group indices that passed culling uint32_t portalCulled = 0; uint32_t distanceCulled = 0; - uint32_t occlusionCulled = 0; }; + std::vector> cullFutures_; // Collision query profiling (per frame). mutable double queryTimeMs = 0.0; diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index 128afc00..13e7614f 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -1,9 +1,11 @@ #pragma once -#include +#include +#include #include #include #include +#include #include #include @@ -11,7 +13,9 @@ namespace wowee { namespace pipeline { class AssetManager; } namespace rendering { -class Shader; +class VkContext; +class VkTexture; +class VkRenderTarget; struct WorldMapZone { uint32_t wmaID = 0; @@ -22,8 +26,8 @@ struct WorldMapZone { uint32_t parentWorldMapID = 0; uint32_t exploreFlag = 0; - // Per-zone cached textures - GLuint tileTextures[12] = {}; + // Per-zone cached textures (owned by WorldMap::zoneTextures) + VkTexture* tileTextures[12] = {}; bool tilesLoaded = false; }; @@ -32,8 +36,15 @@ public: WorldMap(); ~WorldMap(); - void initialize(pipeline::AssetManager* assetManager); + bool initialize(VkContext* ctx, pipeline::AssetManager* assetManager); + void shutdown(); + + /// Off-screen composite pass β€” call BEFORE the main render pass begins. + void compositePass(VkCommandBuffer cmd); + + /// ImGui overlay β€” call INSIDE the main render pass (during ImGui frame). void render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); + void setMapName(const std::string& name); void setServerExplorationMask(const std::vector& masks, bool hasData); bool isOpen() const { return open; } @@ -42,9 +53,6 @@ public: private: enum class ViewLevel { WORLD, CONTINENT, ZONE }; - void createFBO(); - void createTileShader(); - void createQuad(); void enterWorldView(); void loadZonesFromDBC(); int findBestContinentForPlayer(const glm::vec3& playerRenderPos) const; @@ -53,15 +61,15 @@ private: bool getContinentProjectionBounds(int contIdx, float& left, float& right, float& top, float& bottom) const; void loadZoneTextures(int zoneIdx); - void compositeZone(int zoneIdx); + void requestComposite(int zoneIdx); void renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); void updateExploration(const glm::vec3& playerRenderPos); void zoomIn(const glm::vec3& playerRenderPos); void zoomOut(); - - // World pos β†’ map UV using a specific zone's bounds glm::vec2 renderPosToMapUV(const glm::vec3& renderPos, int zoneIdx) const; + void destroyZoneTextures(); + VkContext* vkCtx = nullptr; pipeline::AssetManager* assetManager = nullptr; bool initialized = false; bool open = false; @@ -70,28 +78,45 @@ private: // All zones for current map std::vector zones; - int continentIdx = -1; // index of AreaID=0 entry in zones - int currentIdx = -1; // currently displayed zone index + int continentIdx = -1; + int currentIdx = -1; ViewLevel viewLevel = ViewLevel::CONTINENT; - int compositedIdx = -1; // which zone is currently composited in FBO + int compositedIdx = -1; + int pendingCompositeIdx = -1; - // FBO for composited map (4x3 tiles = 1024x768) + // FBO replacement (4x3 tiles = 1024x768) static constexpr int GRID_COLS = 4; static constexpr int GRID_ROWS = 3; static constexpr int TILE_PX = 256; - static constexpr int FBO_W = GRID_COLS * TILE_PX; // 1024 - static constexpr int FBO_H = GRID_ROWS * TILE_PX; // 768 + static constexpr int FBO_W = GRID_COLS * TILE_PX; + static constexpr int FBO_H = GRID_ROWS * TILE_PX; - GLuint fbo = 0; - GLuint fboTexture = 0; - std::unique_ptr tileShader; - GLuint tileQuadVAO = 0; - GLuint tileQuadVBO = 0; + std::unique_ptr compositeTarget; + + // Quad vertex buffer (pos2 + uv2) + ::VkBuffer quadVB = VK_NULL_HANDLE; + VmaAllocation quadVBAlloc = VK_NULL_HANDLE; + + // Descriptor resources + VkDescriptorSetLayout samplerSetLayout = VK_NULL_HANDLE; + VkDescriptorPool descPool = VK_NULL_HANDLE; + static constexpr uint32_t MAX_DESC_SETS = 32; + + // Tile composite pipeline + VkPipeline tilePipeline = VK_NULL_HANDLE; + VkPipelineLayout tilePipelineLayout = VK_NULL_HANDLE; + VkDescriptorSet tileDescSets[2][12] = {}; // [frameInFlight][tileSlot] + + // ImGui display descriptor set (points to composite render target) + VkDescriptorSet imguiDisplaySet = VK_NULL_HANDLE; + + // Texture storage (owns all VkTexture objects for zone tiles) + std::vector> zoneTextures; // Exploration / fog of war std::vector serverExplorationMask; bool hasServerExplorationMask = false; - std::unordered_set exploredZones; // zone indices the player has visited + std::unordered_set exploredZones; }; } // namespace rendering diff --git a/include/ui/auth_screen.hpp b/include/ui/auth_screen.hpp index 80ada9fa..484797e3 100644 --- a/include/ui/auth_screen.hpp +++ b/include/ui/auth_screen.hpp @@ -1,11 +1,13 @@ #pragma once #include "auth/auth_handler.hpp" -#include "rendering/video_player.hpp" +#include #include #include #include +namespace wowee { namespace rendering { class VkContext; } } + namespace wowee { namespace ui { /** @@ -103,9 +105,18 @@ private: void upsertCurrentServerProfile(bool includePasswordHash); std::string currentExpansionId() const; - // Background video - bool videoInitAttempted = false; - rendering::VideoPlayer backgroundVideo; + // Background image (Vulkan) + bool bgInitAttempted = false; + bool loadBackgroundImage(); + void destroyBackgroundImage(); + rendering::VkContext* bgVkCtx = nullptr; + VkImage bgImage = VK_NULL_HANDLE; + VkDeviceMemory bgMemory = VK_NULL_HANDLE; + VkImageView bgImageView = VK_NULL_HANDLE; + VkSampler bgSampler = VK_NULL_HANDLE; + VkDescriptorSet bgDescriptorSet = VK_NULL_HANDLE; + int bgWidth = 0; + int bgHeight = 0; bool musicInitAttempted = false; bool musicPlaying = false; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index b9af2763..cd0cf212 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -2,13 +2,13 @@ #include "game/game_handler.hpp" #include "game/inventory.hpp" -#include "rendering/world_map.hpp" +// WorldMap is now owned by Renderer, accessed via getWorldMap() #include "rendering/character_preview.hpp" #include "ui/inventory_screen.hpp" #include "ui/quest_log_screen.hpp" #include "ui/spellbook_screen.hpp" #include "ui/talent_screen.hpp" -#include +#include #include #include #include @@ -80,7 +80,7 @@ private: bool pendingFullscreen = false; bool pendingVsync = false; int pendingResIndex = 0; - bool pendingShadows = false; + bool pendingShadows = true; int pendingMasterVolume = 100; int pendingMusicVolume = 30; int pendingAmbientVolume = 100; @@ -94,6 +94,7 @@ private: int pendingActivityVolume = 100; float pendingMouseSensitivity = 0.2f; bool pendingInvertMouse = false; + bool pendingExtendedZoom = false; int pendingUiOpacity = 65; bool pendingMinimapRotate = false; bool pendingMinimapSquare = false; @@ -102,6 +103,11 @@ private: bool pendingAutoLoot = false; bool pendingUseOriginalSoundtrack = true; int pendingGroundClutterDensity = 100; + int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x + bool pendingNormalMapping = true; // on by default + float pendingNormalMapStrength = 0.8f; // 0.0-2.0 + bool pendingPOM = true; // on by default + int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64) // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) float uiOpacity_ = 0.65f; @@ -110,6 +116,8 @@ private: bool minimapNpcDots_ = false; bool minimapSettingsApplied_ = false; bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers + bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer + bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied // Mute state: mute bypasses master volume without touching slider values bool soundMuted_ = false; @@ -212,24 +220,24 @@ private: QuestLogScreen questLogScreen; SpellbookScreen spellbookScreen; TalentScreen talentScreen; - rendering::WorldMap worldMap; + // WorldMap is now owned by Renderer (accessed via renderer->getWorldMap()) // Spell icon cache: spellId -> GL texture ID - std::unordered_map spellIconCache_; + std::unordered_map spellIconCache_; // SpellIconID -> icon path (from SpellIcon.dbc) std::unordered_map spellIconPaths_; // SpellID -> SpellIconID (from Spell.dbc field 133) std::unordered_map spellIconIds_; bool spellIconDbLoaded_ = false; - GLuint getSpellIcon(uint32_t spellId, pipeline::AssetManager* am); + VkDescriptorSet getSpellIcon(uint32_t spellId, pipeline::AssetManager* am); // Action bar drag state (-1 = not dragging) int actionBarDragSlot_ = -1; - GLuint actionBarDragIcon_ = 0; + VkDescriptorSet actionBarDragIcon_ = VK_NULL_HANDLE; // Bag bar state - GLuint backpackIconTexture_ = 0; - GLuint emptyBagSlotTexture_ = 0; + VkDescriptorSet backpackIconTexture_ = VK_NULL_HANDLE; + VkDescriptorSet emptyBagSlotTexture_ = VK_NULL_HANDLE; int bagBarPickedSlot_ = -1; // Visual drag in progress (-1 = none) int bagBarDragSource_ = -1; // Mouse pressed on this slot, waiting for drag or click (-1 = none) diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index 237b10e0..baffe8de 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -3,7 +3,7 @@ #include "game/inventory.hpp" #include "game/character.hpp" #include "game/world_packets.hpp" -#include +#include #include #include #include @@ -93,9 +93,9 @@ private: pipeline::AssetManager* assetManager_ = nullptr; // Item icon cache: displayInfoId -> GL texture - std::unordered_map iconCache_; + std::unordered_map iconCache_; public: - GLuint getItemIcon(uint32_t displayInfoId); + VkDescriptorSet getItemIcon(uint32_t displayInfoId); private: // Character model preview diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index ae3ec39e..b77a2ece 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -1,7 +1,7 @@ #pragma once #include "game/game_handler.hpp" -#include +#include #include #include #include @@ -41,7 +41,7 @@ public: // Drag-and-drop state for action bar assignment bool isDraggingSpell() const { return draggingSpell_; } uint32_t getDragSpellId() const { return dragSpellId_; } - void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = 0; } + void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; } private: bool open = false; @@ -55,7 +55,7 @@ private: // Icon data (loaded from SpellIcon.dbc) bool iconDbLoaded = false; std::unordered_map spellIconPaths; // SpellIconID -> path - std::unordered_map spellIconCache; // SpellIconID -> GL texture + std::unordered_map spellIconCache; // SpellIconID -> GL texture // Skill line data (loaded from SkillLine.dbc + SkillLineAbility.dbc) bool skillLineDbLoaded = false; @@ -71,13 +71,13 @@ private: // Drag-and-drop from spellbook to action bar bool draggingSpell_ = false; uint32_t dragSpellId_ = 0; - GLuint dragSpellIconTex_ = 0; + VkDescriptorSet dragSpellIconTex_ = VK_NULL_HANDLE; void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); void loadSkillLineDBCs(pipeline::AssetManager* assetManager); void categorizeSpells(const std::unordered_set& knownSpells); - GLuint getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); + VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); const SpellInfo* getSpellInfo(uint32_t spellId) const; }; diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 55dd429b..792e7706 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -2,7 +2,7 @@ #include "game/game_handler.hpp" #include -#include +#include #include #include @@ -25,7 +25,7 @@ private: void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); - GLuint getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); + VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); bool open = false; bool nKeyWasDown = false; @@ -35,7 +35,7 @@ private: bool iconDbcLoaded = false; std::unordered_map spellIconIds; // spellId -> iconId std::unordered_map spellIconPaths; // iconId -> path - std::unordered_map spellIconCache; // iconId -> texture + std::unordered_map spellIconCache; // iconId -> texture std::unordered_map spellTooltips; // spellId -> description }; diff --git a/src/audio/activity_sound_manager.cpp b/src/audio/activity_sound_manager.cpp index 2c43b6c5..9ab37a2d 100644 --- a/src/audio/activity_sound_manager.cpp +++ b/src/audio/activity_sound_manager.cpp @@ -28,9 +28,8 @@ bool ActivitySoundManager::initialize(pipeline::AssetManager* assets) { assetManager = assets; if (!assetManager) return false; - rebuildJumpClipsForProfile("Human", "Human", true); - rebuildSwimLoopClipsForProfile("Human", "Human", true); - rebuildHardLandClipsForProfile("Human", "Human", true); + // Voice profile clips (jump, swim, hardLand, combat vocals) are set at + // character spawn via setCharacterVoiceProfile() with the correct race/gender. preloadCandidates(splashEnterClips, { "Sound\\Character\\Footsteps\\EnterWaterSplash\\EnterWaterSmallA.wav", @@ -162,6 +161,11 @@ void ActivitySoundManager::rebuildJumpClipsForProfile(const std::string& raceFol prefix + stem + "\\" + stem + "Jump01.wav", prefix + stem + "\\" + stem + "Jump02.wav", }); + if (jumpClips.empty()) { + LOG_WARNING("No jump clips found for ", stem, " (tried exert prefix: ", exertPrefix, ")"); + } else { + LOG_INFO("Loaded ", jumpClips.size(), " jump clips for ", stem); + } } void ActivitySoundManager::rebuildSwimLoopClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) { @@ -393,6 +397,24 @@ void ActivitySoundManager::setCharacterVoiceProfile(const std::string& modelName " death clips=", deathClips.size()); } +void ActivitySoundManager::setCharacterVoiceProfile(const std::string& raceFolder, const std::string& raceBase, bool male) { + if (!assetManager) return; + std::string key = raceFolder + "|" + raceBase + "|" + (male ? "M" : "F"); + if (key == voiceProfileKey) return; + voiceProfileKey = key; + rebuildJumpClipsForProfile(raceFolder, raceBase, male); + rebuildSwimLoopClipsForProfile(raceFolder, raceBase, male); + rebuildHardLandClipsForProfile(raceFolder, raceBase, male); + rebuildCombatVocalClipsForProfile(raceFolder, raceBase, male); + core::Logger::getInstance().info("Activity SFX voice profile (explicit): ", voiceProfileKey, + " jump clips=", jumpClips.size(), + " swim clips=", swimLoopClips.size(), + " hardLand clips=", hardLandClips.size(), + " attackGrunt clips=", attackGruntClips.size(), + " wound clips=", woundClips.size(), + " death clips=", deathClips.size()); +} + void ActivitySoundManager::playWaterEnter() { LOG_INFO("Water entry detected - attempting to play splash sound"); auto now = std::chrono::steady_clock::now(); diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp index 92065709..b08d10eb 100644 --- a/src/audio/audio_engine.cpp +++ b/src/audio/audio_engine.cpp @@ -273,7 +273,7 @@ bool AudioEngine::playSound2D(const std::vector& wavData, float volume, } // Set volume (pitch not supported with NO_PITCH flag) - ma_sound_set_volume(sound, volume * masterVolume_); + ma_sound_set_volume(sound, volume); // Start playback result = ma_sound_start(sound); @@ -361,7 +361,7 @@ bool AudioEngine::playSound3D(const std::vector& wavData, const glm::ve // Set 3D position and attenuation ma_sound_set_position(sound, position.x, position.y, position.z); - ma_sound_set_volume(sound, volume * masterVolume_); + ma_sound_set_volume(sound, volume); ma_sound_set_pitch(sound, pitch); // Enable pitch variation ma_sound_set_attenuation_model(sound, ma_attenuation_model_inverse); ma_sound_set_min_gain(sound, 0.0f); @@ -462,7 +462,7 @@ bool AudioEngine::playMusic(const std::vector& musicData, float volume, } // Set volume and looping - ma_sound_set_volume(musicSound_, volume * masterVolume_); + ma_sound_set_volume(musicSound_, volume); ma_sound_set_looping(musicSound_, loop ? MA_TRUE : MA_FALSE); // Start playback @@ -510,7 +510,7 @@ bool AudioEngine::isMusicPlaying() const { void AudioEngine::setMusicVolume(float volume) { musicVolume_ = glm::clamp(volume, 0.0f, 1.0f); if (musicSound_) { - ma_sound_set_volume(musicSound_, musicVolume_ * masterVolume_); + ma_sound_set_volume(musicSound_, musicVolume_); } } diff --git a/src/auth/crypto.cpp b/src/auth/crypto.cpp index 91690ecf..b4508393 100644 --- a/src/auth/crypto.cpp +++ b/src/auth/crypto.cpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace wowee { namespace auth { @@ -19,7 +20,8 @@ std::vector Crypto::sha1(const std::string& data) { std::vector Crypto::md5(const std::vector& data) { std::vector hash(MD5_DIGEST_LENGTH); - MD5(data.data(), data.size(), hash.data()); + unsigned int length = 0; + EVP_Digest(data.data(), data.size(), hash.data(), &length, EVP_md5(), nullptr); return hash; } diff --git a/src/core/application.cpp b/src/core/application.cpp index ec1e39fa..e5ca6e54 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -44,7 +44,7 @@ #include "pipeline/dbc_layout.hpp" #include -#include +// GL/glew.h removed β€” Vulkan migration Phase 1 #include #include #include @@ -58,6 +58,15 @@ namespace wowee { namespace core { +namespace { +bool envFlagEnabled(const char* key, bool defaultValue = false) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || + raw[0] == 'n' || raw[0] == 'N'); +} +} // namespace + const char* Application::mapIdToName(uint32_t mapId) { switch (mapId) { @@ -221,6 +230,10 @@ bool Application::initialize() { void Application::run() { LOG_INFO("Starting main loop"); + const bool frameProfileEnabled = envFlagEnabled("WOWEE_FRAME_PROFILE", false); + if (frameProfileEnabled) { + LOG_INFO("Frame timing profile enabled (WOWEE_FRAME_PROFILE=1)"); + } auto lastTime = std::chrono::high_resolution_clock::now(); @@ -266,7 +279,7 @@ void Application::run() { int newWidth = event.window.data1; int newHeight = event.window.data2; window->setSize(newWidth, newHeight); - glViewport(0, 0, newWidth, newHeight); + // Vulkan viewport set in command buffer, not globally if (renderer && renderer->getCamera()) { renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); } @@ -314,32 +327,37 @@ void Application::run() { // Update input Input::getInstance().update(); - // Timing breakdown - static int frameCount = 0; - static double totalUpdateMs = 0, totalRenderMs = 0, totalSwapMs = 0; - auto t1 = std::chrono::steady_clock::now(); - // Update application state - update(deltaTime); - auto t2 = std::chrono::steady_clock::now(); - + try { + update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } // Render - render(); - auto t3 = std::chrono::steady_clock::now(); - + try { + render(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } // Swap buffers - window->swapBuffers(); - auto t4 = std::chrono::steady_clock::now(); - - totalUpdateMs += std::chrono::duration(t2 - t1).count(); - totalRenderMs += std::chrono::duration(t3 - t2).count(); - totalSwapMs += std::chrono::duration(t4 - t3).count(); - - if (++frameCount >= 60) { - printf("[Frame] Update: %.1f ms, Render: %.1f ms, Swap: %.1f ms\n", - totalUpdateMs / 60.0, totalRenderMs / 60.0, totalSwapMs / 60.0); - frameCount = 0; - totalUpdateMs = totalRenderMs = totalSwapMs = 0; + try { + window->swapBuffers(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during swapBuffers: ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during swapBuffers: ", e.what()); + throw; } } @@ -358,9 +376,12 @@ void Application::shutdown() { } } - // Stop renderer first: terrain streaming workers may still be reading via - // AssetManager during shutdown, so renderer/terrain teardown must complete - // before AssetManager is destroyed. + // Explicitly shut down the renderer before destroying it β€” this ensures + // all sub-renderers free their VMA allocations in the correct order, + // before VkContext::shutdown() calls vmaDestroyAllocator(). + if (renderer) { + renderer->shutdown(); + } renderer.reset(); world.reset(); @@ -529,21 +550,27 @@ void Application::logoutToLogin() { } void Application::update(float deltaTime) { + const char* updateCheckpoint = "enter"; + try { // Update based on current state + updateCheckpoint = "state switch"; switch (state) { case AppState::AUTHENTICATION: + updateCheckpoint = "auth: enter"; if (authHandler) { authHandler->update(deltaTime); } break; case AppState::REALM_SELECTION: + updateCheckpoint = "realm_selection: enter"; if (authHandler) { authHandler->update(deltaTime); } break; case AppState::CHARACTER_CREATION: + updateCheckpoint = "char_creation: enter"; if (gameHandler) { gameHandler->update(deltaTime); } @@ -553,26 +580,37 @@ void Application::update(float deltaTime) { break; case AppState::CHARACTER_SELECTION: + updateCheckpoint = "char_selection: enter"; if (gameHandler) { gameHandler->update(deltaTime); } break; case AppState::IN_GAME: { - // Application update profiling - static int appProfileCounter = 0; - static float ghTime = 0.0f, worldTime = 0.0f, spawnTime = 0.0f; - static float creatureQTime = 0.0f, goQTime = 0.0f, mountTime = 0.0f; - static float npcMgrTime = 0.0f, questMarkTime = 0.0f, syncTime = 0.0f; - - auto gh1 = std::chrono::high_resolution_clock::now(); - if (gameHandler) { - gameHandler->update(deltaTime); - } - auto gh2 = std::chrono::high_resolution_clock::now(); - ghTime += std::chrono::duration(gh2 - gh1).count(); - + updateCheckpoint = "in_game: enter"; + const char* inGameStep = "begin"; + try { + auto runInGameStage = [&](const char* stageName, auto&& fn) { + try { + fn(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during IN_GAME update stage '", stageName, "': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during IN_GAME update stage '", stageName, "': ", e.what()); + throw; + } + }; + inGameStep = "gameHandler update"; + updateCheckpoint = "in_game: gameHandler update"; + runInGameStage("gameHandler->update", [&] { + if (gameHandler) { + gameHandler->update(deltaTime); + } + }); // Always unsheath on combat engage. + inGameStep = "auto-unsheathe"; + updateCheckpoint = "in_game: auto-unsheathe"; if (gameHandler) { const bool autoAttacking = gameHandler->isAutoAttacking(); if (autoAttacking && !wasAutoAttacking_ && weaponsSheathed_) { @@ -583,6 +621,8 @@ void Application::update(float deltaTime) { } // Toggle weapon sheathe state with Z (ignored while UI captures keyboard). + inGameStep = "weapon-toggle input"; + updateCheckpoint = "in_game: weapon-toggle input"; { const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard; auto& input = Input::getInstance(); @@ -592,24 +632,26 @@ void Application::update(float deltaTime) { } } - auto w1 = std::chrono::high_resolution_clock::now(); - if (world) { - world->update(deltaTime); - } - auto w2 = std::chrono::high_resolution_clock::now(); - worldTime += std::chrono::duration(w2 - w1).count(); - - auto cq1 = std::chrono::high_resolution_clock::now(); - processPlayerSpawnQueue(); - // Process deferred online creature spawns (throttled) - processCreatureSpawnQueue(); - // Process deferred equipment compositing (max 1 per frame to avoid stutter) - processDeferredEquipmentQueue(); - auto cq2 = std::chrono::high_resolution_clock::now(); - creatureQTime += std::chrono::duration(cq2 - cq1).count(); - + inGameStep = "world update"; + updateCheckpoint = "in_game: world update"; + runInGameStage("world->update", [&] { + if (world) { + world->update(deltaTime); + } + }); + inGameStep = "spawn/equipment queues"; + updateCheckpoint = "in_game: spawn/equipment queues"; + runInGameStage("spawn/equipment queues", [&] { + processPlayerSpawnQueue(); + // Process deferred online creature spawns (throttled) + processCreatureSpawnQueue(); + // Process deferred equipment compositing (max 1 per frame to avoid stutter) + processDeferredEquipmentQueue(); + }); // Self-heal missing creature visuals: if a nearby UNIT exists in // entity state but has no render instance, queue a spawn retry. + inGameStep = "creature resync scan"; + updateCheckpoint = "in_game: creature resync scan"; if (gameHandler) { static float creatureResyncTimer = 0.0f; creatureResyncTimer += deltaTime; @@ -654,43 +696,40 @@ void Application::update(float deltaTime) { } } - auto goq1 = std::chrono::high_resolution_clock::now(); - processGameObjectSpawnQueue(); - processPendingTransportDoodads(); - auto goq2 = std::chrono::high_resolution_clock::now(); - goQTime += std::chrono::duration(goq2 - goq1).count(); - - auto m1 = std::chrono::high_resolution_clock::now(); - processPendingMount(); - auto m2 = std::chrono::high_resolution_clock::now(); - mountTime += std::chrono::duration(m2 - m1).count(); - - auto nm1 = std::chrono::high_resolution_clock::now(); - auto nm2 = std::chrono::high_resolution_clock::now(); - npcMgrTime += std::chrono::duration(nm2 - nm1).count(); - - auto qm1 = std::chrono::high_resolution_clock::now(); + inGameStep = "gameobject/transport queues"; + updateCheckpoint = "in_game: gameobject/transport queues"; + runInGameStage("gameobject/transport queues", [&] { + processGameObjectSpawnQueue(); + processPendingTransportDoodads(); + }); + inGameStep = "pending mount"; + updateCheckpoint = "in_game: pending mount"; + runInGameStage("processPendingMount", [&] { + processPendingMount(); + }); // Update 3D quest markers above NPCs - updateQuestMarkers(); - auto qm2 = std::chrono::high_resolution_clock::now(); - questMarkTime += std::chrono::duration(qm2 - qm1).count(); - - auto sync1 = std::chrono::high_resolution_clock::now(); - + inGameStep = "quest markers"; + updateCheckpoint = "in_game: quest markers"; + runInGameStage("updateQuestMarkers", [&] { + updateQuestMarkers(); + }); // Sync server run speed to camera controller - if (renderer && gameHandler && renderer->getCameraController()) { - renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); - } + inGameStep = "post-update sync"; + updateCheckpoint = "in_game: post-update sync"; + runInGameStage("post-update sync", [&] { + if (renderer && gameHandler && renderer->getCameraController()) { + renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); + } - bool onTaxi = gameHandler && - (gameHandler->isOnTaxiFlight() || - gameHandler->isTaxiMountActive() || - gameHandler->isTaxiActivationPending()); - bool onTransportNow = gameHandler && gameHandler->isOnTransport(); - if (worldEntryMovementGraceTimer_ > 0.0f) { - worldEntryMovementGraceTimer_ -= deltaTime; - } - if (renderer && renderer->getCameraController()) { + bool onTaxi = gameHandler && + (gameHandler->isOnTaxiFlight() || + gameHandler->isTaxiMountActive() || + gameHandler->isTaxiActivationPending()); + bool onTransportNow = gameHandler && gameHandler->isOnTransport(); + if (worldEntryMovementGraceTimer_ > 0.0f) { + worldEntryMovementGraceTimer_ -= deltaTime; + } + if (renderer && renderer->getCameraController()) { const bool externallyDrivenMotion = onTaxi || onTransportNow || chargeActive_; // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet β€” prevents gravity from pulling player through void. @@ -755,22 +794,22 @@ void Application::update(float deltaTime) { } else if (!idleOrbit) { idleYawned_ = false; } - } - if (renderer) { - renderer->setTaxiFlight(onTaxi); - } - if (renderer && renderer->getTerrainManager()) { + } + if (renderer) { + renderer->setTaxiFlight(onTaxi); + } + if (renderer && renderer->getTerrainManager()) { renderer->getTerrainManager()->setStreamingEnabled(true); // Keep taxi streaming responsive so flight paths don't outrun terrain/model uploads. renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.1f : 0.1f); renderer->getTerrainManager()->setLoadRadius(onTaxi ? 3 : 4); renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 6 : 7); renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi); - } - lastTaxiFlight_ = onTaxi; + } + lastTaxiFlight_ = onTaxi; - // Sync character render position ↔ canonical WoW coords each frame - if (renderer && gameHandler) { + // Sync character render position ↔ canonical WoW coords each frame + if (renderer && gameHandler) { bool onTransport = gameHandler->isOnTransport(); // Debug: Log transport state changes @@ -918,11 +957,14 @@ void Application::update(float deltaTime) { } } } - } + } + }); // Keep creature render instances aligned with authoritative entity positions. // This prevents desync where target circles move with server entities but // creature models remain at stale spawn positions. + inGameStep = "creature render sync"; + updateCheckpoint = "in_game: creature render sync"; if (renderer && gameHandler && renderer->getCharacterRenderer()) { auto* charRenderer = renderer->getCharacterRenderer(); static float npcWeaponRetryTimer = 0.0f; @@ -1052,20 +1094,12 @@ void Application::update(float deltaTime) { // Movement heartbeat is sent from GameHandler::update() to avoid // duplicate packets from multiple update loops. - auto sync2 = std::chrono::high_resolution_clock::now(); - syncTime += std::chrono::duration(sync2 - sync1).count(); - - // Log profiling every 60 frames - if (++appProfileCounter >= 60) { - LOG_DEBUG("APP UPDATE PROFILE (60 frames): gameHandler=", ghTime / 60.0f, - "ms world=", worldTime / 60.0f, "ms spawn=", spawnTime / 60.0f, - "ms creatureQ=", creatureQTime / 60.0f, "ms goQ=", goQTime / 60.0f, - "ms mount=", mountTime / 60.0f, "ms npcMgr=", npcMgrTime / 60.0f, - "ms questMark=", questMarkTime / 60.0f, "ms sync=", syncTime / 60.0f, "ms"); - appProfileCounter = 0; - ghTime = worldTime = spawnTime = 0.0f; - creatureQTime = goQTime = mountTime = 0.0f; - npcMgrTime = questMarkTime = syncTime = 0.0f; + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM inside AppState::IN_GAME at step '", inGameStep, "': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception inside AppState::IN_GAME at step '", inGameStep, "': ", e.what()); + throw; } break; } @@ -1076,29 +1110,37 @@ void Application::update(float deltaTime) { } // Update renderer (camera, etc.) only when in-game - static int rendererProfileCounter = 0; - static float rendererTime = 0.0f, uiTime = 0.0f; - - auto r1 = std::chrono::high_resolution_clock::now(); + updateCheckpoint = "renderer update"; if (renderer && state == AppState::IN_GAME) { - renderer->update(deltaTime); + try { + renderer->update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update stage 'renderer->update': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update stage 'renderer->update': ", e.what()); + throw; + } } - auto r2 = std::chrono::high_resolution_clock::now(); - rendererTime += std::chrono::duration(r2 - r1).count(); - // Update UI - auto u1 = std::chrono::high_resolution_clock::now(); + updateCheckpoint = "ui update"; if (uiManager) { - uiManager->update(deltaTime); + try { + uiManager->update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update stage 'uiManager->update': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update stage 'uiManager->update': ", e.what()); + throw; + } } - auto u2 = std::chrono::high_resolution_clock::now(); - uiTime += std::chrono::duration(u2 - u1).count(); - - if (state == AppState::IN_GAME && ++rendererProfileCounter >= 60) { - LOG_DEBUG("RENDERER/UI PROFILE (60 frames): renderer=", rendererTime / 60.0f, - "ms ui=", uiTime / 60.0f, "ms"); - rendererProfileCounter = 0; - rendererTime = uiTime = 0.0f; + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM in Application::update checkpoint '", updateCheckpoint, "': ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception in Application::update checkpoint '", updateCheckpoint, "': ", e.what()); + throw; } } @@ -1506,6 +1548,18 @@ void Application::setupUICallbacks() { despawnOnlineGameObject(guid); }); + // GameObject custom animation callback (e.g. chest opening) + gameHandler->setGameObjectCustomAnimCallback([this](uint64_t guid, uint32_t /*animId*/) { + auto it = gameObjectInstances_.find(guid); + if (it == gameObjectInstances_.end() || !renderer) return; + auto& info = it->second; + if (!info.isWmo) { + if (auto* m2r = renderer->getM2Renderer()) { + m2r->setInstanceAnimationFrozen(info.instanceId, false); + } + } + }); + // Charge callback β€” warrior rushes toward target gameHandler->setChargeCallback([this](uint64_t targetGuid, float tx, float ty, float tz) { if (!renderer || !renderer->getCameraController() || !gameHandler) return; @@ -1722,7 +1776,6 @@ void Application::setupUICallbacks() { if (auto* mr = renderer->getM2Renderer()) { glm::mat4 transform(1.0f); transform = glm::translate(transform, renderPos); - transform = glm::rotate(transform, orientation - glm::radians(90.0f), glm::vec3(0, 0, 1)); mr->setInstanceTransform(info.instanceId, transform); } } @@ -2337,7 +2390,7 @@ void Application::spawnPlayerCharacter() { layers.push_back(up); } if (layers.size() > 1) { - GLuint compositeTex = charRenderer->compositeTextures(layers); + rendering::VkTexture* compositeTex = charRenderer->compositeTextures(layers); if (compositeTex != 0) { for (size_t ti = 0; ti < model.textures.size(); ti++) { if (model.textures[ti].type == 1) { @@ -2352,8 +2405,8 @@ void Application::spawnPlayerCharacter() { } // Override hair texture on GPU (type-6 slot) after model load if (!hairTexturePath.empty()) { - GLuint hairTex = charRenderer->loadTexture(hairTexturePath); - if (hairTex != 0) { + rendering::VkTexture* hairTex = charRenderer->loadTexture(hairTexturePath); + if (hairTex) { for (size_t ti = 0; ti < model.textures.size(); ti++) { if (model.textures[ti].type == 6) { charRenderer->setModelTexture(1, static_cast(ti), hairTex); @@ -2492,6 +2545,32 @@ void Application::spawnPlayerCharacter() { static_cast(spawnPos.z), ")"); playerCharacterSpawned = true; + // Set voice profile to match character race/gender + if (auto* asm_ = renderer->getActivitySoundManager()) { + const char* raceFolder = "Human"; + const char* raceBase = "Human"; + switch (playerRace_) { + case game::Race::HUMAN: raceFolder = "Human"; raceBase = "Human"; break; + case game::Race::ORC: raceFolder = "Orc"; raceBase = "Orc"; break; + case game::Race::DWARF: raceFolder = "Dwarf"; raceBase = "Dwarf"; break; + case game::Race::NIGHT_ELF: raceFolder = "NightElf"; raceBase = "NightElf"; break; + case game::Race::UNDEAD: raceFolder = "Scourge"; raceBase = "Scourge"; break; + case game::Race::TAUREN: raceFolder = "Tauren"; raceBase = "Tauren"; break; + case game::Race::GNOME: raceFolder = "Gnome"; raceBase = "Gnome"; break; + case game::Race::TROLL: raceFolder = "Troll"; raceBase = "Troll"; break; + case game::Race::BLOOD_ELF: raceFolder = "BloodElf"; raceBase = "BloodElf"; break; + case game::Race::DRAENEI: raceFolder = "Draenei"; raceBase = "Draenei"; break; + default: break; + } + bool useFemaleVoice = (playerGender_ == game::Gender::FEMALE); + if (playerGender_ == game::Gender::NONBINARY && gameHandler) { + if (const game::Character* ch = gameHandler->getActiveCharacter()) { + useFemaleVoice = ch->useFemaleModel; + } + } + asm_->setCharacterVoiceProfile(std::string(raceFolder), std::string(raceBase), !useFemaleVoice); + } + // Track which character's appearance this instance represents so we can // respawn if the user logs into a different character without restarting. spawnedPlayerGuid_ = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; @@ -2929,6 +3008,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // --- Loading screen for online mode --- rendering::LoadingScreen loadingScreen; + loadingScreen.setVkContext(window->getVkContext()); bool loadingScreenOk = loadingScreen.initialize(); auto showProgress = [&](const char* msg, float progress) { @@ -2944,7 +3024,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float int w = event.window.data1; int h = event.window.data2; window->setSize(w, h); - glViewport(0, 0, w, h); + // Vulkan viewport set in command buffer if (renderer && renderer->getCamera()) { renderer->getCamera()->setAspectRatio(static_cast(w) / h); } @@ -3099,6 +3179,13 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float LOG_INFO("Online world terrain loading initiated"); } + // Character renderer is created inside loadTestTerrain(), so spawn the + // player model now that the renderer actually exists. + if (!playerCharacterSpawned) { + spawnPlayerCharacter(); + loadEquippedWeapons(); + } + showProgress("Streaming terrain tiles...", 0.35f); // Wait for surrounding terrain tiles to stream in @@ -3131,7 +3218,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float int w = event.window.data1; int h = event.window.data2; window->setSize(w, h); - glViewport(0, 0, w, h); + // Vulkan viewport set in command buffer if (renderer->getCamera()) { renderer->getCamera()->setAspectRatio(static_cast(w) / h); } @@ -3246,7 +3333,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float int w = event.window.data1; int h = event.window.data2; window->setSize(w, h); - glViewport(0, 0, w, h); + // Vulkan viewport set in command buffer if (renderer && renderer->getCamera()) { renderer->getCamera()->setAspectRatio(static_cast(w) / h); } @@ -3686,10 +3773,14 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } // Load skin file (only for WotLK M2s - vanilla has embedded skin) - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - auto skinData = assetManager->readFile(skinPath); - if (!skinData.empty() && model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, model); + if (model.version >= 264) { + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + auto skinData = assetManager->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, model); + } else { + LOG_WARNING("Missing skin file for WotLK creature M2: ", skinPath); + } } // Load external .anim files for sequences without flag 0x20 @@ -3933,7 +4024,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x std::string bakePath = "Textures\\BakedNpcTextures\\" + extra.bakeName; // Composite equipment textures over baked NPC texture, or just load baked texture - GLuint finalTex = 0; + rendering::VkTexture* finalTex = nullptr; if (allowNpcRegionComposite && !npcRegionLayers.empty()) { finalTex = charRenderer->compositeWithRegions(bakePath, {}, npcRegionLayers); LOG_DEBUG("Composited NPC baked texture with ", npcRegionLayers.size(), @@ -3942,7 +4033,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x finalTex = charRenderer->loadTexture(bakePath); } - if (finalTex != 0 && modelData) { + if (finalTex && modelData) { for (size_t ti = 0; ti < modelData->textures.size(); ti++) { uint32_t texType = modelData->textures[ti].type; // Humanoid NPCs typically use creature-skin texture types (11-13). @@ -4008,7 +4099,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (!npcFaceUpper.empty()) skinLayers.push_back(npcFaceUpper); for (const auto& uw : npcUnderwear) skinLayers.push_back(uw); - GLuint npcSkinTex = 0; + rendering::VkTexture* npcSkinTex = nullptr; if (allowNpcRegionComposite && !npcRegionLayers.empty()) { npcSkinTex = charRenderer->compositeWithRegions(npcSkinPath, std::vector(skinLayers.begin() + 1, skinLayers.end()), @@ -4019,7 +4110,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x npcSkinTex = charRenderer->loadTexture(npcSkinPath); } - if (npcSkinTex != 0 && modelData) { + if (npcSkinTex && modelData) { for (size_t ti = 0; ti < modelData->textures.size(); ti++) { uint32_t texType = modelData->textures[ti].type; if (texType == 1 || texType == 11 || texType == 12 || texType == 13) { @@ -4058,8 +4149,8 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } if (!hairTexPath.empty()) { - GLuint hairTex = charRenderer->loadTexture(hairTexPath); - if (hairTex != 0 && modelData) { + rendering::VkTexture* hairTex = charRenderer->loadTexture(hairTexPath); + if (hairTex && modelData) { for (size_t ti = 0; ti < modelData->textures.size(); ti++) { if (modelData->textures[ti].type == 6) { charRenderer->setModelTexture(modelId, static_cast(ti), hairTex); @@ -4136,8 +4227,8 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } if (!skinPath.empty()) { - GLuint skinTex = charRenderer->loadTexture(skinPath); - if (skinTex != 0) { + rendering::VkTexture* skinTex = charRenderer->loadTexture(skinPath); + if (skinTex) { charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); LOG_DEBUG("Applied creature skin texture: ", skinPath, " to slot ", ti); } @@ -4362,7 +4453,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x uint16_t geosetPants = pickGeoset(1301, 13); // Bare legs (group 13) uint16_t geosetCape = 0; // Group 15 disabled unless cape is equipped uint16_t geosetTabard = 0; // TODO: NPC tabard geosets currently flicker/apron; keep hidden for now - GLuint npcCapeTextureId = 0; + rendering::VkTexture* npcCapeTextureId = nullptr; // Load equipment geosets from ItemDisplayInfo.dbc // DBC columns: 7=GeosetGroup[0], 8=GeosetGroup[1], 9=GeosetGroup[2] @@ -4467,10 +4558,10 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x addCapeCandidate(baseTex + "_U.blp"); } } - const GLuint whiteTex = charRenderer->loadTexture(""); + const rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); for (const auto& candidate : capeCandidates) { - GLuint tex = charRenderer->loadTexture(candidate); - if (tex != 0 && tex != whiteTex) { + rendering::VkTexture* tex = charRenderer->loadTexture(candidate); + if (tex && tex != whiteTex) { npcCapeTextureId = tex; break; } @@ -4531,7 +4622,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]"); charRenderer->setActiveGeosets(instanceId, activeGeosets); - if (geosetCape != 0 && npcCapeTextureId != 0) { + if (geosetCape != 0 && npcCapeTextureId) { charRenderer->setGroupTextureOverride(instanceId, 15, npcCapeTextureId); if (const auto* md = charRenderer->getModelData(modelId)) { for (size_t ti = 0; ti < md->textures.size(); ti++) { @@ -5168,7 +5259,7 @@ void Application::spawnOnlinePlayer(uint64_t guid, } // Composite base skin + face + underwear overlays - GLuint compositeTex = 0; + rendering::VkTexture* compositeTex = nullptr; { std::vector layers; layers.push_back(bodySkinPath); @@ -5182,22 +5273,22 @@ void Application::spawnOnlinePlayer(uint64_t guid, } } - GLuint hairTex = 0; + rendering::VkTexture* hairTex = nullptr; if (!hairTexturePath.empty()) { hairTex = charRenderer->loadTexture(hairTexturePath); } - GLuint underwearTex = 0; + rendering::VkTexture* underwearTex = nullptr; if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]); else underwearTex = charRenderer->loadTexture(pelvisPath); const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId]; - if (slots.skin >= 0 && compositeTex != 0) { + if (slots.skin >= 0 && compositeTex) { charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.skin), compositeTex); } - if (slots.hair >= 0 && hairTex != 0) { + if (slots.hair >= 0 && hairTex) { charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.hair), hairTex); } - if (slots.underwear >= 0 && underwearTex != 0) { + if (slots.underwear >= 0 && underwearTex) { charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.underwear), underwearTex); } @@ -5409,8 +5500,8 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, const PlayerTextureSlots& slots = slotsIt->second; if (slots.skin < 0) return; - GLuint newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); - if (newTex != 0) { + rendering::VkTexture* newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); + if (newTex) { charRenderer->setTextureSlotOverride(st.instanceId, static_cast(slots.skin), newTex); } } @@ -5451,9 +5542,6 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t if (auto* mr = renderer->getM2Renderer()) { glm::mat4 transform(1.0f); transform = glm::translate(transform, renderPos); - // M2 gameobjects use model-forward alignment like character M2s. - // Apply -90deg in render space to match world-facing orientation. - transform = glm::rotate(transform, orientation - glm::radians(90.0f), glm::vec3(0, 0, 1)); mr->setInstanceTransform(info.instanceId, transform); } } @@ -5508,7 +5596,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); const float renderYawWmo = orientation; - const float renderYawM2 = orientation - glm::radians(90.0f); + const float renderYawM2go = orientation + glm::radians(180.0f); bool loadedAsWmo = false; if (isWmo) { @@ -5674,12 +5762,15 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawM2), 1.0f); + glm::vec3(0.0f, 0.0f, renderYawM2go), 1.0f); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); return; } + // Freeze animation β€” gameobjects are static until interacted with + m2Renderer->setInstanceAnimationFrozen(instanceId, true); + gameObjectInstances_[guid] = {modelId, instanceId, false}; } @@ -5905,10 +5996,14 @@ void Application::processPendingMount() { } // Load skin file (only for WotLK M2s - vanilla has embedded skin) - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - auto skinData = assetManager->readFile(skinPath); - if (!skinData.empty() && model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, model); + if (model.version >= 264) { + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + auto skinData = assetManager->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, model); + } else { + LOG_WARNING("Missing skin file for WotLK mount M2: ", skinPath); + } } // Load external .anim files (only idle + run needed for mounts) @@ -6038,8 +6133,8 @@ void Application::processPendingMount() { texPath = modelDir + dispData.skin3 + ".blp"; } if (!texPath.empty()) { - GLuint skinTex = charRenderer->loadTexture(texPath); - if (skinTex != 0) { + rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); + if (skinTex) { charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); LOG_INFO(" Applied skin texture slot ", ti, ": ", texPath); replaced++; @@ -6062,8 +6157,8 @@ void Application::processPendingMount() { texPath = modelDir + dispData.skin2 + ".blp"; } if (!texPath.empty()) { - GLuint skinTex = charRenderer->loadTexture(texPath); - if (skinTex != 0) { + rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); + if (skinTex) { charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); LOG_INFO(" Forced skin on empty hardcoded slot ", ti, ": ", texPath); replaced++; @@ -6077,8 +6172,8 @@ void Application::processPendingMount() { if (replaced == 0) { for (size_t ti = 0; ti < md->textures.size(); ti++) { if (!md->textures[ti].filename.empty()) { - GLuint texId = charRenderer->loadTexture(md->textures[ti].filename); - if (texId != 0) { + rendering::VkTexture* texId = charRenderer->loadTexture(md->textures[ti].filename); + if (texId) { charRenderer->setModelTexture(modelId, static_cast(ti), texId); LOG_INFO(" Used model embedded texture slot ", ti, ": ", md->textures[ti].filename); replaced++; @@ -6100,8 +6195,8 @@ void Application::processPendingMount() { nullptr }; for (const char** p = gryphonSkins; *p; ++p) { - GLuint texId = charRenderer->loadTexture(*p); - if (texId != 0) { + rendering::VkTexture* texId = charRenderer->loadTexture(*p); + if (texId) { charRenderer->setModelTexture(modelId, 0, texId); LOG_INFO(" Forced gryphon skin fallback: ", *p); replaced++; @@ -6115,8 +6210,8 @@ void Application::processPendingMount() { nullptr }; for (const char** p = wyvernSkins; *p; ++p) { - GLuint texId = charRenderer->loadTexture(*p); - if (texId != 0) { + rendering::VkTexture* texId = charRenderer->loadTexture(*p); + if (texId) { charRenderer->setModelTexture(modelId, 0, texId); LOG_INFO(" Forced wyvern skin fallback: ", *p); replaced++; @@ -6272,11 +6367,6 @@ void Application::updateQuestMarkers() { const auto& questStatuses = gameHandler->getNpcQuestStatuses(); - static int logCounter = 0; - if (++logCounter % 300 == 0) { // Log every ~10 seconds at 30fps - LOG_DEBUG("Quest markers: ", questStatuses.size(), " NPCs with quest status"); - } - // Clear all markers (we'll re-add active ones) questMarkerRenderer->clear(); diff --git a/src/core/logger.cpp b/src/core/logger.cpp index ddeed740..498dd219 100644 --- a/src/core/logger.cpp +++ b/src/core/logger.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace wowee { namespace core { @@ -15,13 +16,26 @@ Logger& Logger::getInstance() { void Logger::ensureFile() { if (fileReady) return; fileReady = true; + if (const char* logStdout = std::getenv("WOWEE_LOG_STDOUT")) { + if (logStdout[0] == '0') { + echoToStdout_ = false; + } + } + if (const char* flushMs = std::getenv("WOWEE_LOG_FLUSH_MS")) { + char* end = nullptr; + unsigned long parsed = std::strtoul(flushMs, &end, 10); + if (end != flushMs && parsed <= 10000ul) { + flushIntervalMs_ = static_cast(parsed); + } + } std::error_code ec; std::filesystem::create_directories("logs", ec); fileStream.open("logs/wowee.log", std::ios::out | std::ios::trunc); + lastFlushTime_ = std::chrono::steady_clock::now(); } void Logger::log(LogLevel level, const std::string& message) { - if (level < minLevel) { + if (!shouldLog(level)) { return; } @@ -58,15 +72,32 @@ void Logger::log(LogLevel level, const std::string& message) { line << "] " << message; - std::cout << line.str() << '\n'; + if (echoToStdout_) { + std::cout << line.str() << '\n'; + } if (fileStream.is_open()) { fileStream << line.str() << '\n'; - fileStream.flush(); + bool shouldFlush = (level >= LogLevel::WARNING); + if (!shouldFlush) { + auto nowSteady = std::chrono::steady_clock::now(); + auto elapsedMs = std::chrono::duration_cast(nowSteady - lastFlushTime_).count(); + shouldFlush = (elapsedMs >= static_cast(flushIntervalMs_)); + if (shouldFlush) { + lastFlushTime_ = nowSteady; + } + } + if (shouldFlush) { + fileStream.flush(); + } } } void Logger::setLogLevel(LogLevel level) { - minLevel = level; + minLevel_.store(static_cast(level), std::memory_order_relaxed); +} + +bool Logger::shouldLog(LogLevel level) const { + return static_cast(level) >= minLevel_.load(std::memory_order_relaxed); } } // namespace core diff --git a/src/core/window.cpp b/src/core/window.cpp index f540b2e1..f533689b 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -1,6 +1,10 @@ #include "core/window.hpp" #include "core/logger.hpp" -#include +#include "rendering/vk_context.hpp" +#include +#ifdef _WIN32 +#include +#endif namespace wowee { namespace core { @@ -28,18 +32,35 @@ bool Window::initialize() { return false; } - // Set OpenGL attributes - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); - SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); - SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); - SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24); - SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8); - SDL_GL_SetAttribute(SDL_GL_MULTISAMPLEBUFFERS, 1); - SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES, 4); + // Explicitly load the Vulkan library before creating the window. + // SDL_CreateWindow with SDL_WINDOW_VULKAN fails on some platforms/drivers + // if the Vulkan loader hasn't been located yet; calling this first gives a + // clear error and avoids the misleading "not configured in SDL" message. + // SDL 2.28+ uses LoadLibraryExW(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS) which does + // not search System32, so fall back to the explicit path on Windows if needed. + bool vulkanLoaded = (SDL_Vulkan_LoadLibrary(nullptr) == 0); +#ifdef _WIN32 + if (!vulkanLoaded) { + const char* sysRoot = std::getenv("SystemRoot"); + if (sysRoot && *sysRoot) { + std::string fallbackPath = std::string(sysRoot) + "\\System32\\vulkan-1.dll"; + vulkanLoaded = (SDL_Vulkan_LoadLibrary(fallbackPath.c_str()) == 0); + if (vulkanLoaded) { + LOG_INFO("Loaded Vulkan library via explicit path: ", fallbackPath); + } + } + } +#endif + if (!vulkanLoaded) { + LOG_ERROR("Failed to load Vulkan library: ", SDL_GetError()); + LOG_ERROR("Ensure the Vulkan runtime (vulkan-1.dll) is installed. " + "Install the latest GPU drivers or the Vulkan Runtime from https://vulkan.lunarg.com/"); + SDL_Quit(); + return false; + } - // Create window - Uint32 flags = SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN; + // Create Vulkan window (no GL attributes needed) + Uint32 flags = SDL_WINDOW_VULKAN | SDL_WINDOW_SHOWN; if (config.fullscreen) { flags |= SDL_WINDOW_FULLSCREEN_DESKTOP; } @@ -61,49 +82,21 @@ bool Window::initialize() { return false; } - // Create OpenGL context - glContext = SDL_GL_CreateContext(window); - if (!glContext) { - LOG_ERROR("Failed to create OpenGL context: ", SDL_GetError()); + // Initialize Vulkan context + vkContext = std::make_unique(); + if (!vkContext->initialize(window)) { + LOG_ERROR("Failed to initialize Vulkan context"); return false; } - // Set VSync - if (SDL_GL_SetSwapInterval(config.vsync ? 1 : 0) != 0) { - LOG_WARNING("Failed to set VSync: ", SDL_GetError()); - } - vsync = config.vsync; - - // Initialize GLEW - glewExperimental = GL_TRUE; - GLenum glewError = glewInit(); - if (glewError != GLEW_OK) { - LOG_ERROR("Failed to initialize GLEW: ", glewGetErrorString(glewError)); - return false; - } - - // Log OpenGL info - LOG_INFO("OpenGL Version: ", glGetString(GL_VERSION)); - LOG_INFO("GLSL Version: ", glGetString(GL_SHADING_LANGUAGE_VERSION)); - LOG_INFO("Renderer: ", glGetString(GL_RENDERER)); - LOG_INFO("Vendor: ", glGetString(GL_VENDOR)); - - // Set up OpenGL defaults - glEnable(GL_MULTISAMPLE); - glEnable(GL_DEPTH_TEST); - glDepthFunc(GL_LESS); - glEnable(GL_CULL_FACE); - glCullFace(GL_BACK); - glFrontFace(GL_CCW); - - LOG_INFO("Window initialized successfully"); + LOG_INFO("Window initialized successfully (Vulkan)"); return true; } void Window::shutdown() { - if (glContext) { - SDL_GL_DeleteContext(glContext); - glContext = nullptr; + if (vkContext) { + vkContext->shutdown(); + vkContext.reset(); } if (window) { @@ -111,19 +104,14 @@ void Window::shutdown() { window = nullptr; } + SDL_Vulkan_UnloadLibrary(); SDL_Quit(); LOG_INFO("Window shutdown complete"); } -void Window::swapBuffers() { - SDL_GL_SwapWindow(window); -} - void Window::pollEvents() { SDL_Event event; while (SDL_PollEvent(&event)) { - // ImGui will handle events in UI manager - // For now, just handle quit if (event.type == SDL_QUIT) { shouldCloseFlag = true; } @@ -131,7 +119,9 @@ void Window::pollEvents() { if (event.window.event == SDL_WINDOWEVENT_RESIZED) { width = event.window.data1; height = event.window.data2; - glViewport(0, 0, width, height); + if (vkContext) { + vkContext->markSwapchainDirty(); + } LOG_DEBUG("Window resized to ", width, "x", height); } } @@ -160,15 +150,16 @@ void Window::setFullscreen(bool enable) { width = windowedWidth; height = windowedHeight; } - glViewport(0, 0, width, height); + if (vkContext) { + vkContext->markSwapchainDirty(); + } } -void Window::setVsync(bool enable) { - if (SDL_GL_SetSwapInterval(enable ? 1 : 0) != 0) { - LOG_WARNING("Failed to set VSync: ", SDL_GetError()); - return; - } +void Window::setVsync([[maybe_unused]] bool enable) { + // VSync in Vulkan is controlled by present mode (set at swapchain creation) + // For now, store the preference β€” applied on next swapchain recreation vsync = enable; + LOG_INFO("VSync preference set to ", enable ? "on" : "off", " (applied on swapchain recreation)"); } void Window::applyResolution(int w, int h) { @@ -184,7 +175,9 @@ void Window::applyResolution(int w, int h) { height = h; windowedWidth = w; windowedHeight = h; - glViewport(0, 0, width, height); + if (vkContext) { + vkContext->markSwapchainDirty(); + } } } // namespace core diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index ea00aa66..03ad0679 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -93,6 +93,13 @@ bool isClassicLikeExpansion() { return isActiveExpansion("classic") || isActiveExpansion("turtle"); } +bool envFlagEnabled(const char* key, bool defaultValue = false) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || + raw[0] == 'n' || raw[0] == 'N'); +} + std::string formatCopperAmount(uint32_t amount) { uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; @@ -517,16 +524,6 @@ bool GameHandler::isConnected() const { } void GameHandler::update(float deltaTime) { - // Timing profiling (log every 60 frames to reduce spam) - static int profileCounter = 0; - static float socketTime = 0.0f; - static float taxiTime = 0.0f; - static float distanceCheckTime = 0.0f; - static float entityUpdateTime = 0.0f; - static float totalTime = 0.0f; - - auto updateStart = std::chrono::high_resolution_clock::now(); - // Fire deferred char-create callback (outside ImGui render) if (pendingCharCreateResult_) { pendingCharCreateResult_ = false; @@ -540,12 +537,9 @@ void GameHandler::update(float deltaTime) { } // Update socket (processes incoming data and triggers callbacks) - auto socketStart = std::chrono::high_resolution_clock::now(); if (socket) { socket->update(); } - auto socketEnd = std::chrono::high_resolution_clock::now(); - socketTime += std::chrono::duration(socketEnd - socketStart).count(); // Detect server-side disconnect (socket closed during update) if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) { @@ -785,9 +779,6 @@ void GameHandler::update(float deltaTime) { } } - // Taxi logic timing - auto taxiStart = std::chrono::high_resolution_clock::now(); - // Detect taxi flight landing: UNIT_FLAG_TAXI_FLIGHT (0x00000100) cleared if (onTaxiFlight_) { updateClientTaxi(deltaTime); @@ -905,18 +896,12 @@ void GameHandler::update(float deltaTime) { } } - auto taxiEnd = std::chrono::high_resolution_clock::now(); - taxiTime += std::chrono::duration(taxiEnd - taxiStart).count(); - // Update transport manager if (transportManager_) { transportManager_->update(deltaTime); updateAttachedTransportChildren(deltaTime); } - // Distance check timing - auto distanceStart = std::chrono::high_resolution_clock::now(); - // Leave combat if auto-attack target is too far away (leash range) // and keep melee intent tightly synced while stationary. if (autoAttackRequested_ && autoAttackTarget != 0) { @@ -1074,12 +1059,6 @@ void GameHandler::update(float deltaTime) { } } - auto distanceEnd = std::chrono::high_resolution_clock::now(); - distanceCheckTime += std::chrono::duration(distanceEnd - distanceStart).count(); - - // Entity update timing - auto entityStart = std::chrono::high_resolution_clock::now(); - // Update entity movement interpolation (keeps targeting in sync with visuals) // Only update entities within reasonable distance for performance const float updateRadiusSq = 150.0f * 150.0f; // 150 unit radius @@ -1106,24 +1085,6 @@ void GameHandler::update(float deltaTime) { } } - auto entityEnd = std::chrono::high_resolution_clock::now(); - entityUpdateTime += std::chrono::duration(entityEnd - entityStart).count(); - } - - auto updateEnd = std::chrono::high_resolution_clock::now(); - totalTime += std::chrono::duration(updateEnd - updateStart).count(); - - // Log profiling every 60 frames - if (++profileCounter >= 60) { - LOG_DEBUG("UPDATE PROFILE (60 frames): socket=", socketTime / 60.0f, "ms taxi=", taxiTime / 60.0f, - "ms distance=", distanceCheckTime / 60.0f, "ms entity=", entityUpdateTime / 60.0f, - "ms TOTAL=", totalTime / 60.0f, "ms"); - profileCounter = 0; - socketTime = 0.0f; - taxiTime = 0.0f; - distanceCheckTime = 0.0f; - entityUpdateTime = 0.0f; - totalTime = 0.0f; } } @@ -1134,6 +1095,9 @@ void GameHandler::handlePacket(network::Packet& packet) { } uint16_t opcode = packet.getOpcode(); + try { + + const bool allowVanillaAliases = isClassicLikeExpansion() || isActiveExpansion("tbc"); // Vanilla compatibility aliases: // - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers @@ -1141,7 +1105,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers) // // We gate these by payload shape so expansion-native mappings remain intact. - if (opcode == 0x006B) { + if (allowVanillaAliases && opcode == 0x006B) { // Try compressed movement batch first: // [u8 subSize][u16 subOpcode][subPayload...] ... // where subOpcode is typically SMSG_MONSTER_MOVE / SMSG_MONSTER_MOVE_TRANSPORT. @@ -1189,7 +1153,7 @@ void GameHandler::handlePacket(network::Packet& packet) { // Not weather-shaped: rewind and fall through to normal opcode table handling. packet.setReadPos(0); } - } else if (opcode == 0x0103) { + } else if (allowVanillaAliases && opcode == 0x0103) { // Expected play-music payload: uint32 sound/music id if (packet.getSize() - packet.getReadPos() == 4) { uint32_t soundId = packet.readUInt32(); @@ -1953,11 +1917,22 @@ void GameHandler::handlePacket(network::Packet& packet) { worldStateZoneId_ = packet.readUInt32(); uint16_t count = packet.readUInt16(); size_t needed = static_cast(count) * 8; - if (packet.getSize() - packet.getReadPos() < needed) { - LOG_WARNING("SMSG_INIT_WORLD_STATES truncated: expected ", needed, - " bytes of state pairs, got ", packet.getSize() - packet.getReadPos()); - packet.setReadPos(packet.getSize()); - break; + size_t available = packet.getSize() - packet.getReadPos(); + if (available < needed) { + // Be tolerant across expansion/private-core variants: if packet shape + // still looks like N*(key,val) dwords, parse what is present. + if ((available % 8) == 0) { + uint16_t adjustedCount = static_cast(available / 8); + LOG_WARNING("SMSG_INIT_WORLD_STATES count mismatch: header=", count, + " adjusted=", adjustedCount, " (available=", available, ")"); + count = adjustedCount; + needed = available; + } else { + LOG_WARNING("SMSG_INIT_WORLD_STATES truncated: expected ", needed, + " bytes of state pairs, got ", available); + packet.setReadPos(packet.getSize()); + break; + } } worldStates_.clear(); worldStates_.reserve(count); @@ -2284,6 +2259,16 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_GAMEOBJECT_PAGETEXT: handleGameObjectPageText(packet); break; + case Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM: { + if (packet.getSize() >= 12) { + uint64_t guid = packet.readUInt64(); + uint32_t animId = packet.readUInt32(); + if (gameObjectCustomAnimCallback_) { + gameObjectCustomAnimCallback_(guid, animId); + } + } + break; + } case Opcode::SMSG_PAGE_TEXT_QUERY_RESPONSE: handlePageTextQueryResponse(packet); break; @@ -2849,6 +2834,23 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM while handling world opcode=0x", std::hex, opcode, std::dec, + " state=", worldStateName(state), + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " what=", e.what()); + if (socket && state == WorldState::IN_WORLD) { + disconnect(); + fail("Out of memory while parsing world packet"); + } + } catch (const std::exception& e) { + LOG_ERROR("Exception while handling world opcode=0x", std::hex, opcode, std::dec, + " state=", worldStateName(state), + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " what=", e.what()); + } } void GameHandler::handleAuthChallenge(network::Packet& packet) { @@ -4581,9 +4583,12 @@ void GameHandler::setOrientation(float orientation) { } void GameHandler::handleUpdateObject(network::Packet& packet) { + static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); UpdateObjectData data; if (!packetParsers_->parseUpdateObject(packet, data)) { - LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); + static int updateObjErrors = 0; + if (++updateObjErrors <= 5) + LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); return; } @@ -4710,48 +4715,46 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Process out-of-range objects first for (uint64_t guid : data.outOfRangeGuids) { - if (entityManager.hasEntity(guid)) { - const bool isKnownTransport = transportGuids_.count(guid) > 0; - if (isKnownTransport) { - // Keep transports alive across out-of-range flapping. - // Boats/zeppelins are global movers and removing them here can make - // them disappear until a later movement snapshot happens to recreate them. - const bool playerAboardNow = (playerTransportGuid_ == guid); - const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (movementInfo.transportGuid == guid); - LOG_INFO("Preserving transport on out-of-range: 0x", - std::hex, guid, std::dec, - " now=", playerAboardNow, - " sticky=", stickyAboard, - " movement=", movementSaysAboard); - continue; - } + auto entity = entityManager.getEntity(guid); + if (!entity) continue; - LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); - // Trigger despawn callbacks before removing entity - auto entity = entityManager.getEntity(guid); - if (entity) { - if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { - creatureDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { - playerDespawnCallback_(guid); - otherPlayerVisibleItemEntries_.erase(guid); - otherPlayerVisibleDirty_.erase(guid); - otherPlayerMoveTimeMs_.erase(guid); - inspectedPlayerItemEntries_.erase(guid); - pendingAutoInspect_.erase(guid); - } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { - gameObjectDespawnCallback_(guid); - } - } - transportGuids_.erase(guid); - serverUpdatedTransportGuids_.erase(guid); - clearTransportAttachment(guid); - if (playerTransportGuid_ == guid) { - clearPlayerTransport(); - } - entityManager.removeEntity(guid); + const bool isKnownTransport = transportGuids_.count(guid) > 0; + if (isKnownTransport) { + // Keep transports alive across out-of-range flapping. + // Boats/zeppelins are global movers and removing them here can make + // them disappear until a later movement snapshot happens to recreate them. + const bool playerAboardNow = (playerTransportGuid_ == guid); + const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); + const bool movementSaysAboard = (movementInfo.transportGuid == guid); + LOG_INFO("Preserving transport on out-of-range: 0x", + std::hex, guid, std::dec, + " now=", playerAboardNow, + " sticky=", stickyAboard, + " movement=", movementSaysAboard); + continue; } + + LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); + // Trigger despawn callbacks before removing entity + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { + creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + playerDespawnCallback_(guid); + otherPlayerVisibleItemEntries_.erase(guid); + otherPlayerVisibleDirty_.erase(guid); + otherPlayerMoveTimeMs_.erase(guid); + inspectedPlayerItemEntries_.erase(guid); + pendingAutoInspect_.erase(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { + gameObjectDespawnCallback_(guid); + } + transportGuids_.erase(guid); + serverUpdatedTransportGuids_.erase(guid); + clearTransportAttachment(guid); + if (playerTransportGuid_ == guid) { + clearPlayerTransport(); + } + entityManager.removeEntity(guid); } // Process update blocks @@ -4991,8 +4994,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } // Trigger creature spawn callback for units/players with displayId if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { - LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " has displayId=0 β€” no spawn (entry=", unit->getEntry(), ")"); + LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " has displayId=0 β€” no spawn (entry=", unit->getEntry(), + " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); } if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { @@ -5081,92 +5085,21 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Extract XP / inventory slot / skill fields for player entity if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - // Store baseline snapshot on first update - static bool baselineStored = false; - static std::map baselineFields; - - if (!baselineStored) { - baselineFields = block.fields; - baselineStored = true; - LOG_INFO("===== BASELINE PLAYER FIELDS STORED ====="); - LOG_INFO(" Total fields: ", block.fields.size()); - } - - // Diff against baseline to find changes - std::vector changedIndices; - std::vector newIndices; - std::vector removedIndices; - - for (const auto& [idx, val] : block.fields) { - auto it = baselineFields.find(idx); - if (it == baselineFields.end()) { - newIndices.push_back(idx); - } else if (it->second != val) { - changedIndices.push_back(idx); - } - } - - for (const auto& [idx, val] : baselineFields) { - if (block.fields.find(idx) == block.fields.end()) { - removedIndices.push_back(idx); - } - } - // Auto-detect coinage index using the previous snapshot vs this full snapshot. maybeDetectCoinageIndex(lastPlayerFields_, block.fields); lastPlayerFields_ = block.fields; detectInventorySlotBases(block.fields); - // Debug: Show field changes - LOG_INFO("Player update with ", block.fields.size(), " fields"); - - if (!changedIndices.empty() || !newIndices.empty() || !removedIndices.empty()) { - LOG_INFO(" ===== FIELD CHANGES DETECTED ====="); - if (!changedIndices.empty()) { - LOG_INFO(" Changed fields (", changedIndices.size(), "):"); - std::sort(changedIndices.begin(), changedIndices.end()); - for (size_t i = 0; i < std::min(size_t(30), changedIndices.size()); ++i) { - uint16_t idx = changedIndices[i]; - uint32_t oldVal = baselineFields[idx]; - uint32_t newVal = block.fields.at(idx); - LOG_INFO(" [", idx, "]: ", oldVal, " -> ", newVal, - " (0x", std::hex, oldVal, " -> 0x", newVal, std::dec, ")"); - } - if (changedIndices.size() > 30) { - LOG_INFO(" ... (", changedIndices.size() - 30, " more)"); - } - } - if (!newIndices.empty()) { - LOG_INFO(" New fields (", newIndices.size(), "):"); - std::sort(newIndices.begin(), newIndices.end()); - for (size_t i = 0; i < std::min(size_t(20), newIndices.size()); ++i) { - uint16_t idx = newIndices[i]; - uint32_t val = block.fields.at(idx); - LOG_INFO(" [", idx, "]: ", val, " (0x", std::hex, val, std::dec, ")"); - } - if (newIndices.size() > 20) { - LOG_INFO(" ... (", newIndices.size() - 20, " more)"); - } - } - if (!removedIndices.empty()) { - LOG_INFO(" Removed fields (", removedIndices.size(), "):"); - std::sort(removedIndices.begin(), removedIndices.end()); - for (size_t i = 0; i < std::min(size_t(20), removedIndices.size()); ++i) { - uint16_t idx = removedIndices[i]; - uint32_t val = baselineFields.at(idx); - LOG_INFO(" [", idx, "]: was ", val, " (0x", std::hex, val, std::dec, ")"); - } + if (kVerboseUpdateObject) { + uint16_t maxField = 0; + for (const auto& [key, _val] : block.fields) { + if (key > maxField) maxField = key; } + LOG_INFO("Player update with ", block.fields.size(), + " fields (max index=", maxField, ")"); } - uint16_t maxField = 0; - for (const auto& [key, val] : block.fields) { - if (key > maxField) maxField = key; - } - - LOG_INFO(" Highest field index: ", maxField); - bool slotsChanged = false; const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); @@ -5184,11 +5117,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } else if (key == ufCoinage) { playerMoneyCopper_ = val; - LOG_INFO("Money set from update fields: ", val, " copper"); + LOG_DEBUG("Money set from update fields: ", val, " copper"); } else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); - LOG_INFO("Armor rating from update fields: ", playerArmorRating_); + LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); } // Do not synthesize quest-log entries from raw update-field slots. // Slot layouts differ on some classic-family realms and can produce @@ -5426,7 +5359,12 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } // Update XP / inventory slot / skill fields for player entity if (block.guid == playerGuid) { - std::map oldFieldsSnapshot = lastPlayerFields_; + const bool needCoinageDetectSnapshot = + (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); + std::map oldFieldsSnapshot; + if (needCoinageDetectSnapshot) { + oldFieldsSnapshot = lastPlayerFields_; + } if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { serverRunSpeed_ = block.runSpeed; // Some server dismount paths update run speed without updating mount display field. @@ -5440,10 +5378,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } } + auto mergeHint = lastPlayerFields_.end(); for (const auto& [key, val] : block.fields) { - lastPlayerFields_[key] = val; + mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); + } + if (needCoinageDetectSnapshot) { + maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); } - maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); maybeDetectVisibleItemLayout(); detectInventorySlotBases(block.fields); bool slotsChanged = false; @@ -5456,15 +5397,15 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; - LOG_INFO("XP updated: ", val); + LOG_DEBUG("XP updated: ", val); } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; - LOG_INFO("Next level XP updated: ", val); + LOG_DEBUG("Next level XP updated: ", val); } else if (key == ufPlayerLevel) { serverPlayerLevel_ = val; - LOG_INFO("Level updated: ", val); + LOG_DEBUG("Level updated: ", val); for (auto& ch : characters) { if (ch.guid == playerGuid) { ch.level = val; @@ -5474,7 +5415,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } else if (key == ufCoinage) { playerMoneyCopper_ = val; - LOG_INFO("Money updated via VALUES: ", val, " copper"); + LOG_DEBUG("Money updated via VALUES: ", val, " copper"); } else if (ufArmor != 0xFFFF && key == ufArmor) { playerArmorRating_ = static_cast(val); @@ -5505,17 +5446,33 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Update item stack count for online items if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { + bool inventoryChanged = false; + const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); + const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); for (const auto& [key, val] : block.fields) { - if (key == fieldIndex(UF::ITEM_FIELD_STACK_COUNT)) { + if (key == itemStackField) { auto it = onlineItems_.find(block.guid); - if (it != onlineItems_.end()) it->second.stackCount = val; + if (it != onlineItems_.end() && it->second.stackCount != val) { + it->second.stackCount = val; + inventoryChanged = true; + } } } // Update container slot GUIDs on bag content changes if (entity->getType() == ObjectType::CONTAINER) { + for (const auto& [key, _] : block.fields) { + if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || + (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { + inventoryChanged = true; + break; + } + } extractContainerFields(block.guid, block.fields); } - rebuildOnlineInventory(); + if (inventoryChanged) { + rebuildOnlineInventory(); + } } if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { if (transportGuids_.count(block.guid) && transportMoveCallback_) { @@ -8650,10 +8607,30 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { void GameHandler::handleMonsterMove(network::Packet& packet) { MonsterMoveData data; + auto logMonsterMoveParseFailure = [&](const std::string& msg) { + static uint32_t failCount = 0; + ++failCount; + if (failCount <= 10 || (failCount % 100) == 0) { + LOG_WARNING(msg, " (occurrence=", failCount, ")"); + } + }; + auto stripWrappedSubpacket = [&](const std::vector& bytes, std::vector& stripped) -> bool { + if (bytes.size() < 3) return false; + uint8_t subSize = bytes[0]; + if (subSize < 2) return false; + size_t wrappedLen = static_cast(subSize) + 1; // size byte + body + if (wrappedLen != bytes.size()) return false; + size_t payloadLen = static_cast(subSize) - 2; // opcode(2) stripped + if (3 + payloadLen > bytes.size()) return false; + stripped.assign(bytes.begin() + 3, bytes.begin() + 3 + payloadLen); + return true; + }; // Turtle WoW (1.17+) compresses each SMSG_MONSTER_MOVE individually: // format: uint32 decompressedSize + zlib data (zlib magic = 0x78 ??) const auto& rawData = packet.getData(); - bool isCompressed = rawData.size() >= 6 && + const bool allowTurtleMoveCompression = isActiveExpansion("turtle"); + bool isCompressed = allowTurtleMoveCompression && + rawData.size() >= 6 && rawData[4] == 0x78 && (rawData[5] == 0x01 || rawData[5] == 0x9C || rawData[5] == 0xDA || rawData[5] == 0x5E); @@ -8685,36 +8662,42 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { } LOG_INFO("MonsterMove decomp[", destLen, "]: ", hex); } - // Some Turtle WoW compressed move payloads include an inner - // sub-packet wrapper: uint8 size + uint16 opcode + payload. - // Do not key this on expansion opcode mappings; strip by structure. - std::vector parseBytes = decompressed; - if (destLen >= 3) { - uint8_t subSize = decompressed[0]; - size_t wrappedLen = static_cast(subSize) + 1; // size byte + subSize bytes - uint16_t innerOpcode = static_cast(decompressed[1]) | - (static_cast(decompressed[2]) << 8); - uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); - bool looksLikeMonsterMoveWrapper = - (innerOpcode == 0x00DD) || (innerOpcode == monsterMoveWire); - // Strict case: one exact wrapped sub-packet in this decompressed blob. - if (subSize >= 2 && wrappedLen == destLen && looksLikeMonsterMoveWrapper) { - size_t payloadStart = 3; - size_t payloadLen = static_cast(subSize) - 2; - parseBytes.assign(decompressed.begin() + payloadStart, - decompressed.begin() + payloadStart + payloadLen); - } - } + std::vector stripped; + bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped); - network::Packet decompPacket(packet.getOpcode(), parseBytes); + // Try unwrapped payload first (common form), then wrapped-subpacket fallback. + network::Packet decompPacket(packet.getOpcode(), decompressed); if (!packetParsers_->parseMonsterMove(decompPacket, data)) { - LOG_WARNING("Failed to parse vanilla SMSG_MONSTER_MOVE (decompressed ", - destLen, " bytes, parseBytes ", parseBytes.size(), " bytes)"); - return; + if (!hasWrappedForm) { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + + std::to_string(destLen) + " bytes)"); + return; + } + network::Packet wrappedPacket(packet.getOpcode(), stripped); + if (!packetParsers_->parseMonsterMove(wrappedPacket, data)) { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + + std::to_string(destLen) + " bytes, wrapped payload " + + std::to_string(stripped.size()) + " bytes)"); + return; + } + LOG_WARNING("SMSG_MONSTER_MOVE parsed via wrapped-subpacket fallback"); } } else if (!packetParsers_->parseMonsterMove(packet, data)) { - LOG_WARNING("Failed to parse SMSG_MONSTER_MOVE"); - return; + // Some realms occasionally embed an extra [size|opcode] wrapper even when the + // outer packet wasn't zlib-compressed. Retry with wrapper stripped by structure. + std::vector stripped; + if (stripWrappedSubpacket(rawData, stripped)) { + network::Packet wrappedPacket(packet.getOpcode(), stripped); + if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { + LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback"); + } else { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE"); + return; + } + } else { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE"); + return; + } } // Update entity position in entity manager diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 0e598125..6dcfe934 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1192,6 +1192,24 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc return true; } +bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { + // Turtle realms can emit both vanilla-like and WotLK-like monster move bodies. + // Try the canonical Turtle/vanilla parser first, then fall back to WotLK layout. + size_t start = packet.getReadPos(); + if (MonsterMoveParser::parseVanilla(packet, data)) { + return true; + } + + packet.setReadPos(start); + if (MonsterMoveParser::parse(packet, data)) { + LOG_WARNING("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); + return true; + } + + packet.setReadPos(start); + return false; +} + // ============================================================================ // Classic/Vanilla quest giver status // diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 3223ae42..e4275640 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -376,83 +376,125 @@ bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& // (WotLK removed this field) // ============================================================================ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectData& data) { - // Read block count - data.blockCount = packet.readUInt32(); + constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; + auto parseWithLayout = [&](bool withHasTransportByte, UpdateObjectData& out) -> bool { + out = UpdateObjectData{}; + size_t start = packet.getReadPos(); + if (packet.getSize() - start < 4) return false; - // TBC/Classic: has_transport byte (WotLK removed this) - /*uint8_t hasTransport =*/ packet.readUInt8(); - - LOG_DEBUG("[TBC] SMSG_UPDATE_OBJECT: objectCount=", data.blockCount); - - // Check for out-of-range objects first - if (packet.getReadPos() + 1 <= packet.getSize()) { - uint8_t firstByte = packet.readUInt8(); - - if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { - uint32_t count = packet.readUInt32(); - for (uint32_t i = 0; i < count; ++i) { - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); - data.outOfRangeGuids.push_back(guid); - LOG_DEBUG(" Out of range: 0x", std::hex, guid, std::dec); - } - } else { - packet.setReadPos(packet.getReadPos() - 1); + out.blockCount = packet.readUInt32(); + if (out.blockCount > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; } - } - // Parse update blocks β€” dispatching movement via virtual parseMovementBlock() - data.blocks.reserve(data.blockCount); - for (uint32_t i = 0; i < data.blockCount; ++i) { - LOG_DEBUG("Parsing block ", i + 1, " / ", data.blockCount); - UpdateBlock block; - - // Read update type - uint8_t updateTypeVal = packet.readUInt8(); - block.updateType = static_cast(updateTypeVal); - LOG_DEBUG("Update block: type=", (int)updateTypeVal); - - bool ok = false; - switch (block.updateType) { - case UpdateType::VALUES: { - block.guid = UpdateObjectParser::readPackedGuid(packet); - ok = UpdateObjectParser::parseUpdateFields(packet, block); - break; + if (withHasTransportByte) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; } - case UpdateType::MOVEMENT: { - block.guid = UpdateObjectParser::readPackedGuid(packet); - ok = this->parseMovementBlock(packet, block); - break; - } - case UpdateType::CREATE_OBJECT: - case UpdateType::CREATE_OBJECT2: { - block.guid = UpdateObjectParser::readPackedGuid(packet); - uint8_t objectTypeVal = packet.readUInt8(); - block.objectType = static_cast(objectTypeVal); - ok = this->parseMovementBlock(packet, block); - if (ok) { - ok = UpdateObjectParser::parseUpdateFields(packet, block); + /*uint8_t hasTransport =*/ packet.readUInt8(); + } + + if (packet.getReadPos() + 1 <= packet.getSize()) { + uint8_t firstByte = packet.readUInt8(); + if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (packet.getReadPos() + 4 > packet.getSize()) { + packet.setReadPos(start); + return false; } - break; + uint32_t count = packet.readUInt32(); + if (count > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + for (uint32_t i = 0; i < count; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + out.outOfRangeGuids.push_back(guid); + } + } else { + packet.setReadPos(packet.getReadPos() - 1); } - case UpdateType::OUT_OF_RANGE_OBJECTS: - case UpdateType::NEAR_OBJECTS: - ok = true; - break; - default: - LOG_WARNING("Unknown update type: ", (int)updateTypeVal); - ok = false; - break; } - if (!ok) { - LOG_WARNING("Failed to parse update block ", i + 1, " of ", data.blockCount, - " β€” keeping ", data.blocks.size(), " parsed blocks"); - break; + out.blocks.reserve(out.blockCount); + for (uint32_t i = 0; i < out.blockCount; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + + UpdateBlock block; + uint8_t updateTypeVal = packet.readUInt8(); + if (updateTypeVal > static_cast(UpdateType::NEAR_OBJECTS)) { + packet.setReadPos(start); + return false; + } + block.updateType = static_cast(updateTypeVal); + + bool ok = false; + switch (block.updateType) { + case UpdateType::VALUES: { + block.guid = UpdateObjectParser::readPackedGuid(packet); + ok = UpdateObjectParser::parseUpdateFields(packet, block); + break; + } + case UpdateType::MOVEMENT: { + block.guid = UpdateObjectParser::readPackedGuid(packet); + ok = this->parseMovementBlock(packet, block); + break; + } + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: { + block.guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getReadPos() >= packet.getSize()) { + ok = false; + break; + } + uint8_t objectTypeVal = packet.readUInt8(); + block.objectType = static_cast(objectTypeVal); + ok = this->parseMovementBlock(packet, block); + if (ok) ok = UpdateObjectParser::parseUpdateFields(packet, block); + break; + } + case UpdateType::OUT_OF_RANGE_OBJECTS: + case UpdateType::NEAR_OBJECTS: + ok = true; + break; + default: + ok = false; + break; + } + + if (!ok) { + packet.setReadPos(start); + return false; + } + out.blocks.push_back(block); } - data.blocks.push_back(block); + return true; + }; + + size_t startPos = packet.getReadPos(); + UpdateObjectData parsed; + if (parseWithLayout(true, parsed)) { + data = std::move(parsed); + return true; } - return true; + packet.setReadPos(startPos); + if (parseWithLayout(false, parsed)) { + LOG_WARNING("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + return false; } network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) { diff --git a/src/game/warden_memory.cpp b/src/game/warden_memory.cpp index 257b3d58..c5a0afd2 100644 --- a/src/game/warden_memory.cpp +++ b/src/game/warden_memory.cpp @@ -182,6 +182,7 @@ void WardenMemory::patchRuntimeGlobals() { // uint32 dwAllocationGranularity // uint16 wProcessorLevel // uint16 wProcessorRevision +#pragma pack(push, 1) struct { uint16_t wProcessorArchitecture; uint16_t wReserved; @@ -194,7 +195,7 @@ void WardenMemory::patchRuntimeGlobals() { uint32_t dwAllocationGranularity; uint16_t wProcessorLevel; uint16_t wProcessorRevision; - } __attribute__((packed)) sysInfo = { + } sysInfo = { 0, // x86 0, 4096, // 4K page size @@ -207,6 +208,7 @@ void WardenMemory::patchRuntimeGlobals() { 6, // P6 family 0x3A09 // revision }; +#pragma pack(pop) static_assert(sizeof(sysInfo) == 36, "SYSTEM_INFO must be 36 bytes"); uint32_t rva = sysInfoAddr - imageBase_; if (rva + 36 <= imageSize_) { diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 262453a8..fba39960 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -8,6 +8,10 @@ #include #include #include +#include +#include +#include +#include #ifdef _WIN32 #ifndef WIN32_LEAN_AND_MEAN @@ -342,37 +346,47 @@ bool WardenModule::verifyRSASignature(const std::vector& data) { std::vector expectedHash = auth::Crypto::sha1(dataToHash); - // Create RSA public key structure - RSA* rsa = RSA_new(); - if (!rsa) { - std::cerr << "[WardenModule] Failed to create RSA structure" << '\n'; - return false; - } - + // Create RSA public key using EVP_PKEY_fromdata (OpenSSL 3.0 compatible) BIGNUM* n = BN_bin2bn(modulus, 256, nullptr); BIGNUM* e = BN_new(); BN_set_word(e, exponent); - #if OPENSSL_VERSION_NUMBER >= 0x10100000L - // OpenSSL 1.1.0+ - RSA_set0_key(rsa, n, e, nullptr); - #else - // OpenSSL 1.0.x - rsa->n = n; - rsa->e = e; - #endif - - // Decrypt signature using public key + EVP_PKEY* pkey = nullptr; + EVP_PKEY_CTX* ctx = nullptr; std::vector decryptedSig(256); - int decryptedLen = RSA_public_decrypt( - 256, - signature.data(), - decryptedSig.data(), - rsa, - RSA_NO_PADDING - ); + int decryptedLen = -1; - RSA_free(rsa); + { + OSSL_PARAM_BLD* bld = OSSL_PARAM_BLD_new(); + OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_N, n); + OSSL_PARAM_BLD_push_BN(bld, OSSL_PKEY_PARAM_RSA_E, e); + OSSL_PARAM* params = OSSL_PARAM_BLD_to_param(bld); + OSSL_PARAM_BLD_free(bld); + + EVP_PKEY_CTX* fromCtx = EVP_PKEY_CTX_new_from_name(nullptr, "RSA", nullptr); + if (fromCtx && EVP_PKEY_fromdata_init(fromCtx) > 0) { + EVP_PKEY_fromdata(fromCtx, &pkey, EVP_PKEY_PUBLIC_KEY, params); + } + if (fromCtx) EVP_PKEY_CTX_free(fromCtx); + OSSL_PARAM_free(params); + + if (pkey) { + ctx = EVP_PKEY_CTX_new(pkey, nullptr); + if (ctx && EVP_PKEY_verify_recover_init(ctx) > 0 && + EVP_PKEY_CTX_set_rsa_padding(ctx, RSA_NO_PADDING) > 0) { + size_t outLen = decryptedSig.size(); + if (EVP_PKEY_verify_recover(ctx, decryptedSig.data(), &outLen, + signature.data(), 256) > 0) { + decryptedLen = static_cast(outLen); + } + } + } + } + + BN_free(n); + BN_free(e); + if (ctx) EVP_PKEY_CTX_free(ctx); + if (pkey) EVP_PKEY_free(pkey); if (decryptedLen < 0) { std::cerr << "[WardenModule] RSA public decrypt failed" << '\n'; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 54a3dc1d..c3fac72a 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -11,6 +11,16 @@ #include #include +namespace { + inline uint32_t bswap32(uint32_t v) { + return ((v & 0xFF000000u) >> 24) | ((v & 0x00FF0000u) >> 8) + | ((v & 0x0000FF00u) << 8) | ((v & 0x000000FFu) << 24); + } + inline uint16_t bswap16(uint16_t v) { + return static_cast(((v & 0xFF00u) >> 8) | ((v & 0x00FFu) << 8)); + } +} + namespace wowee { namespace game { @@ -532,29 +542,46 @@ bool LoginVerifyWorldParser::parse(network::Packet& packet, LoginVerifyWorldData } bool AccountDataTimesParser::parse(network::Packet& packet, AccountDataTimesData& data) { - // SMSG_ACCOUNT_DATA_TIMES format (WoW 3.3.5a): - // uint32 serverTime (Unix timestamp) - // uint8 unknown (always 1?) - // uint32[8] accountDataTimes (timestamps for each data slot) - - if (packet.getSize() < 37) { - LOG_ERROR("SMSG_ACCOUNT_DATA_TIMES packet too small: ", packet.getSize(), " bytes"); + // Common layouts seen in the wild: + // - WotLK-like: uint32 serverTime, uint8 unk, uint32 mask, uint32[up to 8] slotTimes + // - Older/variant: uint32 serverTime, uint8 unk, uint32[up to 8] slotTimes + // Some servers only send a subset of slots. + if (packet.getSize() < 5) { + LOG_ERROR("SMSG_ACCOUNT_DATA_TIMES packet too small: ", packet.getSize(), + " bytes (need at least 5)"); return false; } + for (uint32_t& t : data.accountDataTimes) { + t = 0; + } data.serverTime = packet.readUInt32(); data.unknown = packet.readUInt8(); + size_t remaining = packet.getSize() - packet.getReadPos(); + uint32_t mask = 0xFF; + if (remaining >= 4 && ((remaining - 4) % 4) == 0) { + // Treat first dword as slot mask when payload shape matches. + mask = packet.readUInt32(); + } + remaining = packet.getSize() - packet.getReadPos(); + size_t slotWords = std::min(8, remaining / 4); + LOG_DEBUG("Parsed SMSG_ACCOUNT_DATA_TIMES:"); LOG_DEBUG(" Server time: ", data.serverTime); LOG_DEBUG(" Unknown: ", (int)data.unknown); + LOG_DEBUG(" Mask: 0x", std::hex, mask, std::dec, " slotsInPacket=", slotWords); - for (int i = 0; i < 8; ++i) { + for (size_t i = 0; i < slotWords; ++i) { data.accountDataTimes[i] = packet.readUInt32(); - if (data.accountDataTimes[i] != 0) { + if (data.accountDataTimes[i] != 0 || ((mask & (1u << i)) != 0)) { LOG_DEBUG(" Data slot ", i, ": ", data.accountDataTimes[i]); } } + if (packet.getReadPos() != packet.getSize()) { + LOG_DEBUG(" AccountDataTimes trailing bytes: ", packet.getSize() - packet.getReadPos()); + packet.setReadPos(packet.getSize()); + } return true; } @@ -886,53 +913,99 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Spline data if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED + auto bytesAvailable = [&](size_t n) -> bool { return packet.getReadPos() + n <= packet.getSize(); }; + if (!bytesAvailable(4)) return false; uint32_t splineFlags = packet.readUInt32(); LOG_DEBUG(" Spline: flags=0x", std::hex, splineFlags, std::dec); if (splineFlags & 0x00010000) { // SPLINEFLAG_FINAL_POINT + if (!bytesAvailable(12)) return false; /*float finalX =*/ packet.readFloat(); /*float finalY =*/ packet.readFloat(); /*float finalZ =*/ packet.readFloat(); } else if (splineFlags & 0x00020000) { // SPLINEFLAG_FINAL_TARGET + if (!bytesAvailable(8)) return false; /*uint64_t finalTarget =*/ packet.readUInt64(); } else if (splineFlags & 0x00040000) { // SPLINEFLAG_FINAL_ANGLE + if (!bytesAvailable(4)) return false; /*float finalAngle =*/ packet.readFloat(); } + // Legacy UPDATE_OBJECT spline layout used by many servers: + // timePassed, duration, splineId, durationMod, durationModNext, + // verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint. + const size_t legacyStart = packet.getReadPos(); + if (!bytesAvailable(12 + 8 + 8 + 4)) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); - /*float durationMod =*/ packet.readFloat(); /*float durationModNext =*/ packet.readFloat(); - /*float verticalAccel =*/ packet.readFloat(); /*uint32_t effectStartTime =*/ packet.readUInt32(); - uint32_t pointCount = packet.readUInt32(); - if (pointCount > 256) { + + const size_t remainingAfterCount = packet.getSize() - packet.getReadPos(); + const bool legacyCountLooksValid = (pointCount <= 256); + const size_t legacyPointsBytes = static_cast(pointCount) * 12ull; + const bool legacyPayloadFits = (legacyPointsBytes + 13ull) <= remainingAfterCount; + + if (legacyCountLooksValid && legacyPayloadFits) { + for (uint32_t i = 0; i < pointCount; i++) { + /*float px =*/ packet.readFloat(); + /*float py =*/ packet.readFloat(); + /*float pz =*/ packet.readFloat(); + } + /*uint8_t splineMode =*/ packet.readUInt8(); + /*float endPointX =*/ packet.readFloat(); + /*float endPointY =*/ packet.readFloat(); + /*float endPointZ =*/ packet.readFloat(); + LOG_DEBUG(" Spline pointCount=", pointCount, " (legacy)"); + } else { + // Legacy pointCount looks invalid; try compact WotLK layout as recovery. + // This keeps malformed/variant packets from desyncing the whole update block. + packet.setReadPos(legacyStart); + const size_t afterFinalFacingPos = packet.getReadPos(); + if (splineFlags & 0x00400000) { // Animation + if (!bytesAvailable(5)) return false; + /*uint8_t animType =*/ packet.readUInt8(); + /*uint32_t animStart =*/ packet.readUInt32(); + } + if (!bytesAvailable(4)) return false; + /*uint32_t duration =*/ packet.readUInt32(); + if (splineFlags & 0x00000800) { // Parabolic + if (!bytesAvailable(8)) return false; + /*float verticalAccel =*/ packet.readFloat(); + /*uint32_t effectStartTime =*/ packet.readUInt32(); + } + if (!bytesAvailable(4)) return false; + const uint32_t compactPointCount = packet.readUInt32(); + if (compactPointCount > 16384) { static uint32_t badSplineCount = 0; ++badSplineCount; if (badSplineCount <= 5 || (badSplineCount % 100) == 0) { LOG_WARNING(" Spline pointCount=", pointCount, - " exceeds maximum, capping at 0 (readPos=", - packet.getReadPos(), "/", packet.getSize(), - ", occurrence=", badSplineCount, ")"); + " invalid (legacy+compact) at readPos=", + afterFinalFacingPos, "/", packet.getSize(), + ", occurrence=", badSplineCount); } - pointCount = 0; - } else { - LOG_DEBUG(" Spline pointCount=", pointCount); + return false; } - for (uint32_t i = 0; i < pointCount; i++) { - /*float px =*/ packet.readFloat(); - /*float py =*/ packet.readFloat(); - /*float pz =*/ packet.readFloat(); + const bool uncompressed = (splineFlags & (0x00080000 | 0x00002000)) != 0; + size_t compactPayloadBytes = 0; + if (compactPointCount > 0) { + if (uncompressed) { + compactPayloadBytes = static_cast(compactPointCount) * 12ull; + } else { + compactPayloadBytes = 12ull; + if (compactPointCount > 1) { + compactPayloadBytes += static_cast(compactPointCount - 1) * 4ull; + } + } + if (!bytesAvailable(compactPayloadBytes)) return false; + packet.setReadPos(packet.getReadPos() + compactPayloadBytes); } - - /*uint8_t splineMode =*/ packet.readUInt8(); - /*float endPointX =*/ packet.readFloat(); - /*float endPointY =*/ packet.readFloat(); - /*float endPointZ =*/ packet.readFloat(); + } // end else (compact fallback) } } else if (updateFlags & UPDATEFLAG_POSITION) { @@ -1025,8 +1098,9 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& LOG_DEBUG(" maskBlockCount = ", (int)blockCount); LOG_DEBUG(" fieldsCapacity (blocks * 32) = ", fieldsCapacity); - // Read update mask - std::vector updateMask(blockCount); + // Read update mask into a reused scratch buffer to avoid per-block allocations. + static thread_local std::vector updateMask; + updateMask.resize(blockCount); for (int i = 0; i < blockCount; ++i) { updateMask[i] = packet.readUInt32(); } @@ -1035,22 +1109,30 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& uint16_t highestSetBit = 0; uint32_t valuesReadCount = 0; - // Read field values for each bit set in mask + // Read only set bits in each mask block (faster than scanning all 32 bits). for (int blockIdx = 0; blockIdx < blockCount; ++blockIdx) { uint32_t mask = updateMask[blockIdx]; - - for (int bit = 0; bit < 32; ++bit) { - if (mask & (1 << bit)) { - uint16_t fieldIndex = blockIdx * 32 + bit; - if (fieldIndex > highestSetBit) { - highestSetBit = fieldIndex; - } - uint32_t value = packet.readUInt32(); - block.fields[fieldIndex] = value; - valuesReadCount++; - - LOG_DEBUG(" Field[", fieldIndex, "] = 0x", std::hex, value, std::dec); + while (mask != 0) { + const uint16_t fieldIndex = +#if defined(__GNUC__) || defined(__clang__) + static_cast(blockIdx * 32 + __builtin_ctz(mask)); +#else + static_cast(blockIdx * 32 + [] (uint32_t v) -> uint16_t { + uint16_t b = 0; + while ((v & 1u) == 0u) { v >>= 1u; ++b; } + return b; + }(mask)); +#endif + if (fieldIndex > highestSetBit) { + highestSetBit = fieldIndex; } + uint32_t value = packet.readUInt32(); + // fieldIndex is monotonically increasing here, so end() is a good insertion hint. + block.fields.emplace_hint(block.fields.end(), fieldIndex, value); + valuesReadCount++; + + LOG_DEBUG(" Field[", fieldIndex, "] = 0x", std::hex, value, std::dec); + mask &= (mask - 1u); } } @@ -1131,9 +1213,16 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& } bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) { + constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; + constexpr uint32_t kMaxReasonableOutOfRangeGuids = 16384; // Read block count data.blockCount = packet.readUInt32(); + if (data.blockCount > kMaxReasonableUpdateBlocks) { + LOG_ERROR("SMSG_UPDATE_OBJECT rejected: unreasonable blockCount=", data.blockCount, + " packetSize=", packet.getSize()); + return false; + } LOG_DEBUG("SMSG_UPDATE_OBJECT:"); LOG_DEBUG(" objectCount = ", data.blockCount); @@ -1146,6 +1235,11 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { // Read out-of-range GUID count uint32_t count = packet.readUInt32(); + if (count > kMaxReasonableOutOfRangeGuids) { + LOG_ERROR("SMSG_UPDATE_OBJECT rejected: unreasonable outOfRange count=", count, + " packetSize=", packet.getSize()); + return false; + } for (uint32_t i = 0; i < count; ++i) { uint64_t guid = readPackedGuid(packet); @@ -1169,11 +1263,16 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) UpdateBlock block; if (!parseUpdateBlock(packet, block)) { - LOG_ERROR("Failed to parse update block ", i + 1); + static int parseBlockErrors = 0; + if (++parseBlockErrors <= 5) { + LOG_ERROR("Failed to parse update block ", i + 1); + if (parseBlockErrors == 5) + LOG_ERROR("(suppressing further update block parse errors)"); + } return false; } - data.blocks.push_back(block); + data.blocks.emplace_back(std::move(block)); } @@ -3655,10 +3754,10 @@ bool TalentsInfoParser::parse(network::Packet& packet, TalentsInfoData& data) { // These two counts are big-endian (network byte order) uint32_t talentCountBE = packet.readUInt32(); - uint32_t talentCount = __builtin_bswap32(talentCountBE); + uint32_t talentCount = bswap32(talentCountBE); uint16_t entryCountBE = packet.readUInt16(); - uint16_t entryCount = __builtin_bswap16(entryCountBE); + uint16_t entryCount = bswap16(entryCountBE); // Sanity check: prevent corrupt packets from allocating excessive memory if (entryCount > 64) { diff --git a/src/main.cpp b/src/main.cpp index cbd1c305..97930e30 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,9 @@ #include "core/logger.hpp" #include #include +#include +#include +#include #include #ifdef __linux__ #include @@ -27,6 +30,19 @@ static void crashHandler(int sig) { std::raise(sig); } +static wowee::core::LogLevel readLogLevelFromEnv() { + const char* raw = std::getenv("WOWEE_LOG_LEVEL"); + if (!raw || !*raw) return wowee::core::LogLevel::WARNING; + std::string level(raw); + for (char& c : level) c = static_cast(std::tolower(static_cast(c))); + if (level == "debug") return wowee::core::LogLevel::DEBUG; + if (level == "info") return wowee::core::LogLevel::INFO; + if (level == "warn" || level == "warning") return wowee::core::LogLevel::WARNING; + if (level == "error") return wowee::core::LogLevel::ERROR; + if (level == "fatal") return wowee::core::LogLevel::FATAL; + return wowee::core::LogLevel::WARNING; +} + int main([[maybe_unused]] int argc, [[maybe_unused]] char* argv[]) { #ifdef __linux__ g_emergencyDisplay = XOpenDisplay(nullptr); @@ -37,7 +53,7 @@ int main([[maybe_unused]] int argc, [[maybe_unused]] char* argv[]) { std::signal(SIGTERM, crashHandler); std::signal(SIGINT, crashHandler); try { - wowee::core::Logger::getInstance().setLogLevel(wowee::core::LogLevel::INFO); + wowee::core::Logger::getInstance().setLogLevel(readLogLevelFromEnv()); LOG_INFO("=== Wowee Native Client ==="); LOG_INFO("Starting application..."); diff --git a/src/network/packet.cpp b/src/network/packet.cpp index 7c8b55a3..d82469b9 100644 --- a/src/network/packet.cpp +++ b/src/network/packet.cpp @@ -1,5 +1,6 @@ #include "network/packet.hpp" #include +#include namespace wowee { namespace network { @@ -9,6 +10,9 @@ Packet::Packet(uint16_t opcode) : opcode(opcode) {} Packet::Packet(uint16_t opcode, const std::vector& data) : opcode(opcode), data(data), readPos(0) {} +Packet::Packet(uint16_t opcode, std::vector&& data) + : opcode(opcode), data(std::move(data)), readPos(0) {} + void Packet::writeUInt8(uint8_t value) { data.push_back(value); } diff --git a/src/network/tcp_socket.cpp b/src/network/tcp_socket.cpp index a83ef190..38a1cf6a 100644 --- a/src/network/tcp_socket.cpp +++ b/src/network/tcp_socket.cpp @@ -28,8 +28,11 @@ bool TCPSocket::connect(const std::string& host, uint16_t port) { net::setNonBlocking(sockfd); // Resolve host - struct hostent* server = gethostbyname(host.c_str()); - if (server == nullptr) { + struct addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* res = nullptr; + if (getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr) { LOG_ERROR("Failed to resolve host: ", host); net::closeSocket(sockfd); sockfd = INVALID_SOCK; @@ -40,8 +43,9 @@ bool TCPSocket::connect(const std::string& host, uint16_t port) { struct sockaddr_in serverAddr; memset(&serverAddr, 0, sizeof(serverAddr)); serverAddr.sin_family = AF_INET; - memcpy(&serverAddr.sin_addr.s_addr, server->h_addr, server->h_length); + serverAddr.sin_addr = reinterpret_cast(res->ai_addr)->sin_addr; serverAddr.sin_port = htons(port); + freeaddrinfo(res); int result = ::connect(sockfd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)); if (result < 0) { diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index fcee6a92..ab29a271 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include namespace { constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024; @@ -40,6 +42,13 @@ inline bool isLoginPipelineCmsg(uint16_t opcode) { return false; } } + +inline bool envFlagEnabled(const char* key, bool defaultValue = false) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || + raw[0] == 'n' || raw[0] == 'N'); +} } // namespace namespace wowee { @@ -58,6 +67,19 @@ static const uint8_t DECRYPT_KEY[] = { WorldSocket::WorldSocket() { net::ensureInit(); + // Always reserve baseline receive capacity (safe, behavior-preserving). + receiveBuffer.reserve(64 * 1024); + useFastRecvAppend_ = envFlagEnabled("WOWEE_NET_FAST_RECV_APPEND", true); + useParseScratchQueue_ = envFlagEnabled("WOWEE_NET_PARSE_SCRATCH", false); + if (useParseScratchQueue_) { + LOG_WARNING("WOWEE_NET_PARSE_SCRATCH is temporarily disabled (known unstable); forcing off"); + useParseScratchQueue_ = false; + } + if (useParseScratchQueue_) { + parsedPacketsScratch_.reserve(64); + } + LOG_INFO("WorldSocket net opts: fast_recv_append=", useFastRecvAppend_ ? "on" : "off", + " parse_scratch=", useParseScratchQueue_ ? "on" : "off"); } WorldSocket::~WorldSocket() { @@ -78,8 +100,11 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) { net::setNonBlocking(sockfd); // Resolve host - struct hostent* server = gethostbyname(host.c_str()); - if (server == nullptr) { + struct addrinfo hints{}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* res = nullptr; + if (getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || res == nullptr) { LOG_ERROR("Failed to resolve host: ", host); net::closeSocket(sockfd); sockfd = INVALID_SOCK; @@ -90,8 +115,9 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) { struct sockaddr_in serverAddr; memset(&serverAddr, 0, sizeof(serverAddr)); serverAddr.sin_family = AF_INET; - memcpy(&serverAddr.sin_addr.s_addr, server->h_addr, server->h_length); + serverAddr.sin_addr = reinterpret_cast(res->ai_addr)->sin_addr; serverAddr.sin_port = htons(port); + freeaddrinfo(res); int result = ::connect(sockfd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)); if (result < 0) { @@ -118,6 +144,8 @@ void WorldSocket::disconnect() { encryptionEnabled = false; useVanillaCrypt = false; receiveBuffer.clear(); + receiveReadOffset_ = 0; + parsedPacketsScratch_.clear(); headerBytesDecrypted = 0; LOG_INFO("Disconnected from world server"); } @@ -128,13 +156,15 @@ bool WorldSocket::isConnected() const { void WorldSocket::send(const Packet& packet) { if (!connected) return; + static const bool kLogCharCreatePayload = envFlagEnabled("WOWEE_NET_LOG_CHAR_CREATE", false); + static const bool kLogSwapItemPackets = envFlagEnabled("WOWEE_NET_LOG_SWAP_ITEM", false); const auto& data = packet.getData(); uint16_t opcode = packet.getOpcode(); uint16_t payloadLen = static_cast(data.size()); // Debug: parse and log character-create payload fields (helps diagnose appearance issues). - if (opcode == 0x036) { // CMSG_CHAR_CREATE + if (kLogCharCreatePayload && opcode == 0x036) { // CMSG_CHAR_CREATE size_t pos = 0; std::string name; while (pos < data.size()) { @@ -181,7 +211,7 @@ void WorldSocket::send(const Packet& packet) { } } - if (opcode == 0x10C || opcode == 0x10D) { // CMSG_SWAP_ITEM / CMSG_SWAP_INV_ITEM + if (kLogSwapItemPackets && (opcode == 0x10C || opcode == 0x10D)) { // CMSG_SWAP_ITEM / CMSG_SWAP_INV_ITEM std::string hex; for (size_t i = 0; i < data.size(); i++) { char buf[4]; @@ -255,6 +285,23 @@ void WorldSocket::send(const Packet& packet) { void WorldSocket::update() { if (!connected) return; + auto bufferedBytes = [&]() -> size_t { + return (receiveBuffer.size() >= receiveReadOffset_) + ? (receiveBuffer.size() - receiveReadOffset_) + : 0; + }; + auto compactReceiveBuffer = [&]() { + if (receiveReadOffset_ == 0) return; + if (receiveReadOffset_ >= receiveBuffer.size()) { + receiveBuffer.clear(); + receiveReadOffset_ = 0; + return; + } + const size_t remaining = receiveBuffer.size() - receiveReadOffset_; + std::memmove(receiveBuffer.data(), receiveBuffer.data() + receiveReadOffset_, remaining); + receiveBuffer.resize(remaining); + receiveReadOffset_ = 0; + }; // Drain the socket. Some servers send an auth response and immediately close; a single recv() // may read the response, and a subsequent recv() can return 0 (FIN). If we disconnect right @@ -270,10 +317,42 @@ void WorldSocket::update() { if (received > 0) { receivedAny = true; ++readOps; - bytesReadThisTick += static_cast(received); - receiveBuffer.insert(receiveBuffer.end(), buffer, buffer + received); - if (receiveBuffer.size() > kMaxReceiveBufferBytes) { - LOG_ERROR("World socket receive buffer overflow (", receiveBuffer.size(), + size_t receivedSize = static_cast(received); + bytesReadThisTick += receivedSize; + if (useFastRecvAppend_) { + size_t liveBytes = bufferedBytes(); + if (liveBytes > kMaxReceiveBufferBytes || receivedSize > (kMaxReceiveBufferBytes - liveBytes)) { + compactReceiveBuffer(); + liveBytes = bufferedBytes(); + } + if (liveBytes > kMaxReceiveBufferBytes || receivedSize > (kMaxReceiveBufferBytes - liveBytes)) { + LOG_ERROR("World socket receive buffer would overflow (buffered=", liveBytes, + " incoming=", receivedSize, " max=", kMaxReceiveBufferBytes, + "). Disconnecting to recover framing."); + disconnect(); + return; + } + const size_t oldSize = receiveBuffer.size(); + const size_t needed = oldSize + receivedSize; + if (receiveBuffer.capacity() < needed) { + size_t newCap = receiveBuffer.capacity() ? receiveBuffer.capacity() : 64 * 1024; + while (newCap < needed && newCap < kMaxReceiveBufferBytes) { + newCap = std::min(kMaxReceiveBufferBytes, newCap * 2); + } + if (newCap < needed) { + LOG_ERROR("World socket receive buffer capacity growth failed (needed=", needed, + " max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing."); + disconnect(); + return; + } + receiveBuffer.reserve(newCap); + } + receiveBuffer.insert(receiveBuffer.end(), buffer, buffer + receivedSize); + } else { + receiveBuffer.insert(receiveBuffer.end(), buffer, buffer + received); + } + if (bufferedBytes() > kMaxReceiveBufferBytes) { + LOG_ERROR("World socket receive buffer overflow (", bufferedBytes(), " bytes). Disconnecting to recover framing."); disconnect(); return; @@ -297,26 +376,29 @@ void WorldSocket::update() { } if (receivedAny) { - LOG_DEBUG("World socket read ", bytesReadThisTick, " bytes in ", readOps, - " recv call(s), buffered=", receiveBuffer.size()); - // Hex dump received bytes for auth debugging - if (bytesReadThisTick <= 128) { + const bool debugLog = core::Logger::getInstance().shouldLog(core::LogLevel::DEBUG); + if (debugLog) { + LOG_DEBUG("World socket read ", bytesReadThisTick, " bytes in ", readOps, + " recv call(s), buffered=", bufferedBytes()); + } + // Hex dump received bytes for auth debugging (debug-only to avoid per-frame string work) + if (debugLog && bytesReadThisTick <= 128) { std::string hex; - for (size_t i = 0; i < receiveBuffer.size(); ++i) { + for (size_t i = receiveReadOffset_; i < receiveBuffer.size(); ++i) { char buf[4]; snprintf(buf, sizeof(buf), "%02x ", receiveBuffer[i]); hex += buf; } LOG_DEBUG("World socket raw bytes: ", hex); } tryParsePackets(); - if (connected && !receiveBuffer.empty()) { - LOG_DEBUG("World socket parse left ", receiveBuffer.size(), + if (debugLog && connected && bufferedBytes() > 0) { + LOG_DEBUG("World socket parse left ", bufferedBytes(), " bytes buffered (awaiting complete packet)"); } } if (sawClose) { LOG_INFO("World server connection closed (receivedAny=", receivedAny, - " buffered=", receiveBuffer.size(), ")"); + " buffered=", bufferedBytes(), ")"); disconnect(); return; } @@ -325,27 +407,44 @@ void WorldSocket::update() { void WorldSocket::tryParsePackets() { // World server packets have 4-byte incoming header: size(2) + opcode(2) int parsedThisTick = 0; - while (receiveBuffer.size() >= 4 && parsedThisTick < kMaxParsedPacketsPerUpdate) { + size_t parseOffset = receiveReadOffset_; + size_t localHeaderBytesDecrypted = headerBytesDecrypted; + std::vector parsedPacketsLocal; + std::vector* parsedPackets = &parsedPacketsLocal; + if (useParseScratchQueue_) { + parsedPacketsScratch_.clear(); + // Keep a warm queue to reduce steady-state allocations, but avoid + // retaining pathological capacity after burst/misaligned streams. + if (parsedPacketsScratch_.capacity() > 1024) { + std::vector().swap(parsedPacketsScratch_); + } else if (parsedPacketsScratch_.capacity() < 64) { + parsedPacketsScratch_.reserve(64); + } + parsedPackets = &parsedPacketsScratch_; + } else { + parsedPacketsLocal.reserve(32); + } + while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < kMaxParsedPacketsPerUpdate) { uint8_t rawHeader[4] = {0, 0, 0, 0}; - std::memcpy(rawHeader, receiveBuffer.data(), 4); + std::memcpy(rawHeader, receiveBuffer.data() + parseOffset, 4); // Decrypt header bytes in-place if encryption is enabled // Only decrypt bytes we haven't already decrypted - if (encryptionEnabled && headerBytesDecrypted < 4) { - size_t toDecrypt = 4 - headerBytesDecrypted; + if (encryptionEnabled && localHeaderBytesDecrypted < 4) { + size_t toDecrypt = 4 - localHeaderBytesDecrypted; if (useVanillaCrypt) { - vanillaCrypt.decrypt(receiveBuffer.data() + headerBytesDecrypted, toDecrypt); + vanillaCrypt.decrypt(receiveBuffer.data() + parseOffset + localHeaderBytesDecrypted, toDecrypt); } else { - decryptCipher.process(receiveBuffer.data() + headerBytesDecrypted, toDecrypt); + decryptCipher.process(receiveBuffer.data() + parseOffset + localHeaderBytesDecrypted, toDecrypt); } - headerBytesDecrypted = 4; + localHeaderBytesDecrypted = 4; } // Parse header (now decrypted in-place). // Size: 2 bytes big-endian. For world packets, this includes opcode bytes. - uint16_t size = (receiveBuffer[0] << 8) | receiveBuffer[1]; + uint16_t size = (receiveBuffer[parseOffset + 0] << 8) | receiveBuffer[parseOffset + 1]; // Opcode: 2 bytes little-endian. - uint16_t opcode = receiveBuffer[2] | (receiveBuffer[3] << 8); + uint16_t opcode = receiveBuffer[parseOffset + 2] | (receiveBuffer[parseOffset + 3] << 8); if (size < 2) { LOG_ERROR("World packet framing desync: invalid size=", size, " rawHdr=", std::hex, @@ -381,50 +480,79 @@ void WorldSocket::tryParsePackets() { static_cast(rawHeader[2]), " ", static_cast(rawHeader[3]), " dec=", - static_cast(receiveBuffer[0]), " ", - static_cast(receiveBuffer[1]), " ", - static_cast(receiveBuffer[2]), " ", - static_cast(receiveBuffer[3]), + static_cast(receiveBuffer[parseOffset + 0]), " ", + static_cast(receiveBuffer[parseOffset + 1]), " ", + static_cast(receiveBuffer[parseOffset + 2]), " ", + static_cast(receiveBuffer[parseOffset + 3]), std::dec, " size=", size, " payload=", payloadLen, " opcode=0x", std::hex, opcode, std::dec, - " buffered=", receiveBuffer.size()); + " buffered=", (receiveBuffer.size() - parseOffset)); --headerTracePacketsLeft; } if (isLoginPipelineSmsg(opcode)) { LOG_INFO("WS RX LOGIN opcode=0x", std::hex, opcode, std::dec, " size=", size, " payload=", payloadLen, - " buffered=", receiveBuffer.size(), + " buffered=", (receiveBuffer.size() - parseOffset), " enc=", encryptionEnabled ? "yes" : "no"); } - if (receiveBuffer.size() < totalSize) { + if ((receiveBuffer.size() - parseOffset) < totalSize) { // Not enough data yet - header stays decrypted in buffer break; } - // Extract payload (skip header) - std::vector packetData(receiveBuffer.begin() + 4, - receiveBuffer.begin() + totalSize); - - // Create packet with opcode and payload - Packet packet(opcode, packetData); - - // Remove parsed data from buffer and reset header decryption counter - receiveBuffer.erase(receiveBuffer.begin(), receiveBuffer.begin() + totalSize); - headerBytesDecrypted = 0; - - // Call callback if set - if (packetCallback) { - packetCallback(packet); + // Extract payload (skip header). Guard allocation failures so malformed + // streams cannot unwind into application-level OOM crashes. + try { + std::vector packetData(payloadLen); + if (payloadLen > 0) { + std::memcpy(packetData.data(), receiveBuffer.data() + parseOffset + 4, payloadLen); + } + // Queue packet; callbacks run after buffer state is finalized. + parsedPackets->emplace_back(opcode, std::move(packetData)); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM while queuing world packet opcode=0x", std::hex, opcode, std::dec, + " payload=", payloadLen, " buffered=", receiveBuffer.size(), + " parseOffset=", parseOffset, " what=", e.what(), + ". Disconnecting to recover."); + disconnect(); + return; } + parseOffset += totalSize; + localHeaderBytesDecrypted = 0; ++parsedThisTick; } - if (parsedThisTick >= kMaxParsedPacketsPerUpdate && receiveBuffer.size() >= 4) { + if (parseOffset > receiveReadOffset_) { + receiveReadOffset_ = parseOffset; + // Compact lazily to avoid front-erase memmove every update. + if (receiveReadOffset_ >= receiveBuffer.size()) { + receiveBuffer.clear(); + receiveReadOffset_ = 0; + } else if (receiveReadOffset_ >= 64 * 1024 || receiveReadOffset_ * 2 >= receiveBuffer.size()) { + const size_t remaining = receiveBuffer.size() - receiveReadOffset_; + std::memmove(receiveBuffer.data(), receiveBuffer.data() + receiveReadOffset_, remaining); + receiveBuffer.resize(remaining); + receiveReadOffset_ = 0; + } + } + headerBytesDecrypted = localHeaderBytesDecrypted; + + if (packetCallback) { + for (const auto& packet : *parsedPackets) { + if (!connected) break; + packetCallback(packet); + } + } + + const size_t buffered = (receiveBuffer.size() >= receiveReadOffset_) + ? (receiveBuffer.size() - receiveReadOffset_) + : 0; + if (parsedThisTick >= kMaxParsedPacketsPerUpdate && buffered >= 4) { LOG_DEBUG("World socket parse budget reached (", parsedThisTick, - " packets); deferring remaining buffered data=", receiveBuffer.size(), " bytes"); + " packets); deferring remaining buffered data=", buffered, " bytes"); } } diff --git a/src/pipeline/adt_loader.cpp b/src/pipeline/adt_loader.cpp index fff4d07c..af3da5ce 100644 --- a/src/pipeline/adt_loader.cpp +++ b/src/pipeline/adt_loader.cpp @@ -13,25 +13,13 @@ float HeightMap::getHeight(int x, int y) const { return 0.0f; } - // WoW uses 9x9 outer + 8x8 inner vertex layout - // Outer vertices: 0-80 (9x9 grid) - // Inner vertices: 81-144 (8x8 grid between outer vertices) - - // Calculate index based on vertex type - int index; - if (x < 9 && y < 9) { - // Outer vertex - index = y * 9 + x; - } else { - // Inner vertex (between outer vertices) - int innerX = x - 1; - int innerY = y - 1; - if (innerX >= 0 && innerX < 8 && innerY >= 0 && innerY < 8) { - index = 81 + innerY * 8 + innerX; - } else { - return 0.0f; - } - } + // MCVT heights are stored in interleaved 9x17 row-major layout: + // Row 0: 9 outer (indices 0-8), then 8 inner (indices 9-16) + // Row 1: 9 outer (indices 17-25), then 8 inner (indices 26-33) + // ... + // Outer vertex (x, y) is at index: y * 17 + x + int index = y * 17 + x; + if (index < 0 || index >= 145) return 0.0f; return heights[index]; } @@ -328,10 +316,14 @@ void ADTLoader::parseMCNK(const uint8_t* data, size_t size, int chunkIndex, ADTT " holes=0x", std::hex, chunk.holes, std::dec); } - // Position (stored at offset 0x68 = 104 in MCNK header) - chunk.position[0] = readFloat(data, 104); // X - chunk.position[1] = readFloat(data, 108); // Y - chunk.position[2] = readFloat(data, 112); // Z + // MCNK position is in canonical WoW coordinates (NOT ADT placement space): + // offset 104: wowY (west axis, horizontal β€” unused, XY computed from tile indices) + // offset 108: wowX (north axis, horizontal β€” unused, XY computed from tile indices) + // offset 112: wowZ = HEIGHT BASE (MCVT heights are relative to this) + chunk.position[0] = readFloat(data, 104); // wowY (unused) + chunk.position[1] = readFloat(data, 108); // wowX (unused) + chunk.position[2] = readFloat(data, 112); // wowZ = height base + // Parse sub-chunks using offsets from MCNK header // WoW ADT sub-chunks may have their own 8-byte headers (magic+size) @@ -409,7 +401,11 @@ void ADTLoader::parseMCVT(const uint8_t* data, size_t size, MapChunk& chunk) { // Log height range for first chunk only static bool logged = false; if (!logged) { - LOG_DEBUG("MCVT height range: [", minHeight, ", ", maxHeight, "]"); + LOG_INFO("MCVT height range: [", minHeight, ", ", maxHeight, "]", + " (heights[0]=", chunk.heightMap.heights[0], + " heights[8]=", chunk.heightMap.heights[8], + " heights[136]=", chunk.heightMap.heights[136], + " heights[144]=", chunk.heightMap.heights[144], ")"); logged = true; } } diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 7a3df05d..bacb3aa5 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -29,6 +29,19 @@ size_t parseEnvSizeMB(const char* name) { } return static_cast(mb); } + +size_t parseEnvCount(const char* name, size_t defValue) { + const char* v = std::getenv(name); + if (!v || !*v) { + return defValue; + } + char* end = nullptr; + unsigned long long n = std::strtoull(v, &end, 10); + if (end == v || n == 0) { + return defValue; + } + return static_cast(n); +} } // namespace AssetManager::AssetManager() = default; @@ -147,8 +160,16 @@ BLPImage AssetManager::loadTexture(const std::string& path) { std::vector blpData = readFile(normalizedPath); if (blpData.empty()) { static std::unordered_set loggedMissingTextures; - if (loggedMissingTextures.insert(normalizedPath).second) { + static bool missingTextureLogSuppressed = false; + static const size_t kMaxMissingTextureLogKeys = + parseEnvCount("WOWEE_TEXTURE_MISS_LOG_KEYS", 400); + if (loggedMissingTextures.size() < kMaxMissingTextureLogKeys && + loggedMissingTextures.insert(normalizedPath).second) { LOG_WARNING("Texture not found: ", normalizedPath); + } else if (!missingTextureLogSuppressed && loggedMissingTextures.size() >= kMaxMissingTextureLogKeys) { + LOG_WARNING("Texture-not-found warning key cache reached ", kMaxMissingTextureLogKeys, + " entries; suppressing new unique texture-miss logs"); + missingTextureLogSuppressed = true; } return BLPImage(); } @@ -156,8 +177,16 @@ BLPImage AssetManager::loadTexture(const std::string& path) { BLPImage image = BLPLoader::load(blpData); if (!image.isValid()) { static std::unordered_set loggedDecodeFails; - if (loggedDecodeFails.insert(normalizedPath).second) { + static bool decodeFailLogSuppressed = false; + static const size_t kMaxDecodeFailLogKeys = + parseEnvCount("WOWEE_TEXTURE_DECODE_LOG_KEYS", 200); + if (loggedDecodeFails.size() < kMaxDecodeFailLogKeys && + loggedDecodeFails.insert(normalizedPath).second) { LOG_ERROR("Failed to load texture: ", normalizedPath); + } else if (!decodeFailLogSuppressed && loggedDecodeFails.size() >= kMaxDecodeFailLogKeys) { + LOG_WARNING("Texture-decode warning key cache reached ", kMaxDecodeFailLogKeys, + " entries; suppressing new unique decode-failure logs"); + decodeFailLogSuppressed = true; } return BLPImage(); } diff --git a/src/pipeline/terrain_mesh.cpp b/src/pipeline/terrain_mesh.cpp index 9af14144..efb4f9da 100644 --- a/src/pipeline/terrain_mesh.cpp +++ b/src/pipeline/terrain_mesh.cpp @@ -1,4 +1,5 @@ #include "pipeline/terrain_mesh.hpp" +#include "core/coordinates.hpp" #include "core/logger.hpp" #include @@ -40,6 +41,7 @@ TerrainMesh TerrainMeshGenerator::generate(const ADTTerrain& terrain) { mesh.validChunkCount = validCount; + return mesh; } @@ -49,10 +51,24 @@ ChunkMesh TerrainMeshGenerator::generateChunkMesh(const MapChunk& chunk, int chu mesh.chunkX = chunkX; mesh.chunkY = chunkY; - // World position from chunk data - mesh.worldX = chunk.position[0]; - mesh.worldY = chunk.position[1]; - mesh.worldZ = chunk.position[2]; + // Compute render-space XY from tile/chunk indices (MCNK position fields are unreliable). + // tileX increases southward (renderY axis), tileY increases eastward (renderX axis). + // NW corner of tile: renderX = (32-tileY)*TILE_SIZE, renderY = (32-tileX)*TILE_SIZE + // Each chunk step goes east (–renderX) or south (–renderY). + const float tileNW_renderX = (32.0f - static_cast(tileY)) * core::coords::TILE_SIZE; + const float tileNW_renderY = (32.0f - static_cast(tileX)) * core::coords::TILE_SIZE; + mesh.worldX = tileNW_renderX - static_cast(chunkY) * CHUNK_SIZE; // iy controls renderX (east-west) + mesh.worldY = tileNW_renderY - static_cast(chunkX) * CHUNK_SIZE; // ix controls renderY (north-south) + mesh.worldZ = chunk.position[2]; // height base (wowZ) from MCNK offset 112 + + // Debug: log chunk positions for first tile + static int posLogCount = 0; + if (posLogCount < 5) { + posLogCount++; + LOG_INFO("Terrain chunk: tile(", tileX, ",", tileY, ") ix=", chunkX, " iy=", chunkY, + " worldXY=(", mesh.worldX, ",", mesh.worldY, ",", mesh.worldZ, ")", + " mcnk=(", chunk.position[0], ",", chunk.position[1], ",", chunk.position[2], ")"); + } // Generate vertices from heightmap (pass chunk grid indices and tile coords) mesh.vertices = generateVertices(chunk, chunkX, chunkY, tileX, tileY); @@ -167,19 +183,21 @@ ChunkMesh TerrainMeshGenerator::generateChunkMesh(const MapChunk& chunk, int chu return mesh; } -std::vector TerrainMeshGenerator::generateVertices(const MapChunk& chunk, [[maybe_unused]] int chunkX, [[maybe_unused]] int chunkY, [[maybe_unused]] int tileX, [[maybe_unused]] int tileY) { +std::vector TerrainMeshGenerator::generateVertices(const MapChunk& chunk, int chunkX, int chunkY, int tileX, int tileY) { std::vector vertices; vertices.reserve(145); // 145 vertices total const HeightMap& heightMap = chunk.heightMap; // WoW terrain uses 145 heights stored in a 9x17 row-major grid layout - const float unitSize = CHUNK_SIZE / 8.0f; // 66.67 units per vertex step + const float unitSize = CHUNK_SIZE / 8.0f; // 33.333/8 units per vertex step - // chunk.position contains world coordinates for this chunk's origin - // Both X and Y are at world scale (no scaling needed) - float chunkBaseX = chunk.position[0]; - float chunkBaseY = chunk.position[1]; + // Compute render-space base from tile/chunk indices (same formula as generateChunkMesh). + const float tileNW_renderX = (32.0f - static_cast(tileY)) * core::coords::TILE_SIZE; + const float tileNW_renderY = (32.0f - static_cast(tileX)) * core::coords::TILE_SIZE; + float chunkBaseX = tileNW_renderX - static_cast(chunkY) * CHUNK_SIZE; // iy controls renderX (east-west) + float chunkBaseY = tileNW_renderY - static_cast(chunkX) * CHUNK_SIZE; // ix controls renderY (north-south) + float chunkBaseZ = chunk.position[2]; // height base (wowZ) from MCNK offset 112 for (int index = 0; index < 145; index++) { int y = index / 17; // Row (0-8) @@ -196,11 +214,12 @@ std::vector TerrainMeshGenerator::generateVertices(const MapChunk TerrainVertex vertex; - // Position - match wowee.js coordinate layout (swap X/Y and negate) - // wowee.js: X = -(y * unitSize), Y = -(x * unitSize) - vertex.position[0] = chunkBaseX - (offsetY * unitSize); - vertex.position[1] = chunkBaseY - (offsetX * unitSize); - vertex.position[2] = chunk.position[2] + heightMap.heights[index]; + // Position in render space: + // MCVT rows (offsetY) go westβ†’east = renderX decreasing + // MCVT columns (offsetX) go northβ†’south = renderY decreasing + vertex.position[0] = chunkBaseX - (offsetY * unitSize); // renderX (row = westβ†’east) + vertex.position[1] = chunkBaseY - (offsetX * unitSize); // renderY (col = northβ†’south) + vertex.position[2] = chunkBaseZ + heightMap.heights[index]; // renderZ // Normal if (index * 3 + 2 < static_cast(chunk.normals.size())) { diff --git a/src/pipeline/wmo_loader.cpp b/src/pipeline/wmo_loader.cpp index 1c32f0e7..450957e2 100644 --- a/src/pipeline/wmo_loader.cpp +++ b/src/pipeline/wmo_loader.cpp @@ -192,7 +192,10 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { } case MOGN: { - // Group names + // Group names β€” store raw chunk for offset-based lookup (MOGI nameOffset) + if (chunkSize > 0 && chunkEnd <= wmoData.size()) { + model.groupNameRaw.assign(wmoData.begin() + chunkStart, wmoData.begin() + chunkEnd); + } uint32_t nameOffset = chunkStart; while (nameOffset < chunkEnd) { std::string name = readString(wmoData, nameOffset); @@ -426,9 +429,11 @@ bool WMOLoader::loadGroup(const std::vector& groupData, } // Read MOGP header - // NOTE: In WMO group files, the MOGP data starts directly at flags - // (groupName/descriptiveGroupName are handled by the root WMO's MOGI chunk). + // MOGP starts with groupName(4) + descriptiveName(4) offsets into MOGN, + // followed by flags at offset +8. uint32_t mogpOffset = offset; + mogpOffset += 4; // skip groupName offset + mogpOffset += 4; // skip descriptiveGroupName offset group.flags = read(groupData, mogpOffset); bool isInterior = (group.flags & 0x2000) != 0; core::Logger::getInstance().debug(" Group flags: 0x", std::hex, group.flags, std::dec, @@ -439,14 +444,14 @@ bool WMOLoader::loadGroup(const std::vector& groupData, group.boundingBoxMax.x = read(groupData, mogpOffset); group.boundingBoxMax.y = read(groupData, mogpOffset); group.boundingBoxMax.z = read(groupData, mogpOffset); - mogpOffset += 4; // nameOffset group.portalStart = read(groupData, mogpOffset); group.portalCount = read(groupData, mogpOffset); mogpOffset += 8; // transBatchCount, intBatchCount, extBatchCount, padding - group.fogIndices[0] = read(groupData, mogpOffset); - group.fogIndices[1] = read(groupData, mogpOffset); - group.fogIndices[2] = read(groupData, mogpOffset); - group.fogIndices[3] = read(groupData, mogpOffset); + // fogIndices: 4 Γ— uint8 (4 bytes total, NOT 4 Γ— uint32) + group.fogIndices[0] = read(groupData, mogpOffset); + group.fogIndices[1] = read(groupData, mogpOffset); + group.fogIndices[2] = read(groupData, mogpOffset); + group.fogIndices[3] = read(groupData, mogpOffset); group.liquidType = read(groupData, mogpOffset); // Skip to end of 68-byte header mogpOffset = offset + 68; @@ -597,7 +602,17 @@ bool WMOLoader::loadGroup(const std::vector& groupData, group.liquid.heights.clear(); group.liquid.flags.clear(); - if (vertexCount > 0 && bytesRemaining >= vertexCount * sizeof(float)) { + // MLIQ vertex data: each vertex is 8 bytes β€” + // 4 bytes flow/unknown data + 4 bytes float height. + const size_t VERTEX_STRIDE = 8; // bytes per vertex + if (vertexCount > 0 && bytesRemaining >= vertexCount * VERTEX_STRIDE) { + group.liquid.heights.resize(vertexCount); + for (size_t i = 0; i < vertexCount; i++) { + parseOffset += 4; // skip flow/unknown data + group.liquid.heights[i] = read(groupData, parseOffset); + } + } else if (vertexCount > 0 && bytesRemaining >= vertexCount * sizeof(float)) { + // Fallback: try reading as plain floats if stride doesn't fit group.liquid.heights.resize(vertexCount); for (size_t i = 0; i < vertexCount; i++) { group.liquid.heights[i] = read(groupData, parseOffset); diff --git a/src/rendering/camera.cpp b/src/rendering/camera.cpp index 825730c0..f8b45f3c 100644 --- a/src/rendering/camera.cpp +++ b/src/rendering/camera.cpp @@ -18,6 +18,8 @@ void Camera::updateViewMatrix() { void Camera::updateProjectionMatrix() { projectionMatrix = glm::perspective(glm::radians(fov), aspectRatio, nearPlane, farPlane); + // Vulkan clip-space has Y pointing down; flip the projection's Y axis. + projectionMatrix[1][1] *= -1.0f; } glm::vec3 Camera::getForward() const { @@ -40,12 +42,15 @@ glm::vec3 Camera::getUp() const { Ray Camera::screenToWorldRay(float screenX, float screenY, float screenW, float screenH) const { float ndcX = (2.0f * screenX / screenW) - 1.0f; - float ndcY = 1.0f - (2.0f * screenY / screenH); + // Vulkan Y-flip is baked into projectionMatrix, so NDC Y maps directly: + // screen top (y=0) β†’ NDC -1, screen bottom (y=H) β†’ NDC +1 + float ndcY = (2.0f * screenY / screenH) - 1.0f; glm::mat4 invVP = glm::inverse(projectionMatrix * viewMatrix); - glm::vec4 nearPt = invVP * glm::vec4(ndcX, ndcY, -1.0f, 1.0f); - glm::vec4 farPt = invVP * glm::vec4(ndcX, ndcY, 1.0f, 1.0f); + // Vulkan / GLM_FORCE_DEPTH_ZERO_TO_ONE: NDC z ∈ [0, 1] + glm::vec4 nearPt = invVP * glm::vec4(ndcX, ndcY, 0.0f, 1.0f); + glm::vec4 farPt = invVP * glm::vec4(ndcX, ndcY, 1.0f, 1.0f); nearPt /= nearPt.w; farPt /= farPt.w; diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 050174a6..9dbc2b71 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -681,7 +681,10 @@ void CameraController::update(float deltaTime) { if (terrainManager) { terrainH = terrainManager->getHeightAt(targetPos.x, targetPos.y); } - float wmoProbeZ = std::max(targetPos.z, lastGroundZ) + stepUpBudget + 0.5f; + // When airborne, anchor probe to last ground level so the + // ceiling doesn't rise with the jump and catch roof geometry. + float wmoBaseZ = grounded ? std::max(targetPos.z, lastGroundZ) : lastGroundZ; + float wmoProbeZ = wmoBaseZ + stepUpBudget + 0.5f; float wmoNormalZ = 1.0f; if (wmoRenderer) { wmoH = wmoRenderer->getFloorHeight(targetPos.x, targetPos.y, wmoProbeZ, &wmoNormalZ); @@ -692,6 +695,13 @@ void CameraController::update(float deltaTime) { if (wmoH && wmoNormalZ < minWalkableWmo) { wmoH = std::nullopt; // Treat as unwalkable } + + // Reject WMO floors far above last known ground when airborne + // (prevents snapping to roof/ceiling surfaces during jumps) + if (wmoH && !grounded && *wmoH > lastGroundZ + stepUpBudget + 0.5f) { + wmoH = std::nullopt; + centerWmoH = std::nullopt; + } centerTerrainH = terrainH; centerWmoH = wmoH; @@ -802,7 +812,8 @@ void CameraController::update(float deltaTime) { {0.0f, WMO_FOOTPRINT}, {0.0f, -WMO_FOOTPRINT} }; - float wmoProbeZ = std::max(targetPos.z, lastGroundZ) + stepUpBudget + 0.6f; + float wmoMultiBaseZ = grounded ? std::max(targetPos.z, lastGroundZ) : lastGroundZ; + float wmoProbeZ = wmoMultiBaseZ + stepUpBudget + 0.6f; float minWalkableWmo = cachedInsideWMO ? MIN_WALKABLE_NORMAL_WMO : MIN_WALKABLE_NORMAL_TERRAIN; for (const auto& o : wmoOffsets) { @@ -811,6 +822,9 @@ void CameraController::update(float deltaTime) { if (!wh) continue; if (nz < minWalkableWmo) continue; + // Reject roof/ceiling surfaces when airborne + if (!grounded && *wh > lastGroundZ + stepUpBudget + 0.5f) continue; + // Keep to nearby, walkable steps only. if (*wh > targetPos.z + stepUpBudget) continue; if (*wh < targetPos.z - 2.5f) continue; @@ -1678,9 +1692,11 @@ void CameraController::teleportTo(const glm::vec3& pos) { } void CameraController::processMouseWheel(float delta) { - // Adjust user's target distance (collision may limit actual distance) - userTargetDistance -= delta * 2.0f; // 2.0 units per scroll notch - userTargetDistance = glm::clamp(userTargetDistance, MIN_DISTANCE, MAX_DISTANCE); + // Scale zoom speed proportionally to current distance for fine control up close + float zoomSpeed = glm::max(userTargetDistance * 0.15f, 0.3f); + userTargetDistance -= delta * zoomSpeed; + float maxDist = extendedZoom_ ? MAX_DISTANCE_EXTENDED : MAX_DISTANCE_NORMAL; + userTargetDistance = glm::clamp(userTargetDistance, MIN_DISTANCE, maxDist); } void CameraController::setFollowTarget(glm::vec3* target) { diff --git a/src/rendering/celestial.cpp b/src/rendering/celestial.cpp index 3629b284..798ac5d5 100644 --- a/src/rendering/celestial.cpp +++ b/src/rendering/celestial.cpp @@ -1,10 +1,13 @@ #include "rendering/celestial.hpp" -#include "rendering/shader.hpp" -#include "rendering/camera.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" -#include #include #include +#include namespace wowee { namespace rendering { @@ -15,564 +18,478 @@ Celestial::~Celestial() { shutdown(); } -bool Celestial::initialize() { - LOG_INFO("Initializing celestial renderer"); +bool Celestial::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { + LOG_INFO("Initializing celestial renderer (Vulkan)"); - // Create celestial shader - celestialShader = std::make_unique(); + vkCtx_ = ctx; + VkDevice device = vkCtx_->getDevice(); - // Vertex shader - billboard facing camera (sky dome locked) - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec2 aTexCoord; - - uniform mat4 model; - uniform mat4 view; - uniform mat4 projection; - - out vec2 TexCoord; - - void main() { - TexCoord = aTexCoord; - - // Sky object: remove translation, keep rotation (skybox technique) - mat4 viewNoTranslation = mat4(mat3(view)); - - gl_Position = projection * viewNoTranslation * model * vec4(aPos, 1.0); - } - )"; - - // Fragment shader - disc with glow and moon phase support - const char* fragmentShaderSource = R"( - #version 330 core - in vec2 TexCoord; - - uniform vec3 celestialColor; - uniform float intensity; - uniform float moonPhase; // 0.0 = new moon, 0.5 = full moon, 1.0 = new moon - uniform float uAnimTime; - - out vec4 FragColor; - - float hash(vec2 p) { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); - } - - float noise(vec2 p) { - vec2 i = floor(p); - vec2 f = fract(p); - f = f * f * (3.0 - 2.0 * f); - float a = hash(i + vec2(0.0, 0.0)); - float b = hash(i + vec2(1.0, 0.0)); - float c = hash(i + vec2(0.0, 1.0)); - float d = hash(i + vec2(1.0, 1.0)); - return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); - } - - void main() { - // Create circular disc - vec2 center = vec2(0.5, 0.5); - float dist = distance(TexCoord, center); - - // Core disc + glow with explicit radial mask to avoid square billboard artifact. - float disc = smoothstep(0.50, 0.38, dist); - float glow = smoothstep(0.64, 0.00, dist) * 0.24; - float radialMask = 1.0 - smoothstep(0.58, 0.70, dist); - - float alpha = (disc + glow) * radialMask * intensity; - vec3 outColor = celestialColor; - - // Very faint animated haze over sun disc/glow (no effect for moon). - if (intensity > 0.5) { - vec2 uv = (TexCoord - vec2(0.5)) * 3.0; - // Slow flow field for atmospheric-like turbulence drift. - vec2 flow = vec2( - noise(uv * 0.9 + vec2(uAnimTime * 0.012, -uAnimTime * 0.009)), - noise(uv * 0.9 + vec2(-uAnimTime * 0.010, uAnimTime * 0.011)) - ) - vec2(0.5); - vec2 warped = uv + flow * 0.42; - float n1 = noise(warped * 1.7 + vec2(uAnimTime * 0.016, -uAnimTime * 0.013)); - float n2 = noise(warped * 3.0 + vec2(-uAnimTime * 0.021, uAnimTime * 0.017)); - float haze = mix(n1, n2, 0.35); - float hazeMask = clamp(disc * 0.75 + glow * 0.28, 0.0, 1.0); - float hazeMix = hazeMask * 0.55; - float lumaMod = mix(1.0, 0.93 + haze * 0.10, hazeMix); - outColor *= lumaMod; - alpha *= mix(1.0, 0.94 + haze * 0.06, hazeMix); - } - - // Apply moon phase shadow (only for moon, indicated by low intensity) - if (intensity < 0.5) { // Moon has lower intensity than sun - // Calculate phase position (-1 to 1, where 0 is center) - float phasePos = (moonPhase - 0.5) * 2.0; - - // Distance from phase terminator line - float x = (TexCoord.x - 0.5) * 2.0; // -1 to 1 - - // Create shadow using smoothstep - float shadow = 1.0; - - if (moonPhase < 0.5) { - // Waning (right to left shadow) - shadow = smoothstep(phasePos - 0.1, phasePos + 0.1, x); - } else { - // Waxing (left to right shadow) - shadow = smoothstep(phasePos - 0.1, phasePos + 0.1, -x); - } - - // Apply elliptical terminator for 3D effect - float y = (TexCoord.y - 0.5) * 2.0; - float ellipse = sqrt(max(0.0, 1.0 - y * y)); - float terminatorX = phasePos / ellipse; - - if (moonPhase < 0.5) { - shadow = smoothstep(terminatorX - 0.15, terminatorX + 0.15, x); - } else { - shadow = smoothstep(terminatorX - 0.15, terminatorX + 0.15, -x); - } - - // Darken shadowed area (not completely black, slight glow remains) - alpha *= mix(0.05, 1.0, shadow); - } - - if (alpha < 0.01) { - discard; - } - - FragColor = vec4(outColor, alpha); - } - )"; - - if (!celestialShader->loadFromSource(vertexShaderSource, fragmentShaderSource)) { - LOG_ERROR("Failed to create celestial shader"); + // ------------------------------------------------------------------ shaders + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/celestial.vert.spv")) { + LOG_ERROR("Failed to load celestial vertex shader"); return false; } - // Create billboard quad - createCelestialQuad(); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/celestial.frag.spv")) { + LOG_ERROR("Failed to load celestial fragment shader"); + return false; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // ------------------------------------------------------------------ push constants + // Layout: mat4(64) + vec4(16) + float*3(12) + pad(4) = 96 bytes + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(CelestialPush); // 96 bytes + + // ------------------------------------------------------------------ pipeline layout + pipelineLayout_ = createPipelineLayout(device, {perFrameLayout}, {pushRange}); + if (pipelineLayout_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create celestial pipeline layout"); + return false; + } + + // ------------------------------------------------------------------ vertex input + // Vertex: vec3 pos + vec2 texCoord, stride = 20 bytes + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); // 20 bytes + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + VkVertexInputAttributeDescription uvAttr{}; + uvAttr.location = 1; + uvAttr.binding = 0; + uvAttr.format = VK_FORMAT_R32G32_SFLOAT; + uvAttr.offset = 3 * sizeof(float); + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + // ------------------------------------------------------------------ pipeline + pipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, uvAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) // test on, write off (sky layer) + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create celestial pipeline"); + return false; + } + + // ------------------------------------------------------------------ geometry + createQuad(); LOG_INFO("Celestial renderer initialized"); return true; } -void Celestial::shutdown() { - destroyCelestialQuad(); - celestialShader.reset(); -} +void Celestial::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); -void Celestial::render(const Camera& camera, float timeOfDay, - const glm::vec3* sunDir, const glm::vec3* sunColor, float gameTime) { - if (!renderingEnabled || vao == 0 || !celestialShader) { + if (pipeline_ != VK_NULL_HANDLE) { vkDestroyPipeline(device, pipeline_, nullptr); pipeline_ = VK_NULL_HANDLE; } + + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/celestial.vert.spv")) { + LOG_ERROR("Celestial::recreatePipelines: failed to load vertex shader"); + return; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/celestial.frag.spv")) { + LOG_ERROR("Celestial::recreatePipelines: failed to load fragment shader"); + vertModule.destroy(); return; } - // Update moon phases from game time if available (deterministic) + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // Vertex input (same as initialize) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + VkVertexInputAttributeDescription uvAttr{}; + uvAttr.location = 1; + uvAttr.binding = 0; + uvAttr.format = VK_FORMAT_R32G32_SFLOAT; + uvAttr.offset = 3 * sizeof(float); + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, uvAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline_ == VK_NULL_HANDLE) { + LOG_ERROR("Celestial::recreatePipelines: failed to create pipeline"); + } +} + +void Celestial::shutdown() { + destroyQuad(); + + if (vkCtx_) { + VkDevice device = vkCtx_->getDevice(); + if (pipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline_, nullptr); + pipeline_ = VK_NULL_HANDLE; + } + if (pipelineLayout_ != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); + pipelineLayout_ = VK_NULL_HANDLE; + } + } + + vkCtx_ = nullptr; +} + +// --------------------------------------------------------------------------- +// Public render entry point +// --------------------------------------------------------------------------- + +void Celestial::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, + float timeOfDay, + const glm::vec3* sunDir, const glm::vec3* sunColor, + float gameTime) { + if (!renderingEnabled_ || pipeline_ == VK_NULL_HANDLE) { + return; + } + + // Update moon phases from server game time if provided if (gameTime >= 0.0f) { updatePhasesFromGameTime(gameTime); } - // Enable additive blending for celestial glow (brighter against sky) - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blending for brightness + // Bind pipeline and per-frame descriptor set once β€” reused for all draws + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); - // Disable depth testing entirely - celestial bodies render "on" the sky - glDisable(GL_DEPTH_TEST); - glDepthMask(GL_FALSE); - - // Disable culling - billboards can face either way - glDisable(GL_CULL_FACE); - - // Render sun with alpha blending (avoids additive white clipping). - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - renderSun(camera, timeOfDay, sunDir, sunColor); - - // Render moons additively for glow. - glBlendFunc(GL_SRC_ALPHA, GL_ONE); - renderMoon(camera, timeOfDay); // White Lady (primary moon) + // Bind the shared quad buffers + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer_, &offset); + vkCmdBindIndexBuffer(cmd, indexBuffer_, 0, VK_INDEX_TYPE_UINT32); + // Draw sun, then moon(s) β€” each call pushes different constants + renderSun(cmd, perFrameSet, timeOfDay, sunDir, sunColor); + renderMoon(cmd, perFrameSet, timeOfDay); if (dualMoonMode_) { - renderBlueChild(camera, timeOfDay); // Blue Child (secondary moon) + renderBlueChild(cmd, perFrameSet, timeOfDay); } - - // Restore state - glEnable(GL_DEPTH_TEST); - glDepthMask(GL_TRUE); - glDisable(GL_BLEND); - glEnable(GL_CULL_FACE); } -void Celestial::renderSun(const Camera& camera, float timeOfDay, - const glm::vec3* sunDir, const glm::vec3* sunColor) { - // Sun visible from 5:00 to 19:00 +// --------------------------------------------------------------------------- +// Private per-body render helpers +// --------------------------------------------------------------------------- + +void Celestial::renderSun(VkCommandBuffer cmd, VkDescriptorSet /*perFrameSet*/, + float timeOfDay, + const glm::vec3* sunDir, const glm::vec3* sunColor) { + // Sun visible 5:00–19:00 if (timeOfDay < 5.0f || timeOfDay >= 19.0f) { return; } - celestialShader->use(); - - // Prefer opposite of light-ray direction (sun->world), but guard against - // profile/convention mismatches that can place the sun below the horizon. + // Resolve sun direction β€” prefer opposite of incoming light ray, clamp below horizon glm::vec3 lightDir = sunDir ? glm::normalize(*sunDir) : glm::vec3(0.0f, 0.0f, -1.0f); glm::vec3 dir = -lightDir; if (dir.z < 0.0f) { dir = lightDir; } - // Place sun on sky sphere at fixed distance const float sunDistance = 800.0f; glm::vec3 sunPos = dir * sunDistance; - // Create model matrix glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, sunPos); - model = glm::scale(model, glm::vec3(95.0f, 95.0f, 1.0f)); // Match WotLK-like apparent size + model = glm::scale(model, glm::vec3(95.0f, 95.0f, 1.0f)); - // Set uniforms - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); - - celestialShader->setUniform("model", model); - celestialShader->setUniform("view", view); - celestialShader->setUniform("projection", projection); - - // Sun color and intensity (use lighting color if provided) glm::vec3 color = sunColor ? *sunColor : getSunColor(timeOfDay); - // Force strong warm/yellow tint; avoid white blowout. const glm::vec3 warmSun(1.0f, 0.88f, 0.55f); color = glm::mix(color, warmSun, 0.52f); float intensity = getSunIntensity(timeOfDay) * 0.92f; - celestialShader->setUniform("celestialColor", color); - celestialShader->setUniform("intensity", intensity); - celestialShader->setUniform("moonPhase", 0.5f); // Sun doesn't use this, but shader expects it - celestialShader->setUniform("uAnimTime", sunHazeTimer_); + CelestialPush push{}; + push.model = model; + push.celestialColor = glm::vec4(color, 1.0f); + push.intensity = intensity; + push.moonPhase = 0.5f; // unused for sun + push.animTime = sunHazeTimer_; - // Render quad - glBindVertexArray(vao); - glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr); - glBindVertexArray(0); + vkCmdPushConstants(cmd, pipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); + + vkCmdDrawIndexed(cmd, 6, 1, 0, 0, 0); } -void Celestial::renderMoon(const Camera& camera, float timeOfDay) { - // Moon visible from 19:00 to 5:00 (night) +void Celestial::renderMoon(VkCommandBuffer cmd, VkDescriptorSet /*perFrameSet*/, + float timeOfDay) { + // Moon (White Lady) visible 19:00–5:00 if (timeOfDay >= 5.0f && timeOfDay < 19.0f) { return; } - celestialShader->use(); - - // Get moon position glm::vec3 moonPos = getMoonPosition(timeOfDay); - // Create model matrix glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, moonPos); - model = glm::scale(model, glm::vec3(40.0f, 40.0f, 1.0f)); // 40 unit diameter (smaller than sun) + model = glm::scale(model, glm::vec3(40.0f, 40.0f, 1.0f)); - // Set uniforms - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); - - celestialShader->setUniform("model", model); - celestialShader->setUniform("view", view); - celestialShader->setUniform("projection", projection); - - // Moon color (pale blue-white) and intensity glm::vec3 color = glm::vec3(0.8f, 0.85f, 1.0f); - // Fade in/out at transitions float intensity = 1.0f; if (timeOfDay >= 19.0f && timeOfDay < 21.0f) { - // Fade in (19:00-21:00) - intensity = (timeOfDay - 19.0f) / 2.0f; - } - else if (timeOfDay >= 3.0f && timeOfDay < 5.0f) { - // Fade out (3:00-5:00) - intensity = 1.0f - (timeOfDay - 3.0f) / 2.0f; + intensity = (timeOfDay - 19.0f) / 2.0f; // Fade in + } else if (timeOfDay >= 3.0f && timeOfDay < 5.0f) { + intensity = 1.0f - (timeOfDay - 3.0f) / 2.0f; // Fade out } - celestialShader->setUniform("celestialColor", color); - celestialShader->setUniform("intensity", intensity); - celestialShader->setUniform("moonPhase", whiteLadyPhase_); - celestialShader->setUniform("uAnimTime", sunHazeTimer_); + CelestialPush push{}; + push.model = model; + push.celestialColor = glm::vec4(color, 1.0f); + push.intensity = intensity; + push.moonPhase = whiteLadyPhase_; + push.animTime = sunHazeTimer_; - // Render quad - glBindVertexArray(vao); - glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr); - glBindVertexArray(0); + vkCmdPushConstants(cmd, pipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); + + vkCmdDrawIndexed(cmd, 6, 1, 0, 0, 0); } -void Celestial::renderBlueChild(const Camera& camera, float timeOfDay) { - // Blue Child visible from 19:00 to 5:00 (night, same as White Lady) +void Celestial::renderBlueChild(VkCommandBuffer cmd, VkDescriptorSet /*perFrameSet*/, + float timeOfDay) { + // Blue Child visible 19:00–5:00 if (timeOfDay >= 5.0f && timeOfDay < 19.0f) { return; } - celestialShader->use(); - - // Get moon position (offset slightly from White Lady) + // Offset slightly from White Lady glm::vec3 moonPos = getMoonPosition(timeOfDay); - // Offset Blue Child to the right and slightly lower - moonPos.x += 80.0f; // Right offset - moonPos.z -= 40.0f; // Slightly lower + moonPos.x += 80.0f; + moonPos.z -= 40.0f; - // Create model matrix (smaller than White Lady) glm::mat4 model = glm::mat4(1.0f); model = glm::translate(model, moonPos); - model = glm::scale(model, glm::vec3(30.0f, 30.0f, 1.0f)); // 30 unit diameter (smaller) + model = glm::scale(model, glm::vec3(30.0f, 30.0f, 1.0f)); - // Set uniforms - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); - - celestialShader->setUniform("model", model); - celestialShader->setUniform("view", view); - celestialShader->setUniform("projection", projection); - - // Blue Child color (pale blue tint) glm::vec3 color = glm::vec3(0.7f, 0.8f, 1.0f); - // Fade in/out at transitions (same as White Lady) float intensity = 1.0f; if (timeOfDay >= 19.0f && timeOfDay < 21.0f) { - // Fade in (19:00-21:00) intensity = (timeOfDay - 19.0f) / 2.0f; - } - else if (timeOfDay >= 3.0f && timeOfDay < 5.0f) { - // Fade out (3:00-5:00) + } else if (timeOfDay >= 3.0f && timeOfDay < 5.0f) { intensity = 1.0f - (timeOfDay - 3.0f) / 2.0f; } + intensity *= 0.7f; // Blue Child is dimmer - // Blue Child is dimmer than White Lady - intensity *= 0.7f; + CelestialPush push{}; + push.model = model; + push.celestialColor = glm::vec4(color, 1.0f); + push.intensity = intensity; + push.moonPhase = blueChildPhase_; + push.animTime = sunHazeTimer_; - celestialShader->setUniform("celestialColor", color); - celestialShader->setUniform("intensity", intensity); - celestialShader->setUniform("moonPhase", blueChildPhase_); - celestialShader->setUniform("uAnimTime", sunHazeTimer_); + vkCmdPushConstants(cmd, pipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); - // Render quad - glBindVertexArray(vao); - glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr); - glBindVertexArray(0); + vkCmdDrawIndexed(cmd, 6, 1, 0, 0, 0); } +// --------------------------------------------------------------------------- +// Position / colour query helpers (identical logic to GL version) +// --------------------------------------------------------------------------- + glm::vec3 Celestial::getSunPosition(float timeOfDay) const { - // Sun rises at 6:00, peaks at 12:00, sets at 18:00 float angle = calculateCelestialAngle(timeOfDay, 6.0f, 18.0f); - - const float radius = 800.0f; // Horizontal distance - const float height = 600.0f; // Maximum height at zenith - - // Arc across sky (angle 0β†’Ο€ maps to sunriseβ†’noonβ†’sunset) - // Z is vertical (matches skybox: Altitude = aPos.z) - // At angle=0: x=radius, z=0 (east horizon) - // At angle=Ο€/2: x=0, z=height (zenith, directly overhead) - // At angle=Ο€: x=-radius, z=0 (west horizon) - float x = radius * std::cos(angle); // Horizontal position (Eβ†’W) - float y = 0.0f; // Y is north-south (keep at 0) - float z = height * std::sin(angle); // Vertical position (Z is UP, matches skybox) - - return glm::vec3(x, y, z); + const float radius = 800.0f; + const float height = 600.0f; + float x = radius * std::cos(angle); + float z = height * std::sin(angle); + return glm::vec3(x, 0.0f, z); } glm::vec3 Celestial::getMoonPosition(float timeOfDay) const { - // Moon rises at 18:00, peaks at 0:00 (24:00), sets at 6:00 - // Adjust time for moon (opposite to sun) float moonTime = timeOfDay + 12.0f; if (moonTime >= 24.0f) moonTime -= 24.0f; - float angle = calculateCelestialAngle(moonTime, 6.0f, 18.0f); - const float radius = 800.0f; const float height = 600.0f; - - // Same arc formula as sun (Z is vertical, matches skybox) float x = radius * std::cos(angle); - float y = 0.0f; float z = height * std::sin(angle); - - return glm::vec3(x, y, z); + return glm::vec3(x, 0.0f, z); } glm::vec3 Celestial::getSunColor(float timeOfDay) const { - // Sunrise/sunset: orange/red - // Midday: bright yellow-white - if (timeOfDay >= 5.0f && timeOfDay < 7.0f) { - // Sunrise: orange - return glm::vec3(1.0f, 0.6f, 0.2f); - } - else if (timeOfDay >= 7.0f && timeOfDay < 9.0f) { - // Morning: blend to yellow + return glm::vec3(1.0f, 0.6f, 0.2f); // Sunrise orange + } else if (timeOfDay >= 7.0f && timeOfDay < 9.0f) { float t = (timeOfDay - 7.0f) / 2.0f; - glm::vec3 orange = glm::vec3(1.0f, 0.6f, 0.2f); - glm::vec3 yellow = glm::vec3(1.0f, 1.0f, 0.9f); - return glm::mix(orange, yellow, t); - } - else if (timeOfDay >= 9.0f && timeOfDay < 16.0f) { - // Day: bright yellow-white - return glm::vec3(1.0f, 1.0f, 0.9f); - } - else if (timeOfDay >= 16.0f && timeOfDay < 18.0f) { - // Evening: blend to orange + return glm::mix(glm::vec3(1.0f, 0.6f, 0.2f), glm::vec3(1.0f, 1.0f, 0.9f), t); + } else if (timeOfDay >= 9.0f && timeOfDay < 16.0f) { + return glm::vec3(1.0f, 1.0f, 0.9f); // Day yellow-white + } else if (timeOfDay >= 16.0f && timeOfDay < 18.0f) { float t = (timeOfDay - 16.0f) / 2.0f; - glm::vec3 yellow = glm::vec3(1.0f, 1.0f, 0.9f); - glm::vec3 orange = glm::vec3(1.0f, 0.5f, 0.1f); - return glm::mix(yellow, orange, t); - } - else { - // Sunset: deep orange/red - return glm::vec3(1.0f, 0.4f, 0.1f); + return glm::mix(glm::vec3(1.0f, 1.0f, 0.9f), glm::vec3(1.0f, 0.5f, 0.1f), t); + } else { + return glm::vec3(1.0f, 0.4f, 0.1f); // Sunset orange } } float Celestial::getSunIntensity(float timeOfDay) const { - // Fade in at sunrise (5:00-6:00) if (timeOfDay >= 5.0f && timeOfDay < 6.0f) { - return (timeOfDay - 5.0f); // 0 to 1 - } - // Full intensity during day (6:00-18:00) - else if (timeOfDay >= 6.0f && timeOfDay < 18.0f) { - return 1.0f; - } - // Fade out at sunset (18:00-19:00) - else if (timeOfDay >= 18.0f && timeOfDay < 19.0f) { - return 1.0f - (timeOfDay - 18.0f); // 1 to 0 - } - else { + return timeOfDay - 5.0f; // Fade in + } else if (timeOfDay >= 6.0f && timeOfDay < 18.0f) { + return 1.0f; // Full day + } else if (timeOfDay >= 18.0f && timeOfDay < 19.0f) { + return 1.0f - (timeOfDay - 18.0f); // Fade out + } else { return 0.0f; } } float Celestial::calculateCelestialAngle(float timeOfDay, float riseTime, float setTime) const { - // Map time to angle (0 to PI) - // riseTime: 0 radians (horizon east) - // (riseTime + setTime) / 2: PI/2 radians (zenith) - // setTime: PI radians (horizon west) - float duration = setTime - riseTime; - float elapsed = timeOfDay - riseTime; - - // Normalize to 0-1 + float elapsed = timeOfDay - riseTime; float t = elapsed / duration; - - // Map to 0 to PI (arc from east to west) - return t * M_PI; + return t * static_cast(M_PI); } -void Celestial::createCelestialQuad() { - // Simple quad centered at origin - float vertices[] = { - // Position // TexCoord - -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, // Top-left - 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, // Top-right - 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, // Bottom-right - -0.5f, -0.5f, 0.0f, 0.0f, 0.0f // Bottom-left - }; - - uint32_t indices[] = { - 0, 1, 2, // First triangle - 0, 2, 3 // Second triangle - }; - - // Create OpenGL buffers - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); - glGenBuffers(1, &ebo); - - glBindVertexArray(vao); - - // Upload vertex data - glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); - - // Upload index data - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW); - - // Set vertex attributes - // Position - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - - // Texture coordinates - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - - glBindVertexArray(0); -} - -void Celestial::destroyCelestialQuad() { - if (vao != 0) { - glDeleteVertexArrays(1, &vao); - vao = 0; - } - if (vbo != 0) { - glDeleteBuffers(1, &vbo); - vbo = 0; - } - if (ebo != 0) { - glDeleteBuffers(1, &ebo); - ebo = 0; - } -} +// --------------------------------------------------------------------------- +// Moon phase helpers +// --------------------------------------------------------------------------- void Celestial::update(float deltaTime) { sunHazeTimer_ += deltaTime; - if (!moonPhaseCycling) { + if (!moonPhaseCycling_) { return; } - // Update moon phase timer - moonPhaseTimer += deltaTime; + moonPhaseTimer_ += deltaTime; + whiteLadyPhase_ = std::fmod(moonPhaseTimer_ / MOON_CYCLE_DURATION, 1.0f); - // White Lady completes full cycle in MOON_CYCLE_DURATION seconds - whiteLadyPhase_ = std::fmod(moonPhaseTimer / MOON_CYCLE_DURATION, 1.0f); - - // Blue Child has a different cycle rate (slightly faster, 3.5 minutes) - constexpr float BLUE_CHILD_CYCLE = 210.0f; - blueChildPhase_ = std::fmod(moonPhaseTimer / BLUE_CHILD_CYCLE, 1.0f); + constexpr float BLUE_CHILD_CYCLE = 210.0f; // Slightly faster: 3.5 minutes + blueChildPhase_ = std::fmod(moonPhaseTimer_ / BLUE_CHILD_CYCLE, 1.0f); } void Celestial::setMoonPhase(float phase) { - // Set White Lady phase (primary moon) whiteLadyPhase_ = glm::clamp(phase, 0.0f, 1.0f); - - // Update timer to match White Lady phase - moonPhaseTimer = whiteLadyPhase_ * MOON_CYCLE_DURATION; + moonPhaseTimer_ = whiteLadyPhase_ * MOON_CYCLE_DURATION; } void Celestial::setBlueChildPhase(float phase) { - // Set Blue Child phase (secondary moon) blueChildPhase_ = glm::clamp(phase, 0.0f, 1.0f); } float Celestial::computePhaseFromGameTime(float gameTime, float cycleDays) const { - // WoW game time: 1 game day = 24 real minutes = 1440 seconds - constexpr float SECONDS_PER_GAME_DAY = 1440.0f; - - // Convert game time to game days + constexpr float SECONDS_PER_GAME_DAY = 1440.0f; // 24 real minutes float gameDays = gameTime / SECONDS_PER_GAME_DAY; - - // Compute phase as fraction of lunar cycle (0.0-1.0) - float phase = std::fmod(gameDays / cycleDays, 1.0f); - - // Ensure positive (fmod can return negative for negative input) - if (phase < 0.0f) { - phase += 1.0f; - } - + float phase = std::fmod(gameDays / cycleDays, 1.0f); + if (phase < 0.0f) phase += 1.0f; return phase; } void Celestial::updatePhasesFromGameTime(float gameTime) { - // Compute deterministic phases from server game time whiteLadyPhase_ = computePhaseFromGameTime(gameTime, WHITE_LADY_CYCLE_DAYS); blueChildPhase_ = computePhaseFromGameTime(gameTime, BLUE_CHILD_CYCLE_DAYS); } +// --------------------------------------------------------------------------- +// GPU buffer management +// --------------------------------------------------------------------------- + +void Celestial::createQuad() { + // Billboard quad centred at origin, vertices: pos(vec3) + uv(vec2) + float vertices[] = { + // Position TexCoord + -0.5f, 0.5f, 0.0f, 0.0f, 1.0f, // Top-left + 0.5f, 0.5f, 0.0f, 1.0f, 1.0f, // Top-right + 0.5f, -0.5f, 0.0f, 1.0f, 0.0f, // Bottom-right + -0.5f, -0.5f, 0.0f, 0.0f, 0.0f, // Bottom-left + }; + + uint32_t indices[] = { 0, 1, 2, 0, 2, 3 }; + + AllocatedBuffer vbuf = uploadBuffer(*vkCtx_, + vertices, sizeof(vertices), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + vertexBuffer_ = vbuf.buffer; + vertexAlloc_ = vbuf.allocation; + + AllocatedBuffer ibuf = uploadBuffer(*vkCtx_, + indices, sizeof(indices), + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + indexBuffer_ = ibuf.buffer; + indexAlloc_ = ibuf.allocation; +} + +void Celestial::destroyQuad() { + if (!vkCtx_) return; + + VmaAllocator allocator = vkCtx_->getAllocator(); + + if (vertexBuffer_ != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, vertexBuffer_, vertexAlloc_); + vertexBuffer_ = VK_NULL_HANDLE; + vertexAlloc_ = VK_NULL_HANDLE; + } + if (indexBuffer_ != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, indexBuffer_, indexAlloc_); + indexBuffer_ = VK_NULL_HANDLE; + indexAlloc_ = VK_NULL_HANDLE; + } +} + } // namespace rendering } // namespace wowee diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 663a9324..964357f4 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -1,15 +1,23 @@ #include "rendering/character_preview.hpp" #include "rendering/character_renderer.hpp" +#include "rendering/vk_render_target.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_frame_data.hpp" #include "rendering/camera.hpp" +#include "rendering/renderer.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/m2_loader.hpp" #include "pipeline/dbc_loader.hpp" #include "pipeline/dbc_layout.hpp" #include "core/logger.hpp" -#include +#include "core/application.hpp" +#include +#include #include #include #include +#include namespace wowee { namespace rendering { @@ -23,12 +31,38 @@ CharacterPreview::~CharacterPreview() { bool CharacterPreview::initialize(pipeline::AssetManager* am) { assetManager_ = am; + // If already initialized with valid resources, reuse them. + // This avoids destroying GPU resources that may still be referenced by + // an in-flight command buffer (compositePass recorded earlier this frame). + if (renderTarget_ && renderTarget_->isValid() && charRenderer_ && camera_) { + // Mark model as not loaded β€” loadCharacter() will handle instance cleanup + modelLoaded_ = false; + return true; + } + + auto* appRenderer = core::Application::getInstance().getRenderer(); + vkCtx_ = appRenderer ? appRenderer->getVkContext() : nullptr; + VkDescriptorSetLayout perFrameLayout = appRenderer ? appRenderer->getPerFrameSetLayout() : VK_NULL_HANDLE; + + if (!vkCtx_ || perFrameLayout == VK_NULL_HANDLE) { + LOG_ERROR("CharacterPreview: no VkContext or perFrameLayout available"); + return false; + } + + // Create off-screen render target first (need its render pass for pipeline creation) + createFBO(); + if (!renderTarget_ || !renderTarget_->isValid()) { + LOG_ERROR("CharacterPreview: failed to create off-screen render target"); + return false; + } + + // Initialize CharacterRenderer with our off-screen render pass charRenderer_ = std::make_unique(); - if (!charRenderer_->initialize()) { + if (!charRenderer_->initialize(vkCtx_, perFrameLayout, am, renderTarget_->getRenderPass(), + renderTarget_->getSampleCount())) { LOG_ERROR("CharacterPreview: failed to initialize CharacterRenderer"); return false; } - charRenderer_->setAssetManager(am); // Disable fog and shadows for the preview charRenderer_->setFog(glm::vec3(0.05f, 0.05f, 0.1f), 9999.0f, 10000.0f); @@ -40,60 +74,188 @@ bool CharacterPreview::initialize(pipeline::AssetManager* am) { camera_->setFov(30.0f); camera_->setAspectRatio(static_cast(fboWidth_) / static_cast(fboHeight_)); // Pull camera back far enough to see full body + head with margin - // Human ~2 units tall, Tauren ~2.5. At distance 4.5 with FOV 30: - // vertical visible = 2 * 4.5 * tan(15Β°) β‰ˆ 2.41 units camera_->setPosition(glm::vec3(0.0f, 4.5f, 0.9f)); camera_->setRotation(270.0f, 0.0f); - createFBO(); - LOG_INFO("CharacterPreview initialized (", fboWidth_, "x", fboHeight_, ")"); return true; } void CharacterPreview::shutdown() { - destroyFBO(); + // Unregister from renderer before destroying resources + auto* appRenderer = core::Application::getInstance().getRenderer(); + if (appRenderer) appRenderer->unregisterPreview(this); + if (charRenderer_) { charRenderer_->shutdown(); charRenderer_.reset(); } camera_.reset(); + destroyFBO(); modelLoaded_ = false; + compositeRendered_ = false; instanceId_ = 0; } void CharacterPreview::createFBO() { - // Create color texture - glGenTextures(1, &colorTexture_); - glBindTexture(GL_TEXTURE_2D, colorTexture_); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, fboWidth_, fboHeight_, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glBindTexture(GL_TEXTURE_2D, 0); + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + VmaAllocator allocator = vkCtx_->getAllocator(); - // Create depth renderbuffer - glGenRenderbuffers(1, &depthRenderbuffer_); - glBindRenderbuffer(GL_RENDERBUFFER, depthRenderbuffer_); - glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, fboWidth_, fboHeight_); - glBindRenderbuffer(GL_RENDERBUFFER, 0); - - // Create FBO - glGenFramebuffers(1, &fbo_); - glBindFramebuffer(GL_FRAMEBUFFER, fbo_); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexture_, 0); - glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRenderbuffer_); - - GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); - if (status != GL_FRAMEBUFFER_COMPLETE) { - LOG_ERROR("CharacterPreview: FBO incomplete, status=", status); + // 1. Create off-screen render target with depth + renderTarget_ = std::make_unique(); + if (!renderTarget_->create(*vkCtx_, fboWidth_, fboHeight_, VK_FORMAT_R8G8B8A8_UNORM, true, + VK_SAMPLE_COUNT_4_BIT)) { + LOG_ERROR("CharacterPreview: failed to create render target"); + renderTarget_.reset(); + return; } - glBindFramebuffer(GL_FRAMEBUFFER, 0); + + // 1b. Transition the color image from UNDEFINED to SHADER_READ_ONLY_OPTIMAL + // so that ImGui::Image doesn't sample an image in UNDEFINED layout before + // the first compositePass runs. + { + VkCommandBuffer cmd = vkCtx_->beginSingleTimeCommands(); + VkImageMemoryBarrier barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = renderTarget_->getColorImage(); + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + vkCtx_->endSingleTimeCommands(cmd); + } + + // 2. Create 1x1 dummy white texture (shadow map placeholder) + { + uint8_t white[] = {255, 255, 255, 255}; + dummyWhiteTex_ = std::make_unique(); + dummyWhiteTex_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + dummyWhiteTex_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE); + } + + // 3. Create descriptor pool for per-frame sets (2 UBO + 2 sampler) + { + VkDescriptorPoolSize sizes[2]{}; + sizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + sizes[0].descriptorCount = MAX_FRAMES; + sizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + sizes[1].descriptorCount = MAX_FRAMES; + + VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + ci.maxSets = MAX_FRAMES; + ci.poolSizeCount = 2; + ci.pPoolSizes = sizes; + if (vkCreateDescriptorPool(device, &ci, nullptr, &previewDescPool_) != VK_SUCCESS) { + LOG_ERROR("CharacterPreview: failed to create descriptor pool"); + return; + } + } + + // 4. Create per-frame UBOs and descriptor sets + auto* appRenderer = core::Application::getInstance().getRenderer(); + VkDescriptorSetLayout perFrameLayout = appRenderer->getPerFrameSetLayout(); + + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + // Create mapped UBO + VkBufferCreateInfo bufInfo{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bufInfo.size = sizeof(GPUPerFrameData); + bufInfo.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(allocator, &bufInfo, &allocInfo, + &previewUBO_[i], &previewUBOAlloc_[i], &mapInfo) != VK_SUCCESS) { + LOG_ERROR("CharacterPreview: failed to create UBO ", i); + return; + } + previewUBOMapped_[i] = mapInfo.pMappedData; + + // Allocate descriptor set + VkDescriptorSetAllocateInfo setAlloc{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + setAlloc.descriptorPool = previewDescPool_; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &perFrameLayout; + if (vkAllocateDescriptorSets(device, &setAlloc, &previewPerFrameSet_[i]) != VK_SUCCESS) { + LOG_ERROR("CharacterPreview: failed to allocate descriptor set ", i); + return; + } + + // Write UBO binding (0) and shadow sampler binding (1) using dummy white texture + VkDescriptorBufferInfo descBuf{}; + descBuf.buffer = previewUBO_[i]; + descBuf.offset = 0; + descBuf.range = sizeof(GPUPerFrameData); + + VkDescriptorImageInfo shadowImg = dummyWhiteTex_->descriptorInfo(); + + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = previewPerFrameSet_[i]; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[0].pBufferInfo = &descBuf; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = previewPerFrameSet_[i]; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[1].pImageInfo = &shadowImg; + + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + } + + // 5. Register the color attachment as an ImGui texture + imguiTextureId_ = ImGui_ImplVulkan_AddTexture( + renderTarget_->getSampler(), + renderTarget_->getColorImageView(), + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + + LOG_INFO("CharacterPreview: off-screen FBO created (", fboWidth_, "x", fboHeight_, ")"); } void CharacterPreview::destroyFBO() { - if (fbo_) { glDeleteFramebuffers(1, &fbo_); fbo_ = 0; } - if (colorTexture_) { glDeleteTextures(1, &colorTexture_); colorTexture_ = 0; } - if (depthRenderbuffer_) { glDeleteRenderbuffers(1, &depthRenderbuffer_); depthRenderbuffer_ = 0; } + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + VmaAllocator allocator = vkCtx_->getAllocator(); + + if (imguiTextureId_) { + ImGui_ImplVulkan_RemoveTexture(imguiTextureId_); + imguiTextureId_ = VK_NULL_HANDLE; + } + + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + if (previewUBO_[i]) { + vmaDestroyBuffer(allocator, previewUBO_[i], previewUBOAlloc_[i]); + previewUBO_[i] = VK_NULL_HANDLE; + } + } + + if (previewDescPool_) { + vkDestroyDescriptorPool(device, previewDescPool_, nullptr); + previewDescPool_ = VK_NULL_HANDLE; + } + + dummyWhiteTex_.reset(); + + if (renderTarget_) { + renderTarget_->destroy(device, allocator); + renderTarget_.reset(); + } } bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, @@ -104,8 +266,11 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, return false; } - // Remove existing instance + // Remove existing instance. + // Must wait for GPU to finish β€” compositePass() may have recorded draw commands + // referencing this instance's bone buffers earlier in the current frame. if (instanceId_ > 0) { + if (vkCtx_) vkDeviceWaitIdle(vkCtx_->getDevice()); charRenderer_->removeInstance(instanceId_); instanceId_ = 0; modelLoaded_ = false; @@ -288,8 +453,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, for (const auto& up : underwearPaths) baseLayers_.push_back(up); if (layers.size() > 1) { - GLuint compositeTex = charRenderer_->compositeTextures(layers); - if (compositeTex != 0) { + VkTexture* compositeTex = charRenderer_->compositeTextures(layers); + if (compositeTex != nullptr) { for (size_t ti = 0; ti < model.textures.size(); ti++) { if (model.textures[ti].type == 1) { charRenderer_->setModelTexture(PREVIEW_MODEL_ID, static_cast(ti), compositeTex); @@ -302,8 +467,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, // If hair scalp texture was found, ensure it's loaded for type-6 slot if (!hairScalpPath.empty()) { - GLuint hairTex = charRenderer_->loadTexture(hairScalpPath); - if (hairTex != 0) { + VkTexture* hairTex = charRenderer_->loadTexture(hairScalpPath); + if (hairTex != nullptr) { for (size_t ti = 0; ti < model.textures.size(); ti++) { if (model.textures[ti].type == 6) { charRenderer_->setModelTexture(PREVIEW_MODEL_ID, static_cast(ti), hairTex); @@ -511,8 +676,8 @@ bool CharacterPreview::applyEquipment(const std::vector& eq } if (!regionLayers.empty()) { - GLuint newTex = charRenderer_->compositeWithRegions(bodySkinPath_, baseLayers_, regionLayers); - if (newTex != 0) { + VkTexture* newTex = charRenderer_->compositeWithRegions(bodySkinPath_, baseLayers_, regionLayers); + if (newTex != nullptr) { charRenderer_->setModelTexture(PREVIEW_MODEL_ID, skinTextureSlotIndex_, newTex); } } @@ -575,10 +740,10 @@ bool CharacterPreview::applyEquipment(const std::vector& eq addCandidate(baseTex + "_U.blp"); } } - const GLuint whiteTex = charRenderer_->loadTexture(""); + VkTexture* whiteTex = charRenderer_->loadTexture(""); for (const auto& c : candidates) { - GLuint capeTex = charRenderer_->loadTexture(c); - if (capeTex != 0 && capeTex != whiteTex) { + VkTexture* capeTex = charRenderer_->loadTexture(c); + if (capeTex != nullptr && capeTex != whiteTex) { charRenderer_->setGroupTextureOverride(instanceId_, 15, capeTex); if (const auto* md = charRenderer_->getModelData(PREVIEW_MODEL_ID)) { for (size_t ti = 0; ti < md->textures.size(); ti++) { @@ -612,33 +777,48 @@ void CharacterPreview::update(float deltaTime) { } void CharacterPreview::render() { - if (!fbo_ || !charRenderer_ || !camera_ || !modelLoaded_) { + // No-op β€” actual rendering happens in compositePass() called from Renderer::beginFrame() +} + +void CharacterPreview::compositePass(VkCommandBuffer cmd, uint32_t frameIndex) { + // Only composite when a UI screen actually requested it this frame + if (!compositeRequested_) return; + compositeRequested_ = false; + + if (!charRenderer_ || !camera_ || !modelLoaded_ || !renderTarget_ || !renderTarget_->isValid()) { return; } - // Save current viewport - GLint prevViewport[4]; - glGetIntegerv(GL_VIEWPORT, prevViewport); + uint32_t fi = frameIndex % MAX_FRAMES; - // Save current FBO binding - GLint prevFbo; - glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo); + // Update per-frame UBO with preview camera matrices and studio lighting + GPUPerFrameData ubo{}; + ubo.view = camera_->getViewMatrix(); + ubo.projection = camera_->getProjectionMatrix(); + ubo.lightSpaceMatrix = glm::mat4(1.0f); + // Studio lighting: key light from upper-right-front + ubo.lightDir = glm::vec4(glm::normalize(glm::vec3(0.5f, -0.7f, 0.5f)), 0.0f); + ubo.lightColor = glm::vec4(1.0f, 0.95f, 0.9f, 0.0f); + ubo.ambientColor = glm::vec4(0.35f, 0.35f, 0.4f, 0.0f); + ubo.viewPos = glm::vec4(camera_->getPosition(), 0.0f); + // No fog in preview + ubo.fogColor = glm::vec4(0.05f, 0.05f, 0.1f, 0.0f); + ubo.fogParams = glm::vec4(9999.0f, 10000.0f, 0.0f, 0.0f); + // Shadows disabled + ubo.shadowParams = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f); - // Bind our FBO - glBindFramebuffer(GL_FRAMEBUFFER, fbo_); - glViewport(0, 0, fboWidth_, fboHeight_); + std::memcpy(previewUBOMapped_[fi], &ubo, sizeof(GPUPerFrameData)); - // Clear with dark blue background - glClearColor(0.05f, 0.05f, 0.1f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - glEnable(GL_DEPTH_TEST); + // Begin off-screen render pass + VkClearColorValue clearColor = {{0.05f, 0.05f, 0.1f, 1.0f}}; + renderTarget_->beginPass(cmd, clearColor); // Render the character model - charRenderer_->render(*camera_, camera_->getViewMatrix(), camera_->getProjectionMatrix()); + charRenderer_->render(cmd, previewPerFrameSet_[fi], *camera_); - // Restore previous FBO and viewport - glBindFramebuffer(GL_FRAMEBUFFER, static_cast(prevFbo)); - glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]); + renderTarget_->endPass(cmd); + + compositeRendered_ = true; } void CharacterPreview::rotate(float yawDelta) { diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index b6ca738d..522f6a48 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1,27 +1,30 @@ /** - * CharacterRenderer β€” GPU rendering of M2 character models with skeletal animation + * CharacterRenderer β€” GPU rendering of M2 character models with skeletal animation (Vulkan) * * Handles: - * - Uploading M2 vertex/index data to OpenGL VAO/VBO/EBO + * - Uploading M2 vertex/index data to Vulkan buffers via VMA * - Per-frame bone matrix computation (hierarchical, with keyframe interpolation) - * - GPU vertex skinning via a bone-matrix uniform array in the vertex shader + * - GPU vertex skinning via a bone-matrix SSBO in the vertex shader * - Per-batch texture binding through the M2 texture-lookup indirection * - Geoset filtering (activeGeosets) to show/hide body part groups * - CPU texture compositing for character skins (base skin + underwear overlays) * * The character texture compositing uses the WoW CharComponentTextureSections * layout, placing region overlays (pelvis, torso, etc.) at their correct pixel - * positions on the 512Γ—512 body skin atlas. Region coordinates sourced from + * positions on the 512x512 body skin atlas. Region coordinates sourced from * the original WoW Model Viewer (charcontrol.h, REGION_FAC=2). */ #include "rendering/character_renderer.hpp" -#include "rendering/shader.hpp" -#include "rendering/texture.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_utils.hpp" #include "rendering/camera.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "core/logger.hpp" -#include #include #include #include @@ -29,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +40,7 @@ #include #include #include +#include namespace wowee { namespace rendering { @@ -51,6 +56,15 @@ size_t envSizeMBOrDefault(const char* name, size_t defMb) { return static_cast(mb); } +size_t envSizeOrDefault(const char* name, size_t defValue) { + const char* v = std::getenv(name); + if (!v || !*v) return defValue; + char* end = nullptr; + unsigned long long n = std::strtoull(v, &end, 10); + if (end == v || n == 0) return defValue; + return static_cast(n); +} + size_t approxTextureBytesWithMips(int w, int h) { if (w <= 0 || h <= 0) return 0; size_t base = static_cast(w) * static_cast(h) * 4ull; @@ -58,6 +72,38 @@ size_t approxTextureBytesWithMips(int w, int h) { } } // namespace +// Descriptor pool sizing +static constexpr uint32_t MAX_MATERIAL_SETS = 4096; +static constexpr uint32_t MAX_BONE_SETS = 8192; + +// CharMaterial UBO layout (matches character.frag.glsl set=1 binding=1) +struct CharMaterialUBO { + float opacity; + int32_t alphaTest; + int32_t colorKeyBlack; + int32_t unlit; + float emissiveBoost; + float emissiveTintR, emissiveTintG, emissiveTintB; + float specularIntensity; + int32_t enableNormalMap; + int32_t enablePOM; + float pomScale; + int32_t pomMaxSamples; + float heightMapVariance; + float normalMapStrength; + float _pad[2]; // pad to 64 bytes +}; + +// GPU vertex struct with tangent (expanded from M2Vertex for normal mapping) +struct CharVertexGPU { + glm::vec3 position; // 12 bytes, offset 0 + uint8_t boneWeights[4]; // 4 bytes, offset 12 + uint8_t boneIndices[4]; // 4 bytes, offset 16 + glm::vec3 normal; // 12 bytes, offset 20 + glm::vec2 texCoords; // 8 bytes, offset 32 + glm::vec4 tangent; // 16 bytes, offset 40 (xyz=dir, w=handedness) +}; // 56 bytes total + CharacterRenderer::CharacterRenderer() { } @@ -65,316 +111,395 @@ CharacterRenderer::~CharacterRenderer() { shutdown(); } -bool CharacterRenderer::initialize() { - core::Logger::getInstance().info("Initializing character renderer..."); +bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* am, + VkRenderPass renderPassOverride, + VkSampleCountFlagBits msaaSamples) { + core::Logger::getInstance().info("Initializing character renderer (Vulkan)..."); - // Create character shader with skeletal animation - const char* vertexSrc = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec4 aBoneWeights; - layout (location = 2) in ivec4 aBoneIndices; - layout (location = 3) in vec3 aNormal; - layout (location = 4) in vec2 aTexCoord; + vkCtx_ = ctx; + assetManager = am; + perFrameLayout_ = perFrameLayout; + renderPassOverride_ = renderPassOverride; + msaaSamplesOverride_ = msaaSamples; + const unsigned hc = std::thread::hardware_concurrency(); + const size_t availableCores = (hc > 1u) ? static_cast(hc - 1u) : 1ull; + // Character updates run alongside M2/WMO work; default to a smaller share. + const size_t defaultAnimThreads = std::max(1, availableCores / 4); + numAnimThreads_ = static_cast(std::max( + 1, envSizeOrDefault("WOWEE_CHAR_ANIM_THREADS", defaultAnimThreads))); + core::Logger::getInstance().info("Character anim threads: ", numAnimThreads_); - uniform mat4 uModel; - uniform mat4 uView; - uniform mat4 uProjection; - uniform mat4 uBones[240]; + VkDevice device = vkCtx_->getDevice(); - out vec3 FragPos; - out vec3 Normal; - out vec2 TexCoord; + // --- Descriptor set layouts --- - void main() { - // Skinning: blend bone transformations - mat4 boneTransform = mat4(0.0); - boneTransform += uBones[aBoneIndices.x] * aBoneWeights.x; - boneTransform += uBones[aBoneIndices.y] * aBoneWeights.y; - boneTransform += uBones[aBoneIndices.z] * aBoneWeights.z; - boneTransform += uBones[aBoneIndices.w] * aBoneWeights.w; + // Material set layout (set 1): binding 0 = sampler2D, binding 1 = CharMaterial UBO, binding 2 = normal/height map + { + VkDescriptorSetLayoutBinding bindings[3] = {}; + bindings[0].binding = 0; + bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + bindings[0].descriptorCount = 1; + bindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + bindings[1].binding = 1; + bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + bindings[1].descriptorCount = 1; + bindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + bindings[2].binding = 2; + bindings[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + bindings[2].descriptorCount = 1; + bindings[2].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - // Transform position and normal - vec4 skinnedPos = boneTransform * vec4(aPos, 1.0); - vec4 worldPos = uModel * skinnedPos; + VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + ci.bindingCount = 3; + ci.pBindings = bindings; + vkCreateDescriptorSetLayout(device, &ci, nullptr, &materialSetLayout_); + } - FragPos = worldPos.xyz; - // Use mat3 directly - avoid expensive inverse() in shader - // Works correctly for uniform scaling; normalize in fragment shader handles the rest - Normal = mat3(uModel) * mat3(boneTransform) * aNormal; - TexCoord = aTexCoord; + // Bone set layout (set 2): binding 0 = STORAGE_BUFFER (bone matrices) + { + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; - gl_Position = uProjection * uView * worldPos; - } - )"; + VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + ci.bindingCount = 1; + ci.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &ci, nullptr, &boneSetLayout_); + } - const char* fragmentSrc = R"( - #version 330 core - in vec3 FragPos; - in vec3 Normal; - in vec2 TexCoord; + // --- Descriptor pools --- + // Material descriptors are transient and allocated every draw; keep per-frame + // pools so we can reset safely each frame slot without exhausting descriptors. + for (int i = 0; i < 2; i++) { + VkDescriptorPoolSize sizes[] = { + {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_MATERIAL_SETS * 2}, // diffuse + normal/height + {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, MAX_MATERIAL_SETS}, + }; + VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + ci.maxSets = MAX_MATERIAL_SETS; + ci.poolSizeCount = 2; + ci.pPoolSizes = sizes; + ci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + vkCreateDescriptorPool(device, &ci, nullptr, &materialDescPools_[i]); + } + { + VkDescriptorPoolSize sizes[] = { + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, MAX_BONE_SETS}, + }; + VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + ci.maxSets = MAX_BONE_SETS; + ci.poolSizeCount = 1; + ci.pPoolSizes = sizes; + ci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + vkCreateDescriptorPool(device, &ci, nullptr, &boneDescPool_); + } - uniform sampler2D uTexture0; - uniform vec3 uLightDir; - uniform vec3 uLightColor; - uniform float uSpecularIntensity; - uniform vec3 uViewPos; + // --- Pipeline layout --- + // set 0 = perFrame, set 1 = material, set 2 = bones + // Push constant: mat4 model = 64 bytes + { + VkDescriptorSetLayout setLayouts[] = {perFrameLayout, materialSetLayout_, boneSetLayout_}; + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pushRange.offset = 0; + pushRange.size = 64; // mat4 - uniform vec3 uFogColor; - uniform float uFogStart; - uniform float uFogEnd; + VkPipelineLayoutCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + ci.setLayoutCount = 3; + ci.pSetLayouts = setLayouts; + ci.pushConstantRangeCount = 1; + ci.pPushConstantRanges = &pushRange; + vkCreatePipelineLayout(device, &ci, nullptr, &pipelineLayout_); + } - uniform sampler2DShadow uShadowMap; - uniform mat4 uLightSpaceMatrix; - uniform int uShadowEnabled; - uniform float uShadowStrength; - uniform float uOpacity; - uniform int uAlphaTest; - uniform int uColorKeyBlack; - uniform int uUnlit; - uniform float uEmissiveBoost; - uniform vec3 uEmissiveTint; + // --- Load shaders --- + rendering::VkShaderModule charVert, charFrag; + charVert.loadFromFile(device, "assets/shaders/character.vert.spv"); + charFrag.loadFromFile(device, "assets/shaders/character.frag.spv"); - out vec4 FragColor; - - void main() { - vec3 normal = normalize(Normal); - vec3 lightDir = normalize(uLightDir); - - // Diffuse lighting - float diff = max(dot(normal, lightDir), 0.0); - - // Blinn-Phong specular - vec3 viewDir = normalize(uViewPos - FragPos); - vec3 halfDir = normalize(lightDir + viewDir); - float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); - vec3 specular = spec * uLightColor * uSpecularIntensity; - - // Shadow mapping - float shadow = 1.0; - if (uShadowEnabled != 0) { - vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); - vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; - if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { - float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); - float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); - float bias = max(0.005 * (1.0 - abs(dot(normal, lightDir))), 0.001); - // Single hardware PCF tap β€” GL_LINEAR + compare mode gives 2Γ—2 bilinear PCF for free - shadow = texture(uShadowMap, vec3(proj.xy, proj.z - bias)); - shadow = mix(1.0, shadow, coverageFade); - } - } - shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); - - // Ambient - vec3 ambient = vec3(0.3); - - // Sample texture - vec4 texColor = texture(uTexture0, TexCoord); - if (uAlphaTest != 0 && texColor.a < 0.5) discard; - if (uColorKeyBlack != 0) { - float key = max(texColor.r, max(texColor.g, texColor.b)); - // Soft black-key: fade fringe instead of hard-cut to avoid dark halo. - float keyAlpha = smoothstep(0.12, 0.30, key); - texColor.a *= keyAlpha; - if (texColor.a < 0.02) discard; - } - - // Combine - vec3 litResult = (ambient + (diff * vec3(1.0) + specular) * shadow) * texColor.rgb; - vec3 warmBase = vec3( - max(texColor.r, texColor.g * 0.92), - texColor.g * 0.90, - texColor.b * 0.45 - ); - vec3 emissiveResult = warmBase * uEmissiveTint * uEmissiveBoost; - vec3 result = (uUnlit != 0) ? emissiveResult : litResult; - - // Fog - float fogDist = length(uViewPos - FragPos); - float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); - result = mix(uFogColor, result, fogFactor); - - // Apply texture alpha and instance opacity (for fade-in effects) - float finalAlpha = texColor.a * uOpacity; - if (finalAlpha < 0.02) discard; - FragColor = vec4(result, finalAlpha); - } - )"; - - // Log GPU uniform limit - GLint maxComponents = 0; - glGetIntegerv(GL_MAX_VERTEX_UNIFORM_COMPONENTS, &maxComponents); - core::Logger::getInstance().info("GPU max vertex uniform components: ", maxComponents, - " (supports ~", maxComponents / 16, " mat4)"); - - characterShader = std::make_unique(); - if (!characterShader->loadFromSource(vertexSrc, fragmentSrc)) { - core::Logger::getInstance().error("Failed to create character shader"); + if (!charVert.isValid() || !charFrag.isValid()) { + LOG_ERROR("Character: Missing required shaders, cannot initialize"); return false; } - const char* shadowVertSrc = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec4 aBoneWeights; - layout (location = 2) in ivec4 aBoneIndices; - layout (location = 4) in vec2 aTexCoord; + VkRenderPass mainPass = renderPassOverride_ ? renderPassOverride_ : vkCtx_->getImGuiRenderPass(); + VkSampleCountFlagBits samples = renderPassOverride_ ? msaaSamplesOverride_ : vkCtx_->getMsaaSamples(); - uniform mat4 uLightSpaceMatrix; - uniform mat4 uModel; - uniform mat4 uBones[240]; + // --- Vertex input --- + // CharVertexGPU: vec3 pos(12) + uint8[4] boneWeights(4) + uint8[4] boneIndices(4) + + // vec3 normal(12) + vec2 texCoords(8) + vec4 tangent(16) = 56 bytes + VkVertexInputBindingDescription charBinding{}; + charBinding.binding = 0; + charBinding.stride = sizeof(CharVertexGPU); + charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - out vec2 vTexCoord; - - void main() { - mat4 boneTransform = mat4(0.0); - boneTransform += uBones[aBoneIndices.x] * aBoneWeights.x; - boneTransform += uBones[aBoneIndices.y] * aBoneWeights.y; - boneTransform += uBones[aBoneIndices.z] * aBoneWeights.z; - boneTransform += uBones[aBoneIndices.w] * aBoneWeights.w; - vec4 skinnedPos = boneTransform * vec4(aPos, 1.0); - vTexCoord = aTexCoord; - gl_Position = uLightSpaceMatrix * uModel * skinnedPos; - } - )"; - - const char* shadowFragSrc = R"( - #version 330 core - in vec2 vTexCoord; - uniform sampler2D uTexture; - uniform bool uAlphaTest; - uniform bool uColorKeyBlack; - void main() { - vec4 tex = texture(uTexture, vTexCoord); - if (uAlphaTest && tex.a < 0.5) discard; - if (uColorKeyBlack) { - float key = max(tex.r, max(tex.g, tex.b)); - if (key < 0.14) discard; - } - } - )"; - - auto compileStage = [](GLenum type, const char* src) -> GLuint { - GLuint shader = glCreateShader(type); - glShaderSource(shader, 1, &src, nullptr); - glCompileShader(shader); - GLint ok = 0; - glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); - if (!ok) { - char log[512]; - glGetShaderInfoLog(shader, sizeof(log), nullptr, log); - LOG_ERROR("Character shadow shader compile error: ", log); - glDeleteShader(shader); - return 0; - } - return shader; + std::vector charAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, position))}, + {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(CharVertexGPU, boneWeights))}, + {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(CharVertexGPU, boneIndices))}, + {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, normal))}, + {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(CharVertexGPU, texCoords))}, + {5, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast(offsetof(CharVertexGPU, tangent))}, }; - GLuint shVs = compileStage(GL_VERTEX_SHADER, shadowVertSrc); - GLuint shFs = compileStage(GL_FRAGMENT_SHADER, shadowFragSrc); - if (!shVs || !shFs) { - if (shVs) glDeleteShader(shVs); - if (shFs) glDeleteShader(shFs); - return false; + // --- Build pipelines --- + auto buildCharPipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline { + return PipelineBuilder() + .setShaders(charVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + charFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({charBinding}, charAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blendState) + .setMultisample(samples) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + opaquePipeline_ = buildCharPipeline(PipelineBuilder::blendDisabled(), true); + alphaTestPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), true); + alphaPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), false); + additivePipeline_ = buildCharPipeline(PipelineBuilder::blendAdditive(), false); + + // Clean up shader modules + charVert.destroy(); + charFrag.destroy(); + + // --- Create white fallback texture --- + { + uint8_t white[] = {255, 255, 255, 255}; + whiteTexture_ = std::make_unique(); + whiteTexture_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + whiteTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT); } - shadowCasterProgram = glCreateProgram(); - glAttachShader(shadowCasterProgram, shVs); - glAttachShader(shadowCasterProgram, shFs); - glLinkProgram(shadowCasterProgram); - GLint linked = 0; - glGetProgramiv(shadowCasterProgram, GL_LINK_STATUS, &linked); - glDeleteShader(shVs); - glDeleteShader(shFs); - if (!linked) { - char log[512]; - glGetProgramInfoLog(shadowCasterProgram, sizeof(log), nullptr, log); - LOG_ERROR("Character shadow shader link error: ", log); - glDeleteProgram(shadowCasterProgram); - shadowCasterProgram = 0; - return false; + // --- Create transparent fallback texture --- + { + uint8_t transparent[] = {0, 0, 0, 0}; + transparentTexture_ = std::make_unique(); + transparentTexture_->upload(*vkCtx_, transparent, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + transparentTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT); } - // Create 1x1 white fallback texture - uint8_t white[] = { 255, 255, 255, 255 }; - glGenTextures(1, &whiteTexture); - glBindTexture(GL_TEXTURE_2D, whiteTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, white); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - - // Create 1x1 transparent fallback texture for hidden texture slots. - uint8_t transparent[] = { 0, 0, 0, 0 }; - glGenTextures(1, &transparentTexture); - glBindTexture(GL_TEXTURE_2D, transparentTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, transparent); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glBindTexture(GL_TEXTURE_2D, 0); + // --- Create flat normal placeholder texture (128,128,255,128) = neutral normal, 0.5 height --- + { + uint8_t flatNormal[] = {128, 128, 255, 128}; + flatNormalTexture_ = std::make_unique(); + flatNormalTexture_->upload(*vkCtx_, flatNormal, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + flatNormalTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT); + } // Diagnostics-only: cache lifetime is currently tied to renderer lifetime. - textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 2048) * 1024ull * 1024ull; + textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 4096) * 1024ull * 1024ull; + LOG_INFO("Character texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB"); - core::Logger::getInstance().info("Character renderer initialized"); + core::Logger::getInstance().info("Character renderer initialized (Vulkan)"); return true; } void CharacterRenderer::shutdown() { - // Clean up GPU resources + if (!vkCtx_) return; + + LOG_INFO("CharacterRenderer::shutdown instances=", instances.size(), + " models=", models.size(), " override=", (void*)renderPassOverride_); + + vkDeviceWaitIdle(vkCtx_->getDevice()); + VkDevice device = vkCtx_->getDevice(); + VmaAllocator alloc = vkCtx_->getAllocator(); + + // Clean up GPU resources for models for (auto& pair : models) { - auto& gpuModel = pair.second; - if (gpuModel.vao) { - glDeleteVertexArrays(1, &gpuModel.vao); - glDeleteBuffers(1, &gpuModel.vbo); - glDeleteBuffers(1, &gpuModel.ebo); - } - for (GLuint texId : gpuModel.textureIds) { - if (texId && texId != whiteTexture) { - glDeleteTextures(1, &texId); - } - } + destroyModelGPU(pair.second); } - // Clean up texture cache - for (auto& pair : textureCache) { - GLuint texId = pair.second.id; - if (texId && texId != whiteTexture) { - glDeleteTextures(1, &texId); - } + // Clean up instance bone buffers + for (auto& pair : instances) { + destroyInstanceBones(pair.second); } + + // Clean up texture cache (VkTexture unique_ptrs auto-destroy) textureCache.clear(); - textureHasAlphaById_.clear(); - textureColorKeyBlackById_.clear(); + textureHasAlphaByPtr_.clear(); + textureColorKeyBlackByPtr_.clear(); textureCacheBytes_ = 0; textureCacheCounter_ = 0; - if (whiteTexture) { - glDeleteTextures(1, &whiteTexture); - whiteTexture = 0; - } - if (transparentTexture) { - glDeleteTextures(1, &transparentTexture); - transparentTexture = 0; - } + // Clean up composite cache + compositeCache_.clear(); + failedTextureCache_.clear(); + + whiteTexture_.reset(); + transparentTexture_.reset(); + flatNormalTexture_.reset(); models.clear(); instances.clear(); - characterShader.reset(); - if (shadowCasterProgram) { - glDeleteProgram(shadowCasterProgram); - shadowCasterProgram = 0; + + // Destroy pipelines + auto destroyPipeline = [&](VkPipeline& p) { + if (p) { vkDestroyPipeline(device, p, nullptr); p = VK_NULL_HANDLE; } + }; + destroyPipeline(opaquePipeline_); + destroyPipeline(alphaTestPipeline_); + destroyPipeline(alphaPipeline_); + destroyPipeline(additivePipeline_); + + if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } + + // Release any deferred transient material UBOs. + for (int i = 0; i < 2; i++) { + for (const auto& b : transientMaterialUbos_[i]) { + if (b.first) { + vmaDestroyBuffer(alloc, b.first, b.second); + } + } + transientMaterialUbos_[i].clear(); + } + + // Destroy descriptor pools and layouts + for (int i = 0; i < 2; i++) { + if (materialDescPools_[i]) { + vkDestroyDescriptorPool(device, materialDescPools_[i], nullptr); + materialDescPools_[i] = VK_NULL_HANDLE; + } + } + if (boneDescPool_) { vkDestroyDescriptorPool(device, boneDescPool_, nullptr); boneDescPool_ = VK_NULL_HANDLE; } + if (materialSetLayout_) { vkDestroyDescriptorSetLayout(device, materialSetLayout_, nullptr); materialSetLayout_ = VK_NULL_HANDLE; } + if (boneSetLayout_) { vkDestroyDescriptorSetLayout(device, boneSetLayout_, nullptr); boneSetLayout_ = VK_NULL_HANDLE; } + + // Shadow resources + if (shadowPipeline_) { vkDestroyPipeline(device, shadowPipeline_, nullptr); shadowPipeline_ = VK_NULL_HANDLE; } + if (shadowPipelineLayout_) { vkDestroyPipelineLayout(device, shadowPipelineLayout_, nullptr); shadowPipelineLayout_ = VK_NULL_HANDLE; } + if (shadowParamsPool_) { vkDestroyDescriptorPool(device, shadowParamsPool_, nullptr); shadowParamsPool_ = VK_NULL_HANDLE; } + if (shadowParamsLayout_) { vkDestroyDescriptorSetLayout(device, shadowParamsLayout_, nullptr); shadowParamsLayout_ = VK_NULL_HANDLE; } + if (shadowParamsUBO_) { vmaDestroyBuffer(alloc, shadowParamsUBO_, shadowParamsAlloc_); shadowParamsUBO_ = VK_NULL_HANDLE; shadowParamsAlloc_ = VK_NULL_HANDLE; } + + vkCtx_ = nullptr; +} + +void CharacterRenderer::destroyModelGPU(M2ModelGPU& gpuModel) { + if (!vkCtx_) return; + VmaAllocator alloc = vkCtx_->getAllocator(); + if (gpuModel.vertexBuffer) { vmaDestroyBuffer(alloc, gpuModel.vertexBuffer, gpuModel.vertexAlloc); gpuModel.vertexBuffer = VK_NULL_HANDLE; } + if (gpuModel.indexBuffer) { vmaDestroyBuffer(alloc, gpuModel.indexBuffer, gpuModel.indexAlloc); gpuModel.indexBuffer = VK_NULL_HANDLE; } +} + +void CharacterRenderer::destroyInstanceBones(CharacterInstance& inst) { + if (!vkCtx_) return; + VmaAllocator alloc = vkCtx_->getAllocator(); + VkDevice device = vkCtx_->getDevice(); + for (int i = 0; i < 2; i++) { + if (inst.boneSet[i] != VK_NULL_HANDLE && boneDescPool_ != VK_NULL_HANDLE) { + vkFreeDescriptorSets(device, boneDescPool_, 1, &inst.boneSet[i]); + inst.boneSet[i] = VK_NULL_HANDLE; + } + if (inst.boneBuffer[i]) { + vmaDestroyBuffer(alloc, inst.boneBuffer[i], inst.boneAlloc[i]); + inst.boneBuffer[i] = VK_NULL_HANDLE; + inst.boneAlloc[i] = VK_NULL_HANDLE; + inst.boneMapped[i] = nullptr; + } } } -GLuint CharacterRenderer::loadTexture(const std::string& path) { +std::unique_ptr CharacterRenderer::generateNormalHeightMap( + const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance) { + if (!vkCtx_ || width == 0 || height == 0) return nullptr; + + const uint32_t totalPixels = width * height; + + // Step 1: Compute height from luminance + std::vector heightMap(totalPixels); + double sumH = 0.0, sumH2 = 0.0; + for (uint32_t i = 0; i < totalPixels; i++) { + float r = pixels[i * 4 + 0] / 255.0f; + float g = pixels[i * 4 + 1] / 255.0f; + float b = pixels[i * 4 + 2] / 255.0f; + float h = 0.299f * r + 0.587f * g + 0.114f * b; + heightMap[i] = h; + sumH += h; + sumH2 += h * h; + } + double mean = sumH / totalPixels; + outVariance = static_cast(sumH2 / totalPixels - mean * mean); + + // Step 1.5: Box blur the height map to reduce noise from diffuse textures + auto wrapSample = [&](const std::vector& map, int x, int y) -> float { + x = ((x % (int)width) + (int)width) % (int)width; + y = ((y % (int)height) + (int)height) % (int)height; + return map[y * width + x]; + }; + + std::vector blurredHeight(totalPixels); + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + int ix = static_cast(x), iy = static_cast(y); + float sum = 0.0f; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + sum += wrapSample(heightMap, ix + dx, iy + dy); + blurredHeight[y * width + x] = sum / 9.0f; + } + } + + // Step 2: Sobel 3x3 β†’ normal map (crisp detail from original, blurred for POM alpha) + // Higher strength than WMO (2.0) because character/weapon textures are hand-painted + // with baked-in lighting that produces low-contrast gradients in the Sobel filter. + const float strength = 5.0f; + std::vector output(totalPixels * 4); + + auto sampleH = [&](int x, int y) -> float { + x = ((x % (int)width) + (int)width) % (int)width; + y = ((y % (int)height) + (int)height) % (int)height; + return heightMap[y * width + x]; + }; + + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + int ix = static_cast(x); + int iy = static_cast(y); + float gx = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix-1, iy) - sampleH(ix-1, iy+1) + + sampleH(ix+1, iy-1) + 2.0f*sampleH(ix+1, iy) + sampleH(ix+1, iy+1); + float gy = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix, iy-1) - sampleH(ix+1, iy-1) + + sampleH(ix-1, iy+1) + 2.0f*sampleH(ix, iy+1) + sampleH(ix+1, iy+1); + + float nx = -gx * strength; + float ny = -gy * strength; + float nz = 1.0f; + float len = std::sqrt(nx*nx + ny*ny + nz*nz); + if (len > 0.0f) { nx /= len; ny /= len; nz /= len; } + + uint32_t idx = (y * width + x) * 4; + output[idx + 0] = static_cast(std::clamp((nx * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); + output[idx + 1] = static_cast(std::clamp((ny * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); + output[idx + 2] = static_cast(std::clamp((nz * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); + output[idx + 3] = static_cast(std::clamp(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f)); + } + } + + auto tex = std::make_unique(); + if (!tex->upload(*vkCtx_, output.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true)) { + return nullptr; + } + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + return tex; +} + +VkTexture* CharacterRenderer::loadTexture(const std::string& path) { // Skip empty or whitespace-only paths (type-0 textures have no filename) - if (path.empty()) return whiteTexture; + if (path.empty()) return whiteTexture_.get(); bool allWhitespace = true; for (char c : path) { if (c != ' ' && c != '\t' && c != '\0' && c != '\n') { allWhitespace = false; break; } } - if (allWhitespace) return whiteTexture; + if (allWhitespace) return whiteTexture_.get(); auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); @@ -396,23 +521,39 @@ GLuint CharacterRenderer::loadTexture(const std::string& path) { auto it = textureCache.find(key); if (it != textureCache.end()) { it->second.lastUse = ++textureCacheCounter_; - return it->second.id; + return it->second.texture.get(); } if (!assetManager || !assetManager->isInitialized()) { - return whiteTexture; - } - - // Check negative cache to avoid repeated file I/O for textures that don't exist - if (failedTextureCache_.count(key)) { - return whiteTexture; + return whiteTexture_.get(); } auto blpImage = assetManager->loadTexture(key); if (!blpImage.isValid()) { - core::Logger::getInstance().warning("Failed to load texture: ", path); - failedTextureCache_.insert(key); - return whiteTexture; + // Return white fallback but don't cache the failure β€” allow retry + // on next character load in case the asset becomes available. + if (loggedTextureLoadFails_.insert(key).second) { + core::Logger::getInstance().warning("Failed to load texture: ", path); + } + return whiteTexture_.get(); + } + + size_t approxBytes = approxTextureBytesWithMips(blpImage.width, blpImage.height); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + static constexpr size_t kMaxFailedTextureCache = 200000; + if (failedTextureCache_.size() < kMaxFailedTextureCache) { + // Budget is saturated; avoid repeatedly decoding/uploading this texture. + failedTextureCache_.insert(key); + } + if (textureBudgetRejectWarnings_ < 3) { + core::Logger::getInstance().warning( + "Character texture cache full (", + textureCacheBytes_ / (1024 * 1024), " MB / ", + textureCacheBudgetBytes_ / (1024 * 1024), " MB), rejecting texture: ", + path); + } + ++textureBudgetRejectWarnings_; + return whiteTexture_.get(); } bool hasAlpha = false; @@ -423,37 +564,37 @@ GLuint CharacterRenderer::loadTexture(const std::string& path) { } } - GLuint texId; - glGenTextures(1, &texId); - glBindTexture(GL_TEXTURE_2D, texId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, blpImage.width, blpImage.height, - 0, GL_RGBA, GL_UNSIGNED_BYTE, blpImage.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); - glGenerateMipmap(GL_TEXTURE_2D); - applyAnisotropicFiltering(); - glBindTexture(GL_TEXTURE_2D, 0); + auto tex = std::make_unique(); + tex->upload(*vkCtx_, blpImage.data.data(), blpImage.width, blpImage.height, + VK_FORMAT_R8G8B8A8_UNORM, true); + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + + VkTexture* texPtr = tex.get(); TextureCacheEntry e; - e.id = texId; - e.approxBytes = approxTextureBytesWithMips(blpImage.width, blpImage.height); + e.texture = std::move(tex); + e.approxBytes = approxBytes; e.lastUse = ++textureCacheCounter_; e.hasAlpha = hasAlpha; e.colorKeyBlack = colorKeyBlackHint; - textureCacheBytes_ += e.approxBytes; - textureCache[key] = e; - textureHasAlphaById_[texId] = hasAlpha; - textureColorKeyBlackById_[texId] = colorKeyBlackHint; - if (textureCacheBytes_ > textureCacheBudgetBytes_) { - core::Logger::getInstance().warning( - "Character texture cache over budget: ", - textureCacheBytes_ / (1024 * 1024), " MB > ", - textureCacheBudgetBytes_ / (1024 * 1024), " MB (textures=", textureCache.size(), ")"); + + // Generate normal/height map from diffuse texture + float nhVariance = 0.0f; + auto nhMap = generateNormalHeightMap(blpImage.data.data(), blpImage.width, blpImage.height, nhVariance); + if (nhMap) { + e.heightMapVariance = nhVariance; + e.approxBytes += approxTextureBytesWithMips(blpImage.width, blpImage.height); + e.normalHeightMap = std::move(nhMap); } + + textureCacheBytes_ += e.approxBytes; + textureHasAlphaByPtr_[texPtr] = hasAlpha; + textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; + textureCache[key] = std::move(e); + core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")"); - return texId; + return texPtr; } // Alpha-blend overlay onto composite at (dstX, dstY) @@ -499,7 +640,7 @@ static void blitOverlayScaledN(std::vector& composite, int compW, int c uint8_t srcA = overlay.data[srcIdx + 3]; if (srcA == 0) continue; - // Write to scaleΓ—scale block of destination pixels + // Write to scale x scale block of destination pixels for (int dy2 = 0; dy2 < scale; dy2++) { int dy = dstY + sy * scale + dy2; if (dy < 0 || dy >= compH) continue; @@ -533,16 +674,26 @@ static void blitOverlayScaled2x(std::vector& composite, int compW, int blitOverlayScaledN(composite, compW, compH, overlay, dstX, dstY, 2); } -GLuint CharacterRenderer::compositeTextures(const std::vector& layerPaths) { +VkTexture* CharacterRenderer::compositeTextures(const std::vector& layerPaths) { if (layerPaths.empty() || !assetManager || !assetManager->isInitialized()) { - return whiteTexture; + return whiteTexture_.get(); + } + + // Composite key is deterministic from layer set; if we've already built it, + // reuse the existing GPU texture to keep live instance pointers valid. + std::string cacheKey = "__composite__"; + for (const auto& lp : layerPaths) { cacheKey += '|'; cacheKey += lp; } + auto cachedComposite = textureCache.find(cacheKey); + if (cachedComposite != textureCache.end()) { + cachedComposite->second.lastUse = ++textureCacheCounter_; + return cachedComposite->second.texture.get(); } // Load base layer auto base = assetManager->loadTexture(layerPaths[0]); if (!base.isValid()) { core::Logger::getInstance().warning("Composite: failed to load base layer: ", layerPaths[0]); - return whiteTexture; + return whiteTexture_.get(); } // Copy base pixel data as our working buffer @@ -628,7 +779,7 @@ GLuint CharacterRenderer::compositeTextures(const std::vector& laye dstX = 128; dstY = 160; expectedW256 = 128; expectedH256 = 64; } else { - // Unknown β€” center placement as fallback + // Unknown -- center placement as fallback dstX = (width - overlay.width) / 2; dstY = (height - overlay.height) / 2; core::Logger::getInstance().info("Composite: UNKNOWN region for '", @@ -674,31 +825,37 @@ GLuint CharacterRenderer::compositeTextures(const std::vector& laye } } - // Upload composite to GPU - GLuint texId; - glGenTextures(1, &texId); - glBindTexture(GL_TEXTURE_2D, texId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, composite.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); - glGenerateMipmap(GL_TEXTURE_2D); - applyAnisotropicFiltering(); - glBindTexture(GL_TEXTURE_2D, 0); + // Upload composite to GPU via VkTexture + auto tex = std::make_unique(); + tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true); + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + + VkTexture* texPtr = tex.get(); + + // Store in texture cache with deterministic key. + // Keep the first allocation for a key to avoid invalidating raw pointers + // held by active render instances. + TextureCacheEntry e; + e.texture = std::move(tex); + e.approxBytes = approxTextureBytesWithMips(width, height); + e.lastUse = ++textureCacheCounter_; + e.hasAlpha = false; + e.colorKeyBlack = false; + textureCache.emplace(cacheKey, std::move(e)); core::Logger::getInstance().info("Composite texture created: ", width, "x", height, " from ", layerPaths.size(), " layers"); - return texId; + return texPtr; } void CharacterRenderer::clearCompositeCache() { // Just clear the lookup map so next compositeWithRegions() creates fresh textures. - // Don't delete GPU textures β€” they may still be referenced by models or instances. + // Don't delete GPU textures -- they may still be referenced by models or instances. // Orphaned textures will be cleaned up when their model/instance is destroyed. compositeCache_.clear(); } -GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, +VkTexture* CharacterRenderer::compositeWithRegions(const std::string& basePath, const std::vector& baseLayers, const std::vector>& regionLayers) { // Build cache key from all inputs to avoid redundant compositing @@ -712,11 +869,22 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, cacheKey += ','; } auto cacheIt = compositeCache_.find(cacheKey); - if (cacheIt != compositeCache_.end() && cacheIt->second != 0) { + if (cacheIt != compositeCache_.end() && cacheIt->second != nullptr) { return cacheIt->second; } - // Region index β†’ pixel coordinates on the 256x256 base atlas + // If the lookup map was cleared, recover from the texture cache without + // regenerating/replacing the underlying GPU texture. + std::string storageKey = "__compositeRegions__" + cacheKey; + auto cachedComposite = textureCache.find(storageKey); + if (cachedComposite != textureCache.end()) { + cachedComposite->second.lastUse = ++textureCacheCounter_; + VkTexture* texPtr = cachedComposite->second.texture.get(); + compositeCache_[cacheKey] = texPtr; + return texPtr; + } + + // Region index -> pixel coordinates on the 256x256 base atlas // These are scaled up by (width/256, height/256) for larger textures (512x512, 1024x1024) static const int regionCoords256[][2] = { { 0, 0 }, // 0 = ArmUpper @@ -737,20 +905,18 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, } // Load base composite into CPU buffer if (!assetManager || !assetManager->isInitialized()) { - return whiteTexture; + return whiteTexture_.get(); } auto base = assetManager->loadTexture(basePath); if (!base.isValid()) { - return whiteTexture; + return whiteTexture_.get(); } std::vector composite; int width = base.width; int height = base.height; - - // If base texture is 256x256 (e.g., baked NPC texture), upscale to 512x512 // so equipment regions can be composited at correct coordinates if (width == 256 && height == 256 && !regionLayers.empty()) { @@ -776,7 +942,7 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, } // Blend face + underwear overlays - // If we upscaled from 256β†’512, scale coords and texels with blitOverlayScaled2x. + // If we upscaled from 256->512, scale coords and texels with blitOverlayScaled2x. // For native 512/1024 textures, face overlays are full atlas size (hit width==width branch). bool upscaled = (base.width == 256 && base.height == 256 && width == 512); for (const auto& ul : baseLayers) { @@ -881,26 +1047,38 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, " at (", dstX, ",", dstY, ") ", overlay.width, "x", overlay.height, " from ", rl.second); } - // Upload to GPU - GLuint texId; - glGenTextures(1, &texId); - glBindTexture(GL_TEXTURE_2D, texId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, composite.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); - glGenerateMipmap(GL_TEXTURE_2D); - applyAnisotropicFiltering(); - glBindTexture(GL_TEXTURE_2D, 0); + // Upload to GPU via VkTexture + auto tex = std::make_unique(); + tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true); + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + + VkTexture* texPtr = tex.get(); + + // Store in texture cache. + // Use emplace to avoid replacing an existing texture for this key; replacing + // would invalidate pointers currently bound to active instances. + TextureCacheEntry entry; + entry.texture = std::move(tex); + entry.approxBytes = approxTextureBytesWithMips(width, height); + entry.lastUse = ++textureCacheCounter_; + entry.hasAlpha = false; + entry.colorKeyBlack = false; + auto ins = textureCache.emplace(storageKey, std::move(entry)); + if (!ins.second) { + // Existing texture already owns this key; keep pointer stable. + ins.first->second.lastUse = ++textureCacheCounter_; + compositeCache_[cacheKey] = ins.first->second.texture.get(); + return ins.first->second.texture.get(); + } core::Logger::getInstance().debug("compositeWithRegions: created ", width, "x", height, " texture with ", regionLayers.size(), " equipment regions"); - compositeCache_[cacheKey] = texId; - return texId; + compositeCache_[cacheKey] = texPtr; + return texPtr; } -void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, GLuint textureId) { +void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, VkTexture* texture) { auto it = models.find(modelId); if (it == models.end()) { core::Logger::getInstance().warning("setModelTexture: model ", modelId, " not found"); @@ -913,24 +1091,12 @@ void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, return; } - // Delete old texture if it's not shared and not in the texture cache - GLuint oldTex = gpuModel.textureIds[textureSlot]; - if (oldTex && oldTex != whiteTexture) { - bool cached = false; - for (const auto& [k, v] : textureCache) { - if (v.id == oldTex) { cached = true; break; } - } - if (!cached) { - glDeleteTextures(1, &oldTex); - } - } - - gpuModel.textureIds[textureSlot] = textureId; + gpuModel.textureIds[textureSlot] = texture; core::Logger::getInstance().debug("Replaced model ", modelId, " texture slot ", textureSlot, " with composited texture"); } void CharacterRenderer::resetModelTexture(uint32_t modelId, uint32_t textureSlot) { - setModelTexture(modelId, textureSlot, whiteTexture); + setModelTexture(modelId, textureSlot, whiteTexture_.get()); } bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) { @@ -941,12 +1107,7 @@ bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) { if (models.find(id) != models.end()) { core::Logger::getInstance().warning("Model ID ", id, " already loaded, replacing"); - auto& old = models[id]; - if (old.vao) { - glDeleteVertexArrays(1, &old.vao); - glDeleteBuffers(1, &old.vbo); - glDeleteBuffers(1, &old.ebo); - } + destroyModelGPU(models[id]); } M2ModelGPU gpuModel; @@ -960,8 +1121,8 @@ bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) { // Load textures from model for (const auto& tex : model.textures) { - GLuint texId = loadTexture(tex.filename); - gpuModel.textureIds.push_back(texId); + VkTexture* texPtr = loadTexture(tex.filename); + gpuModel.textureIds.push_back(texPtr); } models[id] = std::move(gpuModel); @@ -976,48 +1137,87 @@ bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) { void CharacterRenderer::setupModelBuffers(M2ModelGPU& gpuModel) { auto& model = gpuModel.data; - glGenVertexArrays(1, &gpuModel.vao); - glGenBuffers(1, &gpuModel.vbo); - glGenBuffers(1, &gpuModel.ebo); + if (model.vertices.empty() || model.indices.empty()) return; - glBindVertexArray(gpuModel.vao); + const size_t vertCount = model.vertices.size(); + const size_t idxCount = model.indices.size(); - // Interleaved vertex data - glBindBuffer(GL_ARRAY_BUFFER, gpuModel.vbo); - glBufferData(GL_ARRAY_BUFFER, model.vertices.size() * sizeof(pipeline::M2Vertex), - model.vertices.data(), GL_STATIC_DRAW); + // Build expanded GPU vertex buffer with tangents (Lengyel's method) + std::vector gpuVerts(vertCount); + std::vector tanAccum(vertCount, glm::vec3(0.0f)); + std::vector bitanAccum(vertCount, glm::vec3(0.0f)); - // Position - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex), - (void*)offsetof(pipeline::M2Vertex, position)); + // Copy base vertex data + for (size_t i = 0; i < vertCount; i++) { + const auto& src = model.vertices[i]; + auto& dst = gpuVerts[i]; + dst.position = src.position; + std::memcpy(dst.boneWeights, src.boneWeights, 4); + std::memcpy(dst.boneIndices, src.boneIndices, 4); + dst.normal = src.normal; + dst.texCoords = src.texCoords[0]; // Use first UV set + dst.tangent = glm::vec4(1.0f, 0.0f, 0.0f, 1.0f); // default + } - // Bone weights (normalize uint8 to float) - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(pipeline::M2Vertex), - (void*)offsetof(pipeline::M2Vertex, boneWeights)); + // Accumulate tangent/bitangent per triangle + for (size_t i = 0; i + 2 < idxCount; i += 3) { + uint16_t i0 = model.indices[i], i1 = model.indices[i+1], i2 = model.indices[i+2]; + if (i0 >= vertCount || i1 >= vertCount || i2 >= vertCount) continue; - // Bone indices - glEnableVertexAttribArray(2); - glVertexAttribIPointer(2, 4, GL_UNSIGNED_BYTE, sizeof(pipeline::M2Vertex), - (void*)offsetof(pipeline::M2Vertex, boneIndices)); + const glm::vec3& p0 = gpuVerts[i0].position; + const glm::vec3& p1 = gpuVerts[i1].position; + const glm::vec3& p2 = gpuVerts[i2].position; + const glm::vec2& uv0 = gpuVerts[i0].texCoords; + const glm::vec2& uv1 = gpuVerts[i1].texCoords; + const glm::vec2& uv2 = gpuVerts[i2].texCoords; - // Normal - glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex), - (void*)offsetof(pipeline::M2Vertex, normal)); + glm::vec3 edge1 = p1 - p0; + glm::vec3 edge2 = p2 - p0; + glm::vec2 duv1 = uv1 - uv0; + glm::vec2 duv2 = uv2 - uv0; - // TexCoord (first UV set) - glEnableVertexAttribArray(4); - glVertexAttribPointer(4, 2, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex), - (void*)offsetof(pipeline::M2Vertex, texCoords)); + float det = duv1.x * duv2.y - duv2.x * duv1.y; + if (std::abs(det) < 1e-8f) continue; + float invDet = 1.0f / det; - // Index buffer - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuModel.ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.indices.size() * sizeof(uint16_t), - model.indices.data(), GL_STATIC_DRAW); + glm::vec3 t = (edge1 * duv2.y - edge2 * duv1.y) * invDet; + glm::vec3 b = (edge2 * duv1.x - edge1 * duv2.x) * invDet; - glBindVertexArray(0); + tanAccum[i0] += t; tanAccum[i1] += t; tanAccum[i2] += t; + bitanAccum[i0] += b; bitanAccum[i1] += b; bitanAccum[i2] += b; + } + + // Orthogonalize and compute handedness + for (size_t i = 0; i < vertCount; i++) { + const glm::vec3& n = gpuVerts[i].normal; + const glm::vec3& t = tanAccum[i]; + if (glm::dot(t, t) < 1e-8f) { + gpuVerts[i].tangent = glm::vec4(1.0f, 0.0f, 0.0f, 1.0f); + continue; + } + // Gram-Schmidt orthogonalize + glm::vec3 tOrtho = glm::normalize(t - n * glm::dot(n, t)); + float w = (glm::dot(glm::cross(n, t), bitanAccum[i]) < 0.0f) ? -1.0f : 1.0f; + gpuVerts[i].tangent = glm::vec4(tOrtho, w); + } + + // Upload vertex buffer (CharVertexGPU, 56 bytes per vertex) + auto vb = uploadBuffer(*vkCtx_, + gpuVerts.data(), + gpuVerts.size() * sizeof(CharVertexGPU), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + gpuModel.vertexBuffer = vb.buffer; + gpuModel.vertexAlloc = vb.allocation; + gpuModel.vertexCount = static_cast(vertCount); + + // Upload index buffer + auto ib = uploadBuffer(*vkCtx_, + model.indices.data(), + idxCount * sizeof(uint16_t), + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + gpuModel.indexBuffer = ib.buffer; + gpuModel.indexAlloc = ib.allocation; + gpuModel.indexCount = static_cast(idxCount); } void CharacterRenderer::calculateBindPose(M2ModelGPU& gpuModel) { @@ -1057,8 +1257,9 @@ uint32_t CharacterRenderer::createInstance(uint32_t modelId, const glm::vec3& po auto& model = models[modelId].data; instance.boneMatrices.resize(std::max(static_cast(1), model.bones.size()), glm::mat4(1.0f)); - instances[instance.id] = instance; - return instance.id; + uint32_t id = instance.id; + instances[id] = std::move(instance); + return id; } void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, bool loop) { @@ -1100,10 +1301,10 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, // Only log missing animation once per model (reduce spam) static std::unordered_map> loggedMissingAnims; - uint32_t modelId = instance.modelId; // Use modelId as identifier - if (loggedMissingAnims[modelId].insert(animationId).second) { + uint32_t mId = instance.modelId; // Use modelId as identifier + if (loggedMissingAnims[mId].insert(animationId).second) { // First time seeing this missing animation for this model - LOG_WARNING("Animation ", animationId, " not found in model ", modelId, ", using default"); + LOG_WARNING("Animation ", animationId, " not found in model ", mId, ", using default"); } } } @@ -1153,22 +1354,44 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { } } - int updatedCount = toUpdate.size(); + const size_t updatedCount = toUpdate.size(); - // Thread bone calculations if we have many characters (4+) - if (updatedCount >= 4) { - std::vector> futures; - futures.reserve(updatedCount); + // Thread animation updates in chunks to avoid spawning one task per instance. + if (updatedCount >= 8 && numAnimThreads_ > 1) { + static const size_t minAnimWorkPerThread = std::max( + 16, envSizeOrDefault("WOWEE_CHAR_ANIM_WORK_PER_THREAD", 64)); + const size_t maxUsefulThreads = std::max( + 1, (updatedCount + minAnimWorkPerThread - 1) / minAnimWorkPerThread); + const size_t numThreads = std::min(static_cast(numAnimThreads_), maxUsefulThreads); - for (auto& instRef : toUpdate) { - futures.push_back(std::async(std::launch::async, [this, &instRef, deltaTime]() { + if (numThreads <= 1) { + for (auto& instRef : toUpdate) { updateAnimation(instRef.get(), deltaTime); - })); - } + } + } else { + const size_t chunkSize = updatedCount / numThreads; + const size_t remainder = updatedCount % numThreads; - // Wait for all to complete - for (auto& f : futures) { - f.get(); + animFutures_.clear(); + if (animFutures_.capacity() < numThreads) { + animFutures_.reserve(numThreads); + } + + size_t start = 0; + for (size_t t = 0; t < numThreads; t++) { + size_t end = start + chunkSize + (t < remainder ? 1 : 0); + animFutures_.push_back(std::async(std::launch::async, + [this, &toUpdate, start, end, deltaTime]() { + for (size_t i = start; i < end; i++) { + updateAnimation(toUpdate[i].get(), deltaTime); + } + })); + start = end; + } + + for (auto& f : animFutures_) { + f.get(); + } } } else { // Sequential for small counts (avoid thread overhead) @@ -1177,13 +1400,6 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { } } - static int logCounter = 0; - if (++logCounter >= 300) { // Log every 10 seconds at 30fps - LOG_DEBUG("CharacterRenderer: ", updatedCount, "/", instances.size(), " instances updated (", - instances.size() - updatedCount, " culled)"); - logCounter = 0; - } - // Update weapon attachment transforms (after all bone matrices are computed) for (auto& pair : instances) { auto& instance = pair.second; @@ -1212,7 +1428,11 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { } void CharacterRenderer::updateAnimation(CharacterInstance& instance, float deltaTime) { - auto& model = models[instance.modelId].data; + auto modelIt = models.find(instance.modelId); + if (modelIt == models.end()) { + return; + } + const auto& model = modelIt->second.data; if (model.sequences.empty()) { return; @@ -1390,52 +1610,50 @@ glm::mat4 CharacterRenderer::getBoneTransform(const pipeline::M2Bone& bone, floa // --- Rendering --- -void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection) { - if (instances.empty()) { +void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (instances.empty() || !opaquePipeline_) { return; } - glEnable(GL_DEPTH_TEST); - glDisable(GL_CULL_FACE); // M2 models have mixed winding; render both sides - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + uint32_t frameIndex = vkCtx_->getCurrentFrame(); + uint32_t frameSlot = frameIndex % 2u; - characterShader->use(); - characterShader->setUniform("uView", view); - characterShader->setUniform("uProjection", projection); - characterShader->setUniform("uLightDir", lightDir); - characterShader->setUniform("uLightColor", lightColor); - characterShader->setUniform("uSpecularIntensity", 0.5f); - characterShader->setUniform("uViewPos", camera.getPosition()); - - // Fog - characterShader->setUniform("uFogColor", fogColor); - characterShader->setUniform("uFogStart", fogStart); - characterShader->setUniform("uFogEnd", fogEnd); - - // Shadows - characterShader->setUniform("uShadowEnabled", shadowEnabled ? 1 : 0); - characterShader->setUniform("uShadowStrength", 0.68f); - characterShader->setUniform("uTexture0", 0); - characterShader->setUniform("uAlphaTest", 0); - characterShader->setUniform("uColorKeyBlack", 0); - characterShader->setUniform("uUnlit", 0); - characterShader->setUniform("uEmissiveBoost", 1.0f); - characterShader->setUniform("uEmissiveTint", glm::vec3(1.0f)); - if (shadowEnabled) { - characterShader->setUniform("uLightSpaceMatrix", lightSpaceMatrix); - glActiveTexture(GL_TEXTURE7); - glBindTexture(GL_TEXTURE_2D, shadowDepthTex); - characterShader->setUniform("uShadowMap", 7); + // Reset transient material allocations once per frame slot. + // beginFrame() waits on this slot's fence before recording. + if (lastMaterialPoolResetFrame_ != frameIndex) { + VmaAllocator alloc = vkCtx_->getAllocator(); + for (const auto& b : transientMaterialUbos_[frameSlot]) { + if (b.first) { + vmaDestroyBuffer(alloc, b.first, b.second); + } + } + transientMaterialUbos_[frameSlot].clear(); + if (materialDescPools_[frameSlot]) { + vkResetDescriptorPool(vkCtx_->getDevice(), materialDescPools_[frameSlot], 0); + } + lastMaterialPoolResetFrame_ = frameIndex; } + // Bind per-frame descriptor set (set 0) -- shared across all draws + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + + // Start with opaque pipeline + VkPipeline currentPipeline = opaquePipeline_; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, currentPipeline); + for (const auto& pair : instances) { const auto& instance = pair.second; // Skip invisible instances (e.g., player in first-person mode) if (!instance.visible) continue; - const auto& gpuModel = models[instance.modelId]; + auto modelIt = models.find(instance.modelId); + if (modelIt == models.end()) continue; + const auto& gpuModel = modelIt->second; + + // Skip models without GPU buffers + if (!gpuModel.vertexBuffer) continue; // Skip fully transparent instances if (instance.opacity <= 0.0f) continue; @@ -1444,28 +1662,88 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons glm::mat4 modelMat = instance.hasOverrideModelMatrix ? instance.overrideModelMatrix : getModelMatrix(instance); - characterShader->setUniform("uModel", modelMat); - characterShader->setUniform("uOpacity", instance.opacity); - // Set bone matrices (upload all at once for performance) + // Push model matrix + vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(glm::mat4), &modelMat); + + // Upload bone matrices to SSBO int numBones = std::min(static_cast(instance.boneMatrices.size()), MAX_BONES); if (numBones > 0) { - characterShader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones); + // Lazy-allocate bone SSBO on first use + auto& instMut = const_cast(instance); + if (!instMut.boneBuffer[frameIndex]) { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = MAX_BONES * sizeof(glm::mat4); + bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, + &instMut.boneBuffer[frameIndex], &instMut.boneAlloc[frameIndex], &allocInfo); + instMut.boneMapped[frameIndex] = allocInfo.pMappedData; + + // Allocate descriptor set for bone SSBO + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = boneDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &boneSetLayout_; + VkResult dsRes = vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &instMut.boneSet[frameIndex]); + if (dsRes != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer: bone descriptor allocation failed (instance=", + instMut.id, ", frame=", frameIndex, ", vk=", static_cast(dsRes), ")"); + if (instMut.boneBuffer[frameIndex]) { + vmaDestroyBuffer(vkCtx_->getAllocator(), + instMut.boneBuffer[frameIndex], instMut.boneAlloc[frameIndex]); + instMut.boneBuffer[frameIndex] = VK_NULL_HANDLE; + instMut.boneAlloc[frameIndex] = VK_NULL_HANDLE; + instMut.boneMapped[frameIndex] = nullptr; + } + } + + if (instMut.boneSet[frameIndex]) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = instMut.boneBuffer[frameIndex]; + bufInfo.offset = 0; + bufInfo.range = bci.size; + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = instMut.boneSet[frameIndex]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + + // Upload bone matrices + if (instMut.boneMapped[frameIndex]) { + memcpy(instMut.boneMapped[frameIndex], instance.boneMatrices.data(), + numBones * sizeof(glm::mat4)); + } + + // Bind bone descriptor set (set 2) + if (instMut.boneSet[frameIndex]) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 2, 1, &instMut.boneSet[frameIndex], 0, nullptr); + } } - // Bind VAO and draw - glBindVertexArray(gpuModel.vao); + // Bind vertex and index buffers + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &gpuModel.vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, gpuModel.indexBuffer, 0, VK_INDEX_TYPE_UINT16); - if (!gpuModel.data.batches.empty()) { - bool applyGeosetFilter = !instance.activeGeosets.empty(); - if (applyGeosetFilter) { - bool hasRenderableGeoset = false; - for (const auto& batch : gpuModel.data.batches) { - if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) { - hasRenderableGeoset = true; - break; - } - } + if (!gpuModel.data.batches.empty()) { + bool applyGeosetFilter = !instance.activeGeosets.empty(); + if (applyGeosetFilter) { + bool hasRenderableGeoset = false; + for (const auto& batch : gpuModel.data.batches) { + if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) { + hasRenderableGeoset = true; + break; + } + } if (!hasRenderableGeoset) { static std::unordered_set loggedGeosetFallback; if (loggedGeosetFallback.insert(instance.id).second) { @@ -1473,111 +1751,76 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons instance.id, " (model ", instance.modelId, "); rendering all batches as fallback"); } - applyGeosetFilter = false; - } - } + applyGeosetFilter = false; + } + } - auto resolveBatchTexture = [&](const CharacterInstance& inst, const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint { - // A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex. - // We currently bind only a single texture, so pick the most appropriate one. - // - // This matters for hair: the first texture in the combo can be a mask/empty slot, - // causing the hair to render as solid white. - if (b.textureIndex == 0xFFFF) return whiteTexture; - if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture; + auto resolveBatchTexture = [&](const CharacterInstance& inst, const M2ModelGPU& gm, const pipeline::M2Batch& b) -> VkTexture* { + // A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex. + // We currently bind only a single texture, so pick the most appropriate one. + if (b.textureIndex == 0xFFFF) return whiteTexture_.get(); + if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture_.get(); - uint32_t comboCount = b.textureCount ? static_cast(b.textureCount) : 1u; - comboCount = std::min(comboCount, 8u); + uint32_t comboCount = b.textureCount ? static_cast(b.textureCount) : 1u; + comboCount = std::min(comboCount, 8u); - struct Candidate { GLuint id; uint32_t type; }; - Candidate first{whiteTexture, 0}; - bool hasFirst = false; - Candidate firstNonWhite{whiteTexture, 0}; - bool hasFirstNonWhite = false; + struct Candidate { VkTexture* tex; uint32_t type; }; + Candidate first{whiteTexture_.get(), 0}; + bool hasFirst = false; + Candidate firstNonWhite{whiteTexture_.get(), 0}; + bool hasFirstNonWhite = false; - for (uint32_t i = 0; i < comboCount; i++) { - uint32_t lookupPos = static_cast(b.textureIndex) + i; - if (lookupPos >= gm.data.textureLookup.size()) break; - uint16_t texSlot = gm.data.textureLookup[lookupPos]; - if (texSlot >= gm.textureIds.size()) continue; + for (uint32_t i = 0; i < comboCount; i++) { + uint32_t lookupPos = static_cast(b.textureIndex) + i; + if (lookupPos >= gm.data.textureLookup.size()) break; + uint16_t texSlot = gm.data.textureLookup[lookupPos]; + if (texSlot >= gm.textureIds.size()) continue; - GLuint texId = gm.textureIds[texSlot]; - uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0; - // Apply texture slot overrides. - // For type-1 (skin) overrides, only apply to skin-group batches - // to prevent the skin composite from bleeding onto cloak/hair. - { - auto itO = inst.textureSlotOverrides.find(texSlot); - if (itO != inst.textureSlotOverrides.end() && itO->second != 0) { - if (texType == 1) { - // Only apply skin override to skin groups - uint16_t grp = b.submeshId / 100; - bool isSkinGroup = (grp == 0 || grp == 3 || grp == 4 || grp == 5 || - grp == 8 || grp == 9 || grp == 13 || grp == 20); - if (isSkinGroup) texId = itO->second; - } else { - texId = itO->second; - } + VkTexture* texPtr = gm.textureIds[texSlot]; + uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0; + // Apply texture slot overrides. + // For type-1 (skin) overrides, only apply to skin-group batches + // to prevent the skin composite from bleeding onto cloak/hair. + { + auto itO = inst.textureSlotOverrides.find(texSlot); + if (itO != inst.textureSlotOverrides.end() && itO->second != nullptr) { + if (texType == 1) { + // Only apply skin override to skin groups + uint16_t grp = b.submeshId / 100; + bool isSkinGroup = (grp == 0 || grp == 3 || grp == 4 || grp == 5 || + grp == 8 || grp == 9 || grp == 13 || grp == 20); + if (isSkinGroup) texPtr = itO->second; + } else { + texPtr = itO->second; } } + } - if (!hasFirst) { - first = {texId, texType}; - hasFirst = true; - } + if (!hasFirst) { + first = {texPtr, texType}; + hasFirst = true; + } - if (texId == 0 || texId == whiteTexture) continue; + if (texPtr == nullptr || texPtr == whiteTexture_.get()) continue; - // Prefer the hair texture slot (type 6) whenever present in the combo. - // Humanoid scalp meshes can live in group 0, so group-based checks are insufficient. - if (texType == 6) { - return texId; - } + // Prefer the hair texture slot (type 6) whenever present in the combo. + if (texType == 6) { + return texPtr; + } - if (!hasFirstNonWhite) { - firstNonWhite = {texId, texType}; - hasFirstNonWhite = true; - } - } - - if (hasFirstNonWhite) return firstNonWhite.id; - if (hasFirst && first.id != 0) return first.id; - return whiteTexture; - }; - - // One-time debug dump of rendered batches per model - static std::unordered_set dumpedModels; - if (dumpedModels.find(instance.modelId) == dumpedModels.end()) { - dumpedModels.insert(instance.modelId); - int bIdx = 0; - int rendered = 0, skipped = 0; - for (const auto& b : gpuModel.data.batches) { - bool filtered = applyGeosetFilter && - (b.submeshId / 100 != 0) && - instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end(); - - GLuint resolvedTex = resolveBatchTexture(instance, gpuModel, b); - std::string texInfo = "GL" + std::to_string(resolvedTex); - - if (filtered) skipped++; else rendered++; - LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId, - " level=", b.submeshLevel, - " idxStart=", b.indexStart, " idxCount=", b.indexCount, - " tex=", texInfo, - filtered ? " [SKIP]" : " [RENDER]"); - bIdx++; + if (!hasFirstNonWhite) { + firstNonWhite = {texPtr, texType}; + hasFirstNonWhite = true; + } } - LOG_DEBUG("Batch summary: ", rendered, " rendered, ", skipped, " skipped, ", - gpuModel.textureIds.size(), " textures loaded, ", - gpuModel.data.textureLookup.size(), " in lookup table"); - for (size_t t = 0; t < gpuModel.data.textures.size(); t++) { - } - } - // Draw batches (submeshes) with per-batch textures - // Geoset filtering: skip batches whose submeshId is not in activeGeosets. - // For character models, group 0 (body/scalp) is also filtered so that only - // the correct scalp mesh renders (not all overlapping variants). + if (hasFirstNonWhite) return firstNonWhite.tex; + if (hasFirst && first.tex != nullptr) return first.tex; + return whiteTexture_.get(); + }; + + // One-time debug dump of rendered batches per model + // Draw batches (submeshes) with per-batch textures for (const auto& batch : gpuModel.data.batches) { if (applyGeosetFilter) { if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) { @@ -1585,12 +1828,12 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons } } - // Resolve texture for this batch (prefer hair textures for hair geosets). - GLuint texId = resolveBatchTexture(instance, gpuModel, batch); + // Resolve texture for this batch (prefer hair textures for hair geosets). + VkTexture* texPtr = resolveBatchTexture(instance, gpuModel, batch); const uint16_t batchGroup = static_cast(batch.submeshId / 100); auto groupTexIt = instance.groupTextureOverrides.find(batchGroup); - if (groupTexIt != instance.groupTextureOverrides.end() && groupTexIt->second != 0) { - texId = groupTexIt->second; + if (groupTexIt != instance.groupTextureOverrides.end() && groupTexIt->second != nullptr) { + texPtr = groupTexIt->second; } // Respect M2 material blend mode for creature/character submeshes. @@ -1607,27 +1850,28 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons if (instance.hasOverrideModelMatrix && blendMode >= 3) { continue; } + + // Select pipeline based on blend mode + VkPipeline desiredPipeline; switch (blendMode) { - case 0: glBlendFunc(GL_ONE, GL_ZERO); break; // Opaque - case 1: glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); break; // AlphaKey - case 2: glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); break; // Alpha - case 3: glBlendFunc(GL_SRC_ALPHA, GL_ONE); break; // Additive - case 4: glBlendFunc(GL_DST_COLOR, GL_ZERO); break; // Mod - case 5: glBlendFunc(GL_DST_COLOR, GL_SRC_COLOR); break; // Mod2x - case 6: glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); break; // BlendAdd - default: glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); break; + case 0: desiredPipeline = opaquePipeline_; break; + case 1: desiredPipeline = alphaTestPipeline_; break; + case 2: desiredPipeline = alphaPipeline_; break; + case 3: + case 6: desiredPipeline = additivePipeline_; break; + default: desiredPipeline = alphaPipeline_; break; + } + if (desiredPipeline != currentPipeline) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); + currentPipeline = desiredPipeline; } - // For body/equipment parts with white/fallback texture, use skin (type 1) texture. - // Groups that share the body skin atlas: 0=body, 3=gloves, 4=boots, 5=chest, - // 8=wristbands, 9=pelvis, 13=pants. Hair (group 1) and facial hair (group 2) do NOT. - if (texId == whiteTexture) { + if (texPtr == whiteTexture_.get()) { uint16_t group = batchGroup; bool isSkinGroup = (group == 0 || group == 3 || group == 4 || group == 5 || group == 8 || group == 9 || group == 13); if (isSkinGroup) { - // Check if this batch's texture slot is a hair type (don't override hair) uint32_t texType = 0; if (batch.textureIndex < gpuModel.data.textureLookup.size()) { uint16_t lk = gpuModel.data.textureLookup[batch.textureIndex]; @@ -1638,16 +1882,15 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons // Do NOT apply skin composite to hair (type 6) batches if (texType != 6) { for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) { - GLuint candidate = gpuModel.textureIds[ti]; + VkTexture* candidate = gpuModel.textureIds[ti]; auto itO = instance.textureSlotOverrides.find(static_cast(ti)); - if (itO != instance.textureSlotOverrides.end() && itO->second != 0) { + if (itO != instance.textureSlotOverrides.end() && itO->second != nullptr) { candidate = itO->second; } - if (candidate != whiteTexture && candidate != 0) { - // Only use type 1 (skin) textures as fallback + if (candidate != whiteTexture_.get() && candidate != nullptr) { if (ti < gpuModel.data.textures.size() && (gpuModel.data.textures[ti].type == 1 || gpuModel.data.textures[ti].type == 11)) { - texId = candidate; + texPtr = candidate; break; } } @@ -1656,21 +1899,18 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons } } - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, texId); + // Determine material properties bool alphaCutout = false; bool colorKeyBlack = false; - if (texId != 0 && texId != whiteTexture) { - auto ait = textureHasAlphaById_.find(texId); - alphaCutout = (ait != textureHasAlphaById_.end()) ? ait->second : false; - auto cit = textureColorKeyBlackById_.find(texId); - colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false; + if (texPtr != nullptr && texPtr != whiteTexture_.get()) { + auto ait = textureHasAlphaByPtr_.find(texPtr); + alphaCutout = (ait != textureHasAlphaByPtr_.end()) ? ait->second : false; + auto cit = textureColorKeyBlackByPtr_.find(texPtr); + colorKeyBlack = (cit != textureColorKeyBlackByPtr_.end()) ? cit->second : false; } const bool blendNeedsCutout = (blendMode == 1) || (blendMode >= 2 && !alphaCutout); - characterShader->setUniform("uAlphaTest", (blendNeedsCutout || alphaCutout) ? 1 : 0); - characterShader->setUniform("uColorKeyBlack", (blendNeedsCutout || colorKeyBlack) ? 1 : 0); const bool unlit = ((materialFlags & 0x01) != 0) || (blendMode >= 3); - characterShader->setUniform("uUnlit", unlit ? 1 : 0); + float emissiveBoost = 1.0f; glm::vec3 emissiveTint(1.0f, 1.0f, 1.0f); // Keep custom warm/flicker treatment narrowly scoped to kobold candle flames. @@ -1695,125 +1935,480 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons float flicker = 0.90f + 0.10f * f1 + 0.06f * f2 + 0.04f * f3; flicker = std::clamp(flicker, 0.72f, 1.12f); emissiveBoost = (blendMode >= 3) ? (2.4f * flicker) : (1.5f * flicker); - // Warm flame bias to avoid green cast from source textures. emissiveTint = glm::vec3(1.28f, 1.04f, 0.82f); } - characterShader->setUniform("uEmissiveBoost", emissiveBoost); - characterShader->setUniform("uEmissiveTint", emissiveTint); - glDrawElements(GL_TRIANGLES, - batch.indexCount, - GL_UNSIGNED_SHORT, - (void*)(batch.indexStart * sizeof(uint16_t))); - } - } else { - // Draw entire model with first texture - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, !gpuModel.textureIds.empty() ? gpuModel.textureIds[0] : whiteTexture); - characterShader->setUniform("uAlphaTest", 0); - characterShader->setUniform("uColorKeyBlack", 0); - characterShader->setUniform("uUnlit", 0); - characterShader->setUniform("uEmissiveBoost", 1.0f); - characterShader->setUniform("uEmissiveTint", glm::vec3(1.0f)); + // Allocate and fill material descriptor set (set 1) + VkDescriptorSet materialSet = VK_NULL_HANDLE; + { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPools_[frameSlot]; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &materialSetLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &materialSet) != VK_SUCCESS) { + continue; // Pool exhausted, skip this batch + } + } - glDrawElements(GL_TRIANGLES, - static_cast(gpuModel.data.indices.size()), - GL_UNSIGNED_SHORT, - 0); - } - } - - glBindVertexArray(0); - glDisable(GL_BLEND); - glEnable(GL_CULL_FACE); // Restore culling for other renderers -} - -void CharacterRenderer::renderShadow(const glm::mat4& lightSpaceMatrix) { - if (instances.empty() || shadowCasterProgram == 0) { - return; - } - - glUseProgram(shadowCasterProgram); - - GLint lightSpaceLoc = glGetUniformLocation(shadowCasterProgram, "uLightSpaceMatrix"); - GLint modelLoc = glGetUniformLocation(shadowCasterProgram, "uModel"); - GLint texLoc = glGetUniformLocation(shadowCasterProgram, "uTexture"); - GLint alphaTestLoc = glGetUniformLocation(shadowCasterProgram, "uAlphaTest"); - GLint colorKeyLoc = glGetUniformLocation(shadowCasterProgram, "uColorKeyBlack"); - GLint bonesLoc = glGetUniformLocation(shadowCasterProgram, "uBones[0]"); - if (lightSpaceLoc < 0 || modelLoc < 0) { - return; - } - - glUniformMatrix4fv(lightSpaceLoc, 1, GL_FALSE, &lightSpaceMatrix[0][0]); - glEnable(GL_CULL_FACE); - glCullFace(GL_FRONT); - - if (texLoc >= 0) glUniform1i(texLoc, 0); - glActiveTexture(GL_TEXTURE0); - - for (const auto& [_, instance] : instances) { - auto modelIt = models.find(instance.modelId); - if (modelIt == models.end()) continue; - const auto& gpuModel = modelIt->second; - - glm::mat4 modelMat = instance.hasOverrideModelMatrix - ? instance.overrideModelMatrix - : getModelMatrix(instance); - glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &modelMat[0][0]); - - if (!instance.boneMatrices.empty() && bonesLoc >= 0) { - int numBones = std::min(static_cast(instance.boneMatrices.size()), MAX_BONES); - glUniformMatrix4fv(bonesLoc, numBones, GL_FALSE, &instance.boneMatrices[0][0][0]); - } - - glBindVertexArray(gpuModel.vao); - - if (!gpuModel.data.batches.empty()) { - for (const auto& batch : gpuModel.data.batches) { - GLuint texId = whiteTexture; - if (batch.textureIndex < gpuModel.data.textureLookup.size()) { - uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex]; - if (lookupIdx < gpuModel.textureIds.size()) { - texId = gpuModel.textureIds[lookupIdx]; - auto itO = instance.textureSlotOverrides.find(lookupIdx); - if (itO != instance.textureSlotOverrides.end() && itO->second != 0) { - texId = itO->second; + // Resolve normal/height map for this texture + VkTexture* normalMap = flatNormalTexture_.get(); + float batchHeightVariance = 0.0f; + if (texPtr && texPtr != whiteTexture_.get()) { + for (const auto& ce : textureCache) { + if (ce.second.texture.get() == texPtr && ce.second.normalHeightMap) { + normalMap = ce.second.normalHeightMap.get(); + batchHeightVariance = ce.second.heightMapVariance; + break; } } } - bool alphaCutout = false; - bool colorKeyBlack = false; - if (texId != 0 && texId != whiteTexture) { - auto itA = textureHasAlphaById_.find(texId); - alphaCutout = (itA != textureHasAlphaById_.end()) ? itA->second : false; - auto itC = textureColorKeyBlackById_.find(texId); - colorKeyBlack = (itC != textureColorKeyBlackById_.end()) ? itC->second : false; - } - if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, alphaCutout ? 1 : 0); - if (colorKeyLoc >= 0) glUniform1i(colorKeyLoc, colorKeyBlack ? 1 : 0); - glBindTexture(GL_TEXTURE_2D, texId ? texId : whiteTexture); + // POM quality β†’ sample count + int pomSamples = 32; + if (pomQuality_ == 0) pomSamples = 16; + else if (pomQuality_ == 2) pomSamples = 64; - glDrawElements(GL_TRIANGLES, - batch.indexCount, - GL_UNSIGNED_SHORT, - (void*)(batch.indexStart * sizeof(uint16_t))); + // Create per-batch material UBO + CharMaterialUBO matData{}; + matData.opacity = instance.opacity; + matData.alphaTest = (blendNeedsCutout || alphaCutout) ? 1 : 0; + matData.colorKeyBlack = (blendNeedsCutout || colorKeyBlack) ? 1 : 0; + matData.unlit = unlit ? 1 : 0; + matData.emissiveBoost = emissiveBoost; + matData.emissiveTintR = emissiveTint.r; + matData.emissiveTintG = emissiveTint.g; + matData.emissiveTintB = emissiveTint.b; + matData.specularIntensity = 0.5f; + matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; + matData.enablePOM = pomEnabled_ ? 1 : 0; + matData.pomScale = 0.06f; + matData.pomMaxSamples = pomSamples; + matData.heightMapVariance = batchHeightVariance; + matData.normalMapStrength = normalMapStrength_; + + // Create a small UBO for this batch's material + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = sizeof(CharMaterialUBO); + bci.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + ::VkBuffer matUBO = VK_NULL_HANDLE; + VmaAllocation matUBOAlloc = VK_NULL_HANDLE; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &matUBO, &matUBOAlloc, &allocInfo); + if (allocInfo.pMappedData) { + memcpy(allocInfo.pMappedData, &matData, sizeof(CharMaterialUBO)); + } + + // Write descriptor set: binding 0 = texture, binding 1 = material UBO, binding 2 = normal/height map + VkTexture* bindTex = (texPtr && texPtr->isValid()) ? texPtr : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = bindTex->descriptorInfo(); + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = matUBO; + bufInfo.offset = 0; + bufInfo.range = sizeof(CharMaterialUBO); + VkDescriptorImageInfo nhImgInfo = normalMap->descriptorInfo(); + + VkWriteDescriptorSet writes[3] = {}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = materialSet; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = materialSet; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + + writes[2].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[2].dstSet = materialSet; + writes[2].dstBinding = 2; + writes[2].descriptorCount = 1; + writes[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[2].pImageInfo = &nhImgInfo; + + vkUpdateDescriptorSets(vkCtx_->getDevice(), 3, writes, 0, nullptr); + + // Bind material descriptor set (set 1) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &materialSet, 0, nullptr); + + vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); + + transientMaterialUbos_[frameSlot].emplace_back(matUBO, matUBOAlloc); } } else { - if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, 0); - if (colorKeyLoc >= 0) glUniform1i(colorKeyLoc, 0); - glBindTexture(GL_TEXTURE_2D, whiteTexture); - glDrawElements(GL_TRIANGLES, - static_cast(gpuModel.data.indices.size()), - GL_UNSIGNED_SHORT, - 0); + // Draw entire model with first texture + VkTexture* texPtr = !gpuModel.textureIds.empty() ? gpuModel.textureIds[0] : whiteTexture_.get(); + if (!texPtr || !texPtr->isValid()) texPtr = whiteTexture_.get(); + + // Allocate material descriptor set + VkDescriptorSet materialSet = VK_NULL_HANDLE; + { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPools_[frameSlot]; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &materialSetLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &materialSet) != VK_SUCCESS) { + continue; + } + } + + // POM quality β†’ sample count + int pomSamples2 = 32; + if (pomQuality_ == 0) pomSamples2 = 16; + else if (pomQuality_ == 2) pomSamples2 = 64; + + CharMaterialUBO matData{}; + matData.opacity = instance.opacity; + matData.alphaTest = 0; + matData.colorKeyBlack = 0; + matData.unlit = 0; + matData.emissiveBoost = 1.0f; + matData.emissiveTintR = 1.0f; + matData.emissiveTintG = 1.0f; + matData.emissiveTintB = 1.0f; + matData.specularIntensity = 0.5f; + matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; + matData.enablePOM = pomEnabled_ ? 1 : 0; + matData.pomScale = 0.06f; + matData.pomMaxSamples = pomSamples2; + matData.heightMapVariance = 0.0f; + matData.normalMapStrength = normalMapStrength_; + + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = sizeof(CharMaterialUBO); + bci.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + ::VkBuffer matUBO = VK_NULL_HANDLE; + VmaAllocation matUBOAlloc = VK_NULL_HANDLE; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &matUBO, &matUBOAlloc, &allocInfo); + if (allocInfo.pMappedData) { + memcpy(allocInfo.pMappedData, &matData, sizeof(CharMaterialUBO)); + } + + VkDescriptorImageInfo imgInfo = texPtr->descriptorInfo(); + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = matUBO; + bufInfo.offset = 0; + bufInfo.range = sizeof(CharMaterialUBO); + VkDescriptorImageInfo nhImgInfo2 = flatNormalTexture_->descriptorInfo(); + + VkWriteDescriptorSet writes[3] = {}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = materialSet; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = materialSet; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + + writes[2].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[2].dstSet = materialSet; + writes[2].dstBinding = 2; + writes[2].descriptorCount = 1; + writes[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[2].pImageInfo = &nhImgInfo2; + + vkUpdateDescriptorSets(vkCtx_->getDevice(), 3, writes, 0, nullptr); + + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &materialSet, 0, nullptr); + + vkCmdDrawIndexed(cmd, gpuModel.indexCount, 1, 0, 0, 0); + + transientMaterialUbos_[frameSlot].emplace_back(matUBO, matUBOAlloc); } } +} - glBindVertexArray(0); - glCullFace(GL_BACK); +bool CharacterRenderer::initializeShadow(VkRenderPass shadowRenderPass) { + if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false; + VkDevice device = vkCtx_->getDevice(); + + // ShadowCharParams UBO (matches character_shadow.frag.glsl set=1 binding=1) + struct ShadowCharParams { + int32_t alphaTest = 0; + int32_t colorKeyBlack = 0; + }; + + // Create ShadowCharParams UBO + VkBufferCreateInfo bufCI{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bufCI.size = sizeof(ShadowCharParams); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufCI, &allocCI, + &shadowParamsUBO_, &shadowParamsAlloc_, &allocInfo) != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer: failed to create shadow params UBO"); + return false; + } + ShadowCharParams defaultParams{}; + std::memcpy(allocInfo.pMappedData, &defaultParams, sizeof(defaultParams)); + + // Descriptor set layout for set 1: binding 0 = sampler2D, binding 1 = ShadowCharParams UBO + VkDescriptorSetLayoutBinding layoutBindings[2]{}; + layoutBindings[0].binding = 0; + layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + layoutBindings[0].descriptorCount = 1; + layoutBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + layoutBindings[1].binding = 1; + layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + layoutBindings[1].descriptorCount = 1; + layoutBindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layoutCI{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + layoutCI.bindingCount = 2; + layoutCI.pBindings = layoutBindings; + if (vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &shadowParamsLayout_) != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer: failed to create shadow params layout"); + return false; + } + + // Descriptor pool (1 set) + VkDescriptorPoolSize poolSizes[2]{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSizes[0].descriptorCount = 1; + poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[1].descriptorCount = 1; + VkDescriptorPoolCreateInfo poolCI{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + poolCI.maxSets = 1; + poolCI.poolSizeCount = 2; + poolCI.pPoolSizes = poolSizes; + if (vkCreateDescriptorPool(device, &poolCI, nullptr, &shadowParamsPool_) != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer: failed to create shadow params pool"); + return false; + } + + // Allocate descriptor set + VkDescriptorSetAllocateInfo setAlloc{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + setAlloc.descriptorPool = shadowParamsPool_; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &shadowParamsLayout_; + if (vkAllocateDescriptorSets(device, &setAlloc, &shadowParamsSet_) != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer: failed to allocate shadow params set"); + return false; + } + + // Write descriptors (white dummy texture + ShadowCharParams UBO) + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = whiteTexture_->getImageView(); + imgInfo.sampler = whiteTexture_->getSampler(); + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = shadowParamsUBO_; + bufInfo.offset = 0; + bufInfo.range = sizeof(ShadowCharParams); + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = shadowParamsSet_; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = shadowParamsSet_; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + + // Pipeline layout: set 0 = perFrameLayout_ (dummy), set 1 = shadowParamsLayout_, set 2 = boneSetLayout_ + // Push constant: 128 bytes (lightSpaceMatrix + model), VERTEX stage + VkDescriptorSetLayout setLayouts[] = {perFrameLayout_, shadowParamsLayout_, boneSetLayout_}; + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pc.offset = 0; + pc.size = 128; + VkPipelineLayoutCreateInfo plCI{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + plCI.setLayoutCount = 3; + plCI.pSetLayouts = setLayouts; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + if (vkCreatePipelineLayout(device, &plCI, nullptr, &shadowPipelineLayout_) != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer: failed to create shadow pipeline layout"); + return false; + } + + // Load character shadow shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/character_shadow.vert.spv")) { + LOG_ERROR("CharacterRenderer: failed to load character_shadow.vert.spv"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/character_shadow.frag.spv")) { + LOG_ERROR("CharacterRenderer: failed to load character_shadow.frag.spv"); + vertShader.destroy(); + return false; + } + + // Character vertex format (CharVertexGPU): stride = 56 bytes + // loc 0: vec3 aPos (R32G32B32_SFLOAT, offset 0) + // loc 1: vec4 aBoneWeights (R8G8B8A8_UNORM, offset 12) + // loc 2: ivec4 aBoneIndices (R8G8B8A8_UINT, offset 16) + // loc 3: vec2 aTexCoord (R32G32_SFLOAT, offset 32) + VkVertexInputBindingDescription vertBind{}; + vertBind.binding = 0; + vertBind.stride = static_cast(sizeof(CharVertexGPU)); + vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + std::vector vertAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, position))}, + {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(CharVertexGPU, boneWeights))}, + {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(CharVertexGPU, boneIndices))}, + {3, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(CharVertexGPU, texCoords))}, + }; + + shadowPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({vertBind}, vertAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setDepthBias(0.05f, 0.20f) + .setNoColorAttachment() + .setLayout(shadowPipelineLayout_) + .setRenderPass(shadowRenderPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!shadowPipeline_) { + LOG_ERROR("CharacterRenderer: failed to create shadow pipeline"); + return false; + } + LOG_INFO("CharacterRenderer shadow pipeline initialized"); + return true; +} + +void CharacterRenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, + const glm::vec3& shadowCenter, float shadowRadius) { + if (!shadowPipeline_ || !shadowParamsSet_) return; + if (instances.empty() || models.empty()) return; + + uint32_t frameIndex = vkCtx_->getCurrentFrame(); + VkDevice device = vkCtx_->getDevice(); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline_); + // Bind shadow params set at set 1 + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 1, 1, &shadowParamsSet_, 0, nullptr); + + struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; + + const float shadowRadiusSq = shadowRadius * shadowRadius; + for (auto& pair : instances) { + auto& inst = pair.second; + if (!inst.visible) continue; + + // Distance cull against shadow frustum + glm::vec3 diff = inst.position - shadowCenter; + if (glm::dot(diff, diff) > shadowRadiusSq) continue; + + auto modelIt = models.find(inst.modelId); + if (modelIt == models.end()) continue; + const M2ModelGPU& gpuModel = modelIt->second; + if (!gpuModel.vertexBuffer) continue; + + glm::mat4 modelMat = inst.hasOverrideModelMatrix + ? inst.overrideModelMatrix + : getModelMatrix(inst); + + // Ensure bone SSBO is allocated and upload bone matrices + int numBones = std::min(static_cast(inst.boneMatrices.size()), MAX_BONES); + if (numBones > 0) { + if (!inst.boneBuffer[frameIndex]) { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = MAX_BONES * sizeof(glm::mat4); + bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo ai{}; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, + &inst.boneBuffer[frameIndex], &inst.boneAlloc[frameIndex], &ai); + inst.boneMapped[frameIndex] = ai.pMappedData; + + VkDescriptorSetAllocateInfo dsAI{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + dsAI.descriptorPool = boneDescPool_; + dsAI.descriptorSetCount = 1; + dsAI.pSetLayouts = &boneSetLayout_; + VkResult dsRes = vkAllocateDescriptorSets(device, &dsAI, &inst.boneSet[frameIndex]); + if (dsRes != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer[shadow]: bone descriptor allocation failed (instance=", + inst.id, ", frame=", frameIndex, ", vk=", static_cast(dsRes), ")"); + if (inst.boneBuffer[frameIndex]) { + vmaDestroyBuffer(vkCtx_->getAllocator(), + inst.boneBuffer[frameIndex], inst.boneAlloc[frameIndex]); + inst.boneBuffer[frameIndex] = VK_NULL_HANDLE; + inst.boneAlloc[frameIndex] = VK_NULL_HANDLE; + inst.boneMapped[frameIndex] = nullptr; + } + } + + if (inst.boneSet[frameIndex]) { + VkDescriptorBufferInfo bInfo{}; + bInfo.buffer = inst.boneBuffer[frameIndex]; + bInfo.offset = 0; + bInfo.range = bci.size; + VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + w.dstSet = inst.boneSet[frameIndex]; + w.dstBinding = 0; + w.descriptorCount = 1; + w.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + w.pBufferInfo = &bInfo; + vkUpdateDescriptorSets(device, 1, &w, 0, nullptr); + } + } + if (inst.boneMapped[frameIndex]) { + memcpy(inst.boneMapped[frameIndex], inst.boneMatrices.data(), + numBones * sizeof(glm::mat4)); + } + } + + if (!inst.boneSet[frameIndex]) continue; + + // Bind bone SSBO at set 2 + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 2, 1, &inst.boneSet[frameIndex], 0, nullptr); + + ShadowPush push{lightSpaceMatrix, modelMat}; + vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, 128, &push); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &gpuModel.vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, gpuModel.indexBuffer, 0, VK_INDEX_TYPE_UINT16); + + bool applyGeosetFilter = !inst.activeGeosets.empty(); + for (const auto& batch : gpuModel.data.batches) { + uint16_t blendMode = 0; + if (batch.materialIndex < gpuModel.data.materials.size()) { + blendMode = gpuModel.data.materials[batch.materialIndex].blendMode; + } + if (blendMode >= 2) continue; // skip transparent + if (applyGeosetFilter && + inst.activeGeosets.find(batch.submeshId) == inst.activeGeosets.end()) continue; + vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); + } + } } glm::mat4 CharacterRenderer::getModelMatrix(const CharacterInstance& instance) const { @@ -1931,17 +2526,17 @@ void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unorder } } -void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId) { +void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture) { auto it = instances.find(instanceId); if (it != instances.end()) { - it->second.groupTextureOverrides[geosetGroup] = textureId; + it->second.groupTextureOverrides[geosetGroup] = texture; } } -void CharacterRenderer::setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, GLuint textureId) { +void CharacterRenderer::setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, VkTexture* texture) { auto it = instances.find(instanceId); if (it != instances.end()) { - it->second.textureSlotOverrides[textureSlot] = textureId; + it->second.textureSlotOverrides[textureSlot] = texture; } } @@ -1955,6 +2550,9 @@ void CharacterRenderer::clearTextureSlotOverride(uint32_t instanceId, uint16_t t void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) { auto it = instances.find(instanceId); if (it != instances.end()) { + if (it->second.visible != visible) { + LOG_INFO("CharacterRenderer::setInstanceVisible id=", instanceId, " visible=", visible); + } it->second.visible = visible; // Also hide/show attached weapons (for first-person mode) @@ -1971,6 +2569,11 @@ void CharacterRenderer::removeInstance(uint32_t instanceId) { auto it = instances.find(instanceId); if (it == instances.end()) return; + LOG_INFO("CharacterRenderer::removeInstance id=", instanceId, + " pos=(", it->second.position.x, ",", it->second.position.y, ",", it->second.position.z, ")", + " remaining=", instances.size() - 1, + " override=", (void*)renderPassOverride_); + // Remove child attachments first (helmets/weapons), otherwise they leak as // orphan render instances when the parent creature despawns. auto attachments = it->second.weaponAttachments; @@ -1978,6 +2581,9 @@ void CharacterRenderer::removeInstance(uint32_t instanceId) { removeInstance(wa.weaponInstanceId); } + // Destroy bone buffers for this instance + destroyInstanceBones(it->second); + instances.erase(it); } @@ -2138,9 +2744,9 @@ bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmen // Apply weapon texture if provided if (!texturePath.empty()) { - GLuint texId = loadTexture(texturePath); - if (texId != whiteTexture) { - setModelTexture(weaponModelId, 0, texId); + VkTexture* texPtr = loadTexture(texturePath); + if (texPtr != whiteTexture_.get()) { + setModelTexture(weaponModelId, 0, texPtr); } } @@ -2321,5 +2927,83 @@ void CharacterRenderer::dumpAnimations(uint32_t instanceId) const { core::Logger::getInstance().info("=== End animation dump ==="); } +void CharacterRenderer::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + // Destroy old main-pass pipelines (NOT shadow, NOT pipeline layout) + if (opaquePipeline_) { vkDestroyPipeline(device, opaquePipeline_, nullptr); opaquePipeline_ = VK_NULL_HANDLE; } + if (alphaTestPipeline_) { vkDestroyPipeline(device, alphaTestPipeline_, nullptr); alphaTestPipeline_ = VK_NULL_HANDLE; } + if (alphaPipeline_) { vkDestroyPipeline(device, alphaPipeline_, nullptr); alphaPipeline_ = VK_NULL_HANDLE; } + if (additivePipeline_) { vkDestroyPipeline(device, additivePipeline_, nullptr); additivePipeline_ = VK_NULL_HANDLE; } + + // --- Load shaders --- + rendering::VkShaderModule charVert, charFrag; + charVert.loadFromFile(device, "assets/shaders/character.vert.spv"); + charFrag.loadFromFile(device, "assets/shaders/character.frag.spv"); + + if (!charVert.isValid() || !charFrag.isValid()) { + LOG_ERROR("CharacterRenderer::recreatePipelines: missing required shaders"); + return; + } + + VkRenderPass mainPass = renderPassOverride_ ? renderPassOverride_ : vkCtx_->getImGuiRenderPass(); + VkSampleCountFlagBits samples = renderPassOverride_ ? msaaSamplesOverride_ : vkCtx_->getMsaaSamples(); + + // --- Vertex input --- + VkVertexInputBindingDescription charBinding{}; + charBinding.binding = 0; + charBinding.stride = sizeof(CharVertexGPU); + charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector charAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, position))}, + {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(CharVertexGPU, boneWeights))}, + {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(CharVertexGPU, boneIndices))}, + {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, normal))}, + {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(CharVertexGPU, texCoords))}, + {5, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast(offsetof(CharVertexGPU, tangent))}, + }; + + auto buildCharPipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline { + return PipelineBuilder() + .setShaders(charVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + charFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({charBinding}, charAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blendState) + .setMultisample(samples) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + LOG_INFO("CharacterRenderer::recreatePipelines: renderPass=", (void*)mainPass, + " samples=", static_cast(samples), + " pipelineLayout=", (void*)pipelineLayout_); + + opaquePipeline_ = buildCharPipeline(PipelineBuilder::blendDisabled(), true); + alphaTestPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), true); + alphaPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), false); + additivePipeline_ = buildCharPipeline(PipelineBuilder::blendAdditive(), false); + + charVert.destroy(); + charFrag.destroy(); + + if (!opaquePipeline_ || !alphaTestPipeline_ || !alphaPipeline_ || !additivePipeline_) { + LOG_ERROR("CharacterRenderer::recreatePipelines FAILED: opaque=", (void*)opaquePipeline_, + " alphaTest=", (void*)alphaTestPipeline_, + " alpha=", (void*)alphaPipeline_, + " additive=", (void*)additivePipeline_, + " renderPass=", (void*)mainPass, " samples=", static_cast(samples)); + } else { + LOG_INFO("CharacterRenderer: pipelines recreated successfully (samples=", + static_cast(samples), ")"); + } +} + } // namespace rendering } // namespace wowee diff --git a/src/rendering/charge_effect.cpp b/src/rendering/charge_effect.cpp index b02443ae..d6fba4de 100644 --- a/src/rendering/charge_effect.cpp +++ b/src/rendering/charge_effect.cpp @@ -1,6 +1,10 @@ #include "rendering/charge_effect.hpp" #include "rendering/camera.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "rendering/m2_renderer.hpp" #include "pipeline/m2_loader.hpp" #include "pipeline/asset_manager.hpp" @@ -8,6 +12,7 @@ #include #include #include +#include namespace wowee { namespace rendering { @@ -26,130 +31,181 @@ static float randFloat(float lo, float hi) { ChargeEffect::ChargeEffect() = default; ChargeEffect::~ChargeEffect() { shutdown(); } -bool ChargeEffect::initialize() { - // ---- Ribbon trail shader ---- - ribbonShader_ = std::make_unique(); +bool ChargeEffect::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { + vkCtx_ = ctx; + VkDevice device = vkCtx_->getDevice(); - const char* ribbonVS = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in float aAlpha; - layout (location = 2) in float aHeat; - layout (location = 3) in float aHeight; + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; - uniform mat4 uView; - uniform mat4 uProjection; - - out float vAlpha; - out float vHeat; - out float vHeight; - - void main() { - gl_Position = uProjection * uView * vec4(aPos, 1.0); - vAlpha = aAlpha; - vHeat = aHeat; - vHeight = aHeight; + // ---- Ribbon trail pipeline (TRIANGLE_STRIP) ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/charge_ribbon.vert.spv")) { + LOG_ERROR("Failed to load charge_ribbon vertex shader"); + return false; } - )"; - - const char* ribbonFS = R"( - #version 330 core - in float vAlpha; - in float vHeat; - in float vHeight; - out vec4 FragColor; - - void main() { - // Vertical gradient: top is red/opaque, bottom is transparent - vec3 topColor = vec3(0.9, 0.15, 0.05); // Deep red at top - vec3 midColor = vec3(1.0, 0.5, 0.1); // Orange in middle - vec3 color = mix(midColor, topColor, vHeight); - // Mix with heat (head vs tail along length) - vec3 hotColor = vec3(1.0, 0.6, 0.15); - color = mix(color, hotColor, vHeat * 0.4); - - // Bottom fades to transparent, top is opaque - float vertAlpha = smoothstep(0.0, 0.4, vHeight); - FragColor = vec4(color, vAlpha * vertAlpha * 0.7); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/charge_ribbon.frag.spv")) { + LOG_ERROR("Failed to load charge_ribbon fragment shader"); + return false; } - )"; - if (!ribbonShader_->loadFromSource(ribbonVS, ribbonFS)) { - LOG_ERROR("Failed to create charge ribbon shader"); - return false; + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + ribbonPipelineLayout_ = createPipelineLayout(device, {perFrameLayout}, {}); + if (ribbonPipelineLayout_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create charge ribbon pipeline layout"); + return false; + } + + // Vertex input: pos(vec3) + alpha(float) + heat(float) + height(float) = 6 floats, stride = 24 bytes + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 6 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(4); + // location 0: vec3 position + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + // location 1: float alpha + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + // location 2: float heat + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); + // location 3: float height + attrs[3].location = 3; + attrs[3].binding = 0; + attrs[3].format = VK_FORMAT_R32_SFLOAT; + attrs[3].offset = 5 * sizeof(float); + + ribbonPipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) // Additive blend for fiery glow + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (ribbonPipeline_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create charge ribbon pipeline"); + return false; + } } - glGenVertexArrays(1, &ribbonVao_); - glGenBuffers(1, &ribbonVbo_); - glBindVertexArray(ribbonVao_); - glBindBuffer(GL_ARRAY_BUFFER, ribbonVbo_); - // pos(3) + alpha(1) + heat(1) + height(1) = 6 floats - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(4 * sizeof(float))); - glEnableVertexAttribArray(2); - glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(5 * sizeof(float))); - glEnableVertexAttribArray(3); - glBindVertexArray(0); + // ---- Dust puff pipeline (POINT_LIST) ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/charge_dust.vert.spv")) { + LOG_ERROR("Failed to load charge_dust vertex shader"); + return false; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/charge_dust.frag.spv")) { + LOG_ERROR("Failed to load charge_dust fragment shader"); + return false; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + dustPipelineLayout_ = createPipelineLayout(device, {perFrameLayout}, {}); + if (dustPipelineLayout_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create charge dust pipeline layout"); + return false; + } + + // Vertex input: pos(vec3) + size(float) + alpha(float) = 5 floats, stride = 20 bytes + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(3); + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); + + dustPipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(dustPipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (dustPipeline_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create charge dust pipeline"); + return false; + } + } + + // ---- Create dynamic mapped vertex buffers ---- + // Ribbon: MAX_TRAIL_POINTS * 2 vertices * 6 floats each + ribbonDynamicVBSize_ = MAX_TRAIL_POINTS * 2 * 6 * sizeof(float); + { + AllocatedBuffer buf = createBuffer(vkCtx_->getAllocator(), ribbonDynamicVBSize_, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + ribbonDynamicVB_ = buf.buffer; + ribbonDynamicVBAlloc_ = buf.allocation; + ribbonDynamicVBAllocInfo_ = buf.info; + if (ribbonDynamicVB_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create charge ribbon dynamic vertex buffer"); + return false; + } + } + + // Dust: MAX_DUST * 5 floats each + dustDynamicVBSize_ = MAX_DUST * 5 * sizeof(float); + { + AllocatedBuffer buf = createBuffer(vkCtx_->getAllocator(), dustDynamicVBSize_, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + dustDynamicVB_ = buf.buffer; + dustDynamicVBAlloc_ = buf.allocation; + dustDynamicVBAllocInfo_ = buf.info; + if (dustDynamicVB_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create charge dust dynamic vertex buffer"); + return false; + } + } ribbonVerts_.reserve(MAX_TRAIL_POINTS * 2 * 6); - - // ---- Dust puff shader (small point sprites) ---- - dustShader_ = std::make_unique(); - - const char* dustVS = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in float aSize; - layout (location = 2) in float aAlpha; - - uniform mat4 uView; - uniform mat4 uProjection; - - out float vAlpha; - - void main() { - gl_Position = uProjection * uView * vec4(aPos, 1.0); - gl_PointSize = aSize; - vAlpha = aAlpha; - } - )"; - - const char* dustFS = R"( - #version 330 core - in float vAlpha; - out vec4 FragColor; - - void main() { - vec2 coord = gl_PointCoord - vec2(0.5); - float dist = length(coord); - if (dist > 0.5) discard; - float alpha = smoothstep(0.5, 0.0, dist) * vAlpha; - vec3 dustColor = vec3(0.65, 0.55, 0.40); - FragColor = vec4(dustColor, alpha * 0.45); - } - )"; - - if (!dustShader_->loadFromSource(dustVS, dustFS)) { - LOG_ERROR("Failed to create charge dust shader"); - return false; - } - - glGenVertexArrays(1, &dustVao_); - glGenBuffers(1, &dustVbo_); - glBindVertexArray(dustVao_); - glBindBuffer(GL_ARRAY_BUFFER, dustVbo_); - // pos(3) + size(1) + alpha(1) = 5 floats - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(4 * sizeof(float))); - glEnableVertexAttribArray(2); - glBindVertexArray(0); - dustVerts_.reserve(MAX_DUST * 5); dustPuffs_.reserve(MAX_DUST); @@ -157,16 +213,158 @@ bool ChargeEffect::initialize() { } void ChargeEffect::shutdown() { - if (ribbonVao_) glDeleteVertexArrays(1, &ribbonVao_); - if (ribbonVbo_) glDeleteBuffers(1, &ribbonVbo_); - ribbonVao_ = 0; ribbonVbo_ = 0; - if (dustVao_) glDeleteVertexArrays(1, &dustVao_); - if (dustVbo_) glDeleteBuffers(1, &dustVbo_); - dustVao_ = 0; dustVbo_ = 0; + if (vkCtx_) { + VkDevice device = vkCtx_->getDevice(); + VmaAllocator allocator = vkCtx_->getAllocator(); + + if (ribbonPipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, ribbonPipeline_, nullptr); + ribbonPipeline_ = VK_NULL_HANDLE; + } + if (ribbonPipelineLayout_ != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, ribbonPipelineLayout_, nullptr); + ribbonPipelineLayout_ = VK_NULL_HANDLE; + } + if (ribbonDynamicVB_ != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, ribbonDynamicVB_, ribbonDynamicVBAlloc_); + ribbonDynamicVB_ = VK_NULL_HANDLE; + ribbonDynamicVBAlloc_ = VK_NULL_HANDLE; + } + + if (dustPipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, dustPipeline_, nullptr); + dustPipeline_ = VK_NULL_HANDLE; + } + if (dustPipelineLayout_ != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, dustPipelineLayout_, nullptr); + dustPipelineLayout_ = VK_NULL_HANDLE; + } + if (dustDynamicVB_ != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, dustDynamicVB_, dustDynamicVBAlloc_); + dustDynamicVB_ = VK_NULL_HANDLE; + dustDynamicVBAlloc_ = VK_NULL_HANDLE; + } + } + + vkCtx_ = nullptr; trail_.clear(); dustPuffs_.clear(); - ribbonShader_.reset(); - dustShader_.reset(); +} + +void ChargeEffect::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + // Destroy old pipelines (NOT layouts) + if (ribbonPipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, ribbonPipeline_, nullptr); + ribbonPipeline_ = VK_NULL_HANDLE; + } + if (dustPipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, dustPipeline_, nullptr); + dustPipeline_ = VK_NULL_HANDLE; + } + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + // ---- Rebuild ribbon trail pipeline (TRIANGLE_STRIP) ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/charge_ribbon.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/charge_ribbon.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 6 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(4); + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); + attrs[3].location = 3; + attrs[3].binding = 0; + attrs[3].format = VK_FORMAT_R32_SFLOAT; + attrs[3].offset = 5 * sizeof(float); + + ribbonPipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } + + // ---- Rebuild dust puff pipeline (POINT_LIST) ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/charge_dust.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/charge_dust.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(3); + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); + + dustPipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(dustPipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } } void ChargeEffect::tryLoadM2Models(M2Renderer* m2Renderer, pipeline::AssetManager* assets) { @@ -345,9 +543,11 @@ void ChargeEffect::update(float deltaTime) { } } -void ChargeEffect::render(const Camera& camera) { +void ChargeEffect::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + VkDeviceSize offset = 0; + // ---- Render ribbon trail as triangle strip ---- - if (trail_.size() >= 2 && ribbonShader_) { + if (trail_.size() >= 2 && ribbonPipeline_ != VK_NULL_HANDLE) { ribbonVerts_.clear(); int n = static_cast(trail_.size()); @@ -385,28 +585,21 @@ void ChargeEffect::render(const Camera& camera) { ribbonVerts_.push_back(1.0f); // height = top } - glBindBuffer(GL_ARRAY_BUFFER, ribbonVbo_); - glBufferData(GL_ARRAY_BUFFER, ribbonVerts_.size() * sizeof(float), - ribbonVerts_.data(), GL_DYNAMIC_DRAW); + // Upload to mapped buffer + VkDeviceSize uploadSize = ribbonVerts_.size() * sizeof(float); + if (uploadSize > 0 && ribbonDynamicVBAllocInfo_.pMappedData) { + std::memcpy(ribbonDynamicVBAllocInfo_.pMappedData, ribbonVerts_.data(), uploadSize); + } - ribbonShader_->use(); - ribbonShader_->setUniform("uView", camera.getViewMatrix()); - ribbonShader_->setUniform("uProjection", camera.getProjectionMatrix()); - - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blend for fiery glow - glDepthMask(GL_FALSE); - - glBindVertexArray(ribbonVao_); - glDrawArrays(GL_TRIANGLE_STRIP, 0, static_cast(n * 2)); - glBindVertexArray(0); - - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDepthMask(GL_TRUE); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, ribbonPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, ribbonPipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonDynamicVB_, &offset); + vkCmdDraw(cmd, static_cast(n * 2), 1, 0, 0); } // ---- Render dust puffs ---- - if (!dustPuffs_.empty() && dustShader_) { + if (!dustPuffs_.empty() && dustPipeline_ != VK_NULL_HANDLE) { dustVerts_.clear(); for (const auto& d : dustPuffs_) { dustVerts_.push_back(d.position.x); @@ -416,25 +609,17 @@ void ChargeEffect::render(const Camera& camera) { dustVerts_.push_back(d.alpha); } - glBindBuffer(GL_ARRAY_BUFFER, dustVbo_); - glBufferData(GL_ARRAY_BUFFER, dustVerts_.size() * sizeof(float), - dustVerts_.data(), GL_DYNAMIC_DRAW); + // Upload to mapped buffer + VkDeviceSize uploadSize = dustVerts_.size() * sizeof(float); + if (uploadSize > 0 && dustDynamicVBAllocInfo_.pMappedData) { + std::memcpy(dustDynamicVBAllocInfo_.pMappedData, dustVerts_.data(), uploadSize); + } - dustShader_->use(); - dustShader_->setUniform("uView", camera.getViewMatrix()); - dustShader_->setUniform("uProjection", camera.getProjectionMatrix()); - - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDepthMask(GL_FALSE); - glEnable(GL_PROGRAM_POINT_SIZE); - - glBindVertexArray(dustVao_); - glDrawArrays(GL_POINTS, 0, static_cast(dustPuffs_.size())); - glBindVertexArray(0); - - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dustPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dustPipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindVertexBuffers(cmd, 0, 1, &dustDynamicVB_, &offset); + vkCmdDraw(cmd, static_cast(dustPuffs_.size()), 1, 0, 0); } } diff --git a/src/rendering/clouds.cpp b/src/rendering/clouds.cpp index dba0359d..eb2a5a25 100644 --- a/src/rendering/clouds.cpp +++ b/src/rendering/clouds.cpp @@ -1,316 +1,325 @@ #include "rendering/clouds.hpp" -#include "rendering/camera.hpp" -#include "rendering/shader.hpp" +#include "rendering/sky_system.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" #include -#include #include namespace wowee { namespace rendering { -Clouds::Clouds() { -} +Clouds::Clouds() = default; Clouds::~Clouds() { - cleanup(); + shutdown(); } -bool Clouds::initialize() { - LOG_INFO("Initializing cloud system"); +bool Clouds::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { + LOG_INFO("Initializing cloud system (Vulkan)"); - // Generate cloud dome mesh - generateMesh(); + vkCtx_ = ctx; + VkDevice device = vkCtx_->getDevice(); - // Create VAO - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); - glGenBuffers(1, &ebo); - - glBindVertexArray(vao); - - // Upload vertex data - glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, - vertices.size() * sizeof(glm::vec3), - vertices.data(), - GL_STATIC_DRAW); - - // Upload index data - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, - indices.size() * sizeof(unsigned int), - indices.data(), - GL_STATIC_DRAW); - - // Position attribute - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), (void*)0); - glEnableVertexAttribArray(0); - - glBindVertexArray(0); - - // Create shader - shader = std::make_unique(); - - // Cloud vertex shader - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - - uniform mat4 uView; - uniform mat4 uProjection; - - out vec3 WorldPos; - out vec3 LocalPos; - - void main() { - LocalPos = aPos; - WorldPos = aPos; - - // Remove translation from view matrix (billboard effect) - mat4 viewNoTranslation = uView; - viewNoTranslation[3][0] = 0.0; - viewNoTranslation[3][1] = 0.0; - viewNoTranslation[3][2] = 0.0; - - vec4 pos = uProjection * viewNoTranslation * vec4(aPos, 1.0); - gl_Position = pos.xyww; // Put at far plane - } - )"; - - // Cloud fragment shader with procedural noise - const char* fragmentShaderSource = R"( - #version 330 core - in vec3 WorldPos; - in vec3 LocalPos; - - uniform vec3 uCloudColor; - uniform float uDensity; - uniform float uWindOffset; - - out vec4 FragColor; - - // Simple 3D noise function - float hash(vec3 p) { - p = fract(p * vec3(0.1031, 0.1030, 0.0973)); - p += dot(p, p.yxz + 19.19); - return fract((p.x + p.y) * p.z); - } - - float noise(vec3 p) { - vec3 i = floor(p); - vec3 f = fract(p); - f = f * f * (3.0 - 2.0 * f); - - return mix( - mix(mix(hash(i + vec3(0,0,0)), hash(i + vec3(1,0,0)), f.x), - mix(hash(i + vec3(0,1,0)), hash(i + vec3(1,1,0)), f.x), f.y), - mix(mix(hash(i + vec3(0,0,1)), hash(i + vec3(1,0,1)), f.x), - mix(hash(i + vec3(0,1,1)), hash(i + vec3(1,1,1)), f.x), f.y), - f.z); - } - - // Fractal Brownian Motion for cloud-like patterns - float fbm(vec3 p) { - float value = 0.0; - float amplitude = 0.5; - float frequency = 1.0; - - for (int i = 0; i < 4; i++) { - value += amplitude * noise(p * frequency); - frequency *= 2.0; - amplitude *= 0.5; - } - - return value; - } - - void main() { - // Normalize position for noise sampling - vec3 pos = normalize(LocalPos); - - // Only render on upper hemisphere - if (pos.y < 0.1) { - discard; - } - - // Apply wind offset to x coordinate - vec3 samplePos = vec3(pos.x + uWindOffset, pos.y, pos.z) * 3.0; - - // Generate two cloud layers - float clouds1 = fbm(samplePos * 1.0); - float clouds2 = fbm(samplePos * 2.8 + vec3(100.0)); - - // Combine layers - float cloudPattern = clouds1 * 0.6 + clouds2 * 0.4; - - // Apply density threshold to create cloud shapes with softer transition. - float cloudStart = 0.34 + (1.0 - uDensity) * 0.26; - float cloudEnd = 0.74; - float cloudMask = smoothstep(cloudStart, cloudEnd, cloudPattern); - - // Fuzzy edge breakup: only modulate near the silhouette so cloud cores stay stable. - float edgeNoise = fbm(samplePos * 7.0 + vec3(41.0)); - float edgeBand = 1.0 - smoothstep(0.30, 0.72, cloudMask); // 1 near edge, 0 in center - float fringe = mix(1.0, smoothstep(0.34, 0.80, edgeNoise), edgeBand * 0.95); - cloudMask *= fringe; - - // Fade clouds near horizon - float horizonFade = smoothstep(0.0, 0.3, pos.y); - cloudMask *= horizonFade; - - // Reduce edge contrast against skybox: soften + lower opacity. - float edgeSoften = smoothstep(0.0, 0.80, cloudMask); - edgeSoften = mix(0.45, 1.0, edgeSoften); - float alpha = cloudMask * edgeSoften * 0.70; - - if (alpha < 0.05) { - discard; - } - - FragColor = vec4(uCloudColor, alpha); - } - )"; - - if (!shader->loadFromSource(vertexShaderSource, fragmentShaderSource)) { - LOG_ERROR("Failed to create cloud shader"); + // ------------------------------------------------------------------ shaders + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/clouds.vert.spv")) { + LOG_ERROR("Failed to load clouds vertex shader"); return false; } - LOG_INFO("Cloud system initialized: ", triangleCount, " triangles"); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/clouds.frag.spv")) { + LOG_ERROR("Failed to load clouds fragment shader"); + return false; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // ------------------------------------------------------------------ push constants + // Fragment-only push: 3 x vec4 = 48 bytes + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(CloudPush); // 48 bytes + + // ------------------------------------------------------------------ pipeline layout + pipelineLayout_ = createPipelineLayout(device, {perFrameLayout}, {pushRange}); + if (pipelineLayout_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create clouds pipeline layout"); + return false; + } + + // ------------------------------------------------------------------ vertex input + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = sizeof(glm::vec3); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + // ------------------------------------------------------------------ pipeline + pipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create clouds pipeline"); + return false; + } + + // ------------------------------------------------------------------ geometry + generateMesh(); + createBuffers(); + + LOG_INFO("Cloud system initialized: ", indexCount_ / 3, " triangles"); return true; } -void Clouds::generateMesh() { - vertices.clear(); - indices.clear(); +void Clouds::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); - // Generate hemisphere mesh for clouds - for (int ring = 0; ring <= RINGS; ++ring) { - float phi = (ring / static_cast(RINGS)) * (M_PI * 0.5f); // 0 to Ο€/2 - float y = RADIUS * cosf(phi); - float ringRadius = RADIUS * sinf(phi); + if (pipeline_ != VK_NULL_HANDLE) { vkDestroyPipeline(device, pipeline_, nullptr); pipeline_ = VK_NULL_HANDLE; } - for (int segment = 0; segment <= SEGMENTS; ++segment) { - float theta = (segment / static_cast(SEGMENTS)) * (2.0f * M_PI); - float x = ringRadius * cosf(theta); - float z = ringRadius * sinf(theta); - - vertices.push_back(glm::vec3(x, y, z)); - } + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/clouds.vert.spv")) { + LOG_ERROR("Clouds::recreatePipelines: failed to load vertex shader"); + return; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/clouds.frag.spv")) { + LOG_ERROR("Clouds::recreatePipelines: failed to load fragment shader"); + vertModule.destroy(); + return; } - // Generate indices - for (int ring = 0; ring < RINGS; ++ring) { - for (int segment = 0; segment < SEGMENTS; ++segment) { - int current = ring * (SEGMENTS + 1) + segment; - int next = current + SEGMENTS + 1; + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); - // Two triangles per quad - indices.push_back(current); - indices.push_back(next); - indices.push_back(current + 1); + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = sizeof(glm::vec3); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - indices.push_back(current + 1); - indices.push_back(next); - indices.push_back(next + 1); - } + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline_ == VK_NULL_HANDLE) { + LOG_ERROR("Clouds::recreatePipelines: failed to create pipeline"); } - - triangleCount = static_cast(indices.size()) / 3; } +void Clouds::shutdown() { + destroyBuffers(); + + if (vkCtx_) { + VkDevice device = vkCtx_->getDevice(); + if (pipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline_, nullptr); + pipeline_ = VK_NULL_HANDLE; + } + if (pipelineLayout_ != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); + pipelineLayout_ = VK_NULL_HANDLE; + } + } + + vkCtx_ = nullptr; +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +void Clouds::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const SkyParams& params) { + if (!enabled_ || pipeline_ == VK_NULL_HANDLE) { + return; + } + + // Derive cloud base color from DBC horizon band, slightly brightened + glm::vec3 cloudBaseColor = params.skyBand1Color * 1.1f; + cloudBaseColor = glm::clamp(cloudBaseColor, glm::vec3(0.0f), glm::vec3(1.0f)); + + // Sun direction (opposite of light direction) + glm::vec3 sunDir = -glm::normalize(params.directionalDir); + float sunAboveHorizon = glm::clamp(sunDir.z, 0.0f, 1.0f); + + // Sun intensity based on elevation + float sunIntensity = sunAboveHorizon; + + // Ambient light β€” brighter during day, dimmer at night + float ambient = glm::mix(0.3f, 0.7f, sunAboveHorizon); + + CloudPush push{}; + push.cloudColor = glm::vec4(cloudBaseColor, 1.0f); + push.sunDirDensity = glm::vec4(sunDir, density_); + push.windAndLight = glm::vec4(windOffset_, sunIntensity, ambient, 0.0f); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); + + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + + vkCmdPushConstants(cmd, pipelineLayout_, + VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer_, &offset); + vkCmdBindIndexBuffer(cmd, indexBuffer_, 0, VK_INDEX_TYPE_UINT32); + + vkCmdDrawIndexed(cmd, static_cast(indexCount_), 1, 0, 0, 0); +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + void Clouds::update(float deltaTime) { - if (!enabled) { + if (!enabled_) { return; } - - // Accumulate wind movement - windOffset += deltaTime * windSpeed * 0.05f; // Slow drift + windOffset_ += deltaTime * windSpeed_ * 0.05f; // Slow drift } -glm::vec3 Clouds::getCloudColor(float timeOfDay) const { - // Base cloud color (white/light gray) - glm::vec3 dayColor(0.95f, 0.95f, 1.0f); - - // Dawn clouds (orange tint) - if (timeOfDay >= 5.0f && timeOfDay < 7.0f) { - float t = (timeOfDay - 5.0f) / 2.0f; - glm::vec3 dawnColor(1.0f, 0.7f, 0.5f); - return glm::mix(dawnColor, dayColor, t); - } - // Dusk clouds (orange/pink tint) - else if (timeOfDay >= 17.0f && timeOfDay < 19.0f) { - float t = (timeOfDay - 17.0f) / 2.0f; - glm::vec3 duskColor(1.0f, 0.6f, 0.4f); - return glm::mix(dayColor, duskColor, t); - } - // Night clouds (dark blue-gray) - else if (timeOfDay >= 20.0f || timeOfDay < 5.0f) { - return glm::vec3(0.15f, 0.15f, 0.25f); - } - - return dayColor; -} - -void Clouds::render(const Camera& camera, float timeOfDay) { - if (!enabled || !shader) { - return; - } - - // Enable blending for transparent clouds - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - - // Disable depth write (clouds are in sky) - glDepthMask(GL_FALSE); - - // Enable depth test so clouds are behind skybox - glEnable(GL_DEPTH_TEST); - glDepthFunc(GL_LEQUAL); - - shader->use(); - - // Set matrices - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); - - shader->setUniform("uView", view); - shader->setUniform("uProjection", projection); - - // Set cloud parameters - glm::vec3 cloudColor = getCloudColor(timeOfDay); - shader->setUniform("uCloudColor", cloudColor); - shader->setUniform("uDensity", density); - shader->setUniform("uWindOffset", windOffset); - - // Render - glBindVertexArray(vao); - glDrawElements(GL_TRIANGLES, static_cast(indices.size()), GL_UNSIGNED_INT, 0); - glBindVertexArray(0); - - // Restore state - glDisable(GL_BLEND); - glDepthMask(GL_TRUE); - glDepthFunc(GL_LESS); -} +// --------------------------------------------------------------------------- +// Density setter +// --------------------------------------------------------------------------- void Clouds::setDensity(float density) { - this->density = glm::clamp(density, 0.0f, 1.0f); + density_ = glm::clamp(density, 0.0f, 1.0f); } -void Clouds::cleanup() { - if (vao) { - glDeleteVertexArrays(1, &vao); - vao = 0; +// --------------------------------------------------------------------------- +// Mesh generation β€” identical algorithm to GL version +// --------------------------------------------------------------------------- + +void Clouds::generateMesh() { + vertices_.clear(); + indices_.clear(); + + // Upper hemisphere β€” Z-up world: altitude goes into Z, horizontal spread in X/Y + for (int ring = 0; ring <= RINGS; ++ring) { + float phi = (ring / static_cast(RINGS)) * (static_cast(M_PI) * 0.5f); + float altZ = RADIUS * std::cos(phi); + float ringRadius = RADIUS * std::sin(phi); + + for (int seg = 0; seg <= SEGMENTS; ++seg) { + float theta = (seg / static_cast(SEGMENTS)) * (2.0f * static_cast(M_PI)); + float x = ringRadius * std::cos(theta); + float y = ringRadius * std::sin(theta); + vertices_.push_back(glm::vec3(x, y, altZ)); + } } - if (vbo) { - glDeleteBuffers(1, &vbo); - vbo = 0; + + for (int ring = 0; ring < RINGS; ++ring) { + for (int seg = 0; seg < SEGMENTS; ++seg) { + uint32_t current = static_cast(ring * (SEGMENTS + 1) + seg); + uint32_t next = current + static_cast(SEGMENTS + 1); + + indices_.push_back(current); + indices_.push_back(next); + indices_.push_back(current + 1); + + indices_.push_back(current + 1); + indices_.push_back(next); + indices_.push_back(next + 1); + } } - if (ebo) { - glDeleteBuffers(1, &ebo); - ebo = 0; + + indexCount_ = static_cast(indices_.size()); +} + +// --------------------------------------------------------------------------- +// GPU buffer management +// --------------------------------------------------------------------------- + +void Clouds::createBuffers() { + AllocatedBuffer vbuf = uploadBuffer(*vkCtx_, + vertices_.data(), + vertices_.size() * sizeof(glm::vec3), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + vertexBuffer_ = vbuf.buffer; + vertexAlloc_ = vbuf.allocation; + + AllocatedBuffer ibuf = uploadBuffer(*vkCtx_, + indices_.data(), + indices_.size() * sizeof(uint32_t), + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + indexBuffer_ = ibuf.buffer; + indexAlloc_ = ibuf.allocation; + + // CPU data no longer needed + vertices_.clear(); + vertices_.shrink_to_fit(); + indices_.clear(); + indices_.shrink_to_fit(); +} + +void Clouds::destroyBuffers() { + if (!vkCtx_) return; + + VmaAllocator allocator = vkCtx_->getAllocator(); + + if (vertexBuffer_ != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, vertexBuffer_, vertexAlloc_); + vertexBuffer_ = VK_NULL_HANDLE; + vertexAlloc_ = VK_NULL_HANDLE; + } + if (indexBuffer_ != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, indexBuffer_, indexAlloc_); + indexBuffer_ = VK_NULL_HANDLE; + indexAlloc_ = VK_NULL_HANDLE; } } diff --git a/src/rendering/frustum.cpp b/src/rendering/frustum.cpp index 6d0d58d4..45e9ff60 100644 --- a/src/rendering/frustum.cpp +++ b/src/rendering/frustum.cpp @@ -5,45 +5,56 @@ namespace wowee { namespace rendering { void Frustum::extractFromMatrix(const glm::mat4& vp) { - // Extract planes from view-projection matrix - // Based on Gribb & Hartmann method (Fast Extraction of Viewing Frustum Planes) + // Extract frustum planes from view-projection matrix. + // Vulkan clip-space conventions (GLM_FORCE_DEPTH_ZERO_TO_ONE + Y-flip): + // x_clip ∈ [-w, w], y_clip ∈ [-w, w] (Y flipped in proj), z_clip ∈ [0, w] + // + // Gribb & Hartmann method adapted for Vulkan depth [0,1]. + // Left/Right/Top/Bottom use the standard row4 Β± row1/row2 formulas + // (the Y-flip swaps the TOP/BOTTOM row2 sign, but the extracted half-spaces + // are still correct β€” they just get each other's label. We swap the + // assignments so the enum names match the geometric meaning.) - // Left plane: row4 + row1 + // Left plane: row4 + row1 (x_clip >= -w_clip) planes[LEFT].normal.x = vp[0][3] + vp[0][0]; planes[LEFT].normal.y = vp[1][3] + vp[1][0]; planes[LEFT].normal.z = vp[2][3] + vp[2][0]; planes[LEFT].distance = vp[3][3] + vp[3][0]; normalizePlane(planes[LEFT]); - // Right plane: row4 - row1 + // Right plane: row4 - row1 (x_clip <= w_clip) planes[RIGHT].normal.x = vp[0][3] - vp[0][0]; planes[RIGHT].normal.y = vp[1][3] - vp[1][0]; planes[RIGHT].normal.z = vp[2][3] - vp[2][0]; planes[RIGHT].distance = vp[3][3] - vp[3][0]; normalizePlane(planes[RIGHT]); - // Bottom plane: row4 + row2 - planes[BOTTOM].normal.x = vp[0][3] + vp[0][1]; - planes[BOTTOM].normal.y = vp[1][3] + vp[1][1]; - planes[BOTTOM].normal.z = vp[2][3] + vp[2][1]; - planes[BOTTOM].distance = vp[3][3] + vp[3][1]; - normalizePlane(planes[BOTTOM]); + // With the Vulkan Y-flip (proj[1][1] negated), row4+row2 extracts + // what is geometrically the TOP plane and row4-row2 extracts BOTTOM. + // Swap the assignments so enum labels match geometry. - // Top plane: row4 - row2 - planes[TOP].normal.x = vp[0][3] - vp[0][1]; - planes[TOP].normal.y = vp[1][3] - vp[1][1]; - planes[TOP].normal.z = vp[2][3] - vp[2][1]; - planes[TOP].distance = vp[3][3] - vp[3][1]; + // Top plane (geometric): row4 - row2 after Y-flip + planes[TOP].normal.x = vp[0][3] + vp[0][1]; + planes[TOP].normal.y = vp[1][3] + vp[1][1]; + planes[TOP].normal.z = vp[2][3] + vp[2][1]; + planes[TOP].distance = vp[3][3] + vp[3][1]; normalizePlane(planes[TOP]); - // Near plane: row4 + row3 - planes[NEAR].normal.x = vp[0][3] + vp[0][2]; - planes[NEAR].normal.y = vp[1][3] + vp[1][2]; - planes[NEAR].normal.z = vp[2][3] + vp[2][2]; - planes[NEAR].distance = vp[3][3] + vp[3][2]; + // Bottom plane (geometric): row4 + row2 after Y-flip + planes[BOTTOM].normal.x = vp[0][3] - vp[0][1]; + planes[BOTTOM].normal.y = vp[1][3] - vp[1][1]; + planes[BOTTOM].normal.z = vp[2][3] - vp[2][1]; + planes[BOTTOM].distance = vp[3][3] - vp[3][1]; + normalizePlane(planes[BOTTOM]); + + // Near plane: row3 (z_clip >= 0 in Vulkan depth [0,1]) + planes[NEAR].normal.x = vp[0][2]; + planes[NEAR].normal.y = vp[1][2]; + planes[NEAR].normal.z = vp[2][2]; + planes[NEAR].distance = vp[3][2]; normalizePlane(planes[NEAR]); - // Far plane: row4 - row3 + // Far plane: row4 - row3 (z_clip <= w_clip) planes[FAR].normal.x = vp[0][3] - vp[0][2]; planes[FAR].normal.y = vp[1][3] - vp[1][2]; planes[FAR].normal.z = vp[2][3] - vp[2][2]; diff --git a/src/rendering/lens_flare.cpp b/src/rendering/lens_flare.cpp index 8bae2551..820641af 100644 --- a/src/rendering/lens_flare.cpp +++ b/src/rendering/lens_flare.cpp @@ -1,9 +1,11 @@ #include "rendering/lens_flare.hpp" #include "rendering/camera.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" #include -#include #include namespace wowee { @@ -13,23 +15,19 @@ LensFlare::LensFlare() { } LensFlare::~LensFlare() { - cleanup(); + shutdown(); } -bool LensFlare::initialize() { +bool LensFlare::initialize(VkContext* ctx, VkDescriptorSetLayout /*perFrameLayout*/) { LOG_INFO("Initializing lens flare system"); + vkCtx = ctx; + VkDevice device = vkCtx->getDevice(); + // Generate flare elements generateFlareElements(); - // Create VAO and VBO for quad rendering - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); - - glBindVertexArray(vao); - glBindBuffer(GL_ARRAY_BUFFER, vbo); - - // Position (x, y) and UV (u, v) for a quad + // Upload static quad vertex buffer (pos2 + uv2, 6 vertices) float quadVertices[] = { // Pos UV -0.5f, -0.5f, 0.0f, 0.0f, @@ -40,81 +38,85 @@ bool LensFlare::initialize() { -0.5f, 0.5f, 0.0f, 1.0f }; - glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), quadVertices, GL_STATIC_DRAW); + AllocatedBuffer vbuf = uploadBuffer(*vkCtx, + quadVertices, + sizeof(quadVertices), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + vertexBuffer = vbuf.buffer; + vertexAlloc = vbuf.allocation; - // Position attribute - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); + // Load SPIR-V shaders + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/lens_flare.vert.spv")) { + LOG_ERROR("Failed to load lens flare vertex shader"); + return false; + } - // UV attribute - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); - glEnableVertexAttribArray(1); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/lens_flare.frag.spv")) { + LOG_ERROR("Failed to load lens flare fragment shader"); + return false; + } - glBindVertexArray(0); + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); - // Create shader - shader = std::make_unique(); + // Push constant range: FlarePushConstants = 32 bytes, used by both vert and frag + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(FlarePushConstants); // 32 bytes - // Lens flare vertex shader (2D screen-space rendering) - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - layout (location = 1) in vec2 aUV; + // No descriptor set layouts β€” lens flare only uses push constants + pipelineLayout = createPipelineLayout(device, {}, {pushRange}); + if (pipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create lens flare pipeline layout"); + return false; + } - uniform vec2 uPosition; // Screen-space position (-1 to 1) - uniform float uSize; // Size in screen space - uniform float uAspectRatio; + // Vertex input: pos2 + uv2, stride = 4 * sizeof(float) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 4 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - out vec2 TexCoord; + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32_SFLOAT; + posAttr.offset = 0; - void main() { - // Scale by size and aspect ratio - vec2 scaledPos = aPos * uSize; - scaledPos.x /= uAspectRatio; + VkVertexInputAttributeDescription uvAttr{}; + uvAttr.location = 1; + uvAttr.binding = 0; + uvAttr.format = VK_FORMAT_R32G32_SFLOAT; + uvAttr.offset = 2 * sizeof(float); - // Translate to position - vec2 finalPos = scaledPos + uPosition; + // Dynamic viewport and scissor + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; - gl_Position = vec4(finalPos, 0.0, 1.0); - TexCoord = aUV; - } - )"; + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, uvAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); - // Lens flare fragment shader (circular gradient) - const char* fragmentShaderSource = R"( - #version 330 core - in vec2 TexCoord; + // Shader modules can be freed after pipeline creation + vertModule.destroy(); + fragModule.destroy(); - uniform vec3 uColor; - uniform float uBrightness; - - out vec4 FragColor; - - void main() { - // Distance from center - vec2 center = vec2(0.5); - float dist = distance(TexCoord, center); - - // Circular gradient with soft edges - float alpha = smoothstep(0.5, 0.0, dist); - - // Add some variation - brighter in center - float centerGlow = smoothstep(0.5, 0.0, dist * 2.0); - alpha = max(alpha * 0.3, centerGlow); - - // Apply brightness - alpha *= uBrightness; - - if (alpha < 0.01) { - discard; - } - - FragColor = vec4(uColor, alpha); - } - )"; - - if (!shader->loadFromSource(vertexShaderSource, fragmentShaderSource)) { - LOG_ERROR("Failed to create lens flare shader"); + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create lens flare pipeline"); return false; } @@ -122,6 +124,86 @@ bool LensFlare::initialize() { return true; } +void LensFlare::shutdown() { + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + if (vertexBuffer != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, vertexBuffer, vertexAlloc); + vertexBuffer = VK_NULL_HANDLE; + vertexAlloc = VK_NULL_HANDLE; + } + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline, nullptr); + pipeline = VK_NULL_HANDLE; + } + if (pipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout, nullptr); + pipelineLayout = VK_NULL_HANDLE; + } + } + + vkCtx = nullptr; +} + +void LensFlare::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + + // Destroy old pipeline (NOT layout) + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline, nullptr); + pipeline = VK_NULL_HANDLE; + } + + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/lens_flare.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/lens_flare.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 4 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32_SFLOAT; + posAttr.offset = 0; + + VkVertexInputAttributeDescription uvAttr{}; + uvAttr.location = 1; + uvAttr.binding = 0; + uvAttr.format = VK_FORMAT_R32G32_SFLOAT; + uvAttr.offset = 2 * sizeof(float); + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, uvAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); +} + void LensFlare::generateFlareElements() { flareElements.clear(); @@ -129,28 +211,27 @@ void LensFlare::generateFlareElements() { flareElements.push_back({0.0f, 0.3f, glm::vec3(1.0f, 0.95f, 0.8f), 0.8f}); // Flare ghosts along sun-to-center axis - // These appear at various positions between sun and opposite side // Bright white ghost near sun flareElements.push_back({0.2f, 0.08f, glm::vec3(1.0f, 1.0f, 1.0f), 0.5f}); // Blue-tinted ghost - flareElements.push_back({0.4f, 0.15f, glm::vec3(0.3f, 0.5f, 1.0f), 0.4f}); + flareElements.push_back({0.4f, 0.15f, glm::vec3(0.4f, 0.55f, 0.9f), 0.35f}); // Small bright spot - flareElements.push_back({0.6f, 0.05f, glm::vec3(1.0f, 0.8f, 0.6f), 0.6f}); + flareElements.push_back({0.6f, 0.05f, glm::vec3(1.0f, 0.8f, 0.6f), 0.5f}); - // Green-tinted ghost (chromatic aberration) - flareElements.push_back({0.8f, 0.12f, glm::vec3(0.4f, 1.0f, 0.5f), 0.3f}); + // Warm amber ghost (replaced oversaturated green) + flareElements.push_back({0.8f, 0.10f, glm::vec3(0.9f, 0.75f, 0.5f), 0.2f}); // Large halo on opposite side - flareElements.push_back({-0.5f, 0.25f, glm::vec3(1.0f, 0.7f, 0.4f), 0.2f}); + flareElements.push_back({-0.5f, 0.22f, glm::vec3(1.0f, 0.8f, 0.5f), 0.15f}); - // Purple ghost far from sun - flareElements.push_back({-0.8f, 0.1f, glm::vec3(0.8f, 0.4f, 1.0f), 0.25f}); + // Faint blue ghost far from sun + flareElements.push_back({-0.8f, 0.08f, glm::vec3(0.6f, 0.5f, 0.9f), 0.15f}); - // Small red ghost - flareElements.push_back({-1.2f, 0.06f, glm::vec3(1.0f, 0.3f, 0.3f), 0.3f}); + // Small warm ghost + flareElements.push_back({-1.2f, 0.05f, glm::vec3(1.0f, 0.6f, 0.4f), 0.2f}); } glm::vec2 LensFlare::worldToScreen(const Camera& camera, const glm::vec3& worldPos) const { @@ -205,8 +286,10 @@ float LensFlare::calculateSunVisibility(const Camera& camera, const glm::vec3& s return angleFactor * edgeFade; } -void LensFlare::render(const Camera& camera, const glm::vec3& sunPosition, float timeOfDay) { - if (!enabled || !shader) { +void LensFlare::render(VkCommandBuffer cmd, const Camera& camera, const glm::vec3& sunPosition, + float timeOfDay, float fogDensity, float cloudDensity, + float weatherIntensity) { + if (!enabled || pipeline == VK_NULL_HANDLE) { return; } @@ -230,6 +313,16 @@ void LensFlare::render(const Camera& camera, const glm::vec3& sunPosition, float return; } + // Atmospheric attenuation β€” fog, clouds, and weather reduce lens flare + float atmosphericFactor = 1.0f; + atmosphericFactor *= (1.0f - glm::clamp(fogDensity * 0.8f, 0.0f, 0.9f)); // Heavy fog nearly kills flare + atmosphericFactor *= (1.0f - glm::clamp(cloudDensity * 0.6f, 0.0f, 0.7f)); // Clouds attenuate + atmosphericFactor *= (1.0f - glm::clamp(weatherIntensity * 0.9f, 0.0f, 0.95f)); // Rain/snow heavily attenuates + + if (atmosphericFactor < 0.01f) { + return; + } + // Get sun screen position glm::vec2 sunScreen = worldToScreen(camera, anchoredSunPos); glm::vec2 screenCenter(0.0f, 0.0f); @@ -237,61 +330,42 @@ void LensFlare::render(const Camera& camera, const glm::vec3& sunPosition, float // Vector from sun to screen center glm::vec2 sunToCenter = screenCenter - sunScreen; - // Enable additive blending for flare effect - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blending - - // Disable depth test (render on top) - glDisable(GL_DEPTH_TEST); - - shader->use(); - - // Set aspect ratio float aspectRatio = camera.getAspectRatio(); - shader->setUniform("uAspectRatio", aspectRatio); - glBindVertexArray(vao); + // Bind pipeline + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + + // Bind vertex buffer + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, &offset); // Render each flare element for (const auto& element : flareElements) { // Calculate position along sun-to-center axis glm::vec2 position = sunScreen + sunToCenter * element.position; - // Set uniforms - shader->setUniform("uPosition", position); - shader->setUniform("uSize", element.size); - shader->setUniform("uColor", element.color); + // Apply visibility, intensity, and atmospheric attenuation + float brightness = element.brightness * visibility * intensityMultiplier * atmosphericFactor; - // Apply visibility and intensity - float brightness = element.brightness * visibility * intensityMultiplier; - shader->setUniform("uBrightness", brightness); + // Set push constants + FlarePushConstants push{}; + push.position = position; + push.size = element.size; + push.aspectRatio = aspectRatio; + push.colorBrightness = glm::vec4(element.color, brightness); - // Render quad - glDrawArrays(GL_TRIANGLES, 0, VERTICES_PER_QUAD); + vkCmdPushConstants(cmd, pipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); + + // Draw quad + vkCmdDraw(cmd, VERTICES_PER_QUAD, 1, 0, 0); } - - glBindVertexArray(0); - - // Restore state - glEnable(GL_DEPTH_TEST); - glDisable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // Restore standard blending } void LensFlare::setIntensity(float intensity) { this->intensityMultiplier = glm::clamp(intensity, 0.0f, 2.0f); } -void LensFlare::cleanup() { - if (vao) { - glDeleteVertexArrays(1, &vao); - vao = 0; - } - if (vbo) { - glDeleteBuffers(1, &vbo); - vbo = 0; - } -} - } // namespace rendering } // namespace wowee diff --git a/src/rendering/lightning.cpp b/src/rendering/lightning.cpp index 58193e4d..9dbd1b95 100644 --- a/src/rendering/lightning.cpp +++ b/src/rendering/lightning.cpp @@ -1,10 +1,14 @@ #include "rendering/lightning.hpp" -#include "rendering/shader.hpp" #include "rendering/camera.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" -#include #include #include +#include namespace wowee { namespace rendering { @@ -41,125 +45,310 @@ Lightning::~Lightning() { shutdown(); } -bool Lightning::initialize() { +bool Lightning::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { core::Logger::getInstance().info("Initializing lightning system..."); - // Create bolt shader - const char* boltVertexSrc = R"( - #version 330 core - layout (location = 0) in vec3 aPos; + vkCtx = ctx; + VkDevice device = vkCtx->getDevice(); - uniform mat4 uViewProjection; - uniform float uBrightness; - - out float vBrightness; - - void main() { - gl_Position = uViewProjection * vec4(aPos, 1.0); - vBrightness = uBrightness; - } - )"; - - const char* boltFragmentSrc = R"( - #version 330 core - in float vBrightness; - out vec4 FragColor; - - void main() { - // Electric blue-white color - vec3 color = mix(vec3(0.6, 0.8, 1.0), vec3(1.0), vBrightness * 0.5); - FragColor = vec4(color, vBrightness); - } - )"; - - boltShader = std::make_unique(); - if (!boltShader->loadFromSource(boltVertexSrc, boltFragmentSrc)) { - core::Logger::getInstance().error("Failed to create bolt shader"); - return false; - } - - // Create flash shader (fullscreen quad) - const char* flashVertexSrc = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - - void main() { - gl_Position = vec4(aPos, 0.0, 1.0); - } - )"; - - const char* flashFragmentSrc = R"( - #version 330 core - uniform float uIntensity; - out vec4 FragColor; - - void main() { - // Bright white flash with fade - vec3 color = vec3(1.0); - FragColor = vec4(color, uIntensity * 0.6); - } - )"; - - flashShader = std::make_unique(); - if (!flashShader->loadFromSource(flashVertexSrc, flashFragmentSrc)) { - core::Logger::getInstance().error("Failed to create flash shader"); - return false; - } - - // Create bolt VAO/VBO - glGenVertexArrays(1, &boltVAO); - glGenBuffers(1, &boltVBO); - - glBindVertexArray(boltVAO); - glBindBuffer(GL_ARRAY_BUFFER, boltVBO); - - // Reserve space for segments - glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec3) * MAX_SEGMENTS * 2, nullptr, GL_DYNAMIC_DRAW); - - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), (void*)0); - - // Create flash quad VAO/VBO - glGenVertexArrays(1, &flashVAO); - glGenBuffers(1, &flashVBO); - - float flashQuad[] = { - -1.0f, -1.0f, - 1.0f, -1.0f, - -1.0f, 1.0f, - 1.0f, 1.0f + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR }; - glBindVertexArray(flashVAO); - glBindBuffer(GL_ARRAY_BUFFER, flashVBO); - glBufferData(GL_ARRAY_BUFFER, sizeof(flashQuad), flashQuad, GL_STATIC_DRAW); + // ---- Bolt pipeline (LINE_STRIP) ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/lightning_bolt.vert.spv")) { + core::Logger::getInstance().error("Failed to load lightning_bolt vertex shader"); + return false; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/lightning_bolt.frag.spv")) { + core::Logger::getInstance().error("Failed to load lightning_bolt fragment shader"); + return false; + } - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); - glBindVertexArray(0); + // Push constant: { float brightness; } = 4 bytes + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(float); + + boltPipelineLayout = createPipelineLayout(device, {perFrameLayout}, {pushRange}); + if (boltPipelineLayout == VK_NULL_HANDLE) { + core::Logger::getInstance().error("Failed to create bolt pipeline layout"); + return false; + } + + // Vertex input: position only (vec3) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = sizeof(glm::vec3); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + boltPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_LINE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() // Always visible (like the GL version) + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) // Additive for electric glow + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(boltPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (boltPipeline == VK_NULL_HANDLE) { + core::Logger::getInstance().error("Failed to create bolt pipeline"); + return false; + } + } + + // ---- Flash pipeline (fullscreen quad, TRIANGLE_STRIP) ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/lightning_flash.vert.spv")) { + core::Logger::getInstance().error("Failed to load lightning_flash vertex shader"); + return false; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/lightning_flash.frag.spv")) { + core::Logger::getInstance().error("Failed to load lightning_flash fragment shader"); + return false; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // Push constant: { float intensity; } = 4 bytes + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(float); + + flashPipelineLayout = createPipelineLayout(device, {}, {pushRange}); + if (flashPipelineLayout == VK_NULL_HANDLE) { + core::Logger::getInstance().error("Failed to create flash pipeline layout"); + return false; + } + + // Vertex input: position only (vec2) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 2 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32_SFLOAT; + posAttr.offset = 0; + + flashPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(flashPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (flashPipeline == VK_NULL_HANDLE) { + core::Logger::getInstance().error("Failed to create flash pipeline"); + return false; + } + } + + // ---- Create dynamic mapped vertex buffer for bolt segments ---- + // Each bolt can have up to MAX_SEGMENTS * 2 vec3 entries (segments + branches) + boltDynamicVBSize = MAX_SEGMENTS * 4 * sizeof(glm::vec3); // generous capacity + { + AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), boltDynamicVBSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + boltDynamicVB = buf.buffer; + boltDynamicVBAlloc = buf.allocation; + boltDynamicVBAllocInfo = buf.info; + if (boltDynamicVB == VK_NULL_HANDLE) { + core::Logger::getInstance().error("Failed to create bolt dynamic vertex buffer"); + return false; + } + } + + // ---- Create static flash quad vertex buffer ---- + { + float flashQuad[] = { + -1.0f, -1.0f, + 1.0f, -1.0f, + -1.0f, 1.0f, + 1.0f, 1.0f + }; + + AllocatedBuffer buf = uploadBuffer(*vkCtx, flashQuad, sizeof(flashQuad), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + flashQuadVB = buf.buffer; + flashQuadVBAlloc = buf.allocation; + if (flashQuadVB == VK_NULL_HANDLE) { + core::Logger::getInstance().error("Failed to create flash quad vertex buffer"); + return false; + } + } core::Logger::getInstance().info("Lightning system initialized"); return true; } void Lightning::shutdown() { - if (boltVAO) { - glDeleteVertexArrays(1, &boltVAO); - glDeleteBuffers(1, &boltVBO); - boltVAO = 0; - boltVBO = 0; + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + if (boltPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, boltPipeline, nullptr); + boltPipeline = VK_NULL_HANDLE; + } + if (boltPipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, boltPipelineLayout, nullptr); + boltPipelineLayout = VK_NULL_HANDLE; + } + if (boltDynamicVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, boltDynamicVB, boltDynamicVBAlloc); + boltDynamicVB = VK_NULL_HANDLE; + boltDynamicVBAlloc = VK_NULL_HANDLE; + } + + if (flashPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, flashPipeline, nullptr); + flashPipeline = VK_NULL_HANDLE; + } + if (flashPipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, flashPipelineLayout, nullptr); + flashPipelineLayout = VK_NULL_HANDLE; + } + if (flashQuadVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, flashQuadVB, flashQuadVBAlloc); + flashQuadVB = VK_NULL_HANDLE; + flashQuadVBAlloc = VK_NULL_HANDLE; + } } - if (flashVAO) { - glDeleteVertexArrays(1, &flashVAO); - glDeleteBuffers(1, &flashVBO); - flashVAO = 0; - flashVBO = 0; + vkCtx = nullptr; +} + +void Lightning::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + + // Destroy old pipelines (NOT layouts) + if (boltPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, boltPipeline, nullptr); + boltPipeline = VK_NULL_HANDLE; + } + if (flashPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, flashPipeline, nullptr); + flashPipeline = VK_NULL_HANDLE; } - boltShader.reset(); - flashShader.reset(); + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + // ---- Rebuild bolt pipeline (LINE_STRIP) ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/lightning_bolt.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/lightning_bolt.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = sizeof(glm::vec3); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + boltPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_LINE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(boltPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } + + // ---- Rebuild flash pipeline (TRIANGLE_STRIP) ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/lightning_flash.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/lightning_flash.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 2 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32_SFLOAT; + posAttr.offset = 0; + + flashPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(flashPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } } void Lightning::update(float deltaTime, const Camera& camera) { @@ -325,73 +514,65 @@ void Lightning::generateBoltSegments(const glm::vec3& start, const glm::vec3& en segments.push_back(end); } -void Lightning::render([[maybe_unused]] const Camera& camera, const glm::mat4& view, const glm::mat4& projection) { +void Lightning::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (!enabled) { return; } - glm::mat4 viewProj = projection * view; - - renderBolts(viewProj); - renderFlash(); + renderBolts(cmd, perFrameSet); + renderFlash(cmd); } -void Lightning::renderBolts(const glm::mat4& viewProj) { - // Enable additive blending for electric glow - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE); - glDisable(GL_DEPTH_TEST); // Always visible +void Lightning::renderBolts(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (boltPipeline == VK_NULL_HANDLE) return; - boltShader->use(); - boltShader->setUniform("uViewProjection", viewProj); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, boltPipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, boltPipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); - glBindVertexArray(boltVAO); - glLineWidth(3.0f); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &boltDynamicVB, &offset); for (const auto& bolt : bolts) { if (!bolt.active || bolt.segments.empty()) { continue; } - boltShader->setUniform("uBrightness", bolt.brightness); + // Upload bolt segments to mapped buffer + VkDeviceSize uploadSize = bolt.segments.size() * sizeof(glm::vec3); + if (uploadSize > boltDynamicVBSize) { + // Clamp to buffer size + uploadSize = boltDynamicVBSize; + } + if (boltDynamicVBAllocInfo.pMappedData) { + std::memcpy(boltDynamicVBAllocInfo.pMappedData, bolt.segments.data(), uploadSize); + } - // Upload segments - glBindBuffer(GL_ARRAY_BUFFER, boltVBO); - glBufferSubData(GL_ARRAY_BUFFER, 0, - bolt.segments.size() * sizeof(glm::vec3), - bolt.segments.data()); + // Push brightness + vkCmdPushConstants(cmd, boltPipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(float), &bolt.brightness); - // Draw as line strip - glDrawArrays(GL_LINE_STRIP, 0, static_cast(bolt.segments.size())); + uint32_t vertexCount = static_cast(uploadSize / sizeof(glm::vec3)); + vkCmdDraw(cmd, vertexCount, 1, 0, 0); } - - glLineWidth(1.0f); - glBindVertexArray(0); - - glEnable(GL_DEPTH_TEST); - glDisable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); } -void Lightning::renderFlash() { - if (!flash.active || flash.intensity <= 0.01f) { +void Lightning::renderFlash(VkCommandBuffer cmd) { + if (!flash.active || flash.intensity <= 0.01f || flashPipeline == VK_NULL_HANDLE) { return; } - // Fullscreen flash overlay - glDisable(GL_DEPTH_TEST); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, flashPipeline); - flashShader->use(); - flashShader->setUniform("uIntensity", flash.intensity); + // Push flash intensity + vkCmdPushConstants(cmd, flashPipelineLayout, + VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(float), &flash.intensity); - glBindVertexArray(flashVAO); - glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); - glBindVertexArray(0); - - glEnable(GL_DEPTH_TEST); - glDisable(GL_BLEND); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &flashQuadVB, &offset); + vkCmdDraw(cmd, 4, 1, 0, 0); } void Lightning::setEnabled(bool enabled) { diff --git a/src/rendering/loading_screen.cpp b/src/rendering/loading_screen.cpp index defd6bfc..3511d8e3 100644 --- a/src/rendering/loading_screen.cpp +++ b/src/rendering/loading_screen.cpp @@ -1,8 +1,9 @@ #include "rendering/loading_screen.hpp" +#include "rendering/vk_context.hpp" #include "core/logger.hpp" #include #include -#include +#include #include #include #include @@ -15,8 +16,7 @@ namespace wowee { namespace rendering { LoadingScreen::LoadingScreen() { - imagePaths.push_back("assets/loading1.jpeg"); - imagePaths.push_back("assets/loading2.jpeg"); + imagePaths.push_back("assets/krayonload.png"); } LoadingScreen::~LoadingScreen() { @@ -24,140 +24,37 @@ LoadingScreen::~LoadingScreen() { } bool LoadingScreen::initialize() { - LOG_INFO("Initializing loading screen"); - - // Background image shader (textured quad) - const char* vertexSrc = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - layout (location = 1) in vec2 aTexCoord; - out vec2 TexCoord; - void main() { - gl_Position = vec4(aPos, 0.0, 1.0); - TexCoord = aTexCoord; - } - )"; - - const char* fragmentSrc = R"( - #version 330 core - in vec2 TexCoord; - out vec4 FragColor; - uniform sampler2D screenTexture; - void main() { - FragColor = texture(screenTexture, TexCoord); - } - )"; - - GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vertexShader, 1, &vertexSrc, nullptr); - glCompileShader(vertexShader); - - GLint success; - glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success); - if (!success) { - char infoLog[512]; - glGetShaderInfoLog(vertexShader, 512, nullptr, infoLog); - LOG_ERROR("Loading screen vertex shader compilation failed: ", infoLog); - return false; - } - - GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fragmentShader, 1, &fragmentSrc, nullptr); - glCompileShader(fragmentShader); - - glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success); - if (!success) { - char infoLog[512]; - glGetShaderInfoLog(fragmentShader, 512, nullptr, infoLog); - LOG_ERROR("Loading screen fragment shader compilation failed: ", infoLog); - return false; - } - - shaderId = glCreateProgram(); - glAttachShader(shaderId, vertexShader); - glAttachShader(shaderId, fragmentShader); - glLinkProgram(shaderId); - - glGetProgramiv(shaderId, GL_LINK_STATUS, &success); - if (!success) { - char infoLog[512]; - glGetProgramInfoLog(shaderId, 512, nullptr, infoLog); - LOG_ERROR("Loading screen shader linking failed: ", infoLog); - return false; - } - - glDeleteShader(vertexShader); - glDeleteShader(fragmentShader); - - // Simple solid-color shader for progress bar - const char* barVertSrc = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - void main() { - gl_Position = vec4(aPos, 0.0, 1.0); - } - )"; - - const char* barFragSrc = R"( - #version 330 core - out vec4 FragColor; - uniform vec4 uColor; - void main() { - FragColor = uColor; - } - )"; - - GLuint bv = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(bv, 1, &barVertSrc, nullptr); - glCompileShader(bv); - GLuint bf = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(bf, 1, &barFragSrc, nullptr); - glCompileShader(bf); - - barShaderId = glCreateProgram(); - glAttachShader(barShaderId, bv); - glAttachShader(barShaderId, bf); - glLinkProgram(barShaderId); - - glDeleteShader(bv); - glDeleteShader(bf); - - createQuad(); - createBarQuad(); + LOG_INFO("Initializing loading screen (Vulkan/ImGui)"); selectRandomImage(); - LOG_INFO("Loading screen initialized"); return true; } void LoadingScreen::shutdown() { - if (textureId) { - glDeleteTextures(1, &textureId); - textureId = 0; - } - if (vao) { - glDeleteVertexArrays(1, &vao); - vao = 0; - } - if (vbo) { - glDeleteBuffers(1, &vbo); - vbo = 0; - } - if (shaderId) { - glDeleteProgram(shaderId); - shaderId = 0; - } - if (barVao) { - glDeleteVertexArrays(1, &barVao); - barVao = 0; - } - if (barVbo) { - glDeleteBuffers(1, &barVbo); - barVbo = 0; - } - if (barShaderId) { - glDeleteProgram(barShaderId); - barShaderId = 0; + if (vkCtx && bgImage) { + VkDevice device = vkCtx->getDevice(); + vkDeviceWaitIdle(device); + + if (bgDescriptorSet) { + // ImGui manages descriptor set lifetime + bgDescriptorSet = VK_NULL_HANDLE; + } + if (bgSampler) { + vkDestroySampler(device, bgSampler, nullptr); + bgSampler = VK_NULL_HANDLE; + } + if (bgImageView) { + vkDestroyImageView(device, bgImageView, nullptr); + bgImageView = VK_NULL_HANDLE; + } + if (bgImage) { + vkDestroyImage(device, bgImage, nullptr); + bgImage = VK_NULL_HANDLE; + } + if (bgMemory) { + vkFreeMemory(device, bgMemory, nullptr); + bgMemory = VK_NULL_HANDLE; + } } } @@ -175,14 +72,36 @@ void LoadingScreen::selectRandomImage() { loadImage(imagePaths[currentImageIndex]); } +static uint32_t findMemoryType(VkPhysicalDevice physDevice, uint32_t typeFilter, VkMemoryPropertyFlags properties) { + VkPhysicalDeviceMemoryProperties memProperties; + vkGetPhysicalDeviceMemoryProperties(physDevice, &memProperties); + for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) { + if ((typeFilter & (1 << i)) && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) { + return i; + } + } + return 0; +} + bool LoadingScreen::loadImage(const std::string& path) { - if (textureId) { - glDeleteTextures(1, &textureId); - textureId = 0; + if (!vkCtx) { + LOG_WARNING("No VkContext for loading screen image"); + return false; + } + + // Clean up old image + if (bgImage) { + VkDevice device = vkCtx->getDevice(); + vkDeviceWaitIdle(device); + if (bgSampler) { vkDestroySampler(device, bgSampler, nullptr); bgSampler = VK_NULL_HANDLE; } + if (bgImageView) { vkDestroyImageView(device, bgImageView, nullptr); bgImageView = VK_NULL_HANDLE; } + if (bgImage) { vkDestroyImage(device, bgImage, nullptr); bgImage = VK_NULL_HANDLE; } + if (bgMemory) { vkFreeMemory(device, bgMemory, nullptr); bgMemory = VK_NULL_HANDLE; } + bgDescriptorSet = VK_NULL_HANDLE; } int channels; - stbi_set_flip_vertically_on_load(true); + stbi_set_flip_vertically_on_load(false); // ImGui expects top-down unsigned char* data = stbi_load(path.c_str(), &imageWidth, &imageHeight, &channels, 4); if (!data) { @@ -192,215 +111,249 @@ bool LoadingScreen::loadImage(const std::string& path) { LOG_INFO("Loaded loading screen image: ", imageWidth, "x", imageHeight); - glGenTextures(1, &textureId); - glBindTexture(GL_TEXTURE_2D, textureId); + VkDevice device = vkCtx->getDevice(); + VkPhysicalDevice physDevice = vkCtx->getPhysicalDevice(); + VkDeviceSize imageSize = static_cast(imageWidth) * imageHeight * 4; - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + // Create staging buffer + VkBuffer stagingBuffer; + VkDeviceMemory stagingMemory; + { + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = imageSize; + bufInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + bufInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + vkCreateBuffer(device, &bufInfo, nullptr, &stagingBuffer); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, imageWidth, imageHeight, 0, - GL_RGBA, GL_UNSIGNED_BYTE, data); + VkMemoryRequirements memReqs; + vkGetBufferMemoryRequirements(device, stagingBuffer, &memReqs); + + VkMemoryAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = findMemoryType(physDevice, memReqs.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + vkAllocateMemory(device, &allocInfo, nullptr, &stagingMemory); + vkBindBufferMemory(device, stagingBuffer, stagingMemory, 0); + + void* mapped; + vkMapMemory(device, stagingMemory, 0, imageSize, 0, &mapped); + memcpy(mapped, data, imageSize); + vkUnmapMemory(device, stagingMemory); + } stbi_image_free(data); - glBindTexture(GL_TEXTURE_2D, 0); + + // Create image + { + VkImageCreateInfo imgInfo{}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = VK_FORMAT_R8G8B8A8_UNORM; + imgInfo.extent = {static_cast(imageWidth), static_cast(imageHeight), 1}; + imgInfo.mipLevels = 1; + imgInfo.arrayLayers = 1; + imgInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + imgInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + vkCreateImage(device, &imgInfo, nullptr, &bgImage); + + VkMemoryRequirements memReqs; + vkGetImageMemoryRequirements(device, bgImage, &memReqs); + + VkMemoryAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = findMemoryType(physDevice, memReqs.memoryTypeBits, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + vkAllocateMemory(device, &allocInfo, nullptr, &bgMemory); + vkBindImageMemory(device, bgImage, bgMemory, 0); + } + + // Transfer: transition, copy, transition + vkCtx->immediateSubmit([&](VkCommandBuffer cmd) { + // Transition to transfer dst + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = bgImage; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + + // Copy buffer to image + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {static_cast(imageWidth), static_cast(imageHeight), 1}; + vkCmdCopyBufferToImage(cmd, stagingBuffer, bgImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + + // Transition to shader read + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + }); + + // Cleanup staging + vkDestroyBuffer(device, stagingBuffer, nullptr); + vkFreeMemory(device, stagingMemory, nullptr); + + // Create image view + { + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = bgImage; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM; + viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCreateImageView(device, &viewInfo, nullptr, &bgImageView); + } + + // Create sampler + { + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.magFilter = VK_FILTER_LINEAR; + samplerInfo.minFilter = VK_FILTER_LINEAR; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + vkCreateSampler(device, &samplerInfo, nullptr, &bgSampler); + } + + // Register with ImGui as a texture + bgDescriptorSet = ImGui_ImplVulkan_AddTexture(bgSampler, bgImageView, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); return true; } -void LoadingScreen::createQuad() { - float vertices[] = { - // Position // TexCoord - -1.0f, 1.0f, 0.0f, 1.0f, - -1.0f, -1.0f, 0.0f, 0.0f, - 1.0f, -1.0f, 1.0f, 0.0f, - - -1.0f, 1.0f, 0.0f, 1.0f, - 1.0f, -1.0f, 1.0f, 0.0f, - 1.0f, 1.0f, 1.0f, 1.0f - }; - - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); - - glBindVertexArray(vao); - glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); - - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); - glEnableVertexAttribArray(1); - - glBindVertexArray(0); -} - -void LoadingScreen::createBarQuad() { - // Dynamic quad β€” vertices updated each frame via glBufferSubData - glGenVertexArrays(1, &barVao); - glGenBuffers(1, &barVbo); - - glBindVertexArray(barVao); - glBindBuffer(GL_ARRAY_BUFFER, barVbo); - glBufferData(GL_ARRAY_BUFFER, 12 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); - - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - - glBindVertexArray(0); -} - void LoadingScreen::render() { - if (!vao || !shaderId) return; + // If a frame is already in progress (e.g. called from a UI callback), + // end it before starting our own + ImGuiContext* ctx = ImGui::GetCurrentContext(); + if (ctx && ctx->FrameCount >= 0 && ctx->WithinFrameScope) { + ImGui::EndFrame(); + } - glClearColor(0.0f, 0.0f, 0.0f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT); + ImGuiIO& io = ImGui::GetIO(); + float screenW = io.DisplaySize.x; + float screenH = io.DisplaySize.y; - glDisable(GL_DEPTH_TEST); + ImGui_ImplVulkan_NewFrame(); + ImGui_ImplSDL2_NewFrame(); + ImGui::NewFrame(); + + // Invisible fullscreen window + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); + ImGui::Begin("##LoadingScreen", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoBringToFrontOnFocus); // Draw background image - if (textureId) { - glUseProgram(shaderId); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, textureId); - glBindVertexArray(vao); - glDrawArrays(GL_TRIANGLES, 0, 6); - glBindVertexArray(0); + if (bgDescriptorSet) { + ImGui::GetWindowDrawList()->AddImage( + reinterpret_cast(bgDescriptorSet), + ImVec2(0, 0), ImVec2(screenW, screenH)); } - // Draw progress bar at bottom center - if (barVao && barShaderId) { - // Bar dimensions in NDC: centered, near bottom - const float barWidth = 0.6f; // half-width in NDC (total 1.2 of 2.0 range = 60% of screen) - const float barHeight = 0.015f; - const float barY = -0.82f; // near bottom - - float left = -barWidth; - float right = -barWidth + 2.0f * barWidth * loadProgress; - float top = barY + barHeight; - float bottom = barY - barHeight; - - // Background (dark) - { - float bgVerts[] = { - -barWidth, top, - -barWidth, bottom, - barWidth, bottom, - -barWidth, top, - barWidth, bottom, - barWidth, top, - }; - glUseProgram(barShaderId); - GLint colorLoc = glGetUniformLocation(barShaderId, "uColor"); - glUniform4f(colorLoc, 0.1f, 0.1f, 0.1f, 0.8f); - - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - - glBindVertexArray(barVao); - glBindBuffer(GL_ARRAY_BUFFER, barVbo); - glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(bgVerts), bgVerts); - glDrawArrays(GL_TRIANGLES, 0, 6); - } - - // Filled portion (gold/amber like WoW) - if (loadProgress > 0.001f) { - float fillVerts[] = { - left, top, - left, bottom, - right, bottom, - left, top, - right, bottom, - right, top, - }; - GLint colorLoc = glGetUniformLocation(barShaderId, "uColor"); - glUniform4f(colorLoc, 0.78f, 0.61f, 0.13f, 1.0f); - - glBindBuffer(GL_ARRAY_BUFFER, barVbo); - glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(fillVerts), fillVerts); - glDrawArrays(GL_TRIANGLES, 0, 6); - } - - // Border (thin bright outline) - { - const float borderInset = 0.002f; - float borderLeft = -barWidth - borderInset; - float borderRight = barWidth + borderInset; - float borderTop = top + borderInset; - float borderBottom = bottom - borderInset; - - // Draw 4 thin border edges as line strip - glUseProgram(barShaderId); - GLint colorLoc = glGetUniformLocation(barShaderId, "uColor"); - glUniform4f(colorLoc, 0.55f, 0.43f, 0.1f, 1.0f); - - float borderVerts[] = { - borderLeft, borderTop, - borderRight, borderTop, - borderRight, borderBottom, - borderLeft, borderBottom, - borderLeft, borderTop, - }; - glBindBuffer(GL_ARRAY_BUFFER, barVbo); - glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(borderVerts), borderVerts); - glDrawArrays(GL_LINE_STRIP, 0, 5); - } - - glBindVertexArray(0); - glDisable(GL_BLEND); - } - - // Draw status text and percentage with ImGui overlay + // Progress bar (top of screen) { - // If a frame is already in progress (e.g. called from a UI callback), - // end it before starting our own - ImGuiContext* ctx = ImGui::GetCurrentContext(); - if (ctx && ctx->FrameCount >= 0 && ctx->WithinFrameScope) { - ImGui::EndFrame(); + const float barWidthFrac = 0.6f; + const float barHeight = 6.0f; + const float barY = screenH * 0.06f; + float barX = screenW * (0.5f - barWidthFrac * 0.5f); + float barW = screenW * barWidthFrac; + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + + // Background + drawList->AddRectFilled( + ImVec2(barX, barY), + ImVec2(barX + barW, barY + barHeight), + IM_COL32(25, 25, 25, 200), 2.0f); + + // Fill (gold) + if (loadProgress > 0.001f) { + drawList->AddRectFilled( + ImVec2(barX, barY), + ImVec2(barX + barW * loadProgress, barY + barHeight), + IM_COL32(199, 156, 33, 255), 2.0f); } - ImGuiIO& io = ImGui::GetIO(); - float screenW = io.DisplaySize.x; - float screenH = io.DisplaySize.y; + // Border + drawList->AddRect( + ImVec2(barX - 1, barY - 1), + ImVec2(barX + barW + 1, barY + barHeight + 1), + IM_COL32(140, 110, 25, 255), 2.0f); + } - ImGui_ImplOpenGL3_NewFrame(); - ImGui_ImplSDL2_NewFrame(); - ImGui::NewFrame(); - - // Invisible fullscreen window for text overlay - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); - ImGui::Begin("##LoadingOverlay", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoBackground | - ImGuiWindowFlags_NoBringToFrontOnFocus); - - // Percentage text centered above bar + // Percentage text above bar + { char pctBuf[32]; snprintf(pctBuf, sizeof(pctBuf), "%d%%", static_cast(loadProgress * 100.0f)); - - float barCenterY = screenH * (1.0f - ((-0.82f + 1.0f) / 2.0f)); // NDC -0.82 to screen Y - float textY = barCenterY - 30.0f; + float barCenterY = screenH * 0.06f; + float textY = barCenterY - 20.0f; ImVec2 pctSize = ImGui::CalcTextSize(pctBuf); ImGui::SetCursorPos(ImVec2((screenW - pctSize.x) * 0.5f, textY)); - ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 1.0f), "%s", pctBuf); - - // Status text centered below bar - float statusY = barCenterY + 16.0f; - ImVec2 statusSize = ImGui::CalcTextSize(statusText.c_str()); - ImGui::SetCursorPos(ImVec2((screenW - statusSize.x) * 0.5f, statusY)); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "%s", statusText.c_str()); - - ImGui::End(); - ImGui::Render(); - - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + ImGui::TextColored(ImVec4(0.0f, 0.0f, 0.0f, 1.0f), "%s", pctBuf); } - glEnable(GL_DEPTH_TEST); + // Status text below bar + { + float statusY = screenH * 0.06f + 14.0f; + ImVec2 statusSize = ImGui::CalcTextSize(statusText.c_str()); + ImGui::SetCursorPos(ImVec2((screenW - statusSize.x) * 0.5f, statusY)); + ImGui::TextColored(ImVec4(0.0f, 0.0f, 0.0f, 1.0f), "%s", statusText.c_str()); + } + + ImGui::End(); + ImGui::Render(); + + // Submit the frame to Vulkan (loading screen runs outside the main render loop) + if (vkCtx) { + uint32_t imageIndex = 0; + VkCommandBuffer cmd = vkCtx->beginFrame(imageIndex); + if (cmd != VK_NULL_HANDLE) { + // Begin render pass + VkRenderPassBeginInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpInfo.renderPass = vkCtx->getImGuiRenderPass(); + rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[imageIndex]; + rpInfo.renderArea.offset = {0, 0}; + rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + + // Render pass has 2 attachments (color + depth) or 3 with MSAA + VkClearValue clearValues[3]{}; + clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + clearValues[1].depthStencil = {1.0f, 0}; + clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + bool msaaOn = vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT; + rpInfo.clearValueCount = msaaOn ? 3 : 2; + rpInfo.pClearValues = clearValues; + + vkCmdBeginRenderPass(cmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), cmd); + vkCmdEndRenderPass(cmd); + + vkCtx->endFrame(cmd, imageIndex); + } + } } } // namespace rendering diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 7e206f11..2cbc1188 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1,6 +1,11 @@ #include "rendering/m2_renderer.hpp" -#include "rendering/texture.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" +#include "rendering/vk_frame_data.hpp" #include "rendering/camera.hpp" #include "rendering/frustum.hpp" #include "pipeline/asset_manager.hpp" @@ -35,6 +40,24 @@ bool envFlagEnabled(const char* key, bool defaultValue) { return !(v == "0" || v == "false" || v == "off" || v == "no"); } +size_t envSizeMBOrDefault(const char* name, size_t defMb) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defMb; + char* end = nullptr; + unsigned long long mb = std::strtoull(raw, &end, 10); + if (end == raw || mb == 0) return defMb; + return static_cast(mb); +} + +size_t envSizeOrDefault(const char* name, size_t defValue) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defValue; + char* end = nullptr; + unsigned long long v = std::strtoull(raw, &end, 10); + if (end == raw || v == 0) return defValue; + return static_cast(v); +} + static constexpr uint32_t kParticleFlagRandomized = 0x40; static constexpr uint32_t kParticleFlagTiled = 0x80; @@ -279,384 +302,306 @@ M2Renderer::~M2Renderer() { shutdown(); } -bool M2Renderer::initialize(pipeline::AssetManager* assets) { +bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* assets) { if (initialized_) { assetManager = assets; return true; } + vkCtx_ = ctx; assetManager = assets; - numAnimThreads_ = std::min(4u, std::max(1u, std::thread::hardware_concurrency() - 1)); - LOG_INFO("Initializing M2 renderer (", numAnimThreads_, " anim threads)..."); + const unsigned hc = std::thread::hardware_concurrency(); + const size_t availableCores = (hc > 1u) ? static_cast(hc - 1u) : 1ull; + // Keep headroom for other frame tasks: M2 gets about half of non-main cores by default. + const size_t defaultAnimThreads = std::max(1, availableCores / 2); + numAnimThreads_ = static_cast(std::max( + 1, envSizeOrDefault("WOWEE_M2_ANIM_THREADS", defaultAnimThreads))); + LOG_INFO("Initializing M2 renderer (Vulkan, ", numAnimThreads_, " anim threads)..."); - // Create M2 shader with skeletal animation support - const char* vertexSrc = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec3 aNormal; - layout (location = 2) in vec2 aTexCoord; - layout (location = 3) in vec4 aBoneWeights; - layout (location = 4) in vec4 aBoneIndicesF; - layout (location = 5) in vec2 aTexCoord2; + VkDevice device = vkCtx_->getDevice(); - uniform mat4 uModel; - uniform mat4 uView; - uniform mat4 uProjection; - uniform bool uUseBones; - uniform mat4 uBones[128]; - uniform vec2 uUVOffset; - uniform int uTexCoordSet; // 0 = UV set 0, 1 = UV set 1 - out vec3 FragPos; - out vec3 Normal; - out vec2 TexCoord; + // --- Descriptor set layouts --- - void main() { - vec3 pos = aPos; - vec3 norm = aNormal; + // Material set layout (set 1): binding 0 = sampler2D, binding 2 = M2Material UBO + // (M2Params moved to push constants alongside model matrix) + { + VkDescriptorSetLayoutBinding bindings[2] = {}; + bindings[0].binding = 0; + bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + bindings[0].descriptorCount = 1; + bindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + bindings[1].binding = 2; + bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + bindings[1].descriptorCount = 1; + bindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - if (uUseBones) { - ivec4 bi = ivec4(aBoneIndicesF); - mat4 boneTransform = uBones[bi.x] * aBoneWeights.x - + uBones[bi.y] * aBoneWeights.y - + uBones[bi.z] * aBoneWeights.z - + uBones[bi.w] * aBoneWeights.w; - pos = vec3(boneTransform * vec4(aPos, 1.0)); - norm = mat3(boneTransform) * aNormal; - } + VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + ci.bindingCount = 2; + ci.pBindings = bindings; + vkCreateDescriptorSetLayout(device, &ci, nullptr, &materialSetLayout_); + } - vec4 worldPos = uModel * vec4(pos, 1.0); - FragPos = worldPos.xyz; - Normal = mat3(uModel) * norm; - TexCoord = (uTexCoordSet == 1 ? aTexCoord2 : aTexCoord) + uUVOffset; + // Bone set layout (set 2): binding 0 = STORAGE_BUFFER (bone matrices) + { + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; - gl_Position = uProjection * uView * worldPos; - } - )"; + VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + ci.bindingCount = 1; + ci.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &ci, nullptr, &boneSetLayout_); + } - const char* fragmentSrc = R"( - #version 330 core - in vec3 FragPos; - in vec3 Normal; - in vec2 TexCoord; + // Particle texture set layout (set 1 for particles): binding 0 = sampler2D + { + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - uniform vec3 uLightDir; - uniform vec3 uLightColor; - uniform float uSpecularIntensity; - uniform vec3 uAmbientColor; - uniform vec3 uViewPos; - uniform sampler2D uTexture; - uniform bool uHasTexture; - uniform bool uAlphaTest; - uniform bool uColorKeyBlack; - uniform float uColorKeyThreshold; - uniform bool uUnlit; - uniform int uBlendMode; - uniform float uFadeAlpha; + VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + ci.bindingCount = 1; + ci.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &ci, nullptr, &particleTexLayout_); + } - uniform vec3 uFogColor; - uniform float uFogStart; - uniform float uFogEnd; + // --- Descriptor pools --- + { + VkDescriptorPoolSize sizes[] = { + {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_MATERIAL_SETS + 256}, + {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, MAX_MATERIAL_SETS + 256}, + }; + VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + ci.maxSets = MAX_MATERIAL_SETS + 256; + ci.poolSizeCount = 2; + ci.pPoolSizes = sizes; + ci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + vkCreateDescriptorPool(device, &ci, nullptr, &materialDescPool_); + } + { + VkDescriptorPoolSize sizes[] = { + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, MAX_BONE_SETS}, + }; + VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + ci.maxSets = MAX_BONE_SETS; + ci.poolSizeCount = 1; + ci.pPoolSizes = sizes; + ci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + vkCreateDescriptorPool(device, &ci, nullptr, &boneDescPool_); + } - uniform sampler2DShadow uShadowMap; - uniform mat4 uLightSpaceMatrix; - uniform bool uShadowEnabled; - uniform float uShadowStrength; - uniform bool uInteriorDarken; + // --- Pipeline layouts --- - out vec4 FragColor; + // Main M2 pipeline layout: set 0 = perFrame, set 1 = material, set 2 = bones + // Push constant: mat4 model + vec2 uvOffset + int texCoordSet + int useBones = 80 bytes + { + VkDescriptorSetLayout setLayouts[] = {perFrameLayout, materialSetLayout_, boneSetLayout_}; + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pushRange.offset = 0; + pushRange.size = 84; // mat4(64) + vec2(8) + int(4) + int(4) + int(4) - void main() { - vec4 texColor; - if (uHasTexture) { - texColor = texture(uTexture, TexCoord); - } else { - texColor = vec4(0.6, 0.5, 0.4, 1.0); // Fallback brownish - } + VkPipelineLayoutCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + ci.setLayoutCount = 3; + ci.pSetLayouts = setLayouts; + ci.pushConstantRangeCount = 1; + ci.pPushConstantRanges = &pushRange; + vkCreatePipelineLayout(device, &ci, nullptr, &pipelineLayout_); + } - // Alpha test / alpha-key cutout for card textures. - if (uAlphaTest && texColor.a < 0.5) { - discard; - } - float maxRgb = max(texColor.r, max(texColor.g, texColor.b)); - if (uAlphaTest && maxRgb < 0.06) { - discard; - } - if (uColorKeyBlack && maxRgb < uColorKeyThreshold) { - discard; - } - // Additive blend modes (3=Add, 6=BlendAdd): near-black fragments - // contribute nothing visually (add ~0 to framebuffer) but show as - // dark rectangles against sky/terrain. Discard them. - // Skip Mod(4)/Mod2x(5) since near-black is intentional for those. - if ((uBlendMode == 3 || uBlendMode == 6) && maxRgb < 0.1) { - discard; - } - // Unlit non-opaque batches (glow effects, emissive surfaces) with - // near-black pixels: these are glow textures where black = transparent. - if (uUnlit && uBlendMode >= 1 && maxRgb < 0.1) { - discard; - } + // Particle pipeline layout: set 0 = perFrame, set 1 = particleTex + // Push constant: vec2 tileCount + int alphaKey (12 bytes) + { + VkDescriptorSetLayout setLayouts[] = {perFrameLayout, particleTexLayout_}; + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = 12; // vec2 + int - // Distance fade - discard nearly invisible fragments - float finalAlpha = texColor.a * uFadeAlpha; - if (finalAlpha < 0.02) { - discard; - } + VkPipelineLayoutCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + ci.setLayoutCount = 2; + ci.pSetLayouts = setLayouts; + ci.pushConstantRangeCount = 1; + ci.pPushConstantRanges = &pushRange; + vkCreatePipelineLayout(device, &ci, nullptr, &particlePipelineLayout_); + } - // Unlit path: emit texture color directly (glow effects, emissive surfaces) - if (uUnlit) { - FragColor = vec4(texColor.rgb, finalAlpha); - return; - } + // Smoke pipeline layout: set 0 = perFrame + // Push constant: float screenHeight (4 bytes) + { + VkDescriptorSetLayout setLayouts[] = {perFrameLayout}; + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pushRange.offset = 0; + pushRange.size = 4; - vec3 normal = normalize(Normal); - vec3 lightDir = normalize(uLightDir); + VkPipelineLayoutCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + ci.setLayoutCount = 1; + ci.pSetLayouts = setLayouts; + ci.pushConstantRangeCount = 1; + ci.pPushConstantRanges = &pushRange; + vkCreatePipelineLayout(device, &ci, nullptr, &smokePipelineLayout_); + } - vec3 result; - if (uInteriorDarken) { - // Interior: dim ambient, minimal directional light - float diff = max(abs(dot(normal, lightDir)), 0.0) * 0.15; - result = texColor.rgb * (0.55 + diff); - } else { - // Two-sided lighting for foliage - float diff = max(abs(dot(normal, lightDir)), 0.3); + // --- Load shaders --- + rendering::VkShaderModule m2Vert, m2Frag; + rendering::VkShaderModule particleVert, particleFrag; + rendering::VkShaderModule smokeVert, smokeFrag; - // Blinn-Phong specular - vec3 viewDir = normalize(uViewPos - FragPos); - vec3 halfDir = normalize(lightDir + viewDir); - float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); - vec3 specular = spec * uLightColor * uSpecularIntensity; + m2Vert.loadFromFile(device, "assets/shaders/m2.vert.spv"); + m2Frag.loadFromFile(device, "assets/shaders/m2.frag.spv"); + particleVert.loadFromFile(device, "assets/shaders/m2_particle.vert.spv"); + particleFrag.loadFromFile(device, "assets/shaders/m2_particle.frag.spv"); + smokeVert.loadFromFile(device, "assets/shaders/m2_smoke.vert.spv"); + smokeFrag.loadFromFile(device, "assets/shaders/m2_smoke.frag.spv"); - // Shadow mapping - float shadow = 1.0; - if (uShadowEnabled) { - vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); - vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; - if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { - float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); - float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); - float bias = max(0.005 * (1.0 - abs(dot(normal, lightDir))), 0.001); - // Single hardware PCF tap β€” GL_LINEAR + compare mode gives 2Γ—2 bilinear PCF for free - shadow = texture(uShadowMap, vec3(proj.xy, proj.z - bias)); - shadow = mix(1.0, shadow, coverageFade); - } - } - shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); - - vec3 ambient = uAmbientColor * texColor.rgb; - vec3 diffuse = diff * texColor.rgb; - - result = ambient + (diffuse + specular) * shadow; - } - - // Fog - float fogDist = length(uViewPos - FragPos); - float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); - result = mix(uFogColor, result, fogFactor); - - FragColor = vec4(result, finalAlpha); - } - )"; - - shader = std::make_unique(); - if (!shader->loadFromSource(vertexSrc, fragmentSrc)) { - LOG_ERROR("Failed to create M2 shader"); + if (!m2Vert.isValid() || !m2Frag.isValid()) { + LOG_ERROR("M2: Missing required shaders, cannot initialize"); return false; } - // Create smoke particle shader - const char* smokeVertSrc = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in float aLifeRatio; - layout (location = 2) in float aSize; - layout (location = 3) in float aIsSpark; + VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); - uniform mat4 uView; - uniform mat4 uProjection; - uniform float uScreenHeight; + // --- Build M2 model pipelines --- + // Vertex input: 18 floats = 72 bytes stride + // loc 0: vec3 pos (0), loc 1: vec3 normal (12), loc 2: vec2 uv0 (24), + // loc 5: vec2 uv1 (32), loc 3: vec4 boneWeights (40), loc 4: vec4 boneIndices (56) + VkVertexInputBindingDescription m2Binding{}; + m2Binding.binding = 0; + m2Binding.stride = 18 * sizeof(float); + m2Binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - out float vLifeRatio; - out float vIsSpark; + std::vector m2Attrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // normal + {2, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float)}, // texCoord0 + {5, 0, VK_FORMAT_R32G32_SFLOAT, 8 * sizeof(float)}, // texCoord1 + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 10 * sizeof(float)}, // boneWeights + {4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 14 * sizeof(float)}, // boneIndices (float) + }; - void main() { - vec4 viewPos = uView * vec4(aPos, 1.0); - gl_Position = uProjection * viewPos; - float dist = -viewPos.z; - float scale = (aIsSpark > 0.5) ? 0.12 : 0.3; - gl_PointSize = clamp(aSize * (uScreenHeight * scale) / max(dist, 1.0), 2.0, 200.0); - vLifeRatio = aLifeRatio; - vIsSpark = aIsSpark; - } - )"; + auto buildM2Pipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline { + return PipelineBuilder() + .setShaders(m2Vert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + m2Frag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({m2Binding}, m2Attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blendState) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; - const char* smokeFragSrc = R"( - #version 330 core - in float vLifeRatio; - in float vIsSpark; - out vec4 FragColor; + opaquePipeline_ = buildM2Pipeline(PipelineBuilder::blendDisabled(), true); + alphaTestPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), true); + alphaPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), false); + additivePipeline_ = buildM2Pipeline(PipelineBuilder::blendAdditive(), false); - void main() { - vec2 coord = gl_PointCoord - vec2(0.5); - float dist = length(coord) * 2.0; + // --- Build particle pipelines --- + if (particleVert.isValid() && particleFrag.isValid()) { + VkVertexInputBindingDescription pBind{}; + pBind.binding = 0; + pBind.stride = 9 * sizeof(float); // pos3 + color4 + size1 + tile1 + pBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - if (vIsSpark > 0.5) { - // Ember/spark: bright hot dot, fades quickly - float circle = 1.0 - smoothstep(0.3, 0.8, dist); - float fade = 1.0 - smoothstep(0.0, 1.0, vLifeRatio); - float alpha = circle * fade; - vec3 color = mix(vec3(1.0, 0.6, 0.1), vec3(1.0, 0.2, 0.0), vLifeRatio); - FragColor = vec4(color, alpha); - } else { - // Smoke: soft gray circle - float circle = 1.0 - smoothstep(0.5, 1.0, dist); - float fadeIn = smoothstep(0.0, 0.1, vLifeRatio); - float fadeOut = 1.0 - smoothstep(0.4, 1.0, vLifeRatio); - float alpha = circle * fadeIn * fadeOut * 0.5; - vec3 color = mix(vec3(0.5, 0.5, 0.53), vec3(0.65, 0.65, 0.68), vLifeRatio); - FragColor = vec4(color, alpha); - } - } - )"; + std::vector pAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 3 * sizeof(float)}, // color + {2, 0, VK_FORMAT_R32_SFLOAT, 7 * sizeof(float)}, // size + {3, 0, VK_FORMAT_R32_SFLOAT, 8 * sizeof(float)}, // tile + }; - smokeShader = std::make_unique(); - if (!smokeShader->loadFromSource(smokeVertSrc, smokeFragSrc)) { - LOG_ERROR("Failed to create smoke particle shader (non-fatal)"); - smokeShader.reset(); + auto buildParticlePipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(particleVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + particleFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({pBind}, pAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(particlePipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + particlePipeline_ = buildParticlePipeline(PipelineBuilder::blendAlpha()); + particleAdditivePipeline_ = buildParticlePipeline(PipelineBuilder::blendAdditive()); } - // Create smoke particle VAO/VBO (only if shader compiled) - if (smokeShader) { - glGenVertexArrays(1, &smokeVAO); - glGenBuffers(1, &smokeVBO); - glBindVertexArray(smokeVAO); - glBindBuffer(GL_ARRAY_BUFFER, smokeVBO); - // 5 floats per particle: pos(3) + lifeRatio(1) + size(1) - // 6 floats per particle: pos(3) + lifeRatio(1) + size(1) + isSpark(1) - glBufferData(GL_ARRAY_BUFFER, MAX_SMOKE_PARTICLES * 6 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); - // Position - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); - // Life ratio - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); - // Size - glEnableVertexAttribArray(2); - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(4 * sizeof(float))); - // IsSpark - glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(5 * sizeof(float))); - glBindVertexArray(0); + // --- Build smoke pipeline --- + if (smokeVert.isValid() && smokeFrag.isValid()) { + VkVertexInputBindingDescription sBind{}; + sBind.binding = 0; + sBind.stride = 6 * sizeof(float); // pos3 + lifeRatio1 + size1 + isSpark1 + sBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector sAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32_SFLOAT, 3 * sizeof(float)}, // lifeRatio + {2, 0, VK_FORMAT_R32_SFLOAT, 4 * sizeof(float)}, // size + {3, 0, VK_FORMAT_R32_SFLOAT, 5 * sizeof(float)}, // isSpark + }; + + smokePipeline_ = PipelineBuilder() + .setShaders(smokeVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + smokeFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({sBind}, sAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(smokePipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); } - // Create M2 particle emitter shader + // Clean up shader modules + m2Vert.destroy(); m2Frag.destroy(); + particleVert.destroy(); particleFrag.destroy(); + smokeVert.destroy(); smokeFrag.destroy(); + + // --- Create dynamic particle buffers (mapped for CPU writes) --- { - const char* particleVertSrc = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec4 aColor; - layout (location = 2) in float aSize; - layout (location = 3) in float aTile; + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT; - uniform mat4 uView; - uniform mat4 uProjection; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; - out vec4 vColor; - out float vTile; + VmaAllocationInfo allocInfo{}; - void main() { - vec4 viewPos = uView * vec4(aPos, 1.0); - gl_Position = uProjection * viewPos; - float dist = max(-viewPos.z, 1.0); - gl_PointSize = clamp(aSize * 400.0 / dist, 1.0, 64.0); - vColor = aColor; - vTile = aTile; - } - )"; + // Smoke particle buffer + bci.size = MAX_SMOKE_PARTICLES * 6 * sizeof(float); + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &smokeVB_, &smokeVBAlloc_, &allocInfo); + smokeVBMapped_ = allocInfo.pMappedData; - const char* particleFragSrc = R"( - #version 330 core - in vec4 vColor; - in float vTile; - uniform sampler2D uTexture; - uniform vec2 uTileCount; - uniform bool uAlphaKey; - out vec4 FragColor; - - void main() { - // Circular soft-edge falloff (GL_POINTS are square by default) - vec2 center = gl_PointCoord - vec2(0.5); - float dist = length(center); - if (dist > 0.5) discard; - float edgeFade = smoothstep(0.5, 0.2, dist); - - vec2 tileCount = max(uTileCount, vec2(1.0)); - float tilesX = tileCount.x; - float tilesY = tileCount.y; - float tileMax = max(tilesX * tilesY - 1.0, 0.0); - float tile = clamp(vTile, 0.0, tileMax); - float col = mod(tile, tilesX); - float row = floor(tile / tilesX); - vec2 tileSize = vec2(1.0 / tilesX, 1.0 / tilesY); - vec2 uv = gl_PointCoord * tileSize + vec2(col, row) * tileSize; - vec4 texColor = texture(uTexture, uv); - - // Alpha-key particle textures often encode transparency as near-black - // color without meaningful alpha. - if (uAlphaKey) { - float maxRgb = max(texColor.r, max(texColor.g, texColor.b)); - if (maxRgb < 0.06 || texColor.a < 0.5) discard; - } - - FragColor = texColor * vColor; - FragColor.a *= edgeFade; - if (FragColor.a < 0.01) discard; - } - )"; - - GLuint vs = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vs, 1, &particleVertSrc, nullptr); - glCompileShader(vs); - - GLuint fs = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fs, 1, &particleFragSrc, nullptr); - glCompileShader(fs); - - m2ParticleShader_ = glCreateProgram(); - glAttachShader(m2ParticleShader_, vs); - glAttachShader(m2ParticleShader_, fs); - glLinkProgram(m2ParticleShader_); - glDeleteShader(vs); - glDeleteShader(fs); - - // Create particle VAO/VBO: 9 floats per particle (pos3 + rgba4 + size1 + tile1) - glGenVertexArrays(1, &m2ParticleVAO_); - glGenBuffers(1, &m2ParticleVBO_); - glBindVertexArray(m2ParticleVAO_); - glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_); - glBufferData(GL_ARRAY_BUFFER, MAX_M2_PARTICLES * 9 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); - // Position (3f) - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)0); - // Color (4f) - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)(3 * sizeof(float))); - // Size (1f) - glEnableVertexAttribArray(2); - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)(7 * sizeof(float))); - // Tile index (1f) - glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)(8 * sizeof(float))); - glBindVertexArray(0); + // M2 particle buffer + bci.size = MAX_M2_PARTICLES * 9 * sizeof(float); + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &m2ParticleVB_, &m2ParticleVBAlloc_, &allocInfo); + m2ParticleVBMapped_ = allocInfo.pMappedData; } - // Create white fallback texture - uint8_t white[] = {255, 255, 255, 255}; - glGenTextures(1, &whiteTexture); - glBindTexture(GL_TEXTURE_2D, whiteTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, white); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glBindTexture(GL_TEXTURE_2D, 0); + // --- Create white fallback texture --- + { + uint8_t white[] = {255, 255, 255, 255}; + whiteTexture_ = std::make_unique(); + whiteTexture_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM); + whiteTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_REPEAT); + } - // Generate soft radial gradient glow texture for light sprites + // --- Generate soft radial gradient glow texture --- { static constexpr int SZ = 64; std::vector px(SZ * SZ * 4); @@ -675,68 +620,151 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { px[idx + 3] = static_cast(a * 255); } } - glGenTextures(1, &glowTexture); - glBindTexture(GL_TEXTURE_2D, glowTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SZ, SZ, 0, GL_RGBA, GL_UNSIGNED_BYTE, px.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glBindTexture(GL_TEXTURE_2D, 0); + glowTexture_ = std::make_unique(); + glowTexture_->upload(*vkCtx_, px.data(), SZ, SZ, VK_FORMAT_R8G8B8A8_UNORM); + glowTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE); + // Pre-allocate glow texture descriptor set (reused every frame) + if (particleTexLayout_ && materialDescPool_) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &glowTexDescSet_) == VK_SUCCESS) { + VkDescriptorImageInfo imgInfo = glowTexture_->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = glowTexDescSet_; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } } + textureCacheBudgetBytes_ = + envSizeMBOrDefault("WOWEE_M2_TEX_CACHE_MB", 4096) * 1024ull * 1024ull; + modelCacheLimit_ = envSizeMBOrDefault("WOWEE_M2_MODEL_LIMIT", 6000); + LOG_INFO("M2 texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB"); + LOG_INFO("M2 model cache limit: ", modelCacheLimit_); - LOG_INFO("M2 renderer initialized"); + LOG_INFO("M2 renderer initialized (Vulkan)"); initialized_ = true; return true; } void M2Renderer::shutdown() { LOG_INFO("Shutting down M2 renderer..."); + if (!vkCtx_) return; - // Delete GPU resources + vkDeviceWaitIdle(vkCtx_->getDevice()); + VkDevice device = vkCtx_->getDevice(); + VmaAllocator alloc = vkCtx_->getAllocator(); + + // Delete model GPU resources for (auto& [id, model] : models) { - if (model.vao != 0) glDeleteVertexArrays(1, &model.vao); - if (model.vbo != 0) glDeleteBuffers(1, &model.vbo); - if (model.ebo != 0) glDeleteBuffers(1, &model.ebo); + destroyModelGPU(model); } models.clear(); + + // Destroy instance bone buffers + for (auto& inst : instances) { + destroyInstanceBones(inst); + } instances.clear(); spatialGrid.clear(); instanceIndexById.clear(); // Delete cached textures - for (auto& [path, entry] : textureCache) { - GLuint texId = entry.id; - if (texId != 0 && texId != whiteTexture) { - glDeleteTextures(1, &texId); - } - } textureCache.clear(); textureCacheBytes_ = 0; textureCacheCounter_ = 0; - textureHasAlphaById_.clear(); - textureColorKeyBlackById_.clear(); - if (whiteTexture != 0) { - glDeleteTextures(1, &whiteTexture); - whiteTexture = 0; - } - if (glowTexture != 0) { - glDeleteTextures(1, &glowTexture); - glowTexture = 0; - } + textureHasAlphaByPtr_.clear(); + textureColorKeyBlackByPtr_.clear(); + failedTextureCache_.clear(); + loggedTextureLoadFails_.clear(); + textureBudgetRejectWarnings_ = 0; + whiteTexture_.reset(); + glowTexture_.reset(); - shader.reset(); - - // Clean up smoke particle resources - if (smokeVAO != 0) { glDeleteVertexArrays(1, &smokeVAO); smokeVAO = 0; } - if (smokeVBO != 0) { glDeleteBuffers(1, &smokeVBO); smokeVBO = 0; } - smokeShader.reset(); + // Clean up particle buffers + if (smokeVB_) { vmaDestroyBuffer(alloc, smokeVB_, smokeVBAlloc_); smokeVB_ = VK_NULL_HANDLE; } + if (m2ParticleVB_) { vmaDestroyBuffer(alloc, m2ParticleVB_, m2ParticleVBAlloc_); m2ParticleVB_ = VK_NULL_HANDLE; } smokeParticles.clear(); - // Clean up M2 particle resources - if (m2ParticleVAO_ != 0) { glDeleteVertexArrays(1, &m2ParticleVAO_); m2ParticleVAO_ = 0; } - if (m2ParticleVBO_ != 0) { glDeleteBuffers(1, &m2ParticleVBO_); m2ParticleVBO_ = 0; } - if (m2ParticleShader_ != 0) { glDeleteProgram(m2ParticleShader_); m2ParticleShader_ = 0; } + // Destroy pipelines + auto destroyPipeline = [&](VkPipeline& p) { if (p) { vkDestroyPipeline(device, p, nullptr); p = VK_NULL_HANDLE; } }; + destroyPipeline(opaquePipeline_); + destroyPipeline(alphaTestPipeline_); + destroyPipeline(alphaPipeline_); + destroyPipeline(additivePipeline_); + destroyPipeline(particlePipeline_); + destroyPipeline(particleAdditivePipeline_); + destroyPipeline(smokePipeline_); + + if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } + if (particlePipelineLayout_) { vkDestroyPipelineLayout(device, particlePipelineLayout_, nullptr); particlePipelineLayout_ = VK_NULL_HANDLE; } + if (smokePipelineLayout_) { vkDestroyPipelineLayout(device, smokePipelineLayout_, nullptr); smokePipelineLayout_ = VK_NULL_HANDLE; } + + // Destroy descriptor pools and layouts + if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; } + if (boneDescPool_) { vkDestroyDescriptorPool(device, boneDescPool_, nullptr); boneDescPool_ = VK_NULL_HANDLE; } + if (materialSetLayout_) { vkDestroyDescriptorSetLayout(device, materialSetLayout_, nullptr); materialSetLayout_ = VK_NULL_HANDLE; } + if (boneSetLayout_) { vkDestroyDescriptorSetLayout(device, boneSetLayout_, nullptr); boneSetLayout_ = VK_NULL_HANDLE; } + if (particleTexLayout_) { vkDestroyDescriptorSetLayout(device, particleTexLayout_, nullptr); particleTexLayout_ = VK_NULL_HANDLE; } + + // Destroy shadow resources + destroyPipeline(shadowPipeline_); + if (shadowPipelineLayout_) { vkDestroyPipelineLayout(device, shadowPipelineLayout_, nullptr); shadowPipelineLayout_ = VK_NULL_HANDLE; } + if (shadowTexPool_) { vkDestroyDescriptorPool(device, shadowTexPool_, nullptr); shadowTexPool_ = VK_NULL_HANDLE; } + if (shadowParamsPool_) { vkDestroyDescriptorPool(device, shadowParamsPool_, nullptr); shadowParamsPool_ = VK_NULL_HANDLE; } + if (shadowParamsLayout_) { vkDestroyDescriptorSetLayout(device, shadowParamsLayout_, nullptr); shadowParamsLayout_ = VK_NULL_HANDLE; } + if (shadowParamsUBO_) { vmaDestroyBuffer(alloc, shadowParamsUBO_, shadowParamsAlloc_); shadowParamsUBO_ = VK_NULL_HANDLE; } + + initialized_ = false; +} + +void M2Renderer::destroyModelGPU(M2ModelGPU& model) { + if (!vkCtx_) return; + VmaAllocator alloc = vkCtx_->getAllocator(); + if (model.vertexBuffer) { vmaDestroyBuffer(alloc, model.vertexBuffer, model.vertexAlloc); model.vertexBuffer = VK_NULL_HANDLE; } + if (model.indexBuffer) { vmaDestroyBuffer(alloc, model.indexBuffer, model.indexAlloc); model.indexBuffer = VK_NULL_HANDLE; } + for (auto& batch : model.batches) { + if (batch.materialUBO) { vmaDestroyBuffer(alloc, batch.materialUBO, batch.materialUBOAlloc); batch.materialUBO = VK_NULL_HANDLE; } + // materialSet freed when pool is reset/destroyed + } +} + +void M2Renderer::destroyInstanceBones(M2Instance& inst) { + if (!vkCtx_) return; + VmaAllocator alloc = vkCtx_->getAllocator(); + for (int i = 0; i < 2; i++) { + if (inst.boneBuffer[i]) { + vmaDestroyBuffer(alloc, inst.boneBuffer[i], inst.boneAlloc[i]); + inst.boneBuffer[i] = VK_NULL_HANDLE; + inst.boneMapped[i] = nullptr; + } + // boneSet freed when pool is reset/destroyed + } +} + +VkDescriptorSet M2Renderer::allocateMaterialSet() { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &materialSetLayout_; + VkDescriptorSet set = VK_NULL_HANDLE; + vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set); + return set; +} + +VkDescriptorSet M2Renderer::allocateBoneSet() { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = boneDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &boneSetLayout_; + VkDescriptorSet set = VK_NULL_HANDLE; + vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set); + return set; } // --------------------------------------------------------------------------- @@ -848,6 +876,14 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Already loaded return true; } + if (models.size() >= modelCacheLimit_) { + if (modelLimitRejectWarnings_ < 3) { + LOG_WARNING("M2 model cache full (", models.size(), "/", modelCacheLimit_, + "), skipping model load: id=", modelId, " name=", model.name); + } + ++modelLimitRejectWarnings_; + return false; + } bool hasGeometry = !model.vertices.empty() && !model.indices.empty(); bool hasParticles = !model.particleEmitters.empty(); @@ -1034,10 +1070,6 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { gpuModel.indexCount = static_cast(model.indices.size()); gpuModel.vertexCount = static_cast(model.vertices.size()); - // Create VAO - glGenVertexArrays(1, &gpuModel.vao); - glBindVertexArray(gpuModel.vao); - // Store bone/sequence data for animation gpuModel.bones = model.bones; gpuModel.sequences = model.sequences; @@ -1049,8 +1081,30 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { break; } } - gpuModel.disableAnimation = foliageOrTreeLike || chestName; - gpuModel.shadowWindFoliage = foliageOrTreeLike; + bool ambientCreature = + (lowerName.find("firefly") != std::string::npos) || + (lowerName.find("fireflies") != std::string::npos) || + (lowerName.find("fireflys") != std::string::npos) || + (lowerName.find("dragonfly") != std::string::npos) || + (lowerName.find("dragonflies") != std::string::npos) || + (lowerName.find("butterfly") != std::string::npos) || + (lowerName.find("moth") != std::string::npos); + gpuModel.disableAnimation = (foliageOrTreeLike && !ambientCreature) || chestName; + gpuModel.shadowWindFoliage = foliageOrTreeLike && !ambientCreature; + gpuModel.isFoliageLike = foliageOrTreeLike && !ambientCreature; + gpuModel.isElvenLike = + (lowerName.find("elf") != std::string::npos) || + (lowerName.find("elven") != std::string::npos) || + (lowerName.find("quel") != std::string::npos); + gpuModel.isLanternLike = + (lowerName.find("lantern") != std::string::npos) || + (lowerName.find("lamp") != std::string::npos) || + (lowerName.find("light") != std::string::npos); + gpuModel.isKoboldFlame = + (lowerName.find("kobold") != std::string::npos) && + ((lowerName.find("candle") != std::string::npos) || + (lowerName.find("torch") != std::string::npos) || + (lowerName.find("mine") != std::string::npos)); gpuModel.isGroundDetail = groundDetailModel; if (groundDetailModel) { // Ground clutter (grass/pebbles/detail cards) should never block camera/movement. @@ -1059,6 +1113,16 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2) gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3; + // Water vegetation: cattails, reeds, bulrushes, kelp, seaweed, lilypad near water + gpuModel.isWaterVegetation = + (lowerName.find("cattail") != std::string::npos) || + (lowerName.find("reed") != std::string::npos) || + (lowerName.find("bulrush") != std::string::npos) || + (lowerName.find("seaweed") != std::string::npos) || + (lowerName.find("kelp") != std::string::npos) || + (lowerName.find("lilypad") != std::string::npos); + // Ambient creature effects: particle-based glow (exempt from particle dampeners) + gpuModel.isFireflyEffect = ambientCreature; // Build collision mesh + spatial grid from M2 bounding geometry gpuModel.collision.vertices = model.collisionVertices; @@ -1116,37 +1180,29 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { vertexData.push_back(static_cast(std::min(v.boneIndices[3], uint8_t(127)))); } - glGenBuffers(1, &gpuModel.vbo); - glBindBuffer(GL_ARRAY_BUFFER, gpuModel.vbo); - glBufferData(GL_ARRAY_BUFFER, vertexData.size() * sizeof(float), - vertexData.data(), GL_STATIC_DRAW); + // Upload vertex buffer to GPU + { + auto buf = uploadBuffer(*vkCtx_, + vertexData.data(), vertexData.size() * sizeof(float), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + gpuModel.vertexBuffer = buf.buffer; + gpuModel.vertexAlloc = buf.allocation; + } - glGenBuffers(1, &gpuModel.ebo); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuModel.ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.indices.size() * sizeof(uint16_t), - model.indices.data(), GL_STATIC_DRAW); - - const size_t stride = floatsPerVertex * sizeof(float); - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0); - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(2); - glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float))); - glEnableVertexAttribArray(5); - glVertexAttribPointer(5, 2, GL_FLOAT, GL_FALSE, stride, (void*)(8 * sizeof(float))); - glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(10 * sizeof(float))); - glEnableVertexAttribArray(4); - glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(14 * sizeof(float))); + // Upload index buffer to GPU + { + auto buf = uploadBuffer(*vkCtx_, + model.indices.data(), model.indices.size() * sizeof(uint16_t), + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + gpuModel.indexBuffer = buf.buffer; + gpuModel.indexAlloc = buf.allocation; + } } - glBindVertexArray(0); - // Load ALL textures from the model into a local vector. // textureLoadFailed[i] is true if texture[i] had a named path that failed to load. // Such batches are hidden (batchOpacity=0) rather than rendered white. - std::vector allTextures; + std::vector allTextures; std::vector textureLoadFailed; std::vector textureKeysLower; if (assetManager) { @@ -1164,26 +1220,31 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { std::replace(texKey.begin(), texKey.end(), '/', '\\'); std::transform(texKey.begin(), texKey.end(), texKey.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - GLuint texId = loadTexture(texPath, tex.flags); - bool failed = (texId == whiteTexture); + VkTexture* texPtr = loadTexture(texPath, tex.flags); + bool failed = (texPtr == whiteTexture_.get()); if (failed) { - static std::unordered_set loggedModelTextureFails; - std::string failKey = model.name + "|" + texKey; - if (loggedModelTextureFails.insert(failKey).second) { + static uint32_t loggedModelTextureFails = 0; + static bool loggedModelTextureFailSuppressed = false; + if (loggedModelTextureFails < 250) { LOG_WARNING("M2 model ", model.name, " texture[", ti, "] failed to load: ", texPath); + ++loggedModelTextureFails; + } else if (!loggedModelTextureFailSuppressed) { + LOG_WARNING("M2 model texture-failure warnings suppressed after ", + loggedModelTextureFails, " entries"); + loggedModelTextureFailSuppressed = true; } } if (isInvisibleTrap) { LOG_INFO(" InvisibleTrap texture[", ti, "]: ", texPath, " -> ", (failed ? "WHITE" : "OK")); } - allTextures.push_back(texId); + allTextures.push_back(texPtr); textureLoadFailed.push_back(failed); textureKeysLower.push_back(std::move(texKey)); } else { if (isInvisibleTrap) { LOG_INFO(" InvisibleTrap texture[", ti, "]: EMPTY (using white fallback)"); } - allTextures.push_back(whiteTexture); + allTextures.push_back(whiteTexture_.get()); textureLoadFailed.push_back(false); // Empty filename = intentional white (type!=0) textureKeysLower.emplace_back(); } @@ -1210,10 +1271,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Copy particle emitter data and resolve textures gpuModel.particleEmitters = model.particleEmitters; - gpuModel.particleTextures.resize(model.particleEmitters.size(), whiteTexture); + gpuModel.particleTextures.resize(model.particleEmitters.size(), whiteTexture_.get()); for (size_t ei = 0; ei < model.particleEmitters.size(); ei++) { uint16_t texIdx = model.particleEmitters[ei].texture; - if (texIdx < allTextures.size() && allTextures[texIdx] != 0) { + if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) { gpuModel.particleTextures[ei] = allTextures[texIdx]; } } @@ -1246,7 +1307,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { bgpu.submeshLevel = batch.submeshLevel; // Resolve texture: batch.textureIndex β†’ textureLookup β†’ allTextures - GLuint tex = whiteTexture; + VkTexture* tex = whiteTexture_.get(); bool texFailed = false; std::string batchTexKeyLower; if (batch.textureIndex < model.textureLookup.size()) { @@ -1271,8 +1332,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { if (texFailed && groundDetailModel) { static const std::string kDetailFallbackTexture = "World\\NoDXT\\Detail\\8des_detaildoodads01.blp"; - GLuint fallbackTex = loadTexture(kDetailFallbackTexture, 0); - if (fallbackTex != 0 && fallbackTex != whiteTexture) { + VkTexture* fallbackTex = loadTexture(kDetailFallbackTexture, 0); + if (fallbackTex != nullptr && fallbackTex != whiteTexture_.get()) { tex = fallbackTex; texFailed = false; } @@ -1333,15 +1394,15 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (batchTexKeyLower.find("ruby") != std::string::npos); bgpu.glowTint = texCoolTint ? 1 : (texRedTint ? 2 : 0); bool texHasAlpha = false; - if (tex != 0 && tex != whiteTexture) { - auto ait = textureHasAlphaById_.find(tex); - texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false; + if (tex != nullptr && tex != whiteTexture_.get()) { + auto ait = textureHasAlphaByPtr_.find(tex); + texHasAlpha = (ait != textureHasAlphaByPtr_.end()) ? ait->second : false; } bgpu.hasAlpha = texHasAlpha; bool colorKeyBlack = false; - if (tex != 0 && tex != whiteTexture) { - auto cit = textureColorKeyBlackById_.find(tex); - colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false; + if (tex != nullptr && tex != whiteTexture_.get()) { + auto cit = textureColorKeyBlackByPtr_.find(tex); + colorKeyBlack = (cit != textureColorKeyBlackByPtr_.end()) ? cit->second : false; } bgpu.colorKeyBlack = colorKeyBlack; // textureCoordIndex is an index into a texture coord combo table, not directly @@ -1407,17 +1468,17 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { M2ModelGPU::BatchGPU bgpu; bgpu.indexStart = 0; bgpu.indexCount = gpuModel.indexCount; - bgpu.texture = allTextures.empty() ? whiteTexture : allTextures[0]; + bgpu.texture = allTextures.empty() ? whiteTexture_.get() : allTextures[0]; bool texHasAlpha = false; - if (bgpu.texture != 0 && bgpu.texture != whiteTexture) { - auto ait = textureHasAlphaById_.find(bgpu.texture); - texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false; + if (bgpu.texture != nullptr && bgpu.texture != whiteTexture_.get()) { + auto ait = textureHasAlphaByPtr_.find(bgpu.texture); + texHasAlpha = (ait != textureHasAlphaByPtr_.end()) ? ait->second : false; } bgpu.hasAlpha = texHasAlpha; bool colorKeyBlack = false; - if (bgpu.texture != 0 && bgpu.texture != whiteTexture) { - auto cit = textureColorKeyBlackById_.find(bgpu.texture); - colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false; + if (bgpu.texture != nullptr && bgpu.texture != whiteTexture_.get()) { + auto cit = textureColorKeyBlackByPtr_.find(bgpu.texture); + colorKeyBlack = (cit != textureColorKeyBlackByPtr_.end()) ? cit->second : false; } bgpu.colorKeyBlack = colorKeyBlack; gpuModel.batches.push_back(bgpu); @@ -1438,6 +1499,66 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Allocate Vulkan descriptor sets and UBOs for each batch + for (auto& bgpu : gpuModel.batches) { + // Create combined UBO for M2Params (binding 1) + M2Material (binding 2) + // We allocate them as separate buffers for clarity + VmaAllocationInfo matAllocInfo{}; + { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = sizeof(M2MaterialUBO); + bci.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &bgpu.materialUBO, &bgpu.materialUBOAlloc, &matAllocInfo); + + // Write initial material data (static per-batch β€” fadeAlpha/interiorDarken updated at draw time) + M2MaterialUBO mat{}; + mat.hasTexture = (bgpu.texture != nullptr && bgpu.texture != whiteTexture_.get()) ? 1 : 0; + mat.alphaTest = (bgpu.blendMode == 1 || (bgpu.blendMode >= 2 && !bgpu.hasAlpha)) ? 1 : 0; + mat.colorKeyBlack = bgpu.colorKeyBlack ? 1 : 0; + mat.colorKeyThreshold = 0.08f; + mat.unlit = (bgpu.materialFlags & 0x01) ? 1 : 0; + mat.blendMode = bgpu.blendMode; + mat.fadeAlpha = 1.0f; + mat.interiorDarken = 0.0f; + mat.specularIntensity = 0.5f; + memcpy(matAllocInfo.pMappedData, &mat, sizeof(mat)); + bgpu.materialUBOMapped = matAllocInfo.pMappedData; + } + + // Allocate descriptor set and write all bindings + bgpu.materialSet = allocateMaterialSet(); + if (bgpu.materialSet) { + VkTexture* batchTex = bgpu.texture ? bgpu.texture : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = batchTex->descriptorInfo(); + + VkDescriptorBufferInfo matBufInfo{}; + matBufInfo.buffer = bgpu.materialUBO; + matBufInfo.offset = 0; + matBufInfo.range = sizeof(M2MaterialUBO); + + VkWriteDescriptorSet writes[2] = {}; + // binding 0: texture + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = bgpu.materialSet; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + // binding 2: M2Material UBO + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = bgpu.materialSet; + writes[1].dstBinding = 2; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &matBufInfo; + + vkUpdateDescriptorSets(vkCtx_->getDevice(), 2, writes, 0, nullptr); + } + } + models[modelId] = std::move(gpuModel); LOG_DEBUG("Loaded M2 model: ", model.name, " (", models[modelId].vertexCount, " vertices, ", @@ -1859,7 +1980,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: // Phase 2: Compute bone matrices (expensive, parallel if enough work) const size_t animCount = boneWorkIndices_.size(); if (animCount > 0) { - if (animCount < 6 || numAnimThreads_ <= 1) { + static const size_t minParallelAnimInstances = std::max( + 8, envSizeOrDefault("WOWEE_M2_ANIM_MT_MIN", 96)); + if (animCount < minParallelAnimInstances || numAnimThreads_ <= 1) { // Sequential β€” not enough work to justify thread overhead for (size_t i : boneWorkIndices_) { if (i >= instances.size()) continue; @@ -1870,35 +1993,49 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: } } else { // Parallel β€” dispatch across worker threads - const size_t numThreads = std::min(static_cast(numAnimThreads_), animCount); - const size_t chunkSize = animCount / numThreads; - const size_t remainder = animCount % numThreads; + static const size_t minAnimWorkPerThread = std::max( + 16, envSizeOrDefault("WOWEE_M2_ANIM_WORK_PER_THREAD", 64)); + const size_t maxUsefulThreads = std::max( + 1, (animCount + minAnimWorkPerThread - 1) / minAnimWorkPerThread); + const size_t numThreads = std::min(static_cast(numAnimThreads_), maxUsefulThreads); + if (numThreads <= 1) { + for (size_t i : boneWorkIndices_) { + if (i >= instances.size()) continue; + auto& inst = instances[i]; + auto mdlIt = models.find(inst.modelId); + if (mdlIt == models.end()) continue; + computeBoneMatrices(mdlIt->second, inst); + } + } else { + const size_t chunkSize = animCount / numThreads; + const size_t remainder = animCount % numThreads; - // Reuse persistent futures vector to avoid allocation - animFutures_.clear(); - if (animFutures_.capacity() < numThreads) { - animFutures_.reserve(numThreads); - } + // Reuse persistent futures vector to avoid allocation + animFutures_.clear(); + if (animFutures_.capacity() < numThreads) { + animFutures_.reserve(numThreads); + } - size_t start = 0; - for (size_t t = 0; t < numThreads; ++t) { - size_t end = start + chunkSize + (t < remainder ? 1 : 0); - animFutures_.push_back(std::async(std::launch::async, - [this, start, end]() { - for (size_t j = start; j < end; ++j) { - size_t idx = boneWorkIndices_[j]; - if (idx >= instances.size()) continue; - auto& inst = instances[idx]; - auto mdlIt = models.find(inst.modelId); - if (mdlIt == models.end()) continue; - computeBoneMatrices(mdlIt->second, inst); - } - })); - start = end; - } + size_t start = 0; + for (size_t t = 0; t < numThreads; ++t) { + size_t end = start + chunkSize + (t < remainder ? 1 : 0); + animFutures_.push_back(std::async(std::launch::async, + [this, start, end]() { + for (size_t j = start; j < end; ++j) { + size_t idx = boneWorkIndices_[j]; + if (idx >= instances.size()) continue; + auto& inst = instances[idx]; + auto mdlIt = models.find(inst.modelId); + if (mdlIt == models.end()) continue; + computeBoneMatrices(mdlIt->second, inst); + } + })); + start = end; + } - for (auto& f : animFutures_) { - f.get(); + for (auto& f : animFutures_) { + f.get(); + } } } } @@ -1921,13 +2058,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: } } -void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection) { - if (instances.empty() || !shader) { +void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (instances.empty() || !opaquePipeline_) { return; } - auto renderStartTime = std::chrono::high_resolution_clock::now(); - // Debug: log once when we start rendering static bool loggedOnce = false; if (!loggedOnce) { @@ -1935,41 +2070,15 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: LOG_INFO("M2 render: ", instances.size(), " instances, ", models.size(), " models"); } - // Set up GL state for M2 rendering - glEnable(GL_DEPTH_TEST); - glDepthFunc(GL_LEQUAL); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDisable(GL_CULL_FACE); // Some M2 geometry is single-sided - // Build frustum for culling + const glm::mat4 view = camera.getViewMatrix(); + const glm::mat4 projection = camera.getProjectionMatrix(); Frustum frustum; frustum.extractFromMatrix(projection * view); // Reuse persistent buffers (clear instead of reallocating) glowSprites_.clear(); - shader->use(); - shader->setUniform("uView", view); - shader->setUniform("uProjection", projection); - shader->setUniform("uLightDir", lightDir); - shader->setUniform("uLightColor", lightColor); - shader->setUniform("uSpecularIntensity", 0.5f); - shader->setUniform("uAmbientColor", ambientColor); - shader->setUniform("uViewPos", camera.getPosition()); - shader->setUniform("uFogColor", fogColor); - shader->setUniform("uFogStart", fogStart); - shader->setUniform("uFogEnd", fogEnd); - bool useShadows = shadowEnabled; - shader->setUniform("uShadowEnabled", useShadows ? 1 : 0); - shader->setUniform("uShadowStrength", 0.68f); - if (useShadows) { - shader->setUniform("uLightSpaceMatrix", lightSpaceMatrix); - glActiveTexture(GL_TEXTURE7); - glBindTexture(GL_TEXTURE_2D, shadowDepthTex); - shader->setUniform("uShadowMap", 7); - } - lastDrawCallCount = 0; // Adaptive render distance: balanced for performance without excessive pop-in @@ -2013,7 +2122,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: } if (model.isGroundDetail) { // Keep clutter local so distant grass doesn't overdraw the scene. - effectiveMaxDistSq *= 0.45f; + effectiveMaxDistSq *= 0.75f; } // Removed aggressive small-object distance caps to prevent city pop-out // Small props (barrels, lanterns, etc.) now use same distance as larger objects @@ -2027,65 +2136,48 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: sortedVisible_.push_back({i, instance.modelId, distSq, effectiveMaxDistSq}); } - // Sort by modelId to minimize VAO rebinds (using stable_sort for better cache behavior) + // Sort by modelId to minimize vertex/index buffer rebinds std::stable_sort(sortedVisible_.begin(), sortedVisible_.end(), [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); - auto cullingSortTime = std::chrono::high_resolution_clock::now(); - double cullingSortMs = std::chrono::duration(cullingSortTime - renderStartTime).count(); - uint32_t currentModelId = UINT32_MAX; const M2ModelGPU* currentModel = nullptr; - // State tracking to avoid redundant GL calls (similar to WMO renderer optimization) - static GLuint lastBoundTexture = 0; - static bool lastHasTexture = false; - static bool lastAlphaTest = false; - static bool lastColorKeyBlack = false; - static bool lastUnlit = false; - static bool lastUseBones = false; - static bool lastInteriorDarken = false; - static uint8_t lastBlendMode = 255; // Invalid initial value - static bool depthMaskState = true; // Track current depth mask state - static glm::vec2 lastUVOffset = glm::vec2(-999.0f); // Track UV offset state - static int lastTexCoordSet = -1; // Track active UV set (0 or 1) + // State tracking + VkPipeline currentPipeline = VK_NULL_HANDLE; + uint32_t frameIndex = vkCtx_->getCurrentFrame(); - // Reset state tracking at start of frame to handle shader rebinds - lastBoundTexture = 0; - lastHasTexture = false; - lastAlphaTest = false; - lastColorKeyBlack = false; - lastUnlit = false; - lastUseBones = false; - lastInteriorDarken = false; - lastBlendMode = 255; - depthMaskState = true; - lastUVOffset = glm::vec2(-999.0f); - lastTexCoordSet = -1; + // Push constants struct matching m2.vert.glsl push_constant block + struct M2PushConstants { + glm::mat4 model; + glm::vec2 uvOffset; + int texCoordSet; + int useBones; + int isFoliage; + }; - // Set texture unit once per frame instead of per-batch - glActiveTexture(GL_TEXTURE0); - shader->setUniform("uTexture", 0); // Texture unit 0, set once per frame - shader->setUniform("uColorKeyBlack", false); - shader->setUniform("uColorKeyThreshold", 0.08f); - shader->setUniform("uBlendMode", 0); + // Bind per-frame descriptor set (set 0) β€” shared across all draws + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - // Performance counters - uint32_t boneMatrixUploads = 0; - uint32_t totalBatchesDrawn = 0; + // Start with opaque pipeline + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, opaquePipeline_); + currentPipeline = opaquePipeline_; for (const auto& entry : sortedVisible_) { if (entry.index >= instances.size()) continue; - const auto& instance = instances[entry.index]; + auto& instance = instances[entry.index]; - // Bind VAO once per model group + // Bind vertex + index buffers once per model group if (entry.modelId != currentModelId) { - if (currentModel) glBindVertexArray(0); currentModelId = entry.modelId; auto mdlIt = models.find(currentModelId); if (mdlIt == models.end()) continue; currentModel = &mdlIt->second; - glBindVertexArray(currentModel->vao); + if (!currentModel->vertexBuffer) continue; + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); } const M2ModelGPU& model = *currentModel; @@ -2099,97 +2191,87 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); } - // Always update per-instance uniforms (these change every instance) float instanceFadeAlpha = fadeAlpha; if (model.isGroundDetail) { instanceFadeAlpha *= 0.82f; } - shader->setUniform("uModel", instance.modelMatrix); - shader->setUniform("uFadeAlpha", instanceFadeAlpha); - // Track interior darken state to avoid redundant updates - if (insideInterior != lastInteriorDarken) { - shader->setUniform("uInteriorDarken", insideInterior); - lastInteriorDarken = insideInterior; - } - - // Upload bone matrices if model has skeletal animation + // Upload bone matrices to SSBO if model has skeletal animation bool useBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); - if (useBones != lastUseBones) { - shader->setUniform("uUseBones", useBones); - lastUseBones = useBones; - } if (useBones) { - int numBones = std::min(static_cast(instance.boneMatrices.size()), 128); - shader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones); - boneMatrixUploads++; - } + // Lazy-allocate bone SSBO on first use + if (!instance.boneBuffer[frameIndex]) { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = 128 * sizeof(glm::mat4); // max 128 bones + bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, + &instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo); + instance.boneMapped[frameIndex] = allocInfo.pMappedData; - // Disable depth writes for fading objects to avoid z-fighting - if (instanceFadeAlpha < 1.0f) { - if (depthMaskState) { - glDepthMask(GL_FALSE); - depthMaskState = false; + // Allocate descriptor set for bone SSBO + instance.boneSet[frameIndex] = allocateBoneSet(); + if (instance.boneSet[frameIndex]) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = instance.boneBuffer[frameIndex]; + bufInfo.offset = 0; + bufInfo.range = bci.size; + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = instance.boneSet[frameIndex]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + + // Upload bone matrices + if (instance.boneMapped[frameIndex]) { + int numBones = std::min(static_cast(instance.boneMatrices.size()), 128); + memcpy(instance.boneMapped[frameIndex], instance.boneMatrices.data(), + numBones * sizeof(glm::mat4)); + } + + // Bind bone descriptor set (set 2) + if (instance.boneSet[frameIndex]) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 2, 1, &instance.boneSet[frameIndex], 0, nullptr); } } - // LOD selection based on distance (WoW retail behavior) - // submeshLevel: 0=base detail, 1=LOD1, 2=LOD2, 3=LOD3 + // LOD selection based on distance float dist = std::sqrt(entry.distSq); uint16_t desiredLOD = 0; - if (dist > 150.0f) desiredLOD = 3; // Far: LOD3 (lowest detail) - else if (dist > 80.0f) desiredLOD = 2; // Medium-far: LOD2 - else if (dist > 40.0f) desiredLOD = 1; // Medium: LOD1 - // else desiredLOD = 0 (close: base detail) + if (dist > 150.0f) desiredLOD = 3; + else if (dist > 80.0f) desiredLOD = 2; + else if (dist > 40.0f) desiredLOD = 1; - // Check if model has the desired LOD level; if not, fall back to LOD 0 uint16_t targetLOD = desiredLOD; if (desiredLOD > 0) { bool hasDesiredLOD = false; for (const auto& b : model.batches) { - if (b.submeshLevel == desiredLOD) { - hasDesiredLOD = true; - break; - } - } - if (!hasDesiredLOD) { - targetLOD = 0; // Fall back to base LOD + if (b.submeshLevel == desiredLOD) { hasDesiredLOD = true; break; } } + if (!hasDesiredLOD) targetLOD = 0; } - std::string modelKeyLower = model.name; - std::transform(modelKeyLower.begin(), modelKeyLower.end(), modelKeyLower.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); + const bool foliageLikeModel = model.isFoliageLike; for (const auto& batch : model.batches) { if (batch.indexCount == 0) continue; - - // Skip batches that don't match target LOD level if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; - - // Skip batches with zero opacity from texture weight tracks (should be invisible) if (batch.batchOpacity < 0.01f) continue; - const bool koboldFlameCard = - batch.colorKeyBlack && - (modelKeyLower.find("kobold") != std::string::npos) && - ((modelKeyLower.find("candle") != std::string::npos) || - (modelKeyLower.find("torch") != std::string::npos) || - (modelKeyLower.find("mine") != std::string::npos)); - - // Replace only likely flame-card submeshes with sprite glow. Keep larger geometry - // (lantern housings, posts, etc.) authored so the prop itself remains visible. + const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; const bool smallCardLikeBatch = (batch.glowSize <= 1.35f) || (batch.lanternGlowHint && batch.glowSize <= 6.0f); const bool batchUnlit = (batch.materialFlags & 0x01) != 0; - const bool elvenLikeModel = - (modelKeyLower.find("elf") != std::string::npos) || - (modelKeyLower.find("elven") != std::string::npos) || - (modelKeyLower.find("quel") != std::string::npos); - const bool lanternLikeModel = - (modelKeyLower.find("lantern") != std::string::npos) || - (modelKeyLower.find("lamp") != std::string::npos) || - (modelKeyLower.find("light") != std::string::npos); + const bool elvenLikeModel = model.isElvenLike; + const bool lanternLikeModel = model.isLanternLike; const bool shouldUseGlowSprite = !koboldFlameCard && (elvenLikeModel || (lanternLikeModel && batch.lanternGlowHint)) && @@ -2212,7 +2294,6 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: } gs.size = batch.glowSize * instance.scale * 1.45f; glowSprites_.push_back(gs); - // Add wider, softer halo to avoid hard "disk" look. GlowSprite halo = gs; halo.color.a *= 0.42f; halo.size *= 1.8f; @@ -2222,15 +2303,13 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: (batch.blendMode >= 3) || batch.colorKeyBlack || ((batch.materialFlags & 0x01) != 0); - // Keep lantern/light model geometry visible; sprite glow should augment, - // not replace, those props. if ((batch.glowCardLike && lanternLikeModel) || (cardLikeSkipMesh && !lanternLikeModel)) { continue; } } - // Compute UV offset for texture animation (only set uniform if changed) + // Compute UV offset for texture animation glm::vec2 uvOffset(0.0f, 0.0f); if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) { uint16_t lookupIdx = batch.textureAnimIndex; @@ -2245,177 +2324,97 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: } } } - // Only update uniform if UV offset changed (most batches have 0,0) - if (uvOffset != lastUVOffset) { - shader->setUniform("uUVOffset", uvOffset); - lastUVOffset = uvOffset; - } - // Apply per-batch blend mode from M2 material (only if changed) - // 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, 4=Mod, 5=Mod2x, 6=BlendAdd, 7=Screen - bool batchTransparent = false; - // Spell effects: override Mod/Mod2x to Additive for bright glow rendering + // Foliage/card-like batches render more stably as cutout (depth-write on) + // instead of alpha-blended sorting. + const bool foliageCutout = + foliageLikeModel && + !model.isSpellEffect && + batch.blendMode <= 3; + const bool forceCutout = + !model.isSpellEffect && + (model.isGroundDetail || + foliageCutout || + batch.blendMode == 1 || + (batch.blendMode >= 2 && !batch.hasAlpha) || + batch.colorKeyBlack); + + // Select pipeline based on blend mode uint8_t effectiveBlendMode = batch.blendMode; if (model.isSpellEffect && (effectiveBlendMode == 4 || effectiveBlendMode == 5)) { - effectiveBlendMode = 3; // Additive + effectiveBlendMode = 3; } - if (model.isGroundDetail) { - // Use regular alpha blending for detail cards to avoid hard cutout loss. - effectiveBlendMode = 2; + if (forceCutout) { + effectiveBlendMode = 1; } - if (effectiveBlendMode != lastBlendMode) { - switch (effectiveBlendMode) { - case 0: // Opaque - glBlendFunc(GL_ONE, GL_ZERO); - break; - case 1: // Alpha Key (alpha test, handled by uAlphaTest) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - break; - case 2: // Alpha - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - batchTransparent = true; - break; - case 3: // Additive - glBlendFunc(GL_SRC_ALPHA, GL_ONE); - batchTransparent = true; - break; - case 4: // Mod - glBlendFunc(GL_DST_COLOR, GL_ZERO); - batchTransparent = true; - break; - case 5: // Mod2x - glBlendFunc(GL_DST_COLOR, GL_SRC_COLOR); - batchTransparent = true; - break; - case 6: // BlendAdd - glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - batchTransparent = true; - break; - default: // Fallback - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - break; - } - lastBlendMode = effectiveBlendMode; - shader->setUniform("uBlendMode", static_cast(effectiveBlendMode)); + + VkPipeline desiredPipeline; + if (forceCutout) { + // Use opaque pipeline + shader discard for stable foliage cards. + desiredPipeline = opaquePipeline_; } else { - // Still need to know if batch is transparent for depth mask logic - batchTransparent = (effectiveBlendMode >= 2); + switch (effectiveBlendMode) { + case 0: desiredPipeline = opaquePipeline_; break; + case 1: desiredPipeline = alphaTestPipeline_; break; + case 2: desiredPipeline = alphaPipeline_; break; + default: desiredPipeline = additivePipeline_; break; + } + } + if (desiredPipeline != currentPipeline) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); + currentPipeline = desiredPipeline; } - // Disable depth writes for transparent/additive batches - if (batchTransparent && instanceFadeAlpha >= 1.0f) { - if (depthMaskState) { - glDepthMask(GL_FALSE); - depthMaskState = false; + // Update material UBO with per-draw dynamic values (fadeAlpha, interiorDarken) + if (batch.materialUBOMapped) { + auto* mat = static_cast(batch.materialUBOMapped); + mat->fadeAlpha = instanceFadeAlpha; + mat->interiorDarken = insideInterior ? 1.0f : 0.0f; + if (batch.colorKeyBlack) { + mat->colorKeyThreshold = (effectiveBlendMode == 4 || effectiveBlendMode == 5) ? 0.7f : 0.08f; + } + if (forceCutout) { + mat->alphaTest = model.isGroundDetail ? 3 : (foliageCutout ? 2 : 1); + if (model.isGroundDetail) { + mat->unlit = 0; + } } } - // Unlit: material flag 0x01 (only update if changed) - bool unlit = (batch.materialFlags & 0x01) != 0; - if (model.isGroundDetail) { - // Ground clutter should receive scene lighting so it doesn't glow. - unlit = false; - } - if (unlit != lastUnlit) { - shader->setUniform("uUnlit", unlit); - lastUnlit = unlit; + // Bind material descriptor set (set 1) + if (batch.materialSet) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); } - // Texture state (only update if changed) - bool hasTexture = (batch.texture != 0); - if (hasTexture != lastHasTexture) { - shader->setUniform("uHasTexture", hasTexture); - lastHasTexture = hasTexture; - } - - bool alphaTest = (effectiveBlendMode == 1) || - (effectiveBlendMode >= 2 && !batch.hasAlpha); - if (model.isGroundDetail) { - alphaTest = false; - } - if (alphaTest != lastAlphaTest) { - shader->setUniform("uAlphaTest", alphaTest); - lastAlphaTest = alphaTest; - } - bool colorKeyBlack = batch.colorKeyBlack; - if (colorKeyBlack != lastColorKeyBlack) { - shader->setUniform("uColorKeyBlack", colorKeyBlack); - lastColorKeyBlack = colorKeyBlack; - } - // ColorKeyBlack textures: discard dark pixels so background shows through. - // Mod blend (4) multiplies framebuffer by texture β€” dark pixels darken - // the scene, so use a high threshold to remove the dark rectangle. - if (colorKeyBlack) { - float thresh = 0.08f; - if (effectiveBlendMode == 4 || effectiveBlendMode == 5) { - thresh = 0.7f; // Mod/Mod2x: only keep near-white pixels - } - shader->setUniform("uColorKeyThreshold", thresh); - } - - // Only bind texture if it changed (texture unit already set to GL_TEXTURE0) - if (hasTexture && batch.texture != lastBoundTexture) { - glBindTexture(GL_TEXTURE_2D, batch.texture); - lastBoundTexture = batch.texture; - } - - // UV set selector (textureUnit: 0=UV0, 1=UV1) - int texCoordSet = static_cast(batch.textureUnit); - if (texCoordSet != lastTexCoordSet) { - shader->setUniform("uTexCoordSet", texCoordSet); - lastTexCoordSet = texCoordSet; - } - - glDrawElements(GL_TRIANGLES, batch.indexCount, GL_UNSIGNED_SHORT, - (void*)(batch.indexStart * sizeof(uint16_t))); - - totalBatchesDrawn++; - - // Restore depth writes after transparent batch - if (batchTransparent && fadeAlpha >= 1.0f) { - if (!depthMaskState) { - glDepthMask(GL_TRUE); - depthMaskState = true; - } - } - // Note: blend func restoration removed - state tracking handles it + // Push constants + M2PushConstants pc; + pc.model = instance.modelMatrix; + pc.uvOffset = uvOffset; + pc.texCoordSet = static_cast(batch.textureUnit); + pc.useBones = useBones ? 1 : 0; + pc.isFoliage = model.shadowWindFoliage ? 1 : 0; + vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); + vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); lastDrawCallCount++; } - - // Restore depth mask after faded instance - if (fadeAlpha < 1.0f) { - if (!depthMaskState) { - glDepthMask(GL_TRUE); - depthMaskState = true; - } - } } - if (currentModel) glBindVertexArray(0); - // Render glow sprites as billboarded additive point lights - if (!glowSprites_.empty() && m2ParticleShader_ != 0 && m2ParticleVAO_ != 0) { - glUseProgram(m2ParticleShader_); + if (!glowSprites_.empty() && particleAdditivePipeline_ && m2ParticleVB_ && glowTexDescSet_) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particleAdditivePipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 1, 1, &glowTexDescSet_, 0, nullptr); - GLint viewLoc = glGetUniformLocation(m2ParticleShader_, "uView"); - GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection"); - GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture"); - GLint tileLoc = glGetUniformLocation(m2ParticleShader_, "uTileCount"); - glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view)); - glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(projection)); - glUniform1i(texLoc, 0); - glUniform2f(tileLoc, 1.0f, 1.0f); + // Push constants for particle: tileCount(vec2) + alphaKey(int) + struct { float tileX, tileY; int alphaKey; } particlePush = {1.0f, 1.0f, 0}; + vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(particlePush), &particlePush); - glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blending - glDepthMask(GL_FALSE); - glEnable(GL_PROGRAM_POINT_SIZE); - glDisable(GL_CULL_FACE); - - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, glowTexture); - - // Build vertex data: pos(3) + color(4) + size(1) + tile(1) = 9 floats per sprite + // Build and upload vertex data std::vector glowData; glowData.reserve(glowSprites_.size() * 9); for (const auto& gs : glowSprites_) { @@ -2430,90 +2429,335 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: glowData.push_back(0.0f); } - glBindVertexArray(m2ParticleVAO_); - glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_); size_t uploadCount = std::min(glowSprites_.size(), MAX_M2_PARTICLES); - glBufferSubData(GL_ARRAY_BUFFER, 0, uploadCount * 9 * sizeof(float), glowData.data()); - glDrawArrays(GL_POINTS, 0, static_cast(uploadCount)); - glBindVertexArray(0); + memcpy(m2ParticleVBMapped_, glowData.data(), uploadCount * 9 * sizeof(float)); - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &m2ParticleVB_, &offset); + vkCmdDraw(cmd, static_cast(uploadCount), 1, 0, 0); } - // Restore state - glDisable(GL_BLEND); - glEnable(GL_CULL_FACE); - - auto renderEndTime = std::chrono::high_resolution_clock::now(); - double totalMs = std::chrono::duration(renderEndTime - renderStartTime).count(); - double drawLoopMs = std::chrono::duration(renderEndTime - cullingSortTime).count(); - - // Log detailed timing every 120 frames (~2 seconds at 60fps) - static int frameCounter = 0; - if (++frameCounter >= 120) { - frameCounter = 0; - LOG_DEBUG("M2 Render: ", totalMs, " ms (culling/sort: ", cullingSortMs, - " ms, draw: ", drawLoopMs, " ms) | ", sortedVisible_.size(), " visible | ", - totalBatchesDrawn, " batches | ", boneMatrixUploads, " bone uploads"); - } } -void M2Renderer::renderShadow(GLuint shadowShaderProgram, const glm::vec3& shadowCenter, float halfExtent) { - if (instances.empty() || shadowShaderProgram == 0) { - return; +bool M2Renderer::initializeShadow(VkRenderPass shadowRenderPass) { + if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false; + VkDevice device = vkCtx_->getDevice(); + + // ShadowParams UBO: useBones, useTexture, alphaTest, foliageSway, windTime, foliageMotionDamp + struct ShadowParamsUBO { + int32_t useBones = 0; + int32_t useTexture = 0; + int32_t alphaTest = 0; + int32_t foliageSway = 0; + float windTime = 0.0f; + float foliageMotionDamp = 1.0f; + }; + + // Create ShadowParams UBO + VkBufferCreateInfo bufCI{}; + bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufCI.size = sizeof(ShadowParamsUBO); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufCI, &allocCI, + &shadowParamsUBO_, &shadowParamsAlloc_, &allocInfo) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow params UBO"); + return false; + } + ShadowParamsUBO defaultParams{}; + std::memcpy(allocInfo.pMappedData, &defaultParams, sizeof(defaultParams)); + + // Create descriptor set layout: binding 0 = sampler2D, binding 1 = ShadowParams UBO + VkDescriptorSetLayoutBinding layoutBindings[2]{}; + layoutBindings[0].binding = 0; + layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + layoutBindings[0].descriptorCount = 1; + layoutBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + layoutBindings[1].binding = 1; + layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + layoutBindings[1].descriptorCount = 1; + layoutBindings[1].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layoutCI{}; + layoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutCI.bindingCount = 2; + layoutCI.pBindings = layoutBindings; + if (vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &shadowParamsLayout_) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow params layout"); + return false; } - GLint modelLoc = glGetUniformLocation(shadowShaderProgram, "uModel"); - GLint useTexLoc = glGetUniformLocation(shadowShaderProgram, "uUseTexture"); - GLint texLoc = glGetUniformLocation(shadowShaderProgram, "uTexture"); - GLint alphaTestLoc = glGetUniformLocation(shadowShaderProgram, "uAlphaTest"); - GLint foliageSwayLoc = glGetUniformLocation(shadowShaderProgram, "uFoliageSway"); - if (modelLoc < 0) { - return; + // Create descriptor pool + VkDescriptorPoolSize poolSizes[2]{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSizes[0].descriptorCount = 1; + poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[1].descriptorCount = 1; + VkDescriptorPoolCreateInfo poolCI{}; + poolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolCI.maxSets = 1; + poolCI.poolSizeCount = 2; + poolCI.pPoolSizes = poolSizes; + if (vkCreateDescriptorPool(device, &poolCI, nullptr, &shadowParamsPool_) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow params pool"); + return false; } - if (useTexLoc >= 0) glUniform1i(useTexLoc, 0); - if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, 0); - if (foliageSwayLoc >= 0) glUniform1i(foliageSwayLoc, 0); - if (texLoc >= 0) glUniform1i(texLoc, 0); - glActiveTexture(GL_TEXTURE0); + // Allocate descriptor set + VkDescriptorSetAllocateInfo setAlloc{}; + setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + setAlloc.descriptorPool = shadowParamsPool_; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &shadowParamsLayout_; + if (vkAllocateDescriptorSets(device, &setAlloc, &shadowParamsSet_) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to allocate shadow params set"); + return false; + } - for (const auto& instance : instances) { - // Cull instances whose AABB doesn't overlap the shadow frustum (XY plane) - glm::vec3 instCenter = (instance.worldBoundsMin + instance.worldBoundsMax) * 0.5f; - glm::vec3 instHalf = (instance.worldBoundsMax - instance.worldBoundsMin) * 0.5f; - if (std::abs(instCenter.x - shadowCenter.x) > halfExtent + instHalf.x) continue; - if (std::abs(instCenter.y - shadowCenter.y) > halfExtent + instHalf.y) continue; + // Write descriptors (use white fallback for binding 0) + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = shadowParamsUBO_; + bufInfo.offset = 0; + bufInfo.range = sizeof(ShadowParamsUBO); - auto it = models.find(instance.modelId); - if (it == models.end()) continue; + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = whiteTexture_->getImageView(); + imgInfo.sampler = whiteTexture_->getSampler(); - const M2ModelGPU& model = it->second; - if (!model.isValid() || model.isSmoke) continue; + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = shadowParamsSet_; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = shadowParamsSet_; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); - glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &instance.modelMatrix[0][0]); - glBindVertexArray(model.vao); - - for (const auto& batch : model.batches) { - if (batch.indexCount == 0) continue; - bool useTexture = (batch.texture != 0); - bool alphaCutout = batch.hasAlpha; - bool foliageSway = model.shadowWindFoliage && alphaCutout; - - if (useTexLoc >= 0) glUniform1i(useTexLoc, useTexture ? 1 : 0); - if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, alphaCutout ? 1 : 0); - if (foliageSwayLoc >= 0) glUniform1i(foliageSwayLoc, foliageSway ? 1 : 0); - if (useTexture) { - glBindTexture(GL_TEXTURE_2D, batch.texture); - } - glDrawElements(GL_TRIANGLES, batch.indexCount, GL_UNSIGNED_SHORT, - (void*)(batch.indexStart * sizeof(uint16_t))); + // Per-frame pool for foliage shadow texture sets (reset each frame) + { + VkDescriptorPoolSize texPoolSizes[2]{}; + texPoolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + texPoolSizes[0].descriptorCount = 256; + texPoolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + texPoolSizes[1].descriptorCount = 256; + VkDescriptorPoolCreateInfo texPoolCI{}; + texPoolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + texPoolCI.maxSets = 256; + texPoolCI.poolSizeCount = 2; + texPoolCI.pPoolSizes = texPoolSizes; + if (vkCreateDescriptorPool(device, &texPoolCI, nullptr, &shadowTexPool_) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow texture pool"); + return false; } } - glBindVertexArray(0); + // Create shadow pipeline layout: set 1 = shadowParamsLayout_, push constants = 128 bytes + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pc.offset = 0; + pc.size = 128; // lightSpaceMatrix (64) + model (64) + shadowPipelineLayout_ = createPipelineLayout(device, {shadowParamsLayout_}, {pc}); + if (!shadowPipelineLayout_) { + LOG_ERROR("M2Renderer: failed to create shadow pipeline layout"); + return false; + } + + // Load shadow shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/shadow.vert.spv")) { + LOG_ERROR("M2Renderer: failed to load shadow vertex shader"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/shadow.frag.spv")) { + LOG_ERROR("M2Renderer: failed to load shadow fragment shader"); + return false; + } + + // M2 vertex layout: 18 floats = 72 bytes stride + // loc0=pos(off0), loc1=normal(off12), loc2=texCoord0(off24), loc5=texCoord1(off32), + // loc3=boneWeights(off40), loc4=boneIndices(off56) + // Shadow shader locations: 0=aPos, 1=aTexCoord, 2=aBoneWeights, 3=aBoneIndicesF + // useBones=0 so locations 2,3 are never used + VkVertexInputBindingDescription vertBind{}; + vertBind.binding = 0; + vertBind.stride = 18 * sizeof(float); + vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + std::vector vertAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // aPos -> position + {1, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float)}, // aTexCoord -> texCoord0 + {2, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 10 * sizeof(float)}, // aBoneWeights + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 14 * sizeof(float)}, // aBoneIndicesF + }; + + shadowPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({vertBind}, vertAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + // Foliage/leaf cards are effectively two-sided; front-face culling can + // drop them from the shadow map depending on light/view orientation. + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setDepthBias(0.05f, 0.20f) + .setNoColorAttachment() + .setLayout(shadowPipelineLayout_) + .setRenderPass(shadowRenderPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!shadowPipeline_) { + LOG_ERROR("M2Renderer: failed to create shadow pipeline"); + return false; + } + LOG_INFO("M2Renderer shadow pipeline initialized"); + return true; +} + +void M2Renderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, float globalTime, + const glm::vec3& shadowCenter, float shadowRadius) { + if (!shadowPipeline_ || !shadowParamsSet_) return; + if (instances.empty() || models.empty()) return; + + struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; + struct ShadowParamsUBO { + int32_t useBones = 0; + int32_t useTexture = 0; + int32_t alphaTest = 0; + int32_t foliageSway = 0; + float windTime = 0.0f; + float foliageMotionDamp = 1.0f; + }; + const float shadowRadiusSq = shadowRadius * shadowRadius; + + // Reset per-frame texture descriptor pool for foliage alpha-test sets + if (shadowTexPool_) { + vkResetDescriptorPool(vkCtx_->getDevice(), shadowTexPool_, 0); + } + // Cache: texture imageView -> allocated descriptor set (avoids duplicates within frame) + std::unordered_map texSetCache; + + auto getTexDescSet = [&](VkTexture* tex) -> VkDescriptorSet { + VkImageView iv = tex->getImageView(); + auto cacheIt = texSetCache.find(iv); + if (cacheIt != texSetCache.end()) return cacheIt->second; + + VkDescriptorSet set = VK_NULL_HANDLE; + VkDescriptorSetAllocateInfo ai{}; + ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + ai.descriptorPool = shadowTexPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &shadowParamsLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set) != VK_SUCCESS) { + return shadowParamsSet_; // fallback to white texture + } + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = iv; + imgInfo.sampler = tex->getSampler(); + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = shadowParamsUBO_; + bufInfo.offset = 0; + bufInfo.range = sizeof(ShadowParamsUBO); + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = set; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = set; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 2, writes, 0, nullptr); + texSetCache[iv] = set; + return set; + }; + + // Helper lambda to draw instances with a given foliageSway setting + auto drawPass = [&](bool foliagePass) { + ShadowParamsUBO params{}; + params.foliageSway = foliagePass ? 1 : 0; + params.windTime = globalTime; + params.foliageMotionDamp = 1.0f; + // For foliage pass: enable texture+alphaTest in UBO (per-batch textures bound below) + if (foliagePass) { + params.useTexture = 1; + params.alphaTest = 1; + } + + VmaAllocationInfo allocInfo{}; + vmaGetAllocationInfo(vkCtx_->getAllocator(), shadowParamsAlloc_, &allocInfo); + std::memcpy(allocInfo.pMappedData, ¶ms, sizeof(params)); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &shadowParamsSet_, 0, nullptr); + + uint32_t currentModelId = UINT32_MAX; + const M2ModelGPU* currentModel = nullptr; + + for (const auto& instance : instances) { + // Distance cull against shadow frustum + glm::vec3 diff = instance.position - shadowCenter; + if (glm::dot(diff, diff) > shadowRadiusSq) continue; + + auto modelIt = models.find(instance.modelId); + if (modelIt == models.end()) continue; + const M2ModelGPU& model = modelIt->second; + if (!model.isValid() || model.isSmoke || model.isInvisibleTrap) continue; + + // Filter: only draw foliage models in foliage pass, non-foliage in non-foliage pass + if (model.shadowWindFoliage != foliagePass) continue; + + // Bind vertex/index buffers when model changes + if (instance.modelId != currentModelId) { + currentModelId = instance.modelId; + currentModel = &model; + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); + } + + ShadowPush push{lightSpaceMatrix, instance.modelMatrix}; + vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, + 0, 128, &push); + + for (const auto& batch : model.batches) { + if (batch.submeshLevel > 0) continue; + // For foliage: bind per-batch texture for alpha-tested shadows + if (foliagePass && batch.hasAlpha && batch.texture) { + VkDescriptorSet texSet = getTexDescSet(batch.texture); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &texSet, 0, nullptr); + } else if (foliagePass) { + // Non-alpha batch: rebind default set (white texture, alpha test passes) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &shadowParamsSet_, 0, nullptr); + } + vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); + } + } + }; + + // Pass 1: non-foliage (no wind displacement) + drawPass(false); + // Pass 2: foliage (wind displacement enabled, per-batch alpha-tested textures) + drawPass(true); } // --- M2 Particle Emitter Helpers --- @@ -2577,6 +2821,20 @@ glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeR return fb.vec3Values.back(); } +std::vector M2Renderer::getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const { + std::vector result; + float maxDistSq = maxDist * maxDist; + for (const auto& inst : instances) { + auto it = models.find(inst.modelId); + if (it == models.end() || !it->second.isWaterVegetation) continue; + glm::vec3 diff = inst.position - camPos; + if (glm::dot(diff, diff) <= maxDistSq) { + result.push_back(inst.position); + } + } + return result; +} + void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) { if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) { inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f); @@ -2641,11 +2899,20 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt // particles pile up at the same position. Give them a drift so they // spread outward like a mist/spray effect instead of clustering. if (std::abs(speed) < 0.01f) { - p.velocity = rotMat * glm::vec3( - distN(particleRng_) * 1.0f, - distN(particleRng_) * 1.0f, - -dist01(particleRng_) * 0.5f - ); + if (gpu.isFireflyEffect) { + // Fireflies: gentle random drift in all directions + p.velocity = rotMat * glm::vec3( + distN(particleRng_) * 0.6f, + distN(particleRng_) * 0.6f, + distN(particleRng_) * 0.3f + ); + } else { + p.velocity = rotMat * glm::vec3( + distN(particleRng_) * 1.0f, + distN(particleRng_) * 1.0f, + -dist01(particleRng_) * 0.5f + ); + } } const uint32_t tilesX = std::max(em.textureCols, 1); @@ -2691,7 +2958,8 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) { gpu.sequences, gpu.globalSequenceDurations); // When M2 gravity is 0, apply default gravity so particles arc downward. // Many fountain M2s rely on bone animation (.anim files) we don't load yet. - if (grav == 0.0f) { + // Firefly/ambient glow particles intentionally have zero gravity β€” skip fallback. + if (grav == 0.0f && !gpu.isFireflyEffect) { float emSpeed = interpFloat(pem.emissionSpeed, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); @@ -2708,12 +2976,12 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) { } } -void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) { - if (m2ParticleShader_ == 0 || m2ParticleVAO_ == 0) return; +void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!particlePipeline_ || !m2ParticleVB_) return; // Collect all particles from all instances, grouped by texture+blend struct ParticleGroupKey { - GLuint texture; + VkTexture* texture; uint8_t blendType; uint16_t tilesX; uint16_t tilesY; @@ -2727,14 +2995,14 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) }; struct ParticleGroupKeyHash { size_t operator()(const ParticleGroupKey& key) const { - size_t h1 = std::hash{}(key.texture); + size_t h1 = std::hash{}(reinterpret_cast(key.texture)); size_t h2 = std::hash{}((static_cast(key.tilesX) << 16) | key.tilesY); size_t h3 = std::hash{}(key.blendType); return h1 ^ (h2 * 0x9e3779b9u) ^ (h3 * 0x85ebca6bu); } }; struct ParticleGroup { - GLuint texture; + VkTexture* texture; uint8_t blendType; uint16_t tilesX; uint16_t tilesY; @@ -2759,24 +3027,14 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); - if (!gpu.isSpellEffect) { - // FBlock colors are tint values meant to multiply a bright texture. - // Desaturate toward white so particles look like water spray, not neon. + if (!gpu.isSpellEffect && !gpu.isFireflyEffect) { color = glm::mix(color, glm::vec3(1.0f), 0.7f); - - // Large-scale particles (>2.0) are volume/backdrop effects meant to be - // nearly invisible mist. Fade them heavily since we render as point sprites. - if (rawScale > 2.0f) { - alpha *= 0.02f; - } - // Reduce additive particle intensity to prevent blinding overlap - if (em.blendingType == 3 || em.blendingType == 4) { - alpha *= 0.05f; - } + if (rawScale > 2.0f) alpha *= 0.02f; + if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f; } - float scale = gpu.isSpellEffect ? rawScale : std::min(rawScale, 1.5f); + float scale = (gpu.isSpellEffect || gpu.isFireflyEffect) ? rawScale : std::min(rawScale, 1.5f); - GLuint tex = whiteTexture; + VkTexture* tex = whiteTexture_.get(); if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { tex = gpu.particleTextures[p.emitterIndex]; } @@ -2813,114 +3071,95 @@ void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) if (totalParticles == 0) return; - // Set up GL state - glEnable(GL_BLEND); - glEnable(GL_DEPTH_TEST); - glDepthMask(GL_FALSE); - glEnable(GL_PROGRAM_POINT_SIZE); - glDisable(GL_CULL_FACE); + // Bind per-frame set (set 0) for particle pipeline + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - glUseProgram(m2ParticleShader_); + VkDeviceSize vbOffset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &m2ParticleVB_, &vbOffset); - GLint viewLoc = glGetUniformLocation(m2ParticleShader_, "uView"); - GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection"); - GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture"); - GLint tileLoc = glGetUniformLocation(m2ParticleShader_, "uTileCount"); - GLint alphaKeyLoc = glGetUniformLocation(m2ParticleShader_, "uAlphaKey"); - glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view)); - glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(proj)); - glUniform1i(texLoc, 0); - glActiveTexture(GL_TEXTURE0); - - glBindVertexArray(m2ParticleVAO_); + VkPipeline currentPipeline = VK_NULL_HANDLE; for (auto& [key, group] : groups) { if (group.vertexData.empty()) continue; - // Use blend mode as specified by the emitter β€” don't override based on texture alpha. - // BlendType: 0=opaque, 1=alphaKey, 2=alpha, 3=add, 4=mod uint8_t blendType = group.blendType; - glUniform1i(alphaKeyLoc, (blendType == 1) ? 1 : 0); - if (blendType == 3 || blendType == 4) { - glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive - } else { - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // Alpha + VkPipeline desiredPipeline = (blendType == 3 || blendType == 4) + ? particleAdditivePipeline_ : particlePipeline_; + if (desiredPipeline != currentPipeline) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); + currentPipeline = desiredPipeline; } - glBindTexture(GL_TEXTURE_2D, group.texture); - glUniform2f(tileLoc, static_cast(group.tilesX), static_cast(group.tilesY)); + // Allocate descriptor set for this group's texture + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + VkDescriptorSet texSet = VK_NULL_HANDLE; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { + VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = texSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); - // Upload and draw in chunks of MAX_M2_PARTICLES + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 1, 1, &texSet, 0, nullptr); + } + + // Push constants: tileCount + alphaKey + struct { float tileX, tileY; int alphaKey; } pc = { + static_cast(group.tilesX), static_cast(group.tilesY), + (blendType == 1) ? 1 : 0 + }; + vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(pc), &pc); + + // Upload and draw in chunks size_t count = group.vertexData.size() / 9; size_t offset = 0; while (offset < count) { size_t batch = std::min(count - offset, MAX_M2_PARTICLES); - glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_); - glBufferSubData(GL_ARRAY_BUFFER, 0, batch * 9 * sizeof(float), - &group.vertexData[offset * 9]); - glDrawArrays(GL_POINTS, 0, static_cast(batch)); + memcpy(m2ParticleVBMapped_, &group.vertexData[offset * 9], batch * 9 * sizeof(float)); + vkCmdDraw(cmd, static_cast(batch), 1, 0, 0); offset += batch; } } - - glBindVertexArray(0); - - // Restore state - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDisable(GL_BLEND); - glEnable(GL_CULL_FACE); } -void M2Renderer::renderSmokeParticles(const Camera& /*camera*/, const glm::mat4& view, const glm::mat4& projection) { - if (smokeParticles.empty() || !smokeShader || smokeVAO == 0) return; +void M2Renderer::renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (smokeParticles.empty() || !smokePipeline_ || !smokeVB_) return; // Build vertex data: pos(3) + lifeRatio(1) + size(1) + isSpark(1) per particle - std::vector data; - data.reserve(smokeParticles.size() * 6); - for (const auto& p : smokeParticles) { - data.push_back(p.position.x); - data.push_back(p.position.y); - data.push_back(p.position.z); - data.push_back(p.life / p.maxLife); - data.push_back(p.size); - data.push_back(p.isSpark); + size_t count = std::min(smokeParticles.size(), static_cast(MAX_SMOKE_PARTICLES)); + float* dst = static_cast(smokeVBMapped_); + for (size_t i = 0; i < count; i++) { + const auto& p = smokeParticles[i]; + *dst++ = p.position.x; + *dst++ = p.position.y; + *dst++ = p.position.z; + *dst++ = p.life / p.maxLife; + *dst++ = p.size; + *dst++ = p.isSpark; } - // Upload to VBO - glBindBuffer(GL_ARRAY_BUFFER, smokeVBO); - glBufferSubData(GL_ARRAY_BUFFER, 0, data.size() * sizeof(float), data.data()); - glBindBuffer(GL_ARRAY_BUFFER, 0); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, smokePipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + smokePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - // Set GL state - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glEnable(GL_DEPTH_TEST); // Occlude behind buildings - glDepthMask(GL_FALSE); - glEnable(GL_PROGRAM_POINT_SIZE); - glDisable(GL_CULL_FACE); + // Push constant: screenHeight + float screenHeight = static_cast(vkCtx_->getSwapchainExtent().height); + vkCmdPushConstants(cmd, smokePipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, + sizeof(float), &screenHeight); - smokeShader->use(); - smokeShader->setUniform("uView", view); - smokeShader->setUniform("uProjection", projection); - - // Get viewport height for point size scaling - GLint viewport[4]; - glGetIntegerv(GL_VIEWPORT, viewport); - smokeShader->setUniform("uScreenHeight", static_cast(viewport[3])); - - glBindVertexArray(smokeVAO); - glDrawArrays(GL_POINTS, 0, static_cast(smokeParticles.size())); - glBindVertexArray(0); - - // Restore state - glEnable(GL_DEPTH_TEST); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); - glDisable(GL_BLEND); - glEnable(GL_CULL_FACE); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &smokeVB_, &offset); + vkCmdDraw(cmd, static_cast(count), 1, 0, 0); } void M2Renderer::setInstancePosition(uint32_t instanceId, const glm::vec3& position) { @@ -2938,6 +3177,16 @@ void M2Renderer::setInstancePosition(uint32_t instanceId, const glm::vec3& posit spatialIndexDirty_ = true; } +void M2Renderer::setInstanceAnimationFrozen(uint32_t instanceId, bool frozen) { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + auto& inst = instances[idxIt->second]; + inst.animSpeed = frozen ? 0.0f : 1.0f; + if (frozen) { + inst.animTime = 0.0f; // Reset to bind pose + } +} + void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { auto idxIt = instanceIndexById.find(instanceId); if (idxIt == instanceIndexById.end()) return; @@ -2963,6 +3212,7 @@ void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tran void M2Renderer::removeInstance(uint32_t instanceId) { for (auto it = instances.begin(); it != instances.end(); ++it) { if (it->id == instanceId) { + destroyInstanceBones(*it); instances.erase(it); rebuildSpatialIndex(); return; @@ -2977,6 +3227,11 @@ void M2Renderer::removeInstances(const std::vector& instanceIds) { std::unordered_set toRemove(instanceIds.begin(), instanceIds.end()); const size_t oldSize = instances.size(); + for (auto& inst : instances) { + if (toRemove.count(inst.id)) { + destroyInstanceBones(inst); + } + } instances.erase(std::remove_if(instances.begin(), instances.end(), [&toRemove](const M2Instance& inst) { return toRemove.find(inst.id) != toRemove.end(); @@ -2989,10 +3244,14 @@ void M2Renderer::removeInstances(const std::vector& instanceIds) { } void M2Renderer::clear() { - for (auto& [id, model] : models) { - if (model.vao != 0) glDeleteVertexArrays(1, &model.vao); - if (model.vbo != 0) glDeleteBuffers(1, &model.vbo); - if (model.ebo != 0) glDeleteBuffers(1, &model.ebo); + if (vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + for (auto& [id, model] : models) { + destroyModelGPU(model); + } + for (auto& inst : instances) { + destroyInstanceBones(inst); + } } models.clear(); instances.clear(); @@ -3100,9 +3359,7 @@ void M2Renderer::cleanupUnusedModels() { for (uint32_t id : toRemove) { auto it = models.find(id); if (it != models.end()) { - if (it->second.vao != 0) glDeleteVertexArrays(1, &it->second.vao); - if (it->second.vbo != 0) glDeleteBuffers(1, &it->second.vbo); - if (it->second.ebo != 0) glDeleteBuffers(1, &it->second.ebo); + destroyModelGPU(it->second); models.erase(it); } } @@ -3112,7 +3369,7 @@ void M2Renderer::cleanupUnusedModels() { } } -GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { +VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), @@ -3125,8 +3382,9 @@ GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { auto it = textureCache.find(key); if (it != textureCache.end()) { it->second.lastUse = ++textureCacheCounter_; - return it->second.id; + return it->second.texture.get(); } + // No negative cache check β€” allow retries for transiently missing textures auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; @@ -3147,13 +3405,30 @@ GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { // Load BLP texture pipeline::BLPImage blp = assetManager->loadTexture(key); if (!blp.isValid()) { - static std::unordered_set loggedTextureLoadFails; - if (loggedTextureLoadFails.insert(key).second) { + // Return white fallback but don't cache the failure β€” MPQ reads can + // fail transiently during streaming; allow retry on next model load. + if (loggedTextureLoadFails_.insert(key).second) { LOG_WARNING("M2: Failed to load texture: ", path); } - // Don't cache failures β€” transient StormLib thread contention can - // cause reads to fail; next loadModel call will retry. - return whiteTexture; + return whiteTexture_.get(); + } + + size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; + size_t approxBytes = base + (base / 3); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + static constexpr size_t kMaxFailedTextureCache = 200000; + if (failedTextureCache_.size() < kMaxFailedTextureCache) { + // Cache budget-rejected keys too; without this we repeatedly decode/load + // the same textures every frame once budget is saturated. + failedTextureCache_.insert(key); + } + if (textureBudgetRejectWarnings_ < 3) { + LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024), + " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), + " MB), rejecting texture: ", path); + } + ++textureBudgetRejectWarnings_; + return whiteTexture_.get(); } // Track whether the texture actually uses alpha (any pixel with alpha < 255). @@ -3165,38 +3440,30 @@ GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { } } - GLuint textureID; - glGenTextures(1, &textureID); - glBindTexture(GL_TEXTURE_2D, textureID); + // Create Vulkan texture + auto tex = std::make_unique(); + tex->upload(*vkCtx_, blp.data.data(), blp.width, blp.height, VK_FORMAT_R8G8B8A8_UNORM); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, - blp.width, blp.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, blp.data.data()); - - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // M2Texture flags: bit 0 = WrapS (1=repeat, 0=clamp), bit 1 = WrapT - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, (texFlags & 0x1) ? GL_REPEAT : GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, (texFlags & 0x2) ? GL_REPEAT : GL_CLAMP_TO_EDGE); - glGenerateMipmap(GL_TEXTURE_2D); - applyAnisotropicFiltering(); + VkSamplerAddressMode wrapS = (texFlags & 0x1) ? VK_SAMPLER_ADDRESS_MODE_REPEAT : VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + VkSamplerAddressMode wrapT = (texFlags & 0x2) ? VK_SAMPLER_ADDRESS_MODE_REPEAT : VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, wrapS, wrapT); - glBindTexture(GL_TEXTURE_2D, 0); + VkTexture* texPtr = tex.get(); TextureCacheEntry e; - e.id = textureID; - size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; - e.approxBytes = base + (base / 3); + e.texture = std::move(tex); + e.approxBytes = approxBytes; e.hasAlpha = hasAlpha; e.colorKeyBlack = colorKeyBlackHint; e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; - textureCache[key] = e; - textureHasAlphaById_[textureID] = hasAlpha; - textureColorKeyBlackById_[textureID] = colorKeyBlackHint; + textureCache[key] = std::move(e); + textureHasAlphaByPtr_[texPtr] = hasAlpha; + textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); - return textureID; + return texPtr; } uint32_t M2Renderer::getTotalTriangleCount() const { @@ -3661,5 +3928,143 @@ float M2Renderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& return closestHit; } +void M2Renderer::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + // Destroy old main-pass pipelines (NOT shadow, NOT pipeline layouts) + if (opaquePipeline_) { vkDestroyPipeline(device, opaquePipeline_, nullptr); opaquePipeline_ = VK_NULL_HANDLE; } + if (alphaTestPipeline_) { vkDestroyPipeline(device, alphaTestPipeline_, nullptr); alphaTestPipeline_ = VK_NULL_HANDLE; } + if (alphaPipeline_) { vkDestroyPipeline(device, alphaPipeline_, nullptr); alphaPipeline_ = VK_NULL_HANDLE; } + if (additivePipeline_) { vkDestroyPipeline(device, additivePipeline_, nullptr); additivePipeline_ = VK_NULL_HANDLE; } + if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; } + if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; } + if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; } + + // --- Load shaders --- + rendering::VkShaderModule m2Vert, m2Frag; + rendering::VkShaderModule particleVert, particleFrag; + rendering::VkShaderModule smokeVert, smokeFrag; + + m2Vert.loadFromFile(device, "assets/shaders/m2.vert.spv"); + m2Frag.loadFromFile(device, "assets/shaders/m2.frag.spv"); + particleVert.loadFromFile(device, "assets/shaders/m2_particle.vert.spv"); + particleFrag.loadFromFile(device, "assets/shaders/m2_particle.frag.spv"); + smokeVert.loadFromFile(device, "assets/shaders/m2_smoke.vert.spv"); + smokeFrag.loadFromFile(device, "assets/shaders/m2_smoke.frag.spv"); + + if (!m2Vert.isValid() || !m2Frag.isValid()) { + LOG_ERROR("M2Renderer::recreatePipelines: missing required shaders"); + return; + } + + VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); + + // --- M2 model vertex input --- + VkVertexInputBindingDescription m2Binding{}; + m2Binding.binding = 0; + m2Binding.stride = 18 * sizeof(float); + m2Binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector m2Attrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // normal + {2, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float)}, // texCoord0 + {5, 0, VK_FORMAT_R32G32_SFLOAT, 8 * sizeof(float)}, // texCoord1 + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 10 * sizeof(float)}, // boneWeights + {4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 14 * sizeof(float)}, // boneIndices (float) + }; + + auto buildM2Pipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline { + return PipelineBuilder() + .setShaders(m2Vert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + m2Frag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({m2Binding}, m2Attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blendState) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + opaquePipeline_ = buildM2Pipeline(PipelineBuilder::blendDisabled(), true); + alphaTestPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), true); + alphaPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), false); + additivePipeline_ = buildM2Pipeline(PipelineBuilder::blendAdditive(), false); + + // --- Particle pipelines --- + if (particleVert.isValid() && particleFrag.isValid()) { + VkVertexInputBindingDescription pBind{}; + pBind.binding = 0; + pBind.stride = 9 * sizeof(float); // pos3 + color4 + size1 + tile1 + pBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector pAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 3 * sizeof(float)}, // color + {2, 0, VK_FORMAT_R32_SFLOAT, 7 * sizeof(float)}, // size + {3, 0, VK_FORMAT_R32_SFLOAT, 8 * sizeof(float)}, // tile + }; + + auto buildParticlePipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(particleVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + particleFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({pBind}, pAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(particlePipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + particlePipeline_ = buildParticlePipeline(PipelineBuilder::blendAlpha()); + particleAdditivePipeline_ = buildParticlePipeline(PipelineBuilder::blendAdditive()); + } + + // --- Smoke pipeline --- + if (smokeVert.isValid() && smokeFrag.isValid()) { + VkVertexInputBindingDescription sBind{}; + sBind.binding = 0; + sBind.stride = 6 * sizeof(float); // pos3 + lifeRatio1 + size1 + isSpark1 + sBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector sAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32_SFLOAT, 3 * sizeof(float)}, // lifeRatio + {2, 0, VK_FORMAT_R32_SFLOAT, 4 * sizeof(float)}, // size + {3, 0, VK_FORMAT_R32_SFLOAT, 5 * sizeof(float)}, // isSpark + }; + + smokePipeline_ = PipelineBuilder() + .setShaders(smokeVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + smokeFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({sBind}, sAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(smokePipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + } + + m2Vert.destroy(); m2Frag.destroy(); + particleVert.destroy(); particleFrag.destroy(); + smokeVert.destroy(); smokeFrag.destroy(); + + core::Logger::getInstance().info("M2Renderer: pipelines recreated"); +} + } // namespace rendering } // namespace wowee diff --git a/src/rendering/minimap.cpp b/src/rendering/minimap.cpp index 96572782..0f44869b 100644 --- a/src/rendering/minimap.cpp +++ b/src/rendering/minimap.cpp @@ -1,11 +1,15 @@ #include "rendering/minimap.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_render_target.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" #include "rendering/camera.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "core/coordinates.hpp" #include "core/logger.hpp" -#include #include #include #include @@ -13,37 +17,48 @@ namespace wowee { namespace rendering { +// Push constant for tile composite vertex shader +struct MinimapTilePush { + glm::vec2 gridOffset; // 8 bytes +}; + +// Push constant for display vertex + fragment shaders +struct MinimapDisplayPush { + glm::vec4 rect; // x, y, w, h in 0..1 screen space + glm::vec2 playerUV; + float rotation; + float arrowRotation; + float zoomRadius; + int32_t squareShape; + float opacity; +}; // 44 bytes + Minimap::Minimap() = default; Minimap::~Minimap() { shutdown(); } -bool Minimap::initialize(int size) { +bool Minimap::initialize(VkContext* ctx, VkDescriptorSetLayout /*perFrameLayout*/, int size) { + vkCtx = ctx; mapSize = size; + VkDevice device = vkCtx->getDevice(); - // --- Composite FBO (3x3 tiles = 768x768) --- - glGenFramebuffers(1, &compositeFBO); - glBindFramebuffer(GL_FRAMEBUFFER, compositeFBO); - - glGenTextures(1, &compositeTexture); - glBindTexture(GL_TEXTURE_2D, compositeTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, COMPOSITE_PX, COMPOSITE_PX, 0, - GL_RGBA, GL_UNSIGNED_BYTE, nullptr); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, compositeTexture, 0); - - if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { - LOG_ERROR("Minimap composite FBO incomplete"); - glBindFramebuffer(GL_FRAMEBUFFER, 0); + // --- Composite render target (768x768) --- + compositeTarget = std::make_unique(); + if (!compositeTarget->create(*vkCtx, COMPOSITE_PX, COMPOSITE_PX)) { + LOG_ERROR("Minimap: failed to create composite render target"); return false; } - glBindFramebuffer(GL_FRAMEBUFFER, 0); - // --- Unit quad for tile compositing --- + // --- No-data fallback texture (dark blue-gray, 1x1) --- + noDataTexture = std::make_unique(); + uint8_t darkPixel[4] = { 12, 20, 30, 255 }; + noDataTexture->upload(*vkCtx, darkPixel, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + noDataTexture->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f); + + // --- Shared quad vertex buffer (unit quad: pos2 + uv2) --- float quadVerts[] = { // pos (x,y), uv (u,v) 0.0f, 0.0f, 0.0f, 0.0f, @@ -53,178 +68,140 @@ bool Minimap::initialize(int size) { 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, }; + auto quadBuf = uploadBuffer(*vkCtx, quadVerts, sizeof(quadVerts), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + quadVB = quadBuf.buffer; + quadVBAlloc = quadBuf.allocation; - glGenVertexArrays(1, &tileQuadVAO); - glGenBuffers(1, &tileQuadVBO); - glBindVertexArray(tileQuadVAO); - glBindBuffer(GL_ARRAY_BUFFER, tileQuadVBO); - glBufferData(GL_ARRAY_BUFFER, sizeof(quadVerts), quadVerts, GL_STATIC_DRAW); - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); - glBindVertexArray(0); + // --- Descriptor set layout: 1 combined image sampler at binding 0 (fragment) --- + VkDescriptorSetLayoutBinding samplerBinding{}; + samplerBinding.binding = 0; + samplerBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + samplerBinding.descriptorCount = 1; + samplerBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + samplerSetLayout = createDescriptorSetLayout(device, { samplerBinding }); - // --- Tile compositing shader --- - const char* tileVertSrc = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - layout (location = 1) in vec2 aUV; + // --- Descriptor pool --- + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = MAX_DESC_SETS; - uniform vec2 uGridOffset; // (col, row) in 0-2 + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = MAX_DESC_SETS; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + vkCreateDescriptorPool(device, &poolInfo, nullptr, &descPool); - out vec2 TexCoord; + // --- Allocate all descriptor sets --- + // 18 tile sets (2 frames Γ— 9 tiles) + 1 display set = 19 total + std::vector layouts(19, samplerSetLayout); + VkDescriptorSetAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.descriptorPool = descPool; + allocInfo.descriptorSetCount = 19; + allocInfo.pSetLayouts = layouts.data(); - void main() { - vec2 gridPos = (uGridOffset + aPos) / 3.0; - gl_Position = vec4(gridPos * 2.0 - 1.0, 0.0, 1.0); - TexCoord = aUV; + VkDescriptorSet allSets[19]; + vkAllocateDescriptorSets(device, &allocInfo, allSets); + + for (int f = 0; f < 2; f++) + for (int t = 0; t < 9; t++) + tileDescSets[f][t] = allSets[f * 9 + t]; + displayDescSet = allSets[18]; + + // --- Write display descriptor set β†’ composite render target --- + VkDescriptorImageInfo compositeImgInfo = compositeTarget->descriptorInfo(); + VkWriteDescriptorSet displayWrite{}; + displayWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + displayWrite.dstSet = displayDescSet; + displayWrite.dstBinding = 0; + displayWrite.descriptorCount = 1; + displayWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + displayWrite.pImageInfo = &compositeImgInfo; + vkUpdateDescriptorSets(device, 1, &displayWrite, 0, nullptr); + + // --- Tile pipeline layout: samplerSetLayout + 8-byte push constant (vertex) --- + VkPushConstantRange tilePush{}; + tilePush.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + tilePush.offset = 0; + tilePush.size = sizeof(MinimapTilePush); + tilePipelineLayout = createPipelineLayout(device, { samplerSetLayout }, { tilePush }); + + // --- Display pipeline layout: samplerSetLayout + 40-byte push constant (vert+frag) --- + VkPushConstantRange displayPush{}; + displayPush.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + displayPush.offset = 0; + displayPush.size = sizeof(MinimapDisplayPush); + displayPipelineLayout = createPipelineLayout(device, { samplerSetLayout }, { displayPush }); + + // --- Vertex input: pos2 (loc 0) + uv2 (loc 1), stride 16 --- + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 4 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(2); + attrs[0] = { 0, 0, VK_FORMAT_R32G32_SFLOAT, 0 }; // aPos + attrs[1] = { 1, 0, VK_FORMAT_R32G32_SFLOAT, 2 * sizeof(float) }; // aUV + + // --- Load tile shaders --- + { + VkShaderModule vs, fs; + if (!vs.loadFromFile(device, "assets/shaders/minimap_tile.vert.spv") || + !fs.loadFromFile(device, "assets/shaders/minimap_tile.frag.spv")) { + LOG_ERROR("Minimap: failed to load tile shaders"); + return false; } - )"; - const char* tileFragSrc = R"( - #version 330 core - in vec2 TexCoord; + tilePipeline = PipelineBuilder() + .setShaders(vs.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fs.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ binding }, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setLayout(tilePipelineLayout) + .setRenderPass(compositeTarget->getRenderPass()) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); - uniform sampler2D uTileTexture; - - out vec4 FragColor; - - void main() { - // BLP minimap tiles have same axis transposition as ADT terrain: - // tile U (cols) = north-south, tile V (rows) = west-east - // Composite grid: TexCoord.x = west-east, TexCoord.y = north-south - // So swap to match - FragColor = texture(uTileTexture, vec2(TexCoord.y, TexCoord.x)); - } - )"; - - tileShader = std::make_unique(); - if (!tileShader->loadFromSource(tileVertSrc, tileFragSrc)) { - LOG_ERROR("Failed to create minimap tile compositing shader"); - return false; + vs.destroy(); + fs.destroy(); } - // --- Screen quad --- - glGenVertexArrays(1, &quadVAO); - glGenBuffers(1, &quadVBO); - glBindVertexArray(quadVAO); - glBindBuffer(GL_ARRAY_BUFFER, quadVBO); - glBufferData(GL_ARRAY_BUFFER, sizeof(quadVerts), quadVerts, GL_STATIC_DRAW); - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); - glBindVertexArray(0); - - // --- Screen quad shader with rotation + circular mask --- - const char* quadVertSrc = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - layout (location = 1) in vec2 aUV; - - uniform vec4 uRect; // x, y, w, h in 0..1 screen space - - out vec2 TexCoord; - - void main() { - vec2 pos = uRect.xy + aUV * uRect.zw; - gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); - TexCoord = aUV; - } - )"; - - const char* quadFragSrc = R"( - #version 330 core - in vec2 TexCoord; - - uniform sampler2D uComposite; - uniform vec2 uPlayerUV; - uniform float uRotation; - uniform float uArrowRotation; - uniform float uZoomRadius; - uniform bool uSquareShape; - - out vec4 FragColor; - - bool pointInTriangle(vec2 p, vec2 a, vec2 b, vec2 c) { - vec2 v0 = c - a, v1 = b - a, v2 = p - a; - float d00 = dot(v0, v0); - float d01 = dot(v0, v1); - float d02 = dot(v0, v2); - float d11 = dot(v1, v1); - float d12 = dot(v1, v2); - float inv = 1.0 / (d00 * d11 - d01 * d01); - float u = (d11 * d02 - d01 * d12) * inv; - float v = (d00 * d12 - d01 * d02) * inv; - return (u >= 0.0) && (v >= 0.0) && (u + v <= 1.0); + // --- Load display shaders --- + { + VkShaderModule vs, fs; + if (!vs.loadFromFile(device, "assets/shaders/minimap_display.vert.spv") || + !fs.loadFromFile(device, "assets/shaders/minimap_display.frag.spv")) { + LOG_ERROR("Minimap: failed to load display shaders"); + return false; } - vec2 rot2(vec2 v, float ang) { - float c = cos(ang); - float s = sin(ang); - return vec2(v.x * c - v.y * s, v.x * s + v.y * c); - } + displayPipeline = PipelineBuilder() + .setShaders(vs.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fs.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ binding }, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(displayPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); - void main() { - vec2 centered = TexCoord - 0.5; - float dist = length(centered); - float maxDist = uSquareShape ? max(abs(centered.x), abs(centered.y)) : dist; - if (maxDist > 0.5) discard; - - // Rotate screen coords β†’ composite UV offset - // Composite: U increases east, V increases north - // Screen: +X=right, +Y=up - float c = cos(uRotation); - float s = sin(uRotation); - float scale = uZoomRadius * 2.0; - - vec2 offset = vec2( - centered.x * c + centered.y * s, - -centered.x * s + centered.y * c - ) * scale; - - vec2 uv = uPlayerUV + offset; - vec3 color = texture(uComposite, uv).rgb; - - // Thin dark border at edge - if (maxDist > 0.49) { - color = mix(color, vec3(0.08), smoothstep(0.49, 0.5, maxDist)); - } - - // Player arrow at center (always points up = forward) - vec2 ap = rot2(centered, -(uArrowRotation + 3.14159265)); - vec2 tip = vec2(0.0, 0.035); - vec2 lt = vec2(-0.018, -0.016); - vec2 rt = vec2(0.018, -0.016); - vec2 nL = vec2(-0.006, -0.006); - vec2 nR = vec2(0.006, -0.006); - vec2 nB = vec2(0.0, 0.006); - - bool inArrow = pointInTriangle(ap, tip, lt, rt) - && !pointInTriangle(ap, nL, nR, nB); - - if (inArrow) { - color = vec3(0.0, 0.0, 0.0); - } - - FragColor = vec4(color, 0.8); - } - )"; - - quadShader = std::make_unique(); - if (!quadShader->loadFromSource(quadVertSrc, quadFragSrc)) { - LOG_ERROR("Failed to create minimap screen quad shader"); - return false; + vs.destroy(); + fs.destroy(); } - // --- No-data fallback texture (dark blue-gray) --- - glGenTextures(1, &noDataTexture); - glBindTexture(GL_TEXTURE_2D, noDataTexture); - uint8_t darkPixel[4] = { 12, 20, 30, 255 }; - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, darkPixel); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + if (!tilePipeline || !displayPipeline) { + LOG_ERROR("Minimap: failed to create pipelines"); + return false; + } LOG_INFO("Minimap initialized (", mapSize, "x", mapSize, " screen, ", COMPOSITE_PX, "x", COMPOSITE_PX, " composite)"); @@ -232,22 +209,72 @@ bool Minimap::initialize(int size) { } void Minimap::shutdown() { - if (compositeFBO) { glDeleteFramebuffers(1, &compositeFBO); compositeFBO = 0; } - if (compositeTexture) { glDeleteTextures(1, &compositeTexture); compositeTexture = 0; } - if (tileQuadVAO) { glDeleteVertexArrays(1, &tileQuadVAO); tileQuadVAO = 0; } - if (tileQuadVBO) { glDeleteBuffers(1, &tileQuadVBO); tileQuadVBO = 0; } - if (quadVAO) { glDeleteVertexArrays(1, &quadVAO); quadVAO = 0; } - if (quadVBO) { glDeleteBuffers(1, &quadVBO); quadVBO = 0; } - if (noDataTexture) { glDeleteTextures(1, &noDataTexture); noDataTexture = 0; } + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + + vkDeviceWaitIdle(device); + + if (tilePipeline) { vkDestroyPipeline(device, tilePipeline, nullptr); tilePipeline = VK_NULL_HANDLE; } + if (displayPipeline) { vkDestroyPipeline(device, displayPipeline, nullptr); displayPipeline = VK_NULL_HANDLE; } + if (tilePipelineLayout) { vkDestroyPipelineLayout(device, tilePipelineLayout, nullptr); tilePipelineLayout = VK_NULL_HANDLE; } + if (displayPipelineLayout) { vkDestroyPipelineLayout(device, displayPipelineLayout, nullptr); displayPipelineLayout = VK_NULL_HANDLE; } + if (descPool) { vkDestroyDescriptorPool(device, descPool, nullptr); descPool = VK_NULL_HANDLE; } + if (samplerSetLayout) { vkDestroyDescriptorSetLayout(device, samplerSetLayout, nullptr); samplerSetLayout = VK_NULL_HANDLE; } + + if (quadVB) { vmaDestroyBuffer(alloc, quadVB, quadVBAlloc); quadVB = VK_NULL_HANDLE; } - // Delete cached tile textures for (auto& [hash, tex] : tileTextureCache) { - if (tex) glDeleteTextures(1, &tex); + if (tex) tex->destroy(device, alloc); } tileTextureCache.clear(); - tileShader.reset(); - quadShader.reset(); + if (noDataTexture) { noDataTexture->destroy(device, alloc); noDataTexture.reset(); } + if (compositeTarget) { compositeTarget->destroy(device, alloc); compositeTarget.reset(); } + + vkCtx = nullptr; +} + +void Minimap::recreatePipelines() { + if (!vkCtx || !displayPipelineLayout) return; + VkDevice device = vkCtx->getDevice(); + + if (displayPipeline) { vkDestroyPipeline(device, displayPipeline, nullptr); displayPipeline = VK_NULL_HANDLE; } + + VkShaderModule vs, fs; + if (!vs.loadFromFile(device, "assets/shaders/minimap_display.vert.spv") || + !fs.loadFromFile(device, "assets/shaders/minimap_display.frag.spv")) { + LOG_ERROR("Minimap: failed to reload display shaders for pipeline recreation"); + return; + } + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 4 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(2); + attrs[0] = { 0, 0, VK_FORMAT_R32G32_SFLOAT, 0 }; + attrs[1] = { 1, 0, VK_FORMAT_R32G32_SFLOAT, 2 * sizeof(float) }; + + displayPipeline = PipelineBuilder() + .setShaders(vs.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fs.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ binding }, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(displayPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + vs.destroy(); + fs.destroy(); + + LOG_INFO("Minimap: display pipeline recreated with MSAA ", static_cast(vkCtx->getMsaaSamples()), "x"); } void Minimap::setMapName(const std::string& name) { @@ -279,27 +306,19 @@ void Minimap::parseTRS() { int count = 0; while (std::getline(stream, line)) { - // Remove \r if (!line.empty() && line.back() == '\r') line.pop_back(); - - // Skip "dir:" lines and empty lines if (line.empty() || line.substr(0, 4) == "dir:") continue; - // Format: "Azeroth\map32_49.blp\t.blp" auto tabPos = line.find('\t'); if (tabPos == std::string::npos) continue; std::string key = line.substr(0, tabPos); std::string hashFile = line.substr(tabPos + 1); - // Strip .blp from key: "Azeroth\map32_49" - if (key.size() > 4 && key.substr(key.size() - 4) == ".blp") { + if (key.size() > 4 && key.substr(key.size() - 4) == ".blp") key = key.substr(0, key.size() - 4); - } - // Strip .blp from hash to get just the md5: "e7f0dea73ee6baca78231aaf4b7e772a" - if (hashFile.size() > 4 && hashFile.substr(hashFile.size() - 4) == ".blp") { + if (hashFile.size() > 4 && hashFile.substr(hashFile.size() - 4) == ".blp") hashFile = hashFile.substr(0, hashFile.size() - 4); - } trsLookup[key] = hashFile; count++; @@ -312,118 +331,80 @@ void Minimap::parseTRS() { // Tile texture loading // -------------------------------------------------------- -GLuint Minimap::getOrLoadTileTexture(int tileX, int tileY) { - // Build TRS key: "Azeroth\map32_49" +VkTexture* Minimap::getOrLoadTileTexture(int tileX, int tileY) { + if (!trsParsed) parseTRS(); + std::string key = mapName + "\\map" + std::to_string(tileX) + "_" + std::to_string(tileY); auto trsIt = trsLookup.find(key); - if (trsIt == trsLookup.end()) { - return noDataTexture; - } + if (trsIt == trsLookup.end()) + return noDataTexture.get(); const std::string& hash = trsIt->second; - // Check texture cache auto cacheIt = tileTextureCache.find(hash); - if (cacheIt != tileTextureCache.end()) { - return cacheIt->second; - } + if (cacheIt != tileTextureCache.end()) + return cacheIt->second.get(); // Load from MPQ std::string blpPath = "Textures\\Minimap\\" + hash + ".blp"; auto blpImage = assetManager->loadTexture(blpPath); if (!blpImage.isValid()) { - tileTextureCache[hash] = noDataTexture; - return noDataTexture; + tileTextureCache[hash] = nullptr; // Mark as failed + return noDataTexture.get(); } - // Create GL texture - GLuint tex; - glGenTextures(1, &tex); - glBindTexture(GL_TEXTURE_2D, tex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, blpImage.width, blpImage.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, blpImage.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + auto tex = std::make_unique(); + tex->upload(*vkCtx, blpImage.data.data(), blpImage.width, blpImage.height, + VK_FORMAT_R8G8B8A8_UNORM, false); + tex->createSampler(vkCtx->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f); - tileTextureCache[hash] = tex; - return tex; + VkTexture* ptr = tex.get(); + tileTextureCache[hash] = std::move(tex); + return ptr; } // -------------------------------------------------------- -// Composite 3x3 tiles into FBO +// Update tile descriptor sets for composite pass // -------------------------------------------------------- -void Minimap::compositeTilesToFBO(const glm::vec3& centerWorldPos) { - // centerWorldPos is in render coords (renderX=wowY, renderY=wowX) - auto [tileX, tileY] = core::coords::worldToTile(centerWorldPos.x, centerWorldPos.y); +void Minimap::updateTileDescriptors(uint32_t frameIdx, int centerTileX, int centerTileY) { + VkDevice device = vkCtx->getDevice(); + int slot = 0; - // Save GL state - GLint prevFBO = 0; - glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFBO); - GLint prevViewport[4]; - glGetIntegerv(GL_VIEWPORT, prevViewport); - - glBindFramebuffer(GL_FRAMEBUFFER, compositeFBO); - glViewport(0, 0, COMPOSITE_PX, COMPOSITE_PX); - glClearColor(0.05f, 0.08f, 0.12f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT); - - glDisable(GL_DEPTH_TEST); - glDisable(GL_CULL_FACE); - glDisable(GL_BLEND); - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); - - tileShader->use(); - tileShader->setUniform("uTileTexture", 0); - - glBindVertexArray(tileQuadVAO); - - // Draw 3x3 tile grid into composite FBO. - // BLP first row β†’ GL V=0 (bottom) = north edge of tile. - // So north tile (dr=-1) goes to row 0 (bottom), south (dr=+1) to row 2 (top). - // West tile (dc=-1) goes to col 0 (left), east (dc=+1) to col 2 (right). - // Result: composite U=0β†’west, U=1β†’east, V=0β†’north, V=1β†’south. for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { - int tx = tileX + dr; - int ty = tileY + dc; + int tx = centerTileX + dr; + int ty = centerTileY + dc; - GLuint tileTex = getOrLoadTileTexture(tx, ty); + VkTexture* tileTex = getOrLoadTileTexture(tx, ty); + if (!tileTex || !tileTex->isValid()) + tileTex = noDataTexture.get(); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, tileTex); + VkDescriptorImageInfo imgInfo = tileTex->descriptorInfo(); - // Grid position: dr=-1 (north) β†’ row 0, dr=0 β†’ row 1, dr=+1 (south) β†’ row 2 - float col = static_cast(dc + 1); // 0, 1, 2 - float row = static_cast(dr + 1); // 0, 1, 2 + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = tileDescSets[frameIdx][slot]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; - tileShader->setUniform("uGridOffset", glm::vec2(col, row)); - glDrawArrays(GL_TRIANGLES, 0, 6); + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + slot++; } } - - glBindVertexArray(0); - - // Restore GL state - glBindFramebuffer(GL_FRAMEBUFFER, prevFBO); - glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]); - - lastCenterTileX = tileX; - lastCenterTileY = tileY; } // -------------------------------------------------------- -// Main render +// Off-screen composite pass (call BEFORE main render pass) // -------------------------------------------------------- -void Minimap::render(const Camera& playerCamera, const glm::vec3& centerWorldPos, - int screenWidth, int screenHeight) { - if (!enabled || !assetManager || !compositeFBO) return; +void Minimap::compositePass(VkCommandBuffer cmd, const glm::vec3& centerWorldPos) { + if (!enabled || !assetManager || !compositeTarget || !compositeTarget->isValid()) return; - // Lazy-parse TRS on first use if (!trsParsed) parseTRS(); // Check if composite needs refresh @@ -438,90 +419,124 @@ void Minimap::render(const Camera& playerCamera, const glm::vec3& centerWorldPos // Also refresh if player crossed a tile boundary auto [curTileX, curTileY] = core::coords::worldToTile(centerWorldPos.x, centerWorldPos.y); - if (curTileX != lastCenterTileX || curTileY != lastCenterTileY) { + if (curTileX != lastCenterTileX || curTileY != lastCenterTileY) needsRefresh = true; + + if (!needsRefresh) return; + + uint32_t frameIdx = vkCtx->getCurrentFrame(); + + // Update tile descriptor sets + updateTileDescriptors(frameIdx, curTileX, curTileY); + + // Begin off-screen render pass + VkClearColorValue clearColor = {{ 0.05f, 0.08f, 0.12f, 1.0f }}; + compositeTarget->beginPass(cmd, clearColor); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tilePipeline); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &quadVB, &offset); + + // Draw 3x3 tile grid + int slot = 0; + for (int dr = -1; dr <= 1; dr++) { + for (int dc = -1; dc <= 1; dc++) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + tilePipelineLayout, 0, 1, + &tileDescSets[frameIdx][slot], 0, nullptr); + + MinimapTilePush push{}; + push.gridOffset = glm::vec2(static_cast(dc + 1), + static_cast(dr + 1)); + vkCmdPushConstants(cmd, tilePipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, + 0, sizeof(push), &push); + + vkCmdDraw(cmd, 6, 1, 0, 0); + slot++; + } } - if (needsRefresh) { - compositeTilesToFBO(centerWorldPos); - lastUpdateTime = now; - lastUpdatePos = centerWorldPos; - hasCachedFrame = true; - } + compositeTarget->endPass(cmd); - // Draw screen quad - renderQuad(playerCamera, centerWorldPos, screenWidth, screenHeight); + // Update tracking + lastCenterTileX = curTileX; + lastCenterTileY = curTileY; + lastUpdateTime = now; + lastUpdatePos = centerWorldPos; + hasCachedFrame = true; } -void Minimap::renderQuad(const Camera& playerCamera, const glm::vec3& centerWorldPos, - int screenWidth, int screenHeight) { - glDisable(GL_DEPTH_TEST); - glDisable(GL_CULL_FACE); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); +// -------------------------------------------------------- +// Display quad (call INSIDE main render pass) +// -------------------------------------------------------- - quadShader->use(); +void Minimap::render(VkCommandBuffer cmd, const Camera& playerCamera, + const glm::vec3& centerWorldPos, + int screenWidth, int screenHeight, + float playerOrientation, bool hasPlayerOrientation) { + if (!enabled || !hasCachedFrame || !displayPipeline) return; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, displayPipeline); + + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + displayPipelineLayout, 0, 1, + &displayDescSet, 0, nullptr); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &quadVB, &offset); // Position minimap in top-right corner float margin = 10.0f; float pixelW = static_cast(mapSize) / screenWidth; float pixelH = static_cast(mapSize) / screenHeight; float x = 1.0f - pixelW - margin / screenWidth; - float y = 1.0f - pixelH - margin / screenHeight; - quadShader->setUniform("uRect", glm::vec4(x, y, pixelW, pixelH)); + float y = margin / screenHeight; // top edge in Vulkan (y=0 is top) // Compute player's UV in the composite texture - // Render coords: renderX = wowY (west axis), renderY = wowX (north axis) constexpr float TILE_SIZE = core::coords::TILE_SIZE; auto [tileX, tileY] = core::coords::worldToTile(centerWorldPos.x, centerWorldPos.y); - // Fractional position within center tile - // tileX = floor(32 - wowX/TILE_SIZE), wowX = renderY - // fracNS: 0 = north edge of tile, 1 = south edge float fracNS = 32.0f - static_cast(tileX) - centerWorldPos.y / TILE_SIZE; - // fracEW: 0 = west edge of tile, 1 = east edge float fracEW = 32.0f - static_cast(tileY) - centerWorldPos.x / TILE_SIZE; - // Composite UV: center tile is grid slot (1,1) β†’ UV range [1/3, 2/3] - // Composite orientation: U=0β†’west, U=1β†’east, V=0β†’north, V=1β†’south float playerU = (1.0f + fracEW) / 3.0f; float playerV = (1.0f + fracNS) / 3.0f; - quadShader->setUniform("uPlayerUV", glm::vec2(playerU, playerV)); - - // Zoom: convert view radius from world units to composite UV fraction float zoomRadius = viewRadius / (TILE_SIZE * 3.0f); - quadShader->setUniform("uZoomRadius", zoomRadius); - // Rotation: compass bearing from north, clockwise - // renderX = wowY (west), renderY = wowX (north) - // Facing north: fwd=(0,1,0) β†’ bearing=0 - // Facing east: fwd=(-1,0,0) β†’ bearing=Ο€/2 float rotation = 0.0f; if (rotateWithCamera) { glm::vec3 fwd = playerCamera.getForward(); rotation = std::atan2(-fwd.x, fwd.y); } - quadShader->setUniform("uRotation", rotation); + float arrowRotation = 0.0f; if (!rotateWithCamera) { - glm::vec3 fwd = playerCamera.getForward(); - arrowRotation = std::atan2(-fwd.x, fwd.y); + // Prefer authoritative orientation if provided. This value is expected + // to already match minimap shader rotation convention. + if (hasPlayerOrientation) { + arrowRotation = playerOrientation; + } else { + glm::vec3 fwd = playerCamera.getForward(); + arrowRotation = std::atan2(-fwd.x, fwd.y); + } } - quadShader->setUniform("uArrowRotation", arrowRotation); - quadShader->setUniform("uSquareShape", squareShape); - quadShader->setUniform("uComposite", 0); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, compositeTexture); + MinimapDisplayPush push{}; + push.rect = glm::vec4(x, y, pixelW, pixelH); + push.playerUV = glm::vec2(playerU, playerV); + push.rotation = rotation; + push.arrowRotation = arrowRotation; + push.zoomRadius = zoomRadius; + push.squareShape = squareShape ? 1 : 0; + push.opacity = opacity_; - glBindVertexArray(quadVAO); - glDrawArrays(GL_TRIANGLES, 0, 6); - glBindVertexArray(0); + vkCmdPushConstants(cmd, displayPipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); - glDisable(GL_BLEND); - glEnable(GL_DEPTH_TEST); + vkCmdDraw(cmd, 6, 1, 0, 0); } } // namespace rendering diff --git a/src/rendering/mount_dust.cpp b/src/rendering/mount_dust.cpp index d57aea59..5678f31c 100644 --- a/src/rendering/mount_dust.cpp +++ b/src/rendering/mount_dust.cpp @@ -1,10 +1,15 @@ #include "rendering/mount_dust.hpp" #include "rendering/camera.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" #include #include #include +#include namespace wowee { namespace rendering { @@ -23,69 +28,92 @@ static float randFloat(float lo, float hi) { MountDust::MountDust() = default; MountDust::~MountDust() { shutdown(); } -bool MountDust::initialize() { +bool MountDust::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { LOG_INFO("Initializing mount dust effects"); - // Dust particle shader (brownish/tan dust clouds) - shader = std::make_unique(); + vkCtx = ctx; + VkDevice device = vkCtx->getDevice(); - const char* dustVS = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in float aSize; - layout (location = 2) in float aAlpha; - - uniform mat4 uView; - uniform mat4 uProjection; - - out float vAlpha; - - void main() { - gl_Position = uProjection * uView * vec4(aPos, 1.0); - gl_PointSize = aSize; - vAlpha = aAlpha; - } - )"; - - const char* dustFS = R"( - #version 330 core - in float vAlpha; - out vec4 FragColor; - - void main() { - vec2 coord = gl_PointCoord - vec2(0.5); - float dist = length(coord); - if (dist > 0.5) discard; - // Soft dust cloud with brownish/tan color - float alpha = smoothstep(0.5, 0.0, dist) * vAlpha; - vec3 dustColor = vec3(0.7, 0.65, 0.55); // Tan/brown dust - FragColor = vec4(dustColor, alpha * 0.4); // Semi-transparent - } - )"; - - if (!shader->loadFromSource(dustVS, dustFS)) { - LOG_ERROR("Failed to create mount dust shader"); + // Load SPIR-V shaders + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/mount_dust.vert.spv")) { + LOG_ERROR("Failed to load mount_dust vertex shader"); + return false; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/mount_dust.frag.spv")) { + LOG_ERROR("Failed to load mount_dust fragment shader"); return false; } - // Create VAO/VBO - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); - glBindVertexArray(vao); - glBindBuffer(GL_ARRAY_BUFFER, vbo); + // No push constants needed for mount dust (all data is per-vertex) + pipelineLayout = createPipelineLayout(device, {perFrameLayout}, {}); + if (pipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create mount dust pipeline layout"); + return false; + } - // Position (vec3) + Size (float) + Alpha (float) = 5 floats per vertex - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); + // Vertex input: pos(vec3) + size(float) + alpha(float) = 5 floats, stride = 20 bytes + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); + std::vector attrs(3); + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(4 * sizeof(float))); - glEnableVertexAttribArray(2); + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; - glBindVertexArray(0); + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create mount dust pipeline"); + return false; + } + + // Create dynamic mapped vertex buffer + dynamicVBSize = MAX_DUST_PARTICLES * 5 * sizeof(float); + AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), dynamicVBSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + dynamicVB = buf.buffer; + dynamicVBAlloc = buf.allocation; + dynamicVBAllocInfo = buf.info; + + if (dynamicVB == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create mount dust dynamic vertex buffer"); + return false; + } particles.reserve(MAX_DUST_PARTICLES); vertexData.reserve(MAX_DUST_PARTICLES * 5); @@ -95,12 +123,86 @@ bool MountDust::initialize() { } void MountDust::shutdown() { - if (vao) glDeleteVertexArrays(1, &vao); - if (vbo) glDeleteBuffers(1, &vbo); - vao = 0; - vbo = 0; + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline, nullptr); + pipeline = VK_NULL_HANDLE; + } + if (pipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout, nullptr); + pipelineLayout = VK_NULL_HANDLE; + } + if (dynamicVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, dynamicVB, dynamicVBAlloc); + dynamicVB = VK_NULL_HANDLE; + dynamicVBAlloc = VK_NULL_HANDLE; + } + } + + vkCtx = nullptr; particles.clear(); - shader.reset(); +} + +void MountDust::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + + // Destroy old pipeline (NOT layout) + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline, nullptr); + pipeline = VK_NULL_HANDLE; + } + + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/mount_dust.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/mount_dust.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(3); + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); } void MountDust::spawnDust(const glm::vec3& position, const glm::vec3& velocity, bool isMoving) { @@ -173,8 +275,8 @@ void MountDust::update(float deltaTime) { } } -void MountDust::render(const Camera& camera) { - if (particles.empty() || !shader) return; +void MountDust::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (particles.empty() || pipeline == VK_NULL_HANDLE) return; // Build vertex data vertexData.clear(); @@ -186,26 +288,25 @@ void MountDust::render(const Camera& camera) { vertexData.push_back(p.alpha); } - // Upload to GPU - glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, vertexData.size() * sizeof(float), vertexData.data(), GL_DYNAMIC_DRAW); + // Upload to mapped buffer + VkDeviceSize uploadSize = vertexData.size() * sizeof(float); + if (uploadSize > 0 && dynamicVBAllocInfo.pMappedData) { + std::memcpy(dynamicVBAllocInfo.pMappedData, vertexData.data(), uploadSize); + } - // Render - shader->use(); - shader->setUniform("uView", camera.getViewMatrix()); - shader->setUniform("uProjection", camera.getProjectionMatrix()); + // Bind pipeline + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDepthMask(GL_FALSE); // Don't write to depth buffer - glEnable(GL_PROGRAM_POINT_SIZE); + // Bind per-frame descriptor set (set 0 - camera UBO) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); - glBindVertexArray(vao); - glDrawArrays(GL_POINTS, 0, static_cast(particles.size())); - glBindVertexArray(0); + // Bind vertex buffer + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &dynamicVB, &offset); - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); + // Draw particles as points + vkCmdDraw(cmd, static_cast(particles.size()), 1, 0, 0); } } // namespace rendering diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index f801fab0..d939f4f9 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -412,32 +412,37 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { if (showControls) { ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "CONTROLS"); ImGui::Separator(); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F1: Toggle HUD"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F2: Wireframe"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F3: Single tile"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F4: Culling"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F5: Stats"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F6: Multi-tile"); + + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Movement"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD: Move/Strafe"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Turn left/right"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Space: Jump"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "X: Sit/Stand"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "~: Auto-run"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Z: Sheathe weapons"); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "UI Panels"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "B: Bags/Inventory"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "C: Character sheet"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "L: Quest log"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "N: Talents"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "P: Spellbook"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "M: World map"); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Combat & Chat"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "1-0,-,=: Action bar"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Tab: Target cycle"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Enter: Chat"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "/: Chat command"); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Debug"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F1: Toggle this HUD"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F4: Toggle shadows"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F7: Level-up FX"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F8: Water"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F9: Time"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F10: Sun/Moon"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F11: Stars"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "F12: Fog"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "+/-: Change time"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "C: Clouds"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "[/]: Density"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "L: Lens Flare"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), ",/.: Intensity"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "M: Moon Cycle"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), ";/': Moon Phase"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "W: Weather"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), ": Wx Intensity"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "K: Spawn Character"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "J: Remove Chars"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "O: Spawn Test WMO"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Shift+O: Real WMO"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "P: Clear WMOs"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Esc: Settings/Close"); } ImGui::End(); diff --git a/src/rendering/quest_marker_renderer.cpp b/src/rendering/quest_marker_renderer.cpp index 2ba86f23..d07096e3 100644 --- a/src/rendering/quest_marker_renderer.cpp +++ b/src/rendering/quest_marker_renderer.cpp @@ -1,16 +1,26 @@ #include "rendering/quest_marker_renderer.hpp" #include "rendering/camera.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_utils.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "core/logger.hpp" -#include #include #include #include #include +#include namespace wowee { namespace rendering { +// Push constant layout matching quest_marker.vert.glsl / quest_marker.frag.glsl +struct QuestMarkerPushConstants { + glm::mat4 model; // 64 bytes, used by vertex shader + float alpha; // 4 bytes, used by fragment shader +}; + QuestMarkerRenderer::QuestMarkerRenderer() { } @@ -18,33 +28,259 @@ QuestMarkerRenderer::~QuestMarkerRenderer() { shutdown(); } -bool QuestMarkerRenderer::initialize(pipeline::AssetManager* assetManager) { - if (!assetManager) { - LOG_WARNING("QuestMarkerRenderer: No AssetManager provided"); +bool QuestMarkerRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* assetManager) +{ + if (!ctx || !assetManager) { + LOG_WARNING("QuestMarkerRenderer: Missing VkContext or AssetManager"); return false; } LOG_INFO("QuestMarkerRenderer: Initializing..."); - createShader(); - createQuad(); - loadTextures(assetManager); - LOG_INFO("QuestMarkerRenderer: Initialization complete"); + vkCtx_ = ctx; + VkDevice device = vkCtx_->getDevice(); + // --- Create material descriptor set layout (set 1: combined image sampler) --- + createDescriptorResources(); + + // --- Load shaders --- + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/quest_marker.vert.spv")) { + LOG_ERROR("Failed to load quest_marker vertex shader"); + return false; + } + + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/quest_marker.frag.spv")) { + LOG_ERROR("Failed to load quest_marker fragment shader"); + vertModule.destroy(); + return false; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // --- Push constant range: mat4 model (64) + float alpha (4) = 68 bytes --- + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(QuestMarkerPushConstants); + + // --- Pipeline layout: set 0 = per-frame, set 1 = material texture --- + pipelineLayout_ = createPipelineLayout(device, + {perFrameLayout, materialSetLayout_}, {pushRange}); + if (pipelineLayout_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create quest marker pipeline layout"); + vertModule.destroy(); + fragModule.destroy(); + return false; + } + + // --- Vertex input: vec3 pos (offset 0) + vec2 uv (offset 12), stride 20 --- + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); // 20 bytes + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + VkVertexInputAttributeDescription uvAttr{}; + uvAttr.location = 1; + uvAttr.binding = 0; + uvAttr.format = VK_FORMAT_R32G32_SFLOAT; + uvAttr.offset = 3 * sizeof(float); // 12 + + // Dynamic viewport and scissor + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + // --- Build pipeline: alpha blending, no cull, depth test on / write off --- + pipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, uvAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) // depth test on, write off + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline_ == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create quest marker pipeline"); + return false; + } + + // --- Upload quad vertex buffer --- + createQuad(); + + // --- Load BLP textures --- + loadTextures(assetManager); + + LOG_INFO("QuestMarkerRenderer: Initialization complete"); return true; } void QuestMarkerRenderer::shutdown() { - if (vao_) glDeleteVertexArrays(1, &vao_); - if (vbo_) glDeleteBuffers(1, &vbo_); - if (shaderProgram_) glDeleteProgram(shaderProgram_); + if (!vkCtx_) return; + + VkDevice device = vkCtx_->getDevice(); + VmaAllocator allocator = vkCtx_->getAllocator(); + + // Wait for device idle before destroying resources + vkDeviceWaitIdle(device); + + // Destroy textures for (int i = 0; i < 3; ++i) { - if (textures_[i]) glDeleteTextures(1, &textures_[i]); + textures_[i].destroy(device, allocator); + texDescSets_[i] = VK_NULL_HANDLE; } + + // Destroy descriptor pool (frees all descriptor sets allocated from it) + if (descriptorPool_ != VK_NULL_HANDLE) { + vkDestroyDescriptorPool(device, descriptorPool_, nullptr); + descriptorPool_ = VK_NULL_HANDLE; + } + + // Destroy descriptor set layout + if (materialSetLayout_ != VK_NULL_HANDLE) { + vkDestroyDescriptorSetLayout(device, materialSetLayout_, nullptr); + materialSetLayout_ = VK_NULL_HANDLE; + } + + // Destroy pipeline + if (pipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline_, nullptr); + pipeline_ = VK_NULL_HANDLE; + } + if (pipelineLayout_ != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); + pipelineLayout_ = VK_NULL_HANDLE; + } + + // Destroy quad vertex buffer + if (quadVB_ != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, quadVB_, quadVBAlloc_); + quadVB_ = VK_NULL_HANDLE; + quadVBAlloc_ = VK_NULL_HANDLE; + } + markers_.clear(); + vkCtx_ = nullptr; +} + +void QuestMarkerRenderer::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + // Destroy old pipeline (NOT layout) + if (pipeline_ != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline_, nullptr); + pipeline_ = VK_NULL_HANDLE; + } + + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/quest_marker.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/quest_marker.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + VkVertexInputAttributeDescription uvAttr{}; + uvAttr.location = 1; + uvAttr.binding = 0; + uvAttr.format = VK_FORMAT_R32G32_SFLOAT; + uvAttr.offset = 3 * sizeof(float); + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline_ = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, uvAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); +} + +void QuestMarkerRenderer::createDescriptorResources() { + VkDevice device = vkCtx_->getDevice(); + + // Material set layout: binding 0 = combined image sampler (fragment stage) + VkDescriptorSetLayoutBinding samplerBinding{}; + samplerBinding.binding = 0; + samplerBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + samplerBinding.descriptorCount = 1; + samplerBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + materialSetLayout_ = createDescriptorSetLayout(device, {samplerBinding}); + + // Descriptor pool: 3 combined image samplers (one per marker type) + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = 3; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = 3; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &descriptorPool_) != VK_SUCCESS) { + LOG_ERROR("Failed to create quest marker descriptor pool"); + return; + } + + // Allocate 3 descriptor sets (one per texture) + VkDescriptorSetLayout layouts[3] = {materialSetLayout_, materialSetLayout_, materialSetLayout_}; + + VkDescriptorSetAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.descriptorPool = descriptorPool_; + allocInfo.descriptorSetCount = 3; + allocInfo.pSetLayouts = layouts; + + if (vkAllocateDescriptorSets(device, &allocInfo, texDescSets_) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate quest marker descriptor sets"); + } } void QuestMarkerRenderer::createQuad() { - // Billboard quad vertices (centered, 1 unit size) + // Billboard quad vertices (centered, 1 unit size) - 6 vertices for 2 triangles float vertices[] = { -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // bottom-left 0.5f, -0.5f, 0.0f, 1.0f, 1.0f, // bottom-right @@ -54,22 +290,10 @@ void QuestMarkerRenderer::createQuad() { 0.5f, 0.5f, 0.0f, 1.0f, 0.0f // top-right }; - glGenVertexArrays(1, &vao_); - glGenBuffers(1, &vbo_); - - glBindVertexArray(vao_); - glBindBuffer(GL_ARRAY_BUFFER, vbo_); - glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); - - // Position attribute - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - - // Texture coord attribute - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - - glBindVertexArray(0); + AllocatedBuffer vbuf = uploadBuffer(*vkCtx_, + vertices, sizeof(vertices), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + quadVB_ = vbuf.buffer; + quadVBAlloc_ = vbuf.allocation; } void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) { @@ -79,6 +303,8 @@ void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) { "Interface\\GossipFrame\\IncompleteQuestIcon.blp" }; + VkDevice device = vkCtx_->getDevice(); + for (int i = 0; i < 3; ++i) { pipeline::BLPImage blp = assetManager->loadTexture(paths[i]); if (!blp.isValid()) { @@ -86,76 +312,32 @@ void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) { continue; } - glGenTextures(1, &textures_[i]); - glBindTexture(GL_TEXTURE_2D, textures_[i]); + // Upload RGBA data to VkTexture + if (!textures_[i].upload(*vkCtx_, blp.data.data(), blp.width, blp.height, + VK_FORMAT_R8G8B8A8_UNORM, true)) { + LOG_WARNING("Failed to upload quest marker texture to GPU: ", paths[i]); + continue; + } - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, blp.width, blp.height, - 0, GL_RGBA, GL_UNSIGNED_BYTE, blp.data.data()); + // Create sampler with clamp-to-edge + textures_[i].createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glGenerateMipmap(GL_TEXTURE_2D); + // Write descriptor set for this texture + VkDescriptorImageInfo imgInfo = textures_[i].descriptorInfo(); + + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = texDescSets_[i]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); LOG_INFO("Loaded quest marker texture: ", paths[i]); } - - glBindTexture(GL_TEXTURE_2D, 0); -} - -void QuestMarkerRenderer::createShader() { - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec2 aTexCoord; - - out vec2 TexCoord; - - uniform mat4 model; - uniform mat4 view; - uniform mat4 projection; - - void main() { - gl_Position = projection * view * model * vec4(aPos, 1.0); - TexCoord = aTexCoord; - } - )"; - - const char* fragmentShaderSource = R"( - #version 330 core - in vec2 TexCoord; - out vec4 FragColor; - - uniform sampler2D markerTexture; - uniform float uAlpha; - - void main() { - vec4 texColor = texture(markerTexture, TexCoord); - if (texColor.a < 0.1) - discard; - FragColor = vec4(texColor.rgb, texColor.a * uAlpha); - } - )"; - - // Compile vertex shader - uint32_t vertexShader = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr); - glCompileShader(vertexShader); - - // Compile fragment shader - uint32_t fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr); - glCompileShader(fragmentShader); - - // Link shader program - shaderProgram_ = glCreateProgram(); - glAttachShader(shaderProgram_, vertexShader); - glAttachShader(shaderProgram_, fragmentShader); - glLinkProgram(shaderProgram_); - - glDeleteShader(vertexShader); - glDeleteShader(fragmentShader); } void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight) { @@ -170,8 +352,8 @@ void QuestMarkerRenderer::clear() { markers_.clear(); } -void QuestMarkerRenderer::render(const Camera& camera) { - if (markers_.empty() || !shaderProgram_ || !vao_) return; +void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (markers_.empty() || pipeline_ == VK_NULL_HANDLE || quadVB_ == VK_NULL_HANDLE) return; // WoW-style quest marker tuning parameters constexpr float BASE_SIZE = 0.65f; // Base world-space size @@ -181,38 +363,31 @@ void QuestMarkerRenderer::render(const Camera& camera) { constexpr float MIN_DIST = 4.0f; // Near clamp constexpr float MAX_DIST = 90.0f; // Far fade-out start constexpr float FADE_RANGE = 25.0f; // Fade-out range - constexpr float GLOW_ALPHA = 0.35f; // Glow pass alpha // Get time for bob animation float timeSeconds = SDL_GetTicks() / 1000.0f; - glEnable(GL_BLEND); - glEnable(GL_DEPTH_TEST); - glDepthMask(GL_FALSE); // Don't write to depth buffer - - glUseProgram(shaderProgram_); - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); glm::vec3 cameraPos = camera.getPosition(); - int viewLoc = glGetUniformLocation(shaderProgram_, "view"); - int projLoc = glGetUniformLocation(shaderProgram_, "projection"); - int modelLoc = glGetUniformLocation(shaderProgram_, "model"); - int alphaLoc = glGetUniformLocation(shaderProgram_, "uAlpha"); - - glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view)); - glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(projection)); - - glBindVertexArray(vao_); - // Get camera right and up vectors for billboarding glm::vec3 cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]); glm::vec3 cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]); + // Bind pipeline + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_); + + // Bind per-frame descriptor set (set 0) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + + // Bind quad vertex buffer + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &quadVB_, &offset); + for (const auto& [guid, marker] : markers_) { if (marker.type < 0 || marker.type > 2) continue; - if (!textures_[marker.type]) continue; + if (!textures_[marker.type].isValid()) continue; // Calculate distance for LOD and culling glm::vec3 toCamera = cameraPos - marker.position; @@ -252,29 +427,22 @@ void QuestMarkerRenderer::render(const Camera& camera) { model[1] = glm::vec4(cameraUp * size, 0.0f); model[2] = glm::vec4(glm::cross(cameraRight, cameraUp), 0.0f); - glBindTexture(GL_TEXTURE_2D, textures_[marker.type]); + // Bind material descriptor set (set 1) for this marker's texture + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 1, 1, &texDescSets_[marker.type], 0, nullptr); - // Glow pass (subtle additive glow for available/turnin markers) - if (marker.type == 0 || marker.type == 1) { // Available or turnin - glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blending - glUniform1f(alphaLoc, fadeAlpha * GLOW_ALPHA); // Reduced alpha for glow - glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model)); - glDrawArrays(GL_TRIANGLES, 0, 6); + // Push constants: model matrix + alpha + QuestMarkerPushConstants push{}; + push.model = model; + push.alpha = fadeAlpha; - // Restore standard alpha blending for main pass - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - } + vkCmdPushConstants(cmd, pipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); - // Main pass with fade alpha - glUniform1f(alphaLoc, fadeAlpha); - glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model)); - glDrawArrays(GL_TRIANGLES, 0, 6); + // Draw the quad (6 vertices, 2 triangles) + vkCmdDraw(cmd, 6, 1, 0, 0); } - - glBindVertexArray(0); - glBindTexture(GL_TEXTURE_2D, 0); - glDepthMask(GL_TRUE); - glDisable(GL_BLEND); } }} // namespace wowee::rendering diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 2103419a..cfd9c21b 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -19,9 +19,11 @@ #include "rendering/charge_effect.hpp" #include "rendering/levelup_effect.hpp" #include "rendering/character_renderer.hpp" +#include "rendering/character_preview.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" #include "rendering/minimap.hpp" +#include "rendering/world_map.hpp" #include "rendering/quest_marker_renderer.hpp" #include "rendering/shader.hpp" #include "game/game_handler.hpp" @@ -50,7 +52,13 @@ #include "audio/combat_sound_manager.hpp" #include "audio/spell_sound_manager.hpp" #include "audio/movement_sound_manager.hpp" -#include +#include "rendering/vk_context.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_utils.hpp" +#include +#include #include #include #include @@ -259,10 +267,366 @@ static void loadEmotesFromDbc() { Renderer::Renderer() = default; Renderer::~Renderer() = default; +bool Renderer::createPerFrameResources() { + VkDevice device = vkCtx->getDevice(); + + // --- Create shadow depth image --- + VkImageCreateInfo imgCI{}; + imgCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgCI.imageType = VK_IMAGE_TYPE_2D; + imgCI.format = VK_FORMAT_D32_SFLOAT; + imgCI.extent = {SHADOW_MAP_SIZE, SHADOW_MAP_SIZE, 1}; + imgCI.mipLevels = 1; + imgCI.arrayLayers = 1; + imgCI.samples = VK_SAMPLE_COUNT_1_BIT; + imgCI.tiling = VK_IMAGE_TILING_OPTIMAL; + imgCI.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + VmaAllocationCreateInfo imgAllocCI{}; + imgAllocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY; + if (vmaCreateImage(vkCtx->getAllocator(), &imgCI, &imgAllocCI, + &shadowDepthImage, &shadowDepthAlloc, nullptr) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow depth image"); + return false; + } + shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; + + // --- Create shadow depth image view --- + VkImageViewCreateInfo viewCI{}; + viewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewCI.image = shadowDepthImage; + viewCI.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewCI.format = VK_FORMAT_D32_SFLOAT; + viewCI.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; + if (vkCreateImageView(device, &viewCI, nullptr, &shadowDepthView) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow depth image view"); + return false; + } + + // --- Create shadow sampler --- + VkSamplerCreateInfo sampCI{}; + sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + sampCI.magFilter = VK_FILTER_LINEAR; + sampCI.minFilter = VK_FILTER_LINEAR; + sampCI.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + sampCI.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + sampCI.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + sampCI.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + sampCI.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE; + sampCI.compareEnable = VK_TRUE; + sampCI.compareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + if (vkCreateSampler(device, &sampCI, nullptr, &shadowSampler) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow sampler"); + return false; + } + + // --- Create shadow render pass (depth-only) --- + VkAttachmentDescription depthAtt{}; + depthAtt.format = VK_FORMAT_D32_SFLOAT; + depthAtt.samples = VK_SAMPLE_COUNT_1_BIT; + depthAtt.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + depthAtt.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + depthAtt.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + depthAtt.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + depthAtt.initialLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + depthAtt.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference depthRef{}; + depthRef.attachment = 0; + depthRef.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.pDepthStencilAttachment = &depthRef; + + VkSubpassDependency dep{}; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + dep.dstStageMask = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep.srcAccessMask = VK_ACCESS_SHADER_READ_BIT; + dep.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo rpCI{}; + rpCI.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpCI.attachmentCount = 1; + rpCI.pAttachments = &depthAtt; + rpCI.subpassCount = 1; + rpCI.pSubpasses = &subpass; + rpCI.dependencyCount = 1; + rpCI.pDependencies = &dep; + if (vkCreateRenderPass(device, &rpCI, nullptr, &shadowRenderPass) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow render pass"); + return false; + } + + // --- Create shadow framebuffer --- + VkFramebufferCreateInfo fbCI{}; + fbCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbCI.renderPass = shadowRenderPass; + fbCI.attachmentCount = 1; + fbCI.pAttachments = &shadowDepthView; + fbCI.width = SHADOW_MAP_SIZE; + fbCI.height = SHADOW_MAP_SIZE; + fbCI.layers = 1; + if (vkCreateFramebuffer(device, &fbCI, nullptr, &shadowFramebuffer) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow framebuffer"); + return false; + } + + // --- Create descriptor set layout for set 0 (per-frame UBO + shadow sampler) --- + VkDescriptorSetLayoutBinding bindings[2]{}; + bindings[0].binding = 0; + bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + bindings[0].descriptorCount = 1; + bindings[0].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + bindings[1].binding = 1; + bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + bindings[1].descriptorCount = 1; + bindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = 2; + layoutInfo.pBindings = bindings; + + if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &perFrameSetLayout) != VK_SUCCESS) { + LOG_ERROR("Failed to create per-frame descriptor set layout"); + return false; + } + + // --- Create descriptor pool for UBO + image sampler (normal frames + reflection) --- + VkDescriptorPoolSize poolSizes[2]{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[0].descriptorCount = MAX_FRAMES + 1; // +1 for reflection perFrame UBO + poolSizes[1].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSizes[1].descriptorCount = MAX_FRAMES + 1; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = MAX_FRAMES + 1; // +1 for reflection descriptor set + poolInfo.poolSizeCount = 2; + poolInfo.pPoolSizes = poolSizes; + + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &sceneDescriptorPool) != VK_SUCCESS) { + LOG_ERROR("Failed to create scene descriptor pool"); + return false; + } + + // --- Create per-frame UBOs and descriptor sets --- + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + // Create mapped UBO + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = sizeof(GPUPerFrameData); + bufInfo.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(vkCtx->getAllocator(), &bufInfo, &allocInfo, + &perFrameUBOs[i], &perFrameUBOAllocs[i], &mapInfo) != VK_SUCCESS) { + LOG_ERROR("Failed to create per-frame UBO ", i); + return false; + } + perFrameUBOMapped[i] = mapInfo.pMappedData; + + // Allocate descriptor set + VkDescriptorSetAllocateInfo setAlloc{}; + setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + setAlloc.descriptorPool = sceneDescriptorPool; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &perFrameSetLayout; + + if (vkAllocateDescriptorSets(device, &setAlloc, &perFrameDescSets[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate per-frame descriptor set ", i); + return false; + } + + // Write binding 0 (UBO) and binding 1 (shadow sampler) + VkDescriptorBufferInfo descBuf{}; + descBuf.buffer = perFrameUBOs[i]; + descBuf.offset = 0; + descBuf.range = sizeof(GPUPerFrameData); + + VkDescriptorImageInfo shadowImgInfo{}; + shadowImgInfo.sampler = shadowSampler; + shadowImgInfo.imageView = shadowDepthView; + shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = perFrameDescSets[i]; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[0].pBufferInfo = &descBuf; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = perFrameDescSets[i]; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[1].pImageInfo = &shadowImgInfo; + + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + } + + // --- Create reflection per-frame UBO and descriptor set --- + { + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = sizeof(GPUPerFrameData); + bufInfo.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(vkCtx->getAllocator(), &bufInfo, &allocInfo, + &reflPerFrameUBO, &reflPerFrameUBOAlloc, &mapInfo) != VK_SUCCESS) { + LOG_ERROR("Failed to create reflection per-frame UBO"); + return false; + } + reflPerFrameUBOMapped = mapInfo.pMappedData; + + VkDescriptorSetAllocateInfo setAlloc{}; + setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + setAlloc.descriptorPool = sceneDescriptorPool; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &perFrameSetLayout; + + if (vkAllocateDescriptorSets(device, &setAlloc, &reflPerFrameDescSet) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate reflection per-frame descriptor set"); + return false; + } + + VkDescriptorBufferInfo descBuf{}; + descBuf.buffer = reflPerFrameUBO; + descBuf.offset = 0; + descBuf.range = sizeof(GPUPerFrameData); + + VkDescriptorImageInfo shadowImgInfo{}; + shadowImgInfo.sampler = shadowSampler; + shadowImgInfo.imageView = shadowDepthView; + shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = reflPerFrameDescSet; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[0].pBufferInfo = &descBuf; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = reflPerFrameDescSet; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[1].pImageInfo = &shadowImgInfo; + + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + } + + LOG_INFO("Per-frame Vulkan resources created (shadow map ", SHADOW_MAP_SIZE, "x", SHADOW_MAP_SIZE, ")"); + return true; +} + +void Renderer::destroyPerFrameResources() { + if (!vkCtx) return; + vkDeviceWaitIdle(vkCtx->getDevice()); + VkDevice device = vkCtx->getDevice(); + + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + if (perFrameUBOs[i]) { + vmaDestroyBuffer(vkCtx->getAllocator(), perFrameUBOs[i], perFrameUBOAllocs[i]); + perFrameUBOs[i] = VK_NULL_HANDLE; + } + } + if (reflPerFrameUBO) { + vmaDestroyBuffer(vkCtx->getAllocator(), reflPerFrameUBO, reflPerFrameUBOAlloc); + reflPerFrameUBO = VK_NULL_HANDLE; + reflPerFrameUBOMapped = nullptr; + } + if (sceneDescriptorPool) { + vkDestroyDescriptorPool(device, sceneDescriptorPool, nullptr); + sceneDescriptorPool = VK_NULL_HANDLE; + } + if (perFrameSetLayout) { + vkDestroyDescriptorSetLayout(device, perFrameSetLayout, nullptr); + perFrameSetLayout = VK_NULL_HANDLE; + } + + // Destroy shadow resources + if (shadowFramebuffer) { vkDestroyFramebuffer(device, shadowFramebuffer, nullptr); shadowFramebuffer = VK_NULL_HANDLE; } + if (shadowRenderPass) { vkDestroyRenderPass(device, shadowRenderPass, nullptr); shadowRenderPass = VK_NULL_HANDLE; } + if (shadowDepthView) { vkDestroyImageView(device, shadowDepthView, nullptr); shadowDepthView = VK_NULL_HANDLE; } + if (shadowDepthImage) { vmaDestroyImage(vkCtx->getAllocator(), shadowDepthImage, shadowDepthAlloc); shadowDepthImage = VK_NULL_HANDLE; shadowDepthAlloc = VK_NULL_HANDLE; } + if (shadowSampler) { vkDestroySampler(device, shadowSampler, nullptr); shadowSampler = VK_NULL_HANDLE; } + shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; +} + +void Renderer::updatePerFrameUBO() { + if (!camera) return; + + currentFrameData.view = camera->getViewMatrix(); + currentFrameData.projection = camera->getProjectionMatrix(); + currentFrameData.viewPos = glm::vec4(camera->getPosition(), 1.0f); + currentFrameData.fogParams.z = globalTime; + + // Lighting from LightingManager + if (lightingManager) { + const auto& lp = lightingManager->getLightingParams(); + currentFrameData.lightDir = glm::vec4(lp.directionalDir, 0.0f); + currentFrameData.lightColor = glm::vec4(lp.diffuseColor, 1.0f); + currentFrameData.ambientColor = glm::vec4(lp.ambientColor, 1.0f); + currentFrameData.fogColor = glm::vec4(lp.fogColor, 1.0f); + currentFrameData.fogParams.x = lp.fogStart; + currentFrameData.fogParams.y = lp.fogEnd; + + // Shift fog to blue when camera is significantly underwater (terrain water only). + if (waterRenderer && camera) { + glm::vec3 camPos = camera->getPosition(); + auto waterH = waterRenderer->getNearestWaterHeightAt(camPos.x, camPos.y, camPos.z); + constexpr float MIN_SUBMERSION = 2.0f; + if (waterH && camPos.z < (*waterH - MIN_SUBMERSION) + && !waterRenderer->isWmoWaterAt(camPos.x, camPos.y)) { + float depth = *waterH - camPos.z - MIN_SUBMERSION; + float blend = glm::clamp(1.0f - std::exp(-depth * 0.08f), 0.0f, 0.7f); + glm::vec3 underwaterFog(0.03f, 0.09f, 0.18f); + glm::vec3 blendedFog = glm::mix(lp.fogColor, underwaterFog, blend); + currentFrameData.fogColor = glm::vec4(blendedFog, 1.0f); + currentFrameData.fogParams.x = glm::mix(lp.fogStart, 20.0f, blend); + currentFrameData.fogParams.y = glm::mix(lp.fogEnd, 200.0f, blend); + } + } + } + + currentFrameData.lightSpaceMatrix = lightSpaceMatrix; + currentFrameData.shadowParams = glm::vec4(shadowsEnabled ? 1.0f : 0.0f, 0.8f, 0.0f, 0.0f); + + // Player water ripple data: pack player XY into shadowParams.zw, ripple strength into fogParams.w + if (cameraController) { + currentFrameData.shadowParams.z = characterPosition.x; + currentFrameData.shadowParams.w = characterPosition.y; + bool inWater = cameraController->isSwimming(); + bool moving = cameraController->isMoving(); + currentFrameData.fogParams.w = (inWater && moving) ? 1.0f : 0.0f; + } else { + currentFrameData.fogParams.w = 0.0f; + } + + // Copy to current frame's mapped UBO + uint32_t frame = vkCtx->getCurrentFrame(); + std::memcpy(perFrameUBOMapped[frame], ¤tFrameData, sizeof(GPUPerFrameData)); +} + bool Renderer::initialize(core::Window* win) { window = win; + vkCtx = win->getVkContext(); deferredWorldInitEnabled_ = envFlagEnabled("WOWEE_DEFER_WORLD_SYSTEMS", true); - LOG_INFO("Initializing renderer"); + LOG_INFO("Initializing renderer (Vulkan)"); // Create camera (in front of Stormwind gate, looking north) camera = std::make_unique(); @@ -283,129 +647,46 @@ bool Renderer::initialize(core::Window* win) { performanceHUD = std::make_unique(); performanceHUD->setPosition(PerformanceHUD::Position::TOP_LEFT); - // Create water renderer - waterRenderer = std::make_unique(); - if (!waterRenderer->initialize()) { - LOG_WARNING("Failed to initialize water renderer"); - waterRenderer.reset(); + // Create per-frame UBO and descriptor sets + if (!createPerFrameResources()) { + LOG_ERROR("Failed to create per-frame Vulkan resources"); + return false; } - // Create skybox - skybox = std::make_unique(); - if (!skybox->initialize()) { - LOG_WARNING("Failed to initialize skybox"); - skybox.reset(); - } else { - skybox->setTimeOfDay(12.0f); // Start at noon - } + // Initialize Vulkan sub-renderers (Phase 3) - // Create celestial renderer (sun and moon) - celestial = std::make_unique(); - if (!celestial->initialize()) { - LOG_WARNING("Failed to initialize celestial renderer"); - celestial.reset(); - } - - // Create star field - starField = std::make_unique(); - if (!starField->initialize()) { - LOG_WARNING("Failed to initialize star field"); - starField.reset(); - } - - // Create clouds - clouds = std::make_unique(); - if (!clouds->initialize()) { - LOG_WARNING("Failed to initialize clouds"); - clouds.reset(); - } else { - clouds->setDensity(0.5f); // Medium cloud coverage - } - - // Create lens flare - lensFlare = std::make_unique(); - if (!lensFlare->initialize()) { - LOG_WARNING("Failed to initialize lens flare"); - lensFlare.reset(); - } - - // Create sky system (coordinator for sky rendering) + // Sky system (owns skybox, starfield, celestial, clouds, lens flare) skySystem = std::make_unique(); - if (!skySystem->initialize()) { - LOG_WARNING("Failed to initialize sky system"); - skySystem.reset(); - } else { - // Note: SkySystem manages its own components internally - // Keep existing components for backwards compatibility (PerformanceHUD access) - LOG_INFO("Sky system initialized successfully (coordinator active)"); + if (!skySystem->initialize(vkCtx, perFrameSetLayout)) { + LOG_ERROR("Failed to initialize sky system"); + return false; } + // Expose sub-components via renderer accessors + skybox = nullptr; // Owned by skySystem; access via skySystem->getSkybox() + celestial = nullptr; + starField = nullptr; + clouds = nullptr; + lensFlare = nullptr; - // Create weather system weather = std::make_unique(); - if (!weather->initialize()) { - LOG_WARNING("Failed to initialize weather"); - weather.reset(); - } + weather->initialize(vkCtx, perFrameSetLayout); - // Create lighting system - lightingManager = std::make_unique(); - auto* assetManager = core::Application::getInstance().getAssetManager(); - if (assetManager && !lightingManager->initialize(assetManager)) { - LOG_WARNING("Failed to initialize lighting manager"); - lightingManager.reset(); - } - - // Create swim effects swimEffects = std::make_unique(); - if (!swimEffects->initialize()) { - LOG_WARNING("Failed to initialize swim effects"); - swimEffects.reset(); - } + swimEffects->initialize(vkCtx, perFrameSetLayout); - // Create mount dust effects mountDust = std::make_unique(); - if (!mountDust->initialize()) { - LOG_WARNING("Failed to initialize mount dust effects"); - mountDust.reset(); - } + mountDust->initialize(vkCtx, perFrameSetLayout); + + chargeEffect = std::make_unique(); + chargeEffect->initialize(vkCtx, perFrameSetLayout); - // Create level-up effect (model loaded later via loadLevelUpEffect) levelUpEffect = std::make_unique(); - // Create charge effect (point-sprite particles + optional M2 models) - chargeEffect = std::make_unique(); - if (!chargeEffect->initialize()) { - LOG_WARNING("Failed to initialize charge effect"); - chargeEffect.reset(); - } + LOG_INFO("Vulkan sub-renderers initialized (Phase 3)"); - // Create character renderer - characterRenderer = std::make_unique(); - if (!characterRenderer->initialize()) { - LOG_WARNING("Failed to initialize character renderer"); - characterRenderer.reset(); - } - - // Create WMO renderer - wmoRenderer = std::make_unique(); - if (!wmoRenderer->initialize(assetManager)) { - LOG_WARNING("Failed to initialize WMO renderer"); - wmoRenderer.reset(); - } - - // Create minimap - minimap = std::make_unique(); - if (!minimap->initialize(200)) { - LOG_WARNING("Failed to initialize minimap"); - minimap.reset(); - } - - // Create quest marker renderer (initialized later with AssetManager) - questMarkerRenderer = std::make_unique(); - - // Create M2 renderer (for doodads) - m2Renderer = std::make_unique(); - // Note: M2 renderer needs asset manager, will be initialized when terrain loads + // LightingManager doesn't use GL β€” initialize for data-only use + lightingManager = std::make_unique(); + [[maybe_unused]] auto* assetManager = core::Application::getInstance().getAssetManager(); // Create zone manager zoneManager = std::make_unique(); @@ -428,42 +709,8 @@ bool Renderer::initialize(core::Window* win) { spellSoundManager = std::make_unique(); movementSoundManager = std::make_unique(); - // Underwater full-screen tint overlay (applies to all world geometry). - underwaterOverlayShader = std::make_unique(); - const char* overlayVS = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - void main() { gl_Position = vec4(aPos, 0.0, 1.0); } - )"; - const char* overlayFS = R"( - #version 330 core - uniform vec4 uTint; - out vec4 FragColor; - void main() { FragColor = uTint; } - )"; - if (!underwaterOverlayShader->loadFromSource(overlayVS, overlayFS)) { - LOG_WARNING("Failed to initialize underwater overlay shader"); - underwaterOverlayShader.reset(); - } else { - const float quadVerts[] = { - -1.0f, -1.0f, 1.0f, -1.0f, - -1.0f, 1.0f, 1.0f, 1.0f - }; - glGenVertexArrays(1, &underwaterOverlayVAO); - glGenBuffers(1, &underwaterOverlayVBO); - glBindVertexArray(underwaterOverlayVAO); - glBindBuffer(GL_ARRAY_BUFFER, underwaterOverlayVBO); - glBufferData(GL_ARRAY_BUFFER, sizeof(quadVerts), quadVerts, GL_STATIC_DRAW); - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - glBindVertexArray(0); - } - - // Initialize post-process FBO pipeline - initPostProcess(window->getWidth(), window->getHeight()); - - // Initialize shadow map - initShadowMap(); + // TODO Phase 6: Vulkan underwater overlay, post-process, and shadow map + // GL versions stubbed during migration LOG_INFO("Renderer initialized"); return true; @@ -485,28 +732,27 @@ void Renderer::shutdown() { waterRenderer.reset(); } - if (skybox) { - skybox->shutdown(); - skybox.reset(); + if (minimap) { + minimap->shutdown(); + minimap.reset(); } - if (celestial) { - celestial->shutdown(); - celestial.reset(); + if (worldMap) { + worldMap->shutdown(); + worldMap.reset(); } - if (starField) { - starField->shutdown(); - starField.reset(); + if (skySystem) { + skySystem->shutdown(); + skySystem.reset(); } - if (clouds) { - clouds.reset(); - } - - if (lensFlare) { - lensFlare.reset(); - } + // Individual sky components are owned by skySystem; just null the aliases + skybox = nullptr; + celestial = nullptr; + starField = nullptr; + clouds = nullptr; + lensFlare = nullptr; if (weather) { weather.reset(); @@ -548,22 +794,18 @@ void Renderer::shutdown() { // Shutdown AudioEngine singleton audio::AudioEngine::instance().shutdown(); - if (underwaterOverlayVAO) { - glDeleteVertexArrays(1, &underwaterOverlayVAO); - underwaterOverlayVAO = 0; + // Cleanup Vulkan selection circle resources + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } + if (selCirclePipelineLayout) { vkDestroyPipelineLayout(device, selCirclePipelineLayout, nullptr); selCirclePipelineLayout = VK_NULL_HANDLE; } + if (selCircleVertBuf) { vmaDestroyBuffer(vkCtx->getAllocator(), selCircleVertBuf, selCircleVertAlloc); selCircleVertBuf = VK_NULL_HANDLE; selCircleVertAlloc = VK_NULL_HANDLE; } + if (selCircleIdxBuf) { vmaDestroyBuffer(vkCtx->getAllocator(), selCircleIdxBuf, selCircleIdxAlloc); selCircleIdxBuf = VK_NULL_HANDLE; selCircleIdxAlloc = VK_NULL_HANDLE; } + if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } + if (overlayPipelineLayout) { vkDestroyPipelineLayout(device, overlayPipelineLayout, nullptr); overlayPipelineLayout = VK_NULL_HANDLE; } } - if (underwaterOverlayVBO) { - glDeleteBuffers(1, &underwaterOverlayVBO); - underwaterOverlayVBO = 0; - } - underwaterOverlayShader.reset(); - // Cleanup shadow map resources - if (shadowFBO) { glDeleteFramebuffers(1, &shadowFBO); shadowFBO = 0; } - if (shadowDepthTex) { glDeleteTextures(1, &shadowDepthTex); shadowDepthTex = 0; } - if (shadowShaderProgram) { glDeleteProgram(shadowShaderProgram); shadowShaderProgram = 0; } - - shutdownPostProcess(); + destroyPerFrameResources(); zoneManager.reset(); @@ -575,22 +817,238 @@ void Renderer::shutdown() { LOG_INFO("Renderer shutdown"); } -void Renderer::beginFrame() { - // Resize post-process FBO if window size changed - int w = window->getWidth(); - int h = window->getHeight(); - if (w != fbWidth || h != fbHeight) { - resizePostProcess(w, h); +void Renderer::registerPreview(CharacterPreview* preview) { + if (!preview) return; + auto it = std::find(activePreviews_.begin(), activePreviews_.end(), preview); + if (it == activePreviews_.end()) { + activePreviews_.push_back(preview); + } +} + +void Renderer::unregisterPreview(CharacterPreview* preview) { + auto it = std::find(activePreviews_.begin(), activePreviews_.end(), preview); + if (it != activePreviews_.end()) { + activePreviews_.erase(it); + } +} + +void Renderer::setMsaaSamples(VkSampleCountFlagBits samples) { + if (!vkCtx) return; + + // Clamp to device maximum + VkSampleCountFlagBits maxSamples = vkCtx->getMaxUsableSampleCount(); + if (samples > maxSamples) samples = maxSamples; + + if (samples == vkCtx->getMsaaSamples()) return; + + // Defer to between frames β€” cannot destroy render pass/framebuffers mid-frame + pendingMsaaSamples_ = samples; + msaaChangePending_ = true; +} + +void Renderer::applyMsaaChange() { + VkSampleCountFlagBits samples = pendingMsaaSamples_; + msaaChangePending_ = false; + + VkSampleCountFlagBits current = vkCtx->getMsaaSamples(); + if (samples == current) return; + + LOG_INFO("Changing MSAA from ", static_cast(current), "x to ", static_cast(samples), "x"); + + // Single GPU wait β€” all subsequent operations are CPU-side object creation + vkDeviceWaitIdle(vkCtx->getDevice()); + + // Set new MSAA and recreate swapchain (render pass, depth, MSAA image, framebuffers) + vkCtx->setMsaaSamples(samples); + if (!vkCtx->recreateSwapchain(window->getWidth(), window->getHeight())) { + LOG_ERROR("MSAA change failed β€” reverting to 1x"); + vkCtx->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); + vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); } - // Clear default framebuffer (login screen renders here directly) - glBindFramebuffer(GL_FRAMEBUFFER, 0); - glClearColor(0.0f, 0.0f, 0.0f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + // Recreate all sub-renderer pipelines (they embed sample count from render pass) + if (terrainRenderer) terrainRenderer->recreatePipelines(); + if (waterRenderer) { + waterRenderer->recreatePipelines(); + if (vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { + waterRenderer->destroyWater1xResources(); + setupWater1xPass(); + } else { + waterRenderer->destroyWater1xResources(); + } + } + if (wmoRenderer) wmoRenderer->recreatePipelines(); + if (m2Renderer) m2Renderer->recreatePipelines(); + if (characterRenderer) characterRenderer->recreatePipelines(); + if (questMarkerRenderer) questMarkerRenderer->recreatePipelines(); + if (weather) weather->recreatePipelines(); + if (swimEffects) swimEffects->recreatePipelines(); + if (mountDust) mountDust->recreatePipelines(); + if (chargeEffect) chargeEffect->recreatePipelines(); + + // Sky system sub-renderers + if (skySystem) { + if (auto* sb = skySystem->getSkybox()) sb->recreatePipelines(); + if (auto* sf = skySystem->getStarField()) sf->recreatePipelines(); + if (auto* ce = skySystem->getCelestial()) ce->recreatePipelines(); + if (auto* cl = skySystem->getClouds()) cl->recreatePipelines(); + if (auto* lf = skySystem->getLensFlare()) lf->recreatePipelines(); + } + + if (minimap) minimap->recreatePipelines(); + + // Selection circle + overlay use lazy init, just destroy them + VkDevice device = vkCtx->getDevice(); + if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } + if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } + + // Reinitialize ImGui Vulkan backend with new MSAA sample count + ImGui_ImplVulkan_Shutdown(); + ImGui_ImplVulkan_InitInfo initInfo{}; + initInfo.ApiVersion = VK_API_VERSION_1_1; + initInfo.Instance = vkCtx->getInstance(); + initInfo.PhysicalDevice = vkCtx->getPhysicalDevice(); + initInfo.Device = vkCtx->getDevice(); + initInfo.QueueFamily = vkCtx->getGraphicsQueueFamily(); + initInfo.Queue = vkCtx->getGraphicsQueue(); + initInfo.DescriptorPool = vkCtx->getImGuiDescriptorPool(); + initInfo.MinImageCount = 2; + initInfo.ImageCount = vkCtx->getSwapchainImageCount(); + initInfo.PipelineInfoMain.RenderPass = vkCtx->getImGuiRenderPass(); + initInfo.PipelineInfoMain.MSAASamples = vkCtx->getMsaaSamples(); + ImGui_ImplVulkan_Init(&initInfo); + + LOG_INFO("MSAA change complete"); +} + +void Renderer::beginFrame() { + if (!vkCtx) return; + + // Apply deferred MSAA change between frames (before any rendering state is used) + if (msaaChangePending_) { + applyMsaaChange(); + } + + // Handle swapchain recreation if needed + if (vkCtx->isSwapchainDirty()) { + vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); + // Rebuild water resources that reference swapchain extent/views + if (waterRenderer) { + waterRenderer->recreatePipelines(); + if (waterRenderer->hasWater1xPass() + && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { + waterRenderer->destroyWater1xResources(); + setupWater1xPass(); + } + } + } + + // Acquire swapchain image and begin command buffer + currentCmd = vkCtx->beginFrame(currentImageIndex); + if (currentCmd == VK_NULL_HANDLE) { + // Swapchain out of date, will retry next frame + return; + } + + // Update per-frame UBO with current camera/lighting state + updatePerFrameUBO(); + + // --- Off-screen pre-passes (before main render pass) --- + // Minimap composite (renders 3x3 tile grid into 768x768 render target) + if (minimap && minimap->isEnabled() && camera) { + glm::vec3 minimapCenter = camera->getPosition(); + if (cameraController && cameraController->isThirdPerson()) + minimapCenter = characterPosition; + minimap->compositePass(currentCmd, minimapCenter); + } + // World map composite (renders zone tiles into 1024x768 render target) + if (worldMap) { + worldMap->compositePass(currentCmd); + } + + // Character preview composite passes + for (auto* preview : activePreviews_) { + if (preview && preview->isModelLoaded()) { + preview->compositePass(currentCmd, vkCtx->getCurrentFrame()); + } + } + + // Shadow pre-pass (before main render pass) + if (shadowsEnabled && shadowDepthImage != VK_NULL_HANDLE) { + renderShadowPass(); + } + + // Water reflection pre-pass (renders scene from mirrored camera into 512x512 texture) + renderReflectionPass(); + + // --- Begin main render pass (clear color + depth) --- + VkRenderPassBeginInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpInfo.renderPass = vkCtx->getImGuiRenderPass(); + rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + rpInfo.renderArea.offset = {0, 0}; + rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + + // MSAA render pass has 3 attachments (color, depth, resolve), non-MSAA has 2 + VkClearValue clearValues[3]{}; + clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + clearValues[1].depthStencil = {1.0f, 0}; + clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; // resolve (DONT_CARE, but count must match) + bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); + rpInfo.clearValueCount = msaaOn ? 3 : 2; + rpInfo.pClearValues = clearValues; + + vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + + // Set dynamic viewport and scissor + VkExtent2D extent = vkCtx->getSwapchainExtent(); + VkViewport viewport{}; + viewport.x = 0.0f; + viewport.y = 0.0f; + viewport.width = static_cast(extent.width); + viewport.height = static_cast(extent.height); + viewport.minDepth = 0.0f; + viewport.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &viewport); + + VkRect2D scissor{}; + scissor.offset = {0, 0}; + scissor.extent = extent; + vkCmdSetScissor(currentCmd, 0, 1, &scissor); } void Renderer::endFrame() { - // Nothing needed here for now + if (!vkCtx || currentCmd == VK_NULL_HANDLE) return; + + // ImGui always renders in the main pass (its pipeline matches the main render pass) + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), currentCmd); + + vkCmdEndRenderPass(currentCmd); + + if (waterRenderer && currentImageIndex < vkCtx->getSwapchainImages().size()) { + waterRenderer->captureSceneHistory( + currentCmd, + vkCtx->getSwapchainImages()[currentImageIndex], + vkCtx->getDepthCopySourceImage(), + vkCtx->getSwapchainExtent(), + vkCtx->isDepthCopySourceMsaa()); + } + + // Render water in separate 1x pass after MSAA resolve + scene capture + bool waterDeferred = waterRenderer && waterRenderer->hasWater1xPass() + && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT; + if (waterDeferred && camera) { + VkExtent2D ext = vkCtx->getSwapchainExtent(); + uint32_t frame = vkCtx->getCurrentFrame(); + if (waterRenderer->beginWater1xPass(currentCmd, currentImageIndex, ext)) { + waterRenderer->render(currentCmd, perFrameDescSets[frame], *camera, globalTime, true); + waterRenderer->endWater1xPass(currentCmd); + } + } + + // Submit and present + vkCtx->endFrame(currentCmd, currentImageIndex); + currentCmd = VK_NULL_HANDLE; } void Renderer::setCharacterFollow(uint32_t instanceId) { @@ -1918,6 +2376,7 @@ audio::FootstepSurface Renderer::resolveFootstepSurface() const { } void Renderer::update(float deltaTime) { + globalTime += deltaTime; if (musicSwitchCooldown_ > 0.0f) { musicSwitchCooldown_ = std::max(0.0f, musicSwitchCooldown_ - deltaTime); } @@ -1926,16 +2385,9 @@ void Renderer::update(float deltaTime) { auto updateStart = std::chrono::steady_clock::now(); lastDeltaTime_ = deltaTime; // Cache for use in updateCharacterAnimation() - // Renderer update profiling - static int rendProfileCounter = 0; - static float camTime = 0.0f, lightTime = 0.0f, charAnimTime = 0.0f; - static float terrainTime = 0.0f, skyTime = 0.0f, charRendTime = 0.0f; - static float audioTime = 0.0f, footstepTime = 0.0f, ambientTime = 0.0f; - if (wmoRenderer) wmoRenderer->resetQueryStats(); if (m2Renderer) m2Renderer->resetQueryStats(); - auto cam1 = std::chrono::high_resolution_clock::now(); if (cameraController) { auto cameraStart = std::chrono::steady_clock::now(); cameraController->update(deltaTime); @@ -1950,8 +2402,6 @@ void Renderer::update(float deltaTime) { } else { lastCameraUpdateMs = 0.0; } - auto cam2 = std::chrono::high_resolution_clock::now(); - camTime += std::chrono::duration(cam2 - cam1).count(); // Visibility hardening: ensure player instance cannot stay hidden after // taxi/camera transitions, but preserve first-person self-hide. @@ -1962,7 +2412,6 @@ void Renderer::update(float deltaTime) { } // Update lighting system - auto light1 = std::chrono::high_resolution_clock::now(); if (lightingManager) { const auto* gh = core::Application::getInstance().getGameHandler(); uint32_t mapId = gh ? gh->getCurrentMapId() : 0; @@ -1976,17 +2425,25 @@ void Renderer::update(float deltaTime) { if (weather && gh) { uint32_t wType = gh->getWeatherType(); float wInt = gh->getWeatherIntensity(); - if (wType == 1) weather->setWeatherType(Weather::Type::RAIN); - else if (wType == 2) weather->setWeatherType(Weather::Type::SNOW); - else weather->setWeatherType(Weather::Type::NONE); - weather->setIntensity(wInt); + if (wType != 0) { + // Server-driven weather (SMSG_WEATHER) β€” authoritative + if (wType == 1) weather->setWeatherType(Weather::Type::RAIN); + else if (wType == 2) weather->setWeatherType(Weather::Type::SNOW); + else weather->setWeatherType(Weather::Type::NONE); + weather->setIntensity(wInt); + } else { + // No server weather β€” use zone-based weather configuration + weather->updateZoneWeather(currentZoneId, deltaTime); + } + weather->setEnabled(true); + } else if (weather) { + // No game handler (single-player without network) β€” zone weather only + weather->updateZoneWeather(currentZoneId, deltaTime); + weather->setEnabled(true); } } - auto light2 = std::chrono::high_resolution_clock::now(); - lightTime += std::chrono::duration(light2 - light1).count(); // Sync character model position/rotation and animation with follow target - auto charAnim1 = std::chrono::high_resolution_clock::now(); if (characterInstanceId > 0 && characterRenderer && cameraController) { if (meleeSwingCooldown > 0.0f) { meleeSwingCooldown = std::max(0.0f, meleeSwingCooldown - deltaTime); @@ -1996,12 +2453,6 @@ void Renderer::update(float deltaTime) { } characterRenderer->setInstancePosition(characterInstanceId, characterPosition); - if (activitySoundManager) { - std::string modelName; - if (characterRenderer->getInstanceModelName(characterInstanceId, modelName)) { - activitySoundManager->setCharacterVoiceProfile(modelName); - } - } // Movement-facing comes from camera controller and is decoupled from LMB orbit. // During taxi flights, orientation is controlled by the flight path (not player input) @@ -2032,36 +2483,15 @@ void Renderer::update(float deltaTime) { // Update animation based on movement state updateCharacterAnimation(); } - auto charAnim2 = std::chrono::high_resolution_clock::now(); - charAnimTime += std::chrono::duration(charAnim2 - charAnim1).count(); // Update terrain streaming - auto terrain1 = std::chrono::high_resolution_clock::now(); if (terrainManager && camera) { terrainManager->update(*camera, deltaTime); } - auto terrain2 = std::chrono::high_resolution_clock::now(); - terrainTime += std::chrono::duration(terrain2 - terrain1).count(); - // Update skybox time progression - auto sky1 = std::chrono::high_resolution_clock::now(); - if (skybox) { - skybox->update(deltaTime); - } - - // Update star field twinkle - if (starField) { - starField->update(deltaTime); - } - - // Update clouds animation - if (clouds) { - clouds->update(deltaTime); - } - - // Update celestial (moon phase cycling) - if (celestial) { - celestial->update(deltaTime); + // Update sky system (skybox time, star twinkle, clouds, celestial moon phases) + if (skySystem) { + skySystem->update(deltaTime); } // Update weather particles @@ -2105,25 +2535,16 @@ void Renderer::update(float deltaTime) { chargeEffect->update(deltaTime); } - auto sky2 = std::chrono::high_resolution_clock::now(); - skyTime += std::chrono::duration(sky2 - sky1).count(); // Update character animations - auto charRend1 = std::chrono::high_resolution_clock::now(); if (characterRenderer && camera) { characterRenderer->update(deltaTime, camera->getPosition()); } - auto charRend2 = std::chrono::high_resolution_clock::now(); - charRendTime += std::chrono::duration(charRend2 - charRend1).count(); // Update AudioEngine (cleanup finished sounds, etc.) - auto audio1 = std::chrono::high_resolution_clock::now(); audio::AudioEngine::instance().update(deltaTime); - auto audio2 = std::chrono::high_resolution_clock::now(); - audioTime += std::chrono::duration(audio2 - audio1).count(); // Footsteps: animation-event driven + surface query at event time. - auto footstep1 = std::chrono::high_resolution_clock::now(); if (footstepManager) { footstepManager->update(deltaTime); cachedFootstepUpdateTimer += deltaTime; // Update surface cache timer @@ -2170,7 +2591,20 @@ void Renderer::update(float deltaTime) { float animDurationMs = 0.0f; if (characterRenderer->getAnimationState(characterInstanceId, animId, animTimeMs, animDurationMs) && shouldTriggerFootstepEvent(animId, animTimeMs, animDurationMs)) { - footstepManager->playFootstep(resolveFootstepSurface(), cameraController->isSprinting()); + auto surface = resolveFootstepSurface(); + footstepManager->playFootstep(surface, cameraController->isSprinting()); + // Play additional splash sound and spawn foot splash particles when wading + if (surface == audio::FootstepSurface::WATER) { + if (movementSoundManager) { + movementSoundManager->playWaterFootstep(audio::MovementSoundManager::CharacterSize::MEDIUM); + } + if (swimEffects && waterRenderer) { + auto wh = waterRenderer->getWaterHeightAt(characterPosition.x, characterPosition.y); + if (wh) { + swimEffects->spawnFootSplash(characterPosition, *wh); + } + } + } } mountFootstepNormInitialized = false; } else { @@ -2243,11 +2677,8 @@ void Renderer::update(float deltaTime) { mountSoundManager->setFlying(flying); } } - auto footstep2 = std::chrono::high_resolution_clock::now(); - footstepTime += std::chrono::duration(footstep2 - footstep1).count(); // Ambient environmental sounds: fireplaces, water, birds, etc. - auto ambient1 = std::chrono::high_resolution_clock::now(); if (ambientSoundManager && camera && wmoRenderer && cameraController) { glm::vec3 camPos = camera->getPosition(); uint32_t wmoId = 0; @@ -2287,19 +2718,12 @@ void Renderer::update(float deltaTime) { ambientSoundManager->update(deltaTime, camPos, isIndoor, isSwimming, isBlacksmith); } - auto ambient2 = std::chrono::high_resolution_clock::now(); - ambientTime += std::chrono::duration(ambient2 - ambient1).count(); // Update M2 doodad animations (pass camera for frustum-culling bone computation) - static int m2ProfileCounter = 0; - static float m2Time = 0.0f; - auto m21 = std::chrono::high_resolution_clock::now(); if (m2Renderer && camera) { m2Renderer->update(deltaTime, camera->getPosition(), camera->getProjectionMatrix() * camera->getViewMatrix()); } - auto m22 = std::chrono::high_resolution_clock::now(); - m2Time += std::chrono::duration(m22 - m21).count(); // Helper: play zone music, dispatching local files (file: prefix) vs MPQ paths auto playZoneMusic = [&](const std::string& music) { @@ -2446,26 +2870,21 @@ void Renderer::update(float deltaTime) { performanceHUD->update(deltaTime); } + // Periodic cache hygiene: drop model GPU data no longer referenced by active instances. + static float modelCleanupTimer = 0.0f; + modelCleanupTimer += deltaTime; + if (modelCleanupTimer >= 5.0f) { + if (wmoRenderer) { + wmoRenderer->cleanupUnusedModels(); + } + if (m2Renderer) { + m2Renderer->cleanupUnusedModels(); + } + modelCleanupTimer = 0.0f; + } + auto updateEnd = std::chrono::steady_clock::now(); lastUpdateMs = std::chrono::duration(updateEnd - updateStart).count(); - - // Log renderer profiling every 60 frames - if (++rendProfileCounter >= 60) { - LOG_DEBUG("RENDERER UPDATE PROFILE (60 frames): camera=", camTime / 60.0f, - "ms light=", lightTime / 60.0f, "ms charAnim=", charAnimTime / 60.0f, - "ms terrain=", terrainTime / 60.0f, "ms sky=", skyTime / 60.0f, - "ms charRend=", charRendTime / 60.0f, "ms audio=", audioTime / 60.0f, - "ms footstep=", footstepTime / 60.0f, "ms ambient=", ambientTime / 60.0f, - "ms m2Anim=", m2Time / 60.0f, "ms"); - rendProfileCounter = 0; - camTime = lightTime = charAnimTime = 0.0f; - terrainTime = skyTime = charRendTime = 0.0f; - audioTime = footstepTime = ambientTime = 0.0f; - m2Time = 0.0f; - } - if (++m2ProfileCounter >= 60) { - m2ProfileCounter = 0; - } } void Renderer::runDeferredWorldInitStep(float deltaTime) { @@ -2497,7 +2916,7 @@ void Renderer::runDeferredWorldInitStep(float deltaTime) { if (movementSoundManager) movementSoundManager->initialize(cachedAssetManager); break; case 5: - if (questMarkerRenderer) questMarkerRenderer->initialize(cachedAssetManager); + if (questMarkerRenderer) questMarkerRenderer->initialize(vkCtx, perFrameSetLayout, cachedAssetManager); break; default: deferredWorldInitPending_ = false; @@ -2513,82 +2932,91 @@ void Renderer::runDeferredWorldInitStep(float deltaTime) { // ============================================================ void Renderer::initSelectionCircle() { - if (selCircleVAO) return; + if (selCirclePipeline != VK_NULL_HANDLE) return; + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); - // Selection effect shader: thin outer ring + inward fade toward center. - const char* vsSrc = R"( - #version 330 core - layout(location = 0) in vec3 aPos; - uniform mat4 uMVP; - out vec2 vLocalPos; - void main() { - vLocalPos = aPos.xy; - gl_Position = uMVP * vec4(aPos, 1.0); - } - )"; - const char* fsSrc = R"( - #version 330 core - uniform vec3 uColor; - in vec2 vLocalPos; - out vec4 FragColor; - void main() { - float r = clamp(length(vLocalPos), 0.0, 1.0); + // Load shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/selection_circle.vert.spv")) { + LOG_ERROR("initSelectionCircle: failed to load vertex shader"); + return; + } + if (!fragShader.loadFromFile(device, "assets/shaders/selection_circle.frag.spv")) { + LOG_ERROR("initSelectionCircle: failed to load fragment shader"); + vertShader.destroy(); + return; + } - float ringInner = 0.93; - float ringOuter = 1.00; - float ring = smoothstep(ringInner - 0.01, ringInner + 0.01, r) * - (1.0 - smoothstep(ringOuter - 0.008, ringOuter + 0.004, r)); + // Pipeline layout: push constants only (mat4 mvp=64 + vec4 color=16), VERTEX|FRAGMENT + VkPushConstantRange pcRange{}; + pcRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pcRange.offset = 0; + pcRange.size = 80; + selCirclePipelineLayout = createPipelineLayout(device, {}, {pcRange}); - float inward = smoothstep(0.0, ringInner, r); - inward = pow(inward, 1.9) * (1.0 - smoothstep(ringInner - 0.015, ringInner + 0.01, r)); + // Vertex input: binding 0, stride 12, vec3 at location 0 + VkVertexInputBindingDescription vertBind{0, 12, VK_VERTEX_INPUT_RATE_VERTEX}; + VkVertexInputAttributeDescription vertAttr{0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}; - float alpha = max(ring * 0.9, inward * 0.45); - FragColor = vec4(uColor, alpha); - } - )"; - - auto compile = [](GLenum type, const char* src) -> GLuint { - GLuint s = glCreateShader(type); - glShaderSource(s, 1, &src, nullptr); - glCompileShader(s); - return s; - }; - - GLuint vs = compile(GL_VERTEX_SHADER, vsSrc); - GLuint fs = compile(GL_FRAGMENT_SHADER, fsSrc); - selCircleShader = glCreateProgram(); - glAttachShader(selCircleShader, vs); - glAttachShader(selCircleShader, fs); - glLinkProgram(selCircleShader); - glDeleteShader(vs); - glDeleteShader(fs); - - // Build a unit disc; fragment shader shapes ring+gradient by radius. + // Build disc geometry as TRIANGLE_LIST (replaces GL_TRIANGLE_FAN) + // N=48 segments: center at origin + ring verts constexpr int SEGMENTS = 48; std::vector verts; - verts.reserve((SEGMENTS + 2) * 3); - - verts.push_back(0.0f); - verts.push_back(0.0f); - verts.push_back(0.0f); - + verts.reserve((SEGMENTS + 1) * 3); + // Center vertex + verts.insert(verts.end(), {0.0f, 0.0f, 0.0f}); + // Ring vertices for (int i = 0; i <= SEGMENTS; ++i) { float angle = 2.0f * 3.14159265f * static_cast(i) / static_cast(SEGMENTS); - float c = std::cos(angle), s = std::sin(angle); - verts.push_back(c); - verts.push_back(s); + verts.push_back(std::cos(angle)); + verts.push_back(std::sin(angle)); verts.push_back(0.0f); } - selCircleVertCount = static_cast(SEGMENTS + 2); - glGenVertexArrays(1, &selCircleVAO); - glGenBuffers(1, &selCircleVBO); - glBindVertexArray(selCircleVAO); - glBindBuffer(GL_ARRAY_BUFFER, selCircleVBO); - glBufferData(GL_ARRAY_BUFFER, verts.size() * sizeof(float), verts.data(), GL_STATIC_DRAW); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr); - glEnableVertexAttribArray(0); - glBindVertexArray(0); + // Build TRIANGLE_LIST indices: N triangles (center=0, ring[i]=i+1, ring[i+1]=i+2) + std::vector indices; + indices.reserve(SEGMENTS * 3); + for (int i = 0; i < SEGMENTS; ++i) { + indices.push_back(0); + indices.push_back(static_cast(i + 1)); + indices.push_back(static_cast(i + 2)); + } + selCircleVertCount = SEGMENTS * 3; // index count for drawing + + // Upload vertex buffer + AllocatedBuffer vbuf = uploadBuffer(*vkCtx, verts.data(), + verts.size() * sizeof(float), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + selCircleVertBuf = vbuf.buffer; + selCircleVertAlloc = vbuf.allocation; + + // Upload index buffer + AllocatedBuffer ibuf = uploadBuffer(*vkCtx, indices.data(), + indices.size() * sizeof(uint16_t), VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + selCircleIdxBuf = ibuf.buffer; + selCircleIdxAlloc = ibuf.allocation; + + // Build pipeline: alpha blend, no depth write/test, TRIANGLE_LIST, CULL_NONE + selCirclePipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({vertBind}, {vertAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(selCirclePipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!selCirclePipeline) { + LOG_ERROR("initSelectionCircle: failed to build pipeline"); + } } void Renderer::setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color) { @@ -2605,6 +3033,7 @@ void Renderer::clearSelectionCircle() { void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection) { if (!selCircleVisible) return; initSelectionCircle(); + if (selCirclePipeline == VK_NULL_HANDLE || currentCmd == VK_NULL_HANDLE) return; // Keep circle anchored near target foot Z. Accept nearby floor probes only, // so distant upper/lower WMO planes don't yank the ring away from feet. @@ -2634,60 +3063,97 @@ void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& pro model = glm::scale(model, glm::vec3(selCircleRadius)); glm::mat4 mvp = projection * view * model; + glm::vec4 color4(selCircleColor, 1.0f); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDisable(GL_CULL_FACE); - glDepthMask(GL_FALSE); - GLboolean depthTestWasEnabled = glIsEnabled(GL_DEPTH_TEST); - glDisable(GL_DEPTH_TEST); + vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, selCirclePipeline); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(currentCmd, 0, 1, &selCircleVertBuf, &offset); + vkCmdBindIndexBuffer(currentCmd, selCircleIdxBuf, 0, VK_INDEX_TYPE_UINT16); + // Push mvp (64 bytes) at offset 0 + vkCmdPushConstants(currentCmd, selCirclePipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, 64, &mvp[0][0]); + // Push color (16 bytes) at offset 64 + vkCmdPushConstants(currentCmd, selCirclePipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 64, 16, &color4[0]); + vkCmdDrawIndexed(currentCmd, static_cast(selCircleVertCount), 1, 0, 0, 0); +} - glUseProgram(selCircleShader); - glUniformMatrix4fv(glGetUniformLocation(selCircleShader, "uMVP"), 1, GL_FALSE, &mvp[0][0]); - glUniform3fv(glGetUniformLocation(selCircleShader, "uColor"), 1, &selCircleColor[0]); +// ────────────────────────────────────────────────────────────── +// Fullscreen overlay pipeline (underwater tint, etc.) +// ────────────────────────────────────────────────────────────── - glBindVertexArray(selCircleVAO); - glDrawArrays(GL_TRIANGLE_FAN, 0, selCircleVertCount); - glBindVertexArray(0); +void Renderer::initOverlayPipeline() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); - if (depthTestWasEnabled) glEnable(GL_DEPTH_TEST); - glDepthMask(GL_TRUE); - glEnable(GL_CULL_FACE); + // Push constant: vec4 color (16 bytes), visible to both stages + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pc.offset = 0; + pc.size = 16; + + VkPipelineLayoutCreateInfo plCI{}; + plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + vkCreatePipelineLayout(device, &plCI, nullptr, &overlayPipelineLayout); + + VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || + !fragMod.loadFromFile(device, "assets/shaders/overlay.frag.spv")) { + LOG_ERROR("Renderer: failed to load overlay shaders"); + vertMod.destroy(); fragMod.destroy(); + return; + } + + overlayPipeline = PipelineBuilder() + .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({}, {}) // fullscreen triangle, no VBOs + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(overlayPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertMod.destroy(); fragMod.destroy(); + + if (overlayPipeline) LOG_INFO("Renderer: overlay pipeline initialized"); +} + +void Renderer::renderOverlay(const glm::vec4& color) { + if (!overlayPipeline) initOverlayPipeline(); + if (!overlayPipeline || currentCmd == VK_NULL_HANDLE) return; + vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline); + vkCmdPushConstants(currentCmd, overlayPipelineLayout, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &color[0]); + vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle } void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { + (void)world; + auto renderStart = std::chrono::steady_clock::now(); lastTerrainRenderMs = 0.0; lastWMORenderMs = 0.0; lastM2RenderMs = 0.0; - // Shadow pass (before main scene) β€” update every frame to avoid temporal popping. - if (shadowsEnabled && shadowFBO && shadowShaderProgram && terrainLoaded) { - renderShadowPass(); - } else { - // Clear shadow maps when disabled - if (terrainRenderer) terrainRenderer->clearShadowMap(); - if (wmoRenderer) wmoRenderer->clearShadowMap(); - if (m2Renderer) m2Renderer->clearShadowMap(); - if (characterRenderer) characterRenderer->clearShadowMap(); - } - - // Bind HDR scene framebuffer for world rendering - glBindFramebuffer(GL_FRAMEBUFFER, sceneFBO); - glViewport(0, 0, fbWidth, fbHeight); - glClearColor(0.0f, 0.0f, 0.0f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); - - (void)world; // Unused for now + uint32_t frameIdx = vkCtx->getCurrentFrame(); + VkDescriptorSet perFrameSet = perFrameDescSets[frameIdx]; + const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f); + const glm::mat4& projection = camera ? camera->getProjectionMatrix() : glm::mat4(1.0f); // Get time of day for sky-related rendering - float timeOfDay = skybox ? skybox->getTimeOfDay() : 12.0f; - bool underwater = false; - bool canalUnderwater = false; + float timeOfDay = (skySystem && skySystem->getSkybox()) ? skySystem->getSkybox()->getTimeOfDay() : 12.0f; // Render sky system (unified coordinator for skybox, stars, celestial, clouds, lens flare) if (skySystem && camera) { - // Populate SkyParams from lighting manager rendering::SkyParams skyParams; skyParams.timeOfDay = timeOfDay; skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f; @@ -2705,428 +3171,143 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { skyParams.horizonGlow = lighting.horizonGlow; } - // TODO: Set skyboxModelId from LightSkybox.dbc (future) + // Weather attenuation for lens flare + if (gameHandler) { + skyParams.weatherIntensity = gameHandler->getWeatherIntensity(); + } + skyParams.skyboxModelId = 0; - skyParams.skyboxHasStars = false; // Gradient skybox has no baked stars + skyParams.skyboxHasStars = false; - skySystem->render(*camera, skyParams); - } else { - // Fallback: render individual components (backwards compatibility) - if (skybox && camera) { - skybox->render(*camera, timeOfDay); - } - - // Get lighting parameters for celestial rendering - const glm::vec3* sunDir = nullptr; - const glm::vec3* sunColor = nullptr; - float cloudDensity = 0.0f; - float fogDensity = 0.0f; - if (lightingManager) { - const auto& lighting = lightingManager->getLightingParams(); - sunDir = &lighting.directionalDir; - sunColor = &lighting.diffuseColor; - cloudDensity = lighting.cloudDensity; - fogDensity = lighting.fogDensity; - } - - if (starField && camera) { - starField->render(*camera, timeOfDay, cloudDensity, fogDensity); - } - - if (celestial && camera) { - celestial->render(*camera, timeOfDay, sunDir, sunColor); - } - - if (clouds && camera) { - clouds->render(*camera, timeOfDay); - } - - if (lensFlare && camera && celestial) { - glm::vec3 sunPosition; - if (sunDir) { - const float sunDistance = 800.0f; - sunPosition = -*sunDir * sunDistance; - } else { - sunPosition = celestial->getSunPosition(timeOfDay); - } - lensFlare->render(*camera, sunPosition, timeOfDay); - } + skySystem->render(currentCmd, perFrameSet, *camera, skyParams); } - // Apply lighting and fog to all renderers - if (lightingManager) { - const auto& lighting = lightingManager->getLightingParams(); - - float lightDir[3] = {lighting.directionalDir.x, lighting.directionalDir.y, lighting.directionalDir.z}; - float lightColor[3] = {lighting.diffuseColor.r, lighting.diffuseColor.g, lighting.diffuseColor.b}; - float ambientColor[3] = {lighting.ambientColor.r, lighting.ambientColor.g, lighting.ambientColor.b}; - float fogColorArray[3] = {lighting.fogColor.r, lighting.fogColor.g, lighting.fogColor.b}; - - if (wmoRenderer) { - wmoRenderer->setLighting(lightDir, lightColor, ambientColor); - wmoRenderer->setFog(glm::vec3(fogColorArray[0], fogColorArray[1], fogColorArray[2]), - lighting.fogStart, lighting.fogEnd); - } - if (m2Renderer) { - m2Renderer->setLighting(lightDir, lightColor, ambientColor); - m2Renderer->setFog(glm::vec3(fogColorArray[0], fogColorArray[1], fogColorArray[2]), - lighting.fogStart, lighting.fogEnd); - } - if (characterRenderer) { - characterRenderer->setLighting(lightDir, lightColor, ambientColor); - characterRenderer->setFog(glm::vec3(fogColorArray[0], fogColorArray[1], fogColorArray[2]), - lighting.fogStart, lighting.fogEnd); - } - } else if (skybox) { - // Fallback to skybox-based fog if no lighting manager - glm::vec3 horizonColor = skybox->getHorizonColor(timeOfDay); - if (wmoRenderer) wmoRenderer->setFog(horizonColor, 100.0f, 600.0f); - if (m2Renderer) m2Renderer->setFog(horizonColor, 100.0f, 600.0f); - if (characterRenderer) characterRenderer->setFog(horizonColor, 100.0f, 600.0f); - } - - // Render terrain if loaded and enabled - if (terrainEnabled && terrainLoaded && terrainRenderer && camera) { - // Check if camera/character is underwater for fog override - if (cameraController && cameraController->isSwimming() && waterRenderer && camera) { - glm::vec3 camPos = camera->getPosition(); - auto waterH = waterRenderer->getWaterHeightAt(camPos.x, camPos.y); - constexpr float MAX_UNDERWATER_DEPTH = 12.0f; - // Require camera to be meaningfully below the surface before - // underwater fog/tint kicks in (avoids "wrong plane" near surface). - constexpr float UNDERWATER_ENTER_EPS = 1.10f; - if (waterH && - camPos.z < (*waterH - UNDERWATER_ENTER_EPS) && - (*waterH - camPos.z) <= MAX_UNDERWATER_DEPTH) { - underwater = true; - } - } - - if (underwater) { - glm::vec3 camPos = camera->getPosition(); - std::optional liquidType = waterRenderer ? waterRenderer->getWaterTypeAt(camPos.x, camPos.y) : std::nullopt; - if (!liquidType && cameraController) { - const glm::vec3* followTarget = cameraController->getFollowTarget(); - if (followTarget && waterRenderer) { - liquidType = waterRenderer->getWaterTypeAt(followTarget->x, followTarget->y); - } - } - canalUnderwater = liquidType && (*liquidType == 5 || *liquidType == 13 || *liquidType == 17); - } - - // Apply lighting from lighting manager - if (lightingManager) { - const auto& lighting = lightingManager->getLightingParams(); - - // Set lighting (direction, color, ambient) - float lightDir[3] = {lighting.directionalDir.x, lighting.directionalDir.y, lighting.directionalDir.z}; - float lightColor[3] = {lighting.diffuseColor.r, lighting.diffuseColor.g, lighting.diffuseColor.b}; - float ambientColor[3] = {lighting.ambientColor.r, lighting.ambientColor.g, lighting.ambientColor.b}; - terrainRenderer->setLighting(lightDir, lightColor, ambientColor); - - // Set fog - float fogColor[3] = {lighting.fogColor.r, lighting.fogColor.g, lighting.fogColor.b}; - terrainRenderer->setFog(fogColor, lighting.fogStart, lighting.fogEnd); - } else if (skybox) { - // Fallback to skybox-based fog if no lighting manager - glm::vec3 horizonColor = skybox->getHorizonColor(timeOfDay); - float fogColorArray[3] = {horizonColor.r, horizonColor.g, horizonColor.b}; - terrainRenderer->setFog(fogColorArray, 400.0f, 1200.0f); - } - + // Terrain (opaque pass) + if (terrainRenderer && camera && terrainEnabled) { auto terrainStart = std::chrono::steady_clock::now(); - terrainRenderer->render(*camera); - auto terrainEnd = std::chrono::steady_clock::now(); - lastTerrainRenderMs = std::chrono::duration(terrainEnd - terrainStart).count(); - + terrainRenderer->render(currentCmd, perFrameSet, *camera); + lastTerrainRenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - terrainStart).count(); } - // Render weather particles (after terrain/water, before characters) - if (weather && camera) { - weather->render(*camera); - } - - // Render swim effects (ripples and bubbles) - if (swimEffects && camera) { - swimEffects->render(*camera); - } - - // Render mount dust effects - if (mountDust && camera) { - mountDust->render(*camera); - } - - // Render charge effect (red haze + dust) - if (chargeEffect && camera) { - chargeEffect->render(*camera); - } - - // Compute view/projection once for all sub-renderers - const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f); - const glm::mat4& projection = camera ? camera->getProjectionMatrix() : glm::mat4(1.0f); - - // Render WMO buildings first so selection circle can be drawn above WMO depth. + // WMO buildings (opaque, drawn before characters so selection circle sits on top) if (wmoRenderer && camera) { auto wmoStart = std::chrono::steady_clock::now(); - wmoRenderer->render(*camera, view, projection); - auto wmoEnd = std::chrono::steady_clock::now(); - lastWMORenderMs = std::chrono::duration(wmoEnd - wmoStart).count(); + wmoRenderer->render(currentCmd, perFrameSet, *camera); + lastWMORenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - wmoStart).count(); } - // Render selection circle after WMO so interiors/shafts do not hide it. - // It remains before character/M2 passes so units still draw over the ring. + // Selection circle (drawn after WMO, before characters) renderSelectionCircle(view, projection); - // Render characters (after selection circle) + // Characters (after selection circle so units draw over the ring) if (characterRenderer && camera) { - characterRenderer->render(*camera, view, projection); + characterRenderer->render(currentCmd, perFrameSet, *camera); } - // Render M2 doodads (trees, rocks, etc.) + // M2 doodads, creatures, glow sprites, particles if (m2Renderer && camera) { - // Dim M2 lighting when player is inside a WMO if (cameraController) { m2Renderer->setInsideInterior(cameraController->isInsideWMO()); m2Renderer->setOnTaxi(cameraController->isOnTaxi()); } auto m2Start = std::chrono::steady_clock::now(); - m2Renderer->render(*camera, view, projection); - m2Renderer->renderSmokeParticles(*camera, view, projection); - m2Renderer->renderM2Particles(view, projection); - auto m2End = std::chrono::steady_clock::now(); - lastM2RenderMs = std::chrono::duration(m2End - m2Start).count(); + m2Renderer->render(currentCmd, perFrameSet, *camera); + m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); + m2Renderer->renderM2Particles(currentCmd, perFrameSet); + lastM2RenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - m2Start).count(); } - // Render water after opaque terrain/WMO/M2 so transparent surfaces remain visible. - if (waterRenderer && camera) { - static float time = 0.0f; - time += 0.016f; // Approximate frame time - waterRenderer->render(*camera, time); + // Water (transparent, after all opaques) + // When MSAA is on and 1x pass is available, water renders after main pass ends + bool waterDeferred = waterRenderer && waterRenderer->hasWater1xPass() + && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT; + if (waterRenderer && camera && !waterDeferred) { + waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime); } - // Render quest markers (billboards above NPCs) + // Weather particles + if (weather && camera) { + weather->render(currentCmd, perFrameSet); + } + + // Swim effects (ripples, bubbles) + if (swimEffects && camera) { + swimEffects->render(currentCmd, perFrameSet); + } + + // Mount dust + if (mountDust && camera) { + mountDust->render(currentCmd, perFrameSet); + } + + // Charge effect + if (chargeEffect && camera) { + chargeEffect->render(currentCmd, perFrameSet); + } + + // Quest markers (billboards above NPCs) if (questMarkerRenderer && camera) { - questMarkerRenderer->render(*camera); + questMarkerRenderer->render(currentCmd, perFrameSet, *camera); } - // Full-screen underwater tint so WMO/M2/characters also feel submerged. - if (false && underwater && underwaterOverlayShader && underwaterOverlayVAO) { - glDisable(GL_DEPTH_TEST); - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - underwaterOverlayShader->use(); - if (canalUnderwater) { - underwaterOverlayShader->setUniform("uTint", glm::vec4(0.01f, 0.05f, 0.11f, 0.50f)); - } else { - underwaterOverlayShader->setUniform("uTint", glm::vec4(0.02f, 0.08f, 0.15f, 0.30f)); + // Underwater blue fog overlay β€” only for terrain water, not WMO water. + if (overlayPipeline && waterRenderer && camera) { + glm::vec3 camPos = camera->getPosition(); + auto waterH = waterRenderer->getNearestWaterHeightAt(camPos.x, camPos.y, camPos.z); + constexpr float MIN_SUBMERSION_OVERLAY = 1.5f; + if (waterH && camPos.z < (*waterH - MIN_SUBMERSION_OVERLAY) + && !waterRenderer->isWmoWaterAt(camPos.x, camPos.y)) { + float depth = *waterH - camPos.z - MIN_SUBMERSION_OVERLAY; + + // Check for canal (liquid type 5, 13, 17) β€” denser/darker fog + bool canal = false; + if (auto lt = waterRenderer->getWaterTypeAt(camPos.x, camPos.y)) + canal = (*lt == 5 || *lt == 13 || *lt == 17); + + // Fog opacity increases with depth: thin at surface, thick deep down + float fogStrength = 1.0f - std::exp(-depth * (canal ? 0.25f : 0.12f)); + fogStrength = glm::clamp(fogStrength, 0.0f, 0.75f); + + glm::vec4 tint = canal + ? glm::vec4(0.01f, 0.04f, 0.10f, fogStrength) + : glm::vec4(0.03f, 0.09f, 0.18f, fogStrength); + renderOverlay(tint); } - glBindVertexArray(underwaterOverlayVAO); - glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); - glBindVertexArray(0); - glDisable(GL_BLEND); - glEnable(GL_DEPTH_TEST); } - // --- Resolve MSAA β†’ non-MSAA texture --- - glBindFramebuffer(GL_READ_FRAMEBUFFER, sceneFBO); - glBindFramebuffer(GL_DRAW_FRAMEBUFFER, resolveFBO); - glBlitFramebuffer(0, 0, fbWidth, fbHeight, 0, 0, fbWidth, fbHeight, - GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT, GL_NEAREST); - - // --- Post-process: tonemap via fullscreen quad --- - glBindFramebuffer(GL_FRAMEBUFFER, 0); - glViewport(0, 0, window->getWidth(), window->getHeight()); - glDisable(GL_DEPTH_TEST); - glClear(GL_COLOR_BUFFER_BIT); - - if (postProcessShader && screenQuadVAO) { - postProcessShader->use(); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, resolveColorTex); - postProcessShader->setUniform("uScene", 0); - glBindVertexArray(screenQuadVAO); - glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); - glBindVertexArray(0); - postProcessShader->unuse(); - } - - // Render minimap overlay (after post-process so it's not overwritten) - if (minimap && camera && window) { + // Minimap overlay + if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); - if (cameraController && cameraController->isThirdPerson()) { + if (cameraController && cameraController->isThirdPerson()) minimapCenter = characterPosition; + float minimapPlayerOrientation = 0.0f; + bool hasMinimapPlayerOrientation = false; + if (cameraController) { + // Use the same yaw that drives character model rendering so minimap + // orientation cannot drift by a different axis/sign convention. + float facingRad = glm::radians(characterYaw); + glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); + minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + hasMinimapPlayerOrientation = true; + } else if (gameHandler) { + minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + hasMinimapPlayerOrientation = true; } - minimap->render(*camera, minimapCenter, window->getWidth(), window->getHeight()); + minimap->render(currentCmd, *camera, minimapCenter, + window->getWidth(), window->getHeight(), + minimapPlayerOrientation, hasMinimapPlayerOrientation); } - glEnable(GL_DEPTH_TEST); - auto renderEnd = std::chrono::steady_clock::now(); lastRenderMs = std::chrono::duration(renderEnd - renderStart).count(); + } -// ────────────────────────────────────────────────────── -// Post-process FBO helpers -// ────────────────────────────────────────────────────── - -void Renderer::initPostProcess(int w, int h) { - fbWidth = w; - fbHeight = h; - constexpr int SAMPLES = 4; - - // --- MSAA FBO (render target) --- - glGenRenderbuffers(1, &sceneColorRBO); - glBindRenderbuffer(GL_RENDERBUFFER, sceneColorRBO); - glRenderbufferStorageMultisample(GL_RENDERBUFFER, SAMPLES, GL_RGBA16F, w, h); - - glGenRenderbuffers(1, &sceneDepthRBO); - glBindRenderbuffer(GL_RENDERBUFFER, sceneDepthRBO); - glRenderbufferStorageMultisample(GL_RENDERBUFFER, SAMPLES, GL_DEPTH_COMPONENT24, w, h); - - glGenFramebuffers(1, &sceneFBO); - glBindFramebuffer(GL_FRAMEBUFFER, sceneFBO); - glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, sceneColorRBO); - glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, sceneDepthRBO); - - if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { - LOG_ERROR("MSAA scene FBO incomplete!"); - } - - // --- Resolve FBO (non-MSAA, for post-process sampling) --- - glGenTextures(1, &resolveColorTex); - glBindTexture(GL_TEXTURE_2D, resolveColorTex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, w, h, 0, GL_RGBA, GL_FLOAT, nullptr); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - - glGenTextures(1, &resolveDepthTex); - glBindTexture(GL_TEXTURE_2D, resolveDepthTex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, w, h, 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - - glGenFramebuffers(1, &resolveFBO); - glBindFramebuffer(GL_FRAMEBUFFER, resolveFBO); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, resolveColorTex, 0); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, resolveDepthTex, 0); - - if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { - LOG_ERROR("Resolve FBO incomplete!"); - } - glBindFramebuffer(GL_FRAMEBUFFER, 0); - - // --- Fullscreen quad (triangle strip, pos + UV) --- - const float quadVerts[] = { - // pos (x,y) uv (u,v) - -1.0f, -1.0f, 0.0f, 0.0f, - 1.0f, -1.0f, 1.0f, 0.0f, - -1.0f, 1.0f, 0.0f, 1.0f, - 1.0f, 1.0f, 1.0f, 1.0f, - }; - glGenVertexArrays(1, &screenQuadVAO); - glGenBuffers(1, &screenQuadVBO); - glBindVertexArray(screenQuadVAO); - glBindBuffer(GL_ARRAY_BUFFER, screenQuadVBO); - glBufferData(GL_ARRAY_BUFFER, sizeof(quadVerts), quadVerts, GL_STATIC_DRAW); - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); - glBindVertexArray(0); - - // --- Post-process shader (Reinhard tonemap + gamma 2.2) --- - const char* ppVS = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - layout (location = 1) in vec2 aUV; - out vec2 vUV; - void main() { - vUV = aUV; - gl_Position = vec4(aPos, 0.0, 1.0); - } - )"; - const char* ppFS = R"( - #version 330 core - in vec2 vUV; - uniform sampler2D uScene; - out vec4 FragColor; - void main() { - vec3 color = texture(uScene, vUV).rgb; - // Shoulder tonemap: identity below 0.9, soft rolloff above - vec3 excess = max(color - 0.9, 0.0); - vec3 mapped = min(color, vec3(0.9)) + 0.1 * excess / (excess + 0.1); - FragColor = vec4(mapped, 1.0); - } - )"; - postProcessShader = std::make_unique(); - if (!postProcessShader->loadFromSource(ppVS, ppFS)) { - LOG_ERROR("Failed to compile post-process shader"); - postProcessShader.reset(); - } - - LOG_INFO("Post-process FBO initialized (", w, "x", h, ")"); -} - -void Renderer::resizePostProcess(int w, int h) { - if (w <= 0 || h <= 0) return; - fbWidth = w; - fbHeight = h; - constexpr int SAMPLES = 4; - - // Resize MSAA renderbuffers - glBindRenderbuffer(GL_RENDERBUFFER, sceneColorRBO); - glRenderbufferStorageMultisample(GL_RENDERBUFFER, SAMPLES, GL_RGBA16F, w, h); - glBindRenderbuffer(GL_RENDERBUFFER, sceneDepthRBO); - glRenderbufferStorageMultisample(GL_RENDERBUFFER, SAMPLES, GL_DEPTH_COMPONENT24, w, h); - - // Resize resolve textures - glBindTexture(GL_TEXTURE_2D, resolveColorTex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, w, h, 0, GL_RGBA, GL_FLOAT, nullptr); - glBindTexture(GL_TEXTURE_2D, resolveDepthTex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, w, h, 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); - - LOG_INFO("Post-process FBO resized (", w, "x", h, ")"); -} - -void Renderer::shutdownPostProcess() { - if (sceneFBO) { - glDeleteFramebuffers(1, &sceneFBO); - sceneFBO = 0; - } - if (sceneColorRBO) { - glDeleteRenderbuffers(1, &sceneColorRBO); - sceneColorRBO = 0; - } - if (sceneDepthRBO) { - glDeleteRenderbuffers(1, &sceneDepthRBO); - sceneDepthRBO = 0; - } - if (resolveFBO) { - glDeleteFramebuffers(1, &resolveFBO); - resolveFBO = 0; - } - if (resolveColorTex) { - glDeleteTextures(1, &resolveColorTex); - resolveColorTex = 0; - } - if (resolveDepthTex) { - glDeleteTextures(1, &resolveDepthTex); - resolveDepthTex = 0; - } - if (screenQuadVAO) { - glDeleteVertexArrays(1, &screenQuadVAO); - screenQuadVAO = 0; - } - if (screenQuadVBO) { - glDeleteBuffers(1, &screenQuadVBO); - screenQuadVBO = 0; - } - postProcessShader.reset(); -} +// initPostProcess(), resizePostProcess(), shutdownPostProcess() removed β€” +// post-process pipeline is now handled by Vulkan (Phase 6 cleanup). bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std::string& adtPath) { if (!assetManager) { @@ -3139,13 +3320,70 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std:: // Create terrain renderer if not already created if (!terrainRenderer) { terrainRenderer = std::make_unique(); - if (!terrainRenderer->initialize(assetManager)) { + if (!terrainRenderer->initialize(vkCtx, perFrameSetLayout, assetManager)) { LOG_ERROR("Failed to initialize terrain renderer"); terrainRenderer.reset(); return false; } } + // Create water renderer if not already created + if (!waterRenderer) { + waterRenderer = std::make_unique(); + if (!waterRenderer->initialize(vkCtx, perFrameSetLayout)) { + LOG_ERROR("Failed to initialize water renderer"); + waterRenderer.reset(); + } else if (vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { + setupWater1xPass(); + } + } + + // Create minimap if not already created + if (!minimap) { + minimap = std::make_unique(); + if (!minimap->initialize(vkCtx, perFrameSetLayout)) { + LOG_ERROR("Failed to initialize minimap"); + minimap.reset(); + } + } + + // Create world map if not already created + if (!worldMap) { + worldMap = std::make_unique(); + if (!worldMap->initialize(vkCtx, assetManager)) { + LOG_ERROR("Failed to initialize world map"); + worldMap.reset(); + } + } + + // Create M2, WMO, and Character renderers + if (!m2Renderer) { + m2Renderer = std::make_unique(); + m2Renderer->initialize(vkCtx, perFrameSetLayout, assetManager); + if (swimEffects) { + swimEffects->setM2Renderer(m2Renderer.get()); + } + } + if (!wmoRenderer) { + wmoRenderer = std::make_unique(); + wmoRenderer->initialize(vkCtx, perFrameSetLayout, assetManager); + } + + // Initialize shadow pipelines (Phase 7/8) + if (wmoRenderer && shadowRenderPass != VK_NULL_HANDLE) { + wmoRenderer->initializeShadow(shadowRenderPass); + } + if (m2Renderer && shadowRenderPass != VK_NULL_HANDLE) { + m2Renderer->initializeShadow(shadowRenderPass); + } + if (!characterRenderer) { + characterRenderer = std::make_unique(); + characterRenderer->initialize(vkCtx, perFrameSetLayout, assetManager); + } + if (characterRenderer && shadowRenderPass != VK_NULL_HANDLE) { + characterRenderer->initializeShadow(shadowRenderPass); + } + // Create and initialize terrain manager if (!terrainManager) { terrainManager = std::make_unique(); @@ -3219,6 +3457,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std:: if (minimap) { minimap->setMapName(mapName); } + if (worldMap) { + worldMap->setMapName(mapName); + } } } @@ -3265,7 +3506,7 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std:: movementSoundManager->initialize(assetManager); } if (questMarkerRenderer) { - questMarkerRenderer->initialize(assetManager); + questMarkerRenderer->initialize(vkCtx, perFrameSetLayout, assetManager); } if (envFlagEnabled("WOWEE_PREWARM_ZONE_MUSIC", false)) { @@ -3391,7 +3632,7 @@ bool Renderer::loadTerrainArea(const std::string& mapName, int centerX, int cent movementSoundManager->initialize(cachedAssetManager); } if (questMarkerRenderer && cachedAssetManager) { - questMarkerRenderer->initialize(cachedAssetManager); + questMarkerRenderer->initialize(vkCtx, perFrameSetLayout, cachedAssetManager); } } else { deferredWorldInitPending_ = true; @@ -3442,156 +3683,8 @@ void Renderer::renderHUD() { // Shadow mapping helpers // ────────────────────────────────────────────────────── -void Renderer::initShadowMap() { - // Compile shadow shader - shadowShaderProgram = compileShadowShader(); - if (!shadowShaderProgram) { - LOG_ERROR("Failed to compile shadow shader"); - return; - } - - // Create depth texture - glGenTextures(1, &shadowDepthTex); - glBindTexture(GL_TEXTURE_2D, shadowDepthTex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, - SHADOW_MAP_SIZE, SHADOW_MAP_SIZE, 0, - GL_DEPTH_COMPONENT, GL_FLOAT, nullptr); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); - float borderColor[] = {1.0f, 1.0f, 1.0f, 1.0f}; - glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL); - glBindTexture(GL_TEXTURE_2D, 0); - - // Create depth-only FBO - glGenFramebuffers(1, &shadowFBO); - glBindFramebuffer(GL_FRAMEBUFFER, shadowFBO); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadowDepthTex, 0); - glDrawBuffer(GL_NONE); - glReadBuffer(GL_NONE); - - if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { - LOG_ERROR("Shadow FBO incomplete!"); - } - glBindFramebuffer(GL_FRAMEBUFFER, 0); - - LOG_INFO("Shadow map initialized (", SHADOW_MAP_SIZE, "x", SHADOW_MAP_SIZE, ")"); -} - -uint32_t Renderer::compileShadowShader() { - const char* vertSrc = R"( - #version 330 core - uniform mat4 uLightSpaceMatrix; - uniform mat4 uModel; - layout(location = 0) in vec3 aPos; - layout(location = 2) in vec2 aTexCoord; - layout(location = 3) in vec4 aBoneWeights; - layout(location = 4) in vec4 aBoneIndicesF; - uniform bool uUseBones; - uniform mat4 uBones[200]; - out vec2 vTexCoord; - out vec3 vWorldPos; - void main() { - vec3 pos = aPos; - if (uUseBones) { - ivec4 bi = ivec4(aBoneIndicesF); - mat4 boneTransform = uBones[bi.x] * aBoneWeights.x - + uBones[bi.y] * aBoneWeights.y - + uBones[bi.z] * aBoneWeights.z - + uBones[bi.w] * aBoneWeights.w; - pos = vec3(boneTransform * vec4(aPos, 1.0)); - } - vTexCoord = aTexCoord; - vec4 worldPos = uModel * vec4(pos, 1.0); - vWorldPos = worldPos.xyz; - gl_Position = uLightSpaceMatrix * worldPos; - } - )"; - const char* fragSrc = R"( - #version 330 core - in vec2 vTexCoord; - in vec3 vWorldPos; - uniform bool uUseTexture; - uniform sampler2D uTexture; - uniform bool uAlphaTest; - uniform bool uFoliageSway; - uniform float uWindTime; - uniform float uFoliageMotionDamp; - - void main() { - if (uUseTexture) { - vec2 uv = vTexCoord; - vec2 uv2 = vTexCoord; - if (uFoliageSway && uAlphaTest) { - // Slow, coherent wind-driven sway for foliage shadow cutouts. - float gust = sin(uWindTime * 0.32 + vWorldPos.x * 0.05 + vWorldPos.y * 0.04); - float flutter = sin(uWindTime * 0.55 + vWorldPos.y * 0.09 + vWorldPos.z * 0.18); - float damp = clamp(uFoliageMotionDamp, 0.2, 1.0); - uv += vec2(gust * 0.0040 * damp, flutter * 0.0022 * damp); - - // Second, phase-shifted sample gives smooth position-to-position - // transitions (less on/off popping during motion). - float gust2 = sin(uWindTime * 0.32 + 1.57 + vWorldPos.x * 0.05 + vWorldPos.y * 0.04); - float flutter2 = sin(uWindTime * 0.55 + 2.17 + vWorldPos.y * 0.09 + vWorldPos.z * 0.18); - uv2 += vec2(gust2 * 0.0040 * damp, flutter2 * 0.0022 * damp); - } - // Force base mip for alpha-cutout casters to avoid temporal - // shadow holes from mip-level transitions on thin foliage cards. - vec4 tex = textureLod(uTexture, uv, 0.0); - vec4 tex2 = textureLod(uTexture, uv2, 0.0); - float alphaCut = 0.5; - float alphaVal = (tex.a + tex2.a) * 0.5; - if (uAlphaTest && alphaVal < alphaCut) discard; - } - } - )"; - - GLuint vs = glCreateShader(GL_VERTEX_SHADER); - glShaderSource(vs, 1, &vertSrc, nullptr); - glCompileShader(vs); - GLint success; - glGetShaderiv(vs, GL_COMPILE_STATUS, &success); - if (!success) { - char log[512]; - glGetShaderInfoLog(vs, 512, nullptr, log); - LOG_ERROR("Shadow vertex shader error: ", log); - glDeleteShader(vs); - return 0; - } - - GLuint fs = glCreateShader(GL_FRAGMENT_SHADER); - glShaderSource(fs, 1, &fragSrc, nullptr); - glCompileShader(fs); - glGetShaderiv(fs, GL_COMPILE_STATUS, &success); - if (!success) { - char log[512]; - glGetShaderInfoLog(fs, 512, nullptr, log); - LOG_ERROR("Shadow fragment shader error: ", log); - glDeleteShader(vs); - glDeleteShader(fs); - return 0; - } - - GLuint program = glCreateProgram(); - glAttachShader(program, vs); - glAttachShader(program, fs); - glLinkProgram(program); - glGetProgramiv(program, GL_LINK_STATUS, &success); - if (!success) { - char log[512]; - glGetProgramInfoLog(program, 512, nullptr, log); - LOG_ERROR("Shadow shader link error: ", log); - glDeleteProgram(program); - program = 0; - } - - glDeleteShader(vs); - glDeleteShader(fs); - return program; -} +// initShadowMap() and compileShadowShader() removed β€” shadow resources now created +// in createPerFrameResources() as part of the Vulkan shadow infrastructure. glm::mat4 Renderer::computeLightSpaceMatrix() { constexpr float kShadowHalfExtent = 180.0f; @@ -3599,12 +3692,14 @@ glm::mat4 Renderer::computeLightSpaceMatrix() { constexpr float kShadowNearPlane = 1.0f; constexpr float kShadowFarPlane = 600.0f; - // Use active lighting direction so shadow projection matches sun/celestial. + // Use active lighting direction so shadow projection matches main shading. + // Fragment shaders derive lighting with `ldir = normalize(-lightDir.xyz)`, + // therefore shadow rays must use -directionalDir to stay aligned. glm::vec3 sunDir = glm::normalize(glm::vec3(-0.3f, -0.7f, -0.6f)); if (lightingManager) { const auto& lighting = lightingManager->getLightingParams(); if (glm::length(lighting.directionalDir) > 0.001f) { - sunDir = glm::normalize(lighting.directionalDir); + sunDir = glm::normalize(-lighting.directionalDir); } } // Shadow camera expects light rays pointing downward in render space (Z up). @@ -3618,46 +3713,16 @@ glm::mat4 Renderer::computeLightSpaceMatrix() { sunDir = glm::normalize(sunDir); } - // Keep a stable shadow focus center and move it smoothly toward the player - // to avoid visible shadow "state jumps" during movement. + // Shadow center follows the player directly; texel snapping below + // prevents shimmer without needing to freeze the projection. glm::vec3 desiredCenter = characterPosition; if (!shadowCenterInitialized) { - shadowCenter = desiredCenter; - shadowCenterInitialized = true; - } else { - const bool movingNow = cameraController && cameraController->isMoving(); - if (movingNow) { - // Hold projection center fixed while moving to eliminate - // frame-to-frame surface flicker from projection churn. - shadowPostMoveFrames_ = 1; // transition marker: was moving last frame - } else { - if (shadowPostMoveFrames_ == 1) { - // First frame after movement: snap once so there's no delayed catch-up. - shadowCenter = desiredCenter; - } else { - // Normal idle smoothing. - constexpr float kCenterLerp = 0.12f; - constexpr float kMaxHorizontalStep = 1.5f; - constexpr float kMaxVerticalStep = 0.6f; - - glm::vec2 deltaXY(desiredCenter.x - shadowCenter.x, desiredCenter.y - shadowCenter.y); - float distXY = glm::length(deltaXY); - if (distXY > 0.001f) { - float step = std::min(distXY * kCenterLerp, kMaxHorizontalStep); - glm::vec2 move = (deltaXY / distXY) * step; - shadowCenter.x += move.x; - shadowCenter.y += move.y; - } - - float deltaZ = desiredCenter.z - shadowCenter.z; - if (std::abs(deltaZ) > 0.001f) { - float stepZ = std::clamp(deltaZ * kCenterLerp, -kMaxVerticalStep, kMaxVerticalStep); - shadowCenter.z += stepZ; - } - } - shadowPostMoveFrames_ = 0; + if (glm::dot(desiredCenter, desiredCenter) < 1.0f) { + return glm::mat4(0.0f); } + shadowCenterInitialized = true; } + shadowCenter = desiredCenter; glm::vec3 center = shadowCenter; // Snap to shadow texel grid to keep projection stable while moving. @@ -3683,139 +3748,176 @@ glm::mat4 Renderer::computeLightSpaceMatrix() { glm::mat4 lightProj = glm::ortho(-halfExtent, halfExtent, -halfExtent, halfExtent, kShadowNearPlane, kShadowFarPlane); + lightProj[1][1] *= -1.0f; // Vulkan Y-flip for shadow pass return lightProj * lightView; } -void Renderer::renderShadowPass() { - constexpr float kShadowHalfExtent = 180.0f; - constexpr float kShadowLightDistance = 280.0f; - constexpr float kShadowNearPlane = 1.0f; - constexpr float kShadowFarPlane = 600.0f; - - // Compute light space matrix - lightSpaceMatrix = computeLightSpaceMatrix(); - - // Bind shadow FBO - glBindFramebuffer(GL_FRAMEBUFFER, shadowFBO); - glViewport(0, 0, SHADOW_MAP_SIZE, SHADOW_MAP_SIZE); - glClear(GL_DEPTH_BUFFER_BIT); - - // Caster-side bias: front-face culling + polygon offset - glEnable(GL_POLYGON_OFFSET_FILL); - glPolygonOffset(2.0f, 4.0f); - glEnable(GL_CULL_FACE); - glCullFace(GL_FRONT); - - // Use shadow shader - glUseProgram(shadowShaderProgram); - GLint lsmLoc = glGetUniformLocation(shadowShaderProgram, "uLightSpaceMatrix"); - glUniformMatrix4fv(lsmLoc, 1, GL_FALSE, &lightSpaceMatrix[0][0]); - GLint useTexLoc = glGetUniformLocation(shadowShaderProgram, "uUseTexture"); - GLint texLoc = glGetUniformLocation(shadowShaderProgram, "uTexture"); - GLint alphaTestLoc = glGetUniformLocation(shadowShaderProgram, "uAlphaTest"); - GLint useBonesLoc = glGetUniformLocation(shadowShaderProgram, "uUseBones"); - GLint foliageSwayLoc = glGetUniformLocation(shadowShaderProgram, "uFoliageSway"); - GLint windTimeLoc = glGetUniformLocation(shadowShaderProgram, "uWindTime"); - GLint foliageDampLoc = glGetUniformLocation(shadowShaderProgram, "uFoliageMotionDamp"); - if (useTexLoc >= 0) glUniform1i(useTexLoc, 0); - if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, 0); - if (useBonesLoc >= 0) glUniform1i(useBonesLoc, 0); - if (texLoc >= 0) glUniform1i(texLoc, 0); - if (foliageSwayLoc >= 0) glUniform1i(foliageSwayLoc, 0); - if (foliageDampLoc >= 0) glUniform1f(foliageDampLoc, 1.0f); - if (windTimeLoc >= 0) { - const auto now = std::chrono::steady_clock::now(); - static auto prev = now; - static float windPhaseSec = 0.0f; - float dt = std::chrono::duration(now - prev).count(); - prev = now; - dt = std::clamp(dt, 0.0f, 0.1f); - // Match moving and idle foliage evolution speed at 80% of original. - float phaseRate = 0.8f; - windPhaseSec += dt * phaseRate; - glUniform1f(windTimeLoc, windPhaseSec); - if (foliageDampLoc >= 0) { - glUniform1f(foliageDampLoc, 1.0f); - } +void Renderer::setupWater1xPass() { + if (!waterRenderer || !vkCtx) return; + VkImageView depthView = vkCtx->getDepthResolveImageView(); + if (!depthView) { + LOG_WARNING("No depth resolve image available - cannot create 1x water pass"); + return; } - // Render terrain into shadow map (only chunks within shadow frustum) - if (terrainRenderer) { - glm::vec3 shadowCtr = shadowCenterInitialized ? shadowCenter : characterPosition; - terrainRenderer->renderShadow(shadowShaderProgram, shadowCtr, kShadowHalfExtent); - } + waterRenderer->createWater1xPass(vkCtx->getSwapchainFormat(), vkCtx->getDepthFormat()); + waterRenderer->createWater1xFramebuffers( + vkCtx->getSwapchainImageViews(), depthView, vkCtx->getSwapchainExtent()); +} - // Render WMO into shadow map - if (wmoRenderer) { - // WMO renderShadow takes separate view/proj matrices and a Shader ref. - // We need to decompose our lightSpaceMatrix or use the raw shader program. - // Since WMO::renderShadow sets uModel per instance, we use the shadow shader - // directly by calling renderShadow with the light view/proj split. - // For simplicity, compute the split: - glm::vec3 sunDir = glm::normalize(glm::vec3(-0.3f, -0.7f, -0.6f)); - if (lightingManager) { - const auto& lighting = lightingManager->getLightingParams(); - if (glm::length(lighting.directionalDir) > 0.001f) { - sunDir = glm::normalize(lighting.directionalDir); +void Renderer::renderReflectionPass() { + if (!waterRenderer || !camera || !waterRenderer->hasReflectionPass() || !waterRenderer->hasSurfaces()) return; + if (currentCmd == VK_NULL_HANDLE || !reflPerFrameUBOMapped) return; + + // Reflection pass uses 1x MSAA. Scene pipelines must be render-pass-compatible, + // which requires matching sample counts. Only render scene into reflection when MSAA is off. + bool canRenderScene = (vkCtx->getMsaaSamples() == VK_SAMPLE_COUNT_1_BIT); + + // Find dominant water height near camera + auto waterH = waterRenderer->getDominantWaterHeight(camera->getPosition()); + if (!waterH) return; + + float waterHeight = *waterH; + + // Skip reflection if camera is underwater (Z is up) + if (camera->getPosition().z < waterHeight + 0.5f) return; + + // Compute reflected view and oblique projection + glm::mat4 reflView = WaterRenderer::computeReflectedView(*camera, waterHeight); + glm::mat4 reflProj = WaterRenderer::computeObliqueProjection( + camera->getProjectionMatrix(), reflView, waterHeight); + + // Update water renderer's reflection UBO with the reflected viewProj + waterRenderer->updateReflectionUBO(reflProj * reflView); + + // Fill the reflection per-frame UBO (same as normal but with reflected matrices) + GPUPerFrameData reflData = currentFrameData; + reflData.view = reflView; + reflData.projection = reflProj; + // Reflected camera position (Z is up) + glm::vec3 reflPos = camera->getPosition(); + reflPos.z = 2.0f * waterHeight - reflPos.z; + reflData.viewPos = glm::vec4(reflPos, 1.0f); + std::memcpy(reflPerFrameUBOMapped, &reflData, sizeof(GPUPerFrameData)); + + // Begin reflection render pass (clears to black; scene rendered if pipeline-compatible) + if (!waterRenderer->beginReflectionPass(currentCmd)) return; + + if (canRenderScene) { + // Render scene into reflection texture (sky + terrain + WMO only for perf) + if (skySystem) { + rendering::SkyParams skyParams; + skyParams.timeOfDay = (skySystem->getSkybox()) ? skySystem->getSkybox()->getTimeOfDay() : 12.0f; + if (lightingManager) { + const auto& lp = lightingManager->getLightingParams(); + skyParams.directionalDir = lp.directionalDir; + skyParams.sunColor = lp.diffuseColor; + skyParams.skyTopColor = lp.skyTopColor; + skyParams.skyMiddleColor = lp.skyMiddleColor; + skyParams.skyBand1Color = lp.skyBand1Color; + skyParams.skyBand2Color = lp.skyBand2Color; + skyParams.cloudDensity = lp.cloudDensity; + skyParams.fogDensity = lp.fogDensity; + skyParams.horizonGlow = lp.horizonGlow; } + // weatherIntensity left at default 0 for reflection pass (no game handler in scope) + skySystem->render(currentCmd, reflPerFrameDescSet, *camera, skyParams); } - if (sunDir.z > 0.0f) { - sunDir = -sunDir; + if (terrainRenderer && terrainEnabled) { + terrainRenderer->render(currentCmd, reflPerFrameDescSet, *camera); } - if (sunDir.z > -0.08f) { - sunDir.z = -0.08f; - sunDir = glm::normalize(sunDir); + if (wmoRenderer) { + wmoRenderer->render(currentCmd, reflPerFrameDescSet, *camera); } - glm::vec3 center = shadowCenterInitialized ? shadowCenter : characterPosition; - float halfExtent = kShadowHalfExtent; - glm::vec3 up(0.0f, 0.0f, 1.0f); - if (std::abs(glm::dot(sunDir, up)) > 0.99f) up = glm::vec3(0.0f, 1.0f, 0.0f); - glm::mat4 lightView = glm::lookAt(center - sunDir * kShadowLightDistance, center, up); - glm::mat4 lightProj = glm::ortho(-halfExtent, halfExtent, -halfExtent, halfExtent, - kShadowNearPlane, kShadowFarPlane); - - // WMO renderShadow needs a Shader reference β€” but it only uses setUniform("uModel", ...) - // We'll create a thin wrapper. Actually, WMO's renderShadow takes a Shader& and calls - // shadowShader.setUniform("uModel", ...). We need a Shader object wrapping our program. - // Instead, let's use the lower-level approach: WMO renderShadow uses the shader passed in. - // We need to temporarily wrap our GL program in a Shader object. - Shader shadowShaderWrapper; - shadowShaderWrapper.setProgram(shadowShaderProgram); - wmoRenderer->renderShadow(lightView, lightProj, shadowShaderWrapper); - shadowShaderWrapper.releaseProgram(); // Don't let wrapper delete our program } - // Render M2 doodads into shadow map (only instances within shadow frustum) + waterRenderer->endReflectionPass(currentCmd); +} + +void Renderer::renderShadowPass() { + if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return; + if (currentCmd == VK_NULL_HANDLE) return; + + // Compute and store light space matrix; write to per-frame UBO + lightSpaceMatrix = computeLightSpaceMatrix(); + // Zero matrix means character position isn't set yet β€” skip shadow pass entirely. + if (lightSpaceMatrix == glm::mat4(0.0f)) return; + uint32_t frame = vkCtx->getCurrentFrame(); + auto* ubo = reinterpret_cast(perFrameUBOMapped[frame]); + if (ubo) { + ubo->lightSpaceMatrix = lightSpaceMatrix; + ubo->shadowParams.x = shadowsEnabled ? 1.0f : 0.0f; + ubo->shadowParams.y = 0.8f; + } + + // Barrier 1: transition shadow map into writable depth layout. + VkImageMemoryBarrier b1{}; + b1.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + b1.oldLayout = shadowDepthLayout_; + b1.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + b1.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b1.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b1.srcAccessMask = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + ? VK_ACCESS_SHADER_READ_BIT + : 0; + b1.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + b1.image = shadowDepthImage; + b1.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; + VkPipelineStageFlags srcStage = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT + : VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + vkCmdPipelineBarrier(currentCmd, + srcStage, VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT, + 0, 0, nullptr, 0, nullptr, 1, &b1); + + // Begin shadow render pass + VkRenderPassBeginInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpInfo.renderPass = shadowRenderPass; + rpInfo.framebuffer = shadowFramebuffer; + rpInfo.renderArea = {{0, 0}, {SHADOW_MAP_SIZE, SHADOW_MAP_SIZE}}; + VkClearValue clear{}; + clear.depthStencil = {1.0f, 0}; + rpInfo.clearValueCount = 1; + rpInfo.pClearValues = &clear; + vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + + VkViewport vp{0, 0, static_cast(SHADOW_MAP_SIZE), static_cast(SHADOW_MAP_SIZE), 0.0f, 1.0f}; + vkCmdSetViewport(currentCmd, 0, 1, &vp); + VkRect2D sc{{0, 0}, {SHADOW_MAP_SIZE, SHADOW_MAP_SIZE}}; + vkCmdSetScissor(currentCmd, 0, 1, &sc); + + // Phase 7/8: render shadow casters + constexpr float kShadowCullRadius = 180.0f; // match kShadowHalfExtent + if (wmoRenderer) { + wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius); + } if (m2Renderer) { - glm::vec3 shadowCtr = shadowCenterInitialized ? shadowCenter : characterPosition; - m2Renderer->renderShadow(shadowShaderProgram, shadowCtr, kShadowHalfExtent); + m2Renderer->renderShadow(currentCmd, lightSpaceMatrix, globalTime, shadowCenter, kShadowCullRadius); } - - // Render characters into shadow map if (characterRenderer) { - // Character shadows need less caster bias to avoid "floating" away from feet. - glDisable(GL_POLYGON_OFFSET_FILL); - glCullFace(GL_BACK); - characterRenderer->renderShadow(lightSpaceMatrix); - glCullFace(GL_FRONT); - glEnable(GL_POLYGON_OFFSET_FILL); - glPolygonOffset(2.0f, 4.0f); + characterRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, kShadowCullRadius); } - // Restore state - glDisable(GL_POLYGON_OFFSET_FILL); - glCullFace(GL_BACK); + vkCmdEndRenderPass(currentCmd); - // Restore main viewport - glViewport(0, 0, fbWidth, fbHeight); - glBindFramebuffer(GL_FRAMEBUFFER, 0); - - // Distribute shadow map to all receivers - if (terrainRenderer) terrainRenderer->setShadowMap(shadowDepthTex, lightSpaceMatrix); - if (wmoRenderer) wmoRenderer->setShadowMap(shadowDepthTex, lightSpaceMatrix); - if (m2Renderer) m2Renderer->setShadowMap(shadowDepthTex, lightSpaceMatrix); - if (characterRenderer) characterRenderer->setShadowMap(shadowDepthTex, lightSpaceMatrix); + // Barrier 2: DEPTH_STENCIL_ATTACHMENT_OPTIMAL β†’ SHADER_READ_ONLY_OPTIMAL + VkImageMemoryBarrier b2{}; + b2.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + b2.oldLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + b2.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + b2.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b2.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b2.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + b2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + b2.image = shadowDepthImage; + b2.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; + vkCmdPipelineBarrier(currentCmd, + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &b2); + shadowDepthLayout_ = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; } } // namespace rendering diff --git a/src/rendering/sky_system.cpp b/src/rendering/sky_system.cpp index 3e96a290..9509cdc2 100644 --- a/src/rendering/sky_system.cpp +++ b/src/rendering/sky_system.cpp @@ -5,6 +5,7 @@ #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/camera.hpp" +#include "rendering/vk_context.hpp" #include "core/logger.hpp" namespace wowee { @@ -16,7 +17,7 @@ SkySystem::~SkySystem() { shutdown(); } -bool SkySystem::initialize() { +bool SkySystem::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { if (initialized_) { LOG_WARNING("SkySystem already initialized"); return true; @@ -24,39 +25,38 @@ bool SkySystem::initialize() { LOG_INFO("Initializing sky system"); - // Initialize skybox (authoritative) + // Skybox (Vulkan) skybox_ = std::make_unique(); - if (!skybox_->initialize()) { + if (!skybox_->initialize(ctx, perFrameLayout)) { LOG_ERROR("Failed to initialize skybox"); return false; } - // Initialize celestial bodies (sun + 2 moons) + // Celestial bodies β€” sun + 2 moons (Vulkan) celestial_ = std::make_unique(); - if (!celestial_->initialize()) { + if (!celestial_->initialize(ctx, perFrameLayout)) { LOG_ERROR("Failed to initialize celestial bodies"); return false; } - // Initialize procedural stars (FALLBACK only) + // Procedural stars β€” fallback / debug (Vulkan) starField_ = std::make_unique(); - if (!starField_->initialize()) { + if (!starField_->initialize(ctx, perFrameLayout)) { LOG_ERROR("Failed to initialize star field"); return false; } - // Default: disabled (skybox is authoritative) - starField_->setEnabled(false); + starField_->setEnabled(false); // Off by default; skybox is authoritative - // Initialize clouds + // Clouds (Vulkan) clouds_ = std::make_unique(); - if (!clouds_->initialize()) { + if (!clouds_->initialize(ctx, perFrameLayout)) { LOG_ERROR("Failed to initialize clouds"); return false; } - // Initialize lens flare + // Lens flare (Vulkan) lensFlare_ = std::make_unique(); - if (!lensFlare_->initialize()) { + if (!lensFlare_->initialize(ctx, perFrameLayout)) { LOG_ERROR("Failed to initialize lens flare"); return false; } @@ -73,12 +73,12 @@ void SkySystem::shutdown() { LOG_INFO("Shutting down sky system"); - // Shutdown components that have explicit shutdown methods - if (starField_) starField_->shutdown(); - if (celestial_) celestial_->shutdown(); - if (skybox_) skybox_->shutdown(); + if (lensFlare_) lensFlare_->shutdown(); + if (clouds_) clouds_->shutdown(); + if (starField_) starField_->shutdown(); + if (celestial_) celestial_->shutdown(); + if (skybox_) skybox_->shutdown(); - // Reset all (destructors handle cleanup for clouds/lensFlare) lensFlare_.reset(); clouds_.reset(); starField_.reset(); @@ -93,55 +93,57 @@ void SkySystem::update(float deltaTime) { return; } - // Update time-based systems - if (skybox_) skybox_->update(deltaTime); + if (skybox_) skybox_->update(deltaTime); if (celestial_) celestial_->update(deltaTime); if (starField_) starField_->update(deltaTime); + if (clouds_) clouds_->update(deltaTime); } -void SkySystem::render(const Camera& camera, const SkyParams& params) { +void SkySystem::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, + const Camera& camera, const SkyParams& params) { if (!initialized_) { return; } - // Render skybox first (authoritative, includes baked stars) + // --- Skybox (authoritative sky gradient, DBC-driven colors) --- if (skybox_) { - skybox_->render(camera, params.timeOfDay); + skybox_->render(cmd, perFrameSet, params); } - // Decide whether to render procedural stars + // --- Procedural stars (debug / fallback) --- bool renderProceduralStars = false; if (debugSkyMode_) { - // Debug mode: always show procedural stars renderProceduralStars = true; } else if (proceduralStarsEnabled_) { - // Fallback mode: show only if skybox doesn't have stars renderProceduralStars = !params.skyboxHasStars; } - // Render procedural stars (FALLBACK or DEBUG only) - if (renderProceduralStars && starField_) { - starField_->setEnabled(true); - starField_->render(camera, params.timeOfDay, params.cloudDensity, params.fogDensity); - } else if (starField_) { - starField_->setEnabled(false); + if (starField_) { + starField_->setEnabled(renderProceduralStars); + if (renderProceduralStars) { + const float cloudDensity = params.cloudDensity; + const float fogDensity = params.fogDensity; + starField_->render(cmd, perFrameSet, params.timeOfDay, cloudDensity, fogDensity); + } } - // Render celestial bodies (sun + White Lady + Blue Child) - // Pass gameTime for deterministic moon phases + // --- Celestial bodies (sun + White Lady + Blue Child) --- if (celestial_) { - celestial_->render(camera, params.timeOfDay, ¶ms.directionalDir, ¶ms.sunColor, params.gameTime); + celestial_->render(cmd, perFrameSet, params.timeOfDay, + ¶ms.directionalDir, ¶ms.sunColor, params.gameTime); } - // Render clouds + // --- Clouds (DBC-driven colors + sun lighting) --- if (clouds_) { - clouds_->render(camera, params.timeOfDay); + clouds_->render(cmd, perFrameSet, params); } - // Render lens flare (sun glow effect) + // --- Lens flare (attenuated by atmosphere) --- if (lensFlare_) { glm::vec3 sunPos = getSunPosition(params); - lensFlare_->render(camera, sunPos, params.timeOfDay); + lensFlare_->render(cmd, camera, sunPos, params.timeOfDay, + params.fogDensity, params.cloudDensity, + params.weatherIntensity); } } @@ -154,27 +156,19 @@ glm::vec3 SkySystem::getSunPosition(const SkyParams& params) const { if (sunDir.z < 0.0f) { sunDir = dir; } - glm::vec3 pos = sunDir * 800.0f; - return pos; + return sunDir * 800.0f; } - void SkySystem::setMoonPhaseCycling(bool enabled) { - if (celestial_) { - celestial_->setMoonPhaseCycling(enabled); - } + if (celestial_) celestial_->setMoonPhaseCycling(enabled); } void SkySystem::setWhiteLadyPhase(float phase) { - if (celestial_) { - celestial_->setMoonPhase(phase); // White Lady is primary moon - } + if (celestial_) celestial_->setMoonPhase(phase); } void SkySystem::setBlueChildPhase(float phase) { - if (celestial_) { - celestial_->setBlueChildPhase(phase); - } + if (celestial_) celestial_->setBlueChildPhase(phase); } float SkySystem::getWhiteLadyPhase() const { diff --git a/src/rendering/skybox.cpp b/src/rendering/skybox.cpp index 4833fb92..3e0e7de6 100644 --- a/src/rendering/skybox.cpp +++ b/src/rendering/skybox.cpp @@ -1,129 +1,193 @@ #include "rendering/skybox.hpp" -#include "rendering/shader.hpp" -#include "rendering/camera.hpp" +#include "rendering/sky_system.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" #include "core/logger.hpp" -#include #include #include -#include namespace wowee { namespace rendering { +// Push constant struct β€” must match skybox.frag.glsl layout +struct SkyPushConstants { + glm::vec4 zenithColor; // DBC skyTopColor + glm::vec4 midColor; // DBC skyMiddleColor + glm::vec4 horizonColor; // DBC skyBand1Color + glm::vec4 fogColor; // DBC skyBand2Color / fogColor blend + glm::vec4 sunDirAndTime; // xyz = sun direction, w = timeOfDay +}; +static_assert(sizeof(SkyPushConstants) == 80, "SkyPushConstants size mismatch"); + Skybox::Skybox() = default; Skybox::~Skybox() { shutdown(); } -bool Skybox::initialize() { +bool Skybox::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { LOG_INFO("Initializing skybox"); - // Create sky shader - skyShader = std::make_unique(); + vkCtx = ctx; - // Vertex shader - position-only skybox - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec3 aPos; + VkDevice device = vkCtx->getDevice(); - uniform mat4 view; - uniform mat4 projection; - - out vec3 WorldPos; - out float Altitude; - - void main() { - WorldPos = aPos; - - // Calculate altitude (0 at horizon, 1 at zenith) - Altitude = normalize(aPos).z; - - // Remove translation from view matrix (keep rotation only) - mat4 viewNoTranslation = mat4(mat3(view)); - - gl_Position = projection * viewNoTranslation * vec4(aPos, 1.0); - - // Ensure skybox is always at far plane - gl_Position = gl_Position.xyww; - } - )"; - - // Fragment shader - gradient sky with time of day - const char* fragmentShaderSource = R"( - #version 330 core - in vec3 WorldPos; - in float Altitude; - - uniform vec3 horizonColor; - uniform vec3 zenithColor; - uniform float timeOfDay; - - out vec4 FragColor; - - void main() { - // Smooth gradient from horizon to zenith - float t = pow(max(Altitude, 0.0), 0.5); // Curve for more interesting gradient - - vec3 skyColor = mix(horizonColor, zenithColor, t); - - // Add atmospheric scattering effect (more saturated near horizon) - float scattering = 1.0 - t * 0.3; - skyColor *= scattering; - - FragColor = vec4(skyColor, 1.0); - } - )"; - - if (!skyShader->loadFromSource(vertexShaderSource, fragmentShaderSource)) { - LOG_ERROR("Failed to create sky shader"); + // Load SPIR-V shaders + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/skybox.vert.spv")) { + LOG_ERROR("Failed to load skybox vertex shader"); return false; } - // Create sky dome mesh - createSkyDome(); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/skybox.frag.spv")) { + LOG_ERROR("Failed to load skybox fragment shader"); + return false; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // Push constant range: 5 x vec4 = 80 bytes + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(SkyPushConstants); // 80 bytes + + // Create pipeline layout with perFrameLayout (set 0) + push constants + pipelineLayout = createPipelineLayout(device, {perFrameLayout}, {pushRange}); + if (pipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create skybox pipeline layout"); + return false; + } + + // Fullscreen triangle β€” no vertex buffer, no vertex input. + // Dynamic viewport and scissor + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) // depth test on, write off, LEQUAL for far plane + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + // Shader modules can be freed after pipeline creation + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create skybox pipeline"); + return false; + } LOG_INFO("Skybox initialized"); return true; } -void Skybox::shutdown() { - destroySkyDome(); - skyShader.reset(); -} +void Skybox::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); -void Skybox::render(const Camera& camera, float time) { - if (!renderingEnabled || vao == 0 || !skyShader) { + if (pipeline != VK_NULL_HANDLE) { vkDestroyPipeline(device, pipeline, nullptr); pipeline = VK_NULL_HANDLE; } + + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/skybox.vert.spv")) { + LOG_ERROR("Skybox::recreatePipelines: failed to load vertex shader"); + return; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/skybox.frag.spv")) { + LOG_ERROR("Skybox::recreatePipelines: failed to load fragment shader"); + vertModule.destroy(); return; } - // Render skybox first (before terrain), with depth test set to LEQUAL - glDepthFunc(GL_LEQUAL); + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); - skyShader->use(); + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; - // Set uniforms - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); - skyShader->setUniform("view", view); - skyShader->setUniform("projection", projection); - skyShader->setUniform("timeOfDay", time); + vertModule.destroy(); + fragModule.destroy(); - // Get colors based on time of day - glm::vec3 horizon = getHorizonColor(time); - glm::vec3 zenith = getZenithColor(time); + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("Skybox::recreatePipelines: failed to create pipeline"); + } +} - skyShader->setUniform("horizonColor", horizon); - skyShader->setUniform("zenithColor", zenith); +void Skybox::shutdown() { + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline, nullptr); + pipeline = VK_NULL_HANDLE; + } + if (pipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout, nullptr); + pipelineLayout = VK_NULL_HANDLE; + } + } - // Render dome - glBindVertexArray(vao); - glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, nullptr); - glBindVertexArray(0); + vkCtx = nullptr; +} - // Restore depth function - glDepthFunc(GL_LESS); +void Skybox::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const SkyParams& params) { + if (pipeline == VK_NULL_HANDLE || !renderingEnabled) { + return; + } + + // Compute sun direction from directionalDir (light points toward scene, sun is opposite) + glm::vec3 sunDir = -glm::normalize(params.directionalDir); + + SkyPushConstants push{}; + push.zenithColor = glm::vec4(params.skyTopColor, 1.0f); + push.midColor = glm::vec4(params.skyMiddleColor, 1.0f); + push.horizonColor = glm::vec4(params.skyBand1Color, 1.0f); + push.fogColor = glm::vec4(params.skyBand2Color, 1.0f); + push.sunDirAndTime = glm::vec4(sunDir, params.timeOfDay); + + // Bind pipeline + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + + // Bind per-frame descriptor set (set 0 β€” camera UBO) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); + + // Push constants + vkCmdPushConstants(cmd, pipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); + + // Draw fullscreen triangle β€” no vertex buffer needed + vkCmdDraw(cmd, 3, 1, 0, 0); } void Skybox::update(float deltaTime) { @@ -145,190 +209,5 @@ void Skybox::setTimeOfDay(float time) { timeOfDay = time; } -void Skybox::createSkyDome() { - // Create an extended dome that goes below horizon for better coverage - const int rings = 16; // Vertical resolution - const int sectors = 32; // Horizontal resolution - const float radius = 2000.0f; // Large enough to cover view without looking curved - - std::vector vertices; - std::vector indices; - - // Generate vertices - extend slightly below horizon - const float minPhi = -M_PI / 12.0f; // Start 15Β° below horizon - const float maxPhi = M_PI / 2.0f; // End at zenith - for (int ring = 0; ring <= rings; ring++) { - float phi = minPhi + (maxPhi - minPhi) * (static_cast(ring) / rings); - float y = radius * std::sin(phi); - float ringRadius = radius * std::cos(phi); - - for (int sector = 0; sector <= sectors; sector++) { - float theta = (2.0f * M_PI) * (static_cast(sector) / sectors); - float x = ringRadius * std::cos(theta); - float z = ringRadius * std::sin(theta); - - // Position - vertices.push_back(x); - vertices.push_back(z); // Z up in WoW coordinates - vertices.push_back(y); - } - } - - // Generate indices - for (int ring = 0; ring < rings; ring++) { - for (int sector = 0; sector < sectors; sector++) { - int current = ring * (sectors + 1) + sector; - int next = current + sectors + 1; - - // Two triangles per quad - indices.push_back(current); - indices.push_back(next); - indices.push_back(current + 1); - - indices.push_back(current + 1); - indices.push_back(next); - indices.push_back(next + 1); - } - } - - indexCount = static_cast(indices.size()); - - // Create OpenGL buffers - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); - glGenBuffers(1, &ebo); - - glBindVertexArray(vao); - - // Upload vertex data - glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices.data(), GL_STATIC_DRAW); - - // Upload index data - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(uint32_t), indices.data(), GL_STATIC_DRAW); - - // Set vertex attributes (position only) - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - - glBindVertexArray(0); - - LOG_DEBUG("Sky dome created: ", (rings + 1) * (sectors + 1), " vertices, ", indexCount / 3, " triangles"); -} - -void Skybox::destroySkyDome() { - if (vao != 0) { - glDeleteVertexArrays(1, &vao); - vao = 0; - } - if (vbo != 0) { - glDeleteBuffers(1, &vbo); - vbo = 0; - } - if (ebo != 0) { - glDeleteBuffers(1, &ebo); - ebo = 0; - } -} - -glm::vec3 Skybox::getHorizonColor(float time) const { - // Time-based horizon colors - // 0-6: Night (dark blue) - // 6-8: Dawn (orange/pink) - // 8-16: Day (light blue) - // 16-18: Dusk (orange/red) - // 18-24: Night (dark blue) - - if (time < 5.0f || time >= 21.0f) { - // Night - dark blue/purple horizon - return glm::vec3(0.05f, 0.05f, 0.15f); - } - else if (time >= 5.0f && time < 7.0f) { - // Dawn - blend from night to orange - float t = (time - 5.0f) / 2.0f; - glm::vec3 night = glm::vec3(0.05f, 0.05f, 0.15f); - glm::vec3 dawn = glm::vec3(1.0f, 0.5f, 0.2f); - return glm::mix(night, dawn, t); - } - else if (time >= 7.0f && time < 9.0f) { - // Morning - blend from orange to blue - float t = (time - 7.0f) / 2.0f; - glm::vec3 dawn = glm::vec3(1.0f, 0.5f, 0.2f); - glm::vec3 day = glm::vec3(0.6f, 0.7f, 0.9f); - return glm::mix(dawn, day, t); - } - else if (time >= 9.0f && time < 17.0f) { - // Day - light blue horizon - return glm::vec3(0.6f, 0.7f, 0.9f); - } - else if (time >= 17.0f && time < 19.0f) { - // Dusk - blend from blue to orange/red - float t = (time - 17.0f) / 2.0f; - glm::vec3 day = glm::vec3(0.6f, 0.7f, 0.9f); - glm::vec3 dusk = glm::vec3(1.0f, 0.4f, 0.1f); - return glm::mix(day, dusk, t); - } - else { - // Evening - blend from orange to night - float t = (time - 19.0f) / 2.0f; - glm::vec3 dusk = glm::vec3(1.0f, 0.4f, 0.1f); - glm::vec3 night = glm::vec3(0.05f, 0.05f, 0.15f); - return glm::mix(dusk, night, t); - } -} - -glm::vec3 Skybox::getZenithColor(float time) const { - // Zenith (top of sky) colors - - if (time < 5.0f || time >= 21.0f) { - // Night - very dark blue, almost black - return glm::vec3(0.01f, 0.01f, 0.05f); - } - else if (time >= 5.0f && time < 7.0f) { - // Dawn - blend from night to light blue - float t = (time - 5.0f) / 2.0f; - glm::vec3 night = glm::vec3(0.01f, 0.01f, 0.05f); - glm::vec3 dawn = glm::vec3(0.3f, 0.4f, 0.7f); - return glm::mix(night, dawn, t); - } - else if (time >= 7.0f && time < 9.0f) { - // Morning - blend to bright blue - float t = (time - 7.0f) / 2.0f; - glm::vec3 dawn = glm::vec3(0.3f, 0.4f, 0.7f); - glm::vec3 day = glm::vec3(0.2f, 0.5f, 1.0f); - return glm::mix(dawn, day, t); - } - else if (time >= 9.0f && time < 17.0f) { - // Day - bright blue zenith - return glm::vec3(0.2f, 0.5f, 1.0f); - } - else if (time >= 17.0f && time < 19.0f) { - // Dusk - blend to darker blue - float t = (time - 17.0f) / 2.0f; - glm::vec3 day = glm::vec3(0.2f, 0.5f, 1.0f); - glm::vec3 dusk = glm::vec3(0.1f, 0.2f, 0.4f); - return glm::mix(day, dusk, t); - } - else { - // Evening - blend to night - float t = (time - 19.0f) / 2.0f; - glm::vec3 dusk = glm::vec3(0.1f, 0.2f, 0.4f); - glm::vec3 night = glm::vec3(0.01f, 0.01f, 0.05f); - return glm::mix(dusk, night, t); - } -} - -glm::vec3 Skybox::getSkyColor(float altitude, float time) const { - // Blend between horizon and zenith based on altitude - glm::vec3 horizon = getHorizonColor(time); - glm::vec3 zenith = getZenithColor(time); - - // Use power curve for more natural gradient - float t = std::pow(std::max(altitude, 0.0f), 0.5f); - - return glm::mix(horizon, zenith, t); -} - } // namespace rendering } // namespace wowee diff --git a/src/rendering/starfield.cpp b/src/rendering/starfield.cpp index 73955993..e472bc8d 100644 --- a/src/rendering/starfield.cpp +++ b/src/rendering/starfield.cpp @@ -1,11 +1,14 @@ #include "rendering/starfield.hpp" -#include "rendering/shader.hpp" -#include "rendering/camera.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" -#include -#include +#include #include #include +#include namespace wowee { namespace rendering { @@ -16,140 +19,222 @@ StarField::~StarField() { shutdown(); } -bool StarField::initialize() { +bool StarField::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { LOG_INFO("Initializing star field"); - // Create star shader - starShader = std::make_unique(); + vkCtx = ctx; + VkDevice device = vkCtx->getDevice(); - // Vertex shader - simple point rendering - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in float aBrightness; - layout (location = 2) in float aTwinklePhase; - - uniform mat4 view; - uniform mat4 projection; - uniform float time; - uniform float intensity; - - out float Brightness; - - void main() { - // Remove translation from view matrix (stars are infinitely far) - mat4 viewNoTranslation = mat4(mat3(view)); - - gl_Position = projection * viewNoTranslation * vec4(aPos, 1.0); - - // Twinkle effect (subtle brightness variation) - float twinkle = sin(time * 2.0 + aTwinklePhase) * 0.2 + 0.8; // 0.6 to 1.0 - - Brightness = aBrightness * twinkle * intensity; - - // Point size based on brightness - gl_PointSize = 2.0 + aBrightness * 2.0; // 2-4 pixels - } - )"; - - // Fragment shader - star color - const char* fragmentShaderSource = R"( - #version 330 core - in float Brightness; - - out vec4 FragColor; - - void main() { - // Circular point (not square) - vec2 coord = gl_PointCoord - vec2(0.5); - float dist = length(coord); - if (dist > 0.5) { - discard; - } - - // Soften edges - float alpha = smoothstep(0.5, 0.3, dist); - - // Star color (slightly blue-white) - vec3 starColor = vec3(0.9, 0.95, 1.0); - - FragColor = vec4(starColor * Brightness, alpha * Brightness); - } - )"; - - if (!starShader->loadFromSource(vertexShaderSource, fragmentShaderSource)) { - LOG_ERROR("Failed to create star shader"); + // Load SPIR-V shaders + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/starfield.vert.spv")) { + LOG_ERROR("Failed to load starfield vertex shader"); return false; } - // Generate random stars - generateStars(); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/starfield.frag.spv")) { + LOG_ERROR("Failed to load starfield fragment shader"); + return false; + } - // Create OpenGL buffers + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // Push constants: float time + float intensity = 8 bytes + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(float) * 2; // time, intensity + + // Pipeline layout: set 0 = per-frame UBO, push constants + pipelineLayout = createPipelineLayout(device, {perFrameLayout}, {pushRange}); + if (pipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create starfield pipeline layout"); + return false; + } + + // Vertex input: binding 0, stride = 5 * sizeof(float) + // location 0: vec3 pos (offset 0) + // location 1: float brightness (offset 12) + // location 2: float twinklePhase (offset 16) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + VkVertexInputAttributeDescription brightnessAttr{}; + brightnessAttr.location = 1; + brightnessAttr.binding = 0; + brightnessAttr.format = VK_FORMAT_R32_SFLOAT; + brightnessAttr.offset = 3 * sizeof(float); + + VkVertexInputAttributeDescription twinkleAttr{}; + twinkleAttr.location = 2; + twinkleAttr.binding = 0; + twinkleAttr.format = VK_FORMAT_R32_SFLOAT; + twinkleAttr.offset = 4 * sizeof(float); + + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, brightnessAttr, twinkleAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) // depth test, no write (stars behind sky) + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create starfield pipeline"); + return false; + } + + // Generate star positions and upload to GPU + generateStars(); createStarBuffers(); LOG_INFO("Star field initialized: ", starCount, " stars"); return true; } -void StarField::shutdown() { - destroyStarBuffers(); - starShader.reset(); - stars.clear(); -} +void StarField::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); -void StarField::render(const Camera& camera, float timeOfDay, - float cloudDensity, float fogDensity) { - if (!renderingEnabled || vao == 0 || !starShader || stars.empty()) { + if (pipeline != VK_NULL_HANDLE) { vkDestroyPipeline(device, pipeline, nullptr); pipeline = VK_NULL_HANDLE; } + + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/starfield.vert.spv")) { + LOG_ERROR("StarField::recreatePipelines: failed to load vertex shader"); + return; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/starfield.frag.spv")) { + LOG_ERROR("StarField::recreatePipelines: failed to load fragment shader"); + vertModule.destroy(); return; } - // Get star intensity based on time of day - float intensity = getStarIntensity(timeOfDay); + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); - // Reduce intensity based on cloud density and fog (more clouds/fog = fewer visible stars) + // Vertex input (same as initialize) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + VkVertexInputAttributeDescription brightnessAttr{}; + brightnessAttr.location = 1; + brightnessAttr.binding = 0; + brightnessAttr.format = VK_FORMAT_R32_SFLOAT; + brightnessAttr.offset = 3 * sizeof(float); + + VkVertexInputAttributeDescription twinkleAttr{}; + twinkleAttr.location = 2; + twinkleAttr.binding = 0; + twinkleAttr.format = VK_FORMAT_R32_SFLOAT; + twinkleAttr.offset = 4 * sizeof(float); + + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr, brightnessAttr, twinkleAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAdditive()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("StarField::recreatePipelines: failed to create pipeline"); + } +} + +void StarField::shutdown() { + destroyStarBuffers(); + + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline, nullptr); + pipeline = VK_NULL_HANDLE; + } + if (pipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout, nullptr); + pipelineLayout = VK_NULL_HANDLE; + } + } + + vkCtx = nullptr; + stars.clear(); +} + +void StarField::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, + float timeOfDay, float cloudDensity, float fogDensity) { + if (!renderingEnabled || pipeline == VK_NULL_HANDLE || vertexBuffer == VK_NULL_HANDLE + || stars.empty()) { + return; + } + + // Compute intensity from time of day then attenuate for clouds/fog + float intensity = getStarIntensity(timeOfDay); intensity *= (1.0f - glm::clamp(cloudDensity * 0.7f, 0.0f, 1.0f)); intensity *= (1.0f - glm::clamp(fogDensity * 0.3f, 0.0f, 1.0f)); - // Don't render if stars would be invisible if (intensity <= 0.01f) { return; } - // Enable blending for star glow - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + // Push constants: time and intensity + struct StarPushConstants { + float time; + float intensity; + }; + StarPushConstants push{twinkleTime, intensity}; - // Enable point sprites - glEnable(GL_PROGRAM_POINT_SIZE); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); - // Disable depth writing (stars are background) - glDepthMask(GL_FALSE); + // Bind per-frame descriptor set (set 0 β€” camera UBO with view/projection) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); - starShader->use(); + vkCmdPushConstants(cmd, pipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); - // Set uniforms - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); + // Bind vertex buffer + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, &offset); - starShader->setUniform("view", view); - starShader->setUniform("projection", projection); - starShader->setUniform("time", twinkleTime); - starShader->setUniform("intensity", intensity); - - // Render stars as points - glBindVertexArray(vao); - glDrawArrays(GL_POINTS, 0, starCount); - glBindVertexArray(0); - - // Restore state - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); - glDisable(GL_BLEND); + // Draw all stars as individual points + vkCmdDraw(cmd, static_cast(starCount), 1, 0, 0); } void StarField::update(float deltaTime) { - // Update twinkle animation twinkleTime += deltaTime; } @@ -157,30 +242,27 @@ void StarField::generateStars() { stars.clear(); stars.reserve(starCount); - // Random number generator std::random_device rd; std::mt19937 gen(rd()); - std::uniform_real_distribution phiDist(0.0f, M_PI / 2.0f); // 0 to 90 degrees (hemisphere) - std::uniform_real_distribution thetaDist(0.0f, 2.0f * M_PI); // 0 to 360 degrees - std::uniform_real_distribution brightnessDist(0.3f, 1.0f); // Varying brightness - std::uniform_real_distribution twinkleDist(0.0f, 2.0f * M_PI); // Random twinkle phase + std::uniform_real_distribution phiDist(0.0f, M_PI / 2.0f); // 0–90Β° (upper hemisphere) + std::uniform_real_distribution thetaDist(0.0f, 2.0f * M_PI); // 0–360Β° + std::uniform_real_distribution brightnessDist(0.3f, 1.0f); + std::uniform_real_distribution twinkleDist(0.0f, 2.0f * M_PI); const float radius = 900.0f; // Slightly larger than skybox for (int i = 0; i < starCount; i++) { Star star; - // Spherical coordinates (hemisphere) - float phi = phiDist(gen); // Elevation angle + float phi = phiDist(gen); // Elevation angle float theta = thetaDist(gen); // Azimuth angle - // Convert to Cartesian coordinates float x = radius * std::sin(phi) * std::cos(theta); float y = radius * std::sin(phi) * std::sin(theta); float z = radius * std::cos(phi); - star.position = glm::vec3(x, y, z); - star.brightness = brightnessDist(gen); + star.position = glm::vec3(x, y, z); + star.brightness = brightnessDist(gen); star.twinklePhase = twinkleDist(gen); stars.push_back(star); @@ -190,9 +272,9 @@ void StarField::generateStars() { } void StarField::createStarBuffers() { - // Prepare vertex data (position, brightness, twinkle phase) + // Interleaved vertex data: pos.x, pos.y, pos.z, brightness, twinklePhase std::vector vertexData; - vertexData.reserve(stars.size() * 5); // 3 pos + 1 brightness + 1 phase + vertexData.reserve(stars.size() * 5); for (const auto& star : stars) { vertexData.push_back(star.position.x); @@ -202,57 +284,36 @@ void StarField::createStarBuffers() { vertexData.push_back(star.twinklePhase); } - // Create OpenGL buffers - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); + VkDeviceSize bufferSize = vertexData.size() * sizeof(float); - glBindVertexArray(vao); + // Upload via staging buffer to GPU-local memory + AllocatedBuffer gpuBuf = uploadBuffer(*vkCtx, vertexData.data(), bufferSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); - // Upload vertex data - glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, vertexData.size() * sizeof(float), vertexData.data(), GL_STATIC_DRAW); - - // Set vertex attributes - // Position - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - - // Brightness - glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - - // Twinkle phase - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(4 * sizeof(float))); - glEnableVertexAttribArray(2); - - glBindVertexArray(0); + vertexBuffer = gpuBuf.buffer; + vertexAlloc = gpuBuf.allocation; } void StarField::destroyStarBuffers() { - if (vao != 0) { - glDeleteVertexArrays(1, &vao); - vao = 0; - } - if (vbo != 0) { - glDeleteBuffers(1, &vbo); - vbo = 0; + if (vkCtx && vertexBuffer != VK_NULL_HANDLE) { + vmaDestroyBuffer(vkCtx->getAllocator(), vertexBuffer, vertexAlloc); + vertexBuffer = VK_NULL_HANDLE; + vertexAlloc = VK_NULL_HANDLE; } } float StarField::getStarIntensity(float timeOfDay) const { - // Stars visible at night (fade in/out at dusk/dawn) - - // Full night: 20:00-4:00 + // Full night: 20:00–4:00 if (timeOfDay >= 20.0f || timeOfDay < 4.0f) { return 1.0f; } - // Fade in at dusk: 18:00-20:00 + // Fade in at dusk: 18:00–20:00 else if (timeOfDay >= 18.0f && timeOfDay < 20.0f) { - return (timeOfDay - 18.0f) / 2.0f; // 0 to 1 over 2 hours + return (timeOfDay - 18.0f) / 2.0f; // 0 β†’ 1 over 2 hours } - // Fade out at dawn: 4:00-6:00 + // Fade out at dawn: 4:00–6:00 else if (timeOfDay >= 4.0f && timeOfDay < 6.0f) { - return 1.0f - (timeOfDay - 4.0f) / 2.0f; // 1 to 0 over 2 hours + return 1.0f - (timeOfDay - 4.0f) / 2.0f; // 1 β†’ 0 over 2 hours } // Daytime: no stars else { diff --git a/src/rendering/swim_effects.cpp b/src/rendering/swim_effects.cpp index 46ef95d6..06cc82f5 100644 --- a/src/rendering/swim_effects.cpp +++ b/src/rendering/swim_effects.cpp @@ -2,11 +2,17 @@ #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" #include "rendering/water_renderer.hpp" -#include "rendering/shader.hpp" +#include "rendering/m2_renderer.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" #include #include #include +#include namespace wowee { namespace rendering { @@ -25,142 +31,400 @@ static float randFloat(float lo, float hi) { SwimEffects::SwimEffects() = default; SwimEffects::~SwimEffects() { shutdown(); } -bool SwimEffects::initialize() { +bool SwimEffects::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { LOG_INFO("Initializing swim effects"); - // --- Ripple/splash shader (small white spray droplets) --- - rippleShader = std::make_unique(); + vkCtx = ctx; + VkDevice device = vkCtx->getDevice(); - const char* rippleVS = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in float aSize; - layout (location = 2) in float aAlpha; + // ---- Vertex input: pos(vec3) + size(float) + alpha(float) = 5 floats, stride = 20 bytes ---- + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - uniform mat4 uView; - uniform mat4 uProjection; + std::vector attrs(3); + // location 0: vec3 position + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + // location 1: float size + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + // location 2: float alpha + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); - out float vAlpha; + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; - void main() { - gl_Position = uProjection * uView * vec4(aPos, 1.0); - gl_PointSize = aSize; - vAlpha = aAlpha; + // ---- Ripple pipeline ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv")) { + LOG_ERROR("Failed to load swim_ripple vertex shader"); + return false; } - )"; - - const char* rippleFS = R"( - #version 330 core - in float vAlpha; - out vec4 FragColor; - - void main() { - vec2 coord = gl_PointCoord - vec2(0.5); - float dist = length(coord); - if (dist > 0.5) discard; - // Soft circular splash droplet - float alpha = smoothstep(0.5, 0.2, dist) * vAlpha; - FragColor = vec4(0.85, 0.92, 1.0, alpha); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/swim_ripple.frag.spv")) { + LOG_ERROR("Failed to load swim_ripple fragment shader"); + return false; } - )"; - if (!rippleShader->loadFromSource(rippleVS, rippleFS)) { - LOG_ERROR("Failed to create ripple shader"); - return false; + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + ripplePipelineLayout = createPipelineLayout(device, {perFrameLayout}, {}); + if (ripplePipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create ripple pipeline layout"); + return false; + } + + ripplePipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(ripplePipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (ripplePipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create ripple pipeline"); + return false; + } } - // --- Bubble shader --- - bubbleShader = std::make_unique(); - - const char* bubbleVS = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in float aSize; - layout (location = 2) in float aAlpha; - - uniform mat4 uView; - uniform mat4 uProjection; - - out float vAlpha; - - void main() { - gl_Position = uProjection * uView * vec4(aPos, 1.0); - gl_PointSize = aSize; - vAlpha = aAlpha; + // ---- Bubble pipeline ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/swim_bubble.vert.spv")) { + LOG_ERROR("Failed to load swim_bubble vertex shader"); + return false; } - )"; - - const char* bubbleFS = R"( - #version 330 core - in float vAlpha; - out vec4 FragColor; - - void main() { - vec2 coord = gl_PointCoord - vec2(0.5); - float dist = length(coord); - if (dist > 0.5) discard; - // Bubble with highlight - float edge = smoothstep(0.5, 0.35, dist); - float hollow = smoothstep(0.25, 0.35, dist); - float bubble = edge * hollow; - // Specular highlight near top-left - float highlight = smoothstep(0.3, 0.0, length(coord - vec2(-0.12, -0.12))); - float alpha = (bubble * 0.6 + highlight * 0.4) * vAlpha; - vec3 color = vec3(0.7, 0.85, 1.0); - FragColor = vec4(color, alpha); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/swim_bubble.frag.spv")) { + LOG_ERROR("Failed to load swim_bubble fragment shader"); + return false; } - )"; - if (!bubbleShader->loadFromSource(bubbleVS, bubbleFS)) { - LOG_ERROR("Failed to create bubble shader"); - return false; + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + bubblePipelineLayout = createPipelineLayout(device, {perFrameLayout}, {}); + if (bubblePipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create bubble pipeline layout"); + return false; + } + + bubblePipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(bubblePipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (bubblePipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create bubble pipeline"); + return false; + } } - // --- Ripple VAO/VBO --- - glGenVertexArrays(1, &rippleVAO); - glGenBuffers(1, &rippleVBO); - glBindVertexArray(rippleVAO); - glBindBuffer(GL_ARRAY_BUFFER, rippleVBO); - // layout: vec3 pos, float size, float alpha (stride = 5 floats) - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(4 * sizeof(float))); - glEnableVertexAttribArray(2); - glBindVertexArray(0); + // ---- Insect pipeline (dark point sprites) ---- + { + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv")) { + LOG_ERROR("Failed to load insect vertex shader"); + return false; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/swim_insect.frag.spv")) { + LOG_ERROR("Failed to load insect fragment shader"); + return false; + } - // --- Bubble VAO/VBO --- - glGenVertexArrays(1, &bubbleVAO); - glGenBuffers(1, &bubbleVBO); - glBindVertexArray(bubbleVAO); - glBindBuffer(GL_ARRAY_BUFFER, bubbleVBO); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(4 * sizeof(float))); - glEnableVertexAttribArray(2); - glBindVertexArray(0); + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + insectPipelineLayout = createPipelineLayout(device, {perFrameLayout}, {}); + if (insectPipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create insect pipeline layout"); + return false; + } + + insectPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(false, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(insectPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (insectPipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create insect pipeline"); + return false; + } + } + + // ---- Create dynamic mapped vertex buffers ---- + rippleDynamicVBSize = MAX_RIPPLE_PARTICLES * 5 * sizeof(float); + { + AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), rippleDynamicVBSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + rippleDynamicVB = buf.buffer; + rippleDynamicVBAlloc = buf.allocation; + rippleDynamicVBAllocInfo = buf.info; + if (rippleDynamicVB == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create ripple dynamic vertex buffer"); + return false; + } + } + + bubbleDynamicVBSize = MAX_BUBBLE_PARTICLES * 5 * sizeof(float); + { + AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), bubbleDynamicVBSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + bubbleDynamicVB = buf.buffer; + bubbleDynamicVBAlloc = buf.allocation; + bubbleDynamicVBAllocInfo = buf.info; + if (bubbleDynamicVB == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create bubble dynamic vertex buffer"); + return false; + } + } + + insectDynamicVBSize = MAX_INSECT_PARTICLES * 5 * sizeof(float); + { + AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), insectDynamicVBSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + insectDynamicVB = buf.buffer; + insectDynamicVBAlloc = buf.allocation; + insectDynamicVBAllocInfo = buf.info; + if (insectDynamicVB == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create insect dynamic vertex buffer"); + return false; + } + } ripples.reserve(MAX_RIPPLE_PARTICLES); bubbles.reserve(MAX_BUBBLE_PARTICLES); + insects.reserve(MAX_INSECT_PARTICLES); rippleVertexData.reserve(MAX_RIPPLE_PARTICLES * 5); bubbleVertexData.reserve(MAX_BUBBLE_PARTICLES * 5); + insectVertexData.reserve(MAX_INSECT_PARTICLES * 5); LOG_INFO("Swim effects initialized"); return true; } void SwimEffects::shutdown() { - if (rippleVAO) { glDeleteVertexArrays(1, &rippleVAO); rippleVAO = 0; } - if (rippleVBO) { glDeleteBuffers(1, &rippleVBO); rippleVBO = 0; } - if (bubbleVAO) { glDeleteVertexArrays(1, &bubbleVAO); bubbleVAO = 0; } - if (bubbleVBO) { glDeleteBuffers(1, &bubbleVBO); bubbleVBO = 0; } - rippleShader.reset(); - bubbleShader.reset(); + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + if (ripplePipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, ripplePipeline, nullptr); + ripplePipeline = VK_NULL_HANDLE; + } + if (ripplePipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, ripplePipelineLayout, nullptr); + ripplePipelineLayout = VK_NULL_HANDLE; + } + if (rippleDynamicVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, rippleDynamicVB, rippleDynamicVBAlloc); + rippleDynamicVB = VK_NULL_HANDLE; + rippleDynamicVBAlloc = VK_NULL_HANDLE; + } + + if (bubblePipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, bubblePipeline, nullptr); + bubblePipeline = VK_NULL_HANDLE; + } + if (bubblePipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, bubblePipelineLayout, nullptr); + bubblePipelineLayout = VK_NULL_HANDLE; + } + if (bubbleDynamicVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, bubbleDynamicVB, bubbleDynamicVBAlloc); + bubbleDynamicVB = VK_NULL_HANDLE; + bubbleDynamicVBAlloc = VK_NULL_HANDLE; + } + + if (insectPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, insectPipeline, nullptr); + insectPipeline = VK_NULL_HANDLE; + } + if (insectPipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, insectPipelineLayout, nullptr); + insectPipelineLayout = VK_NULL_HANDLE; + } + if (insectDynamicVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, insectDynamicVB, insectDynamicVBAlloc); + insectDynamicVB = VK_NULL_HANDLE; + insectDynamicVBAlloc = VK_NULL_HANDLE; + } + } + + vkCtx = nullptr; ripples.clear(); bubbles.clear(); + insects.clear(); +} + +void SwimEffects::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + + // Destroy old pipelines (NOT layouts) + if (ripplePipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, ripplePipeline, nullptr); + ripplePipeline = VK_NULL_HANDLE; + } + if (bubblePipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, bubblePipeline, nullptr); + bubblePipeline = VK_NULL_HANDLE; + } + if (insectPipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, insectPipeline, nullptr); + insectPipeline = VK_NULL_HANDLE; + } + + // Shared vertex input: pos(vec3) + size(float) + alpha(float) = 5 floats + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 5 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(3); + attrs[0].location = 0; + attrs[0].binding = 0; + attrs[0].format = VK_FORMAT_R32G32B32_SFLOAT; + attrs[0].offset = 0; + attrs[1].location = 1; + attrs[1].binding = 0; + attrs[1].format = VK_FORMAT_R32_SFLOAT; + attrs[1].offset = 3 * sizeof(float); + attrs[2].location = 2; + attrs[2].binding = 0; + attrs[2].format = VK_FORMAT_R32_SFLOAT; + attrs[2].offset = 4 * sizeof(float); + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + // ---- Rebuild ripple pipeline ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/swim_ripple.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + ripplePipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(ripplePipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } + + // ---- Rebuild bubble pipeline ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/swim_bubble.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/swim_bubble.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + bubblePipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(bubblePipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } + + // ---- Rebuild insect pipeline ---- + { + VkShaderModule vertModule; + vertModule.loadFromFile(device, "assets/shaders/swim_ripple.vert.spv"); + VkShaderModule fragModule; + fragModule.loadFromFile(device, "assets/shaders/swim_insect.frag.spv"); + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + insectPipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(false, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(insectPipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + } } void SwimEffects::spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, float waterH) { @@ -187,6 +451,27 @@ void SwimEffects::spawnRipple(const glm::vec3& pos, const glm::vec3& moveDir, fl ripples.push_back(p); } +void SwimEffects::spawnFootSplash(const glm::vec3& footPos, float waterH) { + // Small burst of splash droplets at foot position (for wading) + constexpr int splashCount = 5; + for (int i = 0; i < splashCount; ++i) { + if (static_cast(ripples.size()) >= MAX_RIPPLE_PARTICLES) break; + Particle p; + float ox = randFloat(-0.4f, 0.4f); + float oy = randFloat(-0.4f, 0.4f); + p.position = glm::vec3(footPos.x + ox, footPos.y + oy, waterH + 0.1f); + // Small upward spray in random horizontal direction + float angle = randFloat(0.0f, 6.2832f); + float speed = randFloat(0.8f, 2.0f); + p.velocity = glm::vec3(std::cos(angle) * speed, std::sin(angle) * speed, randFloat(1.0f, 2.5f)); + p.lifetime = 0.0f; + p.maxLifetime = randFloat(0.3f, 0.6f); + p.size = randFloat(2.0f, 4.0f); + p.alpha = randFloat(0.4f, 0.7f); + ripples.push_back(p); + } +} + void SwimEffects::spawnBubble(const glm::vec3& pos, float /*waterH*/) { if (static_cast(bubbles.size()) >= MAX_BUBBLE_PARTICLES) return; @@ -205,6 +490,31 @@ void SwimEffects::spawnBubble(const glm::vec3& pos, float /*waterH*/) { bubbles.push_back(p); } +void SwimEffects::spawnInsect(const glm::vec3& vegPos) { + if (static_cast(insects.size()) >= MAX_INSECT_PARTICLES) return; + + InsectParticle p; + p.orbitCenter = vegPos; + p.phase = randFloat(0.0f, 6.2832f); + p.orbitRadius = randFloat(0.5f, 2.0f); + p.orbitSpeed = randFloat(1.5f, 4.0f); + p.heightOffset = randFloat(0.5f, 3.0f); + p.lifetime = 0.0f; + p.maxLifetime = randFloat(3.0f, 8.0f); + p.size = randFloat(2.0f, 3.0f); + p.alpha = randFloat(0.6f, 0.9f); + + // Start at orbit position + float angle = p.phase; + p.position = vegPos + glm::vec3( + std::cos(angle) * p.orbitRadius, + std::sin(angle) * p.orbitRadius, + p.heightOffset + ); + + insects.push_back(p); +} + void SwimEffects::update(const Camera& camera, const CameraController& cc, const WaterRenderer& water, float deltaTime) { glm::vec3 camPos = camera.getPosition(); @@ -242,7 +552,8 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc, } } else { rippleSpawnAccum = 0.0f; - ripples.clear(); + // Don't clear ripples β€” foot splash particles are added while wading + // (not swimming) and need to live out their lifetime. } // --- Bubble spawning --- @@ -259,6 +570,23 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc, bubbles.clear(); } + // --- Insect spawning near water vegetation --- + if (m2Renderer) { + auto vegPositions = m2Renderer->getWaterVegetationPositions(camPos, 60.0f); + if (!vegPositions.empty()) { + // Spawn rate: ~4/sec per nearby vegetation cluster (capped by MAX_INSECT_PARTICLES) + float spawnRate = std::min(static_cast(vegPositions.size()) * 4.0f, 20.0f); + insectSpawnAccum += spawnRate * deltaTime; + while (insectSpawnAccum >= 1.0f && static_cast(insects.size()) < MAX_INSECT_PARTICLES) { + // Pick a random vegetation position to spawn near + int idx = static_cast(randFloat(0.0f, static_cast(vegPositions.size()) - 0.01f)); + spawnInsect(vegPositions[idx]); + insectSpawnAccum -= 1.0f; + } + if (insectSpawnAccum > 2.0f) insectSpawnAccum = 0.0f; + } + } + // --- Update ripples (splash droplets with gravity) --- for (int i = static_cast(ripples.size()) - 1; i >= 0; --i) { auto& p = ripples[i]; @@ -308,6 +636,42 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc, } } + // --- Update insects (erratic orbiting flight) --- + for (int i = static_cast(insects.size()) - 1; i >= 0; --i) { + auto& p = insects[i]; + p.lifetime += deltaTime; + if (p.lifetime >= p.maxLifetime) { + insects[i] = insects.back(); + insects.pop_back(); + continue; + } + + float t = p.lifetime / p.maxLifetime; + float time = p.lifetime * p.orbitSpeed + p.phase; + + // Erratic looping: primary orbit + secondary wobble + float primaryAngle = time; + float wobbleAngle = std::sin(time * 2.3f) * 0.8f; + float radius = p.orbitRadius + std::sin(time * 1.7f) * 0.3f; + + float heightWobble = std::sin(time * 1.1f + p.phase * 0.5f) * 0.5f; + + p.position = p.orbitCenter + glm::vec3( + std::cos(primaryAngle + wobbleAngle) * radius, + std::sin(primaryAngle + wobbleAngle) * radius, + p.heightOffset + heightWobble + ); + + // Fade in/out + if (t < 0.1f) { + p.alpha = glm::mix(0.0f, 0.8f, t / 0.1f); + } else if (t > 0.85f) { + p.alpha = glm::mix(0.8f, 0.0f, (t - 0.85f) / 0.15f); + } else { + p.alpha = 0.8f; + } + } + // --- Build vertex data --- rippleVertexData.clear(); for (const auto& p : ripples) { @@ -326,54 +690,63 @@ void SwimEffects::update(const Camera& camera, const CameraController& cc, bubbleVertexData.push_back(p.size); bubbleVertexData.push_back(p.alpha); } + + insectVertexData.clear(); + for (const auto& p : insects) { + insectVertexData.push_back(p.position.x); + insectVertexData.push_back(p.position.y); + insectVertexData.push_back(p.position.z); + insectVertexData.push_back(p.size); + insectVertexData.push_back(p.alpha); + } } -void SwimEffects::render(const Camera& camera) { - if (rippleVertexData.empty() && bubbleVertexData.empty()) return; +void SwimEffects::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (rippleVertexData.empty() && bubbleVertexData.empty() && insectVertexData.empty()) return; - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - glDepthMask(GL_FALSE); - glEnable(GL_PROGRAM_POINT_SIZE); - - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); + VkDeviceSize offset = 0; // --- Render ripples (splash droplets above water surface) --- - if (!rippleVertexData.empty() && rippleShader) { - rippleShader->use(); - rippleShader->setUniform("uView", view); - rippleShader->setUniform("uProjection", projection); + if (!rippleVertexData.empty() && ripplePipeline != VK_NULL_HANDLE) { + VkDeviceSize uploadSize = rippleVertexData.size() * sizeof(float); + if (rippleDynamicVBAllocInfo.pMappedData) { + std::memcpy(rippleDynamicVBAllocInfo.pMappedData, rippleVertexData.data(), uploadSize); + } - glBindVertexArray(rippleVAO); - glBindBuffer(GL_ARRAY_BUFFER, rippleVBO); - glBufferData(GL_ARRAY_BUFFER, - rippleVertexData.size() * sizeof(float), - rippleVertexData.data(), - GL_DYNAMIC_DRAW); - glDrawArrays(GL_POINTS, 0, static_cast(rippleVertexData.size() / 5)); - glBindVertexArray(0); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, ripplePipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, ripplePipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindVertexBuffers(cmd, 0, 1, &rippleDynamicVB, &offset); + vkCmdDraw(cmd, static_cast(rippleVertexData.size() / 5), 1, 0, 0); } // --- Render bubbles --- - if (!bubbleVertexData.empty() && bubbleShader) { - bubbleShader->use(); - bubbleShader->setUniform("uView", view); - bubbleShader->setUniform("uProjection", projection); + if (!bubbleVertexData.empty() && bubblePipeline != VK_NULL_HANDLE) { + VkDeviceSize uploadSize = bubbleVertexData.size() * sizeof(float); + if (bubbleDynamicVBAllocInfo.pMappedData) { + std::memcpy(bubbleDynamicVBAllocInfo.pMappedData, bubbleVertexData.data(), uploadSize); + } - glBindVertexArray(bubbleVAO); - glBindBuffer(GL_ARRAY_BUFFER, bubbleVBO); - glBufferData(GL_ARRAY_BUFFER, - bubbleVertexData.size() * sizeof(float), - bubbleVertexData.data(), - GL_DYNAMIC_DRAW); - glDrawArrays(GL_POINTS, 0, static_cast(bubbleVertexData.size() / 5)); - glBindVertexArray(0); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, bubblePipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, bubblePipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindVertexBuffers(cmd, 0, 1, &bubbleDynamicVB, &offset); + vkCmdDraw(cmd, static_cast(bubbleVertexData.size() / 5), 1, 0, 0); } - glDisable(GL_BLEND); - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); + // --- Render insects --- + if (!insectVertexData.empty() && insectPipeline != VK_NULL_HANDLE) { + VkDeviceSize uploadSize = insectVertexData.size() * sizeof(float); + if (insectDynamicVBAllocInfo.pMappedData) { + std::memcpy(insectDynamicVBAllocInfo.pMappedData, insectVertexData.data(), uploadSize); + } + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, insectPipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, insectPipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindVertexBuffers(cmd, 0, 1, &insectDynamicVB, &offset); + vkCmdDraw(cmd, static_cast(insectVertexData.size() / 5), 1, 0, 0); + } } } // namespace rendering diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 91f04b54..4b5c0b7c 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -26,6 +27,26 @@ namespace rendering { namespace { +int computeTerrainWorkerCount() { + const char* raw = std::getenv("WOWEE_TERRAIN_WORKERS"); + if (raw && *raw) { + char* end = nullptr; + unsigned long long forced = std::strtoull(raw, &end, 10); + if (end != raw && forced > 0) { + return static_cast(forced); + } + } + + unsigned hc = std::thread::hardware_concurrency(); + if (hc > 0) { + // Terrain streaming should leave CPU room for render/update threads. + const unsigned availableCores = (hc > 1u) ? (hc - 1u) : 1u; + const unsigned targetWorkers = std::max(2u, availableCores / 2u); + return static_cast(targetWorkers); + } + return 2; // Fallback +} + bool decodeLayerAlpha(const pipeline::MapChunk& chunk, size_t layerIdx, std::vector& outAlpha) { if (layerIdx >= chunk.layers.size()) return false; const auto& layer = chunk.layers[layerIdx]; @@ -128,15 +149,9 @@ bool TerrainManager::initialize(pipeline::AssetManager* assets, TerrainRenderer* LOG_INFO("Terrain tile cache budget: ", tileCacheBudgetBytes_ / (1024 * 1024), " MB (dynamic)"); // Start background worker pool (dynamic: scales with available cores) - // Use 75% of logical cores for decompression, leaving headroom for render/OS + // Keep defaults moderate; env override can increase if streaming is bottlenecked. workerRunning.store(true); - unsigned hc = std::thread::hardware_concurrency(); - if (hc > 0) { - unsigned targetWorkers = std::max(6u, (hc * 3) / 4); // 75% of cores, minimum 6 - workerCount = static_cast(targetWorkers); - } else { - workerCount = 6; // Fallback - } + workerCount = computeTerrainWorkerCount(); workerThreads.reserve(workerCount); for (int i = 0; i < workerCount; i++) { workerThreads.emplace_back(&TerrainManager::workerLoop, this); @@ -726,7 +741,7 @@ void TerrainManager::finalizeTile(const std::shared_ptr& pending) { if (m2Renderer && assetManager) { // Always pass the latest asset manager. initialize() is idempotent and updates // the pointer even when the renderer was initialized earlier without assets. - m2Renderer->initialize(assetManager); + m2Renderer->initialize(nullptr, VK_NULL_HANDLE, assetManager); // Upload M2 models immediately (batching was causing hangs) // The 5ms time budget in processReadyTiles() limits the spike @@ -768,7 +783,7 @@ void TerrainManager::finalizeTile(const std::shared_ptr& pending) { // Upload WMO models to GPU and create instances if (wmoRenderer && assetManager) { // WMORenderer may be initialized before assets are ready; always re-pass assets. - wmoRenderer->initialize(assetManager); + wmoRenderer->initialize(nullptr, VK_NULL_HANDLE, assetManager); int loadedWMOs = 0; int loadedLiquids = 0; @@ -926,12 +941,10 @@ void TerrainManager::processReadyTiles() { if (pending) { TileCoord coord = pending->coord; - auto tileStart = std::chrono::high_resolution_clock::now(); finalizeTile(pending); - auto tileEnd = std::chrono::high_resolution_clock::now(); - float tileTimeMs = std::chrono::duration(tileEnd - tileStart).count(); + auto now = std::chrono::high_resolution_clock::now(); { std::lock_guard lock(queueMutex); @@ -940,7 +953,7 @@ void TerrainManager::processReadyTiles() { processed++; // Check if we've exceeded time budget - float elapsedMs = std::chrono::duration(tileEnd - startTime).count(); + float elapsedMs = std::chrono::duration(now - startTime).count(); if (elapsedMs >= timeBudgetMs) { if (processed > 1) { LOG_DEBUG("Processed ", processed, " tiles in ", elapsedMs, "ms (budget: ", timeBudgetMs, "ms)"); @@ -1183,13 +1196,7 @@ void TerrainManager::unloadAll() { // Restart worker threads so streaming can resume (dynamic: scales with available cores) // Use 75% of logical cores for decompression, leaving headroom for render/OS workerRunning.store(true); - unsigned hc = std::thread::hardware_concurrency(); - if (hc > 0) { - unsigned targetWorkers = std::max(6u, (hc * 3) / 4); // 75% of cores, minimum 6 - workerCount = static_cast(targetWorkers); - } else { - workerCount = 6; // Fallback - } + workerCount = computeTerrainWorkerCount(); workerThreads.reserve(workerCount); for (int i = 0; i < workerCount; i++) { workerThreads.emplace_back(&TerrainManager::workerLoop, this); diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 4f2814bb..208ae25b 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -1,94 +1,320 @@ #include "rendering/terrain_renderer.hpp" -#include "rendering/texture.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" +#include "rendering/vk_frame_data.hpp" #include "rendering/frustum.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "core/logger.hpp" #include #include -#include #include #include #include +#include namespace wowee { namespace rendering { -TerrainRenderer::TerrainRenderer() { +namespace { +size_t envSizeMBOrDefault(const char* name, size_t defMb) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defMb; + char* end = nullptr; + unsigned long long mb = std::strtoull(raw, &end, 10); + if (end == raw || mb == 0) return defMb; + return static_cast(mb); } +} // namespace + +// Matches set 1 binding 7 in terrain.frag.glsl +struct TerrainParamsUBO { + int32_t layerCount; + int32_t hasLayer1; + int32_t hasLayer2; + int32_t hasLayer3; +}; + +TerrainRenderer::TerrainRenderer() = default; TerrainRenderer::~TerrainRenderer() { shutdown(); } -bool TerrainRenderer::initialize(pipeline::AssetManager* assets) { +bool TerrainRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* assets) { + vkCtx = ctx; assetManager = assets; - if (!assetManager) { - LOG_ERROR("Asset manager is null"); + if (!vkCtx || !assetManager) { + LOG_ERROR("TerrainRenderer: null context or asset manager"); return false; } - LOG_INFO("Initializing terrain renderer"); + LOG_INFO("Initializing terrain renderer (Vulkan)"); + VkDevice device = vkCtx->getDevice(); - // Load terrain shader - shader = std::make_unique(); - if (!shader->loadFromFile("assets/shaders/terrain.vert", "assets/shaders/terrain.frag")) { - LOG_ERROR("Failed to load terrain shader"); + // --- Create material descriptor set layout (set 1) --- + // bindings 0-6: combined image samplers (base + 3 layer + 3 alpha) + // binding 7: uniform buffer (TerrainParams) + std::vector materialBindings(8); + for (uint32_t i = 0; i < 7; i++) { + materialBindings[i] = {}; + materialBindings[i].binding = i; + materialBindings[i].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + materialBindings[i].descriptorCount = 1; + materialBindings[i].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + } + materialBindings[7] = {}; + materialBindings[7].binding = 7; + materialBindings[7].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + materialBindings[7].descriptorCount = 1; + materialBindings[7].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + materialSetLayout = createDescriptorSetLayout(device, materialBindings); + if (!materialSetLayout) { + LOG_ERROR("TerrainRenderer: failed to create material set layout"); return false; } - // Create default white texture for fallback + // --- Create descriptor pool --- + VkDescriptorPoolSize poolSizes[] = { + { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_MATERIAL_SETS * 7 }, + { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, MAX_MATERIAL_SETS }, + }; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = MAX_MATERIAL_SETS; + poolInfo.poolSizeCount = 2; + poolInfo.pPoolSizes = poolSizes; + + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &materialDescPool) != VK_SUCCESS) { + LOG_ERROR("TerrainRenderer: failed to create descriptor pool"); + return false; + } + + // --- Create pipeline layout --- + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(GPUPushConstants); + + std::vector setLayouts = { perFrameLayout, materialSetLayout }; + pipelineLayout = createPipelineLayout(device, setLayouts, { pushRange }); + if (!pipelineLayout) { + LOG_ERROR("TerrainRenderer: failed to create pipeline layout"); + return false; + } + + // --- Load shaders --- + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/terrain.vert.spv")) { + LOG_ERROR("TerrainRenderer: failed to load vertex shader"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/terrain.frag.spv")) { + LOG_ERROR("TerrainRenderer: failed to load fragment shader"); + return false; + } + + // --- Vertex input --- + VkVertexInputBindingDescription vertexBinding{}; + vertexBinding.binding = 0; + vertexBinding.stride = sizeof(pipeline::TerrainVertex); + vertexBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector vertexAttribs(4); + vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, position)) }; + vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, normal)) }; + vertexAttribs[2] = { 2, 0, VK_FORMAT_R32G32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, texCoord)) }; + vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, layerUV)) }; + + // --- Build fill pipeline --- + VkRenderPass mainPass = vkCtx->getImGuiRenderPass(); + + pipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + if (!pipeline) { + LOG_ERROR("TerrainRenderer: failed to create fill pipeline"); + vertShader.destroy(); + fragShader.destroy(); + return false; + } + + // --- Build wireframe pipeline --- + wireframePipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_LINE, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + if (!wireframePipeline) { + LOG_WARNING("TerrainRenderer: wireframe pipeline not available"); + } + + vertShader.destroy(); + fragShader.destroy(); + + // --- Create fallback textures --- + whiteTexture = std::make_unique(); uint8_t whitePixel[4] = {255, 255, 255, 255}; - glGenTextures(1, &whiteTexture); - glBindTexture(GL_TEXTURE_2D, whiteTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, whitePixel); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glBindTexture(GL_TEXTURE_2D, 0); + whiteTexture->upload(*vkCtx, whitePixel, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + whiteTexture->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); - // Create default opaque alpha texture for terrain layer masks + opaqueAlphaTexture = std::make_unique(); uint8_t opaqueAlpha = 255; - glGenTextures(1, &opaqueAlphaTexture); - glBindTexture(GL_TEXTURE_2D, opaqueAlphaTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, 1, 1, 0, GL_RED, GL_UNSIGNED_BYTE, &opaqueAlpha); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glBindTexture(GL_TEXTURE_2D, 0); + opaqueAlphaTexture->upload(*vkCtx, &opaqueAlpha, 1, 1, VK_FORMAT_R8_UNORM, false); + opaqueAlphaTexture->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE); + textureCacheBudgetBytes_ = + envSizeMBOrDefault("WOWEE_TERRAIN_TEX_CACHE_MB", 4096) * 1024ull * 1024ull; + LOG_INFO("Terrain texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB"); - LOG_INFO("Terrain renderer initialized"); + LOG_INFO("Terrain renderer initialized (Vulkan)"); return true; } +void TerrainRenderer::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + + // Destroy old pipelines (keep layouts) + if (pipeline) { vkDestroyPipeline(device, pipeline, nullptr); pipeline = VK_NULL_HANDLE; } + if (wireframePipeline) { vkDestroyPipeline(device, wireframePipeline, nullptr); wireframePipeline = VK_NULL_HANDLE; } + + // Load shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/terrain.vert.spv")) { + LOG_ERROR("TerrainRenderer::recreatePipelines: failed to load vertex shader"); + return; + } + if (!fragShader.loadFromFile(device, "assets/shaders/terrain.frag.spv")) { + LOG_ERROR("TerrainRenderer::recreatePipelines: failed to load fragment shader"); + vertShader.destroy(); + return; + } + + // Vertex input (same as initialize) + VkVertexInputBindingDescription vertexBinding{}; + vertexBinding.binding = 0; + vertexBinding.stride = sizeof(pipeline::TerrainVertex); + vertexBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector vertexAttribs(4); + vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, position)) }; + vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, normal)) }; + vertexAttribs[2] = { 2, 0, VK_FORMAT_R32G32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, texCoord)) }; + vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32_SFLOAT, + static_cast(offsetof(pipeline::TerrainVertex, layerUV)) }; + + VkRenderPass mainPass = vkCtx->getImGuiRenderPass(); + + // Rebuild fill pipeline + pipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + if (!pipeline) { + LOG_ERROR("TerrainRenderer::recreatePipelines: failed to create fill pipeline"); + } + + // Rebuild wireframe pipeline + wireframePipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_LINE, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + if (!wireframePipeline) { + LOG_WARNING("TerrainRenderer::recreatePipelines: wireframe pipeline not available"); + } + + vertShader.destroy(); + fragShader.destroy(); +} + void TerrainRenderer::shutdown() { LOG_INFO("Shutting down terrain renderer"); + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + vkDeviceWaitIdle(device); + clear(); - // Delete white texture - if (whiteTexture) { - glDeleteTextures(1, &whiteTexture); - whiteTexture = 0; - } - if (opaqueAlphaTexture) { - glDeleteTextures(1, &opaqueAlphaTexture); - opaqueAlphaTexture = 0; - } - - // Delete cached textures for (auto& [path, entry] : textureCache) { - GLuint texId = entry.id; - if (texId != 0 && texId != whiteTexture) { - glDeleteTextures(1, &texId); - } + if (entry.texture) entry.texture->destroy(device, allocator); } textureCache.clear(); textureCacheBytes_ = 0; textureCacheCounter_ = 0; + failedTextureCache_.clear(); + loggedTextureLoadFails_.clear(); + textureBudgetRejectWarnings_ = 0; - shader.reset(); + if (whiteTexture) { whiteTexture->destroy(device, allocator); whiteTexture.reset(); } + if (opaqueAlphaTexture) { opaqueAlphaTexture->destroy(device, allocator); opaqueAlphaTexture.reset(); } + + if (pipeline) { vkDestroyPipeline(device, pipeline, nullptr); pipeline = VK_NULL_HANDLE; } + if (wireframePipeline) { vkDestroyPipeline(device, wireframePipeline, nullptr); wireframePipeline = VK_NULL_HANDLE; } + if (pipelineLayout) { vkDestroyPipelineLayout(device, pipelineLayout, nullptr); pipelineLayout = VK_NULL_HANDLE; } + if (materialDescPool) { vkDestroyDescriptorPool(device, materialDescPool, nullptr); materialDescPool = VK_NULL_HANDLE; } + if (materialSetLayout) { vkDestroyDescriptorSetLayout(device, materialSetLayout, nullptr); materialSetLayout = VK_NULL_HANDLE; } + + vkCtx = nullptr; } bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh, @@ -96,61 +322,82 @@ bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh, int tileX, int tileY) { LOG_DEBUG("Loading terrain mesh: ", mesh.validChunkCount, " chunks"); - // Upload each chunk to GPU for (int y = 0; y < 16; y++) { for (int x = 0; x < 16; x++) { const auto& chunk = mesh.getChunk(x, y); - - if (!chunk.isValid()) { - continue; - } + if (!chunk.isValid()) continue; TerrainChunkGPU gpuChunk = uploadChunk(chunk); - if (!gpuChunk.isValid()) { LOG_WARNING("Failed to upload chunk [", x, ",", y, "]"); continue; } - // Calculate bounding sphere for frustum culling calculateBoundingSphere(gpuChunk, chunk); // Load textures for this chunk if (!chunk.layers.empty()) { - // Base layer (always present) uint32_t baseTexId = chunk.layers[0].textureId; if (baseTexId < texturePaths.size()) { gpuChunk.baseTexture = loadTexture(texturePaths[baseTexId]); } else { - gpuChunk.baseTexture = whiteTexture; + gpuChunk.baseTexture = whiteTexture.get(); } - // Additional layers (with alpha blending) for (size_t i = 1; i < chunk.layers.size() && i < 4; i++) { const auto& layer = chunk.layers[i]; + int li = static_cast(i) - 1; - // Load layer texture - GLuint layerTex = whiteTexture; + VkTexture* layerTex = whiteTexture.get(); if (layer.textureId < texturePaths.size()) { layerTex = loadTexture(texturePaths[layer.textureId]); } - gpuChunk.layerTextures.push_back(layerTex); + gpuChunk.layerTextures[li] = layerTex; - // Create alpha texture - GLuint alphaTex = opaqueAlphaTexture; + VkTexture* alphaTex = opaqueAlphaTexture.get(); if (!layer.alphaData.empty()) { alphaTex = createAlphaTexture(layer.alphaData); } - gpuChunk.alphaTextures.push_back(alphaTex); + gpuChunk.alphaTextures[li] = alphaTex; + gpuChunk.layerCount = static_cast(i); } } else { - // No layers, use default white texture - gpuChunk.baseTexture = whiteTexture; + gpuChunk.baseTexture = whiteTexture.get(); } gpuChunk.tileX = tileX; gpuChunk.tileY = tileY; - chunks.push_back(gpuChunk); + + // Create per-chunk params UBO + TerrainParamsUBO params{}; + params.layerCount = gpuChunk.layerCount; + params.hasLayer1 = gpuChunk.layerCount >= 1 ? 1 : 0; + params.hasLayer2 = gpuChunk.layerCount >= 2 ? 1 : 0; + params.hasLayer3 = gpuChunk.layerCount >= 3 ? 1 : 0; + + VkBufferCreateInfo bufCI{}; + bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufCI.size = sizeof(TerrainParamsUBO); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + vmaCreateBuffer(vkCtx->getAllocator(), &bufCI, &allocCI, + &gpuChunk.paramsUBO, &gpuChunk.paramsAlloc, &mapInfo); + if (mapInfo.pMappedData) { + std::memcpy(mapInfo.pMappedData, ¶ms, sizeof(params)); + } + + // Allocate and write material descriptor set + gpuChunk.materialSet = allocateMaterialSet(); + if (gpuChunk.materialSet) { + writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); + } + + chunks.push_back(std::move(gpuChunk)); } } @@ -166,69 +413,22 @@ TerrainChunkGPU TerrainRenderer::uploadChunk(const pipeline::ChunkMesh& chunk) { gpuChunk.worldZ = chunk.worldZ; gpuChunk.indexCount = static_cast(chunk.indices.size()); - // Debug: verify Z values in uploaded vertices - static int uploadLogCount = 0; - if (uploadLogCount < 3 && !chunk.vertices.empty()) { - float minZ = 999999.0f, maxZ = -999999.0f; - for (const auto& v : chunk.vertices) { - if (v.position[2] < minZ) minZ = v.position[2]; - if (v.position[2] > maxZ) maxZ = v.position[2]; - } - LOG_DEBUG("GPU upload Z range: [", minZ, ", ", maxZ, "] delta=", maxZ - minZ); - uploadLogCount++; - } + VkDeviceSize vbSize = chunk.vertices.size() * sizeof(pipeline::TerrainVertex); + AllocatedBuffer vb = uploadBuffer(*vkCtx, chunk.vertices.data(), vbSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + gpuChunk.vertexBuffer = vb.buffer; + gpuChunk.vertexAlloc = vb.allocation; - // Create VAO - glGenVertexArrays(1, &gpuChunk.vao); - glBindVertexArray(gpuChunk.vao); - - // Create VBO - glGenBuffers(1, &gpuChunk.vbo); - glBindBuffer(GL_ARRAY_BUFFER, gpuChunk.vbo); - glBufferData(GL_ARRAY_BUFFER, - chunk.vertices.size() * sizeof(pipeline::TerrainVertex), - chunk.vertices.data(), - GL_STATIC_DRAW); - - // Create IBO - glGenBuffers(1, &gpuChunk.ibo); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuChunk.ibo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, - chunk.indices.size() * sizeof(pipeline::TerrainIndex), - chunk.indices.data(), - GL_STATIC_DRAW); - - // Set up vertex attributes - // Location 0: Position (vec3) - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, - sizeof(pipeline::TerrainVertex), - (void*)offsetof(pipeline::TerrainVertex, position)); - - // Location 1: Normal (vec3) - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, - sizeof(pipeline::TerrainVertex), - (void*)offsetof(pipeline::TerrainVertex, normal)); - - // Location 2: TexCoord (vec2) - glEnableVertexAttribArray(2); - glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, - sizeof(pipeline::TerrainVertex), - (void*)offsetof(pipeline::TerrainVertex, texCoord)); - - // Location 3: LayerUV (vec2) - glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, - sizeof(pipeline::TerrainVertex), - (void*)offsetof(pipeline::TerrainVertex, layerUV)); - - glBindVertexArray(0); + VkDeviceSize ibSize = chunk.indices.size() * sizeof(pipeline::TerrainIndex); + AllocatedBuffer ib = uploadBuffer(*vkCtx, chunk.indices.data(), ibSize, + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + gpuChunk.indexBuffer = ib.buffer; + gpuChunk.indexAlloc = ib.allocation; return gpuChunk; } -GLuint TerrainRenderer::loadTexture(const std::string& path) { +VkTexture* TerrainRenderer::loadTexture(const std::string& path) { auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), @@ -237,59 +437,55 @@ GLuint TerrainRenderer::loadTexture(const std::string& path) { }; std::string key = normalizeKey(path); - // Check cache first auto it = textureCache.find(key); if (it != textureCache.end()) { it->second.lastUse = ++textureCacheCounter_; - return it->second.id; + return it->second.texture.get(); } - - // Load BLP texture pipeline::BLPImage blp = assetManager->loadTexture(key); if (!blp.isValid()) { - LOG_WARNING("Failed to load texture: ", path); - // Do not cache failure as white: MPQ/file reads can fail transiently - // during heavy streaming and should be allowed to recover. - return whiteTexture; + // Return white fallback but don't cache the failure β€” allow retry + // on next tile load in case the asset becomes available. + if (loggedTextureLoadFails_.insert(key).second) { + LOG_WARNING("Failed to load texture: ", path); + } + return whiteTexture.get(); } - // Create OpenGL texture - GLuint textureID; - glGenTextures(1, &textureID); - glBindTexture(GL_TEXTURE_2D, textureID); - - // Upload texture data (BLP loader outputs RGBA8) - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, - blp.width, blp.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, blp.data.data()); - - // Set texture parameters - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); - - // Generate mipmaps - glGenerateMipmap(GL_TEXTURE_2D); - applyAnisotropicFiltering(); - - glBindTexture(GL_TEXTURE_2D, 0); - - // Cache texture - TextureCacheEntry e; - e.id = textureID; size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; - e.approxBytes = base + (base / 3); + size_t approxBytes = base + (base / 3); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + if (textureBudgetRejectWarnings_ < 3) { + LOG_WARNING("Terrain texture cache full (", textureCacheBytes_ / (1024 * 1024), + " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), + " MB), rejecting texture: ", path); + } + ++textureBudgetRejectWarnings_; + return whiteTexture.get(); + } + + auto tex = std::make_unique(); + if (!tex->upload(*vkCtx, blp.data.data(), blp.width, blp.height, + VK_FORMAT_R8G8B8A8_UNORM, true)) { + LOG_WARNING("Failed to upload texture to GPU: ", path); + return whiteTexture.get(); + } + tex->createSampler(vkCtx->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + + VkTexture* raw = tex.get(); + TextureCacheEntry e; + e.texture = std::move(tex); + e.approxBytes = approxBytes; e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; - textureCache[key] = e; + textureCache[key] = std::move(e); - LOG_DEBUG("Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); - - return textureID; + return raw; } -void TerrainRenderer::uploadPreloadedTextures(const std::unordered_map& textures) { +void TerrainRenderer::uploadPreloadedTextures( + const std::unordered_map& textures) { auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), @@ -298,52 +494,28 @@ void TerrainRenderer::uploadPreloadedTextures(const std::unordered_map(); + if (!tex->upload(*vkCtx, blp.data.data(), blp.width, blp.height, + VK_FORMAT_R8G8B8A8_UNORM, true)) continue; + tex->createSampler(vkCtx->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); TextureCacheEntry e; - e.id = textureID; + e.texture = std::move(tex); size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; e.approxBytes = base + (base / 3); e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; - textureCache[key] = e; + textureCache[key] = std::move(e); } } -GLuint TerrainRenderer::createAlphaTexture(const std::vector& alphaData) { - if (alphaData.empty()) { - return opaqueAlphaTexture; - } +VkTexture* TerrainRenderer::createAlphaTexture(const std::vector& alphaData) { + if (alphaData.empty()) return opaqueAlphaTexture.get(); - if (alphaData.size() != 4096) { - LOG_WARNING("Unexpected terrain alpha size: ", alphaData.size(), " (expected 4096)"); - } - - GLuint textureID; - glGenTextures(1, &textureID); - glBindTexture(GL_TEXTURE_2D, textureID); - - // Alpha data should be 64x64 (4096 bytes). Clamp to a sane fallback when malformed. std::vector expanded; const uint8_t* src = alphaData.data(); if (alphaData.size() < 4096) { @@ -352,141 +524,137 @@ GLuint TerrainRenderer::createAlphaTexture(const std::vector& alphaData src = expanded.data(); } - int width = 64; - int height = 64; - - glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, - width, height, 0, - GL_RED, GL_UNSIGNED_BYTE, src); - - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - - glBindTexture(GL_TEXTURE_2D, 0); - - return textureID; -} - -void TerrainRenderer::renderShadow(GLuint shaderProgram, const glm::vec3& shadowCenter, float halfExtent) { - if (chunks.empty()) return; - - GLint modelLoc = glGetUniformLocation(shaderProgram, "uModel"); - glm::mat4 identity(1.0f); - glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &identity[0][0]); - - for (const auto& chunk : chunks) { - if (!chunk.isValid()) continue; - - // Cull chunks whose bounding sphere doesn't overlap the shadow frustum (XY plane) - float maxDist = halfExtent + chunk.boundingSphereRadius; - float dx = chunk.boundingSphereCenter.x - shadowCenter.x; - float dy = chunk.boundingSphereCenter.y - shadowCenter.y; - if (dx * dx + dy * dy > maxDist * maxDist) continue; - - glBindVertexArray(chunk.vao); - glDrawElements(GL_TRIANGLES, chunk.indexCount, GL_UNSIGNED_INT, 0); - glBindVertexArray(0); + auto tex = std::make_unique(); + if (!tex->upload(*vkCtx, src, 64, 64, VK_FORMAT_R8_UNORM, false)) { + return opaqueAlphaTexture.get(); } + tex->createSampler(vkCtx->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE); + + VkTexture* raw = tex.get(); + static uint64_t alphaCounter = 0; + std::string key = "__alpha_" + std::to_string(++alphaCounter); + TextureCacheEntry e; + e.texture = std::move(tex); + e.approxBytes = 64 * 64; + e.lastUse = ++textureCacheCounter_; + textureCacheBytes_ += e.approxBytes; + textureCache[key] = std::move(e); + + return raw; } -void TerrainRenderer::render(const Camera& camera) { - if (chunks.empty() || !shader) { +VkDescriptorSet TerrainRenderer::allocateMaterialSet() { + VkDescriptorSetAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.descriptorPool = materialDescPool; + allocInfo.descriptorSetCount = 1; + allocInfo.pSetLayouts = &materialSetLayout; + + VkDescriptorSet set = VK_NULL_HANDLE; + if (vkAllocateDescriptorSets(vkCtx->getDevice(), &allocInfo, &set) != VK_SUCCESS) { + LOG_WARNING("TerrainRenderer: failed to allocate material descriptor set"); + return VK_NULL_HANDLE; + } + return set; +} + +void TerrainRenderer::writeMaterialDescriptors(VkDescriptorSet set, const TerrainChunkGPU& chunk) { + VkTexture* white = whiteTexture.get(); + VkTexture* opaque = opaqueAlphaTexture.get(); + + VkDescriptorImageInfo imageInfos[7]; + imageInfos[0] = (chunk.baseTexture ? chunk.baseTexture : white)->descriptorInfo(); + for (int i = 0; i < 3; i++) { + imageInfos[1 + i] = (chunk.layerTextures[i] ? chunk.layerTextures[i] : white)->descriptorInfo(); + imageInfos[4 + i] = (chunk.alphaTextures[i] ? chunk.alphaTextures[i] : opaque)->descriptorInfo(); + } + + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = chunk.paramsUBO; + bufInfo.offset = 0; + bufInfo.range = sizeof(TerrainParamsUBO); + + VkWriteDescriptorSet writes[8] = {}; + for (int i = 0; i < 7; i++) { + writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[i].dstSet = set; + writes[i].dstBinding = static_cast(i); + writes[i].descriptorCount = 1; + writes[i].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[i].pImageInfo = &imageInfos[i]; + } + writes[7].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[7].dstSet = set; + writes[7].dstBinding = 7; + writes[7].descriptorCount = 1; + writes[7].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[7].pBufferInfo = &bufInfo; + + vkUpdateDescriptorSets(vkCtx->getDevice(), 8, writes, 0, nullptr); +} + +void TerrainRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (chunks.empty() || !pipeline) { + static int emptyLog = 0; + if (++emptyLog <= 3) + LOG_WARNING("TerrainRenderer::render: chunks=", chunks.size(), " pipeline=", (pipeline != VK_NULL_HANDLE)); return; } - // Enable depth testing - glEnable(GL_DEPTH_TEST); - glDepthFunc(GL_LESS); - glDepthMask(GL_TRUE); - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glDisable(GL_BLEND); - - // Disable backface culling temporarily to debug flashing - glDisable(GL_CULL_FACE); - // glEnable(GL_CULL_FACE); - // glCullFace(GL_BACK); - - // Wireframe mode - if (wireframe) { - glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); - } else { - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + // One-time diagnostic: log chunk nearest to camera + static bool loggedDiag = false; + if (!loggedDiag && !chunks.empty()) { + loggedDiag = true; + glm::vec3 cam = camera.getPosition(); + // Find chunk nearest to camera + const TerrainChunkGPU* nearest = nullptr; + float nearestDist = 1e30f; + for (const auto& ch : chunks) { + float dx = ch.boundingSphereCenter.x - cam.x; + float dy = ch.boundingSphereCenter.y - cam.y; + float dz = ch.boundingSphereCenter.z - cam.z; + float d = dx*dx + dy*dy + dz*dz; + if (d < nearestDist) { nearestDist = d; nearest = &ch; } + } + if (nearest) { + float d2d = std::sqrt((nearest->boundingSphereCenter.x-cam.x)*(nearest->boundingSphereCenter.x-cam.x) + + (nearest->boundingSphereCenter.y-cam.y)*(nearest->boundingSphereCenter.y-cam.y)); + LOG_INFO("Terrain diag: chunks=", chunks.size(), + " cam=(", cam.x, ",", cam.y, ",", cam.z, ")", + " nearest_center=(", nearest->boundingSphereCenter.x, ",", nearest->boundingSphereCenter.y, ",", nearest->boundingSphereCenter.z, ")", + " dist2d=", d2d, " dist3d=", std::sqrt(nearestDist), + " radius=", nearest->boundingSphereRadius, + " matSet=", (nearest->materialSet != VK_NULL_HANDLE ? "ok" : "NULL")); + } } - // Use shader - shader->use(); + VkPipeline activePipeline = (wireframe && wireframePipeline) ? wireframePipeline : pipeline; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, activePipeline); - // Bind sampler uniforms to texture units (constant, only needs to be set once per use) - shader->setUniform("uBaseTexture", 0); - shader->setUniform("uLayer1Texture", 1); - shader->setUniform("uLayer2Texture", 2); - shader->setUniform("uLayer3Texture", 3); - shader->setUniform("uLayer1Alpha", 4); - shader->setUniform("uLayer2Alpha", 5); - shader->setUniform("uLayer3Alpha", 6); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); - // Set view/projection matrices - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); - glm::mat4 model = glm::mat4(1.0f); + GPUPushConstants push{}; + push.model = glm::mat4(1.0f); + vkCmdPushConstants(cmd, pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, + 0, sizeof(GPUPushConstants), &push); - shader->setUniform("uModel", model); - shader->setUniform("uView", view); - shader->setUniform("uProjection", projection); - - // Set lighting - shader->setUniform("uLightDir", glm::vec3(lightDir[0], lightDir[1], lightDir[2])); - shader->setUniform("uLightColor", glm::vec3(lightColor[0], lightColor[1], lightColor[2])); - shader->setUniform("uAmbientColor", glm::vec3(ambientColor[0], ambientColor[1], ambientColor[2])); - - // Set camera position - glm::vec3 camPos = camera.getPosition(); - shader->setUniform("uViewPos", camPos); - - // Set fog (disable by setting very far distances) - shader->setUniform("uFogColor", glm::vec3(fogColor[0], fogColor[1], fogColor[2])); - if (fogEnabled) { - shader->setUniform("uFogStart", fogStart); - shader->setUniform("uFogEnd", fogEnd); - } else { - shader->setUniform("uFogStart", 100000.0f); // Very far - shader->setUniform("uFogEnd", 100001.0f); // Effectively disabled - } - - // Shadow map - shader->setUniform("uShadowEnabled", shadowEnabled ? 1 : 0); - shader->setUniform("uShadowStrength", 0.68f); - if (shadowEnabled) { - shader->setUniform("uLightSpaceMatrix", lightSpaceMatrix); - glActiveTexture(GL_TEXTURE7); - glBindTexture(GL_TEXTURE_2D, shadowDepthTex); - shader->setUniform("uShadowMap", 7); - } - - // Extract frustum for culling Frustum frustum; if (frustumCullingEnabled) { - glm::mat4 viewProj = projection * view; + glm::mat4 viewProj = camera.getProjectionMatrix() * camera.getViewMatrix(); frustum.extractFromMatrix(viewProj); } - // Render each chunk β€” track last-bound textures to skip redundant binds + glm::vec3 camPos = camera.getPosition(); + const float maxTerrainDistSq = 1200.0f * 1200.0f; + renderedChunks = 0; culledChunks = 0; - GLuint lastBound[7] = {0, 0, 0, 0, 0, 0, 0}; - int lastLayerConfig = -1; // track hasLayer1|hasLayer2|hasLayer3 bitmask - - // Distance culling: maximum render distance for terrain - const float maxTerrainDistSq = 1200.0f * 1200.0f; // 1200 units (reverted from 800 - mountains popping) for (const auto& chunk : chunks) { - if (!chunk.isValid()) { - continue; - } + if (!chunk.isValid() || !chunk.materialSet) continue; - // Early distance culling (before expensive frustum check) float dx = chunk.boundingSphereCenter.x - camPos.x; float dy = chunk.boundingSphereCenter.y - camPos.y; float distSq = dx * dx + dy * dy; @@ -495,83 +663,26 @@ void TerrainRenderer::render(const Camera& camera) { continue; } - // Frustum culling if (frustumCullingEnabled && !isChunkVisible(chunk, frustum)) { culledChunks++; continue; } - // Bind base texture (slot 0) β€” skip if same as last chunk - if (chunk.baseTexture != lastBound[0]) { - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, chunk.baseTexture); - lastBound[0] = chunk.baseTexture; - } + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 1, 1, &chunk.materialSet, 0, nullptr); - // Layer configuration - bool hasLayer1 = chunk.layerTextures.size() > 0; - bool hasLayer2 = chunk.layerTextures.size() > 1; - bool hasLayer3 = chunk.layerTextures.size() > 2; - int layerConfig = (hasLayer1 ? 1 : 0) | (hasLayer2 ? 2 : 0) | (hasLayer3 ? 4 : 0); - - if (layerConfig != lastLayerConfig) { - shader->setUniform("uHasLayer1", hasLayer1 ? 1 : 0); - shader->setUniform("uHasLayer2", hasLayer2 ? 1 : 0); - shader->setUniform("uHasLayer3", hasLayer3 ? 1 : 0); - lastLayerConfig = layerConfig; - } - - if (hasLayer1) { - if (chunk.layerTextures[0] != lastBound[1]) { - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, chunk.layerTextures[0]); - lastBound[1] = chunk.layerTextures[0]; - } - if (chunk.alphaTextures[0] != lastBound[4]) { - glActiveTexture(GL_TEXTURE4); - glBindTexture(GL_TEXTURE_2D, chunk.alphaTextures[0]); - lastBound[4] = chunk.alphaTextures[0]; - } - } - - if (hasLayer2) { - if (chunk.layerTextures[1] != lastBound[2]) { - glActiveTexture(GL_TEXTURE2); - glBindTexture(GL_TEXTURE_2D, chunk.layerTextures[1]); - lastBound[2] = chunk.layerTextures[1]; - } - if (chunk.alphaTextures[1] != lastBound[5]) { - glActiveTexture(GL_TEXTURE5); - glBindTexture(GL_TEXTURE_2D, chunk.alphaTextures[1]); - lastBound[5] = chunk.alphaTextures[1]; - } - } - - if (hasLayer3) { - if (chunk.layerTextures[2] != lastBound[3]) { - glActiveTexture(GL_TEXTURE3); - glBindTexture(GL_TEXTURE_2D, chunk.layerTextures[2]); - lastBound[3] = chunk.layerTextures[2]; - } - if (chunk.alphaTextures[2] != lastBound[6]) { - glActiveTexture(GL_TEXTURE6); - glBindTexture(GL_TEXTURE_2D, chunk.alphaTextures[2]); - lastBound[6] = chunk.alphaTextures[2]; - } - } - - // Draw chunk - glBindVertexArray(chunk.vao); - glDrawElements(GL_TRIANGLES, chunk.indexCount, GL_UNSIGNED_INT, 0); - glBindVertexArray(0); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &chunk.vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, chunk.indexBuffer, 0, VK_INDEX_TYPE_UINT32); + vkCmdDrawIndexed(cmd, chunk.indexCount, 1, 0, 0, 0); renderedChunks++; } - // Reset wireframe - if (wireframe) { - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); - } +} + +void TerrainRenderer::renderShadow(VkCommandBuffer /*cmd*/, const glm::vec3& /*shadowCenter*/, float /*halfExtent*/) { + // Phase 6 stub } void TerrainRenderer::removeTile(int tileX, int tileY) { @@ -579,12 +690,7 @@ void TerrainRenderer::removeTile(int tileX, int tileY) { auto it = chunks.begin(); while (it != chunks.end()) { if (it->tileX == tileX && it->tileY == tileY) { - if (it->vao) glDeleteVertexArrays(1, &it->vao); - if (it->vbo) glDeleteBuffers(1, &it->vbo); - if (it->ibo) glDeleteBuffers(1, &it->ibo); - for (GLuint alpha : it->alphaTextures) { - if (alpha) glDeleteTextures(1, &alpha); - } + destroyChunkGPU(*it); it = chunks.erase(it); removed++; } else { @@ -597,43 +703,45 @@ void TerrainRenderer::removeTile(int tileX, int tileY) { } void TerrainRenderer::clear() { - // Delete all GPU resources + if (!vkCtx) return; + for (auto& chunk : chunks) { - if (chunk.vao) glDeleteVertexArrays(1, &chunk.vao); - if (chunk.vbo) glDeleteBuffers(1, &chunk.vbo); - if (chunk.ibo) glDeleteBuffers(1, &chunk.ibo); - - // Delete alpha textures (not cached) - for (GLuint alpha : chunk.alphaTextures) { - if (alpha) glDeleteTextures(1, &alpha); - } + destroyChunkGPU(chunk); } - chunks.clear(); renderedChunks = 0; + + if (materialDescPool) { + vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); + } } -void TerrainRenderer::setLighting(const float lightDirIn[3], const float lightColorIn[3], - const float ambientColorIn[3]) { - lightDir[0] = lightDirIn[0]; - lightDir[1] = lightDirIn[1]; - lightDir[2] = lightDirIn[2]; +void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) { + VmaAllocator allocator = vkCtx->getAllocator(); - lightColor[0] = lightColorIn[0]; - lightColor[1] = lightColorIn[1]; - lightColor[2] = lightColorIn[2]; + if (chunk.vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = chunk.vertexBuffer; ab.allocation = chunk.vertexAlloc; + destroyBuffer(allocator, ab); + chunk.vertexBuffer = VK_NULL_HANDLE; + } + if (chunk.indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = chunk.indexBuffer; ab.allocation = chunk.indexAlloc; + destroyBuffer(allocator, ab); + chunk.indexBuffer = VK_NULL_HANDLE; + } + if (chunk.paramsUBO) { + AllocatedBuffer ab{}; ab.buffer = chunk.paramsUBO; ab.allocation = chunk.paramsAlloc; + destroyBuffer(allocator, ab); + chunk.paramsUBO = VK_NULL_HANDLE; + } + chunk.materialSet = VK_NULL_HANDLE; - ambientColor[0] = ambientColorIn[0]; - ambientColor[1] = ambientColorIn[1]; - ambientColor[2] = ambientColorIn[2]; -} - -void TerrainRenderer::setFog(const float fogColorIn[3], float fogStartIn, float fogEndIn) { - fogColor[0] = fogColorIn[0]; - fogColor[1] = fogColorIn[1]; - fogColor[2] = fogColorIn[2]; - fogStart = fogStartIn; - fogEnd = fogEndIn; + // Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly) + VkDevice device = vkCtx->getDevice(); + for (auto& tex : chunk.ownedAlphaTextures) { + if (tex) tex->destroy(device, allocator); + } + chunk.ownedAlphaTextures.clear(); } int TerrainRenderer::getTriangleCount() const { @@ -645,7 +753,6 @@ int TerrainRenderer::getTriangleCount() const { } bool TerrainRenderer::isChunkVisible(const TerrainChunkGPU& chunk, const Frustum& frustum) { - // Test bounding sphere against frustum return frustum.intersectsSphere(chunk.boundingSphereCenter, chunk.boundingSphereRadius); } @@ -657,7 +764,6 @@ void TerrainRenderer::calculateBoundingSphere(TerrainChunkGPU& gpuChunk, return; } - // Calculate AABB first glm::vec3 min(std::numeric_limits::max()); glm::vec3 max(std::numeric_limits::lowest()); @@ -667,10 +773,8 @@ void TerrainRenderer::calculateBoundingSphere(TerrainChunkGPU& gpuChunk, max = glm::max(max, pos); } - // Center is midpoint of AABB gpuChunk.boundingSphereCenter = (min + max) * 0.5f; - // Radius is distance from center to furthest vertex float maxDistSq = 0.0f; for (const auto& vertex : meshChunk.vertices) { glm::vec3 pos(vertex.position[0], vertex.position[1], vertex.position[2]); diff --git a/src/rendering/vk_buffer.cpp b/src/rendering/vk_buffer.cpp new file mode 100644 index 00000000..bd045fd6 --- /dev/null +++ b/src/rendering/vk_buffer.cpp @@ -0,0 +1,91 @@ +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_context.hpp" +#include "core/logger.hpp" +#include + +namespace wowee { +namespace rendering { + +VkBuffer::~VkBuffer() { + destroy(); +} + +VkBuffer::VkBuffer(VkBuffer&& other) noexcept + : buf_(other.buf_), allocator_(other.allocator_), size_(other.size_) { + other.buf_ = {}; + other.allocator_ = VK_NULL_HANDLE; + other.size_ = 0; +} + +VkBuffer& VkBuffer::operator=(VkBuffer&& other) noexcept { + if (this != &other) { + destroy(); + buf_ = other.buf_; + allocator_ = other.allocator_; + size_ = other.size_; + other.buf_ = {}; + other.allocator_ = VK_NULL_HANDLE; + other.size_ = 0; + } + return *this; +} + +bool VkBuffer::uploadToGPU(VkContext& ctx, const void* data, VkDeviceSize size, + VkBufferUsageFlags usage) +{ + destroy(); + allocator_ = ctx.getAllocator(); + size_ = size; + + buf_ = uploadBuffer(ctx, data, size, usage); + if (!buf_.buffer) { + LOG_ERROR("Failed to upload buffer (size=", size, ")"); + return false; + } + + return true; +} + +bool VkBuffer::createMapped(VmaAllocator allocator, VkDeviceSize size, + VkBufferUsageFlags usage) +{ + destroy(); + allocator_ = allocator; + size_ = size; + + buf_ = createBuffer(allocator, size, usage, VMA_MEMORY_USAGE_CPU_TO_GPU); + if (!buf_.buffer) { + LOG_ERROR("Failed to create mapped buffer (size=", size, ")"); + return false; + } + + return true; +} + +void VkBuffer::updateMapped(const void* data, VkDeviceSize size, VkDeviceSize offset) { + if (!buf_.info.pMappedData) { + LOG_ERROR("Attempted to update non-mapped buffer"); + return; + } + std::memcpy(static_cast(buf_.info.pMappedData) + offset, data, size); +} + +void VkBuffer::destroy() { + if (buf_.buffer && allocator_) { + destroyBuffer(allocator_, buf_); + } + buf_ = {}; + allocator_ = VK_NULL_HANDLE; + size_ = 0; +} + +VkDescriptorBufferInfo VkBuffer::descriptorInfo(VkDeviceSize offset, VkDeviceSize range) const { + VkDescriptorBufferInfo info{}; + info.buffer = buf_.buffer; + info.offset = offset; + info.range = range; + return info; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp new file mode 100644 index 00000000..8dcdef1b --- /dev/null +++ b/src/rendering/vk_context.cpp @@ -0,0 +1,1415 @@ +#define VMA_IMPLEMENTATION +#include "rendering/vk_context.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback( + VkDebugUtilsMessageSeverityFlagBitsEXT severity, + [[maybe_unused]] VkDebugUtilsMessageTypeFlagsEXT type, + const VkDebugUtilsMessengerCallbackDataEXT* callbackData, + [[maybe_unused]] void* userData) +{ + if (severity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) { + LOG_ERROR("Vulkan: ", callbackData->pMessage); + } else if (severity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) { + LOG_WARNING("Vulkan: ", callbackData->pMessage); + } + return VK_FALSE; +} + +VkContext::~VkContext() { + shutdown(); +} + +bool VkContext::initialize(SDL_Window* window) { + LOG_INFO("Initializing Vulkan context"); + + if (!createInstance(window)) return false; + if (!createSurface(window)) return false; + if (!selectPhysicalDevice()) return false; + if (!createLogicalDevice()) return false; + if (!createAllocator()) return false; + + int w, h; + SDL_Vulkan_GetDrawableSize(window, &w, &h); + if (!createSwapchain(w, h)) return false; + + if (!createCommandPools()) return false; + if (!createSyncObjects()) return false; + if (!createImGuiResources()) return false; + + LOG_INFO("Vulkan context initialized successfully"); + return true; +} + +void VkContext::shutdown() { + if (device) { + vkDeviceWaitIdle(device); + } + + destroyImGuiResources(); + + // Destroy sync objects + for (auto& frame : frames) { + if (frame.inFlightFence) vkDestroyFence(device, frame.inFlightFence, nullptr); + if (frame.renderFinishedSemaphore) vkDestroySemaphore(device, frame.renderFinishedSemaphore, nullptr); + if (frame.imageAvailableSemaphore) vkDestroySemaphore(device, frame.imageAvailableSemaphore, nullptr); + if (frame.commandPool) vkDestroyCommandPool(device, frame.commandPool, nullptr); + frame = {}; + } + + if (immFence) { vkDestroyFence(device, immFence, nullptr); immFence = VK_NULL_HANDLE; } + if (immCommandPool) { vkDestroyCommandPool(device, immCommandPool, nullptr); immCommandPool = VK_NULL_HANDLE; } + + destroySwapchain(); + + if (allocator) { vmaDestroyAllocator(allocator); allocator = VK_NULL_HANDLE; } + if (device) { vkDestroyDevice(device, nullptr); device = VK_NULL_HANDLE; } + if (surface) { vkDestroySurfaceKHR(instance, surface, nullptr); surface = VK_NULL_HANDLE; } + + if (debugMessenger) { + auto func = reinterpret_cast( + vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT")); + if (func) func(instance, debugMessenger, nullptr); + debugMessenger = VK_NULL_HANDLE; + } + + if (instance) { vkDestroyInstance(instance, nullptr); instance = VK_NULL_HANDLE; } + + LOG_INFO("Vulkan context shutdown"); +} + +bool VkContext::createInstance(SDL_Window* window) { + // Get required SDL extensions + unsigned int sdlExtCount = 0; + SDL_Vulkan_GetInstanceExtensions(window, &sdlExtCount, nullptr); + std::vector sdlExts(sdlExtCount); + SDL_Vulkan_GetInstanceExtensions(window, &sdlExtCount, sdlExts.data()); + + vkb::InstanceBuilder builder; + builder.set_app_name("Wowee") + .set_app_version(VK_MAKE_VERSION(1, 0, 0)) + .require_api_version(1, 1, 0); + + for (auto ext : sdlExts) { + builder.enable_extension(ext); + } + + if (enableValidation) { + builder.request_validation_layers(true) + .set_debug_callback(debugCallback); + } + + auto instRet = builder.build(); + if (!instRet) { + LOG_ERROR("Failed to create Vulkan instance: ", instRet.error().message()); + return false; + } + + vkbInstance_ = instRet.value(); + instance = vkbInstance_.instance; + debugMessenger = vkbInstance_.debug_messenger; + + LOG_INFO("Vulkan instance created"); + return true; +} + +bool VkContext::createSurface(SDL_Window* window) { + if (!SDL_Vulkan_CreateSurface(window, instance, &surface)) { + LOG_ERROR("Failed to create Vulkan surface: ", SDL_GetError()); + return false; + } + return true; +} + +bool VkContext::selectPhysicalDevice() { + vkb::PhysicalDeviceSelector selector{vkbInstance_}; + selector.set_surface(surface) + .set_minimum_version(1, 1) + .prefer_gpu_device_type(vkb::PreferredDeviceType::discrete); + + auto physRet = selector.select(); + if (!physRet) { + LOG_ERROR("Failed to select Vulkan physical device: ", physRet.error().message()); + return false; + } + + vkbPhysicalDevice_ = physRet.value(); + physicalDevice = vkbPhysicalDevice_.physical_device; + + VkPhysicalDeviceProperties props; + vkGetPhysicalDeviceProperties(physicalDevice, &props); + uint32_t apiVersion = props.apiVersion; + + VkPhysicalDeviceDepthStencilResolveProperties dsResolveProps{}; + dsResolveProps.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DEPTH_STENCIL_RESOLVE_PROPERTIES; + VkPhysicalDeviceProperties2 props2{}; + props2.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; + props2.pNext = &dsResolveProps; + vkGetPhysicalDeviceProperties2(physicalDevice, &props2); + + if (apiVersion >= VK_API_VERSION_1_2) { + VkResolveModeFlags modes = dsResolveProps.supportedDepthResolveModes; + if (modes & VK_RESOLVE_MODE_SAMPLE_ZERO_BIT) { + depthResolveMode_ = VK_RESOLVE_MODE_SAMPLE_ZERO_BIT; + depthResolveSupported_ = true; + } else if (modes & VK_RESOLVE_MODE_MIN_BIT) { + depthResolveMode_ = VK_RESOLVE_MODE_MIN_BIT; + depthResolveSupported_ = true; + } else if (modes & VK_RESOLVE_MODE_MAX_BIT) { + depthResolveMode_ = VK_RESOLVE_MODE_MAX_BIT; + depthResolveSupported_ = true; + } else if (modes & VK_RESOLVE_MODE_AVERAGE_BIT) { + depthResolveMode_ = VK_RESOLVE_MODE_AVERAGE_BIT; + depthResolveSupported_ = true; + } + } else { + depthResolveSupported_ = false; + depthResolveMode_ = VK_RESOLVE_MODE_NONE; + } + + LOG_INFO("Vulkan device: ", props.deviceName); + LOG_INFO("Vulkan API version: ", VK_VERSION_MAJOR(props.apiVersion), ".", + VK_VERSION_MINOR(props.apiVersion), ".", VK_VERSION_PATCH(props.apiVersion)); + LOG_INFO("Depth resolve support: ", depthResolveSupported_ ? "YES" : "NO"); + + return true; +} + +bool VkContext::createLogicalDevice() { + vkb::DeviceBuilder deviceBuilder{vkbPhysicalDevice_}; + auto devRet = deviceBuilder.build(); + if (!devRet) { + LOG_ERROR("Failed to create Vulkan logical device: ", devRet.error().message()); + return false; + } + + auto vkbDevice = devRet.value(); + device = vkbDevice.device; + + auto gqRet = vkbDevice.get_queue(vkb::QueueType::graphics); + if (!gqRet) { + LOG_ERROR("Failed to get graphics queue"); + return false; + } + graphicsQueue = gqRet.value(); + graphicsQueueFamily = vkbDevice.get_queue_index(vkb::QueueType::graphics).value(); + + auto pqRet = vkbDevice.get_queue(vkb::QueueType::present); + if (!pqRet) { + // Fall back to graphics queue for presentation + presentQueue = graphicsQueue; + presentQueueFamily = graphicsQueueFamily; + } else { + presentQueue = pqRet.value(); + presentQueueFamily = vkbDevice.get_queue_index(vkb::QueueType::present).value(); + } + + LOG_INFO("Vulkan logical device created"); + return true; +} + +bool VkContext::createAllocator() { + VmaAllocatorCreateInfo allocInfo{}; + allocInfo.instance = instance; + allocInfo.physicalDevice = physicalDevice; + allocInfo.device = device; + allocInfo.vulkanApiVersion = VK_API_VERSION_1_1; + + if (vmaCreateAllocator(&allocInfo, &allocator) != VK_SUCCESS) { + LOG_ERROR("Failed to create VMA allocator"); + return false; + } + + LOG_INFO("VMA allocator created"); + return true; +} + +bool VkContext::createSwapchain(int width, int height) { + vkb::SwapchainBuilder swapchainBuilder{physicalDevice, device, surface}; + + auto swapRet = swapchainBuilder + .set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}) + .set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR) // VSync + .set_desired_extent(static_cast(width), static_cast(height)) + .set_image_usage_flags(VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT) + .set_desired_min_image_count(2) + .set_old_swapchain(swapchain) // For recreation + .build(); + + if (!swapRet) { + LOG_ERROR("Failed to create Vulkan swapchain: ", swapRet.error().message()); + return false; + } + + // Destroy old swapchain if recreating + if (swapchain != VK_NULL_HANDLE) { + destroySwapchain(); + } + + auto vkbSwap = swapRet.value(); + swapchain = vkbSwap.swapchain; + swapchainFormat = vkbSwap.image_format; + swapchainExtent = vkbSwap.extent; + swapchainImages = vkbSwap.get_images().value(); + swapchainImageViews = vkbSwap.get_image_views().value(); + + // Create framebuffers for ImGui render pass (created after ImGui resources) + // Will be created in createImGuiResources or recreateSwapchain + + LOG_INFO("Vulkan swapchain created: ", swapchainExtent.width, "x", swapchainExtent.height, + " (", swapchainImages.size(), " images)"); + swapchainDirty = false; + return true; +} + +void VkContext::destroySwapchain() { + for (auto fb : swapchainFramebuffers) { + if (fb) vkDestroyFramebuffer(device, fb, nullptr); + } + swapchainFramebuffers.clear(); + + for (auto iv : swapchainImageViews) { + if (iv) vkDestroyImageView(device, iv, nullptr); + } + swapchainImageViews.clear(); + swapchainImages.clear(); + + if (swapchain) { + vkDestroySwapchainKHR(device, swapchain, nullptr); + swapchain = VK_NULL_HANDLE; + } +} + +bool VkContext::createCommandPools() { + // Per-frame command pools (resettable) + for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { + VkCommandPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + poolInfo.queueFamilyIndex = graphicsQueueFamily; + + if (vkCreateCommandPool(device, &poolInfo, nullptr, &frames[i].commandPool) != VK_SUCCESS) { + LOG_ERROR("Failed to create command pool for frame ", i); + return false; + } + + VkCommandBufferAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocInfo.commandPool = frames[i].commandPool; + allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocInfo.commandBufferCount = 1; + + if (vkAllocateCommandBuffers(device, &allocInfo, &frames[i].commandBuffer) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate command buffer for frame ", i); + return false; + } + } + + // Immediate submit pool + VkCommandPoolCreateInfo immPoolInfo{}; + immPoolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + immPoolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + immPoolInfo.queueFamilyIndex = graphicsQueueFamily; + + if (vkCreateCommandPool(device, &immPoolInfo, nullptr, &immCommandPool) != VK_SUCCESS) { + LOG_ERROR("Failed to create immediate command pool"); + return false; + } + + return true; +} + +bool VkContext::createSyncObjects() { + VkSemaphoreCreateInfo semInfo{}; + semInfo.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO; + + VkFenceCreateInfo fenceInfo{}; + fenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + fenceInfo.flags = VK_FENCE_CREATE_SIGNALED_BIT; // Start signaled so first frame doesn't block + + for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { + if (vkCreateSemaphore(device, &semInfo, nullptr, &frames[i].imageAvailableSemaphore) != VK_SUCCESS || + vkCreateSemaphore(device, &semInfo, nullptr, &frames[i].renderFinishedSemaphore) != VK_SUCCESS || + vkCreateFence(device, &fenceInfo, nullptr, &frames[i].inFlightFence) != VK_SUCCESS) { + LOG_ERROR("Failed to create sync objects for frame ", i); + return false; + } + } + + // Immediate submit fence (not signaled initially) + VkFenceCreateInfo immFenceInfo{}; + immFenceInfo.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO; + if (vkCreateFence(device, &immFenceInfo, nullptr, &immFence) != VK_SUCCESS) { + LOG_ERROR("Failed to create immediate submit fence"); + return false; + } + + return true; +} + +bool VkContext::createDepthBuffer() { + VkImageCreateInfo imgInfo{}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = depthFormat; + imgInfo.extent = {swapchainExtent.width, swapchainExtent.height, 1}; + imgInfo.mipLevels = 1; + imgInfo.arrayLayers = 1; + imgInfo.samples = msaaSamples_; + imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imgInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_GPU_ONLY; + + if (vmaCreateImage(allocator, &imgInfo, &allocInfo, &depthImage, &depthAllocation, nullptr) != VK_SUCCESS) { + LOG_ERROR("Failed to create depth image"); + return false; + } + + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = depthImage; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = depthFormat; + viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + viewInfo.subresourceRange.levelCount = 1; + viewInfo.subresourceRange.layerCount = 1; + + if (vkCreateImageView(device, &viewInfo, nullptr, &depthImageView) != VK_SUCCESS) { + LOG_ERROR("Failed to create depth image view"); + return false; + } + + return true; +} + +void VkContext::destroyDepthBuffer() { + if (depthImageView) { vkDestroyImageView(device, depthImageView, nullptr); depthImageView = VK_NULL_HANDLE; } + if (depthImage) { vmaDestroyImage(allocator, depthImage, depthAllocation); depthImage = VK_NULL_HANDLE; depthAllocation = VK_NULL_HANDLE; } +} + +bool VkContext::createMsaaColorImage() { + if (msaaSamples_ == VK_SAMPLE_COUNT_1_BIT) return true; // No MSAA image needed + + VkImageCreateInfo imgInfo{}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = swapchainFormat; + imgInfo.extent = {swapchainExtent.width, swapchainExtent.height, 1}; + imgInfo.mipLevels = 1; + imgInfo.arrayLayers = 1; + imgInfo.samples = msaaSamples_; + imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imgInfo.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_GPU_ONLY; + allocInfo.preferredFlags = VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT; + + if (vmaCreateImage(allocator, &imgInfo, &allocInfo, &msaaColorImage_, &msaaColorAllocation_, nullptr) != VK_SUCCESS) { + // Retry without TRANSIENT (some drivers reject it at high sample counts) + imgInfo.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + allocInfo.preferredFlags = 0; + if (vmaCreateImage(allocator, &imgInfo, &allocInfo, &msaaColorImage_, &msaaColorAllocation_, nullptr) != VK_SUCCESS) { + LOG_ERROR("Failed to create MSAA color image"); + return false; + } + } + + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = msaaColorImage_; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = swapchainFormat; + viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + viewInfo.subresourceRange.levelCount = 1; + viewInfo.subresourceRange.layerCount = 1; + + if (vkCreateImageView(device, &viewInfo, nullptr, &msaaColorView_) != VK_SUCCESS) { + LOG_ERROR("Failed to create MSAA color image view"); + return false; + } + + return true; +} + +void VkContext::destroyMsaaColorImage() { + if (msaaColorView_) { vkDestroyImageView(device, msaaColorView_, nullptr); msaaColorView_ = VK_NULL_HANDLE; } + if (msaaColorImage_) { vmaDestroyImage(allocator, msaaColorImage_, msaaColorAllocation_); msaaColorImage_ = VK_NULL_HANDLE; msaaColorAllocation_ = VK_NULL_HANDLE; } +} + +bool VkContext::createDepthResolveImage() { + if (msaaSamples_ == VK_SAMPLE_COUNT_1_BIT || !depthResolveSupported_) return true; + + VkImageCreateInfo imgInfo{}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = depthFormat; + imgInfo.extent = {swapchainExtent.width, swapchainExtent.height, 1}; + imgInfo.mipLevels = 1; + imgInfo.arrayLayers = 1; + imgInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imgInfo.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_GPU_ONLY; + + if (vmaCreateImage(allocator, &imgInfo, &allocInfo, &depthResolveImage, &depthResolveAllocation, nullptr) != VK_SUCCESS) { + LOG_ERROR("Failed to create depth resolve image"); + return false; + } + + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = depthResolveImage; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = depthFormat; + viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + viewInfo.subresourceRange.levelCount = 1; + viewInfo.subresourceRange.layerCount = 1; + if (vkCreateImageView(device, &viewInfo, nullptr, &depthResolveImageView) != VK_SUCCESS) { + LOG_ERROR("Failed to create depth resolve image view"); + return false; + } + + return true; +} + +void VkContext::destroyDepthResolveImage() { + if (depthResolveImageView) { + vkDestroyImageView(device, depthResolveImageView, nullptr); + depthResolveImageView = VK_NULL_HANDLE; + } + if (depthResolveImage) { + vmaDestroyImage(allocator, depthResolveImage, depthResolveAllocation); + depthResolveImage = VK_NULL_HANDLE; + depthResolveAllocation = VK_NULL_HANDLE; + } +} + +VkSampleCountFlagBits VkContext::getMaxUsableSampleCount() const { + VkPhysicalDeviceProperties props; + vkGetPhysicalDeviceProperties(physicalDevice, &props); + VkSampleCountFlags counts = props.limits.framebufferColorSampleCounts + & props.limits.framebufferDepthSampleCounts; + if (counts & VK_SAMPLE_COUNT_8_BIT) return VK_SAMPLE_COUNT_8_BIT; + if (counts & VK_SAMPLE_COUNT_4_BIT) return VK_SAMPLE_COUNT_4_BIT; + if (counts & VK_SAMPLE_COUNT_2_BIT) return VK_SAMPLE_COUNT_2_BIT; + return VK_SAMPLE_COUNT_1_BIT; +} + +void VkContext::setMsaaSamples(VkSampleCountFlagBits samples) { + // Clamp to max supported + VkSampleCountFlagBits maxSamples = getMaxUsableSampleCount(); + if (samples > maxSamples) samples = maxSamples; + msaaSamples_ = samples; + swapchainDirty = true; +} + +bool VkContext::createImGuiResources() { + // Create depth buffer first + if (!createDepthBuffer()) return false; + + // Create MSAA color image if needed + if (!createMsaaColorImage()) return false; + // Create single-sample depth resolve image for MSAA path (if supported) + if (!createDepthResolveImage()) return false; + + bool useMsaa = (msaaSamples_ > VK_SAMPLE_COUNT_1_BIT); + + if (useMsaa) { + const bool useDepthResolve = (depthResolveImageView != VK_NULL_HANDLE); + // MSAA render pass: 3 or 4 attachments + VkAttachmentDescription attachments[4] = {}; + + // Attachment 0: MSAA color target + attachments[0].format = swapchainFormat; + attachments[0].samples = msaaSamples_; + attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[0].finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + // Attachment 1: Depth (multisampled) + attachments[1].format = depthFormat; + attachments[1].samples = msaaSamples_; + attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + // Attachment 2: Resolve target (swapchain image) + attachments[2].format = swapchainFormat; + attachments[2].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[2].loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[2].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[2].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[2].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[2].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[2].finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + + if (useDepthResolve) { + attachments[3].format = depthFormat; + attachments[3].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[3].loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[3].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[3].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[3].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[3].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[3].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + } + + if (useDepthResolve) { + VkAttachmentDescription2 attachments2[4]{}; + for (int i = 0; i < 4; ++i) { + attachments2[i].sType = VK_STRUCTURE_TYPE_ATTACHMENT_DESCRIPTION_2; + attachments2[i].format = attachments[i].format; + attachments2[i].samples = attachments[i].samples; + attachments2[i].loadOp = attachments[i].loadOp; + attachments2[i].storeOp = attachments[i].storeOp; + attachments2[i].stencilLoadOp = attachments[i].stencilLoadOp; + attachments2[i].stencilStoreOp = attachments[i].stencilStoreOp; + attachments2[i].initialLayout = attachments[i].initialLayout; + attachments2[i].finalLayout = attachments[i].finalLayout; + } + + VkAttachmentReference2 colorRef2{}; + colorRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + colorRef2.attachment = 0; + colorRef2.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + VkAttachmentReference2 depthRef2{}; + depthRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + depthRef2.attachment = 1; + depthRef2.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + VkAttachmentReference2 resolveRef2{}; + resolveRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + resolveRef2.attachment = 2; + resolveRef2.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + VkAttachmentReference2 depthResolveRef2{}; + depthResolveRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + depthResolveRef2.attachment = 3; + depthResolveRef2.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkSubpassDescriptionDepthStencilResolve dsResolve{}; + dsResolve.sType = VK_STRUCTURE_TYPE_SUBPASS_DESCRIPTION_DEPTH_STENCIL_RESOLVE; + dsResolve.depthResolveMode = depthResolveMode_; + dsResolve.stencilResolveMode = VK_RESOLVE_MODE_NONE; + dsResolve.pDepthStencilResolveAttachment = &depthResolveRef2; + + VkSubpassDescription2 subpass2{}; + subpass2.sType = VK_STRUCTURE_TYPE_SUBPASS_DESCRIPTION_2; + subpass2.pNext = &dsResolve; + subpass2.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass2.colorAttachmentCount = 1; + subpass2.pColorAttachments = &colorRef2; + subpass2.pDepthStencilAttachment = &depthRef2; + subpass2.pResolveAttachments = &resolveRef2; + + VkSubpassDependency2 dep2{}; + dep2.sType = VK_STRUCTURE_TYPE_SUBPASS_DEPENDENCY_2; + dep2.srcSubpass = VK_SUBPASS_EXTERNAL; + dep2.dstSubpass = 0; + dep2.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep2.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep2.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo2 rpInfo2{}; + rpInfo2.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO_2; + rpInfo2.attachmentCount = 4; + rpInfo2.pAttachments = attachments2; + rpInfo2.subpassCount = 1; + rpInfo2.pSubpasses = &subpass2; + rpInfo2.dependencyCount = 1; + rpInfo2.pDependencies = &dep2; + + if (vkCreateRenderPass2(device, &rpInfo2, nullptr, &imguiRenderPass) != VK_SUCCESS) { + LOG_ERROR("Failed to create MSAA render pass (depth resolve)"); + return false; + } + } else { + VkAttachmentReference colorRef{}; + colorRef.attachment = 0; + colorRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + VkAttachmentReference depthRef{}; + depthRef.attachment = 1; + depthRef.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference resolveRef{}; + resolveRef.attachment = 2; + resolveRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + subpass.pDepthStencilAttachment = &depthRef; + subpass.pResolveAttachments = &resolveRef; + + VkSubpassDependency dependency{}; + dependency.srcSubpass = VK_SUBPASS_EXTERNAL; + dependency.dstSubpass = 0; + dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.srcAccessMask = 0; + dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpInfo.attachmentCount = 3; + rpInfo.pAttachments = attachments; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dependency; + + if (vkCreateRenderPass(device, &rpInfo, nullptr, &imguiRenderPass) != VK_SUCCESS) { + LOG_ERROR("Failed to create MSAA render pass"); + return false; + } + } + + // Framebuffers: [msaaColorView, depthView, swapchainView, depthResolveView?] + swapchainFramebuffers.resize(swapchainImageViews.size()); + for (size_t i = 0; i < swapchainImageViews.size(); i++) { + VkImageView fbAttachments[4] = {msaaColorView_, depthImageView, swapchainImageViews[i], depthResolveImageView}; + + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = imguiRenderPass; + fbInfo.attachmentCount = useDepthResolve ? 4 : 3; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = swapchainExtent.width; + fbInfo.height = swapchainExtent.height; + fbInfo.layers = 1; + + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &swapchainFramebuffers[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to create MSAA swapchain framebuffer ", i); + return false; + } + } + } else { + // Non-MSAA render pass: 2 attachments (color + depth) β€” original path + VkAttachmentDescription attachments[2] = {}; + + // Color attachment (swapchain image) + attachments[0].format = swapchainFormat; + attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[0].finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + + // Depth attachment + attachments[1].format = depthFormat; + attachments[1].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference colorRef{}; + colorRef.attachment = 0; + colorRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + VkAttachmentReference depthRef{}; + depthRef.attachment = 1; + depthRef.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + subpass.pDepthStencilAttachment = &depthRef; + + VkSubpassDependency dependency{}; + dependency.srcSubpass = VK_SUBPASS_EXTERNAL; + dependency.dstSubpass = 0; + dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.srcAccessMask = 0; + dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpInfo.attachmentCount = 2; + rpInfo.pAttachments = attachments; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dependency; + + if (vkCreateRenderPass(device, &rpInfo, nullptr, &imguiRenderPass) != VK_SUCCESS) { + LOG_ERROR("Failed to create render pass"); + return false; + } + + // Framebuffers: [swapchainView, depthView] + swapchainFramebuffers.resize(swapchainImageViews.size()); + for (size_t i = 0; i < swapchainImageViews.size(); i++) { + VkImageView fbAttachments[2] = {swapchainImageViews[i], depthImageView}; + + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = imguiRenderPass; + fbInfo.attachmentCount = 2; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = swapchainExtent.width; + fbInfo.height = swapchainExtent.height; + fbInfo.layers = 1; + + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &swapchainFramebuffers[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to create swapchain framebuffer ", i); + return false; + } + } + } + + // Create descriptor pool for ImGui + VkDescriptorPoolSize poolSizes[] = { + {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 100}, + }; + + VkDescriptorPoolCreateInfo dpInfo{}; + dpInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + dpInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; + dpInfo.maxSets = 100; + dpInfo.poolSizeCount = 1; + dpInfo.pPoolSizes = poolSizes; + + if (vkCreateDescriptorPool(device, &dpInfo, nullptr, &imguiDescriptorPool) != VK_SUCCESS) { + LOG_ERROR("Failed to create ImGui descriptor pool"); + return false; + } + + return true; +} + +void VkContext::destroyImGuiResources() { + // Destroy uploaded UI textures + for (auto& tex : uiTextures_) { + if (tex.view) vkDestroyImageView(device, tex.view, nullptr); + if (tex.image) vkDestroyImage(device, tex.image, nullptr); + if (tex.memory) vkFreeMemory(device, tex.memory, nullptr); + } + uiTextures_.clear(); + if (uiTextureSampler_) { + vkDestroySampler(device, uiTextureSampler_, nullptr); + uiTextureSampler_ = VK_NULL_HANDLE; + } + + if (imguiDescriptorPool) { + vkDestroyDescriptorPool(device, imguiDescriptorPool, nullptr); + imguiDescriptorPool = VK_NULL_HANDLE; + } + destroyMsaaColorImage(); + destroyDepthResolveImage(); + destroyDepthBuffer(); + // Framebuffers are destroyed in destroySwapchain() + if (imguiRenderPass) { + vkDestroyRenderPass(device, imguiRenderPass, nullptr); + imguiRenderPass = VK_NULL_HANDLE; + } +} + +static uint32_t findMemType(VkPhysicalDevice physDev, uint32_t typeFilter, VkMemoryPropertyFlags props) { + VkPhysicalDeviceMemoryProperties memProps; + vkGetPhysicalDeviceMemoryProperties(physDev, &memProps); + for (uint32_t i = 0; i < memProps.memoryTypeCount; i++) { + if ((typeFilter & (1 << i)) && (memProps.memoryTypes[i].propertyFlags & props) == props) + return i; + } + return 0; +} + +VkDescriptorSet VkContext::uploadImGuiTexture(const uint8_t* rgba, int width, int height) { + if (!device || !physicalDevice || width <= 0 || height <= 0 || !rgba) + return VK_NULL_HANDLE; + + VkDeviceSize imageSize = static_cast(width) * height * 4; + + // Create shared sampler on first call + if (!uiTextureSampler_) { + VkSamplerCreateInfo si{}; + si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + si.magFilter = VK_FILTER_LINEAR; + si.minFilter = VK_FILTER_LINEAR; + si.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + si.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + if (vkCreateSampler(device, &si, nullptr, &uiTextureSampler_) != VK_SUCCESS) { + LOG_ERROR("Failed to create UI texture sampler"); + return VK_NULL_HANDLE; + } + } + + // Staging buffer + VkBuffer stagingBuffer; + VkDeviceMemory stagingMemory; + { + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = imageSize; + bufInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + bufInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + if (vkCreateBuffer(device, &bufInfo, nullptr, &stagingBuffer) != VK_SUCCESS) + return VK_NULL_HANDLE; + + VkMemoryRequirements memReqs; + vkGetBufferMemoryRequirements(device, stagingBuffer, &memReqs); + + VkMemoryAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = findMemType(physicalDevice, memReqs.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (vkAllocateMemory(device, &allocInfo, nullptr, &stagingMemory) != VK_SUCCESS) { + vkDestroyBuffer(device, stagingBuffer, nullptr); + return VK_NULL_HANDLE; + } + vkBindBufferMemory(device, stagingBuffer, stagingMemory, 0); + + void* mapped; + vkMapMemory(device, stagingMemory, 0, imageSize, 0, &mapped); + memcpy(mapped, rgba, imageSize); + vkUnmapMemory(device, stagingMemory); + } + + // Create image + VkImage image; + VkDeviceMemory imageMemory; + { + VkImageCreateInfo imgInfo{}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = VK_FORMAT_R8G8B8A8_UNORM; + imgInfo.extent = {static_cast(width), static_cast(height), 1}; + imgInfo.mipLevels = 1; + imgInfo.arrayLayers = 1; + imgInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + imgInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + if (vkCreateImage(device, &imgInfo, nullptr, &image) != VK_SUCCESS) { + vkDestroyBuffer(device, stagingBuffer, nullptr); + vkFreeMemory(device, stagingMemory, nullptr); + return VK_NULL_HANDLE; + } + + VkMemoryRequirements memReqs; + vkGetImageMemoryRequirements(device, image, &memReqs); + + VkMemoryAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = findMemType(physicalDevice, memReqs.memoryTypeBits, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + if (vkAllocateMemory(device, &allocInfo, nullptr, &imageMemory) != VK_SUCCESS) { + vkDestroyImage(device, image, nullptr); + vkDestroyBuffer(device, stagingBuffer, nullptr); + vkFreeMemory(device, stagingMemory, nullptr); + return VK_NULL_HANDLE; + } + vkBindImageMemory(device, image, imageMemory, 0); + } + + // Upload via immediate submit + immediateSubmit([&](VkCommandBuffer cmd) { + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {static_cast(width), static_cast(height), 1}; + vkCmdCopyBufferToImage(cmd, stagingBuffer, image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + }); + + // Cleanup staging + vkDestroyBuffer(device, stagingBuffer, nullptr); + vkFreeMemory(device, stagingMemory, nullptr); + + // Create image view + VkImageView imageView; + { + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = image; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM; + viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + if (vkCreateImageView(device, &viewInfo, nullptr, &imageView) != VK_SUCCESS) { + vkDestroyImage(device, image, nullptr); + vkFreeMemory(device, imageMemory, nullptr); + return VK_NULL_HANDLE; + } + } + + // Register with ImGui + VkDescriptorSet ds = ImGui_ImplVulkan_AddTexture(uiTextureSampler_, imageView, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + + // Track for cleanup + uiTextures_.push_back({image, imageMemory, imageView}); + + return ds; +} + +bool VkContext::recreateSwapchain(int width, int height) { + vkDeviceWaitIdle(device); + + // Destroy old framebuffers + for (auto fb : swapchainFramebuffers) { + if (fb) vkDestroyFramebuffer(device, fb, nullptr); + } + swapchainFramebuffers.clear(); + + // Destroy old image views + for (auto iv : swapchainImageViews) { + if (iv) vkDestroyImageView(device, iv, nullptr); + } + swapchainImageViews.clear(); + + VkSwapchainKHR oldSwapchain = swapchain; + + vkb::SwapchainBuilder swapchainBuilder{physicalDevice, device, surface}; + auto swapRet = swapchainBuilder + .set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}) + .set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR) + .set_desired_extent(static_cast(width), static_cast(height)) + .set_image_usage_flags(VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT) + .set_desired_min_image_count(2) + .set_old_swapchain(oldSwapchain) + .build(); + + if (oldSwapchain) { + vkDestroySwapchainKHR(device, oldSwapchain, nullptr); + } + + if (!swapRet) { + LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message()); + swapchain = VK_NULL_HANDLE; + return false; + } + + auto vkbSwap = swapRet.value(); + swapchain = vkbSwap.swapchain; + swapchainFormat = vkbSwap.image_format; + swapchainExtent = vkbSwap.extent; + swapchainImages = vkbSwap.get_images().value(); + swapchainImageViews = vkbSwap.get_image_views().value(); + + // Recreate depth buffer + MSAA color image + depth resolve image + destroyMsaaColorImage(); + destroyDepthResolveImage(); + destroyDepthBuffer(); + + // Destroy old render pass (needs recreation if MSAA changed) + if (imguiRenderPass) { + vkDestroyRenderPass(device, imguiRenderPass, nullptr); + imguiRenderPass = VK_NULL_HANDLE; + } + + if (!createDepthBuffer()) return false; + if (!createMsaaColorImage()) return false; + if (!createDepthResolveImage()) return false; + + bool useMsaa = (msaaSamples_ > VK_SAMPLE_COUNT_1_BIT); + + if (useMsaa) { + const bool useDepthResolve = (depthResolveImageView != VK_NULL_HANDLE); + // MSAA render pass: 3 or 4 attachments + VkAttachmentDescription attachments[4] = {}; + attachments[0].format = swapchainFormat; + attachments[0].samples = msaaSamples_; + attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[0].finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + attachments[1].format = depthFormat; + attachments[1].samples = msaaSamples_; + attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + attachments[2].format = swapchainFormat; + attachments[2].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[2].loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[2].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[2].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[2].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[2].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[2].finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + + if (useDepthResolve) { + attachments[3].format = depthFormat; + attachments[3].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[3].loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[3].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[3].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[3].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[3].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[3].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + } + + if (useDepthResolve) { + VkAttachmentDescription2 attachments2[4]{}; + for (int i = 0; i < 4; ++i) { + attachments2[i].sType = VK_STRUCTURE_TYPE_ATTACHMENT_DESCRIPTION_2; + attachments2[i].format = attachments[i].format; + attachments2[i].samples = attachments[i].samples; + attachments2[i].loadOp = attachments[i].loadOp; + attachments2[i].storeOp = attachments[i].storeOp; + attachments2[i].stencilLoadOp = attachments[i].stencilLoadOp; + attachments2[i].stencilStoreOp = attachments[i].stencilStoreOp; + attachments2[i].initialLayout = attachments[i].initialLayout; + attachments2[i].finalLayout = attachments[i].finalLayout; + } + + VkAttachmentReference2 colorRef2{}; + colorRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + colorRef2.attachment = 0; + colorRef2.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + VkAttachmentReference2 depthRef2{}; + depthRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + depthRef2.attachment = 1; + depthRef2.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + VkAttachmentReference2 resolveRef2{}; + resolveRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + resolveRef2.attachment = 2; + resolveRef2.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + VkAttachmentReference2 depthResolveRef2{}; + depthResolveRef2.sType = VK_STRUCTURE_TYPE_ATTACHMENT_REFERENCE_2; + depthResolveRef2.attachment = 3; + depthResolveRef2.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkSubpassDescriptionDepthStencilResolve dsResolve{}; + dsResolve.sType = VK_STRUCTURE_TYPE_SUBPASS_DESCRIPTION_DEPTH_STENCIL_RESOLVE; + dsResolve.depthResolveMode = depthResolveMode_; + dsResolve.stencilResolveMode = VK_RESOLVE_MODE_NONE; + dsResolve.pDepthStencilResolveAttachment = &depthResolveRef2; + + VkSubpassDescription2 subpass2{}; + subpass2.sType = VK_STRUCTURE_TYPE_SUBPASS_DESCRIPTION_2; + subpass2.pNext = &dsResolve; + subpass2.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass2.colorAttachmentCount = 1; + subpass2.pColorAttachments = &colorRef2; + subpass2.pDepthStencilAttachment = &depthRef2; + subpass2.pResolveAttachments = &resolveRef2; + + VkSubpassDependency2 dep2{}; + dep2.sType = VK_STRUCTURE_TYPE_SUBPASS_DEPENDENCY_2; + dep2.srcSubpass = VK_SUBPASS_EXTERNAL; + dep2.dstSubpass = 0; + dep2.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep2.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep2.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo2 rpInfo2{}; + rpInfo2.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO_2; + rpInfo2.attachmentCount = 4; + rpInfo2.pAttachments = attachments2; + rpInfo2.subpassCount = 1; + rpInfo2.pSubpasses = &subpass2; + rpInfo2.dependencyCount = 1; + rpInfo2.pDependencies = &dep2; + + if (vkCreateRenderPass2(device, &rpInfo2, nullptr, &imguiRenderPass) != VK_SUCCESS) { + LOG_ERROR("Failed to recreate MSAA render pass (depth resolve)"); + return false; + } + } else { + VkAttachmentReference colorRef{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkAttachmentReference depthRef{1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL}; + VkAttachmentReference resolveRef{2, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + subpass.pDepthStencilAttachment = &depthRef; + subpass.pResolveAttachments = &resolveRef; + + VkSubpassDependency dependency{}; + dependency.srcSubpass = VK_SUBPASS_EXTERNAL; + dependency.dstSubpass = 0; + dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.srcAccessMask = 0; + dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpInfo.attachmentCount = 3; + rpInfo.pAttachments = attachments; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dependency; + + if (vkCreateRenderPass(device, &rpInfo, nullptr, &imguiRenderPass) != VK_SUCCESS) { + LOG_ERROR("Failed to recreate MSAA render pass"); + return false; + } + } + + swapchainFramebuffers.resize(swapchainImageViews.size()); + for (size_t i = 0; i < swapchainImageViews.size(); i++) { + VkImageView fbAttachments[4] = {msaaColorView_, depthImageView, swapchainImageViews[i], depthResolveImageView}; + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = imguiRenderPass; + fbInfo.attachmentCount = useDepthResolve ? 4 : 3; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = swapchainExtent.width; + fbInfo.height = swapchainExtent.height; + fbInfo.layers = 1; + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &swapchainFramebuffers[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to recreate MSAA swapchain framebuffer ", i); + return false; + } + } + } else { + // Non-MSAA render pass: 2 attachments + VkAttachmentDescription attachments[2] = {}; + attachments[0].format = swapchainFormat; + attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[0].finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + + attachments[1].format = depthFormat; + attachments[1].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference colorRef{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkAttachmentReference depthRef{1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL}; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + subpass.pDepthStencilAttachment = &depthRef; + + VkSubpassDependency dependency{}; + dependency.srcSubpass = VK_SUBPASS_EXTERNAL; + dependency.dstSubpass = 0; + dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependency.srcAccessMask = 0; + dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpInfo.attachmentCount = 2; + rpInfo.pAttachments = attachments; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dependency; + + if (vkCreateRenderPass(device, &rpInfo, nullptr, &imguiRenderPass) != VK_SUCCESS) { + LOG_ERROR("Failed to recreate render pass"); + return false; + } + + swapchainFramebuffers.resize(swapchainImageViews.size()); + for (size_t i = 0; i < swapchainImageViews.size(); i++) { + VkImageView fbAttachments[2] = {swapchainImageViews[i], depthImageView}; + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = imguiRenderPass; + fbInfo.attachmentCount = 2; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = swapchainExtent.width; + fbInfo.height = swapchainExtent.height; + fbInfo.layers = 1; + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &swapchainFramebuffers[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to recreate swapchain framebuffer ", i); + return false; + } + } + } + + swapchainDirty = false; + LOG_INFO("Swapchain recreated: ", swapchainExtent.width, "x", swapchainExtent.height); + return true; +} + +VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { + auto& frame = frames[currentFrame]; + + // Wait for this frame's fence (with timeout to detect GPU hangs) + static int beginFrameCounter = 0; + beginFrameCounter++; + VkResult fenceResult = vkWaitForFences(device, 1, &frame.inFlightFence, VK_TRUE, 5000000000ULL); // 5 second timeout + if (fenceResult == VK_TIMEOUT) { + LOG_ERROR("beginFrame[", beginFrameCounter, "] FENCE TIMEOUT (5s) on frame slot ", currentFrame, " β€” GPU hang detected!"); + return VK_NULL_HANDLE; + } + if (fenceResult != VK_SUCCESS) { + LOG_ERROR("beginFrame[", beginFrameCounter, "] fence wait failed: ", (int)fenceResult); + return VK_NULL_HANDLE; + } + + // Acquire next swapchain image + VkResult result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, + frame.imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex); + + if (result == VK_ERROR_OUT_OF_DATE_KHR) { + swapchainDirty = true; + return VK_NULL_HANDLE; + } + if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) { + LOG_ERROR("Failed to acquire swapchain image"); + return VK_NULL_HANDLE; + } + + vkResetFences(device, 1, &frame.inFlightFence); + vkResetCommandBuffer(frame.commandBuffer, 0); + + VkCommandBufferBeginInfo beginInfo{}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + + vkBeginCommandBuffer(frame.commandBuffer, &beginInfo); + + return frame.commandBuffer; +} + +void VkContext::endFrame(VkCommandBuffer cmd, uint32_t imageIndex) { + static int endFrameCounter = 0; + endFrameCounter++; + + VkResult endResult = vkEndCommandBuffer(cmd); + if (endResult != VK_SUCCESS) { + LOG_ERROR("endFrame[", endFrameCounter, "] vkEndCommandBuffer FAILED: ", (int)endResult); + } + + auto& frame = frames[currentFrame]; + + VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + + VkSubmitInfo submitInfo{}; + submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submitInfo.waitSemaphoreCount = 1; + submitInfo.pWaitSemaphores = &frame.imageAvailableSemaphore; + submitInfo.pWaitDstStageMask = &waitStage; + submitInfo.commandBufferCount = 1; + submitInfo.pCommandBuffers = &cmd; + submitInfo.signalSemaphoreCount = 1; + submitInfo.pSignalSemaphores = &frame.renderFinishedSemaphore; + + VkResult submitResult = vkQueueSubmit(graphicsQueue, 1, &submitInfo, frame.inFlightFence); + if (submitResult != VK_SUCCESS) { + LOG_ERROR("endFrame[", endFrameCounter, "] vkQueueSubmit FAILED: ", (int)submitResult); + } + + VkPresentInfoKHR presentInfo{}; + presentInfo.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR; + presentInfo.waitSemaphoreCount = 1; + presentInfo.pWaitSemaphores = &frame.renderFinishedSemaphore; + presentInfo.swapchainCount = 1; + presentInfo.pSwapchains = &swapchain; + presentInfo.pImageIndices = &imageIndex; + + VkResult result = vkQueuePresentKHR(presentQueue, &presentInfo); + if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) { + swapchainDirty = true; + } + + currentFrame = (currentFrame + 1) % MAX_FRAMES_IN_FLIGHT; +} + +VkCommandBuffer VkContext::beginSingleTimeCommands() { + VkCommandBufferAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocInfo.commandPool = immCommandPool; + allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocInfo.commandBufferCount = 1; + + VkCommandBuffer cmd; + vkAllocateCommandBuffers(device, &allocInfo, &cmd); + + VkCommandBufferBeginInfo beginInfo{}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vkBeginCommandBuffer(cmd, &beginInfo); + + return cmd; +} + +void VkContext::endSingleTimeCommands(VkCommandBuffer cmd) { + vkEndCommandBuffer(cmd); + + VkSubmitInfo submitInfo{}; + submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submitInfo.commandBufferCount = 1; + submitInfo.pCommandBuffers = &cmd; + + vkQueueSubmit(graphicsQueue, 1, &submitInfo, immFence); + vkWaitForFences(device, 1, &immFence, VK_TRUE, UINT64_MAX); + vkResetFences(device, 1, &immFence); + + vkFreeCommandBuffers(device, immCommandPool, 1, &cmd); +} + +void VkContext::immediateSubmit(std::function&& function) { + VkCommandBuffer cmd = beginSingleTimeCommands(); + function(cmd); + endSingleTimeCommands(cmd); +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/vk_pipeline.cpp b/src/rendering/vk_pipeline.cpp new file mode 100644 index 00000000..4e565b07 --- /dev/null +++ b/src/rendering/vk_pipeline.cpp @@ -0,0 +1,291 @@ +#include "rendering/vk_pipeline.hpp" +#include "core/logger.hpp" + +namespace wowee { +namespace rendering { + +PipelineBuilder::PipelineBuilder() { + // Default: one blend attachment with blending disabled + colorBlendAttachments_.push_back(blendDisabled()); + + // Default dynamic states: viewport + scissor (almost always dynamic) + dynamicStates_ = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}; +} + +PipelineBuilder& PipelineBuilder::setShaders( + VkPipelineShaderStageCreateInfo vert, VkPipelineShaderStageCreateInfo frag) +{ + shaderStages_ = {vert, frag}; + return *this; +} + +PipelineBuilder& PipelineBuilder::setVertexInput( + const std::vector& bindings, + const std::vector& attributes) +{ + vertexBindings_ = bindings; + vertexAttributes_ = attributes; + return *this; +} + +PipelineBuilder& PipelineBuilder::setNoVertexInput() { + vertexBindings_.clear(); + vertexAttributes_.clear(); + return *this; +} + +PipelineBuilder& PipelineBuilder::setTopology(VkPrimitiveTopology topology, + VkBool32 primitiveRestart) +{ + topology_ = topology; + primitiveRestart_ = primitiveRestart; + return *this; +} + +PipelineBuilder& PipelineBuilder::setRasterization(VkPolygonMode polygonMode, + VkCullModeFlags cullMode, VkFrontFace frontFace) +{ + polygonMode_ = polygonMode; + cullMode_ = cullMode; + frontFace_ = frontFace; + return *this; +} + +PipelineBuilder& PipelineBuilder::setDepthTest(bool enable, bool writeEnable, + VkCompareOp compareOp) +{ + depthTestEnable_ = enable; + depthWriteEnable_ = writeEnable; + depthCompareOp_ = compareOp; + return *this; +} + +PipelineBuilder& PipelineBuilder::setNoDepthTest() { + depthTestEnable_ = false; + depthWriteEnable_ = false; + return *this; +} + +PipelineBuilder& PipelineBuilder::setDepthBias(float constantFactor, float slopeFactor) { + depthBiasEnable_ = true; + depthBiasConstant_ = constantFactor; + depthBiasSlope_ = slopeFactor; + return *this; +} + +PipelineBuilder& PipelineBuilder::setColorBlendAttachment( + VkPipelineColorBlendAttachmentState blendState) +{ + colorBlendAttachments_ = {blendState}; + return *this; +} + +PipelineBuilder& PipelineBuilder::setNoColorAttachment() { + colorBlendAttachments_.clear(); + return *this; +} + +PipelineBuilder& PipelineBuilder::setMultisample(VkSampleCountFlagBits samples) { + msaaSamples_ = samples; + return *this; +} + +PipelineBuilder& PipelineBuilder::setAlphaToCoverage(bool enable) { + alphaToCoverage_ = enable; + return *this; +} + +PipelineBuilder& PipelineBuilder::setLayout(VkPipelineLayout layout) { + pipelineLayout_ = layout; + return *this; +} + +PipelineBuilder& PipelineBuilder::setRenderPass(VkRenderPass renderPass, uint32_t subpass) { + renderPass_ = renderPass; + subpass_ = subpass; + return *this; +} + +PipelineBuilder& PipelineBuilder::setDynamicStates(const std::vector& states) { + dynamicStates_ = states; + return *this; +} + +VkPipeline PipelineBuilder::build(VkDevice device) const { + // Vertex input + VkPipelineVertexInputStateCreateInfo vertexInput{}; + vertexInput.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vertexInput.vertexBindingDescriptionCount = static_cast(vertexBindings_.size()); + vertexInput.pVertexBindingDescriptions = vertexBindings_.data(); + vertexInput.vertexAttributeDescriptionCount = static_cast(vertexAttributes_.size()); + vertexInput.pVertexAttributeDescriptions = vertexAttributes_.data(); + + // Input assembly + VkPipelineInputAssemblyStateCreateInfo inputAssembly{}; + inputAssembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + inputAssembly.topology = topology_; + inputAssembly.primitiveRestartEnable = primitiveRestart_; + + // Viewport / scissor (dynamic, so just specify count) + VkPipelineViewportStateCreateInfo viewportState{}; + viewportState.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + viewportState.viewportCount = 1; + viewportState.scissorCount = 1; + + // Rasterization + VkPipelineRasterizationStateCreateInfo rasterizer{}; + rasterizer.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + rasterizer.depthClampEnable = VK_FALSE; + rasterizer.rasterizerDiscardEnable = VK_FALSE; + rasterizer.polygonMode = polygonMode_; + rasterizer.lineWidth = 1.0f; + rasterizer.cullMode = cullMode_; + rasterizer.frontFace = frontFace_; + rasterizer.depthBiasEnable = depthBiasEnable_ ? VK_TRUE : VK_FALSE; + rasterizer.depthBiasConstantFactor = depthBiasConstant_; + rasterizer.depthBiasSlopeFactor = depthBiasSlope_; + + // Multisampling + VkPipelineMultisampleStateCreateInfo multisampling{}; + multisampling.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + multisampling.sampleShadingEnable = VK_FALSE; + multisampling.rasterizationSamples = msaaSamples_; + multisampling.alphaToCoverageEnable = alphaToCoverage_ ? VK_TRUE : VK_FALSE; + + // Depth/stencil + VkPipelineDepthStencilStateCreateInfo depthStencil{}; + depthStencil.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + depthStencil.depthTestEnable = depthTestEnable_ ? VK_TRUE : VK_FALSE; + depthStencil.depthWriteEnable = depthWriteEnable_ ? VK_TRUE : VK_FALSE; + depthStencil.depthCompareOp = depthCompareOp_; + depthStencil.depthBoundsTestEnable = VK_FALSE; + depthStencil.stencilTestEnable = VK_FALSE; + + // Color blending + VkPipelineColorBlendStateCreateInfo colorBlending{}; + colorBlending.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + colorBlending.logicOpEnable = VK_FALSE; + colorBlending.attachmentCount = static_cast(colorBlendAttachments_.size()); + colorBlending.pAttachments = colorBlendAttachments_.data(); + + // Dynamic state + VkPipelineDynamicStateCreateInfo dynamicState{}; + dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dynamicState.dynamicStateCount = static_cast(dynamicStates_.size()); + dynamicState.pDynamicStates = dynamicStates_.data(); + + // Create pipeline + VkGraphicsPipelineCreateInfo pipelineInfo{}; + pipelineInfo.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pipelineInfo.stageCount = static_cast(shaderStages_.size()); + pipelineInfo.pStages = shaderStages_.data(); + pipelineInfo.pVertexInputState = &vertexInput; + pipelineInfo.pInputAssemblyState = &inputAssembly; + pipelineInfo.pViewportState = &viewportState; + pipelineInfo.pRasterizationState = &rasterizer; + pipelineInfo.pMultisampleState = &multisampling; + pipelineInfo.pDepthStencilState = &depthStencil; + pipelineInfo.pColorBlendState = colorBlendAttachments_.empty() ? nullptr : &colorBlending; + pipelineInfo.pDynamicState = dynamicStates_.empty() ? nullptr : &dynamicState; + pipelineInfo.layout = pipelineLayout_; + pipelineInfo.renderPass = renderPass_; + pipelineInfo.subpass = subpass_; + + VkPipeline pipeline = VK_NULL_HANDLE; + if (vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, + nullptr, &pipeline) != VK_SUCCESS) + { + LOG_ERROR("Failed to create graphics pipeline"); + return VK_NULL_HANDLE; + } + + return pipeline; +} + +VkPipelineColorBlendAttachmentState PipelineBuilder::blendDisabled() { + VkPipelineColorBlendAttachmentState state{}; + state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + state.blendEnable = VK_FALSE; + return state; +} + +VkPipelineColorBlendAttachmentState PipelineBuilder::blendAlpha() { + VkPipelineColorBlendAttachmentState state{}; + state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + state.blendEnable = VK_TRUE; + state.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + state.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + state.colorBlendOp = VK_BLEND_OP_ADD; + state.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + state.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + state.alphaBlendOp = VK_BLEND_OP_ADD; + return state; +} + +VkPipelineColorBlendAttachmentState PipelineBuilder::blendPremultiplied() { + VkPipelineColorBlendAttachmentState state{}; + state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + state.blendEnable = VK_TRUE; + state.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; + state.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + state.colorBlendOp = VK_BLEND_OP_ADD; + state.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + state.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA; + state.alphaBlendOp = VK_BLEND_OP_ADD; + return state; +} + +VkPipelineColorBlendAttachmentState PipelineBuilder::blendAdditive() { + VkPipelineColorBlendAttachmentState state{}; + state.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + state.blendEnable = VK_TRUE; + state.srcColorBlendFactor = VK_BLEND_FACTOR_SRC_ALPHA; + state.dstColorBlendFactor = VK_BLEND_FACTOR_ONE; + state.colorBlendOp = VK_BLEND_OP_ADD; + state.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + state.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + state.alphaBlendOp = VK_BLEND_OP_ADD; + return state; +} + +VkPipelineLayout createPipelineLayout(VkDevice device, + const std::vector& setLayouts, + const std::vector& pushConstants) +{ + VkPipelineLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + layoutInfo.setLayoutCount = static_cast(setLayouts.size()); + layoutInfo.pSetLayouts = setLayouts.data(); + layoutInfo.pushConstantRangeCount = static_cast(pushConstants.size()); + layoutInfo.pPushConstantRanges = pushConstants.data(); + + VkPipelineLayout layout = VK_NULL_HANDLE; + if (vkCreatePipelineLayout(device, &layoutInfo, nullptr, &layout) != VK_SUCCESS) { + LOG_ERROR("Failed to create pipeline layout"); + } + + return layout; +} + +VkDescriptorSetLayout createDescriptorSetLayout(VkDevice device, + const std::vector& bindings) +{ + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = static_cast(bindings.size()); + layoutInfo.pBindings = bindings.data(); + + VkDescriptorSetLayout layout = VK_NULL_HANDLE; + if (vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &layout) != VK_SUCCESS) { + LOG_ERROR("Failed to create descriptor set layout"); + } + + return layout; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/vk_render_target.cpp b/src/rendering/vk_render_target.cpp new file mode 100644 index 00000000..48e3a50e --- /dev/null +++ b/src/rendering/vk_render_target.cpp @@ -0,0 +1,328 @@ +#include "rendering/vk_render_target.hpp" +#include "rendering/vk_context.hpp" +#include "core/logger.hpp" + +namespace wowee { +namespace rendering { + +VkRenderTarget::~VkRenderTarget() { + // Must call destroy() explicitly with device/allocator before destruction +} + +bool VkRenderTarget::create(VkContext& ctx, uint32_t width, uint32_t height, + VkFormat format, bool withDepth, VkSampleCountFlagBits msaaSamples) { + VkDevice device = ctx.getDevice(); + VmaAllocator allocator = ctx.getAllocator(); + hasDepth_ = withDepth; + msaaSamples_ = msaaSamples; + bool useMSAA = msaaSamples != VK_SAMPLE_COUNT_1_BIT; + + // Create color image (multisampled if MSAA) + colorImage_ = createImage(device, allocator, width, height, format, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | (useMSAA ? VkImageUsageFlags(0) : VK_IMAGE_USAGE_SAMPLED_BIT), + msaaSamples); + + if (!colorImage_.image) { + LOG_ERROR("VkRenderTarget: failed to create color image (", width, "x", height, ")"); + return false; + } + + // Create resolve image for MSAA (single-sample, sampled for reading) + if (useMSAA) { + resolveImage_ = createImage(device, allocator, width, height, format, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + if (!resolveImage_.image) { + LOG_ERROR("VkRenderTarget: failed to create resolve image (", width, "x", height, ")"); + destroy(device, allocator); + return false; + } + } + + // Create depth image if requested (multisampled to match color) + if (withDepth) { + depthImage_ = createImage(device, allocator, width, height, + VK_FORMAT_D32_SFLOAT, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, msaaSamples); + if (!depthImage_.image) { + LOG_ERROR("VkRenderTarget: failed to create depth image (", width, "x", height, ")"); + destroy(device, allocator); + return false; + } + } + + // Create sampler (linear filtering, clamp to edge) + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = VK_FILTER_LINEAR; + samplerInfo.magFilter = VK_FILTER_LINEAR; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + samplerInfo.minLod = 0.0f; + samplerInfo.maxLod = 0.0f; + + if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) { + LOG_ERROR("VkRenderTarget: failed to create sampler"); + destroy(device, allocator); + return false; + } + + // Create render pass + if (useMSAA) { + // MSAA render pass: color(MSAA) + resolve(1x) + optional depth(MSAA) + // Attachment 0: MSAA color (rendered into, not stored after resolve) + // Attachment 1: resolve color (stores final resolved result) + // Attachment 2: MSAA depth (optional) + VkAttachmentDescription attachments[3]{}; + + // MSAA color + attachments[0].format = format; + attachments[0].samples = msaaSamples; + attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // resolved, don't need MSAA data + attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[0].finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + + // Resolve color + attachments[1].format = format; + attachments[1].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[1].finalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + // MSAA depth + attachments[2].format = VK_FORMAT_D32_SFLOAT; + attachments[2].samples = msaaSamples; + attachments[2].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[2].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[2].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[2].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[2].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[2].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference colorRef{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkAttachmentReference resolveRef{1, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkAttachmentReference depthRef{2, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL}; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + subpass.pResolveAttachments = &resolveRef; + if (withDepth) subpass.pDepthStencilAttachment = &depthRef; + + VkSubpassDependency dep{}; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + dep.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dep.srcAccessMask = VK_ACCESS_SHADER_READ_BIT; + dep.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + if (withDepth) { + dep.dstStageMask |= VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep.dstAccessMask |= VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + } + + VkRenderPassCreateInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpInfo.attachmentCount = withDepth ? 3u : 2u; + rpInfo.pAttachments = attachments; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dep; + + if (vkCreateRenderPass(device, &rpInfo, nullptr, &renderPass_) != VK_SUCCESS) { + LOG_ERROR("VkRenderTarget: failed to create MSAA render pass"); + destroy(device, allocator); + return false; + } + + // Create framebuffer: MSAA color + resolve + optional MSAA depth + VkImageView fbAttachments[3] = { colorImage_.imageView, resolveImage_.imageView, depthImage_.imageView }; + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = renderPass_; + fbInfo.attachmentCount = withDepth ? 3u : 2u; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = width; + fbInfo.height = height; + fbInfo.layers = 1; + + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &framebuffer_) != VK_SUCCESS) { + LOG_ERROR("VkRenderTarget: failed to create MSAA framebuffer"); + destroy(device, allocator); + return false; + } + } else { + // Non-MSAA render pass (original path) + VkAttachmentDescription attachments[2]{}; + attachments[0].format = format; + attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[0].finalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + attachments[1].format = VK_FORMAT_D32_SFLOAT; + attachments[1].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference colorRef{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkAttachmentReference depthRef{1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL}; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + if (withDepth) subpass.pDepthStencilAttachment = &depthRef; + + VkSubpassDependency dependencies[2]{}; + uint32_t depCount = 1; + dependencies[0].srcSubpass = VK_SUBPASS_EXTERNAL; + dependencies[0].dstSubpass = 0; + dependencies[0].srcStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + dependencies[0].dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dependencies[0].srcAccessMask = VK_ACCESS_SHADER_READ_BIT; + dependencies[0].dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + + if (withDepth) { + dependencies[0].dstStageMask |= VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dependencies[0].dstAccessMask |= VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + dependencies[1].srcSubpass = 0; + dependencies[1].dstSubpass = VK_SUBPASS_EXTERNAL; + dependencies[1].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + dependencies[1].dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + dependencies[1].srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + dependencies[1].dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + depCount = 2; + } + + VkRenderPassCreateInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpInfo.attachmentCount = withDepth ? 2u : 1u; + rpInfo.pAttachments = attachments; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = depCount; + rpInfo.pDependencies = dependencies; + + if (vkCreateRenderPass(device, &rpInfo, nullptr, &renderPass_) != VK_SUCCESS) { + LOG_ERROR("VkRenderTarget: failed to create render pass"); + destroy(device, allocator); + return false; + } + + VkImageView fbAttachments[2] = { colorImage_.imageView, depthImage_.imageView }; + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = renderPass_; + fbInfo.attachmentCount = withDepth ? 2u : 1u; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = width; + fbInfo.height = height; + fbInfo.layers = 1; + + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &framebuffer_) != VK_SUCCESS) { + LOG_ERROR("VkRenderTarget: failed to create framebuffer"); + destroy(device, allocator); + return false; + } + } + + LOG_INFO("VkRenderTarget created (", width, "x", height, + withDepth ? ", depth" : "", + useMSAA ? ", MSAAx" : "", useMSAA ? std::to_string(msaaSamples) : "", ")"); + return true; +} + +void VkRenderTarget::destroy(VkDevice device, VmaAllocator allocator) { + if (framebuffer_) { + vkDestroyFramebuffer(device, framebuffer_, nullptr); + framebuffer_ = VK_NULL_HANDLE; + } + if (renderPass_) { + vkDestroyRenderPass(device, renderPass_, nullptr); + renderPass_ = VK_NULL_HANDLE; + } + if (sampler_) { + vkDestroySampler(device, sampler_, nullptr); + sampler_ = VK_NULL_HANDLE; + } + destroyImage(device, allocator, resolveImage_); + destroyImage(device, allocator, depthImage_); + destroyImage(device, allocator, colorImage_); + hasDepth_ = false; + msaaSamples_ = VK_SAMPLE_COUNT_1_BIT; +} + +void VkRenderTarget::beginPass(VkCommandBuffer cmd, const VkClearColorValue& clear) { + VkRenderPassBeginInfo rpBegin{}; + rpBegin.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpBegin.renderPass = renderPass_; + rpBegin.framebuffer = framebuffer_; + rpBegin.renderArea.offset = {0, 0}; + rpBegin.renderArea.extent = getExtent(); + + VkClearValue clearValues[3]{}; + clearValues[0].color = clear; // MSAA color (or single-sample color) + clearValues[1].color = clear; // resolve (only used for MSAA) + clearValues[2].depthStencil = {1.0f, 0}; // depth + + bool useMSAA = msaaSamples_ != VK_SAMPLE_COUNT_1_BIT; + if (useMSAA) { + rpBegin.clearValueCount = hasDepth_ ? 3u : 2u; + } else { + clearValues[1].depthStencil = {1.0f, 0}; // depth is attachment 1 in non-MSAA + rpBegin.clearValueCount = hasDepth_ ? 2u : 1u; + } + rpBegin.pClearValues = clearValues; + + vkCmdBeginRenderPass(cmd, &rpBegin, VK_SUBPASS_CONTENTS_INLINE); + + // Set viewport and scissor to match render target + VkViewport viewport{}; + viewport.x = 0.0f; + viewport.y = 0.0f; + viewport.width = static_cast(colorImage_.extent.width); + viewport.height = static_cast(colorImage_.extent.height); + viewport.minDepth = 0.0f; + viewport.maxDepth = 1.0f; + vkCmdSetViewport(cmd, 0, 1, &viewport); + + VkRect2D scissor{}; + scissor.offset = {0, 0}; + scissor.extent = getExtent(); + vkCmdSetScissor(cmd, 0, 1, &scissor); +} + +void VkRenderTarget::endPass(VkCommandBuffer cmd) { + vkCmdEndRenderPass(cmd); + // Image is now in SHADER_READ_ONLY_OPTIMAL (from render pass finalLayout) +} + +VkDescriptorImageInfo VkRenderTarget::descriptorInfo() const { + VkDescriptorImageInfo info{}; + info.sampler = sampler_; + // Always return the resolved (single-sample) image for shader reads + info.imageView = resolveImage_.imageView ? resolveImage_.imageView : colorImage_.imageView; + info.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + return info; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/vk_shader.cpp b/src/rendering/vk_shader.cpp new file mode 100644 index 00000000..5ebc7a08 --- /dev/null +++ b/src/rendering/vk_shader.cpp @@ -0,0 +1,114 @@ +#include "rendering/vk_shader.hpp" +#include "core/logger.hpp" +#include + +namespace wowee { +namespace rendering { + +VkShaderModule::~VkShaderModule() { + destroy(); +} + +VkShaderModule::VkShaderModule(VkShaderModule&& other) noexcept + : device_(other.device_), module_(other.module_) { + other.module_ = VK_NULL_HANDLE; +} + +VkShaderModule& VkShaderModule::operator=(VkShaderModule&& other) noexcept { + if (this != &other) { + destroy(); + device_ = other.device_; + module_ = other.module_; + other.module_ = VK_NULL_HANDLE; + } + return *this; +} + +bool VkShaderModule::loadFromFile(VkDevice device, const std::string& path) { + std::ifstream file(path, std::ios::ate | std::ios::binary); + if (!file.is_open()) { + LOG_ERROR("Failed to open shader file: ", path); + return false; + } + + size_t fileSize = static_cast(file.tellg()); + if (fileSize == 0 || fileSize % 4 != 0) { + LOG_ERROR("Invalid SPIR-V file size (", fileSize, "): ", path); + return false; + } + + std::vector code(fileSize / sizeof(uint32_t)); + file.seekg(0); + file.read(reinterpret_cast(code.data()), fileSize); + file.close(); + + return loadFromMemory(device, code.data(), fileSize); +} + +bool VkShaderModule::loadFromMemory(VkDevice device, const uint32_t* code, size_t sizeBytes) { + destroy(); + device_ = device; + + VkShaderModuleCreateInfo createInfo{}; + createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + createInfo.codeSize = sizeBytes; + createInfo.pCode = code; + + if (vkCreateShaderModule(device_, &createInfo, nullptr, &module_) != VK_SUCCESS) { + LOG_ERROR("Failed to create shader module"); + return false; + } + + return true; +} + +void VkShaderModule::destroy() { + if (module_ != VK_NULL_HANDLE && device_ != VK_NULL_HANDLE) { + vkDestroyShaderModule(device_, module_, nullptr); + module_ = VK_NULL_HANDLE; + } +} + +VkPipelineShaderStageCreateInfo VkShaderModule::stageInfo( + VkShaderStageFlagBits stage, const char* entryPoint) const +{ + VkPipelineShaderStageCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + info.stage = stage; + info.module = module_; + info.pName = entryPoint; + return info; +} + +VkPipelineShaderStageCreateInfo loadShaderStage(VkDevice device, + const std::string& path, VkShaderStageFlagBits stage) +{ + // This creates a temporary module β€” caller must keep it alive while pipeline is created. + // Prefer using VkShaderModule directly for proper lifetime management. + VkShaderModuleCreateInfo moduleInfo{}; + moduleInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + + std::ifstream file(path, std::ios::ate | std::ios::binary); + std::vector code; + if (file.is_open()) { + size_t fileSize = static_cast(file.tellg()); + code.resize(fileSize / sizeof(uint32_t)); + file.seekg(0); + file.read(reinterpret_cast(code.data()), fileSize); + moduleInfo.codeSize = fileSize; + moduleInfo.pCode = code.data(); + } + + ::VkShaderModule module = VK_NULL_HANDLE; + vkCreateShaderModule(device, &moduleInfo, nullptr, &module); + + VkPipelineShaderStageCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + info.stage = stage; + info.module = module; + info.pName = "main"; + return info; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/vk_texture.cpp b/src/rendering/vk_texture.cpp new file mode 100644 index 00000000..fba6d72b --- /dev/null +++ b/src/rendering/vk_texture.cpp @@ -0,0 +1,395 @@ +#include "rendering/vk_texture.hpp" +#include "rendering/vk_context.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { + +VkTexture::~VkTexture() { + // Must call destroy() explicitly with device/allocator before destruction +} + +VkTexture::VkTexture(VkTexture&& other) noexcept + : image_(other.image_), sampler_(other.sampler_), mipLevels_(other.mipLevels_) { + other.image_ = {}; + other.sampler_ = VK_NULL_HANDLE; +} + +VkTexture& VkTexture::operator=(VkTexture&& other) noexcept { + if (this != &other) { + image_ = other.image_; + sampler_ = other.sampler_; + mipLevels_ = other.mipLevels_; + other.image_ = {}; + other.sampler_ = VK_NULL_HANDLE; + } + return *this; +} + +bool VkTexture::upload(VkContext& ctx, const uint8_t* pixels, uint32_t width, uint32_t height, + VkFormat format, bool generateMips) +{ + if (!pixels || width == 0 || height == 0) return false; + + mipLevels_ = generateMips + ? static_cast(std::floor(std::log2(std::max(width, height)))) + 1 + : 1; + + // Determine bytes per pixel from format + uint32_t bpp = 4; // default RGBA8 + if (format == VK_FORMAT_R8_UNORM) bpp = 1; + else if (format == VK_FORMAT_R8G8_UNORM) bpp = 2; + else if (format == VK_FORMAT_R8G8B8_UNORM) bpp = 3; + + VkDeviceSize imageSize = width * height * bpp; + + // Create staging buffer + AllocatedBuffer staging = createBuffer(ctx.getAllocator(), imageSize, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_ONLY); + + void* mapped; + vmaMapMemory(ctx.getAllocator(), staging.allocation, &mapped); + std::memcpy(mapped, pixels, imageSize); + vmaUnmapMemory(ctx.getAllocator(), staging.allocation); + + // Create image with transfer dst + src (src for mipmap generation) + sampled + VkImageUsageFlags usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + if (generateMips) { + usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + } + image_ = createImage(ctx.getDevice(), ctx.getAllocator(), width, height, + format, usage, VK_SAMPLE_COUNT_1_BIT, mipLevels_); + + if (!image_.image) { + destroyBuffer(ctx.getAllocator(), staging); + return false; + } + + ctx.immediateSubmit([&](VkCommandBuffer cmd) { + // Transition to transfer dst + transitionImageLayout(cmd, image_.image, + VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + + // Copy staging buffer to image (mip 0) + VkBufferImageCopy region{}; + region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.imageSubresource.mipLevel = 0; + region.imageSubresource.layerCount = 1; + region.imageExtent = {width, height, 1}; + + vkCmdCopyBufferToImage(cmd, staging.buffer, image_.image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + + if (!generateMips) { + // Transition to shader read + transitionImageLayout(cmd, image_.image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + } + }); + + if (generateMips) { + generateMipmaps(ctx, format, width, height); + } + + destroyBuffer(ctx.getAllocator(), staging); + return true; +} + +bool VkTexture::uploadMips(VkContext& ctx, const uint8_t* const* mipData, + const uint32_t* mipSizes, uint32_t mipCount, uint32_t width, uint32_t height, VkFormat format) +{ + if (!mipData || mipCount == 0) return false; + + mipLevels_ = mipCount; + + // Calculate total staging size + VkDeviceSize totalSize = 0; + for (uint32_t i = 0; i < mipCount; i++) { + totalSize += mipSizes[i]; + } + + AllocatedBuffer staging = createBuffer(ctx.getAllocator(), totalSize, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_ONLY); + + void* mapped; + vmaMapMemory(ctx.getAllocator(), staging.allocation, &mapped); + VkDeviceSize offset = 0; + for (uint32_t i = 0; i < mipCount; i++) { + std::memcpy(static_cast(mapped) + offset, mipData[i], mipSizes[i]); + offset += mipSizes[i]; + } + vmaUnmapMemory(ctx.getAllocator(), staging.allocation); + + image_ = createImage(ctx.getDevice(), ctx.getAllocator(), width, height, + format, VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_SAMPLE_COUNT_1_BIT, mipLevels_); + + if (!image_.image) { + destroyBuffer(ctx.getAllocator(), staging); + return false; + } + + ctx.immediateSubmit([&](VkCommandBuffer cmd) { + transitionImageLayout(cmd, image_.image, + VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + + VkDeviceSize bufOffset = 0; + uint32_t mipW = width, mipH = height; + for (uint32_t i = 0; i < mipCount; i++) { + VkBufferImageCopy region{}; + region.bufferOffset = bufOffset; + region.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + region.imageSubresource.mipLevel = i; + region.imageSubresource.layerCount = 1; + region.imageExtent = {mipW, mipH, 1}; + + vkCmdCopyBufferToImage(cmd, staging.buffer, image_.image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + + bufOffset += mipSizes[i]; + mipW = std::max(1u, mipW / 2); + mipH = std::max(1u, mipH / 2); + } + + transitionImageLayout(cmd, image_.image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + }); + + destroyBuffer(ctx.getAllocator(), staging); + return true; +} + +bool VkTexture::createDepth(VkContext& ctx, uint32_t width, uint32_t height, VkFormat format) { + mipLevels_ = 1; + + image_ = createImage(ctx.getDevice(), ctx.getAllocator(), width, height, + format, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + + if (!image_.image) return false; + + ctx.immediateSubmit([&](VkCommandBuffer cmd) { + transitionImageLayout(cmd, image_.image, + VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, + VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT); + }); + + return true; +} + +bool VkTexture::createSampler(VkDevice device, + VkFilter minFilter, VkFilter magFilter, + VkSamplerAddressMode addressMode, float maxAnisotropy) +{ + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = minFilter; + samplerInfo.magFilter = magFilter; + samplerInfo.addressModeU = addressMode; + samplerInfo.addressModeV = addressMode; + samplerInfo.addressModeW = addressMode; + samplerInfo.anisotropyEnable = maxAnisotropy > 1.0f ? VK_TRUE : VK_FALSE; + samplerInfo.maxAnisotropy = maxAnisotropy; + samplerInfo.borderColor = VK_BORDER_COLOR_INT_OPAQUE_BLACK; + samplerInfo.unnormalizedCoordinates = VK_FALSE; + samplerInfo.compareEnable = VK_FALSE; + samplerInfo.mipmapMode = (minFilter == VK_FILTER_LINEAR) + ? VK_SAMPLER_MIPMAP_MODE_LINEAR : VK_SAMPLER_MIPMAP_MODE_NEAREST; + samplerInfo.mipLodBias = 0.0f; + samplerInfo.minLod = 0.0f; + samplerInfo.maxLod = static_cast(mipLevels_); + + if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) { + LOG_ERROR("Failed to create texture sampler"); + return false; + } + + return true; +} + +bool VkTexture::createSampler(VkDevice device, + VkFilter filter, + VkSamplerAddressMode addressModeU, + VkSamplerAddressMode addressModeV, + float maxAnisotropy) +{ + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = filter; + samplerInfo.magFilter = filter; + samplerInfo.addressModeU = addressModeU; + samplerInfo.addressModeV = addressModeV; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT; + samplerInfo.anisotropyEnable = maxAnisotropy > 1.0f ? VK_TRUE : VK_FALSE; + samplerInfo.maxAnisotropy = maxAnisotropy; + samplerInfo.borderColor = VK_BORDER_COLOR_INT_OPAQUE_BLACK; + samplerInfo.unnormalizedCoordinates = VK_FALSE; + samplerInfo.compareEnable = VK_FALSE; + samplerInfo.mipmapMode = (filter == VK_FILTER_LINEAR) + ? VK_SAMPLER_MIPMAP_MODE_LINEAR : VK_SAMPLER_MIPMAP_MODE_NEAREST; + samplerInfo.mipLodBias = 0.0f; + samplerInfo.minLod = 0.0f; + samplerInfo.maxLod = static_cast(mipLevels_); + + if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) { + LOG_ERROR("Failed to create texture sampler"); + return false; + } + + return true; +} + +bool VkTexture::createShadowSampler(VkDevice device) { + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = VK_FILTER_NEAREST; + samplerInfo.magFilter = VK_FILTER_NEAREST; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER; + samplerInfo.borderColor = VK_BORDER_COLOR_FLOAT_OPAQUE_WHITE; + samplerInfo.compareEnable = VK_TRUE; + samplerInfo.compareOp = VK_COMPARE_OP_GREATER_OR_EQUAL; + samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + samplerInfo.minLod = 0.0f; + samplerInfo.maxLod = 1.0f; + + if (vkCreateSampler(device, &samplerInfo, nullptr, &sampler_) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow sampler"); + return false; + } + + return true; +} + +void VkTexture::destroy(VkDevice device, VmaAllocator allocator) { + if (sampler_ != VK_NULL_HANDLE) { + vkDestroySampler(device, sampler_, nullptr); + sampler_ = VK_NULL_HANDLE; + } + destroyImage(device, allocator, image_); +} + +VkDescriptorImageInfo VkTexture::descriptorInfo(VkImageLayout layout) const { + VkDescriptorImageInfo info{}; + info.sampler = sampler_; + info.imageView = image_.imageView; + info.imageLayout = layout; + return info; +} + +void VkTexture::generateMipmaps(VkContext& ctx, VkFormat format, + uint32_t width, uint32_t height) +{ + // Check if format supports linear blitting + VkFormatProperties formatProperties; + vkGetPhysicalDeviceFormatProperties(ctx.getPhysicalDevice(), format, &formatProperties); + + bool canBlit = (formatProperties.optimalTilingFeatures & + VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT) != 0; + + if (!canBlit) { + LOG_WARNING("Format does not support linear blitting for mipmap generation"); + // Fall back to simple transition + ctx.immediateSubmit([&](VkCommandBuffer cmd) { + transitionImageLayout(cmd, image_.image, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + }); + return; + } + + ctx.immediateSubmit([&](VkCommandBuffer cmd) { + int32_t mipW = static_cast(width); + int32_t mipH = static_cast(height); + + for (uint32_t i = 1; i < mipLevels_; i++) { + // Transition previous mip to transfer src + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.image = image_.image; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = i - 1; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + + // Blit from previous mip to current + VkImageBlit blit{}; + blit.srcOffsets[0] = {0, 0, 0}; + blit.srcOffsets[1] = {mipW, mipH, 1}; + blit.srcSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.srcSubresource.mipLevel = i - 1; + blit.srcSubresource.layerCount = 1; + blit.dstOffsets[0] = {0, 0, 0}; + blit.dstOffsets[1] = { + mipW > 1 ? mipW / 2 : 1, + mipH > 1 ? mipH / 2 : 1, + 1 + }; + blit.dstSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + blit.dstSubresource.mipLevel = i; + blit.dstSubresource.layerCount = 1; + + vkCmdBlitImage(cmd, + image_.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + image_.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + 1, &blit, VK_FILTER_LINEAR); + + // Transition previous mip to shader read + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + + mipW = mipW > 1 ? mipW / 2 : 1; + mipH = mipH > 1 ? mipH / 2 : 1; + } + + // Transition last mip to shader read + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.image = image_.image; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.baseMipLevel = mipLevels_ - 1; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + }); +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/vk_utils.cpp b/src/rendering/vk_utils.cpp new file mode 100644 index 00000000..d105c986 --- /dev/null +++ b/src/rendering/vk_utils.cpp @@ -0,0 +1,208 @@ +#include "rendering/vk_utils.hpp" +#include "rendering/vk_context.hpp" +#include "core/logger.hpp" +#include + +namespace wowee { +namespace rendering { + +AllocatedBuffer createBuffer(VmaAllocator allocator, VkDeviceSize size, + VkBufferUsageFlags usage, VmaMemoryUsage memoryUsage) +{ + AllocatedBuffer result{}; + + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = size; + bufInfo.usage = usage; + bufInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = memoryUsage; + if (memoryUsage == VMA_MEMORY_USAGE_CPU_TO_GPU || memoryUsage == VMA_MEMORY_USAGE_CPU_ONLY) { + allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + } + + if (vmaCreateBuffer(allocator, &bufInfo, &allocInfo, + &result.buffer, &result.allocation, &result.info) != VK_SUCCESS) { + LOG_ERROR("Failed to create VMA buffer (size=", size, ")"); + } + + return result; +} + +void destroyBuffer(VmaAllocator allocator, AllocatedBuffer& buffer) { + if (buffer.buffer) { + vmaDestroyBuffer(allocator, buffer.buffer, buffer.allocation); + buffer.buffer = VK_NULL_HANDLE; + buffer.allocation = VK_NULL_HANDLE; + } +} + +AllocatedImage createImage(VkDevice device, VmaAllocator allocator, + uint32_t width, uint32_t height, VkFormat format, + VkImageUsageFlags usage, VkSampleCountFlagBits samples, uint32_t mipLevels) +{ + AllocatedImage result{}; + result.extent = {width, height}; + result.format = format; + + VkImageCreateInfo imgInfo{}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = format; + imgInfo.extent = {width, height, 1}; + imgInfo.mipLevels = mipLevels; + imgInfo.arrayLayers = 1; + imgInfo.samples = samples; + imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imgInfo.usage = usage; + imgInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + VmaAllocationCreateInfo allocInfo{}; + allocInfo.usage = VMA_MEMORY_USAGE_GPU_ONLY; + + if (vmaCreateImage(allocator, &imgInfo, &allocInfo, + &result.image, &result.allocation, nullptr) != VK_SUCCESS) { + LOG_ERROR("Failed to create VMA image (", width, "x", height, ")"); + return result; + } + + // Create image view + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = result.image; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = format; + + // Determine aspect mask from format + if (format == VK_FORMAT_D32_SFLOAT || format == VK_FORMAT_D16_UNORM || + format == VK_FORMAT_D24_UNORM_S8_UINT || format == VK_FORMAT_D32_SFLOAT_S8_UINT) { + viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + } else { + viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + } + viewInfo.subresourceRange.baseMipLevel = 0; + viewInfo.subresourceRange.levelCount = mipLevels; + viewInfo.subresourceRange.baseArrayLayer = 0; + viewInfo.subresourceRange.layerCount = 1; + + if (vkCreateImageView(device, &viewInfo, nullptr, &result.imageView) != VK_SUCCESS) { + LOG_ERROR("Failed to create image view"); + } + + return result; +} + +void destroyImage(VkDevice device, VmaAllocator allocator, AllocatedImage& image) { + if (image.imageView) { + vkDestroyImageView(device, image.imageView, nullptr); + image.imageView = VK_NULL_HANDLE; + } + if (image.image) { + vmaDestroyImage(allocator, image.image, image.allocation); + image.image = VK_NULL_HANDLE; + image.allocation = VK_NULL_HANDLE; + } +} + +void transitionImageLayout(VkCommandBuffer cmd, VkImage image, + VkImageLayout oldLayout, VkImageLayout newLayout, + VkPipelineStageFlags srcStage, VkPipelineStageFlags dstStage) +{ + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = oldLayout; + barrier.newLayout = newLayout; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = VK_REMAINING_MIP_LEVELS; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = VK_REMAINING_ARRAY_LAYERS; + + if (newLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL || + newLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL) { + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + } else { + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + } + + // Set access masks based on layouts + switch (oldLayout) { + case VK_IMAGE_LAYOUT_UNDEFINED: + barrier.srcAccessMask = 0; + break; + case VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL: + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + break; + case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL: + barrier.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + break; + case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL: + barrier.srcAccessMask = VK_ACCESS_SHADER_READ_BIT; + break; + default: + barrier.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT; + break; + } + + switch (newLayout) { + case VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL: + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + break; + case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL: + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + break; + case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL: + barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + break; + case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL: + barrier.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + break; + case VK_IMAGE_LAYOUT_PRESENT_SRC_KHR: + barrier.dstAccessMask = 0; + break; + default: + barrier.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT; + break; + } + + vkCmdPipelineBarrier(cmd, srcStage, dstStage, 0, + 0, nullptr, 0, nullptr, 1, &barrier); +} + +AllocatedBuffer uploadBuffer(VkContext& ctx, const void* data, VkDeviceSize size, + VkBufferUsageFlags usage) +{ + // Create staging buffer + AllocatedBuffer staging = createBuffer(ctx.getAllocator(), size, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VMA_MEMORY_USAGE_CPU_ONLY); + + // Copy data to staging + void* mapped; + vmaMapMemory(ctx.getAllocator(), staging.allocation, &mapped); + std::memcpy(mapped, data, size); + vmaUnmapMemory(ctx.getAllocator(), staging.allocation); + + // Create GPU buffer + AllocatedBuffer gpuBuffer = createBuffer(ctx.getAllocator(), size, + usage | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VMA_MEMORY_USAGE_GPU_ONLY); + + // Copy staging -> GPU + ctx.immediateSubmit([&](VkCommandBuffer cmd) { + VkBufferCopy copyRegion{}; + copyRegion.size = size; + vkCmdCopyBuffer(cmd, staging.buffer, gpuBuffer.buffer, 1, ©Region); + }); + + // Destroy staging buffer + destroyBuffer(ctx.getAllocator(), staging); + + return gpuBuffer; +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index fc43130a..89c1e509 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -1,216 +1,573 @@ #include "rendering/water_renderer.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" +#include "rendering/vk_frame_data.hpp" #include "rendering/camera.hpp" #include "pipeline/adt_loader.hpp" #include "pipeline/wmo_loader.hpp" #include "core/logger.hpp" -#include #include #include #include +#include #include +#include namespace wowee { namespace rendering { +// Matches set 1 binding 0 in water.frag.glsl +struct WaterMaterialUBO { + glm::vec4 waterColor; + float waterAlpha; + float shimmerStrength; + float alphaScale; + float _pad; +}; + +// Push constants matching water.vert.glsl +struct WaterPushConstants { + glm::mat4 model; + float waveAmp; + float waveFreq; + float waveSpeed; + float liquidBasicType; // 0=water, 1=ocean, 2=magma, 3=slime +}; + +// Matches set 2 binding 3 in water.frag.glsl +struct ReflectionUBOData { + glm::mat4 reflViewProj; +}; + WaterRenderer::WaterRenderer() = default; WaterRenderer::~WaterRenderer() { shutdown(); } -bool WaterRenderer::initialize() { - LOG_INFO("Initializing water renderer"); +bool WaterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { + vkCtx = ctx; + if (!vkCtx) return false; - // Create water shader - waterShader = std::make_unique(); + LOG_INFO("Initializing water renderer (Vulkan)"); + VkDevice device = vkCtx->getDevice(); - // Vertex shader - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec3 aNormal; - layout (location = 2) in vec2 aTexCoord; + // --- Material descriptor set layout (set 1) --- + // binding 0: WaterMaterial UBO + VkDescriptorSetLayoutBinding matBinding{}; + matBinding.binding = 0; + matBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + matBinding.descriptorCount = 1; + matBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - uniform mat4 model; - uniform mat4 view; - uniform mat4 projection; - uniform float time; - uniform float waveAmp; - uniform float waveFreq; - uniform float waveSpeed; - uniform vec3 viewPos; - - out vec3 FragPos; - out vec3 Normal; - out vec2 TexCoord; - out float WaveOffset; - - void main() { - vec3 pos = aPos; - - // Distance from camera for LOD blending - float dist = length(viewPos - aPos); - float gridBlend = smoothstep(150.0, 400.0, dist); // 0=close (seamless), 1=far (grid effect) - - // Seamless waves (continuous across tiles) - float w1_seamless = sin((aPos.x + time * waveSpeed) * waveFreq) * waveAmp; - float w2_seamless = cos((aPos.y - time * (waveSpeed * 0.78)) * (waveFreq * 0.82)) * (waveAmp * 0.72); - float w3_seamless = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + aPos.y * 0.3) * waveFreq * 2.1) * (waveAmp * 0.35); - float w4_seamless = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + aPos.x * 0.2) * waveFreq * 1.8) * (waveAmp * 0.28); - - // Grid effect waves (per-vertex randomization for distance view) - float hash1 = fract(sin(dot(aPos.xy, vec2(12.9898, 78.233))) * 43758.5453); - float hash2 = fract(sin(dot(aPos.xy, vec2(93.9898, 67.345))) * 27153.5328); - float w1_grid = sin((aPos.x + time * waveSpeed + hash1 * 6.28) * waveFreq) * waveAmp; - float w2_grid = cos((aPos.y - time * (waveSpeed * 0.78) + hash2 * 6.28) * (waveFreq * 0.82)) * (waveAmp * 0.72); - float w3_grid = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + hash1 * 3.14) * waveFreq * 2.1) * (waveAmp * 0.35); - float w4_grid = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + hash2 * 3.14) * waveFreq * 1.8) * (waveAmp * 0.28); - - // Blend between seamless (close) and grid (far) - float wave = mix( - w1_seamless + w2_seamless + w3_seamless + w4_seamless, - w1_grid + w2_grid + w3_grid + w4_grid, - gridBlend - ); - pos.z += wave; - - FragPos = vec3(model * vec4(pos, 1.0)); - // Use mat3(model) directly - avoids expensive inverse() per vertex - Normal = mat3(model) * aNormal; - TexCoord = aTexCoord; - WaveOffset = wave; - - gl_Position = projection * view * vec4(FragPos, 1.0); - } - )"; - - // Fragment shader - const char* fragmentShaderSource = R"( - #version 330 core - in vec3 FragPos; - in vec3 Normal; - in vec2 TexCoord; - in float WaveOffset; - - uniform vec3 viewPos; - uniform vec4 waterColor; - uniform float waterAlpha; - uniform float time; - uniform float shimmerStrength; - uniform float alphaScale; - - uniform vec3 uFogColor; - uniform float uFogStart; - uniform float uFogEnd; - - out vec4 FragColor; - - void main() { - // Normalize interpolated normal - vec3 norm = normalize(Normal); - - // Simple directional light (sun) - vec3 lightDir = normalize(vec3(0.5, 1.0, 0.3)); - float diff = max(dot(norm, lightDir), 0.0); - - // Specular highlights (shininess for water) - vec3 viewDir = normalize(viewPos - FragPos); - vec3 reflectDir = reflect(-lightDir, norm); - float specBase = pow(max(dot(viewDir, reflectDir), 0.0), mix(64.0, 180.0, shimmerStrength)); - float sparkle = 0.65 + 0.35 * sin((TexCoord.x + TexCoord.y + time * 0.4) * 80.0); - float spec = specBase * mix(1.0, sparkle, shimmerStrength); - - // Animated texture coordinates for flowing effect - vec2 uv1 = TexCoord + vec2(time * 0.02, time * 0.01); - vec2 uv2 = TexCoord + vec2(-time * 0.01, time * 0.015); - - // Combine lighting - vec3 ambient = vec3(0.3) * waterColor.rgb; - vec3 diffuse = vec3(0.6) * diff * waterColor.rgb; - vec3 specular = vec3(1.0) * spec; - - // Add wave offset to brightness - float brightness = 1.0 + WaveOffset * 0.1; - - vec3 result = (ambient + diffuse + specular) * brightness; - // Add a subtle sky tint and luminance floor so large ocean sheets - // never turn black at grazing angles. - float horizon = pow(1.0 - max(dot(norm, viewDir), 0.0), 1.6); - vec3 skyTint = vec3(0.22, 0.35, 0.48) * (0.25 + 0.55 * shimmerStrength) * horizon; - result += skyTint; - result = max(result, waterColor.rgb * 0.24); - - // Subtle foam on wave crests only (no grid artifacts) - float wavePeak = smoothstep(0.35, 0.6, WaveOffset); // Only highest peaks - float foam = wavePeak * 0.25; // Subtle white highlight - result += vec3(foam); - - // Slight fresnel: more reflective/opaque at grazing angles. - float fresnel = pow(1.0 - max(dot(norm, viewDir), 0.0), 3.0); - - // Distance-based opacity: distant water is more opaque to hide underwater objects - float dist = length(viewPos - FragPos); - float distFade = smoothstep(40.0, 300.0, dist); // Start at 40 units, full opaque at 300 - float distAlpha = mix(0.0, 0.75, distFade); // Add up to 75% opacity at distance - - float alpha = clamp(waterAlpha * alphaScale * (0.80 + fresnel * 0.45) + distAlpha, 0.20, 0.98); - - // Apply distance fog - float fogDist = length(viewPos - FragPos); - float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); - vec3 finalColor = mix(uFogColor, result, fogFactor); - - FragColor = vec4(finalColor, alpha); - } - )"; - - if (!waterShader->loadFromSource(vertexShaderSource, fragmentShaderSource)) { - LOG_ERROR("Failed to create water shader"); + materialSetLayout = createDescriptorSetLayout(device, { matBinding }); + if (!materialSetLayout) { + LOG_ERROR("WaterRenderer: failed to create material set layout"); return false; } - LOG_INFO("Water renderer initialized"); + // --- Descriptor pool --- + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSize.descriptorCount = MAX_WATER_SETS; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = MAX_WATER_SETS; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &materialDescPool) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create descriptor pool"); + return false; + } + + // --- Scene history + reflection descriptor set layout (set 2) --- + VkDescriptorSetLayoutBinding sceneColorBinding{}; + sceneColorBinding.binding = 0; + sceneColorBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + sceneColorBinding.descriptorCount = 1; + sceneColorBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutBinding sceneDepthBinding{}; + sceneDepthBinding.binding = 1; + sceneDepthBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + sceneDepthBinding.descriptorCount = 1; + sceneDepthBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutBinding reflColorBinding{}; + reflColorBinding.binding = 2; + reflColorBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + reflColorBinding.descriptorCount = 1; + reflColorBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutBinding reflUBOBinding{}; + reflUBOBinding.binding = 3; + reflUBOBinding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + reflUBOBinding.descriptorCount = 1; + reflUBOBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + sceneSetLayout = createDescriptorSetLayout(device, + {sceneColorBinding, sceneDepthBinding, reflColorBinding, reflUBOBinding}); + if (!sceneSetLayout) { + LOG_ERROR("WaterRenderer: failed to create scene set layout"); + return false; + } + + // Pool needs 3 combined image samplers + 1 uniform buffer + std::array scenePoolSizes{}; + scenePoolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + scenePoolSizes[0].descriptorCount = 3; + scenePoolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + scenePoolSizes[1].descriptorCount = 1; + VkDescriptorPoolCreateInfo scenePoolInfo{}; + scenePoolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + scenePoolInfo.maxSets = 1; + scenePoolInfo.poolSizeCount = static_cast(scenePoolSizes.size()); + scenePoolInfo.pPoolSizes = scenePoolSizes.data(); + if (vkCreateDescriptorPool(device, &scenePoolInfo, nullptr, &sceneDescPool) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene descriptor pool"); + return false; + } + + // --- Pipeline layout --- + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(WaterPushConstants); + + std::vector setLayouts = { perFrameLayout, materialSetLayout, sceneSetLayout }; + pipelineLayout = createPipelineLayout(device, setLayouts, { pushRange }); + if (!pipelineLayout) { + LOG_ERROR("WaterRenderer: failed to create pipeline layout"); + return false; + } + + // Create reflection resources FIRST so reflectionUBO exists when + // createSceneHistoryResources writes descriptor binding 3 + createReflectionResources(); + + createSceneHistoryResources(vkCtx->getSwapchainExtent(), + vkCtx->getSwapchainFormat(), + vkCtx->getDepthFormat()); + + // --- Shaders --- + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/water.vert.spv")) { + LOG_ERROR("WaterRenderer: failed to load vertex shader"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/water.frag.spv")) { + LOG_ERROR("WaterRenderer: failed to load fragment shader"); + return false; + } + + // --- Vertex input (interleaved: pos3 + normal3 + uv2 = 8 floats = 32 bytes) --- + VkVertexInputBindingDescription vertBinding{}; + vertBinding.binding = 0; + vertBinding.stride = 8 * sizeof(float); + vertBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + // Water vertex shader only takes aPos(vec3) at loc 0 and aTexCoord(vec2) at loc 1 + // (normal is computed in shader from wave derivatives) + std::vector vertAttribs = { + { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0 }, // aPos + { 1, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float) }, // aTexCoord (skip normal) + }; + + VkRenderPass mainPass = vkCtx->getImGuiRenderPass(); + + waterPipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertBinding }, vertAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) // depth test yes, write no + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!waterPipeline) { + LOG_ERROR("WaterRenderer: failed to create pipeline"); + return false; + } + + LOG_INFO("Water renderer initialized (Vulkan)"); return true; } +void WaterRenderer::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + + destroyReflectionResources(); + createReflectionResources(); + createSceneHistoryResources(vkCtx->getSwapchainExtent(), + vkCtx->getSwapchainFormat(), + vkCtx->getDepthFormat()); + + // Destroy old pipeline (keep layout) + if (waterPipeline) { vkDestroyPipeline(device, waterPipeline, nullptr); waterPipeline = VK_NULL_HANDLE; } + + // Load shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/water.vert.spv")) { + LOG_ERROR("WaterRenderer::recreatePipelines: failed to load vertex shader"); + return; + } + if (!fragShader.loadFromFile(device, "assets/shaders/water.frag.spv")) { + LOG_ERROR("WaterRenderer::recreatePipelines: failed to load fragment shader"); + vertShader.destroy(); + return; + } + + // Vertex input (same as initialize) + VkVertexInputBindingDescription vertBinding{}; + vertBinding.binding = 0; + vertBinding.stride = 8 * sizeof(float); + vertBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector vertAttribs = { + { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0 }, + { 1, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float) }, + }; + + VkRenderPass mainPass = vkCtx->getImGuiRenderPass(); + + waterPipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertBinding }, vertAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!waterPipeline) { + LOG_ERROR("WaterRenderer::recreatePipelines: failed to create pipeline"); + } +} + void WaterRenderer::shutdown() { clear(); - waterShader.reset(); + + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + vkDeviceWaitIdle(device); + + destroyWater1xResources(); + destroyReflectionResources(); + destroySceneHistoryResources(); + if (waterPipeline) { vkDestroyPipeline(device, waterPipeline, nullptr); waterPipeline = VK_NULL_HANDLE; } + if (pipelineLayout) { vkDestroyPipelineLayout(device, pipelineLayout, nullptr); pipelineLayout = VK_NULL_HANDLE; } + if (sceneDescPool) { vkDestroyDescriptorPool(device, sceneDescPool, nullptr); sceneDescPool = VK_NULL_HANDLE; } + if (sceneSetLayout) { vkDestroyDescriptorSetLayout(device, sceneSetLayout, nullptr); sceneSetLayout = VK_NULL_HANDLE; } + if (materialDescPool) { vkDestroyDescriptorPool(device, materialDescPool, nullptr); materialDescPool = VK_NULL_HANDLE; } + if (materialSetLayout) { vkDestroyDescriptorSetLayout(device, materialSetLayout, nullptr); materialSetLayout = VK_NULL_HANDLE; } + + vkCtx = nullptr; } +VkDescriptorSet WaterRenderer::allocateMaterialSet() { + VkDescriptorSetAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.descriptorPool = materialDescPool; + allocInfo.descriptorSetCount = 1; + allocInfo.pSetLayouts = &materialSetLayout; + + VkDescriptorSet set = VK_NULL_HANDLE; + if (vkAllocateDescriptorSets(vkCtx->getDevice(), &allocInfo, &set) != VK_SUCCESS) { + return VK_NULL_HANDLE; + } + return set; +} + +void WaterRenderer::destroySceneHistoryResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + if (sceneColorView) { vkDestroyImageView(device, sceneColorView, nullptr); sceneColorView = VK_NULL_HANDLE; } + if (sceneDepthView) { vkDestroyImageView(device, sceneDepthView, nullptr); sceneDepthView = VK_NULL_HANDLE; } + if (sceneColorImage) { vmaDestroyImage(vkCtx->getAllocator(), sceneColorImage, sceneColorAlloc); sceneColorImage = VK_NULL_HANDLE; sceneColorAlloc = VK_NULL_HANDLE; } + if (sceneDepthImage) { vmaDestroyImage(vkCtx->getAllocator(), sceneDepthImage, sceneDepthAlloc); sceneDepthImage = VK_NULL_HANDLE; sceneDepthAlloc = VK_NULL_HANDLE; } + if (sceneColorSampler) { vkDestroySampler(device, sceneColorSampler, nullptr); sceneColorSampler = VK_NULL_HANDLE; } + if (sceneDepthSampler) { vkDestroySampler(device, sceneDepthSampler, nullptr); sceneDepthSampler = VK_NULL_HANDLE; } + sceneSet = VK_NULL_HANDLE; + sceneHistoryExtent = {0, 0}; + sceneHistoryReady = false; +} + +void WaterRenderer::createSceneHistoryResources(VkExtent2D extent, VkFormat colorFormat, VkFormat depthFormat) { + if (!vkCtx || extent.width == 0 || extent.height == 0 || !sceneSetLayout || !sceneDescPool) return; + VkDevice device = vkCtx->getDevice(); + + destroySceneHistoryResources(); + vkResetDescriptorPool(device, sceneDescPool, 0); + sceneHistoryExtent = extent; + + VkImageCreateInfo colorImgInfo{}; + colorImgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + colorImgInfo.imageType = VK_IMAGE_TYPE_2D; + colorImgInfo.format = colorFormat; + colorImgInfo.extent = {extent.width, extent.height, 1}; + colorImgInfo.mipLevels = 1; + colorImgInfo.arrayLayers = 1; + colorImgInfo.samples = VK_SAMPLE_COUNT_1_BIT; + colorImgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + colorImgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + colorImgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY; + if (vmaCreateImage(vkCtx->getAllocator(), &colorImgInfo, &allocCI, &sceneColorImage, &sceneColorAlloc, nullptr) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene color history image"); + return; + } + + VkImageCreateInfo depthImgInfo = colorImgInfo; + depthImgInfo.format = depthFormat; + if (vmaCreateImage(vkCtx->getAllocator(), &depthImgInfo, &allocCI, &sceneDepthImage, &sceneDepthAlloc, nullptr) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene depth history image"); + return; + } + + VkImageViewCreateInfo colorViewInfo{}; + colorViewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + colorViewInfo.image = sceneColorImage; + colorViewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + colorViewInfo.format = colorFormat; + colorViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + colorViewInfo.subresourceRange.levelCount = 1; + colorViewInfo.subresourceRange.layerCount = 1; + if (vkCreateImageView(device, &colorViewInfo, nullptr, &sceneColorView) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene color history view"); + return; + } + + VkImageViewCreateInfo depthViewInfo = colorViewInfo; + depthViewInfo.image = sceneDepthImage; + depthViewInfo.format = depthFormat; + depthViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + if (vkCreateImageView(device, &depthViewInfo, nullptr, &sceneDepthView) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene depth history view"); + return; + } + + VkSamplerCreateInfo sampCI{}; + sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + sampCI.magFilter = VK_FILTER_LINEAR; + sampCI.minFilter = VK_FILTER_LINEAR; + sampCI.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampCI.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampCI.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + if (vkCreateSampler(device, &sampCI, nullptr, &sceneColorSampler) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene color sampler"); + return; + } + sampCI.magFilter = VK_FILTER_NEAREST; + sampCI.minFilter = VK_FILTER_NEAREST; + if (vkCreateSampler(device, &sampCI, nullptr, &sceneDepthSampler) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene depth sampler"); + return; + } + + VkDescriptorSetAllocateInfo ai{}; + ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + ai.descriptorPool = sceneDescPool; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &sceneSetLayout; + if (vkAllocateDescriptorSets(device, &ai, &sceneSet) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to allocate scene descriptor set"); + sceneSet = VK_NULL_HANDLE; + return; + } + + VkDescriptorImageInfo colorInfo{}; + colorInfo.sampler = sceneColorSampler; + colorInfo.imageView = sceneColorView; + colorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkDescriptorImageInfo depthInfo{}; + depthInfo.sampler = sceneDepthSampler; + depthInfo.imageView = sceneDepthView; + depthInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + // Reflection color texture (binding 2) β€” use scene color as placeholder until reflection is created + VkDescriptorImageInfo reflColorInfo{}; + reflColorInfo.sampler = sceneColorSampler; + reflColorInfo.imageView = reflectionColorView ? reflectionColorView : sceneColorView; + reflColorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + // Reflection UBO (binding 3) + VkDescriptorBufferInfo reflUBOInfo{}; + reflUBOInfo.buffer = reflectionUBO; + reflUBOInfo.offset = 0; + reflUBOInfo.range = sizeof(ReflectionUBOData); + + // Write bindings 0,1 always; write 2,3 only if reflection resources exist + std::vector writes; + + VkWriteDescriptorSet w0{}; + w0.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w0.dstSet = sceneSet; + w0.dstBinding = 0; + w0.descriptorCount = 1; + w0.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w0.pImageInfo = &colorInfo; + writes.push_back(w0); + + VkWriteDescriptorSet w1{}; + w1.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w1.dstSet = sceneSet; + w1.dstBinding = 1; + w1.descriptorCount = 1; + w1.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w1.pImageInfo = &depthInfo; + writes.push_back(w1); + + VkWriteDescriptorSet w2{}; + w2.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w2.dstSet = sceneSet; + w2.dstBinding = 2; + w2.descriptorCount = 1; + w2.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w2.pImageInfo = &reflColorInfo; + writes.push_back(w2); + + if (reflectionUBO) { + VkWriteDescriptorSet w3{}; + w3.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w3.dstSet = sceneSet; + w3.dstBinding = 3; + w3.descriptorCount = 1; + w3.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + w3.pBufferInfo = &reflUBOInfo; + writes.push_back(w3); + } + + vkUpdateDescriptorSets(device, static_cast(writes.size()), writes.data(), 0, nullptr); + + // Initialize history images to shader-read layout so first frame samples are defined. + vkCtx->immediateSubmit([&](VkCommandBuffer cmd) { + VkImageMemoryBarrier barriers[2]{}; + barriers[0].sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barriers[0].oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barriers[0].newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barriers[0].image = sceneColorImage; + barriers[0].subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + barriers[0].dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + + barriers[1] = barriers[0]; + barriers[1].image = sceneDepthImage; + barriers[1].subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 2, barriers); + }); +} + +void WaterRenderer::updateMaterialUBO(WaterSurface& surface) { + glm::vec4 color = getLiquidColor(surface.liquidType); + float alpha = getLiquidAlpha(surface.liquidType); + + // WMO liquid material override + if (surface.wmoId != 0) { + const uint8_t basicType = (surface.liquidType == 0) ? 0 : ((surface.liquidType - 1) % 4); + if (basicType == 2 || basicType == 3) { + color = glm::vec4(0.2f, 0.4f, 0.6f, 1.0f); + alpha = 0.45f; + } + } + + bool canalProfile = (surface.wmoId != 0) || (surface.liquidType == 5); + float shimmerStrength = canalProfile ? 0.95f : 0.50f; + float alphaScale = canalProfile ? 0.90f : 1.00f; + + WaterMaterialUBO mat{}; + mat.waterColor = color; + mat.waterAlpha = alpha; + mat.shimmerStrength = shimmerStrength; + mat.alphaScale = alphaScale; + + // Create UBO + VkBufferCreateInfo bufCI{}; + bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufCI.size = sizeof(WaterMaterialUBO); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + vmaCreateBuffer(vkCtx->getAllocator(), &bufCI, &allocCI, + &surface.materialUBO, &surface.materialAlloc, &mapInfo); + if (mapInfo.pMappedData) { + std::memcpy(mapInfo.pMappedData, &mat, sizeof(mat)); + } + + // Allocate and write descriptor set + surface.materialSet = allocateMaterialSet(); + if (surface.materialSet) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = surface.materialUBO; + bufInfo.offset = 0; + bufInfo.range = sizeof(WaterMaterialUBO); + + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = surface.materialSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + write.pBufferInfo = &bufInfo; + + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr); + } +} + +// ============================================================== +// Data loading (preserved from GL version β€” no GL calls) +// ============================================================== + void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool append, int tileX, int tileY) { constexpr float TILE_SIZE = 33.33333f / 8.0f; if (!append) { - LOG_DEBUG("Loading water from terrain (replacing)"); clear(); - } else { - LOG_DEBUG("Loading water from terrain (appending)"); } - // Load water surfaces from MH2O data int totalLayers = 0; for (int chunkIdx = 0; chunkIdx < 256; chunkIdx++) { const auto& chunkWater = terrain.waterData[chunkIdx]; + if (!chunkWater.hasWater()) continue; - if (!chunkWater.hasWater()) { - continue; - } - - // Get the terrain chunk for position reference int chunkX = chunkIdx % 16; int chunkY = chunkIdx / 16; const auto& terrainChunk = terrain.getChunk(chunkX, chunkY); - // Process each water layer in this chunk for (const auto& layer : chunkWater.layers) { WaterSurface surface; - // Use the chunk base position - layer offsets will be applied in mesh generation - // to match terrain's coordinate transformation surface.position = glm::vec3( terrainChunk.position[0], terrainChunk.position[1], @@ -224,85 +581,51 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f); surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f); - // Debug log first few water surfaces - if (totalLayers < 5) { - LOG_DEBUG("Water layer ", totalLayers, ": chunk=", chunkIdx, - " liquidType=", layer.liquidType, - " offset=(", (int)layer.x, ",", (int)layer.y, ")", - " size=", (int)layer.width, "x", (int)layer.height, - " height range=[", layer.minHeight, ",", layer.maxHeight, "]"); - } - surface.minHeight = layer.minHeight; surface.maxHeight = layer.maxHeight; surface.liquidType = layer.liquidType; - // Store dimensions surface.xOffset = layer.x; surface.yOffset = layer.y; surface.width = layer.width; surface.height = layer.height; - // Prefer per-vertex terrain water heights when sane; fall back to flat - // minHeight if data looks malformed (prevents sky-stretch artifacts). size_t numVertices = (layer.width + 1) * (layer.height + 1); bool useFlat = true; if (layer.heights.size() == numVertices) { bool sane = true; for (float h : layer.heights) { - if (!std::isfinite(h) || std::abs(h) > 50000.0f) { - sane = false; - break; - } - // Conservative acceptance window around MH2O min/max metadata. - if (h < layer.minHeight - 8.0f || h > layer.maxHeight + 8.0f) { - sane = false; - break; - } - } - if (sane) { - useFlat = false; - surface.heights = layer.heights; + if (!std::isfinite(h) || std::abs(h) > 50000.0f) { sane = false; break; } + if (h < layer.minHeight - 8.0f || h > layer.maxHeight + 8.0f) { sane = false; break; } } + if (sane) { useFlat = false; surface.heights = layer.heights; } } - if (useFlat) { - surface.heights.resize(numVertices, layer.minHeight); - } + if (useFlat) surface.heights.resize(numVertices, layer.minHeight); - // Lower all terrain water in Stormwind area to prevent it from showing in tunnels/buildings/parks - // Only apply to Stormwind to avoid affecting water elsewhere - // Expanded bounds to cover all of Stormwind including outlying areas and park + // Stormwind water lowering bool isStormwindArea = (tileX >= 28 && tileX <= 50 && tileY >= 28 && tileY <= 52); - // Only lower high water (canal level >94) to avoid affecting moonwell and other low features if (isStormwindArea && layer.minHeight > 94.0f) { - // Calculate approximate world position from tile coordinates float tileWorldX = (32.0f - tileX) * 533.33333f; float tileWorldY = (32.0f - tileY) * 533.33333f; - - // Exclude moonwell area at (-8755.9, 1108.9) - don't lower water within 50 units glm::vec3 moonwellPos(-8755.9f, 1108.9f, 96.1f); float distToMoonwell = glm::distance(glm::vec2(tileWorldX, tileWorldY), glm::vec2(moonwellPos.x, moonwellPos.y)); - - if (distToMoonwell > 300.0f) { // Terrain tiles are large, use bigger exclusion radius - LOG_DEBUG(" -> LOWERING water at tile (", tileX, ",", tileY, ") from height ", layer.minHeight, " by 1 unit"); - for (float& h : surface.heights) { - h -= 1.0f; - } + if (distToMoonwell > 300.0f) { + for (float& h : surface.heights) h -= 1.0f; surface.minHeight -= 1.0f; surface.maxHeight -= 1.0f; - } else { - LOG_DEBUG(" -> SKIPPING tile (", tileX, ",", tileY, ") - moonwell exclusion (dist: ", distToMoonwell, ")"); } } - // Copy render mask surface.mask = layer.mask; - surface.tileX = tileX; surface.tileY = tileY; + createWaterMesh(surface); - surfaces.push_back(surface); + if (surface.indexCount > 0 && vkCtx) { + updateMaterialUBO(surface); + } + surfaces.push_back(std::move(surface)); totalLayers++; } } @@ -330,18 +653,10 @@ void WaterRenderer::removeTile(int tileX, int tileY) { void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liquid, [[maybe_unused]] const glm::mat4& modelMatrix, [[maybe_unused]] uint32_t wmoId) { - if (!liquid.hasLiquid() || liquid.xTiles == 0 || liquid.yTiles == 0) { - return; - } - if (liquid.xVerts < 2 || liquid.yVerts < 2) { - return; - } - if (liquid.xTiles != liquid.xVerts - 1 || liquid.yTiles != liquid.yVerts - 1) { - return; - } - if (liquid.xTiles > 64 || liquid.yTiles > 64) { - return; - } + if (!liquid.hasLiquid() || liquid.xTiles == 0 || liquid.yTiles == 0) return; + if (liquid.xVerts < 2 || liquid.yVerts < 2) return; + if (liquid.xTiles != liquid.xVerts - 1 || liquid.yTiles != liquid.yVerts - 1) return; + if (liquid.xTiles > 64 || liquid.yTiles > 64) return; WaterSurface surface; surface.tileX = -1; @@ -362,7 +677,7 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu surface.stepX = glm::vec3(modelMatrix * glm::vec4(localStepX, 0.0f)); surface.stepY = glm::vec3(modelMatrix * glm::vec4(localStepY, 0.0f)); surface.position = surface.origin; - // Guard against malformed transforms that produce giant/vertical sheets. + float stepXLen = glm::length(surface.stepX); float stepYLen = glm::length(surface.stepY); glm::vec3 planeN = glm::cross(surface.stepX, surface.stepY); @@ -371,81 +686,54 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu float spanY = stepYLen * static_cast(surface.height); if (stepXLen < 0.2f || stepXLen > 12.0f || stepYLen < 0.2f || stepYLen > 12.0f || - nz < 0.60f || - spanX > 450.0f || spanY > 450.0f) { - return; - } + nz < 0.60f || spanX > 450.0f || spanY > 450.0f) return; const int gridWidth = static_cast(surface.width) + 1; const int gridHeight = static_cast(surface.height) + 1; const int vertexCount = gridWidth * gridHeight; - // Keep WMO liquid flat for stability; some files use variant payload layouts - // that can produce invalid per-vertex heights if interpreted generically. - surface.heights.assign(vertexCount, surface.origin.z); - surface.minHeight = surface.origin.z; - surface.maxHeight = surface.origin.z; - // Lower WMO water in Stormwind area to prevent it from showing in tunnels/buildings/parks - // Calculate tile coordinates from world position - int tileX = static_cast(std::floor((32.0f - surface.origin.x / 533.33333f))); - int tileY = static_cast(std::floor((32.0f - surface.origin.y / 533.33333f))); + // WMO liquid base heights sit ~2 units above the visual waterline. + // Lower them to match surrounding terrain water and prevent clipping + // at bridge edges and walkways. + constexpr float WMO_WATER_Z_OFFSET = -1.0f; + float adjustedZ = surface.origin.z + WMO_WATER_Z_OFFSET; + surface.heights.assign(vertexCount, adjustedZ); + surface.minHeight = adjustedZ; + surface.maxHeight = adjustedZ; + surface.origin.z = adjustedZ; + surface.position.z = adjustedZ; - // Log all WMO water to debug park issue - LOG_DEBUG("WMO water at pos=(", surface.origin.x, ",", surface.origin.y, ",", surface.origin.z, - ") tile=(", tileX, ",", tileY, ") wmoId=", wmoId); - // Expanded bounds to cover all of Stormwind including outlying areas and park - bool isStormwindArea = (tileX >= 28 && tileX <= 50 && tileY >= 28 && tileY <= 52); + if (surface.origin.z > 300.0f || surface.origin.z < -100.0f) return; - // Only lower high WMO water (canal level >94) to avoid affecting moonwell and other low features - if (isStormwindArea && surface.origin.z > 94.0f) { - // Exclude moonwell area at (-8755.9, 1108.9) - don't lower water within 20 units - glm::vec3 moonwellPos(-8755.9f, 1108.9f, 96.1f); - float distToMoonwell = glm::distance(glm::vec2(surface.origin.x, surface.origin.y), - glm::vec2(moonwellPos.x, moonwellPos.y)); - - if (distToMoonwell > 20.0f) { - LOG_DEBUG(" -> LOWERING by 1 unit (dist to moonwell: ", distToMoonwell, ")"); - for (float& h : surface.heights) { - h -= 1.0f; + // Build tile mask from MLIQ flags β€” tiles with (flag & 0x0F) == 0x0F have no liquid + size_t tileCount = static_cast(surface.width) * static_cast(surface.height); + size_t maskBytes = (tileCount + 7) / 8; + surface.mask.assign(maskBytes, 0x00); + for (size_t t = 0; t < tileCount; t++) { + bool hasLiquid = true; + if (t < liquid.flags.size()) { + // In WoW MLIQ format, (flags & 0x0F) == 0x0F means "no liquid" for this tile + if ((liquid.flags[t] & 0x0F) == 0x0F) { + hasLiquid = false; } - surface.minHeight -= 1.0f; - surface.maxHeight -= 1.0f; - } else { - LOG_DEBUG(" -> SKIPPING (moonwell exclusion zone, dist: ", distToMoonwell, ")"); + } + if (hasLiquid) { + size_t byteIdx = t / 8; + size_t bitIdx = t % 8; + surface.mask[byteIdx] |= (1 << bitIdx); } } - // Skip WMO water that's clearly invalid (extremely high - above 300 units) - // This is a conservative global filter that won't affect normal gameplay - if (surface.origin.z > 300.0f) { - LOG_DEBUG("WMO water filtered: height=", surface.origin.z, " wmoId=", wmoId, " (too high)"); - return; - } - - // Skip WMO water that's extremely low (deep underground where it shouldn't be) - if (surface.origin.z < -100.0f) { - LOG_DEBUG("WMO water filtered: height=", surface.origin.z, " wmoId=", wmoId, " (too low)"); - return; - } - - size_t tileCount = static_cast(surface.width) * static_cast(surface.height); - size_t maskBytes = (tileCount + 7) / 8; - // WMO liquid flags vary across files; for now treat all WMO liquid tiles as - // visible for rendering. Swim/gameplay queries already ignore WMO surfaces. - surface.mask.assign(maskBytes, 0xFF); - createWaterMesh(surface); if (surface.indexCount > 0) { - surfaces.push_back(surface); + if (vkCtx) updateMaterialUBO(surface); + surfaces.push_back(std::move(surface)); } } void WaterRenderer::removeWMO(uint32_t wmoId) { - if (wmoId == 0) { - return; - } - + if (wmoId == 0) return; auto it = surfaces.begin(); while (it != surfaces.end()) { if (it->wmoId == wmoId) { @@ -462,155 +750,209 @@ void WaterRenderer::clear() { destroyWaterMesh(surface); } surfaces.clear(); + + if (vkCtx && materialDescPool) { + vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); + } } -void WaterRenderer::render(const Camera& camera, float time) { - if (!renderingEnabled || surfaces.empty() || !waterShader) { +// ============================================================== +// Rendering +// ============================================================== + +void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, + const Camera& /*camera*/, float /*time*/, bool use1x) { + VkPipeline pipeline = (use1x && water1xPipeline) ? water1xPipeline : waterPipeline; + if (!renderingEnabled || surfaces.empty() || !pipeline) return; + if (!sceneSet) return; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); + + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 2, 1, &sceneSet, 0, nullptr); + + for (const auto& surface : surfaces) { + if (surface.vertexBuffer == VK_NULL_HANDLE || surface.indexCount == 0) continue; + if (!surface.materialSet) continue; + + bool isWmoWater = (surface.wmoId != 0); + bool canalProfile = isWmoWater || (surface.liquidType == 5); + uint8_t basicType = (surface.liquidType == 0) ? 0 : ((surface.liquidType - 1) % 4); + // WMO water gets no wave displacement β€” prevents visible slosh at + // geometry edges (bridges, docks) where water is far below the surface. + float waveAmp = isWmoWater ? 0.0f : (basicType == 1 ? 0.35f : 0.08f); + float waveFreq = canalProfile ? 0.35f : (basicType == 1 ? 0.20f : 0.30f); + float waveSpeed = canalProfile ? 1.00f : (basicType == 1 ? 1.20f : 1.40f); + + WaterPushConstants push{}; + push.model = glm::mat4(1.0f); + push.waveAmp = waveAmp; + push.waveFreq = waveFreq; + push.waveSpeed = waveSpeed; + push.liquidBasicType = static_cast(basicType); + + vkCmdPushConstants(cmd, pipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(WaterPushConstants), &push); + + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 1, 1, &surface.materialSet, 0, nullptr); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &surface.vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, surface.indexBuffer, 0, VK_INDEX_TYPE_UINT32); + + vkCmdDrawIndexed(cmd, static_cast(surface.indexCount), 1, 0, 0, 0); + } +} + +void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, + VkImage srcColorImage, + VkImage srcDepthImage, + VkExtent2D srcExtent, + bool srcDepthIsMsaa) { + if (!vkCtx || !cmd || !sceneColorImage || !sceneDepthImage || srcExtent.width == 0 || srcExtent.height == 0) { return; } - glDisable(GL_CULL_FACE); + VkExtent2D copyExtent{ + std::min(srcExtent.width, sceneHistoryExtent.width), + std::min(srcExtent.height, sceneHistoryExtent.height) + }; + if (copyExtent.width == 0 || copyExtent.height == 0) return; - // Enable alpha blending for transparent water - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + auto barrier2 = [&](VkImage image, + VkImageAspectFlags aspect, + VkImageLayout oldLayout, + VkImageLayout newLayout, + VkAccessFlags srcAccess, + VkAccessFlags dstAccess, + VkPipelineStageFlags srcStage, + VkPipelineStageFlags dstStage) { + VkImageMemoryBarrier b{}; + b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + b.oldLayout = oldLayout; + b.newLayout = newLayout; + b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.image = image; + b.subresourceRange.aspectMask = aspect; + b.subresourceRange.baseMipLevel = 0; + b.subresourceRange.levelCount = 1; + b.subresourceRange.baseArrayLayer = 0; + b.subresourceRange.layerCount = 1; + b.srcAccessMask = srcAccess; + b.dstAccessMask = dstAccess; + vkCmdPipelineBarrier(cmd, srcStage, dstStage, 0, 0, nullptr, 0, nullptr, 1, &b); + }; - // Disable depth writing so terrain shows through water - glDepthMask(GL_FALSE); + // Color source: final render pass layout is PRESENT_SRC. + barrier2(srcColorImage, VK_IMAGE_ASPECT_COLOR_BIT, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + 0, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + barrier2(sceneColorImage, VK_IMAGE_ASPECT_COLOR_BIT, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - waterShader->use(); + VkImageCopy colorCopy{}; + colorCopy.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + colorCopy.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + colorCopy.extent = {copyExtent.width, copyExtent.height, 1}; + vkCmdCopyImage(cmd, srcColorImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + sceneColorImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &colorCopy); - // Set uniforms - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); + barrier2(sceneColorImage, VK_IMAGE_ASPECT_COLOR_BIT, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + barrier2(srcColorImage, VK_IMAGE_ASPECT_COLOR_BIT, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, + VK_ACCESS_TRANSFER_READ_BIT, 0, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT); - waterShader->setUniform("view", view); - waterShader->setUniform("projection", projection); - waterShader->setUniform("viewPos", camera.getPosition()); - waterShader->setUniform("time", time); - waterShader->setUniform("uFogColor", fogColor); - waterShader->setUniform("uFogStart", fogStart); - waterShader->setUniform("uFogEnd", fogEnd); + // Depth source: only copy when source is single-sampled. + if (!srcDepthIsMsaa && srcDepthImage != VK_NULL_HANDLE) { + barrier2(srcDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT, + VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + barrier2(sceneDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - // Render each water surface - for (const auto& surface : surfaces) { - if (surface.vao == 0) { - continue; - } + VkImageCopy depthCopy{}; + depthCopy.srcSubresource = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 0, 1}; + depthCopy.dstSubresource = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 0, 1}; + depthCopy.extent = {copyExtent.width, copyExtent.height, 1}; + vkCmdCopyImage(cmd, srcDepthImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + sceneDepthImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &depthCopy); - // Model matrix (identity, position already in vertices) - glm::mat4 model = glm::mat4(1.0f); - waterShader->setUniform("model", model); - - // Set liquid-specific color and alpha - glm::vec4 color = getLiquidColor(surface.liquidType); - float alpha = getLiquidAlpha(surface.liquidType); - - // WMO liquid material IDs are not always 1:1 with terrain LiquidType.dbc semantics. - // Avoid accidental magma/slime tint (red/green waterfalls) by forcing WMO liquids - // to water-like shading unless they're explicitly ocean. - if (surface.wmoId != 0) { - const uint8_t basicType = (surface.liquidType == 0) ? 0 : ((surface.liquidType - 1) % 4); - if (basicType == 2 || basicType == 3) { - color = glm::vec4(0.2f, 0.4f, 0.6f, 1.0f); - alpha = 0.45f; - } - } - - // City/canal liquid profile: clearer water + stronger ripples/sun shimmer. - // Stormwind canals typically use LiquidType 5 in this data set. - bool canalProfile = (surface.wmoId != 0) || (surface.liquidType == 5); - // Reduced wave amplitude to prevent tile seam gaps (tiles don't share wave state) - float waveAmp = canalProfile ? 0.04f : 0.06f; // Subtle waves to avoid boundary gaps - float waveFreq = canalProfile ? 0.30f : 0.22f; // Frequency maintained for visual - float waveSpeed = canalProfile ? 1.20f : 2.00f; // Speed maintained for animation - float shimmerStrength = canalProfile ? 0.95f : 0.50f; - float alphaScale = canalProfile ? 0.90f : 1.00f; // Increased from 0.72 to make canal water less transparent - - waterShader->setUniform("waterColor", color); - waterShader->setUniform("waterAlpha", alpha); - waterShader->setUniform("waveAmp", waveAmp); - waterShader->setUniform("waveFreq", waveFreq); - waterShader->setUniform("waveSpeed", waveSpeed); - waterShader->setUniform("shimmerStrength", shimmerStrength); - waterShader->setUniform("alphaScale", alphaScale); - - // Render - glBindVertexArray(surface.vao); - glDrawElements(GL_TRIANGLES, surface.indexCount, GL_UNSIGNED_INT, nullptr); - glBindVertexArray(0); + barrier2(sceneDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + barrier2(srcDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, + VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT); } - // Restore state - glDepthMask(GL_TRUE); - glDisable(GL_BLEND); - glEnable(GL_CULL_FACE); + sceneHistoryReady = true; } +// ============================================================== +// Mesh creation (Vulkan upload instead of GL) +// ============================================================== + void WaterRenderer::createWaterMesh(WaterSurface& surface) { - // Variable-size grid based on water layer dimensions - const int gridWidth = surface.width + 1; // Vertices = tiles + 1 + const int gridWidth = surface.width + 1; const int gridHeight = surface.height + 1; - constexpr float VISUAL_WATER_Z_BIAS = 0.02f; // Small bias to avoid obvious overdraw on city meshes + constexpr float VISUAL_WATER_Z_BIAS = 0.02f; std::vector vertices; std::vector indices; - // Generate vertices for (int y = 0; y < gridHeight; y++) { for (int x = 0; x < gridWidth; x++) { int index = y * gridWidth + x; - - // Use per-vertex height data if available, otherwise flat at minHeight - float height; - if (index < static_cast(surface.heights.size())) { - height = surface.heights[index]; - } else { - height = surface.minHeight; - } + float height = (index < static_cast(surface.heights.size())) + ? surface.heights[index] : surface.minHeight; glm::vec3 pos = surface.origin + surface.stepX * static_cast(x) + surface.stepY * static_cast(y); pos.z = height + VISUAL_WATER_Z_BIAS; - // Debug first surface's corner vertices - static int debugCount = 0; - if (debugCount < 4 && (x == 0 || x == gridWidth-1) && (y == 0 || y == gridHeight-1)) { - LOG_DEBUG("Water vertex: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - debugCount++; - } - + // pos (3 floats) vertices.push_back(pos.x); vertices.push_back(pos.y); vertices.push_back(pos.z); - - // Normal (pointing up for water surface) + // normal (3 floats) - up vertices.push_back(0.0f); vertices.push_back(0.0f); vertices.push_back(1.0f); - - // Texture coordinates + // texcoord (2 floats) vertices.push_back(static_cast(x) / std::max(1, gridWidth - 1)); vertices.push_back(static_cast(y) / std::max(1, gridHeight - 1)); } } - // Generate indices (triangles), respecting the render mask + // Generate indices respecting render mask (same logic as GL version) for (int y = 0; y < gridHeight - 1; y++) { for (int x = 0; x < gridWidth - 1; x++) { - // Check render mask - each bit represents a tile - // Also render edge tiles to blend coastlines (avoid square gaps) bool renderTile = true; if (!surface.mask.empty()) { int tileIndex; if (surface.wmoId == 0 && surface.mask.size() >= 8) { - // Terrain MH2O mask is chunk-wide 8x8. int cx = static_cast(surface.xOffset) + x; int cy = static_cast(surface.yOffset) + y; tileIndex = cy * 8 + cx; } else { - // Local mask indexing (WMO/custom). tileIndex = y * surface.width + x; } int byteIndex = tileIndex / 8; @@ -621,29 +963,19 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) { bool msbOrder = (maskByte & (1 << (7 - bitIndex))) != 0; renderTile = lsbOrder || msbOrder; - // If this tile is masked out, check neighbors to fill coastline gaps if (!renderTile) { - // Check adjacent tiles - render if any neighbor is water (blend coastline) for (int dy = -1; dy <= 1; dy++) { for (int dx = -1; dx <= 1; dx++) { if (dx == 0 && dy == 0) continue; - int nx = x + dx; - int ny = y + dy; - // Bounds check neighbors + int nx = x + dx, ny = y + dy; if (nx < 0 || ny < 0 || nx >= gridWidth-1 || ny >= gridHeight-1) continue; - - // Calculate neighbor mask index (consistent with main tile indexing) int neighborIdx; if (surface.wmoId == 0 && surface.mask.size() >= 8) { - // Terrain MH2O: account for xOffset/yOffset - int ncx = static_cast(surface.xOffset) + nx; - int ncy = static_cast(surface.yOffset) + ny; - neighborIdx = ncy * 8 + ncx; + neighborIdx = (static_cast(surface.yOffset) + ny) * 8 + + (static_cast(surface.xOffset) + nx); } else { - // WMO/custom: local indexing neighborIdx = ny * surface.width + nx; } - int nByteIdx = neighborIdx / 8; int nBitIdx = neighborIdx % 8; if (nByteIdx < static_cast(surface.mask.size())) { @@ -660,30 +992,24 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) { } } - if (!renderTile) { - continue; // Skip this tile - } + if (!renderTile) continue; int topLeft = y * gridWidth + x; int topRight = topLeft + 1; int bottomLeft = (y + 1) * gridWidth + x; int bottomRight = bottomLeft + 1; - // First triangle indices.push_back(topLeft); indices.push_back(bottomLeft); indices.push_back(topRight); - - // Second triangle indices.push_back(topRight); indices.push_back(bottomLeft); indices.push_back(bottomRight); } } + // Fallback: if terrain MH2O mask produced no tiles, render full rect if (indices.empty() && surface.wmoId == 0) { - // Terrain MH2O masks can be inconsistent in some tiles. If a terrain layer - // produces no visible tiles, fall back to its full local rect for rendering. for (int y = 0; y < gridHeight - 1; y++) { for (int x = 0; x < gridWidth - 1; x++) { int topLeft = y * gridWidth + x; @@ -701,98 +1027,82 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) { } if (indices.empty()) return; - surface.indexCount = static_cast(indices.size()); - // Create OpenGL buffers - glGenVertexArrays(1, &surface.vao); - glGenBuffers(1, &surface.vbo); - glGenBuffers(1, &surface.ebo); + if (!vkCtx) return; - glBindVertexArray(surface.vao); + // Upload vertex buffer + VkDeviceSize vbSize = vertices.size() * sizeof(float); + AllocatedBuffer vb = uploadBuffer(*vkCtx, vertices.data(), vbSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + surface.vertexBuffer = vb.buffer; + surface.vertexAlloc = vb.allocation; - // Upload vertex data - glBindBuffer(GL_ARRAY_BUFFER, surface.vbo); - glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices.data(), GL_STATIC_DRAW); - - // Upload index data - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, surface.ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(uint32_t), indices.data(), GL_STATIC_DRAW); - - // Set vertex attributes - // Position - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0); - glEnableVertexAttribArray(0); - - // Normal - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float))); - glEnableVertexAttribArray(1); - - // Texture coordinates - glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float))); - glEnableVertexAttribArray(2); - - glBindVertexArray(0); + // Upload index buffer + VkDeviceSize ibSize = indices.size() * sizeof(uint32_t); + AllocatedBuffer ib = uploadBuffer(*vkCtx, indices.data(), ibSize, + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + surface.indexBuffer = ib.buffer; + surface.indexAlloc = ib.allocation; } void WaterRenderer::destroyWaterMesh(WaterSurface& surface) { - if (surface.vao != 0) { - glDeleteVertexArrays(1, &surface.vao); - surface.vao = 0; + if (!vkCtx) return; + VmaAllocator allocator = vkCtx->getAllocator(); + + if (surface.vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = surface.vertexBuffer; ab.allocation = surface.vertexAlloc; + destroyBuffer(allocator, ab); + surface.vertexBuffer = VK_NULL_HANDLE; } - if (surface.vbo != 0) { - glDeleteBuffers(1, &surface.vbo); - surface.vbo = 0; + if (surface.indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = surface.indexBuffer; ab.allocation = surface.indexAlloc; + destroyBuffer(allocator, ab); + surface.indexBuffer = VK_NULL_HANDLE; } - if (surface.ebo != 0) { - glDeleteBuffers(1, &surface.ebo); - surface.ebo = 0; + if (surface.materialUBO) { + AllocatedBuffer ab{}; ab.buffer = surface.materialUBO; ab.allocation = surface.materialAlloc; + destroyBuffer(allocator, ab); + surface.materialUBO = VK_NULL_HANDLE; } + surface.materialSet = VK_NULL_HANDLE; } +// ============================================================== +// Query functions (data-only, no GL) +// ============================================================== + std::optional WaterRenderer::getWaterHeightAt(float glX, float glY) const { std::optional best; - for (size_t si = 0; si < surfaces.size(); si++) { - const auto& surface = surfaces[si]; + for (const auto& surface : surfaces) { glm::vec2 rel(glX - surface.origin.x, glY - surface.origin.y); - glm::vec2 stepX(surface.stepX.x, surface.stepX.y); - glm::vec2 stepY(surface.stepY.x, surface.stepY.y); - float lenSqX = glm::dot(stepX, stepX); - float lenSqY = glm::dot(stepY, stepY); - if (lenSqX < 1e-6f || lenSqY < 1e-6f) { - continue; - } - float gx = glm::dot(rel, stepX) / lenSqX; - float gy = glm::dot(rel, stepY) / lenSqY; + glm::vec2 sX(surface.stepX.x, surface.stepX.y); + glm::vec2 sY(surface.stepY.x, surface.stepY.y); + float lenSqX = glm::dot(sX, sX); + float lenSqY = glm::dot(sY, sY); + if (lenSqX < 1e-6f || lenSqY < 1e-6f) continue; + float gx = glm::dot(rel, sX) / lenSqX; + float gy = glm::dot(rel, sY) / lenSqY; if (gx < 0.0f || gx > static_cast(surface.width) || - gy < 0.0f || gy > static_cast(surface.height)) { - continue; - } + gy < 0.0f || gy > static_cast(surface.height)) continue; int gridWidth = surface.width + 1; - - // Bilinear interpolation int ix = static_cast(gx); int iy = static_cast(gy); float fx = gx - ix; float fy = gy - iy; - // Clamp to valid vertex range if (ix >= surface.width) { ix = surface.width - 1; fx = 1.0f; } if (iy >= surface.height) { iy = surface.height - 1; fy = 1.0f; } - if (ix < 0 || iy < 0) { - continue; - } + if (ix < 0 || iy < 0) continue; - // Respect per-tile mask so holes/non-liquid tiles do not count as swimmable. if (!surface.mask.empty()) { int tileIndex; if (surface.wmoId == 0 && surface.mask.size() >= 8) { - int cx = static_cast(surface.xOffset) + ix; - int cy = static_cast(surface.yOffset) + iy; - tileIndex = cy * 8 + cx; + tileIndex = (static_cast(surface.yOffset) + iy) * 8 + + (static_cast(surface.xOffset) + ix); } else { tileIndex = iy * surface.width + ix; } @@ -800,12 +1110,8 @@ std::optional WaterRenderer::getWaterHeightAt(float glX, float glY) const int bitIndex = tileIndex % 8; if (byteIndex < static_cast(surface.mask.size())) { uint8_t maskByte = surface.mask[byteIndex]; - bool lsbOrder = (maskByte & (1 << bitIndex)) != 0; - bool msbOrder = (maskByte & (1 << (7 - bitIndex))) != 0; - bool renderTile = lsbOrder || msbOrder; - if (!renderTile) { - continue; - } + bool renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex))); + if (!renderTile) continue; } } @@ -817,16 +1123,80 @@ std::optional WaterRenderer::getWaterHeightAt(float glX, float glY) const int total = static_cast(surface.heights.size()); if (idx11 >= total) continue; - float h00 = surface.heights[idx00]; - float h10 = surface.heights[idx10]; - float h01 = surface.heights[idx01]; - float h11 = surface.heights[idx11]; + float h00 = surface.heights[idx00], h10 = surface.heights[idx10]; + float h01 = surface.heights[idx01], h11 = surface.heights[idx11]; + float h = h00*(1-fx)*(1-fy) + h10*fx*(1-fy) + h01*(1-fx)*fy + h11*fx*fy; - float h = h00 * (1-fx) * (1-fy) + h10 * fx * (1-fy) + - h01 * (1-fx) * fy + h11 * fx * fy; + if (!best || h > *best) best = h; + } - if (!best || h > *best) { + return best; +} + +std::optional WaterRenderer::getNearestWaterHeightAt(float glX, float glY, float queryZ, float maxAbove) const { + std::optional best; + float bestDist = 1e9f; + + for (const auto& surface : surfaces) { + glm::vec2 rel(glX - surface.origin.x, glY - surface.origin.y); + glm::vec2 sX(surface.stepX.x, surface.stepX.y); + glm::vec2 sY(surface.stepY.x, surface.stepY.y); + float lenSqX = glm::dot(sX, sX); + float lenSqY = glm::dot(sY, sY); + if (lenSqX < 1e-6f || lenSqY < 1e-6f) continue; + float gx = glm::dot(rel, sX) / lenSqX; + float gy = glm::dot(rel, sY) / lenSqY; + + if (gx < 0.0f || gx > static_cast(surface.width) || + gy < 0.0f || gy > static_cast(surface.height)) continue; + + int gridWidth = surface.width + 1; + int ix = static_cast(gx); + int iy = static_cast(gy); + float fx = gx - ix; + float fy = gy - iy; + + if (ix >= surface.width) { ix = surface.width - 1; fx = 1.0f; } + if (iy >= surface.height) { iy = surface.height - 1; fy = 1.0f; } + if (ix < 0 || iy < 0) continue; + + if (!surface.mask.empty()) { + int tileIndex; + if (surface.wmoId == 0 && surface.mask.size() >= 8) { + tileIndex = (static_cast(surface.yOffset) + iy) * 8 + + (static_cast(surface.xOffset) + ix); + } else { + tileIndex = iy * surface.width + ix; + } + int byteIndex = tileIndex / 8; + int bitIndex = tileIndex % 8; + if (byteIndex < static_cast(surface.mask.size())) { + uint8_t maskByte = surface.mask[byteIndex]; + bool renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex))); + if (!renderTile) continue; + } + } + + int idx00 = iy * gridWidth + ix; + int idx10 = idx00 + 1; + int idx01 = idx00 + gridWidth; + int idx11 = idx01 + 1; + + int total = static_cast(surface.heights.size()); + if (idx11 >= total) continue; + + float h00 = surface.heights[idx00], h10 = surface.heights[idx10]; + float h01 = surface.heights[idx01], h11 = surface.heights[idx11]; + float h = h00*(1-fx)*(1-fy) + h10*fx*(1-fy) + h01*(1-fx)*fy + h11*fx*fy; + + // Only consider water that's above queryZ but not too far above + if (h < queryZ - 2.0f) continue; // water below camera, skip + if (h > queryZ + maxAbove) continue; // water way above camera, skip + + float dist = std::abs(h - queryZ); + if (!best || dist < bestDist) { best = h; + bestDist = dist; } } @@ -839,20 +1209,16 @@ std::optional WaterRenderer::getWaterTypeAt(float glX, float glY) cons for (const auto& surface : surfaces) { glm::vec2 rel(glX - surface.origin.x, glY - surface.origin.y); - glm::vec2 stepX(surface.stepX.x, surface.stepX.y); - glm::vec2 stepY(surface.stepY.x, surface.stepY.y); - float lenSqX = glm::dot(stepX, stepX); - float lenSqY = glm::dot(stepY, stepY); - if (lenSqX < 1e-6f || lenSqY < 1e-6f) { - continue; - } + glm::vec2 sX(surface.stepX.x, surface.stepX.y); + glm::vec2 sY(surface.stepY.x, surface.stepY.y); + float lenSqX = glm::dot(sX, sX); + float lenSqY = glm::dot(sY, sY); + if (lenSqX < 1e-6f || lenSqY < 1e-6f) continue; - float gx = glm::dot(rel, stepX) / lenSqX; - float gy = glm::dot(rel, stepY) / lenSqY; + float gx = glm::dot(rel, sX) / lenSqX; + float gy = glm::dot(rel, sY) / lenSqY; if (gx < 0.0f || gx > static_cast(surface.width) || - gy < 0.0f || gy > static_cast(surface.height)) { - continue; - } + gy < 0.0f || gy > static_cast(surface.height)) continue; int ix = static_cast(gx); int iy = static_cast(gy); @@ -863,9 +1229,8 @@ std::optional WaterRenderer::getWaterTypeAt(float glX, float glY) cons if (!surface.mask.empty()) { int tileIndex; if (surface.wmoId == 0 && surface.mask.size() >= 8) { - int cx = static_cast(surface.xOffset) + ix; - int cy = static_cast(surface.yOffset) + iy; - tileIndex = cy * 8 + cx; + tileIndex = (static_cast(surface.yOffset) + iy) * 8 + + (static_cast(surface.xOffset) + ix); } else { tileIndex = iy * surface.width + ix; } @@ -873,14 +1238,11 @@ std::optional WaterRenderer::getWaterTypeAt(float glX, float glY) cons int bitIndex = tileIndex % 8; if (byteIndex < static_cast(surface.mask.size())) { uint8_t maskByte = surface.mask[byteIndex]; - bool lsbOrder = (maskByte & (1 << bitIndex)) != 0; - bool msbOrder = (maskByte & (1 << (7 - bitIndex))) != 0; - bool renderTile = lsbOrder || msbOrder; + bool renderTile = (maskByte & (1 << bitIndex)) || (maskByte & (1 << (7 - bitIndex))); if (!renderTile) continue; } } - // Use minHeight as stable selector for "topmost surface at XY". float h = surface.minHeight; if (!bestHeight || h > *bestHeight) { bestHeight = h; @@ -891,43 +1253,569 @@ std::optional WaterRenderer::getWaterTypeAt(float glX, float glY) cons return bestType; } -glm::vec4 WaterRenderer::getLiquidColor(uint16_t liquidType) const { - // WoW 3.3.5a LiquidType.dbc IDs: - // 1,5,9,13,17 = Water variants (still, slow, fast) - // 2,6,10,14 = Ocean - // 3,7,11,15 = Magma - // 4,8,12 = Slime - // Map to basic type using (id - 1) % 4 for standard IDs, or handle ranges - uint8_t basicType; - if (liquidType == 0) { - basicType = 0; // Water (fallback) - } else { - basicType = ((liquidType - 1) % 4); +bool WaterRenderer::isWmoWaterAt(float glX, float glY) const { + for (const auto& surface : surfaces) { + if (surface.wmoId == 0) continue; + glm::vec2 rel(glX - surface.origin.x, glY - surface.origin.y); + glm::vec2 sX(surface.stepX.x, surface.stepX.y); + glm::vec2 sY(surface.stepY.x, surface.stepY.y); + float lenSqX = glm::dot(sX, sX); + float lenSqY = glm::dot(sY, sY); + if (lenSqX < 1e-6f || lenSqY < 1e-6f) continue; + float gx = glm::dot(rel, sX) / lenSqX; + float gy = glm::dot(rel, sY) / lenSqY; + if (gx >= 0.0f && gx <= static_cast(surface.width) && + gy >= 0.0f && gy <= static_cast(surface.height)) + return true; } + return false; +} +glm::vec4 WaterRenderer::getLiquidColor(uint16_t liquidType) const { + uint8_t basicType = (liquidType == 0) ? 0 : ((liquidType - 1) % 4); switch (basicType) { - case 0: // Water - return glm::vec4(0.2f, 0.4f, 0.6f, 1.0f); - case 1: // Ocean - return glm::vec4(0.06f, 0.18f, 0.34f, 1.0f); - case 2: // Magma - return glm::vec4(0.9f, 0.3f, 0.05f, 1.0f); - case 3: // Slime - return glm::vec4(0.2f, 0.6f, 0.1f, 1.0f); - default: - return glm::vec4(0.2f, 0.4f, 0.6f, 1.0f); // Water fallback + case 0: return glm::vec4(0.12f, 0.32f, 0.48f, 1.0f); // inland: blue-green + case 1: return glm::vec4(0.04f, 0.14f, 0.30f, 1.0f); // ocean: deep blue + case 2: return glm::vec4(0.9f, 0.3f, 0.05f, 1.0f); // magma + case 3: return glm::vec4(0.2f, 0.6f, 0.1f, 1.0f); // slime + default: return glm::vec4(0.12f, 0.32f, 0.48f, 1.0f); } } float WaterRenderer::getLiquidAlpha(uint16_t liquidType) const { uint8_t basicType = (liquidType == 0) ? 0 : ((liquidType - 1) % 4); switch (basicType) { - case 1: return 0.68f; // Ocean - case 2: return 0.72f; // Magma - case 3: return 0.62f; // Slime - default: return 0.38f; // Water + case 1: return 0.72f; // ocean + case 2: return 0.75f; // magma + case 3: return 0.65f; // slime + default: return 0.48f; // inland water } } +// ============================================================== +// Planar reflection resources +// ============================================================== + +void WaterRenderer::createReflectionResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + // --- Reflection color image --- + VkImageCreateInfo colorImgCI{}; + colorImgCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + colorImgCI.imageType = VK_IMAGE_TYPE_2D; + colorImgCI.format = vkCtx->getSwapchainFormat(); + colorImgCI.extent = {REFLECTION_WIDTH, REFLECTION_HEIGHT, 1}; + colorImgCI.mipLevels = 1; + colorImgCI.arrayLayers = 1; + colorImgCI.samples = VK_SAMPLE_COUNT_1_BIT; + colorImgCI.tiling = VK_IMAGE_TILING_OPTIMAL; + colorImgCI.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + colorImgCI.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY; + + if (vmaCreateImage(allocator, &colorImgCI, &allocCI, + &reflectionColorImage, &reflectionColorAlloc, nullptr) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection color image"); + return; + } + + VkImageViewCreateInfo colorViewCI{}; + colorViewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + colorViewCI.image = reflectionColorImage; + colorViewCI.viewType = VK_IMAGE_VIEW_TYPE_2D; + colorViewCI.format = vkCtx->getSwapchainFormat(); + colorViewCI.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + if (vkCreateImageView(device, &colorViewCI, nullptr, &reflectionColorView) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection color view"); + return; + } + + // --- Reflection depth image --- + VkImageCreateInfo depthImgCI{}; + depthImgCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + depthImgCI.imageType = VK_IMAGE_TYPE_2D; + depthImgCI.format = vkCtx->getDepthFormat(); + depthImgCI.extent = {REFLECTION_WIDTH, REFLECTION_HEIGHT, 1}; + depthImgCI.mipLevels = 1; + depthImgCI.arrayLayers = 1; + depthImgCI.samples = VK_SAMPLE_COUNT_1_BIT; + depthImgCI.tiling = VK_IMAGE_TILING_OPTIMAL; + depthImgCI.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT; + depthImgCI.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + if (vmaCreateImage(allocator, &depthImgCI, &allocCI, + &reflectionDepthImage, &reflectionDepthAlloc, nullptr) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection depth image"); + return; + } + + VkImageViewCreateInfo depthViewCI{}; + depthViewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + depthViewCI.image = reflectionDepthImage; + depthViewCI.viewType = VK_IMAGE_VIEW_TYPE_2D; + depthViewCI.format = vkCtx->getDepthFormat(); + depthViewCI.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; + if (vkCreateImageView(device, &depthViewCI, nullptr, &reflectionDepthView) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection depth view"); + return; + } + + // --- Reflection sampler --- + VkSamplerCreateInfo sampCI{}; + sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + sampCI.magFilter = VK_FILTER_LINEAR; + sampCI.minFilter = VK_FILTER_LINEAR; + sampCI.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampCI.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampCI.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + if (vkCreateSampler(device, &sampCI, nullptr, &reflectionSampler) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection sampler"); + return; + } + + // --- Reflection render pass --- + VkAttachmentDescription colorAttach{}; + colorAttach.format = vkCtx->getSwapchainFormat(); + colorAttach.samples = VK_SAMPLE_COUNT_1_BIT; + colorAttach.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + colorAttach.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + colorAttach.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + colorAttach.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + colorAttach.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + colorAttach.finalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkAttachmentDescription depthAttach{}; + depthAttach.format = vkCtx->getDepthFormat(); + depthAttach.samples = VK_SAMPLE_COUNT_1_BIT; + depthAttach.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + depthAttach.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + depthAttach.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + depthAttach.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + depthAttach.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + depthAttach.finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference colorRef{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkAttachmentReference depthRef{1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL}; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + subpass.pDepthStencilAttachment = &depthRef; + + VkSubpassDependency dep{}; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT; + dep.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep.srcAccessMask = 0; + dep.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + std::array attachments = {colorAttach, depthAttach}; + VkRenderPassCreateInfo rpCI{}; + rpCI.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpCI.attachmentCount = static_cast(attachments.size()); + rpCI.pAttachments = attachments.data(); + rpCI.subpassCount = 1; + rpCI.pSubpasses = &subpass; + rpCI.dependencyCount = 1; + rpCI.pDependencies = &dep; + + if (vkCreateRenderPass(device, &rpCI, nullptr, &reflectionRenderPass) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection render pass"); + return; + } + + // --- Reflection framebuffer --- + std::array fbAttach = {reflectionColorView, reflectionDepthView}; + VkFramebufferCreateInfo fbCI{}; + fbCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbCI.renderPass = reflectionRenderPass; + fbCI.attachmentCount = static_cast(fbAttach.size()); + fbCI.pAttachments = fbAttach.data(); + fbCI.width = REFLECTION_WIDTH; + fbCI.height = REFLECTION_HEIGHT; + fbCI.layers = 1; + + if (vkCreateFramebuffer(device, &fbCI, nullptr, &reflectionFramebuffer) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection framebuffer"); + return; + } + + // --- Reflection UBO --- + VkBufferCreateInfo bufCI{}; + bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufCI.size = sizeof(ReflectionUBOData); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + + VmaAllocationCreateInfo uboAllocCI{}; + uboAllocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + uboAllocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + + VmaAllocationInfo mapInfo{}; + if (vmaCreateBuffer(allocator, &bufCI, &uboAllocCI, + &reflectionUBO, &reflectionUBOAlloc, &mapInfo) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create reflection UBO"); + return; + } + reflectionUBOMapped = mapInfo.pMappedData; + + // Initialize with identity + ReflectionUBOData initData{}; + initData.reflViewProj = glm::mat4(1.0f); + if (reflectionUBOMapped) { + std::memcpy(reflectionUBOMapped, &initData, sizeof(initData)); + } + + // Transition reflection color image to shader-read so first frame doesn't read undefined + vkCtx->immediateSubmit([&](VkCommandBuffer cmd) { + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = reflectionColorImage; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &barrier); + }); + reflectionColorLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + LOG_INFO("Water reflection resources created (", REFLECTION_WIDTH, "x", REFLECTION_HEIGHT, ")"); +} + +void WaterRenderer::destroyReflectionResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + if (reflectionFramebuffer) { vkDestroyFramebuffer(device, reflectionFramebuffer, nullptr); reflectionFramebuffer = VK_NULL_HANDLE; } + if (reflectionRenderPass) { vkDestroyRenderPass(device, reflectionRenderPass, nullptr); reflectionRenderPass = VK_NULL_HANDLE; } + if (reflectionColorView) { vkDestroyImageView(device, reflectionColorView, nullptr); reflectionColorView = VK_NULL_HANDLE; } + if (reflectionDepthView) { vkDestroyImageView(device, reflectionDepthView, nullptr); reflectionDepthView = VK_NULL_HANDLE; } + if (reflectionColorImage) { vmaDestroyImage(allocator, reflectionColorImage, reflectionColorAlloc); reflectionColorImage = VK_NULL_HANDLE; } + if (reflectionDepthImage) { vmaDestroyImage(allocator, reflectionDepthImage, reflectionDepthAlloc); reflectionDepthImage = VK_NULL_HANDLE; } + if (reflectionSampler) { vkDestroySampler(device, reflectionSampler, nullptr); reflectionSampler = VK_NULL_HANDLE; } + if (reflectionUBO) { + AllocatedBuffer ab{}; ab.buffer = reflectionUBO; ab.allocation = reflectionUBOAlloc; + destroyBuffer(allocator, ab); + reflectionUBO = VK_NULL_HANDLE; + reflectionUBOMapped = nullptr; + } + reflectionColorLayout = VK_IMAGE_LAYOUT_UNDEFINED; +} + +// ============================================================== +// Reflection pass begin/end +// ============================================================== + +bool WaterRenderer::beginReflectionPass(VkCommandBuffer cmd) { + if (!reflectionRenderPass || !reflectionFramebuffer || !cmd) return false; + + VkRenderPassBeginInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpInfo.renderPass = reflectionRenderPass; + rpInfo.framebuffer = reflectionFramebuffer; + rpInfo.renderArea = {{0, 0}, {REFLECTION_WIDTH, REFLECTION_HEIGHT}}; + + VkClearValue clears[2]{}; + clears[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + clears[1].depthStencil = {1.0f, 0}; + rpInfo.clearValueCount = 2; + rpInfo.pClearValues = clears; + + vkCmdBeginRenderPass(cmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + + VkViewport vp{0, 0, static_cast(REFLECTION_WIDTH), static_cast(REFLECTION_HEIGHT), 0.0f, 1.0f}; + vkCmdSetViewport(cmd, 0, 1, &vp); + VkRect2D sc{{0, 0}, {REFLECTION_WIDTH, REFLECTION_HEIGHT}}; + vkCmdSetScissor(cmd, 0, 1, &sc); + + return true; +} + +void WaterRenderer::endReflectionPass(VkCommandBuffer cmd) { + if (!cmd) return; + vkCmdEndRenderPass(cmd); + reflectionColorLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + // Update scene descriptor set with the freshly rendered reflection texture + if (sceneSet && reflectionColorView && reflectionSampler) { + VkDescriptorImageInfo reflInfo{}; + reflInfo.sampler = reflectionSampler; + reflInfo.imageView = reflectionColorView; + reflInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = sceneSet; + write.dstBinding = 2; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &reflInfo; + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr); + } +} + +void WaterRenderer::updateReflectionUBO(const glm::mat4& reflViewProj) { + if (!reflectionUBOMapped) return; + ReflectionUBOData data{}; + data.reflViewProj = reflViewProj; + std::memcpy(reflectionUBOMapped, &data, sizeof(data)); +} + +// ============================================================== +// Mirror camera computations +// ============================================================== + +std::optional WaterRenderer::getDominantWaterHeight(const glm::vec3& cameraPos) const { + if (surfaces.empty()) return std::nullopt; + + // Find the water surface closest to the camera (XY distance) + float bestDist = std::numeric_limits::max(); + float bestHeight = 0.0f; + bool found = false; + + for (const auto& surface : surfaces) { + // Skip magma/slime β€” only reflect water/ocean + uint8_t basicType = (surface.liquidType == 0) ? 0 : ((surface.liquidType - 1) % 4); + if (basicType >= 2) continue; + + // Compute center of surface in world space + glm::vec3 center = surface.origin + + surface.stepX * (static_cast(surface.width) * 0.5f) + + surface.stepY * (static_cast(surface.height) * 0.5f); + + float dx = cameraPos.x - center.x; + float dy = cameraPos.y - center.y; + float dist = dx * dx + dy * dy; + if (dist < bestDist) { + bestDist = dist; + bestHeight = surface.minHeight; + found = true; + } + } + + if (!found) return std::nullopt; + return bestHeight; +} + +glm::mat4 WaterRenderer::computeReflectedView(const Camera& camera, float waterHeight) { + // In this engine, Z is up. Water height is stored in the Z component. + // Mirror camera position across Z = waterHeight plane. + + glm::vec3 camPos = camera.getPosition(); + glm::vec3 reflPos = camPos; + reflPos.z = 2.0f * waterHeight - camPos.z; + + // Get camera forward and reflect the Z component + glm::vec3 forward = camera.getForward(); + forward.z = -forward.z; + glm::vec3 reflTarget = reflPos + forward; + + glm::vec3 up(0.0f, 0.0f, 1.0f); + return glm::lookAt(reflPos, reflTarget, up); +} + +glm::mat4 WaterRenderer::computeObliqueProjection(const glm::mat4& proj, const glm::mat4& view, + float waterHeight) { + // Clip plane: everything below waterHeight in world space + // Z is up, so the clip plane normal is (0, 0, 1) + glm::vec4 clipPlaneWorld(0.0f, 0.0f, 1.0f, -waterHeight); + glm::vec4 clipPlaneView = glm::transpose(glm::inverse(view)) * clipPlaneWorld; + + // Lengyel's oblique near-plane projection matrix modification + glm::mat4 result = proj; + glm::vec4 q; + q.x = (glm::sign(clipPlaneView.x) + result[2][0]) / result[0][0]; + q.y = (glm::sign(clipPlaneView.y) + result[2][1]) / result[1][1]; + q.z = -1.0f; + q.w = (1.0f + result[2][2]) / result[3][2]; + + glm::vec4 c = clipPlaneView * (2.0f / glm::dot(clipPlaneView, q)); + result[0][2] = c.x; + result[1][2] = c.y; + result[2][2] = c.z + 1.0f; + result[3][2] = c.w; + + return result; +} + +// ============================================================== +// Separate 1x water pass (used when MSAA is active) +// ============================================================== + +bool WaterRenderer::createWater1xPass(VkFormat colorFormat, VkFormat depthFormat) { + if (!vkCtx) return false; + VkDevice device = vkCtx->getDevice(); + + VkAttachmentDescription attachments[2]{}; + // Color: load existing resolved content, store after water draw + attachments[0].format = colorFormat; + attachments[0].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[0].loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; + attachments[0].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[0].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[0].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[0].initialLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + attachments[0].finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + + // Depth: load resolved depth for depth testing + attachments[1].format = depthFormat; + attachments[1].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[1].loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; + attachments[1].storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[1].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[1].initialLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + attachments[1].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkAttachmentReference colorRef{0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkAttachmentReference depthRef{1, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL}; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + subpass.pDepthStencilAttachment = &depthRef; + + VkSubpassDependency dep{}; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + dep.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT; + dep.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + dep.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT; + + VkRenderPassCreateInfo rpCI{}; + rpCI.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + rpCI.attachmentCount = 2; + rpCI.pAttachments = attachments; + rpCI.subpassCount = 1; + rpCI.pSubpasses = &subpass; + rpCI.dependencyCount = 1; + rpCI.pDependencies = &dep; + + if (vkCreateRenderPass(device, &rpCI, nullptr, &water1xRenderPass) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create 1x water render pass"); + return false; + } + + // Build 1x water pipeline against this render pass + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/water.vert.spv") || + !fragShader.loadFromFile(device, "assets/shaders/water.frag.spv")) { + LOG_ERROR("WaterRenderer: failed to load shaders for 1x pipeline"); + return false; + } + + VkVertexInputBindingDescription vertBinding{}; + vertBinding.binding = 0; + vertBinding.stride = 8 * sizeof(float); + vertBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector vertAttribs = { + { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0 }, + { 1, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float) }, + }; + + water1xPipeline = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertBinding }, vertAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(VK_SAMPLE_COUNT_1_BIT) + .setLayout(pipelineLayout) + .setRenderPass(water1xRenderPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!water1xPipeline) { + LOG_ERROR("WaterRenderer: failed to create 1x water pipeline"); + return false; + } + + LOG_INFO("WaterRenderer: created 1x water pass and pipeline"); + return true; +} + +void WaterRenderer::createWater1xFramebuffers(const std::vector& swapViews, + VkImageView depthView, VkExtent2D extent) { + if (!vkCtx || !water1xRenderPass || !depthView) return; + VkDevice device = vkCtx->getDevice(); + + // Destroy old framebuffers + for (auto fb : water1xFramebuffers) { + if (fb) vkDestroyFramebuffer(device, fb, nullptr); + } + water1xFramebuffers.clear(); + + water1xFramebuffers.resize(swapViews.size()); + for (size_t i = 0; i < swapViews.size(); i++) { + VkImageView views[2] = { swapViews[i], depthView }; + VkFramebufferCreateInfo fbCI{}; + fbCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbCI.renderPass = water1xRenderPass; + fbCI.attachmentCount = 2; + fbCI.pAttachments = views; + fbCI.width = extent.width; + fbCI.height = extent.height; + fbCI.layers = 1; + if (vkCreateFramebuffer(device, &fbCI, nullptr, &water1xFramebuffers[i]) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create 1x framebuffer ", i); + } + } +} + +void WaterRenderer::destroyWater1xResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + for (auto fb : water1xFramebuffers) { + if (fb) vkDestroyFramebuffer(device, fb, nullptr); + } + water1xFramebuffers.clear(); + if (water1xPipeline) { vkDestroyPipeline(device, water1xPipeline, nullptr); water1xPipeline = VK_NULL_HANDLE; } + if (water1xRenderPass) { vkDestroyRenderPass(device, water1xRenderPass, nullptr); water1xRenderPass = VK_NULL_HANDLE; } +} + +bool WaterRenderer::beginWater1xPass(VkCommandBuffer cmd, uint32_t imageIndex, VkExtent2D extent) { + if (!water1xRenderPass || imageIndex >= water1xFramebuffers.size() || !water1xFramebuffers[imageIndex]) + return false; + + VkRenderPassBeginInfo rpBI{}; + rpBI.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpBI.renderPass = water1xRenderPass; + rpBI.framebuffer = water1xFramebuffers[imageIndex]; + rpBI.renderArea = {{0, 0}, extent}; + rpBI.clearValueCount = 0; + rpBI.pClearValues = nullptr; + vkCmdBeginRenderPass(cmd, &rpBI, VK_SUBPASS_CONTENTS_INLINE); + + VkViewport vp{0, 0, static_cast(extent.width), static_cast(extent.height), 0.0f, 1.0f}; + vkCmdSetViewport(cmd, 0, 1, &vp); + VkRect2D sc{{0, 0}, extent}; + vkCmdSetScissor(cmd, 0, 1, &sc); + + return true; +} + +void WaterRenderer::endWater1xPass(VkCommandBuffer cmd) { + vkCmdEndRenderPass(cmd); +} + } // namespace rendering } // namespace wowee diff --git a/src/rendering/weather.cpp b/src/rendering/weather.cpp index e6bca672..fed604dc 100644 --- a/src/rendering/weather.cpp +++ b/src/rendering/weather.cpp @@ -1,10 +1,15 @@ #include "rendering/weather.hpp" #include "rendering/camera.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "core/logger.hpp" #include #include #include +#include namespace wowee { namespace rendering { @@ -13,71 +18,95 @@ Weather::Weather() { } Weather::~Weather() { - cleanup(); + shutdown(); } -bool Weather::initialize() { +bool Weather::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout) { LOG_INFO("Initializing weather system"); - // Create shader - shader = std::make_unique(); + vkCtx = ctx; + VkDevice device = vkCtx->getDevice(); - // Vertex shader - point sprites with instancing - const char* vertexShaderSource = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - - uniform mat4 uView; - uniform mat4 uProjection; - uniform float uParticleSize; - - void main() { - gl_Position = uProjection * uView * vec4(aPos, 1.0); - gl_PointSize = uParticleSize; - } - )"; - - // Fragment shader - simple particle with alpha - const char* fragmentShaderSource = R"( - #version 330 core - - uniform vec4 uParticleColor; - - out vec4 FragColor; - - void main() { - // Circular particle shape - vec2 coord = gl_PointCoord - vec2(0.5); - float dist = length(coord); - - if (dist > 0.5) { - discard; - } - - // Soft edges - float alpha = smoothstep(0.5, 0.3, dist) * uParticleColor.a; - - FragColor = vec4(uParticleColor.rgb, alpha); - } - )"; - - if (!shader->loadFromSource(vertexShaderSource, fragmentShaderSource)) { - LOG_ERROR("Failed to create weather shader"); + // Load SPIR-V shaders + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/weather.vert.spv")) { + LOG_ERROR("Failed to load weather vertex shader"); return false; } - // Create VAO and VBO for particle positions - glGenVertexArrays(1, &vao); - glGenBuffers(1, &vbo); + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/weather.frag.spv")) { + LOG_ERROR("Failed to load weather fragment shader"); + return false; + } - glBindVertexArray(vao); - glBindBuffer(GL_ARRAY_BUFFER, vbo); + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); - // Position attribute - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), (void*)0); - glEnableVertexAttribArray(0); + // Push constant range: { float particleSize; float pad0; float pad1; float pad2; vec4 particleColor; } = 32 bytes + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pushRange.offset = 0; + pushRange.size = 32; // 4 floats + vec4 - glBindVertexArray(0); + // Create pipeline layout with perFrameLayout (set 0) + push constants + pipelineLayout = createPipelineLayout(device, {perFrameLayout}, {pushRange}); + if (pipelineLayout == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create weather pipeline layout"); + return false; + } + + // Vertex input: position only (vec3), stride = 3 * sizeof(float) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 3 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + // Dynamic viewport and scissor + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) // depth test on, write off (transparent particles) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create weather pipeline"); + return false; + } + + // Create a dynamic mapped vertex buffer large enough for MAX_PARTICLES + dynamicVBSize = MAX_PARTICLES * sizeof(glm::vec3); + AllocatedBuffer buf = createBuffer(vkCtx->getAllocator(), dynamicVBSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU); + dynamicVB = buf.buffer; + dynamicVBAlloc = buf.allocation; + dynamicVBAllocInfo = buf.info; + + if (dynamicVB == VK_NULL_HANDLE) { + LOG_ERROR("Failed to create weather dynamic vertex buffer"); + return false; + } // Reserve space for particles particles.reserve(MAX_PARTICLES); @@ -87,6 +116,65 @@ bool Weather::initialize() { return true; } +void Weather::recreatePipelines() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + + if (pipeline != VK_NULL_HANDLE) { vkDestroyPipeline(device, pipeline, nullptr); pipeline = VK_NULL_HANDLE; } + + VkShaderModule vertModule; + if (!vertModule.loadFromFile(device, "assets/shaders/weather.vert.spv")) { + LOG_ERROR("Weather::recreatePipelines: failed to load vertex shader"); + return; + } + VkShaderModule fragModule; + if (!fragModule.loadFromFile(device, "assets/shaders/weather.frag.spv")) { + LOG_ERROR("Weather::recreatePipelines: failed to load fragment shader"); + vertModule.destroy(); + return; + } + + VkPipelineShaderStageCreateInfo vertStage = vertModule.stageInfo(VK_SHADER_STAGE_VERTEX_BIT); + VkPipelineShaderStageCreateInfo fragStage = fragModule.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT); + + // Vertex input (same as initialize) + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 3 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + VkVertexInputAttributeDescription posAttr{}; + posAttr.location = 0; + posAttr.binding = 0; + posAttr.format = VK_FORMAT_R32G32B32_SFLOAT; + posAttr.offset = 0; + + std::vector dynamicStates = { + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + + pipeline = PipelineBuilder() + .setShaders(vertStage, fragStage) + .setVertexInput({binding}, {posAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx->getMsaaSamples()) + .setLayout(pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates(dynamicStates) + .build(device); + + vertModule.destroy(); + fragModule.destroy(); + + if (pipeline == VK_NULL_HANDLE) { + LOG_ERROR("Weather::recreatePipelines: failed to create pipeline"); + } +} + void Weather::update(const Camera& camera, float deltaTime) { if (!enabled || weatherType == Type::NONE) { return; @@ -162,58 +250,54 @@ void Weather::updateParticle(Particle& particle, const Camera& camera, float del particle.position += particle.velocity * deltaTime; } -void Weather::render(const Camera& camera) { - if (!enabled || weatherType == Type::NONE || particlePositions.empty() || !shader) { +void Weather::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!enabled || weatherType == Type::NONE || particlePositions.empty() || + pipeline == VK_NULL_HANDLE) { return; } - // Enable blending - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - - // Disable depth write (particles are transparent) - glDepthMask(GL_FALSE); - - // Enable point sprites - glEnable(GL_PROGRAM_POINT_SIZE); - - shader->use(); - - // Set matrices - glm::mat4 view = camera.getViewMatrix(); - glm::mat4 projection = camera.getProjectionMatrix(); - - shader->setUniform("uView", view); - shader->setUniform("uProjection", projection); - - // Set particle appearance based on weather type - if (weatherType == Type::RAIN) { - // Rain: white/blue streaks, small size - shader->setUniform("uParticleColor", glm::vec4(0.7f, 0.8f, 0.9f, 0.6f)); - shader->setUniform("uParticleSize", 3.0f); - } else { // SNOW - // Snow: white fluffy, larger size - shader->setUniform("uParticleColor", glm::vec4(1.0f, 1.0f, 1.0f, 0.9f)); - shader->setUniform("uParticleSize", 8.0f); + // Upload particle positions to mapped buffer + VkDeviceSize uploadSize = particlePositions.size() * sizeof(glm::vec3); + if (uploadSize > 0 && dynamicVBAllocInfo.pMappedData) { + std::memcpy(dynamicVBAllocInfo.pMappedData, particlePositions.data(), uploadSize); } - // Upload particle positions - glBindVertexArray(vao); - glBindBuffer(GL_ARRAY_BUFFER, vbo); - glBufferData(GL_ARRAY_BUFFER, - particlePositions.size() * sizeof(glm::vec3), - particlePositions.data(), - GL_DYNAMIC_DRAW); + // Push constant data: { float particleSize; float pad0; float pad1; float pad2; vec4 particleColor; } + struct WeatherPush { + float particleSize; + float pad0; + float pad1; + float pad2; + glm::vec4 particleColor; + }; - // Render particles as points - glDrawArrays(GL_POINTS, 0, static_cast(particlePositions.size())); + WeatherPush push{}; + if (weatherType == Type::RAIN) { + push.particleSize = 3.0f; + push.particleColor = glm::vec4(0.7f, 0.8f, 0.9f, 0.6f); + } else { // SNOW + push.particleSize = 8.0f; + push.particleColor = glm::vec4(1.0f, 1.0f, 1.0f, 0.9f); + } - glBindVertexArray(0); + // Bind pipeline + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline); - // Restore state - glDisable(GL_BLEND); - glDepthMask(GL_TRUE); - glDisable(GL_PROGRAM_POINT_SIZE); + // Bind per-frame descriptor set (set 0 - camera UBO) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, + 0, 1, &perFrameSet, 0, nullptr); + + // Push constants + vkCmdPushConstants(cmd, pipelineLayout, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, sizeof(push), &push); + + // Bind vertex buffer + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &dynamicVB, &offset); + + // Draw particles as points + vkCmdDraw(cmd, static_cast(particlePositions.size()), 1, 0, 0); } void Weather::resetParticles(const Camera& camera) { @@ -260,14 +344,157 @@ int Weather::getParticleCount() const { return static_cast(particles.size()); } -void Weather::cleanup() { - if (vao) { - glDeleteVertexArrays(1, &vao); - vao = 0; +void Weather::shutdown() { + if (vkCtx) { + VkDevice device = vkCtx->getDevice(); + VmaAllocator allocator = vkCtx->getAllocator(); + + if (pipeline != VK_NULL_HANDLE) { + vkDestroyPipeline(device, pipeline, nullptr); + pipeline = VK_NULL_HANDLE; + } + if (pipelineLayout != VK_NULL_HANDLE) { + vkDestroyPipelineLayout(device, pipelineLayout, nullptr); + pipelineLayout = VK_NULL_HANDLE; + } + if (dynamicVB != VK_NULL_HANDLE) { + vmaDestroyBuffer(allocator, dynamicVB, dynamicVBAlloc); + dynamicVB = VK_NULL_HANDLE; + dynamicVBAlloc = VK_NULL_HANDLE; + } } - if (vbo) { - glDeleteBuffers(1, &vbo); - vbo = 0; + + vkCtx = nullptr; + particles.clear(); + particlePositions.clear(); +} + +// --------------------------------------------------------------------------- +// Zone-based weather configuration +// --------------------------------------------------------------------------- + +void Weather::setZoneWeather(uint32_t zoneId, Type type, float minIntensity, float maxIntensity, float probability) { + zoneWeatherTable_[zoneId] = {type, minIntensity, maxIntensity, probability}; +} + +void Weather::initializeZoneWeatherDefaults() { + if (zoneWeatherInitialized_) return; + zoneWeatherInitialized_ = true; + + // Eastern Kingdoms zones + setZoneWeather(10, Type::RAIN, 0.2f, 0.6f, 0.3f); // Duskwood β€” frequent rain + setZoneWeather(11, Type::RAIN, 0.1f, 0.4f, 0.15f); // Wetlands β€” moderate rain + setZoneWeather(8, Type::RAIN, 0.1f, 0.5f, 0.2f); // Swamp of Sorrows + setZoneWeather(33, Type::RAIN, 0.2f, 0.7f, 0.25f); // Stranglethorn Vale + setZoneWeather(44, Type::RAIN, 0.1f, 0.3f, 0.1f); // Redridge Mountains β€” light rain + setZoneWeather(36, Type::RAIN, 0.1f, 0.4f, 0.15f); // Alterac Mountains + setZoneWeather(45, Type::RAIN, 0.1f, 0.3f, 0.1f); // Arathi Highlands + setZoneWeather(267, Type::RAIN, 0.2f, 0.5f, 0.2f); // Hillsbrad Foothills + setZoneWeather(28, Type::RAIN, 0.1f, 0.3f, 0.1f); // Western Plaguelands β€” occasional rain + setZoneWeather(139, Type::RAIN, 0.1f, 0.3f, 0.1f); // Eastern Plaguelands + + // Snowy zones + setZoneWeather(1, Type::SNOW, 0.2f, 0.6f, 0.3f); // Dun Morogh + setZoneWeather(51, Type::SNOW, 0.1f, 0.5f, 0.2f); // Searing Gorge (occasional) + setZoneWeather(41, Type::SNOW, 0.1f, 0.4f, 0.15f); // Deadwind Pass + setZoneWeather(2817, Type::SNOW, 0.3f, 0.7f, 0.4f); // Crystalsong Forest + setZoneWeather(67, Type::SNOW, 0.2f, 0.6f, 0.35f); // Storm Peaks + setZoneWeather(65, Type::SNOW, 0.2f, 0.5f, 0.3f); // Dragonblight + setZoneWeather(394, Type::SNOW, 0.1f, 0.4f, 0.2f); // Grizzly Hills + setZoneWeather(495, Type::SNOW, 0.3f, 0.8f, 0.5f); // Howling Fjord + setZoneWeather(210, Type::SNOW, 0.2f, 0.5f, 0.25f); // Icecrown + setZoneWeather(3537, Type::SNOW, 0.2f, 0.6f, 0.3f); // Borean Tundra + setZoneWeather(4742, Type::SNOW, 0.2f, 0.5f, 0.3f); // Hrothgar's Landing + + // Kalimdor zones + setZoneWeather(15, Type::RAIN, 0.1f, 0.4f, 0.15f); // Dustwallow Marsh + setZoneWeather(16, Type::RAIN, 0.1f, 0.3f, 0.1f); // Azshara + setZoneWeather(148, Type::RAIN, 0.1f, 0.4f, 0.15f); // Darkshore + setZoneWeather(331, Type::RAIN, 0.1f, 0.3f, 0.1f); // Ashenvale + setZoneWeather(405, Type::RAIN, 0.1f, 0.3f, 0.1f); // Desolace + setZoneWeather(15, Type::RAIN, 0.2f, 0.5f, 0.2f); // Dustwallow Marsh + setZoneWeather(490, Type::RAIN, 0.1f, 0.4f, 0.15f); // Un'Goro Crater + setZoneWeather(493, Type::RAIN, 0.1f, 0.3f, 0.1f); // Moonglade + + // Winterspring is snowy + setZoneWeather(618, Type::SNOW, 0.2f, 0.6f, 0.3f); // Winterspring + + // Outland + setZoneWeather(3483, Type::RAIN, 0.1f, 0.3f, 0.1f); // Hellfire Peninsula (occasional) + setZoneWeather(3521, Type::RAIN, 0.1f, 0.4f, 0.15f); // Zangarmarsh + setZoneWeather(3519, Type::RAIN, 0.1f, 0.3f, 0.1f); // Terokkar Forest +} + +void Weather::updateZoneWeather(uint32_t zoneId, float deltaTime) { + if (!zoneWeatherInitialized_) { + initializeZoneWeatherDefaults(); + } + + // Zone changed β€” reset weather cycle + if (zoneId != currentWeatherZone_) { + currentWeatherZone_ = zoneId; + zoneWeatherTimer_ = 0.0f; + + auto it = zoneWeatherTable_.find(zoneId); + if (it == zoneWeatherTable_.end()) { + // Zone has no configured weather β€” clear gradually + targetIntensity_ = 0.0f; + } else { + // Roll whether weather is active based on probability + float roll = static_cast(rand()) / static_cast(RAND_MAX); + zoneWeatherActive_ = (roll < it->second.probability); + + if (zoneWeatherActive_) { + weatherType = it->second.type; + // Random intensity within configured range + float t = static_cast(rand()) / static_cast(RAND_MAX); + targetIntensity_ = glm::mix(it->second.minIntensity, it->second.maxIntensity, t); + // Random cycle duration: 3-8 minutes + zoneWeatherCycleDuration_ = 180.0f + static_cast(rand()) / static_cast(RAND_MAX) * 300.0f; + } else { + targetIntensity_ = 0.0f; + zoneWeatherCycleDuration_ = 120.0f + static_cast(rand()) / static_cast(RAND_MAX) * 180.0f; + } + } + } + + // Smooth intensity transitions + float transitionSpeed = 0.15f * deltaTime; // ~7 seconds to full transition + if (intensity < targetIntensity_) { + intensity = std::min(intensity + transitionSpeed, targetIntensity_); + } else if (intensity > targetIntensity_) { + intensity = std::max(intensity - transitionSpeed, targetIntensity_); + } + + // If intensity reached zero and target is zero, clear weather type + if (intensity <= 0.01f && targetIntensity_ <= 0.01f) { + if (weatherType != Type::NONE) { + weatherType = Type::NONE; + particles.clear(); + } + } + + // Weather cycling β€” periodically re-roll weather + zoneWeatherTimer_ += deltaTime; + if (zoneWeatherTimer_ >= zoneWeatherCycleDuration_ && zoneWeatherCycleDuration_ > 0.0f) { + zoneWeatherTimer_ = 0.0f; + + auto it = zoneWeatherTable_.find(zoneId); + if (it != zoneWeatherTable_.end()) { + float roll = static_cast(rand()) / static_cast(RAND_MAX); + zoneWeatherActive_ = (roll < it->second.probability); + + if (zoneWeatherActive_) { + weatherType = it->second.type; + float t = static_cast(rand()) / static_cast(RAND_MAX); + targetIntensity_ = glm::mix(it->second.minIntensity, it->second.maxIntensity, t); + } else { + targetIntensity_ = 0.0f; + } + + // New cycle duration + zoneWeatherCycleDuration_ = 180.0f + static_cast(rand()) / static_cast(RAND_MAX) * 300.0f; + } } } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 0a1af80a..40338e11 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1,18 +1,23 @@ #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" -#include "rendering/texture.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" +#include "rendering/vk_frame_data.hpp" #include "rendering/camera.hpp" #include "rendering/frustum.hpp" #include "pipeline/wmo_loader.hpp" #include "pipeline/asset_manager.hpp" #include "core/logger.hpp" -#include #include #include #include #include #include +#include #include #include #include @@ -23,6 +28,26 @@ namespace wowee { namespace rendering { +namespace { +size_t envSizeMBOrDefault(const char* name, size_t defMb) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defMb; + char* end = nullptr; + unsigned long long mb = std::strtoull(raw, &end, 10); + if (end == raw || mb == 0) return defMb; + return static_cast(mb); +} + +size_t envSizeOrDefault(const char* name, size_t defValue) { + const char* raw = std::getenv(name); + if (!raw || !*raw) return defValue; + char* end = nullptr; + unsigned long long v = std::strtoull(raw, &end, 10); + if (end == raw || v == 0) return defValue; + return static_cast(v); +} +} // namespace + static void transformAABB(const glm::mat4& modelMatrix, const glm::vec3& localMin, const glm::vec3& localMax, @@ -36,175 +61,226 @@ WMORenderer::~WMORenderer() { shutdown(); } -bool WMORenderer::initialize(pipeline::AssetManager* assets) { +bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout, + pipeline::AssetManager* assets) { if (initialized_) { assetManager = assets; return true; } - core::Logger::getInstance().info("Initializing WMO renderer..."); + core::Logger::getInstance().info("Initializing WMO renderer (Vulkan)..."); + vkCtx_ = ctx; assetManager = assets; - numCullThreads_ = std::min(4u, std::max(1u, std::thread::hardware_concurrency() - 1)); - - // Create WMO shader with texture support - const char* vertexSrc = R"( - #version 330 core - layout (location = 0) in vec3 aPos; - layout (location = 1) in vec3 aNormal; - layout (location = 2) in vec2 aTexCoord; - layout (location = 3) in vec4 aColor; - - uniform mat4 uModel; - uniform mat4 uView; - uniform mat4 uProjection; - - out vec3 FragPos; - out vec3 Normal; - out vec2 TexCoord; - out vec4 VertexColor; - - void main() { - vec4 worldPos = uModel * vec4(aPos, 1.0); - FragPos = worldPos.xyz; - // Use mat3(uModel) directly - avoids expensive inverse() per vertex - // This works correctly for uniform scale transforms - Normal = mat3(uModel) * aNormal; - TexCoord = aTexCoord; - VertexColor = aColor; - - gl_Position = uProjection * uView * worldPos; - } - )"; - - const char* fragmentSrc = R"( - #version 330 core - in vec3 FragPos; - in vec3 Normal; - in vec2 TexCoord; - in vec4 VertexColor; - - uniform vec3 uLightDir; - uniform vec3 uLightColor; - uniform float uSpecularIntensity; - uniform vec3 uViewPos; - uniform vec3 uAmbientColor; - uniform sampler2D uTexture; - uniform bool uHasTexture; - uniform bool uAlphaTest; - uniform bool uUnlit; - uniform bool uIsInterior; - - uniform vec3 uFogColor; - uniform float uFogStart; - uniform float uFogEnd; - - uniform sampler2DShadow uShadowMap; - uniform mat4 uLightSpaceMatrix; - uniform bool uShadowEnabled; - uniform float uShadowStrength; - - out vec4 FragColor; - - void main() { - // Sample texture or use vertex color - vec4 texColor; - float alpha = 1.0; - if (uHasTexture) { - texColor = texture(uTexture, TexCoord); - // Alpha test only for cutout materials (lattice, grating, etc.) - if (uAlphaTest && texColor.a < 0.5) discard; - alpha = texColor.a; - // Don't multiply texture by vertex color here - it zeros out black MOCV areas - // Vertex colors will be applied as AO modulation after lighting - } else { - // MOCV vertex color alpha is a lighting blend factor, not transparency - texColor = vec4(VertexColor.rgb, 1.0); - } - - // Unlit materials (windows, lamps) β€” emit texture color directly - if (uUnlit) { - // Apply fog only - float fogDist = length(uViewPos - FragPos); - float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); - vec3 result = mix(uFogColor, texColor.rgb, fogFactor); - FragColor = vec4(result, alpha); - return; - } - - vec3 normal = normalize(Normal); - vec3 lightDir = normalize(uLightDir); - - vec3 litColor; - if (uIsInterior) { - // Interior: MOCV vertex colors are baked lighting. - // Use them directly as the light multiplier on the texture. - vec3 vertLight = VertexColor.rgb * 2.4 + 0.35; - // Subtle directional fill so geometry reads - float diff = max(dot(normal, lightDir), 0.0); - vertLight += diff * 0.10; - litColor = texColor.rgb * vertLight; - } else { - // Exterior: standard diffuse + specular lighting - vec3 ambient = uAmbientColor; - - float diff = max(dot(normal, lightDir), 0.0); - vec3 diffuse = diff * vec3(1.0); - - vec3 viewDir = normalize(uViewPos - FragPos); - vec3 halfDir = normalize(lightDir + viewDir); - float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); - vec3 specular = spec * uLightColor * uSpecularIntensity; - - // Shadow mapping - float shadow = 1.0; - if (uShadowEnabled) { - vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); - vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; - if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { - float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); - float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); - float bias = max(0.005 * (1.0 - dot(normal, lightDir)), 0.001); - // Single hardware PCF tap β€” GL_LINEAR + compare mode gives 2Γ—2 bilinear PCF for free - shadow = texture(uShadowMap, vec3(proj.xy, proj.z - bias)); - shadow = mix(1.0, shadow, coverageFade); - } - } - shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); - - litColor = (ambient + (diffuse + specular) * shadow) * texColor.rgb; - - // Apply vertex color as ambient occlusion (AO) with minimum to prevent blackout - // MOCV values of (0,0,0) are clamped to 0.5 to keep areas visible - vec3 ao = max(VertexColor.rgb, vec3(0.5)); - litColor *= ao; - } - - // Fog - float fogDist = length(uViewPos - FragPos); - float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); - vec3 result = mix(uFogColor, litColor, fogFactor); - - FragColor = vec4(result, alpha); - } - )"; - - shader = std::make_unique(); - if (!shader->loadFromSource(vertexSrc, fragmentSrc)) { - core::Logger::getInstance().error("Failed to create WMO shader"); + if (!vkCtx_) { + core::Logger::getInstance().error("WMORenderer: null VkContext"); return false; } - // Create default white texture for fallback + const unsigned hc = std::thread::hardware_concurrency(); + const size_t availableCores = (hc > 1u) ? static_cast(hc - 1u) : 1ull; + // WMO culling is lighter than animation; keep defaults conservative to reduce spikes. + const size_t defaultCullThreads = std::max(1, availableCores / 4); + numCullThreads_ = static_cast(std::max( + 1, envSizeOrDefault("WOWEE_WMO_CULL_THREADS", defaultCullThreads))); + core::Logger::getInstance().info("WMO cull threads: ", numCullThreads_); + + VkDevice device = vkCtx_->getDevice(); + + // --- Create material descriptor set layout (set 1) --- + // binding 0: sampler2D (diffuse texture) + // binding 1: uniform buffer (WMOMaterial) + // binding 2: sampler2D (normal+height map) + std::vector materialBindings(3); + materialBindings[0] = {}; + materialBindings[0].binding = 0; + materialBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + materialBindings[0].descriptorCount = 1; + materialBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + materialBindings[1] = {}; + materialBindings[1].binding = 1; + materialBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + materialBindings[1].descriptorCount = 1; + materialBindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + materialBindings[2] = {}; + materialBindings[2].binding = 2; + materialBindings[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + materialBindings[2].descriptorCount = 1; + materialBindings[2].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + materialSetLayout_ = createDescriptorSetLayout(device, materialBindings); + if (!materialSetLayout_) { + core::Logger::getInstance().error("WMORenderer: failed to create material set layout"); + return false; + } + + // --- Create descriptor pool --- + VkDescriptorPoolSize poolSizes[] = { + { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_MATERIAL_SETS * 2 }, // diffuse + normal/height + { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, MAX_MATERIAL_SETS }, + }; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = MAX_MATERIAL_SETS; + poolInfo.poolSizeCount = 2; + poolInfo.pPoolSizes = poolSizes; + + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &materialDescPool_) != VK_SUCCESS) { + core::Logger::getInstance().error("WMORenderer: failed to create descriptor pool"); + return false; + } + + // --- Create pipeline layout --- + VkPushConstantRange pushRange{}; + pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pushRange.offset = 0; + pushRange.size = sizeof(GPUPushConstants); + + std::vector setLayouts = { perFrameLayout, materialSetLayout_ }; + pipelineLayout_ = createPipelineLayout(device, setLayouts, { pushRange }); + if (!pipelineLayout_) { + core::Logger::getInstance().error("WMORenderer: failed to create pipeline layout"); + return false; + } + + // --- Load shaders --- + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/wmo.vert.spv")) { + core::Logger::getInstance().error("WMORenderer: failed to load vertex shader"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/wmo.frag.spv")) { + core::Logger::getInstance().error("WMORenderer: failed to load fragment shader"); + return false; + } + + // --- Vertex input --- + // WMO vertex: pos3 + normal3 + texCoord2 + color4 + tangent4 = 64 bytes + struct WMOVertexData { + glm::vec3 position; + glm::vec3 normal; + glm::vec2 texCoord; + glm::vec4 color; + glm::vec4 tangent; // xyz=tangent dir, w=handedness Β±1 + }; + + VkVertexInputBindingDescription vertexBinding{}; + vertexBinding.binding = 0; + vertexBinding.stride = sizeof(WMOVertexData); + vertexBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector vertexAttribs(5); + vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(WMOVertexData, position)) }; + vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(WMOVertexData, normal)) }; + vertexAttribs[2] = { 2, 0, VK_FORMAT_R32G32_SFLOAT, + static_cast(offsetof(WMOVertexData, texCoord)) }; + vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, + static_cast(offsetof(WMOVertexData, color)) }; + vertexAttribs[4] = { 4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, + static_cast(offsetof(WMOVertexData, tangent)) }; + + // --- Build opaque pipeline --- + VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); + + opaquePipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + if (!opaquePipeline_) { + core::Logger::getInstance().error("WMORenderer: failed to create opaque pipeline"); + vertShader.destroy(); + fragShader.destroy(); + return false; + } + + // --- Build transparent pipeline --- + transparentPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + if (!transparentPipeline_) { + core::Logger::getInstance().warning("WMORenderer: transparent pipeline not available"); + } + + // --- Build glass pipeline (alpha blend WITH depth write for windows) --- + glassPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + // --- Build wireframe pipeline --- + wireframePipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_LINE, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + if (!wireframePipeline_) { + core::Logger::getInstance().warning("WMORenderer: wireframe pipeline not available"); + } + + vertShader.destroy(); + fragShader.destroy(); + + // --- Create fallback white texture --- + whiteTexture_ = std::make_unique(); uint8_t whitePixel[4] = {255, 255, 255, 255}; - glGenTextures(1, &whiteTexture); - glBindTexture(GL_TEXTURE_2D, whiteTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, whitePixel); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glBindTexture(GL_TEXTURE_2D, 0); + whiteTexture_->upload(*vkCtx_, whitePixel, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + whiteTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); - // Initialize occlusion query resources - initOcclusionResources(); + // --- Create flat normal placeholder texture --- + // (128,128,255,128) = flat normal pointing up (0,0,1), mid-height + flatNormalTexture_ = std::make_unique(); + uint8_t flatNormalPixel[4] = {128, 128, 255, 128}; + flatNormalTexture_->upload(*vkCtx_, flatNormalPixel, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + flatNormalTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + textureCacheBudgetBytes_ = + envSizeMBOrDefault("WOWEE_WMO_TEX_CACHE_MB", 4096) * 1024ull * 1024ull; + modelCacheLimit_ = envSizeMBOrDefault("WOWEE_WMO_MODEL_LIMIT", 4000); + core::Logger::getInstance().info("WMO texture cache budget: ", + textureCacheBudgetBytes_ / (1024 * 1024), " MB"); + core::Logger::getInstance().info("WMO model cache limit: ", modelCacheLimit_); - core::Logger::getInstance().info("WMO renderer initialized"); + core::Logger::getInstance().info("WMO renderer initialized (Vulkan)"); initialized_ = true; return true; } @@ -212,47 +288,66 @@ bool WMORenderer::initialize(pipeline::AssetManager* assets) { void WMORenderer::shutdown() { core::Logger::getInstance().info("Shutting down WMO renderer..."); - // Free all GPU resources + if (!vkCtx_) { + loadedModels.clear(); + instances.clear(); + spatialGrid.clear(); + instanceIndexById.clear(); + initialized_ = false; + return; + } + + VkDevice device = vkCtx_->getDevice(); + VmaAllocator allocator = vkCtx_->getAllocator(); + + vkDeviceWaitIdle(device); + + // Free all GPU resources for loaded models for (auto& [id, model] : loadedModels) { for (auto& group : model.groups) { - if (group.vao != 0) glDeleteVertexArrays(1, &group.vao); - if (group.vbo != 0) glDeleteBuffers(1, &group.vbo); - if (group.ebo != 0) glDeleteBuffers(1, &group.ebo); + destroyGroupGPU(group); } } // Free cached textures for (auto& [path, entry] : textureCache) { - GLuint texId = entry.id; - if (texId != 0 && texId != whiteTexture) { - glDeleteTextures(1, &texId); - } + if (entry.texture) entry.texture->destroy(device, allocator); + if (entry.normalHeightMap) entry.normalHeightMap->destroy(device, allocator); } textureCache.clear(); textureCacheBytes_ = 0; textureCacheCounter_ = 0; + failedTextureCache_.clear(); + loggedTextureLoadFails_.clear(); + textureBudgetRejectWarnings_ = 0; - // Free white texture - if (whiteTexture != 0) { - glDeleteTextures(1, &whiteTexture); - whiteTexture = 0; - } + // Free white texture and flat normal texture + if (whiteTexture_) { whiteTexture_->destroy(device, allocator); whiteTexture_.reset(); } + if (flatNormalTexture_) { flatNormalTexture_->destroy(device, allocator); flatNormalTexture_.reset(); } loadedModels.clear(); instances.clear(); spatialGrid.clear(); instanceIndexById.clear(); - shader.reset(); - // Free occlusion query resources - for (auto& [key, query] : occlusionQueries) { - glDeleteQueries(1, &query); - } - occlusionQueries.clear(); - occlusionResults.clear(); - if (bboxVao != 0) { glDeleteVertexArrays(1, &bboxVao); bboxVao = 0; } - if (bboxVbo != 0) { glDeleteBuffers(1, &bboxVbo); bboxVbo = 0; } - occlusionShader.reset(); + // Destroy pipelines + if (opaquePipeline_) { vkDestroyPipeline(device, opaquePipeline_, nullptr); opaquePipeline_ = VK_NULL_HANDLE; } + if (transparentPipeline_) { vkDestroyPipeline(device, transparentPipeline_, nullptr); transparentPipeline_ = VK_NULL_HANDLE; } + if (glassPipeline_) { vkDestroyPipeline(device, glassPipeline_, nullptr); glassPipeline_ = VK_NULL_HANDLE; } + if (wireframePipeline_) { vkDestroyPipeline(device, wireframePipeline_, nullptr); wireframePipeline_ = VK_NULL_HANDLE; } + if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } + if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; } + if (materialSetLayout_) { vkDestroyDescriptorSetLayout(device, materialSetLayout_, nullptr); materialSetLayout_ = VK_NULL_HANDLE; } + + // Destroy shadow resources + if (shadowPipeline_) { vkDestroyPipeline(device, shadowPipeline_, nullptr); shadowPipeline_ = VK_NULL_HANDLE; } + if (shadowPipelineLayout_) { vkDestroyPipelineLayout(device, shadowPipelineLayout_, nullptr); shadowPipelineLayout_ = VK_NULL_HANDLE; } + if (shadowParamsPool_) { vkDestroyDescriptorPool(device, shadowParamsPool_, nullptr); shadowParamsPool_ = VK_NULL_HANDLE; } + if (shadowParamsLayout_) { vkDestroyDescriptorSetLayout(device, shadowParamsLayout_, nullptr); shadowParamsLayout_ = VK_NULL_HANDLE; } + if (shadowParamsUBO_) { vmaDestroyBuffer(allocator, shadowParamsUBO_, shadowParamsAlloc_); shadowParamsUBO_ = VK_NULL_HANDLE; } + + vkCtx_ = nullptr; + initialized_ = false; } bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { @@ -266,17 +361,25 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { if (existingIt != loadedModels.end()) { // If a model was first loaded while texture resolution failed (or before // assets were fully available), it can remain permanently white because - // merged batches cache texture IDs at load time. Do a one-time reload for + // merged batches cache texture pointers at load time. Do a one-time reload for // models that have texture paths but no resolved non-white textures. if (assetManager && !model.textures.empty()) { bool hasResolvedTexture = false; - for (GLuint texId : existingIt->second.textures) { - if (texId != 0 && texId != whiteTexture) { + for (VkTexture* tex : existingIt->second.textures) { + if (tex != nullptr && tex != whiteTexture_.get()) { hasResolvedTexture = true; break; } } static std::unordered_set retryReloadedModels; + static bool retryReloadedModelsCapped = false; + if (retryReloadedModels.size() > 8192) { + retryReloadedModels.clear(); + if (!retryReloadedModelsCapped) { + core::Logger::getInstance().warning("WMO fallback-retry set exceeded 8192 entries; reset"); + retryReloadedModelsCapped = true; + } + } if (!hasResolvedTexture && retryReloadedModels.insert(id).second) { core::Logger::getInstance().warning( "WMO model ", id, @@ -289,6 +392,15 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { return true; } } + if (loadedModels.size() >= modelCacheLimit_) { + if (modelLimitRejectWarnings_ < 3) { + core::Logger::getInstance().warning("WMO model cache full (", + loadedModels.size(), "/", modelCacheLimit_, + "), skipping model load: id=", id); + } + ++modelLimitRejectWarnings_; + return false; + } core::Logger::getInstance().debug("Loading WMO model ", id, " with ", model.groups.size(), " groups, ", model.textures.size(), " textures..."); @@ -313,8 +425,13 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { for (size_t i = 0; i < model.textures.size(); i++) { const auto& texPath = model.textures[i]; core::Logger::getInstance().debug(" Loading texture ", i, ": ", texPath); - GLuint texId = loadTexture(texPath); - modelData.textures.push_back(texId); + VkTexture* tex = loadTexture(texPath); + modelData.textures.push_back(tex); + // Store lowercase texture name for material detection + std::string lowerPath = texPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + modelData.textureNames.push_back(lowerPath); } core::Logger::getInstance().debug(" Loaded ", modelData.textures.size(), " textures for WMO"); } @@ -369,11 +486,26 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { modelData.materialTextureIndices.push_back(texIndex); modelData.materialBlendModes.push_back(mat.blendMode); modelData.materialFlags.push_back(mat.flags); + } + // Helper: look up group name from MOGN raw data via MOGI nameOffset + auto getGroupName = [&](uint32_t groupIdx) -> std::string { + if (groupIdx < model.groupInfo.size()) { + int32_t nameOff = model.groupInfo[groupIdx].nameOffset; + if (nameOff >= 0 && static_cast(nameOff) < model.groupNameRaw.size()) { + const char* str = reinterpret_cast(model.groupNameRaw.data() + nameOff); + size_t maxLen = model.groupNameRaw.size() - nameOff; + return std::string(str, strnlen(str, maxLen)); + } + } + return {}; + }; + // Create GPU resources for each group uint32_t loadedGroups = 0; - for (const auto& wmoGroup : model.groups) { + for (size_t gi = 0; gi < model.groups.size(); gi++) { + const auto& wmoGroup = model.groups[gi]; // Skip empty groups if (wmoGroup.vertices.empty() || wmoGroup.indices.empty()) { continue; @@ -381,6 +513,32 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { GroupResources resources; if (createGroupResources(wmoGroup, resources, wmoGroup.flags)) { + // Detect distance-only LOD/exterior shell groups: + // 1. Very low vertex count (<100) β€” portal connectors, tiny shells + // 2. ALWAYS_DRAW (0x10000) with low verts β€” distant LOD stand-ins + // 3. Pure OUTDOOR groups (0x8 set, 0x2000 not set) in large WMOs β€” + // exterior cityscape shells (e.g. "city01" in Stormwind) + bool alwaysDraw = (wmoGroup.flags & 0x10000) != 0; + size_t nVerts = wmoGroup.vertices.size(); + bool isLargeWmo = model.nGroups > 50; + // Detect facade groups by name (exterior face of buildings) + std::string gname = getGroupName(static_cast(gi)); + bool isFacade = false; + bool isCityShell = false; + if (!gname.empty()) { + std::string lower = gname; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + isFacade = lower.find("facade") != std::string::npos; + // "city01" etc are exterior cityscape shells in large WMOs + isCityShell = (lower.find("city") == 0 && lower.size() <= 8); + } + // Flag 0x80 on INDOOR groups in large WMOs = interior cathedral shell + bool hasFlag80 = (wmoGroup.flags & 0x80) != 0; + bool isIndoor = (wmoGroup.flags & 0x2000) != 0; + if (nVerts < 100 || (alwaysDraw && nVerts < 5000) || (isFacade && isLargeWmo) || (isCityShell && isLargeWmo) || (hasFlag80 && isIndoor && isLargeWmo)) { + resources.isLOD = true; + } modelData.groups.push_back(resources); loadedGroups++; } @@ -393,21 +551,34 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { // Build pre-merged batches for each group (texture-sorted for efficient rendering) for (auto& groupRes : modelData.groups) { - std::unordered_map batchMap; + // Use pointer value as key for batching + struct BatchKey { + uintptr_t texPtr; + bool alphaTest; + bool unlit; + bool isWindow; + bool operator==(const BatchKey& o) const { return texPtr == o.texPtr && alphaTest == o.alphaTest && unlit == o.unlit && isWindow == o.isWindow; } + }; + struct BatchKeyHash { + size_t operator()(const BatchKey& k) const { + return std::hash()(k.texPtr) ^ (std::hash()(k.alphaTest) << 1) ^ (std::hash()(k.unlit) << 2) ^ (std::hash()(k.isWindow) << 3); + } + }; + std::unordered_map batchMap; for (const auto& batch : groupRes.batches) { - GLuint texId = whiteTexture; + VkTexture* tex = whiteTexture_.get(); bool hasTexture = false; if (batch.materialId < modelData.materialTextureIndices.size()) { uint32_t texIndex = modelData.materialTextureIndices[batch.materialId]; if (texIndex < modelData.textures.size()) { - texId = modelData.textures[texIndex]; - hasTexture = (texId != 0 && texId != whiteTexture); + tex = modelData.textures[texIndex]; + hasTexture = (tex != nullptr && tex != whiteTexture_.get()); + if (!tex) tex = whiteTexture_.get(); } } - bool alphaTest = false; uint32_t blendMode = 0; if (batch.materialId < modelData.materialBlendModes.size()) { @@ -422,30 +593,119 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { unlit = (matFlags & 0x01) != 0; } - // Skip materials that are sky/window panes (render as grey curtains if drawn opaque) - // 0x20 = F_SIDN (night sky window), 0x40 = F_WINDOW - if (matFlags & 0x60) continue; + // Detect window/glass materials by texture name. + // Flag 0x10 (F_SIDN) marks night-glow materials (windows AND lamps), + // so we additionally check for "window" in the texture path to + // distinguish actual glass from lamp post geometry. + bool isWindow = false; + if (batch.materialId < modelData.materialTextureIndices.size()) { + uint32_t ti = modelData.materialTextureIndices[batch.materialId]; + if (ti < modelData.textureNames.size()) { + isWindow = (modelData.textureNames[ti].find("window") != std::string::npos); + } + } - // Merge key: texture ID + alphaTest + unlit (unlit batches must not merge with lit) - uint64_t key = (static_cast(texId) << 2) - | (alphaTest ? 1ULL : 0ULL) - | (unlit ? 2ULL : 0ULL); + BatchKey key{ reinterpret_cast(tex), alphaTest, unlit, isWindow }; auto& mb = batchMap[key]; - if (mb.counts.empty()) { - mb.texId = texId; + if (mb.draws.empty()) { + mb.texture = tex; mb.hasTexture = hasTexture; mb.alphaTest = alphaTest; mb.unlit = unlit; - mb.blendMode = blendMode; + mb.isTransparent = (blendMode >= 2); + mb.isWindow = isWindow; + // Look up normal/height map from texture cache + if (hasTexture && tex != whiteTexture_.get()) { + for (const auto& [cacheKey, cacheEntry] : textureCache) { + if (cacheEntry.texture.get() == tex) { + mb.normalHeightMap = cacheEntry.normalHeightMap.get(); + mb.heightMapVariance = cacheEntry.heightMapVariance; + break; + } + } + } } - mb.counts.push_back(static_cast(batch.indexCount)); - mb.offsets.push_back(reinterpret_cast(batch.startIndex * sizeof(uint16_t))); + GroupResources::MergedBatch::DrawRange dr; + dr.firstIndex = batch.startIndex; + dr.indexCount = batch.indexCount; + mb.draws.push_back(dr); } + // Allocate descriptor sets and UBOs for each merged batch groupRes.mergedBatches.reserve(batchMap.size()); bool anyTextured = false; + bool isInterior = (groupRes.groupFlags & 0x2000) != 0; for (auto& [key, mb] : batchMap) { if (mb.hasTexture) anyTextured = true; + + // Create material UBO + VmaAllocator allocator = vkCtx_->getAllocator(); + AllocatedBuffer matBuf = createBuffer(allocator, sizeof(WMOMaterialUBO), + VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, + VMA_MEMORY_USAGE_CPU_TO_GPU); + mb.materialUBO = matBuf.buffer; + mb.materialUBOAlloc = matBuf.allocation; + + // Write material params + WMOMaterialUBO matData{}; + matData.hasTexture = mb.hasTexture ? 1 : 0; + matData.alphaTest = mb.alphaTest ? 1 : 0; + matData.unlit = mb.unlit ? 1 : 0; + matData.isInterior = isInterior ? 1 : 0; + matData.specularIntensity = 0.5f; + matData.isWindow = mb.isWindow ? 1 : 0; + matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; + matData.enablePOM = pomEnabled_ ? 1 : 0; + matData.pomScale = 0.012f; + { + static const int pomSampleTable[] = { 16, 32, 64 }; + matData.pomMaxSamples = pomSampleTable[std::clamp(pomQuality_, 0, 2)]; + } + matData.heightMapVariance = mb.heightMapVariance; + matData.normalMapStrength = normalMapStrength_; + if (matBuf.info.pMappedData) { + memcpy(matBuf.info.pMappedData, &matData, sizeof(matData)); + } + + // Allocate and write descriptor set + mb.materialSet = allocateMaterialSet(); + if (mb.materialSet) { + VkTexture* texToUse = mb.texture ? mb.texture : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = texToUse->descriptorInfo(); + + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = mb.materialUBO; + bufInfo.offset = 0; + bufInfo.range = sizeof(WMOMaterialUBO); + + VkTexture* nhMap = mb.normalHeightMap ? mb.normalHeightMap : flatNormalTexture_.get(); + VkDescriptorImageInfo nhImgInfo = nhMap->descriptorInfo(); + + VkWriteDescriptorSet writes[3] = {}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = mb.materialSet; + writes[0].dstBinding = 0; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].descriptorCount = 1; + writes[0].pImageInfo = &imgInfo; + + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = mb.materialSet; + writes[1].dstBinding = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].descriptorCount = 1; + writes[1].pBufferInfo = &bufInfo; + + writes[2].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[2].dstSet = mb.materialSet; + writes[2].dstBinding = 2; + writes[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[2].descriptorCount = 1; + writes[2].pImageInfo = &nhImgInfo; + + vkUpdateDescriptorSets(vkCtx_->getDevice(), 3, writes, 0, nullptr); + } + groupRes.mergedBatches.push_back(std::move(mb)); } groupRes.allUntextured = !anyTextured && !groupRes.mergedBatches.empty(); @@ -544,9 +804,7 @@ void WMORenderer::unloadModel(uint32_t id) { // Free GPU resources for (auto& group : it->second.groups) { - if (group.vao != 0) glDeleteVertexArrays(1, &group.vao); - if (group.vbo != 0) glDeleteBuffers(1, &group.vbo); - if (group.ebo != 0) glDeleteBuffers(1, &group.ebo); + destroyGroupGPU(group); } loadedModels.erase(it); @@ -792,20 +1050,7 @@ void WMORenderer::clearCollisionFocus() { collisionFocusEnabled = false; } -void WMORenderer::setLighting(const float lightDirIn[3], const float lightColorIn[3], - const float ambientColorIn[3]) { - lightDir[0] = lightDirIn[0]; - lightDir[1] = lightDirIn[1]; - lightDir[2] = lightDirIn[2]; - - lightColor[0] = lightColorIn[0]; - lightColor[1] = lightColorIn[1]; - lightColor[2] = lightColorIn[2]; - - ambientColor[0] = ambientColorIn[0]; - ambientColor[1] = ambientColorIn[1]; - ambientColor[2] = ambientColorIn[2]; -} +// setLighting is now a no-op (lighting is in the per-frame UBO) void WMORenderer::resetQueryStats() { queryTimeMs = 0.0; @@ -1017,77 +1262,49 @@ void WMORenderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& q } } -void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection) { - if (!shader || instances.empty()) { +void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (!opaquePipeline_ || instances.empty()) { lastDrawCalls = 0; return; } - lastDrawCalls = 0; - - // Set shader uniforms - shader->use(); - shader->setUniform("uView", view); - shader->setUniform("uProjection", projection); - shader->setUniform("uViewPos", camera.getPosition()); - shader->setUniform("uLightDir", glm::vec3(lightDir[0], lightDir[1], lightDir[2])); - shader->setUniform("uLightColor", glm::vec3(lightColor[0], lightColor[1], lightColor[2])); - shader->setUniform("uSpecularIntensity", 0.5f); - shader->setUniform("uAmbientColor", glm::vec3(ambientColor[0], ambientColor[1], ambientColor[2])); - shader->setUniform("uFogColor", fogColor); - shader->setUniform("uFogStart", fogStart); - shader->setUniform("uFogEnd", fogEnd); - shader->setUniform("uShadowEnabled", shadowEnabled ? 1 : 0); - shader->setUniform("uShadowStrength", 0.68f); - if (shadowEnabled) { - shader->setUniform("uLightSpaceMatrix", lightSpaceMatrix); - glActiveTexture(GL_TEXTURE7); - glBindTexture(GL_TEXTURE_2D, shadowDepthTex); - shader->setUniform("uShadowMap", 7); - } - - // Set up texture unit 0 for diffuse textures (set once per frame) - glActiveTexture(GL_TEXTURE0); - shader->setUniform("uTexture", 0); - - // Initialize new uniforms to defaults - shader->setUniform("uUnlit", false); - shader->setUniform("uIsInterior", false); - - // Enable wireframe if requested - if (wireframeMode) { - glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); - } - - // WMOs are opaque β€” ensure blending is off (alpha test via discard in shader) - glDisable(GL_BLEND); - - // Disable backface culling for WMOs (some faces may have wrong winding) - glDisable(GL_CULL_FACE); - - // Extract frustum planes for proper culling - Frustum frustum; - frustum.extractFromMatrix(projection * view); - - lastPortalCulledGroups = 0; - lastDistanceCulledGroups = 0; - lastOcclusionCulledGroups = 0; - - // Collect occlusion query results from previous frame (non-blocking) - if (occlusionCulling) { - for (auto& [queryKey, query] : occlusionQueries) { - GLuint available = 0; - glGetQueryObjectuiv(query, GL_QUERY_RESULT_AVAILABLE, &available); - if (available) { - GLuint result = 0; - glGetQueryObjectuiv(query, GL_QUERY_RESULT, &result); - occlusionResults[queryKey] = (result > 0); + // Update material UBOs if settings changed + if (materialSettingsDirty_) { + materialSettingsDirty_ = false; + static const int pomSampleTable[] = { 16, 32, 64 }; + int maxSamples = pomSampleTable[std::clamp(pomQuality_, 0, 2)]; + for (auto& [modelId, model] : loadedModels) { + for (auto& group : model.groups) { + for (auto& mb : group.mergedBatches) { + if (!mb.materialUBO) continue; + // Read existing UBO data, update normal/POM fields + VmaAllocationInfo allocInfo{}; + vmaGetAllocationInfo(vkCtx_->getAllocator(), mb.materialUBOAlloc, &allocInfo); + if (allocInfo.pMappedData) { + auto* ubo = reinterpret_cast(allocInfo.pMappedData); + ubo->enableNormalMap = normalMappingEnabled_ ? 1 : 0; + ubo->enablePOM = pomEnabled_ ? 1 : 0; + ubo->pomScale = 0.012f; + ubo->pomMaxSamples = maxSamples; + ubo->heightMapVariance = mb.heightMapVariance; + ubo->normalMapStrength = normalMapStrength_; + } + } } } } + lastDrawCalls = 0; + + // Extract frustum planes for proper culling + glm::mat4 viewProj = camera.getProjectionMatrix() * camera.getViewMatrix(); + Frustum frustum; + frustum.extractFromMatrix(viewProj); + + lastPortalCulledGroups = 0; + lastDistanceCulledGroups = 0; + // ── Phase 1: Parallel visibility culling ────────────────────────── - // Build list of instances for draw list generation. std::vector visibleInstances; visibleInstances.reserve(instances.size()); for (size_t i = 0; i < instances.size(); ++i) { @@ -1097,11 +1314,8 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm: visibleInstances.push_back(i); } - // Per-instance cull lambda β€” produces an InstanceDrawList for one instance. - // Reads only const data; each invocation writes to its own output. glm::vec3 camPos = camera.getPosition(); bool doPortalCull = portalCulling; - bool doOcclusionCull = occlusionCulling; bool doFrustumCull = false; // Temporarily disabled: can over-cull world WMOs bool doDistanceCull = distanceCulling; @@ -1125,19 +1339,12 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm: } for (size_t gi = 0; gi < model.groups.size(); ++gi) { - // Portal culling if (usePortalCulling && portalVisibleGroups.find(static_cast(gi)) == portalVisibleGroups.end()) { result.portalCulled++; continue; } - // Occlusion culling (reads previous-frame results, read-only map) - if (doOcclusionCull && isGroupOccluded(instance.id, static_cast(gi))) { - result.occlusionCulled++; - continue; - } - if (gi < instance.worldGroupBounds.size()) { const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; @@ -1150,7 +1357,6 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm: } } - // Frustum culling if (doFrustumCull && !frustum.intersectsAABB(gMin, gMax)) continue; } @@ -1164,104 +1370,349 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm: std::vector drawLists; drawLists.reserve(visibleInstances.size()); - if (visibleInstances.size() >= 4 && numCullThreads_ > 1) { - const size_t numThreads = std::min(static_cast(numCullThreads_), - visibleInstances.size()); - const size_t chunkSize = visibleInstances.size() / numThreads; - const size_t remainder = visibleInstances.size() % numThreads; + static const size_t minParallelCullInstances = std::max( + 4, envSizeOrDefault("WOWEE_WMO_CULL_MT_MIN", 128)); + if (visibleInstances.size() >= minParallelCullInstances && numCullThreads_ > 1) { + static const size_t minCullWorkPerThread = std::max( + 16, envSizeOrDefault("WOWEE_WMO_CULL_WORK_PER_THREAD", 64)); + const size_t maxUsefulThreads = std::max( + 1, (visibleInstances.size() + minCullWorkPerThread - 1) / minCullWorkPerThread); + const size_t numThreads = std::min(static_cast(numCullThreads_), maxUsefulThreads); + if (numThreads <= 1) { + for (size_t idx : visibleInstances) { + drawLists.push_back(cullInstance(idx)); + } + } else { + const size_t chunkSize = visibleInstances.size() / numThreads; + const size_t remainder = visibleInstances.size() % numThreads; - // Each future returns a vector of InstanceDrawList for its chunk. - std::vector>> futures; - futures.reserve(numThreads); + drawLists.resize(visibleInstances.size()); - size_t start = 0; - for (size_t t = 0; t < numThreads; ++t) { - size_t end = start + chunkSize + (t < remainder ? 1 : 0); - futures.push_back(std::async(std::launch::async, - [&, start, end]() { - std::vector chunk; - chunk.reserve(end - start); - for (size_t j = start; j < end; ++j) - chunk.push_back(cullInstance(visibleInstances[j])); - return chunk; - })); - start = end; - } + cullFutures_.clear(); + if (cullFutures_.capacity() < numThreads) { + cullFutures_.reserve(numThreads); + } - for (auto& f : futures) { - auto chunk = f.get(); - for (auto& dl : chunk) - drawLists.push_back(std::move(dl)); + size_t start = 0; + for (size_t t = 0; t < numThreads; ++t) { + const size_t end = start + chunkSize + (t < remainder ? 1 : 0); + cullFutures_.push_back(std::async(std::launch::async, + [&, start, end]() { + for (size_t j = start; j < end; ++j) { + drawLists[j] = cullInstance(visibleInstances[j]); + } + })); + start = end; + } + + for (auto& f : cullFutures_) { + f.get(); + } } } else { for (size_t idx : visibleInstances) drawLists.push_back(cullInstance(idx)); } - // ── Phase 2: Sequential GL draw ──────────────────────────────── + // ── Phase 2: Vulkan draw ──────────────────────────────── + // Select pipeline based on wireframe mode + VkPipeline activePipeline = (wireframeMode && wireframePipeline_) ? wireframePipeline_ : opaquePipeline_; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, activePipeline); + + // Bind per-frame descriptor set (set 0) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 0, 1, &perFrameSet, 0, nullptr); + + // Track which pipeline is currently bound: 0=opaque, 1=transparent, 2=glass + int currentPipelineKind = 0; + for (const auto& dl : drawLists) { if (dl.instanceIndex >= instances.size()) continue; const auto& instance = instances[dl.instanceIndex]; auto modelIt = loadedModels.find(instance.modelId); if (modelIt == loadedModels.end()) continue; const ModelData& model = modelIt->second; - // Occlusion query pre-pass (GL calls β€” must be main thread) - if (occlusionCulling && occlusionShader && bboxVao != 0) { - runOcclusionQueries(instance, model, view, projection); - shader->use(); - } - shader->setUniform("uModel", instance.modelMatrix); + + // Push model matrix + GPUPushConstants push{}; + push.model = instance.modelMatrix; + vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, + 0, sizeof(GPUPushConstants), &push); + + // LOD shell groups render only beyond this distance squared (190 units) + static constexpr float LOD_SHELL_DIST_SQ = 196.0f * 196.0f; // Render visible groups for (uint32_t gi : dl.visibleGroups) { const auto& group = model.groups[gi]; - // Only skip antiportal geometry. Other flags vary across assets and can - // incorrectly hide valid world building groups. - if (group.groupFlags & 0x4000000) { - continue; + // Only skip antiportal geometry + if (group.groupFlags & 0x4000000) continue; + + // Skip distance-only LOD shell groups when camera is close to the group + if (group.isLOD) { + glm::vec3 groupCenter = instance.modelMatrix * glm::vec4( + (group.boundingBoxMin + group.boundingBoxMax) * 0.5f, 1.0f); + float groupDistSq = glm::dot(camPos - groupCenter, camPos - groupCenter); + if (groupDistSq < LOD_SHELL_DIST_SQ) continue; } - // Do not globally cull untextured groups: some valid world WMOs can - // temporarily resolve to fallback textures. Render geometry anyway. + // Bind vertex + index buffers + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &group.vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, group.indexBuffer, 0, VK_INDEX_TYPE_UINT16); + // Render each merged batch + for (const auto& mb : group.mergedBatches) { + if (!mb.materialSet) continue; - renderGroup(group, model, instance.modelMatrix, view, projection); + // Determine which pipeline this batch needs + int neededPipeline = 0; // opaque + if (mb.isWindow && glassPipeline_) { + neededPipeline = 2; // glass (alpha blend + depth write) + } else if (mb.isTransparent && transparentPipeline_) { + neededPipeline = 1; // transparent (alpha blend, no depth write) + } + + // Switch pipeline if needed (descriptor sets and push constants + // are preserved across compatible pipeline layout switches) + if (neededPipeline != currentPipelineKind) { + VkPipeline targetPipeline = activePipeline; + if (neededPipeline == 1) targetPipeline = transparentPipeline_; + else if (neededPipeline == 2) targetPipeline = glassPipeline_; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, targetPipeline); + currentPipelineKind = neededPipeline; + } + + // Bind material descriptor set (set 1) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, + 1, 1, &mb.materialSet, 0, nullptr); + + // Issue draw calls for each range in this merged batch + for (const auto& dr : mb.draws) { + vkCmdDrawIndexed(cmd, dr.indexCount, 1, dr.firstIndex, 0, 0); + lastDrawCalls++; + } + } } lastPortalCulledGroups += dl.portalCulled; lastDistanceCulledGroups += dl.distanceCulled; - lastOcclusionCulledGroups += dl.occlusionCulled; } - - // Restore polygon mode - if (wireframeMode) { - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); - } - - // Re-enable backface culling - glEnable(GL_CULL_FACE); } -void WMORenderer::renderShadow(const glm::mat4& lightView, const glm::mat4& lightProj, Shader& shadowShader) { - if (instances.empty()) return; - Frustum frustum; - frustum.extractFromMatrix(lightProj * lightView); +bool WMORenderer::initializeShadow(VkRenderPass shadowRenderPass) { + if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false; + VkDevice device = vkCtx_->getDevice(); + + // ShadowParams UBO: useBones, useTexture, alphaTest, foliageSway, windTime, foliageMotionDamp + struct ShadowParamsUBO { + int32_t useBones = 0; + int32_t useTexture = 0; + int32_t alphaTest = 0; + int32_t foliageSway = 0; + float windTime = 0.0f; + float foliageMotionDamp = 1.0f; + }; + + // Create ShadowParams UBO + VkBufferCreateInfo bufCI{}; + bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufCI.size = sizeof(ShadowParamsUBO); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufCI, &allocCI, + &shadowParamsUBO_, &shadowParamsAlloc_, &allocInfo) != VK_SUCCESS) { + core::Logger::getInstance().error("WMORenderer: failed to create shadow params UBO"); + return false; + } + ShadowParamsUBO defaultParams{}; + std::memcpy(allocInfo.pMappedData, &defaultParams, sizeof(defaultParams)); + + // Create descriptor set layout: binding 0 = sampler2D (texture), binding 1 = ShadowParams UBO + VkDescriptorSetLayoutBinding layoutBindings[2]{}; + layoutBindings[0].binding = 0; + layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + layoutBindings[0].descriptorCount = 1; + layoutBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + layoutBindings[1].binding = 1; + layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + layoutBindings[1].descriptorCount = 1; + layoutBindings[1].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layoutCI{}; + layoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutCI.bindingCount = 2; + layoutCI.pBindings = layoutBindings; + if (vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &shadowParamsLayout_) != VK_SUCCESS) { + core::Logger::getInstance().error("WMORenderer: failed to create shadow params layout"); + return false; + } + + // Create descriptor pool + VkDescriptorPoolSize poolSizes[2]{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSizes[0].descriptorCount = 1; + poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[1].descriptorCount = 1; + VkDescriptorPoolCreateInfo poolCI{}; + poolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolCI.maxSets = 1; + poolCI.poolSizeCount = 2; + poolCI.pPoolSizes = poolSizes; + if (vkCreateDescriptorPool(device, &poolCI, nullptr, &shadowParamsPool_) != VK_SUCCESS) { + core::Logger::getInstance().error("WMORenderer: failed to create shadow params pool"); + return false; + } + + // Allocate descriptor set + VkDescriptorSetAllocateInfo setAlloc{}; + setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + setAlloc.descriptorPool = shadowParamsPool_; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &shadowParamsLayout_; + if (vkAllocateDescriptorSets(device, &setAlloc, &shadowParamsSet_) != VK_SUCCESS) { + core::Logger::getInstance().error("WMORenderer: failed to allocate shadow params set"); + return false; + } + + // Write descriptors + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = shadowParamsUBO_; + bufInfo.offset = 0; + bufInfo.range = sizeof(ShadowParamsUBO); + + VkWriteDescriptorSet writes[2]{}; + // binding 0: texture (use white fallback so binding is valid; useTexture=0 so it's not sampled) + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = whiteTexture_->getImageView(); + imgInfo.sampler = whiteTexture_->getSampler(); + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = shadowParamsSet_; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + // binding 1: params UBO + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = shadowParamsSet_; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + + // Create shadow pipeline layout: set 1 = shadowParamsLayout_, push constants = 128 bytes + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pc.offset = 0; + pc.size = 128; // lightSpaceMatrix (64) + model (64) + shadowPipelineLayout_ = createPipelineLayout(device, {shadowParamsLayout_}, {pc}); + if (!shadowPipelineLayout_) { + core::Logger::getInstance().error("WMORenderer: failed to create shadow pipeline layout"); + return false; + } + + // Load shadow shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/shadow.vert.spv")) { + core::Logger::getInstance().error("WMORenderer: failed to load shadow vertex shader"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/shadow.frag.spv")) { + core::Logger::getInstance().error("WMORenderer: failed to load shadow fragment shader"); + return false; + } + + // WMO vertex layout: pos(loc0,off0) normal(loc1,off12) texCoord(loc2,off24) color(loc3,off32) tangent(loc4,off48), stride=64 + // Shadow shader locations: 0=aPos, 1=aTexCoord, 2=aBoneWeights, 3=aBoneIndicesF + // useBones=0 so locations 2,3 are never read; we alias them to existing data offsets + VkVertexInputBindingDescription vertBind{}; + vertBind.binding = 0; + vertBind.stride = 64; + vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + std::vector vertAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // aPos -> position + {1, 0, VK_FORMAT_R32G32_SFLOAT, 24}, // aTexCoord -> texCoord + {2, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 32}, // aBoneWeights (aliased to color, not used) + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 32}, // aBoneIndicesF (aliased to color, not used) + }; + + shadowPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({vertBind}, vertAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setDepthBias(0.05f, 0.20f) + .setNoColorAttachment() + .setLayout(shadowPipelineLayout_) + .setRenderPass(shadowRenderPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertShader.destroy(); + fragShader.destroy(); + + if (!shadowPipeline_) { + core::Logger::getInstance().error("WMORenderer: failed to create shadow pipeline"); + return false; + } + core::Logger::getInstance().info("WMORenderer shadow pipeline initialized"); + return true; +} + +void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, + const glm::vec3& shadowCenter, float shadowRadius) { + if (!shadowPipeline_ || !shadowParamsSet_) return; + if (instances.empty() || loadedModels.empty()) return; + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &shadowParamsSet_, 0, nullptr); + + struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; + + const float shadowRadiusSq = shadowRadius * shadowRadius; for (const auto& instance : instances) { + // Distance cull against shadow frustum + glm::vec3 diff = instance.position - shadowCenter; + if (glm::dot(diff, diff) > shadowRadiusSq) continue; auto modelIt = loadedModels.find(instance.modelId); if (modelIt == loadedModels.end()) continue; - if (frustumCulling) { - glm::vec3 instMin = instance.worldBoundsMin - glm::vec3(0.5f); - glm::vec3 instMax = instance.worldBoundsMax + glm::vec3(0.5f); - if (!frustum.intersectsAABB(instMin, instMax)) continue; - } const ModelData& model = modelIt->second; - shadowShader.setUniform("uModel", instance.modelMatrix); + + ShadowPush push{lightSpaceMatrix, instance.modelMatrix}; + vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, + 0, 128, &push); + for (const auto& group : model.groups) { - glBindVertexArray(group.vao); - glDrawElements(GL_TRIANGLES, group.indexCount, GL_UNSIGNED_SHORT, 0); - glBindVertexArray(0); + if (group.vertexBuffer == VK_NULL_HANDLE || group.indexBuffer == VK_NULL_HANDLE) continue; + + // Skip antiportal geometry + if (group.groupFlags & 0x4000000) continue; + + // Skip LOD groups in shadow pass (they overlap real geometry) + if (group.isLOD) continue; + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &group.vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, group.indexBuffer, 0, VK_INDEX_TYPE_UINT16); + + // Draw all batches in shadow pass. + // WMO transparency classification is not reliable enough for caster + // selection here and was dropping major world casters. + for (const auto& mb : group.mergedBatches) { + for (const auto& dr : mb.draws) { + vkCmdDrawIndexed(cmd, dr.indexCount, 1, dr.firstIndex, 0, 0); + } + } } } } @@ -1289,12 +1740,13 @@ bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupRes resources.boundingBoxMin = group.boundingBoxMin; resources.boundingBoxMax = group.boundingBoxMax; - // Create vertex data (position, normal, texcoord, color) + // Create vertex data (position, normal, texcoord, color, tangent) struct VertexData { glm::vec3 position; glm::vec3 normal; glm::vec2 texCoord; glm::vec4 color; + glm::vec4 tangent; // xyz=tangent dir, w=handedness Β±1 }; std::vector vertices; @@ -1306,48 +1758,73 @@ bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupRes vd.normal = v.normal; vd.texCoord = v.texCoord; vd.color = v.color; + vd.tangent = glm::vec4(0.0f); vertices.push_back(vd); } - // Create VAO/VBO/EBO - glGenVertexArrays(1, &resources.vao); - glGenBuffers(1, &resources.vbo); - glGenBuffers(1, &resources.ebo); + // Compute tangents using Lengyel's method + { + std::vector tan1(vertices.size(), glm::vec3(0.0f)); + std::vector tan2(vertices.size(), glm::vec3(0.0f)); - glBindVertexArray(resources.vao); + const auto& indices = group.indices; + for (size_t i = 0; i + 2 < indices.size(); i += 3) { + uint16_t i0 = indices[i], i1 = indices[i + 1], i2 = indices[i + 2]; + if (i0 >= vertices.size() || i1 >= vertices.size() || i2 >= vertices.size()) continue; - // Upload vertex data - glBindBuffer(GL_ARRAY_BUFFER, resources.vbo); - glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(VertexData), - vertices.data(), GL_STATIC_DRAW); + const glm::vec3& p0 = vertices[i0].position; + const glm::vec3& p1 = vertices[i1].position; + const glm::vec3& p2 = vertices[i2].position; + const glm::vec2& uv0 = vertices[i0].texCoord; + const glm::vec2& uv1 = vertices[i1].texCoord; + const glm::vec2& uv2 = vertices[i2].texCoord; - // Upload index data - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, resources.ebo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, group.indices.size() * sizeof(uint16_t), - group.indices.data(), GL_STATIC_DRAW); + glm::vec3 dp1 = p1 - p0; + glm::vec3 dp2 = p2 - p0; + glm::vec2 duv1 = uv1 - uv0; + glm::vec2 duv2 = uv2 - uv0; - // Vertex attributes - // Position - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(VertexData), - (void*)offsetof(VertexData, position)); + float det = duv1.x * duv2.y - duv1.y * duv2.x; + if (std::abs(det) < 1e-8f) continue; // degenerate UVs + float r = 1.0f / det; - // Normal - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(VertexData), - (void*)offsetof(VertexData, normal)); + glm::vec3 sdir = (dp1 * duv2.y - dp2 * duv1.y) * r; + glm::vec3 tdir = (dp2 * duv1.x - dp1 * duv2.x) * r; - // TexCoord - glEnableVertexAttribArray(2); - glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(VertexData), - (void*)offsetof(VertexData, texCoord)); + tan1[i0] += sdir; tan1[i1] += sdir; tan1[i2] += sdir; + tan2[i0] += tdir; tan2[i1] += tdir; tan2[i2] += tdir; + } - // Color - glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, sizeof(VertexData), - (void*)offsetof(VertexData, color)); + for (size_t i = 0; i < vertices.size(); i++) { + glm::vec3 n = glm::normalize(vertices[i].normal); + glm::vec3 t = tan1[i]; - glBindVertexArray(0); + if (glm::dot(t, t) < 1e-8f) { + // Fallback: generate tangent perpendicular to normal + glm::vec3 up = (std::abs(n.y) < 0.999f) ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); + t = glm::normalize(glm::cross(n, up)); + } + + // Gram-Schmidt orthogonalize + t = glm::normalize(t - n * glm::dot(n, t)); + float w = (glm::dot(glm::cross(n, t), tan2[i]) < 0.0f) ? -1.0f : 1.0f; + vertices[i].tangent = glm::vec4(t, w); + } + } + + // Upload vertex buffer to GPU + AllocatedBuffer vertBuf = uploadBuffer(*vkCtx_, vertices.data(), + vertices.size() * sizeof(VertexData), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + resources.vertexBuffer = vertBuf.buffer; + resources.vertexAlloc = vertBuf.allocation; + + // Upload index buffer to GPU + AllocatedBuffer idxBuf = uploadBuffer(*vkCtx_, group.indices.data(), + group.indices.size() * sizeof(uint16_t), + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + resources.indexBuffer = idxBuf.buffer; + resources.indexAlloc = idxBuf.allocation; // Store collision geometry for floor raycasting resources.collisionVertices.reserve(group.vertices.size()); @@ -1390,60 +1867,48 @@ bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupRes return true; } -void WMORenderer::renderGroup(const GroupResources& group, [[maybe_unused]] const ModelData& model, - [[maybe_unused]] const glm::mat4& modelMatrix, - [[maybe_unused]] const glm::mat4& view, - [[maybe_unused]] const glm::mat4& projection) { - glBindVertexArray(group.vao); +// renderGroup removed β€” draw calls are inlined in render() - // Set interior flag once per group (0x2000 = interior) - bool isInterior = (group.groupFlags & 0x2000) != 0; - shader->setUniform("uIsInterior", isInterior); +void WMORenderer::destroyGroupGPU(GroupResources& group) { + if (!vkCtx_) return; + VmaAllocator allocator = vkCtx_->getAllocator(); - // Use pre-computed merged batches (built at load time) - // Track state within this draw call only. - GLuint lastBoundTex = std::numeric_limits::max(); - bool lastHasTexture = false; - bool lastAlphaTest = false; - bool lastUnlit = false; - bool firstBatch = true; - - for (const auto& mb : group.mergedBatches) { - if (firstBatch || mb.texId != lastBoundTex) { - glBindTexture(GL_TEXTURE_2D, mb.texId); - lastBoundTex = mb.texId; - } - if (firstBatch || mb.hasTexture != lastHasTexture) { - shader->setUniform("uHasTexture", mb.hasTexture); - lastHasTexture = mb.hasTexture; - } - if (firstBatch || mb.alphaTest != lastAlphaTest) { - shader->setUniform("uAlphaTest", mb.alphaTest); - lastAlphaTest = mb.alphaTest; - } - if (firstBatch || mb.unlit != lastUnlit) { - shader->setUniform("uUnlit", mb.unlit); - lastUnlit = mb.unlit; - } - firstBatch = false; - - // Enable alpha blending for translucent materials (blendMode >= 2) - bool needsBlend = (mb.blendMode >= 2); - if (needsBlend) { - glEnable(GL_BLEND); - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); - } - - glMultiDrawElements(GL_TRIANGLES, mb.counts.data(), GL_UNSIGNED_SHORT, - mb.offsets.data(), static_cast(mb.counts.size())); - lastDrawCalls++; - - if (needsBlend) { - glDisable(GL_BLEND); - } + if (group.vertexBuffer) { + vmaDestroyBuffer(allocator, group.vertexBuffer, group.vertexAlloc); + group.vertexBuffer = VK_NULL_HANDLE; + group.vertexAlloc = VK_NULL_HANDLE; + } + if (group.indexBuffer) { + vmaDestroyBuffer(allocator, group.indexBuffer, group.indexAlloc); + group.indexBuffer = VK_NULL_HANDLE; + group.indexAlloc = VK_NULL_HANDLE; } - glBindVertexArray(0); + // Destroy material UBOs (descriptor sets are freed when pool is reset/destroyed) + for (auto& mb : group.mergedBatches) { + if (mb.materialUBO) { + vmaDestroyBuffer(allocator, mb.materialUBO, mb.materialUBOAlloc); + mb.materialUBO = VK_NULL_HANDLE; + mb.materialUBOAlloc = VK_NULL_HANDLE; + } + } +} + +VkDescriptorSet WMORenderer::allocateMaterialSet() { + if (!materialDescPool_ || !materialSetLayout_) return VK_NULL_HANDLE; + + VkDescriptorSetAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.descriptorPool = materialDescPool_; + allocInfo.descriptorSetCount = 1; + allocInfo.pSetLayouts = &materialSetLayout_; + + VkDescriptorSet set = VK_NULL_HANDLE; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &allocInfo, &set) != VK_SUCCESS) { + core::Logger::getInstance().warning("WMORenderer: failed to allocate material descriptor set"); + return VK_NULL_HANDLE; + } + return set; } bool WMORenderer::isGroupVisible(const GroupResources& group, const glm::mat4& modelMatrix, @@ -1607,9 +2072,95 @@ void WMORenderer::WMOInstance::updateModelMatrix() { invModelMatrix = glm::inverse(modelMatrix); } -GLuint WMORenderer::loadTexture(const std::string& path) { - if (!assetManager) { - return whiteTexture; +std::unique_ptr WMORenderer::generateNormalHeightMap( + const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance) { + if (!vkCtx_ || width == 0 || height == 0) return nullptr; + + const uint32_t totalPixels = width * height; + + // Step 1: Compute height from luminance + std::vector heightMap(totalPixels); + double sumH = 0.0, sumH2 = 0.0; + for (uint32_t i = 0; i < totalPixels; i++) { + float r = pixels[i * 4 + 0] / 255.0f; + float g = pixels[i * 4 + 1] / 255.0f; + float b = pixels[i * 4 + 2] / 255.0f; + float h = 0.299f * r + 0.587f * g + 0.114f * b; + heightMap[i] = h; + sumH += h; + sumH2 += h * h; + } + double mean = sumH / totalPixels; + outVariance = static_cast(sumH2 / totalPixels - mean * mean); + + // Step 1.5: Box blur the height map to reduce noise from diffuse textures + auto wrapSample = [&](const std::vector& map, int x, int y) -> float { + x = ((x % (int)width) + (int)width) % (int)width; + y = ((y % (int)height) + (int)height) % (int)height; + return map[y * width + x]; + }; + + std::vector blurredHeight(totalPixels); + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + int ix = static_cast(x), iy = static_cast(y); + float sum = 0.0f; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + sum += wrapSample(heightMap, ix + dx, iy + dy); + blurredHeight[y * width + x] = sum / 9.0f; + } + } + + // Step 2: Sobel 3x3 β†’ normal map + // Use ORIGINAL height for normals (crisp detail), blurred height for POM alpha only + const float strength = 2.0f; + std::vector output(totalPixels * 4); + + auto sampleH = [&](int x, int y) -> float { + x = ((x % (int)width) + (int)width) % (int)width; + y = ((y % (int)height) + (int)height) % (int)height; + return heightMap[y * width + x]; + }; + + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + int ix = static_cast(x); + int iy = static_cast(y); + // Sobel X + float gx = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix-1, iy) - sampleH(ix-1, iy+1) + + sampleH(ix+1, iy-1) + 2.0f*sampleH(ix+1, iy) + sampleH(ix+1, iy+1); + // Sobel Y + float gy = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix, iy-1) - sampleH(ix+1, iy-1) + + sampleH(ix-1, iy+1) + 2.0f*sampleH(ix, iy+1) + sampleH(ix+1, iy+1); + + float nx = -gx * strength; + float ny = -gy * strength; + float nz = 1.0f; + float len = std::sqrt(nx*nx + ny*ny + nz*nz); + if (len > 0.0f) { nx /= len; ny /= len; nz /= len; } + + uint32_t idx = (y * width + x) * 4; + output[idx + 0] = static_cast(std::clamp((nx * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); + output[idx + 1] = static_cast(std::clamp((ny * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); + output[idx + 2] = static_cast(std::clamp((nz * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); + output[idx + 3] = static_cast(std::clamp(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f)); + } + } + + // Step 3: Upload to GPU with mipmaps + auto tex = std::make_unique(); + if (!tex->upload(*vkCtx_, output.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true)) { + return nullptr; + } + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + return tex; +} + +VkTexture* WMORenderer::loadTexture(const std::string& path) { + if (!assetManager || !vkCtx_) { + return whiteTexture_.get(); } auto normalizeKey = [](std::string key) { @@ -1625,7 +2176,7 @@ GLuint WMORenderer::loadTexture(const std::string& path) { key = normalizeKey(key); if (key.rfind(".\\", 0) == 0) key = key.substr(2); while (!key.empty() && key.front() == '\\') key.erase(key.begin()); - if (key.empty()) return whiteTexture; + if (key.empty()) return whiteTexture_.get(); auto hasKnownExt = [](const std::string& p) { if (p.size() < 4) return false; @@ -1679,14 +2230,16 @@ GLuint WMORenderer::loadTexture(const std::string& path) { auto it = textureCache.find(c); if (it != textureCache.end()) { it->second.lastUse = ++textureCacheCounter_; - return it->second.id; + return it->second.texture.get(); } } + const auto& attemptedCandidates = uniqueCandidates; + // Try loading all candidates until one succeeds pipeline::BLPImage blp; std::string resolvedKey; - for (const auto& c : uniqueCandidates) { + for (const auto& c : attemptedCandidates) { blp = assetManager->loadTexture(c); if (blp.isValid()) { resolvedKey = c; @@ -1694,50 +2247,67 @@ GLuint WMORenderer::loadTexture(const std::string& path) { } } if (!blp.isValid()) { - core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); + if (loggedTextureLoadFails_.insert(key).second) { + core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); + } // Do not cache failures as white. MPQ reads can fail transiently // during streaming/contention, and caching white here permanently // poisons the texture for this session. - return whiteTexture; + return whiteTexture_.get(); } core::Logger::getInstance().debug("WMO texture: ", path, " size=", blp.width, "x", blp.height); - // Create OpenGL texture - GLuint textureID; - glGenTextures(1, &textureID); - glBindTexture(GL_TEXTURE_2D, textureID); + size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; + size_t approxBytes = base + (base / 3); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + if (textureBudgetRejectWarnings_ < 3) { + core::Logger::getInstance().warning( + "WMO texture cache full (", textureCacheBytes_ / (1024 * 1024), + " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), + " MB), rejecting texture: ", path); + } + ++textureBudgetRejectWarnings_; + return whiteTexture_.get(); + } - // Upload texture data (BLP loader outputs RGBA8) - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, - blp.width, blp.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, blp.data.data()); + // Create Vulkan texture + auto texture = std::make_unique(); + if (!texture->upload(*vkCtx_, blp.data.data(), blp.width, blp.height, + VK_FORMAT_R8G8B8A8_UNORM, true)) { + core::Logger::getInstance().warning("WMO: Failed to upload texture to GPU: ", path); + return whiteTexture_.get(); + } + texture->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); - // Set texture parameters with mipmaps - glGenerateMipmap(GL_TEXTURE_2D); - applyAnisotropicFiltering(); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); - - glBindTexture(GL_TEXTURE_2D, 0); + // Generate normal+height map from diffuse pixels + float nhVariance = 0.0f; + std::unique_ptr nhMap; + if (normalMappingEnabled_ || pomEnabled_) { + nhMap = generateNormalHeightMap(blp.data.data(), blp.width, blp.height, nhVariance); + if (nhMap) { + approxBytes *= 2; // account for normal map in budget + } + } // Cache it TextureCacheEntry e; - e.id = textureID; - size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; - e.approxBytes = base + (base / 3); + VkTexture* rawPtr = texture.get(); + e.approxBytes = approxBytes; e.lastUse = ++textureCacheCounter_; + e.texture = std::move(texture); + e.normalHeightMap = std::move(nhMap); + e.heightMapVariance = nhVariance; textureCacheBytes_ += e.approxBytes; if (!resolvedKey.empty()) { - textureCache[resolvedKey] = e; + textureCache[resolvedKey] = std::move(e); } else { - textureCache[key] = e; + textureCache[key] = std::move(e); } core::Logger::getInstance().debug("WMO: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); - return textureID; + return rawPtr; } // Ray-AABB intersection (slab method) @@ -2710,122 +3280,114 @@ float WMORenderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3 return closestHit; } -void WMORenderer::initOcclusionResources() { - // Simple vertex shader for bounding box rendering - const char* occVertSrc = R"( - #version 330 core - layout(location = 0) in vec3 aPos; - uniform mat4 uMVP; - void main() { - gl_Position = uMVP * vec4(aPos, 1.0); - } - )"; +// Occlusion queries stubbed out in Vulkan (were disabled by default anyway) - // Fragment shader that writes nothing (depth-only) - const char* occFragSrc = R"( - #version 330 core - void main() { - // No color output - depth only - } - )"; +void WMORenderer::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); - occlusionShader = std::make_unique(); - if (!occlusionShader->loadFromSource(occVertSrc, occFragSrc)) { - core::Logger::getInstance().warning("Failed to create occlusion shader"); - occlusionCulling = false; + // Destroy old main-pass pipelines (NOT shadow, NOT pipeline layout) + if (opaquePipeline_) { vkDestroyPipeline(device, opaquePipeline_, nullptr); opaquePipeline_ = VK_NULL_HANDLE; } + if (transparentPipeline_) { vkDestroyPipeline(device, transparentPipeline_, nullptr); transparentPipeline_ = VK_NULL_HANDLE; } + if (glassPipeline_) { vkDestroyPipeline(device, glassPipeline_, nullptr); glassPipeline_ = VK_NULL_HANDLE; } + if (wireframePipeline_) { vkDestroyPipeline(device, wireframePipeline_, nullptr); wireframePipeline_ = VK_NULL_HANDLE; } + + // --- Load shaders --- + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/wmo.vert.spv") || + !fragShader.loadFromFile(device, "assets/shaders/wmo.frag.spv")) { + core::Logger::getInstance().error("WMORenderer::recreatePipelines: failed to load shaders"); return; } - // Create unit cube vertices (will be scaled to group bounds) - float cubeVerts[] = { - // Front face - 0,0,1, 1,0,1, 1,1,1, 0,0,1, 1,1,1, 0,1,1, - // Back face - 1,0,0, 0,0,0, 0,1,0, 1,0,0, 0,1,0, 1,1,0, - // Left face - 0,0,0, 0,0,1, 0,1,1, 0,0,0, 0,1,1, 0,1,0, - // Right face - 1,0,1, 1,0,0, 1,1,0, 1,0,1, 1,1,0, 1,1,1, - // Top face - 0,1,1, 1,1,1, 1,1,0, 0,1,1, 1,1,0, 0,1,0, - // Bottom face - 0,0,0, 1,0,0, 1,0,1, 0,0,0, 1,0,1, 0,0,1, + // --- Vertex input --- + struct WMOVertexData { + glm::vec3 position; + glm::vec3 normal; + glm::vec2 texCoord; + glm::vec4 color; + glm::vec4 tangent; }; - glGenVertexArrays(1, &bboxVao); - glGenBuffers(1, &bboxVbo); + VkVertexInputBindingDescription vertexBinding{}; + vertexBinding.binding = 0; + vertexBinding.stride = sizeof(WMOVertexData); + vertexBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - glBindVertexArray(bboxVao); - glBindBuffer(GL_ARRAY_BUFFER, bboxVbo); - glBufferData(GL_ARRAY_BUFFER, sizeof(cubeVerts), cubeVerts, GL_STATIC_DRAW); + std::vector vertexAttribs(5); + vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(WMOVertexData, position)) }; + vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, + static_cast(offsetof(WMOVertexData, normal)) }; + vertexAttribs[2] = { 2, 0, VK_FORMAT_R32G32_SFLOAT, + static_cast(offsetof(WMOVertexData, texCoord)) }; + vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, + static_cast(offsetof(WMOVertexData, color)) }; + vertexAttribs[4] = { 4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, + static_cast(offsetof(WMOVertexData, tangent)) }; - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0); + VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); - glBindVertexArray(0); + opaquePipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); - core::Logger::getInstance().info("Occlusion query resources initialized"); -} + transparentPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); -void WMORenderer::runOcclusionQueries(const WMOInstance& instance, const ModelData& model, - const glm::mat4& view, const glm::mat4& projection) { - if (!occlusionShader || bboxVao == 0) return; + glassPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); - occlusionShader->use(); - glBindVertexArray(bboxVao); + wireframePipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ vertexBinding }, vertexAttribs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_LINE, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); - // Disable color writes, keep depth test - glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); - glDepthMask(GL_FALSE); // Don't write depth + vertShader.destroy(); + fragShader.destroy(); - for (size_t gi = 0; gi < model.groups.size(); ++gi) { - const auto& group = model.groups[gi]; - - // Create query key - uint32_t queryKey = (instance.id << 16) | static_cast(gi); - - // Get or create query object - GLuint query; - auto it = occlusionQueries.find(queryKey); - if (it == occlusionQueries.end()) { - glGenQueries(1, &query); - occlusionQueries[queryKey] = query; - } else { - query = it->second; - } - - // Compute MVP for this group's bounding box - glm::vec3 bboxSize = group.boundingBoxMax - group.boundingBoxMin; - glm::mat4 bboxModel = instance.modelMatrix; - bboxModel = glm::translate(bboxModel, group.boundingBoxMin); - bboxModel = glm::scale(bboxModel, bboxSize); - glm::mat4 mvp = projection * view * bboxModel; - - occlusionShader->setUniform("uMVP", mvp); - - // Run occlusion query - glBeginQuery(GL_ANY_SAMPLES_PASSED, query); - glDrawArrays(GL_TRIANGLES, 0, 36); - glEndQuery(GL_ANY_SAMPLES_PASSED); - } - - // Restore state - glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); - glDepthMask(GL_TRUE); - glBindVertexArray(0); -} - -bool WMORenderer::isGroupOccluded(uint32_t instanceId, uint32_t groupIndex) const { - uint32_t queryKey = (instanceId << 16) | groupIndex; - - // Check previous frame's result - auto resultIt = occlusionResults.find(queryKey); - if (resultIt != occlusionResults.end()) { - return !resultIt->second; // Return true if NOT visible - } - - // No result yet - assume visible - return false; + core::Logger::getInstance().info("WMORenderer: pipelines recreated"); } } // namespace rendering diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 9afc30e9..15aed5a7 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -1,11 +1,15 @@ #include "rendering/world_map.hpp" -#include "rendering/shader.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_render_target.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_layout.hpp" #include "core/coordinates.hpp" #include "core/input.hpp" #include "core/logger.hpp" -#include #include #include #include @@ -35,44 +39,195 @@ bool isLeafContinent(const std::vector& zones, int idx) { } } // namespace +// Push constant for world map tile composite vertex shader +struct WorldMapTilePush { + glm::vec2 gridOffset; // 8 bytes + float gridCols; // 4 bytes + float gridRows; // 4 bytes +}; // 16 bytes + WorldMap::WorldMap() = default; WorldMap::~WorldMap() { - if (fbo) glDeleteFramebuffers(1, &fbo); - if (fboTexture) glDeleteTextures(1, &fboTexture); - if (tileQuadVAO) glDeleteVertexArrays(1, &tileQuadVAO); - if (tileQuadVBO) glDeleteBuffers(1, &tileQuadVBO); - for (auto& zone : zones) { - for (auto& tex : zone.tileTextures) { - if (tex) glDeleteTextures(1, &tex); - } - } - tileShader.reset(); + shutdown(); } -void WorldMap::initialize(pipeline::AssetManager* am) { - if (initialized) return; +bool WorldMap::initialize(VkContext* ctx, pipeline::AssetManager* am) { + if (initialized) return true; + vkCtx = ctx; assetManager = am; - createFBO(); - createTileShader(); - createQuad(); + VkDevice device = vkCtx->getDevice(); + + // --- Composite render target (1024x768) --- + compositeTarget = std::make_unique(); + if (!compositeTarget->create(*vkCtx, FBO_W, FBO_H)) { + LOG_ERROR("WorldMap: failed to create composite render target"); + return false; + } + + // --- Quad vertex buffer (unit quad: pos2 + uv2) --- + float quadVerts[] = { + 0.0f, 0.0f, 0.0f, 0.0f, + 1.0f, 0.0f, 1.0f, 0.0f, + 1.0f, 1.0f, 1.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 0.0f, + 1.0f, 1.0f, 1.0f, 1.0f, + 0.0f, 1.0f, 0.0f, 1.0f, + }; + auto quadBuf = uploadBuffer(*vkCtx, quadVerts, sizeof(quadVerts), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + quadVB = quadBuf.buffer; + quadVBAlloc = quadBuf.allocation; + + // --- Descriptor set layout: 1 combined image sampler at binding 0 --- + VkDescriptorSetLayoutBinding samplerBinding{}; + samplerBinding.binding = 0; + samplerBinding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + samplerBinding.descriptorCount = 1; + samplerBinding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + samplerSetLayout = createDescriptorSetLayout(device, { samplerBinding }); + + // --- Descriptor pool (24 tile + 1 display = 25) --- + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = MAX_DESC_SETS; + + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = MAX_DESC_SETS; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + vkCreateDescriptorPool(device, &poolInfo, nullptr, &descPool); + + // --- Allocate descriptor sets: 12*2 tile + 1 display = 25 --- + constexpr uint32_t totalSets = 25; + std::vector layouts(totalSets, samplerSetLayout); + VkDescriptorSetAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocInfo.descriptorPool = descPool; + allocInfo.descriptorSetCount = totalSets; + allocInfo.pSetLayouts = layouts.data(); + + VkDescriptorSet allSets[25]; + vkAllocateDescriptorSets(device, &allocInfo, allSets); + + for (int f = 0; f < 2; f++) + for (int t = 0; t < 12; t++) + tileDescSets[f][t] = allSets[f * 12 + t]; + imguiDisplaySet = allSets[24]; + + // --- Write display descriptor set β†’ composite render target --- + VkDescriptorImageInfo compositeImgInfo = compositeTarget->descriptorInfo(); + VkWriteDescriptorSet displayWrite{}; + displayWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + displayWrite.dstSet = imguiDisplaySet; + displayWrite.dstBinding = 0; + displayWrite.descriptorCount = 1; + displayWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + displayWrite.pImageInfo = &compositeImgInfo; + vkUpdateDescriptorSets(device, 1, &displayWrite, 0, nullptr); + + // --- Pipeline layout: samplerSetLayout + push constant (16 bytes, vertex) --- + VkPushConstantRange tilePush{}; + tilePush.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + tilePush.offset = 0; + tilePush.size = sizeof(WorldMapTilePush); + tilePipelineLayout = createPipelineLayout(device, { samplerSetLayout }, { tilePush }); + + // --- Vertex input: pos2 (loc 0) + uv2 (loc 1), stride 16 --- + VkVertexInputBindingDescription binding{}; + binding.binding = 0; + binding.stride = 4 * sizeof(float); + binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector attrs(2); + attrs[0] = { 0, 0, VK_FORMAT_R32G32_SFLOAT, 0 }; + attrs[1] = { 1, 0, VK_FORMAT_R32G32_SFLOAT, 2 * sizeof(float) }; + + // --- Load tile shaders and build pipeline --- + { + VkShaderModule vs, fs; + if (!vs.loadFromFile(device, "assets/shaders/world_map.vert.spv") || + !fs.loadFromFile(device, "assets/shaders/world_map.frag.spv")) { + LOG_ERROR("WorldMap: failed to load tile shaders"); + return false; + } + + tilePipeline = PipelineBuilder() + .setShaders(vs.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fs.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({ binding }, attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setLayout(tilePipelineLayout) + .setRenderPass(compositeTarget->getRenderPass()) + .setDynamicStates({ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }) + .build(device); + + vs.destroy(); + fs.destroy(); + } + + if (!tilePipeline) { + LOG_ERROR("WorldMap: failed to create tile pipeline"); + return false; + } + initialized = true; - LOG_INFO("WorldMap initialized (", FBO_W, "x", FBO_H, " FBO)"); + LOG_INFO("WorldMap initialized (", FBO_W, "x", FBO_H, " composite)"); + return true; +} + +void WorldMap::shutdown() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + + vkDeviceWaitIdle(device); + + if (tilePipeline) { vkDestroyPipeline(device, tilePipeline, nullptr); tilePipeline = VK_NULL_HANDLE; } + if (tilePipelineLayout) { vkDestroyPipelineLayout(device, tilePipelineLayout, nullptr); tilePipelineLayout = VK_NULL_HANDLE; } + if (descPool) { vkDestroyDescriptorPool(device, descPool, nullptr); descPool = VK_NULL_HANDLE; } + if (samplerSetLayout) { vkDestroyDescriptorSetLayout(device, samplerSetLayout, nullptr); samplerSetLayout = VK_NULL_HANDLE; } + if (quadVB) { vmaDestroyBuffer(alloc, quadVB, quadVBAlloc); quadVB = VK_NULL_HANDLE; } + + destroyZoneTextures(); + + if (compositeTarget) { compositeTarget->destroy(device, alloc); compositeTarget.reset(); } + + zones.clear(); + initialized = false; + vkCtx = nullptr; +} + +void WorldMap::destroyZoneTextures() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + + for (auto& tex : zoneTextures) { + if (tex) tex->destroy(device, alloc); + } + zoneTextures.clear(); + + for (auto& zone : zones) { + for (auto& tex : zone.tileTextures) tex = nullptr; + zone.tilesLoaded = false; + } } void WorldMap::setMapName(const std::string& name) { if (mapName == name && !zones.empty()) return; mapName = name; - // Clear old zone data - for (auto& zone : zones) { - for (auto& tex : zone.tileTextures) { - if (tex) { glDeleteTextures(1, &tex); tex = 0; } - } - } + + destroyZoneTextures(); zones.clear(); continentIdx = -1; currentIdx = -1; compositedIdx = -1; + pendingCompositeIdx = -1; viewLevel = ViewLevel::WORLD; } @@ -82,107 +237,17 @@ void WorldMap::setServerExplorationMask(const std::vector& masks, bool serverExplorationMask.clear(); return; } - hasServerExplorationMask = true; serverExplorationMask = masks; } // -------------------------------------------------------- -// GL resource creation -// -------------------------------------------------------- - -void WorldMap::createFBO() { - glGenFramebuffers(1, &fbo); - glBindFramebuffer(GL_FRAMEBUFFER, fbo); - - glGenTextures(1, &fboTexture); - glBindTexture(GL_TEXTURE_2D, fboTexture); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, FBO_W, FBO_H, 0, - GL_RGBA, GL_UNSIGNED_BYTE, nullptr); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, fboTexture, 0); - - if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) { - LOG_ERROR("WorldMap FBO incomplete"); - } - glBindFramebuffer(GL_FRAMEBUFFER, 0); -} - -void WorldMap::createTileShader() { - const char* vertSrc = R"( - #version 330 core - layout (location = 0) in vec2 aPos; - layout (location = 1) in vec2 aUV; - - uniform vec2 uGridOffset; // (col, row) in grid - uniform float uGridCols; - uniform float uGridRows; - - out vec2 TexCoord; - - void main() { - vec2 gridPos = vec2( - (uGridOffset.x + aPos.x) / uGridCols, - (uGridOffset.y + aPos.y) / uGridRows - ); - gl_Position = vec4(gridPos * 2.0 - 1.0, 0.0, 1.0); - TexCoord = aUV; - } - )"; - - const char* fragSrc = R"( - #version 330 core - in vec2 TexCoord; - - uniform sampler2D uTileTexture; - - out vec4 FragColor; - - void main() { - FragColor = texture(uTileTexture, TexCoord); - } - )"; - - tileShader = std::make_unique(); - if (!tileShader->loadFromSource(vertSrc, fragSrc)) { - LOG_ERROR("Failed to create WorldMap tile shader"); - } -} - -void WorldMap::createQuad() { - float quadVerts[] = { - // pos (x,y), uv (u,v) - 0.0f, 0.0f, 0.0f, 0.0f, - 1.0f, 0.0f, 1.0f, 0.0f, - 1.0f, 1.0f, 1.0f, 1.0f, - 0.0f, 0.0f, 0.0f, 0.0f, - 1.0f, 1.0f, 1.0f, 1.0f, - 0.0f, 1.0f, 0.0f, 1.0f, - }; - - glGenVertexArrays(1, &tileQuadVAO); - glGenBuffers(1, &tileQuadVBO); - glBindVertexArray(tileQuadVAO); - glBindBuffer(GL_ARRAY_BUFFER, tileQuadVBO); - glBufferData(GL_ARRAY_BUFFER, sizeof(quadVerts), quadVerts, GL_STATIC_DRAW); - glEnableVertexAttribArray(0); - glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); - glEnableVertexAttribArray(1); - glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); - glBindVertexArray(0); -} - -// -------------------------------------------------------- -// DBC zone loading +// DBC zone loading (identical to GL version) // -------------------------------------------------------- void WorldMap::loadZonesFromDBC() { if (!zones.empty() || !assetManager) return; - // Step 1: Resolve mapID from Map.dbc const auto* activeLayout = pipeline::getActiveDBCLayout(); const auto* mapL = activeLayout ? activeLayout->getLayout("Map") : nullptr; @@ -210,7 +275,6 @@ void WorldMap::loadZonesFromDBC() { } } - // Step 2: Load AreaTable explore flags by areaID. const auto* atL = activeLayout ? activeLayout->getLayout("AreaTable") : nullptr; std::unordered_map exploreFlagByAreaId; auto areaDbc = assetManager->loadDBC("AreaTable.dbc"); @@ -218,29 +282,16 @@ void WorldMap::loadZonesFromDBC() { for (uint32_t i = 0; i < areaDbc->getRecordCount(); i++) { const uint32_t areaId = areaDbc->getUInt32(i, atL ? (*atL)["ID"] : 0); const uint32_t exploreFlag = areaDbc->getUInt32(i, atL ? (*atL)["ExploreFlag"] : 3); - if (areaId != 0) { - exploreFlagByAreaId[areaId] = exploreFlag; - } + if (areaId != 0) exploreFlagByAreaId[areaId] = exploreFlag; } - } else { - LOG_WARNING("WorldMap: AreaTable.dbc missing or unexpected format; server exploration may be incomplete"); } - // Step 3: Load ALL WorldMapArea records for this mapID auto wmaDbc = assetManager->loadDBC("WorldMapArea.dbc"); if (!wmaDbc || !wmaDbc->isLoaded()) { LOG_WARNING("WorldMap: WorldMapArea.dbc not found"); return; } - LOG_INFO("WorldMap: WorldMapArea.dbc has ", wmaDbc->getFieldCount(), - " fields, ", wmaDbc->getRecordCount(), " records"); - - // WorldMapArea.dbc layout (11 fields, no localized strings): - // 0: ID, 1: MapID, 2: AreaID, 3: AreaName (stringref) - // 4: locLeft, 5: locRight, 6: locTop, 7: locBottom - // 8: displayMapID, 9: defaultDungeonFloor, 10: parentWorldMapID - const auto* wmaL = activeLayout ? activeLayout->getLayout("WorldMapArea") : nullptr; for (uint32_t i = 0; i < wmaDbc->getRecordCount(); i++) { @@ -258,60 +309,42 @@ void WorldMap::loadZonesFromDBC() { zone.displayMapID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["DisplayMapID"] : 8); zone.parentWorldMapID = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["ParentWorldMapID"] : 10); auto exploreIt = exploreFlagByAreaId.find(zone.areaID); - if (exploreIt != exploreFlagByAreaId.end()) { + if (exploreIt != exploreFlagByAreaId.end()) zone.exploreFlag = exploreIt->second; - } int idx = static_cast(zones.size()); - // Debug: also log raw uint32 values for bounds fields - uint32_t raw4 = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["LocLeft"] : 4); - uint32_t raw5 = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["LocRight"] : 5); - uint32_t raw6 = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["LocTop"] : 6); - uint32_t raw7 = wmaDbc->getUInt32(i, wmaL ? (*wmaL)["LocBottom"] : 7); - LOG_INFO("WorldMap: zone[", idx, "] areaID=", zone.areaID, " '", zone.areaName, "' L=", zone.locLeft, " R=", zone.locRight, " T=", zone.locTop, - " B=", zone.locBottom, - " (raw4=", raw4, " raw5=", raw5, - " raw6=", raw6, " raw7=", raw7, ")"); + " B=", zone.locBottom); - if (zone.areaID == 0 && continentIdx < 0) { + if (zone.areaID == 0 && continentIdx < 0) continentIdx = idx; - } zones.push_back(std::move(zone)); } - // For each continent entry with missing bounds, derive bounds from its child zones only. + // Derive continent bounds from child zones if missing for (int ci = 0; ci < static_cast(zones.size()); ci++) { auto& cont = zones[ci]; if (cont.areaID != 0) continue; - if (std::abs(cont.locLeft) > 0.001f || std::abs(cont.locRight) > 0.001f || - std::abs(cont.locTop) > 0.001f || std::abs(cont.locBottom) > 0.001f) { + std::abs(cont.locTop) > 0.001f || std::abs(cont.locBottom) > 0.001f) continue; - } bool first = true; for (const auto& z : zones) { if (z.areaID == 0) continue; if (std::abs(z.locLeft - z.locRight) < 0.001f || - std::abs(z.locTop - z.locBottom) < 0.001f) { + std::abs(z.locTop - z.locBottom) < 0.001f) continue; - } - - // Prefer explicit parent linkage when deriving continent extents. - if (z.parentWorldMapID != 0 && cont.wmaID != 0 && z.parentWorldMapID != cont.wmaID) { + if (z.parentWorldMapID != 0 && cont.wmaID != 0 && z.parentWorldMapID != cont.wmaID) continue; - } if (first) { - cont.locLeft = z.locLeft; - cont.locRight = z.locRight; - cont.locTop = z.locTop; - cont.locBottom = z.locBottom; + cont.locLeft = z.locLeft; cont.locRight = z.locRight; + cont.locTop = z.locTop; cont.locBottom = z.locBottom; first = false; } else { cont.locLeft = std::max(cont.locLeft, z.locLeft); @@ -320,11 +353,6 @@ void WorldMap::loadZonesFromDBC() { cont.locBottom = std::min(cont.locBottom, z.locBottom); } } - - if (!first) { - LOG_INFO("WorldMap: computed bounds for continent '", cont.areaName, "': L=", cont.locLeft, - " R=", cont.locRight, " T=", cont.locTop, " B=", cont.locBottom); - } } LOG_INFO("WorldMap: loaded ", zones.size(), " zones for mapID=", mapID, @@ -332,8 +360,8 @@ void WorldMap::loadZonesFromDBC() { } int WorldMap::findBestContinentForPlayer(const glm::vec3& playerRenderPos) const { - float wowX = playerRenderPos.y; // north/south - float wowY = playerRenderPos.x; // west/east + float wowX = playerRenderPos.y; + float wowY = playerRenderPos.x; int bestIdx = -1; float bestArea = std::numeric_limits::max(); @@ -363,56 +391,39 @@ int WorldMap::findBestContinentForPlayer(const glm::vec3& playerRenderPos) const bool contains = (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY); float area = spanX * spanY; if (contains) { - if (area < bestArea) { - bestArea = area; - bestIdx = i; - } + if (area < bestArea) { bestArea = area; bestIdx = i; } } else if (bestIdx < 0) { - // Fallback if player isn't inside any continent bounds: nearest center. - float cx = (minX + maxX) * 0.5f; - float cy = (minY + maxY) * 0.5f; - float dx = wowX - cx; - float dy = wowY - cy; - float dist2 = dx * dx + dy * dy; - if (dist2 < bestCenterDist2) { - bestCenterDist2 = dist2; - bestIdx = i; - } + float cx = (minX + maxX) * 0.5f, cy = (minY + maxY) * 0.5f; + float dist2 = (wowX - cx) * (wowX - cx) + (wowY - cy) * (wowY - cy); + if (dist2 < bestCenterDist2) { bestCenterDist2 = dist2; bestIdx = i; } } } - return bestIdx; } int WorldMap::findZoneForPlayer(const glm::vec3& playerRenderPos) const { - float wowX = playerRenderPos.y; // north/south - float wowY = playerRenderPos.x; // west/east + float wowX = playerRenderPos.y; + float wowY = playerRenderPos.x; int bestIdx = -1; float bestArea = std::numeric_limits::max(); for (int i = 0; i < static_cast(zones.size()); i++) { const auto& z = zones[i]; - if (z.areaID == 0) continue; // skip continent-level entries + if (z.areaID == 0) continue; float minX = std::min(z.locLeft, z.locRight); float maxX = std::max(z.locLeft, z.locRight); float minY = std::min(z.locTop, z.locBottom); float maxY = std::max(z.locTop, z.locBottom); - float spanX = maxX - minX; - float spanY = maxY - minY; + float spanX = maxX - minX, spanY = maxY - minY; if (spanX < 0.001f || spanY < 0.001f) continue; - bool contains = (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY); - if (contains) { + if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) { float area = spanX * spanY; - if (area < bestArea) { - bestArea = area; - bestIdx = i; - } + if (area < bestArea) { bestArea = area; bestIdx = i; } } } - return bestIdx; } @@ -422,13 +433,10 @@ bool WorldMap::zoneBelongsToContinent(int zoneIdx, int contIdx) const { const auto& z = zones[zoneIdx]; const auto& cont = zones[contIdx]; - if (z.areaID == 0) return false; - // Prefer explicit parent linkage from WorldMapArea.dbc. - if (z.parentWorldMapID != 0 && cont.wmaID != 0) { + if (z.parentWorldMapID != 0 && cont.wmaID != 0) return z.parentWorldMapID == cont.wmaID; - } auto rectMinX = [](const WorldMapZone& a) { return std::min(a.locLeft, a.locRight); }; auto rectMaxX = [](const WorldMapZone& a) { return std::max(a.locLeft, a.locRight); }; @@ -439,13 +447,11 @@ bool WorldMap::zoneBelongsToContinent(int zoneIdx, int contIdx) const { float zMinY = rectMinY(z), zMaxY = rectMaxY(z); if ((zMaxX - zMinX) < 0.001f || (zMaxY - zMinY) < 0.001f) return false; - // Fallback: assign zone to the continent with highest overlap area. int bestContIdx = -1; float bestOverlap = 0.0f; for (int i = 0; i < static_cast(zones.size()); i++) { const auto& c = zones[i]; if (c.areaID != 0) continue; - float cMinX = rectMinX(c), cMaxX = rectMaxX(c); float cMinY = rectMinY(c), cMaxY = rectMaxY(c); if ((cMaxX - cMinX) < 0.001f || (cMaxY - cMinY) < 0.001f) continue; @@ -453,55 +459,35 @@ bool WorldMap::zoneBelongsToContinent(int zoneIdx, int contIdx) const { float ox = std::max(0.0f, std::min(zMaxX, cMaxX) - std::max(zMinX, cMinX)); float oy = std::max(0.0f, std::min(zMaxY, cMaxY) - std::max(zMinY, cMinY)); float overlap = ox * oy; - if (overlap > bestOverlap) { - bestOverlap = overlap; - bestContIdx = i; - } + if (overlap > bestOverlap) { bestOverlap = overlap; bestContIdx = i; } } + if (bestContIdx >= 0) return bestContIdx == contIdx; - if (bestContIdx >= 0) { - return bestContIdx == contIdx; - } - - // Last resort: center-point containment. float centerX = (z.locLeft + z.locRight) * 0.5f; float centerY = (z.locTop + z.locBottom) * 0.5f; - float cMinX = rectMinX(cont), cMaxX = rectMaxX(cont); - float cMinY = rectMinY(cont), cMaxY = rectMaxY(cont); - return centerX >= cMinX && centerX <= cMaxX && - centerY >= cMinY && centerY <= cMaxY; + return centerX >= rectMinX(cont) && centerX <= rectMaxX(cont) && + centerY >= rectMinY(cont) && centerY <= rectMaxY(cont); } bool WorldMap::getContinentProjectionBounds(int contIdx, float& left, float& right, float& top, float& bottom) const { if (contIdx < 0 || contIdx >= static_cast(zones.size())) return false; - const auto& cont = zones[contIdx]; if (cont.areaID != 0) return false; - // Prefer authored continent bounds from DBC when available. if (std::abs(cont.locLeft - cont.locRight) > 0.001f && std::abs(cont.locTop - cont.locBottom) > 0.001f) { - left = cont.locLeft; - right = cont.locRight; - top = cont.locTop; - bottom = cont.locBottom; + left = cont.locLeft; right = cont.locRight; + top = cont.locTop; bottom = cont.locBottom; return true; } - std::vector northEdges; - std::vector southEdges; - std::vector westEdges; - std::vector eastEdges; - + std::vector northEdges, southEdges, westEdges, eastEdges; for (int zi = 0; zi < static_cast(zones.size()); zi++) { if (!zoneBelongsToContinent(zi, contIdx)) continue; const auto& z = zones[zi]; if (std::abs(z.locLeft - z.locRight) < 0.001f || - std::abs(z.locTop - z.locBottom) < 0.001f) { - continue; - } - + std::abs(z.locTop - z.locBottom) < 0.001f) continue; northEdges.push_back(std::max(z.locLeft, z.locRight)); southEdges.push_back(std::min(z.locLeft, z.locRight)); westEdges.push_back(std::max(z.locTop, z.locBottom)); @@ -509,31 +495,25 @@ bool WorldMap::getContinentProjectionBounds(int contIdx, float& left, float& rig } if (northEdges.size() < 3) { - left = cont.locLeft; - right = cont.locRight; - top = cont.locTop; - bottom = cont.locBottom; + left = cont.locLeft; right = cont.locRight; + top = cont.locTop; bottom = cont.locBottom; return std::abs(left - right) > 0.001f && std::abs(top - bottom) > 0.001f; } - // Fallback: derive full extents from child zones. left = *std::max_element(northEdges.begin(), northEdges.end()); right = *std::min_element(southEdges.begin(), southEdges.end()); top = *std::max_element(westEdges.begin(), westEdges.end()); bottom = *std::min_element(eastEdges.begin(), eastEdges.end()); if (left <= right || top <= bottom) { - left = cont.locLeft; - right = cont.locRight; - top = cont.locTop; - bottom = cont.locBottom; + left = cont.locLeft; right = cont.locRight; + top = cont.locTop; bottom = cont.locBottom; } - return std::abs(left - right) > 0.001f && std::abs(top - bottom) > 0.001f; } // -------------------------------------------------------- -// Per-zone texture loading +// Per-zone texture loading (Vulkan) // -------------------------------------------------------- void WorldMap::loadZoneTextures(int zoneIdx) { @@ -552,7 +532,9 @@ void WorldMap::loadZoneTextures(int zoneIdx) { if (folder != "EasternKingdoms") candidateFolders.push_back("EasternKingdoms"); } + VkDevice device = vkCtx->getDevice(); int loaded = 0; + for (int i = 0; i < 12; i++) { pipeline::BLPImage blpImage; bool found = false; @@ -560,28 +542,22 @@ void WorldMap::loadZoneTextures(int zoneIdx) { std::string path = "Interface\\WorldMap\\" + testFolder + "\\" + testFolder + std::to_string(i + 1) + ".blp"; blpImage = assetManager->loadTexture(path); - if (blpImage.isValid()) { - found = true; - break; - } + if (blpImage.isValid()) { found = true; break; } } if (!found) { - zone.tileTextures[i] = 0; + zone.tileTextures[i] = nullptr; continue; } - GLuint tex; - glGenTextures(1, &tex); - glBindTexture(GL_TEXTURE_2D, tex); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, blpImage.width, blpImage.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, blpImage.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + auto tex = std::make_unique(); + tex->upload(*vkCtx, blpImage.data.data(), blpImage.width, blpImage.height, + VK_FORMAT_R8G8B8A8_UNORM, false); + tex->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f); - zone.tileTextures[i] = tex; + zone.tileTextures[i] = tex.get(); + zoneTextures.push_back(std::move(tex)); loaded++; } @@ -589,65 +565,81 @@ void WorldMap::loadZoneTextures(int zoneIdx) { } // -------------------------------------------------------- -// Composite a zone's tiles into the FBO +// Request composite (deferred to compositePass) // -------------------------------------------------------- -void WorldMap::compositeZone(int zoneIdx) { +void WorldMap::requestComposite(int zoneIdx) { if (zoneIdx < 0 || zoneIdx >= static_cast(zones.size())) return; + pendingCompositeIdx = zoneIdx; +} + +// -------------------------------------------------------- +// Off-screen composite pass (call BEFORE main render pass) +// -------------------------------------------------------- + +void WorldMap::compositePass(VkCommandBuffer cmd) { + if (!initialized || pendingCompositeIdx < 0 || !compositeTarget) return; + if (pendingCompositeIdx >= static_cast(zones.size())) { + pendingCompositeIdx = -1; + return; + } + + int zoneIdx = pendingCompositeIdx; + pendingCompositeIdx = -1; + if (compositedIdx == zoneIdx) return; const auto& zone = zones[zoneIdx]; + uint32_t frameIdx = vkCtx->getCurrentFrame(); + VkDevice device = vkCtx->getDevice(); - // Save GL state - GLint prevFBO = 0; - glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFBO); - GLint prevViewport[4]; - glGetIntegerv(GL_VIEWPORT, prevViewport); - GLboolean prevBlend = glIsEnabled(GL_BLEND); - GLboolean prevDepthTest = glIsEnabled(GL_DEPTH_TEST); - - glBindFramebuffer(GL_FRAMEBUFFER, fbo); - glViewport(0, 0, FBO_W, FBO_H); - glClearColor(0.05f, 0.08f, 0.12f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT); - - glDisable(GL_DEPTH_TEST); - glDisable(GL_CULL_FACE); - glDisable(GL_BLEND); - glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); - - tileShader->use(); - tileShader->setUniform("uTileTexture", 0); - tileShader->setUniform("uGridCols", static_cast(GRID_COLS)); - tileShader->setUniform("uGridRows", static_cast(GRID_ROWS)); - - glBindVertexArray(tileQuadVAO); - - // Tiles 1-12 in a 4x3 grid: tile N at col=(N-1)%4, row=(N-1)/4 - // Row 0 (tiles 1-4) = top of image (north) β†’ placed at FBO bottom (GL y=0) - // ImGui::Image maps GL (0,0) β†’ widget top-left β†’ north at top βœ“ + // Update tile descriptor sets for this frame for (int i = 0; i < 12; i++) { - if (zone.tileTextures[i] == 0) continue; + VkTexture* tileTex = zone.tileTextures[i]; + if (!tileTex || !tileTex->isValid()) continue; + + VkDescriptorImageInfo imgInfo = tileTex->descriptorInfo(); + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = tileDescSets[frameIdx][i]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + + // Begin off-screen render pass + VkClearColorValue clearColor = {{ 0.05f, 0.08f, 0.12f, 1.0f }}; + compositeTarget->beginPass(cmd, clearColor); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, tilePipeline); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &quadVB, &offset); + + // Draw 4x3 tile grid + for (int i = 0; i < 12; i++) { + if (!zone.tileTextures[i] || !zone.tileTextures[i]->isValid()) continue; int col = i % GRID_COLS; int row = i / GRID_COLS; - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, zone.tileTextures[i]); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + tilePipelineLayout, 0, 1, + &tileDescSets[frameIdx][i], 0, nullptr); - tileShader->setUniform("uGridOffset", glm::vec2( - static_cast(col), static_cast(row))); - glDrawArrays(GL_TRIANGLES, 0, 6); + WorldMapTilePush push{}; + push.gridOffset = glm::vec2(static_cast(col), static_cast(row)); + push.gridCols = static_cast(GRID_COLS); + push.gridRows = static_cast(GRID_ROWS); + vkCmdPushConstants(cmd, tilePipelineLayout, VK_SHADER_STAGE_VERTEX_BIT, + 0, sizeof(push), &push); + + vkCmdDraw(cmd, 6, 1, 0, 0); } - glBindVertexArray(0); - - // Restore GL state - glBindFramebuffer(GL_FRAMEBUFFER, prevFBO); - glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]); - if (prevBlend) glEnable(GL_BLEND); - if (prevDepthTest) glEnable(GL_DEPTH_TEST); - + compositeTarget->endPass(cmd); compositedIdx = zoneIdx; } @@ -656,61 +648,44 @@ void WorldMap::enterWorldView() { int rootIdx = -1; for (int i = 0; i < static_cast(zones.size()); i++) { - if (isRootContinent(zones, i)) { - rootIdx = i; - break; - } + if (isRootContinent(zones, i)) { rootIdx = i; break; } } if (rootIdx >= 0) { loadZoneTextures(rootIdx); bool hasAnyTile = false; - for (GLuint tex : zones[rootIdx].tileTextures) { - if (tex != 0) { hasAnyTile = true; break; } + for (VkTexture* tex : zones[rootIdx].tileTextures) { + if (tex != nullptr) { hasAnyTile = true; break; } } if (hasAnyTile) { - compositeZone(rootIdx); + requestComposite(rootIdx); currentIdx = rootIdx; return; } } - // Fallback: use first leaf continent as world-view backdrop. int fallbackContinent = -1; for (int i = 0; i < static_cast(zones.size()); i++) { - if (isLeafContinent(zones, i)) { - fallbackContinent = i; - break; - } + if (isLeafContinent(zones, i)) { fallbackContinent = i; break; } } if (fallbackContinent < 0) { for (int i = 0; i < static_cast(zones.size()); i++) { if (zones[i].areaID == 0 && !isRootContinent(zones, i)) { - fallbackContinent = i; - break; + fallbackContinent = i; break; } } } if (fallbackContinent >= 0) { loadZoneTextures(fallbackContinent); - compositeZone(fallbackContinent); + requestComposite(fallbackContinent); currentIdx = fallbackContinent; return; } - // No root world texture available: clear to neutral background. currentIdx = -1; compositedIdx = -1; - GLint prevFBO = 0; - glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFBO); - GLint prevViewport[4]; - glGetIntegerv(GL_VIEWPORT, prevViewport); - glBindFramebuffer(GL_FRAMEBUFFER, fbo); - glViewport(0, 0, FBO_W, FBO_H); - glClearColor(0.05f, 0.08f, 0.12f, 1.0f); - glClear(GL_COLOR_BUFFER_BIT); - glBindFramebuffer(GL_FRAMEBUFFER, prevFBO); - glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]); + // Render target will be cleared by next compositePass + pendingCompositeIdx = -2; // Signal "clear only" } // -------------------------------------------------------- @@ -722,15 +697,11 @@ glm::vec2 WorldMap::renderPosToMapUV(const glm::vec3& renderPos, int zoneIdx) co return glm::vec2(0.5f, 0.5f); const auto& zone = zones[zoneIdx]; + float wowX = renderPos.y; + float wowY = renderPos.x; - // renderPos: x = wowY (west axis), y = wowX (north axis) - float wowX = renderPos.y; // north - float wowY = renderPos.x; // west - - float left = zone.locLeft; - float right = zone.locRight; - float top = zone.locTop; - float bottom = zone.locBottom; + float left = zone.locLeft, right = zone.locRight; + float top = zone.locTop, bottom = zone.locBottom; if (zone.areaID == 0) { float l, r, t, b; if (getContinentProjectionBounds(zoneIdx, l, r, t, b)) { @@ -738,44 +709,34 @@ glm::vec2 WorldMap::renderPosToMapUV(const glm::vec3& renderPos, int zoneIdx) co } } - // WorldMapArea.dbc axis mapping: - // locLeft/locRight contain wowX values (N/S), locLeft=north > locRight=south - // locTop/locBottom contain wowY values (W/E), locTop=west > locBottom=east - // World map textures are laid out with axes transposed, so horizontal uses wowX. - float denom_h = left - right; // wowX span (N-S) β†’ horizontal - float denom_v = top - bottom; // wowY span (W-E) β†’ vertical - + float denom_h = left - right; + float denom_v = top - bottom; if (std::abs(denom_h) < 0.001f || std::abs(denom_v) < 0.001f) return glm::vec2(0.5f, 0.5f); float u = (left - wowX) / denom_h; float v = (top - wowY) / denom_v; - // Continent overlay calibration: shift overlays/player marker upward. if (zone.areaID == 0) { constexpr float kVScale = 1.0f; - constexpr float kVOffset = -0.15f; // ~15% upward total + constexpr float kVOffset = -0.15f; v = (v - 0.5f) * kVScale + 0.5f + kVOffset; } return glm::vec2(u, v); } // -------------------------------------------------------- -// Exploration tracking +// Exploration tracking (identical to GL version) // -------------------------------------------------------- void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { auto isExploreFlagSet = [this](uint32_t flag) -> bool { if (!hasServerExplorationMask || serverExplorationMask.empty() || flag == 0) return false; - const auto isSet = [this](uint32_t bitIndex) -> bool { const size_t word = bitIndex / 32; if (word >= serverExplorationMask.size()) return false; - const uint32_t bit = bitIndex % 32; - return (serverExplorationMask[word] & (1u << bit)) != 0; + return (serverExplorationMask[word] & (1u << (bitIndex % 32))) != 0; }; - - // Most cores use zero-based bit indices; some data behaves one-based. if (isSet(flag)) return true; if (flag > 0 && isSet(flag - 1)) return true; return false; @@ -793,56 +754,42 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { } } } - - // Fall back to local bounds-based reveal if server masks are missing/unusable. if (markedAny) return; - float wowX = playerRenderPos.y; // north/south - float wowY = playerRenderPos.x; // west/east + float wowX = playerRenderPos.y; + float wowY = playerRenderPos.x; for (int i = 0; i < static_cast(zones.size()); i++) { const auto& z = zones[i]; - if (z.areaID == 0) continue; // skip continent-level entries - - float minX = std::min(z.locLeft, z.locRight); - float maxX = std::max(z.locLeft, z.locRight); - float minY = std::min(z.locTop, z.locBottom); - float maxY = std::max(z.locTop, z.locBottom); - float spanX = maxX - minX; - float spanY = maxY - minY; - if (spanX < 0.001f || spanY < 0.001f) continue; - - bool contains = (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY); - if (contains) { + if (z.areaID == 0) continue; + float minX = std::min(z.locLeft, z.locRight), maxX = std::max(z.locLeft, z.locRight); + float minY = std::min(z.locTop, z.locBottom), maxY = std::max(z.locTop, z.locBottom); + if (maxX - minX < 0.001f || maxY - minY < 0.001f) continue; + if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) { exploredZones.insert(i); markedAny = true; } } - // Fallback for imperfect DBC bounds: reveal nearest zone so exploration still progresses. if (!markedAny) { int zoneIdx = findZoneForPlayer(playerRenderPos); - if (zoneIdx >= 0) { - exploredZones.insert(zoneIdx); - } + if (zoneIdx >= 0) exploredZones.insert(zoneIdx); } } void WorldMap::zoomIn(const glm::vec3& playerRenderPos) { if (viewLevel == ViewLevel::WORLD) { - // World β†’ Continent if (continentIdx >= 0) { loadZoneTextures(continentIdx); - compositeZone(continentIdx); + requestComposite(continentIdx); currentIdx = continentIdx; viewLevel = ViewLevel::CONTINENT; } } else if (viewLevel == ViewLevel::CONTINENT) { - // Continent β†’ Zone (use player's current zone) int zoneIdx = findZoneForPlayer(playerRenderPos); if (zoneIdx >= 0 && zoneBelongsToContinent(zoneIdx, continentIdx)) { loadZoneTextures(zoneIdx); - compositeZone(zoneIdx); + requestComposite(zoneIdx); currentIdx = zoneIdx; viewLevel = ViewLevel::ZONE; } @@ -851,20 +798,18 @@ void WorldMap::zoomIn(const glm::vec3& playerRenderPos) { void WorldMap::zoomOut() { if (viewLevel == ViewLevel::ZONE) { - // Zone β†’ Continent if (continentIdx >= 0) { - compositeZone(continentIdx); + requestComposite(continentIdx); currentIdx = continentIdx; viewLevel = ViewLevel::CONTINENT; } } else if (viewLevel == ViewLevel::CONTINENT) { - // Continent β†’ World enterWorldView(); } } // -------------------------------------------------------- -// Main render +// Main render (input + ImGui overlay) // -------------------------------------------------------- void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight) { @@ -872,12 +817,8 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr auto& input = core::Input::getInstance(); - // Track exploration even when map is closed - if (!zones.empty()) { - updateExploration(playerRenderPos); - } + if (!zones.empty()) updateExploration(playerRenderPos); - // When map is open, always allow M/Escape to close (bypass ImGui keyboard capture) if (open) { if (input.isKeyJustPressed(SDL_SCANCODE_M) || input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { @@ -885,24 +826,16 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr return; } - // Mouse wheel: scroll up = zoom in, scroll down = zoom out. - // Use both ImGui and raw input wheel deltas for reliability across frame order/capture paths. auto& io = ImGui::GetIO(); float wheelDelta = io.MouseWheel; - if (std::abs(wheelDelta) < 0.001f) { + if (std::abs(wheelDelta) < 0.001f) wheelDelta = input.getMouseWheelDelta(); - } - if (wheelDelta > 0.0f) { - zoomIn(playerRenderPos); - } else if (wheelDelta < 0.0f) { - zoomOut(); - } + if (wheelDelta > 0.0f) zoomIn(playerRenderPos); + else if (wheelDelta < 0.0f) zoomOut(); } else { auto& io = ImGui::GetIO(); if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_M)) { open = true; - - // Lazy-load zone data on first open if (zones.empty()) loadZonesFromDBC(); int bestContinent = findBestContinentForPlayer(playerRenderPos); @@ -911,17 +844,16 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr compositedIdx = -1; } - // Open directly to the player's current zone int playerZone = findZoneForPlayer(playerRenderPos); if (playerZone >= 0 && continentIdx >= 0 && zoneBelongsToContinent(playerZone, continentIdx)) { loadZoneTextures(playerZone); - compositeZone(playerZone); + requestComposite(playerZone); currentIdx = playerZone; viewLevel = ViewLevel::ZONE; } else if (continentIdx >= 0) { loadZoneTextures(continentIdx); - compositeZone(continentIdx); + requestComposite(continentIdx); currentIdx = continentIdx; viewLevel = ViewLevel::CONTINENT; } @@ -929,7 +861,6 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr } if (!open) return; - renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight); } @@ -941,7 +872,6 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi float sw = static_cast(screenWidth); float sh = static_cast(screenHeight); - // Full-screen dark background ImGui::SetNextWindowPos(ImVec2(0, 0)); ImGui::SetNextWindowSize(ImVec2(sw, sh)); @@ -955,8 +885,7 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); if (ImGui::Begin("##WorldMap", nullptr, flags)) { - // Map display area: maintain 4:3 aspect ratio, fit within ~85% of screen - float mapAspect = static_cast(FBO_W) / static_cast(FBO_H); // 1.333 + float mapAspect = static_cast(FBO_W) / static_cast(FBO_H); float availW = sw * 0.85f; float availH = sh * 0.85f; float displayW, displayH; @@ -972,7 +901,8 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi float mapY = (sh - displayH) / 2.0f; ImGui::SetCursorPos(ImVec2(mapX, mapY)); - ImGui::Image(static_cast(static_cast(fboTexture)), + // Display composite render target via ImGui (VkDescriptorSet as ImTextureID) + ImGui::Image(reinterpret_cast(imguiDisplaySet), ImVec2(displayW, displayH), ImVec2(0, 0), ImVec2(1, 1)); ImVec2 imgMin = ImGui::GetItemRectMin(); @@ -991,8 +921,6 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi continentIndices.push_back(i); } } - // If we have multiple continent choices, hide the root/world alias entry - // (commonly "Azeroth") so picker only shows real continents. if (continentIndices.size() > 1) { std::vector filtered; filtered.reserve(continentIndices.size()); @@ -1008,7 +936,7 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } - // World-level continent selection UI. + // World-level continent selection UI if (viewLevel == ViewLevel::WORLD && !continentIndices.empty()) { ImVec2 titleSz = ImGui::CalcTextSize("World"); ImGui::SetCursorPos(ImVec2((sw - titleSz.x) * 0.5f, mapY + 8.0f)); @@ -1019,17 +947,15 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi int ci = continentIndices[i]; if (i > 0) ImGui::SameLine(); const bool selected = (ci == continentIdx); - if (selected) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f)); - } + if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f)); - std::string rawName = zones[ci].areaName.empty() ? "Continent" : zones[ci].areaName; - if (rawName == "Azeroth") rawName = "Eastern Kingdoms"; - std::string label = rawName + "##" + std::to_string(ci); + std::string rawName = zones[ci].areaName.empty() ? "Continent" : zones[ci].areaName; + if (rawName == "Azeroth") rawName = "Eastern Kingdoms"; + std::string label = rawName + "##" + std::to_string(ci); if (ImGui::Button(label.c_str())) { continentIdx = ci; loadZoneTextures(continentIdx); - compositeZone(continentIdx); + requestComposite(continentIdx); currentIdx = continentIdx; viewLevel = ViewLevel::CONTINENT; } @@ -1041,9 +967,7 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi int ci = continentIndices[i]; if (i > 0) ImGui::SameLine(); const bool selected = (ci == continentIdx); - if (selected) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f)); - } + if (selected) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.35f, 0.25f, 0.05f, 0.9f)); std::string rawName = zones[ci].areaName.empty() ? "Continent" : zones[ci].areaName; if (rawName == "Azeroth") rawName = "Eastern Kingdoms"; @@ -1051,40 +975,33 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi if (ImGui::Button(label.c_str())) { continentIdx = ci; loadZoneTextures(continentIdx); - compositeZone(continentIdx); + requestComposite(continentIdx); currentIdx = continentIdx; } if (selected) ImGui::PopStyleColor(); } } - // Player marker on current view + // Player marker if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { glm::vec2 playerUV = renderPosToMapUV(playerRenderPos, currentIdx); - if (playerUV.x >= 0.0f && playerUV.x <= 1.0f && playerUV.y >= 0.0f && playerUV.y <= 1.0f) { float px = imgMin.x + playerUV.x * displayW; float py = imgMin.y + playerUV.y * displayH; - - drawList->AddCircleFilled(ImVec2(px, py), 6.0f, - IM_COL32(255, 40, 40, 255)); - drawList->AddCircle(ImVec2(px, py), 6.0f, - IM_COL32(0, 0, 0, 200), 0, 2.0f); + drawList->AddCircleFilled(ImVec2(px, py), 6.0f, IM_COL32(255, 40, 40, 255)); + drawList->AddCircle(ImVec2(px, py), 6.0f, IM_COL32(0, 0, 0, 200), 0, 2.0f); } } - // --- Continent view: show clickable zone overlays --- + // Continent view: clickable zone overlays if (viewLevel == ViewLevel::CONTINENT && continentIdx >= 0) { const auto& cont = zones[continentIdx]; - // World map textures are transposed; match the same axis mapping as player UV. - float cLeft = cont.locLeft; - float cRight = cont.locRight; - float cTop = cont.locTop; - float cBottom = cont.locBottom; + float cLeft = cont.locLeft, cRight = cont.locRight; + float cTop = cont.locTop, cBottom = cont.locBottom; getContinentProjectionBounds(continentIdx, cLeft, cRight, cTop, cBottom); - float cDenomU = cLeft - cRight; // wowX span (N-S) - float cDenomV = cTop - cBottom; // wowY span (W-E) + float cDenomU = cLeft - cRight; + float cDenomV = cTop - cBottom; ImVec2 mousePos = ImGui::GetMousePos(); int hoveredZone = -1; @@ -1092,65 +1009,45 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi if (std::abs(cDenomU) > 0.001f && std::abs(cDenomV) > 0.001f) { for (int zi = 0; zi < static_cast(zones.size()); zi++) { if (!zoneBelongsToContinent(zi, continentIdx)) continue; - const auto& z = zones[zi]; - - // Skip zones with zero-size bounds if (std::abs(z.locLeft - z.locRight) < 0.001f || std::abs(z.locTop - z.locBottom) < 0.001f) continue; - // Project zone bounds to continent UV - // u axis (left->right): north->south - float zuMin = (cLeft - z.locLeft) / cDenomU; // zone north edge - float zuMax = (cLeft - z.locRight) / cDenomU; // zone south edge - // v axis (top->bottom): west->east - float zvMin = (cTop - z.locTop) / cDenomV; // zone west edge - float zvMax = (cTop - z.locBottom) / cDenomV; // zone east edge + float zuMin = (cLeft - z.locLeft) / cDenomU; + float zuMax = (cLeft - z.locRight) / cDenomU; + float zvMin = (cTop - z.locTop) / cDenomV; + float zvMax = (cTop - z.locBottom) / cDenomV; - // Slightly shrink DBC AABB overlays to reduce heavy overlap. constexpr float kOverlayShrink = 0.92f; - float cu = (zuMin + zuMax) * 0.5f; - float cv = (zvMin + zvMax) * 0.5f; + float cu = (zuMin + zuMax) * 0.5f, cv = (zvMin + zvMax) * 0.5f; float hu = (zuMax - zuMin) * 0.5f * kOverlayShrink; float hv = (zvMax - zvMin) * 0.5f * kOverlayShrink; - zuMin = cu - hu; - zuMax = cu + hu; - zvMin = cv - hv; - zvMax = cv + hv; + zuMin = cu - hu; zuMax = cu + hu; + zvMin = cv - hv; zvMax = cv + hv; - // Continent overlay calibration (matches player marker calibration). - constexpr float kVScale = 1.0f; constexpr float kVOffset = -0.15f; - zvMin = (zvMin - 0.5f) * kVScale + 0.5f + kVOffset; - zvMax = (zvMax - 0.5f) * kVScale + 0.5f + kVOffset; + zvMin = (zvMin - 0.5f) + 0.5f + kVOffset; + zvMax = (zvMax - 0.5f) + 0.5f + kVOffset; - // Clamp to [0,1] zuMin = std::clamp(zuMin, 0.0f, 1.0f); zuMax = std::clamp(zuMax, 0.0f, 1.0f); zvMin = std::clamp(zvMin, 0.0f, 1.0f); zvMax = std::clamp(zvMax, 0.0f, 1.0f); - - // Skip tiny or degenerate zones if (zuMax - zuMin < 0.001f || zvMax - zvMin < 0.001f) continue; - // Convert to screen coordinates float sx0 = imgMin.x + zuMin * displayW; float sy0 = imgMin.y + zvMin * displayH; float sx1 = imgMin.x + zuMax * displayW; float sy1 = imgMin.y + zvMax * displayH; bool explored = exploredZones.count(zi) > 0; - - // Check hover bool hovered = (mousePos.x >= sx0 && mousePos.x <= sx1 && mousePos.y >= sy0 && mousePos.y <= sy1); - // Fog of war: darken unexplored zones if (!explored) { drawList->AddRectFilled(ImVec2(sx0, sy0), ImVec2(sx1, sy1), IM_COL32(0, 0, 0, 160)); } - if (hovered) { hoveredZone = zi; drawList->AddRectFilled(ImVec2(sx0, sy0), ImVec2(sx1, sy1), @@ -1164,27 +1061,22 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } - // Zone name tooltip if (hoveredZone >= 0) { ImGui::SetTooltip("%s", zones[hoveredZone].areaName.c_str()); - - // Click to zoom into zone if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { loadZoneTextures(hoveredZone); - compositeZone(hoveredZone); + requestComposite(hoveredZone); currentIdx = hoveredZone; viewLevel = ViewLevel::ZONE; } } } - // --- Zone view: back to continent --- + // Zone view: back to continent if (viewLevel == ViewLevel::ZONE && continentIdx >= 0) { - // Right-click or Back button auto& io = ImGui::GetIO(); - bool goBack = io.MouseClicked[1]; // right-click (direct IO check) + bool goBack = io.MouseClicked[1]; - // "< Back" button in top-left of map area ImGui::SetCursorPos(ImVec2(mapX + 8.0f, mapY + 8.0f)); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.3f, 0.1f, 0.9f)); @@ -1193,12 +1085,11 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi ImGui::PopStyleColor(3); if (goBack) { - compositeZone(continentIdx); + requestComposite(continentIdx); currentIdx = continentIdx; viewLevel = ViewLevel::CONTINENT; } - // Zone name header const char* zoneName = zones[currentIdx].areaName.c_str(); ImVec2 nameSize = ImGui::CalcTextSize(zoneName); float nameY = mapY - nameSize.y - 8.0f; @@ -1208,7 +1099,7 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi } } - // --- Continent view: back to world --- + // Continent view: back to world if (viewLevel == ViewLevel::CONTINENT) { auto& io = ImGui::GetIO(); bool goWorld = io.MouseClicked[1]; @@ -1226,13 +1117,13 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi // Help text const char* helpText; - if (viewLevel == ViewLevel::ZONE) { + if (viewLevel == ViewLevel::ZONE) helpText = "Scroll out or right-click to zoom out | M or Escape to close"; - } else if (viewLevel == ViewLevel::WORLD) { + else if (viewLevel == ViewLevel::WORLD) helpText = "Select a continent | Scroll in to zoom | M or Escape to close"; - } else { + else helpText = "Click zone or scroll in to zoom | Scroll out / right-click for World | M or Escape to close"; - } + ImVec2 textSize = ImGui::CalcTextSize(helpText); float textY = mapY + displayH + 8.0f; if (textY + textSize.y < sh) { diff --git a/src/ui/auth_screen.cpp b/src/ui/auth_screen.cpp index 9be570f3..f76b21ba 100644 --- a/src/ui/auth_screen.cpp +++ b/src/ui/auth_screen.cpp @@ -3,10 +3,13 @@ #include "core/application.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "rendering/vk_context.hpp" #include "pipeline/asset_manager.hpp" #include "audio/music_manager.hpp" #include "game/expansion_profile.hpp" #include +#include +#include "stb_image.h" #include #include #include @@ -159,39 +162,34 @@ void AuthScreen::render(auth::AuthHandler& authHandler) { loginInfoLoaded = true; } - if (!videoInitAttempted) { - videoInitAttempted = true; - std::string videoPath = "assets/startscreen.mp4"; - if (!std::filesystem::exists(videoPath)) { - videoPath = (std::filesystem::current_path() / "assets/startscreen.mp4").string(); - } - backgroundVideo.open(videoPath); + if (!bgInitAttempted) { + bgInitAttempted = true; + loadBackgroundImage(); } - backgroundVideo.update(ImGui::GetIO().DeltaTime); - if (backgroundVideo.isReady()) { + if (bgDescriptorSet) { ImVec2 screen = ImGui::GetIO().DisplaySize; float screenW = screen.x; float screenH = screen.y; - float videoW = static_cast(backgroundVideo.getWidth()); - float videoH = static_cast(backgroundVideo.getHeight()); - if (videoW > 0.0f && videoH > 0.0f) { + float imgW = static_cast(bgWidth); + float imgH = static_cast(bgHeight); + if (imgW > 0.0f && imgH > 0.0f) { float screenAspect = screenW / screenH; - float videoAspect = videoW / videoH; + float imgAspect = imgW / imgH; ImVec2 uv0(0.0f, 0.0f); ImVec2 uv1(1.0f, 1.0f); - if (videoAspect > screenAspect) { - float scale = screenAspect / videoAspect; + if (imgAspect > screenAspect) { + float scale = screenAspect / imgAspect; float crop = (1.0f - scale) * 0.5f; uv0.x = crop; uv1.x = 1.0f - crop; - } else if (videoAspect < screenAspect) { - float scale = videoAspect / screenAspect; + } else if (imgAspect < screenAspect) { + float scale = imgAspect / screenAspect; float crop = (1.0f - scale) * 0.5f; uv0.y = crop; uv1.y = 1.0f - crop; } ImDrawList* bg = ImGui::GetBackgroundDrawList(); - bg->AddImage(static_cast(static_cast(backgroundVideo.getTextureId())), + bg->AddImage(reinterpret_cast(bgDescriptorSet), ImVec2(0, 0), ImVec2(screenW, screenH), uv0, uv1); } } @@ -763,4 +761,164 @@ void AuthScreen::loadLoginInfo() { LOG_INFO("Login info loaded from ", path); } +static uint32_t findMemType(VkPhysicalDevice pd, uint32_t filter, VkMemoryPropertyFlags props) { + VkPhysicalDeviceMemoryProperties mp; + vkGetPhysicalDeviceMemoryProperties(pd, &mp); + for (uint32_t i = 0; i < mp.memoryTypeCount; i++) { + if ((filter & (1 << i)) && (mp.memoryTypes[i].propertyFlags & props) == props) return i; + } + return 0; +} + +bool AuthScreen::loadBackgroundImage() { + auto& app = core::Application::getInstance(); + auto* renderer = app.getRenderer(); + if (!renderer) return false; + bgVkCtx = renderer->getVkContext(); + if (!bgVkCtx) return false; + + std::string imgPath = "assets/krayonsignin.png"; + if (!std::filesystem::exists(imgPath)) + imgPath = (std::filesystem::current_path() / imgPath).string(); + + int channels; + stbi_set_flip_vertically_on_load(false); + unsigned char* data = stbi_load(imgPath.c_str(), &bgWidth, &bgHeight, &channels, 4); + if (!data) { + LOG_WARNING("Auth screen: failed to load background image: ", imgPath); + return false; + } + + VkDevice device = bgVkCtx->getDevice(); + VkPhysicalDevice physDevice = bgVkCtx->getPhysicalDevice(); + VkDeviceSize imageSize = static_cast(bgWidth) * bgHeight * 4; + + // Staging buffer + VkBuffer stagingBuffer; + VkDeviceMemory stagingMemory; + { + VkBufferCreateInfo bufInfo{}; + bufInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufInfo.size = imageSize; + bufInfo.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + bufInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + vkCreateBuffer(device, &bufInfo, nullptr, &stagingBuffer); + + VkMemoryRequirements memReqs; + vkGetBufferMemoryRequirements(device, stagingBuffer, &memReqs); + VkMemoryAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = findMemType(physDevice, memReqs.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + vkAllocateMemory(device, &allocInfo, nullptr, &stagingMemory); + vkBindBufferMemory(device, stagingBuffer, stagingMemory, 0); + + void* mapped; + vkMapMemory(device, stagingMemory, 0, imageSize, 0, &mapped); + memcpy(mapped, data, imageSize); + vkUnmapMemory(device, stagingMemory); + } + stbi_image_free(data); + + // Create VkImage + { + VkImageCreateInfo imgInfo{}; + imgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + imgInfo.imageType = VK_IMAGE_TYPE_2D; + imgInfo.format = VK_FORMAT_R8G8B8A8_UNORM; + imgInfo.extent = {static_cast(bgWidth), static_cast(bgHeight), 1}; + imgInfo.mipLevels = 1; + imgInfo.arrayLayers = 1; + imgInfo.samples = VK_SAMPLE_COUNT_1_BIT; + imgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + imgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + imgInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + imgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + vkCreateImage(device, &imgInfo, nullptr, &bgImage); + + VkMemoryRequirements memReqs; + vkGetImageMemoryRequirements(device, bgImage, &memReqs); + VkMemoryAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocInfo.allocationSize = memReqs.size; + allocInfo.memoryTypeIndex = findMemType(physDevice, memReqs.memoryTypeBits, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); + vkAllocateMemory(device, &allocInfo, nullptr, &bgMemory); + vkBindImageMemory(device, bgImage, bgMemory, 0); + } + + // Transfer + bgVkCtx->immediateSubmit([&](VkCommandBuffer cmd) { + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = bgImage; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {static_cast(bgWidth), static_cast(bgHeight), 1}; + vkCmdCopyBufferToImage(cmd, stagingBuffer, bgImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion); + + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + }); + + vkDestroyBuffer(device, stagingBuffer, nullptr); + vkFreeMemory(device, stagingMemory, nullptr); + + // Image view + { + VkImageViewCreateInfo viewInfo{}; + viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + viewInfo.image = bgImage; + viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + viewInfo.format = VK_FORMAT_R8G8B8A8_UNORM; + viewInfo.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCreateImageView(device, &viewInfo, nullptr, &bgImageView); + } + + // Sampler + { + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.magFilter = VK_FILTER_LINEAR; + samplerInfo.minFilter = VK_FILTER_LINEAR; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + vkCreateSampler(device, &samplerInfo, nullptr, &bgSampler); + } + + bgDescriptorSet = ImGui_ImplVulkan_AddTexture(bgSampler, bgImageView, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + + LOG_INFO("Auth screen background loaded: ", bgWidth, "x", bgHeight); + return true; +} + +void AuthScreen::destroyBackgroundImage() { + if (!bgVkCtx) return; + VkDevice device = bgVkCtx->getDevice(); + vkDeviceWaitIdle(device); + if (bgDescriptorSet) { ImGui_ImplVulkan_RemoveTexture(bgDescriptorSet); bgDescriptorSet = VK_NULL_HANDLE; } + if (bgSampler) { vkDestroySampler(device, bgSampler, nullptr); bgSampler = VK_NULL_HANDLE; } + if (bgImageView) { vkDestroyImageView(device, bgImageView, nullptr); bgImageView = VK_NULL_HANDLE; } + if (bgImage) { vkDestroyImage(device, bgImage, nullptr); bgImage = VK_NULL_HANDLE; } + if (bgMemory) { vkFreeMemory(device, bgMemory, nullptr); bgMemory = VK_NULL_HANDLE; } +} + }} // namespace wowee::ui diff --git a/src/ui/character_create_screen.cpp b/src/ui/character_create_screen.cpp index b1c548c4..fa81756f 100644 --- a/src/ui/character_create_screen.cpp +++ b/src/ui/character_create_screen.cpp @@ -1,5 +1,7 @@ #include "ui/character_create_screen.hpp" #include "rendering/character_preview.hpp" +#include "rendering/renderer.hpp" +#include "core/application.hpp" #include "game/game_handler.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_layout.hpp" @@ -128,7 +130,10 @@ void CharacterCreateScreen::initializePreview(pipeline::AssetManager* am) { assetManager_ = am; if (!preview_) { preview_ = std::make_unique(); - preview_->initialize(am); + if (preview_->initialize(am)) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) renderer->registerPreview(preview_.get()); + } } // Force model reload prevRaceIndex_ = -1; @@ -332,6 +337,7 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { if (preview_) { updatePreviewIfNeeded(); preview_->render(); + preview_->requestComposite(); } ImVec2 displaySize = ImGui::GetIO().DisplaySize; @@ -363,11 +369,11 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { static_cast(preview_->getHeight())); } - ImGui::Image( - static_cast(preview_->getTextureId()), - ImVec2(imgW, imgH), - ImVec2(0.0f, 1.0f), // UV top-left (flipped Y) - ImVec2(1.0f, 0.0f)); // UV bottom-right (flipped Y) + if (preview_->getTextureId()) { + ImGui::Image( + reinterpret_cast(preview_->getTextureId()), + ImVec2(imgW, imgH)); + } // Mouse drag rotation on the preview image if (ImGui::IsItemHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 64e24716..406164ac 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -1,5 +1,6 @@ #include "ui/character_screen.hpp" #include "rendering/character_preview.hpp" +#include "rendering/renderer.hpp" #include "pipeline/asset_manager.hpp" #include "core/application.hpp" #include "core/logger.hpp" @@ -250,6 +251,9 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { if (!previewInitialized_) { LOG_WARNING("CharacterScreen: failed to init CharacterPreview"); preview_.reset(); + } else { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) renderer->registerPreview(preview_.get()); } } if (preview_) { @@ -280,9 +284,10 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { previewEquipHash_ = equipHash; } - // Drive preview animation and render to its FBO. + // Drive preview animation and request composite for next beginFrame. preview_->update(ImGui::GetIO().DeltaTime); preview_->render(); + preview_->requestComposite(); } } @@ -290,7 +295,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::BeginChild("CharDetails", ImVec2(detailPanelW, listH), true); // 3D preview portrait - if (preview_ && preview_->getTextureId() != 0) { + if (preview_ && preview_->getTextureId()) { float imgW = ImGui::GetContentRegionAvail().x; float imgH = imgW * (static_cast(preview_->getHeight()) / static_cast(preview_->getWidth())); @@ -302,10 +307,8 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { static_cast(preview_->getHeight())); } ImGui::Image( - static_cast(preview_->getTextureId()), - ImVec2(imgW, imgH), - ImVec2(0.0f, 1.0f), // flip Y for OpenGL - ImVec2(1.0f, 0.0f)); + reinterpret_cast(preview_->getTextureId()), + ImVec2(imgW, imgH)); if (ImGui::IsItemHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { preview_->rotate(ImGui::GetIO().MouseDelta.x * 0.2f); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 843decdb..8c0ae570 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1,12 +1,15 @@ #include "ui/game_screen.hpp" #include "rendering/character_preview.hpp" +#include "rendering/vk_context.hpp" #include "core/application.hpp" #include "core/coordinates.hpp" #include "core/spawn_presets.hpp" #include "core/input.hpp" #include "rendering/renderer.hpp" +#include "rendering/wmo_renderer.hpp" #include "rendering/terrain_manager.hpp" #include "rendering/minimap.hpp" +#include "rendering/world_map.hpp" #include "rendering/character_renderer.hpp" #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" @@ -193,6 +196,16 @@ void GameScreen::render(game::GameHandler& gameHandler) { float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; + // Sync minimap opacity with UI opacity + { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + if (auto* minimap = renderer->getMinimap()) { + minimap->setOpacity(uiOpacity_); + } + } + } + // Apply initial settings when renderer becomes available if (!minimapSettingsApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -225,41 +238,76 @@ void GameScreen::render(game::GameHandler& gameHandler) { auto* renderer = core::Application::getInstance().getRenderer(); if (renderer && renderer->getUiSoundManager()) { float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; - audio::AudioEngine::instance().setMasterVolume(soundMuted_ ? 0.0f : masterScale); + audio::AudioEngine::instance().setMasterVolume(masterScale); if (auto* music = renderer->getMusicManager()) { - music->setVolume(static_cast(pendingMusicVolume * masterScale)); + music->setVolume(pendingMusicVolume); } if (auto* ambient = renderer->getAmbientSoundManager()) { - ambient->setVolumeScale(pendingAmbientVolume / 100.0f * masterScale); + ambient->setVolumeScale(pendingAmbientVolume / 100.0f); } if (auto* ui = renderer->getUiSoundManager()) { - ui->setVolumeScale(pendingUiVolume / 100.0f * masterScale); + ui->setVolumeScale(pendingUiVolume / 100.0f); } if (auto* combat = renderer->getCombatSoundManager()) { - combat->setVolumeScale(pendingCombatVolume / 100.0f * masterScale); + combat->setVolumeScale(pendingCombatVolume / 100.0f); } if (auto* spell = renderer->getSpellSoundManager()) { - spell->setVolumeScale(pendingSpellVolume / 100.0f * masterScale); + spell->setVolumeScale(pendingSpellVolume / 100.0f); } if (auto* movement = renderer->getMovementSoundManager()) { - movement->setVolumeScale(pendingMovementVolume / 100.0f * masterScale); + movement->setVolumeScale(pendingMovementVolume / 100.0f); } if (auto* footstep = renderer->getFootstepManager()) { - footstep->setVolumeScale(pendingFootstepVolume / 100.0f * masterScale); + footstep->setVolumeScale(pendingFootstepVolume / 100.0f); } if (auto* npcVoice = renderer->getNpcVoiceManager()) { - npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f * masterScale); + npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f); } if (auto* mount = renderer->getMountSoundManager()) { - mount->setVolumeScale(pendingMountVolume / 100.0f * masterScale); + mount->setVolumeScale(pendingMountVolume / 100.0f); } if (auto* activity = renderer->getActivitySoundManager()) { - activity->setVolumeScale(pendingActivityVolume / 100.0f * masterScale); + activity->setVolumeScale(pendingActivityVolume / 100.0f); } volumeSettingsApplied_ = true; } } + // Apply saved MSAA setting once when renderer is available + if (!msaaSettingsApplied_ && pendingAntiAliasing > 0) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + static const VkSampleCountFlagBits aaSamples[] = { + VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT, + VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT + }; + renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]); + msaaSettingsApplied_ = true; + } + } else { + msaaSettingsApplied_ = true; + } + + // Apply saved normal mapping / POM settings once when WMO renderer is available + if (!normalMapSettingsApplied_) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + wr->setNormalMapStrength(pendingNormalMapStrength); + wr->setPOMEnabled(pendingPOM); + wr->setPOMQuality(pendingPOMQuality); + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + cr->setNormalMapStrength(pendingNormalMapStrength); + cr->setPOMEnabled(pendingPOM); + cr->setPOMQuality(pendingPOMQuality); + } + normalMapSettingsApplied_ = true; + } + } + } + // Apply auto-loot setting to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); @@ -838,7 +886,7 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (const auto* eq = findComparableEquipped(static_cast(info->inventoryType))) { ImGui::Separator(); ImGui::TextDisabled("Equipped:"); - GLuint eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); + VkDescriptorSet eqIcon = inventoryScreen.getItemIcon(eq->item.displayInfoId); if (eqIcon) { ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); ImGui::SameLine(); @@ -1820,6 +1868,77 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float dz = target->getZ() - movement.z; float distance = std::sqrt(dx*dx + dy*dy + dz*dz); ImGui::TextDisabled("%.1f yd", distance); + + // Target auras (buffs/debuffs) + const auto& targetAuras = gameHandler.getTargetAuras(); + int activeAuras = 0; + for (const auto& a : targetAuras) { + if (!a.isEmpty()) activeAuras++; + } + if (activeAuras > 0) { + auto* assetMgr = core::Application::getInstance().getAssetManager(); + constexpr float ICON_SIZE = 24.0f; + constexpr int ICONS_PER_ROW = 8; + + ImGui::Separator(); + + int shown = 0; + for (size_t i = 0; i < targetAuras.size() && shown < 16; ++i) { + const auto& aura = targetAuras[i]; + if (aura.isEmpty()) continue; + + if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); + + ImGui::PushID(static_cast(10000 + i)); + + bool isBuff = (aura.flags & 0x80) == 0; + ImVec4 auraBorderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); + + VkDescriptorSet iconTex = VK_NULL_HANDLE; + if (assetMgr) { + iconTex = getSpellIcon(aura.spellId, assetMgr); + } + + if (iconTex) { + ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##taura", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(ICON_SIZE - 2, ICON_SIZE - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor); + char label[8]; + snprintf(label, sizeof(label), "%u", aura.spellId); + ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE)); + ImGui::PopStyleColor(); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remaining = aura.getRemainingMs(nowMs); + if (remaining > 0) { + int seconds = remaining / 1000; + if (seconds < 60) { + ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); + } else { + ImGui::SetTooltip("%s (%dm %ds)", name.c_str(), seconds / 60, seconds % 60); + } + } else { + ImGui::SetTooltip("%s", name.c_str()); + } + } + + ImGui::PopID(); + shown++; + } + } } ImGui::End(); @@ -3129,8 +3248,8 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { charRenderer->clearCompositeCache(); // Use per-instance texture override (not model-level) to avoid deleting cached composites. uint32_t instanceId = renderer->getCharacterInstanceId(); - GLuint newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers); - if (newTex != 0 && instanceId != 0) { + auto* newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers); + if (newTex != nullptr && instanceId != 0) { charRenderer->setTextureSlotOverride(instanceId, static_cast(skinSlot), newTex); } @@ -3155,8 +3274,8 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { std::string capeName = displayInfoDbc->getString(static_cast(recIdx), dispL ? (*dispL)["LeftModelTexture"] : 3); if (!capeName.empty()) { std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp"; - GLuint capeTex = charRenderer->loadTexture(capePath); - if (capeTex != 0) { + auto* capeTex = charRenderer->loadTexture(capePath); + if (capeTex != nullptr) { charRenderer->setTextureSlotOverride(instanceId, static_cast(cloakSlot), capeTex); LOG_INFO("Cloak texture applied: ", capePath); } @@ -3176,17 +3295,17 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { auto& app = core::Application::getInstance(); auto* renderer = app.getRenderer(); - auto* assetMgr = app.getAssetManager(); - if (!renderer || !assetMgr) return; + if (!renderer) return; - worldMap.initialize(assetMgr); + auto* wm = renderer->getWorldMap(); + if (!wm) return; // Keep map name in sync with minimap's map name auto* minimap = renderer->getMinimap(); if (minimap) { - worldMap.setMapName(minimap->getMapName()); + wm->setMapName(minimap->getMapName()); } - worldMap.setServerExplorationMask( + wm->setServerExplorationMask( gameHandler.getPlayerExploredZoneMasks(), gameHandler.hasPlayerExploredZoneMasks()); @@ -3194,15 +3313,15 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { auto* window = app.getWindow(); int screenW = window ? window->getWidth() : 1280; int screenH = window ? window->getHeight() : 720; - worldMap.render(playerPos, screenW, screenH); + wm->render(playerPos, screenW, screenH); } // ============================================================ // Action Bar (Phase 3) // ============================================================ -GLuint GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) { - if (spellId == 0 || !am) return 0; +VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) { + if (spellId == 0 || !am) return VK_NULL_HANDLE; // Check cache first auto cit = spellIconCache_.find(spellId); @@ -3260,43 +3379,41 @@ GLuint GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) { // Look up spellId -> SpellIconID -> icon path auto iit = spellIconIds_.find(spellId); if (iit == spellIconIds_.end()) { - spellIconCache_[spellId] = 0; - return 0; + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } auto pit = spellIconPaths_.find(iit->second); if (pit == spellIconPaths_.end()) { - spellIconCache_[spellId] = 0; - return 0; + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } // Path from DBC has no extension β€” append .blp std::string iconPath = pit->second + ".blp"; auto blpData = am->readFile(iconPath); if (blpData.empty()) { - spellIconCache_[spellId] = 0; - return 0; + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } auto image = pipeline::BLPLoader::load(blpData); if (!image.isValid()) { - spellIconCache_[spellId] = 0; - return 0; + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } - GLuint texId = 0; - glGenTextures(1, &texId); - glBindTexture(GL_TEXTURE_2D, texId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glBindTexture(GL_TEXTURE_2D, 0); + // Upload to Vulkan via VkContext + auto* window = core::Application::getInstance().getWindow(); + auto* vkCtx = window ? window->getVkContext() : nullptr; + if (!vkCtx) { + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } - spellIconCache_[spellId] = texId; - return texId; + VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); + spellIconCache_[spellId] = ds; + return ds; } void GameScreen::renderActionBar(game::GameHandler& gameHandler) { @@ -3346,7 +3463,7 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { }; // Try to get icon texture for this slot - GLuint iconTex = 0; + VkDescriptorSet iconTex = VK_NULL_HANDLE; const game::ItemDef* barItemDef = nullptr; uint32_t itemDisplayInfoId = 0; std::string itemNameFromQuery; @@ -3639,22 +3756,17 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { if (!blpData.empty()) { auto image = pipeline::BLPLoader::load(blpData); if (image.isValid()) { - glGenTextures(1, &backpackIconTexture_); - glBindTexture(GL_TEXTURE_2D, backpackIconTexture_); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glBindTexture(GL_TEXTURE_2D, 0); + auto* w = core::Application::getInstance().getWindow(); + auto* vkCtx = w ? w->getVkContext() : nullptr; + if (vkCtx) + backpackIconTexture_ = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); } } } // Track bag slot screen rects for drop detection ImVec2 bagSlotMins[4], bagSlotMaxs[4]; - GLuint bagIcons[4] = {}; + VkDescriptorSet bagIcons[4] = {}; // Slots 1-4: Bag slots (leftmost) for (int i = 0; i < 4; ++i) { @@ -3664,7 +3776,7 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { game::EquipSlot bagSlot = static_cast(static_cast(game::EquipSlot::BAG1) + i); const auto& bagItem = inv.getEquipSlot(bagSlot); - GLuint bagIcon = 0; + VkDescriptorSet bagIcon = VK_NULL_HANDLE; if (!bagItem.empty() && bagItem.item.displayInfoId != 0) { bagIcon = inventoryScreen.getItemIcon(bagItem.item.displayInfoId); } @@ -3828,7 +3940,7 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { auto pickedEquip = static_cast( static_cast(game::EquipSlot::BAG1) + bagBarPickedSlot_); const auto& pickedItem = inv2.getEquipSlot(pickedEquip); - GLuint pickedIcon = 0; + VkDescriptorSet pickedIcon = VK_NULL_HANDLE; if (!pickedItem.empty() && pickedItem.item.displayInfoId != 0) { pickedIcon = inventoryScreen.getItemIcon(pickedItem.item.displayInfoId); } @@ -4413,7 +4525,7 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); // Try to get spell icon - GLuint iconTex = 0; + VkDescriptorSet iconTex = VK_NULL_HANDLE; if (assetMgr) { iconTex = getSpellIcon(aura.spellId, assetMgr); } @@ -4518,7 +4630,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Get item icon uint32_t displayId = item.displayInfoId; if (displayId == 0 && info) displayId = info->displayInfoId; - GLuint iconTex = inventoryScreen.getItemIcon(displayId); + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(displayId); ImVec2 cursor = ImGui::GetCursorScreenPos(); float rowH = std::max(iconSize, ImGui::GetTextLineHeight() * 2.0f); @@ -4947,7 +5059,7 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { bool selected = (selectedChoice == static_cast(i)); // Get item icon if we have displayInfoId - uint32_t iconTex = 0; + VkDescriptorSet iconTex = VK_NULL_HANDLE; if (info && info->valid && info->displayInfoId != 0) { iconTex = inventoryScreen.getItemIcon(info->displayInfoId); } @@ -5781,7 +5893,7 @@ void GameScreen::renderSettingsWindow() { constexpr int kDefaultResH = 1080; constexpr bool kDefaultFullscreen = false; constexpr bool kDefaultVsync = true; - constexpr bool kDefaultShadows = false; + constexpr bool kDefaultShadows = true; constexpr int kDefaultMusicVolume = 30; constexpr float kDefaultMouseSensitivity = 0.2f; constexpr bool kDefaultInvertMouse = false; @@ -5798,12 +5910,13 @@ void GameScreen::renderSettingsWindow() { if (!settingsInit) { pendingFullscreen = window->isFullscreen(); pendingVsync = window->isVsyncEnabled(); - pendingShadows = renderer ? renderer->areShadowsEnabled() : true; if (renderer) { + renderer->setShadowsEnabled(pendingShadows); // Read non-volume settings from actual state (volumes come from saved settings) if (auto* cameraController = renderer->getCameraController()) { pendingMouseSensitivity = cameraController->getMouseSensitivity(); pendingInvertMouse = cameraController->isInvertMouse(); + cameraController->setExtendedZoom(pendingExtendedZoom); } } pendingResIndex = 0; @@ -5865,6 +5978,17 @@ void GameScreen::renderSettingsWindow() { if (renderer) renderer->setShadowsEnabled(pendingShadows); saveSettings(); } + { + const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; + if (ImGui::Combo("Anti-Aliasing", &pendingAntiAliasing, aaLabels, 4)) { + static const VkSampleCountFlagBits aaSamples[] = { + VK_SAMPLE_COUNT_1_BIT, VK_SAMPLE_COUNT_2_BIT, + VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT + }; + if (renderer) renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]); + saveSettings(); + } + } if (ImGui::SliderInt("Ground Clutter Density", &pendingGroundClutterDensity, 0, 150, "%d%%")) { if (renderer) { if (auto* tm = renderer->getTerrainManager()) { @@ -5873,6 +5997,55 @@ void GameScreen::renderSettingsWindow() { } saveSettings(); } + if (ImGui::Checkbox("Normal Mapping", &pendingNormalMapping)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + } + } + saveSettings(); + } + if (pendingNormalMapping) { + if (ImGui::SliderFloat("Normal Map Strength", &pendingNormalMapStrength, 0.0f, 2.0f, "%.1f")) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMapStrength(pendingNormalMapStrength); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMapStrength(pendingNormalMapStrength); + } + } + saveSettings(); + } + } + if (ImGui::Checkbox("Parallax Mapping", &pendingPOM)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setPOMEnabled(pendingPOM); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setPOMEnabled(pendingPOM); + } + } + saveSettings(); + } + if (pendingPOM) { + const char* pomLabels[] = { "Low", "Medium", "High" }; + if (ImGui::Combo("Parallax Quality", &pendingPOMQuality, pomLabels, 3)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setPOMQuality(pendingPOMQuality); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setPOMQuality(pendingPOMQuality); + } + } + saveSettings(); + } + } const char* resLabel = "Resolution"; const char* resItems[kResCount]; @@ -5895,16 +6068,36 @@ void GameScreen::renderSettingsWindow() { pendingVsync = kDefaultVsync; pendingShadows = kDefaultShadows; pendingGroundClutterDensity = kDefaultGroundClutterDensity; + pendingAntiAliasing = 0; + pendingNormalMapping = true; + pendingNormalMapStrength = 0.8f; + pendingPOM = true; + pendingPOMQuality = 1; pendingResIndex = defaultResIndex; window->setFullscreen(pendingFullscreen); window->setVsync(pendingVsync); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); if (renderer) renderer->setShadowsEnabled(pendingShadows); + if (renderer) renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); if (renderer) { if (auto* tm = renderer->getTerrainManager()) { tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); } } + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + wr->setNormalMapStrength(pendingNormalMapStrength); + wr->setPOMEnabled(pendingPOM); + wr->setPOMQuality(pendingPOMQuality); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + cr->setNormalMapStrength(pendingNormalMapStrength); + cr->setPOMEnabled(pendingPOM); + cr->setPOMQuality(pendingPOMQuality); + } + } saveSettings(); } @@ -5922,36 +6115,36 @@ void GameScreen::renderSettingsWindow() { auto applyAudioSettings = [&]() { if (!renderer) return; float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; - audio::AudioEngine::instance().setMasterVolume(soundMuted_ ? 0.0f : masterScale); + audio::AudioEngine::instance().setMasterVolume(masterScale); if (auto* music = renderer->getMusicManager()) { - music->setVolume(static_cast(pendingMusicVolume * masterScale)); + music->setVolume(pendingMusicVolume); } if (auto* ambient = renderer->getAmbientSoundManager()) { - ambient->setVolumeScale(pendingAmbientVolume / 100.0f * masterScale); + ambient->setVolumeScale(pendingAmbientVolume / 100.0f); } if (auto* ui = renderer->getUiSoundManager()) { - ui->setVolumeScale(pendingUiVolume / 100.0f * masterScale); + ui->setVolumeScale(pendingUiVolume / 100.0f); } if (auto* combat = renderer->getCombatSoundManager()) { - combat->setVolumeScale(pendingCombatVolume / 100.0f * masterScale); + combat->setVolumeScale(pendingCombatVolume / 100.0f); } if (auto* spell = renderer->getSpellSoundManager()) { - spell->setVolumeScale(pendingSpellVolume / 100.0f * masterScale); + spell->setVolumeScale(pendingSpellVolume / 100.0f); } if (auto* movement = renderer->getMovementSoundManager()) { - movement->setVolumeScale(pendingMovementVolume / 100.0f * masterScale); + movement->setVolumeScale(pendingMovementVolume / 100.0f); } if (auto* footstep = renderer->getFootstepManager()) { - footstep->setVolumeScale(pendingFootstepVolume / 100.0f * masterScale); + footstep->setVolumeScale(pendingFootstepVolume / 100.0f); } if (auto* npcVoice = renderer->getNpcVoiceManager()) { - npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f * masterScale); + npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f); } if (auto* mount = renderer->getMountSoundManager()) { - mount->setVolumeScale(pendingMountVolume / 100.0f * masterScale); + mount->setVolumeScale(pendingMountVolume / 100.0f); } if (auto* activity = renderer->getActivitySoundManager()) { - activity->setVolumeScale(pendingActivityVolume / 100.0f * masterScale); + activity->setVolumeScale(pendingActivityVolume / 100.0f); } saveSettings(); }; @@ -5962,7 +6155,7 @@ void GameScreen::renderSettingsWindow() { } ImGui::Separator(); - if (ImGui::Checkbox("Original Soundtrack", &pendingUseOriginalSoundtrack)) { + if (ImGui::Checkbox("Enable WoWee Music", &pendingUseOriginalSoundtrack)) { if (renderer) { if (auto* zm = renderer->getZoneManager()) { zm->setUseOriginalSoundtrack(pendingUseOriginalSoundtrack); @@ -5971,7 +6164,7 @@ void GameScreen::renderSettingsWindow() { saveSettings(); } if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Include original music tracks in zone music rotation"); + ImGui::SetTooltip("Include WoWee music tracks in zone music rotation"); ImGui::Separator(); ImGui::Text("Music"); @@ -6083,6 +6276,16 @@ void GameScreen::renderSettingsWindow() { } saveSettings(); } + if (ImGui::Checkbox("Extended Camera Zoom", &pendingExtendedZoom)) { + if (renderer) { + if (auto* cameraController = renderer->getCameraController()) { + cameraController->setExtendedZoom(pendingExtendedZoom); + } + } + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Allow the camera to zoom out further than normal"); ImGui::Spacing(); ImGui::Spacing(); @@ -6162,6 +6365,7 @@ void GameScreen::renderSettingsWindow() { if (ImGui::Button("Restore Gameplay Defaults", ImVec2(-1, 0))) { pendingMouseSensitivity = kDefaultMouseSensitivity; pendingInvertMouse = kDefaultInvertMouse; + pendingExtendedZoom = false; pendingUiOpacity = 65; pendingMinimapRotate = false; pendingMinimapSquare = false; @@ -6176,6 +6380,7 @@ void GameScreen::renderSettingsWindow() { if (auto* cameraController = renderer->getCameraController()) { cameraController->setMouseSensitivity(pendingMouseSensitivity); cameraController->setInvertMouse(pendingInvertMouse); + cameraController->setExtendedZoom(pendingExtendedZoom); } if (auto* minimap = renderer->getMinimap()) { minimap->setRotateWithCamera(minimapRotate_); @@ -6243,6 +6448,60 @@ void GameScreen::renderSettingsWindow() { ImGui::EndTabItem(); } + // ============================================================ + // ABOUT TAB + // ============================================================ + if (ImGui::BeginTabItem("About")) { + ImGui::Spacing(); + ImGui::Spacing(); + + ImGui::TextWrapped("WoWee - World of Warcraft Client Emulator"); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::Text("Developer"); + ImGui::Indent(); + ImGui::Text("Kelsi Davis"); + ImGui::Unindent(); + ImGui::Spacing(); + + ImGui::Text("GitHub"); + ImGui::Indent(); + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "https://github.com/Kelsidavis/WoWee"); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Click to copy"); + } + if (ImGui::IsItemClicked()) { + ImGui::SetClipboardText("https://github.com/Kelsidavis/WoWee"); + } + ImGui::Unindent(); + ImGui::Spacing(); + + ImGui::Text("Contact"); + ImGui::Indent(); + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "github.com/Kelsidavis"); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Click to copy"); + } + if (ImGui::IsItemClicked()) { + ImGui::SetClipboardText("https://github.com/Kelsidavis"); + } + ImGui::Unindent(); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::TextWrapped("A multi-expansion WoW client supporting Classic, TBC, and WotLK (3.3.5a)."); + ImGui::Spacing(); + ImGui::TextDisabled("Built with Vulkan, SDL2, and ImGui"); + + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } @@ -6457,34 +6716,34 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { audio::AudioEngine::instance().setMasterVolume(masterScale); if (!activeRenderer) return; if (auto* music = activeRenderer->getMusicManager()) { - music->setVolume(static_cast(pendingMusicVolume * masterScale)); + music->setVolume(pendingMusicVolume); } if (auto* ambient = activeRenderer->getAmbientSoundManager()) { - ambient->setVolumeScale(pendingAmbientVolume / 100.0f * masterScale); + ambient->setVolumeScale(pendingAmbientVolume / 100.0f); } if (auto* ui = activeRenderer->getUiSoundManager()) { - ui->setVolumeScale(pendingUiVolume / 100.0f * masterScale); + ui->setVolumeScale(pendingUiVolume / 100.0f); } if (auto* combat = activeRenderer->getCombatSoundManager()) { - combat->setVolumeScale(pendingCombatVolume / 100.0f * masterScale); + combat->setVolumeScale(pendingCombatVolume / 100.0f); } if (auto* spell = activeRenderer->getSpellSoundManager()) { - spell->setVolumeScale(pendingSpellVolume / 100.0f * masterScale); + spell->setVolumeScale(pendingSpellVolume / 100.0f); } if (auto* movement = activeRenderer->getMovementSoundManager()) { - movement->setVolumeScale(pendingMovementVolume / 100.0f * masterScale); + movement->setVolumeScale(pendingMovementVolume / 100.0f); } if (auto* footstep = activeRenderer->getFootstepManager()) { - footstep->setVolumeScale(pendingFootstepVolume / 100.0f * masterScale); + footstep->setVolumeScale(pendingFootstepVolume / 100.0f); } if (auto* npcVoice = activeRenderer->getNpcVoiceManager()) { - npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f * masterScale); + npcVoice->setVolumeScale(pendingNpcVoiceVolume / 100.0f); } if (auto* mount = activeRenderer->getMountSoundManager()) { - mount->setVolumeScale(pendingMountVolume / 100.0f * masterScale); + mount->setVolumeScale(pendingMountVolume / 100.0f); } if (auto* activity = activeRenderer->getActivitySoundManager()) { - activity->setVolumeScale(pendingActivityVolume / 100.0f * masterScale); + activity->setVolumeScale(pendingActivityVolume / 100.0f); } }; @@ -6830,10 +7089,17 @@ void GameScreen::saveSettings() { // Gameplay out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; + out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; + out << "antialiasing=" << pendingAntiAliasing << "\n"; + out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; + out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; + out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; + out << "pom_quality=" << pendingPOMQuality << "\n"; // Controls out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n"; + out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; @@ -6907,9 +7173,16 @@ void GameScreen::loadSettings() { // Gameplay else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); + else if (key == "shadows") pendingShadows = (std::stoi(val) != 0); + else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); + else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); + else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); + else if (key == "pom") pendingPOM = (std::stoi(val) != 0); + else if (key == "pom_quality") pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); // Controls else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0); + else if (key == "extended_zoom") pendingExtendedZoom = (std::stoi(val) != 0); // Chat else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 4879f89d..aa48cc40 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1,9 +1,11 @@ #include "ui/inventory_screen.hpp" #include "game/game_handler.hpp" #include "core/application.hpp" +#include "rendering/vk_context.hpp" #include "core/input.hpp" #include "rendering/character_preview.hpp" #include "rendering/character_renderer.hpp" +#include "rendering/renderer.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "pipeline/blp_loader.hpp" @@ -72,10 +74,7 @@ const game::ItemSlot* findComparableEquipped(const game::Inventory& inventory, u } // namespace InventoryScreen::~InventoryScreen() { - // Clean up icon textures - for (auto& [id, tex] : iconCache_) { - if (tex) glDeleteTextures(1, &tex); - } + // Vulkan textures are owned by VkContext and cleaned up on shutdown iconCache_.clear(); } @@ -95,8 +94,8 @@ ImVec4 InventoryScreen::getQualityColor(game::ItemQuality quality) { // Item Icon Loading // ============================================================ -GLuint InventoryScreen::getItemIcon(uint32_t displayInfoId) { - if (displayInfoId == 0 || !assetManager_) return 0; +VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) { + if (displayInfoId == 0 || !assetManager_) return VK_NULL_HANDLE; auto it = iconCache_.find(displayInfoId); if (it != iconCache_.end()) return it->second; @@ -104,50 +103,48 @@ GLuint InventoryScreen::getItemIcon(uint32_t displayInfoId) { // Load ItemDisplayInfo.dbc auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc) { - iconCache_[displayInfoId] = 0; - return 0; + iconCache_[displayInfoId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); if (recIdx < 0) { - iconCache_[displayInfoId] = 0; - return 0; + iconCache_[displayInfoId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } // Field 5 = inventoryIcon_1 const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; std::string iconName = displayInfoDbc->getString(static_cast(recIdx), dispL ? (*dispL)["InventoryIcon"] : 5); if (iconName.empty()) { - iconCache_[displayInfoId] = 0; - return 0; + iconCache_[displayInfoId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } std::string iconPath = "Interface\\Icons\\" + iconName + ".blp"; auto blpData = assetManager_->readFile(iconPath); if (blpData.empty()) { - iconCache_[displayInfoId] = 0; - return 0; + iconCache_[displayInfoId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } auto image = pipeline::BLPLoader::load(blpData); if (!image.isValid()) { - iconCache_[displayInfoId] = 0; - return 0; + iconCache_[displayInfoId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } - GLuint texId = 0; - glGenTextures(1, &texId); - glBindTexture(GL_TEXTURE_2D, texId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glBindTexture(GL_TEXTURE_2D, 0); + // Upload to Vulkan via VkContext + auto* window = core::Application::getInstance().getWindow(); + auto* vkCtx = window ? window->getVkContext() : nullptr; + if (!vkCtx) { + iconCache_[displayInfoId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } - iconCache_[displayInfoId] = texId; - return texId; + VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); + iconCache_[displayInfoId] = ds; + return ds; } // ============================================================ @@ -179,6 +176,8 @@ void InventoryScreen::initPreview() { charPreview_.reset(); return; } + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) renderer->registerPreview(charPreview_.get()); } charPreview_->loadCharacter(playerRace_, playerGender_, @@ -507,7 +506,7 @@ void InventoryScreen::renderHeldItem() { ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qColor); // Try to show icon - GLuint iconTex = getItemIcon(heldItem.displayInfoId); + VkDescriptorSet iconTex = getItemIcon(heldItem.displayInfoId); if (iconTex) { drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos, ImVec2(pos.x + size, pos.y + size)); @@ -929,6 +928,7 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (charPreview_ && previewInitialized_) { charPreview_->update(ImGui::GetIO().DeltaTime); charPreview_->render(); + charPreview_->requestComposite(); } ImGui::SetNextWindowPos(ImVec2(20.0f, 80.0f), ImGuiCond_FirstUseEver); @@ -1124,9 +1124,8 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { // Background for preview area drawList->AddRectFilled(pMin, pMax, IM_COL32(13, 13, 25, 255)); drawList->AddImage( - (ImTextureID)(uintptr_t)charPreview_->getTextureId(), - pMin, pMax, - ImVec2(0, 1), ImVec2(1, 0)); // flip Y for GL + reinterpret_cast(charPreview_->getTextureId()), + pMin, pMax); drawList->AddRect(pMin, pMax, IM_COL32(60, 60, 80, 200)); // Drag-to-rotate: detect mouse drag over the preview image @@ -1351,7 +1350,7 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } // Try to show icon - GLuint iconTex = getItemIcon(item.displayInfoId); + VkDescriptorSet iconTex = getItemIcon(item.displayInfoId); if (iconTex) { drawList->AddImage((ImTextureID)(uintptr_t)iconTex, pos, ImVec2(pos.x + size, pos.y + size)); @@ -1559,7 +1558,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I if (const game::ItemSlot* eq = findComparableEquipped(*inventory, item.inventoryType)) { ImGui::Separator(); ImGui::TextDisabled("Equipped:"); - GLuint eqIcon = getItemIcon(eq->item.displayInfoId); + VkDescriptorSet eqIcon = getItemIcon(eq->item.displayInfoId); if (eqIcon) { ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); ImGui::SameLine(); diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 0cccac44..9fec4524 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -1,6 +1,7 @@ #include "ui/spellbook_screen.hpp" #include "core/input.hpp" #include "core/application.hpp" +#include "rendering/vk_context.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "pipeline/blp_loader.hpp" @@ -201,44 +202,41 @@ void SpellbookScreen::categorizeSpells(const std::unordered_set& known lastKnownSpellCount = knownSpells.size(); } -GLuint SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { - if (iconId == 0 || !assetManager) return 0; +VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { + if (iconId == 0 || !assetManager) return VK_NULL_HANDLE; auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { - spellIconCache[iconId] = 0; - return 0; + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } std::string iconPath = pit->second + ".blp"; auto blpData = assetManager->readFile(iconPath); if (blpData.empty()) { - spellIconCache[iconId] = 0; - return 0; + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } auto image = pipeline::BLPLoader::load(blpData); if (!image.isValid()) { - spellIconCache[iconId] = 0; - return 0; + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } - GLuint texId = 0; - glGenTextures(1, &texId); - glBindTexture(GL_TEXTURE_2D, texId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glBindTexture(GL_TEXTURE_2D, 0); + auto* window = core::Application::getInstance().getWindow(); + auto* vkCtx = window ? window->getVkContext() : nullptr; + if (!vkCtx) { + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } - spellIconCache[iconId] = texId; - return texId; + VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); + spellIconCache[iconId] = ds; + return ds; } const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const { @@ -320,7 +318,7 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana bool isPassive = info->isPassive(); bool isDim = isPassive || onCooldown; - GLuint iconTex = getSpellIcon(info->iconId, assetManager); + VkDescriptorSet iconTex = getSpellIcon(info->iconId, assetManager); // Selectable consumes clicks properly (prevents window drag) ImGui::Selectable("##row", false, diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index 0275433d..a2906695 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -2,11 +2,11 @@ #include "core/input.hpp" #include "core/application.hpp" #include "core/logger.hpp" +#include "rendering/vk_context.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "pipeline/dbc_layout.hpp" #include -#include namespace wowee { namespace ui { @@ -260,7 +260,7 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, // Get spell icon uint32_t spellId = talent.rankSpells[0]; - GLuint iconTex = 0; + VkDescriptorSet iconTex = VK_NULL_HANDLE; if (spellId != 0) { auto it = spellIconIds.find(spellId); if (it != spellIconIds.end()) { @@ -491,47 +491,41 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { LOG_INFO("Talent screen: Loaded ", spellIconPaths.size(), " spell icon paths from SpellIcon.dbc"); } -GLuint TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { - if (iconId == 0 || !assetManager) return 0; +VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { + if (iconId == 0 || !assetManager) return VK_NULL_HANDLE; - // Check cache auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; - // Look up icon path auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { - spellIconCache[iconId] = 0; - return 0; + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } - // Load BLP file std::string iconPath = pit->second + ".blp"; auto blpData = assetManager->readFile(iconPath); if (blpData.empty()) { - spellIconCache[iconId] = 0; - return 0; + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } - // Decode BLP auto image = pipeline::BLPLoader::load(blpData); if (!image.isValid()) { - spellIconCache[iconId] = 0; - return 0; + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; } - // Create OpenGL texture - GLuint texId = 0; - glGenTextures(1, &texId); - glBindTexture(GL_TEXTURE_2D, texId); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, image.width, image.height, 0, - GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glBindTexture(GL_TEXTURE_2D, 0); + auto* window = core::Application::getInstance().getWindow(); + auto* vkCtx = window ? window->getVkContext() : nullptr; + if (!vkCtx) { + spellIconCache[iconId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } - spellIconCache[iconId] = texId; - return texId; + VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); + spellIconCache[iconId] = ds; + return ds; } }} // namespace wowee::ui diff --git a/src/ui/ui_manager.cpp b/src/ui/ui_manager.cpp index ff691070..305bb7de 100644 --- a/src/ui/ui_manager.cpp +++ b/src/ui/ui_manager.cpp @@ -4,9 +4,10 @@ #include "core/logger.hpp" #include "auth/auth_handler.hpp" #include "game/game_handler.hpp" +#include "rendering/vk_context.hpp" #include #include -#include +#include namespace wowee { namespace ui { @@ -26,6 +27,12 @@ bool UIManager::initialize(core::Window* win) { window = win; LOG_INFO("Initializing UI manager"); + auto* vkCtx = window->getVkContext(); + if (!vkCtx) { + LOG_ERROR("No Vulkan context available for ImGui initialization"); + return false; + } + // Initialize ImGui IMGUI_CHECKVERSION(); ImGui::CreateContext(); @@ -56,19 +63,37 @@ bool UIManager::initialize(core::Window* win) { colors[ImGuiCol_HeaderHovered] = ImVec4(0.25f, 0.30f, 0.50f, 0.80f); colors[ImGuiCol_HeaderActive] = ImVec4(0.20f, 0.25f, 0.45f, 1.00f); - // Initialize ImGui for SDL2 and OpenGL3 - ImGui_ImplSDL2_InitForOpenGL(window->getSDLWindow(), window->getGLContext()); - ImGui_ImplOpenGL3_Init("#version 330 core"); + // Initialize ImGui for SDL2 + Vulkan + ImGui_ImplSDL2_InitForVulkan(window->getSDLWindow()); + + ImGui_ImplVulkan_InitInfo initInfo{}; + initInfo.ApiVersion = VK_API_VERSION_1_1; + initInfo.Instance = vkCtx->getInstance(); + initInfo.PhysicalDevice = vkCtx->getPhysicalDevice(); + initInfo.Device = vkCtx->getDevice(); + initInfo.QueueFamily = vkCtx->getGraphicsQueueFamily(); + initInfo.Queue = vkCtx->getGraphicsQueue(); + initInfo.DescriptorPool = vkCtx->getImGuiDescriptorPool(); + initInfo.MinImageCount = 2; + initInfo.ImageCount = vkCtx->getSwapchainImageCount(); + initInfo.PipelineInfoMain.RenderPass = vkCtx->getImGuiRenderPass(); + initInfo.PipelineInfoMain.MSAASamples = vkCtx->getMsaaSamples(); + + ImGui_ImplVulkan_Init(&initInfo); imguiInitialized = true; - LOG_INFO("UI manager initialized successfully"); + LOG_INFO("UI manager initialized successfully (Vulkan)"); return true; } void UIManager::shutdown() { if (imguiInitialized) { - ImGui_ImplOpenGL3_Shutdown(); + auto* vkCtx = window ? window->getVkContext() : nullptr; + if (vkCtx) { + vkDeviceWaitIdle(vkCtx->getDevice()); + } + ImGui_ImplVulkan_Shutdown(); ImGui_ImplSDL2_Shutdown(); ImGui::DestroyContext(); imguiInitialized = false; @@ -80,7 +105,7 @@ void UIManager::update([[maybe_unused]] float deltaTime) { if (!imguiInitialized) return; // Start ImGui frame - ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplVulkan_NewFrame(); ImGui_ImplSDL2_NewFrame(); ImGui::NewFrame(); } @@ -126,7 +151,6 @@ void UIManager::render(core::AppState appState, auth::AuthHandler* authHandler, case core::AppState::DISCONNECTED: authScreen->stopLoginMusic(); - // Show disconnected message ImGui::SetNextWindowSize(ImVec2(400, 150), ImGuiCond_Always); ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f - 200, ImGui::GetIO().DisplaySize.y * 0.5f - 75), @@ -141,9 +165,8 @@ void UIManager::render(core::AppState appState, auth::AuthHandler* authHandler, break; } - // Render ImGui + // Finalize ImGui draw data (actual rendering happens in the command buffer) ImGui::Render(); - ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); } void UIManager::processEvent(const SDL_Event& event) { diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 00000000..60b8639e --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,15 @@ +{ + "name": "wowee", + "version": "1.0.0", + "dependencies": [ + { + "name": "sdl2", + "features": [ "vulkan" ] + }, + "openssl", + "glew", + "glm", + "zlib", + "ffmpeg" + ] +}