[OpenGL] 삼각형 그리기, EBO

김가은·2026년 2월 24일

OpenGL

목록 보기
3/6

삼각형 그리기

이전 게시물에서 설명하였던 그래픽 파이프라인과 VBO, VAO를 참조하여 삼각형 두 개를 그려보자. 삼각형 두 개가 겹쳐진 형태로, 사각형처럼 보이는 삼각형 두 개를 그려볼 것이다.

그리고 EBO에 대해 배우고 활용해보자!!!


RawModel

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

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

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());
    
}

DisplayerManager

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

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

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
             );

EBO 적용하여 삼각형 그리기

Loader

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);가 호출되어야 한다.

Renderer

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 함수를 작성한다.

MainGameLoop

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++ 실력으로, 비교적 간단한 내용이었지만 무척이나 헷갈리고 이해하는 데 시간이 많이 걸렸다. 그래도 지하철이나 잠자기 전이나 틈틈히 눈에 익히려 노력했다.

가장 간단한 도형 그리기를 성공하였으니 이제 쭉쭉 진도나가 배워나갈 시간이닷~~~!!

0개의 댓글