diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index 1b559d17..d3949f88 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -196,6 +196,16 @@ struct M2Model { std::vector textureTransforms; std::vector textureTransformLookup; + // Texture weights (per-batch opacity, from M2Track) + // Each entry is the "at-rest" opacity value (0=transparent, 1=opaque). + // batch.transparencyIndex → textureTransformLookup[idx] → textureWeights[trackIdx] + std::vector textureWeights; + + // Color animation alpha values (from M2Color.alpha M2Track) + // One entry per color animation slot; batch.colorIndex indexes directly into this. + // Value 0=transparent, 1=opaque. Independent from textureWeights. + std::vector colorAlphas; + // Attachment points (for weapon/effect anchoring) std::vector attachments; std::vector attachmentLookup; // attachment ID → index diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 064038fe..9368c2dc 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -36,6 +36,9 @@ struct M2ModelGPU { uint16_t blendMode = 0; // 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, etc. uint16_t materialFlags = 0; // M2 material flags (0x01=Unlit, 0x04=TwoSided, 0x10=NoDepthWrite) uint16_t submeshLevel = 0; // LOD level: 0=base, 1=LOD1, 2=LOD2, 3=LOD3 + uint8_t textureUnit = 0; // UV set index (0=texCoords[0], 1=texCoords[1]) + uint8_t texFlags = 0; // M2Texture.flags (bit0=WrapS, bit1=WrapT) + float batchOpacity = 1.0f; // Resolved texture weight opacity (0=transparent, skip batch) glm::vec3 center = glm::vec3(0.0f); // Center of batch geometry (model space) float glowSize = 1.0f; // Approx radius of batch geometry }; @@ -355,7 +358,7 @@ private: uint32_t nextInstanceId = 1; uint32_t lastDrawCallCount = 0; - GLuint loadTexture(const std::string& path); + GLuint loadTexture(const std::string& path, uint32_t texFlags = 0); struct TextureCacheEntry { GLuint id = 0; size_t approxBytes = 0; diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index ced0cdd0..1cb3e081 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -965,6 +965,37 @@ M2Model M2Loader::load(const std::vector& m2Data) { model.textureLookup = readArray(m2Data, header.ofsTexLookup, header.nTexLookup); } + // Parse color animation alpha values (M2Color: vec3 color track + fixed16 alpha track). + // Each M2Color is two M2TrackDisk headers (20+20 = 40 bytes). + // We only need the alpha track (at offset 20) — controls per-batch opacity. + if (header.nColors > 0 && header.ofsColors > 0 && header.nColors < 4096) { + static constexpr uint32_t M2COLOR_SIZE = 40; // 20-byte color track + 20-byte alpha track + model.colorAlphas.reserve(header.nColors); + for (uint32_t ci = 0; ci < header.nColors; ci++) { + uint32_t alphaTrackOfs = header.ofsColors + ci * M2COLOR_SIZE + 20; // skip vec3 track + if (alphaTrackOfs + sizeof(M2TrackDisk) > m2Data.size()) { + model.colorAlphas.push_back(1.0f); + continue; + } + M2TrackDisk td = readValue(m2Data, alphaTrackOfs); + float alpha = 1.0f; + if (td.nKeys > 0 && td.ofsKeys > 0 && td.nKeys < 4096) { + for (uint32_t si = 0; si < td.nKeys; si++) { + uint32_t hdOfs = td.ofsKeys + si * 8; + if (hdOfs + 8 > m2Data.size()) break; + uint32_t count = readValue(m2Data, hdOfs); + uint32_t offset = readValue(m2Data, hdOfs + 4); + if (count == 0 || offset == 0) continue; + if (offset + sizeof(uint16_t) > m2Data.size()) continue; + uint16_t rawVal = readValue(m2Data, offset); + alpha = std::min(1.0f, rawVal / 32767.0f); + break; + } + } + model.colorAlphas.push_back(alpha); + } + } + // Read bone lookup table (vertex bone indices reference this to get actual bone index) if (header.nBoneLookupTable > 0 && header.ofsBoneLookupTable > 0) { model.boneLookupTable = readArray(m2Data, header.ofsBoneLookupTable, header.nBoneLookupTable); @@ -1021,10 +1052,43 @@ M2Model M2Loader::load(const std::vector& m2Data) { } // Read texture transform lookup (nTransLookup) + // Note: ofsTransLookup holds the transparency track lookup table (indexed by batch.transparencyIndex). if (header.nTransLookup > 0 && header.ofsTransLookup > 0) { model.textureTransformLookup = readArray(m2Data, header.ofsTransLookup, header.nTransLookup); } + // Parse transparency tracks (M2Track) — controls per-batch opacity. + // fixed16 = uint16_t / 32767.0f, range 0 (transparent) to 1 (opaque). + // We extract the "at-rest" value from the first available keyframe. + if (header.nTransparency > 0 && header.ofsTransparency > 0 && + header.nTransparency < 4096) { + model.textureWeights.reserve(header.nTransparency); + for (uint32_t ti = 0; ti < header.nTransparency; ti++) { + uint32_t trackOfs = header.ofsTransparency + ti * sizeof(M2TrackDisk); + if (trackOfs + sizeof(M2TrackDisk) > m2Data.size()) { + model.textureWeights.push_back(1.0f); + continue; + } + M2TrackDisk td = readValue(m2Data, trackOfs); + float opacity = 1.0f; + // Scan sub-arrays until we find one with keyframe data + if (td.nKeys > 0 && td.ofsKeys > 0 && td.nKeys < 4096) { + for (uint32_t si = 0; si < td.nKeys; si++) { + uint32_t hdOfs = td.ofsKeys + si * 8; + if (hdOfs + 8 > m2Data.size()) break; + uint32_t count = readValue(m2Data, hdOfs); + uint32_t offset = readValue(m2Data, hdOfs + 4); + if (count == 0 || offset == 0) continue; + if (offset + sizeof(uint16_t) > m2Data.size()) continue; + uint16_t rawVal = readValue(m2Data, offset); + opacity = std::min(1.0f, rawVal / 32767.0f); + break; + } + } + model.textureWeights.push_back(opacity); + } + } + // Read attachment points (vanilla uses 48-byte struct, WotLK uses 40-byte) if (header.nAttachments > 0 && header.ofsAttachments > 0) { model.attachments.reserve(header.nAttachments); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 9be92c8d..d372ea50 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -274,6 +274,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { layout (location = 2) in vec2 aTexCoord; layout (location = 3) in vec4 aBoneWeights; layout (location = 4) in vec4 aBoneIndicesF; + layout (location = 5) in vec2 aTexCoord2; uniform mat4 uModel; uniform mat4 uView; @@ -281,6 +282,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { uniform bool uUseBones; uniform mat4 uBones[128]; uniform vec2 uUVOffset; + uniform int uTexCoordSet; // 0 = UV set 0, 1 = UV set 1 out vec3 FragPos; out vec3 Normal; out vec2 TexCoord; @@ -302,7 +304,7 @@ bool M2Renderer::initialize(pipeline::AssetManager* assets) { vec4 worldPos = uModel * vec4(pos, 1.0); FragPos = worldPos.xyz; Normal = mat3(uModel) * norm; - TexCoord = aTexCoord + uUVOffset; + TexCoord = (uTexCoordSet == 1 ? aTexCoord2 : aTexCoord) + uUVOffset; gl_Position = uProjection * uView * worldPos; } @@ -1017,8 +1019,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } // Create VBO with interleaved vertex data - // Format: position (3), normal (3), texcoord (2), boneWeights (4), boneIndices (4 as float) - const size_t floatsPerVertex = 16; + // Format: position (3), normal (3), texcoord0 (2), texcoord1 (2), boneWeights (4), boneIndices (4 as float) + const size_t floatsPerVertex = 18; std::vector vertexData; vertexData.reserve(model.vertices.size() * floatsPerVertex); @@ -1031,6 +1033,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { vertexData.push_back(v.normal.z); vertexData.push_back(v.texCoords[0].x); vertexData.push_back(v.texCoords[0].y); + vertexData.push_back(v.texCoords[1].x); + vertexData.push_back(v.texCoords[1].y); // Bone weights (normalized 0-1) float w0 = v.boneWeights[0] / 255.0f; float w1 = v.boneWeights[1] / 255.0f; @@ -1069,40 +1073,49 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float))); - // TexCoord + // TexCoord0 glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float))); + // TexCoord1 + glEnableVertexAttribArray(5); + glVertexAttribPointer(5, 2, GL_FLOAT, GL_FALSE, stride, (void*)(8 * sizeof(float))); + // Bone Weights glEnableVertexAttribArray(3); - glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(8 * sizeof(float))); + glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(10 * sizeof(float))); // Bone Indices (as integer attribute) glEnableVertexAttribArray(4); - glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(12 * sizeof(float))); + glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(14 * sizeof(float))); glBindVertexArray(0); - // Load ALL textures from the model into a local vector + // Load ALL textures from the model into a local vector. + // textureLoadFailed[i] is true if texture[i] had a named path that failed to load. + // Such batches are hidden (batchOpacity=0) rather than rendered white. std::vector allTextures; + std::vector textureLoadFailed; if (assetManager) { for (size_t ti = 0; ti < model.textures.size(); ti++) { const auto& tex = model.textures[ti]; if (!tex.filename.empty()) { - GLuint texId = loadTexture(tex.filename); - if (texId == whiteTexture) { + GLuint texId = loadTexture(tex.filename, tex.flags); + bool failed = (texId == whiteTexture); + if (failed) { LOG_WARNING("M2 model ", model.name, " texture[", ti, "] failed to load: ", tex.filename); } if (isInvisibleTrap) { - LOG_INFO(" InvisibleTrap texture[", ti, "]: ", tex.filename, " -> ", (texId == whiteTexture ? "WHITE" : "OK")); + LOG_INFO(" InvisibleTrap texture[", ti, "]: ", tex.filename, " -> ", (failed ? "WHITE" : "OK")); } allTextures.push_back(texId); + textureLoadFailed.push_back(failed); } else { - LOG_WARNING("M2 model ", model.name, " texture[", ti, "] has empty filename (using white fallback)"); if (isInvisibleTrap) { LOG_INFO(" InvisibleTrap texture[", ti, "]: EMPTY (using white fallback)"); } allTextures.push_back(whiteTexture); + textureLoadFailed.push_back(false); // Empty filename = intentional white (type!=0) } } } @@ -1146,16 +1159,38 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // Resolve texture: batch.textureIndex → textureLookup → allTextures GLuint tex = whiteTexture; + bool texFailed = false; if (batch.textureIndex < model.textureLookup.size()) { uint16_t texIdx = model.textureLookup[batch.textureIndex]; if (texIdx < allTextures.size()) { tex = allTextures[texIdx]; + texFailed = (texIdx < textureLoadFailed.size()) && textureLoadFailed[texIdx]; + } + if (texIdx < model.textures.size()) { + bgpu.texFlags = static_cast(model.textures[texIdx].flags & 0x3); } } else if (!allTextures.empty()) { tex = allTextures[0]; + texFailed = !textureLoadFailed.empty() && textureLoadFailed[0]; } bgpu.texture = tex; bgpu.hasAlpha = (tex != 0 && tex != whiteTexture); + bgpu.textureUnit = static_cast(batch.textureUnit & 0x1); + + // Resolve opacity: texture weight track × color animation alpha + // Batches whose texture failed to load are hidden (avoid white shell artifacts) + bgpu.batchOpacity = texFailed ? 0.0f : 1.0f; + // Texture weight track (via transparency lookup) + if (batch.transparencyIndex < model.textureTransformLookup.size()) { + uint16_t trackIdx = model.textureTransformLookup[batch.transparencyIndex]; + if (trackIdx < model.textureWeights.size()) { + bgpu.batchOpacity *= model.textureWeights[trackIdx]; + } + } + // Color animation alpha (M2Color.alpha, indexed directly by colorIndex) + if (batch.colorIndex < model.colorAlphas.size()) { + bgpu.batchOpacity *= model.colorAlphas[batch.colorIndex]; + } // Compute batch center and radius for glow sprite positioning if (bgpu.blendMode >= 3 && batch.indexCount > 0) { @@ -1217,6 +1252,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { LOG_DEBUG("Loaded M2 model: ", model.name, " (", models[modelId].vertexCount, " vertices, ", models[modelId].indexCount / 3, " triangles, ", models[modelId].batches.size(), " batches)"); + return true; } @@ -1807,6 +1843,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: static uint8_t lastBlendMode = 255; // Invalid initial value static bool depthMaskState = true; // Track current depth mask state static glm::vec2 lastUVOffset = glm::vec2(-999.0f); // Track UV offset state + static int lastTexCoordSet = -1; // Track active UV set (0 or 1) // Reset state tracking at start of frame to handle shader rebinds lastBoundTexture = 0; @@ -1818,6 +1855,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: lastBlendMode = 255; depthMaskState = true; lastUVOffset = glm::vec2(-999.0f); + lastTexCoordSet = -1; // Set texture unit once per frame instead of per-batch glActiveTexture(GL_TEXTURE0); @@ -1912,6 +1950,9 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: // Skip batches that don't match target LOD level if (batch.submeshLevel != targetLOD) continue; + // Skip batches with zero opacity from texture weight tracks (should be invisible) + if (batch.batchOpacity < 0.01f) continue; + // Additive/mod batches (glow halos, light effects): collect as glow sprites // instead of rendering the mesh geometry which appears as flat orange disks. if (batch.blendMode >= 3) { @@ -2022,6 +2063,13 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: lastBoundTexture = batch.texture; } + // UV set selector (textureUnit: 0=UV0, 1=UV1) + int texCoordSet = static_cast(batch.textureUnit); + if (texCoordSet != lastTexCoordSet) { + shader->setUniform("uTexCoordSet", texCoordSet); + lastTexCoordSet = texCoordSet; + } + glDrawElements(GL_TRIANGLES, batch.indexCount, GL_UNSIGNED_SHORT, (void*)(batch.indexStart * sizeof(uint16_t))); @@ -2760,7 +2808,7 @@ void M2Renderer::cleanupUnusedModels() { } } -GLuint M2Renderer::loadTexture(const std::string& path) { +GLuint M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), @@ -2804,8 +2852,9 @@ GLuint M2Renderer::loadTexture(const std::string& path) { 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); + // M2Texture flags: bit 0 = WrapS (1=repeat, 0=clamp), bit 1 = WrapT + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, (texFlags & 0x1) ? GL_REPEAT : GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, (texFlags & 0x2) ? GL_REPEAT : GL_CLAMP_TO_EDGE); glGenerateMipmap(GL_TEXTURE_2D); applyAnisotropicFiltering();