Implementing multiple shadow "layers" per light using Virtual Shadow Maps

Hi! This is an open-ended question about how one would approach an engine modification to allow for what we’re trying to do.

For context, we are developing a rendering stack that uses the composition of a pre-rendered background (which is composed of multiple “light contribution” layers which bake high-quality light and shadow interaction for a given light), and a small amount of dynamically rendered 3D geometry (like the player pawn). The 3D geometry is currently rendered with deferred rendering, but it could be forward rendered as well.

We also define invisible shadow proxy geometry for anything that is present in the pre-rendered background and that should cast shadows onto dynamic 3D geometry.

What I’m looking to resolve is the ability for :

  • Environment shadow proxies to cast shadows onto 3D geometry
    • For these, shadow depths should contain both 3D geometry (for self-shadows) and shadow proxies
  • Dynamic 3D geometry to cast shadows onto the pre-rendered environment
    • For these, shadow depths should only contain 3D geometry, since the pre-renders already contain high-quality baked shadows

We had a working prototype using the ES3.1 Mobile Renderer, CSM and whole-scene shadow maps, where I added a bMovableSubjectsOnly flag in FProjectedShadowInfo, and modified ComputeWholeSceneShadowCacheModes to ask for generation of a “duplicate” shadowmap which contains only movable subjects (3D geometry).

Then, FLightSceneInfo::SetupMobileMovableLocalLightShadowParameters was modified to feed the right shadow map to shader uniforms for deferred shading of 3D geometry, and I manually sampled the movable-subjects-only shadow map when compositing the pre-rendered background.

We are now transitioning to the non-mobile Deferred Renderer, and we’d like to support Virtual Shadow Maps. This poses a few problems :

  • Is it possible a single light to have more than 1 set of VSM tiles, with different primitive gathering settings? Would that be using separate physical page pools, or a second FVirtualShadowMapArray? Is there something like that already implemented in the engine that I could use as a reference?
  • Since VSM tiles are generated based on view culling results, and the pre-rendered background isn’t made of geometry (it’s injected as a fullscreen pass), how can I make sure that I have shadow depths generated for the entire screen?
  • Would it be required to have a separate shadow mask generated for the background, or is it possible sample VSM “inline” in screen-space as I’m performing compositing, similarly to how I did it in the CSM+Mobile setup?
  • Would this approach be compatible with VSM caching at all?

I’m not looking for exact implementation steps, but I’m curious about the general best approach to do this, and whether I’m going in a dangerous/overly complex direction to begin with. My working proof-of-concept Mobile implementation made me think this was viable, but in the VSM world it may be a different story.

Thank you!

[Attachment Removed]

Quick answer (without really reading properly):

At a high level it sounds similar to the thing we did for first-person shadows. You could look at that as a starting point and see if that helps. See FShadowScene/FShadowSceneRenderer for what drives this.

[Attachment Removed]

Hi. Glad to hear it was helpful. For the local light part, yes, we don’t have as much of a template for that - hopefully the path for the directional can be adapted as you say & the machinery is flexible enough that it can be fairly self contained at least. I did try to make sure the general VSM machinery was not specific to the FP rendering.

[Attachment Removed]

Hi.

The issue there is looks like the page marking doesn’t mark any pages for the new VSM - at least that very much looks like just the coarse pages being rendered and used. So yes, if you have a separate depth buffer when you want pages marked for that needs to be injected in the page marking shader or done as a separate pass. Both hair and water has some examples of how this might be done.

Hope this helps!

[Attachment Removed]

That was a really helpful comment, thanks Ola!

I’m starting to have a working implementation for directional lights, the tricky part after that will be to have the same functionality with local lights as well, but hopefully we can use the same method.

[Attachment Removed]

I think I have most of the pipeline in place (with some assumptions) such that I can have separate VSMs for a subset of the scene geometry, but I’m having some issues with shadow resolution that I don’t yet understand.

In broad strokes :

  • I modified FShadowSceneRenderer::AddLocalLightShadow, currently only for non-distant spot lights, such that it allocates all the data structures required for a movable-objects-only VSM. I don’t think I forgot anything, but there’s a lot of moving parts so it’s possible that I did. I use the same Shadow Map Type ID that works for the directional light clipmap approach, fed to FindCreateLightCacheEntry :
	if (!ShadowScene.MovablePrimitives.IsEmpty() && NumMaps == 1 && !bIsDistantLight)
	{
		FProjectedShadowInfo* MovableOnlyProjectedShadowInfo = SceneRenderer.Allocator.Create<FProjectedShadowInfo>();
		FLocalLightShadowFrameSetup& MovableOnlyLocalLightSetup = LocalLights.AddDefaulted_GetRef();
 
		MovableOnlyProjectedShadowInfo->SetupWholeSceneProjection(
			LightSceneInfo, 
			nullptr, 
			ProjectedShadowInitializer, 
			ProjectedShadowInfo->ResolutionX,
			ProjectedShadowInfo->ResolutionY,
			ProjectedShadowInfo->ResolutionX,
			ProjectedShadowInfo->ResolutionY,
			0);
		
		FVisibleLightInfo& VisibleLightInfo = SceneRenderer.VisibleLightInfos[LightId];
		VisibleLightInfo.AllProjectedShadows.Add(MovableOnlyProjectedShadowInfo);
		TSharedPtr<FVirtualShadowMapPerLightCacheEntry> MovableOnlyPerLightCacheEntry = CacheManager->FindCreateLightCacheEntry(LightId, 0, 1, EVirtualShadowTypeId::MovableOnly);
		MovableOnlyLocalLightSetup.PerLightCacheEntry = MovableOnlyPerLightCacheEntry;
 
		MovableOnlyPerLightCacheEntry->UpdateLocal(
			ProjectedShadowInitializer,
			LightSceneProxy->GetOrigin(),
			LightSceneProxy->GetRadius(),
			false,
			true,
			!bShouldForceTimeSliceDistantUpdate,
			IsVirtualShadowMapLocalReceiverMaskEnabled());
 
		const int32 MovableOnlyVirtualShadowMapId = VirtualShadowMapArray.Allocate(false, 1);
		MovableOnlyLocalLightSetup.VirtualShadowMapId = MovableOnlyVirtualShadowMapId;
		MovableOnlyProjectedShadowInfo->VirtualShadowMapId = MovableOnlyVirtualShadowMapId;
		MovableOnlyProjectedShadowInfo->VirtualShadowMapPerLightCacheEntry = MovableOnlyPerLightCacheEntry;
		MovableOnlyProjectedShadowInfo->bShouldRenderVSM = !MovableOnlyPerLightCacheEntry->IsFullyCached();
		MovableOnlyProjectedShadowInfo->bVSM = true;
		MovableOnlyProjectedShadowInfo->MeshSelectionMask = EShadowMeshSelection::VSM;
		MovableOnlyProjectedShadowInfo->bMovableSubjectsOnly = true;
		
		MovableOnlyLocalLightSetup.ProjectedShadowInfo = MovableOnlyProjectedShadowInfo;
		MovableOnlyLocalLightSetup.LightSceneInfo = LightSceneInfo;
		
		FVirtualShadowMapCacheEntry& MovableOnlyVirtualSmCacheEntry = MovableOnlyPerLightCacheEntry->ShadowMapEntries[0];
		MovableOnlyVirtualSmCacheEntry.Update(VirtualShadowMapArray, *MovableOnlyPerLightCacheEntry, MovableOnlyVirtualShadowMapId);
 
		FVirtualShadowMapProjectionShaderData& ProjectionData = MovableOnlyVirtualSmCacheEntry.ProjectionData;
 
		UpdateLocalLightProjectionShaderDataMatrices(MovableOnlyProjectedShadowInfo, 0, &ProjectionData);
 
		const FLightSceneProxy* Proxy = LightSceneInfo->Proxy;
		uint32 PackedCullingViewId = FVirtualShadowMapProjectionShaderData::PackCullingViewId(SceneRenderer.Views[ClosestCullingViewIndex].SceneRendererPrimaryViewId, SceneRenderer.Views[ClosestCullingViewIndex].PersistentViewId);
		uint32 Flags = MovableOnlyPerLightCacheEntry->IsUncached() ? VSM_PROJ_FLAG_UNCACHED : 0U;
		Flags |= MovableOnlyPerLightCacheEntry->ShouldUseReceiverMask() ? VSM_PROJ_FLAG_USE_RECEIVER_MASK : 0U;
		
		ProjectionData.LightType			= Proxy->GetLightType();
		ProjectionData.LightSourceRadius	= Proxy->GetSourceRadius();	
		ProjectionData.LightRadius			= Proxy->GetRadius();
		ProjectionData.TexelDitherScale		= Proxy->GetVSMTexelDitherScale();
		ProjectionData.ResolutionLodBias	= ResolutionLODBiasLocal;
		ProjectionData.Flags				= Flags;
		ProjectionData.MinMipLevel			= MinMipLevel;
		ProjectionData.PackedCullingViewId	= PackedCullingViewId;
		
		for (FPrimitiveSceneInfo* PrimitiveSceneInfo : ShadowScene.MovablePrimitives)
		{
			MovableOnlyProjectedShadowInfo->AddSubjectPrimitive(PrimitiveSceneInfo, SceneRenderer.Views, false);
			MovableOnlyPerLightCacheEntry->OnPrimitiveRendered(PrimitiveSceneInfo, false);
		}
	}
  • I added the movable-only VSM ID into packed light data structure (in a pretty crude way, to make sure I can obtain it when projecting the mask bits), and added support for this secondary VSM ID in one-pass-projection of local lights in ProjectLight() (VirtualShadowMapProjection.usf).

The issue I have is that I don’t think the culling systems know what to do with my secondary VSM, or at least it’s not allocating the amount of pages I would expect it to allocate, it seems to be the lowest possible resolution. Also, depending on where the movable primtives are, sometimes the movable-only projected shadow will completely disappear.

[Image Removed]The shadows do only contain the movable primitives, so the specific sampling of the secondary VSM appears to work, and subject-primitive association with the ProjectedShadowInfo seems to work as well. I suspect that the VSM systems don’t consider that the pre-rendered background is a proper shadow receiver, and thus do not allocate the right amount of pages?

I do have depth information of this background in SceneDepth, but the G-Buffer is otherwise unpopulated.

I would love any pointer to progress on this, I will continue investigating on my side but I don’t have a clear trail yet.

Thanks!

[Attachment Removed]