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.
This commit is contained in:
Kelsi 2026-02-23 01:10:58 -08:00
parent 1b16bcf71f
commit eaceb58e77
8 changed files with 424 additions and 33 deletions

View file

@ -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);
}

Binary file not shown.

View file

@ -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;
}

Binary file not shown.

View file

@ -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<VkTexture> 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<VkTexture> texture;
std::unique_ptr<VkTexture> 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<VkTexture> whiteTexture_;
// Flat normal placeholder (128,128,255,128) = up-pointing normal, mid-height
std::unique_ptr<VkTexture> flatNormalTexture_;
// Loaded models (modelId -> ModelData)
std::unordered_map<uint32_t, ModelData> 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;

View file

@ -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;

View file

@ -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<VkDescriptorSetLayoutBinding> materialBindings(2);
// binding 2: sampler2D (normal+height map)
std::vector<VkDescriptorSetLayoutBinding> 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<VkVertexInputAttributeDescription> vertexAttribs(4);
std::vector<VkVertexInputAttributeDescription> vertexAttribs(5);
vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT,
static_cast<uint32_t>(offsetof(WMOVertexData, position)) };
vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT,
@ -169,6 +176,8 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou
static_cast<uint32_t>(offsetof(WMOVertexData, texCoord)) };
vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32B32A32_SFLOAT,
static_cast<uint32_t>(offsetof(WMOVertexData, color)) };
vertexAttribs[4] = { 4, 0, VK_FORMAT_R32G32B32A32_SFLOAT,
static_cast<uint32_t>(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<VkTexture>();
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<WMOMaterialUBO*>(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<VkVertexInputAttributeDescription> 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<VertexData> 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<glm::vec3> tan1(vertices.size(), glm::vec3(0.0f));
std::vector<glm::vec3> 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<VkTexture> 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<float> 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<float>(sumH2 / totalPixels - mean * mean);
// Step 2: Sobel 3x3 → normal map (wrap-sampled for tiling textures)
const float strength = 2.0f;
std::vector<uint8_t> 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<int>(x);
int iy = static_cast<int>(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<uint8_t>(std::clamp((nx * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
output[idx + 1] = static_cast<uint8_t>(std::clamp((ny * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
output[idx + 2] = static_cast<uint8_t>(std::clamp((nz * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
output[idx + 3] = static_cast<uint8_t>(std::clamp(heightMap[y * width + x] * 255.0f, 0.0f, 255.0f));
}
}
// Step 3: Upload to GPU with mipmaps
auto tex = std::make_unique<VkTexture>();
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<VkTexture> 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<VkVertexInputAttributeDescription> vertexAttribs(4);
std::vector<VkVertexInputAttributeDescription> vertexAttribs(5);
vertexAttribs[0] = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT,
static_cast<uint32_t>(offsetof(WMOVertexData, position)) };
vertexAttribs[1] = { 1, 0, VK_FORMAT_R32G32B32_SFLOAT,
@ -3026,6 +3229,8 @@ void WMORenderer::recreatePipelines() {
static_cast<uint32_t>(offsetof(WMOVertexData, texCoord)) };
vertexAttribs[3] = { 3, 0, VK_FORMAT_R32G32B32A32_SFLOAT,
static_cast<uint32_t>(offsetof(WMOVertexData, color)) };
vertexAttribs[4] = { 4, 0, VK_FORMAT_R32G32B32A32_SFLOAT,
static_cast<uint32_t>(offsetof(WMOVertexData, tangent)) };
VkRenderPass mainPass = vkCtx_->getImGuiRenderPass();

View file

@ -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<float>(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);