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 372107a2..2cfb1540 100644 Binary files a/assets/shaders/wmo.frag.spv and b/assets/shaders/wmo.frag.spv differ 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 b355eae2..95428d7e 100644 Binary files a/assets/shaders/wmo.vert.spv and b/assets/shaders/wmo.vert.spv differ diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index fd79f5db..328e6f7e 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -182,6 +182,16 @@ public: */ uint32_t getDrawCallCount() const { return lastDrawCalls; } + /** + * Normal mapping / Parallax Occlusion Mapping settings + */ + void setNormalMappingEnabled(bool enabled) { normalMappingEnabled_ = enabled; materialSettingsDirty_ = true; } + void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; materialSettingsDirty_ = true; } + void setPOMQuality(int q) { pomQuality_ = q; materialSettingsDirty_ = true; } + bool isNormalMappingEnabled() const { return normalMappingEnabled_; } + bool isPOMEnabled() const { return pomEnabled_; } + int getPOMQuality() const { return pomQuality_; } + /** * Enable/disable wireframe rendering */ @@ -305,14 +315,19 @@ public: private: // WMO material UBO — matches WMOMaterial in wmo.frag.glsl struct WMOMaterialUBO { - int32_t hasTexture; - int32_t alphaTest; - int32_t unlit; - int32_t isInterior; - float specularIntensity; - int32_t isWindow; - float pad[2]; // pad to 32 bytes - }; + int32_t hasTexture; // 0 + int32_t alphaTest; // 4 + int32_t unlit; // 8 + int32_t isInterior; // 12 + float specularIntensity; // 16 + int32_t isWindow; // 20 + int32_t enableNormalMap; // 24 + int32_t enablePOM; // 28 + float pomScale; // 32 (height scale) + int32_t pomMaxSamples; // 36 (max ray-march steps) + float heightMapVariance; // 40 (low variance = skip POM) + float pad; // 44 + }; // 48 bytes total /** * WMO group GPU resources @@ -341,6 +356,8 @@ private: // Pre-merged batches for efficient rendering (computed at load time) struct MergedBatch { VkTexture* texture = nullptr; // from cache, NOT owned + VkTexture* normalHeightMap = nullptr; // generated from diffuse, NOT owned + float heightMapVariance = 0.0f; // variance of height map (low = flat texture) VkDescriptorSet materialSet = VK_NULL_HANDLE; // set 1 ::VkBuffer materialUBO = VK_NULL_HANDLE; VmaAllocation materialUBOAlloc = VK_NULL_HANDLE; @@ -515,6 +532,16 @@ private: */ VkTexture* loadTexture(const std::string& path); + /** + * Generate normal+height map from diffuse RGBA8 pixels + * @param pixels RGBA8 pixel data + * @param width Texture width + * @param height Texture height + * @param outVariance Receives height map variance (for POM threshold) + * @return Generated VkTexture (RGBA8: RGB=normal, A=height) + */ + std::unique_ptr 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);