예제 분석: 호요 툰 (hoyo_toon)

원신·스타레일·젠존제 공식 모델을 위한 hoyo_toon 팩의 원리와 SDF 얼굴 그림자 기법, 실제 HLSL 코드를 분석합니다.

호요 툰(hoyo_toon) 팩 소개

hoyo_toon은 MMDX12에 기본 탑재된 공식 교육용 셰이더 팩입니다. 원신(Genshin Impact), 붕괴: 스타레일(Honkai: Star Rail), 젠레스 존 제로(Zenless Zone Zero)의 공식 배포 MMD 모델에 최적화된 애니메이션 셀 셰이딩을 제공합니다.

셰이더 팩 개발자가 알아야 할 3대 핵심 기법이 모두 담겨 있습니다:

  1. PackParam을 통한 사용자 조절 슬라이더 연결
  2. s.materialClass에 따른 부위별 분기 처리 (얼굴, 눈, 피부, 머리카락, 옷)
  3. headPos, headRight, headForward를 활용한 머리 본 기준 조명 연산

왜 노멀 기반 셰이딩을 쓰지 않는가?

일반적인 3D 렌더러는 표면의 노멀(법선) 벡터와 빛의 각도($N \cdot L$)를 바탕으로 음영을 계산합니다. 그러나 정밀하게 다듬어진 2D 원화 느낌의 애니메이션 모델에 이를 적용하면 다음과 같은 문제가 발생합니다:

  • 코 밑이나 눈두덩이에 지저분한 삼각형 그림자가 생김
  • 뺨의 미세한 굴곡 때문에 얼굴이 울퉁불퉁한 조각상처럼 왜곡됨
  • 앰비언트 오클루전(AO)이 얼굴에 칙칙한 얼룩을 만듦

실제 원작 게임에서는 아티스트가 직접 그린 2D SDF(부호 거리 필드) 텍스처를 사용하여 머리의 회전 각도에 따라 그림자가 얼굴 위를 깨끗하게 쓸고 지나가도록 만듭니다. 하지만 공식 배포된 MMD용 PMX 모델에는 게임 내부의 전용 SDF 텍스처가 포함되어 있지 않습니다.

hoyo_toon은 이를 해결하기 위해 머리 본의 회전 틀을 이용한 기하학적 SDF 근사(Geometric SDF Stand-in) 기법을 사용합니다.

핵심 기법: 머리 본 기준 얼굴 그림자 (FaceLight)

빛 벡터를 머리 본의 수평 평면에 투영하여 광원의 방위각(theta)을 구하고, 얼굴 좌우 폭(side)에 따라 그림자 경계선이 부드럽게 넘어가도록 만듭니다:

float FaceLight(PackSurface s) {
    if (!s.hasHead) return 1.0;

    // 머리 본의 우측(+X)과 전방(-Z)에 빛을 투영
    float lx = dot(s.L, s.headRight);
    float lz = dot(s.L, s.headForward);
    float theta = atan2(abs(lx), lz); // 0 (정면) .. pi (후방)

    // 광원 각도에 따른 그림자 경계선 위치
    float boundary = lerp(-1.15, 1.15, theta / 3.14159265);

    // 픽셀이 얼굴 중심에서 좌우로 얼마나 떨어져 있는지 (-1 .. 1)
    float side = dot(s.worldPos - s.headPos, s.headRight) / (P_FACE_WIDTH * s.headScale);
    side = clamp(side, -1.0, 1.0) * (lx >= 0.0 ? 1.0 : -1.0);

    return smoothstep(boundary - P_FACE_SOFTNESS, boundary + P_FACE_SOFTNESS, side);
}

이 연산에는 픽셀 노멀이 일절 관여하지 않습니다. 빛이 정면에서 비추면 얼굴 전체가 깨끗하게 밝아지고, 옆에서 비추면 정확히 절반만 가려지며, 뒤에서 비추면 자연스럽게 전체가 어두워집니다.

부위별 재질 분기 처리

surface.hlsl의 PackShade 내부에서는 s.materialClass에 따라 라이팅 방식을 분기합니다:

PackResult r;
r.alpha = s.alpha;
r.reflectivity = 0.0;
r.noAo = false;

float term;      // 0: 그림자 .. 1: 밝은 영역
float flatFill;  // 1: 점광원이 노멀을 무시하고 평평하게 비춤
float warmth = 0.0;

if (s.materialClass == PACK_FACE || s.materialClass == PACK_EYE) {
    term = FaceLight(s) * lerp(1.0, s.shadow, P_FACE_CAST);
    flatFill = 1.0;
    warmth = 0.15;
    r.noAo = true; // 중요: SSAO/RTAO를 꺼서 얼굴의 깨끗함을 유지

    if (s.materialClass == PACK_EYE) {
        term = lerp(term, 1.0, 0.6); // 눈동자는 그림자 속에서도 또렷하게 유지
    }
} else {
    // 몸체와 옷: 하프 램버트(Half-Lambert) 기반 2단 램프
    float halfLambert = dot(s.N, s.L) * 0.5 + 0.5;
    float soft = P_RAMP_SOFTNESS * (s.materialClass == PACK_SKIN ? 2.0 : 1.0);
    term = smoothstep(P_RAMP_THRESHOLD - soft, P_RAMP_THRESHOLD + soft, halfLambert) * s.shadow;
    flatFill = 0.0;
    if (s.materialClass == PACK_SKIN) warmth = 0.2;
}

MMD 툰 텍스처와 그림자 틴팅

그림자 색상을 단순 검은색으로 처리하면 탁해 보입니다. hoyo_toon은 모델 원본의 MMD 툰 텍스처 어두운 끝(PackSampleToon(1.0))을 추출하고, 따뜻한 장밋빛 틴트(P_SHADOW_WARMTH)를 혼합하여 생기 있는 그림자를 만듭니다:

float3 ShadowColour(float3 lit, float extraWarmth) {
    float3 toonDark = PackSampleToon(1.0);
    float3 warm = lerp(float3(1, 1, 1), float3(1.0, 0.82, 0.84), saturate(P_SHADOW_WARMTH + extraWarmth));
    return lit * lerp(float3(1, 1, 1), toonDark * warm, P_SHADOW_DARKNESS);
}

계단식 하이라이트와 림 라이트

  • 머리카락 하이라이트 (엔젤링): 스펙큘러 부드러운 그라데이션을 smoothstep(0.45, 0.55, spec)으로 계단화하여 만화풍의 또렷한 띠로 표현합니다.
  • 실루엣 림 라이트: 프레넬 반사를 이용해 모델 외곽선 부근에 얇고 선명한 테두리 빛을 넣되, 얼굴 부위(PACK_FACE, PACK_EYE)에는 적용하지 않아 정갈한 인상을 유지합니다.