03. 윈도우 입력과 OpenGL에서의 멀티스레딩
Quote
이 글에서 사용된 기술의 원본 소스를 작성하신 CoockedNick 님께 크게 감사드립니다.
포크된 소스
Note
이 게시글은 기초적인 멀티스레딩 지식이 있어야 읽기가 수월합니다.
GLFW 이벤트 시스템 소개 부분만 읽고 넘어가셔도 추후 내용 이해에는 아무 문제 없습니다.
목차
- GLFW 이벤트 시스템 소개
- 싱글 스레드면 생기는 문제
- 다중스레드 그래픽스 프로그래밍과 GL의 한계 (OpenGL의 Context와 State Machine)
- 스레드 나누고 동기화하기
GLFW 이벤트 시스템 소개
GLFW는 창을 여는 기능 뿐만 아니라, 여러 플랫폼에 대한 입력 시스템도 추상화합니다.
다음은 GLFW가 제공하는 입력 이벤트 종류입니다.
| 이벤트 이름 | 설명 |
|---|---|
| 창 종료 (Window Close) | 창을 닫으려고 함 (우측 상단 종료버튼 / Alt + F4) |
| 창 움직임 (Window Pos) | 창이 움직임 (창의 상단 타이틀바를 드래그) |
| 창 크기 바뀜 (Window Size) | 창의 크기가 바뀜 (창의 가장자리를 드래그) |
| 프레임버퍼 크기 바뀜 (Framebuffer Size) | 기본 프레임버퍼1의 크기가 바뀜 |
| 콘텐츠 스케일 바뀜 (Content Scale) | DPI가 변함 |
| 창 새로고침됨 (Window Refresh) | 창이 업데이트됨 |
| 창 포커스됨 / 포커스 풀림 (Window Focus) | 창이 포커스(다른 창을 선택된 상황에서 창을 선택) 되거나 언포커스(다른 창을 선택)됨 |
| 창 최소화됨 / 복원됨 (Window Iconify) | 창이 최소화(우측 상단 최소화버튼)되거나 다시 복원(작업 표시줄의 창 아이콘을 클릭)됨 |
| 창 최대화됨 / 복원됨 (Window Maximize) | 창이 최대화(우측 상단 최대화버튼)되거나 다시 복원(타이틀바를 드래그 / 최대화버튼 클릭)됨 |
| 마우스 위치 바뀜 (Cursor Pos) | 창 안에서 마우스의 위치가 바뀜 |
| 마우스가 들어옴 / 나감 (Cursor Enter) | 창 영역에 마우스 커서가 들어오거나 나감 |
| 마우스 버튼이 클릭됨 / 클릭 떼짐 | 마우스 버튼이 눌리거나 떼짐 |
| 마우스 스크롤됨 (Scroll) | 마우스 휠로 스크롤이 감지됨. (가로축, 세로축 스크롤) |
| 자판 눌림 / 뗌 / 눌림 유지됨 (Key) | 키보드 자판이 눌리고, 떼지거나 유지됨(클릭한 상태로 그 상태가 유지되는 걸 뜻함. 정확한 용어는 Repeated) |
| 유니코드 문자(또는 Ctrl같은 모디파이어와 함께) 입력됨 (Char, CharMods) | 유니코드 문자가 (또는 모디파이어와 함께) 입력됨 |
| 파일 / 폴더 드랍됨 (Drop) | 파일 또는 폴더가 창 영역 안에 드래그됨 (여러 파일도 감지됨) |
| 모니터 연결됨 / 연결 끊김 (Monitor) | 모니터가 연결 / 연결 해제됨 |
| 조이스틱 이벤트 감지됨 (Joystick) | 조이스틱 이벤트 감지됨 |
GLFWwindow* 란 객체에다가 콜백(Callback)을 연결하면
해당 윈도우에서 일어난 이벤트를 함수 형태로 받을 수 있습니다.
창에 연결되는 콜백은 대개 다음과 같이 생겼습니다.
void callback(GLFWwindow* window, int flag0, int flag1 ... ) {
}
첫 번째 인자는 '어느 창에서 생긴 이벤트인지' 를 나타냅니다.
나머지 인자는 해당 이벤트의 부가정보를 담습니다(키 입력의 경우 '무슨 키'인지, '눌렸는지 떼졌는지')
팁이 있다면, GLFW에는 창에 'User Pointer' 를 저장하는 기능이 있어서
glfwSetWindowUserPointer 로 창에 '그 창을 감싸는 클래스의 인스턴스' 의 포인터를 저장해두고
콜백에서 받은 GLFWwindow* window와 와 glfwGetWindowUserPointer 를 사용해
인스턴스에 접근, 해당 프레임에 발생한 이벤트를 쉽게 기록할 수 있습니다.
다시 말해, CustomWindow 를 통해 GLFWwindow* 를 얻거나, 그 반대가 가능하다는 겁니다.
다음 구조를 보시면 이해가 쉽습니다.
struct Window {
GLFWwindow* glfw_window;
std::vector<int> pressed_keys; // 현재 프레임에 눌린 키들의 목록을 저장해둠.
}
... {
...
Window* my_window = CreateWindow();
glfwSetWindowUserPointer(my_window->glfw_window, reinterpret_cast<void*>(my_window));
...
}
void key_callback(GLFWwindow* window, int key, int scancode, int action, int mods) {
Window* my_window = reinterpret_cast<Window*>(glfwGetWindowUserPointer(window));
if (action == GLFW_PRESS){
my_window->pressed_keys.push_back(key);
}
}
User Pointer 기능을 쓰지 않을 경우 커스텀 윈도우 클래스 <-> GLFW 윈도우 간의
전역 매핑 테이블을 만들어야 하는데 그건 코드가 더러워질 가능성이 큽니다.
해당 콜백들이 호출되는 시점은
glfwPollEventsglfwWaitEventsglfwWaitEventsTimeout
이 호출되는 시점과 같습니다.
GLFW 내부에서는 '이벤트 폴링' 이라는 작업이 진행됩니다.
이는 GLFW가 생성한 OS의 모든 창에 대해서, 어느 창에서 무슨 이벤트가 일어났는지 감시하고
감지된 경우 그 정보를 리스트 형식으로 저장하는 것입니다.
그리고 그 리스트를 사용자에게 콜백 형식으로 전달하게 하는 함수가 저 세 함수입니다.
우선 glfwPollEvents 함수의 경우, "호출된 그 즉시" GLFW 내부의 이벤트 폴링 리스트를 확인하고,
해당 리스트에 있는 모든 정보를 콜백으로 전달합니다.
이 함수는 호출된 즉시 콜백에 이벤트를 전달한 후에 종료되다 보니까,
입력 반응성이 좋고 대신 CPU 사용량이 급격하게 늘어날 수 있다는 특징이 있습니다.
glfwWaitEvents와 glfwWaitEventsTimeout의 경우, 호출된 순간부터 해당 스레드를 재운 후에
이벤트가 하나라도 발생하면 그 즉시 스레드를 깨우고 콜백으로 전달, 종료됩니다.
두 함수의 차이점은, 전자의 경우 이벤트가 일어날 때까지 호출한 스레드가 무한정으로 재우는 반면,
후자의 경우 최대 인자로 받은 double형의 timeout 초 만큼만 재우고
그 시간이 지나면 강제로 깨웁니다(이벤트가 하나라도 있던 없던).
즉 이 두 함수는 glfwPollEvents 에 비해서 (스레드를 재우다 보니) 반응성은 떨어지지만,
CPU 사용량을 극도로 줄일 수 있다는 장점이 있습니다.
싱글 스레드면 생기는 문제
혹시 전에 삼각형 그리기 예제를 실행했을 때,
창의 크기를 바꾸려고 한 적이 있나요?
마우스를 창 끄트머리에 가져다 두고 클릭한 채로 드래그하면
화면의 일부는 검정색으로 뒤덮이고, 아무 반응이 없다가
마우스를 떼면 삼각형은 그대로, 배경이 늘어난 것을 볼 수 있습니다.
또한 해당 예제에서는 눈으로 볼 수 없지만
싱글 스레드로 돌아가는 OpenGL 프로그램의 창의 위치를 바꾸려고 하면
바꾸는 도중에는 화면이 업데이트되지 않은 채 멈추게 됩니다.
저는 개인적으로 OpenGL을 처음 배울 때 이 점이 마음에 들지 않았습니다.
그래서 이런 주제의 글을 초반에 작성하려 합니다.
싱글 스레드 GL 프로그램은, 창을 움직이거나 크기를 바꾸는 등의
스레드를 블로킹하는 입력이 일어나면 즉각적으로 반응(=스크린을 업데이트)하지 못합니다.
물론 stack overflow같은 데에 보면
해당 상황에 대해서 스크린 전체를 다시 그리는, 다시 말해서
화면의 크기가 바뀔 때마다 그 크기에 맞춰서 렌더링을 다시 하는 해답이 있습니다.
그러나 그 방법은 (싱글 스레드를 고수하면) CPU 사용률을 매우 높이고, 동기화 문제 뿐 아니라
추후에 엔진 구조로 넘어가서 추상화를 할 때에도 코드가 많이 더러워집니다.
(윈도우 관리, 입력 시스템 관리, 렌더링 관리는 제각각 다른 파트로 쪼개집니다)
이 문제의 핵심은 '창에 대한 입력 이벤트는 OS가 담당하기' 때문입니다.
OS가 해당 입력이 끝나기를 기다리며 하나뿐인 스레드를 블로킹해버리면,
그동안은 저희 단에서 아무것도 못하고 가만히 있어야 하는 겁니다.
이를 해결하려면 메인 스레드를 OS에서 이벤트를 받는 스레드 로 지정하고,
또다른 스레드를 프레임 업데이트와 렌더링 담당으로 지정해야 합니다.
또한 스레드를 나눈 것에 대한 적절한 동기화를 해야 합니다.
다중스레드 그래픽스 프로그래밍과 GL의 한계
그래픽스 프로그래밍이라고 하면 대개 실시간 렌더링을 뜻하기 때문에,
최대한 CPU 점유율을 낮추고 안정적인 프레임을 내는 것을 이상적으로 봅니다.
Vulkan과 DX12 같은 모던 그래픽스 API 의 경우에는, 초기 디자인부터
멀티스레딩을 고려하였기에 여러 스레드에서 해당 API의 자원에 접근해도
상호배제와 동기화만 잘 해준다면 효과적으로 돌아갑니다.
예를 들어서, 한 프레임을 그리기 위해서 꼭 해야하는
'무거운 계산 작업' A, B, C 가 있고, 계산 순서는 상관없다고 해보죠.
만약 모던 API처럼 여러 스레드에서 자원 접근이 가능하다면,
A, B, C의 작업을 A-B-C 이렇게 순서대로 할 필요 없이
병렬적으로 실행해서 세 작업 중 가장 오래 걸리는 작업 시간만 고려하면 될 것입니다.
이쯤에서 뜬금없지만 좋은 C라이브러리의 구조와 그렇지 않은 구조를 한번 살펴봅시다.
먼저 좋지 않은 구조를 봐주세요.
int main() {
mylib_initialize();
mylib_set_something(111);
auto* a = mylib_get_something();
mylib_finalize();
}
이제 이상적인 구조입니다.
int main() {
mylib_ctx ctx;
mylib_init_ctx(&ctx);
mylib_set_something(&ctx, 111);
auto* a = mylib_get_something(&ctx);
mylib_uninit_ctx(&ctx);
}
두 구조의 차이가 무엇인지 아시겠나요?
바로 라이브러리가 데이터를 어디에 저장해두는지의 차이입니다.
첫 번째 구조의 경우, initialize와 함께 라이브러리의 '전역 메모리' 가 할당됩니다.
이후 라이브러리 관련 동작은 그 라이브러리만 접근이 가능한 전역 변수를 통해서 일어나게 되죠.
두 번째 구조의 경우, '라이브러리 컨텍스트' 라는 구조체가 보입니다.
즉, 라이브러리의 저장 공간을 사용자가 직접 할당하고 해제하는 방식인 것이죠.
그리고 이후 라이브러리 관련 동작은 항상 컨텍스트를 참조하여
어느 저장공간을 사용할 것인지를 사용자가 명시하게 됩니다.
그렇습니다. 눈치 채셨겠지만 첫 번째 구조는 OpenGL의 형태이고,
두 번째 구조는 모던 그래픽스 API의 형태입니다.
OpenGL은 모든 프로그램이 싱글 스레드로 돌아가던 옛 시절에 디자인되었습니다.
즉, OpenGL 자원 생성 및 접근을 포함한 모든 GL 함수들은 하나의 스레드에서 호출되어야만 합니다.
더 나아가 GL의 내부 구조를 설명드리자면,
GL의 내부는 위의 나쁜 구조와 같이, '전역 상태 머신' 으로 이루어져 있습니다.
예를 들어 glSetA 라는 함수로 A라는 설정값을 바꾸면,
그 A는 전역 상태로 기록되어 glSetA 가 또다시 불리기 전까지 그 값을 전역으로 유지합니다.
또한 다른 함수들은 각자 A라는 하나의 전역 값을 읽고 그에 따라 행동하고요.
'OpenGL의 디버깅이 어렵다' 는 의견은 대부분 이 구조때문에 나옵니다.
만약 2개의 순차 과정이 있을 때, 그 과정 사이에서 (사용자의 실수던 구조상의 문제건) 전역 설정이 하나라도 바뀌면
이후의 과정에 막대한 영향을 줄 수 있기 때문이죠.
이것이 OpenGL의 한계입니다.
아무리 '모던' 이라는 수식어가 붙어도 API 단에서의 멀티코어 렌더링이 불가능한 것은 여전합니다.
스레드 나누고 동기화하기
아무튼, OpenGL이 얼마나 낡았는지는 제쳐둡시다. 말만 그렇지 사실 큰 문제는 아니에요.
위에서 언급했듯이 '싱글 스레드면 생기는 문제' 를 해결하려면
입력 스레드와 렌더링 스레드를 구분짓고 적절한 동기화를 해야합니다.
역할은 다음과 같이 나눠지겠네요.
입력 스레드에서는:
- GLFW 콜백들을 통해서 OS단의 (블로킹 존재하는) 입력을 받는다.
- 렌더 스레드에서 사용될 이벤트 정보들을 이벤트 큐에 등록(Produce)한다.
렌더 스레드에서는:
- 매 루프마다 렌더링을 진행한다.
- 입력 스레드에서 받은 이벤트 큐 안의 정보들을 적절히 사용(Comsume)한다.
해결하고자 하는 문제를 다시 정리해봅시다.
- 창의 위치가 바뀔 때 스크린이 업데이트되지 않는다.
- 창의 크기가 바뀔 때 스크린이 즉각적으로 그려지지 않는다.
첫 번째 문제는 간단합니다. 이는 '스레드를 2개로 나누는 것' 만으로도 해결됩니다.
그러나 두 번째 문제는 고려해야 할 것이 있습니다.
glViewport를 어느 시점에 부르는가?- 어느 시점의 윈도우 크기를 사용해야 하는가?
glViewport 는 사각형 영역을 받아서, 해당 영역을 뷰포트 - 즉 픽셀이 보여지는 구역으로 지정합니다.
예를 들어서 창의 크기가 1280 x 720일 때, glViewport(640, 360, 640, 360) 을 부르면?
왼쪽 하단 (0, 0) 기준 점 (640, 360) 을 원점으로, 오른쪽 상단2으로 [640, 360] 만큼의 사각형 영역만이 그려질 것입니다.
두 번째 문제는 렌더링 스레드에서 이벤트 큐의 '크기가 변한 창의 크기' 정보를 쓰면 되겠네요.
이제 코드로 넘어갑시다.
구조부터 정의합니다.
- FrameFlag 는 해당 프레임에서 일어난 일들을 나타내는 플래그입니다.
- 예를 들어 WindowSizeHasChanged 플래그가 있다면, 해당 프레임에 변화된 크기를 적용해야 합니다.
- FrameInfo 는 해당 프레임에서의 크기 / 플래그 정보를 저장합니다.
- WindowInfo 는 해당 창의 최신 크기, 다음 프레임 정보, 뮤텍스와 CV를 가집니다.
enum FrameFlag : int {
WindowSizeHasChanged = 1,
TerminateRenderThread = (1 << 1),
WakeMainThreadWhenCompleted = (1 << 2),
};
struct FrameInfo {
float width = 0.0f, height = 0.0f;
int flag = WakeMainThreadWhenCompleted;
};
struct WindowInfo {
float width = 0.0f, height = 0.0f;
std::mutex window_mtx{};
std::condition_variable window_cv{};
FrameInfo next_frame{};
};
이제 창을 띄워 봅시다.
int main() {
glfwInit();
WindowInfo info{};
info.width = 1024; info.height = 600;
GLFWwindow* window = glfwCreateWindow(info.width, info.height, "OpenGL 2 Thread", nullptr, nullptr);
glfwSetWindowRefreshCallback(window, on_window_refreshed);
glfwSetWindowCloseCallback(window, on_window_terminated);
glfwSetWindowSizeCallback(window, on_window_resized);
glfwSetWindowUserPointer(window, (void*)&info);
...
WindowInfo 안의 창의 크기를 초기화하고, 해당 정보를
GLFW 콜백에서 접근 가능하도록 User Pointer에 지정합니다.
메인 스레드에서는 OS 단의 입력을 받는다고 했었죠?
새 스레드를 만들고 입력 루프는 다음과 같이 돌아갑니다.
....
// RenderThread 라는 함수로 스레드를 시작합니다.
// WindowInfo와 GLFWwindow* 를 보내주는 것을 잊지 마세요
std::thread renderThread{ RenderThread, std::ref(info), window };
// 창이 닫힐 때까지, CPU 점유율을 최소화하는
// glfwWaitEvents를 사용해서 이벤트를 받습니다.
// (glfwPollEvents를 써도 큰 문제 없습니다.)
while (glfwWindowShouldClose(window) == false) {
glfwWaitEvents();
}
// 스레드 합류도 잊지 마세요
renderThread.join();
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
다음 코드들이 이 기술의 핵심입니다.
연결된 GLFW 콜백입니다;
(전부 메인 스레드에서 glfwWaitEvents가 깨워질 때마다 불립니다)
창 리사이징 콜백부터 볼까요?
static void on_window_resized(GLFWwindow* window, int width, int height) {
auto* info = (WindowInfo*)glfwGetWindowUserPointer(window);
info->width = width; info->height = height;
}
창의 크기가 바뀌는 즉시, WindowInfo 의 width와 height를 갱신해줍니다.
다음은 창 새로고침 콜백입니다.
이 콜백에서 해야 하는 것은 다음과 같습니다.
-
WindowInfo의 뮤텍스를 잠급니다.
-
WindowInfo의 '다음 프레임에서의 창 크기'를 최신화합니다.
-
만약 (2)에서 최신화가 필요했다면 플래그에 'WindowSizeHasChanged' 를 추가합니다.
-
이 스레드를 깨울 것을 플래그에 기록하고, 이 스레드를 재웁니다.
-> 렌더 스레드가 깨워줄 때까지 잠들어 있습니다.
static void on_window_refreshed(GLFWwindow* window) {
auto* info = (WindowInfo*)glfwGetWindowUserPointer(window);
std::unique_lock lock{ info->window_mtx };
{
info->next_frame.flag |= WakeMainThreadWhenCompleted;
if (info->width != info->next_frame.width || info->height != info->next_frame.height) {
info->next_frame.width = info->width;
info->next_frame.height = info->height;
info->next_frame.flag |= WindowSizeHasChanged;
}
info->window_cv.notify_one();
info->window_cv.wait(lock);
}
}
창 종료 콜백입니다.
창을 닫으려는 시도가 있다면, 뮤텍스를 건 후에
'렌더 스레드를 종료하고 후에 이 스레드를 깨울 것' 을 플래그에 기록하고,
렌더 스레드가 종료될 때까지 스레드를 재우고 대기합니다.
-> 렌더 스레드가 종료되기 직전 깨어납니다.
static void on_window_terminated(GLFWwindow* window) {
auto* info = (WindowInfo*)glfwGetWindowUserPointer(window);
std::unique_lock lock{ info->window_mtx };
{
info->next_frame.flag |= TerminateRenderThread | WakeMainThreadWhenCompleted;
info->window_cv.notify_one();
info->window_cv.wait(lock);
}
}
다음은 렌더 스레드의 구현입니다.
먼저, 함수를 시작함과 동시에 glfwMakeContextCurrent를 통해서, 해당 스레드를
OpenGL 렌더링 스레드로 사용할 것을 알려야 합니다.
또한, 최초 1번 GLAD를 초기화하고 자원을 준비합니다.
void RenderThread(WindowInfo& info, GLFWwindow* window) {
glfwMakeContextCurrent(window);
assert(gladLoadGL() != 0);
glfwSwapInterval(1);
// GLsync 는 CPU와 GPU를 동기화시키기 위한 OpenGL 자원입니다.
GLsync fence = nullptr;
// 현재 조사하는 프레임을 받아올 공간입니다.
FrameInfo thisFrame { };
...
렌더링 루프를 시작해 봅시다.
루프 안에서 제일 먼저 해야 할 것은, 다음 프레임 정보를 받아오는 겁니다.
뮤텍스를 걸고, thisFrame에 새 프레임 정보를 복사하고,
새 프레임의 플래그를 초기화해줍니다.
만약 새 프레임의 플래그에 '렌더 스레드 종료' 플래그가 있었다면,
그대로 렌더링 루프를 종료해야 합니다.
...
while (true) {
std::unique_lock lock{ info.window_mtx };
{
// 새 프레임 정보를 복사하기
thisFrame = info.next_frame;
// 만약 이번 프레임에 앱이 종료된다면?
if (thisFrame.flag & TerminateRenderThread) {
// 락 풀고 루프 종료
lock.unlock();
break;
}
info.next_frame.flag = 0;
}
lock.unlock();
...
새 프레임 정보를 받아왔다면, 락을 명시적으로 해제하고(std::unique_lock) 렌더링을 하기 전에
현재 프레임 정보로 뷰포트를 갱신합니다.
...
if (thisFrame.flag & WindowSizeHasChanged) {
glViewport(0, 0, thisFrame.width, thisFrame.height);
}
...
만약 현재 프레임에 창의 크기가 변했다면(플래그에 기록되었다면),
glViewport를 사용하여 현재 창의 크기에 맞춰서 뷰포트를 갱신해줍니다.
이제 실질적인 렌더링이 진행됩니다.
...
// 여기에 렌더링 코드를 작성하세요.
glClearColor(0.2f, 0.3f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
{
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
// 주의: 투영 행렬의 width와 height 값을 창에 맞출 때에는
// 꼭 현재 프레임 정보에서 가져오는 것을 잊지 마세요.
glOrtho(0, thisFrame.width, 0, thisFrame.height, 0, 1);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
glBegin(GL_QUADS);
{
float wo = thisFrame.width / 2.0f, ho = thisFrame.height / 2.0f;
float offset = std::abs(std::sin(glfwGetTime()));
float extent = 50.0f + (50.0f * offset);
glColor3f(1, 1, 1);
glVertex2f(wo - extent, thisFrame.height - (ho - extent));
glColor3f(1, 0, 1);
glVertex2f(wo + extent, thisFrame.height - (ho - extent));
glColor3f(0, 1, 1);
glVertex2f(wo + extent, thisFrame.height - (ho + extent));
glColor3f(0, 0, 1);
glVertex2f(wo - extent, thisFrame.height - (ho + extent));
}
glEnd();
}
...
위 코드에는 glVertex2f같은 레거시 GL 함수들이 사용되었지만,
그냥 예제 렌더링 코드를 줄이기 위해서 사용한 것입니다.
(저 영역 안에 차차 배워갈 모던 함수들을 사용하시면 됩니다)
주의할 점은, 렌더링 루프 안에서 만약 '창의 크기' 를 사용해야 한다면
glfwGetWindowSize 같이 GLFW에서 제공하는 크기가 아닌
현재 프레임 정보(thisFrame) 안의 값을 사용하는 것이 바람직하단 것입니다.
렌더링이 끝났다면, CPU가 GPU의 명령이 다 수행되길 기다려야 합니다.
Note
저희가 렌더링 부분에서 부르는 OpenGL 함수들은, 모두 부르는 즉시 실행되는 것이 아닌,
불린 순간 일단 명령 리스트로 저장했다가 GPU 단에서 해당 리스트의 명령들을 하나하나씩 실행합니다.
다시 말해, 따로 동기화를 하지 않았다면,
CPU가 OpenGL 함수를 다 불렀다고 해서 GPU의 명령이 모두 끝난 것이 아닙니다.
GPU의 명령이 다 끝나기를 기다리려면,
glClientWaitSync 또는 glFinish 라는 함수를 사용해야 합니다.
동기화의 과정은 다음과 같습니다.
glFlush를 통해 OpenGL 명령 큐 안, 모든 명령의 '실행을 시작'합니다.glfwSwapBuffers를 통해 새롭게 그려질 백 버퍼를 프론트 버퍼와 스왑3합니다.glFenceSync로GLsync를 만들고,glClientWaitSync를 통해 GPU 명령이 끝나길 기다립니다.glDeleteSync로GLsync를 삭제합니다.
...
glFlush();
glfwSwapBuffers(window);
// GL_SYNC_GPU_COMMANDS_COMPLETE : GPU 명령이 끝나기를 기다리기 위함
fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
if (fence) {
// 1000000000 은 ns(나노세컨드) 단위로 1초입니다.
glClientWaitSync(fence, GL_SYNC_FLUSH_COMMANDS_BIT, 1000000000);
glDeleteSync(fence);
}
...
해당 동기화가 끝나면 메인 스레드는 렌더 스레드가 자신을 깨우길 기다릴 수도 있기에,
플래그를 확인하고 메인 스레드를 깨워줍니다.
...
if (thisFrame.flag & WakeMainThreadWhenCompleted) {
info.window_cv.notify_one();
}
...
또한 while 루프가 끝난 후에 (렌더 스레드가 종료되기 전)
on_window_terminated 함수가 재워져 있으므로 깨우는 것을 잊지 마세요.
while (true) {
...
}
info.window_cv.notify_one();
}
전체 코드는 다음과 같습니다.
깃허브 Gist에서도 확인하실 수 있습니다.
전체 코드 보기
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | |
세줄 요약
지금까지 너무 코드만 장황하게 늘어놓았죠?
이 기술의 핵심적인 부분을 3줄로 설명하겠습니다.
- 메인(입력) 스레드에서 새 창 크기를 기록하면, 렌더 스레드에서 받아와 뷰포트를 조정한다.
- 메인 스레드에서 창 크기를 기록한 후에, 렌더 스레드에서 '한 프레임이 그려지기를' 기다린다.
- 렌더 스레드에서는 프레임이 다 그려진 후에 메인 스레드를 깨워준다.
사실 이 기술은 앱이 아닌 게임 개발에는 크게 의미가 없습니다.
(창 크기 변화를 중요시하는 게임이 아니라면요)
그러나 일렉트론(Electron), 플러터(Flutter) 같은 주된 앱 개발 도구나 브라우저 등을 사용해 보면
이렇게 '창을 움직이거나 크기를 바꿀 때 프로그램이 멈추지 않는' 기능은 모두 탑재하고 있습니다.
또 제일 중요한건 창 움직인다고 게임이 멈추면 멋이 없잖아요.
그렇듯 사용자 입장에서 보면 굉장히 기본적인 기능이지만,
이런 구현이 따로 필요하다는 것을 적어보고 싶었습니다.
다음 글에서는 좌표계와 WVP 행렬 이라는 주제로,
OpenGL에서 간단한 2D 렌더러를 구현해 보면서 좌표계를 변환해 가는 과정을 알아봅니다.
-
OpenGL에는 '기본 프레임버퍼', 즉 사용자가 아무것도 만들지 않아도 GL측에서 자동으로 만드는 FBO가 존재합니다. 이는 구식 API인 GL의 기괴한 특징이며, 이 프레임버퍼(와 그 첨부물)는 창의 크기와 강하게 연결되어 OS단의 창 크기 변화 시에 자동으로(...) 크기가 조절됩니다. ↩
-
왜 왼쪽 하단에서 시작해 오른쪽 상단 방향으로 늘어나는지는
4. 좌표계와 WVP행렬를 참고하세요. ↩ -
GLFW는 기본적으로 두 개의 화면 버퍼를 사용해서 번갈아 뒤집어 가며 그리는 '더블 버퍼링' 이라는 기술을 사용합니다. 이는 저희가 부른 렌더 콜들이 완전히 그러진 후에야 이를 화면에 보여주기(Present) 위한 것입니다. ↩