/** * CharacterRenderer — GPU rendering of M2 character models with skeletal animation * * Handles: * - Uploading M2 vertex/index data to OpenGL VAO/VBO/EBO * - Per-frame bone matrix computation (hierarchical, with keyframe interpolation) * - GPU vertex skinning via a bone-matrix uniform array in the vertex shader * - Per-batch texture binding through the M2 texture-lookup indirection * - Geoset filtering (activeGeosets) to show/hide body part groups * - CPU texture compositing for character skins (base skin + underwear overlays) * * The character texture compositing uses the WoW CharComponentTextureSections * layout, placing region overlays (pelvis, torso, etc.) at their correct pixel * positions on the 512×512 body skin atlas. Region coordinates sourced from * the original WoW Model Viewer (charcontrol.h, REGION_FAC=2). */ #include "rendering/character_renderer.hpp" #include "rendering/shader.hpp" #include "rendering/texture.hpp" #include "rendering/camera.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "core/logger.hpp" #include #include #include #include #include #include #include #include #include #include #include namespace wowee { namespace rendering { CharacterRenderer::CharacterRenderer() { } CharacterRenderer::~CharacterRenderer() { shutdown(); } bool CharacterRenderer::initialize() { core::Logger::getInstance().info("Initializing character renderer..."); // Create character shader with skeletal animation const char* vertexSrc = R"( #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec4 aBoneWeights; layout (location = 2) in ivec4 aBoneIndices; layout (location = 3) in vec3 aNormal; layout (location = 4) in vec2 aTexCoord; uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProjection; uniform mat4 uBones[200]; out vec3 FragPos; out vec3 Normal; out vec2 TexCoord; void main() { // Skinning: blend bone transformations mat4 boneTransform = mat4(0.0); boneTransform += uBones[aBoneIndices.x] * aBoneWeights.x; boneTransform += uBones[aBoneIndices.y] * aBoneWeights.y; boneTransform += uBones[aBoneIndices.z] * aBoneWeights.z; boneTransform += uBones[aBoneIndices.w] * aBoneWeights.w; // Transform position and normal vec4 skinnedPos = boneTransform * vec4(aPos, 1.0); vec4 worldPos = uModel * skinnedPos; FragPos = worldPos.xyz; // Use mat3 directly - avoid expensive inverse() in shader // Works correctly for uniform scaling; normalize in fragment shader handles the rest Normal = mat3(uModel) * mat3(boneTransform) * aNormal; TexCoord = aTexCoord; gl_Position = uProjection * uView * worldPos; } )"; const char* fragmentSrc = R"( #version 330 core in vec3 FragPos; in vec3 Normal; in vec2 TexCoord; uniform sampler2D uTexture0; uniform vec3 uLightDir; uniform vec3 uLightColor; uniform float uSpecularIntensity; uniform vec3 uViewPos; uniform vec3 uFogColor; uniform float uFogStart; uniform float uFogEnd; uniform sampler2DShadow uShadowMap; uniform mat4 uLightSpaceMatrix; uniform int uShadowEnabled; uniform float uShadowStrength; uniform float uOpacity; out vec4 FragColor; void main() { vec3 normal = normalize(Normal); vec3 lightDir = normalize(uLightDir); // Diffuse lighting float diff = max(dot(normal, lightDir), 0.0); // Blinn-Phong specular vec3 viewDir = normalize(uViewPos - FragPos); vec3 halfDir = normalize(lightDir + viewDir); float spec = pow(max(dot(normal, halfDir), 0.0), 32.0); vec3 specular = spec * uLightColor * uSpecularIntensity; // Shadow mapping float shadow = 1.0; if (uShadowEnabled != 0) { vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) { float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); float bias = max(0.005 * (1.0 - abs(dot(normal, lightDir))), 0.001); shadow = 0.0; vec2 texelSize = vec2(1.0 / 2048.0); for (int sx = -1; sx <= 1; sx++) { for (int sy = -1; sy <= 1; sy++) { shadow += texture(uShadowMap, vec3(proj.xy + vec2(sx, sy) * texelSize, proj.z - bias)); } } shadow /= 9.0; shadow = mix(1.0, shadow, coverageFade); } } shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); // Ambient vec3 ambient = vec3(0.3); // Sample texture vec4 texColor = texture(uTexture0, TexCoord); // Combine vec3 result = (ambient + (diff * vec3(1.0) + specular) * shadow) * texColor.rgb; // Fog float fogDist = length(uViewPos - FragPos); float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0); result = mix(uFogColor, result, fogFactor); // Apply opacity (for fade-in effects) FragColor = vec4(result, uOpacity); } )"; // Log GPU uniform limit GLint maxComponents = 0; glGetIntegerv(GL_MAX_VERTEX_UNIFORM_COMPONENTS, &maxComponents); core::Logger::getInstance().info("GPU max vertex uniform components: ", maxComponents, " (supports ~", maxComponents / 16, " mat4)"); characterShader = std::make_unique(); if (!characterShader->loadFromSource(vertexSrc, fragmentSrc)) { core::Logger::getInstance().error("Failed to create character shader"); return false; } const char* shadowVertSrc = R"( #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec4 aBoneWeights; layout (location = 2) in ivec4 aBoneIndices; layout (location = 4) in vec2 aTexCoord; uniform mat4 uLightSpaceMatrix; uniform mat4 uModel; uniform mat4 uBones[200]; out vec2 vTexCoord; void main() { mat4 boneTransform = mat4(0.0); boneTransform += uBones[aBoneIndices.x] * aBoneWeights.x; boneTransform += uBones[aBoneIndices.y] * aBoneWeights.y; boneTransform += uBones[aBoneIndices.z] * aBoneWeights.z; boneTransform += uBones[aBoneIndices.w] * aBoneWeights.w; vec4 skinnedPos = boneTransform * vec4(aPos, 1.0); vTexCoord = aTexCoord; gl_Position = uLightSpaceMatrix * uModel * skinnedPos; } )"; const char* shadowFragSrc = R"( #version 330 core in vec2 vTexCoord; uniform sampler2D uTexture; uniform bool uAlphaTest; void main() { if (uAlphaTest && texture(uTexture, vTexCoord).a < 0.5) discard; } )"; auto compileStage = [](GLenum type, const char* src) -> GLuint { GLuint shader = glCreateShader(type); glShaderSource(shader, 1, &src, nullptr); glCompileShader(shader); GLint ok = 0; glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); if (!ok) { char log[512]; glGetShaderInfoLog(shader, sizeof(log), nullptr, log); LOG_ERROR("Character shadow shader compile error: ", log); glDeleteShader(shader); return 0; } return shader; }; GLuint shVs = compileStage(GL_VERTEX_SHADER, shadowVertSrc); GLuint shFs = compileStage(GL_FRAGMENT_SHADER, shadowFragSrc); if (!shVs || !shFs) { if (shVs) glDeleteShader(shVs); if (shFs) glDeleteShader(shFs); return false; } shadowCasterProgram = glCreateProgram(); glAttachShader(shadowCasterProgram, shVs); glAttachShader(shadowCasterProgram, shFs); glLinkProgram(shadowCasterProgram); GLint linked = 0; glGetProgramiv(shadowCasterProgram, GL_LINK_STATUS, &linked); glDeleteShader(shVs); glDeleteShader(shFs); if (!linked) { char log[512]; glGetProgramInfoLog(shadowCasterProgram, sizeof(log), nullptr, log); LOG_ERROR("Character shadow shader link error: ", log); glDeleteProgram(shadowCasterProgram); shadowCasterProgram = 0; return false; } // Create 1x1 white fallback texture uint8_t white[] = { 255, 255, 255, 255 }; glGenTextures(1, &whiteTexture); glBindTexture(GL_TEXTURE_2D, whiteTexture); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, white); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glBindTexture(GL_TEXTURE_2D, 0); core::Logger::getInstance().info("Character renderer initialized"); return true; } void CharacterRenderer::shutdown() { // Clean up GPU resources for (auto& pair : models) { auto& gpuModel = pair.second; if (gpuModel.vao) { glDeleteVertexArrays(1, &gpuModel.vao); glDeleteBuffers(1, &gpuModel.vbo); glDeleteBuffers(1, &gpuModel.ebo); } for (GLuint texId : gpuModel.textureIds) { if (texId && texId != whiteTexture) { glDeleteTextures(1, &texId); } } } // Clean up texture cache for (auto& pair : textureCache) { if (pair.second && pair.second != whiteTexture) { glDeleteTextures(1, &pair.second); } } textureCache.clear(); if (whiteTexture) { glDeleteTextures(1, &whiteTexture); whiteTexture = 0; } models.clear(); instances.clear(); characterShader.reset(); if (shadowCasterProgram) { glDeleteProgram(shadowCasterProgram); shadowCasterProgram = 0; } } GLuint CharacterRenderer::loadTexture(const std::string& path) { // Skip empty or whitespace-only paths (type-0 textures have no filename) if (path.empty()) return whiteTexture; bool allWhitespace = true; for (char c : path) { if (c != ' ' && c != '\t' && c != '\0' && c != '\n') { allWhitespace = false; break; } } if (allWhitespace) return whiteTexture; // Check cache auto it = textureCache.find(path); if (it != textureCache.end()) return it->second; if (!assetManager || !assetManager->isInitialized()) { return whiteTexture; } auto blpImage = assetManager->loadTexture(path); if (!blpImage.isValid()) { core::Logger::getInstance().warning("Failed to load texture: ", path); // Do not cache failures as white. Some asset reads can fail transiently and // we want later retries (e.g., mount skins loaded shortly after model spawn). return whiteTexture; } GLuint texId; glGenTextures(1, &texId); glBindTexture(GL_TEXTURE_2D, texId); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, blpImage.width, blpImage.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, blpImage.data.data()); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glGenerateMipmap(GL_TEXTURE_2D); applyAnisotropicFiltering(); glBindTexture(GL_TEXTURE_2D, 0); textureCache[path] = texId; core::Logger::getInstance().info("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")"); return texId; } // Alpha-blend overlay onto composite at (dstX, dstY) static void blitOverlay(std::vector& 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(sy) * overlay.width + sx) * 4; size_t dstIdx = (static_cast(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(overlay.data[srcIdx + 0] * alpha + composite[dstIdx + 0] * invAlpha); composite[dstIdx + 1] = static_cast(overlay.data[srcIdx + 1] * alpha + composite[dstIdx + 1] * invAlpha); composite[dstIdx + 2] = static_cast(overlay.data[srcIdx + 2] * alpha + composite[dstIdx + 2] * invAlpha); composite[dstIdx + 3] = std::max(composite[dstIdx + 3], srcA); } } } } // Nearest-neighbor 2x scale blit of overlay onto composite at (dstX, dstY) static void blitOverlayScaled2x(std::vector& composite, int compW, int compH, const pipeline::BLPImage& overlay, int dstX, int dstY) { for (int sy = 0; sy < overlay.height; sy++) { for (int sx = 0; sx < overlay.width; sx++) { size_t srcIdx = (static_cast(sy) * overlay.width + sx) * 4; uint8_t srcA = overlay.data[srcIdx + 3]; if (srcA == 0) continue; // Write to 2x2 block of destination pixels for (int dy2 = 0; dy2 < 2; dy2++) { int dy = dstY + sy * 2 + dy2; if (dy < 0 || dy >= compH) continue; for (int dx2 = 0; dx2 < 2; dx2++) { int dx = dstX + sx * 2 + dx2; if (dx < 0 || dx >= compW) continue; size_t dstIdx = (static_cast(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(overlay.data[srcIdx + 0] * alpha + composite[dstIdx + 0] * invAlpha); composite[dstIdx + 1] = static_cast(overlay.data[srcIdx + 1] * alpha + composite[dstIdx + 1] * invAlpha); composite[dstIdx + 2] = static_cast(overlay.data[srcIdx + 2] * alpha + composite[dstIdx + 2] * invAlpha); composite[dstIdx + 3] = std::max(composite[dstIdx + 3], srcA); } } } } } } GLuint CharacterRenderer::compositeTextures(const std::vector& layerPaths) { if (layerPaths.empty() || !assetManager || !assetManager->isInitialized()) { return whiteTexture; } // Load base layer auto base = assetManager->loadTexture(layerPaths[0]); if (!base.isValid()) { core::Logger::getInstance().warning("Composite: failed to load base layer: ", layerPaths[0]); return whiteTexture; } // Copy base pixel data as our working buffer std::vector composite = base.data; int width = base.width; int height = base.height; core::Logger::getInstance().info("Composite: base layer ", width, "x", height, " from ", layerPaths[0]); // 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()) { core::Logger::getInstance().warning("Composite: failed to load overlay: ", layerPaths[layer]); continue; } core::Logger::getInstance().info("Composite: overlay ", layerPaths[layer], " (", overlay.width, "x", overlay.height, ")"); // Debug: save overlay to disk { std::string fname = (std::filesystem::temp_directory_path() / ("overlay_debug_" + std::to_string(layer) + ".rgba")).string(); FILE* f = fopen(fname.c_str(), "wb"); if (f) { fwrite(&overlay.width, 4, 1, f); fwrite(&overlay.height, 4, 1, f); fwrite(overlay.data.data(), 1, overlay.data.size(), f); fclose(f); } // Check alpha values int opaquePixels = 0, transPixels = 0, semiPixels = 0; size_t pxCount = static_cast(overlay.width) * overlay.height; for (size_t p = 0; p < pxCount; p++) { uint8_t a = overlay.data[p * 4 + 3]; if (a == 255) opaquePixels++; else if (a == 0) transPixels++; else semiPixels++; } core::Logger::getInstance().info(" Overlay alpha stats: opaque=", opaquePixels, " transparent=", transPixels, " semi=", semiPixels); } if (overlay.width == width && overlay.height == height) { // Same size: full alpha-blend blitOverlay(composite, width, height, overlay, 0, 0); } else { // WoW character texture layout (512x512, from CharComponentTextureSections): // Region X Y W H // 0 Base 0 0 512 512 // 1 Arm Upper 0 0 256 128 // 2 Arm Lower 0 128 256 128 // 3 Hand 0 256 256 64 // 4 Face Upper 0 320 256 64 // 5 Face Lower 0 384 256 128 // 6 Torso Upper 256 0 256 128 // 7 Torso Lower 256 128 256 64 // 8 Pelvis Upper 256 192 256 128 // 9 Pelvis Lower 256 320 256 128 // 10 Foot 256 448 256 64 // // Determine region by filename keywords int dstX = 0, dstY = 0; std::string pathLower = layerPaths[layer]; for (auto& c : pathLower) c = std::tolower(c); if (pathLower.find("pelvis") != std::string::npos) { // Pelvis Upper: (256, 192) 256x128 dstX = 256; dstY = 192; core::Logger::getInstance().info("Composite: placing pelvis region at (", dstX, ",", dstY, ")"); } else if (pathLower.find("torso") != std::string::npos) { // Torso Upper: (256, 0) 256x128 dstX = 256; dstY = 0; core::Logger::getInstance().info("Composite: placing torso region at (", dstX, ",", dstY, ")"); } else if (pathLower.find("armupper") != std::string::npos) { dstX = 0; dstY = 0; } else if (pathLower.find("armlower") != std::string::npos) { dstX = 0; dstY = 128; } else if (pathLower.find("hand") != std::string::npos) { dstX = 0; dstY = 256; } else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) { dstX = 256; dstY = 448; } else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) { dstX = 256; dstY = 320; } else { // Unknown — center placement as fallback dstX = (width - overlay.width) / 2; dstY = (height - overlay.height) / 2; core::Logger::getInstance().info("Composite: unknown region '", layerPaths[layer], "', placing at (", dstX, ",", dstY, ")"); } blitOverlay(composite, width, height, overlay, dstX, dstY); } } // Debug: save composite as raw RGBA file { std::string dbgPath = (std::filesystem::temp_directory_path() / "composite_debug.rgba").string(); FILE* f = fopen(dbgPath.c_str(), "wb"); if (f) { // Write width, height as 4 bytes each, then pixel data fwrite(&width, 4, 1, f); fwrite(&height, 4, 1, f); fwrite(composite.data(), 1, composite.size(), f); fclose(f); core::Logger::getInstance().info("DEBUG: saved composite to ", dbgPath); } } // Upload composite to GPU GLuint texId; glGenTextures(1, &texId); glBindTexture(GL_TEXTURE_2D, texId); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, composite.data()); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glGenerateMipmap(GL_TEXTURE_2D); applyAnisotropicFiltering(); glBindTexture(GL_TEXTURE_2D, 0); core::Logger::getInstance().info("Composite texture created: ", width, "x", height, " from ", layerPaths.size(), " layers"); return texId; } GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, const std::vector& baseLayers, const std::vector>& regionLayers) { // Region index → pixel coordinates on the 512x512 atlas static const int regionCoords[][2] = { { 0, 0 }, // 0 = ArmUpper { 0, 128 }, // 1 = ArmLower { 0, 256 }, // 2 = Hand { 256, 0 }, // 3 = TorsoUpper { 256, 128 }, // 4 = TorsoLower { 256, 192 }, // 5 = LegUpper { 256, 320 }, // 6 = LegLower { 256, 448 }, // 7 = Foot }; // First, build base skin + underwear using existing compositeTextures std::vector layers; layers.push_back(basePath); for (const auto& ul : baseLayers) { layers.push_back(ul); } // Load base composite into CPU buffer if (!assetManager || !assetManager->isInitialized()) { return whiteTexture; } auto base = assetManager->loadTexture(basePath); if (!base.isValid()) { return whiteTexture; } std::vector composite; int width = base.width; int height = base.height; // If base texture is 256x256 (e.g., baked NPC texture), upscale to 512x512 // so equipment regions can be composited at correct coordinates if (width == 256 && height == 256 && !regionLayers.empty()) { width = 512; height = 512; composite.resize(width * height * 4); // Simple 2x nearest-neighbor upscale for (int y = 0; y < 512; y++) { for (int x = 0; x < 512; x++) { int srcX = x / 2; int srcY = y / 2; int srcIdx = (srcY * 256 + srcX) * 4; int dstIdx = (y * 512 + x) * 4; composite[dstIdx + 0] = base.data[srcIdx + 0]; composite[dstIdx + 1] = base.data[srcIdx + 1]; composite[dstIdx + 2] = base.data[srcIdx + 2]; composite[dstIdx + 3] = base.data[srcIdx + 3]; } } core::Logger::getInstance().info("compositeWithRegions: upscaled 256x256 to 512x512"); } else { composite = base.data; } // Blend underwear overlays (same logic as compositeTextures) 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 { int dstX = 0, dstY = 0; std::string pathLower = ul; for (auto& c : pathLower) c = std::tolower(c); if (pathLower.find("pelvis") != std::string::npos) { dstX = 256; dstY = 192; } else if (pathLower.find("torso") != std::string::npos) { dstX = 256; dstY = 0; } else if (pathLower.find("armupper") != std::string::npos) { dstX = 0; dstY = 0; } else if (pathLower.find("armlower") != std::string::npos) { dstX = 0; dstY = 128; } else if (pathLower.find("hand") != std::string::npos) { dstX = 0; dstY = 256; } else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) { dstX = 256; dstY = 448; } else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) { dstX = 256; dstY = 320; } else { dstX = (width - overlay.width) / 2; dstY = (height - overlay.height) / 2; } blitOverlay(composite, width, height, overlay, dstX, dstY); } } // Expected region sizes on the 512x512 atlas static const int regionSizes[][2] = { { 256, 128 }, // 0 = ArmUpper { 256, 128 }, // 1 = ArmLower { 256, 64 }, // 2 = Hand { 256, 128 }, // 3 = TorsoUpper { 256, 64 }, // 4 = TorsoLower { 256, 128 }, // 5 = LegUpper { 256, 128 }, // 6 = LegLower { 256, 64 }, // 7 = Foot }; // 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; } int dstX = regionCoords[regionIdx][0]; int dstY = regionCoords[regionIdx][1]; // Component textures are stored at half resolution — scale 2x if needed int expectedW = regionSizes[regionIdx][0]; int expectedH = regionSizes[regionIdx][1]; if (overlay.width * 2 == expectedW && overlay.height * 2 == expectedH) { blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY); } else { blitOverlay(composite, width, height, overlay, dstX, dstY); } core::Logger::getInstance().info("compositeWithRegions: region ", regionIdx, " at (", dstX, ",", dstY, ") ", overlay.width, "x", overlay.height, " from ", rl.second); } // Upload to GPU GLuint texId; glGenTextures(1, &texId); glBindTexture(GL_TEXTURE_2D, texId); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, composite.data()); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glGenerateMipmap(GL_TEXTURE_2D); applyAnisotropicFiltering(); glBindTexture(GL_TEXTURE_2D, 0); core::Logger::getInstance().info("compositeWithRegions: created ", width, "x", height, " texture with ", regionLayers.size(), " equipment regions"); return texId; } void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, GLuint textureId) { 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; } // Delete old texture if it's not shared and not in the texture cache GLuint oldTex = gpuModel.textureIds[textureSlot]; if (oldTex && oldTex != whiteTexture) { bool cached = false; for (const auto& [k, v] : textureCache) { if (v == oldTex) { cached = true; break; } } if (!cached) { glDeleteTextures(1, &oldTex); } } gpuModel.textureIds[textureSlot] = textureId; core::Logger::getInstance().debug("Replaced model ", modelId, " texture slot ", textureSlot, " with composited texture"); } void CharacterRenderer::resetModelTexture(uint32_t modelId, uint32_t textureSlot) { setModelTexture(modelId, textureSlot, whiteTexture); } 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"); auto& old = models[id]; if (old.vao) { glDeleteVertexArrays(1, &old.vao); glDeleteBuffers(1, &old.vbo); glDeleteBuffers(1, &old.ebo); } } 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) { GLuint texId = loadTexture(tex.filename); gpuModel.textureIds.push_back(texId); } models[id] = std::move(gpuModel); core::Logger::getInstance().debug("Loaded M2 model ", id, " (", model.vertices.size(), " verts, ", model.bones.size(), " bones, ", model.sequences.size(), " anims, ", model.textures.size(), " textures)"); return true; } void CharacterRenderer::setupModelBuffers(M2ModelGPU& gpuModel) { auto& model = gpuModel.data; glGenVertexArrays(1, &gpuModel.vao); glGenBuffers(1, &gpuModel.vbo); glGenBuffers(1, &gpuModel.ebo); glBindVertexArray(gpuModel.vao); // Interleaved vertex data glBindBuffer(GL_ARRAY_BUFFER, gpuModel.vbo); glBufferData(GL_ARRAY_BUFFER, model.vertices.size() * sizeof(pipeline::M2Vertex), model.vertices.data(), GL_STATIC_DRAW); // Position glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex), (void*)offsetof(pipeline::M2Vertex, position)); // Bone weights (normalize uint8 to float) glEnableVertexAttribArray(1); glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(pipeline::M2Vertex), (void*)offsetof(pipeline::M2Vertex, boneWeights)); // Bone indices glEnableVertexAttribArray(2); glVertexAttribIPointer(2, 4, GL_UNSIGNED_BYTE, sizeof(pipeline::M2Vertex), (void*)offsetof(pipeline::M2Vertex, boneIndices)); // Normal glEnableVertexAttribArray(3); glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex), (void*)offsetof(pipeline::M2Vertex, normal)); // TexCoord (first UV set) glEnableVertexAttribArray(4); glVertexAttribPointer(4, 2, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex), (void*)offsetof(pipeline::M2Vertex, texCoords)); // Index buffer glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuModel.ebo); glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.indices.size() * sizeof(uint16_t), model.indices.data(), GL_STATIC_DRAW); glBindVertexArray(0); } 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 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(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(1), model.bones.size()), glm::mat4(1.0f)); instances[instance.id] = instance; return instance.id; } void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, bool loop) { auto it = instances.find(instanceId); if (it == instances.end()) { core::Logger::getInstance().warning("Cannot play animation: instance ", instanceId, " not found"); return; } auto& instance = it->second; auto& model = models[instance.modelId].data; // Track death state for preventing movement while dead if (animationId == 1) { instance.isDead = true; } else if (instance.isDead && animationId == 0) { instance.isDead = false; // Respawned } // Find animation sequence index by ID instance.currentAnimationId = animationId; instance.currentSequenceIndex = -1; instance.animationTime = 0.0f; instance.animationLoop = loop; for (size_t i = 0; i < model.sequences.size(); i++) { if (model.sequences[i].id == animationId) { instance.currentSequenceIndex = static_cast(i); break; } } if (instance.currentSequenceIndex < 0) { // Fall back to first sequence if (!model.sequences.empty()) { instance.currentSequenceIndex = 0; instance.currentAnimationId = model.sequences[0].id; } // Only log missing animation once per model (reduce spam) static std::unordered_map> loggedMissingAnims; uint32_t modelId = instance.modelId; // Use modelId as identifier if (loggedMissingAnims[modelId].insert(animationId).second) { // First time seeing this missing animation for this model LOG_WARNING("Animation ", animationId, " not found in model ", modelId, ", using default"); } } } void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { // Distance culling for animation updates (150 unit radius) const float animUpdateRadiusSq = 150.0f * 150.0f; // 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 if (inst.currentAnimationId == 4) { playAnimation(id, 0, true); } } else { inst.position = glm::mix(inst.moveStart, inst.moveEnd, t); } } } // Only update animations for nearby characters (performance optimization) // Collect instances that need updates std::vector> toUpdate; toUpdate.reserve(instances.size()); for (auto& pair : instances) { float distSq = glm::distance2(pair.second.position, cameraPos); if (distSq < animUpdateRadiusSq) { toUpdate.push_back(std::ref(pair.second)); } } int updatedCount = toUpdate.size(); // Thread bone calculations if we have many characters (4+) if (updatedCount >= 4) { std::vector> futures; futures.reserve(updatedCount); for (auto& instRef : toUpdate) { futures.push_back(std::async(std::launch::async, [this, &instRef, deltaTime]() { updateAnimation(instRef.get(), deltaTime); })); } // Wait for all to complete for (auto& f : futures) { f.get(); } } else { // Sequential for small counts (avoid thread overhead) for (auto& instRef : toUpdate) { updateAnimation(instRef.get(), deltaTime); } } static int logCounter = 0; if (++logCounter >= 300) { // Log every 10 seconds at 30fps LOG_DEBUG("CharacterRenderer: ", updatedCount, "/", instances.size(), " instances updated (", instances.size() - updatedCount, " culled)"); logCounter = 0; } // 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) { auto& model = models[instance.modelId].data; if (model.sequences.empty()) { return; } // Resolve sequence index if not set if (instance.currentSequenceIndex < 0) { instance.currentSequenceIndex = 0; instance.currentAnimationId = model.sequences[0].id; } const auto& sequence = model.sequences[instance.currentSequenceIndex]; // Update animation time (convert to milliseconds) instance.animationTime += deltaTime * 1000.0f; if (sequence.duration > 0 && instance.animationTime >= static_cast(sequence.duration)) { if (instance.animationLoop) { instance.animationTime = std::fmod(instance.animationTime, static_cast(sequence.duration)); } else { instance.animationTime = static_cast(sequence.duration); } } // Update bone matrices calculateBoneMatrices(instance); } // --- Keyframe interpolation helpers --- int CharacterRenderer::findKeyframeIndex(const std::vector& 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(timestamps[i + 1])) { return static_cast(i); } } return static_cast(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(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(idx); size_t i1 = std::min(i0 + 1, keys.vec3Values.size() - 1); if (i0 == i1) return safeVec3(keys.vec3Values[i0]); float t0 = static_cast(keys.timestamps[i0]); float t1 = static_cast(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(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(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(keys.timestamps[i0]); float t1 = static_cast(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(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 --- void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection) { if (instances.empty()) { return; } glEnable(GL_DEPTH_TEST); glDisable(GL_CULL_FACE); // M2 models have mixed winding; render both sides glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); characterShader->use(); characterShader->setUniform("uView", view); characterShader->setUniform("uProjection", projection); characterShader->setUniform("uLightDir", lightDir); characterShader->setUniform("uLightColor", lightColor); characterShader->setUniform("uSpecularIntensity", 0.5f); characterShader->setUniform("uViewPos", camera.getPosition()); // Fog characterShader->setUniform("uFogColor", fogColor); characterShader->setUniform("uFogStart", fogStart); characterShader->setUniform("uFogEnd", fogEnd); // Shadows characterShader->setUniform("uShadowEnabled", shadowEnabled ? 1 : 0); characterShader->setUniform("uShadowStrength", 0.65f); if (shadowEnabled) { characterShader->setUniform("uLightSpaceMatrix", lightSpaceMatrix); glActiveTexture(GL_TEXTURE7); glBindTexture(GL_TEXTURE_2D, shadowDepthTex); characterShader->setUniform("uShadowMap", 7); } for (const auto& pair : instances) { const auto& instance = pair.second; // Skip invisible instances (e.g., player in first-person mode) if (!instance.visible) continue; const auto& gpuModel = models[instance.modelId]; // Skip fully transparent instances if (instance.opacity <= 0.0f) continue; // Set model matrix (use override for weapon instances) glm::mat4 modelMat = instance.hasOverrideModelMatrix ? instance.overrideModelMatrix : getModelMatrix(instance); characterShader->setUniform("uModel", modelMat); characterShader->setUniform("uOpacity", instance.opacity); // Set bone matrices (upload all at once for performance) int numBones = std::min(static_cast(instance.boneMatrices.size()), MAX_BONES); if (numBones > 0) { characterShader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones); } // Bind VAO and draw glBindVertexArray(gpuModel.vao); if (!gpuModel.data.batches.empty()) { bool applyGeosetFilter = !instance.activeGeosets.empty(); if (applyGeosetFilter) { bool hasRenderableGeoset = false; for (const auto& batch : gpuModel.data.batches) { if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) { hasRenderableGeoset = true; break; } } if (!hasRenderableGeoset) { static std::unordered_set loggedGeosetFallback; if (loggedGeosetFallback.insert(instance.id).second) { LOG_WARNING("Geoset filter matched no batches for instance ", instance.id, " (model ", instance.modelId, "); rendering all batches as fallback"); } applyGeosetFilter = false; } } auto resolveBatchTexture = [&](const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint { // 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. // // This matters for hair: the first texture in the combo can be a mask/empty slot, // causing the hair to render as solid white. if (b.textureIndex == 0xFFFF) return whiteTexture; if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture; uint32_t comboCount = b.textureCount ? static_cast(b.textureCount) : 1u; comboCount = std::min(comboCount, 8u); struct Candidate { GLuint id; uint32_t type; }; Candidate first{whiteTexture, 0}; bool hasFirst = false; Candidate firstNonWhite{whiteTexture, 0}; bool hasFirstNonWhite = false; for (uint32_t i = 0; i < comboCount; i++) { uint32_t lookupPos = static_cast(b.textureIndex) + i; if (lookupPos >= gm.data.textureLookup.size()) break; uint16_t texSlot = gm.data.textureLookup[lookupPos]; if (texSlot >= gm.textureIds.size()) continue; GLuint texId = gm.textureIds[texSlot]; uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0; if (!hasFirst) { first = {texId, texType}; hasFirst = true; } if (texId == 0 || texId == whiteTexture) continue; // Prefer the hair texture slot (type 6) whenever present in the combo. // Humanoid scalp meshes can live in group 0, so group-based checks are insufficient. if (texType == 6) { return texId; } if (!hasFirstNonWhite) { firstNonWhite = {texId, texType}; hasFirstNonWhite = true; } } if (hasFirstNonWhite) return firstNonWhite.id; if (hasFirst && first.id != 0) return first.id; return whiteTexture; }; // One-time debug dump of rendered batches per model static std::unordered_set dumpedModels; if (dumpedModels.find(instance.modelId) == dumpedModels.end()) { dumpedModels.insert(instance.modelId); int bIdx = 0; int rendered = 0, skipped = 0; for (const auto& b : gpuModel.data.batches) { bool filtered = applyGeosetFilter && (b.submeshId / 100 != 0) && instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end(); GLuint resolvedTex = resolveBatchTexture(gpuModel, b); std::string texInfo = "GL" + std::to_string(resolvedTex); if (filtered) skipped++; else rendered++; LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId, " level=", b.submeshLevel, " idxStart=", b.indexStart, " idxCount=", b.indexCount, " tex=", texInfo, filtered ? " [SKIP]" : " [RENDER]"); bIdx++; } LOG_DEBUG("Batch summary: ", rendered, " rendered, ", skipped, " skipped, ", gpuModel.textureIds.size(), " textures loaded, ", gpuModel.data.textureLookup.size(), " in lookup table"); for (size_t t = 0; t < gpuModel.data.textures.size(); t++) { } } // Draw batches (submeshes) with per-batch textures // Geoset filtering: skip batches whose submeshId is not in activeGeosets. // For character models, group 0 (body/scalp) is also filtered so that only // the correct scalp mesh renders (not all overlapping variants). for (const auto& batch : gpuModel.data.batches) { if (applyGeosetFilter) { if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) { continue; } } // Resolve texture for this batch (prefer hair textures for hair geosets). GLuint texId = resolveBatchTexture(gpuModel, batch); // For body parts with white/fallback texture, use skin (type 1) texture // This handles humanoid models where some body parts use different texture slots // that may not be set (e.g., baked NPC textures only set slot 0) // Only apply to body skin slots (type 1), NOT hair (type 6) or other types if (texId == whiteTexture) { uint16_t group = batch.submeshId / 100; if (group == 0) { // Check if this batch's texture slot is a body skin type 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; } } // Only fall back for body skin (type 1), underwear (type 8), or cloak (type 2) // Do NOT apply skin composite to hair (type 6) batches if (texType != 6) { for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) { if (gpuModel.textureIds[ti] != whiteTexture && gpuModel.textureIds[ti] != 0) { // Only use type 1 (skin) textures as fallback if (ti < gpuModel.data.textures.size() && (gpuModel.data.textures[ti].type == 1 || gpuModel.data.textures[ti].type == 11)) { texId = gpuModel.textureIds[ti]; break; } } } } } } glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texId); glDrawElements(GL_TRIANGLES, batch.indexCount, GL_UNSIGNED_SHORT, (void*)(batch.indexStart * sizeof(uint16_t))); } } else { // Draw entire model with first texture glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, !gpuModel.textureIds.empty() ? gpuModel.textureIds[0] : whiteTexture); glDrawElements(GL_TRIANGLES, static_cast(gpuModel.data.indices.size()), GL_UNSIGNED_SHORT, 0); } } glBindVertexArray(0); glDisable(GL_BLEND); glEnable(GL_CULL_FACE); // Restore culling for other renderers } void CharacterRenderer::renderShadow(const glm::mat4& lightSpaceMatrix) { if (instances.empty() || shadowCasterProgram == 0) { return; } glUseProgram(shadowCasterProgram); GLint lightSpaceLoc = glGetUniformLocation(shadowCasterProgram, "uLightSpaceMatrix"); GLint modelLoc = glGetUniformLocation(shadowCasterProgram, "uModel"); GLint texLoc = glGetUniformLocation(shadowCasterProgram, "uTexture"); GLint alphaTestLoc = glGetUniformLocation(shadowCasterProgram, "uAlphaTest"); GLint bonesLoc = glGetUniformLocation(shadowCasterProgram, "uBones[0]"); if (lightSpaceLoc < 0 || modelLoc < 0) { return; } glUniformMatrix4fv(lightSpaceLoc, 1, GL_FALSE, &lightSpaceMatrix[0][0]); glEnable(GL_CULL_FACE); glCullFace(GL_FRONT); if (texLoc >= 0) glUniform1i(texLoc, 0); glActiveTexture(GL_TEXTURE0); for (const auto& [_, instance] : instances) { auto modelIt = models.find(instance.modelId); if (modelIt == models.end()) continue; const auto& gpuModel = modelIt->second; glm::mat4 modelMat = instance.hasOverrideModelMatrix ? instance.overrideModelMatrix : getModelMatrix(instance); glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &modelMat[0][0]); if (!instance.boneMatrices.empty() && bonesLoc >= 0) { int numBones = std::min(static_cast(instance.boneMatrices.size()), MAX_BONES); glUniformMatrix4fv(bonesLoc, numBones, GL_FALSE, &instance.boneMatrices[0][0][0]); } glBindVertexArray(gpuModel.vao); if (!gpuModel.data.batches.empty()) { for (const auto& batch : gpuModel.data.batches) { GLuint texId = whiteTexture; if (batch.textureIndex < gpuModel.data.textureLookup.size()) { uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex]; if (lookupIdx < gpuModel.textureIds.size()) { texId = gpuModel.textureIds[lookupIdx]; } } bool alphaCutout = (texId != 0 && texId != whiteTexture); if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, alphaCutout ? 1 : 0); glBindTexture(GL_TEXTURE_2D, texId ? texId : whiteTexture); glDrawElements(GL_TRIANGLES, batch.indexCount, GL_UNSIGNED_SHORT, (void*)(batch.indexStart * sizeof(uint16_t))); } } else { if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, 0); glBindTexture(GL_TEXTURE_2D, whiteTexture); glDrawElements(GL_TRIANGLES, static_cast(gpuModel.data.indices.size()), GL_UNSIGNED_SHORT, 0); } } glBindVertexArray(0); glCullFace(GL_BACK); } glm::mat4 CharacterRenderer::getModelMatrix(const CharacterInstance& instance) const { glm::mat4 model = glm::mat4(1.0f); // Apply transformations: T * R * S model = glm::translate(model, instance.position); // Apply rotation (euler angles, Z-up) // Convention: yaw around Z, pitch around X, roll around Y. model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); // Yaw model = glm::rotate(model, instance.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); // Pitch model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Roll model = glm::scale(model, glm::vec3(instance.scale)); return model; } void CharacterRenderer::setInstancePosition(uint32_t instanceId, const glm::vec3& position) { auto it = instances.find(instanceId); if (it != instances.end()) { it->second.position = position; } } void CharacterRenderer::setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation) { auto it = instances.find(instanceId); if (it != instances.end()) { it->second.rotation = rotation; } } void CharacterRenderer::moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds) { auto it = instances.find(instanceId); if (it == instances.end()) return; auto& inst = it->second; // Don't move dead instances (corpses shouldn't slide around) if (inst.isDead) return; if (durationSeconds <= 0.0f) { // Instant move (stop) inst.position = destination; inst.isMoving = false; // Return to idle animation if currently walking if (inst.currentAnimationId == 4) { playAnimation(instanceId, 0, true); } return; } inst.moveStart = inst.position; inst.moveEnd = destination; inst.moveDuration = durationSeconds; inst.moveElapsed = 0.0f; inst.isMoving = true; // Face toward destination (yaw around Z axis since Z is up) glm::vec3 dir = destination - inst.position; if (glm::length(glm::vec2(dir.x, dir.y)) > 0.001f) { float angle = std::atan2(dir.y, dir.x); inst.rotation.z = angle; } // Play walk animation (ID 4) while moving if (inst.currentAnimationId == 0) { playAnimation(instanceId, 4, true); } } const pipeline::M2Model* CharacterRenderer::getModelData(uint32_t modelId) const { auto it = models.find(modelId); if (it == models.end()) return nullptr; return &it->second.data; } void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) { auto it = instances.find(instanceId); if (it == instances.end()) return; it->second.opacity = 0.0f; it->second.fadeInTime = 0.0f; it->second.fadeInDuration = durationSeconds; } void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets) { auto it = instances.find(instanceId); if (it != instances.end()) { it->second.activeGeosets = geosets; } } void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId) { auto it = instances.find(instanceId); if (it != instances.end()) { it->second.groupTextureOverrides[geosetGroup] = textureId; } } void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) { auto it = instances.find(instanceId); if (it != instances.end()) { it->second.visible = visible; // Also hide/show attached weapons (for first-person mode) for (const auto& wa : it->second.weaponAttachments) { auto weapIt = instances.find(wa.weaponInstanceId); if (weapIt != instances.end()) { weapIt->second.visible = visible; } } } } void CharacterRenderer::removeInstance(uint32_t instanceId) { auto it = instances.find(instanceId); if (it == instances.end()) return; // 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); } instances.erase(it); } bool CharacterRenderer::getAnimationState(uint32_t instanceId, uint32_t& animationId, float& animationTimeMs, float& animationDurationMs) const { auto it = instances.find(instanceId); if (it == instances.end()) { return false; } const CharacterInstance& instance = it->second; auto modelIt = models.find(instance.modelId); if (modelIt == models.end()) { return false; } const auto& sequences = modelIt->second.data.sequences; if (instance.currentSequenceIndex < 0 || instance.currentSequenceIndex >= static_cast(sequences.size())) { return false; } animationId = instance.currentAnimationId; animationTimeMs = instance.animationTime; animationDurationMs = static_cast(sequences[instance.currentSequenceIndex].duration); return true; } bool CharacterRenderer::hasAnimation(uint32_t instanceId, uint32_t animationId) const { auto it = instances.find(instanceId); if (it == instances.end()) { return false; } auto modelIt = models.find(it->second.modelId); if (modelIt == models.end()) { return false; } const auto& sequences = modelIt->second.data.sequences; for (const auto& seq : sequences) { if (seq.id == animationId) { return true; } } return false; } bool CharacterRenderer::getAnimationSequences(uint32_t instanceId, std::vector& out) const { out.clear(); auto it = instances.find(instanceId); if (it == instances.end()) { return false; } auto modelIt = models.find(it->second.modelId); if (modelIt == models.end()) { return false; } out = modelIt->second.data.sequences; return !out.empty(); } bool CharacterRenderer::getInstanceModelName(uint32_t instanceId, std::string& modelName) const { auto it = instances.find(instanceId); if (it == instances.end()) { return false; } auto modelIt = models.find(it->second.modelId); if (modelIt == models.end()) { return false; } modelName = modelIt->second.data.name; return !modelName.empty(); } bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmentId, const pipeline::M2Model& weaponModel, uint32_t weaponModelId, const std::string& texturePath) { auto charIt = instances.find(charInstanceId); if (charIt == instances.end()) { core::Logger::getInstance().warning("attachWeapon: character instance ", charInstanceId, " not found"); return false; } auto& charInstance = charIt->second; auto charModelIt = models.find(charInstance.modelId); if (charModelIt == models.end()) return false; const auto& charModel = charModelIt->second.data; // Find bone index for this attachment point uint16_t boneIndex = 0; glm::vec3 offset(0.0f); bool found = false; // Try attachment lookup first if (attachmentId < charModel.attachmentLookup.size()) { uint16_t attIdx = charModel.attachmentLookup[attachmentId]; if (attIdx < charModel.attachments.size()) { boneIndex = charModel.attachments[attIdx].bone; offset = charModel.attachments[attIdx].position; found = true; } } // Fallback: scan attachments by id if (!found) { for (const auto& att : charModel.attachments) { if (att.id == attachmentId) { boneIndex = att.bone; offset = att.position; found = true; break; } } } // Fallback: scan bones for keyBoneId 26 (right hand) / 27 (left hand) if (!found) { 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(i); found = true; break; } } } if (!found) { core::Logger::getInstance().warning("attachWeapon: no bone found for attachment ", attachmentId); return false; } // Remove existing weapon at this attachment point detachWeapon(charInstanceId, attachmentId); // Load weapon model into renderer if (models.find(weaponModelId) == models.end()) { if (!loadModel(weaponModel, weaponModelId)) { core::Logger::getInstance().warning("attachWeapon: failed to load weapon model ", weaponModelId); return false; } } // Apply weapon texture if provided if (!texturePath.empty()) { GLuint texId = loadTexture(texturePath); if (texId != whiteTexture) { setModelTexture(weaponModelId, 0, texId); } } // Create weapon instance uint32_t weaponInstanceId = createInstance(weaponModelId, glm::vec3(0.0f)); if (weaponInstanceId == 0) return false; // Mark weapon instance as override-positioned auto weapIt = instances.find(weaponInstanceId); if (weapIt != instances.end()) { weapIt->second.hasOverrideModelMatrix = true; } // Store attachment on parent character instance WeaponAttachment wa; wa.weaponModelId = weaponModelId; wa.weaponInstanceId = weaponInstanceId; wa.attachmentId = attachmentId; wa.boneIndex = boneIndex; wa.offset = offset; charInstance.weaponAttachments.push_back(wa); core::Logger::getInstance().info("Attached weapon model ", weaponModelId, " to instance ", charInstanceId, " at attachment ", attachmentId, " (bone ", boneIndex, ", offset ", offset.x, ",", offset.y, ",", offset.z, ")"); return true; } bool CharacterRenderer::getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const { auto it = instances.find(instanceId); if (it == instances.end()) return false; auto mIt = models.find(it->second.modelId); if (mIt == models.end()) return false; const auto& inst = it->second; const auto& model = mIt->second.data; glm::vec3 localCenter = (model.boundMin + model.boundMax) * 0.5f; float radius = model.boundRadius; if (radius <= 0.001f) { radius = glm::length(model.boundMax - model.boundMin) * 0.5f; } float scale = std::max(0.001f, inst.scale); outCenter = inst.position + localCenter * scale; outRadius = std::max(0.5f, radius * scale); return true; } void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) { auto charIt = instances.find(charInstanceId); if (charIt == instances.end()) return; auto& attachments = charIt->second.weaponAttachments; for (auto it = attachments.begin(); it != attachments.end(); ++it) { if (it->attachmentId == attachmentId) { removeInstance(it->weaponInstanceId); attachments.erase(it); core::Logger::getInstance().info("Detached weapon from instance ", charInstanceId, " attachment ", attachmentId); return; } } } bool CharacterRenderer::getAttachmentTransform(uint32_t instanceId, uint32_t attachmentId, glm::mat4& outTransform) { auto instIt = instances.find(instanceId); if (instIt == instances.end()) return false; const auto& instance = instIt->second; auto modelIt = models.find(instance.modelId); if (modelIt == models.end()) return false; const auto& model = modelIt->second.data; // Find attachment point uint16_t boneIndex = 0; glm::vec3 offset(0.0f); bool found = false; // Try attachment lookup first if (attachmentId < model.attachmentLookup.size()) { uint16_t attIdx = model.attachmentLookup[attachmentId]; if (attIdx < model.attachments.size()) { boneIndex = model.attachments[attIdx].bone; offset = model.attachments[attIdx].position; found = true; } } // Fallback: scan attachments by id if (!found) { for (const auto& att : model.attachments) { if (att.id == attachmentId) { boneIndex = att.bone; offset = att.position; found = true; break; } } } if (!found) return false; // 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 ==="); } } // namespace rendering } // namespace wowee