게임 엔진에서 털 표현하는 방법

- http://sorumi.xyz/posts/unity-fur-shader/

- https://github.com/hecomi/UnityFurURP

 

게임 엔진에서 털을 표현하는 방법은 크게 알파 패스를 여러번 겹쳐서 그리거나, 지오메트리 쉐이더나 테셀레이션을 활용해 털 폴리곤을 표현하는 방법이 있습니다.

하지만 위 자료 전부 드로우콜, 오버드로우 이슈 등 퍼포먼스가 어느정도 요구됩니다.

 

 

로스트아크에서 털 표현하는 방법

로스트아크에서 몇몇 객체의 털 표현하는 방법을 매우 가벼워보였습니다.

노이즈 형태의 알파 Cutout Pass를 하나 더 그려서 볼륨감을 표현하고, 밀도 높고 거친 노멀맵으로 털 표면을 표현한 것 처럼 보입니다.

 

 

URP 환경에서 직접 구현해보기

1. 2Pass 알파 Cutout 표현

좌 : 커스텀 라이팅 쉐이더 중 : 2 Pass 추가 우 : 알파 Cutout 추가

위 자료는 간단히 제작한 커스텀 라이팅 쉐이더를 기반으로 제작했습니다.

먼저 털에도 라이팅 표현이 동일하게 되기 위해 기존 Pass를 그대로 복사했습니다.

 

그리고 Normal 방향으로 버텍스를 확장 시키는 코드를 추가하고

v2f vert(appdata v)
{
	v2f o = (v2f)0;

	v.positionOS.xyz += v.normalOS.xyz * _FurLength * 0.01f; // Normal 방향으로 메쉬 크기 확장
	float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
	float3 positionVS = TransformWorldToView(positionWS);
	float4 positionCS = TransformWorldToHClip(positionWS);
    ...

노이즈맵과 마스킹맵을 샘플링하여 알파 Cutout의 형태를 커스텀할 수 있게 했습니다.

float4 frag(v2f i) : SV_Target
{
	...
    // 털 표현 예외 마스킹
	float2 furrMaskTexUV = i.uv.xy * _FurMaskTex_ST.xy + _FurMaskTex_ST.zw;
	float4 furrMask = SAMPLE_TEXTURE2D(_FurMaskTex, sampler_FurMaskTex, furrMaskTexUV);

	// 털 노이즈맵
	float2 furrTexUV = i.uv.xy * _FurTex_ST.xy + _FurTex_ST.zw;
	float4 furrPattern = SAMPLE_TEXTURE2D(_FurTex, sampler_FurTex, furrTexUV * _FurThinness);
                
	clip((furrMask.r * furrPattern.r) - _FurAlphaThreshold);
	return float4(finalColor, 1);

 

털 텍스처는 Substance Designer의 기본 템플릿을 추출했습니다.

여기까지 결과물만 봐도 레퍼런스 자료와 방향성은 맞는 것 같습니다.

 

 

 

2. 털 재질 노멀맵 추가

좌 Before 우 After

다음 털 표면 표현을 위해 털 노멀맵을 추가했습니다.

노멀맵도 위 털 노이즈맵과 동일한 템플릿으로 추출했습니다.

 

 

지금까지 결과물

2 Pass Alpha Cutout에 노멀맵만으로 원하는 결과물과 거의 비슷하게 구현되었습니다.

하지만 가벼운 만큼, 짧고 단단한 형태의 털만 표현이 가능한 한계가 있습니다.

 

 

 

2Pass -> Geometry 쉐이더로 변경하기

동일한 라이팅 연산을 위해 같은 Pass를 2번 그리는게 좀 아쉽습니다.

또한 코드를 깔끔하게 만들기 위해서는 Pass를 따로 호출하는 방식으로 변경도 필요합니다.

그래서 Geometry 쉐이더 기능을 활용해 버텍스 쉐이더를 반복 호출하는 방식으로 개선해봤습니다.

 

아래 자료를 참고하여 R&D했으며 지오메트리 쉐이더에 대한 내용은 추후 자세히 다뤄보겠습니다.

참고 자료

- https://woo-dev.tistory.com/260

- https://danielilett.com/2021-08-24-tut5-17-stylised-grass/

- https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=canny708&logNo=221549767701 

 

Geometry 쉐이더 추가

쉐이더 코드에서 geometry 쉐이더를 사용한다고 명시합니다.

#pragma vertex vert
#pragma require geometry    // geometry 쉐이더를 사용한다고 명시 (Metal API는 geometry쉐이더를 지원 안함)
#pragma geometry geom       // geom함수로 geometry쉐이더 선언
#pragma fragment frag

 

버텍스 쉐이더에서는 아무 것도 하지 않고 버텍스 데이터만 return합니다.

지오메트리 쉐이더는 버텍스 쉐이더 다음 동작하는데, 이때 따로 뺀 함수를 반복 호출해서 버텍스 연산을 할 예정입니다.

// 버텍스 쉐이더에서 아무것도 하지 않고 그대로 버텍스 데이터를 지오메트리 쉐이더로 return.
appdata vert(appdata v)
{
    return v;
}

// 지오메트리 쉐이더에서 버텍스 쉐이더 연산 진행.
void AppendVertexPass(inout TriangleStream<v2f> stream, appdata v, int index)
{
    v2f o = (v2f)0;

    v.positionOS.xyz += v.normalOS.xyz * _FurLength * 0.01f * index;    // Normal 방향으로 메쉬 크기 확장
    
    float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
    float3 positionVS = TransformWorldToView(positionWS);
    float4 positionCS = TransformWorldToHClip(positionWS);

    o.normalWS = normalize(TransformObjectToWorldNormal(v.normalOS));
    #ifdef _NORMALMAP_ON
        o.tangentWS.xyz = normalize(TransformObjectToWorldDir(v.tangentOS.xyz));
        o.tangentWS.w = v.tangentOS.w * GetOddNegativeScale();
    #endif

    o.positionWS = positionWS;
    o.positionCS = positionCS;
    o.uv.xy = v.uv.xy; 
    o.layerIndex = index;   // 반복호출 카운트 저장
   
    o.shadowCoord = TransformWorldToShadowCoord(positionWS);
    #ifdef _ADDITIONAL_LIGHTS_VERTEX 
        o.fogCoordAndVertexLight.xyz = VertexLighting(positionWS, o.normalWS);
    #endif
    #if _FOG
        o.fogCoordAndVertexLight.w = ComputeFogFactor(o.positionCS.z);
    #endif

    stream.Append(o);
}

 

위 #pragma에서 명시한 함수를 추가하여 반복 호출합니다.

결과적으로 버텍스 쉐이더 연산을 지오메트리 쉐이더에서 1회 이상 반복 수행하게 되었습니다.

[maxvertexcount(21)]    // 최대 사용할 버텍스 갯수 (_vertPassCount최대값 * triangle버텍스 수)
void geom(triangle appdata input[3], inout TriangleStream<v2f> stream)
{
    for (int i = 0; i < _vertPassCount + 1; i++)
    {
        for (float j = 0; j < 3; j++)   // 3 = 1개의 triangle의 버텍스 수
        {
            AppendVertexPass(stream, input[j], i);  // 버텍스 쉐이더 기능을 반복 호출
        }
        stream.RestartStrip();  // Triangle Weld 끊기
    }
}

 

프래그먼트 쉐이더에서는, 맨 처음 레이어(Pass)를 제외하고 clip기능을 수행합니다.

float4 frag(v2f i) : SV_Target
{
	...
    // 0번 레이어를 제외하고 AlphaClip 연산
    if (i.layerIndex > 0.0f) 
    {
        float2 furrMaskTexUV = i.uv.xy * _FurMaskTex_ST.xy + _FurMaskTex_ST.zw;
        float4 furrMask = SAMPLE_TEXTURE2D(_FurMaskTex, sampler_FurMaskTex, furrMaskTexUV);

        float2 furrTexUV = i.uv.xy * _FurTex_ST.xy + _FurTex_ST.zw;
        float4 furrPattern = SAMPLE_TEXTURE2D(_FurTex, sampler_FurTex, furrTexUV * _FurThinness);

        clip((furrMask.r * furrPattern.r) - _FurAlphaThreshold);
    }

 

의도한대로 마치 여러 Pass가 동작하는 것 처럼, Mesh가 반복 생성됩니다.

또한 반복횟수와 상관없이 1 드로우콜로 처리됩니다.

 

필요에 따라 여러번 메쉬 갯수를 늘려서 풍성한 느낌을 표현할 수 있게 됬습니다.

좌 1 VertPass 우 8 VertPass

 

 

 

프로파일링 비교

{"originWidth":1307,"originHeight":633,"style":"alignCenter","caption":"좌 : 2Pass

최초에 가볍게 2 Pass로 시작했지만, 지오메트리 쉐이더가 들어가면서 다른 예제와 비슷하게 진행되었습니다.

그래서 단순 2 Pass와 지오메트리 쉐이더와 gpu 프로파일링 비교를 했습니다.

 

Unity 2021.2.4f1 PC빌드 / RenderDoc 측정

  드로우콜 Stats Verts GPU us
URPLit 1 707 15.36
2 Pass 2 1.2k 34.74(21.43 + 13.31)
지오메트리 2 Vert 1 707 48.06
지오메트리 9 Vert 1 707 141.31

측정 결과 GPU 속도기준으로 2 Pass만 비교했을때 지오메트리 쉐이더가 대략 13% 더 무겁습니다.

하지만 여러 패스를 겹치는 상황에는 지오메트리 기반이 증가 폭이 낮아 더 효율적입니다.

여러 오브젝트, SRP Batcher까지 동작시켰을때 어떤 결과가 나올지는 추후 테스트해봐야 겠습니다.

 

 

한계점 & 이슈

1. 지오메트리 쉐이더의 반복 횟수는 반드시 Properties로 세팅된 변수 값이 들어가야한다.

버텍스 Pass를 반복시키는 변수를 만약 상수(썡 숫자)로 지정하면 아래와 같이 버텍스 MVP 메트릭스 연산이 동작하지 않게 됩니다.

지오메트리 쉐이더에 대해서 자세히 공부하고 원인 파악이 필요합니다.

 

2. SRP Batcher 동작하지 않음.

위 1번에서 언급한 Properties 변수는 현재 CBUFFER에 들어 있지 않아 SRP Batcher가 동작하지 않습니다.

해당 변수를 그냥 CBUFFER에 포함한다면 지오메트리 쉐이더도 동작하지 않습니다.

관련해서 추가 R&D도 필요합니다.

 

 

3. Metal API에서 지오메트리 쉐이더가 동작하지 않는다.

아래 유니티 도큐먼트 페이지를 보면 Metal에서 지오메트리 쉐이더를 지원하지 않는다고 되어 있습니다.

직접 테스트해보지 않았지만, 해당 내용대로라면 iOS, Mac 플랫폼을 대응해야 한다면 2Pass 방식을 사용해야겠습니다.

https://docs.unity.cn/kr/2021.3/Manual/SL-PragmaDirectives.html

 

 

 

 

지오메트리 쉐이더 전체 코드

Shader "CatDarkGame/FastFurShader_Geom"
{
    Properties
    {
        [Header(Albedo)]
        _Color("Color", Color) = (1, 1, 1, 1)
        _MainTex ("Texture(RGB)", 2D) = "white" {}
        
        [Header(Normal)]
        [Toggle] _NormalMap("Enable Normal Map", float) = 0.0
        _BumpMap("Normal Texture", 2D) = "bump" {}
        _BumpScale("Normal Intensity", Range(0.01, 5)) = 1
        
        [Header(Highlight)]
        _SpecularColor("SpecularColor", Color) = (1, 1, 1, 1)
        _Shininess ("Shininess", Range(0.01, 256.0)) = 8.0
        _RimColor ("Rim Color", Color) = (0, 0, 0, 1)
        _RimPower ("Rim Power", Range(0.01, 8.0)) = 6.0
        
        [Header(Fur)]
        _FurTex ("Fur Pattern(R)", 2D) = "white" { }
        _FurThinness ("Fur Pattern Tiling", Range(0.01, 10)) = 1
        _FurMaskTex ("Fur Alpha Mask(R)", 2D) = "white" { }
         _FurAlphaThreshold("Fur Alpha", Range(0, 1)) = 0.5
         _FurLength("Fur Length", Range(0.01, 2)) = 0.4

         [IntRange] _vertPassCount("Add VertPass Count", Range(1, 8)) = 1
    }

    SubShader
    {
        Tags { "RenderType" = "TransparentCutout" "Queue"="AlphaTest" "RenderPipeline" = "UniversalPipeline" }

        Pass
        {
            Name "FurLit"
            Tags {"LightMode" = "UniversalForward"}
            Cull off
            ZWrite On
            
            HLSLPROGRAM

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            #define _FOG 1
            #define _HALFLAMBERT 1

            #pragma target 4.5
            #pragma prefer_hlslcc gles  
            #pragma exclude_renderers d3d11_9x 
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS
            #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
            #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
            #pragma multi_compile_fragment _ _SHADOWS_SOFT
            #pragma multi_compile_fog

            #pragma shader_feature_local _NORMALMAP_ON

            #pragma vertex vert
            #pragma require geometry    // geometry 쉐이더를 사용한다고 명시 (Metal API는 geometry쉐이더를 지원 안함)
            #pragma geometry geom       // geom함수로 geometry쉐이더 선언
            #pragma fragment frag


            struct appdata
            {
                float4 positionOS : POSITION;
                float3 normalOS     : NORMAL;
                float4 tangentOS    : TANGENT;

                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float4 positionCS : SV_POSITION;
                float2 uv : TEXCOORD0;

                float3 positionWS       : TEXCOORD1;
                float3 normalWS         : TEXCOORD2;
                float4 tangentWS        : TEXCOORD3;
                float4 shadowCoord      : TEXCOORD4;

                float4 fogCoordAndVertexLight : TEXCOORD5;
                float layerIndex : TEXCOORD6;
            }; 
            

            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);

            TEXTURE2D(_BumpMap);
            SAMPLER(sampler_BumpMap);

            TEXTURE2D(_FurTex);
            SAMPLER(sampler_FurTex);

            TEXTURE2D(_FurMaskTex);
            SAMPLER(sampler_FurMaskTex);

           int _vertPassCount;
            
            // SRP Batcher
            CBUFFER_START(UnityPerMaterial)
                float4 _MainTex_ST;
                float4 _Color;

                float4 _BumpMap_ST;
                float _BumpScale;

                float4 _FurTex_ST;
                float4 _FurMaskTex_ST;

                float4 _SpecularColor;
                float _Shininess;

                float4 _RimColor;
                float _RimPower;

                float _FurLength;
                float _FurThinness;
                float _FurAlphaThreshold;
            CBUFFER_END
             
            // 버텍스 쉐이더에서 아무것도 하지 않고 그대로 버텍스 데이터를 지오메트리 쉐이더로 return.
            appdata vert(appdata v)
            {
                return v;
            }

            // 지오메트리 쉐이더에서 버텍스 쉐이더 연산 진행.
            void AppendVertexPass(inout TriangleStream<v2f> stream, appdata v, int index)
            {
                v2f o = (v2f)0;

                v.positionOS.xyz += v.normalOS.xyz * _FurLength * 0.01f * index;    // Normal 방향으로 메쉬 크기 확장
                
                float3 positionWS = TransformObjectToWorld(v.positionOS.xyz);
                float3 positionVS = TransformWorldToView(positionWS);
                float4 positionCS = TransformWorldToHClip(positionWS);

                o.normalWS = normalize(TransformObjectToWorldNormal(v.normalOS));
                #ifdef _NORMALMAP_ON
                    o.tangentWS.xyz = normalize(TransformObjectToWorldDir(v.tangentOS.xyz));
                    o.tangentWS.w = v.tangentOS.w * GetOddNegativeScale();
                #endif

                o.positionWS = positionWS;
                o.positionCS = positionCS;
                o.uv.xy = v.uv.xy; 
                o.layerIndex = index;   // 반복호출 카운트 저장
               
                o.shadowCoord = TransformWorldToShadowCoord(positionWS);
                #ifdef _ADDITIONAL_LIGHTS_VERTEX 
                    o.fogCoordAndVertexLight.xyz = VertexLighting(positionWS, o.normalWS);
                #endif
                #if _FOG
                    o.fogCoordAndVertexLight.w = ComputeFogFactor(o.positionCS.z);
                #endif

                stream.Append(o);
            }

            [maxvertexcount(21)]    // 최대 사용할 버텍스 갯수 (_vertPassCount최대값 * triangle버텍스 수)
            void geom(triangle appdata input[3], inout TriangleStream<v2f> stream)
            {
                for (int i = 0; i < _vertPassCount + 1; i++)
                {
                    for (float j = 0; j < 3; j++)   // 3 = 1개의 triangle의 버텍스 수
                    {
                        AppendVertexPass(stream, input[j], i);  // 버텍스 쉐이더 기능을 반복 호출
                    }
                    stream.RestartStrip();  // Triangle Weld 끊기
                }
            }

            float4 frag(v2f i) : SV_Target
            {
                Light mainlight = GetMainLight(i.shadowCoord);
                float3 lightDirWS = mainlight.direction;
                float3 viewDirWS = GetWorldSpaceNormalizeViewDir(i.positionWS);
                float3 halfDirWS = normalize(viewDirWS + lightDirWS);
                
                float3 normalWS = i.normalWS;
                #ifdef _NORMALMAP_ON
                    float2 bumpTexUV = i.uv.xy * _BumpMap_ST.xy + _BumpMap_ST.zw;
                    float3 normalTS = UnpackNormalScale(SAMPLE_TEXTURE2D(_BumpMap, sampler_BumpMap, bumpTexUV), _BumpScale);
                    float sign = i.tangentWS.w; 
                    float3 bitangent = sign * cross(i.normalWS.xyz, i.tangentWS.xyz);
                    half3x3 tangentToWorld = half3x3(i.tangentWS.xyz, bitangent.xyz, i.normalWS.xyz);
                    normalWS = TransformTangentToWorld(normalTS, tangentToWorld);
                    normalWS = normalize(normalWS);
                #endif
                
                float NDotV = saturate(dot(normalWS, viewDirWS));
                float NDotL = dot(normalWS, lightDirWS);
                #if _HALFLAMBERT
                     float Lambert = NDotL * 0.5f + 0.5f;
                #else
                     float Lambert = saturate(NDotL); 
                #endif
                float NDotH = saturate(dot(normalWS, halfDirWS));
                float3 reflectVector = reflect(-viewDirWS, normalWS);
               
                float3 lightColor = mainlight.color;
                float shadowAtten = mainlight.shadowAttenuation * mainlight.distanceAttenuation;
                float3 ambientColor = SampleSH(normalWS);

                float smoothness = 0;
                half mip = PerceptualRoughnessToMipmapLevel(smoothness);
                half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflectVector, mip);
                half3 irradiance = DecodeHDREnvironment(encodedIrradiance, unity_SpecCube0_HDR);
               
                float2 mainTexUV = i.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw;
                float4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, mainTexUV) * _Color;
                float3 ambient = ambientColor * albedo.rgb;
                float3 diffuse = (lightColor.rgb * albedo.rgb) * Lambert * shadowAtten;
                float3 specular = lightColor.rgb * _SpecularColor.rgb * pow(NDotH, _Shininess);
                float3 rim = (_RimColor.rgb * pow((1.0f - NDotV), _RimPower)) * irradiance;

                #ifdef _ADDITIONAL_LIGHTS
                    uint pixelLightCount = GetAdditionalLightsCount();
                    for (uint lightIndex = 0u; lightIndex < pixelLightCount; ++lightIndex)
                    { 
                        Light addLight = GetAdditionalLight(lightIndex, i.positionWS);
                        float3 attenLightColor = addLight.color.rgb * (addLight.distanceAttenuation * addLight.shadowAttenuation);
                        float NDotL_Add = saturate(dot(normalWS, addLight.direction));
                        diffuse.rgb += attenLightColor.rgb * albedo.rgb * NDotL_Add;
                    }
                #endif 
                #ifdef _ADDITIONAL_LIGHTS_VERTEX 
                    diffuse.rgb += i.fogCoordAndVertexLight.xyz * albedo.rgb;
                #endif

                float3 finalColor = diffuse + ambient + ((specular + rim));
                #if _FOG
                    finalColor.rgb = MixFog(finalColor.rgb, i.fogCoordAndVertexLight.w);
                #endif

               
                // 0번 레이어를 제외하고 AlphaClip 연산
                if (i.layerIndex > 0.0f) 
                {
                    float2 furrMaskTexUV = i.uv.xy * _FurMaskTex_ST.xy + _FurMaskTex_ST.zw;
                    float4 furrMask = SAMPLE_TEXTURE2D(_FurMaskTex, sampler_FurMaskTex, furrMaskTexUV);

                    float2 furrTexUV = i.uv.xy * _FurTex_ST.xy + _FurTex_ST.zw;
                    float4 furrPattern = SAMPLE_TEXTURE2D(_FurTex, sampler_FurTex, furrTexUV * _FurThinness);
                
                    clip((furrMask.r * furrPattern.r) - _FurAlphaThreshold);
                }
               
                return float4(finalColor, 1);
            }
            
            ENDHLSL
        }
    }
}

 

 

 


WRITTEN BY
CatDarkGame
Technical Artist dhwlgn12@gmail.com

,