해당 챕터에서는 본격적으로 캐릭터에 Diffuse 처리를 해 볼 것이다.
3D 모델에 대해서 물체의 물성에 따른 라이팅을 하는 방법과 방식에 대해
수많은 선대 컴퓨터 그래픽스 학자들이 깊은 고민을 해 왔고
이에 따라서 다양한 라이팅 모델이 존재한다.


우리의 눈, 혹은 카메라에 랜더링이 되는 것이라고 함은
결국은 물체의 고유 색상인 Albedo와 Light, 그리고 관찰자인 View의 관계로
내부적인 블랙박스 함수로 인해 보여지는 결과로
이해할 수 있다.
라이팅은 최종적으로 픽셀 단위의 색을 결정하는 과정이며,
일반적인 forward 셰이딩에서는 fragment shader에서 조명 항을 계산한다.
결론적으로는 모니터의 각 픽셀마다의 RGB값을
어떤 방식으로 계산할 것인가가 주된 관심사이다.
라이팅을 처리할때는 표면에 나타나는 여러 효과들에 대한 항들을 구분하여 이를 조합하는 방식을 사용한다.
1. Ambient
2. Diffuse
3. Specular
4. Fresnel
5. etc
본 글에서는 저번 챕터의 Face SDF와 이어지는 내용으로써 Diffuse Lighting + Ramp Diffuse 방식을 소개한다.
준비물
1. Lambertian Model
2. Half Lambertian Model
3. Ramp Texture
4. Custom Mapping
5. Custom Mapping - Ambient Occlusion(green channel)
1. Lambertian Model
게임과 같은 RTR(Realtime Rendering)에 최적화 되어 현재까지도 많이 쓰이는 모델은 Lambert Lighting Model이다.
Lambertian Diffuse는 기본적으로 물체의 표면을 플라스틱과 같은 난반사 재질이라고 가정한다.
해당 모델은 빛이 표면에 입사하여 반사되었을때 빛의 반사 분포가 모든 방향으로 고르게 퍼져 나가는 것을 가정한다.
따라서 표면의 법선 벡터 N을 알고 Light Vector L이 있을때
MAX(N dot L, 0 ) 로 근사할 수 있다.
Lambertian = MAX(N dot L , 0)
2. Half Lambertian Model
하프 램버트 모델은 램버트 라이팅 모델을 사용시
음수 범위가 발생하고 음영이 급격하게 어두워 진다는 점에서 이를 -1~1 범위에서 0~1 범위로 매핑한
결과이다.
Half Lambertian = N dot L * 0.5 + 0.5
3. Ramp Texture

NPR의 목적은 라이팅 및 랜더링 결과가 사실적인 묘사와 다르게 카툰풍의 효과를 내는데에 있다.
따라서, 색감이나 라이팅 처리를 하는데 보다 아티스트 친화적이고 필요하다면 색상에 왜곡을 주어서
Beautiful하게 만들면 그만인 것이다.
이를 보조하기 위한 방법으로 라이팅 처리에 있어서
표면의 라이팅 값에 따라 색상을 매핑하는 Color Ramp 방식을 사용할 수 있다. (LUT, Look Up Table)

해당 방식의 핵심은 텍스쳐를 GPU에 입력으로 받아올 때
텍스쳐의 UV 좌표를 Lighting의 결과값으로 받아 오는 것이다.
라이팅 모델에서의 음영에 대한 값을 uv 좌표의 x축에 대한 값으로 매핑했을때
해당 구간에 대한 색상을 미리 정의함으로써 물체 표면에 작용하는 빛의 색상에 대해서
Ambient + Dfifuse Lighting을 처리할 수 있다.
요즘 만들어지고 있는 대부분의 NPR 스타일 게임들은 RampTexture 기반 라이팅을 사용한다.

미호요 게임에서는 다음과 같이 Ramp Color가 하나의 텍스쳐에 여러 행으로 나뉘어져 있는 통합된 텍스쳐를 사용한다.
총 10개의 Ramp Texture가 존재하는데
낮일때랑 밤일때 그림자의 색감 차이를 반영하기 위해서 각각 5개씩 총 10개를 하나의 텍스쳐에 담아놓은 것이다.
색감을 확인해 보면 위쪽 ramp가 낮일때(0.5 ~ 1.0),
아래쪽 ramp가 밤일때(0.0~0.5)의 색상임을 확인해 볼 수 있다.
Ramp가 5개인 이유는
텍스쳐에서 재질마다의 물성에 따른 그림자 색을 다르게 하기 위해서라고 추정해 볼 수 있다.
물체의 색상이나 재질에 따라서 빛을 반사하는 값이 다르므로 음영에 대해서 하나의 색상으로 처리하기 보다
여러개의 color ramp를 만들어서 보다 다채롭게 만드는 것이다.
4. Custom Mapping
텍스쳐는 UV 좌표의 2차원 배열에 각 텍셀에 대해서 r,g,b,a의 4차원 벡터 값을 가질 수 있다.
텍스쳐는 색상으로써도 표현할 수 있지만 FaceSDF에서도 보았듯이 중요한것은 색상 정보 뿐 만 아니라
벡터나 임계값과 같은 커스텀 데이터를 포함시킬 수 있다는 것이다.
즉, 텍스쳐는 아티스트의 용도에 맞게 커스텀 할 수 있으며 텍스쳐 정보를 통한 마스킹, 분기 등을 처리할 수 있다.

실제 인게임 모델을 기반으로 어떠한 부분에 어떤 효과가 나타났는지를 역추적 해 보자






Diffuse Texture.rgb / Diffuse Texture.a
Diffuse 텍스쳐를 보면 색상에 대한 값을 담고 있는 rgb 정보와
특정 부분에 대해서 마스킹 되어 있는 alpha 채널의 정보를 확인해 볼 수 있다.
마스킹 정보는 확인해 봤을때 보석과 같이 광택 재질이 있는 부분에 대해서 마스킹 되어 있는 모습을 볼 수 있으며,
인게임에서 대조했을때 해당 부분들에 대해서 밝게 Emission 처리가 되어 있는 것을 볼 수 있다.






Lightmap Texture.rgb / Diffuse Texture.a
Lightmap Texture에서는 일반적으로 알고 있는 Lightmap과는 다르게
텍스쳐에 대해서 여러 채널들이 특정 목적으로써 커스텀으로 사용되고 있음을 확인해 볼 수 있다.
중요한 것은 해당 텍스쳐의 값을 색상 값으로 해석해서는 안된다는 것이다(sRGB 공간이 아님)
Lightmap Texture에 대해서 rb 채널은 추후에 specular 처리를 위한 마스킹 레이어로 사용되며,
현재 Ramp Diffuse에 필요한 정보는 Lightmap Texture.a에 담겨 있다.


body lightmap.a / hair lightmap.a
각 파츠의 Lightmap Texture.a의 색상을 확인해 보면
0 ~ 1 사이에 단계별 회색 색상들이 각 파츠에 배치되어 있는데
다음과 같이 총 5개의 색상이 구분되어 있다.
body diffuse
255 -> 피부
78 -> 금속
0 -> 옷
177 > 실크
hair diffuse
255 -> metalic
128 -> hair
0 -> extra object
로 총 5개 [0, 78, 128, 177, 255]
의 alpha 값의 분기로 처리되어 있다.
텍스쳐상 오차값은 어느 정도 존재하는 것으로 보인다.




Vertex Color r, g, b, a
모델의 버텍스 컬러에도 다음과 같은 마스킹 정보가 존재한다.
R - 앰비언트 오클루젼의 일환, 얼굴 아래의 목 부분은 그림자를 강제함
G - 의상 몸 분할
B - 의미 x
A - 얼굴의 홍조부분이나 윤곽선 마스킹
5. Custom Mapping- Ambient Occlusion(Green Channel)


차폐광(Occlusion Shadow)은 환경광 등의 Ambient Light 마져도 들어오지 않는
골짜기나 두 면이 맞닿아 있는 영역 등에서 발생한다.
Occlusion Shadow는 랜더링 되는 화면이나 그림에 있어서
깊이감과 양감을 확연하게 높혀주는 성분이며 매우 중요한 요소 중 하나이다.
Diffuse Lighting에서 AO 텍스쳐를 통해 빛을 받는 영역에도 그림자가 질 수 있도록 확실히 마스킹 한다.
로직 설명
수행 해야 하는 문제
1. face diffuse와 general diffuse를 GPU shader variant 기반으로 분기 처리한다.
2. 앰비언트 오클루젼을 포함한 lighting 결과의 factor를 계산한다
3. 계산된 lighting factor를 가지고 color ramp의 uv의 x값에 대입한다
4. lightmap.a에 분기된 alpha 값을 통해 color ramp의uv y값에 매핑한다
5. 시간변화 (낮, 밤) 에 따라서 uv y값에 offset을 추가한다
1. face diffuse와 general diffuse를 GPU shader variant 기반으로 분기 처리한다.
사전에 FaceSDF 텍스쳐를 통해 얼굴의 음영 처리를 수행하였다.
현재 항목은 일반적인 램버트 라이팅을 통한 Diffuse 처리를 수행하는 쉐이더를 작성해야 하므로
facial 에 대한 부분과 아닌 부분으로 처리를 분기해야 한다.
한편,
GPU는 SIMD(Single Instruction Multiple Data)/SIMT(Single Instruction Multiple Threads) 구조이기 때문에,
한 Warp/WaveFront(NVIDA 32개, AMD 64개) 내 스레드들이 서로 다른 분기 경로를 타면(divergence)
분기 마스킹이 발생하고 실행 효율이 떨어진다.
분기마스킹은 쉽게 말해서 vertex 정보가 묶음으로 gpu 파이프라인을 타는데
분기 코드를 만났을때 각 분기에 대해서 무조건 실행이 되게끔 처리된다.
왜냐하면 GPU는 cpu와 다르게 병렬 처리에 특화된 하드웨어로 발전해 왔기 때문에
한번의 명령으로 처리를 하는것을 원칙으로 하여 분기와 같은 여러 명령에 대한 처리를 수행하기 어렵다.
따라서, 일단 계산은 하고 false인 분기는 계산 결과를 버리는 처리를 하게 된다.
이러한 부분에 있어서 리소스 + 계산 낭비가 발생하는 것이다.
-> 쉐이더 코드에서 if, else와 같은 런타임 분기 코드를 작성하는 것은 조심해야 함
조건이 자주 고정되는 기능 토글은
런타임 분기 대신 shader variant(컴파일 타임 분기)방식으로 분리하는 것이 원칙이다.
유니티에서는 shader variant를 수행하기 위해
#pragma multi_compile
#pragma shader_feature
두 전처리어를 사용할 수 있다.
multi_compile
- 가능한 모든 키워드 조합을 항상 컴파일한다(ENUM)
- 실제 사용 여부와 관계없이 빌드에 포함된다
- 주로 파이프라인 기능이나 런타임에서 동적으로 변경될 수 있는 기능에 사용된다
shader_feature
- 실제로 사용되는 키워드만 컴파일된다
- 사용되지 않는 배리언트는 빌드 시 자동으로 제거(stripping)된다
- 머티리얼 단위의 기능 토글에 적합하다
해당 문제에서는 face인지 아닌지에 대한 것만을 파악하면 되므로 shader_feature를 통한 분기를 한다.
Shader "Study/GenshinToon"
{
Properties
{
[Header(Face)]
[Toggle(_IS_FACE)] _IsFace("Is Face", Float) = 0
.
.
.
}
Pass
{
HLSLPROGRAM
#pragma shader_feature _IS_FACE
#pragma vertex vert
#pragma fragment frag
.
.
.
half4 frag(Varyings input) : SV_Target
{
#if _IS_FACE
shadow = FaceShadow(input, LightDirection);
#endif
return shadow;
}
ENDHLSL
}
1. 유니티에서는 shader variant를 구현하기 위해 toggle keyword를 지원한다. Toggle( )를 통해 구현되며 괄호 안에 _IS_FACE등과 같은 키워드를 넣어 shader variant를 구현한다.
2. 전처리어로 #pragma shader_feature[name] 을 통해 미리 지시한다.
3. #if, #else, #endif 로 분기한다.
2. 앰비언트 오클루젼을 포함한 lighting 결과의 factor를 계산한다
half AO = lightMap.g * input.color.r;
half NdotL = dot(input.normalWS, LightDirection);
half halfLambert = NdotL * 0.5 + 0.5;
half shadow = saturate(halfLambert * saturate(2 * AO));
#if _IS_FACE
shadow = FaceShadow(input, LightDirection);
#endif
return shadow;
1. AO 마스킹을 lightmap.r 값과 버택스 컬러의 r 값을 가져와서 multiply 한다
2. AO 텍스쳐의 basecolor 자체가 기본적으로 0.5값이다.(127)
때문에 이를 밝게 빼주어야 하므로
offset(최소 2가 되어야 함 ->1이상이 되어야 lambert lighting에 영향이 없으므로)을 multiply를 한다.


HalfLambert / AO 적용 후
3. 계산된 lighting factor를 가지고 color ramp의 uv의 x값에 대입한다.
4. lightmap.a에 분기된 alpha 값을 통해 color ramp의uv y값에 매핑한다
5. 시간변화 (낮, 밤) 에 따라서 uv y값에 offset을 추가한다

-

body ramp/ hair ramp
해당 문제를 해결 하기 위해서 특징을 먼저 파악해 보자.
1. index 2, 4를 확인해 보았을 때
아래쪽 5개의 색감은 한색이므로 Night,
위쪽 5개의 색감은 음,명부 경계에 난색으로 그라데이션과 명부가 밝게 흰색으로 바래지므로으로 Day인 것으로 보인다.
따라서, offset를 두어 Night 일때는 y가 0.0 부터 Day 일때는 uv.y가 0.5 부터 기준을 잡도록 한다.
Day는 런타임 분기가 되어야 하므로 ShaderVariant를 사용하지 않는다.
2. Lightmap.a에 대하여 각 material id 에 대해서 0~1의 float 값을 매핑해 보면
0 -> 0.0
78 -> 0.3
127 -> 0.5
178 -> 0.7
255-> 1.0
약 0.2 간격으로 떨어져 있다.
3. 각 캐릭터의 쉐이더 코드는 리소스 재사용, 관리 용이상 우버쉐이더로 제작되었음을 유추해 보았을 때
Hair Ramp의 index 수치와 Body Ramp의 index 수치가 서로 호환되어야 한다.
body diffuse
255 -> 피부 (1)
78 -> 금속 (0.3)
0 -> 옷 (0)
177 > 실크 (0.7)
hair diffuse
255 -> metalic (1)
128 -> hair(0.5)
0 -> extra object(0)
ramp의 색상와 material id를 서로 상호 비교해보면 다음과 같이 유추해 볼 수 있다.
- body diffuse의 hair diffuse의 1 값의 물성이 ramp index 3번의 피부색, 금속 색상과 매핑된다
- body diffuse의 0.3 값의 물성이 ramp index의 1번의 금속 색상과 매핑된다.


- hair diffuse에는 금속을 의미하는 1을 제외한 0.5, 0 의 값이 존재하며 hair ramp index에 대하여 2번 또는 4번 중 하나이다.
- 머리 명부와 암부의 terminator 영역에서 색상이 노란색으로 되어 있는것으로 보아 0.5에 해당하는 값이 ramp texture에서 명부가 보다 노란색상에 가까운 2번에 연결될 가능성이 높고, 따라서 0에 해당하는 값은 자연스럽게 4번이 된다.
- body diffuse의 0.7 물성은 나머지 ramp index의 0번 값에 해당한다.
한편, 저러한 명부와 암부에 대해서 terminator 경계에
난색으로 saturate 되는 현상은 기본적으로 표면하 산란(SSS)에 착안된다.
보통은 피부나 나뭇잎과 같이 빛의 투과가 내부로 침투할 수 있는 반투명한 재질에 대해서 이러한 현상이 주로 발생하고
위와 같은 머리카락의 경우에는 이러한 현상이 덜하지만,
NPR shading은 기본적으로 그림을 레퍼런스하여 3d 공간에서
이쁘고 비현실적인 라이팅을 재현하는 데에 의의가 있고 경계에 이러한 효과를 줌으로써 한난 대비를
이루며 보다 생동감이 있게 느껴지기에 명부에서 암부로 갈때 saturate을 올렸다가
다시 명도랑 채도를 떨구는 효과를 주게 된다.

한편, Ramp Texture 색상을 보았을 때 머리카락에 대한 Ramp Texture에 대해서 sss를 모델링하는 색상이 Ramp texture의 명부를 채우고 있는 모습을 볼 수 있다.
해당 값 에서 RampTexture 자체가 명부와 암부를 모두 전역적으로 담당하고 있는것은 아니며 암부 -> 명부로 넘어가는 경계까지의 영역에 대해서만 커버를 하고 나머지 명부 색상에 대해서는
Albedo 색상 그대로 사용하도록 설정을 해 주어야 할 것이다.(빛 색상이 r,g,b가 1일때, white)
지금까지 내용으로 처리해야 하는 동작을 살펴보면 다음과 같다.
이전 단계에서 AO 적용된 조명으로 얻은 lighting factor(0~1) 를 Ramp UV의 x = lighting Factor 로 사용한다.
Ramp의 y는 (Day/Night 오프셋) + (material -> row y 좌표) 로 구성하며, Night=0.0, Day=0.5 를 기준으로 한다.
따라서 샘플 좌표 로직은 UV = (lightingFactor, dayOffset(0.5) + row y ) 이다.
material value -> index표
| material id | index | ramp mapping(nig) | ramp mapping + offset(day) |
| 0 | 4 | 0.4~0.5 | 0.9~1.0 |
| 0.3 | 1 | 0.1~0.2 | 0.6~0.7 |
| 0.5 | 2 | 0.2~0.3 | 0.7~0.8 |
| 0.7 | 0 | 0.0~0.1 | 0.5 ~ 0.6 |
| 1.0 | 3 | 0.3~0.4 | 0.8 ~ 0.9 |
int index = 4;
index = lerp(index, 1, step(0.2, material));
index = lerp(index, 2, step(0.4, material));
index = lerp(index, 0, step(0.6, material));
index = lerp(index, 3, step(0.8, material));
half2 rampUV = half2(shadow, (index / 10.0) + (0.5 * _IsDay) + 0.05);
half3 shadowRamp = SAMPLE_TEXTURE2D(_ShadowRamp, sampler_ShadowRamp, rampUV);
GPU에서의 if else를 통한 분기는 divergence할 위험이 크기 때문에
lerp 연산( a*t + b*(1-t))를 통해 분기를 흉내낼 수 있다.


보았을 때 빛이 비추어지는 방향과 면적 대비 명부에 대한 비율이 너무 작다는 것을 알 수 있다.
이를 해결하기 위하여 계산한 shadow 영역 등에 대해 offset을 추가한다.
이유에 대해서 추적해보면 하프 램버트 라이팅에 대해서 값의 비율이
0.7~0.9 사이에 존재하여 명부에 대해서 매핑이 0.9 ~ 1 가까이의 값으로
끌어올리지 못하고 있다는 데에 있다는 것을 알 수 있다.

명부 경계 픽셀 값이 약 239/255 = 0.93 인것을 보면 그 원인에 대한 파악이 가능하다.
따라서, lighting에 대한 값에 대해 offset을 추가함으로써 문제를 해결해 볼 수 있을 것이다.
half rangeMin = 0.5 +_ShadowOffset - _ShadowSmoothness;
half rangeMax = 0.5 + _ShadowOffset;
shadow = smoothstep(rangeMin, rangeMax, shadow);
0.5값은 휴리스틱하게 설정한 bound 값인데, 기본적으로 shadow가 램버트 라이팅이므로 0.5값이 그림자 경계 값임을 착안하여 0.5를 기준으로 값을 조정할 수 있도록 설정하였다.



이를 albedo에 대해서 곱하면 다음과 같다.
현재 albedo 의 피부색 * ramp color의 피부색이 곱해져서 피부색이 이중으로 곱해진 상태이다.
RampColor에 대해서 처리된 두번째 값에 대해서 명부에 대한 부분을
1의 값에 가깝에 만들어 주어 곱한 값이 원 피부색 값 또는 그 이상으로 만들어 줄 필요가 있다.
또한 경계에 대하여 terminator에서의 sss를 표현하기 위한 margin을 추가해 주어야 한다.
//range max 아래 범위는 0으로
half rangeMin = 0.5 +_ShadowOffset - _ShadowSmoothness;
//range max 위에 부분은 1로
half rangeMax = 0.5 + _ShadowOffset;
//range min ~ range max 사이 범위는 smoothstep -> 램버트 라이팅 밝기 값 보정
half2 rampUV = half2(smoothstep(rangeMin, rangeMax, shadow), (index / 10.0) + (0.5 * _IsDay) + 0.05);
half3 shadowRamp = SAMPLE_TEXTURE2D(_ShadowRamp, sampler_ShadowRamp, rampUV);
//명부에 대해서 shadow color multiply 적용을 위한 부분
half3 shadowColor = shadowRamp * lerp(_ShadowColor, 1.0, smoothstep(0.9,1.0, rampUV.x));
// 램프 색상은 이미 terminator 영역의 색상을 나타낸 색깔들
//위에서 영역으로 정한 rangeMax 부분에 대하여 기존 하프램버트 음영값보다 밝으면 1, 아니면 0으로
//밝게(1로) 날려 버리기
shadowColor = lerp(shadowColor, 1.0, step(rangeMax, shadow));
float4(shadowColor,1) * float4(albedo,1);


개선점



1. 현재 다리 부분에 대하여 y성분에 대한 빛 방향에 따른 경계값 문제와 예쁘지 않은 쉐이딩 결과가 나오고 있다.
2. 레퍼런스 모델 제작시 뒷날걔를 plane으로 만들어서 backface culling으로 인하여 앞에서 볼때 날개가 보이지 않는다.
1번 문제에 대하여
빛이 수직으로 내려올때 ( y성분의 비중이 클때) 다음과 같이 그림자 영역들이 깨지는 현상이 발생한다.
완벽히 제어하기는 어렵지만, 월드 전역 공간에서 태양빛이 캐릭터의 바로 위에 배치되지 않도록 사선으로 배치하거나 lightdirection에 대한 offset을 추가한다.
half3 LightDirection = SafeNormalize(mainLight.direction * _LightDirectionOffset.xyz);
2번 문제에 대하여
머터리얼에 cull option을 해제하여 normal 방향에 대한 front, back 모두 렌더링 하도록 설정한다.
하지만 해당 방법은 비용이 비싸므로 submesh에 대한 부분 영역에 대해서만 CULL OFF를 처리한다.
[Enum(UnityEngine.Rendering.CullMode)] _Cull("Cull", Float) = 2
Cull [_Cull]
유니티에서는 머터리얼에 ENUM으로 CULL 옵션을 설정 할 수 있도록 지원하며
0 : OFF
1: Front
2: Back
이다.
기본은 BackFace Culling이며
Plane의 경우 앞면과 뒷면이 모두 그려지도록 하려면 OFF로,
후면이 그려지도록 하려면 Front CULL mode 로 설정한다.
FRONT CULL은 이후 2PASS 방식으로 OUTLINE을 그릴때 구현한다.
한편, FACE CULLING 과정은
그래픽스 파이프라인에서 viewport -> rasterization 단계 사이에 처리된다.


'NPR Project Demo' 카테고리의 다른 글
| 04. Specular_[NPR Project] (0) | 2026.02.17 |
|---|---|
| 03. Normal Map_[NPR Project] (0) | 2026.02.11 |
| 01. Face SDF_[NPR Project] (0) | 2026.02.04 |
| 2025-12-25~2026-01-23 까지 진행한 것들 (0) | 2026.01.23 |
| NPR Project Demo (0) | 2026.01.15 |