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

@ -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<VkVertexInputAttributeDescription> charAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneIndices))},
{3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, normal))},
{4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, texCoords))},
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(CharVertexGPU, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(CharVertexGPU, boneIndices))},
{3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, normal))},
{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 ---
@ -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<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.
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<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) {
// 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<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_,
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<uint32_t>(model.vertices.size());
gpuModel.vertexCount = static_cast<uint32_t>(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<uint32_t>(model.indices.size());
gpuModel.indexCount = static_cast<uint32_t>(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<uint32_t>(sizeof(pipeline::M2Vertex));
vertBind.stride = static_cast<uint32_t>(sizeof(CharVertexGPU));
vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> vertAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneIndices))},
{3, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, texCoords))},
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(CharVertexGPU, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(CharVertexGPU, boneIndices))},
{3, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(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<VkVertexInputAttributeDescription> charAttrs = {
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, boneIndices))},
{3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, normal))},
{4, 0, VK_FORMAT_R32G32_SFLOAT, static_cast<uint32_t>(offsetof(pipeline::M2Vertex, texCoords))},
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, position))},
{1, 0, VK_FORMAT_R8G8B8A8_UNORM, static_cast<uint32_t>(offsetof(CharVertexGPU, boneWeights))},
{2, 0, VK_FORMAT_R8G8B8A8_UINT, static_cast<uint32_t>(offsetof(CharVertexGPU, boneIndices))},
{3, 0, VK_FORMAT_R32G32B32_SFLOAT, static_cast<uint32_t>(offsetof(CharVertexGPU, normal))},
{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 {