Как приручить Widget Component: идеальные 3D‑виджеты без мыла в UE5

Допустим, вам требуется реализовать World-Space виджеты на основе стандартного UMG в Unreal Engine 5.

Самый быстрый и простой способ — добавить Widget Component к какому‑нибудь актору.

Как приручить Widget Component: идеальные 3D‑виджеты без мыла в UE5

В поле Space выбрать World, в поле Widget Class указать ваш UserWidget класс.

Как приручить Widget Component: идеальные 3D‑виджеты без мыла в UE5

Также можно явно задать размер виджета или сделать его автоматическим, но эта статья не об этом.

Как выглядит наш виджет:

Виджет в <b>UMG UI Designer</b>
Виджет в UMG UI Designer
Виджет на сцене
Виджет на сцене

На первый взгляд всё работает корректно, однако у этого подхода есть ряд скрытых ограничений.

Ограничения метода «из коробки»

Нельзя отключить тень

Внутри Widget Component — это обычный меш, на который наложен материал с Render Target‑текстурой.

Эпики скрыли параметры освещения этого меша, поэтому галочку <b>Cast Shadow</b> не снять.
Эпики скрыли параметры освещения этого меша, поэтому галочку Cast Shadow не снять.

Решается наследованием своего Widget Component и вызовом SetCastShadow(false) в конструкторе класса.

Цветокоррекция и тонмаппер

Наш виджет существует прямо в мире: на него влияют HDR‑коррекция, Color Grading, Tonemapper и т. д. В стилизованных или малоконтрастных интерфейсах это легко «съедает» читаемость.

Подробнее о преобразовании HDR → LDR см. в документации Epic Games.

Апскейлинг

Следующий важный аспект — совместимость нашего подхода с технологиями масштабирования рендеринга, такими как DLSS, FSR и TAA.

80% рендер‑скейла
80% рендер‑скейла
40% рендер‑скейла
40% рендер‑скейла

В статике все работает неплохо.

А что насчет динамики?

Как правило, наш виджеты имеют анимации и эффекты. Не говоря о том, что игрок может быстро перемещаться относительно виджета. Как в таком случае поведет себя апскейлер?

80% рендер‑скейла
40% рендер‑скейла

Как и ожидалось, виджет начинает демонстрировать артефакты при использовании апскейлинга. Для прототипов этого может быть достаточно, для продакшена — нет.

Очевидное решение?

В самом <b>Widget Component</b> есть свойство <b>Space</b>, где мы можем указать “<b>Screen</b>”
В самом Widget Component есть свойство Space, где мы можем указать “Screen

Это должно решать ряд вышеуказанных проблем. Виджет будет рендерится как HUD, минуя все коррекции. А также, что самое главное, будет рисоваться в полном разрешении и тем самым проблемы апксейлинга и артефактов уйдут. Пробуем!

Вот так виджет ведет себя в мире при Space: World
И вот так при Space: Screen

Виджет действительно переходит в HUD‑слой: цветокоррекция и апскейлеры к нему не применяются. Но позиция вычисляется просто по центру компонента, перспективы нет, размеры — фиксированы. Итог: виджет «прилипает» к экрану и не повторяет геометрию объекта.

На этом описание проблемы закончили. Теперь перейдем к решению.

Если хочешь сделать что-то хорошо — сделай сам.

Мы сделаем собственный HUD‑виджет, который:

  • Получает Render Target текстуру;
  • Принимает четыре угловые точки в мире;
  • В NativePaint строит перспективно‑корректный меш
  • Отрисовывает его через материал (можно добавить любые пост‑эффекты).

Немного кода

WorldSpaceWidget.h

#pragma once #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" #include "WorldSpaceWidget.generated.h" struct FSubdividedQuad { TArray<FSlateVertex> Vertices; TArray<SlateIndex> Indices; }; UCLASS(Blueprintable, BlueprintType) class MYGAME_API UWorldSpaceWidget : public UUserWidget { GENERATED_BODY() public: void SetSourceTarget(UObject* NewSourceTarget, class UMaterialInterface* EffectMaterial); TArray<FVector> CornerPoints{FVector::ZeroVector, FVector::ZeroVector, FVector::ZeroVector, FVector::ZeroVector}; protected: static FVector2f NDCToScreenPosition(const FVector4& ClipSpacePos, const FVector2D& ViewportSize, const FVector2D& ViewportOffset); static FSubdividedQuad BuildPerspectiveCorrectQuad( const FVector& TL_World, const FVector& TR_World, const FVector& BR_World, const FVector& BL_World, int32 Nx, int32 Ny, const APlayerController* PlayerController, const FGeometry& AllottedGeometry); virtual int32 NativePaint( const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override; UPROPERTY() UMaterialInstanceDynamic* MID; UPROPERTY() UObject* SourceTexture = nullptr; UPROPERTY() FSlateBrush MyBrush; };

WorldSpaceWidget.cpp

#include "WorldSpaceWidget.h" void UWorldSpaceWidget::SetSourceTarget(UObject* NewSourceTarget, class UMaterialInterface* EffectMaterial) { if (SourceTexture == NewSourceTarget) { return; } SourceTexture = NewSourceTarget; MID = UMaterialInstanceDynamic::Create(EffectMaterial, this); MID->SetTextureParameterValue("Texture", Cast<UTexture>(SourceTexture)); MyBrush.SetResourceObject(MID); MyBrush.DrawAs = ESlateBrushDrawType::Image; } FVector2f UWorldSpaceWidget::NDCToScreenPosition(const FVector4& ClipSpacePos, const FVector2D& ViewportSize, const FVector2D& ViewportOffset) { if (ClipSpacePos.W <= KINDA_SMALL_NUMBER) { return FVector2f(-1000.f, -1000.f); } FVector2D NDC(ClipSpacePos.X / ClipSpacePos.W, ClipSpacePos.Y / ClipSpacePos.W); float ScreenX = (ViewportSize.X * (NDC.X + 1.f)) * 0.5f; float ScreenY = (ViewportSize.Y * (1.f - NDC.Y)) * 0.5f; return FVector2f(ScreenX + ViewportOffset.X, ScreenY + ViewportOffset.Y); } FSubdividedQuad UWorldSpaceWidget::BuildPerspectiveCorrectQuad(const FVector& TL_World, const FVector& TR_World, const FVector& BR_World, const FVector& BL_World, int32 Nx, int32 Ny, const APlayerController* PlayerController, const FGeometry& AllottedGeometry) { FSubdividedQuad Result; const int32 NumVerts = (Nx + 1) * (Ny + 1); Result.Vertices.SetNum(NumVerts); Result.Indices.SetNum(Nx * Ny * 6); if (!PlayerController) { return Result; } ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer(); if (!LocalPlayer || !LocalPlayer->ViewportClient || !LocalPlayer->ViewportClient->Viewport) { return Result; } FSceneViewProjectionData ProjectionData; if (!LocalPlayer->GetProjectionData(LocalPlayer->ViewportClient->Viewport, ProjectionData)) { return Result; } const FMatrix ViewProjectionMatrix = ProjectionData.ComputeViewProjectionMatrix(); const FVector2D ViewportSize = AllottedGeometry.GetAbsoluteSize(); const FVector2D ViewportOffset = AllottedGeometry.GetAbsolutePosition(); int32 VertexIndex = 0; for (int32 y = 0; y <= Ny; ++y) { float v = static_cast<float>(y) / Ny; FVector LeftWorld = FMath::Lerp(TL_World, BL_World, v); FVector RightWorld = FMath::Lerp(TR_World, BR_World, v); for (int32 x = 0; x <= Nx; ++x) { float u = static_cast<float>(x) / Nx; FVector WorldPos = FMath::Lerp(LeftWorld, RightWorld, u); FVector4 ClipSpacePos = ViewProjectionMatrix.TransformFVector4(FVector4(WorldPos, 1.f)); FVector2f ScreenPosition = NDCToScreenPosition(ClipSpacePos, ViewportSize, ViewportOffset); FSlateVertex& Vertex = Result.Vertices[VertexIndex++]; Vertex.Position = ScreenPosition; Vertex.TexCoords[0] = u; Vertex.TexCoords[1] = v; Vertex.TexCoords[2] = u; Vertex.TexCoords[3] = v; Vertex.Color = FColor::White; } } int32 IndexPos = 0; for (int32 y = 0; y < Ny; ++y) { for (int32 x = 0; x < Nx; ++x) { int32 V00 = y * (Nx + 1) + x; int32 V01 = V00 + 1; int32 V10 = V00 + (Nx + 1); int32 V11 = V10 + 1; Result.Indices[IndexPos++] = V00; Result.Indices[IndexPos++] = V01; Result.Indices[IndexPos++] = V11; Result.Indices[IndexPos++] = V00; Result.Indices[IndexPos++] = V11; Result.Indices[IndexPos++] = V10; } } return Result; } int32 UWorldSpaceWidget::NativePaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, const int32 LayerId, const FWidgetStyle& InWidgetStyle, const bool bParentEnabled) const { const int32 CurrentLayer = Super::NativePaint( Args, AllottedGeometry, MyCullingRect, OutDrawElements, LayerId, InWidgetStyle, bParentEnabled ); int32 MyLayer = CurrentLayer; APlayerController* PC = GetWorld()->GetFirstPlayerController(); FSubdividedQuad MeshData = BuildPerspectiveCorrectQuad(CornerPoints[0], CornerPoints[1], CornerPoints[2], CornerPoints[3], 16, 16, PC, AllottedGeometry); FSlateResourceHandle Handle = MyBrush.GetRenderingResource(); if (!Handle.IsValid()) { return MyLayer; } const FSlateShaderResourceProxy* ShaderProxy = Handle.GetResourceProxy(); if (!ShaderProxy) { return MyLayer; } MyLayer += 1; FSlateDrawElement::MakeCustomVerts( OutDrawElements, MyLayer, Handle, MeshData.Vertices, MeshData.Indices, nullptr, 0, 0, ESlateDrawEffect::None ); return MyLayer; }

Далее расширяем Widget Component:

ScreenSpaceWidgetComponent.h

#pragma once #include "CoreMinimal.h" #include "Components/WidgetComponent.h" #include "WorldSpaceWidget.h" #include "ScreenSpaceWidgetComponent.generated.h" UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent)) class MYGAME_API UScreenSpaceWidgetComponent : public UWidgetComponent { GENERATED_BODY() public: UScreenSpaceWidgetComponent(); UPROPERTY(EditAnywhere, BlueprintReadOnly) class UMaterialInterface* EffectMaterial; protected: virtual void BeginPlay() override; virtual void BeginDestroy() override; virtual void UpdateRenderTarget(FIntPoint DesiredRenderTargetSize) override; UPROPERTY() UWorldSpaceWidget* ScreenSpaceWidget; FVector2D OriginalScale = FVector2D::ZeroVector; virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; };

ScreenSpaceWidgetComponent.cpp

#include "ScreenSpaceWidgetComponent.h" #include "Engine/TextureRenderTarget2D.h" #include "Kismet/GameplayStatics.h" constexpr float SCALE_WIDGET_HACK = 0.00001f; constexpr float INVERSE_SCALE_WIDGET_HACK = 1.f / SCALE_WIDGET_HACK; UScreenSpaceWidgetComponent::UScreenSpaceWidget() { PrimaryComponentTick.bCanEverTick = true; SetCastShadow(false); } void UScreenSpaceWidgetComponent::BeginPlay() { Super::BeginPlay(); if (GetWidget() == nullptr) { return; } ScreenSpaceWidget = CreateWidget<UWorldSpaceWidget>(GetWorld(), UWorldSpaceWidget::StaticClass()); if (ScreenSpaceWidget) { ScreenSpaceWidget->AddToViewport(0); } OriginalScale = FVector2D(GetComponentScale().Y, GetComponentScale().Z); SetWorldScale3D(FVector(SCALE_WIDGET_HACK)); } void UScreenSpaceWidgetComponent::BeginDestroy() { if (ScreenSpaceWidget) { ScreenSpaceWidget->RemoveFromParent(); ScreenSpaceWidget = nullptr; } Super::BeginDestroy(); } void UScreenSpaceWidgetComponent::UpdateRenderTarget(FIntPoint DesiredRenderTargetSize) { Super::UpdateRenderTarget(DesiredRenderTargetSize); if (!RenderTarget) { return; } if (!ScreenSpaceWidget) { return; } ScreenSpaceWidget->SetSourceTarget(RenderTarget, EffectMaterial); } void UScreenSpaceWidgetComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); if (!ScreenSpaceWidget) { return; } const auto PC = GetWorld()->GetFirstPlayerController(); if (PC == nullptr) { return; } const FVector2D WidgetSize = GetDrawSize() * OriginalScale * INVERSE_SCALE_WIDGET_HACK; const FVector2D HalfSize = WidgetSize * 0.5f; FTransform ComponentTransform = GetComponentTransform(); const FVector TRLocation = ComponentTransform.TransformPosition(FVector(0.f, -HalfSize.X, HalfSize.Y)); const FVector TLLocation = ComponentTransform.TransformPosition(FVector(0.f, HalfSize.X, HalfSize.Y)); const FVector BLLocation = ComponentTransform.TransformPosition(FVector(0.f, HalfSize.X, -HalfSize.Y)); const FVector BRLocation = ComponentTransform.TransformPosition(FVector(0.f, -HalfSize.X, -HalfSize.Y)); FVector2D TLScreenPosition; for (const auto& Location : {TLLocation, TRLocation, BRLocation, BLLocation}) { if (!UGameplayStatics::ProjectWorldToScreen(PC, Location, TLScreenPosition, true)) { return; } } ScreenSpaceWidget->CornerPoints = {TLLocation, TRLocation, BRLocation, BLLocation}; }

И не забываем добавить модули в *.Build.cs

PublicDependencyModuleNames.AddRange( new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", "Slate", "SlateCore" } );

Использование

Добавляем наш компонент вместо обычного Widget компонента

Как приручить Widget Component: идеальные 3D‑виджеты без мыла в UE5
Необходимо указать в свойствах <b>Space</b>: <b>World</b>, а также выбрать <b>Effect Material</b>
Необходимо указать в свойствах Space: World, а также выбрать Effect Material

Материал для примера выглядит так:

Не забудьте указать имя параметра: <b>Texture</b>.
Не забудьте указать имя параметра: Texture.

Результат

20% рендер‑скейла. Никаких цветовых искажений, артефактов и т. п.
Пример в живом проекте (The Abyss Break)

Спасибо, что дочитали! Если материал оказался полезным — дайте знать, буду писать еще.

12
1
9 комментариев