[OpenGL] 텍스처 Texture

김가은·2026년 3월 3일

OpenGL

목록 보기
5/6
post-thumbnail

learnopengl.com, 막님의 learn OpenGL 쉬운 번역, ThinMatrix Youtube를 공부하며 작성한 글입니다.


이번 공부도 힘들었지만 다 하니까 무지 뿌듯하다. 여러 날에 걸쳐서 꾸준히 학습이 퇴적되다보니, 전에는 이해가 되지 않았던 부분도 오늘은 한 자라도 조금 더 이해가 된다. 앞으로도 지금처럼 꾸준히 쌓아올리고 싶다.
이렇게 하루하루 모여서 한달이 되고.. 일년이 되고 수년이 되고 가다보면 그래픽스 개발자가 될 수 있을 것이다....


Texture

앞서 각 정점마다 색을 추가하고 셰이딩을 하며 멋있는 이미지를 만들 수 있는 것을 배웠다.

그러나 정말 더 멋있거나 현실적인 표현을 얻으려면 많은 정점이 필요하고, 이 수많은 정점마다 색을 입히는 것은 많은 데이터를 필요로 하며 이것은 오버헤드로 이어진다..

그래서 아티스트와 프로그래머들이 일반적으로 선호하는 방법은 바로 Texture을 사용하는 것이다. 텍스처는 객체에 디테일을 추가하기 위해 사용되는 2D 이미지이다. (1D와 3D 텍스처도 존재한다.)

텍스처를 쉽게 예를 들면, 벽돌 이미지가 그려진 종이를 3D 집 위에 덮어씌워 집이 벽돌 외벽을 가진 것처럼 보이게 하는 것이다.
하나의 이미지에 많은 디테일을 담을 수 있기 때문에 정점을 추가하지 않더라도 객체가 정교해보이는 착시를 줄 수 있다.

(이미지 외에도 텍스처는 셰이더로 전달할 임의의 대량 데이터를 저장 하는 데에도 사용할 수 있다고 한다.)


삼각형에 텍스처를 매핑하기 위해서는 컴퓨터에게 삼각형의 각 정점이 텍스처의 어느 부분에 해당하는지를 알려주어야 한다. 따라서 각 정점에는 텍스처 이미지에서 샘플링할 위치를 지정하는 텍스처 좌표가 연관되어 있어야 한다.

정리하자면, 삼각형의 세 정점에 텍스처 이미지의 텍스처 좌표를 맞추면, 나머지 픽셀들은 프래그먼트 보간에 의해 자동처리가 된다!
위 그림의 (0,0), (0.5,1), (1,0)만 맞추면 나머지는 자동 처리되는 것이다.

텍스처 좌표는 x축과 y축 모두에서 0부터 1까지의 범위를 가진다.
텍스처 좌표를 사용하여 텍스처 색상을 가져오는 것샘플링이라고 한다.
텍스처 좌표는 텍스처 이미지의 왼쪽 아래 모서리가 (0,0), 오른쪽 위 모서리가 (1,1)이다.

float texCoords[] = {
    0.0f, 0.0f,  // 왼쪽 아래 모서리  
    1.0f, 0.0f,  // 오른쪽 아래 모서리
    0.5f, 1.0f   // 상단 중앙
};

이렇게 세 개의 텍스처 좌표를 Vertex shader에 전달하면, 이후 Fragment shader에서 모든 프래그먼트에 대해 텍스처 좌표가 보간된다.

적은 수의 좌표만을 넘기다보니 텍스처 샘플링은 느슨하게 여러 방식으로 수행될 수 있다.
따라서 텍스처를 구체적으로 어떻게 샘플링할지 OpenGL에게 알려주는 것은 프로그래머의 몫이다.


Texture Wrapping

위에서 텍스처 좌표는 보통 (0,0)에서 (1,1) 범위를 가진다고 했다.
만약 이 범위를 벗어난 좌표를 지정하면 어떻게 될까??!

OpenGL의 기본 동작은 텍스처 이미지를 반복하는 것이지만, 프로그래머의 지시에 따라 다른 옵션을 가질 수 있다.

  1. GL_REPEAT
    텍스처의 기본 동작. 텍스처 이미지를 반복한다.

  2. GL_MIRRORED_REPEAT
    GL_REPEAT과 동일하지만 반복될 때마다 이미지를 반전시킨다.

  3. GL_CLAMP_TO_EDGE
    좌표를 0과 1 사이로 제한한다. 그 결과 범위를 초과한 좌표는 가장자리로 고정되어, 가장자리가 늘어난 패턴이 된다.

  4. GL_CLAMP_TO_BORDER
    범위를 벗어난 좌표에 대해 사용자가 지정한 테두리 색상을 사용한다.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
  1. GL_TEXTURE_2D
    첫번째 인자는 텍스처 타겟을 지정한다.
    2D 텍스처를 사용하는 경우 GL_TEXTURE_2D를 사용한다.

  2. GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T
    두번째 인자는 어떤 옵션을 설정할지와 어떤 축에 적용할지를 지정한다.
    위 코드는 S, T축 모두 설정한 것이다.

  3. GL_MIRRORED_REPEAT
    마지막 인자는 사용할 텍스처 래핑 모드를 전달한다.
    위 코드는 현재 활성화된 텍스처에 대해 GL_MIRRORED_REPEAT 래핑 옵션을 설정한다.

    float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
    glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

    만약 GL_CLAMP_TO_BORDER를 선택하는 경우, 테두리 색상도 지정한다.


Texture Filtering

텍스처 좌표는 해상도에 의존하지 않고 어떤 부동소수점 값도 될 수 있다. 따라서 OpenGL은 해당 텍스처 좌표를 어떤 텍스처 픽셀(텍셀, texel)에 매핑할지 결정해야 한다.

쉽게 말해 좌표가 픽셀 딱 중간일 때 무슨 색을 표현할지 결정해야 하는 것이다. 이는 큰 객체에 낮은 해상도의 텍스처를 사용할 때 특히 중요하다. 이럴 때 사용하는 것이 texture filtering이다.

  1. GL_NEAREST(포인트 필터링이라고도 함)는 OpenGL의 기본 텍스처 필터링 방식이다.
    GL_NEAREST로 설정하면, OpenGL은 텍스처 좌표에 가장 가까운 중심을 가진 텍셀을 선택한다.
    텍셀 블록이 뚜렷하게 보여 레트로/픽셀 아트에 어울린다.
  1. GL_LINEAR은 텍스처 좌표 주변의 텍셀들로부터 보간된 값을 가져와, 텍셀 사이의 색상을 근사한다. 텍스처 좌표와 텍셀 중심 간 거리가 가까울수록 해당 텍셀의 색상이 더 많이 반영된다.
    픽셀이 덜 보이는 부드러운 패턴을 만든다. GL_NEAREST보다 비교적 현실적인 결과를 만든다. 대부분 3D 게임의 기본값이다.

Texture filtering은 확대와 축소 연산에 각각 설정할 수 있다.
예를 들어 텍스처를 축소할 때는 Linear filtering, 확대할 때는 nearest filtering을 선택할 수 있는 것이다. 따라서 glTexParameteri*를 통해 두 옵션을 모두 지정해야 한다.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

Mipmaps

멀리 있는 객체에도 가까이 있는 객체들과 동일한 고해상도 텍스처를 사용한다면 문제가 된다. 일단 프래그먼트 수는 적은데 고해상도 텍스처에서 올바른 색상 값을 가져오는 것에 어려움을 겪는다. 게다가 메모리 대역폭 낭비이기도 하며, 결과로도 작은 객체에서 눈에 띄는 아티팩트가 발생한다.

이를 해결하기 위해 OpenGL은 mipmaps라는 개념을 사용한다. mipmaps는 각 단계마다 이전 텍스처보다 해상도가 절반씩 줄어드는 텍스처 이미지들의 집합을 의미한다.

OpenGL은 화면에서 텍스처가 차지하는 픽셀 크기(텍셀 밀도)를 기준으로 가장 적절한 mipmap 레벨을 선택한다. 이로 인해 올바른 텍셀을 샘플링할 수 있으며, 텍스처 캐시 메모리 사용량도 줄일 수 있다.

텍스처를 생성한 뒤 glGenerateMipmap 호출 한 번으로 mipmap 텍스처들이 생성된다.

렌더링을 하는 도중에 mipmap 레벨을 변경하면 부자연스러운 아티팩트가 생길 수 있는데, 이때도 마찬가지로 필터링을 사용한다.
mipmap 레벨 간 필터링 방식을 지정하기 위해 기존 필터링 방법을 다음 중 하나로 교체할 수 있다.

  1. GL_NEAREST_MIPMAP_NEAREST
    픽셀 크기에 가장 가까운 mipmap을 선택하고, 텍스처 샘플링을 위해 근접 보간(nearest neighbor interpolation)을 사용한다.

  2. GL_LINEAR_MIPMAP_NEAREST
    가장 가까운 mipmap 레벨을 선택하고, 해당 레벨을 선형 보간(linear interpolation)으로 샘플링한다.

  3. GL_NEAREST_MIPMAP_LINEAR
    픽셀 크기에 가장 가까운 두 mipmap 을 선형 보간하여, 보간된 레벨을 근접 보간으로 샘플링한다.

  4. GL_LINEAR_MIPMAP_LINEAR
    가장 가까운 두 mipmap을 선형 보간하여, 보간된 레벨을 선형 보간으로 샘플링한다.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

한 가지 주의할 점은 mipmap 필터링 옵션을 magnification 필터로 설정할 필요가 없다는 것이다. 밉맵은 당연히 텍스처가 축소될 때 주로 사용되기 때문이다.
확대 필터에 밉맵 필터링 옵션을 추가한다면, GL_INVALID_ENUM 오류가 발생한다.


실습: Texturing

Loading and creating textures

텍스처는 파일이 아니라 GPU 메모리 객체이다.
이미지 파일을 CPU로 읽어오고, GPU 텍스처로 업로드하는 과정이 필요하다.

이때 CPU를 읽어오는 과정이 복잡한데, stb_image.h 라이브러리를 사용하여 쉽게 해결할 수 있다.

즉,
JPG / PNG를
stbi_load → CPU RAM으로 읽어오고,
glTexImage2D → GPU VRAM 텍스처로 업로드한다.

stb_image 라이브러리는 이미지 데이터를 CPU로 로드하는 이미지로더이다.

stb_image.h를 생성하여 stb_image 라이브러리 내용을 복붙하여 저장한다.
그리고 stb_image.cpp를 생성하여 아래 코드를 입력해 저장한다.

#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

이제 stb_image 라이브러리가 쓰이는 스크립트에 #include "stb_image.h"를 작성하면 해당 라이브러리 이미지로더를 사용할 수 있다!


Generating a texture

unsigned int textureID;
glGenTextures(1, &textureID);
  1. glGenTextures(1, &textureID)
    첫번째 인자로 생성할 텍스처 개수를 받고, 두번째 인자로 전달된 unsigned int 배열에 텍스처 ID를 저장한다.

glBindTexture(GL_TEXTURE_2D, textureID);
  1. glBindTexture(GL_TEXTURE_2D, textureID);
    다른 객체들과 마찬가지로, 이후의 텍스처 명령이 현재 텍스처를 설정하도록 바인딩한다.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
  1. glTexParameteri(...)
    필터링 옵션들을 적용한다.

int width, height, nrChannels;
unsigned char* data = stbi_load(filePath, &width, &height, &nrChannels, 0);
  1. unsigned char* data = stbi_load(filePath, &width, &height, &nrChannels, 0);
    stbi_image 라이브러리의 stbi_load 함수를 이용하여 텍스처를 로드한다.
    &nrChannels: 픽셀 하나당 채널 수로, 이미지 포맷에 따라 달라진다.(3-RGB, 4-RGBA)
    0: 원본 이미지 채널 수 그대로 로드하라는 뜻이다. (3: 강제로 RGB)

GLenum format = (nrChannels == 4) ? GL_RGBA : GL_RGB;

glTexImage2D(
            GL_TEXTURE_2D,
            0,
            format,
            width,
            height,
            0,
            format,
            GL_UNSIGNED_BYTE,
            data
        );
glGenerateMipmap(GL_TEXTURE_2D);
  1. glTexImage2D(...)
    • GL_TEXTURE_2D: 텍스처 타겟을 지정한다.
    • 0: 생성할 밉맵 레벨을 지정한다. 0은 기본 레벨이다.
    • format: 텍스처를 어떤 포맷으로 저장할지 지정한다. nrChannels을 통해 파일을 구분하여 png라면 GL_RGBA, jpg라면 GL_RGB이다.
    • width: 텍스처의 너비
    • height: 텍스처의 높이
    • 0: 항상 0이어야 한다.(일부 레거시 요소땜..?!)
    • format: 소스 이미지의 형식
    • GL_UNSIGNED_BYTE: 데이터 유형
    • data: 실제 이미지 데이터

stbi_image_free(data);
glBindTexture(GL_TEXTURE_2D, 0);
  1. 텍스처 생성과 mipmap 생성이 끝났다면, 이미지 메모리를 해제하고, 현재 바인딩돼 있는 GL_TEXTURE_2D 텍스처를 해제(unbind)한다.
RawModel Loader::loadToVAO(
                           const std::vector<float>& positions,
                           const std::vector<unsigned int>& indices,
                           const std::vector<float>& colors,
                           const std::vector<float>& textureCoords) {
    GLuint vaoID = createVAO();  // VAO bind 보장
    bindIndicesBuffer(indices);  // bindIndicesBuffer는 VAO가 바인딩된 상태에서만 호출해야 한다
    storeDataInAttributeList(0, 3, positions);
    storeDataInAttributeList(1, 3, colors);
    storeDataInAttributeList(2, 2, textureCoords);
    unbindVAO();
    
    // C++: 객체를 바로 생성해서 반환
    return RawModel(vaoID, static_cast<int>(indices.size()));
}

++ storeDataInAttributeList(2, 2, textureCoords);
VAO에 텍스처 좌표가 필요하게 되었으므로,
location인 2, 길이인 2와 함께 VAO에 텍스처 좌표 데이터를 입력하는 코드를 작성해주어야 한다.


텍스처 로드 전체 코드 (Loader.cpp)

// Loader.cpp

#define GL_SILENCE_DEPRECATION
#include <GLFW/glfw3.h>
#include <OpenGL/gl3.h>
#include "Loader.h"

// 텍스처 로드용
#include "stb_image.h"

// 소멸자 구현: 객체가 소멸될 때 OpenGL 메모리를 자동으로 해제
Loader::~Loader() {
    for (GLuint vao : vaos) {
        glDeleteVertexArrays(1, &vao);
    }
    for (GLuint vbo : vbos) {
        glDeleteBuffers(1, &vbo);
    }
    for (GLuint textures : textures) {
        glDeleteBuffers(1, &textures);
    }
    for (GLuint colors : colors) {
        glDeleteBuffers(1, &colors);
    }
}

RawModel Loader::loadToVAO(
                           const std::vector<float>& positions,
                           const std::vector<unsigned int>& indices,
                           const std::vector<float>& colors,
                           const std::vector<float>& textureCoords) {
    GLuint vaoID = createVAO();  // VAO bind 보장
    bindIndicesBuffer(indices);  // bindIndicesBuffer는 VAO가 바인딩된 상태에서만 호출해야 한다
    storeDataInAttributeList(0, 3, positions);
    storeDataInAttributeList(1, 3, colors);
    storeDataInAttributeList(2, 2, textureCoords);
    unbindVAO();
    
    // C++: 객체를 바로 생성해서 반환
    return RawModel(vaoID, static_cast<int>(indices.size()));
}

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

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

// 텍스처 로드
GLuint Loader::loadTexture(const char* filePath)
{
    stbi_set_flip_vertically_on_load(true);
    
    GLuint textureID;
    glGenTextures(1, &textureID);
    textures.push_back(textureID); // 소멸자에서 지우기 위해 저장

    glBindTexture(GL_TEXTURE_2D, textureID);

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    int width, height, nrChannels;
    unsigned char* data = stbi_load(filePath, &width, &height, &nrChannels, 0);

    if (data)
    {
        GLenum format = (nrChannels == 4) ? GL_RGBA : GL_RGB;

        glTexImage2D(
            GL_TEXTURE_2D,
            0,
            format,
            width,
            height,
            0,
            format,
            GL_UNSIGNED_BYTE,
            data
        );
        glGenerateMipmap(GL_TEXTURE_2D);
    }
    else
    {
        printf("Failed to load texture: %s\n", filePath);
    }

    stbi_image_free(data);
    glBindTexture(GL_TEXTURE_2D, 0);

    return textureID;
}

Applying textures

텍스처를 로드했으니 이제 텍스처를 적용할 차례이다.

그러기 위해서 vertex shader와 fragment shader를 약간 손봐야 한다.
정점 셰이더를 수정해서 텍스처 좌표를 정점 속성으로 받아 프래그먼트 셰이더로 전달해야 한다.

// VertexShader.vert

#version 330 core

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

out vec3 ourColor;
out vec2 TexCoord;

void main() {
    gl_Position = vec4(aPos, 1.0);
    ourColor = aColor;
    TexCoord = aTexCoords;
}

텍스처 좌표를 받는 location을 2로 설정해주고, 프래그먼트 셰이더로 전달하기 위해 out vec2 TexCoord;를 작성한다.


// FragmentShader.frag

#version 330 core

in vec3 ourColor;
in vec2 TexCoord;

out vec4 FragColor;

uniform sampler2D ourTexture;

void main() {
    FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
}

uniform sampler2D ourTexture;
Fragment Shader는 텍스처 객체에 접근할 수 있어야 텍스처링을 할 수 있다.
이를 위해 GLSL에는 텍스처 객체를 위한 내장 타입인 sampler가 있으며, 텍스처 종류에 따라 sampler1D, sampler3D, sampler2D 등이 있다.
여기서는 sampler2D를 uniform으로 선언한다.

FragColor = texture(ourTexture, TexCoord) * vec4(ourColor, 1.0);
텍스처의 색상을 샘플링하기 위해 GLSL의 texture 함수를 사용한다. 이 함수는 텍스처 샘플러와 텍스처 좌표를 인자로 받아, 설정된 텍스처 파라미터에 따라 색상을 반환한다.
texture 색과 vertex 색을 혼합하기 위해 텍스처와 ourColor를 곱해주었다. ㅎㅎ

이제 main에서 glDrawElements를 호출하기 전에 텍스처를 바인딩하면, 텍스처는 자동으로 프래그먼트 셰이더의 sampler에 할당된다. (glBindTexture(GL_TEXTURE_2D, textureID);)


std::vector<float> textureCoords = {
    // textures
    0.0f, 1.0f,
    0.0f, 0.0f,
    1.0f, 0.0f,
    1.0f, 1.0f
};

먼저 텍스처를 어떻게 샘플링할지 OpenGL에게 알려주기 위해 텍스처 좌표를 정의한다.


// int main()

RawModel model = loader.loadToVAO(vertices, indices, colors, textureCoords);

GLuint textureID = loader.loadTexture(
        "/Users/geemgaeun/Desktop/스크린샷/고릴라.jpg"
    );

main 블록 안에서 텍스처 좌표를 포함한 데이터를 VAO에 로드하여 모델을 생성하고,
앞서 작성했던 loader.loadTexture를 통해 텍스처를 로드해준다.


glUniform1i(glGetUniformLocation(shader, "ourTexture"), 0);

glUniform1i(glGetUniformLocation(shader, "ourTexture"), 0);
각 샘플러가 어떤 텍스처 유닛을 사용할지 지정한다. 렌더링 루프 전에 한 번만 해주면 된다.


텍스처 적용 전체 코드 (MainGameLoop.cpp)

#include "DisplayManager.h"
#include "Loader.h"
#include "Renderer.h"
#include "RawModel.h"
#include <vector>

#include <fstream>

// 셰이더 프로그램 작성
// ----------------------
GLuint shader;

std::string     ReadFile(const char* filePath)
{
        std::string content;
        std::ifstream fileStream(filePath, std::ios::in);

        if (!fileStream.is_open())
        {
                printf("Failed to read %s file! The file doesn't exist.\n", filePath);
                return "";
        }

        std::string line = "";
        while (!fileStream.eof())
        {
                std::getline(fileStream, line);
                content.append(line + "\n");
        }

        fileStream.close();
        return content;
}

GLuint  AddShader(const char* shaderCode, GLenum shaderType)
{
        GLuint new_shader = glCreateShader(shaderType);

        const GLchar* code[1];
        code[0] = shaderCode;

        glShaderSource(new_shader, 1, code, NULL);

        GLint result = 0;
        GLchar err_log[1024] = { 0 };

        glCompileShader(new_shader);
        glGetShaderiv(new_shader, GL_COMPILE_STATUS, &result);
        if (!result)
        {
                glGetShaderInfoLog(new_shader, sizeof(err_log), NULL, err_log);
                printf("Error compiling the %d shader: '%s'\n", shaderType, err_log);
                return 0;
        }
        return new_shader;
}

void    CompileShader(const char* vsCode, const char* fsCode)
{
        GLuint vs, fs;

        shader = glCreateProgram();

        if (!shader)
        {
                printf("Error: Cannot create shader program.");
                return;
        }

        vs = AddShader(vsCode, GL_VERTEX_SHADER);
        fs = AddShader(fsCode, GL_FRAGMENT_SHADER);
        glAttachShader(shader, vs);  // Attach shaders to the program for linking process.
        glAttachShader(shader, fs);

        GLint result = 0;
        GLchar err_log[1024] = { 0 };

        glLinkProgram(shader);  // Create executables from shader codes to run on corresponding processors.
        glGetProgramiv(shader, GL_LINK_STATUS, &result);
        if (!result)
        {
                glGetProgramInfoLog(shader, sizeof(err_log), NULL, err_log);
                printf("Error linking program: '%s'\n", err_log);
                return;
        }
}

void    CreateShaderProgramFromFiles(const char* vsPath, const char* fsPath)
{
        std::string vsFile = ReadFile(vsPath);
        std::string fsFile = ReadFile(fsPath);
        const char* vsCode = vsFile.c_str();
        const char* fsCode = fsFile.c_str();

        CompileShader(vsCode, fsCode);
}

// ----------------------

std::vector<float> vertices = {
    // positions
    -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<float> colors = {
    // colors
    1.0f, 1.0f, 0.0f,
    0.0f, 0.0f, 1.0f,
    0.0f, 1.0f, 0.0f,
    1.0f, 0.0f, 0.0f,
};
std::vector<float> textureCoords = {
    // textures
    0.0f, 1.0f,
    0.0f, 0.0f,
    1.0f, 0.0f,
    1.0f, 1.0f
};

std::vector<unsigned int> indices = {
    0,1,3,
    3,1,2
};


int main() {
    // 1. 디스플레이 생성 (창 띄우기)
    DisplayManager::createDisplay();

    // 2. 필요한 엔진 컴포넌트 생성
    Loader loader;
    Renderer renderer;

    // 3. 사각형(Quad) 정점 데이터 정의
    // 두 개의 삼각형이 합쳐져서 하나의 사각형이 된다

    // 4. 데이터를 VAO에 로드하여 RawModel 생성
    RawModel model = loader.loadToVAO(vertices, indices, colors, textureCoords);
    
    // 텍스처
    GLuint textureID = loader.loadTexture(
        "/Users/geemgaeun/Desktop/스크린샷/고릴라.jpg"
    );

    // 5. 게임 루프 (창을 닫기 전까지 무한 반복)
    // 루프 시작 전, 사용할 셰이더 프로그램을 활성화한다.
    // 매 프레임마다 바꿀 필요가 없다면 루프 밖에서 한 번만 호출해도 된다.
    
    CreateShaderProgramFromFiles(
        "/Users/geemgaeun/Desktop/개인 프로젝트/OpenGL 3D Game/OpenGL-3D-Game-tutorial/OpenGL 3D Game/VertexShader.vert",
        "/Users/geemgaeun/Desktop/개인 프로젝트/OpenGL 3D Game/OpenGL-3D-Game-tutorial/OpenGL 3D Game/FragmentShader.frag"
    );
    glUseProgram(shader);
    
    glUniform1i(glGetUniformLocation(shader, "ourTexture"), 0);
    
    
    while (!DisplayManager::isCloseRequested()) {
        // 1. 게임 로직 및 물리 업데이트 (CPU 연산)
        // 예: 캐릭터 이동 계산, 충돌 체크 등

        // 2. 화면 렌더링 준비 (배경색으로 지우기)
        // 내부적으로 glClear(GL_COLOR_BUFFER_BIT)를 수행
        renderer.prepare();
        
        glBindTexture(GL_TEXTURE_2D, textureID);

        // 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;
}

실습: 결과

실행을 하면 사진의 위아래가 뒤집혀 보인다!!! 이런!!!

이는 OpenGL의 텍스처 좌표 기준과 이미지 파일의 좌표 기준이 다르기 때문이다. stb_image.h에서 이미지를 로드할 때 y축을 뒤집도록 설정할 수 있다.

나는 learnopengl의 코드를 중심으로 작성하여서 좌측 하단을 (0,0)으로 가정하고 textureCoords를 작성하였는데,
thinmatrix는 좌측 상단을 (0,0)으로 가정하여 textureCoords를 작성하였기 때문에 그대로 잘 나온다.

loader.cpp의 loadTexture에 stbi_set_flip_vertically_on_load(true);를 추가해준다.

GLuint Loader::loadTexture(const char* filePath)
{
    stbi_set_flip_vertically_on_load(true);
    
    GLuint textureID;
    glGenTextures(1, &textureID);
    textures.push_back(textureID); // 소멸자에서 지우기 위해 저장

    glBindTexture(GL_TEXTURE_2D, textureID);

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    int width, height, nrChannels;
    unsigned char* data = stbi_load(filePath, &width, &height, &nrChannels, 0);

    if (data)
    {...}
    
    ...
}

실습: 최종 결과!

코드를 추가한 뒤 다시 실행해보면 정상적으로 셰이딩과 텍스처가 적용된 것을 볼 수 있다 !!!!!


Texture Units

위에서 아래 코드를 설명하며

glUniform1i(glGetUniformLocation(shader, "ourTexture"), 0);

glUniform1i는 각 샘플러가 어떤 텍스처 유닛을 사용할지 지정한다. 렌더링 루프 전에 한 번만 해주면 된다. 라고 했던 것을 기억하는가?

glUniform1i를 사용하면 텍스처 샘플러에 위치 값을 할당할 수 있고, 이를 통해 프래그먼트 셰이더에서 여러 개의 텍스처를 동시에 설정할 수 있다.
이 텍스처의 위치 값은 일반적으로 texture unit이라고 불린다.

texture unit의 목적은 shader에서 한 번에 여러 texture를 사용할 수 있게 하는 것이다.. sampler에 texture unit을 할당하면 활성화시 여러 texture를 한 번에 binding이 가능하다. glBindTexture 이전에 glActiveTexture를 사용해서 texture unit을 활성화한다.

glActiveTexture(GL_TEXTURE0); // 텍스처를 바인딩하기 전에 텍스처 유닛 활성화
glBindTexture(GL_TEXTURE_2D, texture);

GL_TEXTURE0은 기본적으로 항상 활성화되어 있기 때문에, 이전 예제에서는 glBindTexture를 사용할 때 텍스처 유닛을 명시적으로 활성화할 필요가 없었다.


실습: Texture Units

한 번 Fragment Shader와 mainGameLoop를 조금만 수정해서 또다른 샘플러를 추가해볼까?

// FragmentShader.frag

#version 330 core

in vec3 ourColor;
in vec2 TexCoord;

out vec4 FragColor;

uniform sampler2D ourTexture1;
uniform sampler2D ourTexture2;

void main() {
    FragColor = mix(texture(ourTexture1, TexCoord), texture(ourTexture2, TexCoord), 0.3)  * vec4(ourColor, 1.0);
}

// MainGameLoop.cpp

...

GLuint textureID = loader.loadTexture(
    "/Users/geemgaeun/Desktop/스크린샷/고릴라.jpg"
);
    
GLuint textureID2 = loader.loadTexture(
    "/Users/geemgaeun/Desktop/스크린샷/바나나.jpg"
);
...

glUniform1i(glGetUniformLocation(shader, "ourTexture1"), 0);
glUniform1i(glGetUniformLocation(shader, "ourTexture2"), 1);
    
...

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureID);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, textureID2);

그 결과, 은은한~ 바나나 사이에 있는 무지개 코딩 고릴라가 나타난다 !!!! ㅎㅎ

0개의 댓글