OpenGL 시리즈 링크

OPENGL 소개: OPENGL, GLFW, GLEW란? [OPENGL E02]
OPENGL 설치: GLEW, GLFW 다운로드법과 VISUAL STUDIO에서 OPENGL 사용하기 [OPENGL E03]
OPENGL 창 만들기: OPENGL 기초 세팅[OPENGL E04]
OPENGL 삼각형 그리기 (1)VBO, VAO [OPENGL E05]
OPENGL 삼각형 그리기 (1)-2 코드 중간 정리 [OPENGL E06]
OPENGL 삼각형 그리기 (2)VERTEX SHADER, FRAGMENT SHADER의 기초 [OPENGL E07]
OPENGL 삼각형 그리기 (3)SHADER PROGRAM [OPENGL E08]

OpenGL 그래픽스 파이프라인(Graphics Pipeline)

OpenGL로 표현하고자 하는 공간은 3차원이나 우리는 이를 2차원 화면을 통해 보게 됩니다.
따라서 3차원 좌표를 2차원 픽셀로 전환하는 과정이 필요하고 이를 그래픽스 파이프라인(Graphics Pipeline), 또는 렌더링 파이프라인(Rendering Pipeline)이라고 합니다.

그래픽스 파이프라인을 간단하게 표현하면 아래와 같습니다.

pipeline

간단하게 설명하자면, 꼭지점 정보를 저장(Vertex Specification)하고 이를 Vertex Shader, Tessellation Shader, Geometry Shader 등 셰이더를 거치고 후처리(Post-Processing)와 일련의 과정을 거친 후 래스터화하여 fragment로 만듭니다. 이 fragment 정보를 Fragment Shader로 전달하여 픽셀의 색을 지정하는 등의 과정을 거치고 Per-Sample Operation 단계에서 깊이 테스트(Depth Test)와 색 블랜딩(Color Blending) 등의 과정을 거치면 우리가 원하는 이미지가 화면에 등장하게 됩니다.

하지만 이 과정을 전부 이해하기에는 너무 이릅니다. 따라서 가장 중요한, 그리고 삼각형을 그리기 위해서 우리가 무조건 직접 작성해야하는 Vertex Specification, Vertex ShaderFragment Shader에 대해서만 일단 알아봅시다.
이번 글에서는 Vertex Specification에 대해 다룹니다. 셰이더는 다음 글에서 다루도록 하겠습니다.

Vertex Specification

Vertex Specification이란 우리가 그릴 삼각형의 꼭지점 정보를 작성하고 저장하는 과정을 의미합니다. 언급했듯이 이 정보는 셰이더로 전달되어서 처리됩니다. 셰이더에 대해서는 나중에 더 자세히 살펴볼 것이지만, 가장 간단히 말하면 GPU에서 실행되는 프로그램입니다. 따라서 우리는 꼭지점 정보를 그래픽 카드의 GPU로 보내야합니다.

하지만 컴퓨터는 꼭지점들을 한 번만 그리고 말지 않습니다. 매 프레임마다 우리가 원하는 꼭지점들을 그려야하고, 매번 CPU에 있는 꼭지점 정보를 GPU로 보내는 것은 효율적이지 않습니다. 그래서 우리는 꼭지점 정보를 그래픽 카드의 메모리 공간에 저장하고, GPU가 이를 필요할 때마다 부르도록 설정할 것입니다.

Vertex Buffer Object(VBO)

그렇다면 꼭지점 정보를 어떻게 그래픽 카드에 저장할까요? 바로 Vertex Buffer Object(VBO)란 OpenGL Object에 저장합니다.

많은 튜토리얼들이 OpenGL Object에 대해 설명하지 않고 넘어갑니다. 하지만 OpenGL의 구동 방식을 이해하기 위해선 VBO와 같은 OpenGL Object에 대해 잘 알고 있어야합니다.

OpenGL Object와 State Machine

기본적으로 OpenGL은 State Machine(상태 기계)입니다. 간단하게 설명하자면, 우리가 일련의 과정들을 통해 OpenGL의 state를 설정하면, OpenGL 함수들은 현재 설정된 state에 따라서 작동합니다. 그리고 이 state를 바꾸지 않는 한 OpenGL 함수들은 그 경향을 계속 유지합니다. 물론 이 state를 우리는 계속해서 바꿀 것이고, OpenGL 함수들도 가장 최근 설정된 state에 따라 작동하겠지요.
이는 마치 우리가 int 변수에 5라는 값을 정의하면, 그 값을 재정의 하기 전까지는 해당 변수를 사용한 모든 계산이 5를 바탕으로 이루어지는 것과 비슷합니다. 물론 논리 구조적으로 완벽한 비유는 아닐 수도 있지만, 여하튼 가장 최근에 설정된 값으로 돌아가는 기계라고 보시면 됩니다.

OpenGL Object는 이러한 state를 담고 있는 구조체와 같은 것입니다. 그리고 이런 OpenGL Object을 OpenGL context에 연결(bind)하면 OpenGL 함수들은 context에 연결된 state를 current state로 인식하고, 이를 바탕으로 작동합니다. 예컨대, 우리가 A라는 삼각형의 꼭지점 정보를 담은 VBO를 GL_ARRAY_BUFFER이라는 OpenGL context에 연결(bind)하면 이 연결을 깨기 전까진 OpenGL 함수들이 A 삼각형의 꼭지점 정보를 바탕으로 작동합니다. 참고로, GL_ARRAY_BUFFER과 같은 특정한 OpenGL context 지점을 target이라고도 합니다.
이러한 target들은 실제로는 unsigned int 값이지만, 비유적으로 본다면 주소와도 같은 것입니다. 이 주소에 VBO라는 값을 저장한다고 보면 됩니다.

여기까지 이해하셨다면 잘하신 겁니다. 저는 이걸 이해하는 데에 꽤나 오래걸렸습니다.

VBO에 꼭지점 정보 저장하기

이제 그리고자 하는 삼각형 정보를 담은 VBO를 실제로 만들고 GL_ARRAY_BUFFER라는 target에 연결(bind)해보도록 하겠습니다.

일단은 VBO에 담을 삼각형의 꼭짓점 정보를 구성해야합니다. 즉, 가장 간단하게 삼각형의 세 꼭지점의 좌푯값을 가지는 배열을 만듭니다.

float vertices[] = {
	-0.5f, -0.5f, 0.0f,
	 0.5f, -0.5f, 0.0f,
	 0.0f,  0.5f, 0.0f
};

vertices 배열이 담고 있는 정보는 세 꼭지점의 x, y, z 좌표입니다. 우리는 간단한 2차원 삼각형을 그릴 예정이기 때문에 z 좌표는 모두 0.0f으로 둡니다.

한 가지 알아두어야 할 점은, 이 좌표들은 Normalized Device Coordinates(NDC)입니다. OpenGL은 모든 좌표들을 2D 화면에 그리지 않습니다. x, y, z 좌표 모두 오직 -1.0 에서 1.0 사이의 값을 갖는 좌표만 화면에 그립니다. 이런 좌표들을 NDC라고 합니다. 그림으로 보면 다음과 같습니다.

NDC

다음으로 VBO를 만듭니다. 모든 OpenGL Object은 GLuint 자료형의 고유 ID를 가집니다. ID를 가지고 glGenBuffers 함수를 호출하여 VBO를 만들어 봅시다.

GLuint VBO;
glGenBuffers(1, &VBO);

glGenBuffers는 첫 번째 인자로 만들고자 하는 버퍼의 갯수를, 두 번째 인자로 버퍼의 ID의 포인터를 받습니다. 이제 VBO라는 ID를 가진 버퍼 VBO를 만들었습니다.

이제 VBO를 GL_ARRAY_BUFFER이라는 target과 연결(bind)해봅시다.

glBindBuffer(GL_ARRAY_BUFFER, VBO);

glBindBuffer는 첫 번째 인자로 target을, 두 번째 인자로 버퍼의 ID를 받습니다.

특이한 점은, 아직 우리는 이 버퍼의 크기마저도 지정하지 않았다는 것입니다. VBO에 담길 데이터와 그 크기를 이제 지정하도록 하겠습니다.

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

glBufferData는 현재 연결(bind)된 Buffer Object에 데이터를 복사하여 담을 수 있게 합니다. 첫 번째 인자는 데이터를 복사하고자 하는 target을 받습니다. 우리의 VBO는 GL_ARRAY_BUFFER에 연결되어 있으므로 GL_ARRAY_BUFFER를 입력합니다. 두 번째 인자는 복사하고자 하는 데이터의 크기(byte 단위)를 받습니다. 세 번째 인자는 복사하고자 하는 데이터 자체를 받습니다. 네 번째 인자는 그래픽 카드가 복사된 데이터를 어떻게 활용할지에 대한 힌트를 담습니다.

네 번째 인자는 Buffer Object의 성능과 관련이 있습니다. 그리고 데이터를 어떻게 다룰 것인지에 대한 힌트일 뿐이므로 그 활용 범위를 강제로 한정 짓지는 않습니다.
이 힌트는 데이터에 얼마나 자주 접근할 것인가와 접근 이유로 구성되어 있습니다. 데이터에 얼마나 자주 접근할 것인가에 따라 STREAM, STATIC, DYNAMIC으로 나누어지며, 접근 이유에 따라 DRAW, READ, COPY로 나누어집니다. 우리는 데이터를 한 번 지정한 이후 수정없이 여러번 접근하되 화면에 그림을 그리는 용도로 사용할 것이므로 GL_STATIC_DRAW라는 값을 네 번째 인자에 전달하면 됩니다.

Vertex Attributes

이제 삼각형의 꼭지점 정보가 GL_ARRAY_BUFFER이라는 OpenGL context에 연결(bind)된 VBO에 복사, 저장되었습니다.

하지만 문제가 있습니다. 우리가 전달한 삼각형의 꼭지점 정보는 9개의 원소를 가진 단순한 배열입니다. 우리는 이 9개의 원소가 x, y, z 좌표의 세트 3개라는 것을 알고 있지만, GPU는 이를 알 수가 없습니다. 더군다나, 꼭지점의 정보는 단순히 좌표로만 구성되지는 않습니다. 꼭지점의 색, 텍스쳐를 입힐 때 사용할 좌표, 노멀(법선) 등 다양한 정보를 꼭지점에 포함시킬 수 있습니다. 그렇게 된다면 3개의 꼭지점이 각각 포함하는 정보가 배열에 어떤 순서로 어떻게 저장되어 있는지 GPU에게 알려주어야합니다.

Example

꼭지점 정보를 담은 배열의 예시는 다음과 같습니다.

example

좌표, 색, 노멀(법선) 등을 각각 Vertex Attribute이라고 합니다. 우린 이 Vertex Attribute을 GPU가 어떻게 해석할지에 대한 정보를 glVertexAttribPointer이라는 함수로 전달합니다.

예시에는 좌표와 uv라는 2개의 Vertex Attribute이 존재합니다. 따라서 각각 glVertexAttribPointer 함수로 그 구성을 정의해야합니다.

(example)

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(GL_FLOAT), (void*)0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(GL_FLOAT), (void*)(3 * sizeof(GL_FLOAT)));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);

glVertexAttribPointer 함수첫 번째 인자로 구성하고자 하는 Vertex Attribute의 번호를 받습니다. 이를 추후 셰이더에서는 Vertex Attribute의 location이라고도 할 것입니다. 예시에서는 각각의 glVertexAttribPointer 함수가 0과 1번째 Vertex Attribute을 구성할 것입니다.

두 번째 인자는 구성하고자 하는 Vertex Attribute의 크기를 받습니다. 이 크기는 요소의 갯수입니다. 좌표는 x, y, z 세 개, uv는 u, v 두 개가 그 크기입니다.

세 번째 인자는 데이터의 자료형입니다. 좌표와 uv 모두 GL_FLOAT 형의 데이터로 구성되어있습니다.

네 번째 인자는 데이터를 정규화(Normalize) 할 것인지에 대한 참, 거짓 값입니다. 정규화해야한다면 GL_TRUE, 아니면 GL_FALSE를 전달합니다. 이에 대한 설명은 추후 하도록 하고 여기선 GL_FALSE를 전달합니다.

여기서부터 중요합니다. 다섯 번째 인자는 stride입니다. stride는 같은 Vertex Attribute 번호의 다음 요소까지의 거리 또는 크기를 바이트로 나타낸 값입니다. 즉, 좌푯값과 다음 꼭지점의 좌푯값 사이의 거리, uv와 다음 꼭지점의 uv 사이의 거리이며, 예시에서는 좌표도 uv도 GL_FLOAT 형의 자료 5개를 넘어가야 다음 좌표 또는 uv에 도달하니 5 * sizeof(GL_FLOAT)을 stride 값으로 전달합니다. 참고로, 예시와 같이 모든 값들이 빈 공간 없이 붙어있을 땐(tightly packed) stride에 0을 전달하여도 무방합니다. OpenGL이 알아서 stride를 계산해줍니다.

마지막으로 여섯 번째 인자는 void 포인터형이어야하며, 해당 Vertex Attribute의 값이 버퍼에서 얼만큼의 offset 후에 시작되는지를 바이트값으로 받습니다. 즉, 좌표는 버퍼의 첫 시작부터 그 값이 시작되니 0, uv는 3개의 GL_FLOAT을 건너뛰고 시작되니 그 값이 3 * sizeof(GL_FLOAT)입니다.

감이 오시겠지만 예컨대 OpenGL이 위 예시에서 uv값들에 접근하고자 할 때 offset 만큼 건너뛴 후 GL_FLOAT 2개의 값을 uv로 인식한 후 stride 만큼 건너뛰어서 다음 uv를 찾습니다.

glVertexAttribPointer를 호출한 이후에는 glEnableVertexAttribArray 함수에 Vertex Attribute 번호를 인자로 전달하여 해당 Vertex Attibute을 직접 설정한대로 인식하라고 OpenGL에게 요청합니다.

glVertexAttribPointer로 꼭지점 정보의 구성 전달하기

현재 삼각형의 꼭지점 정보는 vertices라는 배열에 저장되어있고 더 간단한데, 아래와 같이 구성되어있습니다.

vertices

위 표로 보아 현재 우리의 삼각형 꼭지점 정보에는 좌표라는 Vertex Attribute 하나가 담겨있습니다. 따라서 다음과 같이 작성하면 됩니다.

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (void*)0);
glEnableVertexAttribArray(0);

Vertex Array Object(VAO)

VBO를 만들고 담긴 내용을 OpenGL이 알 수 있도록 설정했습니다.

while (!glfwWindowShouldClose(window))
{
	glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
	glClear(GL_COLOR_BUFFER_BIT);

	GLuint VBO;
	glGenBuffers(1, &VBO);
	glBindBuffer(GL_ARRAY_BUFFER, VBO);
	glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (void*)0);
	glEnableVertexAttribArray(0);

	glUseProgram(ShaderProgramThatWeWillMakeLater); // 아직 모르셔도 됩니다
	glDrawArrays(GL_TRIANGLES, 0, 3); // 아직 모르셔도 됩니다

	glfwSwapBuffers(window);
	glfwPollEvents();
}

셰이더만 있다면 우린 이제 삼각형을 그릴 수 있습니다. 위 코드대로 삼각형 그리는 작업을 매 프레임마다 반복하면 됩니다. 하지만 매 프레임마다 GL_ARRAY_BUFFER에 VBO를 연결(bind)하고, 데이터를 저장하고, Vertex Attribute들을 설정하는 것은 비효율적입니다. 하나의 삼각형이 아니라 수백, 수천, 수만개의 삼각형을 그린다고 생각하면 더욱 더 그렇습니다.

그래서 마지막 작업이 필요합니다. 바로 위의 모든 작업을 하나의 state로 묶어서 OpenGL Object에 저장하는 것입니다. 그렇게 된다면 우리는 위의 작업을 한 번만 하고, 삼각형을 그리기 전에 위 작업을 하나로 묶은 state를 current state로 만들어주기만 하면 됩니다.

이 때 필요한 OpenGL Object이 Vertex Array Object(VAO)입니다. 위의 과정을 VAO에 담아 관리하는 것은 매우 쉽습니다. 위 과정을 진행하기 전에 VAO를 만들고 이를 OpenGL context에 연결(bind) 해놓기만 하면 알아서 위 과정들이 VAO에 저장됩니다. 그리고 나중에 위 과정으로 만든 삼각형을 그리고 싶을 때, 즉 매 프레임에서, 여기서 만들어놓은 VAO를 OpenGL context에 연결(bind) 해주기만 하면 됩니다.

VAO를 생성하고 OpenGL context에 연결(bind)하는 과정은 VBO와 흡사합니다.

GLuint VAO;
GLuint VBO;

glGenVertexArrays(1, &VAO); // VAO 생성
glBindVertexArray(VAO); // VAO를 OpenGL context에 연결(bind)
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (void*)0);
glEnableVertexAttribArray(0);

glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);

while (!glfwWindowShouldClose(window))
{
	glUseProgram(ShaderProgramThatWeWillMakeLater); // 아직 모르셔도 됩니다
	glBindVertexArray(VAO);	// 한 줄로 끝!
	glDrawArrays(GL_TRIANGLES, 0, 3); // 아직 모르셔도 됩니다
}

이제 다소 복잡한 과정이 반복문 밖으로 빠졌습니다. 덕분에 훨씬 OpenGL스러운 코드로 바뀌었고 효율도 높아졌습니다.

한 가지 짚고 넘어갈 점은, OpenGL context는 순간 순간 필요에 따라 다른 OpenGL Object와 연결됩니다. 따라서 OpenGL Object를 OpenGL context에 연결(bind)하는 것도 중요하지만 이를 적절하게 끊어주는 것(unbind)도 중요합니다. 연결(bind)할 때 사용했던 함수들에 인자로 OpenGL Object의 ID 대신 0을 전달하면 현재 OpenGL context와 그에 연결(bind)되어 있는 OpenGL Object의 연결고리가 끊어집니다.

전체 코드

#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <stdlib.h>

int main(void)
{
	if (!glfwInit())
		exit(EXIT_FAILURE);

	glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
	glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5);
	glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
	glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);

	GLFWwindow* window = glfwCreateWindow(1080, 720, "title", NULL, NULL);
	if (!window)
	{
		glfwTerminate();
		exit(EXIT_FAILURE);
	}
	
	glfwMakeContextCurrent(window);

	int framebuf_width, framebuf_height;
	glfwGetFramebufferSize(window, &framebuf_width, &framebuf_height);
	glViewport(0, 0, framebuf_width, framebuf_height);

	if (glewInit() != GLEW_OK)
	{
		glfwTerminate();
		exit(EXIT_FAILURE);
	}

	float vertices[] = {
	-0.5f, -0.5f, 0.0f,
	 0.5f, -0.5f, 0.0f,
	 0.0f,  0.5f, 0.0f
	};

	GLuint VAO;
	GLuint VBO;

	glGenVertexArrays(1, &VAO); // VAO 생성
	glBindVertexArray(VAO); // VAO를 OpenGL context에 연결(bind)

	glGenBuffers(1, &VBO);
	glBindBuffer(GL_ARRAY_BUFFER, VBO);
	glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (void*)0);
	glEnableVertexAttribArray(0);

	glBindBuffer(GL_ARRAY_BUFFER, 0);
	glBindVertexArray(0);
	

	while (!glfwWindowShouldClose(window))
	{
		glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
		glClear(GL_COLOR_BUFFER_BIT);
		glUseProgram(ShaderProgramThatWeWillMakeLater); // 아직 모르셔도 됩니다
		glBindVertexArray(VAO);	// 한 줄로 끝!
		glDrawArrays(GL_TRIANGLES, 0, 3); // 아직 모르셔도 됩니다
		glfwSwapBuffers(window);
		glfwPollEvents();
	}

	glfwTerminate();

	return 0;
}

다음편 예고

이제 삼각형을 그릴 준비가 다 되었습니다. 다만 아직 셰이더를 작성하지 않았습니다. 위 코드에서 glUseProgram을 호출하기 위해서는 셰이더 프로그램을 만들어야합니다. 이 작업은 다음 글에서 진행하도록 하겠습니다.

더불어 Index Buffer Object(IBO) 또는 Element Buffer Object(EBO)이라는 OpenGL Object 하나를 더 배울 것입니다. 지금은 삼각형의 꼭지점 하나 하나를 지정해서 삼각형을 그려내지만, 이는 삼각형 여러개로 구성된 복잡한 물체를 그릴 때 매우 비효율적입니다. 겹치는 꼭지점들이 있을텐데 그런 꼭지점들도 매번 새로운 삼각형의 꼭지점으로 새로이 지정해야하기 때문입니다. 따라서 Indexed Drawing 방식을 사용해야하고, 이 때 IBO 또는 EBO가 필요합니다.