TECHARTNOMAD | TECHARTFLOWIO.COM

TECH.ART.FLOW.IO

[번역] Unity6에서 RenderGraph를 활용해보자 - 구현 응용편 1편

jplee 2024. 10. 6. 21:03

이전 글 Unity6에서 RenderGraph를 활용해보자 - 기본 기능편 을 먼저 읽고 오는 것이 좋아요.

[번역]Unity6에서 RenderGraph를 활용해보자 - 기본 기능편

역자의 말.최근 보름동안 유니티2022 버전에서 개발 된 렌더링 메서드들을 유니티6 으로 전환하고 있습니다. 역자 역시 Render Graph 를 최근에서야 다뤄보고 있기 때문에 많은 자료와 예제들을 찾아

techartnomad.tistory.com

소개

안녕하세요, CyberAgent SGE코아텍 소속 장유빈입니다.
지난 시간에는 RenderGraph의 기본 개념과 유용한 기능에 대해 알아보았습니다.이번 시간에는 RenderGraph를 구체적으로 어떻게 다루는지에 대한 응용편에 대해 알아보도록 하겠습니다.
이번 글에서는 RenderGraph와 기존 시스템의 구현 방법의 변경 사항을 시작으로 Blit 작업의 구현 코드를 살펴보면서 RenderGraph의 구현 방법에 대해 소개하고자 합니다.

글을 쓰는 시점의 환경

  • Unity 6 (6000.0.9f1)
  • URP 17.0.3

RenderGraph와 기존 시스템의 구현 변경 사항

기존 시스템에서 가장 크게 달라진 점

기존 시스템에서는

  • Configure 및 OnCameraSetup 함수로 드로잉 파라미터 설정하기
  • Execute 함수로 CommandBuffer를 통해 드로잉 실행하기

등 용도별 기능도 있었지만요,
RenderGraph에서는

  • RecordRenderGraph 함수에 드로잉 설정, 드로잉 실행 등 일련의 작업이 모두 집약되어 있다.

 RecordRenderGraph만 구현하여 이해하기 쉽게 만들었습니다.

기존 시스템에서 거의 변화가 없는 것

다음 두 가지 사항은 거의 변경 사항이 없기 때문에 RenderGraph에서도 그대로 사용할 수 있습니다.
ScriptableRenderPass를 드로잉 대열에 추가하는 방법은 변하지 않았습니다.
간단히 말해서 다음 두 단계로 구성됩니다.

  1. ScriptableRenderPass를 상속받아 MyPass 클래스 만들기 (기능*1)
  2. ScriptableRenderer의 ActiveRenderPassQueue에 MyPass 추가하기

Step 2에서는 ScriptableRenderFeature를 통해 추가해도 되고, MonoBehavior로 Camera의 Renderer에 접속하여 추가해도 됩니다.
드로잉용 Shader는 거의 변하지 않았습니다.
RenderGraph의 일부 기능*1을 제외하고는 특별한 Shader 작성 방법을 요구하지 않기 때문에, 기본적으로 지금까지 사용하던 Shader를 그대로 사용할 수 있습니다.
그 특수한 기능에 대해서는 추후 글에서 설명할 예정입니다.
(*1 MemoryLess의 RenderTexture: 이전 Pass에서 그린 결과를 메모리에 기록하지 않고 GPU의 TileMemory에 저장하여 다음 Pass에 사용하도록 하는 기능)

Blit 작업 구현 예시

색상을 반전시켜 출력하는 간단한 Shader

Shader "Hidden/Sample/Negative"
    {
       SubShader
       {
           Tags { "RenderType"="Opaque" "RenderPipeline" = "UniversalPipeline"}
           ZTest Off ZWrite Off Cull Off
           Pass
           {
               Name "Negative"
    
               HLSLPROGRAM
               #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
               #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
    
               #pragma vertex Vert
               #pragma fragment Frag
    
               half4 Frag(Varyings input) : SV_Target0
               {
                   float2 uv = input.texcoord.xy;
                   half4 color = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
                   half4 negative = half4(1 - color.rgb, color.a);
                   return negative;
               }
               ENDHLSL
           }
       }
    }

네거티브 렌더 패스 및 렌더러 기능

using UnityEngine;
    using UnityEngine.Rendering;
    using UnityEngine.Rendering.RenderGraphModule;
    using UnityEngine.Rendering.RenderGraphModule.Util;
    using UnityEngine.Rendering.Universal;
    
    public class NegativeRenderPass : ScriptableRenderPass
    {
        private const string ShaderPath = "Hidden/Sample/Negative";
        private Material _material;
        private Material Material
        {
            get
            {
                if (_material == null)
                {
                    _material = CoreUtils.CreateEngineMaterial(ShaderPath);
                }
                return _material;
            }
        }
    
        public void Cleanup()
        {
            // 런타임에 생성된 Material은 수동으로 폐기해야 한다.
            // 이를 잊어버리면 메모리 누수가 발생한다
            CoreUtils.Destroy(_material);
        }
    
        private class PassData
        {
            public Material Material;
        }
    
        public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
        {
            // 해설 *1
            // frameData에서 URP 내장 리소스 데이터 가져오기
            var resourceData = frameData.Get<UniversalResourceData>();
    
            // 해설 *2
            // resourceData에서 입력으로 사용할 텍스처를 가져옵니다.
            // activeColorTexture는 카메라가 그린 메인 컬러 버퍼의 색상 버퍼
            var sourceTextureHandle = resourceData.activeColorTexture;  
    
            // 해설 *3
            // 출력용 텍스처 Descriptor 생성하기
            // 입력 텍스처의 Descriptor 복사하기
            var negativeDescriptor = renderGraph.GetTextureDesc(sourceTextureHandle);  
            
            // 텍스처 이름 설정하기
            negativeDescriptor.name = "NegativeTexture";   
            
            // 클리어 필요 없음
            negativeDescriptor.clearBuffer = false; 
            
            // MSAA는 필요 없음.
            negativeDescriptor.msaaSamples = MSAASamples.None;
            
            // 깊이 버퍼 불필요
            negativeDescriptor.depthBufferBits = 0;                                      
    
            // 해설 *4
            // Descriptor로 색상 반전 텍스처 만들기
            var negativeTextureHandle = renderGraph.CreateTexture(negativeDescriptor);
    
            // 해설 *5
            // 카메라 색상을 반전시켜 출력용 텍스처로 그리는 RasterRenderPass를 생성하고 
            // RenderGraph에 추가합니다.
            using (var builder = renderGraph.AddRasterRenderPass<PassData>("NegativeRenderPass", out var passData))
            {
                // passData에 필요한 데이터 입력
                passData.Material = Material;
    
                // 해설 *6
                // builder를 통해 RenderGraphPass에 대한 각종 설정하기
                // 참고로, 드로잉 타겟이나 기타 사용되는 텍스처는 반드시 이 단계에서 설정해야 합니다.
                
                // 드로잉 타겟에 출력용 텍스처를 설정합니다.
                builder.SetRenderAttachment(negativeTextureHandle, 0, AccessFlags.Write);    
                
                // 입력 텍스처 사용 선언하기
                builder.UseTexture(sourceTextureHandle, AccessFlags.Read);                      
    
                // 해설 *7
                // 실제 그리기 함수 설정하기(static 함수 권장)
                builder.SetRenderFunc(static (PassData data, RasterGraphContext context) =>
                {
                    // 해설 *8
                    // context에서 CommandBuffer 가져오기
                    var cmd = context.cmd;
                    var material = data.Material;
                    // Blitter의 편리한 기능으로 Blit 실행하기
                    Blitter.BlitTexture(cmd, Vector2.one, material, 0);
                });
            }
    
            // 해설 *9
            // 색상 반전 텍스처를 카메라 색상으로 Blit하기
            // 간단한 Blit이라면 RenderGraph가 제공하는 편리한 함수를 사용해서
            renderGraph.AddBlitPass(negativeTextureHandle, sourceTextureHandle, Vector2.one, Vector2.zero, passName: "BlitNegativeTextureToCameraColor");
        }
    }
    
    public class NegativeRendererFeature : ScriptableRendererFeature
    {
        private NegativeRenderPass _pass;
    
        public override void Create()
        {
            _pass = new NegativeRenderPass
            {
                renderPassEvent = RenderPassEvent.AfterRenderingPostProcessing
            };
        }
    
        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            renderer.EnqueuePass(_pass);
        }
    
        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                // 여기서 패스 파기 처리 호출
                _pass.Cleanup();
            }
        }
    }

위의 NegativeRenderPass.RecordRenderGraph의 내용물이 카메라 타겟 텍스처 → 색상 반전 텍스처 → 카메라 타겟 텍스처 이 두 번의 Blit 작업을 RenderGraph에서 구현하는 코드입니다.

색상 반전

구현 코드 설명

위의 NegativeRenderPass를 참고해 주셨으면 합니다,
이제부터는 구현 코드에 대해 자세히 설명하고자 합니다.

해설 *1

RecordRenderGraph 함수의 인수로 RenderGraph 인스턴스 외에 ContextContainer 타입의 frameData가 들어옵니다.
ContextContainer는 형 이름처럼 드로잉에 필요한 데이터를 저장하는 컨테이너가 됩니다.범용적으로 설계되어 있어 ContextItem 클래스를 상속받으면 어떤 데이터도 담을 수 있습니다.
그리고 URP는 드로잉에 사용할 각종 데이터 클래스를 준비하여 그 안에 삽입합니다.
GetContextContainer.Get<Type>()을 호출하면 된다.
가장 많이 사용되는 것은 아래 4가지이며, 그리기에 필요한 데이터는 대부분 이 중에서 가져올 수 있습니다.

// 카메라 관련 정보: 변환 매트릭스, 카메라 설정 등
var cameraData = frameData.Get<UniversalCameraData>();
// 리소스 관련 정보: 카메라 컬러 버퍼, 카메라 심도 버퍼 등
var resourceData = frameData.Get<UniversalResourceData>();
// 렌더링 관련 정보 : RenderCullingResults, RenderLayerMask 등
var renderingData = frameData.Get<UniversalRenderingData>();
// 조명 관련 정보 : MainLight, AdditionalLights에 대한 정보 등
var lightData = frameData.Get<UniversalLightData>();

해설 *2

UniversalResourceData 중에서 URP가 제공하는 그리기에 필요한 텍스처 리소스를 가져올 수 있습니다.
그리고 가져오는 텍스처는 모두 TextureHandle 타입이 됩니다.
RenderGraph가 다루는 RenderTexture 데이터 타입은 기존의 RTHandle이 아닌 새로운 TextureHandle이 됩니다.

TextureHandle은 다음과 같은 특징을 가지고 있습니다.
● 클래스 내부에 실제 리소스를 직접 보유하지 않고, 리소스 ID만 보유하고 있습니다.
● 리소스 확보 및 해제 등은 RenderGraph가 모두 관리해주기 때문에 관리가 매우 편하다(RenderGraph가 관리하지 않게 하는 방법도 있습니다).보충설명*2 )
- 필요한 자원(접근 방식 등)은 도면 단계 전에 반드시 신청해야 합니다..
- 신청된 리소스는 그리기 단계에 들어가서야 비로소 확보합니다.(그리기 단계 이전에는 리소스에 접근할 수 없음).
- 신청된 리소스는 도면 처리에 사용되는 만큼만 확보 합니다.(낭비되는 리소스 확보 방지)
- 수동으로 리소스 해제할 필요가 없습니다.

해설 *3

 TextureHandle을 만들려면 기존과 마찬가지로 먼저 텍스처의 속성을 지정하는 Descriptor를 준비해야 합니다.
하지만 TextureHandle을 만들기 위한 Descriptor 타입은 기존의 RenderTextureDescriptor가 아닌 새로운 TextureDesc가 됩니다.
하지만 텍스처의 속성을 지정하는 역할은 동일하므로 기존 RenderTextureDescriptor로 TextureHandle을 만드는 방법도 URP에서 제공해 주었습니다.
또한 RenderGraph가 관리하는 텍스처 리소스라면 아래의 함수로 TextureDesc를 가져올 수 있습니다.

// 텍스처의 속성을 확인하고 싶을 때
var desc = renderGraph.GetTextureDesc(textureHandle);

해설 *4

RenderGraph에 새로운 텍스처 리소스를 신청하는 방법은 두 가지가 있습니다.

(앞서 언급했듯이, 신청한 시점에서는 아직 자원이 확보되지 않았습니다.)

// 새로운 TextureDesc를 사용하는 경우
var textureHandle1 = renderGraph.CreateTexture(textureDesc);
// 기존 RenderTextureDescriptor를 사용하는 경우
var textureHandle2 = UniversalRenderer.CreateRenderGraphTexture(renderGraph, renderTextureDescriptor,
                                                textureName, clearFlag, filterMode, textureWrapMode);

기존 RenderTextureDescriptor로 신청하려면 textureName, clearFlag, filterMode, textureWrapMode를 추가해야 하므로 코드가 길어집니다.
내부적으로도 RenderTextureDescriptor를 TextureDesc로 변환하여 renderGraph.CreateTexture()를 실행하면 됩니다.
직접 새로운 TextureDesc를 사용하는 것이 더 편리할 것 같습니다.

기존 RTHandle을 RenderGraph로 가져와서 사용할 수도 있습니다.

// RTHandle 시스템에서 이미 관리하고 있는 리소스를 RenderGraph로 가져온다.
var textureHandle = renderGraph.ImportTexture(rtHandle);

RenderGraph는 RTHandle을 직접 처리할 수 없기 때문에, RTHandle을 가져와서 textureHandle로 변환한 후 사용하는 형태입니다.
보충설명 2 .
RenderGraph로 가져온 RTHandle은 RenderGraph에 직접 관리되지 않습니다.렌더링 종료 후 직접 RTHandle을 해제해야 합니다.
왜 굳이 기존 RTHandle 시스템의 리소스를 RenderGraph가 관리하게 하지 않고 굳이 RTHandle 시스템 리소스를 가져와야 할까요?
RenderGraph에서 관리되는 리소스는 RenderGraph가 더 이상 사용되지 않는다고 판단하는 시점에 해제됩니다.또한, 한 프레임 렌더링이 끝나면 모든 리소스가 해제됩니다.
따라서 RenderGraph에서 관리되는 리소스는 여러 프레임에 걸쳐 유지될 수 없습니다.
TAA, 모션 블러등 템포럴 처리가 필요한 경우 해당 리소스를 기존 RTHandle 시스템에서 관리하도록 해야 합니다.

해설 *5

RenderGraph에 그림을 그리게 하려면 RenderGraphPass를 추가해야 합니다.
RenderGraph의 그리기 실행 단위가 기존 ScriptableRenderPass에서 RenderGraphPass로 바뀌었습니다.
기존의 ScriptableRenderPass는 이제 RenderGraphPass를 만들어 RenderGraph에 추가하기 위한 중개자 역할을 합니다.
그리고 RenderGraphPass를 추가하는 함수를 호출할 때 <클래스 타입>을 지정해야 합니다.

class PassData  // 클래스명은 임의대로 OK
{
    // 드로잉 실행 시 필요한 데이터
    // 드로잉에 사용하는 Material
    // Material에 설정하는 TextureHandle
    // 등...
}

// 전략
using (var builder = renderGraph.AddRasterRenderPass<PassData>(PassName, out var passData))
{ ... }

RenderGraph에는 용도별로 3가지 Pass가 준비되어 있으며, 각각의 Add 기능도 준비되어 있습니다.

// **RasterRenderPass**: 일반 렌더링, 머티리얼에 파라미터를 설정하는 등의 용도로 사용
renderGraph.AddRasterRenderPass();
// **ComputeRenderPass: Compute Shader를 실행하는 데 사용
renderGraph.AddComputePass();
// **UnsafeRenderPass**: 기존 시스템처럼 Command Buffer를 자유롭게 다루고 싶을 때 사용
renderGraph.AddUnsafePass();

이 예제에서는 Blit 작업을 위해 RasterRenderPass를 사용하고 있습니다.
ComputeRenderPass와 UnsafeRenderPass를 다루는 방법에 대해서는 다음 글에서 다루도록 하겠습니다.

해설 *6

RenderGraphPass는 경로를 추가할 때 획득한 builder를 통해 각종 설정을 수행합니다.
각종 설정 중 드로잉 타겟 지정과 텍스처 사용 신청이 가장 중요하고 필수적인 설정 항목입니다. (다른 설정 항목에 대해서는 다음 글에서 다시 설명할 예정입니다.)

드로잉 타겟 지정에 대해:

// 드로잉의 ColorTarget 설정
// 인수: 1. 대상 텍스처 2. 출력 슬롯 3. 액세스 권한
// 출력 슬롯: 단일 타겟의 경우 0으로 고정, 다중 타겟의 경우 0부터 순서대로 번호가 매겨진다.
builder.SetRenderAttachment(colorTextureHandle, 0, AccessFlags.Write);
// 드로잉의 DepthTarget 설정
// 인수: 1. 대상 텍스처 2. 액세스 권한
builder.SetRenderAttachmentDepth(depthTextureHandle, AccessFlags.Write);

여기서 주의해야 할 점이 있습니다:

  • SetRenderAttachment와 SetRenderAttachmentDepth에는 반드시 타입에 맞는 포맷의 텍스처를 지정해야 합니다.
    • SetRenderAttachment에 Depth 형식의 텍스처를, SetRenderAttachmentDepth에 Color 형식의 텍스처를 지정하면 오류가 발생합니다.

사용 텍스처 사용 신청에 대해:

드로잉 타겟 외에 사용되는 텍스처가 있다면, 그 텍스처를 사용 신청해야 합니다.

// 텍스처 사용 신청하기
// 인수: 1. 사용 텍스처 2. 액세스 권한
builder.UseTexture(textureHandle, AccessFlags.Read);

여기서 주의해야 할 점이 있습니다:
 

  • RenderAttachment나 RenderAttachmentDepth에 설정된 대상에 UseTexture를 사용할 필요가 없는데,
    • 이를 시도하면 오히려 중복 신청으로 오류가 발생합니다.

액세스 권한에 대해:

그리기 대상 지정과 사용 텍스처 사용 신청에도 각각 접근 권한을 설정해야 합니다.
접근 권한은 아래 Enum에서 선택합니다.

public enum AccessFlags
{
    ///<summary>The pass does not access the resource at all. Calling Use* functions with none has no effect.</summary>
    None = 0,

    ///<summary>This pass will read data the resource. Data in the resource should never be written unless one of the write flags is also present. Writing to a read-only resource may lead to undefined results, significant performance penaties, and GPU crashes.</summary>
    Read = 1 << 0,

    ///<summary>This pass will at least write some data to the resource. Data in the resource should never be read unless one of the read flags is also present. Reading from a write-only resource may lead to undefined results, significant performance penaties, and GPU crashes.</summary>
    Write = 1 << 1,

    ///<summary>Previous data in the resource is not preserved. The resource will contain undefined data at the beginning of the pass.</summary>
    Discard = 1 << 2,

    ///<summary>All data in the resource will be written by this pass. Data in the resource should never be read.</summary>
    WriteAll = Write | Discard,

    ///<summary> Shortcut for Read | Write</summary>
    ReadWrite = Read | Write
}

일반적인 도면 처리의 경우,

  • 드로잉 타겟은 Write로 읽어야
    • 할 필요가 있는 경우 ReadWrite
  • 다른 텍스처는 Read를 사용합니다.

여기서 주의할 점입니다:

  • 잘못된 권한을 부여해도 RenderGraph 측에서 오류를 내지는 않지만, 올바르게 설정하는 것이 좋습니다
    • RenderGraph는 Shader 측에서 실제로 수행되는 행위를 미리 감지할 수 없기 때문에 미리 오류를 내지는
    • 못합니다
    • 오류는나오지 않지만, 기대하는 렌더링 결과를 얻지 못할 가능성이 높습니다. 

해설 *7

이제부터는 드디어 드로잉을 실행하는 함수입니다.

// 실제 그리기 함수 설정하기(static 함수를 권장함)
builder.SetRenderFunc(static (PassData data, RasterGraphContext context) =>
{
    // 描画実行コード
});

builder. SetRenderFunc에 전달되는 함수는 다음과 같은 인수를 가져야 합니다.

  • PassData
    • RenderGraphPass를 추가할 때 지정한 유형과 일치합니다.
    • 함수 외부에서 데이터를 전송하기 위해
  • RenderGraphContext
    • 해당 Pass에서 사용하는 CommandBuffer를 가지고 있습니다.
    • 3종류의 Pass에 대해 3종류의 Context가 있습니다.
      • RasterGraphContext
      • ComputeGraphContext
      • UnsafeGraphContext

해설 *8

3종류의 Context는 각각의 CommandBuffer를 가지고 있습니다.

  • RasterGraphContext → RasterCommandBuffer
  • ComputeGraphContext → ComputeCommandBuffer
  • UnsafeGraphContext → UnsafeCommandBuffer

3종류의 CommandBuffer는 기존의 CommandBuffer를 래핑한 것으로, 각각의 용도에 맞게 필요한 명령어만 제공합니다.

해설 *9

간단한 Blit 작업이라면 RenderGraph에서 직접 Blit을 실행하는 Pass를 추가할 수 있는 편리한 함수를 제공하고 있습니다.

  • renderGraph.AddBlitPass()

또한 텍스처 복사 처리에도 복사를 실행하는 Pass를 추가하는 편리한 함수를 제공하고 있습니다.

  • renderGraph.AddCopyPass

단, 이 글을 작성하는 시점에 다음과 같은 결함 현상이 있으니 주의해야 합다!

  • 카메라의 MSAA가 활성화된 경우, Windows 플랫폼에서 renderGraph.AddBlitPass도 renderGraph.AddCopyPass도 제대로 실행되지 않을 수 있습니다.

마지막으로

이번 시간에는 Blit 작업의 예제를 시작으로 RenderGraph 구현에 필요한 내용을 설명했습니다.
RenderGraph를 처음 다룰 때는 모르는 게 많아서 시간이 많이 걸리기도 했어요.하지만 RenderGraph에 익숙해지면 기존 시스템보다 훨씬 편리하다는 것을 느낄 수 있을 것입니다.
여러분도 꼭 RenderGraph로 구현해 보시기 바랍니다.
다음 시간에는 RenderGraphPass 간에 데이터를 전달할 때 매우 유용한 ContextContainer의 사용법에 대해 알아보도록 하겠습니다.

참조

  • URP 내 RenderGraph 구현

[소식][번역]URP 17로의 업그레이드와 Render Graph 활용 방법

2024년 9월 초 U/Day Tokyo 2024 에서 소개 된 URP17 과 렌더그래프에 대한 활용방법이라는 주제의 강연입니다. 그리고 추가로... 게임개발자이신 쿄우카이님의 Zenn 블로그에 실린 글을 간략히 소개 해

techartnomad.tistory.com

 


원문

Unity6からRenderGraphを使いこなそう ー 実装応用編 その1 - CORETECH ENGINEER BLOG

Unity6のRenderGraphで描画パイプラインをカスタマイズする方法解説

blog.sge-coretech.com