HLSL과 Shader Graph로 구현하는 카툰 셰이딩
카툰 셰이더의 핵심인 단계형 명암, 림 라이트, 스페큘러 하이라이트를 조명 계산 관점에서 정리하고 Unity에서 HLSL과 Shader Graph로 구현하는 방법을 설명합니다.

카툰 셰이딩은 무엇을 바꾸는가
일반적인 PBR 조명은 빛과 법선의 각도에 따라 명암이 연속적으로 변화한다. 반면 카툰 셰이딩은 그 연속값을 몇 개의 구간으로 나누어 색을 갑자기 바꾼다. 이 단순한 변화만으로도 애니메이션 풍의 평평하고 읽기 쉬운 명암을 만들 수 있다.
가장 기본이 되는 값은 표면 법선 N과 광원 방향 L의 내적이다.
두 벡터가 같은 방향일수록 값은 1에 가까워지고 수직이면 0이 된다. 보통 이 값을 NdotL이라 부른다. 카툰 셰이더는 NdotL을 그대로 쓰지 않고 임계값을 기준으로 밝음과 어두움으로 나눈다.
flowchart LR
N[월드 공간 법선 N] --> D[NdotL 계산]
L[광원 방향 L] --> D
D --> T[임계값 또는 램프 텍스처]
T --> C[명암 색 선택]
C --> O[최종 표면 색]

가장 작은 단계형 명암 함수
두 단계 명암은 step으로 구현할 수 있다. 아래 함수는 threshold보다 밝으면 1, 아니면 0을 반환한다.
float ToonStep(float ndotl, float threshold)
{
return step(threshold, ndotl);
}
하지만 경계가 너무 날카로우면 해상도나 움직임에 따라 깜빡임이 눈에 띌 수 있다. smoothstep으로 경계 폭을 약간 주면 형태는 유지하면서도 더 안정적인 결과를 얻는다.
float ToonBand(float ndotl, float threshold, float softness)
{
float minEdge = threshold - softness;
float maxEdge = threshold + softness;
return smoothstep(minEdge, maxEdge, ndotl);
}
softness는 카툰 느낌을 약하게 만드는 값이 아니라 경계의 앨리어싱을 제어하는 값이다. 아주 작은 값부터 시작해 캐릭터가 움직일 때만 필요한 만큼 늘리는 편이 좋다.
HLSL로 기본 카툰 조명 만들기
다음 예시는 단일 방향광을 가정한 핵심 조명 계산이다. 실제 Unity 프로젝트에서는 사용하는 렌더 파이프라인에 맞춰 광원 데이터와 그림자 값을 받아야 한다.
float3 EvaluateToonLighting(
float3 normalWS,
float3 lightDirWS,
float3 baseColor,
float3 shadowColor,
float threshold,
float softness)
{
float3 N = normalize(normalWS);
float3 L = normalize(lightDirWS);
float ndotl = saturate(dot(N, L));
float lightBand = ToonBand(ndotl, threshold, softness);
return lerp(shadowColor * baseColor, baseColor, lightBand);
}
여기서 shadowColor는 실제 그림자 판정값이 아니라 셀의 어두운 색조다. 예를 들어 푸른 그림자를 원한다면 중성 회색을 곱하는 대신 약간 푸른 색을 넣을 수 있다. 조명 색과 그림자 감쇠는 이 계산에 별도로 곱해 주는 편이 제어하기 쉽다.
float3 litColor = EvaluateToonLighting(
normalWS,
mainLightDirection,
baseColor,
shadowTint,
_Threshold,
_Softness
);
litColor *= mainLightColor * shadowAttenuation;
주의할 점은 방향 벡터의 기준이다. 어떤 API는 표면에서 광원으로 향하는 벡터를 제공하고 어떤 구현은 그 반대 방향을 사용한다. 명암이 뒤집혔다면 임계값부터 바꾸지 말고 L의 방향과 dot(N, L)의 부호를 먼저 확인한다.
Shader Graph에서 같은 구조 만들기
Shader Graph에서는 HLSL 함수와 거의 같은 노드 흐름으로 구성할 수 있다.
단계형 명암 노드 구성
Normal Vector노드를 World 공간으로 설정한다.- 메인 라이트 방향을 얻는다. URP에서는
Main Light노드 또는 커스텀 함수로 방향을 받아야 하는 버전에 유의한다. Dot Product에 법선과 광원 방향을 연결한다.Saturate로 값을 0~1 범위로 제한한다.Step또는Smoothstep으로 명암 밴드를 만든다.Lerp의 A에 그림자 색, B에 기본 색을 넣고 밴드 값을 T에 연결한다.
두 단계가 아니라 세 단계 이상의 명암이 필요하다면 Step을 여러 개 만들고 색을 순서대로 보간할 수 있다. 다만 관리가 어려워지므로 명암 단계가 많아질 때는 램프 텍스처가 더 적합하다.
램프 텍스처로 명암 단계 관리하기
램프 텍스처는 가로축에 NdotL을 넣어 원하는 색을 꺼내는 1차원 색상표다. 왼쪽에는 어두운 색, 오른쪽에는 밝은 색을 배치한다. 중간에 색 띠를 늘리면 3단계 이상도 코드 변경 없이 조절할 수 있다.
float2 rampUV = float2(saturate(ndotl), 0.5);
float3 rampColor = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, rampUV).rgb;
float3 finalColor = baseColor * rampColor;
램프 텍스처는 보통 세로 방향 변화가 필요 없으므로 높이가 낮아도 된다. 색 구간을 선명하게 유지하려면 임포트 설정에서 필터 모드를 Point로 선택한다. 부드러운 전환이 목표라면 Bilinear를 사용하되 텍스처 가장자리에서 의도치 않은 색이 섞이지 않도록 Wrap Mode는 Clamp로 설정한다.
림 라이트 추가하기
림 라이트는 카메라를 향한 방향과 표면 법선이 수직에 가까운 가장자리를 밝히는 효과다. 캐릭터 실루엣을 배경에서 분리하는 데 유용하다.
V는 표면에서 카메라로 향하는 정규화된 벡터다. 아래처럼 파워 연산으로 가장자리 폭을 조절할 수 있다.
float3 V = normalize(viewDirWS);
float fresnel = 1.0 - saturate(dot(N, V));
float rim = pow(fresnel, _RimPower);
float rimBand = smoothstep(_RimThreshold - _RimSoftness,
_RimThreshold + _RimSoftness,
rim);
finalColor += rimBand * _RimColor.rgb * _RimIntensity;
림 라이트가 그림자 면에서도 동일하게 보이면 물체가 지나치게 발광하는 느낌이 날 수 있다. 의도에 따라 rimBand에 lightBand 또는 그림자 감쇠를 곱해 밝은 면에서만 보이도록 제한한다.
카툰 스페큘러 하이라이트
매끈한 재질의 하이라이트도 연속적인 반사 대신 잘라낸 모양으로 만들 수 있다. Blinn-Phong 방식의 반쪽 벡터 H를 사용하면 기본 형태를 쉽게 얻는다.
float3 H = normalize(L + V);
float specular = pow(saturate(dot(N, H)), _SpecularPower);
float specularBand = step(_SpecularThreshold, specular);
finalColor += specularBand * _SpecularColor.rgb;
_SpecularPower는 하이라이트의 크기, _SpecularThreshold는 잘려 나가는 기준을 제어한다. 피부, 금속, 플라스틱처럼 재질마다 하이라이트 크기를 다르게 주면 단순한 셰이더에서도 재질 구분이 좋아진다.
그림자와 후처리에서 확인할 점
카툰 셰이딩의 명암 밴드와 실시간 그림자는 역할이 다르다. 밴드는 표면의 방향을 기준으로 색을 나누고 그림자는 다른 물체나 자기 자신이 빛을 가리는지를 판정한다. 따라서 최종 조명에서는 보통 둘을 함께 고려해야 한다.
float toonLight = ToonBand(ndotl, _Threshold, _Softness);
float visibility = shadowAttenuation;
float3 shaded = lerp(_ShadowColor.rgb, 1.0.xxx, toonLight * visibility);
finalColor = baseColor * shaded;
이 방식은 그림자가 닿는 영역을 어둡게 만들지만 그림자 가장자리 자체는 일반적인 섀도 맵 필터링 특성을 따른다. 그림자까지 만화처럼 단순화하려면 그림자 감쇠에도 별도 임계값을 적용할 수 있다. 다만 얇은 물체나 움직이는 광원에서 계단 현상과 떨림이 늘어날 수 있으므로 화면을 움직이며 확인해야 한다.
구현 순서와 디버깅 팁
처음부터 림 라이트, 스페큘러, 노멀 맵을 모두 넣기보다 다음 순서로 검증하는 편이 빠르다.
NdotL값을 흑백으로 출력해 법선과 광원 방향을 확인한다.step으로 2단계 명암을 만든다.- 그림자 색과 조명 색을 적용한다.
- 램프 텍스처 또는 추가 밴드로 색 단계를 늘린다.
- 림 라이트와 스페큘러를 필요한 재질에만 추가한다.
특히 Shader Graph와 HLSL 결과가 다르면 공간 설정을 먼저 비교한다. 법선은 World 공간인데 광원 방향은 Object 공간인 식의 불일치가 가장 흔한 원인이다. 또한 선형 색 공간 프로젝트에서는 램프 텍스처의 sRGB 설정이 의도한 색 변화에 영향을 줄 수 있으므로 색상 램프인지 데이터 텍스처인지에 맞춰 임포트 옵션을 정해야 한다.
마무리
카툰 셰이더의 출발점은 복잡한 조명 모델이 아니라 NdotL을 의도적으로 단순화하는 일이다. 단계형 명암을 안정적으로 만든 뒤 램프 텍스처로 아트 디렉션 범위를 넓히고 필요한 경우에만 림 라이트와 하이라이트를 더하면 유지보수하기 좋은 셰이더가 된다.



