본문 바로가기

컴퓨터그래픽스

[OpenGL 공부] Hello Triangle

학습을 위해 참고한 사이트

https://learnopengl.com/Getting-started/Hello-Triangle

 

그래픽 파이프라인. 파란색 영역은 사용자가 수정가능한 영역.

 

Vertex Shader : 3D 좌표를 다른 3D 좌표로 변환

Geometry Shader(선택적) : 도형을 이루는 정점 컬렉션에서 새로운 정점을 형성하여 다른 모양을 형성할 수 있도록 함.

Shape Assembly : 하나 이상의 프리미티브를 형성하는 정점들을 입력으로 하여 모든 점들을 조립

Raterization : Shape 결과를 최종 화면의 픽셀에 매핑하여 프래그먼트 셰이더가 사용할 수 있도록 프래그먼트를 생성

Fragment Shader : 픽셀의 최종 색상을 계산

Tests and Blending : 알파 테스트, 블렌딩 단계. 또한 각 객체의 깊이 값을 통해 개체 간 폐기되어야 할 부분을 결정

 

프리미티브(primitive) 란?

렌더링될 때 정점 스트림이 무엇을 나타낼 것인지 결정하기 위한 OpenGL의 해석 체계이다.

예를 들어 OpenGL의 기본적인 그리기를 위한 명령인 GL_POINTS, GL_TRIANGLE, GL_LINE_STRIP는 각각 점, 삼각형, 선을 구별하여 해석하기 위한 명령이고, 이를 통해 Shape Assembly에서 정점 스트림을 해석하고 나면 결과로 점, 삼각형, 선이라는 기본 프리미티브를 형성하게 된다.

 

 

본격적인 삼각형을 그리기 위한 과정

 

1. 정점 입력을 위한 준비

OpenGL은 특정 범위에 있는 3D 좌표만을 처리하고, 이를 위해 정규화된 장치 좌표(Normalized Device Coordinates)를 사용한다. (x, y, z 축을 포함한 -1.0 ~ 1.0 사이의 범위)

따라서 정점 셰이더에서 정점 좌표가 처리되기 위해서는 해당 범위가 벗어나지 않도록 적절한 데이터가 필요하다.

NDC 범위 내에서 세 점을 가지는 좌표를 정의한다.

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

 

 

정점 데이터가 정의되고 나면 정점 셰이더에 대한 입력으로 보낼 준비를 한다. 이때,

1. 일반적인 렌더링 과정에서 정점의 개수는 매우 많다.

2. CPU에서 GPU로 데이터를 보내는 것은 상대적으로 느리다.

위와 같은 문제를 해결하기 위해 데이터를 저장하는 메모리를 GPU에 생성하여 대량의 정점 데이터를 저장할 수 있도록 도와주는 정점 버퍼 객체(VBO)를 생성할 수 있다.

unsigned int VBO;			// Vertex Buffer Object
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);

우선 glGenBuffers를 통해 생성할 버퍼의 수와 버퍼 객체를 생성한다. 여기서 unsigned int 타입으로 VBO 변수를 선언하여 주었는데, VBO 변수 자체는 버퍼 객체가 아니라 버퍼 객체의 주소를 가리키는 포인터라고 이해하면 된다. 또한 OpenGL에는 버퍼의 유형을 지정해주어야 하는데, 정점 버퍼 객체의 유형은 GL_ARRAY_BUFFER이므로 glBindBuffer를 통해 생성한 버퍼에 바인딩시켜준다. 일단 버퍼에 바인딩을 시켜주고 나면, 이후 수행되는 버퍼 호출은 VBO가 가리키는 버퍼를 구성하는데 사용된다.

 

생성된 버퍼 객체에 정점 데이터를 복사하기 위해 다음 명령을 사용한다.

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

각 인수를 설명하면 첫번째는 GL_ARRAY_BUFFER에 바인딩된 버퍼를 대상, 두 번째는 정점 정보인 vertices의 크기, 세 번째는 복사할 실제 데이터, 마지막으로는 해당 데이터를 관리할 방법을 지정한다. GL_STATIC_DRAW 옵션은 정점 정보에 대해 앞으로 변경될 필요가 없는 경우, 한 번만 정의하도록 하여 하드웨어의 부담을 줄일 수 있다.

 

이제 그래픽 파이프라인에서 첫번째 입력으로 사용할 정점 데이터가 준비가 되었으니, 삼각형을 최종적으로 나타내기 위해서 필요한 최소한의 과정인 정점 셰이더와 프래그먼트 셰이더를 정의해보자.

 

2. 셰이더 정의 및 동적 컴파일 코드 작성

LearnOpenGL 사이트에서 제공한 기본적인 셰이더 코드는 다음과 같다.

// 정점 셰이더
#version 330 core

layout (location = 0) in vec3 aPos;

void main(void) {
	gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
// 프래그먼트 셰이더
#version 330 core

out vec4 color;

void main(void) {
	color = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

OpenGL에서 셰이더는 GLSL(OpenGL Shading Language)로 정의된다. 현재는 그래픽스 파이프라인의 각 단계를 거쳐 화면에 삼각형을 그려내는 것이 목표이므로 간단하게만 설명하는 것으로 그치고 추후 셰이더 코딩에 대해서 더욱 자세하게 다뤄보려고 한다.

 

정점 셰이더에서 layout (location = 0)이라는 키워드는 정점 셰이더나 프래그먼트 셰이더에서 입력 속성(Attribute) 또는 출력(Output)의 위치를 지정하는 데 활용된다. 여기선 정점 속성의 위치를 0으로 설정한 것이라고만 이해하였다. 정점 속성은 위치 이외에도 색상이나 법선 벡터 등을 가질 수 있다.

in vec3 : 각 정점은 3D 좌표를 포함하므로 vec3 타입의 입력 데이터를 받도록 한다.

이후 main 함수에서 gl_position을 통해 위치 데이터를 할당한다.

 

프래그먼트 셰이더는 픽셀의 색상을 결정하므로, 4개의 값 (r, g, b, 그리고 a)을 가진 벡터를 지정한다.

프래그먼트 셰이더는 정점 셰이더와는 다르게 out 키워드를 사용하여 지정한 color값을 최종 색상으로 출력하도록 정의한다.

 

정의한 셰이더 코드들을 사용하기 위해서는 런타임에 동적으로 컴파일을 할 필요가 있다. OpenGL에서는 이를 도와주기 위해 정의된 여러 함수가 존재한다. 셰이더를 컴파일하기 위해 필요한 코드는 다음과 같다.

string readShaderSource(const char* filePath) {
	string content = "";
	ifstream fileStream(filePath, ios::in);
	string line = "";
	while (!fileStream.eof()) {
		getline(fileStream, line);
		content.append(line + "\n");
	}
	fileStream.close();
	return content;
}

GLuint createShaderProgram() {
	string vertShaderStr = readShaderSource("vertShader.glsl");
	string fragShaderStr = readShaderSource("fragShader.glsl");

	const char* vertShaderSrc = vertShaderStr.c_str();
	const char* fragShaderSrc = fragShaderStr.c_str();

	GLuint vShader = glCreateShader(GL_VERTEX_SHADER);
	GLuint fShader = glCreateShader(GL_FRAGMENT_SHADER);

	glShaderSource(vShader, 1, &vertShaderSrc, nullptr);
	glShaderSource(fShader, 1, &fragShaderSrc, nullptr);
	glCompileShader(vShader);
	glCompileShader(fShader);

	GLuint vfProgram = glCreateProgram();
	glAttachShader(vfProgram, vShader);
	glAttachShader(vfProgram, fShader);
	glLinkProgram(vfProgram);

	return vfProgram;
}

위 코드에서 각 함수의 역할은 다음과 같다.

readShaderSource 함수는 정점 셰이더 및 프래그먼트 셰이더 파일의 코드를 불러온다.

createShaderProgram 함수에선 readShaderSource 함수를 호출하여 const char* 형태로 각각 저장한 뒤, glCreateShader를 통해 셰이더 객체를 생성한다. 이후 glShaderSource를 통해 저장한 소스 코드로 바꾸어 glCompileShader를 호출하면, 셰이더 객체에 저장된 소스 코드 문자열을 컴파일하게 된다. glCompileShader의 결과는 GL_TRUE 또는 GL_FALSE의 형태로 저장이 되는데, 서술한 코드에는 기술하진 않았지만 해당 결과를 통해 추가적으로 glGetShader를 호출하여 상태값을 쿼리 하는 것도 가능하다. 만약 셰이더가 올바르게 컴파일되었을 경우 GL_TRUE로 설정될 것이다. glCreateProgram은 빈 프로그램 객체를 생성하는데, 컴파일된 셰이더를 프로그램 객체와 연결 및 링크 과정을 나타내는 것이 glAttachShader와 glLinkProgram의 역할이다. 마지막으로 셰이더 프로그램 객체를 사용하기 위해 프로그램의 ID를 반환하도록 한다.

 

3. 정점 속성 연결

앞서서 우리는 정점 속성 중 하나인 위치 정보를 GPU 메모리에 VBO를 생성하여 값을 복사하여 저장하였다. 하지만 VBO에 저장된 데이터만 보았을 때, 정점 속성 데이터가 배열에 나열되었을 뿐 이를 OpenGL이 어떻게 해석할지는 모르기 때문에, 정점 속성들을 어떻게 다루어야 할지 명시해주어야 한다.

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

glVertexAttribPointer 함수를 사용하면 정점 속성이 어떻게 처리할지 명시해 줄 수 있다. 기본적으로 정점 속성은 비활성화되어 있기 때문에 glEnableVertexAttribArray 함수를 사용하여 해당 인덱스의 정점 속성을 활성화시켜줄 수 있다. 아래 표는 glVertexAttribPointer의 각 파라미터로 넘겨주어야 할 값들의 목록이다.

GLuint index 해당 인덱스의 정점 속성 지정
GLint size 정점 속성의 크기 지정 (삼각형인 경우 vec3이므로 넘겨주어야 할 값은 3)
GLenum type  데이터 유형
GLboolean normalized 데이터의 정규화 여부
GLsizei stride 정점 속성 간 간격 크기 지정
const void* pointer 오프셋, 데이터를 읽기 시작할 위치 지정

 

정점 속성의 해석 방법도 지정해 주었기 때문에, 드디어 무엇인가를 그려낼 수 있을 것 같다고 생각했지만 아직 해야 할 일이 하나 더 남아있다. 일반적으로 우리는 여러 물체를 화면에 그려낼 것이고, 각 물체가 정점 속성이 하나 이상일 경우가 많다. 이 경우 각 물체를 그리기 위해 수많은 VBO를 바인딩하고 데이터를 복사하여, 서식을 지정해야 하는 번거로움이 발생하게 된다. OpenGL에서는 이러한 번거로움을 피하기 위해 각 객체마다 사용할 정점 속성들의 정보를 저장하기 위한 별도 공간을 생성하여 모아놓았다가, 물체를 그릴 때 알맞은 공간을 선택하여 호출하도록 도와주는 방법을 제공하고 있다. 이 별도 공간은 VAO(Vertex Array Object)라고 불리며, 아래 도식을 통해 VBO와의 관계도를 파악할 수 있다.

VAO와 VBO의 관계

 

하나의 VAO 객체는 여러 개의 정점 속성을 가리킬 수 있으며, VBO의 구성을 설정하기 전에 VAO를 생성하여 바인딩해서 이후에 작성되는 VBO 구성 설정들을 저장할 수 있다. VAO의 생성 방법은 VBO와 유사하며 필요한 코드는 다음과 같다.

unsigned int VAO;			// Vertex Array Object
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

// ..이후 VBO 설정 시작

 

이제 작성하는 코드에서 그리기 영역에서 원하는 VAO를 바인딩하는 것으로, 해당 VAO에 속한 모든 정점 속성들을 불러와 사용할 수가 있다. VAO를 구성할 때와 마찬가지로 그리기 영역에서도 glBindVertexArray 함수를 사용하여 바인딩한다.

 

4. 종합

드디어 삼각형을 그리기 위한 모든 과정이 끝이 났다. 아래의 코드는 삼각형을 출력하는 전체적인 코드이다. 렌더링 결과를 확인하기 위해서 GLFW의 도움을 받아 윈도우 객체를 생성하고 관리하도록 하였다.

#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <string>
#include <iostream>
#include <fstream>

GLuint renderingProgram;
unsigned int VBO;			// Vertex Buffer Object
unsigned int VAO;			// Vertex Array Object

using namespace std;

string readShaderSource(const char* filePath) {
	string content = "";
	ifstream fileStream(filePath, ios::in);
	string line = "";
	while (!fileStream.eof()) {
		getline(fileStream, line);
		content.append(line + "\n");
	}
	fileStream.close();
	return content;
}

GLuint createShaderProgram() {
	string vertShaderStr = readShaderSource("vertShader.glsl");
	string fragShaderStr = readShaderSource("fragShader.glsl");

	const char* vertShaderSrc = vertShaderStr.c_str();
	const char* fragShaderSrc = fragShaderStr.c_str();

	GLuint vShader = glCreateShader(GL_VERTEX_SHADER);
	GLuint fShader = glCreateShader(GL_FRAGMENT_SHADER);

	glShaderSource(vShader, 1, &vertShaderSrc, nullptr);
	glShaderSource(fShader, 1, &fragShaderSrc, nullptr);
	glCompileShader(vShader);
	glCompileShader(fShader);

	GLuint vfProgram = glCreateProgram();
	glAttachShader(vfProgram, vShader);
	glAttachShader(vfProgram, fShader);
	glLinkProgram(vfProgram);

	return vfProgram;
}

// callback 함수
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
	glViewport(0, 0, width, height);
}

void processInput(GLFWwindow* window) 
{
	if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
	{
		std::cout << "Pressed ESC" << std::endl;
		glfwSetWindowShouldClose(window, true);
	}
}

// vertex 정의
float vertices[] = {
	-0.5f, -0.5f, 0.0f,
	 0.5f, -0.5f, 0.0f,
	 0.0f,  0.5f, 0.0f
};

int main() {
	// GLFW 초기화
	glfwInit();
	// GLFW 설정
	glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
	glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
	glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

	// 윈도우 객체 생성, 모든 window 데이터를 보유
	GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
	if (window == NULL) 
	{
		std::cout << "Failed to create GLFW window" << std::endl;
		glfwTerminate();
		return -1;
	}
	glfwMakeContextCurrent(window);
	glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);

	// GLAD가 OpenGL 함수 포인터를 관리
	if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
	{
		std::cout << "Failed to initialize GLAD" << std::endl;
		return -1;
	}

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

	// GLFW가 종료되도록 지시되었는지 확인
	// Swap buffer를 사용하여 back 버퍼에 렌더링 명령이 완료되면, 
	// 최종 색상이 담긴 front 버퍼로, swap하여 이미지가 깜박이는 문제 방지
	// glClearColor, glClear : 컬러 버퍼를 지움, 이때 지우고자 하는 색을 설정
	while (!glfwWindowShouldClose(window)) {
		// 입력 감지
		processInput(window);

		// 렌더링 명령 ...
		glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
		glClear(GL_COLOR_BUFFER_BIT);
		
		renderingProgram = createShaderProgram();
 		// 셰이더 활성화
		glUseProgram(renderingProgram);			
		glBindVertexArray(VAO);
		// 바인딩된 VAO에 의해 간접적으로 VBO의 정보를 불러와 그리기 명령을 수행한다.
		glDrawArrays(GL_TRIANGLES, 0, 3);

		// 렌더링 명령 ...

		glfwSwapBuffers(window);
		glfwPollEvents();
	}

	glfwTerminate();
	return 0;
}

 

출력 결과

 


OpenGL에 대해 좀 더 심도있게 배우고자 이번 방학 때 학습 목표로 잡게 되었다. 아주 기초적인 내용은 아니지만 OpenGL을 학습하기 좋은 사이트를 발견하게 되어서, 하나하나씩 따라가볼 계획이다. 이번 장에서는 특히 정점 데이터와 관련해서 그래픽 파이프라인에서 어떻게 다루어지고 처리되는지, 또한 전반적인 파이프라인 과정에 대해 어느정도 이해가 된 것 같다.

'컴퓨터그래픽스' 카테고리의 다른 글

[OpenGL 공부] Transformations  (0) 2024.01.20
[OpenGL 공부] Textures (exercises)  (0) 2024.01.16
[OpenGL 공부] Textures (2)  (1) 2024.01.15
[OpenGL 공부] Textures (1)  (1) 2024.01.13
[OpenGL 공부] Shader  (0) 2024.01.13