UE 5.7.4 — Use-after-free of FD3D12SamplerState in FD3D12DescriptorCache::BuildSamplerTable during Nanite BasePass

Summary

Reproducible EXCEPTION_ACCESS_VIOLATION in the D3D12 RHI while binding sampler
descriptors for a Nanite base-pass compute shader. The faulting instruction
dereferences a FD3D12SamplerState* that passes a null check but points to freed
memory.

Reproduces on 10+ machines, on both AMD and NVIDIA GPUs. The faulting address
resolves inside the game executable, not in any driver module.

Setting r.Nanite=0 makes it stop reproducing (see KEY LOCALIZATION below).

ENVIRONMENT

Engine : 5.7.4-51494982+++UE5+Release-5.7 (Launcher binary install)
Configurations : reproduced in DebugGame, Development and Shipping
Platform : Win64
OS (capture rig) : Windows 11 25H2 [10.0.26200.9168]
CPU (capture rig) : AMD Ryzen 7 9800X3D
GPU (capture rig) : NVIDIA GeForce RTX 5090, driver 32.0.16.1088
Also reproduced : 10+ other machines, AMD and NVIDIA GPUs, various tiers

Project : open world, World Partition, Nanite + Lumen + VSM
Relevant settings : r.RayTracing=False
Bindless enabled (Configuration=RayTracing)
“Bindless is supported. MaxNonSamplerDescriptors: 1000000,
MaxSamplerDescriptors: 2048”
“Bindless is enabled (Configuration=RayTracing).
Initializing allocators with 32768 Resource and
2048 Sampler descriptors”

What Type of Bug are you experiencing?

Rendering (Graphics / Niagara)

Steps to Reproduce

Load the open-world map and traverse it so World Partition streams cells in and
out. The crash occurs after roughly 100-210 seconds of play. It is not tied to a
specific location, actor or asset (see ELIMINATED below).

SecondsSinceStart across the 11 captures:
103, 105, 106, 109, 112, 114, 125, 154, 186, 206, 484

Expected Result

KEY LOCALIZATION: r.Nanite 0 AVOIDS THE CRASH

Setting r.Nanite=0 at runtime (the cvar is ECVF_Scalability | ECVF_RenderThreadSafe,
so it can be toggled live) makes the crash stop reproducing.

This was verified as a reversible A/B/A test on one single build, same route,
same machine, within a 10 minute window:

A  Nanite on   -> crash   (11 captures, SecondsSinceStart 103 to 484)
B  r.Nanite=0  -> clean   182 s, no crash
B  r.Nanite=0  -> clean   186 s, no crash
B  r.Nanite=0  -> clean   441 s, no crash, while flying the entire World
                           Partition map at 55000 speed, i.e. maximum cell
                           streaming churn, the exact stress that should
                           trigger the bug
A  Nanite on   -> crash   125 s, identical signature
A  Nanite on   -> crash   484 s, identical signature

Observed Result

ELIMINATED, WITH THE METHOD USED

Each of these was verified as actually applied before drawing a conclusion.

GPU vendor / driver / overlays
The minidump exception address resolves inside
Frommisfortunecomes-Win64-Shipping.exe, not in nvwgf2umx.dll, D3D12Core.dll
or dxgi.dll. Reproduces on AMD and NVIDIA across 10+ machines.

RHI breadcrumbs / diagnostic buffer
Shipping has them off by default. The crash persists and
BindDiagnosticBuffer disappears from the stack, proving that frame was not
the cause.

Nanite tessellation
Built with r.Nanite.AllowTessellation=0 and r.Nanite.Tessellation=0.
Still crashes.

Text3D plugin
Plugin disabled in the build. Still crashes.

Gameplay actors
All NPCs and all traffic destroyed at session start via debug commands, and
prevented from respawning. Still crashes.

RHI thread boundary
Launched with -norhithread. Verified applied: the crashing thread changed
from “Background Worker #0” to “RenderThread 0”, and frame 8 changed from
LowLevelTasks::TTaskDelegate to FNamedTaskThread::ProcessTasksUntilQuit.
Still crashes, with the same faulting instruction. So the corruption is not
a race across the RHI thread boundary.

Location in the level
Reproduces anywhere in the map, not tied to a cell or an asset.

Sampler cache LRU eviction
RHICreateSamplerState in D3D12State.cpp frees a descriptor immediately when
the cache reaches D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE:

    SamplerCache.Find(CurrentKey)->GetReference()->FreeDescriptor();
    SamplerCache.Remove(CurrentKey);

This looked like an exact fit for a dangling raw pointer. It is NOT what is
happening here: the accompanying warning

    "New SamplerState would exceed cache limit"

does not appear in any of the 13 captured logs, and LogD3D12RHI is not
suppressed in the project's [Core.Log]. Ruling this out may still be useful
to you, since the immediate-free path does leave raw pointers in the
per-context sampler cache and looks unsafe by inspection.

Affects Versions

5.7

Platform(s)

Windows

For crash reports, include your callstack

CRASH SIGNATURE

Error : Unhandled Exception: EXCEPTION_ACCESS_VIOLATION reading address
0xffffffffffffffff (one capture: 0x000000030003a796)
GPU breadcrumb (identical in every capture that produced one):

Breadcrumbs 'Parallel'
 - Nanite::BasePass
 - NaniteBasePass
 - BasePass
 - Scene
 - RenderGraphExecute - /ViewFamilies
 - SceneRender - ViewFamilies
 - Frame <n>

Callstack (Shipping, symbolicated with matching PDB):

FD3D12DescriptorCache::SetSamplerTable()
FD3D12CommandContext::RHISetShaderParameters()
FRHICommandSetShaderParameters<FRHIComputeShader>::Execute()
FRHICommandListExecutor::FTranslateState::Translate()
FRHICommandListExecutor::FTaskPipe::Execute()
TGraphTask<TFunctionGraphTaskImpl<void __cdecl(ENamedThreads::Type,
    TRefCountPtr<FBaseGraphTask> const&),0> >::ExecuteTask()
UE::Tasks::Private::FTaskBase::TryExecuteTask()
LowLevelTasks::FScheduler::WorkerLoop()
FThreadImpl::Run()
FRunnableThreadWin::Run()

Additional Notes

In DebugGame builds an additional top frame appears,
FD3D12ContextCommon::BindDiagnosticBuffer(). That frame is inlining noise: it
disappears in Shipping (where RHI breadcrumbs are off by default) while the
crash and the faulting instruction stay identical.

PCallStackHash per configuration (differs only because the binaries differ):
DebugGame 0378955D700B67513AEA2022718E66810E523220
Shipping 138150CF9CC9C6D5B3E72C41FAA0E22772180F8F
Shipping -norhithread 61EFA5F0D7A44ABF3CBDFA99245B32C86FBEF9EF
Development D370C92727D9D35B7C6B52904B346E43112DF1CC

Crashing thread: “Foreground Worker #1” / “Background Worker #0”, or
“RenderThread 0” when launched with -norhithread.

ROOT CAUSE — EXACT INSTRUCTION AND SOURCE LINE

Faulting address, Shipping build: module RVA 0x2d4e517.
Bytes around the fault, taken from the minidump:

4d 8b c1                    mov  r8, r9
49 c1 e0 04                 shl  r8, 4
4c 03 c2                    add  r8, rdx
48 85 c9                    test rcx, rcx
74 2f                       je   +0x2f

→ 48 8b 81 10 01 00 00 mov rax, [rcx+0x110] ← FAULTS
48 85 c0 test rax, rax
74 23 je +0x23
4a 3b 84 c5 c8 23 00 00 cmp rax, [rbp + r88 + 0x23c8]
74 49 je +0x49
4a 89 84 c5 c8 23 … mov [rbp + r8
8 + 0x23c8], rax

This matches, instruction for instruction, this code in
Engine/Source/Runtime/D3D12RHI/Private/D3D12DescriptorCache.cpp
(FD3D12DescriptorCache::BuildSamplerTable, around lines 350-410):

Desc.SamplerID[SlotIndex] = Samplers[SlotIndex] ? Samplers[SlotIndex]->ID : 0;
...
if (Samplers[SlotIndex] != nullptr)
{
    SrcDescriptors[SlotIndex] = Samplers[SlotIndex]->OfflineDescriptor;
}

test rcx,rcx / je   == the "Samplers[SlotIndex] != nullptr" guard
mov rax,[rcx+0x110] == reading OfflineDescriptor off that pointer
cmp/mov [rbp+r8*8+..] == writing into the per-slot array

So rcx is Samplers[SlotIndex], an FD3D12SamplerState*.

WHY THIS IS A USE-AFTER-FREE, NOT A NULL DEREFERENCE

  1. The pointer PASSES the explicit “!= nullptr” check immediately before the
    faulting instruction. It is not null.

  2. The garbage value CHANGES between runs while the faulting instruction stays
    the same:
    run A: fault address 0xFFFFFFFFFFFFFFFF (non-canonical → #GP, Windows
    reports -1 because there is no valid CR2)
    run B: fault address 0x000000030003a796
    A fixed poison value or an uninitialised constant would be identical every
    run. A value that varies is characteristic of reading recycled memory.

Conclusion: an FD3D12SamplerState is destroyed while a raw pointer to it is
still held in the per-context FD3D12SamplerStateCache, and the next
BuildSamplerTable dereferences it.

ELIMINATED, WITH THE METHOD USED

Each of these was verified as actually applied before drawing a conclusion.

GPU vendor / driver / overlays
The minidump exception address resolves inside
Frommisfortunecomes-Win64-Shipping.exe, not in nvwgf2umx.dll, D3D12Core.dll
or dxgi.dll. Reproduces on AMD and NVIDIA across 10+ machines.

RHI breadcrumbs / diagnostic buffer
Shipping has them off by default. The crash persists and
BindDiagnosticBuffer disappears from the stack, proving that frame was not
the cause.

Nanite tessellation
Built with r.Nanite.AllowTessellation=0 and r.Nanite.Tessellation=0.
Still crashes.

Text3D plugin
Plugin disabled in the build. Still crashes.

Gameplay actors
All NPCs and all traffic destroyed at session start via debug commands, and
prevented from respawning. Still crashes.

RHI thread boundary
Launched with -norhithread. Verified applied: the crashing thread changed
from “Background Worker #0” to “RenderThread 0”, and frame 8 changed from
LowLevelTasks::TTaskDelegate to FNamedTaskThread::ProcessTasksUntilQuit.
Still crashes, with the same faulting instruction. So the corruption is not
a race across the RHI thread boundary.

Location in the level
Reproduces anywhere in the map, not tied to a cell or an asset.

Sampler cache LRU eviction
RHICreateSamplerState in D3D12State.cpp frees a descriptor immediately when
the cache reaches D3D12_MAX_SHADER_VISIBLE_SAMPLER_HEAP_SIZE:

    SamplerCache.Find(CurrentKey)->GetReference()->FreeDescriptor();
    SamplerCache.Remove(CurrentKey);

This looked like an exact fit for a dangling raw pointer. It is NOT what is
happening here: the accompanying warning

    "New SamplerState would exceed cache limit"

does not appear in any of the 13 captured logs, and LogD3D12RHI is not
suppressed in the project's [Core.Log]. Ruling this out may still be useful
to you, since the immediate-free path does leave raw pointers in the
per-context sampler cache and looks unsafe by inspection.

KEY LOCALIZATION: r.Nanite 0 AVOIDS THE CRASH

Setting r.Nanite=0 at runtime (the cvar is ECVF_Scalability | ECVF_RenderThreadSafe,
so it can be toggled live) makes the crash stop reproducing.

This was verified as a reversible A/B/A test on one single build, same route,
same machine, within a 10 minute window:

A  Nanite on   -> crash   (11 captures, SecondsSinceStart 103 to 484)
B  r.Nanite=0  -> clean   182 s, no crash
B  r.Nanite=0  -> clean   186 s, no crash
B  r.Nanite=0  -> clean   441 s, no crash, while flying the entire World
                           Partition map at 55000 speed, i.e. maximum cell
                           streaming churn, the exact stress that should
                           trigger the bug
A  Nanite on   -> crash   125 s, identical signature
A  Nanite on   -> crash   484 s, identical signature

The 441 s clean run under maximum streaming stress is longer than 10 of the 11
observed crash times, and the crash returned both times Nanite was re-enabled. Combined with the GPU breadcrumb, which reads
Nanite::BasePass in every single capture without exception, this points at the
Nanite compute-material shading path as the trigger rather than shader parameter
binding in general.

Note that FD3D12DescriptorCache::BuildSamplerTable is generic code used by all
shader parameter binding, so this is a localization of the trigger, not proof
that the lifetime bug itself is Nanite-specific. The Nanite base pass is the
path that binds material parameters through
FRHICommandSetShaderParameters.

This is not a viable workaround for us: the project has 5272 Nanite static
meshes out of 15434, the entire environment is built on it.

CONTENT RULED OUT BY A CLEAN BUILD

Before concluding this is an engine issue we cleaned every content-side warning
that touched the same subsystem, then reproduced the crash on the clean build:

Invalid material [...] used on Nanite static mesh   3800  ->  0
Invalid GameplayTag                                  142  ->  0
Materials failing to compile (null ShaderMap)          8  ->  0
Plugin dependency warnings                            34  ->  0
Total log lines per session                       226178  ->  7473

The crash still reproduced on that build (Development, 154 s,
PCallStackHash 3824BBEB7D5EC05E4675C5F81F91A20060B803B3, same instruction, same
Nanite::BasePass breadcrumb). No translucent-on-Nanite material and no material
with a null shadermap remains in the affected map.

WHAT WE COULD NOT DO

AddressSanitizer would identify the exact free site, but the bug is in engine
D3D12RHI code and this is a Launcher binary install: Engine/Source is present
but there is no engine solution to rebuild with bUseAddressSanitizer. A
source-build repro on your side would pin down the free site directly.

ATTACHMENTS TO INCLUDE

Crash captures (CrashContext.runtime-xml + UEMinidump.dmp, plus
Frommisfortunecomes.log where the configuration produced one):

Desktop\Crashes\Event1
Desktop\Crashes\Event2\UECC-Windows-6EEB4BC0411D88A4FEBC9EAE21210887_0001
Desktop\Crashes\Event3\UECC-Windows-D214B83C4349176946C18C8B93E1F992_0001
Desktop\NewCrash\UECC-Windows-D7B9A8544DDB2CBA44C91EB5AC44B8EC_0001
Desktop\NewCrashv2\UECC-Windows-9033BC6A4AE17C381F721189F0A3A5B9_0001
Desktop\NewCrashv3\UECC-Windows-A6229CCE427F51BCE19AB9856D44FA8F_0001
Desktop\NewCrashv4 shipping\Crashes\UECC-Windows-CCBB9C0C4371014628EBF2BAA492F283_0000
%LOCALAPPDATA%\Frommisfortunecomes\Saved\Crashes\
    UECC-Windows-1F6655384EEF5DABB6F990B788D74363_0000   (Shipping)
    UECC-Windows-CA5F259C4078028741F610B5F7DB0BA4_0000   (Shipping -norhithread)

<SteamInstall>/Frommisfortunecomes/Saved/Crashes/
    UECC-Windows-93A0AFC840CBFACF8C54309DE918510D_0000

This last one is the single most useful capture: Development configuration,
built after every content-side warning was cleaned, full 8502 line log, and it
is the second “A” of the A/B/A Nanite test above. It is the only capture with a
symbolicated stack, a complete log, and no content warnings left to distract
from the actual defect.

Also attach the matching Frommisfortunecomes-Win64-Shipping.pdb for the
Shipping captures.

WHAT WOULD HELP MOST

Identification of where the FD3D12SamplerState referenced by
FD3D12SamplerStateCache::Samplers can be destroyed without the cache slot
being invalidated. The cache holds raw pointers and does not participate in the
refcount, so any path that destroys a sampler while a context still has it bound
produces exactly this crash.

1 Like

Thanks for your post, sir

1 Like

UE 5.8.2 — access violation in BindUniformBuffer from Nanite::RecordShadingCommand (stale cached FNaniteShadingPipeline)

Summary

A UE 5.8.2 client access-violates after roughly two minutes while Nanite records its base-pass shading commands. BindUniformBuffer reads a uniform buffer pointer that addresses freed memory:

EXCEPTION_ACCESS_VIOLATION reading address 0xffffffffffffffff

It reproduces on two machines, two GPUs, and two driver branches, at a consistent 2:00–2:15, and also in the editor (-game, no cook needed, ~6 minutes).

The project contains no custom rendering or RHI code. Its only RHI usage is reading GRHIGlobals.GpuInfo.DedicatedVideoMemory, and it enqueues no render commands of its own.

Environment

Item Value
Engine 5.8.2, source build, branch 5.8 @ 6fd3f15c5dc78a8f6075f02b8aa915466c698b72
Build Packaged Development -WithSymbols (also reproduces in the editor)
RHI D3D12 SM6. Bindless enabled, Configuration=RayTracing (engine default on an RT GPU)
OS Windows 11 Pro 26200
GPUs RTX 3070 Ti Laptop/driver 610.88 · RTX 3080 desktop/driver 610.74 (616.56 also tested)

The callstack, with the caller visible

Run with -forcerhibypass -norhithread so the RHI command list executes inline. This is the key step: it removes the deferred translate and exposes the real caller.

BindUniformBuffer()                                   D3D12Commands.cpp:63
FD3D12CommandContext::SetShaderParametersOnContext()  D3D12Commands.cpp:752
FD3D12CommandContext::RHISetShaderParameters()        D3D12Commands.cpp:802
FRHIComputeCommandList::SetBatchedShaderParameters()  RHICommandList.h:2632
Nanite::RecordShadingCommand()                        NaniteShading.cpp:1274
Nanite::DispatchBasePass'::<lambda_4>::operator()()   NaniteShading.cpp:1941
TRDGLambdaPass<FNaniteShadingPassParameters, …>::Execute()
FRDGBuilder::ExecutePass()                            RenderGraphBuilder.cpp:3525
FRDGBuilder::Execute()                                RenderGraphBuilder.cpp:2157
FSceneRenderProcessor::Execute'::<lambda_4>           SceneRenderBuilder.cpp:918
   … render thread …

Without those flags, the same fault appears with a deferred tail (FRHICommandSetShaderParameters<FRHIComputeShader>::ExecuteTranslate_ExecuteCommandList), which hides the caller entirely.

Where the stale pointer comes from

RecordShadingParameters pulls three things out of a cached, long-lived FNaniteShadingPipeline and binds them every dispatch:

// NaniteShared.h:902 — all raw, non-owning
TPimplPtr<FNaniteBasePassData,     DeepCopy> BasePassData;
TPimplPtr<FMeshDrawShaderBindings, DeepCopy> ShaderBindings;   // built once, NaniteShading.cpp:1181
const FMaterialRenderProxy* MaterialProxy = nullptr;
const FMaterial*            Material      = nullptr;
FRHIComputeShader*          ComputeShader = nullptr;
FRHIUniformBuffer*          MaterialUB    = nullptr;           // 5.8 only
int32                       MaterialUBSerialNumber = 0;        // captured, never compared

Nothing refreshes any of these when the underlying material resources are rebuilt.

MaterialUBSerialNumber exists specifically to detect that staleness, and its only consumer is a debug-only ensureMsgf behind r.Nanite.Debug.ValidateShadingBinUB (default 0), whose message names the condition outright:

Nanite shading pipeline has stale MaterialUB for material '%s'. Cached=%p, Current=%p. SerialNumber at capture: %d, current: %d

RecordShadingParameters also carries an unresolved comment on the adjacent line — //RUNETODO: Is it safe to use BasePassData from the pipeline? — and PipelineHash/operator== both key on the raw MaterialUB address, so a recycled allocation can make two different pipelines compare equal.

What we ruled out, with measurements

Hypothesis Test Result
Deferred-command lifetime -forcerhibypass -norhithread, verified (deferred frames gone) Crash unchanged — not the deferral window
MaterialUB specifically Bindless disabled via [ShaderPlatformConfig PCD3D_SM6], verified by the absence of Bindless is enabled in the log — that bind then never happens Crash unchanged — so it is ShaderBindings or BasePassData, not MaterialUB
Stale MaterialUB detectable r.Nanite.Debug.ValidateShadingBinUB 1, verified applied Ensure never fired
Nanite itself r.Nanite=0, verified applied Not a fix — becomes an unlogged stall at the same moment
A specific material/asset Patched RecordShadingCommand to log every material bound; 269,345 binds to the crash 48 materials, nearly all bound exactly 5,497×; zero new materials in the final 30 frames; the last-bound material differs every run
Sampler descriptor exhaustion Content sampler demand 2744 → 50 against a 2048 cap; instrumented ~FD3D12SamplerState Zero seconds change; zero sampler destructions
Our own object lifetimes A real AActor* use-after-free we found and fixed earlier Crash outlived the fix; project has no render/RHI code
GPU timeout/TDR Log inspection across runs Zero DEVICE_HUNG/DEVICE_REMOVED; engine reports TDR settings OK
Drivers/GPU/thermal/VRAM 2 GPUs, 2 driver branches, Game Ready + Studio; 80°C peak, 0 throttle; 4647 MB of 8192 All eliminated

The material telemetry is the strongest single argument that this is not content-side: uniform bind counts, no late arrivals, and a different “last” material every run all point at the pipeline cache rather than at any asset.

The sampler reports are very likely this same bug

A Launcher build and a source build of the identical crash symbolicate differently:

Built by Top frame File/line
Source engine BindUniformBuffer() D3D12Commands.cpp:63
Launcher engine FD3D12ContextCommon::BindDiagnosticBuffer()/FD3D12DescriptorCache::SetSamplerTable() None

Everything below the top frame is identical, and the Launcher build emits no file/line for any frame. So a Launcher build reports this as a sampler-descriptor fault — which is exactly the signature of several existing reports. It seems worth checking whether these are all one issue:

  • UE 5.7.4 — use-after-free of FD3D12SamplerState in BuildSamplerTable during Nanite BasePass (reported on 10+ machines, AMD and NVIDIA)
  • UE 5.5.4 — intermittent D3D12 UniformBuffer access violation in packaged builds (same faulting function)
  • UE 5.6 — intermittent D3D12 crashes across Nanite and Base Pass shaders

We independently disproved the sampler theory on our own project (zero sampler destructions across a full run; a 98% cut in sampler demand moved the crash by zero seconds) before we found the symbolication difference.

Not a regression — the structure is unchanged since 5.3

FNaniteShadingPipeline is identical in 5.3 and 5.8 for ShaderBindings, BasePassData, and the three raw pointers. MaterialUB is the only member added since, and it is exonerated above. There is no 5.x release with a materially different structure here, which fits the 5.5.4 report carrying the same faulting function three versions earlier.

Reproduction notes

  1. Use a source build. A Launcher build mis-symbolicates the top frame (see above) and cost us two days.
  2. Add -forcerhibypass -norhithread to see the caller. Bypass also needs the RHI thread off: bLatchedBypass is … && !IsRunningRHIInSeparateThread() (RHICommandList.cpp:1897), so -forcerhibypass alone is inert. Development/Debug only (CAN_TOGGLE_COMMAND_LIST_BYPASS).
  3. The editor reproduces it (-game, ~6 min), so no packaging is required.
  4. Verify every flag from the log, not the exit code. -ini: cannot set RHI-init cvars and logs deferred - dummy variable created; an -ExecCmds value containing a space is split unless quoted.
  5. Note that UE log frame counters wrap at 1000.

The ask

  1. Should FNaniteShadingPipeline’s cached ShaderBindings/BasePassData/MaterialUB hold references rather than raw pointers — or is something meant to invalidate the pipeline when a material’s resources are rebuilt? Today nothing appears to.
  2. PipelineHash and operator== key on the raw MaterialUB address. Is that safe against allocator reuse?
  3. Are the 5.5.4/5.6/5.7.4 reports above the same underlying issue seen through different symbolication?
  4. ValidateBoundUniformBuffer dereferences before it validates, so a stale pointer becomes an access violation rather than a diagnosable message.

Attachments: trimmed log (init + crash block), minidump, GPU breadcrumbs, crash context.

1 Like

I was not allowed to upload attachments since I am new user - not sure how to get past that - apologies.

1 Like

Hi everyone, I found the solution to my problem. I had two different triggers causing the same crash: one was related to Nanite, and the other was caused by an invalid shader. Both were sharing information through a Material Parameter Collection.

Since I only have around five Material Parameter Collections in the entire project, I decided to keep them loaded at all time (Basically, what I did was create an array of hard references to the five Material Parameter Collections in the Game Instance. So hopefully the solution I found can help others too, because I can see that a lot of people are having this problem.). One of the things happening in both cases was that they were trying to be used after they had already been released.

So I kept them permanently loaded, and that fixed the bug. I’ve had the game running for a full day now, and it hasn’t crashed or thrown any errors.

Lastly, one of the things I fixed that wasn’t directly causing the crash, but could have triggered other issues later, was that I had around 190 materials at runtime that weren’t compiling because they were losing references to Material Functions. Unreal was then interpreting them as more basic materials, I think, which left the project exposed to potential future errors.

I tracked down the missing references and updated them, which fixed the issue with the ~190 materials that weren’t compiling.