Fix WMO wall collision, normal mapping, POM backfill, and M2/WMO rendering performance

- Fix MOPY flag check (0x08 not 0x01) for proper wall collision detection
- Cap MAX_PUSH to PLAYER_RADIUS to prevent gradual clip-through
- Fix WMO doodad quaternion component ordering (X/Y swap)
- Linear normal map strength blend in shader for smooth slider control
- Enable shadow sampling for interior WMO groups (covered outdoor areas)
- Backfill deferred normal/height maps after streaming with descriptor rebind
- M2: prepareRender only iterates animated instances, bone dirty flag
- M2: remove worker thread VMA allocation, skip unready bone instances
- WMO: persistent visibility vectors, sequential culling
- Add FSR EASU/RCAS shaders
This commit is contained in:
Kelsi 2026-03-07 22:03:28 -08:00
parent 16c6c2b6a0
commit a4966e486f
25 changed files with 1467 additions and 352 deletions

View file

@ -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);
}

Binary file not shown.

View file

@ -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);
}

Binary file not shown.

View file

@ -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;

Binary file not shown.

View file

@ -236,6 +236,11 @@ private:
std::optional<PendingWorldEntry> 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;

View file

@ -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(uint32_t mapId, float x, float y, float z)>;
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_;

View file

@ -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);

View file

@ -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<pipeline::M2ParticleEmitter> 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);
/**

View file

@ -4,10 +4,12 @@
#include <string>
#include <cstdint>
#include <vector>
#include <future>
#include <glm/glm.hpp>
#include <vulkan/vulkan.h>
#include <vk_mem_alloc.h>
#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<CharacterPreview*> activePreviews_;

View file

@ -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<VkFramebuffer> swapchainFramebuffers;
bool swapchainDirty = false;
bool deviceLost_ = false;
bool vsync_ = true;
// Per-frame resources
FrameData frames[MAX_FRAMES_IN_FLIGHT];

View file

@ -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<std::future<void>> cullFutures_;
std::vector<size_t> visibleInstances_; // reused per frame
std::vector<InstanceDrawList> drawLists_; // reused per frame
// Collision query profiling (per frame).
mutable double queryTimeMs = 0.0;

View file

@ -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;

View file

@ -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)");
});
}

View file

@ -84,6 +84,7 @@ bool Window::initialize() {
// Initialize Vulkan context
vkContext = std::make_unique<rendering::VkContext>();
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) {

View file

@ -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;

View file

@ -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<int>(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<int>(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;

View file

@ -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,7 +2303,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
}
// Sort by modelId to minimize vertex/index buffer rebinds
std::stable_sort(sortedVisible_.begin(), sortedVisible_.end(),
std::sort(sortedVisible_.begin(), sortedVisible_.end(),
[](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; });
uint32_t currentModelId = UINT32_MAX;
@ -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<int>(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;

View file

@ -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<uint32_t>(ext.width * sf) & ~1u;
uint32_t ih = static_cast<uint32_t>(ext.height * sf) & ~1u;
ImGui::Text(" %ux%u -> %ux%u (%.0f%%)", iw, ih, ext.width, ext.height, sf * 100.0f);
}
}
ImGui::Spacing();
}

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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<uint32_t>(width), static_cast<uint32_t>(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<uint32_t>(width), static_cast<uint32_t>(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);

View file

@ -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<size_t> 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<uint32_t> portalVisibleGroups;
// Portal-based visibility — use a flat sorted vector instead of unordered_set
std::vector<uint32_t> portalVisibleGroups;
bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty();
if (usePortalCulling) {
std::unordered_set<uint32_t> 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<uint32_t>(gi)) == portalVisibleGroups.end()) {
!std::binary_search(portalVisibleGroups.begin(), portalVisibleGroups.end(),
static_cast<uint32_t>(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<uint32_t>(gi));
}
return result;
};
// Dispatch culling — parallel when enough instances, sequential otherwise.
std::vector<InstanceDrawList> drawLists;
drawLists.reserve(visibleInstances.size());
// Resize drawLists to match (reuses previous capacity)
drawLists_.resize(visibleInstances_.size());
static const size_t minParallelCullInstances = std::max<size_t>(
4, envSizeOrDefault("WOWEE_WMO_CULL_MT_MIN", 128));
if (visibleInstances.size() >= minParallelCullInstances && numCullThreads_ > 1) {
static const size_t minCullWorkPerThread = std::max<size_t>(
16, envSizeOrDefault("WOWEE_WMO_CULL_WORK_PER_THREAD", 64));
const size_t maxUsefulThreads = std::max<size_t>(
1, (visibleInstances.size() + minCullWorkPerThread - 1) / minCullWorkPerThread);
const size_t numThreads = std::min(static_cast<size_t>(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;

View file

@ -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);