TECHARTNOMAD | TECHARTFLOWIO.COM

카테고리 없음

[번역/추가] 명조 캐릭터 효과 분석 복원

jplee 2025. 9. 22. 21:50

역자의 말.

최근 가장 재미있게 즐기는 게임이 명조다. 
재미있게 라는 Context 는 매우 상대적인지라 누구에게 뭐라고 하기 힘든부분이다.
내 블로그에서 명조에 대한 내용을 꽤 다룬 적이 있다.

[번역]2차원 오픈 월드의 포스트 아포칼립스 미학을 탐구하다 | 鸣潮(명조) 개발 인터뷰

에픽 차이나에서 진행 한 명조 개발자 인터뷰. 2024년 4월 1일. 위쳇 마이크로 블로그 애픽 차이나 페이지에 실린 기사를 탐독 합니다. 이하 번역문. 2차원 오픈 월드 게임이라고 하면 자유도 높은

techartnomad.tistory.com

[번역] 《명조》언리얼 엔진 4를 기반으로 한 멀티 플랫폼 이펙트 및 퍼포먼스 최적화 사례.

역자의 말. 최근 명조 게임이 오픈 한 이후 날마다 명조 게임 광고를 보게 되는군요! 렌더링 쪽에 깊게 관여한 Weedowo 군이 언리얼 페스트 2024 쇼케이스에서 발표 한 내용을 본인의 블로그에 간략

techartnomad.tistory.com

중국 테크니컬 아티스트가 렌더독으로 분석 후 나름의 역구현을 해 본 간단한 블로그 내용을 공유하고자 한다.


서문

왜 PBR 기반 NPR 렌더링 접근을 선택했는가? 팀의 사전 연구 단계에서 ‘鸣潮(명조)’ 캐릭터 표현을 리버스 엔지니어링해 지식 자산을 축적하고, 향후 회고가 용이하도록 하기 위함이다. NPR 방식은 다양하다. 대표적으로 Guilty Gear, Zelda, 원신이 있고, 차별화를 시도한 사례로는 Granblue Fantasy Relink 초기 빌드, 미호요의 절구영(ZZZ), Hypergryph의 미출시작, 서풍(서山居)의 Dust White Zone 등이 있다. 이번에 다루는 ‘鸣潮(명조)’의 캐릭터 렌더링은 개발사 이전 타이틀 ‘전쌍’의 PBR 기반 NPR 파이프라인을 계승한 형태로 보인다.
이번 구현은 전방 렌더링을 사용한다. 원작은 지연 렌더링을 쓰기 때문에 완전 동일 재현은 어렵다. 예컨대 원작은 ShaderModelID류의 마스크 채널을 활용해 파트별 처리를 분기한다. 전방 렌더링이지만 가시 결과는 최대한 근접하게 맞춘다.

아트 자원 분석

2.1 모델 자원

  • UV 4세트
  • Vertex Color, Normal 포함

2.2 텍스처 자원

  • 총 8개 파트. 파트당 1~4장 텍스처 사용

캐릭터 효과 재현

3.1 PBR 라이팅 모델

Unity PBR을 사용한다. 직접광과 간접광으로 구분한다.

3.1.1 직접광

Unity 기준 DirectDiffuse와 DirectSpecular로 나눈다.

  • DirectDiffuse: albedo와 metallic으로 산출
  • DirectSpecular: Unity의 Cook–Torrance BRDF 단순화 버전을 사용. 일부 항은 근사 적용

아래는 Cook–Torrance 관련 코드이다.

// Cook-Torrance BRDF(간략화) 스페큘러 항 계산
// 입력: BRDFData, 월드 법선 N, 광원 방향 L, 카메라 방향 V
half DirectBRDFSpecular(BRDFData brdfData, half3 normalWS, half3 lightDirectionWS, half3 viewDirectionWS)
{
    // 하프벡터 H = normalize(L + V)
    float3 halfDir = SafeNormalize(float3(lightDirectionWS) + float3(viewDirectionWS));

    // N·H, L·H 클램프
    float NoH = saturate(dot(normalWS, halfDir));
    half  LoH = saturate(dot(lightDirectionWS, halfDir));

    // 러프니스 기반 보정 항 (d)
    float d = NoH * NoH * brdfData.roughness2MinusOne + 1.00001f;

    // 간단화된 기하 감쇠 근사 사용
    half LoH2 = LoH * LoH;

    // 정규화 계수 포함 최종 스페큘러 항
    half specularTerm = brdfData.roughness2 / ((d * d) * max(0.1h, LoH2) * brdfData.normalizationTerm);

    return specularTerm;
}

3.1.2 간접광

간접광(GI)은 Unity에서 Light Probe, Lightmap, Reflection Probe로 구성된다. 일반적으로 프레넬 항을 추가하지만, 이후 ‘등치 림라이트’를 별도 처리하므로 본 구현에서는 프레넬 항을 제거했다.
아래는 간접광 계산 코드이다.

// GI(간접광) 계산: 베이크/리얼타임 GI를 통합하여 환경반사/확산을 결합
half3 NPRGlobalIllumination(
    BRDFData brdfData,
    half3 bakedGI,                 // 베이크된 간접광(라이트프로브/라이트맵)
    half3 normalWS,                // 월드 법선
    half3 viewDirectionWS,         // 카메라 방향
    half  occlusion = 1.0f)        // AO 계수
{
    // 반사 벡터 R = reflect(-V, N)
    half3 reflectVector = reflect(-viewDirectionWS, normalWS);

    // 등치 림라이트를 별도 처리하므로 프레넬 항 제거
    half fresnelTerm = 0;

    // 간접 확산/스페큘러 분리 계산
    half3 indirectDiffuse  = bakedGI * occlusion;
    half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, brdfData.perceptualRoughness, 1);
    
    // 환경 BRDF 합성
    half3 color = EnvironmentBRDF(brdfData, indirectDiffuse, indirectSpecular, fresnelTerm);
    return color;
}

3.2 Matcap 적용

‘鸣潮(명조)’의 matcap 운용은 일반적 사례와 일부 유사하나, 계산 방식이 다르다. 금속 하이라이트 용도로 사용하되, 결과를 albedo에 가산하는 구조다. 캡쳐 프레임 분석 결과 albedo 상에 하이라이트가 존재한다.
가산 시 최종 채도가 과도해질 수 있어 약한 채도 저감으로 보정한다.

// Matcap 샘플 후 채도 약간 낮춰 금속 하이라이트를 알베도에 가산
// metallic 임계 이상에서만 기여
half4 matcap = SAMPLE_TEXTURE2D(_MatCap, sampler_MatCap, input.matcapUV);
albedo += Desaturate(matcap.rgb, 0.1) * step(.80, metallic) * _MatcapStrength;

3.3 그림자(Shadow)

원작은 LUT를 사용해 N·L 구간을 x축으로, 파트 ID를 y축으로 샘플링한다. 테스트 결과 색감 차이가 커서(원작 측 LUT에 톤 변환 파라미터 개입 가능성) 근사 전략을 채택했다.

3.3.1 그림자 영역

// N·L 결과를 smoothstep으로 완화하여 경계 제어
float smoothLambert = smoothstep(0.0, _Grey, (NdotL * 0.5 + 0.5) + _Dark);

3.3.2 그림자 색

  • LUT 기반
  • Vertex Color 기반 음영 색 페인팅
  • HSV 변환 기반 암부 색 산출
  • 본 구현: 지정 색을 diffuse에 곱하는 단순 근사

3.4 상하 그라디언트

모델의 특정 UV 채널을 이용해 상하 그라디언트를 적용한다. 모델 공간 Y를 직접 쓰면 애니메이션 포즈에 따라 뒤집힘 문제로 일관성이 깨지므로 UV 기반이 안전하다. 상체에 시선 집중을 유도하는 보정으로 유효하다.

// 최종색에 Multiply-Blend 형태로 하부 색을 섞고 UV 그라디언트로 보간
finalColor = lerp(finalColor * lowColor, finalColor, gradingValue);

3.5 림라이트

여러 접근이 가능하다.

  • 프레넬 기반 step/smoothstep
  • 깊이 컨볼루션 기반(Pre-Depth 필요)
  • 지연 렌더링의 ShaderModelID 채널 기반 컨볼루션

3.5.1 프레넬 림

저면수 영역에서 단절이나 폭 불균일이 발생할 수 있다.

// edgeLight: 피네일 기반 림라이트 계산
// Params:
//   NdotV      - N·V(0~1)
//   baseColor  - 베이스 색
// Returns:
//   림 색(rgb)
float3 edgeLight(float NdotV, float3 baseColor)
{
    float3 fresnel = pow(1 - NdotV, _fresnel);
    fresnel = step(0.5, fresnel) * _edgeLight * baseColor;
    return fresnel;
}

3.5.2 깊이 컨볼루션 림

전방 렌더링에서 흔히 쓰는 방식. 불투명 큐에서 샘플하면 PreDepthBuffer 비용이 있고, 투명 큐는 오브젝트 간 정렬 이슈를 관리해야 한다.

// Character_Rim: 깊이 소벨 기반 림 마스크 생성
// Params: screenUV(0~1), rimWidth, rimLightThreshold
float Character_Rim(float2 screenUV, float rimWidth, float rimLightThreshold)
{
    float2 uv = screenUV;
    float Center = SampleSceneDepth(uv);
    float dis    = LinearEyeDepth(Center, _ZBufferParams);
    float DistanceAlpha = (1.0 - min(dis / 80, 1.0));
    float Width = 1.0f * rimWidth * DistanceAlpha;
    float2 ans = float2(1 / _ScreenParams.x, 1 / _ScreenParams.y);
    float2 Offset = ans * Width;

    float2 MaxUV = float2(1, 1);
    float UpLeft  = SampleDepthCmp(Center, uv + float2( Offset.x,  Offset.y));
    float Up      = SampleDepthCmp(Center, uv + float2( 0,         Offset.y));
    float UpRight = SampleDepthCmp(Center, min(uv + float2(-Offset.x, Offset.y), MaxUV));

    float dX = UpLeft - UpRight;
    float dY = UpLeft - Center + (Up - Center) * 2.0 + UpRight - Center;
    float Sobel = sqrt((dX * dX + dY * dY) * 5.0f);
    Sobel = step(rimLightThreshold * Center, Sobel);
    return Sobel * DistanceAlpha;
}

3.6 헤어 하이라이트

일반적으로 헤어 하이라이트 마스크에 역 프레넬을 곱해 동적 감을 준다.

// 헤어 하이라이트 적용: N·V 임계 + HM 마스크
float NdotV = step(0.3, dot(normalWS, viewWS));
float4 hmMap = SAMPLE_TEXTURE2D(_Hair_HM_Tex, sampler_Hair_HM_Tex, input.uv);
specular += hmMap.r * _SpecularColor.rgb * NdotV;

3.7 앞머리(헤어) 그림자

헤어 전용 RT를 선행 렌더한 뒤, 화면좌표 오프셋 샘플로 얼굴 광량을 감산한다. 조명 방향을 반영해 오프셋을 좌우로 가변화하면 정확도가 올라간다.

float hairShadow = SAMPLE_TEXTURE2D(_HairShadowRT, sampler_HairShadowRT, positionSS + float2(_FaceShadowOffsetX, _FaceShadowOffsetY)).r;
NdotL *= (1 - hairShadow.r);

3.8 얼굴 SDF 그림자

얼굴 명암 분리를 위해 SDF 텍스처 2채널을 사용한다. 한 채널은 렘브란트 라이트 패턴, 다른 채널은 광원 방향에 따른 암부→명부 완만 전이를 담당한다. 얼굴의 전방/상방 기준 벡터를 정의하고, 좌/우 벡터로 광 방향 민감도를 추출해 SDF 판정에 사용한다.

float NPRFaceSDF(float3 lightDir, float3 frontDir, TEXTURE2D_PARAM(_SDFMap, sampler_SDFMap), float2 uv, float lightSmooth)
{
    float3 L = normalize(float3(lightDir.x, 0.0, lightDir.z));
    float3 F = TransformObjectToWorldDir(frontDir, true);
    F = float3(F.x, 0, F.z);
    float lightAtten = 1 - (dot(L, F) * 0.5 + 0.5);

    float3 up = float3(0,1,0);
    float3 left = cross(F, up);
    float flipSign = sign(dot(L, left));

    float4 sdf = SAMPLE_TEXTURE2D(_SDFMap, sampler_SDFMap, float2(flipSign, 1) * uv);
    return smoothstep(lightAtten - lightSmooth, lightAtten, sdf.b) * step(lightAtten, sdf.a);
}

3.9 눈 렌더링

3.9.1 눈 구조 요약

애니메이션 스타일에서는 동공/홍채, 공막(흰자), 전방(방수로 채워진 공간) 표현이 핵심이다. 전방은 동공/홍채의 깊이감을 형성한다.

3.9.2 단순화된 구현

본 데모는折射은 생략하고, PBR 조명 + 스페큘러 마스크(=자발광 유사)로 표현한다. 필요 시 기본적인 시차(parallax) 모델로 굴절 근사를 추가할 수 있다.

float2 viewL = mul(viewW, (float3x2)worldInverse);
float2 offset = height * viewL;
offset.y = -offset.y;
texcoord -= parallaxScale * offset;

주의: 로컬/탱gent 공간 축 차이로 UV 오프셋 방향이 달라질 수 있으므로, 일반적으로 탱전트 공간에서 처리하는 편이 안전하다.

3.10 외곽선(Outline)

멀티 패스 법선 외향 확장 방식 사용. 탠전트 기반 스무딩 대신, Vertex Color 채널로 억제 마스크를 둔다.

3.10.1 외곽선 폭

카메라 거리 기반 가중으로 근/원거리 폭을 보간한다.

// 거리 기반 외곽선 폭 반환(근/원거리 가중 보간)
float DistanceOutlineWidthFuntion(float3 posWS, float3 cameraPos)
{
    float3 nearfar = float3(0.005, 1, 10);
    float3 weight  = float3(0.13, 0.3, 1.5);
    float  dis     = distance(

3.10.2 외곽선 색

  • 저해상도 색상 맵으로 파트별 색 지정
  • ID(마스크) 기반 파트 분리 후 파라미터로 색 지정(피부/비피부 등)
  • 주 텍스처 색과의 혼합

포스트 프로세싱

핵심 후처리는 AA, Bloom, Tonemapping이다.

  • AA: URP FXAA 사용
  • Bloom: URP Bloom 사용
  • Tonemapping: UE 스타일 톤매핑 적용

용어 링크 모음