포스트

[TIL] 2026-07-23 — 툰 룩 머티리얼 해부: 마스터-인스턴스 설계와 노드 그래프

팀 게임에 걸린 툰 룩의 머티리얼 내부 구조 편 — 셀 셰이딩 포스트 프로세스(M_PP_ToonTest)의 휘도 양자화 노드 체인과 툰스카이(M_ToonSky)의 절차식 하늘 그래프를 에디터에서 직접 열어 뜯어보고, 마스터-인스턴스 패턴이 튜닝 반복을 어떻게 흡수하는지 정리했다.

[TIL] 2026-07-23 — 툰 룩 머티리얼 해부: 마스터-인스턴스 설계와 노드 그래프

부트캠프 8조 팀 프로젝트(UE5 협동 게임, 저장소 TeamCarry)의 툰 룩 셰이더 작업을 맡아 왔다. 적용기 글이 “레벨에 입히며 생긴 문제들”을 다뤘다면, 이 글은 그 안에 걸려 있는 머티리얼 자체를 해부하는 편이다. 셀 셰이딩 포스트 프로세스 M_PP_ToonTest와 툰스카이 M_ToonSky를 에디터에서 열어, 노드 그래프의 실제 체인과 인스턴스 파라미터 값을 그대로 옮겨 적었다. 제작 배경은 7/6 툰스카이 제작기7/8 셀 셰이딩·아웃라인 다듬기에 있다.

왜 마스터-인스턴스 패턴인가

두 머티리얼 모두 마스터(로직) + 인스턴스(값) 구조다.

  • 마스터(M_)에는 노드 그래프 — 계산 로직만 둔다. 튜닝할 값은 전부 Scalar/Vector 파라미터 노드로 뚫어 놓는다.
  • 인스턴스(MI_)는 마스터를 부모로 두고 파라미터 값만 오버라이드한다. 셰이더 재컴파일 없이 슬라이더로 값을 바꾸면 뷰포트에 즉시 반영된다.

툰 룩 튜닝은 “계단 수를 하나 줄여 보고, 그림자를 조금 밝혀 보고”의 반복이다. 이 반복을 전부 인스턴스에서 돌리면 마스터는 한 번 만든 뒤 손댈 일이 거의 없다. 실제로 셀 셰이딩 마스터의 계단 수 기본값은 3인데, 레벨에 걸린 인스턴스는 5를 쓴다 — 신설 당시(7/03) 6에서 시작해 튜닝을 거친 값이고, 그 과정에서 마스터 그래프는 그대로였다.

현재 팀 레벨에 걸린 관계도는 이렇다 (에셋 레지스트리 참조 기준).

1
2
3
4
5
6
7
8
M_PP_ToonTest (셀 셰이딩 PP 마스터)
 ├─ MI_PP_ToonTest        → L_Level1 / L_Level2 / L_Lobby (공용 3레벨 전부)
 ├─ MI_PP_ToonTest_Lobby  → (미사용)
 └─ MI_PP_ToonTest_Pastel → (미사용)

M_ToonSky (Unlit·IsSky 스카이 마스터) ← MPC_ToonSky(SunDir)
 ├─ MI_ToonSky        → L_Level1 / L_Level2
 └─ MI_ToonSky_Lobby  → L_Lobby

로비 전용으로 만들어 둔 _Lobby·파스텔 톤 실험용 _Pastel 인스턴스는 현재 어느 레벨에서도 안 쓰고 있었다 — 로비 레벨도 공용 MI_PP_ToonTest를 참조 중. 인스턴스는 만들기 쉬운 만큼 이렇게 고아가 되기도 쉬워서, 가끔 레퍼런서를 훑어 정리 대상을 확인할 필요가 있다.

M_PP_ToonTest — 씬 밝기를 계단으로 깎는 노드 체인

Content/Developers/goldb/Shading/M_PP_ToonTest. Material Domain Post Process, Blendable Location Scene Color Before Bloom, 출력은 EmissiveColor 하나다. 표현식 41개, 연결 50개 — 화면에 걸리는 필터치고는 단출한 그래프다.

핵심 아이디어는 조명 항만 분리해서 계단화하는 것. 씬 컬러를 통째로 양자화하면 알베도(재질 고유색)까지 뭉개지므로, 먼저 조명을 알베도에서 떼어낸다.

M_PP_ToonTest 핵심 체인 확대 핵심 체인 — Divide(라이팅 복원) → Dot(휘도) → Multiply → Floor/Frac 분리. Toon_Steps(3.0)·Band_Softness(0.08) 파라미터 노드가 체인에 꽂혀 있다

입력은 SceneTexture 2개(PostProcessInput0 = 씬 컬러, BaseColor = GBuffer 알베도)이고, 체인은 7단계다.

  1. 조명 항 분리씬 컬러 ÷ Max(알베도, Base_Min). 렌더링이 끝난 씬 컬러를 알베도로 나눠 라이팅 항을 복원한다. Base_Min(0.1)은 0-나눗셈 방지 하한.
  2. 휘도 계산 — 조명 항을 luma 가중치 (0.3, 0.59, 0.11)과 내적해 스칼라 휘도 L을 얻는다.
  3. 계단화L × Toon_StepsFloor(정수부)와 Frac(소수부)로 분리. 소수부를 t = Saturate((Frac − (0.5 − BS)) / (BS × 2))로 스무딩해 Floor + t를 다시 Toon_Steps로 나누면 양자화 휘도 Lq가 된다. Band_Softness(BS)가 0이면 계단 경계가 칼같이 끊기고, 키울수록 경계가 부드러워진다.
  4. 게인 산출·클램프Max(Lq, Shadow_Floor) ÷ Max(L, 0.001)로 “원래 휘도를 계단 휘도로 바꾸는 배율”을 만들고, Min(게인, 12)로 상한을 건다. 이 Min 노드가 적용기 글의 초록 얼룩 수정(어두운 픽셀에서 나눗셈 노이즈가 수십 배 증폭되는 문제) 그 자체다 — 커밋 메시지의 “상한 x12”가 그래프에서는 상수 12 하나로 존재한다. Shadow_Floor는 그림자가 완전 검정으로 떨어지지 않게 받치는 최저 밝기.
  5. 하늘 보호 마스크 — 알베도 휘도로 Saturate((0.02 − dot) × 1000). GBuffer에 알베도가 없는(거의 검정) 하늘·Unlit 픽셀에서만 1이 되는 마스크다. ×1000은 문턱을 급경사로 만들어 사실상 스텝 함수로 쓰는 트릭.
  6. 하늘 원본 유지 — 툰 처리된 컬러와 원본 씬 컬러를 위 마스크로 Lerp. 하늘까지 계단화하면 툰스카이가 이미 만들어 놓은 그라데이션이 망가지므로, PP가 하늘을 건드리지 않게 비켜 간다.
  7. 최종 블렌드Lerp(원본, 툰 결과, Cel_Blend) → EmissiveColor. Cel_Blend는 효과 전체의 강도 페이더다(0 = 원본, 1 = 풀 셀 셰이딩).

MI_PP_ToonTest — 파라미터 다섯 개가 룩을 정한다

마스터에 뚫린 Scalar 파라미터는 5개. 레벨에 걸린 인스턴스의 실제 값과 함께 보면 각 값의 의미가 잡힌다.

파라미터마스터 기본MI_PP_ToonTest(현재 적용)역할
Toon_Steps3.05.0휘도 계단 수. 적을수록 만화적, 많을수록 원본에 가까움
Band_Softness0.080.12계단 경계의 부드러움(스무딩 폭)
Shadow_Floor0.30.7그림자 최저 밝기. 올릴수록 어두운 면이 들려 밝은 톤이 됨
Cel_Blend1.00.85효과 강도. 1 미만이면 원본 명암이 살짝 섞여 계단이 완화됨
Base_Min0.10.1알베도 나눗셈 하한(튜닝 대상이라기보다 안전장치)

신설 시점(7/03) 값은 Toon_Steps=6, Cel_Blend=0.35였다. 계단은 6→5로 줄이면서 블렌드는 0.35→0.85로 크게 올렸다 — 처음에는 바닥 노이즈가 무서워 효과를 3분의 1만 섞었는데, 이후 노이즈 원인(증폭 비율)을 상한으로 잡고 나서는 효과를 거의 풀로 걸 수 있게 된 흐름이다. 이 왕복이 전부 인스턴스 값 변경이었고, 마스터 재컴파일은 없었다.

재미있는 흔적도 하나 나왔다. 인스턴스에 Outline_Threshold=0.35, Outline_Hardness=15.0, Outline_Thickness=1.5 오버라이드가 남아 있는데, 현재 마스터에는 이 파라미터들이 존재하지 않는다. 포스트 프로세스 아웃라인을 시도하던 시절 마스터에 있던 파라미터의 잔존 오버라이드다 — 마스터에서 파라미터를 지워도 인스턴스의 오버라이드 기록은 무효 상태로 남는다. 이 시도가 왜 접혔는지는 아래 실패 기록에.

M_ToonSky — 텍스처 0장, 수식 130노드로 그린 하늘

Content/Developers/goldb/Shading/M_ToonSky. Surface 도메인이지만 Shading Model Unlit, Is Sky 체크, Two Sided — 조명을 받지 않고 스스로 색을 내는 하늘 돔 전용 설정이다. 표현식 130개, 연결 145개로 PP 머티리얼의 3배 규모이고, 역시 출력은 EmissiveColor 하나.

M_ToonSky 그래프 전경 그래프 전경 — 상단 하늘 그라데이션 블록, 중앙 구름 블록, 하단 태양·주야·별밭 블록. 텍스처 샘플 노드는 하나도 없다

모든 계산의 출발점은 뷰 방향이다. 월드 위치 − 카메라 위치를 정규화한 방향 벡터에서 z 성분만 뽑아 Saturate하면 “지평선 위 높이” h(0~1)가 나오고, 이 h로 하늘 전체를 그린다.

M_ToonSky 핵심 체인 확대 핵심 체인 — 뷰 방향(WorldPos−CamPos→Normalize→Mask B), Band1/Band2 스무스스텝과 Horizon/Mid/Zenith Lerp, 구름 UV(Mask RG → CloudScale·Time×CloudSpeed → Noise) 체인

  • 2밴드 그라데이션 — h를 (h − BandPos) / BandFeather 꼴 스무스스텝 2개에 통과시켜, HorizonColor → MidColor → ZenithColor를 두 번의 Lerp로 잇는다. 밴드 위치·페더 폭이 전부 파라미터라, 인스턴스에서 “지평선 띠를 더 두껍게” 같은 조정이 값 하나로 된다.
  • 구름 — 방향 벡터의 xy를 (h + 0.3)으로 나눠 하늘 돔에 펼치는 투영 UV를 만들고, Time × CloudSpeed로 흘리면서 Simplex Noise 노드(Turbulence, 3레벨)에 넣는다. 노이즈 값을 CloudCover 문턱으로 잘라 구름 마스크를, 더 높은 CloudCoreThreshold로 잘라 코어 마스크를 만들어 구름색·구름 그림자색 2단 음영을 얹는다. 텍스처 없이 노이즈 문턱값 두 개로 만화풍 뭉게구름이 나온다.
  • 태양 디스크+헤일로 — 뷰 방향과 태양 방향의 내적을 Power(400)(코어)과 Power(10) × 0.18(헤일로)로 좁게/넓게 깎아 더한다. 지수가 클수록 하이라이트가 좁아지는 스펙큘러와 같은 원리. 태양이 낮을 때는 (1.25, 0.82, 0.55) 주황 틴트를 곱해 저녁 해 색을 낸다.
  • 주야 전환SkyAtmosphereLightDirection과 (0,0,1)의 내적, 즉 태양 고도를 스무스스텝에 넣어 밤하늘색과 주간색을 Lerp한다. 레벨의 디렉셔널 라이트 각도를 돌리면 하늘이 따라서 저물게 되는 연결점이 이 노드다.
  • 노을 밴드1 − |고도 − SunsetPeak| / SunsetWidth로 “태양이 특정 고도(0.05) 근처일 때만 1”이 되는 종 모양 강도를 만들고, SunsetColor(지평선 주황)→SunsetZenithColor(상공 태운 적색)의 수직 그라데이션을 그 강도로 섞는다. 피크·폭·강도·색 2종이 전부 인스턴스 파라미터 — 적용기 글의 노을 연출 튜닝이 이 값들이다.
  • 밤 별밭+지평선 글로우 — 여기만 노드가 아니라 Custom(HLSL) 노드 1개다. 뷰 방향을 atan2/asin으로 구면 UV로 펴고, StarDensity 셀 그리드의 해시로 별 위치·크기를 랜덤 배치, View.GameTime으로 반짝임을 준다. 지평선 글로우는 pow(saturate(1−h), 4) × 야간량 × NightGlowColor. 셀 그리드 해시·트윙클 같은 로직은 노드로 풀면 수십 개가 필요해서, 이 블록만 HLSL로 접은 판단이다.

파라미터는 Scalar 15개 + Vector 11개. 인게임/로비 인스턴스(MI_ToonSky / MI_ToonSky_Lobby)의 차이는 태양 방향(SunDir) 하나뿐이고, 구름 밀도·경계 페더 같은 공통 튜닝(CloudCover 0.52→0.56, Band2Feather 0.05→0.18 등)은 두 인스턴스가 같은 값을 쓴다.

태양 방향은 머티리얼 파라미터 외에 MaterialParameterCollection(MPC_ToonSky)으로도 들어온다. 이 MPC는 툰스카이만이 아니라 도시 백드롭 머티리얼(M_SkylineBackdrop, M_BackdropGround)도 참조한다 — 하늘과 배경 실루엣이 같은 태양 방향 하나를 공유해야 광원이 어긋나 보이지 않기 때문에, 여러 머티리얼이 함께 읽는 전역 값은 MPC로 뺐다.

실패 기록 — PP 림라이트·아웃라인의 흔적

위에서 본 Outline_* 잔존 오버라이드가 그 흔적이다. 포스트 프로세스 단계에서 림라이트·아웃라인까지 얹는 시도를 했지만 TSR 환경에서 화면 가장자리 스테일 메모리 아티팩트로 불가 판정을 내렸고(7/03), 아웃라인은 오브젝트별 inverted-hull 방식으로 방향을 틀었다. 시도의 상세와 판정 근거는 적용기 글에 정리돼 있다.

오늘 배운 것

  • 마스터-인스턴스 패턴의 실익은 튜닝 이력이 마스터를 오염시키지 않는 것 — 계단 수 6→5, 블렌드 0.35→0.85의 왕복이 전부 인스턴스 값 변경이었고 셰이더 재컴파일은 없었다
  • PP 셀 셰이딩의 요체는 조명 항 분리 — 씬 컬러를 통째로 양자화하지 않고 씬 컬러 ÷ 알베도로 라이팅만 복원해 계단화해야 재질 고유색이 산다
  • 여러 머티리얼이 공유하는 전역 값(태양 방향)은 파라미터 복붙이 아니라 MPC로 — 하늘과 백드롭의 광원이 자동으로 일치한다
  • 마스터에서 지운 파라미터의 인스턴스 오버라이드는 무효 상태로 잔존한다 — 접은 시도의 흔적이 인스턴스에 남으니 가끔 청소가 필요하다

면접에서 이렇게 말한다

  • 예상 질문: “머티리얼 인스턴스를 왜 쓰나요?” → 재컴파일 없는 파라미터 튜닝 + 마스터(로직)/인스턴스(값) 분리로 튜닝 반복을 흡수한 이 사례로 답변
  • 예상 질문: “텍스처 없이 하늘을 그린다면?” → 뷰 방향 z(고도) 기반 그라데이션, 노이즈 문턱값 구름, 내적+Power 태양 디스크, 태양 고도 스무스스텝 주야 전환의 구성으로 답변
  • 키워드: 마스터 머티리얼/인스턴스, 포스트 프로세스 블렌더블, 휘도 양자화, MaterialParameterCollection, Custom HLSL 노드
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.