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

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

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

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

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

게임 만들다 겪는 사고로 가장 흔한 게 “에디터에서는 블룸이 잘 보이는데 빌드하면 싹 사라진다” 같은 후처리 이슈다. 씬에는 Volume도 있고 프로파일도 넣었는데, 카메라를 움직이면 효과가 튀거나, 특정 구역에서만 먹어야 할 색보정이 전역으로 퍼지기도 한다. URP에서는 이 현상이 ‘Volume이 언제, 어떤 카메라에, 어떤 마스크로, 어떤 우선순위로 섞였는지’를 이해하지 못하면 반복된다. 이 글은 URP가 프레임마다 Volume 스택을 재구성하고 RenderPass에서 셰이더 파라미터를 채우는 흐름까지 연결해 설명한다.

핵심 개념

URP에서 Post-processing은 “카메라가 그린 최종 컬러 버퍼에 후처리 패스를 더한다”로 끝나지 않는다. 실제 문제는 ‘어떤 설정이 최종적으로 선택되었는가’다. Volume은 씬에 흩어진 여러 프로파일을 프레임마다 섞어서 하나의 “스택(VolumeStack)”을 만들고, 그 스택을 렌더 패스가 읽어 셰이더 상수로 전달한다. 그래서 같은 Bloom를 켰어도 Priority, Weight, Blend Distance, Layer Mask, 카메라의 Volume Layer Mask 조합에 따라 결과가 달라진다.

용어를 엔진 관점으로 정의한다. Volume Component(예: Bloom, Color Adjustments)는 C# ScriptableObject가 아니라, 직렬화 가능한 설정 묶음이며 내부적으로는 OverrideState와 값 필드를 가진다. Volume Profile은 Volume Component들의 컨테이너이고, 씬의 Volume 컴포넌트가 이 프로파일을 참조한다. Global Volume은 공간 조건 없이 항상 후보로 들어오고, Local Volume은 Collider(대개 Box Collider)로 카메라 위치를 테스트해 후보가 된다.

Volume Layer Mask는 “어떤 레이어의 Volume을 카메라가 평가할지”를 결정한다. 카메라 쪽(UniversalAdditionalCameraData)에서 마스크를 설정하고, Volume 오브젝트의 레이어와 AND 연산으로 필터링된다. 여기서 초보가 자주 겪는 현상은 Volume이 분명 있는데 아무 효과도 없는 상태다. 원인은 대개 카메라 마스크가 Default만 켜져 있고 Volume 오브젝트가 PostProcessing 같은 커스텀 레이어에 있기 때문이다.

Priority는 후보들의 정렬 키다. Weight는 후보의 기여도(0~1)다. Local Volume은 Blend Distance로 경계에서 Weight를 자동으로 감쇠한다. 중요한 점은 URP가 “효과별로 독립적으로 섞는 게 아니라, 스택을 통째로 갱신한 뒤 각 효과가 그 스택에서 값을 읽는다”는 구조다. 그래서 한 효과만 튀는 것처럼 보여도, 실제로는 스택 자체가 기대와 다르게 구성된 경우가 많다.

VolumeQuickInspector.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class VolumeQuickInspector : MonoBehaviour
6{
7    [SerializeField] private Volume volume;
8
9    private void Reset()
10    {
11        volume = GetComponent<Volume>();
12    }
13
14    private void Start()
15    {
16        if (volume == null)
17        {
18            Debug.LogError("VolumeQuickInspector: Volume reference is null");
19            return;
20        }
21
22        Debug.Log($"isGlobal={volume.isGlobal}, priority={volume.priority}, weight={volume.weight}");
23        Debug.Log($"profile={(volume.profile != null ? volume.profile.name : "null")}, sharedProfile={(volume.sharedProfile != null ? volume.sharedProfile.name : "null")}");
24    }
25}

이 스크립트를 Volume 오브젝트에 붙이고 Play를 누르면 콘솔에 isGlobal/priority/weight와 profile 참조 상태가 찍힌다. profile이 null인데 Inspector에서 뭔가 들어있어 보이는 경우가 있다. sharedProfile만 연결된 상태에서 런타임에 volume.profile로 접근하면 null로 보일 수 있고, 코드에서 수정한 값이 에디터 에셋을 오염시키는 사고도 여기서 시작한다.

엔진 관점에서의 내부 동작

URP의 후처리는 PlayerLoop의 MonoBehaviour.Update 같은 곳에서 직접 실행되지 않는다. 카메라가 렌더링될 때 Scriptable Render Pipeline(SRP)이 렌더 루프를 돌리고, URP는 카메라별로 ScriptableRenderer를 통해 RenderPass들을 enqueue한다. Volume은 이 렌더링 준비 과정에서 “현재 카메라 위치와 레이어 마스크에 해당하는 Volume들을 찾아서 스택을 갱신”하는 단계로 끼어든다.

C#에서 Volume 컴포넌트는 UnityEngine.Rendering.Volume이고, 내부 데이터는 네이티브 엔진 오브젝트(UnityEngine.Object) 핸들을 가진다. VolumeManager는 C# 싱글톤처럼 보이지만, 실제로는 SRP 렌더링 중에 호출되는 정적 서비스에 가깝다. 카메라 렌더 직전에 VolumeManager가 씬에 등록된 Volume 목록을 스캔하고(레이어 필터), Local Volume이면 카메라 위치를 기준으로 Collider bounds와 거리를 계산해 가중치를 만든다.

처음에 나도 “Volume은 그냥 데이터인데 왜 카메라 움직일 때마다 CPU가 튀지?”를 몰랐다. URP Profiler에서 Camera.Render가 2ms쯤 올라가고, Hierarchy에 Volume을 80개쯤 깔아둔 씬에서 갑자기 스파이크가 생겼다. 3시간 삽질 끝에 찾은 건 VolumeManager.Update가 매 카메라마다 후보 Volume 리스트를 정렬(priority)하고, 각 Volume Profile의 컴포넌트들을 스택에 apply하는 비용이 누적된다는 점이었다.

메모리 관점에서 VolumeStack은 카메라마다 따로 들고 갈 수도 있고(렌더러 구현에 따라), 공용 스택을 카메라 렌더 전에 갱신해 재사용할 수도 있다. 중요한 건 “스택 갱신 과정에서 GC Alloc이 발생하면 프레임이 흔들린다”는 점이다. 대개 GC는 프로파일을 런타임에 Instantiate하거나, GetComponent/FindObjectOfType 같은 탐색을 렌더 중에 반복할 때 발생한다. Volume 자체는 값 타입처럼 보여도 내부는 많은 ScriptableObject/클래스 인스턴스로 이어져 있다.

후처리 패스는 VolumeStack의 값을 읽어 셰이더 파라미터를 채운다. 예를 들어 Bloom 강도, Threshold 같은 값은 C#에서 Material/ComputeBuffer/GlobalShaderProperty로 전달되고, 네이티브 렌더러는 커맨드 버퍼에 draw/blit 명령을 기록한다. URP에서는 ScriptableRenderPass.Execute가 호출되며, 이 시점은 MonoBehaviour.Update 이후, 렌더링 단계에서 동작한다. 그래서 Update에서 값을 바꿔도 실제 화면 반영은 같은 프레임의 렌더 패스에서 일어난다.

왜 API가 이런 형태인가에 대한 설계 의도가 보인다. “씬에 분산된 아티스트용 설정(Volume)”과 “카메라별 렌더링(URP)”을 느슨하게 연결해야 한다. 카메라가 2개(메인+미니맵)면 서로 다른 Volume Mask로 서로 다른 스택을 써야 한다. 그래서 Volume은 컴포넌트로 씬에 존재하고, VolumeManager는 카메라 렌더 시점에 스택을 만들어주는 구조가 된다.

PlayerLoopProbe.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3using UnityEngine.Rendering.Universal;
4
5public class PlayerLoopProbe : MonoBehaviour
6{
7    private void Awake() => Debug.Log("[Loop] Awake");
8    private void OnEnable() => Debug.Log("[Loop] OnEnable");
9    private void Start() => Debug.Log("[Loop] Start");
10    private void Update() => Debug.Log("[Loop] Update");
11    private void LateUpdate() => Debug.Log("[Loop] LateUpdate");
12
13    private void OnPreCull() => Debug.Log("[Camera] OnPreCull");
14    private void OnPreRender() => Debug.Log("[Camera] OnPreRender");
15    private void OnPostRender() => Debug.Log("[Camera] OnPostRender");
16}

이 스크립트를 카메라가 있는 씬에서 빈 GameObject에 붙이고 Play를 누르면 로그 순서가 찍힌다. Update/LateUpdate 이후에 OnPreCull/OnPreRender가 오고, 그 사이에서 SRP 렌더가 준비된다. URP의 Volume 스택 갱신은 체감상 OnPreRender 근처의 렌더 준비 단계에서 일어난다. 그래서 Update에서 Volume 파라미터를 바꾸면 같은 프레임에 반영되는 것처럼 보이는 이유가 생긴다.

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 UniversalAdditionalCameraData _camData;
10
11    private void Awake()
12    {
13        if (targetCamera == null) targetCamera = Camera.main;
14        if (targetCamera != null) targetCamera.TryGetComponent(out _camData);
15    }
16
17    private void Update()
18    {
19        if (_camData == null) return;
20
21        var stack = VolumeManager.instance.stack;
22        var bloom = stack.GetComponent<Bloom>();
23        var color = stack.GetComponent<ColorAdjustments>();
24
25        Debug.Log($"mask={_camData.volumeLayerMask.value}, bloomActive={bloom.active}, bloomIntensity={bloom.intensity.value}, colorPostExposure={color.postExposure.value}");
26    }
27}

이 코드를 실행하면 매 프레임 스택에서 Bloom/ColorAdjustments 값을 읽어 로그로 확인할 수 있다. 값이 기대와 다르면 Volume 후보 선정(레이어/글로벌/로컬)이나 Priority 정렬이 어긋난 상태다. 주의점은 Debug.Log 자체가 느리므로 실제 성능 측정용이 아니라 “스택이 어떤 값으로 굳었는지”를 확인하는 용도다.

한 문단 요약: URP 후처리는 ‘Volume 컴포넌트들이 프레임마다 스택으로 합성되고, 렌더 패스가 그 스택을 읽어 셰이더 파라미터를 채우는 구조’라서, 레이어 마스크·우선순위·로컬 블렌딩이 한 군데만 틀려도 화면이 전혀 다르게 나온다.

실습하기

1단계: 프로젝트와 URP 설정

Unity Hub에서 2022.3 LTS 또는 2023.2 이상을 설치하고, New project → 3D (URP) 템플릿을 선택한다. 템플릿을 쓰는 이유는 URP Asset, Renderer, 기본 셰이더 변환이 이미 세팅되어 있어서 “후처리가 안 먹는” 원인을 불필요하게 늘리지 않기 때문이다.

프로젝트가 열리면 Project 창에서 Settings 폴더의 URP Asset을 클릭한다. Inspector에서 Rendering 섹션의 Post-processing 옵션이 켜져 있는지 확인한다(버전에 따라 Renderer 쪽 설정이거나 카메라 쪽 옵션일 수 있다). 카메라에도 설정이 있다. Main Camera 선택 → Inspector → Universal Additional Camera Data → Rendering → Post Processing 체크를 켠다.

CameraPostFxGuard.cs
1using UnityEngine;
2using UnityEngine.Rendering.Universal;
3
4public class CameraPostFxGuard : MonoBehaviour
5{
6    [SerializeField] private Camera targetCamera;
7
8    private void Awake()
9    {
10        if (targetCamera == null) targetCamera = Camera.main;
11        if (targetCamera == null)
12        {
13            Debug.LogError("CameraPostFxGuard: Camera.main not found");
14            return;
15        }
16
17        if (!targetCamera.TryGetComponent(out UniversalAdditionalCameraData data))
18        {
19            Debug.LogError("CameraPostFxGuard: UniversalAdditionalCameraData missing (URP camera component)");
20            return;
21        }
22
23        Debug.Log($"Camera PostProcessing enabled={data.renderPostProcessing}, volumeMask={data.volumeLayerMask.value}");
24    }
25}

이 스크립트를 빈 GameObject에 붙이고 Play를 누르면 카메라의 renderPostProcessing 상태가 콘솔에 찍힌다. 효과가 안 보일 때 가장 먼저 확인할 지점이 이 값이다. 에디터에서는 켜둔 줄 알았는데 프리팹 오버라이드가 깨져서 꺼져 있는 경우가 실제로 자주 나온다.

2단계: 씬에 Global Volume과 Local Volume 만들기

Hierarchy 우클릭 → Volume → Global Volume을 생성한다. Inspector에서 Volume 컴포넌트의 Profile 옆 New 버튼을 눌러 Volume Profile 에셋을 만든다. Add Override 버튼을 눌러 Post-processing → Bloom, Color Adjustments, Tonemapping을 추가한다. Bloom Intensity를 2~5로 올리고, Color Adjustments Post Exposure를 0.5 정도로 올리면 Game 뷰에서 밝아지고 번지는 변화가 보인다.

로컬 효과를 만들려면 Hierarchy 우클릭 → Volume → Box Volume(버전에 따라 Local Volume) 생성한다. Inspector에서 Is Global 체크를 끄고, Box Collider 크기를 (10, 5, 10) 정도로 키운다. Profile을 새로 만들고 Color Adjustments의 Color Filter를 파란색으로 바꾼다. 카메라를 WASD로 움직일 수 있게 간단한 컨트롤러를 붙이면, 박스 안에 들어갈 때만 화면 톤이 바뀌는 게 보인다.

SimpleFlyCamera.cs
1using UnityEngine;
2
3public class SimpleFlyCamera : MonoBehaviour
4{
5    [SerializeField] private float moveSpeed = 5f;
6    [SerializeField] private float lookSpeed = 120f;
7
8    private float _yaw;
9    private float _pitch;
10
11    private void Start()
12    {
13        var e = transform.eulerAngles;
14        _yaw = e.y;
15        _pitch = e.x;
16    }
17
18    private void Update()
19    {
20        if (Input.GetMouseButton(1))
21        {
22            _yaw += Input.GetAxis("Mouse X") * lookSpeed * Time.deltaTime;
23            _pitch -= Input.GetAxis("Mouse Y") * lookSpeed * Time.deltaTime;
24            _pitch = Mathf.Clamp(_pitch, -80f, 80f);
25            transform.rotation = Quaternion.Euler(_pitch, _yaw, 0f);
26        }
27
28        Vector3 dir = new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
29        if (Input.GetKey(KeyCode.E)) dir.y += 1f;
30        if (Input.GetKey(KeyCode.Q)) dir.y -= 1f;
31
32        transform.position += transform.TransformDirection(dir.normalized) * moveSpeed * Time.deltaTime;
33    }
34}

Main Camera에 이 스크립트를 붙이고 Play 모드에서 마우스 오른쪽 버튼을 누른 채로 이동하면, Local Volume 박스 경계를 통과할 때 화면 색이 변한다. 변하지 않으면 Local Volume 오브젝트 레이어와 카메라의 Volume Layer Mask가 일치하는지 확인한다. Box Collider의 Center가 카메라 경로에 있는지도 확인한다.

3단계: 레이어 마스크와 우선순위로 충돌 상황 재현

충돌 상황을 일부러 만든다. Project 창 → Tags and Layers → Layers에서 User Layer 8에 PostFXLocal을 만든다. Local Volume 오브젝트 선택 → Inspector 상단 Layer를 PostFXLocal로 변경한다. Main Camera 선택 → Universal Additional Camera Data → Volume Layer Mask에서 PostFXLocal을 끈다. Play하면 박스 안에 들어가도 로컬 색보정이 사라진다. 콘솔에는 스택 값이 Global만 반영된 상태로 찍힌다.

Priority 충돌도 재현한다. Global Volume의 Priority를 0, Local Volume의 Priority를 -1로 두면 로컬이 후보로 들어와도 최종 스택에서 밀릴 수 있다(효과별 override 상태에 따라 체감이 다르다). Local Volume Priority를 10으로 올리고 Weight를 1로 두면 박스 안에서 로컬이 확실히 이긴다. Blend Distance를 2로 두면 경계에서 2m 구간 동안 점진적으로 섞이는 게 보인다.

VolumeRuntimeTuner.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class VolumeRuntimeTuner : MonoBehaviour
5{
6    [SerializeField] private Volume localVolume;
7    [SerializeField, Range(0f, 1f)] private float weight = 1f;
8    [SerializeField] private float priority = 10f;
9
10    private void Update()
11    {
12        if (localVolume == null) return;
13
14        localVolume.weight = weight;
15        localVolume.priority = priority;
16
17        if (Input.GetKeyDown(KeyCode.Space))
18        {
19            Debug.Log($"LocalVolume tuned: weight={localVolume.weight}, priority={localVolume.priority}");
20        }
21    }
22}

이 스크립트는 “왜 런타임에 값이 즉시 반영되는가”를 체감시키는 용도다. Update에서 weight/priority를 바꾸면 같은 프레임 렌더 준비 단계에서 VolumeManager가 다시 스택을 만들 때 이 값이 반영된다. Space를 누르면 현재 값이 로그로 찍혀서, Inspector 슬라이더 조작과 결과를 연결하기 쉽다.

심화 활용

패턴 1: 런타임에서 프로파일 안전하게 조절하기(sharedProfile 오염 방지)

실무에서는 “피격 시 화면 채도 낮추기”, “독가스 지역에서 그린 틴트”, “스텔스 모드에서 비네팅 강화” 같은 연출을 코드로 건드린다. 여기서 가장 큰 사고는 sharedProfile을 직접 수정해서 에디터 에셋이 바뀌는 경우다. Play를 끝냈는데도 프로젝트 에셋이 변해 있고, Git diff에 VolumeProfile.asset이 찍힌다.

URP Volume 컴포넌트는 Volume.sharedProfile(에셋 참조)과 Volume.profile(런타임 인스턴스)을 구분한다. 런타임 조절은 volume.profile을 확보한 뒤 그 안의 Override 값을 바꿔야 한다. profile이 null이면 내부적으로 인스턴스가 없다는 의미라서, Instantiate로 복제하고 volume.profile에 넣어야 한다.

SafeProfileRuntimeEdit.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class SafeProfileRuntimeEdit : MonoBehaviour
5{
6    [SerializeField] private Volume targetVolume;
7
8    private ColorAdjustments _color;
9    private VolumeProfile _runtimeProfile;
10
11    private void Awake()
12    {
13        if (targetVolume == null) targetVolume = GetComponent<Volume>();
14        if (targetVolume == null)
15        {
16            Debug.LogError("SafeProfileRuntimeEdit: targetVolume missing");
17            return;
18        }
19
20        var src = targetVolume.sharedProfile;
21        if (src == null)
22        {
23            Debug.LogError("SafeProfileRuntimeEdit: sharedProfile is null");
24            return;
25        }
26
27        _runtimeProfile = Instantiate(src);
28        targetVolume.profile = _runtimeProfile;
29
30        if (!_runtimeProfile.TryGet(out _color))
31        {
32            Debug.LogError("SafeProfileRuntimeEdit: ColorAdjustments override not found in profile");
33        }
34    }
35
36    private void Update()
37    {
38        if (_color == null) return;
39
40        float t = Mathf.PingPong(Time.time, 1f);
41        _color.saturation.value = Mathf.Lerp(-80f, 0f, t);
42    }
43}

Play 중에 화면 채도가 출렁이듯 변한다. Play 종료 후 Project 창의 원본 프로파일 에셋이 변하지 않았으면 성공이다. 이 방식이 필요한 이유는 VolumeProfile이 ScriptableObject이고, sharedProfile은 프로젝트 에셋을 직접 가리키기 때문이다. 에셋을 수정하면 에디터가 dirty로 표시하고 디스크에 저장될 수 있다.

패턴 2: 카메라별로 서로 다른 후처리 스택 만들기(메인/미니맵/리플렉션)

미니맵 카메라에 블룸이 들어가면 UI가 번져서 보기 싫다. 리플렉션 카메라에 색보정이 들어가면 반사색이 틀어진다. URP에서는 카메라마다 Volume Layer Mask를 다르게 주는 게 가장 단순하고 예측 가능하다. 같은 씬에 Volume이 공존해도 카메라가 평가할 레이어를 분리하면 스택 충돌이 줄어든다.

구체적인 구성: Global Volume은 Default 레이어, 연출용 Local Volume은 PostFXLocal 레이어, UI 전용 카메라는 Volume Mask에서 Default만 켜고 PostFXLocal은 끈다. 반대로 메인 카메라는 둘 다 켠다. 이렇게 하면 연출 구역 효과가 UI 카메라에는 절대 섞이지 않는다.

CameraVolumeMaskSetter.cs
1using UnityEngine;
2using UnityEngine.Rendering.Universal;
3
4public class CameraVolumeMaskSetter : MonoBehaviour
5{
6    [SerializeField] private Camera targetCamera;
7    [SerializeField] private string[] enableLayers = new[] { "Default", "PostFXLocal" };
8
9    private void Awake()
10    {
11        if (targetCamera == null) targetCamera = GetComponent<Camera>();
12        if (targetCamera == null)
13        {
14            Debug.LogError("CameraVolumeMaskSetter: targetCamera missing");
15            return;
16        }
17
18        if (!targetCamera.TryGetComponent(out UniversalAdditionalCameraData data))
19        {
20            Debug.LogError("CameraVolumeMaskSetter: UniversalAdditionalCameraData missing");
21            return;
22        }
23
24        int mask = 0;
25        foreach (var layerName in enableLayers)
26        {
27            int layer = LayerMask.NameToLayer(layerName);
28            if (layer < 0) continue;
29            mask |= 1 << layer;
30        }
31
32        data.volumeLayerMask = mask;
33        Debug.Log($"CameraVolumeMaskSetter: volumeLayerMask set to {data.volumeLayerMask.value}");
34    }
35}

이 스크립트를 각 카메라에 붙이면 Play 시작 시 마스크가 강제된다. 프리팹 오버라이드가 꼬여서 카메라 마스크가 바뀌는 문제를 줄이는 용도다. 엔진 관점에서는 UniversalAdditionalCameraData가 카메라 렌더 준비 단계에서 참조되는 설정 묶음이고, 마스크가 바뀌면 VolumeManager가 후보 Volume 리스트를 만들 때 필터 결과가 달라진다.

내 흑역사 1: 빌드에서만 블룸이 사라졌다. 원인은 Quality 레벨이 바뀌면서 다른 URP Asset이 선택됐고, 그 URP Asset의 Renderer에서 Post-processing이 꺼져 있었다. 에디터에서는 High, 안드로이드 빌드에서는 Medium이 기본으로 잡혀 있었다. 로그는 없고 화면만 밋밋해져서 원인 찾기가 더 괴로웠다.

내 흑역사 2: 특정 스테이지에서만 프레임이 16ms→28ms로 튀었다. Profiler에서 VolumeManager.Update가 카메라당 1.5ms를 먹고 있었고, 원인은 로컬 볼륨 120개를 모든 레이어에 걸어둔 상태였다. ‘연출 포인트마다 볼륨 하나’ 패턴이 누적된 결과였다. 해결은 레이어 분리(카메라별 필터), 불필요한 볼륨 통합, Blend Distance 최소화, 그리고 카메라 수 줄이기였다.

자주 하는 실수

실수 1: 카메라에서 Post Processing이 꺼져 있음

증상: Volume을 만들고 Bloom Intensity를 10까지 올려도 화면이 전혀 변하지 않는다. Scene 뷰에서는 뭔가 달라 보이는데 Game 뷰는 그대로인 경우도 있다.

원인: URP는 카메라별로 후처리 렌더 패스를 enqueue할지 결정한다. Universal Additional Camera Data의 Render Post-processing이 false면 Volume 스택이 있어도 후처리 패스가 실행되지 않는다.

해결: Main Camera 선택 → Inspector → Universal Additional Camera Data → Rendering → Post Processing 체크를 켠다. 카메라가 프리팹이면 Apply 여부도 확인한다.

실수 2: Volume 레이어와 카메라 Volume Layer Mask가 불일치

증상: Global Volume은 먹는데 Local Volume만 안 먹는다. 혹은 특정 카메라(미니맵)에서만 효과가 없다.

원인: VolumeManager는 카메라가 허용한 레이어만 후보로 넣는다. Volume 오브젝트 레이어가 PostFXLocal인데 카메라 마스크가 Default만 켜져 있으면, 해당 Volume은 존재하지 않는 것처럼 취급된다.

해결: Volume 오브젝트 선택 → Layer 확인. 카메라 선택 → Universal Additional Camera Data → Volume Layer Mask에서 해당 레이어를 켠다. 멀티 카메라면 각 카메라별로 다 확인한다.

실수 3: Local Volume에 Collider 설정이 없거나 크기가 0에 가까움

증상: 박스 안에 들어가도 효과가 안 바뀌거나, 특정 각도에서만 잠깐 바뀐다.

원인: Local Volume은 공간 테스트가 필요하다. Box Volume은 Box Collider bounds를 기준으로 카메라 위치를 평가한다. Collider Size가 작거나 Center가 엉뚱한 곳에 있으면 카메라가 들어가지 않는다.

해결: Local Volume 선택 → Box Collider → Size를 충분히 키운다(예: 10,5,10). Scene 뷰에서 Gizmo로 볼륨 영역을 눈으로 확인한다.

실수 4: Priority/Weight 충돌로 기대한 프로파일이 적용되지 않음

증상: 로컬 구역에서 색보정이 바뀌어야 하는데, 전역 프로파일 값이 계속 유지된다. Weight를 1로 줬는데도 변화가 미미하다.

원인: 후보 Volume이 많으면 priority 정렬 결과로 다른 프로파일이 이긴다. 또 각 Override는 overrideState가 꺼져 있으면 섞이지 않는다. Color Adjustments를 추가했지만 체크박스를 안 켠 상태면 값이 있어도 적용되지 않는다.

해결: Local Volume priority를 전역보다 높게 준다(예: 10). Profile 안에서 각 항목 왼쪽 체크(overrideState)를 켠다. Weight가 0으로 내려가 있지 않은지도 확인한다.

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

증상: Play 중에 연출을 만들었는데, Play 종료 후에도 프로파일 에셋 값이 바뀌어 있다. Git에서 VolumeProfile.asset 변경이 잡힌다.

원인: Volume.sharedProfile은 프로젝트 에셋을 직접 가리킨다. 코드에서 sharedProfile의 컴포넌트 값을 바꾸면 에셋이 dirty가 되고 저장될 수 있다.

해결: sharedProfile을 Instantiate해서 volume.profile에 넣고, 그 인스턴스를 수정한다. 런타임 연출은 항상 복제본을 대상으로 한다.

SharedProfileAccidentDetector.cs
1using UnityEngine;
2using UnityEngine.Rendering;
3
4public class SharedProfileAccidentDetector : MonoBehaviour
5{
6    [SerializeField] private Volume target;
7
8    private void Start()
9    {
10        if (target == null) target = GetComponent<Volume>();
11        if (target == null) return;
12
13        string sp = target.sharedProfile != null ? target.sharedProfile.name : "null";
14        string rp = target.profile != null ? target.profile.name : "null";
15
16        if (target.profile == null && target.sharedProfile != null)
17            Debug.LogWarning($"Only sharedProfile is set ({sp}). Runtime edits will touch the asset unless you clone to volume.profile");
18
19        Debug.Log($"sharedProfile={sp}, runtimeProfile={rp}");
20    }
21}

이 경고가 뜨는 상태에서 런타임 연출 코드를 붙이면 에셋 오염 확률이 높다. 특히 팀 프로젝트에서 누군가가 “왜 내 브랜치에서만 화면 색이 이상하지?”를 겪는 지점이 여기다.

성능 최적화 체크리스트

  • Main Camera의 Universal Additional Camera Data에서 Render Post-processing이 켜져 있는지 확인한다(꺼져 있으면 어떤 Volume도 화면에 반영되지 않는다).
  • 카메라의 Volume Layer Mask와 Volume 오브젝트의 Layer가 AND로 매칭되는지 확인한다(로컬 볼륨이 안 먹는 1순위 원인).
  • Global/Local Volume을 섞어 쓸 때 Priority 규칙을 문서화한다(예: Global=0, Local=10, Cutscene=50).
  • Local Volume의 Collider Size/Center를 Scene 뷰 Gizmo로 확인한다(카메라가 영역에 들어가지 않으면 Weight가 0으로 남는다).
  • Profile의 각 Override 항목에서 왼쪽 체크(overrideState)를 켰는지 확인한다(값만 바꾸고 체크를 안 켜면 적용되지 않는다).
  • 런타임 연출은 sharedProfile을 직접 수정하지 않고 Instantiate 후 volume.profile로 교체한다(에셋 오염 방지).
  • 카메라가 여러 대면(미니맵/리플렉션/씬캡처) 카메라별 Volume Mask를 분리한다(불필요한 스택 갱신과 충돌 감소).
  • Volume 개수가 많은 씬에서 Profiler로 VolumeManager.Update 비용을 확인한다(카메라당 정렬+블렌딩 비용 누적).
  • 프레임 스파이크가 있으면 Debug.Log를 끄고 Development Build + Autoconnect Profiler로 재측정한다(로그가 CPU 시간을 가린다).
  • Update에서 FindObjectOfType/FindObjectsOfType로 Volume을 찾지 않는다(씬 탐색은 프레임당 수백 μs~ms까지 튄다).
  • 후처리 강도를 애니메이션할 때는 매 프레임 프로파일을 Instantiate하지 않는다(Instantiate는 GC와 로딩 스파이크를 만든다).
  • 모바일 타깃이면 Bloom 품질/Downsample을 낮추고, Color Grading LUT/톤매핑 설정을 기기별로 프로파일링한다(후처리는 Fillrate에 민감).

자주 묻는 질문

Volume을 만들었는데 아무 효과도 적용되지 않는다. 어디부터 의심해야 하나?

첫 번째는 카메라 설정이다. Main Camera의 Universal Additional Camera Data에서 Render Post-processing이 false면 URP가 후처리 RenderPass 자체를 enqueue하지 않는다. 두 번째는 Volume Layer Mask다. 카메라가 평가할 레이어에 Volume 오브젝트의 레이어가 포함되지 않으면 VolumeManager가 후보 리스트에 넣지 않는다. 세 번째는 프로파일의 Override 체크 상태다. Bloom를 추가했어도 왼쪽 체크(overrideState)가 꺼져 있으면 스택에 값이 들어가지 않는다. 다음 학습 키워드는 UniversalAdditionalCameraData, VolumeManager, overrideState, ScriptableRenderPass다.

Global Volume과 Local Volume이 동시에 있을 때 어떤 값이 최종 적용되는가?

URP는 카메라 렌더 직전에 후보 Volume들을 모으고(priority로 정렬), weight와 blend distance로 가중치를 계산한 뒤 VolumeStack을 갱신한다. Global은 항상 후보이고, Local은 카메라가 Collider 영역 안(또는 경계 거리)일 때만 후보로 들어온다. 최종 값은 ‘프로파일 단위’가 아니라 ‘각 Override 컴포넌트의 필드 단위’로 overrideState가 켜진 항목만 섞인다. 그래서 Local이 우선순위가 높아도, Local 프로파일에서 해당 항목 체크를 안 켜면 Global 값이 남는다. 다음 학습 키워드는 priority 정렬, blend distance, VolumeStack 합성 규칙이다.

빌드에서만 Post-processing이 사라진다. 에디터에서는 정상이다.

Quality 레벨과 URP Asset 매핑을 먼저 확인한다. Project Settings → Quality에서 플랫폼별로 어떤 Quality가 선택되는지, 그리고 각 Quality가 어떤 Render Pipeline Asset(URP Asset)을 참조하는지 확인한다. 빌드 타깃에서 다른 URP Asset이 적용되면 Renderer 설정이나 후처리 관련 옵션이 달라져 화면이 바뀐다. 그 다음은 카메라 프리팹 오버라이드와 스크립트 초기화 순서다. Awake에서 카메라 설정을 강제하는 스크립트가 있으면 빌드에서만 실행 순서가 달라져 꺼질 수도 있다. 다음 학습 키워드는 Quality Settings, RenderPipelineAsset, Script Execution Order, Development Build 프로파일링이다.

Local Volume 경계에서 효과가 갑자기 튄다. 부드럽게 섞이게 하려면?

Local Volume의 Blend Distance가 0이면 경계에서 weight가 계단처럼 변해 튀는 느낌이 난다. Blend Distance를 1~3m 정도로 주면 카메라가 경계에 접근할 때 URP가 거리 기반으로 weight를 감쇠시키면서 스택이 연속적으로 변한다. 또 Priority가 같은 Volume이 겹치면 정렬 결과에 따라 특정 프레임에서 선택이 바뀌는 것처럼 보일 수 있으니, 겹치는 구역이 있다면 Priority를 명확히 나눈다. 마지막으로 카메라가 물리 이동(캐릭터 컨트롤러)로 프레임마다 위치가 튀면 효과도 튈 수 있으니, 카메라 스무딩(LateUpdate)과 함께 확인한다. 다음 학습 키워드는 blend distance 수식, priority 충돌, 카메라 업데이트 타이밍이다.

런타임에서 색보정 값을 바꾸고 싶다. 어떤 방식이 안전한가?

sharedProfile을 직접 수정하지 않는 방식이 안전하다. sharedProfile은 프로젝트 에셋을 가리켜서, Play 중 수정이 에셋 오염으로 이어질 수 있다. 안전한 패턴은 sharedProfile을 Instantiate해서 volume.profile에 넣고, 그 복제본에서 ColorAdjustments 같은 Override를 TryGet으로 얻어 value를 바꾸는 방식이다. 이때도 매 프레임 Instantiate하면 GC와 스파이크가 생기므로, Awake에서 한 번만 복제하고 이후에는 값만 갱신한다. 다음 학습 키워드는 ScriptableObject 인스턴싱, Volume.profile vs sharedProfile, GC Alloc 원인 추적이다.

Volume이 많으면 왜 CPU가 느려지나? GPU가 느려지는 게 아닌가?

후처리 자체는 GPU 비용(블릿, 풀스크린 패스)이 크지만, Volume이 많을 때의 추가 비용은 CPU에서 먼저 터진다. 카메라 렌더마다 VolumeManager가 후보 Volume을 필터링(레이어), 로컬이면 거리 계산, priority 정렬을 하고, 각 프로파일의 Override들을 스택에 apply한다. Volume 수가 100개, 카메라가 2개면 이 과정이 200번 반복될 수 있다. Profiler에서 Camera.Render 아래에 VolumeManager.Update 또는 비슷한 샘플이 보이면 이 구간이다. 다음 학습 키워드는 SRP 렌더 루프, 카메라별 스택 갱신, 정렬 비용, 멀티 카메라 최적화다.

URP에서 Post-processing은 PlayerLoop의 어느 시점에 실행되나?

MonoBehaviour.Update에서 직접 실행되는 구조가 아니라, 카메라 렌더링 단계에서 SRP가 실행되면서 RenderPass로 처리된다. 일반적인 체감 순서는 Update/LateUpdate가 끝난 뒤, 카메라 이벤트(OnPreCull/OnPreRender) 근처에서 렌더 준비가 되고, URP가 ScriptableRenderer에 패스를 enqueue한 다음 ScriptableRenderPass.Execute에서 커맨드 버퍼를 기록한다. Volume 스택 갱신은 이 렌더 준비 과정에서 카메라 위치·마스크 기준으로 수행되고, 후처리 패스는 갱신된 스택 값을 읽어 셰이더 파라미터를 채운다. 다음 학습 키워드는 ScriptableRenderContext, ScriptableRenderer, ScriptableRenderPass, Camera.Render 호출 흐름이다.

관련 글

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

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

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

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

29. Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기

29. Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기

Unity Prefab Variant를 엔진 직렬화·오버라이드 관점에서 설명하고, 캐릭터/아이템 파생 프리팹을 공통 설정 유지하며 운영하는 실습과 성능 함정을 다룬다. 2022 LTS 기준 실무 패턴 포함.