Fix terrain streaming crash: pendingTiles data race and missing null checks

Guard pendingTiles.erase() with queueMutex in processReadyTiles and
unloadTile to prevent data race with worker threads. Add defensive null
checks in M2/WMO render and animation paths. Move cleanupUnusedModels
out of per-tile unload loop to run once after all tiles are removed.
This commit is contained in:
Kelsi 2026-02-07 18:57:34 -08:00
parent 0422b7573c
commit c0803089c0
3 changed files with 44 additions and 19 deletions

View file

@ -1318,9 +1318,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
if (animCount < 32 || numAnimThreads_ <= 1) { if (animCount < 32 || numAnimThreads_ <= 1) {
// Sequential — not enough work to justify thread overhead // Sequential — not enough work to justify thread overhead
for (size_t i : boneWorkIndices) { for (size_t i : boneWorkIndices) {
if (i >= instances.size()) continue;
auto& inst = instances[i]; auto& inst = instances[i];
const auto& mdl = models.find(inst.modelId)->second; auto mdlIt = models.find(inst.modelId);
computeBoneMatrices(mdl, inst); if (mdlIt == models.end()) continue;
computeBoneMatrices(mdlIt->second, inst);
} }
} else { } else {
// Parallel — dispatch across worker threads // Parallel — dispatch across worker threads
@ -1338,9 +1340,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
[this, &boneWorkIndices, start, end]() { [this, &boneWorkIndices, start, end]() {
for (size_t j = start; j < end; ++j) { for (size_t j = start; j < end; ++j) {
size_t idx = boneWorkIndices[j]; size_t idx = boneWorkIndices[j];
if (idx >= instances.size()) continue;
auto& inst = instances[idx]; auto& inst = instances[idx];
const auto& mdl = models.find(inst.modelId)->second; auto mdlIt = models.find(inst.modelId);
computeBoneMatrices(mdl, inst); if (mdlIt == models.end()) continue;
computeBoneMatrices(mdlIt->second, inst);
} }
})); }));
start = end; start = end;
@ -1354,8 +1358,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
// Phase 3: Particle update (sequential — uses RNG, not thread-safe) // Phase 3: Particle update (sequential — uses RNG, not thread-safe)
for (size_t idx : boneWorkIndices) { for (size_t idx : boneWorkIndices) {
if (idx >= instances.size()) continue;
auto& instance = instances[idx]; auto& instance = instances[idx];
const auto& model = models.find(instance.modelId)->second; auto mdlIt = models.find(instance.modelId);
if (mdlIt == models.end()) continue;
const auto& model = mdlIt->second;
if (!model.particleEmitters.empty()) { if (!model.particleEmitters.empty()) {
emitParticles(instance, model, deltaTime); emitParticles(instance, model, deltaTime);
updateParticles(instance, deltaTime); updateParticles(instance, deltaTime);
@ -1471,13 +1478,16 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
const M2ModelGPU* currentModel = nullptr; const M2ModelGPU* currentModel = nullptr;
for (const auto& entry : sortedVisible) { for (const auto& entry : sortedVisible) {
if (entry.index >= instances.size()) continue;
const auto& instance = instances[entry.index]; const auto& instance = instances[entry.index];
// Bind VAO once per model group // Bind VAO once per model group
if (entry.modelId != currentModelId) { if (entry.modelId != currentModelId) {
if (currentModel) glBindVertexArray(0); if (currentModel) glBindVertexArray(0);
currentModelId = entry.modelId; currentModelId = entry.modelId;
currentModel = &models.find(currentModelId)->second; auto mdlIt = models.find(currentModelId);
if (mdlIt == models.end()) continue;
currentModel = &mdlIt->second;
glBindVertexArray(currentModel->vao); glBindVertexArray(currentModel->vao);
} }

View file

@ -676,7 +676,10 @@ void TerrainManager::processReadyTiles() {
if (pending) { if (pending) {
TileCoord coord = pending->coord; TileCoord coord = pending->coord;
finalizeTile(std::move(pending)); finalizeTile(std::move(pending));
{
std::lock_guard<std::mutex> lock(queueMutex);
pendingTiles.erase(coord); pendingTiles.erase(coord);
}
processed++; processed++;
} }
} }
@ -694,16 +697,22 @@ void TerrainManager::processAllReadyTiles() {
if (pending) { if (pending) {
TileCoord coord = pending->coord; TileCoord coord = pending->coord;
finalizeTile(std::move(pending)); finalizeTile(std::move(pending));
{
std::lock_guard<std::mutex> lock(queueMutex);
pendingTiles.erase(coord); pendingTiles.erase(coord);
} }
} }
} }
}
void TerrainManager::unloadTile(int x, int y) { void TerrainManager::unloadTile(int x, int y) {
TileCoord coord = {x, y}; TileCoord coord = {x, y};
// Also remove from pending if it was queued but not yet loaded // Also remove from pending if it was queued but not yet loaded
{
std::lock_guard<std::mutex> lock(queueMutex);
pendingTiles.erase(coord); pendingTiles.erase(coord);
}
auto it = loadedTiles.find(coord); auto it = loadedTiles.find(coord);
if (it == loadedTiles.end()) { if (it == loadedTiles.end()) {
@ -750,14 +759,6 @@ void TerrainManager::unloadTile(int x, int y) {
} }
loadedTiles.erase(it); loadedTiles.erase(it);
// Clean up any models that are no longer referenced
if (m2Renderer) {
m2Renderer->cleanupUnusedModels();
}
if (wmoRenderer) {
wmoRenderer->cleanupUnusedModels();
}
} }
void TerrainManager::unloadAll() { void TerrainManager::unloadAll() {
@ -1091,6 +1092,14 @@ void TerrainManager::streamTiles() {
} }
if (!tilesToUnload.empty()) { if (!tilesToUnload.empty()) {
// Clean up models that lost all instances (once, after all tiles removed)
if (m2Renderer) {
m2Renderer->cleanupUnusedModels();
}
if (wmoRenderer) {
wmoRenderer->cleanupUnusedModels();
}
LOG_INFO("Unloaded ", tilesToUnload.size(), " distant tiles, ", LOG_INFO("Unloaded ", tilesToUnload.size(), " distant tiles, ",
loadedTiles.size(), " remain"); loadedTiles.size(), " remain");
} }

View file

@ -852,8 +852,11 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm:
bool doFrustumCull = frustumCulling; bool doFrustumCull = frustumCulling;
auto cullInstance = [&](size_t instIdx) -> InstanceDrawList { auto cullInstance = [&](size_t instIdx) -> InstanceDrawList {
if (instIdx >= instances.size()) return InstanceDrawList{};
const auto& instance = instances[instIdx]; const auto& instance = instances[instIdx];
const ModelData& model = loadedModels.find(instance.modelId)->second; auto mdlIt = loadedModels.find(instance.modelId);
if (mdlIt == loadedModels.end()) return InstanceDrawList{};
const ModelData& model = mdlIt->second;
InstanceDrawList result; InstanceDrawList result;
result.instanceIndex = instIdx; result.instanceIndex = instIdx;
@ -942,8 +945,11 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm:
// ── Phase 2: Sequential GL draw ──────────────────────────────── // ── Phase 2: Sequential GL draw ────────────────────────────────
for (const auto& dl : drawLists) { for (const auto& dl : drawLists) {
if (dl.instanceIndex >= instances.size()) continue;
const auto& instance = instances[dl.instanceIndex]; const auto& instance = instances[dl.instanceIndex];
const ModelData& model = loadedModels.find(instance.modelId)->second; auto modelIt = loadedModels.find(instance.modelId);
if (modelIt == loadedModels.end()) continue;
const ModelData& model = modelIt->second;
// Occlusion query pre-pass (GL calls — must be main thread) // Occlusion query pre-pass (GL calls — must be main thread)
if (occlusionCulling && occlusionShader && bboxVao != 0) { if (occlusionCulling && occlusionShader && bboxVao != 0) {