본문 바로가기
게임 공부/HLSL

기초적인 라이팅을 위한 정점 및 픽셀 셰이더를 작성해보자

by woohyeon 2021. 3. 12.
반응형

Shader

image

위 그림은 그래픽스 파이프라인을 간단하게 나타낸 것이다. 그중 1, 3단계인 정점 처리프래그먼트 처리 단계가 우리가 직접 작성할 단계이다. 정점 처리를 수행하는 프로그램을 정점 셰이더(vertex shader)라고 하며 프래그먼트 처리를 수행하는 프로그램을 픽셀(프래그먼트) 셰이더(pixel shader)라고 한다.

우리는 3D 오브젝트를 화면에 렌더링하기 위해 정점 셰이더(vertex shader)픽셀 셰이더(pixel shader)를 작성해야 한다. 셰이더는 HLSL(High-Level Shading Language)라는 언어로 작성을 하며 C와 거의 비슷한 문법을 가지고 있다.

정점 셰이더

정점 셰이더는 파이프라인의 정점 처리 단계에서 사용자가 작성한 루틴대로 정점을 처리한다. 정점 셰이더의 입력은 정점 1개이며, 이 정점에 포함된 정보들을 가지고 여러 변환(transformation)을 거쳐 최종적인 위치(공간)로 이동시킨다. 정점이 36개인 오브젝트가 있다면, 정점 셰이더는 36번 실행될 것이다. 정점 셰이더는 변환한 정점과 관련된 정보들을 반환한다.

이러한 데이터들은 다음 단계인 래스터라이저라는 장치가 입력으로 받아서 처리한다. 래스터라이저는 셰이더처럼 프로그래밍이 가능하지 않고 하드웨어로 고정되어 있다. 변환된 정점을 가지고 각 폴리곤 내부를 프래그먼트로 채운다. 이러한 프래그먼트 들이 픽셀 셰이더의 입력으로 전달된다.

픽셀 셰이더

픽셀 셰이더는 파이프라인의 프래그먼트 처리 단계에서 사용자가 작성한 루틴대로 픽셀을 처리한다.(픽셀과 프래그먼트는 비슷하지만 다른 용어이다. 하지만 DirectX에선 모두 픽셀이라 부른다.) 픽셀 셰이더의 결과물은 색상이다. 픽셀 셰이더는 입력으로 받은 데이터를 통해 하나의 픽셀에 대한 최종적인 색상을 결정하여 반환한다.



난반사, 정반사를 표현하기 위한 간단한 정점 및 픽셀 셰이더를 작성해보자.

Diffuse reflection (난반사)

카메라에 들어오는 난반사광의 세기(양)을 구하려면 다음과 같이 빛 벡터(l)과 표면에 대한 법선(n) 벡터이 이루는 각도 Θ가 필요하다. 카메라의 위치에 따라 이 값이 달라지지 않는 이유는 게임에선 주로 램버시안 모델을 사용하고, 이 모델에 들어오는 난반사광은 모든 뱡향을 따라 같은 강도로 퍼지기 때문이다.

image

Θ를 얻었다면 램버시안 모델에선 cosΘ라는 값이 반사된 빛의 세기가 된다. 하지만 Θ를 얻고, cos 값을 구하는 것보다 더욱 쉬운 방법이 있다. 두 벡터의 성분을 알고 있다면 두 벡터에 대한 내적(dot product)이 cosΘ와 동일하다.(cos이 더 간단하지 않나 생각할 수 있지만, 코드에서 따졌을 때 cos 함수의 비용이 더 비싸다) 다만 두 벡터의 길이가 모두 1이라는 가정이 필요한데, 이러한 가정을 임의로 하더라도 결과가 달라지지 않는다. 벡터의 길이가 길어지거나 짧아진다고 두 벡터가 이루는 각이 변하는 것은 아니기 때문이다. 따라서 우리는 반사된 빛의 세기를 구하기 위해 두 벡터의  크기를 1로 가정하고 내적을 이용해도 된다.

우선 다음과 같이 정점 셰이더의 입력으로 받을 구조체 타입을 정의한다. float3float4는 각각 float 타입의 성분을 가지는 벡터를 의미한다.

정점 셰이더

struct VS_INPUT
{
    float4 mPosition : POSITION; // (x,y,z,w)
    float3 mNormal   : NORMAL;   // (x,y,z)
};

mPosition은 정점의 좌표고, mNormal은 정점에 대한 법선을 의미한다. 옆에 콜론과 함께 적힌 대문자 단어는 시맨틱(Semantic)이라 한다. 이는 일종의 key라고 생각하면 된다. 실제로 넘어오는 데이터들은 더욱 많은 데이터들을 포함할 수 있는데, 데이터를 보내는 쪽에서 그리고 받는 쪽에서 이러한 key를 이용하여 데이터를 구분하는 것이다.

 

출력할 데이터 구조는 다음과 같다.

struct VS_OUTPUT
{
    float4 mPosition : POSITION;
    float3 mDiffuse  : TEXCOORD1;
};

 

정점 셰이더의 헤더는 다음과 같다.

VS_OUTPUT vs_main(VS_INPUT Input)
{
    ...
}

 

정점 셰이더에서 입력으로 받는 정점은 오브젝트 공간의 정점들이다. 오브젝트 공간의 정점은 월드, 뷰, 투영 변환을 통해 최종적인 공간으로 이동되어야 한다. 이러한 변환 행렬들은 항상 동일하므로 전역 변수로 정의되어 있다고 가정한다. 아래에 나오는 코드들은 모두 main 내에 들어가는 코드들이다.

VS_OUTPUT Output;

Output.mPosition = mul(Input.mPosition, gWorldMatrix);

: mul은 벡터와 행렬을 곱해주는 연산이다. Input.mPosition는 행벡터이며, gWorldMatrix는 4x4 행렬이다. gWorldMatrix는 월드 행렬로 오브젝트 공간의 정점을 월드 공간으로 이동시킨다.

 

float3 lightDir = normalize(Output.mPosition.xyz - gWorldLightPosition.xyz);

: 다른 공간으로 이동하기 전에 빛 벡터를 구하려면 현재 월드 공간에서의 정점 좌표가 필요하다. gWorldLightPosition는 월드 공간의 좌표이기 때문에 같은 월드 공간의 정점과 연산을 해야한다.
광원의 위치는 gWorldLightPosition로 정의되어 있다고 가정한다. 이 위치로부터 해당 정점까지 잇는 방향 벡터를 얻어야 한다. 처음에 살펴본 벡터 l에 해당한다. (벡터 연산은 모두 알고 있다고 가정..)
그리고 처음에 말했듯이 크기를 1로 만든다. 이를 정규화(normalize)라고 하며 위와 같이 함수를 제공한다. 그러면 길이가 1인 월드 공간의 빛 벡터를 얻게 된다.

 

Output.mPosition = mul(Output.mPosition, gViewMatrix);
Output.mPosition = mul(Output.mPosition, gProjectionMatrix);

: 뷰 변환과 투영 변환을 수행한다.

 

float3 worldNormal = mul(Input.mNormal, gWorldMatrix);
worldNormal = normalize(worldNormal);

: 빛 벡터와 표면에 대한 노멀을 내적하기 위해 길이가 1인 월드 공간의 노멀이 필요하다. 위 코드는 이러한 노멀 벡터를 얻어낸다.

 

Output.mDiffuse = dot(-lightDir, worldNormal);

return Output;

: 빛 벡터와 노멀을 내적하여 그 결과를 Output에 저장한다. lightDir 앞에 -가 붙는 이유는 lightDir은 노멀과의 방향을 맞추기 위해서다. lightDir은 광원에서 표면을 향하는 방향의 벡터이다. 반면 노멀은 표면에서 위로 향하는데, 이렇게 되면 두 벡터의 사이각은 우리가 원하는 사이각이 아니다. 따라서 빛 벡터의 방향을 바꾸어 연산해준다. 마지막으로 결과를 반환해준다.

사실 빛을 계산해주는 부분은 픽셀 셰이더에서 수행해도 상관없다. 그럴 경우 lightDir와 worldNormal을 추가로 넘겨야 할 것이다. 하지만 정점 셰이더에서 수행하는 이유는 픽셀 셰이더가 보통 정점 셰이더보다 호출되는 횟수가 훨씬 많기 때문이다.

 

픽셀 셰이더

struct PS_INPUT
{
   float3 mDiffuse : TEXCOORD1; 
};

: 정점 셰이더에서 출력된 난반사광의 값을 입력으로 받는 구조체를 정의

 

float4 ps_main(PS_INPUT Input) : COLOR
{
    float3 diffuse = saturate(Input.mDiffuse);
    return float4(diffuse, 1);
}

위의 시맨틱 COLOR는 반환 타입에 대한 설명을 해준다. 픽셀 셰이더의 내용은 매우 간단하다. 정점 셰이더에서 필요한 것들을 모두 계산했기 때문에, 단순히 빛의 세기를 컬러 값으로 변환하여 출력하면 된다. saturate라는 함수는 인자를 [0, 1] 범위의 값으로 재조정하는 함수이다. 정점 셰이더에서 계산한 cos 값은 항상 0부터 1 사이지만, 래스터라이저에서 보간이 되어 값의 범위가 달라질 수 있기 때문에 해주는 것이다. 그리고 불투명도를 의미하는 알파값과 함께 컬러 타입인 float4로 변환하여 값을 반환한다.

 

이렇게 작성한 정점 및 픽셀 셰이더는 다음과 같이 난반사광이 적용된 물체를 표현해낸다.

image




Specular reflection (정반사)

정반사는 다음과 같은 특징을 가진다. 입사각과 반사각은 동일하다. 정반사의 경우 카메라에 들어오는 빛의 세기는 반사광 벡터 r과 카메라 벡터 v의 각에 대한 cos 값의 거듭 제곱 형태이다. 

image


표면 p가 완전하게 매끄러운 표면인 경우 r 위치로만 빛이 들어오는 완전 정반사라고 볼 수 있지만, 이러한 경우가 아니라면 꼭 r의 위치가 아니더라도 빛이 어느정도 들어오게 된다. 반사 벡터 r과 카메라 벡터 v를 이루는 각을 ρ라고 할 때, 이 값이 0에 수렴할 수록 더 많은 빛을 받게 된다.

r을 중심으로 빛이 어느 범위까지 영향을 미칠 것인지 결정 하기 위한 값이 필요한데, 이 값은 표면의 매끈함의 정도를 나타낸다. 그리고 이 값만큼 cos을 거듭 제곱한다. 난반사와 마찬가지로 두 벡터의 크기를 1로 변경하고 내적으로 계산할 수 있는데, 결국 cos 값은 1보다 작으므로 제곱을 할 수록 값이 작아진다. 즉 매끈함의 정도가 클 수록 범위가 좁아지게 된다. r과 v가 같을 경우 카메라로 들어오는 빛이 최대가 된다. 두 벡터가 이루는 각이 0도이기 때문에 cos 값에 어떤 값을 제곱하던지 1로 최대이기 때문이다.

 

정점 셰이더

정반사의 경우 정점 셰이더의 인풋 타입은 난반사의 경우와 동일하다.

struct VS_OUTPUT
{
    float4 mPosition   : POSITION;
    float3 mViewDir    : TEXCOORD1;
    float3 mReflection : TEXCOORD2;
};

: 오직 정반사만을 고려할 경우 Diffuse가 빠지고 두 벡터가 추가 된다. 결과를 정점 셰이더에서 산출하지 않고, 픽셀 셰이더로 두 벡터를 보내야 하는 이유가 있기 때문에 정점 셰이더에 아웃풋에 두 벡터가 추가된 것이다.

 

모든 방향으로 동일하게 방출되어 카메라의 위치를 고려할 필요가 없는 난반사와 달리 정반사는 카메라의 위치를 고려해야 한다. 따라서 월드 공간의 카메라의 위치가 전역 변수로 선언되어 있다고 가정한다.(gWorldLightPosition)

 

 VS_OUTPUT vs_main(VS_INPUT Input)
 {
     ...
 }

: 정점 셰이더의 헤더는 위와 같다.

 

// vs_main
VS_OUTPUT Output;

Output.mPosition = mul(Input.mPosition, gWorldMatrix);

float3 lightDir = normalize(Output.mPosition.xyz - gWorldLightPosition.xyz);

float3 viewDir = normalize(Output.mPosition.xyz - gWorldCameraPosition.xyz);
Output.mViewDir = viewDir;

: 정점을 월드 공간으로 이동시킨 후 먼저 반사광을 얻어야 하기에, 정규화된 입사광 벡터를 얻는다. 그리고 정규화된 카메라 벡터를 구한다. 카메라 벡터는 카메라의 위치로부터 현재 정점까지 잇는 벡터이다.

 

Output.mPosition = mul(Output.mPosition, gViewMatrix);
Output.mPosition = mul(Output.mPosition, gProjectionMatrix);

float3 worldNormal = mul(Input.mNormal, gWorldMatrix);
worldNormal = normalize(worldNormal);

Output.mReflection = reflect(lightDir, worldNormal);

return Output;

: 정점에 뷰 변환 및 투영 변환을 수행한다. 입사광에 대한 반사광을 구해야 하는데, 입사광 벡터와 노멀이 있다면 reflect 함수를 통해 반사광을 얻을 수 있다. 따라서 월드 공간의 정규화된 노멀을 얻고, 반사광 벡터를 얻고 결과를 반환한다.



픽셀 셰이더

struct PS_INPUT
{
    float3 mViewDir    : TEXCOORD1;
    float3 mReflection : TEXCOORD2;
};

: 입력으로 받을 구조체 타입이다.

 

float4 ps_main(PS_INPUT Input)
{
    ...
}

 

float3 viewDir    = normalize(Input.mViewDir);
float3 reflection = normalize(Input.mReflection);

: 정점 셰이더에서 정규화를 했는데 한 번 더 정규화를 하는 이유는, 정점 셰이더에서 출력된 결과들이 그동안 보간을 통해 변경되었을 수 있기 때문이다.

 

float3 specular = saturate(dot(reflection, -viewDir));
specular = pow(specular, 20.0f);

return float4(specular, 1);

: 카메라 벡터와 반사광 벡터를 내적하여 범위를 0과 1사이로 조정한다. 그리고 20만큼 제곱하여 컬러값을 생성하여 반환한다.

이렇게 표현된 정반사는 카메라에 다음과 같이 물체가 보이게 된다.

image




댓글