정작 작업은 30일에 했는데 일지를 완전 새까맣게 잊고 있다가 31일 저녁에서야 일지를 적어봅니다.

일지 적고 일주일 정도 게임모드와 RPC 관련해서 문서를 읽었습니다.

함수 콜이 어떤 순서로 되는지는 대략적으로 눈에 넣었는데 정작 카메라를 어떻게 지정하고 어떻게 붙는지는 파악이 안되더라구요.

결국 코드를 다시 읽어야 하나 고민하고 있던게 저번주 금요일(22일)이었습니다.

 

23일에 운동을 할까 하다까 몸이 좀 안좋아 그냥 던지고 쉬었는데 일요일에 열이 확 오르더라구요.

그렇습니다. 코로나였습니다.

코로나 이후에는 크게 코드에 손을 못댄 것 같습니다.

일요일은 한참 열이 올라 누워 있었고 이후에는 일이 바쁘게 움직여서 문서만 깨작깨작 봤습니다.

 

그러다 토요일 즈음에 약을 다 먹고 정상 생활 궤도로 재진입 하기 위해 코드를 다시 보았고, 별로 좋아하지는 않지만 에라 모르겠다는 심정으로 입문서를 보고 따라 만들어봤습니다.

이 과정에서 Level에 다 만들어놓은 Mode를 지정하지 않아서 캐릭터 카메라가 제대로 동작 안한다는 것을 깨달았습니다.

 

Mode 지정 하니까 T자 캐릭터가 스폰이 되고 카메라도 지정한 위치에서 보이더라구요.

일단 여기까지 작업을 했습니다.

이 뒤에는 결국 Mode 구조가 문제가 아니라는 것을 알았으니 Input 작업을 시작해서 상하좌우 이동을 만들고, 그 다음 대쉬와 점프를 넣어볼 예정입니다.

여기까지 하면 그 뒤에는 애니메이션을 보면서 추가 기능을 구현하고, 상호작용 넣으면서 다채로운 캐릭터 이동을 먼저 만들어놓을 계획입니다.

'개발일지 > 코어 플레이 개발' 카테고리의 다른 글

22년 08월 10일 개발일지  (0) 2022.08.10
22년 08월 07일 개발 일지  (0) 2022.08.07
22년 07월 17일 개발일지  (0) 2022.07.17
22년 07월 16일 개발일지  (0) 2022.07.16
22년 07월 15일 개발일지  (0) 2022.07.15

작업사항부터 정리를 하자면

  1. 더미 프로젝트에 있는 캐릭터, 애니메이션을 본 프로젝트에 이식
  2. 예시로 RPG 캐릭터 개발을 먼저 하기 위해 관련 GameMode, GameState, PlayerController, Character, PlayerState, AnimInistance 생성 및 연결.
  3. 세션 구조 공부 선행 예정

이렇게 됩니다.

 

우선 에셋 이식.

이게 정말 개 거지같은 작업이었습니다.

이전에 언급 했듯이 UE5의 Animation Retarget은 IK 리타겟과 IK 릭을 이용해야만 합니다.

한 프로젝트 내에서는 이 방식이 문제가 없었는데 다른 프로젝트로 애셋을 복붙해서 열어보니 경로가 바뀌어 Skeletal Mesh를 찾지 못하였습니다.

여기서 아주 큰 문제가 발생을 하는데, 외부에서 옵션을 통해 Skeletal Mesh를 새로 지정하는 방법이 없습니다.

Animation을 하나하나 열어서 대체 Skeletal Mesh를 지정해야 합니다.

그리고 저는 OpenWorldAnimSet이 있었고 이게 한 2, 300개 정도 Animation을 지원합니다.

모든 Animation을 다 합치면 도합 400개가 될 것 같네요.

이걸 3시간에 걸쳐 하나하나 열어서 대체 Skeletal Mesh를 지정하였습니다.

다시는 하고 싶지 않네요.

작업을 다 하고 혹시 다른 프로젝트의 IK 릭과 리타겟을 사용할 수 있는지 확인했으나 경로가 지원되지 않았습니다.

꼼짝없이 수기작업을 할 수밖에 없었던거죠.

 

개인적으로 최선은 범위를 지정해서 대체 Skeletal Mesh를 지정하는 옵션이 추가가 되는 것입니다.

하지만 그렇지 못하다면, 프로젝트에 에셋을 직접 연결해서 사용하는 것을 추천드립니다.

단, 이 방법은 제가 이전에 구상했던 GitHub로 관리하는 Repository 구조로는 대응이 안됩니다.

이 점은 기억하시기 바랍니다.

 

애니메이션을 연결한 뒤에 실제로 동작을 하는지 AnimInstance를 생성해서 하나의 Animation을 지정하고, Mode 등을 지정해서 재생 됨을 확인했습니다.

이 시기에 한가지 중요한 사실을 깨달았습니다.

AnimInstance 작업을 하더라도 GameMode 작업을 하지 않으면 카메라 조절이나 조작을 못한다는 점이었습니다.

그래서 GameMode쪽 공부를 먼저 선행하고자 합니다.

GameMode와 GameState가 어떻게 동작하는지.

PlayerController와 Character, PlayerState가 어떻게 동작하는지.

GameSession은 무엇인지.

이런 것들에 대한 개념을 확립해서 정리를 하고, 기본 기능을 구현한 뒤 Animation 기능 구현을 할 예정입니다.

물론 RPC를 사용해 Dedicate Server-Client 구조까지 염두를 하고자 합니다.

시간이 오래 거릴 것 같은데 이걸 해야 나중에 재작업을 하거나 관련 작업을 할 때 더 좋은 구조를 구상할 수 있을 것 같습니다.

'개발일지 > 코어 플레이 개발' 카테고리의 다른 글

22년 08월 07일 개발 일지  (0) 2022.08.07
22년 07월 31일 개발 일지  (0) 2022.07.31
22년 07월 16일 개발일지  (0) 2022.07.16
22년 07월 15일 개발일지  (0) 2022.07.15
22년 07월 10일 개발일지  (0) 2022.07.10

어제 1개의 애셋을 Retarget 세팅 한것에 이어 오늘은 5개를 추가로 Retarget을 하고, 모든 애니메이션을 Export했습니다.

다만 본 프로젝트에 복사해오니까 스켈레탈과 텍스쳐를 찾지 못해서 삭제후 좀 더 확인해볼 예정입니다.

주말에는 처리하고 그 뒤에는 Animation FSM 공부를 할 차례겠군요.

'개발일지 > 코어 플레이 개발' 카테고리의 다른 글

22년 07월 31일 개발 일지  (0) 2022.07.31
22년 07월 17일 개발일지  (0) 2022.07.17
22년 07월 15일 개발일지  (0) 2022.07.15
22년 07월 10일 개발일지  (0) 2022.07.10
22년 02월 19일 개발일지  (0) 2022.02.19

이전에도 짬짬히 했는데 일지 쓰는걸 까먹고 있다가 오늘 몰아서 써봅니다.

저번에 Animation Retarget을 하겠다고 했는데 UE5는 UE4보다 더 복잡스럽네요.

 

지금 참고해서 보고 있는 블로그입니다.

https://devjino.tistory.com/276?category=1020908 

 

[UE5] Animation Retargeting 방법

UE5에서는 UE4와 리타겟팅 하는 방법이 많이 바뀌었으며 그 과정을 정리하였습니다. Frank 애니메이션을 언리얼 마네킹으로 리타겟을 해보겠습니다. 소스 : SK_Frank_FS3_Meshes_Skeleton 타겟 : SK_Mannequin_Sk

devjino.tistory.com

 

UE4에서는 스켈레탈 우클릭 해서 옵션 지정하고 설정해서 쭉 긁으면 되던 것에 UE5에서는 IK Rig를 만들고 이걸 Retargeter로 엮어서 옵션 설정을 하고 난 뒤에야 Export가 되네요.

 

이 방식이 한번 처리하면 이후에 동일 스켈레탈에 대해 애니메이션이 추가되면 이를 관리하기에는 매우 용이한 구조인 것 같습니다.

다만 그 처음 한번이 너무 복잡스럽네요.

특히 Retarget Loop를 자동으로 설정할 수 없고 하나 하나 범위와 이름을 지정해서 해야 합니다.

자동으로 해주는게 없어요.

위 블로그에서는 파츠별로 나누어서 처리를 했지만 만약 부위별로 세세하게 들어가거나 파츠가 많다면 작업량이 어마무시하게 되겠죠.

 

물론 전 지금 여러 에셋의 애니메이션을 하나로 묶느라 Rig와 Retargeter가 애셋 갯수만큼 생겨버려서 더 작업량도 그에 비례하게 늘어난 편입니다.

그래도 이것저것 비교해보면 별로 달가운 작업은 아니었습니다.

그래서 하나 하다가 짜증나서 다시 껐습니다.

 

그래도 최소한의 작업량을 파악해서 한 한시간 정도 집중하면 정리는 될 것 같습니다.

이 뒤에는 이제 애니메이션 구조를 잘 고민해야할텐데 예전에 Rocomotion과 특정 상황 모션(사다리 타기, 사망, 공격, 피격 등등)을 분리하는 구조를 추천 받았는데 성능이나 관리 측면에서 매우 좋은 구조를 알아보는데 시간을 크게 할애하고 시작해야 할 것 같습니다.

 

이전에도 대략 이 시점에서 멈췄는데 부디 이번에는 제 자신이 멈추지 않았으면 합니다.

'개발일지 > 코어 플레이 개발' 카테고리의 다른 글

22년 07월 17일 개발일지  (0) 2022.07.17
22년 07월 16일 개발일지  (0) 2022.07.16
22년 07월 10일 개발일지  (0) 2022.07.10
22년 02월 19일 개발일지  (0) 2022.02.19
22년 02월 19일 개발일지  (0) 2022.02.19

개발하다 날리고 개발하다 날리다가 다시 시작을 했습니다.

목적은 예전과 비슷합니다.

게임플레이(코어 컨텐츠) 개발을 지향하고 기회가 된다면 엔진 코드 수정을 적극적으로 병행 할 예정입니다.

그러기 위해 상당시간 프로젝트 구조 잡는데 시간을 들였고, 정상 진행이 가능한 수준의 프로젝트를 생성할 수 있었습니다.

https://redchiken.tistory.com/311

 

Unreal Engine 코드를 포함한 Project 생성하기 (with VCS)

일반적으로 언리얼 엔진 입문을 하게 되면 Epic Games 플랫폼으로 프로젝트를 생성을 해서 개발을 시작하게 됩니다. 여기서 조금 더 발전하게 되면 VCS를 사용하게 되고, 더 나아가 엔진 코드를 따

redchiken.tistory.com

다만 오늘 찾은 문제는 기존의 언리얼 엔진 에셋을 바로 사용할 수는 없더군요.

그래서 더미 프로젝트를 따로 만들어서 해당 프로젝트에 에셋을 적용했습니다.

 

다음 작업은 더미 프로젝트에서 적용된 여러 애니메이션 에셋들을 하나의 스켈레탈로 리타겟팅 할 예정입니다.

메쉬나 머테리얼은 최대한 줄이고 애니메이션이나 포즈는 최대한 챙겨서 파일명 정리하고 본 프로젝트에 옮겨오고자 합니다.

그 뒤에는 이전처럼 키 입력 기반으로 애니메이션 하나씩 넣고, 히트박스 포르노 급으로 콜리젼 작업을 진행해볼 예정입니다.

'개발일지 > 코어 플레이 개발' 카테고리의 다른 글

22년 07월 16일 개발일지  (0) 2022.07.16
22년 07월 15일 개발일지  (0) 2022.07.15
22년 02월 19일 개발일지  (0) 2022.02.19
22년 02월 19일 개발일지  (0) 2022.02.19
공부 다시 시작  (0) 2022.02.19

원래는 ULevel이나 ClientTravel 부분부터 상세히 찾아들어가보려 했는데 그러기에 앞서 Level 정보를 담고 있는 FWorldContext의 데이터나 대략적인 변수 용처를 먼저 알고 들어가는 것이 개념 정리에 좋을 것 같아 FWorldContext를 먼저 공부 해보았다.

 

/** FWorldContext
 *	A context for dealing with UWorlds at the engine level. As the engine brings up and destroys world, we need a way to keep straight
 *	what world belongs to what.
 *
 *	WorldContexts can be thought of as a track. By default we have 1 track that we load and unload levels on. Adding a second context is adding
 *	a second track; another track of progression for worlds to live on. 
 *
 *	For the GameEngine, there will be one WorldContext until we decide to support multiple simultaneous worlds.
 *	For the EditorEngine, there may be one WorldContext for the EditorWorld and one for the PIE World.
 *
 *	FWorldContext provides both a way to manage 'the current PIE UWorld*' as well as state that goes along with connecting/travelling to 
 *  new worlds.
 *
 *	FWorldContext should remain internal to the UEngine classes. Outside code should not keep pointers or try to manage FWorldContexts directly.
 *	Outside code can still deal with UWorld*, and pass UWorld*s into Engine level functions. The Engine code can look up the relevant context 
 *	for a given UWorld*.
 *
 *  For convenience, FWorldContext can maintain outside pointers to UWorld*s. For example, PIE can tie UWorld* UEditorEngine::PlayWorld to the PIE
 *	world context. If the PIE UWorld changes, the UEditorEngine::PlayWorld pointer will be automatically updated. This is done with AddRef() and
 *  SetCurrentWorld().
 *
 */

FWorldContext 구조체 선언 위에 적혀 있는 설명 주석입니다.

대략 번역해보면 다음과 같다.

  • 엔진 단위에서 UWorlds를 관리하기 위한 Context
  • 기본적으로 1개를 소지 하고 있다.
  • World간의 이동 뿐만 아니라 현재 PIE(Play In Editor, 에디터에서 실행하는 경우)의 UWorld까지 관리하는 방안을 제공
  • 반드시 UEngine 내부에서만 접근되어야 하며 외부에서 직접적으로 관리되어서는 안됨.
  • 외부에서는 UWorld*를 사용할 수 있으며 이를 엔진 단위 함수로 전달할 수 있다.
    이때 엔진 코드는 주어진 UWorld*와 연관된 WorldContext를 탐색할 수 있다.
  • 편의를 위해 UWorld*의 외부 포인터를 유지할 수 있다.
    예를 들어, PIE는 UEditorEngine::PlayWorld 포인터를 PIE WorldContext에 의존시킬 수 있다.
    만약 PIE UWorld가 바뀌면, PlayWorld 포인터는 AddRef()와 SeetCurrentWorld()함수로 인해 자동으로 갱신이 된다.

 

정확히 이해 못하겠지만 여기서 확실한 것이 하나 있습니다.

우리는 ULevel을 공부하기 전에 UWorld를 먼저 알아야 합니다.

이에 대해 매우 잘 정리된 블로그 포스트를 공유드립니다.

https://www.zhihu.com/column/insideue4

 

InsideUE5

深入UE5剖析源码,浅出游戏引擎架构理念

www.zhihu.com

중국에서 작성된 포스트인데 구글 번역으로 보면 내용 설명과 더불어 구조적인 고민이나 설명들이 여타 튜토리얼이나 문서에서는 볼 수 없는 것들이 많습니다.

원래는 출처 넣고 번역을 하려고 했으나 펌을 금지하기 때문에 링크로 공유 하겠습니다.

 

UWORLD

https://docs.unrealengine.com/5.0/en-US/API/Runtime/Engine/Engine/UWorld/

 

UWorld

The World is the top level object representing a map or a sandbox in which Actors and Components will exist and be rendered.

docs.unrealengine.com

 

UWorld는 일종의 ULevel의 대표 오브젝트다.

하나의 Persistent Level과 함께 월드 볼륨상에서 로드되거나 취소될 수 있는 Streaming Level을 사용할 수 있고, 여러가지 Level들을 WorldComposition으로 정리해 사용할 수 있게 해준다.

Stand Alone Game에서는 일반적으로 하나의 World만 존재하나, 에디터 안에서는 많은 World들이 수정될 수 있다.

 

ULEVEL

https://docs.unrealengine.com/4.27/en-US/API/Runtime/Engine/Engine/ULevel/

 

ULevel

The level object.

docs.unrealengine.com

말 그대로 Level 오브젝트이다.

Level 내에 존재하는 Actor 리스트나 BSP 정보, Brush List들을 들고 있다.

모든 Level은 Outer로 World을 가지고 있고, Persistent Level로 지정될 수 있으며, 여러 Sub Level들을 가질 수 있다.

 

몇가지 용어 설명을 추가하자면

Persistent Level: 아무것도 없을 때에 호출되는 기본 Level.

Sub Level: Persistent Level에서 일정부분을 담당하는 Level. 지역 뿐 아니라 사운드나 레이아웃 등 기능 별로도 분리가 가능하다. 이는 동시 작업이 불가한 Binary 파일인 레벨 작업에서 최소한의 동시작업을 지원한다.

 

즉 UWorld와 ULevel들의 관계는 대략 이런 식인 것이다.

이에 대한 부분은 차후 UWorld를 공부 할 때 자세히 다뤄보겠다.

 

본론으로 돌아가, FWorldContext는 위에서 우리가 길게 핥아본 UWorld를 관리하는 구조체이다.

이제 코드 부분을 다시 살펴보자.

더보기
USTRUCT()
struct FWorldContext
{
	GENERATED_USTRUCT_BODY()

	/**************************************************************/
	
	TEnumAsByte<EWorldType::Type>	WorldType;

	FSeamlessTravelHandler SeamlessTravelHandler;

	FName ContextHandle;

	/** URL to travel to for pending client connect */
	FString TravelURL;

	/** TravelType for pending client connects */
	uint8 TravelType;

	/** URL the last time we traveled */
	UPROPERTY()
	struct FURL LastURL;

	/** last server we connected to (for "reconnect" command) */
	UPROPERTY()
	struct FURL LastRemoteURL;

	UPROPERTY()
	TObjectPtr<UPendingNetGame>  PendingNetGame;

	/** A list of tag/array pairs that is used at LoadMap time to fully load packages that may be needed for the map/game with DLC, but we can't use DynamicLoadObject to load from the packages */
	UPROPERTY()
	TArray<struct FFullyLoadedPackagesInfo> PackagesToFullyLoad;

	/**
	 * Array of package/ level names that need to be loaded for the pending map change. First level in that array is
	 * going to be made a fake persistent one by using ULevelStreamingPersistent.
	 */
	TArray<FName> LevelsToLoadForPendingMapChange;

	/** Array of already loaded levels. The ordering is arbitrary and depends on what is already loaded and such.	*/
	UPROPERTY()
	TArray<TObjectPtr<class ULevel>> LoadedLevelsForPendingMapChange;

	/** Human readable error string for any failure during a map change request. Empty if there were no failures.	*/
	FString PendingMapChangeFailureDescription;

	/** If true, commit map change the next frame.																	*/
	uint32 bShouldCommitPendingMapChange:1;

	/** Handles to object references; used by the engine to e.g. the prevent objects from being garbage collected.	*/
	UPROPERTY()
	TArray<TObjectPtr<class UObjectReferencer>> ObjectReferencers;

	UPROPERTY()
	TArray<struct FLevelStreamingStatus> PendingLevelStreamingStatusUpdates;

	UPROPERTY()
	TObjectPtr<class UGameViewportClient> GameViewport;

	UPROPERTY()
	TObjectPtr<class UGameInstance> OwningGameInstance;

	/** A list of active net drivers */
	UPROPERTY(transient)
	TArray<FNamedNetDriver> ActiveNetDrivers;

	/** The PIE instance of this world, -1 is default */
	int32	PIEInstance;

	/** The Prefix in front of PIE level names, empty is default */
	FString	PIEPrefix;

	/** The feature level that PIE world should use */
	ERHIFeatureLevel::Type PIEWorldFeatureLevel;

	/** Is this running as a dedicated server */
	bool	RunAsDedicated;

	/** Is this world context waiting for an online login to complete (for PIE) */
	bool	bWaitingOnOnlineSubsystem;

	/** Handle to this world context's audio device.*/
	uint32 AudioDeviceID;

	/** Custom description to be display in blueprint debugger UI */
	FString CustomDescription;

	// If > 0, tick this world at a fixed rate in PIE
	float PIEFixedTickSeconds  = 0.f;
	float PIEAccumulatedTickSeconds = 0.f;

	/**************************************************************/

	/** Outside pointers to CurrentWorld that should be kept in sync if current world changes  */
	TArray<UWorld**> ExternalReferences;

	/** Adds an external reference */
	void AddRef(UWorld*& WorldPtr)
	{
		WorldPtr = ThisCurrentWorld;
		ExternalReferences.AddUnique(&WorldPtr);
	}

	/** Removes an external reference */
	void RemoveRef(UWorld*& WorldPtr)
	{
		ExternalReferences.Remove(&WorldPtr);
		WorldPtr = nullptr;
	}

	/** Set CurrentWorld and update external reference pointers to reflect this*/
	ENGINE_API void SetCurrentWorld(UWorld *World);

	/** Collect FWorldContext references for garbage collection */
	void AddReferencedObjects(FReferenceCollector& Collector, const UObject* ReferencingObject);

	FORCEINLINE UWorld* World() const
	{
		return ThisCurrentWorld;
	}

	FWorldContext()
		: WorldType(EWorldType::None)
		, ContextHandle(NAME_None)
		, TravelURL()
		, TravelType(0)
		, PendingNetGame(nullptr)
		, bShouldCommitPendingMapChange(0)
		, GameViewport(nullptr)
		, OwningGameInstance(nullptr)
		, PIEInstance(INDEX_NONE)
		, PIEWorldFeatureLevel(ERHIFeatureLevel::Num)
		, RunAsDedicated(false)
		, bWaitingOnOnlineSubsystem(false)
		, AudioDeviceID(INDEX_NONE)
		, ThisCurrentWorld(nullptr)
	{ }

private:

	UWorld*	ThisCurrentWorld;
};

 

WorldType

WorldType은 현재 World가 어떤 케이스인지 지칭한다.

타입인 EWorldType은 다음과 같다.

/** Specifies the goal/source of a UWorld object */
namespace EWorldType
{
	enum Type
	{
		/** An untyped world, in most cases this will be the vestigial worlds of streamed in sub-levels */
		None,

		/** The game world */
		Game,

		/** A world being edited in the editor */
		Editor,

		/** A Play In Editor world */
		PIE,

		/** A preview world for an editor tool */
		EditorPreview,

		/** A preview world for a game */
		GamePreview,

		/** A minimal RPC world for a game */
		GameRPC,

		/** An editor world that was loaded but not currently being edited in the level editor */
		Inactive
	};
}

여기서 잘 모르는 것이 몇 있다.

Preview가 붙은 것은 오브젝트나 머테리얼에서 미리보기를 하는 World들을 지칭한다.

 

SeamlessTravelHandler

Seamless World Travel을 관리하는 구조체의 변수이다.

https://docs.unrealengine.com/5.0/ko/travelling-in-multiplayer-in-unreal-engine/

 

멀티플레이어에서의 이동(Travel)

멀티플레이어에서의 travel, 이동 작동방식에 대한 개요입니다.

docs.unrealengine.com

구조체인만큼 추가적으로 확인해야 할 사항이 많기 때문에 차후 추가로 공부하는 기회를 갖도록 하겠다.

 

ContextHandle

FWorldContext를 구분짓는 일종의 Key이다.

이 값으로 WorldContext를 검색한다.

 

TravelURL

아래 포스트에서 어느정도 설명을 했으나 한번 더 짚어보겠다.

https://redchiken.tistory.com/310

 

UGameplayStatics::OpenLevel 분석 - 1

UGamePlayStatics::OpenLevel은 C++에서 레벨을 변경하고자 할 때 사용하는 함수이다. /** * Travel to another level * * @param LevelName the level to open * @param bAbsolute if true options are reset, if..

redchiken.tistory.com

TravelURL은 client connect를 할 Level의 URL이다.

형식은 [LevelName]?[Option]으로 구성되어 있다.

 

TravelType

TravelURL과 마찬가지로 어떤 형태로 Travel을 할지에 대한 정보이다.

enum ETravelType
{
    TRAVEL_Absolute,
    TRAVEL_Partial,
    TRAVEL_Relative,
    TRAVEL_MAX,
}

Absolute와 Relative는 각각 절대경로, 상대경로로 이해하면 된다.

하지만 Partial은 어떤 경로인지 감이 잘 안잡힐 수 있다.

TRAVEL_Partial의 의미를 유추할 수 있는 코드 부분은 UEngine::HandleDisconnect이다.

void UEngine::HandleDisconnect( UWorld *InWorld, UNetDriver *NetDriver )
{
	// There must be some context for this disconnect
	check(InWorld || NetDriver);

	// If the NetDriver that failed was a pending netgame driver, cancel the PendingNetGame
	CancelPending(NetDriver);

	// InWorld might be null. It might also not map to any valid world context (for example, a pending net game disconnect)
	// If there is a context for this world, setup client travel.
	if (FWorldContext* WorldContext = GetWorldContextFromWorld(InWorld))
	{
		// Remove ?Listen parameter, if it exists
		WorldContext->LastURL.RemoveOption( TEXT("Listen") );
		WorldContext->LastURL.RemoveOption( TEXT("LAN") );

		// Net driver destruction will occur during LoadMap (prevents GetNetMode from changing output for the remainder of the frame)
		SetClientTravel( InWorld, TEXT("?closed"), TRAVEL_Absolute );
	}
	else if (NetDriver)
	{
		// Shut down any existing game connections
		if (InWorld)
		{
			// Call this to remove the NetDriver from the world context's ActiveNetDriver list
			DestroyNamedNetDriver(InWorld, NetDriver->NetDriverName);
		}
		else
		{
			NetDriver->Shutdown();
			NetDriver->LowLevelDestroy();

			// In this case, the world is null and something went wrong, so we should travel back to the default world so that we
			// can get back to a good state.
			for (FWorldContext& PotentialWorldContext : WorldList)
			{
				if (PotentialWorldContext.WorldType == EWorldType::Game)
				{
					FURL DefaultURL;
					DefaultURL.LoadURLConfig(TEXT("DefaultPlayer"), GGameIni);
					const UGameMapsSettings* GameMapsSettings = GetDefault<UGameMapsSettings>();
					if (GameMapsSettings)
					{
						PotentialWorldContext.TravelURL = FURL(&DefaultURL, *(GameMapsSettings->GetGameDefaultMap() + GameMapsSettings->LocalMapOptions), TRAVEL_Partial).ToString();
						PotentialWorldContext.TravelType = TRAVEL_Partial;
					}
				}
			}
		}
	}
}

마지막 부분을 보면 주석으로 "무언가 잘못 되었을 때 이전 World로 되돌린다"는 설명이 되어있다.

그리고 이 때 TravelType은 TRAVEL_Partial로 지정되어 있다.

이를 통해 TRAVEL_Partial은 일부 정보만 리셋하는 것으로 예상해볼 수 있다.

실제 도큐먼트에서도 carry name, reset server로 기재가 되어있기도 하구 말이다.

 

LastURL

가장 마지막에 접근했던 Level의 URL이다.

 

LastRemoteURL

가장 마지막에 connect 했던 Level의 URL이다.

일반적으로는 사용하지 않지만 reconnect를 할 때에는 이 값을 저장한다.

 

PendingNetGame

이 변수에 대해서는 이렇다 할 정보를 찾지 못했다.

우선 UPendingNetGame 타입이라 별도 클래스이므로 나중에 추가로 확인해보도록 하겠다.

 

PackagesToFullyLoad

 

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

[Level] UGameplayStatics::OpenLevel 분석 - 1  (0) 2022.07.02

일반적으로 언리얼 엔진 입문을 하게 되면 Epic Games 플랫폼으로 프로젝트를 생성을 해서 개발을 시작하게 됩니다.

여기서 조금 더 발전하게 되면 VCS를 사용하게 되고, 더 나아가 엔진 코드를 따로 Clone을 해서 코드를 읽어보게 됩니다.

그러다 보면 문뜩 여타 기업들처럼 엔진 코드를 따로 빌드해서 프로젝트를 진행하고 싶은 욕심이 듭니다.

이는 엔진 코드를 수정하고자 하는 욕심에서 비롯되죠.

 

그런데 그 방법이 뭔가 명확하게 기재가 되어있지 않습니다.

특히 VCS로 관리를 하게 된다면 "그래서 이걸 어떻게 엔진에다가 관리를 하지?"라고 고민을 하게 됩니다.

오늘은 이러한 제 고민을 해결한 방식을 공유하고자 합니다.

 

미리 말씀드리자면, 제 나름대로의 방식이니 효율 등이 완벽하게 고려된 사항은 아님을 먼저 말씀드립니다.

https://github.com/RedChiken/UnrealPractice

 

프로젝터에 언리얼 엔진 코드를 넣자니 업데이트 때마다 커밋을 해줘야 하고, 그 양이 매우 방대합니다.

개인 프로젝트에서 레포지토리에 그 많은 코드를 통째로 넣는 것은 매우 비효율적이라 생각했습니다.

그리고 이미 UnrealEngine 레포지토리가 공개가 되어 있으니 이걸 최대한 활용을 하고 싶었습니다.

그러다가 제가 생각해낸 것은 submodule이었습니다.

 

https://git-scm.com/book/ko/v2/Git-%EB%8F%84%EA%B5%AC-%EC%84%9C%EB%B8%8C%EB%AA%A8%EB%93%88

 

Git - 서브모듈

gitmodules 파일에 있는 URL은 조건에 맞는 사람이면 누구든지 Clone 하고 Fetch 할 수 있도록 접근할 수 있어야 한다. 예를 들어 다른 사람이 Pull을 하는 URL과 라이브러리의 작업을 Push 하는 URL이 서로

git-scm.com

submodule을 이용하면 등록된 레포지토리를 통해 Github상에서 엔진 코드와 클라이언트 코드를 깔끔하게 분리할 수 있을거라 생각했습니다.

그리고 기회가 된다면 적극적으로 EngineCode를 수정하고 싶었습니다.

때문에 기존의 EpicGames/UnrealEngine 레포지토리를 Fork하여 RedChiken/UnrealEngine 레포지토리를 생성하였고, 이를 submodule로 등록을 했습니다.

 

이 뒤에는 튜토리얼에서 공개된 것과 비슷합니다.

프로젝트 레포지토리를 Clone을 받은 뒤, UnrealEngine 폴더에 들어가 엔진 코드를 빌드를 하고 실행을 합니다.

그럼 UE5 이후로 생성된 Unreal Engine 클라이언트를 실행한 것처럼 아래 창이 노출됩니다.

여기서 프로직트 위치를 Clone 받은 레포지토리로 지정하고, 그 안에 다시 언리얼 게임 프로젝트를 생성을 하면 됩니다.

 

이후 언리얼 게임 프로젝트를 열면 엔진 프로젝트와 비슷하게 매우 많은 항목들이 공개되어 있습니다.

대신 한가지 차이점이 있다면, 거기에 게임 프로젝트도 포함이 되어 있는거죠.

이 게임 프로젝트를 빌드를 한 뒤 개발을 시작하면 Unreal Engine 코드가 프로젝트에 포함된 게임 프로젝트를 Github에 관리하기 걸맞게 생성을 하게 됩니다.

 

이 과정에서 몇가지 고려를 해야 할 사항이 있습니다.

첫번째로, 엔진 빌드, 게임 프로젝트 초기 빌드 시 엔진 쪽 설정 파일 일부가 변경이 됩니다.

때문에 이들을 따로 커밋하거나, ignore 해야 하는데 위에 언급 했듯이 게임 프로젝트까지 모두 빌드를 한 뒤에 한꺼번에 처리하는 편이 용이합니다.

두번째로, 개발을 하다 보면 UE5는 오브젝트들을 바이너리 파일들로 관리를 하는 방식을 제공하는데 별 다른 설정 변경이 없다면 이 정책이 유지가 될겁니다.

이를 ignore하는 것도 좋지만, 저는 lfs를 사용하는 것을 추천해보고 싶습니다.

 

언리얼 엔진 개발을 하다 보면 기능 개발에 대한 설명은 조금이라도 남아 있지만 프로젝트 구성에 대한 설명은 매우 미흡합니다.

이 글이 다른 사람들에게 도움이 되었으면 합니다.

'메모장 > 개발 지식' 카테고리의 다른 글

AWS CodeCommit 사용 후기  (0) 2022.09.17
툴팁(Tooltip) 만들기  (0) 2022.05.05
C++ Lambda Capture 주의사항  (0) 2021.11.13
Print client side log in listen server  (0) 2020.06.25
FObjectInitializer constructor fatal error c1853  (0) 2020.04.20

UGamePlayStatics::OpenLevel은 C++에서 레벨을 변경하고자 할 때 사용하는 함수이다.

/**
* Travel to another level
*
* @param	LevelName			the level to open
* @param	bAbsolute			if true options are reset, if false options are carried over from current level
* @param	Options				a string of options to use for the travel URL
*/
UFUNCTION(BlueprintCallable, meta=(WorldContext="WorldContextObject", AdvancedDisplay = "2", DisplayName = "Open Level (by Name)"), Category="Game")
static void OpenLevel(const UObject* WorldContextObject, FName LevelName, bool bAbsolute = true, FString Options = FString(TEXT("")));

 

함수 설명을 하기 앞서 Specifier를 살펴보자.

관련 사항은 아래 링크에 모든 것이 정리되어 있지만 이 글을 보는 사람들이 바로 알아볼 수 있게 따로 언급을 하겠다.

https://docs.unrealengine.com/4.27/en-US/ProgrammingAndScripting/GameplayArchitecture/Functions/Specifiers/

 

Function Specifiers

Keywords used when declaring UFunctions to specify how the function behaves with various aspects of the Engine and Editor.

docs.unrealengine.com

BlueprintCallable: 이 함수를 Blueprint에서 호출할 수 있도록 한다.

Meta: Specifier 중에서 특정 커맨드가 아니라 파라미터 입력을 받는 것들을 지칭한다.

WorldContext: 해당 함수 작업이 수행 될 World를 지칭합니다. BlueprintCallable 옵션이 있을 때 사용 됩니다.

AdvancedDisplay: Blueprint에서 몇번째 옵션까지 노출시킬지를 나타냅니다. AdvancedDisplay가 2라면 함수 파라미터 중 2개까지 기본으로 노출되고, 나머지는 확장을 해야만 노출이 됩니다.

DisplayName: Blueprint Node에서 따로 표기가 될 이름입니다.

Category: Blueprint 창에서 분류될 카테고리입니다.

 

사실 이 부분은 함수 기능 그 자체를 이해하는 것과는 직접적인 연관은 없다고 생각합니다.

다만 이러한 함수를 만들거나, 목적을 이해하는데 있어 범용적으로 요구되는 기본지식이라고 생각합니다.

나중에 기회가 되면 따로 정리를 해보겠습니다.

 

함수 파라미터는 WorldContextObject를 제외하고는 주석으로 잘 설명이 되어 있습니다.

하지만 직관적으로 어떤건지 파악이 되는 것은 bAbsolute 하나 뿐입니다.

우리는 이 파라미터가 "현재 레벨의 옵션을 그대로 유지할지 여부" 정부라는 것만 기억을 하고 구현부로 넘어가겠습니다.

 

void UGameplayStatics::OpenLevel(const UObject* WorldContextObject, FName LevelName, bool bAbsolute, FString Options)
{
	UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull);
	if (World == nullptr)
	{
		return;
	}

	const ETravelType TravelType = (bAbsolute ? TRAVEL_Absolute : TRAVEL_Relative);
	FWorldContext &WorldContext = GEngine->GetWorldContextFromWorldChecked(World);
	FString Cmd = LevelName.ToString();
	if (Options.Len() > 0)
	{
		Cmd += FString(TEXT("?")) + Options;
	}
	FURL TestURL(&WorldContext.LastURL, *Cmd, TravelType);
	if (TestURL.IsLocalInternal())
	{
		// make sure the file exists if we are opening a local file
		if (!GEngine->MakeSureMapNameIsValid(TestURL.Map))
		{
			UE_LOG(LogLevel, Warning, TEXT("WARNING: The map '%s' does not exist."), *TestURL.Map);
		}
	}

	GEngine->SetClientTravel( World, *Cmd, TravelType );
}

 

 

첫번째 함수 호출부분을 보자.

GEngine::GetWorldFromContextObject를 통해 World를 불러온다.

해당 함수의 선언부는 아래와 같은데, 이는 파라미터로 입력된 UObject가 소속된 UWorld 포인터를 반환하는 함수이다.

/** 
* Obtain a world object pointer from an object with has a world context.
*
* @param Object		Object whose owning world we require.
* @param ErrorMode		Controls what happens if the Object cannot be found
* @return				The world to which the object belongs or nullptr if it cannot be found.
*/
UWorld* GetWorldFromContextObject(const UObject* Object, EGetWorldErrorMode ErrorMode) const;

이 함수에 대해서는 UObject와 GEngine 모두를 분석해야 정확히 설명이 가능할 것 같아 기능만 짚겠다.

 

이 다음에는 bAbsolute를 통해 ETravelType을 지정한다.

이 ETravelType에는 Travel_Absolute, Travel_Relative 외에 Travel_Partial도 존재한다.

이에 대한 명확한 의미를 찾지는 못했지만 대략적인 사용 의의를 찾을 수는 있었습니다.

출처:&nbsp;https://blog.csdn.net/u012999985/article/details/78484511

더 명확한 옵션은 지금 당장 확인하고 찾기에는 힘들 것 같아 타입을 지정한다는 점만 짚고 넘어가도록 하겠다.

 

그 다음으로 봐야 할 함수는 UEngine::GetWorldContextFromHandleChecked이다.

해당 함수는 파라미터로 전달된 UWorld 포인터를 들고 있는 FWorldContext를 반환한다.

FWorldContext& UEngine::GetWorldContextFromWorldChecked(const UWorld *InWorld)
{
	if (FWorldContext* WorldContext = GetWorldContextFromWorld(InWorld))
	{
		return *WorldContext;
	}
	return HandleInvalidWorldContext();
}

 

코드를 보면 알겠지만, 아니 어쩌면 이름으로도 알았겠지만 이 함수는 GetWorldContextFromWorld의 반호나 값을 Valid 체크까지 진행 한 함수다.

즉, 실제 동작은 다음과 같다.

FWorldContext* UEngine::GetWorldContextFromWorld(const UWorld* InWorld)
{
	for (FWorldContext& WorldContext : WorldList)
	{
		if (WorldContext.World() == InWorld)
		{
			return &WorldContext;
		}
	}
	return nullptr;
}

이제 우리가 우회해야 할 또 한가지 사항이 생긴다.

바로 FWorldContext이다. 이는 해당 Struct에 적혀있는 설명 주석을 먼저 기재하고 작성하겠다.

/** FWorldContext
 *	A context for dealing with UWorlds at the engine level. As the engine brings up and destroys world, we need a way to keep straight
 *	what world belongs to what.
 *
 *	WorldContexts can be thought of as a track. By default we have 1 track that we load and unload levels on. Adding a second context is adding
 *	a second track; another track of progression for worlds to live on. 
 *
 *	For the GameEngine, there will be one WorldContext until we decide to support multiple simultaneous worlds.
 *	For the EditorEngine, there may be one WorldContext for the EditorWorld and one for the PIE World.
 *
 *	FWorldContext provides both a way to manage 'the current PIE UWorld*' as well as state that goes along with connecting/travelling to 
 *  new worlds.
 *
 *	FWorldContext should remain internal to the UEngine classes. Outside code should not keep pointers or try to manage FWorldContexts directly.
 *	Outside code can still deal with UWorld*, and pass UWorld*s into Engine level functions. The Engine code can look up the relevant context 
 *	for a given UWorld*.
 *
 *  For convenience, FWorldContext can maintain outside pointers to UWorld*s. For example, PIE can tie UWorld* UEditorEngine::PlayWorld to the PIE
 *	world context. If the PIE UWorld changes, the UEditorEngine::PlayWorld pointer will be automatically updated. This is done with AddRef() and
 *  SetCurrentWorld().
 *
 */

 

간단히 요약을 하면, UWorld 포인터와 이를 들고 있는 Level에 대한 정보를 들고 있다.

그렇기에 우리는 UWorld 포인터를 통해 FWorldContext 포인터를 검색을 할 수 있는 것이다.

 

이에 대한 설명을 하기 앞서 한가지 짚어야 할 것은 Cmd라는 FString 생성 과정이다.

Cmd는 LevelName과 Option을 ? 좌우로 붙여서 생성된다.

이 포맷은 무엇이고 Option은 무엇인가?

그에 대한 설명은 아래 문서에 자세히 기재되어 있다.

https://docs.unrealengine.com/4.27/en-US/ProductionPipelines/CommandLineArguments/

 

Command-Line Arguments

Collection of arguments that can be passed to the engine's executable to configure options controlling how it runs.

docs.unrealengine.com

여기서도 한가지 의문이 생긴다.

위 문서에서 나온 예시는 레벨을 경로로 지정하는데 우리가 입력한 LevelName은 파일명만 지칭하고 있다.

그럼 이 둘은 전혀 다른 것인가?

 

결론부터 말하자면 두 옵션은 완전히 동일하게 작성된다.

정확히는 포맷이 정확히 동일하다.

다만 OpenLevel의 내부에서는 이를 다른 형태로 사용하고 있을 뿐이다.

때문에 우리가 봐야할 것은 그다음 부분인 FURL 구조체와 FWorldContext::LastURL() 함수이다.

 

FURL은 Unreal 내부에서 경로를 지칭할 때 사용하는 구조체이다.

이는 URL이라는 String에 여러 편의기능을 덧붙인 Wrapping 구조체로 봐도 무방하다.

FWorldContext::LastURL은 FWorldContext가 가장 마지막에 접속한 FURL을 반환한다.

 

정리를 하면 TestURL은 LastURL와 파일명?옵션으로 이루어진 Cmd, 그리고 TravelType으로 재생성이 된다는 점이다.

이에 대한 중요한 코드 부분은 다음과 같다.

USTRUCT()
struct ENGINE_API FURL
{
	GENERATED_USTRUCT_BODY()

	// Protocol, i.e. "unreal" or "http".
	UPROPERTY()
	FString Protocol;

	// Optional hostname, i.e. "204.157.115.40" or "unreal.epicgames.com", blank if local.
	UPROPERTY()
	FString Host;

	// Optional host port.
	UPROPERTY()
	int32 Port;

	UPROPERTY()
	int32 Valid;

	// Map name, i.e. "SkyCity", default is "Entry".
	UPROPERTY()
	FString Map;

	// Optional place to download Map if client does not possess it
	UPROPERTY()
	FString RedirectURL;

	// Options.
	UPROPERTY()
	TArray<FString> Op;

	// Portal to enter through, default is "".
	UPROPERTY()
	FString Portal;
    ....
}

FURL의 선언부다.

눈여겨 볼 정보는 Protocol/Host/Port와 Map이다.

Protocol은 해당 FURL이 파일 경로인지 서버 경로인지를 지칭한다.

만약 서버 경로라면 Host와 Port 값이 Valid 한 것이고, 그렇지 않다면 이 값들은 빈 값이 들어가 있다는 점이다.

그럼 다음을 보자.

 

FURL::FURL( FURL* Base, const TCHAR* TextURL, ETravelType Type )
:	Protocol	( UrlConfig.DefaultProtocol )
,	Host		( UrlConfig.DefaultHost )
,	Port		( UrlConfig.DefaultPort )
,	Valid		( 1 )
,	Map			( UGameMapsSettings::GetGameDefaultMap() )
,	Op			()
,	Portal		( UrlConfig.DefaultPortal )
{
    ....
    
    // Parse optional map
	if (Valid == 1 && URLStr.Len() > 0)
	{
		// Map.
		if (URLStr[0] != '/')
		{
			// find full pathname from short map name
			FString MapFullName;
			FText MapNameError;
			bool bFoundMap = false;

			if (FPaths::FileExists(URLStr) && FPackageName::TryConvertFilenameToLongPackageName(URLStr, MapFullName))
			{
				Map = MapFullName;
				bFoundMap = true;
			}
			else if (!FPackageName::DoesPackageNameContainInvalidCharacters(URLStr, &MapNameError))
			{
				// First try to use the asset registry if it is available and finished scanning
				if (FModuleManager::Get().IsModuleLoaded("AssetRegistry"))
				{
					IAssetRegistry& AssetRegistry = FModuleManager::Get().LoadModuleChecked<FAssetRegistryModule>("AssetRegistry").Get();

					if (!AssetRegistry.IsLoadingAssets())
					{
						TArray<FAssetData> MapList;
						if (AssetRegistry.GetAssetsByClass(UWorld::StaticClass()->GetFName(), /*out*/ MapList))
						{
							FName TargetTestName(*URLStr);
							for (const FAssetData& MapAsset : MapList)
							{
								if (MapAsset.AssetName == TargetTestName)
								{
									Map = MapAsset.PackageName.ToString();
									bFoundMap = true;
									break;
								}
							}
						}
					}
				}

				if (!bFoundMap)
				{
					// Fall back to a slow AssetRegistry scan for the package
					IAssetRegistry& AssetRegistry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
					FName ExistingPackageName = AssetRegistry.GetFirstPackageByName(URLStr);
					if (!ExistingPackageName.IsNone())
					{
						ExistingPackageName.ToString(Map);
						bFoundMap = true;
					}
				}
			}

			if (!bFoundMap)
			{
				// can't find file, invalidate and bail
				UE_CLOG(MapNameError.ToString().Len() > 0, LogLongPackageNames, Warning, TEXT("URL: %s: %s"), *URLStr, *MapNameError.ToString());
				*this = FURL();
				Valid = 0;
			}
		}
		else
		{
			// already a full pathname
			Map = URLStr;
		}
        .......
	}
}

사실 구현부는 매우매우 길고 많은 경우를 계산하고 있다.

하지만 이를 하나하나 설명하기 힘드니 명확히 우리가 알고자 하는 부분만 발췌했다.

결국 이 Map은 파일명으로 애셋을 검색해 그 경로를 받아와 저장된 값이다.

즉 우리는 이 긴 코드를 통해 FURL의 생성자 안에서 파일명을 넣으면 경로를 반환한다는 것을 알아냈다.

 

이 다음은 비교적 간단하다.

FURL::IsLocalInternal과 UEngine::MakeSureMapNameIsValid를 통해 현재 URL이 파일 경로이고, 실제 존재하는지 확인을 한다.

이 조건을 통과하면 UEngine::SetClientTravel이라는 함수를 호출하며 마무리가 된다.

void UEngine::SetClientTravel( UWorld *InWorld, const TCHAR* NextURL, ETravelType InTravelType )
{
	FWorldContext &Context = GetWorldContextFromWorldChecked(InWorld);

	// set TravelURL.  Will be processed safely on the next tick in UGameEngine::Tick().
	Context.TravelURL    = NextURL;
	Context.TravelType   = InTravelType;

	// Prevent crashing the game by attempting to connect to own listen server
	if ( Context.LastURL.HasOption(TEXT("Listen")) )
	{
		Context.LastURL.RemoveOption(TEXT("Listen"));
	}
}

 

함수가 간단해서 안심을 했다면 오산이다.

자세히 보면 FWorldContext를 받아와 TravelURL와 TravelType을 변환하고, 이전 레벨의 Listen 옵션을 제거를 해주는데 그 다음 동작은 UGameEngine::Tick에서 진행 된다고 되어 있다.

그럼 우리는 UGameEngine::Tick을 봐야 한다.

void UGameEngine::Tick( float DeltaSeconds, bool bIdleMode )
{
	CSV_SCOPED_TIMING_STAT_EXCLUSIVE(EngineTickMisc);
	SCOPE_TIME_GUARD(TEXT("UGameEngine::Tick"));
	SCOPE_CYCLE_COUNTER(STAT_GameEngineTick);
	NETWORK_PROFILER(GNetworkProfiler.TrackFrameBegin());
    
	.......

	// -----------------------------------------------------
	// Begin ticking worlds
	// -----------------------------------------------------

	bool bIsAnyNonPreviewWorldUnpaused = false;

	FName OriginalGWorldContext = NAME_None;
	for (int32 i=0; i < WorldList.Num(); ++i)
	{
		if (WorldList[i].World() == GWorld)
		{
			OriginalGWorldContext = WorldList[i].ContextHandle;
			break;
		}
	}

	for (int32 WorldIdx = 0; WorldIdx < WorldList.Num(); ++WorldIdx)
	{
		FWorldContext &Context = WorldList[WorldIdx];
		if (Context.World() == NULL || !Context.World()->ShouldTick())
		{
			continue;
		}

		GWorld = Context.World();

		// Tick all travel and Pending NetGames (Seamless, server, client)
		{
			QUICK_SCOPE_CYCLE_COUNTER(STAT_UGameEngine_Tick_TickWorldTravel);
			TickWorldTravel(Context, DeltaSeconds);
		}

		if (!bIdleMode)
		{
			SCOPE_TIME_GUARD(TEXT("UGameEngine::Tick - WorldTick"));

			// Tick the world.
			Context.World()->Tick( LEVELTICK_All, DeltaSeconds );
		}

		if (!IsRunningDedicatedServer() && !IsRunningCommandlet())
		{
			QUICK_SCOPE_CYCLE_COUNTER(STAT_UGameEngine_Tick_CheckCaptures);
			// Only update reflection captures in game once all 'always loaded' levels have been loaded
			// This won't work with actual level streaming though
			if (Context.World()->AreAlwaysLoadedLevelsLoaded())
			{
				// Update sky light first because it's considered direct lighting, sky diffuse will be visible in reflection capture indirect specular
				USkyLightComponent::UpdateSkyCaptureContents(Context.World());
				UReflectionCaptureComponent::UpdateReflectionCaptureContents(Context.World());
			}
		}
        .....
}

World를 Tick 하는 부분이다.

여기서 다음으로 넘어가야 할 부분은 TickWorldTravel이다.

왜냐하면 이 뒤에 Context.World()->Tick이 있는데 이는 Level의 Tick이고, GWorld에서 Context.World()가 저장되기 때문에 이 둘 사이에 초기화가 되기 때문이다.

void UEngine::TickWorldTravel(FWorldContext& Context, float DeltaSeconds)
{
	// Handle seamless traveling
	if (Context.SeamlessTravelHandler.IsInTransition())
	{
		// Note: SeamlessTravelHandler.Tick may automatically update Context.World and GWorld internally
		Context.SeamlessTravelHandler.Tick();
	}

	// Handle server traveling.
	if (Context.World() == nullptr)
	{
		UE_LOG(LogLoad, Error, TEXT("UEngine::TickWorldTravel has no world after ticking seamless travel handler."));
		BrowseToDefaultMap(Context);
		BroadcastTravelFailure(Context.World(), ETravelFailure::ServerTravelFailure, TEXT("UEngine::TickWorldTravel has no world after ticking seamless travel handler."));
		return;
	}

	if( !Context.World()->NextURL.IsEmpty() )
	{
		Context.World()->NextSwitchCountdown -= DeltaSeconds;
		if( Context.World()->NextSwitchCountdown <= 0.f )
		{
			UE_LOG(LogEngine, Log,  TEXT("Server switch level: %s"), *Context.World()->NextURL );
			if (Context.World()->GetAuthGameMode() != NULL)
			{
				Context.World()->GetAuthGameMode()->StartToLeaveMap();
			}
			FString Error;
			FString NextURL = Context.World()->NextURL;
			EBrowseReturnVal::Type Ret = Browse( Context, FURL(&Context.LastURL,*NextURL,(ETravelType)Context.World()->NextTravelType), Error );
			if (Ret != EBrowseReturnVal::Success )
			{
				UE_LOG(LogLoad, Warning, TEXT("UEngine::TickWorldTravel failed to Handle server travel to URL: %s. Error: %s"), *NextURL, *Error);
				ensureMsgf(Ret != EBrowseReturnVal::Pending, TEXT("Server travel should never create a pending net game"));

				// Failed to load a new map
				if (Context.World() != nullptr)
				{
					// If we didn't change worlds, clear out NextURL so we don't do this again next frame.
					Context.World()->NextURL = TEXT("");
				}
				else
				{
					// Our old world got stomped out. Load the default map
					BrowseToDefaultMap(Context);
				}

				// Let people know that we failed to server travel
				BroadcastTravelFailure(Context.World(), ETravelFailure::ServerTravelFailure, Error);
			}
			return;
		}
	}

	// Handle client traveling.
	if( !Context.TravelURL.IsEmpty() )
	{	
		AGameModeBase* const GameMode = Context.World()->GetAuthGameMode();
		if (GameMode)
		{
			GameMode->StartToLeaveMap();
		}

		FString Error, TravelURLCopy = Context.TravelURL;
		if (Browse( Context, FURL(&Context.LastURL,*TravelURLCopy,(ETravelType)Context.TravelType), Error ) == EBrowseReturnVal::Failure)
		{
			// If the failure resulted in no world being loaded (we unloaded our last world, then failed to load the new one)
			// then load the default map to avoid getting in a situation where we have no valid UWorld.
			if (Context.World() == NULL)
			{
				BrowseToDefaultMap(Context);
			}

			// Let people know that we failed to client travel
			BroadcastTravelFailure(Context.World(), ETravelFailure::ClientTravelFailure, Error);
		}
		check(Context.World() != NULL);
		return;
	}

서버일 경우와 클라이언트인 경우로 코드가 크게 나뉜다.

여기서는 좀 더 함축해서 바로 클라이언트 부분을 살펴 봐 그 다음 우리가 봐야할 것이 Browse 함수라는 것을 알리겠다.

서버 쪽으로 탐색을 해도 결국 Browse로 귀결이 되니 이는 따로 확인을 해보면 된다.

EBrowseReturnVal::Type UEngine::Browse( FWorldContext& WorldContext, FURL URL, FString& Error )
{
	Error = TEXT("");
	WorldContext.TravelURL = TEXT("");

	if (WorldContext.World() && WorldContext.World()->GetNetDriver())
	{
		const TCHAR* InTickErrorString = TEXT("Attempting to call UEngine::Browse and destroy the net driver while the net driver is ticking. Instead try using UWorld::ServerTravel or APlayerController::ClientTravel.");
		if (!ensureMsgf(!WorldContext.World()->GetNetDriver()->IsInTick(), TEXT("%s"), InTickErrorString))
		{
			Error = InTickErrorString;
			return EBrowseReturnVal::Failure;
		}
	}

	// Convert .unreal link files.
	const TCHAR* LinkStr = TEXT(".unreal");//!!
	if( FCString::Strstr(*URL.Map,LinkStr)-*URL.Map==FCString::Strlen(*URL.Map)-FCString::Strlen(LinkStr) )
	{
		UE_LOG(LogNet, Log,  TEXT("Link: %s"), *URL.Map );
		FString NewUrlString;
		if( GConfig->GetString( TEXT("Link")/*!!*/, TEXT("Server"), NewUrlString, *URL.Map ) )
		{
			// Go to link.
			URL = FURL( NULL, *NewUrlString, TRAVEL_Absolute );//!!
		}
		else
		{
			// Invalid link.
			Error = FText::Format( NSLOCTEXT("Engine", "InvalidLink", "Invalid Link: {0}"), FText::FromString( URL.Map ) ).ToString();
			return EBrowseReturnVal::Failure;
		}
	}

	// Crack the URL.
	UE_LOG(LogNet, Log, TEXT("Browse: %s"), *URL.ToString() );

	// Handle it.
	if( !URL.Valid )
	{
		// Unknown URL.
		Error = FText::Format( NSLOCTEXT("Engine", "InvalidUrl", "Invalid URL: {0}"), FText::FromString( URL.ToString() ) ).ToString();
		BroadcastTravelFailure(WorldContext.World(), ETravelFailure::InvalidURL, Error);
		return EBrowseReturnVal::Failure;
	}
	else if (URL.HasOption(TEXT("failed")) || URL.HasOption(TEXT("closed")))
	{
		if (WorldContext.PendingNetGame)
		{
			CancelPending(WorldContext);
		}
		if (WorldContext.World() != NULL)
		{
			ResetLoaders( WorldContext.World()->GetOuter() );
		}

		if (WorldContext.WorldType == EWorldType::GameRPC)
		{
			UE_LOG(LogNet, Log, TEXT("RPC connection failed; retrying..."));

			// Clean up all current net-drivers before we create a new one
			// Need to copy the array as DestroyNamedNetDriver_Local mutates it
			{
				TArray<FNamedNetDriver> ActiveNetDrivers = WorldContext.ActiveNetDrivers;
				for (const FNamedNetDriver& ActiveNetDriver : ActiveNetDrivers)
				{
					if (ActiveNetDriver.NetDriver)
					{
						DestroyNamedNetDriver_Local(WorldContext, ActiveNetDriver.NetDriver->NetDriverName);
					}
				}
				CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS);
			}

			{
				LLM_SCOPE(ELLMTag::Networking);

				// Just reload the RPC world (as we have no real map to load)
				WorldContext.PendingNetGame = NewObject<UPendingNetGame>();
				WorldContext.PendingNetGame->Initialize(WorldContext.LastURL);
				WorldContext.PendingNetGame->InitNetDriver();
			}

			if (!WorldContext.PendingNetGame->NetDriver)
			{
				// UPendingNetGame will set the appropriate error code and connection lost type, so
				// we just have to propagate that message to the game.
				BroadcastTravelFailure(WorldContext.World(), ETravelFailure::PendingNetGameCreateFailure, WorldContext.PendingNetGame->ConnectionError);
				WorldContext.PendingNetGame = NULL;
				return EBrowseReturnVal::Failure;
			}

			return EBrowseReturnVal::Pending;
		}

		// Browsing after a failure, load default map
		UE_LOG(LogNet, Log, TEXT("Connection failed; returning to Entry"));
		
		const UGameMapsSettings* GameMapsSettings = GetDefault<UGameMapsSettings>();
		const FURL DefaultURL = FURL(&URL, *(GameMapsSettings->GetGameDefaultMap() + GameMapsSettings->LocalMapOptions), TRAVEL_Partial);

		if (!LoadMap(WorldContext, DefaultURL, NULL, Error))
		{
			HandleBrowseToDefaultMapFailure(WorldContext, DefaultURL.ToString(), Error);
			return EBrowseReturnVal::Failure;
		}

		CollectGarbage( GARBAGE_COLLECTION_KEEPFLAGS );

		// now remove "failed" and "closed" options from LastURL so it doesn't get copied on to future URLs
		WorldContext.LastURL.RemoveOption(TEXT("failed"));
		WorldContext.LastURL.RemoveOption(TEXT("closed"));
		return EBrowseReturnVal::Success;
	}
	else if( URL.HasOption(TEXT("restart")) )
	{
		// Handle restarting.
		URL = WorldContext.LastURL;
	}

	// Handle normal URL's.
	if (GDisallowNetworkTravel && URL.HasOption(TEXT("listen")))
	{
		Error = NSLOCTEXT("Engine", "UsedCheatCommands", "Console commands were used which are disallowed in netplay.  You must restart the game to create a match.").ToString();
		BroadcastTravelFailure(WorldContext.World(), ETravelFailure::CheatCommands, Error);
		return EBrowseReturnVal::Failure;
	}
	if( URL.IsLocalInternal() )
	{
		// Local map file.
		return LoadMap( WorldContext, URL, NULL, Error ) ? EBrowseReturnVal::Success : EBrowseReturnVal::Failure;
	}
	else if( URL.IsInternal() && GIsClient )
	{
		// Network URL.
		if( WorldContext.PendingNetGame )
		{
			CancelPending(WorldContext);
		}

		// Clean up the netdriver/socket so that the pending level succeeds
		if (WorldContext.World() && ShouldShutdownWorldNetDriver())
		{
			ShutdownWorldNetDriver(WorldContext.World());
		}

		WorldContext.PendingNetGame = NewObject<UPendingNetGame>();
		WorldContext.PendingNetGame->Initialize(URL); //-V595
		WorldContext.PendingNetGame->InitNetDriver(); //-V595

		if( !WorldContext.PendingNetGame )
		{
			// If the inital packet sent in InitNetDriver results in a socket error, HandleDisconnect() and CancelPending() may be called, which will null the PendingNetGame.
			Error = NSLOCTEXT("Engine", "PendingNetGameInitFailure", "Error initializing the network driver.").ToString();
			BroadcastTravelFailure(WorldContext.World(), ETravelFailure::PendingNetGameCreateFailure, Error);
			return EBrowseReturnVal::Failure;
		}

		if( !WorldContext.PendingNetGame->NetDriver )
		{
			// UPendingNetGame will set the appropriate error code and connection lost type, so
			// we just have to propagate that message to the game.
			BroadcastTravelFailure(WorldContext.World(), ETravelFailure::PendingNetGameCreateFailure, WorldContext.PendingNetGame->ConnectionError);
			WorldContext.PendingNetGame = NULL;
			return EBrowseReturnVal::Failure;
		}
		return EBrowseReturnVal::Pending;
	}
	else if( URL.IsInternal() )
	{
		// Invalid.
		Error = NSLOCTEXT("Engine", "ServerOpen", "Servers can't open network URLs").ToString();
		return EBrowseReturnVal::Failure;
	}
	{
		// External URL - disabled by default.
		// Client->Viewports(0)->Exec(TEXT("ENDFULLSCREEN"));
		// FPlatformProcess::LaunchURL( *URL.ToString(), TEXT(""), &Error );
		return EBrowseReturnVal::Failure;
	}
}

링크에서 .unreal 확장자를 제거하고 추적을 해서 함수 실행을 하고 있다.

우리가 원하는 Level을 여는 조건은 탐색을 하면 LoadMap이라는 것을 알 수 있다.

bool UEngine::LoadMap( FWorldContext& WorldContext, FURL URL, class UPendingNetGame* Pending, FString& Error )
{
	TRACE_LOADTIME_REQUEST_GROUP_SCOPE(TEXT("LoadMap - %s"), *URL.Map);
	STAT_ADD_CUSTOMMESSAGE_NAME( STAT_NamedMarker, *(FString( TEXT( "LoadMap - " ) + URL.Map )) );
	TRACE_BOOKMARK(TEXT("LoadMap - %s"), *URL.Map);

	DECLARE_SCOPE_CYCLE_COUNTER(TEXT("UEngine::LoadMap"), STAT_LoadMap, STATGROUP_LoadTime);

	LLM_SCOPE(ELLMTag::LoadMapMisc);

	FDisableHitchDetectorScope SuspendHitchDetector;

	NETWORK_PROFILER(GNetworkProfiler.TrackSessionChange(true,URL));
	MALLOC_PROFILER( FMallocProfiler::SnapshotMemoryLoadMapStart( URL.Map ) );
	FMoviePlayerProxyBlock MoviePlayerBlock;
	Error = TEXT("");

	FLoadTimeTracker::Get().ResetRawLoadTimes();

	// make sure level streaming isn't frozen
	if (WorldContext.World())
	{
		WorldContext.World()->bIsLevelStreamingFrozen = false;
	}

	// send a callback message
	FCoreUObjectDelegates::PreLoadMapWithContext.Broadcast(WorldContext, URL.Map);
	FCoreUObjectDelegates::PreLoadMap.Broadcast(URL.Map);
	FMoviePlayerProxy::BlockingTick();

	// make sure there is a matching PostLoadMap() no matter how we exit
	struct FPostLoadMapCaller
	{
		FPostLoadMapCaller()
			: bCalled(false)
		{}

		~FPostLoadMapCaller()
		{
			if (!bCalled)
			{
				FCoreUObjectDelegates::PostLoadMapWithWorld.Broadcast(nullptr);
			}
		}

		void Broadcast(UWorld* World)
		{
			if (ensure(!bCalled))
			{
				bCalled = true;
				FCoreUObjectDelegates::PostLoadMapWithWorld.Broadcast(World);
			}
		}

	private:
		bool bCalled;

	} PostLoadMapCaller;

	// Cancel any pending texture streaming requests.  This avoids a significant delay on consoles 
	// when loading a map and there are a lot of outstanding texture streaming requests from the previous map.
	UTexture2D::CancelPendingTextureStreaming();

	// play a load map movie if specified in ini
	bStartedLoadMapMovie = false;

	// clean up any per-map loaded packages for the map we are leaving
	if (WorldContext.World() && WorldContext.World()->PersistentLevel)
	{
		CleanupPackagesToFullyLoad(WorldContext, FULLYLOAD_Map, WorldContext.World()->PersistentLevel->GetOutermost()->GetName());
	}

	// cleanup the existing per-game pacakges
	// @todo: It should be possible to not unload/load packages if we are going from/to the same GameMode.
	//        would have to save the game pathname here and pass it in to SetGameMode below
	CleanupPackagesToFullyLoad(WorldContext, FULLYLOAD_Game_PreLoadClass, TEXT(""));
	CleanupPackagesToFullyLoad(WorldContext, FULLYLOAD_Game_PostLoadClass, TEXT(""));
	CleanupPackagesToFullyLoad(WorldContext, FULLYLOAD_Mutator, TEXT(""));


	// Cancel any pending async map changes after flushing async loading. We flush async loading before canceling the map change
	// to avoid completion after cancellation to not leave references to the "to be changed to" level around. Async loading is
	// implicitly flushed again later on during garbage collection.
	FlushAsyncLoading();
	CancelPendingMapChange(WorldContext);
	WorldContext.SeamlessTravelHandler.CancelTravel();

	double	StartTime = FPlatformTime::Seconds();

	UE_LOG(LogLoad, Log,  TEXT("LoadMap: %s"), *URL.ToString() );
	GInitRunaway();

#if !UE_BUILD_SHIPPING
	const bool bOldWorldWasShowingCollisionForHiddenComponents = WorldContext.World() && WorldContext.World()->bCreateRenderStateForHiddenComponentsWithCollsion;
#endif

	// Unload the current world
	if( WorldContext.World() )
	{
		WorldContext.World()->BeginTearingDown();

		if(!URL.HasOption(TEXT("quiet")) )
		{
			TransitionType = ETransitionType::Loading;
			TransitionDescription = URL.Map;
			if (URL.HasOption(TEXT("Game=")))
			{
				TransitionGameMode = URL.GetOption(TEXT("Game="), TEXT(""));
			}
			else
			{
				TransitionGameMode = TEXT("");
			}

			// Display loading screen.		
			// Check if a loading movie is playing.  If so it is not safe to redraw the viewport due to potential race conditions with font rendering
			bool bIsLoadingMovieCurrentlyPlaying = FCoreDelegates::IsLoadingMovieCurrentlyPlaying.IsBound() ? FCoreDelegates::IsLoadingMovieCurrentlyPlaying.Execute() : false;
			if(!bIsLoadingMovieCurrentlyPlaying)
			{
				LoadMapRedrawViewports();
			}

			TransitionType = ETransitionType::None;
		}

		// Clean up networking
		ShutdownWorldNetDriver(WorldContext.World());

		// Make sure there are no pending visibility requests.
		WorldContext.World()->FlushLevelStreaming(EFlushLevelStreamingType::Visibility);

		// send a message that all levels are going away (NULL means every sublevel is being removed
		// without a call to RemoveFromWorld for each)
		//if (WorldContext.World()->GetNumLevels() > 1)
		{
			// TODO: Consider actually broadcasting for each level?
			FWorldDelegates::LevelRemovedFromWorld.Broadcast(nullptr, WorldContext.World());
		}

		// Disassociate the players from their PlayerControllers in this world.
		if (WorldContext.OwningGameInstance != nullptr)
		{
			for(auto It = WorldContext.OwningGameInstance->GetLocalPlayerIterator(); It; ++It)
			{
				ULocalPlayer *Player = *It;
				if(Player->PlayerController && Player->PlayerController->GetWorld() == WorldContext.World())
				{
					if(Player->PlayerController->GetPawn())
					{
						WorldContext.World()->DestroyActor(Player->PlayerController->GetPawn(), true);
					}
					WorldContext.World()->DestroyActor(Player->PlayerController, true);
					Player->PlayerController = nullptr;
				}
				// reset split join info so we'll send one after loading the new map if necessary
				Player->bSentSplitJoin = false;
				// When loading maps, clear out mids that are referenced as they may prevent the world from shutting down cleanly and the local player will not be cleaned up until later
				Player->CleanupViewState(); 
			}
		}

		for (FActorIterator ActorIt(WorldContext.World()); ActorIt; ++ActorIt)
		{
			ActorIt->RouteEndPlay(EEndPlayReason::LevelTransition);
		}

		// Do this after destroying pawns/playercontrollers, in case that spawns new things (e.g. dropped weapons)
		WorldContext.World()->CleanupWorld();

		if( GEngine )
		{
			// clear any "DISPLAY" properties referencing level objects
			if (GEngine->GameViewport != nullptr)
			{
				ClearDebugDisplayProperties();
			}

			GEngine->WorldDestroyed(WorldContext.World());
		}
		WorldContext.World()->RemoveFromRoot();

		// mark everything else contained in the world to be deleted
		for (auto LevelIt(WorldContext.World()->GetLevelIterator()); LevelIt; ++LevelIt)
		{
			const ULevel* Level = *LevelIt;
			if (Level)
			{
				CastChecked<UWorld>(Level->GetOuter())->MarkObjectsPendingKill();
			}
		}

		for (ULevelStreaming* LevelStreaming : WorldContext.World()->GetStreamingLevels())
		{
			// If an unloaded levelstreaming still has a loaded level we need to mark its objects to be deleted as well
			if (LevelStreaming->GetLoadedLevel() && (!LevelStreaming->ShouldBeLoaded() || !LevelStreaming->ShouldBeVisible()))
			{
				CastChecked<UWorld>(LevelStreaming->GetLoadedLevel()->GetOuter())->MarkObjectsPendingKill();
			}
		}

		// Stop all audio to remove references to current level.
		if (FAudioDevice* AudioDevice = WorldContext.World()->GetAudioDeviceRaw())
		{
			AudioDevice->Flush(WorldContext.World());
			AudioDevice->SetTransientMasterVolume(1.0f);
		}

		WorldContext.SetCurrentWorld(nullptr);
	}

	FMoviePlayerProxy::BlockingTick();
	// trim memory to clear up allocations from the previous level (also flushes rendering)
	if (GDelayTrimMemoryDuringMapLoadMode == 0)
	{
		TrimMemory();
	}

	// Cancels the Forced StreamType for textures using a timer.
	if (!IStreamingManager::HasShutdown())
	{
		IStreamingManager::Get().CancelForcedResources();
	}

	if (FPlatformProperties::RequiresCookedData())
	{
		appDefragmentTexturePool();
		appDumpTextureMemoryStats(TEXT(""));
	}

	// if we aren't trimming memory above, then the world won't be fully cleaned up at this point, so don't bother checking
	if (GDelayTrimMemoryDuringMapLoadMode == 0)
	{
		// Dump info
		VerifyLoadMapWorldCleanup();
	}

	FMoviePlayerProxy::BlockingTick();

	MALLOC_PROFILER( FMallocProfiler::SnapshotMemoryLoadMapMid( URL.Map ); )

	WorldContext.OwningGameInstance->PreloadContentForURL(URL);

	UPackage* WorldPackage = NULL;
	UWorld*	NewWorld = NULL;

	// If this world is a PIE instance, we need to check if we are traveling to another PIE instance's world.
	// If we are, we need to set the PIERemapPrefix so that we load a copy of that world, instead of loading the
	// PIE world directly.
	if (!WorldContext.PIEPrefix.IsEmpty())
	{
		for (const FWorldContext& WorldContextFromList : WorldList)
		{
			// We want to ignore our own PIE instance so that we don't unnecessarily set the PIERemapPrefix if we are not traveling to
			// a server.
			if (WorldContextFromList.World() != WorldContext.World())
			{
				if (!WorldContextFromList.PIEPrefix.IsEmpty() && URL.Map.Contains(WorldContextFromList.PIEPrefix))
				{
					FString SourceWorldPackage = UWorld::RemovePIEPrefix(URL.Map);

					// We are loading a new world for this context, so clear out PIE fixups that might be lingering.
					// (note we dont want to do this in DuplicateWorldForPIE, since that is also called on streaming worlds.
					GPlayInEditorID = WorldContext.PIEInstance;
					UpdatePlayInEditorWorldDebugString(&WorldContext);
					FLazyObjectPtr::ResetPIEFixups();

					NewWorld = UWorld::DuplicateWorldForPIE(SourceWorldPackage, nullptr);
					if (NewWorld == nullptr)
					{
						NewWorld = CreatePIEWorldByLoadingFromPackage(WorldContext, SourceWorldPackage, WorldPackage);
						if (NewWorld == nullptr)
						{
							Error = FString::Printf(TEXT("Failed to load package '%s' while in PIE"), *SourceWorldPackage);
							return false;
						}
					}
					else
					{
						WorldPackage = CastChecked<UPackage>(NewWorld->GetOuter());
					}

					NewWorld->StreamingLevelsPrefix = UWorld::BuildPIEPackagePrefix(WorldContext.PIEInstance);
					GIsPlayInEditorWorld = true;
				}
			}
		}
	}

	// Is this a minimal net RPC world?
	if (WorldContext.WorldType == EWorldType::GameRPC)
	{
		UGameInstance::CreateMinimalNetRPCWorld(*URL.Map, WorldPackage, NewWorld);
	}

	const FString URLTrueMapName = URL.Map;

	// Normal map loading
	if (NewWorld == NULL)
	{
		// Set the world type in the static map, so that UWorld::PostLoad can set the world type
		const FName URLMapFName = FName(*URL.Map);
		UWorld::WorldTypePreLoadMap.FindOrAdd( URLMapFName ) = WorldContext.WorldType;

		// See if the level is already in memory
		WorldPackage = FindPackage(nullptr, *URL.Map);

		bool bPackageAlreadyLoaded = (WorldPackage != nullptr);

		// If the level isn't already in memory, load level from disk
		if (WorldPackage == nullptr)
		{
			WorldPackage = LoadPackage(nullptr, *URL.Map, (WorldContext.WorldType == EWorldType::PIE ? LOAD_PackageForPIE : LOAD_None));
		}

		// Clean up the world type list now that PostLoad has occurred
		UWorld::WorldTypePreLoadMap.Remove( URLMapFName );

		if (WorldPackage == nullptr)
		{
			// it is now the responsibility of the caller to deal with a NULL return value and alert the user if necessary
			Error = FString::Printf(TEXT("Failed to load package '%s'"), *URL.Map);
			return false;
		}

		// Find the newly loaded world.
		NewWorld = UWorld::FindWorldInPackage(WorldPackage);

		// If the world was not found, it could be a redirector to a world. If so, follow it to the destination world.
		if ( !NewWorld )
		{
			NewWorld = UWorld::FollowWorldRedirectorInPackage(WorldPackage);
			if ( NewWorld )
			{
				// Treat this as an already loaded package because we were loaded by the redirector
				bPackageAlreadyLoaded = true;
				WorldPackage = NewWorld->GetOutermost();
			}
		}

		// This can still be null if the package name is ambiguous, for example if there exists a umap and uasset with the same
		// name.
		if (NewWorld == nullptr)
		{
			// it is now the responsibility of the caller to deal with a NULL return value and alert the user if necessary
			Error = FString::Printf(TEXT("Failed to load package '%s'"), *URL.Map);
			return false;
		}


		NewWorld->PersistentLevel->HandleLegacyMapBuildData();

		FScopeCycleCounterUObject MapScope(WorldPackage);

		if (WorldContext.WorldType == EWorldType::PIE)
		{
			// If we are a PIE world and the world we just found is already initialized, then we're probably reloading the editor world and we
			// need to create a PIE world by duplication instead
			if (bPackageAlreadyLoaded || NewWorld->WorldType == EWorldType::Editor)
			{
				if (WorldContext.PIEInstance == -1)
				{
					// Assume if we get here, that it's safe to just give a PIE instance so that we can duplicate the world 
					//	If we won't duplicate the world, we'll refer to the existing world (most likely the editor version, and it can be modified under our feet, which is bad)
					// So far, the only known way to get here is when we use the console "open" command while in a client PIE instance connected to non PIE server 
					// (i.e. multi process PIE where client is in current editor process, and dedicated server was launched as separate process)
					WorldContext.PIEInstance = 0;
				}

				NewWorld = CreatePIEWorldByDuplication(WorldContext, NewWorld, URL.Map);
				// CreatePIEWorldByDuplication clears GIsPlayInEditorWorld so set it again
				GIsPlayInEditorWorld = true;
			}
			// Otherwise we are probably loading new map while in PIE, so we need to rename world package and all streaming levels
			else if (WorldContext.PIEInstance != -1 && ((Pending == nullptr) || (Pending->GetDemoNetDriver() != nullptr)))
			{
				NewWorld->RenameToPIEWorld(WorldContext.PIEInstance);
			}
			ResetPIEAudioSetting(NewWorld);

#if WITH_EDITOR
			// PIE worlds should use the same feature level as the editor
			if (WorldContext.PIEWorldFeatureLevel != ERHIFeatureLevel::Num && NewWorld->FeatureLevel != WorldContext.PIEWorldFeatureLevel)
			{
				NewWorld->ChangeFeatureLevel(WorldContext.PIEWorldFeatureLevel);
			}
#endif
		}
		else if (WorldContext.WorldType == EWorldType::Game)
		{
			// If we are a game world and the world we just found is already initialized, then we're probably trying to load
			// an independent fresh copy of the world into a different context. Create a package with a prefixed name
			// and load the world from disk to keep the instances independent. If this is the case, assume the creator
			// of the FWorldContext was aware and set WorldContext.PIEInstance to a valid value.
			if (NewWorld->bIsWorldInitialized && WorldContext.PIEInstance != -1)
			{
				NewWorld = CreatePIEWorldByLoadingFromPackage(WorldContext, URL.Map, WorldPackage);

				if (NewWorld == nullptr)
				{
					Error = FString::Printf(TEXT("Failed to load package '%s' into a new game world."), *URL.Map);
					return false;
				}
			}
		}
	}
	NewWorld->SetGameInstance(WorldContext.OwningGameInstance);

	GWorld = NewWorld;

	WorldContext.SetCurrentWorld(NewWorld);
	WorldContext.World()->WorldType = WorldContext.WorldType;

#if !UE_BUILD_SHIPPING
	GWorld->bCreateRenderStateForHiddenComponentsWithCollsion = bOldWorldWasShowingCollisionForHiddenComponents;
#endif

	// PIE worlds are not added to root and are initialized differently
	if (WorldContext.WorldType == EWorldType::PIE)
	{
		check(WorldContext.World()->GetOutermost()->HasAnyPackageFlags(PKG_PlayInEditor));
		WorldContext.World()->ClearFlags(RF_Standalone);

		PostCreatePIEWorld(WorldContext.World());
	}
	else
	{
		WorldContext.World()->AddToRoot();

		// The world should not have been initialized before this
		if (ensure(!WorldContext.World()->bIsWorldInitialized))
		{
			WorldContext.World()->InitWorld();
		}
	}

	// Handle pending level.
	if( Pending )
	{
		check(Pending == WorldContext.PendingNetGame);
		MovePendingLevel(WorldContext);
	}
	else
	{
		check(!WorldContext.World()->GetNetDriver());
	}

	WorldContext.World()->SetGameMode(URL);

	if (FAudioDevice* AudioDevice = WorldContext.World()->GetAudioDeviceRaw())
	{
		if (AWorldSettings* WorldSettings = WorldContext.World()->GetWorldSettings())
		{
			AudioDevice->SetDefaultBaseSoundMix(WorldSettings->DefaultBaseSoundMix);
		}
		else
		{
			UE_LOG(LogInit, Warning, TEXT("Unable to get world settings. Can't initialize default base soundmix."));
		}
	}

	// Listen for clients.
	if (Pending == NULL && (!GIsClient || URL.HasOption(TEXT("Listen"))))
	{
		if (!WorldContext.World()->Listen(URL))
		{
			UE_LOG(LogNet, Error, TEXT("LoadMap: failed to Listen(%s)"), *URL.ToString());
		}
	}

	const TCHAR* MutatorString = URL.GetOption(TEXT("Mutator="), TEXT(""));
	if (MutatorString)
	{
		TArray<FString> Mutators;
		FString(MutatorString).ParseIntoArray(Mutators, TEXT(","), true);

		for (int32 MutatorIndex = 0; MutatorIndex < Mutators.Num(); MutatorIndex++)
		{
			LoadPackagesFully(WorldContext.World(), FULLYLOAD_Mutator, Mutators[MutatorIndex]);
		}
	}

	// Process global shader results before we try to render anything
	// Do this before we register components, as USkinnedMeshComponents require the GPU skin cache global shaders when creating render state.
	if (GShaderCompilingManager)
	{
		GShaderCompilingManager->ProcessAsyncResults(false, true);
	}

	{
		DECLARE_SCOPE_CYCLE_COUNTER(TEXT("UEngine::LoadMap.LoadPackagesFully"), STAT_LoadMap_LoadPackagesFully, STATGROUP_LoadTime);

		// load any per-map packages
		check(WorldContext.World()->PersistentLevel);
		LoadPackagesFully(WorldContext.World(), FULLYLOAD_Map, WorldContext.World()->PersistentLevel->GetOutermost()->GetName());

		// Make sure "always loaded" sub-levels are fully loaded
		WorldContext.World()->FlushLevelStreaming(EFlushLevelStreamingType::Visibility);

		if (!GIsEditor && !IsRunningDedicatedServer())
		{
			// If requested, duplicate dynamic levels here after the source levels are created.
			WorldContext.World()->DuplicateRequestedLevels(FName(*URL.Map));
		}
	}
	FMoviePlayerProxy::BlockingTick();

#if WITH_EDITOR
	// Gives a chance to any assets being used for PIE/game to complete
	FAssetCompilingManager::Get().ProcessAsyncTasks();
#endif

	// Note that AI system will be created only if ai-system-creation conditions are met
	WorldContext.World()->CreateAISystem();

	// Initialize gameplay for the level.
	{
		FRegisterComponentContext Context(WorldContext.World());
		WorldContext.World()->InitializeActorsForPlay(URL, true, &Context);
		Context.Process();
	}

	// calling it after InitializeActorsForPlay has been called to have all potential bounding boxed initialized
	FNavigationSystem::AddNavigationSystemToWorld(*WorldContext.World(), FNavigationSystemRunMode::GameMode);

	// Remember the URL. Put this before spawning player controllers so that
	// a player controller can get the map name during initialization and
	// have it be correct
	WorldContext.LastURL = URL;
	WorldContext.LastURL.Map = URLTrueMapName;

	if (WorldContext.World()->GetNetMode() == NM_Client)
	{
		WorldContext.LastRemoteURL = URL;
	}
	FMoviePlayerProxy::BlockingTick();

	// Spawn play actors for all active local players
	if (WorldContext.OwningGameInstance != NULL)
	{
		for(auto It = WorldContext.OwningGameInstance->GetLocalPlayerIterator(); It; ++It)
		{
			FString Error2;
			if(!(*It)->SpawnPlayActor(URL.ToString(1),Error2,WorldContext.World()))
			{
				UE_LOG(LogEngine, Fatal, TEXT("Couldn't spawn player: %s"), *Error2);
			}
		}
	}

	// Prime texture streaming.
	IStreamingManager::Get().NotifyLevelChange();

	if (GEngine && GEngine->XRSystem.IsValid())
	{
		GEngine->XRSystem->OnBeginPlay(WorldContext);
	}
	WorldContext.World()->BeginPlay();

	FMoviePlayerProxy::BlockingTick();
	// send a callback message
	PostLoadMapCaller.Broadcast(WorldContext.World());
	MoviePlayerBlock.Finish();
	FMoviePlayerProxy::BlockingForceFinished();

	WorldContext.World()->bWorldWasLoadedThisTick = true;

	// We want to update streaming immediately so that there's no tick prior to processing any levels that should be initially visible
	// that requires calculating the scene, so redraw everything now to take care of it all though don't present the frame.
	RedrawViewports(false);

	// RedrawViewports() may have added a dummy playerstart location. Remove all views to start from fresh the next Tick().
	IStreamingManager::Get().RemoveStreamingViews( RemoveStreamingViews_All );

	// See if we need to record network demos
	if ( WorldContext.World()->GetAuthGameMode() == NULL || !WorldContext.World()->GetAuthGameMode()->IsHandlingReplays() )
	{
		if ( URL.HasOption( TEXT( "DemoRec" ) ) && WorldContext.OwningGameInstance != nullptr )
		{
			const TCHAR* DemoRecName = URL.GetOption( TEXT( "DemoRec=" ), NULL );

			// Record the demo, optionally with the specified custom name.
			WorldContext.OwningGameInstance->StartRecordingReplay( FString(DemoRecName), WorldContext.World()->GetMapName(), URL.Op );
		}
	}

	STAT_ADD_CUSTOMMESSAGE_NAME( STAT_NamedMarker, *(FString( TEXT( "LoadMapComplete - " ) + URL.Map )) );
	TRACE_BOOKMARK(TEXT("LoadMapComplete - %s"), *URL.Map);
	MALLOC_PROFILER( FMallocProfiler::SnapshotMemoryLoadMapEnd( URL.Map ); )

	double StopTime = FPlatformTime::Seconds();

	UE_LOG(LogLoad, Log, TEXT("Took %f seconds to LoadMap(%s)"), StopTime - StartTime, *URL.Map);
	FLoadTimeTracker::Get().DumpRawLoadTimes();
	WorldContext.OwningGameInstance->LoadComplete(StopTime - StartTime, *URL.Map);

	// perform the delayed TrimMemory if desired
	if (GDelayTrimMemoryDuringMapLoadMode != 0)
	{
		TrimMemory();

		if (GDelayTrimMemoryDuringMapLoadMode == 1)
		{
			// all future map loads should be normal
			GDelayTrimMemoryDuringMapLoadMode = 0;
		}
	}

	// Successfully started local level.
	return true;
}

이 길고 우람한 코드를 보아라.

이를 읽고 읽어 보았지만 이 이상은 추가적인 기반 지식이 요구가 되어 한번 끊어 가도록 하겠다.

 

거의 겉핥기 식으로 보았지만 소득이 없는 것은 아니었다.

우리는 최소한 Level이 어떻게 관리 되는지, 어떻게 열리는지 대략적인 구조를 파악했다.

남은건 Engine.h 안의 내용을 차근차근 익혀나가는 것이다.

 

아마 긴 싸움이 되지 않을까 싶다.

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

[Level] FWorldContext 분석 - 1  (0) 2022.07.10

UE4에서 제공하는 Tooltip 예시 이미지. 출처: Ryan Laley 유튜브

"언리얼에서 툴팁을 제공하는데 이걸 왜 따로 만들지?"라고 생각하실 수 있습니다.

언리얼 툴팁은 몇가지 특징이 있습니다.

 

1. 툴팁 UI가 적당히 잘 배치됩니다.

"적당히 잘"은 언뜻 들었을 때에는 썩 괜찮은 단어입니다.

하지만 시각적인 부분이 중요한 UI에서 "적당히 잘"은 최소한의 기능 제공만 의미하기도 합니다.

실제로 언리얼이 제공하는 툴팁은 보통 클릭한 커서를 중심으로 위젯이 호출됩니다.

이는 몇가지 누락된 기능이 있는데, 가장 아쉬운 점은 "화면 밖으로 나가는 것을 막지 못한다"는 점입니다.

800*600 화면에서 700위치에 150*50 크기의 툴팁을 생성하면 1/3은 화면 밖에 나가게 되는 것이죠.

 

2. 원하는 위치에 배치할 수 없다.

예를 들어 인벤토리에서 아이템 융합 UI를 생성한다고 합시다.

이 UI는 현재 융합 대기중인 아이템 뿐만 아니라 남은 아이템 목록도 한 눈에 들어와야 하기 때문에 인벤토리와 겹치면 안됩니다.

이럴 때 이 UI를 툴팁으로 만들어 버린다면 원하는 스펙을 맞출 수 없겠죠.

 

위에서 얘기한 것을 바탕으로, 새로 만들 툴팁은 최소 2가지 기능을 갖추어야 합니다.

1) 내가 원하는 위치에 정확히 배치를 해야 한다.

2) 화면 밖으로 나가지 않아야 한다.

 

1. UI의 위치 적용

1. 툴팁 위젯을 대상 위젯과 중심을 일치시키는 작업

언리얼상에서 디스플레이의 원점은 좌상단입니다.(사실 보통 그래픽스는 대체로 그렇습니다.)

그리고 UI의 기본 중심 또한 좌상단으로 잡혀 있죠.

하지만 툴팁은 기준 UI의 무게중심(개념상 일치하니 이렇게 지칭하겠습니다.)을 기준으로 그 외각에 붙어 있어야 하죠.

그렇기 때문에 1차적으로 각 UI의 중심지를 좌상단에서 무게중심으로 이동시키고,

툴팁 UI의 중심과 기준 UI의 중심을 좌표상 같은 곳에 일치하도록 하는 작업을 선행해야 합니다.

 

이는 여러분들이 고등학교(근래는 대학교)에서 배운 기하와 벡터만으로 충분합니다.

기준 UI(이하 A)의 좌상단 좌표가 (a1, a2)이고, 툴팁 UI(이하 B)의 좌상단이 (b1, b2)입니다.

여기서 A의 중심 좌표가 A`(a`1, a`2)이고, B의 것은 B`(b`1, b`2)입니다.

이 때 B와 A의 중심 좌표를 일치시키기 위해서는 디스플레이서부터 A`까지의 벡터와 디스플레이서부터 B`까지의 벡터를 감산 해야 합니다.

그렇게 되면 AB(a`1 - b`1, a`2 - b`2)라는 벡터가 나오게 됩니다.

2. 툴팁을 중심 UI와 겹치지 않게 하는 작업

A와 B가 겹쳐지면 이제 B를 A의 테두리 바깥에 붙도록 위치를 옮겨야 합니다.

이는 1보다는 훨씬 간단한 작업입니다.

B를 옮길 방향에 맞게 A와 B의 해당 크기 값을 연산해주면 됩니다.

여기서는 B를 A의 상단에 붙일겁니다.

이 때 A의 크기는 Ax * Ay이고, B의 크기는 Bx * By입니다.

그럼 B의 좌표(A`1, A`2)에 A와 B의 y크기의 절반만큼을 감산을 합니다.

감산을 하는 이유는 원점이 좌상단이기 때문에 윗 방향은 -Y축이기 때문이구요.

그럼 B의 중심 좌표가 이제 (A`1, A`2 - (Ay + By) / 2)가 됩니다.

 

3. 화면 밖으로 나간 툴팁을 다시 안으로 밀어줘야 한다.

일반적으로는 2번 과정까지만 하면 큰 문제가 없습니다.

하지만 우리는 한가지 더 고려를 해줘야 합니다.

B가 화면 밖으로 삐져나가는 경우에 다시 안쪽으로 밀어줘야 합니다.

 

이 또한 간단한 산수작업입니다.

B의 상단서부터 A의 중심까지의 Y값은 By + Ay / 2입니다.

그리고 실제 A의 중심의 Y좌표값은 A'y입니다.

이들의 차인 By + Ay / 2 - A`y 는 B가 화면 밖으로 나간만큼의 Y 수치가 되는 것이죠.

이만큼 Y좌표 값에 가산을 해주면 B가 화면에 맞게 조절이 됩니다.

 

4. 툴팁 완성

 

자세히 보면 B가 A의 위에 살짝 걸쳐진 것을 볼 수 있습니다.

하지만 어쩔 수 없습니다. 여기서는 B의 크기를 고정해놓기 때문에 A와의 겹침보다 화면 안에 들어가는게 더 우선순위가 높거든요.

물론 UI를 줄일 수 있으나 이런 UI들은 특성상 상당히 많은 정보를 제공하기 때문에 쉬이 크기 조절을 할 수 없을겁니다.

장담컨에 디 방식이 보편적인 타협책이라고 봅니다.

 

이런 과정을 거치기 위해서는 우리는 5개의 값이 필요합니다.

1) 화면 전체 크기

2) 기준 UI의 중심 좌표

3) 기준 UI의 크기

4) 툴팁 UI의 중심 좌표

5) 툴팁 UI의 크기

 

이를 3가지로 줄일 수 있는 것이 있는데, 바로 FGeometry입니다.

https://docs.unrealengine.com/4.26/en-US/API/Runtime/SlateCore/Layout/FGeometry/

 

FGeometry

Represents the position, size, and absolute position of a Widget in Slate.

docs.unrealengine.com

 

FGeomtery는 의미 그대로 Geometry. 크기와 좌표 정보를 가지고 있습니다.

이걸 이용하면 위 5가지 정보를 아래 3가지로 압축할 수 있습니다.

1) 화면 전체 크기

2) 기준 UI의 Geometry

3) 툴팁 UI의 Geometry

 

여기서 한가지 편의사항을 알려드리자면, 게임 개발 하면서 화면 전체를 덮는 UI가 적어도 1개는 발생을 하게 됩니다.

그 UI의 Geometry를 구한다면, 화면 전체의 크기를 구할 수 있게 되죠.

그럼 최종적으로 이렇게 됩니다.

1) 화면 전체를 덮는 UI의 Geometry

2) 기준 UI의 Geometry

3) 툴팁 UI의 Geometry

 

Geometry를 이용한 위젯의 위치 변경은 대략 이렇게 표현이 가능할 것 같습니다.

const FVector2D GetMovementVector(const FGeometry& DisplayGeometry, const FGeometry& IconGeometry, const FGeometry& TooltipGeometry, const EAlignVertical:::Type& VertAlign, cont EAlignHorizental::Type& HorAlign)
{
	const FVector2D& DisplaySize = DisplayGeometry.GetAbsoluteSize();

	const FVector2D& IconCenterPosition = IconGeometry.GetAbsolutePositionAtCoordinates(FVector2D(0.5f, 0.5f));
    const FVeoctor2D& IconCenterVector = DisplayGeometry.AbsoluteToLocal(IconCenterPosition);
    const FVector2D& IconSize = IconGeometry.GetAbsoluteSize();
    
	const FVector2D& TooltipCenterPosition = TooltipGeometry.GetAbsolutePositionAtCoordinates(FVector2D(0.5f, 0.5f));
    const FVeoctor2D& TooltipCenterVector = DisplayGeometry.AbsoluteToLocal(TooltipCenterPosition);
    const FVector2D& TooltipSize = IconGeometry.GetAbsoluteSize();
    
    FVector2D MoveVector = IconCenterVector = TooltipCenterVector;
    const FVector2D& TotalOutlineVector = IconSize + TooltipSize;
    Switch(VertAlign)
    {
    	case EAlignVertical::Type::Left
        	MoveVector.x -= TotalOutlineVector.x;
        	break;
        case EAlignVertical::Type::Right
        	MoveVector.x += TotalOutlineVector.x;
            break;
    }
    MoveVector.x = FMath::Clamp(MoveVector.x, 0, DisplaySize.x);
    
    swietch(HorAlign)
    {
    	case EAlignHorizental::Type::Up
        	MoveVector.y -= TotalOutlineVector.y;
            break;
        case EAlignHorizental::Type::Down
        	MoveVector.y += TotalOutlineVector.y;
            break;            
    }
    MoveVector.y = FMath::Clamp(MoveVector.y, 0, DisplaySize.x);
    
    return MoveVector;
}

 

수식은 위에서 다 설명 하였고, 몇가지 함수 설명을 첨하면 될 것 같습니다.

 

GetAbsolutePositionAtCoordinates는 NormalizedVector를 통해 중심값에서부터 절대 좌표값을 구하는 수식입니다.

즉, UI의 좌상단을 기준으로 무게중심의 절대 좌표를 구하는 함수입니다.

AbsoluteToLocal은 절대 좌표를 해당 Geometry의 Position을 기준으로 지역 좌표로 변환하는 함수입니다.

위 코드에서는 DisplayGeometry의 Position, 즉 화면의 좌상단을 기준으로 하는 상대 좌표로 변환을 하게 됩니다.

이를 통해 TooltipCenterVector와 IconCenterVector는 Display상에서의 값들로 변환이 되게 됩니다.

 

화면 밖으로 나가는 것은 DisplaySize를 구해놓고 Clamp로 변환을 해주었습니다.

Vector2D Clamp가 따로 있었다면 좋았겠지만 아쉽게도 없더군요.

 

자 이렇게 해서 간단한 수식을 작업해 보았습니다.

이걸로 마무리...를 하면 여러분들은 제대로 된 결과를 보지 못하게 될겁니다.

그리고 절 욕하겠죠.

위 코드는 한가지 맹점이 있습니다. 무엇일까요?

 

바로 Geometry 부분입니다.

정확히는 저 Geometry가 런타임 상에서 Invalid한 값이 들어올 수 있다는 점입니다.

보통 Geometry 값을 받아올 때에는 GetCachedGeometry라는 함수를 사용합니다.

https://docs.unrealengine.com/4.27/en-US/BlueprintAPI/Widget/GetCachedGeometry/

 

Get Cached Geometry

Get Cached Geometry

docs.unrealengine.com

그런데 이 GetCachedGeometry는 구조 상 몇가지 제약이 있습니다.

1) 화면상에 노출되어야 한다.

2) 최상위 레이어에 위치해야 한다.

 

이는 GetCachedGeometry가 호출 될 시 그 Tick에서의 Geometry 정보를 담아와서 반환하기 때문입니다.

사실 UI의 크기가 고정적이라면 아무런 문제가 되지 않습니다.

UI 내부에 USizeBox를 두고 모든 항목을 그 아래로 내린 뒤, OverrideSize를 지정해서 사이즈를 고정해버리면 해당 SizeBox의 OverrideSize로 값을 연산할 수 있을테니까요.

하지만 UI가 내부 데이터에 따라 크기가 변동이 된다면 엄청난 오차가 발생을 하게 될겁니다.

그리고 이런 눈꼴시러운 오차를 없애기 위해 노력을 하다 보면 저 GetCachedGeometry에서 막히게 될겁니다.

 

저 제약이 썩 거지같은 점은 "무조건 한번은 화면 레이어에 올라가야 한다"는 점입니다.

이는 디스플레이에 한번은 찍혀야 하는 의미이고, 이는 곧 잔상을 의미합니다.

이제 우리는 3번째 기능을 고민해야 합니다.

3) 화면상에 잔상이 남지 않고 어느 사이즈더라도 동작하는 Geometry 값을 구한다.

 

문제를 해결하기 위해서는 제약을 잘 살펴봐야 합니다.

1)을 다시 볼까요? 

"노출 되어야 한다"라고 되어 있지 "표시되어야 한다"라고 되어 있지 않습니다.

이것이 의미하는 바는, 어떤 형태로든 화면상에 나오기만 하면 Valid한 Geometry를 구할 수 있다는 점입니다.

그것이 "투명"하더라도 말이죠.

 

네 3번째 기능에 대한 해답은 바로 Opacity에서 alpha값을 0으로 내려버리는 것이었습니다.

이렇게 하면 레이어 상으로는 최상위에 툴팁 UI가 올라가 있지만, 투명하기 때문에 유저들이 육안으로 볼 수는 없죠.

이 때 Geometry를 구해서 위치를 옮겨주고, alpha값을 복구하면 잔상 없는 커스텀 툹팁 기능이 완성이 됩니다.

 

UTooltipWidger : public UWidget
{
	virtual void NativeConstruct() override
    {
    	SetOpacity(0.f, 0.f, 0.f, 0.f);
    }

	virtual void Open(bool bCancelAnim) override
    {
    	Super::Open(bCancelAnim);
    	auto World = GetWorld();
        if(IsValid(World) == false)
        {
        	return;
        }
        
        World->GetTimerManager().AddTimer(PositionHandle, this, &ThisClass::UpdatePosition, World->GetTickDeltaTime());
    }
    
    void UpdatePosition()
    {
    	const FGeometry& DisplayGeometry = GetDisplayGeometry();	// 어떻게 구해 왔다고 가정
        const FGeometry& IconGeometry = GetIconGeometry();			// 어떻게 구해 왔다고 가정
        const FGeometry& TooltipGeometry = GetCachedGeoemtry();
        
        SetPosition(GetMovementVector(DisplayGeometry, IconGeometry, TooltipGeometry, EVerticalAlign::Center, EHorizentalAlign::Up));
    	SetOpacity(0.f, 0.f, 0.f, 1.f);
	}
}

 

물론 이 방식은 약간의 딜레이를 요구합니다.

하지만 그 수치가 Tick 함수가 한번 호출 될 정도의 간극이라는 점을 감안하면 손해라고는 있을 수 없는 로직이라 볼 수도 있습니다.

 

저처럼 개발자(혹은 타 업무자)가 좀 더 디테일하게 커스텀이 가능한 툴팁을 필요로 하는 경우가 얼마나 있을지 잘 모르겠습니다.

특히 이런 블로그 글을 보고 들어온건 개인개발이라는 건데 개인 개발에서 이런 정도의 스펙은 잘 구현 안하잖아요?

하지만 어떻게 해야 할지 조금 막막한 기능이었으나 의외로 쉬운 로직이라는 점을 명시하면서.

이 글이 필요로 하시는 분들에게 도움이 되었으면 좋겠습니다.

어제 Monatge를 상속으로 처리를 시도해보겠다고 했는데 Montage를 상속으로 처리하니까 Animation 재생 시간이 부모의 것에 고정이 되었습니다.

이를 맞추느니 그냥 새 Montage를 만드는게 좋겠다 싶어서 이전에 만들어 놓은 Montage를 다 따로 분리해놓았습니다.

 

이렇게 해놓으니 다시 몇가지 고민이 생겼습니다.

하나는 이동 애니메이션이 중간에 멈췄을 때 매끄럽지 못할 수 있다는 점.

다른 하나는 방향 전환 시 대처가 될지 감이 안잡힌다는 점입니다.

 

그래서 결국 예전 방식으로 회귀를 해야 할 것 같습니다.

상하좌우 이동 관련된 애니메이션을 만든 뒤 캐릭터 키 바인드와 카메라 조절을 넣어서 테스트 환경을 먼저 조성.

이후 IK랑 에임 오프셋 등을 고려하면서 동작을 늘려가는 방향으로 조절했습니다.

 

한가지 더.

프로젝트에 적용 된 애니메이션 중 Generic NPC Anim Pack은 실제 동작에 활용 가능한 것이 매우 적어서 일부 필요한 Anim Sequence만 가져다 쓰고 실질적으로 내부 구현은 하지 않을 예정입니다.

마찬가지로 Death Animation도 전체를 사용하기 보다는 필요한 것만 가져다가 3개 이상을 사용할 예정입니다.

이 외에는 겹치는 것을 제외하고는 최대한 모두 활용을 하고자 합니다.

'개발일지 > 코어 플레이 개발' 카테고리의 다른 글

22년 07월 16일 개발일지  (0) 2022.07.16
22년 07월 15일 개발일지  (0) 2022.07.15
22년 07월 10일 개발일지  (0) 2022.07.10
22년 02월 19일 개발일지  (0) 2022.02.19
공부 다시 시작  (0) 2022.02.19

+ Recent posts