### Working hypothesis: transient doubling of active spaces during recreate
`Engine/Source/Runtime/Engine/Private/VT/RuntimeVirtualTexture.cpp` — `FRuntimeVirtualTextureRenderResource::Init()` deliberately registers the **new** producer (and thus acquires its VT space) *before* releasing the **old** one, per the comment: “Release old producer after new one is created so that any destroy callbacks can access the new producer.” During a mass component recreate (`FGlobalComponentRecreateRenderStateContext` destroying and recreating every `URuntimeVirtualTextureComponent` in the world at once), this means each RVT can momentarily hold **both** its old and new space simultaneously. With 3 RVTs that’s a transient 3→6 spike; in a level closer to the `VIRTUALTEXTURE_MAX_FEEDBACK_SPACES` (16) ceiling (RVTs plus any other private-space VT consumers), this transient doubling could plausibly push the *momentary* active-space count over 16. If that happens, `AcquireSpace()`'s fallback path either forces a `DestroyPendingVirtualTextures(true)` flush mid-recreate (the `TryIndex == 1` retry) or allocates a space at an ID `>= VIRTUALTEXTURE_MAX_FEEDBACK_SPACES`, which the code explicitly warns “Feedback will not work for this space” for. Either outcome would produce exactly the kind of persistent, non-self-healing corruption we’re seeing, and would explain the “3+ RVTs” threshold far more directly than a pure timing race would.
### The empirically “safe” recipe (and what breaks it)
Through extensive live testing (with temporary `UE_LOG` instrumentation added to `FGlobalComponentRecreateRenderStateContext` and `ScalabilityCVarsSinkCallback`), we found the corruption reliably tracks three conditions, all of which must hold:
1. The cvar `Set()` call **and** the `IConsoleManager::Get().CallAllConsoleVariableSinks()` call must both happen synchronously, inside the same `FGlobalComponentRecreateRenderStateContext` scope — i.e. all affected components must already be destroyed *before* the value change and sink dispatch happen, with no gap where a component could be alive with the new value applied but not yet recreated.
2. The sink dispatch must happen *immediately* (forced by us), not left to the engine’s normal once-per-frame automatic `CallAllConsoleVariableSinks()` dispatch — relying on the automatic dispatch reopens a window where components are alive with the new value for part of a frame.
3. The change must cause `r.DetailMode`'s actual value to differ from its previously cached value, which flips `ScalabilityCVarsSinkCallback`'s local `bRecreateRenderstate` flag to true (`UnrealEngine.cpp` ~line 906).
Violating **any single one** of these reliably reproduces the corruption in our testing:
- Skipping condition 1 or 2 (e.g. a raw `sg.EffectsQuality 2` console command, or any `Set()` not immediately followed by a forced synchronous sink call inside a `FGlobalComponentRecreateRenderStateContext`) — breaks, even when `r.DetailMode` does change.
- Satisfying 1 and 2 but violating condition 3 — breaks. We hit this concretely with our project’s `sg.EffectsQuality` presets: `EffectsQuality[Content removed] 0↔2).
We could not find any code path where `r.DetailMode` has a *direct* effect on RVT/VT allocation. We suspect condition 3 is incidental — DetailMode changes trigger a large amount of *additional*, unrelated component/render-state churn that somehow avoids hitting the race — but a plain `FPlatformProcess::Sleep()` **and** an explicit `FlushRenderingCommands()` inserted in the same dead-component window (in place of the DetailMode side effects) do **not** reproduce the same protective effect, which rules out “just waiting longer” or “just flushing the render thread” as the mechanism.
MaterialQualitySetting_Test.mp4(17.8 MB)