Fix POM distortions and add normal map strength slider

POM fixes: use blurred height only for ray march (keep crisp Sobel for
normals), reduce pomScale 0.03→0.012, clamp grazing angle denominator
to 0.15, hard-limit max UV offset, smooth fadeout at steep view angles.

Add Normal Map Strength slider (0.0-2.0) in Video settings for user
control over surface detail intensity. Persisted across sessions.
This commit is contained in:
Kelsi 2026-02-23 01:18:42 -08:00
parent eaceb58e77
commit bec3190b08
6 changed files with 68 additions and 10 deletions

View file

@ -27,6 +27,7 @@ layout(set = 1, binding = 1) uniform WMOMaterial {
float pomScale;
int pomMaxSamples;
float heightMapVariance;
float normalMapStrength;
};
layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap;
@ -55,6 +56,10 @@ float computeLodFactor() {
// 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
// Fade out POM at grazing angles to avoid distortion
if (VdotN < 0.15) return uv;
float angleFactor = clamp(VdotN, 0.15, 1.0);
int maxS = pomMaxSamples;
int minS = max(maxS / 4, 4);
@ -64,8 +69,11 @@ vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float 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;
// Direction to shift UV per layer — clamp denominator to prevent explosion at grazing angles
vec2 P = viewDirTS.xy / max(VdotN, 0.15) * pomScale;
// Hard-clamp total UV offset to prevent texture swimming
float maxOffset = pomScale * 3.0;
P = clamp(P, vec2(-maxOffset), vec2(maxOffset));
vec2 deltaUV = P / float(numSamples);
vec2 currentUV = uv;
@ -84,7 +92,11 @@ vec2 parallaxOcclusionMap(vec2 uv, vec3 viewDirTS, float lodFactor) {
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);
vec2 result = mix(currentUV, prevUV, weight);
// Fade toward original UV at grazing angles for smooth transition
float fadeFactor = smoothstep(0.15, 0.35, VdotN);
return mix(uv, result, fadeFactor);
}
void main() {
@ -114,11 +126,16 @@ void main() {
// Compute normal (with normal mapping if enabled)
vec3 norm = vertexNormal;
if (enableNormalMap != 0 && lodFactor < 0.99) {
if (enableNormalMap != 0 && lodFactor < 0.99 && normalMapStrength > 0.001) {
vec3 mapNormal = texture(uNormalHeightMap, finalUV).rgb * 2.0 - 1.0;
// Scale XY by strength to control effect intensity
mapNormal.xy *= normalMapStrength;
mapNormal = normalize(mapNormal);
vec3 worldNormal = normalize(TBN * mapNormal);
if (!gl_FrontFacing) worldNormal = -worldNormal;
norm = normalize(mix(worldNormal, vertexNormal, lodFactor));
// Blend: strength + LOD both contribute to fade toward vertex normal
float blendFactor = max(lodFactor, 1.0 - normalMapStrength);
norm = normalize(mix(worldNormal, vertexNormal, blendFactor));
}
vec3 result;

Binary file not shown.

View file

@ -186,9 +186,11 @@ public:
* Normal mapping / Parallax Occlusion Mapping settings
*/
void setNormalMappingEnabled(bool enabled) { normalMappingEnabled_ = enabled; materialSettingsDirty_ = true; }
void setNormalMapStrength(float s) { normalMapStrength_ = s; materialSettingsDirty_ = true; }
void setPOMEnabled(bool enabled) { pomEnabled_ = enabled; materialSettingsDirty_ = true; }
void setPOMQuality(int q) { pomQuality_ = q; materialSettingsDirty_ = true; }
bool isNormalMappingEnabled() const { return normalMappingEnabled_; }
float getNormalMapStrength() const { return normalMapStrength_; }
bool isPOMEnabled() const { return pomEnabled_; }
int getPOMQuality() const { return pomQuality_; }
@ -326,7 +328,7 @@ private:
float pomScale; // 32 (height scale)
int32_t pomMaxSamples; // 36 (max ray-march steps)
float heightMapVariance; // 40 (low variance = skip POM)
float pad; // 44
float normalMapStrength; // 44 (0=flat, 1=full, 2=exaggerated)
}; // 48 bytes total
/**
@ -643,6 +645,7 @@ private:
// Normal mapping / POM settings
bool normalMappingEnabled_ = true; // on by default
float normalMapStrength_ = 1.0f; // 0.0 = flat, 1.0 = full, 2.0 = exaggerated
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

View file

@ -104,6 +104,7 @@ private:
int pendingGroundClutterDensity = 100;
int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x
bool pendingNormalMapping = true; // on by default
float pendingNormalMapStrength = 1.0f; // 0.0-2.0
bool pendingPOM = false; // off by default (expensive)
int pendingPOMQuality = 1; // 0=Low(16), 1=Medium(32), 2=High(64)

View file

@ -601,12 +601,13 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
matData.isWindow = mb.isWindow ? 1 : 0;
matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0;
matData.enablePOM = pomEnabled_ ? 1 : 0;
matData.pomScale = 0.03f;
matData.pomScale = 0.012f;
{
static const int pomSampleTable[] = { 16, 32, 64 };
matData.pomMaxSamples = pomSampleTable[std::clamp(pomQuality_, 0, 2)];
}
matData.heightMapVariance = mb.heightMapVariance;
matData.normalMapStrength = normalMapStrength_;
if (matBuf.info.pMappedData) {
memcpy(matBuf.info.pMappedData, &matData, sizeof(matData));
}
@ -1228,9 +1229,10 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
auto* ubo = reinterpret_cast<WMOMaterialUBO*>(allocInfo.pMappedData);
ubo->enableNormalMap = normalMappingEnabled_ ? 1 : 0;
ubo->enablePOM = pomEnabled_ ? 1 : 0;
ubo->pomScale = 0.03f;
ubo->pomScale = 0.012f;
ubo->pomMaxSamples = maxSamples;
ubo->heightMapVariance = mb.heightMapVariance;
ubo->normalMapStrength = normalMapStrength_;
}
}
}
@ -2019,7 +2021,27 @@ std::unique_ptr<VkTexture> WMORenderer::generateNormalHeightMap(
double mean = sumH / totalPixels;
outVariance = static_cast<float>(sumH2 / totalPixels - mean * mean);
// Step 2: Sobel 3x3 → normal map (wrap-sampled for tiling textures)
// Step 1.5: Box blur the height map to reduce noise from diffuse textures
auto wrapSample = [&](const std::vector<float>& 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<float> blurredHeight(totalPixels);
for (uint32_t y = 0; y < height; y++) {
for (uint32_t x = 0; x < width; x++) {
int ix = static_cast<int>(x), iy = static_cast<int>(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
// Use ORIGINAL height for normals (crisp detail), blurred height for POM alpha only
const float strength = 2.0f;
std::vector<uint8_t> output(totalPixels * 4);
@ -2050,7 +2072,7 @@ std::unique_ptr<VkTexture> WMORenderer::generateNormalHeightMap(
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));
output[idx + 3] = static_cast<uint8_t>(std::clamp(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f));
}
}

View file

@ -284,6 +284,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(pendingNormalMapping);
wr->setNormalMapStrength(pendingNormalMapStrength);
wr->setPOMEnabled(pendingPOM);
wr->setPOMQuality(pendingPOMQuality);
normalMapSettingsApplied_ = true;
@ -5916,6 +5917,16 @@ void GameScreen::renderSettingsWindow() {
}
saveSettings();
}
if (pendingNormalMapping) {
if (ImGui::SliderFloat("Normal Map Strength", &pendingNormalMapStrength, 0.0f, 2.0f, "%.1f")) {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMapStrength(pendingNormalMapStrength);
}
}
saveSettings();
}
}
if (ImGui::Checkbox("Parallax Mapping", &pendingPOM)) {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
@ -5959,6 +5970,7 @@ void GameScreen::renderSettingsWindow() {
pendingGroundClutterDensity = kDefaultGroundClutterDensity;
pendingAntiAliasing = 0;
pendingNormalMapping = true;
pendingNormalMapStrength = 1.0f;
pendingPOM = false;
pendingPOMQuality = 1;
pendingResIndex = defaultResIndex;
@ -5975,6 +5987,7 @@ void GameScreen::renderSettingsWindow() {
if (renderer) {
if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(pendingNormalMapping);
wr->setNormalMapStrength(pendingNormalMapStrength);
wr->setPOMEnabled(pendingPOM);
wr->setPOMQuality(pendingPOMQuality);
}
@ -6906,6 +6919,7 @@ void GameScreen::saveSettings() {
out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n";
out << "antialiasing=" << pendingAntiAliasing << "\n";
out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n";
out << "normal_map_strength=" << pendingNormalMapStrength << "\n";
out << "pom=" << (pendingPOM ? 1 : 0) << "\n";
out << "pom_quality=" << pendingPOMQuality << "\n";
@ -6987,6 +7001,7 @@ void GameScreen::loadSettings() {
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 == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f);
else if (key == "pom") pendingPOM = (std::stoi(val) != 0);
else if (key == "pom_quality") pendingPOMQuality = std::clamp(std::stoi(val), 0, 2);
// Controls