/** * 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 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; 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); FragColor = vec4(result, texColor.a); } )"; // 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; } // 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(); } 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); textureCache[path] = whiteTexture; 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 = base.data; int width = base.width; int height = base.height; // 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().info("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().info("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; core::Logger::getInstance().info("Created character instance ", instance.id, " from model ", modelId); 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; // 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; } core::Logger::getInstance().warning("Animation ", animationId, " not found, using default"); // Dump available animation IDs for debugging std::string ids; for (size_t i = 0; i < model.sequences.size(); i++) { if (!ids.empty()) ids += ", "; ids += std::to_string(model.sequences[i].id); } core::Logger::getInstance().info("Available animation IDs (", model.sequences.size(), "): ", ids); } } void CharacterRenderer::update(float deltaTime) { for (auto& pair : instances) { updateAnimation(pair.second, deltaTime); } // 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 characterShader->use(); characterShader->setUniform("uView", view); characterShader->setUniform("uProjection", projection); characterShader->setUniform("uLightDir", glm::vec3(0.0f, -1.0f, 0.3f)); characterShader->setUniform("uLightColor", glm::vec3(1.5f, 1.4f, 1.3f)); 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]; // Set model matrix (use override for weapon instances) glm::mat4 modelMat = instance.hasOverrideModelMatrix ? instance.overrideModelMatrix : getModelMatrix(instance); characterShader->setUniform("uModel", modelMat); // 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()) { // One-time debug dump of rendered batches static bool dumpedBatches = false; if (!dumpedBatches) { dumpedBatches = true; int bIdx = 0; int rendered = 0, skipped = 0; for (const auto& b : gpuModel.data.batches) { bool filtered = !instance.activeGeosets.empty() && instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end(); GLuint resolvedTex = whiteTexture; std::string texInfo = "white(fallback)"; if (b.textureIndex != 0xFFFF && b.textureIndex < gpuModel.data.textureLookup.size()) { uint16_t lk = gpuModel.data.textureLookup[b.textureIndex]; if (lk < gpuModel.textureIds.size()) { resolvedTex = gpuModel.textureIds[lk]; texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->tex[" + std::to_string(lk) + "]=GL" + std::to_string(resolvedTex); } else { texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->OOB(" + std::to_string(lk) + ")"; } } else if (b.textureIndex == 0xFFFF) { texInfo = "texIdx=FFFF"; } else { texInfo = "texIdx=" + std::to_string(b.textureIndex) + " OOB(lookupSz=" + std::to_string(gpuModel.data.textureLookup.size()) + ")"; } if (filtered) skipped++; else rendered++; LOG_INFO("Batch ", bIdx, ": submesh=", b.submeshId, " level=", b.submeshLevel, " idxStart=", b.indexStart, " idxCount=", b.indexCount, " tex=", texInfo, filtered ? " [SKIP]" : " [RENDER]"); bIdx++; } LOG_INFO("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++) { LOG_INFO(" Texture[", t, "]: type=", gpuModel.data.textures[t].type, " file=", gpuModel.data.textures[t].filename, " glId=", (t < gpuModel.textureIds.size() ? std::to_string(gpuModel.textureIds[t]) : "N/A")); } } // Draw batches (submeshes) with per-batch textures for (const auto& batch : gpuModel.data.batches) { // Filter by active geosets (if set) if (!instance.activeGeosets.empty() && instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) { continue; } // Resolve texture for this batch 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]; } } 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); glEnable(GL_CULL_FACE); // Restore culling for other renderers } 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) model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Yaw model = glm::rotate(model, instance.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); // Pitch model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.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::setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets) { auto it = instances.find(instanceId); if (it != instances.end()) { it->second.activeGeosets = geosets; } } 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) { instances.erase(instanceId); } 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::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; } 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; } } } } // namespace rendering } // namespace wowee