들어가며
컴퓨터 그래픽스는 입문 장벽이 높은 영역 중 하나라고 생각한다. 이는 해당 도메인의 난이도 자체의 문제도 있지만, 한글로 된 자료가 부족하고, 선형 대수 연산의 기하학적인 감을 어느 정도는 잡고 있어야 하기 때문인 것 같다. 또한 코드를 작성할 떄도 managed 언어로 입문하거나, 이에 더 적응이 많이 되어있는 사람들에게는 C++이라는 언어 자체도 입문 장벽을 높이는데 기여를 꽤나 하게 된다. 여러 LLM 서비스들이 활성화되어 학습에 매우 도움이 되지만, 그래도 레퍼런스로 잡고갈만한 학습 자료는 매우 중요하다. 이에 대한 갈증을 Rinthel Kwon 교수님께서 해소시켜 주셨으며, 이에 대해 감사인사를 포스팅을 작성하며 남긴다. VScode 기반에서 CMake로 C++ 프로젝트를 운용하고 컴퓨터 그래픽스를 실습할 수 있는 레퍼런스는 매우 양질의 자료라고 생각하고, 해당 course note는 절반정도 수강하였다. 여러 번 코드를 따라치며 복습해도 기억에 남지 않아, Learn OpenGL의 설명과 Rinthel Kwon 교수님의 영상의 내용을 복습하며 공부 차원에서 포스팅을 남기며, 다시 한 번 좋은 자료를 무료로 풀어주신 Rinthel Kwon 교수님께 감사 인사를 남기고 시작한다. (추후 다른 포스팅으로 OpenGL의 내용을 추가적으로 작성할 수 있다. 틀린 부분 존재 시 알려주시면 감사드리겠습니다.)
삼각형 렌더링
OpenGL을 사용하기 위한 기본적인 준비
OpenGL을 통해 삼각형을 렌더링하려면 최소 2가지의 라이브러리가 필요하다. 첫 번째는 GLFW로, 윈도우 창을 생성, OpenGL 컨텍스트 관리 입력, 이벤트 핸들링를 하는 라이브러리이다.
두 번째는 GLAD로 OpenGL의 실제 함수 포인터를 가져오는 역할을 수행한다. OpenGL은 사실 라이브러리가 아니라 사양서(Specification)이라고 하는데, 크로노스 그룹에서 OpenGL의 표준 사양서를 제작하지만, 직접 구현체를 제작하진 않는다. 직접 구현체를 제작하는 곳은 GPU의 제조사들이며 각 제조사들은 해당 표준 사양서에 따라 동작이 될 수 있도록 실제 구현체를 만든다. 예를 들어, `nvoglv64.dll`와 같은 파일은 NVIDIA 드라이버안에 존재하는 동적 라이브러리 파일이며, OpenGL의 구현체가 존재한다.
이 상황에서 문제점은 함수들의 주소가 컴파일 타임에 고정되어있지 않다는 것으로 런타임 때 드라이버에서 직접 함수 포인터를 가져와야 한다. 이 역할을 GLAD가 수행한다.
GLFW를 통한 창 생성과 GLAD를 통해 불러온 함수를 통한 색상 변경
`while(!glfwWindowShouldClose(window))`로 인해 반복되는 코드로 `glfwSwapBuffers(window)`를 작성해놓았는데, OpenGL은 기본적으로 더블버퍼링을 사용하기에 작성하지 않는다면 윈도우가 정상 동작 하지 않음에 유의해야 한다.
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
constexpr int WIDTH = 640;
constexpr int HEIGHT = 480;
void framebuffersizeCallback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}
int main()
{
if (!glfwInit())
{
std::cerr << "[GLFW] error : init glfw" << std::endl;
return -1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(WIDTH, HEIGHT, "MyOpenGL", NULL, NULL);
if(!window)
{
std::cerr << "[GLFW] error : create window" << std::endl;
return -1;
}
glfwMakeContextCurrent(window); // OpenGL Context 설정
glfwSetFramebufferSizeCallback(window, framebuffersizeCallback);
glfwSwapInterval(1);
//창의 OpenGL 컨텍스트를 현재 컨텍스트로 설정한 뒤 OpenGL Context를 설정한 뒤 Glad를 로드해야 함
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cerr << "[GLAD] error : load gl" << std::endl;
return -1;
}
while(!glfwWindowShouldClose(window))
{
// OpenGL 함수 : glClearColor, glClear. gladLoadGLLoader를 호출하였기에 사용 가능.
glClearColor(0.2f, 0.2f, 0.2f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glfwSwapBuffers(window); // 더블 버퍼링이 디폴트이므로, 사용하지 않으면 창이 정상적으로 동작하지 않음
glfwPollEvents();
}
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
삼각형 렌더링을 위한 작업들과 렌더링 파이프라인의 간략한 설명
사용자가 작성해야하는 코드는 크게 두 가지다. 첫 번째는 드로우콜까지 호출할 수 있는 전반적인 `.c` 또는 `.cpp` 코드와 두 번째는 셰이더 코드이다. 필요한 내용들의 전체 구조를 그림으로 그려서 정리해보았다. 함수에 대한 자세한 설명은 Learn OpenGL을 참고하길 바란다. VAO와 VBO 설명도 매우 중요하니 숙지 필요하다.
필자도 완벽하진 않고, 틀릴 수 있지만 대략적으로 이렇게 알아두면 삼각형을 렌더링하는 코드를 작성하는데는 문제가 없다. GLFW를 통한 윈도우 객체 생성을 제외한 내용을 간략히 설명해보겠다.
셰이더
우선 Modern OpenGL 부터는 셰이더를 작성해야 렌더링 파이프라인이 동작하므로 무조건 작성을 해야한다. 셰이더(shader)란 GPU에서 실행되는 작은 프로그램으로 OpenGL을 위해 셰이더를 작성할 때는 GLSL이라는 C와 비슷한 언어로 작성한다. 셰이더를 통해 GPU의 렌더링 파이프라인의 특정 단계를 개발자가 직접 프로그래밍할 수 있게 해준다. 이는 GPU에서 실행되는 프로그램인만큼 병렬 실행이 핵심인데, Vertex Shader는 정점의 수만큼, Fragment Shader는 픽셀의 수만큼 수천 개의 코어를 가진 GPU에서 동시에 실행된다. 자세한 정보는 SIMT(Single Instruction Multiple Threads)을 찾아보면 될 것 같은데, 필자도 더 추가적인 공부가 필요하다.
어찌되었든, 각 정점 데이터를 처리하기 위한 Vertex shader를 작성하고, 픽셀 색상 결정을 위해 Fragment shader 를 작성해야 한다.
VAO/VBO Setup
실제 정점 데이터(정점의 위치, 색상 등)를 VBO(Vertex Buffer Object)에 담아 GPU 메모리에 올린다. 다만, VBO는 raw 데이터 덩어리라 GPU 혼자서는 이 데이터를 해석할 수 없다. 이 상황에서 VAO(Vertex Array Object)가 필요해지며 이를 통해 데이터를 어떻게 읽을지 (몇 바이트씩, 어떤 순서로) 해석 방법을 저장해둔다. Vertex Shader에 작성하게 될 `layout (location = 0)`과 같은 코드는 VAO의 해석 방법과 연결되어 실제 데이터가 셰이더 안으로 들어오게 된다.
- VAO가 저장하는 것은 "VBO에 데이터를 어떻게 읽을지"에 대한 상태를 저장한다. 이는 `glVertexAttribPointer`으로 지정한다.
- 드로우 콜 호출 시 그전에 VAO를 바인드해야하는데, GPU가 해당 VAO에 저장된 대로 VBO 데이터를 읽게 하기 위함이다.
드로우 콜과 렌더링 파이프라인
이 준비가 끝난 뒤 드로우 콜(`glDrawArrays`)을 호출하면 정점 데이터를 받아서 최종적으로 프레임버퍼에 픽셀을 기록하는 과정인 GPU의 렌더링 파이프라인이 시작된다. Vertex Shader가 각 정점을 처리하고, Primitive Assembly가 정점들을 도형으로 조립하고, Rasterization이 도형을 fragment(픽셀 후보)로 변환하고, Fragment Shader가 픽셀의 색상을 결정한 뒤, Test and Blending 과정을 거쳐 최종적으로 프레임버퍼에 기록된다. 마지막으로 C++ 코드에서 `glfwSwapBuffers()` 호출을 하면 화면에 렌더링이 수행된다.

삼각형을 렌더링할 수 있는 코드는 다음과 같다.
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include <fstream>
#include <istream>
#include <sstream>
constexpr int WIDTH = 640;
constexpr int HEIGHT = 480;
unsigned int compileShader(GLenum type, const char* path)
{
std::ifstream file(path);
if (!file.is_open())
{
std::cerr << "Shader 파일을 열 수 없음" << path << "\n";
return 0;
}
std::stringstream ss;
ss << file.rdbuf();
std::string src = ss.str();
const char* cSrc = src.c_str();
unsigned int shader = glCreateShader(type);
glShaderSource(shader, 1, &cSrc, NULL);
glCompileShader(shader);
int success;
char log[512];
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(shader, 512, NULL, log);
}
return shader;
}
void framebuffersizeCallback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}
int main()
{
// [1-1] 초기화 작업 : 윈도우 관련 컨텍스트 설정
if (!glfwInit())
{
std::cerr << "[GLFW] error : init glfw" << std::endl;
return -1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(WIDTH, HEIGHT, "MyOpenGL", NULL, NULL);
if(!window)
{
std::cerr << "[GLFW] error : create window" << std::endl;
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffersizeCallback);
glfwSwapInterval(1);
// [1-2] 초기화 작업 : OpenGL 함수 로드
//창의 OpenGL 컨텍스트를 현재 컨텍스트로 설정한 뒤 OpenGL Context를 설정한 뒤 Glad를 로드해야 함
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cerr << "[GLAD] error : load gl" << std::endl;
return -1;
}
// [1-3] 초기화 작업 : 셰이더 컴파일 & 링크
unsigned int vertexShader = compileShader(GL_VERTEX_SHADER, "shaders/basic.vert");
unsigned int fragmentShader = compileShader(GL_FRAGMENT_SHADER, "shaders/basic.frag");
unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
// [1-4] 초기화 작업 : VAO / VBO 설정
float vertices[] =
{
0.0f, 0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f
};
unsigned int vao, vbo;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
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, sizeof(float) * 3, 0);
glEnableVertexAttribArray(0);
// [2] Render loop
while(!glfwWindowShouldClose(window))
{
glClearColor(0.2f, 0.2f, 0.2f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
참고 자료
- https://learnopengl.com/Getting-started/Hello-Triangle
- https://www.youtube.com/watch?v=kEAKvJKnvfA&list=PLvNHCGtd4kh_cYLKMP_E-jwF3YKpDP4hf
'Computer Graphics > OpenGL' 카테고리의 다른 글
| [OpenGL] glPointSize()의 값 지정 (0) | 2025.12.30 |
|---|---|
| [OpenGL, SPDLOG] SPDLOG의 'formatting of non-void pointers is disallowed' (0) | 2025.04.22 |
