9 minutes
🍪 Cookie Engine - 1. SDL and OpenGL
우리가 만들려는 첫 화면은 아주 단순하다.
프로그램 시작
-> SDL 초기화
-> SDL 창 생성
-> OpenGL context 생성
-> GLAD로 OpenGL 함수 로딩
-> GPU에 sphere 정점/인덱스 데이터 업로드
-> 셰이더 생성
-> 매 프레임 화면 지우기
-> sphere draw call 호출
-> 화면에 결과 출력
-> 프로그램 종료 시 리소스 정리
이 흐름을 계층적으로 조금 더 나누면 다음과 같다.
platform layer
-> SDL3 담당
-> 창, 이벤트, 입력, OpenGL context 생성
graphics API layer
-> OpenGL 담당
-> GPU 리소스 생성, 셰이더, 버퍼, 렌더링 상태, draw call 담당
OpenGL loader layer
-> GLAD 담당
-> OpenGL 함수 포인터를 런타임에 가져옴
여기서 SDL은 플랫폼 추상화 계층이고, OpenGL은 렌더링 API다.
SDL만으로는 3D sphere를 그릴 수 없고, OpenGL만으로는 운영체제 창과 입력 이벤트를 깔끔하게 다루기 어렵다. 두 라이브러리는 서로 담당하는 계층이 다르고, 이번 프로젝트에서는 이 둘을 조합한다.
1. SDL3
SDL은 Simple Direct Media Layer의 약자로, 게임이나 멀티미디어 프로그램을 만들 때 필요한 운영체제 의존 기능을 추상화해주는 라이브러리다. SDL이 도와주는 대표적인 일은 다음과 같다.
창 생성
이벤트 처리
키보드 / 마우스 / 게임패드 입력
오디오
타이머
OpenGL, Vulkan, Metal 같은 그래픽스 API와 연결할 수 있는 window 준비
SDL 자체에도 간단한 렌더링 기능이 포함되어 있기는 하지만, 이번 프로젝트에서 SDL은 직접 렌더링하지 않는다. 첫 단계에서 SDL의 역할은 다음과 같다.
OpenGL을 사용할 수 있는 창 생성
OpenGL Context 생성
이벤트 루프 제공
매 프레임 back buffer를 front buffer로 swap
1.1. SDL3의 주요 함수
SDL_InitSDL의 특정 subsystem을 초기화한다. 창과 OpenGL Context를 만들려면 video subsystem이 필요하다.
if (!SDL_Init(SDL_INIT_VIDEO)) { SDL_Log("SDL_Init failed: %s", SDL_GetError()); return -1; }SDL3의 많은 함수들은 실패 시
false나nullptr을 반환하고, 자세한 에러 메시지는SDL_GetError()로 확인한다.SDL_GL_SetAttributeOpenGL Context를 만들기 전에 원하는 속성을 요청하는 함수. 이번 프로젝트에서 사용하는 OpenGL 4.6 Core Profile을 요청하려면 이런 식의 설정이 필요하다.
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 6); SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1); SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);OpenGL Context는 생성 시점에 속성이 결정되기 때문에 이 함수는 반드시 창과 context를 만들기 전에 호출해야 한다. 하나 주의할 점은 이 함수는 그저 요청이라는 점으로, 드라이버나 플랫폼이 정확히 같은 속성을 보장하지 않을 수 있다.
context 생성 후
SDL_GL_GetAttribute로 실제 생성된 값을 확인할 수 있다.SDL_CreateWindow운영체제 창을 만드는 함수. OpenGL을 사용할 창이라면 반드시
SDL_WINDOW_OPENGLflag가 필요하다.SDL_WINDOW_OPENGL이 빠지면SDL_GL_CreateContext가 실패할 수 있다.SDL_Window* window = SDL_CreateWindow( "Cookie Engine", 1280, 720, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE ); if (!window) { SDL_Log("SDL_CreateWindow failed: %s", SDL_GetError()); return -1; }SDL_WINDOW_RESIZABLE은 창 크기 변경을 허용한다. 게임 엔진 에디터에서는 창 크기 변경에 대응해야 하므로 초반부터 켜두는 편이 좋다.SDL_GL_CreateContextSDL window에 연결된 OpenGL Context를 만든다. OpenGL Context는 현재 OpenGL 호출들이 어떤 GPU 상태와 리소스 집합에 대해 실행되는가를 나타내는 실행 문맥이다.
SDL_GLContext context = SDL_GL_CreateContext(window); if (!context) { SDL_Log("SDL_GL_CreateContext failed: %s", SDL_GetError()); return -1; }OpenGL 함수는 context 없이 제대로 사용할 수 없다. GLAD를 로딩하는 것도 context 생성 이후에 해야 한다.
SDL_GL_MakeCurrentOpenGL Context는 생성만 했다고 끝이 아니고, 현재 스레드에서 어떤 context를 사용할지 지정해야 한다. main 스레드 하나만 사용할 때는 내용이 크게 복잡하지 않다. 하지만 나중에 렌더 스레드, 리소스 로딩 스레드, shared context 같은 개념으로 나누어지면 이 함수의 의미가 중요해진다.
if (!SDL_GL_MakeCurrent(window, context)) { SDL_Log("SDL_GL_MakeCurrent failed: %s", SDL_GetError()); return -1; }SDL_GL_SetSwapIntervalVSync 설정에 사용한다. 매개 변수로 1을 넘겨주면 모니터의 refresh에 맞추어 swap한다는 뜻이다.
SDL_GL_SetSwapInterval(1);대략적으로 화면 찢어짐을 줄이는 대신 FPS가 모니터 주사율에 묶일 수 있다.
SDL_PollEvent이벤트 큐에서 이벤트를 하나씩 꺼낸다. 이벤트는 운영체제나 SDL이 프로그램에 전달하는 입력 / 상태 변화 메시지를 의미한다.
SDL_Event event; while (SDL_PollEvent(&event)) { if (event.type == SDL_EVENT_QUIT) { running = false; } }게임 엔진에서는 이 이벤트들을 Input System으로 변환하거나, Editor UI로 전달하거나, Viewport Client에 dispatch하는 구조로 발전시킨다.
SDL_GL_SwapWindowOpenGL은 보통 double buffering을 사용한다. 화면에 보이는 front buffer와, 다음 프레임을 그리는 back buffer가 따로 있는데, 우리는 back buffer에 렌더링하고, 프레임 마지막에 swap한다.
SDL_GL_SwapWindow(window);이 함수를 호출해야 방금 OpenGL로 그린 결과가 실제 창에 표시된다.
SDL_DestroyWindow,SDL_GL_DestroyContext,SDL_Quit프로그램 종료 시에는 생성한 리소스를 정리해야 한다.
SDL_GL_DestroyContext(context); SDL_DestroyWindow(window); SDL_Quit();이 부분은 C++ RAII 구조로 감싸면 나중에 자동화할 수 있다.
2. OpenGL
OpenGL은 GPU에게 렌더링 작업을 지시하는 그래픽스 API다. OpenGL 자체는 엔진도 아니고, 수학 라이브러리도 아니다.
OpenGL이 해주는 일은 더 낮은 수준이다.
GPU buffer 생성
GPU texture 생성
shader program 생성
vertex 데이터를 GPU pipeline에 입력
draw call 실행
framebuffer에 픽셀 결과 기록
즉, OpenGL은 엔진의 Renderer Backend에 해당한다.
2.1. 기본 흐름
OpenGL이 한 프레임을 그리는 기본 흐름은 다음과 같다.
1. viewport 설정
2. color/depth buffer clear
3. shader program 사용
4. VAO bind
5. draw call 호출
6. SDL_GL_SwapWindow로 화면 표시
코드로 보면 다음과 같은 형태가 된다
glViewport(0, 0, width, height);
glClearColor(0.1f, 0.15f, 0.25f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(shaderProgram);
glBindVertexArray(sphereVao);
glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, nullptr);
SDL_GL_SwapWindow(window);
2.2. OpenGL 렌더링 파이프라인
OpenGL에서 sphere 하나가 그려지는 과정을 아주 단순화하면 다음과 같다.
CPU 메모리의 정점 배열
-> VBO로 GPU에 업로드
-> EBO로 인덱스 업로드
-> VAO에 정점 해석 방식 저장
-> vertex shader 실행
-> clipping / NDC 변환
-> rasterization
-> fragment shader 실행
-> depth test
-> color buffer에 픽셀 기록
CPU는 sphere를 구성하는 정점 목록과 삼각형 목록을 만든다
VBO는 정점 데이터를 GPU에 저장한다
EBO는 어떤 정점들을 묶어 삼각형을 만들지 저장한다
VAO는 VBO 안의 데이터를 어떻게 읽을지 저장한다
vertex shader는 각 정점의 최종 위치를 계산한다
rasterizer는 삼각형을 픽셀 후보들로 바꾼다
fragment shader는 각 픽셀의 색을 계산한다
depth test는 더 가까운 픽셀만 남긴다
color buffer에 최종 색이 기록된다
2.3. OpenGL Context
OpenGL Context는 OpenGL 상태와 리소스를 담는 실행 문맥이다. OpenGL 함수들은 대부분 전역 함수처럼 보인다.
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
하지만 이 함수들은 실제로는 현재 스레드의 current로 지정된 OpenGL Context에 대해 동작한다. 그래서 context 생성과 current 지정이 중요하다.
2.4. State Machine
OpenGL을 처음 볼 때 가장 자주 헷갈리는 부분이 bind 기반 상태 모델이다. OpenGL은 state machine적인 성격이 강하다. 어떤 객체를 bind하면, 이후 함수 호출이 그 bind된 객체에 대해 동작한다.
GLuint vbo = 0;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW);
위 코드에서 glBufferData는 인자로 vbo를 직접 받는 대신 현재 GL_ARRAY_BUFFER에 bind된 buffer에 데이터를 업로드한다.
OpenGL 4.5 이상의 DSA(Direct State Access)를 쓰면 bind를 줄일 수 있다는 장점이 있다.
2.5. OpenGL Loader and GLAD
C++에서 OpenGL 함수를 바로 호출할 수 있을 것 같지만, 실제로는 많은 OpenGL 함수가 런타임에 드라이버에서 로딩되어야 한다. 특히 Windows에서는 OpenGL 1.1 이후에 추가된 함수들이 기본적으로 노출되지 않는 역사적 문제가 있고, Linux에서도 확장 / 버전별 함수 포인터 로딩 문제가 있다.
여담으로, 이런 구조가 된 이유는 다음과 같다. Windows는 아주 오래전에 OpenGL을 시스템 API로 넣었다. 그때 Windows에서 기본 제공한 OpenGL 버전이 OpenGL 1.1이였다.
문제는 그 이후에 발생했는데, OpenGL은 계속 발전했지만 Windows의 기본 opengl32.dll은 현대 OpenGL 함수들을 계속 직접 export하는 방식으로 업데이트되지 않았다.
대신 Windows에서는 현대 OpenGL 함수들을 GPU vendor들의 ICD(Intallable Client Driver)를 이용하여 제공한다. 즉 실제 현대 OpenGL 함수들의 구현은 Microsoft의 opengl32.dll 안에 있는 것이 아니라 NVIDIA / AMD / Intel 드라이버가 제공한다는 것이다.
그래서 현대 OpenGL 함수들을 얻기 위해서는 런타임에 함수 주소를 가져와야 한다. Windows에서는 OpenGL context 시스템인 WGL에서 사용하는 함수를 이용해 다음과 같이 함수를 가져온다.
wglGetProcAddress("glCreateShader")
GLAD는 이 작업을 대신해주는 loader-generator다. GLAD 2를 사용하면 보통 다음 흐름이 된다.
#include <glad/gl.h>
int version = gladLoadGL((GLADloadfunc) SDL_GL_GetProcAddress);
if (version == 0)
{
SDL_Log("Failed to load OpenGL");
return -1;
}
SDL_Log("Loaded OpenGL %d.%d",
GLAD_VERSION_MAJOR(version),
GLAD_VERSION_MINOR(version));
SDL_GL_CreateContext와 SDL_GL_MakeCurrent 이후에 GLAD를 로딩해야 한다는 점을 유의한다. 전체적인 호출 순서는 다음과 같아야 한다.
SDL_Init
-> SDL_GL_SetAttribute
-> SDL_CreateWindow
-> SDL_GL_CreateContext
-> SDL_GL_MakeCurrent
-> gladLoadGL
-> OpenGL 함수 사용 가능
2.6. OpenGL Profile
OpenGL 4.6을 사용할 때 Core Profile과 Compatibility Profile을 구분해야 한다. Core Profile은 현대 OpenGL 기능만 제공하고, Compatibility Profile은 옛날 OpenGL 기능까지 포함한다.
Core Profile에서는 반드시 다음과 같은 구조를 사용한다.
VAO
VBO
shader program
vertex attribute
draw call
2.7. 주요 함수
glGetStringOpenGL vendor, renderer, version을 확인한다.
SDL_Log("OpenGL Vendor: %s", glGetString(GL_VENDOR)); SDL_Log("OpenGL Renderer: %s", glGetString(GL_RENDERER)); SDL_Log("OpenGL Version: %s", glGetString(GL_VERSION));glViewportNDC를 window pixel 영역으로 변환할 viewport를 설정한다.
glViewport(0, 0, width, height);glClearColor,glClear화면을 지운다.
glClearColor를 통해 어떤 색으로 clear할 지 설정할 수 있다.glClearColor(0.1f, 0.15f, 0.25f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);glEnableOpenGL 기능을 켠다.
glEnable(GL_DEPTH_TEST);glGenVertexArrays,glBindVertexArrayVAO를 만들고 bind한다.
GLuint vao = 0; glGenVertexArrays(1, &vao); glBindVertexArray(vao);glGenBuffers,glBindBuffer,glBufferDataGPU buffer를 만들고 데이터를 업로드한다.
GLuint vbo = 0; glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW);glVertexAttribPointer,glEnableVertexAttribArrayvertex 자체의 attribute 해석 규칙을 설정한다.
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0); glEnableVertexAttribArray(0);glCreateShader,glShaderSource,glCompileShadershader object를 만들고 컴파일한다.
GLuint vs = glCreateShader(GL_VERTEX_SHADER); glShaderSource(vs, 1, &vertexSource, nullptr); glCompileShader(vs);glCreateProgram,glAttachShader,glLinkProgram,glUseProgramshader program을 만든다
GLuint program = glCreateProgram(); glAttachShader(program, vs); glAttachShader(program, fs); glLinkProgram(program); glUseProgram(program);glDrawElements현재 VAO와 shader program을 기준으로 index buffer 기반 draw call을 실행한다. 참고로 index buffer 대신 vertex buffer 기반으로 draw call을 실행할 때는
glDraw를 사용한다.glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, nullptr);glDelete*OpenGL object를 삭제한다.
glDeleteProgram(program); glDeleteShader(vs); glDeleteShader(fs); glDeleteBuffers(1, &vbo); glDeleteBuffers(1, &ebo); glDeleteVertexArrays(1, &vao);OpenGL object도 리소스이므로 수명 관리가 필요하다. 나중에는 C++ RAII 패턴을 이용하는 wrapper를 만드는 것이 좋다.
2.8. VAO, VBO, EBO
VBO(Vertex Buffer Object)
정점 데이터를 GPU 메모리에 저장하는 OpenGL object다.
GLuint vbo = 0; glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, vertexDataSize, vertices, GL_STATIC_DRAW);여기서
GL_STATIC_DRAW는 데이터 사용 패턴에 대한 힌트다. static draw는 자주 바뀌지 않고 렌더링에 주로 사용되는 데이터라는 뜻이다.EBO(Element Buffer Object)
Index Buffer라고도 부른다.
GL_ELEMENT_ARRAY_BUFFER에 bind된 buffer가glDrawElements에서 사용된다.GLuint ebo = 0; glGenBuffers(1, &ebo); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo); glBufferData(GL_ELEMENT_ARRAY_BUFFER, indexDataSize, indices, GL_STATIC_DRAW);VAO(Vertex Array Object)
정점 데이터를 어떻게 해석할지를 저장한다.
glGenVertexArrays(1, &vao); glBindVertexArray(vao); glBindBuffer(GL_ARRAY_BUFFER, vbo); glVertexAttribPointer( 0, // shader layout location 3, // vec3 GL_FLOAT, // float GL_FALSE, // normalized 아님 sizeof(Vertex), // stride (void*) 0 // offset ); glEnableVertexAttribArray(0);어떤 VBO를 사용할지
position attribute가 몇 번째 location인지
position이 float 몇 개로 구성되는지
vertex 하나의 stride가 몇 byte인지
position이 vertex 구조체 안에서 몇 byte offset에 있는지
2.9. WSL Debian에서 OpenGL이 실행되는 구조
WSL Debian에서 OpenGL을 사용하면, 일반적인 Linux-native 환경과 조금 다르게 동작한다. Windows 11 + WSLg 환경에서는 대략 다음 구조로 실행된다.
SDL3 Window
-> OpenGL 호출
-> Mesa libGL
-> Mesa Gallium d3d12 backend
-> Direct3D 12
-> Windows GPU Driver
-> GPU
여기서 Mesa는 OpenGL의 대표적인 오픈소스 구현체다.
OpenGL은 표준 API이고, Mesa는 그 표준을 실제로 구현한 런타임 / 드라이버 스택이다. WSL에서는 Mesa가 직접 Linux GPU 드라이버를 타는 것이 아니라, D3D12 backend를 통해 Windows의 Direct3D 12 경로로 GPU를 사용한다. 그래서 glxinfo 결과가 정상이라면 보통 다음과 같이 나온다.
OpenGL vendor string: Microsoft Corporation
OpenGL renderer string: D3D12 (Intel(R) Arc(TM) Graphics)
OpenGL core profile version string: 4.6 (Core Profile) Mesa ...
반대로 결과가 다음과 같이 나오면 GPU 대신 CPU를 사용하는 software rendering 상태다.
OpenGL renderer string: llvmpipe (...)
WSL에서 처음 별도의 설정을 해주지 않으면 기본 상태가 CPU software rendering인 경우가 많다. 다음 환경 변수로 D3D12 backend를 강제할 수 있다.
GALLIUM_DRIVER=d3d12
3. CMake
이번 프로젝트는 CMake 기반의 프로젝트다. C++ 프로젝트는 단순히 .cpp 파일만 컴파일하는 것으로 끝나지 않고, 다음과 같은 다양한 요소들을 함께 빌드해야 한다.
내 엔진 코드
SDL3
GLAD에서 생성한
gl.cImGui
시스템 OpenGL 라이브러리
이런 것들을 매번 직접 clang++ 명령으로 나열하면 금방 복잡해지기 때문에 CMake를 사용한다. CMake는 쉽게 말해 이 프로젝트는 어떤 소스 파일로 구성되어 있고, 어떤 라이브러리를 링크해야 하며, 어떤 include path가 필요한지를 선언하는 도구다.
3.1 Source Tree & Build Tree
Source Tree는 내가 작성한 코드가 있는 디렉토리고, Build Tree는 CMake가 생성한 빌드 산출물이 들어가는 디렉토리다.
cookie-engine/
build/
external/
src/
CMakeLists.txt
다음과 같이 빌드한다.
cmake -S . -B build
cmake --build build
-S .는 현재 디렉토리를 소스 디렉토리로 사용한다는 뜻이고, -B build는 build 디렉토리를 빌드 디렉토리로 사용한다는 뜻이다.
3.2 Target
CMake에서는 실행 파일이나 라이브러리를 target이라고 부른다.
실행 파일 target
add_executable(cookie-engine src/main.cpp )정적 라이브러리 target
add_library(glad STATIC external/glad/src/gl.c )
3.3 Include Directory
C++에서 #include <...>를 하려면 컴파일러가 헤더 파일을 어디서 찾을지 알아야 한다. GLAD 헤더가 external/glad/include/glad/gl.h에 있다면, include directory는 external/glad/include가 된다.
target_include_directories(glad PUBLIC
external/glad/include
)
3.4 Link Library
소스 파일을 컴파일하면 object file이 생기는데, 실제 실행 가능한 프로그램을 빌드하려면 여러 object file과 라이브러리를 하나로 link하는 작업이 필요하다. CMake에서는 다음과 같이 target끼리 연결한다.
target_link_libraries(cookie-engine PRIVATE
SDL3::SDL3
glad
GL
)
Linux에서는 OpenGL 시스템 라이브러리를 보통 GL로 링크하고, Windows native에서는 보통 opengl32를 링크한다.