웹 개발자, C++와 벌칸으로 나만의 3D 엔진 만들기: 1부. 벌칸 뼈대 세우기
2026. 5. 18.
웹 개발자, C++와 벌칸으로 나만의 3D 엔진 만들기: 1부. 벌칸 뼈대 세우기
왜 이 프로젝트가 시작되었을까요?
저는 수년간 웹 개발 분야에서 경력을 쌓아왔습니다. TypeScript, Node.js, React가 제 세상이었죠. SOLID 원칙, 어니언 아키텍처, 의존성 주입, 관심사 분리 같은 소프트웨어 아키텍처 원칙들을 웹 환경에서 어떻게 적용할지 끊임없이 고민했습니다.
하지만 제가 처음 기술의 세계에 발을 들인 이유, 그건 웹 폼이나 REST API와는 전혀 무관합니다. 바로 슈퍼 마리오 64 때문이었죠. 어린 시절 처음으로 3D 그래픽을 경험했던 그 순간은 제게 지울 수 없는 흔적을 남겼습니다. 이 프로젝트의 이름인 "Ultra"도 닌텐도 64의 초기 코드명인 "Ultra 64"에 대한 오마주랍니다.
저는 언제나 3D 엔진이 모든 레벨에서 어떻게 작동하는지 이해하고 싶었습니다. 단순히 "함수를 호출하면 삼각형이 나타난다"는 수준이 아니라, 그 뒤에 숨겨진 실제 메커니즘을 말이죠. GPU는 무엇을 그려야 할지 어떻게 알까요? 픽셀은 어떻게 화면에 나타날까요? 내 코드와 디스플레이 사이에서 정확히 무슨 일이 벌어질까요?
이 프로젝트는 C++와 Vulkan을 이용해 3D 엔진을 밑바닥부터 만들면서 이 질문들에 답하려는 저의 시도입니다. 저 역시 배워가는 과정이기 때문에, 저와 같은 길을 걷는 다른 사람들—특히 웹 개발 배경을 가진 분들—에게 도움이 될 수 있도록 모든 과정을 문서화하고 있습니다.
소스 코드는 EU에 호스팅된 비영리 GitHub 대안 서비스인 Codeberg에 있습니다. codeberg.org/remojansen/ultra에서 저장소를 찾으실 수 있습니다.
이 프로젝트가 어디까지 갈지는 저도 확실하지 않습니다. 저는 재미와 배움을 위해 진행하고 있으며, 흥미와 몰입감을 느끼는 한 계속 이어갈 생각입니다. 로드맵도, 마감 기한도, "완성된" 엔진에 대한 약속도 없습니다. 그저 순수한 호기심과 추진력이 이 모든 것을 이끌고 있습니다.
준비물
이 시리즈는 여러분이 숙련된 웹 개발자라는 가정을 전제로 합니다. 다음 기술들에 익숙해야 합니다.
- JavaScript 및 TypeScript: 매일 JS/TS를 작성하고 타입 시스템, 모듈 시스템, 비동기 패턴을 이해하고 있어야 합니다.
- Node.js: 백엔드 서비스를 구축하고
npm을 사용해봤으며 Node 런타임이 어떻게 작동하는지 이해해야 합니다. - 브라우저 API: DOM,
<canvas>, 그리고 브라우저가 페이지를 렌더링하는 방식에 대한 실무 지식이 있어야 합니다. - 소프트웨어 아키텍처: 관심사 분리(Separation of Concerns), 의존성 주입(Dependency Injection), 계층형 아키텍처와 같은 개념들이 낯설지 않아야 합니다.
C++나 그래픽스 프로그래밍 경험은 전혀 필요 없습니다. 이 시리즈가 바로 그 부분을 가르쳐 드릴 테니까요. 하지만 새로운 도구를 접했을 때 문서를 읽고 스스로 문제를 해결하는 데 편안함을 느끼는 유형의 개발자여야 합니다.
도구 체인 둘러보기
본격적으로 엔진 코드를 작성하기 전에, 사용할 도구들을 이해하는 것이 중요합니다. 만약 여러분이 JavaScript 생태계에서 오셨다면, C++ 도구 체인은 꽤 다르게 느껴질 겁니다. npm install 같은 명령어는 없고, node index.js처럼 바로 실행하지도 않거든요. 하지만 기본적인 개념들은 놀랍도록 잘 매칭됩니다. 처음 이 환경에 발을 들였을 때, npm 한 줄이면 끝나던 의존성 관리가 얼마나 소중했는지 새삼 깨달았던 기억이 생생합니다. 하지만 익숙해지면 이 역시 나름의 효율과 매력이 있더군요.
Clang (컴파일러)
JavaScript에서 코드는 V8이나 SpiderMonkey 같은 엔진 위에서 직접 실행됩니다. 이 엔진들은 JIT(Just-In-Time) 컴파일 방식을 사용합니다. 프로그램이 실행되는 도중에 코드를 머신 코드로 컴파일하고, 실제 사용 패턴에 따라 자주 실행되는 코드 경로를 즉석에서 최적화합니다.
C++는 정반대로 작동합니다. 코드는 실행되기 전에 바이너리로 컴파일되어야 합니다. 이를 AOT(Ahead-Of-Time) 컴파일 방식이라고 합니다. 우리가 사용할 Clang은 컴파일러로서, .cpp 파일을 읽어들여 런타임이나 인터프리터 없이 CPU에서 직접 실행되는 머신 코드를 생성합니다.
이 둘의 장단점은 명확합니다. JIT 컴파일은 빠른 시작과 런타임 적응성(엔진이 자주 실행되는 코드 경로를 최적화할 수 있음)을 제공하지만, 컴파일러가 프로그램과 함께 실행되므로 오버헤드가 발생합니다. AOT 컴파일은 초반에는 느립니다(테스트하기 전에 컴파일해야 하니까요). 하지만 출력되는 결과물은 런타임 오버헤드가 전혀 없는 완전히 최적화된 네이티브 코드입니다. 실시간 그래픽 엔진에서는 매 마이크로초가 중요하므로, AOT 컴파일이 우리가 원하는 절충안입니다.
CMake (빌드 시스템 생성기)
설치: CMake Download
Node.js에는 프로젝트를 설명하는 package.json 파일이 있습니다. C++에서는 CMakeLists.txt 파일이 그 역할을 합니다. CMake는 이 파일을 읽어서 여러분의 플랫폼에 맞는 실제 빌드 명령어를 생성합니다.
CMake는 코드를 직접 빌드하지는 않습니다. 대신 다른 도구(우리 경우에는 Ninja)를 위한 빌드 파일을 생성합니다. 이것이 불필요한 간접 계층처럼 느껴질 수 있지만, 동일한 프로젝트가 macOS, Linux, Windows에서 수정 없이 빌드될 수 있도록 해주는 핵심 기능입니다.
우리의 CMakeLists.txt는 두 가지 타겟을 정의합니다.
# Ultra engine library
file(GLOB_RECURSE ULTRA_SOURCES src/engine/*.cpp)
add_library(ultra_engine STATIC ${ULTRA_SOURCES})
target_include_directories(ultra_engine PUBLIC ${CMAKE_SOURCE_DIR}/src)
target_link_libraries(ultra_engine PUBLIC glfw Vulkan::Vulkan)
# Demo application
file(GLOB_RECURSE DEMO_SOURCES src/demo/*.cpp)
add_executable(demo ${DEMO_SOURCES})
target_link_libraries(demo PRIVATE ultra_engine)
엔진은 정적 라이브러리(ultra_engine)로 빌드되고, 데모 게임은 이 라이브러리에 연결되는 실행 파일(demo)로 빌드됩니다. 이들이 무엇을 의미하는지 자세히 살펴보겠습니다.
**실행 파일(Executable)**은 운영체제가 직접 실행할 수 있는 바이너리입니다. main()이라는 진입점(entry point)을 가지고 있으며, 더블 클릭하거나 터미널에서 실행하면 프로세스가 시작됩니다. **정적 라이브러리(Static Library)**는 실행할 수 없는 파일입니다. 컴파일된 코드 묶음으로, 실행 파일에 포함되기 위해 대기하고 있는 상태입니다. Java의 .jar 파일이나 컴파일된 npm 패키지를 떠올려보세요. 유용한 코드를 포함하고 있지만, 실제 실행을 위해서는 호스트 프로그램이 필요합니다.
**링크(Linking)**는 이 둘을 연결하는 과정입니다. 컴파일러가 demo를 빌드할 때, Application()이나 Run() 같은 함수 호출을 만나게 됩니다. 이 함수들은 demo 자체의 소스 코드에 정의되어 있지 않죠. 대신 ultra_engine에 존재합니다. 링커의 역할은 이러한 참조들을 해결하는 것입니다. 링커는 정적 라이브러리를 통해 각 함수의 컴파일된 코드를 찾아 최종 실행 파일에 직접 복사합니다. 그 결과는 demo라는 단일 바이너리이며, 자신의 코드와 엔진 코드가 모두 포함되어 실행에 필요한 모든 것을 스스로 가지고 있습니다.
이것은 JavaScript 모듈이 작동하는 방식과는 근본적으로 다릅니다. Node.js에서 패키지를 import하면, 해당 패키지는 런타임에 node_modules에서 로드됩니다. 정적 링크 방식에서는 런타임 조회(runtime lookup)가 없습니다. 라이브러리 코드가 빌드 시점에 실행 파일에 물리적으로 내장되는 것이죠. 컴파일 후에는 정적 라이브러리 파일 자체가 필요 없으며, 실행 파일은 완전히 자급자족(self-contained)하게 됩니다.
Ninja (빌드 실행기)
Ninja는 실제로 컴파일러를 실행하는 도구입니다. CMake가 명령어를 생성하면, Ninja가 이를 실행합니다. 매우 빠르고 최소한의 기능을 제공하죠. 여러분이 직접 Ninja와 상호작용할 일은 거의 없을 겁니다. 그저 cmake --build build를 실행하면 CMake가 알아서 Ninja를 호출해줍니다.
vcpkg (패키지 매니저)
이것은 꽤 친숙하게 느껴질 겁니다. vcpkg는 C++에서 npm과 가장 유사한 도구입니다. GLFW와 같은 서드파티 라이브러리를 설치할 때 사용하죠. 의존성은 vcpkg.json에 선언되며, vcpkg는 이를 해결하고 다운로드하며 빌드합니다.
{
"dependencies": [
"glfw3"
]
}
clang-format 및 clang-tidy (코드 품질 도구)
설치: 두 도구 모두 LLVM toolchain에 포함되어 있습니다. Clang을 설치했다면 이미 가지고 있을 가능성이 높습니다.
- clang-format은 C++를 위한 Prettier입니다. 일관된 코드 스타일을 자동으로 강제합니다.
- clang-tidy는 C++를 위한 ESLint입니다. 정적 분석을 수행하여 흔한 버그나 안티패턴을 컴파일 시점에 잡아냅니다.
.h 파일과 .cpp 파일
웹 개발에서는 모든 것을 단일 .ts 또는 .js 파일에 작성하는 경우가 많습니다. C++에서는 코드가 두 가지 파일 유형으로 나뉩니다.
- 헤더 파일(
.h): 이 파일들은 **선언(declarations)**을 담고 있습니다. 무엇이 존재하는지를 설명하죠. 구조체 이름, 메서드 시그니처, 타입 등이 여기에 해당합니다. TypeScript의 인터페이스 파일이나.d.ts선언을 떠올려보세요. 다른 파일들은 사용 가능한 것을 알기 위해 헤더 파일을 포함(include)합니다. - 소스 파일(
.cpp): 이 파일들은 **구현(implementations)**을 담고 있습니다. 실제 실행되는 코드가 여기에 포함되죠. TypeScript 인터페이스를 구현하는 구체적인 클래스라고 생각하면 됩니다.
이러한 분리는 C++ 컴파일러가 파일을 독립적으로 처리하기 때문에 존재합니다. application.cpp가 Window 구조체를 사용해야 할 때, window.cpp를 읽지 않습니다. 대신 window.h를 읽어 Window의 형태를 파악하고, 최종적으로 링커가 모든 것을 연결합니다. 초보 시절엔 이 .h와 .cpp 분리가 괜히 복잡하게 느껴졌지만, 프로젝트 규모가 커질수록 명확한 인터페이스 정의와 구현 분리가 얼마나 코드의 응집도를 높이고 의존성을 줄여주는지 체감할 수 있었습니다. 마치 잘 설계된 API 문서와 그 구현체를 보는 듯하죠.
디렉터리 아키텍처
관심사 분리는 3D 엔진에서 웹 애플리케이션만큼이나 중요합니다. 우리는 소스 코드를 명확한 책임을 가진 계층으로 구성합니다.
src/
├── engine/ ← 엔진 (정적 라이브러리)
│ ├── core/
│ │ └── application ← 게임 루프 소유, 모든 것을 조율
│ ├── platform/
│ │ ├── window ← OS 창 관리 (GLFW)
│ │ └── surface ← 창과 벌칸 사이의 다리 역할
│ └── renderer/
│ ├── instance ← 벌칸 런타임 초기화
│ ├── device ← GPU 선택 및 논리 장치 생성
│ └── swapchain ← 프레임 제시를 위한 이미지 버퍼
├── demo/
│ └── main.cpp ← 엔진을 사용하는 데모 애플리케이션
의존성 흐름은 단방향입니다.
main.cpp → Application → Renderer
→ Platform (Window, Surface)
platform 계층은 OS 관련 모든 것(창 생성, 벌칸 연결)을 처리합니다. renderer 계층은 순수하게 벌칸 코드만 다룹니다. core 계층은 이 둘을 연결하는 역할을 합니다. 데모 애플리케이션은 Application에 대해서만 알고 있을 뿐, GLFW나 Vulkan의 존재에 대해서는 전혀 알지 못합니다.
Node.js에서 어니언 아키텍처를 경험해봤다면, 이 구조가 친숙하게 느껴질 것입니다. 내부 계층은 외부 계층에 대해 알지 못합니다. 렌더러는 창에 대해 모르고, 애플리케이션은 마치 의존성 주입 설정의 컴포지션 루트처럼 경계에 위치하여 모든 것을 연결합니다.
초기화 흐름
Vulkan은 매우 명시적인(explicit) API입니다. OpenGL(또는 WebGL)과 달리, Vulkan은 뒤에서 어떤 일도 알아서 해주지 않습니다. 파이프라인의 모든 부분을 직접 설정해야 합니다. 초기화 흐름은 다음과 같습니다.
Instance → Window → Surface → Device → Swapchain
각 단계는 이전 단계에 의존합니다. 하지만 자세히 들어가기 전에, 우리가 다루게 될 두 가지 주요 기술인 Vulkan과 GLFW를 이해하는 것이 중요합니다.
1단계: Vulkan과 GLFW는 무엇일까요?
웹 개발자라면 픽셀이 화면에 어떻게 나타나는지에 대해 깊이 고민할 필요가 없었을 겁니다. 브라우저가 이 모든 것을 처리해주니까요. 여러분은 HTML과 CSS를 작성하거나 <canvas>에 그림을 그리고, 브라우저의 렌더링 엔진이 GPU와 소통하는 방법을 알아서 처리합니다.
네이티브 개발에서는 브라우저가 없습니다. 여러분의 애플리케이션은 그래픽스 API를 통해 GPU와 직접 소통합니다. 바로 Vulkan이 그 역할을 합니다. Vulkan은 GPU에 명령을 보낼 수 있게 해주는 저수준(low-level) API입니다. 예를 들어 "이미지 버퍼를 생성해라", "이 셰이더 프로그램을 실행해라", "이 삼각형들을 그려라", "이 프레임을 화면에 제시해라" 같은 명령들이죠. 브라우저에서 보셨을 법한 WebGL API와 비슷하지만, 훨씬 더 명시적이고 상세합니다. WebGL이 대부분의 복잡성을 숨기는 반면, Vulkan은 모든 것을 노출합니다. 메모리 할당, 동기화, 커맨드 기록 등 모든 것을 직접 제어해야 하죠. 이것이 고성능 엔진에 강력한 이유이자, 동시에 배우기 어려운 이유이기도 합니다. 처음 WebGL에서 Vulkan으로 넘어왔을 때, 이 엄청난 명시성과 제어권에 감탄하면서도 동시에 '이걸 다 내가 해야 한다고?' 하는 부담감에 식은땀이 나기도 했습니다. 하지만 그만큼 퍼포먼스 최적화의 여지가 많다는 점이 매력적이죠.
Vulkan은 크로스 플랫폼입니다. Windows, Linux, Android에서 네이티브로 실행됩니다. macOS에서는 Vulkan 호출을 애플의 Metal API로 변환하는 MoltenVK라는 변환 계층을 통해 실행됩니다. 이는 우리가 Vulkan 코드를 작성해도 macOS에서 작동한다는 의미이지만, 몇 가지 추가적인 설정 단계(이식성 확장 기능)가 필요하며, 이는 곧 살펴보게 될 것입니다.
Vulkan이 하지 않는 한 가지는 창(window)을 생성하는 것입니다. Vulkan은 그래픽스 API이지, 창 관리 API가 아닙니다. 픽셀을 렌더링할 수는 있지만, 운영체제에서 창을 열거나, 키보드 입력을 처리하거나, 닫기 버튼에 반응하는 방법은 알지 못합니다. 이를 위해서는 별도의 라이브러리가 필요합니다.
여기서 GLFW가 등장합니다. GLFW는 OS 수준의 작업(창 생성, 입력 이벤트(키보드, 마우스, 게임패드) 처리, OS 창과 사용 중인 그래픽스 API 간의 연결)을 처리하는 작은 C 라이브러리입니다. 브라우저의 window 객체를 떠올려보세요. 렌더링 결과가 나타날 컨테이너를 제공하고, 사용자가 상호작용할 때 이벤트를 발생시킵니다.
GLFW는 Vulkan에 대해서도 잘 알고 있습니다. 여러분의 플랫폼에서 창에 렌더링된 출력을 표시하는 데 필요한 **Vulkan 확장 기능(extensions)**이 무엇인지 알려줄 수 있습니다. Vulkan의 확장 기능은 선택적 기능들입니다. 핵심 API는 최소한이며, 플랫폼별 특정 기능(예: "macOS 창에 픽셀을 어떻게 표시하나요?")은 확장 기능으로 제공됩니다. GLFW는 시스템을 쿼리하여 활성화해야 할 확장 기능 목록을 반환합니다. Vulkan 인스턴스를 생성할 때 이것이 실제로 어떻게 작동하는지 보게 될 것입니다.
이러한 배경 지식을 바탕으로 각 초기화 단계를 살펴보겠습니다.
2단계: VkInstance
VkInstance는 Vulkan API의 진입점입니다. 이를 생성하면 여러분의 머신에서 Vulkan 런타임이 초기화됩니다. 이것 없이는 어떤 Vulkan 관련 작업도 일어날 수 없습니다.
웹 개발 배경이라면, 브라우저를 여는 것과 같다고 생각해보세요. 브라우저 자체는 아직 어떤 웹 페이지도 보여주지 않지만, 이제 렌더링 엔진에 접근할 수 있게 된 거죠. VkInstance가 바로 그런 역할입니다. 시스템에 "저는 Vulkan을 사용하고 싶습니다"라고 말하는 것과 같습니다.
헤더 파일을 살펴보죠.
#pragma once
#include <vulkan/vulkan.h>
struct Instance
{
VkInstance instance;
Instance();
void Destroy();
};
C++에 익숙하지 않은 분들을 위해 몇 가지 설명이 필요합니다.
struct: TypeScript 출신이라면 class를 기대했을 겁니다. C++에는 struct와 class 둘 다 있으며, 거의 동일합니다. 유일한 차이점은 기본 접근성(default visibility)입니다. struct에서는 모든 멤버가 기본적으로 public이지만, class에서는 기본적으로 private입니다. 우리 엔진에서는 모든 멤버가 public이므로, class를 작성하고 바로 public:을 추가하여 기본값을 되돌리는 대신 struct를 사용합니다. C++ 코드베이스에서는 두 가지 관습 모두 볼 수 있으며, 이는 기능적 차이보다는 스타일 선택에 가깝습니다.
**#pragma once**는 전처리기 지시문입니다. 여러 파일이 이 파일을 포함하려 해도 "이 파일을 딱 한 번만 포함해라"라고 컴파일러에 지시하는 역할을 합니다. 이것 없이는 중복 정의 오류가 발생할 수 있습니다. 같은 모듈을 두 번 import하지 않도록 하는 JavaScript의 개념과 비슷하지만, C++에서는 컴파일러가 자동으로 처리해주지 않는다는 차이가 있습니다.
**#include <vulkan/vulkan.h>**는 Vulkan API 선언들을 가져옵니다. 이것은 개념적으로 JavaScript의 import vulkan from 'vulkan'과 같습니다. VkInstance와 같은 타입이 존재하며 어떤 모습인지 컴파일러에게 알려줍니다.
인스턴스를 생성할 때 세 가지 일이 일어납니다.
VkApplicationInfo로 애플리케이션 정보를 기술합니다: 앱 이름, Vulkan API 버전과 같은 메타데이터입니다. HTTP의User-Agent헤더를 떠올려보세요.- GLFW에 필요한 확장 기능을 요청합니다: 1단계에서 논의했듯이, GLFW는 여러분의 플랫폼에서 창에 프레임을 제시(present)하기 위해 어떤 Vulkan 확장 기능이 필요한지 알고 있습니다. macOS에서는 앞에서 언급했던 MoltenVK 이식성 확장 기능이 여기에 포함됩니다.
- 유효성 검사 계층(Validation Layers)을 활성화합니다 (디버그 빌드에서만): 이것들은 런타임 린터와 같습니다. 모든 Vulkan API 호출을 감시하고, 잘못된 작업을 하면 경고를 줍니다. 배우는 과정에서 엄청나게 유용하죠.
3단계: Window
창(Window)은 운영체제와의 연결점입니다. 화면에 픽셀이 나타날 직사각형 영역이죠. 우리는 GLFW를 사용하여 창을 생성하고 관리합니다. 1단계에서 다뤘듯이, GLFW는 플랫폼 전반에 걸쳐 창 생성, 입력 및 OS 이벤트를 처리합니다.
#pragma once
#include <GLFW/glfw3.h>
#include <cstdio>
#include <cstdlib>
struct Window
{
GLFWwindow* window;
Window();
Window(int width, int height, const char* title);
bool ShouldClose() const;
void PollEvents() const;
void Destroy();
};
메서드 선언 뒤에 붙은 const 키워드(bool ShouldClose() const)에 주목해주세요. 이것은 JavaScript나 TypeScript에는 없는 기능입니다. 이 메서드가 객체를 수정하지 않을 것이라는 컴파일러에 대한 약속입니다. 데이터를 읽기만 하고 절대 쓰지 않겠다는 의미죠. 만약 const 메서드 안에서 멤버 변수를 실수로 변경하려고 하면, 컴파일러가 이를 거부할 것입니다. TypeScript의 Readonly<T>와 비슷하지만, 타입 레벨이 아니라 메서드 레벨에서 강제되는 읽기 전용 계약이라고 생각하면 됩니다. Destroy()는 상태를 변경(창을 해체)하므로 const가 아닙니다.
여기서 처음으로 **포인터(pointer)**를 보게 됩니다. GLFWwindow* window가 무엇을 의미하는지 이야기해봅시다.
JavaScript에서 const element = document.getElementById('app')을 작성하면, DOM 요소에 대한 참조를 얻게 됩니다. 요소 자체를 얻는 것이 아니라, 요소가 메모리에 어디에 살고 있는지를 가리키는 무언가를 얻는 것이죠. 만약 브라우저가 요소를 메모리에서 이동시키더라도, 여러분의 참조는 업데이트될 겁니다.
C++에서 포인터는 이것의 명시적인 버전입니다. GLFWwindow*는 "메모리에서 GLFWwindow가 존재하는 주소"를 의미합니다. *가 포인터를 만드는 역할을 합니다. 여러분은 GLFW 창 데이터를 직접 소유하는 것이 아닙니다. GLFW가 내부적으로 데이터를 할당하고, 그 데이터의 포인터를 여러분에게 주는 것이죠. 이 포인터를 다른 GLFW 함수에 전달하면, 함수들은 이 주소를 따라가 실제 창 데이터를 찾습니다.
지금 당장 알아야 할 중요한 점은 이것입니다. 포인터는 주소입니다. GLFWwindow* window는 "window는 메모리 어딘가에 있는 GLFWwindow의 주소를 담고 있는 변수이다"라는 의미입니다.
창은 간단한 역할을 합니다. GLFW를 통해 OS 창을 생성하고 세 가지 작업을 노출합니다.
ShouldClose(): 사용자가 닫기 버튼을 클릭했는지 확인합니다.PollEvents(): OS 이벤트(마우스, 키보드, 크기 변경, 닫기)를 확인합니다. 이 작업을 하지 않으면 OS는 여러분의 앱이 멈췄다고 생각합니다.Destroy(): 모든 것을 해체합니다.
구현에서 GLFW_NO_API 힌트에 주목하세요.
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
이것은 GLFW에게 "OpenGL 컨텍스트를 설정하지 마라. 대신 Vulkan을 사용할 것이다"라고 알려줍니다. 기본적으로 GLFW는 OpenGL 컨텍스트를 생성하는데, 우리는 그것을 원치 않습니다.
4단계: VkSurfaceKHR
표면(Surface)은 여러분의 OS 창과 Vulkan 사이의 다리 역할을 합니다. "Vulkan이 픽셀을 어디에 그려야 하는가?"라는 질문에 답을 제공하죠.
웹 용어로 비유하자면, <canvas> 요소와 WebGL 컨텍스트가 있다고 상상해보세요. 캔버스는 창이고, WebGL 컨텍스트는 Vulkan입니다. 표면은 이 둘 사이의 바인딩입니다. 렌더링 API가 화면의 특정 직사각형 영역에 결과물을 출력할 수 있도록 해주는 것이죠.
#pragma once
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
struct Surface
{
VkSurfaceKHR surface;
Surface();
Surface(VkInstance instance, GLFWwindow* window);
void Destroy(VkInstance instance);
};
**#define GLFW_INCLUDE_VULKAN**은 전처리기 매크로입니다. JavaScript에는 이런 기능이 없습니다. 컴파일러가 여러분의 코드를 보기 전에, **전처리기(preprocessor)**라는 별도의 단계가 코드를 훑으며 텍스트 치환을 수행합니다. #define GLFW_INCLUDE_VULKAN은 플래그를 생성합니다. 그 자체로 어떤 코드도 생성하지 않죠. 다음 줄에서 GLFW의 헤더 파일이 포함될 때, 이 플래그의 존재 여부를 확인하고, 플래그가 존재하면 Vulkan 타입 선언들(예: VkInstance 및 VkSurfaceKHR)도 함께 가져옵니다. 이것이 없으면 GLFW는 Vulkan 타입에 대해 알 수 없게 됩니다. 컴파일 시간 기능 플래그(compile-time feature flag)라고 생각하면 됩니다. 런타임이 아닌 컴파일 전에 해결되는 환경 변수와 비슷합니다.
표면은 VkInstance와 GLFWwindow* 모두를 필요로 합니다. 두 세계 사이에 존재하기 때문이죠. GLFW는 플랫폼별 세부 사항을 처리하는 도우미 함수(glfwCreateWindowSurface)를 제공합니다. macOS에서는 Metal 표면을, Linux에서는 X11 또는 Wayland 표면을, Windows에서는 Win32 표면을 생성합니다.
이것이 표면이 인스턴스와 창이 모두 생성된 후에 생성되고, platform/ 디렉터리에 위치하는 이유입니다. Vulkan 타입을 사용함에도 불구하고, 본질적으로 OS 수준의 개념을 감싸고 있기 때문입니다.
5단계: VkDevice
장치(Device) 단계는 실제로 두 가지 일을 합니다. 물리적 GPU를 선택하고 논리적 장치를 생성하는 것이죠.
물리적 장치 선택
여러분의 컴퓨터에는 여러 개의 GPU가 있을 수 있습니다(예: 내장 GPU와 외장 GPU). 우리는 필요한 작업을 수행할 수 있는 GPU를 하나 선택해야 합니다. 선택 과정은 다음과 같습니다.
- 시스템의 모든 GPU를 열거합니다.
- 각 GPU에 대해 **큐 패밀리(queue families)**를 확인합니다. 큐 패밀리는 다양한 작업(그래픽스, 컴퓨트, 전송, 프레임 제시)을 수행할 수 있는 "워커" 그룹입니다.
- 그래픽스 큐 패밀리(그림을 그릴 수 있는)와 프레임 제시 큐 패밀리(우리의 표면에 결과물을 표시할 수 있는)를 모두 가진 GPU를 찾습니다.
- 해당 GPU가 스왑체인 확장 기능(렌더링된 프레임을 제시하는 데 필요)을 지원하는지 확인합니다.
- 모든 검사를 통과하는 첫 번째 GPU를 선택합니다.
웹 개발자 출신이라면 큐 패밀리를 서로 다른 스레드 풀로 생각할 수 있습니다. 한 풀은 그래픽스 작업을 처리하고, 다른 풀은 화면에 결과물을 표시하는 작업을 처리합니다. 종종 같은 풀인 경우도 많지만, API는 확인을 요구합니다.
논리적 장치 생성
GPU를 선택했다면, **논리적 장치(logical device)**를 생성합니다. 이것은 우리의 애플리케이션이 GPU에 접근하는 핸들입니다. 이 구분은 중요합니다. VkPhysicalDevice는 실제 하드웨어를 나타내고, VkDevice는 하드웨어에 대한 우리의 연결을 나타냅니다. 여러 애플리케이션이 동일한 VkPhysicalDevice를 가리키는 자신만의 VkDevice를 가질 수 있습니다.
논리적 장치를 생성할 때 우리는 다음을 요청합니다.
- 우리가 식별한 패밀리에서 **큐(Queues)**를 요청합니다. 이것들은 우리가 그리기 명령을 보낼 메일박스 역할을 합니다.
- **확장 기능(Extensions)**을 요청합니다. 특히
VK_KHR_swapchain을 요청하여 프레임을 제시할 수 있도록 합니다.
생성 후에는 큐 핸들(queue handles)을 검색합니다. 이것들이 나중에 렌더링 명령을 제출하고 이미지를 화면에 제시하는 데 사용될 것입니다.
셰이더(Shader)란? 셰이더는 GPU에서 실행되는 작은 프로그램입니다. 이 프로그램은 2부에서 작성할 것입니다. 지금은 GPU가 셰이더 프로그램을 실행하여 정점(vertices)이 어디에 나타나고 각 픽셀의 색상이 무엇이 되어야 하는지 결정한다는 것만 알아두세요.
6단계: Swapchain
프레임(Frame)이란? 프레임은 화면에 표시되는 단일한 완전한 이미지입니다. 영화는 초당 24프레임으로 재생됩니다. 24장의 정지 이미지가 너무 빨리 깜빡여서 움직이는 것처럼 보이는 것이죠. 게임도 비슷하게 작동하며, 일반적으로 초당 60프레임으로 구동됩니다. 각 프레임은 GPU에 의해 처음부터 다시 그려지고, 잠시 표시된 다음 다음 프레임으로 교체됩니다.
스왑체인(Swapchain)은 화면에 번갈아 표시되는 이미지들의 큐입니다. <canvas> 게임의 더블 또는 트리플 버퍼링과 비슷하다고 생각해보세요.
2~3개의 오프스크린 캔버스가 있다고 상상해봅시다. 브라우저가 하나를 표시하는 동안, 여러분은 다른 하나에 그림을 그립니다. 그림을 다 그리면 이들을 바꿉니다. 새로 그려진 캔버스가 화면으로 가고, 이전에 표시되던 캔버스는 다음 프레임을 위해 자유로워집니다. 스왑체인이 하는 일이 바로 이것입니다.
Image A: [화면에 표시 중]
Image B: [GPU가 다음 프레임을 여기에 그리고 있음]
Image C: [대기 중, GPU가 다음으로 사용할 준비 완료]
↓
스왑 → Image B가 화면으로, Image A는 이제 자유로움
이것이 없으면 사용자는 반쯤 그려진 프레임을 보게 될 것이고, 이를 **테어링(tearing)**이라는 시각적 결함이라고 부릅니다. 테어링 현상을 방지하는 데 스왑체인이 필수적이죠. 이 스왑체인 개념을 제대로 이해하는 것은 끊김 없는 부드러운 화면을 만드는 데 핵심입니다. 제가 예전에 웹 애니메이션에서 requestAnimationFrame을 써서 버벅임을 줄이던 경험과 놀랍도록 닮아 있어서, 원리를 깨달았을 때 쾌감을 느꼈던 기억이 나네요. 뚝뚝 끊기는 화면만큼 사용자 경험을 해치는 것도 없으니까요.
테어링(Tearing)이란? 테어링은 화면의 위쪽 절반이 한 프레임을 보여주고 아래쪽 절반은 다음 프레임을 보여주는 현상입니다. 디스플레이가 그림을 그리는 도중에 새로고침될 때 발생하죠. 모니터가 현재 읽고 있는 이미지에 GPU가 동시에 쓰고 있을 때 나타납니다. 스왑체인은 표시 중인 이미지와 작업 중인 이미지를 분리하여 이를 방지합니다.
스왑체인을 생성하는 과정은 표면이 무엇을 지원하는지 쿼리하고 최상의 옵션을 선택하는 것을 포함합니다.
- 포맷(Format): 픽셀 포맷과 색 공간(color space)입니다. 우리는
B8G8R8A8_SRGB(sRGB를 사용하는 BGRA 8비트)를 선호합니다. 이것은 모니터의 표준 포맷이며, sRGB는 색상이 올바르게 보이도록 보장합니다. - 프레젠트 모드(Present mode): 스왑이 작동하는 방식입니다.
MAILBOX는 트리플 버퍼링(낮은 지연 시간, GPU가 큐에 있는 프레임을 교체함)이고,FIFO는 수직 동기화(vsync,requestAnimationFrame과 유사하며, 사용 가능성이 보장되어 부드럽지만 지연 시간이 더 김)입니다. 우리는 메일박스를 선호하고, FIFO로 대체합니다.
수직 동기화(Vsync)란? Vsync(vertical sync)는 프레임률을 모니터의 재생률(보통 60Hz)에 고정시킵니다. 모니터가 한 프레임 표시를 마칠 때까지 기다렸다가 다음 프레임을 교체하여 테어링을 방지합니다. 브라우저의
requestAnimationFrame은 본질적으로 Vsync와 같습니다. 브라우저는 디스플레이 새로 고침마다 콜백을 한 번 호출합니다.
- 범위(Extent): 해상도로, 일반적으로 창 크기와 일치합니다.
스왑체인을 생성한 후, 우리는 이미지 핸들(드라이버가 생성했고 우리는 참조만 얻습니다)을 얻고 각 이미지에 대한 **이미지 뷰(image views)**를 생성합니다. 이미지 뷰는 Vulkan에게 이미지를 어떻게 해석할지 알려주는 "렌즈"와 같습니다. 이미지의 포맷, 2D 이미지라는 점, 그리고 색상 채널에 관심이 있다는 점 등을 알려주죠.
모든 것을 한데 모으기: Application
Application 구조체는 가장 상위에 위치하여 모든 것을 조율합니다. 이는 컴포지션 루트(composition root)로, 모든 구성 요소를 알고 이들을 연결하는 유일한 곳입니다.
Application::Application(int width, int height, const char* title)
: window(width, height, title), renderer(),
surface(renderer.instance.instance, window.window)
{
renderer.InitDevice(surface.surface);
renderer.InitSwapchain(surface.surface,
static_cast<uint32_t>(width),
static_cast<uint32_t>(height));
}
여기에는 JavaScript에는 없는 두 가지 문법이 있습니다.
멤버 초기화 리스트(Member initializer list): 생성자 시그니처 뒤의 : window(width, height, title), renderer(), surface(...) 부분이 바로 그것입니다. JavaScript에서는 생성자 본문 안에서 this.window = new Window(width, height, title)와 같이 작성했을 겁니다. C++에는 메모리 작동 방식 때문에 이를 위한 별도의 문법이 있습니다. Application이 생성될 때, 모든 멤버 변수는 생성자 본문이 실행되기 전에 생성되어야 합니다. 초기화 리스트는 각 멤버를 어떻게 생성할지 컴파일러에게 알려주는 곳입니다. 만약 이를 건너뛰고 본문 안에서 할당했다면, 각 멤버는 먼저 기본 생성된 다음(불필요한 작업이 발생할 수 있음) 다시 할당될 것입니다. 초기화 리스트는 이러한 이중 초기화를 방지합니다.
static_cast<uint32_t>(width): 명시적 타입 변환입니다. JavaScript에서는 숫자는 그저 숫자이며, 단 하나의 number 타입만 있습니다. C++에서는 int와 uint32_t(부호 없는 32비트 정수)는 다른 타입이며, 부호 있는 타입에서 부호 없는 타입으로 변환할 때 정보 손실(음수 값이 래핑됨)이 발생할 수 있으므로 컴파일러는 암시적 변환에 대해 경고할 것입니다. static_cast는 "이 타입들은 다르지만, 의도적으로 변환하고 있다"고 말합니다. TypeScript에서 width as number라고 작성하는 것과 유사한 C++의 명시적 어노테이션으로, 의도를 명확히 합니다.
각 단계는 이전 단계에 의존하므로, 생성 순서가 중요합니다.
| 순서 | 구성 요소 | 의존성 |
|---|---|---|
| 1 | Window | 없음 |
| 2 | Instance | 없음 |
| 3 | Surface | Instance + Window |
| 4 | Device | Instance + Surface |
| 5 | Swapchain | Device + Surface |
해체(Destruction)는 역순으로 진행됩니다. 생성 순서의 반대로 해체해야, 이미 사라진 것을 사용하려는 시도를 방지할 수 있습니다.
void Application::Shutdown()
{
surface.Destroy(renderer.instance.instance);
renderer.Destroy(); // swapchain → device → instance
window.Destroy();
}
JavaScript에서 넘어오신 분들은 Destroy()를 굳이 호출해야 하는지 의아할 수도 있습니다. JS에서는 객체에 대한 참조를 끊으면 **가비지 컬렉터(garbage collector)**가 언젠가 메모리를 해제해주니까요. C++에는 가비지 컬렉터가 없습니다. Vulkan 장치, 창, GPU 메모리 블록 등 리소스를 할당하면, 명시적으로 해제할 때까지 계속 할당된 상태로 남아 있습니다. 만약 이를 잊으면 리소스 누수(leak)가 발생합니다. 프로그램 관점에서는 리소스가 사라졌지만, 프로세스가 종료될 때까지 OS나 GPU 드라이버에 의해 여전히 점유된 상태로 남아있게 되죠. 이 엔진에서 보게 될 모든 Destroy() 메서드는 JavaScript에서 가비지 컬렉터가 대신해줄 일을 수동으로, 그리고 특정 순서대로 수행하는 것입니다. 이러한 리소스들은 서로 의존하고 있기 때문입니다.
현재로서는 게임 루프가 간단합니다.
void Application::Run()
{
while (!window.ShouldClose())
{
window.PollEvents();
}
Shutdown();
}
애플리케이션이 루프를 소유합니다. 창은 그저 이벤트를 보고할 뿐입니다. 렌더러는 루프에 대해 전혀 알지 못합니다. 엔진이 성장함에 따라 루프는 씬 업데이트 및 렌더 호출을 포함하도록 확장될 것이지만, 기본 구조는 동일하게 유지됩니다.
데모 애플리케이션은 의도적으로 최소한의 코드를 가집니다. 실제 코드는 단 두 줄이죠.
int main() {
Application app(800, 600, "Ultra 3D Engine");
app.Run();
return EXIT_SUCCESS;
}
이것이 계층형 아키텍처의 핵심입니다. Vulkan 초기화, GPU 선택, 스왑체인 생성의 복잡성 그 어느 것도 애플리케이션 코드로 새어 나오지 않습니다. 이 모든 것은 Application 뒤에 깔끔하게 감춰져 있으며, 그래야 마땅합니다.
다음 단계는?
이 시점에서 우리는 창, Vulkan 인스턴스, 표면, 장치, 그리고 스왑체인을 모두 갖추게 되었습니다. 이제 인프라는 제자리를 찾았습니다. 렌더링할 이미지가 있고, GPU는 명령을 받을 준비가 된 상태입니다.
다음 장에서는 **렌더 패스(render pass)**와 **그래픽스 파이프라인(graphics pipeline)**을 생성할 것입니다. 이 지점에서 우리는 비로소 GPU에 무엇을 그릴지 지시하게 됩니다.
원문: https://dev.to/remojansen/building-a-3d-engine-from-scratch-with-c-and-vulkan-for-web-developers-part-i-bootstrapping-1bap 수집일: 2026-05-18 02:00:37