※ 본 포스팅은 learnopengl.com을 번역 및 가감한 포스팅이며, learnopengl에서는 번역 작업 참여를 적극 장려하고 있습니다!
아래 링크에서 원문을 확인할 수 있습니다.
Uniforms
uniform은 CPU에서 GPU shader로 데이터를 전달하는 또 다른 방법입니다. vertex attribute와는 좀 다른데, uniform은 전역적이라서 shader program object에 변수가 고유하게 유지되어있어서 어떤 shader 단계에서도 접근할 수 있습니다. 전역 변수와 비슷한 개념이라고 보시면 됩니다. 한 번 설정하면 재설정하는게 아니라면 값을 유지합니다.
GLSL에서 uniform을 선언해봅시다. uniform 키워드와 type 변수명으로 구성됩니다. uniform 변수로 색상을 설정해봅시다.
#version 330 core
out vec4 FragColor;
uniform vec4 ourColor; // 이 변수를 OpenGL 코드에서 설정합니다.
void main()
{
FragColor = ourColor;
}
fragment shader에서 uniform 변수 ourColor 변수를 선언해서 색상 값으로 사용합니다. opengl 에서 uniform 변수를 설정하면 vertex shader에서 넘겨주지 않아도 이렇게 사용할 수 있습니다.
GLSL코드에서 uniform을 선언하고 사용하지 않는다면 컴파일러는 변수를 삭제합니다. 근데 이게 예상치 못한 오류를 불러올 수도 있습니다.
아직 opengl에서 uniform 변수에 값을 넣지 않았죠? shader 에서 uniform의 위치를 찾고 업데이트 해야합니다. 이번에는 시간에 따라 변하는 색상 코드입니다.
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~1 범위에서 색상을 바꾸고 greenValue에 저장하는 식입니다.
값을 uniform에 저장하려면 먼저 glGetUniformLocation을 사용해서 ourColor uniform의 위치를 찾습니다. -1을 반환하면 찾지 못한 겁니다. 그 다음 glUniform4f 함수로 uniform 값을 설정합니다. uniform 위치를 찾고 나서 값을 변경하려면 shader program을 사용하는걸로 설정해야합니다. uniform 업데이트는 현재 활성화된 프로그램에 하는 것입니다.
그거 아시나요? Opengl은 원래 C라이브러리라서 함수 오버로딩이 없습니다. 그래서 일일히 다른 함수를 만들어야하는데, glUniform이 그렇습니다. 설정하는 uniform 형태에 따라서 접미사가 다르죠.
● f: 함수가 float 값을 기대합니다.
● i: 함수가 int 값을 기대합니다.
● ui: 함수가 unsigned int 값을 기대합니다.
● 3f: 함수가 3개의 float 값을 기대합니다.
● fv: 함수가 float 벡터/배열을 기대합니다.
필요한 type에 맞는 함수를 써야합니다. 우리는 float을 설정하려고 하니까 glUniform4f를 사용해서 전달하였습니다.
이제 색상이 점차적으로 변하도록 프레임마다 uniform을 업데이트해서 렌더링에 반영합니다. 매 프레임마다 greenValue를 계산해서 unifrom을 업데이트합니다.
while(!glfwWindowShouldClose(window))
{
// 입력 처리
processInput(window);
// 렌더링
// 색상 버퍼 지우기
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// 셰이더 활성화
glUseProgram(shaderProgram);
// 유니폼 색상 업데이트
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);
// 삼각형 렌더링
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
// 버퍼 교체 및 IO 이벤트 처리
glfwSwapBuffers(window);
glfwPollEvents();
}
삼각형을 그리기 전에 프레임마다 unifrom값을 업데이트하면 삼각형에 색상이 반영되서 연속적으로 변합니다.
uniform은 이렇게 애플리케이션과 shader간 데이터 교환에 유용합니다. 여기서 이제는 vertex마다 색상을 각자 다르게 하려면 그만큼 uniform을 만들어야하는데 더 나은 방법이 있습니다. vertex attribute에 데이터를 더 추가하는 방법입니다.
More attributes!
이전에 vertex data를 VBO와 vertex attribute를 구성해서 저장하고 이를 VAO에 저장했었습니다. vertex data에 color 데이터를 추가합시다. vertex array에 색상 rgb를 각각 float 3개로 추가합니다.
float vertices[] = {
// positions // colors
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 오른쪽 아래
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 왼쪽 아래
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 위쪽
};
데이터가 많아졌으니 vertex attribute에 색상을 추가해야합니다. aColor 속성르 지정해서 layout을 1로 설정합니다.
#version 330 core
layout (location = 0) in vec3 aPos; // 위치 변수는 속성 위치 0을 가집니다.
layout (location = 1) in vec3 aColor; // 색상 변수는 속성 위치 1을 가집니다.
out vec3 ourColor; // 프래그먼트 셰이더로 색상을 출력
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor; // ourColor를 정점 데이터에서 받은 입력 색상으로 설정
}
uniform은 지금은 사용하지 않을거라 fragment shader에서 outColor를 입력변수로 선업합니다.
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
void main()
{
FragColor = vec4(ourColor, 1.0);
}
이렇게 vertex attribute를 추가해서 VBO의 메모리를 업데이트했으니, vertex attribute pointer를 다시 구성합니다.
현재 color를 추가해서 이렇게 vertex attribute가 변경되었습니다. layout을 알았으니 glVertexAttribPointer를 사용해서 vertex 형식을 업데이트합니다.
// 위치 속성
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 색상 속성
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
glVertexAttribPointer를 보면 color attribution은 attribute positoin 1에 구성되고, 3개의 float 크기를 갖고, 값은 정규화하지 않는 설정입니다.
여기서 vertex attribute가 추가되었으니 stride를 다시 설정해야합니다. 현재에서 다음 vertex attribute값을 얻기 위해 이동해야하는 거리는 6개의 float 칸만큼 이동해야하므로 stride는 6 * 4 = 24 byte가 됩니다.
그리고 색상 attibute의 경우 offset을 정해야합니다. position attibute는 0에서 시작하면 되지만, color attribute는 float 3칸을 이용해야하니 4 * 3 = 12byte가 offset 값입니다.
이렇게 실행하면 아래 이미지가 나옵니다.
이미지가 무지개색입니다. 색상은 3개만 줬는데 말이죠. 이런 fragment interpolation이라는 건데, 래스터화 단계에서는 vertex가 세 개였더라도 화면에서의 fragment는 많이 생성됩니다. 삼각형의 어디 위치의 픽셀인지에 따라서 그만큼 color들이 보간된 색상이 나오는 것입니다.
이해가 어렵다면 예를들어 봅시다. 위쪽 점이 녹색이고 아래쪽 점이 파란색인 선이 있다고 가정해 봅시다. 프래그먼트 셰이더가 선의 70% 지점에 있는 프래그먼트에서 실행된다면, 해당 프래그먼트의 결과 색상 입력 속성은 녹색과 파란색의 선형 결합이 됩니다. 30%의 파란색과 70%의 녹색이 혼합된 색상이 되는 겁니다.
우리는 삼각형을 그릴 때 3개의 정점과 3개의 색상을 가지고 그렸지만, 삼각형의 픽셀을 보면 약 50000개의 프래그먼트가 포함되어있습니다. 프래그먼트 셰이더는 이러한 픽셀들 사이에서 색상을 보간한 것입니다.
Our own shader class
셰이더를 작성하고, 컴파일하고, 관리하는 과정은 꽤 번거롭죠. 그래서 셰이더 클래스를 만들어 우리의 작업을 조금 더 쉽게 만드는 방법을 알아봅시다. 지금은 몰라도 나중에는 추상 객체로 캡슐화하여 개발하는 방법이 필요할 수도 있으니 말입니다.
shader class는 따로 헤더를 분리해서 구현합니다.
#ifndef SHADER_H
#define SHADER_H
#include <glad/glad.h> // OpenGL 헤더를 얻기 위해 glad를 포함
#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
헤더 파일 상단에서 여러 전처리 지시문을 사용했는데요. 보면 헤더 파일이 아직 포함되지 않았을 때만 포함시키고 컴파일하도록 하는겁니다. 여러 파일이 셰이더 헤더를 포함하더라도 중복 포함을 방지하여 링크 충돌을 예방할 수 있습니다.
Shader class에 shader program의 ID를 저장하고, 생성자에는 vertex shader fragment shader 소스 코드 파일 경로를 넣어 초기화합니다. 단순히 텍스트 파일로 넣을 겁니다. shader program을 활성화하는 use나 uniform 위치를 요청하고 업뎃하는 set과 같은 몇 가지 유틸리티 함수도 추가했습니다.
Reading from file
file을 읽는 방법 입니다.
C++ 파일 스트림을 사용하여 파일의 내용을 여러 문자열 객체에 저장합니다.
Shader(const char* vertexPath, const char* fragmentPath)
{
// 1. 파일 경로에서 정점/프래그먼트 셰이더 소스 코드를 가져옵니다.
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 = fShaderCode.c_str();
[...]
다음으로, shader를 컴파일하고 링크합니다. 컴파일 및 링크가 실패했는지 확인하고, 실패한 경우 컴파일 시 발생한 오류를 출력하도록 하는 예외처리를 꼭 구현해야합니다. 없으면 오류 발생시 디버깅이 매우 어려울 겁니다.
// 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 함수는 glUseProgram만을 실행합니다.
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 관련된 조작을 할 수 있을 겁니다.
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라는 두 파일에 저장했는데, 이름은 자유롭게 정하면 되고, 확장자는 vertex shader는 vs fragment shader는 fs를 사용했습니다.