Add normal mapping and parallax occlusion mapping for character models

Extends the WMO normal mapping/POM system to character M2 models with
bone-skinned tangents. Auto-generates normal/height maps from diffuse
textures using luminance→height, Sobel→normals (same algorithm as WMO).

- Expand vertex buffer from M2Vertex (48B) to CharVertexGPU (56B) with
  tangent vec4 computed via Lengyel's method in setupModelBuffers()
- Tangents are bone-transformed and Gram-Schmidt orthogonalized in the
  vertex shader, output as TBN for fragment shader consumption
- Fragment shader gains POM ray marching, normal map blending, and LOD
  crossfade via dFdx/dFdy (identical to WMO shader)
- Descriptor set 1 extended with binding 2 for normal/height sampler
- Settings (enable, strength, POM quality) wired from game_screen.cpp
  to both WMO and character renderers via shared UI controls
This commit is contained in:
Kelsi 2026-02-23 01:40:23 -08:00
parent 3c31c43ca6
commit 9eeb9ce64d
7 changed files with 420 additions and 40 deletions

View file

@ -23,18 +23,96 @@ layout(set = 1, binding = 1) uniform CharMaterial {
float emissiveBoost; float emissiveBoost;
vec3 emissiveTint; vec3 emissiveTint;
float specularIntensity; 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(set = 0, binding = 1) uniform sampler2DShadow uShadowMap;
layout(location = 0) in vec3 FragPos; layout(location = 0) in vec3 FragPos;
layout(location = 1) in vec3 Normal; layout(location = 1) in vec3 Normal;
layout(location = 2) in vec2 TexCoord; layout(location = 2) in vec2 TexCoord;
layout(location = 3) in vec3 Tangent;
layout(location = 4) in vec3 Bitangent;
layout(location = 0) out vec4 outColor; 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() { 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 (alphaTest != 0 && texColor.a < 0.5) discard;
if (colorKeyBlack != 0) { if (colorKeyBlack != 0) {
@ -44,8 +122,17 @@ void main() {
if (texColor.a < 0.01) discard; if (texColor.a < 0.01) discard;
} }
vec3 norm = normalize(Normal); // Compute normal (with normal mapping if enabled)
if (!gl_FrontFacing) norm = -norm; 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; vec3 result;

Binary file not shown.

View file

@ -26,10 +26,13 @@ layout(location = 1) in vec4 aBoneWeights;
layout(location = 2) in ivec4 aBoneIndices; layout(location = 2) in ivec4 aBoneIndices;
layout(location = 3) in vec3 aNormal; layout(location = 3) in vec3 aNormal;
layout(location = 4) in vec2 aTexCoord; layout(location = 4) in vec2 aTexCoord;
layout(location = 5) in vec4 aTangent;
layout(location = 0) out vec3 FragPos; layout(location = 0) out vec3 FragPos;
layout(location = 1) out vec3 Normal; layout(location = 1) out vec3 Normal;
layout(location = 2) out vec2 TexCoord; layout(location = 2) out vec2 TexCoord;
layout(location = 3) out vec3 Tangent;
layout(location = 4) out vec3 Bitangent;
void main() { void main() {
mat4 skinMat = bones[aBoneIndices.x] * aBoneWeights.x mat4 skinMat = bones[aBoneIndices.x] * aBoneWeights.x
@ -39,11 +42,22 @@ void main() {
vec4 skinnedPos = skinMat * vec4(aPos, 1.0); vec4 skinnedPos = skinMat * vec4(aPos, 1.0);
vec3 skinnedNorm = mat3(skinMat) * aNormal; vec3 skinnedNorm = mat3(skinMat) * aNormal;
vec3 skinnedTan = mat3(skinMat) * aTangent.xyz;
vec4 worldPos = push.model * skinnedPos; vec4 worldPos = push.model * skinnedPos;
mat3 modelMat3 = mat3(push.model);
FragPos = worldPos.xyz; FragPos = worldPos.xyz;
Normal = mat3(push.model) * skinnedNorm; Normal = modelMat3 * skinnedNorm;
TexCoord = aTexCoord; 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; gl_Position = projection * view * worldPos;
} }

Binary file not shown.

View file

@ -99,6 +99,12 @@ public:
size_t getInstanceCount() const { return instances.size(); } 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 // 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 setFog(const glm::vec3&, float, float) {}
void setLighting(const float[3], const float[3], const float[3]) {} void setLighting(const float[3], const float[3], const float[3]) {}
@ -247,6 +253,8 @@ private:
// Texture cache // Texture cache
struct TextureCacheEntry { struct TextureCacheEntry {
std::unique_ptr<VkTexture> texture; std::unique_ptr<VkTexture> texture;
std::unique_ptr<VkTexture> normalHeightMap;
float heightMapVariance = 0.0f;
size_t approxBytes = 0; size_t approxBytes = 0;
uint64_t lastUse = 0; uint64_t lastUse = 0;
bool hasAlpha = false; bool hasAlpha = false;
@ -263,12 +271,23 @@ private:
uint32_t textureBudgetRejectWarnings_ = 0; uint32_t textureBudgetRejectWarnings_ = 0;
std::unique_ptr<VkTexture> whiteTexture_; std::unique_ptr<VkTexture> whiteTexture_;
std::unique_ptr<VkTexture> transparentTexture_; std::unique_ptr<VkTexture> transparentTexture_;
std::unique_ptr<VkTexture> flatNormalTexture_;
std::unordered_map<uint32_t, M2ModelGPU> models; std::unordered_map<uint32_t, M2ModelGPU> models;
std::unordered_map<uint32_t, CharacterInstance> instances; std::unordered_map<uint32_t, CharacterInstance> instances;
uint32_t nextInstanceId = 1; uint32_t nextInstanceId = 1;
// Normal map generation (same algorithm as WMO renderer)
std::unique_ptr<VkTexture> 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 // Maximum bones supported
static constexpr int MAX_BONES = 240; static constexpr int MAX_BONES = 240;
uint32_t numAnimThreads_ = 1; uint32_t numAnimThreads_ = 1;

View file

@ -85,9 +85,25 @@ struct CharMaterialUBO {
float emissiveBoost; float emissiveBoost;
float emissiveTintR, emissiveTintG, emissiveTintB; float emissiveTintR, emissiveTintG, emissiveTintB;
float specularIntensity; 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() { CharacterRenderer::CharacterRenderer() {
} }
@ -116,9 +132,9 @@ bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFram
// --- Descriptor set layouts --- // --- 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].binding = 0;
bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
bindings[0].descriptorCount = 1; 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].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
bindings[1].descriptorCount = 1; bindings[1].descriptorCount = 1;
bindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; 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}; VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
ci.bindingCount = 2; ci.bindingCount = 3;
ci.pBindings = bindings; ci.pBindings = bindings;
vkCreateDescriptorSetLayout(device, &ci, nullptr, &materialSetLayout_); 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. // pools so we can reset safely each frame slot without exhausting descriptors.
for (int i = 0; i < 2; i++) { for (int i = 0; i < 2; i++) {
VkDescriptorPoolSize sizes[] = { 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}, {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, MAX_MATERIAL_SETS},
}; };
VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; 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(); VkSampleCountFlagBits samples = renderPassOverride_ ? VK_SAMPLE_COUNT_1_BIT : vkCtx_->getMsaaSamples();
// --- Vertex input --- // --- Vertex input ---
// M2Vertex: vec3 pos(12) + uint8[4] boneWeights(4) + uint8[4] boneIndices(4) + // CharVertexGPU: vec3 pos(12) + uint8[4] boneWeights(4) + uint8[4] boneIndices(4) +
// vec3 normal(12) + vec2[2] texCoords(16) = 48 bytes // vec3 normal(12) + vec2 texCoords(8) + vec4 tangent(16) = 56 bytes
VkVertexInputBindingDescription charBinding{}; VkVertexInputBindingDescription charBinding{};
charBinding.binding = 0; charBinding.binding = 0;
charBinding.stride = sizeof(pipeline::M2Vertex); charBinding.stride = sizeof(CharVertexGPU);
charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> charAttrs = { std::vector<VkVertexInputAttributeDescription> charAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, position))}, {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneWeights))}, {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(CharVertexGPU, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneIndices))}, {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(CharVertexGPU, boneIndices))},
{3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, normal))}, {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, normal))},
{4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, texCoords))}, {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, texCoords))},
{5, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, tangent))},
}; };
// --- Build pipelines --- // --- 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); 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<VkTexture>();
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. // Diagnostics-only: cache lifetime is currently tied to renderer lifetime.
textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 1024) * 1024ull * 1024ull; textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 1024) * 1024ull * 1024ull;
LOG_INFO("Character texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB"); LOG_INFO("Character texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB");
@ -305,6 +334,7 @@ void CharacterRenderer::shutdown() {
whiteTexture_.reset(); whiteTexture_.reset();
transparentTexture_.reset(); transparentTexture_.reset();
flatNormalTexture_.reset();
models.clear(); models.clear();
instances.clear(); instances.clear();
@ -376,6 +406,88 @@ void CharacterRenderer::destroyInstanceBones(CharacterInstance& inst) {
} }
} }
std::unique_ptr<VkTexture> 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<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 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 (crisp detail from original, blurred for POM alpha)
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);
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<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(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f));
}
}
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* CharacterRenderer::loadTexture(const std::string& path) { VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
// Skip empty or whitespace-only paths (type-0 textures have no filename) // Skip empty or whitespace-only paths (type-0 textures have no filename)
if (path.empty()) return whiteTexture_.get(); if (path.empty()) return whiteTexture_.get();
@ -467,6 +579,16 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
e.lastUse = ++textureCacheCounter_; e.lastUse = ++textureCacheCounter_;
e.hasAlpha = hasAlpha; e.hasAlpha = hasAlpha;
e.colorKeyBlack = colorKeyBlackHint; 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; textureCacheBytes_ += e.approxBytes;
textureHasAlphaByPtr_[texPtr] = hasAlpha; textureHasAlphaByPtr_[texPtr] = hasAlpha;
textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint;
@ -1018,23 +1140,85 @@ void CharacterRenderer::setupModelBuffers(M2ModelGPU& gpuModel) {
if (model.vertices.empty() || model.indices.empty()) return; 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<CharVertexGPU> gpuVerts(vertCount);
std::vector<glm::vec3> tanAccum(vertCount, glm::vec3(0.0f));
std::vector<glm::vec3> 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_, auto vb = uploadBuffer(*vkCtx_,
model.vertices.data(), gpuVerts.data(),
model.vertices.size() * sizeof(pipeline::M2Vertex), gpuVerts.size() * sizeof(CharVertexGPU),
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); VK_BUFFER_USAGE_VERTEX_BUFFER_BIT);
gpuModel.vertexBuffer = vb.buffer; gpuModel.vertexBuffer = vb.buffer;
gpuModel.vertexAlloc = vb.allocation; gpuModel.vertexAlloc = vb.allocation;
gpuModel.vertexCount = static_cast<uint32_t>(model.vertices.size()); gpuModel.vertexCount = static_cast<uint32_t>(vertCount);
// Upload index buffer // Upload index buffer
auto ib = uploadBuffer(*vkCtx_, auto ib = uploadBuffer(*vkCtx_,
model.indices.data(), model.indices.data(),
model.indices.size() * sizeof(uint16_t), idxCount * sizeof(uint16_t),
VK_BUFFER_USAGE_INDEX_BUFFER_BIT); VK_BUFFER_USAGE_INDEX_BUFFER_BIT);
gpuModel.indexBuffer = ib.buffer; gpuModel.indexBuffer = ib.buffer;
gpuModel.indexAlloc = ib.allocation; gpuModel.indexAlloc = ib.allocation;
gpuModel.indexCount = static_cast<uint32_t>(model.indices.size()); gpuModel.indexCount = static_cast<uint32_t>(idxCount);
} }
void CharacterRenderer::calculateBindPose(M2ModelGPU& gpuModel) { 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 // Create per-batch material UBO
CharMaterialUBO matData{}; CharMaterialUBO matData{};
matData.opacity = instance.opacity; matData.opacity = instance.opacity;
@ -1820,6 +2022,12 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
matData.emissiveTintG = emissiveTint.g; matData.emissiveTintG = emissiveTint.g;
matData.emissiveTintB = emissiveTint.b; matData.emissiveTintB = emissiveTint.b;
matData.specularIntensity = 0.5f; 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 // Create a small UBO for this batch's material
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; 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)); 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(); VkTexture* bindTex = (texPtr && texPtr->isValid()) ? texPtr : whiteTexture_.get();
VkDescriptorImageInfo imgInfo = bindTex->descriptorInfo(); VkDescriptorImageInfo imgInfo = bindTex->descriptorInfo();
VkDescriptorBufferInfo bufInfo{}; VkDescriptorBufferInfo bufInfo{};
bufInfo.buffer = matUBO; bufInfo.buffer = matUBO;
bufInfo.offset = 0; bufInfo.offset = 0;
bufInfo.range = sizeof(CharMaterialUBO); 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].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[0].dstSet = materialSet; writes[0].dstSet = materialSet;
writes[0].dstBinding = 0; 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].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writes[1].pBufferInfo = &bufInfo; 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) // Bind material descriptor set (set 1)
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, 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{}; CharMaterialUBO matData{};
matData.opacity = instance.opacity; matData.opacity = instance.opacity;
matData.alphaTest = 0; matData.alphaTest = 0;
@ -1896,6 +2117,12 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
matData.emissiveTintG = 1.0f; matData.emissiveTintG = 1.0f;
matData.emissiveTintB = 1.0f; matData.emissiveTintB = 1.0f;
matData.specularIntensity = 0.5f; 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}; VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bci.size = sizeof(CharMaterialUBO); bci.size = sizeof(CharMaterialUBO);
@ -1916,8 +2143,9 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
bufInfo.buffer = matUBO; bufInfo.buffer = matUBO;
bufInfo.offset = 0; bufInfo.offset = 0;
bufInfo.range = sizeof(CharMaterialUBO); 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].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[0].dstSet = materialSet; writes[0].dstSet = materialSet;
writes[0].dstBinding = 0; 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].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writes[1].pBufferInfo = &bufInfo; 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, vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout_, 1, 1, &materialSet, 0, nullptr); pipelineLayout_, 1, 1, &materialSet, 0, nullptr);
@ -2066,20 +2301,20 @@ bool CharacterRenderer::initializeShadow(VkRenderPass shadowRenderPass) {
return false; 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 0: vec3 aPos (R32G32B32_SFLOAT, offset 0)
// loc 1: vec4 aBoneWeights (R8G8B8A8_UNORM, offset 12) // loc 1: vec4 aBoneWeights (R8G8B8A8_UNORM, offset 12)
// loc 2: ivec4 aBoneIndices (R8G8B8A8_UINT, offset 16) // loc 2: ivec4 aBoneIndices (R8G8B8A8_UINT, offset 16)
// loc 3: vec2 aTexCoord (R32G32_SFLOAT, offset 32) // loc 3: vec2 aTexCoord (R32G32_SFLOAT, offset 32)
VkVertexInputBindingDescription vertBind{}; VkVertexInputBindingDescription vertBind{};
vertBind.binding = 0; vertBind.binding = 0;
vertBind.stride = static_cast<uint32_t>(sizeof(pipeline::M2Vertex)); vertBind.stride = static_cast<uint32_t>(sizeof(CharVertexGPU));
vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> vertAttrs = { std::vector<VkVertexInputAttributeDescription> vertAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, position))}, {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneWeights))}, {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(CharVertexGPU, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneIndices))}, {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(CharVertexGPU, boneIndices))},
{3, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, texCoords))}, {3, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, texCoords))},
}; };
shadowPipeline_ = PipelineBuilder() shadowPipeline_ = PipelineBuilder()
@ -2755,15 +2990,16 @@ void CharacterRenderer::recreatePipelines() {
// --- Vertex input --- // --- Vertex input ---
VkVertexInputBindingDescription charBinding{}; VkVertexInputBindingDescription charBinding{};
charBinding.binding = 0; charBinding.binding = 0;
charBinding.stride = sizeof(pipeline::M2Vertex); charBinding.stride = sizeof(CharVertexGPU);
charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> charAttrs = { std::vector<VkVertexInputAttributeDescription> charAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, position))}, {0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneWeights))}, {1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(CharVertexGPU, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneIndices))}, {2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(CharVertexGPU, boneIndices))},
{3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, normal))}, {3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, normal))},
{4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, texCoords))}, {4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, texCoords))},
{5, 0, VK_FORMAT_R32G32B32A32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, tangent))},
}; };
auto buildCharPipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline { auto buildCharPipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline {

View file

@ -287,6 +287,12 @@ void GameScreen::render(game::GameHandler& gameHandler) {
wr->setNormalMapStrength(pendingNormalMapStrength); wr->setNormalMapStrength(pendingNormalMapStrength);
wr->setPOMEnabled(pendingPOM); wr->setPOMEnabled(pendingPOM);
wr->setPOMQuality(pendingPOMQuality); wr->setPOMQuality(pendingPOMQuality);
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(pendingNormalMapping);
cr->setNormalMapStrength(pendingNormalMapStrength);
cr->setPOMEnabled(pendingPOM);
cr->setPOMQuality(pendingPOMQuality);
}
normalMapSettingsApplied_ = true; normalMapSettingsApplied_ = true;
} }
} }
@ -5914,6 +5920,9 @@ void GameScreen::renderSettingsWindow() {
if (auto* wr = renderer->getWMORenderer()) { if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMappingEnabled(pendingNormalMapping); wr->setNormalMappingEnabled(pendingNormalMapping);
} }
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(pendingNormalMapping);
}
} }
saveSettings(); saveSettings();
} }
@ -5923,6 +5932,9 @@ void GameScreen::renderSettingsWindow() {
if (auto* wr = renderer->getWMORenderer()) { if (auto* wr = renderer->getWMORenderer()) {
wr->setNormalMapStrength(pendingNormalMapStrength); wr->setNormalMapStrength(pendingNormalMapStrength);
} }
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMapStrength(pendingNormalMapStrength);
}
} }
saveSettings(); saveSettings();
} }
@ -5932,6 +5944,9 @@ void GameScreen::renderSettingsWindow() {
if (auto* wr = renderer->getWMORenderer()) { if (auto* wr = renderer->getWMORenderer()) {
wr->setPOMEnabled(pendingPOM); wr->setPOMEnabled(pendingPOM);
} }
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setPOMEnabled(pendingPOM);
}
} }
saveSettings(); saveSettings();
} }
@ -5942,6 +5957,9 @@ void GameScreen::renderSettingsWindow() {
if (auto* wr = renderer->getWMORenderer()) { if (auto* wr = renderer->getWMORenderer()) {
wr->setPOMQuality(pendingPOMQuality); wr->setPOMQuality(pendingPOMQuality);
} }
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setPOMQuality(pendingPOMQuality);
}
} }
saveSettings(); saveSettings();
} }
@ -5991,6 +6009,12 @@ void GameScreen::renderSettingsWindow() {
wr->setPOMEnabled(pendingPOM); wr->setPOMEnabled(pendingPOM);
wr->setPOMQuality(pendingPOMQuality); wr->setPOMQuality(pendingPOMQuality);
} }
if (auto* cr = renderer->getCharacterRenderer()) {
cr->setNormalMappingEnabled(pendingNormalMapping);
cr->setNormalMapStrength(pendingNormalMapStrength);
cr->setPOMEnabled(pendingPOM);
cr->setPOMQuality(pendingPOMQuality);
}
} }
saveSettings(); saveSettings();
} }