
본 문서는 실무에서 Unity URP 기반 프로젝트를 진행하던 중 Decal Render Feature와 Depth Texture Mode 간의 충돌 문제를 경험하고, 이를 해결하기 위해 여러 방법을 연구한 결과를 정리한 내용입니다. ( feat. 김강언 시니어 TA )
URP의 Decal Render Feature와 "After Transparents" Depth Texture Mode는 근본적인 아키텍처 충돌로 인해 함께 사용할 수 없습니다.
Unity 공식 입장
Unity Support 팀에 따르면 "엔진 코드 수정 없이는 해결책이 없다"는 고 보고됨. DBuffer 데칼은 RenderPass Event 201에서 깊이 텍스처를 요구하지만, "After Transparents"는 Event 500까지 깊이 복사를 지연시키려 합니다. 이 타이밍 충돌은 URP 12.0(Unity 2021.2)부터 Unity 6까지 지속되고 있습니다.
렌더링 파이프라인은 다음과 같은 순서로 실행됩니다.
BeforeRenderingShadows(50)
↓
AfterRenderingShadows(100)
↓
BeforeRenderingPrePasses(150)
↓
AfterRenderingPrePasses(200) ← DBuffer 데칼이 여기서 깊이 요구(201)
↓
BeforeRenderingOpaques(250)
↓
AfterRenderingOpaques(300)
↓
BeforeRenderingSkybox(350)
↓
BeforeRenderingTransparents(450)
↓
AfterRenderingTransparents(500) ← "After Transparents" 설정 타겟
↓
BeforeRenderingPostProcessing(550)
↓
AfterRendering(1000)
DBuffer 데칼의 요구사항
DBuffer 데칼은 RenderPassEvent.AfterRenderingPrePasses + 1(이벤트 201)에서 실행됩니다. 이때 ScriptableRenderPassInput.Depth와 ScriptableRenderPassInput.Normal이 필수 입력으로 요구됩니다. 그 결과 UniversalRenderer가 자동으로 깊이 복사를 이벤트 200에서 강제 실행하게 됩니다.
데칼 없이 After Transparents를 사용하는 경우
DBuffer 데칼을 활성화한 경우
요약하면, DBuffer 데칼을 활성화하면 렌더링 파이프라인 초반부(이벤트 200)에서 깊이 복사가 강제로 실행되어, "After Transparents" 설정(이벤트 500)이 의도한 대로 작동하지 않게 됩니다. 이는 데칼이 깊이 정보를 미리 필요로 하기 때문에 발생하는 근본적인 타이밍 충돌입니다.
DBuffer 데칼의 조기 깊이 복사를 그대로 두고, 투명 오브젝트 렌더링 후 추가로 깊이를 다시 복사합니다.
DBuffer 데칼의 기능을 유지하면서 투명 오브젝트에 대한 정확한 깊이 정보를 제공합니다.
PC/콘솔 프로젝트, 고품질 그래픽이 요구되는 프로젝트일 경우 검토 할 가치가 있으며 추가 렌더패스로 인한 오버헤드 발생 할 우려가 있습니다.
Unity URP의 Decal과 Depth Texture 충돌 문제는 단일 정답이 없는 복잡한 이슈입니다. 프로젝트의 특성, 타겟 플랫폼, 개발 리소스를 종합적으로 고려하여 가장 적합한 솔루션을 선택하고, 지속적으로 모니터링하며 개선해 나가는 것이 최선의 접근 방법입니다.
Cyanilux의 CopyDepthFeature 사용
커뮤니티에서 가장 신뢰받는 해결책은 AfterRenderingTransparents에서 수동으로 깊이를 복사하여 DBuffer 데칼의 조기 복사를 우회하는 방법입니다.
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Experimental.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.Rendering.Universal.Internal;
public class CopyDepthFeature : ScriptableRendererFeature
{
private class SetGlobalTexture : ScriptableRenderPass
{
private RTHandle rt;
public SetGlobalTexture(RenderPassEvent renderPassEvent)
{
base.renderPassEvent = renderPassEvent;
}
public void Setup(RTHandle rt)
{
this.rt = rt;
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get();
cmd.SetGlobalTexture("_CameraDepthTexture", rt);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
}
private class CopyDepthPass : ScriptableRenderPass
{
private RTHandle source;
private RTHandle destination;
public CopyDepthPass(RenderPassEvent renderPassEvent)
{
base.renderPassEvent = renderPassEvent;
}
public void Setup(RTHandle source, RTHandle destination)
{
this.source = source;
this.destination = destination;
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get("Copy Depth for Transparents");
cmd.CopyTexture(source, destination);
context.ExecuteCommandBuffer(cmd);
CommandBufferPool.Release(cmd);
}
}
[SerializeField] private RenderPassEvent renderPassEvent = RenderPassEvent.AfterRenderingTransparents;
[SerializeField] private UniversalRendererData rendererData;
private CopyDepthPass copyDepthPass;
private SetGlobalTexture setGlobalTexturePass;
private RTHandle depthTextureHandle;
public override void Create()
{
copyDepthPass = new CopyDepthPass(renderPassEvent);
setGlobalTexturePass = new SetGlobalTexture(renderPassEvent + 1);
RenderTextureDescriptor descriptor = new RenderTextureDescriptor(
Screen.width,
Screen.height,
RenderTextureFormat.Depth,
32
);
RenderingUtils.ReAllocateIfNeeded(
ref depthTextureHandle,
descriptor,
FilterMode.Point,
TextureWrapMode.Clamp,
name: "_CameraDepthTexture"
);
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (rendererData == null) return;
RTHandle cameraDepthTexture = renderer.cameraDepthTargetHandle;
copyDepthPass.Setup(cameraDepthTexture, depthTextureHandle);
setGlobalTexturePass.Setup(depthTextureHandle);
renderer.EnqueuePass(copyDepthPass);
renderer.EnqueuePass(setGlobalTexturePass);
}
protected override void Dispose(bool disposing)
{
depthTextureHandle?.Release();
}
}
다음 단계를 순서대로 진행합니다.
첫째, 위 스크립트를 프로젝트에 추가합니다.
둘째, Universal Renderer Data 에셋을 엽니다.
셋째, Add Renderer Feature를 선택하여 CopyDepthFeature를 추가합니다.
넷째, Feature 리스트에서 Decal Feature 아래에 배치하는 것이 중요합니다.
다섯째, Event를 AfterRenderingTransparents로 설정합니다.
여섯째, Renderer Asset 필드에 현재 Renderer Data를 할당합니다.
가장 간단한 해결책입니다.
첫째, Universal Renderer Data 에셋을 엽니다.
둘째, Decal Renderer Feature를 찾습니다.
셋째, Technique 설정을 DBuffer에서 Screen Space로 변경합니다.
설정 변경만으로 즉시 해결할 수 있습니다. DepthNormal prepass 요구사항이 제거되며, 이벤트 300 이후 실행되므로 충돌이 발생하지 않습니다.
노멀 블렌딩만 지원되며 albedo나 roughness 블렌딩이 불가능합니다. Deferred 렌더링과 Accurate G-buffer normals 조합에서는 작동하지 않습니다. 타일 기반 GPU(모바일)에서 prepass가 발생할 수 있습니다.
Decal Renderer Feature를 완전히 우회하는 방법입니다.
// 간단한 Screen Space 데칼 셰이더 구조
Pass
{
Name "ScreenSpaceDecal"
Tags { "Queue" = "Transparent" }
ZWrite Off
Blend SrcAlpha OneMinusSrcAlpha
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
struct Varyings
{
float4 positionCS : SV_POSITION;
float4 screenPos : TEXCOORD0;
float3 ray : TEXCOORD1;
};
Varyings vert(Attributes input)
{
Varyings output;
output.positionCS = TransformObjectToHClip([input.positionOS.xyz](http://input.positionOS.xyz));
output.screenPos = ComputeScreenPos(output.positionCS);
output.ray = mul(UNITY_MATRIX_I_V, float4([output.positionCS.xyz](http://output.positionCS.xyz), 1.0)).xyz;
return output;
}
half4 frag(Varyings input) : SV_Target
{
float2 screenUV = input.screenPos.xy / input.screenPos.w;
float depth = SampleSceneDepth(screenUV);
float3 worldPos = ComputeWorldSpacePosition(screenUV, depth, UNITY_MATRIX_I_VP);
// 데칼 박스 안에 있는지 확인
float3 localPos = mul(unity_WorldToObject, float4(worldPos, 1.0)).xyz;
clip(0.5 - abs(localPos));
// 데칼 텍스처 샘플링
float2 decalUV = localPos.xz + 0.5;
half4 decalColor = SAMPLE_TEXTURE2D(_DecalTexture, sampler_DecalTexture, decalUV);
return decalColor;
}
ENDHLSL
}
ColinLeung-NiloCat의 UnityURPUnlitScreenSpaceDecalShader와 Daniel Ilett의 Shader Graph 튜토리얼을 참고할 수 있습니다.
Universal Renderer의 Rendering Path를 Deferred로 설정합니다. G-buffer의 일부로 깊이가 생성되므로 별도 복사가 불필요합니다.
깊이가 G-buffer의 일부로 "무료"로 제공됩니다. 다수의 라이트를 사용하는 씬에서 효율적입니다.
G-buffer 메모리 오버헤드가 발생하므로 모바일 플랫폼에서 주의가 필요합니다. Accurate G-buffer normals와 Screen Space 데칼 노멀 블렌딩을 함께 사용할 수 없습니다. 구형 모바일 디바이스에서는 지원되지 않습니다.
플랫폼별로 다른 전략을 사용하는 방법입니다.
public class ConditionalDecalManager : MonoBehaviour
{
[SerializeField] private UniversalRendererData rendererData;
void Start()
{
var decalFeature = rendererData.rendererFeatures
.OfType<DecalRendererFeature>().FirstOrDefault();
if (decalFeature != null)
{
// 모바일에서는 데칼 비활성화
#if UNITY_ANDROID || UNITY_IOS
decalFeature.SetActive(false);
#else
decalFeature.SetActive(true);
#endif
}
}
}
첫째, 커스텀 깊이 복사 패스에서 ConfigureInput(ScriptableRenderPassInput.Depth)를 호출하면 안 됩니다. 이것은 조기 깊이 복사를 다시 유발합니다.
둘째, RTHandle 미해제는 메모리 누수를 발생시킵니다.
셋째, Feature 순서를 잘못 배치하면 문제가 발생합니다. 깊이 복사는 데칼 아래에 배치해야 합니다.
넷째, 플랫폼별 깊이 포맷 차이를 무시하면 안 됩니다.
다섯째, Unity 버전 업그레이드 시 커스텀 코드를 업데이트하지 않으면 문제가 발생할 수 있습니다.
Window → Analysis → Frame Debugger를 열어 다음 이벤트들을 확인합니다.
"Copy Depth" 이벤트의 위치를 확인합니다.
"DBuffer Decals" 실행 타이밍을 확인합니다.
"_CameraDepthTexture" 업데이트 시점을 확인합니다.
Unity 6 (2024)
미해결 상태입니다. Render Graph API 변경이 있었습니다.
2025년 10월 입니다. 모바일 디퍼드 플러스 사용 검토 해 볼만 합니다. 고객사 프로젝트 릴리스 한 사례중에 모바일 디퍼드 사용 사례가 있습니다.
Cyanilux의 CopyDepthFeature를 구현하는 것을 권장합니다.
DBuffer 데칼을 유지하면서 수동 깊이 복사를 수행합니다.
성능이 허용된다면 추가 렌더패스를 수용합니다.
Quality Settings별로 다른 전략을 사용합니다.
모바일에서는 커스텀 셰이더 또는 베이킹(동적 데칼이 아니라면)을 사용합니다.
PC에서는 DBuffer와 커스텀 깊이 복사를 사용합니다.
조건부 컴파일로 플랫폼별 코드를 관리합니다.
Frame Debugger로 깊이 복사 타이밍을 확인합니다.
현재 사용 중인 데칼 기법을 파악합니다.
타겟 플랫폼을 확인합니다.
Screen Space 기법으로 전환하여 테스트합니다.
노멀만으로 충분하다면 해당 방법을 채택합니다.
타겟 플랫폼에서 프로파일링을 수행합니다.
Depth texture와 데칼 비용을 정량화합니다.
CopyDepthFeature를 Decal Feature 아래에 추가합니다.
AfterRenderingTransparents로 이벤트를 설정합니다.
RTHandle 관리를 확인합니다. Dispose에서 Release를 호출해야 합니다.
투명 오브젝트 깊이 캡처를 확인합니다.
데칼이 투명 오브젝트에 정상적으로 투영되는지 테스트합니다.
Frame Debugger로 최종 확인을 진행합니다.
CommandBuffer.CopyTexture를 사용하면 Blit보다 빠릅니다.
// GPU-GPU 직접 복사 (권장)
cmd.CopyTexture(sourceDepth, destDepth);
// Blit 사용 (호환성이 필요한 경우만)
// Blitter.BlitCameraTexture(cmd, sourceDepth, destDepth, material, 0);
_CameraDepthTexture를 시각화하는 셰이더입니다.
half4 frag(Varyings input) : SV_Target
{
float depth = SampleSceneDepth(input.uv);
return half4(depth, depth, depth, 1.0);
}
Pass
{
Name "DepthOnly"
Tags { "LightMode" = "DepthOnly" }
ZWrite On
ColorMask 0 // 깊이만 쓰기
HLSLPROGRAM
#pragma vertex DepthOnlyVertex
#pragma fragment DepthOnlyFragment
// 알파 클리핑 지원
#ifdef _ALPHATEST_ON
clip(alpha - _Cutoff);
#endif
ENDHLSL
}
Cyanilux - Custom Renderer Features
Unity Graphics GitHub - DBufferRenderPass.cs
Unity Issue Tracker - Decal Layer Texture lifetime
Daniel Ilett - Decals and Stickers Tutorial
ColinLeung-NiloCat - Screen Space Decal Shader
Unity URP의 Decal과 After Transparents 충돌은 아키텍처 수준의 설계 제약입니다. Unity가 공식적으로 해결하지 못한 이 문제는 커뮤니티의 창의적인 우회 방법들로 극복 가능합니다.
핵심 메시지
완벽한 해결책은 없지만, 프로젝트에 맞는 우회 방법은 존재합니다.
성능과 품질의 트레이드오프를 이해하고 선택해야 합니다.
커뮤니티 솔루션들은 프로덕션에서 검증되었습니다.
가장 중요한 것은 프로젝트의 구체적 요구사항과 타겟 플랫폼에 맞는 전략을 선택하는 것입니다.