지난 강좌
1. OpenGL 윈도우 만들기
전체 소스 코드 : https://github.com/sunduk/opengl-3d-examples
2. 삼각형 그리기
프로그래밍 언어를 처음 배우면 "Hello, world!"를 출력하는 단순한 프로그램 부터 만들기 시작 합니다. 3D 프로그래밍에서는 삼각형을 그리는 것이 제일 기초이자 중요한 부분 이라고 생각 합니다. 복잡한 3D 모델도 결국엔 삼각형(또는 폴리곤 이라고도 부릅니다) 들이 모여서 만들어 진 것이기 때문입니다. 삼각형은 꼭지점 세 개를 갖고 있는 매우 단순한 도형 이지만 이것을 화면에 출력하는데 까지는 그렇게 간단하지 않습니다. 삼각형 하나만 출력 하려고 해도 3D 렌더링의 모든 과정을 거쳐야 하기 때문이죠. 대표적으로 좌표계 변환 처리는 기존의 2D 게임에서는 몰라도 문제 없었던 부분이라서 초보자 분들에게 큰 혼란으로 다가올 수 있습니다. 하지만 작은 부분 부분들로 쪼개 파악 해 보면 좀 더 이해하기 쉬울 수 있습니다. 먼저 가장 기본적인 도형인 삼각형이 어떤 원리로 화면에 출력 되는지 알아보겠습니다.
버텍스 버퍼
좌표계
2D 게임을 개발할 때는 직관적인 판단으로 좌표 계산이 가능합니다. 화면 해상도에 따라 좌표값을 입력 하면 예상했던 그 위치에 이미지가 그려지기 때문이죠. 별다른 좌표 변환도 필요 없고 x, y 체계만 알면 되었습니다.
하지만 3D 환경 에서는 알아야 하는 좌표계도 많고 하나의 좌표계에서 다른 좌표계로 변환 하기 위해 복잡한 수학 계산도 필요 합니다. 더군다나 코드를 잘못 작성 해도 에러가 나지 않고 그냥 화면에 아무것도 안나오는 경우가 많습니다. 심지어 잘못 작성 했음에도 불구하고 제대로 나오는 것 처럼 보이는 경우도 있는데 이 때가 더 심각한 상황 입니다. 잘못 되었는데 잘 된 줄 알고 있게 되니까 말이죠.
3D 환경에서 삼각형을 출력하려면 몇 가지 행렬 변환을 거치고 화면의 뷰포트 크기에 맞게 최종 픽셀의 위치를 계산 해야 합니다. 이 과정을 구현하려면 일단 모델, 뷰, 프로젝션 이라는 행렬 연산에 대한 이해가 필요합니다. 하지만 처음 부터 행렬 연산 과정을 설명 하기에는 생소한 개념들이 많을 것입니다. 일단 3D환경에서는 화면에 무언가 출력하려면 여러 좌표계를 거쳐야 한다 라고 이해하고 넘어가겠습니다.
버텍스(Vertex)
삼각형은 세 개의 꼭지점으로 이루어져 있습니다. 이 꼭지점을 3D 세상에서는 버텍스(Vertex)라고 부릅니다. 삼각형을 화면에 출력하려면 당연히 이 세 버텍스의 좌표를 정해 줘야 하겠죠.
아래 그림은 Example02를 실행 해 본 결과 입니다. 2D 화면처럼 보이지만 내부적으로는 3D 연산 과정을 거쳐서 출력된 삼각형 입니다. 세 개의 검은 점은 실제로 렌더링 되지는 않고 단지 이해를 돕기 위해 추가한 점 입니다. 우리가 해야 할 일은 이 버텍스들의 좌표값을 적절하게 OpenGL에 넘겨 주는 것입니다.

만약 이 화면이 2D 게임의 한 장면이라고 생각 한다면 각 버텍스의 좌표를 계산 하는 것은 매우 쉽습니다. 윈도우 크기가 100 x 100이고 왼쪽 상단이 0,0이라고 했을 때 삼각형을 그리는 의사 코드는 다음과 같을 겁니다.
vertex0 = (50,0)
vertex1 = (0,100)
vertex2 = (100,100)
DrawTriangle2D(vertex0, vertex1, vertex2);
여기에는 좌표계나 행렬 같은 개념이 개입될 여지가 전혀 없습니다. 화면에서 왼쪽 위가 보통 (0,0) 이기 때문에 세 버텍스의 좌표는 단순히 윈도우 크기에 따라 x, y축의 픽셀의 위치만 계산하면 그만 입니다. 하지만 3D 세상 에서는 이와 다른 특별한 규칙이 존재 합니다. 그 규칙대로 좌표값을 입력 해야 원하는 모습을 만들어 낼 수 있죠.
NDC(Normalized Device Coordinates)

같은 그림 이지만 3D 세상에 맞게 바꿔 보겠습니다. 화면 가운데를 0,0이라 하고 윈도우를 둘러 싸는 빨간색 네모 칸이 있다고 생각 해 봅시다. 실제 윈도우 크기가 몇 픽셀 인지와 상관 없이 x,y 좌표가 항상 -1 ~ +1 사이로 정해진다고 가정한 뒤 삼각형을 그리는 의사 코드를 작성 해 보겠습니다.
// 각각 x,y,z를 뜻합니다.
vertex0 = (0,1,0)
vertex1 = (-1,-1,0)
vertex2 = (1,-1,0)
DrawTriangle3D(vertex0, vertex1, vertex2);

3D 렌더링 과정에서 내부적으로 저런 정육면체 형태의 공간에 물체들이 들어가게 됩니다. 여기에 존재하는 물체들은 최종적으로 2D 윈도우 좌표를 나타내는 뷰포트 좌표계로 변환 되어 모니터 화면에 출력 됩니다. 윈도우 크기는 사용자가 설정하기에 따라서 다 제각각 이지만 이 정육면체 공간은 무조건 -1 ~ +1 만큼의 크기를 갖고 있습니다. 그래서 정규화된 좌표계인 NDC(Normalized Device Coordinates) 라고 불리웁니다.
NDC좌표계 에서 Z값은 물체가 그려질 순서를 판별 하는데 사용되며 OpenGL에서는 모니터 안쪽으로 깊이 들어간 곳이 +1, 사람과 가까운 쪽이 -1 입니다.
삼각형 그리기
이제 OpenGL에서 삼각형을 직접 그려 보면서 방금 배웠던 이론을 실습 해 보겠습니다. 삼각형 그리는 코드는 Example02.cpp 파일에 들어 있습니다. 각 버텍스 좌표를 지정 하는 CreateTriangle함수의 내용입니다.
void Example02::CreateTriangle()
{
/*
* (0, 1, 0)
* (index:0)
* *
* * *
* * *
* * *
* * + *
* * (center)*
* * (0,0,0) *
* * *
* *****************
* (-1,-1,0) (1,-1,0)
* (index:1) (index:2)
*/
// NDC(Normalized Device Coordinate) 기준으로 삼각형의 버텍스 좌표를 설정한다.
mVertices.push_back(glm::vec3(0.0f, 1.0f, 0.0f)); // center top.
mVertices.push_back(glm::vec3(-1.0f, -1.0f, 0.0f)); // left bottom.
mVertices.push_back(glm::vec3(1.0f, -1.0f, 0.0f)); // right bottom.
}
앞에서 배웠던 의사 코드와 크게 다르지 않습니다.
- mVertices : 버텍스 좌표를 보관할 배열 입니다.
- glm::vec3 : 3차원 벡터를 나타내는 타입 입니다. 앞에서 부터 차례대로 x,y,z 값을 의미 합니다.
// glm은 C++로 구현된 수학 라이브러리 입니다.
// 실제로 glm::vec3는 템플릿 기반으로 되어 있어 매우 복잡하지만,
// 이해를 돕기 위해서 간략하게 나타내면 아래와 같은 형태 입니다.
struct vec3
{
float x;
float y;
float z;
};
std::vector<glm::vec3> mVertices{};
버텍스 버퍼
다음은 이 버텍스 좌표들을 OpenGL에게 전달 하는 코드 입니다. mVertices는 일반적인 변수 이므로 램(RAM)이라고 불리는 시스템 메모리에 생성 됩니다. GPU에서 이것을 가져다 사용하려면 비디오 카드에 있는 메모리에 존재 해야 합니다. 이 과정은 OpenGL에서 제공되는 함수들로 구현 할 수 있습니다. 여기서 부터는 본격적으로 gl접두사가 붙은 OpenGL관련 함수들이 등장하기 시작 합니다. 일단 CreateVertexBuffer함수의 전체 코드를 보고 부분 부분 설명 드리겠습니다.
void Example02::CreateVertexBuffer()
{
// (1)
// 버텍스 버퍼와 인덱스 버퍼를 보관할 "vertex array" 오브젝트를 생성한다.
glGenVertexArrays(1, &mVertexArrayObjectId);
// Create vertex buffer.
glGenBuffers(1, &mVertexBufferObjectId);
// (2)
// Bind VAO.
glBindVertexArray(mVertexArrayObjectId);
{
// (3)
// 버텍스 데이터를 버텍스 버퍼에 올린다.
glBindBuffer(GL_ARRAY_BUFFER, mVertexBufferObjectId);
{
glBufferData(GL_ARRAY_BUFFER, mVertices.size() * sizeof(glm::vec3), &mVertices[0], GL_STATIC_DRAW);
}
// (4)
// 버텍스 속성(attribute)을 설정한다.
glEnableVertexAttribArray(0);
{
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), nullptr);
}
}
// (5)
// Unbind.
glBindVertexArray(0);
}
(1) glGenVertexArrays와 glGenBuffers함수를 호출하여 새로운 버텍스 버퍼를 요청 합니다.
(2) glBindVertexArray함수로 방금 생성한 버텍스 어레이를 선택 합니다.
bind란 앞으로 호출될 gl함수들이 해당 객체를 대상으로 작업 하겠다는 뜻 입니다. OpenGL은 하나의 거대한 상태머신 과도 같은 모습으로 이루어져 있는데 어떤 작업을 할 때 항상 이렇게 bind를 한 후 에 진행 됩니다.
그 아래에 중괄호({ })로 묶은 부분은 코드 가독성을 위해서 일부러 넣은 것입니다. 중괄호가 없어도 결과물은 같습니다.
(3) 이제 드디어 버텍스 데이터를 버텍스 버퍼에 올리는 부분 입니다. 여기서도 여지 없이 bind가 쓰였네요. glBindBuffer함수로 방금 생성한 버텍스 버퍼의 ID를 bind 해 줍니다.
첫번째 파라미터인 GL_ARRAY_BUFFER는 OpenGL에서 정의 되어 있는 상수 값 입니다. 버텍스 버퍼를 올릴 것이므로 GL_ARRAY_BUFFER로 입력 해 줍니다. 나중에 엘레먼트 버퍼(인덱스 버퍼)도 배우게 될 건데 그 때는 GL_ELEMENT_ARRAY_BUFFER라는 값으로 들어 갑니다.
glBufferData함수를 호출 해서 버텍스 데이터를 버텍스 버퍼로 올리는 부분은 다음과 같습니다.
glBufferData(
// target : 작업할 대상을 선택 합니다.
GL_ARRAY_BUFFER,
// size : 버텍스 배열의 크기를 입력 합니다.
mVertices.size() * sizeof(glm::vec3),
// data* : 배열의 시작 주소를 넘겨 줍니다.
&mVertices[0],
// usage : 버텍스 적용 타입 같은 건데 GL_STATIC_DRAW 또는 GL_DYNAMIC_DRAW를 넣어 줍니다.
// 지금은 한번 만든 버텍스 좌표는 변경될 일이 없기 때문에 GL_STATIC_DRAW로 입력 합니다.
GL_STATIC_DRAW
);
(4) 다음으로 glVertexAttribPointer함수를 호출해서 버텍스 속성을 설정 합니다. 방금 올린 버텍스 데이터가 어떤 레이아웃으로 구성 되어 있는지 OpenGL에게 알려 주는 부분 입니다. mVertices는 메모리에 올라가 있으면 그냥 데이터의 나열일 뿐이기 때문에 이것이 어떤 형식으로 사용될 것인지 구획을 나눠주는 작업을 의미 합니다.
또한 버텍스 버퍼에는 좌표 뿐만 아니라 나중에 배우게 될 노말 벡터나 컬러값도 들어갈 수 있습니다. 우리가 어떻게 구성하는가에 따라서 레이아웃은 달라 집니다. 현재는 좌표를 의미하는 float값 3개만 사용할 것이며 레이아웃은 다음 그림과 같습니다.

glVertexAttribPointer(
0, // index : 지금은 버텍스 좌표만 사용할 것이기 때문에 0으로 설정 합니다.
// 이 값은 나중에 배울 버텍스 셰이더에서 참조하게 됩니다.
3, // size : 버텍스 좌표를 담은 구조체(glm::vec3)는 x,y,z 세 개로 구성 되어 있으므로 3을 입력 합니다.
GL_FLOAT, // type : 각각의 요소는 float타입 임을 나타 냅니다.
GL_FALSE, // normalized : 데이터 타입이 정수형일 경우 입력된 값을 정규화 할지 그대로 쓸지를 결정 합니다.
// float타입일 경우에는 아무런 변화가 없습니다.
sizeof(glm::vec3), // stride : 다음 데이터 까지의 보폭을 의미합니다.
// glm::vec3의 사이즈를 입력 합니다.
nullptr // 버텍스 버퍼에서 좌표 데이터가 시작 하는 위치값 입니다. 0 또는 nullptr을 입력 합니다.
);
(5) 작업이 완료되면 glBindVertexArray(0)를 호출하여 Unbind합니다. OpenGL은 최종 입력된 상태 값을 기억 하고 있기 때문에 어떤 작업이 완료되면 Unbind를 해 주는 것이 좋습니다. 만약 Unbind 하지 않고 그대로 놔 둔 다면 다음번 gl명령어가 수행 될 때 마지막에 Bind된 상태에 적용 되기 때문에 의도치 않은 결과를 가져올 수 있습니다.
렌더링
지루했던 버텍스 버퍼 설정을 완료 했습니다. 하지만 아직 한 가지 과정이 더 남아 있습니다. 메모리에 올려놓은 버텍스들을 사용해서 화면에 렌더링 하라는 명령을 줘야 합니다.
void Example02::Render()
{
// (1)
// 렌더링에 적용할 셰이더 프로그램을 설정합니다.
glUseProgram(mDefaultShaderID);
// (2)
// Bind VAO.
glBindVertexArray(mVertexArrayObjectId);
{
// 버텍스 버퍼를 이용하여 삼각형을 렌더링 합니다.
glDrawArrays(GL_TRIANGLES, 0, 3);
}
}
(1) 셰이더란 GPU안에서 실행 되는 작은 프로그램 입니다. 여기에는 삼각형을 어떤 색상으로 출력 할지, 어느 위치에 출력할지 결정하는 코드가 들어가 있습니다. 나중에 더 자세히 파악해 보기로 하고 지금은 이런것이 있다 라고만 알고 넘어가겠습니다.
(2) 작업할 대상을 선택하기 위해 우리가 생성 했던 버텍스 어레이를 Bind 해 주고 삼각형을 그리는 glDrawArrays함수를 호출 해 줍니다.
glDrawArrays(
GL_TRIANGLES, // mode : 어떤 것을 그릴지 결정 합니다.
// GL_TRIANGLES, GL_LINES등이 있으나
// 우리는 대부분 GL_TRIANGLES를 사용하게 될 겁니다.
0, // first : 버텍스 배열의 시작 인덱스 입니다.
3 // count : 시작 인덱스로 부터 몇 개의 버텍스를 사용할지 입력 합니다.
// 삼각형 한 개를 그릴 것이기 때문에 버텍스 3개가 필요하므로 3을 입력 합니다.
);
아직 셰이더등 생소한 개념들이 남아 있긴 하지만 이쯤에서 에서 코드를 빌드 하여 실행 해 보도록 하겠습니다. 문제 없이 잘 실행 되었다면 아래와 같은 빨간색 삼각형이 출력된 모습을 볼 수 있습니다.

전체 소스 코드 : Example02.h / cpp (https://github.com/sunduk/opengl-3d-examples)
이번 시간에는 간단하게 빨간색 삼각형을 출력하는 코드를 짜 봤습니다. 다음번에는 알록달록한 무지개색 삼각형을 찍어 보겠습니다.

>>> 다음 강좌 : 3. 알록달록한 삼각형 그리기
감사합니다.