작성일 :

개별 셰이더에서 프로젝트 전체로

셰이더 최적화 (1) - 셰이더 성능의 원리에서는 셰이더 하나의 비용을 결정하는 요소들을 다루었습니다. ALU 연산 수, 텍스처 샘플링 횟수, 정밀도 선택이 프래그먼트 셰이더의 실행 시간을 좌우한다는 점을 확인했습니다.


셰이더를 하나씩 놓고 보면 연산량이 성능의 핵심입니다.

하지만 프로젝트 전체로 시야를 넓히면, 셰이더 배리언트(Variant) 수가 빌드 시간, 메모리, 로딩 속도에 큰 영향을 미칩니다. 셰이더 하나의 연산이 아무리 가볍더라도 배리언트가 수만 개로 불어나면 빌드 시간이 수십 분에서 수 시간으로 늘어나고, 셰이더 바이너리가 수백 MB의 메모리를 차지하게 됩니다.



셰이더 배리언트란

조건부 기능과 키워드

셰이더에는 다양한 조건부 기능이 포함됩니다. 포그(Fog)를 적용할지 여부, 노멀 맵을 사용할지 여부, 그림자를 받을지 여부, 라이트맵을 사용할지 여부 등이 대표적입니다.

이런 조건부 기능을 셰이더 코드 안에서 분기 처리하는 방식으로 구현하면, GPU에서 런타임 분기(branch)가 발생합니다.

Part 1에서 다루었듯, GPU의 런타임 분기는 SIMD 구조 때문에 비효율적입니다.


Unity의 셰이더 시스템은 이 문제를 컴파일 타임 분기로 해결합니다.

조건부 기능마다 키워드(keyword)를 정의하고, 키워드 조합마다 별도의 셰이더 프로그램을 미리 컴파일하는 방식입니다. 이렇게 키워드 조합별로 생성되는 개별 셰이더 프로그램을 셰이더 배리언트라고 부릅니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
셰이더 소스 코드 (하나)
┌────────────────────────────────────────────────────────┐
│                                                        │
│  #pragma multi_compile _ FOG_ON          (2가지 상태)  │
│  #pragma multi_compile _ NORMAL_MAP_ON   (2가지 상태)  │
│                                                        │
│  ( _ 는 "키워드 없음", 즉 해당 기능 OFF)               │
│                                                        │
│  셰이더 로직...                                        │
│  #if defined(FOG_ON)                                   │
│      포그 계산                                         │
│  #endif                                                │
│  #if defined(NORMAL_MAP_ON)                            │
│      노멀 맵 샘플링                                    │
│  #endif                                                │
│                                                        │
└────────────────────────────────────────────────────────┘
          │
          │  컴파일: 2 × 2 = 배리언트 4개
          ▼
┌─────────────────────────────────────────────┐
│  배리언트 1: FOG_OFF  + NORMAL_MAP_OFF      │
│  배리언트 2: FOG_ON   + NORMAL_MAP_OFF      │
│  배리언트 3: FOG_OFF  + NORMAL_MAP_ON       │
│  배리언트 4: FOG_ON   + NORMAL_MAP_ON       │
│                                             │
│  각 배리언트는 독립적인 GPU 프로그램        │
│  → 런타임 분기 없이 실행                    │
└─────────────────────────────────────────────┘


각 배리언트에는 해당 기능 조합에 필요한 코드만 들어 있습니다. FOG_OFF + NORMAL_MAP_OFF 배리언트에는 포그 계산 코드와 노멀 맵 샘플링 코드가 아예 존재하지 않습니다.

런타임에는 현재 설정에 맞는 배리언트 하나를 선택하여 실행하므로, GPU에서 분기 비용이 발생하지 않습니다.


배리언트 폭증(Variant Explosion)

곱셈으로 늘어나는 조합

배리언트의 수는 키워드 옵션 수의 으로 결정됩니다. 키워드가 하나 추가될 때마다 배리언트 수가 배수로 증가합니다.


1
2
3
4
5
6
7
키워드 조합과 배리언트 수

키워드 A: 2개 옵션 (ON / OFF)
키워드 B: 3개 옵션 (LOW / MEDIUM / HIGH)
키워드 C: 2개 옵션 (ON / OFF)

배리언트 수 = 2 × 3 × 2 = 12개


키워드 3개에서 12개라면 관리할 수 있지만, 실제 프로젝트에서는 키워드가 10개 이상으로 늘어나기 쉽습니다.


1
2
3
4
5
6
7
8
9
각 키워드가 2개 옵션(ON/OFF)인 경우

키워드 수    계산                배리언트 수
────────────────────────────────────────
    2       2 × 2                      4
    3       2 × 2 × 2                  8
    5       2⁵                        32
   10       2¹⁰                    1,024
   20       2²⁰                1,048,576


Unity의 Built-in 키워드(포그, 라이트맵, 인스턴싱 등)와 프로젝트에서 추가하는 커스텀 키워드를 합치면 10~15개는 쉽게 도달합니다. 키워드 10개면 배리언트가 1,024개이고, 옵션이 3개 이상인 키워드가 섞이면 그 이상으로 늘어납니다.


배리언트 폭증의 영향

배리언트 수가 많아지면 빌드 시간, 빌드 크기, 로딩 시간이 모두 증가합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
배리언트 폭증의 영향

(1) 빌드 시간
    1,000개 배리언트 = 1,000번 컴파일
    셰이더가 여러 개이면 배리언트 수도 합산
    빌드 시간이 수 분에서 수 시간으로 증가

(2) 빌드 크기와 메모리
    각 배리언트가 독립적인 바이너리로 빌드에 포함
    설치 크기 증가 + 런타임 메모리 점유 증가

(3) 로딩 시간
    씬 전환·머티리얼 변경 시 필요한 배리언트를 탐색·로드
    첫 프레임에서 셰이더를 즉석 컴파일하며 순간 멈춤(히치) 발생 가능


모바일에서는 이 영향이 더 큽니다. 저사양 기기에서 수천 개의 배리언트를 메모리에 올리면 앱이 강제 종료될 수 있고, 처음 만나는 배리언트를 즉석에서 컴파일하면 그 프레임이 수십~수백 ms 지연되어 화면이 버벅입니다.


multi_compile과 shader_feature

multi_compile

multi_compile은 머티리얼의 실제 사용 여부와 관계없이, 정의된 키워드의 모든 조합을 빌드에 포함합니다.


1
2
3
4
5
6
7
8
9
10
multi_compile의 동작

#pragma multi_compile _ FOG_ON
#pragma multi_compile _ SHADOW_ON
#pragma multi_compile _ LIGHTMAP_ON

→ 2 × 2 × 2 = 8개 배리언트 모두 빌드에 포함

씬의 머티리얼에서 실제로 사용하는 조합이 2개뿐이어도,
나머지 6개 배리언트도 빌드에 포함됨


multi_compile이 필요한 상황은 런타임에 키워드를 동적으로 전환하는 경우입니다. 포그를 런타임에 켜고 끄는 기능이 있다면, 포그 ON 배리언트와 포그 OFF 배리언트가 둘 다 빌드에 있어야 합니다.


shader_feature

shader_feature빌드 시점에 프로젝트의 머티리얼을 검사하여, 실제로 사용되는 키워드 조합만 빌드에 포함합니다. 사용되지 않는 조합은 제외됩니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
shader_feature의 동작

#pragma shader_feature _ NORMAL_MAP_ON
#pragma shader_feature _ EMISSION_ON

가능한 배리언트: 4개

씬의 머티리얼 검사:
  머티리얼 A: NORMAL_MAP_ON + EMISSION_OFF  ← 사용됨
  머티리얼 B: NORMAL_MAP_OFF + EMISSION_ON  ← 사용됨

→ 사용되는 2개 배리언트만 빌드에 포함
→ 나머지 2개는 제외 (빌드 크기 절약)


shader_feature는 빌드 크기를 줄이는 데 효과적이지만, 런타임에 키워드를 변경하면 해당 조합의 배리언트가 빌드에 없을 수 있습니다. 빌드 시점에 머티리얼 A가 NORMAL_MAP_ON만 사용하고 있었다면, NORMAL_MAP_ON 배리언트만 빌드에 포함됩니다. 런타임에 코드로 EMISSION_ON을 추가해도 NORMAL_MAP_ON + EMISSION_ON 조합의 배리언트가 없으므로, Emission 효과가 적용되지 않습니다.


선택 기준

핵심은 런타임에 키워드가 바뀌는지 여부입니다.


1
2
3
4
5
6
7
8
9
10
11
multi_compile vs shader_feature 선택

multi_compile
├ 런타임에 키워드를 동적으로 전환하는 경우
├ Unity 엔진 내부 키워드 (포그, 조명 모드 등)
└ 모든 조합이 빌드에 포함 → 빌드 크기 증가

shader_feature
├ 머티리얼별로 고정된 기능 (노멀 맵, 이미션 등)
├ 런타임에 키워드를 변경하지 않는 경우
└ 사용되는 조합만 빌드에 포함 → 빌드 크기 절약


고정 키워드를 multi_compile로 선언하면 불필요한 배리언트가 빌드 크기를 늘리고, 동적 키워드를 shader_feature로 선언하면 런타임에 배리언트가 누락되어 렌더링이 깨집니다.


키워드 관리와 스트리핑

사용하지 않는 키워드 제거

배리언트 수를 줄이는 가장 직접적인 방법은 키워드 자체를 줄이는 것입니다.


1
2
3
4
5
6
7
8
9
키워드 제거의 효과

제거 전: 키워드 10개 (각 2옵션)
  배리언트 수 = 2^10 = 1,024

키워드 2개 제거 후: 키워드 8개 (각 2옵션)
  배리언트 수 = 2^8 = 256

→ 키워드 2개 제거로 배리언트 75% 감소


각 키워드가 2개 옵션이면, 키워드 하나를 제거할 때마다 전체 배리언트 수가 절반으로 줄어듭니다. 라이트맵이나 포그처럼 프로젝트에서 사용하지 않는 기능의 키워드가 대표적인 제거 대상입니다.


Unity의 셰이더 스트리핑(Shader Stripping)

Unity는 빌드 시점에 프로젝트 설정과 씬 구성을 분석하여, 사용하지 않는 배리언트를 자동으로 제거하는 셰이더 스트리핑 기능을 제공합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
셰이더 스트리핑 과정

셰이더 컴파일
       │
       ▼
전체 배리언트 목록 생성
       │
       ▼
스트리핑 단계
├── (1) 자동 스트리핑
│     ├ 프로젝트에서 사용하지 않는 라이트 모드 배리언트 제거
│     ├ 비활성화된 기능(포그, 라이트맵 등)의 배리언트 제거
│     └ URP: Built-in 셰이더 키워드 자동 스트리핑
│
├── (2) shader_feature에 의한 제외
│     └ 머티리얼에서 사용하지 않는 조합 제외
│
└── (3) 커스텀 스트리핑 (IPreprocessShaders)
      └ 개발자가 콜백으로 직접 배리언트 제거
       │
       ▼
최종 배리언트만 빌드에 포함


URP는 Built-in 파이프라인보다 스트리핑 범위가 넓습니다. Built-in 파이프라인은 포워드 렌더링과 디퍼드 렌더링을 모두 지원하므로, 두 방식에 해당하는 키워드가 모두 포함됩니다. URP는 포워드 렌더링만 사용하므로 디퍼드 관련 키워드가 불필요하고, 사용하지 않는 라이트 타입 키워드도 자동으로 제거됩니다. 파이프라인을 전환하는 것만으로도 배리언트 수가 줄어드는 이유입니다.


IPreprocessShaders로 커스텀 스트리핑

Unity의 자동 스트리핑으로 충분하지 않은 경우, IPreprocessShaders 인터페이스를 구현하여 빌드 시점에 직접 배리언트를 제거할 수 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
IPreprocessShaders 콜백의 동작

셰이더 컴파일 완료
       │
       ▼
OnProcessShader(shader, snippet, shaderCompilerData) 호출
       │
       ├── 셰이더 이름으로 필터링
       │     예: "Debug/"로 시작하면 → 모든 배리언트 제거
       │
       ├── 패스 타입으로 필터링
       │     예: Meta 패스를 사용하지 않으면 → 해당 배리언트 제거
       │
       ├── 키워드로 필터링
       │     예: 포인트 라이트를 쓰지 않으면 → POINT 키워드 배리언트 제거
       │
       └── 제거되지 않은 배리언트만 빌드에 포함


콜백은 셰이더가 컴파일된 후, 빌드에 포함되기 직전에 호출됩니다. 셰이더 이름, 패스 타입, 키워드 조합을 모두 확인할 수 있으므로, 자동 스트리핑이 놓치는 프로젝트 고유의 조건을 직접 지정할 수 있습니다.


빌드 로그에서 배리언트 수 확인

배리언트 관리의 출발점은 현재 프로젝트의 배리언트 수를 파악하는 것입니다. Unity 에디터에서 빌드를 수행하면 Editor.log에 셰이더별 배리언트 수가 기록되므로, 이를 통해 현황을 확인할 수 있습니다.


1
2
3
4
5
6
7
8
9
10
Editor.log의 셰이더 배리언트 정보 (예시)

Compiling shader "Universal Render Pipeline/Lit" pass "ForwardLit"
    Full variant space:         24,576
    After settings filtering:    4,096
    After built-in stripping:    1,024
    After scriptable stripping:    256
    Compiled:                      256

→ 원래 24,576개였던 배리언트가 스트리핑을 거쳐 256개로 감소


Full variant space는 키워드 조합으로 가능한 전체 배리언트 수입니다. 이후 프로젝트 설정 필터링, Built-in 스트리핑, 커스텀 스트리핑을 거치며 최종 컴파일 수까지 줄어듭니다. 새로운 키워드를 추가한 뒤 이 로그를 확인하면, 어느 단계에서 배리언트가 얼마나 제거되는지 바로 파악할 수 있습니다.


모바일 셰이더 기법

배리언트 수를 관리하는 것이 프로젝트 수준의 최적화라면, 셰이더 자체의 연산을 줄이는 것은 런타임 성능에 직접 영향을 미치는 최적화입니다. 모바일 GPU는 데스크톱에 비해 연산 능력과 대역폭이 제한적이므로, 같은 시각적 결과를 더 적은 연산으로 달성하는 기법이 필요합니다.


베이크 라이팅 활용

실시간 조명 계산은 프래그먼트 셰이더에서 픽셀마다 반복되며, 광원 수에 비례하여 연산이 늘어납니다. 베이크 라이팅(Bake Lighting)은 이 연산을 빌드 전에 미리 수행하여 텍스처에 저장하는 방식입니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
실시간 조명 vs 베이크 라이팅

[실시간 조명]
프래그먼트 셰이더에서 매 픽셀마다:
  광원 방향 계산
  거리 감쇠 계산
  법선 · 광원 방향 내적 (디퓨즈)
  반사 벡터 계산 (스페큘러)
  여러 광원이면 위 과정을 광원 수만큼 반복

→ 광원 3개이면 ALU 연산도 3배

[베이크 라이팅 (라이트맵)]
에디터에서 미리 조명을 계산하여 텍스처(라이트맵)에 저장

프래그먼트 셰이더에서 매 픽셀마다:
  라이트맵 텍스처 샘플링 1회
  기본 텍스처와 곱하기

→ 광원 수와 무관하게 텍스처 읽기 1회 + 곱셈 1회


라이트맵은 빌드 전에 계산되므로, 런타임에 위치가 바뀌는 오브젝트나 광원의 변화를 반영하지 못합니다.

모바일 게임에서는 배경과 정적 오브젝트에 베이크 라이팅을 적용하고, 캐릭터 등 움직이는 오브젝트에만 실시간 조명을 사용하는 구조가 일반적입니다.


간소화된 PBR

데스크톱 환경의 Standard 셰이더(Built-in 파이프라인)는 물리 기반 렌더링(PBR)을 완전히 구현합니다. URP의 Lit 셰이더는 이 연산을 모바일에 맞게 간소화합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
데스크톱 PBR vs 모바일 간소화 PBR

[데스크톱 Standard 셰이더]
프래그먼트 셰이더:
  GGX 분포 함수 (D)        ← 삼각함수·지수 연산
  기하 함수 (G)             ← 삼각함수·지수 연산
  Fresnel-Schlick (F)       ← 지수 연산
  에너지 보존 보정
  IBL 큐브맵 샘플링 + mip 계산
  → 높은 ALU 비용 + 다수의 텍스처 샘플링

[URP Lit 셰이더 (모바일)]
프래그먼트 셰이더:
  간소화된 BRDF 근사        ← 곱셈·덧셈 위주
  환경 BRDF를 수학적 근사 수식으로 대체  ← 텍스처 샘플링 제거
  IBL을 구면 조화 함수(SH)로 근사       ← 큐브맵 샘플링 제거
  → ALU 비용 감소 + 텍스처 샘플링 최소화


간소화의 대표적인 예가 환경 BRDF(양방향 반사 분포 함수)의 처리 방식입니다. 환경 BRDF 적분은 원래 수치 적분이 필요하므로, HDRP는 그 결과를 미리 계산한 2D LUT 텍스처에서 샘플링합니다. URP는 이 텍스처 대신 roughness와 Fresnel 항만으로 계산하는 근사 수식을 사용합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
환경 BRDF 근사 방식 비교

[HDRP 방식: LUT 텍스처]
  미리 계산된 2D 텍스처에서 roughness × NdotV로 샘플링
  → 정확도 높음, 텍스처 메모리 + 샘플링 비용 발생

[URP 방식: 수학적 근사]
  surfaceReduction = 1.0 / (roughness² + 1.0)
  result = surfaceReduction × lerp(specular, grazingTerm, fresnelTerm)

  → 텍스처 접근 없이 곱셈·덧셈으로 계산
  → 모바일 GPU에서 대역폭 절약
  → 정확도는 LUT보다 낮지만 시각적 차이는 미미


모바일 프로젝트에서 PBR이 필요하다면 URP의 Lit 셰이더가 기본 선택입니다. 데스크톱 Standard 셰이더와 시각적으로 유사한 결과를 텍스처 샘플링과 ALU 연산을 줄여 달성합니다.

UI, 파티클, 단색 배경처럼 광원에 반응할 필요가 없는 오브젝트도 있습니다. 이런 오브젝트에는 Unlit 셰이더가 적합합니다. Unlit 셰이더는 조명 계산을 아예 수행하지 않고 텍스처 색상을 그대로 출력하므로, Lit 셰이더보다 연산 비용이 낮습니다.


정점 기반 이펙트

Part 1에서 프래그먼트 셰이더의 실행 횟수가 버텍스 셰이더보다 많다는 점을 확인했습니다. 화면에 100 × 100 픽셀을 차지하는 삼각형이 정점 3개로 이루어져 있다면, 버텍스 셰이더는 3번, 프래그먼트 셰이더는 10,000번 실행됩니다. 프래그먼트 셰이더에서 수행하던 일부 연산을 버텍스 셰이더로 옮기면 실행 횟수 차이만큼 연산량이 줄어듭니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
프래그먼트 → 버텍스로 연산 이동

[프래그먼트에서 계산]
버텍스 셰이더: 위치 변환만 수행 (3번 실행)
프래그먼트 셰이더: 리플 이펙트 계산 (10,000번 실행)

→ 리플 계산이 10,000번 실행됨

[버텍스에서 계산]
버텍스 셰이더: 위치 변환 + 리플 이펙트 계산 (3번 실행)
  → 결과를 varying으로 프래그먼트에 전달
프래그먼트 셰이더: varying 값을 보간하여 사용 (10,000번 실행)

→ 리플 계산이 3번 실행됨
→ 프래그먼트에서는 보간된 값만 사용 (추가 연산 없음)


버텍스 셰이더에서 계산한 값은 래스터화 단계에서 정점 사이를 선형 보간하여 프래그먼트 셰이더에 전달됩니다. 이 보간은 하드웨어가 자동으로 수행합니다.


보간은 정점 사이의 값을 직선으로 이어주므로, 원래 값이 표면을 따라 부드럽게 변하는 경우에는 결과가 거의 같습니다. 반대로, 값이 급격하게 변하는 연산을 버텍스에서 계산하면 정점 사이의 디테일이 뭉개집니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
버텍스 이동이 적합한 연산과 부적합한 연산

적합 (부드럽게 변하는 값)
├── 간단한 디퓨즈 조명 (NdotL)
├── 리플, 파동 이펙트
├── 환경광 색상
├── 정점 색상 기반 이펙트
└── 안개(Fog) 농도 계산

부적합 (급격하게 변하는 값)
├── 노멀 맵 기반 스페큘러       ← 보간으로 하이라이트가 뭉개짐
├── 날카로운 마스크 경계         ← 보간으로 경계가 흐려짐
├── 텍스처 기반 디테일           ← 텍스처 샘플링은 프래그먼트에서만 가능
└── 정밀한 반사/굴절 효과       ← 보간으로 방향 정밀도 손실


프래그먼트 셰이더 간소화

텍스처 샘플링 수 최소화. Part 1에서 텍스처 샘플링이 메모리 대역폭을 소모하는 연산임을 확인했습니다. 모바일 GPU는 대역폭이 제한적이므로 샘플링 횟수를 줄여야 합니다. 채널 패킹(Channel Packing)으로 여러 흑백 텍스처를 하나의 텍스처의 RGBA 채널에 합치면, 샘플링 1회로 여러 데이터를 얻을 수 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
채널 패킹

[패킹 전]
텍스처 1: Metallic (흑백)   → 샘플링 1회
텍스처 2: Roughness (흑백)  → 샘플링 1회
텍스처 3: AO (흑백)         → 샘플링 1회
→ 총 3회 샘플링

[채널 패킹 후]
텍스처 1개:
  R 채널: Metallic
  G 채널: Roughness
  B 채널: AO
  A 채널: (여분 / 다른 데이터)
→ 총 1회 샘플링으로 3가지 데이터를 모두 획득


복잡한 수학 함수를 LUT 텍스처로 대체. 앞서 환경 BRDF LUT를 다루었는데, 이 기법은 다른 복잡한 수학 함수에도 적용할 수 있습니다. pow, exp, sin, cos 등의 함수가 반복적으로 호출되는 경우, 입력 범위에 대한 결과를 미리 텍스처에 저장해두고 런타임에 샘플링으로 대체하면 ALU 연산이 줄어듭니다. 다만 LUT 텍스처 자체가 메모리를 차지하고 샘플링 비용이 발생하므로, 대체하려는 수학 연산이 충분히 복잡할 때만 효과가 있습니다.


half 정밀도 활용. Part 1에서 float(32bit)과 half(16bit)의 차이를 다루었습니다. 모바일 GPU에서 half 연산은 float 대비 처리량이 약 2배입니다. 위치 좌표나 깊이 값처럼 정밀도가 중요한 데이터만 float를 유지하고, 나머지는 half로 선언합니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
정밀도 선택 가이드

float (32bit) 유지
├── 월드 공간 위치 좌표
├── 깊이(depth) 값
├── 누적되는 시간 값
└── 큰 범위의 수학 연산

half (16bit) 사용
├── 색상 값 (RGB, Alpha)
├── UV 좌표
├── 법선 벡터
├── 조명 강도
├── 텍스처 샘플링 결과
└── 대부분의 중간 계산 값


불필요한 기능 비활성화. 셰이더에서 사용하지 않는 기능(그림자 받기, 포그 적용, 라이트 프로브 등)이 활성화되어 있으면, 해당 기능의 연산이 프래그먼트 셰이더에 포함됩니다. URP Lit 셰이더의 경우 머티리얼 Inspector에서 개별 기능을 끌 수 있습니다.


모바일 셰이더 전략 요약

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
모바일 셰이더 최적화 전략

(1) 조명 전략 결정
    ├ 정적 오브젝트: 베이크 라이팅 (라이트맵)
    ├ 동적 오브젝트: 라이트 프로브 + 제한된 실시간 조명
    └ 실시간 광원 수 최소화

(2) 셰이더 선택
    ├ PBR이 필요한 오브젝트: URP Lit (간소화된 PBR)
    ├ 조명이 필요 없는 오브젝트: URP Unlit
    └ 특수 효과: 최소한의 연산으로 커스텀 셰이더

(3) 연산 최적화
    ├ 부드럽게 변하는 계산은 버텍스 셰이더로 이동
    ├ 텍스처 채널 패킹으로 샘플링 수 감소
    ├ 복잡한 수학 함수는 LUT로 대체
    └ half 정밀도 활용

(4) 불필요한 기능 제거
    ├ 사용하지 않는 셰이더 기능 비활성화
    └ 키워드 수 최소화 → 배리언트 감소


조명 전략이 먼저 결정되어야 셰이더 선택이 가능하고, 셰이더가 결정되어야 연산 최적화를 적용할 수 있으므로, (1)부터 순서대로 진행합니다.


Shader Graph 고려사항

Shader Graph의 편의성

Unity의 Shader Graph는 노드를 연결하는 시각적 인터페이스로 셰이더를 작성하는 도구입니다. HLSL 코드를 직접 작성하지 않으므로, 셰이더 프로그래밍에 익숙하지 않은 개발자나 아티스트도 사용할 수 있습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Shader Graph의 구조

Shader Graph 에디터

  [텍스처 노드] ─── [샘플링 노드] ──┐
                                 ├── [Multiply] ──── [Fragment Output]
  [색상 노드] ────────────────────┘

  [노멀 맵 노드] ── [Unpack] ─────── [Normal Output]

→ 노드를 연결하면 HLSL 코드를 자동 생성
         │
         ▼  컴파일

자동 생성된 HLSL 코드

  void SurfaceDescription(...) {
      texSample = SAMPLE_TEXTURE2D(...)   ← [샘플링 노드]에 대응
      result = texSample * color          ← [Multiply 노드]에 대응
      ...
  }


생성 코드의 비효율성

Shader Graph가 자동으로 생성하는 HLSL 코드는, 수작업으로 최적화된 HLSL보다 비효율적일 수 있습니다.


불필요한 노드 연결. 그래프에 연결된 모든 노드의 연산이 생성 코드에 포함됩니다. 테스트 목적으로 연결했다가 제거하지 않은 노드, 최종 출력에 도달하지 않는 중간 노드도 예외가 아닙니다. GPU 컴파일러가 일부를 제거(dead code elimination)하기도 하지만, 모든 경우에 보장되지는 않습니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
불필요한 노드가 포함된 경우

Shader Graph:

  [텍스처 A] ── [샘플링] ──┐
                         ├── [Lerp] ── [Multiply] ── [출력]
  [텍스처 B] ── [샘플링] ──┘
                                   ↑
  [파라미터] ───────────────────────┘

  [텍스처 C] ── [샘플링] ── [Contrast] ── (연결 끊김, 출력에 도달하지 않음)

→ 텍스처 C의 샘플링과 Contrast 노드는 출력에 기여하지 않음
→ GPU 컴파일러가 제거할 수도 있지만, 보장되지 않음
→ 수작업이라면 처음부터 포함하지 않음


범용적인 코드 생성. Shader Graph는 다양한 상황에서 동작해야 하므로, 생성 코드가 범용적으로 작성됩니다. 예를 들어, 노멀 벡터가 이미 정규화되어 있는 상황에서도 normalize()를 다시 호출하는 코드가 생성됩니다. 수작업 HLSL에서는 이런 불필요한 연산을 생략할 수 있지만, Shader Graph는 입력 상태를 가정할 수 없으므로 안전한 쪽을 선택합니다.


키워드 생성. Shader Graph의 일부 노드는 내부적으로 셰이더 키워드를 생성합니다. Boolean Keyword 노드를 하나 추가하면 multi_compile 키워드가 하나 늘어나 배리언트 수가 2배가 됩니다. 개발자가 명시적으로 키워드를 추가하지 않았는데도 배리언트가 늘어나므로, 그래프의 Keyword 목록을 확인할 필요가 있습니다.


생성 코드 확인과 최적화

Shader Graph의 성능을 관리하려면, 생성된 코드를 직접 확인하는 과정이 필요합니다.


1
2
3
4
5
6
7
8
9
10
11
Shader Graph의 생성 코드 확인 방법

(1) Shader Graph 에디터에서 셰이더 에셋을 선택
(2) Inspector에서 "Compiled Shader" 또는 "Generated Code" 확인
(3) 또는: Inspector에서 "View Generated Shader" 버튼 클릭

확인할 항목:
  - 사용되지 않는 텍스처 샘플링이 포함되어 있는지
  - 불필요한 정규화(normalize)나 변환이 반복되는지
  - float으로 선언된 변수 중 half로 바꿀 수 있는 것이 있는지
  - 키워드가 의도하지 않게 추가되었는지


성능에 민감한 셰이더는 Shader Graph로 프로토타입을 만든 뒤, 생성된 코드를 기반으로 수작업 HLSL로 전환하는 방법도 있습니다.


1
2
3
4
5
6
7
8
9
10
11
Shader Graph 활용 워크플로

(1) Shader Graph에서 프로토타입 (시각적으로 결과 확인)
         │
         ▼
(2) 생성 코드 확인 (불필요한 연산, 키워드 수 파악)
         │
         ▼
(3) 판단
    ├ 성능에 민감하지 않은 셰이더 → Shader Graph 그대로 사용
    └ 성능에 민감한 셰이더       → 수작업 HLSL로 전환하여 최적화


모든 셰이더를 수작업 HLSL로 작성할 필요는 없습니다. 소량만 사용되는 특수 효과나 화면에서 작은 면적을 차지하는 오브젝트는 Shader Graph로 충분합니다. 프로파일러로 GPU 비용을 측정하여, 비용이 높은 셰이더만 선택적으로 최적화합니다.


셰이더에서 물리로

Part 1에서는 셰이더 코드 수준에서 ALU 연산, 텍스처 샘플링, 정밀도를 조정하여 개별 셰이더의 실행 비용을 줄였습니다. 이 글에서는 프로젝트 수준으로 범위를 넓혀, 배리언트 수를 관리하고 모바일에 적합한 셰이더 기법을 적용했습니다.


두 글 모두 GPU 측의 렌더링 비용에 집중했습니다. 게임의 프레임 시간에는 렌더링 외에도 CPU에서 실행되는 물리 엔진의 비용이 포함됩니다.


마무리

  • 셰이더 배리언트는 키워드 조합마다 별도의 GPU 프로그램이 생성되는 구조이며, 키워드 n개일 때 배리언트는 2ⁿ으로 증가합니다.
  • multi_compile은 모든 조합을 빌드에 포함하고, shader_feature는 머티리얼에서 사용하는 조합만 포함합니다.
  • 사용하지 않는 키워드 제거, Unity 자동 스트리핑, IPreprocessShaders 콜백으로 배리언트 수를 줄일 수 있습니다.
  • 베이크 라이팅은 실시간 조명 계산을 텍스처 샘플링 한 번으로 대체하고, URP Lit 셰이더는 PBR 연산을 모바일에 맞게 간소화합니다.
  • 부드럽게 변하는 연산은 버텍스 셰이더로 이동하고, 채널 패킹과 half 정밀도로 프래그먼트 셰이더 비용을 줄입니다.
  • Shader Graph는 편리하지만 생성 코드에 불필요한 연산이 포함될 수 있으므로, 성능에 민감한 셰이더는 생성 코드를 확인하고 필요 시 수작업 HLSL로 전환합니다.

키워드 하나를 무심코 추가했다가 빌드가 두 배로 느려지고, 앱 크기가 수십 MB 늘어나는 일이 실제로 발생합니다. 배리언트 수는 빌드 로그에서 바로 확인할 수 있으므로, 키워드를 추가하거나 셰이더를 변경할 때마다 확인하는 것이 가장 확실한 관리 방법입니다.

이 시리즈에서는 GPU 측 렌더링 비용에 집중했습니다. 게임의 프레임 시간에는 렌더링 외에도 CPU에서 실행되는 물리 연산이 포함됩니다. 충돌 감지, 리지드바디 시뮬레이션, 레이캐스트 등이 매 프레임 CPU 시간을 소모합니다.

PhysicsOptimization 시리즈에서는 물리 엔진의 구조와 최적화를 다룹니다.



관련 글

시리즈

전체 시리즈

Tags: Unity, 모바일, 배리언트, 셰이더, 최적화

Categories: ,