Prefab and per Instance Changes Workflow in Unreal

After migrating from Unity to work in Unreal, one of the most difficult aspects is finding a proper pathway to move away from Prefabs. In particular, I was trying to address the issue of reusing the same room prefab across a dungeon while being able to turn on or off certain walls and doors depending on where that room is placed.

Variant Manager

The Variant Manager, added in Unreal Engine 5, is a new editor feature that lets you set up multiple different configurations of the Actors in your Level.

You can create a Variant Manager Set for each level. Therein lies the main problem with the Variant Manager: I am limited to a unique variable instance per level. One might consider configuring different Variant Sets of doors and walls for every room present in the level in a top-down fashion, but this does not seem scalable. Another perspective would be to use Level Instance, a new feature of Unreal Engine 5, in combination with variant sets. However, as of version 5.4, variant sets are only defined for the topmost level.

Prefabricator

Prefabricator is a free Unreal Engine distributed by Coderespawn, the author of the famous Dungeon Architect. Prefabricator enables the creation of prefab asset via the content browser. Prefab updates are propagated through all prefab instances placed in the scene.

However, I have found that Prefabricator leaves much to be desired in terms of its serialization. First, it does not allow the placement of a component at the root of the prefab. Secondly, Prefabricator does not permit complex types in its fields, such as structs or arrays that refer to other structs and arrays. This limitation arises because Prefabricator relies exclusively on Unreal’s built-in PropertyPathHelpers::GetPropertyValueAsString, which is incompatible with properties of nested structs.

The Prefabricator’s most promising feature, namely its automatic propagation mechanism, also proved to be a curse in disguise. One of the requirements for the game is support for per prefab instance changes. However, because the automatic propagation occurs every time the level is loaded or a change is made to the prefab asset, it is impossible to maintain the changes made.

With both serialization and change propagation concerns in mind, I decided to customize Prefabricator to suit my needs. I have added support for serializing prefab components located at the root. Instead of relying on Unreal’s built-in serialization method, to support complex property types, I have opted to recursively visit a property’s hierarchy and save each nested property individually using a constructed path as a key. Finally, to support per-instance variation, I found it necessary to track changes made to exclude them when loading a prefab. Improvements made to Prefabricator can be found here.

Despite those efforts, doubts quickly began to surface regarding the reliability of the solution. In particular, change tracking is not robust, and it is evident that problems will continue to arise. Currently, the solution requires a modification in the Unreal code base to detect when a property is nested within an array element. These issues have left me wondering whether there is a way to rely on a built-in Unreal feature to achieve the same goal.

Blueprint, Packed Level Actors, Level Instance

Blueprints on their own are useful for quickly building object templates. However, problems quickly arise when a UPROPERTY refers to another component within the object. Components edited in the Blueprint Editor are referred to as GEN_VARIABLE, temporary components that are added and removed every time a change is made. Thus, assigning it to a field is meaningless. Furthermore, since I am mostly interested in a prefab workflow in the context of level design, I would like to access some of Unreal’s level editing features, such as the cube grid.

Level instances are a new feature of Unreal Engine 5, which allows you to repeatedly use a level inside your principal level. Packed level actors are level instances that are optimized for rendering and can only contain static meshes. The idea is to nest a packed level actor inside your blueprint.

// CouragePackedLevelActor.h

USTRUCT(BlueprintType)
struct FCourageRoomElementGroup
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    bool bIsActive = false;

    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    FString Name = "";

    UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (AllowPrivateAccess=true))
    TObjectPtr<UInstancedStaticMeshComponent> Element;

    UPROPERTY(BlueprintReadWrite, EditAnywhere, meta = (AllowPrivateAccess = true))
    TArray<TObjectPtr<UInstancedStaticMeshComponent>> Elements;

    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    TArray<FString> IncludedGroupNames;

    UPROPERTY(BlueprintReadWrite, EditAnywhere)
    TArray<FString> IncludedGroupPatterns;

    operator bool() const
    {
        return Name != "";
    }
};

UCLASS(ClassGroup = Courage, meta = (BlueprintSpawnableComponent, PrioritizeCategories = "Courage Courage|Internal", AutoCollapseCategories = "Courage|Internal"))
class ACourageRoomPackedLevelActor : public APackedLevelActor
{
    GENERATED_BODY()

public:

    ACourageRoomPackedLevelActor();

    virtual ~ACourageRoomPackedLevelActor();

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Courage")
    bool bPopulateDefaultGroups = true;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Courage", meta = (TitleProperty=Name))
    TArray<FCourageRoomElementGroup> BlueprintCreatedGroups;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Courage", meta = (TitleProperty = Name))
    TArray<FCourageRoomElementGroup> Groups;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Courage", meta = (GetOptions = "GetGroupNames"))
    TArray<FString> ActiveGroups;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Courage")
    TArray<FString> ActiveGroupPatterns;

    UFUNCTION(CallInEditor)
    TArray<FString> GetGroupNames() const;

    bool GetGroupsForPattern(const FString& Pattern, TArray<const FCourageRoomElementGroup*>& Groups) const;

    bool GetGroupsForPattern(const FString& Pattern, TArray<FCourageRoomElementGroup*>& Groups);

    const FCourageRoomElementGroup* GetGroup(const FString& Name) const;

    FCourageRoomElementGroup* GetGroup(const FString& Name);

    void ActivateGroup(FCourageRoomElementGroup& Group, bool bActivated);

    void RefreshGroupActivations();

   
    void BeginPlay() override;

    virtual void PostActorCreated() override;

    void OnConstruction(const FTransform& Transform) override;
};

Inside of the construction script, we turn on or off the correct meshes (walls, or doors) for a given room instance. It is useful to use glob patterns to avoid typing out the names of each walls manually.

// CouragePackedLevelActor.cpp

#include "glob/glob.hpp"

void ACourageRoomPackedLevelActor::PostActorCreated()
{
    Super::PostActorCreated();
    Groups.Empty();
    BlueprintCreatedGroups.Empty();
}

void ACourageRoomPackedLevelActor::BeginPlay()
{
    using namespace Courage;
    Super::BeginPlay();
    TArray< UInstancedStaticMeshComponent*> MeshComponents;
    GetComponents<UInstancedStaticMeshComponent>(MeshComponents);
    for (auto& Comp : MeshComponents)
    {
        if (Comp && !IsMeshVisibleAndCollidable(Comp))
        {
            Comp->UnregisterComponent();
        }
    }
}

void ACourageRoomPackedLevelActor::OnConstruction(const FTransform& Transform)
{
    using namespace Courage;

    Super::OnConstruction(Transform);

    Groups.Empty();
    if (bPopulateDefaultGroups)
    {
        TArray< UInstancedStaticMeshComponent*> MeshComponents;
        GetComponents<UInstancedStaticMeshComponent>(MeshComponents);
        for (auto& Comp : MeshComponents)
        {
            Groups.Add({ .Name = Comp->GetName(), .Elements = {Comp}});
        }
    }
    Groups.Append(BlueprintCreatedGroups);
    for (auto& Group : Groups)
    {
        if (Group.Element != nullptr)
        {
            Group.Elements.Add(Group.Element);
        }
    }
    auto Parent = Cast<ACourageRoom>(GetParentActor());
    
    if (
        ActiveGroups.Num() == 0
        && ActiveGroupPatterns.Num() == 0
        && (!Parent || Parent->ActiveGroups.Num() == 0)
        && (!Parent || Parent->ActiveGroupPatterns.Num() == 0)
        )
    {
        ActiveGroupPatterns.Add("*");
    }
    
    RefreshGroupActivations();
}

void ACourageRoomPackedLevelActor::RefreshGroupActivations()
{
    for (auto& Group : Groups)
        ActivateGroup(Group, false);

    TArray<FString> ActiveGroupNamesAndPatterns;
    ActiveGroupNamesAndPatterns.Append(ActiveGroups);
    ActiveGroupNamesAndPatterns.Append(ActiveGroupPatterns);
    if(auto Parent = Cast<ACourageRoom>(GetParentActor()))
    {
        ActiveGroupNamesAndPatterns.Append(Parent->ActiveGroups);
        ActiveGroupNamesAndPatterns.Append(Parent->ActiveGroupPatterns);
    }

    TSet<FCourageRoomElementGroup*> GroupsToActivate;
    for (auto& Pattern : ActiveGroupNamesAndPatterns)
    {
        TArray<FCourageRoomElementGroup*> GroupsForPattern;
        if (GetGroupsForPattern(Pattern, GroupsForPattern))
        {
            GroupsToActivate.Append(GroupsForPattern);
        }
    }
    for (auto& Group : GroupsToActivate)
    {
        ActivateGroup(*Group, true);
    }
}

void ACourageRoomPackedLevelActor::ActivateGroup(FCourageRoomElementGroup& Group, bool bActivated)
{
    using namespace Courage;

    for (auto& Elem : Group.Elements)
    {
        if (!Elem) continue;
        SetMeshVisibleAndCollidable(Elem.Get(), bActivated);
    }
    Group.bIsActive = bActivated;
    TSet<FCourageRoomElementGroup*> IncludedGroups;
    TArray<FString> ActiveGroupNamesAndPatterns;
    ActiveGroupNamesAndPatterns.Append(Group.IncludedGroupNames);
    ActiveGroupNamesAndPatterns.Append(Group.IncludedGroupPatterns);
    for (auto& Pattern : ActiveGroupNamesAndPatterns)
    {
        if (Pattern == "") continue;
        if (Pattern == Group.Name) continue;
        TArray<FCourageRoomElementGroup*> PatternGroups;
        if(GetGroupsForPattern(Pattern, PatternGroups))
        {
            IncludedGroups.Append(PatternGroups);
        }
    }
    for (auto& IncludedGroup : IncludedGroups)
    {
        ActivateGroup(*IncludedGroup, bActivated);
    }
}

bool ACourageRoomPackedLevelActor::GetGroupsForPattern(const FString& GroupName, TArray<FCourageRoomElementGroup*>& OutGroups)
{
    using namespace Courage;
    auto GlobRegex = glob::compile_pattern(TCHAR_TO_UTF8(*GroupName));
    for (auto& Group : Groups)
    {
        if (
            Group.Name == GroupName ||
            std::regex_match(TCHAR_TO_UTF8(*Group.Name), GlobRegex))
        {
            OutGroups.Add(&Group);
        }
    }

    return OutGroups.Num() != 0;
}

bool ACourageRoomPackedLevelActor::GetGroupsForPattern(const FString& GroupName, TArray<const FCourageRoomElementGroup*>& OutGroups) const
{
    using namespace Courage;
    auto GlobRegex = glob::compile_pattern(TCHAR_TO_UTF8(*GroupName));
    for (auto& Group : Groups)
    {        
        if (
            Group.Name == GroupName ||
            std::regex_match(TCHAR_TO_UTF8(*Group.Name), GlobRegex))
        {
            OutGroups.Add(&Group);
        }
    }
    return OutGroups.Num() != 0;
}

FCourageRoomElementGroup* ACourageRoomPackedLevelActor::GetGroup(const FString& GroupName)
{
    using namespace Courage;
    for (auto& Group : Groups)
    {
        if (Group.Name == GroupName)
        {
            return &Group;
        }
    }
    return nullptr;
}

const FCourageRoomElementGroup* ACourageRoomPackedLevelActor::GetGroup(const FString& GroupName) const
{
    using namespace Courage;
    for (auto& Group : Groups)
    {
        if (Group.Name == GroupName)
        {
            return &Group;
        }
    }
    return nullptr;
}


TArray<FString> ACourageRoomPackedLevelActor::GetGroupNames() const
{
    TArray<FString> GroupNames;
    for (auto& Group : Groups)
    {
        GroupNames.Add(Group.Name);
    }
    return GroupNames;
}

Known Issues

When creating a packed level actor for a given level, static mesh components are generated for each static mesh actor. At the moment, names of generated static mesh components are generated at random and therefore lose the correspondance to the original static mesh actor. This issue make it really difficult to define groups of meshes to turn on and off by name. To addresss the issue, you can either refer to the randomly generated identifier (I think they are persistent), or you can incorporate the change to Unreal source code discussed here.

Courage! Keep on building.

More conversation about this topic

Level Instances (Packed Level Instance Actor) Need Serious Work – General / Feedback & Requests – Epic Developer Community Forums (unrealengine.com)

Please fix FComponentReference to allow easy reference to other child actors within a blueprint. – General / Feedback & Requests – Epic Developer Community Forums (unrealengine.com)

What are “GEN_VARIABLE” components? – Development / Programming & Scripting – Epic Developer Community Forums (unrealengine.com)