Soft Pointers

Working in a game engine means working with assets. An asset is simply a chunk of data that might be used by the code, e.g. a texture or a mesh. From a C++ programmer perspective, the editor is mainly there to link assets to each other, and to link assets to the code. But not all asset references are created equal. When adding a new variable to a blueprint you can click the object type to create a simple hard object reference, but you can also hover over the object type to create a hard class reference, a soft object reference, or a soft class reference. What’s the difference between these four?

Hard vs Soft

With a hard reference, the asset is loaded immediately when the owning blueprint class is loaded. For a mesh or a texture this means their data is send to the GPU. This is an expensive operation, since the memory on the GPU is limited, and sending the data over can take quite some milliseconds. Sometimes this makes sense. You can’t show an NPC in game without its skeletal mesh and textures, so loading the pawn blueprint also logically implies loading the mesh and textures.

But other times you’re not planning to do something with that asset right away. It could be a list of 100 hats the NPC could possibly wear, and you’re choosing one on BeginPlay. Those 99 other hats do not need to be loaded into memory! In this case you can make an array of soft object references. Soft references need to be loaded manually, and there are 2 ways in which you can do that. You can load it “synchronously” (aka “blocking”) or “asynchronously”. Loading the asset synchronously will block the game thread for a couple of milliseconds and might cause a hitch, but ensures the asset is loaded before continuing. Generally speaking though, when using soft references it’s better to load them asynchronously. This allows the game thread to continue running and gives you a callback (the “Completed” pin on the node below) when it is finished loading.

Object vs Class

Textures and meshes aren’t the only type of asset. In fact, everything you see in the content browser is considered an asset. Yes, that includes blueprints. You might think of blueprints as code (you are scripting them after all), but they are not compiled to machine code. Instead, they are compiled to a script that can be interpreted by a virtual machine at runtime. This script is included in the game’s pak file and will need to be loaded into memory before it can be read.

Non-blueprint assets are always objects. The C++ class UStaticMesh, for example, describes all the variables the class has. The meshes in the content browser are instances of this class — they only state what the value is for each variable. Blueprints, on the other hand, define a class. You can add new variables to that class in the editor, and the class will only be instanced once you place the blueprint in the level editor or spawn an instance during runtime (well, technically speaking the blueprint asset itself is actually also an instance of the UBlueprint class, but we tend to think of the blueprint as being the actor class it generates, so let’s not get into that for simplicity’s sake).

So to summarize:

Hard Object ReferenceHard Class ReferenceSoft Object ReferenceSoft Class Reference
BlueprintAn instance placed in the world or spawned at runtimeLoaded when the owning blueprint is loaded. Avoid these.Doesn’t existLoaded manually. Saves valuable hardware resources.
Other assetLoaded when the owning blueprint is loaded. Sometimes necessary.Doesn’t existLoaded manually. Saves valuable hardware resources.Doesn’t exist

C++

C++ doesn’t have all those fancy execution pins that make async loading so effortless in blueprints. But that doesn’t mean you have to always bind and unbind delegates and declare a callback function for every asset you want to load. You can use a simple lambda expression within the function where you would otherwise synchronously load the asset. Here’s an example

void TestActor::SetActorMesh(USkeletalMeshComponent& meshComponent, const TSoftObjectPtr<USkeletalMesh>& mesh)
{
	UAssetManager* manager = UAssetManager::GetIfValid();
	if (manager == nullptr)
	{
		UE_LOG(LogYvoraEylanders, Error,
			TEXT("failed to set actor mesh on '%s' "
                                "because the asset manager couldn't be found"),
			*AActor::GetDebugName(meshComponent.GetOwner()));
		return;
	}
	if (mesh.IsNull())
	{
		UE_LOG(LogYvoraEylanders, Error,
			TEXT("failed to set actor mesh on '%s' "
                                "because no mesh asset was given"),
			*AActor::GetDebugName(meshComponent.GetOwner()));
		return;
	}

	FStreamableDelegate onAssetLoaded;
	onAssetLoaded.BindLambda([meshComponent, mesh]()
	{
		meshComponent.SetSkeletalMesh(mesh.LoadSynchronous());
	});
	manager->LoadAssetList({mesh.ToSoftObjectPath()}, onAssetLoaded);
}
Why the if-statements?

The first two conditions are known as guards, and are an important part of what we call “defensive programming”. If you check your pointers before using them you can prevent fatal crashes, and if you log a unique error message whenever something fails you’ll make it way easier for yourself to debug your code.

To asynchronously load an asset, you’ll have to get the asset manager using UAssetManager::GetIfValid(). Then you can call UAssetManager::LoadAssetList() by passing a list of object paths to load and a delegate to call when loading is finished. Soft object pointers use soft object paths, so you can always convert a pointer to a path. If you need to load a class, TSoftClassPtr contains a soft object pointer (the UBlueprint being the object) so you can still get the path using ToSoftObjectPath(). The delegate we pass into that function has been bound to a lambda that we define right here in the function. Inside the lambda we still call LoadSynchronous(), but that’s just as a sanity check. The asset has already been loaded at that point, and LoadSynchronous() directly returns the loaded asset.

What is a lambda?

A lambda is basically a function that you can pass around as a parameter. The syntax of a lambda is as follows:

[ list of variables to capture ]( function parameters ){ function body }

If we would have left the square brackets empty, the function body wouldn’t know what meshComponent and mesh refer to once the function is executed by the asset manager. By including them they are attached to the function you pass into LoadAssetList(). Lambas are a standard part of C++.

FStreamableDelegate allows you to bind functions in several different ways, lambdas being just one of them. We could have also bound the callback using a UObject binding like this:

FStreamableDelegate onAssetLoaded;
onAssetLoaded.BindUObject(this, &TestActor::OnSkeletalMeshLoaded);
manager->LoadAssetList({mesh.ToSoftObjectPath()}, onAssetLoaded);

The function OnSkeletalMeshLoaded() will need to be a member of TestActor with the UFUNCTION() specifier above the declaration in order for this to work. I personally don’t prefer this kind of callback when asynchronously loading assets because your code can get really distributed and disorganized, but there might be situations where it’s more appropriate. Note that you can’t enclose variables from the current scope this way, so they would have to become member variables of the TestActor class.

Unloading

The LoadAssetList() function returns a TSharedPtr<FStreamableHandle>, which you can use to unload the loaded assets. If keep a shared pointer to the handle the assets will stay loaded until you manually release it. If you don’t keep a reference to the handle the asset manager will wait until the asset is loaded and then immediately release the assets. In our case we also keep a hard reference to the asset in the mesh component so it can’t be garbage collected. The asset is then tied to the lifetime of its actual use. If you want to manually unload the assets you can do so like this:

if (skeletalMeshHandle.IsValid())
{
	skeletalMeshHandle->ReleaseHandle();
}

In blueprints, assets can’t be manually unloaded when using the AsyncLoadAsset node. The garbage collection will just track the resulting hard reference and see when it goes out of scope. As always, GC will only release an asset when the last hard reference to it is destroyed. Keep in mind though that the “Object” output pin is cached, and a cached reference will only go out of scope if the owning blueprint instance is destroyed.

Laat een reactie achter

Het e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *