Unity 6 URP Render Graph 완전 정복: 왜 Graph로 화면을 렌더링하게 됐는가

Unity 6 URP Render Graph 5부작 시리즈
1편: 기초와 구현 원리
2편: 패스 종류 Deep Dive
3편: Viewer 완전 정복
4편: Migration 준비편
5편: Migration 실전·운영편


Unity 6.3으로 프로젝트를 업데이트하고, 프로젝트를 열었을 때 이런 경고가 떠서 당황하시는 분들이 많을 것입니다.

“The project currently uses compatibility mode where the render graph API is disabled.”

Compatibility Mode(호환성 모드). 당장 문제는 없어 보이지만, 경고 메시지가 암시하는 건 분명합니다. Unity는 Execute 메서드 기반의 ScriptableRenderPass 방식을 점진적으로 걷어내고 있습니다. 이 경고를 무시하고 넘어갈 수도 있지만, 한 번쯤은 짚어볼 필요가 있습니다. 왜 Unity는 굳이 렌더링 구조 자체를 바꿔야 했을까요.

이 글은 그 질문에 답하면서, Render Graph의 핵심 개념과 첫 커스텀 패스 구현을 다룹니다. 코드 한 줄씩 따라가는 튜토리얼보다는, 왜 이 구조여야만 하는가에 집중합니다.

이 글의 핵심
ScriptableRenderPass: 메모리·의존성을 개발자가 직접 관리 → 규모가 커질수록 복잡성 폭증
RecordRenderGraph = 실행이 아닌 선언. 실제 GPU 명령은 Render Graph가 의존성 분석 후 별도 실행
TextureHandle = 즉시 메모리 할당이 아님. 시스템이 필요한 시점에만 실제로 확보
– Tinting Effect 구현 코드로 위 세 가지 흐름을 직접 확인


목차

  1. 기존 ScriptableRenderPass의 한계
  2. Render Graph란 무엇인가
  3. 핵심 클래스 — RendererFeature와 RenderPass
  4. RecordRenderGraph — “실행이 아니라 의도 기술”
  5. Texture Handle — 메모리 즉시 할당이 아니다
  6. 첫 커스텀 패스 구현: Tinting Effect
  7. 마무리 — 다음 편 예고

기존 ScriptableRenderPass의 한계

Unity의 URP는 오랫동안 ScriptableRenderPass를 통해 렌더링 커스터마이징을 지원했습니다. 패스를 만들고, 순서를 정해 파이프라인에 추가하고, Execute 메서드 안에서 직접 GPU 명령을 쌓는 방식이었습니다.

아래는 화면을 단색으로 틴팅하는 간단한 효과를 구현한 구조입니다.

// TintingFeature.cs — RendererFeature가 패스 생성과 등록을 담당
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class TintingFeature : ScriptableRendererFeature
{
    private TintingPass m_Pass;

    public override void Create()
    {
        m_Pass = new TintingPass(RenderPassEvent.AfterRenderingOpaques);
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        renderer.EnqueuePass(m_Pass);
    }
}
// TintingPass.cs — RenderPass가 실제 GPU 명령을 Execute 안에서 즉시 실행
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class TintingPass : ScriptableRenderPass
{
    private RenderTargetIdentifier m_Source;
    private RenderTargetHandle m_TempTexture;
    private Material m_TintMaterial;

    public TintingPass(RenderPassEvent evt)
    {
        renderPassEvent = evt;
        m_TempTexture.Init("_TempTintTexture");
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        m_Source = renderingData.cameraData.renderer.cameraColorTarget; // 렌더 타겟 직접 참조
        RenderTextureDescriptor desc = renderingData.cameraData.cameraTargetDescriptor;
        cmd.GetTemporaryRT(m_TempTexture.id, desc); // 텍스처 수동 할당
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        CommandBuffer cmd = CommandBufferPool.Get("TintingPass");

        // **↓ GPU 명령을 CommandBuffer에 쌓는 부분 (아직 실행되지 않음)**
        Blit(cmd, m_Source, m_TempTexture.Identifier());                // 명령 1: 소스 → 임시 텍스처 복사
        Blit(cmd, m_TempTexture.Identifier(), m_Source, m_TintMaterial);// 명령 2: 머티리얼 적용 후 소스로 복사
        // **↑ 여기까지는 CPU에서 명령 목록만 작성한 것**

        context.ExecuteCommandBuffer(cmd); // 쌓인 명령을 GPU에 한꺼번에 제출 (이 시점에 실제 실행)

        CommandBufferPool.Release(cmd);
    }

    public override void OnCameraCleanup(CommandBuffer cmd)
    {
        cmd.ReleaseTemporaryRT(m_TempTexture.id); // 텍스처 수동 해제
    }
}

소규모 프로젝트에서는 잘 돌아갔습니다. 다만, 프로젝트의 규모가 커졌을 때 아래와 같은 문제가 있습니다.

  1. 어디에 무엇이 있는지 파악하기 어렵습니다. 렌더 텍스처를 누가 만들고, 누가 쓰고, 언제 해제되는지를 직접 추적해야 합니다. 글로벌 텍스처에 의존하는 코드가 쌓이면 의존 관계가 복잡해집니다. 한 패스의 변경이 다른 패스에 어떤 영향을 미칠지 예측하기 어렵습니다.

  2. 메모리 관리가 수동입니다. 필요 없는 텍스처가 여전히 GPU 메모리를 차지하고 있어도 시스템은 모릅니다. 최적화는 개발자 몫입니다.

  3. 디버깅이 고통스럽습니다. 화면이 이상하게 나올 때, 어느 패스에서 문제가 생겼는지 추적하는 공식 도구가 없었습니다. Frame Debugger를 뒤지며 직접 찾아야 했습니다.

Unity는 이 문제를 근본적으로 해결하기 위해 Render Graph를 도입했습니다. “개발자가 직접 관리하던 것을 시스템이 대신 한다”는 방향으로요.


Render Graph란 무엇인가

Render Graph라는 이름은 시각적 에디터처럼 들리지만, 실제로는 프로그래밍 인터페이스입니다. 이름의 “Graph”는 컴퓨터 과학에서 말하는 노드와 엣지의 자료구조를 뜻합니다.

Old: 선형 패스 체인 New: Render Graph 의존성 맵 Pass A Pass B Pass C Pass D 순서 고정 최적화 없음 Pass A Pass B Pass C Pass D 병렬 실행 가능 자동 메모리 관리 패스 머지 최적화

Render Graph의 동작 원리는 이렇습니다. 개발자는 각 렌더 패스가 어떤 자원을 읽고(read) 쓰는지(write)를 기술(declare)합니다. 직접 실행하는 게 아니라 의도를 선언하는 것입니다. 그러면 Render Graph 시스템이 이 선언들을 분석해서 의존성 맵(Dependency Map)을 만들고, 세 가지 최적화를 자동으로 수행합니다.

최적화 항목 내용
불필요한 패스 제거 최종 결과에 영향 없는 패스를 자동으로 건너뜀
GPU 메모리 재사용 겹치지 않는 Transient 텍스처들이 같은 메모리를 공유
패스 머지 타일 기반 GPU(모바일)에서 인접한 패스를 하나로 병합해 메모리 대역폭 절감

이 세 가지를 직접 구현하는 것은 쉽지 않습니다. Render Graph를 쓰면 공짜로 따라옵니다.


핵심 클래스 — RendererFeature와 RenderPass

Render Graph로 커스텀 렌더링을 구현할 때 핵심이 되는 두 클래스를 먼저 이해해야 합니다.

ScriptableRendererFeature 매니저 클래스 • Create(): 패스 인스턴스 생성 • AddRenderPasses(): 파이프라인에 추가 • Inspector 설정값 보유 생성 ScriptableRenderPass 워커 클래스 • RecordRenderGraph(): 의도 기술 • PassData: 실행 시 전달 데이터 • 렌더 함수(static delegate) 정의

ScriptableRendererFeature는 매니저입니다. Inspector에서 설정할 수 있는 프로퍼티를 갖고, 패스 인스턴스를 생성하고, 파이프라인에 추가하는 역할을 합니다. Unity Editor에서 Universal Renderer Data에 추가하는 것이 바로 이 클래스입니다.

ScriptableRenderPass는 워커입니다. 실제 렌더링 작업이 여기에 정의됩니다. 이전 방식에서는 Execute 메서드에 직접 GPU 명령을 작성했지만, Render Graph에서는 RecordRenderGraph 메서드에 의도를 기술합니다.

새 Renderer Feature를 만들려면 Project 뷰에서 우클릭 → Create → Rendering → URP Renderer Feature Script를 선택하면 이 두 클래스가 포함된 보일러플레이트 코드가 자동으로 생성됩니다.


RecordRenderGraph — “실행이 아니라 의도 기술”

Render Graph를 처음 접하면 RecordRenderGraph가 혼란스럽습니다. 매 프레임 호출되는데, 텍스처를 “생성”하고 패스를 “추가”하는 코드가 안에 있습니다. 매 프레임 메모리를 할당하는 걸까요?

아닙니다. 이것이 Render Graph의 핵심입니다.

RecordRenderGraph는 실행 단계가 아니라 기록 단계입니다. 이 메서드 안에서 하는 모든 것은 “나는 이런 자원을 쓰고, 이런 패스를 실행하려 한다”는 선언입니다. 실제 GPU 명령 실행은 Render Graph 시스템이 분석을 마친 후에 따로 일어납니다.

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
    // 이 메서드는 '실행'이 아닌 '의도 기술'
    var resourceData = frameData.Get<UniversalResourceData>();
    var source = resourceData.activeColorTexture;

    // 텍스처 핸들 생성 — 메모리 할당이 아님
    var descriptor = renderGraph.GetTextureDesc(source);
    descriptor.name = "CameraColor_TintingPass";
    descriptor.clearBuffer = false;
    TextureHandle destination = renderGraph.CreateTexture(descriptor);

    // AddBlitPass — 패스 등록 (실행은 나중에)
    var blitParams = new RenderGraphUtils.BlitMaterialParameters(source, destination, material, 0);
    renderGraph.AddBlitPass(blitParams, "TintingPass");

    // 이후 패스들이 수정된 텍스처를 쓰도록 참조 업데이트
    resourceData.cameraColor = destination;
}

CreateTexture가 실제 GPU 메모리를 할당하지 않는다는 점은 처음 보면 헷갈릴 수 있습니다. 반환되는 건 Texture Handle, 즉 “나중에 이런 텍스처가 필요할 것”이라는 예약입니다. Render Graph가 모든 패스의 의존성을 분석한 뒤, 실제로 필요한 시점에만 메모리를 잡습니다.


Texture Handle — 메모리 즉시 할당이 아니다

TextureHandle이 중요한 이유를 좀 더 짚어보겠습니다.

기존 방식에서는 RenderTexture.GetTemporary()RTHandle 등을 직접 관리해야 했습니다. 패스가 끝나면 해제해야 하고, 잊으면 메모리 누수가 생겼습니다. Render Graph에서는 이 과정을 자동으로 관리합니다.

TextureHandle은 내부적으로 RT Handle을 포함하는데, 이 RT Handle은 처음에는 할당되지 않은 상태(null)입니다. Render Graph 시스템이 실제로 해당 텍스처가 필요하다고 판단하면 그때 메모리를 잡습니다. 필요 없다면 아예 할당하지 않습니다.

실제로 쓰이지 않는 텍스처는 Render Graph가 컴파일 시 잘라냅니다. “혹시 모르니 만들어두자”는 방어적 코드가 성능을 건드리지 않는다는 의미입니다. 기존 방식에서는 불가능했던 접근입니다.

Render Graph Viewer(Window → Analysis → Render Graph Viewer)를 열면 각 패스가 어떤 텍스처를 읽고(초록) 쓰는지(빨강) 시각적으로 확인할 수 있습니다. 이 도구에 대한 자세한 설명은 3편에서 다룹니다.
URP Render Graph Viewer 화면 — 패스별 텍스처 read(초록)·write(빨강) 관계 시각화


첫 커스텀 패스 구현: Tinting Effect

화면 전체에 색조를 입히는 Tinting Effect를 직접 구현하면서 흐름을 잡아보겠습니다. 간단한 예제지만 Render Graph의 데이터 흐름을 손으로 느끼는 데는 충분합니다.

1. Renderer Feature 뼈대

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

// ScriptableRendererFeature: Inspector에 노출되는 매니저 역할
// URP Renderer Asset에 추가하면 매 카메라 렌더링마다 동작한다
public class TintingRendererFeature : ScriptableRendererFeature
{
    // Inspector에 노출할 설정값을 별도 클래스로 묶는 관례적 패턴
    [System.Serializable]
    public class Settings
    {
        public Material material;
        public RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing;
    }

    public Settings settings = new Settings();
    private TintingRenderPass _pass;

    // Create(): RendererFeature가 초기화될 때 1회 호출 — 패스 인스턴스 생성
    public override void Create()
    {
        _pass = new TintingRenderPass();
        _pass.renderPassEvent = settings.renderPassEvent; // 패스 실행 시점 지정
    }

    // AddRenderPasses(): 매 프레임 카메라마다 호출 — 파이프라인에 패스를 등록
    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (settings.material == null) return; // 머티리얼 미지정 시 등록 건너뜀
        _pass.Setup(settings.material);
        renderer.EnqueuePass(_pass); // 렌더러 큐에 패스 추가
    }
}

2. Render Pass — RecordRenderGraph 구현

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Rendering.RenderGraphModule;      // RenderGraph, TextureHandle
using UnityEngine.Rendering.RenderGraphModule.Util; // BlitMaterialParameters, AddBlitPass

public class TintingRenderPass : ScriptableRenderPass
{
    private Material _material;

    // 이 패스는 현재 컬러 버퍼에 접근해야 하므로 중간 텍스처 필요
    public TintingRenderPass()
    {
        requiresIntermediateTexture = true;
    }

    public void Setup(Material material) => _material = material;

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

        // 백 버퍼에서는 실행 불가 (AfterRendering보다 이른 시점에서만 중간 텍스처 사용 가능)
        if (resourceData.isActiveTargetBackBuffer)
        {
            Debug.LogWarning("TintingPass: 백 버퍼에서는 실행할 수 없습니다.");
            return;
        }

        var source = resourceData.activeColorTexture;
        var descriptor = renderGraph.GetTextureDesc(source);
        descriptor.name = "CameraColor_TintingPass";
        descriptor.clearBuffer = false;

        TextureHandle destination = renderGraph.CreateTexture(descriptor);

        var blitParams = new RenderGraphUtils.BlitMaterialParameters(source, destination, _material, 0);
        renderGraph.AddBlitPass(blitParams, "TintingPass");

        // 이후 패스들이 수정된 텍스처를 참조하도록 업데이트
        resourceData.cameraColor = destination;
    }
}

3. Shader Graph로 재질 만들기

Create → Shader Graph → URP → Fullscreen Shader Graph를 선택해 풀스크린 셰이더 그래프를 만듭니다. URP Sample Buffer 노드의 Source Buffer를 Blit Source로 설정하고, Multiply 노드로 원하는 색조를 곱한 뒤 Fragment 출력에 연결하면 됩니다.
Unity Fullscreen Shader Graph 에디터 — URP Sample Buffer 노드를 Blit Source로 설정한 화면

Tinting Effect Shader Graph 완성 화면 — Blit Source에서 색조를 Multiply해 Fragment 출력에 연결한 구성
셰이더 그래프 저장 후, 재질을 만들어 연결합니다.

  1. Project 창에서 Create → Material로 새 머티리얼을 생성합니다.
  2. Inspector 상단의 Shader 드롭다운을 클릭하고, 방금 만든 Shader Graph 이름을 검색해 선택합니다.
  3. 또는 Shader Graph 에셋을 우클릭하고 Create → Material을 선택하면 자동으로 연결된 머티리얼이 생성됩니다.

4. RendererFeature 등록 및 재질 할당

  1. Project 창에서 URP Renderer Asset을 선택합니다 (UniversalRenderPipelineAsset_Renderer 등).
  2. Inspector의 Renderer Features 항목에서 Add Renderer Feature → TintingRendererFeature를 선택합니다.
  3. 추가된 Feature의 Settings → Material 필드에 3단계에서 만든 Shader Graph 머티리얼을 할당합니다.
  4. Render Pass Event는 기본값 After Rendering Post Processing을 그대로 둡니다.
    URP Renderer Asset Inspector — TintingRendererFeature를 추가하고 Shader Graph 머티리얼을 Settings에 할당한 화면

5. Render Graph Viewer로 확인하기

  1. Play Mode를 시작합니다.
  2. Window → Analysis → Render Graph Viewer를 열면 현재 프레임의 패스 목록이 표시됩니다.
  3. 컬럼 목록에서 TintingPass를 찾습니다. RenderPassEvent.AfterRenderingPostProcessing으로 설정했으므로 Blit Post ProcessingBlit Final To Back Buffer 사이에 위치합니다.
  4. TintingPass 컬럼을 클릭하면 이 패스가 읽고 쓰는 텍스처 리소스가 강조 표시됩니다.
    Render Graph Viewer에서 TintingPass가 Blit Post Processing 직후에 위치한 패스 목록 화면

이렇게 만든 Tinting Effect를 Render Graph Viewer에서 확인하면, Blit Post ProcessingBlit Final To Back Buffer 사이에 TintingPass가 나타나는 것을 볼 수 있습니다. AfterRenderingPostProcessing 시점으로 설정했으므로 포스트 프로세싱이 끝난 직후에 실행됩니다.

그리고 인접한 패스들과 머지되지 않습니다. AddBlitPass가 내부적으로 Unsafe Pass를 사용하기 때문입니다. 어떤 패스가 머지될 수 있고 어떤 패스는 안 되는지, 이것이 성능에 어떤 차이를 가져오는지는 다음 편에서 다룹니다.


마무리

Render Graph의 핵심은 결국 제어권의 이동입니다. 기존 방식에서는 렌더 텍스처 할당·해제·의존 순서를 개발자가 전부 직접 챙겨야 했습니다. Render Graph는 그 역할을 시스템이 가져갑니다. 대신 개발자는 “무엇을 읽고, 무엇을 쓰는지”만 선언하면 됩니다. 처음에는 선언적 사고방식이 낯설지만, 한 번 손에 익으면 돌아가기 싫어집니다.

RecordRenderGraph가 실행이 아닌 기록이라는 점, CreateTexture가 실제 메모리 할당이 아니라 약속이라는 점, 이 두 가지만 체득해도 이 시스템의 절반은 이해한 것입니다. 나머지 절반은 어떤 패스 타입을 어떤 상황에서 골라야 하는가의 문제입니다.

다음 편에서는 AddBlitPass, AddCopyPass, AddRasterRenderPass 등 6가지 Add 메서드가 각각 언제 쓰이는지, 머지 가능 여부가 왜 다른지를 다룹니다. Tinting Effect가 왜 머지되지 않았는지, 그 이유도 거기서 밝혀집니다.


더 깊이 공부하려면

이 글을 쓰면서 직접 확인한 자료들입니다.


← 이전 편: 없음 (시리즈 1편)     다음 편 → 2편: 패스 종류 Deep Dive — AddBlitPass vs AddRasterRenderPass

함께 읽으면 좋은 포스팅

댓글 남기기