LYSC STUDIO

LIST
Cover

인디 개발자를 위한 Lumen과 Nanite 실전 최적화: 60FPS 달성을 위한 프로파일링과 렌더링 튜닝

·UE5 Engine

아름다운 그래픽은 무거운 사양을 요구하기 마련입니다. 차세대 렌더링 기술인 Lumen과 Nanite를 사용하면서도 인디 게임이 목표로 하는 하드웨어 스펙에서 안정적인 60FPS를 달성하기 위한 구체적인 프로파일링 기법과 렌더링 최적화 전략을 공유합니다.

도입 및 개요

언리얼 엔진 5의 시그니처 기술인 Lumen(다이내믹 글로벌 일루미네이션 및 리플렉션)과 Nanite(가상화된 마이크로폴리곤 지오메트리)는 인디 게임 개발자들에게 과거에는 상상할 수 없었던 시각적 디테일을 제공합니다. 폴리곤 수의 제약에서 벗어나 하이폴리 에셋을 그대로 에디터에 던져 넣고, 복잡한 라이트맵 굽기(Baking) 과정 없이 실시간으로 변화하는 아름다운 빛을 구현할 수 있다는 것은 혁명과도 같습니다. 하지만 이러한 차세대 렌더링 기술을 기본 설정 그대로 무분별하게 사용한다면, 하이엔드 그래픽 카드에서도 30FPS를 간신히 방어하거나 미들 레인지 이하의 PC에서는 플레이 자체가 불가능한 최적화의 악몽에 직면하게 됩니다. 따라서 인디 개발자는 엔진의 마법 뒤에 숨겨진 비용을 이해하고, 타겟 하드웨어에 맞게 렌더링 파이프라인을 조율하는 실전 최적화 기술을 갖추어야 합니다.

최적화의 첫걸음은 막연한 추측이 아니라 정확한 '프로파일링(Profiling)'입니다. 게임을 플레이하며 프레임이 떨어지는 구간에서 콘솔 명령어 stat gpu와 stat RHI를 입력하여 병목이 발생하는 지점을 파악해야 합니다. 더 깊은 분석을 위해서는 언리얼 프론트엔드(Unreal Frontend)의 Unreal Insights 기능과 GPU 프로파일러(Ctrl + Shift + Comma)를 활용해야 합니다. 예를 들어 GPU 프로파일러 창을 열었을 때 'Lumen Scene Lighting'이나 'Lumen Screen Probe Gather' 항목이 전체 GPU 시간의 40% 이상을 차지하고 있다면, Lumen 설정이 타겟 기기 대비 과도하게 높게 잡혀 있는 것입니다. 이 경우 프로젝트 세팅이나 포스트 프로세스 볼륨(Post Process Volume)에서 Lumen의 퀄리티 설정을 조절하거나, Scalability 세팅(sg.GlobalIlluminationQuality)을 단계별로 낮추는 전략을 취해야 합니다. 특히 Lumen의 Software Ray Tracing 모드는 메시에 설정된 Distance Field의 해상도에 크게 의존하므로, 얇은 벽이나 복잡한 구조물의 Mesh Distance Field 해상도를 최적화하여 빛샘 현상을 막으면서도 연산 부하를 줄이는 밸런스가 중요합니다.

Nanite의 경우 폴리곤 수 자체는 렌더링 비용에 큰 영향을 미치지 않지만, 오버드로우(Overdraw)와 재질(Material)의 복잡도가 렌더링 성능을 좌우하는 핵심 요소가 됩니다. Nanite는 기본적으로 픽셀 단위로 어떤 폴리곤이 화면에 보여야 할지 결정하는 래스터라이저를 사용하기 때문에 매우 효율적이지만, 불투명도 마스크(Masked) 재질이나 풀잎, 나뭇잎 같은 폴리지(Foliage)에 과도하게 적용할 경우 처리 비용이 기하급수적으로 증가할 수 있습니다. 구체적인 최적화 사례로, 한 오픈월드 인디 게임에서는 무성한 숲을 렌더링할 때 심각한 프레임 드랍을 겪었습니다. 프로파일링 결과, Nanite가 활성화된 수백만 개의 나뭇잎 인스턴스가 겹쳐지면서 엄청난 Overdraw 비용을 발생시키고 있었습니다. 이를 해결하기 위해 개발팀은 나뭇잎 재질의 Masked 픽셀 셰이더 명령(Instruction) 수를 최소화하고, 카메라에서 멀리 떨어진 나무들은 Nanite 폴리지 대신 전통적인 임포스터(Imposter)나 2D 빌보드로 전환하는 하이브리드 LOD 시스템을 구축하여 GPU 렌더 타임을 15ms에서 7ms 이하로 절반 이상 감축시키는 데 성공했습니다.

마지막으로 Virtual Shadow Map(VSM)의 캐싱(Caching) 전략을 간과해서는 안 됩니다. VSM은 매우 정밀한 그림자를 제공하지만 모든 동적 객체에 대해 매 프레임 그림자를 다시 그리는 것은 엄청난 부하를 초래합니다. 씬에 배치된 광원들이 움직이지 않는다면(예: 멈춰 있는 가로등, 실내 조명), 엔진은 해당 조명의 그림자 페이지를 캐싱하여 다음 프레임에 재사용함으로써 비용을 아낍니다. 하지만 작은 나뭇잎이 바람에 흔들리거나 정체불명의 더미 액터가 틱(Tick)마다 미세하게 움직인다면 해당 캐시가 무효화(Invalidate)되어 매번 그림자 맵을 새로 갱신하게 됩니다. ShowFlag.VirtualShadowMap.VisualizeCached 커맨드를 통해 뷰포트에서 캐시가 깨지는 영역(빨간색으로 표시됨)을 시각적으로 확인하고, 그림자에 영향을 주지 않아도 되는 자잘한 동적 프롭들은 Cast Shadow 옵션을 끄거나 광원의 범위를 조정하여 VSM 캐시 히트율을 극대화하는 세밀한 튜닝이 60FPS 달성을 위한 마지막 퍼즐 조각이 될 것입니다.

Implementation C++ / UE5
// 콘솔 변수(Console Variables)를 통해 런타임에 성능을 튜닝하는 C++ 예시
void AMyGameMode::ApplyPerformanceScalability(int32 QualityLevel)
{
    IConsoleManager& ConsoleManager = IConsoleManager::Get();
    
    if (QualityLevel <= 1) // Low or Medium Quality
    {
        // 루멘 퀄리티 낮춤
        ConsoleManager.ProcessUserConsoleInput(TEXT("sg.GlobalIlluminationQuality 1"), *GLog, GetWorld());
        // VSM 캐시 업데이트 제한
        ConsoleManager.ProcessUserConsoleInput(TEXT("r.Shadow.Virtual.Cache.MaxUpdatePagesPerFrame 128"), *GLog, GetWorld());
        // 나나이트 패스 최적화
        ConsoleManager.ProcessUserConsoleInput(TEXT("r.Nanite.MaxPixelsPerEdge 2.0"), *GLog, GetWorld());
    }
    else // High or Epic Quality
    {
        ConsoleManager.ProcessUserConsoleInput(TEXT("sg.GlobalIlluminationQuality 3"), *GLog, GetWorld());
        ConsoleManager.ProcessUserConsoleInput(TEXT("r.Shadow.Virtual.Cache.MaxUpdatePagesPerFrame 1024"), *GLog, GetWorld());
        ConsoleManager.ProcessUserConsoleInput(TEXT("r.Nanite.MaxPixelsPerEdge 1.0"), *GLog, GetWorld());
    }
}
최고급 렌더링 기술을 채택하는 것보다 중요한 것은, 우리 게임의 비주얼 아트 스타일과 타겟 유저층의 평균 PC 사양 사이에서 최적의 타협점을 찾는 것입니다. 무조건적인 시각적 타협보다는 정확한 프로파일링에 기반한 렌더링 파이프라인의 이해가 최적화의 핵심입니다.