From dfc53f30a8005b231a5a76f8b5b8abc693afeee5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 17:03:29 -0800 Subject: [PATCH 01/16] Effect model additive blend, teleport facing, tighter area triggers Classify light shafts, portals, spotlights, bubbles, and similar M2 doodads as spell effects so they render with additive blending instead of as solid opaque objects. Set camera yaw from server orientation on world load so teleports face the correct direction. Reduce area trigger minimum radius (3.0 sphere, 4.0 box) to prevent premature portal firing near tram entrances. --- src/core/application.cpp | 9 +++++++-- src/game/game_handler.cpp | 8 ++++---- src/rendering/m2_renderer.cpp | 31 ++++++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 8c94289a..d99f99f1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -3428,10 +3428,15 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float glm::vec3 spawnCanonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); glm::vec3 spawnRender = core::coords::canonicalToRender(spawnCanonical); - // Set camera position + // Set camera position and facing from server orientation if (renderer->getCameraController()) { + float yawDeg = 0.0f; + if (gameHandler) { + float canonicalYaw = gameHandler->getMovementInfo().orientation; + yawDeg = 180.0f - glm::degrees(canonicalYaw); + } renderer->getCameraController()->setOnlineMode(true); - renderer->getCameraController()->setDefaultSpawn(spawnRender, 0.0f, -15.0f); + renderer->getCameraController()->setDefaultSpawn(spawnRender, yawDeg, -15.0f); renderer->getCameraController()->reset(); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 5ff720b3..c8dba11f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -8814,16 +8814,16 @@ void GameHandler::checkAreaTriggers() { bool inside = false; if (at.radius > 0.0f) { - // Sphere trigger — small minimum so player must be near the portal - float effectiveRadius = std::max(at.radius, 12.0f); + // Sphere trigger — use actual radius, with small floor for very tiny triggers + float effectiveRadius = std::max(at.radius, 3.0f); float dx = px - at.x; float dy = py - at.y; float dz = pz - at.z; float distSq = dx * dx + dy * dy + dz * dz; inside = (distSq <= effectiveRadius * effectiveRadius); } else if (at.boxLength > 0.0f || at.boxWidth > 0.0f || at.boxHeight > 0.0f) { - // Box trigger — small minimum so player must walk into the portal area - float boxMin = 16.0f; + // Box trigger — use actual size, with small floor for tiny triggers + float boxMin = 4.0f; float effLength = std::max(at.boxLength, boxMin); float effWidth = std::max(at.boxWidth, boxMin); float effHeight = std::max(at.boxHeight, boxMin); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 925c020b..0998694a 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1116,9 +1116,24 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Ground clutter (grass/pebbles/detail cards) should never block camera/movement. gpuModel.collisionNoBlock = true; } - // Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2) - gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 && - model.particleEmitters.size() >= 3; + // Spell effect / pure-visual models: particle-dominated with minimal geometry, + // or named effect models (light shafts, portals, emitters, spotlights) + bool effectByName = + (lowerName.find("lightshaft") != std::string::npos) || + (lowerName.find("volumetriclight") != std::string::npos) || + (lowerName.find("instanceportal") != std::string::npos) || + (lowerName.find("mageportal") != std::string::npos) || + (lowerName.find("worldtreeportal") != std::string::npos) || + (lowerName.find("particleemitter") != std::string::npos) || + (lowerName.find("bubbles") != std::string::npos) || + (lowerName.find("spotlight") != std::string::npos) || + (lowerName.find("hazardlight") != std::string::npos) || + (lowerName.find("lavasplash") != std::string::npos) || + (lowerName.find("lavabubble") != std::string::npos) || + (lowerName.find("wisps") != std::string::npos); + gpuModel.isSpellEffect = effectByName || + (hasParticles && model.vertices.size() <= 200 && + model.particleEmitters.size() >= 3); // Water vegetation: cattails, reeds, bulrushes, kelp, seaweed, lilypad near water gpuModel.isWaterVegetation = (lowerName.find("cattail") != std::string::npos) || @@ -2381,8 +2396,14 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Select pipeline based on blend mode uint8_t effectiveBlendMode = batch.blendMode; - if (model.isSpellEffect && (effectiveBlendMode == 4 || effectiveBlendMode == 5)) { - effectiveBlendMode = 3; + if (model.isSpellEffect) { + // Effect models: force additive blend for opaque/cutout batches + // so the mesh renders as a transparent glow, not a solid object + if (effectiveBlendMode <= 1) { + effectiveBlendMode = 3; // additive + } else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) { + effectiveBlendMode = 3; + } } if (forceCutout) { effectiveBlendMode = 1; From 585d0bf50ed7142a8677d665745035344b2a8958 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 18:02:56 -0800 Subject: [PATCH 02/16] Instance portal glow, spin, and transparent additive rendering --- include/rendering/m2_renderer.hpp | 4 +++ src/core/application.cpp | 1 + src/rendering/m2_renderer.cpp | 47 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 6b8ebc15..62e984fd 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -112,6 +112,7 @@ struct M2ModelGPU { bool hasAnimation = false; // True if any bone has keyframes bool isSmoke = false; // True for smoke models (UV scroll animation) bool isSpellEffect = false; // True for spell effect models (skip particle dampeners) + bool isInstancePortal = false; // Instance portal model (spin + glow) bool disableAnimation = false; // Keep foliage/tree doodads visually stable bool shadowWindFoliage = false; // Apply wind sway in shadow pass for foliage/tree cards bool isFoliageLike = false; // Model name matches foliage/tree/bush/grass etc (precomputed) @@ -181,9 +182,11 @@ struct M2Instance { bool cachedHasParticleEmitters = false; bool cachedIsGroundDetail = false; bool cachedIsInvisibleTrap = false; + bool cachedIsInstancePortal = false; bool cachedIsValid = false; bool skipCollision = false; // WMO interior doodads — skip player wall collision float cachedBoundRadius = 0.0f; + float portalSpinAngle = 0.0f; // Accumulated spin angle for portal rotation // Frame-skip optimization (update distant animations less frequently) uint8_t frameSkipCounter = 0; @@ -476,6 +479,7 @@ private: // Smoke particle system std::vector smokeParticles; std::vector smokeInstanceIndices_; // Indices into instances[] for smoke emitters + std::vector portalInstanceIndices_; // Indices into instances[] for spinning portals static constexpr int MAX_SMOKE_PARTICLES = 1000; float smokeEmitAccum = 0.0f; std::mt19937 smokeRng{42}; diff --git a/src/core/application.cpp b/src/core/application.cpp index d99f99f1..8450dfb8 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -6568,6 +6568,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t std::string lowerPath = modelPath; std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), ::tolower); bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || + lowerPath.find("instancenewportal") != std::string::npos || lowerPath.find("portalfx") != std::string::npos || lowerPath.find("spellportal") != std::string::npos); if (!isAnimatedEffect) { diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 0998694a..690a5234 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1122,6 +1122,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("lightshaft") != std::string::npos) || (lowerName.find("volumetriclight") != std::string::npos) || (lowerName.find("instanceportal") != std::string::npos) || + (lowerName.find("instancenewportal") != std::string::npos) || (lowerName.find("mageportal") != std::string::npos) || (lowerName.find("worldtreeportal") != std::string::npos) || (lowerName.find("particleemitter") != std::string::npos) || @@ -1134,6 +1135,15 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { gpuModel.isSpellEffect = effectByName || (hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3); + gpuModel.isInstancePortal = + (lowerName.find("instanceportal") != std::string::npos) || + (lowerName.find("instancenewportal") != std::string::npos) || + (lowerName.find("portalfx") != std::string::npos) || + (lowerName.find("spellportal") != std::string::npos); + // Instance portals are spell effects too (additive blend, no collision) + if (gpuModel.isInstancePortal) { + gpuModel.isSpellEffect = true; + } // Water vegetation: cattails, reeds, bulrushes, kelp, seaweed, lilypad near water gpuModel.isWaterVegetation = (lowerName.find("cattail") != std::string::npos) || @@ -1634,6 +1644,7 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, instance.cachedBoundRadius = mdlRef.boundRadius; instance.cachedIsGroundDetail = mdlRef.isGroundDetail; instance.cachedIsInvisibleTrap = mdlRef.isInvisibleTrap; + instance.cachedIsInstancePortal = mdlRef.isInstancePortal; instance.cachedIsValid = mdlRef.isValid(); // Initialize animation: play first sequence (usually Stand/Idle) @@ -1652,6 +1663,9 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, if (mdlRef.isSmoke) { smokeInstanceIndices_.push_back(idx); } + if (mdlRef.isInstancePortal) { + portalInstanceIndices_.push_back(idx); + } if (!mdlRef.particleEmitters.empty()) { particleInstanceIndices_.push_back(idx); } @@ -1941,6 +1955,18 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: ++i; } + // --- Spin instance portals --- + static constexpr float PORTAL_SPIN_SPEED = 1.2f; // radians/sec + for (size_t idx : portalInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& inst = instances[idx]; + inst.portalSpinAngle += PORTAL_SPIN_SPEED * deltaTime; + if (inst.portalSpinAngle > 6.2831853f) + inst.portalSpinAngle -= 6.2831853f; + inst.rotation.z = inst.portalSpinAngle; + inst.updateModelMatrix(); + } + // --- Normal M2 animation update --- // Advance animTime for ALL instances (needed for texture UV animation on static doodads). // This is a tight loop touching only one float per instance — no hash lookups. @@ -2251,6 +2277,22 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const if (model.isGroundDetail) { instanceFadeAlpha *= 0.82f; } + if (model.isInstancePortal) { + // Render mesh at low alpha + emit glow sprite at center + instanceFadeAlpha *= 0.12f; + if (entry.distSq < 400.0f * 400.0f) { + glm::vec3 center = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); + GlowSprite gs; + gs.worldPos = center; + gs.color = glm::vec4(0.35f, 0.5f, 1.0f, 1.1f); + gs.size = instance.scale * 5.0f; + glowSprites_.push_back(gs); + GlowSprite halo = gs; + halo.color.a *= 0.3f; + halo.size *= 2.2f; + glowSprites_.push_back(halo); + } + } // Upload bone matrices to SSBO if model has skeletal animation bool useBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); @@ -3419,6 +3461,7 @@ void M2Renderer::clear() { instanceIndexById.clear(); smokeParticles.clear(); smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); animatedInstanceIndices_.clear(); particleOnlyInstanceIndices_.clear(); particleInstanceIndices_.clear(); @@ -3454,6 +3497,7 @@ void M2Renderer::rebuildSpatialIndex() { instanceIndexById.clear(); instanceIndexById.reserve(instances.size()); smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); animatedInstanceIndices_.clear(); particleOnlyInstanceIndices_.clear(); particleInstanceIndices_.clear(); @@ -3465,6 +3509,9 @@ void M2Renderer::rebuildSpatialIndex() { if (inst.cachedIsSmoke) { smokeInstanceIndices_.push_back(i); } + if (inst.cachedIsInstancePortal) { + portalInstanceIndices_.push_back(i); + } if (inst.cachedHasParticleEmitters) { particleInstanceIndices_.push_back(i); } From 8b0c2a0fa1f761567ca437a0468cf644948f7dea Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 18:38:38 -0800 Subject: [PATCH 03/16] Fix minimap horizontal inversion by flipping composite sampling --- assets/shaders/minimap_display.frag.glsl | 4 ++-- assets/shaders/minimap_display.frag.spv | Bin 6444 -> 6552 bytes 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/shaders/minimap_display.frag.glsl b/assets/shaders/minimap_display.frag.glsl index ff0477ae..dacaaed1 100644 --- a/assets/shaders/minimap_display.frag.glsl +++ b/assets/shaders/minimap_display.frag.glsl @@ -40,7 +40,7 @@ void main() { float cs = cos(push.rotation); float sn = sin(push.rotation); vec2 rotated = vec2(center.x * cs - center.y * sn, center.x * sn + center.y * cs); - vec2 mapUV = push.playerUV + rotated * push.zoomRadius * 2.0; + vec2 mapUV = push.playerUV + vec2(-rotated.x, rotated.y) * push.zoomRadius * 2.0; vec4 mapColor = texture(uComposite, mapUV); @@ -48,7 +48,7 @@ void main() { float acs = cos(push.arrowRotation); float asn = sin(push.arrowRotation); vec2 ac = center; - vec2 arrowPos = vec2(ac.x * acs - ac.y * asn, ac.x * asn + ac.y * acs); + vec2 arrowPos = vec2(-(ac.x * acs - ac.y * asn), ac.x * asn + ac.y * acs); vec2 tip = vec2(0.0, -0.04); vec2 left = vec2(-0.02, 0.02); diff --git a/assets/shaders/minimap_display.frag.spv b/assets/shaders/minimap_display.frag.spv index 5fd812cdd34c4508f84f397c0683011b768d0ee1..5c0ac7b00a4d21d05c44ef1d7a25ad0cd1187c6d 100644 GIT binary patch literal 6552 zcmZ9P36zy(8OOh27GT&HMZ;_m&w-Lq!39D=AsnnpMnG*lXS&Q>;M&aG@y-lNWil!4 zX?u|^mZoH7R$5|`MSE$hNu_2)i;3-#Xlng__kIt(+>i5|=l}el^Y0DasV5mQF>9>G88ayxoi(AAzCx*AM?o9s zWa8v$0BWRamhFab%38Dbj;{9AtJ^zPc6AR7bPWz|=`D8kmimibJ*7&qd#JRzXxx}2 zuGHV%JKU27G!v2bZKl(BdURLHgM$lt=5F4wV2J{iHPgQp?QG(4==D`RE}M=XD3|(& z&h1}UDHZxR_7)H2*4LP9Y&EYjn}UvdQ^B=*joCDCcOEkb-E*=%o3i=ffkLIwSM@bz zCmv=uW`t!u3u>{84)Zl-ix0CKGZ)pfuok=gFke%)qRu`CdsAWXyyC`Md}qz>EDzS~ zF6{1N|4^}_-CSMMy5eOk%H>KAuIlU?&^@KWp|D>K=o}v0q}@VKfelfqZ7~z-zI)t9#IxjaY+8Jg05o>TJcHs=Xb% zuP{(ufcx{An!TdjTV}^Y_hDrm(Hf4Ljy~PcO_Wc3uN@?RJ3H=YD(>wKWoxQit-dAVu$6n7l$JzVdmWPK9 zt*xCN1!9gxd{)sl&U$u@I>eEh$@z{%oaZceRKhjp+%XB~*&5fDaGtB&@d@Xd%FRXE z*-K}!hjV%c$00sp&#IYK+qK0yrjcddENVE9_(+iT+mIR5^Sh<3uMK+)T27h8K8!`{ zmsh3}IT1aJh)HO1_(C5+og?Af5T6rrdq&Ny_HnPGclc+*H6YHhGWT0wzcZ{teDuEx zZLBqxZn3%~}#W=M+d(rNPvG&yVeUr1N zvAkaFS}nee)+ZnS>dTbauLH;4Uk*0jei8p6uru2KTC{#=4F3&a*Q9OU4ut0{<39~B zwuBr_=4a8ySTB9Tp2^*~1&49we=ZM_bG_Q$KcDD-JJ`Dw{eKQ_-7|aFJa)m}_amO0 ze8l_$+`8xbyTblu%|0V?euZs}d~gruwp`>r0?x9n%fkK>*1h+1ESpDX*Ze4AjJz|5 zBla=yHMlcD&-oHW{$^q(qWS5q za=!PEQ)3wI^Ca>5{Ed_I4v4K||ILW~?#%04nsctpI$O{_*3owvB4-`(Q%o=Jkm4`wfb|SLA%`!&Yqf!L{gfAH08Ck*M1TAkJ z&-IOnXW;Kd%=4z45Bts7_TNU1`}7vX82PC4Rt}o~oP5-IA2?m>`>~CYAIWF@0NDEa z;{JaS?Ckp7XTJw>_P!SVVZ`(Dx5Zwg(H}wNW4@1q%}dYXW7zUn=RN!+;ygDXvGyBt{%T^J?+KI`m6%g4EW73@9Iw+pRpkE76EL*&vk{CdvE?7Oj@E#8A~ zfQ^xlI^P6aCq371VarFIZ-bpJ>U;-mjC{OL-vwJ=U)-VZf#svu_rdiu{{T)tdffv~ zd)KBOP9-o@y@A@cD%`uCixpq=@jXdh?R_YXwQnZ+^Jzrgm3 zd--p$+yNx+<>TN-5PjzP9e4t9&8u0@LG+V3C-39hMj?KS^#2Dj##(U)p8{KB26BFW zpPmLA>*HP+BNsW(fX$hSM9zP~=GfC5W8@;I0f#vTM9wI%InHQ~F>;u4jSeBjy;ext?RhJR2+@Yd;ojo_ypz2W0>({@STj)JJ3Gpr(qtBQ*X!)qW0PJ3ey%6loG2| zRB$@uY1nDb>Db1|+tYowj@UidA2YrH?2N|fGsbh1kA7!>-Gi{t1UqxgxD;%Re8emR zr!$^~o#vd4ZH&Anr; Ob6yFS_q!wZyYoNZmUa~Y delta 2360 zcmZ9MOKeqD6oz+uTY4{0krY8?fYKp2s7*+W(n^R?qedK==)hDgT0_0HtwTIErg0`Q zXQBfYtXf30Vts*ti17_7Dq>N5pv8Ap6oIP6?>jvkn|qqS>tFv`d+oI!ci-Q<>HPXa zbz@x!RbfV`4>RwpzoL2~Y`*S5^;Lr*gwD3E<>l4oZfL0G?@8<(%gdcz<(`h-cC|tn zLTg*ulkw-|J-222rA(h3w>9l6G$ik#^mcR+tqFVKtJ_!gVlsapw!7ozTNI4%$9A=K zw{_-?(Fk>UuPwm3ocP2k8-imz&6ms7g z-Urk7H*Q}{w^siET$byxix0D$fo;+MXwtukF-bP)V+4~7#6q9szFHi32p+;+tpCs# zUg&cgr`A3fURCwK@7~m;{l$CEaG$+}?uH}J#15siOY<)&!n|KGqQAAm_qI)qd*i11 z`_i`3f~t}9_R^wSD~}HEEnQzomNJoP4}pHLjN@2?T?f33UJP?Qvh`pcxHmttN8pZA zkJ_Vf^#RbA=RF305EyG=BObu2Q8&r%%qAS~OKj#TxJlPizaoy^a0}G~V;1ruP>&1P0>2@T zd2ss9;l{rV)aHSuNxrq)@<8l-8YhXv(G^?RD>npu{)`Dc}pf`!X4&DH9^0(lXlfR9o9w&bXK6MfA;+v!1 zmoIEL-1^2=pm`Vf1kS~#47$DEb;OK&&wVS@z2iOsa$i(``c7_JI(_k zx85Mu7v}_8jA`S(eFt9^!V<1rF+2V55vo8FI1E+-lPorhHSWQbu|K5iXE!82%D_fc zZ~RX{tsB_pFIYd@H1;!4vrTzyWen~N@&5b-oBxPzS3 zK_;oiL4U)YXdJ{b|G*t%la4V*%|nYjrOj6yPQp(uxE{yZ6iG&@A8p Q<4)j$CyWi3=B%y#58psW6#xJL From 7630c7aec73ed43e5c7a1e395776fb83d486d552 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 18:48:12 -0800 Subject: [PATCH 04/16] Fix WMO doodad rotation: remove incorrect quaternion X/Y swap The glm::quat(w,x,y,z) constructor was receiving swapped X/Y components, causing doodads like the Deeprun Tram gears to be oriented horizontally instead of vertically. Also use createInstanceWithMatrix for instance WMO doodads to preserve full rotation from the quaternion. --- src/core/application.cpp | 6 +++--- src/rendering/terrain_manager.cpp | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/core/application.cpp b/src/core/application.cpp index 8450dfb8..d169b156 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -3712,8 +3712,8 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float } if (!m2Model.isValid()) continue; - glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y, - doodad.rotation.x, doodad.rotation.z); + glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x, + doodad.rotation.y, doodad.rotation.z); glm::mat4 doodadLocal(1.0f); doodadLocal = glm::translate(doodadLocal, doodad.position); doodadLocal *= glm::mat4_cast(fixedRotation); @@ -3724,7 +3724,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); m2Renderer->loadModel(m2Model, doodadModelId); - uint32_t doodadInstId = m2Renderer->createInstance(doodadModelId, worldPos, glm::vec3(0.0f), doodad.scale); + uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos); if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true); loadedDoodads++; } diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 9ecd3df9..5fbca940 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -567,8 +567,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { // Build doodad's local transform (WoW coordinates) // WMO doodads use quaternion rotation - // Fix: WoW quaternions need X/Y swap for correct orientation - glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y, doodad.rotation.x, doodad.rotation.z); + glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x, doodad.rotation.y, doodad.rotation.z); glm::mat4 doodadLocal(1.0f); doodadLocal = glm::translate(doodadLocal, doodad.position); From 5a227c0376b860b3024ac3313e47c90685f1c4c1 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 19:15:34 -0800 Subject: [PATCH 05/16] Add water refraction toggle with per-frame scene history Fix VK_ERROR_DEVICE_LOST crash by allocating per-frame scene history images (color + depth) instead of a single shared image that raced between frames in flight. Water refraction can now be toggled via Settings > Video > Water Refraction. Without refraction: richer blue base colors, animated caustic shimmer, and normal-based color shifts give the water visible life. With refraction: clean screen-space refraction with Beer-Lambert absorption. Disabling clears scene history to black for immediate fallback. --- assets/shaders/water.frag.glsl | 29 +- assets/shaders/water.frag.spv | Bin 32584 -> 34440 bytes include/rendering/renderer.hpp | 3 + include/rendering/water_renderer.hpp | 28 +- include/ui/game_screen.hpp | 2 + src/rendering/renderer.cpp | 33 ++- src/rendering/water_renderer.cpp | 402 ++++++++++++++++----------- src/ui/game_screen.cpp | 17 ++ 8 files changed, 323 insertions(+), 191 deletions(-) diff --git a/assets/shaders/water.frag.glsl b/assets/shaders/water.frag.glsl index ecd7ee1d..c7dbc5b4 100644 --- a/assets/shaders/water.frag.glsl +++ b/assets/shaders/water.frag.glsl @@ -226,11 +226,32 @@ void main() { float depthFade = 1.0 - exp(-verticalDepth * 0.15); vec3 waterBody = mix(shallowColor, deepColor, depthFade); - vec3 refractedColor = mix(foggedScene * absorbed, waterBody, depthFade * 0.7); + // Detect if scene history is available (scene data captured for refraction) + float sceneBrightness = dot(sceneRefract, vec3(0.299, 0.587, 0.114)); + bool hasSceneData = (sceneBrightness > 0.003); - if (verticalDepth < 0.01) { - float opticalDepth = 1.0 - exp(-dist * 0.004); - refractedColor = mix(foggedScene, waterBody, opticalDepth * 0.6); + // Animated caustic shimmer — only without refraction (refraction already provides movement) + if (!hasSceneData) { + float caustic1 = noiseValue(FragPos.xy * 1.8 + time * vec2(0.3, 0.15)); + float caustic2 = noiseValue(FragPos.xy * 3.2 - time * vec2(0.2, 0.35)); + float causticPattern = caustic1 * 0.6 + caustic2 * 0.4; + vec3 causticTint = vec3(0.08, 0.18, 0.28) * smoothstep(0.35, 0.75, causticPattern); + waterBody += causticTint; + } + + vec3 refractedColor; + if (hasSceneData) { + refractedColor = mix(foggedScene * absorbed, waterBody, depthFade * 0.7); + if (verticalDepth < 0.01) { + float opticalDepth = 1.0 - exp(-dist * 0.004); + refractedColor = mix(foggedScene, waterBody, opticalDepth * 0.6); + } + } else { + // No refraction data — use lit water body with animated variation + vec3 litWater = waterBody * (ambientColor.rgb * 0.8 + NdotL * lightColor.rgb * 0.6); + float normalShift = dot(detailNorm.xy, vec2(0.5, 0.5)); + litWater += vec3(0.02, 0.06, 0.10) * normalShift; + refractedColor = litWater; } vec3 litBase = waterBody * (ambientColor.rgb * 0.7 + NdotL * lightColor.rgb * 0.5); diff --git a/assets/shaders/water.frag.spv b/assets/shaders/water.frag.spv index 5f1d56b1b98de9dc2b06e9828729bd0a7d61ea76..6fe7f2a6449a2a30df1299673bfdd8b027c567bb 100644 GIT binary patch literal 34440 zcmZ{t2b^71)xHm8CiLDxdXX*=s~8u2FEPD`7%|tRJB62 zQgvBxRUfNWOQTek^)A#aRAXyCb@~z0cN|^NzvHgE?WE(%)flVVwo0{P)dRN7>l>J_ zzRc29wGL$i$}t!g5&vD3ODU_;R$n>?jiINidVo%Qs^zPx(@&my=%G`mPd<6p@bJl_ z3(g*Fojf=&zjbo|z({M>f`Kzy`Yl`d4a}c4xUj!;=*6SVrI)xX(?|cpzQH4AjSLMA z9@BS5>yV+5d3}RhpD}xvy=U*dH|DCBTvntWu5k~uQdJ8E=Fy|J<*K!)4;UC-FfuT6 z;evso`BSGJOY?DiTMS|Q)$uu`_8(KNK|QU;z15o3BSQ=4%$?sF9j&!xs|~29wua`l z7L1&I#Jqt8bGIf+`;S;X)w#24HFJA9utG(c} zT7!cN2m3~*4E4?HZo5a(c2DrB0}B?k7-=s%d!PT+7R^5GygkM-)qddqo!k0EF(;iq zQ^X`EehYYZXMtxmKIe%WpG(Az&*k9$X=l~>f2PJ~*Z488o6T|kNL;t=sh$M8*W2eyPxW+o zV}BOj-p&n`QE7YgexTpFT^_)3pk)3{A2bQWiQQGY$!v`8X4eS_=cNDm< z#y!>1;NiZJzVvk*d}d=ismpdUd{$$d)n#kJa~=$I+0KEV(X_i@30qHfWf%YG5`0W` zBRpfc1>7D(PjxGJ-oSh>QT3Q^ho4>dF{Zi)?7Dg0d99t_8kjS8=8#i}(o;Q%=3YOL zgyj#zhld8{FKFzK$UAe=Q~h8GKBjsKp0U2TgsrD~xr_e=zW6-rtzLo8XpKx^#_MbC zSTa!aIAfr7ruA6<-pqm4`~{6gTRX3@*vxCj&}b*V_p;eTa|+*fzRR}t z8BX0OIPsSQkIwDuA3C#RBKnx>?+%c|b@~r@X5Z+*tRv4Jc4AzY*U@Gy9G$C=`N%!y z4fVGMsiP^M$z5*3yx}@W{hTAUDI=}ZfgRnsQ9ok1)p8BZQUCVboW5|Ne_|&Kb1=|w zlY@Eo!bfL~v|96z@)CnW1hhGWC$px^$J9N9<`uOy7pBfl!dzpvN7Ixq|$lm}j_vpr5d^7m`P9MG1HrfaK&Tfqup`G{6 zXd?r|!-K6O7L2s!&so4syDqz;I|%cAbH``${PsEATTNB#>mTSFY;Q30Im|ZG`a9>? zFx-8khxW7KgFM${%!}aV{r=*Hx9|5o)n)L0PwqO8p6Y6NbFU}f*ye0P z&YnHmYM;Bk)h8G0^#bX83)-mbaCo~xJ7#ZnTlD(g-X8DmX!V+Pw4UmVXytwVOYru6 zeQb4CVoc{^`{ULG&>#kr5Io<#4PIXW~l>~&|-P>V?Jg%_gF ztWO=3WvZ9Z7S8W)jnv6c>0A|z|1!2?921wQ)(G2$x$#=0?ol`CT#MRz`&`C-!r<`S zzV`X4zfDHx4$Pa^8tH7-#PXc()BOkr$i>4w{O6_*b9@cFM2=JX`rFqSt|{2Kmd$DP z*Q-w3u~iQNxn2zp4xQP##yS4w(feDi;fQLh)*AC Rlh9^1sB{`Ng-Y_*{=-GdWH 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 literal 32584 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 AqyPW_ diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 8953a78f..d29ada83 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -256,6 +256,9 @@ public: bool areShadowsEnabled() const { return shadowsEnabled; } void setMsaaSamples(VkSampleCountFlagBits samples); + void setWaterRefractionEnabled(bool enabled); + bool isWaterRefractionEnabled() const; + private: void applyMsaaChange(); VkSampleCountFlagBits pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT; diff --git a/include/rendering/water_renderer.hpp b/include/rendering/water_renderer.hpp index 99767782..edc15e6b 100644 --- a/include/rendering/water_renderer.hpp +++ b/include/rendering/water_renderer.hpp @@ -93,12 +93,13 @@ public: bool hasWater1xPass() const { return water1xRenderPass != VK_NULL_HANDLE; } VkRenderPass getWater1xRenderPass() const { return water1xRenderPass; } - void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, float time, bool use1x = false); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, float time, bool use1x = false, uint32_t frameIndex = 0); void captureSceneHistory(VkCommandBuffer cmd, VkImage srcColorImage, VkImage srcDepthImage, VkExtent2D srcExtent, - bool srcDepthIsMsaa); + bool srcDepthIsMsaa, + uint32_t frameIndex = 0); // --- Planar reflection pass --- // Call sequence: beginReflectionPass → [render scene] → endReflectionPass @@ -124,6 +125,9 @@ public: void setEnabled(bool enabled) { renderingEnabled = enabled; } bool isEnabled() const { return renderingEnabled; } + void setRefractionEnabled(bool enabled); + bool isRefractionEnabled() const { return refractionEnabled; } + std::optional getWaterHeightAt(float glX, float glY) const; /// Like getWaterHeightAt but only returns water surfaces whose height is /// close to the query Z (within maxAbove units above). Avoids false @@ -159,17 +163,22 @@ private: VkDescriptorPool materialDescPool = VK_NULL_HANDLE; VkDescriptorSetLayout sceneSetLayout = VK_NULL_HANDLE; VkDescriptorPool sceneDescPool = VK_NULL_HANDLE; - VkDescriptorSet sceneSet = VK_NULL_HANDLE; static constexpr uint32_t MAX_WATER_SETS = 16384; VkSampler sceneColorSampler = VK_NULL_HANDLE; VkSampler sceneDepthSampler = VK_NULL_HANDLE; - VkImage sceneColorImage = VK_NULL_HANDLE; - VmaAllocation sceneColorAlloc = VK_NULL_HANDLE; - VkImageView sceneColorView = VK_NULL_HANDLE; - VkImage sceneDepthImage = VK_NULL_HANDLE; - VmaAllocation sceneDepthAlloc = VK_NULL_HANDLE; - VkImageView sceneDepthView = VK_NULL_HANDLE; + // Per-frame scene history to avoid race between frames in flight + static constexpr uint32_t SCENE_HISTORY_FRAMES = 2; + struct PerFrameSceneHistory { + VkImage colorImage = VK_NULL_HANDLE; + VmaAllocation colorAlloc = VK_NULL_HANDLE; + VkImageView colorView = VK_NULL_HANDLE; + VkImage depthImage = VK_NULL_HANDLE; + VmaAllocation depthAlloc = VK_NULL_HANDLE; + VkImageView depthView = VK_NULL_HANDLE; + VkDescriptorSet sceneSet = VK_NULL_HANDLE; + }; + PerFrameSceneHistory sceneHistory[SCENE_HISTORY_FRAMES]; VkExtent2D sceneHistoryExtent = {0, 0}; bool sceneHistoryReady = false; mutable uint32_t renderDiagCounter_ = 0; @@ -200,6 +209,7 @@ private: std::vector surfaces; bool renderingEnabled = true; + bool refractionEnabled = false; }; } // namespace rendering diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index cc1bd4ab..0a2f72f8 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -87,6 +87,7 @@ private: bool pendingVsync = false; int pendingResIndex = 0; bool pendingShadows = true; + bool pendingWaterRefraction = false; int pendingMasterVolume = 100; int pendingMusicVolume = 30; int pendingAmbientVolume = 100; @@ -123,6 +124,7 @@ private: bool minimapSettingsApplied_ = false; bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer + bool waterRefractionApplied_ = false; bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied // Mute state: mute bypasses master volume without touching slider values diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 1570057f..0f1d7593 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -855,6 +855,14 @@ void Renderer::unregisterPreview(CharacterPreview* preview) { } } +void Renderer::setWaterRefractionEnabled(bool enabled) { + if (waterRenderer) waterRenderer->setRefractionEnabled(enabled); +} + +bool Renderer::isWaterRefractionEnabled() const { + return waterRenderer && waterRenderer->isRefractionEnabled(); +} + void Renderer::setMsaaSamples(VkSampleCountFlagBits samples) { if (!vkCtx) return; @@ -1054,20 +1062,27 @@ void Renderer::endFrame() { vkCmdEndRenderPass(currentCmd); - // Scene-history capture is disabled: with MAX_FRAMES_IN_FLIGHT=2, the single - // sceneColorImage can race between frame N-1's water shader read and frame N's - // transfer write, eventually causing VK_ERROR_DEVICE_LOST. Water renders - // without refraction until per-frame scene-history images are implemented. - // TODO: allocate per-frame sceneColor/Depth images to fix the race. + uint32_t frame = vkCtx->getCurrentFrame(); - // Render water in separate 1x pass (without scene refraction for now) + // Capture scene color/depth into per-frame history images for water refraction + if (waterRenderer && waterRenderer->isRefractionEnabled() && waterRenderer->hasSurfaces() + && currentImageIndex < vkCtx->getSwapchainImages().size()) { + waterRenderer->captureSceneHistory( + currentCmd, + vkCtx->getSwapchainImages()[currentImageIndex], + vkCtx->getDepthCopySourceImage(), + vkCtx->getSwapchainExtent(), + vkCtx->isDepthCopySourceMsaa(), + 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(); - uint32_t frame = vkCtx->getCurrentFrame(); if (waterRenderer->beginWater1xPass(currentCmd, currentImageIndex, ext)) { - waterRenderer->render(currentCmd, perFrameDescSets[frame], *camera, globalTime, true); + waterRenderer->render(currentCmd, perFrameDescSets[frame], *camera, globalTime, true, frame); waterRenderer->endWater1xPass(currentCmd); } } @@ -3268,7 +3283,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { bool waterDeferred = waterRenderer && waterRenderer->hasWater1xPass() && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT; if (waterRenderer && camera && !waterDeferred) { - waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime); + waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, vkCtx->getCurrentFrame()); } // Weather particles diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index fd3c3981..452d6dc2 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -118,15 +118,15 @@ bool WaterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLay return false; } - // Pool needs 3 combined image samplers + 1 uniform buffer + // Pool needs 3 combined image samplers + 1 uniform buffer per frame std::array scenePoolSizes{}; scenePoolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - scenePoolSizes[0].descriptorCount = 3; + scenePoolSizes[0].descriptorCount = 3 * SCENE_HISTORY_FRAMES; scenePoolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; - scenePoolSizes[1].descriptorCount = 1; + scenePoolSizes[1].descriptorCount = SCENE_HISTORY_FRAMES; VkDescriptorPoolCreateInfo scenePoolInfo{}; scenePoolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; - scenePoolInfo.maxSets = 1; + scenePoolInfo.maxSets = SCENE_HISTORY_FRAMES; scenePoolInfo.poolSizeCount = static_cast(scenePoolSizes.size()); scenePoolInfo.pPoolSizes = scenePoolSizes.data(); if (vkCreateDescriptorPool(device, &scenePoolInfo, nullptr, &sceneDescPool) != VK_SUCCESS) { @@ -267,6 +267,47 @@ void WaterRenderer::recreatePipelines() { } } +void WaterRenderer::setRefractionEnabled(bool enabled) { + if (refractionEnabled == enabled) return; + refractionEnabled = enabled; + + // When turning off, clear scene history images to black so the shader + // detects "no data" and uses the non-refraction path. + if (!enabled && vkCtx) { + vkCtx->immediateSubmit([&](VkCommandBuffer cmd) { + for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) { + auto& sh = sceneHistory[f]; + if (!sh.colorImage) continue; + + VkImageMemoryBarrier toTransfer{}; + toTransfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + toTransfer.oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + toTransfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + toTransfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + toTransfer.image = sh.colorImage; + toTransfer.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + toTransfer.srcAccessMask = VK_ACCESS_SHADER_READ_BIT; + toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &toTransfer); + + VkClearColorValue clearColor = {{0.0f, 0.0f, 0.0f, 0.0f}}; + VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdClearColorImage(cmd, sh.colorImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &clearColor, 1, &range); + + VkImageMemoryBarrier toRead = toTransfer; + toRead.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + toRead.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + toRead.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + toRead.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &toRead); + } + }); + } +} + void WaterRenderer::shutdown() { clear(); @@ -304,13 +345,15 @@ VkDescriptorSet WaterRenderer::allocateMaterialSet() { void WaterRenderer::destroySceneHistoryResources() { if (!vkCtx) return; VkDevice device = vkCtx->getDevice(); - if (sceneColorView) { vkDestroyImageView(device, sceneColorView, nullptr); sceneColorView = VK_NULL_HANDLE; } - if (sceneDepthView) { vkDestroyImageView(device, sceneDepthView, nullptr); sceneDepthView = VK_NULL_HANDLE; } - if (sceneColorImage) { vmaDestroyImage(vkCtx->getAllocator(), sceneColorImage, sceneColorAlloc); sceneColorImage = VK_NULL_HANDLE; sceneColorAlloc = VK_NULL_HANDLE; } - if (sceneDepthImage) { vmaDestroyImage(vkCtx->getAllocator(), sceneDepthImage, sceneDepthAlloc); sceneDepthImage = VK_NULL_HANDLE; sceneDepthAlloc = VK_NULL_HANDLE; } + for (auto& sh : sceneHistory) { + if (sh.colorView) { vkDestroyImageView(device, sh.colorView, nullptr); sh.colorView = VK_NULL_HANDLE; } + if (sh.depthView) { vkDestroyImageView(device, sh.depthView, nullptr); sh.depthView = VK_NULL_HANDLE; } + if (sh.colorImage) { vmaDestroyImage(vkCtx->getAllocator(), sh.colorImage, sh.colorAlloc); sh.colorImage = VK_NULL_HANDLE; sh.colorAlloc = VK_NULL_HANDLE; } + if (sh.depthImage) { vmaDestroyImage(vkCtx->getAllocator(), sh.depthImage, sh.depthAlloc); sh.depthImage = VK_NULL_HANDLE; sh.depthAlloc = VK_NULL_HANDLE; } + sh.sceneSet = VK_NULL_HANDLE; + } if (sceneColorSampler) { vkDestroySampler(device, sceneColorSampler, nullptr); sceneColorSampler = VK_NULL_HANDLE; } if (sceneDepthSampler) { vkDestroySampler(device, sceneDepthSampler, nullptr); sceneDepthSampler = VK_NULL_HANDLE; } - sceneSet = VK_NULL_HANDLE; sceneHistoryExtent = {0, 0}; sceneHistoryReady = false; } @@ -323,54 +366,7 @@ void WaterRenderer::createSceneHistoryResources(VkExtent2D extent, VkFormat colo vkResetDescriptorPool(device, sceneDescPool, 0); sceneHistoryExtent = extent; - VkImageCreateInfo colorImgInfo{}; - colorImgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; - colorImgInfo.imageType = VK_IMAGE_TYPE_2D; - colorImgInfo.format = colorFormat; - colorImgInfo.extent = {extent.width, extent.height, 1}; - colorImgInfo.mipLevels = 1; - colorImgInfo.arrayLayers = 1; - colorImgInfo.samples = VK_SAMPLE_COUNT_1_BIT; - colorImgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; - colorImgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; - colorImgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; - - VmaAllocationCreateInfo allocCI{}; - allocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY; - if (vmaCreateImage(vkCtx->getAllocator(), &colorImgInfo, &allocCI, &sceneColorImage, &sceneColorAlloc, nullptr) != VK_SUCCESS) { - LOG_ERROR("WaterRenderer: failed to create scene color history image"); - return; - } - - VkImageCreateInfo depthImgInfo = colorImgInfo; - depthImgInfo.format = depthFormat; - if (vmaCreateImage(vkCtx->getAllocator(), &depthImgInfo, &allocCI, &sceneDepthImage, &sceneDepthAlloc, nullptr) != VK_SUCCESS) { - LOG_ERROR("WaterRenderer: failed to create scene depth history image"); - return; - } - - VkImageViewCreateInfo colorViewInfo{}; - colorViewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; - colorViewInfo.image = sceneColorImage; - colorViewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; - colorViewInfo.format = colorFormat; - colorViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; - colorViewInfo.subresourceRange.levelCount = 1; - colorViewInfo.subresourceRange.layerCount = 1; - if (vkCreateImageView(device, &colorViewInfo, nullptr, &sceneColorView) != VK_SUCCESS) { - LOG_ERROR("WaterRenderer: failed to create scene color history view"); - return; - } - - VkImageViewCreateInfo depthViewInfo = colorViewInfo; - depthViewInfo.image = sceneDepthImage; - depthViewInfo.format = depthFormat; - depthViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; - if (vkCreateImageView(device, &depthViewInfo, nullptr, &sceneDepthView) != VK_SUCCESS) { - LOG_ERROR("WaterRenderer: failed to create scene depth history view"); - return; - } - + // Create shared samplers VkSamplerCreateInfo sampCI{}; sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; sampCI.magFilter = VK_FILTER_LINEAR; @@ -389,99 +385,155 @@ void WaterRenderer::createSceneHistoryResources(VkExtent2D extent, VkFormat colo return; } - VkDescriptorSetAllocateInfo ai{}; - ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; - ai.descriptorPool = sceneDescPool; - ai.descriptorSetCount = 1; - ai.pSetLayouts = &sceneSetLayout; - if (vkAllocateDescriptorSets(device, &ai, &sceneSet) != VK_SUCCESS) { - LOG_ERROR("WaterRenderer: failed to allocate scene descriptor set"); - sceneSet = VK_NULL_HANDLE; - return; + VkImageCreateInfo colorImgInfo{}; + colorImgInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + colorImgInfo.imageType = VK_IMAGE_TYPE_2D; + colorImgInfo.format = colorFormat; + colorImgInfo.extent = {extent.width, extent.height, 1}; + colorImgInfo.mipLevels = 1; + colorImgInfo.arrayLayers = 1; + colorImgInfo.samples = VK_SAMPLE_COUNT_1_BIT; + colorImgInfo.tiling = VK_IMAGE_TILING_OPTIMAL; + colorImgInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + colorImgInfo.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + VkImageCreateInfo depthImgInfo = colorImgInfo; + depthImgInfo.format = depthFormat; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY; + + // Create per-frame images, views, and descriptor sets + for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) { + auto& sh = sceneHistory[f]; + + if (vmaCreateImage(vkCtx->getAllocator(), &colorImgInfo, &allocCI, &sh.colorImage, &sh.colorAlloc, nullptr) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene color history image [", f, "]"); + return; + } + if (vmaCreateImage(vkCtx->getAllocator(), &depthImgInfo, &allocCI, &sh.depthImage, &sh.depthAlloc, nullptr) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene depth history image [", f, "]"); + return; + } + + VkImageViewCreateInfo colorViewInfo{}; + colorViewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + colorViewInfo.image = sh.colorImage; + colorViewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D; + colorViewInfo.format = colorFormat; + colorViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + colorViewInfo.subresourceRange.levelCount = 1; + colorViewInfo.subresourceRange.layerCount = 1; + if (vkCreateImageView(device, &colorViewInfo, nullptr, &sh.colorView) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene color history view [", f, "]"); + return; + } + + VkImageViewCreateInfo depthViewInfo = colorViewInfo; + depthViewInfo.image = sh.depthImage; + depthViewInfo.format = depthFormat; + depthViewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + if (vkCreateImageView(device, &depthViewInfo, nullptr, &sh.depthView) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to create scene depth history view [", f, "]"); + return; + } + + // Allocate descriptor set for this frame + VkDescriptorSetAllocateInfo ai{}; + ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + ai.descriptorPool = sceneDescPool; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &sceneSetLayout; + if (vkAllocateDescriptorSets(device, &ai, &sh.sceneSet) != VK_SUCCESS) { + LOG_ERROR("WaterRenderer: failed to allocate scene descriptor set [", f, "]"); + sh.sceneSet = VK_NULL_HANDLE; + return; + } + + VkDescriptorImageInfo colorInfo{}; + colorInfo.sampler = sceneColorSampler; + colorInfo.imageView = sh.colorView; + colorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkDescriptorImageInfo depthInfo{}; + depthInfo.sampler = sceneDepthSampler; + depthInfo.imageView = sh.depthView; + depthInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkDescriptorImageInfo reflColorInfo{}; + reflColorInfo.sampler = sceneColorSampler; + reflColorInfo.imageView = reflectionColorView ? reflectionColorView : sh.colorView; + reflColorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkDescriptorBufferInfo reflUBOInfo{}; + reflUBOInfo.buffer = reflectionUBO; + reflUBOInfo.offset = 0; + reflUBOInfo.range = sizeof(ReflectionUBOData); + + std::vector writes; + + VkWriteDescriptorSet w0{}; + w0.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w0.dstSet = sh.sceneSet; + w0.dstBinding = 0; + w0.descriptorCount = 1; + w0.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w0.pImageInfo = &colorInfo; + writes.push_back(w0); + + VkWriteDescriptorSet w1{}; + w1.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w1.dstSet = sh.sceneSet; + w1.dstBinding = 1; + w1.descriptorCount = 1; + w1.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w1.pImageInfo = &depthInfo; + writes.push_back(w1); + + VkWriteDescriptorSet w2{}; + w2.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w2.dstSet = sh.sceneSet; + w2.dstBinding = 2; + w2.descriptorCount = 1; + w2.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + w2.pImageInfo = &reflColorInfo; + writes.push_back(w2); + + if (reflectionUBO) { + VkWriteDescriptorSet w3{}; + w3.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + w3.dstSet = sh.sceneSet; + w3.dstBinding = 3; + w3.descriptorCount = 1; + w3.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + w3.pBufferInfo = &reflUBOInfo; + writes.push_back(w3); + } + + vkUpdateDescriptorSets(device, static_cast(writes.size()), writes.data(), 0, nullptr); } - VkDescriptorImageInfo colorInfo{}; - colorInfo.sampler = sceneColorSampler; - colorInfo.imageView = sceneColorView; - colorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - VkDescriptorImageInfo depthInfo{}; - depthInfo.sampler = sceneDepthSampler; - depthInfo.imageView = sceneDepthView; - depthInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - - // Reflection color texture (binding 2) — use scene color as placeholder until reflection is created - VkDescriptorImageInfo reflColorInfo{}; - reflColorInfo.sampler = sceneColorSampler; - reflColorInfo.imageView = reflectionColorView ? reflectionColorView : sceneColorView; - reflColorInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - - // Reflection UBO (binding 3) - VkDescriptorBufferInfo reflUBOInfo{}; - reflUBOInfo.buffer = reflectionUBO; - reflUBOInfo.offset = 0; - reflUBOInfo.range = sizeof(ReflectionUBOData); - - // Write bindings 0,1 always; write 2,3 only if reflection resources exist - std::vector writes; - - VkWriteDescriptorSet w0{}; - w0.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - w0.dstSet = sceneSet; - w0.dstBinding = 0; - w0.descriptorCount = 1; - w0.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - w0.pImageInfo = &colorInfo; - writes.push_back(w0); - - VkWriteDescriptorSet w1{}; - w1.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - w1.dstSet = sceneSet; - w1.dstBinding = 1; - w1.descriptorCount = 1; - w1.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - w1.pImageInfo = &depthInfo; - writes.push_back(w1); - - VkWriteDescriptorSet w2{}; - w2.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - w2.dstSet = sceneSet; - w2.dstBinding = 2; - w2.descriptorCount = 1; - w2.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - w2.pImageInfo = &reflColorInfo; - writes.push_back(w2); - - if (reflectionUBO) { - VkWriteDescriptorSet w3{}; - w3.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - w3.dstSet = sceneSet; - w3.dstBinding = 3; - w3.descriptorCount = 1; - w3.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; - w3.pBufferInfo = &reflUBOInfo; - writes.push_back(w3); - } - - vkUpdateDescriptorSets(device, static_cast(writes.size()), writes.data(), 0, nullptr); - - // Initialize history images to shader-read layout so first frame samples are defined. + // Initialize all per-frame history images to shader-read layout vkCtx->immediateSubmit([&](VkCommandBuffer cmd) { - VkImageMemoryBarrier barriers[2]{}; - barriers[0].sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - barriers[0].oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; - barriers[0].newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - barriers[0].srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barriers[0].dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - barriers[0].image = sceneColorImage; - barriers[0].subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; - barriers[0].dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - - barriers[1] = barriers[0]; - barriers[1].image = sceneDepthImage; - barriers[1].subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + std::vector barriers; + for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) { + VkImageMemoryBarrier b{}; + b.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + b.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + b.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + b.image = sceneHistory[f].colorImage; + b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + barriers.push_back(b); + b.image = sceneHistory[f].depthImage; + b.subresourceRange.aspectMask = VK_IMAGE_ASPECT_DEPTH_BIT; + barriers.push_back(b); + } vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, - 0, 0, nullptr, 0, nullptr, 2, barriers); + 0, 0, nullptr, 0, nullptr, static_cast(barriers.size()), barriers.data()); }); } @@ -986,7 +1038,7 @@ void WaterRenderer::clear() { // ============================================================== void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, - const Camera& /*camera*/, float /*time*/, bool use1x) { + const Camera& /*camera*/, float /*time*/, bool use1x, uint32_t frameIndex) { VkPipeline pipeline = (use1x && water1xPipeline) ? water1xPipeline : waterPipeline; if (!renderingEnabled || surfaces.empty() || !pipeline) { if (renderDiagCounter_++ % 300 == 0 && !surfaces.empty()) { @@ -997,7 +1049,9 @@ void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, } return; } - if (!sceneSet) { + uint32_t fi = frameIndex % SCENE_HISTORY_FRAMES; + VkDescriptorSet activeSceneSet = sceneHistory[fi].sceneSet; + if (!activeSceneSet) { if (renderDiagCounter_++ % 300 == 0) { LOG_WARNING("Water: render skipped — sceneSet is null, surfaces=", surfaces.size()); } @@ -1009,7 +1063,7 @@ void WaterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, 0, 1, &perFrameSet, 0, nullptr); vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout, - 2, 1, &sceneSet, 0, nullptr); + 2, 1, &activeSceneSet, 0, nullptr); for (const auto& surface : surfaces) { if (surface.vertexBuffer == VK_NULL_HANDLE || surface.indexCount == 0) continue; @@ -1050,8 +1104,11 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, VkImage srcColorImage, VkImage srcDepthImage, VkExtent2D srcExtent, - bool srcDepthIsMsaa) { - if (!vkCtx || !cmd || !sceneColorImage || !sceneDepthImage || srcExtent.width == 0 || srcExtent.height == 0) { + bool srcDepthIsMsaa, + uint32_t frameIndex) { + uint32_t fi = frameIndex % SCENE_HISTORY_FRAMES; + auto& sh = sceneHistory[fi]; + if (!vkCtx || !cmd || !sh.colorImage || !sh.depthImage || srcExtent.width == 0 || srcExtent.height == 0) { return; } @@ -1091,7 +1148,7 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, 0, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - barrier2(sceneColorImage, VK_IMAGE_ASPECT_COLOR_BIT, + barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); @@ -1101,9 +1158,9 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, colorCopy.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; colorCopy.extent = {copyExtent.width, copyExtent.height, 1}; vkCmdCopyImage(cmd, srcColorImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - sceneColorImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &colorCopy); + sh.colorImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &colorCopy); - barrier2(sceneColorImage, VK_IMAGE_ASPECT_COLOR_BIT, + barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); @@ -1118,7 +1175,7 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); - barrier2(sceneDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT, + barrier2(sh.depthImage, VK_IMAGE_ASPECT_DEPTH_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); @@ -1128,9 +1185,9 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, depthCopy.dstSubresource = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 0, 1}; depthCopy.extent = {copyExtent.width, copyExtent.height, 1}; vkCmdCopyImage(cmd, srcDepthImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - sceneDepthImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &depthCopy); + sh.depthImage, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &depthCopy); - barrier2(sceneDepthImage, VK_IMAGE_ASPECT_DEPTH_BIT, + barrier2(sh.depthImage, VK_IMAGE_ASPECT_DEPTH_BIT, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); @@ -1543,11 +1600,11 @@ bool WaterRenderer::isWmoWaterAt(float glX, float glY) const { glm::vec4 WaterRenderer::getLiquidColor(uint16_t liquidType) const { uint8_t basicType = (liquidType == 0) ? 0 : ((liquidType - 1) % 4); switch (basicType) { - case 0: return glm::vec4(0.12f, 0.32f, 0.48f, 1.0f); // inland: blue-green - case 1: return glm::vec4(0.04f, 0.14f, 0.30f, 1.0f); // ocean: deep blue + case 0: return glm::vec4(0.10f, 0.28f, 0.55f, 1.0f); // inland: richer blue + case 1: return glm::vec4(0.04f, 0.16f, 0.38f, 1.0f); // ocean: deep blue case 2: return glm::vec4(0.9f, 0.3f, 0.05f, 1.0f); // magma case 3: return glm::vec4(0.2f, 0.6f, 0.1f, 1.0f); // slime - default: return glm::vec4(0.12f, 0.32f, 0.48f, 1.0f); + default: return glm::vec4(0.10f, 0.28f, 0.55f, 1.0f); } } @@ -1815,21 +1872,28 @@ void WaterRenderer::endReflectionPass(VkCommandBuffer cmd) { vkCmdEndRenderPass(cmd); reflectionColorLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - // Update scene descriptor set with the freshly rendered reflection texture - if (sceneSet && reflectionColorView && reflectionSampler) { + // Update all per-frame scene descriptor sets with the freshly rendered reflection texture + if (reflectionColorView && reflectionSampler) { VkDescriptorImageInfo reflInfo{}; reflInfo.sampler = reflectionSampler; reflInfo.imageView = reflectionColorView; reflInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - VkWriteDescriptorSet write{}; - write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - write.dstSet = sceneSet; - write.dstBinding = 2; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - write.pImageInfo = &reflInfo; - vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr); + std::vector writes; + for (uint32_t f = 0; f < SCENE_HISTORY_FRAMES; f++) { + if (!sceneHistory[f].sceneSet) continue; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = sceneHistory[f].sceneSet; + write.dstBinding = 2; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &reflInfo; + writes.push_back(write); + } + if (!writes.empty()) { + vkUpdateDescriptorSets(vkCtx->getDevice(), static_cast(writes.size()), writes.data(), 0, nullptr); + } } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 5a232e23..98c2f4fb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -288,6 +288,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { msaaSettingsApplied_ = true; } + // Apply saved water refraction setting once when renderer is available + if (!waterRefractionApplied_) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + renderer->setWaterRefractionEnabled(pendingWaterRefraction); + waterRefractionApplied_ = true; + } + } + // Apply saved normal mapping / POM settings once when WMO renderer is available if (!normalMapSettingsApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -6237,6 +6246,10 @@ void GameScreen::renderSettingsWindow() { if (renderer) renderer->setShadowsEnabled(pendingShadows); saveSettings(); } + if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { + if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + saveSettings(); + } { const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; if (ImGui::Combo("Anti-Aliasing", &pendingAntiAliasing, aaLabels, 4)) { @@ -6336,7 +6349,9 @@ void GameScreen::renderSettingsWindow() { window->setFullscreen(pendingFullscreen); window->setVsync(pendingVsync); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); + pendingWaterRefraction = false; if (renderer) renderer->setShadowsEnabled(pendingShadows); + if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); if (renderer) renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); if (renderer) { if (auto* tm = renderer->getTerrainManager()) { @@ -7349,6 +7364,7 @@ void GameScreen::saveSettings() { out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; + out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; @@ -7433,6 +7449,7 @@ void GameScreen::loadSettings() { else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); else if (key == "shadows") pendingShadows = (std::stoi(val) != 0); + else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); From 4cae4bfcdc5a9d94fac17d701241b03491477af5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 19:21:48 -0800 Subject: [PATCH 06/16] Fix WMO shadow culling: use AABB instead of origin point distance WMO origins can be far from their visible geometry, causing large city buildings to be culled from the shadow pass. Use world bounding box for instance culling and per-group AABB culling. Also increase WMO shadow cull radius to match the shadow map coverage (180 units). --- src/rendering/wmo_renderer.cpp | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index ce4d4ab1..5c8be756 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1724,11 +1724,18 @@ void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceM struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; - const float shadowRadiusSq = shadowRadius * shadowRadius; + // WMO shadow cull uses the ortho half-extent (shadow map coverage) rather than + // the proximity radius so that distant buildings whose shadows reach the player + // are still rendered into the shadow map. + const float wmoCullRadius = std::max(shadowRadius, 180.0f); + const float wmoCullRadiusSq = wmoCullRadius * wmoCullRadius; + for (const auto& instance : instances) { - // Distance cull against shadow frustum - glm::vec3 diff = instance.position - shadowCenter; - if (glm::dot(diff, diff) > shadowRadiusSq) continue; + // Distance cull using world bounding box — WMO origins can be far from + // their geometry, so point-based culling misses large buildings. + glm::vec3 closest = glm::clamp(shadowCenter, instance.worldBoundsMin, instance.worldBoundsMax); + glm::vec3 diff = closest - shadowCenter; + if (glm::dot(diff, diff) > wmoCullRadiusSq) continue; auto modelIt = loadedModels.find(instance.modelId); if (modelIt == loadedModels.end()) continue; const ModelData& model = modelIt->second; @@ -1737,7 +1744,8 @@ void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceM vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, 128, &push); - for (const auto& group : model.groups) { + for (size_t gi = 0; gi < model.groups.size(); ++gi) { + const auto& group = model.groups[gi]; if (group.vertexBuffer == VK_NULL_HANDLE || group.indexBuffer == VK_NULL_HANDLE) continue; // Skip antiportal geometry @@ -1746,13 +1754,18 @@ void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceM // Skip LOD groups in shadow pass (they overlap real geometry) if (group.isLOD) continue; + // Per-group AABB cull against shadow frustum + if (gi < instance.worldGroupBounds.size()) { + const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; + glm::vec3 gClosest = glm::clamp(shadowCenter, gMin, gMax); + glm::vec3 gDiff = gClosest - shadowCenter; + if (glm::dot(gDiff, gDiff) > wmoCullRadiusSq) continue; + } + VkDeviceSize offset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, &group.vertexBuffer, &offset); vkCmdBindIndexBuffer(cmd, group.indexBuffer, 0, VK_INDEX_TYPE_UINT16); - // Draw all batches in shadow pass. - // WMO transparency classification is not reliable enough for caster - // selection here and was dropping major world casters. for (const auto& mb : group.mergedBatches) { for (const auto& dr : mb.draws) { vkCmdDrawIndexed(cmd, dr.indexCount, 1, dr.firstIndex, 0, 0); From 8014dde29b74b8dc8565557be3e350b5c8bb31f7 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 20:00:27 -0800 Subject: [PATCH 07/16] Improve WMO wall collision, unstuck, interior zoom, and chat focus - Stronger wall collision push (0.35/0.15) and swept push (0.45/0.25) for interior/exterior WMOs to reduce clipping through tunnel walls - Use all triangles (not just pre-classified walls) for collision checks - Allow invisible collidable triangles (MOPY 0x01 without 0x20) to block - Pass insideWMO flag to all collision callers, match swim sweep to ground - Widen swept hit detection radius from 0.15 to 0.25 - Restrict camera zoom to 12 units inside WMO interiors - Fix /unstuck launching player above WMOs: remove +20 fallback, use gravity when no floor found - Slash and Enter keys always focus chat unless already typing --- include/rendering/camera_controller.hpp | 2 ++ src/core/application.cpp | 8 ++++++-- src/rendering/camera_controller.cpp | 17 +++++++++++------ src/rendering/wmo_renderer.cpp | 25 +++++++++++++------------ src/ui/game_screen.cpp | 8 ++++---- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 3431ce55..06ba2578 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -81,6 +81,7 @@ public: bool isSitting() const { return sitting; } bool isSwimming() const { return swimming; } bool isInsideWMO() const { return cachedInsideWMO; } + void setGrounded(bool g) { grounded = g; } bool isOnTaxi() const { return externalFollow_; } const glm::vec3* getFollowTarget() const { return followTarget; } glm::vec3* getFollowTargetMutable() { return followTarget; } @@ -141,6 +142,7 @@ private: static constexpr float MIN_DISTANCE = 0.5f; // Minimum zoom (first-person threshold) static constexpr float MAX_DISTANCE_NORMAL = 22.0f; // Default max zoom out static constexpr float MAX_DISTANCE_EXTENDED = 50.0f; // Extended max zoom out + static constexpr float MAX_DISTANCE_INTERIOR = 12.0f; // Max zoom inside WMOs bool extendedZoom_ = false; static constexpr float ZOOM_SMOOTH_SPEED = 15.0f; // How fast zoom eases static constexpr float CAM_SMOOTH_SPEED = 20.0f; // How fast camera position smooths diff --git a/src/core/application.cpp b/src/core/application.cpp index d169b156..d5da23ff 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1667,13 +1667,17 @@ void Application::setupUICallbacks() { } // Sample floor at the DESTINATION position (after nudge). + // Pick the highest floor so we snap up to WMO floors when fallen below. + bool foundFloor = false; if (auto floor = sampleBestFloorAt(pos.x, pos.y, pos.z + 60.0f)) { pos.z = *floor + 0.2f; - } else { - pos.z += 20.0f; + foundFloor = true; } cc->teleportTo(pos); + if (!foundFloor) { + cc->setGrounded(false); // Let gravity pull player down to a surface + } syncTeleportedPositionToServer(pos); forceServerTeleportCommand(pos); clearStuckMovement(); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index e184f842..392ef71b 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -626,7 +626,8 @@ void CameraController::update(float deltaTime) { glm::vec3 stepPos = swimFrom; if (swimMoveDist > 0.01f) { - int swimSteps = std::max(1, std::min(3, static_cast(std::ceil(swimMoveDist / 0.65f)))); + float swimStepSize = cachedInsideWMO ? 0.20f : 0.35f; + int swimSteps = std::max(1, std::min(8, static_cast(std::ceil(swimMoveDist / swimStepSize)))); glm::vec3 stepDelta = (swimTo - swimFrom) / static_cast(swimSteps); for (int i = 0; i < swimSteps; i++) { @@ -634,7 +635,7 @@ void CameraController::update(float deltaTime) { if (wmoRenderer) { glm::vec3 adjusted; - if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { + if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted, cachedInsideWMO)) { candidate.x = adjusted.x; candidate.y = adjusted.y; candidate.z = std::max(candidate.z, adjusted.z); @@ -1274,8 +1275,10 @@ void CameraController::update(float deltaTime) { lastInsideWMOCheckPos = targetPos; } - // Do not clamp zoom target by ceiling checks. First-person should always - // be reachable; occlusion handling below will resolve camera placement safely. + // Smoothly pull camera in when entering WMO interiors + if (cachedInsideWMO && userTargetDistance > MAX_DISTANCE_INTERIOR) { + userTargetDistance = MAX_DISTANCE_INTERIOR; + } } // ===== Camera collision (sphere sweep approximation) ===== @@ -1499,14 +1502,15 @@ void CameraController::update(float deltaTime) { float moveDist = glm::length(desiredFeet - startFeet); if (moveDist > 0.01f) { - int sweepSteps = std::max(1, std::min(3, static_cast(std::ceil(moveDist / 0.65f)))); + float stepSize = cachedInsideWMO ? 0.20f : 0.35f; + int sweepSteps = std::max(1, std::min(8, static_cast(std::ceil(moveDist / stepSize)))); glm::vec3 stepPos = startFeet; glm::vec3 stepDelta = (desiredFeet - startFeet) / static_cast(sweepSteps); for (int i = 0; i < sweepSteps; i++) { glm::vec3 candidate = stepPos + stepDelta; glm::vec3 adjusted; - if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted)) { + if (wmoRenderer->checkWallCollision(stepPos, candidate, adjusted, cachedInsideWMO)) { candidate.x = adjusted.x; candidate.y = adjusted.y; candidate.z = std::max(candidate.z, adjusted.z); @@ -1985,6 +1989,7 @@ void CameraController::processMouseWheel(float delta) { float zoomSpeed = glm::max(userTargetDistance * 0.15f, 0.3f); userTargetDistance -= delta * zoomSpeed; float maxDist = extendedZoom_ ? MAX_DISTANCE_EXTENDED : MAX_DISTANCE_NORMAL; + if (cachedInsideWMO) maxDist = std::min(maxDist, MAX_DISTANCE_INTERIOR); userTargetDistance = glm::clamp(userTargetDistance, MIN_DISTANCE, maxDist); } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 5c8be756..6fe36a41 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -3095,7 +3095,7 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, float rangeMinY = std::min(localFrom.y, localTo.y) - PLAYER_RADIUS - 1.5f; float rangeMaxX = std::max(localFrom.x, localTo.x) + PLAYER_RADIUS + 1.5f; float rangeMaxY = std::max(localFrom.y, localTo.y) + PLAYER_RADIUS + 1.5f; - group.getWallTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, triScratch_); + group.getTrianglesInRange(rangeMinX, rangeMinY, rangeMaxX, rangeMaxY, triScratch_); for (uint32_t triStart : triScratch_) { // Use pre-computed Z bounds for fast vertical reject @@ -3113,17 +3113,18 @@ 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. - // Only RENDERED triangles (flag 0x20) with collision intent (0x01) - // should block the player. Skip invisible collision hulls (0x08/0x48) - // and non-collidable render-only geometry. + // 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. uint32_t triIdx = triStart / 3; if (!group.triMopyFlags.empty() && triIdx < group.triMopyFlags.size()) { uint8_t mopy = group.triMopyFlags[triIdx]; - // Must be rendered (0x20) AND have base collision flag (0x01) - bool rendered = (mopy & 0x20) != 0; - bool collidable = (mopy & 0x01) != 0; - if (mopy != 0 && !(rendered && collidable)) { - continue; + if (mopy != 0) { + bool collidable = (mopy & 0x01) != 0; + bool detail = (mopy & 0x04) != 0; + if (!collidable || detail) { + continue; + } } } @@ -3149,13 +3150,13 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, glm::vec3 hitPoint = localFrom + (localTo - localFrom) * tHit; glm::vec3 hitClosest = closestPointOnTriangle(hitPoint, v0, v1, v2); float hitErrSq = glm::dot(hitClosest - hitPoint, hitClosest - hitPoint); - if (hitErrSq <= 0.15f * 0.15f) { + if (hitErrSq <= 0.25f * 0.25f) { float side = fromDist > 0.0f ? 1.0f : -1.0f; glm::vec3 safeLocal = hitPoint + normal * side * (PLAYER_RADIUS + 0.05f); glm::vec3 pushLocal(safeLocal.x - localTo.x, safeLocal.y - localTo.y, 0.0f); // Cap swept pushback so walls don't shove the player violently float pushLen = glm::length(glm::vec2(pushLocal.x, pushLocal.y)); - const float MAX_SWEPT_PUSH = 0.15f; + const float MAX_SWEPT_PUSH = insideWMO ? 0.45f : 0.25f; if (pushLen > MAX_SWEPT_PUSH) { float scale = MAX_SWEPT_PUSH / pushLen; pushLocal.x *= scale; @@ -3185,7 +3186,7 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, 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.12f : 0.08f; + const float MAX_PUSH = insideWMO ? 0.35f : 0.15f; 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 98c2f4fb..1d02d795 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -1367,16 +1367,16 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } - // Slash key: focus chat input - if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { + // Slash key: focus chat input — always works unless already typing in chat + if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { refocusChatInput = true; chatInputBuffer[0] = '/'; chatInputBuffer[1] = '\0'; chatInputMoveCursorToEnd = true; } - // Enter key: focus chat input (empty) - if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_RETURN)) { + // Enter key: focus chat input (empty) — always works unless already typing + if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_RETURN)) { refocusChatInput = true; } From ad66ef9ca6e74cf22b3b30a22f07e4a8bbb91839 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 20:04:19 -0800 Subject: [PATCH 08/16] Fix shadow flicker: render every frame, tighten shadow frustum Remove frame throttling that skipped shadow updates in dense scenes, causing visible flicker on player and NPCs. Reduce shadow half-extent from 180 to 60 for 3x higher resolution on nearby shadows. --- src/rendering/renderer.cpp | 23 +++++------------------ 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 0f1d7593..77b4c74a 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -3766,10 +3766,10 @@ void Renderer::renderHUD() { // in createPerFrameResources() as part of the Vulkan shadow infrastructure. glm::mat4 Renderer::computeLightSpaceMatrix() { - constexpr float kShadowHalfExtent = 180.0f; - constexpr float kShadowLightDistance = 280.0f; + constexpr float kShadowHalfExtent = 60.0f; + constexpr float kShadowLightDistance = 200.0f; constexpr float kShadowNearPlane = 1.0f; - constexpr float kShadowFarPlane = 600.0f; + constexpr float kShadowFarPlane = 450.0f; // Use active lighting direction so shadow projection matches main shading. // Fragment shaders derive lighting with `ldir = normalize(-lightDir.xyz)`, @@ -3920,18 +3920,7 @@ void Renderer::renderShadowPass() { if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return; if (currentCmd == VK_NULL_HANDLE) return; - const int baseInterval = std::max(1, envIntOrDefault("WOWEE_SHADOW_INTERVAL", 1)); - const int denseInterval = std::max(baseInterval, envIntOrDefault("WOWEE_SHADOW_INTERVAL_DENSE", 3)); - const uint32_t denseCharThreshold = static_cast(std::max(1, envIntOrDefault("WOWEE_DENSE_CHAR_THRESHOLD", 120))); - const uint32_t denseM2Threshold = static_cast(std::max(1, envIntOrDefault("WOWEE_DENSE_M2_THRESHOLD", 900))); - const bool denseScene = - (characterRenderer && characterRenderer->getInstanceCount() >= denseCharThreshold) || - (m2Renderer && m2Renderer->getInstanceCount() >= denseM2Threshold); - const int shadowInterval = denseScene ? denseInterval : baseInterval; - if (++shadowFrameCounter_ < static_cast(shadowInterval)) { - return; - } - shadowFrameCounter_ = 0; + // Shadows render every frame — throttling causes visible flicker on player/NPCs // Compute and store light space matrix; write to per-frame UBO lightSpaceMatrix = computeLightSpaceMatrix(); @@ -3984,9 +3973,7 @@ void Renderer::renderShadowPass() { vkCmdSetScissor(currentCmd, 0, 1, &sc); // Phase 7/8: render shadow casters - const float baseShadowCullRadius = static_cast(std::max(40, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS", 180))); - const float denseShadowCullRadius = static_cast(std::max(30, envIntOrDefault("WOWEE_SHADOW_CULL_RADIUS_DENSE", 90))); - const float shadowCullRadius = denseScene ? std::min(baseShadowCullRadius, denseShadowCullRadius) : baseShadowCullRadius; + const float shadowCullRadius = 80.0f; if (wmoRenderer) { wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius); } From e4d94e5d7c9c05a4db04e8cbbf0964b6a59dd1eb Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 20:23:22 -0800 Subject: [PATCH 09/16] Fix terrain water horizontal flip by correcting step vector axes stepX/stepY were transposed: columns stepped south instead of east and rows stepped east instead of south, mirroring all overworld water. Also fix per-chunk origin to use layer.x for east offset and layer.y for south. --- src/rendering/water_renderer.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 452d6dc2..6a5c310d 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -684,12 +684,12 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap layer.minHeight ); surface.origin = glm::vec3( - surface.position.x - (static_cast(layer.y) * TILE_SIZE), - surface.position.y - (static_cast(layer.x) * TILE_SIZE), + surface.position.x - (static_cast(layer.x) * TILE_SIZE), + surface.position.y - (static_cast(layer.y) * TILE_SIZE), layer.minHeight ); - surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f); - surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f); + surface.stepX = glm::vec3(-TILE_SIZE, 0.0f, 0.0f); + surface.stepY = glm::vec3(0.0f, -TILE_SIZE, 0.0f); surface.minHeight = layer.minHeight; surface.maxHeight = layer.maxHeight; @@ -750,8 +750,8 @@ void WaterRenderer::loadFromTerrain(const pipeline::ADTTerrain& terrain, bool ap // Origin = chunk(0,0) position (NW corner of tile) surface.origin = glm::vec3(chunk00.position[0], chunk00.position[1], groupHeight); surface.position = surface.origin; - surface.stepX = glm::vec3(0.0f, -TILE_SIZE, 0.0f); - surface.stepY = glm::vec3(-TILE_SIZE, 0.0f, 0.0f); + surface.stepX = glm::vec3(-TILE_SIZE, 0.0f, 0.0f); + surface.stepY = glm::vec3(0.0f, -TILE_SIZE, 0.0f); surface.minHeight = groupHeight; surface.maxHeight = groupHeight; From e001aaa2b66cd497829f794babc3795713a03136 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 20:38:58 -0800 Subject: [PATCH 10/16] Suppress movement after teleport/portal, add shadow distance slider - Add movementSuppressTimer to camera controller that forces all movement keys to read as false, preventing held W key from carrying through loading screens (fixes always-running-forward after instance portals) - Increase shadow frustum default from 60 to 72 units (+20%) - Make shadow distance configurable via setShadowDistance() (40-200 range) - Add shadow distance slider in Video settings tab (persisted to config) --- include/rendering/camera_controller.hpp | 4 ++++ include/rendering/renderer.hpp | 3 +++ include/ui/game_screen.hpp | 1 + src/core/application.cpp | 8 ++++++-- src/rendering/camera_controller.cpp | 20 +++++++++++++------- src/rendering/renderer.cpp | 8 ++++---- src/ui/game_screen.cpp | 17 ++++++++++++++++- 7 files changed, 47 insertions(+), 14 deletions(-) diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 06ba2578..34600b47 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -97,6 +97,7 @@ public: void setExternalMoving(bool moving) { externalMoving_ = moving; } void setFacingYaw(float yaw) { facingYaw = yaw; } // For taxi/scripted movement void clearMovementInputs(); + void suppressMovementFor(float seconds) { movementSuppressTimer_ = seconds; } // Trigger mount jump (applies vertical velocity for physics hop) void triggerMountJump(); @@ -211,6 +212,9 @@ private: static constexpr float SWIM_SINK_SPEED = -3.0f; static constexpr float WATER_SURFACE_OFFSET = 0.9f; + // Movement input suppression (after teleport/portal, ignore held keys) + float movementSuppressTimer_ = 0.0f; + // State bool enabled = true; bool sitting = false; diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index d29ada83..ab14021c 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -244,6 +244,7 @@ private: glm::vec3 shadowCenter = glm::vec3(0.0f); bool shadowCenterInitialized = false; bool shadowsEnabled = true; + float shadowDistance_ = 72.0f; // Shadow frustum half-extent (default: 72 units) uint32_t shadowFrameCounter_ = 0; @@ -254,6 +255,8 @@ public: void setShadowsEnabled(bool enabled) { shadowsEnabled = enabled; } bool areShadowsEnabled() const { return shadowsEnabled; } + void setShadowDistance(float dist) { shadowDistance_ = glm::clamp(dist, 40.0f, 200.0f); } + float getShadowDistance() const { return shadowDistance_; } void setMsaaSamples(VkSampleCountFlagBits samples); void setWaterRefractionEnabled(bool enabled); diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 0a2f72f8..7e428523 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -87,6 +87,7 @@ private: bool pendingVsync = false; int pendingResIndex = 0; bool pendingShadows = true; + float pendingShadowDistance = 72.0f; bool pendingWaterRefraction = false; int pendingMasterVolume = 100; int pendingMusicVolume = 30; diff --git a/src/core/application.cpp b/src/core/application.cpp index d5da23ff..417d6438 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1555,8 +1555,10 @@ void Application::setupUICallbacks() { taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; // Stop any movement that was active before the teleport - if (renderer->getCameraController()) + if (renderer->getCameraController()) { renderer->getCameraController()->clearMovementInputs(); + renderer->getCameraController()->suppressMovementFor(0.5f); + } return; } @@ -1573,8 +1575,10 @@ void Application::setupUICallbacks() { taxiLandingClampTimer_ = 0.0f; lastTaxiFlight_ = false; // Stop any movement that was active before the teleport - if (renderer && renderer->getCameraController()) + if (renderer && renderer->getCameraController()) { renderer->getCameraController()->clearMovementInputs(); + renderer->getCameraController()->suppressMovementFor(1.0f); + } loadOnlineWorldTerrain(mapId, x, y, z); // loadedMapId_ is set inside loadOnlineWorldTerrain (including // any deferred entries it processes), so we must NOT override it here. diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 392ef71b..4103cc9f 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -200,16 +200,22 @@ void CameraController::update(float deltaTime) { // Don't process keyboard input when UI text input (e.g. chat box) has focus bool uiWantsKeyboard = ImGui::GetIO().WantTextInput; + // Suppress movement input after teleport/portal (keys may still be held) + if (movementSuppressTimer_ > 0.0f) { + movementSuppressTimer_ -= deltaTime; + } + bool movementSuppressed = movementSuppressTimer_ > 0.0f; + // Determine current key states - bool keyW = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_W); - bool keyS = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_S); - bool keyA = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_A); - bool keyD = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_D); - bool keyQ = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_Q); - bool keyE = !uiWantsKeyboard && !sitting && input.isKeyPressed(SDL_SCANCODE_E); + bool keyW = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_W); + bool keyS = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_S); + bool keyA = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_A); + bool keyD = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_D); + bool keyQ = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_Q); + bool keyE = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_E); bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); - bool nowJump = !uiWantsKeyboard && !sitting && input.isKeyJustPressed(SDL_SCANCODE_SPACE); + bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE); // Idle camera: any input resets the timer; timeout triggers a slow orbit pan bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 77b4c74a..5f3e48ae 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -3766,10 +3766,10 @@ void Renderer::renderHUD() { // in createPerFrameResources() as part of the Vulkan shadow infrastructure. glm::mat4 Renderer::computeLightSpaceMatrix() { - constexpr float kShadowHalfExtent = 60.0f; - constexpr float kShadowLightDistance = 200.0f; + const float kShadowHalfExtent = shadowDistance_; + const float kShadowLightDistance = shadowDistance_ * 3.0f; constexpr float kShadowNearPlane = 1.0f; - constexpr float kShadowFarPlane = 450.0f; + const float kShadowFarPlane = shadowDistance_ * 6.5f; // Use active lighting direction so shadow projection matches main shading. // Fragment shaders derive lighting with `ldir = normalize(-lightDir.xyz)`, @@ -3973,7 +3973,7 @@ void Renderer::renderShadowPass() { vkCmdSetScissor(currentCmd, 0, 1, &sc); // Phase 7/8: render shadow casters - const float shadowCullRadius = 80.0f; + const float shadowCullRadius = shadowDistance_ * 1.35f; if (wmoRenderer) { wmoRenderer->renderShadow(currentCmd, lightSpaceMatrix, shadowCenter, shadowCullRadius); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 1d02d795..3f1c0eb9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6180,6 +6180,7 @@ void GameScreen::renderSettingsWindow() { pendingVsync = window->isVsyncEnabled(); if (renderer) { renderer->setShadowsEnabled(pendingShadows); + renderer->setShadowDistance(pendingShadowDistance); // Read non-volume settings from actual state (volumes come from saved settings) if (auto* cameraController = renderer->getCameraController()) { pendingMouseSensitivity = cameraController->getMouseSensitivity(); @@ -6246,6 +6247,14 @@ void GameScreen::renderSettingsWindow() { if (renderer) renderer->setShadowsEnabled(pendingShadows); saveSettings(); } + if (pendingShadows) { + ImGui::SameLine(); + ImGui::SetNextItemWidth(150.0f); + if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 200.0f, "%.0f")) { + if (renderer) renderer->setShadowDistance(pendingShadowDistance); + saveSettings(); + } + } if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); saveSettings(); @@ -6339,6 +6348,7 @@ void GameScreen::renderSettingsWindow() { pendingFullscreen = kDefaultFullscreen; pendingVsync = kDefaultVsync; pendingShadows = kDefaultShadows; + pendingShadowDistance = 72.0f; pendingGroundClutterDensity = kDefaultGroundClutterDensity; pendingAntiAliasing = 0; pendingNormalMapping = true; @@ -6350,7 +6360,10 @@ void GameScreen::renderSettingsWindow() { window->setVsync(pendingVsync); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); pendingWaterRefraction = false; - if (renderer) renderer->setShadowsEnabled(pendingShadows); + if (renderer) { + renderer->setShadowsEnabled(pendingShadows); + renderer->setShadowDistance(pendingShadowDistance); + } if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); if (renderer) renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); if (renderer) { @@ -7364,6 +7377,7 @@ void GameScreen::saveSettings() { out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; + out << "shadow_distance=" << pendingShadowDistance << "\n"; out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; @@ -7449,6 +7463,7 @@ void GameScreen::loadSettings() { else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); else if (key == "shadows") pendingShadows = (std::stoi(val) != 0); + else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 200.0f); else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); From f4c115ade9a6d67a1d0663c2a2a54b5c42dbc4fa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 23:01:11 -0800 Subject: [PATCH 11/16] Fix Deeprun Tram: visual movement, direction, and player riding - Fix NULL renderer pointers by moving TransportManager connection after initializeRenderers for WMO-only maps - Fix tram direction by negating DBC TransportAnimation X/Y local offsets before serverToCanonical conversion - Implement client-side M2 transport boarding via proximity detection (server doesn't send transport attachment for trams) - Use position-delta approach: player keeps normal movement while transport's frame-to-frame motion is applied on top - Prevent server movement packets from clearing client-side M2 transport state (isClientM2Transport guard) - Fix getPlayerWorldPosition for M2 transports: simple canonical addition instead of render-space matrix multiplication --- include/game/game_handler.hpp | 3 + include/game/transport_manager.hpp | 5 + src/core/application.cpp | 152 ++++++++++++++++++++++++----- src/game/game_handler.cpp | 27 ++++- src/game/transport_manager.cpp | 116 ++++++++++++++-------- 5 files changed, 233 insertions(+), 70 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index add60a7b..8a3ee441 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -658,6 +658,9 @@ public: playerTransportStickyTimer_ = 8.0f; movementInfo.transportGuid = transportGuid; } + void setPlayerTransportOffset(const glm::vec3& offset) { + playerTransportOffset_ = offset; + } void clearPlayerTransport() { if (playerTransportGuid_ != 0) { playerTransportStickyGuid_ = playerTransportGuid_; diff --git a/include/game/transport_manager.hpp b/include/game/transport_manager.hpp index 571d3b3f..496380c4 100644 --- a/include/game/transport_manager.hpp +++ b/include/game/transport_manager.hpp @@ -9,6 +9,7 @@ namespace wowee::rendering { class WMORenderer; + class M2Renderer; } namespace wowee::pipeline { @@ -71,6 +72,7 @@ struct ActiveTransport { float serverAngularVelocity; bool hasServerVelocity; bool allowBootstrapVelocity; // Disable DBC bootstrap when spawn/path mismatch is clearly invalid + bool isM2 = false; // True if rendered as M2 (not WMO), uses M2Renderer for transforms }; class TransportManager { @@ -79,12 +81,14 @@ public: ~TransportManager(); void setWMORenderer(rendering::WMORenderer* renderer) { wmoRenderer_ = renderer; } + void setM2Renderer(rendering::M2Renderer* renderer) { m2Renderer_ = renderer; } void update(float deltaTime); void registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos, uint32_t entry = 0); void unregisterTransport(uint64_t guid); ActiveTransport* getTransport(uint64_t guid); + const std::unordered_map& getTransports() const { return transports_; } glm::vec3 getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset); glm::mat4 getTransportInvTransform(uint64_t transportGuid); @@ -141,6 +145,7 @@ private: std::unordered_map paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc) std::unordered_map taxiPaths_; // Indexed by TaxiPath.dbc ID (world-coord paths for MO_TRANSPORT) rendering::WMORenderer* wmoRenderer_ = nullptr; + rendering::M2Renderer* m2Renderer_ = nullptr; bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction float elapsedTime_ = 0.0f; // Total elapsed time (seconds) }; diff --git a/src/core/application.cpp b/src/core/application.cpp index 417d6438..3310c406 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -968,6 +968,15 @@ void Application::update(float deltaTime) { gameHandler->isTaxiMountActive() || gameHandler->isTaxiActivationPending()); bool onTransportNow = gameHandler && gameHandler->isOnTransport(); + // M2 transports (trams) use position-delta approach: player keeps normal + // movement and the transport's frame-to-frame delta is applied on top. + // Only WMO transports (ships) use full external-driven mode. + bool isM2Transport = false; + if (onTransportNow && gameHandler->getTransportManager()) { + auto* tr = gameHandler->getTransportManager()->getTransport(gameHandler->getPlayerTransportGuid()); + isM2Transport = (tr && tr->isM2); + } + bool onWMOTransport = onTransportNow && !isM2Transport; if (worldEntryMovementGraceTimer_ > 0.0f) { worldEntryMovementGraceTimer_ -= deltaTime; // Clear stale movement from before teleport each frame @@ -976,7 +985,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->clearMovementInputs(); } if (renderer && renderer->getCameraController()) { - const bool externallyDrivenMotion = onTaxi || onTransportNow || chargeActive_; + 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 landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f && @@ -1057,14 +1066,18 @@ void Application::update(float deltaTime) { // Sync character render position ↔ canonical WoW coords each frame if (renderer && gameHandler) { - bool onTransport = gameHandler->isOnTransport(); + // For position sync branching, only WMO transports use the dedicated + // onTransport branch. M2 transports use the normal movement else branch + // with a position-delta correction applied on top. + bool onTransport = onWMOTransport; - // Debug: Log transport state changes static bool wasOnTransport = false; - if (onTransport != wasOnTransport) { - LOG_DEBUG("Transport state changed: onTransport=", onTransport, + bool onTransportNowDbg = gameHandler->isOnTransport(); + if (onTransportNowDbg != wasOnTransport) { + LOG_DEBUG("Transport state changed: onTransport=", onTransportNowDbg, + " isM2=", isM2Transport, " guid=0x", std::hex, gameHandler->getPlayerTransportGuid(), std::dec); - wasOnTransport = onTransport; + wasOnTransport = onTransportNowDbg; } if (onTaxi) { @@ -1092,13 +1105,11 @@ void Application::update(float deltaTime) { } } } else if (onTransport) { - // Transport mode: compose world position from transport transform + local offset + // WMO transport mode (ships): compose world position from transform + local offset glm::vec3 canonical = gameHandler->getComposedWorldPosition(); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->getCharacterPosition() = renderPos; - // Keep movementInfo in lockstep with composed transport world position. gameHandler->setPosition(canonical.x, canonical.y, canonical.z); - // Update camera follow target if (renderer->getCameraController()) { glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); if (followTarget) { @@ -1172,6 +1183,27 @@ void Application::update(float deltaTime) { } } else { glm::vec3 renderPos = renderer->getCharacterPosition(); + + // M2 transport riding: apply transport's frame-to-frame position delta + // so the player moves with the tram while retaining normal movement input. + if (isM2Transport && gameHandler->getTransportManager()) { + auto* tr = gameHandler->getTransportManager()->getTransport( + gameHandler->getPlayerTransportGuid()); + if (tr) { + static glm::vec3 lastTransportCanonical(0); + static uint64_t lastTransportGuid = 0; + if (lastTransportGuid == gameHandler->getPlayerTransportGuid()) { + glm::vec3 deltaCanonical = tr->position - lastTransportCanonical; + glm::vec3 deltaRender = core::coords::canonicalToRender(deltaCanonical) + - core::coords::canonicalToRender(glm::vec3(0)); + renderPos += deltaRender; + renderer->getCharacterPosition() = renderPos; + } + lastTransportCanonical = tr->position; + lastTransportGuid = gameHandler->getPlayerTransportGuid(); + } + } + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); gameHandler->setPosition(canonical.x, canonical.y, canonical.z); @@ -1203,6 +1235,41 @@ void Application::update(float deltaTime) { facingSendCooldown_ = 0.1f; // max 10 Hz } } + + // Client-side transport boarding detection (for M2 transports like trams + // where the server doesn't send transport attachment data). + // Use a generous AABB around each transport's current position. + if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) { + auto* tm = gameHandler->getTransportManager(); + glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); + + for (auto& [guid, transport] : tm->getTransports()) { + if (!transport.isM2) continue; + glm::vec3 diff = playerCanonical - transport.position; + float horizDistSq = diff.x * diff.x + diff.y * diff.y; + float vertDist = std::abs(diff.z); + if (horizDistSq < 144.0f && vertDist < 15.0f) { + gameHandler->setPlayerOnTransport(guid, playerCanonical - transport.position); + LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, guid, std::dec); + break; + } + } + } + + // M2 transport disembark: player walked far enough from transport center + if (isM2Transport && gameHandler->getTransportManager()) { + auto* tm = gameHandler->getTransportManager(); + auto* tr = tm->getTransport(gameHandler->getPlayerTransportGuid()); + if (tr) { + glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); + glm::vec3 diff = playerCanonical - tr->position; + float horizDistSq = diff.x * diff.x + diff.y * diff.y; + if (horizDistSq > 225.0f) { + gameHandler->clearPlayerTransport(); + LOG_DEBUG("M2 transport disembark"); + } + } + } } } }); @@ -2073,7 +2140,7 @@ void Application::setupUICallbacks() { } uint32_t wmoInstanceId = it->second.instanceId; - LOG_DEBUG("Registering server transport: GUID=0x", std::hex, guid, std::dec, + LOG_WARNING("Registering server transport: GUID=0x", std::hex, guid, std::dec, " entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId, " pos=(", x, ", ", y, ", ", z, ")"); @@ -2101,15 +2168,18 @@ void Application::setupUICallbacks() { hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); } + LOG_WARNING("Transport path check: entry=", entry, " hasUsablePath=", hasUsablePath, + " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); + if (preferServerData) { // Strict server-authoritative mode: do not infer/remap fallback routes. if (!hasUsablePath) { std::vector path = { canonicalSpawnPos }; transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_DEBUG("Server-first strict registration: stationary fallback for GUID 0x", + LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", std::hex, guid, std::dec, " entry=", entry); } else { - LOG_DEBUG("Server-first transport registration: using entry DBC path for entry ", entry); + LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", entry); } } else if (!hasUsablePath) { // Remap/infer path by spawn position when entry doesn't map 1:1 to DBC ids. @@ -2119,12 +2189,12 @@ void Application::setupUICallbacks() { canonicalSpawnPos, 1200.0f, allowZOnly); if (inferredPath != 0) { pathId = inferredPath; - LOG_DEBUG("Using inferred transport path ", pathId, " for entry ", entry); + LOG_WARNING("Using inferred transport path ", pathId, " for entry ", entry); } else { uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); if (remappedPath != 0) { pathId = remappedPath; - LOG_DEBUG("Using remapped fallback transport path ", pathId, + LOG_WARNING("Using remapped fallback transport path ", pathId, " for entry ", entry, " displayId=", displayId, " (usableEntryPath=", transportManager->hasPathForEntry(entry), ")"); } else { @@ -2137,12 +2207,19 @@ void Application::setupUICallbacks() { } } } else { - LOG_DEBUG("Using real transport path from TransportAnimation.dbc for entry ", entry); + LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", entry); } // Register the transport with spawn position (prevents rendering at origin until server update) transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); + // Mark M2 transports (e.g. Deeprun Tram cars) so TransportManager uses M2Renderer + if (!it->second.isWmo) { + if (auto* tr = transportManager->getTransport(guid)) { + tr->isM2 = true; + } + } + // Server-authoritative movement - set initial position from spawn data glm::vec3 canonicalPos(x, y, z); transportManager->updateServerTransport(guid, canonicalPos, orientation); @@ -2171,7 +2248,7 @@ void Application::setupUICallbacks() { } if (auto* tr = transportManager->getTransport(guid); tr) { - LOG_DEBUG("Transport registered: guid=0x", std::hex, guid, std::dec, + LOG_WARNING("Transport registered: guid=0x", std::hex, guid, std::dec, " entry=", entry, " displayId=", displayId, " pathId=", tr->pathId, " mode=", (tr->useClientAnimation ? "client" : "server"), @@ -3458,11 +3535,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float renderer->getTerrainManager()->setMapName(mapName); } - // Connect TransportManager to WMORenderer (for server transports) - if (gameHandler && gameHandler->getTransportManager() && renderer->getWMORenderer()) { - gameHandler->getTransportManager()->setWMORenderer(renderer->getWMORenderer()); - LOG_INFO("TransportManager connected to WMORenderer for online mode"); - } + // NOTE: TransportManager renderer connection moved to after initializeRenderers (later in this function) // Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents) if (renderer->getWMORenderer() && renderer->getM2Renderer()) { @@ -3931,9 +4004,18 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float renderer->getCameraController()->reset(); } - // Set up test transport (development feature) + // Test transport disabled — real transports come from server via UPDATEFLAG_TRANSPORT showProgress("Finalizing world...", 0.94f); - setupTestTransport(); + // setupTestTransport(); + + // Connect TransportManager to renderers (must happen AFTER initializeRenderers) + if (gameHandler && gameHandler->getTransportManager()) { + auto* tm = gameHandler->getTransportManager(); + if (renderer->getWMORenderer()) tm->setWMORenderer(renderer->getWMORenderer()); + if (renderer->getM2Renderer()) tm->setM2Renderer(renderer->getM2Renderer()); + LOG_WARNING("TransportManager connected: wmoR=", (renderer->getWMORenderer() ? "yes" : "NULL"), + " m2R=", (renderer->getM2Renderer() ? "yes" : "NULL")); + } // Set up NPC animation callbacks (for online creatures) showProgress("Preparing creatures...", 0.97f); @@ -6368,6 +6450,10 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } else if (displayId == 2454 || displayId == 181688 || displayId == 190536) { modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo"); + } else if (displayId == 3831) { + // Deeprun Tram car + modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2"; + LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2"); } } @@ -6508,7 +6594,12 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t // Transport GameObjects are not always named "transport" in their WMO path // (e.g. elevators/lifts). If the server marks it as a transport, always // notify so TransportManager can animate/carry passengers. - if (gameHandler && gameHandler->isTransportGuid(guid)) { + bool isTG = gameHandler && gameHandler->isTransportGuid(guid); + LOG_WARNING("WMO GO spawned: guid=0x", std::hex, guid, std::dec, + " entry=", entry, " displayId=", displayId, + " isTransport=", isTG, + " pos=(", x, ", ", y, ", ", z, ")"); + if (isTG) { gameHandler->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); } @@ -6572,18 +6663,27 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t return; } - // Freeze animation for static gameobjects, but let portals/effects animate + // Freeze animation for static gameobjects, but let portals/effects/transports animate + bool isTransportGO = gameHandler && gameHandler->isTransportGuid(guid); std::string lowerPath = modelPath; std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), ::tolower); bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || lowerPath.find("instancenewportal") != std::string::npos || lowerPath.find("portalfx") != std::string::npos || lowerPath.find("spellportal") != std::string::npos); - if (!isAnimatedEffect) { + if (!isAnimatedEffect && !isTransportGO) { m2Renderer->setInstanceAnimationFrozen(instanceId, true); } gameObjectInstances_[guid] = {modelId, instanceId, false}; + + // Notify transport system for M2 transports (e.g. Deeprun Tram cars) + if (gameHandler && gameHandler->isTransportGuid(guid)) { + LOG_WARNING("M2 transport spawned: guid=0x", std::hex, guid, std::dec, + " entry=", entry, " displayId=", displayId, + " instanceId=", instanceId); + gameHandler->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); + } } LOG_DEBUG("Spawned gameobject: guid=0x", std::hex, guid, std::dec, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c8dba11f..e80e727f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4936,10 +4936,17 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); } else { - if (playerTransportGuid_ != 0) { - LOG_INFO("Player left transport"); + // Don't clear client-side M2 transport boarding (trams) — + // the server doesn't know about client-detected transport attachment. + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport"); + clearPlayerTransport(); } - clearPlayerTransport(); } } @@ -5173,9 +5180,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { queryGameObjectInfo(itEntry->second, block.guid); } // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) + LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), " displayId=", go->getDisplayId(), + " updateFlags=0x", std::hex, block.updateFlags, std::dec, + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); if (block.updateFlags & 0x0002) { transportGuids_.insert(block.guid); - LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, " entry=", go->getEntry(), " displayId=", go->getDisplayId(), " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); @@ -5691,7 +5702,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { movementInfo.x = pos.x; movementInfo.y = pos.y; movementInfo.z = pos.z; - if (playerTransportGuid_ != 0) { + // Don't clear client-side M2 transport boarding + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { LOG_INFO("Player left transport (MOVEMENT)"); clearPlayerTransport(); } diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index f134b5c0..955f8eaa 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -1,5 +1,6 @@ #include "game/transport_manager.hpp" #include "rendering/wmo_renderer.hpp" +#include "rendering/m2_renderer.hpp" #include "core/coordinates.hpp" #include "core/logger.hpp" #include "pipeline/dbc_loader.hpp" @@ -80,10 +81,11 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, transport.localClockMs = 0; transport.hasServerClock = false; transport.serverClockOffsetMs = 0; - // Default is server-authoritative movement. - // Exception: elevator-style transports (z-only DBC paths) often do not stream continuous - // movement updates from the server, but the client is expected to animate them. - transport.useClientAnimation = (path.fromDBC && path.zOnly && path.durationMs > 0); + // Start with client-side animation for all DBC paths with real movement. + // If the server sends actual position updates, updateServerTransport() will switch + // to server-driven mode. This ensures transports like trams (which the server doesn't + // stream updates for) still animate, while ships/zeppelins switch to server authority. + transport.useClientAnimation = (path.fromDBC && path.durationMs > 0); transport.clientAnimationReverse = false; transport.serverYaw = 0.0f; transport.hasServerYaw = false; @@ -98,16 +100,19 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, if (transport.useClientAnimation && path.durationMs > 0) { // Seed to a stable phase based on our local clock so elevators don't all start at t=0. transport.localClockMs = static_cast(elapsedTime_ * 1000.0f) % path.durationMs; - LOG_INFO("TransportManager: Enabled client animation for z-only transport 0x", + LOG_INFO("TransportManager: Enabled client animation for transport 0x", std::hex, guid, std::dec, " path=", pathId, - " durationMs=", path.durationMs, " seedMs=", transport.localClockMs); + " durationMs=", path.durationMs, " seedMs=", transport.localClockMs, + (path.worldCoords ? " [worldCoords]" : (path.zOnly ? " [z-only]" : ""))); } updateTransformMatrices(transport); // CRITICAL: Update WMO renderer with initial transform - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } transports_[guid] = transport; @@ -140,6 +145,14 @@ glm::vec3 TransportManager::getPlayerWorldPosition(uint64_t transportGuid, const return localOffset; // Fallback } + if (transport->isM2) { + // M2 transports (trams): localOffset is a canonical world-space delta + // from the transport's canonical position. Just add directly. + return transport->position + localOffset; + } + + // WMO transports (ships): localOffset is in transport-local space, + // use the render-space transform matrix. glm::vec4 localPos(localOffset, 1.0f); glm::vec4 worldPos = transport->transform * localPos; return glm::vec3(worldPos); @@ -284,14 +297,17 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float glm::vec3 pathOffset = evalTimedCatmullRom(path, pathTimeMs); // Guard against bad fallback Z curves on some remapped transport paths (notably icebreakers), // where path offsets can sink far below sea level when we only have spawn-time data. - if (transport.useClientAnimation && transport.serverUpdateCount <= 1) { - constexpr float kMinFallbackZOffset = -2.0f; - pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset); - } - if (!transport.useClientAnimation && !transport.hasServerClock) { - constexpr float kMinFallbackZOffset = -2.0f; - constexpr float kMaxFallbackZOffset = 8.0f; - pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset); + // Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions. + if (!path.worldCoords) { + if (transport.useClientAnimation && transport.serverUpdateCount <= 1) { + constexpr float kMinFallbackZOffset = -2.0f; + pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset); + } + if (!transport.useClientAnimation && !transport.hasServerClock) { + constexpr float kMinFallbackZOffset = -2.0f; + constexpr float kMaxFallbackZOffset = 8.0f; + pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset); + } } transport.position = transport.basePosition + pathOffset; @@ -307,24 +323,20 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float updateTransformMatrices(transport); // Update WMO instance position - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } - // Debug logging every 120 frames (~2 seconds at 60fps) + // Debug logging every 600 frames (~10 seconds at 60fps) static int debugFrameCount = 0; - if (debugFrameCount++ % 120 == 0) { - // Log canonical position AND render position to check coordinate conversion - glm::vec3 renderPos = core::coords::canonicalToRender(transport.position); + if (debugFrameCount++ % 600 == 0) { LOG_DEBUG("Transport 0x", std::hex, transport.guid, std::dec, " pathTime=", pathTimeMs, "ms / ", path.durationMs, "ms", - " canonicalPos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")", - " renderPos=(", renderPos.x, ", ", renderPos.y, ", ", renderPos.z, ")", - " basePos=(", transport.basePosition.x, ", ", transport.basePosition.y, ", ", transport.basePosition.z, ")", - " pathOffset=(", pathOffset.x, ", ", pathOffset.y, ", ", pathOffset.z, ")", + " pos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")", " mode=", (transport.useClientAnimation ? "client" : "server"), - " hasServerClock=", transport.hasServerClock, - " offset=", transport.serverClockOffsetMs, "ms"); + " isM2=", transport.isM2); } } @@ -561,12 +573,24 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos // Track server updates transport->serverUpdateCount++; transport->lastServerUpdate = elapsedTime_; - // Server updates take precedence for moving XY transports, but z-only elevators should - // remain client-animated (server may only send sparse state updates). - if (!isZOnlyPath) { - transport->useClientAnimation = false; - } else { + // Z-only elevators and world-coordinate paths (TaxiPathNode) always stay client-driven. + // For other DBC paths (trams, ships): only switch to server-driven mode when the server + // sends a position that actually differs from the current position, indicating it's + // actively streaming movement data (not just echoing the spawn position). + if (isZOnlyPath || isWorldCoordPath) { transport->useClientAnimation = true; + } else if (transport->useClientAnimation && hasPath && pathIt->second.fromDBC) { + float posDelta = glm::length(position - transport->position); + if (posDelta > 1.0f) { + // Server sent a meaningfully different position — it's actively driving this transport + transport->useClientAnimation = false; + LOG_INFO("Transport 0x", std::hex, guid, std::dec, + " switching to server-driven (posDelta=", posDelta, ")"); + } + // Otherwise keep client animation (server just echoed spawn pos or sent small jitter) + } else if (!hasPath || !pathIt->second.fromDBC) { + // No DBC path — purely server-driven + transport->useClientAnimation = false; } transport->clientAnimationReverse = false; @@ -576,8 +600,10 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos transport->position = position; transport->rotation = glm::angleAxis(orientation, glm::vec3(0.0f, 0.0f, 1.0f)); updateTransformMatrices(*transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + if (transport->isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); } return; } @@ -846,12 +872,23 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg std::vector timedPoints; timedPoints.reserve(sortedWaypoints.size() + 1); // +1 for wrap point - // Log first few waypoints for transport 2074 to see conversion + // Log DBC waypoints for tram entries + if (transportEntry >= 176080 && transportEntry <= 176085) { + size_t mid = sortedWaypoints.size() / 4; // ~quarter through + size_t mid2 = sortedWaypoints.size() / 2; // ~halfway + LOG_WARNING("DBC path entry=", transportEntry, " nPts=", sortedWaypoints.size(), + " [0] t=", sortedWaypoints[0].first, " raw=(", sortedWaypoints[0].second.x, ",", sortedWaypoints[0].second.y, ",", sortedWaypoints[0].second.z, ")", + " [", mid, "] t=", sortedWaypoints[mid].first, " raw=(", sortedWaypoints[mid].second.x, ",", sortedWaypoints[mid].second.y, ",", sortedWaypoints[mid].second.z, ")", + " [", mid2, "] t=", sortedWaypoints[mid2].first, " raw=(", sortedWaypoints[mid2].second.x, ",", sortedWaypoints[mid2].second.y, ",", sortedWaypoints[mid2].second.z, ")"); + } + for (size_t idx = 0; idx < sortedWaypoints.size(); idx++) { const auto& [tMs, pos] = sortedWaypoints[idx]; - // TransportAnimation.dbc uses server coordinates - convert to canonical - glm::vec3 canonical = core::coords::serverToCanonical(pos); + // TransportAnimation.dbc local offsets use a coordinate system where + // the travel axis is negated relative to server world coords. + // Negate X and Y before converting to canonical (Z=height stays the same). + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(-pos.x, -pos.y, pos.z)); // CRITICAL: Detect if serverToCanonical is zeroing nonzero inputs if ((pos.x != 0.0f || pos.y != 0.0f || pos.z != 0.0f) && @@ -896,7 +933,8 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg // Add duplicate first point at end with wrap duration // This makes the wrap segment (last → first) have proper duration - glm::vec3 firstCanonical = core::coords::serverToCanonical(sortedWaypoints.front().second); + const auto& fp = sortedWaypoints.front().second; + glm::vec3 firstCanonical = core::coords::serverToCanonical(glm::vec3(-fp.x, -fp.y, fp.z)); timedPoints.push_back({lastTimeMs + wrapMs, firstCanonical}); uint32_t durationMs = lastTimeMs + wrapMs; From 2c5b7cd368dc5ce03c3bcca1a6f546542862093c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 23:48:35 -0800 Subject: [PATCH 12/16] WMO glass transparency for instances, disable interior shadows - Add case-insensitive "glass" detection for WMO window materials - Make instance (WMO-only) glass highly transparent (12-35% alpha) so underwater scenes are visible through Deeprun Tram windows - Keep normal world windows at existing opacity (40-95% alpha) - Disable shadow mapping for interior WMO groups to fix dark indoor areas like Ironforge --- assets/shaders/wmo.frag.glsl | 11 ++++++++--- include/rendering/wmo_renderer.hpp | 2 ++ src/core/application.cpp | 4 +++- src/rendering/wmo_renderer.cpp | 13 ++++++++++--- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index dbd55436..b8db595d 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -152,9 +152,9 @@ void main() { vec3 result; - // Sample shadow map for all non-window WMO surfaces + // Sample shadow map — skip for interior WMO groups (no sun indoors) float shadow = 1.0; - if (shadowParams.x > 0.5) { + if (shadowParams.x > 0.5 && isInterior == 0) { vec3 ldir = normalize(-lightDir.xyz); float normalOffset = SHADOW_TEXEL * 2.0 * (1.0 - abs(dot(norm, ldir))); vec3 biasedPos = FragPos + norm * normalOffset; @@ -219,7 +219,12 @@ void main() { glass += specBroad * lightColor.rgb * 0.12; result = glass; - alpha = mix(0.4, 0.95, NdotV); + if (isWindow == 2) { + // Instance/dungeon glass: mostly transparent to see through + alpha = mix(0.12, 0.35, fresnel); + } else { + alpha = mix(0.4, 0.95, NdotV); + } } outColor = vec4(result, alpha); diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 4587d0b7..5c928571 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -196,6 +196,7 @@ public: void setNormalMapStrength(float s) { normalMapStrength_ = s; materialSettingsDirty_ = true; } void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; materialSettingsDirty_ = true; } void setPOMQuality(int q) { pomQuality_ = q; materialSettingsDirty_ = true; } + void setWMOOnlyMap(bool v) { wmoOnlyMap_ = v; materialSettingsDirty_ = true; } bool isNormalMappingEnabled() const { return normalMappingEnabled_; } float getNormalMapStrength() const { return normalMapStrength_; } bool isPOMEnabled() const { return pomEnabled_; } @@ -670,6 +671,7 @@ private: bool pomEnabled_ = true; // on by default int pomQuality_ = 1; // 0=Low(16), 1=Medium(32), 2=High(64) bool materialSettingsDirty_ = false; // rebuild UBOs when settings change + bool wmoOnlyMap_ = false; // true for dungeon/instance WMO-only maps // Rendering state bool wireframeMode = false; diff --git a/src/core/application.cpp b/src/core/application.cpp index 3310c406..2a8ef041 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -3525,9 +3525,10 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float renderer->getCameraController()->reset(); } - // Set map name for WMO renderer + // Set map name for WMO renderer and reset instance mode if (renderer->getWMORenderer()) { renderer->getWMORenderer()->setMapName(mapName); + renderer->getWMORenderer()->setWMOOnlyMap(false); } // Set map name for terrain manager @@ -3634,6 +3635,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // Set map name on WMO renderer and disable terrain streaming (no ADT tiles for instances) if (renderer->getWMORenderer()) { renderer->getWMORenderer()->setMapName(mapName); + renderer->getWMORenderer()->setWMOOnlyMap(true); } if (renderer->getTerrainManager()) { renderer->getTerrainManager()->setStreamingEnabled(false); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 6fe36a41..5bae174f 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -593,16 +593,23 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { // Detect window/glass materials by texture name. // Flag 0x10 (F_SIDN) marks night-glow materials (windows AND lamps), - // so we additionally check for "window" in the texture path to + // so we additionally check for "window" or "glass" in the texture path to // distinguish actual glass from lamp post geometry. bool isWindow = false; if (batch.materialId < modelData.materialTextureIndices.size()) { uint32_t ti = modelData.materialTextureIndices[batch.materialId]; if (ti < modelData.textureNames.size()) { - isWindow = (modelData.textureNames[ti].find("window") != std::string::npos); + const auto& texName = modelData.textureNames[ti]; + // Case-insensitive search for "window" or "glass" + std::string texNameLower = texName; + std::transform(texNameLower.begin(), texNameLower.end(), texNameLower.begin(), ::tolower); + isWindow = (texNameLower.find("window") != std::string::npos || + texNameLower.find("glass") != std::string::npos); } } + + BatchKey key{ reinterpret_cast(tex), alphaTest, unlit, isWindow }; auto& mb = batchMap[key]; if (mb.draws.empty()) { @@ -651,7 +658,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { matData.unlit = mb.unlit ? 1 : 0; matData.isInterior = isInterior ? 1 : 0; matData.specularIntensity = 0.5f; - matData.isWindow = mb.isWindow ? 1 : 0; + matData.isWindow = mb.isWindow ? (wmoOnlyMap_ ? 2 : 1) : 0; matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; matData.enablePOM = pomEnabled_ ? 1 : 0; matData.pomScale = 0.012f; From a24fe4cc45aaa1762f01546a8ac81810fc377394 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 7 Mar 2026 00:48:04 -0800 Subject: [PATCH 13/16] Ironforge Great Forge lava, magma water rendering, LavaSteam particle effects - Add magma/slime rendering path to water shader (fbm noise, crust/molten/core coloring) - Fix WMO liquid height filter rejecting high-altitude zones like Ironforge (Z>300) - Allow interior WMO magma/slime MLIQ groups to load (skip only water/ocean) - Mark LAVASTEAM.m2 as spell effect for proper additive blend, hide emission mesh - Add isLavaModel flag for M2 ForgeLava/LavaPots UV scroll fallback - Add isLava material detection in WMO renderer for lava texture UV animation - Fix WMO material UBO colors for magma (was blue, now orange-red) --- assets/shaders/water.frag.glsl | 46 ++++++++++++++++++++++++ assets/shaders/water.frag.spv | Bin 34440 -> 37836 bytes assets/shaders/wmo.frag.glsl | 14 +++++++- assets/shaders/wmo.frag.spv | Bin 20156 -> 21120 bytes include/rendering/m2_renderer.hpp | 1 + include/rendering/wmo_renderer.hpp | 5 ++- src/rendering/m2_renderer.cpp | 56 +++++++++++++++++++++++++++++ src/rendering/terrain_manager.cpp | 20 +++++++++-- src/rendering/water_renderer.cpp | 13 ++++--- src/rendering/wmo_renderer.cpp | 15 ++++++-- 10 files changed, 158 insertions(+), 12 deletions(-) diff --git a/assets/shaders/water.frag.glsl b/assets/shaders/water.frag.glsl index c7dbc5b4..5d3af519 100644 --- a/assets/shaders/water.frag.glsl +++ b/assets/shaders/water.frag.glsl @@ -155,6 +155,52 @@ void main() { float time = fogParams.z; float basicType = push.liquidBasicType; + // ============================================================ + // Magma / Slime — self-luminous flowing surfaces, skip water path + // ============================================================ + if (basicType > 1.5) { + float dist = length(viewPos.xyz - FragPos); + vec2 flowUV = FragPos.xy; + + bool isMagma = basicType < 2.5; + + // Multi-octave flowing noise for organic lava look + float n1 = fbmNoise(flowUV * 0.06 + vec2(time * 0.02, time * 0.03), time * 0.4); + float n2 = fbmNoise(flowUV * 0.10 + vec2(-time * 0.015, time * 0.025), time * 0.3); + float n3 = noiseValue(flowUV * 0.25 + vec2(time * 0.04, -time * 0.02)); + float flow = n1 * 0.45 + n2 * 0.35 + n3 * 0.20; + + // Dark crust vs bright molten core + vec3 crustColor, hotColor, coreColor; + if (isMagma) { + crustColor = vec3(0.15, 0.04, 0.01); // dark cooled rock + hotColor = vec3(1.0, 0.45, 0.05); // orange molten + coreColor = vec3(1.0, 0.85, 0.3); // bright yellow-white core + } else { + crustColor = vec3(0.05, 0.15, 0.02); + hotColor = vec3(0.3, 0.8, 0.15); + coreColor = vec3(0.5, 1.0, 0.3); + } + + // Three-tier color: crust → molten → hot core + float crustMask = smoothstep(0.25, 0.50, flow); + float coreMask = smoothstep(0.60, 0.80, flow); + vec3 color = mix(crustColor, hotColor, crustMask); + color = mix(color, coreColor, coreMask); + + // Subtle pulsing emissive glow + float pulse = 1.0 + 0.15 * sin(time * 1.5 + flow * 6.0); + color *= pulse; + + // Emissive brightening for hot areas + color *= 1.0 + coreMask * 0.6; + + float fogFactor = clamp((fogParams.y - dist) / (fogParams.y - fogParams.x), 0.0, 1.0); + color = mix(fogColor.rgb, color, fogFactor); + outColor = vec4(color, 0.97); + return; + } + vec2 screenUV = gl_FragCoord.xy / vec2(textureSize(SceneColor, 0)); // --- Normal computation --- diff --git a/assets/shaders/water.frag.spv b/assets/shaders/water.frag.spv index 6fe7f2a6449a2a30df1299673bfdd8b027c567bb..a5b91695bce51f42f74950164e1da6098a7e99ed 100644 GIT binary patch literal 37836 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 V+$Z^aa({<=E!RhzIjNb`{{!U*RZ;)| literal 34440 zcmZ{t2b^71)xHm8CiLDxdXX*=s~8u2FEPD`7%|tRJB62 zQgvBxRUfNWOQTek^)A#aRAXyCb@~z0cN|^NzvHgE?WE(%)flVVwo0{P)dRN7>l>J_ zzRc29wGL$i$}t!g5&vD3ODU_;R$n>?jiINidVo%Qs^zPx(@&my=%G`mPd<6p@bJl_ z3(g*Fojf=&zjbo|z({M>f`Kzy`Yl`d4a}c4xUj!;=*6SVrI)xX(?|cpzQH4AjSLMA z9@BS5>yV+5d3}RhpD}xvy=U*dH|DCBTvntWu5k~uQdJ8E=Fy|J<*K!)4;UC-FfuT6 z;evso`BSGJOY?DiTMS|Q)$uu`_8(KNK|QU;z15o3BSQ=4%$?sF9j&!xs|~29wua`l z7L1&I#Jqt8bGIf+`;S;X)w#24HFJA9utG(c} zT7!cN2m3~*4E4?HZo5a(c2DrB0}B?k7-=s%d!PT+7R^5GygkM-)qddqo!k0EF(;iq zQ^X`EehYYZXMtxmKIe%WpG(Az&*k9$X=l~>f2PJ~*Z488o6T|kNL;t=sh$M8*W2eyPxW+o zV}BOj-p&n`QE7YgexTpFT^_)3pk)3{A2bQWiQQGY$!v`8X4eS_=cNDm< z#y!>1;NiZJzVvk*d}d=ismpdUd{$$d)n#kJa~=$I+0KEV(X_i@30qHfWf%YG5`0W` zBRpfc1>7D(PjxGJ-oSh>QT3Q^ho4>dF{Zi)?7Dg0d99t_8kjS8=8#i}(o;Q%=3YOL zgyj#zhld8{FKFzK$UAe=Q~h8GKBjsKp0U2TgsrD~xr_e=zW6-rtzLo8XpKx^#_MbC zSTa!aIAfr7ruA6<-pqm4`~{6gTRX3@*vxCj&}b*V_p;eTa|+*fzRR}t z8BX0OIPsSQkIwDuA3C#RBKnx>?+%c|b@~r@X5Z+*tRv4Jc4AzY*U@Gy9G$C=`N%!y z4fVGMsiP^M$z5*3yx}@W{hTAUDI=}ZfgRnsQ9ok1)p8BZQUCVboW5|Ne_|&Kb1=|w zlY@Eo!bfL~v|96z@)CnW1hhGWC$px^$J9N9<`uOy7pBfl!dzpvN7Ixq|$lm}j_vpr5d^7m`P9MG1HrfaK&Tfqup`G{6 zXd?r|!-K6O7L2s!&so4syDqz;I|%cAbH``${PsEATTNB#>mTSFY;Q30Im|ZG`a9>? zFx-8khxW7KgFM${%!}aV{r=*Hx9|5o)n)L0PwqO8p6Y6NbFU}f*ye0P z&YnHmYM;Bk)h8G0^#bX83)-mbaCo~xJ7#ZnTlD(g-X8DmX!V+Pw4UmVXytwVOYru6 zeQb4CVoc{^`{ULG&>#kr5Io<#4PIXW~l>~&|-P>V?Jg%_gF ztWO=3WvZ9Z7S8W)jnv6c>0A|z|1!2?921wQ)(G2$x$#=0?ol`CT#MRz`&`C-!r<`S zzV`X4zfDHx4$Pa^8tH7-#PXc()BOkr$i>4w{O6_*b9@cFM2=JX`rFqSt|{2Kmd$DP z*Q-w3u~iQNxn2zp4xQP##yS4w(feDi;fQLh)*AC Rlh9^1sB{`Ng-Y_*{=-GdWH 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 diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index b8db595d..c04e1a93 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -28,6 +28,7 @@ layout(set = 1, binding = 1) uniform WMOMaterial { int pomMaxSamples; float heightMapVariance; float normalMapStrength; + int isLava; }; layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap; @@ -120,6 +121,14 @@ void main() { // Compute final UV (with POM if enabled) vec2 finalUV = TexCoord; + // Lava/magma: scroll UVs for flowing effect + if (isLava != 0) { + float time = fogParams.z; + // Scroll both axes — pools get horizontal flow, waterfalls get vertical flow + // (UV orientation depends on mesh, so animate both) + finalUV += vec2(time * 0.04, time * 0.06); + } + // Build TBN matrix vec3 T = normalize(Tangent); vec3 B = normalize(Bitangent); @@ -170,7 +179,10 @@ void main() { shadow = mix(1.0, shadow, shadowParams.y); } - if (unlit != 0) { + if (isLava != 0) { + // Lava is self-luminous — bright emissive, no shadows + result = texColor.rgb * 1.5; + } else if (unlit != 0) { result = texColor.rgb * shadow; } else if (isInterior != 0) { vec3 mocv = max(VertColor.rgb, vec3(0.5)); diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index a7b9ef940c38550f2f1e033f8f4ee5c56700db4e..2453f0ff763aa7053f9ce3d164e1646cb97d2d03 100644 GIT binary patch literal 21120 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 Wo&{T=^Hb((JY(qRyPY=Q`~MGgd{}q@ literal 20156 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 2 bool isWindow = false; // F_SIDN or F_WINDOW material + bool isLava = false; // lava/magma texture (UV scroll) // For multi-draw: store index ranges struct DrawRange { uint32_t firstIndex; uint32_t indexCount; }; std::vector draws; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 690a5234..acf2816d 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1131,10 +1131,32 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("hazardlight") != std::string::npos) || (lowerName.find("lavasplash") != std::string::npos) || (lowerName.find("lavabubble") != std::string::npos) || + (lowerName.find("lavasteam") != std::string::npos) || (lowerName.find("wisps") != std::string::npos); gpuModel.isSpellEffect = effectByName || (hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3); + gpuModel.isLavaModel = + (lowerName.find("forgelava") != std::string::npos) || + (lowerName.find("lavapot") != std::string::npos) || + (lowerName.find("lavaflow") != std::string::npos); + if (lowerName.find("lava") != std::string::npos || lowerName.find("steam") != std::string::npos) { + LOG_WARNING("M2 LAVA/STEAM: '", model.name, "' isSpellEffect=", gpuModel.isSpellEffect ? "Y" : "N", + " effectByName=", effectByName ? "Y" : "N", + " particles=", model.particleEmitters.size(), + " verts=", model.vertices.size(), + " batches=", model.batches.size(), + " texTransforms=", model.textureTransforms.size(), + " texTransformLookup=", model.textureTransformLookup.size(), + " isLavaModel=", gpuModel.isLavaModel ? "Y" : "N"); + for (size_t bi = 0; bi < model.batches.size(); bi++) { + const auto& b = model.batches[bi]; + uint8_t bm = (b.materialIndex < model.materials.size()) ? model.materials[b.materialIndex].blendMode : 255; + uint16_t mf = (b.materialIndex < model.materials.size()) ? model.materials[b.materialIndex].flags : 0; + LOG_WARNING(" batch[", bi, "]: blend=", (int)bm, " matFlags=0x", std::hex, mf, std::dec, + " texAnimIdx=", b.textureAnimIndex, " idxCount=", b.indexCount); + } + } gpuModel.isInstancePortal = (lowerName.find("instanceportal") != std::string::npos) || (lowerName.find("instancenewportal") != std::string::npos) || @@ -2357,6 +2379,9 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } const bool foliageLikeModel = model.isFoliageLike; + // Particle-dominant spell effects: mesh is emission geometry, render dim + const bool particleDominantEffect = model.isSpellEffect && + !model.particleEmitters.empty() && model.batches.size() <= 2; for (const auto& batch : model.batches) { if (batch.indexCount == 0) continue; if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; @@ -2421,6 +2446,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } } + // Lava M2 models: fallback UV scroll if no texture animation + if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { + static auto startTime = std::chrono::steady_clock::now(); + float t = std::chrono::duration(std::chrono::steady_clock::now() - startTime).count(); + uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); + } // Foliage/card-like batches render more stably as cutout (depth-write on) // instead of alpha-blended sorting. @@ -2498,6 +2529,10 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const pc.useBones = useBones ? 1 : 0; pc.isFoliage = model.shadowWindFoliage ? 1 : 0; pc.fadeAlpha = instanceFadeAlpha; + // Particle-dominant effects: mesh is emission geometry, don't render + if (particleDominantEffect && batch.blendMode <= 1) { + continue; + } vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); @@ -2948,8 +2983,23 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt std::uniform_real_distribution distN(-1.0f, 1.0f); std::uniform_int_distribution distTile; + static uint32_t steamDiagCounter = 0; + bool steamDiag = (gpu.isSpellEffect && gpu.particleEmitters.size() >= 6 && steamDiagCounter < 3); + for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) { const auto& em = gpu.particleEmitters[ei]; + if (steamDiag) { + float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + LOG_WARNING("STEAM PARTICLE DIAG emitter[", ei, "]: enabled=", em.enabled ? "Y" : "N", + " rate=", rate, " life=", life, + " animTime=", inst.animTime, " seq=", inst.currentSequenceIndex, + " bone=", em.bone, " blendType=", (int)em.blendingType, + " globalSeq=", em.emissionRate.globalSequence, + " rateSeqs=", em.emissionRate.sequences.size()); + } if (!em.enabled) continue; float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, @@ -3038,6 +3088,12 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt inst.emitterAccumulators[ei] = 0.0f; } } + if (steamDiag) { + LOG_WARNING("STEAM PARTICLE DIAG: totalParticles=", inst.particles.size(), + " sequences=", gpu.sequences.size(), + " globalSeqDurations=", gpu.globalSequenceDurations.size()); + steamDiagCounter++; + } } void M2Renderer::updateParticles(M2Instance& inst, float dt) { diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 5fbca940..b0454ffa 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -835,10 +835,24 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); - for (const auto& group : wmoReady.model.groups) { + for (size_t gi = 0; gi < wmoReady.model.groups.size(); gi++) { + const auto& group = wmoReady.model.groups[gi]; if (!group.liquid.hasLiquid()) continue; - // Skip interior groups — their liquid is for indoor areas - if (group.flags & 0x2000) continue; + uint16_t lt = group.liquid.materialId; + uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4); + bool isInterior = (group.flags & 0x2000) != 0; + LOG_WARNING("WMO MLIQ group", gi, ": flags=0x", std::hex, group.flags, std::dec, + " materialId=", lt, " basicType=", (int)basicType, + " interior=", isInterior ? "Y" : "N", + " xVerts=", group.liquid.xVerts, " yVerts=", group.liquid.yVerts); + // Skip interior water/ocean but keep magma/slime (e.g. Ironforge lava) + if (isInterior) { + if (basicType < 2) { + LOG_WARNING(" -> SKIPPED (interior water/ocean)"); + continue; + } + LOG_WARNING(" -> LOADING (interior magma/slime)"); + } waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId); loadedLiquids++; } diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index 6a5c310d..a01cfedb 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -544,9 +544,14 @@ void WaterRenderer::updateMaterialUBO(WaterSurface& surface) { // WMO liquid material override if (surface.wmoId != 0) { const uint8_t basicType = (surface.liquidType == 0) ? 0 : ((surface.liquidType - 1) % 4); - if (basicType == 2 || basicType == 3) { - color = glm::vec4(0.2f, 0.4f, 0.6f, 1.0f); - alpha = 0.45f; + if (basicType == 2) { + // Magma — bright orange-red, opaque + color = glm::vec4(1.0f, 0.35f, 0.05f, 1.0f); + alpha = 0.95f; + } else if (basicType == 3) { + // Slime — green, semi-opaque + color = glm::vec4(0.2f, 0.6f, 0.1f, 1.0f); + alpha = 0.85f; } } @@ -935,7 +940,7 @@ void WaterRenderer::loadFromWMO([[maybe_unused]] const pipeline::WMOLiquid& liqu surface.origin.z = adjustedZ; surface.position.z = adjustedZ; - if (surface.origin.z > 300.0f || surface.origin.z < -100.0f) return; + if (surface.origin.z > 2000.0f || surface.origin.z < -500.0f) return; // Build tile mask from MLIQ flags and per-vertex heights size_t tileCount = static_cast(surface.width) * static_cast(surface.height); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 5bae174f..8e72bd0d 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -596,20 +596,26 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { // so we additionally check for "window" or "glass" in the texture path to // distinguish actual glass from lamp post geometry. bool isWindow = false; + bool isLava = false; if (batch.materialId < modelData.materialTextureIndices.size()) { uint32_t ti = modelData.materialTextureIndices[batch.materialId]; if (ti < modelData.textureNames.size()) { const auto& texName = modelData.textureNames[ti]; - // Case-insensitive search for "window" or "glass" + // Case-insensitive search for material types std::string texNameLower = texName; std::transform(texNameLower.begin(), texNameLower.end(), texNameLower.begin(), ::tolower); isWindow = (texNameLower.find("window") != std::string::npos || texNameLower.find("glass") != std::string::npos); + isLava = (texNameLower.find("lava") != std::string::npos || + texNameLower.find("molten") != std::string::npos || + texNameLower.find("magma") != std::string::npos); + if (isLava) { + LOG_WARNING("WMO LAVA BATCH: tex='", texName, "' matId=", batch.materialId, + " blend=", blendMode, " flags=0x", std::hex, matFlags, std::dec); + } } } - - BatchKey key{ reinterpret_cast(tex), alphaTest, unlit, isWindow }; auto& mb = batchMap[key]; if (mb.draws.empty()) { @@ -619,6 +625,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { mb.unlit = unlit; mb.isTransparent = (blendMode >= 2); mb.isWindow = isWindow; + mb.isLava = isLava; // Look up normal/height map from texture cache if (hasTexture && tex != whiteTexture_.get()) { for (const auto& [cacheKey, cacheEntry] : textureCache) { @@ -668,6 +675,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } matData.heightMapVariance = mb.heightMapVariance; matData.normalMapStrength = normalMapStrength_; + matData.isLava = mb.isLava ? 1 : 0; if (matBuf.info.pMappedData) { memcpy(matBuf.info.pMappedData, &matData, sizeof(matData)); } @@ -789,6 +797,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { doodadTemplate.m2Path = m2Path; doodadTemplate.localTransform = localTransform; modelData.doodadTemplates.push_back(doodadTemplate); + } if (!modelData.doodadTemplates.empty()) { From 41218a3b04195d7e0c9d2e509a4368a134d729cd Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 7 Mar 2026 00:49:11 -0800 Subject: [PATCH 14/16] Remove diagnostic logging for lava/steam/MLIQ --- src/rendering/m2_renderer.cpp | 38 ------------------------------- src/rendering/terrain_manager.cpp | 20 ++++------------ src/rendering/wmo_renderer.cpp | 4 ---- 3 files changed, 5 insertions(+), 57 deletions(-) diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index acf2816d..d76843a0 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1140,23 +1140,6 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("forgelava") != std::string::npos) || (lowerName.find("lavapot") != std::string::npos) || (lowerName.find("lavaflow") != std::string::npos); - if (lowerName.find("lava") != std::string::npos || lowerName.find("steam") != std::string::npos) { - LOG_WARNING("M2 LAVA/STEAM: '", model.name, "' isSpellEffect=", gpuModel.isSpellEffect ? "Y" : "N", - " effectByName=", effectByName ? "Y" : "N", - " particles=", model.particleEmitters.size(), - " verts=", model.vertices.size(), - " batches=", model.batches.size(), - " texTransforms=", model.textureTransforms.size(), - " texTransformLookup=", model.textureTransformLookup.size(), - " isLavaModel=", gpuModel.isLavaModel ? "Y" : "N"); - for (size_t bi = 0; bi < model.batches.size(); bi++) { - const auto& b = model.batches[bi]; - uint8_t bm = (b.materialIndex < model.materials.size()) ? model.materials[b.materialIndex].blendMode : 255; - uint16_t mf = (b.materialIndex < model.materials.size()) ? model.materials[b.materialIndex].flags : 0; - LOG_WARNING(" batch[", bi, "]: blend=", (int)bm, " matFlags=0x", std::hex, mf, std::dec, - " texAnimIdx=", b.textureAnimIndex, " idxCount=", b.indexCount); - } - } gpuModel.isInstancePortal = (lowerName.find("instanceportal") != std::string::npos) || (lowerName.find("instancenewportal") != std::string::npos) || @@ -2983,23 +2966,8 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt std::uniform_real_distribution distN(-1.0f, 1.0f); std::uniform_int_distribution distTile; - static uint32_t steamDiagCounter = 0; - bool steamDiag = (gpu.isSpellEffect && gpu.particleEmitters.size() >= 6 && steamDiagCounter < 3); - for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) { const auto& em = gpu.particleEmitters[ei]; - if (steamDiag) { - float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - LOG_WARNING("STEAM PARTICLE DIAG emitter[", ei, "]: enabled=", em.enabled ? "Y" : "N", - " rate=", rate, " life=", life, - " animTime=", inst.animTime, " seq=", inst.currentSequenceIndex, - " bone=", em.bone, " blendType=", (int)em.blendingType, - " globalSeq=", em.emissionRate.globalSequence, - " rateSeqs=", em.emissionRate.sequences.size()); - } if (!em.enabled) continue; float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, @@ -3088,12 +3056,6 @@ void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt inst.emitterAccumulators[ei] = 0.0f; } } - if (steamDiag) { - LOG_WARNING("STEAM PARTICLE DIAG: totalParticles=", inst.particles.size(), - " sequences=", gpu.sequences.size(), - " globalSeqDurations=", gpu.globalSequenceDurations.size()); - steamDiagCounter++; - } } void M2Renderer::updateParticles(M2Instance& inst, float dt) { diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index b0454ffa..b164d969 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -835,23 +835,13 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); - for (size_t gi = 0; gi < wmoReady.model.groups.size(); gi++) { - const auto& group = wmoReady.model.groups[gi]; + for (const auto& group : wmoReady.model.groups) { if (!group.liquid.hasLiquid()) continue; - uint16_t lt = group.liquid.materialId; - uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4); - bool isInterior = (group.flags & 0x2000) != 0; - LOG_WARNING("WMO MLIQ group", gi, ": flags=0x", std::hex, group.flags, std::dec, - " materialId=", lt, " basicType=", (int)basicType, - " interior=", isInterior ? "Y" : "N", - " xVerts=", group.liquid.xVerts, " yVerts=", group.liquid.yVerts); // Skip interior water/ocean but keep magma/slime (e.g. Ironforge lava) - if (isInterior) { - if (basicType < 2) { - LOG_WARNING(" -> SKIPPED (interior water/ocean)"); - continue; - } - LOG_WARNING(" -> LOADING (interior magma/slime)"); + if (group.flags & 0x2000) { + uint16_t lt = group.liquid.materialId; + uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4); + if (basicType < 2) continue; } waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId); loadedLiquids++; diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 8e72bd0d..ff6b0035 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -609,10 +609,6 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { isLava = (texNameLower.find("lava") != std::string::npos || texNameLower.find("molten") != std::string::npos || texNameLower.find("magma") != std::string::npos); - if (isLava) { - LOG_WARNING("WMO LAVA BATCH: tex='", texName, "' matId=", batch.materialId, - " blend=", blendMode, " flags=0x", std::hex, matFlags, std::dec); - } } } From 88dc1f94a68613a3b37021c0ab4d2f3c6edb32f8 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 7 Mar 2026 00:50:45 -0800 Subject: [PATCH 15/16] Update README and status docs for v0.3.6-preview - Add water/lava/lighting rendering features to README - Add transport riding to movement features - Update status date and rendering capabilities - Note interior shadow and lava steam known gaps --- README.md | 10 ++++++---- docs/status.md | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9b59a5d1..b3f6d600 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. > **Legal Disclaimer**: This is an educational/research project. It does not include any Blizzard Entertainment assets, data files, or proprietary code. World of Warcraft and all related assets are the property of Blizzard Entertainment, Inc. This project is not affiliated with or endorsed by Blizzard Entertainment. Users are responsible for supplying their own legally obtained game data files and for ensuring compliance with all applicable laws in their jurisdiction. -## Status & Direction (2026-02-18) +## Status & Direction (2026-03-07) - **Compatibility**: **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a** are all supported via expansion profiles and per-expansion packet parsers (`src/game/packet_parsers_classic.cpp`, `src/game/packet_parsers_tbc.cpp`). All three expansions are roughly on par — no single one is significantly more complete than the others. - **Tested against**: AzerothCore, TrinityCore, and Mangos. -- **Current focus**: protocol correctness across server variants, visual accuracy (M2/WMO edge cases, equipment textures), and multi-expansion coverage. +- **Current focus**: protocol correctness across server variants, visual accuracy (M2/WMO edge cases, lava/water rendering, equipment textures), and multi-expansion coverage. - **Warden**: Full module execution via Unicorn Engine CPU emulation. Decrypts (RC4→RSA→zlib), parses and relocates the PE module, executes via x86 emulation with Windows API interception. Module cache at `~/.local/share/wowee/warden_cache/`. ## Features @@ -33,7 +33,9 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. - **Atmosphere** -- Procedural clouds (FBM noise), lens flare with chromatic aberration, cloud/fog star occlusion - **Characters** -- Skeletal animation with GPU vertex skinning (256 bones), race-aware textures - **Buildings** -- WMO renderer with multi-material batches, frustum culling, 160-unit distance culling -- **Particles** -- M2 particle emitters with WotLK struct parsing, billboarded glow effects +- **Water & Lava** -- Terrain and WMO water with refraction/reflection, magma/slime rendering with fbm noise flow, Beer-Lambert absorption +- **Particles** -- M2 particle emitters with WotLK struct parsing, billboarded glow effects, lava steam/splash effects +- **Lighting** -- Shadow mapping with PCF, interior/exterior light modes, WMO window glass with fresnel reflections ### Asset Pipeline - Extracted loose-file **`Data/`** tree indexed by **`manifest.json`** (fast lookup + caching) @@ -44,7 +46,7 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. ### Gameplay Systems - **Authentication** -- Full SRP6a implementation with RC4 header encryption - **Character System** -- Creation (with nonbinary gender option), selection, 3D preview, stats panel, race/class support -- **Movement** -- WASD movement, camera orbit, spline path following +- **Movement** -- WASD movement, camera orbit, spline path following, transport riding (trams, ships, zeppelins) - **Combat** -- Auto-attack, spell casting with cooldowns, damage calculation, death handling - **Targeting** -- Tab-cycling, click-to-target, faction-based hostility (using Faction.dbc) - **Inventory** -- 23 equipment slots, 16 backpack slots, drag-drop, auto-equip diff --git a/docs/status.md b/docs/status.md index d67a931b..d5acc2a7 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Project Status -**Last updated**: 2026-02-19 +**Last updated**: 2026-03-07 ## What This Repo Is @@ -11,7 +11,7 @@ Wowee is a native C++ World of Warcraft client experiment focused on connecting Implemented (working in normal use): - Auth flow: SRP6a auth + realm list + world connect with header encryption -- Rendering: terrain, WMO/M2 rendering, water, sky system, particles, minimap/world map, loading video playback +- Rendering: terrain, WMO/M2 rendering, water/magma/slime, sky system, particles, shadow mapping, minimap/world map, loading video playback - Character system: creation (including nonbinary gender), selection, 3D preview with equipment, character screen - Core gameplay: movement, targeting, combat, action bar, inventory/equipment, chat (tabs/channels, emotes, item links) - Quests: quest markers (! and ?) on NPCs/minimap, quest log with detail queries/retry, objective tracking, accept/complete flow, turn-in @@ -25,9 +25,10 @@ Implemented (working in normal use): In progress / known gaps: -- Transports (ships, zeppelins, elevators): partial support, timing and edge cases still buggy +- Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain - 3D positional audio: not implemented (mono/stereo only) - Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects) +- Interior rendering: WMO interior shadows disabled (too dark); lava steam particles sparse ## Where To Look From f374e1923940bcc7bbaca151aafa7a37a133ce4f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 7 Mar 2026 00:55:34 -0800 Subject: [PATCH 16/16] Comprehensive README/status update covering 60+ commits since Feb 2026 Add instances, bank, auction house, mail, pets, party stats, map exploration, talent/spellbook revamp, chest looting, spirit healer, CI builds, performance optimizations, shadow/collision/lava fixes. --- README.md | 46 ++++++++++++++++++++++++++++++---------------- docs/status.md | 22 ++++++++++++++++------ 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b3f6d600..1196da24 100644 --- a/README.md +++ b/README.md @@ -22,20 +22,23 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. ## Status & Direction (2026-03-07) - **Compatibility**: **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a** are all supported via expansion profiles and per-expansion packet parsers (`src/game/packet_parsers_classic.cpp`, `src/game/packet_parsers_tbc.cpp`). All three expansions are roughly on par — no single one is significantly more complete than the others. -- **Tested against**: AzerothCore, TrinityCore, and Mangos. -- **Current focus**: protocol correctness across server variants, visual accuracy (M2/WMO edge cases, lava/water rendering, equipment textures), and multi-expansion coverage. +- **Tested against**: AzerothCore, TrinityCore, Mangos, and Turtle WoW (1.17). +- **Current focus**: instance dungeons, visual accuracy (lava/water, shadow mapping, WMO interiors), and multi-expansion coverage. - **Warden**: Full module execution via Unicorn Engine CPU emulation. Decrypts (RC4→RSA→zlib), parses and relocates the PE module, executes via x86 emulation with Windows API interception. Module cache at `~/.local/share/wowee/warden_cache/`. +- **CI**: GitHub Actions builds for Linux (x86-64, ARM64), Windows (MSYS2), and macOS (ARM64). Security scans via CodeQL, Semgrep, and sanitizers. ## Features ### Rendering Engine -- **Terrain** -- Multi-tile streaming with async loading, texture splatting (4 layers), frustum culling +- **Terrain** -- Multi-tile streaming with async loading, texture splatting (4 layers), frustum culling, expanded load radius during taxi flights - **Atmosphere** -- Procedural clouds (FBM noise), lens flare with chromatic aberration, cloud/fog star occlusion -- **Characters** -- Skeletal animation with GPU vertex skinning (256 bones), race-aware textures -- **Buildings** -- WMO renderer with multi-material batches, frustum culling, 160-unit distance culling -- **Water & Lava** -- Terrain and WMO water with refraction/reflection, magma/slime rendering with fbm noise flow, Beer-Lambert absorption +- **Characters** -- Skeletal animation with GPU vertex skinning (256 bones), race-aware textures, per-instance NPC hair/skin textures +- **Buildings** -- WMO renderer with multi-material batches, frustum culling, collision (wall/floor classification, slope sliding), interior glass transparency +- **Instances** -- WDT parser for WMO-only dungeon maps, area trigger portals with glow/spin effects, seamless zone transitions +- **Water & Lava** -- Terrain and WMO water with refraction/reflection, magma/slime rendering with multi-octave FBM noise flow, Beer-Lambert absorption, M2 lava waterfalls with UV scroll - **Particles** -- M2 particle emitters with WotLK struct parsing, billboarded glow effects, lava steam/splash effects -- **Lighting** -- Shadow mapping with PCF, interior/exterior light modes, WMO window glass with fresnel reflections +- **Lighting** -- Shadow mapping with PCF filtering, per-frame shadow updates, AABB-based culling, interior/exterior light modes, WMO window glass with fresnel reflections +- **Performance** -- Binary keyframe search for animations, incremental spatial index, static doodad skip, hash-free render/shadow culling ### Asset Pipeline - Extracted loose-file **`Data/`** tree indexed by **`manifest.json`** (fast lookup + caching) @@ -46,21 +49,27 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. ### Gameplay Systems - **Authentication** -- Full SRP6a implementation with RC4 header encryption - **Character System** -- Creation (with nonbinary gender option), selection, 3D preview, stats panel, race/class support -- **Movement** -- WASD movement, camera orbit, spline path following, transport riding (trams, ships, zeppelins) -- **Combat** -- Auto-attack, spell casting with cooldowns, damage calculation, death handling -- **Targeting** -- Tab-cycling, click-to-target, faction-based hostility (using Faction.dbc) -- **Inventory** -- 23 equipment slots, 16 backpack slots, drag-drop, auto-equip -- **Spells** -- Spellbook with class specialty tabs, drag-drop to action bar, spell icons +- **Movement** -- WASD movement, camera orbit, spline path following, transport riding (trams, ships, zeppelins), movement ACK responses +- **Combat** -- Auto-attack, spell casting with cooldowns, damage calculation, death handling, spirit healer resurrection +- **Targeting** -- Tab-cycling with hostility filtering, click-to-target, faction-based hostility (using Faction.dbc) +- **Inventory** -- 23 equipment slots, 16 backpack slots, drag-drop, auto-equip, item tooltips with weapon damage/speed +- **Bank** -- Full bank support for all expansions, bag slots, drag-drop, right-click deposit (non-equippable items) +- **Spells** -- Spellbook with specialty, general, profession, mount, and companion tabs; drag-drop to action bar; item use support +- **Talents** -- Talent tree UI with proper visuals and functionality - **Action Bar** -- 12 slots, drag-drop from spellbook/inventory, click-to-cast, keybindings - **Trainers** -- Spell trainer UI, buy spells, known/available/unavailable states -- **Quests** -- Quest markers (! and ?) on NPCs and minimap, quest log, quest details, turn-in flow +- **Quests** -- Quest markers (! and ?) on NPCs and minimap, quest log, quest details, turn-in flow, quest item progress tracking +- **Auction House** -- Search with filters, pagination, sell picker with tooltips, bid/buyout +- **Mail** -- Item attachment support for sending - **Vendors** -- Buy, sell, and buyback (most recent sold item), gold tracking, inventory sync -- **Loot** -- Loot window, gold looting, item pickup +- **Loot** -- Loot window, gold looting, item pickup, chest/gameobject looting - **Gossip** -- NPC interaction, dialogue options - **Chat** -- Tabs/channels, emotes, chat bubbles, clickable URLs, clickable item links with tooltips -- **Party** -- Group invites, party list +- **Party** -- Group invites, party list, out-of-range member health via SMSG_PARTY_MEMBER_STATS +- **Pets** -- Pet tracking via SMSG_PET_SPELLS, dismiss pet button +- **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior - **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine) -- **UI** -- Loading screens with progress bar, settings window, minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view) +- **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view) ## Building @@ -216,6 +225,11 @@ make -j$(nproc) - [Warden Quick Reference](docs/WARDEN_QUICK_REFERENCE.md) -- Warden module execution overview and testing - [Warden Implementation](docs/WARDEN_IMPLEMENTATION.md) -- Technical details of the implementation +## CI / CD + +- GitHub Actions builds on every push: Linux (x86-64, ARM64), Windows (MSYS2), macOS (ARM64) +- Container build via `container/build-in-container.sh` (Podman) + ## Security - GitHub Actions runs a dedicated security workflow at `.github/workflows/security.yml`. diff --git a/docs/status.md b/docs/status.md index d5acc2a7..8244b425 100644 --- a/docs/status.md +++ b/docs/status.md @@ -11,17 +11,26 @@ Wowee is a native C++ World of Warcraft client experiment focused on connecting Implemented (working in normal use): - Auth flow: SRP6a auth + realm list + world connect with header encryption -- Rendering: terrain, WMO/M2 rendering, water/magma/slime, sky system, particles, shadow mapping, minimap/world map, loading video playback -- Character system: creation (including nonbinary gender), selection, 3D preview with equipment, character screen -- Core gameplay: movement, targeting, combat, action bar, inventory/equipment, chat (tabs/channels, emotes, item links) -- Quests: quest markers (! and ?) on NPCs/minimap, quest log with detail queries/retry, objective tracking, accept/complete flow, turn-in +- Rendering: terrain, WMO/M2, water/magma/slime (FBM noise shaders), sky system, particles, shadow mapping, minimap/world map, loading video playback +- Instances: WDT parser, WMO-only dungeon maps, area trigger portals with glow/spin effects, zone transitions +- Character system: creation (including nonbinary gender), selection, 3D preview with equipment, character screen, per-instance NPC hair/skin textures +- Core gameplay: movement (with ACK responses), targeting (hostility-filtered tab-cycle), combat, action bar, inventory/equipment, chat (tabs/channels, emotes, item links) +- Quests: quest markers (! and ?) on NPCs/minimap, quest log with detail queries/retry, objective tracking, accept/complete flow, turn-in, quest item progress - Trainers: spell trainer UI, buy spells, known/available/unavailable states -- Vendors, loot, gossip dialogs (including buyback for most recently sold item) -- Spellbook with class tabs, drag-drop to action bar, spell icons +- Vendors, loot (including chest/gameobject loot), gossip dialogs (including buyback for most recently sold item) +- Bank: full bank support for all expansions, bag slots, drag-drop, right-click deposit +- Auction house: search with filters, pagination, sell picker, bid/buyout, tooltips +- Mail: item attachment support for sending +- Spellbook with specialty/general/profession/mount/companion tabs, drag-drop to action bar, spell icons, item use +- Talent tree UI with proper visuals and functionality +- Pet tracking (SMSG_PET_SPELLS), dismiss pet button +- Party: group invites, party list, out-of-range member health (SMSG_PARTY_MEMBER_STATS) +- Map exploration: subzone-level fog-of-war reveal - Warden anti-cheat: full module execution via Unicorn Engine x86 emulation; module caching - Audio: ambient, movement, combat, spell, and UI sound systems - Bag UI: separate bag windows, open-bag indicator on bag bar, optional collapse-empty mode in aggregate bag view - Multi-expansion: Classic/Vanilla, TBC, WotLK, and Turtle WoW (1.17) protocol and asset variants +- CI: GitHub Actions for Linux (x86-64, ARM64), Windows (MSYS2), macOS (ARM64); container builds via Podman In progress / known gaps: @@ -29,6 +38,7 @@ In progress / known gaps: - 3D positional audio: not implemented (mono/stereo only) - Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects) - Interior rendering: WMO interior shadows disabled (too dark); lava steam particles sparse +- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs) ## Where To Look