Unity 6 URP RenderGraph 알아보기: 아키텍처와 핵심 API 정리

Unity 6 URP의 기본 백엔드로 채택된 RenderGraph의 선언형 3단계 아키텍처와 ContextContainer, Blitter 등 핵심 변경점을 살펴봅니다.
Unity 6 URP RenderGraph 아키텍처 다이어그램

Unity 6로 전환하면서 가장 큰 변화를 체감하게 되는 영역은 단연 렌더링 파이프라인의 백엔드 아키텍처입니다. 기존 Universal Render Pipeline(URP)에서 커스텀 렌더 패스를 작성할 때 사용하던 명령형(Imperative) 방식의 ScriptableRenderPass.Execute() 구조가 레거시로 분류되고, RenderGraph(프레임 그래프) 시스템이 기본 렌더링 백엔드로 전면 활성화되었습니다.

이번 글에서는 RenderGraph가 도입된 구조적 배경과 3단계 파이프라인의 동작 원리를 살펴보고, 기존 URP 시스템에서 전환할 때 알아두어야 할 핵심 API(ContextContainer, Blitter, TextureHandle) 및 기본 구현 패턴을 차근차근 정리해 드리고자 합니다.

1. RenderGraph 도입 배경과 패러다임의 전환

기존 URP의 커스텀 렌더 패스 작성 방식은 CPU에서 GPU 커맨드를 순차적으로 빌드하여 즉시 커맨드 버퍼에 기록하는 명령형(Imperative) 모델이었습니다. 이 방식은 코드가 직관적이라는 장점이 있었으나, 복잡한 렌더 파이프라인에서 다음과 같은 구조적 한계를 노출했습니다.

  • 리소스 수명 주기의 불투명성: 여러 렌더 패스 간에 중간 렌더 타깃(RTHandle)이 언제 생성되고 파기되는지 프레임워크가 전체적으로 파악하기 어려워 메모리 재사용(Aliasing)이 제한되었습니다.
  • 불필요한 패스 실행: 특정 패스의 출력 결과가 후속 패스나 최종 화면에 기여하지 않더라도 렌더 큐에 등록된 이상 무조건 GPU 연산이 수행되었습니다.
  • 동기화 배리어 오버헤드: 리소스 간의 읽기/쓰기 의존성이 명시적이지 않아 보수적인 GPU 동기화 배리어가 빈번하게 발생했습니다.

RenderGraph는 이러한 문제를 해결하기 위해 선언형(Declarative) 패러다임을 도입했습니다. 렌더 패스를 즉시 실행하는 대신, 패스가 요구하는 입력(Read)과 출력(Write) 리소스를 먼저 프레임워크에 선언하고, 컴파일러가 의존성 방향성 비순환 그래프(DAG)를 분석하여 최적의 실행 계획을 수립합니다.

구분 레거시 ScriptableRenderPass Unity 6 RenderGraph
실행 방식 명령형 (즉시 CommandBuffer 기록) 선언형 (의존성 선언 후 일괄 컴파일/디스패치)
진입점 API Execute(ScriptableRenderContext, ref RenderingData) RecordRenderGraph(RenderGraph, ContextContainer)
데이터 전달 RenderingData (단일 거대 구조체) ContextContainer (타입 안전 분할 컨테이너)
화면 복사 cmd.Blit() Blitter.BlitCameraTexture() / Blitter.BlitTexture()
메모리 관리 수동 RTHandle 생성 및 Pool 관리 Transient 리소스 및 자동 Memory Aliasing
최적화 수동 최적화 필요 자동 패스 컬링(Culling) 및 모바일 Subpass 병합

2. RenderGraph 3단계 실행 파이프라인

RenderGraph 기반 렌더링은 매 프레임 CPU와 GPU 사이에서 명확히 분리된 3단계를 거쳐 처리됩니다.

  1. Record (의존성 선언 단계):
    CPU에서 실행되며, 각 렌더 패스가 어떤 리소스를 생성하고, 어떤 버퍼/텍스처를 읽고 쓰는지 builder.UseTexture() 및 builder.SetRenderAttachment() 등의 API로 선언합니다. 이 단계에서는 실제 GPU 드로우콜을 기록하지 않습니다.
  2. Compile (그래프 분석 및 최적화 단계):
    렌더 그래프 컴파일러가 전체 패스와 리소스 의존 관계를 DAG 형태로 분석합니다. 최종 카메라 백버퍼에 도달하지 않는 불필요한 패스를 자동으로 제거(Pass Culling)하고, 생명 주기가 겹치지 않는 임시 리소스들의 GPU 물리 메모리를 서로 병합(Memory Aliasing)합니다.
  3. Execute (GPU 커맨드 디스패치 단계):
    컴파일된 최적 순서에 따라 SetRenderFunc()에 등록된 렌더링 람다 함수를 실행하고, 네이티브 컨텍스트에 GPU 커맨드를 일괄 기록하여 디스패치합니다.

3. 패스 유형 및 리소스 수명 주기

RenderGraph 시스템에서 커스텀 렌더링을 구성할 때 사용하는 핵심 패스 유형과 리소스 핸들 체계는 다음과 같습니다.

주요 패스 유형

  • Raster Render Pass (AddRasterRenderPass): 일반적인 지오메트리 렌더링 및 포스트 프로세싱에 사용됩니다. 모바일 타일 GPU에서 Vulkan/Metal의 Native Render Pass 및 Subpass와 결합하여 대역폭을 최적화합니다.
  • Compute Pass (AddComputePass): Compute Shader를 디스패치하고 UAV 버퍼 및 텍스처를 조작하는 전용 패스입니다.
  • Unsafe Pass (AddUnsafePass): 네이티브 그래픽스 컨텍스트나 저수준 CommandBuffer에 직접 접근해야 하는 특수 목적용 패스입니다. 자동 의존성 분석과 최적화가 제한되므로 필요한 경우에만 제한적으로 사용합니다.

리소스 핸들과 수명 주기

RenderGraph에서는 RenderTexture 인스턴스를 직접 참조하지 않고 가벼운 핸들 객체인 TextureHandle을 통해 리소스를 제어합니다.

  • Transient Resources (임시 리소스): renderGraph.CreateTexture()로 선언되는 프레임 내부 리소스입니다. 프레임이 종료되면 내부 텍스처 풀로 반납되며, 메모리 Aliasing에 의해 다른 패스의 임시 버퍼와 메모리를 공유합니다.
  • Imported Resources (외부 리소스): 카메라 타깃이나 백버퍼처럼 그래프 외부에서 유지되는 리소스로, renderGraph.ImportTexture()를 통해 그래프 내 핸들로 변환하여 사용합니다.
  • AccessFlags: AccessFlags.Read, AccessFlags.Write, AccessFlags.ReadWrite를 명시하여 패스 간 리소스 접근 순서와 배리어를 프레임워크가 명확히 인지하도록 합니다.

4. Unity 6에서 갱신된 핵심 API

Unity 6 URP로 넘어오면서 기존에 널리 사용되던 전역 구조체와 블릿 함수가 새로운 API로 교체되었습니다.

ContextContainer 프레임 데이터

기존의 ref RenderingData는 파이프라인의 모든 데이터가 한곳에 집중된 거대 구조체였습니다. Unity 6에서는 ContextContainer를 통해 데이터가 세분화되어 필요한 객체만 타입 안전하게 조회할 수 있습니다.

// 기존: renderingData.cameraData.cameraTargetDescriptor
// Unity 6:
UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
UniversalRenderingData renderingData = frameData.Get<UniversalRenderingData>();
UniversalLightData lightData = frameData.Get<UniversalLightData>();

Blitter API 표준화

레거시 cmd.Blit()은 타일 기반 GPU에서 불필요한 Resolve 동작을 유발하여 성능 저하의 원인이 되었습니다. Unity 6에서는 Blitter 정적 클래스를 표준으로 사용합니다.

// 카메라 컬러 텍스처 간 풀스크린 블릿
Blitter.BlitCameraTexture(ctx.cmd, sourceHandle, destinationHandle, material, passIndex);

// 일반 TextureHandle 간 블릿
Blitter.BlitTexture(ctx.cmd, sourceHandle, new Vector4(1, 1, 0, 0), material, passIndex);

5. 실전 커스텀 RenderPass 및 RendererFeature 구현

Unity 6 URP에서 커스텀 렌더링을 완전히 동작시키기 위해서는 셰이더(Shader), ScriptableRenderPass(패스 로직), 그리고 이를 렌더러에 주입하는 ScriptableRendererFeature(인스펙터 바인딩 및 머티리얼 관리)의 3단계를 함께 구성해야 합니다.

5-1. 포스트 프로세싱 셰이더 작성 (HLSL)

화면의 컬러 버퍼(_BlitTexture)를 읽어와 _Intensity 비율(0~1)만큼 색상을 반전시키는 Pass 0과 최종 복귀를 위한 Pass 1로 구성된 URP 표준 풀스크린 셰이더입니다.

Shader "Hidden/CustomColorInvert"
{
    Properties
    {
        _Intensity ("Invert Intensity", Range(0.0, 1.0)) = 1.0
    }

    HLSLINCLUDE
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
    #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

    CBUFFER_START(UnityPerMaterial)
        float _Intensity;
    CBUFFER_END

    // Pass 0: 색상 반전 (Intensity 블렌딩)
    half4 FragInvert(Varyings input) : SV_Target
    {
        UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
        float4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, input.texcoord);
        half3 inverted = 1.0 - color.rgb;
        half3 finalColor = lerp(color.rgb, inverted, _Intensity);
        return half4(finalColor, color.a);
    }

    // Pass 1: 단순 복사 (Writeback)
    half4 FragCopy(Varyings input) : SV_Target
    {
        UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
        return SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, input.texcoord);
    }
    ENDHLSL

    SubShader
    {
        Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }
        LOD 100
        ZWrite Off
        Cull Off
        ZTest Always

        Pass
        {
            Name "ColorInvertPass"
            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment FragInvert
            ENDHLSL
        }

        Pass
        {
            Name "CopyPass"
            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment FragCopy
            ENDHLSL
        }
    }
}

5-2. Custom ScriptableRenderPass 작성

RenderGraph API(RecordRenderGraph)를 활용하여 중간 텍스처를 생성하고 블릿 및 강도 파라미터를 안전하게 전달하는 렌더 패스입니다.

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Rendering.RenderGraphModule;

public class CustomColorInvertPass : ScriptableRenderPass
{
    private Material _material;
    private float _intensity = 1.0f;

    // 1. PassData는 풀링을 위해 반드시 참조 형식(class)으로 선언
    private class PassData
    {
        public TextureHandle sourceTexture;
        public TextureHandle destinationTexture;
        public Material passMaterial;
        public float intensity;
    }

    public CustomColorInvertPass(Material mat)
    {
        _material = mat;
        renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing;
    }

    public void Setup(float intensity)
    {
        _intensity = intensity;
    }

    // 2. Unity 6 RenderGraph 레코드 진입점
    public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
    {
        UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
        UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();

        if (resourceData.isActiveTargetBackBuffer)
            return;

        TextureHandle source = resourceData.activeColorTexture;

        // 3. Transient 중간 텍스처 생성
        TextureDesc desc = renderGraph.GetTextureDesc(source);
        desc.name = "Invert_Intermediate";
        desc.clearBuffer = false;
        TextureHandle intermediate = renderGraph.CreateTexture(desc);

        // 4. Pass 1: Source -> Intermediate (반전 처리 및 강도 전달)
        using (var builder = renderGraph.AddRasterRenderPass<PassData>("Invert Color Pass", out var passData))
        {
            passData.sourceTexture = source;
            passData.destinationTexture = intermediate;
            passData.passMaterial = _material;
            passData.intensity = _intensity;

            // 의존성 명시
            builder.UseTexture(source, AccessFlags.Read);
            builder.SetRenderAttachment(intermediate, 0, AccessFlags.Write);

            // 5. 실행 함수 (람다 외부 변수 캡처 금지, passData만 활용)
            builder.SetRenderFunc((PassData data, RasterGraphContext ctx) =>
            {
                data.passMaterial.SetFloat("_Intensity", data.intensity);
                Blitter.BlitTexture(ctx.cmd, data.sourceTexture, new Vector4(1, 1, 0, 0), data.passMaterial, 0);
            });
        }

        // 6. Pass 2: Intermediate -> Source (최종 복귀)
        using (var builder = renderGraph.AddRasterRenderPass<PassData>("Invert Writeback Pass", out var passData))
        {
            passData.sourceTexture = intermediate;
            passData.destinationTexture = source;
            passData.passMaterial = _material;

            builder.UseTexture(intermediate, AccessFlags.Read);
            builder.SetRenderAttachment(source, 0, AccessFlags.Write);

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

5-3. ScriptableRendererFeature 작성 (Shader 및 강도 인스펙터 바인딩)

머티리얼 에셋을 별도로 생성할 필요 없이 Feature의 Shader 필드에 셰이더를 연결하고, 0~1 슬라이더(_intensity)로 반전 강도를 즉시 조절할 수 있는 컨테이너 클래스입니다.

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

[Serializable]
public class CustomColorInvertFeature : ScriptableRendererFeature
{
    [SerializeField] private Shader _shader;
    [Range(0f, 1f)] [SerializeField] private float _intensity = 1.0f;
    [SerializeField] private RenderPassEvent _renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing;

    private Material _material;
    private CustomColorInvertPass _customPass;

    // 1. Shader 기반 인메모리 머티리얼 생성 및 Pass 초기화
    public override void Create()
    {
        if (_shader != null)
        {
            _material = CoreUtils.CreateEngineMaterial(_shader);
        }

        _customPass = new CustomColorInvertPass(_material)
        {
            renderPassEvent = _renderPassEvent
        };
    }

    // 2. 렌더러에 Pass 큐잉 및 인스펙터 파라미터 전달
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (_material == null)
            return;

        _customPass.Setup(_intensity);
        renderer.EnqueuePass(_customPass);
    }

    // 3. 런타임 머티리얼 메모리 해제
    protected override void Dispose(bool disposing)
    {
        CoreUtils.Destroy(_material);
    }
}
💡 팁: 왜 머티리얼 에셋 대신 Shader를 직접 받는가?
별도의 .mat 머티리얼 에셋을 프로젝트에 생성해둘 필요 없이 .shader 파일만 인스펙터에 연결하면, CoreUtils.CreateEngineMaterial()로 런타임 머티리얼을 생성하고 Dispose()에서 안전하게 메모리를 해제할 수 있습니다. 또한 직렬화 필드 바인딩을 통해 빌드 시 셰이더 스트리핑을 방지합니다.

스크립트 작성 후 Unity Editor의 Universal Renderer Data(예: UniversalRenderer_Asset) 인스펙터 창 하단의 Add Renderer Feature 버튼을 누르고 CustomColorInvertFeature를 추가한 뒤, CustomColorInvert.shader를 할당하면 렌더 그래프 파이프라인에 즉시 반영됩니다.

Universal Renderer Data 인스펙터에서 CustomColorInvertFeature의 Shader 및 Intensity 설정 화면

5-4. 실시간 렌더링 결과 비교

실제 씬에 렌더 피처를 적용하고 Intensity 슬라이더를 조절하여 색상 반전 효과가 실시간으로 블렌딩 적용되는 결과입니다.

포스트 프로세싱 색상 반전 효과 적용 전 원본 씬 화면 CustomColorInvertFeature 색상 반전 효과가 적용된 씬 화면

6. 트러블슈팅 및 주요 주의점

RenderGraph 기반 코드를 작성하면서 자주 마주치게 되는 핵심 주의점과 해결 방안입니다.

⚠️ 주의: 람다 식 외부 변수 캡처 및 TextureHandle 보관 금지
builder.SetRenderFunc 람다 내부에서 클래스 멤버 변수(_material, this)를 직접 참조하면 Execute 시점의 컨텍스트 분리로 인해 GC 할당 및 경쟁 상태가 발생하므로 반드시 PassData를 통해서만 접근해야 합니다. 또한 TextureHandle은 해당 프레임의 RecordRenderGraph 스코프 내에서만 유효하므로 클래스 변수에 캐싱하지 마십시오.

Blitter.BlitTexture의 pass 인덱스 범위 초과 (invalid pass index)

Blitter.BlitTexture(..., material, passIndex) 호출 시 전달한 passIndex가 셰이더 내부에 선언된 Pass 블록의 총 개수(인덱스 0부터 시작)를 초과하면 invalid pass index N in DrawProcedural 런타임 에러가 발생합니다. 셰이더 파일에 해당 인덱스의 서브 패스(예: Pass 1 CopyPass)가 실제로 선언되어 있는지 확인해야 합니다.

패스가 화면에 렌더링되지 않는 현상 (Pass Culling)

작성한 패스의 출력 리소스를 후속 패스나 최종 카메라 백버퍼에서 전혀 읽지 않을 경우, RenderGraph 컴파일러가 이를 불필요한 작업으로 판단하여 자동 컬링합니다. 최종 컬러 버퍼로 Writeback을 수행하거나, 디버깅 목적이라면 builder.AllowPassCulling(false)를 호출하여 컬링을 방지할 수 있습니다.

💡 팁: Render Graph Viewer로 그래프 연결 및 컬링 시각화 점검
작성한 패스가 화면에 렌더링되지 않거나 리소스 의존성을 확인하고 싶다면 Unity Editor 메뉴의 Window > Analysis > Render Graph Viewer를 열어 프레임별 패스 연결 관계, Pass Culling 여부, 메모리 Aliasing 현황을 시각적으로 점검할 수 있습니다.
Unity 6 Render Graph Viewer에서 Invert Color Pass와 Invert Writeback Pass의 리소스 연결 및 실행 순서를 시각적으로 확인하는 화면

마무리하며

Unity 6 URP의 RenderGraph 시스템은 초기 학습 곡선과 패러다임 전환이 다소 낯설게 느껴질 수 있으나, 프레임 그래프 아키텍처가 제공하는 명시적 리소스 관리와 자동 최적화 이점은 대규모 그래픽스 파이프라인에서 매우 큰 효율성을 제공합니다.

기존 프로젝트에서 커스텀 렌더 패스를 마이그레이션하면서 겪으신 특이 케이스나 ContextContainer 구조 활용에 대한 추가적인 팁이 있으시다면 댓글로 자유롭게 의견을 나누어 주시기 바랍니다.