From 1003b25ff486f5ced020697c11974b9ba56d6cb5 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 21 Feb 2026 01:26:16 -0800 Subject: [PATCH] Improve runtime stutter handling and ground clutter performance - reduce per-tile ground clutter generation pressure and enforce tighter caps to avoid spikes - remove expensive detail dedupe scans from the hot render path - add progressive/lazy clutter updates around player movement to smooth frame pacing - lower noisy runtime INFO logging to DEBUG/throttled paths - keep terrain/game screen updates responsive while preserving existing behavior --- include/rendering/m2_renderer.hpp | 1 + include/rendering/terrain_manager.hpp | 16 + include/ui/game_screen.hpp | 1 + src/core/application.cpp | 13 + src/game/world_packets.cpp | 20 +- src/pipeline/asset_manager.cpp | 4 +- src/pipeline/m2_loader.cpp | 11 +- src/rendering/character_renderer.cpp | 2 +- src/rendering/m2_renderer.cpp | 101 +++- src/rendering/terrain_manager.cpp | 640 ++++++++++++++++++++++---- src/ui/game_screen.cpp | 21 + 11 files changed, 714 insertions(+), 116 deletions(-) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 097bfb7a..c0b59a60 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -65,6 +65,7 @@ struct M2ModelGPU { bool collisionStatue = false; bool isSmallFoliage = false; // Small foliage (bushes, grass, plants) - skip during taxi bool isInvisibleTrap = false; // Invisible trap objects (don't render, no collision) + bool isGroundDetail = false; // Ground clutter/detail doodads (special fallback render path) // Collision mesh with spatial grid (from M2 bounding geometry) struct CollisionMesh { diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 4c531f8f..7d4d21b7 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace wowee { @@ -191,6 +192,8 @@ public: void setStreamingEnabled(bool enabled) { streamingEnabled = enabled; } void setUpdateInterval(float seconds) { updateInterval = seconds; } void setTaxiStreamingMode(bool enabled) { taxiStreamingMode_ = enabled; } + void setGroundClutterDensityScale(float scale) { groundClutterDensityScale_ = glm::clamp(scale, 0.0f, 1.5f); } + float getGroundClutterDensityScale() const { return groundClutterDensityScale_; } void setWaterRenderer(WaterRenderer* renderer) { waterRenderer = renderer; } void setM2Renderer(M2Renderer* renderer) { m2Renderer = renderer; } void setWMORenderer(WMORenderer* renderer) { wmoRenderer = renderer; } @@ -264,6 +267,9 @@ private: * Main thread: poll for completed tiles and upload to GPU */ void processReadyTiles(); + void ensureGroundEffectTablesLoaded(); + void generateGroundClutterPlacements(std::shared_ptr& pending, + std::unordered_set& preparedModelIds); pipeline::AssetManager* assetManager = nullptr; TerrainRenderer* terrainRenderer = nullptr; @@ -345,6 +351,16 @@ private: static constexpr int MAX_M2_UPLOADS_PER_FRAME = 5; // Upload up to 5 models per frame void processM2UploadQueue(); + + struct GroundEffectEntry { + std::array doodadIds{{0, 0, 0, 0}}; + std::array weights{{0, 0, 0, 0}}; + uint32_t density = 0; + }; + bool groundEffectsLoaded_ = false; + std::unordered_map groundEffectById_; // effectId -> config + std::unordered_map groundDoodadModelById_; // doodadId -> model path + float groundClutterDensityScale_ = 1.0f; }; } // namespace rendering diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index dc34f36f..b9af2763 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -101,6 +101,7 @@ private: bool pendingSeparateBags = true; bool pendingAutoLoot = false; bool pendingUseOriginalSoundtrack = true; + int pendingGroundClutterDensity = 100; // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) float uiOpacity_ = 0.65f; diff --git a/src/core/application.cpp b/src/core/application.cpp index 421d3e71..c3b5b758 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2645,6 +2645,19 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan auto entity = gameHandler->getEntityManager().getEntity(guid); if (!entity || entity->getType() != game::ObjectType::UNIT) return false; + auto unit = std::static_pointer_cast(entity); + if (!unit) return false; + + // Virtual weapons are only appropriate for humanoid-style displays. + // Non-humanoids (wolves/boars/etc.) can expose non-zero virtual item fields + // and otherwise end up with comedic floating weapons. + uint32_t displayId = unit->getDisplayId(); + auto dIt = displayDataMap_.find(displayId); + if (dIt == displayDataMap_.end()) return false; + uint32_t extraDisplayId = dIt->second.extraDisplayId; + if (extraDisplayId == 0 || humanoidExtraMap_.find(extraDisplayId) == humanoidExtraMap_.end()) { + return false; + } auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); if (!itemDisplayDbc) return false; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index fca97d4c..39b337c6 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -730,7 +730,7 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u char b[4]; snprintf(b, sizeof(b), "%02x ", raw[i]); hex += b; } - LOG_INFO("MOVEPKT opcode=0x", std::hex, wireOpcode(opcode), std::dec, + LOG_DEBUG("MOVEPKT opcode=0x", std::hex, wireOpcode(opcode), std::dec, " guid=0x", std::hex, playerGuid, std::dec, " payload=", raw.size(), " bytes", " flags=0x", std::hex, info.flags, std::dec, @@ -741,7 +741,7 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u " ONTRANSPORT guid=0x" + std::to_string(info.transportGuid) + " localPos=(" + std::to_string(info.transportX) + "," + std::to_string(info.transportY) + "," + std::to_string(info.transportZ) + ")" : "")); - LOG_INFO("MOVEPKT hex: ", hex); + LOG_DEBUG("MOVEPKT hex: ", hex); } return packet; @@ -780,11 +780,17 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Log transport-related flag combinations if (updateFlags & 0x0002) { // UPDATEFLAG_TRANSPORT - LOG_INFO(" Transport flags detected: 0x", std::hex, updateFlags, std::dec, - " (TRANSPORT=", !!(updateFlags & 0x0002), - ", POSITION=", !!(updateFlags & 0x0100), - ", ROTATION=", !!(updateFlags & 0x0200), - ", STATIONARY=", !!(updateFlags & 0x0040), ")"); + static int transportFlagLogCount = 0; + if (transportFlagLogCount < 12) { + LOG_INFO(" Transport flags detected: 0x", std::hex, updateFlags, std::dec, + " (TRANSPORT=", !!(updateFlags & 0x0002), + ", POSITION=", !!(updateFlags & 0x0100), + ", ROTATION=", !!(updateFlags & 0x0200), + ", STATIONARY=", !!(updateFlags & 0x0040), ")"); + transportFlagLogCount++; + } else { + LOG_DEBUG(" Transport flags detected: 0x", std::hex, updateFlags, std::dec); + } } // UpdateFlags bit meanings: diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 59c2cc00..85c85730 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -222,7 +222,9 @@ std::shared_ptr AssetManager::loadDBC(const std::string& name) { (name == "CreatureDisplayInfo.dbc" || name == "CreatureDisplayInfoExtra.dbc" || name == "ItemDisplayInfo.dbc" || - name == "CreatureModelData.dbc"); + name == "CreatureModelData.dbc" || + name == "GroundEffectTexture.dbc" || + name == "GroundEffectDoodad.dbc"); // Try expansion-specific CSV first (e.g. Data/expansions/wotlk/db/Spell.csv) bool loadedFromCSV = false; diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index 16e890a7..fc4cf6ae 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -368,12 +368,15 @@ std::string readString(const std::vector& data, uint32_t offset, uint32 return ""; } - // Strip trailing null bytes (M2 nameLength includes \0) - while (length > 0 && data[offset + length - 1] == 0) { - length--; + // M2 string blocks are C-strings. Some extracted files have a valid + // string terminated early with embedded NUL and garbage bytes after it. + // Respect first NUL within the declared length. + uint32_t actualLen = 0; + while (actualLen < length && data[offset + actualLen] != 0) { + actualLen++; } - return std::string(reinterpret_cast(&data[offset]), length); + return std::string(reinterpret_cast(&data[offset]), actualLen); } enum class TrackType { VEC3, QUAT_COMPRESSED, FLOAT }; diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index c431da9b..8032832a 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -2156,7 +2156,7 @@ bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmen wa.offset = offset; charInstance.weaponAttachments.push_back(wa); - core::Logger::getInstance().info("Attached weapon model ", weaponModelId, + core::Logger::getInstance().debug("Attached weapon model ", weaponModelId, " to instance ", charInstanceId, " at attachment ", attachmentId, " (bone ", boneIndex, ", offset ", offset.x, ",", offset.y, ",", offset.z, ")"); return true; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 9b01c724..8885ad81 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -38,6 +38,14 @@ bool envFlagEnabled(const char* key, bool defaultValue) { 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; @@ -874,6 +882,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } bool foliageOrTreeLike = false; bool chestName = false; + bool groundDetailModel = false; { std::string lowerName = model.name; std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), @@ -969,6 +978,9 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (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) || @@ -1038,6 +1050,11 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } gpuModel.disableAnimation = foliageOrTreeLike || chestName; + 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; @@ -1133,14 +1150,21 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { if (assetManager) { for (size_t ti = 0; ti < model.textures.size(); ti++) { const auto& tex = model.textures[ti]; - if (!tex.filename.empty()) { - GLuint texId = loadTexture(tex.filename, tex.flags); + 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: ", tex.filename); + LOG_WARNING("M2 model ", model.name, " texture[", ti, "] failed to load: ", texPath); } if (isInvisibleTrap) { - LOG_INFO(" InvisibleTrap texture[", ti, "]: ", tex.filename, " -> ", (failed ? "WHITE" : "OK")); + LOG_INFO(" InvisibleTrap texture[", ti, "]: ", texPath, " -> ", (failed ? "WHITE" : "OK")); } allTextures.push_back(texId); textureLoadFailed.push_back(failed); @@ -1207,6 +1231,15 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { 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) { @@ -1228,7 +1261,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // 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. - bgpu.batchOpacity = texFailed ? 0.0f : 1.0f; + // 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) { @@ -1301,7 +1335,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // 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 && gpuModel.vertexCount <= 24 && gpuModel.indexCount <= 36 + 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}); @@ -1323,17 +1358,23 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, const glm::vec3& rotation, float scale) { - if (models.find(modelId) == models.end()) { + 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; - // 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; + // 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; + } } } } @@ -1342,15 +1383,18 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, 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(models[modelId], 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 = models[modelId]; + const auto& mdl = mdlRef; if (mdl.hasAnimation && !mdl.disableAnimation && !mdl.sequences.empty()) { instance.currentSequenceIndex = 0; instance.idleSequenceIndex = 0; @@ -1876,6 +1920,10 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: 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; @@ -1961,8 +2009,12 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: } // 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", fadeAlpha); + shader->setUniform("uFadeAlpha", instanceFadeAlpha); // Track interior darken state to avoid redundant updates if (insideInterior != lastInteriorDarken) { @@ -1983,7 +2035,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: } // Disable depth writes for fading objects to avoid z-fighting - if (fadeAlpha < 1.0f) { + if (instanceFadeAlpha < 1.0f) { if (depthMaskState) { glDepthMask(GL_FALSE); depthMaskState = false; @@ -2032,7 +2084,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: if (batch.indexCount == 0) continue; // Skip batches that don't match target LOD level - if (batch.submeshLevel != targetLOD) continue; + 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; @@ -2095,6 +2147,10 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: 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 @@ -2135,7 +2191,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: } // Disable depth writes for transparent/additive batches - if (batchTransparent && fadeAlpha >= 1.0f) { + if (batchTransparent && instanceFadeAlpha >= 1.0f) { if (depthMaskState) { glDepthMask(GL_FALSE); depthMaskState = false; @@ -2144,6 +2200,10 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: // 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; @@ -2158,6 +2218,9 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm:: bool alphaTest = (effectiveBlendMode == 1) || (effectiveBlendMode >= 2 && !batch.hasAlpha); + if (model.isGroundDetail) { + alphaTest = false; + } if (alphaTest != lastAlphaTest) { shader->setUniform("uAlphaTest", alphaTest); lastAlphaTest = alphaTest; diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index c538e882..91f04b54 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -82,6 +82,12 @@ bool decodeLayerAlpha(const pipeline::MapChunk& chunk, size_t layerIdx, std::vec return false; } +std::string toLowerCopy(std::string v) { + std::transform(v.begin(), v.end(), v.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return v; +} + } // namespace TerrainManager::TerrainManager() { @@ -255,6 +261,63 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { return nullptr; } + // WotLK split ADTs can store placements in *_obj0.adt. + // Merge object chunks so doodads/WMOs (including ground clutter) are available. + std::string objPath = "World\\Maps\\" + mapName + "\\" + mapName + "_" + + std::to_string(coord.x) + "_" + std::to_string(coord.y) + "_obj0.adt"; + auto objData = assetManager->readFile(objPath); + if (!objData.empty()) { + pipeline::ADTTerrain objTerrain = pipeline::ADTLoader::load(objData); + if (objTerrain.isLoaded()) { + const uint32_t doodadNameBase = static_cast(terrain.doodadNames.size()); + const uint32_t wmoNameBase = static_cast(terrain.wmoNames.size()); + + terrain.doodadNames.insert(terrain.doodadNames.end(), + objTerrain.doodadNames.begin(), objTerrain.doodadNames.end()); + terrain.wmoNames.insert(terrain.wmoNames.end(), + objTerrain.wmoNames.begin(), objTerrain.wmoNames.end()); + + std::unordered_set existingDoodadUniqueIds; + existingDoodadUniqueIds.reserve(terrain.doodadPlacements.size()); + for (const auto& p : terrain.doodadPlacements) { + if (p.uniqueId != 0) existingDoodadUniqueIds.insert(p.uniqueId); + } + + size_t mergedDoodads = 0; + for (auto placement : objTerrain.doodadPlacements) { + if (placement.nameId >= objTerrain.doodadNames.size()) continue; + placement.nameId += doodadNameBase; + if (placement.uniqueId != 0 && !existingDoodadUniqueIds.insert(placement.uniqueId).second) { + continue; + } + terrain.doodadPlacements.push_back(placement); + mergedDoodads++; + } + + std::unordered_set existingWmoUniqueIds; + existingWmoUniqueIds.reserve(terrain.wmoPlacements.size()); + for (const auto& p : terrain.wmoPlacements) { + if (p.uniqueId != 0) existingWmoUniqueIds.insert(p.uniqueId); + } + + size_t mergedWmos = 0; + for (auto placement : objTerrain.wmoPlacements) { + if (placement.nameId >= objTerrain.wmoNames.size()) continue; + placement.nameId += wmoNameBase; + if (placement.uniqueId != 0 && !existingWmoUniqueIds.insert(placement.uniqueId).second) { + continue; + } + terrain.wmoPlacements.push_back(placement); + mergedWmos++; + } + + if (mergedDoodads > 0 || mergedWmos > 0) { + LOG_DEBUG("Merged obj0 tile [", x, ",", y, "]: +", mergedDoodads, + " doodads, +", mergedWmos, " WMOs"); + } + } + } + // Set tile coordinates so mesh knows where to position this tile in world terrain.coord.x = x; terrain.coord.y = y; @@ -271,95 +334,99 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { pending->terrain = std::move(terrain); pending->mesh = std::move(mesh); + std::unordered_set preparedModelIds; + auto ensureModelPrepared = [&](const std::string& m2Path, + uint32_t modelId, + int& skippedFileNotFound, + int& skippedInvalid, + int& skippedSkinNotFound) -> bool { + if (preparedModelIds.find(modelId) != preparedModelIds.end()) return true; + + std::vector m2Data = assetManager->readFile(m2Path); + if (m2Data.empty()) { + skippedFileNotFound++; + LOG_WARNING("M2 file not found: ", m2Path); + return false; + } + + pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + if (m2Model.name.empty()) { + m2Model.name = m2Path; + } + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + std::vector skinData = assetManager->readFileOptional(skinPath); + if (!skinData.empty() && m2Model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, m2Model); + } else if (skinData.empty() && m2Model.version >= 264) { + skippedSkinNotFound++; + } + + if (!m2Model.isValid()) { + skippedInvalid++; + LOG_DEBUG("M2 model invalid (no verts/indices): ", m2Path); + return false; + } + + PendingTile::M2Ready ready; + ready.modelId = modelId; + ready.model = std::move(m2Model); + ready.path = m2Path; + pending->m2Models.push_back(std::move(ready)); + preparedModelIds.insert(modelId); + return true; + }; + // Pre-load M2 doodads (CPU: read files, parse models) - if (!pending->terrain.doodadPlacements.empty()) { - std::unordered_set preparedModelIds; + int skippedNameId = 0, skippedFileNotFound = 0, skippedInvalid = 0, skippedSkinNotFound = 0; + for (const auto& placement : pending->terrain.doodadPlacements) { + if (placement.nameId >= pending->terrain.doodadNames.size()) { + skippedNameId++; + continue; + } - int skippedNameId = 0, skippedFileNotFound = 0, skippedInvalid = 0, skippedSkinNotFound = 0; - - for (const auto& placement : pending->terrain.doodadPlacements) { - if (placement.nameId >= pending->terrain.doodadNames.size()) { - skippedNameId++; - continue; - } - - std::string m2Path = pending->terrain.doodadNames[placement.nameId]; - - // Convert .mdx to .m2 if needed - if (m2Path.size() > 4) { - std::string ext = m2Path.substr(m2Path.size() - 4); - for (char& c : ext) c = std::tolower(c); - if (ext == ".mdx") { - m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2"; - } - } - - // Use path hash as globally unique model ID (nameId is per-tile local) - uint32_t modelId = static_cast(std::hash{}(m2Path)); - - // Parse model if not already done for this tile - if (preparedModelIds.find(modelId) == preparedModelIds.end()) { - std::vector m2Data = assetManager->readFile(m2Path); - if (!m2Data.empty()) { - pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); - - // Try to load skin file (only for WotLK M2s - vanilla has embedded skin) - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - std::vector skinData = assetManager->readFile(skinPath); - if (!skinData.empty() && m2Model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, m2Model); - } else if (skinData.empty() && m2Model.version >= 264) { - skippedSkinNotFound++; - LOG_WARNING("M2 skin not found: ", skinPath); - } - - if (m2Model.isValid()) { - PendingTile::M2Ready ready; - ready.modelId = modelId; - ready.model = std::move(m2Model); - ready.path = m2Path; - pending->m2Models.push_back(std::move(ready)); - preparedModelIds.insert(modelId); - } else { - skippedInvalid++; - LOG_DEBUG("M2 model invalid (no verts/indices): ", m2Path); - } - } else { - skippedFileNotFound++; - LOG_WARNING("M2 file not found: ", m2Path); - } - } - - // Store placement data for instance creation on main thread - if (preparedModelIds.count(modelId)) { - float wowX = placement.position[0]; - float wowY = placement.position[1]; - float wowZ = placement.position[2]; - glm::vec3 glPos = core::coords::adtToWorld(wowX, wowY, wowZ); - - PendingTile::M2Placement p; - p.modelId = modelId; - p.uniqueId = placement.uniqueId; - p.position = glPos; - p.rotation = glm::vec3( - -placement.rotation[2] * 3.14159f / 180.0f, - -placement.rotation[0] * 3.14159f / 180.0f, - (placement.rotation[1] + 180.0f) * 3.14159f / 180.0f - ); - p.scale = placement.scale / 1024.0f; - pending->m2Placements.push_back(p); + std::string m2Path = pending->terrain.doodadNames[placement.nameId]; + if (m2Path.size() > 4) { + std::string ext = toLowerCopy(m2Path.substr(m2Path.size() - 4)); + if (ext == ".mdx") { + m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2"; } } - if (skippedNameId > 0 || skippedFileNotFound > 0 || skippedInvalid > 0) { - LOG_DEBUG("Tile [", x, ",", y, "] doodad issues: ", - skippedNameId, " bad nameId, ", - skippedFileNotFound, " file not found, ", - skippedInvalid, " invalid model, ", - skippedSkinNotFound, " skin not found"); + uint32_t modelId = static_cast(std::hash{}(m2Path)); + if (!ensureModelPrepared(m2Path, modelId, skippedFileNotFound, skippedInvalid, skippedSkinNotFound)) { + continue; } + + float wowX = placement.position[0]; + float wowY = placement.position[1]; + float wowZ = placement.position[2]; + glm::vec3 glPos = core::coords::adtToWorld(wowX, wowY, wowZ); + + PendingTile::M2Placement p; + p.modelId = modelId; + p.uniqueId = placement.uniqueId; + p.position = glPos; + p.rotation = glm::vec3( + -placement.rotation[2] * 3.14159f / 180.0f, + -placement.rotation[0] * 3.14159f / 180.0f, + (placement.rotation[1] + 180.0f) * 3.14159f / 180.0f + ); + p.scale = placement.scale / 1024.0f; + pending->m2Placements.push_back(p); } + if (skippedNameId > 0 || skippedFileNotFound > 0 || skippedInvalid > 0 || skippedSkinNotFound > 0) { + LOG_DEBUG("Tile [", x, ",", y, "] doodad issues: ", + skippedNameId, " bad nameId, ", + skippedFileNotFound, " file not found, ", + skippedInvalid, " invalid model, ", + skippedSkinNotFound, " skin not found"); + } + + // Procedural ground clutter from terrain layer effectId -> GroundEffectTexture/Doodad DBCs. + ensureGroundEffectTablesLoaded(); + generateGroundClutterPlacements(pending, preparedModelIds); + // Pre-load WMOs (CPU: read files, parse models and groups) if (!pending->terrain.wmoPlacements.empty()) { for (const auto& placement : pending->terrain.wmoPlacements) { @@ -445,6 +512,9 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { if (m2Data.empty()) continue; pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + if (m2Model.name.empty()) { + m2Model.name = m2Path; + } std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; std::vector skinData = assetManager->readFile(skinPath); if (!skinData.empty() && m2Model.version >= 264) { @@ -675,15 +745,17 @@ void TerrainManager::finalizeTile(const std::shared_ptr& pending) { int skippedDedup = 0; for (const auto& p : pending->m2Placements) { // Skip if this doodad was already placed by a neighboring tile - if (placedDoodadIds.count(p.uniqueId)) { + if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) { skippedDedup++; continue; } uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale); if (instId) { m2InstanceIds.push_back(instId); - placedDoodadIds.insert(p.uniqueId); - tileUniqueIds.push_back(p.uniqueId); + if (p.uniqueId != 0) { + placedDoodadIds.insert(p.uniqueId); + tileUniqueIds.push_back(p.uniqueId); + } loadedDoodads++; } } @@ -1148,6 +1220,406 @@ std::string TerrainManager::getADTPath(const TileCoord& coord) const { std::to_string(coord.x) + "_" + std::to_string(coord.y) + ".adt"; } +void TerrainManager::ensureGroundEffectTablesLoaded() { + if (groundEffectsLoaded_ || !assetManager) return; + groundEffectsLoaded_ = true; + + auto groundEffectTex = assetManager->loadDBC("GroundEffectTexture.dbc"); + auto groundEffectDoodad = assetManager->loadDBC("GroundEffectDoodad.dbc"); + if (!groundEffectTex || !groundEffectDoodad) { + LOG_WARNING("Ground clutter DBCs missing; skipping procedural ground effects"); + return; + } + + // GroundEffectTexture: id + 4 doodad IDs + 4 weights + density + sound + for (uint32_t i = 0; i < groundEffectTex->getRecordCount(); ++i) { + uint32_t effectId = groundEffectTex->getUInt32(i, 0); + if (effectId == 0) continue; + + GroundEffectEntry e; + e.doodadIds[0] = groundEffectTex->getUInt32(i, 1); + e.doodadIds[1] = groundEffectTex->getUInt32(i, 2); + e.doodadIds[2] = groundEffectTex->getUInt32(i, 3); + e.doodadIds[3] = groundEffectTex->getUInt32(i, 4); + e.weights[0] = groundEffectTex->getUInt32(i, 5); + e.weights[1] = groundEffectTex->getUInt32(i, 6); + e.weights[2] = groundEffectTex->getUInt32(i, 7); + e.weights[3] = groundEffectTex->getUInt32(i, 8); + e.density = groundEffectTex->getUInt32(i, 9); + groundEffectById_[effectId] = e; + } + + // GroundEffectDoodad: id + modelName(offset) + flags + for (uint32_t i = 0; i < groundEffectDoodad->getRecordCount(); ++i) { + uint32_t doodadId = groundEffectDoodad->getUInt32(i, 0); + std::string modelName = groundEffectDoodad->getString(i, 1); + if (doodadId == 0 || modelName.empty()) continue; + + std::string lower = toLowerCopy(modelName); + if (lower.size() > 4 && lower.substr(lower.size() - 4) == ".mdl") { + lower = lower.substr(0, lower.size() - 4) + ".m2"; + } + if (lower.find('\\') != std::string::npos || lower.find('/') != std::string::npos) { + groundDoodadModelById_[doodadId] = lower; + } else { + groundDoodadModelById_[doodadId] = "World\\NoDXT\\Detail\\" + lower; + } + } + + LOG_INFO("Ground clutter tables loaded: ", groundEffectById_.size(), + " effects, ", groundDoodadModelById_.size(), " doodad models"); +} + +void TerrainManager::generateGroundClutterPlacements(std::shared_ptr& pending, + std::unordered_set& preparedModelIds) { + if (taxiStreamingMode_) return; // Skip clutter while on taxi flights. + if (!pending || groundEffectById_.empty() || groundDoodadModelById_.empty()) return; + + static const std::string kGroundClutterProxyModel = "World\\NoDXT\\Detail\\ElwGra01.m2"; + static bool loggedProxy = false; + if (!loggedProxy) { + LOG_INFO("Ground clutter: forcing proxy model ", kGroundClutterProxyModel); + loggedProxy = true; + } + + size_t modelMissing = 0; + size_t modelInvalid = 0; + auto ensureModelPrepared = [&](const std::string& m2Path, uint32_t modelId) -> bool { + if (preparedModelIds.count(modelId)) return true; + + std::vector m2Data = assetManager->readFile(m2Path); + if (m2Data.empty()) { + modelMissing++; + return false; + } + + pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + if (m2Model.name.empty()) { + m2Model.name = m2Path; + } + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + std::vector skinData = assetManager->readFileOptional(skinPath); + if (!skinData.empty() && m2Model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, m2Model); + } + if (!m2Model.isValid()) { + modelInvalid++; + return false; + } + + PendingTile::M2Ready ready; + ready.modelId = modelId; + ready.model = std::move(m2Model); + ready.path = m2Path; + pending->m2Models.push_back(std::move(ready)); + preparedModelIds.insert(modelId); + return true; + }; + + constexpr float unitSize = CHUNK_SIZE / 8.0f; + constexpr float pi = 3.1415926535f; + constexpr size_t kBaseMaxGroundClutterPerTile = 220; + constexpr uint32_t kBaseMaxAttemptsPerLayer = 4; + const float densityScaleRaw = glm::clamp(groundClutterDensityScale_, 0.0f, 1.5f); + // Keep runtime density bounded to avoid large streaming spikes in dense tiles. + const float densityScale = std::min(densityScaleRaw, 1.0f); + const size_t kMaxGroundClutterPerTile = std::max( + 0, static_cast(std::lround(static_cast(kBaseMaxGroundClutterPerTile) * densityScale))); + const uint32_t kMaxAttemptsPerLayer = std::max( + 1u, static_cast(std::lround(static_cast(kBaseMaxAttemptsPerLayer) * densityScale))); + std::vector alphaScratch; + std::vector alphaScratchTex; + size_t added = 0; + size_t attemptsTotal = 0; + size_t alphaRejected = 0; + size_t roadRejected = 0; + size_t noEffectMatch = 0; + size_t textureIdFallbackMatch = 0; + size_t noDoodadModel = 0; + std::array perChunkAdded{}; + + auto isRoadLikeTexture = [](const std::string& texPath) -> bool { + std::string t = toLowerCopy(texPath); + return (t.find("road") != std::string::npos) || + (t.find("cobble") != std::string::npos) || + (t.find("path") != std::string::npos) || + (t.find("street") != std::string::npos) || + (t.find("pavement") != std::string::npos) || + (t.find("brick") != std::string::npos); + }; + + auto layerWeightAt = [&](const pipeline::MapChunk& chunk, size_t layerIdx, int alphaIndex) -> int { + if (layerIdx >= chunk.layers.size()) return 0; + if (layerIdx == 0) { + int accum = 0; + size_t numLayers = std::min(chunk.layers.size(), static_cast(4)); + for (size_t i = 1; i < numLayers; ++i) { + int a = 0; + if (decodeLayerAlpha(chunk, i, alphaScratchTex) && + alphaIndex >= 0 && + alphaIndex < static_cast(alphaScratchTex.size())) { + a = alphaScratchTex[alphaIndex]; + } + accum += a; + } + return glm::clamp(255 - accum, 0, 255); + } + if (decodeLayerAlpha(chunk, layerIdx, alphaScratchTex) && + alphaIndex >= 0 && + alphaIndex < static_cast(alphaScratchTex.size())) { + return alphaScratchTex[alphaIndex]; + } + return 0; + }; + + auto hasRoadLikeTextureAt = [&](const pipeline::MapChunk& chunk, float fracX, float fracY) -> bool { + if (chunk.layers.empty()) return false; + int alphaX = glm::clamp(static_cast((fracX / 8.0f) * 63.0f), 0, 63); + int alphaY = glm::clamp(static_cast((fracY / 8.0f) * 63.0f), 0, 63); + int alphaIndex = alphaY * 64 + alphaX; + + size_t numLayers = std::min(chunk.layers.size(), static_cast(4)); + for (size_t layerIdx = 0; layerIdx < numLayers; ++layerIdx) { + uint32_t texId = chunk.layers[layerIdx].textureId; + if (texId >= pending->terrain.textures.size()) continue; + const std::string& texPath = pending->terrain.textures[texId]; + if (!isRoadLikeTexture(texPath)) continue; + // Treat meaningful blend contribution as road occupancy. + int w = layerWeightAt(chunk, layerIdx, alphaIndex); + if (w >= 24) return true; + } + return false; + }; + + for (int cy = 0; cy < 16; ++cy) { + if (added >= kMaxGroundClutterPerTile) break; + for (int cx = 0; cx < 16; ++cx) { + if (added >= kMaxGroundClutterPerTile) break; + const auto& chunk = pending->terrain.getChunk(cx, cy); + if (!chunk.hasHeightMap() || chunk.layers.empty()) continue; + + for (size_t layerIdx = 0; layerIdx < chunk.layers.size(); ++layerIdx) { + if (added >= kMaxGroundClutterPerTile) break; + const auto& layer = chunk.layers[layerIdx]; + if (layer.effectId == 0) continue; + + auto geIt = groundEffectById_.find(layer.effectId); + if (geIt == groundEffectById_.end() && layer.textureId != 0) { + geIt = groundEffectById_.find(layer.textureId); + if (geIt != groundEffectById_.end()) { + textureIdFallbackMatch++; + } + } + if (geIt == groundEffectById_.end()) { + noEffectMatch++; + continue; + } + const GroundEffectEntry& ge = geIt->second; + + uint32_t totalWeight = ge.weights[0] + ge.weights[1] + ge.weights[2] + ge.weights[3]; + if (totalWeight == 0) totalWeight = 4; + + uint32_t density = std::min(ge.density, 16u); + density = static_cast(std::lround(static_cast(density) * densityScale)); + if (density == 0) continue; + uint32_t attempts = std::max(3u, density * 2u); + attempts = std::min(attempts, kMaxAttemptsPerLayer); + attemptsTotal += attempts; + + bool hasAlpha = decodeLayerAlpha(chunk, layerIdx, alphaScratch); + uint32_t seed = static_cast( + ((pending->coord.x & 0xFF) << 24) ^ + ((pending->coord.y & 0xFF) << 16) ^ + ((cx & 0x1F) << 8) ^ + ((cy & 0x1F) << 3) ^ + (layerIdx & 0x7)); + auto nextRand = [&seed]() -> uint32_t { + seed = seed * 1664525u + 1013904223u; + return seed; + }; + + for (uint32_t a = 0; a < attempts; ++a) { + float fracX = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f; + float fracY = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f; + + if (hasAlpha && !alphaScratch.empty()) { + int alphaX = glm::clamp(static_cast((fracX / 8.0f) * 63.0f), 0, 63); + int alphaY = glm::clamp(static_cast((fracY / 8.0f) * 63.0f), 0, 63); + int alphaIndex = alphaY * 64 + alphaX; + if (alphaIndex < 0 || alphaIndex >= static_cast(alphaScratch.size())) continue; + if (alphaScratch[alphaIndex] < 64) { + alphaRejected++; + continue; + } + } + + if (hasRoadLikeTextureAt(chunk, fracX, fracY)) { + roadRejected++; + continue; + } + + uint32_t roll = nextRand() % totalWeight; + int pick = 0; + uint32_t acc = 0; + for (int i = 0; i < 4; ++i) { + uint32_t w = ge.weights[i] > 0 ? ge.weights[i] : 1; + acc += w; + if (roll < acc) { pick = i; break; } + } + uint32_t doodadId = ge.doodadIds[pick]; + if (doodadId == 0) continue; + + auto doodadIt = groundDoodadModelById_.find(doodadId); + if (doodadIt == groundDoodadModelById_.end()) { + noDoodadModel++; + continue; + } + const std::string& doodadModelPath = doodadIt->second; + uint32_t modelId = static_cast(std::hash{}(doodadModelPath)); + if (!ensureModelPrepared(doodadModelPath, modelId)) { + modelId = static_cast(std::hash{}(kGroundClutterProxyModel)); + if (!ensureModelPrepared(kGroundClutterProxyModel, modelId)) { + continue; + } + } + + float worldX = chunk.position[0] - fracY * unitSize; + float worldY = chunk.position[1] - fracX * unitSize; + + int gx0 = glm::clamp(static_cast(std::floor(fracX)), 0, 8); + int gy0 = glm::clamp(static_cast(std::floor(fracY)), 0, 8); + int gx1 = std::min(gx0 + 1, 8); + int gy1 = std::min(gy0 + 1, 8); + float tx = fracX - static_cast(gx0); + float ty = fracY - static_cast(gy0); + float h00 = chunk.heightMap.getHeight(gx0, gy0); + float h10 = chunk.heightMap.getHeight(gx1, gy0); + float h01 = chunk.heightMap.getHeight(gx0, gy1); + float h11 = chunk.heightMap.getHeight(gx1, gy1); + float worldZ = chunk.position[2] + + (h00 * (1 - tx) * (1 - ty) + + h10 * tx * (1 - ty) + + h01 * (1 - tx) * ty + + h11 * tx * ty); + + PendingTile::M2Placement p; + p.modelId = modelId; + p.uniqueId = 0; + // MCNK chunk.position is already in terrain/render world space. + // Do not convert via ADT placement mapping (that is for MDDF/MODF records). + p.rotation = glm::vec3(0.0f, 0.0f, (nextRand() & 0xFFFFu) / 65535.0f * (2.0f * pi)); + p.scale = 0.80f + ((nextRand() & 0xFFFFu) / 65535.0f) * 0.35f; + // Snap directly to sampled terrain height. + p.position = glm::vec3(worldX, worldY, worldZ + 0.01f); + pending->m2Placements.push_back(p); + added++; + perChunkAdded[cy * 16 + cx]++; + if (added >= kMaxGroundClutterPerTile) break; + } + } + } + } + + size_t fallbackAdded = 0; + const size_t kMinGroundClutterPerTile = static_cast(std::lround(40.0f * densityScale)); + size_t fallbackNeeded = (added < kMinGroundClutterPerTile) ? (kMinGroundClutterPerTile - added) : 0; + if (fallbackNeeded > 0) { + const uint32_t proxyModelId = static_cast(std::hash{}(kGroundClutterProxyModel)); + if (ensureModelPrepared(kGroundClutterProxyModel, proxyModelId)) { + constexpr uint32_t kFallbackPerChunk = 2; + for (int cy = 0; cy < 16; ++cy) { + for (int cx = 0; cx < 16; ++cx) { + if (fallbackAdded >= fallbackNeeded || added >= kMaxGroundClutterPerTile) break; + const auto& chunk = pending->terrain.getChunk(cx, cy); + if (!chunk.hasHeightMap()) continue; + + for (uint32_t i = 0; i < kFallbackPerChunk; ++i) { + if (fallbackAdded >= fallbackNeeded || added >= kMaxGroundClutterPerTile) break; + // Deterministic scatter so the tile stays visually stable. + uint32_t seed = static_cast( + ((pending->coord.x & 0xFF) << 24) ^ + ((pending->coord.y & 0xFF) << 16) ^ + ((cx & 0x1F) << 8) ^ + ((cy & 0x1F) << 3) ^ + (i & 0x7)); + auto nextRand = [&seed]() -> uint32_t { + seed = seed * 1664525u + 1013904223u; + return seed; + }; + + float fracX = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f; + float fracY = (nextRand() & 0xFFFFu) / 65535.0f * 8.0f; + if (hasRoadLikeTextureAt(chunk, fracX, fracY)) { + roadRejected++; + continue; + } + float worldX = chunk.position[0] - fracY * unitSize; + float worldY = chunk.position[1] - fracX * unitSize; + + int gx0 = glm::clamp(static_cast(std::floor(fracX)), 0, 8); + int gy0 = glm::clamp(static_cast(std::floor(fracY)), 0, 8); + int gx1 = std::min(gx0 + 1, 8); + int gy1 = std::min(gy0 + 1, 8); + float tx = fracX - static_cast(gx0); + float ty = fracY - static_cast(gy0); + float h00 = chunk.heightMap.getHeight(gx0, gy0); + float h10 = chunk.heightMap.getHeight(gx1, gy0); + float h01 = chunk.heightMap.getHeight(gx0, gy1); + float h11 = chunk.heightMap.getHeight(gx1, gy1); + float worldZ = chunk.position[2] + + (h00 * (1 - tx) * (1 - ty) + + h10 * tx * (1 - ty) + + h01 * (1 - tx) * ty + + h11 * tx * ty); + + PendingTile::M2Placement p; + p.modelId = proxyModelId; + p.uniqueId = 0; + p.rotation = glm::vec3(0.0f, 0.0f, (nextRand() & 0xFFFFu) / 65535.0f * (2.0f * pi)); + p.scale = 0.75f + ((nextRand() & 0xFFFFu) / 65535.0f) * 0.40f; + p.position = glm::vec3(worldX, worldY, worldZ + 0.01f); + pending->m2Placements.push_back(p); + fallbackAdded++; + added++; + perChunkAdded[cy * 16 + cx]++; + } + } + if (fallbackAdded >= fallbackNeeded || added >= kMaxGroundClutterPerTile) break; + } + } + } + + // Baseline pass disabled: one-per-chunk fill caused large instance spikes and hitches + // when streaming tiles around the player. + size_t baselineAdded = 0; + + if (added > 0) { + static int clutterLogCount = 0; + if (clutterLogCount < 12) { + LOG_INFO("Ground clutter tile [", pending->coord.x, ",", pending->coord.y, + "] added=", added, " attempts=", attemptsTotal, + " fallbackAdded=", fallbackAdded, + " baselineAdded=", baselineAdded, + " roadRejected=", roadRejected); + clutterLogCount++; + } + } else { + static int noClutterLogCount = 0; + if (noClutterLogCount < 8) { + LOG_INFO("Ground clutter tile [", pending->coord.x, ",", pending->coord.y, + "] added=0 attempts=", attemptsTotal, + " alphaRejected=", alphaRejected, + " roadRejected=", roadRejected, + " noEffect=", noEffectMatch, + " textureFallback=", textureIdFallbackMatch, + " noDoodadModel=", noDoodadModel, + " modelMissing=", modelMissing, + " modelInvalid=", modelInvalid); + noClutterLogCount++; + } + } +} + std::optional TerrainManager::getHeightAt(float glX, float glY) const { // Terrain mesh vertices use chunk.position directly (WoW coordinates) // But camera is in GL coordinates. We query using the mesh coordinates directly diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index c77e0eb7..b76ae63b 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -5,6 +5,7 @@ #include "core/spawn_presets.hpp" #include "core/input.hpp" #include "rendering/renderer.hpp" +#include "rendering/terrain_manager.hpp" #include "rendering/minimap.hpp" #include "rendering/character_renderer.hpp" #include "rendering/camera.hpp" @@ -206,6 +207,9 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (auto* zm = renderer->getZoneManager()) { zm->setUseOriginalSoundtrack(pendingUseOriginalSoundtrack); } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); + } // Restore mute state: save actual master volume first, then apply mute if (soundMuted_) { float actual = audio::AudioEngine::instance().getMasterVolume(); @@ -5774,6 +5778,7 @@ void GameScreen::renderSettingsWindow() { constexpr int kDefaultMusicVolume = 30; constexpr float kDefaultMouseSensitivity = 0.2f; constexpr bool kDefaultInvertMouse = false; + constexpr int kDefaultGroundClutterDensity = 100; int defaultResIndex = 0; for (int i = 0; i < kResCount; i++) { @@ -5853,6 +5858,14 @@ void GameScreen::renderSettingsWindow() { if (renderer) renderer->setShadowsEnabled(pendingShadows); saveSettings(); } + if (ImGui::SliderInt("Ground Clutter Density", &pendingGroundClutterDensity, 0, 150, "%d%%")) { + if (renderer) { + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); + } + } + saveSettings(); + } const char* resLabel = "Resolution"; const char* resItems[kResCount]; @@ -5874,11 +5887,17 @@ void GameScreen::renderSettingsWindow() { pendingFullscreen = kDefaultFullscreen; pendingVsync = kDefaultVsync; pendingShadows = kDefaultShadows; + pendingGroundClutterDensity = kDefaultGroundClutterDensity; pendingResIndex = defaultResIndex; window->setFullscreen(pendingFullscreen); window->setVsync(pendingVsync); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); if (renderer) renderer->setShadowsEnabled(pendingShadows); + if (renderer) { + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(static_cast(pendingGroundClutterDensity) / 100.0f); + } + } saveSettings(); } @@ -6803,6 +6822,7 @@ void GameScreen::saveSettings() { // Gameplay out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; + out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; // Controls out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; @@ -6879,6 +6899,7 @@ void GameScreen::loadSettings() { else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); // Gameplay else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); + else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); // Controls else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0);