- OpenGL 경험이 있던 개발자가 Vulkan을 처음 배워 약 3개월 만에 두 개의 작은 게임 데모와 재사용 가능한 EDBR 엔진을 만들었음
- 처음부터 범용 엔진을 설계하지 않고 작은 게임을 먼저 만든 뒤 필요한 부분만 엔진화해 과도한 설계와 bike-shedding을 줄임
- 엔진은 19k LoC 규모이며 그래픽 코드 6.7k LoC, Vulkan 경량 추상화 2k LoC로 구성되고 컴퓨트 스키닝, CSM, PBR 셰이딩, MSAA, 포스트 FX, UI 렌더링을 포함함
vk-bootstrap, Vulkan Memory Allocator,volk,VK_KHR_dynamic_rendering, 푸시 상수, 버퍼 디바이스 주소, bindless descriptor로 Vulkan 보일러플레이트와 descriptor set 사용을 줄임- Vulkan은 전역 상태 제거, 더 나은 검증 오류, RenderDoc 셰이더 디버깅, GPU·OS 간 일관성을 제공했지만 명시적 동기화는 여전히 직접 다뤄야 함
3개월 동안 만든 Vulkan 기반 EDBR 엔진
- EDBR(Elias Daler’s Bikeshed Engine)는 Vulkan 학습 프로젝트에서 시작해 이후 프로젝트에 재사용할 수 있는 작은 엔진으로 발전함
- 엔진과 게임 코드는 GitHub 저장소에 공개되어 있음
- 작성 시점 코드 규모는 다음과 같음
- 엔진 자체: 19k LoC
- 그래픽 관련 코드: 6.7k LoC
- Vulkan 경량 추상화: 2k LoC
- 3D 고양이 게임: 4.6k LoC
- 2D 플랫폼 게임: 1.2k LoC
- 엔진 자체: 19k LoC
- 입력 처리와 오디오 시스템 등 일부 비그래픽 코드는 이전 엔진에서 가져왔고, 그래픽과 여러 핵심 시스템은 새로 작성함
- Vulkan을 기존 OpenGL 추상화에 끼워 넣기보다 새로 작성하는 편이 더 적합했다고 평가함
그래픽 프로그래밍에서 Vulkan까지의 학습 순서
- 그래픽 프로그래밍을 처음 시작한다면 Vulkan보다 OpenGL로 시작하는 편이 복잡도에 압도되지 않기 쉬움
- 최소 목표로는 텍스처가 적용된 모델을 화면에 표시하고 간단한 Blinn-Phong 조명을 구현하는 수준을 권함
- 기본 shadow mapping은 장면을 다른 시점과 다른 렌더 타깃으로 렌더링하고, depth texture를 샘플링하는 방법을 익히는 데 도움 됨
- OpenGL 학습 자료로 다음을 추천함
- Vulkan 학습에는 vkguide가 가장 도움이 되었고, 처음 시작한다면 전체를 따라가되 간단한 게임에는 “GPU driven rendering” 수준의 복잡도가 당장 필요하지 않을 수 있음
- Vulkan Lecture Series by TU Wien는 Vulkan 기본기를 다루며, 특히 동기화 강의가 도움이 됨
- 첫 달 학습 결과로 다음 기능을 구현함
- glTF 모델 로딩
- 컴퓨트 스키닝
- frustum culling
- shadow mapping과 cascaded shadow maps
Vulkan을 선택한 이유와 WebGPU와의 비교
- 목표는 Windows와 Linux 중심의 데스크톱용 작은 3D 게임이었고, 오픈소스 기술과 개방 표준을 선호해 OpenGL과 Vulkan 사이에서 선택함
- OpenGL은 작은 게임에는 충분하지만 새 버전이 나올 가능성이 낮고, macOS에서는 deprecated 상태라 미래가 불확실하다고 봄
- WebGPU도 일부 학습했지만 다음 제약이 있었음
- 아직 안정적이지 않고 튜토리얼과 예제가 많지 않음
- WGSL 문법이 GLSL보다 덜 마음에 들었음
- 데스크톱에서는 DirectX, Vulkan, Metal 위의 래퍼에 가까워 플랫폼별 RenderDoc 캡처가 달라지고 WebGPU 호출과 네이티브 API 호출이 1:1로 대응하지 않음
- bindless textures와 push constants가 없음
- WebGPU에는 분명한 장점도 있음
- OpenGL/WebGL보다 검증 오류가 좋고 전역 상태가 없음
- Vulkan과 비슷한 부분이 있어 Vulkan 학습 전에 도움이 됨
- Vulkan보다 화면에 무언가를 띄우는 데 필요한 보일러플레이트가 적음
- 명시적 동기화를 직접 다루지 않아도 됨
- 브라우저에서 게임을 실행할 수 있음
프레임 렌더링 흐름
- 한 프레임은 여러 단계로 나뉘며, 각 단계는 파이프라인 또는 패스 형태로 구현됨
- 스키닝 단계에서는 skeletal animation이 있는 모델을 compute shader로 처리함
- 입력은 스키닝되지 않은 mesh와 joint matrices
- 출력은 이후 렌더링 단계에서 사용할 vertex buffer
- 이후 단계에서는 정적 mesh와 skinned mesh를 비슷하게 다룰 수 있음
- CSM 단계에서는 4096x4096 depth texture와 3개 slice를 사용해 cascaded shadow mapping을 수행함
- geometry + shading 단계에서는 모델을 그리고 shadow map과 light 정보를 사용해 셰이딩함
- PBR 모델은 Physically Based Rendering in Filament에 설명된 방식과 거의 같음
- fragment shader는 한 draw call에서 해당 mesh에 영향을 주는 모든 light 계산을 수행함
- 모든 것은 multi-sampled texture에 그려진 뒤 resolve됨
- depth resolve는 fragment shader로 수동 처리함
- multi-sample depth texture의 모든 fragment를 순회해 최소값을 non-MS depth texture에 기록함
- post FX 단계는 현재 depth fog만 적용하며, 이후 tone mapping과 bloom도 이 단계에서 처리할 계획임
- UI 단계에서는 dialogue UI를 그리고 한 draw call로 처리함
Vulkan 보일러플레이트를 줄이는 라이브러리
vk-bootstrap은 physical device 선택, swapchain 생성 등 Vulkan 초기화 보일러플레이트를 줄임- 전체 Vulkan 함수 래퍼가 아니라 초기화 단계에 주로 영향을 줌
- Vulkan Memory Allocator는 Vulkan 메모리 할당을 직접 다루지 않아도 되게 해 줌
volk는 extension function loading을 단순화함- 예를 들어
vkSetDebugUtilsObjectNameEXT같은 extension 함수를 직접 포인터로 저장하지 않고 사용할 수 있음
- 예를 들어
GfxDevice클래스는 자주 쓰는 Vulkan 기능과 객체를 묶음- Vulkan context 초기화
- swapchain 생성과 관리
beginFrame과endFrame- image 생성과 texture 로딩
- buffer 생성
- bindless descriptor set 관리
GfxDevice.cpp는 작성 시점 714줄이며,VkDevice,VkQueue,VmaAllocator등을 여러 곳에 전달하는 대신 하나의 객체를 넘기는 방식이 편리함
셰이더 빌드와 descriptor set 회피 전략
- 셰이더 언어는 OpenGL 경험 때문에 GLSL을 선택함
- 셰이더는 런타임이 아니라 빌드 단계에서 SPIR-V로 컴파일함
- 런타임 shader 로딩 코드가 단순해짐
- 런타임 shader compiler 의존성이 없음
- shader 오류를 빌드 단계에서 발견함
glslc는 CMake의DEPFILE을 지정할 수 있어 shader include 변경 시 관련 파일을 자동 재컴파일할 수 있음- Vulkan에서는 uniform을 descriptor set으로 묶어야 해서 OpenGL보다 데이터 전달이 복잡해짐
- 구현에서는 descriptor set 사용을 크게 줄임
- bindless texture와 sampler를 위한 전역 descriptor set 하나만 사용함
- 나머지는 대부분 push constants로 전달함
- buffer device address를 사용해 buffer 주소를 push constants로 넘김
pipeline class와 dynamic rendering
- 렌더링 단계는
PostFXPipeline같은 pipeline class로 분리함 - 각 pipeline은 보통 다음 역할을 가짐
init: shader 로딩,VkPipeline,VkPipelineLayout초기화cleanup: pipeline과 layout 정리draw: 매 프레임 필요한 입력을 받아 draw call 수행
draw는vkCmdBeginRendering과vkCmdEndRendering사이에서 호출된다고 가정함- render pass가 어떤 texture에 렌더링하는지는 pipeline 내부에서 신경 쓰지 않고, 호출자가 render target을 결정함
VK_KHR_dynamic_rendering을 전체적으로 사용하고 Vulkan render pass와 subpass는 쓰지 않음- tile-based GPU에서는 render pass와 subpass가 더 효율적이라는 이야기를 들었지만, 현재 모바일 지원은 고려하지 않음
- dynamic rendering이 구현을 훨씬 쉽게 만듦
PVP, BDA, bindless descriptor 활용
- 모든 mesh에 하나의 vertex 타입을 사용함
- programmable vertex pulling을 사용하면 OpenGL의 VAO나 Vulkan의
VkVertexInputBindingDescription,VkVertexInputAttributeDescription같은 vertex format 정의를 피할 수 있음 - buffer device address를 사용하면 vertex buffer를 descriptor set에 바인딩하지 않고 buffer 주소를 push constants로 전달할 수 있음
- push constants와 buffer에는
scalarlayout을 사용함std430보다 정렬 처리가 쉬워 C++ 구조체와 거의 비슷하게 다룰 수 있음- C++ 구조체의 padding 멤버 필요성이 줄어듦
- bindless descriptor는 큰 descriptor set에 texture와 sampler 배열을 두는 방식으로 사용함
- 새 texture가 로드되면
textures배열에 넣고 해당 index를 bindless texture id로 사용함 - shader에는 texture id를 push constants로 전달함
- 새 texture가 로드되면
- sampler는 image와 분리해 common sampler를 startup 때 만들고
samplers배열에 넣음 - material buffer에도 bindless texture id를 사용함
- material ID만 push constants로 전달하고 fragment shader에서 material buffer를 조회함
- bulky descriptor set 없이 material당 정수 하나로 texture 접근이 가능함
- bindless texture 관련 참고 자료로 Vulkan Bindless Texture를 추천함
매 프레임 업로드되는 동적 데이터
- 매 프레임 CPU에서 GPU로 넣어야 하는 데이터는 큰 배열을 미리 할당하고 매 프레임 index 0부터 채우는 방식을 사용함
- 예로 모든 joint matrix를 하나의 큰
mat4배열에 저장하고, skinned mesh마다 시작 index를 push constants로 전달함 - 두 가지 방식이 있음
- GPU에 N개 buffer를 두고 frame-in-flight마다 교체함
- GPU에는 하나의 buffer만 두고 CPU 측 staging buffer를 N개 둠
- 대부분의 경우 첫 번째 방식이 권장됨
- GPU 메모리를 더 쓰지만 수동 동기화가 필요 없음
- GPU 메모리를 아껴야 한다면 두 번째 방식이 유용할 수 있음
- 두 방식 사이의 눈에 띄는 성능 차이는 발견하지 못했지만, 매 프레임 매우 큰 데이터를 업로드하면 차이가 생길 수 있음
cleanup과 동기화
- vkguide의 deletion queue 패턴은 작성자의 엔진에서는 큰 효용이 없었음
- 매 프레임 새 Vulkan 객체를 할당하거나 파괴하지 않기 때문임
- C++ destructor 기반 cleanup도 편하지 않았음
- wrapper class, move constructor, move assignment가 필요해 복잡도가 늘어남
- mid-frame에 사용 중인 객체를 wrapper 파괴로 실수로 제거할 위험이 있음
- 현재는
cleanup함수를 명시적으로 호출해 Vulkan 객체를 한곳에서 정리함- 호출을 잊기 쉽지만, 종료 시 Vulkan validation error와 VMA assert가 누락된 cleanup을 알려줌
- Vulkan synchronization은 어렵고 명시적으로 관리해야 함
- OpenGL과 WebGPU는 texture나 buffer 읽기에 필요한 동기화를 대신 처리함
- Vulkan에서는 data race를 피하기 위해 barrier를 직접 넣어야 함
- 현재는 draw를 pass와 pipeline으로 나누고 그 사이에 barrier를 수동 삽입함
- 예를 들어 compute shader의 skinning pass가 vertex data를 쓴 뒤 shadow mapping pass가 이를 읽기 전에 barrier를 넣음
- render graph로 자동화할 수 있지만 현재는 수동 동기화에 만족함
vkconfig의 synchronization validation layer가 동기화 오류를 찾는 데 도움 됨
sprite, skinning, game/renderer 분리
- bindless texture를 사용하면 많은 sprite를 vertex buffer 없이 한 draw call로 그리기 쉬움
- sprite vertex shader는
gl_VertexIndex로 quad의 vertex coordinate와 UV를 생성함 - 모든 sprite draw call은
SpriteDrawBuffer에SpriteDrawCommand로 모아짐- transform
- UV 범위
- color
- texture ID
- shader ID
- 실제 draw call은
vkCmdDraw(cmd, 6, spriteDrawCommands.size(), 0, 0)형태임- sprite당 6개 vertex
- sprite 수만큼 instance
- sprite renderer는 1만 개 sprite를 315마이크로초에 그릴 수 있음
- 컴퓨트 스키닝은 skeletal animation이 있는 mesh에서 입력 vertex와 joint matrices를 받아 skinned vertex buffer를 생성함
- 동일한 mesh를 가진 고양이 3마리도 서로 다른 animation을 가질 수 있음
- output vertex buffer는 mesh instance마다 필요함
- game logic과 renderer는 draw command로 분리됨
- game logic은 entt를 사용함
- renderer는 entity나 game object를 알지 않고 light, scene parameter, mesh draw command만 다룸
MeshDrawCommand에는meshId, transform matrix, bounding sphere, skinned mesh 포인터, joint matrix 시작 index, shadow casting 여부가 포함됨
scene loading, UI, Dear ImGui
- level editor는 직접 만들지 않고 Blender를 사용하고 glTF로 export함
- level editor를 직접 작성하면 몇 달 또는 몇 년이 걸릴 수 있어 시간을 절약함
- node 이름으로 prefab 생성과 physics shape를 지정함
- 예:
Interact.Sphere.Diary에서 첫 점 앞의Interact는 prefab 이름 Sphere는 physics system에서 sphere physics body를 만들 때 사용함Capsule,Box도 사용할 수 있고, 없으면 mesh vertex로 physics shape를 생성함
- 예:
- 복잡한 model은 level glTF에 직접 넣지 않고
Empty->Arrowsobject로 배치한 뒤Cat.NearStore같은 이름을 붙임Catprefab을 생성하고NearStoretag를 붙임
- prefab은 JSON으로 작성하며 외부 glTF와 movement, physics 정보를 포함할 수 있음
- UI 시스템은 Roblox UI API에서 영감을 받음
- origin
- relative size
- relative position
offsetPosition,offsetSize- fixed size
- label/image content 기반 size
- UI element size를 재귀적으로 계산한 뒤 position을 계산하고, parent부터 children 순서로 그림
- Dear ImGui는 개발·디버그 도구에 사용함
- sRGB framebuffer에서 Dear ImGui가 잘못 보이는 문제가 있어 자체 Dear ImGui backend를 작성함
- rendering 부분만 작성하고, input event 처리와 clipboard 등 logic/OS interaction은 기본 Dear ImGui SDL backend가 처리함
- 자체 backend의 장점은 다음과 같음
- bindless texture id를 지원해
ImGui::Image(bindlessTextureId, ...)로 image를 그릴 수 있음 - linear image와 non-linear image를 format 전달로 올바르게 그릴 수 있음
- 엔진의 다른 Vulkan 코드와 같은 방식으로 초기화하고 다룰 수 있음
- bindless texture id를 지원해
사용한 라이브러리와 Vulkan 전환 효과
- 물리는 Jolt Physics를 사용함
- collision resolution과 basic character movement에 주로 사용함
JPH::CharacterVirtual이 기본 character movement를 잘 처리함
- ECS는 entt를 사용함
- 직접 만든 ECS 대신 외부 라이브러리를 사용해 유지할 코드를 줄임
- 오디오는 openal-soft, libogg, libvorbis를 사용함
- profiling은 Tracy를 사용함
- 어떤 코드가 실제로 얼마나 적은 시간을 쓰는지 확인해 불필요한 bike-shedding을 피하는 데 도움 됨
- Vulkan으로 전환해 얻은 점은 다음과 같음
- OpenGL의 전역 상태가 없어져 추상화가 쉬워짐
- OpenGL식
shader.bind()나 state tracker, magic RAII가 덜 필요함 - validation error가 OpenGL보다 더 풍부함
- RenderDoc에서 vertex shader와 fragment shader를 직접 디버깅할 수 있음
- GPU와 OS 간 동작 차이가 OpenGL보다 덜 두드러짐
- 향후 Slang, Shady 같은 다른 shading language를 탐색할 수 있음
- 그래픽 파이프라인의 각 측면을 더 많이 제어할 수 있음
앞으로 할 작업
- 앞으로 계획한 작업은 다음과 같음
- sign-distance field font 지원
- 많은 image 로딩과 mipmap 병렬 생성
- bloom
- volumetric fog
- animation blending
- render graph
- ambient occlusion
- 게임 완성
- Vulkan 학습은 어려웠지만 예상만큼 어렵지는 않았고, 그래픽 프로그래밍과 modern API를 더 깊게 이해하는 계기가 됨