Common UI, UMG ViewModel 플러그인을 근 2달 가량 RnD 한 후기를 두서없이 간략하게 남겨봅니다.

 

Common UI

  • 멀티 플랫폼, 멀티 컨트롤러에 대해 크게 강조를 하였지만 실상은 그다지 효과적이지는 않음.
  • 물론 PC-콘솔 멀티 플랫폼에서는 할만 함.
  • 하지만 모바일이 들어가면 답이 없어 짐.
  • Common UI가 소개하는 기능 대부분이 InputAction을 UI에 Bind하는 것.
  • 아직 UI Event에 InputAction Bind는 제대로 동작 안함.
  • 게다가 UI의 Focus를 강력하게 체크하는데 모바일은 특성 상 UI의 터치, 드래그 등의 조작이 빈번함.
  • 이것저것 편의 기능을 만들었지만 아직 실무에서 활용하기에는 부족함이 큼.

UMG ViewModel

  • ViewModel의 근간이 되는 FieldNotify는 UE5 순정 기능.
  • MVVMViewModelBase에 선언 된 매크로와 SetProperty 템플릿 함수만 따로 분리해서 사용하는 것도 충분히 좋음.
  • ViewModel Creation 방식이 제한적임.
  • 가장 무난하게 사용 가능한게 Global Context, 그 다음이 Manual.
  • Resolve, Property Path는 구현이 덜 되었거나 제대로 분석이 안되어 사용 불가.
  • Creation은 사용 가능하나 현실적인 이유로 사용 불가.
  • 현실적인 이유: ViewModel을 BP로 안 만든다.
  • UMG ViewModel의 Bind가 BP에서 이루어 지지만, Model과 View의 중간자인 만큼 생성과 데이터 입력은 코드에서 일어날 가능성이 높음.
  • 그런데 Native Class에서 선언한 ViewModel 변수를 Bind 받지 못함.
  • BP 함수로 Set을 명시적으로 해줘야 함.
  • 이 지점에서 일단 사용성이 기대한 것만큼 안 나옴.
  • 여기에 ViewModel이 모두 UObject이므로 LifeCycle을 조절해줘야 함.
  • 이 부분이 ViewModel을 순정으로 사용 못하고 상당한 별도의 아키텍쳐 설계를 요구함.
  • 즉, 어느정도 Utility가 있어야 사용 가능함.

사실 2가지를 모두 섞어서 쓰다가 Common UI는 아직 시기상조이지만 UMG ViewModel은 편의기능만 만들면 쓰기 좋겠다 싶었습니다.

특히 개발자 입장에서는 View에 해당하는 Native C++ 작업을 ViewModel 작업으로 대체.

ViewModel이 제공하는 정보의 종류만 공유가 되면 아티스트와 완전한 병행 작업이 가능하기 때문에 꼭 쓰고 싶었습니다.

하지만 한국에서는 아직 대다수의 구성원들이 업무를 좁게 해석하더군요.

디자이너들도 BP 작업을 완강히 거절하였고, 아티스트도 시각디자인이 아닌 작업을 할 이유를 못 찾겠다 하네요....

 

혹 이 Plugin에 관심을 가지시는 분들이 계시다면 지나가면서 살짝 보시고 RnD 해보시기 바랍니다.

'UE5 > UI' 카테고리의 다른 글

[UI] Common UI FAQ  (0) 2024.07.10
[UI] CommonUI Technical Guide  (0) 2024.07.10
[UI] Common UI Widget  (0) 2024.07.10
[UI] Common UI Introduction  (0) 2024.07.10
[UI] UMG ViewModel  (0) 2024.07.08

Common UI의 사용 여부 판단

  • 주로 다음 상황에서 Common UI 사용이 권장된다.
    • 복잡한 Multiple Layer UI를 제공해야 하는 경우
    • Cross-Platform을 지원하는 경우
  • 반대로 이 두 케이스에 모두 해당되지 않으면 Common UI를 사용하지 않아도 무방하다.
    • RTS 게임
  • 이와 별개로, WidgetComponent를 사용하여 배치된 Widget은 가급적 Common UI 사용을 지양하는 것이 좋다.
    • 여기서 WidgetComponent라 함은 3D 월드 상에 표기되는 Widget들을 지칭한다.
      • 캐릭터 닉네임, 체력바, 상태 등등
    • Common UI는 Cursor-Focus Search, Activate Order, Paint Order(LayerID)에 의존한다.
    • 때문에 2D 게임 HUD도 처리 할 수는 있지만, Game World에 배치 된 Widget과 함께 작동하지는 않는다.

Keyboard와 Mouse 입력을 동시에 처리하는 방법

  • 일반적으로 Common UI는 Mouse와 Keyboard의 입력을 동시에 지원하지는 않는다.
    • 이를 동시에 지원하게 되면 UI 입장에서는 Mouse가 2개인 것처럼 느껴진다.

붕괴 스타레일은 PC에서 마우스 커서를 띄우기 위해서는 ALT 키를 누르고 있어야만 한다.

  • 가장 간단한 방법은 입력을 Toggle로 관리하는 것이다.
    • 보편적인 상황에서는 Keyboard Input으로 메뉴를 띄운다.
    • 특정 키를 입력하고 있으면 Mouse Cursor가 노출된다.
      • 이 때, 모든 키보드 입력은 막히게 된다.
    • Mouse Cursor가 노출되고 있는 동안에는 마우스 커서만으로 입력을 받는다.

UI 성능에 영향을 미치는 Tick이나 일시정지를 우회하는 방법

  • Common UI는 LocalPlayerSubsystem으로 동작하여 게임이 일시정지 하면 Tick이 되지 않는다.
    • Tick이 멈추면 CommonBoundActionBar를 포함한 Common UI가 동작하지 않는다.
  • 이를 우회하려면 관련 Actor와 Widget에 tickable when paused 옵션을 활성화 해야 한다.
    • UI의 의도된 기능이나 퍼포먼스와 상충되지 않는지 확인 후 작업하자.

KeyHandler Method에서 Analog Input을 얻게 되는 이유

  • 현재 InputKey/InputAxis는 PlayerController 단위에서 작업이 통합되어 있다.
    • 이는 가짜 Input 추가를 쉽게 하고, Input Parameter를 하나로 묶어 쉽게 업데이트 하기 위함이다.
  • UE5 이전의 코드가 있으면 Analog Input이 Key Handler Callback을 트리거하거나, 반대로 트리거 될 수 있다.
    • Common UI는 이러한 기능으로부터 영향을 받지 않도록 하는 작업이 필요하다.
  • 하지만 Input Pipeline 초기에 디버깅 중이라면, 이러한 교차 Trigger는 쉽게 발견할 수 있다.
    • 예를 들어 FCommonAnalogCursor는 Input Processor이므로 Input Pipeline 초기 부분과 상호작용한다.
    • 이 때문에 FCommonAnalogCursor::HandleKeyDownEvent에서 Corss Trigger 현상이 발생할 수 있다.

Input Mode가 Menu일 때 Game Input을 받을 수 있는 이유

bool UCommonUIActionRouterBase::CanProcessNormalGameInput() const
{
	if (GetActiveInputMode() == ECommonInputMode::Menu)
	{
		// We still process normal game input in menu mode if the game viewport has mouse capture
		// This allows manipulation of preview items and characters in the world while in menus. 
		// If this is not desired, disable viewport mouse capture in your desired input config.
		const ULocalPlayer& LocalPlayer = *GetLocalPlayerChecked();
		if (TSharedPtr<FSlateUser> SlateUser = FSlateApplication::Get().GetUser(GetLocalPlayerIndex()))
		{
			return LocalPlayer.ViewportClient && SlateUser->DoesWidgetHaveCursorCapture(LocalPlayer.ViewportClient->GetGameViewportWidget());
		}
	}
	return true;
}
  • 이 함수로 인해 Game Viewport에 Mouse Capture가 있으면 Menu Mode에서의 Game Input이 가능하다.
    • 이 때문에 Menu에 있을 때 World 내 Preview Item과 Character를 조작할 수 있다.
  • 만약 Game Input을 받고 싶지 않다면 원하는 InputMode에서 Mouse Capture를 비활성화 하면 된다.

'UE5 > UI' 카테고리의 다른 글

[회고] Common UI, UMG ViewModel 사용 후기  (0) 2024.09.28
[UI] CommonUI Technical Guide  (0) 2024.07.10
[UI] Common UI Widget  (0) 2024.07.10
[UI] Common UI Introduction  (0) 2024.07.10
[UI] UMG ViewModel  (0) 2024.07.08

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/commonui-input-technical-guide-for-unreal-engine

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/input-fundamentals-for-commonui-in-unreal-engine

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/using-commonui-with-enhnaced-input-in-unreal-engine


  • CommonUI의 Input System은 Cross-Platform을 대응할 뿐 아니라 복잡한 다중 Layer Menu 관리에도 용이하다.
  • 아래 내용은 Common UI Input System의 작동 방식에 대한 내용이다.

Gamepad Navigation using Synthetic Cursor

  • Common UI에서 Gamepad Input은 보이지 않는 Synthetic Cursor를 기반으로 동작한다.
    • 때문에 Mouse를 사용하도록 UI를 설정할 경우, Common UI가 동작함에 있어 필요한 것은 2가지 뿐이다.
      • 보이지 않은 Cursor가 올바른 위치에 있을 것
      • Mouse와 같은 Click 입력을 받을 것.
    • 이 구조는 Cross-Platform에서의 모든 Input을 하나의 Input Path로 간소화 한다.

Click with Synthetic Cursor/Gamepad

  • Gamepad의 Accept 혹은 Default Click을 한다고 가정하자.

  • Virtual Accept Key는 대체로 EKeys::Virtual_Accept로 Mapping 된다.
  • 내부적으로 Input Flow는 GenericApplication으로부터 파생된 Platform Application에서 시작된다.
    • 예를 들어 Windows의 경우 FWindowsApplication을 사용한다.
bool FSlateApplication::OnKeyDown( const int32 KeyCode, const uint32 CharacterCode, const bool IsRepeat ) 
{
	FKey const Key = FInputKeyManager::Get().GetKeyFromCodes( KeyCode, CharacterCode );
	FKeyEvent KeyEvent(Key, PlatformApplication->GetModifierKeys(), GetUserIndexForKeyboard(), IsRepeat, CharacterCode, KeyCode);

	return ProcessKeyDownEvent( KeyEvent );
}
  • 이 Input은 FSlateApplication::ProcessKeyDownEvent로 처리가 된다.
    • ProcessKeyDownEvent는 Input 처리를 위해 IInputProcessor Interface를 Implement하고,
      가능하면 Input을 처리하는 Input Processor를 사용한다.
    • Input이 처리되면, 추가 Input 처리가 막히게 됩니다.
더보기
bool FSlateApplication::ProcessKeyDownEvent( const FKeyEvent& InKeyEvent )
{
/*...*/
	// Analog cursor gets first chance at the input
	if (InputPreProcessors.HandleKeyDownEvent(*this, InKeyEvent))
	{
		return true;
	}
/*...*/
}
  • FCommonAnalogCursor는 FAnalogCursor와 마찬가지로 IInputProcessor의 구현체이다.
  • FcommonAnalogCursor는 현재 Widget의 Bound Action으로 캡처되지 않은
    Gamepad의 표준 Accept Action Input을 처리하지 않는다.
bool FCommonAnalogCursor::HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent)
{
	if (IsRelevantInput(InKeyEvent))
	{
/*....*/
		if (bIsVirtualAccept && ActionRouter.ProcessInput(InKeyEvent.GetKey(), InputEventType) == ERouteUIInputResult::Handled)
		{
			return true;
		}
		else if (!bIsVirtualAccept || ShouldVirtualAcceptSimulateMouseButton(InKeyEvent, IE_Pressed))
		{
			// There is no awareness on a mouse event of whether it's real or not, so mark that here.
			UCommonInputSubsystem& InputSubsytem = ActionRouter.GetInputSubsystem();
			InputSubsytem.SetIsGamepadSimulatedClick(bIsVirtualAccept);
			bool bReturnValue = FAnalogCursor::HandleKeyDownEvent(SlateApp, InKeyEvent);
/*....*/
		}
	}
	return false;
}
  • 대신 Input을 HandleKeyDownEvent로 전달한다.
bool FAnalogCursor::HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent)
{
	if (IsRelevantInput(InKeyEvent))
	{
		FKey Key = InKeyEvent.GetKey();
/*...*/

		// Bottom face button is a click
		if (Key == EKeys::Virtual_Accept)
		{
			if (!InKeyEvent.IsRepeat())
			{
				if (TSharedPtr<FSlateUser> SlateUser = SlateApp.GetUser(InKeyEvent))
				{
					const bool bIsPrimaryUser = FSlateApplication::CursorUserIndex == SlateUser->GetUserIndex();
					FPointerEvent MouseEvent(
						SlateUser->GetUserIndex(),
						FSlateApplication::CursorPointerIndex,
						SlateUser->GetCursorPosition(),
						SlateUser->GetPreviousCursorPosition(),
						bIsPrimaryUser ? SlateApp.GetPressedMouseButtons() : TSet<FKey>(),
						EKeys::LeftMouseButton,
						0,
						bIsPrimaryUser ? SlateApp.GetModifierKeys() : FModifierKeysState()
					);

					TSharedPtr<FGenericWindow> GenWindow;
					return SlateApp.ProcessMouseButtonDownEvent(GenWindow, MouseEvent);
				}
			}

			return true;
		}
	}

	return false;
}
  • 이후, FSlateApplication에서 처리할 Synthetic Mouse Click Event를 생성한다.
  • 여기까지 진행되면 Mouse Event는 Regular Click과 유사한 Input Process를 거치고, 최종 Click을 Trigger한다.
    • 좀 더 자세한 흐름을 보고 싶다면 SButton::OnMouseButtonDown에 중단점을 찍고 디버깅을 해보길 추천한다.

TroubleShooting

  • Common UI의 Synthetic Cursor Click이 예상과 다른 경우, FPointerEvent의 문제일 가능성이 높다.
    • FPointEvent에서 올바른 User Input을 처리하지 않은 경우
    • Synthetic Cursor가 예상 위치와 멀리 떨어져 있는 경우
    • Click이 FSlateApplication::ProcessMouseButtonDownEvent에서 처리될 때
      Capture가 있는 다른 Widget이 FWidgetPath에 영향을 미친 경우
      • 이는 Input Config의 MouseCaptureMode를 기반으로 발생할 수 있다.
    • FWidgetPath가 FSlateApplication::LocateWindowUnerMouse를 사용하는 위치를 기반으로 자연 생성
      • FWidgetPath에는 Input이 Route되는 Widget List가 포함되어 있다.

How Synthetic Cursor/Gamdpad Navigate and Focus

  • 단순 Navigation 측면에서는 Common UI와 UMG의 기본 구현 과정은 크게 다를게 없다.
  • 늘 그렇든 Input의 시작은 특정 Platform Application에서 시작한다.
  • 아래 예시는 화살표나 Analog 이동의 시나리오를 예시로 삼는다.
  • Input Louter System은 이 UI Navigation Input을 UI 내 Widget으로 Route한다.

  • Navigation Input은 보통 SWidget::OnKeydown이나 SWidget::OnAnalogValueChanged에 의해 처리된다.
  • 하지만 이러한 기본 Method는 Widget Focus를 직접 변경하지 않고 아래와 같이 동작한다.
FReply SWidget::OnKeyDown( const FGeometry& MyGeometry, const FKeyEvent& InKeyEvent )
{
	if (bCanSupportFocus && SupportsKeyboardFocus())
	{
		EUINavigation Direction = FSlateApplicationBase::Get().GetNavigationDirectionFromKey(InKeyEvent);
		// It's the left stick return a navigation request of the correct direction
		if (Direction != EUINavigation::Invalid)
		{
			const ENavigationGenesis Genesis = InKeyEvent.GetKey().IsGamepadKey() ? ENavigationGenesis::Controller : ENavigationGenesis::Keyboard;
			return FReply::Handled().SetNavigation(Direction, Genesis);
		}
	}
	return FReply::Unhandled();
}

FReply SWidget::OnAnalogValueChanged( const FGeometry& MyGeometry, const FAnalogInputEvent& InAnalogInputEvent )
{
	if (bCanSupportFocus && SupportsKeyboardFocus())
	{
		EUINavigation Direction = FSlateApplicationBase::Get().GetNavigationDirectionFromAnalog(InAnalogInputEvent);
		// It's the left stick return a navigation request of the correct direction
		if (Direction != EUINavigation::Invalid)
		{
			return FReply::Handled().SetNavigation(Direction, ENavigationGenesis::Controller);
		}
	}
	return FReply::Unhandled();
}
  • FSlateApplication::GetNavigationDirectionFromKey나 FSlateApplication::GetNavigationDirectionFromAnalog를
    이용해 Input을 Navigation Direction으로 변환
EUINavigation FSlateApplication::GetNavigationDirectionFromKey(const FKeyEvent& InKeyEvent) const
{
	TSharedRef<FNavigationConfig> RelevantNavConfig = GetRelevantNavConfig(InKeyEvent.GetUserIndex());
	return RelevantNavConfig->GetNavigationDirectionFromKey(InKeyEvent);
}

EUINavigation FSlateApplication::GetNavigationDirectionFromAnalog(const FAnalogInputEvent& InAnalogEvent)
{
	TSharedRef<FNavigationConfig> RelevantNavConfig = GetRelevantNavConfig(InAnalogEvent.GetUserIndex());
	return RelevantNavConfig->GetNavigationDirectionFromAnalog(InAnalogEvent);
}

 

  • 이 과정에서 Widget에 대한 Navigation Config을 고려한다.
/** An event should return FReply::Handled().SetNavigation( NavigationType ) as a means of asking the system to attempt a navigation*/
FReply& SetNavigation(EUINavigation InNavigationType, const ENavigationGenesis InNavigationGenesis, const ENavigationSource InNavigationSource = ENavigationSource::FocusedWidget)
{
	this->NavigationType = InNavigationType;
	this->NavigationGenesis = InNavigationGenesis;
	this->NavigationSource = InNavigationSource;
	this->NavigationDestination = nullptr;
	return Me();
}
  • Navigation Direction은 Capture되어 FReply::Handled에 포함된다.
    • FReply::Handled는 FReply::SetNavigation을 통해 전송된다.
void FSlateApplication::ProcessReply( const FWidgetPath& CurrentEventPath, const FReply& TheReply, const FWidgetPath* WidgetsUnderMouse, const FPointerEvent* InMouseEvent, const uint32 UserIndex )
{
/*...*/
	
	// If we have a valid Navigation request attempt the navigation.
	if (TheReply.GetNavigationDestination().IsValid() || TheReply.GetNavigationType() != EUINavigation::Invalid)
	{
		FWidgetPath NavigationSource;
/*...*/
		if (NavigationSource.IsValid())
		{
			if (!GSlateEnableGamepadEditorNavigation && TheReply.GetNavigationGenesis() == ENavigationGenesis::Controller && !NavigationSource.GetLastWidget()->GetPersistentState().bIsInGameLayer)
			{
				// Gamepad navigation while not in a game layer, do nothing as specified by GSlateEnableGamepadEditorNavigation
			}
			else if (TheReply.GetNavigationDestination().IsValid())
			{
				const bool bAlwaysHandleNavigationAttempt = false;
				ExecuteNavigation(NavigationSource, TheReply.GetNavigationDestination(), UserIndex, bAlwaysHandleNavigationAttempt);
			}
			else
			{
                                TSharedRef<SWindow> NavigationWindow = NavigationSource.GetDeepestWindow();
                                FNavigationEvent NavigationEvent(PlatformApplication->GetModifierKeys(), UserIndex, TheReply.GetNavigationType(), TheReply.GetNavigationGenesis());
                                FNavigationReply NavigationReply = FNavigationReply::Escape();
                                for (int32 WidgetIndex = NavigationSource.Widgets.Num() - 1; WidgetIndex >= 0; --WidgetIndex)
                                {
                                        FArrangedWidget& SomeWidgetGettingEvent = NavigationSource.Widgets[WidgetIndex];
                                        if (SomeWidgetGettingEvent.Widget->IsEnabled())
                                        {
                                                NavigationReply = SomeWidgetGettingEvent.Widget->OnNavigation(SomeWidgetGettingEvent.Geometry, NavigationEvent).SetHandler(SomeWidgetGettingEvent.Widget);
                                                if (NavigationReply.GetBoundaryRule() != EUINavigationRule::Escape || SomeWidgetGettingEvent.Widget == NavigationWindow || WidgetIndex == 0)
                                                {
                                                        AttemptNavigation(NavigationSource, NavigationEvent, NavigationReply, SomeWidgetGettingEvent);
                                                        break;
                                                }
                                        }
                                }
			}
		}
	}

/*...*/

	// Set focus if requested.
	TSharedPtr<SWidget> RequestedFocusRecepient = TheReply.GetUserFocusRecepient();
	if (TheReply.ShouldSetUserFocus() && RequestedFocusRecepient.IsValid())
	{
		if (TheReply.AffectsAllUsers())
		{
			SetAllUserFocus(RequestedFocusRecepient, TheReply.GetFocusCause());
		}
		else
		{
			SlateUser->SetFocus(RequestedFocusRecepient.ToSharedRef(), TheReply.GetFocusCause());
		}
	}
}
  • Slate가 FSlateApplication::ProcessREply를 사용하여 Reply를 처리한다.
    • 이 과정에서 Navigation이 발생한다.
    • Navigation Event가 Direction에 의해 대략 정의 된 경우, FSlateApplication::AttemptNavigation은 Navigation 할 Widget을 찾으려 한다.
    • Navigation이 가능한 경우, FSlateExecuteNavigation이 대상 Widget으로 Navigation한다.
  • 대상 Widget이 유효한 경우, FSlateApplication::SetUserFocus가 호출된다.
    • 이는 대상 Widget이 지정 되었는지, 다른 Widget을 찾았는지와 무관하게 동작한다.
void FSlateApplication::FinishedInputThisFrame()
{
	const float DeltaTime = GetDeltaTime();

	PlatformApplication->FinishedInputThisFrame();
	
	// Any preprocessors are given a chance to process accumulated values (or do whatever other tick things they want)
	// after we've finished processing all of the input for the frame
	if (PlatformApplication->Cursor.IsValid())
	{
		InputPreProcessors.Tick(DeltaTime, *this, PlatformApplication->Cursor.ToSharedRef());
	}

/*....*/
}

void FSlateApplication::InputPreProcessorsHelper::Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor)
{
	TGuardValue<bool> IteratingGuard(bIsIteratingPreProcessors, true);

	for (const TSharedPtr<IInputProcessor>& Preprocessor : InputPreProcessorList)
	{
		if (Preprocessor)
		{
			Preprocessor->Tick(DeltaTime, SlateApp, Cursor);
		}
	}
}
  • Slate Focus Navigation이 종료된 후,
    FSlateAnalogCursor::Tick에서 자동으로 다음 Tick 도중 Synthetic Cursor를 Focused Widget의 중앙으로 이동시킨다.
    • 이를 통해 Gamepad에서 Hover Effect를 사용할 수 있다.

Customize Synthetic Cursor Behavior in Common UI

  • Common UI를 이용하면 고유한 Analogue Cursor나 Synthetic Cursor를 제공할 수 있다.
  • Common UI를 통해 고유 Cursor를 제공하는 것에는 몇 가지 이점이 있다.
    • Gamepad와 유사하게 작동하는 Keyboard Navigation을 만들려 하는 경우,
      Gamepad를 사용하지 않을 때에도 FCommonAnalogCursor::Tick이 Widget의 중앙에 Snap되도록 할 수 있다.
    • Synthetic Mouse를 보이도록 하고, Focus가 달라질 때 Tween을 구현할 수 있다.
      • Tween: 그래픽 상으로 두 상태나 프레임 사이를 자동으로 생성하는 Process
  • Custom cursor를 만드는 방법은 다음과 같다.
    • UCommonUIActionRouterBase를 상속받은 Custom ActionRouter Class 생성
    • FCommonAnalogCursor를 상속받은 Custom AnalogCursor Class 생성
    • Custom ActionRouter Class에서 MakeAnalogCursor를 Override하여 Custom AnalogCursor Class를 반환
  • Custom Router Class에서 ShouldCreateSubsystem은 Override 된 경우 Instance를 생성하지 않는다.
  • 이러한 Input Processor를 Customize 할 때에는 한가지 주의할 점이 있다.
    • Input Processor는 모든 Input에서 실행 된다.
      • 이는 Editor도 포함한다.
    • 아래 함수를 이용하면 Application에서 발생한 Input과 그렇지 않은 것을 구분하는데 도움이 될 수 있다.
bool FCommonAnalogCursor::IsGameViewportInFocusPathWithoutCapture() const
{
	if (const UGameViewportClient* ViewportClient = GetViewportClient())
	{
		if (TSharedPtr<SViewport> GameViewportWidget = ViewportClient->GetGameViewportWidget())
		{
			TSharedPtr<FSlateUser> SlateUser = FSlateApplication::Get().GetUser(GetOwnerUserIndex());
			if (SlateUser && !SlateUser->DoesWidgetHaveCursorCapture(GameViewportWidget))
			{
#if PLATFORM_DESKTOP
				// Not captured - is it in the focus path?
				return SlateUser->IsWidgetInFocusPath(GameViewportWidget);
#endif
				// If we're not on desktop, focus on the viewport is irrelevant, as there aren't other windows around to care about
				return true;
			}
		}
	}
	return false;
}

Input Routing

  • 간략하게 정리하면 다음과 같다.
    • Common UI는 ActivableWidget을 Navigation을 처리하는 Node Tree로 구성한다.
      • Inactive Widget은 추가되지 않고, 그에 따라 Router 대상으로 고려되지 않는다.
    • CommonGameviewportClient가 Input을 Capture하고, Hierarchy의 최상단에 표시되는 Node를 탐색한다.
    • 탐색된 Node에서 사용 가능 한 Input Handler를 이용해 Input을 처리할 수 있는지 확인한다.
      • Player Input과 일치한 Handler가 없는 경우 자손 Node로 전달한다.
    • 적절한 Node를 찾을 때까지 탐색을 반복한다.
  • 이 Process는 모든 Node를 탐색할 때까지 반복한다.
bool FActivatableTreeNode::ProcessNormalInput(ECommonInputMode ActiveInputMode, FKey Key, EInputEvent InputEvent) const
{
	if (IsReceivingInput())
	{
		for (const FActivatableTreeNodeRef& ChildNode : Children)
		{
			if (ChildNode->ProcessNormalInput(ActiveInputMode, Key, InputEvent))
			{
				return true;
			}
		}
		return FActionRouterBindingCollection::ProcessNormalInput(ActiveInputMode, Key, InputEvent);
	}
	return false;
}
  • 탐색 방식은 BFS에 가깝다.

Execution Flow

  • Synthetic Cursor와 마찬가지로 Input Processor는 Input Routing 발생 전에 Event를 처리할 수 있다.
    • Input Processor에서 처리하지 않는 경우, Input Event는 Common UI의 Input Routing을 진행한다.

Click Event Process

  • 특정 Platform Application이 FSlateApplication::ProcessKeyDownEvent를 Trigger 할 때 Input이 시작된다.
bool FSlateApplication::ProcessKeyDownEvent( const FKeyEvent& InKeyEvent )
{
/*...*/
	// Send out key down events.
    if ( !Reply.IsEventHandled() )
    {
        Reply = FEventRouter::RouteAlongFocusPath(this, FEventRouter::FBubblePolicy(EventPath), InKeyEvent, [] (const FArrangedWidget& SomeWidgetGettingEvent, const FKeyEvent& Event)
        {
            if (SomeWidgetGettingEvent.Widget->IsEnabled())
            {
                const FReply TempReply = SomeWidgetGettingEvent.Widget->OnKeyDown(SomeWidgetGettingEvent.Geometry, Event);
                return TempReply;
            }
            return FReply::Unhandled();
        }, ESlateDebuggingInputEvent::KeyDown);
    }
/*...*/
	return Reply.IsEventHandled();
}
  • Slate Application은 현재 Focus 경로를 기반으로 Widget에 Input Event를 전달한다.
FReply SViewport::OnKeyDown( const FGeometry& MyGeometry, const FKeyEvent& KeyEvent )
{
	return ViewportInterface.IsValid() ? ViewportInterface.Pin()->OnKeyDown(MyGeometry, KeyEvent) : FReply::Unhandled();
}
  • Game 내에서는 보통 SViewport::OnKeyDown Input Event이다.
    • 이 Input Event는 현재 Viewport Interface Implemented Class로 키 누름을 전달한다.
FReply FSceneViewport::OnKeyDown( const FGeometry& InGeometry, const FKeyEvent& InKeyEvent )
{
	// Start a new reply state
	CurrentReplyState = FReply::Handled(); 

	FKey Key = InKeyEvent.GetKey();
	if (Key.IsValid())
	{
		KeyStateMap.Add(Key, true);

		//@todo Slate Viewports: FWindowsViewport checks for Alt+Enter or F11 and toggles fullscreen.  Unknown if fullscreen via this method will be needed for slate viewports. 
		if (ViewportClient && GetSizeXY() != FIntPoint::ZeroValue)
		{
			// Switch to the viewport clients world before processing input
			FScopedConditionalWorldSwitcher WorldSwitcher(ViewportClient);

			if (!ViewportClient->InputKey(FInputKeyEventArgs(this, InKeyEvent.GetInputDeviceId(), Key, InKeyEvent.IsRepeat() ? IE_Repeat : IE_Pressed, 1.0f, false)))
			{
				CurrentReplyState = FReply::Unhandled();
			}
		}
	}
	else
	{
		CurrentReplyState = FReply::Unhandled();
	}
	return CurrentReplyState;
}
  • 보통은 키 누름이 전달되면 FSceneViewPort::OnKeyDown이 트리거 된다.
  • 마지막으로 SceneViewPort는 Input을 현재 GameViewportClient의 InputKey Method로 전달한다.
bool UCommonGameViewportClient::InputKey(const FInputKeyEventArgs& InEventArgs)
{
	FInputKeyEventArgs EventArgs = InEventArgs;

	if (IsKeyPriorityAboveUI(EventArgs))
	{
		return true;
	}

	// Check override before UI
	if (OnOverrideInputKey().IsBound())
	{
		if (OnOverrideInputKey().Execute(EventArgs))
		{
			return true;
		}
	}

	// The input is fair game for handling - the UI gets first dibs
#if !UE_BUILD_SHIPPING
	if (ViewportConsole && !ViewportConsole->ConsoleState.IsEqual(NAME_Typing) && !ViewportConsole->ConsoleState.IsEqual(NAME_Open))
#endif
	{		
		FReply Result = FReply::Unhandled();
		if (!OnRerouteInput().ExecuteIfBound(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result))
		{
			HandleRerouteInput(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result);
		}

		if (Result.IsEventHandled())
		{
			return true;
		}
	}

	return Super::InputKey(EventArgs);
}
  • Common UI를 사용할 때에는 이것이 UCommonGameViewportClient::InputKey이다.
    • 때문에 Common UI를 사용하려면 GameViewportClass가 CommonViewportClient로 설정되어야 한다.

Action Router Process

bool UCommonGameViewportClient::InputKey(const FInputKeyEventArgs& InEventArgs)
{
	FInputKeyEventArgs EventArgs = InEventArgs;

	if (IsKeyPriorityAboveUI(EventArgs))
	{
		return true;
	}

	// Check override before UI
	if (OnOverrideInputKey().IsBound())
	{
		if (OnOverrideInputKey().Execute(EventArgs))
		{
			return true;
		}
	}

	// The input is fair game for handling - the UI gets first dibs
#if !UE_BUILD_SHIPPING
	if (ViewportConsole && !ViewportConsole->ConsoleState.IsEqual(NAME_Typing) && !ViewportConsole->ConsoleState.IsEqual(NAME_Open))
#endif
	{		
		FReply Result = FReply::Unhandled();
		if (!OnRerouteInput().ExecuteIfBound(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result))
		{
			HandleRerouteInput(EventArgs.InputDevice, EventArgs.Key, EventArgs.Event, Result);
		}

		if (Result.IsEventHandled())
		{
			return true;
		}
	}

	return Super::InputKey(EventArgs);
}
  • Common UI의 Action Router Process는 Input이 CommonGameViewportClient에 전달되면서 시작합니다.
void UCommonGameViewportClient::HandleRerouteInput(FInputDeviceId DeviceId, FKey Key, EInputEvent EventType, FReply& Reply)
{
	FPlatformUserId OwningPlatformUser = IPlatformInputDeviceMapper::Get().GetUserForInputDevice(DeviceId);
	ULocalPlayer* LocalPlayer = GameInstance->FindLocalPlayerFromPlatformUserId(OwningPlatformUser);
	Reply = FReply::Unhandled();

	if (LocalPlayer)
	{
		UCommonUIActionRouterBase* ActionRouter = LocalPlayer->GetSubsystem<UCommonUIActionRouterBase>();
		if (ensure(ActionRouter))
		{
			ERouteUIInputResult InputResult = ActionRouter->ProcessInput(Key, EventType);
			if (InputResult == ERouteUIInputResult::BlockGameInput)
			{
				// We need to set the reply as handled otherwise the input won't actually be blocked from reaching the viewport.
				Reply = FReply::Handled();
				// Notify interested parties that we blocked the input.
				OnRerouteBlockedInput().ExecuteIfBound(DeviceId, Key, EventType, Reply);
			}
			else if (InputResult == ERouteUIInputResult::Handled)
			{
				Reply = FReply::Handled();
			}
		}
	}	
}
  • UCommonGaameViewportClient::InputKey는 ActionLouter에
    UCommonGameViewportClient::HandleRerouteInput의 입력을 처리할 기회를 제공한다.
ERouteUIInputResult UCommonUIActionRouterBase::ProcessInput(FKey Key, EInputEvent InputEvent) const
{
/*....*/
	const auto ProcessNormalInputFunc = [Key, ActiveMode, this](EInputEvent Event)
		{
			bool bHandled = PersistentActions->ProcessNormalInput(ActiveMode, Key, Event);

			if (!bHandled)
			{
				if (bIsActivatableTreeEnabled && ActiveRootNode)
				{
					bHandled = ActiveRootNode->ProcessNormalInput(ActiveMode, Key, Event);
				}

				if (!bHandled)
				{
					bHandled = ProcessInputOnActionDomains(ActiveMode, Key, Event);
				}
			}

			return bHandled;
		};

	bool bHandledInput = ProcessHoldResult == EProcessHoldActionResult::Handled;
	if (!bHandledInput)
	{
		if (ProcessHoldResult == EProcessHoldActionResult::GeneratePress)
		{
			// A hold action was in progress but quickly aborted, so we want to generate a press action now for any normal bindings that are interested
			ProcessNormalInputFunc(IE_Pressed);
		}

		// Even if no widget cares about this input, we don't want to let anything through to the actual game while we're in menu mode
		bHandledInput = ProcessNormalInputFunc(InputEvent); 
	}

	if (bHandledInput)
	{
		return ERouteUIInputResult::Handled;
	}
	return CanProcessNormalGameInput() ? ERouteUIInputResult::Unhandled : ERouteUIInputResult::BlockGameInput;
}
  • 성공하면, UCommonUIActionRouterBase::ProcessInput을 호출한다.
  • Action Router는 ActivatableWidget Tree에서 현재 Active 중인 Root Node를 유지합니다.
bool FActivatableTreeNode::ProcessNormalInput(ECommonInputMode ActiveInputMode, FKey Key, EInputEvent InputEvent) const
{
	if (IsReceivingInput())
	{
		for (const FActivatableTreeNodeRef& ChildNode : Children)
		{
			if (ChildNode->ProcessNormalInput(ActiveInputMode, Key, InputEvent))
			{
				return true;
			}
		}
		return FActionRouterBindingCollection::ProcessNormalInput(ActiveInputMode, Key, InputEvent);
	}
	return false;
}
  • ProcessInput은 Root Node에서 FActivatableTreeNode::ProcessNormalInput을 호출해
    Input 처리 시도를 지시합니다.
    • 이는 반복적으로 모든 Child Node에 ProcessNormalInput을 지시한다.
bool FActionRouterBindingCollection::ProcessNormalInput(ECommonInputMode ActiveInputMode, FKey Key, EInputEvent InputEvent) const
{
	for (FUIActionBindingHandle BindingHandle : ActionBindings)
	{
		if (TSharedPtr<FUIActionBinding> Binding = FUIActionBinding::FindBinding(BindingHandle))
		{
			if (ActiveInputMode == ECommonInputMode::All || ActiveInputMode == Binding->InputMode)
			{
				auto TryConsumeInput = [&](const FKey& InKey, const UInputAction* InInputAction)
				{
					// A persistent displayed action skips the normal rules for reachability, since it'll always appear in a bound action bar
					const bool bIsDisplayedPersistentAction = Binding->bIsPersistent && Binding->bDisplayInActionBar;
					if (InKey == Key && Binding->InputEvent == InputEvent && (bIsDisplayedPersistentAction || IsWidgetReachableForInput(Binding->BoundWidget.Get())))
					{
						// Just in case this was in the middle of a hold process with a different key, reset now
						Binding->CancelHold();

						// If injecting enhanced input. don't fire 'OnExecuteAction' since that can be manually done if desired in BP
						bool bEnhancedInputInjected = false;
						if (InInputAction)
						{
							if (TObjectPtr<const UCommonInputMetadata> CommonInputMetadata = CommonUI::GetEnhancedInputActionMetadata(InInputAction))
							{
								// Non generic actions should inject enhanced input so users can bind to enhanced input events
								if (!CommonInputMetadata->bIsGenericInputAction)
								{
									FInputActionValue RawValue = FInputActionValue(true);
									CommonUI::InjectEnhancedInputForAction(GetActionRouter().GetLocalPlayerChecked(), InInputAction, RawValue);
									bEnhancedInputInjected = true;
								}
							}
						}
						
						if (!bEnhancedInputInjected)
						{
							Binding->OnExecuteAction.ExecuteIfBound();
						}

						if (Binding->bConsumesInput)
						{
							return true;
						}
					}

					return false;
				};


				if (CommonUI::IsEnhancedInputSupportEnabled() && Binding->InputAction.IsValid())
				{
					if (const UInputAction* InputAction = Binding->InputAction.Get())
					{
						TArray<FKey> InputActionKeys;
						CommonUI::GetEnhancedInputActionKeys(GetActionRouter().GetLocalPlayerChecked(), InputAction, InputActionKeys);
						for (const FKey& InputActionKey : InputActionKeys)
						{
							if (TryConsumeInput(InputActionKey, InputAction))
							{
								return true;
							}
						}
					}
				}

				for (const FUIActionKeyMapping& KeyMapping : Binding->NormalMappings)
				{
					if (TryConsumeInput(KeyMapping.Key, nullptr))
					{
						return true;
					}
				}
			}
		}
	}
	return false;
}
  • FActivatableTreeNode는 FActionRouterBindingCollection으로,
    Node의 ActivatableWidget에 모든 ActionBinding List를 유지한다.
  • 현재 Node 내의 모든 Child Node에서 Input을 처리하지 못하면,
    FActivatableTreeNode::ProcessNormalInput을 호출한다.
    • 이 때 FActivatableTreeNode는 Binding Collection으로서 현재 Node는 Widget의 모든 Action Binding을 확인한다.
    • Action Binding이 해당 Key와 일치하는 경우, 관련 동작이 실행되고 Input이 처리된 것으로 간주된다.

Modify the Input Routing System

  • UCommonGameViewportClient를 상속받고 모든 Input Handling Method를 Override 하라.
    • 이후 Project Setting에서 이 Class를 GameVieportClss로 설정한다.
  • UCommonUIActionRouterBase를 상속받고 모든 Virtual Function을 Override한다.
    • 예를 들어, FUIInputconfig에서 ApplyUIInputConfig를 Override 할 수 있다.

Change UI Input Handling with Input config

  • 현재 활성화 된 Widget을 기반으로 Application의 Input Handling이 변경되어야 하는 경우가 있다.
    • 예를 들어 Social Sidebar 혹은 Pause Menu가 열려 있을 때 Player Input을 막는다던가.
  • 이를 처리하기 위해 Common UI에서는 ActivatableWidget의 Inputconfig 옵션을 지원한다.
    • 이는 Common UI 사용에 필수적인 사안은 아니다.

Use Input Config

/**
 * Input Config that can be applied on widget activation. Allows for input setup  (Mouse capture, 
 * UI-only input, move / look ignore, etc), to be controlled by widget activation.
 */
USTRUCT(BlueprintType)
struct COMMONUI_API FUIInputConfig
{
	GENERATED_BODY()

	ECommonInputMode GetInputMode() const { return InputMode; }
	EMouseCaptureMode GetMouseCaptureMode() const { return MouseCaptureMode; }
	EMouseLockMode GetMouseLockMode() const { return MouseLockMode; }
	bool HideCursorDuringViewportCapture() const { return bHideCursorDuringViewportCapture; }

	FUIInputConfig();
	FUIInputConfig(ECommonInputMode InInputMode, EMouseCaptureMode InMouseCaptureMode, bool bInHideCursorDuringViewportCapture = true);
	FUIInputConfig(ECommonInputMode InInputMode, EMouseCaptureMode InMouseCaptureMode, EMouseLockMode InMouseLockMode, bool bInHideCursorDuringViewportCapture = true);

	bool operator==(const FUIInputConfig& Other) const
	{
		return bIgnoreMoveInput == Other.bIgnoreMoveInput
			&& bIgnoreLookInput == Other.bIgnoreLookInput
			&& InputMode == Other.InputMode
			&& MouseCaptureMode == Other.MouseCaptureMode
			&& MouseLockMode == Other.MouseLockMode
			&& bHideCursorDuringViewportCapture == Other.bHideCursorDuringViewportCapture;
	}

	bool operator!=(const FUIInputConfig& Other) const
	{
		return !operator==(Other);
	}

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	bool bIgnoreMoveInput = false;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	bool bIgnoreLookInput = false;

	/** Simplification of config as string */
	FString ToString() const;

protected:


	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	ECommonInputMode InputMode;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	EMouseCaptureMode MouseCaptureMode;


	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	EMouseLockMode MouseLockMode;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	bool bHideCursorDuringViewportCapture = true;
};
  • 각 InputConfig는 여러 Input의 상태를 추적한다.
    • Mouse Capture Option
    • 이동 및 시야 축 처리
    • Common UI 전체의 Input Mode등
/**
 * Gets the desired input configuration to establish when this widget activates and can receive input (i.e. all parents are also active).
 * This configuration will override the existing one established by any previous activatable widget and restore it (if valid) upon deactivation.
 */
 TOptional<FUIInputConfig> UCommonActivatableWidget::GetDesiredInputConfig() const
{
	// Check if there is a BP implementation for input configs
	if (GetClass()->IsFunctionImplementedInScript(GET_FUNCTION_NAME_CHECKED(UCommonActivatableWidget, BP_GetDesiredInputConfig)))
	{
		return BP_GetDesiredInputConfig();
	}

	// No particular config is desired by default
	return TOptional<FUIInputConfig>();
}
  • ActivatableWidget을 활성화하면 GetDesiredInputConfig를 사용하여 InputConfig를 가져온다.
    • 일반적으로는 null을 반환하지만 Override가 가능하다.
    • GetDesiredInputConfig가 Null을 반환할 때마다 Common UI는 마지막으로 유효했던 InputConfig로 되돌아간다.
/**
* Controls whether a default Input Config will be set when the active CommonActivatableWidgets do not specify a desired one.
* Disable this if you want to control the Input Mode via alternative means.
*/
UPROPERTY(config, EditAnywhere, Category = "Input")
bool bEnableDefaultInputConfig = true;
  • 기본적으로 Common UI는 ActivatableWidget으로 지정되지 않는 경우 예비로 InputConfig를 적용한다.
    • 하지만 위 변수를 사용해 이를 비활성화 할 수 있다.
void FActivatableTreeRoot::ApplyLeafmostNodeConfig()
{
#if WITH_EDITOR
	if (!IsViewportWindowInFocusPath(GetActionRouter()))
	{
		return;
	}
#endif
	if (FActivatableTreeNodePtr PinnedLeafmostNode = LeafmostActiveNode.Pin())
	{
		GetActionRouter().SetActiveActivationMetadata(PinnedLeafmostNode->FindActivationMetadata());

		if (ensure(PinnedLeafmostNode->IsReceivingInput()))
		{
			UE_LOG(LogUIActionRouter, Display, TEXT("Applying input config for leaf-most node [%s]"), *PinnedLeafmostNode->GetWidget()->GetName());

			TOptional<FUIInputConfig> DesiredConfig = PinnedLeafmostNode->FindDesiredInputConfig();
			if(DesiredConfig.IsSet())
			{
				GetActionRouter().SetActiveUIInputConfig(DesiredConfig.GetValue(), PinnedLeafmostNode->GetWidget());
			}
			else if(ICommonInputModule::GetSettings().GetEnableDefaultInputConfig())
			{
				// Nobody in the entire tree cares about the config and the default is enabled so fall back to the default
				GetActionRouter().SetActiveUIInputConfig(FUIInputConfig());
			}

			FocusLeafmostNode();
		}
		else
		{
			UE_LOG(LogUIActionRouter, Log, TEXT("Didn't apply input config for leaf-most node [%s] because it's not receiving input right now"), *PinnedLeafmostNode->GetWidget()->GetName());
		}
	}
}
  • Widget이 비활성화 되면 Common UI는 이전 InputConfig를 복구한다.
    • 이는 현재 Widget을 지원할 적절한 Inputconfig가 없이 중단되는 것을 방지하기 위함이다.
  • 만약 UI의 모든 Widget이 비활성화 되면
    CommonUI는 마지막으로 비활성화 된 Widget의 Default InputConfig를 사용한다.
    • 이 경우 마지막으로 비활성화 된 Widget이 Soft Lock 방지를 위해
      합리적인 InputConfig 상태를 다시 적용하도록 해야 한다.

Recommended Use

  • InputConfig를 사용하는 경우 UI에서 표준 InputConfig를 사용하지 않아야 한다.
더보기
void UCommonUIActionRouterBase::ApplyUIInputConfig(const FUIInputConfig& NewConfig, bool bForceRefresh)
{
	if (bForceRefresh || NewConfig != ActiveInputConfig.GetValue())
	{
		UE_LOG(LogUIActionRouter, Display, TEXT("UIInputConfig being changed. bForceRefresh: %d"), bForceRefresh ? 1 : 0);
		UE_LOG(LogUIActionRouter, Display, TEXT("\tInputMode: Previous (%s), New (%s)"),
			ActiveInputConfig.IsSet() ? *StaticEnum<ECommonInputMode>()->GetValueAsString(ActiveInputConfig->GetInputMode()) : TEXT("None"), *StaticEnum<ECommonInputMode>()->GetValueAsString(NewConfig.GetInputMode()));

		const ECommonInputMode PreviousInputMode = GetActiveInputMode();

		TOptional<FUIInputConfig> OldConfig = ActiveInputConfig;
		ActiveInputConfig = NewConfig;

		ULocalPlayer& LocalPlayer = *GetLocalPlayerChecked();

		// Note: may not work for splitscreen. We need per-player viewport client settings for mouse capture
		if (UGameViewportClient* GameViewportClient = LocalPlayer.ViewportClient)
		{
			if (TSharedPtr<SViewport> ViewportWidget = GameViewportClient->GetGameViewportWidget())
			{
				if (APlayerController* PC = LocalPlayer.GetPlayerController(GetWorld()))
				{
					if (!OldConfig.IsSet() || OldConfig.GetValue().bIgnoreMoveInput != NewConfig.bIgnoreMoveInput)
					{
						PC->SetIgnoreMoveInput(NewConfig.bIgnoreMoveInput);						
					}
					
					if (!OldConfig.IsSet() || OldConfig.GetValue().bIgnoreLookInput != NewConfig.bIgnoreLookInput)
					{
						PC->SetIgnoreLookInput(NewConfig.bIgnoreLookInput);						
					}

					if (bAutoFlushPressedKeys && NewConfig.GetInputMode() == ECommonInputMode::Menu && PreviousInputMode != NewConfig.GetInputMode())
					{
						// Flushing pressed keys after switching to the Menu InputMode. This prevents the inputs from being artificially "held down".
						// This needs to be delayed by one frame to successfully clear input captured at the end of this frame
						GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ThisClass::FlushPressedKeys);
					}

					const bool bWasCursorHidden = !PC->ShouldShowMouseCursor();

					GameViewportClient->SetMouseCaptureMode(NewConfig.GetMouseCaptureMode());
					GameViewportClient->SetHideCursorDuringCapture(NewConfig.HideCursorDuringViewportCapture() && !ShouldAlwaysShowCursor());
					GameViewportClient->SetMouseLockMode(NewConfig.GetMouseLockMode());

					FReply& SlateOperations = LocalPlayer.GetSlateOperations();
					const EMouseCaptureMode CaptureMode = NewConfig.GetMouseCaptureMode();
					switch (CaptureMode)
					{
					case EMouseCaptureMode::CapturePermanently:
					case EMouseCaptureMode::CapturePermanently_IncludingInitialMouseDown:
					{
						PC->SetShowMouseCursor(ShouldAlwaysShowCursor() || !NewConfig.HideCursorDuringViewportCapture());

						TSharedRef<SViewport> ViewportWidgetRef = ViewportWidget.ToSharedRef();
						SlateOperations.UseHighPrecisionMouseMovement(ViewportWidgetRef);
						SlateOperations.SetUserFocus(ViewportWidgetRef);
						SlateOperations.CaptureMouse(ViewportWidgetRef);

						if (GameViewportClient->ShouldAlwaysLockMouse() || GameViewportClient->LockDuringCapture() || !PC->ShouldShowMouseCursor())
						{
							SlateOperations.LockMouseToWidget(ViewportWidget.ToSharedRef());
						}
						else
						{
							SlateOperations.ReleaseMouseLock();
						}
					}
					break;
					case EMouseCaptureMode::NoCapture:
					case EMouseCaptureMode::CaptureDuringMouseDown:
					case EMouseCaptureMode::CaptureDuringRightMouseDown:
					{
						PC->SetShowMouseCursor(true);

						SlateOperations.ReleaseMouseCapture();

						if (GameViewportClient->ShouldAlwaysLockMouse())
						{
							SlateOperations.LockMouseToWidget(ViewportWidget.ToSharedRef());
						}
						else
						{
							SlateOperations.ReleaseMouseLock();
						}
					}
					break;
					}

					// If the mouse was hidden previously, set it back to the center of the viewport now that we're showing it again 
					if (!bForceRefresh && bWasCursorHidden && PC->ShouldShowMouseCursor())
					{
						const ECommonInputType CurrentInputType = GetInputSubsystem().GetCurrentInputType();
						
						bool bCenterCursor = true;
						switch (CurrentInputType)
						{
							// Touch - Don't do it - the cursor isn't really relevant there.
							case ECommonInputType::Touch:
								bCenterCursor = false;
								break;
							// Gamepad - Let the settings tell us if we should center it.
							case ECommonInputType::Gamepad:
								break;
						}

						if (bCenterCursor)
						{
							TSharedPtr<FSlateUser> SlateUser = LocalPlayer.GetSlateUser();
							TSharedPtr<IGameLayerManager> GameLayerManager = GameViewportClient->GetGameLayerManager();
							if (ensure(SlateUser) && ensure(GameLayerManager))
							{
								FGeometry PlayerViewGeometry = GameLayerManager->GetPlayerWidgetHostGeometry(&LocalPlayer);
								const FVector2D AbsoluteViewCenter = PlayerViewGeometry.GetAbsolutePositionAtCoordinates(FVector2D(0.5f, 0.5f));
								SlateUser->SetCursorPosition(AbsoluteViewCenter);

								UE_LOG(LogUIActionRouter, Verbose, TEXT("Moving the cursor to the viewport center."));
							}
						}
					}
				}
				else
				{
					UE_LOG(LogUIActionRouter, Warning, TEXT("\tFailed to commit change! Local player controller was null."));
				}
			}
			else
			{
				UE_LOG(LogUIActionRouter, Warning, TEXT("\tFailed to commit change! ViewportWidget was null."));
			}
		}
		else
		{
			UE_LOG(LogUIActionRouter, Warning, TEXT("\tFailed to commit change! GameViewportClient was null."));
		}

		if (PreviousInputMode != NewConfig.GetInputMode())
		{
			OnActiveInputModeChanged().Broadcast(NewConfig.GetInputMode());
		}
	}
}
  • UCommonUIActionRouterBase::ApplyInputConfig에서는 Config Process 과정에서
    다음 Unreal의 표준 InputConfig들을 기능의 일부로 호출한다.
void AController::SetIgnoreMoveInput(bool bNewMoveInput)
{
	IgnoreMoveInput = FMath::Max(IgnoreMoveInput + (bNewMoveInput ? +1 : -1), 0);
}
void UGameViewportClient::SetMouseCaptureMode(EMouseCaptureMode Mode)
{
	if (MouseCaptureMode != Mode)
	{
		UE_LOG(LogViewport, Display, TEXT("Viewport MouseCaptureMode Changed, %s -> %s"),
			*StaticEnum<EMouseCaptureMode>()->GetNameStringByValue((uint64)MouseCaptureMode),
			*StaticEnum<EMouseCaptureMode>()->GetNameStringByValue((uint64)Mode)
		);

		MouseCaptureMode = Mode;
	}
}
void UGameViewportClient::SetHideCursorDuringCapture(bool InHideCursorDuringCapture)
{
	if (bHideCursorDuringCapture != InHideCursorDuringCapture)
	{
		UE_LOG(LogViewport, Display, TEXT("Viewport HideCursorDuringCapture Changed, %s -> %s"),
			bHideCursorDuringCapture ? TEXT("True") : TEXT("False"),
			InHideCursorDuringCapture ? TEXT("True") : TEXT("False")
		);

		bHideCursorDuringCapture = InHideCursorDuringCapture;
	}
}
  • 때문에 다른 곳에서 이런 함수를 호출하며 Common UI의 InputConfig와 혼용할 경우 서로를 Override하여 관리할 때 혼란이 발생할 수 있다.
  • Input Config 관리를 간소화하기 위해
    Widget의 Enum값을 기반으로 주로 사용하는 InputConfig를 할당하는 기본 구현을 생성할 수 있다.
    • 이는 Widget별로 몇 개의 고정적인 Async InputConfig만 필요한 경우에 유용하다.

Input Handling State Reference

FUIInputConfig

더보기
/**
 * Input Config that can be applied on widget activation. Allows for input setup  (Mouse capture, 
 * UI-only input, move / look ignore, etc), to be controlled by widget activation.
 */
USTRUCT(BlueprintType)
struct COMMONUI_API FUIInputConfig
{
	GENERATED_BODY()

	ECommonInputMode GetInputMode() const { return InputMode; }
	EMouseCaptureMode GetMouseCaptureMode() const { return MouseCaptureMode; }
	EMouseLockMode GetMouseLockMode() const { return MouseLockMode; }
	bool HideCursorDuringViewportCapture() const { return bHideCursorDuringViewportCapture; }

	FUIInputConfig();
	FUIInputConfig(ECommonInputMode InInputMode, EMouseCaptureMode InMouseCaptureMode, bool bInHideCursorDuringViewportCapture = true);
	FUIInputConfig(ECommonInputMode InInputMode, EMouseCaptureMode InMouseCaptureMode, EMouseLockMode InMouseLockMode, bool bInHideCursorDuringViewportCapture = true);

	bool operator==(const FUIInputConfig& Other) const
	{
		return bIgnoreMoveInput == Other.bIgnoreMoveInput
			&& bIgnoreLookInput == Other.bIgnoreLookInput
			&& InputMode == Other.InputMode
			&& MouseCaptureMode == Other.MouseCaptureMode
			&& MouseLockMode == Other.MouseLockMode
			&& bHideCursorDuringViewportCapture == Other.bHideCursorDuringViewportCapture;
	}

	bool operator!=(const FUIInputConfig& Other) const
	{
		return !operator==(Other);
	}

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	bool bIgnoreMoveInput = false;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	bool bIgnoreLookInput = false;

	/** Simplification of config as string */
	FString ToString() const;

protected:


	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	ECommonInputMode InputMode;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	EMouseCaptureMode MouseCaptureMode;


	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	EMouseLockMode MouseLockMode;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = InputConfig)
	bool bHideCursorDuringViewportCapture = true;
};

ECommonInputMode

더보기
UENUM(BlueprintType)
enum class ECommonInputMode : uint8
{
	Menu	UMETA(Tooltip = "Input is received by the UI only"),
	Game	UMETA(Tooltip = "Input is received by the Game only"),
	All		UMETA(Tooltip = "Input is received by UI and the Game"),

	MAX UMETA(Hidden)
};

EMouseCaptureMode

더보기
UENUM()
enum class EMouseCaptureMode : uint8
{
	/** Do not capture the mouse at all */
	NoCapture,
	/** Capture the mouse permanently when the viewport is clicked, and consume the initial mouse down that caused the capture so it isn't processed by player input */
	CapturePermanently,
	/** Capture the mouse permanently when the viewport is clicked, and allow player input to process the mouse down that caused the capture */
	CapturePermanently_IncludingInitialMouseDown,
	/** Capture the mouse during a mouse down, releases on mouse up */
	CaptureDuringMouseDown,
	/** Capture only when the right mouse button is down, not any of the other mouse buttons */
	CaptureDuringRightMouseDown,
};

EMouseLockMode

더보기
UENUM()
enum class EMouseLockMode : uint8
{
	/** Do not lock the mouse cursor to the viewport */
	DoNotLock,
	/** Only lock the mouse cursor to the viewport when the mouse is captured */
	LockOnCapture,
	/** Always lock the mouse cursor to the viewport */
	LockAlways,
	/** Lock the cursor if we're in fullscreen */
	LockInFullscreen,
};

Change Widget Input Respons Using FReply

  • FReply는 InputEvent의 처리 상태를 추적한다.
/**
 * An event should return a FReply::Handled() to let the system know that an event was handled.
 */
static FReply Handled( )
{
	return FReply(true);
}

/**
 * An event should return a FReply::Unhandled() to let the system know that an event was unhandled.
 */
static FReply Unhandled( )
{
	return FReply(false);
}
  • Slate의 Input은 대부분 FReply::Handled나 FReply::Unhandled를 결과로 반환한다.
  • SWidget에서 사용되는 수 많은 Input Event들은 대부분 FReply를 반환한다.
    • 이를 이용해 특정 FReply를 반환하여 원하는 결과를 얻게할 수 있다.
      • 예를들어 특정 InputType의 처리를 허용 또는 중지한다던가.
    • 하지만 대부분은 순정 FReply를 사용해도 크게 문제 없을 것이다.
  • FReply를 Customize 할 때에는 Slate Widget 작업에서 문제가 발생하는 경우가 많다.

FReply Settings

  • 보통은 FReply의 Handled/UnHandled로만 상태를 추적하지만, 몇 가지 추가 데이터를 제공하기도 한다.
  • 이러한 Method는 UMG나 Slate의 일부 Method와 비슷해 보일 수 있다.
    • 하지만 기본적으로 FReply Namespace에 존재하며, Slate에서 FReply를 처리할 때 발생하는 동작을 수정한다.
  • FReply에서 이런 Method를 호출하면 FReply 외부의 동등한 Method를 호출한다.
    • 이는 쉽게 복제할 수 없는 조금 다른 동작이 나올 수 있다.

CaptureMouse

/** An event should return a FReply::Handled().CaptureMouse( SomeWidget ) as a means of asking the system to forward all mouse events to SomeWidget */
FReply& CaptureMouse( TSharedRef<SWidget> InMouseCaptor )
{
	this->MouseCaptor = InMouseCaptor;
	return Me();
}
  • System에 특정 Widget으로 모든 MouseEvent를 전달하도록 요청

ClearUserFocus

/** An event should return a FReply::Handled().ClearUserFocus() to ask the system to clear user focus*/
FReply& ClearUserFocus(bool bInAllUsers = false)
{
    return ClearUserFocus(EFocusCause::SetDirectly, bInAllUsers);
}

/** An event should return a FReply::Handled().ClearUserFocus() to ask the system to clear user focus*/
SLATECORE_API FReply& ClearUserFocus(EFocusCause ReasonFocusIsChanging, bool bInAllUsers = false)
{
	this->FocusRecipient = nullptr;
	this->FocusChangeReason = ReasonFocusIsChanging;
	this->bReleaseUserFocus = true;
	this->bSetUserFocus = false;
	this->bAllUsers = bInAllUsers;
	return Me();
}
  • System에 User Focus를 지우기를 요청

ReleaseMouseCapture

/** 
 * An event should return a FReply::Handled().ReleaseMouse() to ask the system to release mouse capture
 * NOTE: Deactivates high precision mouse movement if activated.
 */
FReply& ReleaseMouseCapture()
{
	this->MouseCaptor.Reset();
	this->bReleaseMouseCapture = true;
	this->bUseHighPrecisionMouse = false;
	return Me();
}
  • System에 Mouse Capture 해제를 요청

SetUserFocus

/** An event should return FReply::Handled().SetUserFocus( SomeWidget ) as a means of asking the system to set users focus to the provided widget*/
SLATECORE_API FReply& SetUserFocus(TSharedRef<SWidget> GiveMeFocus, EFocusCause ReasonFocusIsChanging = EFocusCause::SetDirectly, bool bInAllUsers = false)
{
	this->bSetUserFocus = true;
	this->FocusRecipient = GiveMeFocus;
	this->FocusChangeReason = ReasonFocusIsChanging;
	this->bReleaseUserFocus = false;
	this->bAllUsers = bInAllUsers;
	return Me();
}
  • System에 User의 Focus를 제공된 Widget으로 설정하도록 요청

SetNavigation

/** An event should return FReply::Handled().SetNavigation( NavigationType ) as a means of asking the system to attempt a navigation*/
FReply& SetNavigation(EUINavigation InNavigationType, const ENavigationGenesis InNavigationGenesis, const ENavigationSource InNavigationSource = ENavigationSource::FocusedWidget)
{
	this->NavigationType = InNavigationType;
	this->NavigationGenesis = InNavigationGenesis;
	this->NavigationSource = InNavigationSource;
	this->NavigationDestination = nullptr;
	return Me();
}

/** An event should return FReply::Handled().SetNavigation( NavigationDestination ) as a means of asking the system to attempt a navigation to the specified destination*/
FReply& SetNavigation(TSharedRef<SWidget> InNavigationDestination, const ENavigationGenesis InNavigationGenesis, const ENavigationSource InNavigationSource = ENavigationSource::FocusedWidget)
{
	this->NavigationType = EUINavigation::Invalid;
	this->NavigationGenesis = InNavigationGenesis;
	this->NavigationSource = InNavigationSource;
	this->NavigationDestination = InNavigationDestination;
	return Me();
}
  • System에 지정된 대상으로의 Navigation을 시도하도록 요청

When would we set FReply?

  • 예를 들어 특정 Key Input으로 Widget Focus를 설정하거나, 지워야 한다고 가정해보자.
  • 보통은 FSlateInputApplication에서 Key Input Handler 관련 함수를 직접 호출할 것이다.
    • 이 방식은 모든 경우에, 특히 Input Routing을 사용할 때 작동하지 않을 수 있다.
    • 현재 Widget에서 Input이 처리되는 동안 Focus 변경/지우기가 발생할 수 있기 때문이다.
  • 이를 대신하여 Input이 완전히 처리되고 나서 발생되는 FReply를 사용하는 것이 좋다.
  • 과거에는 FReply API가 제어 또는 노출되는 상태일 때에는 FReply만 사용 하도록 하였다.
    • 하지만 이 방식은 너무 제한적이라 위와 같이 언제든 사용 가능하도록 바뀌었다.
    • 현재 버전에서 기존의 가이드 이상으로 강력히 권장되는 방식이다.

Customize Navigation

Navigation Config

/**
 * Navigation context for event
 */
UENUM(BlueprintType)
enum class EUINavigation : uint8
{
	/** Four cardinal directions*/
	Left,
	Right,
	Up,
	Down,

	/** Conceptual next and previous*/
	Next,
	Previous,

	/** Number of navigation types*/
	Num UMETA(Hidden),

	/** Denotes an invalid navigation, more important used to denote no specified navigation*/
	Invalid,
};
  • Slate는 CommonUI 사용 여부와 무관하게 기본 Navigation을 지원한다.
/**
 * Sets the navigation config.  If you need to control navigation config dynamically, you
 * should subclass FNavigationConfig to be dynamically adjustable to your needs.
 */
SLATE_API void SetNavigationConfig(TSharedRef<FNavigationConfig> InNavigationConfig)
{
	NavigationConfig->OnUnregister();
	NavigationConfig = InNavigationConfig;
	NavigationConfig->OnRegister();

#if WITH_SLATE_DEBUGGING
	TryDumpNavigationConfig(NavigationConfig);
#endif // WITH_SLATE_DEBUGGING
}
  • NavigationConfig를 설정하려면 FSlateApplication::NetNavigationConfig를 호출한다.
  • 대체로 FNavigationConfig에서 파생된 Custom Navigation 구성을 할 때 이 함수를 호출한다.
    • 예를 들어 WASD로 UI라 상호작용 한다면 이 부분에서부터 시작하는게 좋다.
void FSlateUser::SetUserNavigationConfig(TSharedPtr<FNavigationConfig> InNavigationConfig)
{
	if (UserNavigationConfig)
	{
		UserNavigationConfig->OnUnregister();
	}

	UserNavigationConfig = InNavigationConfig;
	
	if (InNavigationConfig)
	{
		InNavigationConfig->OnRegister();
	}

#if WITH_SLATE_DEBUGGING
	FSlateApplication::Get().TryDumpNavigationConfig(UserNavigationConfig);
#endif // WITH_SLATE_DEBUGGING
}
  • 또한 FSlateUser::SetUserNavigationConfig를 호출해 User 기반으로 Navigation 구성을 설정할 수도 있다.

Manually Control Navigation

  • UMG에서 Widget을 선택하고 Details -> Navigation을 선택하면
    Navigation Event 발생 시 어떻게 될지 수동으로 지정할 수 있다.
UENUM(BlueprintType)
enum class EUINavigationRule : uint8
{
	/** Allow the movement to continue in that direction, seeking the next navigable widget automatically. */
	Escape,
	/** Move to a specific widget. */
	Explicit,
	/**
	 * Wrap movement inside this container, causing the movement to cycle around from the opposite side, 
	 * if the navigation attempt would have escaped.
	 */
	Wrap,
	/** Stops movement in this direction */
	Stop,
	/** Custom navigation handled by user code. */
	Custom,
	/** Custom navigation handled by user code if the boundary is hit. */
	CustomBoundary,
	/** Invalid Rule */
	Invalid
};

Activatable Widgets and Action Bind

Set Focus for Activatable Widget on Activation

UWidget* UCommonActivatableWidget::GetDesiredFocusTarget() const
{
	return NativeGetDesiredFocusTarget();
}

UWidget* UCommonActivatableWidget::NativeGetDesiredFocusTarget() const
{
	// Prioritize BP implementation of this function.
	UWidget* DesiredFocusTarget = BP_GetDesiredFocusTarget();

	if (!DesiredFocusTarget)
	{
		// BP didn't specify focus target, fallback to DesiredFocusWidget property on UserWidget.
		DesiredFocusTarget = GetDesiredFocusWidget();
	}

	return DesiredFocusTarget;
}

UWidget* UUserWidget::GetDesiredFocusWidget() const
{
	return DesiredFocusWidget.GetWidget();
}
  • ActivatableWidget을 Activate할 때마다 GetDesiredFocusTarget이 호출된다.
    • 이 함수는 User Input 발생 시 Common UI가 Focus해야 할 Widget을 선택한다.
    • 해당 함수를 Customize 하지 않을 시 Common UI에서
      Widget이 Active/Inactive 할 때마다 Focus 할 위치를 파악하는데 어려울 수 있다.
    • 그러니 반드시 Customize를 하자.
  • Lyra에서는 ActivatableWidget에서 원하는 Focus Target을 가져오는데 사용할 Method를 Enum Type으로 결정한다.
    • 이는 Focus 대상이 고정된 Async Method를 사용하는 경우에 권장된다.
    • 대부분의 Menu에서라던가.

Change when Input Action Fire Triggered

  • Action Bind를 위해 FBindUIActionArgs를 생성할 때 KeyEvent를
    InputAction Event를 Trigger해야 할 Type으로 설정한다.

Console Variable Reference

Use With Enhanced Input

  • Unreal Engine 5.2 이전 버전에서는 사용을 권장하지 않음.
    • Enhanced Input이 5.2버전 기준으로 테스트가 충분히 거치지 않음
  • 주요 기능인 EnhancedInput의 PlayerMappableKeySettings가 아직 Experimental 단계임.
    • 적어도 Beta가 되기 전까지는 시도를 안 하는 편이 좋음.

필수 사항

  • Common UI와 Enhanced Input을 모두 사용할 것
  • ViewportClass가 CommonGameViewportClient나 그 하위 Class일 것
  • CommonInputData에서 Accept/Back에 대한 Input Action이 등록되어 있을 것

설정법

  • Project Settings -> Game -> Common Input Settings에서 Enable Enhanced Input Support를 체크

  • Miscellaneous -> DataAsset을 선택해 CommonMappingContextMetadataInterface를 선택해 Asset 생성
    • 해당 Asset은 Common UI Input Data를 설정할 수 있는 Metadata Object를 제공한다.
    • 여기서 쓰이는 Nav Bar Priority는 Common UI와 동일한 개념이다.
    • 만약 기능이 필요하다면 CommonInputMetaData를 상속받아 확장 가능하다.
  • 위와 같이 설정
    • Per Action Enhanced Input Metadata
      • 대응 될 InputAction BP
      • Action마다 Asset을 각자 만드는 대신,
        Per Action Enhanced Input Metadata를 사용해 한 Asset에서 여러 Action을 처리할 수 있다.
    • Is Generic Iput Action
      • true로 설정한 이유는 Common UI가 Input Action을 Broadcast하는 것을 방지하기 위함이다.
      • 만약 Generic InputAction이 아닌 InputAction에서 사용하고 싶다면 이 값을 해제하면 된다.
        • 이 경우, 해당 InputAction에는 Input을 Bind할 수 있다.

  • InputAction에서 Player Mappable Key Settings에서 Metadata 필드를 설정한다.
    • 여기서 사용하는 Metadata는 위에서 만든 Asset이다.

  • 이렇게 만들어진 InputAction은 Input을 받을 수 있는 Common UI에 연결해 사용할 수 있다.
    • CommonButtonBase
    • CommonActionWidget
      • UI가 아닌 InputAction의 Key를 표시할 수 있다.
    • CommonUIInputData
      • 여기서 Default Navigation Action이 정의된다.
  • 만약 이 옵션이 보이지 않는다면 Project Settings에서 Enable Enhanced Input Support 옵션을 확인해보자.

  • CommonActivatableWidget의 경우, Active 여부에 따라 적용/제거 될 IMC를 지정할 수 있다.
    • 더 좋은 구조를 위해 게임 IMC를 적용 할 때 Common UI IMC를 같이 적용할 것을 권장한다.

'UE5 > UI' 카테고리의 다른 글

[회고] Common UI, UMG ViewModel 사용 후기  (0) 2024.09.28
[UI] Common UI FAQ  (0) 2024.07.10
[UI] Common UI Widget  (0) 2024.07.10
[UI] Common UI Introduction  (0) 2024.07.10
[UI] UMG ViewModel  (0) 2024.07.08

+ Recent posts