본문 바로가기
잡(job)기술/opengl

OpenGL #06 — VBO·VAO·EBO로 사각형 그리기: 인덱스 버퍼와 정점 재사용

by 무니이구나 2026. 8. 24.

🎯 이 글에서 얻을 수 있는 것

  • 사각형이 왜 삼각형 2개인지, 그리고 삼각형 2개를 그릴 때 정점이 왜 중복되는지 이해한다.
  • 인덱스 버퍼(EBO)가 정점 재사용을 통해 메모리/대역폭/정점 변환 비용을 어떻게 줄이는지 설명할 수 있다.
  • VAO·VBO·EBO의 생성 → 바인딩 → 데이터 업로드 → 드로우 순서를 직접 코드로 구현한다.
  • glDrawElements로 인덱스를 사용해 사각형을 렌더링한다.
  • 바인딩 순서 실수(EBO 언바인딩, VAO 미바인딩 등)로 생기는 검은 화면/반쪽 화면을 스스로 디버깅할 수 있다.

📸 결과물 미리보기

네 모서리가 각각 빨강·초록·파랑·노랑인 사각형이 화면 중앙에 그려지고, 내부는 정점 색이 보간되어 부드럽게 섞입니다. 와이어프레임 모드로 전환하면 사각형이 대각선으로 나뉜 삼각형 2개라는 사실이 눈에 보입니다. 이번 편의 핵심은 "보기 좋은 사각형"이 아니라, 그 사각형을 6정점이 아닌 4정점 + 인덱스 6개로 만드는 구조입니다.

🧠 개념 설명

1. 사각형은 삼각형 2개다

GPU가 조립할 수 있는 프리미티브는 점·선·삼각형뿐입니다. 사각형은 볼록 다각형이므로 대각선을 하나 그어 삼각형 2개로 나눠야 합니다.

2.단순한 방식의 문제: 정점 중복

glDrawArrays 방식으로 삼각형 2개를 나란히 늘어놓으면 정점이 6개 필요합니다. 그런데 그림을 보면 대각선 양끝의 두 모서리는 두 삼각형이 공유합니다. 즉 6개 정점 중 2개는 완전한 중복 사본입니다.

 

사각형 하나야 별것 아니지만,.mesh가 커지는 순간 이야기가 달라집니다. 간단한 추정치를 계산해 봅시다. 쿼드 100×100 그리드(삼각형 20,000개)를 생각해보면:

방식 정점 수 인덱스 수 대략적인 크기 (정점 32B, 인덱스 4B 가정)
인덱스 없음 60,000 - 약 1.92 MB
인덱스 있음 101×101 = 10,201 60,000 약 326 KB + 240 KB ≈ 0.57 MB

 

전체 메모리 사용량이 약 3.4배(정점 수 기준 약 5.9배) 줄어듭니다. 게다가 이건 최소 속성만 가정한 것이고, 실제 정점에는 법선·UV·본 가중치 등이 붙어 정점 하나가 수십~수백 바이트가 되므로 격차는 훨씬 더 벌어집니다. 메모리만의 문제도 아닙니다(정점 셰이더 캐시 재사용 등 성능 이점 포함). .

  • 대역폭: GPU는 매 프레임 이 데이터를 메모리에서 읽어와야 합니다. 중복 정점은 중복 전송입니다.
  • 정점 변환 중복: 인덱스 드로우를 쓰면 GPU는 최근에 변환한 정점 결과를 작은 캐시(post-transform vertex cache)에 보관해 재사용하므로, 꼭짓점 하나를 위해 정점 셰이더를 여러 번 돌리는 일이 줄어듭니다.
  • 일관성: 중복 사본을 저장하면 "왼쪽 아래 모서리의 색을 바꾸기" 같은 수정에서 사본을 전부 찾아 고쳐야 합니다. 공유 정점은 한 곳만 수정하면 됩니다.

이 중복을 제거하는 장치가 인덱스 버퍼, OpenGL 용어로는 EBO(Element Buffer Object) 입니다. Direct3D에서는 그대로 index buffer라고 부릅니다.

3. VBO / EBO / VAO 한 줄 정의

객체 정식 이름 저장하는 것 바인딩 방법
VBO Vertex Buffer Object 정점 데이 터(위치, 색 등) glBindBuffer(GL_ARRAY_BUFFER, ...)
EBO Element Buffer Object 인덱스 배열 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ...)
VAO Vertex Array Object 정점 입력 상태(어트리뷰트 포인터 설정 + VBO/EBO 참조) glBindVertexArray(...)

비유하자면:

  • VBO = 부품 창고. 정점이라는 부품을 번호(오프셋)대로 진열해 둔 GPU 메모리.
  • EBO = 조립 지시서. "0번, 1번, 2번 부품으로 한 면을 만들고, 0번, 2번, 3번으로 다음 면을 만들어라."
  • VAO = 작업 안내판. 어떤 창고(VBO)에서, 어떤 간격(stride)으로, 어떤 속성(offset/location)을 읽고, 어떤 지시서(EBO)를 쓸지 미리 기록해 둔 프리셋. 드로우할 때는 안내판(VAO) 하나만 바인딩하면 셋업이 끝납니다.

4. 파이프라인에서의 위치

VAO/VBO/EBO는 셰이더 코드가 아니라, 정점 셰이더 앞단의 "정점 입력(vertex fetch / input assembly)" 단계에 해당합니다. 흐름은 이렇습니다.

 

즉 드로우 콜 하나가 "얼마나(6개 인덱스), 어떤 타입으로(GL_UNSIGNED_INT), 어디서(EBO) 읽어와서 삼각형을 조립할지"를 결정합니다. 여기서 glDrawElements의 두 번째 인자 count는 정점 개수(4)가 아니라 인덱스 개수(6) 라는 점을 꼭 기억하세요.

🛠️ 구현 준비

이전 편(01~05)에서 GLFW 윈도우 생성, GLAD 로딩, 셰이더 컴파일 파이프라인은 이미 갖췄다고 가정합니다. 이번 편에서 추가/수정할 파일은 다음과 같습니다.

project/
├── CMakeLists.txt          # 수정 없음 (기존 스켈레톤 재사용)
├── shaders/
│   ├── rectangle.vert      # 신규
│   └── rectangle.frag      # 신규
└── src/
    └── main.cpp            # 수정 (버퍼/VAO/EBO 셋업 + 드로우)
  • 라이브러리: GLFW, GLAD. (GLM, stb_image는 이번 편에서 사용하지 않습니다. 변환 행렬이 없으므로 곱셈 순서 문제도 아직 없습니다.)
  • OpenGL 3.3 Core Profile 기준이며, Core Profile에서는 기본 VAO(0번)가 존재하지 않으므로 VAO를 직접 생성해 바인딩하지 않으면 드로우가 실패합니다.

💻 코드 구현

1단계 — GLSL 셰이더 (C++ 코드와 분리)

정점 셰이더는 위치와 색을 받아 그대로 넘기고, 프래그먼트 셰이더는 보간된 색을 출력합니다.

// shaders/rectangle.vert
#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;

out vec3 vColor;

void main()
{
    // z=0, w=1 → NDC 좌표를 그대로 사용 (행렬 변환은 이후 편에서 도입)
    gl_Position = vec4(aPos, 1.0);
    vColor = aColor; // 프래그먼트로 넘어가며 픽셀 단위로 선형 보간됨
}
// shaders/rectangle.frag
#version 330 core

in vec3 vColor;
out vec4 fragColor;

void main()
{
    fragColor = vec4(vColor, 1.0);
}

2단계 — main.cpp 전반부: 디버그 헬퍼와 초기화

아래 블록들을 순서대로 이어 붙이면 완전한 src/main.cpp 가 됩니다.

// src/main.cpp
#include <glad/glad.h>   // GL 헤더 충돌을 피하려면 GLFW보다 먼저 포함
#include <GLFW/glfw3.h>

#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <string>

// ----------------------------------------------------------
// 디버깅 헬퍼
// ----------------------------------------------------------

// glGetError()는 큐처럼 쌓인 오류를 하나씩 반환하므로,
// GL_NO_ERROR가 나올 때까지 루프를 돌려야 전부 배수됩니다.
static void checkGLError(const char* file, int line)
{
    for (GLenum err = glGetError(); err != GL_NO_ERROR; err = glGetError())
    {
        const char* name = "UNKNOWN";
        switch (err)
        {
        case GL_INVALID_ENUM:                  name = "GL_INVALID_ENUM"; break;
        case GL_INVALID_VALUE:                 name = "GL_INVALID_VALUE"; break;
        case GL_INVALID_OPERATION:             name = "GL_INVALID_OPERATION"; break;
        case GL_OUT_OF_MEMORY:                 name = "GL_OUT_OF_MEMORY"; break;
        case GL_INVALID_FRAMEBUFFER_OPERATION: name = "GL_INVALID_FRAMEBUFFER_OPERATION"; break;
        default: break;
        }
        std::fprintf(stderr, "[GL ERROR] %s:%d %s (0x%04X)\n", file, line, name, err);
    }
}
#define GL_CHECK() checkGLError(__FILE__, __LINE__)

static std::string readFile(const std::string& path)
{
    std::ifstream in(path);
    if (!in.is_open())
    {
        std::fprintf(stderr, "셰이더 파일을 열 수 없습니다: %s\n", path.c_str());
        std::exit(EXIT_FAILURE);
    }
    std::stringstream ss;
    ss << in.rdbuf();
    return ss.str();
}

static GLuint compileShader(GLenum type, const std::string& source)
{
    GLuint shader = glCreateShader(type);
    const char* src = source.c_str();
    glShaderSource(shader, 1, &src, nullptr);
    glCompileShader(shader);

    GLint compiled = GL_FALSE;
    glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
    if (compiled != GL_TRUE)
    {
        // 왜 필요한가: 로그 없이는 셰이더 실패 원인을 알 방법이 없다
        char log[1024] = {};
        glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
        std::fprintf(stderr, "[SHADER COMPILE ERROR]\n%s\n", log);
        std::exit(EXIT_FAILURE);
    }
    return shader;
}

static GLuint linkProgram(GLuint vs, GLuint fs)
{
    GLuint program = glCreateProgram();
    glAttachShader(program, vs);
    glAttachShader(program, fs);
    glLinkProgram(program);

    GLint linked = GL_FALSE;
    glGetProgramiv(program, GL_LINK_STATUS, &linked);
    if (linked != GL_TRUE)
    {
        char log[1024] = {};
        glGetProgramInfoLog(program, sizeof(log), nullptr, log);
        std::fprintf(stderr, "[PROGRAM LINK ERROR]\n%s\n", log);
        std::exit(EXIT_FAILURE);
    }
    glDeleteShader(vs); // 링크로 프로그램에 복사되었으므로 이제 삭제해도 안전
    glDeleteShader(fs);
    return program;
}

// 윈도우(프레임버퍼) 크기가 바뀌면 뷰포트도 다시 설정해야 합니다.
// 안 하면 리사이즈 후 화면이 늘어나거나 한구석에 찍힙니다.
static void framebufferSizeCallback(GLFWwindow* window, int width, int height)
{
    glViewport(0, 0, width, height);
}
int main()
{
    // ---- 1) 윈도우와 컨텍스트 (이전 편과 동일) ----
    if (glfwInit() != GLFW_TRUE)
    {
        std::fprintf(stderr, "GLFW 초기화 실패\n");
        return EXIT_FAILURE;
    }

    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_CAP, GLFW_TRUE); // macOS는 forward-cap 필요
#endif

    GLFWwindow* window = glfwCreateWindow(960, 720,
        "Episode 06 | Indexed Rectangle", nullptr, nullptr);
    if (window == nullptr)
    {
        std::fprintf(stderr, "GLFW 윈도우 생성 실패\n");
        glfwTerminate();
        return EXIT_FAILURE;
    }
    glfwMakeContextCurrent(window);

    if (gladLoadGLLoader(reinterpret_cast<GLADloadproc>(glfwGetProcAddress)) == 0)
    {
        std::fprintf(stderr, "GLAD 로드 실패\n");
        return EXIT_FAILURE;
    }

    // 최초 뷰포트도 현재 프레임버퍼 크기로 한 번 설정
    {
        int w = 0, h = 0;
        glfwGetFramebufferSize(window, &w, &h);
        glViewport(0, 0, w, h);
    }
    glfwSetFramebufferSizeCallback(window, framebufferSizeCallback);

    // ---- 2) 셰이더 ----
    GLuint vs = compileShader(GL_VERTEX_SHADER,   readFile("shaders/rectangle.vert"));
    GLuint fs = compileShader(GL_FRAGMENT_SHADER, readFile("shaders/rectangle.frag"));
    GLuint program = linkProgram(vs, fs);

3단계 — 정점 데이터와 VAO/VBO/EBO 셋업

이번 편의 심장부입니다. 순서가 곧 스펙이라고 생각하고 읽으세요.

    // ---- 3) 정점 데이터: 중복을 제거한 고유 정점 4개 ----
    // 정점 1개 = position(x,y,z) + color(r,g,b) = float 6개
    const float vertices[] = {
        // position           color
        -0.5f, -0.5f, 0.0f,   1.0f, 0.0f, 0.0f,  // 0: 왼쪽 아래
         0.5f, -0.5f, 0.0f,   0.0f, 1.0f, 0.0f,  // 1: 오른쪽 아래
         0.5f,  0.5f, 0.0f,   0.0f, 0.0f, 1.0f,  // 2: 오른쪽 위
        -0.5f,  0.5f, 0.0f,   1.0f, 1.0f, 0.0f,  // 3: 왼쪽 위
    };
    // 조립 지시서: 어떤 정점을 어떤 순서로 삼각형으로 만들지
    // (반시계 방향 = 앞면. 2와 3이 두 삼각형에서 재사용됨)
    const unsigned int indices[] = {
        0, 1, 2,   // 첫 번째 삼각형
        0, 2, 3,   // 두 번째 삼각형
    };

    // ---- 4) 객체 생성 → 바인딩 → 업로드 순서 엄수 ----
    GLuint vao = 0, vbo = 0, ebo = 0;
    glGenVertexArrays(1, &vao);
    glGenBuffers(1, &vbo);
    glGenBuffers(1, &ebo);

    // 핵심 규칙: VAO를 "먼저" 바인딩하고, 그 안에서 모든 셋업을 한다.
    // VBO/EBO 바인딩과 어트리뷰트 설정이 현재 바인딩된 VAO에 기록되기 때문.
    glBindVertexArray(vao);

    // VBO: 정점 데이터를 GPU 메모리로 업로드
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    // EBO: 인덱스 데이터를 GPU 메모리로 업로드
    // 왜 VAO 바인딩 직후에 하는가: GL_ELEMENT_ARRAY_BUFFER 바인딩 자체는
    // "현재 VAO의 상태"로 저장된다. VAO 밖에서 바인딩하면 VAO가 기억하지 못한다.
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // 정점 포맷 해석법 등록 (stride = float 6개 = 24바이트)
    // location 0 = position: 오프셋 0에서 float 3개
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float),
                          reinterpret_cast<void*>(0));
    glEnableVertexAttribArray(0); // 등록만 하고 활성화하지 않으면 데이터가 안 넘어감

    // location 1 = color: float 3개(12바이트) 지난 위치에서 float 3개
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float),
                          reinterpret_cast<void*>(3 * sizeof(float)));
    glEnableVertexAttribArray(1);

    // glVertexAttribPointer는 호출 시점의 GL_ARRAY_BUFFER(vbo)를 이미 기억했다.
    // 따라서 이제 VBO를 풀어줘도 안전하다.
    glBindBuffer(GL_ARRAY_BUFFER, 0);

    // ⚠️ 여기서 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0) 하면 안 된다!
    // EBO 바인딩은 VAO 상태의 일부라서, VAO가 바인딩된 채로 0을 바인딩하면
    // VAO가 인덱스 버퍼를 잃어버린다. 셋업 마무리로는 VAO만 언바인딩한다.
    glBindVertexArray(0);
    GL_CHECK();

 

정리하면 상태 기록의 주체가 다릅니다.

  • GL_ARRAY_BUFFER 바인딩은 VAO에 기록되지 않고, glVertexAttribPointer 호출 순간 해당 VBO 참조가 VAO에 복사됩니다. → 나중에 풀어줘도 안전.
  • GL_ELEMENT_ARRAY_BUFFER 바인딩은 VAO 상태에 그대로 저장됩니다. → VAO가 바인딩된 동안에는 함부로 풀면 안 됨.

4단계 — 드로우와 렌더 루프, 정리

    // ---- 5) 렌더 루프 ----
    while (!glfwWindowShouldClose(window))
    {
        glfwPollEvents();

        glClearColor(0.15f, 0.15f, 0.17f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        glUseProgram(program);    // 어떤 셰이더로 그릴지
        glBindVertexArray(vao);   // 어떤 데이터로 그릴지 (VBO+EBO+포맷이 한 번에 복원)

        // 인덱스 6개 = 삼각형 2개. 마지막 인자는 EBO 내 오프셋(0 = 처음부터).
        // 정점 배열 포인터가 아니라 "오프셋"이라는 점이 glDrawArrays와의 차이.
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
        GL_CHECK(); // 개발 중에는 드로우 직후 오류 확인을 습관화

        glBindVertexArray(0);
        glfwSwapBuffers(window);
    }

    // ---- 6) 자원 정리 ----
    glDeleteProgram(program);
    glDeleteBuffers(1, &vbo);
    glDeleteBuffers(1, &ebo);
    glDeleteVertexArrays(1, &vao);

    glfwTerminate();
    return EXIT_SUCCESS;
}


GL_CHECK()는 개발 빌드에서 의심스러운 호출 직후에 끼워 넣는 용도로 쓰면 됩니다. 오류가 발생 호출 "이후"의 첫 체크에서 잡히므로, 이분법적으로 위치를 옮겨가면 범인을 빠르게 특정할 수 있습니다.

✅ 실행 방법

CMakeLists.txt는 이전 편 스켈레톤 그대로입니다. 실행 파일 위치에서 실행하더라도 셰이더를 찾을 수 있도록 복사 라인만 추가했습니다.

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(modern-gl-renderer LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(renderer
    src/main.cpp
    third_party/glad/src/glad.c
)
target_include_directories(renderer PRIVATE third_party/glad/include)

find_package(glfw3 REQUIRED)
target_link_libraries(renderer PRIVATE glfw)

# 빌드 디렉터리에서 실행해도 셰이더 경리가 resolves 되도록 복사
file(COPY ${CMAKE_SOURCE_DIR}/shaders DESTINATION ${CMAKE_BINARY_DIR})

빌드와 실행:

cmake -B build
cmake --build build
./build/renderer          # Linux/macOS
# Windows: build\Debug\renderer.exe (또는 빌드 디렉터리에서 renderer.exe)

 

셰이더 경로는 실행 디렉터리 기준 상대 경로이므로, 프로젝트 루트나 빌드 디렉터리처럼 shaders/ 폴더가 보이는 위치에서 실행하세요. 모든 것이 정상이라면 콘솔에는 아무 오류도 출력되지 않고, 네 모서리가 서로 다른 색으로 보간된 사각형이 보입니다.

⚠️ 흔한 실수와 디버깅 체크리스트

  • 검은 화면: glUseProgramglBindVertexArray를 드로우 전에 호출했는가? Core Profile에서는 VAO 0번이 존재하지 않으므로, VAO를 안 만들고 그리면 GL_INVALID_OPERATION이 난다.
  • 삼각형 하나만 보임: glDrawElements의 count가 6인가? 실수로 glDrawArrays(GL_TRIANGLES, 0, 4)를 쓰면 정점 4개 = 삼각형 1개 + 버려지는 정점 1개만 처리되어 사각형의 절반만 보인다.
  • 반쪽만 보이고 색이 이상함: 인덱스 배열 값이 정점 번호(0~3)를 벗어나지는 않았는가? 인덱스 오타는 멀쩡해 보이는 데이터에서 기묘한 삼각형을 만든다.
  • 색/위치가 깨져 보임: glVertexAttribPointer의 stride(6 * sizeof(float))와 offset(0, 3 * sizeof(float))이 인터리브 구조와 일치하는가? glEnableVertexAttribArray를 빼먹지는 않았는가?
  • 인덱스 타입 불일치: C++ 쪽은 unsigned int인데 glDrawElementsGL_UNSIGNED_INT를 넘겼는가? int 배열에 GL_UNSIGNED_INT를 쓰면 메모리가 잘못 해석된다.
  • EBO 언바인딩 순서: 셋업 마무리에서 glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0)을 VAO 언바인딩 "전에" 호출해 VAO가 인덱스 버퍼를 잃지 않았는가? 이 실수는 셋업 코드가 멀쩡해 보여서 더 찾기 어렵다.
  • 리사이즈 후 화면이 늘어남: glfwSetFramebufferSizeCallback에서 glViewport(0, 0, width, height)를 호출하는가?
  • 셰이더 오류: 컴파일 실패 시 glGetShaderInfoLog, 링크 실패 시 glGetProgramInfoLog가 콘솔에 찍히는가? (이 글의 헬퍼가 포함되어 있어야 한다.)
  • 오류 확인 습관: 의심스러운 호출 뒤에 GL_CHECK()를 넣었을 때 GL_INVALID_OPERATION 등이 잡히지 않는가? 더 깊이 파고 싶다면 RenderDoc으로 드로우 콜을 캡처해 VAO 상태를 눈으로 확인하는 것이 다음 단계다.

🧪 연습 문제

  1. (개념 확인 / 필수) 정점 2번의 색을 (1, 1, 1) 하양으로 바꿔 실행한다. 대각선 위쪽과 아래쪽 삼각형 양쪽의 오른쪽 위 모서리가 동시에 하얘지는 것을 확인하고, 이것이 "정점 2를 두 삼각형이 공유한다"는 뜻임을 설명해 보자. 이어서 glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)을 추가해 와이어프레임으로 두 삼각형을 눈으로 확인하자.
  2. (개념 확인) 같은 VBO를 GL_TRIANGLE_STRIP과 인덱스 {0, 1, 3, 2}, count 4로 그려보자. 스트립은 (v0,v1,v2), (v1,v2,v3)처럼 연속된 3인덱스로 삼각형을 만들며 앞면 판정을 위해 홀수 번째 삼각형의 winding을 뒤집어 해석합니다. 왜 인덱스 순서가 {0,1,2,3}이 아니라 {0,1,3,2}이어야 하는지 삼각형 커버리지로 설명해 보자.
  3. (응용 확장) 정점 배열에 꼭짓점 4: (0.0, 0.9, 0.0)을 추가하고, 인덱스에 지붕 삼각형 {3, 2, 4}를 추가해 "집" 모양을 만들어보자. 정점은 1개만 늘었는데 도형은 삼각형 1개만큼 자라는 것이 인덱스 드로우의 이점임을 직접 체험하는 문제다.

🔭 다음 편 예고

사각형은 그렸지만, 아직 이 사각형은 "사진"을 입지 못했습니다. 07편에서는 stb_image로 이미지를 읽어 이 사각형 위에 텍스처로 감쌉니다. UV 좌표가 왜 0~1인지, 왜 로드한 이미지는 늘 뒤집혀 보이는지가 자연스럽게 풀리는 편이 될 것입니다.

📚 참고 자료