저자: 秋大叔
잡담
지난 글 발행 시점을 슬쩍 봤더니 어느새 반년 넘게 새 글을 못 올렸네요
졸업 압박 때문에 다른 분야 연구에 많은 시간과 에너지를 쏟아야 했고, 그래서 TA 렌더링 학습을 한동안 미뤄둘 수밖에 없었습니다
다행히 최근에야 일이 어느 정도 일단락되어서, 앞서 약속드렸던 기술들을 튜토리얼로 정리해 다시 여러분께 풀어보려 합니다
이번 글은 앞서 다뤘던 미호요 붕괴: 스타레일 카툰 렌더링의 후속 심화 편입니다. 원래 계획은 극도로 일본풍 셀룩 스타일인 미호요식 카툰 렌더링을 마무리한 뒤, 소녀전선 2: 망명(소전2)의 PBR 스타일 카툰 렌더링으로 자연스럽게 이어가는 것이었습니다. 그런데 저는 명일방주 팬이다 보니, Arknights: Endfield(终末地) 출시 직후 곧바로 분해해서 렌더링을 연구하고 복각해봤습니다. Endfield 역시 PBR 카툰 렌더링 스타일이고 소전2와 닮은 부분도 많아서, 결국 두 번째 카툰 렌더링 편을 Endfield 효과 복각 튜토리얼로 바꾸게 되었습니다
독자분들은 이 글의 내용을 단백톤(蛋白胨) 선배의 소전2 렌더링 튜토리얼과 비교하면서 학습하면, 머릿속에 더 탄탄한 캐릭터 카툰 렌더링 방안 체계를 구축하고 두 가지 서로 다른 PBR 기법이 만들어내는 직관적인 화면 효과의 차이를 이해하실 수 있습니다 (사실 본질적으로 제 소전2 복각에는 기초적인 문제가 좀 있는데, 글이 이미 거의 끝나가서 처음부터 고치고 싶진 않네요 ㅎㅎ)
구체적인 설명에 들어가기 전에, 늘 그렇듯 제 의도를 한 번 짚고 가겠습니다. 이전 글들과 마찬가지로, 독자분들이 이 글을 읽고 전체 구현에 대해 종합적인 이해를 갖길 바라며, 가능하면 0부터 따라 하면서 동일한 효과를 만들어낼 수 있길 바랍니다
그래서 제 글은 상대적으로 길어지고, 입문하신 분들도 기술 문서를 보면서 한 단계씩 복각해내며 그 과정에서 배우고 성장할 수 있도록 최대한 노력하고 있습니다
마찬가지로 뒤에 코드 저장소도 공개하겠지만, 아트 리소스는 일절 포함하지 않고 순수한 셰이더만 들어 있습니다. 완성된 셰이더를 직접 보면서 학습·이해하시길 권합니다. 물론 요즘 가장 효율적인 방법은 이 블로그와 오픈소스 저장소를 그대로 코드 에이전트에 넘겨서 분석·재현해달라고 하는 것일 수도 있겠네요. 극강의 학습 효율을 체험해보세요
이번 Endfield 카툰 렌더링 재현은 다른 글이나 타인의 구현을 그렇게 많이 참고하지 않았고, 본질적으로 제 자신의 역방향 분석 연구 과정에 가깝습니다
모종의 신비한 프레임 캡처 툴과 Gemini의 신묘한 도움을 결합해 게임의 전체 셰이더를 재현하려 시도했고, 그래서 시간도 꽤 들였습니다
미리 말씀드리면, 글에는 여전히 원작 게임 효과와 차이가 나는 부분이 적지 않습니다. 모든 부분을 완전히 파악할 수는 없었고, AI의 보조 분석에 제 기존 경험을 더해 만들어낸 복각 결과가 아래입니다. 더 자세한 영상은 빌리빌리에서 동명 ID로 검색해 보시면 됩니다
구체적인 기술 방안 요약 목차는 다음 장에서 설명드리겠지만, 미리 말씀드리면 다음 부분들은 중점적으로 구현하지 않았습니다. 추후 제 코드 위에서 이를 보완해주신다면 정말 좋겠습니다
- 양털 술 치맛단 렌더링. 나름대로 이해하고 연구해서 복각해봤지만 핵심을 짚지 못해 효과 차이가 큽니다. 영상의 치맛단에서도 보이듯이요. 그리고 불꽃 호흡 변화 이펙트도 연구하지 못했습니다
- 외곽선 셰이딩 로직. 외곽선 확장은 여전히 스무스 노멀 기반이지만 셰이딩에는 자체적인 규칙이 있습니다. 제가 해석한 바로는 일반적인 PBR 카툰 렌더링 로직인데, 최종 결과는 게임과 차이가 있어서 아마 트릭이 더해진 듯합니다
- 다중 광원 셰이딩 로직은 다루지 않았습니다. 게임 내 다중 광원 효과를 관찰·분석하지도 않았습니다
또 효과 비교에서 보이는 셰이딩상의 원작과의 차이는 셰이더 코드의 완성도, 라이팅 설정, 포스트프로세싱 선택 등 다양한 요인에서 비롯되었을 수 있습니다
이 글의 복각이 별로라고 생각되신다면, 음, 다음에 더 깊이 연구하도록 노력해보겠습니다. 카툰 렌더링은 일단 여기까지 연구할 예정입니다
잡담은 여기까지 마치고, 마지막으로 오픈소스 저장소 링크를 남기고 본격적으로 시작하겠습니다
GitHub - qiudashu233/MyZmdShaders: 终末地渲染复刻shader仓库,具体内容参考个人知乎博客https://www.zhihu.com
终末地渲染复刻shader仓库,具体内容参考个人知乎博客https://www.zhihu.com/people/qiu-da-shu-64 - qiudashu233/MyZmdShaders
github.com
목차
늘 하던 대로, 먼저 목차부터 쓰겠습니다. 기술을 배울 때 전체를 조망하는 것이 매우 중요하니까요
전체 구조
- 캐릭터 모델 리소스 텍스처 분석 및 정리
- 의상 toon base shader
- zmd toon shader 특징 분석, 밝음/어두움 두 가지 셰이딩 상태
- 상태에 따른 주 광원 변화
- 3단 계조 변화 + 역광 보상을 적용한 toon 디퓨즈
- 카메라 방향 기반 스타일라이즈된 PBR 스페큘러
- 수학 함수 근사로 시뮬레이션한 IBL 스페큘러
- 프레넬 기반 림라이트 + 라이팅 기반 림라이트
- 다양한 머티리얼 상황에서의 트릭, 투명 머티리얼과 특수 오브젝트 머티리얼
- 피부 skin toon shader
- toon base 위에서의 셰이딩 로직, IBL 스페큘러 제거
- LUT 맵 기반의 통일된 피부 암부 컬러 취득
- 프레넬 기반 SSS 투과광 효과
- 얼굴 face toon shader
- SDF 맵 기반의 라이팅 변화 구현 방식
- toon base 위에서의 셰이딩 로직, IBL 스페큘러·일반 림라이트 제거
- 표정 텍스처 트릭, 얼굴·목 이음지점 스무스 트릭, 림라이트 커스터마이징 트릭
- 눈 eye toon shader
- 눈 전용 특수 노멀 기반의 디퓨즈
- 매트캡 기반의 눈 스페큘러
- 나머지 부위는 toon base 디퓨즈 셰이딩을 그대로 사용
- 머리카락 hair toon shader
- 이방성 카툰 스페큘러 구현, 스무스 노멀·스페큘러 컬러 맵 기반
- toon base 위에서의 셰이딩 로직, IBL 스페큘러·일반 스페큘러 제거
- 외곽선 outline shader
- 스무스 노멀 기반 외곽 확장
- 털 마감 치맛단 fur shader
- 멀티 패스 인스턴스 렌더링, 셰이프 맵 기반
- 노이즈 맵 기반의 불꽃 이펙트
- 그 외 다양한 트릭
- 앞머리 그림자, 오프셋 기반의 앞머리 모델
- 눈 그림자
- 포스트프로세싱
- LUT 컬러그레이딩 + 블룸 + 안티에일리어싱
전체 기술 아키텍처는 위 목차 그대로고, 이후 설명도 이 흐름에 따라 진행하겠습니다
이 글은 결국 개인의 복각 구현일 뿐이라 많은 부분이 경험에 의거한 추측입니다. 이 점 양해 부탁드립니다
리소스 분석 및 정리
리소스 취득 방식과 처리 프로세스는 여기서 다루지 않습니다. 리소스 추출 및 모델·텍스처의 표준화는 독자분들께서 직접 해결해 주세요
잘 모르시겠다면, 제 이전 카툰 렌더링 글들을 참고하거나 다른 분들의 글을 찾아보세요
여기서는 이후 렌더링 파이프라인에서 반드시 필요한 아트 리소스와 그 용도를 설명하고, 이후 구현 과정에서는 이 텍스처들을 다시 언급하지 않을 예정입니다. 여러분이 이 리소스들의 존재를 이미 알고 있다고 전제합니다
먼저 toon base shader에서는 albedo, normal, 그리고 머티리얼 속성 텍스처 한 장이 필요합니다. Endfield의 어린 양(小羊) 캐릭터의 의상 부위를 예로 들면

머티리얼 속성 텍스처 예시
R 채널은 metallic, G는 reflectivity, B는 AO, A는 smoothness입니다. 거칠기(roughness)는 반대로 들어 있으니 주의하세요
albedo의 A 채널이 존재하는지 꼭 확인하세요. 컷링과 반투명 머티리얼 구현과 관련이 있습니다
또한 부위별로 고유의 램프(ramp) 그라디언트 맵이 존재합니다. 이 캐릭터의 셰이딩에서 의상에는 부위별로 서로 다른 램프 컬러맵이 3장, 피부와 얼굴은 램프 1장을 공유하고, 머리카락 1장, 털 마감 1장이 있습니다
미호요·소전2처럼 여러 램프를 이어 붙여 ID로 읽는 방식은 쓰지 않아서, 램프를 모두 확보했는지 수동으로 확인해야 합니다

의상에 쓰이는 3장의 램프 맵 예시
구현할 때 램프 맵을 잘못 지정하지만 않으면 됩니다. 또 zmd 램프 맵의 RGB는 컬러 매핑, A 채널은 NoL 매핑이라 커스터마이징된 라이팅 명암 분포를 얻을 수 있습니다.

램프 맵의 A 채널
또 toon base에는 이미션(자발광) 텍스처가 있습니다

부위별로 3장의 서로 다른 이미션이 있고, 특정 부위의 아트 효과 보완용입니다. 텍스처는 식별하기 쉬우며, 검은 바탕 가운데에 컬러가 있다면 이미션 텍스처입니다
환경 텍스처는 큐브맵 환경 텍스처 1장이면 충분합니다. 게임 내 환경 텍스처를 그대로 써도 되고요. 여러 곳에 재사용되는 환경 텍스처로, 소전2에서도 본 적 있습니다
동시에 스페큘러 컬러 매핑 텍스처도 필요합니다. 램프와는 로직이 달라 IBL 사전 적분 텍스처의 샘플링 방식과 같습니다. 스페큘러 BRDF 매핑 결과를 미리 사전 적분해둔 텍스처로, 특수 오브젝트의 F0 컬러를 조정하는 용도입니다.

다음은 skin shader입니다. LUT 컬러그레이딩 맵 한 장이 필요합니다

face shader에서는 SDF 매직 맵과 영역 제한 맵 각 1장이 필요합니다. 그 안에 트릭 디자인들이 숨겨져 있습니다


그리고 4칸 구조로 나뉘어진 표정 텍스처 한 장도 필요합니다

eye shader에는 matcap 텍스처가 필요합니다. 이 글을 읽으실 정도의 독자분들이라면 matcap의 특징을 이미 아실 테니 이미지는 생략하겠습니다
hair shader에는 스페큘러 컬러 매핑 텍스처가 있다는 점을 주의하세요.

전체 파이프라인에서 조금 특이한 텍스처는 대략 모두 예시로 보여드렸습니다. 리소스 추출 중 이런 텍스처를 만나면 잘 저장하고 표시해두세요
털 마감 셰이더에서 쓰이는 노이즈 텍스처는 매우 흔하고 식별이 쉬워 한눈에 용도를 알 수 있으므로 따로 설명하지 않겠습니다
모델 리소스에서는 스무스 노멀(smooth normal)이 UV에 저장되어 있다는 점만 주의하면 됩니다

스무스 노멀 텍스처를 UV에 저장해둔 것과 비슷한 느낌입니다. 이 로직은 프레임 캡처 데이터를 분석해 도출한 결론이고, 실제 게임의 스무스 로직은 이렇지 않을 수도 있지만, 제가 테스트해본 결과 실제로 제대로 작동했습니다
toon base shader
모델·텍스처 리소스가 대략 확보되었다면, 이제 본격적인 구현을 시작할 수 있습니다
특징 분석
구현에 들어가기 전에, 먼저 게임 내 효과를 한 번 분석해보겠습니다. 이후 모든 셰이딩 로직에 관계되는 핵심 설정이 여기서 나옵니다
무릭(武陵)의 라이팅이 가장 좋은 위치에 가서 잠시 머물며 관찰해봅니다.

보시면 환경에서 주 광원인 태양이 계속 고정되어 있지 않고, 강도가 시간에 따라 변하는 것을 확인할 수 있습니다. 영향 요인은 하늘의 구름, 현재 날씨 등과 관련이 있을 수 있겠죠
캐릭터 몸의 셰이딩 변화를 중점적으로 보면, 전체적으로 어두워지는 경향을 보입니다. 이런 어두움 효과는 그냥 주 광원의 강도를 동적으로 조절하면 되는 거 아닌가 싶으실 수도 있습니다
이제 다른 위치에서도 한번 관찰해봅시다

사실 구현 레벨에서는 충분히 가능합니다. 날씨와 캐릭터가 놓인 위치에 따라 태양광 강도를 동적으로 조절하면 되죠. 하지만 세부 디테일이 떨어집니다
예를 들어 주 광원 방향의 강한 분포 영향이 여전히 느껴집니다. 전체 강도만 감소했을 뿐, 라이팅 영향 범위는 그대로 유지되기 때문입니다
하지만 캐릭터가 방구석 위치, 예를 들어 세 번째 이미지처럼 벽 모서리에 있을 때는, 이런 강한 주 광원 방향의 유도 효과는 존재하지 않아야 자연스럽습니다. 캐릭터가 대부분 주변의 부차 광원들에 의해 밝혀지고 있기 때문이죠
이런 상황에서는 주 광원 관련 림라이트를 사라지게 하고, 주 광원이 직접 비췼어야만 나타나는 효과들을 약화하거나 제거해야 합니다. 그래서 우리는 두 가지 셰이딩 상태를 구분해서, 태양이 직접 비춰주는 일광(주 광원)이 우리에게 직접적으로 주는 영향을 결정해야 합니다
이걸 “일광 직사 강도(daylight strength)”이라고 부르겠습니다 (이름은 아무래도 상관없고, 의미만 전달되면 됩니다)
영상에서 두 상태 간 전환을 볼 수 있는데, 보통 최저값까지 낮추지는 않고 상황에 따라 조절합니다
두 상태가 등장하므로, 이후 구현에서는 항상 두 상태에 대해 다르게 처리해야 하는지를 의식해야 합니다
예를 들어 toon base shader의 디퓨즈 부분은 일광 직사 강도 1일 때 이렇게 계산하고, 0일 때는 다른 로직으로 계산합니다. 후자에서는 주 광원의 직접 조명이 있어야만 나타나는 영향들을 제거하거나 약화합니다. 예를 들면 NoL을 약화하는 식이죠

일광 직사 강도 변화에 따른 영향
대형 오픈월드처럼 장면과 라이팅 변화가 복잡한 환경에서는, 이런 상태 기반 설정이 광원 강도를 실시간으로 조절하는 방식보다 더 정확하고, 동시에 디테일도 더 풍부해집니다
전처리 데이터 준비
이제 본격적으로 렌더링 구현 프로세스에 들어갑니다. 제목에서 보이듯이, 이 글의 모든 구현은 Unity URP에서 이루어지며 단백톤 선배의 프레임워크 위에서 진행됩니다. 먼저 단백톤 선배께 감사의 말씀을 드립니다. 우수하고 성숙한 카툰 렌더링 파이프라인을 공유해 주신 덕에 이 글이 존재합니다
세부 코드 디테일은 설명에서 생략될 수 있으므로, 공유된 완성된 코드를 대조해 가면서 이해하시길 권합니다. 그래도 0부터 1까지 차근차근 쌓아가는 흐름으로 설명해드리겠습니다
// 기본 텍스처 리소스 취득
float4 mainTex_var = SAMPLE_TEXTURE2D(_AlbedoTex, sampler_AlbedoTex, i.uv0);
float4 ormTex_var = SAMPLE_TEXTURE2D(_OrmTex, sampler_OrmTex, i.uv0);
float4 normalTex_var = SAMPLE_TEXTURE2D(_NormalTex, sampler_NormalTex, i.uv0);
ormTex_var = lerp(float4(0,1,1,0),ormTex_var,_IsNeedOrmTex);
// 광원 속성 취득
float3 mainLightDir = _MainLightPosition;
// xz 평면 광원 dir 취득
float3 mainLightDir_xz = normalize(float3(mainLightDir.x, 6.10351562e-05, mainLightDir.z));
// normal 처리
float3x3 tangentTransform = float3x3( i.tangenWS, i.bitangenWS, i.normalWS); // TBN 행렬
float3 normalTex_processed = UnpackNormalFromTex(normalTex_var, _BumpScale);
float3 normalWS = lerp( i.normalWS ,normalize(mul(normalTex_processed, tangentTransform)), _IsNeedNormalMap);
// 면 방향
float facing = isFrontFace ? 1.0 : -1.0;
normalWS = normalWS * facing;
// view dir
float3 viewDir = normalize(_WorldSpaceCameraPos.xyz-i.posWS.xyz);
// 카메라 forward dir
float3 cameraForward = UNITY_MATRIX_V[2].xyz;
cameraForward = normalize(cameraForward);
viewDir = lerp(viewDir, cameraForward, _ForwardDirStrength);
viewDir = normalize(viewDir);
구현에 들어가기 전에, 필요한 기본 속성들을 먼저 계산해둡니다. 코드는 직관적이고, light dir·normal·view dir 같은 일반적인 값들입니다
조금 특별한 것은 camera forward dir를 추가로 확보해야 한다는 점입니다. 이 카메라 방향은 이후 여러 번 사용되므로, 직접 출력해 확인해봐도 좋습니다. light dir과 마찬가지로 항상 현재 픽셀에서 바깥으로 향한다는 점을 주의하세요. 카메라에서 캐릭터를 향하는 방향이 아닙니다
광원 계산
Endfield의 캐릭터 렌더링에는 광원에 특이한 트릭 디자인이 들어 있습니다. 캐릭터 머리 위에 올라이트(top light) 하나가 추가로 설치되어 있다고 볼 수 있죠
주 광원(태양광)을 mainLight로, 올라이트 효과를 otherLight로 정의하겠습니다
게임 내에서 올라이트를 관찰하고 싶다면, 어린 양(小羊) 캐릭터가 좋은 관찰 대상입니다

게임에서 위에서 아래로 비춰야만 나타나는 라이팅 분포가 두드러지게 보이지만, 바닥의 그림자를 보면 주 광원은 분명히 캐릭터 앞의 왼쪽 위치에 있다는 것을 알 수 있습니다
주 광원(mainLight)의 라이팅 분포는 대략 이렇습니다

분포상 명확한 차이가 보이죠. 광원 방안을 확정했으니 광원 관련 계산을 구현해봅시다
앞서 mainLight의 light dir은 확보했으니, 이제 강도·컬러와 라이팅 분포 NoL을 구해봅니다
// 광원 컬러 취득
float3 mainLightColor = _MainLightColor;
float mainLightIntensity = max(0.001, (0.299 * mainLightColor.r + 0.587 * mainLightColor.g + 0.114 * mainLightColor.b));
mainLightColor = mainLightColor / mainLightIntensity;
float NoL = dot(normalWS, mainLightDir);
NoL의 대략적인 효과는 아래와 같습니다

주 광원 속성은 이걸로 계산 완료, 다음으로 올라이트 otherLight를 처리합니다
// 머리 위 보조광(otherLight) 계산
float3 otherLightDir = float3(0,1,0);
float otherLightNoL = dot(otherLightDir, normalWS);
otherLightNoL = saturate(otherLightNoL + _OtherLightOffset);
otherLightNoL = otherLightNoL * _OtherLightStrength + _OtherLightStrength_Offset;
float3 otherLightColor = _OtherLightColor;
float3 otherLightResult = otherLightColor * otherLightNoL;
올라이트이므로 dir은 (0, 1, 0)입니다. 주관적으로 설계된 광원이므로 NoL에 수동 조절을 가하고, 컬러도 사용자 정의 파라미터로 두었습니다
올라이트 NoL 분포는 아래와 같습니다.

원작 게임 효과에 맞춰 전체 분포 강도를 다소 조정했는데, NoL*0.5+0.5에 가까운 분포입니다. 이차원(만화풍) 스타일에서는 그렇게 딱딱한 라이팅 분포가 필요 없기 때문에 수동으로 조정해야 하고, 앞서 구한 mainLight의 NoL도 이후 계산에서 비슷한 처리를 거칩니다
코드 마지막에서 otherLight의 result를 color × NoL로 정의했습니다. NoL 항은 원래 셰이딩 렌더링 방정식에서 등장해야 하는데, 여기서 미리 적용한 이유는 mainLight의 NoL이 이후 램프 맵을 거치며 특수 매핑되기 때문입니다. 이 otherLightResult를 이렇게 구현한 이유는 다음 절 디퓨즈에서 설명하겠습니다
앞서 언급한 zmd 셰이딩의 특징, 일광 직사 강도 1과 0에 대해 서로 다른 두 상태를 설계해야 한다는 점을 광원에서도 반영해야 합니다. 일광 직사 강도 0일 때는 주 광원(mainLight)을 제거해야 합니다. 타당한 조치이죠. 대신 머리 위 올라이트 영역은 일광 직사 강도에 따라 어떻게 변해야 할까요? 1일 때는 너무 강해서는 안 되고 본질적으로 보조적 역할이니, 영화의 캐릭터 라이팅처럼 조명을 조금 더해 캐릭터를 입체감 있고 예쁘게 만듭니다.
0일 때는 주 광원 영향이 거의 0이지만 캐릭터가 완전히 어두워지면 곤란하니, 환경광 하나만으로는 부족합니다. 복잡한 전역 GI도 사용하지 않으니, 올라이트가 셰이딩의 주체를 담당하도록 합니다. 최종 광원 구현은 아래와 같습니다
// 일광 강도 두 임계값에서의 보조광 결과 계산
float3 otherLightResult_day1 = otherLightResult * _OtherLightResultStrength_day1;
float3 otherLightResult_day0 = otherLightResult * _OtherLightResultStrength_day0;
// 주광과 보조광을 합성해 최종 주 광원을 도출
float3 mainLightColor_final = lerp(otherLightResult_day0, mainLightColor + otherLightResult_day1, _DayStrength);
여기서는 광원 강도를 분리해 관리했는데, 강도를 color 안에 녹여도 무방합니다
코드에서 보이듯, 올라이트에 두 상태의 강도 제어 파라미터를 둬 따로 조절할 수 있으며, 일광 직사 강도 1일 때는 주광 + 올라이트 강도 억제 결과, 0일 때는 주광을 제거한 올라이트만의 결과를 가집니다
앞서 분석한 아이디어 그대로입니다. 구체적인 광원 효과는 다음 절의 디퓨즈에서 관찰해보겠습니다
디퓨즈 구현
zmd의 디퓨즈 구현은 본질적으로 제가 앞서 쓴 카툰 렌더링 글들의 로직과 동일합니다. 핵심은 렌더링 방정식의 BRDF를 수정해 NoL이라는 라이팅 분포 항을 단순한 강도 변화가 아니라 컬러 변화로 바꾸는 것입니다
전통적인 렌더링 방정식 흐름에 따라 디퓨즈 구현을 살펴봅시다
light × brdf × NoL × 기타 항(shadow, ao 등)
light 부분은 앞서 계산했고, 나머지 세 부분이 남았습니다. 먼저 shadow와 ao를 따로 계산해두겠습니다
// shadow 취득
float shadowAttenuation = 1;
float2 screenUV = GetNormalizedScreenSpaceUV(i.pos.xy);
shadowAttenuation = SAMPLE_TEXTURE2D(_ScreenSpaceShadowmapTexture, sampler_PointClamp, screenUV).x;
shadowAttenuation = min(shadowAttenuation, SamplePerObjectScreenSpaceShadowmap(screenUV));
float shadowScene = (SigmoidSharp(shadowAttenuation, _ShadowCenter, _ShadowSmoothness) + _ShadowOffset) * _ShadowStrength;
shadowScene = saturate(shadowScene);
shadow 계산은 이미 갖고 계신 방안을 따라 가시면 됩니다. 다만 가능하면 캐릭터별 그림자 같은 고정밀도 방식이 좋고, 거기에 sigmoid 기반의 명암 경계 제어를 수동으로 추가합니다
// 머티리얼 속성 초기화
float roughness = 1 - ormTex_var.w;
float roughness2 = max(roughness * roughness, 0.0078);
float metallic = ormTex_var.r;
float reflectivity = ormTex_var.g;
float ao = ormTex_var.b;
ao = pow(ao, _AoStrength);
ao를 가져오는 김에 다른 PBR 속성도 함께 추출하고, ao에는 pow 보정을 줘서 강도를 사용자 정의로 조절할 수 있게 합니다
이후 일부 효과 비교 이미지에서 저는 포스트프로세싱 컬러그레이딩을 켜지 않았기 때문에, 게임 효과와 직접 비교하면 다소 차이가 있을 수 있지만 렌더링 로직만 보자면 이쪽이 더 명확합니다
다음으로 BRDF와 NoL을 처리합니다. 핵심 로직은 여전히 NoL로 램프 맵을 샘플링해 디퓨즈 BRDF의 컬러 매핑 변화를 얻어내는 것입니다
다만 zmd는 램프 맵의 디퓨즈 BRDF 매핑 위에 전통 카툰 렌더링 디퓨즈 방식을 추가로 얹습니다. 라이팅 분포 기반 명암 분층이고, 두 가지 사용자 정의 컬러로 명암 변화를 수동 부여하는 방식이죠. 제가 예전에 쓴 일본식 toon shader 글을 참고하셔도 좋습니다
그 옛날 글에서의 전통 카툰 방식은 물리 라이팅 분포를 인위적으로 매우 딱딱한 분층 효과로 통제했습니다. 물리 분포가 스타일라이즈드 분포로 변한 셈이죠. 반면 zmd는 PBR 결합형 카툰 렌더링이라 분층이 그렇게 딱딱하지 않고 실제 물리 라이팅 분포를 따르며, 명암의 컬러만 인위적으로 제어합니다
이런 설계에서는 물리 라이팅 분포 + 사용자 정의 컬러 제어가 디퓨즈의 주체를 담당하고, 램프 맵은 디테일과 질감을 더해주는 컬러 보정 역할에 더 가깝습니다
Endfield는 디퓨즈에서 물리 라이팅 분포를 주체 효과로 채택했기 때문에 zmd 캐릭터 의상에서는 뚜렷한 명암 경계선이 잘 보이지 않고, 더 사실적으로 보입니다
소전2의 방안과도 동일합니다. 소전2 역시 물리 라이팅 분포를 주체 영향으로 두고 분포를 과하게 건드리지 않은 채 램프 맵을 샘플링합니다. 단지 소전2는 램프 맵의 컬러 효과를 주체로 두는 반면, zmd는 보조로 둔다는 차이가 있죠
램프 맵에 의존하는지 여부는 초기 아트 디자인 방안과 관련이 있겠습니다. 개인적으로는 zmd 방식이 범용성이 더 좋다고 느낍니다. 램프 맵이 분리·디커플링되어 조정이 더 편리하니까요. 다만 최종 결과 차이는 그리 크지 않습니다
얘기가 길어졌네요. 구현으로 돌아가서, 디퓨즈의 BRDF와 NoL 부분은 “전통 카툰의 분층 컬러 조정 + 램프 샘플링 컬러 매핑”을 결합해야 한다는 점을 분명히 합시다
먼저 전통 카툰의 분층 컬러 조정부터 구현합니다
분층한다면 몇 층이냐? zmd는 3층을 선택했으니, 세 가지 컬러가 필요합니다. 명부(亮部), 암부(暗部), 그리고 암중암(暗中暗)이죠
명부 컬러는 albedo 텍스처 샘플링 결과 그대로, 암부 컬러는 명부 컬러에서 강도를 감쇠하는데, zmd에서는 채도 감쇠도 추가합니다. 암중암 컬러는 암부 컬러를 다시 0.65배 한 값입니다
// albedo 컬러 명암 처리
float3 baseColor = mainTex_var.xyz * _BaseColor.xyz;
baseColor = pow(baseColor, _BaseColorPow); // 명부 컬러
float3 baseColor_dark = baseColor * _AlbedoDarkStrength;
float baseColor_dark_strength = MyCalculateLightStrength(baseColor_dark);
baseColor_dark = lerp(baseColor_dark_strength.xxx, baseColor_dark, _AlbedoDarkSaturation); // 암부 컬러
// 디퓨즈 컬러 취득
float energyDistribution_metallic = 0.96 - 0.96 * metallic;
float3 mainDiffuseColor_Light = baseColor * energyDistribution_metallic;
float3 mainDiffuseColor_Dark = baseColor_dark * energyDistribution_metallic;
float3 mainDiffuseColor_Dark_attention = mainDiffuseColor_Dark * 0.65; // 암중암 컬러
채도 조정은 흑백 결과 컬러와 lerp하는 것입니다
그리고 PBR 로직을 떠올려 보시면, 디퓨즈와 스페큘러 사이에 에너지 보존이 있다는 걸 기억하실 겁니다. 그래서 metallic에 따라 에너지를 분배해 디퓨즈 컬러, 즉 렌더링 방정식의 BRDF 항을 얻습니다
최종적으로 다음과 같은 세 컬러가 나옵니다

세 층의 컬러가 갖춰졌습니다. 각 층 사이의 가중치 분포를 계산하기 전에, 먼저 램프 맵을 샘플링해서 컬러를 가져옵니다. 리소스 분석 절에서 램프 맵의 W 채널이 신비한 역할을 한다고 언급했죠. 우리의 NoL 라이팅 분포 항도 램프 맵을 거쳐 보정되어야 합니다. 그래야 소전2 같은 일부 전통 방식처럼 sigmoid·smoothness로 범위를 재매핑하지 않아도 됩니다. 그래서 라이팅 분포 항 NoL을 얻으려면 먼저 램프 샘플링을 해결해야 합니다. 여기서의 NoL은 모두 주 광원의 NoL 분포이고, 올라이트의 NoL은 앞서 광원에 미리 적용해뒀기 때문에 BRDF 이후 단계에서 올라이트 NoL이 추가로 영향을 미칠 일은 없습니다.
// 램프 샘플링용 NoL 계산
float rampNoL = 0.5 - 0.5 * NoL * NoL;
float NoL_rampFinal = rampNoL * backLight + NoL; // 역광 보상 추가
NoL_rampFinal = max(NoL_rampFinal, -1);
NoL_rampFinal = min(NoL_rampFinal, 1);
NoL_rampFinal = NoL_rampFinal * 0.5 + 0.5;
// 램프 샘플링
float4 rampColor = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(NoL_rampFinal, 0.5));
zmd의 램프 샘플링은 물리 라이팅 분포를 그대로 가져다 샘플링하면 끝이지만, 그 위에 라이팅 보상, 즉 역광 보상 항을 더했습니다
목적은 이차원 캐릭터가 완전히 역광에 놓였을 때 수광면이 거의 사라지고 캐릭터가 너무 어두워져 실루엣이 산산조각 나는 문제를 해결하는 것입니다
물리 위에 끼워 넣은 스타일라이즈드 트릭이라 볼 수 있고, 매우 좋은 선택입니다
역광 보상 계산 코드는 아래와 같습니다
// 역광 판정 계산
float2 cameraForward_xz = normalize(cameraForward.xz);
float backLight = saturate(-dot(cameraForward_xz, mainLightDir_xz.xz));
// y축 작용
float backLight_y = saturate(-abs(cameraForward.y) + 0.75);
backLight_y = MySmoothstep(backLight_y);
backLight = backLight * backLight_y;
간단히 설명하면, 먼저 카메라 dir의 xz와 광원 dir의 xz를 dot해 xz 평면에서 카메라가 광원을 마주하는지 등지는지를 판단합니다. 두 번째로 y축 항은 카메라가 너무 위·아래를 보고 있을 때 보상을 제한하는 역할입니다. 극단적인 시점에서는 이런 보상이 필요 없으니까요
구체적인 화면 효과는 직접 엔진에서 출력해 확인해 보시고, 대략 아래와 같습니다.

정리하면, 먼저 수평면 기준으로 역광인지 판정하고, 그렇다면 카메라 부앙각이 너무 높거나 낮은지 확인합니다. 각도가 극단적이면 역광 보상을 잘라내되, 잘라내는 과정에서 smoothstep으로 부드럽게 전환되도록 합니다
램프 샘플링 결과는 아래와 같습니다. 왼쪽은 컬러 xyz, 오른쪽은 재매핑된 W 채널 NoL입니다

다시 셰이딩 로직으로 돌아옵시다. 분층에 사용할 라이팅 분포가 필요한데, 이제 이 분포 NoL을 얻었고 물리 위에 인위적인 매핑 보정도 추가했습니다
렌더링 방정식 light × brdf × NoL × 기타 항(ao, shadow)에서 네 부분이 모두 미리 계산된 상태입니다
이전 글들에서 카툰 렌더링에서 BRDF의 특수성을 어떻게 이해해야 하는지 다뤘으니 참고하셔도 좋습니다
간단히 다시 정리하면, 디퓨즈를 예로 들 때 BRDF는 결국 하나의 컬러입니다. 라이팅 조건이 어떻게 바뀌어도 머티리얼의 고유색은 변하지 않습니다. 즉 BRDF 컬러 자체는 그대로죠. 그런데 카툰 렌더링의 특수성은, 그림 그리듯 라이팅 분포에 따라 서로 다른 컬러 변화를 만들어내야 한다는 것이고, 따라서 BRDF가 라이팅 분포를 따라 변화하게 됩니다
그래서 카툰 렌더링 로직에서는 렌더링 방정식의 네 항이 더 이상 단순한 곱셈이 아니라, NoL + 기타 항이 BRDF에 영향을 주고, 최종적으로 분포 변화가 반영된 BRDF 컬러가 곧 뒤 세 항의 완성된 결과가 됩니다
우리가 진행할 3층 디퓨즈 셰이딩도 이 원리입니다. 3층 변화 아래의 디퓨즈 BRDF를 만들어내야 하고, 그 전에 기타 항에 특수 트릭 하나를 추가합니다. NoF 변화입니다
// 두 번째 램프 샘플링으로 가장자리 명암 변화 취득
float NoF = dot(normalWS, cameraForward);
NoF = NoF * 0.5 + 0.5;
float rampColor_NoF = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(NoF, 0.5)).w;
얻어지는 분포는 아래와 같습니다

그림에서의 AO 표현법에 가깝습니다. 가장자리 위치에 어둡게 깔아 입체감을 더해주는 방식이죠. 이 항은 주로 암중암에 쓰입니다. 왜 암중암에만 쓰이는가는 zmd 아티스트들의 설계 결과겠죠
이제 NoL, ao, shadow, NoF를 활용해 세 층 간의 분포를 만듭니다. 먼저 명-암 사이는 min 방식을 써서 가장 뚜렷한 경계 분포를 얻습니다
명과 암처럼 차이가 극단적인 두 층은 min 방식으로 강도 분포를 조합하는 게 적절합니다
float ao_shadow = ao * shadowScene;
float min_shadowEffect = min(ao, shadowScene);
min_shadowEffect = min(min_shadowEffect, rampColor.w);
얻어지는 분포는 대략 아래와 같습니다

다음으로 암부와 암중암의 강도 분포는 아래 방식으로 계산합니다
float ao_shadow_NoFRamp = ao * shadowScene * rampColor_NoF;
saturate(ao_shadow_NoFRamp + rampColor.w)
기타 항은 곱셈으로 묶고 NoL 주체에 더합니다. 분포 효과는 대략 아래와 같습니다

암부 영역이 크게 압축되어 본질적으로 암중암 부분을 계산해낸 것을 볼 수 있습니다. 암중암은 암부 안에서 영역을 찾아야 하기 때문이죠. 계산상으로 풀어보면, NoL의 암부 영역에 있고 동시에 기타 항도 모두 암부일 때만 진짜 암중암이 만들어집니다
또한 대부분의 암중암은 ao, NoF 같은 입체 표현 쪽에 치우쳐 있습니다(일본식 회화 레벨에서요). 그래서 zmd는 암부와 암중암 분포를 이렇게 구현했습니다
다만 두 층 사이의 분포 정의가 반드시 이래야 하는 건 아닙니다. 합리적인 물리 분포 항 위에서 논리적으로 말이 되는 조합이라면 무엇이든 좋습니다
마지막으로 세 층 컬러를 lerp 합성해 전통 카툰 렌더링 방식의 디퓨즈 BRDF 결과를 얻습니다
// 주광 디퓨즈
// 1단계 lerp: 암부 안에서 두 층을 분리
float3 mainDiffuseColor_Dark_lerp = lerp(mainDiffuseColor_Dark_attention, mainDiffuseColor_Dark, saturate(ao_shadow_NoFRamp + rampColor.w));
// 2단계 lerp: 명부와 암부 구분
float3 mainDiffuseBrdf = lerp(mainDiffuseColor_Dark_lerp, mainDiffuseColor_Light, min_shadowEffect);

이제 램프 맵의 컬러 매핑을 더해줍니다. rampColor를 그대로 곱해주면 되고, 일반적인 램프 사용 방식과 같습니다
float3 mainDiffuseBrdf_rampColor = mainDiffuseBrdf * rampColor;

그런데 연구 과정에서 zmd가 실제로는 이렇게 하지 않고, 램프 컬러에 색상(hue) 영향만 적용하는 별도 처리를 한다는 것을 발견했습니다
코드를 다음과 같이 수정해야 합니다
// rampColor의 xyz 차이로 채도 분배 결과 산출
float rampColor_max = max(max(rampColor.x, rampColor.y), rampColor.z);
float rampColor_min = min(min(rampColor.x, rampColor.y), rampColor.z);
float rampColor_xyzStrength = rampColor_max - rampColor_min;
// rampColor 영향 적용
// xyz 채널 영향
float3 rampColor_xyzEffect = rampColor * rampColor_xyzStrength + 1 - rampColor_xyzStrength;
float3 mainDiffuseBrdf_rampColor = mainDiffuseBrdf * rampColor_xyzEffect;
AI에게 물어보니, 색채학에서 Max(RGB) − Min(RGB)는 색도(chroma)이며 곧 그 컬러의 채도를 직접 나타냅니다. 램프 컬러의 채도에 따라 순백(1,1,1)과 램프 원색 사이를 보간하는 셈이죠
높은 채도 = 램프 톤을 보존, 낮은 채도 = 원본 컬러를 보존
어떤 의미에서 이 설계는 앞서 말한 “zmd에서 램프 매핑은 디테일 보조이지 주체가 아니다”라는 점을 뒷받침합니다. 램프 영향력을 한 단계 더 약화시키는 디자인이죠
또는 zmd 아티스트가 “자기가 지정한 컬러가 그대로 적용되기보다, 컬러마다 다른 영향력을 가져 더 매끄럽고 예쁜 컬러 전환이 되도록 하고 싶다”라는 적응적·동적 램프 컬러그레이딩 아이디어를 가졌을 수도 있겠네요
뒷이야기는 알 수 없습니다

최종적으로 다시 얻어진 BRDF 결과입니다. 실제로 대부분 영역에는 영향이 없고, 변화가 두드러진 부분을 표시해뒀으니 앞서의 rampColor 결과와 비교해 관찰해 보세요
앞선 광원 계산에서처럼, 일광 직사 강도에 대해 두 가지 BRDF 상태를 설계해야 합니다
위에서 큰 공을 들여 계산한 BRDF는 일광 직사 강도 1일 때의 상태이고, 0 상태일 때의 결과도 따로 필요합니다
이 상황의 특징을 생각해 봅시다. 우선 주광, 즉 태양광의 영향이 사라져야 하므로 NoL의 영향을 제거해야 합니다
이쯤에서 앞서 왜 올라이트의 NoL을 미리 광원에 적용해뒀는지가 이해됩니다. 일광 직사 강도 0 상태의 일부 계산과 맞물리도록 하기 위해서였죠
그리고 3층처럼 복잡한 변화는 필요 없고 2층이면 충분합니다. 명부·암부 두 층만, 램프 컬러도 더할 필요가 없습니다. 램프 컬러는 주광 NoL과 결합되는 설계니까요. 분포는 가장 평범한 곱셈을 씁니다
float3 mainDiffuseBrdf_lowLight = lerp(mainDiffuseColor_Dark, mainDiffuseColor_Light, ao_shadow_NoFRamp);

마지막으로 일광 직사 강도에 따라 두 BRDF를 lerp하면 됩니다
// 디퓨즈 BRDF를 일광 강도에 따라 lerp
float3 mainDiffuseBrdf_final = lerp(mainDiffuseBrdf_lowLight, mainDiffuseBrdf_rampColor * rampColor_control, _DayStrength);
light × brdf × NoL × 기타 항을 모두 처리했으니, 곧바로 최종 디퓨즈 결과를 얻습니다
// 디퓨즈 결과 계산
float3 mainDiffuseResult = mainLightColor_final * mainDiffuseBrdf_final;

일광 직사 강도가 높은 값에서 낮은 값으로 변화하는 결과입니다. 여기까지 toon base shader의 디퓨즈 부분을 마무리했습니다
스페큘러 구현
스페큘러는 소주4처럼 전용 램프 맵으로 조색하는 방식을 쓰지 않고 BRDF 공식은 PBR 흐름을 따르는데, 스페큘러의 분포 계산에서는 큰 폭으로 손을 대게 둡니다
먼저 특수한 light dir을 구해봅니다
// forward light dir 산출
float forwardLightDir_y = lerp(0.5, mainLightDir.y, _DayStrength);
float3 forwardLightDir = normalize(float3(cameraForward.x, forwardLightDir_y, cameraForward.z));
계산에서 느껴지듯이, 이 방향은 카메라에서 캐릭터를 향해 쓰는 광입니다
이 가상 광 방향을 주광 light dir과 혼합해야 하는데, 혼합 로직은 일광 직사 강도의 영향을 받습니다
// forward light dir을 주광과 혼합해 NoH 계산
float forwardLightDir_y = lerp(0.5, mainLightDir.y, _DayStrength);
float3 forwardLightDir = normalize(float3(cameraForward.x, forwardLightDir_y, cameraForward.z));
float NoV = saturate(dot(normalWS, viewDir));
float3 mainLightDir_new = mainLightDir * _DayStrength + 2 * forwardLightDir;
float3 halfDir_new = viewDir * (2 + _DayStrength) + mainLightDir_new;
halfDir_new = normalize(halfDir_new);
float NoH = dot(normalWS, halfDir_new);
zmd의 스페큘러에서는 이 forward light dir이 스페큘러 계산의 주체 방향 영향을 차지합니다. 일광 강도 1 상태에서는 mainLight dir 비중이 커지고, 0일 때는 완전히 제거됩니다
스페큘러 형상 분포가 매우 스타일라이즈되었다고 말할 수 있죠. NoH를 통해 차이를 비교 관찰해보겠습니다

왼쪽은 forwardLightDir을 쓴 결과, 오른쪽은 일반적인 light dir입니다. 정상 물리 결과에서는 장면 비스듬한 위쪽 광원 방향이 느껴지는데, 왼쪽은 그런 느낌이 확 약해진 걸 볼 수 있습니다
NoH를 구한 다음, PBR 전통 스페큘러 BRDF 공식대로 스페큘러 결과를 계산하고 다시 비교해봅시다
float roughness = 1 - ormTex_var.w;
float roughness2 = max(roughness * roughness, 0.0078);
float metallic = ormTex_var.r;
float reflectivity = ormTex_var.g;
float3 F0 = 0.04 * reflectivity.xxx + metallic * (baseColor - reflectivity.xxx * 0.04);
// 스페큘러 계산
// D 항
float a2 = roughness2 * roughness2;
float specular_D = (NoH * a2 - NoH) * NoH + 1;
specular_D *= specular_D;
specular_D = a2 / specular_D;
// V 항
float specular_V = NoV * 2 + roughness2 + 9.99999975e-05;
specular_V = 0.5 / specular_V;
// DVF 결합
float specular_DV = specular_D * specular_V - 6.10351562e-05;
specular_DV = max(0, specular_DV);
specular_DV = min(20, specular_DV);
float3 specular_brdf = specular_DV * F0;
// 스페큘러 결과 계산
float3 specularLight = mainLightColor_final * (ao_shadow_lowLight * 0.5 + 0.5);
float3 mainLightSpeuclarResult = specularLight * specular_brdf;
mainLightSpeuclarResult *= _SpecularStrength;
여기서도 ao·shadow 같은 기타 항을 적용해야 합니다. zmd는 이후 계산에서 그 기타 항들에 일괄적인 일광 직사 강도 변화를 주었습니다. 디퓨즈에서는 BRDF 계산 단계에 이 상태 변화 구분을 넣었으므로, 스페큘러 계산에서는 그냥 기타 항 변화 + NoH 변화로 0·1 상태의 차이를 대신 나타냅니다
float ao_shadow_lowLight = lerp(ao_shadow_NoFRamp, min_shadowEffect, _DayStrength);

육안으로 스타일라이즈되었을 뿐, 일반적인 NoH 연산을 그대로 채택해도 무방합니다. 개인적으로는 이 스페큘러 트릭이 일광 직사 강도 0일 때를 위해 준비해둔 것이라고 느껴집니다. 올라이트 설계와 비슷한 맥락이죠. 0일 때 올라이트가 스페큘러까지 만들어낼 수는 없으니, 카메라 방향 스페큘러를 선택한 겁니다
물론 이 스페큘러가 이차원 아트 직관에 더 잘 맞을 수 있겠죠. 하지만 제가 그림을 그릴 때는 스페큘러도 광원 방향에서 출발해 생각하기 때문에, 이 부분의 설계는 완전히 이해하진 못했습니다
디퓨즈와 스페큘러의 합성
디퓨즈와 스페큘러를 결합할 때, 디퓨즈 결과에 albedo W 채널을 작용시켜야 합니다
// 디퓨즈·스페큘러 결합
float diffuseSpecularBlend = lerp(1, mainTex_var.w, _DiffuseBlendEffect);
float3 mainLightResult = mainDiffuseResult * diffuseSpecularBlend + mainLightSpeuclarResult;
albedo W 채널 사용 여부를 파라미터로 제어합니다
먼저 W 채널이 뭐지 살펴봅시다

대부분 1이지만 일부 특수 머티리얼 영역에서는 강도 변화가 있습니다. 본질적으로 SSS 머티리얼에서 나타나는 디퓨즈 에너지 제어입니다. 서브서페이스 스캐터링 하에서는 일반적인 디퓨즈 에너지 분포대로 생각할 수 없기 때문에, 이 항은 SSS 머티리얼 BRDF의 근사 피팅 효과로 이해할 수 있습니다
뒤에서 이런 특수 머티리얼을 다룰 때 SSS 항과 이미션 항이 추가로 도입되고, 이들이 디퓨즈 에너지의 일부를 나눠 가져갑니다. 따라서 여기서 약화하는 것은 물리적으로도 자연스러운 결과입니다
IBL 스페큘러
zmd는 근사 수학 함수 피팅을 채택했고, 따라서 따로 설명할 게 그리 많지 않습니다. 이 부분도 AI에게 부탁해 구현했고, 구체적인 수학 공식은 그래픽스 논문에서 가져온 것으로 보이나 저는 더 깊게 파고들지는 않았습니다
AI의 분석에 따르면, 주로 NVIDIA NRD(NVIDIA Real-Time Denoisers) 라이브러리의 DFG 피팅 함수를 참고했고, 여기에 Imageworks의 Kulla-Conty 다단 반사 보상을 도입했습니다
제 추천은 이 방법의 패턴을 머릿속에 넣어둔 다음, 다음에 IBL 스페큘러가 필요할 때 꺼내 효과를 테스트해보는 겁니다. baseline으로 잡고 이후 마개조를 시작할 때 세세히 연구해도 늦지 않습니다
// IBL 스페큘러 계산
float roughness4 = roughness2 * roughness2;
float roughness6 = roughness4 * roughness2;
float NoV2 = NoV * NoV;
float NoV3 = NoV2 * NoV;
float fit_A = 3.32707 * NoV + 0.0365463;
float fit_B = -9.04755 * NoV + 9.0632;
float IBLspecular_brdf1 = fit_A + fit_B * roughness2;
float fitX = 3.59685 * NoV2 - 1.36772 * NoV3 + 1.0;
float fitY = 9.22949 * NoV3 - 16.3174 * NoV2 + 9.04401;
float fitZ = -20.2123 * NoV3 + 19.7886 * NoV2 + 5.56589;
float3 nvFactors = float3(fitX, fitY, fitZ);
float IBLspecular_brdf2 = dot(nvFactors, float3(1, roughness2, roughness6));
float IBLspecular_brdf = IBLspecular_brdf1 / IBLspecular_brdf2;
// IBL 스페큘러 함수 계산 계속 - LUT 조회 부분 피팅(DFG 피팅 블록)
float scale_fit_part1 = dot(float2(-1.28514, 1.0), float2(NoV, 0.990440011));
float scale_fit_part2 = dot(float2(1.0, -0.75591), float2(1.29678, NoV));
float env_scale = dot(float2(scale_fit_part1, scale_fit_part2), float2(1, roughness2));
float bias_fit_x = dot(float3(2.92338, 59.4188, 1.0), float3(NoV, NoV3, 1.0));
float bias_fit_y = dot(float3(1.0, -27.0302, 222.592), float3(20.3225, NoV, NoV3));
float bias_fit_z = dot(float3(626.130, 316.627, 1.0), float3(NoV, NoV3, 121.563004));
float bias_denominator = dot(float3(bias_fit_x, bias_fit_y, bias_fit_z), float3(1, roughness2, roughness6));
float env_bias = env_scale / bias_denominator;
float3 IBLspecular_brdf_final = IBLspecular_brdf * F0 + env_bias;
float IBLspecular_brdf_final_noF0 = IBLspecular_brdf + env_bias;
// IBL의 전통적인 사전 적분 과정을 표준 수학 함수로 피팅한 결과
// IBL 스페큘러 보충 항 - 다단 반사 보상(앞서는 단일 반사의 함수 피팅이었음)
float directionalAlbedo = IBLspecular_brdf_final_noF0;
// 다단 반사 보상 계수 계산 (Kulla-Conty Approximation)
// 목적: 마이크로서페이스 틈사이에서 여러 번 반사된 뒤에야 빠져나오는 광선을 되돌려받기
float energyLossFactor = (1.0 - directionalAlbedo) / directionalAlbedo;
// 보상 컬러는 주로 F0의 영향을 받음
float3 ms_compensation = F0 * energyLossFactor;
// 최종 IBL BRDF = 기본 항 + 보상 항
// 거친 머티리얼의 스페큘러 채도·밝기를 크게 향상
float3 final_ibl_brdf = IBLspecular_brdf_final * (1.0 + ms_compensation);
// IBL 스페큘러의 light 부분 취득
float3 reflectDir = reflect(-viewDir, normalWS);
reflectDir.x = -reflectDir.x;
// reflectDir.y = -reflectDir.y;
reflectDir.z = -reflectDir.z;
// 환경 맵 회전
float angle = _EnvRotation * 0.0174532925; // 각도를 라디안으로 변환(PI/180)
float s, c;
sincos(angle, s, c); // 사인·코사인 동시 산출
float3 rotatedDir;
rotatedDir.x = reflectDir.x * c - reflectDir.z * s; // X 회전
rotatedDir.y = reflectDir.y; // Y 유지
rotatedDir.z = reflectDir.x * s + reflectDir.z * c; // Z 회전
float envMap_level = log2(max(0.01, roughness));
envMap_level = envMap_level * 1.2 + 5.0;
float3 envMap_color = SAMPLE_TEXTURECUBE_LOD(_EnvMap, sampler_LinearRepeat, rotatedDir, envMap_level);
envMap_color *= _EnvColor;
// IBL 스페큘러 결과 취득
float3 indirLightSpecular = envMap_color * final_ibl_brdf * _EnvLightStrength;
저의 코드 구현을 그대로 복각하시면 됩니다

림라이트 구현
zmd의 림라이트 구현은 두 부분으로 나뉘어집니다
먼저 가장 흔한 NoV 기반의 프레넬 림라이트입니다
// rimLight
float rimStart = _RimLightArea * -0.6 + 0.8;
float rimEnd = _RimLightArea * -0.4 + 0.9;
float rimWidth = rimEnd - rimStart;
float rimt = ((1.0 - NoV) - rimStart) / rimWidth;
rimt = saturate(rimt);
float rimArea = rimt * rimt * (3.0 - 2.0 * rimt); // rim 스무스 처리(smoothstep)
float3 rimLight = rimArea * _RimLightColor * _RimLightStrength;
float3 rimLight_effectd = rimLight * min(ao, shadowScene);
// rimLight brdf
float3 rimLight_brdf = lerp(0.25, mainDiffuseColor_Light, _RimLightDiffuseColorEffect);
float3 rimLightResult = rimLight_brdf * rimLight_effectd;
NoV 림라이트 구현을 분석해보죠. 먼저 1 − NoV에 smoothstep 범위 매핑을 적용해 림라이트의 범위 크기를 결정합니다
그 다음 컬러를 적용하고 ao·shadow 영향을 곱해 rimLight_effectd를 구한 뒤, 원래의 디퓨즈 컬러 영향을 도입할지 여부를 설정합니다
최종 결과는 아래와 같습니다

오른쪽은 디퓨즈 컬러를 완전히 도입한 결과, 왼쪽은 일부만 적용한 결과입니다
한마디로, 1 − NoV에 사용자 정의 범위 매핑을 거친 다음 고유색 영향을 제어하는 구조입니다
NoV 기반 외에도 라이팅 기반 림라이트 효과도 있습니다. 제가 이전에 쓴 toon shader 2 블로그와 단백톤 선배의 소전2 블로그에서도 언급한, 매우 이차원적인 림라이트입니다. 다만 범위는 여전히 NoV를 쓰고, 깊이 차이로 구현하진 않습니다.
// NoLxz rim
// 광원에 따라 변하는 rimLight
float3 rim_mainLight = lerp(1, mainLightColor * mainLightIntensity, _DayStrength);
float NoLxz = dot(normalWS, mainLightDir_xz);
float NoLxz_refine = 0.5 - (0.5 * NoLxz - 1) * NoLxz;
NoLxz_refine *= _DayStrength;
float3 rim_mainLightResult = rim_mainLight * NoLxz_refine;
float NoV_mask = saturate(5.0 * (0.4 - NoV)); // width 0.2, min 0, max 0.2 정도
NoV_mask = MySmoothstep(NoV_mask);
rim_mainLightResult *= NoV_mask;
rim_mainLightResult *= min(ao, shadowScene);
rim_mainLightResult *= _RimLightNoLxzStrength;
이 림라이트의 로직을 분석해 봅시다. NoV 기반보다 조금 복잡합니다. 일광 직사 강도 상태 변화까지 관여하기 때문이죠
- rimLight의 컬러는 일광 강도와 관련이 있는데, 0일 때는 1, 1일 때는 mainLight의 컬러가 그대로 적용됩니다
- NoL은 특수 처리되는데, y축 영향은 모두 무시하고 mainLightDir의 xz 성분만 씁니다. 캐릭터를 원통으로 상정하고 림라이트를 비추는 셈입니다
- 림라이트 강도는 일광 강도의 영향을 받아, 0일 때는 현재 NoLxz 기반 림라이트 영향이 완전히 제거됩니다
- 여전히 1 − NoV + 범위 재매핑으로 림라이트 범위를 계산합니다. NoV_mask = saturate(5.0*(0.4 − NoV))는 코드를 그대로 복각한 결과이고, smoothstep(0, 0.2, 1 − NoV)와 비슷한 수치 연산입니다
- ao·shadow와 사용자 정의 강도 제어 영향을 적용합니다
최종 결과는 아래와 같습니다.

수광면의 영향을 뚜렷하게 받습니다. 사실 그리 복잡한 건 아니고, 핵심은 상태 변화 디자인입니다
최종 결과 통합
앞에서 구한 부분들을 모두 더하면 최종 결과가 나옵니다
float3 resultColor = mainLightResult + indirLightSpecular + max(rimLight_finalResult, 0);
rimLight 부분에 안전 제한을 걸어둔 점을 주목하세요. 사실 현재 구현된 코드에는 안전 처리가 더 필요한 지점이 적지 않습니다. 이런 디테일이 너무 많아서, 혼자 복각하다 보면 관리하는 게 조금 버겁습니다

toon base shader 최종 결과
아직 포스트프로세싱 컬러그레이딩을 적용하지 않아 일부 영역이 과노출될 수 있고, 나중에 대비 조정을 거치면 정상으로 돌아옵니다
특수 머티리얼 변형(variant)
toon base shader는 여기서 거의 끝났고, 몇 가지 특수 부위에 대해 변형을 추가하면 됩니다
먼저 의상 외의 두 작은 액세서리 부위에 대해 스페큘러 특수 처리가 추가됩니다

특수 처리가 필요한 두 부위
변형 셰이더 작성법은 여기서 설명하지 않고, _SPECULARREFINE_ON 변형을 하나 정의합니다
그리고 앞서 언급한 컬러 매핑 텍스처 + 이미션 텍스처를 사용합니다

아래 검은 이미지가 이미션 텍스처입니다. 복각 당시에는 사용자 정의 스페큘러인 줄 잘못 알았는데, 의미만 이해하면 됩니다
스페큘러 계산 과정에서 F0에 보정을 가합니다
// F0 refine 절차
#ifdef _SPECULARREFINE_ON
float f0Refine_u = lerp(specular_D * roughness2, NoV * NoV, _RefineF0U_lerp);
float f0Refine_v = roughness * (1 - ao);
float4 F0RefineTex_var = SAMPLE_TEXTURE2D(_SpecularRefineF0Tex, sampler_AlbedoTex, float2(f0Refine_u, 1 - f0Refine_v));
F0 *= F0RefineTex_var.xyz;
#endif
컬러 매핑 텍스처를 샘플링하는 로직은 IBL 사전 적분 LUT 샘플링 방식과 비슷합니다. 왜 하필 roughness와 NoV인지 묻지 마세요. zmd가 그렇게 사전 적분해둔 것을 그대로 복각했을 뿐이니까요
보정된 최종 F0는 아래와 같습니다

왼쪽이 보정 후 결과입니다. 미묘한 컬러 변화가 보이죠. 이런 순금속 특수 오브젝트는 아티스트가 스타일라이즈드 스페큘러 특징을 수동으로 더하고 싶을 수 있죠. 아무튼 게임에서 이렇게 처리되어 있으므로 그대로 복각했습니다
// 이미션 효과
#ifdef _SPECULARREFINE_ON
float4 specularRefineColorTex_var = SAMPLE_TEXTURE2D(_SpecularRefineColorTex, sampler_AlbedoTex, i.uv0);
float3 specularRefineResult = specularRefineColorTex_var.xyz * _SpecularRefineColor * _SpecularRefineColorStrength * diffuseSpecularBlend;
mainLightResult += specularRefineResult;
#endif
그다음 주광 셰이딩 결과에 이미션 효과를 더합니다. 주로 인형과 리본에서 나타나며, 아래가 specularRefineResult 결과입니다
이미션은 albedo 텍스처의 W 채널로 제어되며, diffuseSpecularBlend의 정의는 앞의 디퓨즈·스페큘러 합성 절에서 찾을 수 있습니다


복각 연구 과정에서 zmd 아트조의 디테일 디자인을 확실히 느꼈습니다. 게임 안에서 섬세한 인게임 원안을 그대로 복각하려는 의도가 보이죠. 이런 트릭들은 원화의 느낌을 맞추기 위한 것이라고 느껴집니다
다음 특수 머티리얼 변형은 검은 스키니(黑丝) 부분입니다. SSS 항을 추가로 넣기만 하면 됩니다

최초 albedo 취득 단계에 적용해 고유색을 변경합니다
// albedo 컬러 명암 처리
float3 baseColor = mainTex_var.xyz * _BaseColor.xyz;
baseColor = pow(baseColor, _BaseColorPow);
// 검은 스키니 등 SSS 효과를 가진 특수 머티리얼
float NoV_sss = saturate(dot(normalWS, viewDir));
float sss_area = pow(1.05 - NoV_sss, 1 + mainTex_var.w * _SssPowStrength);
baseColor = lerp(baseColor, _SssColor, lerp(0, sss_area, _IsNeedSss));
마찬가지로 1 − NoV로 가장자리 영역을 계산해 SSS 분포 가중치로 쓰는, 가장 단순한 SSS 구현입니다
여기서는 심지어 변형도 두지 않았는데, 텍스처 샘플링이 없고 계산도 크게 복잡하지 않아서 lerp 제어만 가볍게 쓴 겁니다

왼쪽이 SSS를 적용한 결과, 오른쪽이 SSS 영향이 albedo에 없는 결과입니다
마지막은 반투명 머티리얼, 즉 어린 양(小羊) 캐릭터의 소매 부위입니다

_TRANSPARENT_ON 변형을 정의하고, 머티리얼의 blend·셰이더 렌더링 순서를 수동으로 조정해야 한다는 점을 잊지 마세요. 그 다음 최종 셰이딩 결과에 이미션 부분을 더해주면 됩니다
#ifdef _TRANSPARENT_ON
finalAlpha = mainTex_var.w * _CustomAlpha;
float3 emissionColor = SAMPLE_TEXTURE2D(_EmissionTex, sampler_LinearClamp, i.uv0).xyz;
// 이미션 컬러 조정 절차 추가
resultColor += _EmissionColor * _EmissionColorStrength * emissionColor * lerp(1, mainTex_var.w, 0.8);
#endif
주의할 점은 이 부위의 램프 맵은 별도로 제작된 것이므로 제대로 지정해야 하고, 이미션 텍스처도 헷갈리지 않도록 주의하세요

컬러가 게임과 비슷해지도록 조금 조정해야 합니다. 다만 제가 복각한 결과는 원작과 차이가 있는데 일단 이 정도로 마무리하겠습니다
skin toon shader
가장 어려웠던 산은 넘었고, toon base shader를 베이스로 둘 수 있으니 이후 셰이더 구현은 훨씬 간단해집니다
이후 셰이더들은 모두 base shader의 셰이딩 로직을 수정·추가하는 형식이므로, 이 셰이더가 toon base shader의 어떤 항을 필요로 하는지만 알려드리고, 새로 등장하는 핵심 기능을 집중적으로 설명하겠습니다
피부 구현에서는 먼저 IBL 스페큘러가 필요 없습니다. 적어도 게임에서는 보이지 않았고, 나머지 디퓨즈·스페큘러·림라이트 로직은 동일하게 유지합니다
그리고 마찬가지로 SSS 효과를 추가해야 합니다
SSS 효과
// SSS 효과 구현 - albedo 처리
float NoV = dot(normalWS, viewDir);
float sss_NoV = saturate(NoV);
sss_NoV = sss_NoV * 0.85 + 0.15;
float sss_area = saturate(_SSSArea * (1.0 - sss_NoV));
float3 sssColorEffect = lerp(float3(1.0, 1.0, 1.0), _SSSColor, sss_area);
float3 albedo_sssRefine = mainTex_var.rgb * sssColorEffect;
1 − NoV의 범위 매핑이 조금 다르고, 앞서 검은 스키니 머티리얼에서처럼 lerp로 대체하는 게 아니라 원래 컬러에 곱해서 적용합니다

왼쪽은 SSS 적용 후 albedo, 오른쪽은 원래 albedo입니다. 효과 차이가 명확하죠. sssColor는 게임 효과와 비슷해지도록 수동으로 조정하셔야 합니다
LUT 맵 기반의 암부 albedo
나머지 셰이딩 로직은 toon base와 동일하지만 약간의 차이가 있습니다. 먼저 피부에는 머티리얼 속성 텍스처가 없으므로, 머티리얼 속성을 기본값으로 직접 주어야 합니다
// 머티리얼 속성 초기화
float metallic = 0;
float energyDistribution_metallic = 0.96 - 0.96 * metallic;
float3 mainDiffuseColor_Light = albedo_sssRefine * energyDistribution_metallic;
float reflectivity = 0.04 * _ReflectivityStrength;
float3 F0 = float3(1,1,1) * reflectivity;
float roughness2 = max(0.0078125, _Roughness * _Roughness);
거칠기와 반사률을 파라미터로 열어두면 됩니다
그리고 피부의 특징이 하나 있습니다. 피부의 통일된 복잡한 효과를 위해 암부를 매핑하는 범용 컬러 LUT 맵을 설계해둔 겁니다. zmd 아티스트가 피부에 얼마나 공을 들였는지 느낄 수 있죠. 피부의 명암 변화를 램프 맵 대신 훨씬 복잡한 컬러 매핑 방식인 LUT로 구현한 겁니다. 보통 LUT는 최종 화면 포스트프로세싱 컬러그레이딩용으로 쓰이는데, 여기서는 피부의 디테일 변화만을 위해 쓰입니다
결국 toon base의 암부 컬러 취득을 여기서는 텍스처를 통해 구현해야 한다는 뜻입니다

제가 추출한 LUT 이미지는 상하가 뒤집혀 있어서, 이후 샘플링 UV에서 반전을 주겠습니다
아래는 이 텍스처를 기반으로 암부 컬러를 구하는 방식이고, 암중암은 여전히 암부에 0.65를 곱한 값입니다
// 디퓨즈 암부 컬러 계산
float3 albedo_sRGB = linear2sRGB(mainTex_var.brg);
float2 lut_uv = albedo_sRGB.xz * float2(31, 0.96875);
float lut_uv_floorx = floor(lut_uv.x);
float2 lut_uv_yz = albedo_sRGB.yz * float2(0.0302734375, 0.96875) + float2(0.00048828125, 0.015625);
float lut_uv_x = lut_uv_floorx * 0.03125 + lut_uv_yz.x;
float2 lut_uv_final = float2(lut_uv_x, 1 - lut_uv_yz.y);
float lutTex_lerp = albedo_sRGB.x * 31 - lut_uv_floorx;
float3 lutTex_var1 = SAMPLE_TEXTURE2D(_LutColorTex, sampler_LutColorTex, lut_uv_final).xyz;
float3 lutTex_var2 = SAMPLE_TEXTURE2D(_LutColorTex, sampler_LutColorTex, lut_uv_final + float2(0.03125, 0.015625)).xyz;
float3 albedo_lutRefine = lerp(lutTex_var1, lutTex_var2, lutTex_lerp);
float3 mainDiffuseColor_dark = albedo_lutRefine * energyDistribution_metallic * _AlbedoDarkStrength;
// 암중암 컬러 계산
float3 mainDiffuseColor_darkindark = mainDiffuseColor_dark * 0.65;
샘플링 로직은 AI나 다른 레퍼런스에서 확인해보세요. LUT 샘플링 방식 설명은 이 글의 주제와 멀고, 코드 안의 brg 채널 순서 교차는 이 부분도 AI의 도움을 받아 정리했기 때문입니다
주목할 점은 샘플링 결과 컬러가 곧 암부 albedo라는 것입니다. 램프처럼 다시 적용할 필요는 없습니다
LUT 맵은 일대일 해시 테이블과 비슷해서, 하나의 컬러가 다른 컬러로 바로 대응되기 때문입니다

명부, 명부 컬러×0.5로 만든 암부 컬러, LUT 기반 암부 컬러를 비교한 결과입니다
차이가 분명하죠. LUT 기반 암부가 피부 질감에 더 잘 맞는다고 느껴지지만, 저는 실사 캐릭터 렌더링 연구가 충분치 않아 추측에 그칩니다
암부 컬러를 구한 다음에는 toon base 로직을 그대로 따라가면 됩니다
최종 피부 결과입니다. 오른쪽은 림라이트를 강화한 결과입니다. 최신 게임 버전의 캐릭터 UI에서는 림라이트를 꽤 강하게 잡아놓았는데, 특수 장면이 아니라면 보통 그렇게 밝게 올리지 않습니다

face toon shader
카툰 렌더링의 얼굴 셰이더 기법은 이제 대부분 SDF 기반입니다. zmd도 예외는 아니고, 핵심은 SDF 라이팅 변화 위에 어떤 디테일 트릭들을 얹느냐입니다
toon base shader 로직 위에서 IBL 스페큘러·모든 림라이트를 제거하고 디퓨즈·스페큘러 셰이딩 로직만 남깁니다. 핵심은 SDF 기반의 라이팅 변화 분포 NoL을 구해 디퓨즈 로직의 각종 NoL 분포를 대체하는 것입니다
SSS + LUT 암부 컬러
face 역시 피부에 속하므로 피부의 특성이 그대로 존재합니다. 앞에서 구현한 SSS 효과와 LUT 맵 기반 암부 컬러 취득은 얼굴에서도 그대로 구현해야 합니다
핵심 코드는 피부 섹션의 로직과 동일하므로, face shader에서는 이 결과를 얼굴 전용 마스크와 SDF 라이팅 분포에 맞춰 다시 결합한다고 이해하면 됩니다
피부와 얼굴은 램프를 공유하고, 피부의 LUT 암부 컬러 역시 얼굴 색감의 통일성을 유지하는 데 사용됩니다
트릭용 표정 텍스처 사용 방식
리소스 안에는 표정용 텍스처 한 장이 존재합니다. 4칸 구조로 저장되어 있으므로, 샘플링 로직에 맞춰 해당 영역을 읽으면 됩니다
샘플링한 결과는 고유색 albedo와 합성해 표정 디테일을 추가합니다. 이 방식은 별도의 복잡한 수학 함수보다 아트 리소스에 직접 제어권을 주는 방식이라, 얼굴처럼 작은 차이가 크게 보이는 영역에 잘 맞습니다
// 기본 컬러와 표정 텍스처 합성 예시
float4 faceMainTex_var = SAMPLE_TEXTURE2D(_AlbedoTex, sampler_AlbedoTex, i.uv0);
float2 expressionUV = i.uv0 * 0.5 + _ExpressionUVOffset;
float4 expressionTex_var = SAMPLE_TEXTURE2D(_ExpressionTex, sampler_AlbedoTex, expressionUV);
float3 faceBaseColor = faceMainTex_var.rgb;
faceBaseColor = lerp(faceBaseColor, expressionTex_var.rgb, expressionTex_var.a);
매직 맵 SDF
미호요 스타레일 얼굴 매직 맵의 샘플링 방식을 기억하신다면, 외부에서 얼굴의 forward dir, right dir, up dir 세 방향을 전달해야 한다는 점도 기억하실 겁니다
// face dir 예시
float3 faceForward = normalize(_FaceForward.xyz);
float3 faceRight = normalize(_FaceRight.xyz);
float3 faceUp = normalize(_FaceUp.xyz);
SDF 텍스처를 샘플링한 뒤, 광원 방향과 얼굴 방향의 관계를 이용해 얼굴의 명암 분포를 구합니다. 이 값이 faceNoL 계열 값이 되며, 이후 toon base shader의 NoL 자리에 들어갑니다
// SDF 기반 얼굴 라이팅 분포 예시
float4 sdfTex_var = SAMPLE_TEXTURE2D(_FaceSdfTex, sampler_AlbedoTex, i.uv0);
float3 lightDir = normalize(mainLightDir);
float front = dot(lightDir, faceForward);
float right = dot(lightDir, faceRight);
float sdfValue = lerp(sdfTex_var.r, sdfTex_var.g, step(0.0, right));
float faceNoL = smoothstep(-_FaceSdfSmooth, _FaceSdfSmooth, sdfValue + front * _FaceLightStrength);
여기서 중요한 것은 얼굴의 SDF 라이팅이 모델 노멀의 물리적 NoL을 그대로 따르지 않는다는 점입니다. 얼굴은 아트가 원하는 명암 경계를 유지해야 하므로, SDF 맵이 명암 분포의 주체가 됩니다
faceNoL 보정
얼굴의 SDF 라이팅만 그대로 쓰면 특정 각도에서 어색한 경계가 생기거나, 목과 얼굴의 연결부가 딱딱하게 보일 수 있습니다. 그래서 보정 항을 추가해 분포를 부드럽게 만듭니다
// faceNoL 보정 예시
float faceNoL_compensation = saturate(dot(normalWS, faceForward) * 0.5 + 0.5);
faceNoL = lerp(faceNoL, max(faceNoL, faceNoL_compensation), _FaceNoLCompensationStrength);
이 보정은 얼굴의 정면성, 노멀 방향, SDF 결과를 함께 고려해 너무 딱 끊기는 그림자를 줄이는 데 사용됩니다
목과 얼굴 이음지점 처리
얼굴과 목의 이음지점은 SDF 기반 얼굴 셰이딩에서 매우 눈에 띄는 문제입니다. 일반적인 얼굴 SDF만 적용하면 목과 얼굴의 라이팅 분포가 서로 달라져 경계가 튀어 보일 수 있습니다
그래서 영역 제한 맵과 보정 마스크를 사용해 얼굴 쪽 SDF 분포와 목 쪽 toon base 분포를 부드럽게 섞어줍니다
// 얼굴/목 이음지점 블렌딩 예시
float seamMask = SAMPLE_TEXTURE2D(_FaceAreaMaskTex, sampler_AlbedoTex, i.uv0).r;
float bodyNoL = saturate(dot(normalWS, mainLightDir) * 0.5 + 0.5);
float finalFaceNoL = lerp(bodyNoL, faceNoL, seamMask);
이렇게 하면 얼굴 영역은 SDF 기반의 안정적인 명암을 유지하면서도, 목으로 이어지는 부분은 모델 노멀 기반 셰이딩과 자연스럽게 연결됩니다
커스터마이징된 림라이트
face shader에서는 toon base shader의 일반 림라이트를 그대로 쓰지 않고, 얼굴 전용 림라이트로 커스터마이징합니다. 얼굴은 작은 밝기 변화도 인상이 크게 달라지므로, 범위와 강도를 더 조심스럽게 제어해야 합니다
// 얼굴 전용 림라이트 예시
float faceNoV = saturate(dot(normalWS, viewDir));
float faceRimArea = smoothstep(_FaceRimStart, _FaceRimEnd, 1.0 - faceNoV);
float faceRimMask = SAMPLE_TEXTURE2D(_FaceAreaMaskTex, sampler_AlbedoTex, i.uv0).g;
float3 faceRimLight = faceRimArea * faceRimMask * _FaceRimColor.rgb * _FaceRimStrength;
최종적으로 얼굴의 디퓨즈, 스페큘러, SSS, LUT 암부 컬러, SDF 라이팅, 표정 텍스처, 커스터마이징 림라이트를 결합해 face shader 결과를 얻습니다

eye toon shader (눈)
이 부분은 난도가 높지 않습니다. 우선 toon base shader의 디퓨즈 부분만 가져오고, 스페큘러는 matcap으로 구현하면 됩니다. 이후 내용을 읽다가 문맥이 잘 이해되지 않는 부분이 생기면, 제가 공유한 코드 저장소를 꼭 참고해서 완성된 shader와 대조해가며 이해해 주세요.
눈 영역 분할
// 눈 중심 마스크 영역 취득
float2 fracUV = frac(i.uv0);
float2 eyeCenterAreaUV = fracUV - float2(0.5, 0.5);
float eyeCenterArea = step(0.25, dot(eyeCenterAreaUV, eyeCenterAreaUV));
UV를 기반으로 눈의 중심 영역을 바로 계산합니다. 이 값은 뒤에서 여러 번 사용되며, 일본풍 이차원 화풍에서 특수한 하이라이트 영역을 다룰 때 쓰입니다.

눈 내부 영역만 신경 쓰면 됩니다. 나머지 부분에는 이런 처리를 하지 않습니다.
특수 노멀 기반 셰이딩
toon base shader의 셰이딩 로직 자체는 크게 달라지지 않지만, 노멀은 모델 노멀을 그대로 쓰면 안 됩니다.
// 특수 노멀 계산
float2 centeredUV = eyeCenterAreaUV * 2.0;
float uvSq = dot(centeredUV, centeredUV);
float zHemi = sqrt(max(0.0, 1.0 - min(1.0, uvSq))); // max와 min은 오버플로 오류 방지용
zHemi = max(1e-16, zHemi); // 완전한 0이 되어 이후 정규화에서 NaN이 생기는 것을 방지
float2 corneaTS_xy = 0.125 * _CorneaBumpStrength * centeredUV;
float3 corneaNormalTS = float3(corneaTS_xy, zHemi);
corneaNormalTS = lerp(corneaNormalTS, float3(0, 0, 1), eyeCenterArea);
// 특수 위치에는 0, 0, 1이 필요하므로 노멀을 유지
corneaNormalTS.x = -corneaNormalTS.x;
float3 customNormalWS = corneaNormalTS.x * tangentWS +
corneaNormalTS.y * bitangentWS +
corneaNormalTS.z * normalWS;
// TBN 변환: 탄젠트 공간에서 월드 공간으로
float3 corneaNormalWS = normalize(customNormalWS);

왼쪽은 계산 후의 특수 노멀이고, 오른쪽은 모델 노멀입니다.
특수 노멀 로직이 꽤 재미있습니다. UV와 거리를 기반으로 부드럽게 전환되는 탄젠트 노멀을 얻습니다. UV를 하나의 2차원 함수장으로 보고, 그 그래디언트를 구해 부드럽게 변화하는 노멀 효과를 얻는다고 이해하면 됩니다.
눈알 UV의 특수성 덕분에 UV 중심이 눈알 정중앙에 정확히 오므로 이런 방법을 사용할 수 있습니다. 개인적으로 이런 눈 전용 특수 기법은 기억해둘 만하다고 생각합니다. 다른 디자인에서도 비슷한 방식을 적용하면 의외의 수확을 얻을 수 있을지도 모릅니다.
이 특수 노멀을 이후 디퓨즈 셰이딩 과정에 적용하면 됩니다.
사용자 정의 영역 발광 트릭
디퓨즈의 2단 lerp 과정에서는 아래처럼 수정이 필요합니다.
// 사용자 정의 컬러 조정
float3 specularTrickColor = lerp(1, _SpecularTrickColor * 2.5, eyeCenterArea);
float3 eyeInTrickColor = lerp(1, _EyeInTrickColor * 2.5, mainTex_var.w);
float3 trickAlbedo = specularTrickColor * eyeInTrickColor;
// ...
// 1단계 lerp: 암부 안에서 암중암과 암부 두 층을 분리
float3 mainDiffuseColor_Dark_lerp = lerp(mainDiffuseColor_Dark_final, mainDiffuseColor_dark, saturate(rampTex_NoF + rampNoL));
// 2단계 lerp: 명부와 암부 구분
float3 mainDiffuseBrdf = lerp(mainDiffuseColor_Dark_lerp, mainDiffuseColor_Light * trickAlbedo, rampNoL);
명부 albedo에는 컬러 보정을 하나 적용해야 합니다. 여기서 eyeCenterArea는 앞에서 본 값으로, 눈의 일본풍 하이라이트 영역을 뜻합니다. 그리고 mainTex_var.w도 조금 특수한데, 일본풍 이차원 화풍에서 꽤 중요한 영역입니다.

이 두 영역에는 각각 별도의 컬러 영향을 주어야 합니다. 컬러 값은 게임 안의 효과와 대조해가며 설정하면 됩니다.
matcap 기반 스페큘러
// 일광 변화에 따른 암부 영향 요소
float DayDarkEffect = lerp(rampTex_NoF, rampNoL, _DayStrength);
// matcap 샘플링: 뷰 공간의 노멀
float3 corneaNormalVS = mul((float3x3)UNITY_MATRIX_V, corneaNormalWS);
corneaNormalVS = normalize(corneaNormalVS);
float2 matcapUV = corneaNormalVS.xy * 0.5 + 0.5;
float4 specularMatcapVar = SAMPLE_TEXTURE2D(_SpecularMatcap, sampler_SpecularMatcap, matcapUV);
float3 specularColor = specularMatcapVar.xyz; // 스페큘러 RGB 컬러
float specularW = specularMatcapVar.w; // 스페큘러 Alpha(보통 하이라이트 범위 제어나 바탕색 Alpha와의 합성에 사용)
// specular BRDF 취득
float3 specularBrdf = specularColor * _SpecularStrength + _SpecularColor * specularW;
float specularDarkEffect = DayDarkEffect * 0.5 + 0.5;
specularDarkEffect *= lerp(_SelfAoShadowStrength, 1, DayDarkEffect);
// 스페큘러 결과
mainSpecularResult = mainLightColor_final * specularBrdf * specularDarkEffect;
일반적인 matcap 샘플링 로직입니다. 뷰 공간의 노멀을 얻은 다음, 그 xy로 샘플링하면 됩니다.
여기서 matcap의 W 채널은 하이라이트 영역 마스크를 나눠줍니다. matcap 결과 위에 사용자 정의 하이라이트 컬러를 추가할 수 있습니다.

matcap W 채널
마지막으로 shadow와 AO를 적용합니다. 동시에 일광 직사 강도의 상태 영향도 신경 써서 최종 하이라이트 결과를 얻습니다.

아주 보기 좋은 하이라이트 효과입니다.
나머지 눈썹 등 부위
스페큘러를 추가할 필요도 없고, 새 노멀을 계산할 필요도 없습니다. toon base shader의 디퓨즈 셰이딩 로직을 그대로 타면 됩니다.

hair toon shader (머리카락)
toon base shader를 기반으로 하되, IBL 스페큘러와 주 광원 스페큘러 부분은 제거합니다.
머리카락 셰이딩 로직도 꽤 특수한 축에 속해서, 처리해야 할 부분이 적지 않습니다.
스무스 노멀 취득
게임 안에서 보면 캐릭터의 하이라이트가 하나의 덩어리처럼 정렬되어 있고, 매우 일본풍 이차원 느낌이 강합니다. 이런 정돈된 효과를 만들려면 반드시 스무스 노멀이 필요합니다.
다만 이 노멀은 하이라이트에만 사용합니다. 디퓨즈 효과에는 여전히 모델 노멀의 물리적 효과를 유지해 셰이딩해야 합니다.
스무스 노멀을 모델 안에 저장하는 것도 일반적인 방식이지만, 여기서 머리카락의 스무스 노멀은 텍스처를 통해 처리합니다.
추출한 머리카락 노멀 텍스처의 ZW 채널을 보면, 앞에서 본 일반 노멀 텍스처와 달리 이 두 채널에도 값이 들어 있는 것을 확인할 수 있습니다.
이 두 채널을 노멀맵 로직에 맞춰 모델 노멀에 적용하면 스무스 노멀 결과를 얻을 수 있습니다.
// HNormal 처리
float3 normalTex_Hprocessed = UnpackNormalFromTex(normalTex_var.zwzw, _BumpScale);
float3 HNormalWS = lerp(i.normalWS, normalize(mul(normalTex_Hprocessed, tangentTransform)), _IsNeedNormalMap);

왼쪽은 노멀 텍스처 ZW 채널을 적용해 스무스 처리한 결과이고, 오른쪽은 모델 노멀입니다.
하지만 제가 사용해보니 이 스무스 효과만으로는 부족했습니다. 텍스처 사용 방식에 문제가 있었을 수도 있고요. 그래서 여기서는 편법에 가까운 대체안을 하나 선택했습니다.
// 중심점을 기준으로 스무스 효과 계산
float3 sphereNormal = normalize(i.posWS.xyz - _FaceCenter.xyz);
HNormalWS = lerp(sphereNormal, HNormalWS, ormTex_var.x);
중심점을 하나 정한 뒤, 곧바로 구체 노멀 같은 효과를 만드는 방식입니다. 단점은 구현이 너무 단순하고 거칠어서 원작 게임과는 여전히 차이가 꽤 난다는 점입니다.
아마 안정적인 하이라이트 형상을 보장하려면 커스텀 스무스 노멀 세트를 따로 제작해야 할 겁니다. 다만 저는 텍스처 기반 스무스 노멀을 완벽하게 복원하지 못했기 때문에, 우선 이런 방식으로 대체했습니다.

가짜 스무스 노멀
독자분들은 여기서 “머리카락에는 후속 하이라이트 계산용 스무스 노멀 한 세트가 필요하다”는 점만 이해하면 됩니다. 이 스무스 방식을 얻는 방법은 여러 가지가 있습니다.
저는 가장 거친 방식을 선택했을 뿐이니, 굳이 제 방식을 따라 할 필요는 없습니다.
이차원 하이라이트 구현
이차원 하이라이트라고는 했지만, 실제로는 여전히 머리카락 이방성 하이라이트 구현을 참고했습니다. 가장 보편적인 Kajiya-Kay Shading을 기반으로 합니다.
모델을 여러 개의 원기둥이 모인 구조로 보고, 하이라이트가 이 원기둥들 위에서 생성된다고 생각합니다. 이 원기둥들의 특수 노멀은 머리카락 방향 탄젠트로부터 구할 수 있고, 공식을 정리하면 하이라이트 결과를 탄젠트와 직접 연결할 수 있습니다. 참고할 만한 글은 많으니, 여기서는 한 편만 예로 들겠습니다.
그래서 하이라이트 형상은 커스텀 UV에 의존합니다. UV는 탄젠트와 밀접하게 관련되어 있으니까요. 다행히 이런 부분은 이미 아트 쪽에서 처리해두었습니다.
최종적으로 하이라이트를 계산하는 공식은 sqrt(1 - pow(dot(T, H)))이고, 이 결과 x에 다시 pow를 적용해 하이라이트의 크기와 형상을 제한합니다. 즉 pow(x, _specularPowStrength)입니다.
H를 얻는 방식은 아주 간단합니다. viewDir + mainLightDir이면 됩니다.
고민해야 할 부분은 머리카락 방향 탄젠트를 어떻게 얻을 것인가입니다. 이 방향은 머리카락 결을 따라 위로 올라가는 방향이라고 시각적으로 떠올리면 됩니다.
모델 방향은 사용하지 않기 때문에 Unity가 구해주는 탄젠트를 그대로 쓸 수 없습니다. 직접 다시 구해야 합니다.
float dotRight = dot(HNormalWS, cameraRight);
float3 cylinderNormal = normalize(HNormalWS - dotRight * cameraRight);
float3 flatHNormal = normalize(lerp(HNormalWS, cylinderNormal, _SpecularTrick_Flatten));
float3 fakeTangent = normalize(cross(float3(0,1,0), flatHNormal));
float3 hairBiNormal = normalize(cross(flatHNormal, fakeTangent));
일단 앞의 세 줄은 무시하고, 뒤의 두 줄부터 봅시다. fakeTangent = normalize(cross(float3(0,1,0), flatHNormal))로 가짜 탄젠트를 계산합니다. 그리고 이 탄젠트와 스무스 노멀을 외적해 머리카락 결 방향을 얻습니다. 간단히 기하적으로 그림을 그려보면 바로 이해될 겁니다.
이제 앞의 세 줄을 봅시다. 저는 완전히 평행하고 정돈된 하이라이트 효과를 만들기 위해 트릭을 하나 넣었습니다. 노멀에서 cameraRight와 겹치는 성분을 제거한 것입니다. 이렇게 하면 노멀이 어느 위치에서든 정면 쪽만 향하게 되고, forward와 up 방향 변화만 남으며 좌우 방향 변화는 사라집니다.
효과는 대략 아래와 같습니다.

이 트릭을 넣은 이유는, 광원 방향을 아무리 조정해도 게임 안의 정돈된 하이라이트 효과가 잘 나오지 않았기 때문입니다. 그래서 어쩔 수 없이 선택한 하책에 가깝습니다.
그다음 앞에서 view dir에 오프셋을 하나 더합니다.
float3 hairViewDir = normalize(viewDir + float3(0, _ViewDirYOffset * (1 - ormTex_var.x), 0));
여기서는 view dir의 Y에 오프셋을 줍니다. 이렇게 하면 최종 하이라이트 위치를 위아래로 이동시킬 수 있어, 이후 조정에 사용할 수 있습니다.
마지막으로 이 머리카락 결 방향을 Kajiya-Kay Shading 공식에 넣으면 아래와 같은 결과를 얻습니다.
float ToH_lut = dot(hairHalfDir, hairBiNormal_flatten);
float lutUV_u = 1 - ToH_lut * ToH_lut;

하이라이트 위치는 얻었습니다. 이제 남은 것은 어떻게 컬러링할 것인가입니다. 여기서는 아트가 준비해둔 컬러 텍스처를 사용해야 합니다.

계산된 하이라이트 위치 분포를 기반으로 이 컬러 텍스처를 샘플링해야 합니다.
변수명에서도 알 수 있듯이, 이미 U 방향 결과는 얻었습니다. 이제 V만 남았습니다. 이것도 사전 계산된 텍스처를 샘플링하기 위한 값이므로, V 계산의 유래를 깊게 따질 필요는 없고 제가 복각한 결과를 그대로 사용하면 됩니다.
// v 계산
float2 viewDirProj = float2(dot(viewDir, cameraRight), dot(viewDir, cameraForward));
float2 HNormalProj = float2(dot(HNormalWS, cameraRight), dot(HNormalWS, cameraForward));
float VoHN_horizontal = saturate(dot(viewDirProj, HNormalProj));
VoHN_horizontal = pow(VoHN_horizontal, _LutVPowStrength);
float directionMask = step(0.0, ToH_lut);
float lutUV_v = VoHN_horizontal * VoHN_horizontal * directionMask;
최종 샘플링 결과는 아래와 같습니다.

이 컬러로 F0를 보정한 뒤, PBR 로직을 타지 않는 간소화된 하이라이트 렌더링을 수행합니다.
float3 lutF0 = SpecularRefineF0Tex_var.xyz * F0;
float3 backF0 = _SpecularBackF0 * ormTex_var.w * pow(sqrt(1 - ToH_lut * ToH_lut), trunc(200 * _SpecularBackF0_ToHPowStrength));
float3 finalF0 = lutF0 * 7 + backF0;
// 일광 변화에 따른 암부 요소
float ao_shadow_lowLight = lerp(ao_shadow_NoFRamp, min_shadowEffect, _DayStrength);
float selfAoShadowEffect = lerp(_SelfAoShadowStrength, 1, ao_shadow_lowLight);
// 스페큘러 결과
float3 mainLightSpecularResult = mainLightColor_final * selfAoShadowEffect * finalF0;
여기서도 일광 직사 강도 변화를 도입했습니다. 앞부분 내용을 복각할 때도 항상 두 상태를 고려하고 있는지 주의해야 합니다.
또 하나의 트릭도 추가했습니다. 이미 계산한 하이라이트 결과 위에 Kajiya-Kay Shading을 한 번 더 계산해서 하이라이트 글로우 같은 효과를 만든 것입니다. 이것이 바로 backF0입니다.

마지막으로 둘을 합성하면 최종 하이라이트 결과를 얻습니다.

이후 디퓨즈 셰이딩과 림라이트는 toon base shader 로직을 참고해 구현하면 됩니다.
outline shader (외곽선)
여기까지 복각을 따라오셨다면, 글쓴이 입장에서는 정말 감사한 일입니다.
주요 렌더링 부분은 이제 모두 끝났고, 남은 것은 여러 자잘한 디테일을 정리하는 일입니다. 하지만 아트 관점에서 디테일에는 상한이 없습니다. 무한히 다듬을 수 있고, 90점과 100점의 차이도 바로 이런 부분에 있습니다.
그러니 조금만 더 버텨봅시다.
외곽선의 셰이딩 로직은 저는 그냥 toon base shader의 디퓨즈를 그대로 사용했습니다. 꽤 거친 방식입니다.
꼭 이렇게 할 필요는 없습니다. 광원 컬러, 광원 방향, 고유색 등을 바탕으로 직접 셰이딩 방안을 설계하면 됩니다.
여기서는 외곽 확장 구현만 설명하겠습니다.
노멀 외곽 확장
// 스무스 노멀 처리
float weightN = sqrt(saturate(1.0 - dot(v.uv1.xy, v.uv1.xy)));
float3 smoothNormalOS = v.uv1.x * v.tangent.xyz + v.uv1.y * biTangentOS + weightN * v.normal;
// 스무스 노멀을 뷰 공간(View Space)으로 변환
float3 smoothNormalVS = mul((float3x3)UNITY_MATRIX_IT_MV, smoothNormalOS);
// 뷰 공간 노멀을 클립 공간으로 투영
// XY만 사용하며, 이것이 화면상의 2D 확장 방향이 됨
float2 clipNormal2D = mul((float2x3)UNITY_MATRIX_P, smoothNormalVS);
// 정규화해서 기본 방향 길이를 1로 보장
float2 normal2D = normalize(clipNormal2D);
normal2D.x /= ScaleX;
// 깊이 보정
float depthScale = o.positionHCS.w;
float outlineDepthClamp = min(depthScale, 20.0);
// 거리 기반 사용자 정의 보정
float depthRefine = lerp(1, _ZMinRefine, smoothstep(1, 12, depthScale));
// 최종 2D 오프셋 벡터 계산
float2 offset = normal2D * (_OutlineWidth * outlineDepthClamp * 0.01 * depthRefine);
o.positionHCS.xy += offset;
스무스 UV 기반 노멀 계산, 카메라 거리 기반 외곽선 두께 보정, 그리고 마지막으로 스크린 공간 기준의 외곽 확장 크기 보정에 주의하면 됩니다.

왼쪽이 스무스 처리 후의 결과입니다.
fur shader (털 마감)
멀티 레이어 pass를 어떻게 실행하는지는 여기서 설명하지 않겠습니다. fur shader는 toon base shader의 모든 셰이딩 로직을 그대로 갖고 있으므로, 디퓨즈·스페큘러·IBL·림라이트를 모두 더해주면 됩니다.
여기서는 셰이프 맵 사용 방식을 중심으로 설명하겠습니다.
// Voronoi 셀 노이즈 실시간 계산
float2 scaledUV = i.uv * _ClumpScale;
float2 cellID = floor(scaledUV * 2.0);
float4 hashInput = float4(123.0, 123.0, 123.1, 123.1) + cellID.xyxy;
// 랜덤 각도
float rand1 = frac(sin(dot(hashInput.xy, float2(127.1, 311.7))) * 43758.5469);
float angle = rand1 * 6.28318548; // * 2π
// 랜덤 반경
float rand2 = frac(sin(dot(hashInput.zw, float2(127.1, 311.7))) * 43758.5469);
float radius = sqrt(rand2);
// 특징점 오프셋과 거리
float2 randomOffset = float2(cos(angle), sin(angle)) * radius * 0.25;
float2 cellCenter = cellID * 0.5 + float2(0.25, 0.25);
float2 featurePoint = cellCenter + randomOffset;
float2 diff = scaledUV - featurePoint; // r14.zw에 대응
float dist = length(diff); // r1.w에 대응(Voronoi 거리)
float4 flowMap_var = SAMPLE_TEXTURE2D(_NoiseTex, sampler_LinearRepeat, i.uv * _NoiseTex_ST.xy);
float3 flowMap = flowMap_var.xyz;
float2 flowDir = flowMap.xy * 2.0 - 1.0;
flowDir *= _FlowStrength;
// 레이어 높이 기반 랜덤 털 거칠기 Hash(Bug 수정의 w1.xx에 대응)
float jitter = frac(sin(dot(float2(i.h, i.h), float2(12.9898, 78.233))) * 43758.5469);
jitter = jitter * 2.0 - 1.0;
jitter = jitter * _JitterStrength * 0.05;
// 기본 오프셋 합성
float2 totalDistortion = flowDir * 0.005 + float2(jitter, jitter);
// 셀 중심으로 강제로 끌어당김(클러스터 오프셋)
totalDistortion = i.h * diff + totalDistortion;
// 최종 샘플링용 휘어진 UV 계산
// 셰이프 맵이 1이라 샘플링이 필요 없더라도, 여기서는 전체 로직을 복원
float2 shellUV = -totalDistortion * i.h + i.uv;
float thicknessThreshold = lerp(baseThickness, sqrt(baseThickness), _TaperCurve);
// 시야각 가장자리 보정(글레이징 각도에서 두께를 보강해 깨짐 방지)
float3 viewDirWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
float NoV = saturate(dot(i.normalWS, viewDirWS));
float heightCubedInv = 1.0 - (i.h * i.h * i.h);
float viewComp = -_ViewCompThreshold + NoV;
float grazingMask = viewComp + heightCubedInv;
// 셀 거리 기반 클러스터 감쇠
float clumpDist = min(1.0, dist * 2.8284);
float heightFactor = saturate(i.h * 2.0); // 위쪽 절반의 털만 강하게 수축
float clumpAttenuation = heightFactor * (clumpDist - 1.0) + 1.0;
// 셰이프 합성
float rawAlpha = clumpAttenuation * shapeTexMask;
// Smoothstep 전환 구간 [-0.25, 0.25] 구성
float2 bounds = float2(-0.25, 0.25) + thicknessThreshold;
float lowerBound = max(0.0, bounds.x);
float upperBound = min(1.0, bounds.y);
// smoothstep을 적용해 매우 부드러운 털 가장자리 생성
float smoothAlpha = smoothstep(lowerBound, upperBound, rawAlpha);
// 피부에 붙은 최하단 레이어는 반드시 불투명하게 유지
float isRoot = (i.h <= 0.01) ? 1.0 : 0.0;
float finalAlpha = lerp(smoothAlpha, 1.0, isRoot);
// 가장자리 보정 마스크 곱하기
finalAlpha = saturate(grazingMask * finalAlpha);
clip(finalAlpha - 0.003);
꽤 복잡합니다. 제대로 학습하고 이해하려면 AI에 넣어 분석시키는 편이 더 좋을 수도 있습니다. 여기서는 간단히 정리만 하겠습니다.
- Voronoi 노이즈로 클러스터 구조를 만들어, 털이 한 묶음씩 모여 있는 효과를 냅니다.
- 셰이프 맵의 XY 방향을 기반으로 털 오프셋 효과를 만듭니다. 다만 실험상 직접적인 효과는 크지 않았습니다.
- 셰이프 맵의 Z 채널로 털 디테일 형상 효과를 결정합니다.
- 마지막으로 smoothstep을 거쳐 분포를 부드럽게 제어합니다.
하지만 이것은 어디까지나 제가 복각한 효과일 뿐이고, 게임의 최종 효과와는 차이가 큽니다. 그래서 제 방식을 그대로 학습하라고 권하고 싶지는 않습니다. 직접 연구해서 더 나은 효과를 만들 수 있는지 시도해보는 편이 좋습니다. 저도 한동안 이것저것 만져봤지만 완전히 복각하지는 못했습니다.

불꽃 특수 효과는 제 중점 연구 대상이 아니어서 가볍게 구현만 해두었습니다. 호흡하듯 변화하는 효과도 넣지 않았습니다.
그냥 이펙트 노이즈 맵을 샘플링하고, 스케일을 조정한 뒤 컬러를 하나 주는 정도입니다. 그래서 원작 게임 효과와는 차이가 큽니다.
아, 그리고 컬링과 투명 효과를 적용하지 않고 단일 레이어로 보면 대략 아래와 같은 효과입니다. 복각 학습 중 대조용으로 보시면 됩니다.

먼저 일반적인 toon base shader처럼 구현하면 됩니다.
그 외 여러 trick shader
어린 양(小羊) 전신은 거의 다 분석한 것 같습니다. 남은 부분은 작은 trick shader들인데, 그중 반드시 구현해야 한다고 느낀 것들만 골라보겠습니다.
앞머리 그림자
가장 좋아하는 부분이고, 구현도 가장 쉬웠던 부분입니다.

프레임 캡처로 리소스를 확보할 때 이런 특수한 앞머리 모델을 얻을 수 있을 겁니다. 위치 오프셋까지 이미 잡혀 있으니, 그대로 템플릿 렌더링을 하면 됩니다.
셰이딩 방식은 취향대로 정하면 됩니다. 일광 직사 강도에 따라 변화시키거나, 광원 방향에 따라 오프셋을 만들거나, 광원 컬러에 따라 색상 영향을 주는 식으로 처리할 수 있습니다.
저는 일광 직사 강도 변화만 추가했습니다. 값이 0일 때는 앞머리 그림자를 제거합니다. 주광의 직접 조명이 없다면 이렇게 뚜렷한 그림자가 생기지 않는 편이 자연스럽기 때문입니다.
눈 영역 그림자

눈 영역 그림자 효과는 대부분의 게임과 비슷합니다. 면 하나를 준비해두고 투명 렌더링을 하는 방식입니다.
구현할 때는 가중치 텍스처 한 장을 사용합니다.

텍스처를 읽고 강도 그라디언트 효과를 만들면 됩니다. 수학 함수 피팅까지 할 필요도 없어서 꽤 편합니다.
포스트프로세싱
공통 포스트프로세싱은 세 가지입니다. LUT 컬러그레이딩 + Bloom + 안티에일리어싱입니다.
뒤의 두 가지는 독자분들 각자의 구현 방식을 따르면 됩니다.
LUT 컬러그레이딩에는 게임 안에서 아래와 같은 LUT 리소스를 확보해야 합니다.

동시에 URP 기본 LUT volume을 쓰지 말고, 준비된 feature를 직접 사용하는 편이 좋습니다. 컬러그레이딩은 포스트프로세싱 전에 실행하면 됩니다.


왼쪽이 컬러그레이딩 전, 오른쪽이 컬러그레이딩 후입니다. 차이가 꽤 크니 필요에 따라 LUT 영향 강도를 조정하면 됩니다.
마무리
이번 글의 길이가 지난번 flux water 글과 비슷해진 것 같습니다. 여기까지 읽어주신 독자분들께 다시 한번 감사드립니다.
이 글이 도움이 되었으면 좋겠고, 너무 길어서 지치지는 않으셨으면 합니다. 저는 확실히 이런 긴 글을 쓰는 걸 좋아합니다. 전반을 체계적으로 정리하는 편이 다른 분들이 배우기에도 더 좋다고 생각해서요. 너무 길다고 느껴진다면 AI 도구에 맡기면 됩니다.
지난번 글 이후 반년 넘게 시간이 흘렀고, 그 사이 꽤 바쁘긴 했지만 이것저것 많이 보고 플레이하기도 했습니다. Endfield는 역시 대만 서버가 제 취향에 잘 맞았습니다. 아트와 게임플레이가 모두 마음에 들고, 스토리는 일단 잠시 제쳐두겠습니다. 핵심은 믿음이죠. 그런데 제가 5번 연속 천장을 친 건 좀 이상하지 않나요?
아무튼 관례대로, 마지막에는 제가 추천하고 싶은 작품을 소개하겠습니다. 바로 곧 출시될 『칭송받는 자』 시리즈 최신작, 『칭송받는 자: 순백의 증명』입니다.

『칭송받는 자』의 오래된 팬으로서, 이번 작품이 시리즈 IP의 종결을 표방하는 것을 보니 마음이 참 복잡합니다. 모바일 게임도 끝을 향해 갔고, 후지와라 님은 너무 일찍 떠나셨으며, 타네다 님의 목소리에도 안타까운 일이 있었습니다. 그래도 모바일 게임 안에서 하쿠 같은 인물들에게 어느 정도 행복한 결말을 준 것은 다행이라고 생각합니다.
마지막으로 이 기회를 빌려 『칭송받는 자』 IP를 한번 홍보하고 싶습니다. 본질적으로는 문장과 스토리텔링이 매우 뛰어난 JRPG라고 볼 수 있고, 아주 고전적인 일본식 JRPG의 맛이 있습니다. 다만 『칭송받는 자』의 핵심 매력은 배경이 현대 인류의 기술 도시가 아니라는 점에 있습니다. 한 번의 세계적 위기 이후 농경 시대로 후퇴했지만, 과거의 핵심 기술은 여전히 남아 있는 SF적 배경이죠. 그 안에서 미소녀들과 모험하다 보면 묘하게 편안하고 여유로운 느낌을 받습니다. 사회 전체가 압박감은 있지만 또 그렇게까지 압박적이지는 않은 분위기라고 느껴진달까요(이차원 필터 포함). 아무튼 제발 『칭송받는 자』 한 번만 해주세요, hhhh.
솔직히 말해서, 『칭송받는 자』도 좋아하고 명일방주도 하는 사람을 또 어디서 찾겠습니까.
하고 싶은 말은 여기까지입니다. 다음 글은 아마 볼류메트릭 클라우드와 대기·하늘 관련 내용이 될 것 같습니다. 푸른 바다와 푸른 하늘은 너무 아름다우니, 어떻게든 전부 연구해봐야겠습니다.
그럼 독자 여러분의 학업과 일이 모두 순조롭기를 바랍니다. 다음에 다시 뵙겠습니다.
편집: 2026-04-20 18:17・쓰촨
원문
(71 封私信 / 30 条消息) 【unity urp】从零模仿复刻实现自己的终末地人物卡通渲染 - 知乎
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [번역] 넷이즈 레이훠 LGDC 시리즈 《영겁무간 모바일》RenderGraph 개조 공유 (0) | 2026.05.15 |
|---|---|
| [번역] 톤매핑만으로는 부족하다: 로컬 톤매핑 방안 정리 (0) | 2026.05.15 |
| 번역[GDC2025 Core Concept] Decoding Light: Neural Compression of Global Illumination. (1) | 2026.05.08 |
| [번역] UE5 코드 한 줄도 안 고치고 카툰 렌더링 외곽선 구현하기 (2) | 2026.05.04 |
| [번역] Colored Shadow Penumbra (4) | 2026.05.04 |