TECHARTNOMAD | TECHARTFLOWIO.COM

(출간예정) 모두의 셰이더 프로그래밍

[원고 일부] 셀 스타일 얼굴 그림자

jplee 2025. 10. 13. 01:00
필자의 말
집필중인 유니티6 셰이더 프로그래밍 원고의 일부를 공유해보고자 한다. 수 개월동안 원고 집필을 할 수 없을 만큼 컨설팅 업무 압력이 강했다. 그리고 툰 셰이딩 장의 전반적인 내용이 바뀌게 될 것이기도 하다. 그 수 개월동안 컨설팅 업무를 진행 하면서 개선 된 점이나 필자 스스로 좀 더 깨우친 내용들 위주로 내용 전체가 거의 다시 써 질 것으로 보여지기 때문이다.

2025년 7월에 스톱 된 원고의 일부중에서…

9.12. 셀 스타일 얼굴 그림자

"Face Shadow Lightmap" 기법이란, 얼굴에 떨어지는 그림자를 섬세하게 표현하기 위해 제작된 커스텀 마스크 기반의 라이팅 보정 기법이다.
이 기법에 사용되는 리소스를 제작할 때는 "SDF(Signed Distance Field)"를 사용하여 변형과 합성 과정을 거친다. 이러한 이유로 종종 "SDF 그림자" 기법이라고 불리기도 하지만, 이는 엄밀히 말해 잘못된 용어 사용이다.
SDF를 활용한 리소스 제작은 준비 단계에만 국한되며, 실제 렌더링 단계에서는 SDF 기반의 실시간 계산이 전혀 이루어지지 않기 때문이다. 따라서 기술적인 정확성을 위해 이러한 명칭 사용에는 유의할 필요가 있다.
가장 처음 이 기법을 도입한 중국 게임 개발팀에서는 이를 "Face Shadow Lightmap"이라고 명명하였고, 서구권에서는 "Face Shadow Mask" 또는 "Stylized Shadow for Face"와 같은 표현이 자주 사용된다. 일본에서는 이를 "Anime-style Shadow Map"이라고 부르는 경우가 많다.
아직까지 이 기법에 대해 산업계에서 통일된 용어가 존재하지 않기 때문에, 본문에서는 최초 개발 팀의 명칭을 따라 "Face Shadow Lightmap"이라는 용어를 사용하였음을 독자에게 미리 양해를 구한다.
"Face Shadow Lightmap"은 렘브란트 라이팅(Rembrandt lighting)에서 영감을 받았다고 할 수 있다.
렘브란트 라이팅은 인물 사진이나 회화에서 자주 사용되는 고전적인 라이팅 기법으로, 얼굴에 뚜렷한 명암 대비를 형성하는 것이 특징이다. 특히 한쪽 뺨에 형성되는 역삼각형 형태의 밝은 영역은 이 기법의 대표적인 시각적 요소로, 인물의 입체감과 감정 표현을 극적으로 강조하는 데 활용된다.

[그림 97.램브란트 라이팅의 특징]
램브란트 라이팅 외에도, 얼굴 그림자의 형태에 따라, 전체에 빛을 받는 파라마운트(Paramount), 코를 중심으로 명암이 뚜렷한 스프릿(Split), 얼굴 중앙에 길게 빛을 받는 루프(Loop) 등의 다양한 기법들이 있다.

탑 라이트(Top Light)를 기반으로 한 그림자 윤곽은 감정을 강조하고 얼굴의 입체감을 단순화된 형태로 전달하기에 적합해 애니메이션이나 셀 셰이딩 스타일의 캐릭터에도 자주 응용되지만, 렌더링 엔진에서 실제 라이팅 계산만으로 구현하려고 하면 생각보다 까다롭다.
삼각형 음영이 자연스럽게 나오려면 물체의 표면이 스스로 그림자를 드리우는 효과와 자기 자신에게 빛이 가려지는 현상을 동시에 적용해야 하고, 라이트의 방향도 고정돼 있어야 하기 때문에  메시 구조도 꽤 정밀해야 원하는 모양이 나온다. 게다가 실시간 렌더링 환경에서는 그림자의 디테일이 잘 뭉개지거나 경계가 깨지기 쉽고, 저사양의 모바일 환경에서는 이런 문제가 더 두드러지고, 라이트가 조금만 움직이기라도 하면, 원래 의도한 모양이 그대로 유지되기 어려운 문제점도 있다.
그래서, 이런 얼굴 그림자를 직접 계산하기보다는 텍스처 마스크나 SDF 기반의 얼굴 그림자 맵( Face Shadow Lightmap )을 이용하는 경우가 많다. 이렇게 하면 결과도 깔끔하고, 라이팅이 바뀌어도 셰이딩 품질이 일정하게 유지된다.
“원신”,“하이파이 러쉬”, “우마무스메” 등의 게임에서 이 기법을 적극적으로 도입하였으며, 최근 국내 게임들 역시 이 방식을 점점 더 많이 도입하고 있다.

2018년도까지만 해도 셀 스타일 캐릭터의 얼굴 셰이딩 폴리싱 기법은 대다수의 게임개발 스튜디오들이 XRD 와 같은 기법들을 보편적으로 사용했다. XRD 기법이란 얼굴의 노멀 방향을 정렬하는 기법이라고 할 수 있는데 레퍼런스 메시의 노멀 방향에 대한 평균값을 가지고 캐릭터 얼굴의 노멀 방향을 다시 정렬하는 방법이라고 말할 수 있다.
[그림 98. 블루 프로토콜 테크컨퍼런스 공개 이미지(XRD 방법과 거의 동일한 기법 사용)]

"블루 프로토콜 테크컨퍼런스"에서 공개된 기법은 크게 두 가지 측면에서 다수의 시행착오와 폴리싱 단계를 필요로 한다.
첫째, 셰이딩 특성에 대한 충분한 이해를 바탕으로 면 분할 및 지오메트리의 꺾임(Edge Flow) 처리가 선행되어야 한다. 둘째, 정점 노멀의 방향 조정과 셰이딩 결과 간의 관계를 정밀하게 제어할 수 있어야 한다. 이러한 요소들은 단순한 자동화로 해결되기 어려우며, 특히 캐릭터의 얼굴처럼 시각적 집중도가 높은 영역에서는 아티스트의 숙련도가 품질에 큰 영향을 미친다. 셀 셰이딩 기반의 얼굴 그림자를 고품질로 구현하기 위해서는, 결과물의 물리적 정확도보다 시각적 의도에 부합하는 조정 능력이 요구된다. 따라서 이 기법은 어느 정도 이상의 셰이딩 지식과 모델링 경험이 결합된 아티스트가 아니라면 완성도 높은 결과를 달성하기 쉽지 않다.
2020년 하반기 이후, 미호요의 "원신"과 싸이게임즈의 "우마무스메"와 같은 셀 렌더링 기반 게임들이 출시되면서, 버티컬 피봇(Vertical Pivot) 기준의 얼굴 그림자 맵( Face Shadow Lightmap ) 기법이 셀 셰이딩 기반 캐릭터 렌더링에서 하나의 주류 방식으로 자리잡기 시작했다.
물론 모든 셀 셰이딩 프로젝트에서 이 기법이 채택되는 것은 아니다. 이 방식은 일관된 기준에 따라 인위적으로 설계된 그림자 형태를 적용하므로, 시각적으로 "딱 떨어지는" 명료한 셰이딩을 제공하는 장점이 있는 반면, 빛의 자연스러운 확산이나 유기적인 셰이딩 표현과는 거리가 있어, 미학적인 관점에서 다소 기계적이라는 비판을 받기도 한다.
그럼에도 불구하고 이러한 기법이 널리 사용되는 이유는 분명하다.
실시간 렌더링 환경에서는 캐릭터의 얼굴 셰이딩 품질이 사용자 경험에 결정적인 영향을 미치기 때문이다.
특히 미소녀 스타일의 셀 셰이딩 캐릭터에서는 얼굴의 구조적 입체감을 과도한 하드 셰이딩 없이도 깔끔하게 전달할 수 있는 수단으로서 높은 실용적 가치를 갖는다.
얼굴 그림자 맵 기법을 적용하기 위해서는 단순히 텍스처를 준비하는 것을 넘어, 일련의 전처리 및 프로세싱 단계를 거쳐야 한다. 이 과정은 일반적인 라이팅 기반 셰이딩과는 다른 성격을 가지며, 다음과 같은 핵심 절차를 포함한다.
첫 번째로 기준 라이팅 각도 정의를 위해 얼굴 정면을 기준으로, 수평 회전에 따른 주요 라이팅 방향(예: 180° → 0°)을 구간별로 나눈다.
두 번째로는 각 구간에 대응되는 그림자 형태를 수작업 또는 자동화 도구를 통해 SDF 기반으로 생성한다.
세 번째로 생성된 SDF 텍스처를 라이팅 방향에 따라 선택되거나 블렌딩될 수 있도록 정리한다.
마지막으로 셰이더에서의 런타임 적용을 수행해야 한다. 월드 공간 라이팅 벡터를 기준으로 적절한 그림자 마스크를 셰이더에서 적용하고, 피부색과의 혼합 정도를 제어하여 자연스러운 결과를 연출한다.

참고로 하이파이 러쉬의 GDC 2024 발표 내용은 “https://techartnomad.tistory.com/171”에서 볼 수 있다.

하지만 이러한 기법에는 표현의 한계가 있다. 카메라 연출이 동적이고 게임의 핵심이 네러티브 중심일 경우 매우 다양한 카메라 앵글이 존재하며 그보다 더 중요한 라이팅 표현이 있기 때문이다.이 절의 초입에서 언급했듯이 이 기법은 게임 스타일 제한적일 수 밖에 없기 때문에 하나의 테크닉으로 이해해 주길 바란다.

“8장 SDF 레이마칭(Ray-marching) 렌더링”에서 SDF 알고리즘에 대해 다뤘지만, 본 절에서는 SDF기법을 활용한 얼굴 그림자 SDF 텍스처 제작을 중심에 두고 설명하고 있으므로 이 점 역시 참고하기 바란다.

9.12.1. 얼굴 그림자 맵의 원리와 생성

아래 그림은 어두운 부분, 즉 셰이딩에서 그림자로 분류되는 영역을 버티컬 피봇(Vertical Pivot) 기준으로 여덟 단계로 구분하여 제작된 텍스처들을 보여준다.

[그림 99. 얼굴 그림자 맵 생성 과정]

첫 번째 행은 원본 텍스처이며, 두 번째  행은 SDF 방식으로 생성된 대응 텍스처들이다. 라이팅 각도는 얼굴 정면을 기준으로 각각 180°, 157.5°, 135°, 112.5°, 90°, 67.5°, 45°, 22.5°, 0° 순으로 설정되어 있으며, 라이트는 텍스처의 오른쪽에 위치한다. 라이팅은 텍스처의 왼편과 오른편 모두에 직접적인 영향을 주지 않으며, 최종 셰이더는 양쪽 라이트 조건 모두에 대응 가능하도록 설계했다.

눈치가 빠르다면 알아챘겠지만 180° 에 대응하는 텍스처는 첫 번째 행에는 존재하지 않는다. SDF 로 생성할 때 생성기등에서 직접 생성하거나 임의로 추가 텍스처를 만들어 준 경우다. 임의 텍스처 180 은 검정색 바탕의 텍스처에 귀 부분쪽에 4픽셀 정도의 흰색 사각형을 추가한 후 180.png 정도의 이름으로 저장하고 사용하면 된다.

얼굴 전체에 그림자가 거의 드리워지지 않는 상태를 표현할 때는 가장 오른쪽의 마지막 텍스처를 기준 레퍼런스로 활용한다. 얼굴 그림자 맵을 생성하는 방법은 아티스트가 포토샵 등에서 각도에 맞춰 그림자 맵들을 생성하고, 생성한 맵들을 툴을 사용해 한 장의 텍스처로 병합해야 한다.

9.12.2. 얼굴 그림자 맵 제작

다음 “그림 100”을 보면 얼굴 UV 가운데에 uv 엣지(Edge) 가 U 방향 0.5 좌표에 정확히 정렬되어 있는 것을 볼 수 있다.
포토샵 또는 섭스텐스 페이터 등에서 8장의 Vertical Pivot 기준의 각도 별 그림자 마스크를 제작하자.

[그림 100. 캐릭터 얼굴 UV]

실제 게임 제작 현장에서도 이 리소스를 만드는 방식은 회사마다 다소 차이가 있다. 예를 들어, 테크니컬 아티스트가 후디니(Houdini)로 자동화 도구를 만들어 쉽게 제작할 수 있게 하거나, DCC 소프트웨어에서 자체 스크립트를 개발해 팀 내부에서만 사용하는 방식으로 데이터를 생성하기도 한다.

[그림 101. 얼굴 그림자 맵의 시퀀스 Raw 데이터를 만드는 과정]

다만 저자는 조금 다른 입장이다. 반복적인 수정과 미세한 폴리싱이 필요한 텍스처일수록 결국은 사람 손으로 하나하나 직접 테스트하면서 다듬는 방식이 가장 높은 퀄리티에 도달할 수 있다고 생각한다. 마치 수제 제품이 더 비싸고 가치 있듯이, 이런 정성 들인 수작업이야말로 캐릭터의 생명력을 극대화할 수 있는 열쇠라고 생각한다.

9.12.3. SDF 텍스처 생성

필자가 제공하는 오픈소스 툴인 “SdfGenerate”는 어떤 과정을 거쳐 텍스처를 생성하는지 살펴보자. 특히 “sdfgenerate.shader”의 코드를 중심으로, 어떻게 동작하는지 확인할 수 있다.
먼저, 픽셀 주변의 공간 정보를 분석하여 경계와의 거리를 파악해야 한다.
이를 위해 각 픽셀을 기준으로 인접한 영역을 탐색하고, 이 과정에서 현재 픽셀과 서로 다른 상태(예: 내부 vs 외부)에 있는 픽셀들을 찾아, 이들 중 가장 가까운 위치에 있는 픽셀까지의 거리를 계산한다.
마지막으로, 현재 픽셀이 내부 상태에 있는 경우 해당 거리에 음수 부호를 적용하여, 경계 내부와 외부를 구분할 수 있도록 한다.
이러한 일련의 계산은 Signed Distance Field(SDF) 기법을 통해 구현되며, 결과적으로 각 픽셀에 부호가 있는 거리값이 기록된 텍스처가 생성된다.

const float range = _range;

const int iRange = int(range);

float halfRange = range / 2.0;

float2 startPosition = float2(i.uv.x - halfRange * _MainTex_TexelSize.x, i.uv.y - halfRange * _MainTex_TexelSize.y);

“_range” 변수는 각 픽셀에서 검색할 주변 영역의 크기를 결정하고, 현재 픽셀을 중심으로 하는 정사각형 영역을 검색하기 위해 시작 위치를 계산한다.

bool fragIsIn = isIn(uv);

“isIn()” 함수는 해당 위치의 픽셀이 내부(값이 0.5보다 큰 경우)인지 외부인지 확인해 결과를 반환한다.

float squaredDistanceToEdge = (halfRange* _MainTex_TexelSize.x*halfRange*_MainTex_TexelSize.y)*2.0;

최대 검색 거리의 제곱값으로 초기화한다. 제곱 거리를 사용하는 이유는 제곱근 계산이 비용이 높기 때문이다.\

for (int dx = 0; dx < iRange; dx++) 
{
		for (int dy = 0; dy < iRange; dy++) 
		{
			float2 scanPositionUV = startPosition + float2(dx * _MainTex_TexelSize.x, dy* _MainTex_TexelSize.y);
			bool scanIsIn = isIn(scanPositionUV / 1);
			if (scanIsIn != fragIsIn) 
			{
				float scanDistance = squaredDistanceBetween(i.uv, scanPositionUV);
				if (scanDistance < squaredDistanceToEdge) 
				{
					squaredDistanceToEdge = scanDistance;
				}
			}
		}
}

반복문을 통해 주변 픽셀을 모두 검사한다. 만약 현재 픽셀과 상태가 다른 픽셀을 발견하면(내부/외부 경계), 그 픽셀까지의 거리를 계산하고 가장 가까운 거리를 기록한다.
인게임 상에서 이러한 반복문을 자주 사용하는 것은 바람직하지 않지만 툴(Tool) 이나 에디터 상에서 생성을 위해 사용되는 반복문 셰이더는 큰 문제가 되지 않는다.

float normalised = squaredDistanceToEdge / ((halfRange * _MainTex_TexelSize.x*halfRange * _MainTex_TexelSize.y)*2.0);

float distanceToEdge = sqrt(normalised);

if (fragIsIn)

distanceToEdge = -distanceToEdge;

normalised = 0.5 - distanceToEdge;

픽셀 간의 거리를 계산할 때는 먼저 주변 픽셀들과의 제곱 거리를 구하고, 그 값을 0에서 1 사이로 정규화한다. 이렇게 하면 거리가 너무 크거나 작아서 생기는 문제를 줄일 수 있다.
그 다음엔 제곱근을 씌워 실제 거리 값으로 바꿔준다. 이때, 현재 픽셀이 도형의 안쪽에 있다면 그 거리에 음수를 붙여서 안과 밖을 구분할 수 있게 한다.
마지막으로는 전체 값을 0.5를 기준으로 다시 조정하는데, 이는 SDF(Signed Distance Field)에서 흔히 사용하는 방식이다. 이렇게 하면 픽셀마다 경계로부터의 거리를 직관적으로 표현할 수 있고, 이 값을 활용해 다양한 시각 효과를 만들 수 있다.

return float4(normalised, normalised, normalised, 1.0);

계산된 거리값을 RGB 채널에 동일하게 적용하여 그레이스케일 이미지로 출력한다.
이러한 과정이 완료되면 “SDF 텍스처”로 1차 변환되는데, 스크립트를 통해 RT 로 생성하게 된다.
코드와 함께 어떤 과정을 거쳐 생성되는지 살폈다. 두 가지 툴을 사용해 얼굴 그림자맵을 생성할 수 있다.

책에서 제공하는 오픈소스를 기반으로 한 유니티 스크립트를 사용한 생성 툴을 사용할 수 있다.

제공된 예제 중 “Chapter09” 프로젝트에 포함된 유니티 스크립트를 사용하여 SDF 텍스처를 만들어보자.
캐릭터가 있는 Scene에서 아래 그림과 같이 비어 있는 게임오브젝트를 하나 만들어 이름을 “SDF_GEN”으로 정한다,
그리고 그 아래에 빈 게임 오브젝트 아홉 개를 추가하고, 순서대로 “180”, “a”, “b”, “c”, “d”, “e”, “f”, “g”, “h”로 변경하자.

“a” 부터 “h” 외에 “180” 이라는 추가적인 요소가 더 들어간다.
[그림 102. SDF_GEN 게임 오브젝트 구조]

자식으로 추가된 아홉 개의 빈 오브젝트에 “SDF Generate” 스크립트를 모두 추가해 주자. 빈 오브젝트를 선택하고 “Add Component” 버튼을 클릭해서 “SDF Generate”를 검색한 후 추가하면 된다.

[그림 103. “SDF Generate” 컴포넌트 추가]

나머지 자식 오브젝트에도 모두 위의 “그림 103”과 같이 추가 해 주자.
아래 그림은 이름이 "b"인 오브젝트에 “SDF Generate” 컴포넌트가 추가된 상태를 보여준다. 먼저, 원본 텍스처 슬롯에 현재 오브젝트 이름과 동일한 이름의 텍스처를 드래그해서 넣어주자.

[그림 104. “SDF Generate” 속성 설정]

그 다음으로는 SDF 생성 셰이더 항목에 “sdfgenerate.shader” 파일을 드래그해 등록한다. 보통은 자동으로 등록되어 있는 경우도 있으니, 따로 건드리지 않아도 될 때도 있다.
SDF 확산 범위는 원본 텍스처 해상도가 2048이라면 기본값인 256으로 설정하면 적당하다. 참고로 출력용 텍스처의 해상도는 원본 텍스처를 기준으로 자동으로 설정되기 때문에 따로 조절할 필요는 없다.
생성된 SDF 텍스처는 원본 텍스처가 들어 있는 폴더 안쪽에 자동으로 저장되며, 이때 “Output”이라는 이름의 하위 폴더가 새로 만들어지고, 그 안에 “Sdf_”라는 접두사가 붙은 이름으로 저장된다.
원본과 비교하기 버튼으로 변경 전 원본과 변경 후 결과물 상태를 프리뷰로 볼 수 있다.
텍스처 원본 크기에 따라 SDF 확산 범위가 달라질 수 있으니 프리뷰를 확인할 수 있도록 인터페이스를 따로 구현했다.
이제 SDF 변환 된 결과를 한 장의 “Face Light Map” 텍스처로 병합하는 과정이 남았다.
다음 “그림 105”와 같이 “SDF_GEN” 부모 오브젝트를 선택하고 “Add Component” 로 “Sdf Texture Blender” 컴포넌트를 추가하자.

[그림 105. SDF_GEN 부모 게임 오브젝트 선택]

다음 “그림 106”은 “Sdf Texture Blender” 컴포넌트가 추가된 인스펙터 화면이다.
“Sdf Texture Blender” 컴포넌트가 추가되면 “SDF 블렌드 셰이더” 를 등록 해야 한다. “sdfblend.shader”를 찾아서 빈 슬롯에 드래그 앤 드롭하면 쉽게 등록이 가능하다. 그리고 “RGB 사용” 을 체크(On)하고 델타(부드러움) 값을 0.005 정도로 설정하자.

[그림 106. 한 장의 텍스처로 생성된 SDF 텍스처]

설정이 마무리 되면 “텍스처 블랜딩” 버튼을 눌러 병합 프로세스를 진행하면 프리뷰에 결과가 보일 것이다.
문제가 없다면 “결과 저장” 버튼을 눌러 한 장으로 병합된 텍스처를 저장한다.
참고로 저장된 텍스처는 “Output/Converted” 경로에 “Face_Sdf_lightmap” 라는 이름으로 생성된다.

9.12.4. 얼굴 그림자 라이트맵 적용

“FlatShading” 패스에서 얼굴 그림자(Face Shadow) 기능을 추가할 차례다.
다음의 코드와 같이 프로퍼티 블록에 속성을 추가하자.
그림자와 관계가 있기 때문에 “SHADOW SETTING” 속성 데코레이터에 추가했다.

Properties

{

~ 중간 생략 ~

[Header(FACE SHADOW SETTINGS)]

// 토글 UI 속성이다.

[ToggleUI] _IsFaceShadow("Face Shadow Enable", Float) = 0

// 얼굴이 바라보는 방향이다.

_FaceDirection("Face Direction", Vector) = (0,0,1,0)

// 얼굴 그림자 맵을 적용하기 위한 텍스처 속성이다.

_FaceShadowTex("Face Shadow Map", 2D) = "white" {}

~ 중간 생략 ~

}

셰이더에서 사용할 수 있도록 “_IsFaceShadow” 와 “_FaceDirection” 을 “CBUFFER” 블록에 추가하자.

CBUFFER_START(UnityPerMaterial)

~ 중간 생략 ~

// FlatShading Cbuffer

~ 중간 생략 ~

float4 _FaceDirection;

float  _IsFaceShadow;

~ 중간 생략 ~

CBUFFER_END

캐릭터의 “Face”에 해당하는 머티리얼을 선택 하고, 인스펙터에서 확인해 보자. 아래 그림과 같이 앞에서 생성한 얼굴 그림자 맵 “Face_Sdf_lightmap” 을 찾아서 적용한다.

[그림 107. SDF 라이트맵 할당]

“FlatShading” 패스의 “HLSLPROGRAM~ENDHLSL” 블록에 2D 텍스처 데이터를 생성하도록 매크로를 추가하자.

Pass

{

Name "FlatShading"

~ 중간 생략 ~

HLSLPROGRAM

~ 중간 생략 ~

TEXTURE2D(_FaceShadowTex);

~ 중간 생략 ~

ENDHLSL

}

별도의 샘플러 정의는 필요 없다. 타일링이나 오프셋을 사용하지 않는다면, 유니티가 기본 제공하는 sampler_LinearClamp 샘플러를 이용해도 무방하다.
제시된 예제 코드는 미호요가 공식 공개한 것이 아니므로, 인터넷 오픈 커뮤니티에서 자발적으로 분석한 내용을 기반으로 재구성한 것이다.
프래그먼트 셰이더 “Frag()” 함수에서는 입력된 텍스처 좌표를 사용해 “_LightMap”을 샘플링한 뒤, 각 픽셀의 월드 공간 노멀 벡터(normalWS)와 라이트 벡터(light.direction) 간의 내적(dot) 연산 결과로 밝기 값을 결정한다.
내적 결과에 따라 얼굴 그림자의 강도가 달라지므로, 이 부분을 주의 깊게 구현해야 한다.
아래와 같이 코드는 “_LightMap” 선언 바로 아래에 추가한다.

void Frag( Varyings input, out half4 outColor : SV_Target0 )

{

~ 중간 생략 ~

//------------------ FACE SHADOW

// 얼굴 방향과 라이트 방향을 수평 방향(xz 평면) 기준으로 정규화한다.

float3 faceDirection = SafeNormalize(float3(_FaceDirection.x, 0.0, _FaceDirection.z));

float3 normalizedLightDir = SafeNormalize(float3(light.direction.x, 0.0, light.direction.z));

// 얼굴 방향과 라이트 방향의 내적 및 외적 계산을 계산한다.

float faceDotLight = dot(faceDirection, normalizedLightDir);

float faceCrossLight = cross(faceDirection, normalizedLightDir).y;

// 라이트 방향에 따라 그림자 텍스처 좌표 반전한다.

float2 shadowUV = input.uv;

shadowUV.x = lerp(shadowUV.x, 1.0 - shadowUV.x, step(0.0, faceCrossLight));

// 그림자 텍스처에서 그림자 값 샘플링한다.

float faceLightMapValue = SAMPLE_TEXTURE2D(_FaceShadowTex, sampler_LinearClamp, shadowUV).r;

// 토글에 따라 얼굴 그림자 적용 여부 결정한다.

faceShadow = (_IsFaceShadow > 0.5)? faceShadow : 1.0;

~ 중간 생략 ~

}

코드를 꼼꼼하게 살펴본다면 이해할 수 있겠지만 쉽지 않은 독자들은 아래 “표 4”를 통해 크게 네 가지 단계 계산을 먼저 읽고 상세한 설명을 읽도록 한다.
단계 설명

1. XZ 평면 투영 및 정규화faceDirection과 light.direction에서 Y 성분을 제거한 후 normalize()로 단위 벡터로 정규화.
2. 내적·외적 계산dot(faceDirection, normalizedLightDir)로 faceDotLight, cross(faceDirection, normalizedLightDir).y로 faceCrossLight를 계산.
3. UV 좌표 반전 및 샘플링step(0.0, faceCrossLight) 결과를 lerp()에 적용해 shadowUV.x를 반전한 뒤 sampler_LinearClamp로 _FaceShadowTex를 샘플링.
4. 동적 임계값 비교 및 적용lerp(maxThreshold, minThreshold, faceDotLight)로 threshold를 계산하고 faceLightMapValue < threshold일 때만 그림자를 적용.

[표 4. 얼굴 그림자에 라이트 맵 적용 단계]
먼저, 얼굴 방향과 라이트 방향을 수평면(XZ 평면)에 투영한 후 정규화한다.
라이트 벡터인 "lightDirection" 역시 Y축 성분을 제거한 후 정규화하여, 두 벡터가 동일한 기준 위에서 비교 가능하도록 만든다.
이처럼 정규화된 두 벡터의 내적(dot product)은 라이팅이 얼굴을 정면에서 비추는 정도를 나타내며, 외적(cross product)의 Y 성분은 라이팅이 얼굴의 왼쪽에 위치하는지, 오른쪽에 위치하는지를 판단하는 기준이 된다.
라이트의 위치에 따라 텍스처 좌표의 X축을 반전시켜야 한다. 이는 얼굴의 좌우 방향에 따라 그림자의 위치도 달라져야 하기 때문이다.
좌우 반전 여부는 "step(0.0, faceCrossLight)" 함수의 반환값에 따라 결정된다. 이 함수는 라이트가 얼굴의 오른쪽에 있을 경우 1, 왼쪽에 있을 경우 0을 반환한다.
따라서 "lerp()" 함수를 이용하여 텍스처 좌표의 X값을 조건에 따라 반전시킨다. 이렇게 조정된 텍스처 좌표를 사용하여 "_FaceShadowTex" 텍스처로부터 밝기 값을 샘플링하게 된다.
이후, 샘플링된 밝기 값은 "임계값(threshold)"과 비교하여 그림자를 적용할지를 결정한다. 이 임계값은 고정된 상수가 아니라, 라이트와 얼굴 사이의 각도에 따라 동적으로 계산된다. 이 계산을 통해 라이팅이 얼굴 정면에 가까울수록 그림자는 옅어지고, 측면에 가까울수록 그림자가 짙어지는 효과를 구현할 수 있다.
앞 절에서 포토샵의 "한계값(threshold) 레이어"를 활용해 시뮬레이션했던 방식과 유사하다.
단, 여기서 주의할 점은 "faceDotLight"와 "faceCrossLight" 계산에 사용되는 "faceDirection"은 씬에 있는 캐릭터의 "Bip001 Head"의 벡터를 이용해서 계산한 "Face Vector" 스크립트를 통해 전달받는 값이다. 아래 내용에서 어떻게 값을 받아오는지 살펴보자.

“Bip001 Head” 는 3D Studio Max 에서 사용하는 “Biped” 로 캐릭터 리깅을 했을 경우이며 Maya 또는 Blender 를 사용했다면 제작자가 정한 임의 이름일 수 있다. 핵심은 캐릭터 머리 메시와 스키닝 된 “Bone” 이라는 점이다.

제공하는 예제 중 “Chapter09” 프로젝트를 올바르게 다운 받았다면, 저자가 작성한 “Face Vector” 컴포넌트를 사용할 수 있다. 캐릭터 오브젝트의 루트(Root)를 선택하고 “Add Component” 버튼으로 “Face Vector” 컴포넌트를 검색하여 추가하자.

[그림 108. FaceVector 컴포넌트 추가]

“Face Vector” 컴포넌트의 핵심역할은 “LateUpdate()” 함수 부분이다.
“LateUpdate()” 함수에서 “direction” 변수에 “bipHeadDirection”이라는 로컬 기준 벡터에 “bipHead”의 회전을 곱해, 현재 머리 본이 바라보는 월드 기준 방향을 계산한다. 이렇게 얻은 벡터는 캐릭터의 얼굴이 어느 쪽을 향하고 있는지를 대입하고 매 프레임마다 갱신한다.

Vector3 direction = bipHead.transform.rotation * bipHeadDirection;

“SetVector()” 함수를 통해 “FaceDirection”이라는 셰이더 속성에 “direction” 벡터를 전달한다. 이를 통해 셰이더는 매 프레임마다 정확한 얼굴 방향을 받아, 라이팅과의 상대적 방향성에 따른 그림자 처리에 반영할 수 있게 된다.

//material.SetVector(FaceDirection, direction);

private void UpdateFaceMaterialDirections()
        {
            // 필수 참조 체크
            if (faceMesh == null || faceMesh.Count == 0 || bipHead == null)
                return;

            // 헤드 트랜스폼이 유효한지 확인
            var headTransform = bipHead.transform;
            Vector3 faceFrontDirection = (headTransform != null)
                ? headTransform.rotation * bipHeadDirection
                : transform.forward;

            foreach (var renderer in faceMesh)
            {
                if (renderer == null)
                    continue;

                // 실행 중에는 materials, 에디터에서는 sharedMaterials 사용
                Material[] materials = Application.isPlaying
                    ? renderer.materials
                    : renderer.sharedMaterials;

                if (materials == null || materials.Length == 0)
                    continue;

                foreach (var mat in materials)
                {
                    if (mat == null)
                        continue;

                    mat.SetVector(FaceDirectionFront, faceFrontDirection);
                }
            }
        }
런타임 게임에서는 ShaderedMaterial 을 사용해야 한다.

“Face Vector” 컴포넌트의 “Head Bone” 은 “Bip001 Head”를 등록했고, “Face Meshes” 배열속성을 하나 추가하고 예제로 사용하고 있는 “Bell”캐릭터의 “Face” 메시를 드레그 앤 드롭하여 적용했다.
직접 제작한 경우 이름이 다를 수 있다. 책과 다른 이름에 당황하지 말고 “Head Bone”에는 머리를 컨트롤 하는 “Bone” 을 찾아 “Head Bone” 속성에 적용하자.

[그림 109. “FaceVector” 컴포넌트의 “HeadBone”과 메시 속성 설정]

정상적으로 설정이 마무리 되었다면, 캐릭터의 루트(Root)를 선택하자.
그러면, 아래 “그림 110”과 같이 “Face Direction” 이라는 기즈모가 씬 화면에서 보인다.

[그림 110. “Face Direction” 기즈모 출력]


캐릭터의 Head bone 을 좌우로 회전 켰을 때 "Face Direction" 기즈모가 그림 111 처럼 같이 회전해야 합니다.

[그림 111. 머리에 회전 적용]
다음 그림과 같이 좌표계의 Z축 방향을 기준으로 작성된 예제는 “FaceDirection” Z값이 캐릭터 기준으로 보면 마이너스로 되어 있다.
[그림 112. 유니티 씬 뷰에서 좌표계를 기준으로 기즈모의 Z축 방향을 확인하자]

캐릭터 머티리얼을 선택하고 인스펙터에서 다음 그림과 같이 “Z” 값을 “-1”로 바꿔줘야 정상적으로 렌더링 될 것이다. 만약 독자분들 본인의 캐릭터가 바라보는 방향과 좌표계 기즈모의 “Z” 축이 일치하다면 “Face Direction”의 “Z”값을 마이너스로 바꿔주지 않아야 한다.

[그림 113. FaceDirection 설정]

작업 환경에 따라서 차이가 있으니 유념하자.

이제 “Face Shadow” 결과를 프래그먼트 셰이더 “Frag()” 함수에서 반환하는 “Color” 와 합쳐야 한다.예제로 작성된 셰이더에서는 의도적으로 “ShaderFeature” 키워드를 사용하지 않았다. 그렇기 때문에 삼항 연산자를 사용해서, “color.rgb” 부분에 추가적인 코드가 필요하다.

void Frag( Varyings input, out half4 outColor : SV_Target0 )

{

~ 중간 생략 ~

// 주석 처리

// color.rgb = DiffuseMap.rgb;

// “faceShadow” 값을 기준으로 얼굴 그림자 색을 적용한다.

float3 IsFaceShadowValue = lerp(_ShadowColor.rgb, _DiffuseColor.rgb, faceShadow);

// “RampColor”와 그림자 감쇠값으로 실시간 그림자 색을 적용한다.

float3 IsRealTimeShadowValue = lerp(_ShadowColor.rgb, _DiffuseColor.rgb, RampColor * shadowAttenuation);

// 토글(_IsFaceShadow)에 따라 적용할 그림자를 적용한다.

color.rgb *= (_IsFaceShadow > 0.5)? IsFaceShadowValue : IsRealTimeShadowValue;

}

“color.rgb” 에 “_IsFaceShadow” 조건을 사용하여 조건 값이 1일 때는 “IsFaceShadowValue”, 0일때는 “IsRealTimeShadowValue”가 대입되도록 하였다.
다음 “그림 114”는 “IsRealTimeShadowValue” 토글을 체크해서 활성화하고, “Directional Light” 의 회전 Y(Rotation Y) 값이 85 일때의 결과이다.

[그림 114. “IsRealTimeShadowValue” 을 1로 설정한 결과]

“그림 115”는 “Directional Light”에 각각의 다른 Y 회전 값을 적용한 결과이다.

[그림 115. 라이트의 Y축 회전 방향에 따른 결과]

집필 중에서 발췌…