Fix city stuttering with incremental tile finalization and GPU optimizations

Replace monolithic finalizeTile() with a phased state machine that spreads
GPU upload work across multiple frames (TERRAIN→M2→WMO→WATER→AMBIENT→DONE).
Each advanceFinalization() call does one bounded unit of work within the
per-frame time budget, eliminating 50-300ms frame hitches when entering cities.

Additional performance improvements:
- Pre-allocate bone SSBOs at M2 instance creation instead of lazily during
  first render frame, preventing hitches when many skinned characters appear
- Enable WMO distance culling (800 units) with active-group exemption so
  the player's current floor/neighbors are never culled
- Add 4-tier adaptive M2 render distance (250/400/600/1000 based on count)
- Remove dead PendingM2Upload queue code superseded by incremental system

Fix tile re-enqueueing bug: keep tiles in pendingTiles until committed to
loadedTiles (not when moved to finalizingTiles_) so streamTiles() doesn't
re-enqueue tiles mid-finalization. Also handle unloadTile() for tiles in
the finalizingTiles_ deque to prevent orphaned water/M2/WMO resources.
This commit is contained in:
Kelsi 2026-02-25 02:36:23 -08:00
parent 8fe53171eb
commit d47ae2a110
6 changed files with 411 additions and 268 deletions

View file

@ -478,6 +478,7 @@ private:
// Helper to allocate descriptor sets // Helper to allocate descriptor sets
VkDescriptorSet allocateMaterialSet(); VkDescriptorSet allocateMaterialSet();
VkDescriptorSet allocateBoneSet(); VkDescriptorSet allocateBoneSet();
void preallocateBoneBuffers(M2Instance& instance);
// Helper to destroy model GPU resources // Helper to destroy model GPU resources
void destroyModelGPU(M2ModelGPU& model); void destroyModelGPU(M2ModelGPU& model);

View file

@ -123,6 +123,41 @@ struct PendingTile {
std::unordered_map<std::string, pipeline::BLPImage> preloadedTextures; std::unordered_map<std::string, pipeline::BLPImage> preloadedTextures;
}; };
/**
* Phases for incremental tile finalization (one bounded unit of work per call)
*/
enum class FinalizationPhase {
TERRAIN, // Upload terrain mesh + textures
M2_MODELS, // Upload ONE M2 model per call
M2_INSTANCES, // Create all M2 instances (lightweight struct allocation)
WMO_MODELS, // Upload ONE WMO model per call
WMO_INSTANCES, // Create all WMO instances + load WMO liquids
WMO_DOODADS, // Upload ONE WMO doodad M2 per call
WATER, // Upload water from terrain
AMBIENT, // Register ambient emitters
DONE // Commit to loadedTiles
};
/**
* In-progress tile finalization state tracks progress across frames
*/
struct FinalizingTile {
std::shared_ptr<PendingTile> pending;
FinalizationPhase phase = FinalizationPhase::TERRAIN;
// Progress indices within current phase
size_t m2ModelIndex = 0; // Next M2 model to upload
size_t wmoModelIndex = 0; // Next WMO model to upload
size_t wmoDoodadIndex = 0; // Next WMO doodad to upload
// Accumulated results (built up across phases)
std::vector<uint32_t> m2InstanceIds;
std::vector<uint32_t> wmoInstanceIds;
std::vector<uint32_t> tileUniqueIds;
std::vector<uint32_t> tileWmoUniqueIds;
std::unordered_set<uint32_t> uploadedM2ModelIds;
};
/** /**
* Terrain manager for multi-tile terrain streaming * Terrain manager for multi-tile terrain streaming
* *
@ -219,8 +254,8 @@ public:
int getLoadedTileCount() const { return static_cast<int>(loadedTiles.size()); } int getLoadedTileCount() const { return static_cast<int>(loadedTiles.size()); }
int getPendingTileCount() const { return static_cast<int>(pendingTiles.size()); } int getPendingTileCount() const { return static_cast<int>(pendingTiles.size()); }
int getReadyQueueCount() const { return static_cast<int>(readyQueue.size()); } int getReadyQueueCount() const { return static_cast<int>(readyQueue.size()); }
/** Total unfinished tiles (worker threads + ready queue) */ /** Total unfinished tiles (worker threads + ready queue + finalizing) */
int getRemainingTileCount() const { return static_cast<int>(pendingTiles.size() + readyQueue.size()); } int getRemainingTileCount() const { return static_cast<int>(pendingTiles.size() + readyQueue.size() + finalizingTiles_.size()); }
TileCoord getCurrentTile() const { return currentTile; } TileCoord getCurrentTile() const { return currentTile; }
/** Process all ready tiles immediately (use during loading screens) */ /** Process all ready tiles immediately (use during loading screens) */
@ -254,9 +289,10 @@ private:
std::shared_ptr<PendingTile> prepareTile(int x, int y); std::shared_ptr<PendingTile> prepareTile(int x, int y);
/** /**
* Main thread: upload prepared tile data to GPU * Advance incremental finalization of a tile (one bounded unit of work).
* Returns true when the tile is fully finalized (phase == DONE).
*/ */
void finalizeTile(const std::shared_ptr<PendingTile>& pending); bool advanceFinalization(FinalizingTile& ft);
/** /**
* Background worker thread loop * Background worker thread loop
@ -341,16 +377,8 @@ private:
// Dedup set for WMO placements across tile boundaries (prevents rendering Stormwind 16x) // Dedup set for WMO placements across tile boundaries (prevents rendering Stormwind 16x)
std::unordered_set<uint32_t> placedWmoIds; std::unordered_set<uint32_t> placedWmoIds;
// Progressive M2 upload queue (spread heavy uploads across frames) // Tiles currently being incrementally finalized across frames
struct PendingM2Upload { std::deque<FinalizingTile> finalizingTiles_;
uint32_t modelId;
pipeline::M2Model model;
std::string path;
};
std::queue<PendingM2Upload> m2UploadQueue_;
static constexpr int MAX_M2_UPLOADS_PER_FRAME = 5; // Upload up to 5 models per frame
void processM2UploadQueue();
struct GroundEffectEntry { struct GroundEffectEntry {
std::array<uint32_t, 4> doodadIds{{0, 0, 0, 0}}; std::array<uint32_t, 4> doodadIds{{0, 0, 0, 0}};

View file

@ -657,9 +657,9 @@ private:
bool wireframeMode = false; bool wireframeMode = false;
bool frustumCulling = true; bool frustumCulling = true;
bool portalCulling = false; // Disabled by default - needs debugging bool portalCulling = false; // Disabled by default - needs debugging
bool distanceCulling = false; // Disabled - causes ground to disappear bool distanceCulling = true; // Enabled with active-group exemption to prevent floor disappearing
float maxGroupDistance = 500.0f; float maxGroupDistance = 800.0f;
float maxGroupDistanceSq = 250000.0f; // maxGroupDistance^2 float maxGroupDistanceSq = 640000.0f; // maxGroupDistance^2
uint32_t lastDrawCalls = 0; uint32_t lastDrawCalls = 0;
mutable uint32_t lastPortalCulledGroups = 0; mutable uint32_t lastPortalCulledGroups = 0;
mutable uint32_t lastDistanceCulledGroups = 0; mutable uint32_t lastDistanceCulledGroups = 0;

View file

@ -767,6 +767,38 @@ VkDescriptorSet M2Renderer::allocateBoneSet() {
return set; return set;
} }
void M2Renderer::preallocateBoneBuffers(M2Instance& instance) {
if (!vkCtx_) return;
for (int fi = 0; fi < 2; fi++) {
if (instance.boneBuffer[fi]) continue; // already allocated
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bci.size = 128 * sizeof(glm::mat4); // max 128 bones
bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
VmaAllocationCreateInfo aci{};
aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT;
VmaAllocationInfo allocInfo{};
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci,
&instance.boneBuffer[fi], &instance.boneAlloc[fi], &allocInfo);
instance.boneMapped[fi] = allocInfo.pMappedData;
instance.boneSet[fi] = allocateBoneSet();
if (instance.boneSet[fi]) {
VkDescriptorBufferInfo bufInfo{};
bufInfo.buffer = instance.boneBuffer[fi];
bufInfo.offset = 0;
bufInfo.range = bci.size;
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
write.dstSet = instance.boneSet[fi];
write.dstBinding = 0;
write.descriptorCount = 1;
write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
write.pBufferInfo = &bufInfo;
vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr);
}
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// M2 collision mesh: build spatial grid + classify triangles // M2 collision mesh: build spatial grid + classify triangles
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1615,6 +1647,11 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
instance.variationTimer = 3000.0f + static_cast<float>(rand() % 8000); instance.variationTimer = 3000.0f + static_cast<float>(rand() % 8000);
} }
// Pre-allocate bone SSBOs so first render frame doesn't hitch
if (mdlRef.hasAnimation && !mdlRef.disableAnimation) {
preallocateBoneBuffers(instance);
}
instances.push_back(instance); instances.push_back(instance);
size_t idx = instances.size() - 1; size_t idx = instances.size() - 1;
instanceIndexById[instance.id] = idx; instanceIndexById[instance.id] = idx;
@ -1648,6 +1685,8 @@ uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4&
} }
} }
const auto& mdlRef = models[modelId];
M2Instance instance; M2Instance instance;
instance.id = nextInstanceId++; instance.id = nextInstanceId++;
instance.modelId = modelId; instance.modelId = modelId;
@ -1657,20 +1696,24 @@ uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4&
instance.modelMatrix = modelMatrix; instance.modelMatrix = modelMatrix;
instance.invModelMatrix = glm::inverse(modelMatrix); instance.invModelMatrix = glm::inverse(modelMatrix);
glm::vec3 localMin, localMax; glm::vec3 localMin, localMax;
getTightCollisionBounds(models[modelId], localMin, localMax); getTightCollisionBounds(mdlRef, localMin, localMax);
transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax); transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax);
// Initialize animation // Initialize animation
const auto& mdl2 = models[modelId]; if (mdlRef.hasAnimation && !mdlRef.disableAnimation && !mdlRef.sequences.empty()) {
if (mdl2.hasAnimation && !mdl2.disableAnimation && !mdl2.sequences.empty()) {
instance.currentSequenceIndex = 0; instance.currentSequenceIndex = 0;
instance.idleSequenceIndex = 0; instance.idleSequenceIndex = 0;
instance.animDuration = static_cast<float>(mdl2.sequences[0].duration); instance.animDuration = static_cast<float>(mdlRef.sequences[0].duration);
instance.animTime = static_cast<float>(rand() % std::max(1u, mdl2.sequences[0].duration)); instance.animTime = static_cast<float>(rand() % std::max(1u, mdlRef.sequences[0].duration));
instance.variationTimer = 3000.0f + static_cast<float>(rand() % 8000); instance.variationTimer = 3000.0f + static_cast<float>(rand() % 8000);
} else { } else {
instance.animTime = static_cast<float>(rand()) / RAND_MAX * 10000.0f; instance.animTime = static_cast<float>(rand()) / RAND_MAX * 10000.0f;
} }
// Pre-allocate bone SSBOs so first render frame doesn't hitch
if (mdlRef.hasAnimation && !mdlRef.disableAnimation) {
preallocateBoneBuffers(instance);
}
instances.push_back(instance); instances.push_back(instance);
size_t idx = instances.size() - 1; size_t idx = instances.size() - 1;
instanceIndexById[instance.id] = idx; instanceIndexById[instance.id] = idx;
@ -1811,7 +1854,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
// Cache camera state for frustum-culling bone computation // Cache camera state for frustum-culling bone computation
cachedCamPos_ = cameraPos; cachedCamPos_ = cameraPos;
const float maxRenderDistance = (instances.size() > 2000) ? 800.0f : 2800.0f; const size_t animInstCount = instances.size();
const float maxRenderDistance = (animInstCount > 3000) ? 600.0f
: (animInstCount > 2000) ? 800.0f
: (animInstCount > 1000) ? 1400.0f
: 2800.0f;
cachedMaxRenderDistSq_ = maxRenderDistance * maxRenderDistance; cachedMaxRenderDistSq_ = maxRenderDistance * maxRenderDistance;
// Build frustum for culling bones // Build frustum for culling bones
@ -2081,8 +2128,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
lastDrawCallCount = 0; lastDrawCallCount = 0;
// Adaptive render distance: balanced for performance without excessive pop-in // Adaptive render distance: tiered for performance without excessive pop-in
const float maxRenderDistance = (instances.size() > 2000) ? 350.0f : 1000.0f; const size_t instCount = instances.size();
const float maxRenderDistance = (instCount > 3000) ? 250.0f
: (instCount > 2000) ? 400.0f
: (instCount > 1000) ? 600.0f
: 1000.0f;
const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance; const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance;
const float fadeStartFraction = 0.75f; const float fadeStartFraction = 0.75f;
const glm::vec3 camPos = camera.getPosition(); const glm::vec3 camPos = camera.getPosition();

View file

@ -226,7 +226,9 @@ bool TerrainManager::loadTile(int x, int y) {
return false; return false;
} }
finalizeTile(pending); FinalizingTile ft;
ft.pending = std::move(pending);
while (!advanceFinalization(ft)) {}
return true; return true;
} }
@ -648,176 +650,160 @@ void TerrainManager::logMissingAdtOnce(const std::string& adtPath) {
} }
} }
void TerrainManager::finalizeTile(const std::shared_ptr<PendingTile>& pending) { bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
auto& pending = ft.pending;
int x = pending->coord.x; int x = pending->coord.x;
int y = pending->coord.y; int y = pending->coord.y;
TileCoord coord = pending->coord; TileCoord coord = pending->coord;
LOG_DEBUG("Finalizing tile [", x, ",", y, "] (GPU upload)"); switch (ft.phase) {
// Check if tile was already loaded (race condition guard) or failed case FinalizationPhase::TERRAIN: {
if (loadedTiles.find(coord) != loadedTiles.end()) { // Check if tile was already loaded or failed
return; if (loadedTiles.find(coord) != loadedTiles.end() || failedTiles.find(coord) != failedTiles.end()) {
} {
if (failedTiles.find(coord) != failedTiles.end()) { std::lock_guard<std::mutex> lock(queueMutex);
return; pendingTiles.erase(coord);
}
// Upload pre-loaded textures to the GL cache so loadTerrain avoids file I/O
if (!pending->preloadedTextures.empty()) {
terrainRenderer->uploadPreloadedTextures(pending->preloadedTextures);
}
// Upload terrain to GPU
if (!terrainRenderer->loadTerrain(pending->mesh, pending->terrain.textures, x, y)) {
LOG_ERROR("Failed to upload terrain to GPU for tile [", x, ",", y, "]");
failedTiles[coord] = true;
return;
}
// Load water
if (waterRenderer) {
waterRenderer->loadFromTerrain(pending->terrain, true, x, y);
}
// Register water surface ambient sound emitters
if (ambientSoundManager) {
// Scan ADT water data for water surfaces
int waterEmitterCount = 0;
for (size_t chunkIdx = 0; chunkIdx < pending->terrain.waterData.size(); chunkIdx++) {
const auto& chunkWater = pending->terrain.waterData[chunkIdx];
if (!chunkWater.hasWater()) continue;
// Calculate chunk position in world coordinates
int chunkX = chunkIdx % 16;
int chunkY = chunkIdx / 16;
// WoW coordinates: Each ADT tile is 533.33 units, each chunk is 533.33/16 = 33.333 units
// Tile origin in GL space
float tileOriginX = (32.0f - x) * 533.33333f;
float tileOriginY = (32.0f - y) * 533.33333f;
// Chunk center position
float chunkCenterX = tileOriginX + (chunkX + 0.5f) * 33.333333f;
float chunkCenterY = tileOriginY + (chunkY + 0.5f) * 33.333333f;
// Use first layer for height and type detection
if (!chunkWater.layers.empty()) {
const auto& layer = chunkWater.layers[0];
float waterHeight = layer.minHeight;
// Determine water type and register appropriate emitter
// liquidType: 0=water/lake, 1=ocean, 2=magma, 3=slime
if (layer.liquidType == 0) {
// Lake/river water - add water surface emitter every 32 chunks to avoid spam
if (chunkIdx % 32 == 0) {
PendingTile::AmbientEmitter emitter;
emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
emitter.type = 4; // WATER_SURFACE
pending->ambientEmitters.push_back(emitter);
waterEmitterCount++;
}
} else if (layer.liquidType == 1) {
// Ocean - add ocean emitter every 64 chunks (oceans are very large)
if (chunkIdx % 64 == 0) {
PendingTile::AmbientEmitter emitter;
emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
emitter.type = 4; // WATER_SURFACE (could add separate OCEAN type later)
pending->ambientEmitters.push_back(emitter);
waterEmitterCount++;
}
}
// Skip magma and slime for now (no ambient sounds for those)
} }
ft.phase = FinalizationPhase::DONE;
return true;
} }
if (waterEmitterCount > 0) {
LOG_DEBUG("Finalizing tile [", x, ",", y, "] (incremental)");
// Upload pre-loaded textures
if (!pending->preloadedTextures.empty()) {
terrainRenderer->uploadPreloadedTextures(pending->preloadedTextures);
} }
// Upload terrain mesh to GPU
if (!terrainRenderer->loadTerrain(pending->mesh, pending->terrain.textures, x, y)) {
LOG_ERROR("Failed to upload terrain to GPU for tile [", x, ",", y, "]");
failedTiles[coord] = true;
{
std::lock_guard<std::mutex> lock(queueMutex);
pendingTiles.erase(coord);
}
ft.phase = FinalizationPhase::DONE;
return true;
}
// Load water immediately after terrain (same frame) — water vertex positions
// depend on terrain chunk data and must be uploaded before the terrain renders
// without water, otherwise vertices appear at origin.
if (waterRenderer) {
waterRenderer->loadFromTerrain(pending->terrain, true, x, y);
}
// Ensure M2 renderer has asset manager
if (m2Renderer && assetManager) {
m2Renderer->initialize(nullptr, VK_NULL_HANDLE, assetManager);
}
ft.phase = FinalizationPhase::M2_MODELS;
return false;
} }
std::vector<uint32_t> m2InstanceIds; case FinalizationPhase::M2_MODELS: {
std::vector<uint32_t> wmoInstanceIds; // Upload ONE M2 model per call
std::vector<uint32_t> tileUniqueIds; if (m2Renderer && ft.m2ModelIndex < pending->m2Models.size()) {
std::vector<uint32_t> tileWmoUniqueIds; auto& m2Ready = pending->m2Models[ft.m2ModelIndex];
// Upload M2 models to GPU and create instances
if (m2Renderer && assetManager) {
// Always pass the latest asset manager. initialize() is idempotent and updates
// the pointer even when the renderer was initialized earlier without assets.
m2Renderer->initialize(nullptr, VK_NULL_HANDLE, assetManager);
// Upload M2 models immediately (batching was causing hangs)
// The 5ms time budget in processReadyTiles() limits the spike
std::unordered_set<uint32_t> uploadedModelIds;
for (auto& m2Ready : pending->m2Models) {
if (m2Renderer->loadModel(m2Ready.model, m2Ready.modelId)) { if (m2Renderer->loadModel(m2Ready.model, m2Ready.modelId)) {
uploadedModelIds.insert(m2Ready.modelId); ft.uploadedM2ModelIds.insert(m2Ready.modelId);
}
ft.m2ModelIndex++;
// Stay in this phase until all models uploaded
if (ft.m2ModelIndex < pending->m2Models.size()) {
return false;
} }
} }
if (!uploadedModelIds.empty()) { if (!ft.uploadedM2ModelIds.empty()) {
LOG_DEBUG(" Uploaded ", uploadedModelIds.size(), " M2 models for tile [", x, ",", y, "]"); LOG_DEBUG(" Uploaded ", ft.uploadedM2ModelIds.size(), " M2 models for tile [", x, ",", y, "]");
} }
ft.phase = FinalizationPhase::M2_INSTANCES;
// Create instances (deduplicate by uniqueId across tile boundaries) return false;
int loadedDoodads = 0;
int skippedDedup = 0;
for (const auto& p : pending->m2Placements) {
// Skip if this doodad was already placed by a neighboring tile
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);
if (p.uniqueId != 0) {
placedDoodadIds.insert(p.uniqueId);
tileUniqueIds.push_back(p.uniqueId);
}
loadedDoodads++;
}
}
LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ",
loadedDoodads, " instances (", uploadedModelIds.size(), " new models, ",
skippedDedup, " dedup skipped)");
} }
// Upload WMO models to GPU and create instances case FinalizationPhase::M2_INSTANCES: {
if (wmoRenderer && assetManager) { // Create all M2 instances (lightweight struct allocation, no GPU work)
// WMORenderer may be initialized before assets are ready; always re-pass assets. if (m2Renderer) {
wmoRenderer->initialize(nullptr, VK_NULL_HANDLE, assetManager); int loadedDoodads = 0;
int skippedDedup = 0;
int loadedWMOs = 0; for (const auto& p : pending->m2Placements) {
int loadedLiquids = 0; if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) {
int skippedWmoDedup = 0; skippedDedup++;
for (auto& wmoReady : pending->wmoModels) { continue;
// Deduplicate by placement uniqueId when available. }
// Some ADTs use uniqueId=0, which is not safe for dedup. uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale);
if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) { if (instId) {
skippedWmoDedup++; ft.m2InstanceIds.push_back(instId);
continue; if (p.uniqueId != 0) {
placedDoodadIds.insert(p.uniqueId);
ft.tileUniqueIds.push_back(p.uniqueId);
}
loadedDoodads++;
}
} }
LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ",
loadedDoodads, " instances (", ft.uploadedM2ModelIds.size(), " new models, ",
skippedDedup, " dedup skipped)");
}
ft.phase = FinalizationPhase::WMO_MODELS;
return false;
}
case FinalizationPhase::WMO_MODELS: {
// Upload ONE WMO model per call
if (wmoRenderer && assetManager) {
wmoRenderer->initialize(nullptr, VK_NULL_HANDLE, assetManager);
if (ft.wmoModelIndex < pending->wmoModels.size()) {
auto& wmoReady = pending->wmoModels[ft.wmoModelIndex];
// Deduplicate
if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) {
// Skip this one, advance
ft.wmoModelIndex++;
if (ft.wmoModelIndex < pending->wmoModels.size()) return false;
} else {
wmoRenderer->loadModel(wmoReady.model, wmoReady.modelId);
ft.wmoModelIndex++;
if (ft.wmoModelIndex < pending->wmoModels.size()) return false;
}
}
}
ft.wmoModelIndex = 0; // Reset for WMO_INSTANCES phase iteration
ft.phase = FinalizationPhase::WMO_INSTANCES;
return false;
}
case FinalizationPhase::WMO_INSTANCES: {
// Create all WMO instances + load WMO liquids
if (wmoRenderer) {
int loadedWMOs = 0;
int loadedLiquids = 0;
int skippedWmoDedup = 0;
for (auto& wmoReady : pending->wmoModels) {
if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) {
skippedWmoDedup++;
continue;
}
if (wmoRenderer->loadModel(wmoReady.model, wmoReady.modelId)) {
uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation); uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation);
if (wmoInstId) { if (wmoInstId) {
wmoInstanceIds.push_back(wmoInstId); ft.wmoInstanceIds.push_back(wmoInstId);
if (wmoReady.uniqueId != 0) { if (wmoReady.uniqueId != 0) {
placedWmoIds.insert(wmoReady.uniqueId); placedWmoIds.insert(wmoReady.uniqueId);
tileWmoUniqueIds.push_back(wmoReady.uniqueId); ft.tileWmoUniqueIds.push_back(wmoReady.uniqueId);
} }
loadedWMOs++; loadedWMOs++;
// Load WMO liquids (canals, pools, etc.) // Load WMO liquids (canals, pools, etc.)
if (waterRenderer) { if (waterRenderer) {
// Compute the same model matrix as WMORenderer uses
glm::mat4 modelMatrix = glm::mat4(1.0f); glm::mat4 modelMatrix = glm::mat4(1.0f);
modelMatrix = glm::translate(modelMatrix, wmoReady.position); modelMatrix = glm::translate(modelMatrix, wmoReady.position);
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f));
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f));
// Load liquids from each WMO group
for (const auto& group : wmoReady.model.groups) { for (const auto& group : wmoReady.model.groups) {
if (group.liquid.hasLiquid()) { if (group.liquid.hasLiquid()) {
waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId); waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId);
@ -827,57 +813,109 @@ void TerrainManager::finalizeTile(const std::shared_ptr<PendingTile>& pending) {
} }
} }
} }
if (loadedWMOs > 0 || skippedWmoDedup > 0) {
LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ",
loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped");
}
if (loadedLiquids > 0) {
LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids);
}
} }
if (loadedWMOs > 0 || skippedWmoDedup > 0) { ft.phase = FinalizationPhase::WMO_DOODADS;
LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", return false;
loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped"); }
}
if (loadedLiquids > 0) {
LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids);
}
// Upload WMO doodad M2 models case FinalizationPhase::WMO_DOODADS: {
if (m2Renderer) { // Upload ONE WMO doodad M2 per call
for (auto& doodad : pending->wmoDoodads) { if (m2Renderer && ft.wmoDoodadIndex < pending->wmoDoodads.size()) {
m2Renderer->loadModel(doodad.model, doodad.modelId); auto& doodad = pending->wmoDoodads[ft.wmoDoodadIndex];
uint32_t wmoDoodadInstId = m2Renderer->createInstanceWithMatrix( m2Renderer->loadModel(doodad.model, doodad.modelId);
doodad.modelId, doodad.modelMatrix, doodad.worldPosition); uint32_t wmoDoodadInstId = m2Renderer->createInstanceWithMatrix(
if (wmoDoodadInstId) m2InstanceIds.push_back(wmoDoodadInstId); doodad.modelId, doodad.modelMatrix, doodad.worldPosition);
if (wmoDoodadInstId) ft.m2InstanceIds.push_back(wmoDoodadInstId);
ft.wmoDoodadIndex++;
if (ft.wmoDoodadIndex < pending->wmoDoodads.size()) return false;
}
ft.phase = FinalizationPhase::WATER;
return false;
}
case FinalizationPhase::WATER: {
// Terrain water was already loaded in TERRAIN phase.
// Generate water ambient emitters here.
if (ambientSoundManager) {
for (size_t chunkIdx = 0; chunkIdx < pending->terrain.waterData.size(); chunkIdx++) {
const auto& chunkWater = pending->terrain.waterData[chunkIdx];
if (!chunkWater.hasWater()) continue;
int chunkX = chunkIdx % 16;
int chunkY = chunkIdx / 16;
float tileOriginX = (32.0f - x) * 533.33333f;
float tileOriginY = (32.0f - y) * 533.33333f;
float chunkCenterX = tileOriginX + (chunkX + 0.5f) * 33.333333f;
float chunkCenterY = tileOriginY + (chunkY + 0.5f) * 33.333333f;
if (!chunkWater.layers.empty()) {
const auto& layer = chunkWater.layers[0];
float waterHeight = layer.minHeight;
if (layer.liquidType == 0 && chunkIdx % 32 == 0) {
PendingTile::AmbientEmitter emitter;
emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
emitter.type = 4;
pending->ambientEmitters.push_back(emitter);
} else if (layer.liquidType == 1 && chunkIdx % 64 == 0) {
PendingTile::AmbientEmitter emitter;
emitter.position = glm::vec3(chunkCenterX, chunkCenterY, waterHeight);
emitter.type = 4;
pending->ambientEmitters.push_back(emitter);
}
}
} }
} }
if (loadedWMOs > 0) { ft.phase = FinalizationPhase::AMBIENT;
LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", loadedWMOs); return false;
}
} }
// Register ambient sound emitters with ambient sound manager case FinalizationPhase::AMBIENT: {
if (ambientSoundManager && !pending->ambientEmitters.empty()) { // Register ambient sound emitters
for (const auto& emitter : pending->ambientEmitters) { if (ambientSoundManager && !pending->ambientEmitters.empty()) {
// Cast uint32_t type to AmbientSoundManager::AmbientType enum for (const auto& emitter : pending->ambientEmitters) {
auto type = static_cast<audio::AmbientSoundManager::AmbientType>(emitter.type); auto type = static_cast<audio::AmbientSoundManager::AmbientType>(emitter.type);
ambientSoundManager->addEmitter(emitter.position, type); ambientSoundManager->addEmitter(emitter.position, type);
}
} }
// Commit tile to loadedTiles
auto tile = std::make_unique<TerrainTile>();
tile->coord = coord;
tile->terrain = std::move(pending->terrain);
tile->mesh = std::move(pending->mesh);
tile->loaded = true;
tile->m2InstanceIds = std::move(ft.m2InstanceIds);
tile->wmoInstanceIds = std::move(ft.wmoInstanceIds);
tile->wmoUniqueIds = std::move(ft.tileWmoUniqueIds);
tile->doodadUniqueIds = std::move(ft.tileUniqueIds);
getTileBounds(coord, tile->minX, tile->minY, tile->maxX, tile->maxY);
loadedTiles[coord] = std::move(tile);
putCachedTile(pending);
// Now safe to remove from pendingTiles (tile is in loadedTiles)
{
std::lock_guard<std::mutex> lock(queueMutex);
pendingTiles.erase(coord);
}
LOG_DEBUG(" Finalized tile [", x, ",", y, "]");
ft.phase = FinalizationPhase::DONE;
return true;
} }
// Create tile entry case FinalizationPhase::DONE:
auto tile = std::make_unique<TerrainTile>(); return true;
tile->coord = coord; }
tile->terrain = std::move(pending->terrain); return true;
tile->mesh = std::move(pending->mesh);
tile->loaded = true;
tile->m2InstanceIds = std::move(m2InstanceIds);
tile->wmoInstanceIds = std::move(wmoInstanceIds);
tile->wmoUniqueIds = std::move(tileWmoUniqueIds);
tile->doodadUniqueIds = std::move(tileUniqueIds);
// Calculate world bounds
getTileBounds(coord, tile->minX, tile->minY, tile->maxX, tile->maxY);
loadedTiles[coord] = std::move(tile);
putCachedTile(pending);
LOG_DEBUG(" Finalized tile [", x, ",", y, "]");
} }
void TerrainManager::workerLoop() { void TerrainManager::workerLoop() {
@ -927,80 +965,60 @@ void TerrainManager::processReadyTiles() {
// Taxi mode gets a slightly larger budget to avoid visible late-pop terrain/models. // Taxi mode gets a slightly larger budget to avoid visible late-pop terrain/models.
const float timeBudgetMs = taxiStreamingMode_ ? 8.0f : 5.0f; const float timeBudgetMs = taxiStreamingMode_ ? 8.0f : 5.0f;
auto startTime = std::chrono::high_resolution_clock::now(); auto startTime = std::chrono::high_resolution_clock::now();
int processed = 0;
while (true) { // Move newly ready tiles into the finalizing deque.
std::shared_ptr<PendingTile> pending; // Keep them in pendingTiles so streamTiles() won't re-enqueue them.
{
{ std::lock_guard<std::mutex> lock(queueMutex);
std::lock_guard<std::mutex> lock(queueMutex); while (!readyQueue.empty()) {
if (readyQueue.empty()) { auto pending = readyQueue.front();
break;
}
pending = readyQueue.front();
readyQueue.pop(); readyQueue.pop();
} if (pending) {
FinalizingTile ft;
if (pending) { ft.pending = std::move(pending);
TileCoord coord = pending->coord; finalizingTiles_.push_back(std::move(ft));
finalizeTile(pending);
auto now = std::chrono::high_resolution_clock::now();
{
std::lock_guard<std::mutex> lock(queueMutex);
pendingTiles.erase(coord);
}
processed++;
// Check if we've exceeded time budget
float elapsedMs = std::chrono::duration<float, std::milli>(now - startTime).count();
if (elapsedMs >= timeBudgetMs) {
if (processed > 1) {
LOG_DEBUG("Processed ", processed, " tiles in ", elapsedMs, "ms (budget: ", timeBudgetMs, "ms)");
}
break;
} }
} }
} }
}
void TerrainManager::processM2UploadQueue() { // Drive incremental finalization within time budget
// Upload up to MAX_M2_UPLOADS_PER_FRAME models per frame while (!finalizingTiles_.empty()) {
int uploaded = 0; auto& ft = finalizingTiles_.front();
while (!m2UploadQueue_.empty() && uploaded < MAX_M2_UPLOADS_PER_FRAME) { bool done = advanceFinalization(ft);
auto& upload = m2UploadQueue_.front();
if (m2Renderer) { if (done) {
m2Renderer->loadModel(upload.model, upload.modelId); finalizingTiles_.pop_front();
} }
m2UploadQueue_.pop();
uploaded++;
}
if (uploaded > 0) { auto now = std::chrono::high_resolution_clock::now();
LOG_DEBUG("Uploaded ", uploaded, " M2 models (", m2UploadQueue_.size(), " remaining in queue)"); float elapsedMs = std::chrono::duration<float, std::milli>(now - startTime).count();
if (elapsedMs >= timeBudgetMs) {
break;
}
} }
} }
void TerrainManager::processAllReadyTiles() { void TerrainManager::processAllReadyTiles() {
while (true) { // Move all ready tiles into finalizing deque
std::shared_ptr<PendingTile> pending; // Keep in pendingTiles until committed (same as processReadyTiles)
{ {
std::lock_guard<std::mutex> lock(queueMutex); std::lock_guard<std::mutex> lock(queueMutex);
if (readyQueue.empty()) break; while (!readyQueue.empty()) {
pending = readyQueue.front(); auto pending = readyQueue.front();
readyQueue.pop(); readyQueue.pop();
} if (pending) {
if (pending) { FinalizingTile ft;
TileCoord coord = pending->coord; ft.pending = std::move(pending);
finalizeTile(pending); finalizingTiles_.push_back(std::move(ft));
{
std::lock_guard<std::mutex> lock(queueMutex);
pendingTiles.erase(coord);
} }
} }
} }
// Finalize all tiles completely (no time budget — used for loading screens)
while (!finalizingTiles_.empty()) {
auto& ft = finalizingTiles_.front();
while (!advanceFinalization(ft)) {}
finalizingTiles_.pop_front();
}
} }
std::shared_ptr<PendingTile> TerrainManager::getCachedTile(const TileCoord& coord) { std::shared_ptr<PendingTile> TerrainManager::getCachedTile(const TileCoord& coord) {
@ -1099,6 +1117,31 @@ void TerrainManager::unloadTile(int x, int y) {
pendingTiles.erase(coord); pendingTiles.erase(coord);
} }
// Remove from finalizingTiles_ if it's being incrementally finalized.
// Water may have already been loaded in TERRAIN phase, so clean it up.
for (auto fit = finalizingTiles_.begin(); fit != finalizingTiles_.end(); ++fit) {
if (fit->pending && fit->pending->coord == coord) {
// If past TERRAIN phase, water was already loaded — remove it
if (fit->phase != FinalizationPhase::TERRAIN && waterRenderer) {
waterRenderer->removeTile(x, y);
}
// Clean up any M2/WMO instances that were already created
if (m2Renderer && !fit->m2InstanceIds.empty()) {
m2Renderer->removeInstances(fit->m2InstanceIds);
}
if (wmoRenderer && !fit->wmoInstanceIds.empty()) {
for (uint32_t id : fit->wmoInstanceIds) {
if (waterRenderer) waterRenderer->removeWMO(id);
}
wmoRenderer->removeInstances(fit->wmoInstanceIds);
}
for (uint32_t uid : fit->tileUniqueIds) placedDoodadIds.erase(uid);
for (uint32_t uid : fit->tileWmoUniqueIds) placedWmoIds.erase(uid);
finalizingTiles_.erase(fit);
return;
}
}
auto it = loadedTiles.find(coord); auto it = loadedTiles.find(coord);
if (it == loadedTiles.end()) { if (it == loadedTiles.end()) {
return; return;
@ -1167,6 +1210,7 @@ void TerrainManager::unloadAll() {
while (!readyQueue.empty()) readyQueue.pop(); while (!readyQueue.empty()) readyQueue.pop();
} }
pendingTiles.clear(); pendingTiles.clear();
finalizingTiles_.clear();
placedDoodadIds.clear(); placedDoodadIds.clear();
LOG_INFO("Unloading all terrain tiles"); LOG_INFO("Unloading all terrain tiles");

View file

@ -1319,6 +1319,9 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
bool doFrustumCull = false; // Temporarily disabled: can over-cull world WMOs bool doFrustumCull = false; // Temporarily disabled: can over-cull world WMOs
bool doDistanceCull = distanceCulling; bool doDistanceCull = distanceCulling;
// Cache active group info for distance-cull exemption (player's current WMO group)
const auto activeGroupCopy = activeGroup_;
auto cullInstance = [&](size_t instIdx) -> InstanceDrawList { auto cullInstance = [&](size_t instIdx) -> InstanceDrawList {
if (instIdx >= instances.size()) return InstanceDrawList{}; if (instIdx >= instances.size()) return InstanceDrawList{};
const auto& instance = instances[instIdx]; const auto& instance = instances[instIdx];
@ -1329,6 +1332,9 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
InstanceDrawList result; InstanceDrawList result;
result.instanceIndex = instIdx; result.instanceIndex = instIdx;
// Check if this instance is the one the player is standing in
bool isActiveInstance = activeGroupCopy.isValid() && activeGroupCopy.instanceIdx == instIdx;
// Portal-based visibility // Portal-based visibility
std::unordered_set<uint32_t> portalVisibleGroups; std::unordered_set<uint32_t> portalVisibleGroups;
bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty();
@ -1349,11 +1355,24 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
const auto& [gMin, gMax] = instance.worldGroupBounds[gi]; const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
if (doDistanceCull) { if (doDistanceCull) {
glm::vec3 closestPoint = glm::clamp(camPos, gMin, gMax); // Never cull the group the player is standing in or its portal neighbors
float distSq = glm::dot(closestPoint - camPos, closestPoint - camPos); bool isExempt = false;
if (distSq > 250000.0f) { if (isActiveInstance) {
result.distanceCulled++; if (static_cast<int32_t>(gi) == activeGroupCopy.groupIdx) {
continue; isExempt = true;
} else {
for (uint32_t ng : activeGroupCopy.neighborGroups) {
if (ng == static_cast<uint32_t>(gi)) { isExempt = true; break; }
}
}
}
if (!isExempt) {
glm::vec3 closestPoint = glm::clamp(camPos, gMin, gMax);
float distSq = glm::dot(closestPoint - camPos, closestPoint - camPos);
if (distSq > maxGroupDistanceSq) {
result.distanceCulled++;
continue;
}
} }
} }