TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] 명일방주: 엔드필드 캐릭터 렌더링 (Unity URP)

jplee 2026. 4. 13. 00:33

역자의 말: 몇 달 전부터 내가 직접 리버싱 하면서 글을 하나 써 봐야겠다~ 싶었으나 고객사 두 곳의 구현 업무가 급격하게 난이도가 높아지면서 나름 시간에 쫓기다보니 이번에도 역시 중국 테크아티스트분의 포스팅을 번역해서 소개 하게 되었네요. 이해 바랍니다.ㅎ


저자: 如风

아트와 기술 모두 꽤 잘 만들어진 것 같아서 간단하게 구현해봤습니다. 아직 이해 못 한 부분도 많으니 고수분들의 조언은 언제나 환영입니다.

모델과 텍스처는 이 분의 작업을 참고했습니다 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 레이어가 하나 더 있는 것 같은데 아직 연구 중.


원문

https://zhuanlan.zhihu.com/p/2010357453523530486