Reduce runtime streaming stutter by throttling heavy spawn workloads

- Add per-frame cap for first-time creature model loads to spread expensive model/texture decode work across frames instead of burst loading.

- Keep cached creature spawns flowing while deferring uncached display IDs, preserving world population updates with smoother frame pacing.

- Defer transport WMO doodad instancing into a queued batch processor with a strict per-frame budget to avoid multi-hundred-ms spikes when ships/elevators register.

- Process deferred transport doodad batches in both normal gameplay update and loading-screen warmup loop so heavy transport setup can be amortized before and after world entry.

- Cleanup pending transport doodad batches on gameobject despawn to prevent stale work and avoid attaching children to removed parents.

- Lower character texture cache miss logging from INFO to DEBUG to reduce log I/O contention during movement-heavy asset streaming.
This commit is contained in:
Kelsi 2026-02-20 20:00:44 -08:00
parent 2da2e75253
commit 48d9de810d
3 changed files with 123 additions and 44 deletions

View file

@ -240,6 +240,7 @@ private:
};
std::vector<PendingCreatureSpawn> pendingCreatureSpawns_;
static constexpr int MAX_SPAWNS_PER_FRAME = 8;
static constexpr int MAX_NEW_CREATURE_MODELS_PER_FRAME = 1;
static constexpr uint16_t MAX_CREATURE_SPAWN_RETRIES = 300;
std::unordered_set<uint64_t> pendingCreatureSpawnGuids_;
std::unordered_map<uint64_t, uint16_t> creatureSpawnRetryCounts_;
@ -289,6 +290,21 @@ private:
};
std::vector<PendingGameObjectSpawn> pendingGameObjectSpawns_;
void processGameObjectSpawnQueue();
struct PendingTransportDoodadBatch {
uint64_t guid = 0;
uint32_t modelId = 0;
uint32_t instanceId = 0;
size_t nextIndex = 0;
size_t doodadBudget = 0;
size_t spawnedDoodads = 0;
float x = 0.0f;
float y = 0.0f;
float z = 0.0f;
float orientation = 0.0f;
};
std::vector<PendingTransportDoodadBatch> pendingTransportDoodadBatches_;
static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 12;
void processPendingTransportDoodads();
// Quest marker billboard sprites (above NPCs)
void loadQuestMarkerModels(); // Now loads BLP textures

View file

@ -654,6 +654,7 @@ void Application::update(float deltaTime) {
auto goq1 = std::chrono::high_resolution_clock::now();
processGameObjectSpawnQueue();
processPendingTransportDoodads();
auto goq2 = std::chrono::high_resolution_clock::now();
goQTime += std::chrono::duration<float, std::milli>(goq2 - goq1).count();
@ -3086,6 +3087,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
processCreatureSpawnQueue();
processDeferredEquipmentQueue();
processGameObjectSpawnQueue();
processPendingTransportDoodads();
processPendingMount();
updateQuestMarkers();
@ -4842,47 +4844,18 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
if (doodadTemplates && !doodadTemplates->empty()) {
constexpr size_t kMaxTransportDoodads = 192;
const size_t doodadBudget = std::min(doodadTemplates->size(), kMaxTransportDoodads);
LOG_INFO("Spawning ", doodadBudget, "/", doodadTemplates->size(),
" doodads for transport WMO instance ", instanceId);
int spawnedDoodads = 0;
for (size_t i = 0; i < doodadBudget; ++i) {
const auto& doodadTemplate = (*doodadTemplates)[i];
// Load M2 model (may be cached)
uint32_t doodadModelId = static_cast<uint32_t>(std::hash<std::string>{}(doodadTemplate.m2Path));
auto m2Data = assetManager->readFile(doodadTemplate.m2Path);
if (m2Data.empty()) continue;
pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data);
std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin";
std::vector<uint8_t> skinData = assetManager->readFile(skinPath);
if (!skinData.empty() && m2Model.version >= 264) {
pipeline::M2Loader::loadSkin(skinData, m2Model);
}
if (!m2Model.isValid()) continue;
// Load model to renderer (cached if already loaded)
m2Renderer->loadModel(m2Model, doodadModelId);
// Create M2 instance at world origin (transform will be updated by WMO parent)
uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f);
if (m2InstanceId == 0) continue;
// Link doodad to WMO instance
wmoRenderer->addDoodadToInstance(instanceId, m2InstanceId, doodadTemplate.localTransform);
spawnedDoodads++;
}
if (spawnedDoodads > 0) {
LOG_INFO("Spawned ", spawnedDoodads, " doodads for transport WMO instance ", instanceId);
// Initial transform update to position doodads correctly
// (subsequent updates will happen automatically via setInstanceTransform)
glm::mat4 wmoTransform(1.0f);
wmoTransform = glm::translate(wmoTransform, renderPos);
wmoTransform = glm::rotate(wmoTransform, renderYaw, glm::vec3(0, 0, 1));
wmoRenderer->setInstanceTransform(instanceId, wmoTransform);
}
LOG_INFO("Queueing ", doodadBudget, "/", doodadTemplates->size(),
" transport doodads for WMO instance ", instanceId);
pendingTransportDoodadBatches_.push_back(PendingTransportDoodadBatch{
guid,
modelId,
instanceId,
0,
doodadBudget,
0,
x, y, z,
orientation
});
} else {
LOG_INFO("Transport WMO has no doodads or templates not available");
}
@ -4962,10 +4935,24 @@ void Application::processCreatureSpawnQueue() {
}
int processed = 0;
while (!pendingCreatureSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) {
int newModelLoads = 0;
size_t rotationsLeft = pendingCreatureSpawns_.size();
while (!pendingCreatureSpawns_.empty() &&
processed < MAX_SPAWNS_PER_FRAME &&
rotationsLeft > 0) {
PendingCreatureSpawn s = pendingCreatureSpawns_.front();
spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation);
pendingCreatureSpawns_.erase(pendingCreatureSpawns_.begin());
const bool needsNewModel = (displayIdModelCache_.find(s.displayId) == displayIdModelCache_.end());
if (needsNewModel && newModelLoads >= MAX_NEW_CREATURE_MODELS_PER_FRAME) {
// Defer additional first-time model/texture loads to later frames so
// movement stays responsive in dense areas.
pendingCreatureSpawns_.push_back(s);
rotationsLeft--;
continue;
}
spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation);
pendingCreatureSpawnGuids_.erase(s.guid);
// If spawn still failed, retry for a limited number of frames.
@ -4992,6 +4979,10 @@ void Application::processCreatureSpawnQueue() {
} else {
creatureSpawnRetryCounts_.erase(s.guid);
}
if (needsNewModel) {
newModelLoads++;
}
rotationsLeft = pendingCreatureSpawns_.size();
processed++;
}
}
@ -5043,6 +5034,73 @@ void Application::processGameObjectSpawnQueue() {
}
}
void Application::processPendingTransportDoodads() {
if (pendingTransportDoodadBatches_.empty()) return;
if (!renderer || !assetManager) return;
auto* wmoRenderer = renderer->getWMORenderer();
auto* m2Renderer = renderer->getM2Renderer();
if (!wmoRenderer || !m2Renderer) return;
size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME;
for (auto it = pendingTransportDoodadBatches_.begin();
it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) {
auto goIt = gameObjectInstances_.find(it->guid);
if (goIt == gameObjectInstances_.end() || !goIt->second.isWmo ||
goIt->second.instanceId != it->instanceId || goIt->second.modelId != it->modelId) {
it = pendingTransportDoodadBatches_.erase(it);
continue;
}
const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(it->modelId);
if (!doodadTemplates || doodadTemplates->empty()) {
it = pendingTransportDoodadBatches_.erase(it);
continue;
}
const size_t maxIndex = std::min(it->doodadBudget, doodadTemplates->size());
while (it->nextIndex < maxIndex && budgetLeft > 0) {
const auto& doodadTemplate = (*doodadTemplates)[it->nextIndex];
it->nextIndex++;
budgetLeft--;
uint32_t doodadModelId = static_cast<uint32_t>(std::hash<std::string>{}(doodadTemplate.m2Path));
auto m2Data = assetManager->readFile(doodadTemplate.m2Path);
if (m2Data.empty()) continue;
pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data);
std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin";
std::vector<uint8_t> skinData = assetManager->readFile(skinPath);
if (!skinData.empty() && m2Model.version >= 264) {
pipeline::M2Loader::loadSkin(skinData, m2Model);
}
if (!m2Model.isValid()) continue;
m2Renderer->loadModel(m2Model, doodadModelId);
uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f);
if (m2InstanceId == 0) continue;
wmoRenderer->addDoodadToInstance(it->instanceId, m2InstanceId, doodadTemplate.localTransform);
it->spawnedDoodads++;
}
if (it->nextIndex >= maxIndex) {
if (it->spawnedDoodads > 0) {
LOG_INFO("Spawned ", it->spawnedDoodads,
" transport doodads for WMO instance ", it->instanceId);
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(it->x, it->y, it->z));
glm::mat4 wmoTransform(1.0f);
wmoTransform = glm::translate(wmoTransform, renderPos);
wmoTransform = glm::rotate(wmoTransform, it->orientation, glm::vec3(0, 0, 1));
wmoRenderer->setInstanceTransform(it->instanceId, wmoTransform);
}
it = pendingTransportDoodadBatches_.erase(it);
} else {
++it;
}
}
}
void Application::processPendingMount() {
if (pendingMountDisplayId_ == 0) return;
uint32_t mountDisplayId = pendingMountDisplayId_;
@ -5388,6 +5446,11 @@ void Application::despawnOnlineCreature(uint64_t guid) {
}
void Application::despawnOnlineGameObject(uint64_t guid) {
pendingTransportDoodadBatches_.erase(
std::remove_if(pendingTransportDoodadBatches_.begin(), pendingTransportDoodadBatches_.end(),
[guid](const PendingTransportDoodadBatch& b) { return b.guid == guid; }),
pendingTransportDoodadBatches_.end());
auto it = gameObjectInstances_.find(guid);
if (it == gameObjectInstances_.end()) return;

View file

@ -440,7 +440,7 @@ GLuint CharacterRenderer::loadTexture(const std::string& path) {
textureCacheBytes_ / (1024 * 1024), " MB > ",
textureCacheBudgetBytes_ / (1024 * 1024), " MB (textures=", textureCache.size(), ")");
}
core::Logger::getInstance().info("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")");
core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")");
return texId;
}