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

9. 유니티 후처리 기술의 이해와 구현(하) - Bloom & Tone Mapping

by 대마왕J 2026. 1. 14.

 

1. Bloom(블룸) 효과의 시각적 차이와 기본 원리

 

효과의 차이

  • 톤 매핑(Tone Mapping)과 블룸을 모두 껐을 때와 비교하면, 블룸을 켰을 때 빛이 번지는 듯한 효과가 나타납니다.
  • 여기에 톤 매핑까지 추가하면 전체적인 색상의 그라데이션이 훨씬 부드러워지며 질감이 고급스럽게 변합니다.

 

구현의 4단계 원리

  1. 발광 영역 추출: 이미지에서 빛이 나는 부분만 따로 떼어냅니다.
  2. 다운샘플링(Downsampling): 이미지를 축소하며 블러(Blur) 계산을 수행합니다. 이 과정은 이중 블러(Dual Blur) 연산과 유사합니다.
  3. 업샘플링(Upsampling): 이미지를 다시 확대하면서 밝기를 계속해서 중첩시킵니다.
  4. 이미지 합성: 최종적으로 블러 처리된 텍스처를 원본 이미지 위에 겹쳐서 블룸 효과를 완성합니다.

 

 

2. 포토샵을 통한 블룸 시뮬레이션

발광 영역 판단 기준

  • 엔진에서는 임계값(Threshold)을 사용하여 발광 영역을 판단합니다.
  • 예를 들어 임계값이 1이라면, 밝기가 1보다 큰 영역을 발광 구역으로 추출합니다.
  • 이를 위해 화면의 밝기 저장 범위가 1보다 커야 하므로 반드시 HDR(High Dynamic Range) 기능을 활성화해야 합니다. HDR이 꺼지면 블룸 효과를 만들 수 없습니다.

포토샵 에서 시뮬레이트 하는 작업 과정

  • 발광 영역 추출: 이미지에서 밝은 부분만 선택하여 복사합니다.
  • 가우시안 블러 적용: 약 10 정도의 반지름으로 블러를 줍니다.
  • 레이어 혼합: '스크린(Screen)' 모드로 레이어를 겹칩니다.
  • 다층 구조 형성: 이 과정을 여러 번 반복하여 블러의 층을 쌓고 투명도를 조절하면 자연스러운 블룸 효과가 만들어집니다.

3. 엔진 내 실시간 Bloom 구현 과정 

환경 설정 및 리소스 준비

  • HDR 텍스처 사용: 톤 매핑을 거치지 않은 HDR 텍스처를 준비합니다.
  • 샘플링 디코딩: 셰이더에서 HDR 텍스처를 샘플링할 때는 디코딩 작업에 주의해야 합니다.

장면 구성: 이미션(Emission) 수치를 높인 간단한 큐브 등을 배치하여 환경을 만듭니다.

발광 영역 추출 함수(pflight) 작성

  • 컬러 샘플링: 현재 화면의 컬러 값을 가져옵니다.
  • 최대 밝기 계산: RGB 성분 중 가장 큰 값을 max 함수로 추출합니다.
  • 임계값 적용: 최대 밝기 값에서 설정한 임계값을 뺍니다. 결과가 음수가 되지 않도록 0 이상으로 제한(Clamp)합니다.
  • 수치 안정화: 계산된 값을 다시 최대 밝기로 나누어 수치 범위를 안정적으로 제한한 뒤, 원래 컬러에 곱하여 반환합니다.
  • 예외 처리: 분모가 0이 되어 발생하는 오류를 막기 위해 0.0001보다 크게 설정하는 방어 코드를 넣습니다.

// 발광 영역 추출을 위한 셰이더 코드 (Pass 0)
// 소스 3, 4의 로직을 기반으로 작성됨

uniform sampler2D _MainTex;
uniform half4 _MainTex_HDR;
float _Threshold;

fixed4 frag_pflight (v2f i) : SV_Target 
{
    // 1. 컬러 샘플링 및 HDR 디코딩 
    float4 texColor = tex2D(_MainTex, i.uv);
    float3 rgb = DecodeHDR(texColor, _MainTex_HDR);

    // 2. 최대 밝기 계산 
    // RGB 채널 중 가장 밝은 분량을 추출합니다.
    float maxVal = max(rgb.r, max(rgb.g, rgb.b));

    // 3. 임계값 적용 
    // 설정한 임계값보다 밝은 부분만 남기며, 음수가 되지 않도록 0으로 제한합니다.
    float br = max(0, maxVal - _Threshold);

    // 4. 수치 안정화 및 예외 처리 
    // 분모가 0이 되어 발생하는 오류를 방지하기 위해 0.0001을 사용합니다.
    // 최대 밝기로 나누어 수치 범위를 제어합니다.
    br /= max(maxVal, 0.0001);

    // 5. 최종 발광 영역 반환 
    return float4(rgb * br, 1.0);
}

4. 듀얼 카와세 블러(Dual Kawase Blur) 알고리즘

 

다운샘플링(Downsampling) 단계

  • 루프를 돌리는 대신 단계별 이해를 위해 코드를 직접 작성합니다.
  • RT1부터 RT6까지 크기를 1/2, 1/4, 1/8 식으로 줄여나가며 총 5번의 다운샘플링 블러를 수행합니다.

업샘플링(Upsampling) 단계

  • 축소된 이미지를 다시 원래 크기로 확대합니다.
  • 단순히 확대만 하는 것이 아니라, 업샘플링 과정에서 이전 다운샘플링 단계의 동일한 크기 텍스처를 가져와서 합치는 것이 핵심입니다.
  • 이 과정을 통해 블러가 중첩되면서 빛의 번짐이 더욱 풍부해집니다.

렌더 텍스처(RT) 관리 및 메모리 해제

  • 연산 도중 화면이 하얗게 변하거나 깜빡이는 현상은 RT가 제대로 해제되지 않았을 때 발생할 수 있습니다.
  • RT 리스트를 배열로 만들어 관리하고, 연산이 끝나는 시점에 for 루프를 돌려 모든 임시 RT를 반드시 release 해줘야 합니다.

// 듀얼 카와세 블러 연산을 제어하는 C# 코드 예시
// 소스 5, 7의 로직을 기반으로 작성됨

void ApplyDualKawaseBlur(RenderTexture source, RenderTexture destination, Material bloomMaterial) 
{
    int tw = source.width / 2;
    int th = source.height / 2;

    // 1. RT 리스트 생성 및 할당 (메모리 관리용)
    // 5단계 다운샘플링과 업샘플링을 위한 RT들을 준비합니다.
    RenderTexture rt1 = RenderTexture.GetTemporary(tw, th, 0, source.format);
    RenderTexture rt2 = RenderTexture.GetTemporary(tw / 2, th / 2, 0, source.format);
    RenderTexture rt3 = RenderTexture.GetTemporary(tw / 4, th / 4, 0, source.format);
    RenderTexture rt4 = RenderTexture.GetTemporary(tw / 8, th / 8, 0, source.format);
    RenderTexture rt5 = RenderTexture.GetTemporary(tw / 16, th / 16, 0, source.format);
    RenderTexture rt6 = RenderTexture.GetTemporary(tw / 32, th / 32, 0, source.format);

    RenderTexture rt5_up = RenderTexture.GetTemporary(tw / 16, th / 16, 0, source.format);
    RenderTexture rt4_up = RenderTexture.GetTemporary(tw / 8, th / 8, 0, source.format);
    RenderTexture rt3_up = RenderTexture.GetTemporary(tw / 4, th / 4, 0, source.format);
    RenderTexture rt2_up = RenderTexture.GetTemporary(tw / 2, th / 2, 0, source.format);
    RenderTexture rt1_up = RenderTexture.GetTemporary(tw, th, 0, source.format);

    RenderTexture[] rtList = { rt1, rt2, rt3, rt4, rt5, rt6, rt5_up, rt4_up, rt3_up, rt2_up, rt1_up };

    // 2. 다운샘플링 단계 (Pass 1)
    // 크기를 줄여가며 블러를 수행합니다.
    Graphics.Blit(source, rt1, bloomMaterial, 1);
    Graphics.Blit(rt1, rt2, bloomMaterial, 1);
    Graphics.Blit(rt2, rt3, bloomMaterial, 1);
    Graphics.Blit(rt3, rt4, bloomMaterial, 1);
    Graphics.Blit(rt4, rt5, bloomMaterial, 1);
    Graphics.Blit(rt5, rt6, bloomMaterial, 1);

    // 3. 업샘플링 및 중첩 단계 (Pass 2)
    // 확대 시 이전 단계의 다운샘플링 결과물(_BloomTex)을 더해주는 것이 핵심입니다.
    bloomMaterial.SetTexture("_BloomTex", rt5);
    Graphics.Blit(rt6, rt5_up, bloomMaterial, 2);

    bloomMaterial.SetTexture("_BloomTex", rt4);
    Graphics.Blit(rt5_up, rt4_up, bloomMaterial, 2);

    bloomMaterial.SetTexture("_BloomTex", rt3);
    Graphics.Blit(rt4_up, rt3_up, bloomMaterial, 2);

    bloomMaterial.SetTexture("_BloomTex", rt2);
    Graphics.Blit(rt3_up, rt2_up, bloomMaterial, 2);

    bloomMaterial.SetTexture("_BloomTex", rt1);
    Graphics.Blit(rt2_up, rt1_up, bloomMaterial, 2);

    // 최종 결과물을 목적지로 전달 (병합 패스 생략 버전)
    Graphics.Blit(rt1_up, destination);

    // 4. RT 메모리 해제
    // 루프를 돌며 모든 임시 RT를 해제하여 화면 오류를 방지합니다.
    for (int i = 0; i < rtList.Length; i++) 
    {
        RenderTexture.ReleaseTemporary(rtList[i]);
    }
}
// 듀얼 카와세 블러 셰이더 패스 정의
// 소스 6, 8의 로직을 기반으로 작성됨

sampler2D _BloomTex; // 업샘플링 시 합쳐질 이전 단계의 RT

// Pass 1: 다운샘플링 블러
fixed4 frag_downsample (v2f i) : SV_Target 
{
    // 주변 4개 픽셀을 샘플링하여 평균을 내는 방식으로 블러를 수행합니다.
    float2 offset = _MainTex_TexelSize.xy;
    fixed4 color = tex2D(_MainTex, i.uv + float2(-offset.x, -offset.y));
    color += tex2D(_MainTex, i.uv + float2(offset.x, -offset.y));
    color += tex2D(_MainTex, i.uv + float2(-offset.x, offset.y));
    color += tex2D(_MainTex, i.uv + float2(offset.x, offset.y));
    return color * 0.25;
}

// Pass 2: 업샘플링 블러 및 합산
fixed4 frag_upsample (v2f i) : SV_Target 
{
    // 현재 확대 중인 이미지의 블러 결과와 이전 단계의 다운샘플링 결과(_BloomTex)를 더합니다.
    fixed4 currentBlur = tex2D(_MainTex, i.uv); // 현재 블러 결과
    fixed4 previousRT = tex2D(_BloomTex, i.uv); // 다운샘플링 과정의 대응 텍스처
    
    return currentBlur + previousRT;
}

5. 최종 병합 및 최적화

병합(Combine) 패스 구현

  • 마지막 단계에서도 결과물을 부드럽게 만들기 위해 가벼운 평균 블러를 한 번 더 적용합니다.
  • 최종 컬러 결정: 원본 이미지(Base Color)에 블러 처리된 블룸 이미지(Bloom Color)를 더합니다.
  • 강도(Intensity) 조절: 외부에서 광후의 세기를 조절할 수 있도록 변수를 곱해줍니다.

트러블슈팅: 텍스처 포맷과 곡선 조절

  • RT 포맷 설정: 일반 ARGB 포맷은 정보를 소실할 수 있으므로, RT 생성 시 DefaultHDR 또는 ARGBHalf 포맷을 사용하여 HDR 정보를 유지해야 합니다.
  • 강도 재매핑: 강도 수치가 너무 민감하게 반응하지 않도록 유니티 내장 공식을 활용하여 곡선 형태로 부드럽게 조절되도록 개선합니다.


// 최적화된 병합 처리를 위한 C# 코드
// 소스 10의 로직을 기반으로 작성됨

public class BloomOptimizer : MonoBehaviour 
{
    public Material bloomMaterial;
    [Range(0, 5)] public float intensity = 1.0f;

    void OnRenderImage(RenderTexture source, RenderTexture destination) 
    {
        // 1. RT 포맷 설정: 정보 소실을 막기 위해 원본의 HDR 포맷을 따르거나 명시적으로 설정합니다.
        // 소스 10: DefaultHDR 또는 ARGBHalf 사용 권장
        RenderTextureFormat format = source.format; 
        RenderTexture bloomRT = RenderTexture.GetTemporary(source.width, source.height, 0, format);

        // 2. 강도 재매핑: 슬라이더 조절을 더 직관적으로 만듭니다.
        // 유니티 내장 곡선 로직을 참고하여 부드러운 변화를 유도합니다.
        float softIntensity = Mathf.GammaToLinearSpace(intensity); 
        bloomMaterial.SetFloat("_Intensity", softIntensity);

        // 3. 병합 패스 실행
        // 원본(source)과 블룸 데이터가 담긴 RT를 전달하여 합성합니다.
        bloomMaterial.SetTexture("_BloomColor", bloomRT);
        Graphics.Blit(source, destination, bloomMaterial, 3);

        RenderTexture.ReleaseTemporary(bloomRT);
    }
}
// 최종 병합을 위한 셰이더 코드 (Pass 3)
// 소스 8, 9의 로직을 기반으로 작성됨

sampler2D _MainTex;    // 원본 이미지 (Base Color)
sampler2D _BloomColor; // 블러 처리된 블룸 이미지
float _Intensity;      // 블룸 강도

fixed4 frag_combine (v2f i) : SV_Target 
{
    // 1. 원본 이미지 샘플링 (가벼운 블러를 추가로 적용할 수 있음)
    float3 baseColor = tex2D(_MainTex, i.uv).rgb;

    // 2. 블룸 이미지 샘플링
    float3 bloomColor = tex2D(_BloomColor, i.uv).rgb;

    // 3. 최종 컬러 결정: 원본 + (블룸 * 강도)
    // 소스 8에서 설명된 합산 공식을 적용합니다.
    float3 finalColor = baseColor + (bloomColor * _Intensity);

    return float4(finalColor, 1.0);
}

6. ACES Tone Mapping 기술

 

필요성

  • 블룸 연산을 거친 화면은 여전히 HDR 상태이므로, 이를 일반 디스플레이에서 볼 수 있는 LDR(Low Dynamic Range) 범위로 자연스럽게 변환해야 합니다.

공식 및 과정

  1. 선형 공간 변환: 입력 컬러에 2.2 거듭제곱(Power)을 하여 Linear Space로 바꿉니다.
  2. ACES 근사 곡선 적용: 수학적으로 모델링된 ACES 공식을 적용하여 밝은 영역과 어두운 영역의 대비를 조절합니다.
  3. 감마 공간 복원: 다시 원래의 Gamma Space로 변환하여 출력합니다.
  4. 주의사항: 강의에서 사용한 공식은 실제 ACES 계산을 근사한 5개 매개변수 기반 곡선입니다. 더 정밀한 제어를 원한다면 UE4의 전체 코드를 참고하는 것이 좋습니다.


// ACES 톤 매핑 셰이더 코드
// 소스 13의 ACES 근사 곡선 로직 및 이미지 코드 기반

Shader "Custom/ACES_Tonemapping"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }

    SubShader
    {
        // 컬링이나 깊이 테스트를 수행하지 않는 설정 (이미지 코드 참고)
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert_img
            #pragma fragment frag
            #include "UnityCG.cginc"

            sampler2D _MainTex;

            // ACES 톤 매핑 근사 공식 함수
            float3 ACES_Tonemapping(float3 x)
            {
                // 5개의 근사 매개변수
                float a = 2.51f;
                float b = 0.03f;
                float c = 2.43f;
                float d = 0.59f;
                float e = 0.14f;

                // saturate를 사용하여 0~1 사이로 값을 제한하며 근사 공식을 적용
                return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
            }

            half4 frag (v2f_img i) : SV_Target
            {
                // 1. 메인 텍스처 컬러 샘플링
                half4 col = tex2D(_MainTex, i.uv);

                // 2. 선형 공간 변환 (Linear Space)
                half3 linear_color = pow(col.rgb, 2.2);

                // 3. ACES 톤 매핑 근사 곡선 적용
                half3 encode_color = ACES_Tonemapping(linear_color);

                // 4. 감마 공간 복원 (Gamma Space)
                half3 final_color = pow(encode_color, 1.0 / 2.2);

                return float4(final_color, col.a);
            }
            ENDCG
        }
    }
}

7. 최종 요약 및 후처리 순서

전체 과정 복습

  1. 다양한 사이즈의 RT 신청
  2. 발광 영역 임계값 추출 (0번 패스)
  3. 다운샘플링 블러 수행
  4. 업샘플링 블러 수행 및 이전 단계 RT 중첩
  5. 원본과 광후 이미지 합치기 (최종 패스)

올바른 후처리 순서

  • 반드시 블룸 계산을 먼저 완료한 뒤에 톤 매핑을 수행해야 합니다.
  • 만약 톤 매핑을 먼저 하면 HDR 정보가 사라져 블룸 연산에 필요한 데이터가 소실되기 때문입니다.
  • 이후 비네트(Vignette)나 일반적인 색상 보정(Color Correction/LDR 공간)을 진행합니다.
  • 일반적인 파이프라인: Bloom -> Tone Mapping -> Color Correction.

반응형

댓글