개요

SpriteRenderer 오브젝트 개별로 블러 효과를 다르게 적용하는 것을 목표로 URP 렌더피처 및 쉐이더 개발 과정을 소개합니다.

 

개발 환경

  • Unity 2021.3.30f1
  • URP 12.1.12

핵심 요소

  1. 다운 샘플링 블러 - 저비용 고품질 블러 효과
  2. 레이어 필터 프리패스 블러 - 원하는 오브젝트만 필터링하여 블러 처리

GitHub 샘플 프로젝트

https://github.com/CatDarkGame/LayerFilterSpriteBlur

 

GitHub - CatDarkGame/LayerFilterSpriteBlur: Unity URP, Blur effect for each spriterenderer

Unity URP, Blur effect for each spriterenderer. Contribute to CatDarkGame/LayerFilterSpriteBlur development by creating an account on GitHub.

github.com

 

 


1. 다운 샘플링 블러 - 저비용 고품질 블러 효과

개발 목표는 모바일 환경까지 고려해 퍼포먼스 비용이 높지 않아야 하며, 블러 품질이 좋아야합니다.

이를 위해 우선 블러를 표현하기 위한 여러가지 방법을 시도했습니다.

 

가우시안 블러 + Mipmap 샘플링

가우시안 블러는 주변 픽셀로 UV Offset 샘플링을 반복하여 블러를 표현하는 방식으로 가장 보편적으로 사용하는 블러 알고리즘입니다. 가우시안 블러는 만족스러운 블러 퀄리티를 표현하기 위해서는 샘플링 반복 횟수와 패스 스탭이 높여야합니다, 하지만 포스트프로세스가 아닌 SpriteRenderer에 블러 효과를 적용해야하기 때문에 이와 같은 방식을 그대로 적용하기는 어렵습니다.

그래서 1 Pass 가우시안 블러 9x9 방식에 Mipmap 샘플링하는 방식으로 블러를 표현해봤습니다.

 

 float GaussianWeight(int x, int y)
 {
     float sigma = 1.0;
     float norm = 1.0 / (2.0 * 3.141592 * sigma * sigma);
     float expPart = exp(-((x * x + y * y) / (2.0 * sigma * sigma)));
     return norm * expPart;
 }
 
 half4 frag (Varyings i) : SV_TARGET 
 {
     float2 uv = i.uv * _MainTex_ST.xy + _MainTex_ST.zw;
     half4 baseMap = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv); 
     half4 blurMap = baseMap;
     for (int x = -4; x <= 4; x++)
     {
         for (int y = -4; y <= 4; y++)
         {
             float weight = GaussianWeight(x, y);
             blurMap += SAMPLE_TEXTURE2D_LOD(_MainTex, sampler_MainTex, uv + (float2(x, y) * _Offset) * _MainTex_TexelSize, _Amount * 3) * weight;
         }
     }

     return lerp(baseMap, blurMap, _Amount);
}

 

의도한대로 블러가 유의미하게 표현되지만 목표한 블러 퀄리티에 비하면 부족하고 각 Sprite별로 82번 샘플링하는 것 자체가 GPU 성능 저하가 발생합니다.

목표 블러 퀄리티(포토샵 제작)

 

 

다른 블러 에셋 분석 - Translucent Image

유니티 에셋 스토어에서 판매하는 블러 에셋을 분석했습니다. 이 에셋은 모바일 WebGL에서도 구동 될 만큼 성능이 안정적이면서 블러 퀄리티도 매우 뛰어납니다. (아래 링크에서 WebGL 확인 가능)

https://leloctai.com/asset/translucentimage/index.html

 

이 에셋의 렌더링 구조는 위와 같습니다. 핵심 블러 알고리즘은 다운샘플링 + 스탭당 Box블러처리 방식으로 의외로 복잡한 알고리즘으로 구현되지 않았습니다.

블러 처리 외에 커스텀 렌더피처 코드 구조가 잘 설계되어 있고 버퍼 업데이트 간격 조절 등 배울 만한 요소가 많은 에셋입니다.

 

쉐이더 연산은 복잡하지 않고 대부분 다운 샘플링 처리에 의존하는 방식이기 때문에 안드로이드 기기(갤럭시s10e) 프로파일링 결과 GPU보다 CPU 비용이 더 높게 측정되었습니다.

  CPU 렌더쓰레드 Gfx.PresentFrame (GPU Time)
Blur 활성화 1.8ms ~ 2.5ms 3.8ms ~ 4.3ms
Blur 비활성화 0.9ms ~ 1.3ms 3.2ms ~ 3.9ms

 

 

 


2. 레이어 필터 프리패스 블러 - 원하는 오브젝트만 필터링하여 블러 처리

전략

  1. 프리패스 버퍼에 특정 레이어 오브젝트 렌더링 (SpriteRenderer 1번 패스)
  2. 프리패스 버퍼를 다운 샘플링 블러 처리 (위에서 소개한 블러 에셋 방식 적용)
  3. 화면 버퍼에 SpriteRenderer 2번 패스 렌더링 & 블러 버퍼와 블랜딩 적용

 

1. 프리패스 버퍼에 특정 레이어 오브젝트 렌더링 (SpriteRenderer 1번 패스)

원하는 SpriteRenderer에만 블러 효과를 적용하기 위해 먼저 프리패스 렌더패스를 제작합니다.

화면 버퍼에 렌더링하는 것이 아닌 Depthpass & ShadowPass와 같이 별도의 렌더 텍스처 버퍼에 렌더링하는 방식입니다.

 

public class LayerFilterRendererPass_Prepass : ScriptableRenderPass
{
    private const string k_ProfilingSamplerName = "LayerFilterPrepass";
    private const string k_RenderTextureName = "_LayerFilterPrepassBufferRT";
    private const string k_TexturePropertyName = "_LayerFilterPrepassBufferTex";

    public RenderTargetHandle prepassBufferRTH;
    private ProfilingSampler m_ProfilingSampler;

    private LayerMask _layerMask;
    private ShaderTagId _shaderTagId;

    private bool _isClearBlack = false;

    public LayerFilterRendererPass_Prepass(RenderPassEvent passEvent, LayerMask layerMask, ShaderTagId shaderTagId, bool isClearBlack)
    {
        renderPassEvent = passEvent;
        _layerMask = layerMask;
        _shaderTagId = shaderTagId;

        m_ProfilingSampler = new ProfilingSampler(k_ProfilingSamplerName);
        _isClearBlack = isClearBlack;
    }

    public void Setup(ref RenderTargetHandle source, RenderTargetIdentifier renderTargetDestination)
    {
        source.Init(k_RenderTextureName);
        prepassBufferRTH = source;
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        // Prepass 렌더링할 새로운 렌더버퍼 생성 (기존 colorpass에 렌더링하는게 아닌 별도 버퍼에 렌더링하기 위함)
        RenderTextureDescriptor renderTextureDescriptor = renderingData.cameraData.cameraTargetDescriptor;
        renderTextureDescriptor.graphicsFormat = GetGraphicsFormat();
        renderTextureDescriptor.msaaSamples = 1;
        cmd.GetTemporaryRT(prepassBufferRTH.id, renderTextureDescriptor);
        cmd.SetGlobalTexture(k_TexturePropertyName, prepassBufferRTH.Identifier());

        if(_isClearBlack) ConfigureClear(ClearFlag.All, Color.black);     // Pass 렌더링 이전에 프레임 Clear 세팅 (검은색 프레임에 Sprite 렌더링)
        ConfigureInput(ScriptableRenderPassInput.Color);
        ConfigureTarget(new RenderTargetIdentifier(prepassBufferRTH.Identifier(), 0, CubemapFace.Unknown, -1));     // Pass 렌더링 대상 세팅
    }

    public override void OnCameraCleanup(CommandBuffer cmd)
    {
        cmd.ReleaseTemporaryRT(prepassBufferRTH.id);
    }


    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer cmd = CommandBufferPool.Get();
        using (new UnityEngine.Rendering.ProfilingScope(cmd, m_ProfilingSampler))
        {
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();

            DrawingSettings drawSetting = CreateDrawingSettings(_shaderTagId, ref renderingData, SortingCriteria.CommonTransparent);
            FilteringSettings filterSetting = new FilteringSettings(RenderQueueRange.transparent, _layerMask);
            context.DrawRenderers(renderingData.cullResults, ref drawSetting, ref filterSetting);
        }

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    public static GraphicsFormat GetGraphicsFormat()
    {
        if (SystemInfo.IsFormatSupported(GraphicsFormat.B10G11R11_UFloatPack32, FormatUsage.Linear | FormatUsage.Render))
        {
            return GraphicsFormat.B10G11R11_UFloatPack32;
        }
        else
        {
            return QualitySettings.activeColorSpace == ColorSpace.Linear
                ? GraphicsFormat.R8G8B8A8_SRGB
                : GraphicsFormat.R8G8B8A8_UNorm;
        }
    }
}

 

 

여기에 사용되는 쉐이더는 2 Pass로 제작합니다.

1번 패스는 Prepass 렌더링, 2번 패스는 최종 화면 버퍼에 렌더링 & 블러 버퍼와 블랜딩하는 기능을 구현합니다.

// 1번 패스 
half4 PrePassFragment (Varyings i) : SV_TARGET 
{
    float2 uv = i.uv * _MainTex_ST.xy + _MainTex_ST.zw;
    half4 baseMap = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv); 
    return baseMap;
}

// 2번 패스
half4 DrawPassFragment (Varyings i) : SV_TARGET 
{
    float2 uv = i.uv * _MainTex_ST.xy + _MainTex_ST.zw;
    half blendAmount = i.color.a * _BlendAmount;
    blendAmount = saturate(pow(blendAmount, 2.2));

    //half4 baseMap = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv); 
    half4 baseMap = SAMPLE_TEXTURE2D_LOD(_MainTex, sampler_MainTex, uv, blendAmount * 3.2); // 자연스러운 블랜딩을 위해 mipmap 샘플링 이용
    
    float2 uv_prepass = i.screenPosition.xy / i.screenPosition.w;
    half4 prepassMap = SAMPLE_TEXTURE2D(_LayerFilterCopypassBufferTex, sampler_LayerFilterCopypassBufferTex, uv_prepass); 
    
    half4 finalColor = lerp(baseMap, prepassMap, blendAmount);
    finalColor.a = baseMap.a;
    return finalColor;
}

 

 

 

2. 프리패스 버퍼를 다운 샘플링 블러 처리 (위에서 소개한 블러 에셋 방식 적용)

다음 다운 샘플링 렌더패스입니다. 다운 샘플링 + 4방향 박스 블러 처리를 수행하는데 블러 퀄리티가 훌륭하게 나옵니다.

렌더 패스 코드에서는 이전 프리패스에서 생성한 렌더 텍스처를 참조해 다운 샘플링 처리합니다.
(GetTemporaryRT 함수로 추가 렌더텍스처 생성하지 않음)

 

public class LayerFilterRendererPass_Copy : ScriptableRenderPass
{
    private const string k_ProfilingSamplerName = "LayerFilter_Copypass";
    private const string k_TexturePropertyName = "_LayerFilterCopypassBufferTex";
    private static int k_DownSampleTexPropertyName = Shader.PropertyToID("_DownsampleTex");
    private static int k_BlurOffsetPropertyName = Shader.PropertyToID("_blurOffset");
    private static int k_TexturePropertyID => Shader.PropertyToID(k_TexturePropertyName);

    private RenderTargetHandle copypassBufferRTH;
    private ProfilingSampler m_ProfilingSampler;
    private Material _material;
    private Shader _shader;

    private int _blurIteration = 3;
    private float _blurOffset = 1.0f;

    public LayerFilterRendererPass_Copy(RenderPassEvent passEvent, Shader shader)
    {
        renderPassEvent = passEvent;
        _shader = shader;

        m_ProfilingSampler = new ProfilingSampler(k_ProfilingSamplerName);
    }


    public void Setup(ref RenderTargetHandle source, int blurIteration = 3, float blurOffset = 1.0f)
    {
        copypassBufferRTH = source;
        _blurIteration = blurIteration;
        _blurOffset = blurOffset;

        if(!_material && _shader)
        {
            _material = CoreUtils.CreateEngineMaterial(_shader);
        }

        ConfigureClear(ClearFlag.None, Color.white);
        ConfigureTarget(copypassBufferRTH.id);
    }

    public void Destroy()
    {
        if (_material)
        {
            CoreUtils.Destroy(_material);
            _material = null;
        }
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        if (!_material || copypassBufferRTH == null) return;

        CommandBuffer cmd = CommandBufferPool.Get();
        using (new UnityEngine.Rendering.ProfilingScope(cmd, m_ProfilingSampler))
        {
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();

            int iteration = _blurIteration;
            int stepCount = Mathf.Max(iteration * 2 - 1, 1);
            string[] shaderIDStr = new string[stepCount];
            int[] shaderID = new int[stepCount];

            RenderTargetIdentifier identifier = new RenderTargetIdentifier(copypassBufferRTH.id);
            RenderTextureDescriptor rtdTempRT = renderingData.cameraData.cameraTargetDescriptor;
            rtdTempRT.msaaSamples = 1;

            int sourceSize_Width = rtdTempRT.width;
            int sourceSize_Height = rtdTempRT.height;

            // 다운 샘플링 Blit 반복
            for (int i = 0; i < stepCount; i++)
            {
                int downsampleIndex = SimplePingPong(i, iteration - 1);
                rtdTempRT.width = sourceSize_Width >> downsampleIndex + 1;
                rtdTempRT.height = sourceSize_Height >> downsampleIndex + 1;
                shaderIDStr[i] = k_TexturePropertyName + i.ToString();
                shaderID[i] = Shader.PropertyToID(shaderIDStr[i]);

                cmd.SetGlobalTexture(k_DownSampleTexPropertyName, identifier);
                _material.SetFloat(k_BlurOffsetPropertyName, _blurOffset);

                cmd.GetTemporaryRT(shaderID[i], rtdTempRT, FilterMode.Bilinear);
                cmd.Blit(identifier, new RenderTargetIdentifier(shaderIDStr[i]), _material, 0);
                if (i < stepCount - 1) identifier = new RenderTargetIdentifier(shaderIDStr[i]);
            }

            // 최종 Blit
            rtdTempRT.width = sourceSize_Width;
            rtdTempRT.height = sourceSize_Height;
            identifier = new RenderTargetIdentifier(k_TexturePropertyName);
            cmd.GetTemporaryRT(k_TexturePropertyID, rtdTempRT, FilterMode.Bilinear);
            cmd.Blit(identifier, new RenderTargetIdentifier(k_TexturePropertyName), _material, 0);

            // RT 메모리 해제
            for (int i = 0; i < stepCount; i++)
            {
                cmd.ReleaseTemporaryRT(shaderID[i]);
            }
            cmd.ReleaseTemporaryRT(k_TexturePropertyID);
        }

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

    private static int SimplePingPong(int t, int max)
    {
        if (t > max) return 2 * max - t;
        return t;
    }
}

 

 

Blit에서 활용하는 쉐이더는 단순하게 4방향 박스 블러 샘플링처리만 합니다.

float4 frag(Varyings input) : SV_Target
{
    float2 baseMapUV = input.uv.xy;

    float offset = _blurOffset;
    float4 tex_1 = SAMPLE_TEXTURE2D(_DownsampleTex, sampler_linear_clamp, baseMapUV + float2(_DownsampleTex_TexelSize.x * -offset, 0.0));
    float4 tex_2 = SAMPLE_TEXTURE2D(_DownsampleTex, sampler_linear_clamp, baseMapUV + float2(_DownsampleTex_TexelSize.x * offset, 0.0));
    float4 tex_3 = SAMPLE_TEXTURE2D(_DownsampleTex, sampler_linear_clamp, baseMapUV + float2(0.0, _DownsampleTex_TexelSize.y * -offset));
    float4 tex_4 = SAMPLE_TEXTURE2D(_DownsampleTex, sampler_linear_clamp, baseMapUV + float2(0.0, _DownsampleTex_TexelSize.y * offset));
    
    float4 finalColor = (tex_1 + tex_2 + tex_3 + tex_4) * 0.25f;
    return finalColor;
}

 

 

 

3. 화면 버퍼에 SpriteRenderer 2번 패스 렌더링 & 블러 버퍼와 블랜딩 적용

마지막으로 화면 버퍼에 렌더링하는 렌더 패스입니다.

이 렌더 패스에서는 단순히 SpriteRenderer 쉐이더의 2번 패스를 렌더링하는 역할만 수행하며

쉐이더에서 이전 블러 패스에서 제작된 블러 텍스처와 Sprite 텍스처를 단순히 Lerp 연산으로 블랜딩하여 블러 강도를 조절합니다.

추가로 블러 텍스처와 자연스러운 블랜딩을 위해 mipmap 샘플링까지 추가했습니다.

 

public class LayerFilterRendererPass_Drawpass : ScriptableRenderPass
{
    private const string k_ProfilingSamplerName = "LayerFilterDrawpass";

    private ProfilingSampler m_ProfilingSampler;

    private LayerMask _layerMask;
    private ShaderTagId _shaderTagId;

    public LayerFilterRendererPass_Drawpass(RenderPassEvent passEvent, LayerMask layerMask, ShaderTagId shaderTagId)
    {
        renderPassEvent = passEvent;
        _layerMask = layerMask;
        _shaderTagId = shaderTagId;

        m_ProfilingSampler = new ProfilingSampler(k_ProfilingSamplerName);
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        ConfigureInput(ScriptableRenderPassInput.None);     // 필요한 렌더버퍼 명시 함수, Copypass에서 원본 패스로 전환하기 위해 사용
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer cmd = CommandBufferPool.Get();
        using (new UnityEngine.Rendering.ProfilingScope(cmd, m_ProfilingSampler))
        {
            context.ExecuteCommandBuffer(cmd);
            cmd.Clear();

            DrawingSettings drawSettings = CreateDrawingSettings(_shaderTagId, ref renderingData, SortingCriteria.CommonTransparent);
            FilteringSettings filterSetting = new FilteringSettings(RenderQueueRange.transparent, _layerMask);
            context.DrawRenderers(renderingData.cullResults, ref drawSettings, ref filterSetting);
        }

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
}

 

// 2번 패스
half4 DrawPassFragment (Varyings i) : SV_TARGET 
{
    float2 uv = i.uv * _MainTex_ST.xy + _MainTex_ST.zw;
    half blendAmount = i.color.a * _BlendAmount;
    blendAmount = saturate(pow(blendAmount, 2.2));

    //half4 baseMap = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv); 
    half4 baseMap = SAMPLE_TEXTURE2D_LOD(_MainTex, sampler_MainTex, uv, blendAmount * 3.2); // 자연스러운 블랜딩을 위해 mipmap 샘플링 이용
    
    float2 uv_prepass = i.screenPosition.xy / i.screenPosition.w;
    half4 prepassMap = SAMPLE_TEXTURE2D(_LayerFilterCopypassBufferTex, sampler_LayerFilterCopypassBufferTex, uv_prepass); 
    
    half4 finalColor = lerp(baseMap, prepassMap, blendAmount);
    finalColor.a = baseMap.a;
    return finalColor;
}

 

 

 


한계점

Dynamic Batching을 위해 버텍스 컬러 알파 값으로 블러 강도 조절

SpriteRenderer의 색상 값 외에 다른 Material Property를 추가하면 Dynamic Batching이 깨지는 이슈가 발생합니다.

그래서 이 포스팅에서 소개하는 예제에서는 알파 색상 값으로 블러 강도를 조절하게 제작했습니다.

 

 

블러 강도 중간 단계가 어색함

이미 완성된 블러 텍스처와 Lerp 블랜딩하는 방식이기 때문에 블러 강도가 중간 값이면 블러가 퍼지는 느낌이 부족합니다.

mipmap 샘플링을 최대한 보완했지만 여전히 어색합니다.

 

 

 

스프라이트 외부 영역 블러 침범 & 알파 마스킹 영역에 블러까지 끊김

Sprite 텍스처의 알파 마스킹 영역(투명 픽셀)이 존재하는 경우 블러가 끊겨 보이는 문제와 스프라이트 외부 픽셀이 침범하는 문제가 있습니다.

알파 정보가 없는 블러 패스를 샘플링하는 방식이기 때문에 이런 이슈가 발생합니다.

 

Prepass에 렌더링 할 때 뒷 배경을 포함하는 방식으로 어느 정도 완화 할 수 있지만 SpriteRenderer는 알파 모양에 맞게 메쉬를 재구성하는 특성 때문에 완벽하게 해결하기는 어렵습니다.

좌 : 개선 전 우 : 개선 후

 

Prepass에 뒷 배경 포함하는 기능은 렌더피처의 Use Copy Color Pass 옵션을 활성화하면 되고 코드는 LayerFilterRendererPass_CopyColor 클래스를 참고해주세요.

 

 

 


WRITTEN BY
CatDarkGame
Technical Artist dhwlgn12@gmail.com

,