Widget & Particle

정혜창·2025년 2월 12일
0

내일배움캠프

목록 보기
29/40

게임에 있어서 UI와 HUD는 매우 중요한 요소이다. 왜냐하면 게임은 현실이 아니기 때문에 플레이어게 정보를 보여주는 UI가 필수불가결한 요소이기 때문이다. 그러나 최근 대작 게임에는 몰입도를 올리기 위해 화면에 보이는 UI를 최소화 시키거나 아이들 상태에서는 UI를 숨김처리 했다가 필요할 때만 UI가 팝업되는 경우도 있다. 물론 MMORPG같은 멀티플레이 같은 경우는 플레이어가 필요한 정보가 많아지기 때문에 HUD가 복잡한 경우도 많다.

아이러니하게 UI는 게임의 몰입도를 떨어뜨리는 요인이 될 수도 있지만 반드시 필요하기도 하다. 그렇다면 어떻게 해야할까?
개인적으로 생각한 정답은 게임의 장르에 따라 적절한 선택이 필요하다는 것이다. 몰입이 중요한 스토리형 비디오 게임, 또는 VR게임의 경우 UI를 최소화하는 것이 게임에 몰입하는데 도움이 될 것이고 앞서 말했듯 많은 사람들이 함께 참여하는 대인 전투, MMO이나 전략게임들은 플레이어에게 보여줘야하는 정보가 타 장르보다 많기 때문에 UI가 많이 필요할 것이다.

이렇듯 게임에서 UI는 매우 중요하다. 이번에 나는 내일배움캠프에서 C++와 Unreal Engine으로 3D 게임 개발 4주차에 UI, HUD를 제작하게 해주는 Widget과 Particle에 대해서 배웠다. 강의를 들으면서 헷갈렸던 부분, 개념정리를 하고자 한다.


🎮 Widget

1️⃣ 언리얼 엔진의 UI 시스템 (HUD & UMG)

언리얼 엔진에서 UI를 만드는 방식은 크게 두 가지로 나뉜다.

📌 1. HUD (AHUD 클래스) - C++ 기반의 클래스로, 화면에 2D 요소를 직접 그리는 방식

📌 2. UMG (Unreal Motion Graphics) - 블루프린트와 C++을 사용하여 UI를 보다 직관적으로 구성하는 시스템

요즘 대부분의 프로젝트는 UMG를 사용하는 추세이며, HUD는 특정 상황(디버그 정보표시 등)에서만 사용하는 경우가 많다.

🔥 HUD (AHUD) 클래스

AHUD는 화면에 텍스트, 도형 등을 그릴 수 있는 클래스로, APlayerController가 관리한다.

✅ 왜 APlayerController에서 관리하는걸까?
언리얼에서 Controller는 플레이어의 입력을 처리하고, 게임에서 플레이어가 상호작용하는 주요 시스템을 관리하는 역할을 한다. UI나 HUD는 플레이어에게 게임 정보를 제공하는 역할을 하므로, 이를 관리하는 최적의 위치는 플레이어 입력을 담당하는 Controller가 되는 것이 자연스럽다.
만약 Controller에서 하지않고 Character에서 관리한다고 생각을 했을 때 캐릭터가 죽거나 바뀌면 UI가 사라질 수 있고, 관리가 매우 힘들어진다. 또한 여러 캐릭터를 컨트롤할 경우 UI를 공유하기가 어려워지고 UI는 개별 캐릭터가 아닌 플레이어 상태와 연관된 경우가 많기 때문에 캐릭터가 아닌 플레이어를 제어하는 Controller에서 관리하는게 더 안정적이다.
FVector2D ScreenCenter(CanVas->ClipX * 0.5f, CanVas->ClipY * 0.5f);
FLinearColor TextColor = FLinearColor::White;

DrawText(TEXT("Hello HUD!"), TextColor, ScreenCenter.X, ScreenCenter.Y);
  • DrawText(), DrawRect() 등을 사용하여 화면에 그린다.

  • HUD를 사용하려면 GameMode에서 설정을 해야한다.



🔥 UMG (Unreal Motion Graphics)

HUD와 달리 UMG는 블루프린트와 위젯 시스템을 사용하여 더 직관적으로 UI를 제작할 수 있다.

UMG UI 생성 과정

📌 1. UMG 블루프린트 생성

  • UUserWidget 을 상속받아 새로운 위젯을 생성한다.

  • 언리얼 엔진 에디터에서 Widget Bluprint를 만들고 버튼, 텍스트, 이미지 등의 UI 요소를 추가


📌 2. C++ 에서 UI 생성 및 표시

헤더파일
// HUD
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
TSubclassOf<UUserWidget> HUDWidgetClass;

UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "HUD")
UUserWidget* HUDWidgetInstance;
CPP파일
if (HUDWidgetClass)
{
	HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
	if (HUDWidgetInstance)
	{
		HUDWidgetInstance->AddToViewport();
	}
}
  • TSubclassOf<UUserWidget> HUDWidgetClass 는 "클래스 타입"을 저장하는 변수이다. 위의 1번 과정을 통해 UserWiget을 상속받는 블루프린트 위젯을 할당하면 된다. 그래서 UPROPERTY 메타데이터 유형을 EditAnyWhere로 한 것이다.

  • CreateWidget을 호출해서 해당 클래스의 인스턴스를 생성한다. 즉, HUDWidgetInstance는 생성된 실제 객체, 블루프린트 위젯 인스턴스를 가리킨다고 보면 된다. 여기서 this는 소유자를 뜻한다.

  • 이것을 통해 AddToViewport()를 활용하여 UI를 화면에 추가하는 것.

    ✅ 여기서 주의할 점은 CreateWidget 을 작동하기 위해서는 UMG 모듈이 빌드 설정에 추가되어 있어야한다는 것이다.

📌 3. HUD 위젯과 GameState 데이터 연동

이제는 게임 플레이를 누르면 내가 만든 UI가 화면에 나올 것이다.

화면에는 Score, Level, Time, HP가 보이는 것을 알 수 있는데 위의 작업만으로는 단지 화면에 글자가 출력이 된 것일 뿐, 실제로 해당 값들을 반영하고 있지는 않다. 이제는 GameState에 있는 데이터와 연동하여 화면에 실시간으로 해당 값을 반영할 수 있도록 해야한다. 방법은 두가지이다.

  1. 에디터에서 Create binding 을 통해 블루프린트 노드를 통해서 하는 방법

  2. SetText를 통해서 점수가 바뀔 때마다 UI를 갱신하는 방법

첫번 째 방법은 Binding 방식으로 코드를 많이 작성하지 않아도 구현할 수 있어서 초보자도 매우 간편하게 구현할 수 있다는 장점이 있지만 Tick 함수 처럼 값이 바뀌지 않아도 계속해서 업데이트를 하는 방식이라 퍼포먼스 적으로는 좋지 않다는 단점이 있다. 그에 반해 SetText는 데이터에 변화가 있을 때만 UI를 업데이트하므로 퍼포먼스적으로는 매우 훌륭하다고 할 수 있다.

📌 4. SetText 를 활용해 TextBlock 업데이트

JungGameState.cpp
void AJungGameState::UpdateHUD()
{
	if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
	{
		if (AJungPlayerController* JungPlayerController = Cast<AJungPlayerController>(PlayerController))
		{
			if (UUserWidget* HUDWidget = JungPlayerController->GetHUDWidget())
			{
				// TimeText
				if (UTextBlock* TimeText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Time"))))
				{
					float RemainingTime = GetWorldTimerManager().GetTimerRemaining(LevelTimerHandle);
					TimeText->SetText(FText::FromString(FString::Printf(TEXT("Time: %.1f"), RemainingTime)));
				}

				// ScoreText
				if (UTextBlock* ScoreText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Score"))))
				{
					if (UGameInstance* GameInstance = GetGameInstance())
					{
						UJungGameInstance* JungGameInstance = Cast<UJungGameInstance>(GameInstance);
						if (JungGameInstance)
						{
							ScoreText->SetText(FText::FromString(FString::Printf(TEXT("Score: %d"), JungGameInstance->TotalScore)));
						}
					}
				}

				// LevelText
				if (UTextBlock* LevelIndexText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Level"))))
				{
					LevelIndexText->SetText(FText::FromString(FString::Printf(TEXT("Level: %d"), CurrentLevelIndex + 1)));
				}
			}
		}
	}
}
✅ 왜 컨트롤러에서 HUD를 관리하는게 좋다면서 왜 GameState 가 나오는거야?
라고 생각할 수 있다. 전체적인 틀, 그러니깐 UI를 생성하고 화면에 출력하며 화면에 나오는 UI를 변경 및 교체 같은 UI에 관련된 기능은 PlayerController에서 구현하고 관리하는게 맞다.
그러나 구현하려는 HUD의 Text Block들이 Time, Score, Level 에 관련된 것이다. 이것이 GameState에서 관리하는 요소들이다. 따라서 계속해서 TextBlock을 업데이트 하기위해서는 GameState에서 구현하는 것이 코드적으로도 간편해지고 적절한 것이다.

🔥 코드분석

  • 코드를 보면 우선 PlayerController을 들고오는 것을 알 수 있다.
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
{
	if (AJungPlayerController* JungPlayerController = Cast<AJungPlayerController>(PlayerController))
    {
     . . . .
     // 월드에 있는 컨트롤러를 들고오고 내가 만든 `AJungPlayerController`로 다운캐스팅.

컨트롤러를 들고 왔기 때문에 여기에 구현한 위젯을 들고 올 수가 있다.

  • UUserWidget* HUDWidget = JungPlayerController->GetHUDWidget() 에서 GetHUDWidget()은 AJungPlayerController에서 구현한 것이고 HUDWidgetInstance를 반환한다. 따라서 HUDWidget은 우리가 만든 WBP_HUD를 가리키게 된다.


  • 그렇기 때문에 우리가 WBP_HUD 에서 만든 TextBlock을 사용할 수 있다.
    UTextBlock* TimeText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Time")))
    이 코드에서 보듯이 캐스팅작업을 거치는 것을 볼 수 있는데 이유는 GetWidgetFromNameUWidget* 타입의 포인터를 반환하기 때문이다. 조금만 생각해보면 알 수 있다. WBP_HUD에서 만든 "Time" 이라는 이름을 가진 Widget을 가져오긴 했지만 여전히 컴파일러는 이것이 Button형 위젯인지, TextBlock인지 Image인지 알 수 없다. 그렇기 때문에 이를 UTextBlock으로 사용 하려면 다운캐스팅이 필요한 것이다.

  • 이제 TimeText에 어떤 값을 넣어 줄지 코드를 작성하면 된다.
TimeText->SetText(FText::FromString(FString::Printf(TEXT("Time: %.1f"), RemainingTime)));
✅ 이것을 이해하기 위해서는 SetText가 왜 FStringFText로 변환하면서 까지 FText를 받는건지 알아야 할 필요가 있다.
우선 결과부터 말하자면 로컬라이제이션(지역화, 다국어 지원) 때문이다.
FString : 단순한 문자열 데이터를 저장하는 타입이고 다국어지원이 없다. 대신 문자열 조작이 용이하다.
FText : 로컬라이제이션 지원하는 문자열, UI에서 사용자에게 보여줄 문자열을 다룰 때 사용 그러나 조작이 불편
즉, 게임같은 경우 여러 언어를 지원하는 경우가 많고 UI는 플레이어에게 직접적으로 보여지는 문자이기 때문에 로컬라이제이션 작업이 필수 인 것이다.

UE은 내부적으로 FString이 유니코드를 사용한다. 따라서 TEXT()매크로를 써서 문자리터럴을 UTF-16, UTF-8로 변환하여 받는다. 또 우리가 여기서 굳이 FString::Printf로 포멧팅 하는 이유는 C 스타일로 RemainingTime변수를 받고 코드의 가독성을 높이기 위해 사용한 것이다. 이렇게 계산된 FString타입의 문자는 FText::FromString을 통해 FText 형으로 바뀐다.

  • 이제 TimeText는 SetText를 해줬으므로 나머지 ScoreText, LevelText도 같은 작업으로 SetText를 해주면된다.
    • ScoreText의 경우 레벨이 바뀌더라도 계속 유지가 되는 변수 TotalScore을 반영해야한다.
    • TotalScore이 GameInstance에 있으므로 GameInstance를 들고오는 작업이 필요했음.

📌 5. 실시간으로 변하는 값을 반영하기 위한 SetTimer

4번의 과정으로 WBP_HUD 에 있는 TextBlock들은 각각 내가 설정한 값들을 반영할 수 있게 되었다. 하지만 시시각각 변하는지 체크하는 작업이 필요하다. 이것을 위해 BeginPlay에서 SetTimer를 생성하여 활용한다.

✅ Tick에서 하면 굳이 SetText를 사용하는 의미가 없어진다. 매 프레임마다 SetText를 호출하면 불필요한 성능소모가 발생하기 때문이다. 최적화를 위해 블루프린트 노드들을 활용한게 아닌 SetText를 선택했는데 Tick에서 처리하는 것은 모순적인 일이다. 따라서 SetTimer를 활용해서 특정주기마다 업데이트를 하거나
void AMyClass::Tick(float DeltaTime)
{
  Super::Tick(DeltaTime);
  FString NewText = GetSomeDynamicValue(); // 계속 변하는 값
  if (CurrentText != NewText) // 값이 바뀌었을 때만 업데이트
  {
      CurrentText = NewText;
      MyTextBlock->SetText(FText::FromString(CurrentText));
  }
}
이런식으로 변화가 있을 때만 UI를 업데이트하는 방법을 사용하는 것이 좋다.
JungGameState.cpp
void AJungGameState::BeginPlay()
{
	Super::BeginPlay();
	
	StartLevel();

	GetWorldTimerManager().SetTimer(
		HUDUpdateTimerHandle,
		this,
		&AJungGameState::UpdateHUD,
		0.1f,
		true
	);
}

📌 6. PlayerController.cpp 에서 UI 초기화

통상적으로 게임을 시작하면
👉 GameMode가 먼저 생성되고
👉 GameState가 그다음 생성 (GameState::BeginPlay() 실행)
👉 PlayerController가 생성 (PlayerController::BeginPlay() 실행)
👉 이후에 Pawn(Character) 생성
이 순서대로 객체가 생성된다.

따라서 우리가 5번 과정을 통해 JungGameState의 BeginPlay에서 UpdateHUD()를 호출하고 있지만 이 시점에서는 HUD 위젯이 아직 생성되지 않았을 가능성이 있다. 이때 호출된 UpdateHUD()UI를 업데이트 하지 못한다. 즉, PlayerController에서 HUD를 생성하기 전까진 SetTimer에서 계속 UpdateHUD를 호출하더라도 UI 업데이트 불가능.

Widget의 소유자는 PlayerController이고 PlayerController에서 HUD를 생성한 후 UpdateHUD()를 다시 호출해서 UI를 초기화할 필요가 있다. 이제는 HUD가 존재하기 때문에 UI가 정상적으로 업데이트 가능.

  • 즉, Controller에서 UpdateHUD()를 다시 호출 하는 것은 HUD가 존재하는 것을 보장하고 나서 UI를 초기화, 업데이트를 하기 위함이다.

이후 HUD가 필요할때 UpdateHUD()를 호출(SetTimer로 인한 반복호출)하면 이미 HUD가 생성된 상태이기때문에 UI가 업데이트가 정상적으로 작동한다.

JungPlayerController.cpp :: BeginPlay()
	if (HUDWidgetClass)
	{
		HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
		if (HUDWidgetInstance)
		{
			HUDWidgetInstance->AddToViewport();
		}

		AJungGameState* JungGameState = GetWorld() ? GetWorld()->GetGameState<AJungGameState>() : nullptr;
		if (JungGameState)
		{
			JungGameState->UpdateHUD();
		}
	}

위의 코드에서 보듯이 HUD 생성 직후 바로 JungGameState를 불러와서 UpdateHUD()를 호출하는 것을 볼 수 있다.

결과


profile
Unreal 1기

0개의 댓글

관련 채용 정보