
Tony Kim님의 OpenGL 삼각형 그리기와 learnopengl.com, ThinMatrix Youtube를 공부하며 작성한 글입니다.
전체적인 삼각형 그리기는 ThinMatrix Youtube의 방법을 따라갔으며, 그 중에서 shader에 대한 이해를 돕고, java 코드가 아닌 c++ 코드로 변환하여 작성하기 위해 Tony Kim님의 블로그를 참고하여 공부하였다.
Shader Progam 스크립트를 이해하기 조금 힘들긴 했는데, 전에 짧게나마 공부했던 c++ 내용들이 새록새록 떠오르며 기분이 좋았다. 과거의 내가 input들을 넣어둔 것들이 현재의 나를 돕고 있다.. 하지만 아직 부족함을 많이 느꼈으며 tour of c++을 병행하여 공부하면 빠르게 늘 것 같다.
셰이더(shader)는 물체의 셰이딩을 할 때 GPU 위에서 실행되는 작은 프로그램들이다.
셰이딩이란 가상의 3D 공간에서 물체의 표면이 빛과 상호작용한 결과를 계산하여 최종 픽셀 색을 결정하는 과정을 의미한다.
최근에는 블러 효과, 크로마키 등 특수 효과를 만들 때 셰이더를 쓰기도 한다.
GPU는 그래픽 출력을 위한 연산을 실행하는 연산 장치이므로 셰이더는 연산 프로그램이라고 볼 수 있다.
이 프로그램들은 그래픽 파이프라인의 각 특정 단계마다 실행된다. 셰이더들은 서로 직접적인 함수 호출이나 메모리 공유는 할 수 없으며, 정해진 파이프라인 단계 간의 입력(in)과 출력(out)을 통해서만 데이터를 전달한다.
정리하자면 셰이더는 가상의 3D 공간에서 물체의 색을 포함한 다양한 효과를 표현하기 위해서 GPU에서 돌리는 연산 프로그램이다. 그리고 우린 이 셰이더 프로그램들에게 명령을 전달하기 위해 셰이더 언어(GLSL, HLSL 등)로 작성된 코드를 전달해야하며 OpenGL은 GLSL 언어로 작성된 셰이더 코드가 필요하다.
셰이더는 C와 유사한 언어인 GLSL(OpenGL Shading Language)로 작성된다. GLSL은 그래픽 처리에 특화되어 있으며, 벡터와 행렬 연산에 유용한 기능들을 포함하고 있다.
셰이더는 항상 버전 선언으로 시작하며, 그 다음 입력 변수, 출력 변수, uniform, 그리고 main 함수가 온다. 각 셰이더의 시작점은 main 함수이며, 여기서 입력 변수를 처리하고 출력 변수에 결과를 저장한다.
// 일반적인 glsl의 구조
#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;
}
우리가 주로 GLSL을 작성하는 셰이더 코드는 Vertex Shader와 Fragment Shader(Pixel Shader라고도 함)에 해당한다.
간단한 3D 도형을 그리는 데에도 이 두 셰이더면 충분하며 빛과 그림자, 특수 효과 등을 표현하기 시작하면서 Geometry Shader, Tessellation Shader 등이 추가된다.
Uniform
Uniform은 CPU(프로그램)에서 GPU(셰이더)로 데이터를 전달할 때 사용하는 전역 변수이다.한 번의 "그리기 호출(Draw Call)"이 진행되는 동안, 모든 정점(Vertex)과 픽셀(Fragment)에 대해 동일한 값을 유지한다.
셰이더는 수천 개의 정점을 동시에 처리한다. 이때 모든 정점에 공통적으로 적용되어야 하는 정보들이 있는데, 그 정보들에 uniform 변수를 사용한다.
(e.g. 변환행렬, 조명 정보, 시간, 텍스쳐)만약 Uniform이 없다면, 모든 정점마다 이 공통 데이터를 일일이 복사해서 넘겨줘야 하므로 메모리와 성능 낭비가 엄청날 것이다.
Vertex Shader는 가상의 3D 공간에 있는 vertex들의 위치를 2차원 화면의 좌표로 변환하는 작업을 수행한다. 더불어 vertices가 포함하고 있는 위치 이외의 다양한 정보들(UV좌표, Normal 정보)을 다음 셰이더 단계로 전달하기 위해 셰이더들이 사용 가능한 변수에 저장하여 내보내기도 한다.
이전 글에서 삼각형의 세 꼭짓점 정보를 vertices라는 배열에 담았다. 그리고 Loader.cpp에서 glVertexAttribPointer 함수를 이용해 0번 Vertex Attribute에 vertices 배열을 읽어들이는 법을 저장하였으며 셰이더는 이 때의 번호 0을 location이라고 부른다고 했다.
// Loader.cpp
...
glVertexAttribPointer(
attributeNumber,
coordinateSize,
GL_FLOAT,
GL_FALSE,
0,
(void*)0
);
...
이렇게 정리된 정보를 glsl을 통해 Vertex Shader에 전달하여 2차원 화면의 좌표로 변환하게 만들도록 하는 것이다.
우선, 셰이더 코드는 별도의 파일에 작성한다.
VertexShader.vert라는 파일을 만들어 준다. 위치는 cpp 파일과 동일한 폴더여도 상관 없고, shaders라는 폴더를 생성하여 관리해도 무방하다.
(이 때, 확장자는 파일의 속성에 아무런 영향이 없지만 관습적으로 vertex shader의 확장자는 vert로 쓴다고 한다.)
// VertexShader.vert
#version 330 core
layout (location = 0) in vec3 aPos;
out vec4 color;
void main() {
gl_Position = vec4(aPos, 1.0);
color = vec4(aPos.x+0.5, 1.0, aPos.y+0.5, 1.0);
}
#version 330 core
GLSL의 버전을 세자리 숫자로 명시한다.
core은 오래되고 비효율적인 함수들(Deprecated)을 모두 제거한 프로필이고, Compatibility은 OpenGL 1.0 시절의 고정 파이프라인(Fixed-function pipeline) 함수부터 최신 함수까지 모두 실행 가능한 프로필이라고 한다. 최적화를 위해서 요즘은 core을 쓴다고 하고 맥은 어차피 Compatibility를 지원하지 않기 때문에 core로 작성하였다.
layout (location = 0) in vec3 apos;
0번째 Vertex Attribute을 가져와 apos라는 이름의 vec3 자료형 변수에 담는다.
즉, 삼각형을 이루는 꼭짓점의 위치값을 vec3 변수에 담는다.
out vec4 color;
도형에 색을 입히기 위해(색이 입혀진 픽셀의 출력을 위해) Vertex Shader의 출력값이자 Fragment Shader의 입력값으로 사용할 color라는 이름의 vec4 자료형 변수이다. 삼각형 색의 정보값을 vec4 변수에 담는다.
void main()
GLSL은 C 언어와 구조가 유사하다. main 함수를 만드는 것도 똑같다.
gl_Position = vec4(apos, 1.0)
gl_Position은 이미 정의되어있는 내장 변수로 vertex shader의 출력 변수(output variable)이다. 이 변수에 저장된 값이 꼭짓점의 좌표값(정확히는 clip-space coordinates)으로 출력되어 나머지 셰이더들에서 사용된다.
Vertex Shader 단계에서 반드시 설정해야 하는 내장 출력 변수이며, 이 값이 이후 클리핑, 투영, 래스터라이즈 과정의 기준이 된다.
vec4 변수이므로 위에서 불러온 apos에 마지막 w값을 1.0으로 지정한다.
color = vec4(aPos.x+0.5, 1.0, aPos.y+0.5, 1.0);
셰이더의 작동 방식에 의해 세 꼭짓점에 색의 정보를 입력해놓으면 나머지 삼각형의 내부의 색은 선형보간으로 fragment shader 단계에서 출력된다.
정확히는 선형보간은 vertex shader 이후 rasterization 단계에서 하드웨어 레벨로 이루어지며 이 값이 fragment shader에 전달된다.
선형보간에 대해서는 이후 선형대수학 관련 포스팅으로 설명할 것이다.
Fragment Shader는 Rasterization 작업으로 생성된 fragment에 색 정보와 깊이 정보를 담는 역할을 한다. Vertex Shader와 Fragment Shader 사이에는 사실 여러 단계들이 내부적으로 존재하는데, 그 중 하나가 Rasterization이다. Rasterization 단계에서 우리가 그리고자 하는 삼각형은 여러 개의 fragment 단위로 쪼개지게 되며, 우린 이 fragment마다 색을 칠해 삼각형을 그려내는 것이다.
// e.g. FragmentShader.frag
#version 330 core
in vec4 color;
out vec4 out_Color;
void main() {
out_Color = color;
}
#version 330 core
Vertex Shader와 마찬가지로 GLSL의 버전을 명시한다.
in vec4 color;
vertex shader에서 작성한 출력값 color를 입력값으로 받는다.
out vec4 out_Color;
vec4 자료형의 출력 변수 out_Color을 선언한다. main 함수에서 이 출력 변수를 정의하여 fragment의 색을 지정한다.
void main()
Vertex Shader와 마찬가지로 main 함수를 작성한다.
out_Color = color;
출력 변수 out_Color을 정의한다. vertex shader에서 작성한 출력값 color와 동일하게 출력할 것이므로 이렇게 작성한다.
위에서 작성한 VertexShader.vert와 FragmentShader.Frag를 적용하여 삼각형을 그리기 위해서는, 추가적인 Shader Program을 작성해야 한다.
Shader Program은
디스크에 있는 GLSL 셰이더 텍스트 파일을 읽어서
→ GPU가 이해할 수 있는 셰이더로 컴파일하고
→ Vertex + Fragment 셰이더를 하나의 실행 프로그램으로 묶는다
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.
glDetachShader(shader, vs);
glDetachShader(shader, fs);
glDeleteShader(vs);
glDeleteShader(fs);
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);
}
GLuint shader;
셰이더 프로그램은 ID 번호를 갖게 된다. 따라서 ID 번호를 저장할 GLuint 변수를 전역 변수로 선언한다.
ReadFile 함수
셰이더 파일을 읽어오는 함수이다. 인자로 읽어올 파일의 경로를 받는다.
AddShader 함수
셰이더 파일을 컴파일하고 ID를 반환하는 함수이다. 인자로 셰이더 코드와 GLenum 자료형의 셰이더 타입을 받는다. 셰이더 타입은 GL_VERTAX_SHADER, GL_FRAGMENT_SHADER 등이 있다.
이 함수에서는 vertex shader/fragment shader 각각 단일 셰이더 객체를 GPU가 실행 가능한 형태로 컴파일하는 것이고, 아직 program은 아니다.
glCreateShader(): 셰이더 프로그램과 연결할 셰이더를 생성하며 그 ID를 반환한다.
glShaderSource(): 직접 작성한 셰이더 코드를 glCreateShader를 통해 만든 셰이더에 연결한다.
glCompileShader(): 셰이더 코드를 컴파일한다.
glGetShaderiv(): 셰이더 코드의 상태를 마지막 인자인 포인터 변수를 통해 반환하는 함수이다.
여기선 컴파일이 잘 됐는지 확인하기 위해서 GL_COMPILE_STATUS 정보를 반환하도록 하였으며 컴파일이 잘 되었으면 GL_TRUE, 실패 시 GL_FALSE를 반환한다.
CompileShader 함수
셰이더 파일들을 컴파일하고 하나의 실행 프로그램으로 링크하는 함수이다. 인자로 Vertex Shader 코드와 Fragment Shader 코드를 저장해둔 문자열을 순서대로 받는다.
이 함수에서 Vertex Shader + Fragment Shader를 묶어서 GPU에서 실제로 실행 가능한 “셰이더 프로그램”을 만든다.
glCreateProgram(): 셰이더 프로그램을 생성하고 그 ID를 반환하는 OpenGL 함수이다.
AddShader(): 셰이더 프로그램에 연결할 셰이더 파일의 ID를 생성하고 셰이더 파일을 컴파일할 함수이다.
glAttachShader(): 링크 과정을 위해 작성한 셰이더를 프로그램에 연결해주는 OpenGL 함수이다.
glLinkProgram(): 셰이더 파일들을 링크하고 GPU에서 실행할 실행 파일을 만드는 OpenGL 함수이다.
glGetProgramiv(): 셰이더 프로그램의 상태를 마지막 인자인 포인터 변수를 통해 반환하는 함수이다.
여기선 링크가 잘 됐는지 확인하기 위해서 GL_LINK_STATUS 정보를 반환하도록 하였으며 링크가 잘 되었으면 GL_TRUE를, 실패 시 GL_FALSE를 반환한다.
CreateShaderProgramFromFiles 함수
ReadFile로 파일을 읽어와 컴파일 단계로 넘겨주는 함수이다. 인자로 Vertex Shader의 파일 경로와 Fragment Shader의 파일 경로를 순서대로 받는다.
const char* vsCode = vsFile.c_str(): “버텍스 셰이더 소스 문자열의 시작 주소”를 얻는다. CompileShader 함수가 각 셰이더 소스 문자열의 시작 주소를 인자로 받기 때문이다.
// 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 = {
-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<unsigned int> indices = {
0,1,3,
3,1,2
};
int main() {
// 1. 디스플레이 생성 (창 띄우기)
DisplayManager::createDisplay();
// 2. 필요한 엔진 컴포넌트 생성
Loader loader;
Renderer renderer;
// 3. 사각형(Quad) 정점 데이터 정의
// 두 개의 삼각형이 합쳐져서 하나의 사각형이 된다.
// EBO 생성으로 생략.
// 4. 데이터를 VAO에 로드하여 RawModel 생성
RawModel model = loader.loadToVAO(vertices, indices);
// 5. 게임 루프 (창을 닫기 전까지 무한 반복)
// 루프 시작 전, 사용할 셰이더 프로그램을 활성화
// 매 프레임마다 바꿀 필요가 없다면 루프 밖에서 한 번만 호출해도 된다.
CreateShaderProgramFromFiles(
"/Users/geemgaeun/Desktop/개인 프로젝트/OpenGL 3D Game/OpenGL 3D Game/VertexShader.vert",
"/Users/geemgaeun/Desktop/개인 프로젝트/OpenGL 3D Game/OpenGL 3D Game/FragmentShader.frag"
);
glUseProgram(shader);
while (!DisplayManager::isCloseRequested()) {
// 1. 게임 로직 및 물리 업데이트 (CPU 연산)
// 예: 캐릭터 이동 계산, 충돌 체크 등
// 생략.
// 2. 화면 렌더링 준비 (배경색으로 지우기)
// 내부적으로 glClear(GL_COLOR_BUFFER_BIT)를 수행
renderer.prepare();
// 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;
}

프로그램을 실행하면, 우리가 작성한 셰이더대로 그려진 삼각형 두 개를 마주할 수 있다.. 감격..!