본문 바로가기
자료는 자료지/외부강의 정리방

8. 유니티 후처리 기술의 이해와 구현 (중)

by 대마왕J 2026. 1. 13.

후처리 기술의 핵심 중 하나인 블러 알고리즘은 화면에 부드러운 효과를 주거나 광원 효과를 극대화하는 데 사용됩니다. 본 가이드에서는 다양한 블러 방식의 원리와 유니티에서의 구현 및 최적화 단계를 상세히 설명합니다.


1. 블러 알고리즘의 기초와 종류

블러 알고리즘은 이미지를 흐리게 만드는 방식에 따라 여러 종류로 나뉩니다.

  • 균등 블러 (Box Blur): 가장 단순한 방식으로, 주변 픽셀의 평균값을 사용합니다. 반경이 커지면 잔상이 심하게 남는 단점이 있습니다.
  • 가우시안 블러 (Gaussian Blur): 거리별 가중치를 적용하여 박스 블러보다 부드러운 품질을 제공합니다.
  • 이중 박스 블러 (Dual Box Blur): 다운샘플링과 업샘플링을 결합하여 고품질과 고성능을 동시에 잡은 방식입니다.
  • 카와세 블러 (Kawase Blur): 모바일 환경에 최적화된 고성능 알고리즘입니다.

2. 컨볼루션 필터링의 원리

모든 블러의 기반은 컨볼루션 필터링입니다.

  • 커널 (Kernel): 특정 픽셀을 처리할 때 참조하는 사각형 영역입니다.
  • 계산 방식: 현재 픽셀을 중심으로 주변 픽셀들을 샘플링한 뒤, 각 픽셀에 할당된 가중치를 곱하고 모두 더합니다.
  • 가중치 예시: 3x3 박스 블러의 경우 모든 픽셀에 9분의 1의 가중치를 부여하며, 가우시안 블러는 가우시안 함수 분포에 따라 중앙 가중치를 높게 설정합니다.


3. 유니티 셰이더 기초 구현 (2x2 박스 블러)

실제 셰이더에서 인접 픽셀을 샘플링하기 위해서는 한 픽셀의 크기인 텍셀 사이즈(Texel Size)를 알아야 합니다.

  • _TexelSize 변수: 유니티에서 제공하는 내장 변수로, x 분량은 1/너비를 의미하여 정확한 픽셀 오프셋을 제공합니다.
  • 샘플링 로직: 현재 UV에 오프셋을 더해 4개 지점을 샘플링하고 0.25를 곱해 평균을 냅니다.
  • 오프셋 조절: blur offset 변수를 통해 블러의 강도를 조절할 수 있습니다.

Shader "Hidden/BoxBlur"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _BlurOffset ("Blur Offset", Float) = 1.0
    }
    SubShader
    {
        // 후처리 효과를 위해 컬링과 깊이 테스트를 해제합니다. 
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag

            #include "UnityCG.cginc"

            sampler2D _MainTex;
            // 유니티 내장 변수: 텍셀 사이즈 (x: 1/width, y: 1/height) 
            float4 _MainTex_TexelSize;
            float _BlurOffset;

            half4 frag (v2f_img i) : SV_Target
            {
                // _MainTex_TexelSize.xyxy를 활용해 네 방향의 UV 오프셋을 계산합니다. 
                // d.xy = (-1, -1), d.zy = (1, -1), d.xw = (-1, 1), d.zw = (1, 1) 조합이 생성됩니다.
                half4 d = _MainTex_TexelSize.xyxy * half4(-1, -1, 1, 1) * _BlurOffset;

                half4 s = 0;
                // 현재 UV 좌표를 중심으로 대각선 방향의 4개 지점을 샘플링합니다. 
                s += tex2D(_MainTex, i.uv + d.xy);
                s += tex2D(_MainTex, i.uv + d.zy);
                s += tex2D(_MainTex, i.uv + d.xw);
                s += tex2D(_MainTex, i.uv + d.zw);

                // 4개 지점의 합에 0.25를 곱하여 평균값을 반환합니다. 
                return s * 0.25;
            }
            ENDCG
        }
    }
}

4. 스크립트를 활용한 반복 처리와 핑퐁 구조

단일 연산보다 여러 번의 반복 처리가 고품질 블러를 만듭니다.

  • 핑퐁 구조: RenderTexture.GetTemporary로 두 개의 임시 렌더 텍스처(RT1, RT2)를 생성하여 서로의 결과를 주고받으며 연산합니다.
  • 메모리 관리: 사용이 끝난 임시 텍스처는 ReleaseTemporary를 통해 반드시 해제하여 메모리 누수를 방지해야 합니다.

using UnityEngine;

[ExecuteInEditMode]
public class BlurEffect : MonoBehaviour
{
    public Material blurMaterial; // 블러 셰이더가 적용된 머티리얼 
    
    [Range(0, 10)]
    public int iterations = 4; // 반복 횟수 
    
    [Range(0.2f, 3.0f)]
    public float blurRadius = 1.0f; // 블러 반경 
    
    [Range(1, 8)]
    public int downsample = 2; // 다운샘플링 계수 

    void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (blurMaterial != null)
        {
            // 1. 초기 해상도 설정 (다운샘플링 반영)
            [cite_start]// 연산량을 줄이기 위해 원본 해상도를 나누어 시작합니다.
            int rtW = source.width / downsample;
            int rtH = source.height / downsample;

            [cite_start]// 2. 첫 번째 임시 렌더 텍스처(RT1) 생성 
            RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
            buffer0.filterMode = FilterMode.Bilinear;

            // 원본 이미지를 낮은 해상도의 buffer0로 복사하며 시작합니다.
            Graphics.Blit(source, buffer0);

            [cite_start]// 3. 핑퐁(Ping-pong) 반복 연산 
            for (int i = 0; i < iterations; i++)
            {
                [cite_start]// 셰이더에 블러 반경 전달 
                blurMaterial.SetFloat("_BlurOffset", blurRadius);

                [cite_start]// 두 번째 임시 렌더 텍스처(RT2) 생성 
                RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);

                [cite_start]// buffer0의 내용을 블러 처리하여 buffer1에 그립니다.
                Graphics.Blit(buffer0, buffer1, blurMaterial);

                [cite_start]// 사용이 끝난 buffer0는 즉시 해제하여 메모리 누수를 방지합니다.
                RenderTexture.ReleaseTemporary(buffer0);

                [cite_start]// 다음 루프를 위해 buffer1을 buffer0로 교체합니다 (핑퐁 구조).
                buffer0 = buffer1;
            }

            // 4. 최종 결과물을 목적지(Destination)로 출력합니다.
            Graphics.Blit(buffer0, destination);

            [cite_start]// 마지막으로 남아있는 임시 텍스처를 해제합니다.
            RenderTexture.ReleaseTemporary(buffer0);
        }
        else
        {
            [cite_start]// 머티리얼이 없을 경우 원본을 그대로 출력합니다[cite: 9].
            Graphics.Blit(source, destination);
        }
    }
}

5. 성능 최적화 기법: 다운샘플링과 커널 분리

실시간 게임 환경에서는 성능이 매우 중요합니다.

  • 다운샘플링 (Downsampling): 해상도를 절반으로 줄여 연산하면 계산량이 4배 줄어들며, 더 부드러운 블러 결과를 얻을 수 있습니다.
  • 분리 가능한 가우시안 블러 (Separable Gaussian Blur): 5x5 커널(25회 샘플링)을 가로 1x5와 세로 5x1(총 10회 샘플링) 패스로 나누어 연산 횟수를 획기적으로 줄입니다.

6. 고수준 블러 기법: 듀얼 블러 (Dual Blur)

광원 효과(Bloom)에 가장 적합한 방식은 듀얼 블러입니다.

  • 프로세스: 이미지를 작게 만드는 다운샘플링 단계와 다시 크게 만드는 업샘플링 단계를 반복하며 매 단계마다 블러를 적용합니다.
  • 장점: 가우시안 블러에서 발생하는 줄무늬 아티팩트가 거의 없으며, 매우 부드럽고 넓은 반경의 블러를 빠르게 얻을 수 있습니다.

Shader "Hidden/SeparableGaussian"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _BlurOffset ("Blur Offset", Float) = 1.0
    }
    SubShader
    {
        // 후처리 효과를 위한 기본 설정: 컬링 끄기, 깊이 기록/테스트 무시
        Cull Off ZWrite Off ZTest Always

        CGINCLUDE
        #include "UnityCG.cginc"
        
        sampler2D _MainTex;
        // 유니티가 제공하는 텍셀 크기 (x: 1/width, y: 1/height, z: width, w: height)
        float4 _MainTex_TexelSize;
        float _BlurOffset;

        // 가우시안 블러 연산 함수
        // uv: 현재 픽셀 좌표
        // direction: 블러 방향 (가로: 1,0 / 세로: 0,1)
        half4 GaussianBlur(float2 uv, float2 direction)
        {
            // 중앙 픽셀 샘플링 (가장 높은 가중치 적용)
            half4 col = tex2D(_MainTex, uv) * 0.4;
            
            // 인접 픽셀 (가까운 거리): 가중치 0.25
            // _MainTex_TexelSize.xy를 사용하여 정확히 1픽셀 단위를 계산합니다.
            col += tex2D(_MainTex, uv + direction * _MainTex_TexelSize.xy * 1.0 * _BlurOffset) * 0.25;
            col += tex2D(_MainTex, uv - direction * _MainTex_TexelSize.xy * 1.0 * _BlurOffset) * 0.25;
            
            // 외곽 픽셀 (먼 거리): 가중치 0.05
            col += tex2D(_MainTex, uv + direction * _MainTex_TexelSize.xy * 2.0 * _BlurOffset) * 0.05;
            col += tex2D(_MainTex, uv - direction * _MainTex_TexelSize.xy * 2.0 * _BlurOffset) * 0.05;
            
            return col;
        }
        ENDCG

        // Pass 0: 가로 방향 블러 (Horizontal Pass)
        Pass
        {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag
            half4 frag (v2f_img i) : SV_Target 
            { 
                return GaussianBlur(i.uv, float2(1, 0)); 
            }
            ENDCG
        }

        // Pass 1: 세로 방향 블러 (Vertical Pass)
        Pass
        {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag
            half4 frag (v2f_img i) : SV_Target 
            { 
                return GaussianBlur(i.uv, float2(0, 1)); 
            }
            ENDCG
        }
    }
}
using UnityEngine;

[ExecuteInEditMode]
public class GaussianBlurEffect : MonoBehaviour
{
    public Material blurMaterial;
    [Range(0, 10)] public int iterations = 3;
    [Range(0, 5)] public float blurRadius = 1.0f;
    [Range(1, 8)] public int downsample = 2;

    void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (blurMaterial == null) { Graphics.Blit(source, destination); return; }

        // 1. 다운샘플링을 통한 초기 연산 해상도 축소
        int rtW = source.width / downsample;
        int rtH = source.height / downsample;
        RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0);
        buffer0.filterMode = FilterMode.Bilinear;

        // 원본 이미지를 저해상도 버퍼로 복사
        Graphics.Blit(source, buffer0);

        // 2. 반복 루프 (가로 패스 -> 세로 패스 순차 실행)
        for (int i = 0; i < iterations; i++)
        {
            blurMaterial.SetFloat("_BlurOffset", blurRadius);
            
            // 세로 연산 결과를 담을 중간 버퍼 생성
            RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
            
            // Pass 0 실행: 가로 블러 (buffer0 -> buffer1)
            Graphics.Blit(buffer0, buffer1, blurMaterial, 0); 
            
            // 이전 버퍼 해제 후 다음 연산을 위해 교체
            RenderTexture.ReleaseTemporary(buffer0);
            buffer0 = buffer1;

            // Pass 1 실행: 세로 블러 (buffer0 -> 새로운 buffer1)
            buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
            Graphics.Blit(buffer0, buffer1, blurMaterial, 1);
            
            RenderTexture.ReleaseTemporary(buffer0);
            buffer0 = buffer1;
        }

        // 최종 결과 출력 및 잔여 메모리 해제
        Graphics.Blit(buffer0, destination);
        RenderTexture.ReleaseTemporary(buffer0);
    }
}
반응형

댓글