Vulkan 오브젝트 이해하기
02 Feb 2022취미로 프로그래밍을 할 수 있을 정도로 회복된 요즘, 다시 Vulkan 튜토리얼을 보면서 복습 중입니다. 예제를 돌려보면서 어떤 식으로 내가 원하는 형태로 추상화를 할 수 있을까 고민하다가, Vulkan 내 오브젝트 간의 의존성이 어떻게 되는지를 정리를 해보는데, ‘그런 일을 누군가는 이미 하지 않았을까’ 싶어서 검색을 좀 했더니 좋은 글이 나오더군요. 공부하는 차원에서 오랜만에 번역을 해봤습니다.
원문 보기: https://gpuopen.com/learn/understanding-vulkan-objects
Vulkan 오브젝트 이해하기
Vulkan API를 배우는 중요한 부분은 (다른 API와 마찬가지로) 어떤 종류의 오브젝트가 정의되어 있는지, 이들이 무엇을 나타내고 서로 어떻게 연관되어 있는지 이해하는 것입니다. 이를 돕기 위해서 우리는 모든 Vulkan 오브젝트들과 이들의 관계성, 특히 어떤 오브젝트로부터 다른 오브젝트를 만드는지에 대한 순서를 보여주는 다이어그램을 만들었습니다.
각각의 Vulkan 오브젝트는 Vk
라는 앞글자가 붙은 특정 타입의 값입니다. 이 앞글자는
다이어그램의 간소화를 위해 생략하였으며 이는 함수 이름 앞에 붙은 vk
도 마찬가지입니다.
예를 들면 다이어그램의 Sampler는 VkSampler
라는 Vulkan 오브젝트 타입이 있다는 의미입니다.
이러한 타입들은 포인터나 보통의 숫자처럼 취급되어서는 안됩니다. 이 값들을 어떤 방식으로든
해석하려고 하면 안됩니다. 그저 이들을 명확한 핸들로서 취급하고, 함수의 파라미터로 넘기고,
당연하겠지만 필요 없어질 경우 제거하는 것만 잊지 않으면 됩니다. 녹색 바탕의 오브젝트들은
자체 타입이 없고, 대신 부모 오브젝트 내의 uint32_t
타입 정수형 인덱스 값으로 표현됩니다.
QueryPool의 Query 같은 것들이 그렇습니다.
화살표가 붙은 실선은 생성 순서를 나타냅니다. 예를 들어 DescriptorSet을 만들기 위해서는 이미 만들어진 DescriptorPool을 지정해야 합니다. 다이아몬드가 붙은 실선은 구성(composition)을 나타내는데, 이는 그 오브젝트가 부모 오브젝트에 이미 존재하므로 별도로 만들 필요가 없이 그냥 가져올 수 있다는 뜻입니다. 예를 들면 Instance 오브젝트로부터 PhysicalDevice 오브젝트들을 열거할 수 있습니다. 점선은 그 밖의 관계를 의미합니다. CommandBuffer에 대한 다양한 명령들을 제출하는 것이 그렇습니다.
이 다이어그램은 크게 세 부분으로 나뉘어져 있습니다. 각 부분은 빨간색으로 표시된 메인 오브젝트를
가지고 있습니다. 다른 모든 오브젝트들은 이 메인 오브젝트로부터 직접적으로 혹은 간접적으로
생성됩니다. 예를 들면 Sampler를 생성하는 vkCreateSampler
함수는 VkDevice
를 첫 번째
파라미터로 받습니다. 간소화를 위해서 메인 오브젝트와의 관계성은 다이어그램에 그리지 않았습니다.
모든 오브젝트들에 대하여 간략한 설명을 해보겠습니다:
- Instance는 첫번째로 만들게 되는 오브젝트입니다. 여러분의 어플리케이션과 vulkan 런타임 간의 연결을 나타내므로 어플리케이션 당 오직 하나만 있어야 합니다. 또한 Instance는 vulkan 사용에 필요한 어플리케이션용 상태값을 모두 저장하므로, Instance를 생성할 때는 여러분에게 필요한 모든 layer와 extension을 지정해야 합니다.
- PhysicalDevice는 그래픽 카드 같이 vulkan을 사용할 수 있는 특정 장치를 나타냅니다.
Instance로부터 이들을 열거한 뒤에 venderID, deviceID, 그리고 지원하는 기능이나
속성, 한계값 등을 질의할 수 있습니다.
- PhysicalDevice는 사용가능한 QueueFamily들을 열거할 수 있습니다. Graphics queue를 주로 사용하겠지만, Compute나 Transfer 용도만 제공하는 추가 큐를 사용할 수도 있습니다
- PhysicalDevice는 또한 장치 내 Memory Heap과 Memory Type을 열거할 수 있습니다. Memory Heap은 RAM의 특정한 풀(pool)을 나타냅니다. 이는 여러분의 메인보드 상에 있는 시스템 RAM이나, 특정 그래픽 카드 상에 있는 비디오 RAM의 특정 메모리 공간, 혹은 구현체가 노출하는 그밖의 호스트/디바이스에 특화된 메모리를 추상화합니다. 메모리를 할당할 때는 Memory Type을 지정해야 합니다. 호스트에서 접근 되어야 한다던지, CPU/GPU 상의 값이 일치해야 한다던지, 혹은 캐시가 되어야 한다던지 등 할당할 메모리에 대한 요구사항을 지정합니다. 디바이스 드라이버에 따라서 이 타입들 간의 임의 조합이 있을 수 있습니다.
- Device는 논리적 장치 혹은 열린(opened) 장치로 생각할 수 있습니다. 다른 모든 오브젝트들을 만들 준비가 되어 있는, 초기화된 vulkan 장치를 나타내는 메인 오브젝트입니다. DirectX의 Device 오브젝트와 비슷한 개념입니다. Device를 만드는 동안에는 활성화하고 싶은 특성을 지정할 필요가 있는데, 이중에는 anisotropic texture filtering 같은 기본적인 것도 있습니다. 또한 사용하고자 하는 모든 큐의 개수와 각 큐의 QueueFamily들을 기술해줘야 합니다.
- Queue는 장치에서 실행될 명령들의 큐를 나타내는 오브젝트입니다.
GPU에서 수행되는 모든 실질적인 작업들은 CommandBuffer를 채우고
vkQueueSubmit
함수를 이용하여 Queue에 제출하는 방식으로 요청됩니다. Graphics queue와 Compute queue 등 여러개의 큐가 있다면, 서로 다른 CommandBuffer를 각각에게 제출할 수 있습니다. 이러한 방식을 올바르게 사용한다면 상당한 성능 향상을 끌어낼 수 있는 비동기 컴퓨팅이 가능해집니다. - CommandPool은 CommandBuffer를 할당하기 위해 사용되는 단순한 오브젝트입니다. 특정한 QueueFamily에 연결되어 있습니다.
- CommandBuffer는 특정한 CommandPool로부터 할당됩니다. 이것은 논리적 장치에서 수행될
다양한 명령들을 담을 버퍼를 나타냅니다. CommandBuffer에 대한 다양한 함수들을 호출할 수 있는데,
이 함수들은 모두
vkCmd
로 시작합니다. 이 명령들은 CommandBuffer가 Queue에 제출되고 Device에 의해 처리될 때 수행되어야 할 작업에 대한 순서, 타입, 파라미터를 지정하는데 쓰입니다. - Sampler는 특정한 이미지에 바인딩되지 않고, 필터링 모드 (nearest / linear) 혹은 어드레싱 모드 (repeat / cleamp-to-edge) 같은 단순한 상태값 묶음으로 보면 됩니다.
- 장치 메모리를 점유하는 리소스는 Buffer와 Image 두 가지 종류가 있습니다.
Buffer는 비교적 단순한 편으로, 바이트 단위로 표현된 길이값을 갖는 바이너리 데이터를
위한 컨테이너입니다. 한편 Image는 픽셀 묶음을 표현하는 것으로 다른 그래픽스 API에서는
텍스처라고 알려져 있습니다. 이미지를 생성하는데는 더 많은 파라미터가 필요합니다.
1D, 2D 혹은 3D가 될 수 있고, (
R8G8B8A8_UNORM
이나R32_SFLOAT
같이) 다양한 픽셀 포맷을 가질 수도 있고, 여러 개의 배열 레이어나 MIP 레벨 혹은 둘 다 갖기 위해서 여러 장의 이미지로 구성될 수도 있습니다. 이미지는 직접 접근할 수 있는 픽셀들의 단순한 선형 구조로만 구성되지는 않기 때문에, 별도의 오브젝트로 만들어집니다. 이미지는 그래픽 드라이버에 의해 관리되는 구현 별로 특화된 내부 포맷값 (tiling과 layout 등)을 가질 수 있습니다. - 특정 길이의 버퍼 혹은 특정 차원의 이미지를 생성하는 것이 자동으로 메모리 할당을
해주지는 않습니다. 여러분에 의해 수동으로 수행되어야 하는 3단계 프로세스가 있습니다.
VulkanMemoryAllocator
같은 라이브러리를 사용할 수도 있습니다.
- DeviceMemory를 할당한다
- Buffer 혹은 Image를 생성한다
vkBindBufferMemory
나vkBindImageMemory
함수를 사용하여 둘을 바인딩한다
- DeviceMemory를 생성해야하는 이유가 바로 이 때문입니다. DeviceMemory는 (PhysicalDevice에서 지원하는) 특정한 메모리 타입으로부터 특정 바이트 길이로 할당된 메모리 블럭을 나타냅니다. Buffer와 Image 별로 각각 DeviceMemory를 할당하면 안됩니다. 대신 큰 메모리 덩어리를 할당하여 그 일부를 Buffer와 Image에 대입시켜야 합니다. 할당은 비용이 큰 연산이고, PhysicalDevice로부터 질의해서 알아낼 수 있는 정보 중에는 최대 할당 개수 한계값도 있습니다.
- 모든 이미지에 대해 DeviceMemory를 할당하고 바인딩할 의무가 있는 중에서 하나의 예외가 있다면
그것은 Swapchain의 생성 부분입니다. 이것은 여러분의 운영체제에서 그리고 있는 화면 혹은 윈도우 내에
최종 이미지를 표시하는데 사용되는 개념입니다. Swapchain을 만드는 방법은 플랫폼에 따라 다릅니다.
시스템 API를 사용하여 초기화된 윈도우를 이미 가지고 있다면 먼저 SurfaceKHR 오브젝트를
생성해야 합니다. 이는 Instance 오브젝트 뿐만 아니라 몇가지 시스템 종속적인 파라미터를
필요로 합니다. 예를 들면 Windows에서는
HINSTANCE
와HWND
같은 것들이지요. SurfaceKHR 오브젝트는 윈도우의 Vulkan식 표현이라고 생각할 수 있습니다. - 이로부터 SwapchainKHR를 생성할 수 있습니다. 이 오브젝트는 Device가 필요합니다. 이것은 Surface에 나타나게 될 여러 장의 이미지 (더블 혹은 트리플 버퍼링을 사용하는) 를 나타냅니다. Swapchain이 가지고 있는 Image를 쿼리할 수 있습니다. 이 이미지들은 시스템에 의해 할당된 메모리를 이미 가지고 있습니다.
- Buffer와 Image은 언제나 직접적으로 렌더링에 사용되지는 않습니다. 그 위에 view라고 불리는 또다른 레이어가 있습니다. 데이터베이스의 view와 같은 것으로 생각하면 됩니다. 내부 데이터 셋을 원하는 방식으로 보는데 사용될 수 있는 파라미터 셋인 셈이죠. BufferView는 특정 버퍼를 기반으로 만들어지는 오브젝트입니다. 버퍼 데이터의 일부분만 보도록 제한하기 위해서 오프셋과 범위를 생성 중에 지정할 수 있습니다. 마찬가지로 ImageView도 특정 이미지를 참조하는데 쓰는 파라미터 셋입니다. 여기서는 별도의 (호환되는) 픽셀 포맷, 스위즐링 (swizzling), MIP 레벨이나 배열 레이어의 특정 범위 제한을 통하여 해석된 픽셀값에 접근하도록 할 수 있습니다.
- 쉐이더가 이러한 리소스들 (Buffer, Image 및 Sampler)에 접근하는 방법은 Descriptor를 통하는 것입니다. Descriptor는 단일 개체로 존재하지 않고 언제나 DescriptorSet의 형태로 그룹을 이룹니다. 그러나 DescriptorSet을 생성하기 전에 DescriptorSetLayout을 생성하여 레이아웃을 지정해야 하는데, 이는 DescriptorSet의 탬플릿과 같은 것입니다. 예를 들어 3D 지오메트리를 그리기 위해 렌더링 패스에 사용되는 쉐이더가 다음과 같은 것들을 요구할 수도 있습니다:
Binding slot | Resource |
---|---|
0 | vertex shader stage에서 활용할 유니폼 버퍼 (DirectX의 constant buffer) |
1 | fragment shader stage에서 사용할 유니폼 버퍼 |
2 | 샘플링된 이미지 |
3 | fragment shader stage에서 사용할 샘플러 |
- DescriptorPool도 생성할 필요가 있습니다. 이것은 DescriptorSet을 할당하기 위한 단순한 오브젝트입니다. DescriptorPool을 생성할 때는 여러분들이 할당할 Descriptor의 종류와 최대 갯수를 지정해야 합니다.
- 마지막으로 DescriptorSet을 할당합니다. 이를 할당하려면 DescriptorPool과 DescriptorSetLayout
둘 다 필요합니다. DescriptorSet은 실제 Descriptor가 가지고 있는 메모리를 나타내고, Descriptor가
특정한 Buffer, BufferView, Image, 혹은 Sampler를 가리키도록 설정할 수 있습니다.
vkUpdateDescriptorSet
함수를 이용하면 이런 설정을 할 수 있습니다. - 몇몇 DescriptorSet들은 렌더링 명령에 사용되기 위해 CommandBuffer에 능동적인 셋으로 바인딩 됩니다.
이를 위해는
vkCmdBindDescriptorSets
함수를 사용합니다. 이 함수는 또다른 오브젝트인 PipelineLayout을 필요로 하는데, 그 이유는 여러 개의 DescriptorSet이 바인딩될 수도 있고 Vulkan이 예상해야 하는 DescriptorSet이 몇 개고 어떤 타입인지 미리 알아야 하기 때문입니다. PipelineLayout은 어떤 타입의 DescriptorSet이 CommandBuffer에 바인딩 될 것인지에 대한 측면에서 렌더링 파이프라인의 설정을 나타냅니다. 이로부터 DescriptorSetLayout을 생성합니다. - 다른 그래픽스 API에서는 immediate mode 접근법을 취하여 다음에 뭐가 나오면 되는지 그냥 그리면 됩니다. Vulkan에서는 그게 불가능합니다. 대신 매 프레임의 렌더링을 사전에 계획하고 Pass와 Subpass들로 조직화해야 할 필요가 있습니다. Subpass는 별도의 오브젝트는 아니므로 여기서 다루지는 않겠지만, Vulkan의 렌더링 시스템에서 중요한 부분입니다. 다행히도 여러분의 작업을 준비할 때 모든 세부사항을 다 알 필요는 없습니다. 예를 들자면 Queue에 제출할 때 렌더링할 삼각형의 개수를 지정할 수 있는데, Vulkan의 RenderPass를 정의할 때 제일 중요한 부분은 Pass에서 사용하게 될 Attachment의 픽셀 포맷과 갯수입니다.
- Attachment란 여러분들이 렌더 타겟 (렌더링으로부터 출력된 결과로 사용될 이미지) 이라고 알고 있을지도
모르는 것의 Vulkan식 이름입니다. 여기서는 특정한 이미지를 지정할 필요는 없고, 픽셀 포맷만
기술해주면 됩니다. 예를 들면 단순한 렌더링 패스의 경우
R8G8B8A8_UNORM
포맷의 컬러 Attachment와D16_UNORM
포맷의 깊이/스텐실 Attachment를 사용할 수도 있습니다. Pass가 시작될 때 이 Attachment들의 내용물을 보존해야 하는지, 버릴지, 아니면 클리어할 지도 여기서 지정합니다. - Framebuffer (SwapchainKHR과 혼동하지 마세요) 는 Attachment (렌더 타겟) 으로 사용될 수 있는
실질적인 이미지에 대한 링크를 나타냅니다. Framebuffer 오브젝트는 RenderPass와 ImageView의 묶음을
지정하여 만들게 됩니다. 물론 Framebuffer내 이미지 갯수와 픽셀 포맷은 RenderPass에 지정된 것과
일치해야 합니다. Framebuffer는 Image 위에 있는 또다른 레이어고 기본적으로 특정한 RenderPass의
렌더링 동안 여러 ImageView들이 Attachment로 바인딩 되도록 그룹화합니다. 어떤 RenderPass의 렌더링을
시작할 때마다
vkCmdBeginRenderPass
함수를 호출하고 여기에 Framebuffer를 넘기게 됩니다. - Pipeline은 위에서 나열할 대부분의 오브젝트들을 묶어놓은 큰 것입니다. 이것은 전체 파이프라인의
설정을 나타내고 매우 많은 파라미터를 가지고 있습니다. 이 파라미터 중 하나가 PipelineLayout입니다 -
Descriptor와 Push constant들의 구성을 정의하죠. ComputePipeline과 GraphicsPipeline 두 가지 종류가
있습니다. ComputePipeline이 좀 더 단순한 편인데, 이쪽이 지원하는 것은 모두 컴퓨팅만 하는 프로그램
(종종 Compute 쉐이더 라고 불리는 것) 이기 때문입니다. GraphicsPipeline이 훨씬 복잡한데,
이쪽은 vertex, fragment, geometry, compute, 그리고 만약 가능하다면 tessellation, 거기에
더해 버텍스 속성, 기본 도형의 토폴로지 종류, 후면 제거, 블랜딩 모드와 같은 모든 파라미터를 감싸는
것이기 때문에 그렇습니다. 이 모든 파라미터들은 예전의 오래된 그래픽스 API (DirectX 9, OpenGL)에서는
개별적인 설정 값으로 존재했었고, 그후에 API가 발전하면서 (DirectX 10, 11) 좀 더 작은 개수의
상태 오브젝트들로 그룹화 되었는데, 지금 Vulkan 같은 현대적인 API에서는 하나의 거대하고 불변하는
오브젝트로 구워져야 하게 되었습니다. 렌더링 하는 동안 필요로하는 각각의 다른 파라미터 셋마다
새로운 Pipeline을 생성해야합니다. 그런 뒤에
vkCmdBindPipeline
함수를 호출하여 CommandBuffer내에 지금 사용하려는 Pipeline을 지정할 수 있습니다. - Vulkan에서 쉐이더 컴파일은 여러 단계로 진행됩니다. Vulkan은 GLSL이나 HLSL같은 고수준 쉐이딩 언어를 지원하지 않습니다. 대신 Vulkan은 SPIR-V라 불리는 중간 형태의 포맷을 받아들이는데, 이 포맷은 어떤 고수준 언어에서든 생성할 수 있습니다. SPIR-V로 되어있는 데이터로 채워진 버퍼가 ShaderModule를 생성하는데 사용됩니다. 이 오브젝트는 쉐이더 코드 조각을 나타내는 것으로, 일부가 컴파일된 형태일 가능성이 있지만, 아직 GPU가 실행할 수 있는 형태의 것은 아닙니다. Pipeline을 만들때 여러분들이 사용할 각 쉐이더 스테이지 (vertex, tessellation control, tessellation evaluation, geometry, fragment, 혹은 compute) 에 대하여 ShaderModule과 함께 엔트리 포인트 함수의 이름 (“main” 같은) 을 지정해야 합니다.
- PipelineCache라고 불리는 헬퍼 오브젝트도 있는데, 파이프라인 생성 속도를 높이는 데 사용될 수 있습니다. Pipeline 생성시에 추가적으로 넘길 수 있는 단순한 오브젝트이지만, 메모리 사용량 감소와 파이프라인 컴파일 시간을 통한 성능 향상에 실제로 도움을 줍니다. 드라이버가 몇가지 중간 데이터를 저장하는데 이것을 내부적으로 사용할 수 있으므로, 비슷한 파이프라인들을 생성하는 것이 더 빨라질 가능성이 생깁니다. 또한 PipelineCache 오브젝트의 상태값을 바이너리 버퍼에 저장하거나 혹은 불러올 수 있고, 디스크에 저장했다가 다음번 어플리케이션 실행 때 이를 사용할 수도 있습니다. 이러한 사용법을 추천드립니다!
- Query는 Vulkan의 또다른 종류의 오브젝트입니다. GPU에 의해 쓰여진 특정한 숫자값을
읽어오는데 사용됩니다. Occlusion 쿼리 (여러분에게 어떤 픽셀이 렌더링 되었는지 아닌지
알려주는 것, 즉 쉐이딩 전 후 테스트를 모두 통과하여 프레임에 쓰여졌는지에 대한 것) 혹은
Timestamp 쿼리 (GPU 하드웨어 카운터로부터 얻은 타임스탬프 값) 등 서로 다른 종류의 쿼리가
있습니다. Query는 자체 타입이 없는데, 이는 QueryPool 내부에 상주하면서
uint32_t
인덱스값으로만 표현되기 때문입니다. QueryPool 타입과 저장할 쿼리의 개수를 지정함으로써 생성할 수 있습니다. 그러고 나면vkCmdBeginQuery
,vkCmdEndQuery
혹은vkCmdWriteTimestamp
같은 함수를 사용하여 CommandBuffer에 명령을 제기하는 것으로 사용 가능합니다. - 마지막으로 동기화를 위해 사용되는 Fence, Semaphore, Event 같은 오브젝트들이
있습니다. Fence는 어떤 작업이 끝났음을 호스트에게 알려줍니다. 기다리거나 폴링하거나
혹은 호스트에 의해 수동으로 시그널 초기화가 될 수 있습니다. 자체 명령 함수는 가지고
있지 않고
vkQueueSubmit
함수를 통해 넘겨집니다. 제출된 큐가 해당 팬스를 끝내면 시그널 처리가 됩니다. - Semaphore는 설정 파라미터 없이 만들어집니다. 이것은 여러 큐들 사이에서의 리소스
접근을 제어하는데 사용됩니다. CommandBuffer 제출의 일부분으로서 시그날 혹은 웨이트될 수 있고
vkQueueSubmit
호출을 통해서도 할 수 있고, 한쪽 큐 (예를 들면 compute) 에서 시그널되고 다른 쪽 큐 (예를 들면 graphics) 에서 이를 기다리게 할 수도 있습니다. - Event 또한 설정 파라미터 없이 만들어집니다.
vkCmdSetEvent
,vkResetEvent
, 그리고vkCmdWaitEvents
함수를 이용하여 CommandBuffer에 제출된 개별 명령에 대해 GPU 상에서 시그널 혹은 웨이트를 걸 수 있습니다. 또한 하나 혹은 여러 개의 CPU 스레드로부터vkGetEventStatus
함수 호출을 폴링하는 것으로 셋, 리셋, 웨이트할 수도 있습니다. GPU 의 특정 지점에서 동기화가 필요하거나 렌더 패스 내에서 Subpass 의존성이 사용될 경우vkCmdPipelineBarrier
함수를 이용할 수도 있습니다.
이제 여러분은 GPU가 모든 프레임에서 실질적인 작업을 수행하도록 하기 위해서 (가능한 병렬적으로!) Vulkan 함수를 어떻게 호출해야 하는지 배울 필요가 있겠습니다. 그러면 현대적인 GPU가 제공하는 훌륭하고 유연한 연산 성능을 모두 활용할 여러분만의 방식을 갖게 될 것입니다.