Game Dev/Unity Shader

명일방주의 눈 밟는 쉐이더 만들기 - Unity Shader

Septentrions 2026. 1. 26. 01:32

최근 명일 방주 : 엔드필드를 플레이하고 있다.

이제 조금은 연식이 되는 내 컴퓨터에서도 풀 성능으로 잘 돌아가는 최적화에 감탄한다.

게임 시작하면 나오는 프로롤그에서는 눈 밟는 쉐이더가 나오는데

이를 한번 만들어보려고 한다.

 

 

 

https://lucid-boundary.tistory.com/142

 

20. Tessellation Shader - 다니엘 릿 쉐이더 프로젝트

드디어 어려운 파트를 넘어갔다..이제는 좀 가벼운 마음으로 포스팅을 하겠다... Tessellation Shader란?이름부터 읽기 어려운 이 쉐이더는 초기 포스팅에도 나온 내용이다.https://lucid-boundary.tistory.com/1

lucid-boundary.tistory.com

 

필요한 쉐이더는 우선 Tessellation Shader이다.

Hull Shader / Domain Shader에 대해 복습하는걸 권장한다.

 

해당 포스팅에서 사용된 코드 원본이다.

이 코드를 기준으로 커스텀해보려고 한다.

더보기

 

Shader "LucidBoundary/Tessellation_WaterWaveEffect"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _BaseColor("Base Color", Color) = (1, 1, 1, 1)
        _WaveStrength("Wave Strength", Range(0, 2)) = 0.1
        _WaveSpeed("Wave Speed", Range(0, 10)) = 1


        [Enum(UnityEngine.Rendering.BlendMode)]
        _SrcBlend("Source Blend Factor", Int) = 0
        
        [Enum(UnityEngine.Rendering.BlendMode)]
        _DstBlend("Destination Blend Factor", Int) = 1

        _TessAmount("Tessellation Amount", Range(1, 64)) = 2
    }
    SubShader
    {
        Tags 
        { 
            "RenderType"="Transparent"
            "Queue"="Transparent"
            "RenderPipeline" = "UniversalPipeline"    
        }

        Pass
        {
            Blend [_SrcBlend] [_DstBlend]
            Tags
            {
                "LightMode" = "UniversalForward"    
            }

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma hull tessHull
            #pragma domain tessDomain
            #pragma target 4.6

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

            struct appdata
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct tessControlPoint
            {
                float4 positionOS : INTERNALTESSPOS;
                float2 uv : TEXCOORD0;
            };

            struct tessFactors
            {
                float edge[3] : SV_TessFactor;
                float inside : SV_InsideTessFactor;
                
            };

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

            texture2D _MainTex;
            SamplerState sampler_MainTex;

            CBUFFER_START(UnityPerMaterial)
                float4 _MainTex_ST;
                float4 _BaseColor;
                float _TessAmount;
                float _WaveStrength;
                float _WaveSpeed;
            CBUFFER_END

            tessControlPoint vert(appdata v)
            {
                tessControlPoint o;
                o.positionOS = v.positionOS;
                o.uv = v.uv;
                return o;
            }

            v2f tessVert(appdata v)
            {
                v2f o;

                float4 positionWS = mul(unity_ObjectToWorld, v.positionOS);
                float height = sin(_Time.y * _WaveSpeed + positionWS.x + positionWS.z);
                positionWS.y += height* _WaveStrength;

                o.positionCS = mul(UNITY_MATRIX_VP, positionWS);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            tessFactors patchConstantFunc(InputPatch<tessControlPoint, 3> patch)
            {
                tessFactors f;
                f.edge[0] = f.edge[1] = f.edge[2] = _TessAmount;
                f.inside = _TessAmount;
                return f;
            }

            [domain("tri")]
            [outputcontrolpoints(3)]
            [outputtopology("triangle_cw")]
            [partitioning("fractional_even")]
            [patchconstantfunc("patchConstantFunc")]
            tessControlPoint tessHull(InputPatch<tessControlPoint, 3> patch, uint id : SV_OutputControlPointID)
            {
                return patch[id];
            }

            [domain("tri")]
            v2f tessDomain(tessFactors factors, OutputPatch<tessControlPoint, 3> patch, float3 bcCoords : SV_DomainLocation)
            {
                appdata i;

                i.positionOS = patch[0].positionOS * bcCoords.x + patch[1].positionOS * bcCoords.y + patch[2].positionOS * bcCoords.z;
                i.uv = patch[0].uv * bcCoords.x + patch[1].uv * bcCoords.y + patch[2].uv * bcCoords.z;

                return tessVert(i);
                    
            }

            float4 frag (v2f i) : SV_Target
            {
                float4 textureSample = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
                return textureSample * _BaseColor;
            }
            ENDHLSL
        }
    }
}

 

이 포스팅에서는 Render Texture를 이용하여  쉐이더를 구현하고자 한다.

Tessellation의 적용된 Shader의 Vertex Shader를 건들면, Hull 된 표면이 자연스럽게 따라오게 되어 있다.

플레이어 오브젝트가 쉐이더의 표면에 닿게 되면, 해당 위치 정보를 기억하여 Render Texture를 업데이트하고

쉐이더의 Fragment 단계에서 Render Texture 와 Main Texture를 샘플링하는 방식이다.

Tessellation 적용 전
Tessellation 적용 후

 

눈이 쌓인 평면만으로 이루어진 지형은 심심하기 그지없다.

명일 방주에서도 지형에 따라 눈이 쌓여있으면서, 정상적으로 눈 밟는 쉐이더가 동작하는걸 알 수 있다.

지형을 표현하기 위한 Noise Texture는 다음 이미지를 사용했다.

 

Noise Texture의 R 값에 따라 Saturate해서 0 ~ 256 값을 0 ~ 1 값으로 바꾼 다음,

World Position 을 그 값에 따라 높이를 주었을 때, 결과이다.

생각보다 재미있는 지형이 생성된다.

 

float snowNoise = SAMPLE_TEXTURE2D_LOD(
                                        _NoiseTex,
                                        sampler_NoiseTex,
                                        float4(positionWS.xz * _Scale, 0, 0),
                                        0).r;
v.positionOS += saturate(snowNoise);

 

다음은 플레이어 오브젝트가 닿은 부분에 효과를 주기 위해 Render Texture를 준비한다.

플레이어가 지나간 자리가 기록된 Render Texture

 

private void OnCollisionStay(Collision collision)
{
    if (collision.transform == Target)
    {
        ContactPoint ctPt = collision.contacts[0];

        Ray ray = new Ray(ctPt.point + Vector3.up, Vector3.down);
        RaycastHit hit;

        if (Physics.Raycast(ray, out hit, 3f, 3))
        {
            Debug.Log(hit.textureCoord);
            int xCoord = (int)(hit.textureCoord.x * textureWidth);
            int yCoord = (int)(hit.textureCoord.y * textureHeight);
            //int xCoord = Mathf.RoundToInt(hit.textureCoord.x * textureWidth);
            //int yCoord = Mathf.RoundToInt(hit.textureCoord.y * textureHeight);

            for (int xError = -10; xError < 10; xError++)
            {
                for (int yError = -10; yError < 10; yError++)
                {
                    if (xCoord + xError < 0 || xCoord + xError > textureWidth) continue;
                    if (yCoord + yError < 0 || yCoord + yError > textureHeight) continue;

                    pixelData[(yCoord + yError) * textureWidth + (xCoord + xError)] += Color.white - Color.white * (Mathf.Abs(xError) + Mathf.Abs(yError)) /20;

                }
            }

            Texture2D tempTexture = new Texture2D(textureWidth, textureHeight, TextureFormat.RGBA32, false);
            tempTexture.SetPixels(pixelData);
            tempTexture.Apply();


            RenderTexture.active = RecordRT;
            Graphics.Blit(tempTexture, RecordRT);
            RenderTexture.active = null;

            SnowPlainMat.SetTexture("_GlobalEffectRT", RecordRT);

            Destroy(tempTexture);
        }
    }
}

 

스크립트는 Collision 판정을 이용한다.

플레이어와 Snow Plain이 닿은 지점 ContactPoint를 구하여

Snow Plaine에 Y축으로 Raycast한다.

RaycastHit 의 TextureCoord가 곧 UV 위치이다.

Render Texture에 UV 위치를 Resolution을 고려해서 좌표를 구해준다.

 

pixelData[(yCoord + yError) * textureWidth + (xCoord + xError)] +=
				Color.white 
                - Color.white * (Mathf.Abs(xError) + Mathf.Abs(yError)) /20;

닿은 지점으로부터 범위를 설정하여 흰색으로 바꿔주면 된다.

 

RenderTexture.active = RecordRT;
Graphics.Blit(tempTexture, RecordRT);
RenderTexture.active = null;

SnowPlainMat.SetTexture("_GlobalEffectRT", RecordRT);

이제 색상을 담은 배열은 RenderTexture 이전에 임시로 만든 Texture2D 에 적용한 뒤에

RenderTexture에 Blit 함수로 복사 한 뒤에 Shader로 보내준다.

 

Fragment 에서는 MainTex와 RenderTexture를 MainTex의 Sampler를 이용하여 매트릭스를 구한 뒤,

적절하게 블렌딩 해주면, 완성이다.