OpenGL의 한계
Vulkan의 등장
2016년 서울 고속 터미널역 근처에 있는 메리어트 호텔에서 Khronos Group의 Vulkan 컨퍼런스에 참석한지도 10년이 되어간다. 국내의 기업 삼성 해외의 Nvidia, AMD 도 참여 했었던 이 컨퍼런스에서는 차세대 그래픽스 API로 등장한 Vulkan API (당시 1.0버전)의 개발 노하우 등을 공유하는 자리였고, 참가 비용이 꽤 되었음에도 당시 대학원생이었던 나는 교수님의 배려로 선배와 함께 해당 컨퍼런스에 참여할 수 있었다.
당시 1.0 버전이었던 Vulkan API는 벌써 1.3버전이 등장하였고 아마도 앞으로 20년은 더 사용하게 되지 않을까 싶다. 최초 Vulkan API를 봤던 그 순간에는 OpenGL에 비해 굉장히 코드 작성량이 많다는 인상을 받았지만 이내 Graphics Driver의 오버헤드를 줄이고자 이런 디자인을 채택했다는 점, 어플리케이션 개발자 스스로 컨트롤 할 수 있는 영역이 늘어났다는 점 납득이 가는 부분이 많았다. 특히 Multi Threading에서 굉장히 문제가 많았던 OpenGL을 생각해보자면 결국 피할 수 없는 수순이라는 생각을 했었다.
OpenGL의 역사
OpenGL은 내가 태어나기도 두달 전인 1992년 6월에 발표되었다. 출시 이후 굉장히 각광을 받은 API 였고 마이크로소프트에서 대항마로 내놓은 Direct X API도 초창기엔 존 콰멕 같은 전설적인 게임 개발자들에게 OpenGL만 못하다는 평가를 받을 정도로 훌륭한 API였다. 하지만 시대는 흘렀고 2000년대 중반부터 멀티스레딩이 등장하면서 점점 디자인의 한계를 갖기 시작했다. OpenGL 2.x 대에서 GLSL 쉐이더 언어가 등장했지만 선택사항 이었고, 대부분 고정된 파이프라인에 연산을 정의해서 사용하는 식이었다. 가장 기본적으로 삼각형을 그리는 예제를 본다면 아래와 같았다.
#include <GL/glut.h>
void display() {
glClear(GL_COLOR_BUFFER_BIT); // 화면 초기화
glBegin(GL_TRIANGLES); // 삼각형 그리기 시작
glColor3f(1.0f, 0.0f, 0.0f); // 첫 번째 정점 색상 (빨강)
glVertex2f(-0.5f, -0.5f); // 첫 번째 정점 좌표
glColor3f(0.0f, 1.0f, 0.0f); // 두 번째 정점 색상 (초록)
glVertex2f(0.5f, -0.5f); // 두 번째 정점 좌표
glColor3f(0.0f, 0.0f, 1.0f); // 세 번째 정점 색상 (파랑)
glVertex2f(0.0f, 0.5f); // 세 번째 정점 좌표
glEnd(); // 삼각형 그리기 종료
glFlush(); // 명령 실행
}
int main(int argc, char** argv) {
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_SINGLE | GLUT_RGB);
glutInitWindowSize(800, 600);
glutCreateWindow("OpenGL 2.x Triangle");
glClearColor(0.0f, 0.0f, 0.0f, 1.0f); // 배경 색상 설정 (검정)
glutDisplayFunc(display); // 렌더링 함수 등록
glutMainLoop(); // 메인 루프
return 0;
}
삼각형을 그리는 예제가 위와같이 굉장히 짧은 소스코드로 끝나고 그만큼 단순하고 개발이 편했지만 고정된 파이프라인을 사용하는 문제점이 있어 유연성이 떨어지고 변화하는 하드웨어에 대한 지원이 굉장히 부족했었다. 그리고 2008년에 Modern OpenGL의 근간이 되는 OpenGL 3.0 이 등장하였고 OpenGL 3.3에서 Modern OpenGL이 완전히 정의 된 뒤, 내가 대학생이 되기도 전인 2010년에 OpenGL 4 가 등장하게 된다.
이전의 고정된 파이프라인 구조는 Deprecated 되었고, 새로운 표준인 Core Profile 에서는 프로그래머블 파이프라인, 쉐이더 언어의 사용이 의무화 되었고 정점 속성을 관리하기 위한 VAO, 고급 렌더링을 위한 FBO 등이 추가되며 대대적인 변화가 있었다.
이렇게 변화한 API에서 삼각형을 그리는 소스코드는 아래와 같이 변모하게 된다.
#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <iostream>
// 정점 셰이더 소스
const char* vertexShaderSource = R"(
#version 330 core
layout(location = 0) in vec2 aPos; // 정점 위치 입력
layout(location = 1) in vec3 aColor; // 정점 색상 입력
out vec3 vertexColor; // 프래그먼트 셰이더로 전달할 색상
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
vertexColor = aColor;
}
)";
// 프래그먼트 셰이더 소스
const char* fragmentShaderSource = R"(
#version 330 core
in vec3 vertexColor; // 정점 셰이더에서 전달받은 색상
out vec4 FragColor;
void main() {
FragColor = vec4(vertexColor, 1.0);
}
)";
void checkShaderCompile(GLuint shader) {
GLint success;
GLchar infoLog[512];
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(shader, 512, NULL, infoLog);
std::cerr << "ERROR::SHADER::COMPILATION_FAILED\n" << infoLog << std::endl;
}
}
void checkProgramLink(GLuint program) {
GLint success;
GLchar infoLog[512];
glGetProgramiv(program, GL_LINK_STATUS, &success);
if (!success) {
glGetProgramInfoLog(program, 512, NULL, infoLog);
std::cerr << "ERROR::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
}
int main() {
if (!glfwInit()) {
std::cerr << "Failed to initialize GLFW" << std::endl;
return -1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL 3.x Triangle", nullptr, nullptr);
if (!window) {
std::cerr << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
if (glewInit() != GLEW_OK) {
std::cerr << "Failed to initialize GLEW" << std::endl;
return -1;
}
// 정점 데이터
float vertices[] = {
// 위치 // 색상
-0.5f, -0.5f, 1.0f, 0.0f, 0.0f, // 빨강
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, // 초록
0.0f, 0.5f, 0.0f, 0.0f, 1.0f // 파랑
};
// VAO, VBO 생성
GLuint VAO, VBO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// VAO 바인드
glBindVertexArray(VAO);
// VBO 설정
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 정점 속성 설정
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0); // 위치
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(2 * sizeof(float))); // 색상
glEnableVertexAttribArray(1);
// 셰이더 컴파일
GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);
checkShaderCompile(vertexShader);
GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);
checkShaderCompile(fragmentShader);
// 셰이더 프로그램 링크
GLuint shaderProgram = glCreateProgram();
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);
checkProgramLink(shaderProgram);
// 셰이더 삭제
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
// 렌더링 루프
while (!glfwWindowShouldClose(window)) {
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
glfwSwapBuffers(window);
glfwPollEvents();
}
// 리소스 정리
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
glDeleteProgram(shaderProgram);
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
기존 소스코드 대비 4-5배는 늘어난 것을 확인할 수 있다. 다만 이를 통해 최신 그래픽스 하드웨어들의 기능을 이용할 수 있게 되었고 유연성 또한 크게 향상되었다.
OpenGL의 문제점
하지만 시대가 계속 흘러가며 OpenGL은 태생적인 한계와 Open Source라는 한계에 직면하게 된다. 우선 Open Source라는 한계에 대해 생각해보자
Open Source의 한계
OpenGL은 Open Source 진영의 대표적인 API이고 위에 언급했듯 OpenGL 1 ~ OpenGL 4 까지 다양한 버전이 존재한다. Microsoft 가 주도하는 DX 진영은 신규 버전이 출시되면 당대의 그래픽스 하드웨어들의 특성에 맞게 API 중 시대에 뒤떨어진 기능을 제거하고 새로운 기능을 빠르게 추가해가며 발전했다.
하지만 OpenGL은 그러지 못했다. 구세대의 기기들까지 최신 운영체제에서도 지원할 수 있어야 했고 이로 인하여 그래픽스 드라이버는 점점 무거워졌다. 무거워진 그래픽스 드라이버들은 유지보수는 둘째치고 스펙 구현조차 하위 호환을 생각하며 작성하다보면 잘못 동작하는 경우가 많았다. 종국에 윈도우스의 그래픽스 드라이버들은 Direct X를 이용하여 OpenGL 스펙을 구현하는 지경까지 이르렀다.
어플리케이션 개발자 입장에서는 OpenGL 3.X 로 넘어가며 Compatibility 프로파일에서만 동작하는 구 OpenGL의 기능들을 혼재하여 코딩을 했고 이로인해 많은 버그를 유발하게 되었다. 결국 Khronos에서는 선택을 할 수 밖에 없었다. OpenGL 3 이전의 모든 기능을 삭제하거나 하는 방식등을 말이다.
태생적 한계
OpenGL은 글로벌 스테이트 기반의 API다. 아주 쉽게 상태가 전역적으로 관리된다는 말이다. 여기서 상태란 파이프라인이나 프레임 버퍼등을 말하는데 특정 시점에 컨텍스트 내의 상태를 변경하면 모든 후속 호출이 해당 상태로 실행된다는 것이다. 태생적으로 싱글 스레드 기반의 어플리케이션 만을 상정하고 개발이 되었다는 말이다.
예를 들어보자
...
glEnable(GL_DEPTH_TEST); // 이 호출 이후의 모든 OpenGL 코드들을 통해 생성되는 프레임은 뎁스 테스팅을 진행함.
...
굉장히 쉽게 설정을 변경할 수 있고, 이를 이용하면 같은 상태의 다른 작업들은 코드 몇줄을 추가하여 렌더링을 진행할 수 있다.
// VAO 생성 및 바인딩
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao); // VAO 상태 활성화
// VBO 생성 및 바인딩
GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo); // VBO 상태 활성화
// 셰이더 프로그램 활성화
GLuint shaderProgram = glCreateProgram();
// ... 셰이더 컴파일 및 링크 과정
glUseProgram(shaderProgram); // 셰이더 프로그램 활성화
// 텍스처 바인딩
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture); // 텍스처 활성화
이런식으로 같은 리소스를 사용하는 여러 쉐이더 프로그램을 하나의 상태에서 렌더링 할 수 있는 장점이 있다. 문제는 이게 멀티 스레딩으로 넘어가면 상태 변경으로 인해 병렬성을 확보하기 어렵거나 예측할 수 없는 문제가 발생한다는 점이다.
// 렌더링 데이터를 저장하는 구조체
struct PipelineData {
GLuint vao;
GLuint vbo;
GLuint shaderProgram;
};
// 간단한 셰이더 컴파일 함수
GLuint compileShader(const char* vertexSource, const char* fragmentSource) {
...
return shaderProgram;
}
// 렌더링 파이프라인 생성
PipelineData createPipeline(float vertices[], const char* vertexShaderSource, const char* fragmentShaderSource) {
PipelineData data;
// VAO 생성
glGenVertexArrays(1, &data.vao);
glBindVertexArray(data.vao);
// VBO 생성
glGenBuffers(1, &data.vbo);
glBindBuffer(GL_ARRAY_BUFFER, data.vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(float) * 9, vertices, GL_STATIC_DRAW);
// 셰이더 컴파일 및 프로그램 생성
data.shaderProgram = compileShader(vertexShaderSource, fragmentShaderSource);
// 속성 설정
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
return data;
}
// 렌더링 함수
void renderPipeline(PipelineData data) {
glUseProgram(data.shaderProgram);
glBindVertexArray(data.vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
// 파이프라인 데이터를 저장
PipelineData pipeline1, pipeline2;
// 스레드 1: 첫 번째 파이프라인 렌더링
void thread1() {
glfwMakeContextCurrent(sharedWindow); // 동일한 컨텍스트 사용
for (int i = 0; i < 10; ++i) {
glClear(GL_COLOR_BUFFER_BIT);
renderPipeline(pipeline1);
std::cout << "Thread 1: Rendered Triangle 1\n";
}
}
// 스레드 2: 두 번째 파이프라인 렌더링
void thread2() {
glfwMakeContextCurrent(sharedWindow); // 동일한 컨텍스트 사용
for (int i = 0; i < 10; ++i) {
glClear(GL_COLOR_BUFFER_BIT);
renderPipeline(pipeline2);
std::cout << "Thread 2: Rendered Triangle 2\n";
}
}
int main() {
// OpenGL 초기화
...
glfwMakeContextCurrent(window);
glewInit();
// 첫 번째 삼각형
float vertices1[] = {...};
const char* vertexShade/rSource1 = "...";
const char* fragmentShaderSource1 = "...";
pipeline1 = createPipeline(vertices1, vertexShaderSource1, fragmentShaderSource1);
// 두 번째 삼각형
float vertices2[] = {...}
const char* vertexShaderSource2 = "...";
const char* fragmentShaderSource2 = "...";
pipeline2 = createPipeline(vertices2, vertexShaderSource2, fragmentShaderSource2);
// 멀티스레드 실행
std::thread t1(thread1);
std::thread t2(thread2);
t1.join();
t2.join();
// 종료
glfwDestroyWindow(sharedWindow);
glfwTerminate();
return 0;
}
위 코드에서는 두개의 삼각형을 그리는 서로 다른 두개의 파이프라인을 구성하였는데 이를 멀티 스레딩을 사용하여 실행한다. 각 파이프라인에서는 상태를 변경하는 GL 함수를 내포하고 있고 한 스레드가 동작하는 중에 다른 스레드로 제어권이 넘어간 경우 글로벌 스테이트가 변하면서 렌더링에 문제가 발생하게 된다. 그래픽스를 제외한 프로그래밍에서도 전역 변수를 무분별하게 사용하면 상태 추적에 큰 어려움을 겪는것과 같은 이치이다. 이러한 문제로 인해 OpenGL을 기반으로 개발할 때 멀티 스레딩을 이용해야하는 경우 드라이버 단에서 이를 동기화하기 위한 작업을 처리해주는데 여기서도 오버헤드가 크게 발생하고 드라이버 제작 난이도를 높인다. glMultiDraw 등을 이용하여 오버헤드를 줄이려고 했으나 신통치 않았다.
Vulkan의 등장 그리고 철학
Vulkan은 위의 문제점들을 해결하기위해 일종의 리부트를 한 API이다. 글로벌 상태 기반을 로컬 상태 기반으로 옮겨 멀티스레딩을 상정한 설계를 가져왔고, OpenGL에서 제공하던 다양한 편의 기능들을 제거하고 그래픽스 렌더링에 필요한 기능만을 정의했으며 메모리의 할당, 어떤 메모리 타입을 사용할 지, 그 메모리를 어떤 리소스에 바인딩할지 동기화는 어떻게 할지 이런 것들을 전부 일일히 정해야하며 이를 관리하는 역할까지 어플리케이션 개발자에게 위임을 했다. 기존의 OpenGL이 드라이버 단에서 처리해주던 일을 어플리케이션 개발자에게 위임하여 드라이버를 가볍게한 것이다. 이로인해 어플리케이션 개발 난이도 자체는 크게 올라갔지만 그만큼 성능 상의 이점과 유연함을 가져갔다. 또한 최신 그래픽스 하드웨어에서 제공하는 기능들 또한 VK_Vendor_Feature 등의 확장기능을 이용하여 사용할 수 있게 설계되었으며, 이 기능을 사용할 수 있는지 없는지 질의하여 지원하지 않는 경우 대체 구현을 할 수 있도록 설계되어있다. 하지만 이에따라 작성해야하는 코드가 많이 늘어났다. 윈도우 핸들 등록부터 절차가 굉장히 많고, 기본 프레임버퍼조차 존재하지 않아 삼각형을 처음 그려본다면 렌더링 자체 코드는 얼마되지 않는데 초기화하는 과정이 전체 코드의 80%를 차지하는 어지러운 상황에 맞닥뜨리게 된다. 그럼에도 불구하고 이런 초기화 과정들을 적절히 래핑해두고 재사용 할 수 있고 그 이후엔 굉장히 유연하게 원하는 기능을 구현할 수 있다.