diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 69653f04..c122fd2e 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -181,8 +181,10 @@ public: /** * Update animation state for all instances * @param deltaTime Time since last frame + * @param cameraPos Camera world position (for frustum-culling bones) + * @param viewProjection Combined view*projection matrix */ - void update(float deltaTime); + void update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection); /** * Render all visible instances @@ -355,6 +357,10 @@ private: static constexpr size_t MAX_M2_PARTICLES = 4000; std::mt19937 particleRng_{123}; + // Cached camera state from update() for frustum-culling bones + glm::vec3 cachedCamPos_ = glm::vec3(0.0f); + float cachedMaxRenderDistSq_ = 0.0f; + // Thread count for parallel bone animation uint32_t numAnimThreads_ = 1; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index d95dd0a0..dd2deba7 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1148,9 +1148,18 @@ static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { } } -void M2Renderer::update(float deltaTime) { +void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { float dtMs = deltaTime * 1000.0f; + // Cache camera state for frustum-culling bone computation + cachedCamPos_ = cameraPos; + const float maxRenderDistance = (instances.size() > 600) ? 320.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); @@ -1276,6 +1285,22 @@ void M2Renderer::update(float deltaTime) { } } + // Frustum + distance cull: skip expensive bone computation for off-screen instances + float worldRadius = model.boundRadius * instance.scale; + float cullRadius = worldRadius; + 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) { + if (worldRadius < 0.8f) { + effectiveMaxDistSq = std::min(effectiveMaxDistSq, 95.0f * 95.0f); + } else if (worldRadius < 1.5f) { + effectiveMaxDistSq = std::min(effectiveMaxDistSq, 140.0f * 140.0f); + } + } + if (distSq > effectiveMaxDistSq) continue; + if (cullRadius > 0.0f && !updateFrustum.intersectsSphere(instance.position, cullRadius)) continue; + boneWorkIndices.push_back(idx); } @@ -1389,29 +1414,32 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: const float fadeStartFraction = 0.75f; const glm::vec3 camPos = camera.getPosition(); - for (const auto& instance : instances) { + // Build sorted visible instance list: cull then sort by modelId to batch VAO binds + struct VisibleEntry { + uint32_t index; + uint32_t modelId; + float distSq; + float effectiveMaxDistSq; + }; + std::vector sortedVisible; + sortedVisible.reserve(instances.size() / 2); + + for (uint32_t i = 0; i < static_cast(instances.size()); ++i) { + const auto& instance = instances[i]; auto it = models.find(instance.modelId); if (it == models.end()) continue; - const M2ModelGPU& model = it->second; - if (!model.isValid()) continue; + if (!model.isValid() || model.isSmoke) continue; - // Skip smoke models — replaced by particle emitters - if (model.isSmoke) continue; - - // Distance culling for small objects (scaled by object size) glm::vec3 toCam = instance.position - camPos; float distSq = glm::dot(toCam, toCam); float worldRadius = model.boundRadius * instance.scale; float cullRadius = worldRadius; if (model.disableAnimation) { - // Many bushes/foliage M2s have conservative tiny bounds; pad to reduce pop-in. cullRadius = std::max(cullRadius, 3.0f); } - // Cull small objects (radius < 20) at distance, keep larger objects visible longer float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); if (model.disableAnimation) { - // Trees/foliage keep a much larger horizon before culling. effectiveMaxDistSq *= 2.6f; } if (!model.disableAnimation) { @@ -1421,24 +1449,39 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: effectiveMaxDistSq = std::min(effectiveMaxDistSq, 140.0f * 140.0f); } } - if (distSq > effectiveMaxDistSq) { - continue; + if (distSq > effectiveMaxDistSq) continue; + if (cullRadius > 0.0f && !frustum.intersectsSphere(instance.position, cullRadius)) continue; + + sortedVisible.push_back({i, instance.modelId, distSq, effectiveMaxDistSq}); + } + + // Sort by modelId to minimize VAO rebinds + std::sort(sortedVisible.begin(), sortedVisible.end(), + [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); + + uint32_t currentModelId = UINT32_MAX; + const M2ModelGPU* currentModel = nullptr; + + for (const auto& entry : sortedVisible) { + const auto& instance = instances[entry.index]; + + // Bind VAO once per model group + if (entry.modelId != currentModelId) { + if (currentModel) glBindVertexArray(0); + currentModelId = entry.modelId; + currentModel = &models.find(currentModelId)->second; + glBindVertexArray(currentModel->vao); } - // Frustum cull: test bounding sphere in world space - if (cullRadius > 0.0f && !frustum.intersectsSphere(instance.position, cullRadius)) { - continue; - } + const M2ModelGPU& model = *currentModel; - // Distance-based fade alpha for smooth pop-in + // 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 = effectiveMaxDistSq * fadeFrac * fadeFrac; - if (distSq > fadeStartDistSq) { - float dist = std::sqrt(distSq); - float effectiveMaxDist = std::sqrt(effectiveMaxDistSq); - float fadeStartDist = effectiveMaxDist * fadeFrac; - fadeAlpha = std::clamp((effectiveMaxDist - dist) / (effectiveMaxDist - fadeStartDist), 0.0f, 1.0f); + float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac; + if (entry.distSq > fadeStartDistSq) { + fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) / + (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); } shader->setUniform("uModel", instance.modelMatrix); @@ -1457,15 +1500,13 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: glDepthMask(GL_FALSE); } - glBindVertexArray(model.vao); - for (const auto& batch : model.batches) { if (batch.indexCount == 0) continue; // Additive/mod batches (glow halos, light effects): collect as glow sprites // instead of rendering the mesh geometry which appears as flat orange disks. if (batch.blendMode >= 3) { - if (distSq < 120.0f * 120.0f) { // Only render glow within 120 units + if (entry.distSq < 120.0f * 120.0f) { // Only render glow within 120 units glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(batch.center, 1.0f)); GlowSprite gs; gs.worldPos = worldPos; @@ -1562,13 +1603,13 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: lastDrawCallCount++; } - glBindVertexArray(0); - if (fadeAlpha < 1.0f) { glDepthMask(GL_TRUE); } } + if (currentModel) glBindVertexArray(0); + // Render glow sprites as billboarded additive point lights if (!glowSprites.empty() && m2ParticleShader_ != 0 && m2ParticleVAO_ != 0) { glUseProgram(m2ParticleShader_); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 6ce25d8a..14f9b79e 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -999,9 +999,10 @@ void Renderer::update(float deltaTime) { } } - // Update M2 doodad animations - if (m2Renderer) { - m2Renderer->update(deltaTime); + // Update M2 doodad animations (pass camera for frustum-culling bone computation) + if (m2Renderer && camera) { + m2Renderer->update(deltaTime, camera->getPosition(), + camera->getProjectionMatrix() * camera->getViewMatrix()); } // Update zone detection and music