주의 : 초심자 튜토리얼은 아닙니다. 그러므로, 실제 API 호출 용례를 알고자 한다면, 샘플이나 튜토리얼을 찾아서 확인해 보세요.

주의 : 완전히 이해하고 작성한 글이 아니므로 잘못된 내용이 포함되어 있을 수 있습니다.

주의 : 이상하면 참고자료를 확인하세요.



저수준 API?


Direct3D 11 과 같은 API 에서는 ID3D11DeviceContext::OMSetDepthStencilState() 와 같이 특정 상태를 설정하는 메서드들을 볼 수 있습니다. 



이 상태를 변경하고자 한다면 한 프레임 내부에서 아무때나 호출하는 것이 가능합니다. 그렇기 때문에 상태를 반복적으로 불필요하게 설정하기 위한 비교 루틴들을 넣고는 했습니다.


하지만 벌칸에서는 이러한 상태들이 하나의 오브젝트( VkPipeline )에 캡슐화됩니다. 그렇기 때문에 아무 곳에서나 상태를 변경하는 것은 불가능하고 반드시 하나의 세트로서 상태가 설정되어야만 합니다.


언뜻 보면 이것은 매우 불편합니다. 왜냐하면 저수준 API 라고 했음에도 불구하고 아무 곳에서나 원하는 작업을 하는게 불가능하기 때문입니다. 벌칸에서의 "저수준 API" 의 의미는 이전 API 들보다 저수준에 대한 제어를 할 수 있다는 의미이지, 아무 곳에서나 마음대로 함수를 호출할 수 있다는 의미는 아닙니다.


그런데 이렇게 하는 이유는 무엇일까요?


그 이유는 렌더 패스( RenderPass )와 그것의 역할을 이해하면 명확해집니다. 제가 처음에 Vulkan 을 접하면서 가장 이해하기 어려웠던 것이 렌더패스였습니다. 개념과 필요성 자체를 이해할 수 없었죠. 그래서 이 문서에서는 렌더패스에 대해서 다뤄 보고자 합니다. 


여기에서는 구구절절 코드를 풀어서 설명하기보다는 개념적인 부분을 중심으로 이야기하도록 하겠습니다.


개체 중심 API?


벌칸은 파이프라인 형태로 기능을 설계하기 매우 좋은 API 입니다. 왜냐하면 파이프라인의 특정 단계들이 오브젝트로 캡슐화되어 있기 때문입니다. 이것이 벌칸을 처음 사용하게 되는 기존의 OpenGL 이나 D3D 사용자들에게 많은 혼선을 주는 것 같습니다.


예전의 그래픽스 API 들이 절차적 프로그래밍 형식이었다면 이제는 개체지향 프로그래밍 형식으로 넘어 갔습니다. 물론 예전에도 C++ 코드 단에서는 나름대로 클래스를 만들어서 기능들을 캡슐화해서 사용하곤 했지만, 이제는 라이브러리 내부에서 그런 캡슐화를 수행하고 있습니다.


여기에서 혼선이 옵니다. 분명히 벌칸은 C ABI( Application Binary Interface ) 를 사용해서 구현되어 있는데, 뜬금없이 개체지향을 언급하다니 이상하지 않나요?


물론 제가 기억하기로는 명세에서도 개체지향이라는 표현은 사용하지 않습니다. 여기에서 제가 개체지향이라는 것은 언어적 특징을 이야기하는 것이 아닙니다. 기능들이 개체를 중심으로 구현되어 있다는 의미입니다. 우리가 접하는 API 는 일종의 마샬링( Marshaling ) API 이고 실제 내부 구현은 개체지향적으로 구현되어 있다고 생각하는 게 벌칸 API 를 이해하는 데 도움이 됩니다.


벌칸 API 의 함수들을 몇 개 살펴 보겠습니다.



vkXXX() 함수를 호출할 때는 반드시 작업의 주체가 되는 벌칸 오브젝트를 첫 번째 파라미터로 넣습니다. 그러면 각각은 다음과 같은 명령을 내립니다.


  • VkPhysicalDevice 오브젝트야! 포맷 속성들 좀 정리해서 알려 줘.
  • VkDevice 오브젝트야! 버퍼 하나 생성해 줘.
  • VkCommandBuffer 오브젝트야! 버퍼를 이미지에 복사하는 명령을 추가해 줘.


그렇기에 "Vulkan.hpp" 와 같은 래퍼 클래스를 만드는 것이 용이합니다.


뭐 다른 API 들도 이 정도는 하는 것 아니냐는 반론을 할 수 있습니다. 하지만 저는 이렇게 무리한 주장을 해 봅니다. 왜냐하면 앞에서 언급했듯이 개체를 중심으로 이해해야 벌칸 API 를 이해하기 편하기 때문입니다.


렌더패스


"렌더패스" 라 하면, 일반적으로 프레임 그래프렌더 그래프라는 것이 생각날 겁니다. 


간단하게 예를 들면, "Depth" --> "G-Buffer" --> "Shadow-Depth" --> "Lighting-Accumulation" --> "Translucency" 으로 이어지는 deferred-lighting 기법을 이런 그래프로 표현할 수 있습니다. 사실 포스트 프로세스를 고려하면 좀 더 복잡하게 그려야 하는데 그냥 대충 뭉뚱그립니다. 그냥 "Lighting-Accumulation" 에는 "Shadow-Applying" 까지 포함되어 있다고 생각해 주세요. 그림이 옆으로 길어져서 귀찮아서 뭉뚱그렸습니다.


그림 1.


각 단계별로 사용하는 어태치먼트( Attachment )들이 다르며 그 어태치먼들 간에는 종속성이 발생합니다. 일단 어태치먼트는 실제 리소스에 대한 기술( description )이라 생각하시면 됩니다. 뒤에서 좀 더 설명하겠습니다.


그림 2.


각 단계를 렌더 패스라고 볼 수 있으며, 이것이 사용하는 어태치먼트들 간의 종속성에 의해 렌더패스의 종속성이 발생합니다. 물론 서브패스( Sub-Pass )라는 것도 존재하기는 하지만 여기에서는 구체적인 언급을 피하겠습니다. 좀 설명이 복잡해집니다. 일단은 그냥 렌더패스 당 한 개의 서브패스가 존재한다고 생각하시면 됩니다.


그런데 어태치먼트들을 지정할 때 규칙을 지정합니다. 포맷이 뭔지, 어떤 용도로 사용되는지, 안티에일리어싱을 위한 샘플 카운트는 몇 개인지, 클리어는 하는지, 로드할 때 기존 데이터를 유지해야 하는지, 세이브를 할 필요가 있는지, 레이아웃이 뭔지 등등을 지정합니다. 이를 어태치먼트 디스크립션( VkAttachmentDescription )이라 합니다.



엄청 복잡합니다. 


이 렌더패스 오브젝트는 일종의 템플릿으로서 존재합니다. 실제로 인스턴스화되는 것은 VkCmdBeginRenderPass() 가 호출될 때죠. 이 함수가 호출될 때 각 어태치먼트와 관련된 리소스들을 VkFrameBuffer 라는 오브젝트로 바인딩합니다.



렌더패스 커맨드를 시작하고 종료하기 위해서는 기본적으로 세 가지 오브젝트가 필요하다는 것을 알 수 있습니다.


  • 렌더패스( VkRenderPass ) 오브젝트.
  • 프레임버퍼( VkFrameBuffer ) 오브젝트.
  • 커맨드버퍼( VkCommandBuffer ) 오브젝트.


아까도 언급했지만 엄청 복잡합니다. 왜 이렇게까지 해야 하는 걸까요? 그리고 이것의 장점은 무엇일까요?


No-Validation & No-Exception


렌더패스를 하나 만들면 VkCmdBeginRenderPass() 호출을 통해서 실제 렌더패스 인스턴스가 ( 내부적으로 ) 생성됩니다. 그리고 렌더패스의 어태치먼트에 대응해서 실제 리소스인 프레임버퍼가 바인딩됩니다.


꼭 렌더패스의 경우가 아니더라도 벌칸에서 이러한 Description-Instance 쌍을 띠고 있는 경우는 많습니다. 예를 들면 VkDescriptorSetLayout 같은게 있습니다.


이런 디스크립터들이 존재하는 이유는 벌칸이 실제 리소스의 메모리구조를 알고 있지 못하기 때문입니다. 텍스쳐를 만들면서 메모리가 2D 라고 가정했다고 합시다. 그런데 이 텍스쳐의 메모리를 채울 때 row-major 로 채울수도 있고 column-major 로 채울수도 있습니다. 그리고 한 픽셀의 크기도 다를 수 있습니다.


그러므로 벌칸에 실제 메모리가 어떤 식으로 구성되어 있는지 알려줄 필요가 있습니다. 그래서 대부분이 Descriptor-Instance 쌍으로 구성됩니다.


이게 어떤 이점을 가질까요?


이렇게 하면 개체를 생성하는 시점에서 validation 을 수행할 수 있습니다. 개체를 바인딩해서 처리하는 API 내부에서 validation 루틴 및 예외처리 루틴을 처리할 필요가 없어진다는 의미입니다. 그렇기 때문에 성능상 이점을 획득할 수 있다는 장점과 문제의 근원을 초반에 파악할 수 있다는 장점이 있습니다.


예를 들어 VkFrameBuffer 생성 함수를 살펴 보죠.



VkFramebufferCreateInfo 에 RenderPass 를 넣는 것을 볼 수 있습니다. 만약 프레임버퍼를 생성하는 데 렌더패스에서 기술한 조건들과 맞지 않았다면, 생성하면서 바로 에러가 나는거죠. 어떤 에러가 발생했는지 알고자 한다면, "Debug Layer" 를 켜면 됩니다.


Dependency Management


그림 1 에서 "Shadow-Depth" 패스는 "Depth" 패스와 "G-Buffer" 패스에 대해 비동기적으로 실행될 수 있습니다. 하지만 "Lighting-Accumulation" 패스 전에는 동기화되어야 합니다.


VkCommandBuffer 들은 동기화 개체를 통해서 서로에 대한 의존성을 지정할 수 있습니다. 만약 그러므로 렌더패스는 구조만 기술하고 렌더패스 인스턴스를 생성하면서 커맨드버퍼를 같이 넘기면 렌더패스끼리의 의존성을 지정할 수 있는 겁니다.


이 경우에는 "Shadow-Depth" 패스를 다른 스레드에서 실행한다고 해도 순서에 문제가 발생하지 않겠죠.


Life-Cylcle Management


요즘에는 보통 게임 스레드와 렌더링 스레드를 분리하는 구현을 합니다. 심지어는 렌더링 스레드와 RHI( Rendering-Hardware-Interface ) 스레드를 분리하기도 하죠.


이렇게 되면 리소스 오브젝트의 무결성에 문제가 생깁니다. 예를 들어 일반적으로 스왑체인( swap-chain )은 여러 개의 백퍼버( Swapchain-Image )를 가질 수 있습니다. 최소한 2 개 정도의 백버퍼를 사용하고 있습니다.


만약 여러 개의 백버퍼를 가지고 있다고 가정하고( 게임스레드와 렌더링스레드를 프레임-동기화하지 않는다는 의미 ), 게임 스레드와 렌더링 스레드를 분리했을 때 다음과 같은 형태로 업데이트가 될 겁니다. 각 프레임마다 처리시간이 가변적이라고 가정했습니다.



첫 번째 프레임을 보죠. 붉은색 게임 프레임이 종료되고 주황색 게임 프레임이 시작될 때 렌더링 스레드에서는 여전히 붉은색 프레임을 실행하고 있습니다. 이 때 렌더패스가 템플릿( 혹은 디스크립션 )이 아닌 인스턴스라고 하면 바로 문제가 발생합니다. 주황색 게임 프레임에서는 사용중인 렌더패스, 커맨드버퍼, 프레임버퍼 등을 변경하도록 요청하겠죠.


그러므로 렌더패스 인스턴스 단위로 커맨드와 프레임버퍼를 바인딩하는 것은 생명주기를 관리하는데 큰 도움을 줍니다.


그래서 보통 프레임버퍼를 스왑체인 이미지의 개수만큼만큼 풀( pool )로 만들어서 관리하게 됩니다. 물론 메모리 문제는 발생하겠지만 무결성이 깨지는 상황은 막을 수 있습니다. 메모리가 걱정된다면 프레임-동기화를 하면 됩니다.


결론


벌칸은 C 로 구현되어 있기는 하지만, 개체 중심으로 구조화되어 있고 추상화되어 있습니다. 대부분이 Descrption-Instance 쌍으로 되어 있으며, 그 중 하나가 렌더패스입니다.


렌더패스는 어태치먼트들을 정의하며, 실제로 이것이 인스턴스화 되는 것은 VkBeginRenderPass() 입니다. 이때 렌더패스, 프레임버퍼, 커맨드버퍼가 필요합니다. 


프레임버퍼는 어태치먼트가 지정하는 실제 리소스 인스턴스입니다. 커맨드버퍼는 렌더패스간 의존성을 제어하는데 사용됩니다.


렌더패스 오브젝트를 실제 인스턴스와 분리함으로써 세 가지 이점을 가질 수 있습니다.


  • API 내부의 에러 처리 루틴이 필요하지 않아 성능이 개선됩니다.
  • 커맨드 버퍼를 통해 렌더패스간 의존성을 관리할 수 있습니다.
  • 멀티스레딩 환경에서 렌더패스 인스턴스, 커맨드 버퍼, 프레임 버퍼 등의 생명주기를 관리하는데 용이합니다.


+ Recent posts