diff --git a/assets/shaders/fsr_easu.frag.glsl b/assets/shaders/fsr_easu.frag.glsl new file mode 100644 index 00000000..20e5ed32 --- /dev/null +++ b/assets/shaders/fsr_easu.frag.glsl @@ -0,0 +1,102 @@ +#version 450 +// FSR 1.0 EASU (Edge Adaptive Spatial Upsampling) — Fragment Shader +// Based on AMD FidelityFX Super Resolution 1.0 +// Implements edge-adaptive bilinear upsampling with directional filtering + +layout(set = 0, binding = 0) uniform sampler2D uInput; + +layout(push_constant) uniform FSRConstants { + vec4 con0; // inputSize.xy, 1/inputSize.xy + vec4 con1; // inputSize.xy / outputSize.xy, 0.5 * inputSize.xy / outputSize.xy + vec4 con2; // outputSize.xy, 1/outputSize.xy + vec4 con3; // sharpness, 0, 0, 0 +} fsr; + +layout(location = 0) in vec2 TexCoord; +layout(location = 0) out vec4 outColor; + +// Fetch a texel with offset (in input pixels) +vec3 fsrFetch(vec2 p, vec2 off) { + return textureLod(uInput, (p + off + 0.5) * fsr.con0.zw, 0.0).rgb; +} + +void main() { + // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, + // but we need standard UV coords for texture sampling) + vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + + // Map output pixel to input space + vec2 pp = tc * fsr.con2.xy; // output pixel position + vec2 ip = pp * fsr.con1.xy - 0.5; // input pixel position (centered) + vec2 fp = floor(ip); + vec2 ff = ip - fp; + + // 12-tap filter: 4x3 grid around the pixel + // b c + // e f g h + // i j k l + // n o + vec3 b = fsrFetch(fp, vec2( 0, -1)); + vec3 c = fsrFetch(fp, vec2( 1, -1)); + vec3 e = fsrFetch(fp, vec2(-1, 0)); + vec3 f = fsrFetch(fp, vec2( 0, 0)); + vec3 g = fsrFetch(fp, vec2( 1, 0)); + vec3 h = fsrFetch(fp, vec2( 2, 0)); + vec3 i = fsrFetch(fp, vec2(-1, 1)); + vec3 j = fsrFetch(fp, vec2( 0, 1)); + vec3 k = fsrFetch(fp, vec2( 1, 1)); + vec3 l = fsrFetch(fp, vec2( 2, 1)); + vec3 n = fsrFetch(fp, vec2( 0, 2)); + vec3 o = fsrFetch(fp, vec2( 1, 2)); + + // Luma (use green channel as good perceptual approximation) + float bL = b.g, cL = c.g, eL = e.g, fL = f.g; + float gL = g.g, hL = h.g, iL = i.g, jL = j.g; + float kL = k.g, lL = l.g, nL = n.g, oL = o.g; + + // Directional edge detection + // Compute gradients in 4 directions (N-S, E-W, NE-SW, NW-SE) + float dc = cL - jL; + float db = bL - kL; + float de = eL - hL; + float di = iL - lL; + + // Length of the edge in each direction + float lenH = abs(eL - fL) + abs(fL - gL) + abs(iL - jL) + abs(jL - kL); + float lenV = abs(bL - fL) + abs(fL - jL) + abs(cL - gL) + abs(gL - kL); + + // Determine dominant edge direction + float dirH = lenV / (lenH + lenV + 1e-7); + float dirV = lenH / (lenH + lenV + 1e-7); + + // Bilinear weights + float w1 = (1.0 - ff.x) * (1.0 - ff.y); + float w2 = ff.x * (1.0 - ff.y); + float w3 = (1.0 - ff.x) * ff.y; + float w4 = ff.x * ff.y; + + // Edge-aware sharpening: boost weights along edges + float sharpness = fsr.con3.x; + float edgeStr = max(abs(lenH - lenV) / (lenH + lenV + 1e-7), 0.0); + float sharp = mix(0.0, sharpness, edgeStr); + + // Sharpen bilinear by pulling toward nearest texel + float maxW = max(max(w1, w2), max(w3, w4)); + w1 = mix(w1, float(w1 == maxW), sharp * 0.25); + w2 = mix(w2, float(w2 == maxW), sharp * 0.25); + w3 = mix(w3, float(w3 == maxW), sharp * 0.25); + w4 = mix(w4, float(w4 == maxW), sharp * 0.25); + + // Normalize + float wSum = w1 + w2 + w3 + w4; + w1 /= wSum; w2 /= wSum; w3 /= wSum; w4 /= wSum; + + // Final color: weighted blend of the 4 nearest texels with edge awareness + vec3 color = f * w1 + g * w2 + j * w3 + k * w4; + + // Optional: blend in some of the surrounding texels for anti-aliasing + float aa = 0.125 * edgeStr; + color = mix(color, (b + c + e + h + i + l + n + o) / 8.0, aa * 0.15); + + outColor = vec4(clamp(color, 0.0, 1.0), 1.0); +} diff --git a/assets/shaders/fsr_easu.frag.spv b/assets/shaders/fsr_easu.frag.spv new file mode 100644 index 00000000..5ddc2ea8 Binary files /dev/null and b/assets/shaders/fsr_easu.frag.spv differ diff --git a/assets/shaders/fsr_rcas.frag.glsl b/assets/shaders/fsr_rcas.frag.glsl new file mode 100644 index 00000000..a2d0e599 --- /dev/null +++ b/assets/shaders/fsr_rcas.frag.glsl @@ -0,0 +1,43 @@ +#version 450 +// FSR 1.0 RCAS (Robust Contrast Adaptive Sharpening) — Fragment Shader +// Based on AMD FidelityFX Super Resolution 1.0 +// Applies contrast-adaptive sharpening after EASU upscaling + +layout(set = 0, binding = 0) uniform sampler2D uInput; + +layout(push_constant) uniform RCASConstants { + vec4 con0; // 1/outputSize.xy, outputSize.xy + vec4 con1; // sharpness (x), 0, 0, 0 +} rcas; + +layout(location = 0) in vec2 TexCoord; +layout(location = 0) out vec4 outColor; + +void main() { + // Fetch center and 4-neighborhood + vec2 texelSize = rcas.con0.xy; + vec3 c = texture(uInput, TexCoord).rgb; + vec3 n = texture(uInput, TexCoord + vec2( 0, -texelSize.y)).rgb; + vec3 s = texture(uInput, TexCoord + vec2( 0, texelSize.y)).rgb; + vec3 w = texture(uInput, TexCoord + vec2(-texelSize.x, 0)).rgb; + vec3 e = texture(uInput, TexCoord + vec2( texelSize.x, 0)).rgb; + + // Luma (green channel approximation) + float cL = c.g, nL = n.g, sL = s.g, wL = w.g, eL = e.g; + + // Min/max of neighborhood + float minL = min(min(nL, sL), min(wL, eL)); + float maxL = max(max(nL, sL), max(wL, eL)); + + // Contrast adaptive sharpening weight + // Higher contrast = less sharpening to avoid ringing + float contrast = maxL - minL; + float sharpness = rcas.con1.x; + float w0 = sharpness * (1.0 - smoothstep(0.0, 0.3, contrast)); + + // Apply sharpening: center + w0 * (center - average_neighbors) + vec3 avg = (n + s + w + e) * 0.25; + vec3 sharpened = c + w0 * (c - avg); + + outColor = vec4(clamp(sharpened, 0.0, 1.0), 1.0); +} diff --git a/assets/shaders/fsr_rcas.frag.spv b/assets/shaders/fsr_rcas.frag.spv new file mode 100644 index 00000000..336e7843 Binary files /dev/null and b/assets/shaders/fsr_rcas.frag.spv differ diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index c04e1a93..a4bae057 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -149,21 +149,21 @@ void main() { 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)); + // Linear blend: strength controls how much normal map detail shows, + // LOD fades out at distance. Both multiply for smooth falloff. + float blend = clamp(normalMapStrength, 0.0, 1.0) * (1.0 - lodFactor); + norm = normalize(mix(vertexNormal, worldNormal, blend)); } vec3 result; - // Sample shadow map — skip for interior WMO groups (no sun indoors) + // Sample shadow map for all WMO groups (interior groups with 0x2000 flag + // include covered outdoor areas like archways/streets that should receive shadows) float shadow = 1.0; - if (shadowParams.x > 0.5 && isInterior == 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; diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index 2453f0ff..524dbd1e 100644 Binary files a/assets/shaders/wmo.frag.spv and b/assets/shaders/wmo.frag.spv differ diff --git a/include/core/application.hpp b/include/core/application.hpp index 165d11bb..4d10acc7 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -236,6 +236,11 @@ private: std::optional pendingWorldEntry_; // Deferred world entry during loading float taxiLandingClampTimer_ = 0.0f; float worldEntryMovementGraceTimer_ = 0.0f; + + // Hearth teleport: freeze player until terrain loads at destination + bool hearthTeleportPending_ = false; + glm::vec3 hearthTeleportPos_{0.0f}; // render coords + float hearthTeleportTimer_ = 0.0f; // timeout safety float facingSendCooldown_ = 0.0f; // Rate-limits MSG_MOVE_SET_FACING float lastSentCanonicalYaw_ = 1000.0f; // Sentinel — triggers first send float taxiStreamCooldown_ = 0.0f; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8a3ee441..3af2f59a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -565,6 +565,8 @@ public: void unstuck(); void setUnstuckGyCallback(UnstuckCallback cb) { unstuckGyCallback_ = std::move(cb); } void unstuckGy(); + void setUnstuckHearthCallback(UnstuckCallback cb) { unstuckHearthCallback_ = std::move(cb); } + void unstuckHearth(); using BindPointCallback = std::function; void setBindPointCallback(BindPointCallback cb) { bindPointCallback_ = std::move(cb); } @@ -1445,6 +1447,7 @@ private: WorldEntryCallback worldEntryCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; + UnstuckCallback unstuckHearthCallback_; BindPointCallback bindPointCallback_; CreatureSpawnCallback creatureSpawnCallback_; CreatureDespawnCallback creatureDespawnCallback_; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index c4676008..7a01c0d7 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -66,6 +66,8 @@ public: void update(float deltaTime, const glm::vec3& cameraPos = glm::vec3(0.0f)); + /** Pre-allocate GPU resources (bone SSBOs, descriptors) on main thread before parallel render. */ + void prepareRender(uint32_t frameIndex); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); void recreatePipelines(); bool initializeShadow(VkRenderPass shadowRenderPass); diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 1c35e34b..4b26214f 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -122,6 +122,7 @@ struct M2ModelGPU { bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed) bool isLavaModel = false; // Model name contains lava/molten/magma (UV scroll fallback) bool hasTextureAnimation = false; // True if any batch has UV animation + uint8_t availableLODs = 0; // Bitmask: bit N set if any batch has submeshLevel==N // Particle emitter data (kept from M2Model) std::vector particleEmitters; @@ -193,6 +194,7 @@ struct M2Instance { // Frame-skip optimization (update distant animations less frequently) uint8_t frameSkipCounter = 0; + bool bonesDirty = false; // Set when bones recomputed, cleared after upload // Per-instance bone SSBO (double-buffered) ::VkBuffer boneBuffer[2] = {}; @@ -265,6 +267,8 @@ public: /** * Render all visible instances (Vulkan) */ + /** Pre-allocate GPU resources (bone SSBOs, descriptors) on main thread before parallel render. */ + void prepareRender(uint32_t frameIndex, const Camera& camera); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); /** diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index ab14021c..c7582eea 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -4,10 +4,12 @@ #include #include #include +#include #include #include #include #include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "rendering/sky_system.hpp" namespace wowee { @@ -259,6 +261,14 @@ public: float getShadowDistance() const { return shadowDistance_; } void setMsaaSamples(VkSampleCountFlagBits samples); + // FSR 1.0 (FidelityFX Super Resolution) upscaling + void setFSREnabled(bool enabled); + bool isFSREnabled() const { return fsr_.enabled; } + void setFSRQuality(float scaleFactor); // 0.50=Perf, 0.59=Balanced, 0.67=Quality, 0.77=UltraQuality + void setFSRSharpness(float sharpness); // 0.0 - 2.0 + float getFSRScaleFactor() const { return fsr_.scaleFactor; } + float getFSRSharpness() const { return fsr_.sharpness; } + void setWaterRefractionEnabled(bool enabled); bool isWaterRefractionEnabled() const; @@ -312,7 +322,7 @@ private: VmaAllocation selCircleIdxAlloc = VK_NULL_HANDLE; int selCircleVertCount = 0; void initSelectionCircle(); - void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection); + void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); glm::vec3 selCirclePos{0.0f}; glm::vec3 selCircleColor{1.0f, 0.0f, 0.0f}; float selCircleRadius = 1.5f; @@ -322,7 +332,36 @@ private: VkPipeline overlayPipeline = VK_NULL_HANDLE; VkPipelineLayout overlayPipelineLayout = VK_NULL_HANDLE; void initOverlayPipeline(); - void renderOverlay(const glm::vec4& color); + void renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); + + // FSR 1.0 upscaling state + struct FSRState { + bool enabled = false; + bool needsRecreate = false; + float scaleFactor = 0.77f; // Ultra Quality default + float sharpness = 0.5f; + uint32_t internalWidth = 0; + uint32_t internalHeight = 0; + + // Off-screen scene target (reduced resolution) + AllocatedImage sceneColor{}; // 1x color (non-MSAA render target / MSAA resolve target) + AllocatedImage sceneDepth{}; // Depth (matches current MSAA sample count) + AllocatedImage sceneMsaaColor{}; // MSAA color target (only when MSAA > 1x) + AllocatedImage sceneDepthResolve{}; // Depth resolve (only when MSAA + depth resolve) + VkFramebuffer sceneFramebuffer = VK_NULL_HANDLE; + VkSampler sceneSampler = VK_NULL_HANDLE; + + // Upscale pipeline + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + VkDescriptorSetLayout descSetLayout = VK_NULL_HANDLE; + VkDescriptorPool descPool = VK_NULL_HANDLE; + VkDescriptorSet descSet = VK_NULL_HANDLE; + }; + FSRState fsr_; + bool initFSRResources(); + void destroyFSRResources(); + void renderFSRUpscale(); // Footstep event tracking (animation-driven) uint32_t footstepLastAnimationId = 0; @@ -411,6 +450,36 @@ private: void setupWater1xPass(); void renderReflectionPass(); + // ── Multithreaded secondary command buffer recording ── + // Indices into secondaryCmds_ arrays + static constexpr uint32_t SEC_SKY = 0; // sky (main thread) + static constexpr uint32_t SEC_TERRAIN = 1; // terrain (worker 0) + static constexpr uint32_t SEC_WMO = 2; // WMO (worker 1) + static constexpr uint32_t SEC_CHARS = 3; // selection circle + characters (main thread) + static constexpr uint32_t SEC_M2 = 4; // M2 + particles + glow (worker 2) + static constexpr uint32_t SEC_POST = 5; // water + weather + effects (main thread) + static constexpr uint32_t SEC_IMGUI = 6; // ImGui (main thread, non-FSR only) + static constexpr uint32_t NUM_SECONDARIES = 7; + static constexpr uint32_t NUM_WORKERS = 3; // terrain, WMO, M2 + + // Per-worker command pools (thread-safe: one pool per thread) + VkCommandPool workerCmdPools_[NUM_WORKERS] = {}; + // Main-thread command pool for its secondary buffers + VkCommandPool mainSecondaryCmdPool_ = VK_NULL_HANDLE; + // Pre-allocated secondary command buffers [secondaryIndex][frameInFlight] + VkCommandBuffer secondaryCmds_[NUM_SECONDARIES][MAX_FRAMES] = {}; + + bool parallelRecordingEnabled_ = false; // set true after pools/buffers created + bool createSecondaryCommandResources(); + void destroySecondaryCommandResources(); + VkCommandBuffer beginSecondary(uint32_t secondaryIndex); + void setSecondaryViewportScissor(VkCommandBuffer cmd); + + // Cached render pass state for secondary buffer inheritance + VkRenderPass activeRenderPass_ = VK_NULL_HANDLE; + VkFramebuffer activeFramebuffer_ = VK_NULL_HANDLE; + VkExtent2D activeRenderExtent_ = {0, 0}; + // Active character previews for off-screen rendering std::vector activePreviews_; diff --git a/include/rendering/vk_context.hpp b/include/rendering/vk_context.hpp index 907e21bf..154a4f98 100644 --- a/include/rendering/vk_context.hpp +++ b/include/rendering/vk_context.hpp @@ -84,6 +84,10 @@ public: bool isSwapchainDirty() const { return swapchainDirty; } void markSwapchainDirty() { swapchainDirty = true; } + // VSync (present mode) + bool isVsyncEnabled() const { return vsync_; } + void setVsync(bool enabled) { vsync_ = enabled; } + bool isDeviceLost() const { return deviceLost_; } // MSAA @@ -145,6 +149,7 @@ private: std::vector swapchainFramebuffers; bool swapchainDirty = false; bool deviceLost_ = false; + bool vsync_ = true; // Per-frame resources FrameData frames[MAX_FRAMES_IN_FLIGHT]; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index f0d3b36f..b8be9485 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -148,6 +148,8 @@ public: * @param perFrameSet Per-frame descriptor set (set 0) * @param camera Camera for frustum culling */ + /** Pre-update mutable state (frame ID, material UBOs) on main thread before parallel render. */ + void prepareRender(); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); /** @@ -332,6 +334,9 @@ public: // Defer normal/height map generation during streaming to avoid CPU stalls void setDeferNormalMaps(bool defer) { deferNormalMaps_ = defer; } + // Generate normal/height maps for cached textures that were loaded while deferred + void backfillNormalMaps(); + private: // WMO material UBO — matches WMOMaterial in wmo.frag.glsl struct WMOMaterialUBO { @@ -720,6 +725,8 @@ private: uint32_t distanceCulled = 0; }; std::vector> cullFutures_; + std::vector visibleInstances_; // reused per frame + std::vector drawLists_; // reused per frame // Collision query profiling (per frame). mutable double queryTimeMs = 0.0; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 7e428523..bf7558cd 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -116,6 +116,10 @@ private: 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) + bool pendingFSR = false; + int pendingFSRQuality = 0; // 0=UltraQuality, 1=Quality, 2=Balanced, 3=Performance + float pendingFSRSharpness = 0.5f; + bool fsrSettingsApplied_ = false; // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) float uiOpacity_ = 0.65f; diff --git a/src/core/application.cpp b/src/core/application.cpp index c1907a15..f9ac557c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1015,14 +1015,33 @@ void Application::update(float deltaTime) { if (renderer && renderer->getCameraController()) renderer->getCameraController()->clearMovementInputs(); } + // Hearth teleport: keep player frozen until terrain loads at destination + if (hearthTeleportPending_ && renderer && renderer->getTerrainManager()) { + hearthTeleportTimer_ -= deltaTime; + auto terrainH = renderer->getTerrainManager()->getHeightAt( + hearthTeleportPos_.x, hearthTeleportPos_.y); + if (terrainH || hearthTeleportTimer_ <= 0.0f) { + // Terrain loaded (or timeout) — snap to floor and release + if (terrainH) { + hearthTeleportPos_.z = *terrainH + 0.5f; + renderer->getCameraController()->teleportTo(hearthTeleportPos_); + } + renderer->getCameraController()->setExternalFollow(false); + worldEntryMovementGraceTimer_ = 1.0f; + hearthTeleportPending_ = false; + LOG_INFO("Unstuck hearth: terrain loaded, player released", + terrainH ? "" : " (timeout)"); + } + } if (renderer && renderer->getCameraController()) { const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_; // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. + bool hearthFreeze = hearthTeleportPending_; bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f && worldEntryMovementGraceTimer_ <= 0.0f && !gameHandler->isMounted(); - renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive); + renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive || hearthFreeze); renderer->getCameraController()->setExternalMoving(externallyDrivenMotion); if (externallyDrivenMotion) { // Drop any stale local movement toggles while server drives taxi motion. @@ -1877,9 +1896,43 @@ void Application::setupUICallbacks() { LOG_INFO("Unstuck: high fallback snap"); }); + // /unstuckhearth — teleport to hearthstone bind point (server-synced). + // Freezes player until terrain loads at destination to prevent falling through world. + gameHandler->setUnstuckHearthCallback([this, clearStuckMovement, forceServerTeleportCommand]() { + if (!renderer || !renderer->getCameraController() || !gameHandler) return; + + uint32_t bindMap = 0; + glm::vec3 bindPos(0.0f); + if (!gameHandler->getHomeBind(bindMap, bindPos)) { + LOG_WARNING("Unstuck hearth: no bind point available"); + return; + } + + worldEntryMovementGraceTimer_ = 10.0f; // long grace — terrain load check will clear it + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + clearStuckMovement(); + + auto* cc = renderer->getCameraController(); + glm::vec3 renderPos = core::coords::canonicalToRender(bindPos); + renderPos.z += 2.0f; + + // Freeze player in place (no gravity/movement) until terrain loads + cc->teleportTo(renderPos); + cc->setExternalFollow(true); + forceServerTeleportCommand(renderPos); + clearStuckMovement(); + + // Set pending state — update loop will unfreeze once terrain is loaded + hearthTeleportPending_ = true; + hearthTeleportPos_ = renderPos; + hearthTeleportTimer_ = 15.0f; // 15s safety timeout + LOG_INFO("Unstuck hearth: teleporting to bind point, waiting for terrain..."); + }); + // Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry if (renderer->getCameraController()) { - renderer->getCameraController()->setAutoUnstuckCallback([this]() { + renderer->getCameraController()->setAutoUnstuckCallback([this, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController()) return; auto* cc = renderer->getCameraController(); @@ -1887,7 +1940,8 @@ void Application::setupUICallbacks() { glm::vec3 spawnPos = cc->getDefaultPosition(); spawnPos.z += 5.0f; cc->teleportTo(spawnPos); - LOG_INFO("Auto-unstuck: teleported to map entry point"); + forceServerTeleportCommand(spawnPos); + LOG_INFO("Auto-unstuck: teleported to map entry point (server synced)"); }); } diff --git a/src/core/window.cpp b/src/core/window.cpp index eed83c97..9f74a81c 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -84,6 +84,7 @@ bool Window::initialize() { // Initialize Vulkan context vkContext = std::make_unique(); + vkContext->setVsync(vsync); if (!vkContext->initialize(window)) { LOG_ERROR("Failed to initialize Vulkan context"); return false; @@ -158,11 +159,13 @@ void Window::setFullscreen(bool enable) { } } -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 +void Window::setVsync(bool enable) { vsync = enable; - LOG_INFO("VSync preference set to ", enable ? "on" : "off", " (applied on swapchain recreation)"); + if (vkContext) { + vkContext->setVsync(enable); + vkContext->markSwapchainDirty(); + } + LOG_INFO("VSync ", enable ? "enabled" : "disabled"); } void Window::applyResolution(int w, int h) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9a7aed97..3cd05d3c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11435,6 +11435,15 @@ void GameHandler::unstuckGy() { } } +void GameHandler::unstuckHearth() { + if (unstuckHearthCallback_) { + unstuckHearthCallback_(); + addSystemChatMessage("Unstuck: teleported to hearthstone location."); + } else { + addSystemChatMessage("No hearthstone bind point set."); + } +} + void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot)) return; lootWindowOpen = true; diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 9607f755..f69ae75c 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1924,6 +1924,61 @@ glm::mat4 CharacterRenderer::getBoneTransform(const pipeline::M2Bone& bone, floa // --- Rendering --- +void CharacterRenderer::prepareRender(uint32_t frameIndex) { + if (instances.empty() || !opaquePipeline_) return; + + // Pre-allocate bone SSBOs + descriptor sets on main thread (pool ops not thread-safe) + for (auto& [id, instance] : instances) { + int numBones = std::min(static_cast(instance.boneMatrices.size()), MAX_BONES); + if (numBones <= 0) continue; + + if (!instance.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, + &instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo); + instance.boneMapped[frameIndex] = allocInfo.pMappedData; + + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = boneDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &boneSetLayout_; + VkResult dsRes = vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &instance.boneSet[frameIndex]); + if (dsRes != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer::prepareRender: bone descriptor alloc failed (instance=", + id, ", frame=", frameIndex, ", vk=", static_cast(dsRes), ")"); + if (instance.boneBuffer[frameIndex]) { + vmaDestroyBuffer(vkCtx_->getAllocator(), + instance.boneBuffer[frameIndex], instance.boneAlloc[frameIndex]); + instance.boneBuffer[frameIndex] = VK_NULL_HANDLE; + instance.boneAlloc[frameIndex] = VK_NULL_HANDLE; + instance.boneMapped[frameIndex] = nullptr; + } + continue; + } + + 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); + } + } + } +} + void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, [[maybe_unused]] const Camera& camera) { if (instances.empty() || !opaquePipeline_) { return; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index d455e494..3a097217 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1602,6 +1602,12 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Pre-compute available LOD levels to avoid per-instance batch iteration + gpuModel.availableLODs = 0; + for (const auto& b : gpuModel.batches) { + if (b.submeshLevel < 8) gpuModel.availableLODs |= (1u << b.submeshLevel); + } + models[modelId] = std::move(gpuModel); LOG_DEBUG("Loaded M2 model: ", model.name, " (", models[modelId].vertexCount, " vertices, ", @@ -1911,6 +1917,7 @@ static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { instance.boneMatrices[i] = local; } } + instance.bonesDirty = true; } void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { @@ -2172,6 +2179,48 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: } +void M2Renderer::prepareRender(uint32_t frameIndex, const Camera& camera) { + if (!initialized_ || instances.empty()) return; + (void)camera; // reserved for future frustum-based culling + + // Pre-allocate bone SSBOs + descriptor sets on main thread (pool ops not thread-safe). + // Only iterate animated instances — static doodads don't need bone buffers. + for (size_t idx : animatedInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& instance = instances[idx]; + + if (instance.boneMatrices.empty()) continue; + + if (!instance.boneBuffer[frameIndex]) { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = 128 * 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, + &instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo); + instance.boneMapped[frameIndex] = allocInfo.pMappedData; + + 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); + } + } + } +} + void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { if (instances.empty() || !opaquePipeline_) { return; @@ -2254,8 +2303,8 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } // 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; }); + std::sort(sortedVisible_.begin(), sortedVisible_.end(), + [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); uint32_t currentModelId = UINT32_MAX; const M2ModelGPU* currentModel = nullptr; @@ -2330,44 +2379,22 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } - // Upload bone matrices to SSBO if model has skeletal animation - bool useBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); + // Upload bone matrices to SSBO if model has skeletal animation. + // Bone buffers are pre-allocated by prepareRender() on the main thread. + // If not yet allocated (race/timing), skip this instance entirely to avoid + // a bind-pose flash — it will render correctly next frame. + bool needsBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); + if (needsBones && (!instance.boneBuffer[frameIndex] || !instance.boneSet[frameIndex])) { + continue; + } + bool useBones = needsBones; if (useBones) { - // 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; - - // 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]) { + // Upload bone matrices only when recomputed (skip frame-skipped instances) + if (instance.bonesDirty && instance.boneMapped[frameIndex]) { int numBones = std::min(static_cast(instance.boneMatrices.size()), 128); memcpy(instance.boneMapped[frameIndex], instance.boneMatrices.data(), numBones * sizeof(glm::mat4)); + instance.bonesDirty = false; } // Bind bone descriptor set (set 2) @@ -2384,12 +2411,8 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1; 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; + if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) { + targetLOD = 0; } const bool foliageLikeModel = model.isFoliageLike; diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index d939f4f9..86dc2f21 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -1,5 +1,6 @@ #include "rendering/performance_hud.hpp" #include "rendering/renderer.hpp" +#include "rendering/vk_context.hpp" #include "rendering/terrain_renderer.hpp" #include "rendering/terrain_manager.hpp" #include "rendering/water_renderer.hpp" @@ -187,6 +188,19 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { 0, nullptr, 0.0f, 33.33f, ImVec2(200, 40)); } + // FSR info + if (renderer->isFSREnabled()) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "FSR 1.0: ON"); + auto* ctx = renderer->getVkContext(); + if (ctx) { + auto ext = ctx->getSwapchainExtent(); + float sf = renderer->getFSRScaleFactor(); + uint32_t iw = static_cast(ext.width * sf) & ~1u; + uint32_t ih = static_cast(ext.height * sf) & ~1u; + ImGui::Text(" %ux%u -> %ux%u (%.0f%%)", iw, ih, ext.width, ext.height, sf * 100.0f); + } + } + ImGui::Spacing(); } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index d487e05e..9f3d65e7 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -721,11 +721,18 @@ bool Renderer::initialize(core::Window* win) { // TODO Phase 6: Vulkan underwater overlay, post-process, and shadow map // GL versions stubbed during migration + // Create secondary command buffer resources for multithreaded rendering + if (!createSecondaryCommandResources()) { + LOG_WARNING("Failed to create secondary command buffers — falling back to single-threaded rendering"); + } + LOG_INFO("Renderer initialized"); return true; } void Renderer::shutdown() { + destroySecondaryCommandResources(); + LOG_WARNING("Renderer::shutdown - terrainManager stopWorkers..."); if (terrainManager) { terrainManager->stopWorkers(); @@ -828,6 +835,7 @@ void Renderer::shutdown() { if (overlayPipelineLayout) { vkDestroyPipelineLayout(device, overlayPipelineLayout, nullptr); overlayPipelineLayout = VK_NULL_HANDLE; } } + destroyFSRResources(); destroyPerFrameResources(); zoneManager.reset(); @@ -901,12 +909,7 @@ void Renderer::applyMsaaChange() { if (terrainRenderer) terrainRenderer->recreatePipelines(); if (waterRenderer) { waterRenderer->recreatePipelines(); - if (vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { - waterRenderer->destroyWater1xResources(); - setupWater1xPass(); - } else { - waterRenderer->destroyWater1xResources(); - } + waterRenderer->destroyWater1xResources(); // no longer used } if (wmoRenderer) wmoRenderer->recreatePipelines(); if (m2Renderer) m2Renderer->recreatePipelines(); @@ -928,10 +931,11 @@ void Renderer::applyMsaaChange() { if (minimap) minimap->recreatePipelines(); - // Selection circle + overlay use lazy init, just destroy them + // Selection circle + overlay + FSR 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; } + if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() // Reinitialize ImGui Vulkan backend with new MSAA sample count ImGui_ImplVulkan_Shutdown(); @@ -961,17 +965,30 @@ void Renderer::beginFrame() { applyMsaaChange(); } + // FSR resource management (safe: between frames, no command buffer in flight) + if (fsr_.needsRecreate && fsr_.sceneFramebuffer) { + destroyFSRResources(); + fsr_.needsRecreate = false; + if (!fsr_.enabled) LOG_INFO("FSR: disabled"); + } + if (fsr_.enabled && !fsr_.sceneFramebuffer) { + if (!initFSRResources()) { + LOG_ERROR("FSR: initialization failed, disabling"); + fsr_.enabled = false; + } + } + // 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(); - } + } + // Recreate FSR resources for new swapchain dimensions + if (fsr_.enabled) { + destroyFSRResources(); + initFSRResources(); } } @@ -1018,47 +1035,131 @@ void Renderer::beginFrame() { renderReflectionPass(); } // !skipPrePasses - // --- Begin main render pass (clear color + depth) --- + // --- Begin render pass --- + // If FSR is enabled, render scene to off-screen target at reduced resolution. + // Otherwise, render directly to swapchain. 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]{}; + VkExtent2D renderExtent; + if (fsr_.enabled && fsr_.sceneFramebuffer) { + rpInfo.framebuffer = fsr_.sceneFramebuffer; + renderExtent = { fsr_.internalWidth, fsr_.internalHeight }; + } else { + rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + renderExtent = vkCtx->getSwapchainExtent(); + } + + rpInfo.renderArea.offset = {0, 0}; + rpInfo.renderArea.extent = renderExtent; + + // Clear values must match attachment count: 2 (no MSAA), 3 (MSAA), or 4 (MSAA+depth resolve) + VkClearValue clearValues[4]{}; 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) + clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + clearValues[3].depthStencil = {1.0f, 0}; bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); - rpInfo.clearValueCount = msaaOn ? 3 : 2; + if (msaaOn) { + bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + rpInfo.clearValueCount = depthRes ? 4 : 3; + } else { + rpInfo.clearValueCount = 2; + } rpInfo.pClearValues = clearValues; - vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + // Cache render pass state for secondary command buffer inheritance + activeRenderPass_ = rpInfo.renderPass; + activeFramebuffer_ = rpInfo.framebuffer; + activeRenderExtent_ = renderExtent; - // 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); + VkSubpassContents subpassMode = parallelRecordingEnabled_ + ? VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS + : VK_SUBPASS_CONTENTS_INLINE; + vkCmdBeginRenderPass(currentCmd, &rpInfo, subpassMode); - VkRect2D scissor{}; - scissor.offset = {0, 0}; - scissor.extent = extent; - vkCmdSetScissor(currentCmd, 0, 1, &scissor); + if (!parallelRecordingEnabled_) { + // Fallback: set dynamic viewport and scissor on primary (inline mode) + VkViewport viewport{}; + viewport.width = static_cast(renderExtent.width); + viewport.height = static_cast(renderExtent.height); + viewport.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &viewport); + + VkRect2D scissor{}; + scissor.extent = renderExtent; + vkCmdSetScissor(currentCmd, 0, 1, &scissor); + } } void Renderer::endFrame() { 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); + if (fsr_.enabled && fsr_.sceneFramebuffer) { + // End the off-screen scene render pass + vkCmdEndRenderPass(currentCmd); + + // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY + // The render pass finalLayout puts the resolve/color attachment in PRESENT_SRC_KHR + transitionImageLayout(currentCmd, fsr_.sceneColor.image, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + + // Begin swapchain render pass at full resolution + 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(); + + // Clear values must match the render pass attachment count + bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); + VkClearValue clearValues[4]{}; + 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}}; + clearValues[3].depthStencil = {1.0f, 0}; + if (msaaOn) { + bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + rpInfo.clearValueCount = depthRes ? 4 : 3; + } else { + rpInfo.clearValueCount = 2; + } + rpInfo.pClearValues = clearValues; + + vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + + // Set full-resolution viewport and scissor + VkExtent2D ext = vkCtx->getSwapchainExtent(); + VkViewport vp{}; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &vp); + VkRect2D sc{}; + sc.extent = ext; + vkCmdSetScissor(currentCmd, 0, 1, &sc); + + // Draw FSR upscale fullscreen quad + renderFSRUpscale(); + } + + // ImGui rendering — must respect subpass contents mode + if (!fsr_.enabled && parallelRecordingEnabled_) { + // Scene pass was begun with VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS, + // so ImGui must be recorded into a secondary command buffer. + VkCommandBuffer imguiCmd = beginSecondary(SEC_IMGUI); + setSecondaryViewportScissor(imguiCmd); + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), imguiCmd); + vkEndCommandBuffer(imguiCmd); + vkCmdExecuteCommands(currentCmd, 1, &imguiCmd); + } else { + // FSR swapchain pass uses INLINE mode; non-parallel also uses INLINE. + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), currentCmd); + } vkCmdEndRenderPass(currentCmd); @@ -1076,16 +1177,7 @@ void Renderer::endFrame() { frame); } - // Render water in separate 1x pass after MSAA resolve + scene capture - bool waterDeferred = waterRenderer && waterRenderer->hasSurfaces() && waterRenderer->hasWater1xPass() - && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT; - if (waterDeferred && camera) { - VkExtent2D ext = vkCtx->getSwapchainExtent(); - if (waterRenderer->beginWater1xPass(currentCmd, currentImageIndex, ext)) { - waterRenderer->render(currentCmd, perFrameDescSets[frame], *camera, globalTime, true, frame); - waterRenderer->endWater1xPass(currentCmd); - } - } + // Water now renders in the main pass (renderWorld), no separate 1x pass needed. // Submit and present vkCtx->endFrame(currentCmd, currentImageIndex); @@ -3097,10 +3189,11 @@ void Renderer::clearSelectionCircle() { selCircleVisible = false; } -void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection) { +void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, VkCommandBuffer overrideCmd) { if (!selCircleVisible) return; initSelectionCircle(); - if (selCirclePipeline == VK_NULL_HANDLE || currentCmd == VK_NULL_HANDLE) return; + VkCommandBuffer cmd = (overrideCmd != VK_NULL_HANDLE) ? overrideCmd : currentCmd; + if (selCirclePipeline == VK_NULL_HANDLE || cmd == 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. @@ -3132,19 +3225,19 @@ void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& pro glm::mat4 mvp = projection * view * model; glm::vec4 color4(selCircleColor, 1.0f); - vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, selCirclePipeline); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, selCirclePipeline); VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(currentCmd, 0, 1, &selCircleVertBuf, &offset); - vkCmdBindIndexBuffer(currentCmd, selCircleIdxBuf, 0, VK_INDEX_TYPE_UINT16); + vkCmdBindVertexBuffers(cmd, 0, 1, &selCircleVertBuf, &offset); + vkCmdBindIndexBuffer(cmd, selCircleIdxBuf, 0, VK_INDEX_TYPE_UINT16); // Push mvp (64 bytes) at offset 0 - vkCmdPushConstants(currentCmd, selCirclePipelineLayout, + vkCmdPushConstants(cmd, 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, + vkCmdPushConstants(cmd, selCirclePipelineLayout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 64, 16, &color4[0]); - vkCmdDrawIndexed(currentCmd, static_cast(selCircleVertCount), 1, 0, 0, 0); + vkCmdDrawIndexed(cmd, static_cast(selCircleVertCount), 1, 0, 0, 0); } // ────────────────────────────────────────────────────────────── @@ -3194,15 +3287,305 @@ void Renderer::initOverlayPipeline() { if (overlayPipeline) LOG_INFO("Renderer: overlay pipeline initialized"); } -void Renderer::renderOverlay(const glm::vec4& color) { +void Renderer::renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd) { if (!overlayPipeline) initOverlayPipeline(); - if (!overlayPipeline || currentCmd == VK_NULL_HANDLE) return; - vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline); - vkCmdPushConstants(currentCmd, overlayPipelineLayout, + VkCommandBuffer cmd = (overrideCmd != VK_NULL_HANDLE) ? overrideCmd : currentCmd; + if (!overlayPipeline || cmd == VK_NULL_HANDLE) return; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline); + vkCmdPushConstants(cmd, overlayPipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &color[0]); - vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle + vkCmdDraw(cmd, 3, 1, 0, 0); // fullscreen triangle } +// ========================= FSR 1.0 Upscaling ========================= + +bool Renderer::initFSRResources() { + if (!vkCtx) return false; + + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + VkExtent2D swapExtent = vkCtx->getSwapchainExtent(); + VkSampleCountFlagBits msaa = vkCtx->getMsaaSamples(); + bool useMsaa = (msaa > VK_SAMPLE_COUNT_1_BIT); + bool useDepthResolve = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + + fsr_.internalWidth = static_cast(swapExtent.width * fsr_.scaleFactor); + fsr_.internalHeight = static_cast(swapExtent.height * fsr_.scaleFactor); + fsr_.internalWidth = (fsr_.internalWidth + 1) & ~1u; + fsr_.internalHeight = (fsr_.internalHeight + 1) & ~1u; + + LOG_INFO("FSR: initializing at ", fsr_.internalWidth, "x", fsr_.internalHeight, + " -> ", swapExtent.width, "x", swapExtent.height, + " (scale=", fsr_.scaleFactor, ", MSAA=", static_cast(msaa), "x)"); + + VkFormat colorFmt = vkCtx->getSwapchainFormat(); + VkFormat depthFmt = vkCtx->getDepthFormat(); + + // sceneColor: always 1x, always sampled — this is what FSR reads + // Non-MSAA: direct render target. MSAA: resolve target. + fsr_.sceneColor = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + if (!fsr_.sceneColor.image) { + LOG_ERROR("FSR: failed to create scene color image"); + return false; + } + + // sceneDepth: matches current MSAA sample count + fsr_.sceneDepth = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, msaa); + if (!fsr_.sceneDepth.image) { + LOG_ERROR("FSR: failed to create scene depth image"); + destroyFSRResources(); + return false; + } + + if (useMsaa) { + // sceneMsaaColor: multisampled color target + fsr_.sceneMsaaColor = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, msaa); + if (!fsr_.sceneMsaaColor.image) { + LOG_ERROR("FSR: failed to create MSAA color image"); + destroyFSRResources(); + return false; + } + + if (useDepthResolve) { + fsr_.sceneDepthResolve = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT); + if (!fsr_.sceneDepthResolve.image) { + LOG_ERROR("FSR: failed to create depth resolve image"); + destroyFSRResources(); + return false; + } + } + } + + // Build framebuffer matching the main render pass attachment layout: + // Non-MSAA: [color, depth] + // MSAA (no depth res): [msaaColor, depth, resolve] + // MSAA (depth res): [msaaColor, depth, resolve, depthResolve] + VkImageView fbAttachments[4]{}; + uint32_t fbCount; + if (useMsaa) { + fbAttachments[0] = fsr_.sceneMsaaColor.imageView; + fbAttachments[1] = fsr_.sceneDepth.imageView; + fbAttachments[2] = fsr_.sceneColor.imageView; // resolve target + fbCount = 3; + if (useDepthResolve) { + fbAttachments[3] = fsr_.sceneDepthResolve.imageView; + fbCount = 4; + } + } else { + fbAttachments[0] = fsr_.sceneColor.imageView; + fbAttachments[1] = fsr_.sceneDepth.imageView; + fbCount = 2; + } + + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = vkCtx->getImGuiRenderPass(); + fbInfo.attachmentCount = fbCount; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = fsr_.internalWidth; + fbInfo.height = fsr_.internalHeight; + fbInfo.layers = 1; + + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &fsr_.sceneFramebuffer) != VK_SUCCESS) { + LOG_ERROR("FSR: failed to create scene framebuffer"); + destroyFSRResources(); + return false; + } + + // Sampler for the resolved scene color + 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; + if (vkCreateSampler(device, &samplerInfo, nullptr, &fsr_.sceneSampler) != VK_SUCCESS) { + LOG_ERROR("FSR: failed to create sampler"); + destroyFSRResources(); + return false; + } + + // Descriptor set layout: binding 0 = combined image sampler + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = 1; + layoutInfo.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &fsr_.descSetLayout); + + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = 1; + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = 1; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + vkCreateDescriptorPool(device, &poolInfo, nullptr, &fsr_.descPool); + + VkDescriptorSetAllocateInfo dsAllocInfo{}; + dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + dsAllocInfo.descriptorPool = fsr_.descPool; + dsAllocInfo.descriptorSetCount = 1; + dsAllocInfo.pSetLayouts = &fsr_.descSetLayout; + vkAllocateDescriptorSets(device, &dsAllocInfo, &fsr_.descSet); + + // Always bind the 1x sceneColor (FSR reads the resolved image) + VkDescriptorImageInfo imgInfo{}; + imgInfo.sampler = fsr_.sceneSampler; + imgInfo.imageView = fsr_.sceneColor.imageView; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fsr_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + + // Pipeline layout + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pc.offset = 0; + pc.size = 64; + VkPipelineLayoutCreateInfo plCI{}; + plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plCI.setLayoutCount = 1; + plCI.pSetLayouts = &fsr_.descSetLayout; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + vkCreatePipelineLayout(device, &plCI, nullptr, &fsr_.pipelineLayout); + + // Load shaders + VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || + !fragMod.loadFromFile(device, "assets/shaders/fsr_easu.frag.spv")) { + LOG_ERROR("FSR: failed to load shaders"); + destroyFSRResources(); + return false; + } + + // FSR upscale pipeline renders into the swapchain pass at full resolution + // Must match swapchain pass MSAA setting + fsr_.pipeline = PipelineBuilder() + .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(msaa) + .setLayout(fsr_.pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertMod.destroy(); + fragMod.destroy(); + + if (!fsr_.pipeline) { + LOG_ERROR("FSR: failed to create upscale pipeline"); + destroyFSRResources(); + return false; + } + + LOG_INFO("FSR: initialized successfully"); + return true; +} + +void Renderer::destroyFSRResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + + vkDeviceWaitIdle(device); + + if (fsr_.pipeline) { vkDestroyPipeline(device, fsr_.pipeline, nullptr); fsr_.pipeline = VK_NULL_HANDLE; } + if (fsr_.pipelineLayout) { vkDestroyPipelineLayout(device, fsr_.pipelineLayout, nullptr); fsr_.pipelineLayout = VK_NULL_HANDLE; } + if (fsr_.descPool) { vkDestroyDescriptorPool(device, fsr_.descPool, nullptr); fsr_.descPool = VK_NULL_HANDLE; fsr_.descSet = VK_NULL_HANDLE; } + if (fsr_.descSetLayout) { vkDestroyDescriptorSetLayout(device, fsr_.descSetLayout, nullptr); fsr_.descSetLayout = VK_NULL_HANDLE; } + if (fsr_.sceneFramebuffer) { vkDestroyFramebuffer(device, fsr_.sceneFramebuffer, nullptr); fsr_.sceneFramebuffer = VK_NULL_HANDLE; } + if (fsr_.sceneSampler) { vkDestroySampler(device, fsr_.sceneSampler, nullptr); fsr_.sceneSampler = VK_NULL_HANDLE; } + destroyImage(device, alloc, fsr_.sceneDepthResolve); + destroyImage(device, alloc, fsr_.sceneMsaaColor); + destroyImage(device, alloc, fsr_.sceneDepth); + destroyImage(device, alloc, fsr_.sceneColor); + + fsr_.internalWidth = 0; + fsr_.internalHeight = 0; +} + +void Renderer::renderFSRUpscale() { + if (!fsr_.pipeline || currentCmd == VK_NULL_HANDLE) return; + + VkExtent2D outExtent = vkCtx->getSwapchainExtent(); + float inW = static_cast(fsr_.internalWidth); + float inH = static_cast(fsr_.internalHeight); + float outW = static_cast(outExtent.width); + float outH = static_cast(outExtent.height); + + // FSR push constants + struct { + glm::vec4 con0; // inputSize.xy, 1/inputSize.xy + glm::vec4 con1; // inputSize.xy / outputSize.xy, 0.5 * inputSize.xy / outputSize.xy + glm::vec4 con2; // outputSize.xy, 1/outputSize.xy + glm::vec4 con3; // sharpness, 0, 0, 0 + } fsrConst; + + fsrConst.con0 = glm::vec4(inW, inH, 1.0f / inW, 1.0f / inH); + fsrConst.con1 = glm::vec4(inW / outW, inH / outH, 0.5f * inW / outW, 0.5f * inH / outH); + fsrConst.con2 = glm::vec4(outW, outH, 1.0f / outW, 1.0f / outH); + fsrConst.con3 = glm::vec4(fsr_.sharpness, 0.0f, 0.0f, 0.0f); + + vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fsr_.pipeline); + vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + fsr_.pipelineLayout, 0, 1, &fsr_.descSet, 0, nullptr); + vkCmdPushConstants(currentCmd, fsr_.pipelineLayout, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 64, &fsrConst); + vkCmdDraw(currentCmd, 3, 1, 0, 0); +} + +void Renderer::setFSREnabled(bool enabled) { + if (fsr_.enabled == enabled) return; + fsr_.enabled = enabled; + + if (!enabled) { + // Defer destruction to next beginFrame() — can't destroy mid-render + fsr_.needsRecreate = true; + } + // Resources created/destroyed lazily in beginFrame() +} + +void Renderer::setFSRQuality(float scaleFactor) { + scaleFactor = glm::clamp(scaleFactor, 0.5f, 1.0f); + if (fsr_.scaleFactor == scaleFactor) return; + fsr_.scaleFactor = scaleFactor; + // Don't destroy/recreate mid-frame — mark for lazy recreation in next beginFrame() + if (fsr_.enabled && fsr_.sceneFramebuffer) { + fsr_.needsRecreate = true; + } +} + +void Renderer::setFSRSharpness(float sharpness) { + fsr_.sharpness = glm::clamp(sharpness, 0.0f, 2.0f); +} + +// ========================= End FSR ========================= + void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { (void)world; @@ -3233,153 +3616,283 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { // Get time of day for sky-related rendering 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 && !skipSky) { - rendering::SkyParams skyParams; - skyParams.timeOfDay = timeOfDay; - skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f; - - if (lightingManager) { - const auto& lighting = lightingManager->getLightingParams(); - skyParams.directionalDir = lighting.directionalDir; - skyParams.sunColor = lighting.diffuseColor; - skyParams.skyTopColor = lighting.skyTopColor; - skyParams.skyMiddleColor = lighting.skyMiddleColor; - skyParams.skyBand1Color = lighting.skyBand1Color; - skyParams.skyBand2Color = lighting.skyBand2Color; - skyParams.cloudDensity = lighting.cloudDensity; - skyParams.fogDensity = lighting.fogDensity; - skyParams.horizonGlow = lighting.horizonGlow; - } - - // Weather attenuation for lens flare - if (gameHandler) { - skyParams.weatherIntensity = gameHandler->getWeatherIntensity(); - } - - skyParams.skyboxModelId = 0; - skyParams.skyboxHasStars = false; - - skySystem->render(currentCmd, perFrameSet, *camera, skyParams); - } - - // Terrain (opaque pass) - if (terrainRenderer && camera && terrainEnabled && !skipTerrain) { - auto terrainStart = std::chrono::steady_clock::now(); - terrainRenderer->render(currentCmd, perFrameSet, *camera); - lastTerrainRenderMs = std::chrono::duration( - std::chrono::steady_clock::now() - terrainStart).count(); - } - - // WMO buildings (opaque, drawn before characters so selection circle sits on top) - if (wmoRenderer && camera && !skipWMO) { - auto wmoStart = std::chrono::steady_clock::now(); - wmoRenderer->render(currentCmd, perFrameSet, *camera); - lastWMORenderMs = std::chrono::duration( - std::chrono::steady_clock::now() - wmoStart).count(); - } - - // Selection circle (drawn after WMO, before characters) - renderSelectionCircle(view, projection); - - // Characters (after selection circle so units draw over the ring) - if (characterRenderer && camera && !skipChars) { - characterRenderer->render(currentCmd, perFrameSet, *camera); - } - - // M2 doodads, creatures, glow sprites, particles - if (m2Renderer && camera && !skipM2) { - if (cameraController) { + // ── Multithreaded secondary command buffer recording ── + // Terrain, WMO, and M2 record on worker threads while main thread handles + // sky, characters, water, and effects. prepareRender() on main thread first + // to handle thread-unsafe GPU allocations (descriptor pools, bone SSBOs). + if (parallelRecordingEnabled_) { + // --- Pre-compute state + GPU allocations on main thread (not thread-safe) --- + if (m2Renderer && cameraController) { m2Renderer->setInsideInterior(cameraController->isInsideWMO()); m2Renderer->setOnTaxi(cameraController->isOnTaxi()); } - auto m2Start = std::chrono::steady_clock::now(); - m2Renderer->render(currentCmd, perFrameSet, *camera); - m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); - m2Renderer->renderM2Particles(currentCmd, perFrameSet); - lastM2RenderMs = std::chrono::duration( - std::chrono::steady_clock::now() - m2Start).count(); - } + if (wmoRenderer) wmoRenderer->prepareRender(); + if (m2Renderer && camera) m2Renderer->prepareRender(frameIdx, *camera); + if (characterRenderer) characterRenderer->prepareRender(frameIdx); - // 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, false, vkCtx->getCurrentFrame()); - } + // --- Dispatch worker threads (terrain + WMO + M2) --- + std::future terrainFuture, wmoFuture, m2Future; - // 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(currentCmd, perFrameSet, *camera); - } - - // 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); + if (terrainRenderer && camera && terrainEnabled && !skipTerrain) { + terrainFuture = std::async(std::launch::async, [&]() -> double { + auto t0 = std::chrono::steady_clock::now(); + VkCommandBuffer cmd = beginSecondary(SEC_TERRAIN); + setSecondaryViewportScissor(cmd); + terrainRenderer->render(cmd, perFrameSet, *camera); + vkEndCommandBuffer(cmd); + return std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + }); } + + if (wmoRenderer && camera && !skipWMO) { + wmoFuture = std::async(std::launch::async, [&]() -> double { + auto t0 = std::chrono::steady_clock::now(); + VkCommandBuffer cmd = beginSecondary(SEC_WMO); + setSecondaryViewportScissor(cmd); + wmoRenderer->render(cmd, perFrameSet, *camera); + vkEndCommandBuffer(cmd); + return std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + }); + } + + if (m2Renderer && camera && !skipM2) { + m2Future = std::async(std::launch::async, [&]() -> double { + auto t0 = std::chrono::steady_clock::now(); + VkCommandBuffer cmd = beginSecondary(SEC_M2); + setSecondaryViewportScissor(cmd); + m2Renderer->render(cmd, perFrameSet, *camera); + m2Renderer->renderSmokeParticles(cmd, perFrameSet); + m2Renderer->renderM2Particles(cmd, perFrameSet); + vkEndCommandBuffer(cmd); + return std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + }); + } + + // --- Main thread: record sky (SEC_SKY) --- + { + VkCommandBuffer cmd = beginSecondary(SEC_SKY); + setSecondaryViewportScissor(cmd); + if (skySystem && camera && !skipSky) { + rendering::SkyParams skyParams; + skyParams.timeOfDay = timeOfDay; + skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f; + if (lightingManager) { + const auto& lighting = lightingManager->getLightingParams(); + skyParams.directionalDir = lighting.directionalDir; + skyParams.sunColor = lighting.diffuseColor; + skyParams.skyTopColor = lighting.skyTopColor; + skyParams.skyMiddleColor = lighting.skyMiddleColor; + skyParams.skyBand1Color = lighting.skyBand1Color; + skyParams.skyBand2Color = lighting.skyBand2Color; + skyParams.cloudDensity = lighting.cloudDensity; + skyParams.fogDensity = lighting.fogDensity; + skyParams.horizonGlow = lighting.horizonGlow; + } + if (gameHandler) skyParams.weatherIntensity = gameHandler->getWeatherIntensity(); + skyParams.skyboxModelId = 0; + skyParams.skyboxHasStars = false; + skySystem->render(cmd, perFrameSet, *camera, skyParams); + } + vkEndCommandBuffer(cmd); + } + + // --- Main thread: record characters + selection circle (SEC_CHARS) --- + { + VkCommandBuffer cmd = beginSecondary(SEC_CHARS); + setSecondaryViewportScissor(cmd); + renderSelectionCircle(view, projection, cmd); + if (characterRenderer && camera && !skipChars) { + characterRenderer->render(cmd, perFrameSet, *camera); + } + vkEndCommandBuffer(cmd); + } + + // --- Wait for workers --- + if (terrainFuture.valid()) lastTerrainRenderMs = terrainFuture.get(); + if (wmoFuture.valid()) lastWMORenderMs = wmoFuture.get(); + if (m2Future.valid()) lastM2RenderMs = m2Future.get(); + + // --- Main thread: record post-opaque (SEC_POST) --- + { + VkCommandBuffer cmd = beginSecondary(SEC_POST); + setSecondaryViewportScissor(cmd); + if (waterRenderer && camera) + waterRenderer->render(cmd, perFrameSet, *camera, globalTime, false, frameIdx); + if (weather && camera) weather->render(cmd, perFrameSet); + if (swimEffects && camera) swimEffects->render(cmd, perFrameSet); + if (mountDust && camera) mountDust->render(cmd, perFrameSet); + if (chargeEffect && camera) chargeEffect->render(cmd, perFrameSet); + if (questMarkerRenderer && camera) questMarkerRenderer->render(cmd, perFrameSet, *camera); + + // Underwater overlay + minimap + 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; + bool canal = false; + if (auto lt = waterRenderer->getWaterTypeAt(camPos.x, camPos.y)) + canal = (*lt == 5 || *lt == 13 || *lt == 17); + 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, cmd); + } + } + if (minimap && minimap->isEnabled() && camera && window) { + glm::vec3 minimapCenter = camera->getPosition(); + if (cameraController && cameraController->isThirdPerson()) + minimapCenter = characterPosition; + float minimapPlayerOrientation = 0.0f; + bool hasMinimapPlayerOrientation = false; + if (cameraController) { + 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(cmd, *camera, minimapCenter, + window->getWidth(), window->getHeight(), + minimapPlayerOrientation, hasMinimapPlayerOrientation); + } + vkEndCommandBuffer(cmd); + } + + // --- Execute all secondary buffers in correct draw order --- + VkCommandBuffer validCmds[6]; + uint32_t numCmds = 0; + validCmds[numCmds++] = secondaryCmds_[SEC_SKY][frameIdx]; + if (terrainRenderer && camera && terrainEnabled && !skipTerrain) + validCmds[numCmds++] = secondaryCmds_[SEC_TERRAIN][frameIdx]; + if (wmoRenderer && camera && !skipWMO) + validCmds[numCmds++] = secondaryCmds_[SEC_WMO][frameIdx]; + validCmds[numCmds++] = secondaryCmds_[SEC_CHARS][frameIdx]; + if (m2Renderer && camera && !skipM2) + validCmds[numCmds++] = secondaryCmds_[SEC_M2][frameIdx]; + validCmds[numCmds++] = secondaryCmds_[SEC_POST][frameIdx]; + + vkCmdExecuteCommands(currentCmd, numCmds, validCmds); + + } else { + // ── Fallback: single-threaded inline recording (original path) ── + + if (skySystem && camera && !skipSky) { + rendering::SkyParams skyParams; + skyParams.timeOfDay = timeOfDay; + skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f; + if (lightingManager) { + const auto& lighting = lightingManager->getLightingParams(); + skyParams.directionalDir = lighting.directionalDir; + skyParams.sunColor = lighting.diffuseColor; + skyParams.skyTopColor = lighting.skyTopColor; + skyParams.skyMiddleColor = lighting.skyMiddleColor; + skyParams.skyBand1Color = lighting.skyBand1Color; + skyParams.skyBand2Color = lighting.skyBand2Color; + skyParams.cloudDensity = lighting.cloudDensity; + skyParams.fogDensity = lighting.fogDensity; + skyParams.horizonGlow = lighting.horizonGlow; + } + if (gameHandler) skyParams.weatherIntensity = gameHandler->getWeatherIntensity(); + skyParams.skyboxModelId = 0; + skyParams.skyboxHasStars = false; + skySystem->render(currentCmd, perFrameSet, *camera, skyParams); + } + + if (terrainRenderer && camera && terrainEnabled && !skipTerrain) { + auto terrainStart = std::chrono::steady_clock::now(); + terrainRenderer->render(currentCmd, perFrameSet, *camera); + lastTerrainRenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - terrainStart).count(); + } + + if (wmoRenderer && camera && !skipWMO) { + wmoRenderer->prepareRender(); + auto wmoStart = std::chrono::steady_clock::now(); + wmoRenderer->render(currentCmd, perFrameSet, *camera); + lastWMORenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - wmoStart).count(); + } + + renderSelectionCircle(view, projection); + + if (characterRenderer && camera && !skipChars) { + characterRenderer->prepareRender(frameIdx); + characterRenderer->render(currentCmd, perFrameSet, *camera); + } + + if (m2Renderer && camera && !skipM2) { + if (cameraController) { + m2Renderer->setInsideInterior(cameraController->isInsideWMO()); + m2Renderer->setOnTaxi(cameraController->isOnTaxi()); + } + m2Renderer->prepareRender(frameIdx, *camera); + auto m2Start = std::chrono::steady_clock::now(); + m2Renderer->render(currentCmd, perFrameSet, *camera); + m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); + m2Renderer->renderM2Particles(currentCmd, perFrameSet); + lastM2RenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - m2Start).count(); + } + + if (waterRenderer && camera) + waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, frameIdx); + if (weather && camera) weather->render(currentCmd, perFrameSet); + if (swimEffects && camera) swimEffects->render(currentCmd, perFrameSet); + if (mountDust && camera) mountDust->render(currentCmd, perFrameSet); + if (chargeEffect && camera) chargeEffect->render(currentCmd, perFrameSet); + if (questMarkerRenderer && camera) questMarkerRenderer->render(currentCmd, perFrameSet, *camera); } - // Minimap overlay - if (minimap && minimap->isEnabled() && camera && window) { - glm::vec3 minimapCenter = camera->getPosition(); - 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; + // Underwater overlay and minimap — in the fallback path these run inline; + // in the parallel path they were already recorded into SEC_POST above. + if (!parallelRecordingEnabled_) { + 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; + bool canal = false; + if (auto lt = waterRenderer->getWaterTypeAt(camPos.x, camPos.y)) + canal = (*lt == 5 || *lt == 13 || *lt == 17); + 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); + } + } + if (minimap && minimap->isEnabled() && camera && window) { + glm::vec3 minimapCenter = camera->getPosition(); + if (cameraController && cameraController->isThirdPerson()) + minimapCenter = characterPosition; + float minimapPlayerOrientation = 0.0f; + bool hasMinimapPlayerOrientation = false; + if (cameraController) { + 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(currentCmd, *camera, minimapCenter, + window->getWidth(), window->getHeight(), + minimapPlayerOrientation, hasMinimapPlayerOrientation); } - minimap->render(currentCmd, *camera, minimapCenter, - window->getWidth(), window->getHeight(), - minimapPlayerOrientation, hasMinimapPlayerOrientation); } auto renderEnd = std::chrono::steady_clock::now(); @@ -3413,8 +3926,6 @@ bool Renderer::initializeRenderers(pipeline::AssetManager* assetManager, const s if (!waterRenderer->initialize(vkCtx, perFrameSetLayout)) { LOG_ERROR("Failed to initialize water renderer"); waterRenderer.reset(); - } else if (vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { - setupWater1xPass(); } } @@ -3868,6 +4379,128 @@ void Renderer::setupWater1xPass() { vkCtx->getSwapchainImageViews(), depthView, vkCtx->getSwapchainExtent()); } +// ========================= Multithreaded Secondary Command Buffers ========================= + +bool Renderer::createSecondaryCommandResources() { + if (!vkCtx) return false; + VkDevice device = vkCtx->getDevice(); + uint32_t queueFamily = vkCtx->getGraphicsQueueFamily(); + + VkCommandPoolCreateInfo poolCI{}; + poolCI.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + poolCI.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + poolCI.queueFamilyIndex = queueFamily; + + // Create worker command pools (one per worker thread) + for (uint32_t w = 0; w < NUM_WORKERS; ++w) { + if (vkCreateCommandPool(device, &poolCI, nullptr, &workerCmdPools_[w]) != VK_SUCCESS) { + LOG_ERROR("Failed to create worker command pool ", w); + return false; + } + } + + // Create main-thread secondary command pool + if (vkCreateCommandPool(device, &poolCI, nullptr, &mainSecondaryCmdPool_) != VK_SUCCESS) { + LOG_ERROR("Failed to create main secondary command pool"); + return false; + } + + // Allocate secondary command buffers + VkCommandBufferAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocInfo.level = VK_COMMAND_BUFFER_LEVEL_SECONDARY; + allocInfo.commandBufferCount = 1; + + // Worker secondaries: SEC_TERRAIN=1, SEC_WMO=2, SEC_M2=4 → worker pools 0,1,2 + const uint32_t workerSecondaries[] = { SEC_TERRAIN, SEC_WMO, SEC_M2 }; + for (uint32_t w = 0; w < NUM_WORKERS; ++w) { + allocInfo.commandPool = workerCmdPools_[w]; + for (uint32_t f = 0; f < MAX_FRAMES; ++f) { + if (vkAllocateCommandBuffers(device, &allocInfo, &secondaryCmds_[workerSecondaries[w]][f]) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate worker secondary buffer w=", w, " f=", f); + return false; + } + } + } + + // Main-thread secondaries: SEC_SKY=0, SEC_CHARS=3, SEC_POST=5, SEC_IMGUI=6 + const uint32_t mainSecondaries[] = { SEC_SKY, SEC_CHARS, SEC_POST, SEC_IMGUI }; + for (uint32_t idx : mainSecondaries) { + allocInfo.commandPool = mainSecondaryCmdPool_; + for (uint32_t f = 0; f < MAX_FRAMES; ++f) { + if (vkAllocateCommandBuffers(device, &allocInfo, &secondaryCmds_[idx][f]) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate main secondary buffer idx=", idx, " f=", f); + return false; + } + } + } + + parallelRecordingEnabled_ = true; + LOG_INFO("Multithreaded rendering: ", NUM_WORKERS, " worker threads, ", + NUM_SECONDARIES, " secondary buffers [ENABLED]"); + return true; +} + +void Renderer::destroySecondaryCommandResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + vkDeviceWaitIdle(device); + + // Secondary buffers are freed when their pool is destroyed + for (uint32_t w = 0; w < NUM_WORKERS; ++w) { + if (workerCmdPools_[w]) { + vkDestroyCommandPool(device, workerCmdPools_[w], nullptr); + workerCmdPools_[w] = VK_NULL_HANDLE; + } + } + if (mainSecondaryCmdPool_) { + vkDestroyCommandPool(device, mainSecondaryCmdPool_, nullptr); + mainSecondaryCmdPool_ = VK_NULL_HANDLE; + } + + for (auto& arr : secondaryCmds_) + for (auto& cmd : arr) + cmd = VK_NULL_HANDLE; + + parallelRecordingEnabled_ = false; +} + +VkCommandBuffer Renderer::beginSecondary(uint32_t secondaryIndex) { + uint32_t frame = vkCtx->getCurrentFrame(); + VkCommandBuffer cmd = secondaryCmds_[secondaryIndex][frame]; + + VkCommandBufferInheritanceInfo inheritInfo{}; + inheritInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO; + inheritInfo.renderPass = activeRenderPass_; + inheritInfo.subpass = 0; + inheritInfo.framebuffer = activeFramebuffer_; + + VkCommandBufferBeginInfo beginInfo{}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT + | VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT; + beginInfo.pInheritanceInfo = &inheritInfo; + + VkResult result = vkBeginCommandBuffer(cmd, &beginInfo); + if (result != VK_SUCCESS) { + LOG_ERROR("vkBeginCommandBuffer failed for secondary ", secondaryIndex, + " frame ", frame, " result=", static_cast(result)); + } + return cmd; +} + +void Renderer::setSecondaryViewportScissor(VkCommandBuffer cmd) { + VkViewport vp{}; + vp.width = static_cast(activeRenderExtent_.width); + vp.height = static_cast(activeRenderExtent_.height); + vp.maxDepth = 1.0f; + vkCmdSetViewport(cmd, 0, 1, &vp); + + VkRect2D sc{}; + sc.extent = activeRenderExtent_; + vkCmdSetScissor(cmd, 0, 1, &sc); +} + void Renderer::renderReflectionPass() { if (!waterRenderer || !camera || !waterRenderer->hasReflectionPass() || !waterRenderer->hasSurfaces()) return; if (currentCmd == VK_NULL_HANDLE || !reflPerFrameUBOMapped) return; diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 97527c8c..f15541ea 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -911,6 +911,8 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { wmoRenderer->setDeferNormalMaps(false); wmoRenderer->setPredecodedBLPCache(nullptr); if (ft.wmoModelIndex < pending->wmoModels.size()) return false; + // All WMO models loaded — backfill normal/height maps that were skipped during streaming + wmoRenderer->backfillNormalMaps(); } ft.phase = FinalizationPhase::WMO_INSTANCES; return false; diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index 79e7eac3..dc4144fa 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -252,14 +252,22 @@ bool VkContext::createAllocator() { bool VkContext::createSwapchain(int width, int height) { vkb::SwapchainBuilder swapchainBuilder{physicalDevice, device, surface}; - auto swapRet = swapchainBuilder + auto& builder = 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(); + .set_old_swapchain(swapchain); + + if (vsync_) { + builder.set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR); + } else { + builder.set_desired_present_mode(VK_PRESENT_MODE_IMMEDIATE_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_MAILBOX_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_FIFO_RELAXED_KHR); + } + + auto swapRet = builder.build(); if (!swapRet) { LOG_ERROR("Failed to create Vulkan swapchain: ", swapRet.error().message()); @@ -1026,14 +1034,22 @@ bool VkContext::recreateSwapchain(int width, int height) { VkSwapchainKHR oldSwapchain = swapchain; vkb::SwapchainBuilder swapchainBuilder{physicalDevice, device, surface}; - auto swapRet = swapchainBuilder + auto& builder = 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(); + .set_old_swapchain(oldSwapchain); + + if (vsync_) { + builder.set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR); + } else { + builder.set_desired_present_mode(VK_PRESENT_MODE_IMMEDIATE_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_MAILBOX_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_FIFO_RELAXED_KHR); + } + + auto swapRet = builder.build(); if (oldSwapchain) { vkDestroySwapchainKHR(device, oldSwapchain, nullptr); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 5dec0e3e..2e5afcc3 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -787,8 +787,8 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } // Build doodad's local transform (WoW coordinates) - // WMO doodads use quaternion rotation (X/Y swapped for correct orientation) - glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y, doodad.rotation.x, doodad.rotation.z); + // WMO doodads use quaternion rotation + glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x, doodad.rotation.y, doodad.rotation.z); glm::mat4 localTransform(1.0f); localTransform = glm::translate(localTransform, doodad.position); @@ -1318,15 +1318,10 @@ void WMORenderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& q } } -void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { +void WMORenderer::prepareRender() { ++currentFrameId; - if (!opaquePipeline_ || instances.empty()) { - lastDrawCalls = 0; - return; - } - - // Update material UBOs if settings changed + // Update material UBOs if settings changed (mapped memory writes — main thread only) if (materialSettingsDirty_) { materialSettingsDirty_ = false; static const int pomSampleTable[] = { 16, 32, 64 }; @@ -1335,7 +1330,6 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const 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) { @@ -1351,6 +1345,13 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } } +} + +void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (!opaquePipeline_ || instances.empty()) { + lastDrawCalls = 0; + return; + } lastDrawCalls = 0; @@ -1362,43 +1363,45 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const lastPortalCulledGroups = 0; lastDistanceCulledGroups = 0; - // ── Phase 1: Parallel visibility culling ────────────────────────── - std::vector visibleInstances; - visibleInstances.reserve(instances.size()); + // ── Phase 1: Visibility culling ────────────────────────── + visibleInstances_.clear(); for (size_t i = 0; i < instances.size(); ++i) { - const auto& instance = instances[i]; - if (loadedModels.find(instance.modelId) == loadedModels.end()) - continue; - visibleInstances.push_back(i); + if (loadedModels.count(instances[i].modelId)) + visibleInstances_.push_back(i); } glm::vec3 camPos = camera.getPosition(); bool doPortalCull = portalCulling; - bool doFrustumCull = false; // Temporarily disabled: can over-cull world WMOs bool doDistanceCull = distanceCulling; - auto cullInstance = [&](size_t instIdx) -> InstanceDrawList { - if (instIdx >= instances.size()) return InstanceDrawList{}; + auto cullInstance = [&](size_t instIdx, InstanceDrawList& result) { + if (instIdx >= instances.size()) return; const auto& instance = instances[instIdx]; auto mdlIt = loadedModels.find(instance.modelId); - if (mdlIt == loadedModels.end()) return InstanceDrawList{}; + if (mdlIt == loadedModels.end()) return; const ModelData& model = mdlIt->second; - InstanceDrawList result; result.instanceIndex = instIdx; + result.visibleGroups.clear(); + result.portalCulled = 0; + result.distanceCulled = 0; - // Portal-based visibility - std::unordered_set portalVisibleGroups; + // Portal-based visibility — use a flat sorted vector instead of unordered_set + std::vector portalVisibleGroups; bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); if (usePortalCulling) { + std::unordered_set pvgSet; glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f); getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum, - instance.modelMatrix, portalVisibleGroups); + instance.modelMatrix, pvgSet); + portalVisibleGroups.assign(pvgSet.begin(), pvgSet.end()); + std::sort(portalVisibleGroups.begin(), portalVisibleGroups.end()); } for (size_t gi = 0; gi < model.groups.size(); ++gi) { if (usePortalCulling && - portalVisibleGroups.find(static_cast(gi)) == portalVisibleGroups.end()) { + !std::binary_search(portalVisibleGroups.begin(), portalVisibleGroups.end(), + static_cast(gi))) { result.portalCulled++; continue; } @@ -1414,62 +1417,18 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const continue; } } - - if (doFrustumCull && !frustum.intersectsAABB(gMin, gMax)) - continue; } result.visibleGroups.push_back(static_cast(gi)); } - return result; }; - // Dispatch culling — parallel when enough instances, sequential otherwise. - std::vector drawLists; - drawLists.reserve(visibleInstances.size()); + // Resize drawLists to match (reuses previous capacity) + drawLists_.resize(visibleInstances_.size()); - 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; - - drawLists.resize(visibleInstances.size()); - - cullFutures_.clear(); - if (cullFutures_.capacity() < numThreads) { - cullFutures_.reserve(numThreads); - } - - 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)); + // Sequential culling (parallel dispatch overhead > savings for typical instance counts) + for (size_t j = 0; j < visibleInstances_.size(); ++j) { + cullInstance(visibleInstances_[j], drawLists_[j]); } // ── Phase 2: Vulkan draw ──────────────────────────────── @@ -1484,7 +1443,7 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Track which pipeline is currently bound: 0=opaque, 1=transparent, 2=glass int currentPipelineKind = 0; - for (const auto& dl : drawLists) { + for (const auto& dl : drawLists_) { if (dl.instanceIndex >= instances.size()) continue; const auto& instance = instances[dl.instanceIndex]; auto modelIt = loadedModels.find(instance.modelId); @@ -2412,6 +2371,69 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { return rawPtr; } +void WMORenderer::backfillNormalMaps() { + if (!normalMappingEnabled_ && !pomEnabled_) return; + + if (!assetManager) return; + + int generated = 0; + for (auto& [key, entry] : textureCache) { + if (entry.normalHeightMap) continue; // already has one + if (!entry.texture) continue; + + // Re-load the BLP from MPQ to get pixel data for normal map generation + pipeline::BLPImage blp = assetManager->loadTexture(key); + if (!blp.isValid() || blp.width == 0 || blp.height == 0) continue; + + float variance = 0.0f; + auto nhMap = generateNormalHeightMap(blp.data.data(), blp.width, blp.height, variance); + if (nhMap) { + entry.normalHeightMap = std::move(nhMap); + entry.heightMapVariance = variance; + generated++; + } + } + + if (generated > 0) { + VkDevice device = vkCtx_->getDevice(); + int rebound = 0; + // Update merged batches: assign normal map pointer and rebind descriptor set + for (auto& [modelId, model] : loadedModels) { + for (auto& group : model.groups) { + for (auto& mb : group.mergedBatches) { + if (mb.normalHeightMap) continue; // already set + if (!mb.texture) continue; + // Find this texture in the cache + for (const auto& [cacheKey, cacheEntry] : textureCache) { + if (cacheEntry.texture.get() == mb.texture) { + if (cacheEntry.normalHeightMap) { + mb.normalHeightMap = cacheEntry.normalHeightMap.get(); + mb.heightMapVariance = cacheEntry.heightMapVariance; + // Rebind descriptor set binding 2 to the real normal/height map + if (mb.materialSet) { + VkDescriptorImageInfo nhImgInfo = mb.normalHeightMap->descriptorInfo(); + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = mb.materialSet; + write.dstBinding = 2; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.descriptorCount = 1; + write.pImageInfo = &nhImgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + rebound++; + } + } + break; + } + } + } + } + } + materialSettingsDirty_ = true; + LOG_INFO("Backfilled ", generated, " normal/height maps (", rebound, " descriptor sets rebound) for deferred WMO textures"); + } +} + // Ray-AABB intersection (slab method) // Returns true if the ray intersects the axis-aligned bounding box static bool rayIntersectsAABB(const glm::vec3& origin, const glm::vec3& dir, @@ -3145,18 +3167,13 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, if (triHeight < 1.0f && tb.maxZ <= localFeetZ + 1.2f) continue; // Use MOPY flags to filter wall collision. - // Collidable triangles (flag 0x01) block the player — including - // invisible collision walls (0x01 without 0x20) used in tunnels. - // Skip detail/decorative geometry (0x04) and render-only surfaces. + // Collide with triangles that have the collision flag (0x08) or no flags at all. + // Skip detail/decorative (0x04) and render-only (0x20 without 0x08) surfaces. uint32_t triIdx = triStart / 3; if (!group.triMopyFlags.empty() && triIdx < group.triMopyFlags.size()) { uint8_t mopy = group.triMopyFlags[triIdx]; if (mopy != 0) { - bool collidable = (mopy & 0x01) != 0; - bool detail = (mopy & 0x04) != 0; - if (!collidable || detail) { - continue; - } + if ((mopy & 0x04) || !(mopy & 0x08)) continue; } } @@ -3217,8 +3234,8 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, if (absNz >= 0.35f) continue; const float SKIN = 0.005f; // small separation so we don't re-collide immediately - // Stronger push when inside WMO for more responsive indoor collision - const float MAX_PUSH = insideWMO ? 0.35f : 0.15f; + // Push must cover full penetration to prevent gradual clip-through + const float MAX_PUSH = PLAYER_RADIUS; float penetration = (PLAYER_RADIUS - horizDist); float pushDist = glm::clamp(penetration + SKIN, 0.0f, MAX_PUSH); glm::vec2 pushDir2; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3f1c0eb9..19db13e9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -317,6 +317,20 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Apply saved FSR setting once when renderer is available + if (!fsrSettingsApplied_ && pendingFSR) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + static const float fsrScales[] = { 0.77f, 0.67f, 0.59f, 0.50f }; + renderer->setFSRQuality(fsrScales[pendingFSRQuality]); + renderer->setFSRSharpness(pendingFSRSharpness); + renderer->setFSREnabled(true); + fsrSettingsApplied_ = true; + } + } else { + fsrSettingsApplied_ = true; + } + // Apply auto-loot setting to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); @@ -2687,6 +2701,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { chatInputBuffer[0] = '\0'; return; } + // /unstuckhearth command — teleport to hearthstone bind point + if (cmdLower == "unstuckhearth") { + gameHandler.unstuckHearth(); + chatInputBuffer[0] = '\0'; + return; + } // /transport board — board test transport if (cmdLower == "transport board") { @@ -6270,6 +6290,25 @@ void GameScreen::renderSettingsWindow() { saveSettings(); } } + // FSR 1.0 Upscaling + { + if (ImGui::Checkbox("FSR Upscaling (Experimental)", &pendingFSR)) { + if (renderer) renderer->setFSREnabled(pendingFSR); + saveSettings(); + } + if (pendingFSR) { + const char* fsrQualityLabels[] = { "Ultra Quality (77%)", "Quality (67%)", "Balanced (59%)", "Performance (50%)" }; + static const float fsrScaleFactors[] = { 0.77f, 0.67f, 0.59f, 0.50f }; + if (ImGui::Combo("FSR Quality", &pendingFSRQuality, fsrQualityLabels, 4)) { + if (renderer) renderer->setFSRQuality(fsrScaleFactors[pendingFSRQuality]); + saveSettings(); + } + if (ImGui::SliderFloat("FSR Sharpness", &pendingFSRSharpness, 0.0f, 2.0f, "%.1f")) { + if (renderer) renderer->setFSRSharpness(pendingFSRSharpness); + saveSettings(); + } + } + } if (ImGui::SliderInt("Ground Clutter Density", &pendingGroundClutterDensity, 0, 150, "%d%%")) { if (renderer) { if (auto* tm = renderer->getTerrainManager()) { @@ -7384,6 +7423,9 @@ void GameScreen::saveSettings() { out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; out << "pom_quality=" << pendingPOMQuality << "\n"; + out << "fsr=" << (pendingFSR ? 1 : 0) << "\n"; + out << "fsr_quality=" << pendingFSRQuality << "\n"; + out << "fsr_sharpness=" << pendingFSRSharpness << "\n"; // Controls out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; @@ -7470,6 +7512,9 @@ void GameScreen::loadSettings() { 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); + else if (key == "fsr") pendingFSR = (std::stoi(val) != 0); + else if (key == "fsr_quality") pendingFSRQuality = std::clamp(std::stoi(val), 0, 3); + else if (key == "fsr_sharpness") pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f); // 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);