← All Posts

UE5 커스텀 셰이딩 모델: Fake Iridescent Specular 구현

Reminiscence
UE5 커스텀 셰이딩 모델: Fake Iridescent Specular 구현
Content imagemeants.dev

들어가며

이전 업무를 다시 복각해보기 위해 UE 5.8 소스 빌드에 MSM_Iridescent / SHADINGMODELID_IRIDESCENT 커스텀 셰이딩 모델을 추가하는 작업을 진행하였다.

이번 목표는 비늘, 자갈, 모공 같은 노멀맵 셀 각각에 무지개빛 specular가 맺히고 glossy highlight나 grazing 영역에서 자연스럽게 드러나는 룩을 만드는 것이었다.

그래서 머티리얼 그래프의 Custom Node나 Material Function만으로 처리하지 않고, 엔진의 셰이딩 모델 목록, GBuffer, Material CustomData, deferred/direct/base-pass lighting 경로에 새 셰이딩 모델을 추가하는 방식으로 진행했다.

주요 특징은 다음과 같다.

  • normal-map local detail에서 iridescent phase를 만든다.
  • CustomData.r/a는 Subsurface Profile 호환성을 위해 유지하고, CustomData.g/b를 iridescent용으로 사용한다.
  • full-resolution SSS에서 colored specular가 약해지는 문제는 SHADINGMODELID_IRIDESCENT 전용 후처리 tint로 보완한다.
  • 모바일 non-extended GBuffer, ray tracing, path tracing은 별도 검토가 필요하다.

왜 ThinFilm으로 표현하지 않았을까

처음 떠올릴 수 있는 접근은 thin-film interference다. 비눗방울, 기름막, 코팅된 표면처럼 얇은 막에서 생기는 간섭색은 thin film으로 설명할 수 있다. 얇은 막의 위아래 경계에서 반사된 빛이 서로 간섭하고, 흰색광 안의 특정 파장이 강화되거나 약해지면서 색이 생긴다.

하지만 내가 원한 것은 넓은 표면 전체가 각도에 따라 바뀌는 큰 그라디언트가 아니었다. 목표는 sunbeam snake나 비늘 표면처럼, 작은 표면 단위마다 무지개색이 따로 맺히는 느낌이었다.

이 차이는 중요하다. Thin-film BRDF는 view angle, light angle, film thickness, IOR 같은 값으로 색을 만든다. 매끈한 표면에 적용하면 보통 넓은 각도 기반 색 변화가 나온다. 반면 비늘이나 모공 같은 작은 표면 단위에 색이 맺히려면, 표면 안에 충분한 normal/orientation variation이 있어야 한다.

A Biologically-Inspired Appearance Model for Snake Skin은 이 방향을 꽤 직접적으로 보여준다. 이 모델은 snake skin을 두 레이어로 나눈다. 위쪽은 thin-film interference로 iridescent reflection을 만들고, 아래쪽은 absorbing diffuse layer로 어두운 기저 반사를 만든다. 특히 중요한 점은 scale mesogeometry를 실제 scale mesh가 아니라 tileable bump mapping으로 모델링한다는 것이다. 이는 실시간 셰이더에서 실제 scale geometry 대신 normal map local slope를 사용하는 접근과 직접적으로 연결된다.

이 논문은 실제 snake skin의 iridescent layer가 quasi-regular photonic structure와 platelet 구조를 갖지만, 효율을 위해 single thin iridescent layer로 근사한다고 설명한다. 또한 platelet orientation distribution을 넣으면 표현력은 좋아지지만 비용이 커질 수 있다고 언급한다. 즉 물리적으로 더 풍부한 모델이 가능하더라도, 실용적인 렌더링에서는 구조를 단순화하고 mesogeometry나 bump mapping으로 표면 디테일을 보강하는 선택이 필요하다.

Stanford CS348B의 sunbeam snake 프로젝트도 비슷한 관점에서 볼 수 있다. 이 프로젝트는 PBRT에 thin-film interference BRDF를 구현했지만, 동시에 실제 scale geometry를 만들고 수천 개의 scale instance를 배치했다. 결과 이미지의 큰 무지개 흐름은 몸의 곡률과 view/light angle을 따라가지만, 각 scale geometry의 normal/orientation 차이가 하이라이트를 잘게 끊고 표면 디테일에 붙게 만든다. 즉 이 사례는 “비늘마다 임의의 색을 부여한다”기보다, 실제 scale geometry가 thin-film 반응을 표면 디테일에 결합시키는 예로 보는 편이 정확하다.

생물 표면의 iridescence를 다룬 자료들도 비슷한 방향을 보여준다. 뱀 피부의 scale nanostructure 연구는 표면 미세구조가 structural iridescence와 관련될 수 있음을 다룬다. Morpho butterfly wing scale 연구도 비늘 내부의 thin film lower lamina와 multilayer upper lamina가 함께 iridescence에 기여한다고 설명한다. 즉 생물 표면의 무지개빛은 단순히 “색 함수” 하나가 아니라 표면 구조와 강하게 연결되어 있다.

게임 캐릭터나 실시간 피부 셰이더에서 오프라인 렌더링처럼 수천 개의 실제 scale geometry를 배치하는 것은 현실적이지 않다. 대부분의 디테일은 normal map에 들어간다. 그래서 실제 scale geometry의 orientation variation을 normal map의 로컬 기울기로 근사하는 방향을 택했다.

이 접근은 bump mapping과 normal mapping의 기본 아이디어와도 맞닿아 있다. Blinn의 bump mapping은 실제 기하를 바꾸지 않고 표면 노멀을 perturb해서 표면 요철처럼 보이게 만드는 방식이다. PBRT의 bump mapping 설명도 geometry를 직접 만들지 않고 shading normal을 계산해 displaced surface처럼 보이게 하는 관점으로 설명한다.

정리하면, thin-film은 색이 생기는 물리적 배경으로는 중요하지만, 내가 원하는 비늘/모공 단위의 색 분포를 만들려면 normal-map local detail에서 phase를 직접 만들어야 했다.

Subsurface Profile을 기반으로 잡은 이유

자세하게 이야기 할 수 없지만 MSM_Iridescent는 피부에 무지개빛 specular를 얹어보고 싶다는 의도에서 시작했다. 피부 표현에는 SSS profile lighting도 필요했기 때문에, 완전히 별개의 셰이딩 모델을 만들기보다는 Subsurface Profile 계열 동작을 기반으로 잡는 편이 자연스러웠다.

대신 이 방식은 GBuffer에 어떤 값을 저장할지 조심해야 한다. Subsurface Profile도 이미 profile id, opacity, 모바일 curvature 관련 값에 CustomData를 사용하기 때문이다. 구체적으로 어떤 채널에 무엇을 넣었는지는 아래 구현 섹션에서 다시 정리한다.

Curvature Outpin

Content imagemeants.dev

일반 MSM_SubsurfaceProfile에서는 CustomData0 핀이 Curvature로 표시된다. 하지만 이 입력은 데스크톱 deferred SSS의 핵심 입력이라기보다 모바일 Subsurface Profile용 입력에 가깝다.

근거도 코드상 명확하다.

MP_CustomData0MSM_SubsurfaceProfile에서 Curvature로 표시되지만, MATERIAL_SUBSURFACE_PROFILE_USE_CURVATURE는 모바일 feature level인 ERHIFeatureLevel::ES3_1 조건에서만 켜진다.

그리고 실제 shader에서도 **GetMaterialCustomData0(PixelMaterialInputs)**를 읽어 GBuffer.Curvature에 넣는 코드는 SHADING_PATH_MOBILE 안에 있다.

plain text
#if SHADING_PATH_MOBILE
    #if MATERIAL_SUBSURFACE_PROFILE_USE_CURVATURE
        GBuffer.Curvature = GetMaterialCustomData0(PixelMaterialInputs);
    #else
        GBuffer.Curvature = CalculateCurvature(
            GBuffer.WorldNormal,
            MaterialParameters.WorldPosition_CamRelative
        );
    #endif

    GBuffer.Curvature = clamp(GBuffer.Curvature, 0.001f, 1.0f);
#endif

즉 핀 이름은 Curvature로 노출되지만, 해당 값이 GBuffer.Curvature로 들어가는 경로는 모바일 셰이딩 경로에 묶여 있다. 또한 shader 쪽에서도 CurvatureCustomData.r/g/b/a 안의 값이 아니라 FGBufferData의 별도 필드다.

plain text
// Curvature for mobile subsurface profile
half Curvature;

MSM_Iridescent에서는 CustomData0를 Iridescent Intensity로 쓰기 때문에, 일반 Subsurface Profile처럼 CustomData0Curvature로 동시에 사용할 수 없다. 그래서 모바일 curvature에서 이 기능을 사용해야 할 경우 매직넘버 값을 찾아서 고정시키거나 다른 방법을 찾아야 할 수 있다.

셰이딩 모델 구현

사전 조사 이후 실제 구현은 크게 세 단계로 나눴다.

  1. 머티리얼 입력을 GBuffer에 저장한다.
  2. 노멀맵 로컬 기울기에서 iridescent phase를 만든다.
  3. lighting과 full-resolution SSS 후처리에서 phase를 specular tint로 사용한다.

머티리얼 입력을 GBuffer에 저장하기

셰이딩 모델에서 사용할 값은 lighting 단계까지 전달되어야 한다. UE의 deferred path에서는 머티리얼에서 계산한 값을 GBuffer에 패킹하고, lighting shader에서 다시 읽는다.

MSM_Iridescent에서는 Subsurface Profile과의 호환성을 유지하면서 iridescent에 필요한 값을 GBuffer.CustomData에 저장했다.

plain text
CustomData.r = Subsurface Profile ID
CustomData.g = Iridescent Intensity
CustomData.b = Normal-map local iridescent phase
CustomData.a = Opacity / SSS radius scale
GBuffer.Curvature = mobile Subsurface Profile helper value, not CustomData

언리얼 엔진의 일반 Subsurface Profile은 profile id를 CustomData.r에 저장한다. ExtractSubsurfaceProfileInt(...)CustomData.r만 읽는다. 그래서 데스크톱 deferred 경로 기준으로는 g/b를 iridescent 전용 데이터로 재사용할 수 있다.

반대로 CustomData.rCustomData.a는 건드리지 않는 편이 안전하다. r은 profile asset 선택에 필요하고, a는 opacity 또는 SSS radius scale 계열 값으로 쓰인다.

머티리얼에서 조절하는 값은 두 개다. 이 중 Iridescent Shift는 phase 생성에 직접 들어가고, Iridescent Intensity는 이후 lighting/postprocess 단계에서 tint가 얼마나 강하게 적용될지를 조절한다.

  • Iridescent Intensity: MP_CustomData0 기반이며 GBuffer.CustomData.g에 저장된다. phase를 만들지는 않고, specular tint와 full-resolution SSS 후처리 tint의 전체 강도를 제어한다.
  • Iridescent Shift: MP_CustomData1 기반이며 노멀맵에서 계산한 phase에 더해진다. 단순 색 강도가 아니라 phase offset에 가깝게 동작한다.

노멀맵 디테일에서 phase 만들기

phase 생성의 핵심 채널은 CustomData.b다. 예전처럼 사용자가 넣은 shift만 저장하는 것이 아니라, 노멀맵 로컬 디테일에서 계산한 phase와 사용자 offset을 합친 값을 저장한다.

plain text
float3 PixelNormal = normalize(GBuffer.WorldNormal);
float3 TangentX = normalize(MaterialParameters.TangentToWorld[0]);
float3 TangentY = normalize(MaterialParameters.TangentToWorld[1]);

float2 NormalDetail = float2(
    dot(PixelNormal, TangentX),
    dot(PixelNormal, TangentY)
);

float DetailAmount = saturate(length(NormalDetail) * 2.0);
float NormalDetailPhase =
    dot(NormalDetail, float2(11.31, 17.17)) + DetailAmount * 5.0;

GBuffer.CustomData.b = frac(
    NormalDetailPhase * 0.15915494 +
    GetMaterialCustomData1(PixelMaterialInputs)
);

여기서 중요한 점은 GBuffer.WorldNormal을 그대로 dot(N, V)에 넣지 않았다는 것이다. 픽셀 노멀을 tangent/bitangent 축에 투영하면, 최종 노멀이 표면의 로컬 tangent 방향으로 얼마나 기울었는지 얻을 수 있다. 이 값은 넓은 view-facing 값보다 노멀맵의 작은 요철 변화에 더 직접적으로 반응하므로, phase 생성에 사용했다.

0.159154941 / 2π에 해당한다. NormalDetailPhasefrac에 넣기 좋은 0..1 주기로 변환하기 위한 값이다.

lighting 쪽에서는 0..1로 저장된 CustomData.b를 다시 라디안 phase로 되돌리고, 이 phase를 spectrum/tint 함수에 넣어 specular 색을 만든다.

plain text
float Phase = GBuffer.CustomData.b * 6.2831853;
float3 IridescentColor = GetIridescentOrganicSpecularTint(Phase);

왜 dot(N, V)만으로는 부족했나

초기 접근은 dot(N, V) 또는 half-vector facing 같은 스칼라 값으로 색을 바꾸는 방식이었다. GBuffer.WorldNormal에는 노멀맵이 반영되므로 이론상 노멀맵 영향은 있다.

하지만 실제 결과는 큰 표면 방향이나 버텍스 노멀 변화에 끌려가기 쉬웠다. dot(N, V)는 결국 view-facing 기반의 스칼라 값이라, 노멀맵 셀마다 독립적인 phase를 안정적으로 만들기 어렵다.

그 결과 노멀맵의 셀마다 색이 맺히는 대신, 넓은 표면에 걸친 밴딩이나 CD 같은 그라디언트가 만들어졌다. 이 작업의 목표는 object-wide rainbow가 아니라 skin pore나 scale 같은 local detail에 붙는 색 변화였다.

그래서 phase를 view-normal angle에서 직접 만들지 않고, 최종 픽셀 노멀을 tangent/bitangent 축에 투영한 로컬 기울기에서 먼저 만들었다. view/grazing 값은 phase와 weight를 보정하는 역할로만 사용한다.

셰이더 통합 지점

커스텀 셰이딩 모델은 한 파일만 수정해서는 안정적으로 동작하지 않는다. 머티리얼 컴파일, GBuffer 패킹, GBuffer 디코드, direct lighting, reflection/IBL 경로가 모두 같은 데이터 배치를 기준으로 값을 읽고 써야 한다.

주요 수정 지점은 다음과 같다.

  • EngineTypes.h: MSM_Iridescent enum 추가
  • ShadingCommon.ush: SHADINGMODELID_IRIDESCENT 정의, SHADINGMODELID_NUM 갱신, iridescent helper 함수 공통화
  • ShadingModelsMaterial.ush: iridescent용 CustomData 패킹, normal-map local phase 계산
  • ShadingModels.ush: IridescentBxDF 구현 및 direct lighting specular tint 적용
  • DeferredShadingCommon.ush: ComputeF0 / SSS adjustment 이후 GBuffer.SpecularColor에 tint 적용
  • BasePassPixelShader.usf: base-pass reflection / IBL 경로에 tint 적용
  • MaterialAttributeDefinitionMap.cpp: CustomData0/1 핀 라벨을 Iridescent Intensity / Iridescent Shift로 지정
  • HLSLMaterialTranslator.cpp / MaterialIRToHLSLTranslator.cpp: MATERIAL_SHADINGMODEL_IRIDESCENT define 설정 및 Subsurface Profile 계열 경로 처리

스펙트럼 변환

phase를 RGB로 바꾸는 부분은 노멀맵 phase 계산과 분리해서 생각해야 한다.

flowchart LR
    A[Normal-map local detail] --> B[Phase]
    B --> C[Hue wheel RGB]
    C --> D[Specular tint]

lighting 단계에서는 노멀맵 로컬 디테일에서 만든 phase를 0..1 범위의 hue 값으로 보고, RGB specular tint 색으로 변환한다.

plain text
float3 IridescentHueToRGB(float Hue)
{
    float3 P = abs(frac(Hue + float3(0.0, 0.6666667, 0.3333333)) * 6.0 - 3.0);
    return saturate(P - 1.0);
}

이 함수는 물리적인 파장 계산이 아니라, phase 값을 선명한 무지개 hue로 바꾸는 간단한 hue wheel이다. frac은 hue가 0..1 범위에서 반복되게 만들고, float3(0.0, 0.6666667, 0.3333333)은 R/G/B 채널마다 서로 다른 offset을 준다. 이렇게 하면 하나의 phase 값에서 각 채널이 다른 구간에서 밝아지며, 결과적으로 무지개색이 순환한다.

이 색은 diffuse color가 아니라 specular tint로만 사용한다.

에너지와 가시성

현재 구현은 완전한 물리 기반 thin-film 모델이 아니라 artist-directed approximation이다. 그래서 무제한 additive term으로 밝기를 키우기보다, 이미 존재하는 specular lobe의 색을 iridescent tint로 바꾸는 쪽을 택했다.

초기에는 낮은 F0의 피부 재질에서도 색을 보이게 하려고 specular floor나 작은 colored sheen을 넣어봤다. 하지만 이 방식은 블랙 베이스 재질에서 바탕을 회색이나 금색 코팅처럼 띄우는 부작용이 있었다. 최종적으로는 이런 floor/additive 보정은 제거했다.

현재 direct lighting 쪽은 다음 원칙을 따른다.

  • ApplyIridescentSpecularTint(...)로 기존 specular color를 tint한다.
  • MinSpecularColor0.0으로 두어 새 specular 바닥값을 만들지 않는다.
  • iridescent tint 색에는 pow(color, 1.65)를 적용한 뒤 luminance를 다시 맞춰 색 대비만 강화한다.
  • specular lobe 전체에 tint가 먹도록 하되, diffuse/base color에는 직접 색을 더하지 않는다.

즉 “스펙큘러 양을 새로 만든다”가 아니라 “이미 보이는 스펙큘러의 hue를 바꾼다”에 가깝다.

SSS 경로 호환

MSM_Iridescent는 Subsurface Profile 계열처럼 처리된다. UE의 SSS 경로는 checkerboard/full-resolution 렌더링 상태에 따라 specular와 diffuse를 분리하는 방식이 달라진다.

plain text
ComputeF0
-> AdjustBaseColorAndSpecularColorForSubsurfaceProfileLighting(...)
-> iridescent specular tint

여기서 중요한 문제가 있었다. 예전 구현에서는 r.SSS.Checkerboard 1에서는 iridescent 색이 잘 보였지만, r.SSS.Checkerboard 0이나 기본 자동 모드 r.SSS.Checkerboard 2가 full-resolution SSS를 선택하면 색이 약해졌다.

원인은 full-resolution SSS recombine 방식에 있다. checkerboard 경로는 diffuse/SSS와 specular를 픽셀 패턴으로 명시적으로 분리한다. 반면 full-resolution 경로는 combined RGB와 alpha luminance를 이용해 diffuse/specular 비율을 추정한다.

plain text
float3 CombinedColor = CenterSample.rgb;
float DiffuseLuminance = CenterSample.a;

float CombinedLuminance = Luminance(CombinedColor);
float DiffuseFactor = saturate(DiffuseLuminance / CombinedLuminance);
float SpecularFactor = 1.0f - DiffuseFactor;

일반 피부 specular에는 충분할 수 있지만, 강한 colored specular에는 불리할 수 있다. iridescent 색이 SSS/diffuse 쪽에 섞이거나 specular factor가 작게 추정되면, 최종 recombine에서 색이 약해진다. 특히 black base 재질에서는 specular가 diffuse/SSS 쪽으로 잘못 들어간 뒤 StoredBaseColor와 곱해지면서 사라진 것처럼 보일 수 있다.

처음에는 MSM_Iridescent가 있는 view를 checkerboard SSS로 강제하는 방법도 검토했다. 실제로 checkerboard 경로는 diffuse/specular를 분리하므로 결과가 가장 직관적으로 잘 나왔다. 하지만 view 단위로 checkerboard를 강제하면 같은 화면의 다른 Subsurface Profile 재질까지 영향을 받고, 고주파 normal/specular에서는 checkerboard 플리커가 생길 수 있었다.

그래서 최종 선택은 full-resolution SSS 경로를 유지하되, recombine 마지막 단계에서 SHADINGMODELID_IRIDESCENT만 후처리 tint를 적용하는 방식이었다.

plain text
float3 FinalColor =
    SubsurfaceLighting * StoredBaseColor +
    ExtractedNonSubsurface;

if (ScreenSpaceData.GBuffer.ShadingModelID == SHADINGMODELID_IRIDESCENT)
{
    float IridescentIntensity =
        saturate(ScreenSpaceData.GBuffer.CustomData.g);

    float IridescentPhase =
        ScreenSpaceData.GBuffer.CustomData.b * 6.2831853;

    float3 IridescentTint =
        GetIridescentOrganicSpecularTint(IridescentPhase);

    float FinalLuminance = Luminance(FinalColor);
    float SpecularCandidateLuminance =
        Luminance(ExtractedNonSubsurface);

    float SpecularMask =
        smoothstep(0.03f, 0.16f, SpecularCandidateLuminance);

    SpecularMask *=
        saturate(1.0f - ScreenSpaceData.GBuffer.Roughness * 0.85f);

    float3 TintedFinalColor = FinalColor * IridescentTint;
    TintedFinalColor *=
        FinalLuminance / max(Luminance(TintedFinalColor), 0.0001f);

    FinalColor =
        lerp(FinalColor, TintedFinalColor,
             saturate(SpecularMask * IridescentIntensity));
}

핵심은 밝기를 새로 더하지 않는 것이다. FinalColor의 luminance를 보존한 채 hue만 바꾸고, mask는 ExtractedNonSubsurface의 luminance와 roughness를 이용한다. 이렇게 하면 checkerboard 1에서만 선명하게 보이던 iridescent를 full-resolution SSS에서도 더 볼 수 있고, black base가 colored coat처럼 뜨는 문제를 줄일 수 있었다.

모바일과 별도 렌더링 경로

데스크톱 deferred에서는 CustomData.r/g/b/a가 비교적 직관적으로 보존되므로 현재 계약이 성립한다. 하지만 모바일 non-extended GBuffer는 Subsurface Profile 계열의 profile id, opacity, curvature를 우선 pack/decode하며 g/b를 그대로 보존하지 않을 수 있다.

따라서 모바일을 지원하려면 MOBILE_EXTENDED_GBUFFER 여부와 mobile pack/decode 정책을 별도로 검증해야 한다. path tracing, ray tracing, material preview, debug view도 CustomData를 다르게 해석할 수 있으므로 같은 계약을 적용했는지 확인이 필요하다.

튜닝 포인트

  • 노멀맵 색 반복 밀도: float2(11.31, 17.17)DetailAmount * 5.0
  • phase offset: 머티리얼의 Iridescent Shift
  • 효과 강도: 머티리얼의 Iridescent Intensity
  • grazing / 직접광 반응: ThinFilmPhaseIridescentWeight
  • 색 대비: pow(SkinIridescentColor, 1.65) 후 luminance 보존
  • full-resolution SSS 후처리 mask: smoothstep(0.03, 0.16, Luminance(ExtractedNonSubsurface))와 roughness mask
plain text
ThinFilmPhase =
    IridescentPhase +
    (1.0 - HighlightFacing) * 2.5 +
    Grazing * 1.5;

IridescentWeight =
    IridescentIntensity *
    saturate(0.70 + Grazing * 0.30);

결과물

Iridescent Shift 테스트meants.dev
Iridescent Shift 테스트
Subsurface Profilemeants.dev
Subsurface Profile
Iridescentmeants.dev
Iridescent
Content imagemeants.dev
Content imagemeants.dev
Content imagemeants.dev

마무리

결과적으로 MSM_Iridescent는 물리적으로 완전한 thin-film 모델은 아니지만, 실시간 머티리얼에서 노멀맵 로컬 디테일에 무지개빛 specular를 붙이는 데 초점을 맞춘 구현이다. Subsurface Profile 기반 SSS와 함께 동작하도록 CustomData.r/a는 기존 의미를 유지하고, CustomData.g/b와 full-resolution SSS 후처리 tint를 iridescent용으로 사용했다.

이후 모바일 non-extended GBuffer, ray tracing, path tracing까지 같은 룩이 필요하다면 각 렌더링 경로의 GBuffer/lighting 처리를 별도로 검토해야 한다.

참고 자료