이전 게시물에서 설명하였던 그래픽 파이프라인과 VBO, VAO를 참조하여 삼각형 두 개를 그려보자. 삼각형 두 개가 겹쳐진 형태로, 사각형처럼 보이는 삼각형 두 개를 그려볼 것이다.
그리고 EBO에 대해 배우고 활용해보자!!!
RawModel.h
#ifndef RAW_MODEL_H
#define RAW_MODEL_H
#include <OpenGL/gl3.h> // GLuint 타입을 위해 포함
class RawModel {
private:
GLuint vaoID;
int vertexCount;
public:
RawModel(GLuint vaoID, int vertexCount)
: vaoID(vaoID), vertexCount(vertexCount) {}
// Renderer가 사용할 Getter 함수들
GLuint getVaoID() const { return vaoID; }
int getVertexCount() const { return vertexCount; }
};
#endif
우리가 그릴 도형의 vaoID와 정점 개수 등 렌더링에 필요한 최소한의 정보만을 가진 RawModel 클래스를 정의한다.
renderer에서 읽을 VaoID와 VertexCount를 위한 getter 함수를 정의한다.
Loader.h
#ifndef LOADER_H
#define LOADER_H
#include <GLFW/glfw3.h>
#include "RawModel.h"
#include <vector>
class Loader {
private:
std::vector<GLuint> vaos;
std::vector<GLuint> vbos;
// 내부 보조 함수들
GLuint createVAO();
void storeDataInAttributeList(
int attributeNumber,
int coordinateSize,
const std::vector<float>& data
);
void unbindVAO();
public:
Loader() = default; // 생성자
~Loader(); // 소멸자: 여기서 모든 메모리 청소
// const reference를 사용하여 불필요한 복사를 방지
RawModel loadToVAO(const std::vector<float>& positions);
};
#endif
Loader는 CPU에 있는 정점 데이터를 GPU 메모리(VAO/VBO)로 업로드하는 역할을 하며, 그 결과로 “이 VAO를 이렇게 그리면 된다”라는 정보 묶음(RawModel)을 반환한다.
std::vector<GLuint> vaos;
std::vector<GLuint> vbos;
-> Loader가 생성한 VAO/VBO의 ID를 저장해두는 컨테이너로,
Loader 객체가 소멸될 때 모든 OpenGL 리소스를 정리하기 위해 사용된다.
createVAO()
-> 새로운 VAO를 생성하고 바인딩한 뒤,
이후 설정되는 정점 속성 정보가 이 VAO에 기록되도록 한다.
storeDataInAttributeList()
현재 바인딩된 VAO에 대해
정점 데이터를 VBO로 생성하여 GPU에 업로드하고,
해당 데이터를 vertex attribute로 해석하는 방식을 정의한다.
-> VBO + attribute 설정이 VAO에 기록된다.
이 과정에서 실제 데이터는 VBO에 저장되고,
그 데이터를 어떻게 해석할지는 VAO에 기록된다.
loadToVAO()
정점 위치 데이터를 GPU(VBO)에 업로드하고,
그 데이터를 어떻게 사용할지 설정된 VAO를 만든 뒤,
렌더러가 사용할 VAO ID와 정점 개수를 RawModel로 묶어 반환한다.
unbindVAO()
VAO를 unbinding 한다.
Loader.cpp
#define GL_SILENCE_DEPRECATION
#include <GLFW/glfw3.h>
#include <OpenGL/gl3.h>
#include "Loader.h"
// 소멸자 구현: 객체가 소멸될 때 OpenGL 메모리를 자동으로 해제
Loader::~Loader() {
for (GLuint vao : vaos) {
glDeleteVertexArrays(1, &vao);
}
for (GLuint vbo : vbos) {
glDeleteBuffers(1, &vbo);
}
}
RawModel Loader::loadToVAO(const std::vector<float>& positions) {
GLuint vaoID = createVAO();
// 3D 좌표이므로 coordinateSize는 3
storeDataInAttributeList(0, 3, positions);
unbindVAO();
// C++: 객체를 바로 생성해서 반환
return RawModel(vaoID, static_cast<int>(positions.size()) / 3);
}
GLuint Loader::createVAO() {
GLuint vaoID;
glGenVertexArrays(1, &vaoID);
vaos.push_back(vaoID); // ArrayList.add()와 동일
glBindVertexArray(vaoID);
return vaoID;
}
void Loader::storeDataInAttributeList(int attributeNumber, int coordinateSize, const std::vector<float>& data) {
GLuint vboID;
glGenBuffers(1, &vboID);
vbos.push_back(vboID);
glBindBuffer(GL_ARRAY_BUFFER, vboID);
// std::vector의 데이터는 .data()를 통해 포인터로 접근 가능하다.
glBufferData(
GL_ARRAY_BUFFER,
data.size() * sizeof(float),
data.data(),
GL_STATIC_DRAW
);
glVertexAttribPointer(
attributeNumber,
coordinateSize,
GL_FLOAT,
GL_FALSE,
0,
(void*)0
);
glEnableVertexAttribArray(attributeNumber);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
void Loader::unbindVAO() {
glBindVertexArray(0);
}
glGenBuffers(...); // GPU에 데이터 박스(VBO) 하나 만들고
glBindBuffer(...); // 그 박스를 선택한 다음
glBufferData(...); // positions 데이터를 통째로 복사
glVertexAttribPointer(...)
데이터를 ‘어떻게 해석할지’ 알려주는 함수
VBO ↔ Vertex Shader 사이를 연결하는 설명서를 작성한다.
attributeNumber는 Vertex Shader의 location을 의미한다.
glEnableVertexAttribArray(attributeNumber);
glVertexAttribPointer는 연결 방법 설명이고
glEnableVertexAttribArray는 그 연결을 실제로 사용하겠다는 스위치다
glBindBuffer(GL_ARRAY_BUFFER, 0);
이제 어떤 VBO도 선택 안 된 상태를 의미하며, 실수를 방지한다.
glBindVertexArray(0);
VAO 설정이 끝났으니, 이제 기록을 중단할 것을 의미한다.
Renderer.h
#ifndef RENDERER_H
#define RENDERER_H
#define GL_SILENCE_DEPRECATION
#include <OpenGL/gl.h>
#include "RawModel.h"
class Renderer {
public:
Renderer() = default;
// 매 프레임 렌더링 전 화면을 지우는 함수
void prepare();
// 모델을 실제로 그리는 함수
void render(const RawModel& model);
};
#endif
Renderer는 모델을 화면에 렌더링하기 위한 클래스이다.
prepare()
매 프레임 렌더링 전 화면을 지우는 함수이다.
render(const RawModel& model)
모델을 실제로 그리는 함수이다.
Renderer.cpp
#include "Renderer.h"
void Renderer::prepare() {
// 배경색 설정
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
// 색상 버퍼를 지움
glClear(GL_COLOR_BUFFER_BIT);
}
void Renderer::render(const RawModel& model) {
// 모델의 VAO를 활성화
glBindVertexArray(model.getVaoID());
// 삼각형 그리기 명령
// glDrawArrays(모드, 시작 인덱스, 정점 개수)
glDrawArrays(GL_TRIANGLES, 0, model.getVertexCount());
}
DisplayManager.h
#ifndef DISPLAY_MANAGER_H
#define DISPLAY_MANAGER_H
#define GL_SILENCE_DEPRECATION
#include <GLFW/glfw3.h>
#include <string>
class DisplayManager {
private:
static GLFWwindow* window;
static const int WIDTH = 720;
static const int HEIGHT = 720;
static const int FPS_CAP = 60;
static const std::string TITLE;
public:
static void createDisplay();
static void updateDisplay();
static void closeDisplay();
static bool isCloseRequested(); // 루프 종료 조건 확인용 추가
};
#endif
모델을 출력할 윈도우창에 대해 정의한다.
DisplayManager.cpp
#include "DisplayManager.h"
#include <iostream>
// static 변수 초기화
GLFWwindow* DisplayManager::window = nullptr;
const std::string DisplayManager::TITLE = "Our First Display";
void DisplayManager::createDisplay() {
if (!glfwInit()) {
std::cerr << "GLFW 초기화 실패!" << std::endl;
return;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); // Mac 필수 설정
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
window = glfwCreateWindow(WIDTH, HEIGHT, TITLE.c_str(), NULL, NULL);
if (!window) {
std::cerr << "윈도우 생성 실패!" << std::endl;
glfwTerminate();
return;
}
glfwMakeContextCurrent(window);
// V-Sync 설정 (FPS 60 제한과 유사한 역할)
glfwSwapInterval(1);
// OpenGL 렌더링 영역 설정
// 실제 픽셀 크기를 가져와서 설정해야 합니다.
int width, height;
glfwGetFramebufferSize(window, &width, &height);
glViewport(0, 0, width, height);
}
void DisplayManager::updateDisplay() {
// 렌더링된 버퍼를 화면에 표시
glfwSwapBuffers(window);
// 키보드/마우스 입력 이벤트 처리
glfwPollEvents();
}
void DisplayManager::closeDisplay() {
glfwDestroyWindow(window);
glfwTerminate();
}
bool DisplayManager::isCloseRequested() {
return glfwWindowShouldClose(window);
}
MainGameLoop.cpp
#include "DisplayManager.h"
#include "Loader.h"
#include "Renderer.h"
#include "RawModel.h"
#include <vector>
// 1. 버텍스 셰이더: 점의 위치를 계산
const char* vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main() {\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";
// 2. 프래그먼트 셰이더: 픽셀의 색상을 결정 (주황색)
const char* fragmentShaderSource = "#version 330 core\n"
"out vec4 FragColor;\n"
"void main() {\n"
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\n\0";
int main() {
// 1. 디스플레이 생성 (창 띄우기)
DisplayManager::createDisplay();
// 2. 필요한 엔진 컴포넌트 생성
Loader loader;
Renderer renderer;
// 3. 사각형(Quad) 정점 데이터 정의
// 두 개의 삼각형이 합쳐져서 하나의 사각형
std::vector<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
};
// 4. 데이터를 VAO에 로드하여 RawModel 생성
RawModel model = loader.loadToVAO(vertices);
// 5. 게임 루프 (창을 닫기 전까지 무한 반복)
// 루프 시작 전, 사용할 셰이더 프로그램을 활성화한다.
// 매 프레임마다 바꿀 필요가 없다면 루프 밖에서 한 번만 호출해도 된다.
// --- 셰이더 컴파일 과정 ---
unsigned int vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
unsigned int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
unsigned int shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
glUseProgram(shaderProgram);
while (!DisplayManager::isCloseRequested()) {
// 1. 게임 로직 및 물리 업데이트 (CPU 연산)
// 예: 캐릭터 이동 계산, 충돌 체크 등
// 2. 화면 렌더링 준비 (배경색으로 지우기)
// 내부적으로 glClear(GL_COLOR_BUFFER_BIT)를 수행한다.
renderer.prepare();
// 3. 모델 그리기 (Draw Call 발생)
// 셰이더가 활성화된 상태에서 VAO를 바인딩하고 glDrawArrays를 호출한다.
renderer.render(model);
// 4. 버퍼 교체 및 이벤트 처리 (Double Buffering)
// Back Buffer에 그려진 그림을 Front Buffer로 Swap하고,
// 마우스/키보드 입력을 체크한다.
DisplayManager::updateDisplay();
}
// 6. 정리 (Clean Up)
// C++에서는 loader의 소멸자(~Loader)가 자동으로 메모리를 청소하므로
// 명시적인 loader.cleanUp() 호출이 없어도 안전하다.
DisplayManager::closeDisplay();
return 0;
}
위 스크립트를 실행하면, 
위와 같은 화면이 출력된다.
우리가 원하는 삼각형 두 개를 이용한 사각형 렌더링이 이루어졌지만, 아쉬운 점이 있다.
std::vector<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
};
정점 데이터에서 겹치는 정점이 두 개가 있다. 지금은 사각형 하나만을 그리기 때문에 중복이 2개 뿐이지만, 더 복잡한 모델을 그리게 된다면, 중복은 걷잡을 수 없이 커질 것이다!
따라서 우리는 중복 vertex를 없앨 EBO와 IBO에 대해 배우고, 똑같은 모델을 그리더라도 효율적인 프로그래밍을 할 것이다.
EBO(Element Buffer Object)는 정점 배열(VBO)에서 몇 번째 정점을 쓸지 순서만 저장하는 버퍼이다.
VBO:
[ A ][ B ][ C ][ D ]
EBO:
[ 0 ][ 1 ][ 2 ][ 2 ][ 1 ][ 3 ]
GPU:
→ VBO[0], VBO[1], VBO[2]
→ VBO[2], VBO[1], VBO[3]
방식으로 작동한다.
EBO를 binding하는 것은 VBO를 binding하는 방법과 비슷하다.
단, GL_ARRAY_BUFFER(VBO)는 전역 바인딩 상태이지만
GL_ELEMENT_ARRAY_BUFFER(EBO)는 현재 바인딩된 VAO에 귀속되는 상태이다.
VBO 정의
GLuint vboID;
glGenBuffers(1, &vboID);
vbos.push_back(vboID);
glBindBuffer(GL_ARRAY_BUFFER, vboID);
glBufferData(
GL_ARRAY_BUFFER,
data.size() * sizeof(float),
data.data(),
GL_STATIC_DRAW
);
glBindBuffer(GL_ARRAY_BUFFER, 0);
EBO 정의
unsigned int ebo;
glGenBuffers(1, &ebo);
vbos.push_back(ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(
GL_ELEMENT_ARRAY_BUFFER,
indices.size() * sizeof(unsigned int),
indices.data(),
GL_STATIC_DRAW
);
RawModel Loader::loadToVAO(const std::vector<float>& positions) {
GLuint vaoID = createVAO(); // VAO bind 보장
bindIndicesBuffer(indices); // bindIndicesBuffer는 VAO가 바인딩된 상태에서만 호출해야 한다
storeDataInAttributeList(0, 3, positions);
unbindVAO();
// C++: 객체를 바로 생성해서 반환
return RawModel(vaoID, static_cast<int>(indices.size()));
}
// EBO
void Loader::bindIndicesBuffer(const std::vector<unsigned int>& indices){
GLuint ebo;
glGenBuffers(1, &ebo);
vbos.push_back(ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
glBufferData(
GL_ELEMENT_ARRAY_BUFFER,
indices.size() * sizeof(unsigned int),
indices.data(),
GL_STATIC_DRAW
);
}
bindIndicesBuffer(...) 함수를 작성한다.
loadToVAO의 반환값 중 rawmodel의 vertex 개수에 해당하는 부분을 static_cast<int>(indices.size())로 수정한다.
EBO에는 VAO 안에 상태가 기록되어야 하므로 loadToVAO 함수 내부에서 VAO가 바인딩되어 있는 동안 bindIndicesBuffer(indices);가 호출되어야 한다.
void Renderer::render(const RawModel& model) {
// 모델의 VAO를 활성화
glBindVertexArray(model.getVaoID());
// 0번 어트리뷰트(위치 데이터) 사용을 선언
glEnableVertexAttribArray(0);
// 삼각형 그리기 명령
// glDrawArrays(GL_TRIANGLES, 0, model.getVertexCount());
glDrawElements(GL_TRIANGLES, model.getVertexCount(), GL_UNSIGNED_INT, 0);
// 사용이 끝난 어트리뷰트와 VAO를 언바인드(정리)
glDisableVertexAttribArray(0);
glBindVertexArray(0);
}
정점 배열이 아닌 인덱스 요소를 활용하여 도형을 그릴 것이기 때문에 glDrawArrays 대신 glDrawElements 함수를 작성한다.
std::vector<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,
};
std::vector<unsigned int> indices = {
0,1,3,
3,1,2
};
int main() {
...
// 4. 데이터를 VAO에 로드하여 RawModel 생성
RawModel model = loader.loadToVAO(vertices, indices);
...
}
vertices와 indices를 미리 적어두고,
loader.loadToVAO에 두번째 인자로 indices를 추가한다.

출력 결과, EBO를 활용하여 똑같이 삼각형 두 개를 그려내는 데 성공하였다! 같은 그림이지만 내부에서 쓰인 데이터는 더 적다!
익숙하지 않은 openGL 코드를 비롯하여 부족한 c++ 실력으로, 비교적 간단한 내용이었지만 무척이나 헷갈리고 이해하는 데 시간이 많이 걸렸다. 그래도 지하철이나 잠자기 전이나 틈틈히 눈에 익히려 노력했다.
가장 간단한 도형 그리기를 성공하였으니 이제 쭉쭉 진도나가 배워나갈 시간이닷~~~!!