Kazimieras Mikelis' Game Devlog

Easy C++ Latent Functions in Unreal Engine 5 Blueprints

Probably the easiest way to implement latent Blueprint functions in C++.

Mikelis' Game Devlog

UCancellableAsyncAction and ExposedAsyncProxy

Unreal Engine 5 has more than a few ways to implement latent behaviors and latent blueprint functions in C++. However, possibly the easiest way is using UCancellableAsyncAction.

Thanks to UCancellableAsyncAction's UCLASS specifier meta=(ExposedAsProxy=AsyncAction), so long as derived C++ classes have specific fields (functions and properties), they will receive an automatically generated latent K2 node. And we don't need to deal with memory managing an FPendingLatentAction, which is the traditional way of creating latent function nodes.

Below is an example implementation of a delay node with this method. Note that it has a factory function (UMyDelayAsyncAction::MyDelayAsyncAction) and two delegates - UMyDelayAsyncAction::OnComplete and UMyDelayAsyncAction::OnFail - which result in the respective output pins.

// .h
#pragma once

#include "CoreMinimal.h"
#include "Engine/CancellableAsyncAction.h"
#include "MyDelayAsyncAction.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FMyDelayAsyncActionEvent);

/**
 * An example async/latent action.
 */
UCLASS()
class PROJECT_API UMyDelayAsyncAction : public UCancellableAsyncAction
{
    GENERATED_BODY()

public:
    /**
     * Start a delay.
     * @param DelayTime		How long the delay will last.
     * @param WorldContext	Object from which the world will be derived.
     */
    UFUNCTION(DisplayName="Delay (Custom)", meta=(WorldContext="WorldContext", BlueprintInternalUseOnly="true"))
    static UMyDelayAsyncAction* MyDelayAsyncAction(const UObject* WorldContext, float DelayTime);

    /** A delegate called when the async action completes. */
    UPROPERTY(BlueprintAssignable)
    FMyDelayAsyncActionEvent OnComplete;

    /** A delegate called when the async action fails. */
    UPROPERTY(BlueprintAssignable)
    FMyDelayAsyncActionEvent OnFail;

    // Start UCancellableAsyncAction Functions
    virtual void Activate() override;
    virtual void Cancel() override;
    // End UCancellableAsyncAction Functions

    // Start UObject Functions
    virtual UWorld* GetWorld() const override
    {
    	return ContextWorld.IsValid() ? ContextWorld.Get() : nullptr;
    }
    // End UObject Functions
    
private:
    /** The context world of this action. */
    TWeakObjectPtr<UWorld> ContextWorld = nullptr;

    /** The time this action will wait before finishing. */
    float DelayTime = 0.f;

    /** The timer handle. */
    FTimerHandle OngoingDelay;
};
// .cpp
#include "MyDelayAsyncAction.h"
#include "Engine.h"

UMyDelayAsyncAction* UMyDelayAsyncAction::MyDelayAsyncAction(const UObject* WorldContext, float DelayTime)
{
    // This function is just a factory that creates a UMyDelayAsyncAction instance.

    // We must have a valid contextual world for this action, so we don't even make it
    // unless we can resolve the UWorld from WorldContext.
    UWorld* ContextWorld = GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::ReturnNull);
    if(!ensureAlwaysMsgf(IsValid(WorldContext), TEXT("World Context was not valid.")))
    {
    	return nullptr;
    }

    // Create a new UMyDelayAsyncAction, and store function arguments in it.
    UMyDelayAsyncAction* NewAction = NewObject<UMyDelayAsyncAction>();
    NewAction->ContextWorld = ContextWorld;
    NewAction->DelayTime = DelayTime;
    NewAction->RegisterWithGameInstance(ContextWorld->GetGameInstance());
    return NewAction;
}

void UMyDelayAsyncAction::Activate()
{
    // When the async action is ready to activate, set a timer using the world's FTimerManager.
    if(const UWorld* World = GetWorld())
    {
    	// The timer manager is a singleton, and GetTimerManger() accessor will always return a valid one.
    	FTimerManager& TimerManager = World->GetTimerManager();

    	// We set the timer for DelayTime, and we pass in the callback function as a lambda.
    	TimerManager.SetTimer(OngoingDelay,
    		FTimerDelegate::CreateLambda([WeakThis = TWeakObjectPtr<UMyDelayAsyncAction>(this)]()
    			{
    				// We're passing "this" as a weak pointer, because there is no guarantee that "this" will
    				// exist by the time this lambda callback executes.
    				if(WeakThis.IsValid() && WeakThis->IsActive())
    				{
    					// If everything went well, broadcast OnComplete (fire the On Complete pin), and wrap up.
    					WeakThis->OnComplete.Broadcast();
    					WeakThis->Cancel();
    				}
    			}),
    			FMath::Max(DelayTime, 0.001f), false);
    	return;
    }

    // If something failed, we can broadcast OnFail, and then wrap up.
    OnFail.Broadcast();
    Cancel();
}

void UMyDelayAsyncAction::Cancel()
{
    Super::Cancel();

    // Cancel the timer if it's ongoing, so OnComplete never broadcasts.
    if(OngoingDelay.IsValid())
    {
    	if(const UWorld* World = GetWorld())
    	{
    		FTimerManager& TimerManager = World->GetTimerManager();
    		TimerManager.ClearTimer(OngoingDelay);
    	}
    }
}

The first declared static UFUNCTION-reflected function that returns an object pointer will be considered the factory function by Unreal Engine when making the custom K2 node. A factory function will be the function called when the K2 node is executed.

All UPROPERTY-reflected and BlueprintAssignable member delegates will appear as output execution pins in the custom K2 node.

The Javadoc-style documentation above the factory function determines what's shown as tooltips when visual programmers hover over our node. If a category is defined for the factory function, the latent function K2 node will appear in that category. Otherwise, the class name will be used as a category.

Mikelis' Game Devlog

Why use this approach?

  • Compared to the classic FPendingLatentAction method, implementing latent blueprint functions this way does not require manual memory management (as you'd need to create an FPendingLatentAction with the new keyword).
  • There isn't a need to keep track of FLatentActionInfo data like callback targets, linkage int32, and UUID to fire an output pin when the action is done - simply broadcasting on a delegate is enough.
  • You can cancel this async action individually by calling Cancel() on the returned async action object, which is more straightforward than canceling FPendingLatentAction.
  • Unlike FPendingLatentAction, this async action does not need to tick; it can be entirely event-based.
  • There is much less boilerplate code in the UCancellableAsyncAction approach than in the FPendingLatentAction approach.
  • Because an async action object is returned when this latent function executes, this object can be used in a future-promise pattern, similar to how the "Run EQSQuery" blueprint function works.
  • The code in the implementation of UCancellableAsyncAction is similar to gameplay actions (UGameplayTask) and AI tasks (UAITask). In fact, both of these classes use the meta=(ExposedAsyncProxy=AsyncTask) specifier and treat factory functions and blueprint assignable delegates the same way to create a K2 node. So using this method keeps our codebase more consistent.
  • While initially it seems like using UCancellableAsyncAction could make it challenging to ensure only one instance of this action runs at a time per object or node, the FLatentActionInfo can still be passed into the constructor with the UFUNCTION specifier meta=(LatentInfo="ArgumentName") to implement that functionality.

There are not many downsides to using UCancellableAsyncAction compared to the older FPendingLatentAction-based implementation of latent blueprint functions in C++. UCancellableAsyncAction appears as the more modern approach, and I recommend using it.

#Engineering #Unreal Engine #Cpp #Game Development #Shortform

- 3 toasts