← All Posts

ToonLit Custom Shading Model — UE5.7 Substrate Material

Deep Dive
ToonLit Custom Shading Model — UE5.7 Substrate Material

프로젝트 개요

Content imagemeants.dev

이 프로젝트는 UE5.7 소스 빌드의 Substrate 라이팅 파이프라인 위에 카툰 렌더링 전용 셰이딩 모델(MSM_ToonLit)을 설계·구현하는 개인 R&D입니다. C++ enum 등록부터 셰이더 평가까지, Substrate가 픽셀 하나를 처리하는 전체 경로에 ToonLit 분기를 삽입하여 라이팅 평가 자체를 제어합니다.

사용 기술

Unreal C++ HLSL Unreal Engine 5.7 Claude Code


구현 전 고민

왜 Post Process가 아닌 셰이딩 모델 레벨인가?

카툰 렌더링을 Post Process로 구현하면 구조적 한계가 있습니다.

  • 광원별 독립 제어 불가 — PP는 Deferred 라이팅이 끝난 뒤 실행되므로, 이미 합산된 최종 밝기만 볼 수 있습니다. 셰이딩 모델은 Deferred 라이팅 루프에서 각 광원마다 BSDF 평가가 호출되므로, 광원별 셀 셰이딩 경계를 독립적으로 제어할 수 있는 기반을 확보합니다.
  • GBuffer에 커스텀 파라미터 기록 불가 — PP는 GBuffer를 읽을 수는 있지만 쓸 수 없습니다. ShadowThreshold, Softness 같은 Toon 전용 파라미터를 GBuffer에 기록하고 라이팅 시점에서 사용하려면 셰이딩 모델 레벨의 개입이 필요합니다.

실제로 Guilty Gear Xrd(GDC 2015), Hi-Fi Rush(GDC 2024) 등 상용 타이틀은 Post Process가 아닌 셰이딩 모델 레벨에서 카툰 라이팅을 제어하는 방식을 채택하고 있습니다.

어떤 결과물을 만들 것인가?

어떤 캐릭터 애셋을 가져와도 동작하는 범용 카툰 셰이딩 프레임워크를 목표로 합니다.

R&D를 위해 명일방주 이본 MMD파일을 구해서 처음에는 명일방주 스타일의 렌더링을 재현하고자 했으나, MMD를 세팅한 사람들의 재해석이 들어간 애셋이어서 UV 정보가 완전하지 않았고 명일방주 스타일의 네이밍 컨벤션을 해석하기엔 시간이 촉박했습니다.

이 상황에서 똑같은 결과물을 내려고 하니 R&D가 산으로 가는 느낌이 들었습니다. 그래서 고민 끝에 툰 셰이딩의 원론적인 부분을 따라가는 방향으로 선회하였습니다.

왜 Substrate Pipeline을 거치는 레거시 셰이딩 모델을 제작했는가?

이건 저의 실수에서 시작됐습니다. UE5.7을 R&D를 시작하면서 설치했고, Substrate가 기본으로 활성화되어 있는 줄 모르고 작업을 시작했습니다. Claude Code와 협업하여 진행했는데, Claude Code가 자꾸 Substrate 쪽 코드를 참조하고 수정하려고 하길래 뭔가 싶어서 확인해보니 Substrate가 활성화되어 있었습니다. 머티리얼이 레거시로 되어있어서 활성화가 되어 있을 거라곤 생각을 못했습니다. 그래서 처음으로 되돌아갈까 고민하다가 이왕 이렇게 된 거 Substrate 구조도 공부하자 싶어서 Substrate Pipeline을 거치는 레거시 셰이딩 모델을 제작하게 되었습니다.


설계 전략

SLAB BSDF 마커 방식

Substrate는 모든 셰이딩 모델을 SLAB(Surface Layer Attribute BSDF)이라는 단일 구조체로 통합합니다. 새 BSDF 타입을 추가하려면 엔진 전체에 걸쳐 수백 개의 분기를 건드려야 합니다.

대신 SLAB 내부의 SSS Type 필드(3비트, 0~7)에 빈 슬롯이 있다는 점을 이용했습니다. 0~5번은 기존 SSS 타입이 사용 중이고, 6번이 비어있어서 여기에 SSS_TYPE_TOON=6을 마커로 삽입했습니다. SLAB이 이미 가지고 있는 GBuffer 패킹/언패킹, 타일 분류, Deferred 라이팅 디스패치 인프라를 전부 재사용할 수 있고, ToonLit이 해야 할 일은 마커를 심고 라이팅 평가 시점에서 마커를 읽어 분기하는 것뿐입니다.

Substrate 파이프라인 통합

C++ enum 등록(MSM_ToonLit=13)부터 시작하여, Substrate가 머티리얼 핀 값을 받아 최종 라이팅을 출력하기까지의 전체 경로에 분기를 삽입했습니다.

c++
Material Pins
LegacyConversion    (SLAB 파라미터 매핑 + SSS_TYPE_TOON 마커 삽입)
SubstrateExport    (VGPRs → GBuffer 패킹)
GBuffer          (화면 공간 저장)
SubstrateRead  (GBuffer → BSDF 복원 + 마커 확인)
SubstrateEvaluation  (마커 분기 → Cel-Shading 라이팅)

머티리얼 핀에서 전달되는 Toon 파라미터(ShadowThreshold, ShadowSoftness, Shadow Tint)는 SLAB의 SSSMFP, FuzzColor 등 기존 슬롯에 패킹하여 GBuffer를 경유하고, Deferred 라이팅 시점에서 언패킹하여 Cel-Shading 평가에 사용합니다.

수정 범위: 기존 함수 시그니처는 변경하지 않고, switch/case 추가와 신규 파일 분리 원칙을 유지했습니다. 엔진 업데이트 시 머지 충돌을 최소화하기 위한 설계입니다.

HalfLambert Cel Shading + Shadow Tint

Lambert의 NdotL(노멀과 라이트 방향의 내적)을 0~1로 리매핑하여 그림자 영역을 부드럽게 만드는 HalfLambert를 기반으로 셀 셰이딩을 구현했습니다.

셀 셰이딩의 핵심은 그림자 경계를 어디에서, 얼마나 날카롭게 끊느냐입니다. Shadow Threshold는 경계 위치를, Softness는 경계의 날카로움을 제어합니다. 이 두 파라미터를 머티리얼에서 실시간으로 조절할 수 있어, 파츠별로 다른 셀 셰이딩 느낌을 줄 수 있습니다.

c++
float HalfLambert = dot(N, L) * 0.5 + 0.5;  // NdotL을 [0,1]로 리매핑
float ToonShadow = smoothstep(
    ShadowThreshold - ShadowSoftness,         // 경계 시작점
    ShadowThreshold + ShadowSoftness,         // 경계 끝점
    HalfLambert);                              // Softness가 작을수록 날카로운 경계

그림자 색감은 SubsurfaceColor 핀을 "Shadow Tint"로 재정의하여 사용합니다. 그림자 영역을 단순히 어둡게만 하는 것이 아니라 색조를 입히기 위한 것으로, HSV 인코딩(H+V만 저장, S=0.375 고정)으로 GBuffer 2채널에 압축합니다. S(채도)는 그림자 틴트에서 변동 범위가 좁아 고정해도 시각적 차이가 거의 없어서 고정값을 선택했습니다.

머티리얼에서 1024x32 Ramp LUT 텍스처를 적용하면 행 단위로 다양한 그림자 프리셋을 사용할 수 있습니다.

DiffuseColor/PI 에너지 보존

UE의 모든 셰이딩 모델은 Lambert BRDF의 수학적 정규화로 DiffuseColor / PI를 적용합니다. 커스텀 셰이딩 모델에서 Diffuse를 자체 계산할 경우 이 공통 경로를 타지 않으므로, /PI를 빠뜨리면 PBR 대비 약 3.14배 밝게 렌더링됩니다.

초기 구현에서 ToonLit 머티리얼이 DefaultLit 대비 눈에 띄게 밝았는데, 처음에는 셀 셰이딩 로직의 문제인 줄 알고 한참을 헤맸습니다. 엔진의 기본 라이팅 경로를 추적하다가 모든 BSDF가 공통적으로 /PI를 거치는 것을 확인하고, 커스텀 경로에서 이 정규화가 빠져있다는 것을 알게 되었습니다. 😓

Cast Shadow 비활성화

엔진의 섀도우맵과 셀 셰이딩 그림자가 중복 적용되어 의도한 아트 결과물이 나오지 않아, Cast Shadow를 해제하고 셀 셰이딩 파이프라인을 먼저 안정화하는 방향으로 진행했습니다. Cast Shadow 개선은 추후 작업에서 다룰 예정입니다.


SDF 얼굴 그림자

카툰 렌더링에서 얼굴 그림자는 일반적인 NdotL 라이팅 대신 SDF(Signed Distance Field) 텍스처로 제어합니다. 미술이 그린 키프레임 그림자를 SDF로 베이킹한 텍스처에서, 각 픽셀 값은 "이 픽셀이 그림자에 들어가는 라이트 각도 threshold"를 의미합니다. 라이트 방향과 threshold를 비교하면 노멀 기반 라이팅에서는 불가능한, 미술 의도대로의 얼굴 그림자가 만들어집니다.

인터넷에서 흔히 볼 수 있는 SDF 텍스처는 원신 스타일의 단일 채널 grayscale입니다. 한 채널에 0~180° 범위의 threshold가 인코딩되어 있고, 반대쪽은 UV를 좌우 반전해서 처리합니다. 구현도 단순하고 레퍼런스도 많습니다.

그런데 MMD에 들어있던 명일방주 엔드필드의 SDF 텍스처는 달랐습니다.

SDF텍스쳐meants.dev
SDF텍스쳐
R채널meants.dev
R채널
G채널meants.dev
G채널
B채널meants.dev
B채널
A채널meants.dev
A채널

RGBA 네 채널 모두에 서로 다른 데이터가 들어있었습니다. R과 G는 단순 grayscale이 아니라 서로 보색 관계의 gradient이고, B는 또 다른 분포, A는 거의 불투명하지만 코 주변만 어두운 마스크. 원신식 단일 채널 레퍼런스로는 해석이 안 되는 텍스처였습니다.

1차 시도 — R/G 채널 해석: 실패

처음에는 R/G 채널을 가지고 시도했습니다. SDF 그림자 표현은 자료가 많아서 Claude Code에게 요청해서 빠르게 넘길 생각이였어요. 그런데 오히려 방향을 잡지 못하고 잘못된 결과를 생성해내서 일단 정해둔 데드라인을 넘기지 않도록 B채널로 구현하기로 결정했습니다. 먼저 텍스쳐를 직접 뜯어보고 고민한 다음에 구현 시도를 해야 했는데 안일한 마음을 가지고 추측만으로 제작을 시도했던 것이 실패의 근본 원인이었다고 생각합니다.

2차 시도 — B채널 단독 사용: 동작하지만 아쉬움

Content imagemeants.dev

R/G 해석을 포기하고, B채널만으로 커뮤니티에서 널리 알려진 단일 채널 방식을 적용했습니다. B채널 + UV 플립으로 좌우 미러링하고, threshold = (FdotL + 1.0) * 0.5로 라이트 각도를 threshold로 변환하여 SDF와 비교하는 방식입니다. 연속 회전에서 자연스러운 전환이 가능했고, threshold 공식 덕분에 배면에서 자연스럽게 전체 그림자로 수렴했습니다.

동작은 했지만, 4채널 중 1채널만 쓰고 있다는 게 마음에 걸렸습니다. 물론 너무 계란 렌더링 한 것 마냥 매끈한 표현이 나온 것도 아쉬웠습니다. 나머지 채널에 분명히 의미가 있을 텐데 해석을 못한 채로 넘어가는 느낌이었습니다.

3차 시도 — RGBA 전체 활용: 텍스처 해석 성공

구현 과정을 블로그로 정리하다가 아쉬워서 한 번 더 시도했습니다.

R채널과 G채널을 다시 확인해보니, 이 두 값을 지지고 볶으면 모두가 아는 SDF 그림자 텍스쳐 값이 나올 것 같았습니다.

처음에는 단순하게 접근했습니다. R채널 기준으로 왼쪽에서 오른쪽으로 빛이 이동한다고 가정했을 때, G채널에 Scalar 값을 곱한 값을 R채널과 더해서 음영 움직임을 확인했습니다.

Content imagemeants.dev
weight = -1meants.dev
weight = -1
weight =-0.5meants.dev
weight =-0.5
weight = 0meants.dev
weight = 0
weight = 0.5meants.dev
weight = 0.5
weight = 1meants.dev
weight = 1
weight = 1.5meants.dev
weight = 1.5
weight = 2meants.dev
weight = 2

saturate(R + G * Weight) — Weight 슬라이더를 -1에서 2까지 움직이면서 그림자가 어떻게 변하는지 확인했습니다. weight가 올라갈수록 G가 R을 보강해서 밝아지고, 음수로 내려가면 상쇄해서 어두워집니다. weight=2(정면)에서 끝부분에 음영이 약간 남지만, 인게임에서는 보이지 않습니다. SDF 텍스처가 얼굴 UV 아일랜드보다 여유를 두고 제작되어 잔여 음영이 UV 바깥 영역에 있는 것으로 보입니다. 생각한 대로 작동해서, 이 Weight를 라이트 방향에 연동했습니다. 오른쪽에서 왼쪽으로 빛이 이동하는 것도 고려해야 했기에 UV 값 중 U값을 뒤집어서 반전시킨 후 합쳤습니다.

c++
float weight = FdotL * 1.5 + 0.5;
float Lit2 = saturate(R + G * weight);

weight = FdotL * 1.5 + 0.5은 캐릭터 정면과 라이트 방향의 내적(FdotL) 범위 [-1, +1]을 슬라이더에서 확인한 weight 범위 [-1, +2]로 리매핑하는 공식입니다. 정면에서 빛을 받으면 weight=2.0으로 밝아지고, 뒤에서 빛을 받으면 weight=-1.0으로 전체 그림자가 됩니다.

방향FdotLweightR + G × weight결과
배면-1-1.0R - G ≈ 0전체 그림자
측면00.5R + 0.5GSDF gradient 반응
정면+12.0R + 2G ≈ 1전체 밝음

여기서 weight가 음수까지 내려가는 것이 중요합니다. 초기에는 (FdotL + 1.0) * Scale로 매핑했는데, 이 공식은 최소값이 0이라 G가 항상 R에 더해지기만 합니다. 배면에서도 saturate(R + 0) = R 값이 그대로 남아서, 뒤에서 빛을 받아도 어두워지지 않았습니다. weight가 음수로 내려가야 G가 R을 깎아내려서 배면이 자연스럽게 전체 그림자로 수렴합니다.

여기까지 구현하고 보니, R/G additive 결과는 smooth gradient라 셀 셰이딩 느낌이 부족했습니다. 결과에 smoothstep(0.45, 0.55, Lit2)를 적용해서 부드러운 gradient를 날카로운 툰 경계로 변환했습니다.

R/G additive로 코 그림자 같은 디테일이 살아났지만, 눈 밑에 아티팩트가 생겼습니다. 여기서 B채널을 다시 봤습니다. B채널의 threshold 결과(Lit1)는 매끈한 형태의 shadow field로, 눈 밑 아티팩트가 없었습니다. Lit1 × Lit2 — B채널이 R/G의 아티팩트를 마스킹하면서 디테일은 유지하는 구조였습니다.

마지막으로 A채널. B채널까지 이해하고 인게임 스크린샷을 다시 보니, 정면 라이팅에서 코 주변에 기본 노멀 라이팅의 음영이 남아있었습니다. A채널이 정확히 코 주변만 어두운 마스크라는 걸 그제야 알아챘습니다. A채널을 곱하자 코 음영이 깔끔하게 제거되었습니다.

c++
float Lit1 = smoothstep(threshold - softness, threshold + softness, B);  // B: clean shadow
float Lit2 = saturate(R + G * weight);  // R/G: detail shadow
Lit2 = smoothstep(0.45, 0.55, Lit2);   // toon 경계 샤프닝

float shadow = Lit1 * Lit2 * A;  // B가 R/G 아티팩트를 마스킹, A가 코 음영을 제거
Content imagemeants.dev

이로써 제작 애셋에서 제공한 텍스처를 전부 사용해서 SDF 그림자 구현을 완료하였습니다. 해석이 틀릴 수 있겠지만, 4채널의 역할은 다음과 같습니다:

채널역할
R/G디테일 shadow field (additive blending, 좌우 미러 쌍)
Bclean shadow field (R/G 아티팩트 마스킹)
A코 그림자 마스크 (기본 노멀 라이팅 음영 제거)
Content imagemeants.dev

눈썹/속눈썹 전면 렌더링

Content imagemeants.dev

처음에는 눈썹/속눈썹을 왜 한번 더 렌더링해야 하는지 이해를 못했습니다. 찾아보니 2D 애니메이션에서 머리카락에 가려져도 눈썹/속눈썹을 항상 보여주는 연출이 있었고, 3D 카툰 렌더링에서도 이걸 재현하는 게 일반적이었습니다. 3D에서는 머리카락이 깊이 테스트로 눈을 가리기 때문에 별도 처리가 필요합니다.

  • Custom Stencil(Hair=1): 머리카락 메시에 스텐실 값을 태워서 "이 픽셀이 머리카락인지" 식별
  • Translucent Unlit Overlay(ZTest Off): 눈썹/속눈썹을 깊이 테스트 없이 렌더링하여 머리카락 앞에 그림
  • Depth 필터: 얼굴과 머리카락의 깊이 차이를 비교하여, 머리카락에 가려진 부분만 선택적으로 보여줌. 머리카락 뒤의 배경까지 보이지 않도록 제한
Content imagemeants.dev

캐릭터의 얼굴 메시에서 Face, Eyes, Eyelash를 별도 메시로 분리하고, Blueprint에서 기본 메시 + 오버레이 메시 두 세트의 컴포넌트를 구성하여 엔진 수정 없이 구현했습니다.


아웃라인 (BackFace Overlay)

Content imagemeants.dev

메시의 뒷면(BackFace)을 노멀 방향으로 확장하여 실루엣을 만드는 BackFace 방식의 아웃라인을 구현했습니다.

Two Sided + Masked 머티리얼에서 TwoSidedSign으로 뒷면만 렌더링하고, WPO(World Position Offset) Custom 노드에서 버텍스 노멀 방향으로 메시를 확장합니다.

카메라 거리에 따라 아웃라인 두께를 보정하여 원근에 관계없이 일정한 굵기를 유지하며, Min/Max 클램프로 극단적인 거리에서의 깨짐을 방지했습니다.

아웃라인 색상은 BaseColor 텍스처를 어둡게 만들어 사용하며, 파츠별로 어둡기 정도를 조절할 수 있습니다.

반투명 머티리얼은 BackFace 아웃라인이 정상 동작하지 않아 생략하고, Masked 슬롯은 원본 알파 텍스처를 연결하여 처리했습니다.

Content imagemeants.dev

BP에서 전신 SkeletalMesh 컴포넌트를 하나 더 추가하고 모든 슬롯을 아웃라인 Material Instance로 오버라이드하는 구조로, 엔진 수정 없이 완결됩니다.

  • 최적화를 위해 아웃라인에 불필요한 일부 메시들 제거 필요합니다.

추후 작업

이방성 머리카락 하이라이트 (Angel Ring) — 현재 적용하지 않은 상태. Kajiya-Kay 기반 결과물 개선 필요
Cast Shadow — 환경 오브젝트의 Cast Shadow를 Shadow Tint 색감으로 수용하도록 분리
Point Light 대응
ETC…

참고 자료