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 TSoftObjectPtr
s, 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 isUObject
, which means you always have to add aCast
node, - in C++, you need to get dirty with
UAssetManager::GetStreamableManager()
and then set up the required environment to call itsRequestAsyncLoad
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
- Game UI Database a database of Game UIs for inspiration for you new game
- Unreal Engine Multiplayer Tips and Tricks find out how to do mutiplayer properly with this fantastic guide
Leave a Reply