이번엔 셰이더를 적용해 봅시다.
뭔가 초장부터 중간보스를 만난 느낌이네요.
들어가기 앞서 기본 개념인 버텍스와 픽셀에 대해서 짚어봅시다.
3D 그래픽을 조금이라도 접해본 사람들은 다 알만한 개념이긴 한데 원문에서 이 개념을 설명하기 위해 한 강의를 투자하였기 때문에 간단히 짚고 갑니다.
버텍스: 3차원 그래픽 공간에서 한 점을 의미하는 가장 기본적인 개념입니다. 카르테시안 좌표계에서 x, y, z 요소로 나타낼 수 있죠.
픽셀: 물리적으로 화면에서 한 점을 나타내는 최소 단위입니다. 하나의 픽셀은 RGBA를 조합한 한가지 색만을 표현할 수 있습니다.
쉐이더
쉐이더도 일종의 프로그램입니다. 일반적인 C/C++ 네이티브 프로그램과 다른점은 쉐이더는 CPU가 아니라 GPU에서 처리되는 프로그램이라는 점이죠(그치만 쿠다를 사용한다면?!). 쉐이더는 여러가지가 있는데 책 "OpenGL Super Bible 6th" 에서 발췌한 렌더링 파이프라인 다이어그램에서 그 중 몇가지를 볼 수 있습니다.
이미지에서 볼 수 있듯이 렌더링 파이프라인에서 가장 처음과 끝에 버텍스 쉐이더와 프레그먼트 쉐이더가 위치합니다. 3D를 렌더링하는데 빠져서는 안 될 엄청 중요한 놈들이죠. 이것들 외에 추가로 사용할 수 있는 쉐이더가 몇 개 더 있는데요 테설레이션 쉐이더와 지오메트리 쉐이더입니다. 렌더링 파이프라인에서 얘네들이 없이도 충분히 3D를 화면을 렌더링 할 수 있지만, 사용한다면 더욱 정교하게 픽셀을 관리하여 멋진 효과를 줄 수 있죠. 이러한 쉐이더들은 프로그래밍으로 직접 컨트롤 할 수 있는데 반해, 버텍스 패치(Vertex fetch), 레스터라이제이션(Rasterization), 프레임버퍼 오퍼레이션(Buffer operations)은 직접 컨트롤 할 수 없습니다.
예제 코드를 작성해보기 전에 쉐이더간의 기본동작에 대해 알아봅시다.
파이프라인에서 한 쉐이더에서 다음 쉐이더로 넘어갈 때 변수를 이용해서 데이터를 전달할 수 있습니다. 이 때 변수들은 다음의 특정 규칙을 따라야 합니다.
1. 변수의 이름과 타입은 앞 쉐이더의 것과 뒷 쉐이더의 것이 일치해야 합니다(뭐 당연한거 같네요).
2. 변수는 파이프라인상에서 바로 다음에 위치한 쉐이더에게만 전달할 수 있습니다. 예를 들어 버텍스 쉐이더, 테설레이션, 프레그먼트 쉐이더를 사용한다고 할 때 버텍스 쉐이더에서 프레그먼트 쉐이더로 데이터를 전달하고 싶다면, 먼저 버텍스 쉐이더에서 테설레이션 쉐이더로 변수를 전달한 다음 다시 테설레이션에서 프레그먼트 쉐이더로 변수를 전달해야 합니다.
또한 CPU에서 보내지는 버텍스 데이터들은 버퍼를 통해 전달되는데, 버텍스 쉐이더에서 밖에 접근할 수 없습니다. 반면 쉐이더간 데이터전달에 사용되는 유니폼 변수들은 어느 쉐이더에서든 접근이 가능합니다(위의 2번 조건과 함께 생각해보면 각 쉐이더가 실행될 때 유니폼 변수들이 같은 메모리 공간을 사용하는거 같아 보이네요. 같은 메모리 주소를 사용하니까 변수 생존 기간이 한 쉐이더에서 다음 쉐이더까지인가 봅니다).
후 드디어 코딩입니다. 쉐이딩 언어로는 GLSL을 사용합니다. OpenGL Shading Language 의 약자에요ㅋ
예제프로그램에서는 쉐이더 소스를 파일로 작성하고, 메인 루틴에서 실행중에 동적으로 이 쉐이더 소스를 읽어와 컴파일하고 렌더링 파이프 라인에 적용합니다. 먼저 이러한 동작을 하는 C++ 모듈을 만들어 보죠.
프로젝트에 Core라는 폴더를 만들고 그 안에 Shader_Loader.h 와 Shader_Loader.cpp 파일을 작성합니다.
// Shader_Loader.h
#pragma once
#include <GL/glew.h>
#include <GL/freeglut.h>
#include <iostream>
namespace Core
{
class Shader_Loader
{
private:
std::string ReadShader(char *filename);
GLuint CreateShader(GLenum shaderType,
std::string source,
char* shaderName);
public:
Shader_Loader(void);
~Shader_Loader(void);
GLuint CreateProgram(char* VertexShaderFilename,
char* FragmentShaderFilename);
};
}
// Shader_Loader.cpp
#include "Shader_Loader.h"
#include <iostream>
#include <fstream>
#include <vector>
using namespace Core;
Shader_Loader::Shader_Loader(void){}
Shader_Loader::~Shader_Loader(void){}
std::string Shader_Loader::ReadShader(char *filename)
{
std::string shaderCode;
std::ifstream file(filename, std::ios::in);
if(!file.good())
{
std::cout<<"Can't read file "<<filename<<std::endl;
std::terminate();
}
file.seekg(0, std::ios::end);
shaderCode.resize((unsigned int)file.tellg());
file.seekg(0, std::ios::beg);
file.read(&shaderCode[0], shaderCode.size());
file.close();
return shaderCode;
}
GLuint Shader_Loader::CreateShader(GLenum shaderType,
std::string source, char* shaderName)
{
int compile_result = 0;
GLuint shader = glCreateShader(shaderType);
const char *shader_code_ptr = source.c_str();
const int shader_code_size = source.size();
glShaderSource(shader, 1, &shader_code_ptr, &shader_code_size);
glCompileShader(shader);
glGetShaderiv(shader, GL_COMPILE_STATUS, &compile_result);
//check for errors
if (compile_result == GL_FALSE)
{
int info_log_length = 0;
glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &info_log_length);
std::vector<char> shader_log(info_log_length);
glGetShaderInfoLog(shader, info_log_length, NULL, &shader_log[0]);
std::cout << "ERROR compiling shader: " << shaderName << std::endl << &shader_log[0] << std::endl;
return 0;
}
return shader;
}
GLuint Shader_Loader::CreateProgram(char* vertexShaderFilename,
char* fragmentShaderFilename)
{
//read the shader files and save the code
std::string vertex_shader_code = ReadShader(vertexShaderFilename);
std::string fragment_shader_code = ReadShader(fragmentShaderFilename);
GLuint vertex_shader = CreateShader(GL_VERTEX_SHADER, vertex_shader_code, "vertex shader");
GLuint fragment_shader = CreateShader(GL_FRAGMENT_SHADER, fragment_shader_code, "fragment shader");
int link_result = 0;
//create the program handle, attatch the shaders and link it
GLuint program = glCreateProgram();
glAttachShader(program, vertex_shader);
glAttachShader(program, fragment_shader);
glLinkProgram(program);
glGetProgramiv(program, GL_LINK_STATUS, &link_result);
//check for link errors
if (link_result == GL_FALSE)
{
int info_log_length = 0;
glGetProgramiv(program, GL_INFO_LOG_LENGTH, &info_log_length);
std::vector<char> program_log(info_log_length);
glGetProgramInfoLog(program, info_log_length, NULL, &program_log[0]);
std::cout << "Shader Loader : LINK ERROR" << std::endl << &program_log[0] << std::endl;
return 0;
}
return program;
}
gl 라이브러리 함수 및 Shader_Loader 클래스의 설명입니다.
GLuint:
쉐이더와 프로그램 핸들들을 갖습니다. GLuint 변수는 기본적으로 이러한 엔티티(쉐이더와 프로그램 핸들들)를 갖는 빈 객체로 존재합니다. OpenGL 에서는 '프로그램' 이라는 용어를 좀 헷갈리게 사용하는데요, OpenGL에서 정의하는 '프로그램'이라는 용어는 쉐이더들(vertex, fragment, tessellation, geometry)을 담을 수 있는 컨테이너를 의미합니다.
glCreateShader(GLenum shader_type):
인자로 전달받는 shader_type 으로 비어있는 쉐이더 객체를 생성하고 핸들을 반환합니다.
glShaderSource(GLuint shader, GLsizei count,
const GLchar **shader_code, const GLint *legth):
shader 객체에 shader_code 를 로드합니다. *GLchar 은 캐릭터형 배열이며 이 배열의 갯수를 count 로 알려줍니다. length 는 각 *GLchar 의 길이 정보를 갖는 배열입니다. 예제에선 소스코드를 한개씩 전달하였으므로 count 를 1로 전달하였죠.
glCompileShader(GLuint shader):
소스코드를 컴파일합니다.
glGetShaderiv(GLuint shader, GLenum pname, GLint *prarams):
에러를 확인하고 콘솔로 검출해냅니다
glCreateProgram():
프로그램 객체를 생성하고 그 핸들을 반환합니다.
glAttachShader(GLuint program, GLuint shader):
프로그램에 쉐이더를 붙입니다.
glLinkProgram(GLuint program):
프로그램 객체를 링킹합니다.
glGetProgramiv(GLuint program, GLenum pname, GLint *params):
glGetShaderiv과 마찬가지로 에러를 확인하고 콘솔로 검출해냅니다.
glUseProgram(GLuint program):
렌더링 루프에서 program 객체를 이용하여 렌더링 하로독 지정합니다.
Shader_Loader:
생성자 함수...
~Shader_Loader:
소멸자 함수...
CreateShader:
쉐이더를 생성하고 컴파일.
ReadShader:
쉐이더 파일의 소스를 읽는 메서드
CreateProgram:
내부적으로 ReadShader, CreateShader 메서드를 호출 하여 버텍스와 프레그먼트 쉐이더를 불러오고 프로그램에 담은 후 링킹하는 메서드
버텍스 쉐이더와 프레그먼트 쉐이더
쉐이더에서 가장 중요한 것은 각 쉐이더들은 버텍스를 다루는데 자신만의 고유의 역할을 갖는다는 것입니다. 버텍스 쉐이더는 x,y,z 3차원 좌표로 이루어진 버텍스들이 2차원 화면에서는 어떤 위치에 그려질지 정사영하는 일을, 프래그먼트 쉐이더는 각 픽셀들이 어떤 색상을 갖는지 정하는 일을 하는 식이죠.
버텍스 쉐이더에서 여러분이 꼭 기억해야할 변수는 gl_Position 입니다. 이 변수는 미리정의 되어 있으며 현재 버텍스의 작업이 다 끝난 후의 스크린상의 점의 위치를 가리킵니다.
프레그먼트 쉐이더의 가장 큰 역할은 각각의 프레그먼트들의 색상을 결정하는 것입니다. 이 쉐이더에서 반환값은 색상버퍼뿐이며, 이 색상버퍼에 표현되지 않은 오브젝트들은 전부 검정색으로 표현됩니다.
다음으로 버텍스 쉐이더의 코드를 작성해 보죠. Core 폴더와 같이 Shaders 폴더도 하나 만들고 Vertex_Sahder.glsl 과 Fragment_Shader.glsl 파일을 작성합니다.
// Vertex_Shader.glsl
#version 330 core
void main(void)
{
const vec4 vertices[3] = vec4[3](vec4( 0.25, -0.25, 0.5, 1.0),
vec4(-0.25, -0.25, 0.5, 1.0),
vec4( 0.25, 0.25, 0.5, 1.0));
gl_Position = vertices[gl_VertexID];
}
// Fragment_Shader.glsl
#version 330 core
out vec4 color;
void main(void)
{
color = vec4(0.0, 1.0, 0.0, 1.0);
}
모든 쉐이더는 먼저 OpenGL 의 버전을 명시해주어야 합니다. 전 3.3버전이므로 330 이라고 적었습니다. 원글에서는 430 이라고 적었네요. 버전 다음에 나오는 core라는 단어는 해당 버전의 glsl 핵심 함수들을 사용할 것임을 나타냅니다.
다음으로 main 함수로 넘어가봅시다. 3D 그래픽에서 삼각형을 그리기 위해서는 꼭짓점을 나타내는 세 개의 버텍스가 필요합니다. OpenGL의 버텍스 쉐이더에서는 이 버텍스들을 스크린상의 어디에 나타낼 것인지 계산을 한 후 gl_Position 변수에 담아 다음 쉐이더로 보내죠.
위 이미지처럼 스크린은 x, y 값의 범위가 모두 -1~1을 갖는 좌표계로 표현 됩니다. (0, 0)은 화면의 정중앙을 나타내죠. 그래서 200x400 해상도의 스크린을 예로 들면 소스코드의 삼각형은 스크린상에서 꼭지점 (125, 150), (75, 150), (125, 150) 으로 표현됩니다. 소스코드에서 사용된 이 좌표표현 방법을 NDC(Nomalized Device Coordinates) 라고 합니다.
실제로 NDC는 x, y, z, w 네가지 요소로 표현이 됩니다. 네 번째 원소인 w는 W를 표현하는데 위 소스의 경우 W가 1.0으로 NDC가 버텍스의 좌표를 다루고 있다는 것을 나타냅니다(W가 뭘 의미하는지는 잘 모르겠네요). NDC 변환이 끝난 후 Window Transformation 또는 Screen Transformation 변환을 합니다. 이 두 변환작업은 장면(scene)을 OpenGL의 viewport(윈도우창 안쪽의 OpenGL 로 그려질 부분)에 맞추기 위해 하는데, 그래픽카드에서 진행되므로 신경쓸 필요 없습니다. 여기까지 모든 변환을 마친 최종 좌표값들은 모든 모양들이 픽셀/프레그먼트로 전환되기 위해 raterization 과정을 거칩니다. 그러면 위 삼각형은 다음 이미지와 같은 형태를 갖게되겠죠.
glVertexID는 현재 처리되고 있는 버텍스의 ID를 나타냅니다.
프래그먼트 쉐이더에서는 color 변수에 결과값을 내어야합니다.
마지막으로 main 파일을 조금 손 보도록 하죠.
// main.cpp
#include <iostream>
#include <stdio.h>
#include <stdlib.h>
#include <fstream>
#include <vector>
#include "Core/Shader_Loader.h"
using namespace Core;
GLuint program;
void renderScene(void)
{
glClearColor(1.0, 0.0, 0.0, 1.0);//clear red
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
//use the created program
glUseProgram(program);
//draw 3 vertices as triangles
glDrawArrays(GL_TRIANGLES, 0, 3);
glutSwapBuffers();
}
void Init()
{
glEnable(GL_DEPTH_TEST);
//load and compile shaders
Core::Shader_Loader shaderLoader;
program = shaderLoader.CreateProgram("Shaders\\Vertex_Shader.glsl",
"Shaders\\Fragment_Shader.glsl");
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
}
int main(int argc, char **argv)
{
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_DEPTH | GLUT_DOUBLE | GLUT_RGBA);
glutInitWindowPosition(100, 100);
glutInitWindowSize(800, 600);
glutCreateWindow("Drawing my first triangle");
glewInit();
Init();
// register callbacks
glutDisplayFunc(renderScene);
glutMainLoop();
glDeleteProgram(program);
return 0;
}
수정한 main 파일에서는 CreateProgram 메서드를 호출하여 쉐이더 소스를 불러와 컴파일 하였습니다. 그리고 나서 glUseProgram 를 호출하여 렌더링 루프에서 사용할 프로그램을 지정하였고 glDrawArrays 를 호출하여 얼마나 많을 버텍스들을 사용하고 어떻게 그려낼 것인지 지정하였습니다. 마지막으로 새로 등장한 glDrawArrays 에 대해 알아보죠.
glDrawArrays(GLenum mode, GLint first, GLsizei count):
mode 는 드로잉 모드 또는 points, lines, triangles 등의 모형을 가리킵니다. first 는 버텍스 배열내에서, 그리기 시작할 버텍스의 인덱스를 나타내며 count 는 그릴 버텍스의 갯수를 나타냅니다.
후 여기까지 오다니... 힘드네요 힘들어 ㅋㅋ
다음은 이번 강의의 원문 링크와 현재까지의 프로젝트 폴더 구조입니다.
* 원문 링크
http://in2gpu.com/2014/10/20/building-blocks-vertex-pixel/
http://in2gpu.com/2014/10/29/shaders-basics/
http://in2gpu.com/2014/11/24/creating-a-triangle-in-opengl-shader/
* 프로젝트 폴더 구조
그럼 다음에 봅시다~~
아, 위 코드를 실행 했을 때 에러가 없는데도 제대로 동작하지 않는 경우가 있을 수 있습니다. 튜토리얼 작성자 말로는 쉐이더 코드가 일부 하드웨어와 맞지 않아 생기는 현상 같다고 하네요. 이번 강의는 쉐이더의 기본개념을 공부하기 위한 강의이고 실제 필드에선 저렇게 버텍스 쉐이더 안에서 버텍스를 정의하지 않으니 제대로 동작하지 않더라도 짜증내지말고 그냥 다음 강의로 넘어갑시다.ㅎㅎㅎ