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

2025. 12. 30. 18:30·Dev./UE 언리얼 엔진

“왜 이렇게 버벅거리지?”

게임 개발을 하다 보면 누구나 한 번쯤 마주하는 질문이다.

제작 중인 프로젝트는 RTS 모드와 TPS 모드를 오가는 하이브리드 게임이다.

 

구현하기에 급급하여 최적화를 신경쓰지 못했다. 특히 TPS 모드에서는 30 FPS 언저리를 맴돌았다.

내가 맡은 파트도 아니였지만 어느 순간부터 프레임이 눈에 띄었다.

 

"이대로는 안 되겠다"는 판단이 섰다. 그래서 개선해보기로 하였다.

이 글은 그 문제를 파헤치고 해결해 나간 약 2주간의 기록이다.


 

1. 문제 진단: 숫자가 말해주는 것들

처음 마주한 숫자

언리얼 엔진의 stat GPU와 stat Game 명령어를 켜고 게임을 돌려봤다.

화면에 쏟아지는 숫자들 사이에서 몇 가지가 눈에 들어왔다.

 

RTS 모드는 그나마 나았다. 43-45 FPS 정도로, “플레이는 가능하다” 수준. 하지만 TPS 모드는...

RTS 모드

TPS 모드의 충격적인 수치들

Frame Time이 32ms를 넘나들었고, 가장 눈에 띈 건 Draw Call.

RTS에서 700개 정도였던 게 TPS에서는 3,600개로 폭증했다. 무려 5배.

처음엔 이 숫자가 뭘 의미하는지 정확히 몰랐다. 그래서 하나씩 파헤쳤다.

TPS 모드


Draw Call이 뭐길래

쉽게 말하면, CPU가 GPU에게 "이거 그려줘!"라고 명령하는 횟수다.

100개의 나무가 있다면, 100번 명령을 내려야 한다.

명령 한 번 한 번이 CPU의 시간을 잡아먹는다.

 

RTS 모드에서는 카메라가 하늘 위에서 내려다본다.

멀리서 보니까 작은 물체들은 컬링되고, 비슷한 것들은 한꺼번에 처리된다.

 

그런데 TPS 모드에서는? 카메라가 캐릭터 바로 뒤에 붙어있다.

캐릭터의 텍스처가 자세히 보인다. “가까이서 보면, 모든 게 다 보인다.”

 

이게 TPS의 딜레마였다.


병목 지점 파악하기

성능 지표를 분석하면서 깨달은 게 있다. 최적화는 "가장 큰 놈부터 잡아야 한다"는 것.

0.1ms를 아끼려고 시간 쓰는 건 비효율적이다. 5ms, 10ms 먹는 항목을 찾아서 그걸 줄여야 체감이 된다.

 

우리 게임에서 GPU 시간을 많이 잡아먹는 항목들은 이랬다.

  • Postprocessing이 4.5-6.5ms,
  • TSR이 4.4-5.9ms,
  • RenderDeferredLighting이 3.5-6.6ms,
  • Nanite 관련 작업이 3-5ms 정도.

CPU 쪽에서는 Draw Call 처리 시간이 압도적이었다.

TPS에서 Draw Time이 32-35ms나 됐다.

 

그러나 그 이전에 CPU(게임 로직) 최적화가 우선이였기에

해당 최적화를 먼저 진행하였다.


2. 최적화 전략 수립

접근 방식: 안정성과 성능의 균형

무작정 설정을 바꾸다가 게임이 망가지면 안 된다. 그래서 우선순위를 정했다.

 

낮은 리스크, 빠른 효과:

  • Distance Culling (거리 기반 컬링)
  • Occlusion Culling (가림 기반 컬링)
  • Animation Update Rate 최적화

높은 효과, 높은 복잡도:

  • ISM/HISM 기반 인스턴싱 (나중에 필요하면)

비교적 구현하기 간편한 것부터 적용하기로 했다.


3. Phase 1: Distance Culling 표준화

“안 보이는 건 안 그린다.”

너무 당연한 말 같지만, 의외로 많은 프로젝트에서 이게 제대로 안 되어 있다. 우리도 그랬다.

몬스터의 HP 위젯만 2000cm에서 컬링되고, 정작 Skeletal Mesh는 제한없이 렌더링되고 있었다.

함정이나 방 모듈도 거리 기반 컬링이 전혀 없었다.

 

프로젝트에 Nanite 설정은 하였으나...

Nanite(나나이트)에 대해서:

더보기

Nanite의 자동 컬링

Nanite는 GPU 기반으로 다음 컬링을 자동 수행합니다.

🔹Frustum Culling은 카메라 시야 밖의 클러스터를 제거합니다.

🔹Occlusion Culling은 Two-Pass Occlusion 방식으로 가려진 클러스터를 GPU에서 직접 판별합니다.

🔹Small Triangle Culling은 화면에서 1픽셀 미만인 삼각형을 자동으로 제거합니다.

🔹Backface Culling은 뒷면 삼각형을 제거합니다.

🔹Hierarchical Culling은 클러스터 단위로 계층적으로 컬링해서 효율을 높입니다.

그런데 왜 Distance Culling을 따로 설정했나?

첫째, Nanite는 Skeletal Mesh를 지원하지 않는다.

몬스터는 Skeletal Mesh라서 Nanite 컬링 혜택을 받지 못한다. 그래서 SetCullDistance()를 직접 설정했다.

 

둘째, Actor 자체의 Tick/로직 비용 문제가 있다.

Nanite가 렌더링을 컬링해도 Actor의 Tick, 애니메이션, AI 로직은 계속 돌아간다.

Distance Culling으로 아예 렌더링 대상에서 제외하면 CPU 부담도 줄어든다.

 

셋째, Draw Call은 Nanite 이전에 발생한다.

Nanite의 GPU 컬링이 아무리 뛰어나도 CPU에서 렌더 커맨드를 제출하는 비용은 남아있다.

먼 거리 오브젝트를 아예 제출하지 않으면 이 비용을 절약할 수 있다.

정리하면

Nanite Static Mesh의 경우 GPU 컬링은 자동으로 잘 되지만,

CPU 측 비용 절감이나 Skeletal Mesh에는 수동 Distance Culling이 여전히 필요하다.

구현: 렌더링 상수 정의

먼저 프로젝트 전체에서 일관되게 쓸 컬링 거리를 정의했다.

// GS_RenderingConstants.h
namespace GS_Rendering 
{
    // Skeletal Mesh Culling Distances (cm)
    constexpr float MONSTER_SMALL_CULL_DISTANCE = 3500.0f;  // 35m
    constexpr float MONSTER_MEDIUM_CULL_DISTANCE = 4500.0f; // 45m
    constexpr float MONSTER_LARGE_CULL_DISTANCE = 6000.0f;  // 60m
    
    // Static Mesh Culling Distances
    constexpr float TRAP_SMALL_CULL_DISTANCE = 3000.0f;     // 30m
    constexpr float TRAP_MEDIUM_CULL_DISTANCE = 4000.0f;    // 40m
    constexpr float ROOM_CULL_DISTANCE = 8000.0f;           // 80m
    
    // Bounds Scale - 팝핑 방지용
    constexpr float DEFAULT_BOUNDS_SCALE = 1.2f;
}

왜 이런 숫자들일까?

RTS 카메라가 최대로 줌아웃했을 때 화면에 보이는 범위를 기준으로 잡았다.

35m 밖의 작은 몬스터는 잘 안보이니까 안 그려도 된다.

방 모듈은 크니까 80m까지는 보여줘야 하고.

직접 테스트하면서 값을 변경해나가야 한다.


구현: 몬스터에 적용

// GS_Monster.cpp - BeginPlay()
void AGS_Monster::BeginPlay()
{
    Super::BeginPlay();
    
    // 클라이언트에서만 적용 (서버는 렌더링 안 함)
    if (!IsRunningDedicatedServer() && GetMesh())
    {
        USkeletalMeshComponent* MeshComp = GetMesh();
        float CullDistance = GetOptimalCullDistance();
        
        MeshComp->SetCullDistance(CullDistance);
        MeshComp->SetCachedMaxDrawDistance(CullDistance);
        MeshComp->bAllowCullDistanceVolume = true;
        MeshComp->SetBoundsScale(GS_Rendering::DEFAULT_BOUNDS_SCALE);
    }
}

float AGS_Monster::GetOptimalCullDistance() const
{
    return GS_Rendering::MONSTER_MEDIUM_CULL_DISTANCE; // 기본값
}

GetOptimalCullDistance()를 가상 함수로 만들어서, 몬스터 종류별로 오버라이드할 수 있게 했다.

작은 몬스터는 35m, 큰 보스급은 60m. 이런 식으로 유연하게 조절 가능하다.


Bounds Scale의 비밀

SetBoundsScale(1.2f)를 넣은 이유가 있다.

컬링 거리에 딱 맞춰서 끊으면, 오브젝트가 갑자기 나타나거나 사라진다.

이걸 "팝핑(Popping)"이라고 하는데, 굉장히 거슬린다.

 

Bounds Scale을 1.2로 늘리면, 실제 컬링이 약간 여유있게 일어난다. 오브젝트가 화면 가장자리에서 부드럽게 나타나고 사라진다. 작은 차이지만, 체감 품질이 확 달라진다.


4. Phase 2: Occlusion Culling 활성화

Distance Culling이 "멀면 안 그린다"라면, Occlusion Culling은 "가려져도 안 그린다"이다.

던전 게임을 생각해보자. 플레이어가 방 안에 있으면, 벽 뒤의 다른 방은 어차피 안 보인다.

그런데 GPU는 열심히 그리고 있을 수 있다.

구현: Room을 Occluder로

// GS_RoomBase.cpp - BeginPlay()
void AGS_RoomBase::BeginPlay()
{
    Super::BeginPlay();
    
    if (!IsRunningDedicatedServer())
    {
        if (Floor)
        {
            Floor->bUseAsOccluder = true;
            Floor->SetCastShadow(true);
        }
        
        if (Wall)
        {
            Wall->bUseAsOccluder = true;  // 벽은 특히 강력한 Occluder
            Wall->SetCastShadow(true);
        }
        
        if (Ceiling)
        {
            Ceiling->bUseAsOccluder = true;
            Ceiling->SetCastShadow(true);
        }
    }
}

bUseAsOccluder = true만 켜주면, 엔진이 알아서 이 메시 뒤에 있는 것들을 컬링해준다. 간단하지만 효과적이다.

프로젝트 설정 확인

DefaultEngine.ini에서 관련 설정도 확인했다.

r.AllowOcclusionQueries=True
r.GenerateMeshDistanceFields=True

다행히 이미 켜져 있었다.


5. Phase 3: Animation Update Rate 최적화

화면에 안 보이는 몬스터가 풀 프레임으로 애니메이션을 돌릴 필요가 있을까?

언리얼 엔진에는 URO(Update Rate Optimization)라는 시스템이 있다.

화면 밖 캐릭터의 애니메이션 업데이트 빈도를 줄여주는 기능이다.

첫 번째 시도와 실패

처음에는 이렇게 설정했다.

MeshComp->VisibilityBasedAnimTickOption = 
    EVisibilityBasedAnimTickOption::OnlyTickPoseWhenRendered;

“화면에 보일 때만 애니메이션 틱을 돌려라.” 완벽해 보였다. 그런데…


🛠 트러블슈팅 #1: AI가 멈췄다

테스트를 돌려보니 몬스터 AI가 이상하게 동작했다. 움직이다가 멈추고, 플레이어를 감지 못하고.

 

OnlyTickPoseWhenRendered 옵션은 화면 밖에서 애니메이션 틱을 완전히 중단한다.

문제는 AI 로직이 애니메이션 시스템에 의존하고 있었다는 것.

본(Bone) 위치를 참조하는 감지 로직, 소켓 위치를 쓰는 기능들… 먹통이 됐다.

특히 RTS 모드에서는 몬스터가 화면 밖으로 나가버리는 경우가 많다. 그 순간 AI가 얼어붙은 것이었다.


🛠 트러블슈팅 #2: 공격이 안 된다

첫 번째 문제를 해결하고 나니, 이번엔 공격이 안 됐다. 몬스터가 공격 모션을 취하는데 데미지가 안 들어간다.

 

또다시 원인 분석. 

우리 게임은 AnimNotify 기반으로 공격 판정을 한다.

애니메이션 특정 프레임에서 “지금 데미지 적용!” 하는 식이다.

그런데 애니메이션 틱이 멈추면? AnimNotify가 호출 안 된다.

 

공격 타이밍, 히트 체크, 데미지 적용… 전부 스킵.


해결책: OnlyTickMontagesWhenNotRendered

결국 찾은 답은 이거였다.

MeshComp->VisibilityBasedAnimTickOption = 
    EVisibilityBasedAnimTickOption::OnlyTickMontagesWhenNotRendered;

이 옵션은 똑똑하다. 화면 밖일 때 Idle, Walk 같은 일반 애니메이션은 틱을 중단한다.

하지만 몽타주(Montage)가 재생 중이면 틱을 유지한다.

공격은 보통 몽타주로 재생하니까, AnimNotify도 정상 호출된다.

 

성능과 기능의 균형점을 찾았다.


서버와 클라이언트 분리

멀티플레이 게임이라 한 가지 더 신경 써야 했다.

서버에는 "화면"이라는 개념이 없다.

서버에서 OnlyTickPoseWhenRendered를 쓰면?

서버 애니메이션 틱이 멈추고, 공격 판정도, AI 로직도 다 무너진다.

void AGS_Monster::BeginPlay()
{
    Super::BeginPlay();
    
    if (USkeletalMeshComponent* MeshComp = GetMesh())
    {
        if (IsRunningDedicatedServer())
        {
            // 서버: 항상 틱 유지 (판정 안정성 최우선)
            MeshComp->VisibilityBasedAnimTickOption = 
                EVisibilityBasedAnimTickOption::AlwaysTickPoseAndRefreshBones;
        }
        else
        {
            // 클라이언트: 최적화 적용
            MeshComp->VisibilityBasedAnimTickOption = 
                EVisibilityBasedAnimTickOption::OnlyTickMontagesWhenNotRendered;
            MeshComp->bEnableUpdateRateOptimizations = true;
        }
    }
}

서버는 안정성, 클라이언트는 성능. 이 원칙을 지키니까 문제가 사라졌다.


6. 해상도 스케일링: 가장 큰 한 방

여기까지 했는데도 TPS 모드는 여전히 45 FPS 근처였다. GPU Time이 문제였다.

 

TSR(Temporal Super Resolution)은 낮은 해상도로 렌더링한 뒤, AI 기반으로 고해상도처럼 업스케일하는 기술이다.

그런데 우리는 100% 해상도로 렌더링하고 TSR을 돌리고 있었다. TSR의 장점을 전혀 살리지 못하고 있었던 것이다.

적용: 59.5% 스케일링

렌더링 해상도를 59.5%로 낮췄다.

원래 2567×1155 픽셀을 그리던 걸 1527×913으로 줄인 것이다. 픽셀 수로 따지면 약 40% 수준.

 

결과는 드라마틱했다.

GPU Time이 20-22ms로 떨어졌다.

TSR 비용도 4.4ms에서 2.7-3.1ms로 줄었다. 처리할 픽셀 자체가 줄었으니까.

 

시각적 품질은? 눈을 크게 뜨고 봐야 차이를 알 정도였다. TSR이 정말 잘 보정해준다.


7. 결과: 숫자로 보는 변화

RTS 모드

항목 개선 전 개선 후 변화
FPS 43-45 56-60 +11~17
Frame Time 22.2-22.8ms 16.6-16.7ms -5.5~6.1ms
GPU Queue 16.6-17.0ms 12.6-15.0ms -1.6~4.4ms
Memory 9.51-9.53GB 8.01-9.31GB -0.2~1.5GB

📍RTS 모드는 목표였던 60 FPS를 거의 달성했다.


TPS 모드

항목 개선 전 개선 후 변화
FPS 30-32 41-47 +9~17
Frame Time 32.0-32.4ms 21.3-24.1ms -7.9~11.1ms
GPU Time 50.54ms 20.0-22.4ms -28~30ms
Primitives 816K-913K 650K-671K -166K~242K

📍 TPS 모드도 30 FPS에서 45 FPS로, 50% 성능 향상을 이뤘다.


8. 남은 숙제

TPS의 Draw Call 문제

TPS 모드의 Draw Call은 여전히 3,200개 수준이다.

RTS의 700개에 비하면 4배 이상 많다.

이게 Draw Time 21-24ms의 원인이고, 60 FPS를 막는 주범이다.

해상도 스케일링으로 GPU 병목은 해결했지만, CPU 쪽 부담은 여전하다.

다음 단계 계획

1순위: Draw Call 추가 감소

목표는 3,200개에서 1,500개 이하로.

 

2순위: LOD 추가 조정

TPS 카메라 뒤쪽/측면의 오브젝트는 더 공격적으로 저폴리로 전환해도 될 것 같다.

어차피 플레이어 시선은 화면 중앙에 집중되니까.

 

3순위: 그림자 및 이펙트 최적화

Shadow Depths가 0-2.7ms로 불안정하고, FXSystemPreRender가 간헐적으로 5-14ms까지 튄다.

이 스파이크들의 원인을 찾아야 한다.


9. 핵심 인사이트

이번 최적화를 진행하면서 배운 것들을 정리했다.

 

측정 없이 최적화 없다

감으로 하면 안 된다.

stat GPU, stat Game, stat SceneRendering…

이런 명령어들을 습관처럼 써야 한다. 숫자가 문제를 말해준다.

 

가장 큰 놈부터

0.1ms 아끼려고 시간 쓰지 마라.

5ms, 10ms 먹는 항목을 찾아서 그걸 잡아야 체감이 된다.

 

안 그리는 게 최고의 최적화

"더 효율적으로 그리기"보다 "아예 안 그리기"가 훨씬 효과적이다.

Distance Culling, Occlusion Culling, LOD… 전부 이 원리다.

 

해상도 스케일링 + TSR 조합

UE5를 쓴다면 꼭 활용하라.

렌더링 해상도를 60-70%로 낮추고 TSR로 업스케일하면, 시각적 품질은 거의 유지하면서 GPU 부담을 크게 줄일 수 있다.

 

서버와 클라이언트는 다르다

멀티플레이 게임이라면, 최적화 코드에 IsRunningDedicatedServer() 체크를 습관화하라.

서버에서 렌더링 최적화 로직이 돌면 예상치 못한 버그가 생길 수 있다.

 

AnimNotify 기반 시스템 주의

애니메이션 최적화 옵션 중 OnlyTickPoseWhenRendered는 위험하다.

AnimNotify에 의존하는 로직이 있다면 OnlyTickMontagesWhenNotRendered를 써라.

 

하나씩 바꾸고 측정하기

한꺼번에 여러 개 바꾸면, 뭐가 효과 있었는지 모른다.

귀찮아도 하나씩 적용하고, 매번 측정하라.


마치며

30 FPS에서 시작해서, RTS는 60 FPS, TPS는 45 FPS까지 끌어올렸다. 완벽하진 않지만, 의미 있는 진전이다.

최적화는 끝이 없는 여정 같다. 하나 해결하면 다음 병목이 보이고, 그걸 해결하면 또 다음 게 보인다.............

하지만 그 과정에서 엔진을 더 깊이 이해하게 되고, 더 나은 게임을 만들 수 있게 된다.

저는 AI를 적극적으로 활용하는 개발자입니다.
코드 구현은 AI 도구와 협업하고, 저는 문제 분석, 기술 설계, 트러블슈팅, 최종 검증에 집중합니다. 모든 기술적 의사결정과 트러블슈팅은 제가 직접 수행한 것이며, AI는 그 과정을 가속화하는 도구였습니다.

이 블로그는 그 판단과 사고의 기록입니다.
"어떤 도구를 쓰느냐"보다 "어떤 문제를 해결하느냐"가 진짜 개발자의 가치라고 믿습니다.

I believe a developer's value lies in "what problems they solve," not "what tools they use."

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

[GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지 (2)  (2) 2026.01.05
[GAS] 이펙트가 프레임을 떨어뜨리는 이유, 오버드로 | 컷아웃 기법  (1) 2026.01.03
[GAS] UE5 네트워크 설정에 대한 조사... 그리고 프로젝트 적용  (0) 2025.12.29
[GAS] 함정 시스템 개선  (0) 2025.12.24
[GAS] RTS 미니맵 구현 & 성능 최적화  (0) 2025.12.05
'Dev./UE 언리얼 엔진' 카테고리의 다른 글
  • [GAS] 렌더링 최적화 : 30 FPS에서 60 FPS까지 (2)
  • [GAS] 이펙트가 프레임을 떨어뜨리는 이유, 오버드로 | 컷아웃 기법
  • [GAS] UE5 네트워크 설정에 대한 조사... 그리고 프로젝트 적용
  • [GAS] 함정 시스템 개선
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)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

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

티스토리툴바