Unity·Unreal 실전

Unity 6 URP Render Graph로 커스텀 패스 마이그레이션하기: Compatibility Mode 졸업

kr-gamedev 2026. 6. 22. 09:03

Unity 6와 URP 17로 올라오면서 가장 크게 달라진 건 렌더 파이프라인을 커스터마이징하는 방식이다. 그동안 ScriptableRenderPassExecute()를 오버라이드하고 CommandBuffer로 직접 Blit을 호출하던 코드는 이제 "Compatibility Mode"로 분류되어 콘솔에 경고를 띄운다. URP Global Settings에서 Render Graph를 끄면 당장은 돌아가지만, 이 호환 모드는 단계적으로 제거될 예정이라 새 프로젝트라면 지금 옮겨두는 게 이득이다.

Render Graph가 기본이 된 이유는 단순한 API 정리가 아니다. 패스 사이의 리소스 의존성을 그래프로 분석해서 안 쓰는 패스를 자동으로 컬링하고, 임시 텍스처 메모리를 재사용하고, 가능한 구간은 async compute로 겹쳐 돌린다. 손으로 RTHandle을 잡고 풀던 일을 프레임워크가 대신 해 주는 대신, 우리는 "이 패스가 무엇을 읽고 무엇을 쓰는지"를 명시적으로 선언해야 한다.

이 글에서는 흔한 풀스크린 포스트 프로세스(화면을 흑백으로 만드는 효과)를 예제로, 구 API를 Render Graph로 옮기는 실전 절차와 자주 밟는 지뢰를 정리한다.

1. 무엇이 바뀌었나

핵심은 Execute()RecordRenderGraph()로 바뀐 것이다. 그리고 카메라 컬러나 뎁스 같은 리소스는 더 이상 renderingData에서 바로 꺼내지 않고, ContextContainer frameData에서 타입으로 조회한다.

var resourceData = frameData.Get<UniversalResourceData>();
var cameraData   = frameData.Get<UniversalCameraData>();
TextureHandle source = resourceData.activeColorTexture;

여기서 TextureHandle은 실제 텍스처가 아니라 그래프 위의 "약속"이다. 실제 메모리 할당과 GPU 실행은 모든 패스를 기록해 그래프가 완성된 뒤 한꺼번에 일어난다. 그래서 핸들을 멤버 변수로 들고 다음 프레임에 재사용하면 깨진다.

2. 구 코드 vs 신 코드

먼저 옛날 방식이다. 카메라 컬러를 임시 RT로 Blit하고 다시 되돌리는 전형적인 패턴이다.

// URP 16 이하 (Compatibility Mode)
public class GrayscalePass : ScriptableRenderPass
{
    Material material;
    RTHandle tempRT;

    public override void Execute(ScriptableRenderContext context, ref RenderingData data)
    {
        CommandBuffer cmd = CommandBufferPool.Get("Grayscale");
        RTHandle source = data.cameraData.renderer.cameraColorTargetHandle;
        Blit(cmd, source, tempRT, material, 0);
        Blit(cmd, tempRT, source);
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
}

tempRT를 직접 Allocate/Release 관리해야 하고, source를 읽어 source에 되쓰는 구조라 GPU 입장에선 위험한 코드다.

3. AddBlitPass로 빠르게

가장 흔한 머티리얼 풀스크린 패스는 RenderGraphUtils의 헬퍼 한 줄로 끝난다. 임시 텍스처는 그래프가 관리하므로 직접 풀링할 필요가 없다.

// URP 17 / Unity 6 (Render Graph)
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.RenderGraphModule.Util;
using UnityEngine.Rendering.Universal;

public class GrayscalePass : ScriptableRenderPass
{
    Material material;

    public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
    {
        var resourceData = frameData.Get<UniversalResourceData>();
        if (resourceData.isActiveTargetBackBuffer) return; // 백버퍼엔 직접 쓰지 않음

        TextureHandle source = resourceData.activeColorTexture;

        TextureDesc desc = renderGraph.GetTextureDesc(source);
        desc.name = "_GrayscaleTarget";
        desc.clearBuffer = false;
        TextureHandle dest = renderGraph.CreateTexture(desc);

        var blit = new RenderGraphUtils.BlitMaterialParameters(source, dest, material, 0);
        renderGraph.AddBlitPass(blit, "Grayscale Blit");

        resourceData.cameraColor = dest; // 이후 패스는 이 텍스처를 카메라 컬러로 사용
    }
}

읽기/쓰기를 분리해 임시 dest에 쓴 뒤 resourceData.cameraColor를 그쪽으로 스왑하는 게 핵심이다. 더블 Blit이 사라지고 의존성도 깔끔해진다.

4. 직접 빌더로 세밀하게

머티리얼 Blit만으로 부족하고 어태치먼트나 추가 입력 텍스처를 직접 다뤄야 한다면, AddRasterRenderPass로 패스를 손수 기록한다. 람다가 GPU 실행 시점에 호출되므로, 필요한 데이터는 클로저 대신 PassData 구조에 담아 넘긴다.

class PassData
{
    public TextureHandle source;
    public Material material;
}

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
    var resourceData = frameData.Get<UniversalResourceData>();
    TextureHandle source = resourceData.activeColorTexture;

    TextureDesc desc = renderGraph.GetTextureDesc(source);
    desc.name = "_GrayscaleTarget";
    TextureHandle dest = renderGraph.CreateTexture(desc);

    using (var builder = renderGraph.AddRasterRenderPass<PassData>("Grayscale", out var pass))
    {
        pass.source = source;
        pass.material = material;

        builder.UseTexture(source, AccessFlags.Read);
        builder.SetRenderAttachment(dest, 0, AccessFlags.Write);
        builder.AllowPassCulling(false); // 사이드 이펙트가 있으면 컬링 방지

        builder.SetRenderFunc((PassData data, RasterGraphContext ctx) =>
        {
            Blitter.BlitTexture(ctx.cmd, data.source,
                new Vector4(1, 1, 0, 0), data.material, 0);
        });
    }

    resourceData.cameraColor = dest;
}

UseTextureSetRenderAttachment로 입출력을 선언하는 순간, 그래프는 이 패스를 어디에 끼워야 하는지 스스로 계산한다. 우리가 할 일은 의존성을 정직하게 적어주는 것뿐이다.

5. 자주 밟는 지뢰

옮기다 보면 비슷한 곳에서 막힌다. 미리 짚어두면 시간을 아낀다.

  • 핸들을 멤버로 캐싱 금지: TextureHandle은 그 프레임의 RecordRenderGraph 안에서만 유효하다. 다음 프레임 재사용은 곧장 에러로 이어진다.
  • 읽으면서 동시에 쓰기 금지: 같은 텍스처를 source이자 target으로 쓰면 검증기에서 막힌다. 항상 임시 dest로 우회하고 cameraColor를 스왑한다.
  • 출력 없는 패스는 사라진다: 그래프에서 보이는 출력이 없으면 컬링된다. 글로벌 텍스처에 쓰거나 사이드 이펙트만 있는 패스는 AllowPassCulling(false)로 살려둔다.
  • 백버퍼 직접 Blit 금지: isActiveTargetBackBuffer로 가드해 두면 마지막 프레임 출력에서 터지는 일을 막는다.
  • frameData 결과 캐싱 금지: frameData.Get은 매 프레임 새로 호출한다.

마무리

Render Graph 전환의 본질은 두 가지다. 진입점이 Execute에서 RecordRenderGraph로 바뀌었고, 리소스를 직접 관리하던 방식이 "무엇을 읽고 쓰는지 선언하면 나머지는 그래프가 처리하는" 선언적 방식으로 바뀌었다. 흔한 머티리얼 풀스크린은 AddBlitPass 한 줄로 끝나고, 세밀한 제어가 필요하면 AddRasterRenderPassPassDataSetRenderFunc를 채운다. 핸들은 프레임 안에서만 살아있고, 읽기와 쓰기는 분리하며, 출력 없는 패스의 컬링만 조심하면 기존 커스텀 패스 대부분은 무리 없이 옮겨진다.