← 목록으로

웹 개발자여, C++와 Vulkan으로 나만의 3D 엔진 심장을 만들자! 1부: Vulkan 부트스트래핑, 첫 발자국

2026. 5. 19.

웹 개발자여, C++와 Vulkan으로 나만의 3D 엔진 심장을 만들자! 1부: Vulkan 부트스트래핑, 첫 발자국

왜 이 프로젝트가 시작되었을까요?

저는 수년간 웹 개발 분야에서 일해왔습니다. 타입스크립트, Node.js, React가 제 세상이었죠. SOLID 원칙, 어니언 아키텍처, 의존성 주입(DI), 관심사 분리(SoC) 같은 소프트웨어 아키텍처에 대한 고민을 정말 많이 했습니다. 물론 이 모든 건 웹이라는 맥락 안에서였죠.

하지만 제가 처음 기술의 세계에 발을 들인 이유는 웹 폼이나 REST API와는 전혀 관계가 없습니다. 바로 '슈퍼 마리오 64' 때문이었죠. 어릴 적 이 게임을 플레이하며 3D 그래픽스를 처음 경험했을 때의 충격은 뇌리에 깊이 박혔습니다. 이 프로젝트 이름인 "Ultra"는 Nintendo 64의 출시 전 이름이었던 "Ultra 64"에 대한 오마주이기도 합니다.

저는 항상 3D 엔진이 모든 레벨에서 어떻게 작동하는지 이해하고 싶었습니다. 그저 '함수 호출했더니 삼각형이 나타났어요!' 정도가 아니죠. GPU는 무엇을 그려야 할지 어떻게 아는지, 픽셀이 화면에 어떻게 나타나는지, 우리가 작성한 코드와 디스플레이 사이에서 정확히 어떤 일이 벌어지는지에 대한 실제 작동 방식을 말입니다.

이 프로젝트는 바로 그런 질문들에 답하기 위해 C++와 Vulkan으로 3D 엔진을 처음부터 만들어보는 저의 시도입니다. 저 또한 이 과정을 통해 배워나가고 있기 때문에, 저와 같은 길을 걷는 다른 사람들, 특히 웹 개발 배경을 가진 분들에게 도움이 될 수 있도록 모든 과정을 문서화하고 있습니다. 제가 실무에서 이 부분을 테스트해 봤을 때, 웹 개발의 추상화 계층에 익숙했던 저에게는 GPU와 직접 대화하는 이 과정 자체가 엄청난 학습의 연속이었습니다.

소스 코드는 EU에서 호스팅되는 비영리 GitHub 대체 서비스인 Codeberg에 있습니다. 저장소는 codeberg.org/remojansen/ultra에서 찾을 수 있습니다.

이 프로젝트가 얼마나 멀리까지 갈지는 저도 확신할 수 없습니다. 저는 즐거움과 배움을 위해 시작했으니, 흥미롭고 재미있는 한 계속 이어나갈 생각입니다. 로드맵도, 데드라인도, '완성된' 엔진이라는 거창한 약속도 없습니다. 그저 순수한 호기심과 추진력이 이 프로젝트를 이끌어갈 뿐이죠.

준비물

이 시리즈는 여러분이 숙련된 웹 개발자라는 전제를 깔고 있습니다. 다음 사항에 익숙해야 합니다.

  • JavaScript와 TypeScript — 매일 JS/TS 코드를 작성하며 타입 시스템, 모듈 시스템, 비동기 패턴을 이해하고 있어야 합니다.
  • Node.js — 백엔드 서비스를 구축해봤고, npm을 사용하며 Node 런타임이 어떻게 작동하는지 알고 있어야 합니다.
  • 브라우저 API — DOM, <canvas> 그리고 브라우저가 페이지를 렌더링하는 방식에 대한 실질적인 지식이 필요합니다.
  • 소프트웨어 아키텍처 — 관심사 분리(SoC), 의존성 주입(DI), 계층형 아키텍처와 같은 개념이 낯설지 않아야 합니다.

C++나 그래픽스 프로그래밍 경험이 전혀 없어도 괜찮습니다. 바로 그 부분을 이 시리즈에서 다룰 예정이니까요. 다만, 낯선 문서를 끈기 있게 읽고 새로운 도구를 능동적으로 파고들 줄 아는 개발자라면 환영입니다.

개발 도구 체인

엔진 코드를 한 줄도 작성하기 전에, 먼저 사용할 도구들을 이해해야 합니다. 자바스크립트 생태계에서 오셨다면, C++ 도구 체인은 매우 낯설게 느껴질 겁니다. npm install도 없고 node index.js 같은 명령어도 없죠. 하지만 놀랍게도 그 근본적인 개념들은 웹 개발 도구와 멋지게 연결됩니다.

Clang (컴파일러)

설치: Clang 시작하기

자바스크립트에서는 코드가 V8이나 SpiderMonkey 같은 엔진에서 직접 실행됩니다. 이 엔진들은 JIT(Just-In-Time) 컴파일 방식을 사용합니다. 즉, 프로그램이 실행되는 도중에 코드를 머신 코드로 컴파일하고, 실제 사용 패턴에 따라 자주 실행되는 코드 경로(hot path)를 즉석에서 최적화합니다.

C++는 이와 반대 방식으로 작동합니다. 코드는 실행되기 전에 먼저 컴파일되어 이진 파일로 만들어져야 합니다. 이를 AOT(Ahead-Of-Time) 컴파일이라고 합니다. Clang은 우리가 사용할 컴파일러입니다. .cpp 파일을 읽어 CPU에서 직접 실행되는 머신 코드를 생성하며, 런타임이나 인터프리터가 개입할 여지가 없습니다.

트레이드오프는 명확합니다. JIT 컴파일은 빠른 시작과 런타임 유연성(엔진이 자주 실행되는 코드 경로를 최적화할 수 있음)을 제공하지만, 컴파일러가 프로그램과 함께 실행되므로 오버헤드가 있습니다. AOT 컴파일은 초기에는 느립니다(테스트하기 전에 컴파일해야 하니까요). 하지만 그 결과물은 런타임 오버헤드가 전혀 없는, 완벽하게 최적화된 네이티브 코드입니다. 마이크로초 단위의 성능이 중요한 실시간 그래픽스 엔진에서는 바로 이런 트레이드오프가 필요합니다.

CMake (빌드 시스템 생성기)

설치: CMake 다운로드

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()이라는 진입점이 있으며, 더블 클릭하거나 터미널에서 실행하면 프로세스가 시작됩니다. **정적 라이브러리(static library)**는 실행할 수 없습니다. 컴파일된 코드의 묶음으로, 실행 파일에 포함되기를 기다리는 상태죠. 자바의 .jar 파일이나 컴파일된 npm 패키지를 떠올려 보세요. 유용한 코드를 포함하지만, 실제 실행을 위해서는 호스트 프로그램이 필요합니다.

**링크(linking)**는 이들을 연결하는 과정입니다. 컴파일러가 demo를 빌드할 때, Application()이나 Run()과 같은 함수 호출을 발견합니다. 이 함수들은 demo의 소스 코드에 정의되어 있지 않고, ultra_engine에 존재합니다. 링커의 역할은 이러한 참조를 해결하는 것입니다. 링커는 정적 라이브러리를 통해 각 함수의 컴파일된 코드를 찾고, 이를 최종 실행 파일에 직접 복사합니다. 결과적으로 demo라는 단일 이진 파일은 자체 코드와 모든 엔진 코드를 포함하게 되어, 실행에 필요한 모든 것을 스스로 갖추게 됩니다.

이는 자바스크립트 모듈 방식과는 근본적으로 다릅니다. Node.js에서 패키지를 import하면, 해당 패키지는 런타임에 node_modules에서 로드됩니다. 정적 링크 방식에서는 런타임 조회가 없습니다. 라이브러리 코드가 빌드 시점에 실행 파일에 물리적으로 내장됩니다. 컴파일 후에는 정적 라이브러리 파일 자체가 더 이상 필요하지 않습니다. 실행 파일은 완전히 독립적인 상태가 되죠.

Ninja (빌드 실행기)

설치: Ninja 시작하기

Ninja는 실제로 컴파일러를 실행하는 도구입니다. CMake가 명령을 생성하면, Ninja가 이를 실행하죠. 빠르고 가볍습니다. 여러분이 직접적으로 조작할 일은 거의 없을 겁니다. 그저 cmake --build build 명령을 실행하면 CMake가 Ninja를 대신 호출해 줍니다.

vcpkg (패키지 관리자)

설치: vcpkg 시작하기

이 도구는 여러분에게 꽤 익숙하게 느껴질 겁니다. vcpkg는 C++ 생태계에서 npm과 가장 비슷한 존재죠. GLFW 같은 서드파티 라이브러리를 설치하는 데 사용됩니다. 의존성은 vcpkg.json에 선언되며, vcpkg가 이를 분석하고 다운로드하며 빌드합니다.

{
  "dependencies": [
    "glfw3"
  ]
}

clang-format과 clang-tidy (코드 품질 관리)

설치: 둘 다 LLVM 툴체인에 포함되어 있습니다. Clang을 설치했다면 이미 가지고 있을 가능성이 높습니다.

  • clang-format은 C++ 버전 Prettier입니다. 일관된 코드 스타일을 자동으로 강제하죠.
  • clang-tidy는 C++ 버전 ESLint입니다. 정적 분석을 수행하여 흔한 버그나 안티 패턴을 컴파일 타임에 잡아냅니다.

.h 파일과 .cpp 파일

웹 개발에서는 단일 .ts 또는 .js 파일에 모든 것을 담아내곤 하죠. C++에서는 코드가 두 가지 유형의 파일로 나뉩니다.

  • 헤더 파일(.h) — 이는 선언입니다. 무엇이 존재하는지를 기술합니다. 구조체 이름, 메서드 시그니처, 타입 등이죠. 타입스크립트의 인터페이스 파일이나 .d.ts 선언을 생각하면 됩니다. 다른 파일들은 사용 가능한 것들을 알기 위해 이 헤더를 포함합니다.
  • 소스 파일(.cpp) — 이는 구현입니다. 실제 실행되는 코드를 포함합니다. 타입스크립트 인터페이스를 구현하는 구체적인 클래스라고 할 수 있겠네요.

이러한 분리는 C++ 컴파일러가 파일을 독립적으로 처리하기 때문에 존재합니다. application.cppWindow 구조체를 사용해야 할 때, window.cpp를 읽는 것이 아니라 window.h를 읽어 Window의 형태를 파악하고, 마지막에 링커가 모든 것을 연결합니다.

디렉터리 아키텍처

관심사 분리는 3D 엔진에서도 웹 애플리케이션만큼이나 중요합니다. 우리는 소스 코드를 명확한 책임을 가진 계층으로 조직했습니다.

src/
├── engine/                  ← 엔진 (정적 라이브러리)
│   ├── core/
│   │   └── application     ← 게임 루프를 소유하고 모든 것을 조율
│   ├── platform/
│   │   ├── window          ← OS 창 관리 (GLFW)
│   │   └── surface         ← 창과 Vulkan 사이의 다리
│   └── renderer/
│       ├── instance         ← Vulkan 런타임 초기화
│       ├── device           ← GPU 선택 및 논리적 장치
│       └── swapchain        ← 프레임 출력을 위한 이미지 버퍼
├── demo/
│   └── main.cpp            ← 엔진을 사용하는 데모 애플리케이션

의존성 흐름은 한 방향입니다.

main.cpp → Application → Renderer
                        → Platform (Window, Surface)

platform 계층은 OS 관련 모든 것(창 생성, Vulkan 연결)을 처리합니다. renderer 계층은 순수 Vulkan 코드입니다. 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이 바로 그런 로우레벨 API입니다. "이미지 버퍼를 생성해라", "이 셰이더 프로그램을 실행해라", "이 삼각형들을 그려라", "이 프레임을 화면에 출력해라"와 같은 명령을 GPU에 보낼 수 있게 해줍니다. 브라우저에서 보았을지도 모르는 WebGL API와 비슷하지만, 훨씬 더 명시적이고 장황합니다. WebGL이 대부분의 복잡성을 숨기는 반면, Vulkan은 모든 것을 노출합니다. 메모리 할당, 동기화, 커맨드 기록 등 모든 것을 직접 제어합니다. 이것이 고성능 엔진에 강력한 이유이자, 배우기 어려운 이유이기도 합니다.

Vulkan은 크로스 플랫폼입니다. Windows, Linux, Android에서 네이티브로 실행됩니다. macOS에서는 Vulkan 호출을 Apple의 Metal API로 변환하는 MoltenVK라는 번역 계층을 통해 실행됩니다. 이는 우리가 Vulkan 코드를 작성하면 macOS에서도 작동한다는 뜻이지만, 몇 가지 추가 설정 단계(이식성 확장)가 필요하며 잠시 후에 살펴볼 예정입니다.

Vulkan이 하지 않는 한 가지는 창을 생성하는 것입니다. Vulkan은 그래픽스 API이지 창 관리 API가 아닙니다. 픽셀을 렌더링할 수는 있지만, 운영체제에서 창을 여는 방법, 키보드 입력을 처리하는 방법, 닫기 버튼에 반응하는 방법에 대해서는 아무것도 모릅니다. 이를 위해서는 별도의 라이브러리가 필요합니다.

바로 GLFW가 여기서 등장합니다. GLFW는 OS 레벨의 일들을 처리하는 작은 C 라이브러리입니다. 창 생성, 입력 이벤트 처리(키보드, 마우스, 게임패드), 그리고 OS 창과 여러분이 사용하는 그래픽스 API 사이의 다리 역할을 제공합니다. 브라우저의 window 객체를 떠올려 보세요. 렌더링이 나타날 컨테이너를 제공하고, 사용자가 상호작용할 때 이벤트를 발생시킵니다.

GLFW는 Vulkan에 대해서도 잘 알고 있습니다. 여러분의 플랫폼에서 창에 렌더링된 결과물을 표시하는 데 필요한 **Vulkan 확장(extensions)**이 무엇인지 알려줄 수 있습니다. Vulkan의 확장은 선택적 기능입니다. 핵심 API는 최소한으로 유지되며, 플랫폼별 특정 기능(예: "macOS 창에 픽셀을 어떻게 표시하나요?")은 확장으로 제공됩니다. GLFW는 시스템을 쿼리하여 여러분이 활성화해야 할 확장 목록을 반환합니다. Vulkan 인스턴스를 생성할 때 이것이 실제로 작동하는 모습을 볼 수 있을 겁니다.

이러한 배경 지식을 바탕으로, 각 초기화 단계를 살펴보겠습니다.

2단계: VkInstance

소스: instance.h · instance.cpp

VkInstance는 Vulkan API의 진입점입니다. 이를 생성하면 시스템에서 Vulkan 런타임이 초기화됩니다. 이 없이는 어떤 Vulkan 관련 작업도 일어날 수 없습니다.

웹 개발 출신이라면, 브라우저를 여는 행위라고 생각해보세요. 브라우저 자체는 아직 어떤 웹 페이지도 보여주지 않지만, 이제 렌더링 엔진에 접근할 수 있게 된 거죠. VkInstance가 바로 그런 역할입니다. 시스템에 "나 Vulkan을 사용하고 싶어"라고 말하는 것과 같습니다.

헤더 파일을 살펴보겠습니다.

#pragma once

#include <vulkan/vulkan.h>

struct Instance
{
    VkInstance instance;

    Instance();
    void Destroy();
};

C++가 처음인 분들을 위해 몇 가지 설명할 부분이 있습니다.

struct — 타입스크립트 출신이라면 class를 기대했을 겁니다. C++에는 structclass 둘 다 있으며, 거의 동일합니다. 유일한 차이점은 기본 접근 지정자(default visibility)입니다. struct에서는 모든 멤버가 기본적으로 public이고, class에서는 private입니다. 우리 엔진에서는 모든 멤버가 public이므로, class를 쓰고 public:을 추가하여 기본값을 되돌릴 이유가 없어 struct를 사용합니다. C++ 코드베이스에서는 두 가지 컨벤션 모두 볼 수 있을 텐데, 이는 스타일 선택이지 기능적인 차이는 없습니다.

**#pragma once**는 전처리기 지시자(preprocessor directive)입니다. 컴파일러에게 "여러 파일이 이 파일을 포함하려고 해도 딱 한 번만 포함하라"고 지시하는 것이죠. 이것이 없으면 중복 정의 오류가 발생할 수 있습니다. 자바스크립트에서 동일한 모듈을 두 번 import하지 않도록 하는 것과 개념적으로 같지만, C++에서는 컴파일러가 이를 자동으로 처리해주지 않습니다. C++ 버전 import 중복 방지 장치라고 할 수 있겠네요.

**#include <vulkan/vulkan.h>**는 Vulkan API 선언들을 가져옵니다. 이것은 개념적으로 자바스크립트의 import vulkan from 'vulkan'과 동일합니다. VkInstance와 같은 타입이 존재하며 어떤 모습인지 컴파일러에게 알려줍니다.

인스턴스를 생성할 때 세 가지 일이 일어납니다.

  1. VkApplicationInfo로 애플리케이션 정보를 기술합니다. 앱 이름, Vulkan API 버전과 같은 메타데이터죠. HTTP의 User-Agent 헤더를 떠올려 보세요.
  2. GLFW에게 필요한 확장을 요청합니다. 1단계에서 논의했듯이, GLFW는 여러분의 플랫폼에서 창에 표시하기 위해 어떤 Vulkan 확장이 필요한지 알고 있습니다. macOS에서는 앞에서 언급했던 MoltenVK 이식성 확장(portability extensions)이 여기에 포함됩니다.
  3. 유효성 검사 계층(validation layers)을 활성화합니다. (디버그 빌드에서만) 이는 실행 중인 린터라고 생각하면 편합니다. 모든 Vulkan API 호출을 감시하고, 잘못된 작업을 하고 있다면 경고를 줍니다. 학습에 엄청나게 도움이 됩니다. 제가 직접 경험해 보니, 이 유효성 검사 계층 덕분에 초기 디버깅 시간을 크게 줄일 수 있었습니다. 마치 경험 많은 동료가 옆에서 '야, 그거 그렇게 쓰는 거 아니야!' 하고 실시간으로 피드백을 주는 느낌이랄까요.

3단계: Window

소스: window.h · window.cpp

창은 운영체제와의 연결 통로입니다. 화면에 픽셀이 나타날 직사각형 영역이죠. 우리는 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에 주목하세요. 이것은 자바스크립트나 타입스크립트에는 없는 개념입니다. 이는 이 메서드가 객체를 변경하지 않을 것이라는 컴파일러에게 보내는 약속입니다. 데이터를 읽기만 하고 쓰지 않는다는 의미죠. const 메서드 안에서 멤버 변수를 실수로 변경하려고 하면 컴파일러가 이를 거부합니다. 읽기 전용 계약이라고 생각하면 됩니다. 타입스크립트의 Readonly<T>와 비슷하지만, 타입 전체가 아닌 메서드 레벨에서 강제됩니다. Destroy()는 상태를 변경(창을 해제)하므로 const가 붙지 않습니다.

여기서 처음으로 **포인터(pointer)**를 보게 됩니다. GLFWwindow* window가 무엇을 의미하는지 이야기해 봅시다.

자바스크립트에서 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.h · surface.cpp

서피스는 여러분의 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**은 전처리기 매크로입니다. 자바스크립트에는 이런 것이 없습니다. 컴파일러가 코드를 보기 전에, **전처리기(preprocessor)**라는 별도의 단계가 코드를 훑어보며 텍스트 치환을 수행합니다. #define GLFW_INCLUDE_VULKAN은 단순히 플래그를 생성할 뿐, 어떤 코드도 직접 생성하지 않습니다. 다음 줄에서 GLFW의 헤더 파일이 포함될 때, 이 플래그의 존재 여부를 확인하고, 존재한다면 Vulkan 타입 선언(예: VkInstanceVkSurfaceKHR)도 함께 가져옵니다. 이것이 없으면 GLFW는 Vulkan 타입을 알지 못할 겁니다. 컴파일 타임 기능 플래그라고 생각하면 됩니다. 환경 변수와 비슷하지만, 런타임이 아닌 컴파일 전에 결정된다는 차이가 있습니다.

서피스는 두 세계 사이에 위치하기 때문에 VkInstanceGLFWwindow* 둘 다 필요로 합니다. GLFW는 플랫폼별 세부 사항을 처리하는 도우미 함수(glfwCreateWindowSurface)를 제공합니다. macOS에서는 Metal 서피스를, Linux에서는 X11 또는 Wayland 서피스를, Windows에서는 Win32 서피스를 생성하죠.

이것이 서피스가 인스턴스와 창이 모두 존재한 후에 생성되는 이유이며, platform/ 디렉터리에 위치하는 이유입니다. Vulkan 타입을 사용함에도 불구하고 근본적으로 OS 레벨의 개념을 래핑하고 있기 때문입니다.

5단계: VkDevice

소스: device.h · device.cpp

장치(device) 단계는 실제로는 두 가지를 의미합니다. 물리적 GPU를 선택하고, 논리적 장치를 생성하는 것이죠.

물리적 장치 선택하기

여러분의 컴퓨터에는 여러 GPU가 있을 수 있습니다(예: 통합 GPU와 개별 GPU). 우리는 우리가 필요한 작업을 수행할 수 있는 GPU를 하나 선택해야 합니다. 선택 과정은 다음과 같습니다.

  1. 시스템의 모든 GPU를 열거합니다.
  2. 각 GPU의 **큐 패밀리(queue families)**를 확인합니다. 이는 다양한 작업(그래픽스, 컴퓨트, 전송, 프리젠테이션)을 수행할 수 있는 "작업자" 그룹입니다.
  3. 그래픽스 큐 패밀리(그림을 그릴 수 있는)와 프리젠트 큐 패밀리(우리의 서피스에 결과를 표시할 수 있는)를 모두 가진 GPU를 찾습니다.
  4. GPU가 **스왑체인 확장(swapchain extension)**을 지원하는지 확인합니다. (렌더링된 프레임을 화면에 표시하는 데 필요합니다.)
  5. 모든 검사를 통과하는 첫 번째 GPU를 선택합니다.

웹 개발 출신이라면, 큐 패밀리를 서로 다른 스레드 풀이라고 생각할 수 있습니다. 한 풀은 그래픽스 작업을 처리하고, 다른 풀은 화면에 결과를 표시하는 작업을 처리하죠. 종종 같은 풀이기도 하지만, API는 이를 확인하도록 강제합니다.

논리적 장치 생성하기

GPU를 선택했다면, **논리적 장치(logical device)**를 생성합니다. 이는 GPU에 대한 우리 애플리케이션의 핸들입니다. 이 구분은 중요합니다. VkPhysicalDevice는 실제 하드웨어를 나타내고, VkDevice는 그 하드웨어에 대한 우리 애플리케이션의 연결을 나타냅니다. 여러 애플리케이션이 동일한 VkPhysicalDevice를 가리키는 각자의 VkDevice를 가질 수 있습니다.

논리적 장치를 생성할 때 다음을 요청합니다.

  • 우리가 식별한 패밀리에서 가져올 — 여기에 그리기 명령을 보낼 것입니다.
  • 확장 기능 — 특히 VK_KHR_swapchain을 요청하여 프레임을 화면에 표시할 수 있도록 합니다.

생성 후에는 큐 핸들을 검색합니다. 이 핸들은 나중에 렌더링 명령을 제출하고 이미지를 화면에 표시하는 데 사용될 것입니다.

셰이더(Shader)란 무엇인가요? 셰이더는 GPU에서 실행되는 작은 프로그램입니다. 이 프로그램들은 2부에서 작성할 예정입니다. 지금은 그저 GPU가 셰이더 프로그램을 실행하여 정점(vertices)이 어디에 나타날지, 각 픽셀의 색상이 무엇이 될지를 결정한다는 정도만 알아두시면 됩니다.

6단계: Swapchain

소스: swapchain.h · swapchain.cpp

프레임(Frame)이란 무엇인가요? 프레임은 화면에 표시되는 하나의 완성된 이미지입니다. 영화는 초당 24프레임으로 재생됩니다. 24장의 정지 이미지가 너무 빠르게 지나가면서 움직이는 것처럼 보이는 거죠. 게임도 마찬가지로 일반적으로 초당 60프레임으로 작동합니다. 각 프레임은 GPU에 의해 처음부터 그려지고, 잠시 표시된 다음, 다음 프레임으로 교체됩니다.

스왑체인은 화면에 번갈아 표시되는 이미지들의 큐입니다. <canvas> 게임의 더블 버퍼링이나 트리플 버퍼링과 흡사합니다.

오프스크린 캔버스가 2~3개 있다고 상상해보세요. 브라우저가 하나를 표시하는 동안, 여러분은 다른 하나에 그림을 그립니다. 그리기를 마치면 이들을 바꿉니다. 새로 그려진 캔버스가 화면에 표시되고, 이전에 표시되던 캔버스는 다음 프레임을 위해 자유로워집니다. 스왑체인이 하는 일이 바로 이것입니다.

이미지 A: [화면에 표시 중]
이미지 B: [GPU가 다음 프레임을 여기에 그리고 있음]
이미지 C: [대기 중, GPU가 다음에 사용할 준비 완료]
         ↓
         스왑 → 이미지 B가 화면으로 이동, 이미지 A는 이제 자유

이것이 없으면 사용자는 반쯤 완성된 프레임을 보게 될 것입니다. 이를 **티어링(tearing)**이라는 시각적 결함이라고 합니다.

티어링(Tearing)이란 무엇인가요? 티어링은 화면 상단 절반이 한 프레임을 표시하고 하단 절반이 다음 프레임을 표시하는 현상입니다. 디스플레이가 그림을 그리는 도중에 새로 고쳐지기 때문에 발생하죠. GPU가 모니터가 현재 읽고 있는 이미지에 쓰기 작업을 할 때 발생합니다. 스왑체인은 표시되는 이미지와 작업 중인 이미지를 분리하여 이를 방지합니다.

스왑체인을 생성하는 것은 서피스가 지원하는 것을 쿼리하고 최상의 옵션을 선택하는 과정을 포함합니다.

  • 포맷(Format) — 픽셀 포맷과 색 공간입니다. 우리는 B8G8R8A8_SRGB (sRGB가 포함된 BGRA 8비트)를 선호합니다. 이는 모니터의 표준 포맷이며, sRGB는 색상이 올바르게 보이도록 보장합니다.

  • 프리젠트 모드(Present mode) — 스와핑이 어떻게 작동하는지입니다. MAILBOX는 트리플 버퍼링(낮은 지연 시간, GPU가 큐에 있는 프레임을 교체)입니다. FIFO는 vsync( requestAnimationFrame과 유사하며, 사용 가능성이 보장되고 부드럽지만 지연 시간이 더 김)입니다. 우리는 메일박스를 선호하고, FIFO로 대체합니다.

vsync(수직 동기화)란 무엇인가요? Vsync(vertical sync)는 프레임 속도를 모니터의 주사율(일반적으로 60Hz)에 고정시키는 기능입니다. 모니터가 한 프레임을 표시하는 것을 마칠 때까지 기다렸다가 다음 프레임을 스왑인(swap in)하여 티어링을 방지합니다. 브라우저의 requestAnimationFrame이 사실상 Vsync와 같은 원리입니다. 브라우저는 디스플레이 새로 고침당 한 번 콜백을 호출합니다.

  • 범위(Extent) — 해상도이며, 일반적으로 창 크기와 일치합니다.

스왑체인을 생성한 후에는 이미지 핸들(드라이버가 생성했고, 우리는 단지 참조만 얻습니다)을 얻고 각 이미지에 대한 **이미지 뷰(image views)**를 생성합니다. 이미지 뷰는 Vulkan에게 이미지를 어떻게 해석해야 하는지 알려주는 "렌즈"입니다. 포맷, 2D 이미지라는 것, 그리고 색상 채널에 관심이 있다는 것 등이죠.

모든 것을 하나로 묶기: Application

소스: application.h · application.cpp

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));
}

여기에는 자바스크립트에는 없는 두 가지 문법이 있습니다.

멤버 초기화 목록(Member initializer list) — 생성자 시그니처 뒤에 오는 : window(width, height, title), renderer(), surface(...) 부분입니다. 자바스크립트에서는 생성자 본문 안에서 this.window = new Window(width, height, title)라고 작성할 겁니다. C++에는 메모리가 작동하는 방식 때문에 이와는 다른 별도의 문법이 있습니다. Application 객체가 생성될 때, 모든 멤버 변수는 생성자 본문이 실행되기 전에 생성되어야 합니다. 초기화 목록은 컴파일러에게 각 멤버를 어떻게 생성할지 알려주는 곳입니다. 만약 초기화 목록을 건너뛰고 본문 안에서 할당한다면, 각 멤버는 먼저 기본 생성자(default-constructed)에 의해 생성되고(불필요한 작업일 수 있음) 그 후에 다시 할당됩니다. 초기화 목록은 이러한 이중 초기화를 방지합니다.

static_cast<uint32_t>(width) — 명시적 타입 변환입니다. 자바스크립트에서 숫자는 그저 숫자입니다. 단일 number 타입이죠. C++에서는 intuint32_t(부호 없는 32비트 정수)는 다른 타입입니다. 컴파일러는 이들 사이의 암시적 변환에 대해 경고를 줄 겁니다. 왜냐하면 부호 있는 타입에서 부호 없는 타입으로 변환할 때 정보 손실(음수 값이 순환됨)이 발생할 수 있기 때문입니다. static_cast는 "이 타입들이 다르다는 것을 알고 있으며, 의도적으로 변환하는 중이다"라고 말하는 것입니다. 타입스크립트에서 width as number라고 명시적으로 주석을 다는 것과 비슷하죠. 의도를 명확히 하는 것입니다.

각 단계는 이전 단계에 의존하므로, 생성 순서가 중요합니다.

순서컴포넌트의존성
1Window없음
2Instance없음
3SurfaceInstance + Window
4DeviceInstance + Surface
5SwapchainDevice + Surface

해체(destruction)는 역순입니다. 생성의 반대 순서로 항상 해체해야 합니다. 그래야 이미 사라진 것을 사용하려는 시도를 막을 수 있습니다.

void Application::Shutdown()
{
    surface.Destroy(renderer.instance.instance);
    renderer.Destroy();  // swapchain → device → instance
    window.Destroy();
}

자바스크립트 출신이라면 "왜 Destroy()를 호출해야 하나요?"라고 궁금해할 수 있습니다. JS에서는 단순히 객체에 대한 참조를 끊으면 가비지 컬렉터가 결국 메모리를 해제합니다. C++에는 가비지 컬렉터가 없습니다. Vulkan 장치, 창, GPU 메모리 블록과 같은 리소스를 할당하면, 명시적으로 해제할 때까지 계속 할당된 상태로 남아 있습니다. 이를 잊어버리면 리소스 누수(leak)가 발생합니다. 프로그램 관점에서는 리소스가 사라졌지만, 프로세스가 종료될 때까지 OS나 GPU 드라이버에 의해 여전히 점유되어 있는 거죠. 이 엔진에서 보는 모든 Destroy() 메서드는 자바스크립트에서 가비지 컬렉터가 여러분을 위해 해줄 일을 하는 것입니다. 하지만 수동으로, 그리고 특정 순서에 따라 처리합니다. 이 리소스들이 서로 의존하기 때문입니다.

게임 루프는 현재로서는 간단합니다.

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-19 01:59:52