2026-02-02 12:24:50 -08:00
|
|
|
/**
|
2026-02-21 19:41:21 -08:00
|
|
|
* CharacterRenderer — GPU rendering of M2 character models with skeletal animation (Vulkan)
|
2026-02-02 12:24:50 -08:00
|
|
|
*
|
|
|
|
|
* Handles:
|
2026-02-21 19:41:21 -08:00
|
|
|
* - Uploading M2 vertex/index data to Vulkan buffers via VMA
|
2026-02-02 12:24:50 -08:00
|
|
|
* - Per-frame bone matrix computation (hierarchical, with keyframe interpolation)
|
2026-02-21 19:41:21 -08:00
|
|
|
* - GPU vertex skinning via a bone-matrix SSBO in the vertex shader
|
2026-02-02 12:24:50 -08:00
|
|
|
* - 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
|
2026-02-21 19:41:21 -08:00
|
|
|
* positions on the 512x512 body skin atlas. Region coordinates sourced from
|
2026-02-02 12:24:50 -08:00
|
|
|
* the original WoW Model Viewer (charcontrol.h, REGION_FAC=2).
|
|
|
|
|
*/
|
|
|
|
|
#include "rendering/character_renderer.hpp"
|
2026-02-21 19:41:21 -08:00
|
|
|
#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"
|
2026-02-02 12:24:50 -08:00
|
|
|
#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>
|
2026-02-03 22:24:17 -08:00
|
|
|
#include <filesystem>
|
2026-02-10 19:30:45 -08:00
|
|
|
#include <future>
|
2026-02-22 06:32:49 -08:00
|
|
|
#include <thread>
|
2026-02-10 19:30:45 -08:00
|
|
|
#include <functional>
|
|
|
|
|
#include <unordered_map>
|
|
|
|
|
#include <unordered_set>
|
2026-02-19 02:39:33 -08:00
|
|
|
#include <chrono>
|
2026-02-12 16:29:36 -08:00
|
|
|
#include <cstdlib>
|
2026-02-17 03:18:01 -08:00
|
|
|
#include <fstream>
|
2026-02-12 16:29:36 -08:00
|
|
|
#include <limits>
|
2026-02-21 19:41:21 -08:00
|
|
|
#include <cstring>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
namespace rendering {
|
|
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 08:12:08 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
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
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Descriptor pool sizing
|
|
|
|
|
static constexpr uint32_t MAX_MATERIAL_SETS = 4096;
|
2026-02-22 06:21:18 -08:00
|
|
|
static constexpr uint32_t MAX_BONE_SETS = 8192;
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// 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;
|
2026-02-23 01:40:23 -08:00
|
|
|
int32_t enableNormalMap;
|
|
|
|
|
int32_t enablePOM;
|
|
|
|
|
float pomScale;
|
|
|
|
|
int32_t pomMaxSamples;
|
|
|
|
|
float heightMapVariance;
|
|
|
|
|
float normalMapStrength;
|
|
|
|
|
float _pad[2]; // pad to 64 bytes
|
2026-02-21 19:41:21 -08:00
|
|
|
};
|
|
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
// 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
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
CharacterRenderer::CharacterRenderer() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
CharacterRenderer::~CharacterRenderer() {
|
|
|
|
|
shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
bool CharacterRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout,
|
2026-02-22 05:58:45 -08:00
|
|
|
pipeline::AssetManager* am,
|
|
|
|
|
VkRenderPass renderPassOverride) {
|
2026-02-21 19:41:21 -08:00
|
|
|
core::Logger::getInstance().info("Initializing character renderer (Vulkan)...");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
vkCtx_ = ctx;
|
|
|
|
|
assetManager = am;
|
|
|
|
|
perFrameLayout_ = perFrameLayout;
|
2026-02-22 05:58:45 -08:00
|
|
|
renderPassOverride_ = renderPassOverride;
|
2026-02-22 08:12:08 -08:00
|
|
|
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_);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
VkDevice device = vkCtx_->getDevice();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// --- Descriptor set layouts ---
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
// Material set layout (set 1): binding 0 = sampler2D, binding 1 = CharMaterial UBO, binding 2 = normal/height map
|
2026-02-21 19:41:21 -08:00
|
|
|
{
|
2026-02-23 01:40:23 -08:00
|
|
|
VkDescriptorSetLayoutBinding bindings[3] = {};
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-23 01:40:23 -08:00
|
|
|
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;
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
|
2026-02-23 01:40:23 -08:00
|
|
|
ci.bindingCount = 3;
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-04 16:41:40 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
VkDescriptorSetLayoutCreateInfo ci{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
|
|
|
|
|
ci.bindingCount = 1;
|
|
|
|
|
ci.pBindings = &binding;
|
|
|
|
|
vkCreateDescriptorSetLayout(device, &ci, nullptr, &boneSetLayout_);
|
2026-02-04 16:41:40 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// --- Descriptor pools ---
|
2026-02-22 06:21:18 -08:00
|
|
|
// 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++) {
|
2026-02-21 19:41:21 -08:00
|
|
|
VkDescriptorPoolSize sizes[] = {
|
2026-02-23 01:40:23 -08:00
|
|
|
{VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, MAX_MATERIAL_SETS * 2}, // diffuse + normal/height
|
2026-02-21 19:41:21 -08:00
|
|
|
{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;
|
2026-02-22 06:21:18 -08:00
|
|
|
vkCreateDescriptorPool(device, &ci, nullptr, &materialDescPools_[i]);
|
2026-02-21 19:41:21 -08:00
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
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_);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 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");
|
2026-02-04 16:41:40 -08:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 05:58:45 -08:00
|
|
|
VkRenderPass mainPass = renderPassOverride_ ? renderPassOverride_ : vkCtx_->getImGuiRenderPass();
|
|
|
|
|
VkSampleCountFlagBits samples = renderPassOverride_ ? VK_SAMPLE_COUNT_1_BIT : vkCtx_->getMsaaSamples();
|
2026-02-20 20:31:04 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// --- Vertex input ---
|
2026-02-23 01:40:23 -08:00
|
|
|
// CharVertexGPU: vec3 pos(12) + uint8[4] boneWeights(4) + uint8[4] boneIndices(4) +
|
|
|
|
|
// vec3 normal(12) + vec2 texCoords(8) + vec4 tangent(16) = 56 bytes
|
2026-02-21 19:41:21 -08:00
|
|
|
VkVertexInputBindingDescription charBinding{};
|
|
|
|
|
charBinding.binding = 0;
|
2026-02-23 01:40:23 -08:00
|
|
|
charBinding.stride = sizeof(CharVertexGPU);
|
2026-02-21 19:41:21 -08:00
|
|
|
charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
|
|
|
|
|
|
|
|
|
|
std::vector<VkVertexInputAttributeDescription> charAttrs = {
|
2026-02-23 01:40:23 -08:00
|
|
|
{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))},
|
2026-02-21 19:41:21 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// --- 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)
|
2026-02-22 05:58:45 -08:00
|
|
|
.setMultisample(samples)
|
2026-02-21 19:41:21 -08:00
|
|
|
.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);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
// --- 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
// Diagnostics-only: cache lifetime is currently tied to renderer lifetime.
|
2026-02-23 04:32:58 -08:00
|
|
|
textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 4096) * 1024ull * 1024ull;
|
2026-02-22 08:12:08 -08:00
|
|
|
LOG_INFO("Character texture cache budget: ", textureCacheBudgetBytes_ / (1024 * 1024), " MB");
|
2026-02-12 16:29:36 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
core::Logger::getInstance().info("Character renderer initialized (Vulkan)");
|
2026-02-02 12:24:50 -08:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::shutdown() {
|
2026-02-21 19:41:21 -08:00
|
|
|
if (!vkCtx_) return;
|
|
|
|
|
|
2026-02-22 05:58:45 -08:00
|
|
|
LOG_INFO("CharacterRenderer::shutdown instances=", instances.size(),
|
|
|
|
|
" models=", models.size(), " override=", (void*)renderPassOverride_);
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
vkDeviceWaitIdle(vkCtx_->getDevice());
|
|
|
|
|
VkDevice device = vkCtx_->getDevice();
|
|
|
|
|
VmaAllocator alloc = vkCtx_->getAllocator();
|
|
|
|
|
|
|
|
|
|
// Clean up GPU resources for models
|
2026-02-02 12:24:50 -08:00
|
|
|
for (auto& pair : models) {
|
2026-02-21 19:41:21 -08:00
|
|
|
destroyModelGPU(pair.second);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Clean up instance bone buffers
|
|
|
|
|
for (auto& pair : instances) {
|
|
|
|
|
destroyInstanceBones(pair.second);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// Clean up texture cache (VkTexture unique_ptrs auto-destroy)
|
2026-02-02 12:24:50 -08:00
|
|
|
textureCache.clear();
|
2026-02-21 19:41:21 -08:00
|
|
|
textureHasAlphaByPtr_.clear();
|
|
|
|
|
textureColorKeyBlackByPtr_.clear();
|
2026-02-12 16:29:36 -08:00
|
|
|
textureCacheBytes_ = 0;
|
|
|
|
|
textureCacheCounter_ = 0;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Clean up composite cache
|
|
|
|
|
compositeCache_.clear();
|
|
|
|
|
failedTextureCache_.clear();
|
|
|
|
|
|
|
|
|
|
whiteTexture_.reset();
|
|
|
|
|
transparentTexture_.reset();
|
2026-02-23 01:40:23 -08:00
|
|
|
flatNormalTexture_.reset();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
models.clear();
|
|
|
|
|
instances.clear();
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// 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; }
|
|
|
|
|
|
2026-02-22 06:21:18 -08:00
|
|
|
// Release any deferred transient material UBOs.
|
|
|
|
|
for (int i = 0; i < 2; i++) {
|
|
|
|
|
for (const auto& b : transientMaterialUbos_[i]) {
|
|
|
|
|
if (b.first) {
|
|
|
|
|
vmaDestroyBuffer(alloc, b.first, b.second);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
transientMaterialUbos_[i].clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Destroy descriptor pools and layouts
|
2026-02-22 06:21:18 -08:00
|
|
|
for (int i = 0; i < 2; i++) {
|
|
|
|
|
if (materialDescPools_[i]) {
|
|
|
|
|
vkDestroyDescriptorPool(device, materialDescPools_[i], nullptr);
|
|
|
|
|
materialDescPools_[i] = VK_NULL_HANDLE;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
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; }
|
|
|
|
|
|
2026-02-21 19:49:50 -08:00
|
|
|
// 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; }
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
vkCtx_ = nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
2026-02-22 06:21:18 -08:00
|
|
|
VkDevice device = vkCtx_->getDevice();
|
2026-02-21 19:41:21 -08:00
|
|
|
for (int i = 0; i < 2; i++) {
|
2026-02-22 06:21:18 -08:00
|
|
|
if (inst.boneSet[i] != VK_NULL_HANDLE && boneDescPool_ != VK_NULL_HANDLE) {
|
|
|
|
|
vkFreeDescriptorSets(device, boneDescPool_, 1, &inst.boneSet[i]);
|
|
|
|
|
inst.boneSet[i] = VK_NULL_HANDLE;
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
if (inst.boneBuffer[i]) {
|
|
|
|
|
vmaDestroyBuffer(alloc, inst.boneBuffer[i], inst.boneAlloc[i]);
|
|
|
|
|
inst.boneBuffer[i] = VK_NULL_HANDLE;
|
2026-02-22 06:21:18 -08:00
|
|
|
inst.boneAlloc[i] = VK_NULL_HANDLE;
|
2026-02-21 19:41:21 -08:00
|
|
|
inst.boneMapped[i] = nullptr;
|
|
|
|
|
}
|
2026-02-04 16:41:40 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
std::unique_ptr<VkTexture> CharacterRenderer::generateNormalHeightMap(
|
|
|
|
|
const uint8_t* pixels, uint32_t width, uint32_t height, float& outVariance) {
|
|
|
|
|
if (!vkCtx_ || width == 0 || height == 0) return nullptr;
|
|
|
|
|
|
|
|
|
|
const uint32_t totalPixels = width * height;
|
|
|
|
|
|
|
|
|
|
// Step 1: Compute height from luminance
|
|
|
|
|
std::vector<float> heightMap(totalPixels);
|
|
|
|
|
double sumH = 0.0, sumH2 = 0.0;
|
|
|
|
|
for (uint32_t i = 0; i < totalPixels; i++) {
|
|
|
|
|
float r = pixels[i * 4 + 0] / 255.0f;
|
|
|
|
|
float g = pixels[i * 4 + 1] / 255.0f;
|
|
|
|
|
float b = pixels[i * 4 + 2] / 255.0f;
|
|
|
|
|
float h = 0.299f * r + 0.587f * g + 0.114f * b;
|
|
|
|
|
heightMap[i] = h;
|
|
|
|
|
sumH += h;
|
|
|
|
|
sumH2 += h * h;
|
|
|
|
|
}
|
|
|
|
|
double mean = sumH / totalPixels;
|
|
|
|
|
outVariance = static_cast<float>(sumH2 / totalPixels - mean * mean);
|
|
|
|
|
|
|
|
|
|
// Step 1.5: Box blur the height map to reduce noise from diffuse textures
|
|
|
|
|
auto wrapSample = [&](const std::vector<float>& map, int x, int y) -> float {
|
|
|
|
|
x = ((x % (int)width) + (int)width) % (int)width;
|
|
|
|
|
y = ((y % (int)height) + (int)height) % (int)height;
|
|
|
|
|
return map[y * width + x];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
std::vector<float> blurredHeight(totalPixels);
|
|
|
|
|
for (uint32_t y = 0; y < height; y++) {
|
|
|
|
|
for (uint32_t x = 0; x < width; x++) {
|
|
|
|
|
int ix = static_cast<int>(x), iy = static_cast<int>(y);
|
|
|
|
|
float sum = 0.0f;
|
|
|
|
|
for (int dy = -1; dy <= 1; dy++)
|
|
|
|
|
for (int dx = -1; dx <= 1; dx++)
|
|
|
|
|
sum += wrapSample(heightMap, ix + dx, iy + dy);
|
|
|
|
|
blurredHeight[y * width + x] = sum / 9.0f;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 2: Sobel 3x3 → normal map (crisp detail from original, blurred for POM alpha)
|
2026-02-23 01:43:09 -08:00
|
|
|
// Higher strength than WMO (2.0) because character/weapon textures are hand-painted
|
|
|
|
|
// with baked-in lighting that produces low-contrast gradients in the Sobel filter.
|
|
|
|
|
const float strength = 5.0f;
|
2026-02-23 01:40:23 -08:00
|
|
|
std::vector<uint8_t> output(totalPixels * 4);
|
|
|
|
|
|
|
|
|
|
auto sampleH = [&](int x, int y) -> float {
|
|
|
|
|
x = ((x % (int)width) + (int)width) % (int)width;
|
|
|
|
|
y = ((y % (int)height) + (int)height) % (int)height;
|
|
|
|
|
return heightMap[y * width + x];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (uint32_t y = 0; y < height; y++) {
|
|
|
|
|
for (uint32_t x = 0; x < width; x++) {
|
|
|
|
|
int ix = static_cast<int>(x);
|
|
|
|
|
int iy = static_cast<int>(y);
|
|
|
|
|
float gx = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix-1, iy) - sampleH(ix-1, iy+1)
|
|
|
|
|
+ sampleH(ix+1, iy-1) + 2.0f*sampleH(ix+1, iy) + sampleH(ix+1, iy+1);
|
|
|
|
|
float gy = -sampleH(ix-1, iy-1) - 2.0f*sampleH(ix, iy-1) - sampleH(ix+1, iy-1)
|
|
|
|
|
+ sampleH(ix-1, iy+1) + 2.0f*sampleH(ix, iy+1) + sampleH(ix+1, iy+1);
|
|
|
|
|
|
|
|
|
|
float nx = -gx * strength;
|
|
|
|
|
float ny = -gy * strength;
|
|
|
|
|
float nz = 1.0f;
|
|
|
|
|
float len = std::sqrt(nx*nx + ny*ny + nz*nz);
|
|
|
|
|
if (len > 0.0f) { nx /= len; ny /= len; nz /= len; }
|
|
|
|
|
|
|
|
|
|
uint32_t idx = (y * width + x) * 4;
|
|
|
|
|
output[idx + 0] = static_cast<uint8_t>(std::clamp((nx * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
|
|
|
|
|
output[idx + 1] = static_cast<uint8_t>(std::clamp((ny * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
|
|
|
|
|
output[idx + 2] = static_cast<uint8_t>(std::clamp((nz * 0.5f + 0.5f) * 255.0f, 0.0f, 255.0f));
|
|
|
|
|
output[idx + 3] = static_cast<uint8_t>(std::clamp(blurredHeight[y * width + x] * 255.0f, 0.0f, 255.0f));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto tex = std::make_unique<VkTexture>();
|
|
|
|
|
if (!tex->upload(*vkCtx_, output.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true)) {
|
|
|
|
|
return nullptr;
|
|
|
|
|
}
|
|
|
|
|
tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, VK_FILTER_LINEAR,
|
|
|
|
|
VK_SAMPLER_ADDRESS_MODE_REPEAT);
|
|
|
|
|
return tex;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
VkTexture* CharacterRenderer::loadTexture(const std::string& path) {
|
2026-02-02 12:24:50 -08:00
|
|
|
// Skip empty or whitespace-only paths (type-0 textures have no filename)
|
2026-02-21 19:41:21 -08:00
|
|
|
if (path.empty()) return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
bool allWhitespace = true;
|
|
|
|
|
for (char c : path) {
|
|
|
|
|
if (c != ' ' && c != '\t' && c != '\0' && c != '\n') { allWhitespace = false; break; }
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
if (allWhitespace) return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-12 16:15:25 -08:00
|
|
|
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);
|
2026-02-19 02:39:33 -08:00
|
|
|
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");
|
2026-02-12 16:15:25 -08:00
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Check cache
|
2026-02-12 16:15:25 -08:00
|
|
|
auto it = textureCache.find(key);
|
2026-02-12 16:29:36 -08:00
|
|
|
if (it != textureCache.end()) {
|
|
|
|
|
it->second.lastUse = ++textureCacheCounter_;
|
2026-02-21 19:41:21 -08:00
|
|
|
return it->second.texture.get();
|
2026-02-12 16:29:36 -08:00
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
if (!assetManager || !assetManager->isInitialized()) {
|
2026-02-21 19:41:21 -08:00
|
|
|
return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-16 00:45:47 -08:00
|
|
|
// Check negative cache to avoid repeated file I/O for textures that don't exist
|
|
|
|
|
if (failedTextureCache_.count(key)) {
|
2026-02-21 19:41:21 -08:00
|
|
|
return whiteTexture_.get();
|
2026-02-16 00:45:47 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 16:15:25 -08:00
|
|
|
auto blpImage = assetManager->loadTexture(key);
|
2026-02-02 12:24:50 -08:00
|
|
|
if (!blpImage.isValid()) {
|
2026-02-22 08:12:08 -08:00
|
|
|
static constexpr size_t kMaxFailedTextureCache = 200000;
|
2026-02-02 12:24:50 -08:00
|
|
|
core::Logger::getInstance().warning("Failed to load texture: ", path);
|
2026-02-22 08:12:08 -08:00
|
|
|
if (failedTextureCache_.size() < kMaxFailedTextureCache) {
|
|
|
|
|
failedTextureCache_.insert(key);
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-02-23 04:32:58 -08:00
|
|
|
if (textureBudgetRejectWarnings_ < 3) {
|
2026-02-22 08:12:08 -08:00
|
|
|
core::Logger::getInstance().warning(
|
|
|
|
|
"Character texture cache full (",
|
|
|
|
|
textureCacheBytes_ / (1024 * 1024), " MB / ",
|
|
|
|
|
textureCacheBudgetBytes_ / (1024 * 1024), " MB), rejecting texture: ",
|
|
|
|
|
path);
|
|
|
|
|
}
|
|
|
|
|
++textureBudgetRejectWarnings_;
|
2026-02-21 19:41:21 -08:00
|
|
|
return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 02:39:33 -08:00
|
|
|
bool hasAlpha = false;
|
|
|
|
|
for (size_t i = 3; i < blpImage.data.size(); i += 4) {
|
|
|
|
|
if (blpImage.data[i] != 255) {
|
|
|
|
|
hasAlpha = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
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();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
TextureCacheEntry e;
|
2026-02-21 19:41:21 -08:00
|
|
|
e.texture = std::move(tex);
|
2026-02-22 08:12:08 -08:00
|
|
|
e.approxBytes = approxBytes;
|
2026-02-12 16:29:36 -08:00
|
|
|
e.lastUse = ++textureCacheCounter_;
|
2026-02-19 02:39:33 -08:00
|
|
|
e.hasAlpha = hasAlpha;
|
|
|
|
|
e.colorKeyBlack = colorKeyBlackHint;
|
2026-02-23 01:40:23 -08:00
|
|
|
|
|
|
|
|
// Generate normal/height map from diffuse texture
|
|
|
|
|
float nhVariance = 0.0f;
|
|
|
|
|
auto nhMap = generateNormalHeightMap(blpImage.data.data(), blpImage.width, blpImage.height, nhVariance);
|
|
|
|
|
if (nhMap) {
|
|
|
|
|
e.heightMapVariance = nhVariance;
|
|
|
|
|
e.approxBytes += approxTextureBytesWithMips(blpImage.width, blpImage.height);
|
|
|
|
|
e.normalHeightMap = std::move(nhMap);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
textureCacheBytes_ += e.approxBytes;
|
2026-02-21 19:41:21 -08:00
|
|
|
textureHasAlphaByPtr_[texPtr] = hasAlpha;
|
|
|
|
|
textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint;
|
|
|
|
|
textureCache[key] = std::move(e);
|
|
|
|
|
|
2026-02-20 20:00:44 -08:00
|
|
|
core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")");
|
2026-02-21 19:41:21 -08:00
|
|
|
return texPtr;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
// 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;
|
2026-02-02 12:24:50 -08:00
|
|
|
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;
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Write to scale x scale block of destination pixels
|
2026-02-17 03:18:01 -08:00
|
|
|
for (int dy2 = 0; dy2 < scale; dy2++) {
|
|
|
|
|
int dy = dstY + sy * scale + dy2;
|
2026-02-02 12:24:50 -08:00
|
|
|
if (dy < 0 || dy >= compH) continue;
|
2026-02-17 03:18:01 -08:00
|
|
|
for (int dx2 = 0; dx2 < scale; dx2++) {
|
|
|
|
|
int dx = dstX + sx * scale + dx2;
|
2026-02-02 12:24:50 -08:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
VkTexture* CharacterRenderer::compositeTextures(const std::vector<std::string>& layerPaths) {
|
2026-02-02 12:24:50 -08:00
|
|
|
if (layerPaths.empty() || !assetManager || !assetManager->isInitialized()) {
|
2026-02-21 19:41:21 -08:00
|
|
|
return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 06:21:18 -08:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Load base layer
|
|
|
|
|
auto base = assetManager->loadTexture(layerPaths[0]);
|
|
|
|
|
if (!base.isValid()) {
|
|
|
|
|
core::Logger::getInstance().warning("Composite: failed to load base layer: ", layerPaths[0]);
|
2026-02-21 19:41:21 -08:00
|
|
|
return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
// 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
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
// 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};
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Alpha-blend each overlay onto the composite
|
|
|
|
|
for (size_t layer = 1; layer < layerPaths.size(); layer++) {
|
|
|
|
|
if (layerPaths[layer].empty()) continue;
|
|
|
|
|
|
|
|
|
|
auto overlay = assetManager->loadTexture(layerPaths[layer]);
|
|
|
|
|
if (!overlay.isValid()) {
|
2026-02-17 03:18:01 -08:00
|
|
|
core::Logger::getInstance().warning("Composite: FAILED to load overlay: ", layerPaths[layer]);
|
2026-02-02 12:24:50 -08:00
|
|
|
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
|
2026-02-15 12:53:15 -08:00
|
|
|
// Coordinates scale with base texture size (256x256 is reference)
|
2026-02-02 12:24:50 -08:00
|
|
|
int dstX = 0, dstY = 0;
|
2026-02-17 03:18:01 -08:00
|
|
|
int expectedW256 = 0, expectedH256 = 0; // Expected size at 256-base
|
2026-02-02 12:24:50 -08:00
|
|
|
std::string pathLower = layerPaths[layer];
|
|
|
|
|
for (auto& c : pathLower) c = std::tolower(c);
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
if (pathLower.find("faceupper") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = faceUpperRegion256.x; dstY = faceUpperRegion256.y;
|
|
|
|
|
expectedW256 = faceUpperRegion256.w; expectedH256 = faceUpperRegion256.h;
|
2026-02-15 12:53:15 -08:00
|
|
|
} else if (pathLower.find("facelower") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = faceLowerRegion256.x; dstY = faceLowerRegion256.y;
|
|
|
|
|
expectedW256 = faceLowerRegion256.w; expectedH256 = faceLowerRegion256.h;
|
2026-02-15 12:53:15 -08:00
|
|
|
} else if (pathLower.find("pelvis") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = 128; dstY = 96;
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("torso") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = 128; dstY = 0;
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("armupper") != std::string::npos) {
|
|
|
|
|
dstX = 0; dstY = 0;
|
2026-02-17 03:18:01 -08:00
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("armlower") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = 0; dstY = 64;
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("hand") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = 0; dstY = 128;
|
|
|
|
|
expectedW256 = 128; expectedH256 = 32;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = 128; dstY = 224;
|
|
|
|
|
expectedW256 = 128; expectedH256 = 32;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
dstX = 128; dstY = 160;
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else {
|
2026-02-21 19:41:21 -08:00
|
|
|
// Unknown -- center placement as fallback
|
2026-02-02 12:24:50 -08:00
|
|
|
dstX = (width - overlay.width) / 2;
|
|
|
|
|
dstY = (height - overlay.height) / 2;
|
2026-02-17 03:18:01 -08:00
|
|
|
core::Logger::getInstance().info("Composite: UNKNOWN region for '",
|
|
|
|
|
layerPaths[layer], "', centering at (", dstX, ",", dstY, ")");
|
|
|
|
|
blitOverlay(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
continue;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
// 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 /tmp for visual inspection
|
|
|
|
|
{
|
|
|
|
|
std::string dumpPath = "/tmp/wowee_composite_debug_" +
|
|
|
|
|
std::to_string(width) + "x" + std::to_string(height) + ".raw";
|
|
|
|
|
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)");
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// 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();
|
|
|
|
|
|
2026-02-22 06:21:18 -08:00
|
|
|
// Store in texture cache with deterministic key.
|
|
|
|
|
// Keep the first allocation for a key to avoid invalidating raw pointers
|
|
|
|
|
// held by active render instances.
|
2026-02-21 19:41:21 -08:00
|
|
|
TextureCacheEntry e;
|
|
|
|
|
e.texture = std::move(tex);
|
|
|
|
|
e.approxBytes = approxTextureBytesWithMips(width, height);
|
|
|
|
|
e.lastUse = ++textureCacheCounter_;
|
|
|
|
|
e.hasAlpha = false;
|
|
|
|
|
e.colorKeyBlack = false;
|
2026-02-22 06:21:18 -08:00
|
|
|
textureCache.emplace(cacheKey, std::move(e));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
core::Logger::getInstance().info("Composite texture created: ", width, "x", height, " from ", layerPaths.size(), " layers");
|
2026-02-21 19:41:21 -08:00
|
|
|
return texPtr;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
void CharacterRenderer::clearCompositeCache() {
|
2026-02-16 00:19:07 -08:00
|
|
|
// Just clear the lookup map so next compositeWithRegions() creates fresh textures.
|
2026-02-21 19:41:21 -08:00
|
|
|
// Don't delete GPU textures -- they may still be referenced by models or instances.
|
2026-02-16 00:19:07 -08:00
|
|
|
// Orphaned textures will be cleaned up when their model/instance is destroyed.
|
2026-02-15 20:53:01 -08:00
|
|
|
compositeCache_.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
VkTexture* CharacterRenderer::compositeWithRegions(const std::string& basePath,
|
2026-02-02 12:24:50 -08:00
|
|
|
const std::vector<std::string>& baseLayers,
|
|
|
|
|
const std::vector<std::pair<int, std::string>>& regionLayers) {
|
2026-02-14 15:48:58 -08:00
|
|
|
// 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);
|
2026-02-21 19:41:21 -08:00
|
|
|
if (cacheIt != compositeCache_.end() && cacheIt->second != nullptr) {
|
2026-02-14 15:48:58 -08:00
|
|
|
return cacheIt->second;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 06:21:18 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Region index -> pixel coordinates on the 256x256 base atlas
|
2026-02-15 20:53:01 -08:00
|
|
|
// These are scaled up by (width/256, height/256) for larger textures (512x512, 1024x1024)
|
|
|
|
|
static const int regionCoords256[][2] = {
|
2026-02-02 12:24:50 -08:00
|
|
|
{ 0, 0 }, // 0 = ArmUpper
|
2026-02-15 20:53:01 -08:00
|
|
|
{ 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
|
2026-02-02 12:24:50 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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()) {
|
2026-02-21 19:41:21 -08:00
|
|
|
return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto base = assetManager->loadTexture(basePath);
|
|
|
|
|
if (!base.isValid()) {
|
2026-02-21 19:41:21 -08:00
|
|
|
return whiteTexture_.get();
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 23:15:57 -08:00
|
|
|
std::vector<uint8_t> composite;
|
2026-02-02 12:24:50 -08:00
|
|
|
int width = base.width;
|
|
|
|
|
int height = base.height;
|
|
|
|
|
|
2026-02-05 23:15:57 -08:00
|
|
|
// 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];
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-15 14:00:41 -08:00
|
|
|
core::Logger::getInstance().debug("compositeWithRegions: upscaled 256x256 to 512x512");
|
2026-02-05 23:15:57 -08:00
|
|
|
} else {
|
|
|
|
|
composite = base.data;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
// Blend face + underwear overlays
|
2026-02-21 19:41:21 -08:00
|
|
|
// If we upscaled from 256->512, scale coords and texels with blitOverlayScaled2x.
|
2026-02-15 20:53:01 -08:00
|
|
|
// For native 512/1024 textures, face overlays are full atlas size (hit width==width branch).
|
2026-02-15 12:53:15 -08:00
|
|
|
bool upscaled = (base.width == 256 && base.height == 256 && width == 512);
|
2026-02-02 12:24:50 -08:00
|
|
|
for (const auto& ul : baseLayers) {
|
|
|
|
|
if (ul.empty()) continue;
|
|
|
|
|
auto overlay = assetManager->loadTexture(ul);
|
|
|
|
|
if (!overlay.isValid()) continue;
|
|
|
|
|
|
|
|
|
|
if (overlay.width == width && overlay.height == height) {
|
|
|
|
|
blitOverlay(composite, width, height, overlay, 0, 0);
|
|
|
|
|
} else {
|
2026-02-15 12:53:15 -08:00
|
|
|
// WoW 256-scale atlas coordinates (from CharComponentTextureSections)
|
2026-02-02 12:24:50 -08:00
|
|
|
int dstX = 0, dstY = 0;
|
|
|
|
|
std::string pathLower = ul;
|
|
|
|
|
for (auto& c : pathLower) c = std::tolower(c);
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
// Scale factor from 256-base coordinates to actual canvas size
|
|
|
|
|
int coordScale = width / 256;
|
|
|
|
|
if (coordScale < 1) coordScale = 1;
|
|
|
|
|
bool useScale = true;
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
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;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("torso") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
dstX = 128; dstY = 0;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("armupper") != std::string::npos) {
|
|
|
|
|
dstX = 0; dstY = 0;
|
|
|
|
|
} else if (pathLower.find("armlower") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
dstX = 0; dstY = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("hand") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
dstX = 0; dstY = 128;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
dstX = 128; dstY = 224;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
dstX = 128; dstY = 160;
|
2026-02-02 12:24:50 -08:00
|
|
|
} else {
|
2026-02-15 20:53:01 -08:00
|
|
|
// 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;
|
2026-02-15 12:53:15 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (upscaled) {
|
2026-02-15 20:53:01 -08:00
|
|
|
// Overlay is 256-base sized, needs 2x texel scaling for 512 canvas
|
2026-02-15 12:53:15 -08:00
|
|
|
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
} else {
|
|
|
|
|
blitOverlay(composite, width, height, overlay, dstX, dstY);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
// 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
|
2026-02-02 12:24:50 -08:00
|
|
|
};
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
// 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;
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Now blit equipment region textures at explicit coordinates
|
|
|
|
|
for (const auto& rl : regionLayers) {
|
|
|
|
|
int regionIdx = rl.first;
|
|
|
|
|
if (regionIdx < 0 || regionIdx >= 8) continue;
|
|
|
|
|
|
|
|
|
|
auto overlay = assetManager->loadTexture(rl.second);
|
|
|
|
|
if (!overlay.isValid()) {
|
|
|
|
|
core::Logger::getInstance().warning("compositeWithRegions: failed to load ", rl.second);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
int dstX = regionCoords256[regionIdx][0] * scaleX;
|
|
|
|
|
int dstY = regionCoords256[regionIdx][1] * scaleY;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
// Expected full-resolution size for this region at current atlas scale
|
|
|
|
|
int expectedW = regionSizes256[regionIdx][0] * scaleX;
|
|
|
|
|
int expectedH = regionSizes256[regionIdx][1] * scaleY;
|
2026-02-02 12:24:50 -08:00
|
|
|
if (overlay.width * 2 == expectedW && overlay.height * 2 == expectedH) {
|
|
|
|
|
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
} else {
|
|
|
|
|
blitOverlay(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
core::Logger::getInstance().debug("compositeWithRegions: region ", regionIdx,
|
2026-02-02 12:24:50 -08:00
|
|
|
" at (", dstX, ",", dstY, ") ", overlay.width, "x", overlay.height, " from ", rl.second);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// 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();
|
|
|
|
|
|
2026-02-22 06:21:18 -08:00
|
|
|
// Store in texture cache.
|
|
|
|
|
// Use emplace to avoid replacing an existing texture for this key; replacing
|
|
|
|
|
// would invalidate pointers currently bound to active instances.
|
2026-02-21 19:41:21 -08:00
|
|
|
TextureCacheEntry entry;
|
|
|
|
|
entry.texture = std::move(tex);
|
|
|
|
|
entry.approxBytes = approxTextureBytesWithMips(width, height);
|
|
|
|
|
entry.lastUse = ++textureCacheCounter_;
|
|
|
|
|
entry.hasAlpha = false;
|
|
|
|
|
entry.colorKeyBlack = false;
|
2026-02-22 06:21:18 -08:00
|
|
|
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();
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
core::Logger::getInstance().debug("compositeWithRegions: created ", width, "x", height,
|
2026-02-02 12:24:50 -08:00
|
|
|
" texture with ", regionLayers.size(), " equipment regions");
|
2026-02-21 19:41:21 -08:00
|
|
|
compositeCache_[cacheKey] = texPtr;
|
|
|
|
|
return texPtr;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, VkTexture* texture) {
|
2026-02-02 12:24:50 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
gpuModel.textureIds[textureSlot] = texture;
|
2026-02-11 22:27:02 -08:00
|
|
|
core::Logger::getInstance().debug("Replaced model ", modelId, " texture slot ", textureSlot, " with composited texture");
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::resetModelTexture(uint32_t modelId, uint32_t textureSlot) {
|
2026-02-21 19:41:21 -08:00
|
|
|
setModelTexture(modelId, textureSlot, whiteTexture_.get());
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
2026-02-21 19:41:21 -08:00
|
|
|
destroyModelGPU(models[id]);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
M2ModelGPU gpuModel;
|
|
|
|
|
gpuModel.data = model;
|
|
|
|
|
|
|
|
|
|
// Setup GPU buffers
|
|
|
|
|
setupModelBuffers(gpuModel);
|
|
|
|
|
|
|
|
|
|
// Calculate bind pose
|
|
|
|
|
calculateBindPose(gpuModel);
|
|
|
|
|
|
|
|
|
|
// Load textures from model
|
|
|
|
|
for (const auto& tex : model.textures) {
|
2026-02-21 19:41:21 -08:00
|
|
|
VkTexture* texPtr = loadTexture(tex.filename);
|
|
|
|
|
gpuModel.textureIds.push_back(texPtr);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
models[id] = std::move(gpuModel);
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
core::Logger::getInstance().debug("Loaded M2 model ", id, " (", model.vertices.size(),
|
|
|
|
|
" verts, ", model.bones.size(), " bones, ", model.sequences.size(),
|
|
|
|
|
" anims, ", model.textures.size(), " textures)");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::setupModelBuffers(M2ModelGPU& gpuModel) {
|
|
|
|
|
auto& model = gpuModel.data;
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
if (model.vertices.empty() || model.indices.empty()) return;
|
|
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
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)
|
2026-02-21 19:41:21 -08:00
|
|
|
auto vb = uploadBuffer(*vkCtx_,
|
2026-02-23 01:40:23 -08:00
|
|
|
gpuVerts.data(),
|
|
|
|
|
gpuVerts.size() * sizeof(CharVertexGPU),
|
2026-02-21 19:41:21 -08:00
|
|
|
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT);
|
|
|
|
|
gpuModel.vertexBuffer = vb.buffer;
|
|
|
|
|
gpuModel.vertexAlloc = vb.allocation;
|
2026-02-23 01:40:23 -08:00
|
|
|
gpuModel.vertexCount = static_cast<uint32_t>(vertCount);
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// Upload index buffer
|
|
|
|
|
auto ib = uploadBuffer(*vkCtx_,
|
|
|
|
|
model.indices.data(),
|
2026-02-23 01:40:23 -08:00
|
|
|
idxCount * sizeof(uint16_t),
|
2026-02-21 19:41:21 -08:00
|
|
|
VK_BUFFER_USAGE_INDEX_BUFFER_BIT);
|
|
|
|
|
gpuModel.indexBuffer = ib.buffer;
|
|
|
|
|
gpuModel.indexAlloc = ib.allocation;
|
2026-02-23 01:40:23 -08:00
|
|
|
gpuModel.indexCount = static_cast<uint32_t>(idxCount);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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& model = models[modelId].data;
|
|
|
|
|
instance.boneMatrices.resize(std::max(static_cast<size_t>(1), model.bones.size()), glm::mat4(1.0f));
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
uint32_t id = instance.id;
|
|
|
|
|
instances[id] = std::move(instance);
|
|
|
|
|
return id;
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-02-06 16:47:07 -08:00
|
|
|
// Track death state for preventing movement while dead
|
|
|
|
|
if (animationId == 1) {
|
|
|
|
|
instance.isDead = true;
|
|
|
|
|
} else if (instance.isDead && animationId == 0) {
|
|
|
|
|
instance.isDead = false; // Respawned
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
|
|
|
|
|
// Only log missing animation once per model (reduce spam)
|
|
|
|
|
static std::unordered_map<uint32_t, std::unordered_set<uint32_t>> loggedMissingAnims;
|
2026-02-21 19:41:21 -08:00
|
|
|
uint32_t mId = instance.modelId; // Use modelId as identifier
|
|
|
|
|
if (loggedMissingAnims[mId].insert(animationId).second) {
|
2026-02-10 19:30:45 -08:00
|
|
|
// First time seeing this missing animation for this model
|
2026-02-21 19:41:21 -08:00
|
|
|
LOG_WARNING("Animation ", animationId, " not found in model ", mId, ", using default");
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
|
|
|
|
|
// Distance culling for animation updates (150 unit radius)
|
|
|
|
|
const float animUpdateRadiusSq = 150.0f * 150.0f;
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
// Update fade-in opacity
|
|
|
|
|
for (auto& [id, inst] : instances) {
|
|
|
|
|
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
|
|
|
|
|
for (auto& [id, inst] : instances) {
|
|
|
|
|
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
|
2026-02-18 03:53:53 -08:00
|
|
|
if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) {
|
2026-02-06 13:47:03 -08:00
|
|
|
playAnimation(id, 0, true);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
inst.position = glm::mix(inst.moveStart, inst.moveEnd, t);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
// Only update animations for nearby characters (performance optimization)
|
|
|
|
|
// Collect instances that need updates
|
|
|
|
|
std::vector<std::reference_wrapper<CharacterInstance>> toUpdate;
|
|
|
|
|
toUpdate.reserve(instances.size());
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
for (auto& pair : instances) {
|
2026-02-10 19:30:45 -08:00
|
|
|
float distSq = glm::distance2(pair.second.position, cameraPos);
|
|
|
|
|
if (distSq < animUpdateRadiusSq) {
|
|
|
|
|
toUpdate.push_back(std::ref(pair.second));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 06:32:49 -08:00
|
|
|
const size_t updatedCount = toUpdate.size();
|
2026-02-10 19:30:45 -08:00
|
|
|
|
2026-02-22 06:32:49 -08:00
|
|
|
// Thread animation updates in chunks to avoid spawning one task per instance.
|
|
|
|
|
if (updatedCount >= 8 && numAnimThreads_ > 1) {
|
2026-02-22 08:12:08 -08:00
|
|
|
static const size_t minAnimWorkPerThread = std::max<size_t>(
|
|
|
|
|
16, envSizeOrDefault("WOWEE_CHAR_ANIM_WORK_PER_THREAD", 64));
|
|
|
|
|
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) {
|
|
|
|
|
updateAnimation(instRef.get(), deltaTime);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const size_t chunkSize = updatedCount / numThreads;
|
|
|
|
|
const size_t remainder = updatedCount % numThreads;
|
2026-02-10 19:30:45 -08:00
|
|
|
|
2026-02-22 08:12:08 -08:00
|
|
|
animFutures_.clear();
|
|
|
|
|
if (animFutures_.capacity() < numThreads) {
|
|
|
|
|
animFutures_.reserve(numThreads);
|
|
|
|
|
}
|
2026-02-22 06:32:49 -08:00
|
|
|
|
2026-02-22 08:12:08 -08:00
|
|
|
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, deltaTime]() {
|
|
|
|
|
for (size_t i = start; i < end; i++) {
|
|
|
|
|
updateAnimation(toUpdate[i].get(), deltaTime);
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
start = end;
|
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
|
2026-02-22 08:12:08 -08:00
|
|
|
for (auto& f : animFutures_) {
|
|
|
|
|
f.get();
|
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Sequential for small counts (avoid thread overhead)
|
|
|
|
|
for (auto& instRef : toUpdate) {
|
|
|
|
|
updateAnimation(instRef.get(), deltaTime);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Update weapon attachment transforms (after all bone matrices are computed)
|
|
|
|
|
for (auto& pair : instances) {
|
|
|
|
|
auto& instance = pair.second;
|
|
|
|
|
if (instance.weaponAttachments.empty()) 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) {
|
2026-02-22 06:32:49 -08:00
|
|
|
auto modelIt = models.find(instance.modelId);
|
|
|
|
|
if (modelIt == models.end()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const auto& model = modelIt->second.data;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
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 for the keyframe bracket
|
|
|
|
|
for (size_t i = 0; i < timestamps.size() - 1; i++) {
|
|
|
|
|
if (time < static_cast<float>(timestamps[i + 1])) {
|
|
|
|
|
return static_cast<int>(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return static_cast<int>(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 len = glm::length(q);
|
|
|
|
|
if (len < 0.001f || std::isnan(len)) 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) {
|
|
|
|
|
auto& model = models[instance.modelId].data;
|
|
|
|
|
|
|
|
|
|
if (model.bones.empty()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
size_t numBones = model.bones.size();
|
|
|
|
|
instance.boneMatrices.resize(numBones);
|
|
|
|
|
|
|
|
|
|
static bool dumpedOnce = false;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Debug: dump first frame bone data
|
|
|
|
|
if (!dumpedOnce && i < 5) {
|
|
|
|
|
glm::vec3 t = interpolateVec3(bone.translation, instance.currentSequenceIndex, instance.animationTime, glm::vec3(0.0f));
|
|
|
|
|
glm::quat r = interpolateQuat(bone.rotation, instance.currentSequenceIndex, instance.animationTime);
|
|
|
|
|
glm::vec3 s = interpolateVec3(bone.scale, instance.currentSequenceIndex, instance.animationTime, glm::vec3(1.0f));
|
|
|
|
|
core::Logger::getInstance().info("Bone ", i, " parent=", bone.parentBone,
|
|
|
|
|
" pivot=(", bone.pivot.x, ",", bone.pivot.y, ",", bone.pivot.z, ")",
|
|
|
|
|
" t=(", t.x, ",", t.y, ",", t.z, ")",
|
|
|
|
|
" r=(", r.w, ",", r.x, ",", r.y, ",", r.z, ")",
|
|
|
|
|
" s=(", s.x, ",", s.y, ",", s.z, ")",
|
|
|
|
|
" seqIdx=", 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!dumpedOnce) {
|
|
|
|
|
dumpedOnce = true;
|
|
|
|
|
// Dump final matrix for bone 0
|
|
|
|
|
auto& m = instance.boneMatrices[0];
|
|
|
|
|
core::Logger::getInstance().info("Bone 0 final matrix row0=(", m[0][0], ",", m[1][0], ",", m[2][0], ",", m[3][0], ")");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 ---
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) {
|
|
|
|
|
if (instances.empty() || !opaquePipeline_) {
|
2026-02-02 12:24:50 -08:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
uint32_t frameIndex = vkCtx_->getCurrentFrame();
|
2026-02-22 06:21:18 -08:00
|
|
|
uint32_t frameSlot = frameIndex % 2u;
|
|
|
|
|
|
|
|
|
|
// Reset transient material allocations once per frame slot.
|
|
|
|
|
// beginFrame() waits on this slot's fence before recording.
|
|
|
|
|
if (lastMaterialPoolResetFrame_ != frameIndex) {
|
|
|
|
|
VmaAllocator alloc = vkCtx_->getAllocator();
|
|
|
|
|
for (const auto& b : transientMaterialUbos_[frameSlot]) {
|
|
|
|
|
if (b.first) {
|
|
|
|
|
vmaDestroyBuffer(alloc, b.first, b.second);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
transientMaterialUbos_[frameSlot].clear();
|
|
|
|
|
if (materialDescPools_[frameSlot]) {
|
|
|
|
|
vkResetDescriptorPool(vkCtx_->getDevice(), materialDescPools_[frameSlot], 0);
|
|
|
|
|
}
|
|
|
|
|
lastMaterialPoolResetFrame_ = frameIndex;
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// 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);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
for (const auto& pair : instances) {
|
|
|
|
|
const auto& instance = pair.second;
|
2026-02-03 14:26:08 -08:00
|
|
|
|
|
|
|
|
// Skip invisible instances (e.g., player in first-person mode)
|
|
|
|
|
if (!instance.visible) continue;
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
auto modelIt = models.find(instance.modelId);
|
|
|
|
|
if (modelIt == models.end()) continue;
|
|
|
|
|
const auto& gpuModel = modelIt->second;
|
|
|
|
|
|
|
|
|
|
// Skip models without GPU buffers
|
|
|
|
|
if (!gpuModel.vertexBuffer) continue;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
// Skip fully transparent instances
|
|
|
|
|
if (instance.opacity <= 0.0f) continue;
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
// Set model matrix (use override for weapon instances)
|
|
|
|
|
glm::mat4 modelMat = instance.hasOverrideModelMatrix
|
|
|
|
|
? instance.overrideModelMatrix
|
|
|
|
|
: getModelMatrix(instance);
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Push model matrix
|
|
|
|
|
vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(glm::mat4), &modelMat);
|
|
|
|
|
|
|
|
|
|
// Upload bone matrices to SSBO
|
2026-02-02 12:24:50 -08:00
|
|
|
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), MAX_BONES);
|
2026-02-02 23:03:45 -08:00
|
|
|
if (numBones > 0) {
|
2026-02-21 19:41:21 -08:00
|
|
|
// 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_;
|
2026-02-22 06:21:18 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
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");
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-15 20:53:01 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasFirst) {
|
|
|
|
|
first = {texPtr, texType};
|
|
|
|
|
hasFirst = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (texPtr == nullptr || texPtr == whiteTexture_.get()) continue;
|
2026-02-12 14:55:27 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// 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
|
2026-02-21 02:52:31 -08:00
|
|
|
for (const auto& batch : gpuModel.data.batches) {
|
2026-02-11 18:25:04 -08:00
|
|
|
if (applyGeosetFilter) {
|
2026-02-06 01:02:35 -08:00
|
|
|
if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) {
|
|
|
|
|
continue;
|
2026-02-05 23:44:45 -08:00
|
|
|
}
|
2026-02-05 23:48:06 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Resolve texture for this batch (prefer hair textures for hair geosets).
|
|
|
|
|
VkTexture* texPtr = resolveBatchTexture(instance, gpuModel, batch);
|
2026-02-20 21:50:32 -08:00
|
|
|
const uint16_t batchGroup = static_cast<uint16_t>(batch.submeshId / 100);
|
|
|
|
|
auto groupTexIt = instance.groupTextureOverrides.find(batchGroup);
|
2026-02-21 19:41:21 -08:00
|
|
|
if (groupTexIt != instance.groupTextureOverrides.end() && groupTexIt->second != nullptr) {
|
|
|
|
|
texPtr = groupTexIt->second;
|
2026-02-20 21:50:32 -08:00
|
|
|
}
|
2026-02-05 23:44:45 -08:00
|
|
|
|
2026-02-19 02:39:33 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-21 02:52:31 -08:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// Select pipeline based on blend mode
|
|
|
|
|
VkPipeline desiredPipeline;
|
2026-02-19 02:39:33 -08:00
|
|
|
switch (blendMode) {
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-19 02:39:33 -08:00
|
|
|
}
|
2026-02-15 20:53:01 -08:00
|
|
|
|
|
|
|
|
// For body/equipment parts with white/fallback texture, use skin (type 1) texture.
|
2026-02-21 19:41:21 -08:00
|
|
|
if (texPtr == whiteTexture_.get()) {
|
2026-02-20 21:50:32 -08:00
|
|
|
uint16_t group = batchGroup;
|
2026-02-15 20:53:01 -08:00
|
|
|
bool isSkinGroup = (group == 0 || group == 3 || group == 4 || group == 5 ||
|
|
|
|
|
group == 8 || group == 9 || group == 13);
|
|
|
|
|
if (isSkinGroup) {
|
2026-02-06 15:18:50 -08:00
|
|
|
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++) {
|
2026-02-21 19:41:21 -08:00
|
|
|
VkTexture* candidate = gpuModel.textureIds[ti];
|
2026-02-13 19:40:54 -08:00
|
|
|
auto itO = instance.textureSlotOverrides.find(static_cast<uint16_t>(ti));
|
2026-02-21 19:41:21 -08:00
|
|
|
if (itO != instance.textureSlotOverrides.end() && itO->second != nullptr) {
|
2026-02-13 19:40:54 -08:00
|
|
|
candidate = itO->second;
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
if (candidate != whiteTexture_.get() && candidate != nullptr) {
|
2026-02-06 15:18:50 -08:00
|
|
|
if (ti < gpuModel.data.textures.size() &&
|
|
|
|
|
(gpuModel.data.textures[ti].type == 1 || gpuModel.data.textures[ti].type == 11)) {
|
2026-02-21 19:41:21 -08:00
|
|
|
texPtr = candidate;
|
2026-02-06 15:18:50 -08:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-05 23:51:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Determine material properties
|
2026-02-19 02:39:33 -08:00
|
|
|
bool alphaCutout = false;
|
|
|
|
|
bool colorKeyBlack = false;
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-19 02:39:33 -08:00
|
|
|
}
|
|
|
|
|
const bool blendNeedsCutout = (blendMode == 1) || (blendMode >= 2 && !alphaCutout);
|
|
|
|
|
const bool unlit = ((materialFlags & 0x01) != 0) || (blendMode >= 3);
|
2026-02-21 19:41:21 -08:00
|
|
|
|
2026-02-19 02:39:33 -08:00
|
|
|
float emissiveBoost = 1.0f;
|
|
|
|
|
glm::vec3 emissiveTint(1.0f, 1.0f, 1.0f);
|
2026-02-19 03:31:49 -08:00
|
|
|
// 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) {
|
2026-02-19 02:39:33 -08:00
|
|
|
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);
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Allocate and fill material descriptor set (set 1)
|
|
|
|
|
VkDescriptorSet materialSet = VK_NULL_HANDLE;
|
|
|
|
|
{
|
|
|
|
|
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
2026-02-22 06:21:18 -08:00
|
|
|
ai.descriptorPool = materialDescPools_[frameSlot];
|
2026-02-21 19:41:21 -08:00
|
|
|
ai.descriptorSetCount = 1;
|
|
|
|
|
ai.pSetLayouts = &materialSetLayout_;
|
|
|
|
|
if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &materialSet) != VK_SUCCESS) {
|
|
|
|
|
continue; // Pool exhausted, skip this batch
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
// 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;
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// 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;
|
2026-02-23 01:40:23 -08:00
|
|
|
matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0;
|
|
|
|
|
matData.enablePOM = pomEnabled_ ? 1 : 0;
|
2026-02-23 01:43:09 -08:00
|
|
|
matData.pomScale = 0.06f;
|
2026-02-23 01:40:23 -08:00
|
|
|
matData.pomMaxSamples = pomSamples;
|
|
|
|
|
matData.heightMapVariance = batchHeightVariance;
|
|
|
|
|
matData.normalMapStrength = normalMapStrength_;
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// Create a small UBO for this batch's material
|
|
|
|
|
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
|
|
|
|
bci.size = sizeof(CharMaterialUBO);
|
|
|
|
|
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{};
|
|
|
|
|
::VkBuffer matUBO = VK_NULL_HANDLE;
|
|
|
|
|
VmaAllocation matUBOAlloc = VK_NULL_HANDLE;
|
|
|
|
|
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &matUBO, &matUBOAlloc, &allocInfo);
|
|
|
|
|
if (allocInfo.pMappedData) {
|
|
|
|
|
memcpy(allocInfo.pMappedData, &matData, sizeof(CharMaterialUBO));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
// Write descriptor set: binding 0 = texture, binding 1 = material UBO, binding 2 = normal/height map
|
2026-02-21 19:41:21 -08:00
|
|
|
VkTexture* bindTex = (texPtr && texPtr->isValid()) ? texPtr : whiteTexture_.get();
|
|
|
|
|
VkDescriptorImageInfo imgInfo = bindTex->descriptorInfo();
|
|
|
|
|
VkDescriptorBufferInfo bufInfo{};
|
|
|
|
|
bufInfo.buffer = matUBO;
|
|
|
|
|
bufInfo.offset = 0;
|
|
|
|
|
bufInfo.range = sizeof(CharMaterialUBO);
|
2026-02-23 01:40:23 -08:00
|
|
|
VkDescriptorImageInfo nhImgInfo = normalMap->descriptorInfo();
|
2026-02-21 19:41:21 -08:00
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
VkWriteDescriptorSet writes[3] = {};
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
|
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
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);
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-02-22 06:21:18 -08:00
|
|
|
transientMaterialUbos_[frameSlot].emplace_back(matUBO, matUBOAlloc);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Draw entire model with first texture
|
2026-02-21 19:41:21 -08:00
|
|
|
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};
|
2026-02-22 06:21:18 -08:00
|
|
|
ai.descriptorPool = materialDescPools_[frameSlot];
|
2026-02-21 19:41:21 -08:00
|
|
|
ai.descriptorSetCount = 1;
|
|
|
|
|
ai.pSetLayouts = &materialSetLayout_;
|
|
|
|
|
if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &materialSet) != VK_SUCCESS) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
// POM quality → sample count
|
|
|
|
|
int pomSamples2 = 32;
|
|
|
|
|
if (pomQuality_ == 0) pomSamples2 = 16;
|
|
|
|
|
else if (pomQuality_ == 2) pomSamples2 = 64;
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-23 01:40:23 -08:00
|
|
|
matData.enableNormalMap = normalMappingEnabled_ ? 1 : 0;
|
|
|
|
|
matData.enablePOM = pomEnabled_ ? 1 : 0;
|
2026-02-23 01:43:09 -08:00
|
|
|
matData.pomScale = 0.06f;
|
2026-02-23 01:40:23 -08:00
|
|
|
matData.pomMaxSamples = pomSamples2;
|
|
|
|
|
matData.heightMapVariance = 0.0f;
|
|
|
|
|
matData.normalMapStrength = normalMapStrength_;
|
2026-02-21 19:41:21 -08:00
|
|
|
|
|
|
|
|
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
|
|
|
|
bci.size = sizeof(CharMaterialUBO);
|
|
|
|
|
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{};
|
|
|
|
|
::VkBuffer matUBO = VK_NULL_HANDLE;
|
|
|
|
|
VmaAllocation matUBOAlloc = VK_NULL_HANDLE;
|
|
|
|
|
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &matUBO, &matUBOAlloc, &allocInfo);
|
|
|
|
|
if (allocInfo.pMappedData) {
|
|
|
|
|
memcpy(allocInfo.pMappedData, &matData, sizeof(CharMaterialUBO));
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
VkDescriptorImageInfo imgInfo = texPtr->descriptorInfo();
|
|
|
|
|
VkDescriptorBufferInfo bufInfo{};
|
|
|
|
|
bufInfo.buffer = matUBO;
|
|
|
|
|
bufInfo.offset = 0;
|
|
|
|
|
bufInfo.range = sizeof(CharMaterialUBO);
|
2026-02-23 01:40:23 -08:00
|
|
|
VkDescriptorImageInfo nhImgInfo2 = flatNormalTexture_->descriptorInfo();
|
2026-02-04 16:36:03 -08:00
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
VkWriteDescriptorSet writes[3] = {};
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-04 16:41:40 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
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;
|
2026-02-04 16:36:03 -08:00
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
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);
|
2026-02-04 16:41:40 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
|
|
|
|
pipelineLayout_, 1, 1, &materialSet, 0, nullptr);
|
2026-02-04 16:36:03 -08:00
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
vkCmdDrawIndexed(cmd, gpuModel.indexCount, 1, 0, 0, 0);
|
2026-02-04 16:36:03 -08:00
|
|
|
|
2026-02-22 06:21:18 -08:00
|
|
|
transientMaterialUbos_[frameSlot].emplace_back(matUBO, matUBOAlloc);
|
2026-02-04 16:36:03 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 19:41:21 -08:00
|
|
|
}
|
2026-02-04 16:36:03 -08:00
|
|
|
|
2026-02-21 19:49:50 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 01:40:23 -08:00
|
|
|
// Character vertex format (CharVertexGPU): stride = 56 bytes
|
2026-02-21 19:49:50 -08:00
|
|
|
// 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;
|
2026-02-23 01:40:23 -08:00
|
|
|
vertBind.stride = static_cast<uint32_t>(sizeof(CharVertexGPU));
|
2026-02-21 19:49:50 -08:00
|
|
|
vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
|
|
|
|
|
std::vector<VkVertexInputAttributeDescription> vertAttrs = {
|
2026-02-23 01:40:23 -08:00
|
|
|
{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))},
|
2026-02-21 19:49:50 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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)
|
2026-02-22 10:23:20 -08:00
|
|
|
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
|
2026-02-21 19:49:50 -08:00
|
|
|
.setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL)
|
2026-02-22 10:23:20 -08:00
|
|
|
.setDepthBias(0.05f, 0.20f)
|
2026-02-21 19:49:50 -08:00
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 04:48:26 -08:00
|
|
|
void CharacterRenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix,
|
|
|
|
|
const glm::vec3& shadowCenter, float shadowRadius) {
|
2026-02-21 19:49:50 -08:00
|
|
|
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; };
|
|
|
|
|
|
2026-02-23 04:48:26 -08:00
|
|
|
const float shadowRadiusSq = shadowRadius * shadowRadius;
|
2026-02-21 19:49:50 -08:00
|
|
|
for (auto& pair : instances) {
|
|
|
|
|
auto& inst = pair.second;
|
|
|
|
|
if (!inst.visible) continue;
|
|
|
|
|
|
2026-02-23 04:48:26 -08:00
|
|
|
// Distance cull against shadow frustum
|
|
|
|
|
glm::vec3 diff = inst.position - shadowCenter;
|
|
|
|
|
if (glm::dot(diff, diff) > shadowRadiusSq) continue;
|
|
|
|
|
|
2026-02-21 19:49:50 -08:00
|
|
|
auto modelIt = models.find(inst.modelId);
|
|
|
|
|
if (modelIt == models.end()) continue;
|
|
|
|
|
const M2ModelGPU& gpuModel = modelIt->second;
|
|
|
|
|
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_;
|
2026-02-22 06:21:18 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-21 19:49:50 -08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-04 16:36:03 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
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);
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
// 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
|
2026-02-02 12:24:50 -08:00
|
|
|
model = glm::rotate(model, instance.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); // Pitch
|
2026-02-06 18:34:45 -08:00
|
|
|
model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Roll
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
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;
|
2026-02-06 16:47:07 -08:00
|
|
|
|
|
|
|
|
// Don't move dead instances (corpses shouldn't slide around)
|
|
|
|
|
if (inst.isDead) return;
|
|
|
|
|
|
2026-02-18 03:56:12 -08:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-18 03:53:53 -08:00
|
|
|
return 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
float planarDist = glm::length(glm::vec2(destination.x - inst.position.x,
|
|
|
|
|
destination.y - inst.position.y));
|
2026-02-18 03:56:12 -08:00
|
|
|
bool synthesizedDuration = false;
|
2026-02-06 13:47:03 -08:00
|
|
|
if (durationSeconds <= 0.0f) {
|
2026-02-18 03:53:53 -08:00
|
|
|
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;
|
2026-02-06 13:47:03 -08:00
|
|
|
}
|
2026-02-18 03:53:53 -08:00
|
|
|
// 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);
|
2026-02-18 03:56:12 -08:00
|
|
|
synthesizedDuration = true;
|
2026-02-06 13:47:03 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 03:53:53 -08:00
|
|
|
// Play movement animation while moving.
|
2026-02-18 03:56:12 -08:00
|
|
|
// 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);
|
2026-02-18 03:53:53 -08:00
|
|
|
if (moveAnim != 0 && inst.currentAnimationId != moveAnim) {
|
|
|
|
|
playAnimation(instanceId, moveAnim, true);
|
2026-02-06 13:47:03 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture) {
|
2026-02-06 01:02:35 -08:00
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
if (it != instances.end()) {
|
2026-02-21 19:41:21 -08:00
|
|
|
it->second.groupTextureOverrides[geosetGroup] = texture;
|
2026-02-06 01:02:35 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
void CharacterRenderer::setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, VkTexture* texture) {
|
2026-02-13 19:40:54 -08:00
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
if (it != instances.end()) {
|
2026-02-21 19:41:21 -08:00
|
|
|
it->second.textureSlotOverrides[textureSlot] = texture;
|
2026-02-13 19:40:54 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot) {
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
it->second.textureSlotOverrides.erase(textureSlot);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) {
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
if (it != instances.end()) {
|
2026-02-22 05:58:45 -08:00
|
|
|
if (it->second.visible != visible) {
|
|
|
|
|
LOG_INFO("CharacterRenderer::setInstanceVisible id=", instanceId, " visible=", visible);
|
|
|
|
|
}
|
2026-02-03 14:26:08 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
void CharacterRenderer::removeInstance(uint32_t instanceId) {
|
2026-02-11 21:14:35 -08:00
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
if (it == instances.end()) return;
|
|
|
|
|
|
2026-02-22 05:58:45 -08:00
|
|
|
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_);
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:41:21 -08:00
|
|
|
// Destroy bone buffers for this instance
|
|
|
|
|
destroyInstanceBones(it->second);
|
|
|
|
|
|
2026-02-11 21:14:35 -08:00
|
|
|
instances.erase(it);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-03 14:55:32 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 19:29:11 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 21:50:32 -08:00
|
|
|
// Fallback to key-bone lookup only for weapon hand attachment IDs.
|
|
|
|
|
if (!found && (attachmentId == 1 || attachmentId == 2)) {
|
2026-02-02 12:24:50 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
// Validate bone index (bad attachment tables should not silently bind to origin)
|
|
|
|
|
if (found && boneIndex >= charModel.bones.size()) {
|
|
|
|
|
found = false;
|
|
|
|
|
}
|
2026-02-20 21:50:32 -08:00
|
|
|
if (!found && (attachmentId == 1 || attachmentId == 2)) {
|
2026-02-13 20:26:55 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
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()) {
|
2026-02-21 19:41:21 -08:00
|
|
|
VkTexture* texPtr = loadTexture(texturePath);
|
|
|
|
|
if (texPtr != whiteTexture_.get()) {
|
|
|
|
|
setModelTexture(weaponModelId, 0, texPtr);
|
2026-02-02 12:24:50 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-02-21 01:26:16 -08:00
|
|
|
core::Logger::getInstance().debug("Attached weapon model ", weaponModelId,
|
2026-02-02 12:24:50 -08:00
|
|
|
" to instance ", charInstanceId, " at attachment ", attachmentId,
|
|
|
|
|
" (bone ", boneIndex, ", offset ", offset.x, ",", offset.y, ",", offset.z, ")");
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 16:02:34 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
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;
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
// Validate bone index; invalid indices bind attachments to origin (looks like weapons at feet).
|
|
|
|
|
if (boneIndex >= model.bones.size()) {
|
2026-02-20 21:50:32 -08:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-02-13 20:26:55 -08:00
|
|
|
}
|
2026-02-20 21:50:32 -08:00
|
|
|
if (!found) return false;
|
|
|
|
|
} else {
|
|
|
|
|
return false;
|
2026-02-13 20:26:55 -08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
// 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 ===");
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 02:59:24 -08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 05:58:45 -08:00
|
|
|
VkRenderPass mainPass = renderPassOverride_ ? renderPassOverride_ : vkCtx_->getImGuiRenderPass();
|
|
|
|
|
VkSampleCountFlagBits samples = renderPassOverride_ ? VK_SAMPLE_COUNT_1_BIT : vkCtx_->getMsaaSamples();
|
2026-02-22 02:59:24 -08:00
|
|
|
|
|
|
|
|
// --- Vertex input ---
|
|
|
|
|
VkVertexInputBindingDescription charBinding{};
|
|
|
|
|
charBinding.binding = 0;
|
2026-02-23 01:40:23 -08:00
|
|
|
charBinding.stride = sizeof(CharVertexGPU);
|
2026-02-22 02:59:24 -08:00
|
|
|
charBinding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
|
|
|
|
|
|
|
|
|
|
std::vector<VkVertexInputAttributeDescription> charAttrs = {
|
2026-02-23 01:40:23 -08:00
|
|
|
{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))},
|
2026-02-22 02:59:24 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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)
|
2026-02-22 05:58:45 -08:00
|
|
|
.setMultisample(samples)
|
2026-02-22 02:59:24 -08:00
|
|
|
.setLayout(pipelineLayout_)
|
|
|
|
|
.setRenderPass(mainPass)
|
|
|
|
|
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
|
|
|
|
|
.build(device);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-22 05:58:45 -08:00
|
|
|
LOG_INFO("CharacterRenderer::recreatePipelines: renderPass=", (void*)mainPass,
|
|
|
|
|
" samples=", static_cast<int>(samples),
|
|
|
|
|
" pipelineLayout=", (void*)pipelineLayout_);
|
|
|
|
|
|
2026-02-22 02:59:24 -08:00
|
|
|
opaquePipeline_ = buildCharPipeline(PipelineBuilder::blendDisabled(), true);
|
|
|
|
|
alphaTestPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), true);
|
|
|
|
|
alphaPipeline_ = buildCharPipeline(PipelineBuilder::blendAlpha(), false);
|
|
|
|
|
additivePipeline_ = buildCharPipeline(PipelineBuilder::blendAdditive(), false);
|
|
|
|
|
|
|
|
|
|
charVert.destroy();
|
|
|
|
|
charFrag.destroy();
|
|
|
|
|
|
2026-02-22 05:58:45 -08:00
|
|
|
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), ")");
|
|
|
|
|
}
|
2026-02-22 02:59:24 -08:00
|
|
|
}
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
} // namespace rendering
|
|
|
|
|
} // namespace wowee
|