5장 OpenGL - ② 셰이더
OpenGL은 25년 넘게 이어져온 2D/3D 크로스 플랫폼 그래픽을 위한 산업 표준 라이브러리이다. 이번 장에서는 OpenGL의 초기화, 삼각형의 사용, 셰이더 프로그램 작성, 변환을 위한 행렬 사용, 텍스처 지원 추가 등 컴퓨터 그래픽스의 여러 주제를 다룬다.
💡 5장의 목차
- OpenGL 초기화
- 삼각형 폴리곤
- 셰이더
- 변환과 행렬
- 텍스처 매핑
셰이더
현대의 그래픽스 파이프라인은 단순히 버텍스/인덱스 버퍼만으로 삼각형을 그리지 않는다. 삼각형을 고정된 색상으로 그릴 것인지, 텍스처에서 얻은 색상을 버텍스에 사용할 것인지, 모든 픽셀에 광원을 계산할 것인지 등 버텍스를 어떻게 그려야 할지를 지정하는 작업이 필요하다.
이를 위해서 OpenGL을 포함한 많은 그래픽 API는 셰이더(Shader) 프로그램을 지원한다. 셰이더는 그래픽 하드웨어상에서 특정 태스크를 수행할 때 실행되는 작은 프로그램이다. 셰이더가 자신만의 메인 기능을 가진 별도의 프로그램이라는 점에 주목하자.
셰이더 프로그램은 GLSL이라는 언어를 사용한다. 그리고 별도의 프로그램이므로 별도의 파일로 셰이더를 작성한다. 그리고 C++ 코드에서는 이 셰이더 프로그램을 로드해서 컴파일한다. 그런 다음 OpenGL에게 이 셰이더 프로그램을 사용하도록 요청한다.
게임에서는 실제 여러 유형의 셰이더를 사용할 수 있지만 여기서는 2가지 가장 중요한 셰이더에 중점을 둔다.
- 버텍스(정점) 셰이더
- 프래그먼트(픽셀) 셰이더
버텍스 셰이더
버텍스 셰이더 프로그램은 그려질 모든 삼각형의 모든 버텍스에 대해 한 번씩 실행된다. 버텍스 셰이더는 입력으로써 버텍스 속성 데이터를 받는다. 그러면 버텍스 셰이더는 이 버텍스 속성을 적절하게 수정한다. 만약 삼각형 아래와 같이 2개 존재한다면, 버텍스는 총 4개 이므로 버텍스 셰이더는 4번 실행되는 것이다. (6번이 아니라 4번 시행되는 것 또한 앞 절에서 언급된 인덱스 버퍼 활용의 장점이다.)
float vertexBuffer[] = {
-0.5f, 0.5f, 0.0f // 버텍스 0
0.5f, 0.5f, 0.0f // 버텍스 1
0.5f, -0.5f, 0.0f // 버텍스 2
-0.5f, -0.5f, 0.0f // 버텍스 3
};
unsigned short indexBuffer[] = {
0, 1, 2, // 첫 번째 삼각형은 버텍스 0, 1, 2을 사용해서 생성
2, 3, 0 // 두 번째 삼각형은 버텍스 2, 3, 0을 사용해서 생성
};
그리고 프레임마다 같은 모델을 여러 번 그린다면 버텍스 셰이더는 모델을 그릴 때마다 매번 독립적으로 호출된다는 것에 유의하자.
프래그먼트 셰이더
3D 그래픽스에서 모든 3차원 오브젝트는 2D 화면에 출력되기 위해서 래스터 변환을 거쳐 픽셀이 되어야 한다. 프래그먼트 셰이더(픽셀 셰이더)의 역할은 각 픽셀의 색상을 결정하는 것이다. 그래서 프래그먼트 셰이더 프로그램은 모든 픽셀마다 한 번씩 실행된다. 프래그먼트 셰이더가 픽셀의 색상을 결정할 때 텍스처나, 재질 같은 표면, 조명 등을 고려해서 결정된다. 일번적으로 버텍스 셰이더보다는 프래그먼트 셰이더가 잠재적으로 계산할 부분이 훨씬 더 많으므로 더 많은 코드를 포함한다.
기본 셰이더 작성하기
셰이더의 확장자는 어떤 값으로 지정해도 상관 없다. 하지만 일반적으로 지정하는 확장자는 있다. 여기에서는 일반적으로 버텍스 셰이더의 확장자는 .vert
으로 프래그먼트 셰이더의 확장자는 .frag
를 사용한다. 여기에서도 일반적은 확장자 작성법을 따른다. 셰이더 기본 작성에 대한 설명은 교재와 코드의 주석을 참고하자.
- 버텍스 셰이더: Basic.vert 소스 보기
- 프래그먼트 셰이더: Basic.frag 소스 보기
셰이더 로딩
별도의 셰이더 파일을 작성하고 난 후에 게임의 C++ 코드에서는 다음 단계를 따라서 이 셰이더를 로드해 OpenGL이 셰이더를 인식할 수 있도록 해야 한다.
- 버텍스 셰이더를 로드하고 컴파일 한다.
- 프래그먼트 셰이더를 로드하고 컴파일한다.
- 2개의 셰이더를 '셰이더 프로그램에' 서로 연결시킨다.
셰이더 로딩 함수
OpenGL에서 셰이더를 로드하고 컴파일하여 사용할 수 있도록 하기까지 몇 단계의 함수들이 있다. 그리고 이 부분은 다소 복잡하고 번거로운 과정이기 때문에 별도의 Shader
클래스로 캡슐화 해두고 사용하는 것이 편하다. 우선 주요함수 몇 가지만 살펴보자. (매개변수 타입과 반환 타입은 생략하고 역할만 요약. 자세한 내용은 이 링크를 참고)
이 함수들을 사용하기 위해서는 <GL/glew.h>
헤더를 include해야 한다.
- 셰이더 생성, 컴파일 함수
glCreateShader(shader type)
: OpenGL 셰이더 오브젝트를 생성하고 생성한 셰이더 ID 반환glShaderSource(shader ID, 1, source code, nullptr)
: 셰이더 오브젝트에 컴파일할 소스코드를 연결한다.glCompileShader(shader ID)
: 셰이더 오브젝트를 컴파일 한다. (버텍스, 프래그먼트 셰이더를 각각 컴파일 해야함.)
- 셰이더 프로그램 함수 (셰이더가 정상적으로 컴파일 된 후 사용)
glCreateProgram()
: 셰이더 프로그램을 생성 후 프로그램 ID 반환glAttachShader(program ID, shader ID)
: 셰이더 프로그램(program ID)에 컴파일 된 셰이더(shader ID)를 연결한다. (버텍스, 프래그먼트 셰이더를 각각 연결해야함.)glLinkProgram(program ID)
: 셰이더 프로그램을 메인 프로그램에 연결한다. (실제 렌더링 파이프 라인에서 동작하도록 연결한다.)
- 셰이더 프로그램 활성화
glUseProgram(program ID)
: 셰이더 프로그램을 활성화 시킨다. 매프레임 마다 새로 활성화 시켜야 한다.
- 셰이더, 프로그램 해제 함수
glDeleteProgram(program ID)
: 셰이더 프로그램을 해제한다.glDeleteShader(shader ID)
: 컴파일 된 셰이더를 해제한다.
- 예외 확인 함수
glGetShaderiv(shader ID, GL_COMPILE_STATUS, &status)
: 셰이더 컴파일 상태를status
에 반환한다. 성공했을 경우status
에GL_TRUE
가 입력된다.glGetShaderInfoLog(shader ID, strlen, nullptr, buffer)
: 컴파일 실패했을 경우 로그를 확인할 수 있는 함수.shader ID
에 해당하는 셰이더 상태를buffer[strlen+1]
에 입력한다.glGetProgramiv(program ID, GL_LINK_STATUS_ &status)
: 셰이더 프로그램이 사용 가능하다면status
에GL_TRUE
가 입력된다.glGetProgramInfoLog(program ID, strlen, nullptr, buffer)
: 프로그램이 사용 가능하지 않을 경우 로그를 확인 할 수 있는 함수.program ID
에 해당하는 셰이더 프로그램 상태를buffer[strlen+1]
에 입력한다.
셰이더를 사용하기 위해서 매번 위 함수들을 순서에 맞게 호출하는 것은 많이 번거로운 일이다. 아래와 같이 Shader
class로 캡슐화 하여서 간단하게 사용하자. 아래는 클래스 선언부만 작성하였고, 함수의 구현부는 아래의 링크에서 확인할 수 있다.
Shader class 의 선언
class Shader
{
public:
Shader() {}
~Shader() {}
// 주어진 이름으로 버텍스/프래그먼트 셰이더 로드
bool Load(const std::string& vertName,
const std::string& fragName);
// 이 셰이더를 활성화된 셰이더 프로그램으로 설정
void SetActive();
void Unload();
private:
// 지정된 셰이더를 컴파일
bool CompileShader(const std::string& fileName,
GLenum shaderType,
GLuint& outShader);
// 셰이더가 성공적으로 컴파일됐는지 확인
bool IsCompiled(GLuint shader);
// 버텍스/프래그먼트 프로그램이 연결됐는지 확인
bool IsValidProgram();
// 셰이더 오브젝트 ID를 저장
GLuint mVertexShader;
GLuint mFragShader;
GLuint mShaderProgram;
};
Shader class의 구현
셰이더로 사각형 그리기
이제 VertexArray
클래스로 버텍스를 생성할 수 있고, Shader
클래스로 셰이더를 생성할 수 있기 때문에 화면에 삼각형 두개로 사각형을 그려낼 수 있다. 지금은 사각형만 그리지만, 앞으로는 이 사각형에 텍스쳐를 입혀서 계속 sprite
로 이용할 것이기 때문에 아래와 같이 mSprite
를 붙인 변수를 Game.h
에 선언한다.
// Sprite 를 그리기 위한 버텍스
class VertexArray* mSpriteVerts;
// Sprite를 그리기 위한 셰이더
class Shader* mSpriteShader;
그리고 Game::Init()
에서 아래의 코드를 추가하여 셰이더를 로드하고, 버텍스를 생성하자.
// 셰이더들을 로드한다.
if (!LoadShader())
{
SDL_Log("Failed to load shaders.");
return false;
}
// 스프라이트를 그리기 위한 사각형 생성
CreateSpriteVerts();
CreateSpriteVerts()
함수는 아래와 같이 사각형 vertexArray를 생성한다.
void Game::CreateSpriteVerts()
{
float vertices[] = {
-0.5f, 0.5f, 0.f, // top left
0.5f, 0.5f, 0.f, // top right
0.5f, -0.5f, 0.f, // bottom right
-0.5f, -0.5f, 0.f // bottom left
};
unsigned int indices[] = {
0, 1, 2,
2, 3, 0
};
// 스프라이트를 그리기 위한 4각형 스프라이트 VertexArray 생성과정
// 앞으로 모든 sprite들은 이 멤버변수를 사용한다.
mSpriteVerts = new VertexArray(vertices, 4, indices, 6);
}
LoadShader()
에서는 앞서 구현한 Shader
class를 활용해서 아래와 같이 셰이더를 load한다.
bool Game::LoadShader()
{
mSpriteShader = new Shader();
if (!mSpriteShader->Load("Shaders/Basic.vert", "Shaders/basic.frag"))
{
return false;
}
mSpriteShader->SetActive();
return true;
}
여기 까지가 게임이 시작될때 진행되는 Init()
단계이다. 스프라이트로 활용할 버텍스가 만들어졌고, 셰이더가 컴파일 되었으니 이제 매 프레임마다 출력을할 차례다. Game::GenerateOutput()
에서 아래와 같이 출력을 만들자.
void Game::GenerateOutput()
{
// 색상을 회색으로 설정
glClearColor(0.86f, 0.86f, 0.86f, 1.0f);
// 색상 버퍼 초기화
glClear(GL_COLOR_BUFFER_BIT);
// Sprite::Draw() 함수에서 glDrawElements()가 실행된다.
// 이 glDrawElements() 함수를 사용하기 위해서는
// "매프레임마다" 활성화된 버텍스 배열 개체와, 셰이더가 반드시 있어야 한다.
// 그래서 Draw()함수를 호출하기 전에 각각을 활성화 한다.
mSpriteShader->SetActive();
mSpriteVerts->SetActive();
for (auto sprite : mSprites)
{
sprite->Draw(mSpriteShader);
}
// 버퍼를 스왑해서 장면을 출력한다.
SDL_GL_SwapWindow(mWindow);
}
Sprite::Draw()
함수에서 화면에 무엇인가를 실제로 그려지는 작업이 일어난다. 이 때 glDrawElements()
함수를 사용한다.
void SpriteComponent::Draw(Shader* shader)
{
// glDrawElements 호출을 위해서는 활성화된 버텍스 배열 개체와 활성화된 셰이더가 필요하다.
// 매 프레임에서 SpriteComponents를 그리기 전에 스프라이트 버텍스 배열개체와 셰이더 모두를 활성화 해야한다.
glDrawElements(
GL_TRIANGLES, // 그려야 할 폴리곤 타입
6, // 인덱스 버퍼에 있는 인덱스의 수
GL_UNSIGNED_INT,// 각 인덱스의 타입
nullptr // nullptr
);
}
마지막으로 프로그램이 종료되기 전에 사용한 자원들을 해제해야한다. Game::ShutDown()
함수를 다음과 같이 정의한다.
void Game::Shutdown()
{
delete mSpriteVerts;
// OpenGL 콘텍스트 제거
mSpriteShader->Unload();
delete mSpriteShader;
SDL_GL_DeleteContext(mContext);
// mWindow 객체 해제.
SDL_DestroyWindow(mWindow);
SDL_DestroyRenderer(mRenderer);
SDL_Quit();
}
여기까지 진행하고 실행하면 파랑색 사각형을 확인할 수 있다. 게임 프로젝트에는 우주선도 있고 운석도 있고 모두가 SpriteComponent
를 가지고 있다. 그리고 이들은 모두 Draw
함수를 실행하고 있다. 하지만 아직 아무런 텍스쳐를 입히지 않았고, 동일한 버텍스와 셰이더를 사용하고 있기 때문에 모든 엑터들이 파랑 사각형으로 출력되는것이 정상이다. 이부분은 앞으로 계속 만들어나간다.
본 게시글은 에이콘 출판사의 Game Programming in C++ 도서를 학습하며 요약 정리한 내용입니다.