커스텀 2D Light에서 생성한 빛 객체는 그림자를 적용할 수 없던 반쪽자리 빛으로, 여기에 그림자 또한 동작하도록 구현해보았다.
이 그림자 구현에는 기존 커스텀 빛 관련 스크립트와 셰이더를 일부 이용해서 구현해주었다.
왼쪽의 노란 빛과 사과의 그림자가 커스텀 라이트와 섀도우, 오른쪽의 오렌지가 유니티 자체적인 빛과 그림자이다.
이전 빛 처리에 사용했던 Custom2DLightFeature에 그림자 관련 코드들을 넣어서 업데이트 해주었다.
//셰이더에서 사용할 패스별 이름 설정
private readonly ShaderTagId m_OccluderShaderTagId = new ShaderTagId("Custom2DOccluder");
private readonly ShaderTagId m_ShadowExtrusionShaderTagId = new ShaderTagId("Custom2DShadowCaster");
//그림자 텍스처를 연결할 프로퍼티 ID 생성
private static readonly int CustomShadowTextureID = Shader.PropertyToID("_CustomShadowTexture");
그림자 처리에 사용할 두 종류의 셰이더 패스 태그를 준비하고, 이후 완성된 그림자 텍스쳐를 전역으로 등록하기 위한 프로퍼티 ID를 생성해주었다.
이번 그림자 처리에는 빛과 다르게 한번이 아닌 두번의 렌더링이 필요하며, 그를 위한 두 패스 태그와 최종 그림자 텍스쳐를 외부에서 사용할 수 있도록 프로퍼티 ID를 선언해둔 것이다.
private class ShadowPassData
{
public RendererListHandle occluderRendererList;
public RendererListHandle shadowExtrusionRendererList;
}
같은 이유로, 두개의 렌더러 리스트가 묶여있는 ShadowPassData를 생성하여, 뒤쪽의 실행 단계에서 두번의 렌더링을 진행할 수 있게 준비한다.
//기존 커스텀 빛 생성 때의 RecordRenderGraph 메소드
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
//기존 빛 관련 내용들..
//그림자가 그려질 그림자 텍스쳐 생성, 기존 빛의 설정(RGB 색상용의 desc) 사용
TextureHandle shadowTexture = UniversalRenderer.CreateRenderGraphTexture(
renderGraph, desc, "_CustomShadowTexture", clear: false, filterMode: FilterMode.Bilinear
);
//현재 카메라 설정을 가져옴
RenderTextureDescriptor depthDesc = cameraData.cameraTargetDescriptor;
//생성할 텍스쳐를 색상(RGB)기반 대신, Depth/Stencil 기반 텍스쳐로 설정
depthDesc.colorFormat = RenderTextureFormat.Depth;
depthDesc.depthBufferBits = 24;
depthDesc.msaaSamples = 1;
//방금 생성한 depthDesc를 사용하는 스텐실 버퍼용 텍스쳐
TextureHandle shadowDepthTexture = UniversalRenderer.CreateRenderGraphTexture(
renderGraph, depthDesc, "_CustomShadowDepthTexture", clear: false, filterMode: FilterMode.Point
);
//그림자용 래스터 패스 등록, 다음 파트에서 추가 설명 예정
using (var builder = renderGraph.AddRasterRenderPass<ShadowPassData>("Custom 2D Shadow Pass", out var passData))
}
텍스쳐를 생성하고, 패스를 등록하는 RecordRenderGraph 메소드 내부에 그림자 관련 코드들이 추가되었다.
그림자 처리를 위해 텍스쳐가 두개 생성되었는데, 첫번째로는 기존 빛 텍스쳐 생성에 사용됐던 desc 설정값을 이용한, 색상 처리용 그림자 텍스쳐이다.
이 그림자 텍스쳐에는 최종적으로 실제 그림자들이 그려지게 될 예정이다.
그 다음으로는 새롭게 카메라 설정을 가져와서, 색상 기반 대신 Depth/Stencil 기반의 텍스쳐가 될 수 있도록 설정해주었는데, 실제로는 뎁스는 사용하지 않고, 스텐실만 사용해줄 계획이다.
이렇게 설정된 depthDesc를 이용하여, 두번째 텍스쳐인 스텐실 버퍼용 텍스쳐를 생성해주는데, 이 두번째 텍스쳐는 그림자에서 현재 물체 범위를 제외해주기 위해 사용된다.
빛과 빛을 받는 객체에 대해 그림자 생성을 판단할 때, 가장 간단한 방법은 객체의 영역에서 빛을 받았을 때, 그 영역부터 빛의 방향에 따라서 쭉 그림자를 늘어뜨려 주는것이다.
다만 이렇게 되면 빛을 받은 객체 자신 또한 그림자에 휩싸여서 그림자의 색인 검은 계열의 색으로 물들게 된다.
이러한 현상을 방지하기 위해, 두번째 텍스쳐를 이용해서 현 객체에 맞도록 스텐실 버퍼를 생성해주고, 그림자 텍스쳐를 작업하면서 이 스텐실 버퍼 텍스쳐를 참고해서 현재 객체의 범위에는 그림자가 생성되지 않도록 예외처리를 진행해준다.
//렌더패스 등록
using (var builder = renderGraph.AddRasterRenderPass<ShadowPassData>("Custom 2D Shadow Pass", out var passData))
{
//작업이 진행될 각 텍스쳐들을 연결해줌
builder.SetRenderAttachment(shadowTexture, 0, AccessFlags.Write);
builder.SetRenderAttachmentDepth(shadowDepthTexture, AccessFlags.Write);
//렌더러들의 작업 순서 지정(카메라 기본 정렬 기준을 그대로 사용)
SortingCriteria sortingCriteria = cameraData.defaultOpaqueSortFlags;
//그림자를 생성할 렌더러 필터 설정
FilteringSettings filteringSettings = new FilteringSettings(RenderQueueRange.all, m_Settings.shadowLayerMask);
//Custom2DOccluder 패스 태그를 이용한 DrawingSettings 생성
DrawingSettings occluderDrawSettings = RenderingUtils.CreateDrawingSettings(
m_OccluderShaderTagId, renderingData, cameraData, lightData, sortingCriteria
);
//설정들을 이용해서 첫번째 렌더러 리스트 생성
RendererListParams occluderParams = new RendererListParams(renderingData.cullResults, occluderDrawSettings, filteringSettings);
passData.occluderRendererList = renderGraph.CreateRendererList(occluderParams);
//Custom2DShadowCaster 패스 태그를 이용한 DrawingSettings 생성
DrawingSettings extrusionDrawSettings = RenderingUtils.CreateDrawingSettings(
m_ShadowExtrusionShaderTagId, renderingData, cameraData, lightData, sortingCriteria
);
//설정들을 이용해서 두번째 렌더러 리스트 생성
RendererListParams extrusionParams = new RendererListParams(renderingData.cullResults, extrusionDrawSettings, filteringSettings);
passData.shadowExtrusionRendererList = renderGraph.CreateRendererList(extrusionParams);
//렌더러 리스트 등록
builder.UseRendererList(passData.occluderRendererList);
builder.UseRendererList(passData.shadowExtrusionRendererList);
//그림자 텍스쳐 전역 등록
builder.SetGlobalTextureAfterPass(shadowTexture, CustomShadowTextureID);
builder.AllowPassCulling(false);
//실제 동작 등록, 다음 파트에서 추가 설명 예정
builder.SetRenderFunc((ShadowPassData data, RasterGraphContext context)
}
렌더 패스는 커스텀 라이트 때의 흐름과 비슷하게 구성되어있다.
먼저 각 작업별 텍스쳐를 등록해주는데, SetRenderAttachment와 SetRenderAttachmentDepth로 등록 작업에 차이가 있는 것을 확인할 수 있다.
이는 렌더 패스에서 작업된 컬러 텍스쳐와 뎁스 텍스쳐를 의미하는것으로, 그에 맞춰 그림자 텍스쳐와 스텐실 버퍼용 텍스쳐를 각각 등록해주었다.
다음으로는 그림자 생성 필터링 설정과 DrawingSettings을 생성하고, 이런 설정들을 통해 각각 렌더러 리스트를 생성해주었다.
각 텍스쳐와 목표에 맞춰서, 그림자용 렌더러 리스트는 Custom2DShadowCaster 패스 태그를 의미하는 m_ShadowExtrusionShaderTagId를, 스텐실용 렌더러 리스트는 Custom2DOccluder 패스 태그를 의미하는 m_OccluderShaderTagId를 지정해서 생성해주었다.
이렇게 생성된 두 리스트를 각각 패스에 등록해주고, 그림자 텍스쳐를 다른 위치에서도 호출할 수 있게 전역으로 등록해준 다음, 작업이 자동으로 제외되지 않게 AllowPassCulling 설정을 해주며, 실제 동작 파트로 넘어간다.
builder.SetRenderFunc((ShadowPassData data, RasterGraphContext context) =>
{
//렌더 타겟 전부 초기화(색상은 블랙, 깊이 버퍼는 1, 스텐실 버퍼는 0)
context.cmd.ClearRenderTarget(RTClearFlags.All, Color.black, 1.0f, 0);
//스텐실 버퍼 기록
context.cmd.DrawRendererList(data.occluderRendererList);
//스텐실 버퍼 영역 확인 후, 영역 외에 그림자 영역 지정
context.cmd.DrawRendererList(data.shadowExtrusionRendererList);
});
작업은 총 3단계로, 렌더 타겟을 전부 깔끔하게 초기화 하며 시작한다.
초기화 후, 두번째 작업은 먼저 스텐실 버퍼와 관련된 작업으로, 간단하게 요약하면 오브젝트 범위에 맞춰서 스텐실 버퍼에 데이터를 등록해준다.
마지막인 세번째 작업 또한 간단하게 요약하면, 스텐실 버퍼를 확인해서 그림자 텍스쳐에 그림자 영역을 그려주도록 되어있다.
각 동작들은 셰이더쪽 코드를 통해 동작하기 때문에 자세한건 각 파트에서 설명할 예정이다.
이번 셰이더는 기존 빛 셰이더와 다르게, 두개의 패스로 구성되어있다.
Shader "Universal Render Pipeline/2D/Custom2DShadow"
{
//입력받는 설정값
Properties
{
//그림자로 이용할 텍스쳐, 그림자의 길이, 컷오프 알파값 기준
_MainTex ("Sprite Texture", 2D) = "white" {}
_ShadowLength ("Shadow Extrusion Length", Float) = 5.0
_Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
}
SubShader
{
Tags
{
"Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline"
}
//블렌딩, 컬링, 깊이처리 전부 Off
Blend Off
Cull Off
ZWrite Off
}
//계속해서 설명할 두 패스
Pass {...}
Pass {...}
}
}
이번 셰이더의 공용 코드는 위와 같다.
머티리얼을 통해 전달받는 값은 그림자로 이용할 텍스쳐와, 만들어질 그림자의 길이, 컷 오프 될 알파값 기준이 존재한다.
텍스쳐에 애초에 알파값이 낮아 반투명, 혹은 투명한 부분에 대해 그림자가 생성되면 어색하기 때문에 그에 대한 예외처리를 위해 컷오프 기준값 설정을 전달받는다.
Pass
{
Name "Custom2DOccluder"
//Custom2DOccluder를 통한 호출인 경우 실행되는 패스
Tags
{
"LightMode" = "Custom2DOccluder"
}
//렌더 타겟에 색상을 그리지 않음을 의미
ColorMask 0
//스텐실 버퍼 설정
Stencil
{
Ref 0
Comp Always //항상 통과
Pass IncrSat //스텐실 값 1 증가
}
HLSLPROGRAM
#include "Packages/com.unity.render-pipelines.universal/Shaders/2D/Include/Core2D.hlsl"
#pragma vertex LitVertex
#pragma fragment LitFragment
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
//버텍스 셰이더에서 사용
struct Attributes
{
//오브젝트의 버텍스 위치
float3 positionOS : POSITION;
};
//픽셀 셰이더에서 사용
struct Varyings
{
//버텍스 셰이더에서 처리한 좌표 전달용
float4 positionCS : SV_POSITION;
};
//버텍스 셰이더
Varyings LitVertex(Attributes input)
{
Varyings output;
//오브젝트 기반 정점의 좌표를 클립 기반 좌표로 변경해서 전달
output.positionCS = TransformObjectToHClip(input.positionOS);
return output;
}
//픽셀 셰이더는 ColorMask 0으로 인해 의미가 없는 상태
half4 LitFragment(Varyings input) : SV_Target
{
return 0;
}
ENDHLSL
}
첫번째 패스의 목표는 스텐실 버퍼에 현재 오브젝트의 모양을 기록해두는 것이 목표이기 때문에 별 다른 작업은 진행되지 않는다.
가장 중요한건 스텐실 영역으로, 현재 스텐실 버퍼는 렌더 타겟 초기화로 인해 전부 0으로만 채워져있는 상태이다.
이 셰이더 기반 오브젝트를 렌더링하면서, 이번 패스가 동작할 때, 스텐실 버퍼에 오브젝트 범위에 맞게 1이 채워지게 된다.
public class CustomLightBinder : MonoBehaviour
{
private static readonly int CustomLightPosWSID = Shader.PropertyToID("_CustomLightPosWS");
void Update()
{
// 현재 광원 오브젝트의 월드 좌표를 전역 셰이더 변수로 업데이트
Vector4 lightPosWS = transform.position;
Shader.SetGlobalVector(CustomLightPosWSID, lightPosWS);
}
}
두번째 패스로 넘어가기 전에 짧은 스크립트를 하나 봐야한다.
이 스크립트는 광원에 붙게 되는 스크립트인데, 단순하게 매 프레임 광원의 위치를 계속해서 업데이트 해주는 역할을 담당한다.
광원의 위치는 다음에 설명할 두번째 패스에서 사용된다.
Pass
{
Name "Custom2DShadowCaster"
//Custom2DShadowCaster를 통한 호출인 경우 실행되는 패스
Tags
{
"LightMode" = "Custom2DShadowCaster"
}
//스텐실 버퍼, 현재 픽셀의 스텐실이 1이 아닌 경우에만 작업을 적용한다는 의미
Stencil
{
Ref 1
Comp NotEqual
}
HLSLPROGRAM
#include "Packages/com.unity.render-pipelines.universal/Shaders/2D/Include/Core2D.hlsl"
#pragma vertex LitVertex
#pragma fragment LitFragment
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
//메인 텍스쳐와 샘플러 선언
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
//CustomLightBinder 스크립트에서 공유중인 실시간 광원 위치
float4 _CustomLightPosWS;
//텍스쳐(타일링 / 오프셋), 그림자 길이, 컷오프 기준값
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float _ShadowLength;
float _Cutoff;
CBUFFER_END
//버텍스 셰이더에서 사용
struct Attributes
{
float3 positionOS : POSITION;
float2 uv : TEXCOORD0;
//이 버텍스가 어느 인스턴스(객체)에 속해있는지 구별하기 위한 값
UNITY_VERTEX_INPUT_INSTANCE_ID
};
//픽셀 셰이더에서 사용
struct Varyings
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
Varyings LitVertex(Attributes input)
{
Varyings output;
//현 버텍스의 인스턴스(객체) 정보 세팅
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
//광원 위치를 오브젝트 공간(Object Space)으로 변환
float3 lightPosOS = TransformWorldToObject(_CustomLightPosWS.xyz);
//광원에서 오브젝트 중심(0,0,0)으로 향하는 방향 벡터
//(0, 0, 0) - lightPosOS.xy = -lightPosOS.xy
float2 lightToCenterDirOS = -lightPosOS.xy;
//광원과 오브젝트 중심 위치가 완전히 겹쳐 0이 될 때의 예외 처리
float dirLen = length(lightToCenterDirOS);
if (dirLen < 0.0001)
{
lightToCenterDirOS = float2(0, 1); //기본 방향 지정
}
//두 벡터에 대한 내적 진행(오브젝트 중심 -> 현재 정점 위치, 광원 -> 오브젝트 중심
float facing = dot(input.positionOS.xy, lightToCenterDirOS);
//빛을 받는 방향을 구함
float3 positionWS = TransformObjectToWorld(input.positionOS);
float2 lightToVertDirWS = positionWS.xy - _CustomLightPosWS.xy;
float dist = length(lightToVertDirWS);
float2 lightToVertNormWS = lightToVertDirWS / max(dist, 0.0001);
//광원 반대편 정점(facing > 0)만 Extrusion 수행
if (facing > 0.0)
{
//빛을 받는 방향에 맞춰서 그림자 거리 설정만큼 밀어내기
positionWS.xy += lightToVertNormWS * _ShadowLength;
}
//Clip Space 기준 좌표로 변환하고 반환 진행
output.positionCS = TransformWorldToHClip(positionWS);
output.uv = TRANSFORM_TEX(input.uv, _MainTex);
return output;
}
half4 LitFragment(Varyings input) : SV_Target
{
half4 tex = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
//텍스쳐의 알파값이 컷오프 기준보다 작다면 기록 제외
if (tex.a < _Cutoff)
discard;
//제외되지 않았다면 그림자 텍스쳐에 흰색(마스킹 용도) 기록
return half4(1.0, 1.0, 1.0, 1.0);
}
ENDHLSL
}
첫 패스 대비 내용이 많은데, 준비 단계에서 중요한건 스텐실 버퍼와 _CustomLightPosWS 선언 정도만 보고, 나머지는 실제 셰이더를 확인하는 정도로 충분하다.
두번째 패스의 스텐실은 Ref 1, Comp NotEqual 으로 설정되어 있는데, 이 두 설정이 의미하는 바는 스텐실 버퍼에서 1이 확인되는 위치에는 로직을 적용하지 않는다는 것을 의미한다.
첫번째 패스에서 1로 채운 부분에 대해서는 이 두번째 패스는 동작하지 않는다는 얘기가 된다.
float4 _CustomLightPosWS;의 경우 바로 위의 CustomLightBinder 스크립트에서 실시간으로 광원의 위치를 전달하는 데이터를 이용하기 위해 선언해서 사용한다.
버텍스 셰이더로 넘어오면, 이 광원의 위치를 월드 스페이스에서 오브젝트 스페이스로 전환해서 좌표를 절대적인 좌표 대신 현재 오브젝트 중심 기준 광원이 얼마나 떨어져있는지에 대한 좌표로 전환한다.
오브젝트 스페이스라는 얘기는 0,0의 좌표는 항상 오브젝트의 중심이라는 얘기이기 때문에 -lightPosOS.xy; 만으로도 광원에서 오브젝트 중심을 향하는 벡터가 완성된다.
이후, 오브젝트 중심 -> 현재 위치(현재 버텍스 위치), 광원 -> 오브젝트 중심이라는 두 벡터에 대한 내적을 진행하는데, 이 데이터는 이후 현재 버텍스에 그림자를 적용해줄지 정할 때 사용된다.
일단은 빛을 받는 방향을 먼저 구해준다.
현재 버텍스의 위치를 월드 좌표 기반으로 변경하고, 광원의 월드좌표에서 현재 월드좌표까지 향하는 벡터를 구해준다.
이렇게 구한 벡터의 순수 길이를 구하고, 그 길이값을 이용해서 벡터를 정규화 해주게 되면, 현재 버텍스가 빛을 받는 순수한 방향값을 확보할 수 있다.
조금 전에 진행한 내적의 값을 보면, 결과는 크게 세가지로, 0보다 크거나 / 0과 같거나 / 0보다 작은 경우로 나뉘어진다.
중요한건 0보다 큰가 하나 뿐인데, 이 값이 0보다 크다는 것은 빛이 오브젝트를 향하고 있는 방향과 오브젝트 중심에서부터 이 버텍스를 향하는 방향이 어느정도(90도 이내) 일치한다는 것이 된다.
좀 더 간단하게 얘기하자면, 광원이 있고 오브젝트가 그 오른쪽에 존재하고 있을 때, 오브젝트를 왼쪽과 오른쪽으로 나눠서, 광원의 반대 방향인 오브젝트 오른쪽 버텍스들이 이 조건에 해당한다는 뜻이다.
이 반대쪽 버텍스들의 위치만 설정된 그림자 길이만큼 밀어준다는 소리는 왼쪽 버텍스들은 제자리에 있고, 오른쪽 버텍스의 위치만 옮겨졌다는 얘기가 되는데, 중요한건 이 버텍스들은 하나의 오브젝트 아래 엮여있는 상태이기 때문에 물체의 길이가 매우 길어지게 된다.
A-B-C의 상황에서 A-B------C와 같은 형태가 된다는 얘기로, 이 길어진 만큼의 영역이 전부 그림자 영역이 된다.
이후, 변경된 월드 좌표를 Clip Space 기반으로 변환하고 반환해주어 다음 과정인 픽셀 셰이더로 넘어가게 된다.
픽셀 셰이더는 간단하다.
우선 그림자가 될 원본 텍스쳐를 가져와서, 해당 텍스쳐 내에 혹시라도 있을 반투명한 부분이 컷오프 기준보다 더 낮은 알파값을 갖고있는지 확인하고, 만약 그렇다면 연산을 더 진행하지 않는다.
아무런 문제가 없다면 해당 위치에 흰색을 기록해두는데, 이 흰색을 그대로 화면에 적용하는 것은 아니고, 그림자의 위치를 기록해두는 그림자 마스킹 텍스쳐 데이터라고 보면 된다.
여기서는 간단하게, 기존의 빛을 받는 오브젝트의 셰이더를 조금 수정해주었다.
//전역 그림자 텍스쳐 선언
TEXTURE2D(_CustomShadowTexture);
SAMPLER(sampler_CustomShadowTexture);
가장 먼저 스크립트에서 전역으로 선언해뒀던 그림자 텍스쳐를 이용하기 위해 설정을 진행해주었다.
half4 LitFragment(Varyings input) : SV_Target
{
const half4 main = input.color * SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
half4 customLight = SAMPLE_TEXTURE2D(_CustomLightTexture, sampler_CustomLightTexture, input.lightingUV);
//그림자 텍스쳐를 샘플링해서 그 텍스쳐의 r 값만 가져오기
//그림자 마스킹 영역은 (1,1,1,1)로 되어있기 때문에 그림자가 존재하는 영역이라면 1이라는 값이 확보됨
half shadowMask = SAMPLE_TEXTURE2D(_CustomShadowTexture, sampler_CustomShadowTexture, input.lightingUV).r;
//조명 + 그림자, 그림자가 존재한다면 * 0이 되어버려서 조명의 영향을 제거함
half3 finalCustomLight = customLight.rgb * (1.0 - shadowMask);
half3 color = main * finalCustomLight;
return half4(color, main.a);
}
픽셀 셰이더에서는 새롭게 그림자 텍스쳐를 샘플링하고, 그 텍스쳐의 r 값만을 가져온다.
이전 단계에서 그림자 마스킹 영역에는 (1,1,1,1)을 할당해주었기 때문에 그림자가 없는 영역에서는 0, 그림자가 존재하는 영역에서는 1이라는 값이 확보된다.
이 값을 최종 조명 연산에 이용해주는데, [조명 * (1 - 마스크값)]을 진행해서, 그림자가 없는 영역은 조명을 그대로 적용하고, 그림자가 있는 영역에는 조명을 제거하고 나서 최종 색상으로 적용한다.