림 라이트(Rim Light)
림라이트는 피사체의 외곽에 밝은 윤곽을 형성하여,
오브젝트와 배경을 시각적으로 분리하고 대비를 강조하는 라이팅 기법이자 효과를 말한다.
Key Light, Fill Light, Back Light의 3점 조명 중에
림라이트는 주로 Back Light에서 발생하며,
Key Light가 역광 조건일 때 더욱 두드러진다.



그림 혹은 3DCG 라이팅을 그리거나 감독하고 설계할 때 역광을 선호하는 이유는
오브젝트의 대비를 키우고 중심이 되는 피사체에 시선과 이목을 별 다른 장치를 하지 않고도 쉽게 주도할 수 있기 때문이다.
PBR 기반 사실적 라이팅에서는
Back Light의 세기가 상대적으로 약하기 때문에 사진과 같이 의도적인 명도대비나 한난 대비를 이루는 장면 등이 아니라면
림라이트가 과도하게 강조되지 않는다.
그러나 NPR와 같은 Stylized 렌더링에서는
물리적 정확성보다 시각적 인상이 중요하므로
물리법칙을 깨고 림라이트를 의도적으로 강조하는 설계가 자주 사용된다.
프레넬 반사(Fresnel Reflection)
림라이트는 주로 프레넬(Fresnel) 반사 특성에서 기인한다.

이때 굴절된 빛이 어느 방향으로 진행하는지는
스넬의 법칙에 의해 결정되며, 이러한 연산을 픽셀 단위로 반복 수행하는 것은
반사와 굴절 중 어느 쪽으로 얼마나 많은 에너지가 분배되는지는
프레넬 방정식이 설명한다.
프레넬 반사는 시선 방향 V와 표면 법선 N사이의 각도,
즉 N⋅V값에 따라 반사율이 변화하는 현상을 의미한다.
프레넬 반사를 설명할때 호숫가의 수면에 대한 반사를 예시로 주로 든다.


사진을 관찰해 본다면
시선 근처 바로 앞에 있는 (수면의 법선과의 입사각이 비교적 작은)수면을 보았을 때
수면 바닥까지 돌이나 모래와 같은 오브젝트가 선명하게 보이는 것을 확인해 볼 수 있다.
반면, 시선 멀리 있는(수면의 법선과의 입사각이 비교적 큰) 수면을 보았을때
산이나 나무의 형상이 수면에 반사되어 마치 거울같이 위 아래로 반전되어 보이는 모습을 확인해 볼 수 있다.
림라이트 효과 또한 기본적으로 이러한 현상에 기인한 것인데,
표면을 정면에 가깝게 바라볼 때보다
비스듬한 각도에서 바라볼수록 반사율이 증가하며,
이로 인해 표면의 외곽선 부근이 다른 빛이나(주광, 환경광 등)
오브젝트 형상 등으로부터 반사되어(반사광) 더 밝게 보이게 된다.
이러한 시선과 물체 표면의 법선에 대하여 의존적인 반사 특성이
림라이트가 형성되는 물리적 기반이 된다.
프레넬 방정식, 프레넬 근사(Fresnel Equation, Approximation)
프레넬 방정식(Fresnel Equation)
프레넬 반사는 전자기파의 매질 경계에 대한 조건에서 유도되는 물리 법칙이다.
이를 통해 매질 경계에서의 반사율과 투과율을 계산할 수 있다.
한편, 매질 내부에서는 흡수가 추가적으로 발생할 수 있지만,
프레넬 방정식은 경계면에서의 반사와 투과 비율만을 다룬다.
프레넬 방정식은
두 매질의 굴절률과 입사각을 기반으로
반사되는 빛의 비율을 계산한다.


이때, 반사율은 전자기파의 편광 상태에 따라
수직편광(s편광)과 수평편광(p편광)의 서로 다른 반사 및 투과율에 대하여
서로 다른 두 식으로 계산되고,
(수직/수평 - 반사/투과 조합 총 4개의 성분 존재)
최종 반사율은 이들의 평균으로 구해진다.
그러나 이러한 정확한 프레넬 방정식을
실시간 렌더링 환경에서 그대로 사용하는 것은 한계가 있다.
프레넬 방정식은 식에서 확인할 수 있듯 매질에 대한 굴절률과 입사각뿐 아니라
굴절각 계산을 포함하며, 이 과정에서 제곱, 나눗셈, 제곱근 연산이 반복적으로 요구되는데,
특히 굴절각은 스넬의 법칙을 통해
추가적으로 계산되어야 하므로 파라메터가 늘어나고 연산 비용이 더욱 증가한다.
이러한 연산을 픽셀 단위로 반복 수행하는 것은
실시간 셰이딩 환경에서 비용 부담이 크다.
따라서 실시간 렌더링에서는
계산 비용을 줄이기 위해
프레넬 반사를 근사한 모델을 사용한다.
프레넬 근사(Fresnel Approximation)
실시간 렌더링에서는 위와 같은 Fresnel Equation의 시각적 효과를 근접하게 보이면서
계산량을 줄일수 있도록 하기 위해 프레넬 반사를 근사한 모델을 사용한다.
그 중 대표적으로 가장 널리 사용되는 모델이
슐릭(Schlick) 근사이다.


빛은 매질 경계에서 반사와 굴절이 동시에 발생하며,
그 비율은 입사각에 따라 변화한다.
시선벡터와 표면의 법선 벡터의 각도가 0도일때 반사가 최소
시선벡터와 표면의 법선 벡터의 각도가 90도일때 반사가 최대라는 것을
직관적으로 알 수 있다.
즉, 프레넬 반사의 각도 의존성을
단순한 함수 형태로 근사한 모델이다.
입사한 빛 I 의 에너지 총량 = 반사 한 빛 O의 에너지 총량 + 굴절한 빛 O의 에너지 총량에서(흡수는 다루지 않으므로)
각각의 비율을 R(0), 1-R(0)로 두어 식을 구성한다.
1. cos(θ)는 N벡터와 V벡터의 사이 각도를 의미한다.(N⋅V)
2. R(θ)는 V벡터에 대한 최종적인 프레넬 반사율을 의미한다.
3. R(0)는 N⋅V = 1일때의 최소 반사율을 의미한다.
4. 1- R(0)는 N⋅V = 1일때 최대 굴절율을 의미한다.
5. 각도가 커질수록 R(θ)는 커지고, 1-R(θ)는 작아지도록 모델링 해야 한다.
해당 식의 직관적인 의미는 R(0) 정면 반사율 기준값 대해서 각도가 커질수록
정면에서 주로 굴절되던 에너지가 각도가 커질수록 점차 반사 쪽으로 분배됨을
모델링한 것이다.
한편, 마지막pow(1-cos(θ) , 5)항은
프레넬 방정식의 결과값과 유사하게 보이도록 수치적으로 조정한 값으로서 경험적으로 맞춘 값이다.
이는 Phong Specular 등에서 임의로
power의 수치를 조정한 것과 유사한 휴리스틱한 접근이다.
구현
1.Fresnel Rim Lighting
2. Depth Driven Rim Lighting
구현에 앞서 먼저 NPR에서 림라이팅을 위한 프레넬 반사는
물리법칙을 따르지 않으며 기본적으로 아트 주도적인 방향으로 치중되어 있다는 점을 알아야 한다.
무엇이 됬든 NPR에는 사실적인 묘사에 치중되기 보다는 예쁘게 나오기만 하면 수식적으로 옳고 그름을 떠나
어떤식으로 보이든 허용될수 있으며 결국은 눈에 보이는 효과에 파라메터를 의존하여 조절할 수 있어야 하는 것이 주된 목적이다.
1.Fresnel Rim Lighting
half3 normalVS = TransformWorldToViewNormal(input.normalWS, true);
half3 V = GetWorldSpaceNormalizeViewDir(input.positionWS);
half NDotV = dot(input.normalWS, V);
half fresnel = Reflectioness + (1-Reflectioness) * pow(saturate(1.0 - NDotV), 5.0) * FresnelIntensity;


프레넬 근사 기반 림라이트를 적용할때 Reflectioness를 0으로 두어 윤곽선에 대한 림라이팅 만을 남기도록 한다.
내부에서 기본 반사에 대한 R(0)부분은 현재 구현 목적에 필요 없는 항이기에, 따라서 림라이트는
실질적으로
pow(saturate(1.0 - NDotV), 5.0)
에 대한 영향만을 받는다.
한편, 이렇게 처리했을 때 날개 부분이나 금속 부분에 있어서 물체의 윤곽선의 N에 의존되어
face나 일부 요철들에 프레넬 효과가 크게 받아 필요 없는 부분까지도 림라이팅의 효과를 받고 있는 모습을 볼 수 있다.
이러한 문제를 해결하기 위해 DepthTexture기반 (ZBuffer 사용하는거 아님, 주의)
픽셀과 픽셀간의 Gradient에 Threshold를 주어 인접한 같은 z(depth)에 있는 항목에 대해서는 마스킹 처리를 하고 깊이 차이가 있는 윤곽선 영역에 대해서만 RimLight 효과를 받게 하도록 한다.
2. Depth Driven Rim Lighting
Depth Driven Rim은 화면에서 실루엣을 직접 검출하여 림라이트를 만든다.
프레넬 기반 림라이트는 구현이 간단하지만, 위와 같이 표면 곡률이나 조명/카메라 조건에 따라
림이 오브젝트 전체에서 과하게 나타날 수 있다.
반면 Depth-driven rim lighting은 화면 공간에서 깊이의 급격한 변화에 대한 영역을 마스킹하여 림을 생성하므로,
내부 영역을 제외한 외곽선만 안정적으로 강조할 수 있다.

내부 외부의 실루엣을 마스킹하기 위해선 DepthTexture를 사용한 처리가 필요한데,
DepthTexture를 쉐이더에서 사용하기 위해서는 사전의 준비가 필요하다.
실시간 렌더링에서 깊이 정보는 GPU의 Depth Buffer(Z buffer)에 기록된다.
하지만 Z-buffer는 기본적으로 렌더링 파이프라인 내부에서 깊이 테스트(painting algorithm) , 차폐(Occlusion)을 위해 사용되는 버퍼이며, 셰이더에서 임의로 샘플링할 수 있는 텍스처 형태로 항상 제공되지는 않는다.
URP는 Screen Space 효과들(SSAO, SSGI, Outline, DepthBased .etc)을 효과적으로 사용하기 위해
SampleSceneDepth(_CameraDepthTexture) 를 별도로 생성하여 셰이더에서 접근할 수 있도록 제공한다.
중요한것은 SampleSceneDepth()가 읽는 값은 상기 서술하였듯이
하드웨어의 Z buffer가 아닌 URP가 렌더 패스를 통해 생성해 둔 depth 텍스처이다.
이와 관련하여 유니티 쉐이더 옵션에서 ZWrite를 OFF ON 하여
Zbuffer에 기록할지 말지를 설정하는 옵션이 있는데 이러한 설정과 혼돈하여서는 안된다.
ZWrite는 단순히 해당 오브젝트가 깊이 버퍼에 기록될지를 결정하는 옵션이다.
한편, SampleSceneDepth()는 GPU의 내부 Z-buffer를 직접 읽는 것이 아니라,
URP가 생성한 _CameraDepthTexture를 샘플링한다.
즉, ZWrite만 활성화한다고 해서 해당 오브젝트가 반드시 depth texture에 기록되는 것은 아니다.
DepthTexture는 별도의 렌더 패스를 통해 생성되며,
이때 셰이더는 LightMode를 DepthOnly로 하는 패스를 제공해야 한다.
따라서, Depth 기반 효과를 구현하려면 ZWrite뿐 아니라 추가적으로 DepthOnly 패스를 함께 구성해야 한다.
다음은 위의 과정에 대한 구현 설명이다.
DepthTexture
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
float2 uv = input.positionNDC.xy / input.positionNDC.w;
float depth = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
//=============================================================
Pass
{
Name "DepthOnly"
Tags
{
"LightMode" = "DepthOnly"
}
ZWrite On
ColorMask R
Cull[_Cull]
HLSLPROGRAM
#pragma vertex DepthOnlyVertex
#pragma fragment DepthOnlyFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
ENDHLSL
}
Pass
{
Name "DepthNormals"
Tags
{
"LightMode" = "DepthNormals"
}
ZWrite On
Cull[_Cull]
HLSLPROGRAM
#pragma vertex DepthNormalsVertex
#pragma fragment DepthNormalsFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitDepthNormalsPass.hlsl"
ENDHLSL
}
depth texture를 만드는데 필요한 코드들이다.
위의 해당 코드들에 대해서 단계별로 설명해 나가본다.
Screen Space UV 생성
DepthTexture는 화면 공간에 대한 텍스처이므로
현재 픽셀의 screen spacee에 대한 UV 좌표가 필요하다.
해당 섹션에 대해서 다루기 전, 그래픽스 파이프라인에 대해서 간단한 review를 해 본다.

여기서 유심히 봐야 할 부분은 Clip Space -> NDC -> Screen Space로 변환되는 과정이다.
우리가 직접적으로 fragment stage에서 계산해서 사용해야 할 항목은 NDC -> Screen Space로 넘어가는 과정이며
ScreenSpace에 대한 UV 좌표를 0~1로 받아올 필요가 있다.
VertexPositionInputs GetVertexPositionInputs(float3 positionOS)
{
VertexPositionInputs input;
input.positionWS = TransformObjectToWorld(positionOS);
input.positionVS = TransformWorldToView(input.positionWS);
input.positionCS = TransformWorldToHClip(input.positionWS);
float4 ndc = input.positionCS * 0.5f;
input.positionNDC.xy = float2(ndc.x, ndc.y * _ProjectionParams.x) + ndc.w;
input.positionNDC.zw = input.positionCS.zw;
return input;
}
vertex shader output에서 구조체의 정보에서
VertexPositionInputs을 통해
vertex output에 대한 OS(Object Space), WS(World Space), VS(View Space), HCS(Homogeneous Clip Space), NDC(normalized device coordinate)등과 관련된 정보들을 쉽게 얻을 수 있었는데
여기서 제공하는 positionNDC를 통해 ScreenSpace에 대한 uv를 구할 수 있다.
사실 위의 유니티 함수에서 볼 수 있듯이 유니티에서 제공하는 position ndc는
실제 그래픽스 파이프라인에서의 순수한 NDC의 좌표가 아닌 ScreenSpace 변환을 쉽게 하도록 하는 중간 과정에 가깝다.
HCS공간에서 내부 클리핑 조건은
-w ≤ x ≤ w
-w ≤ y ≤ w
0 ≤ z ≤ w (OpenGL에서는 -w ≤ z ≤ w)
이를 NDC좌표로 넘어갈때 동차나누기를 통해
x/w, y/w,, z/w
-1 ≤ x/w ≤ 1
-1 ≤ y/w ≤ 1
0 ≤ z/w ≤ 1 ( OpenGL에서는 -1 ≤ z/w ≤ 1)
로 만들어 주는게 기본 개념이다. (유니티는 멀티플랫폼이고 호환되는 graphics api마다 처리 방식이 다르다)
하지만 위의 GetVertexPositionInputs 유니티 함수에서 구현은
float4 ndc = input.positionCS * 0.5f;
input.positionNDC.xy = float2(ndc.x, ndc.y * _ProjectionParams.x) + ndc.w;
로 처리된다.
여기서 _ProjectionParams.x은 y flip에 대한 보정 값인데 이는 그래픽스 API에 따라서
좌표의 기준이 다르기 때문에 처리하는 보정값일 뿐이다.
| 그래픽스 API | UV 기준 위치 |
| DirectX | 좌상단 |
| OpenGL | 좌하단 |
유니티는 멀티 플랫폼이므로 뷰포트에서 보이는 좌표계가 서로 통일되어야 하기 때문에
이를 변환시켜서 좌하단을 (0,0)우상단을(1,1)로 하는 좌표로 통일한다.
해당 식이 나온 과정을 해부하면 다음과 같다.
ndc_xy = clip.xy / clip.w //동차 나누기
uv = 0.5 * (ndc_xy + 1) // x,y -1~1을 0~1로 매핑하여 screen space로 처리
uv = 0.5 * (clip.xy / clip.w + 1)
//여기서 w값을 따로 바깥으로 빼버리면 다음과 같다.
uv = (0.5 * (clip.xy + clip.w)) / clip.w
즉, 유니티에서는 NDC좌표를 이용해 ScreenSpace를 구하는 직전 단계의 정보를 positionNDC에 저장해 놓는 것이다.
따라서 유니티에서 제공하는 NDC를 통해 screenspace 좌표를 얻으려면 다음과 같은 작업을 처리하면 된다.
float2 uv = input.positionNDC.xy / input.positionNDC.w;
이를 화면에 출력하면 다음과 같다.


ScreenSpace에서 유니티는 상기 서술하였듯
좌하단이 (0,0) 좌상단이 (1,1이고 이를 fragment에서 해당 색상값을 출력하여 디버깅할 수 있다.
이렇게 출력된 Screen UV 값을 통해 depth texture를 만든다.
depth texture 읽기
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
float depth = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
depth texture를 유니티 쉐이더에서 사용하는 방법은 간단한데,
화면의 UV 좌표를 받았다면 위와 같은 코드를 통해 화면의 depth 정보를 입력 받을 수 있다.
DelcareDepthTexture.hlsl은 depth texture를 만들기 위한 재료들을 모아둔 hlsl로써
예시로 SampleSceneDepth 함수들을 사용하기 위한 함수의 정의가 포함되어 있는 유틸리티 라이브러리이다.
이 밖에도 추후 설명할 _CameraDepthTexture, _CameraDepthTexture 샘플러의 선언이나
Reversed Z 대응 처리와 같은 내용들이 포함되어 있다.(_ZBufferParams)
SampleSceneDepth함수는 화면 공간 UV 좌표에서 해당 Scene의
depth texture를 샘플링 하여 현재 보이는 화면 기준의 가장 앞 픽셀의 깊이 정보를 가져온다.
LinearEyeDepth 함수는 읽어들인 depth texture 정보를 카메라의 거리의 falloff에 가깝게 만들어주는 보정 함수인데,
non reversed z일 경우, depth는 기본적으로 near plane에 가까운 곳의 수치가 0, far plane에 가까운 곳의 수치가 1이다.
near plane far plane 사이에 대한 깊이 정보는 ndc로 변환될때
동차나누기를 통해 깊이 정보가 기록되는 것을 알수 있었을텐데 문제는 wr값으로 나누게 되면 깊이 값이
멱급수의 형태를 띄게 되어 근거리에서의 깊이 변화와 원거리에서의 깊이 변화에 따른 값의 차이가 매우 크게 발생한다.

그래프를 확인해보면 near plane 근처에는 깊이 변화에 따른 값의 변화 차이가 크며
far plane으로 갈수록 깊이 변화에 따른 값의 변화가 완만하다는 것을 확인해 볼 수 있다.
따라서 이를 Linear로 변환하여 카메라의 깊이 변화 값이 자연스럽게 나타날 수 있도록 만들어 주는 함수가 LinearEyeDepth이다.
_ZBufferParams는 frustrum의 far plane, near plane에 대한 정보나 reverse -z 여부 등을 담고 있는 파라메터이며
내부에서 자동적으로 처리되어 값을 반환한다.
하지만 해당 코드만을 unlit 쉐이더에서 사용한다고 바로 depth texture를 사용할수 있는 것은 아닌데,
이유는 SampleSceneDepth에서 depth texture가 생성되지 않은 텍스쳐를 참조하고 있는 상태이기 때문이다.
이게 무슨 의미인지 한번 알아보자.
depth texture 생성
_CameraDepthTexture
먼저, 다시한번 알아야 할 것은 depthtexture는 zbuffer를 직접 읽어 올 수 없다고 위에서 미리 설명하였다.
ZBuffer는 하드웨어에서 z-test, stencil 등과 같은 그래픽스 파이프라인 내부에서 처리하기 위한 목적이지 이를 직접적으로 render target으로 받아올 수 없기 때문이다.
유니티 urp에서는 이를 샘플링 하기 위해 별도 패스로 생성한 _CameraDepthTexture를 제공한다.
(텍스쳐 샘플링은 DelcareDepthTexture.hlsl에서 처리)
혼동하지 말아야 할 점은 Zwrite를 on 한다고 _CameraDepthTexture가 자동으로 생성되는 것은 아니다.
_CameraDepthTexture는 유니티에서 depth texutre를 필요로 하는 feature를 사용할때에 내부적으로 생성되게 되어 있으며 대표적으로SSAO,SSGI등을 유니티 renderer에서 사용할때 생성된다.
한편, depth texture는 유니티의 랜더링 파이프라인에서 depth prepass 단계에서 생성되고
이를 위해 셰이더에서 별도로 처리해야할 항목들이 있다.
Depth Prepass
유니티에서 SSAO 등과 같은 옵션을 켤때 내부적으로 depth texture( _CameraDepthTexture )를 생성을 하기 위해
depth texture를 그리기 위한 render target을 임시 레지스터에 지정한다.
유니티 랜더링 파이프라인에서는 Depth Prepass 단계에 해당 render target에다가 그릴 오브젝트들에 대해 확인하고
해당되는 오브젝트에 대해서만 값을 저장하는데 이때 필요한 것이 상기 서술하였던
LightMode를 DepthOnly로 하는 패스이다.(유니티 설계적 약속)
다시 말하지만 헷갈릴수 있는 점은
Zwrite option은 해당 오브젝트의 깊이 정보를 z buffer에 저장할지 말지에 대한 옵션인 것이고 Lightmode를 DepthOnly로 하는 패스는 depth texture render target에 해당 오브젝트를 기록할지 말지를 선별하는 옵션이다.
즉, 결론적으로 Depth Prepass로 depth texture 를 만들 때는,
해당 오브젝트가 DepthOnly 패스로 그려지면서 depth 타겟에 기록되는 구조다.
이 과정에서 일반적으로 ZWrite On이 설정되어 깊이값이 정상 기록된다.
(서로 연관관계는 있지만 필드가 약간 다른 부분이기에 혼동될 수 있는 항목이다.)
DepthOnly Pass, DepthNormal Pass
사전에 depth texture를 depth prepass단계에서 만들기 위해서는
lightmode를 depth only로 하는 패스를 추가하여 랜더링 파이프라인에서
해당 오브젝트를 depthtexture에 기록할수 있도록 해야 한다고 했다.
URP는 필요한 화면공간 효과(Screen Space Effect)에 따라
Depth만 필요한 경우(DepthOnly) 와
Depth+Normal이 필요한 경우(DepthNormals) 로
사전 패스를 구분해서 돌린다.

유니티의 Universal Renderer를 보면 depth texture를 사용하는 SSAO에서 Source를 지정할 수 있는 카테고리가 있고 여기에서
depth 기반을 사용할지 혹은 normal을 포함한 depth normal을 사용할지 옵션을 선택할 수 있다.
각각의 차이점이 무엇인지에 대해서 알아보자.
DephtOnly, Depth Normal Pass를 처리하기 위해서 유니티에서 기본적으로 제공하는 셰이더를 사용할 것이다.
유니티에서 기본적으로 Material을 생성할 시에 URP에서는 Lit 쉐이더를 기본 쉐이더로 사용하고 있다.
Lit 쉐이더 내부를 확인해 보면 다음과 같은 depth only, depth normal 패스가 있다.
Pass
{
Name "DepthOnly"
Tags
{
"LightMode" = "DepthOnly"
}
// -------------------------------------
// Render State Commands
ZWrite On
ColorMask R
Cull[_Cull]
HLSLPROGRAM
#pragma target 2.0
// -------------------------------------
// Shader Stages
#pragma vertex DepthOnlyVertex
#pragma fragment DepthOnlyFragment
// -------------------------------------
// Material Keywords
#pragma shader_feature_local _ALPHATEST_ON
#pragma shader_feature_local_fragment _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
// -------------------------------------
// Unity defined keywords
#pragma multi_compile_fragment _ LOD_FADE_CROSSFADE
//--------------------------------------
// GPU Instancing
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DOTS.hlsl"
// -------------------------------------
// Includes
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
ENDHLSL
}
// This pass is used when drawing to a _CameraNormalsTexture texture
Pass
{
Name "DepthNormals"
Tags
{
"LightMode" = "DepthNormals"
}
// -------------------------------------
// Render State Commands
ZWrite On
Cull[_Cull]
HLSLPROGRAM
#pragma target 2.0
// -------------------------------------
// Shader Stages
#pragma vertex DepthNormalsVertex
#pragma fragment DepthNormalsFragment
// -------------------------------------
// Material Keywords
#pragma shader_feature_local _NORMALMAP
#pragma shader_feature_local _PARALLAXMAP
#pragma shader_feature_local _ _DETAIL_MULX2 _DETAIL_SCALED
#pragma shader_feature_local _ALPHATEST_ON
#pragma shader_feature_local_fragment _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
// -------------------------------------
// Unity defined keywords
#pragma multi_compile_fragment _ LOD_FADE_CROSSFADE
// -------------------------------------
// Universal Pipeline keywords
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl"
//--------------------------------------
// GPU Instancing
#pragma multi_compile_instancing
#include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DOTS.hlsl"
// -------------------------------------
// Includes
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitDepthNormalsPass.hlsl"
ENDHLSL
}
여기서 필요한 항목들만 넣고 나머지 필요 없는 기능들에 대해서 과감하게 날리면 다음과 같이 사용할 수 있다.
필요없는 기능들에 대해서도 사실 각각 빼놓지 못할 만큼 매우 중요한 개념들을 다루고 있지만
(gpu instancing 항목, alphatest, normal map, parallex map .etc)
해당 섹션에서는 사용하지 않으므로 이후 탐구 과정으로 남겨 놓는다.
Pass
{
Name "DepthOnly"
Tags
{
"LightMode" = "DepthOnly"
}
ZWrite On
ColorMask R
Cull[_Cull]
HLSLPROGRAM
#pragma vertex DepthOnlyVertex
#pragma fragment DepthOnlyFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
ENDHLSL
}
// This pass is used when drawing to a _CameraNormalsTexture texture
Pass
{
Name "DepthNormals"
Tags
{
"LightMode" = "DepthNormals"
}
ZWrite On
Cull[_Cull]
HLSLPROGRAM
#pragma vertex DepthNormalsVertex
#pragma fragment DepthNormalsFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitDepthNormalsPass.hlsl"
ENDHLSL
}
DepthOnly
위의 코드에 포함되어 있는 핵심 동작은 다음과 같다.
ZWrite On
ColorMask R
#pragma vertex DepthOnlyVertex
#pragma fragment DepthOnlyFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
Depth Prepass에서 DepthOnly 패스로 렌더링될 때, 일반적으로 ZWrite On 상태에서 깊이값이 기록되도록 구성된다.
해당 depth texture는 색상 정보를 포함할 필요가 없으므로(float 값이므로)
채널중에 하나의 채널에만 값을 기록할 수 있도록
ColorMask R을 통해 R 채널에만 기록되게 하여 불필요한 기록 동작을 최소화 한다.
DepthOnlyVertex, DepthOnlyFragment는 DepthOnlyPass.hlsl에 정의되어 있는
각 vertex shader, fragment shader의 구조체 이름으로써 사전처리기에서 미리 이름 대용으로 사용하겠다는 항목이다.
DepthOnlyPass.hlsl 내부는 다음과 같이 생겼다.
#ifndef UNIVERSAL_DEPTH_ONLY_PASS_INCLUDED
#define UNIVERSAL_DEPTH_ONLY_PASS_INCLUDED
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#if defined(LOD_FADE_CROSSFADE)
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/LODCrossFade.hlsl"
#endif
struct Attributes
{
float4 position : POSITION;
float2 texcoord : TEXCOORD0;
UNITY_VERTEX_INPUT_INSTANCE_ID
};
struct Varyings
{
#if defined(_ALPHATEST_ON)
float2 uv : TEXCOORD0;
#endif
float4 positionCS : SV_POSITION;
UNITY_VERTEX_INPUT_INSTANCE_ID
UNITY_VERTEX_OUTPUT_STEREO
};
Varyings DepthOnlyVertex(Attributes input)
{
Varyings output = (Varyings)0;
UNITY_SETUP_INSTANCE_ID(input);
UNITY_TRANSFER_INSTANCE_ID(input, output);
UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
#if defined(_ALPHATEST_ON)
output.uv = TRANSFORM_TEX(input.texcoord, _BaseMap);
#endif
output.positionCS = TransformObjectToHClip(input.position.xyz);
return output;
}
half DepthOnlyFragment(Varyings input) : SV_TARGET
{
UNITY_SETUP_INSTANCE_ID(input);
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
#if defined(_ALPHATEST_ON)
Alpha(SampleAlbedoAlpha(input.uv, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap)).a, _BaseColor, _Cutoff);
#endif
#if defined(LOD_FADE_CROSSFADE)
LODFadeCrossFade(input.positionCS);
#endif
return input.positionCS.z;
}
#endif
여기서 눈여겨볼 부분은 vertex shader stage와 fragment shader stage이다.
depth texture는 색상출력 등을 해당 pass에서 수행하지 않으므로
vertex shader 에서도 따로 색상에 대한 정보를 받아오지 않는다.
fragment shader stage에서는
alpha test를 처리하여 alpha가 0인 부분에 대해서는 depth 정보가 쓰여지지 않도록 한다.


나뭇잎, 머리카락 카드처럼
투명 영역이 존재하는 오브젝트에서
해당 영역까지 깊이가 기록되어 버리면 SSAO, Outline, Depth 기반 Rim 등
화면공간 효과가 잘못된 마스크를 참조하게 되기 때문이다.
출력은 다음과 같다.
return input.positionCS.z;
여기서 input.positionCS는 vertex shader에서 TransformObjectToHClip()을 통해 계산된
Clip Space 위치(SV_POSITION) 이며,
DepthOnly 패스는 clip space에서 계산된 z 값을 반환하고,
이 값은 RT에 기록되어 이후 _CameraDepthTexture 생성에 사용된다.
한편, 해당 섹션에서 문득 든 생각은,
단순 깊이 값을 저장하는 용도라면 positionCS, positionNDC에서 w값을 사용하면 되지 않은가?
라는 생각이 들수 있다.
하지만 이는 화면의 최종 출력에 대한 전면부의 오브젝트의 깊이값이 아닌
해당 오브젝트의 3차원 공간상 위치 정보만을 통해서 사용하는 것이기 때문에
만약 해당 오브젝트가 다른 오브젝트에 의하여 차폐되거나 하는 조건들을 고려하지 못한다는 점이 문제가 되는 것이다.
DepthNormal
#pragma vertex DepthNormalsVertex
#pragma fragment DepthNormalsFragment
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitDepthNormalsPass.hlsl"
ENDHLSL
DepthNormals 패스는 위의 DepthOnly와 동일하게 depth prepass 단계에서 실행되지만,
차이점은 깊이(depth)정보 만이 아니라 표면의 법선(normal) 정보까지 함께 기록한다는 점이다.
화면공간 효과 중 일부는 단순한 depth만으로는 충분하지 않고,
표면이 어느 방향을 바라보는지를 알아야 더 정확한 결과를 만들 수 있다.
대표적으로 Screen Space effect 중 대표적인 SSAO(또는 SSGI)에서는
깊이 변화만으로는 동일 평면에서의 급격한 굴곡을 구분하기 어렵기 때문에
법선을 함께 사용하여 occlusion 판정을 보다 안정적이고 정확하게 수행한다.
그리고 엣지, 실루엣 검출에서도 depth만 사용할 때보다 노이즈가 줄어드는 경우가 많기에
SSAO를 사용할때는 depth normal pass를 사용하는 것이 일반적이다.
DepthNormals는 normal을 텍스처에 저장해야 하므로,
DepthOnly처럼 ColorMask R로 채널을 제한하지 않는다.
(normal은 RGB 채널을 사용해 방향벡터를 인코딩되어 저장되므로)
정리하면, 본 문서의 Depth-driven Rim Lighting은 인접 픽셀 간
scene depth의 차이(gradient) 만을 이용해 실루엣을 검출하므로 DepthOnly만으로 충분하다.
반면 SSAO처럼 표면 방향 정보가 필요하거나, 노멀 기반 edge 검출을 수행하는 화면공간 효과에서는
DepthNormals 경로가 사용될 수 있다.


depth only pass 사용 o / depth only pass 사용 x(depthtexture에 기록이 안되므로)
Gradient Offset
half3 normalVS = TransformWorldToViewNormal(input.normalWS, true);
float2 uv = input.positionNDC.xy / input.positionNDC.w;
float2 texelSize = 1.0 / _ScreenParams.xy;
float2 baseoffset = float2(
normalVS.x,
normalVS.y
);
float2 offset = baseOffset * texelSize.xy * _RimAxisOffsetMask.xy;
float depth = LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams);
float offsetDepth = LinearEyeDepth(SampleSceneDepth(uv + offset), _ZBufferParams);
half rim = smoothstep(0.0, _RimThreshold, offsetDepth - depth) * _RimIntensity;
최종적으로
depth texture를 쉐이더에서 입력을 받을 때 화면의 UV 좌표에 대하여
원본 depth texture와 uv를 오브젝트의 normal 방향에 대해 확장시킨 offset uv depth texture의 차이를 통해
Threshold를 걸어 윤곽선 라인에 대해서만 처리되도록 마스킹 한다.
첫 부분에서 _ScreenParams.xy는 화면의 해상도에 대한 w, h 값이다.
즉 texelsize는 1픽셀에 대한 크기 값을 나타내며 이를 통해 화면 해상도에 offset이 일정하게 대응되도록 처리하는 부분이다.
예를 들어1920 * 1080FHD해상도와 3840 * 2160 4K 해상도를 비교하였을때 해당 부분을 처리하지 않으면
4K 해상도에서 offset의 변화가 비교적 작게 처리되기 때문에 이를 맞춰주기 위한 단위 크기라고 볼 수 있다.
offset방향은 오브젝트 노멀에 대한 뷰 공간의 노말 방향으로 확장을 하여 depth에 대한 offset을 만든다.
본 방식은 영상처리에서 사용하는 neighborhood sampling개념과 유사한데,
현재 픽셀과 인접 픽셀의 depth 값을 비교하여 깊이장의 국소적인 변화량(gradient)을 계산하고,
그 값이 급격히 증가하는 지점을 실루엣으로 간주한다.
이를 위해 표면의 normalVS를 이용해 화면 공간에서의 샘플링 방향을 정의하고,
texel 단위로 보정된 offset을 생성한다.
_RimAxisMask.xy 부분은 벡터 마스크 파라메터이다.
offset방향에 있어서 x방향을 사용할지, y방향을 사용할지, 아니면 둘다 사용할지에 대한 variation을 추가한 부분이다.
적절한 수치를 통해 림라이트에 적합한 이미지의 offset을 사용한다.
각 x축, y축, xy축 모두 offset을 주었을때의 이미지는 다음과 같다.



x축 offset / y축 offset / xy축 offset
xy축에 대한 offset의 비중을 동일하게 처리하였을때
너무 윤곽선처럼 보인다는 생각이 들어 어느정도의 비중 조절이 필요할 것으로 로 판단하였다.
림라이트는 directional light가 backlight로 주로 쓰일때를 상정하고,
sun이 하늘 위에서 형성되는 것을 가정했을때
y축에 대한 offset 비중을 키우고 x축에 대해서는 약하게만 비율을 조정해 0.5 : 2 정도로 조절했을때가
가장 이미지가 좋게 나오는 것으로 판단하여 해당 값을 부여하였다.
최종 depth driven fresnel rim light
half3 depthfresnel = fresnel * rim * albedo.xyz;
#if _IS_FACE
depthfresnel = 0;
#endif
기존 프레넬 반사 rim light와 depth mask, 그리고 albedo 색상을 혼합한다.
얼굴의 경우 프레넬 효과가 나타나지 않도록 하여 npr 스타일의 일관성을 보장한다.


림라이트 / 최종 림라이트 효과


림라이트 x / 림라이트 o
'NPR Project Demo' 카테고리의 다른 글
| 연구 및 진행중인 것들(Shadow Caster & Global Illumination) (0) | 2026.03.11 |
|---|---|
| 06. 2 Pass Outline(Inverted Hull)_[NPR Project] (0) | 2026.02.27 |
| 04. Specular_[NPR Project] (0) | 2026.02.17 |
| 03. Normal Map_[NPR Project] (0) | 2026.02.11 |
| 02. Ramp Color Diffuse_[NPR Project] (0) | 2026.02.06 |