🎯 이 글에서 얻을 수 있는 것
- OpenGL 파이프라인에서 vertex shader와 fragment shader가 각각 무엇을 담당하는지 이해
- GLSL 셰이더 파일을 C++에서 로드하고 컴파일/링크하는 완전한 코드 확보
- 셰이더 컴파일/링크 실패 시 로그(
glGetShaderInfoLog,glGetProgramInfoLog)를 뽑는 습관 형성 uniform변수로 CPU에서 GPU로 매 프레임 값(시간)을 넘기는 방법 습득- 시간에 따라 색이 부드럽게 순환하는 삼각형 렌더링
📸 결과물 미리보기

어두운 배경 위에 삼각형 하나가 그려지고, 색이 빨강·초록·파랑 계열로 부드럽게 순환합니다. 색 변화는 CPU가 매 프레임 uTime uniform을 갱신하고, fragment shader가 그 값으로 사인파를 계산하기 때문에 만들어집니다. 윈도우 크기를 바꿔도 삼각형 비율이 유지됩니다.
🧠 개념 설명
셰이더란 무엇인가
셰이더(shader)는 GPU 위에서 실행되는 작은 프로그램입니다. 옛날 고정 파이프라인(glBegin/glEnd 시대)에서는 하드웨어가 하던 일을, 모던 OpenGL에서는 우리가 GLSL(OpenGL Shading Language)로 직접 작성합니다.
여기서 중요한 차이가 하나 있습니다. C++ 코드는 빌드 시점에 컴파일되지만, GLSL 소스는 프로그램이 실행되는 도중에 그래픽스 드라이버가 컴파일합니다. 즉 셰이더에 오타가 있으면 빌드가 아니라 실행 중에 실패하므로, 런타임에 로그를 확인하는 코드가 반드시 필요합니다. 이 글에서 로그 출력 코드를 집요하게 넣는 이유입니다.
파이프라인에서의 위치
OpenGL 3.3 코어 프로파일에서 우리가 직접 프로그래밍할 수 있는 단계는 주로 두 개입니다.

- vertex shader: 정점 한 개당 한 번 실행됩니다. 역할은 정점 데이터를 받아 위치를 결정하는 것. 결과는 내장 변수
gl_Position(vec4, 클립 좌표)에 써야 합니다. 정점을 새로 만들거나 없앨 수는 없습니다. - 래스터화: 결정된 꼭짓점 3개로 "화면의 어떤 픽셀 후보(fragment)들을 이 삼각형이 덮는가"를 GPU가 계산합니다. 이 단계는 우리가 작성하지 않습니다.
- fragment shader: fragment 한 개당 한 번 실행됩니다. 역할은 그 픽셀 후보의 색을 결정하는 것. 3.3 코어 프로파일에는 기본 색 출력이 없으므로
out vec4를 직접 선언해야 합니다.
비유로 정리하면, vertex shader는 "삼각형의 꼭짓점 3개를 화면 어디에 박을지" 정하는 작업이고, fragment shader는 "그 삼각형이 덮은 픽셀들을 무슨 색으로 칠할지" 정하는 작업입니다.
uniform: 한 드로우 콜 전체가 공유하는 값
uniform은 CPU(C++)에서 GPU(GLSL)로 넘기는 전역 변수입니다. 핵심 성질은 하나입니다. 한 번의 드로우 콜(glDrawArrays 한 번) 동안 모든 vertex/fragment 실행이 같은 값을 읽는다. 이름이 uniform인 이유가 바로 "어디서 봐도 동일(uniform)하다"는 뜻입니다.
비유하자면:
- vertex attribute(지난 편의 정점 좌표): 작업자 한 명 한 명에게 개별 배포되는 작업 지시서. 정점마다 값이 다릅니다.
- uniform: 휴게소 게시판에 붙은 전체 공지. 지금 이 삼각형을 칠하는 모든 작업자(모든 fragment 실행)가 똑같이 읽어 갑니다. "지금 시각", "오늘의 기본 색" 같은 값에 적합합니다.
| 구분 | vertex attribute | uniform |
| 값이 바뀌는 단위 | 정점마다 | 드로우 콜마다 (호출 전체에서 동일) |
| CPU에서 설정하는 방법 | VBO + glVertexAttribPointer |
glUniform* 계열 함수 |
| 대표적인 예 | 위치, 색, UV | 시간, 색상 배율, 변환 행렬 |
셰이더 객체의 생명주기 순서
실수하기 쉬우니 순서 그대로 외워 두세요.

glUniform*은 "현재 활성화된 프로그램"의 uniform을 수정하므로, 반드시 glUseProgram 이후에 호출해야 합니다.
🛠️ 구현 준비
이 시리즈 01~02편의 프로젝트(GLFW 윈도우 + GLAD 로딩 완료) 위에 파일만 추가합니다. 이번 편에는 GLM, stb_image는 필요 없습니다.
renderer/
├── CMakeLists.txt # 01~02편에서 구성한 파일
├── main.cpp # 아래 C++ 코드로 교체/추가
└── shaders/
├── triangle.vert # vertex shader (새 파일)
└── triangle.frag # fragment shader (새 파일)
사전 조건 체크:
- GLFW 윈도우 생성 및
gladLoadGLLoader호출이 되어 있을 것 - OpenGL 컨텍스트가 3.3 core 이상일 것
- 실행 시 작업 디렉터리가 프로젝트 루트일 것 (셰이더 경로를 상대 경로로 찾기 때문, 아래 ⚠️ 션 참고)
💻 코드 구현
1단계: GLSL 셰이더 파일 작성
shaders/triangle.vert — vertex shader입니다. 지난 편에서 VBO에 넣은 2D 좌표를 attribute로 받아 그대로 클립 좌표로 내보냅니다.
#version 330 core // 반드시 파일의 첫 줄. 앞에 주석/BOM이 오면 컴파일 오류
// location = 0 은 C++ 쪽 glVertexAttribPointer(0, ...)과 짝이 맞아야 한다
layout (location = 0) in vec2 aPos;
void main()
{
// gl_Position은 vec4(클립 좌표). w = 1 이면 원근 나눗셈 없이 NDC로 그대로 간다
gl_Position = vec4(aPos, 0.0, 1.0);
}
shaders/triangle.frag — fragment shader입니다. uTime으로 채널별 위상을 120°씩 어긋낸 사인파를 만들어 색이 순환하게 합니다.
#version 330 core
uniform float uTime; // 이번 드로우 콜 전체에서 동일한 값: 경과 시간(초)
uniform vec3 uBaseColor; // 기본 색 배율 (uniform vec3 사용 예시)
out vec4 FragColor; // 3.3 core는 기본 출력이 없으므로 직접 선언
void main()
{
const float PHASE = 2.0943951; // 2*pi/3 : 채널마다 위상을 120도씩 어긋냄
vec3 wave;
wave.r = 0.5 + 0.5 * sin(uTime); // sin은 -1~1 → 0~1로 정규화
wave.g = 0.5 + 0.5 * sin(uTime + PHASE);
wave.b = 0.5 + 0.5 * sin(uTime + PHASE * 2.0);
FragColor = vec4(wave * uBaseColor, 1.0);
}
위상을 어긋히지 않으면 세 채널이 동시에 올라가고 동시에 꺼져서 회색 명멸만 보입니다. 위상을 나눠야 "색이 순환"하는 것처럼 보입니다.
2단계: C++ 셰이더 로더 (main.cpp 상단)
파일 읽기 → 컴파일 → 링크 전 과정을 담당하는 헬퍼 클래스입니다. 컴파일은 glGetShaderiv + glGetShaderInfoLog, 링크는 glGetProgramiv + glGetProgramInfoLog로 확인합니다. 둘은 쌍이 다르므로 혼동하지 마세요.
// main.cpp
#include <glad/glad.h> // glad는 GLFW보다 먼저 포함
#include <GLFW/glfw3.h>
#include <fstream>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <string>
// OpenGL 오류는 조용히 누적되므로, 꺼내 올 때까지 반복해서 비워준다
static void checkGlError(const char* context)
{
while (GLenum err = glGetError()) // GL_NO_ERROR(0)가 나올 때까지 배수
{
std::cerr << "[GL Error] " << context
<< " | code=0x" << std::hex << err << std::dec << '\n';
}
}
class Shader
{
public:
unsigned int program = 0;
Shader(const char* vertexPath, const char* fragmentPath)
{
const std::string vSrc = readFile(vertexPath);
const std::string fSrc = readFile(fragmentPath);
const unsigned int vs = compileShader(GL_VERTEX_SHADER, vSrc);
const unsigned int fs = compileShader(GL_FRAGMENT_SHADER, fSrc);
program = glCreateProgram();
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program);
// 링크 상태 확인. 실패하면 "빈 프로그램"으로 그려서 검은 화면이 된다
int linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked != GL_TRUE)
{
char log[1024] = {};
glGetProgramInfoLog(program, sizeof(log), nullptr, log);
std::cerr << "[Program Link Error]\n" << log << '\n';
throw std::runtime_error("shader program link failed");
}
// 링크가 끝난 셰이더 객체는 프로그램에 복사되므로 이제 삭제해도 안전
glDeleteShader(vs);
glDeleteShader(fs);
}
void use() const { glUseProgram(program); }
// uniform 설정은 반드시 use() 이후(활성 프로그램 상태)에 호출
void setFloat(const char* name, float v) const
{
// 데모라 매 프레임 조회. 실 프로젝트면 시작 시 한 번만 조회해 캐시 권장
glUniform1f(glGetUniformLocation(program, name), v);
}
void setVec3(const char* name, float x, float y, float z) const
{
glUniform3f(glGetUniformLocation(program, name), x, y, z);
}
private:
static std::string readFile(const char* path)
{
std::ifstream in(path, std::ios::in | std::ios::binary);
if (!in) // 작업 디렉터리가 다르면 여기서 걸린다
throw std::runtime_error(std::string("cannot read shader file: ") + path);
std::stringstream buffer;
buffer << in.rdbuf();
return buffer.str();
}
static unsigned int compileShader(GLenum type, const std::string& source)
{
unsigned int shader = glCreateShader(type);
const char* cSrc = source.c_str();
glShaderSource(shader, 1, &cSrc, nullptr);
glCompileShader(shader);
// 컴파일 상태 확인. 이 로그가 없으면 오류가 나도 아무 메시지도 안 나온다
int compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled != GL_TRUE)
{
char log[1024] = {};
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
std::cerr << "[Shader Compile Error]\n" << log << '\n';
glDeleteShader(shader);
throw std::runtime_error("shader compile failed");
}
return shader;
}
};
3단계: main() — 초기화와 렌더 루프
삼각형 geometry는 지난 편과 동일합니다. 새로워진 점은 렌더 루프에서 매 프레임 uTime을 갱신한다는 것뿐입니다.
static void framebufferSizeCallback(GLFWwindow* window, int width, int height)
{
// 윈도우 크기 변경 시 뷰포트도 갱신해야 비율이 깨지지 않는다
glViewport(0, 0, width, height);
}
int main()
{
if (!glfwInit())
return -1;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); // macOS에서는 필수
#endif
GLFWwindow* window = glfwCreateWindow(960, 720, "Renderer #05 - GLSL Shader",
nullptr, nullptr);
if (!window)
{
std::cerr << "window creation failed\n";
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
if (!gladLoadGLLoader(reinterpret_cast<GLADloadproc>(glfwGetProcAddress)))
{
std::cerr << "GLAD init failed\n";
glfwTerminate();
return -1;
}
// 일부 환경은 생성 시점에 콜백이 안 오므로 초기 뷰포트는 직접 설정
int fbW = 0, fbH = 0;
glfwGetFramebufferSize(window, &fbW, &fbH);
glViewport(0, 0, fbW, fbH);
glfwSetFramebufferSizeCallback(window, framebufferSizeCallback);
// 경로는 실행 파일 위치가 아니라 "작업 디렉터리" 기준이다
Shader shader("shaders/triangle.vert", "shaders/triangle.frag");
float vertices[] = { // NDC 좌표 (x, y)
0.0f, 0.5f, // 꼭대기
-0.5f, -0.5f, // 왼쪽 아래
0.5f, -0.5f, // 오른쪽 아래
};
unsigned int vao = 0, vbo = 0;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
// 순서 중요: VAO 바인딩 → VBO 바인딩 → 업로드 → attribute 레이아웃 정의
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
while (!glfwWindowShouldClose(window))
{
glfwPollEvents();
glClearColor(0.09f, 0.09f, 0.12f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
shader.use(); // 1) 프로그램 활성화
shader.setFloat("uTime", static_cast<float>(glfwGetTime())); // 2) uniform 갱신
shader.setVec3("uBaseColor", 1.0f, 1.0f, 1.0f);
glBindVertexArray(vao); // 3) VAO 바인딩
glDrawArrays(GL_TRIANGLES, 0, 3); // 4) 드로우
checkGlError("frame end"); // 문제 없을 땐 아무것도 출력하지 않는다
glfwSwapBuffers(window);
}
glDeleteVertexArrays(1, &vao);
glDeleteBuffers(1, &vbo);
glDeleteProgram(shader.program);
glfwTerminate();
return 0;
}
uniform 설정은 매 프레임 해도 되는가? 됩니다. uniform 갱신 자체는 가벼운 상태 변경이고, 이런 시간 값 갱신은 실제로도 매 프레임 합니다. 다만 glGetUniformLocation 문자열 조회를 매 프레임 반복하는 것은 데모라서 허용한 것이고, 실 프로젝트에서는 프로그램 링크 후 한 번만 조회해 멤버 변수에 캐시해 두는 편이 좋습니다.
실행 결과
실행하면 어두운 남색 계열 배경 위에 삼각형이 나타나고, sin(uTime)의 위상 차이에 의해 빨강→초록→파랑이 겹치듯 순환합니다. 콘솔에 [Shader Compile Error]나 [GL Error]가何も 출력되지 않고 색이 변한다면 정상입니다.
✅ 실행 방법
01~02편의 CMake 설정을 그대로 사용합니다. 셰이더 파일을 빌드 디렉터리로도 복사해 두면 IDE에서 실행 버튼으로 돌려도 경로 문제가 줄어듭니다.
# CMakeLists.txt에 추가 (선택)
file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/shaders
DESTINATION ${CMAKE_CURRENT_BINARY_DIR})
cmake -B build -S .
cmake --build build
# 프로젝트 루트에서 실행 (shaders/ 상대 경로가 해결되어야 한다)
./build/renderer # Linux / macOS
build\Debug\renderer.exe # Windows (생성기에 따라 경로 차이)
⚠️ 흔한 실수와 디버깅 체크리스트
- 검은 화면 ① — 셰이더 로그를 안 보고 있다. 컴파일/링크 실패는 예외도 없이 조용히 지나갑니다.
glGetShaderiv(GL_COMPILE_STATUS)/glGetProgramiv(GL_LINK_STATUS)확인과glGetShaderInfoLog/glGetProgramInfoLog출력이 코드에 없으면, 오류의 존재 자체를 알 수 없습니다. - 검은 화면 ② — 셰이더 파일 로드 실패.
"shaders/triangle.vert"는 실행 파일 위치가 아니라 작업 디렉터리 기준입니다. IDE의 기본 작업 디렉터리가 소스 디렉터리와 다른 경우가 흔합니다. -
#version 330 core가 첫 줄이 아니다. 앞에 주석 한 줄, 빈 줄, BOM만 있어도 컴파일 오류입니다. GLSL에서#version은 반드시 첫 줄이어야 합니다. -
glUseProgram전에glUniform*호출. 활성 프로그램이 없으면GL_INVALID_OPERATION(0x0502)이 나거나, 다른 프로그램의 uniform을 수정하는 사고가 됩니다. 순서:use()→ uniform 설정 → 드로우. -
glGetUniformLocation이 -1을 반환. 이름 오타, 혹은 셰이더에서 해당 uniform을 한 번도 사용하지 않아 컴파일러가 최적화로 제거한 경우입니다. location -1로의glUniform*은 아무 오류 없이 무시되므로, 값이 반영 안 되면 스펠링부터 의심하세요. - VAO 미바인딩 / attribute 미활성화.
glBindVertexArray없이glDrawArrays를 호출하거나glEnableVertexAttribArray을 빼면 아무것도 안 그려지거나 부정확한 결과가 나옵니다. - 좌표 범위 실수. vertex shader가 쓰는 좌표는 NDC 기준 -1~1입니다. 이 범위를 벗어나면 클리핑되어 삼각형이 잘리거나 사라집니다.
- 리사이즈 후 비율 깨짐.
glfwSetFramebufferSizeCallback에서glViewport를 갱신하지 않으면 윈도우를 늘렸을 때 화면이 stretched 됩니다. - uniform으로 정점별 변화를 기대. uniform은 드로우 콜 전체에서 동일합니다. 정점마다 다른 값(예: 정점 색)은 attribute와 VBO로 넘겨야 합니다. (다음 편 주제입니다.)
🧪 연습 문제
- (개념 확인) C++에서
uBaseColor를(1.0, 0.6, 0.2)로 바꿔 실행해 보세요. 결과 색이 어떻게 변하는지 관찰하고, "왜 모든 픽셀이 동일하게 변하는가"를 uniform의 정의로 한 문장 설명해 보세요. - (응용) vertex shader에
uniform vec2 uOffset;을 추가하고, C++에서uOffset = (0.3*sin(t), 0.2*cos(t))를 넣어 삼각형이 타원 궤도로 움직이게 만들어 보세요. uniform이 fragment뿐 아니라 vertex 단계에서도 동일하게 동작함을 확인하는 연습입니다. - (확장) fragment shader의 내장 변수
gl_FragCoord(현재 fragment의 화면 픽셀 좌표)를 이용해, 시간뿐만 아니라 화면 위치에 따라서도 색이 바뀌는 그라디언트를 만들어 보세요. 예:FragColor = vec4(vec3(gl_FragCoord.x / 960.0) * wave, 1.0);
🔭 다음 편 예고
이번 편에서 삼각형 "전체"가 같은 색으로 변하는 것을 봤다면, 다음 질문은 당연히 이것입니다. "꼭짓점마다 다른 색을 주면 가운데는 왜 부드럽게 섞일까?" 다음 편에서는 vertex shader의 out / fragment shader의 in 변수, 그리고 그 사이에서 GPU가 자동으로 수행하는 보간(interpolation)을 다룹니다.
📚 참고 자료
- Khronos OpenGL Reference Pages —
glGetShaderInfoLog,glGetProgramInfoLog,glUniform,glUseProgram항목 - Khronos OpenGL Wiki — Core Language (GLSL)
- LearnOpenGL — Getting started: Shaders
- GLFW Documentation
- GLAD (OpenGL 로더 제너레이터) 공식 리포지터리
'잡(job)기술 > opengl' 카테고리의 다른 글
| OpenGL #7 - 텍스처 매핑 - stb_image로 이미지 로드하고 UV·샘플러·필터링·래핑 마스터하기 (0) | 2026.08.27 |
|---|---|
| OpenGL #06 — VBO·VAO·EBO로 사각형 그리기: 인덱스 버퍼와 정점 재사용 (0) | 2026.08.24 |
| OpenGL #04 - 첫 삼각형 그리기 (0) | 2026.08.23 |
| OpenGL #03 - GLFW, GLAD, CMake로 OpenGL 3.3 개발 환경 세팅 (0) | 2026.08.22 |
| OpenGL #02 - 그래픽스 파이프라인 A to Z: GPU의 역할, 상태 머신, 정점에서 픽셀까지 (1) | 2026.08.20 |