Site Search

Unity、URP、RenderFeature를 사용한 멀티 렌더링【 구현편 】

안녕하세요.

Cocone Engineering(이하 CE)에서 그래픽 엔지니어 겸 테크니컬 아티스트 TechLead로 데이터 파이프라인 및 렌더 파이프라인 개발, 에셋 제작 환경 정비 등을 담당하고 있는 타카노 마사야라고 합니다.

CE에서는 항상 사용자 경험의 극대화를 목표로 Unity 6와 URP를 활용한 애플리케이션 개발을 진행하고 있습니다.

이번 글에서는 그 동안 쌓아온 실무 지식의 일환으로 Unity의 RenderFeature를 이용한 멀티패스 렌더링에 대해 그 구조와 구현 방법에 대해 설명하겠습니다.

들어가며

Cocone에서는 주로 Unity 6와 Universal Render Pipeline (URP)을 사용하여 앱 개발을 진행하고 있습니다. 기존에 사용하던 BuiltIn 렌더러에서는 쉐이더 내에 정의된 다중 경로를 이용한 멀티패스 렌더링을 쉽게 할 수 있었습니다.

또한 URP도 초기 버전에서는 멀티패스 렌더링을 쉽게 구현할 수 있었으나, 사양 변경으로 인해 현재는 RenderFeature를 이용한 구현이 필요하게 되었습니다.

이 글에 대해

이번에 소개하는 멀티패스 렌더링은 매우 간단한 작업이지만, URP의 버전 업데이트 등의 영향으로 샘플 예제를 찾지 못해 처음 URP에서 멀티패스 렌더링을 하고자 하는 분들 중 어려움을 겪는 분들이 많을 것이라고 생각됩니다.

이 글은 멀티패스 렌더링을 바로 구현할 수 있도록 최대한 간단하게 작성하였습니다. 샘플이 여러분에게 도움이 되었으면 좋겠습니다.

・Unity 6000.2.2f1, URP 17.2.0, Forward Rendering Path에서 동작을 확인했지만, Unity 6000 계열(Unity 6)이라면 문제없이 동작합니다.

RenderFeature とは何か

RenderFeature는 렌더 파이프라인에 유연한 렌더링 처리를 추가하기 위한 구조입니다. 다음과 같은 장점이 있습니다.

확장성: 향후 추가 드로잉 단계(예: 커스텀 라이트, 포스트 FX, 섀도우 패스 등)를 쉽게 추가할 수 있습니다.

유연성: 드로잉 플로우 중간에 코드를 삽입할 수 있어 표현력이 풍부해집니다.

예제 설명

3D 모델에 할당된 CustomMultipass쉐이더의 두 패스를 사용하여 2패스 렌더링을 수행하는 샘플로, 첫 번째 패스에서 스텐실 버퍼에 1을 그려넣고, 두 번째 패스에서 스텐실 버퍼에 1이 쓰여진 부분만 그려 넣는 예제입니다.

구현 내용은 매우 간단하지만, 응용에 따라 동일 좌표의 모델 우선 그리기, 부분 마스크 그리기, 윤곽선 그리기 등 다양한 용도로 활용할 수 있습니다.

実装手順

 CustomRenderFeature.cs、CustomRenderPass.cs、CustomMultipass.shader세 개의 파일을 Unity 프로젝트 내에 작성하고, 내용을 복사 붙여넣기하여 저장합니다.

CustomRenderFeature.cs
using System;
using UnityEngine;
using UnityEngine.Rendering.Universal;

namespace Example.Graphics
{
    public class CustomRenderFeature : ScriptableRendererFeature
    {
        [Serializable]
        public class Settings
        {
            public string passTag = "CustomPass";
            public string drawTagName = "WriteStencil";
            public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingOpaques;
            public LayerMask layerMask = ~0;
        }

        public Settings settings = new();
        private CustomRenderPass renderPass;

        public override void Create()
        {
            renderPass = new CustomRenderPass(
                    settings.passTag,
                    settings.drawTagName,
                    settings.renderPassEvent,
                    settings.layerMask
            );
        }

        public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
        {
            // 프리뷰용 카메라에서는 스킵
            if (renderingData.cameraData.cameraType == CameraType.Preview)
            {
                return;
            }

            renderer.EnqueuePass(renderPass);
        }
    }
}


CustomRenderPass.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RendererUtils;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.Universal;

namespace Example.Graphics
{
    public class CustomRenderPass : ScriptableRenderPass
    {
        private readonly string _profilerTag;
        private readonly ProfilingSampler _profilingSampler;

        private readonly Material _overrideMaterial;
        private readonly int _overrideMaterialPassIndex;
        private readonly LayerMask _layerMask;

        private readonly RenderStateBlock _renderStateBlock;
        private readonly List<ShaderTagId> _shaderTagIds = new();

        public CustomRenderPass(
                string tag,
                string shaderTagName,
                RenderPassEvent passEvent,
                LayerMask layerMask
        )
        {
            _profilerTag = tag;
            _profilingSampler = new ProfilingSampler(tag);
            renderPassEvent = passEvent;

            _layerMask = layerMask;
            _renderStateBlock = new RenderStateBlock(RenderStateMask.Nothing);

            if (!string.IsNullOrEmpty(shaderTagName))
            {
                _shaderTagIds.Add(new ShaderTagId(shaderTagName));
            }
        }

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

            foreach (var shaderTag in _shaderTagIds)
            {
                using var builder = renderGraph.AddRasterRenderPass<PassData>(
                        $"{_profilerTag}_{shaderTag.name}",
                        out var passData,
                        _profilingSampler
                );

                passData.ColorTarget = resourceData.activeColorTexture;

                builder.SetRenderAttachment(passData.ColorTarget, 0);
                builder.SetRenderAttachmentDepth(resourceData.activeDepthTexture);

                var sorting = SortingCriteria.CommonOpaque;

                var rendererListDesc = new RendererListDesc(
                        shaderTag,
                        renderingData.cullResults,
                        cameraData.camera
                )
                {
                    sortingCriteria = sorting,
                    renderQueueRange = RenderQueueRange.opaque,
                    layerMask = _layerMask,
                    overrideMaterial = _overrideMaterial,
                    overrideMaterialPassIndex = _overrideMaterialPassIndex,
                    stateBlock = _renderStateBlock,
                    rendererConfiguration = PerObjectData.None,
                };

                passData.RendererList = renderGraph.CreateRendererList(rendererListDesc);
                builder.UseRendererList(passData.RendererList);

                builder.AllowPassCulling(false);
                builder.SetRenderFunc((PassData data, RasterGraphContext ctx) => { ctx.cmd.DrawRendererList(data.RendererList); });
            }
        }

        private class PassData
        {
            public TextureHandle ColorTarget;
            public RendererListHandle RendererList;
        }
    }
}


CustomMultipass.shader
Shader "Example/CustomMultipass"
{
    Properties
    {
        _MainTex("Main Texture", 2D) = "white" {}
        _Color("Color Tint", Color) = (1,1,1,1)
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" }

        // === 패스 1: 스텐실를 작성 ===
        Pass
        {
            Name "WriteStencil"
            Tags { "LightMode" = "WriteStencil" }

            Stencil
            {
                Ref 1
                Comp always
                Pass replace
            }

            ZWrite Off
            ColorMask 0

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct Attributes
            {
                float4 positionOS : POSITION;
            };

            struct Varyings
            {
                float4 positionHCS : SV_POSITION;
            };

            Varyings vert(Attributes IN)
            {
                Varyings OUT;
                OUT.positionHCS = TransformObjectToHClip(IN.positionOS);
                return OUT;
            }

            half4 frag(Varyings IN) : SV_Target
            {
                return 0;
            }
            ENDHLSL
        }

        // === 패스 2: 스텐실에 마스킹된 범위만 렌더링 ===
        Pass
        {
            Name "RenderMasked"
            Tags { "LightMode" = "UniversalForward" }

            Stencil
            {
                Ref 1
                Comp equal
                Pass keep
            }

            ZWrite On
            ColorMask RGBA

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            CBUFFER_START(UnityPerMaterial)
                float4 _MainTex_ST;
                float4 _Color;
            CBUFFER_END

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct Varyings
            {
                float4 positionHCS : SV_POSITION;
                float2 uv : TEXCOORD0;
            };

            Varyings vert(Attributes IN)
            {
                Varyings OUT;
                OUT.positionHCS = TransformObjectToHClip(IN.positionOS);
                OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex);
                return OUT;
            }

            half4 frag(Varyings IN) : SV_Target
            {
                half4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
                return texColor * _Color;
            }
            ENDHLSL
        }
    }

    FallBack "Hidden/Shader Graph/FallbackError"
}

 그림 모델의 머티리얼에 CustomMultipass 쉐이더를 할당합니다. 또한 모델에 이 처리를 적용할 전용 레이어 이름인 CustomRenderFeatureLayer를 추가하고 할당합니다.

③ 사용 중인 UniversalRenderData 하단의 Add Render Feature 버튼을 눌러 CustomRenderFeature를 추가합니다. (모바일 기본 설정은 Assets/URP-Balanced-Renderer 사용) Layer Mask에 추가한 CustomRenderFeatureLayer만 설정합니다.

이상으로 구현이 완료되었습니다.

하얀색 구체가 그려진 것 외에는 평소와 다를 바 없어 보이지만, 쉐이더에 들어있는 두 개의 패스를 이용한 멀티패스 렌더링으로 그려져 있습니다.

다음 시간에는 구현 내용에 대해 해설편으로 설명하겠습니다.

Category

Tag