TECHARTNOMAD | MAZELINE.TECH

UNREAL ENGINE

[MooaToon] 5-2화 — 림라이트: 화면 공간 깊이 테스트

jplee 2026. 9. 27. 02:57

들어가며

5-1화에서는 아티스트가 그려 둔 디스턴스필드 텍스처와 라이트 각도의 비교로 얼굴 그림자를 재생하는 SDF 기반 접근을 살펴보았습니다. 이번 화의 주제는 같은 캐릭터 인상을 만드는 또 다른 표현, 림라이트입니다.

주제는 "프레넬이 아니라 깊이 테스트"입니다. 지오메트리 노멀이 아니라 화면 공간의 깊이 버퍼로 실루엣을 찾는 방식이 왜 애니메이션풍 표현에 더 맞는지, 그 계산이 엔진 어디에서 실행되고 어떤 파라미터 계층으로 제어되는지를 살펴봅니다.

구성은 5-1화와 같습니다. 표현이 해결하려는 문제를 정리하고, 엔진 측 계산을 추적한 뒤, 실습으로 확인합니다. 파라미터명과 값은 실행 중인 에디터에서 실측한 값이며, 셰이더 코드에는 엔진 소스의 파일명과 행 번호를 병기합니다.

프레넬이 아니라 깊이 테스트

림라이트의 일반적인 구현은 프레넬, 즉 시선과 노멀의 각도(NdotV)를 이용하는 방식입니다. 이 방식은 지오메트리의 노멀에 직접 의존하므로 세 가지 문제가 있습니다. 노멀 맵이나 하드 엣지에서 림이 끊기고, 얇은 지오메트리는 전체가 림으로 밝아지며, 스무딩 상태에 따라 결과가 달라집니다.

MooaToon의 림라이트는 이들과 다른 접근을 택했습니다. 이름 그대로 화면 공간 깊이 테스트(GetScreenSpaceDepthTestRimlightColor)입니다. 노멀 대신 깊이 버퍼를 사용해, 화면에 보이는 실루엣의 안쪽 경계를 찾습니다. 애니메이션의 림라이트가 "외곽선 안쪽을 따라 일정한 폭으로 들어오는 빛"이라는 점을 생각하면, 지오메트리 기준보다 화면 기준이 이 표현에 더 가깝습니다. 치마 주름이나 머리 다발처럼 노멀이 복잡한 부위에서도 폭이 균일해집니다.

엔진 측 계산: GetScreenSpaceDepthTestRimlightColor

구현은 ToonShadingModel.ush:235-274입니다. 핵심 부분을 발췌합니다.

float3 GetScreenSpaceDepthTestRimlightColor(FGBufferData GBuffer, FToonGBufferData ToonGBuffer, float2 ViewportUV, float2 L_ViewportOffsetBase)
{
	float3 RimLightColor = 0;
	
	BRANCH if (ToonGBuffer.RimLightIntensity > 0 && ToonGBuffer.RimLightWidth > 0)
	{
		const float MaxRimLightWidth = 3.0f * View.MooaRimLightMaxWidth;
		const float DepthFadeDistance = View.MooaRimLightDepthTestFadeDistance;
		const float RimLightDepthThreshold = View.MooaRimLightDepthTestThreshold;
		// const float RimLightAngle = View.MooaRimLightAngle;
		const float RimLightWidth = Pow2(ToonGBuffer.RimLightWidth);
		const float RimLightIntensity = ToonGBuffer.RimLightIntensity;

		// Recompute Normals to override DCC Normals on Face/Hair
		// float3 WorldNormalFromDepth = ReconstructNormalFromDepthBuffer_Copy(MooaToonContext.PixelPos);
		// float3 DepthToN_ClipSpace = mul(WorldNormalFromDepth, (float3x3)View.TranslatedWorldToClip).xyz;
		// float2 DepthToN_ViewportSpace = DepthToN_ClipSpace.xy * float2(0.5, -0.5);
        // float NoL_ClipSpaceAngle01 = (dot(normalize(DepthToN_ClipSpace.xy), normalize(L_ClipSpace.xy)) * 0.5 + 0.5);
        // float WidthScaleByAngle = saturate(lerp(NoL_ClipSpaceAngle01, 1, RimLightAngle));

		float2 ViewportUVOffset = L_ViewportOffsetBase * MaxRimLightWidth * RimLightWidth;
		float2 TargetBufferUV = ViewportUVToBufferUV(saturate(ViewportUV + ViewportUVOffset));

		float NewDepth = 0;
#if MATERIALBLENDING_TRANSLUCENT || MATERIALBLENDING_ADDITIVE || MATERIALBLENDING_MODULATE
		// TranslucentBasePass.SceneDepth include Translucent First Layer Depth
		// see UseFrontLayerReflection()
		float SingleLayerDeviceZ = Texture2DSampleLevel( TranslucentBasePass.SceneDepth, GlobalPointClampedSampler, TargetBufferUV, 0).r;
		NewDepth = ConvertFromDeviceZ(SingleLayerDeviceZ);
#elif SHADING_PATH_DEFERRED
		NewDepth = CalcSceneDepth(TargetBufferUV);
#endif
		float DepthFade = saturate((NewDepth - GBuffer.Depth - RimLightDepthThreshold)
			/ max(1e-5, DepthFadeDistance));

		RimLightColor = DepthFade * ComputeF0(RimLightIntensity, GBuffer.BaseColor, GBuffer.Metallic);
	}

	return RimLightColor;
}

단계별로 볼 것은 네 가지입니다.

  1. 머티리얼 게이트 — 첫 줄에서 머티리얼의 RimLightIntensity와 RimLightWidth가 0보다 클 때만 계산합니다. 파츠별로 림을 끄는 스위치이며, '파라미터의 계층' 절에서 실측 예를 확인합니다.
  2. 오프셋 방향 — 라이트 쪽으로 화면을 탐색합니다. 사용하는 L_ViewportOffsetBase는 호출부인 ToonBxDF()에서 계산됩니다(ToonShadingModel.ush:287-289).라이트 방향을 클립 공간에 투영하고 뷰포트 좌표로 바꾼 뒤, rcp(Depth)로 원근 스케일을 곱합니다. 멀리 있는 픽셀일수록 화면 오프셋이 작아지므로, 탐색 반경이 월드 단위로 일정하게 유지됩니다. 4화 5절의 화면 공간 헤어 그림자가 같은 벡터를 사용했다는 점에 주목하세요. 라이트 방향의 화면 투영이라는 하나의 장치를 헤어 그림자와 림라이트가 공유합니다.
  3. const float3 L_ClipSpace = mul(AreaLight.DiffuseL, (float3x3)View.TranslatedWorldToClip).xyz; const float2 L_ViewportSpace = (L_ClipSpace.xy * float2(0.5, -0.5)); const float2 L_ViewportOffsetBase = L_ViewportSpace * rcp(GBuffer.Depth);
  4. 깊이 비교 — 오프셋된 위치의 씬 깊이를 읽어 현재 픽셀과 비교합니다. 라이트 쪽 이웃이 더 멀다는 것은 이 픽셀이 라이트를 향해 열린 실루엣 가장자리라는 뜻입니다.
    • RimLightDepthThreshold는 노이즈성 오검출을 막는 최소 깊이 차이고, DepthFadeDistance는 경계가 최대 강도에 도달하는 거리입니다. 실측 기본값(Threshold 2, FadeDistance 10)으로 읽으면, 라이트 쪽 이웃이 현재 픽셀보다 2cm 더 멀어지는 지점부터 림이 나타나기 시작해 12cm 차이에서 최대 강도에 도달합니다(UE의 씬 깊이는 cm 단위입니다).
    • 반투명 머티리얼은 TranslucentBasePass.SceneDepth를 읽습니다(ToonShadingModel.ush:259-263). 반투명 첫 레이어의 깊이가 포함된 버퍼이므로, 투명한 머리칼 파츠의 가장자리에서도 림이 계산됩니다.
  5. 색상 결정 — ComputeF0(RimLightIntensity, BaseColor, Metallic)로 림의 기본 색을 정합니다. 정의는 엔진 표준식 lerp(0.08 * Specular, BaseColor, Metallic)이고(ShadingCommon.ush:117-120, 151-154, DielectricSpecularToF0 경유), Specular 자리에 머티리얼의 RimLightIntensity가 들어갑니다.
    • 비금속(Metallic=0)에서는 0.08 * Intensity의 중성 계수가 되고, 금속성이 올라갈수록 베이스 컬러가 섞이는 구조입니다.
    • 4화에서 이 캐릭터의 Metallic이 0으로 고정되어 있음을 확인했으므로, 이 샘플의 림 색은 사실상 DepthFade * 0.08 * Intensity에 라이트 색을 곱한 값입니다. 즉 림의 색조는 베이스 컬러가 아니라 라이트 색에서 결정됩니다.
    half DielectricSpecularToF0(half Specular)
    {
    	return half(0.08f * Specular);
    }
    
    half3 ComputeF0(half Specular, half3 BaseColor, half Metallic)
    {
    	return lerp(DielectricSpecularToF0(Specular).xxx, BaseColor, Metallic.xxx);
    }
    

폭의 곱셈 체인도 정리해 둡니다. 최종 화면 오프셋의 크기는 |L_ViewportSpace| * (3 * MaxWidth) * RimLightWidth² / Depth입니다.

  • 머티리얼의 4비트 [0, 1] 값은 Pow2로 제곱되므로(ToonShadingModel.ush:245) 좁은 폭 구간에서 조절 해상도가 확보됩니다(입력 0.5는 0.25가 됩니다).
  • 전역 MaxWidth에 3을 곱한 값이 탐색 반경의 상한입니다.
  • 1/Depth 항 때문에 화면상 픽셀 폭은 거리가 멀수록 작아지고, 월드 기준 폭은 일정하게 유지됩니다.

최종 적용은 ToonBxDF()의 말미입니다(ToonShadingModel.ush:396-398).

float3 RimLight = GetScreenSpaceDepthTestRimlightColor(GBuffer, ToonGBuffer, MooaToonContext.ViewportUV, L_ViewportOffsetBase) * LightColorAndAttenuation * Shadow.SurfaceShadow;
ColorSaturationPowerAndScale(RimLight, View.MooaRimLightSaturationScale, View.MooaRimLightIntensity);
Lighting.Specular += RimLight;

여기서 세 가지가 드러납니다. 라이트 색과 감쇠를 곱하므로 림은 조명의 색을 따르고, Shadow.SurfaceShadow를 곱하므로 그림자 안의 림은 소멸하며, 출력은 Lighting.Specular에 합산됩니다. 림라이트가 디퓨즈가 아니라 스페큘러 채널로 나가는 구조입니다.

참고로 이 함수에는 시도의 흔적이 주석으로 남아 있습니다. View.MooaRimLightAngle을 쓰는 각도 보정 블록(ToonShadingModel.ush:244, 248-253)인데, 깊이에서 노멀을 재구성해 라이트 각도에 따라 폭을 보정하려던 설계입니다. 현재는 전부 주석 처리되어 있고, 폭은 라이트 각도와 무관하게 일정합니다. 각도 보정보다 단순한 균일 폭을 택한 것 역시 표현 우선의 판단으로 읽힙니다.

파라미터의 계층: 머티리얼·전역 CVar·포스트 프로세스

림라이트의 파라미터는 세 계층에 나뉘어 있습니다.

계층 파라미터 성격

머티리얼(파츠별) Rim Light Intensity, Rim Light Width 파츠별 켬/끔과 폭의 미세 조정  
전역 CVar r.Mooa.RimLight.MaxWidth, r.Mooa.RimLight.DepthTestThreshold, r.Mooa.RimLight.DepthTestFadeDistance 씬 전체의 폭 스케일과 검출 조건  
포스트 프로세스 Rimlight Intensity, Saturation Scale (Scene.h:2476-2480, 카테고리 "Mooa Toon Direct Lighting - Rimlight", 기본값 1.0) 최종 색의 전역 강도와 채도

머티리얼 쪽 두 값은 4화 6절의 인코딩 경로를 탑니다. MP_MooaEncodedAttribute3.z/w로 전달되고, ToonBufferA.z에 RimLightIntensity(4)와 RimLightWidth(4)로 각각 4비트씩 패킹됩니다(ToonShadingCommon.ush:319, 336-339).

EncodedToonBufferA.z = EncodeFloat2ToFloat(
    ToonGBuffer.DiffuseColorRampUVOffset,
    EncodeFloat2ToFloat(ToonGBuffer.RimLightIntensity, ToonGBuffer.RimLightWidth, 4, 4),
    8, 8);

4비트이므로 파츠별 값은 16단계의 정밀도입니다. 큰 폭의 조정은 전역 MaxWidth(3.0을 곱한 값이 최대 탐색 폭)의 몫이고, 머티리얼 쪽은 파츠 사이의 상대적인 차이를 나타내는 용도입니다. 이 구분은 비트 수에서도 드러납니다. 전역 CVar들은 SceneRendering.cpp:153-158에 등록되어 있고, 4화 1절에서 확인한 대로 BP_MooaLookDevTool의 Rimlight 프로퍼티 5종과 1:1로 연동됩니다(실측값: MaxWidth 1, Threshold 2, FadeDistance 10).

이번 화에서도 파츠별 게이트의 실측 예가 나옵니다. MI_UnityChan_Base의 Rim Light Intensity는 1.0인데 MI_UnityChan_Eyes는 0.0입니다. 눈, 특히 각막에 림라이트가 들어가면 반짝이는 유리 조각처럼 보이기 때문에, 눈 파츠는 머티리얼 단계에서 림을 끈 것입니다. 전역으로 림을 아무리 강하게 줘도 이 파츠에는 적용되지 않습니다.

실습. 림라이트의 폭과 임계값을 바꿉니다.

  1. 콘솔에서 r.Mooa.RimLight.MaxWidth를 0.5와 2로 번갈아 설정합니다(또는 BP_MooaLookDevTool 디테일 패널의 Rimlight Max Width). 실루엣을 따라 들어오는 림의 폭이 두 배로 변하는 것을 확인합니다.
  2. r.Mooa.RimLight.DepthTestThreshold를 0으로 내리면 얕은 깊이 차이에도 림이 생겨 실루엣이 아닌 곳까지 번짐을 확인하고, 다시 2로 돌립니다.
  3. 라이트를 회전시켜 림이 라이트가 오는 쪽의 실루엣에만 형성되는지 확인합니다.
  4. 얼굴과 눈을 나란히 놓고 비교합니다. 눈에 림이 없는 이유, 즉 머티리얼의 Intensity 0.0을 '파라미터의 계층' 절과 대조합니다.
  5. 라이트 반대쪽, 즉 그림자 안으로 들어간 실루엣에서 림이 소멸하는지 확인합니다(Shadow.SurfaceShadow 곱의 효과).

마무리

림라이트는 노멀 대신 깊이 버퍼를 읽는 화면 공간 연산으로, 텍스처 베이크에 의존한 5-1화의 SDF 얼굴 그림자와 대비됩니다. 머티리얼 4비트 파라미터·전역 CVar·포스트 프로세스의 세 계층으로 나뉜 제어 구조도 기억할 만합니다. 다음 화(5-3화)에서는 엔진 코드가 아예 없는 표현 — 아웃라인 — 을 다루고, 세 표현이 타는 데이터 경로를 하나로 연결해 시리즈를 마무리합니다.