# 프레임이 떨어져요..

![Image](https://upload.cafenono.com/image/slashpagePost/20251221/233820_NqW3DD6gPmy5wBUqqN?q=80&s=1280x180&t=outside&f=webp)

## 문제 상황:  PC에서 프레임 드랍 발생?

잔디가 잔뜩 배치된 씬을 구성한 뒤, **에디터(Mac M2 환경)**에서 눈에 띄는 프레임 드랍이 발생했다.

잔디 오브젝트는 **6개의 custom sprite**로 구성된 비교적 단순한 구조였다.

겨우 이 정도인데 프레임이 떨어진다고?

**Profiler (Analysis > Profiler > Render)** 를 확인했다.

```javascript
SetPass Calls: 5.1k
Draw Calls: 5.2k
Batches: 5232
Triangles: 66.9k
Vertices: 63.8k

(Dynamic Batching): 0
(Static Batching): 0
(Instancing): 0

Shadow Casters: 1802
```

수치만 봐도 상황은 심각했다.

### 핵심 문제 요약

1. **Draw Calls / SetPass Calls 과다**

- 매 프레임 5천 번 이상 드로우 → PC 에디터에서도 부담

- 모바일 환경에서는 사실상 치명적

2. **배칭 & 인스턴싱이 전부 0**

- Static Batching / GPU Instancing이 전혀 잡히지 않음

- 구조적으로 "아무 것도 묶을 수 없는 상태"일 가능성

3. **Shadow Casters 1802개**

- 그림자 캐스터 수가 과도하게 많음

---

## 1차 개선: 메시 컴바인 (Mesh Combine)

배칭이나 인스턴싱을 적용하기 전에, **가장 단순하고 확실한 방법**부터 적용했다.

### 접근 방식

- 잔디 하나당 6개로 나뉜 sprite를 **하나의 combined mesh**로 통합

- 잔디 오브젝트 기준으로 **드로우콜 1/6 감소**

메시를 합치는 코드는 GPT를 활용해 빠르게 작성했다.

예전처럼 에셋스토어나 GitHub를 뒤질 필요가 없어 생산성이 크게 올라간 느낌이었다.

### 결과

```javascript
SetPass Calls: 2.0k
Draw Calls: 2.1k
:
Shadow Casters: 796
```

### 개선 효과

- **Draw Calls**: 5.2k → 2.1k

- **Shadow Casters**: 1802 → 796

 이 단계만으로도 체감 성능이 크게 좋아졌다.

---

## 그림자 처리 최적화

그림자는 **생각보다 훨씬 비싼 연산**이다. URP 설정을 중심으로 정리했다.

### 주요 포인트

- **Cascading Shadow 수는 최소화**

    - 가능하면 1~2개

- **Shadow Max Distance는 작을수록 좋음**

- **카메라 밖 오브젝트도 그림자를 그리는 문제**

    - 청크 단위로 inactive

    - 또는 Renderer.enabled = false 처리

---

## 청크(Chunk) 시스템 적용

자체 제작한 **Voxel 스타일 타일 시스템**은 처음부터 청크 개념을 염두에 두고 설계했지만,

실제로 on/off 하는 로직은 사용하지 않고 있었다.

### 개선 내용

- 카메라 위치 기준으로 **청크 활성 / 비활성** 처리

- 월드 오브젝트도 청크 시스템에 포함

### Active 방식별 특징

| 방식 | 장점 | 단점 |
| --- | --- | --- |
| GameObject Inactive | 스크립트도 멈춤 → 성능 최적 | GC 발생 가능 재활성 시 스파이크 가능 |
| Renderer.enabled | 렌더링 부하 감소 | 스크립트는 계속 실행됨  CPU 부하 여전 |

---

## 배칭 vs 인스턴싱 개념 정리

둘 다 **CPU 부담을 줄이는 목적**이지만, 접근 방식은 완전히 다르다.

| 구분 | 배칭 (Batching) | 인스턴싱 (Instancing) |
| --- | --- | --- |
| 핵심 | 여러 메쉬를 하나로 묶음 | 같은 메쉬를 여러 번 그림 |
| 처리 위치 | CPU | GPU |
| 메쉬 | 서로 달라도 가능 | **완전히 동일해야 함** |
| 머티리얼 | 같아야 함 | 같아야 함 |
| 오브젝트별 데이터 | 거의 불가 | float / color 등 가능 |
| 텍스처 변경 | ❌ | ❌ |
| 대표 예 | SRP Batcher | GPU Instancing |

이 프로젝트는 **같은 오브젝트를 대량으로 배치**하는 구조(잔디 등)라
**GPU Instancing이 핵심 목표**였다.

---

## 인스턴싱이 깨졌던 이유 (프로젝트 기준)

### 1) Material 직접 접근

- Material.SetXXX 호출 시 **즉시 인스턴싱 깨짐**

- 반드시 **MaterialPropertyBlock(MPB)** 사용 필요

### 2) MPB + SetTexture 문제

- MPB를 써도 **SetTexture 호출 시 인스턴싱이 깨짐**

- 같은 텍스처를 넣어도 깨진다

- 기존엔 텍스처 단위로 Material 만든게 아니라, 템플릿 Material를 하나만 두고 Mpb에서 SetTexture 하는 구조

### 해결 방법

- 방법1: Material 분리 (수백개 이상의 Material을 만드는 노가다 필요)

- 방법2: 텍스처별 Material 캐싱 (수백개의 머트리얼을 안만들고 기존 구조 유지)

```javascript
public Material ReadyMaterial(Material template, Sprite sprite) {
    // 생략

    // 해시키 생성
    var key = template.GetHashCode();
    if (sprite != null) {
        key ^= sprite.texture.GetHashCode() << 2;
    }
    
    // 캐싱된 머트리얼 반납
    if (materials.TryGetValue(key, out var data)) {
        data.referenceCount++;
        return data.material;
    }

    // 텍스처별 매터리얼 생성
    var mat = new Material(template);

    if (sprite != null) {
	    // 메인 텍스처 세팅
        mat.SetTexture(MainTex, sprite.texture);

	    // 세컨더리 텍스처 세팅
        var secondaryTextureCount = sprite.GetSecondaryTextureCount();
        if (secondaryTextureCount > 0) {
            // ..
            foreach (SecondarySpriteTexture second in secondaryTextureBuffer) {
                // ..
                mat.SetTexture(pid, second.texture);
            }
        }
    }

	// 텍스처 캐싱
    materials.Add(key, new MaterialData {
        referenceCount = 1,
        material = mat,
    });

    return mat;
}
```

---

### 3) Mesh 객체 공유 문제

- 인스턴싱은 **"값이 같은 Mesh"가 아니라 "같은 Mesh 객체"** 여야 한다

- `[meshFilter.sharedMesh](https://meshFilter.sharedMesh)`에 값만 같은 Mesh를 넣어도 인스턴싱 실패

### 4) 자체 Sprite System 구조적 문제

- 스프라이트 시트 애니메이션을 위해 **Mesh UV를 직접 수정**

- 이 방식은 Mesh 공유가 불가능 → 인스턴싱 불가

### 대안 검토

- Shader에 UV 프로퍼티로 전달 방식

- 하지만 모든 쉐이더 수정은 비용 과다 → 보류

다행히 인스턴싱이 필요한 오브젝트 대부분은 **스프라이트 애니메이션이 없는 잔디류**였기 때문에,

해당 오브젝트에 한해서

**Mesh 공유 방식으로 구조 수정**

(총 20여 종의 자체제작 Custom Sprite Renderer 수정)

---

## 7. 최종 결과

```javascript
SetPass Calls: 135
Draw Calls: 409
Batches: 409
Triangles: 28.6k
Vertices: 40.8k

(Instancing)
Batched Draw Calls: 1.5k
Batches: 307
Triangles: 23.2k
Vertices: 29.8k

Shadow Casters: 676
```

### 최종 성능 개선 요약

- **Draw Calls**: 5.2k → 409 

- **Instancing**: 0 → 1.5k 

- **Shadow Casters**: 1802 → 676 

---

## 8. 기타 전역 성능 최적화

### URP Render Scale

- 픽셀 게임 특성상 약간 낮춰도 위화감 적음

- `renderScale = 0.5`

    - 렌더 타겟 크기 = 1/4

    - 성능 개선 효과 매우 큼

- 단, **앞선 최적화가 없으면 퀄리티만 떨어질 뿐 프레임은 그대로**

### Point Light Shadow

- 모바일에서 특히 비용이 큼

- 옵션으로 켜고 끌 수 있게 설정 제공

---

## 마무리하며

이번 작업을 통해 **PC 환경이라고 해서 성능을 안일하게 생각하면 안 된다**는 걸 다시 느꼈다.

에디터에서 이미 부담이 되는 구조는, 모바일에서는 더 큰 문제가 된다.

또한 **Frame Debugger와 Profiler는 필수 도구**라는 걸 체감했다.

막연한 감이 아니라 수치를 통해 문제 지점을 명확히 확인할 수 있었고, 인스턴싱이 왜 깨지는지 까지 알려준다.

덕분에 어디부터 손대야 할지 판단하기 쉬웠다.

마지막으로 **ChatGPT의 도움으로 분석 방향을 잡는 데 많은 도움이 됐다. **프로파일러 결과를 기반으로 핵심 병목을 빠르게 짚을 수 있었고, 시행착오를 줄이며 효율적으로 최적화를 진행할 수 있었다.

For the site tree, see the [root Markdown](https://slashpage.com/concode.md).
