From eaceb58e774d4cb84ded81bc8f719e35a663785e Mon Sep 17 00:00:00 2001 From: Kelsi Date: Mon, 23 Feb 2026 01:10:58 -0800 Subject: [PATCH] Add normal mapping and parallax occlusion mapping for WMO surfaces Generate normal+height maps from diffuse textures at load time using luminance-to-height and Sobel 3x3 filtering. Compute per-vertex tangents via Lengyel's method for TBN basis construction. Fragment shader uses screen-space UV derivatives (dFdx/dFdy) for smooth LOD crossfade and angle-adaptive POM sample counts. Flat textures naturally produce low height variance, causing POM to self-select off. Settings: Normal Mapping on by default, POM off by default with Low/Medium/High quality presets. Persisted across sessions. --- assets/shaders/wmo.frag.glsl | 97 ++++++++++-- assets/shaders/wmo.frag.spv | Bin 10820 -> 17296 bytes assets/shaders/wmo.vert.glsl | 18 ++- assets/shaders/wmo.vert.spv | Bin 2936 -> 3832 bytes include/rendering/wmo_renderer.hpp | 54 ++++++- include/ui/game_screen.hpp | 4 + src/rendering/wmo_renderer.cpp | 227 +++++++++++++++++++++++++++-- src/ui/game_screen.cpp | 57 ++++++++ 8 files changed, 424 insertions(+), 33 deletions(-) diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index 00852448..8d21dc74 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -22,23 +22,104 @@ layout(set = 1, binding = 1) uniform WMOMaterial { int isInterior; float specularIntensity; int isWindow; + int enableNormalMap; + int enablePOM; + float pomScale; + int pomMaxSamples; + float heightMapVariance; }; +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 vec4 VertColor; +layout(location = 4) in vec3 Tangent; +layout(location = 5) 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)); + // Low density = close/head-on = full detail (0) + // High density = far/steep = vertex normals only (1) + 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); // 1=head-on, 0=grazing + 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; + + // Direction to shift UV per layer + vec2 P = viewDirTS.xy / max(abs(viewDirTS.z), 0.001) * pomScale; + vec2 deltaUV = P / float(numSamples); + + vec2 currentUV = uv; + float currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + + // Ray march through layers + for (int i = 0; i < 64; i++) { + if (i >= numSamples || currentLayerDepth >= currentDepthMapValue) break; + currentUV -= deltaUV; + currentDepthMapValue = 1.0 - texture(uNormalHeightMap, currentUV).a; + currentLayerDepth += layerDepth; + } + + // Interpolate between last two layers for smooth result + 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); + return mix(currentUV, prevUV, weight); +} + void main() { - vec4 texColor = hasTexture != 0 ? texture(uTexture, TexCoord) : vec4(1.0); + float lodFactor = computeLodFactor(); + + vec3 vertexNormal = normalize(Normal); + if (!gl_FrontFacing) vertexNormal = -vertexNormal; + + // Compute final UV (with POM if enabled) + 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 = hasTexture != 0 ? texture(uTexture, finalUV) : vec4(1.0); if (alphaTest != 0 && texColor.a < 0.5) 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) { + vec3 mapNormal = texture(uNormalHeightMap, finalUV).rgb * 2.0 - 1.0; + vec3 worldNormal = normalize(TBN * mapNormal); + if (!gl_FrontFacing) worldNormal = -worldNormal; + norm = normalize(mix(worldNormal, vertexNormal, lodFactor)); + } vec3 result; @@ -82,39 +163,29 @@ void main() { float alpha = texColor.a; // Window glass: opaque but simulates dark tinted glass with reflections. - // No real alpha blending — we darken the base texture and add reflection - // on top so it reads as glass without needing the transparent pipeline. if (isWindow != 0) { vec3 viewDir = normalize(viewPos.xyz - FragPos); float NdotV = abs(dot(norm, viewDir)); - // Fresnel: strong reflection at grazing angles float fresnel = 0.08 + 0.92 * pow(1.0 - NdotV, 4.0); - // Glass darkness depends on view angle — bright when sun glints off, - // darker when looking straight on with no sun reflection. vec3 ldir = normalize(-lightDir.xyz); vec3 reflectDir = reflect(-viewDir, norm); float sunGlint = pow(max(dot(reflectDir, ldir), 0.0), 32.0); - // Base ranges from dark (0.3) to bright (0.9) based on sun reflection float baseBrightness = mix(0.3, 0.9, sunGlint); vec3 glass = result * baseBrightness; - // Reflection: blend sky/ambient color based on Fresnel vec3 reflectTint = mix(ambientColor.rgb * 1.2, vec3(0.6, 0.75, 1.0), 0.6); glass = mix(glass, reflectTint, fresnel * 0.8); - // Sharp sun glint on glass vec3 halfDir = normalize(ldir + viewDir); float spec = pow(max(dot(norm, halfDir), 0.0), 256.0); glass += spec * lightColor.rgb * 0.8; - // Broad warm sheen when sun is nearby float specBroad = pow(max(dot(norm, halfDir), 0.0), 12.0); glass += specBroad * lightColor.rgb * 0.12; result = glass; - // Fresnel-based transparency: more transparent at oblique angles alpha = mix(0.4, 0.95, NdotV); } diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index 372107a2a212de232df3b694f9e4f9d722214380..2cfb1540da5b85cd5939757f84af985c93c03ee3 100644 GIT binary patch literal 17296 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 literal 10820 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 Qxvl95w7&0B^-|=204ylx@Bjb+ diff --git a/assets/shaders/wmo.vert.glsl b/assets/shaders/wmo.vert.glsl index 12e9ab59..737b30aa 100644 --- a/assets/shaders/wmo.vert.glsl +++ b/assets/shaders/wmo.vert.glsl @@ -21,17 +21,33 @@ layout(location = 0) in vec3 aPos; layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aTexCoord; layout(location = 3) in vec4 aColor; +layout(location = 4) 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 vec4 VertColor; +layout(location = 4) out vec3 Tangent; +layout(location = 5) out vec3 Bitangent; void main() { vec4 worldPos = push.model * vec4(aPos, 1.0); FragPos = worldPos.xyz; - Normal = mat3(push.model) * aNormal; + + mat3 normalMatrix = mat3(push.model); + Normal = normalMatrix * aNormal; TexCoord = aTexCoord; VertColor = aColor; + + // Compute TBN basis vectors for normal mapping + vec3 T = normalize(normalMatrix * aTangent.xyz); + vec3 N = normalize(Normal); + // Gram-Schmidt re-orthogonalize + 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/wmo.vert.spv b/assets/shaders/wmo.vert.spv index b355eae2a6df68dc8bc20cdaae45c4c4ea20712c..95428d7e2b993912155486b8bc7146dd02e5dfda 100644 GIT binary patch literal 3832 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 r{%_?W=Al3wyq)_<@&Uo-;7`f;!IPV}-4u9N generateNormalHeightMap(const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance); + /** * Allocate a material descriptor set from the pool */ @@ -584,6 +611,8 @@ private: // Texture cache (path -> VkTexture) struct TextureCacheEntry { std::unique_ptr texture; + std::unique_ptr normalHeightMap; // generated normal+height from diffuse + float heightMapVariance = 0.0f; // variance of generated height map size_t approxBytes = 0; uint64_t lastUse = 0; }; @@ -598,6 +627,9 @@ private: // Default white texture std::unique_ptr whiteTexture_; + // Flat normal placeholder (128,128,255,128) = up-pointing normal, mid-height + std::unique_ptr flatNormalTexture_; + // Loaded models (modelId -> ModelData) std::unordered_map loadedModels; size_t modelCacheLimit_ = 4000; @@ -609,6 +641,12 @@ private: bool initialized_ = false; + // Normal mapping / POM settings + bool normalMappingEnabled_ = true; // on by default + 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 + // Rendering state bool wireframeMode = false; bool frustumCulling = true; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 57b8be0b..5f269477 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -103,6 +103,9 @@ private: bool pendingUseOriginalSoundtrack = true; int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x + bool pendingNormalMapping = true; // on by default + bool pendingPOM = false; // off by default (expensive) + int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64) // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) float uiOpacity_ = 0.65f; @@ -112,6 +115,7 @@ private: bool minimapSettingsApplied_ = false; bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer + bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied // Mute state: mute bypasses master volume without touching slider values bool soundMuted_ = false; diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index dc486647..c4d8431f 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -87,7 +87,8 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou // --- Create material descriptor set layout (set 1) --- // binding 0: sampler2D (diffuse texture) // binding 1: uniform buffer (WMOMaterial) - std::vector materialBindings(2); + // binding 2: sampler2D (normal+height map) + std::vector materialBindings(3); materialBindings[0] = {}; materialBindings[0].binding = 0; materialBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; @@ -98,6 +99,11 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou materialBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; materialBindings[1].descriptorCount = 1; materialBindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + materialBindings[2] = {}; + materialBindings[2].binding = 2; + materialBindings[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + materialBindings[2].descriptorCount = 1; + materialBindings[2].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; materialSetLayout_ = createDescriptorSetLayout(device, materialBindings); if (!materialSetLayout_) { @@ -107,7 +113,7 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou // --- Create descriptor pool --- VkDescriptorPoolSize poolSizes[] = { - { 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 }, }; @@ -147,12 +153,13 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou } // --- Vertex input --- - // WMO vertex: pos3 + normal3 + texCoord2 + color4 = 48 bytes + // WMO vertex: pos3 + normal3 + texCoord2 + color4 + tangent4 = 64 bytes struct WMOVertexData { glm::vec3 position; glm::vec3 normal; glm::vec2 texCoord; glm::vec4 color; + glm::vec4 tangent; // xyz=tangent dir, w=handedness ±1 }; VkVertexInputBindingDescription vertexBinding{}; @@ -160,7 +167,7 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou vertexBinding.stride = sizeof(WMOVertexData); vertexBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - std::vector vertexAttribs(4); + std::vector vertexAttribs(5); vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(WMOVertexData, position)) }; vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, @@ -169,6 +176,8 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou static_cast(offsetof(WMOVertexData, texCoord)) }; vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast(offsetof(WMOVertexData, color)) }; + vertexAttribs[4] = { 4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, + static_cast(offsetof(WMOVertexData, tangent)) }; // --- Build opaque pipeline --- VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); @@ -256,6 +265,14 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou whiteTexture_->upload(*vkCtx_, whitePixel, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); whiteTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_REPEAT); + + // --- Create flat normal placeholder texture --- + // (128,128,255,128) = flat normal pointing up (0,0,1), mid-height + flatNormalTexture_ = std::make_unique(); + uint8_t flatNormalPixel[4] = {128, 128, 255, 128}; + flatNormalTexture_->upload(*vkCtx_, flatNormalPixel, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false); + flatNormalTexture_->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR, + VK_SAMPLER_ADDRESS_MODE_REPEAT); textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_WMO_TEX_CACHE_MB", 1024) * 1024ull * 1024ull; modelCacheLimit_ = envSizeMBOrDefault("WOWEE_WMO_MODEL_LIMIT", 4000); @@ -295,6 +312,7 @@ void WMORenderer::shutdown() { // Free cached textures for (auto& [path, entry] : textureCache) { if (entry.texture) entry.texture->destroy(device, allocator); + if (entry.normalHeightMap) entry.normalHeightMap->destroy(device, allocator); } textureCache.clear(); textureCacheBytes_ = 0; @@ -303,8 +321,9 @@ void WMORenderer::shutdown() { loggedTextureLoadFails_.clear(); textureBudgetRejectWarnings_ = 0; - // Free white texture + // Free white texture and flat normal texture if (whiteTexture_) { whiteTexture_->destroy(device, allocator); whiteTexture_.reset(); } + if (flatNormalTexture_) { flatNormalTexture_->destroy(device, allocator); flatNormalTexture_.reset(); } loadedModels.clear(); instances.clear(); @@ -540,6 +559,16 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { mb.unlit = unlit; mb.isTransparent = (blendMode >= 2); mb.isWindow = isWindow; + // Look up normal/height map from texture cache + if (hasTexture && tex != whiteTexture_.get()) { + for (const auto& [cacheKey, cacheEntry] : textureCache) { + if (cacheEntry.texture.get() == tex) { + mb.normalHeightMap = cacheEntry.normalHeightMap.get(); + mb.heightMapVariance = cacheEntry.heightMapVariance; + break; + } + } + } } GroupResources::MergedBatch::DrawRange dr; dr.firstIndex = batch.startIndex; @@ -570,6 +599,14 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { matData.isInterior = isInterior ? 1 : 0; matData.specularIntensity = 0.5f; matData.isWindow = mb.isWindow ? 1 : 0; + matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0; + matData.enablePOM = pomEnabled_ ? 1 : 0; + matData.pomScale = 0.03f; + { + static const int pomSampleTable[] = { 16, 32, 64 }; + matData.pomMaxSamples = pomSampleTable[std::clamp(pomQuality_, 0, 2)]; + } + matData.heightMapVariance = mb.heightMapVariance; if (matBuf.info.pMappedData) { memcpy(matBuf.info.pMappedData, &matData, sizeof(matData)); } @@ -585,7 +622,10 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { bufInfo.offset = 0; bufInfo.range = sizeof(WMOMaterialUBO); - VkWriteDescriptorSet writes[2] = {}; + VkTexture* nhMap = mb.normalHeightMap ? mb.normalHeightMap : flatNormalTexture_.get(); + VkDescriptorImageInfo nhImgInfo = nhMap->descriptorInfo(); + + VkWriteDescriptorSet writes[3] = {}; writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; writes[0].dstSet = mb.materialSet; writes[0].dstBinding = 0; @@ -600,7 +640,14 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { writes[1].descriptorCount = 1; writes[1].pBufferInfo = &bufInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 2, writes, 0, nullptr); + writes[2].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[2].dstSet = mb.materialSet; + writes[2].dstBinding = 2; + writes[2].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[2].descriptorCount = 1; + writes[2].pImageInfo = &nhImgInfo; + + vkUpdateDescriptorSets(vkCtx_->getDevice(), 3, writes, 0, nullptr); } groupRes.mergedBatches.push_back(std::move(mb)); @@ -1165,6 +1212,31 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const return; } + // Update material UBOs if settings changed + if (materialSettingsDirty_) { + materialSettingsDirty_ = false; + static const int pomSampleTable[] = { 16, 32, 64 }; + int maxSamples = pomSampleTable[std::clamp(pomQuality_, 0, 2)]; + for (auto& [modelId, model] : loadedModels) { + 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) { + auto* ubo = reinterpret_cast(allocInfo.pMappedData); + ubo->enableNormalMap = normalMappingEnabled_ ? 1 : 0; + ubo->enablePOM = pomEnabled_ ? 1 : 0; + ubo->pomScale = 0.03f; + ubo->pomMaxSamples = maxSamples; + ubo->heightMapVariance = mb.heightMapVariance; + } + } + } + } + } + lastDrawCalls = 0; // Extract frustum planes for proper culling @@ -1491,12 +1563,12 @@ bool WMORenderer::initializeShadow(VkRenderPass shadowRenderPass) { return false; } - // WMO vertex layout: pos(loc0,off0) normal(loc1,off12) texCoord(loc2,off24) color(loc3,off32), stride=48 + // WMO vertex layout: pos(loc0,off0) normal(loc1,off12) texCoord(loc2,off24) color(loc3,off32) tangent(loc4,off48), stride=64 // Shadow shader locations: 0=aPos, 1=aTexCoord, 2=aBoneWeights, 3=aBoneIndicesF // useBones=0 so locations 2,3 are never read; we alias them to existing data offsets VkVertexInputBindingDescription vertBind{}; vertBind.binding = 0; - vertBind.stride = 48; + vertBind.stride = 64; vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; std::vector vertAttrs = { {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // aPos -> position @@ -1594,12 +1666,13 @@ bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupRes resources.boundingBoxMin = group.boundingBoxMin; resources.boundingBoxMax = group.boundingBoxMax; - // Create vertex data (position, normal, texcoord, color) + // Create vertex data (position, normal, texcoord, color, tangent) struct VertexData { glm::vec3 position; glm::vec3 normal; glm::vec2 texCoord; glm::vec4 color; + glm::vec4 tangent; // xyz=tangent dir, w=handedness ±1 }; std::vector vertices; @@ -1611,9 +1684,60 @@ bool WMORenderer::createGroupResources(const pipeline::WMOGroup& group, GroupRes vd.normal = v.normal; vd.texCoord = v.texCoord; vd.color = v.color; + vd.tangent = glm::vec4(0.0f); vertices.push_back(vd); } + // Compute tangents using Lengyel's method + { + std::vector tan1(vertices.size(), glm::vec3(0.0f)); + std::vector tan2(vertices.size(), glm::vec3(0.0f)); + + const auto& indices = group.indices; + for (size_t i = 0; i + 2 < indices.size(); i += 3) { + uint16_t i0 = indices[i], i1 = indices[i + 1], i2 = indices[i + 2]; + if (i0 >= vertices.size() || i1 >= vertices.size() || i2 >= vertices.size()) continue; + + const glm::vec3& p0 = vertices[i0].position; + const glm::vec3& p1 = vertices[i1].position; + const glm::vec3& p2 = vertices[i2].position; + const glm::vec2& uv0 = vertices[i0].texCoord; + const glm::vec2& uv1 = vertices[i1].texCoord; + const glm::vec2& uv2 = vertices[i2].texCoord; + + glm::vec3 dp1 = p1 - p0; + glm::vec3 dp2 = p2 - p0; + glm::vec2 duv1 = uv1 - uv0; + glm::vec2 duv2 = uv2 - uv0; + + float det = duv1.x * duv2.y - duv1.y * duv2.x; + if (std::abs(det) < 1e-8f) continue; // degenerate UVs + float r = 1.0f / det; + + glm::vec3 sdir = (dp1 * duv2.y - dp2 * duv1.y) * r; + glm::vec3 tdir = (dp2 * duv1.x - dp1 * duv2.x) * r; + + tan1[i0] += sdir; tan1[i1] += sdir; tan1[i2] += sdir; + tan2[i0] += tdir; tan2[i1] += tdir; tan2[i2] += tdir; + } + + for (size_t i = 0; i < vertices.size(); i++) { + glm::vec3 n = glm::normalize(vertices[i].normal); + glm::vec3 t = tan1[i]; + + if (glm::dot(t, t) < 1e-8f) { + // Fallback: generate tangent perpendicular to normal + glm::vec3 up = (std::abs(n.y) < 0.999f) ? glm::vec3(0, 1, 0) : glm::vec3(1, 0, 0); + t = glm::normalize(glm::cross(n, up)); + } + + // Gram-Schmidt orthogonalize + t = glm::normalize(t - n * glm::dot(n, t)); + float w = (glm::dot(glm::cross(n, t), tan2[i]) < 0.0f) ? -1.0f : 1.0f; + vertices[i].tangent = glm::vec4(t, w); + } + } + // Upload vertex buffer to GPU AllocatedBuffer vertBuf = uploadBuffer(*vkCtx_, vertices.data(), vertices.size() * sizeof(VertexData), @@ -1874,6 +1998,72 @@ void WMORenderer::WMOInstance::updateModelMatrix() { invModelMatrix = glm::inverse(modelMatrix); } +std::unique_ptr WMORenderer::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 2: Sobel 3x3 → normal map (wrap-sampled for tiling textures) + 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); + // Sobel X + 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); + // Sobel Y + 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(heightMap[y * width + x] * 255.0f, 0.0f, 255.0f)); + } + } + + // Step 3: Upload to GPU with mipmaps + 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* WMORenderer::loadTexture(const std::string& path) { if (!assetManager || !vkCtx_) { return whiteTexture_.get(); @@ -1997,12 +2187,24 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { texture->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR, VK_SAMPLER_ADDRESS_MODE_REPEAT); + // Generate normal+height map from diffuse pixels + float nhVariance = 0.0f; + std::unique_ptr nhMap; + if (normalMappingEnabled_ || pomEnabled_) { + nhMap = generateNormalHeightMap(blp.data.data(), blp.width, blp.height, nhVariance); + if (nhMap) { + approxBytes *= 2; // account for normal map in budget + } + } + // Cache it TextureCacheEntry e; VkTexture* rawPtr = texture.get(); e.approxBytes = approxBytes; e.lastUse = ++textureCacheCounter_; e.texture = std::move(texture); + e.normalHeightMap = std::move(nhMap); + e.heightMapVariance = nhVariance; textureCacheBytes_ += e.approxBytes; if (!resolvedKey.empty()) { textureCache[resolvedKey] = std::move(e); @@ -3010,6 +3212,7 @@ void WMORenderer::recreatePipelines() { glm::vec3 normal; glm::vec2 texCoord; glm::vec4 color; + glm::vec4 tangent; }; VkVertexInputBindingDescription vertexBinding{}; @@ -3017,7 +3220,7 @@ void WMORenderer::recreatePipelines() { vertexBinding.stride = sizeof(WMOVertexData); vertexBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - std::vector vertexAttribs(4); + std::vector vertexAttribs(5); vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast(offsetof(WMOVertexData, position)) }; vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT, @@ -3026,6 +3229,8 @@ void WMORenderer::recreatePipelines() { static_cast(offsetof(WMOVertexData, texCoord)) }; vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast(offsetof(WMOVertexData, color)) }; + vertexAttribs[4] = { 4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, + static_cast(offsetof(WMOVertexData, tangent)) }; VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 65c722fa..42392cd8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -6,6 +6,7 @@ #include "core/spawn_presets.hpp" #include "core/input.hpp" #include "rendering/renderer.hpp" +#include "rendering/wmo_renderer.hpp" #include "rendering/terrain_manager.hpp" #include "rendering/minimap.hpp" #include "rendering/world_map.hpp" @@ -277,6 +278,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { msaaSettingsApplied_ = true; } + // Apply saved normal mapping / POM settings once when WMO renderer is available + if (!normalMapSettingsApplied_) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + wr->setPOMEnabled(pendingPOM); + wr->setPOMQuality(pendingPOMQuality); + normalMapSettingsApplied_ = true; + } + } + } + // Apply auto-loot setting to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); @@ -5894,6 +5908,33 @@ void GameScreen::renderSettingsWindow() { } saveSettings(); } + if (ImGui::Checkbox("Normal Mapping", &pendingNormalMapping)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + } + } + saveSettings(); + } + if (ImGui::Checkbox("Parallax Mapping", &pendingPOM)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setPOMEnabled(pendingPOM); + } + } + saveSettings(); + } + if (pendingPOM) { + const char* pomLabels[] = { "Low", "Medium", "High" }; + if (ImGui::Combo("Parallax Quality", &pendingPOMQuality, pomLabels, 3)) { + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setPOMQuality(pendingPOMQuality); + } + } + saveSettings(); + } + } const char* resLabel = "Resolution"; const char* resItems[kResCount]; @@ -5917,6 +5958,9 @@ void GameScreen::renderSettingsWindow() { pendingShadows = kDefaultShadows; pendingGroundClutterDensity = kDefaultGroundClutterDensity; pendingAntiAliasing = 0; + pendingNormalMapping = true; + pendingPOM = false; + pendingPOMQuality = 1; pendingResIndex = defaultResIndex; window->setFullscreen(pendingFullscreen); window->setVsync(pendingVsync); @@ -5928,6 +5972,13 @@ void GameScreen::renderSettingsWindow() { tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); } } + if (renderer) { + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(pendingNormalMapping); + wr->setPOMEnabled(pendingPOM); + wr->setPOMQuality(pendingPOMQuality); + } + } saveSettings(); } @@ -6854,6 +6905,9 @@ void GameScreen::saveSettings() { out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; + out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; + out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; + out << "pom_quality=" << pendingPOMQuality << "\n"; // Controls out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; @@ -6932,6 +6986,9 @@ void GameScreen::loadSettings() { else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); + else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); + else if (key == "pom") pendingPOM = (std::stoi(val) != 0); + else if (key == "pom_quality") pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); // 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);