이번 글에서는 셰이더에 대해 더 자세한 사용법을 정리하고자 한다.
지난 글에서 셰이더는 그래픽스 스테이지에서 실행되는 작은 프로그램을 의미한다고 하였다.
대표적으로 이 수업에서 다룰 셰이더는 Vertext Shader, Fragment Shader 2가지 이다.
Vertex Shader 사용 예
모든 셰이더 프로그램은 GLSL 이라는 별도의 언어를 사용한다고 하였다.
Vertex Shader 는 일반적으로 아래 형태로 사용하는데, 각 코드를 뜯어서 설명해보려고 한다.
const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
우선 위 코드는 main.cpp 에 문자열로 GLSL 코드를 하드코딩한 형태이다.
다른 예제를 보면 이 문자열 값을 별도 파일로 빼기도 한다.
한번 이 Shader 프로그램을 뜯어보면
#version 330 core
이 부분은 OpenGL 버전을 명시한다.
현재 3.3 버전을 사용하고 있으므로 330 을 적어주었다.
이 코드를 어떤 컴파일러?를 사용하여 컴파일할지 명시하는 #include 같은 전처리기라고 이해하였다.
layout (location = 0) in vec3 aPos;
이 부분은 입력 변수의 위치와 입력 변수의 타입, 변수 명을 기입하는 부분이다.
모든 셰이더는 어떤 입력값을 받아서 적절히 처리하고, 다음 stage에 넘길 출력값을 생성한다.
이 Vertex Shader 는 vec3 타입 (원소가 3개인 3차원 벡터) 의 데이터를 aPos 라는 변수로 받는다.
그리고 이 입력 변수의 위치는 0 이다.
(입력 변수의 위치에 대한 내용은 아직 이해가 안됨..)
아무튼 어떻게보면 프로그램에 넘기는 매개변수, argument 라고 이해를 하면 될 것 같다.
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
이 부분은 실제 셰이더 프로그램이 실행되는 부분이다. (말 그대로 main() 함수이다.)
셰이더의 출력값은 정해져있다.
저 gl_Position 이 Vertex Shader 가 출력하는 형식이고, 반드시 vec4 형태로 출력해야 한다.
후술하겠지만, Homogenious 형식으로 계산을 하기 위해 3차원 벡터값을 받은 뒤, 각 벡터의 x, y, z 성분에 1.0 이라는 성분을 하나 추가하여 4차원 벡터를 돌려주고 있다.
Fragment Shader 사용 예
fragment shader 의 GLSL 소스코드는 보통 아래와 같다.
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5, 0.2f, 1.0f);
}
보통 색상값을 넣을 때 fragment shader가 처리한다.
색상값은 rgba 형식을 이용하므로 4개 원소를 가진 벡터를 사용한다.
따라서 출력값(out) 의 형태를 vec4 형태 변수를 사용하며, 그 변수의 이름은 FragColor; 라고 미리 위에서 지정해둔 것이다.
실행된 프로그램은 지정한 out 변수에 담길 데이터를 지정하고있다.
Vertex Buffer Object (VBO)
GPU에서 셰이더 프로그램을 실행할 때, 셰이더 프로그램에 넘길 데이터를 임시로 갖고 있을 버퍼 (GPU 메모리)가 필요하다.
특히 (Vertex Shader 를 위해 사용하는?) 버퍼를 Vertex Buffer Object (VBO) 라고 한다.
이 버퍼값은 GPU의 메모리에 저장되며 아래와 같이 생성할 수 있다.
Vertext Buffer Object 는 다양한 유형이 있는데, GL_ARRAY_BUFFER, GL_ELEMENT_ARRAY_BUFFER 등이 있다.
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
우선 버퍼 오브젝트를 저장할 32bit 공간을 VBO 라는 이름으로 할당한다.
glGenBuffers 함수는 버퍼 오브젝트 네임을 생성한다.
인자로는 (생성할 버퍼 오브젝트 네임의 개수, 생성한 버퍼 오브젝트 네임을 저장할 변수의 주소값) 를 받는다.
위 예제의 경우 버퍼를 1개만 생성하므로 그냥 변수하나의 주소값을 지정하였다.
만약에 버퍼를 10개 생성한다면 10개 integer 배열을 만든 뒤, 그 배열의 주소값을 지정하면 된다.
이때 OpenGL의 오브젝트와 네임에 대해 알아야 하는데, 이 블로그를 참고하여 아래와 같이 정리하였다.
오브젝트 : 특정 상태를 저장하고 있다. (OpenGL은 State Machine 이므로 상태(state)가 필요하다)
네임 : 오브젝트를 가리키는 (참조하는) 정수형 값
나는 오브젝트를 key - value 쌍으로 저장하고 있는데, 이 key 를 정수형 key로 하는 오브젝트 네임이 필요하다고 이해해봤다.
glBindBuffer() 함수는 오브젝트를 컨텍스트에 바인딩하는 함수이다.
오브젝트는 갈아끼울 상태를 미리 정의한 것이고, 이렇게 만든 상태값을 현재 상태에 연결하여 반영하는 작업이 glBindBuffer() 함수의 역할이라고 이해했다.
매개변수로 넘기는 값은 (바인딩하는 버퍼 오브젝트의 사용 방법, 바인딩할 버퍼 오브젝트) 이다.
위 예제에서는 GL_ARRAY_BUFFER 로 넘겼기 때문에 버퍼 오브젝트를 배열로서 사용하겠다는 의미이다.
배열로 사용하는 이유는 Vertex Shader에 값을 넘길 때 3차원 벡터 (배열) 형태로 데이터를 넘기기 때문이다.
(버퍼 오브젝트를 넘긴걸까, 버퍼 오브젝트를 가리키는 name을 넘긴걸까?)
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, -0.5f, 0.0f,
-0.5f, 0.5f, 0.0f
};
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
다음은 이렇게 생성한 버퍼 오브젝트에 값을 넣는 작업이다.
우리가 버퍼 오브젝트를 만든 이유는 Vertex Shader에 입력값을 넘기기 위함이다.
이제 Vertex Shader에 실제로 넘길 값을 미리 변수에 저장해두고, 그 값을 생성한 버퍼에 복사하여 넣어둔다.
Vertex Input은 각 축 (x, y, z축)에서 -1.0 과 1.0 사이의 값으로 표현되는 정점데이터만을 처리할 수 있다.
이런 좌표계를 NDC (Normalized Device Coordinates, 정규좌표) 라고 한다.
만약 이 범위를 벗어나도록 작성했다면, 그 정점은 표시되지 않는다.
나중에 이 좌표값들은 glViewport 함수에서 정의된 화면 크기에 맞게 변환된다.
(x = -1.0 으로 썼을 때, 나중에 화면 가로 길이를 800 이라고 지정하면 x= -400 으로 변환)
glBufferData() 함수는 어떤 버퍼로부터 데이터를 복사해올 것인지 복사해올 버퍼를 지정하는 작업이다.
(실제로 복사를 해오진 않는다.)
매개변수로 아래 4개의 데이터를 받는다.
1. 복사할 데이터가 담긴 버퍼의 유형
우리는 float 배열 타입의 vertices 로부터 복사한다. 따라서 GL_ARRAY_BUFFER 를 입력하였다.
2. 복사할 데이터가 담긴 버퍼의 크기
vertices 라는 float 배열의 크기를 가져온다.
3. 복사할 버퍼 주소
vertices 는 배열의 이름이므로 주소값이다!
4. 복사할 데이터를 어떻게 다룰지 설정값
GL_STATIC_DRAW 는 데이터가 한번만 지정되고, 이렇게 지정된 데이터를 GPU가 여러번 사용한다는 뜻이다.
그 밖에도 GL_STREAM_DRAW, GL_DYAMIC_DRAW 같은 설정값이 있다.
Shader 객체 생성과 컴파일
unsigned int vertexShader
vertexShader=glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
예제로는 Vertex Shader를 만들어보았다.
우선 glCreateShader 함수에 생성할 셰이더 종류를 넘겨서 셰이더를 생성한다.
GL_VERTEX_SHADER, GL_GEOMETRY_SHADER, GL_FRAGMENT_SHADER 같은 것들이 있다.
생성한 셰이더에 셰이더가 실행할 GLSL 프로그램 소스코드를 지정해준다.
glShaderSource 함수를 사용하면 되고, 생성한 셰이더, 지정할 소스코드 개수, 지정할 소스코드 문자열, 문자열들의 길이 배열을 지정하면 되는데, 길이배열은 그냥 NULL 을 쓰면 되는 듯 하다.
마지막으로 연결한 소스코드를 glCompileShader 함수를 이용하여 컴파일해준다.
그러면 셰이더 프로그램이 준비된다!
다른 종류의 셰이더를 만들때도 같은 과정을 거치면 된다.
Shader Program
실제로 셰이더를 cpp 소스코드에서 사용할 때는 셰이더 프로그램을 따로따로 가져다 쓰지 않고 Shader Program 이라는 하나의 프로그램에 모두 link 시켜두고 사용한다.
이 프로그램은 여러 shader가 링크된 최종버전의 프로그램 객체이며, 객체를 렌더링할 때 이 shader program을 활성화하여 실행한다.
shader program 은 우선 다음과 같이 생성한다.
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
다음으로 생성한 프로그램에 셰이더들을 링크시킨다.
이때 주의할 점은 각 셰이더들 간에 입출력 형식이 같도록 순서를 맞춰서 링크시켜야 한다.
이게 다르면 링크 에러가 발생한다.
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
셰이더를 일단 하나씩 ShaderProgram에 붙여놓고, 마지막에 한번에 링크한다.
내가 이해한 것은 vertex shader는 위치 정보로 3개의 입력값을 최초로 받아서 4개의 출력값을 보낸다.
그런데 fragment shader는 딱히 입력값을 지정해서 받고 있지 않으니 에러날 것은 없고, 색상값만 하드코딩해서 내보내고 있다.
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
한번 이렇게 shader program 에 링크된 이후에는 더 이상 shader 객체가 단독으로 필요가 없다.
그래서 기존에 만들었던 shader는 모두 삭제해준다.
while (!glfwWindowShouldClose(pWindow)) {
...
glUseProgram(shaderProgram);
...
}
만든 Shader Program 은 나중에 렌더링 Loop를 돌 때 그 안에서 호출하여 사용하게 된다.
glUserProgram() 함수를 호출하면 링크도니 셰이더 프로그램을 활성화할 수 있다.
객체를 렌더링할 때는 셰이더 프로그램을 활성화해야 한다.
이번 글에서는 셰이더의 생성과 컴파일, 그리고 shader program 에 링킹하고 지우는 과정을 정리하였다.
다음 글에서는 VBO를 통해 연결한 데이터셋에서 우리가 필요한 만큼 끊어 가져와서 셰이더로 넘기는 방법을 정리하려고 한다.
'CS > HCI 윈도우즈프로그래밍' 카테고리의 다른 글
[OpenGL] 7. Shader (3) - EBO (Element Buffer Object) (0) | 2024.04.08 |
---|---|
[OpenGL] 6. Shader (2) - VAO (1) | 2024.04.07 |
[OpenGL] 4. Graphics Pipeline (1) | 2024.04.04 |
[OpenGL] 3. GLFW 기본 예제 뜯어보기 (0) | 2024.04.04 |
[OpenGL] 2. Open GL 개념 (0) | 2024.04.03 |