In today's post, I'd like to show you how to retrieve an image provided by The Art Institute of Chicago via its public API, how to create a texture from this image, and how to feed this texture to a material and render it on a plane accompanied by a floating text with title, name of the artist and some other details.

To achieve that, we're going to write a custom C++ class derived from Actor, with UStaticMeshComponent and UTextRenderComponent both attached to a USceneComponent set as a RootComponent.

The key property of this class will be CatalogId, an integer that tells which artwork to fetch from the collection of the above-mentioned art institute, using an HTTP request.

We'll be able to build an entire virtual gallery using just this single custom actor. Adding a new artwork would be as simple as placing an instance of the actor into our map and setting the CatalogId.

However, I like my tutorials to be beginner friendly even when it covers slightly advanced topics. Let's take a step back now and talk a little bit about what is an API.

If you're already well familiar with the term, feel free to skip the following few paragraphs and dive straight into the implementation from Preparing a new Unreal Engine project.

What Is an API?

For the rest, API stands for Application Programming Interface. It provides a set of instructions that enable different systems to communicate with one another, specifying the methods for sending and receiving data, as well as the actions that can be performed.

In our scenario, we have two systems, one is the application we're going to build, using the Unreal Engine, and the other is a web server that provides an API for retrieving data from a database of The Art Institute of Chicago (ARTIC).

The web server acts as an API provider, while our application serves as its consumer. As long as a consumer makes a valid request, the API provider doesn't concern itself with who the consumer is or what they plan to do with the data; it simply provides it.

Actually, we could build a similar project in Unity or use an API from any other application that can make an HTTP request, such as a web browser. You can make a request right now by simply clicking on the following link:

https://api.artic.edu/api/v1/artworks/129884

When you clicked the link, your web browser made a GET request using the HTTP protocol and received a response containing data in JSON (JavaScript Object Notation) format.

Upon examining the JSON data¹ that you received, you'll notice that it contains information about Alma Thomas's artwork, Starry Night and the Astronauts, which is stored in the ARTIC collection with an ID of 129884.

1) To make the JSON data more readable, you may want to install a browser extension, such as "JSON Prettifier" or "JSON Formatter." These extensions will format the data in a more organized and easy-to-read manner.

How does this work? How can we retrieve and utilize data from a single API that doesn't differentiate between a web browser and an Unreal Engine application? The answer lies partly in the previous paragraphs.

This is because the API provides data using a standardized protocol and format. Your web browser knows how to send a request using the HTTP protocol and how to handle the response.

Fortunately, Unreal Engine is also equipped to handle this. It includes an HttpModule and JSON Serializer that we'll use to retrieve and parse data, respectively.

There's much more to learn about APIs and the technology behind them, including various protocols and data formats such as XML and others. Of significance is the fact that both requests and responses have headers, although I didn't cover them in this brief introduction.

Some APIs require authentication, which opens up another deep rabbit hole to explore. It's also worth noting that not all APIs are web APIs, and there's something called REST (Representational State Transfer), which is a specific architectural style and widely used approach for building APIs.

Preparing a New Unreal Engine Project

Now let's start building something. If you want to follow along (and I highly recommend that you do), begin by creating an empty C++ project in Unreal Engine 5.1.1.

It might work on other versions too, but version 5.1.1 is the one I've built the example project, so I cannot guarantee that it would also work on, for instance, version 4.27.

I've named my project FetchArt, but feel free to choose any name you like.

Creating a Material

We won't be needing any starter content, but we do require a material that we'll use to render texture to a plane later on.

In the Content Browser, create a new folder by right-clicking and selecting New Folder. Name the folder Materials. Then, create a new material by selecting New Material from the same context menu. Name the material ExampleMaterial.

Open the material and navigate to the Material Graph. From there, right-click inside the graph and search for Texture Sample. Once the TextureSample node appears, click on it to add it to the Material Graph.

Finally, right-click on the Texture Sample node and select Convert to Parameter. This will convert the Texture Sample node into a parameter node, allowing us to easily change the texture used by the material later on.

We convert the TextureSample node into a TextureParameter node so that we can easily assign a texture created from an image fetched from an API in our custom C++ class derived from Actor later on.

When converting the Texture Sample node into a Texture Parameter node, make sure to name the parameter TextureParameter and double-check the spelling. This is important because we'll be referencing this parameter by name in our C++ code.

With the Texture Parameter node selected, look for the Material Expression Texture Base section in the Details panel on the bottom left of the editor. To assign a default material, click on the small curved arrow icon located on the right side of the Param row.

We need to assign any texture to TextureParameter, even if we're not going to use that particular texture. This is because, without any assigned texture, we won't be able to save the material after we connect the RGB output pit of the parameter with the Base Color input of the parameter due to an error.

Finally, connect the TextureParameter output pin to the Base Color input pin of the ExampleMaterial. Make sure to save the material before closing the window or tab.

Fun fact: this default texture has been with Unreal for more than 20 years. I remember it well from my days of making custom maps in UnrealEd 2.0 for LAN parties where my friends and I used to spend hours playing Unreal Tournament (GOTY 1999).

Adding a Custom Actor Class

In the Content Browser, enter the C++ Classes folder of your project. Right-click on the folder and select Add New C++ Class. From the pop-up window, select Actor as the parent class and name the class RemoteImagePlane.

According to the Unreal Engine documentation, "An Actor is any object that can be placed into a level... Actors support 3D transformations such as translation, rotation, and scaling", which makes it the perfect parent class for our needs.

Importing Modules

Time to write some code. First, to prevent linker errors, you need to specify the modules that our project depends on in {YourProjectName}.Build.cs.

This C# file is a part of the Unreal Build Tool (UBT). When a project is built, UBT reads the file and uses it to generate the necessary build scripts and configuration files.

One typically adds modules when needed in Unreal Engine, but since I have already implemented the code for this tutorial, I already know which modules we will need, which are:

  • HTTP
  • Json
  • JsonUtilities
  • ImageWrapper

Add these module names to the PublicDependencyModuleNames list as follows:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HTTP", "Json", "JsonUtilities", "ImageWrapper" });
This allows for better control over which modules are included in the build, which can help reduce build times and improve overall performance.

Header File

Now that the modules have been added to the project, it's time to make changes to our RemoteImagePlane class. Open the header file for this class, which is RemoteImagePlane.h.

This actor doesn't need to run any logic in every tick, so you can safely delete the declaration of the Tick method. Don't forget to also delete the implementation of the method in the source file, RemoteImagePlane.cpp.  Additionally, in the constructor change the value of the PrimaryActorTick.bCanEverTick flag from true to false.

Back in the header file, between the #include RemoteImagePlane.generated.h and UCLASS() macro, add the following forward declarations and type definitions:

class UStaticMeshComponent;
class UTextRenderComponent;
class USceneComponent;

class IHttpRequest;
class IHttpResponse;

typedef TSharedPtr<IHttpRequest, ESPMode::ThreadSafe> FHttpRequestPtr;
typedef TSharedPtr<IHttpResponse, ESPMode::ThreadSafe> FHttpResponsePtr;
Forward declarations help to reduce compilation times and can help to avoid circular dependencies between header files. Type aliases are useful in reducing the amount of code you need to write. They allow you to define an alias for a type, which can make your code more readable and easier to maintain.

Under the protected access specifier, declare the two delegate methods as follows:

void OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful);
void OnImageDownloaded(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful);
It is a common convention to name delegate methods with the prefix "On," followed by a descriptive name of the event that the delegate represents.

To implement the desired functionality, our custom Actor requires a few components. These include:

  • UStaticMeshComponent to render a plane mesh on which we'll set a dynamically created material instance. The fetched artwork will be rendered on this component as a texture.
  • UTextRenderComponent to display the title of the artwork, the name of the artist, and other relevant information about the artwork.
  • USceneComponent to be used as a root component and to attach the other two components.

We don't want to attach the UTextRenderComponent to the UStaticMeshComponent or the default root component of the Actor, because it would restrict our ability to adjust the position of the text.

We'll need to modify the scale of the Plane to match the aspect ratio of the fetched artwork. To address these issues, we'll use a USceneComponent as the root component and attach the other two components to it.

Declare these three member variables, pointers to the above-mentioned classes, as follows.

UPROPERTY(EditAnywhere)
UStaticMeshComponent* PlaneComponent;

UPROPERTY(EditAnywhere)
UTextRenderComponent* TextComponent;

USceneComponent* SceneComponent;
The UPROPERTY() macro is used to mark class member variables as properties of the class. The EditAnywhere specifier allows these properties to be edited in the Unreal Editor, both in the Blueprint and the Details panel of the component.

Also declare two additional member variables to set the width of the texture for our material and the ID of the artwork we're going to fetch:

UPROPERTY(EditAnywhere)
int TextureWidth = 512;

UPROPERTY(EditAnywhere)
int CatalogId = 129884;
We want to be able to edit the values of our actors in the Unreal Editor, particularly the CatalogId, so we can display different artworks on different instances in the game world.

The last thing in our header file is a little helper method. This method pulls out properties from a received JSON without repeating the same code multiple times.

bool TryGetStringField(const TSharedPtr<FJsonObject, ESPMode::ThreadSafe>& JsonObject, const FString& FieldName, FString& OutString) const;
This method will help us to keep our code more DRY. DRY stands for "Don't Repeat Yourself", the opposite of a DRY code is a WET code - "Write Everything Twice".

Source File

Let's move on to the source file, RemoteImagePlane.cpp. The first step is to include all necessary header files:

#include "RemoteImagePlane.h"
#include "Http.h"
#include "JsonUtilities.h"
#include "Components/StaticMeshComponent.h"
#include "Components/TextRenderComponent.h"
#include "Components/SceneComponent.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "Engine/Texture2D.h"
#include "IImageWrapperModule.h"
#include "IImageWrapper.h"
We used forward declaration in the header file. In the source file, we need to provide the actual implementations by including all necessary header files using the #include preprocessor directive. This directive effectively copies and pastes the entire file that it includes. While it's a simple approach, it can sometimes lead to problems.

In the constructor, where we already set PrimaryActorTick.bCanEverTick to false, create a USceneComponent and set it as the Actor's root component.

SceneComponent = CreateDefaultSubobject<USceneComponent>(TEXT("SceneComponent"));
SetRootComponent(SceneComponent);
We create a USceneComponent using a template method CreateDefaultSubobject. Passing "SceneComponent" as the name for the new component.

To create a UStaticMeshComponent in a similar way, start by finding the Plane mesh among Unreal Engine basic shapes using FObjectFinder. If the search succeeds, set the Plane as the component's static mesh. In case of failure, log an error using the UE_LOG macro. Finally, attach the PlaneComponent to the SceneComponent.

PlaneComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("PlaneComponent"));
static ConstructorHelpers::FObjectFinder<UStaticMesh> PlaneMesh(TEXT("/Engine/BasicShapes/Plane"));
if (PlaneMesh.Succeeded())
{
	PlaneComponent->SetStaticMesh(PlaneMesh.Object);
}
else 
{
	UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Failed to find mesh"));
}
PlaneComponent->SetupAttachment(SceneComponent);
Prefixing log messages with the name of the class can be useful. This way, you or your colleague can immediately see in the output console where the log message came from.

The last thing we need to do in the constructor is to create a UTextRenderComponent and attach it to our SceneComponent as well.

TextComponent = CreateDefaultSubobject<UTextRenderComponent>(TEXT("TextRenderComponent"));
TextComponent->SetupAttachment(SceneComponent);
Nothing new here.

Now that we've created and set the components of our custom Actor, let's move on to the BeginPlay method. After calling Super::BeginPlay(), which invokes the method in the parent class (in our case, the AActor class), we need to create an instance of FHttpModule.

FHttpModule* HttpModule = &FHttpModule::Get();
FHttpModule provides the necessary logic for working with the HTTP protocol.

Next, use the HttpModule to create an HttpRequest. Set the request method to GET and construct the RequestURL using the ARTIC Public API route for getting artwork data with our CatalogId member variable as a parameter, and the Content-Type header to application/json.

TSharedRef<IHttpRequest> HttpRequest = HttpModule->CreateRequest();
HttpRequest->SetVerb("GET");
FString RequestURL = FString::Format(TEXT("https://api.artic.edu/api/v1/artworks/{0}?fields=artist_display,title,image_id"), { CatalogId });
HttpRequest->SetURL(RequestURL);
HttpRequest->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
In Unreal Engine, the function to set a request method is called SetVerb. Apart from GET, there are POST, PUT, DELETE, HEAD, OPTIONS, TRACE, and CONNECT. These methods are defined by the HTTP protocol and are used to indicate the desired action to be performed.

Finally, bind our delegate method OnResponseReceived, we yet need to implement, to OnProcessRequestComplete and invoke sending of the request using ProcessRequest method of HttpRequest class.

HttpRequest->OnProcessRequestComplete().BindUObject(this, &ARemoteImagePlane::OnResponseReceived);

HttpRequest->ProcessRequest();
The OnResponseReceived delegate method will be called when the API returns data, and this data will be passed to the delegate, where we can handle them as desired.

Let's now implement the body of our OnResponseReceived delegate method. First, check if the bWasSuccessful parameter is true and if the Response object is in a valid state. If not, print an error message to the Output Console and return early from the method.

if (!bWasSuccessful || !Response.IsValid())
{
	UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Request failed"));
	return;
}
The opposite of both values being true is that at least one is false, which is the case when we want to log the error and early return.

Now when we know our Response is valid, we can get the content data by using its GetContentAsString method. The returned string is in JSON format and needs to be deserialized to a JsonObject using the TJsonReader and static FJsonSerializer::Deserialize method, as follows:

FString ResponseStr = Response->GetContentAsString();
TSharedPtr<FJsonObject> JsonObject;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(ResponseStr);
if (!FJsonSerializer::Deserialize(Reader, JsonObject))
{
	UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Failed to parse JSON content"));
	return;
}
If the Deserialize method returns false, the deserialization failed, and we once again want to log the error and return early.

Now that we've deserialized our content into a JsonObject, we can begin extracting data from the object and storing them in FString variables. However, before doing so, let's first implement our helper method, TryGetStringField.

This method takes a JsonObject, a field name, and a reference to an FString variable whose value will be set to the value of the field.

bool ARemoteImagePlane::TryGetStringField(const TSharedPtr<FJsonObject, ESPMode::ThreadSafe>& JsonObject, const FString& FieldName, FString& OutString) const
{
    if (JsonObject->TryGetStringField(FieldName, OutString))
    {
        UE_LOG(LogTemp, Log, TEXT("[ARemoteImagePlane] %s: %s"), *FieldName, *OutString);
        return true;
    }
    else
    {
        UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Failed to get %s"), *FieldName);
        return false;
    }
}
Note that the method is just a thin wrapper around the TryGetStringField method on JsonObject. It provides logging functionality for both successful and failed outcomes.

With that done, let's continue where we left off in the OnResponseReceived delegate method.

The JSON response from the ARTIC API is not a flat structure; instead, it looks something like this:

{
    "data": {
        "title": "Starry Night and the Astronauts",
        "artist_display": "Alma Thomas\nAmerican, 1891–1978",
        "image_id": "e966799b-97ee-1cc6-bd2f-a94b4b8bb8f9"
    },
    "config": {
        "iiif_url": "https://www.artic.edu/iiif/2",
        "website_url": "http://www.artic.edu"
    },
    "info": { 
        ... 
    }
}

Because of this, we first need to extract the individual data and config blocks, which are also of JsonObject type (yes, JSON objects can be, and often are, composed of multiple inner JSON objects). To do so, use the GetObjectField method.

const TSharedPtr<FJsonObject, ESPMode::ThreadSafe>& DataObject = JsonObject->GetObjectField("data");
if (!DataObject.IsValid())
{
	UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Invalid DataObject"));
	return;
}
As usual, we check for validity and log an error before early returning in case of an error.

Now that we have isolated the data block as an individual JSON object DataObject, we can declare FString variables for ImageId, Title, ArtistDisplay, and IIIFUrl, and use our TryGetStringField method to assign their values.

FString ImageId, Title, ArtistDisplay, IIIFUrl;
if (!TryGetStringField(DataObject, "image_id", ImageId) ||
!TryGetStringField(DataObject, "title", Title) ||
!TryGetStringField(DataObject, "artist_display", ArtistDisplay))
	return;
We pass the FString values to our method via reference, and the return value is a boolean indicating success or failure. Therefore, we can assign values directly in the conditional statement, and if there is an error, we can return early. We don't need to call UE_LOG here since we already implemented logging in the TryGetStringField method itself.

Now we can use the Title and ArtistDisplay strings we retrieved to set the text of our TextComponent. However, before doing so, we need to replace any en dashes with hyphens because there is no glyph for en dashes or many other special characters in the font material the UTextRenderComponent is using by default. We can accomplish this simply by using the Replace method.

FString LabelText = FString::Format(TEXT("{0}\n{1}"), { Title, ArtistDisplay });

FString EnDashChar = FString::Chr(0x2013);
FString HyphenChar = FString::Chr(0x002D);
LabelText = LabelText.Replace(*EnDashChar, *HyphenChar);

TextComponent->SetText(FText::FromString(LabelText));
There are 3 types of strings in Unreal Engine, FString, FName and FText.

Since we're talking about strings, it's worth mentioning there are three types of string in Unreal Engine:

  • FString – a mutable type that can be modified at runtime. It is used for general-purpose string handling.
  • FName – an immutable type that is used to represent names and identifiers within the engine. It is often used for things like object names and variable names.
  • FText – a localized type. It is used for UI text, game messages, and other text that needs to be displayed to the user.

Now that we have set the TextComponent, we can move on to getting the URL of the actual image.

This can be done by concatenating the ImageId we already retrieved from the DataObject with the IIIFUrl we still need to get from the ConfigData, an another JsonObject.

You already know how to achieve that. Pulling IIIFUrl out from ConfigData is not much different from retrieving data from DataObject.

const TSharedPtr<FJsonObject, ESPMode::ThreadSafe>& ConfigObject = JsonObject->GetObjectField("config");
if (!ConfigObject.IsValid())
{
	UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Invalid ConfigObject"));
	return;
}
    
if (!TryGetStringField(ConfigObject, "iiif_url", IIIFUrl))
	return;
Previously, we discussed the importance of not repeating code (also known as DRY). However, in this case, we are only using this small piece of logic twice. In my opinion, it may not be worth it to abstract it into a separate function.

Now we have all the bits for concatenating the ImageUrl.

FString ImageUrl = FString::Format(TEXT("{0}/{1}/full/{2},/0/default.jpg"), { IIIFUrl, ImageId, TextureWidth });
UE_LOG(LogTemp, Log, TEXT("[ARemoteImagePlane] ImageUrl: %s"), *ImageUrl); 
Note that we also used ourTextureWidth member variable.

In the case of the artwork with CatalogId 129884, TextureWidth 512, a and retrieved ImageId e966799b-97ee-1cc6-bd2f-a94b4b8bb8f9 the ImageUrl should be set to https://www.artic.edu/iiif/2/e966799b-97ee-1cc6-bd2f-a94b4b8bb8f9/full/512,/0/default.jpg.

We can use the ImageUrl to make a second HTTP request, this time to retrieve the image data. The syntax for this request is almost identical to the first one, since we are still obtaining data over the HTTP protocol.

FHttpModule* HttpModule = &FHttpModule::Get();

TSharedRef<IHttpRequest> GetImageRequest = FHttpModule::Get().CreateRequest();
GetImageRequest->SetVerb("GET");
GetImageRequest->SetURL(ImageUrl);
GetImageRequest->OnProcessRequestComplete().BindUObject(this, &ARemoteImagePlane::OnImageDownloaded);

GetImageRequest->ProcessRequest();

The key difference between the two HTTP requests is the delegate method bound to OnProcessRequestComplete. This marks the last piece of logic, we need to implement.

First, in the body of OnImageDownload method, ensure the request was successful and the response is valid, similarly as we did before.

if (!bWasSuccessful || !Response.IsValid())
{
	UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Failed get image"));
	return;
}
It's unlikely that Response won't be valid when bWasSuccessful is true, but I tend to be a bit more defensive in the Unreal Engine, where a single null pointer can easily crash the editor.

After downloading the image data, it will be stored in memory. However, we still need to do a bit of work in order to render that image on our PlaneComponent.

Start by loading the ExampleMaterial we've created in the editor and creating an instance of UMaterialInstanceDynamic type based on this material.

UMaterial* MaterialToInstance = LoadObject<UMaterial>(nullptr, TEXT("Material'/Game/Materials/ExampleMaterial.ExampleMaterial'"));

UMaterialInstanceDynamic* MaterialInstance = UMaterialInstanceDynamic::Create(MaterialToInstance, nullptr);
We need one instance material for every plane, otherwise we wouldn't be able to display more artworks. It would be efficient to share one instance material, but our virtual gallery would be quite boring with just one artwork copied all over the place.

With the dynamic material instance ready and the binary data of the fetched image stored in memory, the next step is to create an ImageWrapper using the IImageWrapperModule to decode the data.

TArray<uint8> ImageData = Response->GetContent();
IImageWrapperModule& ImageWrapperModule = FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
TSharedPtr<IImageWrapper> ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::JPEG);
We assume the image format is JPEG.

Now, check if the ImageWrapper is in a valid state. If it is, decompress the image data from the ImageData buffer and store them in another buffer UncompressedBGRA, also declared as TArray<uint8>.

The UncompressedBGRA buffer stores the image data in raw BGRA format, where BGRA stands for the color channels blue, green, red, and alpha (the transparency channel).

TArray<uint8> UncompressedBGRA;
if (!ImageWrapper.IsValid() || !ImageWrapper->SetCompressed(ImageData.GetData(), ImageData.Num()) || !ImageWrapper->GetRaw(ERGBFormat::BGRA, 8, UncompressedBGRA))
{ 
            UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Failed to wrap image data"));
            return;
}
As before, we want to log any errors and exit the function if any of the logic fails.

We're almost there. Create a transient texture of the width and height taken from ImageWrapper, and the pixel format PF_B8G8R8A8. The transient means, the texture will be created only in memory, without creating a file on disk and the pixel format is the 32-bit format with 8 bits for each channel (blue, green, red, and alpha).

UTexture2D* Texture = UTexture2D::CreateTransient(ImageWrapper->GetWidth(), ImageWrapper->GetHeight(), PF_B8G8R8A8);

Set the texture's compression to TC_Default and SRGB flag to true, this flag is indicating that the texture should be gamma-corrected when displayed on a screen. Then, call the AddToRoot method to prevent the object and its descendants from being deleted during garbage collection.

Texture->CompressionSettings = TC_Default;
Texture->SRGB = true;
Texture->AddToRoot();

Now create a pointer to texture data, copy the data from UncompressedBGRA buffer and update resource with the new data. This involves sending the texture data to the GPU, where it can be used in rendering.

void* TextureData = Texture->GetPlatformData()->Mips[0].BulkData.Lock(LOCK_READ_WRITE);
FMemory::Memcpy(TextureData, UncompressedBGRA.GetData(), UncompressedBGRA.Num());
Texture->GetPlatformData()->Mips[0].BulkData.Unlock();
Texture->UpdateResource();
The first element of Mips array represents the base level of the texture. We used the Lock and Unlock methods to temporarily prevent access to the texture data while we modify it.

Great! We now have a texture ready to be assigned to the TextureParameter of our material instance. Once the texture is assigned, we can then set the material instance as the material on our PlaneComponent.

MaterialInstance->SetTextureParameterValue("TextureParameter", Texture);
PlaneComponent->SetMaterial(0, MaterialInstance);
We pushed the dynamic material instance to the first material slot of our PlaneComponent, the slot with the index 0.

One last thing, not every artwork is a square, in fact, very few artworks has an aspect ratio of 1:1, most of them have different widths and heights and thus different aspect ratios.

That means we need to calculate the aspect ratio of the image and adjust the scale of our plane accordingly.

Fortunately, this is a straightforward process: we simply divide the image height by the image width to obtain the aspect ratio, and then scale the Y dimension of the plane by that value.

float AspectRatio = (float)ImageWrapper->GetHeight() / (float)ImageWrapper->GetWidth();
PlaneComponent->SetWorldScale3D(FVector(1.f, AspectRatio, 1.f));   
GetHeight and GetWidth methods return integers, so we need to cast them to floats, otherwise, our results would mostly end up being 0.

This ensures that the artwork will be displayed without any stretching. The width of our plane remains constant, while the height would be scaled proportionally to match the aspect ratio of the image.

Blueprint Class

Our code is now complete, and we can proceed to create a Blueprint class based on our custom C++ class in the Unreal Editor.

To do this, right-click on the class in the Content Browser and select Create Blueprint Class Based on RemoteImagePlane from the context menu. Name the new Blueprint class BP_RemoteImagePlane.

It's a common practice to prefix a name of a Blueprint class with "BP_"

We could have set the default position and orientation of our plane and text in C++ code, or created an instance by drag-and-dropping the class from the Content browser into the map and then edit the values in the Details panel.

However, customizing our actor visually in the Blueprint editor will be much more convenient. To do this, open the BP_RemoteImagePlane in the Full Blueprint editor and select the Plane Component in the Components tab.

We parented Plan Component and Text Component to Scene Component, which we've set as the Root Component in the constructor of our C++ class.

When you have the Plane Component selected, you can adjust its position and orientation using gizmos. To switch between the gizmos for setting the relative position and rotation, you can use the buttons located on the top-right corner of the Viewport tab, or you can use the W and E keys, respectively.

Alternatively, you can set the Transform values in the Details panel, which is located by default on the right side.

Also set the position and rotation of the Text Component. You can select it from the Components tab or by using the Select object (O) tool, which is the leftmost button located next to the buttons mentioned previously.

After positioning your plane and text as desired, hit the Compile button, save the blueprint, and exit the blueprint editor.

Finally, drag and drop the BP_RemoteImagePlane class into the map and click on Play button (or press Alt+P). You should see something similar to this:

Et voilà! Simply add a VR controller to the project, and you can create an immersive VR gallery experience!

Place some more instances of BP_RemotePlaneImage in the map, and set a different CatalogId for each one in the Details tab while instance is selected.

To obtain more catalog numbers, you can browse the ARTIC collection at https://www.artic.edu/collection. The number is always present in the link to the artwork, such as in the following example: https://www.artic.edu/artworks/28560/the-bedroom.

By default, in Unreal Engine 5.1.1, the Details tab is located in the bottom right corner, just below the Outliner.

Alternatively, you can obtain catalog numbers and all other public API data from the Data Dump.

Final Thoughts

If you have followed along and managed to get this far, give yourself a pat on the back, especially if you are a beginner. You have learned about APIs, how to make an HTTP request, how to deserialize a JSON response, and how to extract data from complex JSON structures. You have also gained a few other bits of knowledge about Unreal Engine, such as the fact that there are three different types of strings.

On top of that, you now know how to create a dynamic material instance and a transient texture. You also know how to convert image data, such as JPEG format, into an array of raw data in a specific pixel format. You're able to push this data to the texture and set the texture as a texture parameter of the dynamic material in C++,  which is a fairly advanced topic.

One final thought on refactoring: almost all the logic is implemented in a few functions, and most of them perform more than what a single function should.

Splitting the logic into smaller functions, each with descriptive names, would be a reasonable thing to do. This can help create self-documented code, which is code that is easy to read and understand without the need for additional comments.

I've decided to keep the tutorial implementation as is so that I can explain it in a linear way. However, the example project has been carefully commented. If you choose to refactor the code, it would be a great exercise and a way to solidify what you've learned today.

Thank you for reading this post. If you enjoyed this post, follow me on Twitter where I post almost exclusively about game development and programming topics.