Saving Screenshots & Byte Data in Unreal Engine
Learn the basics of C++ memcpy and byte arrays to store arbitrary data in your save game objects for Unreal Engine, and get the hang of UE serialization.
Unreal Engine 4 has a platform-agnostic save game system that can serialize all core data types and even nested objects through pointers. So long as we declare all data we want to save as object properties, the system will work out of the box. But sometimes, we want to save binary data from transient resources or arbitrary blocks of memory. In this Devlog entry, we will be exploring one such scenario by embedding a screenshot texture in our save game.
As usual, this post will cover a broader range of useful topics and best practices. We will go over the entire process of building an actor that we will use to save and load our game with an embedded screenshot. The first part of this process — taking a screenshot to memory in Unreal Engine — is already up on Devlog, so go ahead and give it a read if you haven't yet. In this entry, we will be focusing on the second part: saving and loading arbitrary memory data to and from disk using UE4's save game system.
Save Game Objects in Unreal Engine
The save game system in Unreal Engine is very robust. It enables saving and loading an object with a set of properties from files with just a few lines of code. What is even better, the engine writes our data to save slots in a platform-agnostic way, which means that whether your game targets PC, console, or mobile, UE4 will entirely handle the hardware abstraction.
By default, Unreal Engine will agreeably serialize most data types that we can declare as object properties when we ask it to save our objects, and all properties that aren't marked with the Transient specifier will be serialized. This system will even serialize properties of objects that our declared properties point to. But it will never attempt to serialize anything that is not declared as an object's property. We can change all of this default behavior with custom Serialize function overrides. But that is a topic for another Devlog.
Robust as the default behavior is, it still presents a problem when we wish to save things like transient textures or chunks of raw memory we generate at runtime. That is because neither bulk data of resources nor arbitrary bits of memory are UPROPERTIES. If we simply declared a property like:
UPROPERTY(VisibleAnywhere, SaveGame)
UTexture2D * SavedTexture;
In an object we serialize, upon unserialization, a UTexture2D will be created, but the original resource might be long gone if the transient package was destroyed. The same goes for all pointers to memory. We can restore a pointer, but who is to say that upon loading the game, the memory block still contains the same data as when our game was saved?
The texture resource we pointed to from our property no longer exists in the transient package upon load.
If we wish to save a block of memory in the save game itself, we have to declare some binary structure as our save object's property and copy the memory we'd like to preserve to and from it. Programmers commonly use TArrays of bytes (TArray) in save game objects to that end because they may contain any block of memory within them. Typically, this property is set from memory at runtime using a memcpy operation, and on load, its value restored to a particular place in memory with another memcpy.
If we wish to save a block of memory in the save game itself, we have to declare some binary structure as our save object's property and copy the memory we'd like to preserve to and from it. Programmers commonly use TArrays of bytes (TArray) in save game objects to that end because they may contain any block of memory within them. Typically, this property is set from memory at runtime using a memcpy operation, and on load, its value restored to a particular place in memory with another memcpy.
Saving and Loading a Save Game Object
A Custom Save Game Object Class
If we'd like like to save some data using the Unreal Engine's save system, we will first have to create a custom class derived from USaveGame, so let's do this now:
// MySaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
UCLASS()
class SANDBOX_API UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(VisibleAnywhere, SaveGame)
TArray<uint8> BinaryTexture;
};
// MySaveGame.cpp
#include "MySaveGame.h"
//It's empty since we have nothing to define here. This will be a container class.
As is evident above, our save game object class will only have one property — a byte array. That's all that we will want Unreal to serialize and unserialize for us. Loading and saving this object will be elementary as well. We may use UGameplayStatics::SaveDataToSlot, UGameplayStatics::LoadDataFromSlot like this:
// Saving a save game object to disk.
// Create a new save game object of our custom class.
UMySaveGame * SaveGameObject = NewObject<UMySaveGame>();
const FString SlotName = "MySlotName";
// Configure the object's data here.
// Save the object to disk.
UGameplayStatics::SaveGameToSlot(SaveGameObject, SlotName,0);
// Saving a save game object to disk.
// Create a new save game object of our custom class.
UMySaveGame * SaveGameObject = NewObject<UMySaveGame>();
const FString SlotName = "MySlotName";
// Configure the object's data here.
// Save the object to disk.
UGameplayStatics::SaveGameToSlot(SaveGameObject, SlotName,0);
There are also asynchronous variants of these functions.
Insight: You may have observed that we are using 0 as the UserIndex. This argument is the user's ID to which the save game belongs on a game console, like Xbox or Playstation. On PC, it is common practice to use 0.
A Helper Testbench Class
For a demonstration, let's also create an actor with some properties and functions exposed to the Unreal Editor. We will use this actor to manipulate our UMySaveGame object and to see how it works:
// Testbench.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MySaveGame.h"
#include "ViewCapture.h"
#include "Testbench.generated.h"
UCLASS()
class SANDBOX_API ATestbench : public AActor
{
GENERATED_BODY()
public:
ATestbench();
UPROPERTY(EditAnywhere, Category="Save Game Testing")
FString SlotName = "SaveSlot";
UFUNCTION(CallInEditor, Category="Save Game Testing")
void SaveGame();
UFUNCTION(CallInEditor, Category="Save Game Testing")
void LoadGame();
UPROPERTY(VisibleAnywhere, Category= "Save Game Testing")
UTexture2D * LoadedTexture;
};
// Testbench.cpp
#include "Testbench.h"
#include "Kismet/GameplayStatics.h"
// Sets default values
ATestbench::ATestbench()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = false;
}
void ATestbench::SaveGame()
{
}
void ATestbench::LoadGame()
{
}
This is how an instance of our Testbench actor looks in the Unreal Editor. We have buttons to load the game, save game, we have the save game slot name, and we have a preview of the loaded texture. Okay, now we're all set to dive into the actual data saving and loading.
Saving Screenshot Texture in a Save Game Object
Saving our Screenshot
If we would like to save a screenshot in a save file using Unreal's save game system, we must take a screenshot, create a save game object, and copy the screenshot's pixel data to a byte array property in the object. If you would like to learn more about the ViewCapture actor we use to take the screenshot, you can check out last week's Devlog post, where we write its class. For our purposes now, it's enough to know that the actor gives us an array of 262,144 pixel colors captured from the player's view.
So let's define our save game function now:
// Excerpt from Testbench.cpp
void ATestbench::SaveGame()
{
// 1. Create a temporary actor to capture the player's view.
AViewCapture * ViewCaptureActor = GetWorld()->SpawnActor<AViewCapture>();
// 2. Get the player's view as a BGRA8 array of colors.
static int32 Resolution = 512;
TArray<FColor> ColorArray;
ViewCaptureActor->CapturePlayersView(Resolution, ColorArray);
// 3. Actors do not get garbage collected automatically since the level is keeping them in GC graph.
ViewCaptureActor->Destroy();
// 4. Create a new save game object of our custom class.
UMySaveGame * SaveGameObject = NewObject<UMySaveGame>();
// 5. Memcpy data of our color array to our BinaryTexture byte array.
// Calculate the total number of bytes we will copy. Every color is represented by 4 bytes: R, G, B, A.
static int32 BufferSize = ColorArray.Num() * 4;
// Pre-allocate enough memory to fit our data. We reserve space before adding uninitialized elements to avoid array
// growth operations and we add uninitialized elements to increase array element count properly.
SaveGameObject->BinaryTexture.Reserve(BufferSize);
SaveGameObject->BinaryTexture.AddUninitialized(BufferSize);
// Copy BufferSize number of bytes starting from the memory address where ColorArray's bulk data starts,
// to a space in memory starting from the memory address where BinaryTexture's bulk data starts.
FMemory::Memcpy(SaveGameObject->BinaryTexture.GetData(),ColorArray.GetData(), BufferSize);
// 6. Save the object to disk.
UGameplayStatics::SaveGameToSlot(SaveGameObject, SlotName,0);
}
Note: we could have also used an array of FColor elements in the save game object. I have chosen to show you a more conventional way of saving arbitrary data — byte arrays — even if it involves an extra memcpy step.
Insight: Memory copying is a powerful tool in C++, so we should take a moment to look into it more. Let's start with a little bit of computer memory theory. In modern computing, memory is always stored in addressable sections of 8 bits, together known as a byte. "Addressable" here means that you can ask the random access memory to retrieve or store any byte you would like in the entire memory space — however many gigabytes of random access memory (RAM) you have. As a programmer, you may say, "memory, get me the 3,521,203,432,806th byte" or "memory, get me 64bytes starting from the 2,304,753rd byte," and your random access memory will oblige. We cannot address individual bits, and although we may perform logic operations on them using bitmasks. Still, we don't need to go into bitwise operations to understand memcpy. To copy some memory from one place to another, all we need is the memory address of the source, the memory address of the destination, and the number of bytes we have to copy. Remember, we can't copy bits individually or have our source or the destination point to a particular bit because we can't efficiently address them. The source and destination addresses in our above examples are pointers (because in C++, pointers simply point to a particular memory address), and the BufferSize variable is simply the number of bytes we want to copy over — 4 per pixel in our case, one for each color and one for alpha. You will find that bytes are almost always the basic unit of memory operations; therefore, you can think of them as the atomic units of memory for memcpy purposes.
Loading our Screenshot
To load our screenshot from a file, we will first need to ask Unreal to load our custom save game object back from a save slot and then convert the bytes we stored into a UTexture2D. We will discuss the conversion in a few moments, but for now, let's define the load function:
// Excerpt from Testbench.cpp
void ATestbench::LoadGame()
{
if(UGameplayStatics::DoesSaveGameExist(SlotName, 0))
{
// Load save game from disk.
UMySaveGame * SaveGameObject = static_cast<UMySaveGame*>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));
if(IsValid(SaveGameObject))
{
// Create a texture object from our data.
LoadedTexture = Create8BitTextureAtRuntime(SaveGameObject->BinaryTexture);
}
}
}
We will talk about the static Create8BitTextureAtRuntime function next.
Converting BGRA Byte Array to an 8-bit Texture
The first steps in restoring our byte data to a screenshot are creating a new UTexture2D and copying memory to it. To carry them out, we need to consider how video game textures look in memory. Textures in most 3D software, including games built with Unreal Engine, are nested sets of metadata and bulk data. The bulk data is pixel color information which could be raw or compressed. And the metadata is information about the texture, like pixel data format, size, and similar. Unreal Engine divides its bulk data into MIPs on a mipmap. Here is a schematic:
Insight: Game engines use textures that have at least one MIP — MIP 0. It is a full-sized copy of the texture's image. MIPs 1 — N are smaller copies of the same image, and they are usually pre-processed before runtime since it takes a little bit of time to scale them down at a high quality. Not all software will require textures to be of a power-of-two resolution (128×128, 256×256, 512×512, etc.), but it is a requirement in Unreal Engine.
Now that we have a good understanding of how textures are stored in memory, let's go ahead and write a static function that copies our BGR8 byte array to a brand new texture:
// Excerpt from Testbench.cpp
UTexture2D* ATestbench::Create8BitTextureAtRuntime(TArray<uint8>& BGRA8PixelData)
{
// 1. Create a new texture of the right size, and get reference to the first MIP for convenience.
// Calculate the resolution from a number of pixels in our array (bytes / 4).
const float Resolution = FGenericPlatformMath::Sqrt(BGRA8PixelData.Num() / 4);
// Create a new transient UTexture2D in a desired pixel format for byte order: B, G, R, A.
UTexture2D * Texture = UTexture2D::CreateTransient(Resolution, Resolution,PF_B8G8R8A8);
// Get a reference to MIP 0, for convenience.
FTexture2DMipMap & Mip = Texture->PlatformData->Mips[0];
// 2. Memcpy operation.
// Calculate the number of bytes we will copy.
const int32 BufferSize = BGRA8PixelData.Num();
// Mutex lock the MIP's data, not letting any other thread read or write it now.
void* MipBulkData = Mip.BulkData.Lock( LOCK_READ_WRITE );
// Pre-allocate enough space to copy our bytes into the MIP's bulk data.
Mip.BulkData.Realloc(BufferSize);
// Copy BufferSize number of bytes starting from BGRA8PixelData's bulk data address in memory to
// a block of memory starting from the memory address MipBulkData.
FMemory::Memcpy( MipBulkData, BGRA8PixelData.GetData(), BufferSize);
// Mutex unlock the MIP's data, letting all other threads read or lock for writing.
Mip.BulkData.Unlock();
// 3. Let the engine process new data.
Texture->UpdateResource();
return Texture;
}
Of course, we will need to declare it in the header, too:
// Excerpt from Testbench.h
static UTexture2D * Create8BitTextureAtRuntime(TArray<uint8> & BGRA8PixelData);
Insight: Unreal engine will store pixel data in the order that is requested by the pixel format, regardless of the endianness of the system. In some cases, like the declaration of FColor, you can see how the compiler will reverse the order of bytes in an int32 to maintain this order. This is why we can safely memcpy directly from the FColor array to a PF_B8G8R8A8 texture. Here is the FColor class declaration from Unreal Engine's source code:
// Excerpt from Unreal Engine's source code shared under license permissions for educational purposes.
struct FColor
{
public:
// Variables.
#if PLATFORM_LITTLE_ENDIAN
#ifdef _MSC_VER
// Win32 x86
union { struct{ uint8 B,G,R,A; }; uint32 AlignmentDummy; };
#else
// Linux x86, etc
uint8 B GCC_ALIGN(4);
uint8 G,R,A;
#endif
#else // PLATFORM_LITTLE_ENDIAN
union { struct{ uint8 A,R,G,B; }; uint32 AlignmentDummy; };
#endif
// ...
Demo
With all that hard work out of the way, let's look at what we have accomplished. Our three-actor system should now capture the player's view, save it as a byte array to disk, load it, and output a game texture for us to work with any way we'd like.
Further Learning
To learn more about the Unreal Engine's save game system, visit the the dedicated Unreal Engine's documentation page.
Conclusion
In this Devlog, we have looked at saving arbitrary data like screenshots using the robust and platform-agnostic Unreal Engine's save game system. I hope you found it useful and are thinking about the many ways you can apply this knowledge. Our creative opportunities are almost endless, with the ability to create textures at runtime from raw pixel data. And with the experience to save any arbitrary memory block on any platform, we have fantastic flexibility with our save games.
As always, if you have any remarks or new uses for textures made at runtime, please leave them in the comments below or get in a message with me on social media. I love hearing how these articles help the community and how my future discussions here can be improved.
Source Code
// MySaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
UCLASS()
class SANDBOX_API UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY(VisibleAnywhere, SaveGame)
TArray<uint8> BinaryTexture;
};
// MySaveGame.cpp
#include "MySaveGame.h"
// Testbehnch.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MySaveGame.h"
#include "ViewCapture.h"
#include "Testbench.generated.h"
UCLASS()
class SANDBOX_API ATestbench : public AActor
{
GENERATED_BODY()
public:
ATestbench();
UPROPERTY(EditAnywhere, Category="Save Game Testing")
FString SlotName = "SaveSlot";
UFUNCTION(CallInEditor, Category="Save Game Testing")
void SaveGame();
UFUNCTION(CallInEditor, Category="Save Game Testing")
void LoadGame();
UPROPERTY(VisibleAnywhere, Category= "Save Game Testing")
UTexture2D * LoadedTexture;
static UTexture2D * Create8BitTextureAtRuntime(TArray<uint8> & BGRA8PixelData);
};
// Testbench.cpp
#include "Testbench.h"
#include "Kismet/GameplayStatics.h"
// Sets default values
ATestbench::ATestbench()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = false;
}
void ATestbench::SaveGame()
{
// 1. Create a temporary actor to capture player's view.
AViewCapture * ViewCaptureActor = GetWorld()->SpawnActor<AViewCapture>();
// 2. Get the player's view as a BGRA8 array of colors.
static int32 Resolution = 512;
TArray<FColor> ColorArray;
ViewCaptureActor->CapturePlayersView(Resolution, ColorArray);
// 3. Actors do not get garbage collected automatically since the level is keeping them in GC graph.
ViewCaptureActor->Destroy();
// 4. Create a new save game object of our custom class.
UMySaveGame * SaveGameObject = NewObject<UMySaveGame>();
// 5. Memcpy data of our color array to our BinaryTexture byte array.
// Calculate the total number of bytes we will copy. Every color is represented by 4 bytes: R, G, B, A.
static int32 BufferSize = ColorArray.Num() * 4;
// Pre-allocate enough memory to fit our data. We reserve space before adding uninitialized elements to avoid array
// growth operations and we add uninitialized elements to increase array element count properly.
SaveGameObject->BinaryTexture.Reserve(BufferSize);
SaveGameObject->BinaryTexture.AddUninitialized(BufferSize);
// Copy BufferSize number of bytes starting from the memory address where ColorArray's bulk data starts,
// to a space in memory starting from the memory address where BinaryTexture's bulk data starts.
FMemory::Memcpy(SaveGameObject->BinaryTexture.GetData(),ColorArray.GetData(), BufferSize);
// 6. Save the object to disk.
UGameplayStatics::SaveGameToSlot(SaveGameObject, SlotName,0);
}
void ATestbench::LoadGame()
{
if(UGameplayStatics::DoesSaveGameExist(SlotName, 0))
{
// Load save game from disk.
UMySaveGame * SaveGameObject = static_cast<UMySaveGame*>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));
if(IsValid(SaveGameObject))
{
// Create a texture object from our data.
LoadedTexture = Create8BitTextureAtRuntime(SaveGameObject->BinaryTexture);
}
}
}
UTexture2D* ATestbench::Create8BitTextureAtRuntime(TArray<uint8>& BGRA8PixelData)
{
// 1. Create a new texture of the right size, and get reference to the first MIP for convenience.
// Calculate the resolution from a number of pixels in our array (bytes / 4).
const float Resolution = FGenericPlatformMath::Sqrt(BGRA8PixelData.Num() / 4);
// Create a new transient UTexture2D in a desired pixel format for byte order: B, G, R, A.
UTexture2D * Texture = UTexture2D::CreateTransient(Resolution, Resolution,PF_B8G8R8A8);
// Get a reference to MIP 0, for convenience.
FTexture2DMipMap & Mip = Texture->PlatformData->Mips[0];
// 2. Memcpy operation.
// Calculate the number of bytes we will copy.
const int32 BufferSize = BGRA8PixelData.Num();
// Mutex lock the MIP's data, not letting any other thread read or write it now.
void* MipBulkData = Mip.BulkData.Lock( LOCK_READ_WRITE );
// Pre-allocate enough space to copy our bytes into the MIP's bulk data.
Mip.BulkData.Realloc(BufferSize);
// Copy BufferSize number of bytes starting from BGRA8PixelData's bulk data address in memory to
// a block of memory starting from the memory address MipBulkData.
FMemory::Memcpy( MipBulkData, BGRA8PixelData.GetData(), BufferSize);
// Mutex unlock the MIP's data, letting all other threads read or lock for writing.
Mip.BulkData.Unlock();
// 3. Let the engine process new data.
Texture->UpdateResource();
return Texture;
}
If you would like to see the source code of the ViewCapture actor used in this Devlog entry, you can find it attached to last week's Devlog.