🎯 이 글에서 얻을 수 있는 것
glm::translate,glm::rotate,glm::scale로 모델 행렬을 만드는 방법- 변환 행렬을 유니폼으로 정점 셰이더에 전달해 물체를 이동·회전·확대하는 방법
- 행렬 곱셈 순서가 정점에 실제 적용되는 순서와 다른 이유와 그 함정
glm::mat4초기화, 라디안 변환, 유니폼 위치 조회에서 자주 하는 실수 회피법- 시간에 따라 회전하는 애니메이션 변환 구현
📸 결과물 미리보기

색이 입혀진 사각형이 화면 오른쪽으로 이동한 상태에서 45도 기울어지고, 크기가 약간 커진 채로 그려집니다. 애니메이션 버전에서는 사각형이 자기 중심을 축으로 계속 회전하며, 이 변환은 매 프레임 GPU의 정점 셰이더에서 수행됩니다.
🧠 개념 설명
변환(Transformation)이란?
우리가 만든 정점 데이터는 기본적으로 "물체 자신의 좌표계"에 있습니다. 이를 오브젝트 공간(Object Space) 좌표라고 부릅니다. 그런데 게임이나 렌더링에서는 물체를 원하는 위치에 놓고, 돌리고, 크기를 바꿔야 합니다. 이 작업을 변환(Transformation) 이라고 합니다.
비유하자면 도화지 위의 스티커를 다루는 것과 같습니다.
- 확대/축소 — 스티커를 원하는 크기로 늘린다.
- 회전 — 스티커를 제자리에서 원하는 각도로 돌린다.
- 이동 — 스티커를 도화지의 원하는 위치에 붙인다.
이 순서가 중요합니다. 스티커를 도화지에 먼저 붙여버린 다음 회전하면, 스티커가 자기 중심이 아니라 다른 기준점을 중심으로 돌아버립니다. 행렬 변환도 똑같습니다. 순서가 결과의 직관을 결정합니다.
4x4 행렬과 동차 좌표계
변환 행렬은 4x4 행렬이고, 정점은 vec4(x, y, z, w)로 확장해서 곱합니다. 이 확장된 좌표를 동차 좌표계(Homogeneous Coordinates) 라고 합니다.
w = 1→ 위치(점)를 나타내며, 이동 변환의 영향을 받습니다.w = 0→ 방향(벡터)을 나타내며, 이동 변환의 영향을 받지 않습니다.
4x4 행렬의 마지막 행/열을 사용하면 회전이나 확대만으로는 표현할 수 없는 평행 이동(translation) 을 행렬 곱셈 하나로 표현할 수 있습니다.
OpenGL 파이프라인에서 변환의 위치
변환은 정점 셰이더 단계에서 일어납니다. 정점 셰이더에서 gl_Position을 계산할 때 변환 행렬을 곱해주면, GPU가 모든 정점에 그 행렬을 적용해 최종 위치를 만듭니다.

이번 편에서는 첫 번째 단계인 모델 행렬(Model Matrix) 을 만듭니다. 뷰 행렬과 투영 행렬은 다음 편에서 이어집니다.
GLM이란?
GLM(OpenGL Mathematics) 은 C++에서 행렬과 벡터 연산을 편하게 해주는 헤더 전용 수학 라이브러리입니다. 구 버전 GLSL 문법과 유사한 형태라 셰이더 코드를 읽는 것처럼 사용할 수 있습니다. GLM은 자체 변환 함수(translate, rotate, scale)를 제공하지만, 이 함수들이 만드는 행렬의 곱셈 순서를 이해하지 못하면 화면이 의도와 다르게 나옵니다. 이 글의 핵심이 바로 그 부분입니다.
🛠️ 구현 준비
사전 조건
- 이전 편(07편)까지의 코드에서 다음이 이미 동작하는 상태라고 가정합니다.
- GLFW 윈도우 생성 및 렌더 루프
- GLAD 로딩
- VAO / VBO / EBO로 사각형(색상 포함) 버퍼 구성
- 이번 편에서 새로 필요한 것은 셰이더 수정과 모델 행렬 생성/전달 코드입니다.
필요한 헤더
#include <glm/glm.hpp> // vec, mat 타입
#include <glm/gtc/matrix_transform.hpp> // translate, rotate, scale
#include <glm/gtc/type_ptr.hpp> // value_ptr로 행렬 데이터를 포인터로 전달
파일 구조
08_transform/
├── CMakeLists.txt
├── assets/shaders/
│ ├── transform.vert // 모델 행렬 유니폼을 받는 정점 셰이더
│ └── transform.frag // 색상을 그대로 출력하는 프래그먼트 셰이더
└── src/
└── main.cpp // 07편 코드에서 렌더 루프와 셰이더 로딩 부분 수정
CMake에서 GLM
GLM은 헤더 전용 라이브러리입니다. 시스템에 설치되어 있다면 find_package(glm REQUIRED) 후 target_link_libraries에 glm::glm을 연결하거나, 간단히 인클루드 경로만 추가해도 됩니다.
find_package(glm REQUIRED)
target_link_libraries(transform_demo PRIVATE glm::glm)
윈도우 리사이즈 처리
창 크기가 바뀌면 렌더링 영역이 찌그러질 수 있으므로, 뷰포트 갱신 콜백을 등록해둡니다. 이 내용은 이번 편의 주제는 아니지만 변환 결과가 화면에 보이는 비율에 영향을 주므로 함께 확인합니다.
glfwSetFramebufferSizeCallback(window, [](GLFWwindow*, int width, int height) {
glViewport(0, 0, width, height); // 프레임버퍼 크기에 맞춰 뷰포트 갱신
});
💻 코드 구현
1단계: 정점 셰이더 — shaders/transform.vert
모델 행렬 유니폼 uModel을 선언하고, 정점 좌표에 곱해서 gl_Position을 만듭니다.
#version 330 core
layout (location = 0) in vec3 aPos; // 정점 위치
layout (location = 1) in vec3 aColor; // 정점 색상
uniform mat4 uModel; // CPU에서 전달할 모델 행렬
out vec3 vColor;
void main()
{
// vec4(aPos, 1.0): w=1인 "점"으로 만들어 이동 변환의 영향을 받게 함
// uModel을 곱해 오브젝트 좌표를 변환된 좌표로 만듦
gl_Position = uModel * vec4(aPos, 1.0);
vColor = aColor;
}
gl_Position = uModel * vec4(aPos, 1.0);이 이번 편의 전부라고 해도 과언이 아닙니다. 행렬 곱셈이 정점 하나하나에 적용되어 물체 전체가 변환됩니다.
2단계: 프래그먼트 셰이더 — shaders/transform.frag
변환과 색상은 무관하므로, 이전과 동일하게 보간된 색상을 그대로 출력합니다.
#version 330 core
in vec3 vColor;
out vec4 FragColor;
void main()
{
FragColor = vec4(vColor, 1.0);
}
3단계: 셰이더 컴파일과 오류 로그 출력 — src/main.cpp
셰이더가 컴파일되지 않으면 검은 화면만 나옵니다. 실패 원인을 알 수 있도록 glGetShaderInfoLog로 로그를 반드시 출력합니다.
#include <iostream>
#include <string>
#include <GLFW/glfw3.h>
#include <glad/gl.h>
GLuint compileShader(GLenum type, const char* source)
{
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
int success = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success)
{
char infoLog[512];
glGetShaderInfoLog(shader, sizeof(infoLog), nullptr, infoLog);
std::cerr << "Shader compilation failed:\n" << infoLog << std::endl;
}
return shader;
}
GLuint createProgram(const char* vertSrc, const char* fragSrc)
{
GLuint vert = compileShader(GL_VERTEX_SHADER, vertSrc);
GLuint frag = compileShader(GL_FRAGMENT_SHADER, fragSrc);
GLuint program = glCreateProgram();
glAttachShader(program, vert);
glAttachShader(program, frag);
glLinkProgram(program);
int success = 0;
glGetProgramiv(program, GL_LINK_STATUS, &success);
if (!success)
{
char infoLog[512];
glGetProgramInfoLog(program, sizeof(infoLog), nullptr, infoLog);
std::cerr << "Program linking failed:\n" << infoLog << std::endl;
}
// 링크 완료 후에는 개별 셰이더 객체 삭제 가능
glDeleteShader(vert);
glDeleteShader(frag);
return program;
}
4단계: OpenGL 오류 확인 도구 — src/main.cpp
glGetError()는 마지막으로 발생한 오류 코드를 반환합니다. 변환이 제대로 안 보이거나 검은 화면이 나올 때, 어디에서 문제가 생겼는지 추적하는 데 유용합니다.
void glCheckError_(const char* file, int line)
{
GLenum errorCode;
while ((errorCode = glGetError()) != GL_NO_ERROR)
{
std::string error;
switch (errorCode)
{
case GL_INVALID_ENUM: error = "INVALID_ENUM"; break;
case GL_INVALID_VALUE: error = "INVALID_VALUE"; break;
case GL_INVALID_OPERATION: error = "INVALID_OPERATION"; break;
case GL_OUT_OF_MEMORY: error = "OUT_OF_MEMORY"; break;
case GL_INVALID_FRAMEBUFFER_OPERATION: error = "INVALID_FRAMEBUFFER_OPERATION"; break;
default: error = "UNKNOWN_ERROR"; break;
}
std::cerr << "[GL ERROR] " << error << " at " << file << ":" << line << std::endl;
}
}
#define glCheckError() glCheckError_(__FILE__, __LINE__)
렌더 루프의 주요 호출 뒤에 glCheckError();를 붙여두면, 어떤 호출에서 오류가 났는지 바로 알 수 있습니다.
5단계: 모델 행렬 만들기 — 핵심
이전 편의 정점/인덱스 버퍼 구성은 그대로 둡니다. 이번 편에서 추가하는 핵심 코드는 렌더 루프 안의 모델 행렬 생성과 유니폼 전달입니다.
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
// 렌더 루프 안에서
{
// 1) 반드시 단위행렬로 초기화.
// glm::mat4 model; 은 값이 초기화되지 않을 수 있어 화면이 깨질 수 있음.
glm::mat4 model = glm::mat4(1.0f);
// 2) 변환 적용: 이동 → 회전 → 확대 순서로 호출
model = glm::translate(model, glm::vec3(0.5f, -0.2f, 0.0f));
model = glm::rotate(model, glm::radians(45.0f), glm::vec3(0.0f, 0.0f, 1.0f));
model = glm::scale(model, glm::vec3(1.2f, 1.2f, 1.0f));
// 3) 유니폼 위치 조회. -1이면 셰이더에서 uModel을 사용하지 않는다는 뜻
glUseProgram(shaderProgram); // 유니폼은 "현재 바인딩된 프로그램"에 설정됨
GLint modelLoc = glGetUniformLocation(shaderProgram, "uModel");
if (modelLoc == -1)
{
std::cerr << "Uniform 'uModel' not found (optimized out or typo?)" << std::endl;
}
// 4) 행렬 데이터를 GPU로 전송
// GL_FALSE: 전치하지 않음. GLM은 열 우선 저장이라 셰이더 mat4와 호환됨
// value_ptr: mat4의 연속된 메모리 포인터를 얻음
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
// 5) 그리기
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
}
호출 순서는 translate → rotate → scale이지만, 정점에 실제로 적용되는 순서는 그 반대입니다. 이 부분이 행렬 변환의 가장 큰 함정이므로 아래에서 따로 정리합니다.
6단계: 행렬 곱셈 순서 — 이 글에서 가장 중요한 개념
glm::translate(A, v)는 수학적으로 A * T(v)를 반환합니다. 따라서 위 코드의 최종 행렬은 다음과 같이 만들어집니다.

그리고 셰이더에서는 gl_Position = model * vec4(aPos, 1.0), 즉 (T * R * S) * v가 계산됩니다. 행렬 곱셈은 오른쪽에서 왼쪽으로 적용됩니다.

| 작성한 호출 순서 | 만들어진 행렬 | 정점 기준 실제 적용 순서 | 결과 |
| translate → rotate → scale | T * R * S |
확대 → 회전 → 이동 | 자기 중심 기준 변환 후 원하는 위치로 이동 (의도한 결과) |
| scale → rotate → translate | S * R * T |
이동 → 회전 → 확대 | 이미 이동된 상태에서 원점 기준 회전 → 물체가 원점 주위를 공전하는 것처럼 보임 |
스티커 비유로 돌아가면, 우리가 원하는 동작은 "확대 → 제자리 회전 → 원하는 위치에 붙이기"입니다. 그러려면 코드에는 반대로 translate → rotate → scale 순서로 작성해야 최종 행렬이 T * R * S가 되어 정점에는 S → R → T 순서로 적용됩니다. 이 규칙 하나만 기억하면 변환 순서 때문에 헤매는 일은 거의 없습니다.
7단계: 애니메이션 — 매 프레임 행렬 갱신
모델 행렬을 매 프레임 새로 만들면 물체가 계속 회전합니다. glfwGetTime()은 초 단위를 반환하므로, 각속도를 곱해 라디안으로 변환합니다.
while (!glfwWindowShouldClose(window))
{
glClearColor(0.1f, 0.1f, 0.12f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shaderProgram);
// 초당 90도 회전. 도(degree) 단위를 라디안으로 꼭 변환
float angle = glm::radians(static_cast<float>(glfwGetTime()) * 90.0f);
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(0.5f, 0.0f, 0.0f)); // 궤도 중심 이동
model = glm::rotate(model, angle, glm::vec3(0.0f, 0.0f, 1.0f)); // 자전
GLint modelLoc = glGetUniformLocation(shaderProgram, "uModel");
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
glCheckError();
glfwSwapBuffers(window);
glfwPollEvents();
}
실전에서는 glGetUniformLocation을 매 프레임 호출하는 대신, 프로그램 초기화 시 한 번만 조회해 캐싱하는 것이 일반적입니다. 여기서는 흐름을 명확히 보여주기 위해 매 프레임 호출합니다.
✅ 실행 방법
프로젝트 루트에서 CMake로 빌드합니다.
mkdir build && cd build
cmake ..
cmake --build .
./transform_demo
정상적으로 실행되면 화면 오른쪽으로 이동한 색 사각형이 45도 기울어진 채 약간 커진 상태로 표시됩니다. 애니메이션 버전을 적용했다면, 사각형이 화면 중앙 기준 오른쪽 위치에서 자기 중심으로 계속 회전합니다. 회전이 사각형의 자기 중심을 기준으로 일어나는지를 확인하는 것이 이 편의 핵심 관찰 포인트입니다.
⚠️ 흔한 실수와 디버깅 체크리스트
실수 1: 행렬 적용 순서를 호출 순서로 착각
증상: 물체가 원점 주위를 공전하거나, 예상한 위치와 전혀 다른 곳에서 회전합니다.
원인: translate → rotate → scale 순서로 호출하면 정점에는 확대 → 회전 → 이동 순서로 적용됩니다. 이 반대라고 착각하면 순서를 반대로 작성하게 됩니다.
해결: "정점 기준 오른쪽에서 왼쪽으로 적용"을 항상 기억합니다. 물체가 자기 중심으로 회전하면서 지정 위치로 이동하기를 원하면 코드에는 translate → rotate → scale 순으로 작성합니다.
실수 2: glm::rotate에 도(degree) 값을 그대로 전달
증상: 물체가 예측할 수 없는 속도와 각도로 돌아갑니다.
원인: glm::rotate(model, 45.0f, axis)는 45도가 아니라 45 라디안(약 2578도) 으로 해석됩니다.
해결: 항상 glm::radians(45.0f)로 변환해서 전달합니다.
실수 3: 모델 행렬을 단위행렬로 초기화하지 않음
증상: 화면이 완전히 안 나오거나, 물체가 심하게 찌그러집니다.
원인: glm::mat4 model;은 GLM에서 값이 초기화되지 않을 수 있습니다. 쓰레기 값이나 0으로 채워진 행렬이 되면 변환 결과가 무의미해집니다.
해결: 반드시 glm::mat4 model = glm::mat4(1.0f);로 단위행렬에서 시작합니다.
실수 4: 유니폼 위치가 -1인데 그대로 전달
증상: 검은 화면 또는 GL_INVALID_OPERATION 오류.
원인: 셰이더에서 uModel을 실제로 사용하지 않으면 컴파일러 최적화로 제거되어 glGetUniformLocation이 -1을 반환합니다. 이름 오타도 같은 결과를 만듭니다.
해결: 셰이더에서 uModel이 gl_Position 계산에 실제로 사용되는지, 이름이 일치하는지 확인합니다. -1 체크 후 오류를 출력하는 코드를 넣어두면 원인 파악이 빨라집니다.
실수 5: glUseProgram 전에 유니폼 설정
증상: 유니폼 값이 반영되지 않아 변환이 적용되지 않은 것처럼 보입니다.
원인: glUniform* 계열 함수는 현재 바인딩된 프로그램에 값을 설정합니다. 프로그램 바인딩 전에 호출하면 아무 일도 일어나지 않거나 오류가 됩니다.
해결: glUseProgram(shaderProgram);을 먼저 호출한 뒤 glUniformMatrix4fv를 호출합니다.
디버깅 체크리스트
-
glm::mat4 model = glm::mat4(1.0f);로 단위행렬 초기화를 했는가? -
glm::rotate에 전달한 각도가 라디안 단위인가? (glm::radians()사용) - 호출 순서(
translate → rotate → scale)와 실제 적용 순서(scale → rotate → translate)를 구분해서 설계했는가? -
glUseProgram을 호출한 뒤에glUniformMatrix4fv를 호출했는가? -
glGetUniformLocation반환값이 -1이 아닌가? (셰이더에서 유니폼을 실제로 사용하는지, 이름 오타가 없는지) - 셰이더 컴파일 실패 시
glGetShaderInfoLog로 로그를 확인했는가? -
glDrawElements호출 전에 VAO가 바인딩되어 있는가? (VAO 미바인딩은 검은 화면의 단골 원인) - 윈도우 리사이즈 시
glViewport를 갱신하는 콜백이 등록되어 있는가? -
glCheckError()를 렌더 루프에 넣어GL_INVALID_OPERATION이 뜨지 않는지 확인했는가?
🧪 연습 문제
- 필수 — 변환 조합: 사각형을 화면 오른쪽 위
(0.5, 0.5)위치로 이동하고, 30도 회전, 크기를 절반으로 줄여보세요. 회전이 사각형 자기 중심 기준으로 일어나는지 확인하세요. 힌트: 호출 순서는translate → rotate → scale입니다. - 응용 — 공전과 자전:
glfwGetTime()을 이용해, 사각형이 자기 중심으로 회전(자전)하면서 동시에 화면 중앙을 기준으로 반지름 0.7의 궤도를 도는(공전) 애니메이션을 만들어보세요. 힌트: 행렬 곱 순서를 다음과 같이 조합하면 됩니다.호출 순서와 적용 순서를 거꾸로 읽으면서 왜 이 순서가 공전+자전이 되는지 설명할 수 있다면 행렬 곱셈을 제대로 이해한 것입니다.model = translate(궤도 중심) * rotate(공전각) * translate(궤도 반지름) * rotate(자전각) - 도전 — 두 물체 그리기: 서로 다른 변환을 가진 사각형 두 개를 한 화면에 그려보세요. 핵심은 "유니폼 갱신 후 드로우"를 두 번 반복하는 것입니다.
glUniformMatrix4fv로 첫 번째 행렬을 넣고glDrawElements, 다시 두 번째 행렬을 넣고glDrawElements를 호출합니다. 이 패턴이 이후 여러 물체를 그리는 기본 골격이 됩니다.
🔭 다음 편 예고
다음 편에서는 뷰 행렬과 투영 행렬을 만들어, 3차원 공간에 물체를 배치하고 카메라로 바라보는 완전한 MVP(Model-View-Projection) 파이프라인을 완성합니다.
📚 참고 자료
'잡(job)기술 > opengl' 카테고리의 다른 글
| OpenGL #7 - 텍스처 매핑 - stb_image로 이미지 로드하고 UV·샘플러·필터링·래핑 마스터하기 (0) | 2026.08.27 |
|---|---|
| OpenGL #06 — VBO·VAO·EBO로 사각형 그리기: 인덱스 버퍼와 정점 재사용 (0) | 2026.08.24 |
| OpenGL #05 - GLSL 셰이더 입문: vertex·fragment 셰이더와 uniform (0) | 2026.08.23 |
| OpenGL #04 - 첫 삼각형 그리기 (0) | 2026.08.23 |
| OpenGL #03 - GLFW, GLAD, CMake로 OpenGL 3.3 개발 환경 세팅 (0) | 2026.08.22 |