평생 공부하는 빠타박스 블로그 : Learning is Happiness
article thumbnail
SMALL

얼마전 시작했던 기능 프로젝트로 부터 맵 이동 구현하는데 있어서 내 프로젝트내에 비동기로딩이 필요했다.

 

이것은 게임 중 렌더링되어 처리되어야 할게 별로 없다면 그냥 동기방식으로 설정해도 무리는 없을 것 같지만. 다만 하도 고퀄이 되어가는 요즘시대에 게임은 오픈월드 방식을 고집하다 보니 계속 처리되고 좀더 간단한 방식이 필요하다 (한번 설정하면 계속 쓸 수 있는?)

 

나는 추후 프로젝트에 그런 맵 이동 방식을 구현하고자 동기와 비동기 방식에 대한 Level이동에 대해 연구하고 문서화하고 있다. 

 

언리얼 엔진 비동기 에셋 로딩

 

참고로 동기/비동기 방식이 무엇인지 무슨 뜻인지 잘 모르겠다면.

이쪽 블로그를 참고해보면 좋을 듯 싶다.

 

인파님 동기/비동기&블로킹/논블록킹

동기와 비동기의 개념 차이

 

👩‍💻 완벽히 이해하는 동기/비동기 & 블로킹/논블로킹

동기/비동기 & 블로킹/논블록킹 프로그래밍에서 웹 서버 혹은 입출력(I/O)을 다루다 보면 동기/비동기 & 블로킹/논블로킹 이러한 용어들을 접해본 경험이 한번 쯤은 있을 것이다. 대부분 사람들은

inpa.tistory.com

 

동기(Synchronous: 동시에 일어나는)

동기 방식(Synchronous)는 요청을 보낸뒤에 결과를 받아야 다음 작업을 시작하는 방식이고, 

  • 설계간단
  • 결과가 주어질때 까지 대기해야함

비동기(Asynchronous : 동시에 일어나지 않는)

비동기 방식(Asynchronous)는 동기와는 반대로 요청한 작업에 대해 완료 여부를 따지지 않기 때문에 자신의 다음 작업을 수행한다.

  • 결과가 주어지는 시간이 있지만 그 시간 동안 작업을 할 수 있어 효율적 
  • 복잡한 설계
더보기

이렇기 때문에 비동기방식이 자원을 효과적으로 처리할 수 있다는 것이다. 언 뜻 보기엔 만약 폴아웃, 스카이림 같은 콘솔게임에서 맵상을 이동할 때 동굴에 들어갈 때 동기방식을 취해야 하는게 아닐까? 

※이것은 나의 지극히 개인적인 추측이지만. 

위 말 뜻대로 오픈월드상은 비동기방식이 어울린다. 계속해서 실시간으로 플레이어한테 계속 지속적인 렌더링 처리를 해서 화면상에 보여줘야 한다. 그리고 빨라야 한다. 그래서 비동기 방식이 적합하고 

 

오픈월드 이지만 어떤 곳은 비동기 방식이 어떤 곳은 동기방식이 필요한 맵을 렌더링 오브젝트 사물 등을 렌더링 해야 할 수 있다. 

 언리얼 엔진 Loading and UnLoading

OpenLevel()_LoadStreamLevel()


일반적인 OpenLevel()방식은 구현자체가 어렵지 않다 대부분 유튜브나 자료에서 많이들 보았을 것이다.

이것 또한 AsyncLoading에 대한 구현을 눈속임 방식으로 구현이 가능하다.

다만 둘의 구분을 잘 봐야한다.

 

이게 정확히 어떤식으로 되어가는지 알 순 없지만. 

 

OpenLevel()

  • 기존 레벨을 파괴하고 새로운 레벨을 로드한다. (레벨과 레벨의 전환)
  • GameInstance를 제외하고 다 날아감
  • 로딩 진행 상황을 알 수 없습니다.
  • 동기적이므로 OpenLevel 동안 게임엔진 스레드를 제외하고 모두 Stuck이 된다.

물론 나와 같이 저런식으로 OpenLevel을 이름으로 또 경로로 설정해줄 필요는 없다. 

ObjectReference로 해당 Level클래스에 대해 직접 블루프린트로 넣어줄 수 있도록 UPROPERTY()해서 지정해줄 수 도 있을 것이다. 

 

C++Sample

그냥 다 때려 넣겠다. Soruce

더보기
/*copy right ppatabox*/
#include "CAsyncLevelMove.h"
#include "Kismet/GameplayStatics.h"
#include "Blueprint/UserWidget.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/PlayerController.h"
#include "TimerManager.h" // FTimerManager 사용을 위해 필요
#include "Misc/Paths.h" // FPlatformTime 사용을 위해 필요
#include "Engine/StreamableManager.h"
#include "Engine/AssetManager.h"


// Sets default values
ACAsyncLevelMove::ACAsyncLevelMove()
{
	// 트리거 박스 컴포넌트 생성 및 루트 컴포넌트로 생성
	TriggerBox = CreateDefaultSubobject<UBoxComponent>(TEXT("TrrigerBox"));
	RootComponent = TriggerBox;

	// 충돌 프리셋 및 이벤트 활성화
	TriggerBox->SetCollisionProfileName(TEXT("Trigger")); // 또는 적절한 프리셋 사용
	TriggerBox->SetGenerateOverlapEvents(true); // 오버랩 이벤트 활성화

	// 다른 액터가 트리거 박스에 들어올 때 OnActorEnter 함수를 호출하도록 설정
	TriggerBox->OnComponentBeginOverlap.AddDynamic(this, &ACAsyncLevelMove::OnActorEnter);

	bIsInputDisabled = false;
}


void ACAsyncLevelMove::BeginPlay()
{
	Super::BeginPlay();
}


void ACAsyncLevelMove::OnActorEnter(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	// 다른 액터가 존재하고, 자기 자신이 아닐 경우
	if (OtherActor && OtherActor != this)
	{
		// 입력 비활성화
		SetInputEnabled(false);
		// 페이드 인 설정
		SetFadeOut(2.0f, FColor::Black); // 1초 동안 페이드 인




		// 일정 시간 후 레벨 전환 및 페이드 아웃 처리
		GetWorld()->GetTimerManager().SetTimer(LevelTransitionHandle, [this]()
		{
				// 비동기 레벨 로드 요청
				LoadLevelAsync();

		}, 2.0f, false); // 페이드 인이 끝난 후 1초 뒤에 실행
	}
}

// 비동기 레벨 호출
void ACAsyncLevelMove::LoadLevelAsync()
{
	// 로딩 화면 위젯을 생성하고 화면 추가
	if (LoadingWidgetClass)
	{
		LoadingWidget = CreateWidget<UUserWidget>(GetWorld(), LoadingWidgetClass);
		if (LoadingWidget)
		{
			LoadingWidget->AddToViewport();
			UE_LOG(LogTemp, Log, TEXT("Loading Screen displayed."));
		}
	}

	//AssetManager의 StreambleManager 를 가져온다
	FStreamableManager& Streamable = UAssetManager::GetStreamableManager();

	// 비동기로드 요청
	FSoftObjectPath LevelPath(FString::Printf(TEXT("/Game/AsyncLevelMapMove/Levels/%s"), *NextLevelName.ToString()));

	/*
	FString LevelPath = FString::Printf(TEXT("/Game/AsyncLevelMapMove/Levels/%s"), *NextLevelName.ToString()));
	FSoftObjectPath LevelObjectPath(LevelPath); // 객체 생성 방식을 이런식으로도 가능하다.
	*/

	/* Debug : 로딩 시작 시간 기록 _ 01 */
	StartTime = FPlatformTime::Seconds();
	UE_LOG(LogTemp, Log, TEXT("AsyncLoading..."));


	// 비동기 로드 요청 및 핸들 저장
	TSharedPtr<FStreamableHandle> LocalHandle = Streamable.RequestAsyncLoad(LevelPath, FStreamableDelegate::CreateLambda([this]()
	{
			// 일정 시간 후 레벨 전환 타이머 설정
			FTimerHandle UnusedHandle;
			GetWorld()->GetTimerManager().SetTimer(UnusedHandle, this, &ACAsyncLevelMove::OnLevelLoadComplete, 5.0f, false); // 5초 지연
	}));
}

void ACAsyncLevelMove::OnLevelLoadComplete()
{
	// 로딩화면 제거
	if (LoadingWidget)
	{
		LoadingWidget->RemoveFromParent();
		UE_LOG(LogTemp, Log, TEXT("Loading screen removed."));
	}

	APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();

	if (PlayerController)
	{
		// 일정 시간 후에 페이드 인과 레벨 전환 수행
		FTimerHandle FadeHandle;
		GetWorld()->GetTimerManager().SetTimer(FadeHandle, [this, PlayerController]()
		{

				// 레벨 로드가 완료되면 해당 레벨로 전환
				UGameplayStatics::OpenLevel(this, NextLevelName);
				UE_LOG(LogTemp, Log, TEXT("Level transition started."));


				SetInputEnabled(true);

		}, 1.0f, false); //1초 뒤 레벨 전환 시작



		// 로딩 완료 시간 기록 및 출력
		EndTime = FPlatformTime::Seconds();
		double LoadDuration = EndTime - StartTime;
		UE_LOG(LogTemp, Log, TEXT("Loading time : %.2f second"), LoadDuration);
	}

	SetFadeIn(5.0f, FColor::Black);
	// UGameplayStatics::OpenLevel(this, NextLevelName);
}

void ACAsyncLevelMove::SetInputEnabled(bool bEnable)
{
	APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0);
	//ACharacter* PlayerCharacter = UGameplayStatics::GetPlayerCharacter(GetWorld(), 0);
	if (PlayerController)
	{
		if (bEnable)
		{
			// 입력 활성화
			PlayerController->SetInputMode(FInputModeGameOnly());
			PlayerController->SetIgnoreMoveInput(false);
			PlayerController->SetIgnoreLookInput(false);
			PlayerController->bShowMouseCursor = false;
			UE_LOG(LogTemp, Log, TEXT("Input Disabled"));
		}
		else
		{
			/*if (PlayerCharacter)
			{
				PlayerCharacter->DisableInput(PlayerController);
			}*/
			// 입력 비활성화
			PlayerController->SetInputMode(FInputModeUIOnly());
			PlayerController->bShowMouseCursor = true;
			PlayerController->SetIgnoreMoveInput(true);
			PlayerController->SetIgnoreLookInput(true);
			UE_LOG(LogTemp, Log, TEXT("Input Enabled (Default)"));
		}
	}
}


void ACAsyncLevelMove::SetFadeIn(float FadeTime, const FColor& FadeColor) // FadeIn 설정
{
	APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
	if (PlayerController)
	{
		PlayerController->ClientSetCameraFade(true, FadeColor, FVector2D(1.0f, 0.0f), FadeTime, false, true);
		UE_LOG(LogTemp, Log, TEXT("FadeIn"));
	}
}

void ACAsyncLevelMove::SetFadeOut(float FadeTime, const FColor& FadeColor) // FadeOut 설정
{
	APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
	if (PlayerController)
	{
		PlayerController->ClientSetCameraFade(true, FadeColor, FVector2D(0.0f, 1.0f), FadeTime, false, true);
		UE_LOG(LogTemp, Log, TEXT("FadeOut"));
	}
}

 

 

 

단 OpenLevel로 나는 비동기 구현을 했는데. 이것의문제가 좀 끊길 수 있다는 단점이 있다...

아무래도 다 지우고 다른 레벨을 새로 생성하다 보니.. 그런것 같다..

 

뭐 다른 방법이 있을 것 같긴한데... 5일째 그 방법을 구현하기 위해 해봤지만. 해결은 못했다... 하지만 좋은게 있는데 써야 하지 않을까? (어차피 동기방식의 OpenLevel자체는 좀 구현적인 문제가 발생할 수 있으니.. 필요할 때 쓰면 될것 같다. 젤다의 전설 사당 들어가는 것처럼?)


 

 

LoadStreamLevel()

  • 하나의 PersistentLevel(부모레벨)에 여러개의 레벨로 나누어 필요할 때 마다 Load/UnLoad 하는 방식
  • PersistentLevel - 항상 로드되어 있다. (언로드불가)
  • PersistentLevel을 우선 OpenLevel하고 밑에 자식Level들을 LoadStreamLevel로 로드한다(즉 기존 맵은 남아있는 상태(모든 환경설정) 맵이 추가로드된다.
  • GetAsyncLoadPercentage()을 사용해서 로딩 진행 상황을 알 수 있지만 정확하진 않다.
  • (레벨, 액터, 컴토넌트 각각 여러가지 ? 비동기 로딩 가능)

UGameplayStatics::LoadStreamLevel(
    UObject* WorldContextObject, // 레벨을 로드할 때 사용할 컨텍스트 객체, 일반적으로 'this' 사용
    FName LevelName,             // 로드할 레벨의 이름 (FName 형식)
    bool bMakeVisibleAfterLoad,  // true이면 로드 후 레벨을 즉시 화면에 보이게 설정, false이면 보이지 않음
    bool bShouldBlockOnLoad,     // true이면 로드 작업이 완료될 때까지 차단 (동기 방식), false이면 비동기 방식으로 로드
    FLatentActionInfo LatentInfo // 비동기 작업의 완료를 추적하기 위한 정보 (FLatentActionInfo 구조체)
);
UGameplayStatics::UnloadStreamLevel(
    UObject* WorldContextObject, // 레벨을 언로드할 때 사용할 컨텍스트 객체, 일반적으로 'this' 사용
    FName LevelName,             // 언로드할 레벨의 이름 (FName 형식)
    FLatentActionInfo LatentInfo,// 비동기 작업의 완료를 추적하기 위한 정보 (FLatentActionInfo 구조체)
    bool bShouldBlockOnUnload    // true이면 언로드 작업이 완료될 때까지 차단 (동기 방식), false이면 비동기 방식으로 언로드
);

 

 

블루프린트상에 시계표시는 별도의 쓰레드 처리를 한다는 의미이라고 알고 있다. 

 

OpenLevel도 AsyncLoading을 요청하는 것을 하면 작동 시킬 솔루션은 되지만. 별로 추천은 안한다. 뭐 동기방식이 필요한 작업일 경우 좋을 수 있다.

 

BlueprintSample

 

PersistentLevel에 2개의 레벨이 보이고 현재 PersistentLevel에 Player가 보인다. 저기 박스는 현재 WorldMap_01p로 설정해둔 상태이다. 다른 레벨 은 보이지 않게 된다. ( 현재 샘플에서는 PersistentLevel이 별도 처리 되고 있지 않다.)

현재 PersistentLevel은 블루프린트상 LoadStreamLevel되도록 계속 설정되어서 떠있는 것을 볼 수 있으나. WolrdMap03p로 가게되면

WorldMap01p가 언로드 된 것을 볼 수 있다.

 

또 이것은 LevelStreamingVolume 을 사용해 비동기 로딩 방식이 아닌 그냥 실시간으로 맵의 특정위치로 움직이면 다음 위치가 보여지게 처리도 가능하다.

 

 

 

참고해서 사용해보길 바란다.

PersistentLevel에서 반드시 처음 시작하는 레벨에 대한 지정을 꼭 해주자.

C++로는 현재 제작중이다. 

728x90
728x90
LIST
profile

평생 공부하는 빠타박스 블로그 : Learning is Happiness

@공부하는 PPATABOX

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!