Day 12 SDL과 OpenGL 연결
SDL 랜더러는 2D 그래픽을 지원하지만 3D 그래픽은 지원하지 않는다. 그래서 3D 그래픽으로 전환하려면 2D와 3D를 모두 지원하는 라이브러리로 전환해야한다. 그 라이브러리가 바로 OpenGL이다.
OpenGL 윈도우 설정
OpenGL을 사용하면 SDL_Renderer를 삭제해야 한다. 따라서 Game파일에 있는 mRenderer나 SDL_CreateRenderer, 그리고 GenerateOutPut에서 사용된 SDL 함수 호출을 포함해 SDL_Renderer에 대한 모든 참조를 제거해야한다. SDL로 윈도우를 생성하는 경우에는 SDL_WINDOW_OPENGL을 인자로 넘겨서 OpenGL을 사용하는 윈도우를 요청하는 것이 가능하다.
OpenGL 윈도우를 생성하기 앞서 OpenGL의 버전이나 색상 깊이, 그리고 몇몇 파라미터에 대한 속성 설정이 가능하다. 파라미터를 설정하기 위해 SDL_GL_SetAttribute 함수를 호출한다.
SDL_GL_SetAttribute(
SDL_GLattr attr, // 설정할 속성
int value // 해당 속성의 값
);
속성을 설정하고자 Game::Initialize 함수 내부의 SDL_CreateWindow 호출에 앞서 속성을 설정한다.
처음 코드는 OpenGL 코어 프로파일을 요청한다. 그 다음 두 속성은 OpenGL 버전 3.3을 요청한다. 그 다음 속성은 각 채널의 비트 깊이를 지정한다. 이 프로그램은 RGBA 마다 8비트를 요청하도록 설정했다. 그래서 픽셀당 32비트의 크기를 가진다. 그 다음 라인은 더블 버퍼링 활성화를 요청한다. 마지막 속성 설정 함수는 하드웨어 가속을 사용한 OpenGL 실행을 요청한다. 하드웨어 가속을 사용한다는 것은 OpenGl 렌더링이 그래픽 하드웨어(GPU)에서 수행될 것임을 뜻한다.
OpenGl 콘텍스트와 GLEW 초기화
OpenGL 속성이 설정되고 윈도우를 생성했다면 다음 단계는 OpenGL 콘텍스트를 생성하는 것이다. 콘텍스트(context)는 OpenGl이 인식하는 모든 상태나 오브젝트를 포함하는 OpenGL의 세계다. OpenGL의 콘텍스트는 색상 깊이, 로드된 이미지나 모델, 그리고 여러 다양한 OpenGL 오브젝트를 포함한다. 하나의 OpenGL 프로그램에 여러 개의 콘텍스트를 생성하는 것도 가능하다. 콘텍스를 생성하기 위해 먼저 Game에 다음 멤버 변수를 추가한다.
SDL_GLContext mContext;
다음으로 SDL_CreateWindow로 SDL 윈도우를 생성한 직후에 다음 코드 라인을 추가한다. 이 코드는 OpenGL 콘텍스트를 생성하며 멤버 변수에 콘텍스트를 저장한다.
윈도우를 생성하고 제거하는 것처럼 OpenGL 콘텍스트를 소멸자에서 제거해야한다. 이를 위해 다음 코드 라인을 Game::Shutdown 내부의 SLD_DeleteWindow 호출 바로 앞에 추가한다.
SDL_GL_DeleteContext(mContext);
프로그램은 이제 콘텍스트를 생성하지만 OpenGL 3.3 기능에 대한 완전한 접근을 얻기 위해 하나의 관문이 남았다. OpenGL은 확장 시스템과 하위 호환성을 지원한다. 이 확장 기능을 사용하려면 일반적으로는 원하는 확장 기능을 수동으로 요청해야한다. 그러나 GLEW(OpenGL Extension Wrangler Library)라고 불리는 오픈 소스 라이브러리를 사용한다. 간단한 하나의 함수 호출로 GLEW는 자동적으로 현재 OpenGL 콘텍스트 버전에서 지원하는 모든 확장 함수를 초기화한다. 그래서 GLEW는 OpenGL 3.3이나 그 이전 버전이 지원했던 확장 함수를 모두 초기화한다. GLEW를 초기화하고자 OpenGL 콘텍스트를 생성한 후 바로 다음 코드를 추가한다.
glewExperimental 라인은 일부 플랫폼에서 코어 콘텍스트를 사용할 때 발생할 수도 있는 초기화 에러를 막는다. 또한 일부 플랫폼에서는 GLEW를 초기화할 때 에러 코드를 내보내므로 glGetError 함수를 호출해서 에러 코드를 제거해야한다.
프레임 렌더링
OpenGL 함수를 사용하려면 Game::GenerateOutput에서 화면을 클리어하고 장면을 그린 뒤 버퍼를 스왑하는 과정이 필요하다.
// 색상을 회색으로 설정
glClearColor(0.86f, 0.86f, 0.86f, 1.0f);
// 색상 버퍼 초기화
glClear(GL_COLOR_BUFFER_BIT);
// 장면을 그린다
// 버퍼를 스왑해서 장면을 출력한다
SDL_GL_SwapWindow(mWindow);
이 코드는 먼저 빨간색, 녹색, 파란색을 모두 86%로 설정하고 알파값을 100%로 설정해서 클리어 색상을 설정한다, 이 조합의 최종 색상은 회색이다. GL_COLOR_BUFFER_BIT 파라미터로 glClear를 호출하면 색상 버퍼를 지정된 색상으로 채운다. 마지막으로 SDL_GLSwapWindow 함수는 전면 버퍼와 후면 버퍼를 교체한다. 이 시점에서는 아직 SpriteComponent를 그리지 않았으므로 회색 화면만이 나타난다.
3D 게임은 시뮬레이션된 3D 환경을 어떻게든 평평한 2D 이미지로 만들어서 화면상에 표현한다. 초기 2D 게임은 스프라이트 이미지를 색상버퍼에 원하는 위치에 간단히 복사할 수 있었다. 블리팅(blitting)이라고 불리는 이 과정은 스프라이트 기반 콘솔 게임기에서는 효율적이었다. 하지만 현대의 그래픽 하드웨어에서는 블리팅이 비효율적인 반면 폴리곤 렌더링은 매우 효율적이다. 이 때문에 2D든 3D든 최근의 모든 게임에서는 궁극적으로 폴리곤을 사용한다.
왜 폴리곤인가?
컴퓨터가 3D 환경을 시뮬레이션하는 데는 여러 가지 방법이 있지만, 폴리곤(다각형)이 여러 가지 이유로 게임에서 널리 사용된다. 다른 3D 그래픽 테크닉과 비교해보면 폴리곤은 런타임 시 많은 계산이 필요하지 않다. 또한 폴리곤은 크기를 가변적으로 조절할 수 있다. 하드웨어 성능이 떨어지는 곳에서 실행되는 게임은 폴리곤의 수를 떨어뜨린 3D 모델을 사용한다. 그리고 대부분의 3D 오브젝트를 폴리곤으로 표현할 수 있다는 것은 매우 중요하다고 볼 수 있다. 삼각형은 대부분의 게임에서 선택하는 폴리곤이다. 삼각형은 가장 간단한 폴리곤이며 삼각형을 형성하려면 오직 3개의 버텍스(vertex)만 필요하다. 또한 삼각형은 한 평면에만 놓일 수 있다. 즉 삼각형의 세 점은 동일 평면상에 있어야한다. 마지막으로 삼각형은 쉽게 테셀레이션(tessellation)할 수 있는데 이는 복잡한 3D 물체를 여러 개의 삼각형으로 쉽게 나눌 수 있다는 것을 뜻한다. 2D 게임은 삼각형을 사용해서 사각형을 그리며, 사각형을 이미지 파일의 색상으로 채워서 스프라이트를 표현한다.
정규화된 장치 좌표
삼각형을 그리기 위해서는 세 버텍스의 좌표를 지정해야한다. SDL에서 화면 왼쪽 상단 좌표는 (0, 0)이었으며 양수 는 오른쪽으로 향하고 양수 는 아랫쪽으로 향했다. 일반적으로 좌표 공간(coordinate space)은 원점의 위치가 어디이며, 좌표가 어느 방향으로 증가하는지를 지정한다. 좌표 공간의 기본 벡터(기저 벡터, basis vector)는 좌표가 증가하는 방향을 나타낸다. 기본 기하학에서 좌표 공간 한 가지로 직표 좌표계(cartesian coordinate system)가 있다. 2D 직교 좌표계에서 원점(0, 0)은 특정 지점을 가리키며(일반적으로 중심) 양수 는 오른쪽으로 향하며 양수 는 위로 향한다. 정규화된 장치 좌표(NDC, Normalized Device Coordinates)는 OpenGL에서 사용하는 기본 좌표계다. 왼쪽 하단은 (-1, -1)이며 오른쪽 상단은 (1, 1)이 된다. 이 좌표 체계는 윈도우의 너비, 높이와 상관없다. 그래서 정규화된 장치 좌표라 부른다. 내부적으로 그래픽 하드웨어는 이 NDC를 해당 윈도우와 일치하는 픽셀로 변환한다. 예를 들어 윈도우의 중심에 변의 길이가 단위 길이인 사각형을 그리려면 2개의 삼각형이 필요하다. 첫 번째 삼각형은 버텍스 (-0.5, 0.5), (0.5, 0.5), (0.5, -0.5)이다. 그리고 두 번째 삼각형은 버텍스 (0.5, 0.5), (-0.5, -0.5), (-0.5, 0.5)를 가진다.
3D에서 정규화된 장치 좌표의 z 요소 또한 [-1. 1]의 범위를 가지며, 양의 값 z값은 화면 안으로 들어가는 방향이다.
버텍스 버퍼와 인덱스 버퍼
여러 개의 삼각형으로 구성된 3D 모델이 있다고 하면 이 삼각형의 버텍스를 메모리상에 저장하려면 어떤 방법이 필요하다. 가장 간단한 방법은 인접한 배열이나 버퍼 형태로 각 삼각형의 좌표값을 직접 저장하는 것이다. 아래 코드가 그 예이다.
float vertices[] = {
-0.5f, 0.5f, 0.0f,
0.5f, 0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
-0.5f, 0.5f, 0.0f,
};
이 간단한 예에서도 버텍스의 배열은 일부 중복 데이터를 갖고 있다. 특히 좌표 (-0.5, 0.5, 0.0)과 (0.5, -0.5, 0.0)은 두 번 나타난다. 이 중복을 제거할 수 있는 방법이 있다면 버퍼에 저장된 값으 수를 33% 줄일 수 있다. 즉 18개의 값을 가질 필요없이 12개만 가지면 되는 것이다. 단정밀도(single-precision) float 변수가 4바이트의 크기라면 중복을 제거해서 24바이트의 메모리를 절약할 수 있다. 이 값이 중요하게 보이지 않을 수도 있다. 하지만 2만 개의 삼각형을 가진 아주 큰 모델을 그린다고 했을 때 중복 좌표로 인한 메모리의 양은 매우 커진다. 이 문제를 해결하기 위한 솔루션은 2단계로 나누어서 접근한다. 먼저 3D 기하에서 사용한 좌표만을 담은 버텍스 버퍼(vertex buffer)를 생성한다. 그런 다음 각 삼각형의 버텍스를 지정하기 위해 이 버텍스 버퍼에 인덱스를 붙인다(배열에 인덱스를 붙이는 것 처럼). 인덱스 버터(index buffer)는 인덱스 3개로 구성된 개별 삼각형 정보를 배열 형태로 저장한다. 이 예의 샘플 삼각형에서는 다음과 같은 버텍스 및 인덱스 버퍼가 필요하다.
예를 들어 첫 번째 삼각형은 좌표 (-0.5, 0.5, 0.0), (0.5, 0.5, 0.0), (0.5, -0.5, 0.0)에 해당하는 버텍스 0, 1 및 2를 가진다. 인덱스는 부동소수점 요소가 아닌 버텍스 번호(예를 들어 인덱스 버퍼의 두 번째 요소값 1은 버텍스 1을 나타낸다)이다. 또한 코드에서는 인덱스 버퍼에 unsigned short(일반적으로 16비트)를 사용해서 인덱스 버퍼의 메모리 사용 공간을 줄인다. 인덱스 버퍼의 메모리를 절약하고자 더 작은 비트 크기의 정수를 사용하는 것도 가능하다. 위 코드에서 버텍스/인덱스 버퍼 조합은 12 4 + 6 2, 전체 60바이트를 사용한다. 원래의 버텍스를 사용한다면 72(12 * 6)바이트가 필요하다. 복잡한 모델일 수록 버텍스/인덱스 버퍼 조합을 사용하면 훨씬 더 많은 메모리를 절약할 수 있다. 버텍스, 인덱스를 사용하려면 먼저 OpenGL에게 이 사실을 전달해야한다. OpenGL은 버텍스 배열 개체(vertex array object)를 사용해서 버텍스 버퍼와 인덱스 버퍼, 그리고 버텍스 레이아웃(vertex layout)을 캡슐화한다. 버텍스 레이아웃은 모델의 각 버텍스에 저장할 데이터를 지정한다. 지금은 버텍스 레이아웃이 3D 위치라고 가정한다(2D로 활용하고 싶다면 z 요소값을 0.0f로 사용하면 된다). 모든 모델은 버텍스 배열 개체를 필요로 하므로 VertexArray 클래스에 그 과정을 캡슐화하면 좋다.
VertexArray의 생성자는 버텍스와 인덱스 버퍼 포인터를 인자로 받아서 그 데이터를 OpenGL로 전달한다(해당 데이터는 그래픽 하드웨어상에 로드 된다). 버텍스 버퍼와 인덱스 버퍼 그리고 버텍스 배열 개체에 대한 몇 개의 unsigned integer 변수가 멤버 데이터로 있다. 멤버 데이터로 선언한 이유는 OpenGL은 생성한 개체의 포인터를 반환하지 않기 때문이다. 대신 OpenGL은 정수형 ID 번호를 돌려준다. ID 번호는 여러 유형의 개체에 대해서 고유하지 않다. 그런데 OpenGL은 여러 유형의 개체를 동시에 사용하므로 버텍스와 인덱스 버퍼 모두 ID가 1인 것도 가능하다. VertexArray 생성자의 구현은 복잡하다. 먼저 버텍스 배열 개체를 생성한 뒤 mVertexArray 멤버 변수에 ID를 저장한다. 그 다음 버텍스 버퍼를 생성한다. glBindBuffers의 첫 번째 인자인GL_ARRAY_BUFFER 파라미터는 버텍스 버퍼를 버퍼로 사용하겠다는 것을 뜻한다. 버텍스 버퍼를 생성했으면 이제 VertexArray 생성자로 전달된 버텍스 데이터를 이 버텍스 버퍼로 복사한다. 데이터를 복사하기 위해 몇 개의 파라미터를 필요로 하는 glBufferData를 사용한다.
glBufferData의 인자에 대해 알아보자.
glBufferData(
GL_ARRAY_BUFFER, // 데이터를 쓸 버퍼의 버퍼 타입
numVerts * 3 * sizeof(float), // 복사할 바이트 크기
verts, // 복사할 소스(포인터)
GL_STATIC_DRAW // 이 데이터를 어떻게 사용할 것인가
);
glBufferData에 개체 ID를 전달하지 않고 대신 현재 바인딩될 버퍼에 타입을 지정한다. 이 경우 GL_ARRAY_BUFFER는 막 생성한 버텍스 버퍼를 사용하겠다는 것을 뜻한다. 두 번째 파라미터 값으로는 각 버텍스의 데이터 크기에 버텍스 개수를 곱한 값인 바이트 크기를 전달한다. 지금은 각 버텍스가 (x, y, z) 3개의 float을 포함하고 있다는 걸 알 수 있다. 네 번째 매개변수는 버퍼 데이터를 어떻게 사용할지를 지정한다. GL_STATIC_DRAW는 데이터를 오직 한 번만 로드하며 버텍스가 자주 그려지는 경우에 사용되는 옵션이다. 다음으로는 인덱스 버퍼를 생성한다. 인덱스 버퍼의 생성은 버퍼 타입을 인덱스 버퍼에 해당하는 GL_ELEMENT_ARRAY_BUFFER로 지정한 것을 제외하고는 버텍스 버퍼를 생성하는 과정과 유사하다.
여기에서 타입은 GL_ELEMENT_ARRAY_BUFFER이며, 크기는 인덱스의 수에 unsigned int의 크기를 곱한 것이다. unsigned int를 곱한 이유는 unsigned int가 인덱스에 사용된 타입이기 때문이다. 마지막으로 버텍스 속성이라고도 불리는 버텍스 레이아웃을 지정해야한다. 현재의 레이아웃은 3개의 float 값을 가진 위치이다. 첫 번째 버텍스 속성(속성 0)을 활성화하기 위해 glEnableVertexattribArray을 사용한다. 그리고 나서 크기와 타입, 속성의 포맷을 지정하기 위햐 glVertexAttribPointer를 사용한다.
glVertexAttribePointer의 인자에 대해 알아보자.
glVertexAttribPointer(
0, // 속성 인덱스 (첫 번째 버텍스 속성 인덱스는 0)
3, // 요소의 수
GL_FLOAT, // 요소의 타입
GL_FALSE, // (정수형 타입에서만 사용)
sizeof(float) * 3, // 간격 (일반적으로 각 버텍스의 크기)
0 // 버텍스의 시작에서 이 속성까지의 오프셋
);
위치는 버텍스 속성 0이며 3개의 요소 (x, y, z)가 있으므로 처음 두 파라미터는 0과 3이다. 각 요소는 float 값이므로 요소의 타입은 GL_FLOAT으로 지정한다. 네 번째 파라미터는 정수형 타입에서만 관련있으므로 float을 사용 할때는 값을 GL_FALSE로 설정한다. 마지막으로 간격(stride)은 연속한 버텍스 사이의 바이트 오프셋이다. 버텍스 버퍼에서 패딩(padding)이 없다면 간격은 버텍스의 크기가 된다. 마지막 파라미터인 오프셋 값은 0이다. 왜냐하면 위치 속성은 버텍스의 시작 위치와 동일하기 때문이다. 추가 속성에 대해서는 오프셋에 0이 아닌 값을 전달해야한다. VertexArray 소멸자에서는 버텍스 버퍼와 인덱스 버퍼, 그리고 버텍스 배열 객체를 해체한다.
마지막으로 SetActive 함수는 현재 사용할 버텍스 배열을 지정하는 glBindVertexArray 함수를 호출한다.
Game::InitSpriteVerts상의 다음 코드는 VertexArray 클래스의 인스턴스를 할당하고 mSpriteVerts라는 Game의 멤버 변수에 해당 인스턴스를 저장한다.
여기에서 버텍스와 인덱스 버퍼 변수는 스프라이트 사각형의 배열이다. 이 경우에는 버텍스 버퍼에 4개의 버텍스가 있으며, 인덱스 버퍼에는 6개의 인덱스가 있다(사각형에는 2개의 삼각형이 존재). 모든 스프라이트가 궁극적으로 같은 버텍스를 사용할 것이므로 스프라이트를 그리기 위해 이 멤버 변수를 사용할 것이다.
현대의 그래픽스 파이프라인은 단순히 버텍스/인덱스 버퍼만 제공받아서 삼각형을 그리지 않는다. 버퍼뿐만 아니라 버텍스를 어떻게 그려야할지를 지정하는 작업이 필요하다. 예를 들면 삼각형은 고정된 색상으로 그릴 것인지 또는 텍스처에서 얻은 색상을 버텍스에 사용할 것인지, 그려야하는 모든 픽셀에 광원 계산을 할 것인지 등이 있다. 장면을 그리는데 사용하는 방법에는 여러 가지 테크닉이 존재하므로 보편적으로 널리 적용되는 단 한 가지 방법이란 존재하지 않으며, 여러 가지 방법을 사용해야 원하는 장면을 최종적으로 그릴 수 있다. 커스터 마이징을 최대한 지원하고자 OpenGL을 포함한 대부분의 그래픽 API에서는 셰이더 프로그램(shader program)을 지원한다. 셰이더 프로그램은 그래픽 하드웨어상에서 특정 태스크를 수행할 때 실행되는 작은 프로그램이다. 주목할 부분은 셰이더는 자신만의 메인 기능을 가진 별도의 프로그램이라는 것이다. 셰이더는 별도의 프로그램이므로 별도의 파일로 셰이더를 작성한다. 그리고 C++ 코드에서는 이 셰이더 프로그램을 로드해서 컴파일한다. 그런 다음 OpenGL에게 이 셰이더 프로그램을 사용하도록 요청한다. 게임에서는 여러 유형의 셰이더를 사용할 수 있지만, 여기서는 2가지 가장 중요한 셰이더에 중점을 둔다.
버텍스 셰이더
버텍스 셰이더(vertex shader) 프로그램은 그려질 모든 삼각형의 몯느 버텍스에 대해 한 번씩 실행된다. 버텍스 셰이더는 입력으로써 버텍스 속성 데이터를 받는다. 그러면 버텍스 셰이더는 이 버텍스 셰이더 속성을 적절하게 수정한다. 삼각형이 3개의 버텍스를 가지고 있다는 것을 감안하면 버텍스 셰이덜르 삼각형마다 세 번씩 실행하는 것으로 생각할 수 있다. 하지만 버텍스 및 인덱스 버퍼를 사용하면 일부 삼각형들은 버텍스를 공유하므로 버텍스 셰이더를 덜 호출할 것이다. 이것이 버텍스 버퍼만을 사용하는 대신 버텍스와 인덱스 버퍼를 사용할 시의 추가적인 이점이다. 프레임마다 같은 모델을 여러 번 그린다면 버텍스 셰이더는 모델을 그릴 때마다 매번 독립적으로 호출된다.
프래그먼트 셰이더
삼각형의 버텍스가 버텍스 셰이더를 거친 후에 OpenGL은 삼각형에 해당하는 픽셀이 어떤 색상을 가지는지를 결정해야한다. 삼각형을 픽셀로 변환하는 과정을 래스터 변환(rasterization)이라 한다. 다양한 래스터 변환 알고리즘이 존재하지만, 최근에는 그래픽 하드웨어가 래스터 변환을 수행한다. 프래그먼트 셰이더(fragment shader, 픽셀 셰이더)의 역할의 각 픽셀의 색상을 결정하는 것이다. 그래서 프래그먼트 셰이더 프로그램은 모든 픽셀마다 한 번씩 실행된다. 이 색상은 텍스처나 색상, 재질 같은 표면 속석을 고려해서 결정된다. 장면에 조명이 존재한다면 픽셀 셰이더는 광원 계산도 고려해야한다. 일반적인 3D 게임에서는 버텍스 셰이더보다는 프래그먼트 셰이더가 잠재적으로 계산할 부분이 훨씬 많으므로 더 많은 코드를 포함한다.
기본 셰이더 작성하기
C++ 코드에서 하드 코딩된 문자열로 셰이더 프로그램을 로드할 수도 있겠지만, 셰이더 프로그램은 별도의 파일로 저장하는 것이 좋다. 이 책은 버텍스 셰이더 파일에 대해 .vert 확장자를 사용하고, 프래그먼트 셰이더 파일에 대해서는 .frag 확장자를 사용한다. 확장자를 사용한다.
Basic.vert 파일
Basic.vert는 버텍스 셰이더 코드를 포함한다. 이 코드는 C++ 코드가 아니다. 모든 GLSL 셰이더 파일은 먼저 사용하려는 GLSL 프로그래밍 언어의 버전을 지정해야한다. 다음 라인은 OpenGL 3.3에 해당하는 GLSL의 버전을 나타낸다.
#version 330
이 파일은 버텍스 셰이더이므로 긱 버텍스에 대한 버텍스 속성을 지정해야한다. 이 속성은 앞서 생성했던 버텍스 배열 개체의 속성과 일치해야하며, 버텍스 배열 개체는 버텍스 셰이더의 입력이 된다. 하지만 GLSL의 메인 함수는 어떤 파라미터도 받지 않는다. 대신 셰이더 입력은 전역 변수 비슷한 형태로 받는다. 전역 변수는 특별한 키워드로 표시된다. 지금은 하나의 입력 변수, 3D 위치 변수만을 가지고 있다. 다음 라인은 이 입력 변수를 선언한다.
in vec3 inPosition;
inPosition 변수의 타입은 vec3이며 3개의 부동 소수점 값의 벡터에 해당된다. 이 변수에는 버텍스의 위치에 해당하는 x, y, z 요소를 포함한다. vec3의 각 요소는 . 문법으로 접근할 수 있다. 예를 들어 inPosition.x는 벡터의 x 요소에 접근한다. C/C++ 프로그램처럼 셰이더 프로그램은 셰이더 프로그램의 엔트리 포인트인 main 함수가 있다.
void main()
{
// 여기에 셰이더 코드를 작성
}
main 함수가 void를 리턴하므로 셰이더의 결과물을 저장하려면 GLSL이 제공하는 전역 변수를 사용해야한다. 이번 예에서는 셰이더의 버텍스 위치 출력값을 저장하기 위해 기본으로 제공하는 gl_Position 변수를 사용한다. 지금은 버텍스 셰이더가 직접 inPosition에서 gl_Position으로 버텍스 위치를 복사한다. 하지만 gl_Position은 위치값인 (x, y, z) 좌표에다가 네 번째 요소인 w 요소(w component)를 필요로 한다. vec3으로부터 vec4로 inPosition을 변환하기 위해 다음 구문을 사용한다.
gl_Position = vec4(inPosition, 1.0);
Basic.vert
위 코드는 버텍스 위치 수정 없이 단순히 복사만 하는 Basic.vert 코드이다.
Basic.frag 파일
프래그먼트 셰이더의 역할은 현재 픽셀의 출력 색상을 결정하는 것이다. Basic.frag는 모든 픽셀을 파란색으로 출력하도록 하드 코딩돼 있다. 버텍스 셰이더처럼 프래그먼트 셰이더는 항상 #version 라인으로 시작한다. 그리고 출력 색상을 저장히기 위해 out 변수 지정자를 사용해서 전역 변수를 선언한다.
out vec4 outColor;
outColor 변수는 RGBA 색상 버퍼의 4개 요소에 해당하는 vec4 타입의 변수다. 다음으로 프래그먼트 셰이더 프로그램의 엔트리 포인트를 선언한다. 이 함수에서 최종적으로 구한 색상을 outColor에 저장한다. 파란색의 RGBA값은 (0.0, 0.0, 1.0, 1.0)이며, 다음과 같이 할당한다.
outColor = vec4(0.0, 0.0, 1.0, 1.0);
Basic.frag
셰이더 로딩
별도의 셰이더 파일을 작성하고 난 후에는 게임의 C++코드에서 이 셰이더를 로드해서 OpenGL이 셰이더를 인식할 수 있도록 해야한다. 고수준 레벨에서는 다음과 같은 단계를 수행해야한다.
- 버텍스 셰이더를 로드하고 컴파일한다.
- 프래그먼트 셰이더를 로드하고 컴파일한다.
- 2개의 셰이더를 '셰이더 프로그램'에 서로 연결시킨다.
셰이더를 로드하는 데는 여러 단계가 있으므로 별도의 Shader 클래스를 선언한다.
shader.h
여기서는 멤버 변수가 셰이더 오브젝트 ID와 어떻게 연결되는지를 볼 수 있다. 멤버 변수는 버텍스나 인덱스 버퍼처럼 오브젝트 ID를 가진다. CompileShader, IsCompiled, IsValidProgram 함수는 Load 함수에서 사용하는 헬퍼 함수이다.
CompileShader 함수
CompileShader는 3개의 파라미터를 전달받는다.
반환값은 CompileShader 호출이 성공했는지 아닌지를 나타내는 bool 값이다.
CompileShader에서는 먼저 파일을 로드하기 위해 ifstream을 생성한다. 그런 다음 문자열 스트림을 이용해서 파일의 전체 내용을 단일 문자열로 로드하고, c_str 함수를 사용해서 C 스타일의 문자열 포인터를 얻는다. 다음으로 glCreateShader 함수 호출은 셰이더에 해당하는 OpenGL 셰이더 오브젝트를 생성한다. 그리고 이 ID를 outShader에 저장한다. shaderType 파라미터는 GL_VERTEX_SHADER, GL_FRAGMENT_SHADER 등 여러 셰이더 타입이 있다. glShaderSource 호출은 셰이더 소스 코드를 포함하는 스트링을 지정한다. 그리고 glCompileShader는 코드를 컴파일한다. 그런 다음 IsCompiled 헬퍼 함수를 사용해 셰이더가 정상적으로 컴파일됐는지 유효성을 체크한다. 셰이더 파일을 로드할 수 없거나 컴파일하는 데 실패하는 등의 에러가 발생한 경우 CompileShader는 에러 메시지를 출력하고 false를 리턴한다.
IsCompiled 함수
IsCompiled 함수는 셰이더 오브젝트가 컴파일됐는지를 확인한다. 컴파일 되지 못했으면 이 함수는 컴파일 에러 메시지를 출력한다. 에러 메시지를 통해 왜 셰이더가 컴파일에 실패했는지에 대한 정보를 얻는 것이 가능하다. glGetShaderiv 함수는 셰이더 컴파일 상태를 질의하며 함수는 정수값으로 상태 코드를 반환한다. 이 상태가 GL_TRUE가 아니면 컴파일 에러가 있었다는 걸 의미한다. 오류가 발생하면 glGetShaderInfoLog로 사람이 읽을 수 있는 컴파일 에러 메시지를 얻을 수 있다.
Load 함수
Load 함수는 버텍스와 프래그먼트 셰이더의 파일 이름을 인자로 받은 뒤 이 셰이더들을 컴파일하고 서로 연결시킨다. 위의 코드는 CompileShader를 사용해서 버텍스와 프래그먼트 셰이더를 컴파일한다. 그런 후 셰이더의 오브젝트 ID를 mVertexShader와 mFragShader에 저장한다. CompileShader 호출이 실패하면 Load 함수는 false를 리턴한다. 프래그먼트 셰이더와 버텍스 셰이더를 컴파일하고 난 후에는 셰이더 프로그램이라는 세 번째 오브젝트를 사용해서 두 셰이더를 서로 연결한다. 오브젝트를 그릴 준비가 되면 OpenGL은 삼각형을 렌더링하고자 현재 활성화된 셰이더 프로그램을 사용한다. 셰이더 프로그램은 glCreateProgram으로 생성하며 이 함수는 새로운 셰이더 프로그램에 대한 오브젝트 ID를 반환한다. 그런 다음 셰이더 프로그램에 버텍스와 프래그먼트 셰이더를 추가하기 위해 glAttatchShader를 사용한다. 그리고 glLinkProgram을 사용해서 모든 추가된 셰이더를 최동 셰이더 프로그램에 연결시킨다. 셰이더 컴파일에서처럼 셰이더 연결이 성공했는지를 알기 위해 IsValidProgram 헬퍼 함수를 사용한다.
IsValidProgram 함수
IsValidProgram 함수는 IsCompiled 코드와 매우 유사하다. 이 두 함수는 오직 2가지의 차이점만 있을 뿐이다. 첫째, glGetShaderiv를 호출하는 대신에 glGetProgramiv를 호출한다. 둘째, glGetShaderInfoLog를 호출하는 대신 glGetProgramInfoLog를 호출한다.
SetActive 함수
SetActive 함수는 셰이더 프로그램을 활성화시킨다.
OpenGL은 삼각형을 그릴 때 활성화된 셰이더를 사용한다.
UnLoad 함수
UnLoad 함수는 셰이더 프로그램과 버텍스 셰이더, 그리고 프래그먼트 셰이더를 삭제한다.
셰이더를 게임에 추가하기
이제 Shader 클래스를 사용하기 위해 Game의 멤버 변수로 Shader 포인터를 추가한다.
class Shader* mSpriteShader;
변수는 mSpriteShader로 이름지었는데 왜냐하면 이 셰이더는 스프라이트를 그리는데 사용되기 때문이다. LaodShader 함수는 셰이더 파일을 로드하고 셰이더를 활성화시킨다.
LoadShdaers는 Game::Initialize에서 OpenGL과 GLEW의 초기화가 끝난 후, 그리고 mSpriteVerts 버텍스 배열 개체가 생성되기 전에 호출한다. 심플한 버텍스/프래그먼트 셰이더를 생성하고 삼각형을 로드했으므로 이제 삼각형을 그릴 수 있는 모든 준비를 마쳤다.
삼각형 그리기
삼각형을 사용하면 화면상에 사각형 스프라이트를 그릴 수 있따. 이미 단위 사각형 버텍스와 픽셀을 그릴 수 있는 기본 셰이더는 로드했다. 이전과 마찬가지로 mSpriteComponent의 Draw 함수가 스프라이트를 그린다. 먼저 SpriteComponent::Draw의 선언을 변경해서 이 함수가 SDL_Renderer 대신에 Shader를 인자로 받게한다. 그리고 glDrawElements를 호출해서 사각형을 그린다.
glDrawElemet의 첫 번째 파라미터는 그리려는 요소의 유형을 지정한다. 두 번째 파라미터는 인덱스 버퍼에 있는 인덱스의 수다. 이번 예에서 단위 사각형에 대한 인덱스 버퍼는 6개의 인덱스를 가지므로 파라미터에 6을 넘긴다. 세 번째 파라미터는 각 인덱스의 타입이다. 각 인덱스는 unsigned int로 설정됐다. 마지막 파라미터는 nullptr이다. glDrawElements 호출을 위해서는 활성화된 버텍스 배열 개체와 활성화된 셰이더가 필요하다. 매 프레임에서 SpriteComponents를 그리기 전에 스프라이트 버텍스 배열 개체와 셰이더 모두를 활성화해야 한다. 이 작업은 Game::GenerateOutput 함수에서 수행한다.
셰이더와 버텍스 배열이 활성화되도록 설정한 후에는 장면에서 각 스프라이트마다 Draw 함수를 한 번씩 호출한다. 이 코드를 지금 실행하면 프래그먼트 셰이더는 오직 파란색만을 출력하도록 구현했기 때문에 파란 사각형으로 보일 것 같지만 다른 이슈 때문에 현재 출력은 예상과 다르게 나타난다. 모든 스프라이트는 같은 스프라이트 버텍스를 사용하는데 이 스프라이트 버텍스는 NDC 좌표로 단위 사각형을 정의하고 있다. 따라서 모든 SpriteComponent는 같은 단위 사각형을 그리므로 지금 바로 게임을 실행하면 회색 화면에 하나의 파란 사각형만 보일 것이다.
각 스프라이트마다 다른 버텍스 배열을 정의하면 문제를 해결할 수 있지만 하나의 버텍스 배열을 가지고도 여러 스프라이트를 원하는 위치에 그리는 것이 가능하다. 핵심은 버텍스 속성을 변환하는 버텍스 셰이더의 기능을 이용하는 것이다.