22. Unity URP Volume·Profile로 포스트 프로세싱 파이프라인 구성 원리
URP에서 Volume과 Profile이 카메라 렌더링 단계에 어떻게 합성되는지, C# 래퍼와 네이티브 처리 흐름·PlayerLoop 시점·성능/GC 포인트까지 연결해 설명한다.','primaryKeywords':['Unity URP','Volume Profile','Post Processing','URP Renderer
Unity URP Volume·Profile로 포스트 프로세싱 파이프라인 구성 원리
게임 만들다 겪는 사고로 가장 흔한 게 “씬에서는 색감이 맞는데 빌드하면 화면이 다르게 나온다”이다. URP에서 포스트 프로세싱은 카메라에 붙는 효과가 아니라, Volume과 Profile이 카메라 렌더링 파이프라인에 끼어드는 구조라서 설정 한 군데만 어긋나도 결과가 갈린다. 문제는 체크박스 몇 개로 끝나지 않는다. 엔진이 프레임마다 어떤 데이터를 합성하고, 어디서 캐시하고, 어떤 시점에 렌더패스가 실행되는지까지 이해해야 재현 가능한 파이프라인이 된다. (요청한 JSON 스키마가 서로 달라 sections 기반/blocks 기반 중 하나를 선택해야 했고, 필수 H2/FAQ/체크리스트 요구사항을 만족시키기 쉬운 blocks 기반으로 구성했다.)
핵심 개념
URP의 포스트 프로세싱은 ‘카메라가 화면을 그린 뒤에 덧칠하는 기능’처럼 보이지만, 실제로는 ScriptableRenderer가 렌더 타깃을 구성하는 과정에 Volume 결과(Volume Stack)가 주입되는 구조이다. 그래서 Volume을 켜도 Renderer가 Post-processing 패스를 안 돌리면 아무 변화가 없다. 반대로 Renderer에서 패스를 돌려도 Volume Stack이 비어 있으면 기본값만 나온다.
Volume은 “어떤 공간에서 어떤 효과를 적용할지”를 정의하는 컨테이너이고, Profile은 “효과 파라미터 묶음(override 목록)”이다. Volume 컴포넌트는 씬 오브젝트에 붙는 MonoBehaviour지만, 실제 합성은 렌더링 직전 카메라 단위로 수행된다. 이때 카메라는 자신이 속한 레이어 마스크와 트리거(보통 카메라 Transform)를 기준으로 볼륨을 수집한다.
핵심 용어를 실제 사용 맥락으로 정의한다. 1) Volume Profile: Bloom/Color Adjustments 같은 VolumeComponent(override)들의 직렬화된 묶음이다. 에셋으로 저장하면 여러 씬/프리팹에서 재사용된다. 2) Volume Override: Profile 안에 들어있는 개별 효과 컴포넌트이다. active가 꺼져 있으면 합성 대상에서 빠진다. 3) Global Volume: 공간 제약 없이 항상 후보가 되는 볼륨이다. UI/메뉴 씬처럼 카메라가 이동하지 않는 화면에서 자주 쓴다. 4) Local Volume: Collider(대개 Box Collider)로 영향 범위를 제한한다. 트리거가 범위에 들어가면 weight가 1로 섞인다. 5) Volume Stack: 프레임마다 카메라별로 계산된 ‘최종 파라미터 스냅샷’이다. URP 렌더패스는 이 Stack을 읽어서 셰이더 상수/키워드를 세팅한다.
왜 Volume/Profile로 나뉘었나에 대한 설계 의도가 보인다. Profile은 데이터(에셋)이고 Volume은 씬에 배치되는 규칙(공간/우선순위/가중치)이다. 데이터와 규칙을 분리하면, 아트팀이 Profile만 교체해도 씬 배치(Volume) 구조는 유지된다. 런타임에서도 Profile을 복제해 일시적으로 바꾸거나(피격 시 채도 낮추기) 특정 카메라만 다른 Stack을 쓰는 식의 확장이 가능하다.
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class BasicVolumeBootstrap : MonoBehaviour
6{
7 [Header("Assign in Inspector")]
8 public Volume volume;
9 public VolumeProfile profile;
10
11 void Awake()
12 {
13 if (volume == null) volume = GetComponent<Volume>();
14 if (volume == null) Debug.LogError("Volume 컴포넌트가 필요하다");
15
16 volume.isGlobal = true;
17 volume.priority = 0;
18 volume.weight = 1f;
19 volume.sharedProfile = profile;
20
21 Debug.Log($"Volume set. Global={volume.isGlobal}, Profile={(volume.sharedProfile ? volume.sharedProfile.name : "null")}");
22 }
23}이 스크립트를 실행하면 Console에 “Volume set…” 로그가 찍힌다. Game 뷰가 바뀌지 않아도 정상일 수 있다. 이유는 URP Renderer에서 Post-processing이 꺼져 있거나, Profile 안에 활성화된 override가 없거나, 카메라가 해당 볼륨을 수집하지 못하는(레이어 마스크/트리거) 상태일 수 있기 때문이다. ‘Volume이 있다’와 ‘렌더패스가 그 값을 사용한다’는 별개다.
엔진 관점에서의 내부 동작
C#의 Volume/VolumeProfile/VolumeComponent는 렌더링 파이프라인에 값을 전달하기 위한 래퍼 계층이다. 실제 렌더링은 네이티브(C++) 엔진이 카메라를 돌리고, SRP(URP)가 C#에서 CommandBuffer를 쌓아 GPU에 제출하는 방식으로 진행된다. URP는 Built-in처럼 엔진 내부에 고정된 후처리 루틴이 아니라, ScriptableRenderer가 렌더패스 목록을 구성하고 실행한다.
Player Loop 관점에서 보면, 일반적인 흐름은 Update 계열에서 게임 로직이 돌고, 프레임 후반에 렌더링 준비가 끝난다. 카메라 렌더는 ‘렌더링 루프’에서 진행되며, URP는 RenderPipelineManager의 콜백을 통해 카메라별 렌더를 수행한다. 이때 Volume 합성은 “카메라 렌더를 시작할 때” 수행되는 편이다. 로직 Update에서 Volume 값을 바꾸면 같은 프레임 렌더에 반영되는 경우가 많지만, 카메라가 여러 대거나 스택 카메라(Overlay)면 카메라별 시점 차이가 생긴다.
Volume Stack이 왜 필요한지가 성능 포인트다. Volume이 씬에 수십 개 있으면, 매 패스/매 효과가 씬 오브젝트를 직접 뒤지면 비용이 폭발한다. 그래서 URP는 카메라별로 “이번 프레임 최종 파라미터”를 한 번 계산해 Stack에 저장하고, Bloom/ColorGrading 같은 패스는 Stack만 읽는다. Stack은 C# 객체지만, 내부적으로는 VolumeComponent들의 파라미터를 복사해 둔 캐시이다. 이 복사 비용이 프레임마다 생기고, 특히 override를 런타임에 Add/Remove하면 GC/리빌드 비용이 튄다.
처음에 나도 Global Volume 하나만 켜면 모든 카메라에 동일하게 먹는 줄 알았다. 실제 프로젝트에서 미니맵 카메라까지 색보정이 적용돼서 “미니맵이 밤처럼 어두워짐” 버그가 났다. 디버깅은 간단했다. 미니맵 카메라의 Additional Camera Data에서 Volume Layer Mask가 Default로 되어 있었고, Global Volume도 Default 레이어라서 수집 대상이 된 것이다. 카메라별로 어떤 볼륨을 수집할지 분리하는 설계가 URP의 기본 전제다.
C# → 네이티브 경계에서 자주 오해하는 지점이 있다. Volume 컴포넌트의 필드(예: weight)를 바꾸는 건 C# 오브젝트의 상태 변경이지만, 그 값이 GPU에 반영되려면 URP 렌더패스가 Stack을 읽고 CommandBuffer에 상수/텍스처 바인딩을 기록해야 한다. 기록된 커맨드는 네이티브에서 그래픽 API(D3D/Vulkan/Metal)로 제출된다. 그래서 “Inspector에서 값 바꿨는데 Game 뷰가 즉시 안 변함” 같은 현상은 대개 (1) 패스가 비활성, (2) 카메라가 해당 볼륨을 수집하지 않음, (3) Scene 뷰와 Game 뷰 카메라가 서로 다른 Stack을 쓰는 경우다.
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class PlayerLoopProbe : MonoBehaviour
5{
6 void Awake() => Debug.Log("Awake");
7 void OnEnable() => Debug.Log("OnEnable");
8 void Start() => Debug.Log("Start");
9 void FixedUpdate() => Debug.Log("FixedUpdate");
10 void Update() => Debug.Log("Update");
11 void LateUpdate() => Debug.Log("LateUpdate");
12 void OnPreCull() => Debug.Log("OnPreCull (Camera cull 직전)");
13 void OnPreRender() => Debug.Log("OnPreRender (카메라 렌더 직전)");
14 void OnPostRender() => Debug.Log("OnPostRender (카메라 렌더 직후)");
15}이 로그를 보면 Update/LateUpdate 이후에 카메라 이벤트가 찍힌다. URP의 Volume 합성은 카메라 렌더 직전/렌더 준비 단계에 붙어 있는 편이라, Update에서 Profile 값을 바꾸면 같은 프레임의 OnPreRender 이후 렌더에서 반영되는 흐름이 자연스럽다. 다만 스택 카메라(Overlay)는 Base 카메라 렌더 중간에 Overlay가 끼므로, Overlay 카메라의 Volume 수집 타이밍이 다르게 느껴질 수 있다.
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class VolumeStackRuntimeRead : MonoBehaviour
6{
7 public Camera targetCamera;
8
9 void Update()
10 {
11 if (targetCamera == null) return;
12
13 var add = targetCamera.GetComponent<UniversalAdditionalCameraData>();
14 if (add == null) return;
15
16 // URP가 카메라 렌더 직전에 갱신하는 값이라, 프레임에 따라 null/이전 값처럼 보일 수 있다.
17 var stack = add.volumeStack;
18 if (stack == null) { Debug.Log("volumeStack is null (아직 생성/갱신 전일 수 있다)"); return; }
19
20 var ca = stack.GetComponent<ColorAdjustments>();
21 if (ca != null)
22 {
23 Debug.Log($"ColorAdjustments: active={ca.active}, postExposure={ca.postExposure.value}, saturation={ca.saturation.value}");
24 }
25 }
26}이 스크립트는 프레임마다 Console에 ColorAdjustments 값을 찍는다. 특정 프레임에서 stack이 null이거나 값이 늦게 바뀌는 것처럼 보일 수 있다. 이유는 volumeStack이 ‘카메라 렌더 시점에’ 준비되는 캐시이기 때문이다. Update에서 읽는 건 ‘지난 렌더에서 계산된 스냅샷’일 가능성이 높다. 런타임 로직이 Volume 값을 기준으로 분기해야 한다면, Update에서 Stack을 읽는 설계 자체를 재검토해야 한다.
한 문단 요약: URP 포스트 프로세싱은 Volume(Profile 데이터)을 카메라 렌더 직전 Volume Stack으로 합성하고, ScriptableRenderer의 렌더패스가 그 Stack을 읽어 CommandBuffer를 기록해 GPU에 제출하는 구조이다. Volume만 켜거나 Profile만 만들어서는 화면이 바뀌지 않는 경우가 많다.
실습하기
1단계: 프로젝트/URP 설정
Unity 2022.3 LTS 또는 2023.2+에서 시작하는 편이 안전하다. 프로젝트 생성은 Unity Hub → New project → (Template) Universal 3D를 선택한다. 이미 Built-in 프로젝트라면 Package Manager에서 Universal RP를 설치하고, URP Pipeline Asset을 만든 뒤 Graphics 설정에 연결해야 한다.
클릭 경로: Edit → Project Settings → Graphics → Scriptable Render Pipeline Settings에 URP Pipeline Asset을 드래그한다. 이어서 Edit → Project Settings → Quality에서도 각 품질 레벨의 Render Pipeline Asset이 비어 있지 않은지 확인한다. 여기서 하나라도 비어 있으면 에디터에서는 보이는데 빌드에서만 후처리가 빠지는 케이스가 나온다.
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class URPAssetSanityCheck : MonoBehaviour
6{
7 void Start()
8 {
9 var rp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset;
10 Debug.Log(rp != null ? $"URP Asset: {rp.name}" : "URP Asset is null (Graphics/Quality 설정 확인 필요)");
11
12 if (rp != null)
13 {
14 Debug.Log($"Supports HDR: {rp.supportsHDR}, MSAA: {rp.msaaSampleCount}");
15 }
16 }
17}Play를 누르면 Console에 URP Asset 이름이 찍힌다. null이면 URP가 실제로 구동되지 않는 상태다. 이 상태에서 Volume을 아무리 만져도 URP 포스트 프로세싱이 돌지 않는다. 엔진 관점에서는 “SRP 렌더러가 카메라 렌더 루프를 소유하지 못한 상태”라서 Volume Stack 합성 자체가 의미가 없다.
2단계: 씬 구성(Volume, Profile, 카메라)
Hierarchy 우클릭 → Volume → Global Volume을 만든다. Inspector에서 Profile 옆 New를 눌러 Volume Profile 에셋을 생성한다. Add Override → Post-processing → Bloom, Color Adjustments를 추가한다. Bloom의 Intensity를 1~3, Threshold를 1 전후로 두고, Color Adjustments의 Saturation을 -50 같은 극단값으로 바꾸면 화면 변화가 바로 눈에 띈다.
카메라 설정이 핵심이다. Main Camera 선택 → Inspector에서 Universal Additional Camera Data 섹션을 찾는다(없으면 카메라에 자동으로 붙는다). Rendering → Post Processing 체크가 켜져 있어야 한다. 또한 Volume Layer Mask가 Global Volume 오브젝트가 속한 레이어를 포함해야 한다. Global Volume을 Default 레이어로 두면 마스크도 Default를 포함해야 한다.
1using UnityEngine;
2using UnityEngine.Rendering.Universal;
3
4public class CameraPostProcessToggle : MonoBehaviour
5{
6 public Camera target;
7
8 void Awake()
9 {
10 if (target == null) target = Camera.main;
11 var add = target != null ? target.GetComponent<UniversalAdditionalCameraData>() : null;
12 if (add == null)
13 {
14 Debug.LogError("UniversalAdditionalCameraData가 없다. URP 카메라인지 확인 필요");
15 return;
16 }
17
18 add.renderPostProcessing = true;
19 Debug.Log($"Camera '{target.name}' renderPostProcessing={add.renderPostProcessing}");
20 }
21}이 스크립트를 붙이고 Play하면 Console에 renderPostProcessing=true가 찍힌다. Game 뷰에서 채도/블룸이 바뀌어야 한다. 변화가 없으면 Renderer Data에서 Post-processing이 꺼져 있거나(특히 커스텀 Renderer), 카메라가 Overlay라서 Base 카메라 설정에 종속되는 경우를 의심한다.
3단계: 런타임에서 Profile 값 바꾸고 확인
런타임 연출에서 많이 하는 게 ‘피격 시 화면을 흑백에 가깝게’ 같은 처리다. 여기서 sharedProfile을 직접 수정하면 프로젝트 에셋이 더럽혀진다(에디터에서 Play 종료 후에도 값이 남는 것처럼 보이는 문제가 생김). 런타임 전용 인스턴스를 만들고 그걸 Volume.profile에 넣는 방식이 안전하다.
Hierarchy에서 Global Volume 선택 후, Inspector에서 Volume 컴포넌트의 Profile 필드가 비어 있지 않은지 확인한다. 스크립트가 profile을 복제하는 경우, Play 중에만 profile 인스턴스가 들어가므로 Play 중 Inspector에서 Profile 참조가 바뀌는지 눈으로 확인 가능하다.
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class RuntimeProfileAnimator : MonoBehaviour
6{
7 public Volume volume;
8 public AnimationCurve hitCurve = AnimationCurve.EaseInOut(0, 0, 0.25f, 1);
9
10 VolumeProfile runtimeProfile;
11 ColorAdjustments color;
12 float t;
13 bool playing;
14
15 void Awake()
16 {
17 if (volume == null) volume = GetComponent<Volume>();
18 if (volume == null || volume.sharedProfile == null)
19 {
20 Debug.LogError("Volume과 sharedProfile이 필요하다");
21 return;
22 }
23
24 runtimeProfile = Instantiate(volume.sharedProfile);
25 volume.profile = runtimeProfile; // 런타임 인스턴스
26
27 if (!runtimeProfile.TryGet(out color))
28 color = runtimeProfile.Add<ColorAdjustments>(true);
29
30 color.active = true;
31 Debug.Log($"Runtime profile created: {runtimeProfile.name}");
32 }
33
34 void Update()
35 {
36 if (Input.GetKeyDown(KeyCode.H)) { t = 0; playing = true; }
37 if (!playing) return;
38
39 t += Time.deltaTime;
40 float w = hitCurve.Evaluate(t);
41 color.saturation.value = Mathf.Lerp(0, -80, w);
42
43 if (t >= hitCurve.keys[^1].time) playing = false;
44 }
45}Play 후 H 키를 누르면 Game 뷰가 순간적으로 채도가 빠졌다가 돌아온다. Console에는 Runtime profile created 로그가 찍힌다. 이 동작이 “왜” 가능한지까지 연결하면, URP는 카메라 렌더 직전에 Volume Stack을 다시 합성하고, 그 Stack에서 ColorAdjustments.saturation을 읽어 컬러 그레이딩 패스의 상수로 넣기 때문이다. 반대로 sharedProfile을 직접 수정하면 에셋 직렬화 상태와 런타임 상태가 섞여서, 팀 작업에서 ‘누가 내 프로파일을 바꿨냐’ 같은 사고로 이어진다.
심화 활용
패턴 1: 카메라별 Volume Layer Mask로 UI/미니맵 후처리 분리
실무에서 가장 많이 쓰는 패턴은 ‘게임 월드 카메라’와 ‘UI/미니맵 카메라’를 분리하고, 각 카메라가 수집하는 Volume 레이어를 분리하는 것이다. 월드는 Color Grading, Bloom을 쓰고, UI는 후처리를 꺼서 텍스트/아이콘이 번지지 않게 한다.
1using UnityEngine;
2using UnityEngine.Rendering.Universal;
3
4public class CameraVolumeMaskSetup : MonoBehaviour
5{
6 public Camera worldCamera;
7 public Camera uiCamera;
8 public LayerMask worldVolumeMask;
9 public LayerMask uiVolumeMask;
10
11 void Awake()
12 {
13 Apply(worldCamera, true, worldVolumeMask);
14 Apply(uiCamera, false, uiVolumeMask);
15 }
16
17 void Apply(Camera cam, bool post, LayerMask mask)
18 {
19 if (cam == null) return;
20 var add = cam.GetComponent<UniversalAdditionalCameraData>();
21 if (add == null) return;
22
23 add.renderPostProcessing = post;
24 add.volumeLayerMask = mask;
25 Debug.Log($"{cam.name}: post={post}, mask={mask.value}");
26 }
27}이 코드를 실행하면 Console에 각 카메라 마스크 값이 찍힌다. UI 카메라에서 post=false면 UI는 후처리 패스를 타지 않는다. 엔진 관점에서 비용도 줄어든다. UI 카메라가 별도로 렌더 타깃을 만들고 포스트 패스를 돌리면, 해상도에 따라 1~3ms가 추가로 튈 수 있다(특히 모바일). 카메라를 늘릴수록 ‘카메라마다 Volume Stack 합성 + 포스트 패스’ 비용이 반복된다.
패턴 2: Local Volume로 구역별 룩 전환 + 우선순위/블렌딩
Local Volume은 던전 입구/보스방처럼 구역별 룩을 바꾸는 데 쓴다. Global Volume은 기본 룩, Local Volume은 특수 룩을 담당한다. priority가 높은 볼륨이 먼저 적용되고, weight와 블렌딩 거리(Blend Distance)가 섞임을 만든다.
1using UnityEngine;
2using UnityEngine.Rendering;
3
4[RequireComponent(typeof(Volume))]
5[RequireComponent(typeof(BoxCollider))]
6public class LocalVolumeSetup : MonoBehaviour
7{
8 public VolumeProfile profile;
9
10 void Reset()
11 {
12 var v = GetComponent<Volume>();
13 v.isGlobal = false;
14 v.priority = 10;
15 v.weight = 1f;
16 v.blendDistance = 3f;
17 v.sharedProfile = profile;
18
19 var c = GetComponent<BoxCollider>();
20 c.isTrigger = true;
21 c.size = new Vector3(10, 5, 10);
22 }
23}Reset은 컴포넌트를 붙이는 순간(또는 Inspector 우측 톱니 → Reset) 실행된다. Hierarchy 우클릭 → 3D Object → Cube로 큐브를 만들고, Cube에 이 스크립트를 붙인 뒤 BoxCollider를 Trigger로 유지하면, 카메라(트리거)가 큐브 범위에 들어갈 때 룩이 섞인다. Game 뷰에서 경계 근처를 움직이면 블렌딩 거리만큼 서서히 변화한다.
3시간 삽질 끝에 알아낸 건 blendDistance가 0인데도 ‘서서히’ 변하는 것처럼 보이는 케이스가 있다는 점이었다. 원인은 Local Volume이 아니라, 같은 레이어에 다른 Volume이 하나 더 있었고 priority가 같아서 weight가 프레임마다 미세하게 섞였다. Profiler에서 RenderThread가 아니라 MainThread의 Volume.Update(또는 URP 내부 VolumeSystem 업데이트) 쪽이 흔들리는 걸 보고 볼륨 수집을 의심했다. 해결은 레이어를 분리하고 priority를 명확히 나누는 방식이었다.
내 흑역사 하나 더 있다. 런타임에서 runtimeProfile = Instantiate(sharedProfile)을 매 타격마다 만들었다. 모바일에서 GC Alloc이 프레임마다 수십 KB씩 튀고, 2~3분 플레이 후에 스파이크로 프레임이 20fps까지 떨어졌다. 원인은 Profile 인스턴스가 ScriptableObject라서 생성 시 내부 override 리스트와 파라미터가 복제되고, 그 객체가 누적되면서 메모리/GC 부담이 커진 것이다. 해결은 ‘한 번만 인스턴스 생성 후 파라미터만 수정’으로 바꿨다.
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class ProfileInstancePool : MonoBehaviour
6{
7 public Volume volume;
8
9 VolumeProfile runtimeProfile;
10 Vignette vignette;
11
12 void Awake()
13 {
14 if (volume == null) volume = GetComponent<Volume>();
15 runtimeProfile = volume.profile != null ? volume.profile : Instantiate(volume.sharedProfile);
16 volume.profile = runtimeProfile;
17
18 if (!runtimeProfile.TryGet(out vignette)) vignette = runtimeProfile.Add<Vignette>(true);
19 vignette.active = true;
20 }
21
22 void Update()
23 {
24 // Space를 누르는 동안만 비네팅을 강하게
25 float target = Input.GetKey(KeyCode.Space) ? 0.45f : 0.15f;
26 vignette.intensity.value = Mathf.MoveTowards(vignette.intensity.value, target, Time.deltaTime * 1.5f);
27 }
28}이 방식은 ScriptableObject 생성이 Awake에서 한 번만 일어난다. 이후에는 float 값만 바뀐다. Profiler에서 GC Alloc이 0B에 가까워진다(다른 시스템이 할당하는 건 별개). URP는 렌더 직전에 Stack을 갱신할 때 이 파라미터를 읽어 셰이더 상수로 넣기 때문에, 값 변경 자체는 가볍고 ‘합성 비용’만 남는다.
자주 하는 실수
1) Volume을 만들었는데 화면이 그대로다
증상: Global Volume과 Bloom을 추가했는데 Game 뷰가 전혀 바뀌지 않는다. Console 에러도 없다.
원인: 카메라의 Universal Additional Camera Data에서 Post Processing이 꺼져 있거나, Renderer Data에서 포스트 패스가 비활성인 경우가 많다. URP는 ‘카메라 단위 스위치’와 ‘렌더러 단위 지원’이 둘 다 맞아야 패스가 돈다.
해결: Main Camera 선택 → Inspector → Rendering → Post Processing 체크를 켠다. 그리고 Project 창에서 사용 중인 Renderer Data(Forward Renderer 등)를 열어 Post-processing 관련 옵션/Feature 구성을 확인한다. 빌드에서만 문제면 Edit → Project Settings → Quality의 Render Pipeline Asset도 확인한다.
2) Scene 뷰에서는 보이는데 Game 뷰에서는 안 보인다
증상: Scene 뷰에서는 색보정이 적용된 것처럼 보이는데, Play하면 Game 뷰는 원래 색이다.
원인: Scene 뷰 카메라와 Game 뷰 카메라는 서로 다른 카메라다. Scene 뷰는 에디터 카메라라서 Volume 트리거/레이어 마스크 조건이 다르게 평가된다. 또 Scene 뷰의 ‘Post Processing’ 토글이 별도로 존재한다.
해결: Game 뷰 기준으로 확인한다. Main Camera의 Volume Layer Mask가 올바른지, Global Volume 오브젝트 레이어가 마스크에 포함되는지 본다. Editor 상단 Scene 뷰 툴바의 Post Processing 토글에 속지 않게 기준을 통일한다.
3) Local Volume에 들어가도 효과가 적용되지 않는다
증상: Box Collider로 Local Volume을 만들었는데, 카메라가 안에 들어가도 변화가 없다.
원인: Collider가 Trigger가 아니거나, Volume의 Blend Distance/Weight가 0이거나, 카메라 트리거가 해당 볼륨을 평가하지 않는 경우다. 특히 카메라의 Volume Trigger가 Player Transform으로 설정돼 있으면 카메라가 들어가도 플레이어가 안 들어간 상태가 된다.
해결: Local Volume 오브젝트 선택 → BoxCollider의 Is Trigger 체크를 켠다. Volume 컴포넌트에서 Weight=1, Blend Distance를 0~3으로 둔다. Main Camera의 Universal Additional Camera Data에서 Volume Trigger가 무엇인지 확인하고, 필요하면 카메라 Transform으로 맞춘다.
4) 런타임에서 Profile 값을 바꿨더니 에디터 에셋이 오염된다
증상: Play 중에 saturation을 바꿨는데, Play 종료 후에도 프로파일 값이 바뀐 것처럼 남아 있다. 팀원이 같은 에셋을 쓰고 있으면 더 혼란스럽다.
원인: sharedProfile(프로젝트 에셋)을 직접 수정했다. ScriptableObject는 에디터에서 참조가 공유되기 때문에, 런타임 변경이 에디터 상태와 섞여 보인다.
해결: Instantiate(sharedProfile)로 런타임 인스턴스를 만들고 volume.profile에 넣는다. 그리고 런타임에는 인스턴스를 재생성하지 말고 파라미터만 수정한다.
5) 카메라가 두 대 이상일 때 포스트 프로세싱이 중복 적용되거나 비용이 폭증한다
증상: 미니맵/리플렉션 카메라를 추가했더니 화면이 과하게 뿌옇거나, 모바일에서 프레임이 급락한다.
원인: 각 카메라가 Post Processing을 켠 채로 같은 Global Volume을 수집한다. URP는 카메라마다 렌더 타깃과 포스트 패스를 돌릴 수 있고, 해상도/카메라 수에 비례해 비용이 증가한다.
해결: 미니맵/리플렉션 카메라는 renderPostProcessing=false로 끄거나, volumeLayerMask를 별도 레이어로 분리한다. 필요하면 렌더 텍스처 해상도를 낮춘다.
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class ProfileNullGuard : MonoBehaviour
5{
6 public Volume volume;
7
8 void Start()
9 {
10 if (volume == null) volume = GetComponent<Volume>();
11 if (volume == null)
12 {
13 Debug.LogError("Missing Volume component");
14 return;
15 }
16
17 if (volume.sharedProfile == null && volume.profile == null)
18 {
19 Debug.LogError("Volume has no Profile. Inspector에서 Profile을 생성/할당해야 한다");
20 }
21 }
22}실수 섹션에서 이 가드가 유용한 이유는, Profile이 비어 있으면 에러 없이 ‘아무 효과도 없는 정상 렌더’처럼 보이기 때문이다. 신입이 가장 오래 붙잡는 유형이라서, 씬 로딩 시점에 명시적으로 터뜨리는 편이 디버깅 시간을 줄인다.
성능 최적화 체크리스트
- Main Camera의 Universal Additional Camera Data에서 Rendering → Post Processing 체크가 켜져 있다
- 카메라의 Volume Layer Mask가 Global/Local Volume 오브젝트 레이어를 포함한다
- Local Volume을 쓸 때 Volume Trigger가 카메라 Transform인지, 플레이어 Transform인지 의도대로 설정했다
- Global Volume과 Local Volume의 priority를 명확히 나눴고, 같은 priority 다중 볼륨을 남발하지 않았다
- 런타임 연출은 sharedProfile을 직접 수정하지 않고 Instantiate한 runtime profile 인스턴스를 사용한다
- Profile 인스턴스 생성(Instantiate)을 이벤트마다 반복하지 않고, 씬/카메라당 1회로 제한했다
- Override 추가/삭제(Add/Remove)는 런타임 빈번 호출을 피하고, 파라미터 값만 변경한다
- 카메라가 여러 대면(미니맵/리플렉션/캡처) post-processing을 꺼야 하는 카메라를 구분했다
- Profiler에서 CPU(MainThread)와 GPU 시간을 분리해 확인했고, URP RenderPass 비용이 16.6ms(60fps) 예산을 넘지 않는다
- Profiler에서 GC Alloc 컬럼을 켜고, 타격/대시 같은 반복 이벤트에서 0B에 가깝게 유지했다
- Bloom/DoF 같은 고비용 효과는 Render Scale, 해상도, 카메라 타깃 크기에 비례해 비용이 커진다는 전제를 반영했다
- Quality 레벨별 Render Pipeline Asset이 모두 설정돼 있고, 빌드 타깃 품질에서 누락이 없다
자주 묻는 질문
Volume을 켰는데도 아무 변화가 없을 때 가장 먼저 어디를 봐야 하나?
카메라와 렌더러 두 군데를 동시에 본다. 1) Main Camera의 Universal Additional Camera Data에서 Rendering → Post Processing이 꺼져 있으면 URP는 포스트 패스를 실행하지 않는다. 2) URP Renderer(Forward Renderer 등)가 해당 패스를 구성하지 않으면 Volume Stack을 계산해도 읽는 쪽이 없다. 그 다음은 Volume Layer Mask와 Volume 오브젝트 레이어 매칭이다. 이 순서가 중요한 이유는, Volume(Profile)은 데이터일 뿐이고 실제 적용은 ScriptableRenderer의 렌더패스 실행 시점에만 일어나기 때문이다. 다음 학습 키워드는 UniversalAdditionalCameraData, ScriptableRenderer, RenderPassEvent이다.
Global Volume과 Local Volume을 섞을 때 priority와 weight는 엔진에서 어떻게 합성되나?
URP는 카메라 렌더 직전 후보 볼륨을 수집하고, priority 순으로 정렬한 뒤 각 VolumeComponent의 파라미터를 weight로 블렌딩해 최종 Volume Stack을 만든다. Global은 항상 후보이고, Local은 트리거가 콜라이더 내부/근처일 때만 후보가 된다. weight=0이면 후보여도 영향이 없다. priority가 같으면 정렬 안정성이나 수집 순서에 의해 미세한 흔들림처럼 보일 수 있어, 실무에서는 priority를 계단식(0,10,20)으로 나누는 편이 안전하다. 다음 학습 키워드는 VolumeManager, VolumeStack, blendDistance이다.
런타임에서 sharedProfile을 수정하면 왜 위험한가? Play를 끄면 원래대로 돌아오지 않나?
sharedProfile은 프로젝트 에셋(ScriptableObject)이라 여러 Volume이 같은 인스턴스를 공유할 수 있다. 에디터는 Play 모드에서의 변경을 완전히 격리해 주지 않고, Inspector에 보이는 값이 ‘변경된 것처럼’ 남거나 Undo/Redo와 섞여 혼란을 만든다. 특히 팀 작업에서는 같은 에셋을 다른 씬/프리팹이 참조하므로, 한 곳의 런타임 변경이 다른 카메라/씬 디버깅을 망친다. 안전한 패턴은 Instantiate(sharedProfile)로 런타임 인스턴스를 만들고 volume.profile에 할당한 뒤, 그 인스턴스의 파라미터만 바꾸는 방식이다. 다음 학습 키워드는 ScriptableObject lifecycle, serialization, domain reload이다.
Volume Stack을 Update에서 읽어서 게임 로직에 쓰면 왜 값이 늦거나 null처럼 보이나?
Volume Stack은 ‘렌더 직전’에 카메라별로 계산되는 스냅샷 캐시다. Update는 렌더 준비보다 앞에서 실행되는 경우가 많아서, Update에서 읽는 stack은 지난 프레임 렌더의 결과이거나 아직 생성되지 않은 상태일 수 있다(특히 카메라가 비활성→활성 전환, 씬 로딩 직후). 엔진 관점에서 보면 Stack은 렌더 파이프라인의 입력값이지, 로직 시스템의 authoritative state가 아니다. 로직이 필요로 하는 상태는 별도의 데이터 모델로 유지하고, Volume은 그 모델을 시각화하는 출력으로 취급하는 편이 버그가 적다. 다음 학습 키워드는 PlayerLoop order, RenderPipelineManager callbacks, frame latency이다.
카메라가 여러 대일 때 포스트 프로세싱 비용이 왜 급격히 늘어나나?
URP는 카메라마다 컬링, 렌더 타깃 설정, 렌더패스 실행을 반복한다. 포스트 프로세싱은 보통 ‘전체 화면 풀스크린 패스’라서 픽셀 수(해상도)에 비례해 GPU 비용이 커진다. 카메라가 2대면 같은 해상도 기준으로 후처리 패스가 2번 돌 수 있고, 미니맵이 RenderTexture로 별도 해상도라도 추가 비용이 생긴다. CPU 측에서도 카메라별 Volume Stack 합성, 패스 셋업이 반복된다. 실무 처방은 (1) 미니맵/캡처 카메라 renderPostProcessing=false, (2) volumeLayerMask 분리, (3) RenderTexture 해상도 축소, (4) 필요한 효과만 남기기다. 다음 학습 키워드는 Camera stacking, RenderTexture, URP Profiler markers이다.
Local Volume이 적용되지 않을 때 Volume Trigger 설정은 어디서 바꾸나?
Main Camera 선택 후 Inspector에서 Universal Additional Camera Data를 펼치면 Volume 섹션에 Volume Trigger가 있다. 기본은 카메라 Transform인 경우가 많지만, 프로젝트 템플릿/설정에 따라 Player Transform을 넣는 경우도 있다. Local Volume은 ‘Trigger가 콜라이더 범위에 들어가는지’로 후보 여부가 결정되므로, 카메라가 아니라 플레이어를 트리거로 쓰면 3인칭에서 카메라가 먼저 들어가도 효과가 늦게 적용되는 것처럼 보인다. 해결은 의도에 맞게 Trigger를 지정하고, 디버깅 시에는 Scene 뷰에서 트리거 오브젝트가 콜라이더 안에 들어가는지 Gizmo로 확인하는 것이다. 다음 학습 키워드는 VolumeTrigger, collider bounds, blending distance이다.
Profiler에서 포스트 프로세싱이 느릴 때 무엇을 기준으로 줄여야 하나?
기준은 ‘풀스크린 패스 수’와 ‘해상도’다. Bloom/DoF/Motion Blur처럼 여러 번 다운샘플/업샘플을 하는 효과는 패스 수가 많고, Render Scale이 1.0이라면 1080p/1440p에서 GPU 시간을 크게 먹는다. CPU는 보통 패스 셋업과 Volume 합성에서 0.x ms 단위로 보이고, GPU는 효과 종류에 따라 1~6ms까지 튈 수 있다. 처방은 (1) Render Scale 조정, (2) Bloom 품질/강도/threshold 조정, (3) 카메라 수 줄이기, (4) UI 카메라 후처리 끄기, (5) 필요 없는 override 비활성화다. 다음 학습 키워드는 Frame Debugger, Render Graph(버전에 따라), GPU Profiler이다.