Friday, February 28, 2020

Managing the lifetime of child actors created from C++ OnConstruction methods in Unreal Engine 4

I have been working on a little example for my UE4 video series based on Space Invaders and designed to talk about some issues in object-oriented decomposition. Along the way, I ran into a technical problem that I know I had seen last summer, but it took me hours to find the solution. I may make a video about this later, but for now, I just want to get the basics explained here on the ol' blog.

The specific modeling problem I am dealing with is the swarms of enemies in Space Invaders. It's pretty clear to even the most novice programmer that "alien" should be an object. The Alien class would be an Actor, and it would be a great place to deal with things like collision detection and explosion effects. However, it gets a bit tricky when dealing with the movement patterns of the aliens: the whole swarm changes direction when one of them on the edge hits the side of the play area. This leads to a less obvious object-oriented modeling idea: that the swarm itself is another kind of Actor.

In my sample solution, I wanted to be able to create a swarm with any number of rows and columns and have it create that many aliens in a grid. In Blueprint, this is pretty easy to do. BP_Swarm has Rows and Columns variables that specify the size of the swarm. Its construction script can loop through all the "cells" of the grid and add a child actor of type BP_Alien for each. Presto, this allows you to drop a BP_Swarm object into a level and configure its placement and size.

The trouble arose when I decided I wanted to do this in C++ instead of in Blueprint. I added what seemed like the right code to the OnConstruction method. Here's the basic idea, leaving out for the moment the code that handles the individual placement of aliens:

void ASwarm::OnConstruction(const FTransform & Transform)
{
    if (AlienClass) {
        check(Columns >= 0 && Rows >= 0);
        for (int i = 0; i < Columns; i++) {
            for (int j = 0; j < Rows; j++) {
                UChildActorComponent* AlienChild = NewObject<UChildActorComponent>(this);
                AlienChild->RegisterComponent();
                AlienChild->SetWorldTransform(GetActorTransform());
            }
        }
    }
}

When I dragged a swarm into the level, it showed up, but its children persisted every time I moved the swarm or changed its parameters. This is clearly a different behavior than in the Blueprint construction script, but it wasn't obvious to me at all how to get that behavior to work in a C++ implementation. I remembered having come across it when working on the building logic in Kaiju Kaboom, but it took me hours to find it again. Indeed, I even posted to the Unreal Engine 4 Developers Community on Facebook, but the best answer I got was one I was aware of and left me unsatisfied: manually check for and destroy child actors at the beginning of each call to OnConstruction. After stepping away and searching again this morning, I found the forum post that I'm sure is the same one I tripped over last Summer—the post that mentions a barely-documented feature of UE4 called the component creation method. Here's the magic line that saved my ability to trust my memory:
AlienChild->CreationMethod = EComponentCreationMethod::UserConstructionScript;

EComponentCreationMethod has four possible values, and this one must be what is used internally by components created via Blueprint construction scripts. It aligns the behavior of the C++ implementation with the Blueprint one.

For reference, here's the full implementation of OnConstruction from my working demo. It uses a designer-configurable Spacing value that specifies the distance between aliens in the grid, and then it makes use of range mapping to translate from array index to geometric space. One other bit of weirdness here that I'll mention is that while UE4 is generally an "X-forward" system, with the X-axis pointing in the direction an actor is facing, UPaperSpriteActor by default has sprites facing in the negative Y direction. Hence, the horizontal placement of aliens is along the X axis and the vertical placement of aliens is in the Z.

void ASwarm::OnConstruction(const FTransform & Transform)
{
    if (AlienClass) {
        const int Width = (Columns-1) * Spacing;
        const int Height = (Rows-1) * Spacing;
        const FVector2D ColumnsRange(0, Columns - 1);
        const FVector2D RowsRange(0, Rows - 1);
        const FVector2D HorizontalRange(-Width/2, Width/2);
        const FVector2D VerticalRange(-Height/2, Height/2);
        const FTransform ActorTransform(GetActorTransform());

        check(Columns >= 0 && Rows >= 0);
        for (int i = 0; i < Columns; i++) {
            for (int j = 0; j < Rows; j++) {
                UChildActorComponent* AlienChild = NewObject<UChildActorComponent>(this);
                AlienChild->RegisterComponent();
                AlienChild->SetChildActorClass(AlienClass);
                const FVector Location(
                    ActorTransform.GetLocation().X + FMath::GetMappedRangeValueUnclamped(ColumnsRange, HorizontalRange, i),
                    ActorTransform.GetLocation().Y,
                    ActorTransform.GetLocation().Z + FMath::GetMappedRangeValueUnclamped(RowsRange, VerticalRange, j)
                );
                FTransform AlienTransform(ActorTransform);
                AlienTransform.SetLocation(Location);
                AlienChild->SetWorldTransform(AlienTransform);
                AlienChild->CreationMethod = EComponentCreationMethod::UserConstructionScript;
            }
        }
    }
}

One other note about this code, potentially for future-me who comes back to remember how I fixed this problem before. I had forgotten for a while to call RegisterComponent, which omission makes the whole thing a bit squirrely. Also, there is temporal coupling in the component's methods: calling RegisterComponent at the end of the loop creates strange editor-time behavior as well, whereas calling it immediately after NewObject gave the expected behavior.

No comments:

Post a Comment