mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-23 07:40:14 +00:00
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
This commit is contained in:
parent
c04e97e375
commit
1003b25ff4
11 changed files with 714 additions and 116 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <glm/glm.hpp>
|
||||
#include <array>
|
||||
|
||||
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<PendingTile>& pending,
|
||||
std::unordered_set<uint32_t>& 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<uint32_t, 4> doodadIds{{0, 0, 0, 0}};
|
||||
std::array<uint32_t, 4> weights{{0, 0, 0, 0}};
|
||||
uint32_t density = 0;
|
||||
};
|
||||
bool groundEffectsLoaded_ = false;
|
||||
std::unordered_map<uint32_t, GroundEffectEntry> groundEffectById_; // effectId -> config
|
||||
std::unordered_map<uint32_t, std::string> groundDoodadModelById_; // doodadId -> model path
|
||||
float groundClutterDensityScale_ = 1.0f;
|
||||
};
|
||||
|
||||
} // namespace rendering
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<game::Unit>(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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -222,7 +222,9 @@ std::shared_ptr<DBCFile> 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;
|
||||
|
|
|
|||
|
|
@ -368,12 +368,15 @@ std::string readString(const std::vector<uint8_t>& 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<const char*>(&data[offset]), length);
|
||||
return std::string(reinterpret_cast<const char*>(&data[offset]), actualLen);
|
||||
}
|
||||
|
||||
enum class TrackType { VEC3, QUAT_COMPRESSED, FLOAT };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<char>(std::tolower(c)); });
|
||||
return v;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TerrainManager::TerrainManager() {
|
||||
|
|
@ -255,6 +261,63 @@ std::shared_ptr<PendingTile> 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<uint32_t>(terrain.doodadNames.size());
|
||||
const uint32_t wmoNameBase = static_cast<uint32_t>(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<uint32_t> 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<uint32_t> 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<PendingTile> TerrainManager::prepareTile(int x, int y) {
|
|||
pending->terrain = std::move(terrain);
|
||||
pending->mesh = std::move(mesh);
|
||||
|
||||
std::unordered_set<uint32_t> 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<uint8_t> 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<uint8_t> 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<uint32_t> 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<uint32_t>(std::hash<std::string>{}(m2Path));
|
||||
|
||||
// Parse model if not already done for this tile
|
||||
if (preparedModelIds.find(modelId) == preparedModelIds.end()) {
|
||||
std::vector<uint8_t> 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<uint8_t> 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<uint32_t>(std::hash<std::string>{}(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<PendingTile> 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<uint8_t> skinData = assetManager->readFile(skinPath);
|
||||
if (!skinData.empty() && m2Model.version >= 264) {
|
||||
|
|
@ -675,15 +745,17 @@ void TerrainManager::finalizeTile(const std::shared_ptr<PendingTile>& 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<PendingTile>& pending,
|
||||
std::unordered_set<uint32_t>& 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<uint8_t> 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<uint8_t> 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<size_t>(
|
||||
0, static_cast<size_t>(std::lround(static_cast<float>(kBaseMaxGroundClutterPerTile) * densityScale)));
|
||||
const uint32_t kMaxAttemptsPerLayer = std::max<uint32_t>(
|
||||
1u, static_cast<uint32_t>(std::lround(static_cast<float>(kBaseMaxAttemptsPerLayer) * densityScale)));
|
||||
std::vector<uint8_t> alphaScratch;
|
||||
std::vector<uint8_t> 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<uint16_t, 256> 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<size_t>(4));
|
||||
for (size_t i = 1; i < numLayers; ++i) {
|
||||
int a = 0;
|
||||
if (decodeLayerAlpha(chunk, i, alphaScratchTex) &&
|
||||
alphaIndex >= 0 &&
|
||||
alphaIndex < static_cast<int>(alphaScratchTex.size())) {
|
||||
a = alphaScratchTex[alphaIndex];
|
||||
}
|
||||
accum += a;
|
||||
}
|
||||
return glm::clamp(255 - accum, 0, 255);
|
||||
}
|
||||
if (decodeLayerAlpha(chunk, layerIdx, alphaScratchTex) &&
|
||||
alphaIndex >= 0 &&
|
||||
alphaIndex < static_cast<int>(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<int>((fracX / 8.0f) * 63.0f), 0, 63);
|
||||
int alphaY = glm::clamp(static_cast<int>((fracY / 8.0f) * 63.0f), 0, 63);
|
||||
int alphaIndex = alphaY * 64 + alphaX;
|
||||
|
||||
size_t numLayers = std::min(chunk.layers.size(), static_cast<size_t>(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<uint32_t>(ge.density, 16u);
|
||||
density = static_cast<uint32_t>(std::lround(static_cast<float>(density) * densityScale));
|
||||
if (density == 0) continue;
|
||||
uint32_t attempts = std::max<uint32_t>(3u, density * 2u);
|
||||
attempts = std::min<uint32_t>(attempts, kMaxAttemptsPerLayer);
|
||||
attemptsTotal += attempts;
|
||||
|
||||
bool hasAlpha = decodeLayerAlpha(chunk, layerIdx, alphaScratch);
|
||||
uint32_t seed = static_cast<uint32_t>(
|
||||
((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<int>((fracX / 8.0f) * 63.0f), 0, 63);
|
||||
int alphaY = glm::clamp(static_cast<int>((fracY / 8.0f) * 63.0f), 0, 63);
|
||||
int alphaIndex = alphaY * 64 + alphaX;
|
||||
if (alphaIndex < 0 || alphaIndex >= static_cast<int>(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<uint32_t>(std::hash<std::string>{}(doodadModelPath));
|
||||
if (!ensureModelPrepared(doodadModelPath, modelId)) {
|
||||
modelId = static_cast<uint32_t>(std::hash<std::string>{}(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<int>(std::floor(fracX)), 0, 8);
|
||||
int gy0 = glm::clamp(static_cast<int>(std::floor(fracY)), 0, 8);
|
||||
int gx1 = std::min(gx0 + 1, 8);
|
||||
int gy1 = std::min(gy0 + 1, 8);
|
||||
float tx = fracX - static_cast<float>(gx0);
|
||||
float ty = fracY - static_cast<float>(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<size_t>(std::lround(40.0f * densityScale));
|
||||
size_t fallbackNeeded = (added < kMinGroundClutterPerTile) ? (kMinGroundClutterPerTile - added) : 0;
|
||||
if (fallbackNeeded > 0) {
|
||||
const uint32_t proxyModelId = static_cast<uint32_t>(std::hash<std::string>{}(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<uint32_t>(
|
||||
((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<int>(std::floor(fracX)), 0, 8);
|
||||
int gy0 = glm::clamp(static_cast<int>(std::floor(fracY)), 0, 8);
|
||||
int gx1 = std::min(gx0 + 1, 8);
|
||||
int gy1 = std::min(gy0 + 1, 8);
|
||||
float tx = fracX - static_cast<float>(gx0);
|
||||
float ty = fracY - static_cast<float>(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<float> 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
|
||||
|
|
|
|||
|
|
@ -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<float>(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<float>(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<float>(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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue