[GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지 (2)

2026. 1. 5. 20:50·Dev./UE 언리얼 엔진

서론: 성능 문제의 시작

지난 개선사항에 이어서...

2025.12.30 - [Dev./UE 언리얼 엔진] - [GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지

 

[GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지

“왜 이렇게 버벅거리지?”게임 개발을 하다 보면 누구나 한 번쯤 마주하는 질문이다. 제작 중인 프로젝트는 RTS 모드와 TPS 모드를 오가는 하이브리드 게임이다. 구현하기에 급급하여 최적화를

raindrovvv.tistory.com

최적화 작업에 착수하기 전, TPS(3인칭 시점) 모드에서의 성능 상태는 아래 표와 같이 심각했다.

항목 (최적화 전 TPS 모드) 수치 문제점 분석
Frame Time ~32.0 ms 목표(16.7ms) 대비 2배에 가까운 시간 소요
GPU Time ~50.54 ms GPU가 렌더링 파이프라인을 처리하는 데 과도한 부하 발생
Draw Calls ~3,600 CPU가 GPU에 보내는 렌더링 명령 과다로 인한 병목 현상
Primitives ~913K 화면에 렌더링되는 트라이앵글 수가 많아 GPU 부담 가중

이처럼 명확한 성능 지표의 악화는 단순한 에셋 최적화를 넘어, 프로젝트의 렌더링 파이프라인과 게임 로직 전반에 걸쳐 분석이 필요했다. 그 첫 단계인 GPU 병목 현상 해결 과정을 살펴보자.


1. GPU 병목 현상 해결 

GPU는 플레이어의 눈에 보이는 모든 것을 직접 그린다.

GPU가 하나의 프레임을 그리는 데 걸리는 시간(GPU Time)을 단축하는 것이 곧바로 프레임 향상으로 이어진다.


TSR 설정의 오해와 교정

가장 먼저 발견한 문제는 'Temporal Super Resolution(TSR)' 기능에 대한 오해였다.

TSR의 본래 목적인 "낮은 해상도로 렌더링 후 업스케일링하여 성능 확보"를 간과하고,

r.ScreenPercentage를 100%로 설정한 채 사용하고 있었다.

 

이는 TSR의 성능 이점은 전혀 취하지 못한 채, 불필요한 오버헤드만 추가하는 실수였다.

이 문제를 해결하기 위해, r.ScreenPercentage 값을 59.5%로 과감하게 낮추었다.

 

이후 화질과 성능의 균형점을 찾아 최종적으로 75%로 조정했습니다.

이 간단한 변경만으로 GPU가 실제로 그려야 할 픽셀 수가 약 40% 수준으로 줄어들었다.

 

TSR은 마치 "작은 그림을 그린 다음, AI가 크고 선명하게 확대해주는 기술" 과 같다.시간적(Temporal) 데이터를 활용하여 저해상도 이미지를 네이티브 해상도에 가까운 품질로 지능적으로 복원해주기 때문에, 최소한의 화질 저하로 막대한 성능 이득을 볼 수 있다.


Lumen과 그림자

언리얼 엔진 5의 핵심 기능인 Lumen과 Virtual Shadow Maps(VSM)는 그 강력한 기능만큼이나 신중한 접근이 필요하다.

  • Lumen의 Hardware Ray Tracing
    • Hardware Ray Tracing의 고품질이냐, Software Ray Tracing의 압도적인 성능과 폭넓은 하드웨어 호환성이냐... 당연히 프레임을 고려했을 때, 선택은 명확했다. RTX 그래픽카드 전용 코어에 의존하는 Hardware Ray Tracing 대신, 모든 GPU에서 작동하며 부하가 훨씬 적은 Software Ray Tracing 모드로 전환.
  • Virtual Shadow Maps (VSM)
    • VSM은 항상 선명한 고해상도 그림자를 제공하는 강력한 기술이지만, 수백 마리의 몬스터가 끊임없이 움직이는 환경에서는 독이 되었다. 동적 오브젝트가 많을수록 매 프레임 그림자 캐시가 무효화되어 비효율이 극대화되었기 때문이다. 처음에는 그림자 경계 품질을 결정하는 r.Shadow.Virtual.SMRT.RayCountLocal 값을 8에서 2로 낮추어 타협을 시도했지만, 최종적으로는 VSM을 완전히 비활성화(r.Shadow.Virtual.Enable=0) 하는 결정을 통해 GPU 부하를 크게 줄일 수 있었다.
  • 라이트 문제 해결
    • Lights in scene: 300...  동적 그림자를 드리우는 각 라이트는 조명을 받는 오브젝트를 별도의 섀도우 패스에서 다시 그리게 하여, 사실상 Draw Call과 GPU 부하를 배가시켰다.
    • 300개의 라이트가 이 작업을 반복하니 엄청난 병목 현상이 발생한 것이다. C++ 코드를 통해 레벨에 배치된 모든 룸 블루프린트 내 라이트 컴포넌트를 찾아 'Cast Shadows(그림자 드리우기)' 옵션을 일괄적으로 비활성화했다.

Draw Call과의 전쟁... 컬링과 나나이트 활용

위 과정을 통해 GPU 부하를 줄인 후에도 Draw Call 수치가 여전히 높아 CPU 병목 현상이 의심되었다. 이 높은 수치를 파헤치는 과정은 쉽지 않았다...

  • Distance Culling의 표준화:
    • 처음 가설은 'Distance Culling이 제대로 작동하지 않는다'였다.
    • C++ 헤더 파일(GS_RenderingConstants.h)에 룸, 몬스터, 트랩 등 종류별 컬링 거리를 상수로 정의하고, RTS 모드에서 컬링 거리에 적용되던 과도한 배율(3.5배)을 2.5배로 낮추는 등 조치를 취했다.
    • 하지만 Draw Call 수치는 요지부동이었다. 초기 가설이 틀렸다는 사실에 혼란스러웠다.
  • Nanite 시각화로 문제점 발견:
    • 다른 가능성을 찾기 위해 'Nanite Visualize' 모드를 켰고, 그 순간 문제의 실체가 드러났다.
    • 벽이나 바닥 같은 주요 스태틱 메시는 Nanite가 잘 적용되어 녹색으로 빛나고 있었지만, Masked 머티리얼을 사용하는 덩굴 같은 폴리지(Foliage) 에셋들은 Nanite가 적용되지 않아 온통 빨간색으로 표시되었다.
    • Draw Call의 진짜 주범은 컬링 거리 밖에 있던 먼 오브젝트가 아니라, 바로 카메라 앞에 잔뜩 있던 이 Nanite 미적용 에셋들이었던 것이다.
  • 폴리지 및 지오메트리 캐시 최적화:
    • Nanite의 혜택을 받지 못하는 폴리지와, 마찬가지로 Nanite 적용이 불가능한 지오메트리 캐시(크리스탈 함정)가 Draw Call을 증가시키고 있었다.
    • C++ 코드 레벨에서 이 두 종류의 컴포넌트를 식별하여, 일반 룸 메시의 컬링 거리(60m)보다 훨씬 공격적인 30m의 컬링 거리를 적용했다.
    • 이 조치로 가까운 거리에서는 시각적 품질을 유지하면서도, 멀어지면 렌더링에서 제외하여 Draw Call을 효과적으로 줄일 수 있었다.

2. CPU 부하 감소 - 게임 로직과 메모리 다이어트

GPU 최적화를 통해 렌더링 파이프라인의 숨통을 틔웠지만,

전투가 격렬해지면 여전히 프레임이 출렁였다.

 

이는 화면에 보이지 않는 수많은 몬스터들이 배경에서 불필요한 연산(애니메이션, AI 로직, 틱 함수)을 계속 수행하며 CPU를 과부하시켰기 때문이다. 이 단계의 목표는 명확했다: '보이지 않는 곳의 낭비를 없애' 전반적인 게임플레이 안정성을 확보하는 것!


Significance Manager 도입

가장 큰 문제는 많은 몬스터가 화면 밖에 있거나 플레이어로부터 멀리 떨어져 있음에도 불구하고,

바로 눈앞의 적과 똑같이 매 프레임 풀 애니메이션 연산과 Actor Tick을 수행하고 있었다는 점이다.

 

Nanite는 스켈레탈 메시에 적용되지 않으며, GPU 단에서 컬링되어 화면에 그려지지 않더라도 CPU 연산은 멈추지 않는다.

이 문제를 해결하기 위해 언리얼 엔진의 공식 시스템인 'Significance Manager'를 도입했다.

 

이 시스템을 한마디로 정의하자면 "중요한 놈에게 자원을 몰아주고, 안 중요한 놈은 대충 처리하는 시스템" 이다.

플레이어와의 거리, 화면 내 위치 등을 종합적으로 판단해 각 몬스터에게 '중요도' 점수를 매기고, 이 점수에 따라 아래 항목들을 동적으로 조절한다.

  • 애니메이션 업데이트 빈도 (URO): 중요도가 낮으면 애니메이션 프레임을 건너뛰어 연산량을 줄인다.
  • Actor Tick 활성화 여부: 중요도가 매우 낮으면 아예 틱 함수를 꺼버린다.
  • 네트워크 업데이트 주기 (NetUpdateFrequency): 멀리 있는 적의 움직임은 동기화 빈도를 낮춘다.

아래는 구현된 C++ 코드의 핵심 흐름.

// 1. BeginPlay에서 Significance Manager에 객체 등록
SM->RegisterObject(this, "Character",
    // 2. 중요도 계산 로직 (거리 기반)
    [this](...) { return CalculateSignificance(Viewpoint); },
    USignificanceManager::EPostSignificanceType::Sequential,
    // 3. 중요도 변경 시 호출될 콜백 함수
    [this](...) { OnSignificanceChanged(NewValue); }
);

// 4. EndPlay에서 반드시 등록 해제
void AGS_Character::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    if (USignificanceManager* SM = ...)
    {
        SM->UnregisterObject(this);
    }
    Super::EndPlay(EndPlayReason);
}

오브젝트 풀링: 비싼 생성/파괴 연산의 대안

화살과 같은 투사체를 발사할 때마다 SpawnActor로 생성하고, 벽에 부딪히면 Destroy로 파괴하는 방식은 직관적이지만 성능에는 매우 비효율적이다. 액터를 스폰하는 과정에는 리플렉션 시스템 조회, 컴포넌트 초기화, 네트워크 등록 등 보이지 않는 무거운 작업들이 수반되기 때문이다.

 

이 문제를 해결하기 위해 '오브젝트 풀링(Object Pooling)' 시스템을 구현했다. 게임이 시작될 때 필요한 만큼의 화살 액터를 미리 생성하여 비활성화 상태로 '풀(Pool)'에 보관하고, 화살이 필요할 때마다 풀에서 비활성화된 액터를 하나 가져와 Activate하고 원하는 위치로 이동시킨다. 화살이 수명을 다하면 파괴(Destroy)하는 대신 Deactivate하여 다시 풀에 반납하는 이 방식은 UGS_VisualPoolComp 컴포넌트로 구현되어, 잦은 생성/파괴로 인한 CPU 스파이크를 차단했다.


네트워크 최적화: Reliable RPC의 함정

초기 구현에서는 피격 몽타주나 사운드 같은 시각적 피드백을 모두 Reliable RPC로 전송했다. "모든 정보는 확실하게 전달되어야 한다"는 생각 때문이었다.

 

하지만 이는 대규모 전투에서 문제를 일으켰다. Reliable은 패킷이 유실되면 재전송을 보장하는데, 전투가 격렬해지면 사소한 시각 효과의 패킷 유실이 재전송 버퍼를 누적시켜 결정적인 순간에 심각한 렉 스파이크를 유발한다.

 

해결책은 '중요한 정보'와 '그렇지 않은 정보'를 구분하는 것이었다. 데미지 계산 같은 핵심 로직은 Reliable을 유지하되, 한두 번 보이지 않아도 게임 플레이에 전혀 지장이 없는 시각/청각 효과는 Unreliable로 변경하여 불필요한 네트워크 부하를 제거했다.

// 변경 전
UFUNCTION(NetMulticast, Reliable)
void Multicast_PlayHitEffect();

// 변경 후
UFUNCTION(NetMulticast, Unreliable)
void Multicast_PlayHitEffect();

가비지 컬렉션(GC) 최적화: 갑작스러운 끊김 현상 제거

60초 주기로 실행되던 기본 가비지 컬렉션(GC) 설정이 

60초 동안 쌓인 수많은 메모리 쓰레기(죽은 몬스터, 사라진 이펙트 등)를 한 번에 청소하려다 보니 게임이 순간적으로 멈추는 'Hitch' 현상을 유발할 수도 있다.

 

이 문제를 해결하기 위해 DefaultEngine.ini 파일의 GC 설정을 아래와 같이 수정했다.

설정 항목 변경 전 변경 후 효과 분석
gc.TimeBetweenPurgingPendingKillObjects 60.0 30.0 청소 주기를 짧게 하여 한 번에 처리하는 쓰레기 양을 줄이고, 부하를 분산시켜 끊김 현상을 완화합니다.
gc.CreateGarbageCollectorUObjectClusters False True 연관된 오브젝트를 그룹(클러스터)으로 묶어 GC의 스캔 속도를 향상시킵니다.
gc.MultithreadedDestructionEnabled False True 객체 파괴 작업을 별도의 스레드에서 처리하여 게임 로직을 실행하는 메인 스레드의 부하를 분산시킵니다.

 


3. 결과 및 학습 포인트

모든 최적화 노력의 성과를 명확한 데이터로 증명하는 것은 매우 중요!

이는 적용된 기법들의 실질적인 효과를 입증할 뿐만 아니라, 향후 유사한 문제에 직면했을 때 귀중한 자산이니다.

 

이 섹션에서는 개선 전후의 성능 지표를 객관적으로 비교하고,

이 경험을 통해 얻은 핵심 지식을 다른 개발자들도 자신의 프로젝트에 적용할 수 있는 실용적인 가이드 형태로 정리하고자 한다.

최종 성능 개선 결과 비교

수치만큼 정직한 것은 없다.

아래 표는 RTS와 TPS 모드에서 최적화 전후의 성능 지표를 비교한 결과이다.

 

목표였던 60 FPS에 근접하거나 안정적으로 유지하는 데 성공했으며,

특히 GPU Time의 감소는 이번 최적화의 가장 큰 성과라 할 수 있다.

RTS 모드 성능 비교

항목 개선 전 개선 후 변화
FPS 43~45 56~60 +11~17 FPS
Frame Time 22.2~22.8 ms 16.6~16.7 ms 감소 (-5.5~6.1 ms)
GPU Time 16.6~17.0 ms 12.6~15.0 ms 감소 (-1.6~4.4 ms)

TPS 모드 성능 비교

항목 개선 전 개선 후 변화
FPS 30~32 41~47 +9~17 FPS
Frame Time 32.0~32.4 ms 21.3~24.1 ms 감소 (-7.9~11.1 ms)
GPU Time 50.54 ms 20.0~22.4 ms 대폭 감소 (-28~30 ms)
Draw Calls 3,535~3,691 3,158~3,292 약 10% 감소
Primitives 816K~913K 650K~671K 약 20~27% 감소

핵심 학습 포인트 정리

  • TSR/DLSS/FSR의 올바른 이해
    • 업스케일링 기술을 100% 해상도 스케일로 사용하는 것은 성능 이득 없이 오버헤드만 추가하는 행위.
    • 50%에서 75% 사이의 스케일링으로 GPU의 렌더링 부담을 줄여주는 것이 이 기술들의 본래 목적에 부합.
  • Nanite와 Skeletal Mesh의 차이
    • Nanite는 스태틱 메시(Static Mesh)를 위한 혁신적인 기술이지만, 캐릭터나 몬스터 같은 스켈레탈 메시(Skeletal Mesh)에는 적용되지 않는다.
    • 스켈레탈 메시는 반드시 별도의 Distance Culling과 Significance Manager를 통해 CPU와 GPU 부하를 수동으로 관리해야 한다.
  • Reliable vs Unreliable RPC
    • 네트워크 통신에서 모든 것을 '신뢰성 있게(Reliable)' 보낼 필요는 없다.
    • 데미지 계산과 같은 핵심 게임 로직은 Reliable RPC로, 피격 이펙트나 사운드 같은 일회성 시각 효과는 Unreliable RPC로 전송하여 불필요한 재전송으로 인한 네트워크 병목을 방지해야 한다.
  • Significance Manager의 중요성
    • 수백 개의 동적 액터가 등장하는 대규모 전투가 있는 게임에서 Significance Manager는 선택이 아닌 필수!
    • 각 액터의 중요도에 따라 연산량을 동적으로 조절함으로써 CPU 부하를 지능적으로 관리할 수 있다.
  • 오브젝트 풀링의 필요성
    • 투사체, 이펙트 등 수명이 짧고 자주 생성/파괴되는 액터는 심각한 성능 저하 유발.
    • SpawnActor와 Destroy 호출을 최소화하는 오브젝트 풀링 패턴을 도입해야 한다.

'Dev. > UE 언리얼 엔진' 카테고리의 다른 글

[GAS] 나나이트(Nanite) 메시의 거리 컬링 최적화  (0) 2026.01.07
[GAS] LOD 설정 실수로 인한 빌드 크래시, 원인 분석과 해결까지  (0) 2026.01.06
[GAS] 이펙트가 프레임을 떨어뜨리는 이유, 오버드로 | 컷아웃 기법  (1) 2026.01.03
[GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지  (2) 2025.12.30
[GAS] UE5 네트워크 설정에 대한 조사... 그리고 프로젝트 적용  (0) 2025.12.29
'Dev./UE 언리얼 엔진' 카테고리의 다른 글
  • [GAS] 나나이트(Nanite) 메시의 거리 컬링 최적화
  • [GAS] LOD 설정 실수로 인한 빌드 크래시, 원인 분석과 해결까지
  • [GAS] 이펙트가 프레임을 떨어뜨리는 이유, 오버드로 | 컷아웃 기법
  • [GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지
raindrovvv
raindrovvv
raindrovvv 님의 블로그 입니다.
  • raindrovvv
    raindrovvv 님의 블로그
    raindrovvv
  • 전체
    오늘
    어제
    • 분류 전체보기 (170) N
      • Dev. (163) N
        • AI 인공지능 (27)
        • UE 언리얼 엔진 (81) N
        • Unity 유니티 (0)
        • Wwise 와이즈 (7)
        • 게임 네트워크 (8)
        • 그래픽스 Graphics (22)
        • 프로젝트 (8)
        • 기타 개발 관련 (10)
      • Computer Science (0)
        • 하드웨어 HW (0)
        • 소프트웨어 SW (0)
        • 통신 (0)
        • 데이터 (0)
      • 블로그 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Wwise
    바이브코딩
    깃
    게임개발
    UE
    그래픽스
    생산성
    에이전트
    AI
    언리얼엔진
    트러블슈팅
    TA
    Unreal
    인디게임
    네트워크
    언리얼
    dev
    devlog
    Git
    unrealengine
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
raindrovvv
[GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지 (2)
상단으로

티스토리툴바