Universal Render Pipeline(URP) 환경에서 모바일 및 하이엔드 PC 플랫폼 모두를 만족시킬 수 있는 극한의 렌더링 최적화 기술, 드로우 콜 배칭(Batching)의 원리, 그리고 셰이더 프로파일링 기법의 모든 것을 해부합니다.
서론: URP의 철학과 렌더링 병목 현상의 근본적 원인 파악
Unity 3D의 Universal Render Pipeline(URP)은 고품질의 물리 기반 렌더링(PBR) 그래픽 퀄리티를 유지하면서도 저사양 안드로이드 스마트폰부터 최신 데스크톱 그래픽카드에 이르기까지 광범위한 플랫폼 스펙트럼에서 뛰어난 확장성을 제공하도록 밑바닥부터 재설계된 차세대 렌더링 파이프라인입니다. 수년간 유니티의 표준이었던 기존의 Built-in 렌더 파이프라인이 내부 구조를 전혀 수정할 수 없는 거대한 블랙박스(Black Box) 형태의 모놀리식 아키텍처였다면, URP는 스크립터블 렌더 파이프라인(SRP) API를 근간으로 삼아 테크니컬 아티스트(TA)와 그래픽스 프로그래머가 렌더링 루프의 구석구석을 직접 C# 스크립트로 개입하여 통제하고 불필요한 패스를 잘라낼 수 있는 훌륭한 유연성을 제공합니다. 3D 게임 그래픽 최적화 분야에서 항상 가장 흔하게 언급되고 두려움의 대상이 되는 병목의 원인은 다름 아닌 '드로우 콜(Draw Call)'과 'SetPass 콜'입니다. 드로우 콜이란 중앙처리장치(CPU)가 그래픽카드(GPU)에게 '지금 메모리에 올라간 이 폴리곤 덩어리 메쉬를 그려내라'라고 렌더 커맨드를 내리는 행위인데, 이 명령 전달 과정 자체가 막대한 시스템적 오버헤드를 동반합니다. CPU가 메모리에서 버텍스 버퍼를 할당하고, 텍스처를 바인딩하고, 셰이더 프로퍼티(상태)를 설정한 뒤 커맨드 버퍼를 통해 GPU로 통신을 시도하는 동안, 연산 스레드는 결과를 기다리며 대기(Blocking) 상태에 빠지기 때문입니다. 특히 열 배출구가 없는 모바일 디바이스 환경에서는 APU의 발열로 인한 스로틀링(Throttling) 현상으로 인해 이러한 명령 전달 비용이 핑이 튀듯 기하급수적으로 커집니다. 따라서 URP 최적화의 궁극적인 핵심은, 무작정 화면에 보이는 화려한 메쉬의 폴리곤 개수나 텍스처 해상도를 깎아내려 시각적 품질을 훼손하는 것이 아니라, CPU와 GPU 간의 통신 비용을 어떻게 획기적으로 압축하고 효율적으로 한 번에 묶어 보낼 것인가(Batching)에 달려있습니다.
SRP 배처(Batcher)의 혁신적 동작 원리와 엄격한 호환성 조건
과거 Built-in 렌더 파이프라인 체제에서 드로우 콜을 억제하기 위해 개발자들이 동원할 수 있었던 주요 수단은 '정적 배칭(Static Batching)'과 '동적 배칭(Dynamic Batching)'뿐이었습니다. 이 구시대적 기술들은 씬에 존재하는 버텍스(Vertex) 배열들을 CPU 연산을 거쳐 강제로 하나의 거대한 덩어리로 합친(Merge) 뒤 GPU에 전송하는 무식한 방식이었습니다. 당연하게도 정적 배칭은 씬 크기에 비례하여 엄청난 RAM 메모리 오버헤드를 유발하여 모바일 OOM의 주범이 되었고, 동적 배칭은 정점 데이터를 합치는 CPU 수학 연산 부하가 드로우 콜을 아끼는 이득보다 커서 사실상 효용성이 떨어지는 유명무실한 기능이었습니다. 이 참담한 한계를 타개하기 위해 URP는 완전히 새로운 아키텍처인 'SRP Batcher'를 엔진 코어 단에 기본 탑재하였습니다. SRP Batcher의 철학은 발상의 전환입니다. '드로우 콜의 횟수 자체를 줄이는 것'을 포기하는 대신, '드로우 콜 사이사이에 렌더링 상태를 변경하는 가장 값비싼 준비 과정(SetPass Call)'을 극단적으로 제로에 가깝게 소거하는 것입니다. URP는 게임이 실행될 때 머티리얼이 가진 셰이더 파라미터들(Color, Offset, Smoothness 등)을 GPU 내부의 초고속 전용 메모리인 상수 버퍼(Constant Buffer, CBUFFER) 구역에 한 번에 모두 업로드해버립니다. 그리고 매 프레임 렌더링을 지시할 때는 셰이더 속성을 다시 전달하는 것이 아니라, 이미 VRAM에 올라가 있는 거대한 CBUFFER 상에서 현재 머티리얼이 사용할 데이터의 메모리 오프셋(Offset) 위치값만 살짝 바꿔주며 드로우 커맨드를 고속으로 연사합니다. 이를 통해 한 프레임에 수천 번의 드로우 콜이 쏟아지더라도 상태 변경(State Change) 비용이 거의 발생하지 않아, CPU 병목이 극적으로 해소되는 마법이 펼쳐집니다. 단, SRP Batcher가 이 마법을 발동시키려면 매우 엄격하고 까다로운 셰이더 작성 규칙(호환성 조건)을 준수해야만 합니다. 개발자가 커스텀 HLSL 셰이더를 작성할 경우, 머티리얼 속성으로 쓰이는 변수들을 반드시 UnityPerMaterial 이라는 정확한 명칭의 CBUFFER 블록 괄호 안에 선언해야 하며, 오브젝트들이 완전히 동일한 셰이더 베리언트(Shader Variant)를 공유하고 있어야 합니다. 이 조건만 만족한다면 씬 내에 제각기 다른 텍스처와 색상을 가진 10,000개의 캐릭터가 뛰어놀더라도, 셰이더 코드가 동일한 이상 SRP Batcher는 기적에 가까운 최상급 성능 퍼포먼스를 개발자에게 선사합니다.
GPU 인스턴싱(Instancing)과 SRP Batcher의 선택적 딜레마 극복
본격적인 렌더링 최적화를 진행하다 보면 테크니컬 아티스트와 프로그래머들은 종종 어떤 기술을 채택해야 할지 깊은 딜레마에 빠지게 됩니다. 예를 들어 드넓은 평야에 동일한 소나무 메쉬 5,000그루를 화면에 빽빽하게 뿌려야 하는 상황에서, 유니티가 자랑하는 SRP Batcher를 믿고 갈 것인지, 아니면 전통적인 강자인 GPU Instancing 기능을 켤 것인지 선택해야 하기 때문입니다. 결론부터 확실히 정리하자면, 완전히 동일한 메쉬 구조를 가지고 있고 완전히 동일한 머티리얼 인스턴스를 공유하는 객체들을 수천 개 단위로 대량 렌더링하는 극단적 상황(예: 울창한 숲의 나무들, 수백만 가닥의 잔디, 쏟아지는 화살비, 대규모 군중 AI 시스템)에서는 GPU Instancing 기술이 SRP Batcher보다 압도적으로 유리한 성능을 발휘합니다. 인스턴싱은 GPU 하드웨어 차원의 기능을 빌려 단 한 번의 단일 드로우 콜(Single Draw Call) 명령만으로 5,000개의 소나무를 공간 좌표만 다르게 하여 순식간에 도장 찍듯 찍어낼 수 있기 때문입니다. 반면, 형태(Mesh)가 제각각이고 입고 있는 옷의 색상(Material Properties)이 조금씩 다채롭지만, 기본적으로 같은 '카툰 렌더링 셰이더' 하나만을 공통으로 바라보고 있는 오브젝트들이 모인 복잡한 도심 환경이나 실내 던전 씬에서는 SRP Batcher가 훨씬 지능적이고 효율적으로 작동합니다. 여기서 개발자가 절대 잊지 말아야 할 가장 중요한 규칙은, Unity 엔진의 최신 렌더링 파이프라인 구조상 특정 한 종류의 오브젝트 그룹에 대해 GPU Instancing 메커니즘과 SRP Batcher 메커니즘은 절대로 동시에 중첩되어 적용될 수 없다는 사실입니다. 만약 개발자가 소나무 머티리얼의 하단 옵션에서 'Enable GPU Instancing' 체크박스를 V 표시하여 활성화했다 하더라도, 해당 소나무 오브젝트들의 메모리 정렬 상태나 머티리얼 속성이 완벽히 인스턴싱 조건에 부합하지 않고 하나라도 어긋난다면, 깐깐한 URP 시스템은 과감하게 개발자의 설정을 무시해 버리고 차선책으로 SRP Batcher를 우선 적용하여 렌더링을 처리해 버립니다. 따라서 자신이 다루는 렌더링 파이프라인의 내부 작동 특성을 투명하게 이해하고, 씬을 구성하는 아트 요소들의 기하학적 성격에 따라 렌더링 패스와 배칭 전략을 논리적으로 철저히 분리하여 설계하는 지혜가 요구됩니다.
모바일 치명타, 필 레이트(Fill Rate)와 오버드로우(Overdraw)의 분석적 최적화
만약 당신의 개발팀이 노력 끝에 CPU 병목을 일으키는 드로우 콜 숫자를 100 이하로 완벽히 제압해 냈다고 해서 렌더링 최적화 임무가 무사히 종료된 것은 절대 아닙니다. 특히 대역폭이 좁고 전력 소모에 극도로 취약한 모바일 환경에서 가장 경계해야 할 최악의 GPU 병목 현상은 바로 '필 레이트(Fill Rate)' 초과와 이에 수반되는 '오버드로우(Overdraw)' 이슈입니다. 필 레이트란 그래픽 처리 장치가 화면의 빈 픽셀 공간에 색상 값을 계산하여 채워 넣을 수 있는 초당 물리적 한계 속도를 의미하며, 이는 스마트폰 디바이스의 내장 GPU 하드웨어 스펙에 의해 냉혹하게 제한됩니다. 특히 반투명(Transparent) 재질을 사용하는 화려한 폭발 파티클, 안개 이펙트, 또는 화면 전체를 덮는 투명한 UI 캔버스가 카메라 앞에서 겹겹이 중첩되어 쌓이는 씬에서는 재앙이 시작됩니다. 동일한 물리적 화면 픽셀 공간에 대해 뒤에 있는 픽셀 색상부터 시작해서 알파 블렌딩 연산을 수십 번 넘게 덧칠하고 또 덧칠하는 비효율적인 반복 연산인 오버드로우 현상이 발생하기 때문입니다. 이 현상은 디바이스의 온도를 불과 3분 만에 손난로 수준으로 높여버리고 프레임을 박살 냅니다. 이를 근본적으로 방지하기 위해서는 URP의 렌더 패스 큐(Queue)를 세밀하게 통제해야 합니다. 투명도가 없는 불투명(Opaque) 오브젝트 그룹들은 카메라 렌즈와 가까운 것부터 먼 순서대로(Front-to-Back) 정렬되어 렌더링되도록 엔진 세팅을 유도하여, 어차피 앞 물체에 가려져 보이지 않게 될 뒤쪽 픽셀들의 무의미한 픽셀 셰이더 연산을 하드웨어의 초기 Z-Culling(심도 판정) 단계에서 가차 없이 날려버려야 합니다. 또한, 화면 절반을 덮는 거대한 반투명 이펙트를 제작할 때는 이펙터들의 흔한 습관인 빈 공간(Alpha가 0인 투명 영역)이 많은 네모 반듯한 쿼드(Quad) 폴리곤 2장을 크게 겹쳐 사용하는 관행을 반드시 뜯어고쳐야 합니다. 다소 귀찮고 버텍스가 늘어나더라도 발광하는 이펙트 형태의 외곽선에 딱 들어맞는 커스텀 폴리곤 메쉬 구조를 3D 툴에서 촘촘하게 깎아 제작하여, 쓸데없이 GPU가 계산해야 하는 빈 투명 픽셀 렌더링 면적 자체를 물리적으로 원천 차단해야 합니다. 씬 뷰 좌측 상단 모니터 옵션에서 Frame Debugger 창을 띄우거나 Overdraw 모드를 켜서 Opaque 렌더와 Transparent 렌더 과정을 픽셀 하나하나 겹침 현상을 한 프레임씩 넘기며 시각적으로 집요하게 검수하는 과정은, 쾌적한 모바일 최적화를 달성하기 위한 도 닦기와도 같은 필수 루틴입니다.
렌더 피처(Render Features)의 남용 경계와 URP 파이프라인 에셋 세팅 타협안
기존 파이프라인 대비 URP가 내세우는 가장 혁신적이고 강력한 특장점 중 하나는 바로 'Render Features'를 통해 테크니컬 아티스트가 화면 전체의 외곽선(Outline) 드로잉, 피격 시의 화면 꿀렁거림 왜곡(Distortion), 총알 자국 데칼(Decal) 등의 커스텀 렌더 패스 흐름을 복잡한 C++ 수정 없이 매우 손쉽게 파이프라인 루프 중간에 꽂아 넣어 확장할 수 있다는 점입니다. 하지만 모든 흑마법에는 등가교환의 대가가 따르듯, 이러한 피처 추가는 렌더링되는 화면 결과물을 또 다른 가상 화면(Render Target)으로 전환하여 임시 저장하고 전체 픽셀 데이터를 고속으로 복사하는 화면 복사 연산(Blit) 과정을 불가피하게 수반하기 때문에 막대한 메모리 대역폭(Memory Bandwidth) 소모 비용을 발생시킵니다. 특히 대부분의 최신 모바일 APU 칩셋이 채택하고 있는 타일 기반 렌더링(TBDR: Tile-Based Deferred Rendering) 하드웨어 아키텍처에서는, 연산 중인 타일 메모리 캐시를 버리고 메인 메모리의 렌더 타겟으로 강제 전환시키는 이 비용이 거대한 데스크톱 PC 환경보다 비교할 수 없을 정도로 훨씬 치명적이고 비쌉니다. 초보 개발자들이나 화려한 비주얼만을 추구하는 기획자들은 데모 영상과 같은 연출을 욕심내기 위해 URP 파이프라인 에셋 설정 인스펙터 창에서 'Depth Texture' 캡처 옵션과 'Opaque Texture' 캡처 옵션을 무심코 토글 버튼 클릭 한 번으로 켜두고는 잊어버리곤 하는데, 이 단순한 행위 하나가 씬 전체를 렌더링하는 본연의 연산 비용에 맞먹는 화면 강제 캡처 오버헤드 짐덩어리를 얹어버리는 꼴이 됩니다. 진정한 최적화의 고수는 새로운 빈 프로젝트를 생성하자마자 가장 먼저 이러한 부가 옵션들을 기본적으로 모조리 비활성화(Disable)하는 것부터 시작합니다. 특정 컷신 연출이나 특수한 굴절 셰이더 효과에서 뎁스 버퍼 텍스처 데이터가 1프레임 내내 지속적으로 반드시 필요한 상황인지 깐깐하게 스스로 검증해야 합니다. 만약 적 캐릭터들의 카툰 렌더링 외곽선 표시 연출이 기획서에 있다면, 무거운 Render Feature를 동원해 1920x1080 화면 전체 픽셀을 포스트 프로세싱하며 경계선을 찾는 방식 대신, 셰이더 코드 내부 단위에서 버텍스를 노멀 방향으로 살짝 밀어내어 내부 면을 뒤집어 씌우는 전통적인 2-Pass 프론트페이스 컬링 방식이나 비용이 거의 공짜인 스텐실(Stencil) 버퍼 렌더 레이어를 활용하는 등 대역폭 소모가 획기적으로 저렴한 우회 기법(Workaround)을 끊임없이 연구하고 고민해야 합니다. Project 윈도우에 놓인 작은 톱니바퀴 아이콘의 'URP Asset' 세팅 패널은 단순히 편리한 옵션 스위치 켜고 끄는 공간이 결코 아닙니다. 그곳은 안정적인 60 프레임 타임과 포기할 수 없는 시각적 비주얼 퀄리티 사이에서 피가 마르는 치열한 기획적 타협을 이뤄내야만 하는 전쟁의 최전선입니다.
// 모바일 극한 성능과 SRP Batcher 호환성을 완벽히 충족하는 URP 커스텀 셰이더 HLSL 작성 예시
Shader "Custom/Optimization/URP_SRPBatcher_Ultimate_Opaque"
{
// 인스펙터에 노출될 변수들 선언 구역
Properties
{
// 텍스처 데이터는 메모리 오프셋으로 치환될 수 없으므로 CBUFFER 밖에 둡니다
_BaseMap ("Base Albedo Map", 2D) = "white" {}
_BaseColor ("Base Tint Color", Color) = (1, 1, 1, 1)
_EmissionTint ("Glow Emission Color", Color) = (0, 0, 0, 1)
_AlphaCutoff ("Alpha Threshold Cutoff", Range(0.0, 1.0)) = 0.5
}
SubShader
{
// URP 파이프라인에서 불투명(Opaque) 객체로 정상 분류받기 위한 태그 세팅
Tags
{
"RenderType"="Opaque"
"RenderPipeline"="UniversalPipeline"
"Queue"="Geometry"
}
LOD 100
Pass
{
Name "ForwardLit"
Tags { "LightMode"="UniversalForward" }
HLSLPROGRAM
// 버텍스 및 프래그먼트 셰이더 함수 진입점 정의
#pragma vertex vert
#pragma fragment frag
// 대량의 동일 객체를 그릴 상황을 대비한 GPU Instancing 컴파일 지시자 추가
// (SRP Batcher 조건과 겹칠 경우 URP 시스템이 알아서 최적의 방식을 동적 스위칭함)
#pragma multi_compile_instancing
// URP 셰이더 라이브러리 코어 인클루드
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
// GPU로 넘어오는 버텍스 기본 입력 데이터 구조체
struct Attributes
{
float4 positionOS : POSITION; // 오브젝트 로컬 스페이스 버텍스 위치
float2 uv : TEXCOORD0; // 텍스처 매핑 좌표
UNITY_VERTEX_INPUT_INSTANCE_ID // 인스턴싱 매크로 필수
};
// 버텍스 셰이더에서 계산되어 프래그먼트 셰이더로 넘겨지는 보간 데이터 구조체
struct Varyings
{
float4 positionCS : SV_POSITION; // 클립 스페이스 변환 완료된 위치
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
// 텍스처 객체 선언 (이 부분은 SRP Batcher CBUFFER 안에 절대 넣으면 안 됨)
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
// ★핵심★ SRP Batcher가 성공적으로 작동하기 위한 절대적 요구사항:
// 모든 Material 프로퍼티 수치형/색상형 값은 반드시 'UnityPerMaterial' 이름의 CBUFFER 블록 내에 묶여 선언되어야 합니다.
// 순서와 타입이 일치해야만 GPU 상수 버퍼 오프셋 교체 렌더링이 성립됩니다.
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST; // 텍스처 타일링 오프셋 파라미터 내장 변수
half4 _BaseColor; // float보다 가벼운 half 정밀도(16bit) 적극 사용 권장
half4 _EmissionTint;
half _AlphaCutoff;
CBUFFER_END
// 버텍스 연산 진입점
Varyings vert(Attributes input)
{
Varyings output = (Varyings)0;
// 인스턴싱 환경 셋업
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
// 행렬 곱셈을 통한 공간 변환 (수동 곱셈 대신 제공되는 최적화 함수 사용)
output.positionCS = TransformObjectToHClip(input.positionOS.xyz);
output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
return output;
}
// 픽셀 색상 결정 진입점
half4 frag(Varyings input) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(input);
// 텍스처 샘플링 비용 최소화 로직
half4 texColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
// 복잡한 런타임 분기(if-else)를 피하기 위한 단순 곱셈/덧셈 조합형 셰이더 아키텍처
half4 finalColor = texColor * _BaseColor + _EmissionTint;
return finalColor;
}
ENDHLSL
}
}
}