Runtime Virtual Texture corruption when changing scalability at runtime (3+ private-space RVTs in the world)

### 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)

재현 방법
### Symptom

With **3 or more `URuntimeVirtualTextureComponent`s** active in the world, changing `r.MaterialQualityLevel` at runtime (via console, in-game settings, or gameplay code) causes **persistent visual corruption** of the affected RVTs — and other VT-consuming textures — that does not self-heal. With 2 or fewer RVTs in the world, the issue never reproduces. Reproduces in packaged Shipping builds, not just PIE/editor.

We suspect the “3+” threshold is not a coincidence: GPU feedback for VT page requests is only supported for up to `VIRTUALTEXTURE_MAX_FEEDBACK_SPACES` (16) spaces

### Repro steps

1. Place 3+ `URuntimeVirtualTextureComponent` volumes in a level, each with its own `URuntimeVirtualTexture` asset (default `bPrivateSpace = true`).

2. At runtime, change `r.MaterialQualityLevel` to a genuinely different value

3. Observe: one or more RVTs (and sometimes unrelated VT-backed textures) render corrupted/garbled content that persists — it does not recover on its own, only a further quality change or full re-registration seems to reset it.

### What we ruled out

- Not PIE-specific — reproduces in packaged builds.

- Not related to `bUseMinMaterialQuality` (confirmed off in our project).

- Not a pool-size/oversubscription issue (increasing `r.VT.PoolSizeMB`/physical pool scale did not help).

- Not fixed by manually invalidating the RVT (`URuntimeVirtualTexture::Invalidate`) or forcing a repaint.

- Not fixed by delaying/pausing world rendering for several frames before/after the change (tested via `FGlobalComponentRecreateRenderStateContext` timing, `FlushRenderingCommands()`, and even `FPlatformProcess::Sleep()` inside the dead-component window — none of these eliminate it).

- Not caused by `UGameUserSettings::ApplySettings()`'s normal flow *per se* — going through the real settings-apply path (full `Scalability::SetQualityLevels()`, which re-applies **all** scalability groups, not just Effects/MaterialQuality) reliably avoids the corruption in our testing, while changing `r.MaterialQualityLevel` or even a single `sg.EffectsQuality` preset step in isolation reliably reproduces it.

- Not solely explained by `bPrivateSpace`'s immediate-release bypass either — setting `bPrivateSpace = false` on the RVTs (which restores the normal `CVarVTSpaceReleaseFrames` grace period in `ReleaseSpace()`) still reproduces the corruption. So while the immediate release for private spaces is a real gap we found in the code, it isn’t the whole story.

### What we found

- `Engine/Source/Runtime/Renderer/Private/VT/VirtualTextureSystem.cpp` — `FVirtualTextureSystem::AcquireSpace()` reuses a freed `FVirtualTextureSpace` slot ID via a simple “first empty slot” scan (no generation/version check on the ID). `FVirtualTextureSpace` itself (`VirtualTextureSpace.h`) has no staleness/generation field — only a plain `NumRefs`.

- `FVirtualTextureSystem::ReleaseSpace()` — **private-space VTs bypass the normal deferred-release safety net entirely**: for non-private spaces, a released space waits `CVarVTSpaceReleaseFrames` frames before its slot can be reused (to avoid handing the ID to a new VT while in-flight GPU feedback still references the old one). For spaces with `bPrivateSpace = true` (the RVT default), the slot is freed **immediately** when ref count hits 0 — no grace period at all. (That said, this alone doesn’t fully explain our repro — see “what we ruled out” above: setting `bPrivateSpace = false` still reproduces the corruption, so the grace period isn’t sufficient protection by itself either.)

- GPU feedback readback for VT page requests is inherently asynchronous (1+ frame latency), decoded via `GetSpace(ID)` with no generation check — so if a private space’s SpaceID gets reused for a brand-new `FVirtualTextureSpace` while feedback tagged with the *old* occupant of that ID is still in flight, the readback can write page-table updates into the wrong (new) space.

- Empirically, whether the corruption reproduces tracks three specific conditions around *how* the quality change is applied — see the next section for the full breakdown.

It’s worth taking this change that was in UE5.8 to see if it helps:

https://github.com/EpicGames/UnrealEngine/commit/49c2c682af0183af19eb056651c11094f2a8cb95