Fix M2 white shell artifact from missing textures, add opacity track support

Batches whose named texture fails to load now render invisible instead of
white (the swampreeds01a.blp case causing a white shell around aquatic plants).

Also implements proper M2 opacity plumbing:
- Parse texture weight tracks (M2Track<fixed16>) and color animation alpha
  tracks (M2Color.alpha) to resolve per-batch opacity at load time
- Skip batches with batchOpacity < 0.01 in the render loop
- Apply M2Texture.flags (bit0=WrapS, bit1=WrapT) to GL sampler wrap mode
- Upload both UV sets (texCoords[0] and texCoords[1]) and select via
  textureUnit uniform, so batches referencing UV set 1 render correctly
This commit is contained in:
Kelsi 2026-02-17 23:52:44 -08:00
parent 4ba10e772b
commit 9a950ce09f
4 changed files with 141 additions and 15 deletions

View file

@ -196,6 +196,16 @@ struct M2Model {
std::vector<M2TextureTransform> textureTransforms;
std::vector<uint16_t> textureTransformLookup;
// Texture weights (per-batch opacity, from M2Track<fixed16>)
// Each entry is the "at-rest" opacity value (0=transparent, 1=opaque).
// batch.transparencyIndex → textureTransformLookup[idx] → textureWeights[trackIdx]
std::vector<float> textureWeights;
// Color animation alpha values (from M2Color.alpha M2Track<fixed16>)
// One entry per color animation slot; batch.colorIndex indexes directly into this.
// Value 0=transparent, 1=opaque. Independent from textureWeights.
std::vector<float> colorAlphas;
// Attachment points (for weapon/effect anchoring)
std::vector<M2Attachment> attachments;
std::vector<uint16_t> attachmentLookup; // attachment ID → index

View file

@ -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;

View file

@ -965,6 +965,37 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.textureLookup = readArray<uint16_t>(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<M2TrackDisk>(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<uint32_t>(m2Data, hdOfs);
uint32_t offset = readValue<uint32_t>(m2Data, hdOfs + 4);
if (count == 0 || offset == 0) continue;
if (offset + sizeof(uint16_t) > m2Data.size()) continue;
uint16_t rawVal = readValue<uint16_t>(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<uint16_t>(m2Data, header.ofsBoneLookupTable, header.nBoneLookupTable);
@ -1021,10 +1052,43 @@ M2Model M2Loader::load(const std::vector<uint8_t>& 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<uint16_t>(m2Data, header.ofsTransLookup, header.nTransLookup);
}
// Parse transparency tracks (M2Track<fixed16>) — 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<M2TrackDisk>(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<uint32_t>(m2Data, hdOfs);
uint32_t offset = readValue<uint32_t>(m2Data, hdOfs + 4);
if (count == 0 || offset == 0) continue;
if (offset + sizeof(uint16_t) > m2Data.size()) continue;
uint16_t rawVal = readValue<uint16_t>(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);

View file

@ -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<float> 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<GLuint> allTextures;
std::vector<bool> 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<uint8_t>(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<uint8_t>(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<int>(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();