들어가며
3화 시리즈에서는 MooaToon의 구조를 디렉토리 단위로 확인했습니다. 4화부터는 에디터에서 레벨과 머티리얼을 직접 열어 확인합니다. 이번 화의 중심은 Content\MooaToonSamples\Maps\L_LookDev_UnityChan.umap입니다. 이 맵을 열고 UnityChan SD에 적용된 툰 머티리얼 설정을 파츠별로 전부 살펴보며, 중간중간 실습으로 파라미터를 바꿔 각 설정의 효과를 확인합니다.
다루는 범위는 디퓨즈 램프, 헤어 하이라이트 스페큘러, 헤어 그림자(레이트레이싱과 화면 공간)까지입니다. VRM 캐릭터(Alice)와 VRM4U의 임포트 과정은 이번 화에서 다루지 않습니다.
이번 화에 등장하는 파라미터명, 상속 관계, 파라미터 값은 전부 실행 중인 에디터에서 직접 읽은 실측값입니다. 초안 단계에서 추정으로 적어 두었던 항목 중 실측과 달랐던 것은 정정했으므로, 그 차이에도 주목해 주세요.
1. L_LookDev_UnityChan 열기
실습 1. L_LookDev_UnityChan.umap을 엽니다. 레벨에는 UnityChan SD(스켈레탈 메시 액터)와 조명이 배치되어 있고, 아웃라이너에서 BP_MooaLookDevTool_C_0을 선택하면 디테일 패널에서 메인 라이트와 셰이딩 조건을 바꿔가며 명암 변화를 확인할 수 있습니다.

BP_MooaLookDevTool은 단순한 데이터 홀더가 아니라 씬 전체를 통제하는 컨트롤러입니다. 에디터에서 읽은 구조는 다음과 같습니다. MooaToon 개발자인 JasonMa 가 프로젝트 기능 설명을 위해 미리 만들어 놓은 것입니다.

- 컴포넌트: DirectionalLight, PostProcess, SkyLight. 즉 이 액터 자체가 룩뎁 맵의 조명 장비입니다.
- 노출 프로퍼티 68개: 디테일 패널에 그대로 노출되며, 용도별로 나누면 아래 표와 같습니다.

| 그룹 | 프로퍼티 |
| 조명 | Enable Main Directional Light, Main Dir Light Intensity/Color, Enable Other Lights, Enable Sky Light(+Intensity/Color/Cubemap/Cubemap Angle) |
| 포스트 프로세스 | Enable PP Override, Tone Curve Amount, Exposure Compensation, Enable Auto Exposure, Mooa Exposure Scale |
| GI·반사·레이트레이싱 | Enable Global Illumination(+GI Directionality, GI Lumen Normal Flatten, GI Color/Intensity), Enable Reflection, Enable Ray Tracing |
| 툰 글로벌 색 | Base Color, Shadow Color, Specular Color, Reflection, Light Saturation Scale, Shadow Color Intensity(Directional/Point/Spot/Rect 4종), Shadow Bias |
| 라이트 채널 | Light Channel 0/1/2, Cast Shadow to Light Channel 0/1/2 |
| 램프·림라이트·헤어 그림자 | Diffuse Ramp Offset Max Range, Specular Ramp Offset Max Range, Diffuse Ramp Enable Post Ramp Shadow/Material AO, Rimlight(Max Width, Depth Test Threshold/Fade Distance, Intensity, Saturation Scale), Hair Shadow(Intensity, Width, Depth Test Threshold, Fade Distance) |
| 디버그 시각화 | Show Vertex Color/Curvature/Normal/Tangent 계열 10종, Enable Baked Smooth Normal, Enable Outline, Enable Post Process Debug View(+Debug PP Material) |
동작 원리는 함수 목록에서 확인할 수 있습니다. Construction Script가 Init → InitCharacters → CreateDMIs를 실행해 레벨 안 캐릭터들의 머티리얼 인스턴스로부터 DMI(다이낼믹 머티리얼 인스턴스)를 생성하고, SetCharacterMaterialParams/SetMaterialVectorParameter/SetMaterialScalarParameter로 디테일 패널의 값을 DMI에 주입합니다. 조명과 포스트 프로세스는 SetLighting, SetPP가, 콘솔 변수는 SetCVars가 담당합니다. 디테일 패널 값을 바꾸면 Construction Script가 다시 실행되므로, 변경이 씬 전체에 즉시 일괄 반영되는 구조입니다.
이 구조를 택한 이유는 룩뎁 맵의 목적과 직결됩니다. 룩뎁에서는 모든 캐릭터를 같은 조건에서 비교해야 하는데, 캐릭터마다 머티리얼 에셋을 직접 열어 값을 바꾸면 조건 맞추기도 어렵고 원본 에셋도 오염됩니다. 한 액터가 DMI를 만들어 일괄 주입하면 원본 인스턴스는 건드리지 않으면서 조명·포스트 프로세스·콘솔 변수까지 한 곳에서 통제할 수 있습니다.
SetCVars가 주입하는 콘솔 변수는 3-4화 부록의 r.Mooa.* 목록과 정확히 대응합니다. 에디터에서 읽은 매핑과 현재 값은 다음과 같습니다.
| BP_MooaLookDevTool 프로퍼티 | 콘솔 변수 | 현재 값 |
| Diffuse Ramp Offset Max Range | r.Mooa.DiffuseColorRamp.UVOffsetMaxRange | 0.25 |
| Diffuse Ramp Enable Post Ramp Shadow | r.Mooa.DiffuseColorRamp.EnablePostRampShadow | 0 |
| Diffuse Ramp Enable Post Ramp Material AO | r.Mooa.DiffuseColorRamp.EnablePostRampMaterialAO | 0 |
| Specular Ramp Offset Max Range | r.Mooa.SpecularColorRamp.UVOffsetMaxRange | 0.25 |
| Rimlight Max Width | r.Mooa.RimLight.MaxWidth | 1 |
| Rimlight Depth Test Threshold | r.Mooa.RimLight.DepthTestThreshold | 2 |
| Rimlight Depth Test Fade Distance | r.Mooa.RimLight.DepthTestFadeDistance | 10 |
| Hair Shadow Intensity | r.Mooa.HairShadow.Intensity | 1 |
| Hair Shadow Width | r.Mooa.HairShadow.Width | 1 |
| Hair Shadow Depth Test Threshold | r.Mooa.HairShadow.DepthTestThreshold | 0 |
| Hair Shadow Depth Test Fade Distance | r.Mooa.HairShadow.DepthTestFadeDistance | 0 |
즉 3-4화에서 "콘솔 변수로 직접 찔러 보는" 방식과, 이 BP로 디테일 패널에서 조작하는 방식은 같은 통로의 양 끝입니다.
관찰 포인트는 명암 경계입니다. PBR 머티리얼에서는 밝기가 연속적으로 변하지만, 툰 셰이딩에서는 경계를 기준으로 단계가 나뉩니다. 라이트를 회전시키면 이 경계가 메시 표면을 따라 이동합니다.
비교용 에셋도 준비되어 있습니다. Content\\MooaToonSamples\\Materials의 MI_NPR(부모: MI_Toon)과 MI_PBR(부모: M_PBR)은 동일 조명에서 NPR과 PBR의 결과를 나란히 비교하기 위한 인스턴스이고, 레벨에 배치된 BP_ColorCalibrator(크롬볼·그레이볼), BP_MooaMatPreviewMesh, BP_DemoDisplay는 조명과 노출의 기준을 잡는 장치들입니다.
이 맵에서 관찰할 것이 두 가지 더 있습니다. 머리칼의 하이라이트(4절)와, 머리칼이 얼굴에 드리우는 그림자(5절)입니다. 라이트를 회전시킬 때 둘 다 함께 움직이는 것을 먼저 확인해 두세요.
2. 파츠별 머티리얼 구성
UnityChan SD에는 16개의 머티리얼 인스턴스가 파츠 단위로 적용되어 있습니다. Content\\MooaToonSamples\\Characters\\UnityChanSD\\Materials에 있는 15개의 MI_UnityChan_*와 오버레이 아웃라인 1개입니다. 에디터에서 상속 관계를 추적한 결과는 다음과 같습니다.
M_ToonLayer ← 플러그인 마스터 머티리얼 (/MooaToon/Materials)
└── MI_ToonLayer ← 플러그인 공통 인스턴스
└── MI_UnityChan_Base ← 공통 텍스처·램프 인덱스·ID (이하 전부 프로젝트 쪽)
├── MI_UnityChan_Eyes
│ └── MI_UnityChan_Eyes_GIFirst
├── MI_UnityChan_Hair
│ └── MI_UnityChan_Hair_GIFirst
└── MI_UnityChan_BodyBase
├── MI_UnityChan_Body_Blue / _Brown / _Orange
├── MI_UnityChan_Body_GIFirst
└── MI_UnityChan_Body_Skin
├── MI_UnityChan_Face
│ └── MI_UnityChan_Face_GIFirst
└── MI_UnityChan_Body_Skin_GIFirst
별도 계열: MI_UnityChan_Outline ← M_Outline
MI_OverlayOutline_UnityChan (오버레이 아웃라인, 5화에서 상술)
초안에서는 MI_UnityChan_Face가 MI_UnityChan_BodyBase의 직계라고 추정했지만, 실측 결과 Face의 부모는 MI_UnityChan_Body_Skin입니다. 얼굴을 "피부 머티리얼의 특수형"으로 취급하는 설계로, 얼굴에 필요한 스위치(Is Face 등)만 피부 설정 위에 덧씌우는 구조입니다.
최상위인 MI_UnityChan_Base가 쥐고 있는 공통 파라미터는 다음과 같습니다.
- 텍스처: Base Color Map과 Shadow Color Map(둘 다 T_UnityChan_BaseColor), ID Map(R)(T_UityChan_IDMap)
- 스칼라: Diffuse Color Ramp Index 12, Face ID 162, Hair ID 27, Rim Light Intensity 1.0
- 스태틱 스위치: Disable Self Shadow, Disable Cast Shadow On Toon (둘 다 켜짐 — 동일 ID 부위의 셀프 섀도우와, 다른 ID를 가진 툰 머티리얼 위로의 캐스트 섀도우를 끕니다)
계층으로 나눈 이유는 파라미터의 성격 차이에 있습니다. 텍스처·램프·ID 같은 공통값은 상위에서 한 번만 지정하고, 파츠마다 다른 값만 하위에서 오버라이드하는 것입니다. 대표적인 예가 MI_UnityChan_Body_Blue/_Brown/_Orange입니다. 이 세 인스턴스의 로컬 오버라이드는 Shadow Color 하나뿐입니다(Blue는 0.145, 0.335, 0.514). 같은 바디 텍스처를 공유하면서 그림자 색조만 바꿔 의상 색 변형을 만들어내는 구조로, 텍스처를 의상별로 복제하지 않는 선택입니다.
한 가지 더 확인된 사실이 있습니다. 이 인스턴스들의 파라미터는 전부 머티리얼 레이어 소유입니다. MI_UnityChan_Base는 머티리얼 레이어 ML_ToonBaseInput 한 장을 부모로부터 링크된 상태로 물려받고, 각 파라미터 오버라이드는 이 레이어(association=layer)에 기록됩니다. 3-2화에서 본 MF_MooaToonBaseInput이 이 레이어의 내용물입니다.
블렌드 모드는 용도에 따라 네 가지 인스턴스 패밀리로 나뉩니다(전부 /MooaToon/Materials에 존재 확인).
| 블렌드 모드 | 인스턴스 | 용도 |
| Opaque | MI_Toon | 피부·의상 등 불투명 표면의 기본 |
| TwoSide | MI_Toon_TwoSide | 뒷면이 보이는 얇은 파츠 (머리칼 뭉치 등) |
| Translucent | MI_Toon_Translucent | 반투명 파츠 |
| Translucent + TwoSide | MI_Toon_Translucent_TwoSide | 반투명이면서 양면 |
Translucent 계열에는 실제로 추가 장치가 있습니다. 플러그인 머티리얼 함수 MF_TranslucencyShadowToOpacityMask입니다. 노드 33개로 구성된 이 함수는 입력 머티리얼 어트리뷰트에서 그림자 항(ShadowReplace)을 추출해 휘도로 환산한 뒤, 화면 좌표와 Temporal Sample Index, 64×64 노이즈 텍스처를 이용한 디더(DitherTemporalAA)로 불투명 마스크로 변환해 Shadow Opacity 출력으로 납니다. 디퍼드 파이프라인에서 반투명 표면은 그림자를 정상적으로 받지 못하므로, 그림자를 스크린 도어 방식의 불투명도 패턴으로 바꿔 표현하는 것입니다. "Alpha Threshold가 0이어도 잔여 픽셀이 남는 정밀도 문제를 피하기 위해 6으로 나눈다"는 주석이 붙은 나눗셈 노드도 이 처리의 일부입니다. 파라미터 표면의 Shadow Opacity, Apply Opacity to Shadow 스칼라가 이 경로의 조절점입니다.
같은 폴T에는 VRM 전용 인스턴스 4개(MI_Toon_VRM 계열)도 있습니다. VRM4U 임포트 결과물과의 연결용이며, 이번 화의 범위에서는 제외합니다.
3. 디퓨즈 램프
동작 방식
3-2화에서 확인한 ToonShadingModel.ush를 기준으로, 디퓨즈 계산은 다음 순서입니다.
- 라이트 방향과 노멀의 내적(NoL)을 0~1 범위로 재매핑합니다(하프 램버트 방식: 원래 NoL=dot(N,L)은 -1~1인데, 이를 NoL*0.5+0.5처럼 0~1로 올려 음영을 부드럽게 하고 완전한 역광에서도 약간의 빛이 남게 만드는 재매핑입니다).
- 머티리얼별 RampUVOffset을 더한 뒤 글로벌 디퓨즈 램프 아틀라스(CA_GlobalDiffuseColorRampAtlas)를 샘플링합니다.
- 램프의 RGB는 디퓨즈 컬러의 승수, 알파는 디퓨즈 컬러와 그림자 컬러의 블렌딩 팩터로 사용됩니다.
즉 램프 한 줄이 명암의 단계 수와 색을 동시에 결정합니다.
위 세 단계가 코드에서는 한 함수의 한 블록에 해당합니다. 디퓨즈 계산 전체가 ToonBxDF()의 디퓨즈 블록(ToonShadingModel.ush:293-331)을 주목 해 보죠. 램프 아틀라스 샘플링에 도달하는 순서로 따라가 봅니다.
// Diffuse
{
const bool EnablePostRampShadow = View.MooaDiffuseColorRampEnablePostRampShadow;
const bool EnablePostRampMaterialAO = View.MooaDiffuseColorRampEnablePostRampMaterialAO;
// Get Shadow Gradients
float DiffuseColorRampUVOffset = (ToonGBuffer.DiffuseColorRampUVOffset * 2.0f - 1.0f) * View.MooaDiffuseColorRampUVOffsetMaxRange;
float ShadowGradient = saturate(NoL_Half + DiffuseColorRampUVOffset);
float HairLinearShadowGradient = GetScreenSpaceDepthTestHairShadow(MooaToonContext, GBuffer, ToonGBuffer, MooaToonContext.ViewportUV, L_ViewportOffsetBase);
ApplyDistanceFieldFacialShadow(MooaToonContext, GBuffer, ToonGBuffer, AreaLight.DiffuseL, DiffuseColorRampUVOffset,
/*inout*/ ShadowGradient);
// Combine Shadow Gradients and Sample the Diffuse Color Ramp
float OutLinearShadow = min(Shadow.SurfaceShadow, HairLinearShadowGradient);
float CustomLinearShadowGradient = 1.0f;
if (!EnablePostRampMaterialAO) CustomLinearShadowGradient = min(CustomLinearShadowGradient, GBuffer.GBufferAO);
if (!EnablePostRampShadow) CustomLinearShadowGradient = min(CustomLinearShadowGradient, OutLinearShadow);
float DiffuseColorRampU = min(ShadowGradient, LinearToDotProductSpace(CustomLinearShadowGradient));
// RGB: Diffuse Color for multiplication. A: Blending Factor of Diffuse Color and Shadow Color.
half4 DiffuseColorRamp = SampleGlobalRamp(View.MooaGlobalDiffuseColorRampAtlas, DiffuseColorRampU, ToonGBuffer.DiffuseColorRampIndex, View.MooaGlobalDiffuseColorRampAtlasHeight);
float DiffuseShadow = DiffuseColorRamp.a;
if (EnablePostRampMaterialAO) DiffuseShadow = min(DiffuseShadow, GBuffer.GBufferAO);
if (EnablePostRampShadow) DiffuseShadow = min(DiffuseShadow, OutLinearShadow);
// Blend Diffuse Color and Shadow Color
half3 ToonDiffuseColor = GBuffer.DiffuseColor;
half3 ToonShadowColor = ToonGBuffer.MainLightShadowColor * (1.0f - GBuffer.Metallic) * GetShadowColorIntensity(MooaToonContext);
#if USE_DEVELOPMENT_SHADERS
ToonShadowColor *= View.DiffuseOverrideParameter.w + View.DiffuseOverrideParameter.xyz;
#endif
ToonDiffuseColor = lerp(ToonShadowColor, ToonDiffuseColor, DiffuseShadow);
// Output
Lighting.Diffuse = Diffuse_Lambert(ToonDiffuseColor) * DiffuseColorRamp.rgb * LightColorAndAttenuation;
Shadow.SurfaceShadow = OutLinearShadow;
}
- DiffuseColorRampUVOffset = (ToonGBuffer.DiffuseColorRampUVOffset * 2.0f - 1.0f) * View.MooaDiffuseColorRampUVOffsetMaxRange — GBuffer에 8비트 [0, 1]로 저장된 머티리얼 오프셋을 [-1, 1]로 되돌리고, 포스트 프로세스가 정한 최대 범위(SceneView.h:855의 유니폼 멤버)를 곱한 최종 스칼라 오프셋입니다.
- ShadowGradient = saturate(NoL_Half + DiffuseColorRampUVOffset) — 앞서 본 하프 램버트(NoL_Half)에 오프셋을 더한 값이 램프 U 좌표의 기본값입니다. 머티리얼의 Diffuse Color Ramp UV Offset이 파츠별로 명암 경계를 미는 장치라는 것이 이 한 줄에서 확정됩니다.
- 헤어 그림자와 SDF 덮어쓰기(301-304행) — 화면 공간 헤어 그림자(5절)와 SDF 얼굴 그림자(5화)가 이 기본값을 가공하는 자리입니다. 둘 다 꺼져 있으면 ShadowGradient는 앞 줄의 값 그대로입니다.
- OutLinearShadow = min(Shadow.SurfaceShadow, HairLinearShadowGradient) — 엔진 표준 섀도우(섀도우맵·레이트레이싱)와 헤어 그림자의 합성 결과입니다. 이 값은 선형 공간의 그림자이므로 램프에 바로 쓰이지 않고 다음 단계로 넘어갑니다.
- AO·섀도우의 램프 반영 시점(308-310행) — CustomLinearShadowGradient에 GBufferAO와 OutLinearShadow를 min으로 합성하는데, 포스트 프로세스 스위치 MooaDiffuseColorRampEnablePostRampMaterialAO·MooaDiffuseColorRampEnablePostRampShadow(SceneView.h:853-854)가 켜져 있으면 이 합성을 건너뛰고 램프 이후(post-ramp)로 미룹니다. 램프 안에 태우면(pre-ramp) AO·섀도우가 램프 커브의 색 변화를 그대로 타고, 램프 밖에서 합성하면(post-ramp) 커브와 무관하게 어두워지는 차이가 생깁니다.
- DiffuseColorRampU = min(ShadowGradient, LinearToDotProductSpace(CustomLinearShadowGradient)) — 최종 U 좌표입니다. 선형 공간의 AO·섀도우를 LinearToDotProductSpace로 dot 공간에 맞춘 뒤 min을 취합니다(이 변환의 수식 전개는 5화 1.4절).
- SampleGlobalRamp(...) — 아틀라스 샘플링은 이 한 줄이지만, 함수 정의(ToonShadingModel.ush:17-20)를 볼 가치가 있습니다.V 좌표를 (행 번호 + 0.5) / 아틀라스 높이로 잡아 정확히 행의 중앙을 찍는 것이 핵심입니다. 바이리니어 샘플러(GlobalBilinearClampedSampler)라도 V 방향으로는 인접 행의 커브가 섞이지 않고, U 방향으로만 커브가 보간됩니다. 아틀라스 텍스처와 높이는 프로젝트 설정에서 지정한 한 벌이 유니폼으로 내려오며(SceneView.h:1158-1159, 디퓨즈용과 스페큘러용이 각각 한 벌씩), 머티리얼이 고르는 것은 이 중 몇 번째 행인지뿐입니다.
- half4 SampleGlobalRamp(Texture2D Tex, float U, uint index, float rampHeight) { return Texture2DSampleLevel(Tex, GlobalBilinearClampedSampler, float2(U, (float(index) + 0.5f) * rcp(rampHeight)), 0); }
- 램프 결과의 해석(316-326행) — DiffuseShadow = DiffuseColorRamp.a가 디퓨즈 색과 그림자 색의 블렌딩 팩터이고, post-ramp로 미뤄 둔 항목은 여기서 다시 min으로 합성됩니다. lerp(ToonShadowColor, ToonDiffuseColor, DiffuseShadow)에서 알파 1이면 원래 디퓨즈 색, 0이면 그림자 색입니다. 그림자 색은 MainLightShadowColor * (1 - Metallic) * GetShadowColorIntensity(...)이며, 라이트 타입별 강도 분기는 22-32행입니다.
- 최종 출력(329-330행) — Lighting.Diffuse = Diffuse_Lambert(ToonDiffuseColor) * DiffuseColorRamp.rgb * LightColorAndAttenuation. 램버트(1/π)와 라이트 색을 곱해 디퓨즈가 완성되고, Shadow.SurfaceShadow = OutLinearShadow를 돌려주어 스페큘러·림라이트가 같은 그림자를 재사용합니다.
흐름을 한 줄로 정리하면 NoL_Half (+ RampUVOffset) → 헤어·SDF 가공 → min(AO·섀도우의 dot 공간 변환값) → U → 아틀라스(U, Index) 샘플링 → RGB·A 해석 → lerp(그림자 색, 디퓨즈 색) → × Lambert × 라이트 색입니다. 앞의 3단계 요약 각 항목이 코드의 어느 줄에 해당하는지 이제 정확히 대응됩니다.
하나 주의 하자면 머티리얼이 사용할 램프는 텍스처 파라미터로 지정하는 것이 아니라, 스칼라 파라미터 Diffuse Color Ramp Index로 지정합니다. 아틀라스 텍스처는 프로젝트 전체에서 한 장뿐이고(6절의 프로젝트 설정 참조), 각 머티리얼은 그 안의 행 번호만 고릅니다. 에디터에서 아틀라스의 내용을 읽은 결과는 다음과 같습니다(빈 행 제외).
| 인덱스 | 커브 | 형태 |
| 0 | CC_DiffuseColorRamp_000_DefaultPBR | 변환 없음 |
| 1~2 | ..._001_Binarization0, ..._002_Binarization1 | 2단계 |
| 3~8 | ..._003_Soft0 ~ ..._008_Soft4 | 연속 |
| 10~15 | ..._010_3Levels0 ~ ..._015_3Levels5 | 3단계 |
| 17 | ..._017_UnityChan_Skin_GIFirst | UnityChan 전용 |
UnityChan SD의 실제 적용값은 얼굴 10(3Levels0), 머리칼 11(3Levels1), 몸통 12(3Levels2)이고, _GIFirst 변형 중 피부·얼굴은 17번 전용 커브를 사용합니다. 같은 3단계 계열 안에서도 파츠별로 다른 변형을 고른 것입니다. 파일명의 숫자 접두사가 곧 아틀라스 행 번호이므로, 콘텐츠 브라우저에서 새 커브를 만들 때도 이 규칙을 따륩면 됩니다.
아틀라스와 인덱스 구조를 사용하는 이유는 GBuffer 비트 예산입니다. 3-2화에서 확인한 것처럼 툰 파라미터는 인덱스와 오프셋으로 인코딩되어 ToonBufferA에 저장됩니다. 머티리얼마다 텍스처를 통째로 교체하는 대신 아틀라스 안의 행을 가리키는 방식이라 인코딩 비용이 작고, 프로젝트 전체가 같은 램프 세트를 공유하므로 표현의 일관성도 얻습니다.
참고로 셰이더에는 LinearToDotProductSpace()라는 변환 함수가 있습니다. NoL은 비선형(cos 공간)이므로, SDF나 AO 같은 선형 그래디언트와 같은 램프를 공유하려면 같은 공간으로 변환해야 한다는 주석이 붙어 있습니다. 이 단계에서는 램프 샘플링의 일관성을 위한 변환이라는 점만 확인하고 넘어가겠습니다.
실습 2: 램프 교체
— JPLEE (@leegoonz73) September 14, 2026
MI_UnityChan_Body_Blue를 열고 레이어 파라메터를 확인합니다. 이 인스턴스를 고른 이유는 로컬 오버라이드가 Shadow Color 하나뿐인 최소 변형이라, 램프 인덱스 변경의 효과를 다른 변수 없이 관찰할 수 있기 때문입니다. 얼굴은 SDF 섀도우(5화 범위)가 섞이므로 실습 대상으로 적합하지 않습니다.

복제본의 Diffuse Color Ramp Index를 12 → 2(Binarization1) → 6(Soft2) → 10(3Levels0) 순서로 바꿉니다.
첨부 된 켑처 이미지에서 유니티 찬의 파란색 옷 부분을 유심히 봐 주시면 됩니다.



룩뎁 맵에서 결과를 비교합니다. Binarization은 2단계, 3Levels는 3단계, Soft는 연속적인 명암으로 표시되어야 합니다.
4. 헤어 하이라이트: Kajiya-Kay 스페큘러
머리칼의 하이라이트는 일반 스페큘러와 형태가 다릅니다. 표면의 점이 아니라 머리칼의 흐름을 따라 띠처럼 나타납니다. MooaToon은 이 표현에 Kajiya-Kay 모델을 사용합니다.

Kajiya-Kay 모델은 머리카락처럼 가늘고 길게 정렬된 섬유 표면은, 일반적인 Blinn-Phong/ GGX 스페큘러처럼 “점” 형태 하이라이트가 아니라 머리카락의 흐름(접선 방향)을 따라 띠 모양으로 하이라이트가 생깁니다. Kajiya-Kay는 이 특성을 단순화해, 하프 벡터 H와 머리카락의 접선 벡터 T의 관계(예: |dot(T, H)|)로 하이라이트의 위치·폭을 결정하는 헤어 전용 스페큘러 모델입니다. 거칠기(roughness)나 램프 설정을 바꾸면 띠의 폭·경계가 달라지고, 접선 데이터가 올바르면 라이트를 회전할 때 하이라이트가 머리카락 결을 따라 자연스럽게 이동합니다.

엔진 쪽 구현 (3-2화에서 확인한 내용입니다):
- ToonShadingCommon.ush에 MOOA_SHADING_FEATURE_ID_KAJIYA_HAIR_SPECULAR(값 2)라는 전용 Shading Feature ID가 있고, ToonShadingModel.ush에서 이 ID로 분기합니다.
- 분기 안에서는 ToH = 1 - |dot(Tangent, H)|를 계산하고, 그 값을 roughness로 매핑해 스페큘러 램프 좌표로 사용합니다. 머리칼의 접선(Tangent) 방향이 하이라이트의 위치를 결정하는 구조입니다.
위 두 가지를 실제 코드로 확인합니다. Feature ID는 ToonShadingCommon.ush:47-53에 매크로로 정의되어 있습니다.
//-----------------------------------------------------------------------------------------------------------------
// Mooa Shading Feature ID
#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
Require Tangents 주석이 붙은 2번·3번 ID가 접선 데이터를 전제로 하는 기능입니다. Kajiya-Kay는 접선 방향이 하이라이트의 위치를 결정하는 구조이므로, 메시에 탄젠트가 없거나 잘못 계산되어 있으면 띠가 머리칼 결을 따라가지 못합니다.
분기는 GetSpecularColorRampUAndMaxSpecularValue()(ToonShadingModel.ush:173-233)에 있습니다. 반환값은 스페큘러 램프의 U 좌표이고, out 파라미터 MaxSpecularValue는 최종 강도의 스케일입니다. Kajiya 분기를 발췌합니다.
// Empirically fit Anisotropy PBR Specular to SpecularRampU and MaxSpecularValue
float GetSpecularColorRampUAndMaxSpecularValue(FGBufferData GBuffer, BxDFContext Context, float3 N, float3 H,
out float MaxSpecularValue)
{
float OneMinusRoughness = 1.0f - GBuffer.Roughness;
float OneMinusRoughness2 = OneMinusRoughness * OneMinusRoughness;
BRANCH if (GBuffer.MooaToonContext.ToonGBuffer.ShadingFeatureID == MOOA_SHADING_FEATURE_ID_KAJIYA_HAIR_SPECULAR)
{
MaxSpecularValue = PI;
// Kajiya Kay Hair Specular
float ToH = 1.0f - abs(dot(GBuffer.WorldTangent, H));
// As a rule of thumb, the binary specular size is approximated to PBR specular, test in L_LookDev_Lighting
const float SpecularGradientPowMin = 2.0f;
const float SpecularGradientPowMax = 128.0f;
// float SpecularGradientPow = Remap01(
// Pow4(1 - GBuffer.Roughness), //, RoughnessGradientPow),
// SpecularGradientPowMin,
// SpecularGradientPowMax);
float SpecularGradientPow = (OneMinusRoughness2 * OneMinusRoughness2) * (SpecularGradientPowMax - SpecularGradientPowMin) + SpecularGradientPowMin;
return FastPow01(ToH, SpecularGradientPow);
}
- MaxSpecularValue = PI(181행) — PBR 분기가 D·Vis·F 근사로 상한을 계산해 16으로 클램프하는 것(198-207행)과 달리, Kajiya 분기는 상수 π입니다. 함수 헤더 주석의 "Empirically fit"이 말해주듯, 툰 스페큘러 곱셈 체인에 맞춘 경험적 스케일입니다.
- ToH = 1.0f - abs(dot(GBuffer.WorldTangent, H))(184행) — Kajiya-Kay의 핵심입니다. |T·H|는 접선과 하프 벡터가 이루는 각의 |cos|이므로, ToH는 H가 접선과 수직일 때 1, 나란할 때 0이 됩니다. 일반적인 Kajiya-Kay 근사의 sin(T,H) = √(1−(T·H)²) 대신 제곱근이 필요 없는 1−|cos|을 그래디언트로 쓰는 형태입니다. ToH가 1에 가까운 영역, 즉 하프 벡터가 머리칼 결에 수직인 띠가 하이라이트가 됩니다.
- SpecularGradientPow(187-193행) — 띠의 폭을 정하는 지수입니다. (1−R)⁴ × (128−2) + 2이므로 roughness가 0이면 128(아주 좁은 띠), 1이면 2(넓은 띠)입니다. 주석 처리된 Remap01(Pow4(1 - GBuffer.Roughness), ...)을 전개한 식이며, 4제곱 매핑이라 낮은 roughness 구간에서 폭이 급격히 좁아집니다. 아래 표의 MI_UnityChan_Hair 값 0.44를 대입하면 지수는 약 14.4로, 범위 중에서는 넓은 쪽입니다.
- return FastPow01(ToH, SpecularGradientPow)(194행) — 최종 U 좌표입니다. FastPow01(ToonShadingCommon.ush:112-120)은 [0, 1] 입력 전용의 빠른 pow 근사입니다.입력 0과 1에서는 정확히 0과 1을 반환하고, 사이에서는 power가 클수록 가파른 단조 곡선이 됩니다. 정확한 pow의 log2+exp2 조합 대신 exp2 한 번으로 끝내는 형태이고, PBR 계열 NPR 스페큘러 분기(230행)도 같은 함수를 사용합니다.
반환된 U는 호출부(ToonShadingModel.ush:384-390)에서 램프 샘플링과 최종 합성으로 이어집니다.
float MaxSpecularValue;
float SpecularColorRampU = GetSpecularColorRampUAndMaxSpecularValue(GBuffer, Context, N, H,
/*out*/ MaxSpecularValue);
float SpecularColorRampUVOffset = (ToonGBuffer.SpecularColorRampUVOffset * 2.0f - 1.0f) * View.MooaSpecularColorRampUVOffsetMaxRange;
half3 SpecularColor = SampleGlobalRamp(View.MooaGlobalSpecularColorRampAtlas, SpecularColorRampU + SpecularColorRampUVOffset, ToonGBuffer.SpecularColorRampIndex, View.MooaGlobalSpecularColorRampAtlasHeight).rgb;
Lighting.Specular = GBuffer.SpecularColor * ToonGBuffer.SpecularColor * MaxSpecularValue * SpecularColor * LightColorAndAttenuation * Shadow.SurfaceShadow;
디퓨즈 램프(3절)와 같은 SampleGlobalRamp지만 아틀라스·인덱스·오프셋이 스페큘러 전용 한 벌입니다. 차이가 두 가지 있습니다. 오프셋은 디퓨즈가 그래디언트 단계에서 더해져 섀도우와의 min 합성에 참여하는 반면, 스페큘러는 U 계산이 끝난 뒤 샘플링 직전에 더합니다. 그리고 램프 RGB는 GBuffer.SpecularColor × 머티리얼 Specular Color × MaxSpecularValue와 곱해져 최종 스페큘러가 되므로, 머티리얼의 Specular Color 벡터(이 인스턴스는 (1.0, 0.905, 0.676))가 하이라이트의 색조를 직접 결정합니다. 마지막의 Shadow.SurfaceShadow 곱 때문에 그림자 안에서는 하이라이트도 함께 꺼집니다.
정리하면 머티리얼 파라미터와 코드의 대응은 다음과 같습니다. Roughness(0.44)가 SpecularGradientPow를 통해 띠의 폭을, Specular Color Ramp Index(5)가 아틀라스 5행의 커브를 통해 띠의 색을, Specular Color 벡터가 색조를, 오프셋 계열 파라미터가 U 좌표의 이동을 담당합니다.
머티리얼 쪽 지정 방법은 스태틱 스위치입니다. MI_UnityChan_Hair에서 Enable Feature Kajiya Hair Specular가 켜져 있으며, 이것이 Shading Feature ID 2번과 연결됩니다. Feature 스위치는 Enable Feature PBR Specular, Enable Feature Kajiya Hair Specular, Enable Feature Distance Field Facial Shadow 세 가지이며, 동시에 하나만 켤 수 있습니다.
실제 스페큘러 램프는 디퓨즈와 같은 방식, 즉 스칼라 Specular Color Ramp Index로 지정하며 값은 5 — 글로벌 스페큘러 아틀라스의 CC_SpecularColorRamp_005_Soft4입니다.

| 파라미터 | 현재 값 | 역할 |
| Specular Color Ramp Index | 5 | 하이라이트의 색과 명암 변화 패턴을 선택합니다. 전역 Ramp Atlas의 인덱스 5를 사용합니다. Ramp는 하이라이트 가장자리부터 중심까지 어떤 색·밝기로 표현할지 정하는 그라데이션입니다. 숫자가 높다고 더 밝아지는 값은 아닙니다. |
| Specular | 0.5 | 정반사의 세기를 조절합니다. 머리카락에 빛이 반짝이는 정도에 관여하며, 최종 밝기는 Specular Color·텍스처·Ramp·조명과 함께 결정됩니다. |
| Specular Color | RGB ≈ (1, 0.905, 0.676) | 하이라이트 전체에 입히는 색입니다. 현재는 따뜻한 연노랑 계열이며, 아래 Color Map의 RGB와 곱해집니다. |
| Specular Color Map | T_UnityChanSD_Hair_SpecularColorMap | 머리카락 위치별 하이라이트 색과 강도를 지정하는 텍스처입니다. 밝은 부분은 반짝임이 강해지고, 어두운 부분은 억제됩니다. 색이 있는 부분은 그 색으로 하이라이트를 물들입니다. |
| Roughness From Global Mask Map | False | 거칠기를 공용 Global Mask Map에서 가져올지 선택합니다. 현재는 꺼져 있어 별도의 Roughness Map을 사용합니다. |
| Roughness | 약 0.4403 | 하이라이트의 폭과 퍼짐을 조절합니다. 현재 헤어 방식에서는 낮추면 반짝이는 띠가 좁게 모이고, 높이면 넓어집니다. 경계의 선명함은 Ramp 형태의 영향도 받습니다. 현재 Roughness Map은 기본 흰색이므로 이 숫자가 그대로 거칠기에 반영됩니다. |
| Specular Ramp Offset From Global Mask Map | False | 하이라이트 형태를 보정할 마스크를 공용 맵에서 가져올지 선택합니다. 현재는 꺼져 있어 아래의 전용 Offset Map을 사용합니다. |
| Specular Ramp Offset Map | T_UnityChanSD_Hair_MaskMap | 부위별로 하이라이트가 나타나는 범위와 경계를 보정합니다. 현재 상속된 채널 설정은 B 채널입니다. 값이 0.5면 변화가 없고, 0에 가까우면 하이라이트에 들어가기 어려워지며, 1에 가까우면 들어가기 쉬워집니다. |
| Specular Ramp Offset Intensity | 1 | 위 Offset Map의 보정 강도입니다. 0이면 맵의 보정 효과가 없어지고, 현재 1은 맵의 효과를 그대로 적용합니다. |
탄젠트 데이터의 확보가 이 표현의 관건입니다. SD 캐릭터 메시의 자동 생성 탄젠트는 머리칼 흐름과 일치하지 않기 때문에, MooaToon은 아트 단계에서 탄젠트를 직접 굽습니다.
머리칼 하이라이트가 띠 형태로 나타나는 이유는 머리카락이 섬유(가닥) 방향으로 강한 이방성(Anisotropy)을 갖기 때문입니다. Kajiya-Kay는 이 섬유 방향을 접선(=hair tangent)으로 보고, 하프 벡터 H와의 관계(예: |dot(T, H)|)로 하이라이트의 위치·폭을 결정합니다. 그래서 픽셀마다 ‘머리카락이 어느 방향으로 흐르는지’를 알려주는 접선 정보가 필요합니다.
SD 캐릭터의 자동 생성 탄젠트는 머리카락 결을 제대로 따라가지 않는 경우가 많아, 하이라이트가 끊기거나 엉뚱한 방향으로 흐르는 아티팩트가 생길 수 있습니다. 따라서 MooaToon은 Houdini 베이크나 재계산 도구로 헤어 결을 따라 정렬된 탄젠트를 확보해 Kajiya-Kay 계산이 의도대로 동작하도록 합니다.
- Art\\Models의 Houdini HDA mooa_bakeHighlightTangentToUV23이 하이라이트용 탄젠트를 UV2·UV3 채널에 베이크합니다 (3-3화 참조). 머티리얼 쪽의 Use Highlight Tangent baked in Houdini UV23 스위치가 이 데이터를 읽는 경로입니다.
- 엔진 쪽의 MooaToonScripts 플러그인에는 MooaRecomputeMikkTSpaceTangentBinormal 함수가 있어 에디터 안에서 MikkTSpace 탄젠트를 재계산할 수 있습니다 (3-2화 참조).
실습 3. 라이트를 회전시키면서 헤어 하이라이트를 관찰합니다. 하이라이트가 머리칼의 흐름을 따라 띠 형태로 이동하면 Kajiya-Kay 경로가 적용된 것입니다. 이어서 MI_UnityChan_Hair의 Specular(강도), Roughness(띠의 폭), Specular Ramp Offset Intensity(오프셋)를 바꿔 변화를 확인합니다.
5. 헤어 그림자: 레이트레이싱과 화면 공간, 두 가지 경로
머리칼이 얼굴에 드리우는 그림자는 애니메이션풍 얼굴 표현에서 비중이 큽니다. MooaToon은 이 그림자를 위해 두 가지 경로를 제공합니다.
공통 기반. ToonShadingCommon.ush에는 RayTracingShadowFlag로 불리는 플래그 4종이 있습니다. NONE, FACE, FACE_SCREEN_SPACE_HAIR_SHADOW, HAIR로, 픽셀이 얼굴인지 머리칼인지를 GBuffer에 기록하는 장치입니다. 이 플래그가 두 경로 모두의 재료입니다.
플래그는 머티리얼의 Is Face / Is Hair 스위치와 Use Screen Space Depth Test Hair Shadow 스위치, 그리고 ID 값(Face ID 162, Hair ID 27)으로부터 MF_MooaToonBaseInput이 기록합니다. 파츠 구분은 ID Map의 채널 값과 이 스위치들로 이루어집니다.

완성형 카툰렌더링의 면모를 갖추려면 헤어 그림자 처리에 대한 섬세한 개발이 수반되야 합니다.
Disable Self Shadow 와 Disable Cast Shadow On Toon
두 옵션은 레이 트레이싱 그림자 레이가 물체에 닿았을 때, Toon ID를 비교해 해당 교차를 IgnoreHit()으로 무시하는 방식입니다.
특히 옵션은 그림자를 드리우는 물체의 머티리얼에서 읽습니다. 그림자를 받는 표면에서는 비교할 ID를 가져옵니다.
첫 번째, 머티리얼에서 옵션과 ID를 하나의 정수로 묶습니다.
/MooaToon/MaterialFunctions/MF_MooaToonBaseInput의 13 - Ray Tracing Shadow에는 두 Static Switch가 있습니다. 각각 True일 때 1, False일 때 0을 Custom 노드로 전달합니다.
해당 Custom 노드의 실제 코드는 다음과 같습니다.
FMooaCustomPayload MooaCustomPayload = (FMooaCustomPayload)0;
if (DisableSelfShadow)
MooaCustomPayload.SetDisableSelfShadow();
if (DisableCastShadowOnToon)
MooaCustomPayload.SetDisableCastShadowOnToon();
MooaCustomPayload.SetStencil(inStencil);
return MooaCustomPayload.MooaCustomData;
결과는 머티리얼의 Custom Data 0으로 전달됩니다. 정수의 비트 구성은 다음과 같습니다.
| 비트 | 저장 내용 |
| 0 | Disable Self Shadow |
| 1 | Disable Cast Shadow On Toon |
| 2~6 | Toon ID 인 Stencil - 5비트, 0~31 |
이 패킹과 접근 함수는 ToonShadingCommon.ush:454에 정의되어 있습니다. 여기의 Stencil은 MooaToon이 머티리얼에서 만든 ID이며, 액터나 메시의 고유 식별자가 아닙니다.
두 번째, 그림자를 받는 픽셀의 ID를 레이에 실어 보냅니다.
RayTracingOcclusionRGS.usf는 Toon 표면에서 그림자 레이를 생성할 때, GBuffer에 저장된 ID를 가져옵니다.
MooaCustomPayload.SetStencil(ToonGBuffer.Stencil);
이 값과 Toon Shading Model ID를 MooaTraceVisibilityRay()에 넘기고, 레이의 Payload에 저장합니다. 현재 구현은 기존 PackedCustomData 필드를 사용합니다.
관련 코드: RayTracingOcclusionRGS.usf:470, RayTracingCommon.ush:1256.
세 번째, 레이가 닿은 물체의 옵션과 ID를 읽어 교차를 제외합니다.
Any-Hit Shader에서 닿은 지점의 머티리얼을 평가하고 다음 값을 읽습니다.
HitMooaCustomPayload.MooaCustomData =
uint(GetMaterialCustomData0(PixelMaterialInputs));
실제 제외 조건은 다음 코드입니다.
//RayTracingMaterialHitShaders.usf:1031
if (PackedPayload.GetShadingModelID() == SHADINGMODELID_TOON
&& IsHitToonShadingModel)
{
if (HitMooaCustomPayload.IsDisableSelfShadow() &&
OriginMooaCustomPayload.GetStencil() == HitMooaCustomPayload.GetStencil())
IgnoreHit();
if (HitMooaCustomPayload.IsDisableCastShadowOnToon() &&
OriginMooaCustomPayload.GetStencil() != HitMooaCustomPayload.GetStencil())
IgnoreHit();
}
Origin은 그림자를 받는 표면, Hit은 광원으로 가는 길을 가로막는 표면입니다. IgnoreHit()은 이번 교차를 차폐물로 인정하지 않고 레이 탐색을 계속하게 합니다. 뒤에 다른 차폐물이 있으면 그림자는 여전히 생길 수 있습니다.
| 그림자를 드리우는 쪽 설정 | 같은 Toon ID에 드리우는 그림자 | 다른 Toon ID에 드리우는 그림자 |
| 둘 다 False | 허용 | 허용 |
| Self만 True | 제외 | 허용 |
| Cast On Toon만 True | 허용 | 제외 |
| 둘 다 True | 제외 | 제외 |
표는 양쪽 표면이 모두 Toon인 경우이며, ‘허용’은 이 옵션으로 제외하지 않는다는 의미입니다.
네 번째, C++에서도 Toon 머티리얼의 Any-Hit 실행을 지원하도록 수정되어 있습니다.
셰이더 컴파일 조건에 MSM_Toon을 추가해, 불투명 Toon 머티리얼도 Any-Hit Shader를 컴파일하도록 합니다.
//RayTracingMaterialHitShaders.cpp:168
RTNeedsAnyHitShader(Parameters.MaterialParameters.BlendMode)
|| Parameters.MaterialParameters.ShadingModels.HasShadingModel(MSM_Toon)
따라서 같은 캐릭터라도 Toon ID가 다르면 Disable Self Shadow의 대상이 아니고, 서로 다른 캐릭터라도 ID가 같으면 대상이 됩니다. ID는 최종적으로 5비트로 표현되므로 원본 ID 값의 차이가 내부에서도 구별되어야 합니다.
현재 적용되어 있는 MI_UnityChan_Hair는 두 옵션 모두 True를 상속하므로, 이 경로에서 헤어가 Toon 표면에 드리우는 그림자는 같은 ID와 다른 ID 모두 제외됩니다. 다른 물체가 헤어에 드리우는 그림자는 그 다른 물체의 옵션으로 판정합니다. 또한 확인한 구현은 !SUBSTRATE_ENABLED인 레이 트레이싱 그림자 경로에 적용되며, 일반 Shadow Map/VSM이나 Toon Ramp 명암을 끄는 옵션은 아닙니다.
Use Screen Space Depth Test Hair Shadow
MooaToon Sample의 유니티찬을 보면 Face는 스크린 스페이스 헤어 그림자를 받도록, Hair는 헤어로 판별되도록 설정되어 있습니다. 또 Is Face와 Is Hair를 끄면 ID 비교로 판별하는 구조라서, False가 곧 “얼굴/헤어 아님”을 뜻하지는 않습니다. 이 차이와 Hair ID의 역할을 연결해서 설명하겠습니다.
픽셀을 얼굴·헤어로 분류한 뒤, 화면에 보이는 헤어의 깊이를 이용해 얼굴에 그림자를 만드는 흐름으로 연결됩니다. 현재 Face/Hair 설정과 엔진 구현을 모두 확인 해 보면 첫 번째로 Is Face, Is Hair, Face ID, Hair ID가 픽셀의 역할을 판별합니다.
/MooaToon/MaterialFunctions/MF_MooaToonBaseInput 그래프를 의사 코드로 옮기면 다음과 같습니다.
ID = floor(SampledIDMap * 255);
Face판별 = IsFace ? true : (ID == floor(FaceID));
Hair판별 = IsHair ? true : (ID == floor(HairID));
즉, 두 가지 사용 방식이 있습니다.
| 방식 | 설명과 동작 |
| 얼굴·헤어 머티리얼이 분리됨 | 얼굴에는 Is Face=True, 헤어에는 Is Hair=True로 직접 지정 |
| 얼굴·헤어가 하나의 머티리얼을 공유함 | 두 스위치를 False로 두고, ID Map의 픽셀 값과 Face ID·Hair ID를 비교 |
Is Hair=False는 강제로 “헤어 아님”으로 지정하는 뜻이 아닙니다. ID 비교 결과를 사용한다는 뜻입니다. Is Face도 같습니다.
또한 Hair ID는 “이 얼굴에 그림자를 드리울 특정 헤어를 선택하는 번호”가 아닙니다. 현재 머티리얼의 어떤 픽셀을 헤어로 분류할지 정하는 값입니다.
두 번째로 Use Screen Space Depth Test Hair Shadow는 얼굴의 그림자 수신 방식을 지정합니다.
위 판별 결과는 하나의 RayTracingShadowFlag로 변환됩니다.
if (Face판별)
Flag = UseScreenSpaceDepthTestHairShadow ? 2 : 1;
else if (Hair판별)
Flag = 3;
else
Flag = 0;
| Flag | 의미 |
| 0 | 일반부위 |
| 1 | 얼굴 |
| 2 | 스크린 스페이스 헤어 그림자를 받는 얼굴 |
| 3 | 헤어 |
따라서 이 옵션은 얼굴 머티리얼에서 켜야 합니다. 헤어 쪽은 헤어로 분류되어 Flag 3을 출력하면 됩니다. 얼굴과 헤어 판별이 동시에 True이면 그래프상 얼굴이 우선합니다.
이 값은 GBuffer에 저장됩니다. 이름에 RayTracing이 있지만, 이 헤어 그림자는 화면의 GBuffer를 읽어 계산합니다. Flag 정의는 ToonShadingCommon.ush:57에 있습니다.
세 번째로 엔진은 얼굴 픽셀에서 광원 방향으로 화면 좌표를 이동해 헤어를 찾습니다.
ToonShadingModel.ush:125의 GetScreenSpaceDepthTestHairShadow()가 실제 계산 함수입니다.
처리 순서는 다음과 같습니다.
- 현재 픽셀이 Flag 2인 얼굴인지 검사합니다.
- 광원 방향을 화면 방향으로 변환하고, 그림자 폭만큼 UV를 이동합니다.
- 이동한 위치에서 GBuffer를 한 번 읽습니다.
- 그 위치가 Toon 머티리얼이며 Flag 3인 헤어인지 확인합니다.
- 헤어가 얼굴보다 카메라에 가까우면 깊이 차이에 따라 얼굴을 어둡게 합니다.
핵심 계산은 다음과 같습니다.
DepthFade = saturate(
(FaceDepth - HairDepth - DepthTestThreshold)
/ max(1e-5, DepthTestFadeDistance)
);
HairShadow = 1 - saturate(HairShadowIntensity * DepthFade);
HairShadow는 1이면 밝음, 0이면 완전한 그림자입니다. 폭은 r.Mooa.HairShadow.Width, 강도는 .Intensity, 깊이 판정은 DepthTestThreshold와 DepthTestFadeDistance가 제어합니다. 광원 종류별 활성화 설정도 통과해야 합니다.
이 단계에서는 Face ID와 Hair ID의 숫자를 서로 비교하지 않습니다. 이미 만들어진 얼굴·헤어 Flag만 검사합니다. 따라서 코드상 같은 캐릭터 소속인지도 검사하지 않으며, 화면 밖이나 다른 표면에 가려져 GBuffer에 없는 헤어는 직접 참조할 수 없습니다.
현재 UnityChan 설정은 다음 흐름입니다.
| 설정 | MI_UnityChan_Face | MI_UnityChan_Hair |
| Is Face | True | False → ID 비교 |
| Is Hair | False → ID 비교 | True |
| Use Screen Space Depth Test Hair Shadow | True | 헤어로 판별되면 사용하지 않음 |
| Hair ID | 27 | 27, Base에서 상속 |
| 결과 | Flag 2 | 얼굴 ID로도 판별되지 않는 헤어 픽셀은 Flag 3 |
현재 Face는 Is Face=True라서 전체가 얼굴로 분류됩니다. Face의 Hair ID=27을 바꿔도, 이 설정에서는 그림자를 드리울 헤어가 바뀌지 않습니다. Hair 머티리얼 역시 Is Hair=True로 직접 지정되어 있어 헤어 판별을 위해 Hair ID 일치를 요구하지 않습니다.
마지막으로 이 그림자는 앞서 설명한 Disable Self Shadow·Disable Cast Shadow On Toon과 별도 경로입니다. 엔진은 두 그림자 결과를 다음처럼 합칩니다.
//ToonShadingModel.ush:301
OutLinearShadow = min(Shadow.SurfaceShadow, HairLinearShadowGradient);
그래서 레이 트레이싱에서 헤어의 투영 그림자를 제외하면서도, 얼굴에는 이 스크린 스페이스 헤어 그림자를 표시할 수 있습니다.
6. 연결 추적: 인스턴스의 값이 엔진에 도달하는 경로
3-4화의 "거꾸로 읽기"를 이번에는 정방향으로 실행합니다. 인스턴스에서 지정한 값이 픽셀 색이 되기까지의 경로입니다.
- MI_UnityChan_* (인스턴스): 램프 인덱스, 색상, 스위치, ID 등의 파라미터 값을 보유합니다.
- M_ToonLayer (부모 마스터 머티리얼): 머티리얼 레이어 ML_ToonBaseInput을 포함하며, 모든 인스턴스는 이 레이어를 부모 링크 상태로 물려받습니다.
- MF_MooaToonBaseInput / MF_MooaEncodeAttributes (머티리얼 함수): 파라미터를 엔진이 정한 규격으로 인코딩합니다. 이번 화에서 본 스위치 30종의 정의도 전부 MF_MooaToonBaseInput에 있습니다.
- MP_MooaEncodedAttribute0~4 (엔진 머티리얼 입력 핀): MSM_Toon 셰이딩 모델일 때만 컴파일되는 전용 입력입니다.
- ToonBufferA (GBuffer 슬롯): 베이스 패스에서 디코드되어 저장됩니다. 20개의 float이 5개의 float4로 압축되며, 여기에 ShadingFeatureID와 RayTracingShadowFlag도 포함됩니다.
- ToonBxDF() (ToonShadingModel.ush): 디퍼드 라이팅에서 램프 샘플링, 스페큘러, 림라이트, 헤어 그림자를 최종 계산합니다.
4절과 5절에서 본 헤어 관련 설정도 모두 이 경로를 지납니다. Kajiya-Kay이냐 PBR 스페큘러냐의 분기, 화면 공간 헤어 그림자의 적용 여부가 전부 인코딩된 플래그로 전달되는 구조입니다.
이 구조에서 프로젝트 쪽 작업은 1~3단계 안에서 끝나고, 4~6단계는 엔진이 미리 만들어 둔 고정 경로입니다. 무언가를 수정할 때 어느 단계를 건드리는 작업인지 판단하는 것이 3-4화 지도의 용도였습니다.
전제 조건으로서의 프로젝트 설정도 확정했습니다. 프로젝트 설정 → MooaToon 페이지에는 프로퍼티가 두 개뿐입니다.
- GlobalDiffuseColorRampAtlas = /MooaToon/Assets/DiffuseColorRamps/CA_GlobalDiffuseColorRampAtlas
- GlobalSpecularColorRampAtlas = /MooaToon/Assets/SpecularColorRamps/CA_GlobalSpecularColorRampAtlas
이 프로젝트의 DefaultEngine.ini에는 이 두 키에 대한 오버라이드가 없으므로, 샘플은 C++ 클래스(UMooaToonSettings, Runtime/Engine)에 적힌 기본값을 그대로 사용합니다. 3-1화에서 "설정 파일에 직접 적힌 것은 플러그인 활성화 두 줄뿐"이라고 했던 분담이 여기서도 반복됩니다 — 아틀라스 지정은 ini가 아니라 설정 페이지의 기본값에 있습니다. 나머지 필수 설정(r.GBufferFormat=1 고정밀 GBuffer, r.CustomDepth=3 커스텀 뎁스+스텐실, r.RayTracing.Shadows=True)은 3-3화에서 확인한 대로 ini에 있습니다.
7. 텍스처: 역할과 채널
지금까지 파츠별 인스턴스와 파라미터를 살펴 보았습니다. 이번 절에서는 그 파라미터들에 실제로 배정되는 텍스처 9장을 전부 열어, 각 텍스처의 역할과 채널 구성, 그리고 왜 이런 형식으로 작성되었는지를 정리합니다. Content\MooaToonSamples\Characters\UnityChanSD\Textures의 9장이 대상이며, 속성은 전부 에디터에서 읽은 실측값입니다.
7.1 텍스처 목록과 공통 속성
| 텍스처 | 크기 | sRGB | 압축 | 소스 포맷 | 밉 |
| T_UnityChan_BaseColor | 4096² | 켜짐 | Default | BGRA8 | 없음(NoMipmaps) |
| T_UityChan_IDMap | 4096² | 꺼짐 | Default | BGRA8 | 13 |
| utc_nomal | 4096² | 꺼짐 | Normalmap | BGRA8 | 13 |
| T_UnityChanSD_Body_ShadowMap | 4096² | 꺼짐 | Masks | RGBA16 | 13 |
| T_UnityChanSD_Skin_ShadowMap | 4096² | 꺼짐 | Grayscale | RGBA16 | 13 |
| T_UnityChanSD_Hair_SpecularColorMap | 4096² | 꺼짐 | Masks | RGBA16 | 13 |
| T_UnityChanSD_Hair_MaskMap | 4096² | 꺼짐 | VectorDisplacementmap | RGBA16 | 13 |
| shadow_threshold_map | 4096² | 꺼짐 | HalfFloat | G16 | 없음(NoMipmaps) |
| utc_spec | 32² | 켜짐 | Default | BGRA8 | 1 |
이 표에서 먼저 읽어야 할 것은 sRGB 열입니다. sRGB가 켜진 텍스처는 T_UnityChan_BaseColor와 utc_spec 두 장뿐입니다. 색을 표현하는 텍스처만 sRGB이고, 나머지는 전부 리니어 데이터 텍스처입니다. MooaToon 공식 문서도 "색이 아닌 모든 텍스처는 sRGB를 꺼야 올바른 값을 샘플링할 수 있다"고 지시합니다. MooaToon 문서: Controlling the Shape of Shadows
압축 열은 채널의 용도를 알려주는 힌트입니다. Masks 압축은 채널별로 독립된 값을 보존하는 방식으로, 여러 데이터를 채널에 나눠 담는 마스크 텍스처에 사용합니다. Grayscale은 단일 채널만 남기는 방식이므로, 그 텍스처가 단일 용도라는 뜻입니다. Normalmap은 RG 두 채널에 X·Y를 저장하고 Z를 셰이더에서 복원하는 전용 포맷입니다.
밉 열에서 두 장의 NoMipmaps도 의도된 설정입니다. shadow_threshold_map은 5화 1절의 SDF 얼굴 그림자에 쓰이는 텍스처인데, 머티리얼 함수의 설명문이 "압축 형식은 반드시 Half Float (R16F)여야 하며 sRGB를 비활성화해야 합니다"라고 요구하고, 공식 튜토리얼은 여기에 더해 Mip Gen을 No Mipmaps로 설정하라고 지시합니다. 임계값 비교에 쓰이는 텍스처는 밉 보간이 섞이면 경계가 오염되기 때문입니다.
마지막으로 utc_spec(32×32)은 16개 인스턴스 중 어디에도 배정되어 있지 않습니다. UnityChan 원본 에셋에서 온 잔여 텍스처로 추정되며, MooaToon 머티리얼 시스템에서는 사용하지 않습니다.
7.2 전용 텍스처와 Global Mask Map: 채널 선택 아키텍처
텍스처 파라미터를 이해하는 열쇠는 MooaToon의 이중 경로 설계입니다. MI_UnityChan_Base의 파라미터 표면을 전부 읽은 결과, 텍스처 파라미터는 15개입니다.
| 구분 | 파라미터 |
| 색 | Base Color Map, Shadow Color Map, Emissive Map, Specular Color Map |
| 데이터(전용 슬롯) | Normal Map, Opacity Map, ID Map(R), AO Map, Roughness Map, Metallic Map, Specular Ramp Offset Map, Diffuse Ramp Offset Map, Inner Outline Map, Distance Field Facial Shadow Map |
| 데이터(통합 슬롯) | Global Mask Map |
포인트는 AO, Roughness, Metallic, Specular Ramp Offset, Diffuse Ramp Offset, ID, Inner Outline, Rim Light Width 같은 데이터 항목마다 두 가지 공급 경로가 있다는 것입니다.
- 전용 텍스처 — 해당 슬롯에 텍스처를 직접 배정합니다.
- Global Mask Map의 한 채널 — X From Global Mask Map 스위치를 켜고, 벡터 파라미터 X Channel로 채널을 고릅니다(R=(1,0,0,0) 같은 원-핫 선택). 별도 스칼라로 Global Mask Map UV Channel/Scale/Offset도 조정할 수 있습니다.
이 구조의 목적은 텍스처 수와 샘플러 수의 절감입니다. 머티리얼 함수의 설명문은 "일부 플랫폼은 샘플러 수 제한이 있으므로(예: DX11은 16개) 모든 텍스처 샘플링은 기본적으로 공유 Bilinear 샘플러를 사용한다"고 적고 있고, 공식 5.4 마이그레이션 문서는 "전용 텍스처를 쓰거나 Global Mask Map의 한 채널을 골라 텍스처 수를 줄일 수 있다"고 이 설계를 소개합니다. MooaToon 문서: 5.4 마이그레이션
텍스처를 아예 쓰지 않는 세 번째 경로도 있습니다. Use Vertex Color Instead Mask Map 스위치는 마스크 맵 대신 버텍스 컬러를 읽는데, 설명문은 "대역폭 비용을 줄이고 클로즈업 렌더링 정밀도를 높일 수 있지만 모델 제작 난이도가 높아진다"고 적혀 있습니다. 같은 계열로 Rim Light Width From Vertex Color, ID From Vertex Color 스위치가 있습니다.
UnityChan SD가 각 항목에 대해 어떤 경로를 택했는지는 7.3절의 배정에서 확인할 수 있습니다. 요약하면 AO는 Global Mask Map 경로, 헤어의 스페큘러 관련 데이터는 전용 텍스처 경로, ID는 ID Map 경로입니다.
7.3 텍스처별 역할과 채널
T_UnityChan_BaseColor — Base Color Map과 Shadow Color Map 두 슬롯에 같은 텍스처가 배정됩니다(MI_UnityChan_Base, MI_UnityChan_Outline, MI_UnityChan_BodyBase). RGB는 sRGB 알베도이고, 알파는 Opacity From Base Color Map 스위치를 켰을 때 불투명도로 읽힙니다. 그림자 색 슬롯에까지 같은 알베도를 배정한 이유는 그림자 색을 별도 텍스처로 그리지 않고 "알베도 × Shadow Color 벡터"로 만들기 위해서입니다. 4화 2절에서 MI_UnityChan_Body_Blue/_Brown/_Orange가 Shadow Color 벡터 하나만 바꿔 의상 색 변형을 만드는 것도 이 분업이 있어서 가능합니다.

T_UityChan_IDMap — ID Map(R) 슬롯에 배정됩니다(MI_UnityChan_Base, MI_UnityChan_Outline). 파라미터 이름이 알려주는 대로 R 채널에 부위 ID를 저장합니다. 설명문에 따르면 ID Map은 "캐릭터의 부위마다 서로 다른 색을 채워 텍스처 제작이나 후처리 시 마스크 추출을 쉽게 하는 맵"이며, 서로 다른 ID는 최소 0.031(8/255)의 값 차이가 필요하고 컬러 스페이스는 반드시 리니어입니다. 이 샘플에서는 얼굴 ID 162, 머리칼 ID 27이 쓰이며(4화 2절의 Face ID/Hair ID 스칼라), 이 값들이 R 채널의 텍셀 값과 비교되어 픽셀의 부위를 판정합니다. 판정 결과는 4화 5절의 RayTracingShadowFlag와 셀프 섀도 제어로 이어집니다. G/B/A 채널은 이 샘플에서 읽는 파라미터가 없습니다. ID Map을 쓰지 않는 대안으로는 파츠별 머티리얼 분리 + ID Offset 스칼라가 있는데, MI_UnityChan_Face의 ID Offset이 0인 것은 이 캐릭터가 ID Map 경로를 쓰기 때문입니다.

utc_nomal — Normal Map 슬롯에 배정됩니다(MI_UnityChan_BodyBase). 표준적인 탄젠트 공간 노멀 맵이고 Normalmap 전용 압축입니다. 주목할 점은 MI_UnityChan_BodyBase의 Normal Scale이 0.1로 매우 낮다는 것입니다. 툰 셰이딩에서는 노멀 디테일이 명암 경계를 어지럽히므로, 노멀 맵의 효과를 의도적으로 약하게 눌러 평면적인 인상을 유지합니다.

T_UnityChanSD_Body_ShadowMap / T_UnityChanSD_Skin_ShadowMap — Global Mask Map 슬롯에 각각 배정됩니다(MI_UnityChan_BodyBase, MI_UnityChan_Body_Skin). 두 인스턴스 모두 AO Channel을 (1,0,0,0), 즉 R 채널로 오버라이드하고 있으므로, 이 텍스처들의 R 채널이 AO(앰비언트 오클루전)로 읽히는 구성입니다. 피부용은 AO Intensity 1.0 오버라이드가 함께 있습니다. 용도가 R 하나뿐인 피부용이 Grayscale 압축이고, 향후 다른 채널을 넣을 여지가 있는 바디용이 Masks 압축인 것도 이 해석과 일치합니다. G/B/A 채널은 현재 구성에서 읽는 파라미터가 없습니다. AO를 텍스처로 굽는 이유는 Guilty Gear의 ILM 텍스처와 같은 발상으로, 목 아래나 옷 주름처럼 그림자가 항상 진해야 할 부위를 아트 단계에서 통제하기 위해서입니다. 공식 튜토리얼은 Substance 3D Painter로 이 마스크를 직접 그리는 과정을 안내합니다. MooaToon 문서: Controlling the Shape of Shadows 이 AO는 5화에서 본 대로 CustomLinearShadowGradient에 min으로 합성되어 램프 좌표를 누르는 방식으로 동작합니다.


T_UnityChanSD_Hair_SpecularColorMap — Specular Color Map 슬롯에 배정됩니다(MI_UnityChan_Hair). 4화 4절의 Kajiya-Kay 하이라이트의 색을 텍셀별로 지정하는 맵입니다. 이름은 컬러 맵이지만 sRGB가 꺼진 리니어 데이터(Masks 압축)로 취급됩니다.

T_UnityChanSD_Hair_MaskMap — Specular Ramp Offset Map 슬롯에 배정됩니다(MI_UnityChan_Hair). 4화 4절의 스페큘러 램프 좌표를 텍셀별로 밀어, 머리칼 하이라이트 띠의 위치를 조정하는 데이터입니다. 헤어는 Specular Ramp Offset From Global Mask Map 스위치가 꺼져 있으므로 Global Mask Map이 아니라 이 전용 텍스처를 읽고, Specular Ramp Offset Intensity 1.0이 강도입니다. 헤어 관련 데이터를 전용 텍스처로 분리한 이유는 하이라이트가 캐릭터의 인상을 좌우하는 만큼 전용 해상도와 제작 워크플로를 확보하기 위한 선택으로 읽힙니다.

shadow_threshold_map — Distance Field Facial Shadow Map 슬롯에 배정됩니다(MI_UnityChan_Face). 5화 1절에서 다룬 SDF 얼굴 그림자의 거리장으로, 단일 채널 G16(16비트 그레이스케일) Half Float 형식입니다. 밉 없음, sRGB 없음은 7.1절에서 본 요구 사양과 정확히 일치합니다. 좌·우 그림자의 채널 선택은 Distance Field Facial Shadow Map Channel 벡터가 담당하며, 이 텍스처는 단일 채널이므로 좌·우가 같은 거리장을 공유하는 구성입니다. 생성에는 외부 도구를 사용합니다.

GitHub - akasaki1211/sdf_shadow_threshold_map: Create a Shadow Threshold Map by interpolating SDF
Create a Shadow Threshold Map by interpolating SDF - akasaki1211/sdf_shadow_threshold_map
github.com
7.4 새 캐릭터에 적용할 때
UnityChan SD의 구성을 기준으로, 새 캐릭터를 MooaToon에 올릴 때 필요한 텍스처를 정리하면 다음과 같습니다.
- 필수: Base Color(sRGB, 알파에 불투명도 선택 가능), ID Map(R 채널, 리니어, ID 간 8/255 이상 간격, 저해상도 가능).
- 권장: Global Mask Map 한 장에 AO를 R 채널로(리니어, Masks 압축). 다른 마스크가 필요하면 같은 텍스처의 G/B/A에 추가하거나 전용 슬롯의 텍스처를 배정합니다.
- 파츠별 선택: 헤어 하이라이트를 텍셀 단위로 제어하려면 Specular Color Map과 Specular Ramp Offset Map, 얼굴에 SDF 그림자를 쓰려면 Half Float 단일 채널의 shadow threshold map과 Face Forward 베이크(5화 1.3절)가 필요합니다.
- 노멀 맵: 필요하면 배정하되 Normal Scale을 낮게 시작합니다.
어느 경로를 쓸지는 머티리얼 인스턴스의 X From Global Mask Map 스위치와 X Channel 벡터로 결정하고, 텍스처 배정은 최상위 Base 인스턴스에서 한 번만 하면 계층 아래 파츠들이 상속받는 구조라는 것이 4화 2절과 이번 절의 결론입니다.
정리하며
이번 화에서는 L_LookDev_UnityChan을 열고 UnityChan SD의 툰 머티리얼 설정을 파츠별로 확인했습니다. 확인한 내용을 세 줄로 정리하면 다음과 같습니다.
- 파츠별 16개 인스턴스는 M_ToonLayer → MI_ToonLayer → MI_UnityChan_Base 계층 아래에서 공통값은 상속하고 차이만 오버라이드합니다.
- 디퓨즈와 스페큘러 램프는 텍스처가 아니라 글로벌 아틀라스의 행 인덱스로 지정하며, 헤어 하이라이트는 Kajiya-Kay Feature 스위치와 스페큘러 램프 인덱스 5번으로 구성되어 있습니다.
- 헤어 그림자는 RT와 화면 공간 두 경로가 있고, 이 샘플은 얼굴에 드리우는 머리칼 그림자에 화면 공간 경로를 선택했습니다.
다음 5화에서는 남은 세 가지 — SDF 기반 얼굴 그림자(Enable Feature Distance Field Facial Shadow의 자리는 이번에 확인했습니다), 림라이트, 아웃라인 — 를 같은 순서, 즉 관찰 → 실습 → 경로 추적으로 다룹니다.
'UNREAL ENGINE' 카테고리의 다른 글
| [MooaToon] 3-4화.기능별 지도와 커스터마이징 원칙 (0) | 2026.09.12 |
|---|---|
| [MooaToon] 3-3화 . 프로젝트 쪽 해부: 머티리얼 시스템 (2) | 2026.09.10 |
| [MooaToon] 3-2화 엔진 쪽 해부: MooaToonScripts 플러그인 (0) | 2026.09.05 |
| [MooaToon] 3-1화 . 큰 그림: 두 개의 세계 (1) | 2026.09.04 |
| [MooaToon] 2화 . 설치와 빌드: 소스코드에서 나만의 툰 엔진 만들기 (0) | 2026.09.03 |