From bec3190b08b20726e6fe7335a364c6d675811891 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 23 Feb 2026 01:18:42 -0800 Subject: [PATCH] Fix POM distortions and add normal map strength slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POM fixes: use blurred height only for ray march (keep crisp Sobel for normals), reduce pomScale 0.03→0.012, clamp grazing angle denominator to 0.15, hard-limit max UV offset, smooth fadeout at steep view angles. Add Normal Map Strength slider (0.0-2.0) in Video settings for user control over surface detail intensity. Persisted across sessions. --- assets/shaders/wmo.frag.glsl | 27 +++++++++++++++++++++----- assets/shaders/wmo.frag.spv | Bin 17296 -> 18492 bytes include/rendering/wmo_renderer.hpp | 5 ++++- include/ui/game_screen.hpp | 1 + src/rendering/wmo_renderer.cpp | 30 +++++++++++++++++++++++++---- src/ui/game_screen.cpp | 15 +++++++++++++++ 6 files changed, 68 insertions(+), 10 deletions(-) diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index 8d21dc74..aaffef29 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -27,6 +27,7 @@ layout(set = 1, binding = 1) uniform WMOMaterial { float pomScale; int pomMaxSamples; float heightMapVariance; + float normalMapStrength; }; layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap; @@ -55,6 +56,10 @@ float computeLodFactor() { // Parallax Occlusion Mapping with angle-adaptive sampling vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float lodFactor) { float VdotN = abs(viewDirTS.z); // 1=head-on, 0=grazing + + // Fade out POM at grazing angles to avoid distortion + if (VdotN < 0.15) return uv; + float angleFactor = clamp(VdotN, 0.15, 1.0); int maxS = pomMaxSamples; int minS = max(maxS / 4, 4); @@ -64,8 +69,11 @@ vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float lodFactor) { float layerDepth = 1.0 / float(numSamples); float currentLayerDepth = 0.0; - // Direction to shift UV per layer - vec2 P = viewDirTS.xy / max(abs(viewDirTS.z), 0.001) * pomScale; + // Direction to shift UV per layer — clamp denominator to prevent explosion at grazing angles + vec2 P = viewDirTS.xy / max(VdotN, 0.15) * pomScale; + // Hard-clamp total UV offset to prevent texture swimming + float maxOffset = pomScale * 3.0; + P = clamp(P, vec2(-maxOffset), vec2(maxOffset)); vec2 deltaUV = P / float(numSamples); vec2 currentUV = uv; @@ -84,7 +92,11 @@ vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float lodFactor) { float afterDepth = currentDepthMapValue - currentLayerDepth; float beforeDepth = (1.0 - texture(uNormalHeightMap, prevUV).a) - currentLayerDepth + layerDepth; float weight = afterDepth / (afterDepth - beforeDepth + 0.0001); - return mix(currentUV, prevUV, weight); + vec2 result = mix(currentUV, prevUV, weight); + + // Fade toward original UV at grazing angles for smooth transition + float fadeFactor = smoothstep(0.15, 0.35, VdotN); + return mix(uv, result, fadeFactor); } void main() { @@ -114,11 +126,16 @@ void main() { // Compute normal (with normal mapping if enabled) vec3 norm = vertexNormal; - if (enableNormalMap != 0 && lodFactor < 0.99) { + 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; - norm = normalize(mix(worldNormal, vertexNormal, lodFactor)); + // Blend: strength + LOD both contribute to fade toward vertex normal + float blendFactor = max(lodFactor, 1.0 - normalMapStrength); + norm = normalize(mix(worldNormal, vertexNormal, blendFactor)); } vec3 result; diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index 2cfb1540da5b85cd5939757f84af985c93c03ee3..f1241ab896f55b43eeff964f992468880552a011 100644 GIT binary patch literal 18492 zcmZ{r2bf(|)rK!gCWI!vBc#xjA`p5c2}vM;2?->CD4N`4?j!>@GsBdSph!YjkX{5- zkdE}CAR9a2VM$jrt>YZI0 zHbq`T-ay_&evj;c^L5B8jIkd5^=Dwva7L@u9>+GKwq|YmjCs>%&6+;rpm_^>dgk@@ zFK;&HH9NZ+^V&Om8w>k8mp1epRq^ZWTG$+DuR65hQO&1~kw!7b!tNzK1O1H|-R%e0 z7xs7e?g-b$EH|X>srS~K&HA!g3yId(+1)j>-m~M*xp-vl z?cn~#vPN@SqpPp8A69-uZ715J+q?S@FZ8zBezf(jMa@QQDuvG^xFz*vrh?v9n@oGm z%vm$*{f*wvdXwlQ3r_pudLL=@5A;&c@UCaQ*|WGlx6#)R9$Ch4eFnOkoh_~v!yVAs zcW9TPyLD`)=VN}v=#l1>FjFnJ_$&_)r)>3!Hur^f@b6J?%pN! zCgmT@%YD+a&7L*0=+nx25UV7y=!2};DFZuIJjng zd82n)qsJib!})Lv2YQLyKciwQ;xE898?mo95^FiDqoc23-5v8}+V)1Xzkbxwh0hA~ zG8T+JqiUzE;)+p=SvCiX`Z)es*auoAJ|ty;qVMq9`Lvw{9#OlSwx_qT)WGI*KYG1` z)gGKnTkS!(1&xmG-bRZw$H(DLDgw81ej2^E(Kpa!62^HBy`$cqZCh~PgZuA$W@POL za7!D#+zPE^QSlv7oGW3xU*3RU)SP#4Z+BNew?b#vqL$w;;X69J>dm57!)w1rpIgT8 zOq$Eh!x|8Cc@Tgx8eaPow$^<&y!Kc7PC2~DWeaW?@Vw5hrRdzd z=;e8EOm}ayyuaIO+rZCm^d8(>UsCSV7DuSU-`4v>KYplt_wk-}-E0E)rTj4XrrUN- zPkmv7Te`QC6J=FD@5jQjZ_ARb+mk*kXT8?poT&|_h1d6iAaoig_q5t=J zI->S2IM3!KWuM)L=;QxOZ>#Nt&ZZg6)3_7i1_n>N*4RhFd7rm(X{#+nUsCVM)++K@ z4tJ6ZS+d4D1CB$gt9*pOjHq1>w`z}%s9kHU?u9%h9ILH(?j6luPEz){9iJx8w3g3^ z+P&!QogE#~A3!f_QH=2j`r>-C!{xRA6X>3^@qbpmsCR3AFQGU4tZYkv72SOm|Ml2V z3p(qXiyXH?ADB}F@aQeZI1H}6vv?E~K1ZOl*5wnY<Pxz#$K zM%0!WgJN|xn#C9^(O1=>t#&qgZ=<8h{x5d2^*ax)Z=h>B?*Sf#Fe7Uh!!4-yHKz1> zQ*<@@n2B|{0)A1mu8Q7PyLwf;xpvp*4!CM9??qo#%eLBMr9P#%yY5YF9N$~I2L_jr z^M6F~p0hP}OtnMsnM~_7uGI{3l)m7+7vpCi{k(s|c@N92UE%B_=X*!|ym#d`s&L-3 za+_5+?^(I6DxBlVjjnLst8&{`IG>YpJ61TKjdD99(>WI>(>f1pF@<(4;#{MTspvj; zs&;kl>F@iZwM_pvz>eoTmhn8ZwObGM-Sxz# zKXv;}M$%7iXg_nW_EWdt3MBpHR`qM;FdR<5N0Intou998#@6rk3YT?u{^@Ic{VZO% ztn+#mF6%rR&h^W4#d>-!k2MBY^f4{9&^@cIzn6B;RQ+iN^{T(``uaOp{XJi*@zwQD zfA6ov-;iB_irue+)*jgeymMCHl-TZ7v3*9PyKl71YxjQAZY=HV&}x@ge_N^R zGX~FfY3*wqeYNYi3GHf#wh5)~`@Gyv==R?Z+4O-I_Km(h`kJp?KVf6+-Z!HWFYW5P zAm$zaJ-}C8x%tG6V8>VZ;sd0%Vv=zVfFF%Gu6a$Qbw2W2lkMaZt4GW&*fzSYVw?kM z&1Ev;{M4r)a^`2hsfhbX|7o<^|V}%>R_+fJHhTv`yWqhf5(siiC{9Ayg8$s z4$`)oROO1kB`bOcyne!WvXaQgnyH%7mq=afmt-x_RBOyO zO%Rv5Ij;-XmM!qo47&r1g@s=IWls zUYX}E=xwEL?B(d3VFx6ilhL<1^MsoN!IDxVAnS5sqQmMZf~js#H)45{SC&NSyP`Ee$%>l)qRHeJsaIU z_{)l}{ntZuze(e-{ZAF$``+)+*wy_Ojjq4nqE+2*tLTpJcU5%#{jQ2`{(e_Q_dfKy zs;W<@==S$}Dt2|hr>eT&Qqhg?cT{x!{f4UQ*ACJBUW(oRelJD0zu!w${gH~Uzu!x- ztNXna-TC>w6y11!FGbhi@1?5lw^3F1+o-DhT@>ATeiubIp5H}P-S494`mY?K`&|^f z{rx7Y>V6MJxBnGGbiaeDcE5q58_#c`s{UB1@5lc29{)b#b9*9pfO}j00Bk;weu(&- zTf02xUI$aY+Chjm`?&vKN9^PC@5jg+$StM*lal)@zCQzd{R5l)PZ2rqF>%KIIoPU=g(mC*XI4L9^2o*juHLuVEae^2iQJGaO#Yt{U5|WUg7=;mNSoV|0+3_ zcaZxx*!W)Y`wv*I+E+!c+IKZLbMZ>wVI^1XJG|uVJGkCRqM9eB6_3f#rM$bRVt* z_HrL;TN{ycABv5AJoa_L#`ayy*sh1)UGfi=u{QwA`TpgeOdQ*{xbqx;L$KqxPxW_< z#M=n$efMA)ZxeJm-`{R7xlO^w&}ROd(|Vb|w#^VZ^A{W2`fUleUUN%*D|9*Q=yOHg zxsIdV78!$#MeOhMal4YA#R=hgusyn*;$`Ey>#u!B2;cKgC#L#4!1j^P{qs&RKgAiM z-CTU8$tUlf!RD8^yMXn%sEoTSSRZ-&`wW!R-!ou0aLxdqhw6#72Uvgm`D|3z=GyKF zb}h8|oK)YBH8kd4;0 zm+PzT-N+Qg^%WcE4D6G@zBj9z<7BY;97CO4zXQ?rkvCVL?Q-Vo7{)VhY*UBW^f9)v zrh)CRukQ@%nd3oV=XfWQIUWorpE*tk%Vmy-g1wxhwnGp(a}pcd=htCi$2%8nY}e^M z=<>N|W`gBrAkHmuY;zAD4t5;pq`zZ$FU_X)G7o)bA#&y+{wKRtEa&*{>p9?@??-~w zQ;WG^YcZBKwKxhapIRIZmdl=SPRF2E&w=-%%WaJ095@ziUv1|8ep)Yc*Y-X{&V0q@ z^MusIz^n;L!)ET4019#}5tSmM~GhV#LWV=eS|4D(n(>t!DL)Db!J5Id&(wHYoMMM?N)@ zvqsL%c{m?yG>-NTWDJrTxdwNZeCBo+x}4%=V-C5G?uO7Od+HvrK7QkAGmqP8<&*b) zVE0+{`@#BLN?zV4p9AY7pL^%?V19}kYOjNheJ@yB=JX)gIgLe9*DrwOGpC2Za*CHt zwXR=;*a6A8|1j7-@|owC!2A^R)NU>h(8_0CUj{p`=#PT+Sy`^vV_<#c6Zdg2KSf;a z#(jiVK5@PRuGa6X==!97PlEN4PyN0I=BKEi_BuGWr%T%waQZ!kSWln*Uk6X2Jrr?# z*XJ8>^6oqJXTZjgH{T~{<;2g@dJZ0lr}4gt?)ppxpGcdS&w;h&ed2kr+ziBX{zY0Z z?@etlAad?$vH8AC`z^%y7b3>qn)cg>eCqcK*!9h`@H=2RuW;WjIq7iUD>?VdZ0>FE zm+!;bUw`e+)#shIAD}z7KGxzTS~>I3{u)>=`|^iiImOGyv7DRlvadtvUwxPT5xRVy z1#f`mBInT8v*E{Z#@B8xuhPn;CO-ko<-O#mU^&IhChsLbL(%Tq%qG6?DnBoEIrq2R zFTnb0%N%|Qb{y9}b^R4sUoUghM=pJS4Yp4|l0I*O?ZZ#eM<2QL`3*SZI7j#CZ{g%0 zAwSw~%<&&!xjOO8BXMk#$Nz{CbJM>Vk39Yb z_A(EB{)xz$hd6os8$2|R|G>$o9(1Y6r5=f6n><#-$8n1JgB`&HN8;EfkJZ7BV{ZC8hIy<(>t#Lk8HLE1hd6nx2_Blq zT5$5I$J$`I)FW|hlgBz>$1ykk9m71y!`5JHlXGSp zbUDS#n5pCF(zm*vW8mbC=RG|Z?3l@a99SRuoE6)G9mgx&b|vR{IXkz9(^s2u;=2P_ z8$Y#U8P#!jL~(BN+MT=4I(he1*26Zrz5}dJo`vs3_gUq$(6cRmJHc!7J>Ic)24gPX z<6Rr$=#w$EJ7%7Tw$^YQ?XK7yV;5TE``mdKSU%64-N15+mtzUzzdOo4kn!IGT~6_e zf8ImK!y8k-%xO=sbC13k`n~AMb8mF>Jd}7DcON+UjJq#bPVvgP`Ig@gULRv8#{S@H z?i0}ElxmFR{%&~V=$Bj%06$U5bt3xETqnWFC)defIi)&Iay<}UA7ig%t$apL0Xx37 z_)P`NTfg{C16yNl?t#n6O)hgj2z(t}ayb}XPO0YNz3jNt;jN|LC;HxqVk~Wkpc_B+ zITT&aJ{eoi{dWz1o9d8*INRh<0;5j8;DP-;rQ*>BOgc|ILBdmi3?{-iqIejGZx#h1Zrb-Uqg?yzg+%>HT2G)|Rv3II#2FskGN=JtLg&5!ke+_8$P7 zxa$*1=7z;cS0v4zZO%AkFQ(O=96zCsb7EbI zu1}slSAq4BxBnHia#_Pqf?dO1XpP|-ehMs~HT*PKPVq7~VaB)?MW1VsoFO-WKU0yM zAveNhZuYw#(bwFrrq!Og-2^t@=r@D)$y{#%yC<`UKMR(-6-geqf#oB~<94{@;W)v# zqp*O?#a-wg*d_`%S+G5XZfjR$J=!1+ZN9z(Zj7z@hZh$35^xIQi^>hrx1+ zmpKTt2fl=&kFmo&0+x3@GUqRYUDMR=QLvo8sl8nM9|u=!^aOgfMqfeKM?Q7F8(io_1ETEpq_cW4EDK@v*lZ0^YUzY5&ROOkA2%|wfo+80`+Zxm_4P~`jp$=O z&QqJQtl18TT=s(g_S+rV4%r^j=ik&*Tk36%{C5vt>mvIQ+d8jT;&o`(FR|}_8z8QU z|A!?t_uaO-2K!a?iQq{?^yx$N85P|zjvJ!SujuBsprY&FRnfJdJVakn(e*#AqMP6O z72W=KS9JaFAEG}vM1Q=Z+yCi`ZvW>>-Sb9$SHzlDe{b@xlFxhLZs@L`?}gf2^POnr zv*xa?T>hQJo?touFM_{^+MCwPcW7;UA#$#tIQwuPujaiUHmOGpN`W}YJ`TX*pkb4i}`0qrt zYcrPnN?T&h1j}`o_sHR3x!7lc9V_tFTbHUYIjzX7<{b;a#lFKn*x#aR*@CHb}0geS*bNS@=K5+6gzA^Mme(wkCll;^( z{s+L-@jr+zmv7s7V8@fUzxSw|cs^|%G1l6&3z7Aa{0_0bPe9yrok+%Mmb}kq`z=B2EAJc@lw4wTqYsVI z11Fz%(q6FKiHI?}XyqK!G5QeWCWn5oK7QM{zXlMw%x`JQ$*0drVEe>p8CXtn-5qB+ zVoj~1V;V=l%u9Q6`Vcrdc`tn!sjmAeC71C&f-a||=BI*H7(=eWPCqw!U|j zv91E^Bd?G9;A&)kMgJt&`Q&dRp91S6e^VLf(_mw1^Q^y?*30~~U4vYUn4j3WCSQ5S zw*J>4u3z-)!7C8!to;T=PCskAf>uudGiYx_s_S_Z`U)iLc{5la`K;$HVEO8L-iq#e zYRh`crAD6xd*(#H4Ll!NNuJj5c0^7;`_HG9bKb7Eb+EO+YkUXdI<26;`duY=CvEnS zTy+mwKl@vkdyo|s{a&zjG#~BvA^ON0@9vUI{+~m4jiY}aJRiyaegLeGy#4Q|mDAt4 ze*vl1{ULP6NZr3!`pBp54};~ab^j8&b=Q_$9s$oslFOID`pL)VQLyWg`acHN?@`2j zAEcFw{}W*65dAAd^sl1ZCu2Sd)<-^Ld<`tW9g^qLQ($9jbKJ*i5!c)g1LGT8Wu{VlM4lIyp@a!PW21)N-sXAJ#(x6|f(|NjDaltmo? literal 17296 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 diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 328e6f7e..3376e5c7 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -186,9 +186,11 @@ public: * Normal mapping / Parallax Occlusion Mapping settings */ void setNormalMappingEnabled(bool enabled) { normalMappingEnabled_ = enabled; materialSettingsDirty_ = true; } + void setNormalMapStrength(float s) { normalMapStrength_ = s; materialSettingsDirty_ = true; } void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; materialSettingsDirty_ = true; } void setPOMQuality(int q) { pomQuality_ = q; materialSettingsDirty_ = true; } bool isNormalMappingEnabled() const { return normalMappingEnabled_; } + float getNormalMapStrength() const { return normalMapStrength_; } bool isPOMEnabled() const { return pomEnabled_; } int getPOMQuality() const { return pomQuality_; } @@ -326,7 +328,7 @@ private: float pomScale; // 32 (height scale) int32_t pomMaxSamples; // 36 (max ray-march steps) float heightMapVariance; // 40 (low variance = skip POM) - float pad; // 44 + float normalMapStrength; // 44 (0=flat, 1=full, 2=exaggerated) }; // 48 bytes total /** @@ -643,6 +645,7 @@ private: // Normal mapping / POM settings bool normalMappingEnabled_ = true; // on by default + float normalMapStrength_ = 1.0f; // 0.0 = flat, 1.0 = full, 2.0 = exaggerated bool pomEnabled_ = false; // off by default (expensive) int pomQuality_ = 1; // 0=Low(16), 1=Medium(32), 2=High(64) bool materialSettingsDirty_ = false; // rebuild UBOs when settings change diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5f269477..69450693 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -104,6 +104,7 @@ private: int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x bool pendingNormalMapping = true; // on by default + float pendingNormalMapStrength = 1.0f; // 0.0-2.0 bool pendingPOM = false; // off by default (expensive) int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64) diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index c4d8431f..2768b5df 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -601,12 +601,13 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { matData.isWindow = mb.isWindow ? 1 : 0; matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; matData.enablePOM = pomEnabled_ ? 1 : 0; - matData.pomScale = 0.03f; + matData.pomScale = 0.012f; { static const int pomSampleTable[] = { 16, 32, 64 }; matData.pomMaxSamples = pomSampleTable[std::clamp(pomQuality_, 0, 2)]; } matData.heightMapVariance = mb.heightMapVariance; + matData.normalMapStrength = normalMapStrength_; if (matBuf.info.pMappedData) { memcpy(matBuf.info.pMappedData, &matData, sizeof(matData)); } @@ -1228,9 +1229,10 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const auto* ubo = reinterpret_cast(allocInfo.pMappedData); ubo->enableNormalMap = normalMappingEnabled_ ? 1 : 0; ubo->enablePOM = pomEnabled_ ? 1 : 0; - ubo->pomScale = 0.03f; + ubo->pomScale = 0.012f; ubo->pomMaxSamples = maxSamples; ubo->heightMapVariance = mb.heightMapVariance; + ubo->normalMapStrength = normalMapStrength_; } } } @@ -2019,7 +2021,27 @@ std::unique_ptr WMORenderer::generateNormalHeightMap( double mean = sumH / totalPixels; outVariance = static_cast(sumH2 / totalPixels - mean * mean); - // Step 2: Sobel 3x3 → normal map (wrap-sampled for tiling textures) + // Step 1.5: Box blur the height map to reduce noise from diffuse textures + auto wrapSample = [&](const std::vector& map, int x, int y) -> float { + x = ((x % (int)width) + (int)width) % (int)width; + y = ((y % (int)height) + (int)height) % (int)height; + return map[y * width + x]; + }; + + std::vector blurredHeight(totalPixels); + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + int ix = static_cast(x), iy = static_cast(y); + float sum = 0.0f; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) + sum += wrapSample(heightMap, ix + dx, iy + dy); + blurredHeight[y * width + x] = sum / 9.0f; + } + } + + // Step 2: Sobel 3x3 → normal map + // Use ORIGINAL height for normals (crisp detail), blurred height for POM alpha only const float strength = 2.0f; std::vector output(totalPixels * 4); @@ -2050,7 +2072,7 @@ std::unique_ptr WMORenderer::generateNormalHeightMap( output[idx + 0] = static_cast(std::clamp((nx * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); output[idx + 1] = static_cast(std::clamp((ny * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); output[idx + 2] = static_cast(std::clamp((nz * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f)); - output[idx + 3] = static_cast(std::clamp(heightMap[y * width + x] * 255.0f, 0.0f, 255.0f)); + output[idx + 3] = static_cast(std::clamp(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f)); } } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 42392cd8..e38d1475 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -284,6 +284,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (renderer) { if (auto* wr = renderer->getWMORenderer()) { wr->setNormalMappingEnabled(pendingNormalMapping); + wr->setNormalMapStrength(pendingNormalMapStrength); wr->setPOMEnabled(pendingPOM); wr->setPOMQuality(pendingPOMQuality); normalMapSettingsApplied_ = true; @@ -5916,6 +5917,16 @@ void GameScreen::renderSettingsWindow() { } saveSettings(); } + if (pendingNormalMapping) { + if (ImGui::SliderFloat("Normal Map Strength", &pendingNormalMapStrength, 0.0f, 2.0f, "%.1f")) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMapStrength(pendingNormalMapStrength); + } + } + saveSettings(); + } + } if (ImGui::Checkbox("Parallax Mapping", &pendingPOM)) { if (renderer) { if (auto* wr = renderer->getWMORenderer()) { @@ -5959,6 +5970,7 @@ void GameScreen::renderSettingsWindow() { pendingGroundClutterDensity = kDefaultGroundClutterDensity; pendingAntiAliasing = 0; pendingNormalMapping = true; + pendingNormalMapStrength = 1.0f; pendingPOM = false; pendingPOMQuality = 1; pendingResIndex = defaultResIndex; @@ -5975,6 +5987,7 @@ void GameScreen::renderSettingsWindow() { if (renderer) { if (auto* wr = renderer->getWMORenderer()) { wr->setNormalMappingEnabled(pendingNormalMapping); + wr->setNormalMapStrength(pendingNormalMapStrength); wr->setPOMEnabled(pendingPOM); wr->setPOMQuality(pendingPOMQuality); } @@ -6906,6 +6919,7 @@ void GameScreen::saveSettings() { out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; + out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; out << "pom_quality=" << pendingPOMQuality << "\n"; @@ -6987,6 +7001,7 @@ void GameScreen::loadSettings() { else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); + else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); else if (key == "pom") pendingPOM = (std::stoi(val) != 0); else if (key == "pom_quality") pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); // Controls