반대로 이 두 케이스에 모두 해당되지 않으면 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개인 것처럼 느껴진다.
가장 간단한 방법은 입력을 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를 비활성화 하면 된다.
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;
}
/** 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 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 대상으로 고려되지 않는다.
이 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;
}
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 옵션을 지원한다.
/**
* 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들을 기능의 일부로 호출한다.
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)
};
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,
};
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
}
또한 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를 같이 적용할 것을 권장한다.
USTRUCT(BlueprintType)
struct COMMONUI_API FCommonInputActionDataBase : public FTableRowBase
{
GENERATED_BODY()
FCommonInputActionDataBase();
/** User facing name (used when NOT a hold action) */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "CommonInput")
FText DisplayName;
/** User facing name used when it IS a hold action */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "CommonInput")
FText HoldDisplayName;
/** Priority in nav-bar */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "CommonInput")
int32 NavBarPriority = 0;
protected:
/**
* Key to bind to for each input method
*/
UPROPERTY(EditAnywhere, Category = "CommonInput")
FCommonInputTypeInfo KeyboardInputTypeInfo;
/**
* Default input state for gamepads
*/
UPROPERTY(EditAnywhere, Category = "CommonInput")
FCommonInputTypeInfo DefaultGamepadInputTypeInfo;
/**
* Override the input state for each input method
*/
UPROPERTY(EditAnywhere, Category = "CommonInput", Meta = (GetOptions = "CommonInput.CommonInputBaseControllerData.GetRegisteredGamepads"))
TMap<FName, FCommonInputTypeInfo> GamepadInputOverrides;
/**
* Override the displayed brush for each input method
*/
UPROPERTY(EditAnywhere, Category = "CommonInput")
FCommonInputTypeInfo TouchInputTypeInfo;
};
DisplayName
해당 InputAction의 이름
Navigation Bar가 있는 경우 해당 Bar에 표시된다.
HoldDisplayName
버튼 Hold 동작이 필요한 InputAction의 이름
NavBarPriority
Navibation Bar Action을 왼쪽에서 오른쪽으로 Sort할 때 사용하는 Priority
USTRUCT(BlueprintType)
struct COMMONUI_API FCommonInputTypeInfo
{
GENERATED_USTRUCT_BODY()
FCommonInputTypeInfo();
private:
/** Key this action is bound to */
UPROPERTY(EditAnywhere, Category = "CommonInput")
FKey Key;
public:
/** Get the input type key bound to this input type, with a potential override */
FKey GetKey() const;
/** Get the input type key bound to this input type, with a potential override */
void SetKey(FKey InKey)
{
Key = InKey;
};
/** EInputActionState::Enabled means that the state isn't overriden and the games dynamic control will work */
UPROPERTY(EditAnywhere, Category = "CommonInput")
EInputActionState OverrrideState;
/** Enables hold time if true */
UPROPERTY(EditAnywhere, Category = "CommonInput")
bool bActionRequiresHold;
/** The hold time in seconds */
UPROPERTY(EditAnywhere, Category = "CommonInput", meta = (EditCondition = "bActionRequiresHold", ClampMin = "0.0", UIMin = "0.0"))
float HoldTime;
/**
* Time (in seconds) for hold progress to go from 1.0 (completed) to 0.0.
* If the hold interaction was interrupted, then hold progress starts to roll back decreasing its value.
* Set to 0.0 to disable the rollback functionality.
*/
UPROPERTY(EditAnywhere, Category = "CommonInput", meta = (EditCondition = "bActionRequiresHold", ClampMin = "0", UIMin = "0", ClampMax = "10.0", UIMax = "10"))
float HoldRollbackTime;
/** Override the brush specified by the Key Display Data */
UPROPERTY(EditAnywhere, Category = "CommonInput")
FSlateBrush OverrideBrush;
};
UENUM(BlueprintType)
enum class EInputActionState : uint8
{
/** Enabled, will call all callbacks */
Enabled,
/** Disabled, will call all the disabled callback if specified otherwise do nothing */
Disabled,
/** The common input reflector will not visualize this but still calls all callbacks. NOTE: Use this sparingly */
Hidden,
/** Hidden and disabled behaves as if it were never added with no callbacks being called */
HiddenAndDisabled,
};
Enabled
Input이 활성화 되어 있음
Callback이 호출 됨
Disabled
Disable Callback이 선언되어 있는 경우 이를 호출 함
그 외의 모든 Callback이 반응하지 않음
Hidden
Reflector가 시각적으로 보이지는 않지만 Callback이 호출 됨.
자주 사용하지 않을 것을 권장 함.
HiddenAndDisabled
Reflector가 시각적으로 보이지 않고, Callback도 동작하지 않음.
Default Navigation Action Configure
Unreal Engine에서 Native Navigation을 지원하지만, CommonUI을 사용하면 CommonUIInputData를 기반으로 한 별도의 Navigation이 정의되어야 한다.
Create New Plueprint Class에서 CommonUIInputData를 선택해 BP 생성
생성한 파일 내에 CommonUI InputActionDataTable과 Row를 지정하여 Navigation을 지정
HoldData의 경우 CommonUIHoldInputData를 기반으로 신규 BP를 생성한 뒤, 해당 파일을 연결해줘야 한다.
Click Action
버튼이나 기타 상호작용 가능한 Element를 Highlight 할 때 Mouse Click을 대체
Back Action
현재 Menu에서 이전 Menu로 이동할 때 공통으로 사용
Project Settings -> Game -> Common Input Settings의 InputData에 연결한다.
설정 시, 지정된 Asset을 Default Navigation에 사용한다.
Bind Controller Data per Platform
Controller Data Asset은 Key-Action을 UI Elemnt에 연결해준다.
각 Controller Data Asset은 Input Type, Gamepad, Platform과 연관되어 있다.
CommonUI는 이 정보를 이용해 현재 Platform과 Input Type을 기반으로 올바른 Platform 별 UI Element를 자동으로 사용한다.
이를 통해 다수의 Input Type이나 고유한 Gamepad를 지원하는 Platform에서 User Input을 올바른 GamePad에 전달하거나, Runtime에서 UI Elemnt를 교체할 수도 있다.
Create New Blueprint Class에서 CommonInputBaseControllerData를 선택해 생성
생성한 모든 Controller Data Asset은 Project Settings -> Game -> Common Input Settings의 Platform Input에 추가되어야 한다.
Platform Input에서 입력하는 Default Gamepad Name은 Controller Data Asset들의 Gamepad Name 필드들로만 입력되어야 한다.
이 값이 일치하지 않으면 Controller Data를 인식하지 못하고 Icon이 표시되지 않는다.
보통은 한 Platform의 Controller Data Array에 여러 개의 Gamepad Data를 작성해 다양한 컨트롤러를 지원한다.
만약 별도의 Controller에 대한 게임플레이 지원을 해야 한다면, Gamepad Data를 새로 작성해 Controller Data에 추가하면 된다.
이는 변수 값이 수정 될 때 등록된 Widget만 업데이트 하기 때문에 Attribute Bind보다 훨씬 효과적이다.
동시에 시간 설정을 수동으로 구현 할 필요도 없어 Event Driven UI Framework의 이점을 살리기 좋다.
Config
View Model
역할
UI에 필요한 변수의 Manifest 관리
UI와 Application의 기타 요소간 Communication을 위한 매개체
UI가 변수를 인지해야 하는 경우
ViewModel에 변수 추가
Widget에 ViewModel 추가
Widget의 Field를 ViewModel에 Bind
변수를 업데이트 해야 하는 경우
ViewModel의 Reference를 가지고 있다면 언제든 직접 접근하여 수정
변경 된 변수에 Bind된 Widget을 Notify하고 업데이트
ViewModel in BP
Creation
Content Borwser를 우클릭하여 New Blueprint를 통해 ViewMode BP 생성
ViewModel은 Widget이 아니기 때문에 User Interface에 없고 일반 BP 생성 방식을 통해야 한다.
FieldNotify Variable
BP의 변수 옆에 종 모양 UI가 FieldNotify 활성화 여부이다.
FieldNotify가 활성화 된 변수의 Set은 BP 라벨의 이름이 Set w/ Broadcast로 지정된다.
위와 같이 설정 된 변수들은 값이 변경될 때마다 Bind된 Widget에 Update 메시지를 전송한다.
FieldNotify Function
Function 역시 FieldNotify로 취급 될 수 있으나 변수에 비해 몇 가지 조건을 요구한다.
Pure Function일 것
Const 마킹이 되어 있을 것
하나의 값만 반환할 것
Input Parameter가 없을 것
FieldNotify를 사용한다면 가급적 값을 반환하는 목적만 있는 Getter 생성은 지양해야 한다.
차후에 Widget을 Bind하려 할 때 추가되는 Getter 함수와 햇갈릴 수 있다.
Bind FieldNotify to Another FieldNotify
만약 FieldNotify 변수가 변경 되는 경우, 그 변수에 FieldNotify Function을 Bind 해줘야 한다.
이는 비단 Function 뿐 아니라 FieldNotify 변수에 다른 FieldNotify 변수를 Bind할 수도 있다.
FieldNotify에 다른 FieldNotify 변수나 함수가 Bind되어 있다면, 변수가 수정될 때 Bind 된 함수의 실행은 물론 Bind된 변수에 Bind 된 Widget에까지 Update가 된다.
ViewModel in C++
Creation
#pragma once
#include "CoreMinimal.h"
#include "MVVMViewModelBase.h"
#include "MyMVVMViewModelBase.generated.h"
UCLASS()
class MYTEST_API UMyMVVMViewModelBase : public UMVVMViewModelBase
{
GENERATED_BODY()
};
기본적인 ViewModel은 UMVVMViewModelBase를 상속 받아 생성할 수 있다.
class INotifyFieldValueChanged : public IInterface
{
GENERATED_BODY()
public:
// using "not checked" user policy (means race detection is disabled) because this delegate is stored in a container and causes its reallocation
// from inside delegate's execution. This is incompatible with race detection that needs to access the delegate instance after its execution
using FFieldValueChangedDelegate = TDelegate<void(UObject*, UE::FieldNotification::FFieldId), FNotThreadSafeNotCheckedDelegateUserPolicy>;
public:
/** Add a delegate that will be notified when the FieldId is value changed. */
virtual FDelegateHandle AddFieldValueChangedDelegate(UE::FieldNotification::FFieldId InFieldId, FFieldValueChangedDelegate InNewDelegate) = 0;
/** Remove a delegate that was added. */
virtual bool RemoveFieldValueChangedDelegate(UE::FieldNotification::FFieldId InFieldId, FDelegateHandle InHandle) = 0;
/** Remove all the delegate that are bound to the specified UserObject. */
virtual int32 RemoveAllFieldValueChangedDelegates(const void* InUserObject) = 0;
/** Remove all the delegate that are bound to the specified Field and UserObject. */
virtual int32 RemoveAllFieldValueChangedDelegates(UE::FieldNotification::FFieldId InFieldId, const void* InUserObject) = 0;
/** @returns the list of all the field that can notify when their value changes. */
virtual const UE::FieldNotification::IClassDescriptor& GetFieldNotificationDescriptor() const = 0;
/** Broadcast to the registered delegate that the FieldId value changed. */
virtual void BroadcastFieldValueChanged(UE::FieldNotification::FFieldId InFieldId) = 0;
};
하지만 이보다 더 근본적으로, INotifyFieldValueChanged만을 Implement 하여 생성할 수도 있다.
/** Base class for MVVM viewmodel. */
UCLASS(Blueprintable, Abstract, DisplayName="MVVM Base Viewmodel")
class MODELVIEWVIEWMODEL_API UMVVMViewModelBase : public UObject, public INotifyFieldValueChanged
{
GENERATED_BODY()
public:
//~ Begin INotifyFieldValueChanged Interface
virtual FDelegateHandle AddFieldValueChangedDelegate(UE::FieldNotification::FFieldId InFieldId, FFieldValueChangedDelegate InNewDelegate) override final;
virtual bool RemoveFieldValueChangedDelegate(UE::FieldNotification::FFieldId InFieldId, FDelegateHandle InHandle) override final;
virtual int32 RemoveAllFieldValueChangedDelegates(const void* InUserObject) override final;
virtual int32 RemoveAllFieldValueChangedDelegates(UE::FieldNotification::FFieldId InFieldId, const void* InUserObject) override final;
virtual const UE::FieldNotification::IClassDescriptor& GetFieldNotificationDescriptor() const override;
virtual void BroadcastFieldValueChanged(UE::FieldNotification::FFieldId InFieldId) override;
//~ End INotifyFieldValueChanged Interface
/*etc*/
};
FieldNotify 변수를 선언하려면 UPROPERTY에서 다음 지정자들을 지정해야 한다.
FieldNotify를 선언하지 않은 경우 Onetime, 즉 변수가 최초로 변경될 때에만 Notify가 발생하고 그 이후에는 발생하지 않는다.
Setter와 Getter는 필요에 따라 추가한다.
단, 추가하지 않으면 해당 동작에 대한 연산은 해당 Class는 물론 하위 Class에서도 수행할 수 없다.
Getter/Setter은 Bind 된 Function 실행이나 FieldNotify Update 외에 변수값을 얻기 전에 연산을 해야 하는 경우에도 적절하다.
Getter/Setter함수는 UFUNCTION으로 생성할 경우 BP에서 상당히 많은 목록을 생성하기에 이를 지양하는 것이 좋다.
UPROPERTY에서 이미 FieldNotify의 Get/Set에 연결을 해준 상태다.
Unreal Engine은 FiendNotify 변수에 대한 Getter/Setter 함수 호출을 강제하지 않는다.
사용자 귀책 실수를 줄이고 싶다면 접근제어자를 조절하는 것이 필요하다.
BP에서는 FieldNotify 변수가 변경될 때 Bind된 FieldNotify 변수나 함수가 자동으로 Update되지만. C++로 작업한다면 이들을 직접 호출해줘야 한다.
FieldNotify Function
FieldNotify Function은 다음 조건을 만족해야 한다.
UFUNCTION에서 BlueprintPure, FieldNotify 지정자 선언
Parameter가 없어야 함
const 선언이 되어 있어야 함
out 인자 없이 단일 값을 반환해야 함.
Widget이 특정 변수에 Bind 되어 있으면서 동시에 값을 직접 사용하지 않고 연산을 거쳐야 하는 경우에 유용하다.
일종의 임시 변수 생성
FieldNotify Macro
/** After a field value changed. Broadcast the event. */
#define UE_MVVM_BROADCAST_FIELD_VALUE_CHANGED(MemberName) \
BroadcastFieldValueChanged(ThisClass::FFieldNotificationClassDescriptor::MemberName)
/** If the property value changed then set the new value and notify. */
#define UE_MVVM_SET_PROPERTY_VALUE(MemberName, NewValue) \
SetPropertyValue(MemberName, NewValue, ThisClass::FFieldNotificationClassDescriptor::MemberName)
/** Use this version to set property values that can't be captured as a function arguments (i.e. bitfields). */
#define UE_MVVM_SET_PROPERTY_VALUE_INLINE(MemberName, NewValue) \
[this, InNewValue = (NewValue)]() { if (MemberName == InNewValue) { return false; } MemberName = InNewValue; BroadcastFieldValueChanged(ThisClass::FFieldNotificationClassDescriptor::MemberName); return true; }()
Add ViewModel to Widget
Widget을 생성하고 Window->Viewmodels항목을 선택하면 다음 창이 뜬다.
해당 창에서 미리 만든 ViewModel을 선택하면 된다.
ViewModel은 여러 개 등록 할 수 있다.
그 말은 Widget과 ViewModel의 관계는 1:1이 아니라 다:다라는 의미이다.
Initialize ViewModel
ViewModel을 처음 추가하면 위와 같은 Noti를 볼 수 있다.
이는 현재 Widget에서 등록된 ViewModel에 Bind가 없기에 굳이 Initialize를 하지 않겠다는 의미이다.
View Binding에서 Bind를 추가해주면 관계가 생성되며 자동으로 Instance가 생성된다.
Create Instance
Widget Instance 별로 각각 새로운 ViewModel Instance를 자동 생성
동일한 Widget이 Viewport 상 여러 개 존재하더라도 하나의 변수를 수정하면 그 ViewModel에 해당하는 Widget만 Update 된다.
이와 동일하게, 여러 개의 서로 다른 Widget을 생성할 때에도 다른 Widget의 정보 변경을 인지하지 못한다.
C++의 Call-back 초기화 다음, 혹은 BP의 Call-back 초기화 도중에 ViewModel을 할당할 수 있다.
ViewModel이 설정되지 않으면 새 Instance만 생성한다.
ViewModel은 PreConstruct와 Construct 사이에 생성된다.
Manual
코드 상 특정 위치에서 Instance를 생성하고 Widget에 할당하는 방식
Widget은 Reference를 가지지만, 할당되기 전까지는 Null 값을 갖는다.
Create Widget 노드에서 생성 시 ViewModel을 할당할 수도 있다.
ViewModel을 할당하면 Widget에 대한 Reference를 구하지 않고 UI를 Update 할 수 있다.
이 방법을 통해 UI가 하나의 Actor Class로부터 서로 다른 다수의 Widget에 동일한 ViewModel을 할당할 수 있게 된다.
Global Viewmodel Collection
MVVMSubsystem에서 Global로 Access 할 수 있는 ViewModel 목록
UCLASS(DisplayName="Viewmodel Engine Subsytem")
class MODELVIEWVIEWMODEL_API UMVVMSubsystem : public UEngineSubsystem
{
GENERATED_BODY()
public:
//~ Begin UEngineSubsystem interface
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
//~ End UEngineSubsystem interface
UFUNCTION(BlueprintCallable, Category = "Viewmodel", meta = (DisplayName = "Get View From User Widget"))
UMVVMView* K2_GetViewFromUserWidget(const UUserWidget* UserWidget) const;
static UMVVMView* GetViewFromUserWidget(const UUserWidget* UserWidget);
UFUNCTION(BlueprintCallable, Category = "Viewmodel")
bool DoesWidgetTreeContainedWidget(const UWidgetTree* WidgetTree, const UWidget* ViewWidget) const;
/** @return The list of all the AvailableBindings that are available for the Class. */
UFUNCTION(BlueprintCallable, Category = "Viewmodel", meta = (DisplayName = "Get Available Bindings"))
TArray<FMVVMAvailableBinding> K2_GetAvailableBindings(const UClass* Class, const UClass* Accessor) const;
static TArray<FMVVMAvailableBinding> GetAvailableBindings(const UClass* Class, const UClass* Accessor);
/**
* @return The list of all the AvailableBindings that are available from the SriptStuct.
* @note When FMVVMAvailableBinding::HasNotify is false, a notification can still be triggered by the owner of the struct. The struct changed but which property of the struct changed is unknown.
*/
static TArray<FMVVMAvailableBinding> GetAvailableBindingsForStruct(const UScriptStruct* Struct);
static TArray<FMVVMAvailableBinding> GetAvailableBindingsForEvent(const UClass* Class, const UClass* Accessor);
/** @return The AvailableBinding from a BindingName. */
UFUNCTION(BlueprintCallable, Category = "Viewmodel", meta = (DisplayName = "Get Available Binding"))
FMVVMAvailableBinding K2_GetAvailableBinding(const UClass* Class, FMVVMBindingName BindingName, const UClass* Accessor) const;
static FMVVMAvailableBinding GetAvailableBinding(const UClass* Class, FMVVMBindingName BindingName, const UClass* Accessor);
/** @return The AvailableBinding from a field. */
static FMVVMAvailableBinding GetAvailableBindingForField(UE::MVVM::FMVVMConstFieldVariant Variant, const UClass* Accessor);
static FMVVMAvailableBinding GetAvailableBindingForEvent(UE::MVVM::FMVVMConstFieldVariant FieldVariant, const UClass* Accessor);
static FMVVMAvailableBinding GetAvailableBindingForEvent(const UClass* Class, FMVVMBindingName BindingName, const UClass* Accessor);
};
이 설정을 해주면 기존 Property를 Widget의 Parameter에 Binding하는 옵션이 제거된다.
Plugin -> Model View ViewModel -> Allow Binding from Detail View를 비활성화 하면 ViewModel에 대한 Detail 패널 Binding도 비활성화 할 수 있다.
이 설정을 쓰더라도 View Binding Menu를 통해 여전히 Bind를 할 수 있다.
View Binding Menu
UMG Designer -> Window -> View Binding 선택
Add Widget을 통해 View Binding 목록에 추가하고 Bind 진행
Configure
Select Target Widget
View Binding을 추가 할 Widget 선택
Create View Binding Entry
Target Widget 하위에 있는 개별 Property마다 별도의 Bind를 걸 수 있다.
물론 하나의 Property에 여러 Bind를 걸 수도 있다.
Select Widget Property
Target Widget의 변수 및 함수 목록이 표시된다.
이 항목에는 C++로 정의된 UFUNCTION(), UPROPERTY()도 포함된다.
BP에서 정의한 변수나 함수는 자동으로 사용 가능하다.
Select ViewModel Property
Target ViewModel과 Target Property를 선택한다.
Set Bind Direction
Bind Direction을 선택해 Widget과 ViewModel간의 정보가 흐르는 방식을 결정한다.
기본적으로 모든 ViewModel은 PreConstruct와 Construct 사이에 한 번 실행된다.
Bind Direction이 Two Way인 경우에는 One Way Bind만 실행된다.
ViewModel 값이 SetViewModel을 이용해 변경되면 모든 Bind가 실행된다.
Set Execution Mode
Bind 된 Widget 등의 실행 방식을 지정
UENUM()
enum class EMVVMExecutionMode : uint8
{
/** Execute the binding as soon as the source value changes. */
Immediate = 0,
/** Execute the binding at the end of the frame before drawing when the source value changes. */
Delayed = 1,
/** Always execute the binding at the end of the frame. */
Tick = 2,
/** When the binding can be triggered from multiple fields, use Delayed. Else, uses Immediate. */
DelayedWhenSharedElseImmediate = 3 UMETA(DisplayName="Auto"),
};
Use Conversion Function
변수에 대한 직접 Bind 대신 Conversion Function을 채택할 수 있다.
Conversion Function은 ViewModel의 변수를 다른 타입의 Data로 Convert하기 위한 Interface를 제공한다.
Convert 함수를 선택하면 설정할 수 있는 창이 드롭다운 아래 나타난다.
만약 이미 드롭다운 데이터가 있다면 이 기능이 비정상 동작한다.
이 때에는 Clear를 해서 한번 날려주고 하면 잘 된다.
새로운 Conversion Function은 Global 단위 혹은 UserWIdget에 추가될 수 있다.
단, 이 함수는 Event, Network, Deprecated, EditorOnly로 선언되면 안된다.
BP에 표시되어야 하고, 하나의 Parameter와 하나의 Return Value를 가지고 있어야 한다.
Global로 정의되는 경우에는 static으로 선언되어야 한다.
UserWidget에서 정의되는 경우에는 pure 및 const여야 한다.
ViewModel 작업 팁
거대한 하나의 ViewModel 대신 작고 간결한 ViewModel을 권장한다.
이는 UI 디버깅을 하기 훨씬 용이하다.
예를 들어 Ability,Inventory 등으로 구성된 Array를 사용해 RPG에서 Character를 나타내는 ViewModel을 상상하자.
이 ViewModel에 Bind 된 Widget 중 일부를 디버깅 하기 위해서는 전체 Character를 Spawn해 ViewModel의 데이터를 채워줘야 한다.
이 때, 서로 다른 Component로 분할하면 Debug 할 때 Test Data로 더 쉽게 채울 수 있다.
또한 ViewModel은 다른 ViewModel 내부에 중첩하면, 복잡한 데이터 작업에서 유연성을 높일 수 있다.
예를 들어, HP ViewModel과 Attribute ViewModel을 각각 생성하고 이를 Character ViewModel에 중첩할 수 있다.
이 경우, Test에서 개별 Widget은 각자 연관 된 ViewModel에서 Data를 취할 수 있다.
Slate Widget을 캐싱하고 Paint, Layout, 계층 정보 등의 변경점을 관리하는 기능.
Widget의 위와 같은 정보가 변경되지 않으면, 매 Frame마다 Widget을 다시 그리는 대신 Cache를 출력한다.
위 정보들에 유효한 변경사항이 발생하면 Slate가 이를 재 계산하여 다시 그려준다.
Invalidation Box
Child Widget의 Geometry를 Cache 하고 관리하는 UI
해당 Widget의 Geometry가 바뀌지 않는 한 Cache 된 Geometry로 대체되어 CPU 사용량을 크게 줄여준다.
Invalidation Box는 감싸진 UI 뿐 아니라 그 하위 계층의 모든 UI에 대해 Geometry Cache를 진행합니다.
Global Invalidation
SWindow의 Invalidation을 이용해 효과적으로 모든 UI를 Invalidation Box로 Wrapping하는 기능.
이 SWindow에 포함 된 모든 Invalidation Box는 무효화 되고, SWindow의 Invalidation Box만 동작하게 된다.
Slate.EnableGlobalInvalidation을 true로 트리거하여 활성화
void SWidget::Invalidate(EInvalidateWidgetReason InvalidateReason)
{
SLATE_CROSS_THREAD_CHECK();
if (InvalidateReason == EInvalidateWidgetReason::None || !IsConstructed())
{
return;
}
SCOPED_NAMED_EVENT_TEXT("SWidget::Invalidate", FColor::Orange);
// Backwards compatibility fix: Its no longer valid to just invalidate volatility since we need to repaint to cache elements if a widget becomes non-volatile. So after volatility changes force repaint
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Volatility))
{
InvalidateReason |= EInvalidateWidgetReason::PaintAndVolatility;
}
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Prepass))
{
MarkPrepassAsDirty();
InvalidateReason |= EInvalidateWidgetReason::Layout;
}
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::ChildOrder) || !PrepassLayoutScaleMultiplier.IsSet())
{
MarkPrepassAsDirty();
InvalidateReason |= EInvalidateWidgetReason::Prepass;
InvalidateReason |= EInvalidateWidgetReason::Layout;
}
const bool bVolatilityChanged = EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Volatility) ? Advanced_InvalidateVolatility() : false;
if(FastPathProxyHandle.IsValid(this))
{
// Current thinking is that visibility and volatility should be updated right away, not during fast path invalidation processing next frame
if (EnumHasAnyFlags(InvalidateReason, EInvalidateWidgetReason::Visibility))
{
SCOPED_NAMED_EVENT(SWidget_UpdateFastPathVisibility, FColor::Red);
UpdateFastPathVisibility(FastPathProxyHandle.GetProxy().Visibility.MimicAsParent(), FastPathProxyHandle.GetInvalidationRoot_NoCheck()->GetHittestGrid());
}
if (bVolatilityChanged)
{
SCOPED_NAMED_EVENT(SWidget_UpdateFastPathVolatility, FColor::Red);
TSharedPtr<SWidget> ParentWidget = GetParentWidget();
UpdateFastPathVolatility(ParentWidget.IsValid() ? ParentWidget->IsVolatile() || ParentWidget->IsVolatileIndirectly() : false);
ensure(!IsVolatile() || IsVolatileIndirectly() || EnumHasAnyFlags(UpdateFlags, EWidgetUpdateFlags::NeedsVolatilePaint));
}
FastPathProxyHandle.MarkWidgetDirty_NoCheck(InvalidateReason);
}
else
{
#if WITH_SLATE_DEBUGGING
FSlateDebugging::BroadcastWidgetInvalidate(this, nullptr, InvalidateReason);
#endif
UE_TRACE_SLATE_WIDGET_INVALIDATED(this, nullptr, InvalidateReason);
}
}
Retainer Panel
Child Widgets을 유저의 화면에 Render 하기 전에 하나의 Texture로 병합
여기서 Phase는 Render를 시작하는 Frame, Phase Count는 Render가 되는 Frame 주기이다.
예를 들어 위의 경우 0 Frame에서 시작하여 3 Frame 단위로 Retainer Panel의 Child Widgets이 Render 된다.
이러한 기능들은 매 Frame마다 호출되는 UI의 Draw call을 줄여주는 기능을 제공한다.
하지만 Retainer Panel은 다시 그려질 때 큰 overhead를 가지면서, Widget 개개인이 점유하는 Memory가 Invalidation Box보다 크다.
이는 Retainer Panel이 각 Widget의 Invalidation Data 뿐 아니라 고유한 Render Target도 가지기 때문이다.
때문에 UI의 CPU 점유율을 낮추려 한다면 우선 Invalidation Box부터 써야 한다.
그럼에도 Draw Call을 줄이고 싶다면, Retainer Panel을 이용함으로 CPU 사용량을 더 압축할 수 있다.
이는 성능 제약이 빡빡한 저성능 모바일에서 유용다.
How Invalidation works
Widget이 화면에 그려질 때 다음 작업들이 순차적으로 발생한다.
계층 구조 - Slate가 root widget과 그들의 child를 모두 포함한 계층에 따라 widget tree를 생성한다.
Layout - Slate가 Render Transform을 기반으로 Widget의 크기와 스크린 상 위치를 계산한다.
Paint - Slate가 각 Widget의 Geometry를 계산한다.
각 작업들은 수행 할 때 뒤이은 작업을 반드시 수행해야 한다.
예를 들어 계층 구조 작업이 수행되면 반드시 Layout, Paint 작업이 수행되어야 한다.
Invalidation System은 위 과정에서 발생하는 모든 데이터를 Memory에 Cache한다.
Widget이 Invalidation Box를 사용하든, SWidget의 Global Invalidation을 사용하든.
Memory에 Cache된 Widget들은 변경사항이 발생하지 않는 한 재연산을 하지 않는다.
만약 변경사항이 발생하게 되면 Dirty List에 추가되고, 이 List의 Widget들은 다음 Frame에서 재연산이 이루어진다.
Invalidation에서 Cache된 Data를 갱신하게 되는 타입은 대략 다음과 같다.
/**
* The different types of invalidation that are possible for a widget.
*/
enum class EInvalidateWidgetReason : uint8
{
None = 0,
/**
* Use Layout invalidation if your widget needs to change desired size. This is an expensive invalidation so do not use if all you need to do is redraw a widget
*/
Layout = 1 << 0,
/**
* Use when the painting of widget has been altered, but nothing affecting sizing.
*/
Paint = 1 << 1,
/**
* Use if just the volatility of the widget has been adjusted.
*/
Volatility = 1 << 2,
/**
* A child was added or removed. (this implies prepass and layout)
*/
ChildOrder = 1 << 3,
/**
* A Widgets render transform changed
*/
RenderTransform = 1 << 4,
/**
* Changing visibility (this implies layout)
*/
Visibility = 1 << 5,
/**
* Attributes got bound or unbound (it's used by the SlateAttributeMetaData)
*/
AttributeRegistration = 1 << 6,
/**
* Re-cache desired size of all of this widget's children recursively (this implies layout)
*/
Prepass = 1 << 7,
/**
* Use Paint invalidation if you're changing a normal property involving painting or sizing.
* Additionally if the property that was changed affects Volatility in anyway, it's important
* that you invalidate volatility so that it can be recalculated and cached.
*/
PaintAndVolatility = Paint | Volatility,
/**
* Use Layout invalidation if you're changing a normal property involving painting or sizing.
* Additionally if the property that was changed affects Volatility in anyway, it's important
* that you invalidate volatility so that it can be recalculated and cached.
*/
LayoutAndVolatility = Layout | Volatility,
/**
* Do not use this ever unless you know what you are doing
*/
All UE_DEPRECATED(4.22, "EInvalidateWidget::All has been deprecated. You probably wanted EInvalidateWidget::Layout but if you need more than that then use bitwise or to combine them") = 0xff
};
Invalidation은 자주 변경되지 않은 UI에서 최적화된 기능이다.
Widget이 오랫동안 변경되지 않을수록 Slate가 Cache Data를 오래 들고 있고, 그 만큼 CPU 부하를 줄여준다.
이는 특히 MMORPG나 라이브 서비스 게임의 Depth가 있는 메뉴와 같이 규모가 크고 복잡한 UI 작업에서 중요하다.
Volatile Widget
간혹 특정 Widget은 가능한 매 Frame마다 업데이트가 되어야 하는 경우가 있다.
이 경우 Widget은 변경사항이 있을 때마다 매 tick이 Invalidate 된다.
하지만 CPU 부하는 Invalidation을 사용할 때와 동일하게 발생한다.
더불어 Hiearchy를 Cache하기 위해 Memory도 점유하게 된다.
이를 해결하기 위해 자주 사용하는 Widget에 Volatile 선언을 해주는 것이 좋다.
Volatile 선언이 된 Widget과 그 Child Widget들은 Paint Data가 Cache되지 않는다.
비록 Geometry는 매 Frame마다 재계산되어 다시 그려지겠지만, Slate는 직접적인 변경사항이 없는 이상 Layout 계산은 계속해서 Skip한다.
이는 UI를 전반적으로 Invalidation 하고 싶지만, 자주 갱신되는 소수의 Widget으로 인해 이득을 보지 못할 때 유용하다.
개발자가 주의해야 할 점
OnTick/OnPaint 사용 지양
OnTick이나 OnPaint에서 작업을 하게 되면 그 작업이 매 Frame마다 호출하게 됨.
가급적 Event Dispatch나 Delegate를 이용할 것을 권장
Attribute Bind 대신 Event-driven Update 사용
Unreal Engine에서 제공하는 Attribute Bind 역시 매 Frame마다 할당 됨.
이 역시 Event와 Delegate를 이용해 변경사항이 있을 때에만 Widget에 적용 되도록 작업할 것을 권장 함.
Widget Construction
Reduce unused widgets
Widget의 모든 child는 시각화 여부와 무관하게 항상 construct 됨.
이는 Render가 되지 않더라도 Loading time, Construction time, Memory를 점유한다는 의미.
때문에 당장 사용되지 않는 Widget들은 Hierarchy에서 제거하는 것이 옳다.
Break complex widgets
특히 Main System에서 사용되는 Widget의 경우 실제로 표시되는 것은 몇 없지만 Cild가 수 천개씩 있는 경우가 있다.
이러한 Widget을 한꺼번에 Load하게 되면 불필요한 Child로 인해 Loading이 지연되고 Memory를 점유하게 된다.
때문에 일정 규모 이상의 Widget은 어느정도 Child Widget을 세분화 하는 것이 좋다.
예를 들어, 다음과 같이 Widget을 구분하여 작업 방향성을 정할 수 있다.
항상 표시되는 Widget
BaseWidget과 같이 Load하면서 화면에 바로 출력
가능한 빨리 표시되어야 하는 Widget
당장 사용하지 않을 수도 있지만 반응성이 높아야 함.
BaseWidget과 같이 Load 하되, 화면에는 출력하지 않고 Visibility로 컨트롤.
조금 늦게 표시되어도 괜찮은 Widget
가끔 사용되거나 아얘 사용되지 않는 경우도 있음.
BaseWidget과 별개로 필요할 때마다 Async Load.
이러한 방식은 Memory를 크게 절약할 뿐 아니라 Load 할 때의 CPU 영향도 줄일 수 있다.
Layout
CanvasPanel 사용 지양
CanvasPanel은 좌표 평면과 Widget 별 Anchor를 이용해 다른 Widget의 위치를 지정할 수 있는 강력한 Widget이다.
이는 Widget을 원하는 위치에 정확하게 지정하면서, 동시에 Screen의 외각을 기준으로 Widget의 위치를 유지할 수 있다.
하지만 그와 동시에 높은 성능을 요구하는 Widget이기도 하다.
Slate의 Draw call은 Widget의 Layer ID별로 발생한다.
VerticalBox나 HorizontalBox등의 다른 Container Widget들은 Child Widget의 Layer ID를 통합한다.
하지만 CanvalsPanel은 Child Widget이 필요할 때 다른 Widget 위에 Render 될 수 있도록 ID를 증가시킨다.
결과적으로 CanvasPanel은 단기로 여러 개의 Draw call을 발생시켜 높은 CPU 사용량을 요구하게 된다.
비록 CanvasPanel보다 용처가 제한적이지만, OverlayPanel 역시 Draw call을 증가시킨다.
GridPanel은 Slot마다 Layer를 직접 지정할 수 있지만, 보통은 Child를 Iterate하며 LayerID를 새로 부여한다.
ScrollBox는 Scroll Bar가 Layer ID를 증가시킨다.
Border 역시 Layer ID를 증가시킨다.
HUD나 Menu System의 Root Widget으로 CanvasPanel을 사용하는 것은 크게 문제가 되지 않는다.
이 경우에는 상세한 위치 조정이나 복잡한 Z-Order 배치가 필요할 가능성이 높기 때문이다.
다만 Template 속성이 있는 Widget은 CanvasPanel 위에서 작업하는 것을 지양해야 한다.
TextBox, Custom Button과 같이 다른 Widget의 구성요소로 사용되는 Custom Widget들
또는 CanvasPanel을 다수의 Layer에서 과도하게 사용하면, 최종 Layer를 혼돈하기 쉽다.
일반적으로 하나의 요소로 구성된 Widget은 Canvas Panel로 감쌀 필요가 전혀 없다.
또한 HUD나 Menu 같은 경우에도, Overlay나 SizeBox를 HorizontalBox, VerticalBox, GridBox와 같이 사용하여 CanvasPanel 사용을 대체할 수 있다.
SizeBox 대신 가능한 Spacer 사용
SizeBox는 자신의 크기를 계산하고 Render하는데 다양한 값을 사용한다.
만약 Widget이 특정 Width와 Height를 고정적으로 가진다면, Spacer가 훨씬 가볍다.
ScaleBox와 SizeBox를 같이 사용하지 않기
ScaleBox와 SizeBox를 같이 사용하면 매 Frame마다 각자 서로의 Size를 오가며 Update하는 Loop에 빠지게 된다.
이 둘에 의존하기 보다는 Layout이 Content의 Native Size에 따라 동작하도록 만드는 것이 적절하다.
RichTextWidget 사용 지양
RichTextWidget은 강력한 Format 지정 기능을 제공하는 만큼 표준 TextBox보다 훨씬 무겁다.
만약 RichTextWidget의 모든 기능이 필요한게 아니라 디자인적인 표현만이 필요하다면, 원하는 외형을 표현할 수 있는 Font를 제작하여 TextWidget에서 사용하는 것이 훨씬 가볍다.
Visibility
화면 출력되어야 하는 Widget이 Visible인 경우, 클릭 시 Click 반응이 동작해 오버헤드가 발생 함.
visible 대신 HitTestInvisible, SelfHitTestInvisible을 사용할 것을 권장
Hidden의 경우 화면에 보이지 않더라도 영역을 차지하기 위해 Layout Space를 사용 함.
Layout Space를 사용하면 Prepass 계산이 매 Frame마다 수행 됨.
완전히 화면에서 숨기면서 영역을 차지할 필요가 없다면 Collapse를 사용하는 것을 권장.
Texture
Merged Texture
Widget에서 여러 개의 Texture를 사용하는 경우, Texture의 갯수만큼 Draw call이 발생한다.
때문에 가능하면 Merge Texture 1개를 사용하는 것이 좋다.
Widget의 Sprite를 사용하면 Merged Texture를 사용하거나 편집할 수 있다.
Local 단위에서 호출하여 Remote로 연결된 1개 이상의 Machinge에서 실행되는 함수
Return이 없는 단방향 함수 호출이 특징
RPC는 주로 일시적이거나, 외형적으로 드러나는 Unreliable Gameplay Event에 사용된다.
사운드 재생
Particle 생성
Animation 재생
RPC는 Replicated/ReplicatedUsing 선언이 된 Property의 Replication을 보완하는 중요한 기능이다.
RPC를 호출하려면 다음 2가지 조건이 성립되어야 한다.
Actor나 Actor Component일 것.
RPC를 호출하는 Object가 Replicate되어 있을 것.
마지막으로 RPC를 잘 사용하기 위해서는 Ownership을 잘 이해하는 편이 좋다.
Type
Client
이 Actor에 대해 Client Connection을 소유한 Client에서 실행되는 Unicast RPC
#pragma once
#include "DerivedActor.generated.h"
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
// Client RPC Function
UFUNCTION(Client)
void ClientRPC();
}
#include "DerivedActor.h"
ADerivedActor::ADerivedActor(const class FPostConstructInitializeProperties & PCIP) : Super(PCIP)
{
bReplicates = true;
}
void ADerivedActor::ClientRPC_Implementation()
{
// This log will print on every machine that executes this function.
UE_LOG(LogTemp, Log, TEXT("ClientRPC executed."))
}
// Call from client to run on server
ADerivedClientActor* MyDerivedClientActor;
MyDerivedClientActor->ClientRPC();
Execution Matrix
Server
해당 Actor를 소유하는 Client에서 호출하여 Server에서 실행되는 Unicast RPC
#pragma once
#include "DerivedActor.generated.h"
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
// Server RPC Function
UFUNCTION(Server)
void ServerRPC();
}
#include "DerivedActor.h"
ADerivedActor::ADerivedActor(const class FPostConstructInitializeProperties & PCIP) : Super(PCIP)
{
bReplicates = true;
}
void ADerivedActor::ServerRPC_Implementation()
{
// This function only executes if ServerRPC_Validate returns true.
// This log will print on every machine that executes this function.
UE_LOG(LogTemp, Log, TEXT("ServerRPC executed."))
}
// Call from client to run on server
ADerivedClientActor* MyDerivedClientActor;
MyDerivedClientActor->ServerRPC();
Execution Matrix
WithValidation
Server RPC에서만 사용할 수 있는 Specifier
Server RPC의 신뢰성과 Network Policy를 구현할 수 있다.
#pragma once
#include "DerivedActor.generated.h"
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
UPROPERTY(Replicated)
int32 Health;
int32 MAXHEALTH = 100;
// Server Unreliable RPC Function
UFUNCTION(Server, Unreliable, WithValidation)
void ServerUnreliableRPC(int32 RecoverHealth);
}
Validate 함수는 내부 로직을 통해 RPC 함수를 Server에서 실행할지 여부를 판단한다.
그렇기에 Validate Specifier가 선언된 Server RPC가 실행될 때 Validate 함수가 가장 먼저 호출된다.
만약 Validate 함수에서 false를 반환하면, 해당 Server RPC를 전송한 Client는 Server로부터 연결이 끊긴다.
NetMulticast
Server에서 호출
호출한 Actor와 Relevant한 모든 Client에서 실행되는 Multicast RPC
Client에서도 호출할 수 있으나 Local에서만 동작한다.
#pragma once
#include "DerivedActor.generated.h"
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
// Multicast RPC Function
UFUNCTION(NetMulticast)
void MulticastRPC();
}
#include "DerivedActor.h"
ADerivedActor::ADerivedActor(const class FPostConstructInitializeProperties & PCIP) : Super(PCIP)
{
bReplicates = true;
}
void ADerivedActor::MulticastRPC_Implementation()
{
// This log will print on every machine that executes this function.
UE_LOG(LogTemp, Log, TEXT("MulticastRPC executed."))
}
// Call from server to run on server and all relevant clients
ADerviedServerActor* MyDerivedServerActor;
MyDerievedServerActor->MulticastRPC();
Execution Matrix
Reliability
Client/Server/NetMulticast와 같이 사용되는 Specifier
Reliable
RPC 수신자로부터 ACK를 받지 못하면 RPC를 재전송한다.
다음 RPC 호출은 앞선 RPC의 ACK를 수신할 때 실행된다.
순서대로 도착하는 것을 보장해준다.
Unreliable
RPC Packet이 Drop되면 실행되지 않는다.
도착 순서를 보장하지 않는다.
Send Policy
ERemoteFunctionSendPolicy를 지정하여 RPC의 전송 순서를 명시적으로 조정할 수 있다.
enum class ERemoteFunctionSendPolicy
{
/** Unreliable multicast are queued. Everything else is send immediately */
Default,
/** Bunch is send immediately no matter what */
ForceSend,
/** Bunch is queued until next actor replication, no matter what */
ForceQueue,
};
Send Policy 조절은 NetDriver::ProcessRemoteFunctionForChannel을 통해 가능하다.
/** Process a remote function on given actor channel. This is called by ::ProcessRemoteFunction.*/
ENGINE_API void ProcessRemoteFunctionForChannel(
UActorChannel* Ch,
const class FClassNetCache* ClassCache,
const FFieldNetCache* FieldCache,
UObject* TargetObj,
UNetConnection* Connection,
UFunction* Function,
void* Parms,
FOutParmRec* OutParms,
FFrame* Stack,
const bool IsServer,
const ERemoteFunctionSendPolicy SendPolicy = ERemoteFunctionSendPolicy::Default);
void UNetDriver::ProcessRemoteFunctionForChannel(
UActorChannel* Ch,
const FClassNetCache* ClassCache,
const FFieldNetCache* FieldCache,
UObject* TargetObj,
UNetConnection* Connection,
UFunction* Function,
void* Parms,
FOutParmRec* OutParms,
FFrame* Stack,
const bool bIsServer,
const ERemoteFunctionSendPolicy SendPolicy)
{
EProcessRemoteFunctionFlags UnusedFlags = EProcessRemoteFunctionFlags::None;
ProcessRemoteFunctionForChannelPrivate(Ch, ClassCache, FieldCache, TargetObj, Connection, Function, Parms, OutParms, Stack, bIsServer, SendPolicy, UnusedFlags);
}
Default
RPC가 bunch에 직렬화 된다.
Bunch는 다음 Frame 마지막에 NetUpdate에서 전송된다.
ForceSend
RPC가 NetDriver::PostTickDispatch에서 trigger되면 bunch에 즉시 직렬화 되고 Network에 전송된다.
tick의 나머지 부분이 동작하는 도중에 trigger 되면, Default로 동작한다.
이 특별한 RPC 최적화 기법은 아래 조건 하에서 동작한다.
Replication Graph나 Iris를 사용할 때에만 동작한다.
NetWroldTickTime에서 호출된 RPC에서 동작.
수신한 패킷되고 수신한 RPC가 실행된다.
ForceQueue
Network Update이 마무리 될 때 Bandwidth가 남아 있다면 Bunch에 직렬화된다.
#pragma once
#include "DerivedActor.generated.h"
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
// Property to replicate
UPROPERTY(Replicated)
uint32 Health;
// Derived Actor constructor
ADerivedActor(const class FPostConstructInitializeProperties & PCIP);
// Override Replicate Properties function
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
해당 Actor의 bReplicates 옵션을 활성화
생성자나 BP 옵션에서 제어
GetLifetimeReplicatedProps() 함수를 override하여 DOREPLIFETIME 매크로를 선언
#include "DerivedActor.h"
#include "Net/UnrealNetwork.h"
ADerivedActor::ADerivedActor(const class FPostConstructInitializeProperties & PCIP) : Super(PCIP)
{
bReplicates = true;
}
void ADerivedActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
// Call the Super
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// Add properties to replicated for the derived class
DOREPLIFETIME(ADerivedActor, Health);
}
위와 같은 방식으로 Object 또한 Replicate를 할 수 있다.
Network를 거쳐서 참조되는 Object는 반드시 Network 기능이 지원되어야 한다.
이를 확인하려면 UObject::IsSupportedForNetworking() 함수를 이용한다.
Network Replicated Reference
Replicated Actor
Replicated Component
Stably-named 하지만 Replicated 하지 않은 Actor나 Component
Package로부터 Load 된 UObject(Actor, Component도 아닌)
Stably-named Object
Server와 Client 양쪽에 같은 이름으로 존재하는 Object를 지칭한다.
Actor가 Gameplay 중 Spawn되지 않고 Package로부터 직접 Load 되면 Stably-named하다.
Actor Component는 다음 케이스일 경우 Stably-named 하다.
Pacakge로부터 직접 load 된 경우
간단한 생성자 호출로 추가 된 경우
UActorComponent::SetNetAddressable로 마킹 된 경우
Component의 이름을 Server와 Client가 모두 명확하게 알고 있는 경우
AActor C++ 생성자에서 추가되는 Component들이 그 대표적인 예시이다.
ReplicatedUsing
"OnRep_"으로 시작하는 함수를 지정해Replicate 될 때마다 특정 행동을 부여할 수 있는 Specifier
#pragma once
#include "DerivedActor.generated.h"
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
// Replicated Property using OnRep_Value
UPROPERTY(ReplicatedUsing=OnRep_Value)
int32 HealthValue1;
// Replicated Property using OnRep_ConstRef
UPROPERTY(ReplicatedUsing=OnRep_ConstRef)
int32 HealthValue2;
// Replicated Property using OnRep_NoParam
UPROPERTY(ReplicatedUsing=OnRep_NoParam)
int32 HealthValue3;
// Signature to pass copy of the last value
UFUNCTION()
void OnRep_Value(int32 LastHealthValue);
// Signature to pass const reference
UFUNCTION()
void OnRep_ConstRef(const int32& LastHealthValue);
// Signature to pass no parameter
UFUNCTION()
void OnRep_NoParam();
// Derived Actor constructor
ADerivedActor(const class FPostConstructInitializeProperties & PCIP);
};
ReplicatedUsing에 연결 될 함수들은 UFUNCTION()으로 선언되어 있어야 한다.
또한 경우에 따라서 Parameter를 받을 수도 있다.
Parameter에 전달되는 값은 Replicated Property가 변경되기 이전의 값이다.
변경된 이후의 값은 Property가 직접 들고 있다.
#include "DerivedActor.h"
#include "Net/UnrealNetwork.h"
ADerivedActor::ADerivedActor(const class FPostConstructInitializeProperties & PCIP) : Super(PCIP)
{
bReplicates = true;
}
void ADerivedActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
// Call the Super
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// Add properties to replicated for the derived class
DOREPLIFETIME(ADerivedActor, HealthValue1);
DOREPLIFETIME(ADerivedActor, HealthValue2);
DOREPLIFETIME(ADerivedActor, HealthValue3);
}
void ADerivedActor::OnRep_Value(int32 LastHealthValue)
{
UE_LOG(LogTemp, Log, TEXT("OnRep_Value with value. Last value: %d"), LastHealthValue)
// Add custom OnRep logic
}
void ADerivedActor::OnRep_ConstRef(const int32& LastHealthValue)
{
UE_LOG(LogTemp, Log, TEXT("OnRep_ConstRef with const ref. Last value: %d"), *LastHealthValue)
// Add custom OnRep logic
}
void ADerivedActor::OnRep_NoParam()
{
UE_LOG(LogTemp, Log, TEXT("OnRep_NoParam with no parameter."))
// Add custom OnRep logic
}
RepNotify는 C++과 BP에서 조금 다르게 동작한다.
BP로 RepNotify가 선언되어 있다면, Replicated Property에 대해 Set 함수가 호출될 때 RepNotify도 호출된다.
하지만 기본적으로 Replicated Property의 Reference를 갖는 Function, Macro에서는 값이 변경되더라도 RepNotify가 호출되지 않는다.
NotReplicated
Replicated 되는 Actor나 Struct 내에서 특정 Property를 Replicate 되지 않도록 한다.
#pragma once
#include "DerivedActor.generated.h"
USTRUCT()
struct FMyStruct
{
GENERATED_BODY()
UPROPERTY()
int32 ReplicatedProperty;
// Not Replicated even though encompassing struct is Replicated
UPROPERTY(NotReplicated)
int32 NotReplicatedProperty;
};
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
UPROPERTY(Replicated)
FMyStruct ReplicatedStruct;
// Derived Actor constructor
ADerivedActor(const class FPostConstructInitializeProperties & PCIP);
};
5.4버전 엔진 코드 기준으로는 NotReplicated Property는 GetLifetimeReplicatedProps() 내에서 DISABLE_REPLICATED_PROPERTY 선언을 해주어야 한다.
Replicated Property는 한번 등록되면 Lifetime 동안 해제할 수 없다.
기본적으로 Replicated Property는 값이 바뀔 때에 Replicate 된다.
그 즉슨, 값이 바뀌지 않으면 Replicate 되지 않고 Bandwidth를 점유하지 않는다.
때문에 Replicate 조건을 부여하여 불필요한 Replicate를 줄이면 이는 성능 향상으로 이어진다.
Replication Condition
GetLifetimeReplicatedProps() 함수에서 DOREPLIFETIME() 매크로 대신 DOREPTIME_CONDITION() 매크로를 사용한다.
#define DOREPLIFETIME_CONDITION(c,v,cond) \
{ \
static_assert(cond != COND_NetGroup, "COND_NetGroup cannot be used on replicated properties. Only when registering subobjects"); \
FDoRepLifetimeParams LocalDoRepParams; \
LocalDoRepParams.Condition = cond; \
DOREPLIFETIME_WITH_PARAMS(c,v,LocalDoRepParams); \
}
이는 Replicated 뿐 아니라 ReplicatedUsing Specifier도 적용된다.
#include "DerivedActor.h"
#include "Net/UnrealNetwork.h"
void ADerivedActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
// Call the Super
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// Add property replication with a condition
DOREPLIFETIME_CONDITION(ADerivedActor, Health, COND_OwnerOnly);
}
RepNotify Condition
Replication System은 Replicate 뿐 아니라 RepNotify 호출에 대해서도 조건을 부여할 수 있다.
이를 위해서는 DOREPLIFETIME_CONDITION_NOTIFY가 필요하다.
/** Allows gamecode to specify RepNotify condition: REPNOTIFY_OnChanged (default) or REPNOTIFY_Always for when repnotify function is called */
#define DOREPLIFETIME_CONDITION_NOTIFY(c,v,cond,rncond) \
{ \
static_assert(cond != COND_NetGroup, "COND_NetGroup cannot be used on replicated properties. Only when registering subobjects"); \
FDoRepLifetimeParams LocalDoRepParams; \
LocalDoRepParams.Condition = cond; \
LocalDoRepParams.RepNotifyCondition = rncond; \
DOREPLIFETIME_WITH_PARAMS(c,v,LocalDoRepParams); \
}
코드를 보면 알 수 있듯이, DOREPLIFETIME_CONDITION_NOTIFY에서는 RepNotify 조건 외에 Replicate 조건도 지정할 수 있다.
#include "DerivedActor.h"
#include "Net/UnrealNetwork.h"
void ADerivedActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
// Call the Super
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// Add property replication with a condition
/** Use this to always execute RepNotify
* Associated OnRep called on client every time property replicates
*/
DOREPLIFETIME_CONDITION_NOTIFY(ADerivedActor, Health, COND_OwnerOnly, REPNOTIFY_Always);
/** Use this to only execute RepNotify when property changes
* Associated OnRep called on client only when property changes
*/
DOREPLIFETIME_CONDITION_NOTIFY(ADerivedActor, Health, COND_OwnerOnly, REPNOTIFY_OnChanged);
}
Custom Condition
다음 작업을 통해 엔진에서 제공하는 조건 외의 조건으로 Replicate를 조절할 수 있다.
Replication Condition을 COND_Custom으로 지정
bool 타입 혹은 이를 반환하는 함수를 PreReplication에 등록
#pragma once
#include "DerivedActor.generated.h"
UCLASS()
class ADerivedActor : public AActor
{
GENERATED_BODY()
public:
UPROPERTY(Replicated)
int32 Health;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// Derived Actor constructor
ADerivedActor(const class FPostConstructInitializeProperties & PCIP);
virtual void PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker) override;
// Custom Replication Condition override function
bool IsInvincible();
};
PreReplication에 Custom Condition을 지정할 때에는 DOREPLIFETIME_ACTIVE_OVERRIDE 매크로를 사용한다.
// Built on Compile time and Disable to work with Array
#define DOREPLIFETIME_ACTIVE_OVERRIDE_FAST(c,v,active) \
{ \
static_assert(ValidateReplicatedClassInheritance<c, ThisClass>(), #c "." #v " is not accessible from this class."); \
UE::Net::Private::FNetPropertyConditionManager::SetPropertyActiveOverride(ChangedPropertyTracker, this, (int32)c::ENetFields_Private::v, active); \
}
// Built on Compile time and Enable to Work with Array
#define DOREPLIFETIME_ACTIVE_OVERRIDE_FAST_STATIC_ARRAY(c,v,active) \
{ \
static_assert(ValidateReplicatedClassInheritance<c, ThisClass>(), #c "." #v " is not accessible from this class."); \
for (int32 i = 0; i < (int32)c::EArrayDims_Private::v; ++i) \
{ \
UE::Net::Private::FNetPropertyConditionManager::SetPropertyActiveOverride(ChangedPropertyTracker, this, (int32)c::ENetFields_Private::v##_STATIC_ARRAY + i, active); \
} \
}
// Built on Runtime and Enable to Work with Array
#define DOREPLIFETIME_ACTIVE_OVERRIDE(c,v,active) \
{ \
static_assert(ValidateReplicatedClassInheritance<c, ThisClass>(), #c "." #v " is not accessible from this class."); \
static FProperty* sp##v = GetReplicatedProperty(StaticClass(), c::StaticClass(),GET_MEMBER_NAME_CHECKED(c,v)); \
for (int32 i = 0; i < sp##v->ArrayDim; i++) \
{ \
UE::Net::Private::FNetPropertyConditionManager::SetPropertyActiveOverride(ChangedPropertyTracker, this, sp##v->RepIndex + i, active); \
} \
}
이제 Health는 IsInvincible()이 false일 때에만 Replicate된다.
#include "DerivedActor.h"
#include "Net/UnrealNetwork.h"
ADerivedActor::ADerivedActor(const class FPostConstructInitializeProperties & PCIP) : Super(PCIP)
{
bReplicates = true;
}
void ADerivedActor::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const
{
// Call the Super
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// Add properties to replicated for the derived class
DOREPLIFETIME_CONDITION(ADerivedActor, Health, COND_Custom);
}
/* Function where the Custom condition is registered. */
void ADerivedActor::PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker)
{
// Call the Super
Super::PreReplication(ChangedPropertyTracker);
/* Use a custom property replication condition In this case, a function IsInvincible() If the actor is invincible, don't replicate Health */
DOREPLIFETIME_ACTIVE_OVERRIDE(ADerivedActor, Health, !IsInvincible());
}
bool IsInvincible()
{
bool bIsInvincible = false;
// Custom logic to determine invincibility...
return bIsInvincible;
}
Custom Condition은 매우 편리해 보이지만 2가지 큰 이유로 자주 사용하지 않는다.
첫째, 작업 시간과 소모 리소스가 크다
둘째, Connection 기준을 변경할 수 없다.
ELifetimeCondition
DOREPLIFETIME_CONDITION(_NOTIFY) 매크로의 3번째 parameter로 전달하는 값들.
/** Secondary condition to check before considering the replication of a lifetime property. */
UENUM(BlueprintType)
enum ELifetimeCondition : int
{
COND_None = 0 UMETA(DisplayName = "None"), // This property has no condition, and will send anytime it changes
COND_InitialOnly = 1 UMETA(DisplayName = "Initial Only"), // This property will only attempt to send on the initial bunch
COND_OwnerOnly = 2 UMETA(DisplayName = "Owner Only"), // This property will only send to the actor's owner
COND_SkipOwner = 3 UMETA(DisplayName = "Skip Owner"), // This property send to every connection EXCEPT the owner
COND_SimulatedOnly = 4 UMETA(DisplayName = "Simulated Only"), // This property will only send to simulated actors
COND_AutonomousOnly = 5 UMETA(DisplayName = "Autonomous Only"), // This property will only send to autonomous actors
COND_SimulatedOrPhysics = 6 UMETA(DisplayName = "Simulated Or Physics"), // This property will send to simulated OR bRepPhysics actors
COND_InitialOrOwner = 7 UMETA(DisplayName = "Initial Or Owner"), // This property will send on the initial packet, or to the actors owner
COND_Custom = 8 UMETA(DisplayName = "Custom"), // This property has no particular condition, but wants the ability to toggle on/off via SetCustomIsActiveOverride
COND_ReplayOrOwner = 9 UMETA(DisplayName = "Replay Or Owner"), // This property will only send to the replay connection, or to the actors owner
COND_ReplayOnly = 10 UMETA(DisplayName = "Replay Only"), // This property will only send to the replay connection
COND_SimulatedOnlyNoReplay = 11 UMETA(DisplayName = "Simulated Only No Replay"), // This property will send to actors only, but not to replay connections
COND_SimulatedOrPhysicsNoReplay = 12 UMETA(DisplayName = "Simulated Or Physics No Replay"), // This property will send to simulated Or bRepPhysics actors, but not to replay connections
COND_SkipReplay = 13 UMETA(DisplayName = "Skip Replay"), // This property will not send to the replay connection
COND_Dynamic = 14 UMETA(Hidden), // This property wants to override the condition at runtime. Defaults to always replicate until you override it to a new condition.
COND_Never = 15 UMETA(Hidden), // This property will never be replicated
COND_NetGroup = 16 UMETA(Hidden), // This subobject will replicate to connections that are part of the same group the subobject is registered to. Not usable on properties.
COND_Max = 17 UMETA(Hidden)
};
ELifetimeRepnotifyCondition
DOREPLIFETIME_CONDITION_NOTIFY 매크로의 4번째 parameter로 전달되는 값들
enum ELifetimeRepNotifyCondition
{
REPNOTIFY_OnChanged = 0, // Only call the property's RepNotify function if it changes from the local value
REPNOTIFY_Always = 1, // Always Call the property's RepNotify function when it is received from the server
};
SubObject
ReplicateSubobjects
class AMyActor : public AActor
{
UPROPERTY(Replicated)
UMySubObjectClass* MySubObject;
}
class UMySubObjectClass : public UObject
{
UPROPERTY(Replicated)
int32 Counter = 0;
}
void AMyActor::CreateMyClass()
{
MySubObject = NewObject<UMySubObjectClass>();
MySubObject->Counter = 10;
}
void AMyActor::ReplicateSubobjects(...)
{
Super::ReplicateSubobjects(...);
Channel->ReplicateSubobject(MySubObject); // Becomes a subobject here
}
Channel에 Subobject를 등록하지 않는다면, Client에서 해당 Subobject는 항상 null일 것이다.
Subobject를 Replicate 하더라도 그 안의 Property를 Replicate하려면 Specifier 선언을 따로 해줘야 한다.
Registered Subobject
Subobject를 Owning Actor/ActorComponent List에 등록
List에 등록 된 Object들은 Actor Channel에 의해 자동으로 replicate 된다.
등록하 때 ELifetimeCondition을 통해 Replicate condition을 부여할 수 있다.
AddReplicatedSubObject 함수는 ReadyForReplication이나 BeginPlay, 또는 Subobject를 생성할 때 호출한다.
이 중 ReadyForReplication에서 함수 호출 시 주의할 점이 있따.
ActorComponent에서 ReadyForReplication 함수는 InitComponent와 BeginPlay 사이에 호출된다.
즉슨, Component를 이 함수 안에서 등록하게 되면 BeginPlay에서는 RPC를 수행할 수 있다는 의미이다.
Subobject를 수정하거나 삭제 할 때에는 반드시 RemoveReplicateSubObject 함수를 호출해야 한다.
해당 함수를 호출하지 않고 수정/삭제 시, Object의 Destruction 과정에서 한번 더 Destroy가 Mark된다.
이는 GC가 동작할 때 Crash를 유발할 가능성이 있다.
이 부분의 코드를 수정 할 때, net.SubObjects.CompareWithLegacy를 Console Command로 설정하면 Runtime에서 Registered SubObjectList와 이전 함수를 비교할 수 있다.
차이점이 감지되면 ensure가 발생한다.
Components
Replicated Component도 기본적으로 Subobject와 동일하다.
Component의 경우에는 AllowActorComponentToReplicate 함수를 override 한다.
이 때 각 Component의 Replicate Condition은 내부 조건문에 맞춰 판정, 반환해야 한다.
만약 BeginPlay가 호출 된 이후에 Component의 ReplicateCondition을 바꾸고 싶다면, SetReplicatedComponentNetCondition 함수를 이용한다.
Owning Component List는 Condition이 확인되기 전에 각 Connection에 Replicate 되어야 한다.
예를 들어 Componenet가 SkipOwner인 경우, SubObject가 OwnerOnly이더라도 Owner에게 Replicate 되지 않는다.
ELifetimeCondition AMyWeaponClass::AllowActorComponentToReplicate(const UActorComponent* ComponentToReplicate) const
{
// Do not replicate some components while the object is on the ground.
if (!bIsInInventory)
{
if (IsA<UDamageComponent>(ComponentToReplicate))
{
return COND_Never;
}
}
Super::AllowActorComponentToReplicate(ComponentToReplicate);
}
void AMyWeaponClass::OnPickup()
{
// Now replicate the component to all
SetReplicatedComponentNetCondition(UDamageComponent, COND_None);
bIsInInventory = true;
}
Complex Replication Condition
NetConditionGroupManager와 COND_NetGroup을 이용해 Replicate Condition을 새로 만들 수 있다.
이는 Subobject와 PlayerController가 여러 Group에 동시에 속해 있을 때, 이 중 하나의 Group에만 속해 있어도 Replicate된다.