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 39a376c7..99c68edf 100644 Binary files a/assets/shaders/character.frag.spv and b/assets/shaders/character.frag.spv differ 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 3836081c..cbe4916d 100644 Binary files a/assets/shaders/character.vert.spv and b/assets/shaders/character.vert.spv differ 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(); }