Skip to content

셰이더

안녕 삼각형 챕터에서 언급했듯이, 셰이더는 GPU에서 실행되는 작은 프로그램입니다. 이러한 프로그램은 그래픽 파이프라인의 각 특정 단계에서 실행됩니다. 간단히 말해, 셰이더는 입력을 출력으로 변환하는 프로그램에 지나지 않습니다. 또한 셰이더는 서로 통신할 수 없도록 매우 독립적으로 작동하는 프로그램입니다. 셰이더 간의 통신은 오직 입력과 출력을 통해서만 이루어집니다.

이전 장에서는 셰이더의 기본적인 개념과 올바른 사용법을 간략하게 살펴보았습니다. 이제 셰이더, 특히 OpenGL 셰이딩 언어에 대해 보다 일반적인 방식으로 설명하겠습니다.

GLSL

셰이더는 C와 유사한 언어인 GLSL로 작성됩니다. GLSL은 그래픽 작업에 최적화되어 있으며, 벡터 및 행렬 조작에 특화된 유용한 기능들을 포함하고 있습니다.

셰이더는 항상 버전 선언으로 시작하고, 그 뒤에 입력 및 출력 변수 목록, 유니폼(uniforms) 변수, 그리고 메인 함수가 이어집니다. 각 셰이더의 진입점은 메인 함수이며, 여기서 입력 변수를 처리하고 결과를 출력 변수에 출력합니다. 유니폼 변수가 무엇인지 모르더라도 걱정하지 마세요. 곧 설명드리겠습니다.

셰이더는 일반적으로 다음과 같은 구조를 가지고 있습니다.

#version version_number
in type in_variable_name;
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

void main()
{
  // 입력값을 처리하고 여러가지 그래픽 작업 수행
  ...
  // 처리된 결과를 출력 변수에 출력
  out_variable_name = weird_stuff_we_processed;
}

정점 셰이더에 대해 구체적으로 이야기할 때, 각 입력 변수는 정점 속성(vertex attribute)이라고도 합니다. 선언할 수 있는 정점 속성의 최대 개수는 하드웨어에 의해 제한됩니다. OpenGL은 항상 최소 16개의 4성분 정점 속성을 사용할 수 있도록 보장하지만, 일부 하드웨어에서는 더 많은 속성을 허용할 수 있으며, 이는 GL_MAX_VERTEX_ATTRIBS를 쿼리하여 확인할 수 있습니다.

int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

이 함수는 대개 최소값인 16을 반환하는데, 이는 대부분의 목적에 충분할 것입니다.

타입

GLSL은 다른 프로그래밍 언어와 마찬가지로 변수의 종류를 지정하는 데이터 타입을 가지고 있습니다. GLSL에는 C 언어에서 흔히 볼 수 있는 int, float, double, uint, bool과 같은 기본 타입이 대부분 포함되어 있습니다. 또한, 벡터와 행렬이라는 두 가지 컨테이너 타입을 많이 사용하게 될 것입니다. 행렬에 대해서는 이후 장에서 자세히 다루겠습니다.

벡터

GLSL에서 벡터는 방금 언급한 기본 유형들을 담을 수 있는 2, 3 또는 4개의 구성 요소로 이루어진 컨테이너입니다. 벡터는 다음과 같은 형태를 취할 수 있습니다(n은 구성 요소의 개수를 나타냅니다).

  • vecn: n개의 부동소수점(float) 값로 이루어진 기본 벡터.
  • bvecn: n개의 불리언 값으로 이루어진 벡터.
  • ivecn: n개의 정수 값으로 이루어진 벡터.
  • uvecn: n개의 부호 없는 정수(unsigned integer) 값으로 이루어진 벡터.
  • dvecn: n개의 더블(double) 값으로 이루어진 벡터.

대부분의 경우 부동소수점 숫자가 우리의 목적에 충분하기 때문에 기본적인 vecn을 사용하게 될 것입니다.

벡터의 구성 요소는 vec.x와 같이 접근할 수 있으며, 여기서 x는 벡터의 첫 번째 구성 요소입니다. .x, .y, .z, .w를 사용하면 각각 첫 번째, 두 번째, 세 번째, 네 번째 구성 요소에 접근할 수 있습니다. GLSL에서는 색상에는 rgba를, 텍스처 좌표에는 stpq를 사용하여 동일한 구성 요소에 접근할 수도 있습니다.

벡터 데이터 타입은 스위즐링(swizzling)이라는 흥미롭고 유연한 구성 요소 선택 기능을 제공합니다. 스위즐링을 사용하면 다음과 같은 구문을 사용할 수 있습니다.

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

원본 벡터에 해당 구성 요소가 있는 한, 최대 4개의 문자를 조합하여 새 벡터(동일한 타입)를 생성할 수 있습니다. 하지만 예시로 vec2의 .z 구성 요소에는 접근할 수 없습니다. 또한 벡터를 다른 벡터 생성자 호출의 인수로 전달하여 필요한 인수의 수를 줄일 수 있습니다.

vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);

벡터는 모든 종류의 입력과 출력에 사용할 수 있는 유연한 데이터 유형입니다. 이 책 전체에서 벡터를 창의적으로 활용하는 다양한 예제를 보게 될 것입니다.

인앤아웃

햄버거가 아니라 인풋(input)과 아웃풋(output)입니다!

셰이더는 그 자체로 훌륭한 프로그램이지만, 전체 시스템의 일부이기 때문에 각 셰이더에 입력과 출력을 지정하여 변수를 이동시킬 수 있도록 해야 합니다. GLSL은 이러한 목적을 위해 inout 키워드를 정의했습니다. 각 셰이더는 이 키워드를 사용하여 입력과 출력을 지정할 수 있으며, 출력 변수가 다음 셰이더 단계의 입력 변수의 타입과 일치하면 해당 변수가 전달됩니다. 다만, 정점 셰이더와 프래그먼트 셰이더는 약간 다릅니다.

정점 셰이더는 어떤 형태로든 입력을 받아야 제대로 작동합니다. 정점 셰이더는 입력을 정점 데이터에서 직접 받는다는 점에서 다른 셰이더와 차별화됩니다. 정점 데이터의 구조를 정의하기 위해 입력 변수에 위치 메타데이터를 지정하여 CPU에서 정점 속성을 설정할 수 있도록 합니다. 이전 장에서 layout (location = 0)으로 이를 살펴보았습니다. 따라서 정점 셰이더는 입력에 대한 추가적인 레이아웃 지정이 필요하며, 이를 통해 정점 데이터와 연결할 수 있습니다.

layout (location = 0)를 생략하고 OpenGL 코드에서 glGetAttribLocation을 통해 속성 위치를 조회하는 것도 가능하지만, 저는 정점 셰이더에서 설정하는 것을 선호합니다. 이해하기 쉽고 (여러분과 OpenGL 모두) 작업량을 줄여줍니다.

또 다른 예외 사항은 프래그먼트 셰이더가 최종 출력 색상을 생성해야 하므로 vec4 색상 출력 변수가 필요하다는 것입니다. 프래그먼트 셰이더에서 출력 색상을 지정하지 않으면 해당 프래그먼트의 색상 버퍼 출력이 정의되지 않은 상태가 됩니다(일반적으로 그런 상황에서 OpenGL은 해당 프래그먼트를 검정색 또는 흰색으로 렌더링합니다).

따라서 한 셰이더에서 다른 셰이더로 데이터를 보내려면 보내는 셰이더에 출력 변수를 선언하고 받는 셰이더에도 동일한 입력 변수를 선언해야 합니다. 양쪽에서 변수의 타입과 이름이 같으면 OpenGL은 해당 변수들을 연결하고, 그 후 셰이더 간에 데이터를 주고받을 수 있게 됩니다(프로그램 객체를 연결할 때 이 과정이 수행됩니다). 실제로 어떻게 작동하는지 보여주기 위해 이전 장에서 다룬 셰이더들을 수정하여 정점 셰이더가 프래그먼트 셰이더의 색상을 결정하도록 하겠습니다.

정점 셰이더

#version 330 core
layout (location = 0) in vec3 aPos; // the position variable has attribute position 0

out vec4 vertexColor; // specify a color output to the fragment shader

void main()
{
    gl_Position = vec4(aPos, 1.0); // see how we directly give a vec3 to vec4's constructor
    vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // set the output variable to a dark-red color
}

프래그먼트 셰이더

#version 330 core
out vec4 FragColor;

in vec4 vertexColor; // the input variable from the vertex shader (same name and same type)  

void main()
{
    FragColor = vertexColor;
}

보시다시피, 정점 셰이더에서 설정하는 vec4 출력 변수인 vertexColor를 선언했고, 프래그먼트 셰이더에서도 유사하게 vertexColor 입력 변수를 선언했습니다. 두 변수의 타입과 이름이 같기 때문에 프래그먼트 셰이더의 vertexColor는 정점 셰이더의 vertexColor와 연결됩니다. 정점 셰이더에서 색상을 진한 빨간색으로 설정했으므로, 결과 프래그먼트도 진한 빨간색이 되어야 합니다. 다음 이미지는 그 결과를 보여줍니다.

자, 이제 됐어요! 정점 셰이더에서 프래그먼트 셰이더로 값을 전달하는 데 성공했습니다. 이제 좀 더 특별하게 애플리케이션에서 프래그먼트 셰이더로 색상을 전달할 수 있는지 확인해 볼까요!

유니폼

유니폼(Uniforms)은 CPU에서 실행되는 애플리케이션의 데이터를 GPU의 셰이더로 전달하는 또 다른 방법입니다. 하지만 유니폼은 정점 속성과는 약간의 차이가 있습니다. 첫째, 유니폼은 전역 변수입니다. 전역 변수란 셰이더 프로그램 객체별로 고유한 값을 가지며, 프로그램의 어느 단계에서든 모든 셰이더에서 접근할 수 있음을 의미합니다. 둘째, 유니폼 값을 어떤 값으로 설정하든, 해당 값은 재설정되거나 업데이트될 때까지 유지됩니다.

GLSL에서 유니폼 변수를 선언하려면 셰이더에 uniform 키워드와 타입, 이름을 추가하기만 하면 됩니다. 이렇게 하면 셰이더에서 새로 선언된 유니폼 변수를 사용할 수 있습니다. 이번에는 유니폼 변수를 통해 삼각형의 색상을 설정할 수 있는지 살펴보겠습니다.

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; // 이 변수는 나중에 OpenGL쪽 코드에서 값을 설정하겠습니다.

void main()
{
    FragColor = ourColor;
}   

프래그먼트 셰이더에서 vec4 ourColor라는 유니폼 변수를 선언하고, 프래그먼트의 출력 색상을 이 유니폼 변수의 값으로 설정했습니다. 유니폼 변수는 전역 변수이므로 원하는 셰이더 단계에서 정의할 수 있으며, 따라서 프래그먼트 셰이더로 값을 전달하기 위해 정점 셰이더를 다시 거칠 필요가 없습니다. 정점 셰이더에서는 이 유니폼 변수를 사용하지 않으므로, 정점 셰이더에서 정의할 필요가 없습니다.

GLSL 코드에서 전혀 사용되지 않는 유니폼 변수를 선언하면 컴파일러가 컴파일된 버전에서 해당 변수를 조용히 제거하는데, 이로 인해 여러 가지 골치 아픈 오류가 발생합니다. 이 점을 꼭 기억하세요!

현재 유니폼은 비어 있습니다. 아직 유니폼에 아무 데이터도 추가하지 않았으니, 이제 추가해 보겠습니다. 먼저 셰이더에서 유니폼 속성의 인덱스/위치를 찾아야 합니다. 유니폼의 인덱스/위치를 찾았으면 값을 업데이트할 수 있습니다. 프래그먼트 셰이더에 단일 색상을 전달하는 대신, 시간이 지남에 따라 색상이 점진적으로 변하도록 만들어 보겠습니다.

float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

먼저 glfwGetTime() 함수를 사용하여 실행 시간을 초 단위로 가져옵니다. 그런 다음 sin 함수를 사용하여 색상을 0.0~1.0 범위로 변화시키고 그 결과를 greenValue에 저장합니다.

다음으로 glGetUniformLocation 함수를 사용하여 ourColor 유니폼의 위치를 ​​조회합니다. 조회 함수에는 셰이더 프로그램과 위치를 가져올 유니폼의 이름을 전달합니다. glGetUniformLocation 함수가 -1을 반환하면 위치를 찾을 수 없다는 의미입니다. 마지막으로 glUniform4f 함수를 사용하여 유니폼 값을 설정할 수 있습니다. 유니폼 위치를 찾는 데에는 셰이더 프로그램을 먼저 사용할 필요가 없지만, 유니폼 값을 업데이트하려면 먼저 해당 프로그램을 사용해야 합니다(glUseProgram 호출). 이는 현재 활성화된 셰이더 프로그램에 유니폼 값을 설정하기 때문입니다.

OpenGL은 본질적으로 C 라이브러리이기 때문에 함수 오버로딩을 기본적으로 지원하지 않습니다. 따라서 함수를 여러 유형으로 호출할 수 있는 경우, OpenGL은 필요한 각 유형에 대해 새로운 함수를 정의합니다. glUniform 함수가 바로 그 대표적인 예입니다. 이 함수는 설정하려는 유니폼 유형에 맞는 특정 접미사를 필요로 합니다. 가능한 접미사 몇 가지는 다음과 같습니다.

  • f: 이 함수는 값으로 부동 소수점(float)을 기대합니다.
  • i: 이 함수는 값으로 정수(int)를 기대합니다.
  • ui: 이 함수는 값으로 부호 없는 정수(unsigned int)를 기대합니다.
  • 3f: 이 함수는 값으로 3개의 부동 소수점 숫자를 기대합니다.
  • fv: 이 함수는 값으로 float형 벡터 또는 배열을 기대합니다.

OpenGL 옵션을 설정하려면 해당 유형에 맞는 오버로드된 함수를 선택하기만 하면 됩니다. 이 경우 유니폼 변수에 4개의 float 값을 개별적으로 설정하려고 하므로 glUniform4f 함수를 통해 데이터를 전달합니다(fv 버전을 사용할 수도 있습니다).

이제 유니폼 변수의 값을 설정하는 방법을 알았으니, 이를 렌더링에 사용할 수 있습니다. 색상이 점진적으로 변하도록 하려면 매 프레임마다 이 유니폼 변수를 업데이트해야 합니다. 그렇지 않으면 한 번만 설정하면 삼각형이 단색으로 유지됩니다. 따라서 greenValue를 계산하고 매 렌더링 반복마다 유니폼 변수를 업데이트합니다.

while(!glfwWindowShouldClose(window))
{
    // input
    processInput(window);

    // render
    // clear the colorbuffer
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // be sure to activate the shader
    glUseProgram(shaderProgram);

    // update the uniform color
    float timeValue = glfwGetTime();
    float greenValue = sin(timeValue) / 2.0f + 0.5f;
    int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
    glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

    // now render the triangle
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // swap buffers and poll IO events
    glfwSwapBuffers(window);
    glfwPollEvents();
}

이 코드는 이전 코드를 비교적 간단하게 변형한 것입니다. 이번에는 삼각형을 그리기 전에 매 프레임마다 uniform 변수를 업데이트합니다. uniform 변수를 올바르게 업데이트하면 삼각형의 색상이 녹색에서 검은색으로, 다시 녹색으로 점차 변하는 것을 볼 수 있습니다.

막히는 부분이 있으면 여기에서 소스 코드를 확인하세요.

보시다시피, 유니폼은 매 프레임마다 변경될 수 있는 속성을 설정하거나 애플리케이션과 셰이더 간에 데이터를 교환하는 데 유용한 도구입니다. 하지만 각 정점에 색상을 설정하려면 어떻게 해야 할까요? 이 경우 정점의 개수만큼 유니폼을 선언해야 합니다. 더 나은 해결책은 정점 속성에 더 많은 데이터를 포함하는 것이며, 지금부터 그 방법을 살펴보겠습니다.

더 많은 속성!

이전 장에서 VBO를 채우고, 정점 속성 포인터를 구성하고, 이 모든 것을 VAO에 저장하는 방법을 살펴보았습니다. 이번에는 정점 데이터에 색상 데이터도 추가하려고 합니다. 정점 배열에 3개의 float형 색상 데이터를 추가할 것입니다. 삼각형의 각 꼭짓점에 각각 빨강, 초록, 파랑 ​​색상을 할당합니다.

float vertices[] = {
    // positions         // colors
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // bottom right
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // bottom left
     0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    // top 
};    

이제 정점 셰이더로 보낼 데이터가 더 많아졌으므로, 정점 셰이더가 색상 값을 정점 속성 입력으로 받도록 조정해야 합니다. 레이아웃 지정자를 사용하여 aColor 속성의 위치를 ​​1로 설정했음에 유의하세요.

#version 330 core
layout (location = 0) in vec3 aPos;   // the position variable has attribute position 0
layout (location = 1) in vec3 aColor; // the color variable has attribute position 1

out vec3 ourColor; // output a color to the fragment shader

void main()
{
    gl_Position = vec4(aPos, 1.0);
    ourColor = aColor; // set ourColor to the input color we got from the vertex data
}  

이제 프래그먼트의 색상에 유니폼 변수를 사용하지 않고 ourColor 출력 변수를 사용하므로 프래그먼트 셰이더도 변경해야 합니다.

#version 330 core
out vec4 FragColor;  
in vec3 ourColor;

void main()
{
    FragColor = vec4(ourColor, 1.0);
}

정점 속성을 하나 더 추가하고 VBO의 메모리를 업데이트했기 때문에 정점 속성 포인터를 다시 구성해야 합니다. VBO 메모리에 업데이트된 데이터는 이제 다음과 같습니다.

현재 레이아웃을 알고 있으므로 glVertexAttribPointer를 사용하여 정점 형식을 업데이트할 수 있습니다.

// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// color attribute
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);

glVertexAttribPointer 함수의 처음 몇 가지 인수는 비교적 간단합니다. 이번에는 속성 위치 1에 있는 정점 속성을 구성합니다. 색상 값은 3개의 float 크기이며 값을 정규화하지 않습니다.

이제 정점 속성이 두 개이므로 스트라이드 값을 다시 계산해야 합니다. 데이터 배열에서 다음 속성 값(예: 위치 벡터의 다음 x 성분)을 얻으려면 위치 값 3개와 색상 값 3개를 포함하여 총 6개의 float만큼 오른쪽으로 이동해야 합니다. 따라서 스트라이드 값은 float 크기의 6배인 바이트 단위(24바이트)가 됩니다.
또한 이번에는 오프셋을 지정해야 합니다. 각 정점에 대해 위치 정점 속성이 먼저 오므로 오프셋을 0으로 설정합니다. 색상 속성은 위치 데이터 다음에 시작되므로 오프셋은 3 * sizeof(float) 바이트(= 12바이트)입니다.

애플리케이션을 실행하면 다음과 같은 이미지가 나타납니다.

막히는 부분이 있으면 여기에서 소스 코드를 확인하세요.

이미지가 예상과 다를 수 있는 이유는, 현재 보이는 것처럼 방대한 색상 팔레트가 아닌 단 3가지 색상만 제공했기 때문입니다. 이는 프래그먼트 셰이더의 프래그먼트 보간(fragment interpolation)이라는 기능 때문입니다. 삼각형을 렌더링할 때 래스터화 단계에서는 일반적으로 처음에 지정한 정점 수보다 훨씬 많은 프래그먼트가 생성됩니다. 그러면 래스터라이저는 삼각형 모양에서 각 프래그먼트의 위치를 ​​​​결정합니다.
이러한 위치를 기반으로 프래그먼트 셰이더의 모든 입력 변수를 보간합니다. 예를 들어, 위쪽 점이 녹색이고 아래쪽 점이 파란색인 선이 있다고 가정해 보겠습니다. 프래그먼트 셰이더가 선의 약 70% 지점에 있는 프래그먼트에서 실행되면, 결과적으로 해당 프래그먼트의 색상 입력 속성은 녹색과 파란색의 선형 조합, 더 정확하게는 30% 파란색과 70% 녹색이 됩니다.

삼각형에서 일어난 일이 바로 이것입니다. 꼭짓점이 3개이므로 색상도 3개입니다. 삼각형의 픽셀들을 보면 대략 5만 개 정도의 프래그먼트가 포함되어 있을 것으로 추정되는데, 프래그먼트 셰이더가 이 픽셀들 사이에서 색상을 보간한 것입니다. 색상을 자세히 살펴보면 모든 것이 이해가 될 것입니다. 빨간색에서 파란색으로 갈수록 먼저 보라색을 거쳐 다시 파란색으로 변합니다. 프래그먼트 보간은 프래그먼트 셰이더의 모든 입력 속성에 적용됩니다.

우리가 만든 셰이더 클래스

셰이더를 작성하고 컴파일하고 관리하는 것은 상당히 번거로울 수 있습니다. 셰이더에 대한 마지막 단계로, 디스크에서 셰이더를 읽어 컴파일 및 링크하고 오류를 검사하며 사용하기 쉬운 셰이더 클래스를 만들어 우리의 작업을 좀 더 간편하게 만들어 보겠습니다. 또한 이를 통해 지금까지 배운 지식들을 유용한 추상 객체로 캡슐화하는 방법에 대한 아이디어를 얻을 수 있을 것입니다.

학습 및 이식성을 위해 셰이더 클래스 전체를 헤더 파일에 작성하겠습니다. 먼저 필요한 헤더 파일을 추가하고 클래스 구조를 정의해 보겠습니다.

#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h> // 필요한 모든 OpenGL 헤더를 받을수 있습니다.

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>


class Shader
{
public:
    // 프로그램 ID
    unsigned int ID;

    // 생성자는 셰이더를 읽고 생성합니다.
    Shader(const char* vertexPath, const char* fragmentPath);
    // 셰이더 사용/활성화
    void use();
    // 유니폼 관련 유틸리티 함수
    void setBool(const std::string &name, bool value) const;  
    void setInt(const std::string &name, int value) const;   
    void setFloat(const std::string &name, float value) const;
};

#endif

헤더 파일 맨 위에 몇 가지 전처리 지시문(preprocessor directives)을 사용했습니다. 이 몇줄의 코드 줄들은 컴파일러에게 해당 헤더 파일이 아직 포함되지 않은 경우에만 포함하고 컴파일하도록 지시합니다. 이는 여러 파일에서 셰이더 헤더를 포함하고 있더라도 마찬가지입니다. 이렇게 하면 링크 충돌을 방지할 수 있습니다.

셰이더 클래스는 셰이더 프로그램의 ID를 저장합니다. 생성자는 정점 셰이더와 프래그먼트 셰이더의 소스 코드 파일 경로를 각각 매개변수로 받는데, 이 경로는 간단한 텍스트 파일로 디스크에 저장할 수 있습니다. 편의성을 높이기 위해 몇 가지 유틸리티 함수도 추가했습니다. use 함수는 셰이더 프로그램을 활성화하고, set... 함수들은 유니폼 변수의 위치를 ​​조회하고 값을 설정합니다.

파일에서 읽기

우리는 C++ 파일 스트림을 사용하여 파일의 내용을 여러 문자열 객체로 읽어보겠습니다.

Shader(const char* vertexPath, const char* fragmentPath)
{
    // 1. filePath에서 정점/프래그먼트 소스 코드를 가져옵니다.
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;
    std::ifstream fShaderFile;
    // ifstream 객체가 예외를 발생시킬 수 있도록 보장합니다.
    vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
    fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
    try 
    {
        // 파일 열기
        vShaderFile.open(vertexPath);
        fShaderFile.open(fragmentPath);
        std::stringstream vShaderStream, fShaderStream;
        // 파일 버퍼의 내용을 스트림으로 읽어들입니다.
        vShaderStream << vShaderFile.rdbuf();
        fShaderStream << fShaderFile.rdbuf();       
        // 파일 핸들러 닫기
        vShaderFile.close();
        fShaderFile.close();
        // 스트림을 문자열로 변환
        vertexCode   = vShaderStream.str();
        fragmentCode = fShaderStream.str();     
    }
    catch(std::ifstream::failure e)
    {
        std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
    }
    const char* vShaderCode = vertexCode.c_str();
    const char* fShaderCode = fragmentCode.c_str();
    [...]

다음으로 셰이더를 컴파일하고 링크해야 합니다. 컴파일/링크가 실패했는지 여부와 실패했을 경우 컴파일 오류를 출력하는 것도 중요합니다. 이는 디버깅 시 매우 유용하며, 나중에 이러한 오류 로그가 필요할 것입니다.

// 2. 컴파일 셰이더
unsigned int vertex, fragment;
int success;
char infoLog[512];

// 정점 셰이더
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// 컴파일 오류가 있으면 출력
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{
    glGetShaderInfoLog(vertex, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};

// 프래그먼트 셰이더도 마찬가지입니다.
[...]

// 셰이더 프로그램
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// 링킹 오류가 있으면 출력합니다.
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if(!success)
{
    glGetProgramInfoLog(ID, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}

// 셰이더는 현재 프로그램에 연결되어 있으므로 더 이상 필요하지 않기 때문에 삭제합니다.
glDeleteShader(vertex);
glDeleteShader(fragment);

use 함수는 간단합니다.

void use() 
{ 
    glUseProgram(ID);
} 

마찬가지로 모든 유니폼 설정 함수에 대해서도 마찬가지입니다.

void setBool(const std::string &name, bool value) const
{         
    glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value); 
}
void setInt(const std::string &name, int value) const
{ 
    glUniform1i(glGetUniformLocation(ID, name.c_str()), value); 
}
void setFloat(const std::string &name, float value) const
{ 
    glUniform1f(glGetUniformLocation(ID, name.c_str()), value); 
} 

자, 이렇게 해서 셰이더 클래스가 완성되었습니다. 셰이더 클래스 사용법은 매우 간단합니다. 셰이더 객체를 한 번 생성한 후에는 바로 사용하기 시작하면 됩니다.

Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
[...]
while(...)
{
    ourShader.use();
    ourShader.setFloat("someUniform", 1.0f);
    DrawStuff();
}

여기서는 정점 셰이더와 프래그먼트 셰이더 소스 코드를 shader.vs와 shader.fs라는 두 파일에 저장했습니다. 셰이더 파일 이름은 원하는 대로 자유롭게 지정할 수 있지만, 개인적으로는 .vs와 .fs 확장자가 직관적이라고 생각합니다.

새로 개발한 셰이더 클래스를 사용한 소스 코드를 여기에서 확인할 수 있습니다. 그리고 여기에서 셰이더 클래스의 소스 코드를 볼 수 있습니다.

연습 문제

  • 정점 셰이더를 조정하여 삼각형이 거꾸로 되도록 해보세요. (해결 방법)
  • 유니폼 변수를 통해 수평 오프셋을 지정하고, 이 오프셋 값을 사용하여 정점 셰이더에서 삼각형을 화면 오른쪽으로 이동시키세요. (해결 방법)
  • out 키워드를 사용하여 정점 위치를 프래그먼트 셰이더로 출력하고, 프래그먼트의 색상을 이 정점 위치와 같게 설정하세요(정점 위치 값이 삼각형 전체에 걸쳐 보간되는 방식을 확인하세요). 이 작업을 완료했다면 다음 질문에 답해 보세요. 삼각형의 왼쪽 아래 변이 검은색인 이유는 무엇일까요? (해결 방법)