LearnOpenGL(04) - Shaders
작성날짜 2022/06/26
이전글: LearnOpenGL(03) - Hello Triangle
GLSL
Hello Triangle 챕터에서 언급했듯이, 셰이더는 GPU에 있는 작은 프로그램입니다. 각 프로그램들은 그래픽 파이프라인의 특정 구간에서 작동합니다. 기본적으로 셰이더는 단순히 입력을 출력으로 바꿔주는 프로그램일뿐입니다. 또한 셰이더는 매우 독립적인데, 셰이더들은 각자의 입력과 출력 이외의 방법으로는 서로 커뮤니케이션을 할 수 없습니다.
셰이더는 C와 비슷한 언어인 GLSL로 쓰여졌습니다. GLSL은 그래픽을 다루거나 벡터나 행렬을 계산하기 위해 맞춤 제작되었습니다.
셰이더는 항상 버전 선언으로 시작하고, 입력과 출력 변수들이 뒤따르며, 유니폼과 메인 함수가 위치합니다. 각 셰이더의 진입점은 메인 함수이며 그곳에서 입력과 출력이 처리됩니다. 혹시 유니폼이 무엇인지 모르겠더라도 괜찮습니다. 곧 설명해 드리겠습니다.
셰이더는 일반적으로 다음과 같은 구조를 가집니다:
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
void main()
{
// process input(s) and do some weird praphics stuff
// output processed stuff to output variable
out_vaiable_name = weird_stuff_we_processed;
}
버텍스 셰이더에서 각 입력 변수는 버텍스 속성(vertex attribute)라고 합니다. 하드웨어에 따라 선언 가능한 버텍스 속성의 최대 개수가 달라집니다. OpneGL은 적어도 4개의 컴포넌트를 가진 16개의 버텍스 속성을 보장하며, 몇몇 하드웨어서는 더 많이 가질 수도 있습니다. 가능한 속성의 개수를 알고 싶다면 GL_MAX_VERTEX_ATTRIBS: 명령어로 검색해 볼 수 있습니다.
int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;
이 명령어는 최소 16을 반환하며 이는 대부분의 목적에서 필요보다 많은 숫자입니다.
제 컴퓨터에서는 16개를 지원하네요!
Types
GLSL은 다른 많은 프로그래밍 언어처럼 우리가 하려는 작업을 위한 특정한 데이터 타입을 가지고 있습니다. GLSL은 int, float, double, uint 그리고 bool처럼 매우 기본적인 데이터 타입을 가지고 있으며 또한 우리가 자주 사용하게 될 vector와 matricex라는 특별한 두 컨테이너 타입도 갖고 있습니다. matrices에 대해서는 다음 챕터에 설명하겠습니다.
Vectors
GLSL에서의 벡터는 방긍 언급한 기본 타입에 대한 2,3 또는 4개의 컴포넌트 컨테이너입니다. 벡터는 다음과 같은 형태를 갖습니다(n 은 컴포넌트의 갯수를 나타냅니다.):
- vecn: n개 float에 대한 기본 벡터
- bvecn: n개 boolean의 벡터
- inecn: n개 integer의 벡터
- uvecn: n개 unsigned integer의 벡터
- dvecn: n개 double 컴포넌트의 벡터
우리는 대부분 vecn을 사용할텐데, 우리의 목적에 float 타입이면 충분합니다.
벡터의 컴포넌트들은 vec.x를 통해 접근할 수 있으며 여기에서 x는 벡터의 첫 번째 컴포넌트입니다. .x, .y, .z, .w를 통해 네 컴포넌트에 접근할 수 있습니다. 또한 GLSL은 rgba를 사용해 색깔이나 stpq를 이용해 텍스처 좌표에 접근할 수 있도록 허용합니다.
벡터를 사용해 Swizzling이라고 하는 재미있고 유연한 컴포넌트 선택이 가능합니다. Swizzling으로 다음과 같은 구문을 사용할 수 있습니다:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
원래 벡터에 해당 컴포넌트가 있는 경우 4개의 문자를 조합하여 (같은 타입인)새로운 벡터를 만들 수 있습니다. 예를 들어 vec2에서 .z 컴포넌트에 접근하는 것은 허용되지 않습니다. 또한 벡터를 다른 벡터 생성자 호출에 대한 인수로 전달하여 필요한 인수 수를 줄일수도 있습니다.
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);
따러서 벡터는 모든 종류의 입력과 출력을 할 때 사용할 수 있는 유연한 데이터 타입이라고 할수 있습니다.
Ins and outs
셰이더는 그 자체로 작고 멋진 프로그램이지만 전체의 일부분일 뿐이므로 각 셰이더가 서로 정보를 교환할 입력과 출력을 갖길 원합니다. GLSL은 이를 위해 특별히 in과 out 키워드를 정의했습니다. 각 셰이더는 해당 키워드를 사용해 입력과 출력을 지정할 수 있으며 출력 변수가 다음 단계의 입력 변수와 일치하면 변수는 전달됩니다. 또한 버텍스 셰이더와 프래그먼트 셰이더는 약간 다릅니다.
버텍스 셰이더는 반드시 어떤 형태의 입력을 받아야하며 그러지 않을 경우 상당히 비효율적입니다. 버텍스 데이터를 직접 입력 받는다는 점에서 입력 방식이 다릅니다. 버텍스 데이터가 어떻게 구성되는지 정의하기 위해 지역 메타데이터로 입력 변수들을 지정하고, 정의된 데이터로 CPU의 버텍스 속성을 구성할 수 있습니다. 이미 지난 챕터에서 이 부분을 ‘layout (location = 0)’ 이런 형태로 본적이 있습니다. 버텍스 셰이더와 버텍스 데이터를 연결할 수 있도록 추가로 layout 사양이 필요합니다.
layout (location = 0) 특정자를 생략하고 glGetAttribLocation OpenGL 코드로 attribute 위치를 검색할 수도 있습니다. 하지만 저는 attribute를 버텍스 셰이더에서 설정하는 것을 선호합니다. 이해하기 더 쉽기 때문입니다.
또 다른 예외 사항은 프레그먼트 셰이터는 결과물로 색상 정보를 반환해야 하므로 색상 값을 담음 vec4 형식의 출력이 필요하다는 점입니다. 프레그먼트 셰이더에서 색상값을 출력하지 못할 경우엔 해당 프레그먼트들의 색상 버퍼가 정의되지 않을 것입니다(주로 검은 색이나 하얀색으로 렌더됩니다.).
한 셰이더에서 다른 셰이더로 데이터를 보내고 싶을 땐 보내는 셰이더에서 출력을 선언하고 받는 셰이더에서도 같은 작업을 합니다. 양쪽에 같은 타입과 이름의 변수가 있다면 OpenGL은 두 변수를 연결하고 셰이더 사이에 데이터를 주고받습니다(이 작업은 프로그램 객체를 연결할 때 수행됩니다). 실제로 어떻게 동작하는지 보여주기 위해 이전 장의 셰이더를 수정하여 버텍스 셰이더가 프레그먼트 셰이더의 색을 결정하도록 하겠습니다.
Vertex shader
#version 330 core
layout (location = 0) in vec3 aPos; // the position variable has attribute position 0
out vec4 vertexColor; // specify a color output to the fragment shader
void main()
{
gl_Position = vec4(aPos, 1.0); see how we directly give a vec3 to vec4’s contruct
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // set the output variable to a dark-red color
}
Fragment shader
#version 330 core
out vec4 FragColor;
in vec4 vertexColor; // the input variable from the vertex shader (same name and same type)
void main()
{
FragColor = vertexColor;
}
vertexColor 변수를 버텍스 셰이더에서 vec4타입 출력으로 선언하고 비슷하게 프레그먼트 셰이더에서도 vertexColor 입력을 선언했습니다. 양쪽에 같은 이름과 타입의 입, 출력이 있으므로 프레그먼트 셰이더의 vertexColor는 버텍스 셰이더의 vertexColor와 연결됩니다. 버텍스 셰이더에서 어두운 빨간색으로 색을 설정했으므로 프레그먼트들도 어두운 빨간색이 됩니다.
좋습니다! 이제 버텍스 셰이더에서 프레그먼트 셰이더로 데이터를 보낼 수 있습니다. 좀 더 다양하게 해봅시다. 이번엔 우리 애플리케이션에서 프레그먼트 셰이더로 색을 보낼 수 있는지 살펴보겠습니다.
Uniforms
유니폼은 CPU상의 애플리케이션에서 GPU의 셰이더에 데이터를 넘기는 방법 중 하나입니다. 유니폼은 버텍스 속성과는 살짝 다릅니다. 가장 큰 차이점은 유니폼은 전역적입니다. 유니폼이 전역적이라는 것은 하나의 유니폼 변수는 각 셰이더 프로그램 객체에 대해 유일하다는 것입니다. 또한, 셰이더 프로그램의 어느 단계에서든지 접근 가능합니다. 두번째로 한 번 유니폼의 값을 설정하면 리셋하거나 업데이트하기 전까지는 그 값이 계속 유지됩니다.
GLSL에서 유니폼을 선언하려면 셰이더에서 형식과 이름 그리고 uniform이라는 키워드를 함께 사용하기만 하면 됩니다. 그 순간부터 새로 선언한 유니폼을 셰이더에서 사용할 수 있습니다. 유니폼을 이용해서 삼각형의 색을 설정하는 코드를 살펴보겠습니다.
#version 330 core
out vec4 FragColor;
uniform vec4 ourColor; // we set this variable in the OpenGL code.
void main()
{
FragColor = ourColor;
}
프레그먼트 셰이더에서 vec4 타입의 ourColor라는 유니폼을 선언했습니다. 그리고 프레그먼트의 출력을 유니폼 값으로 설정했습니다. 유니폼은 전역변수이므로 다른 아무 셰이더 단계에서 유니폼을 정의할 수 있습니다. 따라서 프레그먼트 셰이더에서 다른 값을 얻으러 버텍스 셰이더에 돌아갈 필요가 없어졌습니다. 이 유니폼은 버텍스 셰이더에선 사용하지 않을 것이므로 버텍스 셰이더에선 정의할 필요 없습니다.
만약 유니폼을 선언하고 GLSL 코드의 아무 데서도 사용하지 않는다면 컴파일러는 조용히 해당 변수를 삭제합니다. 이것이 컴파일된 버전의 오류 원인이 될 수도 있으니 명심하시기 바랍니다!
유니폼에 아무런 값도 더하지 않아 유니폼은 현재 비어있습니다. 유니폼에 값을 더하려면 셰이더에서 유니폼 속성의 인덱스/로케이션을 찾아야합니다. 인덱스/로케이션을 찾으면 유니폼의 값을 업데이트할 수 있습니다. 프레그먼트 셰이더에 단일 색상값을 넘겨주는 대신에 시간 흐름에 따라 색을 점점 바뀌게 해보겠습니다.
float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
먼저, glfwGetTime() 메소드로 시간을 가져옵니다. sin 함수로 색을 0.0에서 1.0 사이의 값으로 바꾸고 greenValue에 결과를 저장합니다.