Post-Processing Stack으로 만드는 동적 스크린 효과 연출

후처리 효과를 단순한 화면 보정이 아니라 게임 상태를 전달하는 연출 도구로 사용하는 방법을 정리한다. 피해, 위험, 집중, 순간 이동 같은 상황에 맞춰 효과를 안정적으로 전환하는 설계와 구현 예제를 다룬다.

✓ 복사 완료!
챕터셰이더 프로그래밍 (HLSL/ShaderGraph)
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Post-Processing Stack으로 만드는 동적 스크린 효과 연출

후처리는 화면 효과가 아니라 상태 전달 수단이다

Post-Processing Stack은 최종 렌더링 결과에 색 보정, 블룸, 비네트, 색수차, 심도 효과 등을 적용하는 시스템이다. 이 효과들은 화면을 보기 좋게 만드는 데만 쓰이지 않는다. 플레이어가 UI를 읽기 전에 위험도, 체력 상태, 시간 흐름의 변화 같은 정보를 빠르게 느끼게 하는 데도 유용하다.

예를 들어 체력이 낮을 때 화면 가장자리를 붉게 물들이고 적의 강력한 공격 직전에 채도를 낮추며 대시 순간에 짧은 색수차를 넣을 수 있다. 중요한 점은 효과를 개별 이벤트에 직접 묶지 않고 게임 상태를 해석한 결과로 다뤄야 한다는 것이다.

flowchart LR
    A[게임 이벤트] --> B[상태 값 갱신]
    B --> C[연출 규칙 평가]
    C --> D[목표 후처리 파라미터]
    D --> E[보간 및 적용]
    E --> F[최종 화면]

이 구조를 따르면 피격과 저체력이 동시에 발생해도 어느 효과를 우선할지 한곳에서 결정할 수 있다. 또한 씬 전환, 일시 정지, 컷신처럼 게임 흐름이 바뀌는 상황에서도 효과를 자연스럽게 정리하기 쉽다.

먼저 효과의 역할과 우선순위를 정한다

효과를 고르기 전에 화면이 전달해야 할 메시지를 한 문장으로 정하는 편이 좋다. 같은 비네트라도 목적에 따라 강도와 변화 속도가 달라진다.

  • 지속 위험: 저체력, 독, 산소 부족처럼 오래 유지되는 상태
  • 순간 충격: 피격, 폭발, 패링 실패처럼 짧게 튀는 사건
  • 감각 변화: 슬로 모션, 집중 모드, 환각처럼 세계의 인상을 바꾸는 상태
  • 전환 강조: 사망, 부활, 텔레포트, 컷신 진입처럼 장면 경계를 알리는 상태

지속 위험과 순간 충격을 같은 변수로 처리하면 문제가 생긴다. 저체력 비네트가 유지되는 중에 피격 플래시가 발생했을 때, 피격이 끝난 뒤 저체력 효과가 사라지거나 값이 과도하게 누적될 수 있다. 따라서 효과를 최소한 기본 상태, 지속 상태, 순간 펄스로 나눠 합성하는 것이 안전하다.

비용과 가독성을 함께 고려한다

후처리는 화면 전체를 대상으로 실행되는 경우가 많으므로 강한 효과를 여러 개 겹치면 GPU 비용과 화면 가독성이 함께 나빠질 수 있다. 특히 고해상도와 모바일 환경에서는 블러, 심도 효과, 여러 번의 블릿 패스가 부담이 될 수 있다.

실무적으로는 다음 기준이 도움이 된다.

  • 게임 플레이 중에는 한 번에 전달할 감정을 하나 또는 둘로 제한한다.
  • 색수차와 강한 블러는 짧은 시간만 사용한다.
  • UI와 조준점이 읽혀야 하는 장르에서는 화면 중심부를 과하게 훼손하지 않는다.
  • 블룸은 광원 강조에 쓰고 피해 표현을 전부 블룸으로 해결하려 하지 않는다.
  • 품질 설정별로 효과를 끌 수 있는 대체 경로를 준비한다.

저체력 상태에서 화면 가장자리만 붉게 강조하고 중앙 시야는 유지하는 비네트 연출 예시

Unity에서 상태 기반으로 파라미터 제어하기

Unity의 Post Processing Stack v2에서는 PostProcessVolume의 프로필을 직접 변경할 수 있다. 다만 여러 오브젝트가 같은 프로필 에셋을 공유한다면 런타임 변경이 다른 볼륨에도 영향을 줄 수 있다. 실행 중에 조작할 프로필은 인스턴스로 분리하거나 프로젝트가 URP/HDRP라면 Volume 시스템의 현재 버전에 맞는 API를 사용해야 한다.

아래 예제는 저체력 비네트와 피격 펄스를 분리한 단순한 컨트롤러다. health01은 0에서 1 사이의 정규화된 체력 값이다.

using System.Collections;
using UnityEngine;
using UnityEngine.Rendering.PostProcessing;

public class ScreenEffectController : MonoBehaviour
{
    [SerializeField] private PostProcessVolume volume;
    [SerializeField] private float lowHealthThreshold = 0.35f;
    [SerializeField] private float healthSmoothing = 6f;

    private Vignette vignette;
    private ChromaticAberration chromaticAberration;
    private float damagePulse;

    private void Awake()
    {
        if (!volume.profile.TryGetSettings(out vignette) ||
            !volume.profile.TryGetSettings(out chromaticAberration))
        {
            Debug.LogError("Vignette와 Chromatic Aberration 설정이 필요합니다.");
            enabled = false;
        }
    }

    private void Update()
    {
        float health01 = GetNormalizedHealth();
        float lackOfHealth = Mathf.InverseLerp(lowHealthThreshold, 0f, health01);

        float targetVignette = Mathf.Clamp01(lackOfHealth * 0.45f + damagePulse * 0.25f);
        vignette.intensity.value = Mathf.Lerp(
            vignette.intensity.value,
            targetVignette,
            1f - Mathf.Exp(-healthSmoothing * Time.deltaTime));

        chromaticAberration.intensity.value = Mathf.Clamp01(damagePulse * 0.6f);
        damagePulse = Mathf.MoveTowards(damagePulse, 0f, Time.deltaTime * 2.5f);
    }

    public void PlayDamagePulse(float strength = 1f)
    {
        damagePulse = Mathf.Max(damagePulse, Mathf.Clamp01(strength));
    }

    private float GetNormalizedHealth()
    {
        // 실제 프로젝트의 체력 컴포넌트 값으로 교체한다.
        return 1f;
    }
}

Mathf.Exp를 이용한 보간은 프레임률이 달라져도 비교적 일관된 감쇠 속도를 만든다. 단순히 Lerp(current, target, speed * deltaTime)을 쓰는 방식도 가능하지만 보간 계수가 1을 넘지 않도록 별도 처리가 필요할 수 있다.

펄스는 시간, 지속 효과는 상태로 관리한다

피격 효과를 코루틴으로 구현할 때 가장 흔한 실수는 동시에 여러 코루틴이 동일한 파라미터를 쓰게 만드는 것이다. 마지막으로 실행된 코루틴이 값을 덮어쓰면서 효과가 깜빡이거나 중간 값에서 멈출 수 있다.

피격처럼 짧은 연출은 시간에 따른 펄스 함수로 만들고 새 이벤트가 오면 최대 강도만 갱신하는 방식이 단순하다. 예를 들어 정규화 시간 t가 0에서 1까지 진행될 때 빠르게 올라갔다 내려오는 값은 다음처럼 만들 수 있다.

pulse(t)=sin⁡(πt)pulse(t)=\sin(\pi t)

하지만 실제 게임에서는 수학식보다 연출 곡선이 더 중요할 때가 많다. Unity에서는 AnimationCurve를 노출해 디자이너가 상승과 감쇠를 조정하도록 만들면 피격감 튜닝이 훨씬 편해진다.

[SerializeField] private AnimationCurve damageCurve =
    AnimationCurve.EaseInOut(0f, 0f, 1f, 0f);

public float EvaluateDamagePulse(float elapsed, float duration)
{
    if (duration <= 0f)
        return 0f;

    float t = Mathf.Clamp01(elapsed / duration);
    return damageCurve.Evaluate(t);
}

커스텀 셰이더가 필요한 경우

기본 볼륨 효과만으로도 많은 연출을 만들 수 있지만 화면 중심에서 퍼지는 충격파나 특정 위치를 기준으로 한 왜곡처럼 공간 정보가 필요한 효과는 커스텀 패스가 적합하다. 이때 월드 좌표를 그대로 화면에 투영하기보다 뷰포트 좌표로 변환해 셰이더에 전달한다.

다음 HLSL 조각은 중심점에서 멀어질수록 약해지는 간단한 왜곡 마스크의 핵심이다.

float2 toCenter = uv - _ImpactCenter.xy;
float distanceToCenter = length(toCenter);
float ring = 1.0 - smoothstep(_Radius - _Width, _Radius, distanceToCenter);
float2 direction = normalize(toCenter + 0.0001);
float2 distortedUv = uv + direction * ring * _DistortionStrength;
float4 color = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, distortedUv);

_ImpactCenter는 0에서 1 범위의 뷰포트 좌표여야 한다. 화면 밖에 있는 충격점이나 카메라 뒤쪽의 좌표를 처리하지 않으면 왜곡이 화면 가장자리에서 갑자기 나타나는 문제가 생길 수 있으므로 적용 전에 좌표 유효성을 검사하는 것이 좋다.

화면상의 충격 위치를 중심으로 원형 왜곡이 퍼지는 커스텀 후처리 효과 예시

디버깅 체크리스트

후처리 연출이 의도대로 보이지 않을 때는 값부터 무작정 키우기보다 다음 항목을 확인한다.

  • 볼륨의 레이어와 카메라의 Volume Mask가 일치하는가
  • 효과 컴포넌트가 활성화되어 있고 override 값이 켜져 있는가
  • 전역 볼륨과 로컬 볼륨이 예상한 우선순위와 가중치로 섞이는가
  • 공유 프로필을 수정해 다른 씬이나 카메라까지 바꾸고 있지 않은가
  • 일시 정지 중에도 효과가 진행되어야 한다면 Time.deltaTime 대신 Time.unscaledDeltaTime이 필요한가
  • 색 보정 효과가 UI 전후 어느 단계에서 적용되는가

마무리

좋은 동적 후처리 효과는 화려함보다 타이밍과 일관성에서 나온다. 게임 이벤트가 발생할 때마다 개별 효과를 재생하기보다 현재 게임 상태와 순간 이벤트를 분리해 목표 파라미터를 계산하자. 그러면 효과가 겹치는 상황에서도 화면은 안정적으로 유지되고 플레이어에게 필요한 감정과 정보를 더 정확하게 전달할 수 있다.