나만의 그래픽 라이브러리를 코딩해봤습니다
요약
본 글은 개인적으로 그래픽 라이브러리를 개발하는 과정을 공유합니다. 초기에는 소프트웨어 레이트라이저로 시작했으나, GPU의 병렬 처리 능력을 활용하기 위해 OpenGL, WebGPU, SDL을 거쳐 최종적으로 Vulkan과 C++를 사용하여 새로운 렌더링 라이브러리 구축에 도전하고 있습니다. 이 과정에서 다양한 그래픽 API와 복잡한 초기화 과정을 다루며, 실제 엔진 구조체 및 GPU 장치 선택 로직까지 구현하는 내용을 담고 있습니다.
핵심 포인트
- GPU 병렬 처리를 위해 Vulkan과 C++를 사용하여 렌더링 라이브러리 개발에 도전합니다.
- OpenGL, WebGPU, SDL 등 여러 그래픽 API의 사용 경험을 공유하며 발전 과정을 보여줍니다.
- Vulkan 헤더 파일, vma/volk, glfw 등의 헬퍼 라이브러리를 활용하여 복잡한 초기화 과정을 진행합니다.
- 물리적 GPU 장치 목록에서 외장(discrete) GPU를 선택하는 로직을 구현했습니다.
안녕하세요 여러분, 또 다른 코딩 모험 에피소드에 오신 것을 환영합니다. 저는 수년 동안 Unity 엔진에서 다양한 작은 시뮬레이션, 그래픽 실험 등을 해왔습니다. 하지만 전체 게임 엔진은 제가 하는 종류의 작업에는 너무 과한 느낌이 들었기 때문에, 한동안 저만의 가벼운 렌더링 라이브러리를 만들 아이디어로 가지고 놀았습니다. 이게 성공할지 아닐지는 모르겠지만, 과정에서 흥미로운 것들을 배울 위험성이 높다고 생각합니다. 자, 어떻게 되는지 한번 봅시다.
작년에는 CPU의 이미지에 삼각형을 하나씩 그리는 간단한 소프트웨어 레이트라이저를 코딩했습니다. 그리고 오늘은 본질적으로 이 작업을 GPU에 맡기고 싶습니다. 수많은 삼각형을 병렬로 그리는 것이 거의 GPU의 전문 분야이기 때문입니다. 그때 살펴봤던 변환(transformations)과 원근 투영(perspective projection)에 대한 많은 수학적 지식들은 오늘 다시 유용하게 쓰일 것입니다. 그러니 혹시 놓치셨다면 그것도 한번 살펴보시는 게 좋을 것 같습니다. 아무튼, GPU와 통신하기 위해 제가 고려했던 몇 가지 API들이 있습니다.
저는 OpenGL로 만지작거리기 시작해서 이 멋진 삼각형을 만들었지만, 몇 년 동안 다른 프로젝트에 정신이 팔렸습니다. 나중에 돌아왔을 때는 좀 더 현대적인 것을 시도해보고 싶어서 WebGPU에서 회전하는 원숭이를 만들었습니다. 하지만 셰이더 언어(shader language)와 관련된 사소한 불만들 때문에 저와 제 원숭이는 대신 SDL로 옮겼습니다. 저는 이것을 사용하는 것이 즐거웠지만, 호환성 문제 때문에 일부 최신 기능들이 사용 가능하지 않습니다. 그리고 이 프로젝트에서는 모든 멋진 장난감들을 마음껏 사용할 수 있으면 좋을 것 같습니다.
그래서 오늘은 vulkan으로 처음부터 다시 시작해 보려고 합니다. 보통은 C#으로 코딩하는 것을 좋아하지만, 이번에는 제가 몇 년 전에 아두이노 프로젝트를 위해 아주 조금 사용했던 C++로 이 라이브러리를 작성하려고 합니다. 이렇게 하면 제가 사용하고 싶은 모든 종속성(dependency)에 대한 바인딩을 찾거나 생성하는 것에 대해 걱정할 필요가 없기 때문입니다. 좋습니다. 우선 프로젝트에 네 가지 항목을 가져왔습니다. 하나는 우리가 사용할 수 있는 다양한 타입과 함수를 정의하는 vulkan 헤더 파일이고, 다른 두 개는 메모리 할당 및 함수 포인터 로딩을 위한 vma와 volk라는 작은 vulkan 헬퍼(helper)입니다.
그리고 마지막으로 현재는 크로스 플랫폼 창 생성 및 입력 처리를 위한 glfw가 있습니다. 좋습니다. 저는 'How to Vulkan in 2026' 같은 가이드나 다양한 비디오를 통해 기초를 배워왔습니다. 과정이 상당히 복잡해서 모든 작은 단계를 지루하게 설명하는 것은 삼가겠지만, 이 여정을 따라가면서 전반적인 개요만 공유하고 싶습니다. 예상대로 초기화 함수들을 호출하면서 시작하고, 그런 다음 glfw를 사용하여 창을 생성합니다.
참고로 저는 이런 창 같은 다양한 구성 요소들을 '엔진(engine)'이라는 구조체에 넣고 있습니다. 더 나은 단어가 없어서 말이죠. 나중에 참조할 수 있도록요. 그리고 테스트하기 위한 작은 run 함수가 여기 있습니다. 그래서 창을 만든 후에는 닫으라는 요청이 있을 때까지 창을 열어두기 위해 루프에 진입합니다. 그리고 이게 어떻게 보이는지 보겠습니다. 창을 움직이거나, 크기를 조절하거나... 이런 식입니다. 물론 닫는 것도 가능합니다.
꽤 멋지네요. 만약 실제로 그 위에 그림을 그릴 수 있다면 훨씬 더 멋질 거예요. 그러려면 GPU를 확보해야 합니다. 하지만 일부 기기는 여러 개가 있을 수 있습니다. 제 노트북처럼요. 통합 GPU와 더 강력한 외장(discrete) GPU가 둘 다 있죠. 그래서 이 투박한 코드는 사용 가능한 모든 장치 목록을 가져온 다음, 만약 존재한다면 단순히 외장(discrete) 종류를 선택합니다. 하지만 우리는 이 물리적 장치와 직접 통신하지 않고, 여기에서 생성된 '논리적인' 장치를 통해 통신하는데, 이것이 GPU가 실행할 명령어를 제출할 수 있는 큐(queue)를 노출합니다.
이 명령어들은 명령어 버퍼(command buffer)에 기록되어야 하므로, 제가 여기서 그것을 만들고 창 데이터의 일부로 저장합니다. 이렇게 하면 여러 개의 창을 열고자 할 때 각각 고유한 명령어 세트를 가질 수 있습니다. 사실 각 창은 여기에 두 개의 명령어 버퍼를 갖게 되는데, 그 이유는 GPU가 프레임을 렌더링하는 명령어를 실행하는 동안 CPU는 이미 다음 프레임에 대한 명령어를 작성하기 시작할 수 있기 때문입니다. 물론 GPU가 바쁘게 작업 중인 것과 같은 버퍼에는 쓰지 않습니다.
이런 방식으로 CPU와 GPU는 항상 서로가 작업을 끝낼 때까지 기다릴 필요가 없습니다. 이것은 '여러 개의 플라이트 프레임(multiple frames in flight)'을 갖는다고 언급됩니다. 이제 우리가 렌더링할 '프레임' 또는 '이미지'를 관리하려면, 스왑체인(swapchain)이라는 것을 빠르게 생성해야 하는데, 여기에는 화면에 표시되는 이미지가 새로 렌더링된 이미지로 언제 교체되어야 하는지를 결정하는 프레젠테이션 모드(presentation mode)와 같은 중요한 설정들이 있습니다.
즉시 처리하여 찢어짐(tearing) 위험을 감수할 수도 있고, 다음 새로 고침 주기(refresh cycle)를 기다릴 수도 있습니다. 처음에는 트레이드오프가 조금 혼란스러워서 여기에 긴 글을 작성했는데요. 하지만 지금은 이해가 됩니다. 아무튼 몇 가지 설정 단계를 더 거치니, 다음과 같은 창 구조를 갖게 되었습니다. 여기서 가장 흥미로운 것은 동기화 객체(synchronization objects)들인데, 이는 GPU가 먼저 교환 체인 이미지(swapchain image)가 준비되어 렌더링할 때까지 기다리게 하고, 렌더링 작업이 완료되면 이미지가 표시될 준비가 되었음을 신호하도록 합니다.
또한 CPU에 완료를 알릴 위한 펜스(fence)도 있습니다. 두 개의 프레임-인-플라이트(frames-in-flight)를 사용하기 때문에, 첫 번째 프레임을 위한 명령을 생성하여 GPU에 제출하고 — 그 후에 해당 프레임에 대한 펜스를 설정할 수 있으며, 이는 우리가 더 이상 해당 명령들을 건드리지 않아야 함을 의미합니다. 하지만 우리는 이미 다음 프레임 작업을 시작할 수 있고, 그 명령들을 생성하고 제출하며, 그 후에도 역시 펜스가 설정될 것입니다.
이 시점쯤이면 첫 번째 프레임은 렌더링이 완료되었겠지만, 그렇지 않다면 CPU는 아무것도 하지 않고 기다려야 합니다. 하지만 렌더링이 마침내 완료되어 표시를 위해 전송되면, 우리는 펜스가 사라지는 것을 상상할 수 있으며, 이는 새로운 명령어 세트를 작성하고 제출하는 것이 자유로워지며, 이로써 주기가 계속됩니다... 어쨌든 관련 코드를 빠르게 살펴보자면 — 잠재적인 대기(waiting)는 각 프레임 시작 시점에 이루어지며, 그 후에 렌더링할 이미지를 교환 체인에서 요청하고, 새로운 명령을 기록할 수 있도록 프레임의 명령어 버퍼를 재설정합니다. 예를 들어, 렌더 패스(render pass)에 대한 명령들 같은 것이죠.
여기서 우리는 이미지의 이전 내용에 어떤 일이 일어나야 하는지 지정할 수 있습니다. 저는 단순히 배경색으로 지우고 있습니다. 그 다음에는 메모리 동기화(memory synchronization) 및 기타 설정이 있지만, 이제 3D 모델을 그리는 것과 같이 우리가 정말 신경 쓰는 명령들을 내보낼 준비가 되었습니다. 하지만 이것들이 지금까지 모두 작동하는지 확인하기 위해, 저는 프레임의 끝 함수를 빠르게 추가했는데, 이 함수는 기본적으로 우리가 기록한 모든 명령들을 제출하고, 이미지가 준비되면 화면에 표시해 달라고 요청합니다.
그리고 마지막으로 다음 프레임을 위해 우리의 프레임 플라이트 인덱스(frame flight index)를 증가시킵니다. 참고로, 명령을 제출하는 함수는 여기 있는데, 여기서 다양한 플래그와 펜스(fences)도 전달합니다. 좋습니다. 믿기 어려우실지 모르겠지만, 우리 창에 색상을 추가하기 위해 필요한 것은 이 모든 것입니다. 이제 저는 glfw에 입력 콜백(input callback)을 빠르게 연결하여 키보드 이벤트를 받을 수 있게 했습니다. 그리고 각 키가 마지막으로 눌리거나 해제된 프레임에 기록하고 있으므로, 필요할 때 언제든지 상태를 조회할 수 있습니다.
그리고 이것을 테스트하기 위해, 저는 작은 창 퐁(window pong) 게임을 만들었습니다. 와, 이거 상당히 강렬하네요. 색상이 많은 창들만 사용해서 많은 게임을 만들 수도 있겠지만, 아마 가장 효율적인 접근 방식은 아닐 것입니다. 그래서 다음 큰 목표는 메시(meshes)를 그리는 것입니다. 저는 위치(position), 법선(normal), UV 좌표에 대한 벡터만을 포함하는 작은 정점(vertex) 구조체를 정의하면서 시작했습니다. 그리고 이 벡터 타입들은 제가 가져온 glm이라는 수학 라이브러리에서 온 것입니다.
Vulkan이 화면의 왼쪽 상단을 -1로, 오른쪽 하단을 양수 값으로 처리한다는 점을 염두에 두고, 여기 삼각형을 형성하기 위해 3개의 정점(vertex)을 만들었습니다. 또한 이 점들을 연결하여 삼각형을 구성하는 데 필요한 인덱스(indices)도 준비했습니다. 지금은 단지 하나만 사용해서 약간 어리석게 느껴질 수도 있지만, 물론 이것은 훨씬 더 복잡한 메시(mesh)로 확장되기를 원합니다. 따라서 이 정보로부터 저는 메시를 생성하고 있는데, 이는 CPU 측과 셰이더가 읽을 수 있는 GPU 버퍼(GPUBuffers)에 데이터를 모두 담기 위해 제가 설정한 작은 구조체입니다.
버퍼는 여기서 이 함수에 의해 처리되는데, Vulkan 메모리 도우미가 힘든 작업을 알아서 해주고 있습니다. 저는 단지 여기에 일부 메모리를 예약해 달라고 요청할 뿐이고, 그런 다음 주어진 데이터를 그 공간으로 복사할 수 있습니다. 이 접근 방식은 몇 가지 시스템 요구 사항이 있는데, 전통적으로 CPU가 GPU의 대부분 메모리에 실제로 접근할 수는 없기 때문입니다. 하지만 매우 좋고 쉽고, 지금 당장 세상의 모든 장치를 지원할 필요는 없습니다.
자, 이제 첫 번째로 아주 간단한 셰이더(shader)를 만들 차례입니다. 이 셰이더는 우리가 제공하는 버퍼를 기반으로 여기에 정점 데이터를 공급받게 됩니다. 현재 저는 단순히 정점 위치를 그대로 출력하고 있으며, 여기에는 나중에 살펴볼 추가 좌표인 1이 붙어 있습니다. 하지만 이 출력된 위치들은 배경에서 목표 이미지의 어떤 부분이 삼각형에 의해 덮이는지 계산하는 데 사용되며, 그런 다음 이 프래그먼트 셰이더(fragment shader)가 해당 모든 픽셀에 대해 실행되어 우리가 원하는 색상을 지정할 수 있게 됩니다.
여기서는 가장 창의적인 색상인 초록색을 사용해봤습니다. 참고로 여기서는 slang이라는 셰이더 언어를 사용하고 있는데, 이는 제가 평소에 사용하는 HLSL과 본질적으로 동일하지만 아직 깊이 살펴보지 못한 몇 가지 추가 기능들이 있는 언어입니다. 다만 이 셰이더는 vulkan이 이해할 수 있도록 SPIR-V라는 것으로 컴파일되어야 합니다. 그래서 slang 컴파일러를 또 다른 의존성으로 넣어 처리하게 할 것입니다. 저는 이것을 버텍스(vertex) 단계와 프래그먼트(fragment) 단계 모두에 걸쳐 실행하고, 우리가 돌려받는 바이트코드는 그래픽 파이프라인을 생성하기 위해 관련 정보들과 함께 전달되어야 합니다.
추가 정보란 예를 들어 버텍스 버퍼 내 데이터의 레이아웃이나 우리의 삼각형이 시계 방향인지 아닌지 같은 것들이에요. 이렇게 하면, 우리가 특별히 요청하지 않는 한 뒷면을 그리는 데 시간을 낭비할 필요가 없게 됩니다. 하지만 이것까지 완료되면, 이제 그릴 준비가 된 겁니다! 따라서 매 프레임마다 원하는 메시(mesh)와 셰이더를 가지고 이 draw 함수를 호출할 수 있고, 그러면 연결된 파이프라인과 메시 버퍼들을 사용하여 그리기를 수행하는 명령들을 기록하기만 하면 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 YouTube Sebastian Lague (절차적 생성)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기