Unity、URP、RenderFeature를 사용한 멀티 렌더링【 해설편 】
-
2025년 10월 16일
안녕하세요.
Cocone Engineering에서 그래픽 엔지니어 겸 테크니컬 아티스트 TechLead를 맡고 있는 타카노 마사야입니다.
지난 글에서는 RenderFeature를 이용한 멀티패스 렌더링의 구체적인 구현 방법에 대해 설명했습니다. 이번 글에서는 구현 내용에 대해 설명하겠습니다. (Unity 6000.2.2f1、URP 17.2.0、Forward Rendering Path 으로 검증)
멀티패스 렌더링 처리 흐름
멀티패스 렌더링 처리는 다음과 같은 흐름으로 실행됩니다
① 커스텀 RenderFeature로 셰이더의 WriteStencil 패스를 렌더링 (1번째 패스)
② Unity 표준 렌더링 프로세스에 의해 UniversalForward 패스를 렌더링 (2번째 패스)
위의 흐름으로 하나의 셰이더에 있는 여러 패스를 호출하여 렌더링을 수행하고 있습니다
아래는 구현 소스를 설명합니다
CustomRenderPass.cs
렌더링 처리를 하는 CustomRenderPass 클래스를 구현한 소스입니다.
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;
}
}
}CustomRenderPassコンストラクタ
public CustomRenderPass(string tag, string shaderTagName, RenderPassEvent passEvent, LayerMask layerMask)・tag: 프로파일링 및 RenderGraph의 라벨용
・shaderTagName: 이 이름을 기준으로 렌더링할 ShaderPass를 선별합니다
・renderPassEvent: 이 패스가 실행되는 시점(예:BeforeRenderingOpaques등)
・layerMask: 대상 오브젝트의 레이어
RecordRenderGraph 함수
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)이 함수는 URP의 RenderGraph 모드에서 활성화되며 AddRasterRenderPass를 통해 드로잉 패스를 등록합니다. RenderGraph는 URP14 이후 RenderFeature에 도입된 구조로 다음과 같은 이점이 있습니다.
・패 간 의존성 자동 해결
・메모리 사용량 최적화(리소스 수명 관리)
・불필요한 패 건너뛰기(AllowPassCulling 등)
(현재 URP는 RenderGraph가 표준이지만, 프로젝트에 따라 ‘RenderGraph를 비활성화’한 경우도 있습니다. 또한, 기존 RenderFeature 기반 처리와 혼용할 수 없으니 주의하시기 바랍니다.)
카메라 렌더링 리소스 데이터 가져오기
var cameraData = frameData.Get<UniversalCameraData>();
var renderingData = frameData.Get<UniversalRenderingData>();
var resourceData = frameData.Get<UniversalResourceData>();URP에서 현재 프레임의 컨텍스트를 가져와 카메라 등 다양한 데이터를 이용할 수 있습니다.
드로잉 패스 등록 및 RendererList 설정
RendererListDesc는 ‘어떤 오브젝트를 어떤 조건으로 렌더링할 것인지’를 정리한 구조체입니다. 여기서 지정한 조건에 따라 Unity가 RendererList를 생성하고, 그 리스트를 모아 렌더링합니다.
var rendererListDesc = new RendererListDesc(
shaderTag,
renderingData.cullResults,
cameraData.camera
)・shaderTag: Shader의 패스명으로 대상을 필터링합니다
・cullResults: 컬링이 완료된 오브젝트 목록입니다
・camera: 현재 카메라를 지정합니다
{
sortingCriteria = SortingCriteria.CommonOpaque,
renderQueueRange = RenderQueueRange.opaque,
layerMask = _layerMask,
overrideMaterial = _overrideMaterial,
overrideMaterialPassIndex = _overrideMaterialPassIndex,
stateBlock = _renderStateBlock,
rendererConfiguration = PerObjectData.None,
}・sortingCriteria: 그리기 순서의 기준을 지정합니다. SortingCriteria.CommonOpaque는 ‘불투명 오브젝트의 표준 정렬’로, 카메라와 거리가 가까운 것부터 순서대로 그리는 방식입니다
・renderQueueRange: 대상 렌더링 큐의 범위를 지정합니다. RenderQueueRange.opaque는 RenderQueue <= 2500 범위에 해당합니다
・layerMask: Unity의 레이어를 사용하여 렌더링 대상을 좁힙니다
・overrideMaterial: 여기서 지정한 머티리얼로 대상 오브젝트를 강제로 렌더링하며, null이면 할당된 머티리얼이 사용됩니다 (이번에는 null로 사용)
・overrideMaterialPassIndex: 지정한 머티리얼의 경우 어떤 패스를 사용할지 선택할 수 있습니다
・stateBlock: 렌더링 시 적용할 렌더 상태를 덮어씁니다. RenderStateMask.Nothing 설정에서 비활성화되어 있지만, 깊이 테스트 활성화/비활성화 바꾸기, 블렌드 설정 강제 변경, 스텐실 작업 덮어쓰기 등을 설정할 수 있습니다
・rendererConfiguration: 오브젝트 별로 GPU에 전달할 추가 데이터를 지정하며, None으로 지정하면 추가 정보 없이 단순하게 렌더링합니다. PerObjectData.LightProbe(라이트 프로브 정보 사용), PerObjectData.ReflectionProbes(리플렉션 프로브 정보 사용), PerObjectData.Lightmaps(라이트맵 정보 사용) 등을 지정할 수 있습니다
렌더링 실행
builder.SetRenderFunc((PassData data, RasterGraphContext ctx) => {
ctx.cmd.DrawRendererList(data.RendererList);
});‘이 RenderPass에서 실제로 어떤 렌더링 명령을 실행할 것인가’를 정의하는 부분입니다.
RenderGraph는 ‘어떤 리소스를 사용할 것인가’와 ‘어떻게 처리할 것인가’를 구분하여 관리합니다. SetRenderFunc는 그 ‘어떻게 처리할 것인가’를 구체적으로 GPU에 전달하는 함수 포인터 같은 등록입니다.
・PassData data: 이 패스가 필요로 하는 정보가 정리된 값입니다
・RasterGraphContext ctx: RenderGraph가 제공하는 런타임 컨텍스트입니다
・ctx.cmd.DrawRendererList(data.RendererList): RendererList에 등록된 객체를 일괄적으로 렌더링하는 부분입니다
CustomRenderFeature.cs
설정용 GUI, 렌더링 처리를 하는 CustomRenderPass를 호출하기 위해 사용하는 CustomRenderFeature 클래스를 구현한 소스입니다.
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);
}
}
}Settings 클래스
[Serializable]
public class Settings
{
public string passTag = "CustomPass";
public string drawTagName = "WriteStencil";
public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingOpaques;
public LayerMask layerMask = ~0
}・passTag: RenderPass의 이름. 프로파일러에서 식별에 사용할 수 있습니다
・drawTagName: ShaderTagId에 전달할 문자열. WriteStencil”이라면, Shader의 Pass { Name “WriteStencil” }만 대상으로 합니다
・renderPassEvent: 이 패스를 언제 실행할 것인지. BeforeRenderingOpaques는 불투명 오브젝트를 그리기 전에 실행합니다
・layerMask: 어떤 레이어의 오브젝트를 대상으로 할 것인가. ~0는 ‘모든 레이어’를 지정합니다
Create 함수
public override void Create()
{
renderPass = new CustomRenderPass(
settings.passTag,
settings.drawTagName,
settings.renderPassEvent,
settings.layerMask
);
}
URP가 RendererFeature를 초기화할 때 호출됩니다. 여기서 CustomRenderPass의 인스턴스를 생성하고 Settings의 내용을 전달합니다. 이 시점에서는 아직 패스가 추가되지 않고 준비만 되어 있는 상태입니다.
AddRenderPasses 함수
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
// 프리뷰용 카메라에서는 스킵
if (renderingData.cameraData.cameraType == CameraType.Preview)
{
return;
}
renderer.EnqueuePass(renderPass);
}
매 프레임마다 호출되어 추가할 패스를 결정합니다. CameraType.Preview일 때는 건너뛰고 프리뷰용 카메라로 처리하지 않도록 하고 있습니다. renderer.EnqueuePass(renderPass)에서 작성한 CustomRenderPass를 큐에 등록. 렌더링 파이프라인에 편입되어 매 프레임마다 실행하게 됩니다
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"
}패스 1: 스텐실 작성
Pass
{
Name "WriteStencil"
Tags { "LightMode" = "WriteStencil" }
Stencil
{
Ref 1
Comp always
Pass replace
}
ZWrite Off
ColorMask 0
...
}
스텐실 설정
・Ref 1: 참조값에 1을 지정
・Comp always: 항상 통과
・Pass replace: 통과하면 스텐실 값을 1로 ‘교체’
그리기 설정
・ZWrite Off: 깊이 버퍼에 쓰지 않는다
・ColorMask 0: 컬러 버퍼에 아무것도 그리지 않는다
이 패스는 ‘대상 형상에 따라 스텐실 버퍼를 업데이트할 뿐’ 깊이 버퍼, 컬러 버퍼 모두에 쓰기를 하지 않습니다
패스 2: 스텐실에 마스킹된 범위만 그리기
Pass
{
Name "RenderMasked"
Tags { "LightMode" = "UniversalForward" }
Stencil
{
Ref 1
Comp equal
Pass keep
}
ZWrite On
ColorMask RGBA
...
}
스텐실 설정
・Ref 1: 판정용 기준값으로 ‘1’을 지정
・Comp equal: 스텐실 값이 ‘1’과 같으면 통과
・Pass keep: 스텐실 값은 변경하지 않음
그리기 설정
・ZWrite On: 깊이 버퍼에 쓰기(일반 드로잉과 동일)
・ColorMask RGBA: 모든 색상을 쓰기 (일반 그리기와 동일)
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;
}
頂点シェーダは、オブジェクト座標のクリップ座標変換、UV値の処理を実施。 フラグメントシェーダーはテクスチャをサンプルし、カラー値と乗算して出力しているだけの処理です。
パス1でステンシル値が1に設定されたピクセルにのみ、カラーと深度を書き込みます
버텍스 쉐이더에서는 오브젝트 좌표를 클립 좌표로 변환하고, UV 값 처리를 수행합니다. 프래그먼트 쉐이더는 텍스처를 샘플링하여 색상 값과 곱한 뒤 출력하는 간단한 처리입니다.
패스 1에서는 스텐실 값이 1로 설정된 픽셀에만 색상과 깊이를 기록합니다
마지막으로
렌더링 처리의 기초라 할 수 있는 멀티 패스 렌더링을 URP에서 구현하는 방법을 설명했습니다.
이번 예제는 최소 구성으로 되어 있지만, 이를 응용하면 포털, 마스크 표현, 아웃 라인등 다양한 시각적 표현을 구현할 수 있습니다.
이 글이 여러분께 도움이 되었기를 바랍니다.