GetShaderBindings Crash

We’re hitting a crash in FMaterialShader::GetShaderBindings (ShaderBaseClasses.cpp) when changing scalability settings at runtime.

checkf(UniformExpressionCache.CachedUniformExpressionShaderMap == Material.GetRenderingThreadShaderMap())

Root cause analysis:

In ScalabilityCVarsSinkCallback() (UnrealEngine.cpp), when MaterialQualityLevel changes, AllMaterialsCacheResourceShadersForRendering() is called, which internally calls CacheUniformExpressions()

on all material proxies. This synchronously invalidates each proxy’s UniformExpressionCache (sets CachedUniformExpressionShaderMap = nullptr) and adds it to the DeferredUniformExpressionCacheRequests set — but does not re-evaluate.

When the FGlobalComponentRecreateRenderStateContext destructor triggers FScene::Update(), two things happen:

Line 5291: UpdateDeferredCachedUniformExpressions() is called, potentially as an async task (UpdateUniformExpressionsTask)

Line 6404: CacheMeshDrawCommandsTask is launched with dependencies on AddStaticMeshesTask only — not on UpdateUniformExpressionsTask

It would seem that there is no dependency between these tasks, CacheMeshDrawCommands can execute while uniform expression caches are still stale.

GetMaterialNoFallback() returns the new quality level’s FMaterial (new shader map), but the proxy’s cache still references the old shader map (or nullptr), triggering the checkf.

Suggested fix:

Add UpdateUniformExpressionsTask as a dependency of CacheMeshDrawCommandsTask in RendererScene.cpp:

CacheMeshDrawCommandsTask = GraphBuilder.AddSetupTask([...] {
 
  FPrimitiveSceneInfo::CacheMeshDrawCommands(this, SceneInfosWithStaticDrawListUpdate);
 
}, MakeArrayView({ AddStaticMeshesTask, UpdateUniformExpressionsTask, ... }), ...);
 
This would ensures uniform expression caches are fully evaluated before mesh draw command caching begins.

Is the dependency fix the correct approach, or would you recommend a different synchronization strategy?

Note: I think [this [Content removed] is describing the same issue.

[Attachment Removed]

Hi Yann,

Thank you for your investigation and report!

At first glance I believe your fix is definitely correct.

We are looking at a similar issue at the moment and we’ll get back to you soon.

Massimo

[Attachment Removed]

Hi Massimo,

This did not fix it.

As it turns out, there is a racing condition in UnrealEngine.cpp in ScalabilityCVarsSinkCallback.

Consider a scenario where a material has Medium and Low quality resources.

We’re currently in medium settings, with some primitive using material instances of that material, let’s call them M_01 and MI_01.

Then we switch from Medium settings to Low.

When declaring

FGlobalComponentRecreateRenderStateContext Recreate;Primitives, which might use MI_01 (cached with medium shadermap), get queued for rendering (but not flushed).

Then we call:

UMaterial::AllMaterialsCacheResourceShadersForRendering(false, bCacheAllRemainingShaders);
UMaterialInstance::AllMaterialsCacheResourceShadersForRendering(true, bCacheAllRemainingShaders);

Which refreshes M_01 with Low shadermap, and queues a deferred update for MI_01.

In parallel, drawcalls queued by Recreate are running, and MI_01, which still runs with outdated medium shadermap, gets the mismatch mentioned in the original post.

So the issue is that Recreate drawcalls are not ran immediately, but while materials are being updated.

I fixed it by adding a FlushRenderingCommands right after Recreate, but before materials are refreshed.

// Make the render state rebuild object before updating the cached values, because its constructor calls UpdateAllPrimitiveSceneInfos(), which
// may end up using the material shader maps for the quality level stored in GCachedScalabilityCVars.MaterialQualityLevel. If that value already
// points to the new quality level, the shader maps won't exist, and we'll crash.
FGlobalComponentRecreateRenderStateContext Recreate;
 
// [SENDBACK] Flush queued drawcalls from Recreate which might use materials we are about to update.
FlushRenderingCommands();
 
// after FlushRenderingCommands() to not have render thread pick up the data partially
GCachedScalabilityCVars = LocalScalabilityCVars;
 
if (bCacheResourceShaders)
{
	// For all materials, UMaterial::CacheResourceShadersForRendering
	UMaterial::AllMaterialsCacheResourceShadersForRendering(false, bCacheAllRemainingShaders);
	UMaterialInstance::AllMaterialsCacheResourceShadersForRendering(true, bCacheAllRemainingShaders);
}

Didn’t have the issue since.

What is your opinion on this?

[Attachment Removed]

Hi Yann,

Thank you for delving into this. You’re right, adding the dependency to the CacheMeshDrawCommands task seems like the fix for another race.

I think your approach is valid. The Game Thread would not have much else to do in the meantime anyway, and swapping scalability path is a heavy operation already anyway.

Thanks for the fix. I will whip up a Jira ticket and solve it using your fix. Will let you know when it’s in but for now, your approach works.

-Massimo

[Attachment Removed]

Thanks Massimo!

[Attachment Removed]