OpenGL/공부

[OpenGL] 쉐이더 사용, 삼각형 그리기 (+ VAO, VBO)

ciel45 2024. 5. 27. 12:12

쉐이더를 사용하기에 앞서 렌더링 파이프라인에 대해 설명하자면,

렌더링 파이프라인은 GPU 내부에서 화면을 렌더링하기 위해 돌아가는 시퀀스이다.

렌더링 파이프라인 (출처 : OpenGL 위키)

 

GPU는 내부적으로 Vertex를 인풋으로 받아서, Pixel을 아웃풋으로 뱉어낸다.

그 중간 과정에서 렌더링 파이프라인이 돌아가며, 핵심 과정은 다음과 같다.

 

  • Vertex Processor: 버텍스를 인풋으로 받아, 버텍스 정보들을 처리한다. 주로 버텍스들의 위치 정보를 선형변환을 통해 Object-Space에서 Clip-Space로 변환한다.
  • Primitive Assembler : 버텍스들을 이용해 폴리곤(primitive)들을 만든다.
  • Rasterization : 폴리곤을 픽셀의 집합으로 만든다.
  • Fragment Processor : 각 픽셀들의 최종 결과를 처리한다.

 

여기서 Vertex Processor의 기능을 커스터마이징할 수 있는 것이 Vertex Shader이고,

Fragment Processor의 기능을 커스터마이징할 수 있는 것이 Fragment Shader인 것이다.

 

Vertex Shader는 각 버텍스에 대하여 main 함수가 병렬처리되어 돌아가고,

Fragment Shader는 각 픽셀에 대하여 main 함수가 병렬처리되어 돌아간다.

 

픽셀과 fragment는 비슷하지만 조금 다르다. 화면에 나타나는 최종 fragment가 픽셀이라 할 수 있다.

 

 

특히, Fragment Shader는 사실적인 반사광 등의 그래픽적 효과를 위해 무척 중요하다.

Vertex 레벨에서 그래픽적 효과를 모두 처리한다면, 픽셀 단위의 세밀한 효과를 낼 수 없기 때문이다.

 

 

다음은 Vertex Shader와 Fragment Shader를 사용한 OpenGL 코드이다. 

자잘한 설명은 주석으로 달아놓았다.

#include <stdio.h>
#include <string.h>

#include <GL/glew.h>
#include <GLFW/glfw3.h>

// 창 크기
const GLint WIDTH = 800, HEIGHT = 600;

GLuint VAO, VBO, shader;

// Vertex Shader (gl_Position에 입력받은 위치를 저장)
static const char* vShader = "        \n\
#version 330                          \n\
                                      \n\
layout (location = 0) in vec3 pos;    \n\ // in : shader로 들어가는 데이터
                                      \n\ // layout (location = 0) : VAO의 0번 속성을 사용
void main()                           \n\
{                                     \n\
    gl_Position = vec4(pos, 1.f);     \n\ // homogeneous coordinate 사용
}";

// Fragment Shader (color를 green으로 설정)
static const char* fShader = "        \n\
#version 330                          \n\
                                      \n\
out vec4 color;                       \n\ // out : shader의 output
                                      \n\
void main()                           \n\
{                                     \n\
    color = vec4(0.f, 1.f, 0.f, 1.f); \n\
}";

void CreateTriangle()
{
    GLfloat vertices[] = 
    {
    	// x ,  y ,  z
        -1.f, -1.f, 0.f, // 화면의 왼쪽 아래
        1.f, -1.f, 0.f, // 화면의 오른쪽 아래
        0.f, 1.f, 0.f // 화면의 중앙 위
    };

	// Vertex Array Object 생성
    glGenVertexArrays(1, &VAO);
    glBindVertexArray(VAO);

	// Vertex Array Object 내부에 Vertex Buffer Object 생성
    glGenBuffers(1, &VBO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); // static draw : 넣은 값을 바꾸지 않겠다

	// float를 3개씩 읽겠다는 의미
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
    glEnableVertexArrayAttrib(VAO, 0);

    glBindBuffer(GL_ARRAY_BUFFER, 0);

    glBindVertexArray(0);
}

void AddShader(GLuint theProgram, const char* shaderCode, GLenum shaderType)
{
    GLuint theShader = glCreateShader(shaderType); // 빈 쉐이더를 생성, ID를 얻어옴

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

    GLint codeLength[1];
    codeLength[0] = strlen(shaderCode);

    glShaderSource(theShader, 1, theCode, codeLength); // 생성한 쉐이더에 코드를 집어넣음
    glCompileShader(theShader); //컴파일

    GLint result = 0;
    GLchar errorLog[1024] = { 0 };
    glGetShaderiv(theShader, GL_COMPILE_STATUS, &result);
    if (!result)
    {
        glGetShaderInfoLog(theShader, sizeof(errorLog), NULL, errorLog);
        printf("Error compiling the %d shader : %s\n", shaderType, errorLog);
    }

    glAttachShader(theProgram, theShader); // 프로그램에 attach
}

void CompileShaders()
{
    shader = glCreateProgram();

    if (!shader)
    {
        printf("Error creating program\n");
        return;
    }

    AddShader(shader, vShader, GL_VERTEX_SHADER);
    AddShader(shader, fShader, GL_FRAGMENT_SHADER);

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

    glLinkProgram(shader); // 각 쉐이더를 컴파일해서 프로그램에 attach했으므로, 링킹 수행
    glGetProgramiv(shader, GL_LINK_STATUS, &result);
    if (!result)
    {
        glGetProgramInfoLog(shader, sizeof(errorLog), NULL, errorLog);
        printf("Error linking program : %s\n", errorLog);
    }
    
    glValidateProgram(shader); // 현재 OpenGL context에 유효한지 확인
    glGetProgramiv(shader, GL_VALIDATE_STATUS, &result);
    if (!result)
    {
        glGetProgramInfoLog(shader, sizeof(errorLog), NULL, errorLog);
        printf("Error validating program : %s\n", errorLog);
    }
}

int main()
{
    // GLFW 초기화
    if (!glfwInit())
    {
        printf("GLFW 초기화 실패\n");
        glfwTerminate();
        return 1;
    }

    // GLFW 윈도우 속성 셋업
    // OpenGL 버전
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    // Core profile = 이전 버전 호환성 없음
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    // 앞으로의 호환성을 허용
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);

    GLFWwindow* mainWindow = glfwCreateWindow(WIDTH, HEIGHT, "Test", NULL, NULL);
    if (!mainWindow)
    {
        printf("GLFW 창 생성 실패\n");
        glfwTerminate();
        return 1;
    }

    // 프레임 버퍼 크기 정보를 가져온다
    int bufferWidth, bufferHeight;
    glfwGetFramebufferSize(mainWindow, &bufferWidth, &bufferHeight);

    // glew가 사용할 컨텍스트 설정
    glfwMakeContextCurrent(mainWindow);

    // 최신 확장 기능을 허용
    glewExperimental = GL_TRUE;

    if (glewInit() != GLEW_OK)
    {
        printf("GLEW 초기화 실패\n");
        glfwDestroyWindow(mainWindow);
        glfwTerminate();
        return 1;
    }

    // 뷰포트 생성
    glViewport(0, 0, bufferWidth, bufferHeight);

    CreateTriangle();
    CompileShaders();

    // 창이 닫힐 때까지 반복
    while (!glfwWindowShouldClose(mainWindow))
    {
        // 사용자 입력 이벤트 가져오고 처리
        glfwPollEvents();

        // 창 지우기
        glClearColor(0.f, 0.f, 0.f, 1.f);
        glClear(GL_COLOR_BUFFER_BIT);

        glUseProgram(shader); //쉐이더 사용

        glBindVertexArray(VAO); // CreateTriangle()에서 만든 VAO로 삼각형을 그리기
        glDrawArrays(GL_TRIANGLES, 0, 3);
        glBindVertexArray(0);

        glUseProgram(0);

        glfwSwapBuffers(mainWindow);
    }
    return 0;
};

 

45 ~ 51번째 줄을 보면, VAO(Vertex Array Object)VBO(Vertex Buffer Object)를 사용하고 있는 것을 볼 수 있다. 

 

VAO는 버텍스의 attribute(position, normal, color, ...)의 상태 에 대한 정보를 저장한다. 일종의 틀이라고 할 수 있다.

 

VBO는 실제 버텍스 데이터를 저장하는 자료구조이다. 만들어진 VBO는 VAO의 특정 속성과 연결될 수 있다.

 

line 54의 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0); 가 바로 VAO의 0번 attribute에 VBO를 연결시켜주는 코드이다.

 

첫번째 파라미터 0이 VAO의 attribute의 index를 의미한다.

또한 마지막 파라미터 0은, 바로 위에서 bind한 버퍼 오브젝트를 사용하겠다는 의미이다.

 

그리고 Vertex Shader 코드에 보이다시피, 0번 attribute는 여기서 position으로 사용되었다.

 

 

이러한 버퍼 오브젝트의 사용을 통해 버텍스의 데이터를 CPU에서 GPU로 매번 보내는 오버헤드를 없앨 수 있다.

버퍼 오브젝트는 GPU의 VRAM 내부에 만들어진 공간과 같다.

계속해서 CPU에서 버텍스의 정보를 쏘아주는 대신, VRAM 안에 버텍스의 정보를 얹어놓고 쓰는 것이다.

 

 

 

 

코드 최상단에 const char*로 쉐이더 코드를 그대로 박고 그걸 이용해 쉐이더 프로그램을 만들도록 했는데, 이건 굉장히 무식한 방법이긴하다.

 

어디까지나 예제일 뿐이고, 실제로는 주로 별도의 파일에서 읽어오는 방식을 사용한다.

 

쉐이더 프로그램 생성 과정을 보면, 각 쉐이더 코드를 가져와 컴파일 한뒤, 그걸 쉐이더 프로그램에 붙여 링킹해서 최종 결과물을 만들고 있다.

 

그림으로 보면 다음과 같다.

 

 

C, C++로 짜여진 프로그램은 gcc, GNU 등 여러 컴파일러로 컴파일 할 수 있지만, OpenGL 쉐이더는 OpenGL 내부적으로 컴파일 함수가 모두 정의되어있다는 것이 특징이다.

 

 

 

또, 중간의 GetShaderiv, GetProgramiv는 쉐이더 코드의 디버깅을 위한 함수이다.

 

비주얼 스튜디오는 자체적으로 쉐이더 코드를 디버깅할 수 없다. 중단점을 설정할 수 없으며, syntax error에 대해 빨간 밑줄을 그어주지도 않는다. 단지 프로그램이 죽을 뿐이다.

 

따라서 에러의 원인을 찾기 위해서는 해당 코드처럼 errorLog[]를 만들어 거기에 뱉어주도록 해야 한다.

 

vShader 코드에서 gl_Position = vec4(pos, 1.f); 부분이 있는데,

gl_Position은 OpenGL 내부 예약어이다. 말 그대로 버텍스의 포지션을 넣어주는 것을 의미한다.

 

원래 vec3인 pos를 뒤에 1.f를 붙여 vec4로 바꿔준 이유는, Homogeneous Coordinates를 사용하기 위함이다.

Homogeneous Coordinates에서는 x, y, z가 있을 때 뒤에 1이 붙는다면 위치를, 0이 붙는다면 벡터를 나타낸다.

 

실행 결과 :