Unity3D 셰이더에 대한 시리즈 포스트 두 번째 파트입니다. 이번 편에서는 surface shader를 다루고자 합니다. 첫 번째 편에서 이야기한 것처럼 셰이더란 Cg/HLSL이라고 불리는 언어로 작성된 GPU에 의해 실행되는 프로그램입니다. 이 프로그램은 3D 모델을 구성하는 삼각형들을 스크린 상에 그리는 데 사용됩니다. 간략히 말해 셰이더란 서로 상이한 머티리얼을 어떻게 렌더링 할지를 표현하는 코드라 할 수 있습니다. 그중 Unity3D에서 사용되는 Surface shader는 간단하게 개발자가 머티리얼의 룩을 정의할 수 있는 간편화 된 버전입니다.
위의 다이어그램은 surface shader가 어떻게 동작하는지를 대충 보여주고 있습니다. 3D 모델은 먼저 모델의 기하 정보를 변경할 수 있는 함수를 거칩니다. 그리고 나서 몇 가지 직관적인 속성을 이용하여 룩을 정의할 수 있는 함수를 통과하게 됩니다. 마지막으로 빛에 의해 기하가 어떻게 영향을 받는지를 결정하는 라이팅 모델을 거칩니다. 이를 통과한 후 최종적으로 스크린 상에 각각의 픽셀에 대한 RGBA 컬러가 결정됩니다.
Surface function
surface shader에서 가장 중요한 부분은 surface 함수입니다. 입력으로 3D 모델의 데이터를 받아 출력으로 렌더링 속성이 결정됩니다. 아래의 surface shader는 하나의 물체에 흰색(diffuse)을 부과한 예제입니다.
Shader "Example/Diffuse Simple" {
SubShader {
Tags {"RenderType" = "Opaque"}
CGPROGRAM
#pragma surface surf Lambert
struct Input {
float4 color : COLOR;
};
void surf(Input IN, inout SurfaceOutput o){
o.Albedo = 1; // 1 = (1, 1, 1, 1) = white
}
ENDCG
}
Fallback "Diffuse"
}
Line 5, Surface 함수로 surf를 쓰겠다는 것과 Lambertian 라이팅 모델을 사용할 것을 명시하고 있고, Line 10, 머티리얼의 albedo(베이스 컬러)로 흰색을 쓰겠다고 명시하고 있습니다. surface 함수는 원래의 3D 모델로 부터 어떠한 데이터도 사용하고 있지 않아요. 그럼에도 불구하고 Cg/HLSL은 정의할 입력 구조체를 요구하네요(구조체 Input으로 정의된 IN을 내부에서 사용하지 않더라도 Input 구조체를 선언함을 의미).
Surface output
구조체 Surface output은 머티리얼의 최종 렌더링된 결과를 결정하는 데 사용될 수 있는 몇 가지 속성들을 가지고 있습니다.
- fixed3 Albedo : 베이스 컬러 / 오브젝트의 텍스쳐로 쓰임
- fixed3 Normal : 면의 방향으로 빛에 의한 반사각을 결정할 수 있음
- fixed3 Emission : 오브젝트가 그 자체로 얼마나 많은 빛을 발광할 수 있는지를 나타냄
- half Specular : 0에서 1사이의 값으로 머티리얼이 빛을 얼마나 잘 반사하는지 나타냄
- fixed Gloss : 경면 반사(specular reflection)의 얼마나 산란(diffuse)되는지는 나타냄
- fixed Alpha : 머티리얼의 투명 정도를 나타냄
Cg/HLSL은 전통적인 float 타입을 지원합니다. 하지만 연산을 위해 32비트가 필요한 경우는 좀처럼 없지요. 보통은 16비트로 충분하기 때문에 half 타입을 선호합니다. 또한 파라미터의 대부분이 0과 1 사이, -1과 +1 사이 등의 범위 값을 나타내는 경우가 많아서 이를 위해 Cg는 fixed 타입을 지원합니다. 최대 범위로 -2에서 2사이까지 확장이 가능하며 이 경우 10비트가 필요합니다.
모든 자료형에 대해 배열 또한 지원합니다. 예를 들어 fixed2, fixed3, fixed4 등이 그것이지요. 이러한 자료형은 병렬 컴퓨팅에 최적화되어 있어 대부분의 연산이 하나의 명령어로 처리되게끔 설계되어 있습니다. 예를 들어 아래의 네 가지의 예제는 모두 같은 것입니다.
// 전통적인 C#
Albedo.r = 1;
Albedo.g = 1;
Albedo.b = 1;
// 배열 표현
Albedo.rgb = fixed3(1, 1, 1);
Albedo.rgb = 1;
Albedo = 1;
Sampling textures
하나의 모델에 텍스쳐를 입히는 건 조금 복잡합니다. 코드를 보여드리기 전에 먼저 3D 오브젝트에 텍스쳐 매핑이 어떻게 이루어지는지 이해할 필요가 있습니다. 텍스쳐가 입혀진 3D 모델은 삼각형들로 구성되어 있는데, 각 삼각형은 다시 3개의 정점으로 구성되지요. 각 정점에는 UV라는 것과 컬러 값을 데이터로 포함하고 있습니다. 여기에서 UV란 2차원의 벡터로서 정점에 매핑되는 텍스쳐의 위치 정보를 의미합니다.
위 그림은 Unity3D 부트캠프(Bootcamp) 데모로 부터 가져온 3D 모델입니다. 현재 보다시피 셰이딩이 적용된 와이어프레임 모델로 렌더링 되어 있네요. 모델을 잘 보면 두 개의 삼각형이 보이시죠? 삼각형을 구성하는 각 정점은 직교 좌표계(Cartesian Coordinates) 상의 정보를 들고 있고, 이 정보는 0과 1 사이의 값으로 정규화되어 있습니다. 그 값이 바로 오른쪽의 텍스쳐 상의 좌표값입니다(정점에 텍스쳐의 좌표 정보가 매핑되어 있는 것이지요). 이 좌표값이 UV 좌표계 상의 정보입니다.
3D 오브젝트에 텍스쳐를 적용시키기 위해서는 각 정점에 대한 UV좌표가 필요합니다. 아래 셰이더는 하나의 텍스쳐를 3D 모델에 매핑하는 예제입니다.
Shader "Example/Diffuse Texture" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
}
SubShader {
Tags {"RenderType" = "Opaque"}
CGPROGRAM
#pragma surface surf Lambert
struct Input{
float2 uv_MainTex;
}
sampler2D _MainTex;
void surf(Input IN, inout SurfaceOutput o) {
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
}
ENDCG
}
Fallback "Diffuse"
}
Line 12의 _MainTex는 텍스쳐를 선언한 것이고, 이는 Line 3에서 머티리얼 인스펙터로부터 들고 옵니다. 현재 픽셀의 UV 데이터는 Line 10에 들어 있습니다. 이때 텍스쳐 이름 앞에 uv를 붙여서 Input 구조체에 넣는 방식으로 선언되어야 합니다(위 예에서는 uv_MainTex로 표현된 것이지요).
다음 단계는 UV가 지시하는 텍스쳐의 일부를 찾아내는 것입니다. Cg/HLSL은 tex2D라는 아주 유용한 함수를 제공하고 있습니다. 이 함수는 하나의 텍스쳐와 관련된 UV 좌표를 입력으로 받고, 해당하는 텍스쳐의 RGB 값을 반환합니다.
여기에서 UV 좌표값은 정점에만 저장된다는 사실이 매우 중요합니다. 이 때 정점이 아닌 픽셀에 대해서 tex2D 함수는 가까운 세 개의 정점으로부터 보간을 통해 값을 구합니다.
Surface input
Cg/HLSL은 몇 가지 흥미로운 기능을 제공합니다. 그 중 하나가 Surface input(input)에 Unity3D가 우리를 위해 계산해 놓은 값들을 설정할 수 있다는 것이지요. 예를 들어 input에 float3 worldPos를 넣으면 입력 정점에 대한 전역 좌표가 저장됩니다. 정점에 대한 전역 좌표는 특정 점으로부터의 거리의 계산이 필요한 특정 이펙트에 효과적으로 사용될 수 있지요.
Shader "Example/Diffuse Distance" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_Center ("Center", Vector) = (0,0,0,0)
_Radius ("Radius", Float) = 0.5
}
SubShader {
Tags { "RenderType" = "Opaque" }
CGPROGRAM
#pragma surface surf Lambert
struct Input {
float2 uv_MainTex;
float3 worldPos;
};
sampler2D _MainTex;
float3 _Center;
float _Radius;
void surf (Input IN, inout SurfaceOutput o) {
float d = distance(_Center, IN.worldPos);
float dN = 1 - saturate(d / _Radius);
if (dN > 0.25 && dN < 0.3)
o.Albedo = half3(1,1,1);
else
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
}
ENDCG
}
Fallback "Diffuse"
}
Line 20-21에는 우리가 머티리얼 인스펙터에서 정의한 _Center 값과 IN.worldPos 사이의 거리를 계산합니다. 그리고 나서 거리 값을 0과 1 사이 값으로 절단하는데, 이때 _Radius에 가까우면 0으로 페이드 되도록 만듭니다. 마지막으로 임의의 영역(여기서는 0.25에서 0.3 사이)에 대해서 픽셀 값으로 흰색을 부과하도록 합니다.
GPU상에서 돌아가는 셰이더는 순차적인 코드에 최적화되어 있습니다. 그말 즉은 위 예제처럼 분기문이 셰이더에 포함되면 성능이 급격하게 나빠집니다. 분기문이나 결과값을 섞는 등의 작업의 경우 아래와 같이 코드를 작성하는 것이 최적화에 좋습니다.
float d = distance(_Center, IN.worldPos);
float dN = 1 - saturate(d / _Radius);
dN = step(0.25, dN) * step(dN, 0.3);
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb * (1-dN) + half3(1,1,1) * dN;
Cg/HLSL에는 위 예제에 사용된 saturate나 step과 같은 수많은 내장함수가 있습니다. 이런 함수를 사용하게 되면 if 구문의 대부분을 대체할 수 있지요.
Other inputs
Cg에는 worldPos와 같이 유용하게 사용할 수 있는 변수들이 많습니다. 전체 목록은 Unity3D의 공식 문서를 참조하도록 하고, 아래에는 그중에서 가장 빈번하게 사용되는 몇 개의 변수를 간추려보았습니다.
- float3 viewDir : 카메라의 방향
- float4 name : COLOR 이 구문을 사용하면 변수(name)에 현재 정점의 컬러 값이 저장
- float4 screenPos : 스크린 상에서의 현재 픽셀의 위치값
- float3 worldPos : 전역 좌표계 상에서의 현재 픽셀의 위치 값
Vertex function
surface shader의 또 다른 흥미로운 특징은 정점을 surf에 보내기 전에 정점을 변경할 수 있다는 것입니다. 알다시피 surf는 RGBA 공간에서 컬러를 조작할 수 있지요. 이와 달리 vertex modifier 함수를 사용하면 우리가 정점의 3차원 좌표를 어떻게 변경할 수 있을지 알 수 있습니다. 아주 간단한 예로서 주어진 3D 모델을 마치 살이 찐 것처럼 만들어보도록 합시다. 이를 위해 모델을 구성하는 삼각형의 면의 방향을 따라 삼각형을 키워보겠습니다. 삼각형의 방향은 이미 노멀로 주어집니다. 노멀은 삼각형 표면에 직교하는 단위 벡터를 의미합니다. 노멀 방향으로 정점을 확장하는 것은 아래 식의 형태로 구하면 됩니다.
newVertex = vertex + normal * amount
여기에서 amount란 새로운 정점을 원래 것으로부터 얼마큼 많이 떨어지게끔 할 것인지에 대한 양을 의미합니다. 이러한 기법을 normal extrusion이라고 부릅니다.
Shader "Example/Normal Extrusion" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_Amount ("Extrusion Amount", Range(-0.0001,0.0001)) = 0
}
SubShader {
Tags { "RenderType" = "Opaque" }
CGPROGRAM
#pragma surface surf Lambert vertex:vert
struct Input {
float2 uv_MainTex;
};
float _Amount;
void vert (inout appdata_full v) {
v.vertex.xyz += v.normal * _Amount;
}
sampler2D _MainTex;
void surf (Input IN, inout SurfaceOutput o) {
o.Albedo = tex2D (_MainTex, IN.uv_MainTex).rgb;
}
ENDCG
}
Fallback "Diffuse"
}
Line 9는 vert라고 정의한 vertex modifier입니다. 이 녀석은 하나의 정점의 위치를 입력으로 받아 노멀 방향을 따라 투영을 수행합니다. appdata_full은 현재 정점의 모든 데이터를 포함하고 있는 구조체입니다.
지금까지 배운 것을 합쳐서 스노우 이펙트를 만들어 봅시다.
surf와 vert를 모두 사용한 전형적인 예제로 스노우 이펙트를 들 수 있습니다. 스노우 이펙트는 시간이 지남에 따라 눈이 3D 모델에 누적해서 쌓여가는 효과를 만들어 냅니다. 처음에는 _SnowDirection를 마주 보는 면에 대해서만 영향을 주다가 점차 _Snow가 증가하면 하늘 방향이 아닌 삼각형들 또한 영향을 받게 만드는 거죠.
이걸 만들려면 제일 먼저 하늘을 향한 삼각형을 어떻게 판별할지부터 이해해야 합니다. 눈의 방향이 하늘로부터 오니까요. 눈의 방향, 즉 _SnowDirection이라는 변수는 단위 벡터로 정의됩니다. 면의 방향과 눈의 방향의 일치 여부를 확인하는 건 여러 가지 방법을 통해 가능하지만, 가장 간단한 방법은 면의 노멀을 눈의 방향에 투영시켜 보는 겁니다. 두 개의 벡터가 모두 크기가 1이므로, 결과값은 +1(동일 방향)에서 -1(반대방향) 사이의 값이 되겠지요.
다음 튜토리얼에서 보다 자세히 다루므로, 일단 이 계산이 내적을 통해서 가능하고, 이 값이 cos(theta)와 동일하다는 사실만 언급하고 넘어가겠습니다. 내적의 결과값이 _Snow로 정의된 값보다 큰지 작은지 대소 관계를 비교함으로써 눈이 쌓일 면을 결정할 수 있습니다.
여기에서 우리가 주의를 기울여야 할 다른 요소가 하나 더 있는데 _SnowDirection은 전역 좌표계의 정보라는 것입니다. 현재 주어진 정점의 노멀은 지역좌표계에서 정의된 값이므로 이 둘을 그냥 비교해서는 안됩니다. Unity3D에서는 WorldNormalVector라는 함수를 통해 전역좌표계 상의 노멀 값을 계산해 줍니다. 이렇게 계산된 결과를 이용해서 비교 연산을 수행하면 되겠지요.
Shader "Example/SnowShader" {
Properties {
_MainColor ("Main Color", Color) = (1.0,1.0,1.0,1.0)
_MainTex ("Base (RGB)", 2D) = "white" {}
_Bump ("Bump", 2D) = "bump" {}
_Snow ("Level of snow", Range(1, -1)) = 1
_SnowColor ("Color of snow", Color) = (1.0,1.0,1.0,1.0)
_SnowDirection ("Direction of snow", Vector) = (0,1,0)
_SnowDepth ("Depth of snow", Range(0,0.0001)) = 0
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
#pragma surface surf Lambert vertex:vert
sampler2D _MainTex;
sampler2D _Bump;
float _Snow;
float4 _SnowColor;
float4 _MainColor;
float4 _SnowDirection;
float _SnowDepth;
struct Input {
float2 uv_MainTex;
float2 uv_Bump;
float3 worldNormal;
INTERNAL_DATA
};
void vert (inout appdata_full v)
{
// Convert _SnowDirection from world space to object space
float4 sn = mul(_SnowDirection, _World2Object);
if(dot(v.normal, sn.xyz) >= _Snow)
v.vertex.xyz += (sn.xyz + v.normal) * _SnowDepth * _Snow;
}
void surf (Input IN, inout SurfaceOutput o)
{
half4 c = tex2D (_MainTex, IN.uv_MainTex);
o.Normal = UnpackNormal (tex2D (_Bump, IN.uv_Bump));
if(dot(WorldNormalVector(IN, o.Normal), _SnowDirection.xyz)>=_Snow)
o.Albedo = _SnowColor.rgb;
else
o.Albedo = c.rgb * _MainColor;
o.Alpha = 1;
}
ENDCG
}
FallBack "Diffuse"
}
벡터 간의 코사인 값을 구하는 코드를 짤 수도 있지만 Cg에서는 dot라고 불리는 내적 연산용 함수가 내장되어 있어, 이를 사용하는 것이 성능면에서 더 좋습니다. Line 36은 지역 좌표계의 노멀 값을 전역 좌표계로 변경하는 또 다른 방법을 이용하고 있습니다. 위 예제에서는 사실 설명한 WorldNormalVector를 사용하고 있지 않습니다.
결론
이번 포스트에서는 surface shader를 소개하고, 이를 이용해서 다양한 효과를 만드는 방법을 살펴보았습니다. 본 포스트는 Unity3D 매뉴얼의 Surface Shader Examples를 많이 참조했습니다. 이 문서의 다른 페이지에는 다른 라이팅 모델을 구현하는 방법에 대해 또한 자세하게 설명하고 있는데요. 이에 관해서는 본 튜토리얼의 3번째 파트에서 보다 자세히 탐구해보도록 하지요.
위 내용은 Alan Zucconi의 "A gentle introduction to shaders in Unity3D [Part 2]"를 참조하여 작성되었습니다.
'CG' 카테고리의 다른 글
Unity Shader 입문(5) : 스크린 셰이더와 이미지 이펙트 (0) | 2022.05.31 |
---|---|
Unity Shader 입문(4) : 버텍스 및 프래그먼트 셰이더 (0) | 2022.05.30 |
Unity Shader 입문(3) : 물리기반 렌더링과 라이팅 모델 (0) | 2022.05.27 |
Unity Shader 입문(1) : Shader에 대한 간략한 소개 (0) | 2022.05.25 |
댓글