From a4966e486f3426181709742505dd3a6ad8780006 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 7 Mar 2026 22:03:28 -0800 Subject: [PATCH] Fix WMO wall collision, normal mapping, POM backfill, and M2/WMO rendering performance - Fix MOPY flag check (0x08 not 0x01) for proper wall collision detection - Cap MAX_PUSH to PLAYER_RADIUS to prevent gradual clip-through - Fix WMO doodad quaternion component ordering (X/Y swap) - Linear normal map strength blend in shader for smooth slider control - Enable shadow sampling for interior WMO groups (covered outdoor areas) - Backfill deferred normal/height maps after streaming with descriptor rebind - M2: prepareRender only iterates animated instances, bone dirty flag - M2: remove worker thread VMA allocation, skip unready bone instances - WMO: persistent visibility vectors, sequential culling - Add FSR EASU/RCAS shaders --- assets/shaders/fsr_easu.frag.glsl | 102 +++ assets/shaders/fsr_easu.frag.spv | Bin 0 -> 10292 bytes assets/shaders/fsr_rcas.frag.glsl | 43 + assets/shaders/fsr_rcas.frag.spv | Bin 0 -> 3720 bytes assets/shaders/wmo.frag.glsl | 14 +- assets/shaders/wmo.frag.spv | Bin 21120 -> 12456 bytes include/core/application.hpp | 5 + include/game/game_handler.hpp | 3 + include/rendering/character_renderer.hpp | 2 + include/rendering/m2_renderer.hpp | 4 + include/rendering/renderer.hpp | 73 +- include/rendering/vk_context.hpp | 5 + include/rendering/wmo_renderer.hpp | 7 + include/ui/game_screen.hpp | 4 + src/core/application.cpp | 60 +- src/core/window.cpp | 11 +- src/game/game_handler.cpp | 9 + src/rendering/character_renderer.cpp | 55 ++ src/rendering/m2_renderer.cpp | 107 ++- src/rendering/performance_hud.cpp | 14 + src/rendering/renderer.cpp | 1033 +++++++++++++++++----- src/rendering/terrain_manager.cpp | 2 + src/rendering/vk_context.cpp | 32 +- src/rendering/wmo_renderer.cpp | 189 ++-- src/ui/game_screen.cpp | 45 + 25 files changed, 1467 insertions(+), 352 deletions(-) create mode 100644 assets/shaders/fsr_easu.frag.glsl create mode 100644 assets/shaders/fsr_easu.frag.spv create mode 100644 assets/shaders/fsr_rcas.frag.glsl create mode 100644 assets/shaders/fsr_rcas.frag.spv diff --git a/assets/shaders/fsr_easu.frag.glsl b/assets/shaders/fsr_easu.frag.glsl new file mode 100644 index 00000000..20e5ed32 --- /dev/null +++ b/assets/shaders/fsr_easu.frag.glsl @@ -0,0 +1,102 @@ +#version 450 +// FSR 1.0 EASU (Edge Adaptive Spatial Upsampling) — Fragment Shader +// Based on AMD FidelityFX Super Resolution 1.0 +// Implements edge-adaptive bilinear upsampling with directional filtering + +layout(set = 0, binding = 0) uniform sampler2D uInput; + +layout(push_constant) uniform FSRConstants { + vec4 con0; // inputSize.xy, 1/inputSize.xy + vec4 con1; // inputSize.xy / outputSize.xy, 0.5 * inputSize.xy / outputSize.xy + vec4 con2; // outputSize.xy, 1/outputSize.xy + vec4 con3; // sharpness, 0, 0, 0 +} fsr; + +layout(location = 0) in vec2 TexCoord; +layout(location = 0) out vec4 outColor; + +// Fetch a texel with offset (in input pixels) +vec3 fsrFetch(vec2 p, vec2 off) { + return textureLod(uInput, (p + off + 0.5) * fsr.con0.zw, 0.0).rgb; +} + +void main() { + // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, + // but we need standard UV coords for texture sampling) + vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + + // Map output pixel to input space + vec2 pp = tc * fsr.con2.xy; // output pixel position + vec2 ip = pp * fsr.con1.xy - 0.5; // input pixel position (centered) + vec2 fp = floor(ip); + vec2 ff = ip - fp; + + // 12-tap filter: 4x3 grid around the pixel + // b c + // e f g h + // i j k l + // n o + vec3 b = fsrFetch(fp, vec2( 0, -1)); + vec3 c = fsrFetch(fp, vec2( 1, -1)); + vec3 e = fsrFetch(fp, vec2(-1, 0)); + vec3 f = fsrFetch(fp, vec2( 0, 0)); + vec3 g = fsrFetch(fp, vec2( 1, 0)); + vec3 h = fsrFetch(fp, vec2( 2, 0)); + vec3 i = fsrFetch(fp, vec2(-1, 1)); + vec3 j = fsrFetch(fp, vec2( 0, 1)); + vec3 k = fsrFetch(fp, vec2( 1, 1)); + vec3 l = fsrFetch(fp, vec2( 2, 1)); + vec3 n = fsrFetch(fp, vec2( 0, 2)); + vec3 o = fsrFetch(fp, vec2( 1, 2)); + + // Luma (use green channel as good perceptual approximation) + float bL = b.g, cL = c.g, eL = e.g, fL = f.g; + float gL = g.g, hL = h.g, iL = i.g, jL = j.g; + float kL = k.g, lL = l.g, nL = n.g, oL = o.g; + + // Directional edge detection + // Compute gradients in 4 directions (N-S, E-W, NE-SW, NW-SE) + float dc = cL - jL; + float db = bL - kL; + float de = eL - hL; + float di = iL - lL; + + // Length of the edge in each direction + float lenH = abs(eL - fL) + abs(fL - gL) + abs(iL - jL) + abs(jL - kL); + float lenV = abs(bL - fL) + abs(fL - jL) + abs(cL - gL) + abs(gL - kL); + + // Determine dominant edge direction + float dirH = lenV / (lenH + lenV + 1e-7); + float dirV = lenH / (lenH + lenV + 1e-7); + + // Bilinear weights + float w1 = (1.0 - ff.x) * (1.0 - ff.y); + float w2 = ff.x * (1.0 - ff.y); + float w3 = (1.0 - ff.x) * ff.y; + float w4 = ff.x * ff.y; + + // Edge-aware sharpening: boost weights along edges + float sharpness = fsr.con3.x; + float edgeStr = max(abs(lenH - lenV) / (lenH + lenV + 1e-7), 0.0); + float sharp = mix(0.0, sharpness, edgeStr); + + // Sharpen bilinear by pulling toward nearest texel + float maxW = max(max(w1, w2), max(w3, w4)); + w1 = mix(w1, float(w1 == maxW), sharp * 0.25); + w2 = mix(w2, float(w2 == maxW), sharp * 0.25); + w3 = mix(w3, float(w3 == maxW), sharp * 0.25); + w4 = mix(w4, float(w4 == maxW), sharp * 0.25); + + // Normalize + float wSum = w1 + w2 + w3 + w4; + w1 /= wSum; w2 /= wSum; w3 /= wSum; w4 /= wSum; + + // Final color: weighted blend of the 4 nearest texels with edge awareness + vec3 color = f * w1 + g * w2 + j * w3 + k * w4; + + // Optional: blend in some of the surrounding texels for anti-aliasing + float aa = 0.125 * edgeStr; + color = mix(color, (b + c + e + h + i + l + n + o) / 8.0, aa * 0.15); + + outColor = vec4(clamp(color, 0.0, 1.0), 1.0); +} diff --git a/assets/shaders/fsr_easu.frag.spv b/assets/shaders/fsr_easu.frag.spv new file mode 100644 index 0000000000000000000000000000000000000000..5ddc2ea8f71d374f23c73a50e87cc4a107ade85c GIT binary patch literal 10292 zcmZ9R37nNx8OA@jcK}%waY0-MaLWY|+);2u7*rGl758D71qOzhkr@^d6%iE?ao-h1 zQIxVQ&9W>^D=RBoZ7<9Aeb-8}|Nrv6cJ6n7_vJj#^FHT2=R4o|mOGSskL;Uey|Mw> zplnO8tn%40>y473^{KS=Q)f;c)!oxP>WCvpYuG+3ReXAO$oghwT4P~jTZf55h>au% z<5On

HHp_9A*Y>z~z6ZK$6%t$ymHhNiBrhVGtA+glpi+d5hrn%fq&H1)JCY0=j& z@wIg{wJ&Z?4K+B@acdZ95M#7EZj_#huj-GBPYE4BKH+6PY)*&?40qt;ep?R!mV^YmkLmR8+ zeDBnB)5NXYJk!B*2#;?j zZCB%>#)UaQyNZ{xS#XA~c+acKm9q<~cqzLG&Z((*8>({Utg(uhDm9r-#cQ8*4s)w` zDQknPIo}0UxpL;b|KGQib;9T6-bGcpa>gmF@-Bf}+q%oDa^-A!6)$Bg;PZ3u6;-)% zwz7(svQ=>BY2DRTxpH=G6)$CL;O)70ZB?$Et*hdtY(3nI$htRI<;vNuRlJnl4tFi| z-dU9^XLnceQg$DF&Q!gnYy-F{#~Z;dIo2s_@ zX!j=k$i1Hj-wBt#;qlg|pH@wuF$}f? ztsajj{RH2G-X5bq$JjA9sg29IYv|`-xV45>ST?~q><(5d6MKU^ezi2LyC<4+GjCnF z_inXa;I2z8;k+D2z7OHY{$5A4VM!L;^uZLBYM{nf00C~Ym_$NutB6}GMa zVPL%;OVnq3^I16|{A}@xNACQNBRn_3>+-zwnFe+)&N=Fx38voGLpWFFW)L2OdT8f@ z9XHi3&bi+3HG}1;=6%<`YB6RPn9qsP?-Gbw+HWb`@$4JE%X3evU7dSU?RqrprP@t- zU-hVWNA6K`eeR`o4bt^~8-B;u*T%+rj^u>6xApG}b}jYq%&OPH;}|D_S=aHwuc`3t zJ%0B#UKjcM;rHG%Y`k?J0y~EL%6%jEI#oN0^W^z;Z~IYu3#Eouuh-Z?MC>`a?-(hc zP~cMv{IrC-XQn6IdNT{$_l~Hq-y3Cc{l0so{DOq*_uV7SHxzhd!u2;L-1;pAJ~!d| z+Y+vSL4kWCkMZ?)CS3ob0{2}c^)E@de&0tTFJGQ;>#s<-`6~*1Wy1BZO1S>j1%7S9 z^{+{|{mVYz^XN9!{u-hu5)+ z$LCjgjs&Y;O&$9jMR*TapN{4jM-$dEH=etn_c}Fem_L?KbDUb*afHwG;|M+8Q;#Rq zr_sYcC*+#C9}XwGCLeD+*qVAA&wHs_jCUefEygj}N)oR{}>wV2l_V6}m)r2bP0$MJq(&OTFU)uYc;uziA`4%QQ6 zo&naQZhxN>YT-W%tlyl^2)Wqj!wlkB=Hm169AXfm$DE$&wCYjod~nqA`61Wmv*TPM zYF!AnmL79@X3?rgt=ZtH)c}_3YoxuHh+1>N*3x56&qcKAQL7akwS4Z#_06SiCZg6n zu(kA<)6+z&9<|!RQOjqNT;D?4`9##}09#9sIXynp)T356IBNO4lI!cC_5CVpEe2al zk2yV^wCYjoQgGB-2A1nvPP>$dT9<*XrN^9}CA8{M>q>Ccx&kcMx03d9B5GX)ww9ii zgq{_&>QU<&aMW52mg~Eg_G%((T?e+79&>tD(W*zS8^BR(Em*E^9qsi*)VdLDEj{M+ ztf5tpTDO3s*3DqKzFTQ;BBIu9U~B0yr)NE_depiL9JTHQ%k|w&dj}DiRd(%ESLK*y|(pKLS_RzmZlR z{zt)H1F8QpxVrvLwDRyj4tAfX{wLt-`Zv?c!~cG;dpY%g0Isfo3#~l-9|XH!Q~!tH z>iVCgm52YsVE1I|{|H=N|5LQ`@IM1~-=+SK!qxRZO)C%o$G~fd)cCJX z*!`3GKM7aY{~WD6{GS55M^gW%;p+OIr(M>5B?dj#~9D&7r`$O>h|ACs}}yx zg56`mKL_@hqyOi@derUz60KVJzW{a*1^*)0a~S=<1lFT&|Cedi!vAHk=P>wJz^-BR z|0-CIy8U0FRSW;u!0Gs3hr36k|2M#T)P2tOq5US&izpN3qt>^;_6`1Ru=_V^y$05! z9<{y$j#}og;<|Q>?-GtYI=HKLR_3`KbS6u=Rrf z1e~7B*Wr59qyA69QU7&t)c+aSG0eN}uhObT?Vp3wee(-=y6(S(>rs!feg%%P%tx(X zgVS~Y4Ln_!-@^5%N3Gw1qn7!Y_wT`uV?OHt0i0eVe}t#!^-pj;>QVpC;HYmt>i-4o z80Mq?U%}Q3{x@*C?th2tQIGon07rfEQU9M{$1v}@zd@@Owci4(1^*Y=a})gEc|Pj> z2d+mww0FSjZxd_s{rX?9waq#1o3v_C*U6^i^eXV)aOV|cl;C>QLo0*T({XFy);8z3 z??Tn;^5M`~nVEd{&w!c@^qQ+j};@EqmsfTBu+>;)6Et-0aGaPI`b;t2{ zuv#6fzc1~6#9qvQByD=m_s?_o83C5ZemD@E?#=hX-HWj%2f_8I$MttGm~Z77U_NRc z0#0ik3Qza-D7YT=tMWZ_7+5{s*Q4Q%VQwJ5?%Z34gZX9$66W`#^?3ZPZ!YFA7VI$w zKLV_0AioT)b0nB=WqqunUyrr?JAwJ=do0-pg-=QkdhQeE zmxA@EN1tWjwxrLcaE~kcEC=gR_dHnVGO)GGMV%Gk6-k}T;T~Vqxxzf59(Aq+TgzP3 zSqWa1)VT`oaYmh0U_I(l=W4LE%(*T-v}&;q*MbiqV!vM}Ce-z>rd5kMtN~ji`1RoF z`J6odweVTAG5!s3J?hbC9r&Web0b`zdU)1@I}^`MaDD3Gxf#49@!SH}ryibL!D|xF zZE$_+;kg~WKJnZE*QXwyJLQSzF1SAR={!|D_Itp0C*j^yzZXrvdF%NMx)1Ey#=1WM zR=b~wdK7blVkHZ9s}#~6WZonQx5I%T4v=4-S+*kdrK Z-}5#t-vrDDGjLj4+bSgv?|CQFh$HUGeAm ztNh|QKF{Q>W1s1BDs}Iz>gwu#yC)rsw=GF&QMx~^NPnhNod_IKR7fxw5!n^ z-?ew&9y3;^j#i>MtJ0FxiPt7e)tZ4vu)El5^3+)X)Irsh{$^5VT9yWf#s-In2ZxT1 zm8Yi08qJFnm9dFxtui)VovxId)!B-9OS8OctvoR^o+osXk+0j8y3&2D(X7l@CPu55 zD1p+=K2kb4&_6m*uQi&bTC)M~CZg^r*K4hPh33A{o;BtgEAZ3hlGETFX#>98!ky_M z{LJy%)J)S@S8MO5D)R&N`t&%VR(>zugK_R|yoJOE@N)$oz%TUMk%qu!{+Kfit`+zs zxKZG@!E*(E7hGxK&h!Czvg!=yceeVREX^0ZE1gGYZ_VjagO1JVN_BkWTxoi$R%t|U z2In{xd$Olq@3*ecGzaPv^=V)_h8AbLO@R@bXREdv8>F^*-itwc z^(?nyj^Li1L+-uW(qaqTb%U>^igkLa*?|{t0CnSgu#L?39?FgNz?b6HG~>(g#=TQp z_-k)!n)%D|-Y@lo1@~^Kt%t9~_h5^#odsV7b~xWXn`=GSTZeZ&`!&8EZ!hYb$lQoG zZoPa2-^|Ph37F^nO+}KL{mDI3hw+E;&UFpra?e!FeqHM^%;A0>$M;AW#^u{F^R2f7 zZ!PCV{2B5`qw|4E=yJzh?&UtSe-`&Q2uX+AJ#&>{yWD%DSw{hQZ9`_CB+;^OF z-*V2&ZTx&2ueWjEaLjkVzT=$VE_gquL9TWIPIzil6< z?b#Z0p7(Dv=G~J!&v)uwJIz|weH1fCeVDJ~o$tZaHHWqRrq=gc^$#e4Q_q+ zvIGAFW>4EO=Xlqi#MG}8{Ha14VU9iS#JvCN*9z@vu(gdv@6Ui;-&pj%3#_g=^4@#l z#-sN=;JkPL(d4~92XF8Fc{KItbuZXn)uY#aU~3tRUiX7t&sg;O0$5#h+4|GGZwwR0an)>d9VI<7?1n< zCfGZk-`BU`>fV2UTjY^H0`A4~{1b3>^Zo6SNB$_-Z=2_zf~%W<5-*SZcfg)!o_`vy zZoa==^2mP=?4I)c_u=a1``aduJMtme9)o`bwuj}s#BTh@nCIpH9CM9F?ikqhgP#Fg zKjxOeG1uHPS?rT6<{IYOpTD)nqUJa_@2>*S`}-8`8vdS`Ydq$j1?T;pgXjHK;W5`- zs7uNj= z^Zdg?{~68m5A(a3{|n}K+lg7r`R1M|zR|B>a~z@l299~5{a$E`@guzBW%xfZ=bLZb PwcW#Ays>{-`2hAGj+gx9 literal 0 HcmV?d00001 diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index c04e1a93..a4bae057 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -149,21 +149,21 @@ void main() { vec3 norm = vertexNormal; if (enableNormalMap != 0 && lodFactor < 0.99 && normalMapStrength > 0.001) { vec3 mapNormal = texture(uNormalHeightMap, finalUV).rgb * 2.0 - 1.0; - // Scale XY by strength to control effect intensity - mapNormal.xy *= normalMapStrength; mapNormal = normalize(mapNormal); vec3 worldNormal = normalize(TBN * mapNormal); if (!gl_FrontFacing) worldNormal = -worldNormal; - // Blend: strength + LOD both contribute to fade toward vertex normal - float blendFactor = max(lodFactor, 1.0 - normalMapStrength); - norm = normalize(mix(worldNormal, vertexNormal, blendFactor)); + // Linear blend: strength controls how much normal map detail shows, + // LOD fades out at distance. Both multiply for smooth falloff. + float blend = clamp(normalMapStrength, 0.0, 1.0) * (1.0 - lodFactor); + norm = normalize(mix(vertexNormal, worldNormal, blend)); } vec3 result; - // Sample shadow map — skip for interior WMO groups (no sun indoors) + // Sample shadow map for all WMO groups (interior groups with 0x2000 flag + // include covered outdoor areas like archways/streets that should receive shadows) float shadow = 1.0; - if (shadowParams.x > 0.5 && isInterior == 0) { + if (shadowParams.x > 0.5) { vec3 ldir = normalize(-lightDir.xyz); float normalOffset = SHADOW_TEXEL * 2.0 * (1.0 - abs(dot(norm, ldir))); vec3 biasedPos = FragPos + norm * normalOffset; diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index 2453f0ff763aa7053f9ce3d164e1646cb97d2d03..524dbd1ea425698858608248b93668dbc5ee5e21 100644 GIT binary patch literal 12456 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 Wo&{T=^Hb((JY(qRyPY=Q`~MGgd{}q@ diff --git a/include/core/application.hpp b/include/core/application.hpp index 165d11bb..4d10acc7 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -236,6 +236,11 @@ private: std::optional pendingWorldEntry_; // Deferred world entry during loading float taxiLandingClampTimer_ = 0.0f; float worldEntryMovementGraceTimer_ = 0.0f; + + // Hearth teleport: freeze player until terrain loads at destination + bool hearthTeleportPending_ = false; + glm::vec3 hearthTeleportPos_{0.0f}; // render coords + float hearthTeleportTimer_ = 0.0f; // timeout safety float facingSendCooldown_ = 0.0f; // Rate-limits MSG_MOVE_SET_FACING float lastSentCanonicalYaw_ = 1000.0f; // Sentinel — triggers first send float taxiStreamCooldown_ = 0.0f; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 8a3ee441..3af2f59a 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -565,6 +565,8 @@ public: void unstuck(); void setUnstuckGyCallback(UnstuckCallback cb) { unstuckGyCallback_ = std::move(cb); } void unstuckGy(); + void setUnstuckHearthCallback(UnstuckCallback cb) { unstuckHearthCallback_ = std::move(cb); } + void unstuckHearth(); using BindPointCallback = std::function; void setBindPointCallback(BindPointCallback cb) { bindPointCallback_ = std::move(cb); } @@ -1445,6 +1447,7 @@ private: WorldEntryCallback worldEntryCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; + UnstuckCallback unstuckHearthCallback_; BindPointCallback bindPointCallback_; CreatureSpawnCallback creatureSpawnCallback_; CreatureDespawnCallback creatureDespawnCallback_; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index c4676008..7a01c0d7 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -66,6 +66,8 @@ public: void update(float deltaTime, const glm::vec3& cameraPos = glm::vec3(0.0f)); + /** Pre-allocate GPU resources (bone SSBOs, descriptors) on main thread before parallel render. */ + void prepareRender(uint32_t frameIndex); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); void recreatePipelines(); bool initializeShadow(VkRenderPass shadowRenderPass); diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 1c35e34b..4b26214f 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -122,6 +122,7 @@ struct M2ModelGPU { bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed) bool isLavaModel = false; // Model name contains lava/molten/magma (UV scroll fallback) bool hasTextureAnimation = false; // True if any batch has UV animation + uint8_t availableLODs = 0; // Bitmask: bit N set if any batch has submeshLevel==N // Particle emitter data (kept from M2Model) std::vector particleEmitters; @@ -193,6 +194,7 @@ struct M2Instance { // Frame-skip optimization (update distant animations less frequently) uint8_t frameSkipCounter = 0; + bool bonesDirty = false; // Set when bones recomputed, cleared after upload // Per-instance bone SSBO (double-buffered) ::VkBuffer boneBuffer[2] = {}; @@ -265,6 +267,8 @@ public: /** * Render all visible instances (Vulkan) */ + /** Pre-allocate GPU resources (bone SSBOs, descriptors) on main thread before parallel render. */ + void prepareRender(uint32_t frameIndex, const Camera& camera); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); /** diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index ab14021c..c7582eea 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -4,10 +4,12 @@ #include #include #include +#include #include #include #include #include "rendering/vk_frame_data.hpp" +#include "rendering/vk_utils.hpp" #include "rendering/sky_system.hpp" namespace wowee { @@ -259,6 +261,14 @@ public: float getShadowDistance() const { return shadowDistance_; } void setMsaaSamples(VkSampleCountFlagBits samples); + // FSR 1.0 (FidelityFX Super Resolution) upscaling + void setFSREnabled(bool enabled); + bool isFSREnabled() const { return fsr_.enabled; } + void setFSRQuality(float scaleFactor); // 0.50=Perf, 0.59=Balanced, 0.67=Quality, 0.77=UltraQuality + void setFSRSharpness(float sharpness); // 0.0 - 2.0 + float getFSRScaleFactor() const { return fsr_.scaleFactor; } + float getFSRSharpness() const { return fsr_.sharpness; } + void setWaterRefractionEnabled(bool enabled); bool isWaterRefractionEnabled() const; @@ -312,7 +322,7 @@ private: VmaAllocation selCircleIdxAlloc = VK_NULL_HANDLE; int selCircleVertCount = 0; void initSelectionCircle(); - void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection); + void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); glm::vec3 selCirclePos{0.0f}; glm::vec3 selCircleColor{1.0f, 0.0f, 0.0f}; float selCircleRadius = 1.5f; @@ -322,7 +332,36 @@ private: VkPipeline overlayPipeline = VK_NULL_HANDLE; VkPipelineLayout overlayPipelineLayout = VK_NULL_HANDLE; void initOverlayPipeline(); - void renderOverlay(const glm::vec4& color); + void renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); + + // FSR 1.0 upscaling state + struct FSRState { + bool enabled = false; + bool needsRecreate = false; + float scaleFactor = 0.77f; // Ultra Quality default + float sharpness = 0.5f; + uint32_t internalWidth = 0; + uint32_t internalHeight = 0; + + // Off-screen scene target (reduced resolution) + AllocatedImage sceneColor{}; // 1x color (non-MSAA render target / MSAA resolve target) + AllocatedImage sceneDepth{}; // Depth (matches current MSAA sample count) + AllocatedImage sceneMsaaColor{}; // MSAA color target (only when MSAA > 1x) + AllocatedImage sceneDepthResolve{}; // Depth resolve (only when MSAA + depth resolve) + VkFramebuffer sceneFramebuffer = VK_NULL_HANDLE; + VkSampler sceneSampler = VK_NULL_HANDLE; + + // Upscale pipeline + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + VkDescriptorSetLayout descSetLayout = VK_NULL_HANDLE; + VkDescriptorPool descPool = VK_NULL_HANDLE; + VkDescriptorSet descSet = VK_NULL_HANDLE; + }; + FSRState fsr_; + bool initFSRResources(); + void destroyFSRResources(); + void renderFSRUpscale(); // Footstep event tracking (animation-driven) uint32_t footstepLastAnimationId = 0; @@ -411,6 +450,36 @@ private: void setupWater1xPass(); void renderReflectionPass(); + // ── Multithreaded secondary command buffer recording ── + // Indices into secondaryCmds_ arrays + static constexpr uint32_t SEC_SKY = 0; // sky (main thread) + static constexpr uint32_t SEC_TERRAIN = 1; // terrain (worker 0) + static constexpr uint32_t SEC_WMO = 2; // WMO (worker 1) + static constexpr uint32_t SEC_CHARS = 3; // selection circle + characters (main thread) + static constexpr uint32_t SEC_M2 = 4; // M2 + particles + glow (worker 2) + static constexpr uint32_t SEC_POST = 5; // water + weather + effects (main thread) + static constexpr uint32_t SEC_IMGUI = 6; // ImGui (main thread, non-FSR only) + static constexpr uint32_t NUM_SECONDARIES = 7; + static constexpr uint32_t NUM_WORKERS = 3; // terrain, WMO, M2 + + // Per-worker command pools (thread-safe: one pool per thread) + VkCommandPool workerCmdPools_[NUM_WORKERS] = {}; + // Main-thread command pool for its secondary buffers + VkCommandPool mainSecondaryCmdPool_ = VK_NULL_HANDLE; + // Pre-allocated secondary command buffers [secondaryIndex][frameInFlight] + VkCommandBuffer secondaryCmds_[NUM_SECONDARIES][MAX_FRAMES] = {}; + + bool parallelRecordingEnabled_ = false; // set true after pools/buffers created + bool createSecondaryCommandResources(); + void destroySecondaryCommandResources(); + VkCommandBuffer beginSecondary(uint32_t secondaryIndex); + void setSecondaryViewportScissor(VkCommandBuffer cmd); + + // Cached render pass state for secondary buffer inheritance + VkRenderPass activeRenderPass_ = VK_NULL_HANDLE; + VkFramebuffer activeFramebuffer_ = VK_NULL_HANDLE; + VkExtent2D activeRenderExtent_ = {0, 0}; + // Active character previews for off-screen rendering std::vector activePreviews_; diff --git a/include/rendering/vk_context.hpp b/include/rendering/vk_context.hpp index 907e21bf..154a4f98 100644 --- a/include/rendering/vk_context.hpp +++ b/include/rendering/vk_context.hpp @@ -84,6 +84,10 @@ public: bool isSwapchainDirty() const { return swapchainDirty; } void markSwapchainDirty() { swapchainDirty = true; } + // VSync (present mode) + bool isVsyncEnabled() const { return vsync_; } + void setVsync(bool enabled) { vsync_ = enabled; } + bool isDeviceLost() const { return deviceLost_; } // MSAA @@ -145,6 +149,7 @@ private: std::vector swapchainFramebuffers; bool swapchainDirty = false; bool deviceLost_ = false; + bool vsync_ = true; // Per-frame resources FrameData frames[MAX_FRAMES_IN_FLIGHT]; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index f0d3b36f..b8be9485 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -148,6 +148,8 @@ public: * @param perFrameSet Per-frame descriptor set (set 0) * @param camera Camera for frustum culling */ + /** Pre-update mutable state (frame ID, material UBOs) on main thread before parallel render. */ + void prepareRender(); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); /** @@ -332,6 +334,9 @@ public: // Defer normal/height map generation during streaming to avoid CPU stalls void setDeferNormalMaps(bool defer) { deferNormalMaps_ = defer; } + // Generate normal/height maps for cached textures that were loaded while deferred + void backfillNormalMaps(); + private: // WMO material UBO — matches WMOMaterial in wmo.frag.glsl struct WMOMaterialUBO { @@ -720,6 +725,8 @@ private: uint32_t distanceCulled = 0; }; std::vector> cullFutures_; + std::vector visibleInstances_; // reused per frame + std::vector drawLists_; // reused per frame // Collision query profiling (per frame). mutable double queryTimeMs = 0.0; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 7e428523..bf7558cd 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -116,6 +116,10 @@ private: float pendingNormalMapStrength = 0.8f; // 0.0-2.0 bool pendingPOM = true; // on by default int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64) + bool pendingFSR = false; + int pendingFSRQuality = 0; // 0=UltraQuality, 1=Quality, 2=Balanced, 3=Performance + float pendingFSRSharpness = 0.5f; + bool fsrSettingsApplied_ = false; // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) float uiOpacity_ = 0.65f; diff --git a/src/core/application.cpp b/src/core/application.cpp index c1907a15..f9ac557c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1015,14 +1015,33 @@ void Application::update(float deltaTime) { if (renderer && renderer->getCameraController()) renderer->getCameraController()->clearMovementInputs(); } + // Hearth teleport: keep player frozen until terrain loads at destination + if (hearthTeleportPending_ && renderer && renderer->getTerrainManager()) { + hearthTeleportTimer_ -= deltaTime; + auto terrainH = renderer->getTerrainManager()->getHeightAt( + hearthTeleportPos_.x, hearthTeleportPos_.y); + if (terrainH || hearthTeleportTimer_ <= 0.0f) { + // Terrain loaded (or timeout) — snap to floor and release + if (terrainH) { + hearthTeleportPos_.z = *terrainH + 0.5f; + renderer->getCameraController()->teleportTo(hearthTeleportPos_); + } + renderer->getCameraController()->setExternalFollow(false); + worldEntryMovementGraceTimer_ = 1.0f; + hearthTeleportPending_ = false; + LOG_INFO("Unstuck hearth: terrain loaded, player released", + terrainH ? "" : " (timeout)"); + } + } if (renderer && renderer->getCameraController()) { const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_; // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. + bool hearthFreeze = hearthTeleportPending_; bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f && worldEntryMovementGraceTimer_ <= 0.0f && !gameHandler->isMounted(); - renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive); + renderer->getCameraController()->setExternalFollow(externallyDrivenMotion || landingClampActive || hearthFreeze); renderer->getCameraController()->setExternalMoving(externallyDrivenMotion); if (externallyDrivenMotion) { // Drop any stale local movement toggles while server drives taxi motion. @@ -1877,9 +1896,43 @@ void Application::setupUICallbacks() { LOG_INFO("Unstuck: high fallback snap"); }); + // /unstuckhearth — teleport to hearthstone bind point (server-synced). + // Freezes player until terrain loads at destination to prevent falling through world. + gameHandler->setUnstuckHearthCallback([this, clearStuckMovement, forceServerTeleportCommand]() { + if (!renderer || !renderer->getCameraController() || !gameHandler) return; + + uint32_t bindMap = 0; + glm::vec3 bindPos(0.0f); + if (!gameHandler->getHomeBind(bindMap, bindPos)) { + LOG_WARNING("Unstuck hearth: no bind point available"); + return; + } + + worldEntryMovementGraceTimer_ = 10.0f; // long grace — terrain load check will clear it + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + clearStuckMovement(); + + auto* cc = renderer->getCameraController(); + glm::vec3 renderPos = core::coords::canonicalToRender(bindPos); + renderPos.z += 2.0f; + + // Freeze player in place (no gravity/movement) until terrain loads + cc->teleportTo(renderPos); + cc->setExternalFollow(true); + forceServerTeleportCommand(renderPos); + clearStuckMovement(); + + // Set pending state — update loop will unfreeze once terrain is loaded + hearthTeleportPending_ = true; + hearthTeleportPos_ = renderPos; + hearthTeleportTimer_ = 15.0f; // 15s safety timeout + LOG_INFO("Unstuck hearth: teleporting to bind point, waiting for terrain..."); + }); + // Auto-unstuck: falling for > 5 seconds = void fall, teleport to map entry if (renderer->getCameraController()) { - renderer->getCameraController()->setAutoUnstuckCallback([this]() { + renderer->getCameraController()->setAutoUnstuckCallback([this, forceServerTeleportCommand]() { if (!renderer || !renderer->getCameraController()) return; auto* cc = renderer->getCameraController(); @@ -1887,7 +1940,8 @@ void Application::setupUICallbacks() { glm::vec3 spawnPos = cc->getDefaultPosition(); spawnPos.z += 5.0f; cc->teleportTo(spawnPos); - LOG_INFO("Auto-unstuck: teleported to map entry point"); + forceServerTeleportCommand(spawnPos); + LOG_INFO("Auto-unstuck: teleported to map entry point (server synced)"); }); } diff --git a/src/core/window.cpp b/src/core/window.cpp index eed83c97..9f74a81c 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -84,6 +84,7 @@ bool Window::initialize() { // Initialize Vulkan context vkContext = std::make_unique(); + vkContext->setVsync(vsync); if (!vkContext->initialize(window)) { LOG_ERROR("Failed to initialize Vulkan context"); return false; @@ -158,11 +159,13 @@ void Window::setFullscreen(bool enable) { } } -void Window::setVsync([[maybe_unused]] bool enable) { - // VSync in Vulkan is controlled by present mode (set at swapchain creation) - // For now, store the preference — applied on next swapchain recreation +void Window::setVsync(bool enable) { vsync = enable; - LOG_INFO("VSync preference set to ", enable ? "on" : "off", " (applied on swapchain recreation)"); + if (vkContext) { + vkContext->setVsync(enable); + vkContext->markSwapchainDirty(); + } + LOG_INFO("VSync ", enable ? "enabled" : "disabled"); } void Window::applyResolution(int w, int h) { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9a7aed97..3cd05d3c 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -11435,6 +11435,15 @@ void GameHandler::unstuckGy() { } } +void GameHandler::unstuckHearth() { + if (unstuckHearthCallback_) { + unstuckHearthCallback_(); + addSystemChatMessage("Unstuck: teleported to hearthstone location."); + } else { + addSystemChatMessage("No hearthstone bind point set."); + } +} + void GameHandler::handleLootResponse(network::Packet& packet) { if (!LootResponseParser::parse(packet, currentLoot)) return; lootWindowOpen = true; diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 9607f755..f69ae75c 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1924,6 +1924,61 @@ glm::mat4 CharacterRenderer::getBoneTransform(const pipeline::M2Bone& bone, floa // --- Rendering --- +void CharacterRenderer::prepareRender(uint32_t frameIndex) { + if (instances.empty() || !opaquePipeline_) return; + + // Pre-allocate bone SSBOs + descriptor sets on main thread (pool ops not thread-safe) + for (auto& [id, instance] : instances) { + int numBones = std::min(static_cast(instance.boneMatrices.size()), MAX_BONES); + if (numBones <= 0) continue; + + if (!instance.boneBuffer[frameIndex]) { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = MAX_BONES * sizeof(glm::mat4); + bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, + &instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo); + instance.boneMapped[frameIndex] = allocInfo.pMappedData; + + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = boneDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &boneSetLayout_; + VkResult dsRes = vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &instance.boneSet[frameIndex]); + if (dsRes != VK_SUCCESS) { + LOG_ERROR("CharacterRenderer::prepareRender: bone descriptor alloc failed (instance=", + id, ", frame=", frameIndex, ", vk=", static_cast(dsRes), ")"); + if (instance.boneBuffer[frameIndex]) { + vmaDestroyBuffer(vkCtx_->getAllocator(), + instance.boneBuffer[frameIndex], instance.boneAlloc[frameIndex]); + instance.boneBuffer[frameIndex] = VK_NULL_HANDLE; + instance.boneAlloc[frameIndex] = VK_NULL_HANDLE; + instance.boneMapped[frameIndex] = nullptr; + } + continue; + } + + if (instance.boneSet[frameIndex]) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = instance.boneBuffer[frameIndex]; + bufInfo.offset = 0; + bufInfo.range = bci.size; + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = instance.boneSet[frameIndex]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + } +} + void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, [[maybe_unused]] const Camera& camera) { if (instances.empty() || !opaquePipeline_) { return; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index d455e494..3a097217 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1602,6 +1602,12 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Pre-compute available LOD levels to avoid per-instance batch iteration + gpuModel.availableLODs = 0; + for (const auto& b : gpuModel.batches) { + if (b.submeshLevel < 8) gpuModel.availableLODs |= (1u << b.submeshLevel); + } + models[modelId] = std::move(gpuModel); LOG_DEBUG("Loaded M2 model: ", model.name, " (", models[modelId].vertexCount, " vertices, ", @@ -1911,6 +1917,7 @@ static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { instance.boneMatrices[i] = local; } } + instance.bonesDirty = true; } void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { @@ -2172,6 +2179,48 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: } +void M2Renderer::prepareRender(uint32_t frameIndex, const Camera& camera) { + if (!initialized_ || instances.empty()) return; + (void)camera; // reserved for future frustum-based culling + + // Pre-allocate bone SSBOs + descriptor sets on main thread (pool ops not thread-safe). + // Only iterate animated instances — static doodads don't need bone buffers. + for (size_t idx : animatedInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& instance = instances[idx]; + + if (instance.boneMatrices.empty()) continue; + + if (!instance.boneBuffer[frameIndex]) { + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = 128 * sizeof(glm::mat4); + bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, + &instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo); + instance.boneMapped[frameIndex] = allocInfo.pMappedData; + + instance.boneSet[frameIndex] = allocateBoneSet(); + if (instance.boneSet[frameIndex]) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = instance.boneBuffer[frameIndex]; + bufInfo.offset = 0; + bufInfo.range = bci.size; + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = instance.boneSet[frameIndex]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + } +} + void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { if (instances.empty() || !opaquePipeline_) { return; @@ -2254,8 +2303,8 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } // Sort by modelId to minimize vertex/index buffer rebinds - std::stable_sort(sortedVisible_.begin(), sortedVisible_.end(), - [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); + std::sort(sortedVisible_.begin(), sortedVisible_.end(), + [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); uint32_t currentModelId = UINT32_MAX; const M2ModelGPU* currentModel = nullptr; @@ -2330,44 +2379,22 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } - // Upload bone matrices to SSBO if model has skeletal animation - bool useBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); + // Upload bone matrices to SSBO if model has skeletal animation. + // Bone buffers are pre-allocated by prepareRender() on the main thread. + // If not yet allocated (race/timing), skip this instance entirely to avoid + // a bind-pose flash — it will render correctly next frame. + bool needsBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); + if (needsBones && (!instance.boneBuffer[frameIndex] || !instance.boneSet[frameIndex])) { + continue; + } + bool useBones = needsBones; if (useBones) { - // Lazy-allocate bone SSBO on first use - if (!instance.boneBuffer[frameIndex]) { - VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; - bci.size = 128 * sizeof(glm::mat4); // max 128 bones - bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; - VmaAllocationCreateInfo aci{}; - aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; - aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; - VmaAllocationInfo allocInfo{}; - vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, - &instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo); - instance.boneMapped[frameIndex] = allocInfo.pMappedData; - - // Allocate descriptor set for bone SSBO - instance.boneSet[frameIndex] = allocateBoneSet(); - if (instance.boneSet[frameIndex]) { - VkDescriptorBufferInfo bufInfo{}; - bufInfo.buffer = instance.boneBuffer[frameIndex]; - bufInfo.offset = 0; - bufInfo.range = bci.size; - VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; - write.dstSet = instance.boneSet[frameIndex]; - write.dstBinding = 0; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; - write.pBufferInfo = &bufInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); - } - } - - // Upload bone matrices - if (instance.boneMapped[frameIndex]) { + // Upload bone matrices only when recomputed (skip frame-skipped instances) + if (instance.bonesDirty && instance.boneMapped[frameIndex]) { int numBones = std::min(static_cast(instance.boneMatrices.size()), 128); memcpy(instance.boneMapped[frameIndex], instance.boneMatrices.data(), numBones * sizeof(glm::mat4)); + instance.bonesDirty = false; } // Bind bone descriptor set (set 2) @@ -2384,12 +2411,8 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1; uint16_t targetLOD = desiredLOD; - if (desiredLOD > 0) { - bool hasDesiredLOD = false; - for (const auto& b : model.batches) { - if (b.submeshLevel == desiredLOD) { hasDesiredLOD = true; break; } - } - if (!hasDesiredLOD) targetLOD = 0; + if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) { + targetLOD = 0; } const bool foliageLikeModel = model.isFoliageLike; diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index d939f4f9..86dc2f21 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -1,5 +1,6 @@ #include "rendering/performance_hud.hpp" #include "rendering/renderer.hpp" +#include "rendering/vk_context.hpp" #include "rendering/terrain_renderer.hpp" #include "rendering/terrain_manager.hpp" #include "rendering/water_renderer.hpp" @@ -187,6 +188,19 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { 0, nullptr, 0.0f, 33.33f, ImVec2(200, 40)); } + // FSR info + if (renderer->isFSREnabled()) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "FSR 1.0: ON"); + auto* ctx = renderer->getVkContext(); + if (ctx) { + auto ext = ctx->getSwapchainExtent(); + float sf = renderer->getFSRScaleFactor(); + uint32_t iw = static_cast(ext.width * sf) & ~1u; + uint32_t ih = static_cast(ext.height * sf) & ~1u; + ImGui::Text(" %ux%u -> %ux%u (%.0f%%)", iw, ih, ext.width, ext.height, sf * 100.0f); + } + } + ImGui::Spacing(); } diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index d487e05e..9f3d65e7 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -721,11 +721,18 @@ bool Renderer::initialize(core::Window* win) { // TODO Phase 6: Vulkan underwater overlay, post-process, and shadow map // GL versions stubbed during migration + // Create secondary command buffer resources for multithreaded rendering + if (!createSecondaryCommandResources()) { + LOG_WARNING("Failed to create secondary command buffers — falling back to single-threaded rendering"); + } + LOG_INFO("Renderer initialized"); return true; } void Renderer::shutdown() { + destroySecondaryCommandResources(); + LOG_WARNING("Renderer::shutdown - terrainManager stopWorkers..."); if (terrainManager) { terrainManager->stopWorkers(); @@ -828,6 +835,7 @@ void Renderer::shutdown() { if (overlayPipelineLayout) { vkDestroyPipelineLayout(device, overlayPipelineLayout, nullptr); overlayPipelineLayout = VK_NULL_HANDLE; } } + destroyFSRResources(); destroyPerFrameResources(); zoneManager.reset(); @@ -901,12 +909,7 @@ void Renderer::applyMsaaChange() { if (terrainRenderer) terrainRenderer->recreatePipelines(); if (waterRenderer) { waterRenderer->recreatePipelines(); - if (vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { - waterRenderer->destroyWater1xResources(); - setupWater1xPass(); - } else { - waterRenderer->destroyWater1xResources(); - } + waterRenderer->destroyWater1xResources(); // no longer used } if (wmoRenderer) wmoRenderer->recreatePipelines(); if (m2Renderer) m2Renderer->recreatePipelines(); @@ -928,10 +931,11 @@ void Renderer::applyMsaaChange() { if (minimap) minimap->recreatePipelines(); - // Selection circle + overlay use lazy init, just destroy them + // Selection circle + overlay + FSR use lazy init, just destroy them VkDevice device = vkCtx->getDevice(); if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } + if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() // Reinitialize ImGui Vulkan backend with new MSAA sample count ImGui_ImplVulkan_Shutdown(); @@ -961,17 +965,30 @@ void Renderer::beginFrame() { applyMsaaChange(); } + // FSR resource management (safe: between frames, no command buffer in flight) + if (fsr_.needsRecreate && fsr_.sceneFramebuffer) { + destroyFSRResources(); + fsr_.needsRecreate = false; + if (!fsr_.enabled) LOG_INFO("FSR: disabled"); + } + if (fsr_.enabled && !fsr_.sceneFramebuffer) { + if (!initFSRResources()) { + LOG_ERROR("FSR: initialization failed, disabling"); + fsr_.enabled = false; + } + } + // Handle swapchain recreation if needed if (vkCtx->isSwapchainDirty()) { vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); // Rebuild water resources that reference swapchain extent/views if (waterRenderer) { waterRenderer->recreatePipelines(); - if (waterRenderer->hasWater1xPass() - && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { - waterRenderer->destroyWater1xResources(); - setupWater1xPass(); - } + } + // Recreate FSR resources for new swapchain dimensions + if (fsr_.enabled) { + destroyFSRResources(); + initFSRResources(); } } @@ -1018,47 +1035,131 @@ void Renderer::beginFrame() { renderReflectionPass(); } // !skipPrePasses - // --- Begin main render pass (clear color + depth) --- + // --- Begin render pass --- + // If FSR is enabled, render scene to off-screen target at reduced resolution. + // Otherwise, render directly to swapchain. VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; rpInfo.renderPass = vkCtx->getImGuiRenderPass(); - rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; - rpInfo.renderArea.offset = {0, 0}; - rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); - // MSAA render pass has 3 attachments (color, depth, resolve), non-MSAA has 2 - VkClearValue clearValues[3]{}; + VkExtent2D renderExtent; + if (fsr_.enabled && fsr_.sceneFramebuffer) { + rpInfo.framebuffer = fsr_.sceneFramebuffer; + renderExtent = { fsr_.internalWidth, fsr_.internalHeight }; + } else { + rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + renderExtent = vkCtx->getSwapchainExtent(); + } + + rpInfo.renderArea.offset = {0, 0}; + rpInfo.renderArea.extent = renderExtent; + + // Clear values must match attachment count: 2 (no MSAA), 3 (MSAA), or 4 (MSAA+depth resolve) + VkClearValue clearValues[4]{}; clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; clearValues[1].depthStencil = {1.0f, 0}; - clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; // resolve (DONT_CARE, but count must match) + clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + clearValues[3].depthStencil = {1.0f, 0}; bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); - rpInfo.clearValueCount = msaaOn ? 3 : 2; + if (msaaOn) { + bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + rpInfo.clearValueCount = depthRes ? 4 : 3; + } else { + rpInfo.clearValueCount = 2; + } rpInfo.pClearValues = clearValues; - vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + // Cache render pass state for secondary command buffer inheritance + activeRenderPass_ = rpInfo.renderPass; + activeFramebuffer_ = rpInfo.framebuffer; + activeRenderExtent_ = renderExtent; - // Set dynamic viewport and scissor - VkExtent2D extent = vkCtx->getSwapchainExtent(); - VkViewport viewport{}; - viewport.x = 0.0f; - viewport.y = 0.0f; - viewport.width = static_cast(extent.width); - viewport.height = static_cast(extent.height); - viewport.minDepth = 0.0f; - viewport.maxDepth = 1.0f; - vkCmdSetViewport(currentCmd, 0, 1, &viewport); + VkSubpassContents subpassMode = parallelRecordingEnabled_ + ? VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS + : VK_SUBPASS_CONTENTS_INLINE; + vkCmdBeginRenderPass(currentCmd, &rpInfo, subpassMode); - VkRect2D scissor{}; - scissor.offset = {0, 0}; - scissor.extent = extent; - vkCmdSetScissor(currentCmd, 0, 1, &scissor); + if (!parallelRecordingEnabled_) { + // Fallback: set dynamic viewport and scissor on primary (inline mode) + VkViewport viewport{}; + viewport.width = static_cast(renderExtent.width); + viewport.height = static_cast(renderExtent.height); + viewport.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &viewport); + + VkRect2D scissor{}; + scissor.extent = renderExtent; + vkCmdSetScissor(currentCmd, 0, 1, &scissor); + } } void Renderer::endFrame() { if (!vkCtx || currentCmd == VK_NULL_HANDLE) return; - // ImGui always renders in the main pass (its pipeline matches the main render pass) - ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), currentCmd); + if (fsr_.enabled && fsr_.sceneFramebuffer) { + // End the off-screen scene render pass + vkCmdEndRenderPass(currentCmd); + + // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY + // The render pass finalLayout puts the resolve/color attachment in PRESENT_SRC_KHR + transitionImageLayout(currentCmd, fsr_.sceneColor.image, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + + // Begin swapchain render pass at full resolution + VkRenderPassBeginInfo rpInfo{}; + rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + rpInfo.renderPass = vkCtx->getImGuiRenderPass(); + rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + rpInfo.renderArea.offset = {0, 0}; + rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + + // Clear values must match the render pass attachment count + bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); + VkClearValue clearValues[4]{}; + clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + clearValues[1].depthStencil = {1.0f, 0}; + clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + clearValues[3].depthStencil = {1.0f, 0}; + if (msaaOn) { + bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + rpInfo.clearValueCount = depthRes ? 4 : 3; + } else { + rpInfo.clearValueCount = 2; + } + rpInfo.pClearValues = clearValues; + + vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); + + // Set full-resolution viewport and scissor + VkExtent2D ext = vkCtx->getSwapchainExtent(); + VkViewport vp{}; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &vp); + VkRect2D sc{}; + sc.extent = ext; + vkCmdSetScissor(currentCmd, 0, 1, &sc); + + // Draw FSR upscale fullscreen quad + renderFSRUpscale(); + } + + // ImGui rendering — must respect subpass contents mode + if (!fsr_.enabled && parallelRecordingEnabled_) { + // Scene pass was begun with VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS, + // so ImGui must be recorded into a secondary command buffer. + VkCommandBuffer imguiCmd = beginSecondary(SEC_IMGUI); + setSecondaryViewportScissor(imguiCmd); + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), imguiCmd); + vkEndCommandBuffer(imguiCmd); + vkCmdExecuteCommands(currentCmd, 1, &imguiCmd); + } else { + // FSR swapchain pass uses INLINE mode; non-parallel also uses INLINE. + ImGui_ImplVulkan_RenderDrawData(ImGui::GetDrawData(), currentCmd); + } vkCmdEndRenderPass(currentCmd); @@ -1076,16 +1177,7 @@ void Renderer::endFrame() { frame); } - // Render water in separate 1x pass after MSAA resolve + scene capture - bool waterDeferred = waterRenderer && waterRenderer->hasSurfaces() && waterRenderer->hasWater1xPass() - && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT; - if (waterDeferred && camera) { - VkExtent2D ext = vkCtx->getSwapchainExtent(); - if (waterRenderer->beginWater1xPass(currentCmd, currentImageIndex, ext)) { - waterRenderer->render(currentCmd, perFrameDescSets[frame], *camera, globalTime, true, frame); - waterRenderer->endWater1xPass(currentCmd); - } - } + // Water now renders in the main pass (renderWorld), no separate 1x pass needed. // Submit and present vkCtx->endFrame(currentCmd, currentImageIndex); @@ -3097,10 +3189,11 @@ void Renderer::clearSelectionCircle() { selCircleVisible = false; } -void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection) { +void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, VkCommandBuffer overrideCmd) { if (!selCircleVisible) return; initSelectionCircle(); - if (selCirclePipeline == VK_NULL_HANDLE || currentCmd == VK_NULL_HANDLE) return; + VkCommandBuffer cmd = (overrideCmd != VK_NULL_HANDLE) ? overrideCmd : currentCmd; + if (selCirclePipeline == VK_NULL_HANDLE || cmd == VK_NULL_HANDLE) return; // Keep circle anchored near target foot Z. Accept nearby floor probes only, // so distant upper/lower WMO planes don't yank the ring away from feet. @@ -3132,19 +3225,19 @@ void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& pro glm::mat4 mvp = projection * view * model; glm::vec4 color4(selCircleColor, 1.0f); - vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, selCirclePipeline); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, selCirclePipeline); VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(currentCmd, 0, 1, &selCircleVertBuf, &offset); - vkCmdBindIndexBuffer(currentCmd, selCircleIdxBuf, 0, VK_INDEX_TYPE_UINT16); + vkCmdBindVertexBuffers(cmd, 0, 1, &selCircleVertBuf, &offset); + vkCmdBindIndexBuffer(cmd, selCircleIdxBuf, 0, VK_INDEX_TYPE_UINT16); // Push mvp (64 bytes) at offset 0 - vkCmdPushConstants(currentCmd, selCirclePipelineLayout, + vkCmdPushConstants(cmd, selCirclePipelineLayout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, 64, &mvp[0][0]); // Push color (16 bytes) at offset 64 - vkCmdPushConstants(currentCmd, selCirclePipelineLayout, + vkCmdPushConstants(cmd, selCirclePipelineLayout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 64, 16, &color4[0]); - vkCmdDrawIndexed(currentCmd, static_cast(selCircleVertCount), 1, 0, 0, 0); + vkCmdDrawIndexed(cmd, static_cast(selCircleVertCount), 1, 0, 0, 0); } // ────────────────────────────────────────────────────────────── @@ -3194,15 +3287,305 @@ void Renderer::initOverlayPipeline() { if (overlayPipeline) LOG_INFO("Renderer: overlay pipeline initialized"); } -void Renderer::renderOverlay(const glm::vec4& color) { +void Renderer::renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd) { if (!overlayPipeline) initOverlayPipeline(); - if (!overlayPipeline || currentCmd == VK_NULL_HANDLE) return; - vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline); - vkCmdPushConstants(currentCmd, overlayPipelineLayout, + VkCommandBuffer cmd = (overrideCmd != VK_NULL_HANDLE) ? overrideCmd : currentCmd; + if (!overlayPipeline || cmd == VK_NULL_HANDLE) return; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline); + vkCmdPushConstants(cmd, overlayPipelineLayout, VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &color[0]); - vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle + vkCmdDraw(cmd, 3, 1, 0, 0); // fullscreen triangle } +// ========================= FSR 1.0 Upscaling ========================= + +bool Renderer::initFSRResources() { + if (!vkCtx) return false; + + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + VkExtent2D swapExtent = vkCtx->getSwapchainExtent(); + VkSampleCountFlagBits msaa = vkCtx->getMsaaSamples(); + bool useMsaa = (msaa > VK_SAMPLE_COUNT_1_BIT); + bool useDepthResolve = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + + fsr_.internalWidth = static_cast(swapExtent.width * fsr_.scaleFactor); + fsr_.internalHeight = static_cast(swapExtent.height * fsr_.scaleFactor); + fsr_.internalWidth = (fsr_.internalWidth + 1) & ~1u; + fsr_.internalHeight = (fsr_.internalHeight + 1) & ~1u; + + LOG_INFO("FSR: initializing at ", fsr_.internalWidth, "x", fsr_.internalHeight, + " -> ", swapExtent.width, "x", swapExtent.height, + " (scale=", fsr_.scaleFactor, ", MSAA=", static_cast(msaa), "x)"); + + VkFormat colorFmt = vkCtx->getSwapchainFormat(); + VkFormat depthFmt = vkCtx->getDepthFormat(); + + // sceneColor: always 1x, always sampled — this is what FSR reads + // Non-MSAA: direct render target. MSAA: resolve target. + fsr_.sceneColor = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + if (!fsr_.sceneColor.image) { + LOG_ERROR("FSR: failed to create scene color image"); + return false; + } + + // sceneDepth: matches current MSAA sample count + fsr_.sceneDepth = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, msaa); + if (!fsr_.sceneDepth.image) { + LOG_ERROR("FSR: failed to create scene depth image"); + destroyFSRResources(); + return false; + } + + if (useMsaa) { + // sceneMsaaColor: multisampled color target + fsr_.sceneMsaaColor = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, msaa); + if (!fsr_.sceneMsaaColor.image) { + LOG_ERROR("FSR: failed to create MSAA color image"); + destroyFSRResources(); + return false; + } + + if (useDepthResolve) { + fsr_.sceneDepthResolve = createImage(device, alloc, fsr_.internalWidth, fsr_.internalHeight, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT); + if (!fsr_.sceneDepthResolve.image) { + LOG_ERROR("FSR: failed to create depth resolve image"); + destroyFSRResources(); + return false; + } + } + } + + // Build framebuffer matching the main render pass attachment layout: + // Non-MSAA: [color, depth] + // MSAA (no depth res): [msaaColor, depth, resolve] + // MSAA (depth res): [msaaColor, depth, resolve, depthResolve] + VkImageView fbAttachments[4]{}; + uint32_t fbCount; + if (useMsaa) { + fbAttachments[0] = fsr_.sceneMsaaColor.imageView; + fbAttachments[1] = fsr_.sceneDepth.imageView; + fbAttachments[2] = fsr_.sceneColor.imageView; // resolve target + fbCount = 3; + if (useDepthResolve) { + fbAttachments[3] = fsr_.sceneDepthResolve.imageView; + fbCount = 4; + } + } else { + fbAttachments[0] = fsr_.sceneColor.imageView; + fbAttachments[1] = fsr_.sceneDepth.imageView; + fbCount = 2; + } + + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = vkCtx->getImGuiRenderPass(); + fbInfo.attachmentCount = fbCount; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = fsr_.internalWidth; + fbInfo.height = fsr_.internalHeight; + fbInfo.layers = 1; + + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &fsr_.sceneFramebuffer) != VK_SUCCESS) { + LOG_ERROR("FSR: failed to create scene framebuffer"); + destroyFSRResources(); + return false; + } + + // Sampler for the resolved scene color + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = VK_FILTER_LINEAR; + samplerInfo.magFilter = VK_FILTER_LINEAR; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + if (vkCreateSampler(device, &samplerInfo, nullptr, &fsr_.sceneSampler) != VK_SUCCESS) { + LOG_ERROR("FSR: failed to create sampler"); + destroyFSRResources(); + return false; + } + + // Descriptor set layout: binding 0 = combined image sampler + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = 1; + layoutInfo.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &fsr_.descSetLayout); + + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = 1; + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = 1; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + vkCreateDescriptorPool(device, &poolInfo, nullptr, &fsr_.descPool); + + VkDescriptorSetAllocateInfo dsAllocInfo{}; + dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + dsAllocInfo.descriptorPool = fsr_.descPool; + dsAllocInfo.descriptorSetCount = 1; + dsAllocInfo.pSetLayouts = &fsr_.descSetLayout; + vkAllocateDescriptorSets(device, &dsAllocInfo, &fsr_.descSet); + + // Always bind the 1x sceneColor (FSR reads the resolved image) + VkDescriptorImageInfo imgInfo{}; + imgInfo.sampler = fsr_.sceneSampler; + imgInfo.imageView = fsr_.sceneColor.imageView; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fsr_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + + // Pipeline layout + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pc.offset = 0; + pc.size = 64; + VkPipelineLayoutCreateInfo plCI{}; + plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plCI.setLayoutCount = 1; + plCI.pSetLayouts = &fsr_.descSetLayout; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + vkCreatePipelineLayout(device, &plCI, nullptr, &fsr_.pipelineLayout); + + // Load shaders + VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || + !fragMod.loadFromFile(device, "assets/shaders/fsr_easu.frag.spv")) { + LOG_ERROR("FSR: failed to load shaders"); + destroyFSRResources(); + return false; + } + + // FSR upscale pipeline renders into the swapchain pass at full resolution + // Must match swapchain pass MSAA setting + fsr_.pipeline = PipelineBuilder() + .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(msaa) + .setLayout(fsr_.pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertMod.destroy(); + fragMod.destroy(); + + if (!fsr_.pipeline) { + LOG_ERROR("FSR: failed to create upscale pipeline"); + destroyFSRResources(); + return false; + } + + LOG_INFO("FSR: initialized successfully"); + return true; +} + +void Renderer::destroyFSRResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + + vkDeviceWaitIdle(device); + + if (fsr_.pipeline) { vkDestroyPipeline(device, fsr_.pipeline, nullptr); fsr_.pipeline = VK_NULL_HANDLE; } + if (fsr_.pipelineLayout) { vkDestroyPipelineLayout(device, fsr_.pipelineLayout, nullptr); fsr_.pipelineLayout = VK_NULL_HANDLE; } + if (fsr_.descPool) { vkDestroyDescriptorPool(device, fsr_.descPool, nullptr); fsr_.descPool = VK_NULL_HANDLE; fsr_.descSet = VK_NULL_HANDLE; } + if (fsr_.descSetLayout) { vkDestroyDescriptorSetLayout(device, fsr_.descSetLayout, nullptr); fsr_.descSetLayout = VK_NULL_HANDLE; } + if (fsr_.sceneFramebuffer) { vkDestroyFramebuffer(device, fsr_.sceneFramebuffer, nullptr); fsr_.sceneFramebuffer = VK_NULL_HANDLE; } + if (fsr_.sceneSampler) { vkDestroySampler(device, fsr_.sceneSampler, nullptr); fsr_.sceneSampler = VK_NULL_HANDLE; } + destroyImage(device, alloc, fsr_.sceneDepthResolve); + destroyImage(device, alloc, fsr_.sceneMsaaColor); + destroyImage(device, alloc, fsr_.sceneDepth); + destroyImage(device, alloc, fsr_.sceneColor); + + fsr_.internalWidth = 0; + fsr_.internalHeight = 0; +} + +void Renderer::renderFSRUpscale() { + if (!fsr_.pipeline || currentCmd == VK_NULL_HANDLE) return; + + VkExtent2D outExtent = vkCtx->getSwapchainExtent(); + float inW = static_cast(fsr_.internalWidth); + float inH = static_cast(fsr_.internalHeight); + float outW = static_cast(outExtent.width); + float outH = static_cast(outExtent.height); + + // FSR push constants + struct { + glm::vec4 con0; // inputSize.xy, 1/inputSize.xy + glm::vec4 con1; // inputSize.xy / outputSize.xy, 0.5 * inputSize.xy / outputSize.xy + glm::vec4 con2; // outputSize.xy, 1/outputSize.xy + glm::vec4 con3; // sharpness, 0, 0, 0 + } fsrConst; + + fsrConst.con0 = glm::vec4(inW, inH, 1.0f / inW, 1.0f / inH); + fsrConst.con1 = glm::vec4(inW / outW, inH / outH, 0.5f * inW / outW, 0.5f * inH / outH); + fsrConst.con2 = glm::vec4(outW, outH, 1.0f / outW, 1.0f / outH); + fsrConst.con3 = glm::vec4(fsr_.sharpness, 0.0f, 0.0f, 0.0f); + + vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fsr_.pipeline); + vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + fsr_.pipelineLayout, 0, 1, &fsr_.descSet, 0, nullptr); + vkCmdPushConstants(currentCmd, fsr_.pipelineLayout, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 64, &fsrConst); + vkCmdDraw(currentCmd, 3, 1, 0, 0); +} + +void Renderer::setFSREnabled(bool enabled) { + if (fsr_.enabled == enabled) return; + fsr_.enabled = enabled; + + if (!enabled) { + // Defer destruction to next beginFrame() — can't destroy mid-render + fsr_.needsRecreate = true; + } + // Resources created/destroyed lazily in beginFrame() +} + +void Renderer::setFSRQuality(float scaleFactor) { + scaleFactor = glm::clamp(scaleFactor, 0.5f, 1.0f); + if (fsr_.scaleFactor == scaleFactor) return; + fsr_.scaleFactor = scaleFactor; + // Don't destroy/recreate mid-frame — mark for lazy recreation in next beginFrame() + if (fsr_.enabled && fsr_.sceneFramebuffer) { + fsr_.needsRecreate = true; + } +} + +void Renderer::setFSRSharpness(float sharpness) { + fsr_.sharpness = glm::clamp(sharpness, 0.0f, 2.0f); +} + +// ========================= End FSR ========================= + void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { (void)world; @@ -3233,153 +3616,283 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { // Get time of day for sky-related rendering float timeOfDay = (skySystem && skySystem->getSkybox()) ? skySystem->getSkybox()->getTimeOfDay() : 12.0f; - // Render sky system (unified coordinator for skybox, stars, celestial, clouds, lens flare) - if (skySystem && camera && !skipSky) { - rendering::SkyParams skyParams; - skyParams.timeOfDay = timeOfDay; - skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f; - - if (lightingManager) { - const auto& lighting = lightingManager->getLightingParams(); - skyParams.directionalDir = lighting.directionalDir; - skyParams.sunColor = lighting.diffuseColor; - skyParams.skyTopColor = lighting.skyTopColor; - skyParams.skyMiddleColor = lighting.skyMiddleColor; - skyParams.skyBand1Color = lighting.skyBand1Color; - skyParams.skyBand2Color = lighting.skyBand2Color; - skyParams.cloudDensity = lighting.cloudDensity; - skyParams.fogDensity = lighting.fogDensity; - skyParams.horizonGlow = lighting.horizonGlow; - } - - // Weather attenuation for lens flare - if (gameHandler) { - skyParams.weatherIntensity = gameHandler->getWeatherIntensity(); - } - - skyParams.skyboxModelId = 0; - skyParams.skyboxHasStars = false; - - skySystem->render(currentCmd, perFrameSet, *camera, skyParams); - } - - // Terrain (opaque pass) - if (terrainRenderer && camera && terrainEnabled && !skipTerrain) { - auto terrainStart = std::chrono::steady_clock::now(); - terrainRenderer->render(currentCmd, perFrameSet, *camera); - lastTerrainRenderMs = std::chrono::duration( - std::chrono::steady_clock::now() - terrainStart).count(); - } - - // WMO buildings (opaque, drawn before characters so selection circle sits on top) - if (wmoRenderer && camera && !skipWMO) { - auto wmoStart = std::chrono::steady_clock::now(); - wmoRenderer->render(currentCmd, perFrameSet, *camera); - lastWMORenderMs = std::chrono::duration( - std::chrono::steady_clock::now() - wmoStart).count(); - } - - // Selection circle (drawn after WMO, before characters) - renderSelectionCircle(view, projection); - - // Characters (after selection circle so units draw over the ring) - if (characterRenderer && camera && !skipChars) { - characterRenderer->render(currentCmd, perFrameSet, *camera); - } - - // M2 doodads, creatures, glow sprites, particles - if (m2Renderer && camera && !skipM2) { - if (cameraController) { + // ── Multithreaded secondary command buffer recording ── + // Terrain, WMO, and M2 record on worker threads while main thread handles + // sky, characters, water, and effects. prepareRender() on main thread first + // to handle thread-unsafe GPU allocations (descriptor pools, bone SSBOs). + if (parallelRecordingEnabled_) { + // --- Pre-compute state + GPU allocations on main thread (not thread-safe) --- + if (m2Renderer && cameraController) { m2Renderer->setInsideInterior(cameraController->isInsideWMO()); m2Renderer->setOnTaxi(cameraController->isOnTaxi()); } - auto m2Start = std::chrono::steady_clock::now(); - m2Renderer->render(currentCmd, perFrameSet, *camera); - m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); - m2Renderer->renderM2Particles(currentCmd, perFrameSet); - lastM2RenderMs = std::chrono::duration( - std::chrono::steady_clock::now() - m2Start).count(); - } + if (wmoRenderer) wmoRenderer->prepareRender(); + if (m2Renderer && camera) m2Renderer->prepareRender(frameIdx, *camera); + if (characterRenderer) characterRenderer->prepareRender(frameIdx); - // Water (transparent, after all opaques) - // When MSAA is on and 1x pass is available, water renders after main pass ends - bool waterDeferred = waterRenderer && waterRenderer->hasWater1xPass() - && vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT; - if (waterRenderer && camera && !waterDeferred) { - waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, vkCtx->getCurrentFrame()); - } + // --- Dispatch worker threads (terrain + WMO + M2) --- + std::future terrainFuture, wmoFuture, m2Future; - // Weather particles - if (weather && camera) { - weather->render(currentCmd, perFrameSet); - } - - // Swim effects (ripples, bubbles) - if (swimEffects && camera) { - swimEffects->render(currentCmd, perFrameSet); - } - - // Mount dust - if (mountDust && camera) { - mountDust->render(currentCmd, perFrameSet); - } - - // Charge effect - if (chargeEffect && camera) { - chargeEffect->render(currentCmd, perFrameSet); - } - - // Quest markers (billboards above NPCs) - if (questMarkerRenderer && camera) { - questMarkerRenderer->render(currentCmd, perFrameSet, *camera); - } - - // Underwater blue fog overlay — only for terrain water, not WMO water. - if (overlayPipeline && waterRenderer && camera) { - glm::vec3 camPos = camera->getPosition(); - auto waterH = waterRenderer->getNearestWaterHeightAt(camPos.x, camPos.y, camPos.z); - constexpr float MIN_SUBMERSION_OVERLAY = 1.5f; - if (waterH && camPos.z < (*waterH - MIN_SUBMERSION_OVERLAY) - && !waterRenderer->isWmoWaterAt(camPos.x, camPos.y)) { - float depth = *waterH - camPos.z - MIN_SUBMERSION_OVERLAY; - - // Check for canal (liquid type 5, 13, 17) — denser/darker fog - bool canal = false; - if (auto lt = waterRenderer->getWaterTypeAt(camPos.x, camPos.y)) - canal = (*lt == 5 || *lt == 13 || *lt == 17); - - // Fog opacity increases with depth: thin at surface, thick deep down - float fogStrength = 1.0f - std::exp(-depth * (canal ? 0.25f : 0.12f)); - fogStrength = glm::clamp(fogStrength, 0.0f, 0.75f); - - glm::vec4 tint = canal - ? glm::vec4(0.01f, 0.04f, 0.10f, fogStrength) - : glm::vec4(0.03f, 0.09f, 0.18f, fogStrength); - renderOverlay(tint); + if (terrainRenderer && camera && terrainEnabled && !skipTerrain) { + terrainFuture = std::async(std::launch::async, [&]() -> double { + auto t0 = std::chrono::steady_clock::now(); + VkCommandBuffer cmd = beginSecondary(SEC_TERRAIN); + setSecondaryViewportScissor(cmd); + terrainRenderer->render(cmd, perFrameSet, *camera); + vkEndCommandBuffer(cmd); + return std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + }); } + + if (wmoRenderer && camera && !skipWMO) { + wmoFuture = std::async(std::launch::async, [&]() -> double { + auto t0 = std::chrono::steady_clock::now(); + VkCommandBuffer cmd = beginSecondary(SEC_WMO); + setSecondaryViewportScissor(cmd); + wmoRenderer->render(cmd, perFrameSet, *camera); + vkEndCommandBuffer(cmd); + return std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + }); + } + + if (m2Renderer && camera && !skipM2) { + m2Future = std::async(std::launch::async, [&]() -> double { + auto t0 = std::chrono::steady_clock::now(); + VkCommandBuffer cmd = beginSecondary(SEC_M2); + setSecondaryViewportScissor(cmd); + m2Renderer->render(cmd, perFrameSet, *camera); + m2Renderer->renderSmokeParticles(cmd, perFrameSet); + m2Renderer->renderM2Particles(cmd, perFrameSet); + vkEndCommandBuffer(cmd); + return std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + }); + } + + // --- Main thread: record sky (SEC_SKY) --- + { + VkCommandBuffer cmd = beginSecondary(SEC_SKY); + setSecondaryViewportScissor(cmd); + if (skySystem && camera && !skipSky) { + rendering::SkyParams skyParams; + skyParams.timeOfDay = timeOfDay; + skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f; + if (lightingManager) { + const auto& lighting = lightingManager->getLightingParams(); + skyParams.directionalDir = lighting.directionalDir; + skyParams.sunColor = lighting.diffuseColor; + skyParams.skyTopColor = lighting.skyTopColor; + skyParams.skyMiddleColor = lighting.skyMiddleColor; + skyParams.skyBand1Color = lighting.skyBand1Color; + skyParams.skyBand2Color = lighting.skyBand2Color; + skyParams.cloudDensity = lighting.cloudDensity; + skyParams.fogDensity = lighting.fogDensity; + skyParams.horizonGlow = lighting.horizonGlow; + } + if (gameHandler) skyParams.weatherIntensity = gameHandler->getWeatherIntensity(); + skyParams.skyboxModelId = 0; + skyParams.skyboxHasStars = false; + skySystem->render(cmd, perFrameSet, *camera, skyParams); + } + vkEndCommandBuffer(cmd); + } + + // --- Main thread: record characters + selection circle (SEC_CHARS) --- + { + VkCommandBuffer cmd = beginSecondary(SEC_CHARS); + setSecondaryViewportScissor(cmd); + renderSelectionCircle(view, projection, cmd); + if (characterRenderer && camera && !skipChars) { + characterRenderer->render(cmd, perFrameSet, *camera); + } + vkEndCommandBuffer(cmd); + } + + // --- Wait for workers --- + if (terrainFuture.valid()) lastTerrainRenderMs = terrainFuture.get(); + if (wmoFuture.valid()) lastWMORenderMs = wmoFuture.get(); + if (m2Future.valid()) lastM2RenderMs = m2Future.get(); + + // --- Main thread: record post-opaque (SEC_POST) --- + { + VkCommandBuffer cmd = beginSecondary(SEC_POST); + setSecondaryViewportScissor(cmd); + if (waterRenderer && camera) + waterRenderer->render(cmd, perFrameSet, *camera, globalTime, false, frameIdx); + if (weather && camera) weather->render(cmd, perFrameSet); + if (swimEffects && camera) swimEffects->render(cmd, perFrameSet); + if (mountDust && camera) mountDust->render(cmd, perFrameSet); + if (chargeEffect && camera) chargeEffect->render(cmd, perFrameSet); + if (questMarkerRenderer && camera) questMarkerRenderer->render(cmd, perFrameSet, *camera); + + // Underwater overlay + minimap + if (overlayPipeline && waterRenderer && camera) { + glm::vec3 camPos = camera->getPosition(); + auto waterH = waterRenderer->getNearestWaterHeightAt(camPos.x, camPos.y, camPos.z); + constexpr float MIN_SUBMERSION_OVERLAY = 1.5f; + if (waterH && camPos.z < (*waterH - MIN_SUBMERSION_OVERLAY) + && !waterRenderer->isWmoWaterAt(camPos.x, camPos.y)) { + float depth = *waterH - camPos.z - MIN_SUBMERSION_OVERLAY; + bool canal = false; + if (auto lt = waterRenderer->getWaterTypeAt(camPos.x, camPos.y)) + canal = (*lt == 5 || *lt == 13 || *lt == 17); + float fogStrength = 1.0f - std::exp(-depth * (canal ? 0.25f : 0.12f)); + fogStrength = glm::clamp(fogStrength, 0.0f, 0.75f); + glm::vec4 tint = canal + ? glm::vec4(0.01f, 0.04f, 0.10f, fogStrength) + : glm::vec4(0.03f, 0.09f, 0.18f, fogStrength); + renderOverlay(tint, cmd); + } + } + if (minimap && minimap->isEnabled() && camera && window) { + glm::vec3 minimapCenter = camera->getPosition(); + if (cameraController && cameraController->isThirdPerson()) + minimapCenter = characterPosition; + float minimapPlayerOrientation = 0.0f; + bool hasMinimapPlayerOrientation = false; + if (cameraController) { + float facingRad = glm::radians(characterYaw); + glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); + minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + hasMinimapPlayerOrientation = true; + } else if (gameHandler) { + minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + hasMinimapPlayerOrientation = true; + } + minimap->render(cmd, *camera, minimapCenter, + window->getWidth(), window->getHeight(), + minimapPlayerOrientation, hasMinimapPlayerOrientation); + } + vkEndCommandBuffer(cmd); + } + + // --- Execute all secondary buffers in correct draw order --- + VkCommandBuffer validCmds[6]; + uint32_t numCmds = 0; + validCmds[numCmds++] = secondaryCmds_[SEC_SKY][frameIdx]; + if (terrainRenderer && camera && terrainEnabled && !skipTerrain) + validCmds[numCmds++] = secondaryCmds_[SEC_TERRAIN][frameIdx]; + if (wmoRenderer && camera && !skipWMO) + validCmds[numCmds++] = secondaryCmds_[SEC_WMO][frameIdx]; + validCmds[numCmds++] = secondaryCmds_[SEC_CHARS][frameIdx]; + if (m2Renderer && camera && !skipM2) + validCmds[numCmds++] = secondaryCmds_[SEC_M2][frameIdx]; + validCmds[numCmds++] = secondaryCmds_[SEC_POST][frameIdx]; + + vkCmdExecuteCommands(currentCmd, numCmds, validCmds); + + } else { + // ── Fallback: single-threaded inline recording (original path) ── + + if (skySystem && camera && !skipSky) { + rendering::SkyParams skyParams; + skyParams.timeOfDay = timeOfDay; + skyParams.gameTime = gameHandler ? gameHandler->getGameTime() : -1.0f; + if (lightingManager) { + const auto& lighting = lightingManager->getLightingParams(); + skyParams.directionalDir = lighting.directionalDir; + skyParams.sunColor = lighting.diffuseColor; + skyParams.skyTopColor = lighting.skyTopColor; + skyParams.skyMiddleColor = lighting.skyMiddleColor; + skyParams.skyBand1Color = lighting.skyBand1Color; + skyParams.skyBand2Color = lighting.skyBand2Color; + skyParams.cloudDensity = lighting.cloudDensity; + skyParams.fogDensity = lighting.fogDensity; + skyParams.horizonGlow = lighting.horizonGlow; + } + if (gameHandler) skyParams.weatherIntensity = gameHandler->getWeatherIntensity(); + skyParams.skyboxModelId = 0; + skyParams.skyboxHasStars = false; + skySystem->render(currentCmd, perFrameSet, *camera, skyParams); + } + + if (terrainRenderer && camera && terrainEnabled && !skipTerrain) { + auto terrainStart = std::chrono::steady_clock::now(); + terrainRenderer->render(currentCmd, perFrameSet, *camera); + lastTerrainRenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - terrainStart).count(); + } + + if (wmoRenderer && camera && !skipWMO) { + wmoRenderer->prepareRender(); + auto wmoStart = std::chrono::steady_clock::now(); + wmoRenderer->render(currentCmd, perFrameSet, *camera); + lastWMORenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - wmoStart).count(); + } + + renderSelectionCircle(view, projection); + + if (characterRenderer && camera && !skipChars) { + characterRenderer->prepareRender(frameIdx); + characterRenderer->render(currentCmd, perFrameSet, *camera); + } + + if (m2Renderer && camera && !skipM2) { + if (cameraController) { + m2Renderer->setInsideInterior(cameraController->isInsideWMO()); + m2Renderer->setOnTaxi(cameraController->isOnTaxi()); + } + m2Renderer->prepareRender(frameIdx, *camera); + auto m2Start = std::chrono::steady_clock::now(); + m2Renderer->render(currentCmd, perFrameSet, *camera); + m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); + m2Renderer->renderM2Particles(currentCmd, perFrameSet); + lastM2RenderMs = std::chrono::duration( + std::chrono::steady_clock::now() - m2Start).count(); + } + + if (waterRenderer && camera) + waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, frameIdx); + if (weather && camera) weather->render(currentCmd, perFrameSet); + if (swimEffects && camera) swimEffects->render(currentCmd, perFrameSet); + if (mountDust && camera) mountDust->render(currentCmd, perFrameSet); + if (chargeEffect && camera) chargeEffect->render(currentCmd, perFrameSet); + if (questMarkerRenderer && camera) questMarkerRenderer->render(currentCmd, perFrameSet, *camera); } - // Minimap overlay - if (minimap && minimap->isEnabled() && camera && window) { - glm::vec3 minimapCenter = camera->getPosition(); - if (cameraController && cameraController->isThirdPerson()) - minimapCenter = characterPosition; - float minimapPlayerOrientation = 0.0f; - bool hasMinimapPlayerOrientation = false; - if (cameraController) { - // Use the same yaw that drives character model rendering so minimap - // orientation cannot drift by a different axis/sign convention. - float facingRad = glm::radians(characterYaw); - glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); - minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); - hasMinimapPlayerOrientation = true; - } else if (gameHandler) { - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; - hasMinimapPlayerOrientation = true; + // Underwater overlay and minimap — in the fallback path these run inline; + // in the parallel path they were already recorded into SEC_POST above. + if (!parallelRecordingEnabled_) { + if (overlayPipeline && waterRenderer && camera) { + glm::vec3 camPos = camera->getPosition(); + auto waterH = waterRenderer->getNearestWaterHeightAt(camPos.x, camPos.y, camPos.z); + constexpr float MIN_SUBMERSION_OVERLAY = 1.5f; + if (waterH && camPos.z < (*waterH - MIN_SUBMERSION_OVERLAY) + && !waterRenderer->isWmoWaterAt(camPos.x, camPos.y)) { + float depth = *waterH - camPos.z - MIN_SUBMERSION_OVERLAY; + bool canal = false; + if (auto lt = waterRenderer->getWaterTypeAt(camPos.x, camPos.y)) + canal = (*lt == 5 || *lt == 13 || *lt == 17); + float fogStrength = 1.0f - std::exp(-depth * (canal ? 0.25f : 0.12f)); + fogStrength = glm::clamp(fogStrength, 0.0f, 0.75f); + glm::vec4 tint = canal + ? glm::vec4(0.01f, 0.04f, 0.10f, fogStrength) + : glm::vec4(0.03f, 0.09f, 0.18f, fogStrength); + renderOverlay(tint); + } + } + if (minimap && minimap->isEnabled() && camera && window) { + glm::vec3 minimapCenter = camera->getPosition(); + if (cameraController && cameraController->isThirdPerson()) + minimapCenter = characterPosition; + float minimapPlayerOrientation = 0.0f; + bool hasMinimapPlayerOrientation = false; + if (cameraController) { + float facingRad = glm::radians(characterYaw); + glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); + minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + hasMinimapPlayerOrientation = true; + } else if (gameHandler) { + minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + hasMinimapPlayerOrientation = true; + } + minimap->render(currentCmd, *camera, minimapCenter, + window->getWidth(), window->getHeight(), + minimapPlayerOrientation, hasMinimapPlayerOrientation); } - minimap->render(currentCmd, *camera, minimapCenter, - window->getWidth(), window->getHeight(), - minimapPlayerOrientation, hasMinimapPlayerOrientation); } auto renderEnd = std::chrono::steady_clock::now(); @@ -3413,8 +3926,6 @@ bool Renderer::initializeRenderers(pipeline::AssetManager* assetManager, const s if (!waterRenderer->initialize(vkCtx, perFrameSetLayout)) { LOG_ERROR("Failed to initialize water renderer"); waterRenderer.reset(); - } else if (vkCtx->getMsaaSamples() != VK_SAMPLE_COUNT_1_BIT) { - setupWater1xPass(); } } @@ -3868,6 +4379,128 @@ void Renderer::setupWater1xPass() { vkCtx->getSwapchainImageViews(), depthView, vkCtx->getSwapchainExtent()); } +// ========================= Multithreaded Secondary Command Buffers ========================= + +bool Renderer::createSecondaryCommandResources() { + if (!vkCtx) return false; + VkDevice device = vkCtx->getDevice(); + uint32_t queueFamily = vkCtx->getGraphicsQueueFamily(); + + VkCommandPoolCreateInfo poolCI{}; + poolCI.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + poolCI.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + poolCI.queueFamilyIndex = queueFamily; + + // Create worker command pools (one per worker thread) + for (uint32_t w = 0; w < NUM_WORKERS; ++w) { + if (vkCreateCommandPool(device, &poolCI, nullptr, &workerCmdPools_[w]) != VK_SUCCESS) { + LOG_ERROR("Failed to create worker command pool ", w); + return false; + } + } + + // Create main-thread secondary command pool + if (vkCreateCommandPool(device, &poolCI, nullptr, &mainSecondaryCmdPool_) != VK_SUCCESS) { + LOG_ERROR("Failed to create main secondary command pool"); + return false; + } + + // Allocate secondary command buffers + VkCommandBufferAllocateInfo allocInfo{}; + allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocInfo.level = VK_COMMAND_BUFFER_LEVEL_SECONDARY; + allocInfo.commandBufferCount = 1; + + // Worker secondaries: SEC_TERRAIN=1, SEC_WMO=2, SEC_M2=4 → worker pools 0,1,2 + const uint32_t workerSecondaries[] = { SEC_TERRAIN, SEC_WMO, SEC_M2 }; + for (uint32_t w = 0; w < NUM_WORKERS; ++w) { + allocInfo.commandPool = workerCmdPools_[w]; + for (uint32_t f = 0; f < MAX_FRAMES; ++f) { + if (vkAllocateCommandBuffers(device, &allocInfo, &secondaryCmds_[workerSecondaries[w]][f]) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate worker secondary buffer w=", w, " f=", f); + return false; + } + } + } + + // Main-thread secondaries: SEC_SKY=0, SEC_CHARS=3, SEC_POST=5, SEC_IMGUI=6 + const uint32_t mainSecondaries[] = { SEC_SKY, SEC_CHARS, SEC_POST, SEC_IMGUI }; + for (uint32_t idx : mainSecondaries) { + allocInfo.commandPool = mainSecondaryCmdPool_; + for (uint32_t f = 0; f < MAX_FRAMES; ++f) { + if (vkAllocateCommandBuffers(device, &allocInfo, &secondaryCmds_[idx][f]) != VK_SUCCESS) { + LOG_ERROR("Failed to allocate main secondary buffer idx=", idx, " f=", f); + return false; + } + } + } + + parallelRecordingEnabled_ = true; + LOG_INFO("Multithreaded rendering: ", NUM_WORKERS, " worker threads, ", + NUM_SECONDARIES, " secondary buffers [ENABLED]"); + return true; +} + +void Renderer::destroySecondaryCommandResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + vkDeviceWaitIdle(device); + + // Secondary buffers are freed when their pool is destroyed + for (uint32_t w = 0; w < NUM_WORKERS; ++w) { + if (workerCmdPools_[w]) { + vkDestroyCommandPool(device, workerCmdPools_[w], nullptr); + workerCmdPools_[w] = VK_NULL_HANDLE; + } + } + if (mainSecondaryCmdPool_) { + vkDestroyCommandPool(device, mainSecondaryCmdPool_, nullptr); + mainSecondaryCmdPool_ = VK_NULL_HANDLE; + } + + for (auto& arr : secondaryCmds_) + for (auto& cmd : arr) + cmd = VK_NULL_HANDLE; + + parallelRecordingEnabled_ = false; +} + +VkCommandBuffer Renderer::beginSecondary(uint32_t secondaryIndex) { + uint32_t frame = vkCtx->getCurrentFrame(); + VkCommandBuffer cmd = secondaryCmds_[secondaryIndex][frame]; + + VkCommandBufferInheritanceInfo inheritInfo{}; + inheritInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO; + inheritInfo.renderPass = activeRenderPass_; + inheritInfo.subpass = 0; + inheritInfo.framebuffer = activeFramebuffer_; + + VkCommandBufferBeginInfo beginInfo{}; + beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT + | VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT; + beginInfo.pInheritanceInfo = &inheritInfo; + + VkResult result = vkBeginCommandBuffer(cmd, &beginInfo); + if (result != VK_SUCCESS) { + LOG_ERROR("vkBeginCommandBuffer failed for secondary ", secondaryIndex, + " frame ", frame, " result=", static_cast(result)); + } + return cmd; +} + +void Renderer::setSecondaryViewportScissor(VkCommandBuffer cmd) { + VkViewport vp{}; + vp.width = static_cast(activeRenderExtent_.width); + vp.height = static_cast(activeRenderExtent_.height); + vp.maxDepth = 1.0f; + vkCmdSetViewport(cmd, 0, 1, &vp); + + VkRect2D sc{}; + sc.extent = activeRenderExtent_; + vkCmdSetScissor(cmd, 0, 1, &sc); +} + void Renderer::renderReflectionPass() { if (!waterRenderer || !camera || !waterRenderer->hasReflectionPass() || !waterRenderer->hasSurfaces()) return; if (currentCmd == VK_NULL_HANDLE || !reflPerFrameUBOMapped) return; diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 97527c8c..f15541ea 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -911,6 +911,8 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { wmoRenderer->setDeferNormalMaps(false); wmoRenderer->setPredecodedBLPCache(nullptr); if (ft.wmoModelIndex < pending->wmoModels.size()) return false; + // All WMO models loaded — backfill normal/height maps that were skipped during streaming + wmoRenderer->backfillNormalMaps(); } ft.phase = FinalizationPhase::WMO_INSTANCES; return false; diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index 79e7eac3..dc4144fa 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -252,14 +252,22 @@ bool VkContext::createAllocator() { bool VkContext::createSwapchain(int width, int height) { vkb::SwapchainBuilder swapchainBuilder{physicalDevice, device, surface}; - auto swapRet = swapchainBuilder + auto& builder = swapchainBuilder .set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}) - .set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR) // VSync .set_desired_extent(static_cast(width), static_cast(height)) .set_image_usage_flags(VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT) .set_desired_min_image_count(2) - .set_old_swapchain(swapchain) // For recreation - .build(); + .set_old_swapchain(swapchain); + + if (vsync_) { + builder.set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR); + } else { + builder.set_desired_present_mode(VK_PRESENT_MODE_IMMEDIATE_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_MAILBOX_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_FIFO_RELAXED_KHR); + } + + auto swapRet = builder.build(); if (!swapRet) { LOG_ERROR("Failed to create Vulkan swapchain: ", swapRet.error().message()); @@ -1026,14 +1034,22 @@ bool VkContext::recreateSwapchain(int width, int height) { VkSwapchainKHR oldSwapchain = swapchain; vkb::SwapchainBuilder swapchainBuilder{physicalDevice, device, surface}; - auto swapRet = swapchainBuilder + auto& builder = swapchainBuilder .set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}) - .set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR) .set_desired_extent(static_cast(width), static_cast(height)) .set_image_usage_flags(VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT) .set_desired_min_image_count(2) - .set_old_swapchain(oldSwapchain) - .build(); + .set_old_swapchain(oldSwapchain); + + if (vsync_) { + builder.set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR); + } else { + builder.set_desired_present_mode(VK_PRESENT_MODE_IMMEDIATE_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_MAILBOX_KHR); + builder.add_fallback_present_mode(VK_PRESENT_MODE_FIFO_RELAXED_KHR); + } + + auto swapRet = builder.build(); if (oldSwapchain) { vkDestroySwapchainKHR(device, oldSwapchain, nullptr); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 5dec0e3e..2e5afcc3 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -787,8 +787,8 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } // Build doodad's local transform (WoW coordinates) - // WMO doodads use quaternion rotation (X/Y swapped for correct orientation) - glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y, doodad.rotation.x, doodad.rotation.z); + // WMO doodads use quaternion rotation + glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.x, doodad.rotation.y, doodad.rotation.z); glm::mat4 localTransform(1.0f); localTransform = glm::translate(localTransform, doodad.position); @@ -1318,15 +1318,10 @@ void WMORenderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& q } } -void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { +void WMORenderer::prepareRender() { ++currentFrameId; - if (!opaquePipeline_ || instances.empty()) { - lastDrawCalls = 0; - return; - } - - // Update material UBOs if settings changed + // Update material UBOs if settings changed (mapped memory writes — main thread only) if (materialSettingsDirty_) { materialSettingsDirty_ = false; static const int pomSampleTable[] = { 16, 32, 64 }; @@ -1335,7 +1330,6 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const for (auto& group : model.groups) { for (auto& mb : group.mergedBatches) { if (!mb.materialUBO) continue; - // Read existing UBO data, update normal/POM fields VmaAllocationInfo allocInfo{}; vmaGetAllocationInfo(vkCtx_->getAllocator(), mb.materialUBOAlloc, &allocInfo); if (allocInfo.pMappedData) { @@ -1351,6 +1345,13 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } } +} + +void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (!opaquePipeline_ || instances.empty()) { + lastDrawCalls = 0; + return; + } lastDrawCalls = 0; @@ -1362,43 +1363,45 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const lastPortalCulledGroups = 0; lastDistanceCulledGroups = 0; - // ── Phase 1: Parallel visibility culling ────────────────────────── - std::vector visibleInstances; - visibleInstances.reserve(instances.size()); + // ── Phase 1: Visibility culling ────────────────────────── + visibleInstances_.clear(); for (size_t i = 0; i < instances.size(); ++i) { - const auto& instance = instances[i]; - if (loadedModels.find(instance.modelId) == loadedModels.end()) - continue; - visibleInstances.push_back(i); + if (loadedModels.count(instances[i].modelId)) + visibleInstances_.push_back(i); } glm::vec3 camPos = camera.getPosition(); bool doPortalCull = portalCulling; - bool doFrustumCull = false; // Temporarily disabled: can over-cull world WMOs bool doDistanceCull = distanceCulling; - auto cullInstance = [&](size_t instIdx) -> InstanceDrawList { - if (instIdx >= instances.size()) return InstanceDrawList{}; + auto cullInstance = [&](size_t instIdx, InstanceDrawList& result) { + if (instIdx >= instances.size()) return; const auto& instance = instances[instIdx]; auto mdlIt = loadedModels.find(instance.modelId); - if (mdlIt == loadedModels.end()) return InstanceDrawList{}; + if (mdlIt == loadedModels.end()) return; const ModelData& model = mdlIt->second; - InstanceDrawList result; result.instanceIndex = instIdx; + result.visibleGroups.clear(); + result.portalCulled = 0; + result.distanceCulled = 0; - // Portal-based visibility - std::unordered_set portalVisibleGroups; + // Portal-based visibility — use a flat sorted vector instead of unordered_set + std::vector portalVisibleGroups; bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); if (usePortalCulling) { + std::unordered_set pvgSet; glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f); getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum, - instance.modelMatrix, portalVisibleGroups); + instance.modelMatrix, pvgSet); + portalVisibleGroups.assign(pvgSet.begin(), pvgSet.end()); + std::sort(portalVisibleGroups.begin(), portalVisibleGroups.end()); } for (size_t gi = 0; gi < model.groups.size(); ++gi) { if (usePortalCulling && - portalVisibleGroups.find(static_cast(gi)) == portalVisibleGroups.end()) { + !std::binary_search(portalVisibleGroups.begin(), portalVisibleGroups.end(), + static_cast(gi))) { result.portalCulled++; continue; } @@ -1414,62 +1417,18 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const continue; } } - - if (doFrustumCull && !frustum.intersectsAABB(gMin, gMax)) - continue; } result.visibleGroups.push_back(static_cast(gi)); } - return result; }; - // Dispatch culling — parallel when enough instances, sequential otherwise. - std::vector drawLists; - drawLists.reserve(visibleInstances.size()); + // Resize drawLists to match (reuses previous capacity) + drawLists_.resize(visibleInstances_.size()); - static const size_t minParallelCullInstances = std::max( - 4, envSizeOrDefault("WOWEE_WMO_CULL_MT_MIN", 128)); - if (visibleInstances.size() >= minParallelCullInstances && numCullThreads_ > 1) { - static const size_t minCullWorkPerThread = std::max( - 16, envSizeOrDefault("WOWEE_WMO_CULL_WORK_PER_THREAD", 64)); - const size_t maxUsefulThreads = std::max( - 1, (visibleInstances.size() + minCullWorkPerThread - 1) / minCullWorkPerThread); - const size_t numThreads = std::min(static_cast(numCullThreads_), maxUsefulThreads); - if (numThreads <= 1) { - for (size_t idx : visibleInstances) { - drawLists.push_back(cullInstance(idx)); - } - } else { - const size_t chunkSize = visibleInstances.size() / numThreads; - const size_t remainder = visibleInstances.size() % numThreads; - - drawLists.resize(visibleInstances.size()); - - cullFutures_.clear(); - if (cullFutures_.capacity() < numThreads) { - cullFutures_.reserve(numThreads); - } - - size_t start = 0; - for (size_t t = 0; t < numThreads; ++t) { - const size_t end = start + chunkSize + (t < remainder ? 1 : 0); - cullFutures_.push_back(std::async(std::launch::async, - [&, start, end]() { - for (size_t j = start; j < end; ++j) { - drawLists[j] = cullInstance(visibleInstances[j]); - } - })); - start = end; - } - - for (auto& f : cullFutures_) { - f.get(); - } - } - } else { - for (size_t idx : visibleInstances) - drawLists.push_back(cullInstance(idx)); + // Sequential culling (parallel dispatch overhead > savings for typical instance counts) + for (size_t j = 0; j < visibleInstances_.size(); ++j) { + cullInstance(visibleInstances_[j], drawLists_[j]); } // ── Phase 2: Vulkan draw ──────────────────────────────── @@ -1484,7 +1443,7 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Track which pipeline is currently bound: 0=opaque, 1=transparent, 2=glass int currentPipelineKind = 0; - for (const auto& dl : drawLists) { + for (const auto& dl : drawLists_) { if (dl.instanceIndex >= instances.size()) continue; const auto& instance = instances[dl.instanceIndex]; auto modelIt = loadedModels.find(instance.modelId); @@ -2412,6 +2371,69 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { return rawPtr; } +void WMORenderer::backfillNormalMaps() { + if (!normalMappingEnabled_ && !pomEnabled_) return; + + if (!assetManager) return; + + int generated = 0; + for (auto& [key, entry] : textureCache) { + if (entry.normalHeightMap) continue; // already has one + if (!entry.texture) continue; + + // Re-load the BLP from MPQ to get pixel data for normal map generation + pipeline::BLPImage blp = assetManager->loadTexture(key); + if (!blp.isValid() || blp.width == 0 || blp.height == 0) continue; + + float variance = 0.0f; + auto nhMap = generateNormalHeightMap(blp.data.data(), blp.width, blp.height, variance); + if (nhMap) { + entry.normalHeightMap = std::move(nhMap); + entry.heightMapVariance = variance; + generated++; + } + } + + if (generated > 0) { + VkDevice device = vkCtx_->getDevice(); + int rebound = 0; + // Update merged batches: assign normal map pointer and rebind descriptor set + for (auto& [modelId, model] : loadedModels) { + for (auto& group : model.groups) { + for (auto& mb : group.mergedBatches) { + if (mb.normalHeightMap) continue; // already set + if (!mb.texture) continue; + // Find this texture in the cache + for (const auto& [cacheKey, cacheEntry] : textureCache) { + if (cacheEntry.texture.get() == mb.texture) { + if (cacheEntry.normalHeightMap) { + mb.normalHeightMap = cacheEntry.normalHeightMap.get(); + mb.heightMapVariance = cacheEntry.heightMapVariance; + // Rebind descriptor set binding 2 to the real normal/height map + if (mb.materialSet) { + VkDescriptorImageInfo nhImgInfo = mb.normalHeightMap->descriptorInfo(); + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = mb.materialSet; + write.dstBinding = 2; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.descriptorCount = 1; + write.pImageInfo = &nhImgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + rebound++; + } + } + break; + } + } + } + } + } + materialSettingsDirty_ = true; + LOG_INFO("Backfilled ", generated, " normal/height maps (", rebound, " descriptor sets rebound) for deferred WMO textures"); + } +} + // Ray-AABB intersection (slab method) // Returns true if the ray intersects the axis-aligned bounding box static bool rayIntersectsAABB(const glm::vec3& origin, const glm::vec3& dir, @@ -3145,18 +3167,13 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, if (triHeight < 1.0f && tb.maxZ <= localFeetZ + 1.2f) continue; // Use MOPY flags to filter wall collision. - // Collidable triangles (flag 0x01) block the player — including - // invisible collision walls (0x01 without 0x20) used in tunnels. - // Skip detail/decorative geometry (0x04) and render-only surfaces. + // Collide with triangles that have the collision flag (0x08) or no flags at all. + // Skip detail/decorative (0x04) and render-only (0x20 without 0x08) surfaces. uint32_t triIdx = triStart / 3; if (!group.triMopyFlags.empty() && triIdx < group.triMopyFlags.size()) { uint8_t mopy = group.triMopyFlags[triIdx]; if (mopy != 0) { - bool collidable = (mopy & 0x01) != 0; - bool detail = (mopy & 0x04) != 0; - if (!collidable || detail) { - continue; - } + if ((mopy & 0x04) || !(mopy & 0x08)) continue; } } @@ -3217,8 +3234,8 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to, if (absNz >= 0.35f) continue; const float SKIN = 0.005f; // small separation so we don't re-collide immediately - // Stronger push when inside WMO for more responsive indoor collision - const float MAX_PUSH = insideWMO ? 0.35f : 0.15f; + // Push must cover full penetration to prevent gradual clip-through + const float MAX_PUSH = PLAYER_RADIUS; float penetration = (PLAYER_RADIUS - horizDist); float pushDist = glm::clamp(penetration + SKIN, 0.0f, MAX_PUSH); glm::vec2 pushDir2; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3f1c0eb9..19db13e9 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -317,6 +317,20 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Apply saved FSR setting once when renderer is available + if (!fsrSettingsApplied_ && pendingFSR) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + static const float fsrScales[] = { 0.77f, 0.67f, 0.59f, 0.50f }; + renderer->setFSRQuality(fsrScales[pendingFSRQuality]); + renderer->setFSRSharpness(pendingFSRSharpness); + renderer->setFSREnabled(true); + fsrSettingsApplied_ = true; + } + } else { + fsrSettingsApplied_ = true; + } + // Apply auto-loot setting to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); @@ -2687,6 +2701,12 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { chatInputBuffer[0] = '\0'; return; } + // /unstuckhearth command — teleport to hearthstone bind point + if (cmdLower == "unstuckhearth") { + gameHandler.unstuckHearth(); + chatInputBuffer[0] = '\0'; + return; + } // /transport board — board test transport if (cmdLower == "transport board") { @@ -6270,6 +6290,25 @@ void GameScreen::renderSettingsWindow() { saveSettings(); } } + // FSR 1.0 Upscaling + { + if (ImGui::Checkbox("FSR Upscaling (Experimental)", &pendingFSR)) { + if (renderer) renderer->setFSREnabled(pendingFSR); + saveSettings(); + } + if (pendingFSR) { + const char* fsrQualityLabels[] = { "Ultra Quality (77%)", "Quality (67%)", "Balanced (59%)", "Performance (50%)" }; + static const float fsrScaleFactors[] = { 0.77f, 0.67f, 0.59f, 0.50f }; + if (ImGui::Combo("FSR Quality", &pendingFSRQuality, fsrQualityLabels, 4)) { + if (renderer) renderer->setFSRQuality(fsrScaleFactors[pendingFSRQuality]); + saveSettings(); + } + if (ImGui::SliderFloat("FSR Sharpness", &pendingFSRSharpness, 0.0f, 2.0f, "%.1f")) { + if (renderer) renderer->setFSRSharpness(pendingFSRSharpness); + saveSettings(); + } + } + } if (ImGui::SliderInt("Ground Clutter Density", &pendingGroundClutterDensity, 0, 150, "%d%%")) { if (renderer) { if (auto* tm = renderer->getTerrainManager()) { @@ -7384,6 +7423,9 @@ void GameScreen::saveSettings() { out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; out << "pom_quality=" << pendingPOMQuality << "\n"; + out << "fsr=" << (pendingFSR ? 1 : 0) << "\n"; + out << "fsr_quality=" << pendingFSRQuality << "\n"; + out << "fsr_sharpness=" << pendingFSRSharpness << "\n"; // Controls out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; @@ -7470,6 +7512,9 @@ void GameScreen::loadSettings() { else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); else if (key == "pom") pendingPOM = (std::stoi(val) != 0); else if (key == "pom_quality") pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); + else if (key == "fsr") pendingFSR = (std::stoi(val) != 0); + else if (key == "fsr_quality") pendingFSRQuality = std::clamp(std::stoi(val), 0, 3); + else if (key == "fsr_sharpness") pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f); // Controls else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0);