#include "rendering/m2_renderer.hpp" #include "rendering/texture.hpp" #include "rendering/shader.hpp" #include "rendering/camera.hpp" #include "rendering/frustum.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 #include #include namespace wowee { namespace rendering { namespace { bool envFlagEnabled(const char* key, bool defaultValue) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; std::string v(raw); std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return !(v == "0" || v == "false" || v == "off" || v == "no"); } static constexpr uint32_t kParticleFlagRandomized = 0x40; static constexpr uint32_t kParticleFlagTiled = 0x80; float computeGroundDetailDownOffset(const M2ModelGPU& model, float scale) { // Keep a tiny sink to avoid hovering, but cap pivot compensation so details // don't get pushed below the terrain on models with large positive boundMin. const float pivotComp = glm::clamp(std::max(0.0f, model.boundMin.z * scale), 0.0f, 0.10f); const float terrainSink = 0.03f; return pivotComp + terrainSink; } void getTightCollisionBounds(const M2ModelGPU& model, glm::vec3& outMin, glm::vec3& outMax) { glm::vec3 center = (model.boundMin + model.boundMax) * 0.5f; glm::vec3 half = (model.boundMax - model.boundMin) * 0.5f; // Per-shape collision fitting: // - small solid props (boxes/crates/chests): tighter than full mesh, but // larger than default to prevent walk-through on narrow objects // - default: tighter fit (avoid oversized blockers) // - stepped low platforms (tree curbs/planters): wider XY + lower Z if (model.collisionTreeTrunk) { // Tree trunk: proportional cylinder at the base of the tree. float modelHoriz = std::max(model.boundMax.x - model.boundMin.x, model.boundMax.y - model.boundMin.y); float trunkHalf = std::clamp(modelHoriz * 0.05f, 0.5f, 5.0f); half.x = trunkHalf; half.y = trunkHalf; // Height proportional to trunk width, capped at 3.5 units. half.z = std::min(trunkHalf * 2.5f, 3.5f); // Shift center down so collision is at the base (trunk), not mid-canopy. center.z = model.boundMin.z + half.z; } else if (model.collisionNarrowVerticalProp) { // Tall thin props (lamps/posts): keep passable gaps near walls. half.x *= 0.30f; half.y *= 0.30f; half.z *= 0.96f; } else if (model.collisionSmallSolidProp) { // Keep full tight mesh bounds for small solid props to avoid clip-through. half.x *= 1.00f; half.y *= 1.00f; half.z *= 1.00f; } else if (model.collisionSteppedLowPlatform) { half.x *= 0.98f; half.y *= 0.98f; half.z *= 0.52f; } else { half.x *= 0.66f; half.y *= 0.66f; half.z *= 0.76f; } outMin = center - half; outMax = center + half; } float getEffectiveCollisionTopLocal(const M2ModelGPU& model, const glm::vec3& localPos, const glm::vec3& localMin, const glm::vec3& localMax) { if (!model.collisionSteppedFountain && !model.collisionSteppedLowPlatform) { return localMax.z; } glm::vec2 center((localMin.x + localMax.x) * 0.5f, (localMin.y + localMax.y) * 0.5f); glm::vec2 half((localMax.x - localMin.x) * 0.5f, (localMax.y - localMin.y) * 0.5f); if (half.x < 1e-4f || half.y < 1e-4f) { return localMax.z; } float nx = (localPos.x - center.x) / half.x; float ny = (localPos.y - center.y) / half.y; float r = std::sqrt(nx * nx + ny * ny); float h = localMax.z - localMin.z; if (model.collisionSteppedFountain) { if (r > 0.85f) return localMin.z + h * 0.18f; // outer lip if (r > 0.65f) return localMin.z + h * 0.36f; // mid step if (r > 0.45f) return localMin.z + h * 0.54f; // inner step if (r > 0.28f) return localMin.z + h * 0.70f; // center platform / statue base if (r > 0.14f) return localMin.z + h * 0.84f; // statue body / sword return localMin.z + h * 0.96f; // statue head / top } // Low square curb/planter profile: // use edge distance (not radial) so corner blocks don't become too low and // clip-through at diagonals. float edge = std::max(std::abs(nx), std::abs(ny)); if (edge > 0.92f) return localMin.z + h * 0.06f; if (edge > 0.72f) return localMin.z + h * 0.30f; return localMin.z + h * 0.62f; } bool segmentIntersectsAABB(const glm::vec3& from, const glm::vec3& to, const glm::vec3& bmin, const glm::vec3& bmax, float& outEnterT) { glm::vec3 d = to - from; float tEnter = 0.0f; float tExit = 1.0f; for (int axis = 0; axis < 3; axis++) { if (std::abs(d[axis]) < 1e-6f) { if (from[axis] < bmin[axis] || from[axis] > bmax[axis]) { return false; } continue; } float inv = 1.0f / d[axis]; float t0 = (bmin[axis] - from[axis]) * inv; float t1 = (bmax[axis] - from[axis]) * inv; if (t0 > t1) std::swap(t0, t1); tEnter = std::max(tEnter, t0); tExit = std::min(tExit, t1); if (tEnter > tExit) return false; } outEnterT = tEnter; return tExit >= 0.0f && tEnter <= 1.0f; } void transformAABB(const glm::mat4& modelMatrix, const glm::vec3& localMin, const glm::vec3& localMax, glm::vec3& outMin, glm::vec3& outMax) { const glm::vec3 corners[8] = { {localMin.x, localMin.y, localMin.z}, {localMin.x, localMin.y, localMax.z}, {localMin.x, localMax.y, localMin.z}, {localMin.x, localMax.y, localMax.z}, {localMax.x, localMin.y, localMin.z}, {localMax.x, localMin.y, localMax.z}, {localMax.x, localMax.y, localMin.z}, {localMax.x, localMax.y, localMax.z} }; outMin = glm::vec3(std::numeric_limits::max()); outMax = glm::vec3(-std::numeric_limits::max()); for (const auto& c : corners) { glm::vec3 wc = glm::vec3(modelMatrix * glm::vec4(c, 1.0f)); outMin = glm::min(outMin, wc); outMax = glm::max(outMax, wc); } } float pointAABBDistanceSq(const glm::vec3& p, const glm::vec3& bmin, const glm::vec3& bmax) { glm::vec3 q = glm::clamp(p, bmin, bmax); glm::vec3 d = p - q; return glm::dot(d, d); } struct QueryTimer { double* totalMs = nullptr; uint32_t* callCount = nullptr; std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); QueryTimer(double* total, uint32_t* calls) : totalMs(total), callCount(calls) {} ~QueryTimer() { if (callCount) { (*callCount)++; } if (totalMs) { auto end = std::chrono::steady_clock::now(); *totalMs += std::chrono::duration(end - start).count(); } } }; // Möller–Trumbore ray-triangle intersection. // Returns distance along ray if hit, negative if miss. float rayTriangleIntersect(const glm::vec3& origin, const glm::vec3& dir, const glm::vec3& v0, const glm::vec3& v1, const glm::vec3& v2) { constexpr float EPSILON = 1e-6f; glm::vec3 e1 = v1 - v0; glm::vec3 e2 = v2 - v0; glm::vec3 h = glm::cross(dir, e2); float a = glm::dot(e1, h); if (a > -EPSILON && a < EPSILON) return -1.0f; float f = 1.0f / a; glm::vec3 s = origin - v0; float u = f * glm::dot(s, h); if (u < 0.0f || u > 1.0f) return -1.0f; glm::vec3 q = glm::cross(s, e1); float v = f * glm::dot(dir, q); if (v < 0.0f || u + v > 1.0f) return -1.0f; float t = f * glm::dot(e2, q); return t > EPSILON ? t : -1.0f; } // Closest point on triangle to a point (Ericson, Real-Time Collision Detection §5.1.5). glm::vec3 closestPointOnTriangle(const glm::vec3& p, const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { glm::vec3 ab = b - a, ac = c - a, ap = p - a; float d1 = glm::dot(ab, ap), d2 = glm::dot(ac, ap); if (d1 <= 0.0f && d2 <= 0.0f) return a; glm::vec3 bp = p - b; float d3 = glm::dot(ab, bp), d4 = glm::dot(ac, bp); if (d3 >= 0.0f && d4 <= d3) return b; float vc = d1 * d4 - d3 * d2; if (vc <= 0.0f && d1 >= 0.0f && d3 <= 0.0f) { float v = d1 / (d1 - d3); return a + v * ab; } glm::vec3 cp = p - c; float d5 = glm::dot(ab, cp), d6 = glm::dot(ac, cp); if (d6 >= 0.0f && d5 <= d6) return c; float vb = d5 * d2 - d1 * d6; if (vb <= 0.0f && d2 >= 0.0f && d6 <= 0.0f) { float w = d2 / (d2 - d6); return a + w * ac; } float va = d3 * d6 - d5 * d4; if (va <= 0.0f && (d4 - d3) >= 0.0f && (d5 - d6) >= 0.0f) { float w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); return b + w * (c - b); } float denom = 1.0f / (va + vb + vc); float v = vb * denom; float w = vc * denom; return a + ab * v + ac * w; } } // namespace void M2Instance::updateModelMatrix() { modelMatrix = glm::mat4(1.0f); modelMatrix = glm::translate(modelMatrix, position); // Rotation in radians modelMatrix = glm::rotate(modelMatrix, rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); modelMatrix = glm::rotate(modelMatrix, rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); modelMatrix = glm::rotate(modelMatrix, rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); modelMatrix = glm::scale(modelMatrix, glm::vec3(scale)); invModelMatrix = glm::inverse(modelMatrix); } M2Renderer::M2Renderer() { } M2Renderer::~M2Renderer() { shutdown(); } bool M2Renderer::initialize(pipeline::AssetManager* assets) { if (initialized_) { assetManager = assets; return true; } assetManager = assets; numAnimThreads_ = std::min(4u, std::max(1u, std::thread::hardware_concurrency() - 1)); LOG_INFO("Initializing M2 renderer (", numAnimThreads_, " anim threads)..."); // Create M2 shader with skeletal animation support const char* vertexSrc = R"( #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; 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; uniform mat4 uProjection; 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; void main() { vec3 pos = aPos; vec3 norm = aNormal; if (uUseBones) { ivec4 bi = ivec4(aBoneIndicesF); mat4 boneTransform = uBones[bi.x] * aBoneWeights.x + uBones[bi.y] * aBoneWeights.y + uBones[bi.z] * aBoneWeights.z + uBones[bi.w] * aBoneWeights.w; pos = vec3(boneTransform * vec4(aPos, 1.0)); norm = mat3(boneTransform) * aNormal; } vec4 worldPos = uModel * vec4(pos, 1.0); FragPos = worldPos.xyz; Normal = mat3(uModel) * norm; TexCoord = (uTexCoordSet == 1 ? aTexCoord2 : aTexCoord) + uUVOffset; gl_Position = uProjection * uView * worldPos; } )"; const char* fragmentSrc = R"( #version 330 core in vec3 FragPos; in vec3 Normal; in vec2 TexCoord; uniform vec3 uLightDir; uniform vec3 uLightColor; uniform float uSpecularIntensity; uniform vec3 uAmbientColor; uniform vec3 uViewPos; uniform sampler2D uTexture; uniform bool uHasTexture; uniform bool uAlphaTest; uniform bool uColorKeyBlack; uniform float uColorKeyThreshold; uniform bool uUnlit; uniform int uBlendMode; uniform float uFadeAlpha; uniform vec3 uFogColor; uniform float uFogStart; uniform float uFogEnd; uniform sampler2DShadow uShadowMap; uniform mat4 uLightSpaceMatrix; uniform bool uShadowEnabled; uniform float uShadowStrength; uniform bool uInteriorDarken; out vec4 FragColor; void main() { vec4 texColor; if (uHasTexture) { texColor = texture(uTexture, TexCoord); } else { texColor = vec4(0.6, 0.5, 0.4, 1.0); // Fallback brownish } // Alpha test / alpha-key cutout for card textures. if (uAlphaTest && texColor.a < 0.5) { discard; } float maxRgb = max(texColor.r, max(texColor.g, texColor.b)); if (uAlphaTest && maxRgb < 0.06) { discard; } if (uColorKeyBlack && maxRgb < uColorKeyThreshold) { discard; } // Additive blend modes (3=Add, 6=BlendAdd): near-black fragments // contribute nothing visually (add ~0 to framebuffer) but show as // dark rectangles against sky/terrain. Discard them. // Skip Mod(4)/Mod2x(5) since near-black is intentional for those. if ((uBlendMode == 3 || uBlendMode == 6) && maxRgb < 0.1) { discard; } // Unlit non-opaque batches (glow effects, emissive surfaces) with // near-black pixels: these are glow textures where black = transparent. if (uUnlit && uBlendMode >= 1 && maxRgb < 0.1) { discard; } // Distance fade - discard nearly invisible fragments float finalAlpha = texColor.a * uFadeAlpha; if (finalAlpha < 0.02) { discard; } // Unlit path: emit texture color directly (glow effects, emissive surfaces) if (uUnlit) { FragColor = vec4(texColor.rgb, finalAlpha); return; } vec3 normal = normalize(Normal); vec3 lightDir = normalize(uLightDir); vec3 result; if (uInteriorDarken) { // Interior: dim ambient, minimal directional light float diff = max(abs(dot(normal, lightDir)), 0.0) * 0.15; result = texColor.rgb * (0.55 + diff); } else { // Two-sided lighting for foliage float diff = max(abs(dot(normal, lightDir)), 0.3); // 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) { 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); // Single hardware PCF tap — GL_LINEAR + compare mode gives 2×2 bilinear PCF for free shadow = texture(uShadowMap, vec3(proj.xy, proj.z - bias)); shadow = mix(1.0, shadow, coverageFade); } } shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); vec3 ambient = uAmbientColor * texColor.rgb; vec3 diffuse = diff * texColor.rgb; result = ambient + (diffuse + specular) * shadow; } // 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, finalAlpha); } )"; shader = std::make_unique(); if (!shader->loadFromSource(vertexSrc, fragmentSrc)) { LOG_ERROR("Failed to create M2 shader"); return false; } // Create smoke particle shader const char* smokeVertSrc = R"( #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in float aLifeRatio; layout (location = 2) in float aSize; layout (location = 3) in float aIsSpark; uniform mat4 uView; uniform mat4 uProjection; uniform float uScreenHeight; out float vLifeRatio; out float vIsSpark; void main() { vec4 viewPos = uView * vec4(aPos, 1.0); gl_Position = uProjection * viewPos; float dist = -viewPos.z; float scale = (aIsSpark > 0.5) ? 0.12 : 0.3; gl_PointSize = clamp(aSize * (uScreenHeight * scale) / max(dist, 1.0), 2.0, 200.0); vLifeRatio = aLifeRatio; vIsSpark = aIsSpark; } )"; const char* smokeFragSrc = R"( #version 330 core in float vLifeRatio; in float vIsSpark; out vec4 FragColor; void main() { vec2 coord = gl_PointCoord - vec2(0.5); float dist = length(coord) * 2.0; if (vIsSpark > 0.5) { // Ember/spark: bright hot dot, fades quickly float circle = 1.0 - smoothstep(0.3, 0.8, dist); float fade = 1.0 - smoothstep(0.0, 1.0, vLifeRatio); float alpha = circle * fade; vec3 color = mix(vec3(1.0, 0.6, 0.1), vec3(1.0, 0.2, 0.0), vLifeRatio); FragColor = vec4(color, alpha); } else { // Smoke: soft gray circle float circle = 1.0 - smoothstep(0.5, 1.0, dist); float fadeIn = smoothstep(0.0, 0.1, vLifeRatio); float fadeOut = 1.0 - smoothstep(0.4, 1.0, vLifeRatio); float alpha = circle * fadeIn * fadeOut * 0.5; vec3 color = mix(vec3(0.5, 0.5, 0.53), vec3(0.65, 0.65, 0.68), vLifeRatio); FragColor = vec4(color, alpha); } } )"; smokeShader = std::make_unique(); if (!smokeShader->loadFromSource(smokeVertSrc, smokeFragSrc)) { LOG_ERROR("Failed to create smoke particle shader (non-fatal)"); smokeShader.reset(); } // Create smoke particle VAO/VBO (only if shader compiled) if (smokeShader) { glGenVertexArrays(1, &smokeVAO); glGenBuffers(1, &smokeVBO); glBindVertexArray(smokeVAO); glBindBuffer(GL_ARRAY_BUFFER, smokeVBO); // 5 floats per particle: pos(3) + lifeRatio(1) + size(1) // 6 floats per particle: pos(3) + lifeRatio(1) + size(1) + isSpark(1) glBufferData(GL_ARRAY_BUFFER, MAX_SMOKE_PARTICLES * 6 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); // Position glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0); // Life ratio glEnableVertexAttribArray(1); glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); // Size glEnableVertexAttribArray(2); glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(4 * sizeof(float))); // IsSpark glEnableVertexAttribArray(3); glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(5 * sizeof(float))); glBindVertexArray(0); } // Create M2 particle emitter shader { const char* particleVertSrc = R"( #version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec4 aColor; layout (location = 2) in float aSize; layout (location = 3) in float aTile; uniform mat4 uView; uniform mat4 uProjection; out vec4 vColor; out float vTile; void main() { vec4 viewPos = uView * vec4(aPos, 1.0); gl_Position = uProjection * viewPos; float dist = max(-viewPos.z, 1.0); gl_PointSize = clamp(aSize * 400.0 / dist, 1.0, 64.0); vColor = aColor; vTile = aTile; } )"; const char* particleFragSrc = R"( #version 330 core in vec4 vColor; in float vTile; uniform sampler2D uTexture; uniform vec2 uTileCount; uniform bool uAlphaKey; out vec4 FragColor; void main() { // Circular soft-edge falloff (GL_POINTS are square by default) vec2 center = gl_PointCoord - vec2(0.5); float dist = length(center); if (dist > 0.5) discard; float edgeFade = smoothstep(0.5, 0.2, dist); vec2 tileCount = max(uTileCount, vec2(1.0)); float tilesX = tileCount.x; float tilesY = tileCount.y; float tileMax = max(tilesX * tilesY - 1.0, 0.0); float tile = clamp(vTile, 0.0, tileMax); float col = mod(tile, tilesX); float row = floor(tile / tilesX); vec2 tileSize = vec2(1.0 / tilesX, 1.0 / tilesY); vec2 uv = gl_PointCoord * tileSize + vec2(col, row) * tileSize; vec4 texColor = texture(uTexture, uv); // Alpha-key particle textures often encode transparency as near-black // color without meaningful alpha. if (uAlphaKey) { float maxRgb = max(texColor.r, max(texColor.g, texColor.b)); if (maxRgb < 0.06 || texColor.a < 0.5) discard; } FragColor = texColor * vColor; FragColor.a *= edgeFade; if (FragColor.a < 0.01) discard; } )"; GLuint vs = glCreateShader(GL_VERTEX_SHADER); glShaderSource(vs, 1, &particleVertSrc, nullptr); glCompileShader(vs); GLuint fs = glCreateShader(GL_FRAGMENT_SHADER); glShaderSource(fs, 1, &particleFragSrc, nullptr); glCompileShader(fs); m2ParticleShader_ = glCreateProgram(); glAttachShader(m2ParticleShader_, vs); glAttachShader(m2ParticleShader_, fs); glLinkProgram(m2ParticleShader_); glDeleteShader(vs); glDeleteShader(fs); // Create particle VAO/VBO: 9 floats per particle (pos3 + rgba4 + size1 + tile1) glGenVertexArrays(1, &m2ParticleVAO_); glGenBuffers(1, &m2ParticleVBO_); glBindVertexArray(m2ParticleVAO_); glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_); glBufferData(GL_ARRAY_BUFFER, MAX_M2_PARTICLES * 9 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); // Position (3f) glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)0); // Color (4f) glEnableVertexAttribArray(1); glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)(3 * sizeof(float))); // Size (1f) glEnableVertexAttribArray(2); glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)(7 * sizeof(float))); // Tile index (1f) glEnableVertexAttribArray(3); glVertexAttribPointer(3, 1, GL_FLOAT, GL_FALSE, 9 * sizeof(float), (void*)(8 * sizeof(float))); glBindVertexArray(0); } // Create 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_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glBindTexture(GL_TEXTURE_2D, 0); // Generate soft radial gradient glow texture for light sprites { static constexpr int SZ = 64; std::vector px(SZ * SZ * 4); float half = SZ / 2.0f; for (int y = 0; y < SZ; y++) { for (int x = 0; x < SZ; x++) { float dx = (x + 0.5f - half) / half; float dy = (y + 0.5f - half) / half; float r = std::sqrt(dx * dx + dy * dy); float a = std::max(0.0f, 1.0f - r); a = a * a; // Quadratic falloff int idx = (y * SZ + x) * 4; px[idx + 0] = 255; px[idx + 1] = 255; px[idx + 2] = 255; px[idx + 3] = static_cast(a * 255); } } glGenTextures(1, &glowTexture); glBindTexture(GL_TEXTURE_2D, glowTexture); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SZ, SZ, 0, GL_RGBA, GL_UNSIGNED_BYTE, px.data()); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glBindTexture(GL_TEXTURE_2D, 0); } LOG_INFO("M2 renderer initialized"); initialized_ = true; return true; } void M2Renderer::shutdown() { LOG_INFO("Shutting down M2 renderer..."); // Delete GPU resources for (auto& [id, model] : models) { if (model.vao != 0) glDeleteVertexArrays(1, &model.vao); if (model.vbo != 0) glDeleteBuffers(1, &model.vbo); if (model.ebo != 0) glDeleteBuffers(1, &model.ebo); } models.clear(); instances.clear(); spatialGrid.clear(); instanceIndexById.clear(); // Delete cached textures for (auto& [path, entry] : textureCache) { GLuint texId = entry.id; if (texId != 0 && texId != whiteTexture) { glDeleteTextures(1, &texId); } } textureCache.clear(); textureCacheBytes_ = 0; textureCacheCounter_ = 0; textureHasAlphaById_.clear(); textureColorKeyBlackById_.clear(); if (whiteTexture != 0) { glDeleteTextures(1, &whiteTexture); whiteTexture = 0; } if (glowTexture != 0) { glDeleteTextures(1, &glowTexture); glowTexture = 0; } shader.reset(); // Clean up smoke particle resources if (smokeVAO != 0) { glDeleteVertexArrays(1, &smokeVAO); smokeVAO = 0; } if (smokeVBO != 0) { glDeleteBuffers(1, &smokeVBO); smokeVBO = 0; } smokeShader.reset(); smokeParticles.clear(); // Clean up M2 particle resources if (m2ParticleVAO_ != 0) { glDeleteVertexArrays(1, &m2ParticleVAO_); m2ParticleVAO_ = 0; } if (m2ParticleVBO_ != 0) { glDeleteBuffers(1, &m2ParticleVBO_); m2ParticleVBO_ = 0; } if (m2ParticleShader_ != 0) { glDeleteProgram(m2ParticleShader_); m2ParticleShader_ = 0; } } // --------------------------------------------------------------------------- // M2 collision mesh: build spatial grid + classify triangles // --------------------------------------------------------------------------- void M2ModelGPU::CollisionMesh::build() { if (indices.size() < 3 || vertices.empty()) return; triCount = static_cast(indices.size() / 3); // Bounding box for grid glm::vec3 bmin(std::numeric_limits::max()); glm::vec3 bmax(-std::numeric_limits::max()); for (const auto& v : vertices) { bmin = glm::min(bmin, v); bmax = glm::max(bmax, v); } gridOrigin = glm::vec2(bmin.x, bmin.y); gridCellsX = std::max(1, std::min(32, static_cast(std::ceil((bmax.x - bmin.x) / CELL_SIZE)))); gridCellsY = std::max(1, std::min(32, static_cast(std::ceil((bmax.y - bmin.y) / CELL_SIZE)))); cellFloorTris.resize(gridCellsX * gridCellsY); cellWallTris.resize(gridCellsX * gridCellsY); triBounds.resize(triCount); for (uint32_t ti = 0; ti < triCount; ti++) { uint16_t i0 = indices[ti * 3]; uint16_t i1 = indices[ti * 3 + 1]; uint16_t i2 = indices[ti * 3 + 2]; if (i0 >= vertices.size() || i1 >= vertices.size() || i2 >= vertices.size()) continue; const auto& v0 = vertices[i0]; const auto& v1 = vertices[i1]; const auto& v2 = vertices[i2]; triBounds[ti].minZ = std::min({v0.z, v1.z, v2.z}); triBounds[ti].maxZ = std::max({v0.z, v1.z, v2.z}); glm::vec3 normal = glm::cross(v1 - v0, v2 - v0); float normalLen = glm::length(normal); float absNz = (normalLen > 0.001f) ? std::abs(normal.z / normalLen) : 0.0f; bool isFloor = (absNz >= 0.35f); // ~70° max slope (relaxed for steep stairs) bool isWall = (absNz < 0.65f); float triMinX = std::min({v0.x, v1.x, v2.x}); float triMaxX = std::max({v0.x, v1.x, v2.x}); float triMinY = std::min({v0.y, v1.y, v2.y}); float triMaxY = std::max({v0.y, v1.y, v2.y}); int cxMin = std::clamp(static_cast((triMinX - gridOrigin.x) / CELL_SIZE), 0, gridCellsX - 1); int cxMax = std::clamp(static_cast((triMaxX - gridOrigin.x) / CELL_SIZE), 0, gridCellsX - 1); int cyMin = std::clamp(static_cast((triMinY - gridOrigin.y) / CELL_SIZE), 0, gridCellsY - 1); int cyMax = std::clamp(static_cast((triMaxY - gridOrigin.y) / CELL_SIZE), 0, gridCellsY - 1); for (int cy = cyMin; cy <= cyMax; cy++) { for (int cx = cxMin; cx <= cxMax; cx++) { int ci = cy * gridCellsX + cx; if (isFloor) cellFloorTris[ci].push_back(ti); if (isWall) cellWallTris[ci].push_back(ti); } } } } void M2ModelGPU::CollisionMesh::getFloorTrisInRange( float minX, float minY, float maxX, float maxY, std::vector& out) const { out.clear(); if (gridCellsX == 0 || gridCellsY == 0) return; int cxMin = std::clamp(static_cast((minX - gridOrigin.x) / CELL_SIZE), 0, gridCellsX - 1); int cxMax = std::clamp(static_cast((maxX - gridOrigin.x) / CELL_SIZE), 0, gridCellsX - 1); int cyMin = std::clamp(static_cast((minY - gridOrigin.y) / CELL_SIZE), 0, gridCellsY - 1); int cyMax = std::clamp(static_cast((maxY - gridOrigin.y) / CELL_SIZE), 0, gridCellsY - 1); for (int cy = cyMin; cy <= cyMax; cy++) { for (int cx = cxMin; cx <= cxMax; cx++) { const auto& cell = cellFloorTris[cy * gridCellsX + cx]; out.insert(out.end(), cell.begin(), cell.end()); } } std::sort(out.begin(), out.end()); out.erase(std::unique(out.begin(), out.end()), out.end()); } void M2ModelGPU::CollisionMesh::getWallTrisInRange( float minX, float minY, float maxX, float maxY, std::vector& out) const { out.clear(); if (gridCellsX == 0 || gridCellsY == 0) return; int cxMin = std::clamp(static_cast((minX - gridOrigin.x) / CELL_SIZE), 0, gridCellsX - 1); int cxMax = std::clamp(static_cast((maxX - gridOrigin.x) / CELL_SIZE), 0, gridCellsX - 1); int cyMin = std::clamp(static_cast((minY - gridOrigin.y) / CELL_SIZE), 0, gridCellsY - 1); int cyMax = std::clamp(static_cast((maxY - gridOrigin.y) / CELL_SIZE), 0, gridCellsY - 1); for (int cy = cyMin; cy <= cyMax; cy++) { for (int cx = cxMin; cx <= cxMax; cx++) { const auto& cell = cellWallTris[cy * gridCellsX + cx]; out.insert(out.end(), cell.begin(), cell.end()); } } std::sort(out.begin(), out.end()); out.erase(std::unique(out.begin(), out.end()), out.end()); } bool M2Renderer::hasModel(uint32_t modelId) const { return models.find(modelId) != models.end(); } bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { if (models.find(modelId) != models.end()) { // Already loaded return true; } bool hasGeometry = !model.vertices.empty() && !model.indices.empty(); bool hasParticles = !model.particleEmitters.empty(); if (!hasGeometry && !hasParticles) { LOG_WARNING("M2 model has no geometry and no particles: ", model.name); return false; } M2ModelGPU gpuModel; gpuModel.name = model.name; // Detect invisible trap models (event objects that should not render or collide) std::string lowerName = model.name; std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); bool isInvisibleTrap = (lowerName.find("invisibletrap") != std::string::npos); gpuModel.isInvisibleTrap = isInvisibleTrap; if (isInvisibleTrap) { LOG_INFO("Loading InvisibleTrap model: ", model.name, " (will be invisible, no collision)"); } // Use tight bounds from actual vertices for collision/camera occlusion. // Header bounds in some M2s are overly conservative. glm::vec3 tightMin(0.0f); glm::vec3 tightMax(0.0f); if (hasGeometry) { tightMin = glm::vec3(std::numeric_limits::max()); tightMax = glm::vec3(-std::numeric_limits::max()); for (const auto& v : model.vertices) { tightMin = glm::min(tightMin, v.position); tightMax = glm::max(tightMax, v.position); } } bool foliageOrTreeLike = false; bool chestName = false; bool groundDetailModel = false; { std::string lowerName = model.name; std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); gpuModel.collisionSteppedFountain = (lowerName.find("fountain") != std::string::npos); glm::vec3 dims = tightMax - tightMin; float horiz = std::max(dims.x, dims.y); float vert = std::max(0.0f, dims.z); bool lowWideShape = (horiz > 1.4f && vert > 0.2f && vert < horiz * 0.70f); bool likelyCurbName = (lowerName.find("planter") != std::string::npos) || (lowerName.find("curb") != std::string::npos) || (lowerName.find("base") != std::string::npos) || (lowerName.find("ring") != std::string::npos) || (lowerName.find("well") != std::string::npos); bool knownStormwindPlanter = (lowerName.find("stormwindplanter") != std::string::npos) || (lowerName.find("stormwindwindowplanter") != std::string::npos); bool lowPlatformShape = (horiz > 1.8f && vert > 0.2f && vert < 1.8f); bool bridgeName = (lowerName.find("bridge") != std::string::npos) || (lowerName.find("plank") != std::string::npos) || (lowerName.find("walkway") != std::string::npos); gpuModel.collisionSteppedLowPlatform = (!gpuModel.collisionSteppedFountain) && (knownStormwindPlanter || bridgeName || (likelyCurbName && (lowPlatformShape || lowWideShape))); gpuModel.collisionBridge = bridgeName; bool isPlanter = (lowerName.find("planter") != std::string::npos); gpuModel.collisionPlanter = isPlanter; bool statueName = (lowerName.find("statue") != std::string::npos) || (lowerName.find("monument") != std::string::npos) || (lowerName.find("sculpture") != std::string::npos); gpuModel.collisionStatue = statueName; bool smallSolidPropName = statueName || (lowerName.find("crate") != std::string::npos) || (lowerName.find("box") != std::string::npos) || (lowerName.find("chest") != std::string::npos) || (lowerName.find("barrel") != std::string::npos); chestName = (lowerName.find("chest") != std::string::npos); bool foliageName = (lowerName.find("bush") != std::string::npos) || (lowerName.find("grass") != std::string::npos) || (lowerName.find("drygrass") != std::string::npos) || (lowerName.find("dry_grass") != std::string::npos) || (lowerName.find("dry-grass") != std::string::npos) || (lowerName.find("deadgrass") != std::string::npos) || (lowerName.find("dead_grass") != std::string::npos) || (lowerName.find("dead-grass") != std::string::npos) || ((lowerName.find("plant") != std::string::npos) && !isPlanter) || (lowerName.find("flower") != std::string::npos) || (lowerName.find("shrub") != std::string::npos) || (lowerName.find("fern") != std::string::npos) || (lowerName.find("vine") != std::string::npos) || (lowerName.find("lily") != std::string::npos) || (lowerName.find("weed") != std::string::npos) || (lowerName.find("wheat") != std::string::npos) || (lowerName.find("pumpkin") != std::string::npos) || (lowerName.find("firefly") != std::string::npos) || (lowerName.find("fireflies") != std::string::npos) || (lowerName.find("fireflys") != std::string::npos) || (lowerName.find("mushroom") != std::string::npos) || (lowerName.find("fungus") != std::string::npos) || (lowerName.find("toadstool") != std::string::npos) || (lowerName.find("root") != std::string::npos) || (lowerName.find("branch") != std::string::npos) || (lowerName.find("thorn") != std::string::npos) || (lowerName.find("moss") != std::string::npos) || (lowerName.find("ivy") != std::string::npos) || (lowerName.find("seaweed") != std::string::npos) || (lowerName.find("kelp") != std::string::npos) || (lowerName.find("cattail") != std::string::npos) || (lowerName.find("reed") != std::string::npos) || (lowerName.find("palm") != std::string::npos) || (lowerName.find("bamboo") != std::string::npos) || (lowerName.find("banana") != std::string::npos) || (lowerName.find("coconut") != std::string::npos) || (lowerName.find("canopy") != std::string::npos) || (lowerName.find("hedge") != std::string::npos) || (lowerName.find("cactus") != std::string::npos) || (lowerName.find("leaf") != std::string::npos) || (lowerName.find("leaves") != std::string::npos) || (lowerName.find("stalk") != std::string::npos) || (lowerName.find("corn") != std::string::npos) || (lowerName.find("crop") != std::string::npos) || (lowerName.find("hay") != std::string::npos) || (lowerName.find("frond") != std::string::npos) || (lowerName.find("algae") != std::string::npos) || (lowerName.find("coral") != std::string::npos); bool treeLike = (lowerName.find("tree") != std::string::npos); foliageOrTreeLike = (foliageName || treeLike); groundDetailModel = (lowerName.find("\\nodxt\\detail\\") != std::string::npos) || (lowerName.find("\\detail\\") != std::string::npos); bool hardTreePart = (lowerName.find("trunk") != std::string::npos) || (lowerName.find("stump") != std::string::npos) || (lowerName.find("log") != std::string::npos); // Only large trees (canopy > 20 model units wide) get trunk collision. // Small/mid trees are walkthrough to avoid getting stuck between them. // Only large trees get trunk collision; all smaller trees are walkthrough. bool treeWithTrunk = treeLike && !hardTreePart && !foliageName && horiz > 40.0f; bool softTree = treeLike && !hardTreePart && !treeWithTrunk; bool forceSolidCurb = gpuModel.collisionSteppedLowPlatform || knownStormwindPlanter || likelyCurbName || gpuModel.collisionPlanter; bool narrowVerticalName = (lowerName.find("lamp") != std::string::npos) || (lowerName.find("lantern") != std::string::npos) || (lowerName.find("post") != std::string::npos) || (lowerName.find("pole") != std::string::npos); bool narrowVerticalShape = (horiz > 0.12f && horiz < 2.0f && vert > 2.2f && vert > horiz * 1.8f); gpuModel.collisionTreeTrunk = treeWithTrunk; gpuModel.collisionNarrowVerticalProp = !gpuModel.collisionSteppedFountain && !gpuModel.collisionSteppedLowPlatform && (narrowVerticalName || narrowVerticalShape); bool genericSolidPropShape = (horiz > 0.6f && horiz < 6.0f && vert > 0.30f && vert < 4.0f && vert > horiz * 0.16f) || statueName; bool curbLikeName = (lowerName.find("curb") != std::string::npos) || (lowerName.find("planter") != std::string::npos) || (lowerName.find("ring") != std::string::npos) || (lowerName.find("well") != std::string::npos) || (lowerName.find("base") != std::string::npos); bool lowPlatformLikeShape = lowWideShape || lowPlatformShape; bool carpetOrRug = (lowerName.find("carpet") != std::string::npos) || (lowerName.find("rug") != std::string::npos); gpuModel.collisionSmallSolidProp = !gpuModel.collisionSteppedFountain && !gpuModel.collisionSteppedLowPlatform && !gpuModel.collisionNarrowVerticalProp && !gpuModel.collisionTreeTrunk && !curbLikeName && !lowPlatformLikeShape && (smallSolidPropName || (genericSolidPropShape && !foliageName && !softTree)); // Disable collision for foliage, soft trees, and decorative carpets/rugs gpuModel.collisionNoBlock = ((foliageName || softTree || carpetOrRug) && !forceSolidCurb); } gpuModel.boundMin = tightMin; gpuModel.boundMax = tightMax; gpuModel.boundRadius = model.boundRadius; gpuModel.indexCount = static_cast(model.indices.size()); gpuModel.vertexCount = static_cast(model.vertices.size()); // Create VAO glGenVertexArrays(1, &gpuModel.vao); glBindVertexArray(gpuModel.vao); // Store bone/sequence data for animation gpuModel.bones = model.bones; gpuModel.sequences = model.sequences; gpuModel.globalSequenceDurations = model.globalSequenceDurations; gpuModel.hasAnimation = false; for (const auto& bone : model.bones) { if (bone.translation.hasData() || bone.rotation.hasData() || bone.scale.hasData()) { gpuModel.hasAnimation = true; break; } } gpuModel.disableAnimation = foliageOrTreeLike || chestName; gpuModel.shadowWindFoliage = foliageOrTreeLike; gpuModel.isGroundDetail = groundDetailModel; if (groundDetailModel) { // Ground clutter (grass/pebbles/detail cards) should never block camera/movement. gpuModel.collisionNoBlock = true; } // Spell effect models: particle-dominated with minimal geometry (e.g. LevelUp.m2) gpuModel.isSpellEffect = hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3; // Build collision mesh + spatial grid from M2 bounding geometry gpuModel.collision.vertices = model.collisionVertices; gpuModel.collision.indices = model.collisionIndices; gpuModel.collision.build(); if (gpuModel.collision.valid()) { core::Logger::getInstance().debug(" M2 collision mesh: ", gpuModel.collision.triCount, " tris, grid ", gpuModel.collision.gridCellsX, "x", gpuModel.collision.gridCellsY); } // Flag smoke models for UV scroll animation (particle emitters not implemented) { std::string smokeName = model.name; std::transform(smokeName.begin(), smokeName.end(), smokeName.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); gpuModel.isSmoke = (smokeName.find("smoke") != std::string::npos); } // Identify idle variation sequences (animation ID 0 = Stand) for (int i = 0; i < static_cast(model.sequences.size()); i++) { if (model.sequences[i].id == 0 && model.sequences[i].duration > 0) { gpuModel.idleVariationIndices.push_back(i); } } if (hasGeometry) { // Create VBO with interleaved vertex data // 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); for (const auto& v : model.vertices) { vertexData.push_back(v.position.x); vertexData.push_back(v.position.y); vertexData.push_back(v.position.z); vertexData.push_back(v.normal.x); vertexData.push_back(v.normal.y); 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); float w0 = v.boneWeights[0] / 255.0f; float w1 = v.boneWeights[1] / 255.0f; float w2 = v.boneWeights[2] / 255.0f; float w3 = v.boneWeights[3] / 255.0f; vertexData.push_back(w0); vertexData.push_back(w1); vertexData.push_back(w2); vertexData.push_back(w3); vertexData.push_back(static_cast(std::min(v.boneIndices[0], uint8_t(127)))); vertexData.push_back(static_cast(std::min(v.boneIndices[1], uint8_t(127)))); vertexData.push_back(static_cast(std::min(v.boneIndices[2], uint8_t(127)))); vertexData.push_back(static_cast(std::min(v.boneIndices[3], uint8_t(127)))); } glGenBuffers(1, &gpuModel.vbo); glBindBuffer(GL_ARRAY_BUFFER, gpuModel.vbo); glBufferData(GL_ARRAY_BUFFER, vertexData.size() * sizeof(float), vertexData.data(), GL_STATIC_DRAW); glGenBuffers(1, &gpuModel.ebo); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuModel.ebo); glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.indices.size() * sizeof(uint16_t), model.indices.data(), GL_STATIC_DRAW); const size_t stride = floatsPerVertex * sizeof(float); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, (void*)0); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, (void*)(3 * sizeof(float))); glEnableVertexAttribArray(2); glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, (void*)(6 * sizeof(float))); glEnableVertexAttribArray(5); glVertexAttribPointer(5, 2, GL_FLOAT, GL_FALSE, stride, (void*)(8 * sizeof(float))); glEnableVertexAttribArray(3); glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, stride, (void*)(10 * sizeof(float))); glEnableVertexAttribArray(4); glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, stride, (void*)(14 * sizeof(float))); } glBindVertexArray(0); // 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]; std::string texPath = tex.filename; // Some extracted M2 texture strings contain embedded NUL + garbage suffix. // Truncate at first NUL so valid paths like "...foo.blp\0junk" still resolve. size_t nul = texPath.find('\0'); if (nul != std::string::npos) { texPath.resize(nul); } if (!texPath.empty()) { GLuint texId = loadTexture(texPath, tex.flags); bool failed = (texId == whiteTexture); if (failed) { LOG_WARNING("M2 model ", model.name, " texture[", ti, "] failed to load: ", texPath); } if (isInvisibleTrap) { LOG_INFO(" InvisibleTrap texture[", ti, "]: ", texPath, " -> ", (failed ? "WHITE" : "OK")); } allTextures.push_back(texId); textureLoadFailed.push_back(failed); } else { 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) } } } // Copy particle emitter data and resolve textures gpuModel.particleEmitters = model.particleEmitters; gpuModel.particleTextures.resize(model.particleEmitters.size(), whiteTexture); for (size_t ei = 0; ei < model.particleEmitters.size(); ei++) { uint16_t texIdx = model.particleEmitters[ei].texture; if (texIdx < allTextures.size() && allTextures[texIdx] != 0) { gpuModel.particleTextures[ei] = allTextures[texIdx]; } } // Copy texture transform data for UV animation gpuModel.textureTransforms = model.textureTransforms; gpuModel.textureTransformLookup = model.textureTransformLookup; gpuModel.hasTextureAnimation = false; // Build per-batch GPU entries if (!model.batches.empty()) { for (const auto& batch : model.batches) { M2ModelGPU::BatchGPU bgpu; bgpu.indexStart = batch.indexStart; bgpu.indexCount = batch.indexCount; // Store texture animation index from batch bgpu.textureAnimIndex = batch.textureAnimIndex; if (bgpu.textureAnimIndex != 0xFFFF) { gpuModel.hasTextureAnimation = true; } // Store blend mode and flags from material if (batch.materialIndex < model.materials.size()) { bgpu.blendMode = model.materials[batch.materialIndex].blendMode; bgpu.materialFlags = model.materials[batch.materialIndex].flags; } // Copy LOD level from batch bgpu.submeshLevel = batch.submeshLevel; // 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]; } if (texFailed && groundDetailModel) { static const std::string kDetailFallbackTexture = "World\\NoDXT\\Detail\\8des_detaildoodads01.blp"; GLuint fallbackTex = loadTexture(kDetailFallbackTexture, 0); if (fallbackTex != 0 && fallbackTex != whiteTexture) { tex = fallbackTex; texFailed = false; } } bgpu.texture = tex; bool texHasAlpha = false; if (tex != 0 && tex != whiteTexture) { auto ait = textureHasAlphaById_.find(tex); texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false; } bgpu.hasAlpha = texHasAlpha; bool colorKeyBlack = false; if (tex != 0 && tex != whiteTexture) { auto cit = textureColorKeyBlackById_.find(tex); colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false; } bgpu.colorKeyBlack = colorKeyBlack; // textureCoordIndex is an index into a texture coord combo table, not directly // a UV set selector. Most batches have index=0 (UV set 0). We always use UV set 0 // since we don't have the full combo table — dual-UV effects are rare edge cases. bgpu.textureUnit = 0; // Batch is hidden only when its named texture failed to load (avoids white shell artifacts). // Do NOT bake transparency/color animation tracks here — they animate over time and // baking the first keyframe value causes legitimate meshes to become invisible. // Keep terrain clutter visible even when source texture paths are malformed. bgpu.batchOpacity = (texFailed && !groundDetailModel) ? 0.0f : 1.0f; // Compute batch center and radius for glow sprite positioning if ((bgpu.blendMode >= 3 || bgpu.colorKeyBlack) && batch.indexCount > 0) { glm::vec3 sum(0.0f); uint32_t counted = 0; for (uint32_t j = batch.indexStart; j < batch.indexStart + batch.indexCount; j++) { if (j < model.indices.size()) { uint16_t vi = model.indices[j]; if (vi < model.vertices.size()) { sum += model.vertices[vi].position; counted++; } } } if (counted > 0) { bgpu.center = sum / static_cast(counted); float maxDist = 0.0f; for (uint32_t j = batch.indexStart; j < batch.indexStart + batch.indexCount; j++) { if (j < model.indices.size()) { uint16_t vi = model.indices[j]; if (vi < model.vertices.size()) { float d = glm::length(model.vertices[vi].position - bgpu.center); maxDist = std::max(maxDist, d); } } } bgpu.glowSize = std::max(maxDist, 0.5f); } } // Optional diagnostics for glow/light batches (disabled by default). static const bool kGlowDiag = envFlagEnabled("WOWEE_M2_GLOW_DIAG", false); if (kGlowDiag && (lowerName.find("light") != std::string::npos || lowerName.find("lamp") != std::string::npos || lowerName.find("lantern") != std::string::npos)) { LOG_DEBUG("M2 GLOW DIAG '", model.name, "' batch ", gpuModel.batches.size(), ": blend=", bgpu.blendMode, " matFlags=0x", std::hex, bgpu.materialFlags, std::dec, " colorKey=", bgpu.colorKeyBlack ? "Y" : "N", " hasAlpha=", bgpu.hasAlpha ? "Y" : "N", " unlit=", (bgpu.materialFlags & 0x01) ? "Y" : "N", " glowSize=", bgpu.glowSize, " tex=", bgpu.texture, " idxCount=", bgpu.indexCount); } gpuModel.batches.push_back(bgpu); } } else { // Fallback: single batch covering all indices with first texture M2ModelGPU::BatchGPU bgpu; bgpu.indexStart = 0; bgpu.indexCount = gpuModel.indexCount; bgpu.texture = allTextures.empty() ? whiteTexture : allTextures[0]; bool texHasAlpha = false; if (bgpu.texture != 0 && bgpu.texture != whiteTexture) { auto ait = textureHasAlphaById_.find(bgpu.texture); texHasAlpha = (ait != textureHasAlphaById_.end()) ? ait->second : false; } bgpu.hasAlpha = texHasAlpha; bool colorKeyBlack = false; if (bgpu.texture != 0 && bgpu.texture != whiteTexture) { auto cit = textureColorKeyBlackById_.find(bgpu.texture); colorKeyBlack = (cit != textureColorKeyBlackById_.end()) ? cit->second : false; } bgpu.colorKeyBlack = colorKeyBlack; gpuModel.batches.push_back(bgpu); } // Detect particle emitter volume models: box mesh (24 verts, 36 indices) // with disproportionately large bounds. These are invisible bounding volumes // that only exist to spawn particles — their mesh should never be rendered. if (!isInvisibleTrap && !groundDetailModel && gpuModel.vertexCount <= 24 && gpuModel.indexCount <= 36 && !model.particleEmitters.empty()) { glm::vec3 size = gpuModel.boundMax - gpuModel.boundMin; float maxDim = std::max({size.x, size.y, size.z}); if (maxDim > 5.0f) { gpuModel.isInvisibleTrap = true; LOG_DEBUG("M2 emitter volume hidden: '", model.name, "' size=(", size.x, " x ", size.y, " x ", size.z, ")"); } } models[modelId] = std::move(gpuModel); LOG_DEBUG("Loaded M2 model: ", model.name, " (", models[modelId].vertexCount, " vertices, ", models[modelId].indexCount / 3, " triangles, ", models[modelId].batches.size(), " batches)"); return true; } uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, const glm::vec3& rotation, float scale) { auto modelIt = models.find(modelId); if (modelIt == models.end()) { LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); return 0; } const auto& mdlRef = modelIt->second; // Ground clutter is procedurally scattered and high-count; avoid O(N) dedup // scans that can hitch when new tiles stream in. if (!mdlRef.isGroundDetail) { // Deduplicate: skip if same model already at nearly the same position for (const auto& existing : instances) { if (existing.modelId == modelId) { glm::vec3 d = existing.position - position; if (glm::dot(d, d) < 0.01f) { return existing.id; } } } } M2Instance instance; instance.id = nextInstanceId++; instance.modelId = modelId; instance.position = position; if (mdlRef.isGroundDetail) { instance.position.z -= computeGroundDetailDownOffset(mdlRef, scale); } instance.rotation = rotation; instance.scale = scale; instance.updateModelMatrix(); glm::vec3 localMin, localMax; getTightCollisionBounds(mdlRef, localMin, localMax); transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax); // Initialize animation: play first sequence (usually Stand/Idle) const auto& mdl = mdlRef; if (mdl.hasAnimation && !mdl.disableAnimation && !mdl.sequences.empty()) { instance.currentSequenceIndex = 0; instance.idleSequenceIndex = 0; instance.animDuration = static_cast(mdl.sequences[0].duration); instance.animTime = static_cast(rand() % std::max(1u, mdl.sequences[0].duration)); instance.variationTimer = 3000.0f + static_cast(rand() % 8000); } instances.push_back(instance); size_t idx = instances.size() - 1; instanceIndexById[instance.id] = idx; GridCell minCell = toCell(instance.worldBoundsMin); GridCell maxCell = toCell(instance.worldBoundsMax); for (int z = minCell.z; z <= maxCell.z; z++) { for (int y = minCell.y; y <= maxCell.y; y++) { for (int x = minCell.x; x <= maxCell.x; x++) { spatialGrid[GridCell{x, y, z}].push_back(instance.id); } } } return instance.id; } uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4& modelMatrix, const glm::vec3& position) { if (models.find(modelId) == models.end()) { LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); return 0; } // Deduplicate: skip if same model already at nearly the same position for (const auto& existing : instances) { if (existing.modelId == modelId) { glm::vec3 d = existing.position - position; if (glm::dot(d, d) < 0.01f) { return existing.id; } } } M2Instance instance; instance.id = nextInstanceId++; instance.modelId = modelId; instance.position = position; // Used for frustum culling instance.rotation = glm::vec3(0.0f); instance.scale = 1.0f; instance.modelMatrix = modelMatrix; instance.invModelMatrix = glm::inverse(modelMatrix); glm::vec3 localMin, localMax; getTightCollisionBounds(models[modelId], localMin, localMax); transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax); // Initialize animation const auto& mdl2 = models[modelId]; if (mdl2.hasAnimation && !mdl2.disableAnimation && !mdl2.sequences.empty()) { instance.currentSequenceIndex = 0; instance.idleSequenceIndex = 0; instance.animDuration = static_cast(mdl2.sequences[0].duration); instance.animTime = static_cast(rand() % std::max(1u, mdl2.sequences[0].duration)); instance.variationTimer = 3000.0f + static_cast(rand() % 8000); } else { instance.animTime = static_cast(rand()) / RAND_MAX * 10000.0f; } instances.push_back(instance); size_t idx = instances.size() - 1; instanceIndexById[instance.id] = idx; GridCell minCell = toCell(instance.worldBoundsMin); GridCell maxCell = toCell(instance.worldBoundsMax); for (int z = minCell.z; z <= maxCell.z; z++) { for (int y = minCell.y; y <= maxCell.y; y++) { for (int x = minCell.x; x <= maxCell.x; x++) { spatialGrid[GridCell{x, y, z}].push_back(instance.id); } } } return instance.id; } // --- Bone animation helpers (same logic as CharacterRenderer) --- static int findKeyframeIndex(const std::vector& timestamps, float time) { if (timestamps.empty()) return -1; if (timestamps.size() == 1) return 0; 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); } // Resolve sequence index and time for a track, handling global sequences. static void resolveTrackTime(const pipeline::M2AnimationTrack& track, int seqIdx, float time, const std::vector& globalSeqDurations, int& outSeqIdx, float& outTime) { if (track.globalSequence >= 0 && static_cast(track.globalSequence) < globalSeqDurations.size()) { // Global sequence: always use sub-array 0, wrap time at global duration outSeqIdx = 0; float dur = static_cast(globalSeqDurations[track.globalSequence]); outTime = (dur > 0.0f) ? std::fmod(time, dur) : 0.0f; } else { outSeqIdx = seqIdx; outTime = time; } } static glm::vec3 interpVec3(const pipeline::M2AnimationTrack& track, int seqIdx, float time, const glm::vec3& def, const std::vector& globalSeqDurations) { if (!track.hasData()) return def; int si; float t; resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); if (si < 0 || si >= static_cast(track.sequences.size())) return def; const auto& keys = track.sequences[si]; if (keys.timestamps.empty() || keys.vec3Values.empty()) return def; auto safe = [&](const glm::vec3& v) -> glm::vec3 { if (std::isnan(v.x) || std::isnan(v.y) || std::isnan(v.z)) return def; return v; }; if (keys.vec3Values.size() == 1) return safe(keys.vec3Values[0]); int idx = findKeyframeIndex(keys.timestamps, t); if (idx < 0) return def; size_t i0 = static_cast(idx); size_t i1 = std::min(i0 + 1, keys.vec3Values.size() - 1); if (i0 == i1) return safe(keys.vec3Values[i0]); float t0 = static_cast(keys.timestamps[i0]); float t1 = static_cast(keys.timestamps[i1]); float dur = t1 - t0; float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; return safe(glm::mix(keys.vec3Values[i0], keys.vec3Values[i1], frac)); } static glm::quat interpQuat(const pipeline::M2AnimationTrack& track, int seqIdx, float time, const std::vector& globalSeqDurations) { glm::quat identity(1.0f, 0.0f, 0.0f, 0.0f); if (!track.hasData()) return identity; int si; float t; resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); if (si < 0 || si >= static_cast(track.sequences.size())) return identity; const auto& keys = track.sequences[si]; if (keys.timestamps.empty() || keys.quatValues.empty()) return identity; auto safe = [&](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 safe(keys.quatValues[0]); int idx = findKeyframeIndex(keys.timestamps, t); 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 safe(keys.quatValues[i0]); float t0 = static_cast(keys.timestamps[i0]); float t1 = static_cast(keys.timestamps[i1]); float dur = t1 - t0; float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; return glm::slerp(safe(keys.quatValues[i0]), safe(keys.quatValues[i1]), frac); } static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { size_t numBones = std::min(model.bones.size(), size_t(128)); if (numBones == 0) return; instance.boneMatrices.resize(numBones); const auto& gsd = model.globalSequenceDurations; for (size_t i = 0; i < numBones; i++) { const auto& bone = model.bones[i]; glm::vec3 trans = interpVec3(bone.translation, instance.currentSequenceIndex, instance.animTime, glm::vec3(0.0f), gsd); glm::quat rot = interpQuat(bone.rotation, instance.currentSequenceIndex, instance.animTime, gsd); glm::vec3 scl = interpVec3(bone.scale, instance.currentSequenceIndex, instance.animTime, glm::vec3(1.0f), gsd); // Sanity check scale to avoid degenerate matrices if (scl.x < 0.001f) scl.x = 1.0f; if (scl.y < 0.001f) scl.y = 1.0f; if (scl.z < 0.001f) scl.z = 1.0f; glm::mat4 local = glm::translate(glm::mat4(1.0f), bone.pivot); local = glm::translate(local, trans); local *= glm::toMat4(rot); local = glm::scale(local, scl); local = glm::translate(local, -bone.pivot); if (bone.parentBone >= 0 && static_cast(bone.parentBone) < numBones) { instance.boneMatrices[i] = instance.boneMatrices[bone.parentBone] * local; } else { instance.boneMatrices[i] = local; } } } void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { if (spatialIndexDirty_) { rebuildSpatialIndex(); } float dtMs = deltaTime * 1000.0f; // Cache camera state for frustum-culling bone computation cachedCamPos_ = cameraPos; const float maxRenderDistance = (instances.size() > 2000) ? 800.0f : 2800.0f; cachedMaxRenderDistSq_ = maxRenderDistance * maxRenderDistance; // Build frustum for culling bones Frustum updateFrustum; updateFrustum.extractFromMatrix(viewProjection); // --- Smoke particle spawning --- std::uniform_real_distribution distXY(-0.4f, 0.4f); std::uniform_real_distribution distVelXY(-0.3f, 0.3f); std::uniform_real_distribution distVelZ(3.0f, 5.0f); std::uniform_real_distribution distLife(4.0f, 7.0f); std::uniform_real_distribution distDrift(-0.2f, 0.2f); smokeEmitAccum += deltaTime; float emitInterval = 1.0f / 8.0f; // 8 particles per second per emitter for (auto& instance : instances) { auto it = models.find(instance.modelId); if (it == models.end()) continue; const M2ModelGPU& model = it->second; if (model.isSmoke && smokeEmitAccum >= emitInterval && static_cast(smokeParticles.size()) < MAX_SMOKE_PARTICLES) { // Emission point: model origin in world space (model matrix already positions at chimney) glm::vec3 emitWorld = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); // Occasionally spawn a spark instead of smoke (~1 in 8) bool spark = (smokeRng() % 8 == 0); SmokeParticle p; p.position = emitWorld + glm::vec3(distXY(smokeRng), distXY(smokeRng), 0.0f); if (spark) { p.velocity = glm::vec3(distVelXY(smokeRng) * 2.0f, distVelXY(smokeRng) * 2.0f, distVelZ(smokeRng) * 1.5f); p.maxLife = 0.8f + static_cast(smokeRng() % 100) / 100.0f * 1.2f; // 0.8-2.0s p.size = 0.5f; p.isSpark = 1.0f; } else { p.velocity = glm::vec3(distVelXY(smokeRng), distVelXY(smokeRng), distVelZ(smokeRng)); p.maxLife = distLife(smokeRng); p.size = 1.0f; p.isSpark = 0.0f; } p.life = 0.0f; p.instanceId = instance.id; smokeParticles.push_back(p); } } if (smokeEmitAccum >= emitInterval) { smokeEmitAccum = 0.0f; } // --- Update existing smoke particles --- for (auto it = smokeParticles.begin(); it != smokeParticles.end(); ) { it->life += deltaTime; if (it->life >= it->maxLife) { it = smokeParticles.erase(it); continue; } it->position += it->velocity * deltaTime; it->velocity.z *= 0.98f; // Slight deceleration it->velocity.x += distDrift(smokeRng) * deltaTime; it->velocity.y += distDrift(smokeRng) * deltaTime; // Grow from 1.0 to 3.5 over lifetime float t = it->life / it->maxLife; it->size = 1.0f + t * 2.5f; ++it; } // --- Normal M2 animation update --- // Phase 1: Update animation state (cheap, sequential) // Collect indices of instances that need bone matrix computation. // Reuse persistent vector to avoid allocation stutter boneWorkIndices_.clear(); if (boneWorkIndices_.capacity() < instances.size()) { boneWorkIndices_.reserve(instances.size()); } for (size_t idx = 0; idx < instances.size(); ++idx) { auto& instance = instances[idx]; auto it = models.find(instance.modelId); if (it == models.end()) continue; const M2ModelGPU& model = it->second; if (!model.hasAnimation || model.disableAnimation) { instance.animTime += dtMs; // Wrap animation time for models with particle emitters so emission // rate tracks keep looping instead of running past their keyframes. if (!model.particleEmitters.empty() && instance.animTime > 3333.0f) { instance.animTime = std::fmod(instance.animTime, 3333.0f); } continue; } instance.animTime += dtMs * instance.animSpeed; // Validate sequence index if (instance.currentSequenceIndex < 0 || instance.currentSequenceIndex >= static_cast(model.sequences.size())) { instance.currentSequenceIndex = 0; if (!model.sequences.empty()) { instance.animDuration = static_cast(model.sequences[0].duration); } } // Handle animation looping / variation transitions // When animDuration is 0 (e.g. "Stand" with infinite loop) but the model // has particle emitters, wrap time so particle emission tracks keep looping. if (instance.animDuration <= 0.0f && !model.particleEmitters.empty()) { instance.animDuration = 3333.0f; // ~3.3s loop for continuous particle effects } if (instance.animDuration > 0.0f && instance.animTime >= instance.animDuration) { if (instance.playingVariation) { // Variation finished — return to idle instance.playingVariation = false; instance.currentSequenceIndex = instance.idleSequenceIndex; if (instance.idleSequenceIndex < static_cast(model.sequences.size())) { instance.animDuration = static_cast(model.sequences[instance.idleSequenceIndex].duration); } instance.animTime = 0.0f; instance.variationTimer = 4000.0f + static_cast(rand() % 6000); } else { // Loop idle instance.animTime = std::fmod(instance.animTime, std::max(1.0f, instance.animDuration)); } } // Idle variation timer — occasionally play a different idle sequence if (!instance.playingVariation && model.idleVariationIndices.size() > 1) { instance.variationTimer -= dtMs; if (instance.variationTimer <= 0.0f) { int pick = rand() % static_cast(model.idleVariationIndices.size()); int newSeq = model.idleVariationIndices[pick]; if (newSeq != instance.currentSequenceIndex && newSeq < static_cast(model.sequences.size())) { instance.playingVariation = true; instance.currentSequenceIndex = newSeq; instance.animDuration = static_cast(model.sequences[newSeq].duration); instance.animTime = 0.0f; } else { instance.variationTimer = 2000.0f + static_cast(rand() % 4000); } } } // Frustum + distance cull: skip expensive bone computation for off-screen instances. // Keep thresholds aligned with render culling so visible distant ambient actors // (fish/seagulls/etc.) continue animating instead of freezing in idle poses. float worldRadius = model.boundRadius * instance.scale; float cullRadius = worldRadius; if (model.disableAnimation) { cullRadius = std::max(cullRadius, 3.0f); } glm::vec3 toCam = instance.position - cachedCamPos_; float distSq = glm::dot(toCam, toCam); float effectiveMaxDistSq = cachedMaxRenderDistSq_ * std::max(1.0f, cullRadius / 12.0f); if (model.disableAnimation) { effectiveMaxDistSq *= 2.6f; } if (distSq > effectiveMaxDistSq) continue; float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); if (cullRadius > 0.0f && !updateFrustum.intersectsSphere(instance.position, paddedRadius)) continue; boneWorkIndices_.push_back(idx); } // Phase 2: Compute bone matrices (expensive, parallel if enough work) const size_t animCount = boneWorkIndices_.size(); if (animCount > 0) { if (animCount < 6 || numAnimThreads_ <= 1) { // Sequential — not enough work to justify thread overhead for (size_t i : boneWorkIndices_) { if (i >= instances.size()) continue; auto& inst = instances[i]; auto mdlIt = models.find(inst.modelId); if (mdlIt == models.end()) continue; computeBoneMatrices(mdlIt->second, inst); } } else { // Parallel — dispatch across worker threads const size_t numThreads = std::min(static_cast(numAnimThreads_), animCount); const size_t chunkSize = animCount / numThreads; const size_t remainder = animCount % numThreads; // Reuse persistent futures vector to avoid allocation animFutures_.clear(); if (animFutures_.capacity() < numThreads) { animFutures_.reserve(numThreads); } size_t start = 0; for (size_t t = 0; t < numThreads; ++t) { size_t end = start + chunkSize + (t < remainder ? 1 : 0); animFutures_.push_back(std::async(std::launch::async, [this, start, end]() { for (size_t j = start; j < end; ++j) { size_t idx = boneWorkIndices_[j]; if (idx >= instances.size()) continue; auto& inst = instances[idx]; auto mdlIt = models.find(inst.modelId); if (mdlIt == models.end()) continue; computeBoneMatrices(mdlIt->second, inst); } })); start = end; } for (auto& f : animFutures_) { f.get(); } } } // Phase 3: Particle update (sequential — uses RNG, not thread-safe) // Run for ALL nearby instances with particle emitters, not just those in // boneWorkIndices_, so particles keep animating even when bone updates are culled. for (size_t idx = 0; idx < instances.size(); ++idx) { auto& instance = instances[idx]; auto mdlIt = models.find(instance.modelId); if (mdlIt == models.end()) continue; const auto& model = mdlIt->second; if (model.particleEmitters.empty()) continue; // Distance cull: only update particles within visible range glm::vec3 toCam = instance.position - cachedCamPos_; float distSq = glm::dot(toCam, toCam); if (distSq > cachedMaxRenderDistSq_) continue; emitParticles(instance, model, deltaTime); updateParticles(instance, deltaTime); } } void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection) { if (instances.empty() || !shader) { return; } auto renderStartTime = std::chrono::high_resolution_clock::now(); // Debug: log once when we start rendering static bool loggedOnce = false; if (!loggedOnce) { loggedOnce = true; LOG_INFO("M2 render: ", instances.size(), " instances, ", models.size(), " models"); } // Set up GL state for M2 rendering glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LEQUAL); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glDisable(GL_CULL_FACE); // Some M2 geometry is single-sided // Build frustum for culling Frustum frustum; frustum.extractFromMatrix(projection * view); // Reuse persistent buffers (clear instead of reallocating) glowSprites_.clear(); shader->use(); shader->setUniform("uView", view); shader->setUniform("uProjection", projection); shader->setUniform("uLightDir", lightDir); shader->setUniform("uLightColor", lightColor); shader->setUniform("uSpecularIntensity", 0.5f); shader->setUniform("uAmbientColor", ambientColor); shader->setUniform("uViewPos", camera.getPosition()); shader->setUniform("uFogColor", fogColor); shader->setUniform("uFogStart", fogStart); shader->setUniform("uFogEnd", fogEnd); bool useShadows = shadowEnabled; shader->setUniform("uShadowEnabled", useShadows ? 1 : 0); shader->setUniform("uShadowStrength", 0.62f); if (useShadows) { shader->setUniform("uLightSpaceMatrix", lightSpaceMatrix); glActiveTexture(GL_TEXTURE7); glBindTexture(GL_TEXTURE_2D, shadowDepthTex); shader->setUniform("uShadowMap", 7); } lastDrawCallCount = 0; // Adaptive render distance: balanced for performance without excessive pop-in const float maxRenderDistance = (instances.size() > 2000) ? 350.0f : 1000.0f; const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance; const float fadeStartFraction = 0.75f; const glm::vec3 camPos = camera.getPosition(); // Build sorted visible instance list: cull then sort by modelId to batch VAO binds // Reuse persistent vector to avoid allocation sortedVisible_.clear(); // Reserve based on expected visible count (roughly 30% of total instances in dense areas) const size_t expectedVisible = std::min(instances.size() / 3, size_t(600)); if (sortedVisible_.capacity() < expectedVisible) { sortedVisible_.reserve(expectedVisible); } // Early distance rejection: max possible render distance (tight but safe upper bound) const float maxPossibleDistSq = maxRenderDistance * maxRenderDistance * 4.0f; // 2x safety margin (reduced from 4x) for (uint32_t i = 0; i < static_cast(instances.size()); ++i) { const auto& instance = instances[i]; // Fast early rejection: skip instances that are definitely too far glm::vec3 toCam = instance.position - camPos; float distSq = glm::dot(toCam, toCam); if (distSq > maxPossibleDistSq) continue; // Early out before model lookup auto it = models.find(instance.modelId); if (it == models.end()) continue; const M2ModelGPU& model = it->second; if (!model.isValid() || model.isSmoke || model.isInvisibleTrap) continue; float worldRadius = model.boundRadius * instance.scale; float cullRadius = worldRadius; if (model.disableAnimation) { cullRadius = std::max(cullRadius, 3.0f); } float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); if (model.disableAnimation) { effectiveMaxDistSq *= 2.6f; } if (model.isGroundDetail) { // Keep clutter local so distant grass doesn't overdraw the scene. effectiveMaxDistSq *= 0.45f; } // Removed aggressive small-object distance caps to prevent city pop-out // Small props (barrels, lanterns, etc.) now use same distance as larger objects if (distSq > effectiveMaxDistSq) continue; // Frustum cull with moderate padding to prevent edge pop-out during camera rotation // Reduced from 2.5x to 1.5x for better performance float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); if (cullRadius > 0.0f && !frustum.intersectsSphere(instance.position, paddedRadius)) continue; sortedVisible_.push_back({i, instance.modelId, distSq, effectiveMaxDistSq}); } // Sort by modelId to minimize VAO rebinds (using stable_sort for better cache behavior) std::stable_sort(sortedVisible_.begin(), sortedVisible_.end(), [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); auto cullingSortTime = std::chrono::high_resolution_clock::now(); double cullingSortMs = std::chrono::duration(cullingSortTime - renderStartTime).count(); uint32_t currentModelId = UINT32_MAX; const M2ModelGPU* currentModel = nullptr; // State tracking to avoid redundant GL calls (similar to WMO renderer optimization) static GLuint lastBoundTexture = 0; static bool lastHasTexture = false; static bool lastAlphaTest = false; static bool lastColorKeyBlack = false; static bool lastUnlit = false; static bool lastUseBones = false; static bool lastInteriorDarken = false; 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; lastHasTexture = false; lastAlphaTest = false; lastColorKeyBlack = false; lastUnlit = false; lastUseBones = false; lastInteriorDarken = false; lastBlendMode = 255; depthMaskState = true; lastUVOffset = glm::vec2(-999.0f); lastTexCoordSet = -1; // Set texture unit once per frame instead of per-batch glActiveTexture(GL_TEXTURE0); shader->setUniform("uTexture", 0); // Texture unit 0, set once per frame shader->setUniform("uColorKeyBlack", false); shader->setUniform("uColorKeyThreshold", 0.08f); shader->setUniform("uBlendMode", 0); // Performance counters uint32_t boneMatrixUploads = 0; uint32_t totalBatchesDrawn = 0; for (const auto& entry : sortedVisible_) { if (entry.index >= instances.size()) continue; const auto& instance = instances[entry.index]; // Bind VAO once per model group if (entry.modelId != currentModelId) { if (currentModel) glBindVertexArray(0); currentModelId = entry.modelId; auto mdlIt = models.find(currentModelId); if (mdlIt == models.end()) continue; currentModel = &mdlIt->second; glBindVertexArray(currentModel->vao); } const M2ModelGPU& model = *currentModel; // Distance-based fade alpha for smooth pop-in (squared-distance, no sqrt) float fadeAlpha = 1.0f; float fadeFrac = model.disableAnimation ? 0.55f : fadeStartFraction; float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac; if (entry.distSq > fadeStartDistSq) { fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) / (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); } // Always update per-instance uniforms (these change every instance) float instanceFadeAlpha = fadeAlpha; if (model.isGroundDetail) { instanceFadeAlpha *= 0.82f; } shader->setUniform("uModel", instance.modelMatrix); shader->setUniform("uFadeAlpha", instanceFadeAlpha); // Track interior darken state to avoid redundant updates if (insideInterior != lastInteriorDarken) { shader->setUniform("uInteriorDarken", insideInterior); lastInteriorDarken = insideInterior; } // Upload bone matrices if model has skeletal animation bool useBones = model.hasAnimation && !model.disableAnimation && !instance.boneMatrices.empty(); if (useBones != lastUseBones) { shader->setUniform("uUseBones", useBones); lastUseBones = useBones; } if (useBones) { int numBones = std::min(static_cast(instance.boneMatrices.size()), 128); shader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones); boneMatrixUploads++; } // Disable depth writes for fading objects to avoid z-fighting if (instanceFadeAlpha < 1.0f) { if (depthMaskState) { glDepthMask(GL_FALSE); depthMaskState = false; } } // LOD selection based on distance (WoW retail behavior) // submeshLevel: 0=base detail, 1=LOD1, 2=LOD2, 3=LOD3 float dist = std::sqrt(entry.distSq); uint16_t desiredLOD = 0; if (dist > 150.0f) desiredLOD = 3; // Far: LOD3 (lowest detail) else if (dist > 80.0f) desiredLOD = 2; // Medium-far: LOD2 else if (dist > 40.0f) desiredLOD = 1; // Medium: LOD1 // else desiredLOD = 0 (close: base detail) // Check if model has the desired LOD level; if not, fall back to LOD 0 uint16_t targetLOD = desiredLOD; if (desiredLOD > 0) { bool hasDesiredLOD = false; for (const auto& b : model.batches) { if (b.submeshLevel == desiredLOD) { hasDesiredLOD = true; break; } } if (!hasDesiredLOD) { targetLOD = 0; // Fall back to base LOD } } std::string modelKeyLower = model.name; std::transform(modelKeyLower.begin(), modelKeyLower.end(), modelKeyLower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); const bool flameLikeModel = (modelKeyLower.find("lantern") != std::string::npos) || (modelKeyLower.find("lamp") != std::string::npos) || (modelKeyLower.find("torch") != std::string::npos) || (modelKeyLower.find("candle") != std::string::npos) || (modelKeyLower.find("flame") != std::string::npos) || (modelKeyLower.find("fire") != std::string::npos) || (modelKeyLower.find("brazier") != std::string::npos) || (modelKeyLower.find("campfire") != std::string::npos) || (modelKeyLower.find("bonfire") != std::string::npos); for (const auto& batch : model.batches) { if (batch.indexCount == 0) continue; // Skip batches that don't match target LOD level if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; // Skip batches with zero opacity from texture weight tracks (should be invisible) if (batch.batchOpacity < 0.01f) continue; const bool koboldFlameCard = batch.colorKeyBlack && (modelKeyLower.find("kobold") != std::string::npos) && ((modelKeyLower.find("candle") != std::string::npos) || (modelKeyLower.find("torch") != std::string::npos) || (modelKeyLower.find("mine") != std::string::npos)); // Replace only likely flame-card submeshes with sprite glow. Keep larger geometry // (lantern housings, posts, etc.) authored so the prop itself remains visible. const bool smallCardLikeBatch = (batch.glowSize <= 1.35f); const bool batchUnlit = (batch.materialFlags & 0x01) != 0; const bool shouldUseGlowSprite = !koboldFlameCard && !model.isSpellEffect && smallCardLikeBatch && ((batch.blendMode >= 3) || (batch.colorKeyBlack && flameLikeModel && batchUnlit && batch.blendMode >= 1)); if (shouldUseGlowSprite) { if (entry.distSq < 180.0f * 180.0f) { glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(batch.center, 1.0f)); GlowSprite gs; gs.worldPos = worldPos; gs.color = glm::vec4(1.0f, 0.82f, 0.46f, 1.15f); gs.size = batch.glowSize * instance.scale * 1.45f; glowSprites_.push_back(gs); } continue; } // Compute UV offset for texture animation (only set uniform if changed) glm::vec2 uvOffset(0.0f, 0.0f); if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) { uint16_t lookupIdx = batch.textureAnimIndex; if (lookupIdx < model.textureTransformLookup.size()) { uint16_t transformIdx = model.textureTransformLookup[lookupIdx]; if (transformIdx < model.textureTransforms.size()) { const auto& tt = model.textureTransforms[transformIdx]; glm::vec3 trans = interpVec3(tt.translation, instance.currentSequenceIndex, instance.animTime, glm::vec3(0.0f), model.globalSequenceDurations); uvOffset = glm::vec2(trans.x, trans.y); } } } // Only update uniform if UV offset changed (most batches have 0,0) if (uvOffset != lastUVOffset) { shader->setUniform("uUVOffset", uvOffset); lastUVOffset = uvOffset; } // Apply per-batch blend mode from M2 material (only if changed) // 0=Opaque, 1=AlphaKey, 2=Alpha, 3=Add, 4=Mod, 5=Mod2x, 6=BlendAdd, 7=Screen bool batchTransparent = false; // Spell effects: override Mod/Mod2x to Additive for bright glow rendering uint8_t effectiveBlendMode = batch.blendMode; if (model.isSpellEffect && (effectiveBlendMode == 4 || effectiveBlendMode == 5)) { effectiveBlendMode = 3; // Additive } if (model.isGroundDetail) { // Use regular alpha blending for detail cards to avoid hard cutout loss. effectiveBlendMode = 2; } if (effectiveBlendMode != lastBlendMode) { switch (effectiveBlendMode) { case 0: // Opaque glBlendFunc(GL_ONE, GL_ZERO); break; case 1: // Alpha Key (alpha test, handled by uAlphaTest) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); break; case 2: // Alpha glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); batchTransparent = true; break; case 3: // Additive glBlendFunc(GL_SRC_ALPHA, GL_ONE); batchTransparent = true; break; case 4: // Mod glBlendFunc(GL_DST_COLOR, GL_ZERO); batchTransparent = true; break; case 5: // Mod2x glBlendFunc(GL_DST_COLOR, GL_SRC_COLOR); batchTransparent = true; break; case 6: // BlendAdd glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); batchTransparent = true; break; default: // Fallback glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); break; } lastBlendMode = effectiveBlendMode; shader->setUniform("uBlendMode", static_cast(effectiveBlendMode)); } else { // Still need to know if batch is transparent for depth mask logic batchTransparent = (effectiveBlendMode >= 2); } // Disable depth writes for transparent/additive batches if (batchTransparent && instanceFadeAlpha >= 1.0f) { if (depthMaskState) { glDepthMask(GL_FALSE); depthMaskState = false; } } // Unlit: material flag 0x01 (only update if changed) bool unlit = (batch.materialFlags & 0x01) != 0; if (model.isGroundDetail) { // Ground clutter should receive scene lighting so it doesn't glow. unlit = false; } if (unlit != lastUnlit) { shader->setUniform("uUnlit", unlit); lastUnlit = unlit; } // Texture state (only update if changed) bool hasTexture = (batch.texture != 0); if (hasTexture != lastHasTexture) { shader->setUniform("uHasTexture", hasTexture); lastHasTexture = hasTexture; } bool alphaTest = (effectiveBlendMode == 1) || (effectiveBlendMode >= 2 && !batch.hasAlpha); if (model.isGroundDetail) { alphaTest = false; } if (alphaTest != lastAlphaTest) { shader->setUniform("uAlphaTest", alphaTest); lastAlphaTest = alphaTest; } bool colorKeyBlack = batch.colorKeyBlack; if (colorKeyBlack != lastColorKeyBlack) { shader->setUniform("uColorKeyBlack", colorKeyBlack); lastColorKeyBlack = colorKeyBlack; } // ColorKeyBlack textures: discard dark pixels so background shows through. // Mod blend (4) multiplies framebuffer by texture — dark pixels darken // the scene, so use a high threshold to remove the dark rectangle. if (colorKeyBlack) { float thresh = 0.08f; if (effectiveBlendMode == 4 || effectiveBlendMode == 5) { thresh = 0.7f; // Mod/Mod2x: only keep near-white pixels } shader->setUniform("uColorKeyThreshold", thresh); } // Only bind texture if it changed (texture unit already set to GL_TEXTURE0) if (hasTexture && batch.texture != lastBoundTexture) { glBindTexture(GL_TEXTURE_2D, batch.texture); 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))); totalBatchesDrawn++; // Restore depth writes after transparent batch if (batchTransparent && fadeAlpha >= 1.0f) { if (!depthMaskState) { glDepthMask(GL_TRUE); depthMaskState = true; } } // Note: blend func restoration removed - state tracking handles it lastDrawCallCount++; } // Restore depth mask after faded instance if (fadeAlpha < 1.0f) { if (!depthMaskState) { glDepthMask(GL_TRUE); depthMaskState = true; } } } if (currentModel) glBindVertexArray(0); // Render glow sprites as billboarded additive point lights if (!glowSprites_.empty() && m2ParticleShader_ != 0 && m2ParticleVAO_ != 0) { glUseProgram(m2ParticleShader_); GLint viewLoc = glGetUniformLocation(m2ParticleShader_, "uView"); GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection"); GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture"); GLint tileLoc = glGetUniformLocation(m2ParticleShader_, "uTileCount"); glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(projection)); glUniform1i(texLoc, 0); glUniform2f(tileLoc, 1.0f, 1.0f); glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blending glDepthMask(GL_FALSE); glEnable(GL_PROGRAM_POINT_SIZE); glDisable(GL_CULL_FACE); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, glowTexture); // Build vertex data: pos(3) + color(4) + size(1) + tile(1) = 9 floats per sprite std::vector glowData; glowData.reserve(glowSprites_.size() * 9); for (const auto& gs : glowSprites_) { glowData.push_back(gs.worldPos.x); glowData.push_back(gs.worldPos.y); glowData.push_back(gs.worldPos.z); glowData.push_back(gs.color.r); glowData.push_back(gs.color.g); glowData.push_back(gs.color.b); glowData.push_back(gs.color.a); glowData.push_back(gs.size); glowData.push_back(0.0f); } glBindVertexArray(m2ParticleVAO_); glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_); size_t uploadCount = std::min(glowSprites_.size(), MAX_M2_PARTICLES); glBufferSubData(GL_ARRAY_BUFFER, 0, uploadCount * 9 * sizeof(float), glowData.data()); glDrawArrays(GL_POINTS, 0, static_cast(uploadCount)); glBindVertexArray(0); glDepthMask(GL_TRUE); glDisable(GL_PROGRAM_POINT_SIZE); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); } // Restore state glDisable(GL_BLEND); glEnable(GL_CULL_FACE); auto renderEndTime = std::chrono::high_resolution_clock::now(); double totalMs = std::chrono::duration(renderEndTime - renderStartTime).count(); double drawLoopMs = std::chrono::duration(renderEndTime - cullingSortTime).count(); // Log detailed timing every 120 frames (~2 seconds at 60fps) static int frameCounter = 0; if (++frameCounter >= 120) { frameCounter = 0; LOG_DEBUG("M2 Render: ", totalMs, " ms (culling/sort: ", cullingSortMs, " ms, draw: ", drawLoopMs, " ms) | ", sortedVisible_.size(), " visible | ", totalBatchesDrawn, " batches | ", boneMatrixUploads, " bone uploads"); } } void M2Renderer::renderShadow(GLuint shadowShaderProgram, const glm::vec3& shadowCenter, float halfExtent) { if (instances.empty() || shadowShaderProgram == 0) { return; } GLint modelLoc = glGetUniformLocation(shadowShaderProgram, "uModel"); GLint useTexLoc = glGetUniformLocation(shadowShaderProgram, "uUseTexture"); GLint texLoc = glGetUniformLocation(shadowShaderProgram, "uTexture"); GLint alphaTestLoc = glGetUniformLocation(shadowShaderProgram, "uAlphaTest"); GLint foliageSwayLoc = glGetUniformLocation(shadowShaderProgram, "uFoliageSway"); if (modelLoc < 0) { return; } if (useTexLoc >= 0) glUniform1i(useTexLoc, 0); if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, 0); if (foliageSwayLoc >= 0) glUniform1i(foliageSwayLoc, 0); if (texLoc >= 0) glUniform1i(texLoc, 0); glActiveTexture(GL_TEXTURE0); for (const auto& instance : instances) { // Cull instances whose AABB doesn't overlap the shadow frustum (XY plane) glm::vec3 instCenter = (instance.worldBoundsMin + instance.worldBoundsMax) * 0.5f; glm::vec3 instHalf = (instance.worldBoundsMax - instance.worldBoundsMin) * 0.5f; if (std::abs(instCenter.x - shadowCenter.x) > halfExtent + instHalf.x) continue; if (std::abs(instCenter.y - shadowCenter.y) > halfExtent + instHalf.y) continue; auto it = models.find(instance.modelId); if (it == models.end()) continue; const M2ModelGPU& model = it->second; if (!model.isValid() || model.isSmoke) continue; glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &instance.modelMatrix[0][0]); glBindVertexArray(model.vao); for (const auto& batch : model.batches) { if (batch.indexCount == 0) continue; bool useTexture = (batch.texture != 0); bool alphaCutout = batch.hasAlpha; bool foliageSway = model.shadowWindFoliage && alphaCutout; if (useTexLoc >= 0) glUniform1i(useTexLoc, useTexture ? 1 : 0); if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, alphaCutout ? 1 : 0); if (foliageSwayLoc >= 0) glUniform1i(foliageSwayLoc, foliageSway ? 1 : 0); if (useTexture) { glBindTexture(GL_TEXTURE_2D, batch.texture); } glDrawElements(GL_TRIANGLES, batch.indexCount, GL_UNSIGNED_SHORT, (void*)(batch.indexStart * sizeof(uint16_t))); } } glBindVertexArray(0); } // --- M2 Particle Emitter Helpers --- float M2Renderer::interpFloat(const pipeline::M2AnimationTrack& track, float animTime, int seqIdx, const std::vector& /*seqs*/, const std::vector& globalSeqDurations) { if (!track.hasData()) return 0.0f; int si; float t; resolveTrackTime(track, seqIdx, animTime, globalSeqDurations, si, t); if (si < 0 || si >= static_cast(track.sequences.size())) return 0.0f; const auto& keys = track.sequences[si]; if (keys.timestamps.empty() || keys.floatValues.empty()) return 0.0f; if (keys.floatValues.size() == 1) return keys.floatValues[0]; int idx = findKeyframeIndex(keys.timestamps, t); if (idx < 0) return 0.0f; size_t i0 = static_cast(idx); size_t i1 = std::min(i0 + 1, keys.floatValues.size() - 1); if (i0 == i1) return keys.floatValues[i0]; float t0 = static_cast(keys.timestamps[i0]); float t1 = static_cast(keys.timestamps[i1]); float dur = t1 - t0; float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; return glm::mix(keys.floatValues[i0], keys.floatValues[i1], frac); } float M2Renderer::interpFBlockFloat(const pipeline::M2FBlock& fb, float lifeRatio) { if (fb.floatValues.empty()) return 1.0f; if (fb.floatValues.size() == 1 || fb.timestamps.empty()) return fb.floatValues[0]; lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); // Find surrounding timestamps for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { if (lifeRatio <= fb.timestamps[i + 1]) { float t0 = fb.timestamps[i]; float t1 = fb.timestamps[i + 1]; float dur = t1 - t0; float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; size_t v0 = std::min(i, fb.floatValues.size() - 1); size_t v1 = std::min(i + 1, fb.floatValues.size() - 1); return glm::mix(fb.floatValues[v0], fb.floatValues[v1], frac); } } return fb.floatValues.back(); } glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio) { if (fb.vec3Values.empty()) return glm::vec3(1.0f); if (fb.vec3Values.size() == 1 || fb.timestamps.empty()) return fb.vec3Values[0]; lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { if (lifeRatio <= fb.timestamps[i + 1]) { float t0 = fb.timestamps[i]; float t1 = fb.timestamps[i + 1]; float dur = t1 - t0; float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; size_t v0 = std::min(i, fb.vec3Values.size() - 1); size_t v1 = std::min(i + 1, fb.vec3Values.size() - 1); return glm::mix(fb.vec3Values[v0], fb.vec3Values[v1], frac); } } return fb.vec3Values.back(); } void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) { if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) { inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f); } std::uniform_real_distribution dist01(0.0f, 1.0f); std::uniform_real_distribution distN(-1.0f, 1.0f); std::uniform_int_distribution distTile; for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) { const auto& em = gpu.particleEmitters[ei]; if (!em.enabled) continue; float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); if (rate <= 0.0f || life <= 0.0f) continue; inst.emitterAccumulators[ei] += rate * dt; while (inst.emitterAccumulators[ei] >= 1.0f && inst.particles.size() < MAX_M2_PARTICLES) { inst.emitterAccumulators[ei] -= 1.0f; M2Particle p; p.emitterIndex = static_cast(ei); p.life = 0.0f; p.maxLife = life; p.tileIndex = 0.0f; // Position: emitter position transformed by bone matrix glm::vec3 localPos = em.position; glm::mat4 boneXform = glm::mat4(1.0f); if (em.bone < inst.boneMatrices.size()) { boneXform = inst.boneMatrices[em.bone]; } glm::vec3 worldPos = glm::vec3(inst.modelMatrix * boneXform * glm::vec4(localPos, 1.0f)); p.position = worldPos; // Velocity: emission speed in upward direction + random spread float speed = interpFloat(em.emissionSpeed, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); float vRange = interpFloat(em.verticalRange, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); float hRange = interpFloat(em.horizontalRange, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); // Base direction: up in model space, transformed to world glm::vec3 dir(0.0f, 0.0f, 1.0f); // Add random spread dir.x += distN(particleRng_) * hRange; dir.y += distN(particleRng_) * hRange; dir.z += distN(particleRng_) * vRange; float len = glm::length(dir); if (len > 0.001f) dir /= len; // Transform direction by bone + model orientation (rotation only) glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform); p.velocity = rotMat * dir * speed; // When emission speed is ~0 and bone animation isn't loaded (.anim files), // particles pile up at the same position. Give them a drift so they // spread outward like a mist/spray effect instead of clustering. if (std::abs(speed) < 0.01f) { p.velocity = rotMat * glm::vec3( distN(particleRng_) * 1.0f, distN(particleRng_) * 1.0f, -dist01(particleRng_) * 0.5f ); } const uint32_t tilesX = std::max(em.textureCols, 1); const uint32_t tilesY = std::max(em.textureRows, 1); const uint32_t totalTiles = tilesX * tilesY; if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { if (em.flags & kParticleFlagRandomized) { distTile = std::uniform_int_distribution(0, static_cast(totalTiles - 1)); p.tileIndex = static_cast(distTile(particleRng_)); } else { p.tileIndex = 0.0f; } } inst.particles.push_back(p); } // Cap accumulator to avoid bursts after lag if (inst.emitterAccumulators[ei] > 2.0f) { inst.emitterAccumulators[ei] = 0.0f; } } } void M2Renderer::updateParticles(M2Instance& inst, float dt) { auto it = models.find(inst.modelId); if (it == models.end()) return; const auto& gpu = it->second; for (size_t i = 0; i < inst.particles.size(); ) { auto& p = inst.particles[i]; p.life += dt; if (p.life >= p.maxLife) { // Swap-and-pop removal inst.particles[i] = inst.particles.back(); inst.particles.pop_back(); continue; } // Apply gravity if (p.emitterIndex >= 0 && p.emitterIndex < static_cast(gpu.particleEmitters.size())) { const auto& pem = gpu.particleEmitters[p.emitterIndex]; float grav = interpFloat(pem.gravity, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); // When M2 gravity is 0, apply default gravity so particles arc downward. // Many fountain M2s rely on bone animation (.anim files) we don't load yet. if (grav == 0.0f) { float emSpeed = interpFloat(pem.emissionSpeed, inst.animTime, inst.currentSequenceIndex, gpu.sequences, gpu.globalSequenceDurations); if (std::abs(emSpeed) > 0.1f) { grav = 4.0f; // spray particles } else { grav = 1.5f; // mist/drift particles - gentler fall } } p.velocity.z -= grav * dt; } p.position += p.velocity * dt; i++; } } void M2Renderer::renderM2Particles(const glm::mat4& view, const glm::mat4& proj) { if (m2ParticleShader_ == 0 || m2ParticleVAO_ == 0) return; // Collect all particles from all instances, grouped by texture+blend struct ParticleGroupKey { GLuint texture; uint8_t blendType; uint16_t tilesX; uint16_t tilesY; bool operator==(const ParticleGroupKey& other) const { return texture == other.texture && blendType == other.blendType && tilesX == other.tilesX && tilesY == other.tilesY; } }; struct ParticleGroupKeyHash { size_t operator()(const ParticleGroupKey& key) const { size_t h1 = std::hash{}(key.texture); size_t h2 = std::hash{}((static_cast(key.tilesX) << 16) | key.tilesY); size_t h3 = std::hash{}(key.blendType); return h1 ^ (h2 * 0x9e3779b9u) ^ (h3 * 0x85ebca6bu); } }; struct ParticleGroup { GLuint texture; uint8_t blendType; uint16_t tilesX; uint16_t tilesY; std::vector vertexData; // 9 floats per particle }; std::unordered_map groups; size_t totalParticles = 0; for (auto& inst : instances) { if (inst.particles.empty()) continue; auto it = models.find(inst.modelId); if (it == models.end()) continue; const auto& gpu = it->second; for (const auto& p : inst.particles) { if (p.emitterIndex < 0 || p.emitterIndex >= static_cast(gpu.particleEmitters.size())) continue; const auto& em = gpu.particleEmitters[p.emitterIndex]; float lifeRatio = p.life / std::max(p.maxLife, 0.001f); glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio); float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); if (!gpu.isSpellEffect) { // FBlock colors are tint values meant to multiply a bright texture. // Desaturate toward white so particles look like water spray, not neon. color = glm::mix(color, glm::vec3(1.0f), 0.7f); // Large-scale particles (>2.0) are volume/backdrop effects meant to be // nearly invisible mist. Fade them heavily since we render as point sprites. if (rawScale > 2.0f) { alpha *= 0.02f; } // Reduce additive particle intensity to prevent blinding overlap if (em.blendingType == 3 || em.blendingType == 4) { alpha *= 0.05f; } } float scale = gpu.isSpellEffect ? rawScale : std::min(rawScale, 1.5f); GLuint tex = whiteTexture; if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { tex = gpu.particleTextures[p.emitterIndex]; } uint16_t tilesX = std::max(em.textureCols, 1); uint16_t tilesY = std::max(em.textureRows, 1); uint32_t totalTiles = static_cast(tilesX) * static_cast(tilesY); ParticleGroupKey key{tex, em.blendingType, tilesX, tilesY}; auto& group = groups[key]; group.texture = tex; group.blendType = em.blendingType; group.tilesX = tilesX; group.tilesY = tilesY; group.vertexData.push_back(p.position.x); group.vertexData.push_back(p.position.y); group.vertexData.push_back(p.position.z); group.vertexData.push_back(color.r); group.vertexData.push_back(color.g); group.vertexData.push_back(color.b); group.vertexData.push_back(alpha); group.vertexData.push_back(scale); float tileIndex = p.tileIndex; if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { float animSeconds = inst.animTime / 1000.0f; uint32_t animFrame = static_cast(std::floor(animSeconds * totalTiles)) % totalTiles; tileIndex = std::fmod(p.tileIndex + static_cast(animFrame), static_cast(totalTiles)); } group.vertexData.push_back(tileIndex); totalParticles++; } } if (totalParticles == 0) return; // Set up GL state glEnable(GL_BLEND); glEnable(GL_DEPTH_TEST); glDepthMask(GL_FALSE); glEnable(GL_PROGRAM_POINT_SIZE); glDisable(GL_CULL_FACE); glUseProgram(m2ParticleShader_); GLint viewLoc = glGetUniformLocation(m2ParticleShader_, "uView"); GLint projLoc = glGetUniformLocation(m2ParticleShader_, "uProjection"); GLint texLoc = glGetUniformLocation(m2ParticleShader_, "uTexture"); GLint tileLoc = glGetUniformLocation(m2ParticleShader_, "uTileCount"); GLint alphaKeyLoc = glGetUniformLocation(m2ParticleShader_, "uAlphaKey"); glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view)); glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(proj)); glUniform1i(texLoc, 0); glActiveTexture(GL_TEXTURE0); glBindVertexArray(m2ParticleVAO_); for (auto& [key, group] : groups) { if (group.vertexData.empty()) continue; // Use blend mode as specified by the emitter — don't override based on texture alpha. // BlendType: 0=opaque, 1=alphaKey, 2=alpha, 3=add, 4=mod uint8_t blendType = group.blendType; glUniform1i(alphaKeyLoc, (blendType == 1) ? 1 : 0); if (blendType == 3 || blendType == 4) { glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive } else { glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); // Alpha } glBindTexture(GL_TEXTURE_2D, group.texture); glUniform2f(tileLoc, static_cast(group.tilesX), static_cast(group.tilesY)); // Upload and draw in chunks of MAX_M2_PARTICLES size_t count = group.vertexData.size() / 9; size_t offset = 0; while (offset < count) { size_t batch = std::min(count - offset, MAX_M2_PARTICLES); glBindBuffer(GL_ARRAY_BUFFER, m2ParticleVBO_); glBufferSubData(GL_ARRAY_BUFFER, 0, batch * 9 * sizeof(float), &group.vertexData[offset * 9]); glDrawArrays(GL_POINTS, 0, static_cast(batch)); offset += batch; } } glBindVertexArray(0); // Restore state glDepthMask(GL_TRUE); glDisable(GL_PROGRAM_POINT_SIZE); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glDisable(GL_BLEND); glEnable(GL_CULL_FACE); } void M2Renderer::renderSmokeParticles(const Camera& /*camera*/, const glm::mat4& view, const glm::mat4& projection) { if (smokeParticles.empty() || !smokeShader || smokeVAO == 0) return; // Build vertex data: pos(3) + lifeRatio(1) + size(1) + isSpark(1) per particle std::vector data; data.reserve(smokeParticles.size() * 6); for (const auto& p : smokeParticles) { data.push_back(p.position.x); data.push_back(p.position.y); data.push_back(p.position.z); data.push_back(p.life / p.maxLife); data.push_back(p.size); data.push_back(p.isSpark); } // Upload to VBO glBindBuffer(GL_ARRAY_BUFFER, smokeVBO); glBufferSubData(GL_ARRAY_BUFFER, 0, data.size() * sizeof(float), data.data()); glBindBuffer(GL_ARRAY_BUFFER, 0); // Set GL state glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glEnable(GL_DEPTH_TEST); // Occlude behind buildings glDepthMask(GL_FALSE); glEnable(GL_PROGRAM_POINT_SIZE); glDisable(GL_CULL_FACE); smokeShader->use(); smokeShader->setUniform("uView", view); smokeShader->setUniform("uProjection", projection); // Get viewport height for point size scaling GLint viewport[4]; glGetIntegerv(GL_VIEWPORT, viewport); smokeShader->setUniform("uScreenHeight", static_cast(viewport[3])); glBindVertexArray(smokeVAO); glDrawArrays(GL_POINTS, 0, static_cast(smokeParticles.size())); glBindVertexArray(0); // Restore state glEnable(GL_DEPTH_TEST); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glDepthMask(GL_TRUE); glDisable(GL_PROGRAM_POINT_SIZE); glDisable(GL_BLEND); glEnable(GL_CULL_FACE); } void M2Renderer::setInstancePosition(uint32_t instanceId, const glm::vec3& position) { auto idxIt = instanceIndexById.find(instanceId); if (idxIt == instanceIndexById.end()) return; auto& inst = instances[idxIt->second]; inst.position = position; inst.updateModelMatrix(); auto modelIt = models.find(inst.modelId); if (modelIt != models.end()) { glm::vec3 localMin, localMax; getTightCollisionBounds(modelIt->second, localMin, localMax); transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); } spatialIndexDirty_ = true; } void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { auto idxIt = instanceIndexById.find(instanceId); if (idxIt == instanceIndexById.end()) return; auto& inst = instances[idxIt->second]; // Update model matrix directly inst.modelMatrix = transform; inst.invModelMatrix = glm::inverse(transform); // Extract position from transform for bounds inst.position = glm::vec3(transform[3]); // Update bounds auto modelIt = models.find(inst.modelId); if (modelIt != models.end()) { glm::vec3 localMin, localMax; getTightCollisionBounds(modelIt->second, localMin, localMax); transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); } spatialIndexDirty_ = true; } void M2Renderer::removeInstance(uint32_t instanceId) { for (auto it = instances.begin(); it != instances.end(); ++it) { if (it->id == instanceId) { instances.erase(it); rebuildSpatialIndex(); return; } } } void M2Renderer::removeInstances(const std::vector& instanceIds) { if (instanceIds.empty() || instances.empty()) { return; } std::unordered_set toRemove(instanceIds.begin(), instanceIds.end()); const size_t oldSize = instances.size(); instances.erase(std::remove_if(instances.begin(), instances.end(), [&toRemove](const M2Instance& inst) { return toRemove.find(inst.id) != toRemove.end(); }), instances.end()); if (instances.size() != oldSize) { rebuildSpatialIndex(); } } void M2Renderer::clear() { for (auto& [id, model] : models) { if (model.vao != 0) glDeleteVertexArrays(1, &model.vao); if (model.vbo != 0) glDeleteBuffers(1, &model.vbo); if (model.ebo != 0) glDeleteBuffers(1, &model.ebo); } models.clear(); instances.clear(); spatialGrid.clear(); instanceIndexById.clear(); smokeParticles.clear(); smokeEmitAccum = 0.0f; } void M2Renderer::setCollisionFocus(const glm::vec3& worldPos, float radius) { collisionFocusEnabled = (radius > 0.0f); collisionFocusPos = worldPos; collisionFocusRadius = std::max(0.0f, radius); collisionFocusRadiusSq = collisionFocusRadius * collisionFocusRadius; } void M2Renderer::clearCollisionFocus() { collisionFocusEnabled = false; } void M2Renderer::resetQueryStats() { queryTimeMs = 0.0; queryCallCount = 0; } M2Renderer::GridCell M2Renderer::toCell(const glm::vec3& p) const { return GridCell{ static_cast(std::floor(p.x / SPATIAL_CELL_SIZE)), static_cast(std::floor(p.y / SPATIAL_CELL_SIZE)), static_cast(std::floor(p.z / SPATIAL_CELL_SIZE)) }; } void M2Renderer::rebuildSpatialIndex() { spatialGrid.clear(); instanceIndexById.clear(); instanceIndexById.reserve(instances.size()); for (size_t i = 0; i < instances.size(); i++) { const auto& inst = instances[i]; instanceIndexById[inst.id] = i; GridCell minCell = toCell(inst.worldBoundsMin); GridCell maxCell = toCell(inst.worldBoundsMax); for (int z = minCell.z; z <= maxCell.z; z++) { for (int y = minCell.y; y <= maxCell.y; y++) { for (int x = minCell.x; x <= maxCell.x; x++) { spatialGrid[GridCell{x, y, z}].push_back(inst.id); } } } } spatialIndexDirty_ = false; } void M2Renderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& queryMax, std::vector& outIndices) const { outIndices.clear(); candidateIdScratch.clear(); GridCell minCell = toCell(queryMin); GridCell maxCell = toCell(queryMax); for (int z = minCell.z; z <= maxCell.z; z++) { for (int y = minCell.y; y <= maxCell.y; y++) { for (int x = minCell.x; x <= maxCell.x; x++) { auto it = spatialGrid.find(GridCell{x, y, z}); if (it == spatialGrid.end()) continue; for (uint32_t id : it->second) { if (!candidateIdScratch.insert(id).second) continue; auto idxIt = instanceIndexById.find(id); if (idxIt != instanceIndexById.end()) { outIndices.push_back(idxIt->second); } } } } } // Safety fallback to preserve collision correctness if the spatial index // misses candidates (e.g. during streaming churn). if (outIndices.empty() && !instances.empty()) { outIndices.reserve(instances.size()); for (size_t i = 0; i < instances.size(); i++) { outIndices.push_back(i); } } } void M2Renderer::cleanupUnusedModels() { // Build set of model IDs that are still referenced by instances std::unordered_set usedModelIds; for (const auto& instance : instances) { usedModelIds.insert(instance.modelId); } // Find and remove models with no instances std::vector toRemove; for (const auto& [id, model] : models) { if (usedModelIds.find(id) == usedModelIds.end()) { toRemove.push_back(id); } } // Delete GPU resources and remove from map for (uint32_t id : toRemove) { auto it = models.find(id); if (it != models.end()) { if (it->second.vao != 0) glDeleteVertexArrays(1, &it->second.vao); if (it->second.vbo != 0) glDeleteBuffers(1, &it->second.vbo); if (it->second.ebo != 0) glDeleteBuffers(1, &it->second.ebo); models.erase(it); } } if (!toRemove.empty()) { LOG_INFO("M2 cleanup: removed ", toRemove.size(), " unused models, ", models.size(), " remaining"); } } 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(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return key; }; std::string key = normalizeKey(path); // Check cache auto it = textureCache.find(key); if (it != textureCache.end()) { it->second.lastUse = ++textureCacheCounter_; return it->second.id; } auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; }; const bool colorKeyBlackHint = containsToken(key, "candle") || containsToken(key, "flame") || containsToken(key, "fire") || containsToken(key, "torch") || containsToken(key, "lamp") || containsToken(key, "lantern") || containsToken(key, "glow") || containsToken(key, "flare") || containsToken(key, "brazier") || containsToken(key, "campfire") || containsToken(key, "bonfire"); // Load BLP texture pipeline::BLPImage blp = assetManager->loadTexture(key); if (!blp.isValid()) { LOG_WARNING("M2: Failed to load texture: ", path); // Don't cache failures — transient StormLib thread contention can // cause reads to fail; next loadModel call will retry. return whiteTexture; } // Track whether the texture actually uses alpha (any pixel with alpha < 255). bool hasAlpha = false; for (size_t i = 3; i < blp.data.size(); i += 4) { if (blp.data[i] != 255) { hasAlpha = true; break; } } GLuint textureID; glGenTextures(1, &textureID); glBindTexture(GL_TEXTURE_2D, textureID); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, blp.width, blp.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, blp.data.data()); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 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(); glBindTexture(GL_TEXTURE_2D, 0); TextureCacheEntry e; e.id = textureID; size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; e.approxBytes = base + (base / 3); e.hasAlpha = hasAlpha; e.colorKeyBlack = colorKeyBlackHint; e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; textureCache[key] = e; textureHasAlphaById_[textureID] = hasAlpha; textureColorKeyBlackById_[textureID] = colorKeyBlackHint; LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); return textureID; } uint32_t M2Renderer::getTotalTriangleCount() const { uint32_t total = 0; for (const auto& instance : instances) { auto it = models.find(instance.modelId); if (it != models.end()) { total += it->second.indexCount / 3; } } return total; } std::optional M2Renderer::getFloorHeight(float glX, float glY, float glZ, float* outNormalZ) const { QueryTimer timer(&queryTimeMs, &queryCallCount); std::optional bestFloor; float bestNormalZ = 1.0f; // Default to flat glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 6.0f); glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 8.0f); gatherCandidates(queryMin, queryMax, candidateScratch); for (size_t idx : candidateScratch) { const auto& instance = instances[idx]; if (collisionFocusEnabled && pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { continue; } auto it = models.find(instance.modelId); if (it == models.end()) continue; if (instance.scale <= 0.001f) continue; const M2ModelGPU& model = it->second; if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; // --- Mesh-based floor: vertical ray vs collision triangles --- // Does NOT skip the AABB path — both contribute and highest wins. if (model.collision.valid()) { glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); model.collision.getFloorTrisInRange( localPos.x - 1.0f, localPos.y - 1.0f, localPos.x + 1.0f, localPos.y + 1.0f, collisionTriScratch_); glm::vec3 rayOrigin(localPos.x, localPos.y, localPos.z + 5.0f); glm::vec3 rayDir(0.0f, 0.0f, -1.0f); float bestHitZ = -std::numeric_limits::max(); bool hitAny = false; for (uint32_t ti : collisionTriScratch_) { if (ti >= model.collision.triCount) continue; if (model.collision.triBounds[ti].maxZ < localPos.z - 10.0f || model.collision.triBounds[ti].minZ > localPos.z + 5.0f) continue; const auto& verts = model.collision.vertices; const auto& idx = model.collision.indices; const auto& v0 = verts[idx[ti * 3]]; const auto& v1 = verts[idx[ti * 3 + 1]]; const auto& v2 = verts[idx[ti * 3 + 2]]; // Two-sided: try both windings float tHit = rayTriangleIntersect(rayOrigin, rayDir, v0, v1, v2); if (tHit < 0.0f) tHit = rayTriangleIntersect(rayOrigin, rayDir, v0, v2, v1); if (tHit < 0.0f) continue; float hitZ = rayOrigin.z - tHit; // Walkable normal check (world space) glm::vec3 worldN(0.0f, 0.0f, 1.0f); // Default to flat glm::vec3 localN = glm::cross(v1 - v0, v2 - v0); float nLen = glm::length(localN); if (nLen > 0.001f) { localN /= nLen; if (localN.z < 0.0f) localN = -localN; worldN = glm::normalize( glm::vec3(instance.modelMatrix * glm::vec4(localN, 0.0f))); if (std::abs(worldN.z) < 0.35f) continue; // too steep (~70° max slope) } if (hitZ <= localPos.z + 3.0f && hitZ > bestHitZ) { bestHitZ = hitZ; hitAny = true; bestNormalZ = std::abs(worldN.z); // Store normal for output } } if (hitAny) { glm::vec3 localHit(localPos.x, localPos.y, bestHitZ); glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); if (worldHit.z <= glZ + 3.0f && (!bestFloor || worldHit.z > *bestFloor)) { bestFloor = worldHit.z; } } // Fall through to AABB floor — both contribute, highest wins } float zMargin = model.collisionBridge ? 25.0f : 2.0f; if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x || glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || glZ < instance.worldBoundsMin.z - zMargin || glZ > instance.worldBoundsMax.z + zMargin) { continue; } glm::vec3 localMin, localMax; getTightCollisionBounds(model, localMin, localMax); glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); // Must be within doodad footprint in local XY. // Stepped low platforms get a small pad so walk-up snapping catches edges. float footprintPad = 0.0f; if (model.collisionSteppedLowPlatform) { footprintPad = model.collisionPlanter ? 0.22f : 0.16f; if (model.collisionBridge) { footprintPad = 0.35f; } } if (localPos.x < localMin.x - footprintPad || localPos.x > localMax.x + footprintPad || localPos.y < localMin.y - footprintPad || localPos.y > localMax.y + footprintPad) { continue; } // Construct "top" point at queried XY in local space, then transform back. float localTopZ = getEffectiveCollisionTopLocal(model, localPos, localMin, localMax); glm::vec3 localTop(localPos.x, localPos.y, localTopZ); glm::vec3 worldTop = glm::vec3(instance.modelMatrix * glm::vec4(localTop, 1.0f)); // Reachability filter: allow a bit more climb for stepped low platforms. float maxStepUp = 1.0f; if (model.collisionStatue) { maxStepUp = 2.5f; } else if (model.collisionSmallSolidProp) { maxStepUp = 2.0f; } else if (model.collisionSteppedFountain) { maxStepUp = 2.5f; } else if (model.collisionSteppedLowPlatform) { maxStepUp = model.collisionPlanter ? 3.0f : 2.4f; if (model.collisionBridge) { maxStepUp = 25.0f; } } if (worldTop.z > glZ + maxStepUp) continue; if (!bestFloor || worldTop.z > *bestFloor) { bestFloor = worldTop.z; } } // Output surface normal if requested if (outNormalZ) { *outNormalZ = bestNormalZ; } return bestFloor; } bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to, glm::vec3& adjustedPos, float playerRadius) const { QueryTimer timer(&queryTimeMs, &queryCallCount); adjustedPos = to; bool collided = false; glm::vec3 queryMin = glm::min(from, to) - glm::vec3(7.0f, 7.0f, 5.0f); glm::vec3 queryMax = glm::max(from, to) + glm::vec3(7.0f, 7.0f, 5.0f); gatherCandidates(queryMin, queryMax, candidateScratch); // Check against all M2 instances in local space (rotation-aware). for (size_t idx : candidateScratch) { const auto& instance = instances[idx]; if (collisionFocusEnabled && pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { continue; } const float broadMargin = playerRadius + 1.0f; if (from.x < instance.worldBoundsMin.x - broadMargin && adjustedPos.x < instance.worldBoundsMin.x - broadMargin) continue; if (from.x > instance.worldBoundsMax.x + broadMargin && adjustedPos.x > instance.worldBoundsMax.x + broadMargin) continue; if (from.y < instance.worldBoundsMin.y - broadMargin && adjustedPos.y < instance.worldBoundsMin.y - broadMargin) continue; if (from.y > instance.worldBoundsMax.y + broadMargin && adjustedPos.y > instance.worldBoundsMax.y + broadMargin) continue; if (from.z > instance.worldBoundsMax.z + 2.5f && adjustedPos.z > instance.worldBoundsMax.z + 2.5f) continue; if (from.z + 2.5f < instance.worldBoundsMin.z && adjustedPos.z + 2.5f < instance.worldBoundsMin.z) continue; auto it = models.find(instance.modelId); if (it == models.end()) continue; const M2ModelGPU& model = it->second; if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; if (instance.scale <= 0.001f) continue; // --- Mesh-based wall collision: closest-point push --- if (model.collision.valid()) { glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); float localRadius = playerRadius / instance.scale; model.collision.getWallTrisInRange( std::min(localFrom.x, localPos.x) - localRadius - 1.0f, std::min(localFrom.y, localPos.y) - localRadius - 1.0f, std::max(localFrom.x, localPos.x) + localRadius + 1.0f, std::max(localFrom.y, localPos.y) + localRadius + 1.0f, collisionTriScratch_); constexpr float PLAYER_HEIGHT = 2.0f; constexpr float MAX_TOTAL_PUSH = 0.02f; // Cap total push per instance bool pushed = false; float totalPushX = 0.0f, totalPushY = 0.0f; for (uint32_t ti : collisionTriScratch_) { if (ti >= model.collision.triCount) continue; if (localPos.z + PLAYER_HEIGHT < model.collision.triBounds[ti].minZ || localPos.z > model.collision.triBounds[ti].maxZ) continue; // Step-up: only skip wall when player is rising (jumping over it) constexpr float MAX_STEP_UP = 1.2f; bool rising = (localPos.z > localFrom.z + 0.05f); if (rising && localPos.z + MAX_STEP_UP >= model.collision.triBounds[ti].maxZ) continue; // Early out if we already pushed enough this instance float totalPushSoFar = std::sqrt(totalPushX * totalPushX + totalPushY * totalPushY); if (totalPushSoFar >= MAX_TOTAL_PUSH) break; const auto& verts = model.collision.vertices; const auto& idx = model.collision.indices; const auto& v0 = verts[idx[ti * 3]]; const auto& v1 = verts[idx[ti * 3 + 1]]; const auto& v2 = verts[idx[ti * 3 + 2]]; glm::vec3 closest = closestPointOnTriangle(localPos, v0, v1, v2); glm::vec3 diff = localPos - closest; float distXY = std::sqrt(diff.x * diff.x + diff.y * diff.y); if (distXY < localRadius && distXY > 1e-4f) { // Gentle push — very small fraction of penetration float penetration = localRadius - distXY; float pushDist = std::clamp(penetration * 0.08f, 0.001f, 0.015f); float dx = (diff.x / distXY) * pushDist; float dy = (diff.y / distXY) * pushDist; localPos.x += dx; localPos.y += dy; totalPushX += dx; totalPushY += dy; pushed = true; } else if (distXY < 1e-4f) { // On the plane — soft push along triangle normal XY glm::vec3 n = glm::cross(v1 - v0, v2 - v0); float nxyLen = std::sqrt(n.x * n.x + n.y * n.y); if (nxyLen > 1e-4f) { float pushDist = std::min(localRadius, 0.015f); float dx = (n.x / nxyLen) * pushDist; float dy = (n.y / nxyLen) * pushDist; localPos.x += dx; localPos.y += dy; totalPushX += dx; totalPushY += dy; pushed = true; } } } if (pushed) { glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(localPos, 1.0f)); adjustedPos.x = worldPos.x; adjustedPos.y = worldPos.y; collided = true; } continue; } glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); float radiusScale = model.collisionNarrowVerticalProp ? 0.45f : 1.0f; float localRadius = (playerRadius * radiusScale) / instance.scale; glm::vec3 rawMin, rawMax; getTightCollisionBounds(model, rawMin, rawMax); glm::vec3 localMin = rawMin - glm::vec3(localRadius); glm::vec3 localMax = rawMax + glm::vec3(localRadius); float effectiveTop = getEffectiveCollisionTopLocal(model, localPos, rawMin, rawMax) + localRadius; glm::vec2 localCenter((localMin.x + localMax.x) * 0.5f, (localMin.y + localMax.y) * 0.5f); float fromR = glm::length(glm::vec2(localFrom.x, localFrom.y) - localCenter); float toR = glm::length(glm::vec2(localPos.x, localPos.y) - localCenter); // Feet-based vertical overlap test: ignore objects fully above/below us. constexpr float PLAYER_HEIGHT = 2.0f; if (localPos.z + PLAYER_HEIGHT < localMin.z || localPos.z > effectiveTop) { continue; } bool fromInsideXY = (localFrom.x >= localMin.x && localFrom.x <= localMax.x && localFrom.y >= localMin.y && localFrom.y <= localMax.y); bool fromInsideZ = (localFrom.z + PLAYER_HEIGHT >= localMin.z && localFrom.z <= effectiveTop); bool escapingOverlap = (fromInsideXY && fromInsideZ && (toR > fromR + 1e-4f)); bool allowEscapeRelax = escapingOverlap && !model.collisionSmallSolidProp; // Swept hard clamp for taller blockers only. // Low/stepable objects should be climbable and not "shove" the player off. float maxStepUp = 1.20f; if (model.collisionStatue) { maxStepUp = 2.5f; } else if (model.collisionSmallSolidProp) { // Keep box/crate-class props hard-solid to prevent phase-through. maxStepUp = 0.75f; } else if (model.collisionSteppedFountain) { maxStepUp = 2.5f; } else if (model.collisionSteppedLowPlatform) { maxStepUp = model.collisionPlanter ? 2.8f : 2.4f; if (model.collisionBridge) { maxStepUp = 25.0f; } } bool stepableLowObject = (effectiveTop <= localFrom.z + maxStepUp); bool climbingAttempt = (localPos.z > localFrom.z + 0.18f); bool nearTop = (localFrom.z >= effectiveTop - 0.30f); float climbAllowance = model.collisionPlanter ? 0.95f : 0.60f; if (model.collisionSteppedLowPlatform && !model.collisionPlanter) { // Let low curb/planter blocks be stepable without sticky side shoves. climbAllowance = 1.00f; } if (model.collisionBridge) { climbAllowance = 3.0f; } if (model.collisionSmallSolidProp) { climbAllowance = 1.05f; } bool climbingTowardTop = climbingAttempt && (localFrom.z + climbAllowance >= effectiveTop); bool forceHardLateral = model.collisionSmallSolidProp && !nearTop && !climbingTowardTop; if ((!stepableLowObject || forceHardLateral) && !allowEscapeRelax) { float tEnter = 0.0f; glm::vec3 sweepMax = localMax; sweepMax.z = std::min(sweepMax.z, effectiveTop); if (segmentIntersectsAABB(localFrom, localPos, localMin, sweepMax, tEnter)) { float tSafe = std::clamp(tEnter - 0.03f, 0.0f, 1.0f); glm::vec3 localSafe = localFrom + (localPos - localFrom) * tSafe; glm::vec3 worldSafe = glm::vec3(instance.modelMatrix * glm::vec4(localSafe, 1.0f)); adjustedPos.x = worldSafe.x; adjustedPos.y = worldSafe.y; collided = true; continue; } } if (localPos.x < localMin.x || localPos.x > localMax.x || localPos.y < localMin.y || localPos.y > localMax.y) { continue; } float pushLeft = localPos.x - localMin.x; float pushRight = localMax.x - localPos.x; float pushBack = localPos.y - localMin.y; float pushFront = localMax.y - localPos.y; float minPush = std::min({pushLeft, pushRight, pushBack, pushFront}); if (allowEscapeRelax) { continue; } if (stepableLowObject && localFrom.z >= effectiveTop - 0.35f) { // Already on/near top surface: don't apply lateral push that ejects // the player from the object (carpets, platforms, etc). continue; } // Gentle fallback push for overlapping cases. float pushAmount; if (model.collisionNarrowVerticalProp) { pushAmount = std::clamp(minPush * 0.10f, 0.001f, 0.010f); } else if (model.collisionSteppedLowPlatform) { if (model.collisionPlanter && stepableLowObject) { pushAmount = std::clamp(minPush * 0.06f, 0.001f, 0.006f); } else { pushAmount = std::clamp(minPush * 0.12f, 0.003f, 0.012f); } } else if (stepableLowObject) { pushAmount = std::clamp(minPush * 0.12f, 0.002f, 0.015f); } else { pushAmount = std::clamp(minPush * 0.28f, 0.010f, 0.045f); } glm::vec3 localPush(0.0f); if (minPush == pushLeft) { localPush.x = -pushAmount; } else if (minPush == pushRight) { localPush.x = pushAmount; } else if (minPush == pushBack) { localPush.y = -pushAmount; } else { localPush.y = pushAmount; } glm::vec3 worldPush = glm::vec3(instance.modelMatrix * glm::vec4(localPush, 0.0f)); adjustedPos.x += worldPush.x; adjustedPos.y += worldPush.y; collided = true; } return collided; } float M2Renderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& direction, float maxDistance) const { QueryTimer timer(&queryTimeMs, &queryCallCount); float closestHit = maxDistance; glm::vec3 rayEnd = origin + direction * maxDistance; glm::vec3 queryMin = glm::min(origin, rayEnd) - glm::vec3(1.0f); glm::vec3 queryMax = glm::max(origin, rayEnd) + glm::vec3(1.0f); gatherCandidates(queryMin, queryMax, candidateScratch); for (size_t idx : candidateScratch) { const auto& instance = instances[idx]; if (collisionFocusEnabled && pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { continue; } // Cheap world-space broad-phase. float tEnter = 0.0f; glm::vec3 worldMin = instance.worldBoundsMin - glm::vec3(0.35f); glm::vec3 worldMax = instance.worldBoundsMax + glm::vec3(0.35f); if (!segmentIntersectsAABB(origin, origin + direction * maxDistance, worldMin, worldMax, tEnter)) { continue; } auto it = models.find(instance.modelId); if (it == models.end()) continue; const M2ModelGPU& model = it->second; if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; glm::vec3 localMin, localMax; getTightCollisionBounds(model, localMin, localMax); // Skip tiny doodads for camera occlusion; they cause jitter and false hits. glm::vec3 extents = (localMax - localMin) * instance.scale; if (glm::length(extents) < 0.75f) continue; glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(origin, 1.0f)); glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(direction, 0.0f))); if (!std::isfinite(localDir.x) || !std::isfinite(localDir.y) || !std::isfinite(localDir.z)) { continue; } // Local-space AABB slab intersection. glm::vec3 invDir = 1.0f / localDir; glm::vec3 tMin = (localMin - localOrigin) * invDir; glm::vec3 tMax = (localMax - localOrigin) * invDir; glm::vec3 t1 = glm::min(tMin, tMax); glm::vec3 t2 = glm::max(tMin, tMax); float tNear = std::max({t1.x, t1.y, t1.z}); float tFar = std::min({t2.x, t2.y, t2.z}); if (tNear > tFar || tFar <= 0.0f) continue; float tHit = tNear > 0.0f ? tNear : tFar; glm::vec3 localHit = localOrigin + localDir * tHit; glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); float worldDist = glm::length(worldHit - origin); if (worldDist > 0.0f && worldDist < closestHit) { closestHit = worldDist; } } return closestHit; } } // namespace rendering } // namespace wowee