Kelsidavis-WoWee/src/rendering/character_renderer.cpp
Kelsi ea9c7e68e7 rendering,ui: sync selection circle to renderer instance position
The selection circle was positioned using the entity's game-logic
interpolator (entity->getX/Y/Z), while the actual M2 model is
positioned by CharacterRenderer's independent interpolator (moveInstanceTo).
These two systems can drift apart during movement, causing the circle
to appear under the wrong position relative to the visible model.

Fix: add CharacterRenderer::getInstancePosition / Application::getRenderPositionForGuid
and use the renderer's inst.position for XY (with footZ override for Z)
so the circle always tracks the rendered model exactly. Falls back to
the entity game-logic position when no CharacterRenderer instance exists.
2026-03-10 06:33:44 -07:00

3382 lines
144 KiB
C++

/**
* CharacterRenderer — GPU rendering of M2 character models with skeletal animation (Vulkan)
*
* Handles:
* - Uploading M2 vertex/index data to Vulkan buffers via VMA
* - Per-frame bone matrix computation (hierarchical, with keyframe interpolation)
* - GPU vertex skinning via a bone-matrix SSBO in the vertex shader
* - Per-batch texture binding through the M2 texture-lookup indirection
* - Geoset filtering (activeGeosets) to show/hide body part groups
* - CPU texture compositing for character skins (base skin + underwear overlays)
*
* The character texture compositing uses the WoW CharComponentTextureSections
* layout, placing region overlays (pelvis, torso, etc.) at their correct pixel
* positions on the 512x512 body skin atlas. Region coordinates sourced from
* the original WoW Model Viewer (charcontrol.h, REGION_FAC=2).
*/
#include "rendering/character_renderer.hpp"
#include "rendering/vk_context.hpp"
#include "rendering/vk_texture.hpp"
#include "rendering/vk_pipeline.hpp"
#include "rendering/vk_shader.hpp"
#include "rendering/vk_buffer.hpp"
#include "rendering/vk_utils.hpp"
#include "rendering/camera.hpp"
#include "pipeline/asset_manager.hpp"
#include "pipeline/blp_loader.hpp"
#include "core/logger.hpp"
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <glm/gtx/quaternion.hpp>
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <future>
#include <thread>
#include <functional>
#include <unordered_map>
#include <unordered_set>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <limits>
#include <cstring>
namespace wowee {
namespace rendering {
namespace {
size_t envSizeMBOrDefault(const char* name, size_t defMb) {
const char* v = std::getenv(name);
if (!v || !*v) return defMb;
char* end = nullptr;
unsigned long long mb = std::strtoull(v, &end, 10);
if (end == v || mb == 0) return defMb;
if (mb > (std::numeric_limits<size_t>::max() / (1024ull * 1024ull))) return defMb;
return static_cast<size_t>(mb);
}
size_t envSizeOrDefault(const char* name, size_t defValue) {
const char* v = std::getenv(name);
if (!v || !*v) return defValue;
char* end = nullptr;
unsigned long long n = std::strtoull(v, &end, 10);
if (end == v || n == 0) return defValue;
return static_cast<size_t>(n);
}
size_t approxTextureBytesWithMips(int w, int h) {
if (w <= 0 || h <= 0) return 0;
size_t base = static_cast<size_t>(w) * static_cast<size_t>(h) * 4ull;
return base + (base / 3); // ~4/3 for mip chain
}
} // namespace
// Descriptor pool sizing
static constexpr uint32_t MAX_MATERIAL_SETS = 4096;
static constexpr uint32_t MAX_BONE_SETS = 8192;
// CharMaterial UBO layout (matches character.frag.glsl set=1 binding=1)
struct CharMaterialUBO {
float opacity;
int32_t alphaTest;
int32_t colorKeyBlack;
int32_t unlit;
float emissiveBoost;
float emissiveTintR, emissiveTintG, emissiveTintB;
float specularIntensity;
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() {
shutdown();
}
bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout,
pipeline::AssetManager* am,
VkRenderPass renderPassOverride,
VkSampleCountFlagBits msaaSamples) {
core::Logger::getInstance().info("Initializing character renderer (Vulkan)...");
vkCtx_ = ctx;
assetManager = am;
perFrameLayout_ = perFrameLayout;
renderPassOverride_ = renderPassOverride;
msaaSamplesOverride_ = msaaSamples;
const unsigned hc = std::thread::hardware_concurrency();
const size_t availableCores = (hc > 1u) ? static_cast<size_t>(hc - 1u) : 1ull;
// Character updates run alongside M2/WMO work; default to a smaller share.
const size_t defaultAnimThreads = std::max<size_t>(1, availableCores / 4);
numAnimThreads_ = static_cast<uint32_t>(std::max<size_t>(
1, envSizeOrDefault("WOWEE_CHAR_ANIM_THREADS", defaultAnimThreads)));
core::Logger::getInstance().info("Character anim threads: ", numAnimThreads_);
VkDevice device = vkCtx_->getDevice();
// --- Descriptor set layouts ---
// Material set layout (set 1): binding 0 = sampler2D, binding 1 = CharMaterial UBO, binding 2 = normal/height map
{
VkDescriptorSetLayoutBinding bindings[3] = {};
bindings[0].binding = 0;
bindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
bindings[0].descriptorCount = 1;
bindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
bindings[1].binding = 1;
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 = 3;
ci.pBindings = bindings;
vkCreateDescriptorSetLayout(device, &ci, nullptr, &materialSetLayout_);
}
// Bone set layout (set 2): binding 0 = STORAGE_BUFFER (bone matrices)
{
VkDescriptorSetLayoutBinding binding{};
binding.binding = 0;
binding.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
binding.descriptorCount = 1;
binding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
ci.bindingCount = 1;
ci.pBindings = &binding;
vkCreateDescriptorSetLayout(device, &ci, nullptr, &boneSetLayout_);
}
// --- Descriptor pools ---
// Material descriptors are transient and allocated every draw; keep per-frame
// 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 * 2}, // diffuse + normal/height
{VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, MAX_MATERIAL_SETS},
};
VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
ci.maxSets = MAX_MATERIAL_SETS;
ci.poolSizeCount = 2;
ci.pPoolSizes = sizes;
ci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
vkCreateDescriptorPool(device, &ci, nullptr, &materialDescPools_[i]);
}
{
VkDescriptorPoolSize sizes[] = {
{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, MAX_BONE_SETS},
};
VkDescriptorPoolCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
ci.maxSets = MAX_BONE_SETS;
ci.poolSizeCount = 1;
ci.pPoolSizes = sizes;
ci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
vkCreateDescriptorPool(device, &ci, nullptr, &boneDescPool_);
}
// --- Material UBO ring buffers (one per frame slot) ---
{
VkPhysicalDeviceProperties props;
vkGetPhysicalDeviceProperties(ctx->getPhysicalDevice(), &props);
materialUboAlignment_ = static_cast<uint32_t>(props.limits.minUniformBufferOffsetAlignment);
if (materialUboAlignment_ < 1) materialUboAlignment_ = 1;
// Round up UBO size to alignment
uint32_t alignedUboSize = (sizeof(CharMaterialUBO) + materialUboAlignment_ - 1) & ~(materialUboAlignment_ - 1);
uint32_t ringSize = alignedUboSize * MATERIAL_RING_CAPACITY;
for (int i = 0; i < 2; i++) {
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bci.size = ringSize;
bci.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
VmaAllocationCreateInfo aci{};
aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
aci.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT | VMA_ALLOCATION_CREATE_MAPPED_BIT;
VmaAllocationInfo allocInfo{};
vmaCreateBuffer(ctx->getAllocator(), &bci, &aci,
&materialRingBuffer_[i], &materialRingAlloc_[i], &allocInfo);
materialRingMapped_[i] = allocInfo.pMappedData;
}
}
// --- Pipeline layout ---
// set 0 = perFrame, set 1 = material, set 2 = bones
// Push constant: mat4 model = 64 bytes
{
VkDescriptorSetLayout setLayouts[] = {perFrameLayout, materialSetLayout_, boneSetLayout_};
VkPushConstantRange pushRange{};
pushRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
pushRange.offset = 0;
pushRange.size = 64; // mat4
VkPipelineLayoutCreateInfo ci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
ci.setLayoutCount = 3;
ci.pSetLayouts = setLayouts;
ci.pushConstantRangeCount = 1;
ci.pPushConstantRanges = &pushRange;
vkCreatePipelineLayout(device, &ci, nullptr, &pipelineLayout_);
}
// --- Load shaders ---
rendering::VkShaderModule charVert, charFrag;
charVert.loadFromFile(device, "assets/shaders/character.vert.spv");
charFrag.loadFromFile(device, "assets/shaders/character.frag.spv");
if (!charVert.isValid() || !charFrag.isValid()) {
LOG_ERROR("Character: Missing required shaders, cannot initialize");
return false;
}
VkRenderPass mainPass = renderPassOverride_ ? renderPassOverride_ : vkCtx_->getImGuiRenderPass();
VkSampleCountFlagBits samples = renderPassOverride_ ? msaaSamplesOverride_ : vkCtx_->getMsaaSamples();
// --- Vertex input ---
// 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(CharVertexGPU);
charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> charAttrs = {
{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 ---
auto buildCharPipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite) -> VkPipeline {
return PipelineBuilder()
.setShaders(charVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
charFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({charBinding}, charAttrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL)
.setColorBlendAttachment(blendState)
.setMultisample(samples)
.setLayout(pipelineLayout_)
.setRenderPass(mainPass)
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
.build(device);
};
opaquePipeline_ = buildCharPipeline(PipelineBuilder::blendDisabled(), true);
alphaTestPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), true);
alphaPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), false);
additivePipeline_ = buildCharPipeline(PipelineBuilder::blendAdditive(), false);
// Clean up shader modules
charVert.destroy();
charFrag.destroy();
// --- Create white fallback texture ---
{
uint8_t white[] = {255, 255, 255, 255};
whiteTexture_ = std::make_unique<VkTexture>();
whiteTexture_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
whiteTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
// --- Create transparent fallback texture ---
{
uint8_t transparent[] = {0, 0, 0, 0};
transparentTexture_ = std::make_unique<VkTexture>();
transparentTexture_->upload(*vkCtx_, transparent, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
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", 4096) * 1024ull * 1024ull;
LOG_INFO("Character texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB");
core::Logger::getInstance().info("Character renderer initialized (Vulkan)");
return true;
}
void CharacterRenderer::shutdown() {
if (!vkCtx_) return;
LOG_INFO("CharacterRenderer::shutdown instances=", instances.size(),
" models=", models.size(), " override=", (void*)renderPassOverride_);
// Wait for any in-flight background normal map generation threads
while (pendingNormalMapCount_.load(std::memory_order_relaxed) > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
vkDeviceWaitIdle(vkCtx_->getDevice());
VkDevice device = vkCtx_->getDevice();
VmaAllocator alloc = vkCtx_->getAllocator();
// Clean up GPU resources for models
for (auto& pair : models) {
destroyModelGPU(pair.second);
}
// Clean up instance bone buffers
for (auto& pair : instances) {
destroyInstanceBones(pair.second);
}
// Clean up texture cache (VkTexture unique_ptrs auto-destroy)
textureCache.clear();
textureHasAlphaByPtr_.clear();
textureColorKeyBlackByPtr_.clear();
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
// Clean up composite cache
compositeCache_.clear();
failedTextureCache_.clear();
whiteTexture_.reset();
transparentTexture_.reset();
flatNormalTexture_.reset();
models.clear();
instances.clear();
// Destroy pipelines
auto destroyPipeline = [&](VkPipeline& p) {
if (p) { vkDestroyPipeline(device, p, nullptr); p = VK_NULL_HANDLE; }
};
destroyPipeline(opaquePipeline_);
destroyPipeline(alphaTestPipeline_);
destroyPipeline(alphaPipeline_);
destroyPipeline(additivePipeline_);
if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; }
// Destroy material ring buffers
for (int i = 0; i < 2; i++) {
if (materialRingBuffer_[i]) {
vmaDestroyBuffer(alloc, materialRingBuffer_[i], materialRingAlloc_[i]);
materialRingBuffer_[i] = VK_NULL_HANDLE;
materialRingAlloc_[i] = VK_NULL_HANDLE;
materialRingMapped_[i] = nullptr;
}
materialRingOffset_[i] = 0;
}
// Destroy descriptor pools and layouts
for (int i = 0; i < 2; i++) {
if (materialDescPools_[i]) {
vkDestroyDescriptorPool(device, materialDescPools_[i], nullptr);
materialDescPools_[i] = VK_NULL_HANDLE;
}
}
if (boneDescPool_) { vkDestroyDescriptorPool(device, boneDescPool_, nullptr); boneDescPool_ = VK_NULL_HANDLE; }
if (materialSetLayout_) { vkDestroyDescriptorSetLayout(device, materialSetLayout_, nullptr); materialSetLayout_ = VK_NULL_HANDLE; }
if (boneSetLayout_) { vkDestroyDescriptorSetLayout(device, boneSetLayout_, nullptr); boneSetLayout_ = VK_NULL_HANDLE; }
// Shadow resources
if (shadowPipeline_) { vkDestroyPipeline(device, shadowPipeline_, nullptr); shadowPipeline_ = VK_NULL_HANDLE; }
if (shadowPipelineLayout_) { vkDestroyPipelineLayout(device, shadowPipelineLayout_, nullptr); shadowPipelineLayout_ = VK_NULL_HANDLE; }
if (shadowParamsPool_) { vkDestroyDescriptorPool(device, shadowParamsPool_, nullptr); shadowParamsPool_ = VK_NULL_HANDLE; }
if (shadowParamsLayout_) { vkDestroyDescriptorSetLayout(device, shadowParamsLayout_, nullptr); shadowParamsLayout_ = VK_NULL_HANDLE; }
if (shadowParamsUBO_) { vmaDestroyBuffer(alloc, shadowParamsUBO_, shadowParamsAlloc_); shadowParamsUBO_ = VK_NULL_HANDLE; shadowParamsAlloc_ = VK_NULL_HANDLE; }
vkCtx_ = nullptr;
}
void CharacterRenderer::clear() {
if (!vkCtx_) return;
LOG_INFO("CharacterRenderer::clear instances=", instances.size(),
" models=", models.size());
// Wait for any in-flight background normal map generation threads
while (pendingNormalMapCount_.load(std::memory_order_relaxed) > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Discard any completed results that haven't been uploaded
{
std::lock_guard<std::mutex> lock(normalMapResultsMutex_);
completedNormalMaps_.clear();
}
vkDeviceWaitIdle(vkCtx_->getDevice());
VkDevice device = vkCtx_->getDevice();
// Destroy GPU resources for all models
for (auto& pair : models) {
destroyModelGPU(pair.second);
}
// Destroy bone buffers for all instances
for (auto& pair : instances) {
destroyInstanceBones(pair.second);
}
// Clear texture cache (VkTexture unique_ptrs auto-destroy)
textureCache.clear();
textureHasAlphaByPtr_.clear();
textureColorKeyBlackByPtr_.clear();
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
loggedTextureLoadFails_.clear();
// Clear composite and failed caches
compositeCache_.clear();
failedTextureCache_.clear();
// Recreate default textures (needed by loadModel/loadTexture fallbacks)
whiteTexture_.reset();
transparentTexture_.reset();
flatNormalTexture_.reset();
{
uint8_t white[] = {255, 255, 255, 255};
whiteTexture_ = std::make_unique<VkTexture>();
whiteTexture_->upload(*vkCtx_, white, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
whiteTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
{
uint8_t transparent[] = {0, 0, 0, 0};
transparentTexture_ = std::make_unique<VkTexture>();
transparentTexture_->upload(*vkCtx_, transparent, 1, 1, VK_FORMAT_R8G8B8A8_UNORM, false);
transparentTexture_->createSampler(device, VK_FILTER_NEAREST, VK_FILTER_NEAREST, VK_SAMPLER_ADDRESS_MODE_REPEAT);
}
{
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);
}
models.clear();
instances.clear();
// Reset material ring buffer offsets (buffers persist, just reset write position)
for (int i = 0; i < 2; i++) {
materialRingOffset_[i] = 0;
}
// Reset descriptor pools (don't destroy — reuse for new allocations)
for (int i = 0; i < 2; i++) {
if (materialDescPools_[i]) {
vkResetDescriptorPool(device, materialDescPools_[i], 0);
}
}
if (boneDescPool_) {
vkResetDescriptorPool(device, boneDescPool_, 0);
}
}
void CharacterRenderer::destroyModelGPU(M2ModelGPU& gpuModel) {
if (!vkCtx_) return;
VmaAllocator alloc = vkCtx_->getAllocator();
if (gpuModel.vertexBuffer) { vmaDestroyBuffer(alloc, gpuModel.vertexBuffer, gpuModel.vertexAlloc); gpuModel.vertexBuffer = VK_NULL_HANDLE; }
if (gpuModel.indexBuffer) { vmaDestroyBuffer(alloc, gpuModel.indexBuffer, gpuModel.indexAlloc); gpuModel.indexBuffer = VK_NULL_HANDLE; }
}
void CharacterRenderer::destroyInstanceBones(CharacterInstance& inst) {
if (!vkCtx_) return;
VmaAllocator alloc = vkCtx_->getAllocator();
VkDevice device = vkCtx_->getDevice();
for (int i = 0; i < 2; i++) {
if (inst.boneSet[i] != VK_NULL_HANDLE && boneDescPool_ != VK_NULL_HANDLE) {
vkFreeDescriptorSets(device, boneDescPool_, 1, &inst.boneSet[i]);
inst.boneSet[i] = VK_NULL_HANDLE;
}
if (inst.boneBuffer[i]) {
vmaDestroyBuffer(alloc, inst.boneBuffer[i], inst.boneAlloc[i]);
inst.boneBuffer[i] = VK_NULL_HANDLE;
inst.boneAlloc[i] = VK_NULL_HANDLE;
inst.boneMapped[i] = nullptr;
}
}
}
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;
// Use the CPU-only static method, then upload to GPU
std::vector<uint8_t> dummy(width * height * 4);
std::memcpy(dummy.data(), pixels, dummy.size());
auto result = generateNormalHeightMapCPU("", std::move(dummy), width, height);
outVariance = result.variance;
auto tex = std::make_unique<VkTexture>();
if (!tex->upload(*vkCtx_, result.pixels.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;
}
// Static, thread-safe CPU-only normal map generation (no GPU access)
CharacterRenderer::NormalMapResult CharacterRenderer::generateNormalHeightMapCPU(
std::string cacheKey, std::vector<uint8_t> srcPixels, uint32_t width, uint32_t height) {
NormalMapResult result;
result.cacheKey = std::move(cacheKey);
result.width = width;
result.height = height;
result.variance = 0.0f;
const uint32_t totalPixels = width * height;
const uint8_t* pixels = srcPixels.data();
// 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;
result.variance = 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
const float strength = 5.0f;
result.pixels.resize(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;
result.pixels[idx + 0] = static_cast<uint8_t>(std::clamp((nx * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
result.pixels[idx + 1] = static_cast<uint8_t>(std::clamp((ny * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
result.pixels[idx + 2] = static_cast<uint8_t>(std::clamp((nz * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
result.pixels[idx + 3] = static_cast<uint8_t>(std::clamp(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f));
}
}
return result;
}
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();
bool allWhitespace = true;
for (char c : path) {
if (c != ' ' && c != '\t' && c != '\0' && c != '\n') { allWhitespace = false; break; }
}
if (allWhitespace) return whiteTexture_.get();
auto normalizeKey = [](std::string key) {
std::replace(key.begin(), key.end(), '/', '\\');
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return key;
};
std::string key = normalizeKey(path);
auto containsToken = [](const std::string& haystack, const char* token) {
return haystack.find(token) != std::string::npos;
};
const bool colorKeyBlackHint =
containsToken(key, "candle") ||
containsToken(key, "flame") ||
containsToken(key, "fire") ||
containsToken(key, "torch");
// Check cache
auto it = textureCache.find(key);
if (it != textureCache.end()) {
it->second.lastUse = ++textureCacheCounter_;
return it->second.texture.get();
}
if (!assetManager || !assetManager->isInitialized()) {
return whiteTexture_.get();
}
// Check pre-decoded BLP cache first (populated by background threads)
pipeline::BLPImage blpImage;
if (predecodedBLPCache_) {
auto pit = predecodedBLPCache_->find(key);
if (pit != predecodedBLPCache_->end()) {
blpImage = std::move(pit->second);
predecodedBLPCache_->erase(pit);
}
}
if (!blpImage.isValid()) {
blpImage = assetManager->loadTexture(key);
}
if (!blpImage.isValid()) {
// Return white fallback but don't cache the failure — allow retry
// on next character load in case the asset becomes available.
if (loggedTextureLoadFails_.insert(key).second) {
core::Logger::getInstance().warning("Failed to load texture: ", path);
}
return whiteTexture_.get();
}
size_t approxBytes = approxTextureBytesWithMips(blpImage.width, blpImage.height);
if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) {
static constexpr size_t kMaxFailedTextureCache = 200000;
if (failedTextureCache_.size() < kMaxFailedTextureCache) {
// Budget is saturated; avoid repeatedly decoding/uploading this texture.
failedTextureCache_.insert(key);
}
if (textureBudgetRejectWarnings_ < 3) {
core::Logger::getInstance().warning(
"Character texture cache full (",
textureCacheBytes_ / (1024 * 1024), " MB / ",
textureCacheBudgetBytes_ / (1024 * 1024), " MB), rejecting texture: ",
path);
}
++textureBudgetRejectWarnings_;
return whiteTexture_.get();
}
bool hasAlpha = false;
for (size_t i = 3; i < blpImage.data.size(); i += 4) {
if (blpImage.data[i] != 255) {
hasAlpha = true;
break;
}
}
auto tex = std::make_unique<VkTexture>();
tex->upload(*vkCtx_, blpImage.data.data(), blpImage.width, blpImage.height,
VK_FORMAT_R8G8B8A8_UNORM, true);
tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_REPEAT);
VkTexture* texPtr = tex.get();
TextureCacheEntry e;
e.texture = std::move(tex);
e.approxBytes = approxBytes;
e.lastUse = ++textureCacheCounter_;
e.hasAlpha = hasAlpha;
e.colorKeyBlack = colorKeyBlackHint;
// Launch normal map generation on background thread — CPU work is pure compute,
// only the GPU upload (in processPendingNormalMaps) needs the main thread (~1-2ms).
if (blpImage.width >= 32 && blpImage.height >= 32) {
uint32_t w = blpImage.width, h = blpImage.height;
std::string ck = key;
std::vector<uint8_t> px(blpImage.data.begin(), blpImage.data.end());
pendingNormalMapCount_.fetch_add(1, std::memory_order_relaxed);
auto* self = this;
std::thread([self, ck = std::move(ck), px = std::move(px), w, h]() mutable {
auto result = generateNormalHeightMapCPU(std::move(ck), std::move(px), w, h);
{
std::lock_guard<std::mutex> lock(self->normalMapResultsMutex_);
self->completedNormalMaps_.push_back(std::move(result));
}
self->pendingNormalMapCount_.fetch_sub(1, std::memory_order_relaxed);
}).detach();
e.normalMapPending = true;
}
textureCacheBytes_ += e.approxBytes;
textureHasAlphaByPtr_[texPtr] = hasAlpha;
textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint;
textureCache[key] = std::move(e);
core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")");
return texPtr;
}
void CharacterRenderer::processPendingNormalMaps(int budget) {
if (!vkCtx_) return;
// Collect completed results from background threads
std::deque<NormalMapResult> ready;
{
std::lock_guard<std::mutex> lock(normalMapResultsMutex_);
if (completedNormalMaps_.empty()) return;
int count = std::min(budget, static_cast<int>(completedNormalMaps_.size()));
for (int i = 0; i < count; i++) {
ready.push_back(std::move(completedNormalMaps_.front()));
completedNormalMaps_.pop_front();
}
}
// GPU upload only (~1-2ms each) — CPU work already done on background thread
for (auto& result : ready) {
auto it = textureCache.find(result.cacheKey);
if (it == textureCache.end()) continue; // texture was evicted
vkCtx_->beginUploadBatch();
auto tex = std::make_unique<VkTexture>();
bool ok = tex->upload(*vkCtx_, result.pixels.data(), result.width, result.height,
VK_FORMAT_R8G8B8A8_UNORM, true);
if (ok) {
tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_REPEAT);
it->second.heightMapVariance = result.variance;
it->second.approxBytes += approxTextureBytesWithMips(result.width, result.height);
textureCacheBytes_ += approxTextureBytesWithMips(result.width, result.height);
it->second.normalHeightMap = std::move(tex);
}
vkCtx_->endUploadBatch();
it->second.normalMapPending = false;
}
}
// Alpha-blend overlay onto composite at (dstX, dstY)
static void blitOverlay(std::vector<uint8_t>& composite, int compW, int compH,
const pipeline::BLPImage& overlay, int dstX, int dstY) {
for (int sy = 0; sy < overlay.height; sy++) {
int dy = dstY + sy;
if (dy < 0 || dy >= compH) continue;
for (int sx = 0; sx < overlay.width; sx++) {
int dx = dstX + sx;
if (dx < 0 || dx >= compW) continue;
size_t srcIdx = (static_cast<size_t>(sy) * overlay.width + sx) * 4;
size_t dstIdx = (static_cast<size_t>(dy) * compW + dx) * 4;
uint8_t srcA = overlay.data[srcIdx + 3];
if (srcA == 0) continue;
if (srcA == 255) {
composite[dstIdx + 0] = overlay.data[srcIdx + 0];
composite[dstIdx + 1] = overlay.data[srcIdx + 1];
composite[dstIdx + 2] = overlay.data[srcIdx + 2];
composite[dstIdx + 3] = 255;
} else {
float alpha = srcA / 255.0f;
float invAlpha = 1.0f - alpha;
composite[dstIdx + 0] = static_cast<uint8_t>(overlay.data[srcIdx + 0] * alpha + composite[dstIdx + 0] * invAlpha);
composite[dstIdx + 1] = static_cast<uint8_t>(overlay.data[srcIdx + 1] * alpha + composite[dstIdx + 1] * invAlpha);
composite[dstIdx + 2] = static_cast<uint8_t>(overlay.data[srcIdx + 2] * alpha + composite[dstIdx + 2] * invAlpha);
composite[dstIdx + 3] = std::max(composite[dstIdx + 3], srcA);
}
}
}
}
// Nearest-neighbor NxN scale blit of overlay onto composite at (dstX, dstY)
static void blitOverlayScaledN(std::vector<uint8_t>& composite, int compW, int compH,
const pipeline::BLPImage& overlay, int dstX, int dstY, int scale) {
if (scale < 1) scale = 1;
for (int sy = 0; sy < overlay.height; sy++) {
for (int sx = 0; sx < overlay.width; sx++) {
size_t srcIdx = (static_cast<size_t>(sy) * overlay.width + sx) * 4;
uint8_t srcA = overlay.data[srcIdx + 3];
if (srcA == 0) continue;
// Write to scale x scale block of destination pixels
for (int dy2 = 0; dy2 < scale; dy2++) {
int dy = dstY + sy * scale + dy2;
if (dy < 0 || dy >= compH) continue;
for (int dx2 = 0; dx2 < scale; dx2++) {
int dx = dstX + sx * scale + dx2;
if (dx < 0 || dx >= compW) continue;
size_t dstIdx = (static_cast<size_t>(dy) * compW + dx) * 4;
if (srcA == 255) {
composite[dstIdx + 0] = overlay.data[srcIdx + 0];
composite[dstIdx + 1] = overlay.data[srcIdx + 1];
composite[dstIdx + 2] = overlay.data[srcIdx + 2];
composite[dstIdx + 3] = 255;
} else {
float alpha = srcA / 255.0f;
float invAlpha = 1.0f - alpha;
composite[dstIdx + 0] = static_cast<uint8_t>(overlay.data[srcIdx + 0] * alpha + composite[dstIdx + 0] * invAlpha);
composite[dstIdx + 1] = static_cast<uint8_t>(overlay.data[srcIdx + 1] * alpha + composite[dstIdx + 1] * invAlpha);
composite[dstIdx + 2] = static_cast<uint8_t>(overlay.data[srcIdx + 2] * alpha + composite[dstIdx + 2] * invAlpha);
composite[dstIdx + 3] = std::max(composite[dstIdx + 3], srcA);
}
}
}
}
}
}
// Legacy 2x wrapper
static void blitOverlayScaled2x(std::vector<uint8_t>& composite, int compW, int compH,
const pipeline::BLPImage& overlay, int dstX, int dstY) {
blitOverlayScaledN(composite, compW, compH, overlay, dstX, dstY, 2);
}
// Nearest-neighbor downscale blit: sample every Nth pixel from overlay
static void blitOverlayDownscaleN(std::vector<uint8_t>& composite, int compW, int compH,
const pipeline::BLPImage& overlay, int dstX, int dstY, int scale) {
if (scale < 2) { blitOverlay(composite, compW, compH, overlay, dstX, dstY); return; }
int outW = overlay.width / scale;
int outH = overlay.height / scale;
for (int oy = 0; oy < outH; oy++) {
int dy = dstY + oy;
if (dy < 0 || dy >= compH) continue;
for (int ox = 0; ox < outW; ox++) {
int dx = dstX + ox;
if (dx < 0 || dx >= compW) continue;
int sx = ox * scale;
int sy = oy * scale;
size_t srcIdx = (static_cast<size_t>(sy) * overlay.width + sx) * 4;
size_t dstIdx = (static_cast<size_t>(dy) * compW + dx) * 4;
uint8_t srcA = overlay.data[srcIdx + 3];
if (srcA == 0) continue;
if (srcA == 255) {
composite[dstIdx + 0] = overlay.data[srcIdx + 0];
composite[dstIdx + 1] = overlay.data[srcIdx + 1];
composite[dstIdx + 2] = overlay.data[srcIdx + 2];
composite[dstIdx + 3] = 255;
} else {
float alpha = srcA / 255.0f;
float invAlpha = 1.0f - alpha;
composite[dstIdx + 0] = static_cast<uint8_t>(overlay.data[srcIdx + 0] * alpha + composite[dstIdx + 0] * invAlpha);
composite[dstIdx + 1] = static_cast<uint8_t>(overlay.data[srcIdx + 1] * alpha + composite[dstIdx + 1] * invAlpha);
composite[dstIdx + 2] = static_cast<uint8_t>(overlay.data[srcIdx + 2] * alpha + composite[dstIdx + 2] * invAlpha);
composite[dstIdx + 3] = std::max(composite[dstIdx + 3], srcA);
}
}
}
}
VkTexture* CharacterRenderer::compositeTextures(const std::vector<std::string>& layerPaths) {
if (layerPaths.empty() || !assetManager || !assetManager->isInitialized()) {
return whiteTexture_.get();
}
// Composite key is deterministic from layer set; if we've already built it,
// reuse the existing GPU texture to keep live instance pointers valid.
std::string cacheKey = "__composite__";
for (const auto& lp : layerPaths) { cacheKey += '|'; cacheKey += lp; }
auto cachedComposite = textureCache.find(cacheKey);
if (cachedComposite != textureCache.end()) {
cachedComposite->second.lastUse = ++textureCacheCounter_;
return cachedComposite->second.texture.get();
}
// Load base layer
pipeline::BLPImage base;
if (predecodedBLPCache_) {
std::string key = layerPaths[0];
std::replace(key.begin(), key.end(), '/', '\\');
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
auto pit = predecodedBLPCache_->find(key);
if (pit != predecodedBLPCache_->end()) {
base = std::move(pit->second);
predecodedBLPCache_->erase(pit);
}
}
if (!base.isValid()) base = assetManager->loadTexture(layerPaths[0]);
if (!base.isValid()) {
core::Logger::getInstance().warning("Composite: failed to load base layer: ", layerPaths[0]);
return whiteTexture_.get();
}
// Copy base pixel data as our working buffer
std::vector<uint8_t> composite = base.data;
int width = base.width;
int height = base.height;
core::Logger::getInstance().info("Composite: base layer ", width, "x", height, " from ", layerPaths[0]);
// WoW character texture atlas regions (from WoW Model Viewer / CharComponentTextureSections)
// Coordinates at 256x256 base resolution:
// Region X Y W H
// Base 0 0 256 256
// Arm Upper 0 0 128 64
// Arm Lower 0 64 128 64
// Hand 0 128 128 32
// Face Upper 0 160 128 32
// Face Lower 0 192 128 64
// Torso Upper 128 0 128 64
// Torso Lower 128 64 128 32
// Pelvis Upper 128 96 128 64
// Pelvis Lower 128 160 128 64
// Foot 128 224 128 32
// Scale factor: base texture may be larger than the 256x256 reference atlas
int coordScale = width / 256;
if (coordScale < 1) coordScale = 1;
// Atlas region sizes at 256x256 base (w, h) for known regions
struct AtlasRegion { int x, y, w, h; };
static const AtlasRegion faceLowerRegion256 = {0, 192, 128, 64};
static const AtlasRegion faceUpperRegion256 = {0, 160, 128, 32};
// Alpha-blend each overlay onto the composite
for (size_t layer = 1; layer < layerPaths.size(); layer++) {
if (layerPaths[layer].empty()) continue;
pipeline::BLPImage overlay;
if (predecodedBLPCache_) {
std::string key = layerPaths[layer];
std::replace(key.begin(), key.end(), '/', '\\');
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
auto pit = predecodedBLPCache_->find(key);
if (pit != predecodedBLPCache_->end()) {
overlay = std::move(pit->second);
predecodedBLPCache_->erase(pit);
}
}
if (!overlay.isValid()) overlay = assetManager->loadTexture(layerPaths[layer]);
if (!overlay.isValid()) {
core::Logger::getInstance().warning("Composite: FAILED to load overlay: ", layerPaths[layer]);
continue;
}
core::Logger::getInstance().info("Composite: overlay ", layerPaths[layer],
" (", overlay.width, "x", overlay.height, ")");
if (overlay.width == width && overlay.height == height) {
// Same size: full alpha-blend
blitOverlay(composite, width, height, overlay, 0, 0);
} else {
// Determine region by filename keywords
// Coordinates scale with base texture size (256x256 is reference)
int dstX = 0, dstY = 0;
int expectedW256 = 0, expectedH256 = 0; // Expected size at 256-base
std::string pathLower = layerPaths[layer];
for (auto& c : pathLower) c = std::tolower(c);
if (pathLower.find("faceupper") != std::string::npos) {
dstX = faceUpperRegion256.x; dstY = faceUpperRegion256.y;
expectedW256 = faceUpperRegion256.w; expectedH256 = faceUpperRegion256.h;
} else if (pathLower.find("facelower") != std::string::npos) {
dstX = faceLowerRegion256.x; dstY = faceLowerRegion256.y;
expectedW256 = faceLowerRegion256.w; expectedH256 = faceLowerRegion256.h;
} else if (pathLower.find("pelvis") != std::string::npos) {
dstX = 128; dstY = 96;
expectedW256 = 128; expectedH256 = 64;
} else if (pathLower.find("torso") != std::string::npos) {
dstX = 128; dstY = 0;
expectedW256 = 128; expectedH256 = 64;
} else if (pathLower.find("armupper") != std::string::npos) {
dstX = 0; dstY = 0;
expectedW256 = 128; expectedH256 = 64;
} else if (pathLower.find("armlower") != std::string::npos) {
dstX = 0; dstY = 64;
expectedW256 = 128; expectedH256 = 64;
} else if (pathLower.find("hand") != std::string::npos) {
dstX = 0; dstY = 128;
expectedW256 = 128; expectedH256 = 32;
} else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) {
dstX = 128; dstY = 224;
expectedW256 = 128; expectedH256 = 32;
} else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) {
dstX = 128; dstY = 160;
expectedW256 = 128; expectedH256 = 64;
} else {
// Unknown -- center placement as fallback
dstX = (width - overlay.width) / 2;
dstY = (height - overlay.height) / 2;
core::Logger::getInstance().info("Composite: UNKNOWN region for '",
layerPaths[layer], "', centering at (", dstX, ",", dstY, ")");
blitOverlay(composite, width, height, overlay, dstX, dstY);
continue;
}
// Scale coordinates from 256-base to actual canvas
dstX *= coordScale;
dstY *= coordScale;
// If overlay is 256-base sized but canvas is larger, scale the overlay up
int expectedW = expectedW256 * coordScale;
int expectedH = expectedH256 * coordScale;
bool needsScale = (coordScale > 1 &&
overlay.width == expectedW256 && overlay.height == expectedH256);
core::Logger::getInstance().info("Composite: placing '", layerPaths[layer],
"' (", overlay.width, "x", overlay.height,
") at (", dstX, ",", dstY, ") on ", width, "x", height,
" expected=", expectedW, "x", expectedH,
needsScale ? " [SCALING]" : "");
if (needsScale) {
blitOverlayScaledN(composite, width, height, overlay, dstX, dstY, coordScale);
} else {
blitOverlay(composite, width, height, overlay, dstX, dstY);
}
}
}
// Debug: dump composite to temp dir for visual inspection
{
std::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" +
std::to_string(width) + "x" + std::to_string(height) + ".raw")).string();
std::ofstream dump(dumpPath, std::ios::binary);
if (dump) {
dump.write(reinterpret_cast<const char*>(composite.data()),
static_cast<std::streamsize>(composite.size()));
core::Logger::getInstance().info("Composite debug dump: ", dumpPath,
" (", width, "x", height, ", ", composite.size(), " bytes)");
}
}
// Upload composite to GPU via VkTexture
auto tex = std::make_unique<VkTexture>();
tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true);
tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_REPEAT);
VkTexture* texPtr = tex.get();
// Store in texture cache with deterministic key.
// Keep the first allocation for a key to avoid invalidating raw pointers
// held by active render instances.
TextureCacheEntry e;
e.texture = std::move(tex);
e.approxBytes = approxTextureBytesWithMips(width, height);
e.lastUse = ++textureCacheCounter_;
e.hasAlpha = false;
e.colorKeyBlack = false;
textureCache.emplace(cacheKey, std::move(e));
core::Logger::getInstance().info("Composite texture created: ", width, "x", height, " from ", layerPaths.size(), " layers");
return texPtr;
}
void CharacterRenderer::clearCompositeCache() {
// Just clear the lookup map so next compositeWithRegions() creates fresh textures.
// Don't delete GPU textures -- they may still be referenced by models or instances.
// Orphaned textures will be cleaned up when their model/instance is destroyed.
compositeCache_.clear();
}
VkTexture* CharacterRenderer::compositeWithRegions(const std::string& basePath,
const std::vector<std::string>& baseLayers,
const std::vector<std::pair<int, std::string>>& regionLayers) {
// Build cache key from all inputs to avoid redundant compositing
std::string cacheKey = basePath;
for (const auto& bl : baseLayers) { cacheKey += '|'; cacheKey += bl; }
cacheKey += '#';
for (const auto& rl : regionLayers) {
cacheKey += std::to_string(rl.first);
cacheKey += ':';
cacheKey += rl.second;
cacheKey += ',';
}
auto cacheIt = compositeCache_.find(cacheKey);
if (cacheIt != compositeCache_.end() && cacheIt->second != nullptr) {
return cacheIt->second;
}
// If the lookup map was cleared, recover from the texture cache without
// regenerating/replacing the underlying GPU texture.
std::string storageKey = "__compositeRegions__" + cacheKey;
auto cachedComposite = textureCache.find(storageKey);
if (cachedComposite != textureCache.end()) {
cachedComposite->second.lastUse = ++textureCacheCounter_;
VkTexture* texPtr = cachedComposite->second.texture.get();
compositeCache_[cacheKey] = texPtr;
return texPtr;
}
// Region index -> pixel coordinates on the 256x256 base atlas
// These are scaled up by (width/256, height/256) for larger textures (512x512, 1024x1024)
static const int regionCoords256[][2] = {
{ 0, 0 }, // 0 = ArmUpper
{ 0, 64 }, // 1 = ArmLower
{ 0, 128 }, // 2 = Hand
{ 128, 0 }, // 3 = TorsoUpper
{ 128, 64 }, // 4 = TorsoLower
{ 128, 96 }, // 5 = LegUpper
{ 128, 160 }, // 6 = LegLower
{ 128, 224 }, // 7 = Foot
};
// First, build base skin + underwear using existing compositeTextures
std::vector<std::string> layers;
layers.push_back(basePath);
for (const auto& ul : baseLayers) {
layers.push_back(ul);
}
// Load base composite into CPU buffer
if (!assetManager || !assetManager->isInitialized()) {
return whiteTexture_.get();
}
pipeline::BLPImage base;
if (predecodedBLPCache_) {
std::string key = basePath;
std::replace(key.begin(), key.end(), '/', '\\');
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
auto pit = predecodedBLPCache_->find(key);
if (pit != predecodedBLPCache_->end()) {
base = std::move(pit->second);
predecodedBLPCache_->erase(pit);
}
}
if (!base.isValid()) base = assetManager->loadTexture(basePath);
if (!base.isValid()) {
return whiteTexture_.get();
}
std::vector<uint8_t> composite;
int width = base.width;
int height = base.height;
// If base texture is 256x256 (e.g., baked NPC texture), upscale to 512x512
// so equipment regions can be composited at correct coordinates
if (width == 256 && height == 256 && !regionLayers.empty()) {
width = 512;
height = 512;
composite.resize(width * height * 4);
// Simple 2x nearest-neighbor upscale
for (int y = 0; y < 512; y++) {
for (int x = 0; x < 512; x++) {
int srcX = x / 2;
int srcY = y / 2;
int srcIdx = (srcY * 256 + srcX) * 4;
int dstIdx = (y * 512 + x) * 4;
composite[dstIdx + 0] = base.data[srcIdx + 0];
composite[dstIdx + 1] = base.data[srcIdx + 1];
composite[dstIdx + 2] = base.data[srcIdx + 2];
composite[dstIdx + 3] = base.data[srcIdx + 3];
}
}
core::Logger::getInstance().debug("compositeWithRegions: upscaled 256x256 to 512x512");
} else {
composite = base.data;
}
// Blend face + underwear overlays
// If we upscaled from 256->512, scale coords and texels with blitOverlayScaled2x.
// For native 512/1024 textures, face overlays are full atlas size (hit width==width branch).
bool upscaled = (base.width == 256 && base.height == 256 && width == 512);
for (const auto& ul : baseLayers) {
if (ul.empty()) continue;
pipeline::BLPImage overlay;
if (predecodedBLPCache_) {
std::string key = ul;
std::replace(key.begin(), key.end(), '/', '\\');
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
auto pit = predecodedBLPCache_->find(key);
if (pit != predecodedBLPCache_->end()) {
overlay = std::move(pit->second);
predecodedBLPCache_->erase(pit);
}
}
if (!overlay.isValid()) overlay = assetManager->loadTexture(ul);
if (!overlay.isValid()) continue;
if (overlay.width == width && overlay.height == height) {
blitOverlay(composite, width, height, overlay, 0, 0);
} else {
// WoW 256-scale atlas coordinates (from CharComponentTextureSections)
int dstX = 0, dstY = 0;
std::string pathLower = ul;
for (auto& c : pathLower) c = std::tolower(c);
// Scale factor from 256-base coordinates to actual canvas size
int coordScale = width / 256;
if (coordScale < 1) coordScale = 1;
bool useScale = true;
if (pathLower.find("faceupper") != std::string::npos) {
dstX = 0; dstY = 160;
} else if (pathLower.find("facelower") != std::string::npos) {
dstX = 0; dstY = 192;
} else if (pathLower.find("pelvis") != std::string::npos) {
dstX = 128; dstY = 96;
} else if (pathLower.find("torso") != std::string::npos) {
dstX = 128; dstY = 0;
} else if (pathLower.find("armupper") != std::string::npos) {
dstX = 0; dstY = 0;
} else if (pathLower.find("armlower") != std::string::npos) {
dstX = 0; dstY = 64;
} else if (pathLower.find("hand") != std::string::npos) {
dstX = 0; dstY = 128;
} else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) {
dstX = 128; dstY = 224;
} else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) {
dstX = 128; dstY = 160;
} else {
// Fallback: center overlay on canvas (already in canvas coords)
dstX = (width - overlay.width) / 2;
dstY = (height - overlay.height) / 2;
useScale = false;
}
if (useScale) {
dstX *= coordScale;
dstY *= coordScale;
}
if (upscaled) {
// Overlay is 256-base sized, needs 2x texel scaling for 512 canvas
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
} else {
blitOverlay(composite, width, height, overlay, dstX, dstY);
}
}
}
// Expected region sizes on the 256x256 base atlas (scaled like coords)
static const int regionSizes256[][2] = {
{ 128, 64 }, // 0 = ArmUpper
{ 128, 64 }, // 1 = ArmLower
{ 128, 32 }, // 2 = Hand
{ 128, 64 }, // 3 = TorsoUpper
{ 128, 32 }, // 4 = TorsoLower
{ 128, 64 }, // 5 = LegUpper
{ 128, 64 }, // 6 = LegLower
{ 128, 32 }, // 7 = Foot
};
// Scale factor from 256-base to actual texture size
int scaleX = width / 256;
int scaleY = height / 256;
if (scaleX < 1) scaleX = 1;
if (scaleY < 1) scaleY = 1;
// Now blit equipment region textures at explicit coordinates
for (const auto& rl : regionLayers) {
int regionIdx = rl.first;
if (regionIdx < 0 || regionIdx >= 8) continue;
pipeline::BLPImage overlay;
if (predecodedBLPCache_) {
std::string key = rl.second;
std::replace(key.begin(), key.end(), '/', '\\');
std::transform(key.begin(), key.end(), key.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
auto pit = predecodedBLPCache_->find(key);
if (pit != predecodedBLPCache_->end()) {
overlay = std::move(pit->second);
predecodedBLPCache_->erase(pit);
}
}
if (!overlay.isValid()) overlay = assetManager->loadTexture(rl.second);
if (!overlay.isValid()) {
core::Logger::getInstance().warning("compositeWithRegions: failed to load ", rl.second);
continue;
}
int dstX = regionCoords256[regionIdx][0] * scaleX;
int dstY = regionCoords256[regionIdx][1] * scaleY;
// Expected full-resolution size for this region at current atlas scale
int expectedW = regionSizes256[regionIdx][0] * scaleX;
int expectedH = regionSizes256[regionIdx][1] * scaleY;
if (overlay.width == expectedW && overlay.height == expectedH) {
// Exact match — blit 1:1
blitOverlay(composite, width, height, overlay, dstX, dstY);
} else if (overlay.width * 2 == expectedW && overlay.height * 2 == expectedH) {
// Overlay is half size — upscale 2x
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
} else if (overlay.width > expectedW && overlay.height > expectedH &&
expectedW > 0 && expectedH > 0) {
// Overlay is larger than region (e.g. HD textures for 1024 atlas on 512 canvas)
// Downscale to fit
int dsX = overlay.width / expectedW;
int dsY = overlay.height / expectedH;
int ds = std::min(dsX, dsY);
if (ds >= 2) {
blitOverlayDownscaleN(composite, width, height, overlay, dstX, dstY, ds);
} else {
blitOverlay(composite, width, height, overlay, dstX, dstY);
}
} else {
blitOverlay(composite, width, height, overlay, dstX, dstY);
}
core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx,
" at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height,
" expected=", expectedW, "x", expectedH, " from ", rl.second);
}
// Upload to GPU via VkTexture
auto tex = std::make_unique<VkTexture>();
tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true);
tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_REPEAT);
VkTexture* texPtr = tex.get();
// Store in texture cache.
// Use emplace to avoid replacing an existing texture for this key; replacing
// would invalidate pointers currently bound to active instances.
TextureCacheEntry entry;
entry.texture = std::move(tex);
entry.approxBytes = approxTextureBytesWithMips(width, height);
entry.lastUse = ++textureCacheCounter_;
entry.hasAlpha = false;
entry.colorKeyBlack = false;
auto ins = textureCache.emplace(storageKey, std::move(entry));
if (!ins.second) {
// Existing texture already owns this key; keep pointer stable.
ins.first->second.lastUse = ++textureCacheCounter_;
compositeCache_[cacheKey] = ins.first->second.texture.get();
return ins.first->second.texture.get();
}
core::Logger::getInstance().debug("compositeWithRegions: created ", width, "x", height,
" texture with ", regionLayers.size(), " equipment regions");
compositeCache_[cacheKey] = texPtr;
return texPtr;
}
void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, VkTexture* texture) {
auto it = models.find(modelId);
if (it == models.end()) {
core::Logger::getInstance().warning("setModelTexture: model ", modelId, " not found");
return;
}
auto& gpuModel = it->second;
if (textureSlot >= gpuModel.textureIds.size()) {
core::Logger::getInstance().warning("setModelTexture: slot ", textureSlot, " out of range (", gpuModel.textureIds.size(), " textures)");
return;
}
gpuModel.textureIds[textureSlot] = texture;
core::Logger::getInstance().debug("Replaced model ", modelId, " texture slot ", textureSlot, " with composited texture");
}
void CharacterRenderer::resetModelTexture(uint32_t modelId, uint32_t textureSlot) {
setModelTexture(modelId, textureSlot, whiteTexture_.get());
}
bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) {
if (!model.isValid()) {
core::Logger::getInstance().error("Cannot load invalid M2 model");
return false;
}
if (models.find(id) != models.end()) {
core::Logger::getInstance().warning("Model ID ", id, " already loaded, replacing");
destroyModelGPU(models[id]);
}
M2ModelGPU gpuModel;
gpuModel.data = model;
// Batch all GPU uploads (VB, IB, textures) into a single command buffer
// submission with one fence wait, instead of one fence wait per upload.
vkCtx_->beginUploadBatch();
// Setup GPU buffers
setupModelBuffers(gpuModel);
// Calculate bind pose
calculateBindPose(gpuModel);
// Load textures from model
for (const auto& tex : model.textures) {
VkTexture* texPtr = loadTexture(tex.filename);
gpuModel.textureIds.push_back(texPtr);
}
vkCtx_->endUploadBatch();
models[id] = std::move(gpuModel);
core::Logger::getInstance().debug("Loaded M2 model ", id, " (", model.vertices.size(),
" verts, ", model.bones.size(), " bones, ", model.sequences.size(),
" anims, ", model.textures.size(), " textures)");
return true;
}
void CharacterRenderer::setupModelBuffers(M2ModelGPU& gpuModel) {
auto& model = gpuModel.data;
if (model.vertices.empty() || model.indices.empty()) return;
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_,
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>(vertCount);
// Upload index buffer
auto ib = uploadBuffer(*vkCtx_,
model.indices.data(),
idxCount * sizeof(uint16_t),
VK_BUFFER_USAGE_INDEX_BUFFER_BIT);
gpuModel.indexBuffer = ib.buffer;
gpuModel.indexAlloc = ib.allocation;
gpuModel.indexCount = static_cast<uint32_t>(idxCount);
}
void CharacterRenderer::calculateBindPose(M2ModelGPU& gpuModel) {
auto& bones = gpuModel.data.bones;
size_t numBones = bones.size();
gpuModel.bindPose.resize(numBones);
// Compute full hierarchical rest pose, then invert.
// Each bone's rest position is T(pivot), composed with its parent chain.
std::vector<glm::mat4> restPose(numBones);
for (size_t i = 0; i < numBones; i++) {
glm::mat4 local = glm::translate(glm::mat4(1.0f), bones[i].pivot);
if (bones[i].parentBone >= 0 && static_cast<size_t>(bones[i].parentBone) < numBones) {
restPose[i] = restPose[bones[i].parentBone] * local;
} else {
restPose[i] = local;
}
gpuModel.bindPose[i] = glm::inverse(restPose[i]);
}
}
uint32_t CharacterRenderer::createInstance(uint32_t modelId, const glm::vec3& position,
const glm::vec3& rotation, float scale) {
if (models.find(modelId) == models.end()) {
core::Logger::getInstance().error("Cannot create instance: model ", modelId, " not loaded");
return 0;
}
CharacterInstance instance;
instance.id = nextInstanceId++;
instance.modelId = modelId;
instance.position = position;
instance.rotation = rotation;
instance.scale = scale;
// Initialize bone matrices to identity
auto& gpuRef = models[modelId];
instance.boneMatrices.resize(std::max(static_cast<size_t>(1), gpuRef.data.bones.size()), glm::mat4(1.0f));
instance.cachedModel = &gpuRef;
uint32_t id = instance.id;
instances[id] = std::move(instance);
return id;
}
void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, bool loop) {
auto it = instances.find(instanceId);
if (it == instances.end()) {
core::Logger::getInstance().warning("Cannot play animation: instance ", instanceId, " not found");
return;
}
auto& instance = it->second;
auto& model = models[instance.modelId].data;
// Track death state for preventing movement while dead
if (animationId == 1) {
instance.isDead = true;
} else if (instance.isDead && animationId == 0) {
instance.isDead = false; // Respawned
}
// Find animation sequence index by ID
instance.currentAnimationId = animationId;
instance.currentSequenceIndex = -1;
instance.animationTime = 0.0f;
instance.animationLoop = loop;
for (size_t i = 0; i < model.sequences.size(); i++) {
if (model.sequences[i].id == animationId) {
instance.currentSequenceIndex = static_cast<int>(i);
break;
}
}
if (instance.currentSequenceIndex < 0) {
// Fall back to first sequence
if (!model.sequences.empty()) {
instance.currentSequenceIndex = 0;
instance.currentAnimationId = model.sequences[0].id;
}
// Only log missing animation once per model (reduce spam)
static std::unordered_map<uint32_t, std::unordered_set<uint32_t>> loggedMissingAnims;
uint32_t mId = instance.modelId; // Use modelId as identifier
if (loggedMissingAnims[mId].insert(animationId).second) {
// First time seeing this missing animation for this model
LOG_WARNING("Animation ", animationId, " not found in model ", mId, ", using default");
}
}
}
void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
// Distance culling for animation updates in dense areas.
const float animUpdateRadius = static_cast<float>(envSizeOrDefault("WOWEE_CHAR_ANIM_RADIUS", 120));
const float animUpdateRadiusSq = animUpdateRadius * animUpdateRadius;
// Single pass: fade-in, movement, and animation bone collection
std::vector<std::reference_wrapper<CharacterInstance>> toUpdate;
toUpdate.reserve(instances.size());
for (auto& pair : instances) {
auto& inst = pair.second;
// Update fade-in opacity
if (inst.fadeInDuration > 0.0f && inst.opacity < 1.0f) {
inst.fadeInTime += deltaTime;
inst.opacity = std::min(1.0f, inst.fadeInTime / inst.fadeInDuration);
if (inst.opacity >= 1.0f) {
inst.fadeInDuration = 0.0f;
}
}
// Interpolate creature movement
if (inst.isMoving) {
inst.moveElapsed += deltaTime;
float t = inst.moveElapsed / inst.moveDuration;
if (t >= 1.0f) {
inst.position = inst.moveEnd;
inst.isMoving = false;
// Return to idle when movement completes
if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) {
playAnimation(pair.first, 0, true);
}
} else {
inst.position = glm::mix(inst.moveStart, inst.moveEnd, t);
}
}
// Skip weapon instances for animation — their transforms are set by parent bones
if (inst.hasOverrideModelMatrix) continue;
float distSq = glm::distance2(inst.position, cameraPos);
if (distSq >= animUpdateRadiusSq) continue;
// Always advance animation time (cheap)
if (inst.cachedModel && !inst.cachedModel->data.sequences.empty()) {
if (inst.currentSequenceIndex < 0) {
inst.currentSequenceIndex = 0;
inst.currentAnimationId = inst.cachedModel->data.sequences[0].id;
}
const auto& seq = inst.cachedModel->data.sequences[inst.currentSequenceIndex];
inst.animationTime += deltaTime * 1000.0f;
if (seq.duration > 0 && inst.animationTime >= static_cast<float>(seq.duration)) {
if (inst.animationLoop) {
inst.animationTime = std::fmod(inst.animationTime, static_cast<float>(seq.duration));
} else {
inst.animationTime = static_cast<float>(seq.duration);
}
}
}
// Distance-tiered bone throttling: near=every frame, mid=every 4th, far=every 8th
uint32_t boneInterval = 1;
if (distSq > 40.0f * 40.0f) boneInterval = 8;
else if (distSq > 20.0f * 20.0f) boneInterval = 4;
else if (distSq > 10.0f * 10.0f) boneInterval = 2;
inst.boneUpdateCounter++;
bool needsBones = (inst.boneUpdateCounter >= boneInterval) || inst.boneMatrices.empty();
if (needsBones) {
inst.boneUpdateCounter = 0;
toUpdate.push_back(std::ref(inst));
}
}
const size_t updatedCount = toUpdate.size();
// Thread bone matrix computation in chunks
if (updatedCount >= 8 && numAnimThreads_ > 1) {
static const size_t minAnimWorkPerThread = std::max<size_t>(
8, envSizeOrDefault("WOWEE_CHAR_ANIM_WORK_PER_THREAD", 16));
const size_t maxUsefulThreads = std::max<size_t>(
1, (updatedCount + minAnimWorkPerThread - 1) / minAnimWorkPerThread);
const size_t numThreads = std::min(static_cast<size_t>(numAnimThreads_), maxUsefulThreads);
if (numThreads <= 1) {
for (auto& instRef : toUpdate) {
calculateBoneMatrices(instRef.get());
}
} else {
const size_t chunkSize = updatedCount / numThreads;
const size_t remainder = updatedCount % numThreads;
animFutures_.clear();
if (animFutures_.capacity() < numThreads) {
animFutures_.reserve(numThreads);
}
size_t start = 0;
for (size_t t = 0; t < numThreads; t++) {
size_t end = start + chunkSize + (t < remainder ? 1 : 0);
animFutures_.push_back(std::async(std::launch::async,
[this, &toUpdate, start, end]() {
for (size_t i = start; i < end; i++) {
calculateBoneMatrices(toUpdate[i].get());
}
}));
start = end;
}
for (auto& f : animFutures_) {
f.get();
}
}
} else {
for (auto& instRef : toUpdate) {
calculateBoneMatrices(instRef.get());
}
}
// Update weapon attachment transforms (after all bone matrices are computed)
for (auto& pair : instances) {
auto& instance = pair.second;
if (instance.weaponAttachments.empty()) continue;
if (glm::distance2(instance.position, cameraPos) > animUpdateRadiusSq) continue;
glm::mat4 charModelMat = instance.hasOverrideModelMatrix
? instance.overrideModelMatrix
: getModelMatrix(instance);
for (const auto& wa : instance.weaponAttachments) {
auto weapIt = instances.find(wa.weaponInstanceId);
if (weapIt == instances.end()) continue;
// Get the bone matrix for the attachment bone
glm::mat4 boneMat(1.0f);
if (wa.boneIndex < instance.boneMatrices.size()) {
boneMat = instance.boneMatrices[wa.boneIndex];
}
// Weapon model matrix = character model * bone transform * offset translation
weapIt->second.overrideModelMatrix =
charModelMat * boneMat * glm::translate(glm::mat4(1.0f), wa.offset);
weapIt->second.hasOverrideModelMatrix = true;
}
}
}
void CharacterRenderer::updateAnimation(CharacterInstance& instance, float deltaTime) {
if (!instance.cachedModel) return;
const auto& model = instance.cachedModel->data;
if (model.sequences.empty()) {
return;
}
// Resolve sequence index if not set
if (instance.currentSequenceIndex < 0) {
instance.currentSequenceIndex = 0;
instance.currentAnimationId = model.sequences[0].id;
}
const auto& sequence = model.sequences[instance.currentSequenceIndex];
// Update animation time (convert to milliseconds)
instance.animationTime += deltaTime * 1000.0f;
if (sequence.duration > 0 && instance.animationTime >= static_cast<float>(sequence.duration)) {
if (instance.animationLoop) {
instance.animationTime = std::fmod(instance.animationTime, static_cast<float>(sequence.duration));
} else {
instance.animationTime = static_cast<float>(sequence.duration);
}
}
// Update bone matrices
calculateBoneMatrices(instance);
}
// --- Keyframe interpolation helpers ---
int CharacterRenderer::findKeyframeIndex(const std::vector<uint32_t>& timestamps, float time) {
if (timestamps.empty()) return -1;
if (timestamps.size() == 1) return 0;
// Binary search using float comparison to match original semantics exactly
auto it = std::upper_bound(timestamps.begin(), timestamps.end(), time,
[](float t, uint32_t ts) { return t < static_cast<float>(ts); });
if (it == timestamps.begin()) return 0;
size_t idx = static_cast<size_t>(it - timestamps.begin()) - 1;
return static_cast<int>(std::min(idx, timestamps.size() - 2));
}
glm::vec3 CharacterRenderer::interpolateVec3(const pipeline::M2AnimationTrack& track,
int seqIdx, float time, const glm::vec3& defaultVal) {
if (!track.hasData()) return defaultVal;
if (seqIdx < 0 || seqIdx >= static_cast<int>(track.sequences.size())) return defaultVal;
const auto& keys = track.sequences[seqIdx];
if (keys.timestamps.empty() || keys.vec3Values.empty()) return defaultVal;
auto safeVec3 = [&](const glm::vec3& v) -> glm::vec3 {
if (std::isnan(v.x) || std::isnan(v.y) || std::isnan(v.z)) return defaultVal;
return v;
};
if (keys.vec3Values.size() == 1) return safeVec3(keys.vec3Values[0]);
int idx = findKeyframeIndex(keys.timestamps, time);
if (idx < 0) return defaultVal;
size_t i0 = static_cast<size_t>(idx);
size_t i1 = std::min(i0 + 1, keys.vec3Values.size() - 1);
if (i0 == i1) return safeVec3(keys.vec3Values[i0]);
float t0 = static_cast<float>(keys.timestamps[i0]);
float t1 = static_cast<float>(keys.timestamps[i1]);
float duration = t1 - t0;
float t = (duration > 0.0f) ? glm::clamp((time - t0) / duration, 0.0f, 1.0f) : 0.0f;
return safeVec3(glm::mix(keys.vec3Values[i0], keys.vec3Values[i1], t));
}
glm::quat CharacterRenderer::interpolateQuat(const pipeline::M2AnimationTrack& track,
int seqIdx, float time) {
glm::quat identity(1.0f, 0.0f, 0.0f, 0.0f);
if (!track.hasData()) return identity;
if (seqIdx < 0 || seqIdx >= static_cast<int>(track.sequences.size())) return identity;
const auto& keys = track.sequences[seqIdx];
if (keys.timestamps.empty() || keys.quatValues.empty()) return identity;
auto safeQuat = [&](const glm::quat& q) -> glm::quat {
float lenSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w;
if (lenSq < 0.000001f || std::isnan(lenSq)) return identity;
return q;
};
if (keys.quatValues.size() == 1) return safeQuat(keys.quatValues[0]);
int idx = findKeyframeIndex(keys.timestamps, time);
if (idx < 0) return identity;
size_t i0 = static_cast<size_t>(idx);
size_t i1 = std::min(i0 + 1, keys.quatValues.size() - 1);
if (i0 == i1) return safeQuat(keys.quatValues[i0]);
glm::quat q0 = safeQuat(keys.quatValues[i0]);
glm::quat q1 = safeQuat(keys.quatValues[i1]);
float t0 = static_cast<float>(keys.timestamps[i0]);
float t1 = static_cast<float>(keys.timestamps[i1]);
float duration = t1 - t0;
float t = (duration > 0.0f) ? glm::clamp((time - t0) / duration, 0.0f, 1.0f) : 0.0f;
return glm::slerp(q0, q1, t);
}
// --- Bone transform calculation ---
void CharacterRenderer::calculateBoneMatrices(CharacterInstance& instance) {
if (!instance.cachedModel) return;
auto& model = instance.cachedModel->data;
if (model.bones.empty()) {
return;
}
size_t numBones = model.bones.size();
instance.boneMatrices.resize(numBones);
for (size_t i = 0; i < numBones; i++) {
const auto& bone = model.bones[i];
// Local transform includes pivot bracket: T(pivot)*T*R*S*T(-pivot)
// At rest this is identity, so no separate bind pose is needed
glm::mat4 localTransform = getBoneTransform(bone, instance.animationTime, instance.currentSequenceIndex);
// Compose with parent
if (bone.parentBone >= 0 && static_cast<size_t>(bone.parentBone) < numBones) {
instance.boneMatrices[i] = instance.boneMatrices[bone.parentBone] * localTransform;
} else {
instance.boneMatrices[i] = localTransform;
}
}
}
glm::mat4 CharacterRenderer::getBoneTransform(const pipeline::M2Bone& bone, float time, int sequenceIndex) {
glm::vec3 translation = interpolateVec3(bone.translation, sequenceIndex, time, glm::vec3(0.0f));
glm::quat rotation = interpolateQuat(bone.rotation, sequenceIndex, time);
glm::vec3 scale = interpolateVec3(bone.scale, sequenceIndex, time, glm::vec3(1.0f));
// M2 bone transform: T(pivot) * T(trans) * R(rot) * S(scale) * T(-pivot)
// At rest (no animation): T(pivot) * I * I * I * T(-pivot) = identity
glm::mat4 transform = glm::translate(glm::mat4(1.0f), bone.pivot);
transform = glm::translate(transform, translation);
transform *= glm::toMat4(rotation);
transform = glm::scale(transform, scale);
transform = glm::translate(transform, -bone.pivot);
return transform;
}
// --- Rendering ---
void CharacterRenderer::prepareRender(uint32_t frameIndex) {
if (instances.empty() || !opaquePipeline_) return;
// Pre-allocate bone SSBOs + descriptor sets on main thread (pool ops not thread-safe)
for (auto& [id, instance] : instances) {
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), MAX_BONES);
if (numBones <= 0) continue;
if (!instance.boneBuffer[frameIndex]) {
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bci.size = MAX_BONES * sizeof(glm::mat4);
bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
VmaAllocationCreateInfo aci{};
aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT;
VmaAllocationInfo allocInfo{};
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci,
&instance.boneBuffer[frameIndex], &instance.boneAlloc[frameIndex], &allocInfo);
instance.boneMapped[frameIndex] = allocInfo.pMappedData;
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = boneDescPool_;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &boneSetLayout_;
VkResult dsRes = vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &instance.boneSet[frameIndex]);
if (dsRes != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer::prepareRender: bone descriptor alloc failed (instance=",
id, ", frame=", frameIndex, ", vk=", static_cast<int>(dsRes), ")");
if (instance.boneBuffer[frameIndex]) {
vmaDestroyBuffer(vkCtx_->getAllocator(),
instance.boneBuffer[frameIndex], instance.boneAlloc[frameIndex]);
instance.boneBuffer[frameIndex] = VK_NULL_HANDLE;
instance.boneAlloc[frameIndex] = VK_NULL_HANDLE;
instance.boneMapped[frameIndex] = nullptr;
}
continue;
}
if (instance.boneSet[frameIndex]) {
VkDescriptorBufferInfo bufInfo{};
bufInfo.buffer = instance.boneBuffer[frameIndex];
bufInfo.offset = 0;
bufInfo.range = bci.size;
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
write.dstSet = instance.boneSet[frameIndex];
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
write.pBufferInfo = &bufInfo;
vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr);
}
}
}
}
void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, [[maybe_unused]] const Camera& camera) {
if (instances.empty() || !opaquePipeline_) {
return;
}
const float renderRadius = static_cast<float>(envSizeOrDefault("WOWEE_CHAR_RENDER_RADIUS", 130));
const float renderRadiusSq = renderRadius * renderRadius;
const float nearNoConeCullSq = 16.0f * 16.0f;
const float backfaceDotCull = -0.30f;
const glm::vec3 camPos = camera.getPosition();
const glm::vec3 camForward = camera.getForward();
uint32_t frameIndex = vkCtx_->getCurrentFrame();
uint32_t frameSlot = frameIndex % 2u;
// Reset material ring buffer and descriptor pool once per frame slot.
if (lastMaterialPoolResetFrame_ != frameIndex) {
materialRingOffset_[frameSlot] = 0;
if (materialDescPools_[frameSlot]) {
vkResetDescriptorPool(vkCtx_->getDevice(), materialDescPools_[frameSlot], 0);
}
lastMaterialPoolResetFrame_ = frameIndex;
}
// Pre-compute aligned UBO stride for ring buffer sub-allocation
const uint32_t uboStride = (sizeof(CharMaterialUBO) + materialUboAlignment_ - 1) & ~(materialUboAlignment_ - 1);
const uint32_t ringCapacityBytes = uboStride * MATERIAL_RING_CAPACITY;
// Bind per-frame descriptor set (set 0) -- shared across all draws
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout_, 0, 1, &perFrameSet, 0, nullptr);
// Start with opaque pipeline
VkPipeline currentPipeline = opaquePipeline_;
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, currentPipeline);
for (const auto& pair : instances) {
const auto& instance = pair.second;
// Skip invisible instances (e.g., player in first-person mode)
if (!instance.visible) continue;
// Character instance culling: avoid drawing far-away / strongly behind-camera
// actors in dense city scenes.
if (!instance.hasOverrideModelMatrix) {
glm::vec3 toInst = instance.position - camPos;
float distSq = glm::dot(toInst, toInst);
if (distSq > renderRadiusSq) continue;
if (distSq > nearNoConeCullSq) {
// Backface cull without sqrt: dot(toInst, camFwd) / |toInst| < threshold
// ⟺ dot < 0 || dot² < threshold² * distSq (when threshold < 0, dot must be negative)
float rawDot = glm::dot(toInst, camForward);
if (backfaceDotCull >= 0.0f) {
if (rawDot < 0.0f || rawDot * rawDot < backfaceDotCull * backfaceDotCull * distSq) continue;
} else {
if (rawDot < 0.0f && rawDot * rawDot > backfaceDotCull * backfaceDotCull * distSq) continue;
}
}
}
if (!instance.cachedModel) continue;
const auto& gpuModel = *instance.cachedModel;
// Skip models without GPU buffers
if (!gpuModel.vertexBuffer) continue;
// Skip fully transparent instances
if (instance.opacity <= 0.0f) continue;
// Set model matrix (use override for weapon instances)
glm::mat4 modelMat = instance.hasOverrideModelMatrix
? instance.overrideModelMatrix
: getModelMatrix(instance);
// Push model matrix
vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(glm::mat4), &modelMat);
// Upload bone matrices to SSBO
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), MAX_BONES);
if (numBones > 0) {
// Lazy-allocate bone SSBO on first use
auto& instMut = const_cast<CharacterInstance&>(instance);
if (!instMut.boneBuffer[frameIndex]) {
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bci.size = MAX_BONES * sizeof(glm::mat4);
bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
VmaAllocationCreateInfo aci{};
aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT;
VmaAllocationInfo allocInfo{};
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci,
&instMut.boneBuffer[frameIndex], &instMut.boneAlloc[frameIndex], &allocInfo);
instMut.boneMapped[frameIndex] = allocInfo.pMappedData;
// Allocate descriptor set for bone SSBO
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = boneDescPool_;
ai.descriptorSetCount = 1;
ai.pSetLayouts = &boneSetLayout_;
VkResult dsRes = vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &instMut.boneSet[frameIndex]);
if (dsRes != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer: bone descriptor allocation failed (instance=",
instMut.id, ", frame=", frameIndex, ", vk=", static_cast<int>(dsRes), ")");
if (instMut.boneBuffer[frameIndex]) {
vmaDestroyBuffer(vkCtx_->getAllocator(),
instMut.boneBuffer[frameIndex], instMut.boneAlloc[frameIndex]);
instMut.boneBuffer[frameIndex] = VK_NULL_HANDLE;
instMut.boneAlloc[frameIndex] = VK_NULL_HANDLE;
instMut.boneMapped[frameIndex] = nullptr;
}
}
if (instMut.boneSet[frameIndex]) {
VkDescriptorBufferInfo bufInfo{};
bufInfo.buffer = instMut.boneBuffer[frameIndex];
bufInfo.offset = 0;
bufInfo.range = bci.size;
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
write.dstSet = instMut.boneSet[frameIndex];
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
write.pBufferInfo = &bufInfo;
vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr);
}
}
// Upload bone matrices
if (instMut.boneMapped[frameIndex]) {
memcpy(instMut.boneMapped[frameIndex], instance.boneMatrices.data(),
numBones * sizeof(glm::mat4));
}
// Bind bone descriptor set (set 2)
if (instMut.boneSet[frameIndex]) {
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout_, 2, 1, &instMut.boneSet[frameIndex], 0, nullptr);
}
}
// Bind vertex and index buffers
VkDeviceSize offset = 0;
vkCmdBindVertexBuffers(cmd, 0, 1, &gpuModel.vertexBuffer, &offset);
vkCmdBindIndexBuffer(cmd, gpuModel.indexBuffer, 0, VK_INDEX_TYPE_UINT16);
if (!gpuModel.data.batches.empty()) {
bool applyGeosetFilter = !instance.activeGeosets.empty();
if (applyGeosetFilter) {
bool hasRenderableGeoset = false;
for (const auto& batch : gpuModel.data.batches) {
if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) {
hasRenderableGeoset = true;
break;
}
}
if (!hasRenderableGeoset) {
static std::unordered_set<uint32_t> loggedGeosetFallback;
if (loggedGeosetFallback.insert(instance.id).second) {
LOG_WARNING("Geoset filter matched no batches for instance ",
instance.id, " (model ", instance.modelId,
"); rendering all batches as fallback");
}
applyGeosetFilter = false;
}
}
auto resolveBatchTexture = [&](const CharacterInstance& inst, const M2ModelGPU& gm, const pipeline::M2Batch& b) -> VkTexture* {
// A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex.
// We currently bind only a single texture, so pick the most appropriate one.
if (b.textureIndex == 0xFFFF) return whiteTexture_.get();
if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture_.get();
uint32_t comboCount = b.textureCount ? static_cast<uint32_t>(b.textureCount) : 1u;
comboCount = std::min<uint32_t>(comboCount, 8u);
struct Candidate { VkTexture* tex; uint32_t type; };
Candidate first{whiteTexture_.get(), 0};
bool hasFirst = false;
Candidate firstNonWhite{whiteTexture_.get(), 0};
bool hasFirstNonWhite = false;
for (uint32_t i = 0; i < comboCount; i++) {
uint32_t lookupPos = static_cast<uint32_t>(b.textureIndex) + i;
if (lookupPos >= gm.data.textureLookup.size()) break;
uint16_t texSlot = gm.data.textureLookup[lookupPos];
if (texSlot >= gm.textureIds.size()) continue;
VkTexture* texPtr = gm.textureIds[texSlot];
uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0;
// Apply texture slot overrides.
// For type-1 (skin) overrides, only apply to skin-group batches
// to prevent the skin composite from bleeding onto cloak/hair.
{
auto itO = inst.textureSlotOverrides.find(texSlot);
if (itO != inst.textureSlotOverrides.end() && itO->second != nullptr) {
if (texType == 1) {
// Only apply skin override to skin groups
uint16_t grp = b.submeshId / 100;
bool isSkinGroup = (grp == 0 || grp == 3 || grp == 4 || grp == 5 ||
grp == 8 || grp == 9 || grp == 13 || grp == 20);
if (isSkinGroup) texPtr = itO->second;
} else {
texPtr = itO->second;
}
}
}
if (!hasFirst) {
first = {texPtr, texType};
hasFirst = true;
}
if (texPtr == nullptr || texPtr == whiteTexture_.get()) continue;
// Prefer the hair texture slot (type 6) whenever present in the combo.
if (texType == 6) {
return texPtr;
}
if (!hasFirstNonWhite) {
firstNonWhite = {texPtr, texType};
hasFirstNonWhite = true;
}
}
if (hasFirstNonWhite) return firstNonWhite.tex;
if (hasFirst && first.tex != nullptr) return first.tex;
return whiteTexture_.get();
};
// One-time debug dump of rendered batches per model
// Draw batches (submeshes) with per-batch textures
for (const auto& batch : gpuModel.data.batches) {
if (applyGeosetFilter) {
if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) {
continue;
}
}
// Resolve texture for this batch (prefer hair textures for hair geosets).
VkTexture* texPtr = resolveBatchTexture(instance, gpuModel, batch);
const uint16_t batchGroup = static_cast<uint16_t>(batch.submeshId / 100);
auto groupTexIt = instance.groupTextureOverrides.find(batchGroup);
if (groupTexIt != instance.groupTextureOverrides.end() && groupTexIt->second != nullptr) {
texPtr = groupTexIt->second;
}
// Respect M2 material blend mode for creature/character submeshes.
uint16_t blendMode = 0;
uint16_t materialFlags = 0;
if (batch.materialIndex < gpuModel.data.materials.size()) {
blendMode = gpuModel.data.materials[batch.materialIndex].blendMode;
materialFlags = gpuModel.data.materials[batch.materialIndex].flags;
}
// Attached weapon models can include additive FX/card batches that
// appear as detached flat quads for some swords. Keep core geometry
// and drop FX-style passes for weapon attachments.
if (instance.hasOverrideModelMatrix && blendMode >= 3) {
continue;
}
// Select pipeline based on blend mode
VkPipeline desiredPipeline;
switch (blendMode) {
case 0: desiredPipeline = opaquePipeline_; break;
case 1: desiredPipeline = alphaTestPipeline_; break;
case 2: desiredPipeline = alphaPipeline_; break;
case 3:
case 6: desiredPipeline = additivePipeline_; break;
default: desiredPipeline = alphaPipeline_; break;
}
if (desiredPipeline != currentPipeline) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline);
currentPipeline = desiredPipeline;
}
// For body/equipment parts with white/fallback texture, use skin (type 1) texture.
if (texPtr == whiteTexture_.get()) {
uint16_t group = batchGroup;
bool isSkinGroup = (group == 0 || group == 3 || group == 4 || group == 5 ||
group == 8 || group == 9 || group == 13);
if (isSkinGroup) {
uint32_t texType = 0;
if (batch.textureIndex < gpuModel.data.textureLookup.size()) {
uint16_t lk = gpuModel.data.textureLookup[batch.textureIndex];
if (lk < gpuModel.data.textures.size()) {
texType = gpuModel.data.textures[lk].type;
}
}
// Do NOT apply skin composite to hair (type 6) batches
if (texType != 6) {
for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) {
VkTexture* candidate = gpuModel.textureIds[ti];
auto itO = instance.textureSlotOverrides.find(static_cast<uint16_t>(ti));
if (itO != instance.textureSlotOverrides.end() && itO->second != nullptr) {
candidate = itO->second;
}
if (candidate != whiteTexture_.get() && candidate != nullptr) {
if (ti < gpuModel.data.textures.size() &&
(gpuModel.data.textures[ti].type == 1 || gpuModel.data.textures[ti].type == 11)) {
texPtr = candidate;
break;
}
}
}
}
}
}
// Determine material properties
bool alphaCutout = false;
bool colorKeyBlack = false;
if (texPtr != nullptr && texPtr != whiteTexture_.get()) {
auto ait = textureHasAlphaByPtr_.find(texPtr);
alphaCutout = (ait != textureHasAlphaByPtr_.end()) ? ait->second : false;
auto cit = textureColorKeyBlackByPtr_.find(texPtr);
colorKeyBlack = (cit != textureColorKeyBlackByPtr_.end()) ? cit->second : false;
}
const bool blendNeedsCutout = (blendMode == 1) || (blendMode >= 2 && !alphaCutout);
const bool unlit = ((materialFlags & 0x01) != 0) || (blendMode >= 3);
float emissiveBoost = 1.0f;
glm::vec3 emissiveTint(1.0f, 1.0f, 1.0f);
// Keep custom warm/flicker treatment narrowly scoped to kobold candle flames.
bool koboldCandleFlame = false;
if (colorKeyBlack) {
std::string modelKey = gpuModel.data.name;
std::transform(modelKey.begin(), modelKey.end(), modelKey.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
koboldCandleFlame =
(modelKey.find("kobold") != std::string::npos) &&
((modelKey.find("candle") != std::string::npos) ||
(modelKey.find("torch") != std::string::npos) ||
(modelKey.find("mine") != std::string::npos));
}
if (unlit && koboldCandleFlame) {
using clock = std::chrono::steady_clock;
float t = std::chrono::duration<float>(clock::now().time_since_epoch()).count();
float phase = static_cast<float>(batch.submeshId) * 0.31f;
float f1 = std::sin(t * 7.9f + phase);
float f2 = std::sin(t * 12.7f + phase * 1.73f);
float f3 = std::sin(t * 4.3f + phase * 2.11f);
float flicker = 0.90f + 0.10f * f1 + 0.06f * f2 + 0.04f * f3;
flicker = std::clamp(flicker, 0.72f, 1.12f);
emissiveBoost = (blendMode >= 3) ? (2.4f * flicker) : (1.5f * flicker);
emissiveTint = glm::vec3(1.28f, 1.04f, 0.82f);
}
// Allocate and fill material descriptor set (set 1)
VkDescriptorSet materialSet = VK_NULL_HANDLE;
{
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = materialDescPools_[frameSlot];
ai.descriptorSetCount = 1;
ai.pSetLayouts = &materialSetLayout_;
if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &materialSet) != VK_SUCCESS) {
continue; // Pool exhausted, skip this batch
}
}
// 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;
matData.alphaTest = (blendNeedsCutout || alphaCutout) ? 1 : 0;
matData.colorKeyBlack = (blendNeedsCutout || colorKeyBlack) ? 1 : 0;
matData.unlit = unlit ? 1 : 0;
matData.emissiveBoost = emissiveBoost;
matData.emissiveTintR = emissiveTint.r;
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.06f;
matData.pomMaxSamples = pomSamples;
matData.heightMapVariance = batchHeightVariance;
matData.normalMapStrength = normalMapStrength_;
// Sub-allocate material UBO from ring buffer
uint32_t matOffset = materialRingOffset_[frameSlot];
if (matOffset + uboStride > ringCapacityBytes) continue; // ring exhausted
memcpy(static_cast<char*>(materialRingMapped_[frameSlot]) + matOffset, &matData, sizeof(CharMaterialUBO));
materialRingOffset_[frameSlot] = matOffset + uboStride;
// 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 = materialRingBuffer_[frameSlot];
bufInfo.offset = matOffset;
bufInfo.range = sizeof(CharMaterialUBO);
VkDescriptorImageInfo nhImgInfo = normalMap->descriptorInfo();
VkWriteDescriptorSet writes[3] = {};
writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[0].dstSet = materialSet;
writes[0].dstBinding = 0;
writes[0].descriptorCount = 1;
writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
writes[0].pImageInfo = &imgInfo;
writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[1].dstSet = materialSet;
writes[1].dstBinding = 1;
writes[1].descriptorCount = 1;
writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writes[1].pBufferInfo = &bufInfo;
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,
pipelineLayout_, 1, 1, &materialSet, 0, nullptr);
vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0);
}
} else {
// Draw entire model with first texture
VkTexture* texPtr = !gpuModel.textureIds.empty() ? gpuModel.textureIds[0] : whiteTexture_.get();
if (!texPtr || !texPtr->isValid()) texPtr = whiteTexture_.get();
// Allocate material descriptor set
VkDescriptorSet materialSet = VK_NULL_HANDLE;
{
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = materialDescPools_[frameSlot];
ai.descriptorSetCount = 1;
ai.pSetLayouts = &materialSetLayout_;
if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &materialSet) != VK_SUCCESS) {
continue;
}
}
// 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;
matData.colorKeyBlack = 0;
matData.unlit = 0;
matData.emissiveBoost = 1.0f;
matData.emissiveTintR = 1.0f;
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.06f;
matData.pomMaxSamples = pomSamples2;
matData.heightMapVariance = 0.0f;
matData.normalMapStrength = normalMapStrength_;
// Sub-allocate material UBO from ring buffer
uint32_t matOffset2 = materialRingOffset_[frameSlot];
if (matOffset2 + uboStride > ringCapacityBytes) continue; // ring exhausted
memcpy(static_cast<char*>(materialRingMapped_[frameSlot]) + matOffset2, &matData, sizeof(CharMaterialUBO));
materialRingOffset_[frameSlot] = matOffset2 + uboStride;
VkDescriptorImageInfo imgInfo = texPtr->descriptorInfo();
VkDescriptorBufferInfo bufInfo{};
bufInfo.buffer = materialRingBuffer_[frameSlot];
bufInfo.offset = matOffset2;
bufInfo.range = sizeof(CharMaterialUBO);
VkDescriptorImageInfo nhImgInfo2 = flatNormalTexture_->descriptorInfo();
VkWriteDescriptorSet writes[3] = {};
writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[0].dstSet = materialSet;
writes[0].dstBinding = 0;
writes[0].descriptorCount = 1;
writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
writes[0].pImageInfo = &imgInfo;
writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[1].dstSet = materialSet;
writes[1].dstBinding = 1;
writes[1].descriptorCount = 1;
writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writes[1].pBufferInfo = &bufInfo;
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);
vkCmdDrawIndexed(cmd, gpuModel.indexCount, 1, 0, 0, 0);
}
}
}
bool CharacterRenderer::initializeShadow(VkRenderPass shadowRenderPass) {
if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false;
VkDevice device = vkCtx_->getDevice();
// ShadowCharParams UBO (matches character_shadow.frag.glsl set=1 binding=1)
struct ShadowCharParams {
int32_t alphaTest = 0;
int32_t colorKeyBlack = 0;
};
// Create ShadowCharParams UBO
VkBufferCreateInfo bufCI{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bufCI.size = sizeof(ShadowCharParams);
bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
VmaAllocationCreateInfo allocCI{};
allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT;
VmaAllocationInfo allocInfo{};
if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufCI, &allocCI,
&shadowParamsUBO_, &shadowParamsAlloc_, &allocInfo) != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer: failed to create shadow params UBO");
return false;
}
ShadowCharParams defaultParams{};
std::memcpy(allocInfo.pMappedData, &defaultParams, sizeof(defaultParams));
// Descriptor set layout for set 1: binding 0 = sampler2D, binding 1 = ShadowCharParams UBO
VkDescriptorSetLayoutBinding layoutBindings[2]{};
layoutBindings[0].binding = 0;
layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
layoutBindings[0].descriptorCount = 1;
layoutBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
layoutBindings[1].binding = 1;
layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
layoutBindings[1].descriptorCount = 1;
layoutBindings[1].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
VkDescriptorSetLayoutCreateInfo layoutCI{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
layoutCI.bindingCount = 2;
layoutCI.pBindings = layoutBindings;
if (vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &shadowParamsLayout_) != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer: failed to create shadow params layout");
return false;
}
// Descriptor pool (1 set)
VkDescriptorPoolSize poolSizes[2]{};
poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
poolSizes[0].descriptorCount = 1;
poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
poolSizes[1].descriptorCount = 1;
VkDescriptorPoolCreateInfo poolCI{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
poolCI.maxSets = 1;
poolCI.poolSizeCount = 2;
poolCI.pPoolSizes = poolSizes;
if (vkCreateDescriptorPool(device, &poolCI, nullptr, &shadowParamsPool_) != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer: failed to create shadow params pool");
return false;
}
// Allocate descriptor set
VkDescriptorSetAllocateInfo setAlloc{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
setAlloc.descriptorPool = shadowParamsPool_;
setAlloc.descriptorSetCount = 1;
setAlloc.pSetLayouts = &shadowParamsLayout_;
if (vkAllocateDescriptorSets(device, &setAlloc, &shadowParamsSet_) != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer: failed to allocate shadow params set");
return false;
}
// Write descriptors (white dummy texture + ShadowCharParams UBO)
VkDescriptorImageInfo imgInfo{};
imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
imgInfo.imageView = whiteTexture_->getImageView();
imgInfo.sampler = whiteTexture_->getSampler();
VkDescriptorBufferInfo bufInfo{};
bufInfo.buffer = shadowParamsUBO_;
bufInfo.offset = 0;
bufInfo.range = sizeof(ShadowCharParams);
VkWriteDescriptorSet writes[2]{};
writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[0].dstSet = shadowParamsSet_;
writes[0].dstBinding = 0;
writes[0].descriptorCount = 1;
writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
writes[0].pImageInfo = &imgInfo;
writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[1].dstSet = shadowParamsSet_;
writes[1].dstBinding = 1;
writes[1].descriptorCount = 1;
writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writes[1].pBufferInfo = &bufInfo;
vkUpdateDescriptorSets(device, 2, writes, 0, nullptr);
// Pipeline layout: set 0 = perFrameLayout_ (dummy), set 1 = shadowParamsLayout_, set 2 = boneSetLayout_
// Push constant: 128 bytes (lightSpaceMatrix + model), VERTEX stage
VkDescriptorSetLayout setLayouts[] = {perFrameLayout_, shadowParamsLayout_, boneSetLayout_};
VkPushConstantRange pc{};
pc.stageFlags = VK_SHADER_STAGE_VERTEX_BIT;
pc.offset = 0;
pc.size = 128;
VkPipelineLayoutCreateInfo plCI{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
plCI.setLayoutCount = 3;
plCI.pSetLayouts = setLayouts;
plCI.pushConstantRangeCount = 1;
plCI.pPushConstantRanges = &pc;
if (vkCreatePipelineLayout(device, &plCI, nullptr, &shadowPipelineLayout_) != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer: failed to create shadow pipeline layout");
return false;
}
// Load character shadow shaders
VkShaderModule vertShader, fragShader;
if (!vertShader.loadFromFile(device, "assets/shaders/character_shadow.vert.spv")) {
LOG_ERROR("CharacterRenderer: failed to load character_shadow.vert.spv");
return false;
}
if (!fragShader.loadFromFile(device, "assets/shaders/character_shadow.frag.spv")) {
LOG_ERROR("CharacterRenderer: failed to load character_shadow.frag.spv");
vertShader.destroy();
return false;
}
// 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(CharVertexGPU));
vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
std::vector<VkVertexInputAttributeDescription> vertAttrs = {
{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()
.setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({vertBind}, vertAttrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL)
.setDepthBias(0.05f, 0.20f)
.setNoColorAttachment()
.setLayout(shadowPipelineLayout_)
.setRenderPass(shadowRenderPass)
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
.build(device);
vertShader.destroy();
fragShader.destroy();
if (!shadowPipeline_) {
LOG_ERROR("CharacterRenderer: failed to create shadow pipeline");
return false;
}
LOG_INFO("CharacterRenderer shadow pipeline initialized");
return true;
}
void CharacterRenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix,
const glm::vec3& shadowCenter, float shadowRadius) {
if (!shadowPipeline_ || !shadowParamsSet_) return;
if (instances.empty() || models.empty()) return;
uint32_t frameIndex = vkCtx_->getCurrentFrame();
VkDevice device = vkCtx_->getDevice();
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline_);
// Bind shadow params set at set 1
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_,
1, 1, &shadowParamsSet_, 0, nullptr);
struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; };
const float shadowRadiusSq = shadowRadius * shadowRadius;
for (auto& pair : instances) {
auto& inst = pair.second;
if (!inst.visible) continue;
// Distance cull against shadow frustum
glm::vec3 diff = inst.position - shadowCenter;
if (glm::dot(diff, diff) > shadowRadiusSq) continue;
if (!inst.cachedModel) continue;
const M2ModelGPU& gpuModel = *inst.cachedModel;
if (!gpuModel.vertexBuffer) continue;
glm::mat4 modelMat = inst.hasOverrideModelMatrix
? inst.overrideModelMatrix
: getModelMatrix(inst);
// Ensure bone SSBO is allocated and upload bone matrices
int numBones = std::min(static_cast<int>(inst.boneMatrices.size()), MAX_BONES);
if (numBones > 0) {
if (!inst.boneBuffer[frameIndex]) {
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bci.size = MAX_BONES * sizeof(glm::mat4);
bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
VmaAllocationCreateInfo aci{};
aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT;
VmaAllocationInfo ai{};
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci,
&inst.boneBuffer[frameIndex], &inst.boneAlloc[frameIndex], &ai);
inst.boneMapped[frameIndex] = ai.pMappedData;
VkDescriptorSetAllocateInfo dsAI{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
dsAI.descriptorPool = boneDescPool_;
dsAI.descriptorSetCount = 1;
dsAI.pSetLayouts = &boneSetLayout_;
VkResult dsRes = vkAllocateDescriptorSets(device, &dsAI, &inst.boneSet[frameIndex]);
if (dsRes != VK_SUCCESS) {
LOG_ERROR("CharacterRenderer[shadow]: bone descriptor allocation failed (instance=",
inst.id, ", frame=", frameIndex, ", vk=", static_cast<int>(dsRes), ")");
if (inst.boneBuffer[frameIndex]) {
vmaDestroyBuffer(vkCtx_->getAllocator(),
inst.boneBuffer[frameIndex], inst.boneAlloc[frameIndex]);
inst.boneBuffer[frameIndex] = VK_NULL_HANDLE;
inst.boneAlloc[frameIndex] = VK_NULL_HANDLE;
inst.boneMapped[frameIndex] = nullptr;
}
}
if (inst.boneSet[frameIndex]) {
VkDescriptorBufferInfo bInfo{};
bInfo.buffer = inst.boneBuffer[frameIndex];
bInfo.offset = 0;
bInfo.range = bci.size;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
w.dstSet = inst.boneSet[frameIndex];
w.dstBinding = 0;
w.descriptorCount = 1;
w.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
w.pBufferInfo = &bInfo;
vkUpdateDescriptorSets(device, 1, &w, 0, nullptr);
}
}
if (inst.boneMapped[frameIndex]) {
memcpy(inst.boneMapped[frameIndex], inst.boneMatrices.data(),
numBones * sizeof(glm::mat4));
}
}
if (!inst.boneSet[frameIndex]) continue;
// Bind bone SSBO at set 2
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_,
2, 1, &inst.boneSet[frameIndex], 0, nullptr);
ShadowPush push{lightSpaceMatrix, modelMat};
vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, 128, &push);
VkDeviceSize offset = 0;
vkCmdBindVertexBuffers(cmd, 0, 1, &gpuModel.vertexBuffer, &offset);
vkCmdBindIndexBuffer(cmd, gpuModel.indexBuffer, 0, VK_INDEX_TYPE_UINT16);
bool applyGeosetFilter = !inst.activeGeosets.empty();
for (const auto& batch : gpuModel.data.batches) {
uint16_t blendMode = 0;
if (batch.materialIndex < gpuModel.data.materials.size()) {
blendMode = gpuModel.data.materials[batch.materialIndex].blendMode;
}
if (blendMode >= 2) continue; // skip transparent
if (applyGeosetFilter &&
inst.activeGeosets.find(batch.submeshId) == inst.activeGeosets.end()) continue;
vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0);
}
}
}
glm::mat4 CharacterRenderer::getModelMatrix(const CharacterInstance& instance) const {
glm::mat4 model = glm::mat4(1.0f);
// Apply transformations: T * R * S
model = glm::translate(model, instance.position);
// Apply rotation (euler angles, Z-up)
// Convention: yaw around Z, pitch around X, roll around Y.
model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); // Yaw
model = glm::rotate(model, instance.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); // Pitch
model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Roll
model = glm::scale(model, glm::vec3(instance.scale));
return model;
}
void CharacterRenderer::setInstancePosition(uint32_t instanceId, const glm::vec3& position) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.position = position;
}
}
void CharacterRenderer::setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.rotation = rotation;
}
}
void CharacterRenderer::moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds) {
auto it = instances.find(instanceId);
if (it == instances.end()) return;
auto& inst = it->second;
// Don't move dead instances (corpses shouldn't slide around)
if (inst.isDead) return;
auto pickMoveAnim = [&](bool preferRun) -> uint32_t {
// Choose movement anim from estimated speed; fall back if missing.
if (preferRun) {
if (hasAnimation(instanceId, 5)) return 5; // Run
if (hasAnimation(instanceId, 4)) return 4; // Walk
} else {
if (hasAnimation(instanceId, 4)) return 4; // Walk
if (hasAnimation(instanceId, 5)) return 5; // Run
}
return 0;
};
float planarDist = glm::length(glm::vec2(destination.x - inst.position.x,
destination.y - inst.position.y));
bool synthesizedDuration = false;
if (durationSeconds <= 0.0f) {
if (planarDist < 0.01f) {
// Stop at current location.
inst.position = destination;
inst.isMoving = false;
if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) {
playAnimation(instanceId, 0, true);
}
return;
}
// Some cores send movement-only deltas without spline duration.
// Synthesize a tiny duration so movement anim/rotation still updates.
durationSeconds = std::clamp(planarDist / 7.0f, 0.05f, 0.20f);
synthesizedDuration = true;
}
inst.moveStart = inst.position;
inst.moveEnd = destination;
inst.moveDuration = durationSeconds;
inst.moveElapsed = 0.0f;
inst.isMoving = true;
// Face toward destination (yaw around Z axis since Z is up)
glm::vec3 dir = destination - inst.position;
if (glm::length(glm::vec2(dir.x, dir.y)) > 0.001f) {
float angle = std::atan2(dir.y, dir.x);
inst.rotation.z = angle;
}
// Play movement animation while moving.
// Prefer run only when speed is clearly above normal walk pace.
float moveSpeed = planarDist / std::max(durationSeconds, 0.001f);
bool preferRun = (!synthesizedDuration && moveSpeed >= 4.5f);
uint32_t moveAnim = pickMoveAnim(preferRun);
if (moveAnim != 0 && inst.currentAnimationId != moveAnim) {
playAnimation(instanceId, moveAnim, true);
}
}
const pipeline::M2Model* CharacterRenderer::getModelData(uint32_t modelId) const {
auto it = models.find(modelId);
if (it == models.end()) return nullptr;
return &it->second.data;
}
void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) {
auto it = instances.find(instanceId);
if (it == instances.end()) return;
it->second.opacity = 0.0f;
it->second.fadeInTime = 0.0f;
it->second.fadeInDuration = durationSeconds;
}
void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.activeGeosets = geosets;
}
}
void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.groupTextureOverrides[geosetGroup] = texture;
}
}
void CharacterRenderer::setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, VkTexture* texture) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.textureSlotOverrides[textureSlot] = texture;
}
}
void CharacterRenderer::clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.textureSlotOverrides.erase(textureSlot);
}
}
void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
if (it->second.visible != visible) {
LOG_INFO("CharacterRenderer::setInstanceVisible id=", instanceId, " visible=", visible);
}
it->second.visible = visible;
// Also hide/show attached weapons (for first-person mode)
for (const auto& wa : it->second.weaponAttachments) {
auto weapIt = instances.find(wa.weaponInstanceId);
if (weapIt != instances.end()) {
weapIt->second.visible = visible;
}
}
}
}
void CharacterRenderer::removeInstance(uint32_t instanceId) {
auto it = instances.find(instanceId);
if (it == instances.end()) return;
LOG_INFO("CharacterRenderer::removeInstance id=", instanceId,
" pos=(", it->second.position.x, ",", it->second.position.y, ",", it->second.position.z, ")",
" remaining=", instances.size() - 1,
" override=", (void*)renderPassOverride_);
// Remove child attachments first (helmets/weapons), otherwise they leak as
// orphan render instances when the parent creature despawns.
auto attachments = it->second.weaponAttachments;
for (const auto& wa : attachments) {
removeInstance(wa.weaponInstanceId);
}
// Destroy bone buffers for this instance
destroyInstanceBones(it->second);
instances.erase(it);
}
bool CharacterRenderer::getAnimationState(uint32_t instanceId, uint32_t& animationId,
float& animationTimeMs, float& animationDurationMs) const {
auto it = instances.find(instanceId);
if (it == instances.end()) {
return false;
}
const CharacterInstance& instance = it->second;
auto modelIt = models.find(instance.modelId);
if (modelIt == models.end()) {
return false;
}
const auto& sequences = modelIt->second.data.sequences;
if (instance.currentSequenceIndex < 0 || instance.currentSequenceIndex >= static_cast<int>(sequences.size())) {
return false;
}
animationId = instance.currentAnimationId;
animationTimeMs = instance.animationTime;
animationDurationMs = static_cast<float>(sequences[instance.currentSequenceIndex].duration);
return true;
}
bool CharacterRenderer::hasAnimation(uint32_t instanceId, uint32_t animationId) const {
auto it = instances.find(instanceId);
if (it == instances.end()) {
return false;
}
auto modelIt = models.find(it->second.modelId);
if (modelIt == models.end()) {
return false;
}
const auto& sequences = modelIt->second.data.sequences;
for (const auto& seq : sequences) {
if (seq.id == animationId) {
return true;
}
}
return false;
}
bool CharacterRenderer::getAnimationSequences(uint32_t instanceId, std::vector<pipeline::M2Sequence>& out) const {
out.clear();
auto it = instances.find(instanceId);
if (it == instances.end()) {
return false;
}
auto modelIt = models.find(it->second.modelId);
if (modelIt == models.end()) {
return false;
}
out = modelIt->second.data.sequences;
return !out.empty();
}
bool CharacterRenderer::getInstanceModelName(uint32_t instanceId, std::string& modelName) const {
auto it = instances.find(instanceId);
if (it == instances.end()) {
return false;
}
auto modelIt = models.find(it->second.modelId);
if (modelIt == models.end()) {
return false;
}
modelName = modelIt->second.data.name;
return !modelName.empty();
}
bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmentId,
const pipeline::M2Model& weaponModel, uint32_t weaponModelId,
const std::string& texturePath) {
auto charIt = instances.find(charInstanceId);
if (charIt == instances.end()) {
core::Logger::getInstance().warning("attachWeapon: character instance ", charInstanceId, " not found");
return false;
}
auto& charInstance = charIt->second;
auto charModelIt = models.find(charInstance.modelId);
if (charModelIt == models.end()) return false;
const auto& charModel = charModelIt->second.data;
// Find bone index for this attachment point
uint16_t boneIndex = 0;
glm::vec3 offset(0.0f);
bool found = false;
// Try attachment lookup first
if (attachmentId < charModel.attachmentLookup.size()) {
uint16_t attIdx = charModel.attachmentLookup[attachmentId];
if (attIdx < charModel.attachments.size()) {
boneIndex = charModel.attachments[attIdx].bone;
offset = charModel.attachments[attIdx].position;
found = true;
}
}
// Fallback: scan attachments by id
if (!found) {
for (const auto& att : charModel.attachments) {
if (att.id == attachmentId) {
boneIndex = att.bone;
offset = att.position;
found = true;
break;
}
}
}
// Fallback to key-bone lookup only for weapon hand attachment IDs.
if (!found && (attachmentId == 1 || attachmentId == 2)) {
int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27;
for (size_t i = 0; i < charModel.bones.size(); i++) {
if (charModel.bones[i].keyBoneId == targetKeyBone) {
boneIndex = static_cast<uint16_t>(i);
found = true;
break;
}
}
}
// Validate bone index (bad attachment tables should not silently bind to origin)
if (found && boneIndex >= charModel.bones.size()) {
found = false;
}
if (!found && (attachmentId == 1 || attachmentId == 2)) {
int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27;
for (size_t i = 0; i < charModel.bones.size(); i++) {
if (charModel.bones[i].keyBoneId == targetKeyBone) {
boneIndex = static_cast<uint16_t>(i);
offset = glm::vec3(0.0f);
found = true;
break;
}
}
}
if (!found) {
core::Logger::getInstance().warning("attachWeapon: no bone found for attachment ", attachmentId);
return false;
}
// Remove existing weapon at this attachment point
detachWeapon(charInstanceId, attachmentId);
// Load weapon model into renderer
if (models.find(weaponModelId) == models.end()) {
if (!loadModel(weaponModel, weaponModelId)) {
core::Logger::getInstance().warning("attachWeapon: failed to load weapon model ", weaponModelId);
return false;
}
}
// Apply weapon texture if provided
if (!texturePath.empty()) {
VkTexture* texPtr = loadTexture(texturePath);
if (texPtr != whiteTexture_.get()) {
setModelTexture(weaponModelId, 0, texPtr);
}
}
// Create weapon instance
uint32_t weaponInstanceId = createInstance(weaponModelId, glm::vec3(0.0f));
if (weaponInstanceId == 0) return false;
// Mark weapon instance as override-positioned
auto weapIt = instances.find(weaponInstanceId);
if (weapIt != instances.end()) {
weapIt->second.hasOverrideModelMatrix = true;
}
// Store attachment on parent character instance
WeaponAttachment wa;
wa.weaponModelId = weaponModelId;
wa.weaponInstanceId = weaponInstanceId;
wa.attachmentId = attachmentId;
wa.boneIndex = boneIndex;
wa.offset = offset;
charInstance.weaponAttachments.push_back(wa);
core::Logger::getInstance().debug("Attached weapon model ", weaponModelId,
" to instance ", charInstanceId, " at attachment ", attachmentId,
" (bone ", boneIndex, ", offset ", offset.x, ",", offset.y, ",", offset.z, ")");
return true;
}
bool CharacterRenderer::getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const {
auto it = instances.find(instanceId);
if (it == instances.end()) return false;
auto mIt = models.find(it->second.modelId);
if (mIt == models.end()) return false;
const auto& inst = it->second;
const auto& model = mIt->second.data;
glm::vec3 localCenter = (model.boundMin + model.boundMax) * 0.5f;
float radius = model.boundRadius;
if (radius <= 0.001f) {
radius = glm::length(model.boundMax - model.boundMin) * 0.5f;
}
float scale = std::max(0.001f, inst.scale);
outCenter = inst.position + localCenter * scale;
outRadius = std::max(0.5f, radius * scale);
return true;
}
bool CharacterRenderer::getInstanceFootZ(uint32_t instanceId, float& outFootZ) const {
auto it = instances.find(instanceId);
if (it == instances.end()) return false;
auto mIt = models.find(it->second.modelId);
if (mIt == models.end()) return false;
const auto& inst = it->second;
const auto& model = mIt->second.data;
float scale = std::max(0.001f, inst.scale);
outFootZ = inst.position.z + model.boundMin.z * scale;
return true;
}
bool CharacterRenderer::getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const {
auto it = instances.find(instanceId);
if (it == instances.end()) return false;
outPos = it->second.position;
return true;
}
void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) {
auto charIt = instances.find(charInstanceId);
if (charIt == instances.end()) return;
auto& attachments = charIt->second.weaponAttachments;
for (auto it = attachments.begin(); it != attachments.end(); ++it) {
if (it->attachmentId == attachmentId) {
removeInstance(it->weaponInstanceId);
attachments.erase(it);
core::Logger::getInstance().info("Detached weapon from instance ", charInstanceId,
" attachment ", attachmentId);
return;
}
}
}
bool CharacterRenderer::getAttachmentTransform(uint32_t instanceId, uint32_t attachmentId, glm::mat4& outTransform) {
auto instIt = instances.find(instanceId);
if (instIt == instances.end()) return false;
const auto& instance = instIt->second;
auto modelIt = models.find(instance.modelId);
if (modelIt == models.end()) return false;
const auto& model = modelIt->second.data;
// Find attachment point
uint16_t boneIndex = 0;
glm::vec3 offset(0.0f);
bool found = false;
// Try attachment lookup first
if (attachmentId < model.attachmentLookup.size()) {
uint16_t attIdx = model.attachmentLookup[attachmentId];
if (attIdx < model.attachments.size()) {
boneIndex = model.attachments[attIdx].bone;
offset = model.attachments[attIdx].position;
found = true;
}
}
// Fallback: scan attachments by id
if (!found) {
for (const auto& att : model.attachments) {
if (att.id == attachmentId) {
boneIndex = att.bone;
offset = att.position;
found = true;
break;
}
}
}
if (!found) return false;
// Validate bone index; invalid indices bind attachments to origin (looks like weapons at feet).
if (boneIndex >= model.bones.size()) {
// Fallback: key bones (26/27) only for hand attachments.
if (attachmentId == 1 || attachmentId == 2) {
int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27;
found = false;
for (size_t i = 0; i < model.bones.size(); i++) {
if (model.bones[i].keyBoneId == targetKeyBone) {
boneIndex = static_cast<uint16_t>(i);
offset = glm::vec3(0.0f);
found = true;
break;
}
}
if (!found) return false;
} else {
return false;
}
}
// Get bone matrix
glm::mat4 boneMat(1.0f);
if (boneIndex < instance.boneMatrices.size()) {
boneMat = instance.boneMatrices[boneIndex];
}
// Compute world transform: modelMatrix * boneMatrix * offsetTranslation
glm::mat4 modelMat = instance.hasOverrideModelMatrix
? instance.overrideModelMatrix
: getModelMatrix(instance);
outTransform = modelMat * boneMat * glm::translate(glm::mat4(1.0f), offset);
return true;
}
void CharacterRenderer::dumpAnimations(uint32_t instanceId) const {
auto instIt = instances.find(instanceId);
if (instIt == instances.end()) {
core::Logger::getInstance().info("dumpAnimations: instance ", instanceId, " not found");
return;
}
const auto& instance = instIt->second;
auto modelIt = models.find(instance.modelId);
if (modelIt == models.end()) {
core::Logger::getInstance().info("dumpAnimations: model not found for instance ", instanceId);
return;
}
const auto& model = modelIt->second.data;
core::Logger::getInstance().info("=== Animation dump for ", model.name, " ===");
core::Logger::getInstance().info("Total animations: ", model.sequences.size());
for (size_t i = 0; i < model.sequences.size(); i++) {
const auto& seq = model.sequences[i];
core::Logger::getInstance().info(" [", i, "] animId=", seq.id,
" variation=", seq.variationIndex,
" duration=", seq.duration, "ms",
" speed=", seq.movingSpeed,
" flags=0x", std::hex, seq.flags, std::dec);
}
core::Logger::getInstance().info("=== End animation dump ===");
}
void CharacterRenderer::recreatePipelines() {
if (!vkCtx_) return;
VkDevice device = vkCtx_->getDevice();
// Destroy old main-pass pipelines (NOT shadow, NOT pipeline layout)
if (opaquePipeline_) { vkDestroyPipeline(device, opaquePipeline_, nullptr); opaquePipeline_ = VK_NULL_HANDLE; }
if (alphaTestPipeline_) { vkDestroyPipeline(device, alphaTestPipeline_, nullptr); alphaTestPipeline_ = VK_NULL_HANDLE; }
if (alphaPipeline_) { vkDestroyPipeline(device, alphaPipeline_, nullptr); alphaPipeline_ = VK_NULL_HANDLE; }
if (additivePipeline_) { vkDestroyPipeline(device, additivePipeline_, nullptr); additivePipeline_ = VK_NULL_HANDLE; }
// --- Load shaders ---
rendering::VkShaderModule charVert, charFrag;
charVert.loadFromFile(device, "assets/shaders/character.vert.spv");
charFrag.loadFromFile(device, "assets/shaders/character.frag.spv");
if (!charVert.isValid() || !charFrag.isValid()) {
LOG_ERROR("CharacterRenderer::recreatePipelines: missing required shaders");
return;
}
VkRenderPass mainPass = renderPassOverride_ ? renderPassOverride_ : vkCtx_->getImGuiRenderPass();
VkSampleCountFlagBits samples = renderPassOverride_ ? msaaSamplesOverride_ : vkCtx_->getMsaaSamples();
// --- Vertex input ---
VkVertexInputBindingDescription charBinding{};
charBinding.binding = 0;
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(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 {
return PipelineBuilder()
.setShaders(charVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
charFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
.setVertexInput({charBinding}, charAttrs)
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST)
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
.setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL)
.setColorBlendAttachment(blendState)
.setMultisample(samples)
.setLayout(pipelineLayout_)
.setRenderPass(mainPass)
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
.build(device);
};
LOG_INFO("CharacterRenderer::recreatePipelines: renderPass=", (void*)mainPass,
" samples=", static_cast<int>(samples),
" pipelineLayout=", (void*)pipelineLayout_);
opaquePipeline_ = buildCharPipeline(PipelineBuilder::blendDisabled(), true);
alphaTestPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), true);
alphaPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), false);
additivePipeline_ = buildCharPipeline(PipelineBuilder::blendAdditive(), false);
charVert.destroy();
charFrag.destroy();
if (!opaquePipeline_ || !alphaTestPipeline_ || !alphaPipeline_ || !additivePipeline_) {
LOG_ERROR("CharacterRenderer::recreatePipelines FAILED: opaque=", (void*)opaquePipeline_,
" alphaTest=", (void*)alphaTestPipeline_,
" alpha=", (void*)alphaPipeline_,
" additive=", (void*)additivePipeline_,
" renderPass=", (void*)mainPass, " samples=", static_cast<int>(samples));
} else {
LOG_INFO("CharacterRenderer: pipelines recreated successfully (samples=",
static_cast<int>(samples), ")");
}
}
} // namespace rendering
} // namespace wowee