Vertex Displacement
Summary So far we only used the vertex shader to move vertices from their object coordinates to their clip space coordinates (or to the world space coordinates which we then used for other things). But there are more things we can do with vertex shaders. A
www.ronja-tutorials.com
Ronja 님의 허락을 받고 번역한 튜토리얼입니다. 원문은 위 링크에서 확인하실 수 있습니다.
몇몇 부분은 생략·추가하였습니다.
의역과 오역이 넘쳐날 수 있으니 편하게 봐주시고 잘못된 부분은 알려주시면 감사하겠습니다!
목차
- 개요
- 서피스 셰이더에서 버텍스 셰이더 정의하기
- 버텍스 셰이더 조작하기
개요
지금까지 우리는 버텍스 셰이더를 버텍스들을 오브젝트 좌표계에서 클립 공간 좌표계로 이동시키는 데에만 사용했습니다. (또는 다른 용도로 사용하기 위해서 월드 공간 좌표계로 변환하기도 했습니다.) 하지만 버텍스 셰이더로 할 수 있는 일은 이보다 더 많습니다. 그 입문으로, 모델에 간단한 사인 파동을 적용해 출렁이는 효과를 주는 방법을 보여드리겠습니다.
이 예제에서는 서피스 셰이더를 사용해 셰이더를 만들 것이므로, 서피스 셰이더에 대한 기본적인 지식을 갖추고 있어야 합니다. 하지만 서피스 셰이더가 아닌 다른 유형의 셰이더에서도 동일하게 동작합니다.

서피스 셰이더에서 버텍스 셰이더 정의하기
모델 표면의 위치를 조작할 때 버텍스 셰이더를 사용합니다. 지금까지는 작성한 서피스 셰이더 안에서 버텍스 셰이더를 작성하지 않았고, 유니티가 백그라운드에서 자동으로 생성해 주었습니다. 버텍스 셰이더를 추가하기 위해서는, 서피스 셰이더를 정의하는 부분(#pragma)에 'vertex:버텍스셰이더이름' 구문을 추가해 버텍스 셰이더를 명시적으로 선언해주면 됩니다.
// 이 셰이더는 서피스 셰이더로,
// Unity가 백그라운드에서 확장하여 다양한 조명 효과와 기타 기능들을 자동으로 추가해줍니다
// 우리의 서피스 셰이더 함수 이름은 surf이며, 사용자 정의 조명 모델을 사용합니다
// fullforwardshadows는 Unity가 셰이더에 필요한 그림자 패스를 추가하도록 합니다
// vertex:vert는 vert 함수를 버텍스 셰이더 함수로 사용하도록 지정합니다
#pragma surface surf Standard fullforwardshadows vertex:vert
이제 실제 버텍스 함수를 작성해야 합니다. 이전의 언릿 셰이더에서는 버텍스 함수 내에서 클립 공간 좌표를 직접 계산했지만, 서피스 셰이더에서는 버텍스 셰이더를 별도로 사용하더라도 유니티가 해당 부분을 자동으로 생성해 줍니다. 우리는 오브젝트 공간의 버텍스 자표만 조작하고, 이후의 좌표계 변환 처리는 유니티에게 맡기면 됩니다.
입력 구조체는 특정 이름의 변수들을 포함해야 하기 때문에, 유니티가 제공하는 입력 구조체를 사용하는 것이 가장 간편합니다. 'appdata_full' 이라는 이름의 구조체를 사용할 것인데, 동일한 명명 규칙을 따른다면 직접 구조체를 정의해 사용할 수도 있습니다.
서피스 함수의 surf 함수처럼, 서피스 셰이더 안의 버텍스 셰이더도 값을 반환하지 않습니다. 대신 inout 키워드가 붙은 파라미터를 받아 이를 조작하는 방식으로 동작합니다.
서피스 셰이더는 클립 공간으로의 좌표 변환을 자동으로 처리해 주기 때문에, 비어있는 버텍스 함수만으로도 셰이더는 이전과 동일하게 동작하게 됩니다.
void vert(inout appdata_full data)
{
// 비어있는 버텍스 함수
}
버텍스 셰이더 조작하기
메시에 적용할 수 있는 간단한 연산으로, 모든 버텍스에 값을 곱해 모델을 더 크게 만들 수 있습니다.
void vert(inout appdata_full data)
{
data.vertex.xyz *= 2;
}

모델이 더 커지긴 했지만, 이상한 아티팩트(깨진 듯한 시각적 오류)도 함께 보입니다. 그림자가 여전히 원본의, 수정되지 않은 버텍스 위치를 기준으로 계산되고 있기 때문입니다. 이는 서피스 셰이더가 우리가 변경한 버텍스 위치를 반영한 그림자 패스를 자공으로 생성하지 않기 때문입니다. 이 문제를 해결하려면 서피스 셰이더 정의에서 addshadow 라는 힌트를 추가해야 하며, 그러면 이러한 아티팩트는 사라질 것입니다.
// addshadow는 우리가 정의한 버텍스 셰이더를 기반으로 그림자 패스를 생성하도록 합니다
#pragma surface surf Standard fullforwardshadows vertex:vert addshadow

좀 더 재미있는 쪽으로 버텍스 셰이더를 변경해 보겠습니다. 모델을 단순히 크게 만들지 않고, x좌표의 사인값을 기반으로 y좌표에 오프셋시켜 물결치는 효과를 주겠습니다.

이렇게 하면 간격이 넓은 큰 물결이 만들어지므로, 이러한 속성을 제어할 수 있도록 두 개의 변수를 추가하겠습니다.
// ···
_Amplitude ("Wave Size", Range(0,1)) = 0.4
_Frequency ("Wave Freqency", Range(1, 8)) = 2
// ···
float _Amplitude;
float _Frequency;
// ···
void vert(inout appdata_full data){
float4 modifiedPos = data.vertex;
modifiedPos.y += sin(data.vertex.x * _Frequency) * _Amplitude;
data.vertex = modifiedPos;
// ···


이제 모델에 멋진 조절 가능한 물결 효과가 생겼지만, 아쉽게도 변형된 모델의 노말이 올바르지 않습니다. 우리는 단지 버텍스 위치만 이동시켰을 뿐, 노말값은 수정하지 않았기 때문입니다.

커스텀 지오메트리에 대해 올바른 노말을 생성하는 가장 쉽고 유연한 방법은, 인접한 표면 지점들에 대한 지오메트리를 계산하고, 그로부터 노말을 다시 계산하는 것입니다.
인접한 표면 지점을 얻기 위해서는 표면의 탄젠트와 비탄젠트를 따라가면 됩니다. 노말, 탄젠트, 비탄젠트는 서로 직교(orthogonal)하며, 탄젠트와 비탄젠트는 모두 객체의 표면 위에 존재하는 방향 벡터입니다.

노말은 파란색, 탄젠트는 빨간색, 비탄젠트는 노란색입니다.
다행히도 탄젠트는 모델 데이터에 이미 포함되어 있어서 그대로 사용할 수 있습니다. 비탄젠트는 포함되어 있지 않지만, 노말과 탄젠트의 외적을 계산하면 쉽게 구할 수 있습니다. (두 벡터의 외적은 두 벡터 모두에 직교하는 벡터를 반환합니다.)
비탄젠트를 얻은 뒤에는, 버텍스 위치에서 아주 조금 떨어진 두 개의 새 지점을 만들어서, 원래 버텍스 위치에 적용했던 것과 동일한 변형을 이 지점들에도 적용합니다.
float3 posPlusTangent = data.vertex + data.tangent * 0.01;
posPlusTangent.y += sin(posPlusTangent.x * _Frequency) * _Amplitude;
float3 bitangent = cross(data.normal, data.tangent);
float3 posPlusBitangent = data.vertex + bitangent * 0.01;
posPlusBitangent.y += sin(posPlusBitangent.x * _Frequency) * _Amplitude;
이제 이렇게 구한 위치들을 이용해 표면의 새로운 노말을 계산할 수 있습니다. 변형된 기준 표면 위치를 기준으로, 이전에 탄젠트와 비탄젠트를 더해 만든 변형된 표면 위치들에서 빼주는 방식으로 새로운 탄젠트와 비탄젠트를 계산합니다. 그렇게 얻은 새로운 탄젠트와 비탄젠트의 외적을 구하면, 우리가 사용할 새로운 노말 벡터가 게산됩니다.
void vert(inout appdata_full data)
{
float4 modifiedPos = data.vertex;
modifiedPos.y += sin(data.vertex.x * _Frequency) * _Amplitude;
float3 posPlusTangent = data.vertex + data.tangent * 0.01;
posPlusTangent.y += sin(posPlusTangent.x * _Frequency) * _Amplitude;
float3 bitangent = cross(data.normal, data.tangent);
float3 posPlusBitangent = data.vertex + bitangent * 0.01;
posPlusBitangent.y += sin(posPlusBitangent.x * _Frequency) * _Amplitude;
float3 modifiedTangent = posPlusTangent - modifiedPos;
float3 modifiedBitangent = posPlusBitangent - modifiedPos;
float3 modifiedNormal = cross(modifiedTangent, modifiedBitangent);
data.normal = normalize(modifiedNormal);
data.vertex = modifiedPos;
}

이 셰이더에 마지막으로 추가하고 싶은 것은 시간에 따른 움직입니다. 지금까지는 정점의 x좌표만을 함수의 파라미터로 사용했지만, 여기에 시간을 추가하는 것은 꽤 간단합니다.
유니티는 시간 값을 모든 셰이더에 자동으로 전달해 주는데, 이는 4차원 벡터 형태입니다. 첫 번째 요소는 시간/20, 두 번째 요소는 초 단위의 시간값, 세 번째는 시간×2, 네 번째는 시간×3 으로 구성되어 있습니다. 우리는 외부 속성값을 통해 시간 조절을 하고 싶기 때문에, 초 단위 시간값인 시간 벡터의 두 번째 요소를 사용합니다. 그리고 나서 애니메이션 속도와 곱한 시간값을 더해주면, 움직이는 물결 효과를 만들 수 있습니다.

샘플링된 표면 위치의 오프셋을 약간 증가시켜서 (최대 0.01 unit 까지) 아티팩트를 더 부드럽게 처리했습니다. 짧은 거리의 오프셋은 복잡한 왜곡을 더 정밀하게 표현할 수 있고, 더 큰 거리의 오프셋은 일부 요소를 부드럽게 감춰줍니다.
Shader "Tutorial/015_vertex_manipulation"
{
// 인스펙터(Inspector) 창에서 조절 가능한 값을 노출시킵니다
Properties
{
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
_Smoothness ("Smoothness", Range(0, 1)) = 0
_Metallic ("Metalness", Range(0, 1)) = 0
[HDR] _Emission ("Emission", color) = (0,0,0)
_Amplitude ("Wave Size", Range(0,1)) = 0.4
_Frequency ("Wave Freqency", Range(1, 8)) = 2
_AnimationSpeed ("Animation Speed", Range(0,5)) = 1
}
SubShader
{
// 이 머티리얼은 완전히 불투명하며, 다른 불투명한 지오메트리들과 동일한 타이밍에 렌더링됩니다
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}
CGPROGRAM
// 이 셰이더는 서피스 셰이더로,
// Unity가 백그라운드에서 확장하여 다양한 조명 효과와 기타 기능들을 자동으로 추가해줍니다
// 우리의 서피스 셰이더 함수 이름은 surf이며, 사용자 정의 조명 모델을 사용합니다
// fullforwardshadows는 Unity가 셰이더에 필요한 그림자 패스를 추가하도록 합니다
// vertex:vert는 vert 함수를 버텍스 셰이더 함수로 사용하도록 지정합니다
// addshadow는 우리가 정의한 버텍스 셰이더를 기반으로 그림자 패스를 생성하도록 합니다
#pragma surface surf Standard fullforwardshadows vertex:vert addshadow
#pragma target 3.0
sampler2D _MainTex;
fixed4 _Color;
half _Smoothness;
half _Metallic;
half3 _Emission;
float _Amplitude;
float _Frequency;
float _AnimationSpeed;
// Unity가 자동으로 값을 채워주는 입력 구조체
struct Input
{
float2 uv_MainTex;
};
void vert(inout appdata_full data)
{
float4 modifiedPos = data.vertex;
modifiedPos.y += sin(data.vertex.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;
float3 posPlusTangent = data.vertex + data.tangent * 0.01;
posPlusTangent.y += sin(posPlusTangent.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;
float3 bitangent = cross(data.normal, data.tangent);
float3 posPlusBitangent = data.vertex + bitangent * 0.01;
posPlusBitangent.y += sin(posPlusBitangent.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;
float3 modifiedTangent = posPlusTangent - modifiedPos;
float3 modifiedBitangent = posPlusBitangent - modifiedPos;
float3 modifiedNormal = cross(modifiedTangent, modifiedBitangent);
data.normal = normalize(modifiedNormal);
data.vertex = modifiedPos;
}
// 조명 함수가 사용하는 다양한 표면 속성을 설정하는 서피스 셰이더 함수
void surf (Input i, inout SurfaceOutputStandard o)
{
// 알베도 텍스처를 샘플링하고 색상 조정
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb;
// Metalic, Smoothness, Emission 값을 직접 적용
o.Metallic = _Metallic;
o.Smoothness = _Smoothness;
o.Emission = _Emission;
}
ENDCG
}
FallBack "Standard"
}
'Graphics > Ronja's Unity Shader tutorials' 카테고리의 다른 글
| 뎁스 텍스처를 활용한 포스트프로세싱 (0) | 2025.08.31 |
|---|---|
| 포스트프로세싱 기초 (0) | 2025.08.04 |
| 폴리곤 클리핑 (0) | 2025.03.22 |
| 커스텀 라이팅 (램프 효과) (0) | 2025.02.23 |
| 프레넬 (0) | 2025.02.02 |