유니티 입문2026년 03월 04일· 8 min read

21. Unity Post-processing Volume과 Profile, 엔진이 처리하는 방식까지 이해하기

Unity Post-processing의 Volume과 Profile이 카메라 렌더링 중 언제, 어떤 데이터로 합성되는지 C# 래퍼와 네이티브 처리 흐름까지 연결해 설명한다. 성능·GC 함정도 포함한다. 154자 내외 기준 맞춤 문장 길이 조정용 문구

21. Unity Post-processing Volume과 Profile, 엔진이 처리하는 방식까지 이해하기

Unity Post-processing Volume과 Profile, 엔진이 처리하는 방식까지 이해하기

게임 만들다 겪는 사고는 보통 “분명히 볼륨을 켰는데 화면이 안 바뀐다”에서 시작한다. 씬에 Global Volume을 넣고 Bloom 값을 올렸는데 Game 뷰는 멀쩡하고, 카메라를 움직이면 갑자기 톤이 튀거나 씬마다 효과가 섞인다. 원인은 대개 Volume과 Profile을 ‘데이터’로 보지 않고 ‘컴포넌트’로 착각해서 생긴다. 렌더 파이프라인은 프레임마다 볼륨을 수집·블렌딩하고, 그 결과를 패스에서 샘플링한다. 그 흐름을 잡으면 왜 이런 버그가 생기는지 바로 보인다.

핵심 개념

Volume은 “어디에서 어떤 후처리 값을 적용할지”를 정의하는 공간 규칙이다. Profile은 “어떤 후처리 파라미터 묶음”이라는 데이터 자산이다. 둘을 분리한 이유는 간단하다. 같은 파라미터 묶음을 여러 Volume에서 재사용하고, 런타임에 블렌딩할 때는 ‘컴포넌트’가 아니라 ‘값 집합’을 빠르게 합성해야 하기 때문이다. Volume 컴포넌트는 씬 오브젝트로 존재하지만, 실제 렌더링에서 중요한 건 매 프레임 만들어지는 VolumeStack(합성 결과)이다.

용어를 엔진 관점으로 고정한다. Volume(컴포넌트)은 수집 대상이며, Profile(ScriptableObject)은 직렬화 가능한 설정 덩어리다. Override(예: Bloom, ColorAdjustments)는 Profile 안에 들어가는 세부 설정 객체다. Weight는 이 Volume이 블렌딩에 기여하는 비율이며, Blend Distance는 카메라가 콜라이더 경계 근처에 있을 때 얼마나 부드럽게 섞을지 결정한다. Is Global은 공간 판정을 생략하고 항상 후보로 넣는 플래그다.

왜 Profile이 ScriptableObject인가가 핵심이다. ScriptableObject는 네이티브 오브젝트 핸들을 가진 에셋이며, 씬과 분리된 상태로 직렬화되고, 여러 씬/프리팹에서 참조해도 하나의 에셋을 공유한다. 그래서 “씬 A에서 Bloom 강도를 바꿨더니 씬 B도 바뀜” 같은 일이 생긴다. 공유 에셋을 수정했기 때문이다. 씬별로 다르게 쓰려면 인스턴스를 만들어야 한다.

초보가 가장 많이 헷갈리는 지점은 ‘Volume 컴포넌트가 렌더링을 직접 한다’는 착각이다. 실제 그리는 주체는 URP/HDRP의 렌더러다. Volume은 렌더러가 참조할 수 있게 데이터를 제공한다. 그래서 카메라에 Post Processing 체크가 꺼져 있거나(URP), 렌더러에 관련 패스가 없는 구성이라면 Volume이 있어도 화면은 바뀌지 않는다. Volume은 트리거가 아니라 입력 데이터다.

VolumeProfileProbe.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class VolumeProfileProbe : MonoBehaviour
6{
7    [SerializeField] private Volume volume;
8
9    private void Awake()
10    {
11        if (volume == null) volume = GetComponent<Volume>();
12        Debug.Log($"Volume isGlobal={volume.isGlobal}, weight={volume.weight}, hasProfile={(volume.profile != null)}");
13
14        if (volume.profile != null && volume.profile.TryGet(out Bloom bloom))
15        {
16            Debug.Log($"Bloom active={bloom.active}, intensity={bloom.intensity.value}, override={bloom.intensity.overrideState}");
17        }
18        else
19        {
20            Debug.Log("Bloom not found in Profile");
21        }
22    }
23}

이 스크립트를 Volume 오브젝트에 붙이고 Play를 누르면 Console에 Profile 안에 Bloom이 실제로 들어있는지, intensity가 overrideState까지 포함해 어떤 값으로 직렬화돼 있는지 찍힌다. overrideState가 false면 값이 있어도 블렌딩 대상에서 제외된다. Inspector에서 Bloom 항목의 체크박스를 켜는 행위가 overrideState를 true로 만드는 동작이라서, 체크를 안 켠 상태에서 intensity 숫자만 바꾸면 “값은 바꿨는데 적용이 안 됨” 현상이 재현된다.

엔진 관점에서의 내부 동작

Player Loop 관점에서 Volume 합성은 “카메라가 렌더링을 준비하는 시점”에 일어난다. MonoBehaviour의 Update가 끝난 뒤, 렌더 파이프라인이 ScriptableRenderContext로 카메라 렌더를 돌릴 때(URP라면 UniversalRenderPipeline.Render) 카메라별로 VolumeManager가 스택을 갱신한다. 그래서 Update에서 volume.weight를 바꾸면 같은 프레임의 렌더에 반영되는 경우가 많지만, 렌더 이벤트 순서(카메라 스택, SRP 배치)에 따라 한 프레임 늦게 보이는 상황도 나온다.

C# 래퍼 계층에서 Volume, VolumeProfile, VolumeComponent(Bloom 등)는 모두 UnityEngine.Object 기반이다. 이들은 네이티브(C++) 오브젝트에 대한 핸들(InstanceID)을 가진다. 다만 Profile 내부의 구성은 완전한 네이티브 배열이 아니라, ScriptableObject 직렬화 데이터(Managed + Native 핸들 혼합)로 저장된다. 렌더 파이프라인은 이 직렬화 데이터를 읽어 VolumeStack(런타임 캐시)으로 복사한 뒤, 패스에서 참조한다. “복사한다”가 중요한 이유는, 매 프레임 블렌딩을 위해 값들을 연속 메모리 형태로 다루는 편이 빠르기 때문이다.

VolumeManager가 하는 일은 크게 3단계다. (1) 씬에 존재하는 Volume 컴포넌트들을 후보 리스트로 관리한다. (2) 카메라 위치와 레이어 마스크를 기준으로 ‘영향권에 들어온 Volume’만 추린다. (3) 우선순위(priority) 정렬 후 weight/거리로 블렌딩해 VolumeStack을 만든다. 이때 Local Volume은 Collider를 사용한다. Collider가 없으면 로컬 판정이 불가능해서 사실상 동작하지 않는다.

왜 Collider 기반인가. 포스트프로세싱은 렌더링 효과라서 물리와 무관해 보이지만, “공간 안/밖 판정”을 이미 엔진이 빠르게 제공하는 구조가 Collider/Bounds이다. URP/HDRP는 트리거 이벤트에 의존하지 않고, 카메라 위치로 Bounds.Contains 같은 계산을 한다. 트리거 이벤트를 쓰면 물리 스텝(FixedUpdate)과 얽혀 타이밍이 복잡해지고, 카메라가 Rigidbody가 아닌 경우도 많다. 매 프레임 위치 기반 계산이 더 단순하다.

메모리/GC 포인트도 여기서 나온다. Profile.TryGet(out Bloom)은 내부 리스트를 탐색한다. 이 호출 자체는 큰 GC를 만들지 않지만, Update에서 매 프레임 여러 Override를 TryGet하면 탐색 비용이 쌓인다. 더 큰 비용은 런타임에 profile을 새로 만들거나(Instantiate), override를 Add/Remove 하면서 직렬화 리스트가 바뀔 때 발생한다. 실무에서는 “Profile 구조는 고정, 값만 변경”이 기본 규칙이다.

처음에 나도 ‘왜 카메라를 움직일 때만 효과가 바뀌지?’를 2시간 붙잡고 있었다. 원인은 Local Volume에 Collider가 없었고, Is Global도 꺼져 있었다. Console에는 에러가 없어서 더 헷갈렸다. Frame Debugger를 켜고(Window → Analysis → Frame Debugger) URP Renderer의 PostProcessPass가 실행되는데도 결과가 안 바뀌는 걸 보고, 그제야 “패스는 돌고 있는데 스택 값이 기본값이구나”로 역추적이 됐다.

VolumeStackDebug.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class VolumeStackDebug : MonoBehaviour
6{
7    [SerializeField] private Camera targetCamera;
8
9    private void Start()
10    {
11        if (targetCamera == null) targetCamera = Camera.main;
12        Debug.Log($"Camera: {targetCamera.name}");
13    }
14
15    private void LateUpdate()
16    {
17        // 렌더 직전 값에 더 가깝게 보기 위해 LateUpdate에서 출력한다.
18        var stack = VolumeManager.instance.stack;
19        var bloom = stack.GetComponent<Bloom>();
20        var color = stack.GetComponent<ColorAdjustments>();
21
22        Debug.Log($"Stack Bloom intensity={bloom.intensity.value:F2}, active={bloom.active}");
23        Debug.Log($"Stack Color postExposure={color.postExposure.value:F2}, active={color.active}");
24    }
25}

이 코드를 실행하면 Console이 미친 듯이 찍힌다. 그 자체가 의도다. VolumeStack은 ‘현재 카메라가 받을 합성 결과’라서, 값이 프레임마다 어떻게 변하는지 눈으로 확인할 수 있다. Local Volume 경계에 카메라를 걸치면 intensity가 0에서 목표값까지 서서히 변하는 로그가 찍힌다. 변화가 없다면 레이어 마스크, Collider, 혹은 카메라가 사용하는 렌더러 설정 쪽 문제로 좁혀진다.

VolumeProfileInstanceGuard.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class VolumeProfileInstanceGuard : MonoBehaviour
5{
6    [SerializeField] private Volume volume;
7    [SerializeField] private bool cloneProfileOnPlay = true;
8
9    private void Awake()
10    {
11        if (volume == null) volume = GetComponent<Volume>();
12        if (volume == null || volume.sharedProfile == null) return;
13
14        if (cloneProfileOnPlay)
15        {
16            // sharedProfile은 에셋 공유 참조다. 런타임 수정 시 씬/프리팹 전체가 오염되는 사고를 막는다.
17            volume.profile = Instantiate(volume.sharedProfile);
18            Debug.Log($"Cloned Profile instanceID={volume.profile.GetInstanceID()} from shared={volume.sharedProfile.GetInstanceID()}");
19        }
20        else
21        {
22            Debug.Log($"Using sharedProfile instanceID={volume.sharedProfile.GetInstanceID()} (edits affect asset)");
23        }
24    }
25}

sharedProfile과 profile의 차이를 Play 중에 InstanceID로 확인한다. cloneProfileOnPlay를 켜면 런타임에만 인스턴스가 생기고, 에디터에서 에셋이 더럽혀지는 일이 줄어든다. 이 패턴을 안 쓰고 sharedProfile을 직접 수정하면, Play를 껐다 켜도 값이 남아 있는 것처럼 보이거나(에셋이 저장됨), 다른 씬에서도 값이 바뀌는 사고로 이어진다.

실습하기

1단계: 프로젝트 설정(URP 기준)

Unity 2022.3 LTS 이상을 권장한다. Hub에서 New project → Universal Render Pipeline 템플릿을 선택한다. 기존 Built-in 프로젝트라면 Edit → Project Settings → Graphics에서 Scriptable Render Pipeline Settings에 URP Asset을 넣고, Edit → Project Settings → Quality에서도 같은 URP Asset을 품질 레벨에 연결한다.

URP Asset에서 Post-processing이 실제로 돌려면 Renderer가 PostProcessPass를 포함해야 한다. 보통 기본 Renderer에 포함돼 있지만, 커스텀 Renderer Data를 쓰는 경우 빠져 있는 일이 있다. Project 창에서 URP Renderer Data(예: UniversalRendererData)를 클릭하고 Inspector에서 Rendering 섹션을 확인한다. 카메라 쪽에서는 Camera Inspector → Rendering → Post Processing 체크가 켜져 있어야 한다.

2단계: 씬 구성(Volume 배치와 트리거 조건 만들기)

Hierarchy 우클릭 → 3D Object → Plane을 생성하고, 그 위에 Cube를 하나 만든다(Hierarchy 우클릭 → 3D Object → Cube). Main Camera는 Plane을 내려다보게 배치한다. 그다음 Hierarchy 우클릭 → Volume → Global Volume을 추가한다. Inspector에서 Profile 옆 New 버튼을 눌러 Volume Profile 에셋을 생성한다.

Global Volume의 Profile Inspector에서 Add Override를 눌러 Bloom과 Color Adjustments를 추가한다. Bloom의 Intensity 체크박스를 켠 뒤 3~8 사이 값을 넣는다. Color Adjustments의 Post Exposure 체크박스를 켠 뒤 0.5~1.5를 넣는다. 체크박스를 안 켜면 overrideState가 false라서 숫자를 바꿔도 스택에 반영되지 않는다.

3단계: 로컬 볼륨과 블렌딩 체감, 코드로 검증

Hierarchy 우클릭 → Volume → Volume을 추가해 Local Volume을 만든다. Inspector에서 Is Global을 끄고, Collider가 필요하므로 Add Component → Box Collider를 추가한다. Box Collider의 Is Trigger는 상관없지만, Size를 (10, 5, 10) 정도로 키우고 Center를 카메라가 지나갈 위치로 맞춘다. Volume 컴포넌트의 Blend Distance를 3으로, Weight를 1로 둔다.

Local Volume의 Profile도 New로 새 에셋을 만든다. Add Override로 Color Adjustments만 넣고 Post Exposure 체크박스를 켠 뒤 -1.0 같은 어두운 값을 넣는다. Play를 누른 뒤 Scene 뷰에서 카메라를 Box Collider 안팎으로 이동하면 Game 뷰가 밝아졌다 어두워졌다 한다. 변화가 없다면 카메라 Post Processing 체크, 레이어 마스크, Collider 누락 순으로 의심한다.

VolumeRuntimeTweaker.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class VolumeRuntimeTweaker : MonoBehaviour
5{
6    [SerializeField] private Volume targetVolume;
7    [SerializeField] private KeyCode toggleKey = KeyCode.B;
8
9    private Bloom bloom;
10
11    private void Awake()
12    {
13        if (targetVolume == null) targetVolume = FindFirstObjectByType<Volume>();
14        if (targetVolume == null || targetVolume.profile == null)
15        {
16            Debug.LogError("No Volume/Profile found. Assign targetVolume in Inspector.");
17            enabled = false;
18            return;
19        }
20
21        if (!targetVolume.profile.TryGet(out bloom))
22        {
23            Debug.LogError("Bloom override missing in Profile. Add Override -> Bloom.");
24            enabled = false;
25            return;
26        }
27    }
28
29    private void Update()
30    {
31        if (Input.GetKeyDown(toggleKey))
32        {
33            bloom.active = !bloom.active;
34            Debug.Log($"Bloom active toggled: {bloom.active}");
35        }
36
37        if (Input.GetKey(KeyCode.UpArrow)) bloom.intensity.value += Time.deltaTime * 2f;
38        if (Input.GetKey(KeyCode.DownArrow)) bloom.intensity.value -= Time.deltaTime * 2f;
39    }
40}

이 스크립트는 “Profile 값이 런타임에 바뀌면 스택이 어떻게 반영되는지”를 손으로 체감시키는 용도다. Play 중에 B를 누르면 Bloom이 켜졌다 꺼지고, 방향키로 intensity를 흔들면 화면이 즉시 반응한다. 즉시 반응이 안 나오면 렌더 패스가 PostProcess를 실행하지 않는 상태다. 그때는 Frame Debugger에서 PostProcessPass가 존재하는지부터 확인이 된다.

VolumeLayerMaskTest.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class VolumeLayerMaskTest : MonoBehaviour
5{
6    [SerializeField] private Volume localVolume;
7    [SerializeField] private Transform cameraTransform;
8
9    private void Start()
10    {
11        if (cameraTransform == null && Camera.main != null) cameraTransform = Camera.main.transform;
12        if (localVolume == null) localVolume = GetComponent<Volume>();
13
14        Debug.Log($"LocalVolume layer={LayerMask.LayerToName(localVolume.gameObject.layer)} mask={localVolume.layerMask.value}");
15    }
16
17    private void Update()
18    {
19        if (cameraTransform == null || localVolume == null) return;
20
21        float dist = Vector3.Distance(cameraTransform.position, localVolume.transform.position);
22        bool inMask = (localVolume.layerMask.value & (1 << localVolume.gameObject.layer)) != 0;
23
24        if (Time.frameCount % 30 == 0)
25            Debug.Log($"dist={dist:F2}, inMask={inMask}, weight={localVolume.weight:F2}, blendDist={localVolume.blendDistance:F2}");
26    }
27}

레이어 마스크는 “볼륨이 어떤 레이어의 카메라에 적용되나”가 아니라 “어떤 레이어의 오브젝트(볼륨)가 수집 대상인가”에 가깝게 쓰인다. 로컬 볼륨 오브젝트 레이어가 Default인데 layerMask가 Nothing이면 수집이 0개가 된다. 이 코드는 30프레임마다 inMask를 찍어서, 마스크 때문에 수집이 누락되는 케이스를 빠르게 잡는다.

심화 활용

패턴 1: 컷신/피격 연출은 ‘값 직접 수정’ 대신 ‘Weight 블렌딩’으로 처리

피격 시 비네팅을 0→0.4로 올리고 0.2초 뒤 내리는 연출을 Profile 값으로 직접 건드리면, 다른 시스템(환경 볼륨, UI 카메라)과 충돌하기 쉽다. 실무에서는 ‘피격 전용 Global Volume’을 하나 두고, 그 Volume의 weight만 애니메이션한다. 스택 합성 단계에서 weight는 곱셈으로 들어가므로, 다른 볼륨과 자연스럽게 합쳐진다.

HitVignetteByWeight.cs
1using System.Collections;
2using UnityEngine;
3using UnityEngine.Rendering;
4
5public class HitVignetteByWeight : MonoBehaviour
6{
7    [SerializeField] private Volume hitVolume;
8    [SerializeField] private float peakWeight = 1f;
9    [SerializeField] private float upTime = 0.05f;
10    [SerializeField] private float downTime = 0.2f;
11
12    private Coroutine routine;
13
14    private void Awake()
15    {
16        if (hitVolume == null) hitVolume = GetComponent<Volume>();
17        if (hitVolume == null)
18        {
19            Debug.LogError("Assign a Global Volume dedicated to hit effect.");
20            enabled = false;
21            return;
22        }
23        hitVolume.weight = 0f;
24    }
25
26    public void Trigger()
27    {
28        if (routine != null) StopCoroutine(routine);
29        routine = StartCoroutine(Pulse());
30    }
31
32    private IEnumerator Pulse()
33    {
34        float t = 0f;
35        while (t < upTime)
36        {
37            t += Time.unscaledDeltaTime;
38            hitVolume.weight = Mathf.Lerp(0f, peakWeight, t / upTime);
39            yield return null;
40        }
41
42        t = 0f;
43        while (t < downTime)
44        {
45            t += Time.unscaledDeltaTime;
46            hitVolume.weight = Mathf.Lerp(peakWeight, 0f, t / downTime);
47            yield return null;
48        }
49        hitVolume.weight = 0f;
50    }
51}

이 방식은 스택 합성이 “정렬 → 블렌딩”으로 구성돼 있다는 설계를 그대로 탄다. 값 자체를 바꾸지 않으니, 같은 Profile을 공유하는 다른 씬/프리팹을 오염시키지 않는다. Profiler에서도 Update에서 override 값을 여러 개 만지는 것보다, float 하나 바꾸는 쪽이 비용이 작게 나온다.

패턴 2: 런타임 품질 단계는 Profile 교체가 아니라 ‘Override 활성화 조합’으로 구성

저사양 기기에서 Bloom/Chromatic Aberration 같은 비싼 효과를 끄려는 요구가 자주 나온다. Profile을 통째로 바꾸면 에셋 로딩/참조 변경이 얽히고, 카메라 스택(Overlay)까지 있으면 디버깅이 어려워진다. Global Volume 하나를 기준으로 두고, Override의 active 또는 개별 파라미터 overrideState를 조합해 품질 단계를 만든다.

PostFxQualitySwitch.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class PostFxQualitySwitch : MonoBehaviour
5{
6    public enum FxQuality { Low, Medium, High }
7
8    [SerializeField] private Volume globalVolume;
9    [SerializeField] private FxQuality quality = FxQuality.High;
10
11    private Bloom bloom;
12    private ChromaticAberration ca;
13    private FilmGrain grain;
14
15    private void Awake()
16    {
17        if (globalVolume == null) globalVolume = GetComponent<Volume>();
18        if (globalVolume == null || globalVolume.profile == null)
19        {
20            Debug.LogError("Assign Global Volume with a Profile.");
21            enabled = false;
22            return;
23        }
24
25        globalVolume.profile.TryGet(out bloom);
26        globalVolume.profile.TryGet(out ca);
27        globalVolume.profile.TryGet(out grain);
28
29        Apply(quality);
30    }
31
32    public void Apply(FxQuality q)
33    {
34        quality = q;
35
36        if (bloom != null) bloom.active = (q != FxQuality.Low);
37        if (ca != null) ca.active = (q == FxQuality.High);
38        if (grain != null) grain.active = (q == FxQuality.High);
39
40        Debug.Log($"FX quality set to {q}");
41    }
42}

이 코드는 TryGet을 Awake에서 한 번만 호출하고 레퍼런스를 캐싱한다. Update에서 매번 TryGet을 반복하면 탐색 비용이 쌓이고, 프로젝트가 커지면 눈에 띄는 ms가 된다. 실무에서 Profiler로 보면, 모바일에서 Volume 관련 C# 비용이 0.1~0.3ms까지 튀는 케이스가 종종 나오는데 대부분 ‘매 프레임 설정 검색’이 원인이다.

처음에 나도 sharedProfile을 직접 수정했다가, 팀원이 “왜 내 씬 색감이 바뀌었지?”라고 묻는 사건이 있었다. Git diff를 봐도 씬 파일은 멀쩡했고, 원인은 Volume Profile 에셋(.asset)이 저장돼 버린 거였다. Play 모드에서 테스트한다고 intensity를 올렸는데, 에셋이 Dirty로 남아 저장된 상태였다. 3시간 삽질 끝에 ‘런타임 수정은 인스턴스 프로파일로만’이라는 규칙을 팀 컨벤션에 박았다.

또 한 번은 로컬 볼륨이 카메라에 전혀 먹지 않아서 렌더러 쪽을 의심했다. 실제 원인은 Volume 오브젝트 레이어를 UI로 바꿔둔 상태에서, URP 카메라의 Volume Layer Mask가 Default만 포함하도록 되어 있었다. Console에는 아무 메시지도 없고, 효과만 조용히 사라진다. 이런 종류의 버그는 스택 값을 로그로 찍거나, 볼륨 오브젝트 레이어와 마스크를 함께 출력하는 디버그 HUD를 붙이면 10분 안에 끝난다.

한 문단 요약: Volume은 렌더링을 ‘실행’하는 컴포넌트가 아니라, 렌더러가 프레임마다 수집·정렬·블렌딩해 VolumeStack을 만드는 입력 데이터다. Profile은 공유 에셋이므로 런타임 수정은 인스턴스로 격리하고, 실무 연출은 값 직접 수정보다 weight 블렌딩이 충돌이 적다.

자주 하는 실수

실수 1: Override 체크박스를 안 켜고 숫자만 바꿈

증상: Inspector에서 Bloom Intensity를 10으로 올렸는데 화면이 그대로다. Console 에러는 없고, Profile 에셋에는 값이 저장된 것처럼 보인다.

원인: Volume 파라미터는 value와 overrideState가 분리돼 있다. 체크박스는 overrideState를 의미한다. overrideState가 false면 블렌딩 단계에서 해당 파라미터는 ‘기본값 유지’로 처리된다.

해결: Profile에서 해당 파라미터 왼쪽 체크박스를 켠다. 코드로는 bloom.intensity.overrideState = true를 설정한다. VolumeStackDebug 로그에서 intensity가 0→목표값으로 변하는지 확인한다.

실수 2: Local Volume인데 Collider가 없음

증상: Local Volume을 만들어도 카메라가 안팎으로 움직여도 변화가 없다. Is Global을 켜면 즉시 적용된다.

원인: 로컬 판정은 Collider/Bounds 기반이다. Collider가 없으면 영향권 계산이 실패하고 후보 리스트에서 사실상 제외된다. 트리거 이벤트가 아니라 위치 기반 계산이라서, Collider가 필수다.

해결: Volume 오브젝트에 Box Collider를 추가하고 Size를 충분히 키운다. Scene 뷰에서 Gizmo로 박스가 카메라 경로를 덮는지 확인한다. Blend Distance를 0이 아닌 값으로 두면 경계에서 부드럽게 변하는 로그가 찍힌다.

실수 3: 카메라 Post Processing 옵션이 꺼져 있음(URP)

증상: VolumeStackDebug에서는 값이 변하는데, Game 뷰는 변화가 없다. Frame Debugger에서 PostProcessPass가 보이지 않거나, 패스는 있어도 스킵된 것처럼 보인다.

원인: URP 카메라에는 Post Processing 토글이 있다. 이 토글이 꺼져 있으면 렌더러가 포스트 프로세싱 패스를 실행하지 않거나, 관련 셰이더 키워드가 비활성화된다.

해결: Main Camera 선택 → Inspector → Rendering → Post Processing 체크를 켠다. 카메라 스택을 쓰는 경우 Base/Overlay 각각의 설정도 확인한다. Frame Debugger에서 PostProcessPass가 실행되는지 다시 확인한다.

실수 4: sharedProfile을 런타임에 수정해서 에셋이 오염됨

증상: Play 모드에서 잠깐 테스트한 값이 다음 실행에도 남아 있다. 다른 씬의 색감까지 바뀐다. Git에서 .asset 파일이 변경되어 있다.

원인: sharedProfile은 에셋 공유 참조다. 이를 수정하면 에디터가 에셋을 Dirty로 표시하고, 저장 또는 자동 저장 시 변경이 남는다. 팀 단위로는 ‘원인 불명 색감 변경’ 사건으로 번진다.

해결: 런타임에 수정할 계획이 있으면 volume.profile = Instantiate(volume.sharedProfile)로 인스턴스를 만든다. Play 중에는 인스턴스만 수정한다. 변경이 에셋에 남았으면 Project 창에서 해당 Profile을 선택하고 Inspector 오른쪽 메뉴에서 Revert로 되돌린다.

실수 5: Volume 레이어 마스크/오브젝트 레이어 불일치

증상: 어떤 볼륨은 먹고 어떤 볼륨은 안 먹는다. 씬을 복사하거나 프리팹으로 만들면 갑자기 효과가 사라진다.

원인: Volume 컴포넌트에는 Layer Mask가 있고, 카메라/파이프라인 구성에 따라 수집 대상 레이어가 제한된다. 볼륨 오브젝트 레이어를 바꾸거나, 마스크를 Nothing으로 두면 조용히 누락된다.

해결: Volume 오브젝트 레이어를 Default로 두고, Volume의 Layer Mask가 Default를 포함하는지 확인한다. 프로젝트 규칙으로 PostFX 전용 레이어를 만들었다면, 카메라/파이프라인에서 그 레이어를 포함하도록 통일한다.

VolumeSanityCheck.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class VolumeSanityCheck : MonoBehaviour
5{
6    [SerializeField] private Volume volume;
7
8    private void Reset()
9    {
10        volume = GetComponent<Volume>();
11    }
12
13    private void OnValidate()
14    {
15        if (volume == null) volume = GetComponent<Volume>();
16        if (volume == null) return;
17
18        if (!volume.isGlobal && volume.GetComponent<Collider>() == null)
19            Debug.LogWarning($"[{name}] Local Volume without Collider. Add BoxCollider.");
20
21        if (volume.sharedProfile == null && volume.profile == null)
22            Debug.LogWarning($"[{name}] Volume has no Profile assigned.");
23
24        bool inMask = (volume.layerMask.value & (1 << volume.gameObject.layer)) != 0;
25        if (!inMask)
26            Debug.LogWarning($"[{name}] Volume layer '{LayerMask.LayerToName(volume.gameObject.layer)}' not included in its LayerMask.");
27    }
28}

이 스크립트는 실수 2, 4, 5를 에디터 단계에서 미리 터뜨리는 용도다. OnValidate는 Inspector 값이 바뀔 때 호출되므로, ‘조용히 안 먹는’ 상태를 경고로 바꿀 수 있다. 팀 프로젝트에서 볼륨 프리팹이 늘어나면 이런 안전장치가 디버깅 시간을 크게 줄인다.

성능 최적화 체크리스트

  • URP/HDRP에서 카메라의 Post Processing 토글이 켜져 있다
  • Global/Local Volume의 Profile이 비어 있지 않고, 필요한 Override가 실제로 추가되어 있다
  • Override 파라미터의 체크박스(overrideState)가 켜져 있어 값이 스택에 반영된다
  • Local Volume에는 Collider가 있고, 카메라 경로를 충분히 덮는 크기/위치로 배치했다
  • Volume 오브젝트 레이어와 Volume의 Layer Mask가 일치해 수집에서 누락되지 않는다
  • 런타임에 값을 바꿀 계획이면 sharedProfile 대신 Instantiate한 profile 인스턴스를 사용한다
  • Update에서 profile.TryGet을 반복 호출하지 않고 Awake/Start에서 캐싱한다
  • 연출은 Override 값 직접 변경보다 Volume.weight 애니메이션을 우선 사용한다
  • Profiler에서 Rendering/Script 비용을 분리해 보고, Volume 관련 스크립트가 0.1ms 이상이면 호출 빈도를 의심한다
  • Frame Debugger로 PostProcessPass가 실제로 실행되는지 확인하고, 실행되는데도 변화가 없으면 스택 값을 로그로 검증한다
  • 카메라 스택(Base/Overlay)을 쓰면 각 카메라의 Volume Layer Mask, Post Processing 토글을 모두 점검한다
  • 품질 단계는 Profile 교체보다 Override active 조합으로 설계해 참조 변경을 줄인다

자주 묻는 질문

Volume이 있는데도 화면이 전혀 안 바뀌는 경우, 어디부터 확인해야 하나?

가장 먼저 렌더러가 포스트프로세싱 패스를 실행하는 구성인지 확인한다. URP라면 Main Camera 선택 → Inspector → Rendering → Post Processing 토글이 꺼져 있으면 VolumeStack이 정상이어도 Game 뷰는 그대로다. 다음으로 Frame Debugger(Window → Analysis → Frame Debugger)에서 PostProcessPass가 존재하는지 본다. 패스가 없으면 Renderer Data 설정 문제다. 패스가 있는데도 변화가 없으면 VolumeStackDebug 같은 로그로 스택 값이 기본값인지 확인한다. 스택이 기본값이면 Volume 수집 조건(레이어 마스크, Local Collider, priority/weight)이 문제다. 다음 학습 키워드는 Frame Debugger, URP Renderer Data, VolumeManager stack이다.

VolumeProfile의 sharedProfile과 profile은 왜 둘로 나뉘어 있나?

sharedProfile은 에셋 참조(공유)이고, profile은 인스턴스 참조(복제 가능)다. 에디터에서 여러 Volume이 같은 Profile 에셋을 공유하면, 하나만 수정해도 전부 바뀌는 대신 관리가 편해진다. 반대로 런타임에서 값을 바꾸면 공유 에셋이 오염될 수 있으니, Unity는 ‘공유를 기본으로 하되 필요하면 인스턴스를 만들어 쓰라’는 구조를 제공한다. 실제로 volume.profile에 Instantiate(sharedProfile)을 넣으면 런타임 인스턴스가 생기고, 그 인스턴스만 수정된다. 다음 학습 키워드는 ScriptableObject 직렬화, 에셋 오염(Dirty), 런타임 인스턴싱이다.

Local Volume은 트리거 이벤트로 동작하나? 카메라가 들어가면 OnTriggerEnter 같은 게 호출되나?

대부분의 경우 트리거 이벤트에 의존하지 않는다. 로컬 볼륨은 카메라 위치를 기준으로 Collider/Bounds에 대해 포함 여부와 거리(Blend Distance)를 계산해 블렌딩한다. 이 방식은 물리 스텝(FixedUpdate)과 분리되어 있고, 카메라가 Rigidbody가 아니어도 안정적으로 동작한다. 그래서 Collider의 Is Trigger는 필수가 아니며, 중요한 건 ‘Bounds가 존재하느냐’다. 카메라가 경계 근처에 있을 때 값이 서서히 변하는 이유도 거리 기반 블렌딩 때문이다. 다음 학습 키워드는 Bounds, Blend Distance, Volume blending 알고리즘이다.

Update에서 Bloom 값을 바꾸면 같은 프레임에 바로 반영되나? 한 프레임 늦는 것처럼 보일 때가 있다

대체로 같은 프레임에 반영되지만, 정확히는 “카메라 렌더 직전에 스택이 갱신되는 시점”에 따라 달라진다. MonoBehaviour Update가 끝난 뒤 SRP가 카메라 렌더를 시작하면서 VolumeManager가 스택을 업데이트하고, 그 스택을 PostProcessPass가 읽는다. 카메라 스택(Base/Overlay), 멀티 카메라, 렌더 이벤트 순서에 따라 Update에서 바꾼 값이 해당 카메라 렌더 전에 반영되지 못하면 다음 프레임에 보일 수 있다. 검증은 LateUpdate에서 stack 값을 로그로 찍고, Frame Debugger로 패스 실행 순서를 보는 방식이 확실하다. 다음 학습 키워드는 PlayerLoop, SRP Render 이벤트, 카메라 스택이다.

왜 Override 파라미터마다 체크박스가 따로 있고, 값만 바꿔서는 적용이 안 되나?

블렌딩 시스템이 ‘기본값 유지’와 ‘이 볼륨이 특정 파라미터를 덮어쓴다’를 구분해야 하기 때문이다. 예를 들어 Color Adjustments는 Post Exposure만 덮고 Contrast는 기본을 쓰고 싶을 수 있다. 체크박스(overrideState)는 “이 파라미터는 블렌딩에 참여한다”라는 플래그다. 값(value)만 저장하고 참여 여부가 없으면, 모든 파라미터가 항상 블렌딩돼 의도치 않은 덮어쓰기가 발생한다. 엔진은 스택 합성 시 overrideState가 true인 항목만 섞어 계산량도 줄인다. 다음 학습 키워드는 overrideState, VolumeParameter, 선택적 블렌딩이다.

Profile을 매 프레임 교체하는 방식으로 연출하면 왜 문제가 되나?

Profile 교체는 참조 변경이며, 내부적으로는 스택 갱신과 캐시 무효화가 더 자주 일어날 수 있다. 또한 에셋 로딩/참조가 얽히면 Addressables 같은 시스템과 충돌 지점이 늘어난다. 반면 weight 애니메이션은 float 하나를 바꾸는 수준이라, 스택 합성 단계에서 자연스럽게 섞이고 충돌이 적다. Profiler 관점에서도 “참조 변경 + 구조 변경”은 스파이크를 만들기 쉽고, “값 변경”은 예측 가능하다. 연출은 전용 Volume을 두고 weight를 올렸다 내리는 방식이 안정적이다. 다음 학습 키워드는 캐시 무효화, 스파이크 분석, 연출용 전용 볼륨 설계다.

Volume 관련 성능은 어디서 병목이 생기나? 블룸 같은 효과 자체가 무거운 건 알겠는데, Volume 시스템도 비용이 있나?

비용은 두 층이다. 첫째는 GPU에서 실제 포스트프로세싱 패스가 수행하는 비용(예: Bloom 다운샘플/업샘플)이다. 둘째는 CPU에서 볼륨을 수집·정렬·블렌딩해 스택을 만드는 비용이다. 씬에 Volume이 많고(수십~수백), Update에서 TryGet을 남발하거나 런타임에 Override 구조를 바꾸면 CPU 비용이 올라간다. Profiler에서는 Script 쪽에서 Volume 관련 호출이 0.1ms 이상 보이거나, GC Alloc이 튀면 의심한다. 해결은 (1) Volume 수를 줄이거나 레이어로 수집 범위를 줄이고 (2) Override 참조를 캐싱하고 (3) Profile 구조는 고정, 값만 변경하는 규칙을 지키는 것이다. 다음 학습 키워드는 Unity Profiler, GC Alloc 원인 추적, VolumeManager 수집 범위 최적화다.

관련 글

23. Unity URP Volume과 Post-processing 프로파일 설정 원리

23. Unity URP Volume과 Post-processing 프로파일 설정 원리

URP Volume과 Post-processing 프로파일이 카메라 렌더링 단계에서 어떻게 합성되는지, 내부 동작과 실습 절차, 성능 함정을 함께 다룬다. 초보도 재현 가능하게 구성한다. 2022~6 기준 URP 공통 흐름 포함한다. 실전 디버깅 팁 제공한다.

22. Unity URP Volume·Profile로 포스트 프로세싱 파이프라인 구성 원리

22. Unity URP Volume·Profile로 포스트 프로세싱 파이프라인 구성 원리

URP에서 Volume과 Profile이 카메라 렌더링 단계에 어떻게 합성되는지, C# 래퍼와 네이티브 처리 흐름·PlayerLoop 시점·성능/GC 포인트까지 연결해 설명한다.','primaryKeywords':['Unity URP','Volume Profile','Post Processing','URP Renderer