UE5 소스 분석 - FEngineLoop::Tick (엔진 메인 루프)
언리얼은 엔진 루프를 담당하는 객체를 두고, 해당 객체에서 프로그램의 루프를 돌린다. 그런데 이 엔진 루프는 추상 클래스를 상속 받아서 만들어진 객체다. 왜 그랬을까?
→ 버전에 따라 다른 루프 객체를 만들어서 손쉽게 변경하기 위함이 아니었을까? 이 루프 객체만 갈아 끼우면 되니까.
1. 프레임 시작 - 종료 확인과 하트비트
void FEngineLoop::Tick()
{
// 매 프레임 종료 요청 확인
BeginExitIfRequested();
// 상태 진단 스레드에게 정보 보내는 객체인듯? 심장 박동이라고 표현한 것이 재밌다.
{
SCOPED_NAMED_EVENT(FEngineLoop_HeartBeat, SystemColor);
FThreadHeartBeat::Get().HeartBeat();
FGameThreadHitchHeartBeat::Get().FrameStart();
}2. 렌더 스레드 설정 Latch
// 프레임마다 렌더링 Config를 Latch(잠금)
// 스냅샷 형태로 잠금을 만든다고 함.
// 프레임에서 남긴 빵부스러기(흔적) 밖에서 호출이 되어야 한단다.
// 즉, 어떤 프레임이든 로그를 남기는데, 그 로그 남기는 내부에서 변경되면
// 추적이 어려워지기 때문에 그 밖에서 호출하라는 뜻
LatchRenderThreadConfiguration();
// 단일 스레드 기반일 때에도 렌더링 객체들을 Tick할 수 있게 보장
if (!GUseThreadedRendering)
{
SCOPED_NAMED_EVENT(Tick_RenderingTickables, SystemColor);
TickRenderingTickables(FRHICommandListImmediate::Get());
}3. 무비 플레이어 / 콘솔 변수 처리
// 영상 재생 중일 때에는 프레임 막는다.(보장됨)
// 이전 프레임에 막은 것을 풀어줌
FMoviePlayerProxy::BlockingForceFinished();
ensure(!IsMoviePlayerEnabled() || (GetMoviePlayer()->IsLoadingFinished() && !GetMoviePlayer()->IsMovieCurrentlyPlaying()));
// 외부 프로파일러와 프레임 싱크 맞춰주는 코드 생략
// 프레임 단위 프로파일링/추적용 코드 생략
// 변경된 콘솔 변수에 해당하는 콜백들 실행
{
SCOPED_NAMED_EVENT(FEngineLoop_CallAllConsoleVariableSinks, SystemColor);
IConsoleManager::Get().CallAllConsoleVariableSinks();
}4. 프레임 시작 브로드캐스트와 시간 갱신
{
// 델리게이트 호출 -> 프레임 시작에 등록된 콜백 호출
FCoreDelegates::OnBeginFrame.Broadcast();
// 다른 스레드에서 들어온 로그 출력/플러시
{
SCOPED_NAMED_EVENT(FEngineLoop_FlushThreadedLogs, SystemColor);
GLog->FlushThreadedLogs(EOutputDeviceRedirectorFlushOptions::Async);
}
// 현재 시간과 델타 타임을 갱신
{
GEngine->UpdateTimeAndHandleMaxTickRate();
GEngine->SetSimulationLatencyMarkerStart(CurrentFrameCounter);
}5. RHI 프레임 시작
RHI = 렌더링 하드웨어 인터페이스. 그래픽 API를 공통 인터페이스를 통해 사용하도록 만든 추상화 계층.
// 게임 스레드에서 -> 렌더 스레드용 명령으로 등록한다.
#if WITH_RHI_BREADCRUMBS
ENQUEUE_RENDER_COMMAND_NOMARKER(BeginFrame)([CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
{
BeginFrameRenderThread(RHICmdList, CurrentFrameCounter);
});
// 프레임 프로파일러 관련 중첩 회피 코드 생략
#endif // WITH_RHI_BREADCRUMBS
// 할당된 씬을 가져와서, 그 씬의 렌더 커맨드를 돌린다?
for (FSceneInterface* Scene : GetRendererModule().GetAllocatedScenes())
{
ENQUEUE_RENDER_COMMAND(FScene_StartFrame)([Scene](FRHICommandListImmediate& RHICmdList)
{
Scene->StartFrame();
});
}
// 씬과 렌더 스레드의 시작에 필요한 프레임 경계를 분리하여, 따로 프레임 시작을 여는 것.
// 씬은 레벨이 아니고, 렌더러 쪽 개념으로 렌더링 데이터를 관리하는 것. (ex 에디터의 미리보기)
// 위에까지가 이전 프레임에 기록된 명령들의 등록.(렌더링 명령)
// 아래부터 다시 렌더링 명령을 파이프에 미리 수집
UE::RenderCommandPipe::StartRecording();
// 실제 게임 실행 환경에서 조건이 맞으면
// 동적 해상도 시스템에 새 프레임 시작을 알린다.
#if !UE_SERVER && WITH_ENGINE
if (!GIsEditor && GEngine->GameViewport && GEngine->GameViewport->GetWorld() && GEngine->GameViewport->GetWorld()->IsCameraMoveable())
{
GEngine->EmitDynamicResolutionEvent(EDynamicResolutionStateEvent::BeginFrame);
}
#endif
}
// 성능 모니터링 코드 생략
// 메모리 할당자의 통계 갱신 생략(언리얼의 GMalloc)6. FPS 계산과 메시지 펌프
// FPS 계산
CalculateFPSTimings();
// 렌더링 스레드에서 수행되는 프레임별 작업들 중
// 지연 갱신 상태 초기화가 필요한 것들은 초기화
ENQUEUE_RENDER_COMMAND(ResetDeferredUpdates)(
[](FRHICommandListImmediate& RHICmdList)
{
FDeferredUpdateResource::ResetNeedsUpdate();
});
// 외부 임베드 상태가 아닐 때 윈도우 메시지 펌프
if (!GUELibraryOverrideSettings.bIsEmbedded)
{
GEngine->SetInputSampleLatencyMarker(CurrentFrameCounter);
SCOPED_NAMED_EVENT(FEngineLoop_PumpMessages, SystemColor);
FPlatformApplicationMisc::PumpMessages(true);
}
// 원래 윈메인에서 하던 메시지 처리를 여기서 하는구나7. 유휴 모드와 VR 스케일
// 절전 모드? 백그라운드일 때 굳~이 렌더링 안 함
bool bIdleMode;
{
bIdleMode = ShouldUseIdleMode();
if (bIdleMode)
{
FPlatformProcess::Sleep(.1f);
}
}
// 이건 VR 기능이라고 함. 근데 임시 기능이라고 함. @ToDo 표시 되어 있었음
extern ENGINE_API float GNewWorldToMetersScale;
if( GNewWorldToMetersScale != 0.0f )
{
#if WITH_ENGINE
UWorld* WorldToScale = GWorld;
#if WITH_EDITOR
if( GIsEditor && GEditor->PlayWorld != nullptr && GEditor->bIsSimulatingInEditor )
{
WorldToScale = GEditor->PlayWorld;
}
#endif //WITH_EDITOR
if( WorldToScale != nullptr )
{
if( GNewWorldToMetersScale != WorldToScale->GetWorldSettings()->WorldToMeters )
{
WorldToScale->GetWorldSettings()->WorldToMeters = GNewWorldToMetersScale;
}
}
GNewWorldToMetersScale = 0.0f;
}
#endif //WITH_ENGINE8. 파일 I/O와 Slate 입력 처리
// 윈도우 파일 API 외에도 파일 I/O 작업을 하는 계층을 둠.
// 그 계층의 프레임별 작업을 갱신
FPlatformFileManager::Get().TickActivePlatformFile();
// Slate Application은 UI 입력·창·위젯 시스템 전체를 관리하는 전역 애플리케이션 객체
// -> 패널이라고 보면 되겠네(혹은 윈도우)
if (FSlateApplication::IsInitialized() && !bIdleMode)
{
FSlateApplication& SlateApp = FSlateApplication::Get();
{
// 입력 장치의 현재 상태를 읽는다.
SlateApp.PollGameDeviceState();
}
// 이번 프레임에 누적된 입력을 위젯이 처리할 기회를 주고
{
// 종료
SlateApp.FinishedInputThisFrame();
}
}9. 엔진 본체 Tick
{
// 여기가 이제 내부 엔진 관리 업데이트인 듯
GEngine->Tick(FApp::GetDeltaTime(), bIdleMode);
}
// 위에 영상/로딩 영상 재생 시에 풀어줬던 것을 다시 막을지 판단
// 엔진 Tick이 돌면서 변했을 수 있으니까
{
if (FPreLoadScreenManager::Get())
{
// 영상 있는지 판단하는 과정 생략
// Destroy / Clean Up PreLoadScreenManager as we are now done
FPreLoadScreenManager::Destroy();
}
else if (IsMoviePlayerEnabled())
{
GetMoviePlayer()->WaitForMovieToFinish(true);
}
}
// 이번 프레임의 플랫폼 및 입력 상태를 먼저 갱신한다.
// 이후 네트워크와 병렬로 실행될 작업들이 일관된 입력 상태를 사용하도록 하기 위함이다.
if (FSlateApplication::IsInitialized() && !bIdleMode)
{
{
check(!IsRunningDedicatedServer());
ProcessLocalPlayerSlateOperations();
}
FSlateApplication::Get().Tick(ESlateTickType::PlatformAndInput);
}
// 네트워크 <-> 슬레이트 동기화 부분 이해 못해서 생략.10. 렌더 명령 기록 종료와 실제 렌더링
// 렌더 명령 기록 종료
{
UE::RenderCommandPipe::StopRecording();
for (FSceneInterface* Scene : GetRendererModule().GetAllocatedScenes())
{
ENQUEUE_RENDER_COMMAND(FScene_EndFrame)([Scene](FRHICommandListImmediate& RHICmdList)
{
Scene->EndFrame(RHICmdList);
});
}
{
// 실제로 장면 전체를 그리는 함수
RHITick( FApp::GetDeltaTime() ); // Update RHI.
}
}11. 정리 객체와 스레드 동기화
// 정리할 객체를 한 프레임 늦춰 삭제
FPendingCleanupObjects* PreviousPendingCleanupObjects = PendingCleanupObjects;
PendingCleanupObjects = GetPendingCleanupObjects();
// 혹여나 게임 스레드가 렌더링 스레드보다 앞서 나갈까봐 싱크 맞춰줌
FFrameEndSync::Sync(FFrameEndSync::EFlushMode::EndFrame);
{
// 이전 프레임 정리 객체 삭제
{
SCOPED_NAMED_EVENT(DeletePendingCleanupObjects, SystemColor);
delete PreviousPendingCleanupObjects;
#if WITH_COREUOBJECT
DeleteLoaders(); // destroy all linkers pending delete
#endif
}
// Core Ticker와 스레드 관리자 Tick(엔진 코어 수준의 범용 Tick)
{
FTSTicker::GetCoreTicker().Tick(FApp::GetDeltaTime());
FThreadManager::Get().Tick();
}
{
// 미뤄둔 엔진 명령 처리
GEngine->TickDeferredCommands();
}
}12. 프레임 종료
// 엔진 프레임 이제 끝났다!
{
SCOPED_NAMED_EVENT_TEXT("FCoreDelegates::OnEndFrame", CoreDelegatesColor);
FCoreDelegates::OnEndFrame.Broadcast();
}
// 프레임 카운터 올려!
// Increment global frame counter. Once for each engine tick.
GFrameCounter++;
// 너희도 너희 스레드 내부에서 알아서 올려!
ENQUEUE_RENDER_COMMAND(FrameCounter)(
[CurrentFrameCounter = GFrameCounter](FRHICommandListImmediate& RHICmdList)
{
GFrameCounterRenderThread = CurrentFrameCounter;
});
UpdateCoreCsvStats_EndFrame();
{
// 동적 해상도 종료!
GEngine->EmitDynamicResolutionEvent(EDynamicResolutionStateEvent::EndFrame);
}
// end of RHI frame
ENQUEUE_RENDER_COMMAND(EndFrame)(
[CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
{
EndFrameRenderThread(RHICmdList, CurrentFrameCounter);
});
}흐름 요약
- 종료 확인 → 하트비트
- 렌더 설정 Latch → 단일 스레드 렌더링 보장
- 무비 플레이어 블로킹 해제 → 콘솔 변수 콜백
- OnBeginFrame 브로드캐스트 → 로그 플러시 → 시간/델타타임 갱신
- RHI 프레임 시작 → 씬 StartFrame → 렌더 명령 파이프 기록 시작
- FPS 계산 → 윈도우 메시지 펌프
- 유휴 모드 판단 → 파일 I/O Tick → Slate 입력 수집
- GEngine→Tick (게임 로직 본체)
- Slate Tick (플랫폼/입력)
- 렌더 명령 기록 종료 → 씬 EndFrame → RHITick (실제 렌더링)
- 한 프레임 지연 삭제 + 게임/렌더 스레드 프레임 동기화
- OnEndFrame → 프레임 카운터 증가 (게임/렌더 스레드 각각) → RHI 프레임 종료
느낀 점
- 게임 스레드는 렌더링을 직접 하지 않고,
ENQUEUE_RENDER_COMMAND로 렌더 스레드에 명령을 등록만 한다. 프레임 카운터조차 게임 스레드 것(GFrameCounter)과 렌더 스레드 것(GFrameCounterRenderThread)이 따로 있고, 렌더 스레드 것은 렌더 명령으로 넘겨서 올리게 한다. - 정리 객체를 한 프레임 늦춰 삭제하는 것도 결국 렌더 스레드가 이전 프레임 데이터를 아직 쓰고 있을 수 있기 때문. 게임/렌더 스레드가 한 프레임 어긋나서 돌아간다는 전제가 루프 전체에 깔려 있다.
- 원래 WinMain에서 하던 메시지 펌프를 엔진 루프 안(
PumpMessages)에서 처리한다는 것도 재밌는 지점. - 여기도
SCOPED_NAMED_EVENT같은 스코프 객체 기반 프로파일링 태깅이 코드 전체에 깔려 있다. 이전 문서에서 본 패턴 그대로.