From 9eeb9ce64d632d9026f7651075db9ead2b9da04c Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 23 Feb 2026 01:40:23 -0800 Subject: [PATCH] Add normal mapping and parallax occlusion mapping for character models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the WMO normal mapping/POM system to character M2 models with bone-skinned tangents. Auto-generates normal/height maps from diffuse textures using luminance→height, Sobel→normals (same algorithm as WMO). - Expand vertex buffer from M2Vertex (48B) to CharVertexGPU (56B) with tangent vec4 computed via Lengyel's method in setupModelBuffers() - Tangents are bone-transformed and Gram-Schmidt orthogonalized in the vertex shader, output as TBN for fragment shader consumption - Fragment shader gains POM ray marching, normal map blending, and LOD crossfade via dFdx/dFdy (identical to WMO shader) - Descriptor set 1 extended with binding 2 for normal/height sampler - Settings (enable, strength, POM quality) wired from game_screen.cpp to both WMO and character renderers via shared UI controls --- assets/shaders/character.frag.glsl | 93 ++++++- assets/shaders/character.frag.spv | Bin 7536 -> 16048 bytes assets/shaders/character.vert.glsl | 16 +- assets/shaders/character.vert.spv | Bin 5060 -> 6184 bytes include/rendering/character_renderer.hpp | 19 ++ src/rendering/character_renderer.cpp | 308 ++++++++++++++++++++--- src/ui/game_screen.cpp | 24 ++ 7 files changed, 420 insertions(+), 40 deletions(-) diff --git a/assets/shaders/character.frag.glsl b/assets/shaders/character.frag.glsl index 758c7d2a..b096ce76 100644 --- a/assets/shaders/character.frag.glsl +++ b/assets/shaders/character.frag.glsl @@ -23,18 +23,96 @@ layout(set = 1, binding = 1) uniform CharMaterial { float emissiveBoost; vec3 emissiveTint; float specularIntensity; + int enableNormalMap; + int enablePOM; + float pomScale; + int pomMaxSamples; + float heightMapVariance; + float normalMapStrength; }; +layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap; + layout(set = 0, binding = 1) uniform sampler2DShadow uShadowMap; layout(location = 0) in vec3 FragPos; layout(location = 1) in vec3 Normal; layout(location = 2) in vec2 TexCoord; +layout(location = 3) in vec3 Tangent; +layout(location = 4) in vec3 Bitangent; layout(location = 0) out vec4 outColor; +// LOD factor from screen-space UV derivatives +float computeLodFactor() { + vec2 dx = dFdx(TexCoord); + vec2 dy = dFdy(TexCoord); + float texelDensity = max(dot(dx, dx), dot(dy, dy)); + return smoothstep(0.0001, 0.005, texelDensity); +} + +// Parallax Occlusion Mapping with angle-adaptive sampling +vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float lodFactor) { + float VdotN = abs(viewDirTS.z); + + if (VdotN < 0.15) return uv; + + float angleFactor = clamp(VdotN, 0.15, 1.0); + int maxS = pomMaxSamples; + int minS = max(maxS / 4, 4); + int numSamples = int(mix(float(minS), float(maxS), angleFactor)); + numSamples = int(mix(float(minS), float(numSamples), 1.0 - lodFactor)); + + float layerDepth = 1.0 / float(numSamples); + float currentLayerDepth = 0.0; + + vec2 P = viewDirTS.xy / max(VdotN, 0.15) * pomScale; + float maxOffset = pomScale * 3.0; + P = clamp(P, vec2(-maxOffset), vec2(maxOffset)); + vec2 deltaUV = P / float(numSamples); + + vec2 currentUV = uv; + float currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + + for (int i = 0; i < 64; i++) { + if (i >= numSamples || currentLayerDepth >= currentDepthMapValue) break; + currentUV -= deltaUV; + currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + currentLayerDepth += layerDepth; + } + + vec2 prevUV = currentUV + deltaUV; + float afterDepth = currentDepthMapValue - currentLayerDepth; + float beforeDepth = (1.0 - texture(uNormalHeightMap, prevUV).a) - currentLayerDepth + layerDepth; + float weight = afterDepth / (afterDepth - beforeDepth + 0.0001); + vec2 result = mix(currentUV, prevUV, weight); + + float fadeFactor = smoothstep(0.15, 0.35, VdotN); + return mix(uv, result, fadeFactor); +} + void main() { - vec4 texColor = texture(uTexture, TexCoord); + float lodFactor = computeLodFactor(); + + vec3 vertexNormal = normalize(Normal); + if (!gl_FrontFacing) vertexNormal = -vertexNormal; + + vec2 finalUV = TexCoord; + + // Build TBN matrix + vec3 T = normalize(Tangent); + vec3 B = normalize(Bitangent); + vec3 N = vertexNormal; + mat3 TBN = mat3(T, B, N); + + if (enablePOM != 0 && heightMapVariance > 0.001 && lodFactor < 0.99) { + mat3 TBN_inv = transpose(TBN); + vec3 viewDirWorld = normalize(viewPos.xyz - FragPos); + vec3 viewDirTS = TBN_inv * viewDirWorld; + finalUV = parallaxOcclusionMap(TexCoord, viewDirTS, lodFactor); + } + + vec4 texColor = texture(uTexture, finalUV); if (alphaTest != 0 && texColor.a < 0.5) discard; if (colorKeyBlack != 0) { @@ -44,8 +122,17 @@ void main() { if (texColor.a < 0.01) discard; } - vec3 norm = normalize(Normal); - if (!gl_FrontFacing) norm = -norm; + // Compute normal (with normal mapping if enabled) + vec3 norm = vertexNormal; + if (enableNormalMap != 0 && lodFactor < 0.99 && normalMapStrength > 0.001) { + vec3 mapNormal = texture(uNormalHeightMap, finalUV).rgb * 2.0 - 1.0; + mapNormal.xy *= normalMapStrength; + mapNormal = normalize(mapNormal); + vec3 worldNormal = normalize(TBN * mapNormal); + if (!gl_FrontFacing) worldNormal = -worldNormal; + float blendFactor = max(lodFactor, 1.0 - normalMapStrength); + norm = normalize(mix(worldNormal, vertexNormal, blendFactor)); + } vec3 result; diff --git a/assets/shaders/character.frag.spv b/assets/shaders/character.frag.spv index 39a376c7421c87c31553f67fb5ddb6dcb1825dae..99c68edf24ddec7f733c836d9232807899737158 100644 GIT binary patch literal 16048 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 K?J0JA2KW!OC%{es diff --git a/assets/shaders/character.vert.glsl b/assets/shaders/character.vert.glsl index 471025dc..52e4ee65 100644 --- a/assets/shaders/character.vert.glsl +++ b/assets/shaders/character.vert.glsl @@ -26,10 +26,13 @@ layout(location = 1) in vec4 aBoneWeights; layout(location = 2) in ivec4 aBoneIndices; layout(location = 3) in vec3 aNormal; layout(location = 4) in vec2 aTexCoord; +layout(location = 5) in vec4 aTangent; layout(location = 0) out vec3 FragPos; layout(location = 1) out vec3 Normal; layout(location = 2) out vec2 TexCoord; +layout(location = 3) out vec3 Tangent; +layout(location = 4) out vec3 Bitangent; void main() { mat4 skinMat = bones[aBoneIndices.x] * aBoneWeights.x @@ -39,11 +42,22 @@ void main() { vec4 skinnedPos = skinMat * vec4(aPos, 1.0); vec3 skinnedNorm = mat3(skinMat) * aNormal; + vec3 skinnedTan = mat3(skinMat) * aTangent.xyz; vec4 worldPos = push.model * skinnedPos; + mat3 modelMat3 = mat3(push.model); FragPos = worldPos.xyz; - Normal = mat3(push.model) * skinnedNorm; + Normal = modelMat3 * skinnedNorm; TexCoord = aTexCoord; + // Gram-Schmidt re-orthogonalize tangent w.r.t. normal + vec3 N = normalize(Normal); + vec3 T = normalize(modelMat3 * skinnedTan); + T = normalize(T - dot(T, N) * N); + vec3 B = cross(N, T) * aTangent.w; + + Tangent = T; + Bitangent = B; + gl_Position = projection * view * worldPos; } diff --git a/assets/shaders/character.vert.spv b/assets/shaders/character.vert.spv index 3836081c4aa8cc819db127cbf0e9a0dc6af71206..cbe4916d0bab60db6a75ce255d5f381f2dbc4fbd 100644 GIT binary patch literal 6184 zcmZ{m>33aa6^Boo1Y#+rh)har+B%~ZWe`Q6O$#X+NH7(g!%gmOa^U9Pcyl}9KoP69 z77&WFR;psfYMnPD{J{?{qpYp+rzu}JDj9v>AJJC zY)N)bc5e31tQ_ZOOJOp&vx<3Wcx-t6T(`dds;jTiV@1|eIO=m=c77rE@_VM1w^S|w ztH3s}8|(##z+rF%JPA&Kli)Py;b*BF3460;+0gL#(8$Qp@J-{Bv$NxK-QCT`cr$M` z#_M^fG1<*`H1zAQ_~ot1=JtBkp%0I0P3p^7WA2{3b!)Ag_3^VDeX!kXjExPBAkuBg zDNn%9G51m13JFFZ9`;r<=$2MJpDbJo-hK=I&PF~p-8B@xFPmU?%~qp6+J?0b_oY^7 zrB8do=eBldrtop!YHGD+k>~vjK3i+-vbo*Y8roE&)kQ40eat)Coo2aOx!!)b(d~27 z`q-noW2RkiG?}gG0P}3}G55i+vsqUrAJ6dSPHoE4jqyBFiRgXB*=}v@+SG1$>X-`7 zv#wQ0_w%T6g}o;`#=JGjPcaV`a&K`zqBV1SR|fO$e6ya#DY&WT_-LbZN2Aki>_UYt zd1Yq=&z6Nq^7U8c!rWYV?hS-90;n?r{Gd!I@vqIXKslGgolR za^-qGMLzvJGw+ro`VYO@e{j)%#JO%YPTjZ-z_pE&TO8-w)i`zI_JD|!TZk)j*@u3| zLHOB|+%px<+;ab_aNb+Fe^)s3%UNXP^L@yztZ=>!xdAxut9@CYXXpD^3ml=ZDOK+3 z(!y^IF+Fg`T!dsT1K?uftwG&BF5$Q9%c&3W+qKo@R}t$uDz8TN-Kgt#F0=XNu1mUa zN$x`Q^Oy&~5^#CaeSZ$?@UOow`^_4fDx^GX;e9vPZ01or1Us!1Jz4Tf7mh1Lr zm!eyXwQIjD+3n5z>b_<3Yxn*uyEl=4ZQ??I9lAL~A4Ybcjju7ATi9Qn*<4|7Ae&#_ z_0_i}&V2e#gDl%~qxtx>7rFHJoZic^ZgOK zJsM{|^)rd{OudUw1N+ebS!UNa=QVthjO_&u`TxN5F`LWtS_br0uQ5+lbni!dk?#L` zRsVez-7`H{(TzX4NI$kn_x~#MZKNvS*Tuj$>ASn0n(WWNz49B`ZQtDU!3yA8*QTF) z`U0TMT)wpzf>j`Lc~%2J{$YHKSp$q408zs_WNYxOqlOnD%SR0_MvfY^MGY@O))qCq z6xkXgmo>Z$$VUw?M>bCWcIt9JUIDDl5!~g-`a14Uey>EdGUv2qdn zYGi%2MeG&G#(HmpdkwO&auNGlWPP=T-<8P5de4Hp3fWk>h`kzFUu~mw>p5Hl?8TbQ zt#5xW^#Uv1%c6SA@HeQ-A;8!H#FL&*AS z3%@PM##Ybc7Ib5)=kX?VeYM4TycyYEtjXN^xQ7=p-wHgBVG!qWTjJw9M$qFtw8eRh zB5O0(YUbO4=MlL)kGCW~&SMPSxRNj5$5wP}bVR>zMb_65`*H`ezS_d?PGn=N=kYdl zW2@)!c65EUMeI9}jjf)?JJF4;p2xe;_0<-!??yJZdLHjVH@12ndWd$GRpG7j?~|TUYEw2U*TB!WY0==YYI$SWk7IyXeNqN6dC)dEr~<&FB}mQd;bMEYcaRJ_IQw4 zKKA5G$o3s|JdP|U9Ff!5L+FnKW4+H`2If<*Gk*nmSL4jSimZ>k@rRk^^nZf+8{kQx z?MTui*EiAiU(VYx_qTwx__s)#F-Mu@qn>XgSL^u>y7wgN`7W|P@{#v@$dOmOd5V2F6?eaUdVH{u3fSW&RU51D*!@_cQ+)$cN7}$oi}V;qw<{`A5lP%wK_V m^7{C;{|3tUT)y?cqw6Pc?q`|*0Zs#RTc5gq|7EwX1^)xwhB4Iu delta 1823 zcmZ9LyH6BR6vprD>;ps;+!#X86{N7RPzn+ggNZ?fM#aQNEzAllu2~qefLfpfzVU&; z_!dF%janG~02@0S6SXwa!qP~s??2;LB?3yhh6WVRcnBZM(b*ns2&mrh=cbI%g^Shm{+FAQLnortF8M(C3!9JI%hUG5dYw}YjSqF3B}vhne`jx z^ObVCMpGrU>4fNFp}jc$jBLI()nxkkoDHAp<6AwGm!DB+aRWQ<3i*6&Iu-m*qO9m+P2%i>Kh4X6 z%$Vi6oG;5ZH9e9?Z;TEZ{;UAEQT|G;a{}8_c0OpXIF!iB8(R2L@hRzV*WC}x9T6BD zf0-5u8Q56?9-ki12}gtp<=~$e(7Yv`MOE^Ma6FC^2wun~LjR_0wD6pWcLax?=Wt)(DG=ky;B(qqt&fFSVNO8L_@4;k z{Uy;;5r|6f=$U-@NfjdLxj-Vky@waFxd(f1FJ+^Hr-6B`uY_rV+BArd?vF`2W{c+c E7l3ZuzyJUM diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 6505ac76..01539ee6 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -99,6 +99,12 @@ public: size_t getInstanceCount() const { return instances.size(); } + // Normal mapping / POM settings + void setNormalMappingEnabled(bool enabled) { normalMappingEnabled_ = enabled; } + void setNormalMapStrength(float strength) { normalMapStrength_ = strength; } + void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; } + void setPOMQuality(int quality) { pomQuality_ = quality; } + // Fog/lighting/shadow are now in per-frame UBO — keep stubs for callers that haven't been updated void setFog(const glm::vec3&, float, float) {} void setLighting(const float[3], const float[3], const float[3]) {} @@ -247,6 +253,8 @@ private: // Texture cache struct TextureCacheEntry { std::unique_ptr texture; + std::unique_ptr normalHeightMap; + float heightMapVariance = 0.0f; size_t approxBytes = 0; uint64_t lastUse = 0; bool hasAlpha = false; @@ -263,12 +271,23 @@ private: uint32_t textureBudgetRejectWarnings_ = 0; std::unique_ptr whiteTexture_; std::unique_ptr transparentTexture_; + std::unique_ptr flatNormalTexture_; std::unordered_map models; std::unordered_map instances; uint32_t nextInstanceId = 1; + // Normal map generation (same algorithm as WMO renderer) + std::unique_ptr generateNormalHeightMap( + const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance); + + // Normal mapping / POM settings + bool normalMappingEnabled_ = true; + float normalMapStrength_ = 0.8f; + bool pomEnabled_ = true; + int pomQuality_ = 1; // 0=Low(16), 1=Medium(32), 2=High(64) + // Maximum bones supported static constexpr int MAX_BONES = 240; uint32_t numAnimThreads_ = 1; diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index b6198113..ccf81d25 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -85,9 +85,25 @@ struct CharMaterialUBO { float emissiveBoost; float emissiveTintR, emissiveTintG, emissiveTintB; float specularIntensity; - float _pad[3]; // pad to 48 bytes + int32_t enableNormalMap; + int32_t enablePOM; + float pomScale; + int32_t pomMaxSamples; + float heightMapVariance; + float normalMapStrength; + float _pad[2]; // pad to 64 bytes }; +// GPU vertex struct with tangent (expanded from M2Vertex for normal mapping) +struct CharVertexGPU { + glm::vec3 position; // 12 bytes, offset 0 + uint8_t boneWeights[4]; // 4 bytes, offset 12 + uint8_t boneIndices[4]; // 4 bytes, offset 16 + glm::vec3 normal; // 12 bytes, offset 20 + glm::vec2 texCoords; // 8 bytes, offset 32 + glm::vec4 tangent; // 16 bytes, offset 40 (xyz=dir, w=handedness) +}; // 56 bytes total + CharacterRenderer::CharacterRenderer() { } @@ -116,9 +132,9 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram // --- Descriptor set layouts --- - // Material set layout (set 1): binding 0 = sampler2D, binding 1 = CharMaterial UBO + // Material set layout (set 1): binding 0 = sampler2D, binding 1 = CharMaterial UBO, binding 2 = normal/height map { - VkDescriptorSetLayoutBinding bindings[2] = {}; + VkDescriptorSetLayoutBinding bindings[3] = {}; bindings[0].binding = 0; bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; bindings[0].descriptorCount = 1; @@ -127,9 +143,13 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram bindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; bindings[1].descriptorCount = 1; bindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + bindings[2].binding = 2; + bindings[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + bindings[2].descriptorCount = 1; + bindings[2].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; - ci.bindingCount = 2; + ci.bindingCount = 3; ci.pBindings = bindings; vkCreateDescriptorSetLayout(device, &ci, nullptr, &materialSetLayout_); } @@ -153,7 +173,7 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram // pools so we can reset safely each frame slot without exhausting descriptors. for (int i = 0; i < 2; i++) { VkDescriptorPoolSize sizes[] = { - {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_MATERIAL_SETS}, + {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_MATERIAL_SETS * 2}, // diffuse + normal/height {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, MAX_MATERIAL_SETS}, }; VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; @@ -207,19 +227,20 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram VkSampleCountFlagBits samples = renderPassOverride_ ? VK_SAMPLE_COUNT_1_BIT : vkCtx_->getMsaaSamples(); // --- Vertex input --- - // M2Vertex: vec3 pos(12) + uint8[4] boneWeights(4) + uint8[4] boneIndices(4) + - // vec3 normal(12) + vec2[2] texCoords(16) = 48 bytes + // CharVertexGPU: vec3 pos(12) + uint8[4] boneWeights(4) + uint8[4] boneIndices(4) + + // vec3 normal(12) + vec2 texCoords(8) + vec4 tangent(16) = 56 bytes VkVertexInputBindingDescription charBinding{}; charBinding.binding = 0; - charBinding.stride = sizeof(pipeline::M2Vertex); + charBinding.stride = sizeof(CharVertexGPU); charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; std::vector charAttrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, position))}, - {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(pipeline::M2Vertex, boneWeights))}, - {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(pipeline::M2Vertex, boneIndices))}, - {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, normal))}, - {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, texCoords))}, + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, position))}, + {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(CharVertexGPU, boneWeights))}, + {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(CharVertexGPU, boneIndices))}, + {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, normal))}, + {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(CharVertexGPU, texCoords))}, + {5, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast(offsetof(CharVertexGPU, tangent))}, }; // --- Build pipelines --- @@ -264,6 +285,14 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram transparentTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT); } + // --- Create flat normal placeholder texture (128,128,255,128) = neutral normal, 0.5 height --- + { + uint8_t flatNormal[] = {128, 128, 255, 128}; + flatNormalTexture_ = std::make_unique(); + flatNormalTexture_->upload(*vkCtx_, flatNormal, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + flatNormalTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT); + } + // Diagnostics-only: cache lifetime is currently tied to renderer lifetime. textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 1024) * 1024ull * 1024ull; LOG_INFO("Character texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB"); @@ -305,6 +334,7 @@ void CharacterRenderer::shutdown() { whiteTexture_.reset(); transparentTexture_.reset(); + flatNormalTexture_.reset(); models.clear(); instances.clear(); @@ -376,6 +406,88 @@ void CharacterRenderer::destroyInstanceBones(CharacterInstance& inst) { } } +std::unique_ptr CharacterRenderer::generateNormalHeightMap( + const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance) { + if (!vkCtx_ || width == 0 || height == 0) return nullptr; + + const uint32_t totalPixels = width * height; + + // Step 1: Compute height from luminance + std::vector heightMap(totalPixels); + double sumH = 0.0, sumH2 = 0.0; + for (uint32_t i = 0; i < totalPixels; i++) { + float r = pixels[i * 4 + 0] / 255.0f; + float g = pixels[i * 4 + 1] / 255.0f; + float b = pixels[i * 4 + 2] / 255.0f; + float h = 0.299f * r + 0.587f * g + 0.114f * b; + heightMap[i] = h; + sumH += h; + sumH2 += h * h; + } + double mean = sumH / totalPixels; + outVariance = static_cast(sumH2 / totalPixels - mean * mean); + + // 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 (crisp detail from original, blurred for POM alpha) + const float strength = 2.0f; + std::vector output(totalPixels * 4); + + auto sampleH = [&](int x, int y) -> float { + x = ((x % (int)width) + (int)width) % (int)width; + y = ((y % (int)height) + (int)height) % (int)height; + return heightMap[y * width + x]; + }; + + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + int ix = static_cast(x); + int iy = static_cast(y); + float gx = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix-1, iy) - sampleH(ix-1, iy+1) + + sampleH(ix+1, iy-1) + 2.0f*sampleH(ix+1, iy) + sampleH(ix+1, iy+1); + float gy = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix, iy-1) - sampleH(ix+1, iy-1) + + sampleH(ix-1, iy+1) + 2.0f*sampleH(ix, iy+1) + sampleH(ix+1, iy+1); + + float nx = -gx * strength; + float ny = -gy * strength; + float nz = 1.0f; + float len = std::sqrt(nx*nx + ny*ny + nz*nz); + if (len > 0.0f) { nx /= len; ny /= len; nz /= len; } + + uint32_t idx = (y * width + x) * 4; + 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(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f)); + } + } + + auto tex = std::make_unique(); + if (!tex->upload(*vkCtx_, output.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true)) { + return nullptr; + } + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); + return tex; +} + VkTexture* CharacterRenderer::loadTexture(const std::string& path) { // Skip empty or whitespace-only paths (type-0 textures have no filename) if (path.empty()) return whiteTexture_.get(); @@ -467,6 +579,16 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { e.lastUse = ++textureCacheCounter_; e.hasAlpha = hasAlpha; e.colorKeyBlack = colorKeyBlackHint; + + // Generate normal/height map from diffuse texture + float nhVariance = 0.0f; + auto nhMap = generateNormalHeightMap(blpImage.data.data(), blpImage.width, blpImage.height, nhVariance); + if (nhMap) { + e.heightMapVariance = nhVariance; + e.approxBytes += approxTextureBytesWithMips(blpImage.width, blpImage.height); + e.normalHeightMap = std::move(nhMap); + } + textureCacheBytes_ += e.approxBytes; textureHasAlphaByPtr_[texPtr] = hasAlpha; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; @@ -1018,23 +1140,85 @@ void CharacterRenderer::setupModelBuffers(M2ModelGPU& gpuModel) { if (model.vertices.empty() || model.indices.empty()) return; - // Upload vertex buffer + const size_t vertCount = model.vertices.size(); + const size_t idxCount = model.indices.size(); + + // Build expanded GPU vertex buffer with tangents (Lengyel's method) + std::vector gpuVerts(vertCount); + std::vector tanAccum(vertCount, glm::vec3(0.0f)); + std::vector bitanAccum(vertCount, glm::vec3(0.0f)); + + // Copy base vertex data + for (size_t i = 0; i < vertCount; i++) { + const auto& src = model.vertices[i]; + auto& dst = gpuVerts[i]; + dst.position = src.position; + std::memcpy(dst.boneWeights, src.boneWeights, 4); + std::memcpy(dst.boneIndices, src.boneIndices, 4); + dst.normal = src.normal; + dst.texCoords = src.texCoords[0]; // Use first UV set + dst.tangent = glm::vec4(1.0f, 0.0f, 0.0f, 1.0f); // default + } + + // Accumulate tangent/bitangent per triangle + for (size_t i = 0; i + 2 < idxCount; i += 3) { + uint16_t i0 = model.indices[i], i1 = model.indices[i+1], i2 = model.indices[i+2]; + if (i0 >= vertCount || i1 >= vertCount || i2 >= vertCount) continue; + + const glm::vec3& p0 = gpuVerts[i0].position; + const glm::vec3& p1 = gpuVerts[i1].position; + const glm::vec3& p2 = gpuVerts[i2].position; + const glm::vec2& uv0 = gpuVerts[i0].texCoords; + const glm::vec2& uv1 = gpuVerts[i1].texCoords; + const glm::vec2& uv2 = gpuVerts[i2].texCoords; + + glm::vec3 edge1 = p1 - p0; + glm::vec3 edge2 = p2 - p0; + glm::vec2 duv1 = uv1 - uv0; + glm::vec2 duv2 = uv2 - uv0; + + float det = duv1.x * duv2.y - duv2.x * duv1.y; + if (std::abs(det) < 1e-8f) continue; + float invDet = 1.0f / det; + + glm::vec3 t = (edge1 * duv2.y - edge2 * duv1.y) * invDet; + glm::vec3 b = (edge2 * duv1.x - edge1 * duv2.x) * invDet; + + tanAccum[i0] += t; tanAccum[i1] += t; tanAccum[i2] += t; + bitanAccum[i0] += b; bitanAccum[i1] += b; bitanAccum[i2] += b; + } + + // Orthogonalize and compute handedness + for (size_t i = 0; i < vertCount; i++) { + const glm::vec3& n = gpuVerts[i].normal; + const glm::vec3& t = tanAccum[i]; + if (glm::dot(t, t) < 1e-8f) { + gpuVerts[i].tangent = glm::vec4(1.0f, 0.0f, 0.0f, 1.0f); + continue; + } + // Gram-Schmidt orthogonalize + glm::vec3 tOrtho = glm::normalize(t - n * glm::dot(n, t)); + float w = (glm::dot(glm::cross(n, t), bitanAccum[i]) < 0.0f) ? -1.0f : 1.0f; + gpuVerts[i].tangent = glm::vec4(tOrtho, w); + } + + // Upload vertex buffer (CharVertexGPU, 56 bytes per vertex) auto vb = uploadBuffer(*vkCtx_, - model.vertices.data(), - model.vertices.size() * sizeof(pipeline::M2Vertex), + gpuVerts.data(), + gpuVerts.size() * sizeof(CharVertexGPU), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); gpuModel.vertexBuffer = vb.buffer; gpuModel.vertexAlloc = vb.allocation; - gpuModel.vertexCount = static_cast(model.vertices.size()); + gpuModel.vertexCount = static_cast(vertCount); // Upload index buffer auto ib = uploadBuffer(*vkCtx_, model.indices.data(), - model.indices.size() * sizeof(uint16_t), + idxCount * sizeof(uint16_t), VK_BUFFER_USAGE_INDEX_BUFFER_BIT); gpuModel.indexBuffer = ib.buffer; gpuModel.indexAlloc = ib.allocation; - gpuModel.indexCount = static_cast(model.indices.size()); + gpuModel.indexCount = static_cast(idxCount); } void CharacterRenderer::calculateBindPose(M2ModelGPU& gpuModel) { @@ -1809,6 +1993,24 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, } } + // Resolve normal/height map for this texture + VkTexture* normalMap = flatNormalTexture_.get(); + float batchHeightVariance = 0.0f; + if (texPtr && texPtr != whiteTexture_.get()) { + for (const auto& ce : textureCache) { + if (ce.second.texture.get() == texPtr && ce.second.normalHeightMap) { + normalMap = ce.second.normalHeightMap.get(); + batchHeightVariance = ce.second.heightMapVariance; + break; + } + } + } + + // POM quality → sample count + int pomSamples = 32; + if (pomQuality_ == 0) pomSamples = 16; + else if (pomQuality_ == 2) pomSamples = 64; + // Create per-batch material UBO CharMaterialUBO matData{}; matData.opacity = instance.opacity; @@ -1820,6 +2022,12 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, matData.emissiveTintG = emissiveTint.g; matData.emissiveTintB = emissiveTint.b; matData.specularIntensity = 0.5f; + matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; + matData.enablePOM = pomEnabled_ ? 1 : 0; + matData.pomScale = 0.03f; + matData.pomMaxSamples = pomSamples; + matData.heightMapVariance = batchHeightVariance; + matData.normalMapStrength = normalMapStrength_; // Create a small UBO for this batch's material VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; @@ -1836,15 +2044,16 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, memcpy(allocInfo.pMappedData, &matData, sizeof(CharMaterialUBO)); } - // Write descriptor set: binding 0 = texture, binding 1 = material UBO + // Write descriptor set: binding 0 = texture, binding 1 = material UBO, binding 2 = normal/height map VkTexture* bindTex = (texPtr && texPtr->isValid()) ? texPtr : whiteTexture_.get(); VkDescriptorImageInfo imgInfo = bindTex->descriptorInfo(); VkDescriptorBufferInfo bufInfo{}; bufInfo.buffer = matUBO; bufInfo.offset = 0; bufInfo.range = sizeof(CharMaterialUBO); + VkDescriptorImageInfo nhImgInfo = normalMap->descriptorInfo(); - VkWriteDescriptorSet writes[2] = {}; + VkWriteDescriptorSet writes[3] = {}; writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[0].dstSet = materialSet; writes[0].dstBinding = 0; @@ -1859,7 +2068,14 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; writes[1].pBufferInfo = &bufInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 2, writes, 0, nullptr); + writes[2].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[2].dstSet = materialSet; + writes[2].dstBinding = 2; + writes[2].descriptorCount = 1; + writes[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[2].pImageInfo = &nhImgInfo; + + vkUpdateDescriptorSets(vkCtx_->getDevice(), 3, writes, 0, nullptr); // Bind material descriptor set (set 1) vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, @@ -1886,6 +2102,11 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, } } + // POM quality → sample count + int pomSamples2 = 32; + if (pomQuality_ == 0) pomSamples2 = 16; + else if (pomQuality_ == 2) pomSamples2 = 64; + CharMaterialUBO matData{}; matData.opacity = instance.opacity; matData.alphaTest = 0; @@ -1896,6 +2117,12 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, matData.emissiveTintG = 1.0f; matData.emissiveTintB = 1.0f; matData.specularIntensity = 0.5f; + matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; + matData.enablePOM = pomEnabled_ ? 1 : 0; + matData.pomScale = 0.03f; + matData.pomMaxSamples = pomSamples2; + matData.heightMapVariance = 0.0f; + matData.normalMapStrength = normalMapStrength_; VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; bci.size = sizeof(CharMaterialUBO); @@ -1916,8 +2143,9 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, bufInfo.buffer = matUBO; bufInfo.offset = 0; bufInfo.range = sizeof(CharMaterialUBO); + VkDescriptorImageInfo nhImgInfo2 = flatNormalTexture_->descriptorInfo(); - VkWriteDescriptorSet writes[2] = {}; + VkWriteDescriptorSet writes[3] = {}; writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[0].dstSet = materialSet; writes[0].dstBinding = 0; @@ -1932,7 +2160,14 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; writes[1].pBufferInfo = &bufInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 2, writes, 0, nullptr); + writes[2].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[2].dstSet = materialSet; + writes[2].dstBinding = 2; + writes[2].descriptorCount = 1; + writes[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[2].pImageInfo = &nhImgInfo2; + + vkUpdateDescriptorSets(vkCtx_->getDevice(), 3, writes, 0, nullptr); vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, 1, 1, &materialSet, 0, nullptr); @@ -2066,20 +2301,20 @@ bool CharacterRenderer::initializeShadow(VkRenderPass shadowRenderPass) { return false; } - // Character vertex format (M2Vertex): stride = 48 bytes + // Character vertex format (CharVertexGPU): stride = 56 bytes // loc 0: vec3 aPos (R32G32B32_SFLOAT, offset 0) // loc 1: vec4 aBoneWeights (R8G8B8A8_UNORM, offset 12) // loc 2: ivec4 aBoneIndices (R8G8B8A8_UINT, offset 16) // loc 3: vec2 aTexCoord (R32G32_SFLOAT, offset 32) VkVertexInputBindingDescription vertBind{}; vertBind.binding = 0; - vertBind.stride = static_cast(sizeof(pipeline::M2Vertex)); + vertBind.stride = static_cast(sizeof(CharVertexGPU)); vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; std::vector vertAttrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, position))}, - {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(pipeline::M2Vertex, boneWeights))}, - {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(pipeline::M2Vertex, boneIndices))}, - {3, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, texCoords))}, + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, position))}, + {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(CharVertexGPU, boneWeights))}, + {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(CharVertexGPU, boneIndices))}, + {3, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(CharVertexGPU, texCoords))}, }; shadowPipeline_ = PipelineBuilder() @@ -2755,15 +2990,16 @@ void CharacterRenderer::recreatePipelines() { // --- Vertex input --- VkVertexInputBindingDescription charBinding{}; charBinding.binding = 0; - charBinding.stride = sizeof(pipeline::M2Vertex); + charBinding.stride = sizeof(CharVertexGPU); charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; std::vector charAttrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, position))}, - {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(pipeline::M2Vertex, boneWeights))}, - {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(pipeline::M2Vertex, boneIndices))}, - {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, normal))}, - {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(pipeline::M2Vertex, texCoords))}, + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, position))}, + {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast(offsetof(CharVertexGPU, boneWeights))}, + {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast(offsetof(CharVertexGPU, boneIndices))}, + {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(CharVertexGPU, normal))}, + {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast(offsetof(CharVertexGPU, texCoords))}, + {5, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast(offsetof(CharVertexGPU, tangent))}, }; auto buildCharPipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 65dd8d2d..4b420721 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -287,6 +287,12 @@ void GameScreen::render(game::GameHandler& gameHandler) { wr->setNormalMapStrength(pendingNormalMapStrength); wr->setPOMEnabled(pendingPOM); wr->setPOMQuality(pendingPOMQuality); + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + cr->setNormalMapStrength(pendingNormalMapStrength); + cr->setPOMEnabled(pendingPOM); + cr->setPOMQuality(pendingPOMQuality); + } normalMapSettingsApplied_ = true; } } @@ -5914,6 +5920,9 @@ void GameScreen::renderSettingsWindow() { if (auto* wr = renderer->getWMORenderer()) { wr->setNormalMappingEnabled(pendingNormalMapping); } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + } } saveSettings(); } @@ -5923,6 +5932,9 @@ void GameScreen::renderSettingsWindow() { if (auto* wr = renderer->getWMORenderer()) { wr->setNormalMapStrength(pendingNormalMapStrength); } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMapStrength(pendingNormalMapStrength); + } } saveSettings(); } @@ -5932,6 +5944,9 @@ void GameScreen::renderSettingsWindow() { if (auto* wr = renderer->getWMORenderer()) { wr->setPOMEnabled(pendingPOM); } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setPOMEnabled(pendingPOM); + } } saveSettings(); } @@ -5942,6 +5957,9 @@ void GameScreen::renderSettingsWindow() { if (auto* wr = renderer->getWMORenderer()) { wr->setPOMQuality(pendingPOMQuality); } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setPOMQuality(pendingPOMQuality); + } } saveSettings(); } @@ -5991,6 +6009,12 @@ void GameScreen::renderSettingsWindow() { wr->setPOMEnabled(pendingPOM); wr->setPOMQuality(pendingPOMQuality); } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(pendingNormalMapping); + cr->setNormalMapStrength(pendingNormalMapStrength); + cr->setPOMEnabled(pendingPOM); + cr->setPOMQuality(pendingPOMQuality); + } } saveSettings(); }