Toon Shader 하프톤과 잉크 외곽선 렌더링: URP에서 구현하는 실전 원리
Unity URP Toon Shader에 하프톤 음영과 잉크 외곽선을 결합하는 방법을 정리합니다. 화면 좌표 기반 도트, 광원 임계값, 노멀 기반 림, 외곽선 패스를 설계해 해상도 변화에도 안정적인 만화풍 렌더링을 구현합니다.

핵심 요약
하프톤과 잉크 외곽선은 단순히 텍스처를 더하는 효과가 아니라 조명 값을 이산화하고 화면 공간에서 패턴과 윤곽을 안정적으로 제어하는 NPR(Non-Photorealistic Rendering) 기법이다. Unity URP에서는 NdotL을 단계형 명암으로 변환한 뒤 화면 좌표 기반 도트 마스크를 곱하고 별도 외곽선 패스에서 확장한 정점과 노멀 또는 깊이 정보를 사용하면 구현할 수 있다.
하프톤 Toon Shader는 무엇이며 왜 화면 좌표를 써야 할까?
하프톤은 명도 차이를 점의 크기·밀도·모양으로 표현하는 인쇄 기법이다. 3D 셰이더에서는 광원으로 계산한 명암을 원형 도트 마스크와 비교해 만화책 같은 그림자를 만든다.
오브젝트 UV만 기준으로 도트를 배치하면 메시마다 패턴의 크기와 방향이 달라진다. 반대로 SV_POSITION에서 얻은 화면 좌표를 기준으로 계산하면 여러 메시를 지나가는 공통 스크린톤처럼 보인다. 다만 카메라 이동 시 패턴도 함께 이동하는 것이 화면 공간 방식의 특성이다.
| 기준 좌표 | 장점 | 주의할 점 | 적합한 용도 |
|---|---|---|---|
| Mesh UV | 캐릭터 표면에 패턴을 고정할 수 있다 | UV 밀도 차이와 심(seam)이 드러날 수 있다 | 의상 무늬, 고정된 망점 |
| World Position | 오브젝트 간 패턴을 이어 붙이기 쉽다 | 이동·회전 시 질감이 미끄러질 수 있다 | 지면, 환경 오브젝트 |
| Screen Position | 해상도 기준으로 균일한 도트가 된다 | 카메라와 함께 패턴이 움직인다 | 만화책 인쇄 질감, 포스트 프로세스풍 표현 |

하프톤 도트의 핵심 수식
화면 좌표 p를 셀 크기 s로 나눈 뒤 frac으로 셀 내부 좌표만 남긴다. 중앙으로부터의 거리를 d로 두고 도트 반지름 r과 비교하면 원형 마스크를 만들 수 있다.
fwidth(d)를 이용해 r_aa를 만들면 멀리 있는 표면과 해상도 변화에서 도트 가장자리의 계단 현상을 줄일 수 있다.
하프톤 명암을 어떻게 계산할까?
Step 1. Lambert 조명값을 만들기
먼저 표면 노멀 N과 메인 라이트 방향 L로 확산광 값을 구한다.
float NdotL = saturate(dot(normalWS, lightDirWS));
float lightValue = NdotL * mainLight.shadowAttenuation;
URP의 그림자 감쇠값을 곱하지 않으면 그림자 안에서도 하프톤이 같은 밝기로 남아 광원과 분리되어 보일 수 있다.
Step 2. Toon 임계값으로 명암을 이산화하기
부드러운 PBR 명암 대신 smoothstep으로 밝은 영역과 그림자 영역의 전이를 제한한다.
float toonLight = smoothstep(_ShadowThreshold - _ShadowSoftness,
_ShadowThreshold + _ShadowSoftness,
lightValue);
_ShadowSoftness를 0에 가깝게 두면 셀 셰이딩처럼 선명해진다. 너무 작은 값은 그림자 경계의 앨리어싱을 키우므로 모바일에서는 최소한의 전이 폭을 남기는 편이 안전하다.
Step 3. 그림자 영역에만 도트를 적용하기
도트 반지름을 1 - toonLight에 연동하면 어두운 곳일수록 도트가 커진다.
float2 pixelPos = screenUV * _ScreenParams.xy;
float2 cellUV = frac(pixelPos / _HalftoneCellSize) - 0.5;
float distanceToCenter = length(cellUV);
float aa = fwidth(distanceToCenter);
float radius = lerp(_HalftoneMinRadius, _HalftoneMaxRadius, 1.0 - toonLight);
float halftone = 1.0 - smoothstep(radius - aa, radius + aa, distanceToCenter);
float3 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).rgb;
float3 shadowColor = baseColor * _ShadowTint.rgb;
float3 toonColor = lerp(shadowColor, baseColor, toonLight);
float3 finalColor = lerp(toonColor, shadowColor, halftone * (1.0 - toonLight));
이 예시는 그림자 영역을 도트로 더 강하게 덮는 방식이다. 밝은 배경 위에 검은 점을 찍는 만화 인쇄 느낌이 필요하면 halftone의 색상 보간 방향을 반대로 바꾸면 된다.
잉크 외곽선은 왜 별도 패스로 렌더링해야 할까?
외곽선은 대개 기본 메시를 약간 팽창시킨 뒤 뒷면만 렌더링하는 inverted hull 방식으로 구현한다. 원본 메시보다 큰 실루엣의 뒷면이 바깥으로 드러나면서 선처럼 보인다.
flowchart LR
A[메시 정점] --> B[노멀 방향으로 정점 확장]
B --> C[Cull Front로 앞면 제거]
C --> D[외곽선 색상 렌더링]
D --> E[기본 Toon + Halftone 패스]
외곽선 패스는 보통 기본 패스보다 먼저 렌더링한다. 이후 기본 패스가 원본 메시 표면을 덮으므로 확장된 가장자리만 남는다.

HLSL 외곽선 정점 패스 예시
Pass
{
Name "Outline"
Cull Front
ZWrite On
HLSLPROGRAM
#pragma vertex OutlineVertex
#pragma fragment OutlineFragment
float _OutlineWidth;
float4 _OutlineColor;
Varyings OutlineVertex(Attributes input)
{
Varyings output;
VertexPositionInputs positionInputs = GetVertexPositionInputs(input.positionOS.xyz);
VertexNormalInputs normalInputs = GetVertexNormalInputs(input.normalOS);
float3 expandedWS = positionInputs.positionWS + normalInputs.normalWS * _OutlineWidth;
output.positionCS = TransformWorldToHClip(expandedWS);
return output;
}
half4 OutlineFragment(Varyings input) : SV_Target
{
return _OutlineColor;
}
ENDHLSL
}
외곽선 두께가 거리마다 달라지는 문제는 어떻게 해결할까?
월드 공간에서 _OutlineWidth만큼 확장하면 카메라에서 멀어질수록 외곽선이 얇아 보인다. 화면 픽셀 단위에 가까운 굵기를 원한다면 클립 공간에서 정점 위치를 보정해야 한다.
float4 positionCS = TransformWorldToHClip(positionWS);
float3 normalVS = TransformWorldToViewDir(normalWS, true);
float2 outlineDir = normalize(normalVS.xy + 1e-5);
float2 pixelOffset = outlineDir * _OutlineWidth * 2.0 / _ScreenParams.xy;
positionCS.xy += pixelOffset * positionCS.w;
| 방식 | 선 굵기 특성 | 장점 | 단점 |
|---|---|---|---|
| 월드 공간 확장 | 원근에 따라 변화 | 구현이 단순하고 자연스럽다 | 원거리 선이 약해진다 |
| 클립 공간 오프셋 | 화면 픽셀 기준으로 일정 | UI적이고 선명한 스타일에 좋다 | 측면 노멀에서 방향 계산이 불안정할 수 있다 |
| 깊이·노멀 에지 검출 | 화면 전체의 경계를 검출 | 메시 내부 경계도 표현할 수 있다 | 포스트 프로세스 비용과 아티팩트가 있다 |
캐릭터 실루엣 위주라면 inverted hull이 효율적이다. 오브젝트 사이의 접촉선, 내부 하드 엣지까지 강조해야 한다면 Depth Texture와 Normal Texture를 이용한 화면 공간 에지 검출을 추가로 검토한다.
하프톤과 외곽선을 함께 쓸 때의 품질 점검 항목
- 도트 크기를 픽셀 기준으로 검증한다.
_HalftoneCellSize는 8~24px 범위에서 시작하고 목표 해상도에서 점이 뭉개지지 않는지 확인한다. - 그림자 임계값과 도트 반지름을 분리한다. 명암 경계를 정하는
_ShadowThreshold와 인쇄 질감을 정하는_HalftoneMaxRadius를 하나의 파라미터로 묶으면 아트 조정 범위가 좁아진다. - 외곽선의 Z-fighting을 확인한다. 확장 폭이 너무 작거나 깊이 설정이 맞지 않으면 원본 표면과 충돌한다.
Cull Front, 패스 순서,ZWrite설정을 먼저 점검한다. - 스키닝 메시를 별도로 확인한다. 관절 주변의 평균화된 노멀은 팽창 방향을 왜곡할 수 있다. 강한 변형이 있는 캐릭터는 노멀 맵, 하드 엣지, 메시 분할 상태를 함께 확인한다.
자주 묻는 질문 (FAQ)
하프톤에 fwidth를 꼭 사용해야 하나요?
권장한다. fwidth는 화면에서 한 픽셀 주변의 변화량을 이용해 smoothstep 전이 폭을 조절하므로 도트 경계의 앨리어싱과 깜빡임을 줄인다.
외곽선 패스에 Cull Front를 쓰는 이유는 무엇인가요?
확장된 메시의 앞면을 제거하고 뒷면만 남기기 위해서다. 원본 메시가 중앙을 덮고 바깥으로 나온 확장 메시의 뒷면만 잉크 선처럼 보인다.
하프톤을 그림자에만 적용해야 하나요?
반드시 그렇지는 않다. 그림자 전용 도트는 읽기 쉬운 Toon 스타일에 적합하다. 밝은 영역에도 작은 도트를 넣으면 신문 인쇄물 같은 질감이 강해지지만 화면 정보량이 늘어날 수 있다.
마무리
좋은 Toon Shader는 효과를 많이 쌓는 것보다 명암, 패턴, 윤곽의 기준 좌표를 명확히 분리하는 데서 시작한다. 조명은 NdotL과 그림자 감쇠로 결정하고 하프톤은 화면 공간 셀로 관리하며 외곽선은 별도 패스에서 제어하면 아트 디렉션과 성능 조정이 모두 쉬워진다.



