TWWL 2

Let’s do another round of This Week We Learned–laugh and point at our mistakes and blunders so you can avoid them.

Do not use pointers [to assets] in Data Tables

If you want to keep asset references in your data tables–in our case ingredients that need their specific UStaticMesh and UTexture2D objects, do not use plain old pointers; instead, use TSoftObjectPtr<T>. On reflection, this should be obvious, and we actually had code that had TSoftObjectPtrs, but then I was cleaning up the code and I saw all those AsyncLoadAsset nodes, and thought that’s it’s just lot of bother, and the asynchrony is adding its own down-sides. I tried ref*ctoring [technical term] the code to use raw pointers, but with appropriate UPROPERTY macros. The FKopiDisplayData structure ended up as bellow, and was used in another struct that ended up being loaded from a Data Table. “That should work.”

USTRUCT(BlueprintType)
struct KOPI_API FKopiDisplayData 
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	FText Name;
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	FText Extras;
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	UStaticMesh* StaticMesh;
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	FColor Colour = {};  // yes, coloUr! Makes the segfaults so much more refined ????.
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	UTexture2D* Texture2D;
};

And it did–on my computer and in the editor, and surprisingly on Linux shipping builds on Steam Deck. However, when I tried running the same Windows shipping build on our various Windows test machines; it segfaulted. Unhandled Exception: EXCEPTION_ACCESS_VIOLATION reading address 0xffffffffffffffff.The good news is that it’s a “nice” segfault; as in the address is a definitely bad memory location, not a use-after-free scenario. Even better news was that it also segfaulted on my own machine. It turns out that you can’t just put raw pointers into a Data Table rows and expect them to be magically resolved and loaded. It is actually necessary to use soft references and deal with loading the assets at the appropriate time.

USTRUCT(BlueprintType)
struct KOPI_API FKopiDisplayData 
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	FText Name;
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	FText Extras;
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	TSoftObjectPtr<UStaticMesh> StaticMesh;
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	FColor Colour = {};  // yes, coloUr! Makes the segfaults so much more refined ????.
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Kopi)
	TSoftObjectPtr<UTexture2D> Texture2D;
};

/*
 * Hint: Do not be tempted to change the StaticMesh and Texture2D back to their non 
 * TSoftObjectPtr versions. When loading these references from a data table, the 
 * static meshes [and the textures] will work in editor, but *will* segfault in the 
 * shipping game.
 */

The correct refactoring turned out to be the final comment.

Use Lyra’s AsyncMixin plugin

If your project has TSoftObjectPtr or TSoftClassPtr, and if you want to avoid synchronous loading using their respective LoadSynchronous() methods, you can:

  • use the AsyncLoadAsset blueprint node, but that comes with the unexpected strangeness of the blueprint VM (to point out one, the async action does does not close / capture its environment), and its return value is UObject, which means you always have to add a Cast node,
  • in C++, you need to get dirty with UAssetManager::GetStreamableManager() and then set up the required environment to call its RequestAsyncLoad method

Both look and are somewhat painful; this is where the FAsyncMixin comes in. When you add it to your project’s Plugins directory, you can then publicly inherit from it and get access to its various AsyncLoad methods.

USTRUCT()
struct FKopiDisplayData {
  ...
  UPROPERTY(...)
  TSoftObjectPtr<UStaticMesh> StaticMesh;
}

void UKopiDisplayDataComponent::Display(const FKopiDisplayData &Display) {
  CancelAsyncLoading();
  
  AsyncLoad(Display.StaticMesh, 
    TFunction<void(UStaticMesh*)>([this](UStaticMesh *SM) {
		SetStaticMesh(SM);
	}));

	StartAsyncLoading();
}

Here it is worth pointing out that it is safe to capture this in the lambda; the FAsyncMixin handles the scenarios where the loading extends past the this lifetime; and so by the time the lambda runs, the captured this is no longer valid. Also note the correct API usage is to

  • first call CancelAsyncLoading() to make sure that there aren’t any re-used values
  • then set up the async operations by calling the appropriate AsyncLoad methods
  • once the async operations are all set up, call StartAsyncLoading() to begin the load operations

UObject*s get affected by GC

This should be another obvious thing, but remember that UObject pointers that are created using Unreal’s ::NewObject function take part in Unreal’s garbage collection. This is why we have the various UPROPERTY() macros, which allow the garbage collector to properly maintain the instance lifetime. Suppose that you still want to use UObject pointers, but are not able to use UPROPERTY() macro, for example on a static member.

class UKopiInteractibleBPFL : public UBlueprintFunctionLibrary {
	GENERATED_BODY()

  // UPROPERTY()
	static TMap<int, UKopiInteractionAction *> DefaultActions;

public:
	UKopiInteractibleBPFL();

	UFUNCTION(BlueprintCallable, Category=Kopi)
	static UKopiInteractionAction *MakeAction(const int Action);
};

If you want to maintain a small cache of pre-cooked UKopiInteractionAction pointers, you can create them in the constructor, but they will [very quickly] get garbage collected. Because the DefaultActions is a static member, you cannot use the UPROPERTY() macro on it. One way to pin the objects not to be garbage collected is to call the AddToRoot() method; this way, the instance will become a top-level, never garbage collected instance.

TMap<int, UKopiInteractionAction *> UKopiInteractibleBPFL::DefaultActions;

UKopiInteractibleBPFL::UKopiInteractibleBPFL() {
	for (int I = 0; I < 10; ++I) {
		UKopiInteractionAction *Action = UKopiInteractionAction::NewObject(I);
		Action->AddToRoot();
		DefaultActions.Add(I, Action);
	}
}

You can then safely lookup the action and know that the action will remain alive for the duration of the game; you can now hand these instances safely knowing they will not become segfaults.

UKopiInteractionAction *UKopiInteractibleBPFL::MakeAction(const int Action) {
	if (auto **A = DefaultActions.Find(Action); A != nullptr)
		return *A;

	return UKopiInteractionAction::NewObject(Action);
}

Don’t leave dangling references

Dangling references are a situation where you have a reference to an object whose scope has ended. This is somewhat easy to detect in C++ code, but becomes a little more difficult when combining C++ and Blueprints.

class AKopiContainer {
  TArray<FKopiIngredient> Ingredients;
protected:
  UFUNCTION(BlueprintImplementableEvent)
  void OnIngredientsChanged(const TArray<FKopiIngredient> &Ingredients,
                            const TArray<FVector> &Data);
public:
  void DoSomeWork();
};


void AKopiContainer::DoSomeWork() {
	TArray<FVector> Data;
  Data.Add({ ... });
	OnIngredientsChanged(Ingredients, Data);
}

Now, when you implement the OnIngredientsChanged event in Blueprint, it is easy to forget that the scope of the reference Data will end when DoSomeWork finishes, so for example when adding Delay or asynchronous nodes remember to copy the data.

Stand on the shoulders of giants

For links we found super useful this week, we have just two, but big ones

Leave a Reply

Your email address will not be published. Required fields are marked *