В этой статье я хочу продемонстрировать вам пример работы со сторонним API в Unreal Engine. Для этого мы создадим виртуальную галерею, в рамках которой мы будем получать изображение картины из The Art Institute of Chicago через его публичный API, создавать текстуру из этого изображения и добавлять эту текстуру в материал, чтобы отрисовать ее на плоском меше, дополнив плавающим текстом с названием, именем художника и некоторыми другими деталями.
Для этого мы напишем собственный класс C++, который будет наследовать от Actor, с UStaticMeshComponent и UTextRenderComponent, прикрепленными к USceneComponent, установленному в качестве корневого компонента.
Ключевым свойством этого класса будет CatalogId — целочисленное значение, указывающее, какую картину следует извлечь из коллекции вышеупомянутого художественного института с помощью HTTP‑запроса.
На основе этого пользовательского актора мы сможем создать целую виртуальную галерею. Чтобы добавить новую картину достаточно будет просто поместить инстанс актора на сцену и указать его CatalogId.
Однако я стараюсь делать свои туториалы доступными для любого уровня, даже если в них рассматриваются уже чуть более продвинутые темы. Давайте сделаем шаг назад и скажем пару слов о том, что такое API.
Если вам не нуждаетесь объяснять, что это такое, смело пропускайте следующие несколько абзацев и переходите непосредственно к разделу «Подготовка нового проекта Unreal Engine».
Что такое API?
Для тех, кто не знаком с этим термином, API расшифровывается как Application Programming Interface — программный интерфейс приложения. Он представляет собой набор инструкций, которые позволяют различным системам взаимодействовать друг с другом. API определяет, как отправлять и получать данные, а также какие действия могут быть выполнены.
В нашем примере будут две взаимодействующие между собой системы: приложение, которое мы собираемся создать, используя Unreal Engine, и веб‑сервер, который предоставляет API для получения данных из базы данных The Art Institute of Chicago (ARTIC).
Наше приложение будет выступать в роли потребителя API, а веб‑сервер — его поставщика (провайдера). Провайдер API не беспокоится о том, кто является потребителем и как он будет использовать полученные данные, его задача — просто предоставить их, если он получил валидный запрос.
На самом деле, мы можем реализовать подобный проект не только в Unreal Engine, но и в других платформах, таких как Unity или даже в веб‑браузере. Вы можете сами убедиться в этом, перейдя по следующей ссылке: https://api.artic.edu/api/v1/artworks/129 884
Когда вы нажмете на эту ссылку, ваш веб‑браузер автоматически выполнит GET‑запрос по протоколу HTTP и получит ответ, содержащий данные в формате JSON (JavaScript Object Notation).
Если вы откроете полученные JSON‑данные¹, вы увидите, что они содержат информацию о картине Альмы Томас «Звездная ночь и астронавты», которое хранится в коллекции ARTIC с ID 129 884.
(1) Чтобы сделать данные в формате JSON более читабельными, вы можете установить специальное расширение для браузера, например «JSON Prettifier» или «JSON Formatter». Эти расширения преобразуют данные в более упорядоченный и удобный для восприятия вид («pretty print»).
Как это работает? Как мы можем получать и использовать данные из API, которое даже не различает, кто его вызывает: веб‑браузер или Unreal Engine приложение? Ответ частично кроется в предыдущих пунктах.
Дело в том, что API предоставляет данные по стандартному протоколу и в стандартном формате. Ваш веб‑браузер уже знает, как отправить запрос по протоколу HTTP и как обработать ответ.
К счастью, Unreal Engine также обладает всем необходимым для этого. В его арсенале есть HttpModule и JSON Serializer, которые мы будем использовать для получения и анализа данных.
Еще очень много чего можно рассказать об API и технологиях, лежащих в их основе, включая различные протоколы и форматы данных, такие как XML и JSON. Но в этой статье для нас будет важен тот факт, что и запросы, и ответы имеют заголовки, хотя я и не рассматривала их в этом кратком вступлении.
Некоторые API требуют аутентификации, что представляет собой еще одну глубокую кроличью нору. Стоит также отметить, что не все API являются веб‑API, и существует так называемый REST (Representational State Transfer) — особый архитектурный стиль и широко используемый подход к созданию API.
Подготовка нового проекта Unreal Engine
Теперь давайте приступим к созданию нашего проекта. Если вы намерены повторять шаги из этого руководства (я настоятельно рекомендую вам это сделать), начните с создания пустого проекта на C++ в Unreal Engine 5.1.1.
Возможно, он будет работать и на других версиях, но этот пример я писала на версии 5.1.1, поэтому я не могу гарантировать, что он будет работать и, например, на версии 4.27.
Я назвала свой проект FetchArt, но вы можете выбрать любое другое название.
Создание материала
Нам не нужен никакой стартовый контент, но нам понадобится материал, который мы позже будем использовать для нанесения текстуры на плоскость.
Перейдите в Content Browser и создайте новую папку, кликнув правой кнопкой мыши и выбрав пункт New Folder. Назовите папку Materials. Затем создайте новый материал, выбрав в том же контекстном меню пункт New Material. Назовите материал ExampleMaterial.
Откройте материал и перейдите в Material Graph. Находясь там кликните правой кнопкой мыши внутри графа и найдите Texture Sample. Когда появится узел Texture Sample, нажмите на него, чтобы добавить в Material Graph.
Наконец, кликните правой кнопкой мыши на узле Texture Sample и выберите Convert to Parameter. Это действие преобразует узел Texture Sample в узел‑параметр, что позволит нам в дальнейшем легко менять текстуру, используемую материалом.

При преобразовании узла Texture Sample в узел Texture Parameter обязательно назовите параметр «TextureParameter» и дважды проверьте написание. Это важно, потому что мы будем ссылаться в нашем C++ коде на этот параметр по его имени.
Выбрав узел Texture Parameter, найдите раздел Material Expression Texture Base на панели Details в левой нижней части редактора. Чтобы назначить материал по умолчанию, нажмите на маленький значок изогнутой стрелки, расположенный в правой части строки Param.

Наконец, подключите выходной контакт TextureParameter к входному контакту Base Color в ExampleMaterial. Не забудьте сохранить материал перед закрытием окна или вкладки.

Добавление пользовательского класса актора
В Content Browser перейдите в папку C++ Classes вашего проекта. Кликните правой кнопкой мыши по папке и выберите Add New C++ Class. Во всплывающем окне выберите Actor в качестве

Импорт модулей
Теперь мы немного попишем код. Во‑первых, чтобы предотвратить ошибки компоновщика, необходимо указать в {ИмяВашегоПроекта}.Build.cs модули, от которых зависит наш проект.
Этот C#‑файл является частью Unreal Build Tool. Когда проект собирается, UBT считывает этот файл и использует его для создания необходимых скриптов сборки и конфигурационных файлов.
В Unreal Engine модули обычно добавляются по мере необходимости, но поскольку я уже написала код для этого руководства, я точно знаю, какие модули нам потребуются:
HTTP
Json
JsonUtilities
ImageWrapper
Добавьте эти модули в список PublicDependencyModuleNames
, как показано ниже:
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "HTTP", "Json", "JsonUtilities", "ImageWrapper" });
Эта система позволяет Unreal Engine более эффективно контролировать, какие модули включаются в сборку, что, в свою очередь, способствует сокращению времени сборки и повышению общей производительности.
Заголовочный файл
Теперь, когда модули добавлены в проект, пришло время внести изменения в наш класс RemoteImagePlane
. Откройте заголовочный файл этого класса — RemoteImagePlane.h.
Этому актору не нужно выполнять логику в каждом тике, поэтому объявление метода Tick можно смело удалить. Не забудьте также удалить реализацию метода в исходном файле RemoteImagePlane.cpp. Кроме того, в конструкторе необходимо изменить значение флага PrimaryActorTick.bCanEverTick
с true
на false
.
Добавьте в заголовочном файле, между #include RemoteImagePlane.generated.h
и макросом UCLASS()
, следующие предварительные объявления и определения типов:
class UStaticMeshComponent;
class UTextRenderComponent;
class USceneComponent;
class IHttpRequest;
class IHttpResponse;
typedef TSharedPtr<IHttpRequest, ESPMode::ThreadSafe> FHttpRequestPtr;
typedef TSharedPtr<IHttpResponse, ESPMode::ThreadSafe> FHttpResponsePtr;
Эти предварительные объявления помогут сократить время компиляции и избежать возникновения циклических зависимостей между заголовочными файлами. Псевдонимы типов, представленные в коде, сокращают объем кода, позволяя определить альтернативное название для типа, что делает код более читабельным и легким в сопровождении.
Объявите два следующих метода делегата под спецификатором доступа protected
:
void OnResponseReceived(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful);
void OnImageDownloaded(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bWasSuccessful);
Существует соглашение, согласно которому название методов делегатов должно начинаться с префикса «On», за которым следует описательное имя события, которое делегат представляет.
Чтобы реализовать нужный нам функционал, нашему пользовательскому актору потребуется несколько компонентов:
UStaticMeshComponent
— для рендеринга плоского меша, на который мы установим динамически создаваемый экземпляр материала. Полученная иллюстрация будет отрисована на этом компоненте в виде текстуры.UTextRenderComponent
— для отображения названия картины, имени художника и другой важной информации о произведении.USceneComponent
— в качестве корневого компонента и присоединения двух других компонентов.
Мы не хотим прикреплять UTextRenderComponent
к UStaticMeshComponent
или дефолтному корневому компоненту актора, потому что это ограничит наши возможности по настройке положения текста.
Нам нужно будет менять размер плоскости (Plane) в соответствии с соотношением сторон полученной картины. Для этого мы будем использовать в качестве корневого USceneComponent, к которому и присоединим два других компонента.
Объявите эти три переменные‑члена, указатели вышеупомянутых типов, следующим образом:
UPROPERTY(EditAnywhere)
UStaticMeshComponent* PlaneComponent;
UPROPERTY(EditAnywhere)
UTextRenderComponent* TextComponent;
USceneComponent* SceneComponent;
Макрос UPROPERTY()
служит для обозначения переменных‑членов класса как свойств этого класса. Спецификатор EditAnywhere позволяет нам редактировать эти свойства в редакторе Unreal Editor, как в Blueprint, так и на панели Details самого компонента.
Также объявите две дополнительные переменные‑члена, чтобы задать ширину текстуры для нашего материала и ID картины, которую мы хотим получить:
UPROPERTY(EditAnywhere)
int TextureWidth = 512;
UPROPERTY(EditAnywhere)
int CatalogId = 129884;
Мы хотим иметь возможность редактировать в Unreal Editor значения наших акторов, в частности CatalogId, чтобы мы могли отображать разные картины на разных экземплярах в игровом пространстве.
Последнее, что есть в нашем заголовочном файле, — это небольшой вспомогательный метод. Он позволяет извлекать свойства из полученного JSON, не дублируя код.
bool TryGetStringField(const TSharedPtr<FJsonObject, ESPMode::ThreadSafe>& JsonObject, const FString& FieldName, FString& OutString) const;
Этот метод поможет нашему коду больше соответствовать принципу DRY. DRY означает «Don't Repeat Yourself», противоположностью DRY‑кода является WET‑код — «Write Everything Twice».
Файл с кодом
Перейдем к файлу с исходным кодом, RemoteImagePlane.cpp. Первым шагом будет включение всех необходимых заголовочных файлов.
#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"
Мы уже сделали предварительное объявление в заголовочном файле. В файле с исходным кодом нам необходимо обеспечить фактическую реализацию, включив все нужные заголовочные файлы с помощью директивы препроцессора #include. Эта директива по сути копирует и вставляет весь файл, который она включает. Хотя это простой подход, иногда он может привести к проблемам.
В конструкторе, где мы уже установили для PrimaryActorTick.bCanEverTick
значение false
, создайте USceneComponent
и установите его в качестве корневого компонента актора.
SceneComponent = CreateDefaultSubobject<USceneComponent>(TEXT("SceneComponent"));
SetRootComponent(SceneComponent);
Мы создадим USceneComponent
с помощью шаблонного метода CreateDefaultSubobject
, передав ему имя «SceneComponent».
Чтобы создать UStaticMeshComponent
аналогичным образом, начните с поиска PlaneMesh среди базовых фигур Unreal Engine с помощью FObjectFinder. Если поиск увенчался успехом, установите Plane в качестве статического меша компонента. В случае неудачи занесите ошибку в логи с помощью макроса UE_LOG. Наконец, прикрепите PlaneComponent
к 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);
Хорошая практика — добавлять префикс с именем класса в сообщения в логах. Это позволит вам или вашим коллегам сразу увидеть в консоли, откуда пришло сообщение.
Последнее, что нам нужно сделать в конструкторе, — это создать UTextRenderComponent
и прикрепить его к нашему SceneComponent
.
TextComponent = CreateDefaultSubobject<UTextRenderComponent>(TEXT("TextRenderComponent"));
TextComponent->SetupAttachment(SceneComponent);
Теперь, когда мы создали и настроили все компоненты нашего пользовательского актора, давайте перейдем к методу BeginPlay. После вызова Super::BeginPlay()
, который вызывает метод в родительском классе (в нашем случае в классе AActor
), нам нужно создать экземпляр FHttpModule
.
FHttpModule* HttpModule = &FHttpModule::Get();
FHttpModule
предоставляет всю необходимую логику для работы с протоколом HTTP.
Затем, используя HttpModule, создайте HttpRequest. Выберите GET в качестве метода запроса и создайте RequestURL, используя маршрут ARTIC Public API для получения данных о картинах. В качестве параметра укажите нашу переменную‑член CatalogId, а для заголовка Content‑Type — 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"));
В Unreal Engine функция для установки метода запроса называется SetVerb. Помимо GET, существуют POST, PUT, DELETE, HEAD, OPTIONS, TRACE и CONNECT. Эти методы определены протоколом HTTP и используются для указания типа действия, которое должно быть выполнено.
Наконец, привяжите метод делегат OnResponseReceived, который нам еще предстоит реализовать, к OnProcessRequestComplete
. Затем отправьте запрос, воспользовавшись методом ProcessRequest
класса HttpRequest
.
HttpRequest->OnProcessRequestComplete().BindUObject(this, &ARemoteImagePlane::OnResponseReceived);
HttpRequest->ProcessRequest();
Метод делегат OnResponseReceived
будет вызываться, когда API вернет данные. Эти данные будут переданы делегату, где мы сможем обработать их по своему усмотрению.
Теперь реализуем тело метода делегата OnResponseReceived
. Сначала проверим, является ли параметр bWasSuccessful
true
и находится ли объект Response в корректном состоянии. Если нет, выведем сообщение об ошибке в Output Console и вернемся из метода досрочно.
if (!bWasSuccessful || !Response.IsValid())
{
UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Request failed"));
return;
}
Если же ответ корректен, мы можем получить данные о его содержимом с помощью метода GetContentAsString
. Возвращенная строка будет в формате JSON, и нам нужно будет ее десериализовать в JsonObject с помощью TJsonReader и статического метода FJsonSerializer::Deserialize
следующим образом:
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;
}
Если метод Deserialize
возвращает false, значит, десериализация не удалась, и нам снова следует занести ошибку в лог и вернуться из метода.
Теперь, когда мы успешно десериализовали содержимое в JsonObject, мы можем приступить к извлечению данных из этого объекта и сохранению их в FString переменных. Однако прежде чем мы это сделаем, давайте реализуем вспомогательный метод TryGetStringField
.
Этот метод принимает JsonObject, имя поля и ссылку на FString переменную, в которую будет сохранено значение поля.
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;
}
}
Обратите внимание, что этот метод является лишь тонкой оберткой вокруг метода TryGetStringField
над JsonObject. Он ведет логи для отслеживания успешных и неудачных результатов.
После этого давайте продолжим работу в методе делегате OnResponseReceived
, на котором мы остановились.
JSON‑ответ от ARTIC API не является простой структурой, а имеет более сложную форму, которая выглядит следующим образом:
{
"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": {
...
}
}
Поэтому сначала нам нужно извлечь отдельные блоки данных и конфигурации, которые также имеют тип JsonObject (да, объекты JSON могут состоять и часто состоят из нескольких вложенных объектов JSON). Для этого воспользуйтесь методом GetObjectField
.
const TSharedPtr<FJsonObject, ESPMode::ThreadSafe>& DataObject = JsonObject->GetObjectField("data");
if (!DataObject.IsValid())
{
UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Invalid DataObject"));
return;
}
Как обычно, мы проверяем корректность и регистрируем ошибку в случае возникновения проблемы.
Теперь, когда мы выделили блок данных в отдельный JSON‑объект DataObject, мы можем объявить FString переменные для ImageId, Title, ArtistDisplay и IIIFUrl и использовать наш метод TryGetStringField
, чтобы присваивать им значения.
FString ImageId, Title, ArtistDisplay, IIIFUrl;
if (!TryGetStringField(DataObject, "image_id", ImageId) ||
!TryGetStringField(DataObject, "title", Title) ||
!TryGetStringField(DataObject, "artist_display", ArtistDisplay))
return;
Мы передаем FString значения в наш метод по ссылке, а возвращаемым значением является логическое значение, указывающее на успех или неудачу. Таким образом, мы можем присваивать значения непосредственно в условном операторе, а в случае ошибки досрочно прерывать выполнение. Нам не нужно вызывать UE_LOG, так как мы уже реализовали логирование в самом методе TryGetStringField
.
Теперь мы можем использовать полученные строки Title и ArtistDisplay для установки текста нашего TextComponent. Однако перед этим нам нужно заменить все тире на дефисы, поскольку в шрифтовом материале, который UTextRenderComponent использует по умолчанию, нет глифа для тире и многих других специальных символов. Мы можем сделать это с помощью метода Replace.
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));
Раз уж мы заговорили о строках, стоит упомянуть, что в Unreal Engine есть три типа строк:
FString — мутабельный тип, который может быть изменен во время выполнения. Используется для работы с обычными строками.
FName — иммутабельный тип, который используется для представления имен и идентификаторов в движке. Этот тип часто применяется для имен объектов и переменных.
FText — локализованный тип. Используется для текста пользовательского интерфейса, игровых сообщений и других текстов, которые необходимо отображать пользователю.
Теперь, когда мы установили TextComponent, мы можем перейти к получению URL‑адреса фактического изображения.
Это можно сделать, соединив ImageId, который мы уже извлекли из DataObject, с IIIFUrl, который нам нужно получить из ConfigData, еще одного JsonObject.
Вы уже знаете, как это можно сделать. Извлечение IIIFUrl из ConfigData мало чем отличается от извлечения данных из 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;
Ранее мы уже обсуждали принцип DRY, который подразумевает минимизацию дублирования кода. Однако в данном случае мы используем этот небольшой фрагмент логики всего лишь дважды. На мой взгляд, возможно, не стоит выделять его в отдельную функцию.
Теперь у нас есть все необходимые элементы для построения 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);
Обратите внимание, что мы также использовали переменную‑член TextureWidth.
В случае картины с CatalogId 129 884, TextureWidth 512 и полученным ImageId e966 799b-97ee-1cc6-bd2f‑a94b4b8bb8f9 ImageUrl должен иметь значение:
https://www.artic.edu/iiif/2/e966 799b-97ee-1cc6-bd2f‑a94b4b8bb8f9/full/512,/0/default.jpg
Мы можем использовать ImageUrl для второго HTTP‑запроса, на этот раз для получения данных изображения. Синтаксис этого запроса практически идентичен первому, поскольку мы по‑прежнему получаем данные по протоколу HTTP.
FHttpModule* HttpModule = &FHttpModule::Get();
TSharedRef<IHttpRequest> GetImageRequest = FHttpModule::Get().CreateRequest();
GetImageRequest->SetVerb("GET");
GetImageRequest->SetURL(ImageUrl);
GetImageRequest->OnProcessRequestComplete().BindUObject(this, &ARemoteImagePlane::OnImageDownloaded);
GetImageRequest->ProcessRequest();
Ключевое различие между этими двумя HTTP‑запросами заключается в методе делегате, привязанном к OnProcessRequestComplete. Это последний фрагмент логики, который нам нужно реализовать.
Во‑первых, в теле метода OnImageDownload проверьте, что запрос был успешным, а ответ — валидным, как мы делали это ранее.
if (!bWasSuccessful || !Response.IsValid())
{
UE_LOG(LogTemp, Error, TEXT("[ARemoteImagePlane] Failed get image"));
return;
}
Хоть и маловероятно, что ответ будет невалидным, когда bWasSuccessful
будет истинным, но я предпочитаю быть немного более осторожной в Unreal Engine, где один нулевой указатель может легко привести к сбою всего приложения!
После загрузки данных изображения оно будет сохранено в памяти. Однако нам еще предстоит проделать несколько шагов, чтобы отрендерить это изображение на нашем PlaneComponent
.
Первым шагом является загрузка созданного в редакторе материала ExampleMaterial
и создание на его основе экземпляра типа UMaterialInstanceDynamic
.
UMaterial* MaterialToInstance = LoadObject<UMaterial>(nullptr, TEXT("Material'/Game/Materials/ExampleMaterial.ExampleMaterial'"));
UMaterialInstanceDynamic* MaterialInstance = UMaterialInstanceDynamic::Create(MaterialToInstance, nullptr);
Нам необходимо создать отдельный экземпляр материала для каждой плоскости, чтобы иметь возможность отображать больше картин. Хотя использование одного экземпляра материала было бы более эффективным, наша виртуальная галерея могла бы показаться довольно скучной, если бы в ней повсюду повторялось только одна картина.
Как только экземпляр динамического материала будет готов, а двоичные данные полученного изображения сохранены в памяти, следующим шагом станет создание ImageWrapper
с использованием модуля IImageWrapperModule
для декодирования этих данных.
TArray<uint8> ImageData = Response->GetContent();
IImageWrapperModule& ImageWrapperModule = FModuleManager::LoadModuleChecked<IImageWrapperModule>(FName("ImageWrapper"));
TSharedPtr<IImageWrapper> ImageWrapper = ImageWrapperModule.CreateImageWrapper(EImageFormat::JPEG);
Мы предполагаем, что формат изображения — JPEG.
Теперь мы проверим, функционирует ли ImageWrapper
должным образом. Если все в порядке, то распакуем данные изображения из буфера ImageData
и сохраним их в другом буфере UncompressedBGRA
, который также объявлен как TArray.
Буфер UncompressedBGRA
хранит данные изображения в чистом формате BGRA, где BGRA обозначает цветовые каналы синий, зеленый, красный и альфа (канал прозрачности).
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;
}
Как и прежде, мы будем регистрировать любые возникающие ошибки и выходить из функции, если какая‑либо логика даст сбой.
Наконец, мы почти готовы. Создадим временную текстуру с шириной и высотой, полученными из ImageWrapper
, и пиксельным форматом PF_B8G8R8A8. Временная текстура будет создаваться исключительно в памяти, без необходимости сохранения на диске в виде файла, а пиксельный формат будет 32-битным, с 8 битами для каждого канала (синий, зеленый, красный и альфа).
UTexture2D* Texture = UTexture2D::CreateTransient(ImageWrapper->GetWidth(), ImageWrapper->GetHeight(), PF_B8G8R8A8);
Установите для текстуры флаг сжатия TC_Default и SRGB в значение true. Этот флаг указывает на то, что текстура должна быть гамма‑корректирована при отображении на экране. После этого вызовите метод AddToRoot
, чтобы предотвратить удаление объекта и его потомков во время сборки мусора.
Texture->CompressionSettings = TC_Default;
Texture->SRGB = true;
Texture->AddToRoot();
Теперь создайте указатель на текстурные данные, скопируйте их из буфера UncompressedBGRA
и обновите ресурс, чтобы он содержал новые значения. Это позволит отправить текстурные данные на графический процессор (GPU), где они будут использоваться при рендеринге.
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();
Первый элемент массива Mips представляет собой базовый уровень текстуры. Мы использовали методы Lock
и Unlock
, чтобы временно предотвратить доступ к данным текстуры, пока мы ее модифицируем.
Теперь у нас есть текстура, которую нужно назначить TextureParameter
нашего экземпляра материала. Как только текстура будет назначена, мы сможем установить экземпляр материала в качестве материала для нашего компонента PlaneComponent
.
MaterialInstance->SetTextureParameterValue("TextureParameter", Texture);
PlaneComponent->SetMaterial(0, MaterialInstance);
Мы поместили экземпляр динамического материала в первый слот материала нашего PlaneComponent, слот с индексом 0.
И последнее: не каждая картина имеет квадратную форму. На самом деле, лишь немногие из них обладают соотношением сторон 1:1. Большинство же имеют различные ширину и высоту, а значит, и различные соотношения сторон.
Это означает, что нам необходимо рассчитать соотношение сторон изображения и соответствующим образом настроить масштаб нашей плоскости.
К счастью, это довольно простой процесс. Мы просто делим высоту изображения на его ширину, чтобы получить соотношение сторон, а затем масштабируем размер Y плоскости на это значение.
float AspectRatio = (float)ImageWrapper->GetHeight() / (float)ImageWrapper->GetWidth();
PlaneComponent->SetWorldScale3D(FVector(1.f, AspectRatio, 1.f));
Поскольку методы GetHeight
и GetWidth
возвращают целые числа, нам необходимо привести их к плавающим значениям, иначе наши результаты будут в основном равны 0.
Это гарантирует, что картина будет отображаться без растяжения. Ширина нашей плоскости остается постоянной, а высота пропорционально масштабируется в соответствии с соотношением сторон изображения.
Blueprint класс
Теперь, когда наш код полностью написан, мы можем приступить к созданию Blueprint класса, который будет основой нашего пользовательского класса C++ в Unreal Editor.
Для этого, в Content Browser, кликните правой кнопкой мыши на нашем классе и в контекстном меню выберите пункт «Create Blueprint Class Based on RemoteImagePlane». Назовите новый класс чертежей BP_RemoteImagePlane
.

Мы могли бы задать положение и ориентацию нашей плоскости и текста по умолчанию в коде C++ или создать экземпляр, перетащив класс из обозревателя содержимого на сцену, а затем отредактировать значения на панели «Details».
Однако гораздо удобнее настраивать нашего актора визуально в редакторе Blueprint. Для этого откройте BP_RemoteImagePlane в редакторе Full Blueprint и выберите компонент Plane на вкладке Components.

Когда выбран Plane Component, вы можете настроить его положение и ориентацию с помощью гизмо (gizmos). Для переключения между гизмо для настройки относительного положения и поворота можно использовать кнопки, расположенные в правом верхнем углу вкладки Viewport, или клавиши W и E соответственно.

Также задайте положение и поворот Text Component. Вы можете выбрать его на вкладке «Components » или с помощью инструмента «Select object» (O), который находится слева от ранее упомянутых кнопок.
После того как вы расположите плоскость и текст как нужно, нажмите кнопку Compile, сохраните блюпринт и выйдите из редактора блюпринтов.
Наконец, перетащите класс BP_RemoteImagePlane
на сцену и нажмите кнопку Play (или Alt+P). Вы должны увидеть нечто похожее на это:

Разместите на сцене еще несколько экземпляров BP_RemotePlaneImage и установите для каждого из них свой CatalogId на вкладке Details при выбранном экземпляре.
Чтобы получить больше каталожных номеров, вы можете просмотреть коллекцию ARTIC. Этот номер всегда присутствует в ссылке на картину, как, например, в следующем примере:
https://www.artic.edu/artworks/28 560/the‑bedroom

Кроме того, вы можете получить каталожные номера и все другие общедоступные данные API из Data Dump.
Заключение
Если вы следовали за нами и смогли дойти до этого места, то можете поздравить себя, особенно если вы новичок. Вы узнали об API, о том, как отправлять HTTP‑запросы, как обрабатывать JSON‑ответы и извлекать данные из сложных структур JSON. Вы также освоили некоторые другие важные аспекты Unreal Engine, например, понимание различий между тремя типами строк.
Кроме того, вы научились создавать экземпляры динамических материалов и использовать временные текстуры. Вы также освоили преобразование данных изображения, таких как JPEG, в массив необработанных данных в определенном пиксельном формате. Теперь вы можете передавать эти данные в текстуру и устанавливать ее в качестве параметра текстуры динамического материала на C++, что является довольно сложной темой.
И наконец, стоит отметить, что почти вся логика реализована в нескольких функциях, и большинство из них выполняют куда больше задач, чем должна одна функция.
Было бы разумно разделить логику на более мелкие функции, давая им описательные имена. Это позволит создать самодокументированный код, который легко читать и понимать без дополнительных комментариев.
Я решила оставить реализацию туториала в том виде, в котором он есть, чтобы объяснить все линейно. Тем не менее, пример проекта был тщательно прокомментирован. Если вы решите рефакторить код, это будет отличным упражнением и способом закрепить полученные сегодня знания.
Материал подготовлен в рамках онлайн-курса "Unreal Engine Game Developer. Basic" (к апрельскому потоку пока можно успеть присоединиться).
На странице курса можно ознакомиться с программой курса, а также посмотреть записи открытых уроков. Недавно уроки прошли по таким темам: