역자의 말: 몇 달 전부터 내가 직접 리버싱 하면서 글을 하나 써 봐야겠다~ 싶었으나 고객사 두 곳의 구현 업무가 급격하게 난이도가 높아지면서 나름 시간에 쫓기다보니 이번에도 역시 중국 테크아티스트분의 포스팅을 번역해서 소개 하게 되었네요. 이해 바랍니다.ㅎ
저자: 如风
아트와 기술 모두 꽤 잘 만들어진 것 같아서 간단하게 구현해봤습니다. 아직 이해 못 한 부분도 많으니 고수분들의 조언은 언제나 환영입니다.

모델과 텍스처는 이 분의 작업을 참고했습니다 https://www.bilibili.com/video/BV13nyyBXEUs
【GooBlender】终末地多角色渲染整合工程-公开配布!_哔哩哔哩_bilibili
未经作者授权,禁止转载
www.bilibili.com
최종 결과 영상은 여기서 확인 가능합니다 https://www.bilibili.com/video/BV1gpfvBhEFX
终末地 角色渲染 初步_哔哩哔哩_bilibili
未经作者授权,禁止转载
www.bilibili.com
프레임 캡처 분석

- 얼굴: 원신, 소녀전선 2와 유사한 SDF 그림자 방식으로 보이나, 게임 내에서 그런 뚜렷한 명암 경계가 없는 걸 보면 여러 버전으로 수정된 것 같음
- 피부: Ramp 텍스처 사용
- 의상: 변형된 PBR
- 머리카락: 소녀전선 2와 유사한 느낌. 앞머리 부분에 특수 처리(반투명으로 보임)
- 앞이마 그림자: 오프셋 적용
- 동공: 마스크 중첩, 소녀전선 2와 유사
얼굴
자세히 분석하진 않았음.

데모 화면을 보면 코 하이라이트와 아랫입술 하이라이트가 추가된 것 같은데, 텍스처가 어디 있는지 못 찾겠음. 아시는 분은 알려주세요.
여기서는 SDF 그림자를 그대로 사용하고 Texture18(ao)을 곱하는 방식으로 처리했음.
결과는 이렇습니다.

코드
half4 frag(Varyings IN) : SV_Target
{
float3 N = normalize(IN.normalWS);
float3 V = normalize(IN.viewDirWS);
Light mainLight = GetMainLight();
float shadow = mainLight.shadowAttenuation;
half4 baseCol = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor;
half ao = SAMPLE_TEXTURE2D(_FaceAO, sampler_FaceAO, IN.uv).g;
float4 sdf = 1 - SAMPLE_TEXTURE2D(_FaceSDF, sampler_FaceSDF, TRANSFORM_TEX(IN.uv, _FaceSDF));
float sdfR = sdf.r * 2 - 1;
float sdfG = sdf.g * 2 - 1;
float sdfB = sdf.b * 2 - 1;
float side = smoothstep(-_FaceSoftness, _FaceSoftness, sdfR + _FaceBias);
float mainShade = smoothstep(-_FaceSoftness, _FaceSoftness, sdfG + _FaceBias);
float detailShade = smoothstep(-_FaceSoftness, _FaceSoftness, sdfB + _FaceBias);
float faceLight = side * (1 - ao);
// ================= 주광 =================
float3 directLightColor = lerp(_ShadowColor.rgb, baseCol.rgb * 1.1, faceLight)
* mainLight.color * shadow;
// ================= Rim Light =================
float3 dir = mainLight.direction;
dir.y = 0;
dir *= -_RimLightEffect;
float3 modifiedViewDir = normalize(V + dir);
float rimNdotV = dot(N, modifiedViewDir);
float rim = 1.0 - saturate(rimNdotV);
float range = max(0.001, 1.0 - _RimPower);
float softness = max(0.001, 1.0 - _RimSoftness);
rim = smoothstep(range - softness, range + softness, rim);
float3 rimColor = rim * _RimColor.rgb * _RimIntensity * 0.1 * shadow * ao;
// ================= 환경광 =================
// SH 환경광 샘플링
float3 ambient = SampleSH(N) * baseCol.rgb * (1 - ao);
// ================= 최종 색상 합성 =================
float3 finalColor = directLightColor + rimColor + ambient;
return half4(finalColor, 1);
}

찾았음. 립 하이라이트는 별도의 텍스처이고, 코 하이라이트는 턱 AO 마스크의 알파 채널에 들어 있었음.

피부

커스텀 Ramp 텍스처로 만들었음.

원본 텍스처는 이렇습니다.

맨 왼쪽이 왜 검은지 모르겠음. 블렌딩 알고리즘이 다른 것 같기도 함.
PBR 부분에는 간단한 GGX 하이라이트를 사용한 후 Ramp로 리매핑했음. Diffuse도 마찬가지로 완전한 물리 기반은 아님.
코드
// 1. 베이스 텍스처 & ORM 샘플링 (채널 매핑 로직 유지)
float4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv) * _BaseColor;
float4 orm = SAMPLE_TEXTURE2D(_ORMMap, sampler_ORMMap, input.uv);
// ================= SSAO 처리 =================
// 현재 픽셀의 스크린 UV 좌표 계산 (VR/XR 지원)
float2 screenUV = GetNormalizedScreenSpaceUV(input.positionCS);
#if defined(_SCREEN_SPACE_OCCLUSION)
// URP의 SSAO 오클루전 팩터 가져오기
AmbientOcclusionFactor aoFactor = GetScreenSpaceAmbientOcclusion(screenUV);
// 환경광 차폐에는 indirectAmbientOcclusion 사용
float ssao = aoFactor.indirectAmbientOcclusion;
#else
float ssao = 1.0;
#endif
float ao = orm.b * ssao;
float roughness = 1.0 - orm.a;
float matellic = orm.r;
float oneMinusReflectivity = 1.0 - matellic;
// 2. 노멀 맵 샘플링 및 언팩
half4 normalSample = SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, input.uv);
half3 tangentNormal = UnpackNormalScale(normalSample, _BumpScale);
// 3. 탄젠트 공간 노멀 → 월드 공간 변환
float3 N = normalize(tangentNormal.x * input.tangentWS
+ tangentNormal.y * input.bitangentWS
+ tangentNormal.z * input.normalWS);
// 4. 주광원 & 그림자 획득
float4 shadowCoord = TransformWorldToShadowCoord(input.positionWS);
Light mainLight = GetMainLight(shadowCoord);
float shadow = mainLight.shadowAttenuation * mainLight.distanceAttenuation;
float3 lightColor = mainLight.color;
// 5. 필요한 벡터 준비
float3 L = normalize(mainLight.direction);
float3 V = normalize(_WorldSpaceCameraPos - input.positionWS);
float3 H = normalize(L + V);
float NoL = dot(N, L);
float ndotl = NoL;
float NdotV = dot(N, V);
float NdotH = dot(N, H);
float ldoth = dot(L, H);
// ================ Diffuse Ramp 로직 ========================
float x = max(0, NoL) * shadow + _RampOffset;
float2 UVramp = half2(x, 1.0 - _RampMapSkin);
half3 ShadowRamp1 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, UVramp).rgb;
half3 finalRamp = ShadowRamp1;
half3 diffuseTerm = baseColor.rgb * finalRamp * lightColor * oneMinusReflectivity;
// ================ Ramp 하이라이트 (BRDF) 로직 ========================
float clampedNdotV = ClampNdotV(NdotV);
float3 fresnel0 = ComputeFresnel0(baseColor.xyz, _Metallic * matellic, DEFAULT_SPECULAR_VALUE);
float3 F = F_Schlick(fresnel0, ldoth);
float DV = DV_SmithJointGGX(NdotH, abs(ndotl), clampedNdotV, max(_Roughness, 0.05) * roughness);
float specRange = saturate(DV);
float3 specRampCol = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(specRange, 0.9)).rgb;
float3 specTerm = F * clamp(specRampCol + DV, 0, 10) * lightColor * ao * shadow;
// ================ 림 라이트 (Rim Light) =====================
float3 dir2 = mainLight.direction;
dir2.y = 0;
dir2 *= -_RimLightEffect;
// 이미 계산된 V(ViewDir)와 노멀맵 적용 N 재사용
float3 modifiedViewDir = normalize(V + dir2);
float rimNdotV = dot(N, modifiedViewDir);
float rim = 1.0 - saturate(rimNdotV);
float rimRange = max(0.001, 1.0 - _RimPower);
float rimSoft = max(0.001, 1.0 - _RimSoftness);
rim = smoothstep(rimRange - rimSoft, rimRange + rimSoft, rim);
// 최종 림 라이트 색상 계산
float3 rimColor = rim * _RimColor.rgb * _RimIntensity * 0.1 * shadow * ao;
// ================ 환경광 (IBL) =====================
float3 R = reflect(-V, N);
float3 indirectSpecular = GlossyEnvironmentReflection(R, input.positionWS, roughness, 1.0);
// 환경 반사에도 프레넬 및 AO 적용
// 시야 각도에 따른 반사 강화를 시뮬레이션하는 단순화된 프레넬
float f0_env = max(fresnel0.r, max(fresnel0.g, fresnel0.b));
float3 envFresnel = fresnel0
+ (max(f0_env, 1.0 - roughness) - fresnel0)
* pow(1.0 - clampedNdotV, 5.0);
float3 envReflection = indirectSpecular * envFresnel * ao;
half3 ambient = SampleSH(N) * baseColor.rgb * ao * oneMinusReflectivity;
// ================ 최종 합성 ========================
half3 finalColor = ambient + envReflection
+ diffuseTerm + specTerm
+ rimColor;
return half4(finalColor, baseColor.a);
의상
피부와 동일한 방식 사용. 피부 파트 참고.
금속도는 Diffuse에 곱해줘야 물리적으로 자연스러움. 그렇지 않으면 금속이 하얗게 떠 보임.
IBL 부분도 유사함.
머리카락

이방성 하이라이트 + 천사 고리
- 이방성 × 천사 고리 마스크면 됨. 위아래로 다르게 표현하는 방법은 아직 모름.


PRTGI
엔드필드에는 컬러풀한 3D 텍스처가 여러 장 있음. 글로벌 일루미네이션용으로 추측되는데, Texture5, 7, 9가 정확히 어디에 쓰이는지 아시는 분은 알려주세요.
아웃라인
다른 셀쉐이딩 게임들과 유사한 방식.
포스트 프로세싱
아래와 비슷하게 했는데 채도가 너무 낮게 나왔음.

SSAO
AO 레이어가 하나 더 있는 것 같은데 아직 연구 중.
원문
'TECH.ART.FLOW.IO' 카테고리의 다른 글
| [발표 번역] UF2025(Shanghai)—《델타포스》글로벌 일루미네이션 기술 심층 분석: Lightmap에서 Lumen까지 크로스플랫폼 구현의 여정 (0) | 2026.04.10 |
|---|---|
| [번역] UE5 포스트 프로세스 ScreenPass에서 에디터 뷰포트 UV를 올바르게 가져오기 (3) | 2026.04.10 |
| [번역] 제로에서 구현한 간편한 FluidFluxWater 물 렌더링 파트 2 (0) | 2026.04.04 |
| [번역] 제로에서 구현한 간편한 FluidFluxWater 물 렌더링 파트 1 (0) | 2026.04.01 |
| [번역] Unreal Engine의 UAV Overlap 특성과 나의 기나긴 사투 (0) | 2026.03.27 |