perf(wmo): upload model groups incrementally to kill the streaming hitch

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.
This commit is contained in:
Kelsi
2026-07-31 10:03:37 -07:00
parent d8b9e8c751
commit 2002e1737d
3 changed files with 84 additions and 28 deletions

View File

@@ -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<std::pair<uint16_t, uint16_t>> 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<uint32_t, ModelData> 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<uint32_t, ModelData> loadingModels_;
size_t modelCacheLimit_ = 4000;
uint32_t modelLimitRejectWarnings_ = 0;

View File

@@ -1176,22 +1176,24 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
&pending->preloadedWMONormalMapVariances);
wmoRenderer->setDeferNormalMaps(true);
bool wmoWorkersIdle;
{
std::lock_guard<std::mutex> 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);

View File

@@ -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<float, std::milli>(
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 {