UE 5.8.1 (visionOS) — Whole-scene spot/point light shadows vanish under single-pass stereo (SelectedViewIndex ignores ShouldRenderView)

Summary

Movable whole-scene spot and point light shadows intermittently disappear entirely when single-pass stereo (mobile multiview or instanced stereo) is enabled. In FSceneRenderer::CreateWholeSceneProjectedShadow (ShadowSetup.cpp), SelectedViewIndex can select a secondary stereo view, which FViewInfo::ShouldRenderView() never renders – causing every shadow caster to be culled from the shadow depth pass.

Engine version: UE 5.8.1 (code inspected in 5.8; the selection loop and ShouldRenderView() gate are long-standing and appear unchanged in earlier 5.x).

What Type of Bug are you experiencing?

Rendering (Graphics / Niagara)

Steps to Reproduce

  1. Create a VR project with instanced stereo or mobile multiview enabled.
  2. Add a Movable Spot Light (or Point Light) with Cast Shadows enabled, covering several movable shadow-casting static meshes.
  3. Run in stereo on device.
  4. Turn your head slowly so the light – and especially its casters – move toward and past the edge of view.

Expected Result

The spot light’s shadows remain stable. Casters outside the view still cast into the shadow map, since their shadows fall on visible geometry.

Observed Result

The light’s shadows vanish completely and simultaneously for all casters, in both eyes, and reappear as the head moves back. The transition is abrupt, not a fade.

Affects Versions

5.8

Platform(s)

visionOS

Additional Notes

PLATFORM
Measured: reproduced and fixed on visionOS only (Apple Vision Pro, Metal, mobile multiview). That is the sole platform on which this defect was observed, instrumented, and verified fixed.
Inferred, NOT tested: the selection loop and its ShouldRenderView() gate are platform-agnostic, so by code inspection the same defect should reach any single-pass-stereo path – Quest/Android Vulkan with mobile multiview, and desktop VR with instanced stereo. We have NOT reproduced it on those platforms and are not claiming we have.

ROOT CAUSE
Engine/Source/Runtime/Renderer/Private/ShadowSetup.cpp, FSceneRenderer::CreateWholeSceneProjectedShadow:

// For whole scene lights, select the view index up-front such that culling & rendering can be consistent.
int32 SelectedViewIndex = 0;
for (int32 ViewIndex = 0; ViewIndex < ViewCount; ++ViewIndex)
{
    ...
    const float ScreenRadius = LightSceneInfo->Proxy->GetEffectiveScreenRadius(
        View.GetShadowViewMatrices(), View.ViewRect.Size());
    if (ScreenRadius > MaxScreenRadius)      // <-- does not check the view is rendered
    {
        SelectedViewIndex = ViewIndex;
        MaxScreenRadius = ScreenRadius;
    }
}

SelectedViewIndex is passed to FProjectedShadowInfo::AddSubjectPrimitive, whose caster loop is:

for (FViewInfo& CurrentView : Views)
{
    if (!CurrentView.ShouldRenderView()) { continue; }
    ...
}

FViewInfo::ShouldRenderView() (SceneRendering.h) is explicitly “Instanced stereo and multi-view only need to render the left eye” and returns false for a secondary-pass view under single-pass stereo.

Because the two eyes see the same light at almost the same screen radius (differing only by IPD parallax), the > comparison is a near-tie that flips with head motion. When the secondary view wins, AddSubjectPrimitive skips every primitive, no caster is added, and the shadow map renders empty – for both eyes, since the map is shared.

EVIDENCE
On-device instrumentation of the caster-gather path, UE 5.8.1 on Apple Vision Pro:

  • While shadows were missing: 982/982 caster rejections logged render=0 staticRel=1 dynRel=0 smallOK=1 – i.e. rejected solely by ShouldRenderView(), with static relevance and the small-caster cull both passing.
  • Per-reason census in the same frames: Visited=23 Added=0, with 6 rejected by interaction flags, 2 by the light-view convex hull, and 15 by the ShouldRenderView() gate (6+2+15=23).
  • After the one-line fix below: 0 such rejections, Added=9..17 every frame, and the shadows can no longer be made to disappear.

SUGGESTED FIX

if (ScreenRadius > MaxScreenRadius && View.ShouldRenderView())
{
    SelectedViewIndex = ViewIndex;
    MaxScreenRadius = ScreenRadius;
}

SelectedViewIndex is already initialized to 0 (the primary view), so a valid fallback always exists, and non-stereo rendering is unaffected because ShouldRenderView() returns true there.

NOTES

  • Not caused by the shadow-caster culls one would first suspect: verified on device that r.Shadow.RadiusThreshold, r.Shadow.LightViewConvexHullCull, r.Shadow.CacheWholeSceneShadows, r.SceneCulling.HierarchicalCPUCulling and r.SceneCulling.Async.* all fail to prevent it.
  • Disabling the whole-scene shadow cache makes it worse, because the cache was masking the defect by retaining casters from frames in which the primary view happened to win the tie.