From 2002e1737deb30378ec691b06210cf7bba4635be Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 31 Jul 2026 10:03:37 -0700 Subject: [PATCH] perf(wmo): upload model groups incrementally to kill the streaming hitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terrain streaming stalled ~165ms finalising a tile, and the phase timing put essentially all of it in one WMO_MODELS step: a single model taking 158ms against an 8ms budget. Within that load, group resource creation was 81% of the time — 131ms for 286 groups — with textures at 13% and merged batches at 6%, so the groups are what had to be split. loadModelIncremental uploads a bounded slice of groups per call and returns InProgress while more remain. The accumulating model lives in loadingModels_ until every group is up, so a half-built model is never visible to rendering, and its textures and materials are done once and skipped on resumed calls. loadModel keeps its old all-at-once behaviour for callers outside terrain streaming by looping until completion. The result is explicit rather than a bare bool, because the caller has to tell "more groups to come" from "this model failed" — overloading false would have spun forever on a bad model. Terrain finalisation now takes one model per step with a 6ms group budget, and no longer uploads two models per step when the loader threads are idle, which had doubled the worst case for nothing. --- include/rendering/wmo_renderer.hpp | 19 +++++++++ src/rendering/terrain_manager.cpp | 26 ++++++------ src/rendering/wmo_renderer.cpp | 67 +++++++++++++++++++++++------- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 7fa68831..3406dd3d 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -71,6 +71,14 @@ public: */ bool loadModel(const pipeline::WMOModel& model, uint32_t id); + enum class ModelLoadResult { Complete, InProgress, Failed }; + + /// Upload a model a few groups at a time. InProgress means call again with + /// the same arguments; a single large model can otherwise take over a + /// hundred milliseconds, which lands as a visible hitch when terrain + /// streaming finalises a tile. + ModelLoadResult loadModelIncremental(const pipeline::WMOModel& model, uint32_t id, float budgetMs); + /** * Check if a WMO model is currently resident in the renderer * @param id WMO model identifier @@ -549,6 +557,14 @@ private: // For each group: which portal refs belong to it (start index, count) std::vector> groupPortalRefs; + // Set once the textures and materials below have been populated, so a + // resumed load skips straight to the groups it has left. + bool setupDone = false; + // Next group to upload. A large model's groups are spread across several + // calls under a time budget rather than uploaded in one stall. + size_t nextGroupIndex = 0; + uint32_t loadedGroups = 0; + uint32_t getTotalTriangles() const { uint32_t total = 0; for (const auto& group : groups) { @@ -729,6 +745,9 @@ private: // Loaded models (modelId -> ModelData) std::unordered_map loadedModels; + // Models part-way through an incremental load; moved into loadedModels once + // every group is uploaded, so a half-built model is never rendered. + std::unordered_map loadingModels_; size_t modelCacheLimit_ = 4000; uint32_t modelLimitRejectWarnings_ = 0; diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 4784af80..0305c8db 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -1176,22 +1176,24 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { &pending->preloadedWMONormalMapVariances); wmoRenderer->setDeferNormalMaps(true); - bool wmoWorkersIdle; - { - std::lock_guard lk(queueMutex); - wmoWorkersIdle = loadQueue.empty() && readyQueue.empty(); - } - const size_t kWmosPerStep = wmoWorkersIdle ? 2 : 1; - size_t uploaded = 0; - while (ft.wmoModelIndex < pending->wmoModels.size() && uploaded < kWmosPerStep) { + // One model per step, and a large model spread across several + // steps: a 286-group WMO took 131ms to upload in one go, against + // this phase's 8ms budget. Uploading two per step when the workers + // were idle only doubled that worst case. + constexpr float kWmoGroupBudgetMs = 6.0f; + while (ft.wmoModelIndex < pending->wmoModels.size()) { auto& wmoReady = pending->wmoModels[ft.wmoModelIndex]; if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) { ft.wmoModelIndex++; - } else { - wmoRenderer->loadModel(wmoReady.model, wmoReady.modelId); - ft.wmoModelIndex++; - uploaded++; + continue; } + const auto result = wmoRenderer->loadModelIncremental( + wmoReady.model, wmoReady.modelId, kWmoGroupBudgetMs); + if (result == WMORenderer::ModelLoadResult::InProgress) { + break; // same model resumes on the next call + } + ft.wmoModelIndex++; // Complete or Failed — either way, move on + break; // one model per step } wmoRenderer->setDeferNormalMaps(false); wmoRenderer->setPredecodedBLPCache(nullptr); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index a0005ae9..7d626b74 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -353,9 +353,20 @@ void WMORenderer::shutdown() { } bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { + // No budget: run to completion, which is what callers outside terrain + // streaming expect. + for (;;) { + const ModelLoadResult r = loadModelIncremental(model, id, 0.0f); + if (r == ModelLoadResult::InProgress) continue; + return r == ModelLoadResult::Complete; + } +} + +WMORenderer::ModelLoadResult WMORenderer::loadModelIncremental( + const pipeline::WMOModel& model, uint32_t id, float budgetMs) { if (!model.isValid()) { core::Logger::getInstance().error("Cannot load invalid WMO model"); - return false; + return ModelLoadResult::Failed; } // Check if already loaded @@ -392,10 +403,10 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { " has only fallback textures; forcing one-time reload"); unloadModel(id); } else { - return true; + return ModelLoadResult::Complete; } } else { - return true; + return ModelLoadResult::Complete; } } if (loadedModels.size() >= modelCacheLimit_) { @@ -405,13 +416,15 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { "), skipping model load: id=", id); } ++modelLimitRejectWarnings_; - return false; + return ModelLoadResult::Failed; } core::Logger::getInstance().debug("Loading WMO model ", id, " with ", model.groups.size(), " groups, ", model.textures.size(), " textures..."); - ModelData modelData; + // The accumulating model lives across calls, so a resumed load picks up its + // textures, materials and the groups already uploaded. + ModelData& modelData = loadingModels_[id]; modelData.id = id; modelData.boundingBoxMin = model.boundingBoxMin; modelData.boundingBoxMax = model.boundingBoxMax; @@ -436,11 +449,12 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { // submission with one fence wait, instead of one per upload. vkCtx_->beginUploadBatch(); - // Which of the three per-model passes owns the time. One of these steps was - // measured at 158ms, stalling terrain streaming; splitting the wrong one - // would not help. const auto wmoLoadT0 = std::chrono::steady_clock::now(); + // Textures and materials are model-level and done once; a resumed call has + // them already and goes straight to the remaining groups. + if (!modelData.setupDone) { + // Load textures for this model core::Logger::getInstance().debug(" WMO has ", model.textures.size(), " texture paths, ", model.materials.size(), " materials"); if (assetManager && !model.textures.empty()) { @@ -511,6 +525,12 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } + + modelData.setupDone = true; + } // end one-time setup + + // Reads only the model, so it is rebuilt cheaply on every resumed call + // rather than kept alive across them. // Helper: look up group name from MOGN raw data via MOGI nameOffset auto getGroupName = [&](uint32_t groupIdx) -> std::string { if (groupIdx < model.groupInfo.size()) { @@ -526,9 +546,20 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { const auto wmoLoadT1 = std::chrono::steady_clock::now(); - // Create GPU resources for each group - uint32_t loadedGroups = 0; - for (size_t gi = 0; gi < model.groups.size(); gi++) { + // Create GPU resources for each group, a bounded number per call. A model + // with hundreds of groups would otherwise upload them all in one step: the + // worst measured was 286 groups at 131ms, against an 8ms budget. + const auto groupStart = std::chrono::steady_clock::now(); + for (size_t gi = modelData.nextGroupIndex; gi < model.groups.size(); gi++) { + modelData.nextGroupIndex = gi + 1; + if (budgetMs > 0.0f) { + const float spent = std::chrono::duration( + std::chrono::steady_clock::now() - groupStart).count(); + if (spent >= budgetMs && gi + 1 < model.groups.size()) { + vkCtx_->endUploadBatch(); + return ModelLoadResult::InProgress; // resume here next call + } + } const auto& wmoGroup = model.groups[gi]; // Skip empty groups if (wmoGroup.vertices.empty() || wmoGroup.indices.empty()) { @@ -568,13 +599,15 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { resources.isLOD = true; } modelData.groups.push_back(resources); - loadedGroups++; + modelData.loadedGroups++; } } - if (loadedGroups == 0) { + if (modelData.loadedGroups == 0) { core::Logger::getInstance().warning("No valid groups loaded for WMO ", id); - return false; + vkCtx_->endUploadBatch(); + loadingModels_.erase(id); + return ModelLoadResult::Failed; } const auto wmoLoadT2 = std::chrono::steady_clock::now(); @@ -913,9 +946,11 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { } } + modelData.setupDone = true; loadedModels[id] = std::move(modelData); - core::Logger::getInstance().debug("WMO model ", id, " loaded successfully (", loadedGroups, " groups)"); - return true; + loadingModels_.erase(id); + core::Logger::getInstance().debug("WMO model ", id, " loaded successfully (", modelData.loadedGroups, " groups)"); + return ModelLoadResult::Complete; } bool WMORenderer::isModelLoaded(uint32_t id) const {