TECHARTNOMAD | MAZELINE.TECH

UNREAL ENGINE

[MooaToon] 5-1화 — SDF 얼굴 그림자: Distance Field Facial Shadow

jplee 2026. 9. 23. 20:44

들어가며

4화에서는 L_LookDev_UnityChan을 기준으로 UnityChan SD의 툰 머티리얼을 파츠별로 확인하고, 디퓨즈 램프·헤어 하이라이트·헤어 그림자의 설정과 데이터 경로를 살펴보았습니다. 이번 화부터는 4화에서 미뤄 둔 세 가지 표현을 다룹니다. SDF 얼굴 그림자, 림라이트, 아웃라인은 모두 애니메이션풍 캐릭터의 인상을 결정하지만, 각각 텍스처 베이크, 화면 공간 연산, 메시 데이터라는 서로 다른 계층에 의존하므로 5-1화(SDF 얼굴 그림자), 5-2화(림라이트), 5-3화(아웃라인과 연결 추적)의 세 편으로 나누었습니다. 첫 번째 주제는 SDF 기반 얼굴 그림자입니다.

물리적으로 정확한 얼굴보다 작화 의도를 보존하는 얼굴

게임 산업에서 애니메이션풍 3D 캐릭터를 제작할 때의 핵심 과제는 단순히 명암을 두 단계로 나누는 것이 아니었습니다. 일반적인 셀 셰이딩은 NdotL을 임계값으로 나누어 밝은 면과 어두운 면을 만들지만, 얼굴에서는 코·입 주변·볼·눈 소켓의 미세한 곡률이 그림자 경계에 그대로 반영됩니다. 그 결과 조명이 조금만 움직여도 얼굴에 작은 음영 조각이 생기거나, 작화에서 중요하게 다루는 눈·코·입의 가독성이 불안정해집니다. 프레임마다 작화 수정이 가능한 2D 애니메이션과 달리 게임은 카메라와 조명이 실시간으로 변하므로, 후반 보정 없이도 아트 디렉션이 유지되는 규칙이 필요했습니다. 연구 분야에서도 이 문제를 실시간 스타일 렌더링에서 원치 않는 그림자를 자동으로 억제하면서 아티스트의 의도를 보존해야 하는 과제로 다뤄 왔습니다(Shading Rig, 2021).

이러한 요구는 2010년대 애니메이션풍 3D 게임의 발전과 함께 구체화되었습니다. Guilty Gear Xrd와 같은 작품은 3D 모델을 사용하면서도 기존 2D 스프라이트의 인상을 유지하는 것을 제작 목표로 제시했고, 전용 셀 셰이더뿐 아니라 모델링·노멀·애니메이션을 표현 규칙에 맞추는 제작 방식을 대중적으로 각인시켰습니다(GDC 발표 자료). 이 시기의 핵심 변화는 조명을 물리 계산의 결과로만 보지 않고, 캐릭터 디자인을 보존하기 위해 적극적으로 통제해야 하는 요소로 다루기 시작했다는 점입니다. 다만 공개 자료만으로 특정 상용 게임을 SDF 얼굴 그림자의 최초 사례로 단정하기는 어렵습니다. 현재의 방식은 한 작품에서 완성되었다기보다, 노멀 편집·얼굴 전용 셰이딩·마스크 기반 명암 제어가 축적되면서 형성된 것으로 보는 편이 정확합니다.

SDF 자체는 얼굴 셰이딩보다 먼저 실시간 그래픽에서 활용되었습니다. Valve의 Chris Green은 SIGGRAPH 2007에서 고해상도 이미지의 경계까지의 거리를 저해상도 텍스처 한 채널에 저장하고, 실행 시 임계값과 비교해 선명하거나 부드러운 경계를 복원하는 방법을 소개했습니다(Improved Alpha-Tested Magnification for Vector Textures and Special Effects). 당시의 주된 대상은 글리프, 데칼, 벡터 형태였지만, 복잡한 경계의 형태를 하나의 스칼라 거리값으로 압축하고 임계값으로 연속 제어한다는 원리는 이후 얼굴 그림자에도 적용할 수 있었습니다.

이 원리를 얼굴 그림자에 적용하는 방식은 다음과 같습니다. 아티스트가 여러 조명 각도에서 원하는 얼굴 그림자를 먼저 그린 뒤 각 경계를 거리장으로 변환하고, 그 결과를 하나의 threshold map으로 합칩니다. 런타임에서는 얼굴의 정면 방향과 광원 방향 사이의 각도를 임계값으로 사용해 맵을 판정합니다. 좌우 조명은 채널 또는 UV 반전으로 구분합니다. 이 방식은 여러 장의 그림자를 매 프레임 교체하는 것보다 데이터가 단순하고, 각도 사이의 변화가 연속적이며, 무엇보다 그림자 형태를 아티스트가 직접 설계할 수 있습니다. 공개 제작 도구인 sdf_shadow_threshold_map도 여러 각도의 마스크를 SDF로 보간해 하나의 threshold map을 만드는 동일한 제작 논리를 사용합니다.

2020년대에는 애니메이션풍 오픈월드와 캐릭터 중심 게임의 확산, 그리고 상용 셰이더를 분석한 공개 구현의 증가를 통해 이 접근이 널리 알려졌습니다. 특히 커뮤니티에서는 Genshin Impact 계열의 얼굴 셰이딩을 재현하는 과정에서 정면 벡터와 광원 각도를 얼굴용 threshold texture와 비교하는 방식이 반복적으로 구현되었습니다(Unity URP 공개 구현). 이러한 사례는 특정 작품이 이 기법을 최초로 발명했다는 증거라기보다, SDF 얼굴 그림자가 현대 애니메이션풍 캐릭터 렌더링의 대표적인 해법으로 자리 잡고 확산된 과정을 보여줍니다.

정리하면 도입 동기는 세 가지입니다.

  1. 얼굴의 가독성 유지: 미세한 지오메트리와 노멀로 인해 발생하는 불필요한 명암 조각을 억제합니다.
  2. 아트 디렉션의 재현: 조명이 변해도 아티스트가 설계한 그림자 형태와 캐릭터의 인상을 유지합니다.
  3. 실시간 운용과 제작 효율: 여러 각도의 작화 결과를 하나의 텍스처와 단순한 임계값 비교로 압축하여, 연속적인 변화와 낮은 런타임 비용을 함께 확보합니다.

MooaToon의 구현도 이 역사적 흐름에 속합니다. Face Forward 데이터를 기준축으로 사용하고, 좌·우 SDF 값을 빈 GBuffer 채널에 실어 보낸 뒤, 광원 각도와 합산해 디퓨즈 램프의 좌표로 변환합니다. 즉 물리적으로 계산된 얼굴 그림자를 더 정밀하게 만드는 것이 아니라, 작화된 그림자를 실시간 조명 체계 안에 안정적으로 연결하는 것이 설계의 목적입니다.

해결하려는 문제

앞에서 정리한 과제를 이제 MooaToon의 구현 단위로 좁혀 보겠습니다. NdotL 기반의 일반적인 그림자 계산을 얼굴에 그대로 적용하면 미세한 굴곡이 전부 명암 경계에 반영되어 그림자가 얼룩집니다. 애니메이션풍 얼굴에 필요한 것은 지오메트리가 아니라 그림으로 제어된 형태이며, 라이트가 정면에 있으면 그림자가 거의 없다가 측면으로 돌아갈수록 미리 그려 둔 형태의 그림자가 서서히 밀려 들어오는 방식입니다.

MooaToon의 Distance Field Facial Shadow는 앞에서 본 SDF threshold map 계열을 이 엔진의 디퓨즈 램프 체계에 연결한 구현입니다. 얼굴을 정면에서 본 이미지 위에 각 픽셀이 그림자 경계까지 얼마나 떨어져 있는지를 거리장으로 저장해 두고(흔히 shadow threshold map이라 부릅니다), 라이트의 각도를 임계값으로 삼아 이 거리장과 비교합니다. 한 장의 텍스처만으로 라이트 각도에 따라 연속적으로 변형되는 그림자를 얻습니다.

이 기능의 위치는 4화 5절 말미에서 확인했습니다. MI_UnityChan_Face에는 Enable Feature Distance Field Facial Shadow 스위치가 꺼진 채로 Distance Field Facial Shadow Map 텍스처(shadow_threshold_map)만 배정되어 있습니다. 즉 데이터는 이미 실려 있고, 스위치만 켜면 되는 상태입니다.

머티리얼 설정: Feature 스위치와 상호 배타성

머티리얼 쪽 지정 방법은 다른 Feature와 같습니다. Feature 스위치는 Enable Feature PBR Specular, Enable Feature Kajiya Hair Specular, Enable Feature Distance Field Facial Shadow 세 가지이며 동시에 하나만 켤 수 있습니다(4화 4절). 이러한 상호 배타성은 단순한 UI 규칙이 아니라 인코딩 구조 자체에 내재합니다. Shading Feature ID는 ToonShadingCommon.ush:49-53에 다음과 같이 정의되어 있습니다.

#define MOOA_SHADING_FEATURE_ID_DEFAULT                         0
#define MOOA_SHADING_FEATURE_ID_PBR_SPECULAR                    1
// Require Tangents:
#define MOOA_SHADING_FEATURE_ID_KAJIYA_HAIR_SPECULAR            2
#define MOOA_SHADING_FEATURE_ID_DISTANCE_FIELD_FACIAL_SHADOW    3

이 ID는 GBuffer의 CustomData.w 안에 2비트로 패킹됩니다(ToonShadingCommon.ush:326). 2비트로 값 4개를 표현하도록 설계했으므로, 한 픽셀은 구조적으로 하나의 Feature만 가질 수 있습니다. Feature를 추가하려면 비트를 늘려야 하고, 그것은 곧 GBuffer 예산 문제가 됩니다. 3-2화에서 본 "비트 단위 예산 배분"이 여기서도 그대로 적용됩니다.

상호 배타 구조에는 한 가지 부수 효과가 있습니다. SDF 얼굴 그림자를 선택하면 해당 픽셀의 Anisotropy 스페큘러가 강제로 꺼집니다(ToonShadingModel.ush:344-345).

if (ToonGBuffer.ShadingFeatureID == MOOA_SHADING_FEATURE_ID_DISTANCE_FIELD_FACIAL_SHADOW)
    bHasAnisotropy = false;

이유는 뒤의 '엔진 측 계산' 절에서 확인할 채널 재사용에 있습니다. Anisotropy 채널이 SDF 데이터의 운송 수단으로 전용되기 때문에, 본래 용도와 동시에 쓸 수 없는 것입니다.

나머지 머티리얼 파라미터는 두 개입니다. 텍스처 Distance Field Facial Shadow Map이 SDF 텍스처이고, 벡터 파라미터 Distance Field Facial Shadow Map Channel이 좌·우 그림자를 읽을 채널을 지정합니다.

데이터 준비: Face Forward와 SDF 텍스처

이 표현에는 두 종류의 데이터가 필요하며, 모두 런타임이 아닌 사전 베이크로 생성합니다. 스위치 설명문에는 준비 절차가 그대로 안내되어 있습니다.

  • Face Forward Direction — 얼굴의 정면 방향을 베이크한 데이터입니다. 런타임에는 버텍스 탄젠트 채널을 통해 GBuffer.WorldTangent까지 운송됩니다(아래 '엔진 측 계산' 절 참조).
    • 베이크 경로는 두 가지이고 둘 다 이 프로젝트에 실재합니다. 에디터 경로: 스켈레탈 메시 우클릭 Scripted Asset Actions > Mooa Toon > Bake Face Forward Direction — 자산은 EUBP_SmoothNormal(/MooaToon/Blueprints/EditorUtilities)이며, 내부에서 5-3화의 MooaToonScripts 메시 데이터 함수들을 호출합니다.
    • Houdini 경로: HDA mooa_bakeFaceForwardDirToUV23(Art/Models/hda에 동봉).
    • 베이크 후에는 Debug View에서 World Tangent 표시로 결과를 검증합니다. 크래시 가능성에 대비해 베이크 전 저장이 권고됩니다.
    • 표기 차이 주의: 같은 베이크 채널을 BP 도구는 UV12, HDA는 UV23로 부릅니다. MooaToonScripts의 C++ 컨텍스트 메뉴 등록 코드(MooaToonEditorScripts.cpp:16-66)는 "MooaToon (C++)" 서브메뉴를 만들던 구형 구현으로 전량 주석 처리되어 있고, 현재는 BP 유틸리티가 그 자리입니다. 주석으로 남은 툴팁에는 옮긴 이유까지 기록되어 있습니다. C++ 구현이 BP보다 훨씬 빠르지만, 헤어 핀 같은 극단적 토폴로지의 버텍스 노멀은 스무딩에 실패할 수 있다는 것입니다.
static void CreateActionsMenuForAsset(FMenuBuilder& MenuBuilder, TArray<FAssetData> SelectedAssets)
{
	// MenuBuilder.AddMenuEntry(
	// 	FText::FromName(TEXT("Bake Smooth Normal and Curvature")),
	// 	FText::FromName(TEXT(
	// 		"烘焙切线空间平滑法线和曲率到texcoord[2].y and texcoord[3], 曲率以长度的形式表示.\n"
	// 		"模型必须具有切线和副法线, 你可以从DCC中计算, 也可以在导入设置中选择重新计算切线.\n"
	// 		"这是基于C++的实现, 虽然比蓝图(Engine/Plugins/MooaToon/Content/Blueprints/EditorUtilities/EUBP_SmoothNormal)方法快很多, 但是可能有部分极端拓扑的顶点法线无法平滑(如发尖)"
	// 		"\n"
	// 		"\n"
	// 		"Bake Smoothed Tangent Space Normal and Curvature to texcoord[2].y and texcoord[3], Curvature expressed as Length.\n"
	// 		"The model must have Tangents and Binormals, you can either calculate from DCC or choose to Recompute Tangents in the import settings.\n"
	// 		"This is a C++ based implementation. Although it is much faster than the blueprint (Engine/Plugins/MooaToon/Content/Blueprints/EditorUtilities/EUBP_SmoothNormal) method, but there may be some extreme topological vertex normals that cannot be smoothed (such as hair tips)\n"
	// 	)),
	// 	FSlateIcon(),
	// 	FExecuteAction::CreateStatic(&SmoothNormalCommand::SmoothNormal, SelectedAssets)
	// );
}

static void CreateSubMenuForAsset(FMenuBuilder& MenuBuilder, TArray<FAssetData> SelectedAssets)
{
	// MenuBuilder.AddSubMenu(FText::FromName(TEXT("MooaToon (C++)")), FText::FromName(TEXT("")),
	// 	FNewMenuDelegate::CreateStatic(&CreateActionsMenuForAsset, SelectedAssets)
	// );
}

static TSharedRef<FExtender> OnExtendContentBrowserAssetSelectionMenu(const TArray<FAssetData>& SelectedAssets)
{
	TSharedRef<FExtender> Extender(new FExtender());
	// Extender->AddMenuExtension(
	// 	"CommonAssetActions",
	// 	EExtensionHook::First,
	// 	nullptr,
	// 	FMenuExtensionDelegate::CreateStatic(&CreateSubMenuForAsset, SelectedAssets));


	return Extender;
}

void FMooaToonEditorScriptsModule::StartupModule()
{
	// This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module
	// FContentBrowserModule& ContentBrowserModule = FModuleManager::LoadModuleChecked<FContentBrowserModule>("ContentBrowser");
	// {
	// 	HookEditorUIContentBrowserExtenderDelegate = FContentBrowserMenuExtender_SelectedAssets::CreateStatic(&OnExtendContentBrowserAssetSelectionMenu);
	// 	TArray<FContentBrowserMenuExtender_SelectedAssets>& CBMenuExtenderDelegates = ContentBrowserModule.GetAllAssetViewContextMenuExtenders();
	// 	CBMenuExtenderDelegates.Add(HookEditorUIContentBrowserExtenderDelegate);
	// 	HookEditorUIContentBrowserExtenderDelegateHandle = CBMenuExtenderDelegates.Last().GetHandle();
	// }
	
}

설명문에는 알려진 문제도 함께 명시되어 있습니다. Face Forward 방향, 월드 Z축, 광원 방향이 같은 평면 위에 놓이면 정밀도가 부족해진다는 것입니다. 이 경우 해결책은 방향 중 하나를 살짝 비트는 것입니다. 이 제약의 원인은 '엔진 측 계산' 절의 좌·우 판정식에서 확인할 수 있습니다.

엔진 측 계산: ApplyDistanceFieldFacialShadow

실제 계산은 ToonShadingModel.ush:155-170의 ApplyDistanceFieldFacialShadow() 전부에 해당합니다. 길이가 짧으므로 전문을 인용합니다.

void ApplyDistanceFieldFacialShadow(FMooaToonContext MooaToonContext, FGBufferData GBuffer, FToonGBufferData ToonGBuffer, float3 L, float DiffuseColorRampUVOffset,
    inout float FacialShadowGradient)
{
    BRANCH if (ToonGBuffer.ShadingFeatureID == MOOA_SHADING_FEATURE_ID_DISTANCE_FIELD_FACIAL_SHADOW
        && GetEnableDistanceFieldFacial(MooaToonContext))
    {
        float3 FaceForwardDir = GBuffer.WorldTangent;
        float LightAngle01 = FastACos(dot(FaceForwardDir, L)) * PI_INV; // 0 - 180 => 0 - 1
        float RampUVOffsetByLightAngle = 1.0f - clamp(LightAngle01, 1e-6, 1.0f - 1e-6) - 0.5f;

        bool bLightAtRight = (FaceForwardDir.x * L.y - FaceForwardDir.y * L.x) >= 0; // cross(FaceForwardDir, L).z >= 0;
        float shadowSdf = bLightAtRight ? ToonGBuffer.FacialShadowSdfRight : ToonGBuffer.FacialShadowSdfLeft;

        FacialShadowGradient = LinearToDotProductSpace(RampUVOffsetByLightAngle + shadowSdf + DiffuseColorRampUVOffset);
    }
}

동작 과정은 다음 다섯 단계로 나눌 수 있습니다.

  1. 정면 방향 읽기 — FaceForwardDir = GBuffer.WorldTangent. Face Forward는 버텍스 탄젠트 채널을 재사용해 GBuffer까지 운송됩니다. 머티리얼의 Tangent 입력이 0이면 메시의 월드 탄젠트가 그대로 출력되도록 되어 있고(MaterialTemplate.ush:4226-4234), 이를 이용해 베이크된 정면 방향을 라이팅 단계까지 전달합니다. Kajiya-Kay에서 탄젠트가 하이라이트 방향 데이터의 운송 수단이었던 것과 같은 패턴입니다.
  2. 라이트 각도 계산 — FastACos(dot(FaceForwardDir, L)) * PI_INV. 정면과 라이트의 각도(0~180도)를 0~1 범위로 정규화합니다.
  3. 램프 오프셋 변환 — 1.0f - angle - 0.5f. 정면 조명에서 0, 측면으로 갈수록 ±0.5 방향으로 램프 좌표를 이동시키는 오프셋을 만듭니다.
  4. 좌·우 판정 — Face Forward와 라이트 방향의 2D 외적 z 성분 부호로 라이트가 오른쪽에 있는지 판정하고, 그 결과로 FacialShadowSdfRight 또는 FacialShadowSdfLeft를 선택합니다. 이 판정식이 수평면 위의 부호 판정이기 때문에, Face Forward·월드 Z축·광원이 같은 평면에 놓이면 외적의 z 성분이 0 근처에서 불안정해집니다. '데이터 준비' 절의 알려진 문제가 이 한 줄에서 발생합니다.
  5. 최종 그림자 합성 — LinearToDotProductSpace(RampUVOffsetByLightAngle + shadowSdf + DiffuseColorRampUVOffset). 라이트 각도 오프셋과 SDF 값, 머티리얼의 램프 오프셋을 더해 램프 좌표계로 변환합니다.

5단계의 LinearToDotProductSpace()는 이 절에서 여러 번 나오는 좌표 변환으로, 정의는 짧습니다(ToonShadingModel.ush:61-64).

float LinearToDotProductSpace(float gradient01)
{
	return cos(saturate(gradient01) * PI) * -0.5f + 0.5f;
}

0~1 선형 그라디언트를 내적 공간 기준의 값으로 되돌리는 변환으로, 2단계의 FastACos(dot(...)) * PI_INV와 역변환 관계에 있습니다. 각도 공간에서 계산한 오프셋과 SDF 값의 합을 램프 샘플링이 기대하는 좌표계로 맞추는 역할이며, cos 곡선을 타므로 정면 조명 부근에서 변화율이 완만하다는 특성이 있습니다.

이 다섯 단계를 실제 수식으로 풀어보겠습니다. 각 줄이 계산하는 값의 의미는 다음과 같습니다.

  1. dot(FaceForwardDir, L) — 두 단위 벡터의 내적, 즉 cosθ입니다. 라이트가 얼굴 정면이면 1, 정측면이면 0, 정후면이면 -1입니다. 여기서 θ는 노멀이 아니라 Face Forward와 라이트가 이루는 각입니다.
  2. FastACos(...) * PI_INV — cosθ를 다시 각도 θ ∈ [0, π]로 되돌리고 π로 나누어 [0, 1]로 정규화합니다. FastACos는 다항식 근사 FastACosPos(ToonShadingCommon.ush:124-131, p(x) = (0.0468878x - 0.203471)x + 1.570796에 √(1-x)를 곱하는 형태)와 음수 입력에 대한 범위 환원(PI - res)의 조합으로, Sébastien Lagarde가 GCN 아키텍처용으로 정리한 최적화를 따릅니다. 주석 기준 12 VALU입니다. Inverse trigonometric functions GPU optimization for AMD GCN architecture
  3. 1.0f - clamp(LightAngle01, 1e-6, 1.0f - 1e-6) - 0.5f — 정규화 각도를 램프 오프셋으로 변환합니다. 정면(0)에서 +0.5, 정측면(0.5)에서 0, 후면(1)에서 -0.5입니다. 양 끝의 1e-6 가드는 acos 근사가 부정확해지는 ±1 근방과 이후 cos 변환의 포화 구간을 피하기 위한 장치로 읽힙니다.
  4. FaceForwardDir.x * L.y - FaceForwardDir.y * L.x — cross(FaceForwardDir, L).z의 전개로, 두 벡터를 월드 XY 평면에 정사영한 2D 외적입니다. 부호가 라이트의 좌우를 결정하고, 그 결과로 FacialShadowSdfRight/FacialShadowSdfLeft 중 하나를 선택합니다. Face Forward와 라이트가 월드 Z축과 같은 수직 평면에 놓이면 XY 정사영이 서로 평행해져 이 값이 0 근처에서 부호를 오락가락합니다. '데이터 준비' 절의 알려진 문제가 발생하는 정확한 지점입니다.
  5. LinearToDotProductSpace(RampUVOffsetByLightAngle + shadowSdf + DiffuseColorRampUVOffset) — 각도 오프셋, SDF 값, 머티리얼 램프 오프셋의 합은 선형 공간의 그라디언트이고, 이 마지막 변환으로 dot 공간(비선형)의 값이 되어 ShadowGradient를 대체합니다.

LinearToDotProductSpace(x)를 정리하면 (1 - cos(πx)) / 2, 즉 sin²(πx/2)입니다. 여기에 2단계의 결과 x = θ/π를 대입하면 (1 - cosθ) / 2 = 1 - NoL_Half가 됩니다. 즉 이 변환은 선형 각도 그라디언트를 NoL_Half와 정확히 같은 비선형(dot) 공간의 곡선 위에 올려놓습니다. 이후의 min 합성(ToonShadingModel.ush:312)과 램프 샘플링이 전부 이 공간에서 이루어지므로, SDF에서 온 값과 NoL에서 온 값이 같은 좌표계 위에서 경쟁합니다. 함수 위 주석의 "sampling results of Ramp are consistent"(ToonShadingModel.ush:58-60)가 말하는 일관성이 이것입니다.

수치를 대입하면 동작을 더 명확하게 확인할 수 있습니다. DiffuseColorRampUVOffset = 0을 가정하면, 픽셀의 명암이 뒤집히는 경계 조건은 합계가 0.5가 되는 지점, 즉 sdf = θ/π입니다. 오프셋이 0.5 - θ/π이므로 정리하면 정확히 이렇게 됩니다.

  • 정면(θ=0°): 경계 조건이 sdf = 0. 어떤 픽셀도 sdf가 0 이하로 내려가지 않으므로 얼굴 전체가 밝습니다.
  • 정측면(θ=90°): 경계 조건이 sdf = 0.5. 텍스처의 0.5 등값선, 즉 그려 둔 그림자 경계가 램프의 정중앙에 그대로 실립니다.
  • 후면(θ=180°): 경계 조건이 sdf = 1. 사실상 전면이 그림자입니다.

이 식은 threshold map 제작 규약을 코드 수준에서 뒷받침합니다. SDF 텍스처의 값 하나하나는 "이 픽셀이 그림자로 넘어가는 라이트 각도를 180도 기준으로 정규화한 값"으로 해석할 수 있습니다. 0.5 등값선이 측면 조명의 그림자 경계이고, 값이 클수록 더 후면 쪽 조명까지 밝게 버티는 영역입니다. 머티리얼의 Diffuse Color Ramp UV Offset이 0이 아니면 경계는 sdf = θ/π - RampUVOffset으로 평행 이동하므로, 램프 오프셋 파라미터는 얼굴 전체의 명암 경계를 각도 축 위에서 밀고 당기는 역할입니다. 그리고 cos 곡선의 미분이 정면(x=0)에서 0이므로, 정면 근처에서는 경계가 천천히, 측면 근처에서는 빠르게 밀립니다.

이 계산 결과가 어디에 사용되는지도 중요합니다. 호출부는 ToonBxDF()의 디퓨즈 블록이며(ToonShadingModel.ush:299-314), ApplyDistanceFieldFacialShadow는 ShadowGradient를 inout으로 덮어씁니다.

float ShadowGradient = saturate(NoL_Half + DiffuseColorRampUVOffset);
...
ApplyDistanceFieldFacialShadow(MooaToonContext, GBuffer, ToonGBuffer, AreaLight.DiffuseL, DiffuseColorRampUVOffset,
    /*inout*/ ShadowGradient);
...
float DiffuseColorRampU = min(ShadowGradient, LinearToDotProductSpace(CustomLinearShadowGradient));
half4 DiffuseColorRamp = SampleGlobalRamp(View.MooaGlobalDiffuseColorRampAtlas, DiffuseColorRampU, ...);

즉 SDF 그림자는 빛의 색에 곱해지는 마스크가 아니라, 디퓨즈 램프의 U 좌표가 됩니다. 이것이 이 구현의 핵심 설계입니다. 그림자의 경계 형태와 부드러움은 별도의 softness 파라미터 없이 전부 램프 커브가 담당하게 되며, 4화 3절의 램프 시스템과 완전히 같은 후처리를 탑니다. 그림자의 "형태"는 SDF 텍스처가, "질감"은 램프가 담당하는 분업입니다.

라이트 타입별 적용 여부는 포스트 프로세스에서 제어합니다. View.MooaEnableDistanceFieldFacialShadow{Directional|Point|Spot|Rect}Lights 네 개의 프로퍼티가 Scene.h:2444-2454에 정의되어 있고, 카테고리는 "Mooa Toon|Direct Lighting - Shadow", 기본값은 전부 true입니다. 함수 첫 줄의 GetEnableDistanceFieldFacial(MooaToonContext)이 이 게이트를 읽는 부분입니다.

채널 재사용: Metallic과 Anisotropy

이제 SDF 값이 GBuffer를 통과하는 과정을 살펴보겠습니다. ToonShadingCommon.ush:315-330의 비트 배정 주석에 그 답이 있습니다.

* Metallic:   FacialShadowSdfLeft(8)
* Anisotropy: FacialShadowSdfRight(8)

SDF 텍스처의 샘플링은 엔진이 아니라 머티리얼 그래프에서 일어납니다. 머티리얼이 Distance Field Facial Shadow Map Channel로 지정된 채널에서 좌·우 threshold 값을 읽어 MP_MooaEncodedAttribute4.x/y에 넣으면, 베이스 패스가 그 값을 Metallic(왼쪽, 8비트)과 Anisotropy(오른쪽, *2-1 인코딩) 채널에 기록합니다(ToonShadingCommon.ush:353-366).

BRANCH if (ToonGBuffer.ShadingFeatureID == MOOA_SHADING_FEATURE_ID_DISTANCE_FIELD_FACIAL_SHADOW)
{
    Metallic = ToonGBuffer.FacialShadowSdfLeft;
}
...
BRANCH if (ToonGBuffer.ShadingFeatureID == MOOA_SHADING_FEATURE_ID_DISTANCE_FIELD_FACIAL_SHADOW)
{
    Anisotropy = ToonGBuffer.FacialShadowSdfRight * 2.0f - 1.0f;
}

라이팅 단계의 디코드에서는 값을 꺼낸 뒤 두 채널을 원래 값으로 되돌립니다(ToonShadingCommon.ush:386-392). Metallic은 0으로, Anisotropy는 중립값 0.5로 초기화되므로 이후 계산에서 오염이 없습니다.

BRANCH if (ToonGBuffer.ShadingFeatureID == MOOA_SHADING_FEATURE_ID_DISTANCE_FIELD_FACIAL_SHADOW)
{
    ToonGBuffer.FacialShadowSdfLeft = Metallic;
    ToonGBuffer.FacialShadowSdfRight = EncodedAnisotropy;
    Metallic = 0.0f;
    EncodedAnisotropy = 0.5f;
}

머티리얼 단계의 패킹은 구조체 필드를 그대로 복사하는 방식입니다. EncodeToonGBufferDataToMaterialAttribute()가 MooaEncodedAttribute4.x/y에 두 SDF 값을 그대로 대입하고(ToonShadingCommon.ush:281-282), 베이스 패스의 디코드(:305-306)를 거쳐 위의 MRT 인코드로 전달됩니다. 4화 6절에서 본 MP_MooaEncodedAttribute4 핀이 이 1차 패킹의 머티리얼 측 출구입니다.

정밀도도 함께 살펴볼 필요가 있습니다. 두 채널 모두 8비트 고정소수(주석의 (8))이므로, R16F 텍스처 원본이 GBuffer를 지나며 256단계로 양자화됩니다. 다만 SDF 값은 마스크가 아니라 램프 U 좌표의 가산항이므로, 양자화 오차는 램프의 경계 구간 안에 흡수되어 밴딩으로 드러나지 않습니다. Anisotropy 채널은 엔진 GBuffer가 [-1, 1] 범위로 저장하기 때문에 * 2 - 1의 재매핑을 한 번 더 거치고(:364), 디코드에서는 이미 [0, 1]로 되돌아온 값을 받습니다(:389). 디코드 끝의 Metallic = 0.0f; EncodedAnisotropy = 0.5f 리셋(:390-391)은 빌려 쓴 채널을 중립값으로 돌려놓는 정리입니다. 이후 PBR 계산이 이 채널들을 본래 의미로 읽어도 오염이 없도록 하는 것인데, Anisotropy 쪽이 0이 아니라 0.5인 이유는 이 채널의 인코딩에서 0.5가 실제값 0에 대응하기 때문입니다.

이 설계가 선택된 이유는 명확합니다. 툰 머티리얼은 Metallic과 Anisotropy를 본래 용도로 사용하지 않습니다. 4화에서 확인한 대로 툰의 머티리얼 속성은 램프 인덱스와 색상, 패킹된 플래그로 구성되며, PBR의 금속성·이방성 개념이 들어갈 자리가 없습니다. 비어 있는 채널을 새 데이터의 운송 수단으로 재활용한 것이고, 이로 인해 SDF 기능은 GBuffer 레이아웃 변경 없이 추가되었습니다. 대가는 '머티리얼 설정' 절에서 본 Anisotropy 스페큘러와의 양립 불가입니다. 얼굴에 Anisotropy 스페큘러를 쓸 일이 없다는 판단이 전제되어 있습니다.

화면 공간 헤어 그림자와의 비교

4화 5절의 화면 공간 헤어 그림자와 이번 절의 SDF 얼굴 그림자는 둘 다 "얼굴 위의 그림자"를 다루지만 목적과 방식이 다릅니다.

구분 SDF 얼굴 그림자 화면 공간 헤어 그림자
대상 얼굴 자체의 명암 형태 머리칼이 얼굴에 드리우는 그림자
형태의 출처 미리 그린 SDF 텍스처 실시간 화면 공간 투영
라이트 대응 정면 기준 각도 보간 라이트 방향의 뷰포트 투영 오프셋
플래그/채널 ShadingFeatureID 3 + Metallic·Anisotropy RayTracingShadowFlag 2(받는 쪽)·3(쏘는 쪽)
적용 위치 ShadowGradient → 램프 U 좌표 OutLinearShadow와 min 합성

RayTracingShadowFlag의 값 정의는 ToonShadingCommon.ush:59-62에 있습니다. NONE(0), FACE(1), FACE_SCREEN_SPACE_HAIR_SHADOW(2), HAIR(3)이며, 4화에서 본 대로 머티리얼의 Is Face/Is Hair 스위치와 ID Map으로부터 기록됩니다.

두 시스템은 충돌하지 않습니다. 화면 공간 헤어 그림자는 OutLinearShadow에 min으로 합성되고(ToonShadingModel.ush:307), SDF 그림자는 ShadowGradient를 대체합니다. 같은 얼굴 픽셀에서 "머리칼의 그림자는 실시간 투영으로, 볼과 이마의 명암은 그려진 형태로"라는 조합이 가능합니다. 이 샘플의 Face 인스턴스는 화면 공간 헤어 그림자만 켜져 있고 SDF는 꺼져 있으므로, 현재 상태는 전자만 사용하는 구성입니다.

실습. SDF 얼굴 그림자를 켭니다. 원본 에셋을 오염시키지 않기 위해 MI_UnityChan_Face를 복제해 복제본에서 작업합니다.

  1. 복제본에서 Enable Feature Distance Field Facial Shadow를 켭니다. 다른 Feature 스위치가 자동으로 해제되는 것을 확인합니다.
  2. 캐릭터의 Face 슬롯 머티리얼을 복제본으로 교체하고, BP_MooaLookDevTool로 라이트를 회전시킵니다. 그림자의 형태가 램프 경계를 따라 얼굴 위를 회전하는지 관찰합니다.
  3. Distance Field Facial Shadow Map Channel의 채널 선택을 바꿔 그림자가 어떻게 달라지는지 확인합니다.
  4. 포스트 프로세스 볼륨의 "Mooa Toon|Direct Lighting - Shadow" 카테고리에서 라이트 타입별 SDF 스위치를 꺼 봅니다.
  5. '데이터 준비' 절의 알려진 문제를 재현해 봅니다. 라이트를 얼굴 정면과 월드 Z축이 이루는 평면 위에 정확히 정렬시켜, 좌·우 판정이 불안정해지는 각도를 찾아봅니다.

마무리

SDF 얼굴 그림자의 요점은 세 가지로 압축됩니다. 그림자의 "형태"는 아티스트가 그린 거리장 텍스처가 결정하고, 계산 결과는 빛의 마스크가 아니라 디퓨즈 램프의 U 좌표가 되며, 데이터 운송에는 툰에서 쓰지 않는 Metallic·Anisotropy 채널이 재활용됩니다. 다음 화(5-2화)에서는 얼굴 위의 그림자를 다루지만 접근 방식은 정반대인 림라이트, 즉 노멀이 아니라 깊이 버퍼로 실루엣을 찾는 방식을 살펴봅니다.