Streaming Sublevel Worlds Never Clean Up Stale UMaterialParameterCollectionInstance Entries After GC

HI, is this a bug?​

UWorld::OnPostGC(), which removes stale UMaterialParameterCollectionInstance (MPCI) entries from a world’s

ParameterCollectionInstances array, is registered ONLY inside UWorld::InitWorld(). However, streaming sublevel

worlds never call InitWorld() (acknowledged in an engine comment at World.cpp line ~6206). As a result, any MPCI

entries pointing to a GC’d UMaterialParameterCollection asset will remain in those worlds’ arrays permanently,

causing a growing memory leak over the lifetime of a session.

Engine Version

--------------

UE 5.x (reproduced on our internal fork based on 5.7.1)

Root Cause

----------

1. Registration is gated behind InitWorld

-----------------------------------------

// World.cpp -- UWorld::InitWorld()

FCoreUObjectDelegates::GetPostGarbageCollect().AddUObject(this, &UWorld::OnPostGC);

This is the ONLY place OnPostGC is subscribed. Streaming sublevel worlds are loaded via

PostLoad -> SetupParameterCollectionInstances(), and explicitly skip InitWorld:

// World.cpp -- UWorld::CleanupWorld()

// "It is currently valid to call CleanupWorld on the UWorld of

// streaming sublevels, and they never call InitWorld."

2. MPCI entries are injected into all worlds including sublevel worlds

--------------------------------------------------------------------

When a UMaterialParameterCollection asset is loaded, PostLoad calls

SetupWorldParameterCollectionInstances(), which iterates EVERY live UWorld:

// ParameterCollection.cpp

void UMaterialParameterCollection::SetupWorldParameterCollectionInstances()

{

for (TObjectIterator\<UWorld\> It(...); It; \+\+It)

{

  UWorld\* CurrentWorld \= \*It;

  // ...

  CurrentWorld\-\>AddParameterCollectionInstance(this, true); // injected into sublevel worlds too

}

}

3. OnPostGC cleanup never fires for sublevel worlds

--------------------------------------------------

// World.cpp -- UWorld::OnPostGC()

void UWorld::OnPostGC()

{

for (int32 InstanceIndex \= ParameterCollectionInstances.Num()\-1; InstanceIndex \>\= 0; InstanceIndex\-\-)

{

  if (!ParameterCollectionInstances\[InstanceIndex]\-\>IsCollectionValid())

  {

    ParameterCollectionInstances.RemoveAt(InstanceIndex); // never reached for sublevel worlds

  }

}

}

Because the callback was never registered, stale MPCI entries accumulate in every sublevel world’s

ParameterCollectionInstances array indefinitely.

can you comfire is it a bug, and can you give me a fix

[Attachment Removed]

Steps to Reproduce
Reproduction Steps

------------------

1. Open a project with streaming sublevels that reference one or more UMaterialParameterCollection assets

(e.g. via a UI Blueprint that uses “Set Material Parameter Collection”).

2. Repeatedly open and close a UI screen that loads a sublevel (or loads assets referencing an MPC).

3. Run `memreport -full` and check `obj list` for MaterialParameterCollectionInstance count.

4. Force multiple GC passes (`obj gc` or `stat memory`).

5. Observe that MaterialParameterCollectionInstance count grows with each open/close cycle and

NEVER decreases after GC.

[Attachment Removed]

Hello!

This is indeed a leak although a very small one. The UMaterialParameterCollectionInstance is ~304 bytes in version 5.7 and will be ~176 bytes in the next release following some optimization at the containers level.

That being said, a quick workaround would be to register the delegate in UWorld::PostLoad and UWorld::InitWorld. There are some free bits in UWorld so it would be possible to add a bool without increasing the size the of the class. Here is some tentative code while the engine team is working on a permanent solution. Please note that I have not tested these changes but I believe them to be correct.

In World.h:

//From Line 1385
	/** Is there at least one material parameter collection instance waiting for a deferred update?								*/
	uint8 bMaterialParameterCollectionInstanceNeedsDeferredUpdate : 1;
 
	/** Whether InitWorld was ever called on this world since its creation. Not cleared to false during CleanupWorld			*/
	uint8 bHasEverBeenInitialized: 1;
 
	/** Whether PostGC was registered. It can happen in PostLoad for loaded levels or InitWorld for Persistent levels that were loaded or created at runtime			*/
	uint8 bIsThePostGCDelegateRegistered: 1;

In World .cpp:

void UWorld::PostLoad()
{
... to line 1703
	// Initially set up the parameter collection list. This may be run again in UWorld::InitWorld but it's required here for some editor and streaming cases
	SetupParameterCollectionInstances();
 
	if(!bIsThePostGCDelegateRegistered)
	{
	    bIsThePostGCDelegateRegistered = true;
	    FCoreUObjectDelegates::GetPostGarbageCollect().AddUObject(this, &UWorld::OnPostGC);
	}
...
 
}
 
...
 
void UWorld::InitWorld(const InitializationValues IVS)
{
... to line 2359
 
	if(!bIsThePostGCDelegateRegistered)
	{
		bIsThePostGCDelegateRegistered = true;
		FCoreUObjectDelegates::GetPostGarbageCollect().AddUObject(this, &UWorld::OnPostGC);
	}
 
...
}

The guard won’t be useful in PostLoad as it will always be called first. I left it there for clarity.

Regards,

Martin

[Attachment Removed]

After some extra discussion with the dev team, I ended up making the change official so the problem is gone in version 5.8. CL52816714

[Attachment Removed]

Thanks, Sevigny, for confirming it. I fixed it similarly.

[Attachment Removed]