Offline PCG Workflow Broken: Partition Actor & Preview Mode Problems

Hello Epic Support,

We’re encountering a significant issue with our PCG workflows following our integration of Unreal Engine 5.6. Our pipeline is designed so that content creators only submit PCG graphs and their inputs. The actual generation is handled offline via our build machine infrastructure — this is strictly for offline content generation, not runtime.

Problem Description

Steps to reproduce:

  1. Open a map and delete the PCG partition actor.
  2. Regenerate the PCG content using the command line.
  3. Reopen the map and observe the following:
    1. The partition actor and instanced Blueprint actors are present on disk.
    2. However, new instanced actors are not added to Perforce (P4) automatically.
    3. We must manually run a reconcile on the external actors to retrieve them.
  4. Add a new PCG graph to the level:
    1. It does not regenerate as expected.
    2. Updating an existing instance may trigger regeneration, but behavior is inconsistent.

Setup Details

  • We’re using a PCG configuration file (screenshot attached) to drive generation.
  • All PCG graphs flagged as “Loaded as Preview” are included in the generation process.
  • Our base PCG actor class is named BP_PCG_Actor.

Additional Observations

  • When setting and saving the editor mode to “Loaded as Preview,” reopening the editor resets the visible editor mode to “Preview,” while the serialized value remains “Loaded as Preview.”
  • This behavior is expected for PCG components instantiated in a map (i.e., actual actors).
  • When instantiating a BP_PCG_Actor in the level, its editor mode defaults to “Preview.”
  • Changing it back to “Loaded as Preview” does not trigger regeneration.

It is critical for us to fix this issue as a lot of our content depends on our capacity to regenerate PCG content. Please let us know if additional logs, repro steps, or configuration details would help your investigation.

Hello,

I wanted to share an update as we continue investigating the issue on our end.

We’ve identified why the new instanced actors were not being added to Perforce. However, we’re still encountering problems with generating new content. Specifically, when adding a new PCG Actor to a level, it doesn’t get incorporated into an existing PCG Partition Actor.

Here’s a simplified reproduction of the issue:

  1. From an empty level, place a new pcg actor to be generated into the map.
    1. The actor needs to be partioned,
    2. The actor editor mode needs to be set to load as preview.
  2. Close the map and run the PCG commandlet to generate content.
  3. Reopen the level and observe that the newly added PCG Actor’s generated content is missing from the PCG Partition Actor.

Let me know if you need additional details,

Hugo

Edit: Modified the repro steps

Hello, I’ve investigated the issue further and tried to follow the code flow by breaking in the code. I was able to make it work, but I don’t know what the ideal solution is. What I did was to:

  1. Set the pcg.DirtyLoadAsPreviewOnLoad value back to true,
  2. In FPCGActorAndComponentMapping::UpdateMappingPCGComponentPartitionActor, I change this line:
// const bool bChangeGraphInstances = bInChangeGraphInstances && !UE::GetIsEditorLoadingPackage();
	const bool bChangeGraphInstances = true;

as I wanted to force updating the pcg mapping to be updated.

The initial problem is coming from APCGPartitionActor::AddGraphInstance as it need to be called twice for a new actor to be added correctly to a partition actor. According to the workflow, it will set everything to preview:

LocalComponent = NewObject<UPCGComponent>(this, NAME_None, OriginalComponent->IsInPreviewMode() ? RF_Transient | RF_NonPIEDuplicateTransient : RF_NoFlags);
	LocalComponent->MarkAsLocalComponent();
	LocalComponent->SetGenerationGridSize(PCGGridSize);
 
	// Note: we'll place the local component prior to the SetPropertiesFromOriginal so that any code that relies on the parent-child relationship works here
	OriginalToLocal.Emplace(OriginalComponent, LocalComponent);
	LocalToOriginal.Emplace(LocalComponent, OriginalComponent);
 
	// Implementation note: since this is a new component, we need to use the current editing mode only for both the current & serialized editing modes
	LocalComponent->SetEditingMode(/*InEditingMode=*/OriginalComponent->GetEditingMode(), /*InSerializedEditingMode=*/OriginalComponent->GetEditingMode());
	LocalComponent->SetPropertiesFromOriginal(OriginalComponent);
 
	LocalComponent->RegisterComponent();

then will update correctly the editor mode on second call as it update the serialized editor mode value to be Load As Preview:

if (LocalComponent)
	{
		// Update properties as needed and early out
		LocalComponent->SetEditingMode(OriginalComponent->GetEditingMode(), OriginalComponent->GetSerializedEditingMode());
		LocalComponent->SetPropertiesFromOriginal(OriginalComponent);
		LocalComponent->MarkAsLocalComponent();
		LocalComponent->SetGenerationGridSize(PCGGridSize);
		return;
	}

What is the reason of not setting up the serialized editor mode on creation by using the original component value?

Let me know if you have a better solution, or if you need more information to understand better our issue.

Hello,

I wanted to share our fix that we end up doing and I would like to get your feedback.

For your information, we are running the commandlet on multiple machines to divide the workload. The divergences are commented:

bool FPCGWorldPartitionBuilder::CreatePartitionedActors(UWorld* InWorld, const FPCGWorldPartitionBuilderArgs& InArgs, TMap<TSoftObjectPtr<UPCGComponent>, FWorldPartitionReference>& InOutPartitionedComponents)
{
	// Scope object tracks if packages were dirtied 
	FPCGDetectDirtyPackageInScope DirtyPackageInScope;
	UPCGSubsystem* PCGSubsystem = UWorld::GetSubsystem<UPCGSubsystem>(InWorld);
	check(PCGSubsystem);
	auto ComponentFilter = [InWorld, &InOutPartitionedComponents](const UPCGComponent* InComponent)
	{
		// Only generate Partitioned Components part of the persistent level
		if (InComponent->GetComponentLevel() != InWorld->PersistentLevel)
		{
			return false;
		}
		// Only process each component once
		if (InOutPartitionedComponents.Contains(InComponent))
		{
			return false;
		}
		return InComponent->IsPartitioned();
	};
	TArray<TWeakObjectPtr<UPCGComponent>> PartitionedComponentsToProcess;
	FPCGWorldPartitionBuilder::CollectComponentsToGenerate(InWorld, InArgs, ComponentFilter, PartitionedComponentsToProcess);
	UWorldPartition* WorldPartition = InWorld->GetWorldPartition();
	for (TWeakObjectPtr<UPCGComponent> ComponentPtr : PartitionedComponentsToProcess)
	{
		if (UPCGComponent* PCGComponent = ComponentPtr.Get())
		{
			check(!InOutPartitionedComponents.Contains(PCGComponent));
			InOutPartitionedComponents.Add(PCGComponent, FWorldPartitionReference(WorldPartition, PCGComponent->GetOwner()->GetActorGuid()));
			bool bHasUnbounded = false;
			PCGHiGenGrid::FSizeArray GridSizes;
			FPCGWorldPartitionBuilder::GetGenerationGridSizes(PCGComponent->GetGraph(), PCGSubsystem->GetPCGWorldActor(), GridSizes, bHasUnbounded);
			// [DIVERGENCE] Avoid Checking out Original PCG Component when running comamndlet
			// [DIVERGENCE] Let the generation state and the generated bounds get set to reflect correct OctTree states
			if (!PCGComponent->bGenerated && !bHasUnbounded)
			{
				// We need to avoid modifying Original PCG Component in order to avoid BM submission conflicts.
				// However, the generated state and the last generation bounds for this should be updated in order to represent the correct state in PCG OctTree 
				//PCGComponent->Modify();
				PCGComponent->bGenerated = true;
				PCGComponent->LastGeneratedBounds = PCGComponent->GetGridBounds();
			}
			// END
			if (!GridSizes.IsEmpty())
			{
				PCGSubsystem->CreatePartitionActorsWithinBounds(PCGComponent, PCGComponent->GetGridBounds(), GridSizes);
			}
		}
	}
	return DirtyPackageInScope.AnyDirtyPackage();
}
// [DIVERGENCE] Skip Partitioned Components from cleaning up if they do not belong to the cell being processed
bool FPCGWorldPartitionBuilder::UpdatePartitionedActors(UWorld* InWorld, const TMap<TSoftObjectPtr<UPCGComponent>, FWorldPartitionReference>& InPartitionedComponents, const FCellInfo& InCellInfo, TSet<FString>& InOutDeletedActorPackages)
// END
{
	// Scope object tracks if packages were dirtied 
	FPCGDetectDirtyPackageInScope DirtyPackageInScope;
	// Will track any deleted actors in this scope
	FPCGDetectDeletedActorInScope DeletedActorScope(InWorld, InOutDeletedActorPackages);
	UPCGSubsystem* PCGSubsystem = UWorld::GetSubsystem<UPCGSubsystem>(InWorld);
	check(PCGSubsystem);
	TArray<TKeyValuePair<UPCGComponent*, TSet<TObjectPtr<APCGPartitionActor>>>> UpdatedMappings;
	// When loading existing PAs, we need to Update the mappings to add missing PCG Components
	for (auto KeyValuePair : InPartitionedComponents)
	{
		if (UPCGComponent* PartitionedComponent = KeyValuePair.Key.Get())
		{
			// Those updated mappings will be partial (only include loaded PAs) but that is ok because here we want to make sure loaded PAs get the missing graph instances and in the next loop remove the graph
			// instances from the loaded PAs no longer in the mappings
			PCGSubsystem->UpdateMappingPCGComponentPartitionActor(PartitionedComponent);
			UpdatedMappings.Add({ PartitionedComponent, PCGSubsystem->GetPCGComponentPartitionActorMappings(PartitionedComponent) });
		}
	}
	// If PartitionActor is loaded and it has invalid Original Components it means that they need to be cleaned up
	for (TActorIterator<APCGPartitionActor> It(InWorld); It; ++It)
	{
		// [DIVERGENCE] Skip Partitioned Components from cleaning up if they do not belong to the cell being processed
		APCGPartitionActor* PartitionActor = *It;
		// Cleanup Graph Instances to invalid Original Components
		const FBox ActorBounds = PCGHelpers::GetActorBounds(PartitionActor);
		
		if (PartitionActor->GetLevel() == InWorld->PersistentLevel && 
			(!InCellInfo.Bounds.IsValid || InCellInfo.Bounds.IsInsideOrOnXY(ActorBounds.GetCenter())))
		{
			// [DIVERGENCE] Make sure to sync Editing Mode for Local Components with their Original counterparts when running generation through the builder.
			// The SerializedEditing mode for Local Components is force-set to Preview when calling AddGraphInstance the first time
			// For new content, or any content without bGenerated flag set, unlike the ones with bGenerated set, AddGraphInstance is never called again
			// Hence. the Editing Mode remains as Preview by the assumption that this component is about to load for Editor workflow, not for commandlet
			// This led to skipped generation of Newly added local Components, or any component that does not have bGenerated flag set
			// Making sure to force-set the right editing mode without having to affect Editor or runtime workflow 
			for (auto& LocalToOriginalMapping : PartitionActor->LocalToOriginal)
			{
				UPCGComponent* LocalComponent = LocalToOriginalMapping.Key;
				if (LocalToOriginalMapping.Value.IsValid() && LocalToOriginalMapping.Value.Get())
				{
					UPCGComponent* OriginalComponent = LocalToOriginalMapping.Value.Get();
					
					if (LocalComponent->GetSerializedEditingMode() != OriginalComponent->GetSerializedEditingMode())
					{
						LocalComponent->SetEditingMode(OriginalComponent->GetEditingMode(), OriginalComponent->GetSerializedEditingMode());
					}
				}
				else
				{
					//We need to make sure that during the following call to CleanupDeadGraphInstancesInternal, Any Local component with a dead reference to an original component should be able to clean its generated output
					//At this point, since all previously LoadAsPreview Components are loaded as Preview, we want to force these to get updated
					LocalComponent->SetEditingMode(EPCGEditorDirtyMode::LoadAsPreview, EPCGEditorDirtyMode::LoadAsPreview);
					LocalComponent->ChangeTransientState(EPCGEditorDirtyMode::LoadAsPreview);
				}
			}
			// Here we cleanup dead graphs since we tried to load the Original components before, if they can't resolve it means they don't exist anymore.
			PartitionActor->CleanupDeadGraphInstancesInternal();
			// Remove Graph Instances from Partition Actors that are no longer part of the component mappings (bounds change)
			for (const TKeyValuePair<UPCGComponent*, TSet<TObjectPtr<APCGPartitionActor>>>& Mapping : UpdatedMappings)
			{
				if (!Mapping.Value.Contains(PartitionActor))
				{
					PartitionActor->RemoveGraphInstance(Mapping.Key);
				}
			}
		}
		// END
	}
	return DirtyPackageInScope.AnyDirtyPackage();
}

Hi Hugo, I’ll take a bit of time to unpack all of this and will report back early next week

Cheers,

Patrick

Hi Hugo,

  • Perforce: For the perforce issue the only issue I managed to repro was if the files are currently “opened for delete” locally the builder fails trying to check them out. I have a fix for this that should make it into 5.7 but if you want the details I can share them here.

  • Blueprint: As for the Blueprint being set to ‘Preview’ on load, it is indeed a bug that we are also going to fix. (Templates/Archetypes shouldn’t go through the same code path)

  • Preview data: I’ve noticed that we properly handle managed resources for Preview/LoadAsPreview PCG Components but we do not properly handle GenerateGraphOutput…meaning that a PCG Component in Preview mode will still serialize its “Preview” generated output instead of keeping the previous gen data. So this will also be fixed.

  • As for adding a new PCG Actor and having the Builder generate it …I still haven’t been able to repro. Once I’ve fixed the other issues above, I’ll try and repro in 5.6 to see if this issue was fixed between then and now.

Cheers,

Patrick

I tested a change for the Editing Mode and I think I have a simpler solution. Could you let me know if it fixes your issue?

It consists in adding a new method in struct FPCGWorldPartitionBuilder (PCGWorldPartitionBuilder.cpp)

void FPCGWorldPartitionBuilder::UpdateEditingMode(UPCGComponent* InComponent)
{
	if (InComponent->GetSerializedEditingMode() == EPCGEditorDirtyMode::LoadAsPreview && InComponent->GetEditingMode() != EPCGEditorDirtyMode::LoadAsPreview)
	{
		UE_LOG(LogPCGWorldPartitionBuilder, Display, TEXT("Setting PCG editing mode to Load As Preview on actor '%s' label '%s' graph '%s'."),
			*InComponent->GetOwner()->GetName(),
			*InComponent->GetOwner()->GetActorNameOrLabel(),
			*InComponent->GetGraph()->GetName());
 
		InComponent->SetEditingMode(EPCGEditorDirtyMode::LoadAsPreview, EPCGEditorDirtyMode::LoadAsPreview);
		InComponent->ChangeTransientState(EPCGEditorDirtyMode::LoadAsPreview);
	}
}

Replace the similar code in FPCGWorldPartitionBuilder::GenerateComponent with

// Non-partitioned components get their editing mode updated here to make sure generation is persisted

FPCGWorldPartitionBuilder::UpdateEditingMode(InComponent);

and add a call to that new method in FPCGWorldPartitionBuilder::CreatePartitionedActors just after the if (UPCGComponent* PCGComponent = ComponentPtr.Get())

// Partitioned components need their editing mode updated before we start creating/updating PAs

FPCGWorldPartitionBuilder::UpdateEditingMode(PCGComponent);

This fixes the weirdness I have been getting trying to repro your issue.

Basically we just make sure that we update the Editing mode to LoadAsPreview so that PAs are properly Created/Updated later

Let me know and I’ll kick off a review and submit this to 5.7

Cheers

Patrick

Ok yes I see what is needed here, I’ll make the fix for the cleanup also to make sure UpdateEditMode is called on the affected partition actors also.

Ok I reproed and here is how I fixed it.

Changed the signature of CleanupDeadGraphInstancesInternal to this in PCGPartitionActor.h/.cpp

bool APCGPartitionActor::CleanupDeadGraphInstancesInternal(bool bTest, TArray<UPCGComponent*>* OutRemovedLocalComponents)
{
	bool bModified = false;
 
	// First find if we have any local dead instance (= nullptr) hooked to an original component.
	TSet<TObjectKey<UPCGComponent>> DeadOriginalInstances;
	for (const auto& OriginalToLocalItem : OriginalToLocal)
	{
		if (!OriginalToLocalItem.Value)
		{
			DeadOriginalInstances.Add(OriginalToLocalItem.Key);
		}
	}
 
	if (!DeadOriginalInstances.IsEmpty())
	{
		bModified = true;
		
		if (bTest)
		{
			return true;
		}
		
		Modify();
 
		for (const TObjectKey<UPCGComponent>& DeadInstance : DeadOriginalInstances)
		{
			OriginalToLocal.Remove(DeadInstance);
		}
 
		LocalToOriginal.Remove(nullptr);
	}
 
	// And do the same with dead original ones.
	TSet<TObjectPtr<UPCGComponent>> DeadLocalInstances;
	for (const auto& LocalToOriginalItem : LocalToOriginal)
	{
		if(!LocalToOriginalItem.Value.IsValid())
		{
			DeadLocalInstances.Add(LocalToOriginalItem.Key);
		}
	}
 
	if (!DeadLocalInstances.IsEmpty())
	{
		bModified = true;
		
		if (OutRemovedLocalComponents)
		{
			OutRemovedLocalComponents->Append(DeadLocalInstances.Array());
		}
 
		if (bTest)
		{
			return true;
		}
 
		Modify();
 
		for (const TObjectPtr<UPCGComponent>& DeadInstance : DeadLocalInstances)
		{
			LocalToOriginal.Remove(DeadInstance);
 
			if (DeadInstance)
			{
				DeadInstance->CleanupLocalImmediate(/*bRemoveComponents=*/true);
				DeadInstance->DestroyComponent();
			}
		}
 
		// Remove all dead entries
		OriginalToLocal.Remove(nullptr);
	}
 
	return bModified;
}

And inside FPCGWorldPartitionBuilder::UpdatePartitionedActors code now looks like this:

bool FPCGWorldPartitionBuilder::UpdatePartitionedActors(UWorld* InWorld, const TMap<TSoftObjectPtr<UPCGComponent>, FWorldPartitionReference>& InPartitionedComponents, TSet<FString>& InOutDeletedActorPackages)
{
	// Scope object tracks if packages were dirtied 
	FPCGDetectDirtyPackageInScope DirtyPackageInScope;
 
	// Will track any deleted actors in this scope
	FPCGDetectDeletedActorInScope DeletedActorScope(InWorld, InOutDeletedActorPackages);
 
	UPCGSubsystem* PCGSubsystem = UWorld::GetSubsystem<UPCGSubsystem>(InWorld);
	check(PCGSubsystem);
 
	TArray<TKeyValuePair<UPCGComponent*, TSet<TObjectPtr<APCGPartitionActor>>>> UpdatedMappings;
 
	// When loading existing PAs, we need to Update the mappings to add missing PCG Components
	for (auto KeyValuePair : InPartitionedComponents)
	{
		if (UPCGComponent* PartitionedComponent = KeyValuePair.Key.Get())
		{
			// Those updated mappings will be partial (only include loaded PAs) but that is ok because here we want to make sure loaded PAs get the missing graph instances and in the next loop remove the graph
			// instances from the loaded PAs no longer in the mappings
			PCGSubsystem->UpdateMappingPCGComponentPartitionActor(PartitionedComponent);
 
			UpdatedMappings.Add({ PartitionedComponent, PCGSubsystem->GetPCGComponentPartitionActorMappings(PartitionedComponent) });
		}
	}
 
	// If PartitionActor is loaded and it has invalid Original Components it means that they need to be cleaned up
	for (TActorIterator<APCGPartitionActor> It(InWorld); It; ++It)
	{
		// Cleanup Graph Instances to invalid Original Components
		APCGPartitionActor* PartitionActor = *It;
 
		if (PartitionActor->GetLevel() == InWorld->PersistentLevel)
		{
			// Here we cleanup dead graphs since we tried to load the Original components before, if they can't resolve it means they don't exist anymore.
			TArray<UPCGComponent*> DeadLocalComponents;
 
			// Test first so we can update editing mode before doing the clean up
			if (PartitionActor->CleanupDeadGraphInstancesInternal(/*bTest=*/true, &DeadLocalComponents))
			{
				for (UPCGComponent* DeadLocalComponent : DeadLocalComponents)
				{
					// Update editing mode before cleaning up to make sure cleanup gets persisted
					FPCGWorldPartitionBuilder::UpdateEditingMode(DeadLocalComponent);
				}
 
				PartitionActor->CleanupDeadGraphInstancesInternal(/*bTest=*/false);
			}
 
			// Remove Graph Instances from Partition Actors that are no longer part of the component mappings (bounds change)
			for (const TKeyValuePair<UPCGComponent*, TSet<TObjectPtr<APCGPartitionActor>>>& Mapping : UpdatedMappings)
			{
				if (!Mapping.Value.Contains(PartitionActor))
				{
					if (UPCGComponent* LocalComponent = PartitionActor->GetLocalComponent(Mapping.Key))
					{
						// Update editing mode before removing graph instance to make sure cleanup gets persisted
						FPCGWorldPartitionBuilder::UpdateEditingMode(LocalComponent);
 
						PartitionActor->RemoveGraphInstance(Mapping.Key);
					}
				}
			}
		}
	}
 
	return DirtyPackageInScope.AnyDirtyPackage();
}

This combined with the previous fix should get you in a better place.

I also have a change for 5.7 where the GenerateGraphOutput will not be persisted when a PCG Component is in LoadAsPreview (it will persist the previously generated data, same as the managed resources). The same CL prevents the LoadAsPreview from changing to Preview on non-generated components (so that includes Blueprint class CDOs/Archetypes)

I submitted the change to the PackageSourceControlHelper.cpp (CL 45867390 in UE5 Main) where opened for delete files wouldn’t properly be checked out when running the builder

Hope I’ve covered everything. Thanks for the great bug reporting!

Cheers,

Patrick

Since I have your attention, I also posted a crash we have when scattering blueprint: [Crash When Unloading World Partition Cell with PCG Partitioned Actors Referencing [Content removed]

Hi Patrick,

Thanks for your response. Your fix seems to partially resolve the issue—it allows generation, but unfortunately, it doesn’t handle cleanup of the PCGPartitionActor when the original PCGActor is deleted.

Here’s the exact repro steps I followed:

  1. Place a PCG actor in a level (partitioned and set as LoadAsPreview).
  2. Save and close the Editor.
  3. Run the commandlet to generate.
  4. Submit the changes.
  5. Reopen the level and delete the original PCG actor.
  6. Save the level. At this point, only the original PCGActor should be saved/deleted. This is the desired behavior, as we don’t want our content creators to manage the PCGPartitionActor; that’s the build machine’s responsibility.
  7. Run the commandlet again to regenerate.
  8. Reopen the level and observe that the
  9. PCGPartitionActor still contains outdated data.

We addressed this issue in FPCGWorldPartitionBuilder::UpdatePartitionedActors (as mentioned earlier). It may not be the most elegant solution, and we’re open to your feedback or suggestions for improvement.

Thanks, I’ll be testing this when I have some time.

In the meantime, feel free to look at this thread:

[PCG Runtime Graph Issues in UE5 – Lock Contention and Memory [Content removed]