주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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



저는 벌칸에 대해서 공부하면서 RenderMonkey 와 유사한 VulkanMonkey 라는 셰이더 편집 도구를 만든다는 최종 목표를 가지고 있습니다. 이 연구 시리즈도 그것을 구현하는 과정에서 발생하는 여러 가지 의문들에 대해서 스스로 답을 내면서 만들어지고 있는 것입니다.



하지만 프로젝트를 진행하다가 벽에 부딪히게 되었습니다. 기존의 D3D9 이나 OpenGL 과는 다르게 Vulkan 은 여러 개의 큐를 가질 수 있으며 커맨드들이 비동기적으로 실행될 수 있기 때문에, RenderMonkey 와 같은 TreeView 구조의 워크스페이스 관리는 비직관적입니다.


이 때문에 구조가 맘에 안 들어 디바이스 생성후 더 이상 진행을 못하고 있었습니다. 물론 마샬링 구조를 결정하는 문제 때문에 프로젝트를 3 차례나 엎고 다시 만드는 데 걸리는 시간이 꽤 길긴 했지만( 현재는 C# <--> C++/CLI <---> C++ 의 구조를 가집니다. 이 부분에 대해서는 나중에 한 번 공유하도록 하겠습니다 ), 그건 과거의 문제이고 미래로 나아가지 못하는 원인은 워크스페이스 구조였습니다.


그런데 오늘 갑자기 나아가야 할 방향이 떠 올랐습니다. 벌칸은 매우 높은 수준으로 파이프라인화되어 있는 API 입니다. 이미 이런 형태를 잘 반영하고 있는 도구들이 존재하고 있는데 삽질을 했다는 생각이 들었습니다. 그건 바로 Graphics Profiler 들입니다. 예를 들어 NSight Graphics 를 보면 다음과 같습니다.



Scrubber View 에서 큐당 커맨드의 흐름을 확인할 수 있습니다. 그리고 이벤트 뷰와 API 인스펙터 뷰에서 세부사항을 확인할 수 있죠.


VulkanMonkey 에서는 커맨드 단위가 아니라 렌더 패스 단위의 흐름을 파악할 수 있도록 만들면 되겠다는 생각이 들었습니다. 커맨드 버퍼 내부에서 동기화를 해야 하는 경우는 거의 없을 것이라고 생각하기 때문입니다.


그러면 아래와 같은 형태로 렌더 패스를 배치할 수 있습니다. 물론 실제 걸리는 시간은 알 수 없으니 적절히 너비를 사용자가 조정하게 만들어야겠죠. 아래 그림에서는 세마포어를 사용해서 "Light Accumulation" 렌더 패스가 시작되기 전에 "CSM3Depth" 렌더 패스에 대해 웨이트하도록 하는 상황을 상정했습니다. 물론 실제 구현 측면에서 보면, "Light Accumulation" 의 첫 번째 커맨드 버퍼를 Submit 할 때 Wait 를 하는 상황이겠죠.




그리고 각각의 렌더 패스의 인스펙터창에서는 세부적인 설정들을 할 수 있도록 하는 거죠. 이렇게 하면 전체적인 흐름을 볼 수 있습니다. 


여기에다가 특정 시점까지의 렌더 패스 실행 결과만을 실행할 수 있는 기능까지 추가하면, 프레임 디버거처럼 중간과정을 확인하는 것도 가능할 것입니다. 


좀 더 나아가면 렌더 패스 내의 커맨드 퍼버들까지 가시화해 줄 수 있으면 금상첨화라는 생각이 듭니다. 그러면 하나씩 그리는 과정도 추적할 수 있겠죠.


이제 좀 앞으로의 구현 방향이 잡히는 기분입니다. 물론 이런 UX 를 구현하려고 생각하니 끔찍하긴 하지만 말이죠.


더 좋은 아이디어가 있으면 댓글 남겨 주시면 감사하겠습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참조하세요.

주의 : 번역이 너무 껄끄러우면 원문을 그대로 사용하겠습니다.

주의 : 허락받고 번역한 것이 아니므로 언제든 내려갈 수 있습니다.

원문 : Performance Tweets series: Barriers, fences, synchronization. GpuOpen.



DX12 시리즈에 오신 걸 환영합니다. 가장 화끈한 주제 중 하나로 들어 가 보죠: 동기화, 즉 배리어와 펜스입니다!


Barriers


배리어는 이전에는 드라이버 단에 숨겨져 있지만 개발자에게 노출된 새로운 개념입니다. 만약 여러분이 이것을 동기화라고 생각한다면, 아주 크게 벗어는 것은 아닙니다. 왜냐하면 이것 또한 배리어의 일부이기 때문입니다. 동기화 부분은 CPU 측에 대해서는 잘 알려져 있습니다: 버퍼를 갱신하는 다중의 쓰기 스레드를 가지고 있고, 모든 쓰기 작업이 종료되도록 만들기 위해서 동기화를 하고, 다중의 읽기 쓰레드를 사용해 데이터를 처리할 수 있습니다. 하지만 그것이 전부는 아닙니다. GPU 배리어가 존재합니다( ResourceBarrier vkCmdPipelineBarrier ). 전체적으로 볼 때 3 가지 기능들이 있습니다:


  • Synchronization 은 새로운 작업을 시작하기 전에 이전의 의존성 작업이 완료되는 것을 보장합니다.
  • Visibility 는 특정 유닛에서 캐싱되어야 하는 데이터가 그것을 확인하고자 하는 다른 유닛에서 가시적임을 보장합니다.
  • Reformatting 은 유닛에 의해서 읽어들여진 데이터가 유닛이 이해할 수 있는 형식임을 보장합니다.


여러분은 리포매팅이 발생하는 경우에 대해서 궁금할 겁니다. 이는 압축을 하는 경우에 종종 요구됩니다. 예를 들어 전통적으로 렌더 타깃은 읽기와 쓰기를 위해 요구되는 대역폭을 줄이기 위해서 압축이 됩니다. GPU 의 어떤 유닛들은 모든 압축 모두를 제공하지는 않으며, 압축해제를 요구합니다. 보통 배리어는 캐시 플러시( cache flush )( 컬러 버퍼, 깊이 버퍼같은 개별 유닛에 붙어 있는 L1 캐시나 좀더 일원화된( unified ) L2 캐시 )로 변환됩니다. 그리고 나서 대기하고( 예를 들어, 결과를 사용하고자 하는 그래픽스 셰이더 이전에 컴퓨트 셰이더가 완료되기를 기다립니다 ), 압축을 해제합니다.


이제 배리어가 뭔지 알게 되었습니다. 그런데 그게 왜 중요할까요? 배리어는 보통 Direct3D 12 와 Vulkan 에서 버그의 온상이 됩니다. 흔들림( flickering ), 깨짐( blocky artifact ), 데이터 손상( corrupted data ), 깨진 지오메트리와 같은 많은 현상들이 잘못된 배리어의 책임일 수 있습니다. 일단, 서브리소스 당 트래킹( per-subresource tracking )을 포함하는 100 % 올바른 배리어가 있다고 가정해 봅시다. 즉 여러분이 텍스쳐 배열의 한 레이어에 쓰기 작업을 하면, 이 레이어는 올바른 상태로 전이( transition )될 필요가 있습니다 -- subresources are often overlooked. You also have to make sure to have all required transitions in place. Some might be easy to overlook like transitioning for presentation.


배리어를 만들었으면, 이제 최적화를 할 시간입니다. Barriers are expensive and every barrier counts. 그것을 요구하는 리소스들만 트랜지션 해야 합니다 -- 그 예로는 텍스쳐로서 읽어들여지는 렌더 타깃이 있습니다. 일반적으로는 쓰기 작업을 하는 리소스보다 더 많은 배리어를 만들어서는 안 됩니다. 다른 모든 리소스들은 프레임 당 트랜지션을 하지 말아야 합니다. 특히 리소스에 대해서 쓰기 작업이 한 번만 수행되고 여러 방식으로 읽어들여진다면, 다중의 읽기-읽기( read-read ) 상태 트랜지션들을 사용하기 보다는, 대부분의 읽기 상태에 대해 리소스를 한 번만 트랜지션하십시오.


배리어를 배칭하는 것도 좋은 성능을 내는 데 있어 중요합니다. 배리어는 GPU 플러싱( flushing )을 요구하므로 배리어 호출 당 더 많은 리소스들을 트랜지션 하십시오. 그러면 배리어를 필요로 하는 모든 리소스들에 대해 단 한 번의 플러싱만이 수행될 것입니다. 이는 성능 향상에 많은 도움이 됩니다 -- 그러므로 가능한 한 배리어 호출들을 배칭하십시오.  이를 구현하는 한 가지 방법은 상태를 식별하는 것입니다. 모든 리소스들은 커맨드 리스트에 존재해야 하며, 커맨드 리스트 시작이나 이전 커맨드 리스트의 끝에서 그것들을 모두 함께 트랜지션하는 것입니다. 물론 실제 및 트랜지션 작업이 실행되기 바로 전에 리소스 상태를 검사하는 안 좋은 패턴은 피해야 할 것입니다 -- 이는 필요한 것 보다 더 많은 트랜지션 호출이 발생하도록 만들 것입니다.


Fences


배리어와 관련이 있는 것으로 펜스가 있습니다( CreateFence 와 vkCreateFence ). 이것들은 CPU 와 GPU 뿐만 아니라 GPU 상의 큐들을 동기화하기 위해서 사용됩니다. 펜스는 매우 무거운 동기화 개체입니다. 왜냐하면 그것은 적어도 GPU 가 모든 캐시들을 플러싱할 것을 요구하며, 잠재적으로 일부 추가적인 동기화를 요구합니다. 그런 비용들 때문에, 펜스는 드물게 사용되어야 합니다. 특히, 프레임 당 리소스들을 그룹화하고, 미세한( fine-grained ) 리소스 당 트래킹을 사용하는 대신에, 단일 펜스를 사용해 함께 트래킹하도록 시도하십시오. 예를 들어, 한 프레임 내에서 사용되는 모든 프레임 버퍼들은 커맨드 버퍼 당 펜스를 사용하기 보다는 하나의 펜스를 사용해서 보호되어야 합니다.


펜스는 컴퓨트 큐, 카피 큐, 그래픽스 큐들을 프레임 단위에서 동기화하기 위해서 드물게 사용될 수도 있습니다. 이상적으로는, 모든 잡들이 종료되는 조짐이 보이는 끝 부분에서 단일 펜스를 사용해서 비동기 컴퓨트 잡들의 큰 배치를 제출하려고 시도하시기 바랍니다. 카피에 대해서도 동일합니다. 가장 가능성있는 성능을 획득하기 위해서 모든 카피의 끝에서 단일 펜스를 사용해서 시그널링을 하십시오.


늘 그렇듯이, 질문이 있으면 편한하게 댓글을 남기거나 Twitter 에 알려 주세요 : @NThibieroz & @NIV_Anteru.


Tweets


02 : 배리어 개수는 쓰기 작업을 하는 리소스 개수와 대충 비슷해야 합니다.

12 : 각 펜스는 ExecuteCommandList 와 비슷한 비용이 듭니다( CPU 와 CPU 비용 ).

22 : 읽기-읽기 배리어를 사용하지 마십시오. 올바른 상태의 리소스를 먼저 획득하십시오.

31 : 항상 리소스를 그것을 작성하는 마지막 큐에서 트랜지션하십시오.


Matthäus Chajdas is a developer technology engineer at AMD. Links to third party sites, and references to third party trademarks, are provided for convenience and illustrative purposes only. Unless explicitly stated, AMD is not responsible for the contents of such links, and no third party endorsement of AMD or any of its products is implied.

주의 : 번역이 개판이므로 이상하면 원문을 참조하세요.

주의 : 허락받고 번역한 게 아니므로 언제든 내려갈 수 있습니다.

주의 : 번역이 좀 껄끄럽거나 귀찮으면 원문을 그대로 쓰겠습니다.

원문 : Vulkan barriers explained, GpuOpen.



벌칸( Vulkan )의 배리어( barrier ) 시스템은 독특합니다. 왜냐하면 그것은 트랜지션( transitioning )중인 리소스들을 여러분이 제공할 것을 요청할 뿐만 아니라, 소스 파이프라인 스테이지( source pipeline stage )와 타깃( target ) 파이프라인 스테이지를 지정할 것도 요구하기 때문입니다. 이는 트랜지션이 실행될 때 더욱 미세한( fine-grained ) 제어를 할 수 있도록 해 줍니다. However, you can also leave quite some performance on the table if you just use the simple way, 그래서 오늘은 vkCmdPipelineBarrier 에 대해서 자세히 알아 보도록 하겠습니다.


Pipeline Overview


GPU 는 엄청나게 파이프라인화되어 있는 디바이스라는 것은 일반적인 상식입니다. 커맨드들은 탑( top )으로 들어가며, 버텍스 셰이딩( vertex shading )이나 프래그먼트( fragment ) 셰이딩과 같은 개별 스테이지들이 순서대로 실행됩니다. 마지막으로 커맨드들은 실행이 완료될 때 파이프라인의 바텀( bottom )에서 리타이어( retire )됩니다( 역주 : 일반적으로 CPU 에서는 리타이어되었다는 의미가 out-of-order execution 에서의 실행을 종료하고 올바른 결과를 산출했다는 의미입니다. 예를 들어 branch-prediction 같은 것에 의해 취소되지 않았다는 의미입니다 ).


이런 스테이지들은 VK_PIPELINE_STAGE 열거형을 통해서 벌칸에 노출됩니다. 아래와 같이 정의되어 있습니다.


  • TOP_OF_PIPE_BIT
  • DRAW_INDIRECT_BIT
  • VERTEX_INPUT_BIT
  • VERTEX_SHADER_BIT
  • TESSELLATION_CONTROL_SHADER_BIT
  • TESSELLATION_EVALUATION_SHADER_BIT
  • GEOMETRY_SHADER_BIT
  • FRAGMENT_SHADER_BIT
  • EARLY_FRAGMENT_TESTS_BIT
  • LATE_FRAGMENT_TESTS_BIT
  • COLOR_ATTACHMENT_OUTPUT_BIT
  • TRANSFER_BIT
  • COMPUTE_SHADER_BIT
  • BOTTOM_OF_PIPE_BIT


이 열거형은 커맨드가 실행되는 순서와 같은 것은 아닙니다 -- 일부 스테이지들은 병합될 수도 있고 일부 스테이지들은 사라질 수도 있지만, 전체적으로 이것들은 커맨드가 통과할 파이프라인 스테이지들입니다.


세 개의 의사 스테이지( pseudo-stage )들이 존재하는데, 이것들은 다중의 스테이지들을 합치거나 특별한 접근을 제어하는데 사용됩니다:


  • HOST_BIT
  • ALL_GRAPHICS_BIT
  • ALL_COMMANDS_BIT


이 아티클에서는 TOP_OF_PIPE_BIT 와 BOTTOM_OF_PIPE_BIT 사이의 리스트들에 대해 논의할 것입니다. 자, 배리어 문맥에서 소스와 타깃은 뭘까요? 그것들을 "생산자( producer )" 스테이지와 "소비자( consumer )" 스테이지로 생각할 수 있습니다 -- 소스 스테이지가 생산자가 되고, 타깃 스테이지가 소비자가 됩니다. 소스 및 타깃 스테이지를 지정함으로써, 드라이버에게 전이가 실행되기 전에 어떤 연산들이 완료될 필요가 있는지 그리고 어떤 연산들이 아직 시작되면 안 되는지를 알려줄 수 있습니다.


예 1: 느린 배리어, 파이프의 바텀을 소스 스테이지로 지정하고 파이프의 탑을 타깃 스테이지로 지정.


가장 단순한 경우를 살펴 봅시다. BOTTOM_OF_PIPE_BIT 를 소스 스테이지로 지정하고 TOP_OF_PIPE_BIT 를 타깃 스테이지로 지정합니다( 예 1 ). 이를 위한 소스 코드는 다음과 같습니다:



이 트랜지션은, 현재 GPU 에서 실행중( in flight )인 모든 커맨드들이 완료되고 나서 그 트랜지션이 실행되어야 하며, 트랜지션을 종료하기 전에 어떠한 커맨드들도 실행되어서는 안 된다는 것을 의미합니다. 이 배리어는 실행중인 모든 것을 종료하고 어떠한 작업도 실행되지 않도록 막기 위해서 대기할 것입니다. 그것은 일반적으로는 이상적이지 않습니다. 왜냐하면 불필요한 파이프라인 버블( bubble, 역주 : pipeline stall 을 pipeline bubble 이라 부르기도 합니다 )을 겪도록 만들기 때문입니다.


예 2: 최적의 배리어. 모든 녹색 파이프라인 스테이지들이 실행되는 것이 허용됨.


버텍스 셰이더에서 데이터를 imageSotre 를 통해서 저장하고 컴퓨트 셰이더가 그것을 소비하기를 원한다고 상상해 봅시다. 이 경우에, imageStore 를 통해 저장하는 작업이 완료될 때까지 시간이 너무 오래 걸릴 수도 있기 때문에, 프래그먼트 셰이더를 처리하지 못하고 대기해야할 수 있습니다. 여러분은 이런 상황을 원하지는 않을 겁니다. 여러분이 정말 원하는 건 버텍스 셰이더가 완료되자 마자 컴퓨트 셰이더를 시작하는 것일 겁니다. 이를 표현하는 방식은 소스 스테이지를 VERTEX_SHADER_BIT 로 설정하고 타깃 스테이지를 COMPUTE_SHADER_BIT 로 설정하는 겁니다( 예 2 ).



만약 렌더 타깃을 쓰고 그것을 프래그먼트 셰이더에서 읽고자 한다면, 이 소스 스테이지는 VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT 일 것이고 타깃 스테이지는 VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT 일 겁니다. 이는 G-Buffer 렌더링에서 일반적입니다. 셰도우 맵의 경우, 소스는 VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT 가 될 겁니다. 다른 일반적인 예는 데이터 복사입니다 - 복사를 통해 데이터를 생성할 때 소스 스테이지를 VK_PIPELINE_STAGE_TRANSFER_BIT 로 설정하고 타깃 스테이지를 그것을 필요로 하는 스테이지로 설정하면 됩니다. 버텍스 버퍼에서 사용한다고 하면 이것은 VK_PIPELINE_STAGE_VERTEX_INPUT_BIT 일 겁니다.


일반적으로 여러분은 언블락킹된( unblocked ) 스테이지들의 개수를 최대화하려고 시도해야 합니다. 즉, 데이터를 빨리 생성하고 그것을 나중에 기다리는 겁니다. 생산자 측에서 파이프라인의 바텀을 향해 이동하는 것은 항상 안전합니다. 왜냐하면 더 많은 스테이지들이 끝날 때까지 대기해야 하기 때문입니다. 하지만 성능은 개선되지 않겠죠. 비슷하게 소비자 측에서 안전하기를 원한다면, 파이프라인의 위쪽인 탑으로 이동하면 됩니다 - 하지만 그것은 더 많은 스테이지들을 실행하는 것을 방해하게 될 것입니다. 그래서 그것 또한 피해야 합니다.


마지막으로 주의할 점: 앞에서 언급했듯이, 하드웨어는 내부적으로 모든 스테이지들을 가지고 있는게 아닙니다. 혹은 특정 스테이지에서는 시그널링하거나 웨이트할 수 없을 수도 있습니다. 그런 경우에는, 드라이버가 마음대로 소스 스테이지를 파이프라인의 탑이나 바텀으로 이동시키거나 타깃 스테이지를 탑으로 이동시킬 수 있습니다. 이는 구현측-명세에 따르기는 하지만 여러분이 그것에 대해서 걱정할 필요가 없습니다 - 여러분의 목적은 스테이지들은 가능한 한 짜임새있게( tight ) 설정하고 블락킹된 스테이지의 개수를 최소화하는데 있기 때문입니다.


Matthäus Chajdas is a developer technology engineer at AMD. Links to third party sites, and references to third party trademarks, are provided for convenience and illustrative purposes only. Unless explicitly stated, AMD is not responsible for the contents of such links, and no third party endorsement of AMD or any of its products is implied.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



Vulkan 명세 [ 6. Synchronization and Cache Control ] 에서 소개하고 있는 주요 동기화 메커니즘은 다음과 같습니다. 


  • Fences : 펜스는 디바이스 상에서 실행되는 어떤 태스크가 완료되었음을 호스트에게 알려주기 위해서 사용될 수 있습니다.
  • Semaphores : 세마포어는 여러 개의 큐 사이에서 리소스에 대한 접근을 제어하기 위해서 사용될 수 있습니다.
  • Events : 이벤트는 미세한 작업 단위의 동기화( fine-grained synchronization )이며, 단일 큐 내에서만 사용이 가능합니다. 작업의 순서를 지정하기 위해서 사용됩니다.
  • Pipeline Barriers : 파이프라인 베리어는 같은 큐에 제출되는 커맨드들이나 같은 서브패스 내의 커맨드들 사이에 종속성을 주입하기 위해 사용됩니다.
  • Render passes : 렌더 패스는 거의 대부분의 렌더링 태스크를 위한 유용한 동기화 프레임워크를 제공합니다. 다른 동기화 요소들을 사용하기 보다는 렌더 패스 단위로 동기화하는 것이 효율적입니다.


일단 이 문서에서는 커맨드 제어를 위해서 가장 많이 사용되는 펜스, 세마포어, 배리어에 대해서 알아보도록 하겠습니다.


[ 1 ] 에서는 동기화 개체에 대해 아래와 같이 다이어그램으로 잘 정리해 뒀습니다.


동기화 용례 : 출처 : [ 1 ].


위의 다이어그램에서는 다음과 같은 용례를 보여 줍니다.


  • 이벤트를 사용해서 커맨드 버퍼 내의 커맨드들 끼리의 동기화를 보장합니다.
  • 세마포어를 사용해 큐 사이의 동기화를 보장합니다.
  • 펜스를 사용해 호스트와 디바이스 사이의 동기화를 보장합니다.


그런데 멀티스레딩에 대해 "제대로" 공부해 보지 않은 분들은 펜스, 이벤트, 세마포어, 배리어 등의 개념에 익숙하지 않을 겁니다( 저도 그렇습니다 ). 그러므로 그 개념들에 대해서 살펴 보고 오시는 것을 추천합니다. 저같은 경우에는, 이벤트나 세마포어같은 걸 스레드 동기화에 대해 공부하면서 종종 접해 봤지만, 펜스와 배리어라는 개념은 그래픽스를 하면서 처음 봤습니다. 


동기화 개체들에 대해서 대해서 간단하게( ? ) 잘 정리한 분들이 있는데, 아래 링크를 확인하시면 좋을 것 같습니다.



사실 뮤텍스와 이벤트는 모두 세마포어의 변종이라고 할 수 있습니다. 뮤텍스는 키가 한개인 세마포어이고, 이벤트는 통지 기능을 가진 뮤텍스나 세마포어죠. 그리고 [ 2 ] 에 의하면 배리어와 펜스는 기존의 드라이버 단에 존재하던 동기화 기법이 호스트단에 노출된 것에 불과합니다.


그러므로 동기화 메커니즘에 사용되는 동기화 개체들에 대해서 사고할 때, 원래의 의미에 대해서 너무 깊게 생각하는 것은 Vulkan 에서 동기화 개체를 사용할 때 혼란의 여지를 준다고 생각합니다. 그래서 어떤 오브젝트에 대해 어떤 시점에 어떤 방식으로 동기화 개체들이 사용되는지를 이해하는 것이 중요하다고 생각합니다.


세마포어


Vulkan 에서 세마포어는 큐들에 제출된 배치들( batches submitted to queues ) 사이에 의존성을 삽입하는 데 사용될 수 있습니다. 세마포어는 시그널( signaled )과 언시그널( unsignaled ) 상태를 가집니다.


세마포어는 커맨드 배치가 완료된 후에 시그널 상태가 변할 수 있습니다. 일반적으로 어떤 배치를 실행하기 전에 세마포어가 시그널 상태로 되기를 기다렸다가, 배치를 실행하기 전에 세마포어를 언시그널 상태로 설정할 수 있습니다. 이 과정을 통해서 순서대로 배치가 실행되도록 보장하는거죠.


다음과 같이 두 개의 커맨드 버퍼가 있다고 가정해 보죠; A 와 B. 그런데 A 와 B 가 순차적으로 제출되는데 반드시 A 의 작업이 종료된 후에 B 가 제출되었으면 한다고 합시다. 그렇다면 동기화를 걸 필요가 있겠죠. 이럴 때 세마포어를 사용할 수 있습니다.


예를 들면  "deferredshadows" 샘플에서는 다음과 같은 식으로 세마포어를 사용해 커맨드버퍼들의 제출 시점을 제어합니다.



VkSubmitInfo 에다가 pWaitSemaphores 와 pSignalSemaphores 를 지정하는 것을 확인하실 수 있습니다. pWaitSemaphores 는 종료될 때까지( 시그널링될 때까지 ) 대기해야 하는 세마포어이고, pSignalSemaphores 는 자신이 완료되면 시그널링시킬 세마포어입니다.


배리어와 펜스


배리어와 펜스의 개념에 대해서는 잘 정리한 글들이 있으므로 제가 직접 정리하기 보다는 그것들을 참조하는 게 좋을 것 같습니다.


[ 번역 : Vulkan barriers expained ].

[ 번역 : Performance Tweets series : Barrier, fences, synchronization ].


커맨드 풀


[ 1 ] 에서는 커맨드 풀을 프레임 단위로 유지함으로써 커맨드 버퍼의 라이프사이클을 유지하는 수단으로 사용할 수 있다고 이야기하고 있습니다. 왜냐하면 커맨드 풀을 통째로 리셋함으로써 커맨드 버퍼를 모두 지워버릴 수 있기 때문입니다.



커맨드 풀 자체는 동기화 개체는 아닙니다. 하지만 어떻게 보면 커맨드 버퍼가 삭제되지 않도록 만드는 일종의 배리어같은 역할을 한다는 생각이 듭니다.


정리


벌칸은 여러 종류의 동기화 개체를 가지고 있습니다. 대표적인 것이 세마포어, 배리어, 펜스, 이벤트 등인데요, 각각의 사용처가 다릅니다. 적절한 위치에서 사용하는 것이 중요합니다. 


안타깝게도 이벤트같은 경우에는 자세한 설명이나 샘플을 찾아 볼 수가 없어서 여기에서 정리를 하지 못했습니다.


일단은 개념적인 부분들에 대해서 살펴 보았지만, 기회가 되면 나중에 실제로 구현해 보고 공유하도록 하겠습니다( 아직까지 렌더러 구조에 대해 고민하는 중이라 샘플을 못 만든 상태입니다 ).


참고자료


[ 1 ] Muti-Threading in Vulkan. arm Community.


[ 2 ] Performance Tweets series: Barriers, fences, synchronization. GPU Open.


[ 3 ] Vulkan 1.1 Specification. Khronos Group.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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



Vulkan 명세의 [ 2.6. Threading Behavior ] 에서는 다음과 같이 이야기하고 있습니다.


Vulkan 은 다중 호스트 스레드를 사용할 때 확장가능한 성능을 제공하도록 설계되어 있습니다. 모든 커맨드들은 다중 스레드에서 동시적으로 호출되도록 지원하고 있습니다. 하지만 특정 파라미터나 파라미터의 요소들은 외부적으로 동기화되도록( externally synchronized ) 정의되어 있습니다. 이는 호출자가 그런 파라미터를 동시에 하나 이상의 스레드에서만 호출되도록 보장해야만 한다는 것을 의미합니다.


좀 더 정확하게 이야기하자면, Vulkan 커맨드들은 Vulkan 오브젝트의 상태를 갱신하기 위한 단순한 저장소( simple stores )들을 사용합니다. 외부적으로 동기화되도록 선언된 파라미터들은 호스트가 커맨드를 실행하는 동안에 언제든 갱신될 수 있습니다. 만약 같은 오브젝트에 대해 동작하는 두 개의 커맨드가 있는데, 둘 중의 하나라도 외부적으로 동기화되도록 오브젝트를 선언했다면, 호출자는 커맨드들이 동시적으로 실행되지 않도록 보장해야할 뿐만 아니라, 그 두 개의 커맨드들이 ( 필요하다면 ) 적절한 메모리 배리어에 의해서 분리되도록 보장해야만 합니다.


요약하자면, Vulkan 은 커맨드를 다중의 스레드에서 실행함으로써 동시성을 보장한다는 것입니다. 하지만 외부 동기화를 요구하는 파라미터( externally synchronized parameter )들을 사용하는 커맨드들은 동시에 실행되서는 안 된다는거죠. 


매우 단순한 예를 하나 들어 보겠습니다. 버퍼 A 와 A 에 쓰기 작업을 하는 두 개의 스레드가 존재한다고 합시다. 그리고 각각의 스레드에서는 개별적으로 커맨드들을 생성했다고 가정하겠습니다. 그러면 각각의 스레드에서 생성한 커맨드들은 서로 관계가 없으므로 동시적으로 실행될 수 있습니다. 하지만 A 에 동시에 쓰기를 할 수는 없겠죠. 그러므로 이런 커맨드들이 동시적으로 실행되지 않도록 보장해 줄 필요가 있습니다.


External Synchronization


"외부적으로 동기화된다( externally synchronized )" 는 의미는 상당히 혼란스럽습니다. 저로서는 상당히 생소한 개념인데요, 일단 구글링을 해 봤습니다. 


그런데 이 용어 자체에 대해서 근본적으로 설명하는 글들은 없고, 대부분이 "Clock Synchronization" 혹은 "Time Synchronization" 에 대한 글들이더군요.


[ 1 ] 에 의하면 internal synchronization 과 external synchronization 의 차이는 다음과 같습니다.


분산 시스템에서 클락 동기화는 보통 하나나 두 개의 목적을 가집니다: (1) 분산 시스템을 구성하는 모든 노드들이 같은 내부 시계( clock )을 소유하는 것을 보장하고, (2) 그 분산 시스템이 다른 외부 시계와 동기화되는 것을 보장합니다.


내부 동기화는 보통 컴퓨팅 클러스터들이 자신들의 로컬 시계들로 동기화되도록 허용하는 동기화 프로토콜을 통해서 수행됩니다. 그 머신은 공통 시간을 사용한다고 동의하는거죠. 하지만, 이 그들이 동의한 시간은 외부 시계와 동기화될 필요는 없습니다. 예를 들면 특정 타임존( time-zone )에 대해서 말이죠.


외부 동기화는 컴퓨팅 시스템들이 NTP 프로토콜을 사용하여 제공되는 서버와 같은 외부 시간에 자신들의 시계가 동기화되도록 만듭니다. 이것의 목적은 컴퓨팅 시스템의 시간을 특정 타임존과 동기화시키는 것입니다. 만약 정확한 시간이 요구된다면, 원자 시계( atomic clock )으로부터 시간을 생성하는 NTP 시스템들이 사용됩니다.


두 경우 모두, NTP 프로토콜이 사용될 수 있으며, 광범위하게 사용됩니다.


결국 Vulkan 명세에서 이야기하는 외부 동기화는 이런 의미가 아니라는 것을 알 수 있습니다. 좀 더 구글링을 해 보니, 다음과 같은 질문이 있었습니다[ 2 ].


컬렉션 프레임워크( collection framework )에서, 왜 외부 동기화가 내부 동기화( Vector, HashTable 등 )보다 빠른가요? 심지어는 같은 메커니즘을 사용함에도 불구하구요?


정확히 내부 동기화와 외부 동기화의 의미가 무엇이며, 그것들이 왜 다른가요?


누가 예제를 가지고 설명해 주면 정말 도움이 될 것 같네요.


답변에서는 외부 동기화에 대해 다음과 같이 설명하고 있더군요.


외부 동기화는 호출자가 synchronized 키워드나 lock 을 사용해서 다중 스레드에서 접근되는 다른 클래스를 보호하는 것을 의미합니다. 이것은 보통 클래스가 자체적으로 동기화되지 않을 때 사용됩니다.


즉 Vulkan 에서의 "외부 동기화"는 오브젝트의 동기화를 오브젝트 외부에서 보장해야 한다는 것을 의미한다고 볼 수 있습니다. 


예를 들자면, 버퍼 A 는 자체적인 동기화 기능을 가지고 있지 않기 때문에, 쓰기 커맨드가 다른 스레드에서 동시에 실행되지 않도록 외부에서 동기화해 줘야 한다는 겁니다. 또한 생성되면 다시는 수정되지 않는 immutable( non-writable ) 속성의 오브젝트들도 다른 스레드에서 사용중일 때 파괴되지 않도록 외부 동기화를 해 줘야만 합니다.


Internal Synchronization


명세에 의하면 어떤 오브젝트들은 내부 동기화를 사용한다고 합니다. 예를 들면 vkCreateGraphicsPipelines() 과 vkCreateComputePipelines() 에서의 VkPipelineCache 파라미터가 있습니다. 이 경우에는 외부 동기화를 하는게 무겁기 때문에 내부 동기화를 한다고 하는군요.


커맨드 파라미터에 대해서 명시적으로 "외부 동기화된다"고 적혀 있지 않다면, 해당 파라미터들은 내부 동기화를 한다고 합니다.


Implicit External Synchronization


명세에 의하면 동기화해야 할 오브젝트가 한 종류 더 있습니다. 묵시적인 외부 동기화인데요, 이 경우는 커맨드의 파라미터와 연관되어 있는 오브젝트들에 대한 외부 동기화를 의미합니다. 예를 들면 Command Pool 과 Descriptor Pool 이 있습니다.


Which objects are externally synchronized?


이제 내부 동기화와 외부 동기화의 개념에 대해서 알게 되었습니다. 하지만 어떤 오브젝트가 외부 동기화되는지 알 수 있을까요?


기본적으로 외부 동기화되거나 묵시적으로 외부 동기화되는 커맨드나 오브젝트의 리스트는 [ 2.6. Threading Behavior ] 섹션에서 찾아볼 수 있습니다. "Externally Synchronized Parameters", "Externally Synchronized Parameter Lists", "Implicit Externally Synchronized Parameters" 라는 항목들에서 그 리스트를 볼 수 있습니다.



물론 이 리스트를 뽑아 놓고 지금 파라미터가 동기화되어야 하는지 찾아 보는 방법도 있지만, 특정 함수에 대해서 동기화 여부를 찾아 볼 수도 있습니다.


예를 들어 "Externally Synchronized Parameters" 리스트에 있는 vkDestroyInstance() 명세에 가보면 "Host Synchronization" 이라는 항목을 볼 수 있습니다.



만약 동기화가 필요하다면 "Host Synchronization" 항목이 반드시 있으므로 이를 확인하시면 됩니다.


정리


Vulkan 오브젝트들은 일부 오브젝트를 제외하고는 외부 동기화를 요구하게 됩니다. VkPipelineCache 같은 특정 오브젝트를 제외한 대부분의 오브젝트가 자체적인 동기화 기능을 내장하고 있지 않으므로, 반드시 외부에서 동기화를 해 줘야 합니다.


외부 동기화의 실례는 다음 글에서 다루도록 하겠습니다.


참고자료


[ 1 ] What is the difference between internal clock synchronization and external clock synchronization in distributed systems?, stack overflow.


[ 2 ] why external synchronization is faster than internal one?, stack overflow.


[ 3 ] Vulkan Specification 1.1, Khronos Group.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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



VkInstance 를 생성할 때 VkApplicationInfo 라는 것이 필요합니다. 거기에는 ApiVersion 이라는 필드가 있습니다.



명세에서의 설명을 보면 다음과 같습니다.


apiVersion must be the highest version of Vulkan that the application is designed to use, encoded as described in the API Version Numbers and Semantics section. The patch version number specified in apiVersion is ignored when creating an instance object. Only the major and minor version of the instance must match those requested in apiVersion.


- Vulkan specification 1.1.96


이 필드에는 VK_API_VERSION_1_0 이나 VK_API_VERSION_1_1 을 넣게 됩니다. 이것은 다음과 같이 정의되죠.



그리고 VK_MAKE_VERSION 은 다음과 같이 정의되어 있습니다.



여기에서 문제가 하나 발생합니다. 제가 지금 제 로컬 머신에 받아 놓은 LUNARG Vulkan SDK 의 버전은 1.1.85.0 입니다. 그리고 LUNAR XCHANGE 에 가 보면 여러 가지 버전들이 있는 것을 알 수 있죠.



여기에는 버전 번호가 하나 더 있어 헷갈립니다. VK_MAKE_VERSION 은 3 개 항목을 입력으로 받는데요, 여기에는 네 번째 버전이 하나 더 들어 가 있습니다.


대체 이 버전들의 의미는 무엇이고, 왜 패치 버전은 사용하지 않는 것일까요?


Api Version



Vulkan 명세에 의하면, Vulkan API 는 세 개의 버전 정보로 정의되며 32 비트 정수 안에 패킹( packing )됩니다; Major, Minor, Patch.



이것은 명세의 버전과 동일합니다. Vulkan 명세에서는 각 버전에서의 차이점을 다음과 같이 설명하고 있습니다.


  • Major 버전에서는 API 셋들이 변경됩니다. 보통 새로운 기능과 인터페이스가 추가되며, 기능이 변경되기도 합니다. 그리고 기능이 없어지거나 수정되기도 하죠. 즉 하위 버전과의 호환성이 유지되지 않을 수 있습니다. 아직 1.1 밖에 안 된 상황이라 미래에도 이런 일이 벌어질 지는 미지수네요.

  • Minor 버전에서는 새로운 기능이 추가되고, 일부 기능들은 변경되거나 없어질 수 있습니다. 하지만 없어진 기능들을 위한 인터페이스는 그대로 유지됩니다.

  • Patch 버전에서는 명세나 헤더가 조금만 변경됩니다. 보통 버그때문에 변경되는 것으로서 하위 호환성이 유지되어야만 합니다.


그러면 LUNARG SDK 의 버전은 무엇일까요? SDK 버전의 처음 세 개는 Vulkan Api 버전과 동일합니다. 단지 같은 버전에 대해서 다시 패치할 때 마지막 버전을 넣습니다.


$(Major).$(Minor).($Patch).($LUNARG_Patch)


뭐 사람이 하는 일이다보니 실수가 없을 수는 없겠죠. Api 버전은 명세일 뿐이고 실제 구현은 다르기 때문에 이런 버전을 추가한 것으로 보입니다.


Comparing patch versions


그런데 왜 ApiVersion 을 넣을 때는 patch 버전이 무시되는 것일까요?


안타깝게도 Vulkan SDK 에서 배포하고 있는 라이브러리들은 이름에 버전정보를 담고 있지 않습니다. 그리고 헤더에다가도 버전 정보를 담고 있지 않죠; 예를 들어 "vulkan-1-1-85.lib" 같은 라이브러리나 "VkCreateDevice_1_1_85()" 같은 함수는 없습니다. 아마도 Dll Hell 을 피하기 위해 항상 가장 높은 버전을 사용하도록 하고 있는 것으로 보입니다. 심지어는 라이브러리 이름조차 major 버전만을 사용합니다; "vulkan-1.lib".


그러므로 VK_MAKE_VERSION 에서는 패치 번호가 무시됩니다. 


제가 처음에 Api Version 을 입력할 때는 이걸 모르고 있었습니다. Api Version 에 patch 버전을 넣을 수 있길래 버전별로 그 결과를 비교할 수 있겠다 싶어서, 다음과 같이 만들었는데 쓸데 없는 짓이었군요.



만약 LUNARG SDK 의 1.1.85.0 버전에서 발생하던 버그가 1.1.92.1 버전에서 해결되었는지 알고자 한다면, 두 개의 SDK 를 모두 깔아 놓고 "VULKAN_SDK", "VK_SDK_PATH" 등의 환경변수를 변경해 가면서 테스트하는 수밖에 없습니다.


환경변수 설정을 batch 파일 등을 통해서 동적으로 제어할 수 있으면, 결과를 비교하는 더 나은 개발 환경을 만들 수는 있을 것 같습니다.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 


 

Memory 관련해서 정리하다가 의문을 하나 가지게 되었습니다.

 

DEVICE_LOCAL 을 지정하면 디바이스 램에 메모리가 할당됩니다. 하지만 allocator 를 사용해서 메모리를 할당하면 어떻게 될까요?

 

먼저 간단하게 malloc 을 사용하는 allocator 용 콜백 함수들을 만들었습니다.

 

 

그리고 나서 allocator 에 할당합니다.

 

 

그리고 1000 개의 이미지를 생성해서 메모리를 할당해 줬습니다. 이 때 앞에서 만든 allocator 를 사용했습니다.

 

 

여러분은 어떤 결과가 나올 거라 예상하시나요? 저는 호스트 램의 메모리가 올라갈 것이라 예상했습니다. 왜냐하면 malloc 은 최종적으로 VirtualAlloc 과 연결되어 있고 호스트 램을 위한 메모리를 할당할 것이라 생각했기 때문이죠. 또한 호스트 메모리 중에서 공유 메모리를 사용할 것이라 생각했습니다.

 

하지만 결과는 충격적( ? )이었습니다. texture 프로세스는 1.6 GB 의 디바이스 메모리를 할당합니다. 제가 캡쳐하는 시점에 다른 응용프로그램이 디바이스 메모리를 사용했는지 수치가 좀 높네요. 실제 수치는 크게 신경쓰지 않으셔도 됩니다. 어디에서 메모리가 늘어나고 있는지가 중요하죠.

 

 

 

리소스 모니터를 보면 좀 다른 결과이긴 하더군요.

 

 

이러한 결과를 볼 때 다음과 같은 가설을 세울 수 있습니다.

 

  • malloc 은 호출되는 문맥에 따라서 호스트 램에 메모리를 할당하기도 하고 디바이스 램에 메모리를 할당하기도 합니다. CUDA 버전 malloc 함수가 따로 있는 이유가 여기에 기반하는 것 같네요.
  • "커밋 크기"는 램의 종류에 상관없이 프로세스가 사용한 메모리의 총량을 보여줍니다.

 

이건 제가 의도한 환경이 아닙니다. 그래서 이미 할당된 메모리를 반환하는 allocator 를 만들어 보기로 했습니다. Reallocation  Free 는 별 의미가 없으므로 대충 넘겼습니다. 메모리가 어디에 할당되는지 확인하는 용도로 만든 것이므로 그냥 Allocation 만 구현했습니다.

 

그래서 처음에 2 GB 를 할당하고 요청이 올 때마다 메모리를 증가시켜 그 주소를 반환할 겁니다.

 

 

실행해 보니 역시나 충격적인 결과를 줍니다. CPU 와 GPU 의 메모리 usage 는 동일한데, 리소스 모니터의 "커밋" 만 다른 결과를 줍니다.

 

 

제가 할당한 2 GB 만 고스란히 늘어났습니다. 즉 DEVICE_LOCAL 을 지정한 메모리를 할당할 때 호스트 메모리를 반환하면, 그것이 무시되고 자체적으로 메모리를 할당한다고 추측해 볼 수 있습니다. Allocator 를 사용하는 의미가 없는거죠.

 

정리

 

DEVICE_LOCAL 을 위한 메모리에 대해서 이미 할당된 메모리를 관리하는 allocator 를 사용하면 메모리가 늘어납니다. 그러므로 HOST_VISIBLE 이나 HOST 전용 메모리( 아무 속성도 지정되지 않은 것 )에 대해서만 allocator 를 사용하시기 바랍니다.

 

이런 관리를 하는 건 매우 귀찮은 일이기 때문에, allocator 를 사용하고 싶으시다면 GPU Open 에서 제공하는 "Vulkan Memory Allocator" 를 사용하는 것을 추천합니다. 헤더 파일 하나만 include 해서 사용할 수 있는 편리한 라이브러리입니다.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



독립적인 Video Card 를 장착한 일반적인 PC 에서의 메모리는 다음과 같이 구성되어 있습니다.



Windows 10 의 작업관리자의 성능탭을 보면 다음과 같이 그 정보가 나와 있습니다.



여기에서 공유 메모리는 호스트 램에 할당될 수 있는 메모리를 의미합니다. 워낙 디바이스 램의 크기가 작다보니 나온 방식이죠. 어쨌든 메모리 속성은 다음과 같이 매핑될 수 있습니다.



VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT 를 지정하면 GPU 전용 메모리에 할당되고, VK_PROEPRTY_HOST_VISIBLE_BIT 를 지정하면 공유 메모리에 할당됩니다. 아무 것도 지정하지 않는다면 CPU 전용 메모리가 되겠죠. 물론 거의 사용되지 않긴 합니다. HOST_VISIBLE 인 경우에는 항상 HOST_COHERENT 이거나 HOST_CACHED 여야만 합니다. 


일반적으로 이미지는 DEVICE_LOCAL 로 생성되고, 버퍼는 HOST_VISIBLE 로 생성되는 것 같습니다. 그리고 버퍼의 경우에는 HOST_VISIBLE 과 함께 HOST_COHERENT 를 사용하고 있는 것 같습니다. 아마도 버퍼에 대한 쓰기 연산이 많기 때문인 것으로 보입니다.


샘플의 "texture" 프로젝트에서는 텍스쳐를 생성해서 메모리를 바인딩합니다.



DEVICE_LOCAL 을 지정했으므로 이것은 GPU 전용 메모리에 할당될 것이라 예측할 수 있습니다. 실제로도 그렇죠.



그럼 이 텍스쳐를 공유 메모리에 할당하는 것도 가능할까요? 속성을 HOST_VISIBLE | HOST_COHERENT 로 변경해 봤습니다.



하지만 이렇게 하면 일치하는 메모리 타입을 찾는데 실패해서 크래시가 납니다( 위의 코드에서 getMemoryType() 호출 ). 


하지만 아직까지 메모리 타입과 힙에 대해서 제대로 이해하지 못했기 때문에 에러가 나도 뭘 봐야할지 갑갑하기만 할 것입니다. 그래서 이 부분에 대해서 좀 더 이해할 필요가 있습니다.


vkGetImageMemoryRequirements() 는 이미지를 위한 메모리 요구사항을 반환합니다.



Memory Management Basics ] 에서 언급했듯이, Vulkan 은 오브젝트 생성과 메모리 할당을 분리하고 있습니다. 그래서 두 번째 인자인 image 에는 이미지의 해상도나 포맷 정보가 포함되어 있죠. 그러면 vkGetImageMemoryRequirements() 는 이를 가져가서 세 번째 인자에다가 적절한 요구사항을 채워 줍니다.



  • size 는 이미지를 생성하는데 필요한 바이트 단위 메모리 크기입니다.
  • alignment 는 이미지를 생성하는 데 필요한 메모리가 몇 바이트 단위에서 정렬되어 있어야 하는지를 의미합니다.
  • memoryTypeBits 는 이미지를 위해 지원되는 메모리 타입들에 대한 비트마스크입니다. i 번째 비트는 VkPhysicalDeviceMemoryProperties 의 i 번째 메모리 타입을 의미합니다.


제가 vkGetImageMemoryRequirements() 를 호출했을 때 다음과 같은 결과를 획득했습니다.



이 이미지를 지원하는 메모리 타입은 1 번과 7 번이라는 의미가 됩니다( 1 번과 7 번 비트가 1 이기 때문입니다 ). 


CapsViewer 를 열어 보도록 하겠습니다. CapsViewer 의 Memory 탭의 내용은 메모리 타입과 힙에 대한 정보를 담고 있습니다. 


스크롤을 마지막까지 내리면 힙 정보가 있죠. 이는 VkPhysicalDeviceMemoryProperties 의 memoryHeaps 에 정의되어 있는 정보를 보여줍니다.



두 개의 힙이 존재하는 것을 보실 수 있습니다. 0 번 힙에는 HEAP_DEVICE_LOCAL 로 지정되어 있고, 1 번 힙에는 HEAP_DEVICE_LOCAL 이 지정되어 있지 않습니다. 이제 스크롤을 위로 올려 보면, 각 메모리 타입에 대한 정의를 볼 수 있습니다. 이는 VkPhysicalDeviceMemoryProperties 의 memoryTypes 에 정의되어 있는 정보를 보여줍니다.


예상하고 계시겠지만 HeapIndex 가 0 이라면 DEVICE_LOCAL 만 활성화될 수 있고, 1 이라면 DEVICE_LOCAL 은 절대 활성화될 수 없겠죠. 



아까 위에서 이미지를 위해서 1 번과 7 번 메모리 타입이 지원된다고 했었죠( 10000010 )? 1 번 메모리 타입은 순수하게 호스트를 위해서만 사용될 수 있습니다. 그러므로 HOST_VISIBLE | HOST_COHERENT 를 지정할 수가 없죠. 


7 번은 아래와 같이 DEVICE_LOCAL 로만 사용할 수 있는 메모리 타입입니다.



그러므로 이 이미지를 위한 메모리는 순수하게 GPU-Only 이거나 CPU-Only 여야 한다는 것을 알 수 있습니다.


어떤 리소스가 어떤 메모리 타입을 사용할 수 있느냐는 리소스를 생성할 때 사용한 정보에 의존합니다. 이 경우에는 VkImageCreateInfo 의 내용에 의존하겠죠. 아직까지 정확하게 어떤 항목들이 영향을 주는지는 잘 모르겠지만, 아마도 usage 가 가장 큰 영향을 주지 않을까 생각하고 있습니다.


정리


리소스의 메모리는 힙에 할당됩니다. 그 힙은 크게 HEAP_DEVICE_LOCAL 플래그를 포함하는 힙과 그렇지 않은 힙으로 나뉩니다. 어떤 힙을 사용하느냐에 따라서 메모리 타입이 결정이 되는데요, 특정 리소스는 그것이 사용할 수 있는 메모리 타입의 종류가 정해져 있습니다.


힙과 메모리 타입의 종류는 VkPhysicalDeviceMemoryProperties 에 저장되어 있으며, CapsViewer 에서 일목요연하게 확인하실 수 있습니다.

주의 : 허락받고 번역한 것이 아니므로 언제든 내려갈 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참조하세요.

원문 : Color Fundamental: Shading



우리는 컬러를 모든 머티리얼( 재질, material )에 대한 속성으로, 빛을 그것을 변경할 수 있는 요소로 이해하는 경향이 있습니다. 토마토는 붉은색이고 풀은 녹색이고 빛은 그냥 그것을 밝거나 어둡게 만듭니다. 맞을까요? 아닙니다.


컬러는 영원하게 존재하는 것이 아닙니다 - 그것은 우리의 비전( vision ) 메커니즘의 효과이며, 이는 빛에 의해서 공급됩니다. 빛이 없으면 색상도 없습니다. 여러분은 어두울 때 이를 쉽게 인지할 수 있습니다. 어두움이 색상을 다루는 것이 아닙니다 - 그것들을 생성하는 것은 빛입니다! 만약 이게 여러분에게 혁명적으로 들린다면, 계속 읽어 보시기 바랍니다 - 아티스트에게 있어서 그것을 이해하는 것보다 중요한 것은 없습니다. 또한 이 문서를 보기 전에 시리즈의 첫 번째 기사를 읽어 보세요.


What is Color?


약간 물리적인 버전을 살펴 보죠. 걱정하지 마세요. 저는 가능한한 쉽게 설명할 겁니다! 어떤 오브젝트들은 방사선( radiation )을 방출할 수 있습니다. 이것이 오브젝트가 다양한 방향으로 파티클 뭉치( bunch of particle)( 혹은 파동( wave ) )를 던진다는 것을 의미합니다. 빛은 일종의 복사에너지이며, 모든 광원은 포톤( phton )을 방출합니다.



포톤들은 다양한 파장( wavelength )( 여기에서는 x, y, z )으로 결합된 파동( wave )들입니다.



우리는 광원과 특정 방향 사이에서 포톤이 날아가는 길을 레이( ray )라 부를 겁니다.



Those were a couple of facts. 그런데 인간이라는 요소가 관여하면 무슨 일이 발생할까요? 우리 주변에는 많은 방사선들이 존재합니다. 하지만 우리의 눈은 특정 영역의 파장에만 특별히 반응을 하죠. 예를 들어, 우리는 파장이 그 영역에 들어 오기 전까지는 열을 볼 수 없습니다( 빨갛고 뜨거운 금속이 갑자기 광원이 됩니다 ). 우리가 볼 수 있는 전자기 방사선( electromagnetic radiation )의 일부를 가시 광선( visible light )라 부르며, 보통 이것을 그냥 빛이라 부릅니다.


우리는 이 시리즈의 첫 번째 기사에서 이에 대해 짧게 언급했었지만, 이제 세부사항을 더해 보도록 합시다. 우리 눈에는 두 종류의 광수용기 세포( photoreceptor cell )가 존재합니다; 원추( cone ) 세포와 간상( rod ) 세포입니다. 레이가 그 세포를 때리면, 그것들이 반응해서 어떤 정보를 뇌에 전달하게 됩니다.



간상 세포는 매우 빛에 민감하며 야간 시력( night vision, 암시 )에 간여합니다. 움직임과 형태를 보게 됩니다. 원추 세포는 반대이며, 우리에게 더욱 흥미로운 것입니다. 그것들은 파동을 특정 파장으로 나눌 수 있는데, 뇌는 그것을 ( 대충 ) 레드( 긴 파장 ), 그린( 중간 파장 ), 블루( 짧은 파장 )으로 해석합니다. 레이가 어떤 파장으로 구성되어 있느냐에 따라, 우리는 세 가지로 혼합된 색상을 인지하게 되는 겁니다.



하지만 만약 그것들이 같은 광원으로부터 온 것이라면, 다양한 파장들은 어디에서 온 걸까요? 대부분의 레이들은 어떤 오브젝트를 때리고 나서, 어딘가로 반사되고 있습니다( 예를 들어 여러분의 눈으로 ). 보통 그것들이 때리는 오브젝트들은 그것들을 완벽하게 거울처럼 반사하지는 않습니다. 파장들의 일부는 오브젝트에 의해서 흡수되고 있으며, 그것들은 여러분의 눈으로 절대 도달하지 않습니다. 결과적으로, 우리는 원래 레이의 일부만을 오브젝트로부터 받고 있는 겁니다. 이것들은 레이에 남아 있게 되고, 여러분의 뇌에 의해 오브젝트의 컬러로 해석됩니다. 다양한 컬러들이 머티리얼들의 흡수 및 반사 속성으로부터 오게 됩니다.



여러분은 아마도 페인팅을 할 때 그게 전부인지 궁금할 것입니다. 컬러만 사용해서 페인팅을 한다면, 물리적으로 그린 것이 아니게 됩니다. 몇 초 안에 모든 것이 명확해질 것이라 생각합니다.


Hue, Saturation, Brightness


이것보다 헷갈리는게 있을까요? 색상( hue ), 채도( saturation ), 명도( brightness )가 무엇인지 직감적으로 이해하고 있을 것입니다. 하지만 페인팅을 시작하게 되면, 그것들을 어떻게 사용해야 할지 추측하기가 어렵습니다. 색상은 글쎄요 컬러입니다. 맞나요? 채도는 선명함... 그리고 명도는 무엇인가가 밝거나 어둡거나 하는 것을 알려 줍니다. 하지만 그건 단지 페인팅이 완료된 그림을 보고 이야기할 때나 말이 됩니다. 그리고 여러분이 직접 그것을 어디에 배치해야 하는지 추측하는 것은 훨씬 더 어렵습니다. 하지만, 우리에게 필요한 것은 이런 값들이 실제로 어디에서 나오는지를 이해하는 것입니다.



The Definition of Hue


색상은 컬러의 "유형( type )"입니다. 레드( red ), 퍼플( purple ), 올리브( olive ), 크림슨( crimson )들은 모두 색상입니다. 그것들은 방금 위에서 이야기한 메커니즘에 기반하고 있습니다 - 반사된 파장들의 다양한 비율의 조합은 뇌에서 해석되는 최종 컬러를 생성합니다. 그러므로 간단하게 이야기하면, 색상은 "오브젝트의 컬러"에 기반합니다. 재밌는 사실: 실버( silver ), 골드( gold ), 브라운( brown )은 색상이 아닙니다. 실버는 빛나는 그레이( gray )이며, 골드는 빛나는 옐로우( yellow )입니다, 그리고 브라운은 어둡거나 채도가 낮은 오렌지( orange )입니다.



색상을 위해서 얼마나 많은 이름들을 개발하든지 간에, 그것들은 모두 레드, 그린, 블루에 기반합니다. The further on the color wheel you are from any of them, the more "original" color you'll get. 예를 들어 50% 레드 + 50% 그린은 옐로우지만, 이 비율을 약간 변경하면, 그리니시( greenish ) 혹은 레디시( reddish ) 색조를 보게 될 것입니다.



There's no greater of lesser hue, being put on a wheel they're all equal. 그러므로 우리는 퍼센트( percent ) 값보다는 디그리( degree ) 값을 사용해 그것들을 기술하게 됩니다( 역주 : 어떤 비율에 의해서 색상이 결정되는 것이 아니라, 각각이 얼마나 존재하느냐가 색상을 결정하고 있으므로, 각 파장끼리의 퍼센티지로 색상을 결정하는 것이 아니라, 각각의 파장이 얼마나 들어 있느냐를 기준으로 색상을 결정한다는 의미인 듯 ).


The Definition of Saturation


색상은 컬러를 의미하는 것은 아닙니다( 적어도 공식적으로는 그렇지 않습니다 )( 역주 : 색상, 명도, 채도가 합쳐져서 컬러라고 이야기하는 듯 ). 아래에 있는 모든 원들은 같은 색상값을 가지며, 컬러 휠에서 정확하게 같은 위치에 존재합니다( 명도 역시 동일합니다 ). 그런데 왜 그것들이 다른 컬러로 인지되는 걸까요?



채도의 일반적인 정의는 색상에 얼마나 많은 흰색이 섞였느냐입니다. 하지만 잠깐요, 그건 명도 아닌가요? 여러분은 더 밝은 컬러를 원하면, 그것을 하얗게 만듭니다... 하지만 이는 더 어두운 영역의 채도를 낮아지게 만듭니다( 역주 : 위의 그림에서 어둡게 보이는 것이 채도가 낮다는 의미인듯 ). 많이 헷갈리시죠? 그게 우리가 더 많은 설명을 필요로 하는 이유입니다.



채도는 주요 컬러( dominance of color )입니다. 아래의 세 가지 샘플들은 같은 명도와 색상을 가지고 있습니다. 달라진건 컴포넌트( component, 요소 )( 역주 : 여기에서는 파장을 의미 )들 사이의 비율뿐입니다. 우리는 "흰색을 더하지" 않습니다 - 컴포넌트 사이의 거리를 줄이고 있으며, 그래서 그 중에 어떤 것도 튀지 않게 만듭니다.



여러분이 추측할 수 있듯이, 컴포넌트 사이의 차이가 줄어들면, 채도를 잃게 되며, 흰색이 됩니다( 아직 명도를 포함시키지 않았습니다 ).



The Definition of Brightness


필요에 의해, 앞에서 했던 것과 유사하게 명도를 조절할 수 있습니다. 그것은 우리 눈이 인지할 수 있는 최대값을 정의합니다. 100% 블루보다 더 블루인 것은 없습니다. 100% 화이트보다 더 밝은 것은 없는 것과 같습니다.



이 바( bar )들은 최대치를 넘어서 채워질 수 없습니다:



그리고, 명확히, 블랙( black )은 정보가 부족해지는 상태가 됩니다.



재밌는 사실: 어두울 때, 우리의 원추 세포들은 적은 정보를 받게 되는데요, 이는 우리를 약간 색맹으로 만듭니다. 이 시점에 모든 빛에 대해서 민감해지는 간상 세포들이 ( 역주 : 빛을 ) 인계받게 됩니다. 그러나, 그것들은 그린-블루에 가장 민감하기 때문에, 그린-블루인 오브젝트가 더 밝게 보이게 만들 겁니다. 이것을 푸르키네 효과( Purkinje effect )라 부릅니다.


Luminance


절대적인 명도를 가지고 있음에도 불구하고, 모든 컬러는 다른 속성인 휘도( luminance )를 가집니다. 명도는 우리에게 얼마나 많은 컬러( 역주 : 아마도 색상을 잘못 쓴 듯 )가 그 컬러 안에 존재하는지를 알려주는 반면에, 어떤 색상들은 우리에게 더 밝게 보입니다 - 심지어 100% 밝기일 때도 그렇습니다. 휘도는 밝은 컬러가 얼마나 화이트에 대해 상대적인가에 대한 것입니다.


우리가 100% 만큼 밝은 주요 컬러를 그레이스케일( grayscale, 흑백 )로 바꾸면, 그것들의 명도는 갑자기 떨어지게 됩니다. 그것들은 여전히 화이트이지만, 블루는 매우, 매우 어둡게 바뀌고, 그린은 다른 것들보다 밝습니다. 이는 모든 원추 세포의 개별적인 민감성으로부터 발생하며, 우리가 옐로우( 밝은 레드 + 매우 어두운 그린 )을 가장 밝은 색으로 인지하게 되는 이유이며, 사이언( cyan )( 어두운 블루 + 매우 밝은 그린 )이 종종 라이트 블루( light blue )라 불리는 이유입니다. 휘도는 여러분이 그레이스케일로 그림을 시작할 때 중요합니다 - 예를 들어, 옐로우는 같은 절대적인 명도를 가진 다른 컬러들보다 더 밝은 베이스( base )를 필요로 합니다.



HSB Model


하지만 여전히 약간 헷갈릴 겁니다. 사실, 우리는 컬러를 주의깊게 만들지 않습니다. 너무 시간이 오래 걸려요! 다행히도, 색상, 채도, 명도는 매우 유용한 도구로 조합될 수 있습니다. 아래의 구성을 살펴 봅시다 - 여러분은 컬러 사이의 명확한 관계를 확인할 수 있습니다. 이걸 쓰지 않을 이유가 뭐가 있을까요?



만약 디지털 페인터를 사용한다면, 이것들은 여러분에게 친숙할 것입니다. 그건 색상, 채도, 명도를 하나로 합치는 방법이며, HSB( 역주 : Hue, Saturation, Brightness ) 라 불리는 일관된 모델입니다. 어떻게 동작하는 것일까요?



일단 색상, 채도, 명도에 대해서 알았기 때문에, 그것들을 모델에 배치하는 것은 쉽습니다. 색상환( Hue wheel )( 혹은 바여도 상관없습니다 )은 SB 사각/삼각형에 대해 독립적이며 상위개념( superior )입니다. 모든 색상은 일정 범위의 채도와 명도를 보유하고 있으며, 이 두 값들은 서로에게 연결되어 있습니다. 둘 다 특정 색상의 "풍부함( richness )" 혹은 "다채로움( colorfulness )" 을 정의합니다.



SB 모델은 서로 다른 속성들에 대한 영역으로 나뉠 수 있습니다. 만약 여러분이 광학적으로 적절한 컬러를 고르는 것을 배운다면, 여러분은 채도나 명도의 특정 값들에 대해서 알 필요가 없어질 것입니다 - 그것은 자연스럽게 빠른 페인팅을 위해 매우 유용합니다.



사각형이 더 직관적이기는 하지만, 개인적으로는 삼각형을 선호합니다. 그것은 채도나 명도를 개별적으로 제어하는 것이 아니라 전체적인 "풍부함"을 제어할 수 있도록 해 줍니다( 개별적인 조정을 위해서는 슬라이더를 사용합니다! ). 만약 여러분이 저같다면, 포토샵에서 항상 열려 있는 색상환을 이용할 수 있습니다. Painters Wheel 을 확인해 보세요.


CMY and RGB


그런데 전통적인 화가들은 어떨까요? 그들은 정돈된 슬라이더를 가진 편리한 색상환을 가지고 있지 않습니다. 어떻게 안료( pigment )의 색상, 채도, 명도를 조절할 수 있을까요?


먼저, 디지털 페인팅과 전통적 페인팅의 차이를 생각해 봅시다. 둘다 컬러를 사용합니다. 그렇죠? 문제는 디지털 페인팅이 다채로운 광원들을 사용해 우리 눈으로 바로 들어 오는 가장 완벽한 컬러들을 생성하고 있다는 것입니다. 반면에 전통적 페인팅에서는 안료로부터 반사되는 빛이 제한적입니다. 그려진 것과 여러분이 실제로 보는 것 사이에서 중개자( middleman )를 사용하는 것과 같습니다! 우리는 어떤 매체( medium )가 더 예술적인지 토론할 수 있지만, 디지털 페인팅이 더욱 비전 메커니즘과 어울린다는 것은 의심의 여지가 없습니다.


그래서, 전통적으로 그리기 위해서는 안료가 필요합니다. 그것들은 스스로 컬러를 방출하지 않는 대신에 그것들을 때리는 빛의 일부를 흡수하고, 그것들의 이름에 맞게 파장을 반사합니다. 예를 들어 레드 페인트는 그린과 블루를 흡수하고 레드만 반사합니다.



문제는 그것이 방출하는 것과 정확히 같은 빛을 반사하는 완벽한 안료를 만드는 것이 불가능하다는 데 있습니다. 예를 들면, "블루" 원추 세포만 시뮬레이션 할 수 없습니다. CMY( 역주 : Cyan, Magenta, Yellow ) 는 일종의 약속입니다: 사이언은 레드를 반사하지 않고, 마젠타는 그린을 반사하지 않으며, 옐로우는 블루를 반사하지 않습니다. 그래서 우리가 "블루" 원추 세포를 시뮬레이션하기를 원한다면, 사이언과 마젠타를 섞을 필요가 있습니다 - 이 안료는 가능한한 레드와 그린을 적게 반사하게 될 것입니다. "K" 는 CMY 에 추가된 블랙입니다. 왜냐하면 그 컴포넌트들은 완벽하지 않아서 같은 비율로 섞여 있다고 해도 순수한 블랙을 만들어낼 수 없기 때문입니다.



RGB 는 가산적( additive )입니다 - 더 많은 값을 더할수록, 더 밝은 컬러를 얻게 됩니다. CMY 는 감산적( subtractive )입니다 - 더 적은 값을 더할수록, 더 밝은 컬러를 얻게 됩니다.



Four Rules of Color Mixing


'

Rule 1 - Hue Mixing


두 색상을 혼합하면, 비율에 따라 그것들 사이의 색상을 얻을 수 있습니다. 이것은 가산혼합일 수도 있고 감산혼합일 수도 있습니다.



Rule 2 - Complementary Hue Mixing


여러분은 아마도 보색에 대해서 들어 보셨을 겁니다. 그것들은 색상환에서 반대쪽에 위치한 색상들을 의미합니다. ( 두 색상이 같은 명도를 가질 때 )그것들 사이의 대비( contrast )는 블랙과 화이트 사이처럼 두드러집니다. 그러나 그것들이 혼합되면, 서로를 중화시켜버립니다.



보색을 혼합하는 것은 중립성( 그레이 혹은 그레이시( grayish ) )을 제공합니다. 100% 명도의 보색들에 대한 가산 혼합은 화이트를 반환할 것이며 감산 혼합은 블랙을 반환할 것입니다.



감산 기법에서, 보색의 일부를 더하는 것은 정확하게 채도를 줄이는 가장 쉬운 방법입니다.



Rule 3 - Saturation Mixing


감산 혼합과 가산 혼합에서, 컴포넌트들 사이의 비율은 동일합니다. 그리고 결과적으로 채도가 감소됩니다.



Rule 4 - Brightness Mixing


가산 혼합은 더 밝은 컬러를 산출하고, 감산 혼합은 컴포넌트들 중 더 밝은 것보다 더 어두운 컬러를 산출합니다.



Color Temperature


색상환을 따뜻한 절반과 차가운 절반으로 나누는 전통은 매우 확고합니다. 우리는 따뜻한 컬러들은 활동적이고 친근한 반면에 차가운 컬러들은 수동적이고 격식이 있다고 알고 있습니다. 모든 책들이 컬러 심리학과 관련해서 작성되었지만, 문제는 그것이 목적성있는 분류가 아니라는 것에 있습니다. 가장 따뜻한 컬러는 무엇일까요? 레드? 옐로우? 퍼플은 따뜻합니까 차갑습니까? 그리고 정확하게 어디에 경계가 존재하는 건가요?



아래 그림을 보죠. 그것들은 모두 레드이며, 이론적으로는 항상 따뜻합니다. 그러면 그것들 중 일부는 왜 다른 것보다 차가워 보일까요? 이는 대비에 대한 것입니다. 컬러는 따뜻하지도 차갑지도 않습니다. 단지 더 따뜻해 보이고 더 차가워 보일 뿐입니다. 색상환은 그래서 가시적으로 그것을 나누는 게 쉽습니다. 왜냐하면 이 모든 컬러들이 한데 모아져 있고 비교하기 쉽기 때문입니다. 레드만 떼어 내서 보죠. 그것은 더 이상 따뜻하지도 차갑지도 않습니다. 그냥 레드입니다.



그런데, 어떻게 더 따뜻한 혹은 더 차가운 컬러를 만들까요? 색상환 위의 모든 색상들은 이웃을 가집니다. 이 이웃들은 우리의 샘플보다 항상 더 차갑거나 더 따뜻합니다( 만약 확신이 들지 않으면, 그것들의 이웃들 역시 살펴 보세요 ). 샘플보다 더 차가운 컬러를 생성하려면, 차가운 이웃쪽으로 이동해 보세요.



The Basic Rules of Shading


더 일찍 다뤘어야 했다구요? 저에게 잠시 시간을 할애해 주세요. 여러분은 이 긴 소개가 전체 과정을 이해하는 데 있어서 필요하다는 것을 알게 될 것입니다. 만약 여러분이 규칙들만을 기억하고 있다면, 스스로를 특정 상황에 국한시키는 것입니다. 하지만 그것들이 어떻게 만들어지는 지를 이해한다면, 제한이 없어질 것입니다.


The Local Color


어떤 광원에 의해서도 빛을 받지 않는 공통 베이스 컬러는 로컬 컬러라 불립니다. 우리는 이미 빛을 받지 않는 오브젝트는 어떤 컬러도 가질 수 없다는 것을 알고 있습니다. 그러므로 더 나은 정의는 빛이나 그림자에 의해서 강하게 영향받지 않는 컬러입니다. 그래서 체리의 로컬 컬러는 레드입니다. 심지어는 그것이 강한 오렌지 빛을 한 쪽에서 받고 다른 쪽에서 반사된 블루 빛을 받았다고 하더라도 말이죠. 로컬 컬러는 여러분이 그림을 그리기 시작할 곳입니다.


로컬 컬러의 채도와 명도는 어떻게 될까요? 명도는 여러분이 씬을 시작할 때 사용하는 가상의 산란된 빛( imaginary scattered light )에 의해 정의됩니다. 씬의 전반적인 명도( 산란된 빛의 세기 )를 정의하기 위해서는, 오브젝트를 화이트 시트에 배치하세요. 그것들은 모두 같은 빛에 의해 비춰지고, 같은 환경 하에서 그 오브젝트는 화이트 시트보다 더 밝을 수가 없습니다.



설명은 단순합니다 - 화이트 시트는 100% 의 빛을 반사합니다. 만약 오브젝트가 그것보다 밝다면, 그것은 오브젝트가 100% 의 빛보다 더 많은 빛을 반사한다는 의미가 됩니다( 그러면 그것은 형광물질이거나 스스로 빛을 방출하는 거겠죠 ). 이는 모두 대비에 관한 것입니다. 그러므로 더 어두운 부분은 여러분의 베이스 라이팅이 됩니다. 여러분은 나중에 더 튀는 광원을 추가할 수 있습니다.



채도는 어떨까요? 명도가 빛의 세기에 대한 것인 반면에, 채도는 컴포넌트들 사이의 비율에서 나옵니다. 이 비율은 빛의 세기가 변경되고 있는 동안에도 동일하게 유지됩니다( 나중에 자그마한 예외에 대해서 이야기할 것입니다 ). It's like adding more water with every teaspoon of sugar - the drink is not going to become any sweeter!




The Direct Light Source


첫 번째 기사에서 언급했던 빛의 영역에 대해서 빠르게 되돌이켜 보도록 하죠:



잘 정의되어 잇는 빛에 의해 비춰지지 않는 단순한 씬에서 시작해 보죠. 바닥은 그린이며, 공은 레드이고, 스카이는... 지금은 신경쓰지 마세요. 만약 배경이 매우 멀리 있다고 하면, 그것은 우리 오브젝트에 영향을 주지 않습니다. 우리는 당장은 직사광선이 없이 명도와 채도를 선택했고, 그것은 평평한 이차원으로 보입니다. 이것이 그것을 플랫 컬러( flat color )라 부르는 이유이며, 페인팅의 가장 쉬운 부분입니다.



광원이 생기면, 그것은 모든 씬으로 퍼져 나갑니다. 그것의 세기 - 명도 - 는 빛이 오브젝트와 직접적으로 만나는 곳에서 가장 높습니다( 풀 라이트( full light ), 하프 라이트( half light ) ). 그리고 빛이 닿지 못하는 곳에서 가장 낮습니다( 코어 섀도우( core shadow ), 캐스트 섀도우( cast shadow ) ). 로컬 컬러는 이제 끝났습니다.




공을 바닥에 붙어 있게 하기 위해서, 우리는 크레비스 섀도우( crevice shadow, 틈새 그림자 )를 추가할 필요가 있습니다 - 그곳은 빛이 도달하지 못하는 곳입니다. 그곳이 그림에서 가장 어두운 영역입니다.



문제는 씬이 여전히... 가짜같아 보인다는데 있습니다. 너무 컬러풀하고, 명랑합니다. 마치 어린이 동화책에 나오는 것 같네요. 그러나 뭔가가 잘못되었습니다... 만약 여러분이 첫 번째 기사를 주의깊게 읽어 보셨다면, 여기에는 디퓨트 리플렉션( diffuse reflection )만 사용했다는 것을 알아챘을 겁니다. 공을 때리는 모든 단일 레이는 일부는 흡수되고 레드만 반사합니다. 그러므로 최대 명도의 영역에서, 우리는 100% 레드를 획득하며 그것을 바꿀 방법은 없습니다! 이것은 매트( 무광, matte ) 머티리얼의 매우 자연스러운 상태이며, 채도를 줄이면 "더 밝은" 레드를 획득하는 것은 실수입니다.


이게 자연스럽다면, 왜 가짜같아 보일까요? 그 이유는 자연상에 완전한 무광 머티리얼이 존재하지 않기 때문입니다. 거의 대부분은 적어도 조금이라도 스펙큘러 리플렉션( specular reflection )을 반사합니다. 그리고 그것은 너무 반짝거릴 필요는 없습니다 - 보통 그것들은 매우 부드럽고 사소합니다. 여러분과 가까운 어떤 오브젝트를 바라보고 있을 때, 위치를 변경해 보세요 - 만약 그것의 "컬러들"이 여러분의 움직임을 따라서 ( 조금이라도! ) 변한다면, 그것들은 스펙큘러 리플렉션의 효과입니다. 여러분의 위치와 상관없는 것들은 디퓨즈 리플렉션에서 옵니다.


이전에 배웠듯이 스펙큘러 리플렉션은 광원의 리플렉션입니다. 그것이 세질수록 오브젝트 상에서 광원의 모양이 선명해집니다. 여기에서 가장 큰 역할은 머티리얼의 스펙큘러와 디큐즈 속성의 비율을 조정하는 것입니다. 매우 매끈한 오브젝트들은 보통 얇은 투명층을 가지고 있으며, 그것들 상에서 강한 스페큘러 머티리얼을 가집니다. 그래서 두 개의 리플렉션이 섞이지 않습니다( 세 번째 공 ).



밝은 영역의 채도를 감소시킨다고( 그것에 "화이트를 추가" ), 그것을 밝게 만드는 것이 아닙니다 - 여러분은 "광택( gloss )를 추가"하고 있는 겁니다.


하지만, 위의 공들은 여전히 가짜같습니다! ( so many ways to paint fake colors, huh? ). 이번에는 그것들이 3D 모델링같아 보입니다. 그 이유는 중립적인 화이트 빛은 자연상에서 발생하지 않기 때문입니다. 태양빛은 여러분의 눈에 들어 오기 전에 대기층을 통해 쪼개질 필요가 있습니다. 이전의 기사에서 무슨 일이 발생한 건지 설명했었습니다, so let's just add color to this mechanism.


짧은 파장 및 중간 파장은 쉽게 산란되고 있습니다. 대기를 통해서 그것들이 오랫 시간 동안 이동할 수록, 그것들은 자신의 길을 더욱 벗어나게 되며, 우리의 눈에 도달하지 않습니다( 적어도 초기의 방향에서 들어 오지는 않습니다 ). 그러므로, "화이트" 레이는 거의 레드나 그린이 되며, 심지어는 가장 태양이 높이 떠 있을 때도 블루가 부족합니다 - 태양은 따뜻합니다.



그런데 왜 따뜻한 광원의 리플렉션은 중립적으로 화이트여야 할까요? 가짜 3D 효과를 피하기 위해서는, 따뜻한 광택을 추가할 때 동시에 채도를 감소시키고 온도를 증가시켜야 합니다( 얼마나 세냐 약하냐는 중요치 않습니다 ). 이전에 배웠듯이, 차가운 레드와 따뜻한 레드가 존재하므로, 레드 표면이 오렌지나 옐로우로 즉각적으로 변한다는 것을 의미하는 것은 아닙니다!




그림을 더욱 매력적으로 만들기 위한 일반적인 방식으로서 광택을 사용하지는 말아야 합니다. 여러분이 느끼기에 화이트에 가까운 색이 나왔다면, 그것은 오브젝트가 빛나거나 젖었다는 것을 의미합니다. 피부를 그릴 때 그것을 고려해 보세요!



The Indirect Light Source


그런데 산란된 이 모든 블루들에는 무슨 일이 벌어진 것일까요? 그것은 물론 스카이 블루를 생성합니다. 우리가 이 밝은 블루를 볼 수 있다면, 그것은 우리의 눈에 도달함을 의미합니다 - 그리고 눈에만 도달하는 것은 아닙니다. 모든 오브젝트들은 이 간접광으로부터 "터치"당합니다. 그리고나서 그것들은 우리에게 다시 반사될 수 있습니다. 이는 태양광만큼 밝지는 않지만, 여전히 표면을 더 밝게 만들어 줍니다. 또한 만약 오브젝트가 완전한 무광이 아니라고 한다면, 표면은 채도를 잃고 차가워집니다( 왜냐하면 우리의 간접광원이 차갑기 때문입니다 ). 직접광은 항상 간접광보다는 강하다는 점을 염두에 두고, 그것들을 절대 섞으면 안 됩니다 - indirect reflection can't cross the terminator line.( 역주 : 직접광이 비치지 않기 시작하는 부분이 terminator line 인듯. 즉 직접광과 섞일 수 없다는 이야기인듯 )



가장 강한 리플렉션은 광택있는 표면에서 생성되지만, 우리의 "바닥"같은 무광 표면은 오브젝트에도 영향을 줍니다.



이전 기사에서 배웠듯이, 대비는 거리가 멀어지면 감소합니다. 그러나 멀어지는 오브젝트의 색상, 채도, 명도는 어떨까요? 글쎄요, 이것은 약간 복잡합니다. 오브젝트가 배경쪽으로 멀어지면, 스카이로부터 반사되는 빛과 그 정보가 혼합됩니다. 그렇죠? 이는 다음을 의미합니다:


  • 스카이의 색상의 방향에서 색상은 점진적으로 온도를 변경합니다.
  • 스카이의 값에 도달하도록 명도가 점진적으로 높아집니다.
  • 채도는 노이즈와 혼합되므로 감소합니다. 그러나 만약 광원이 배경( 뒤쪽 )에 존재하면( 전면이 어두울 때 ), 그 광원에 가까워질수록 채도가 증가할 수도 있습니다.

 


대기가 깨끗할수록 이 효과들은 줄어듭니다. 상대적으로, 먼지, 연기, 습도가 높으면, 오브젝트가 가까워도 이런 변화는 심해집니다. ( 무비 제작자들을 포함하는 ) 아티스트들의 일반적인 트릭은 작은 스케일에 대해서도 대기 효과를 표현하는 겁니다. 예를 들어, 몬스터의 한쪽 다리를 더 파랗게, 밝게, 채도를 낮게 만듭니다. 우리의 뇌는 그것을 더 멀다라고 인식합니다. 그래서 깊이감을 느낍니다. 그러나 대기의 두께도 고려해야만 합니다 - 이는 맑은 공기에는 맞지 않습니다.


Color and Value


적절한 컬러링은 자신도 모르게 올바른 값들을 생성합니다. 초심자들은 보통 그림을 시작할 때 적절하게 정의되어 있는 값들을 사용하려고 합니다. 하지만 우리가 방금 배운 규칙들을 사용하면, 컬러 페인팅에 있어서 문제를 겪지 않게 될 것입니다.


  • 로컬 컬러의 초기 명도는 전체 씬에서 균일한 명도로 설정합니다.
  • 디퓨즈 라이트들과 섀도우들은 로컬 컬러의 채도를 감소시킵니다 - 채도가 감소되지 않는 섀도우들은 더 밝은 값으로 보일 겁니다.
  • 광택이 심할수록, 값이 밝아집니다.
  • 간접광은 결코 직접광보다 밝을 수 없습니다. 그러므로 주요 광원과 헷갈릴 수가 없습니다.
  • 로컬 컬러는 한쪽은 빛을 받고 한쪽은 그림자가 있는 형태로 변하며, 이는 적절한 대비를 만듭니다.



더 많은 라이트들과 섀도우들이 추가되었다면, 어떻게 검사를 해야 할까요? 이를 위해서는 대비( contrast )가 중요하며, 어떤 대기( atmosphere )가 최선인지 선택할 필요가 있습니다. 일반적으로 메인 오브젝트를 세 개의 배경에 배치하는 것이 좋습니다: 화이트, 블랙, 50% 그레이. 만약 모든 상황에서 괜찮아 보이면, OK 입니다. 테스트를 위해 그림을 그레이스케일로 바꿔보는 것도 좋은 생각입니다.




Points to Remember


  • 높은 채도와 명도를 가지는 컬러는 자연상에 거의 존재하지 않습니다 - 그것들은 꽃, 새, 마법같은 것들을 위해서 남겨 두세요.
  • 빛을 받는 부분에 라이트를 배치해야지, 그림자 부분에 라이트를 배치하지 마세요! 만약에 라이트를 어두운 영역에 배치하고자 한다면, 그것을 점진적으로 밝게 만드세요.
  • 셰이딩이 너무 다채로우면, 잠시 쉬었다가 멀리서 보세요. There's a chance your eyes are just too focused on them after hours of work and the colors are actually OK. 그림을 돌려 보거나 거울을 통해서 간접적으로 보는 것도 도움이 됩니다.



No More Guessing


일단 컬러가 어떤 정보를 담는 단순한 신호라는 것을 이해하게 되면, 여러분의 그림에서 실세계를 베끼는 것이 더 쉬워집니다. 수백개의 규칙을 기억할 필요는 없습니다 - 기본을 이해하고 있다면, 훌륭한 정확도를 가지고 사실성을 계산할 수 있습니다! 물론 그것들을 성공을 위한 일반적인 방식으로 여기지는 마세요 - 예술은 예술입니다. 그리고 보통 실제로는 그 규칙들을 깰 때, 여러분은 최고의 효과를 얻기도 합니다.


다음 시리즈에서는 더 많은 트릭들을 보여 줄 것입니다. 다중의 다채로운 광원, 투명, 서브서피스 스캐터링, 빛 방출, 굴절, 그리고 텍스쳐에 대해서 보여 줄 것입니다.




Monika Zagrobelna

Poland


저는 오랜 시간 동안 쓸데없지만 창조적인 경험들을 한 폴란드 예술가입니다. 몇년이 지나서 제가 얼마나 잘못되었는지를 깨달았기 때문에, 저는 스스로 운명에 순응하고 그것들을 제대로 그리기 위해 연구를 해 보기로 결심했습니다! 제 튜토리얼은 그런 연구의 결과물입니다 - 그게 저에게 도움이 되었듯이 여러분에게도 도움이 되었으면 합니다.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



이 문서는 optimal tiling 에 대해서 정리하기 위한 목적으로 작성되었습니다.


Resource View


데이터라는 것은 본질적으로 일련의 메모리입니다. 그런데 그 데이터를 다루기 위해서는 데이터가 메모리에 어떤 식으로 써져 있는지 알 필요가 있습니다. Vulkan 은 리소스가 포함하고 있는 데이터에 대한 뷰( view )를 제공합니다. 그 뷰는 해당 데이터의 메모리가 어떤 포맷을 형성( formatting )하고 어떤 차원성( dimensionality )를 가지고 있는지를 기술합니다. 이것은 메모리를 보는 일종의 관점입니다.




명세에서는 버퍼와 이미지에 대해서 다음과 같이 기술하고 있습니다.


Buffers are essentially unformatted arrays of bytes whereas images contain format information, can be multidimensional and may have associated metadata.


여기에서 버퍼에 대해 설명할 때, "essentially unformmated" 라고 이야기하고 있습니다. 이게 참 헷갈리는데요, VkBufferViewCreateInfo 구조체는 내부에 format 이라는 필드를 포함하고 있습니다. 명세의 valid usage 항목에 보면, "format must be a valid VkFormat value" 라고 이야기하고 있기도 합니다.


그러므로 저 문장은 무시해 주시는 것이 정신 건강에 좋습니다. 굳이 해석을 해야 한다면 "필수적으로 포맷팅되어 있지는 않다" 라고 생각하시는 것이 좋습니다. 왜냐하면 컴퓨트 셰이더같은 곳에는 버퍼가 이미지 형태로 들어 갈 수도 있기 때문입니다.


Formats


일단 버퍼나 이미지를 위해서 어떤 포맷을 지원하느냐를 아는 것이 중요할 것입니다. 이를 위해서 VkGetPhysicalDeviceFormatProperties() 와 VkGetPhysicalDeviceImageFormatProperties() 라는 함수가 존재합니다.


Physical Device Format Properties


먼저 VkGetPhysicalDeviceFormatProperties() 에 대해서 살펴 보겠습니다. 이 메서드는 특정 포맷( VkFormat )에 대한 속성을 제공합니다.



VkFormatProperties 는 다음과 같이 정의되어 있죠.



여기에 linearTilingFeatures, optimalTilingFeatures, bufferFeatures 라는 세 개의 필드가 존재합니다. 이것들은 전부 VkFormatFeatureFlagBits 의 조합입니다. 이 포맷의 리소스가 linearTiling 일 때, optimalTiling 일 때, buffer 일 때, 어떤 용도로 사용될 수 있느냐를 지정하게 됩니다.



엄청나게 많은 플래그 비트가 존재합니다. 그 조합을 보고 용처를 정할 수 있는거죠. 예를 들어 CapsViewer 의 "Formats" 탭에는 여러 가지 포맷들이 존재하는데, 각 포맷이 어떤 플래그 비트들로 구성되어 있는지 알 수 있습니다.



각각의 의미에 대해서는 나중에 천천히 알아 보기로 하고, 일단은 linear-tiling 과 optimal-tiling 이라는 개념에 대해서 살펴 보도록 하겠습니다. 아마도 대부분에게는 익숙하지 않은 개념일 거라 생각합니다. 


Optimal Tiling


Linear-Tiling 이라는 것은 데이터가 좌상단이 Top-Left 이고 우하단이 Bottom-Right 인 형태로 되어 있는 것을 의미합니다. 위에서 view 를 설명할 때 Row-Major 라는 것을 이야기했습니다. 일반적으로 우리가 생각하는 데이터의 구성 순서죠. 말 그대로 선형적 순서로 데이터에 접근합니다.


하지만 GPU 입장에서는 이런 데이터 구조가 성능저하를 발생시킬 수 있습니다. 캐시 미스를 발생시킬 수 있기 때문이죠. 


GPU 는 필터링이라는 것을 수행하는데, 이를 위해서는 row 를 건너 뛰고 접근해야 하는 경우가 있습니다. 텍셀당 4 바이트인 1024 x 1024 크기의 텍스쳐가 있고, 2 x 2 크기의 커널로 필터링을 한다고 가정해 보죠. ( 0, 0 ) 에서 ( 0, 1 ) 에 접근할 때는 같은 캐시라인에 존재할 확률이 높습니다. 하지만 ( 1, 0 ) 과 ( 1, 1 ) 에 접근하기 위해서는 4 K 의 메모리 주소를 건너 뛰어야 합니다. 일반적으로 캐시미스가 발생할 확률이 높아집니다. 왜냐하면 보통 GPU 의 캐시라인은 CPU 의 캐시라인에 비해 매우 작기 때문입니다. 구글링을 해 보니 Nvidia 의 Fermi 와 Kelper 에서는 L2 캐시라인이 32 byte 라고 하더군요. 이 경우에 캐시라인은 다음과 같이 채워지겠죠( 한 칸에 4 Byte 라고 가정한 겁니다 ).



이 때 텍셀이 linear tiling 으로 채워져 있다면 캐시미스가 발생합니다. 




하지만 2 x 2 블록으로 텍셀이 채워져 있다면 어떻게 될까요?



필터링시에 캐시미스가 줄어들게 될 것입니다. 같은 캐시라인 안에 텍셀들이 들어 있을 확률이 높아지기 때문입니다. 이런 식으로 하드웨어를 위해 최적화된 형태로 메모리를 구성하는 것이 바로 optimal tiling 입니다. 


우리가 VkImageCreateInfo::tiling 에 VK_IMAGE_TILING_LINEAR 를 지정하면 하드웨어는 메모리를 linear 라 간주하고 읽어들일 것이고, VK_IMAGE_TILING_OPTIMAL 을 지정하면 optimal 이라 간주하고 읽어들일 것입니다.


그런데 한 가지 문제가 있습니다. Optimal filing 을 위한 포맷은 불투명하고 vendor-specific 하다는 것입니다. 그렇기 때문에 CPU 측에 있는 linear format 을 GPU 측을 위한 optimal format 으로 변경하는 작업을 해야 합니다. 바로 그것을 스테이징( staging )이라고 합니다.


Staging


D3D 에서 사용하던 스테이징이라는 개념에 친숙한 분들도 있을 겁니다. 일반적으로 GPU 메모리( ex : render-target )를 CPU 가 접근할 수 있는 메모리로 "read-back" 하는데 쓰였었죠. CopyResource() 나 CopySubResourceRegion() 같은 메서드를 호출해서 이런 일을 수행했었습니다.


이 스테이징이라는 개념은 이종의 메모리 사이에서 복사를 가능하게 하는데 사용됩니다. GPU 메모리를 CPU 메모리로, 혹은 CPU 메모리를 GPU 메모리로 옮겨주기 위해서 사용됩니다. 이 때 호스트나 디바이스에 걸맞는 포맷으로 컨버팅하는 과정이 포함됩니다. 그런데 일반적으로 스테이징을 수행할 때는 이미지가 아니라 버퍼를 사용합니다. [ 3 ] 에 의하면 NVidia 하드웨어에서는 버퍼를 사용하라고 권장하고 있더군요.


On devices with dedicated device memory, it is most likely that all resources that are used for many frames are filled through staging buffers. When updating image data we recommend the use of staging buffers, rather than staging images for our hardware. For a small data buffer, updates via the CommandBuffer provide an alternative approach by inlining the data directly.


백문이 불여일견이라고, 파일에서 로드한 linear tiling 이미지를 optimal tiling 이미지로 만드는 과정에 대해서 살펴 보도록 하겠습니다. "texture.cpp" 샘플의 loadTexture() 메서드의 구현을 살펴 볼 것입니다. 이 과정을 짧게 정리하자면, VkImage 를 DEVICE_LOCAL 로 생성하고 VkBuffer 를 HOST_VISIBLE | HOST_COHERENT 로 만들어서 스테이징하는 것입니다.


먼저 이미지를 파일로부터 로드합니다. 여기에서 texture 는 샘플에서 만든 래퍼 클래스입니다. VkImage 를 캡슐화( encapsulation )하고 있습니다.



다음으로는 스테이징 버퍼를 생성하게 됩니다. 오브젝트 생성, 메모리 할당, 메모리 바인딩의 순서를 거치게 됩니다. 그리고 파일에서 읽어들인 데이터를 vkMapMemory() 와 vkUnmapMemory() 를 통해 스테이징 버퍼에 복사합니다.



이제 스테이징 버퍼를 채웠으니, 이것을 어떻게 복사할 것인지 영역을 지정할 필요가 있습니다.



다음으로 우리가 최종적으로 optimal tiling 으로 채울 이미지를 만듭니다. 역시나 오브젝트 생성, 메모리 할당, 메모리 바인딩의 순서를 거칩니다. 스테이징 버퍼 생성과 다른 점이 있다면, 스테이징을 통해 내용을 채울 것이기 때문에 vkMapMemory() 와 vkUnmapMemory() 를 사용하지 않는다는 것입니다.



여기에서 VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT 를 지정하는 것을 보실 수 있습니다. VK_IMAGE_TILING_OPTIMAL 을 지정했기 때문에 반드시 지원되는 usage 를 사용해야 합니다. 실제로 CapsViewer 를 열어 보면 이 usage 들을 사용할 수 있도록 지정되어 있는 것을 확인할 수 있습니다.



여기에서 예리하신 분들은 뭔가 이상하다는 것을 발견하셨을 겁니다. CapsViewer 에는 SAMPLED_IMAGE_BIT 라고 되어 있는데 VK_IMAGE_USAGE_SAMPLED_BIT 와 이름이 다릅니다. 그렇습니다. 이것은 VkImageUsageFlagBits 로 이미지만을 위한 usage 입니다. VkFormatFeatureFlagBits 는 모든 리소스를 위한 usage 죠. 



버퍼도 따로 VkBufferUsageFlagBits 라는 버퍼만을 위한 usage 를 가지고 있습니다.



스테이징 버퍼와 이미지를 생성했으니, 실제로 스테이징을 수행할 차례입니다.



프라이머리 커맨드 버퍼를 만들어서 vkCmdCopyBufferToImage() 커맨드를 날리는 것을 보실 수 있습니다. 이를 통해서 실제 스테이징이 수행되는거죠.


정리


GPU 는 자신만의 최적화된 형태의 메모리 구조를 가지고 있습니다. 그런데 그 형태는 불투명하기 때문에 우리가 CPU 를 통해 그런 메모리 구조를 만들 수 없습니다. 하지만 스테이징 버퍼를 사용해 GPU 메모리에 데이터를 복사하는 과정에서 optimal format 으로의 컨버팅이 가능해 집니다.


이 문서에서는 코드에 대해서 자세히 설명하지 않았습니다. Optimal tiling 의 개념을 정리하는데 그 목적이 있기 때문입니다. 스테이징 버퍼에 대한 더 많은 정보를 원하신다면 cpp0x 님이 번역한 [ Staging buffer ] 를 참고하시기 바랍니다.



참고 자료


[ 1 ] Vulkan 1.1 Specification, Khronos.


[ 2 ] CUDA Memory and Cache Architecture, The Supercomputing Blog.


[ 3 ] Vulkan Memory Management, NVidia.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



지난 번에는 메모리 관리의 기본에 대해서 살펴 봤는데, 이 번에는 각 메모리 타입의 용례에 대해서 좀 깊게 살펴 보도록 할 계획입니다.


DEVICE_LOCAL


의심할 바 없는 가장 빠른 메모리를 의미합니다. 디바이스 내에서만 접근할 수 있습니다. 렌더타깃이나 매우 자주 접근해야 하는 리소스들을 위해서 사용됩니다.


[ 2 ] 에 의하면, DEVICE_LOCAL 할당에 실패했을 경우에는 HOST_VISIBLE | HOST_COHERENT 를 통해서 생성하라고 합니다. 이 때 HOST_CACHE 를 사용하지 말라고 하는군요. 그리고 물리 디바이스 메모리 단편화 때문에 할당이 실패하는 것을  막기 위해서는, 게임 내에서 해상도 변경과 같은 이유로 재할당될 때, 전체를 지우고 재할당하는 것을 추천한다고 합니다.


샘플에서 이를 사용하는 경우는 다음과 같았습니다( 아래에서 비트 연산자를 사용한 것은 Buffer Usage 를 표현하기 위함입니다 ).


  • Image.
  • VERTEX | STORAGE | TRANSFER_DST Buffer.
  • VERTEX | TRANSFER_DST Buffer.
  • INDEX | TRANSFER_DST Buffer.
  • INDIRECT | STORAGE | TRANSFER_DST Buffer.
  • STORAGE | TRANSFER_SRC | TRANSER_DST Buffer.


HOST_VISIBLE | HOST_COHERENT


호스트에서 접근할 수 있고, 그 접근이 메모리 일관성을 보장합니다. [ 2 ] 에 의하면 CPU 에서 GPU 로 데이터를 보낼 때 적절하다고 합니다.


샘플에서 이 조합을 사용하는 경우는 다음과 같았습니다( TRANSFER_SRC 버퍼는 일명 "Staging Buffer" 입니다 ).


  • TRANSFER_SRC Buffer.
  • UNIFORM Buffer.
  • STORAGE | TRANSFER_SRC Buffer.
  • VERTEX Buffer.
  • INDEX Buffer.
  • CONDITIONAL_RENDERING Buffer.


버퍼 중 대부분은 이런 조합을 사용합니다. 


예전에는 스키닝 결과에 기반해 파티클을 뿌리기 위해서 CPU 스키닝을 했는데, 이걸 잘 이용하면 이제는 GPU 스키닝의 결과를 받아 올 수 있을 것 같습니다.


HOST_VISIBLE | HOST_CACHED


[ 2 ] 에 의하면, GPU 에서 CPU 로 데이터를 보내는 경우에 이러한 조합을 사용한다고 합니다. 스크린샷을 찍거나 Hierarchical Z-Buffer occlusion 을 위한 피드백을 제공하는 경우에 좋다고 합니다.


샘플에서는 사용하는 경우가 없더군요.


HOST_VISIBLE


특이하게 HOST_VISIBLE 만 사용하는 경우가 있습니다. 명세에 의하면 HOST_CACHED 가 지정되지 않으면 기본적으로 HOST_COHERENT 하다고 이야기하고 있는데( however uncached memory is always host coherent ), 이건 HOST_VISIBLE | HOST_CACHED 와 동급으로 생각해야 하는 것인지 의문이 듭니다.


일단 샘플에서 이 플래그만 사용한 경우는 다음과 같습니다.


  • UINFORM Buffer.
  • TRANSFER_SRC Buffer.
  • VERTEX Buffer.
  • INDEX Buffer.



참고 자료


[ 1 ] Vulkan 1.1 Specification, Khronos.


[ 2 ] Vulkan Device Memory, gpuopen.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



Vulkan 이나 D3D 12 로 오면서 가장 큰 변화는 저수준( low-level ) API 를 제공한다는 점에 있다고 할 수 있습니다. 앞서 살펴 봤던 커맨드 큐도 그렇고 메모리마저 응용프로그램이 관리할 수 있도록 하고 있습니다.


다음과 같은 이유들 때문에 커스텀 메모리 관리를 사용하게 됩니다[ 2 ]:


  • 메모리 할당은 종종 다소 무거운 운영체제의 동작을 포함합니다.
  • 이미 할당한 메모리를 재사용하는 것이 그것을 해제하고 새로운 메모리를 재할당하는 것보다 빠릅니다.
  • 연속된 청크의 메모리에 있는 오브젝트는 캐시 활용도를 높일 수 있습니다.
  • 하드웨어에 대해 잘 정렬된 데이터는 더 빠르게 처리될 수 있습니다.


오늘은 이러한 메모리 관리에 대해서 알아 보고자 합니다.


Device & Host


Vulkan 에서 "메모리 관리" 라는 개념에 친숙해지기 위해서는 디바이스와 호스트라는 관리 영역에 대해서 익숙해질 필요가 있습니다. 디바이스는 그래픽 카드를 의미하는 것이며 호스트는 디바이스를 이용하는 응용프로그램이 실행되는 시스템을 의미합니다.


일반적으로 디바이스와 호스트는 개별 메모리를 가지고 있습니다. SoC( System On Chip )을 사용하는 일부 머신을 제외하면 대부분의 데스크탑은 그래픽스 카드를 따로 가지고 있습니다. 물론 최신의 범용 프로세서들이 그 내부에 그래픽스 카드를 내장하고 있기는 하지만, 게이밍 그래픽스 하드웨어로서 그것을 사용하는 경우는 많지 않으므로 신경쓰지는 않겠습니다.


PS4 나 Xbox One 과 같은 게이밍 콘솔들도 SoC 에 GPU 를 내장하고 있습니다.


전통적인 PC 들은 고사양 GPU 와 같은 특정 작업을 위한 가속기들을 추가하는 옵션을 가진 일반화된 시스템으로서 설계되었습니다. 콘솔의 경우에는 적은 공간을 필요로 하고 게임을 위해서 목적성있게 만들어졌기 때문에, 많은 컴포넌트들을 통합하는 것이 유리합니다. 스마트폰, 게임 콘솔, PC 는 모두 SoC 혹은 System-on-Chip 이라 알려진 상당히 통합되어 있는 프로세서를 사용합니다. SoC 는 실제 CPU 코어와 CPU L1, L2 캐시, 그래픽 프로세서, 다양한 연결성( USB 포트, 하드 드라이브 ), 그리고 다른 기능블락들 및 메인 시스템 RAM 사이의 인터페이스 역할을 하는 메모리 컨트롤러 등을 포함합니다. 예전에는 이런 기능들이 보통 마더보드상에 다중의 칩으로 나뉘어 있었지만, 요새는 하나의 기능 블락으로 통합됩니다.


출처 : [ 3 ]


이런 SoC 같은 경우에는 모든 컴포넌트들이 RAM 을 공유하게 됩니다. 예를 들어 RAM 중에서 GPU 를 위해 사용되는 메모리를 할당/해제/전송하기 위해 사용하는 버스를 갈릭 버스( Garlic Bus )라 부르고, CPU 를 위해 사용되는 메모리를 할당/해제/전송하기 위해 사용하는 버스를 어니언 버스( Onion Bus )라고 부릅니다. 예전에 콘솔쪽 작업을 할 때 이것이 PS4 전용 용어인줄 알았는데, XBox 문서에서도 그렇게 구분하고 있더군요. 그런데 이 버스들이 사용할 수 있는 메모리의 총량을 미리 정할 수 있도록 하고 있습니다. 예를 들어 "갈릭 버스를 위해서 4 GB 를 할당한다"고 정할 수 있습니다. 그러면 소위 말하는 그래픽스 메모리는 4 GB 인 것입니다. 하나의 램을 사용하는데 굳이 이렇게 하는 이유는 모르겠습니다. 아마도 성능을 위해 메모리 지역성( locality )을 유지하려고 하는게 아닌가 싶습니다.


아래 그림을 보면 GPU 를 제외한 다른 장치들은 노스브릿지를 통해서 DRAM 과 통신합니다. 이때 사용하는 버스를 어니언 버스라고 부릅니다. 그리고 GPU 는 전용 메모리 컨트롤러를 통해서 DRAM 과 통신합니다. 이 때 사용하는 버스를 갈릭 버스라 부릅니다. 그런데 갈릭 버스의 대역폭이 어니언 버스의 대역폭보다 크다는 것을 알 수 있습니다. 이는 노스브릿지의 한계 때문이라고 합니다[ 4 ]. Xbox one 의 구조에 대해서 더 자세하게 알고자 한다면 [ 4 ] 를 참고하세요.


출처 : [ 4 ].


어쨌든 이러한 SoC 가 아닌 이상에는 디바이스 메모리와 호스트( 시스템 ) 메모리는 구분될 수밖에 없습니다.


Heap Memory


힙이라는 것은 디바이스나 호스트에 있는 RAM 에 할당된 메모리 블락을 의미합니다. 


이것을 관리하는 것은 OS 마다 다릅니다. 일단 윈도우즈에 국한해 설명드리자면 다음과 같이 메모리를 계층적으로 관리합니다( 아래 그림은 Win32 시절에 작성된 것이지만 Win64 에서도 유사합니다. ).


출처 : [ 9 ].


물리 메모리는 페이지 단위로 관리됩니다. 일반적으로 4 KB 를 사용하는 것으로 알고 있습니다. 예를 들어 4 KB 의 페이지를 사용하는 OS 에서 응용프로그램이 1 B 의 메모리를 할당하고 싶다고 하더라도 최소 4 KB 의 메모리가 할당된다는 것입니다. 1 B 를 위해서 4 KB 를 할당하면 낭비가 심해질 것입니다. 그러므로 페이지에서 다중의 오브젝트를 위한 메모리가 할당될 수 있습니다.


그런데 여기에는 몇 가지 문제가 있습니다.


  • OS 에는 여러 개의 프로그램이 있을 수 있는데, 그것들의 메모리 사용량을 합치면 물리 메모리의 양을 넘어설 수 있습니다. 이를 위해서는 메모리 상태를 관리하면서 필요한  메모리를 물리 메모리에 올리거나 불필요한 메모리를 물리 메모리에서 내려서 백업하는 등의 복잡한 작업이 필요합니다.
  • 페이지 내부에서 메모리 할당/해제하는 기능을 구현해야 합니다.


그래서 OS 들은 가상 메모리( virtual memory )라는 개념을 중심으로 응용프로그램의 메모리를 관리하고 위에서 언급했던 여러 가지 이슈들은 OS 에서 알아서 처리합니다. 그래서 어떤 메모리 할당함수든 최종적으로는 VirtualAlloc/VirtualFree 같은 함수들을 호출하게 됩니다. new/delete, malloc/free 도 모두 최종적으로는 앞의 함수들을 호출합니다.


그런데 VirtualAlloc/VirtualFree 도 메모리 상태( Reserved, Committed, Free )관리를 할 필요가 있습니다. 그래서 이것보다는 좀 더 쉽게 메모리 블락을 사용할 수 있는 레이어로서 힙이 만들어졌습니다. 일단 따로 OS 의 메모리 할당함수를 사용하지 않는다면 new/delete, malloc/free 류의 연산자/함수들은 이 힙과 연결됩니다. 이 힙은 최소한 4 KB 의 크기를 가집니다. 위에서 말한 물리 메모리 크기와 같죠. 이렇게 해야 페이지 단위로 물리 메모리에 올렸다가 다시 내렸다가 하는 것이 가능해집니다.


이야기가 좀 길어졌는데요, 더욱 자세한 내용에 대해 알고자 하신다면 [ 9 ], [ 10 ] 의 MSDN 문서를 참고하시기 바랍니다.


어쨌든, 이제 상상을 좀 해 보죠. 앞서 응용프로그램은 가상 메모리를 사용한다고 했습니다. 그러면 그 메모리들은 물리 메모리에 매핑되어 올라갔다 내려갔다 합니다. 실제 물리 메모리에서 그 주소가 어떻게 될지는 아무도 모르죠. 하지만 적어도 응용프로그램에서의 가상메모리는 4 KB 단위로 선형적입니다.


예를 들어 2 GB 의 물리메모리( RAM )를 가지고 있다고 가정하고, 이상적인 환경에서 응용프로그램이 그 물리메모리를 온전히 사용할 수 있다고 가정해 봅시다. 그리고 힙 하나의 크기를 64 KB 라고 가저합시다. 그러면 응용프로그램이 8 GB / 64 KB = 32768 개 만큼의 힙을 가지고 있게 됩니다. 물론 OS 마다 기본 힙 크기와 물리 메모리 크기는 다를 수 있으므로 단순한 예일 뿐입니다.


그러면 응용프로그램 입장에서는 다음과 같이 선형적으로 메모리를 관리할 수 있겠죠.



"그래서 뭐?" 라는 의문이 들 겁니다. 이렇게 선형적으로 관리하게 되면 할당된 가상 메모리 주소를 64 K 로 나누는 것만으로 힙의 인덱스( offset )을 알아낼 수 있다는 것입니다. 이것보다는 복잡하기는 하지만, 일반적인 페이지 기반 메모리 풀들은 이런 식으로 메모리 풀을 관리합니다. 여기서 이야기하고자 하는 핵심은 대부분의 커스텀 메모리 관리자들이, 메모리를 관리할 때, 특정 크기의 힙 블락이나 가상 메모리 블락을 사용한다는 것입니다.


뜬금없이 여기에서 힙에 대해서 이야기한 이유는, 아래에서 메모리 타입을 이야기할 때, 힙에 대해서 언급하기 때문입니다.


Object Creation & Memory Allocation


모든 Vulkan 오브젝트들은 "vkCreate" 라는 접두어를 가진 함수들을 통해서 생성됩니다. 그런데 이런 함수에는 항상 pAllocator 라는 인자를 넘기게 되어 있습니다. 예를 들어 vkCreateInstance() 의 원형은 다음과 같습니다.



만약 pAllocator 에 nullptr 를 넣으면 커스텀 할당이 없이 그냥 OS 기본동작에 의해 메모리 할당이 이루어집니다. VkAllocationCallbacks 의 정의는 다음과 같습니다.



이 구조체의 필드에는 여러 개의 함수 포인터들이 존재합니다. 기본적으로 malloc(), free(), realloc() 과 관련한 호출에 매핑되는 콜백 함수들이 있고, 통지( nofitication )와 관련한 콜백 함수들이 있습니다. 이런 콜백들을 잘 이용하면 메모리 프로우파일러들을 만들 수도 있습니다. 할당하는 예를 들어 보면 다음과 같습니다[ 8 ].



이 글의 주제는 커스텀 메모리 관리자 구현과 관련해서 이야기하는 것이 아니므로 더 깊게 파고들지는 않겠습니다.


Vulkan 에서는 오브젝트 생성과 메모리 할당을 분리해서 생각하고 있습니다. 대표적으로 그런 형태를 띄고 있는 오브젝트를 리소스라 부릅니다. 리소스는 크게 두 가지로 나뉩니다; 버퍼( buffer )와 이미지( image ).


버퍼는 단순한 선형 메모리를 의미합니다. 그리고 이미지는 구조화되고 타입 및 포맷 정보를 가진 메모리입니다. 이러한 리소스들은 오브젝트 생성과 메모리 할당이 분리되어 있습니다. 그래서 이런 개념을 모르는 상태라면, 리소스를 생성했지만 실제로는 아무런 메모리도 할당되어 있지 않아 당황하게 되는 상황에 부딪힐 수 있습니다.


리소스를 온전히 생성하기 위해서는 다음과 같은 과정을 거쳐야 합니다.


  • vkCreateBuffer() 나 vkCreateImage() 를 호출해서 오브젝트를 생성합니다.
  • vkAllocateMemory() 를 호출해서 메모리를 할당합니다.
  • vkBindBufferMemory() 나 vkBindImageMemroy() 를 호출해서 할당된 메모리를 오브젝트와 연관시킵니다.


굳이 이렇게 복잡한 과정을 거치는 이유는 메모리 관리를 유연하게 만들기 위한 것으로 보입니다. 이런 구조를 가지게 되면 호스트측에서 오브젝트를 파괴하지 않고 메모리를 재할당하는 것이 가능해집니다. 그리고 서로 다른 오브젝트가 같은 메모리를 공유할 수 있도록 해 줍니다. 심지어는 ( 서로 다른 디바이스에서 ) 같은 메모리를 공유하도록 만들수도 있다고 합니다.


Memory Properties


앞에서 vkAllocateMemory() 에 대해서 언급했습니다. 이 함수의 원형은 다음과 같습니다.



pAllocator 에 대해서는 앞에서 살펴 봤었죠. pAllocateInfo 에 대해서 살펴 보도록 하겠습니다. VkMemoryAllocateInfo 라는 것은 다음과 같이 정의됩니다.



여기에서 allocationSize 는 바이트 단위 할당 크기므로 별건 없고, memoryTypeIndex 라는 녀석이 중요합니다. 이 memoryTypeIndex 라는 것은 VkPhysicalDeviceMemoryProperties 구조체로부터 가지고 온 인덱스입니다. 이 구조체는 vkGetPhysicalDevicememoryProperties() 함수 호출을 통해서 얻어 올 수 있는 것이며, 다음과 같이 정의되어 있습니다.



이제 슬슬 짜증나기 시작하실텐데요... 곧 정리가 될 겁니다. 새롭게 두 가지 종류의 필드가 존재합니다. 메모리 타입과 힙입니다. 이 메모리 타입과 힙 타입을 잘 골라야 자신이 원하는 형태의 메모리를 할당받을 수 있습니다.


Memory Type


메모리 타입은 다음과 같이 정의됩니다.



heapIndex 는 이 메모리가 연관되어 있는 힙이 무엇인지 지정하는 것입니다. 이건 VkPhysicalDeviceMemoryProperties::memoryHeapCount 보다는 작아야 합니다. 그리고 propertyFlags 는 접근성이나 할당 방식과 관련한 속성들의 비트 플래그입니다. 이는 다음과 같이 정의되어 있습니다.



이 플래그값이 0 이라면 호스트 메모리를 의미합니다. 기본적으로 응용프로그램이 시스템 메모리에 접근하는 것은 ( 응용프로그램이 할당한 메모리라는 가정하에서 ) 제약이 없기 때문에, 디바이스에서 할당되는 메모리에만 이러한 플래그가 조합되는 것으로 보입니다.


그 의미는 다음과 같습니다.


  • VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT : 디바이스 전용 메모리입니다. 호스트에서 접근할 수가 없습니다.
  • VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT : 호스트에서 vkMapMemory() 커맨드를 통해서 접근할 수 있는 디바이스 메모리입니다.
  • VK_MEMORY_PROPERTY_HOST_COHERENT_BIT : 코우히어런트( coherent )는 일관성이라는 뜻이죠. 이것과 관련해서는 언급해야 할 내용이 좀 많아서 아래에서 따로 설명하도록 하겠습니다.
  • VK_MEMORY_PROPERTY_HOST_CACHED_BIT : 호스트에 캐싱된 메모리라는 의미입니다. 캐싱되지 않은 메모리에 호스트가 접근하는 것은 캐싱된 메모리에 접근하는 것 보다 느립니다. 캐싱되지 않은 메모리는 항상 HOST_COHERENT 합니다.
  • VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT : 즉시 할당되는 것이 아니라 필요할 때 할당될 수 있는 디바이스 전용 메모리입니다. VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 와 같이 설정되어서는 안 됩니다.


CPU 나 GPU 는 위의 플래그들에 따라서 메모리에 접근하는 것이 가능합니다. 여기에서 약간 혼란스러운 점이 있을 수 있는데, 물리적으로 호스트에 존재하는 메모리라고 할지라도 디바이스용으로 할당되면 디바이스 메모리입니다. 여기에서 제가 "물리 디바이스 메모리" 가 아니라 "디바이스 메모리" 라고 하고 있다는 데 주의하시기 바랍니다. SoC 가 아니더라도 디바이스가 호스트 메모리의 일부를 공유할 수 있습니다. 순수하게 물리 디바이스 메모리에 할당하기 위해서는 DEVICE_LOCAL 로 만드시면 됩니다.


출처 : [ 8 ].


Vulkan spec 에 따르면 호스트 메모리와 디바이스 메모리는 다음과 같이 정의되어 있습니다.


Host memory is memory needed by the Vulkan implementation for non-device-visible storage.


Device memory is memory that is visible to the device — for example the contents of the image or buffer objects, which can be natively used by the device.


Coherent Memory Access


다중의 코어가 동일한 메모리에 접근하고 있다고 가정해 봅시다. 한 코어는 쓰고 있고 다른 코어는 읽고 있다고 가정해 보죠( 전자를 라이터라 하고 후자를 리더라 하겠습니다 ). 라이터가 메모리에다가 데이터를 쓸 때 그것은 데이터를 메모리에 직접 쓰지 않습니다. 일단 자신의 캐시에다가 쓴 다음에 그것을 메모리로 옮기게 됩니다. 리더는 동일한 메모리에 접근을 해야 하죠. 마찬가지로 리더도 메모리를 바로 읽는 것이 아니라 캐시에서 읽게 됩니다. 


동시에 메모리에 접근을 하는 상황에서 캐시를 공유하고 있다면 별 문제가 없겠죠. 하지만 코어마다 캐시를 따로 유지하는 경우가 많기 때문에 동일한 내용을 획득할 수 있는 방법이 필요합니다. 이것을 캐시 일관성이라고 하죠. 캐시 일관성에 대해 더 알고자 한다면 [ 7 ] 의 문서를 참조하세요. 그림을 통해서 쉽게 설명하고 있습니다.


출처 : [ 6 ].


SoC 를 사용하지 않는 대부분의 머신에서는 CPU 와 GPU 가 다른 디바이스에 존재합니다. 그리고 서로 다른 캐시를 가지고 있습니다. 그래서 GPU 가 CPU 의 캐시에 접근할 수가 없습니다. 같은 프로세서 내의 코어들은 어떻게든 서로의 캐시에 접근해 일관성을 보장할 수 있지만, 장치가 아예 달라지면 그게 불가능합니다. 언제 캐시에 있는 데이터가 메모리에 복사되는지 알 방법이 없습니다.


그래서 GPU 는 메모리 일관성이라는 개념을 제공합니다. 그것이 바로 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 입니다. 이런 종류의 메모리에 대한 읽기/쓰기 작업은 일관성이 보장된다는 의미입니다.


예를 들어 보겠습니다. vkMapMemeory() 함수는 응용프로그램의 메모리 주소로 오브젝트의 메모리를 매핑합니다. Vulkan 에서는 이를 "호스트에 매핑되었다" 고 표현합니다. 응용프로그램이 그 메모리에 대한 쓰기 작업을 하고 나면 vkFlushMappedMemoryRanges() 를 호출합니다. 그래야 완전히 작업이 끝난 메모리에 GPU 가 접근할 수 있죠. 



코드에서 볼 수 있듯이 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 가 지정되어 있으면 vkFlushMappedMemoryRange() 호출이 필요하지 않습니다. 일관성이 보장되기 때문입니다.


Memory Heap


메모리 힙은 다음과 같이 정의됩니다.



위에서 제가 64 KB 단위로 전체 메모리를 쪼개서 관리하는 예를 들어 드렸죠? 하지만 그 힙의 크기는 다양할 수 있습니다. 적어도 VkMemoryAllcoateInfo::allocationSize 보다는 큰 힙을 골라야겠죠. 


그런데 여기에 조금 애매한 점이 존재합니다. 메모리 단편화라는 것을 고려해야 하기 때문에 적절한 크기의 힙을 골라야 합니다. 여기에서부터는 응용프로그램마다 다른 최적화 영역이 되는 겁니다.


정리


메모리는 호스트 메모리와 디바이스 메모리로 나뉩니다. 호스트 메모리는 호스트측에 할당된 메모리이고 디바이스 메모리는 디바이스측에 할당된 메모리입니다. 물론 서로 VISIBLE 관계에 존재할 수는 있습니다. 호스트에 물리적으로 장착되어 있는 메모리라 할지라도 디바이스가 사용하게 되면 그것은 디바이스 메모리라 불립니다.


모든 Vulkan 오브젝트는 별도의 메모리 풀을 통해서 관리될 수 있습니다. 특히 리소스( 이미지, 버퍼 )의 경우에는 오브젝트 생성과 메모리 할당/바인딩이 분리되어 있습니다. 이는 재사용성을 높일 수 있고 원하는 형태로 메모리를 관리할 수 있도록 해 줍니다.


여기까지는 기본적인 메모리 관리라고 할 수 있는데요, 사실 리소스 메모리 관리를 위해서는 좀 더 특별한 기법들이 있습니다; staging, alignment, aliasing, offset 등[ 2, 5 ]. 그러한 특별한 기법들에 대해서는 다음에 다루도록 하겠습니다.


참고 자료


[ 1 ] Vulkan 1.1 Specification, Khronos.


[ 2 ] Vulkan Memory Management, NVidia.


[ 3 ] Here's How the Inside of Your Gaming Console Really Works, extreamtech.


[ 4 ] Xbox One SDK & Hardware Leak Analysis CPU, GPU, RAM & More Part One - Tech Tribunal, redgamingtech.


[ 5 ]. Memory management in Vulkan, Vulkan DEVELOPER DAY.


[ 6 ] 캐시 일관성, 위키백과.


[ 7 ] 캐시 일관성과 거짓 공유, 끄적끄적 소소한 일상.


[ 8 ] Vulkan Programming Guide : The Official Guide to Learning Vulkan, informIT.


[ 9 ] Managing Virtual Memory, MSDN.


[ 10 ] Managing Heap Memory, MSDN.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 


 

Command & Queue

 

커맨드라는 것은 디바이스에 그리기, 계산, 메모리 전송 등의 연산을 할 것을 요구하는 함수를 의미합니다. 이러한 명령은 함수 호출과 동시에 디바이스에서 실행되지는 않습니다. 큐로 보내져서 나중에 vkQueueSubmit() 이라는 함수된 후에 실제로 실행됩니다.

 

커맨드와 관련한 함수들은 "vkCmd" 라는 접두어를 가지고 있습니다. 예를 들면 vkCmdBeginRenderPass() 같은 함수가 있습니다. 이름만 봐도 딱 커맨드 관련 함수라는 것을 알 수 있습니다. 그리고 반드시 첫 번째 파라미터로 VkCommandbuffer 를 받습니다.

 

아래 코드는 1.1 명세에서의 커맨드 관련 함수 리스트를 보여 줍니다. 생각보다는 많지 않습니다.

 

 

이러한 커맨드들은 반드시 vkBeginCommandBuffer() 와 vkEndCommandBuffer() 호출 사이에서 호출되어야만 합니다. 커맨드 버퍼에 대해서는 뒤에서 다루도록 하겠습니다.

 

 

이 절의 제목에서 언급하고 있듯이 커맨드들은 큐의 종류에 의해서 카테고리화될 수 있습니다.

 

 

그런데 여기에서 여러분은 하나의 의문을 가지게 될 것입니다. 

 

저 많은 커맨드들이 어떤 큐를 위해 사용되는지 어떻게 알아?

 

다 방법이 있습니다. Vulkan 명세가 꽤 탄탄하므로 모르는 것은 거기에서 찾아 볼 수 있습니다. Vulkan 명세에 보면 커맨드에 대한 설명에는 "Supported Queue Types" 라는 항목이 있습니다. 예를 들어 vkCmdPipelineBarrier() 는 동기화 관련 커맨드인데요, 명세에서 그것을 기술하는 챕터의 마지막에 보면 커맨드 속성 테이블이 나옵니다.

 

 

vkCmdPipelineBarrier() 는 Transfer/Graphics/Compute 타입의 큐에서 사용이 가능한 커맨드라는 것을 알 수 있습니다. 다시 말해 엉뚱한 유형의 큐에 넣으면 제대로 동작할 수 없다는 것이죠.

 

이 외에도 여러 가지 속성들이 있습니다:

 

  • "Command Buffer Levels" 는 이 커맨드가 삽입될 수 있는 커맨드 버퍼의 종류에 대해서 지정하고 있습니다. 자세한 건 뒤에서 언급하도록 하겠습니다.
  • "Render Pass Scope" 는 RenderPass 범위 안에서 정의되어야 하느냐를 의미합니다. vkCmdBeginRenderPass() 호출과 vkCmdEndRenderPass() 호출 사이에 있어야 한다는 의미입니다.
  • "Pipeline Type" 은 이 커맨드가 사용될 수 있는 파이프라인 스테이지의 종류를 의미합니다. 자세한 건 다른 글에서 언급하도록 하겠습니다.

 

Command Buffer & Levels

 

그런데 커맨드의 종류와 양이 많기 때문에 적절히 그룹화해서 관리할 필요가 있습니다. 예를 들어 Shadow 패스를 위한 모두 모을 수도 있고 G-Buffer 패스를 위한 커맨드를 모두 모을 수도 있습니다. 이렇게 커맨드를 그룹화할 수 있는 버퍼가 바로 커맨드 버퍼입니다. 위에서 언급했던 vkBeginCommandBuffer() 와 vkEndCommandBuffer() 가 커맨드 버퍼를 만들기 위한 함수들입니다.

 

출처 : [ 2 ].

 

 

그런데 커맨드 버퍼에는 크게 두 종류가 있습니다. 이것을 Command Buffer Levels 라 부릅니다( 여전히 양키들의 네이밍 센스는 어렵군요 ). 하나는 프라이머리( primary ) 커맨드 버퍼이고 다른 하나는 세컨더리( secondary ) 커맨드 버퍼입니다. 프라이머리 커맨드 버퍼는 큐에 직접적으로 서밋되는 커맨드 버퍼입니다. 세컨더리 커맨드버퍼는 프라이머리 커맨드 버퍼에 의해 간접적으로 실행될 수 있으며, 큐에 직접적으로 서밋되지 않습니다.

 

출처 : [ 2 ].

 

세컨더리 커맨드 버퍼는 프라이머리 커맨드를 위한 vkBeginCommandBuffer() 호출과 vkEndCommandBuffer() 호출 사이에서 실행 커맨드인 VkCmdExecuteCommands() 라는 커맨드를 삽입함으로써 실행됩니다. 나중에 프라이머리 커맨드 버퍼가 실제로 실행되는 중에 그것들이 실행되는 것이죠.

 

이런 세컨더리 커맨드 버퍼는 재사용이 가능하지만 커맨드 버퍼끼리 상태를 상속해 주지 않습니다. 예를 들어 A 와 B 가 세컨더리 커맨드 버퍼인데 A 의 상태가 B 의 상태에 전이되지 않는다는 것입니다.

 

그런데 이 세컨더리 커맨드 버퍼는 왜 사용하는 것일까요? 명세에서는 이유에 대해서 언급하지는 않습니다. 매우 안타까운 일이죠. 구글링을 해 보니 그 이유에 대해서 갑론을박을 하고 있더군요.

 

어쨌든 납득이 갈 수 있는 한도 내에서 정리해 보면 그 이유는 다음과 같습니다. 세컨더리 커맨드 버퍼를 사용하는 가장 큰 이유는 재사용성과 효율성을 높이기 위해서입니다.

 

  • 재사용성 : 세컨더리 커맨드 버퍼는 한 번 만들어 놓으면 다른 프라이머리 커맨드 버퍼에서 사용될 수 있습니다.
  • 효율성 : 프라이머리 커맨드 버퍼를 만들고 있는 도중에 다른 스레드에서 세컨더리 커맨드 버퍼를 만들 수 있습니다.

 

Secondary Command Buffer Sample

 

예제를 통해 세컨더리 커맨드 버퍼를 실행하는 방법에 대해서 설펴 보겠습니다. "multithreading" 샘플에서는 Background 와 UI 를 세컨더리 커맨드로 렌더링합니다. 

 

 

아래의 updateCommandBuffers() 는 프라이머리 커맨드 버퍼를 채웁니다. 그 와중에 vkCmdExecuteCommands() 함수를 호출해 실행 커맨드를 프라이머리 커맨드 큐에 삽입하고 있습니다.

 

 

실제로 서밋은 프라이머리 커맨드 버퍼에 대해서만 수행하죠.

 

 

이 예제는, 프라이머리 커맨드 버퍼가 GPU 에서 실행될 때, SkyBox, UFO, UI 를 순차적으로 그리도록 만듭니다.

 

정리

 

커맨드는 디바이스에 명령을 내리기 위한 함수들이며 "vkCmd" 로 시작합니다. 각 큐마다 사용할 수 있는 커맨드의 종류는 다르며, 명세의 "Supported Queue Types" 에는 그 커맨드가 사용될 수 있는 큐의 종류가 명시되어 있습니다.

 

커맨드 버퍼는 커맨드들의 집합이며, 프라이머리 커맨드 버퍼와 세컨더리 커맨드 버퍼로 나뉩니다. 프라이머리 커맨드 버퍼는 큐에 직접적으로 추가되며 세컨더리 커맨드 버퍼를 실행할 수 있습니다. 세컨더리 커맨드 버퍼는 큐에 직접적으로 추가되지 않습니다.

 

세컨더리 커맨드 버퍼를 사용하면 재사용성과 효율성을 높일 수 있습니다.

 

참고 자료

 

[ 1 ] Vulkan 1.1 Specification, Khronos.

 

[ 2 ] Engaging the Voyage to Vulkan, NVidia.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

주의 : 허락받고 번역한 것이 아니라 언제든 내려갈 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참고하세요.

원문 : Leveraging asynchronous queues for concurrent execution, gpuopen.



동시성( concurrency ) 및 그것을 저해하는 것에 대해서 이해하는 것은 최신( modern ) GPU 들을 최적화할 때 매우 중요합니다. DirectX 12 나 Vulkan 과 같은 최신 API 들은 태스크( task )들을 비동기적으로 스케줄링할 수 있는 기능을 제공하는데, 이는 상대적으로 적은 노력으로 GPU 를 더욱 많이 활용( utilization )할 수 있도록 해 줍니다.


Why concurrency is important


렌더링은 embarrassingly parallel( 역주 : 병행 작업을 처리하기 위해서 노력을 거의 할 필요가 없는 작업을 의미. 작업이 서로 독립적일 때 이런 상황이 발생함. 출처 : Embarrassingly parallel, Wikipedia ) 태스크입니다. 메시 내의 모든 삼각형들은 병행적으로 변환될( transformed ) 수 있으며, 겹치지 않는 삼각형들은 병행적으로 래스터화될( rasterized ) 수 있습니다. 결과적으로 GPU 들은 매우 많은 작업들을 병행적으로 수행할 수 있도록 설계됩니다. 예를 들어 Radeon Fury X GPU 는 64 개의 컴퓨트 유닛( compute unit, CU )들로 구성되어 있으며, 각각은 4 개의 Single-Instruction-Multiple-Data( SIMD ) 유닛들을 가지고 있으며, 각각의 SIMD 들은 64 개의 스레드들로 구성된 블락( block )을 실행합니다. 우리는 그 블락들을 "웨이브프런트( wavefront )" 라 부릅니다. 메모리 접근에 대한 지연( latency )는 쉐이더를 실행할 때 엄청난 스톨( stall )을 야기할 수 있는데, 이 지연을 줄이기( hide ) 위해서 10 개까지의 웨이브프런트들이 각 SIMD 에서 스케줄링될 수 있습니다.


실행중에 실제 웨이브프런트의 개수가 이론적인 최대치보다 작아지는 데는 몇 가지 이유가 존재합니다. 가장 일반적인 이유는 다음과 같습니다:


  • 셰이더는 많은 Vector General Purpose Register( VGPR ) 들을 사용합니다. 예를 들어 셰이더가 128 개 이상의 VGPR 을 사용한다면, 단 하나만의 웨이브프런트만이 SIMD 를 위해서 스케줄링될 수 있습니다( 그렇게 되는 이유와 그리고 셰이더가 실행할 수 있는 웨이브프런트들을 계산하는 방법에 대해 세부적으로 알고자 한다면, GPR 사용을 최적화하기 위해서 CodeXL 을 사용하는 방법에 대해서 다루는 기사를 참고하세요 ).
  • LDS 요구사항 : 만약 셰이더가 32KiB 의 LDS 를 사용하고 스레드 그룹당 64 개의 스레드들을 사용한다면, 이는 CU 당 동시에 스케줄링될 수 있는 웨이브프런트가 2 개밖에 안 된다는 것을 의미합니다( 역주 : LDS 는 Local Data Share 의 머리글자입니다. LDS 는 각 CU 를 위한 칩에 위치한 램을 의미합니다. 32 KiB 의 LDS 가 있을 때 CU 당 두 개의 LDS 만 쓸 수 있다고 한 건 다음과 같이 계산하기 때문입니다. 64(CU) * 4(SIMD) * 64(Thread) = 16384 = 16 KiB 이므로 32 KiB 일때 CU 당 2 개 인 것입니다 ).
  • 만약 컴퓨트 셰이더( compute shader )가 충분한 웨이브프런트를 생성하지 않거나, 스크린상에 몇 개 안 되는 픽셀만을 그리는 드로 콜( draw call )을 날리는 작은 지오메트리들이 많다면, 모든 CU 들을 포화상태로 만드는데 충분한 웨이브프런트들을 생성하기 위해 스케줄링되는 작업들이 별로 없을 것입니다.
  • 모든 프레임은 동기화 지점( sync point )를 가지고 있으며 올바른 렌더링 결과를 보장하기 위한 배리어( barrier )들을 가지고 있습니다. 이것들은 GPU 를 놀게 만듭니다( idle ).


비동기 컴퓨트는 그러한 GPU 리소스들에 다가가기 위해서 사용될 수 있으며, 그렇지 않으면 그 리소스들은 테이블에 그냥 남아 있게 될 것입니다.


아래에 있는 두 개의 이미지들은 일반적인 프레임에서 Radeon RX480 GPU 의 셰이더 엔진 중 하나에 대해 무슨 일이 발생하고 있는지를 가시화해주는 스크린샷입니다. 그 그래프들은 게임의 잠재적인 성능을 확인하기 위해서 AMD 내부적으로 사용하고 있는 도구를 통해서 생성되었습니다.


이미지의 위쪽에 있는 섹션들은 하나의 CU 내부의 서로 다른 부분들에 대한 활용도를 보여 줍니다. 아래쪽에 있는 섹션들은 서로 다른 셰이더 타입들을 위해 얼마나 많은 웨이브프런트들이 생성되었는지를 보여줍니다.


첫 번째 이미지는 G-Buffer 렌더링을 위해서 0.25 ms 까지 사용하는 것을 보여 줍니다. 위쪽 부분에서 GPU 는 매우 바빠 보입니다. 특히 익스포트 유닛( export unit )이 그렇습니다. 그러나 CU 내에 있는 어떠한 컴포넌트들도 완전히 포화상태가 아니라는 것에 주의를 기울일 필요가 있습니다.



두 번째 이미지는 깊이만 렌더링하는데 0.5 ms 를 사용하는 것을 보여 줍니다. 왼쪽 절반은 PS 를 사용하지 않으며, 이것은 CU 활용도를 매우 낮게 만듭니다. 중간쯤에서는 일부 PS 웨이브( 역주 : 웨이브프런트의 약자인듯 )들이 생성됩니다. 아마도 알파 테스트를 통해서 반투명 지오메트리를 렌더링하기 때문인 것으로 보입니다( 하지만 그런 그래프들에서 그 이유를 보여 주는 것은 아닙니다 ). 오른쪽 1/4 에서는 생성되는 전체 웨이브 개수가 0 으로 떨어집니다. 이는 렌더타깃들이 다음 드로 콜들에서 텍스쳐로서 사용되기 때문에 GPU 가 기존 태스크들이 끝날 때까지 대기하기 때문일 수 있습니다.



Improved performance through higher GPU utilization


위의 이미지들에서 볼 수 있듯이, 일반적인 프레임에서 GPU 리소스들이 많이 놀고 있습니다. 새로운 API 들은 GPU 에서 태스크가 스케줄링되는 방식을 개발자가 더 많이 제어할 수 있는 방법을 제공하도록 설계되어 있습니다. 차이가 하나 있다면, 거의 대부분의 콜들은 묵시적으로 독립된 것이라는 가정을 깔고 있다는 것입니다. 드로 연산이 이전의 결과에 언제 의존하게 되느냐와 같은 정확성을 보장하기 위해서 배리어를 지정하는 것은 개발자의 책임하에 있습니다. 배리어에 대한 배칭( batching )을 강화하기 위해서 작업들( workloads )을 섞음( shuffling )으로서, 응용프로그램들은 GPU 활용도를 높일 수 있고, 각 프레임에서 배리어를 위해 소요되는 GPU idle time 을 줄일 수 있습니다( 역주 : lock 이 덜 걸리게 독립적인 작업들을 잘 분류한다는 것을 의미 ).


GPU 활용도를 높이는 추가적인 방법은 비동기 컴퓨트( asynchronous compute )입니다: 프레임의 특정 위치에서 다른 작업들과 함께 컴퓨트 셰이더를 순차적으로 실행하는 대신에, 비동기 컴퓨트는 다른 작업들과 동시에 실행되는 것을 허용됩니다. 이는 위의 그래프들에서 보이는 일부 간격들을 채울 수 있으며 추가적인 성능 향상을 제공합니다.


개발자로 하여금 어떤 작업들을 병행적으로 실행할 수 있는지 지정할 수 있도록 하기 위해서, 새로운 API 들은 응용프로그램이 태스크를 스케줄링할 수 있는 다중의 큐들을 정의할 수 있도록 합니다.


세 종류의 큐가 존재합니다:


  • Copy Queue ( DirectX 12 ) / Transfer Queue ( Vulkan ) : PCIe 버스를 통해 데이터를 전송하는 DMA( 역주 : Direct Memory Access ).
  • Compute Queue ( DirectX 12 와 Vulkan ) : 컴퓨트 셰이더를 실행하거나 데이터를 복사하는데, 로컬 메모리를 선호합니다.
  • Direct Queue ( DirectX 12 ) / Graphics Queue ( Vulkan ) : 이 큐는 아무 일이나 수행할 수 있어서, 기존 API 들의 메인 디바이스와 유사합니다.


응용프로그램은 동시성 활용을 위해서 다중의 큐를 생성할 수 있습니다: DirectX 12 에서는 임의의 개수의 큐가 각각의 타입을 위해서 생성될 수 있지만, Vulkan 에서는 드라이버가 지원되는 큐의 개수를 열거해 줄 것입니다.


GCN 하드웨어는 단일 지오메트리 프런트엔드를 포함하고 있어서, DirectX 12 에서 다중의 direct queue 를 생성하더라도 추가적인 성능 향상이 존재하지는 않을 것입니다. Direct queue 에 스케줄링된 모든 커맨드 리스트들은 같은 하드웨어 큐에 직렬화될 것입니다. GCN 하드웨어는 다중 컴퓨트 엔진을 지원하지만, 하나 이상의 컴퓨트 큐를 응용프로그램에서 사용한다고 해서 특별한 성능 향상을 확인할 수는 없습니다. 하드웨어가 지원하는 것보다 더 많은 큐를 생성하지 않는 것이 일반적으로 중요합니다. 그래야 커맨드 리스트 실행에 대한 더 직접적인 제어가 가능합니다.


Build a task graph based engine


어떤 작업이 비동기적으로 스케줄링되어야 하는지 어떻게 결정할까요? 프레임은 태스크에 대한 그래프로 간주되어야 합니다. 여기에서 각 태스크는 다른 태스크들에 대한 의존성을 가지고 있습니다. 예를 들어 여러 개의 섀도우 맵들은 독립적으로 생성될 수 있습니다. 그리고 이것들은 섀도우 맵을 입력으로 사용하여 Variance Shadow Map( VSM )을 생성하는 컴퓨트 셰이더를 사용하는 처리 단계를 포함할 수 있겠죠. 그림자가 드리워진 광원을 동시에 처리하는 타일 기반 라이팅 셰이더도 모든 섀도우 맵들이 만들어지고 G-Buffer 의 처리가 끝난 후에 시작될 수 있습니다. 이 경우 VSM 생성은 다른 섀도우 맵들이 렌더링되고 있는 동안 실행되거나 G-Buffer 를 렌더링하는 동안 배치될( batched ) 수 있습니다.


이와 유사하게, Ambient Occlusion 은 깊이 버퍼에 의존합니다. 하지만 섀도우나 타일 기반 라이팅에는 독립적이죠. 그러므로 비동기 컴퓨트 큐에서 실행하는 것도 좋은 선택입니다.


게임 개발자들이 비동기 컴퓨트의 이점을 취할 수 있는 최적의 시나리오를 찾을 수 있게 도왔던 경험에서, 수동으로 태스크를 병행적으로 실행하도록 지정하는 것이 이 처리를 자동화하려고 시도하는 것보다 더 낫다는 것을 발견했습니다. 컴퓨트 태스크만이 비동기적으로 스케줄링되기 때문에, 가능한 한 많은 렌더링 작업에 대해 컴퓨트 패스를 구현하는 것을 추천합니다. 그래야지 어떤 태스크들이 실행중에 겹치게 되는지를 결정하는데 있어서 더 많은 자유도를 가질 수 있게 됩니다.


마지막으로 작업을 컴퓨트 패스로 옮길 때, 응용프로그램은 각 커맨드 리스트들이 충분히 커지도록 해야 합니다. 이는, 커맨드 리스트를 쪼개는 비용과 서로 다른 큐에서 태스크를 동기화하는 연산에 요구되는 펜스( fence ) 상에서의 스톨을 만회해서, 비동기 컴퓨트로부터 성능 향상을 얻을 수 있도록 해 줄 것입니다.


How to check if queues are working expected


응용프로그램에서 비동기 큐들이 원하는 대로 동작하고 있는지 확실하게 하기 위해서는 GPUView 를 사용하는 것을 추천합니다. GPUView 는 어떤 큐들이 사용되고 있는지, 얼마나 많은 작업들이 각 큐에 포함되어 있는지, 그리고 가장 중요하게는 작업들이 실제로 서로에 대해서 병행적으로 실행되고 있는지를 가시화해 줍니다.


Windows 10 환경에서, 거의 대부분의 응용프로램들은 적어도 하나의 3D 그래픽스 큐와 카피 큐를 보여 줄 것입니다. 카피 큐는 페이징( paging )을 위해 Windows 에 의해 사용됩니다. 아래 이미지에서, GPU 에 데이터를 업로드하기 위한 추가적인 카피 큐를 사용하는 응용프로그램의 한 프레임을 볼 수 있습니다. 렌더링을 시작하기 전에 데이터를 스트리밍하고 동적 상수 버퍼를 업로드하기 위해 카피 큐를 사용하는, 개발중인 게임에서 가지고 온 것입니다. 이 게임 빌드에서 그래픽스 큐는 렌더링을 시작하기 전에 복사가 완료될 때까지 대기할 필요가 있습니다. 아래 그림에서도 보이듯이, 카피 큐는 복사를 시작하기 전에 이전 프레임의 렌더링이 완료될 때까지 대기합니다:



이 경우에, 카피 큐를 사용하는 데는 어떠한 성능 이점도 없습니다. 왜냐하면 업로드된 데이터 상에서 더블 버퍼링이 구현되어 있지 않기 때문입니다. 데이터에 대해 더블 버퍼링이 수행된 후에, 그제서야 업로드가 발생할 것입니다. 그 동안에 이전 프레임은 여전히 3D 큐에 의해서 처리되고 있는 중이며, 3D 큐에서의 그 차이( gap )가 제거됩니다. 이러한 변경은 전체 프레임 시간을 거의 10 % 정도 줄여줍니다.


두 번째 예는 컴퓨트 큐를 엄청나게 사용하는 게임인 Ashes of the Singularity 의 벤치마크 씬에서의 두 프레임을 보여 줍니다.



비동기 컴퓨트 큐가 프레임의 대부분을 위해서 사용됩니다. 그래픽스 큐가 컴퓨트 큐를 기다리기 위해서 스톨되지 않는 것을 보실 수 있습니다. 그것은 비동기 컴퓨트가 성능 향상을 제공하는 가장 좋은 수단임을 확인할 수 있는 좋은 시작점입니다.


What could possibly go wrong?


비동기 커퓨트를 사용할 때, 서로 다른 큐에 존재하는 커맨드 리스트들이 병행적으로 실행됨에도 불구하고 그것들은 여전히 같은 GPU 리소스들을 공유할 수 있다는 데 주의할 필요가 있습니다.


  • 만약 리소스들이 시스템 메모리에 존재한다면, 그래픽스 큐나 컴퓨트 큐에서 그것들에 접근하는 것은 DMA 큐 성능 등에 영향을 줄 수 있습니다.
  • 로컬 메모리에 접근( 예를 들어, 텍스쳐 패칭( fetching ), UAV 에 대한 쓰기, rasterization-heavy task 수행 )하는 그래픽스 큐나 컴퓨트 큐는 대역폭 한계 때문에 서로에게 영향을 줄 수 있습니다.
  • 같은 CU 를 공유하는 스레드는 GPR 과 LDS 를 공유하게 되며, 그래서 이용가능한 모든 리소스들을 사용하는 태스크들은 같은 CU 상에서 수행되는 비동기 작업들을 방해할 수 있습니다.
  • 서로 다른 큐들은 그것들의 캐시들을 공유합니다. 만약 다중 큐가 같은 캐시를 활용한다면, 이는 더 많은 캐시가 버려지거나( trashing ) 성능을 저하시키는 결과를 산출할 수 있습니다.


Due to the reasons above it is recommended to determine bottlenecks for each pass and place passes with complementary bottlenecks next to each other( 역주 : 병목을 없애기 위해서는 동시에 처리해야 할 패스를 잘 선택해서 보완해야 한다는 의미인듯 ):


  • DLS 와 ALU 를 심하게 사용하는 컴퓨트 셰이더는 보통 비동기 컴퓨트 큐를 위한 좋은 후보입니다.
  • 깊이만 그리는 렌더링 패스들은 보통 그것 다음에 실행하는 컴퓨트 태스크를 가지고 있는 좋은 후보입니다.
  • 효율적인 비동기 컴퓨트를 위한 일반적인 해결책은 프레임 N 의 포스트 프로세싱과 프레임 N+1 의 섀도우 맵 렌더링을 겹치는 것입니다.
  • 가능한 한 많은 프레임들을 컴퓨트로 포팅( porting )하면 다음에 스케줄링될 수 있는 태스크들을 시험할 때 더욱 유연한 결과를 산출할 것입니다.
  • 태스크를 서브 태스크들로 나누고 삽입하는 것은 배리어를 줄이고 효율적인 비동기 컴퓨트를 위한 기회를 창출할 것입니다( 예를 들어 "각 광원을 위해 섀도우 맵을 클리어하고, 섀도우를 렌더링하고, VSM 을 계산하는 것" 대신에 "모든 섀도우 맵을 클리어하고, 모든 섀도우 맵을 렌더링하고, 모든 섀도우 맵을 위해 VSM 을 계산하는 것" ). 

비동기 컴퓨트는 적합하게 사용되지 않을 때는 성능을 저하시킨다는 점에 주의해야만 합니다. 이런 경우를 피하기 위해서는, 각 태스크들을 위해서 비동기 컴퓨트 사용이 쉽게 활성화되거나 비활성화될 수 있도록 하는 것을 권장합니다. 이는 여러분이 성능 이점을 측정하고 응용프로그램이 여러 하드웨어에서 최적으로 실행될 수 있도록 해 줄 것입니다.


Stephan Hodes is a member of the Developer Technology Group at AMD. Links to third party sites are provided for convenience and unless explicitly stated, AMD is not responsible for the contents of such linked sites and no endorsement is implied.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 


Supported Hardware Architectures


Vulkan 을 지원하는 하드웨어는 OpenGL 4.0 이상을 지원해야 합니다. 갑자기 뜬금없이 하드웨어 사양을 이야기하는 이유는 Vulkan 의 Command Queue 와 하드웨어 설계가 연관이 있기 때문입니다.


Vulkan 을 지원하는 그래픽스 하드웨어의 최소 아키텍쳐는 다음과 같습니다 [ 1 ]:

  • NVidia Kelper 아키텍쳐( GK110 이후, Geforce 600 시리즈 이후 ).
  • Intel SkyLake 아키텍쳐( Graphics Gen9 아키텍쳐를 포함한 아키텍쳐. 예를 들어 i5 6600 이후 ).
  • Amd GCN 아키텍쳐( Radeon HD 77xx 이후 ).


이러한 아키텍쳐들의 특징은 OpenCL, CUDA, Compute Shader 등을 지원하기 위해서 여러 개의 큐를 만들었다는 데 있습니다. 예를 들어 Nvidia 같은 경우에는 Kelper 아키텍쳐에서 Dynamic Parallelism 과 Hyper-Q 라는 기술을 도입했습니다. 그 중에서 Hyper-Q 가 multiple command queue 와 관련이 높아 보입니다. 백서[ 2 ]에서는 CUDA 를 중심으로 설명하고 있지만 이것은 다른 명령에도 영향을 줬을 것으로 보입니다.


과거에는 최적으로 스케줄링된 여러 스트림의 워크로드를 계속 GPU에 제공하는 것이 문제였다. Fermi 아키텍처는 별도의 스트림에 전달된 16개의 커널을 동시에 실행하도록 지원했지만 결국 모든 스트림이 동일한 하드웨어 작업 대기열로 멀티플렉스되었다. 때문에 잘못된 스트림 내부 종속성이 발생하여 특정 스트림 내의 종속 커널이 다른 스트림의 추가 커널을 실행하기 전에 완료해야 하는 문제가 나타났다. 너비우선(Breath-first) 실행 순서를 사용하면 문제를 어느 정도 완화할 수 있지만 프로그램의 복잡성이 높아지면서 이를 효율적으로 관리하기가 점점 더 어려워졌다.


Kelper GK110에서는 새로운 Hyper-Q 기능으로 이러한 기능을 향상시켰다. Hyper-Q는 호스트와 GPU의 CWD(CUDA Work Distributor) 로직 간의 총 연결(작업 대기열) 수를 늘려 주며, 하드웨어에서 관리되는 동시 연결 수를 32개(Fermi의 경우 단일 연결만 가능)까지 지원한다, Hyper-Q는 여러 CUDA 스트림, 여러 MPI(메시지 전달 인터페이스) 프로세스 또는 프로세스 내의 여러 스레드에서 연결할 수 있도록 하는 유연한 솔루션이다. 이전에 여러 작업에 걸쳐 직렬화 오류가 발생해 GPU 활용에 한계가 있었던 애플리케이션도 기존 코드를 변경하지 않고 성능이 32배까지 개선되는 효과를 볼 수 있다.


각 CUDA 스트림은 자체 하드웨어 작업 대기열에서 관리되며, 스트림 각 종속성이 최적화되고, 특정 스트림의 연산이 더 이상 다른 스트림을 차단하지 않기 때문에 잘못된 종속성이 발생할 가능성을 없애기 위해 실행 순서를 특별히 맞추지 않고도 여러 스트림을 동시에 실행할 수 있다.


Hyper-Q는 MPI 기반 병령 컴퓨터 시스템에서 사용할 경우 막대한 이점을 제공한다. 기존의 MPI 기반 알고리즘은 멀티 코어 CPU 시스템에서 실행하도록 작성하는 경우가 많았다. 각 MPI 프로세스에 할당되는 작업량은 코어 수에 따라 조정된다. 이 경우 단일 MPI 프로세스에 GPU 를 완전히 점유하기에 불충분한 작업량이 할당될 수 있다. 여러 MPI 프로세스가 GPU 하나를 공유할 수도 있지만, 이러한 프로세스에서 잘못된 종속성으로 인해 병목 현상이 발생할 수도 있다. Hyper-Q 는 이러한 잘못된 종속성을 없애 MPI 프로세스간에 공유되는 GPU의 효율성을 크게 높인다.



출처 : [ 2 ].


Queue & Queue Family


큐라는 것은 커맨드들을 전송할 수 있는 버퍼라고 생각하시면 됩니다. 아래 그림에서 Kernel 은 쉐이더 처리를 의미하고 파란색은 리소스 트랜스퍼를 의미합니다.


그림 출처 : [ 5 ].


Vulkan 에서는 4 가지 종류의 큐를 지원합니다[ 3 ].



  • VK_QUEUE_GRAPHCIS_BIT : 그래픽스( 일반 shader ) 관련 연산을 지원하는 큐입니다.
  • VK_QUEUE_COMPUTE_BIT : 컴퓨트( compute shader ) 관련 연산을 지원하는 큐입니다.
  • VK_QUEUE_TRANSFER_BIT : 트랜스퍼( 리소스 전송 ) 관련 연산을 지원하는 큐입니다.
  • VK_QUEUE_SPARSE_BINDING_BIT : 스파스 리소스( Sparse resource, 혹은 Virtual texture ) 관련 연산을 지원하는 큐입니다.


비트 플래그로 되어 있다는 것을 보셨으니 이미 눈치채셨겠지만, 하나의 큐는 여러 가지 역할을 수행할 수 있습니다. 그래서 Vulkan 에서는 같은 구성을 가진 큐를 그룹으로 묶어 놓았는데 그것을 큐 패밀리라고 부릅니다.


vkGetPhysicalDeviceQueueFamilyProperties() 를 통해서 큐 패밀리들을 획득할 수 있습니다. 큐 패밀리라는 것은 실체가 존재하는 것은 아니고, 그냥 그룹화를 위한 정보일 뿐이라는 데 주의하시기 바랍니다. 괜히 이것을 실제 인스턴스와 매핑해서 추상화하려고 하면 골치가 아픕니다.



피지컬 디바이스에서 얻어 온 정보이므로 그 자체는 "단지" 정보일 뿐입니다. 실제 큐 생성에 대해서는 나중에 다루도록 하겠습니다. 이 정보는 다음과 같이 구성되어 있습니다.



  • queueFlags : 이 큐 패밀리에서 사용할 수 있는 연산의 종류입니다.
  • queueCount : 이 큐 패밀리에 소속된 큐의 개수입니다.
  • timestampValidBits : 커맨드 실행시간을 재기 위한 메커니즘을 제공하는 timestamp 관련 변수인데요, 여기에서 다루지는 않겠습니다. 보통 36 에서 64 사이이며, timestamp 를 지원하지 않을 경우에는 0 입니다.
  • minImageTransferGranularity : 큐에서 수행되는 트랜스터 연산을 위해 지원되는 최소 해상도 정보입니다. 예를 들어 minImageTransferGranularity 가 6 이라면 6 텍셀 이상의 너비를 가진 텍스쳐를 전송해야 합니다.


이를 통해서 얻은 정보들은 CapsViewer 의 "Queue Families" 에서 확인할 수 있습니다.



자, 제 GTX 980 카드는 두 종류의 큐 패밀리를 가지고 있군요. 하나는 4 종류의 연산을 위해서 사용할 수 있는 공용 큐 패밀리이며, 다른 하나는 트랜스퍼 전용 큐 패밀리입니다. 이를 도식화하면 다음과 같이 표현할 수 있겠죠. 디바이스는 손을 17 개 가지고 있는데, 그중 16 개는 공용이고, 1 개는 트랜스퍼 전용입니다.



그러면 여기에서 의문이 하나 생깁니다. 대체 이 큐를 어떻게 사용해야 하는 걸까요? 


보통 큐 하나에 스레드 하나를 매핑하고, 큐에 대해서 독립적인 작업을 수행합니다. 지금까지 제가 본 샘플들에서는, 전용 큐를 가지고 있는 경우에는 그것을 사용하고, 그렇지 않은 경우에는 공용 큐를 사용합니다. 하지만 전용 큐를 가지고 있다고 하더라도 자신의 큐 패밀리가 꽉 찼다면 이용할 수 있는 다른 큐 패밀리에 있는 큐를 사용할 수 있겠죠. 어떻게 관리할 것이냐는 구현측의 마음에 달려 있습니다. 


물론 기본 원칙은 있습니다. 큐끼리 동기화하는 경우가 자주 발생해서는 안 된다는 것입니다. 이건 뭐 concurrency/parallelism 의 성능을 극대화하기 위해서는 기본이 되는 원칙이니 당연하겠죠.


정리


다중의 큐를 지원하는 아키텍쳐들부터 Vulkan 을 지원합니다. 각 큐들에서 수행할 수 있는 연산은 4 종류이며, 같은 속성을 가진 큐들끼리 모아서 큐 패밀리라 부릅니다.


이 큐들은 스레드와 1:1 로 매핑될 수 있으며, 그것을 어떻게 관리할 것이냐는 응용프로그램에 달려 있습니다.


다음에는 multithreading 샘플을 살펴 보고, [ 4 ] 와 [ 5 ] 를 번역하도록 하겠습니다.


추가 : multithreading 샘플을 통해 스레드당 큐를 할당하는 방식을 살펴 보려 했으나, 확인해 보니 그 샘플은 단순히 커맨드 버퍼를 스레드에서 만드는 예제였습니다. 그러므로 multithreading 샘플은 커맨드 버퍼를 다루는 시점에 분석하도록 하겠습니다.


참고자료


[ 1 ] 벌컨 (API), 위키백과.


[ 2 ] NVIDIA의 차세대 CUDATM 컴퓨팅 아키텍처: KelperTM GK110, Nvidia.


[ 3 ] Vulkan 1.1 specification, Khronos.


[ 4 ] Leveraging asynchronous queues for concurrent execution, gpuopen.


[ 5 ] Moving To Vulkan, NVidia.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



Installable Client Driver


Vulkan 로더는 ICD 라는 라이브러리와 통신함으로써 피지컬 디바이스( Physical Device, 실제 그래픽스 카드 )와 통신합니다.



레이어 정보를 기술하기 위해서 json 매니페스트를 유지하듯이, ICD 정보를 기술하기 위한 json 매니페스트 파일도 존재합니다. 


HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Class\{Adapter GUID}\000X\VulkanDriverNameWow

HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Class\{SoftwareComponent GUID}\000X\VulkanDriverNameWow


추가적으로 다음과 같은 경로에 정보가 있다면 읽어들이기도 합니다.


HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers\


그런데 "{Adapter GUID}" 와 "{SoftwareComponent GUID}" 는 클래스 아이디로서 이미 정해져 있는 값이 있습니다. "{Adapter GUID}" 는 디스플레이 어댑터를 의미하는 클래스 아이디로서 이것도 이미 정해져 있는 값이 있습니다. 실제로 Vulkan-Loader 소스에서도 하드코딩해서 사용하고 있더군요.


장치 관리자에서 찾아 봤더니 "{4d36e968-e325-11ce-bfc1-08002be10318}" 였습니다. 이는 Intel 카드에서도 동일한 값을 가지고 있었습니다.



Software-Component 라는 것은 Windows 10 에서 실질적인 드라이버 소프트웨어를 의미하는 것 같더군요. 이것의 클래스 아이디도 "{5c4c3332-344d-483c-8739-259e934c9cc8}" 로 정해져 있었습니다.


어쨌든 "{Adapter GUID}" 와 관련한 레지스트리를 찾아 보니 "0000" 에는 다음과 같이 Nvidia 그래픽스 카드가 사용하는 Vulkan Driver 의 정보가 들어 있었습니다.



"0001" 에는 Intel 그래픽스 카드를 위한 정보가 들어 있었는데 Vulkan 을 지원하지 않는지 특별한 정보가 없었습니다. 실제로 Physical-Device 를 열거해도 나오지 않습니다.


"{SoftwareComponent GUID}" 와 관련한 레지스트리에는 별 정보가 없었고, "HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\Drivers\" 는 아예 존재하지도 않았습니다.


어쨌든 "VulkanDriverNameWow" 가 가리키는 경로에는 ICD 용 json 파일이 있습니다. 이름이 "nv-v64.json" 이더군요. 열어 보니 다음과 같은 정보가 담겨 있었습니다.



"ICD" 필드에 "library_path" 와 "api_version" 이 기입되어 있어서 적절한 라이브러리와 버전을 찾을 수 있도록 하고 있습니다. 추가적으로 "VK_LAYER_NV_optimus" 레이어도 등록하고 있네요.


Physical Device


Vulkan 에서 physical device 는 VkPhysicalDevice 라는 디스패쳐블 오브젝트로서 정의됩니다. 그런데 이 오브젝트는 응용프로그램에서 생성하는 것이 아닙니다. vkEnumeratePhysicalDevices() 함수를 통해서 열거할 수만 있습니다. 왜냐하면 피지컬 디바이스는 OS 가 로드되면서 이미 생성되어 있는 상태이기 때문입니다.


다른 열거 함수들과 마찬가지로 count 를 획득한 다음에 실제 데이터를 채웁니다.



앞에서 레지스트리에 대해서 이야기할 때 언급했듯이 제 머신에는 Vulkan 을 지원하는 카드가 하나밖에 없기 때문에 "Geforce GTX 980" 만 잡힙니다. 


일단 피지컬 디바이스를 획득하면 거기에서 피지컬 디바이스의 속성과 기능 등을 획득할 수 있습니다.



이 정보들은 CapsViewer 에서 확인할 수 있으며 아래와 같이 대응됩니다.

  • deviceProperties 는 "Limits" 에 대응합니다.
  • deviceFeatures 는 "Features" 에 대응합니다.
  • deviceMemoryProperties 는 "Memory" 에 대응합니다.



QueueFamily 정보도 피지컬 디바이스에서 가지고 오는 것이긴 한데 나중에 따로 언급하도록 하겠습니다.


Physical Device Group


Vulkan 은 1.1 부터 여러 개의 GPU 를 응용프로그램에서 사용하는 기능을 제공합니다. 


[ 3 ] 에 따르면 제조사( vendor )에 중립적( neutral )이라고 합니다. 예전의 SLI 나 Cross-Fire 는 드라이버가 각 GPU 에 태스크를 분배하는 방식이었지만, Vulkan 은 이를 개발자가 할 수 있도록 지원한다고 합니다. 그리고 여기에 사용되는 피지컬 디바이스들은 디바이스 그룹이라는 개념으로 묶여 하나의 로지컬 디바이스로 관리된다고 합니다.


안타깝게도 제 머신의 Intel 카드가 Vulkan 을 지원하지 않아서 테스트는 못 해 봤지만 제조사-중립적인 기능이라니... 정말 혁신적인 것 같습니다.


정리


머신에는 여러 개의 그래픽스 카드가 존재할 수 있으며, Vulkan 은 ICD 를 통해서 그 장치들을 열거하고 거기에 접근할 수 있습니다. 이러한 그래픽스 카드들은 VkPhysicalDevice 로 표현되며, 그것을 통해서 피지컬 디바이스의 여러 가지 속성과 기능들을 열거할 수 있습니다.


일단 피지컬 디바이스를 선택하면, 이제 실제로 사용할 로지컬( logical ) 디바이스를 생성할 준비가 된 것입니다.


참고자료


[ 1 ] Architecture of the Vulkan Loader Interface, LUNAR XCHANGE.


[ 2 ] Vulkan 1.1 Specificiation, Khronos.


[ 3 ] Vulkan 1.1 out today with multi-GPU support, better DirectX compatability, ars TECHNICA.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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



Vulkan 의 오브젝트는 크게 디스패쳐블( Dispatchable ) 오브젝트와 논디스패쳐블( Non-Dispatchable ) 오브젝트로 나뉩니다. Vulkan 명세에서는 오브젝트라는 용어 대신 핸들( handle )이라는 용어를 사용하기도 합니다.


이 디스패치( dispatch )라는 용어는 Vulkan 소스 전반에 걸쳐 나오고 있기 때문에 기본적인 개념에 대해서 파악할 필요가 있습니다. 원래 "발송하다" 는 뜻을 가지고 있는데요, 보통 프로그래밍 언어에서는 데이터를 어디론가 전송하는 것을 의미합니다. 이런 개념에서 보면 "어딘가로 보낼 수 있는 오브젝트" 정도로 생각하는 것이 타당하겠죠. 하지만 그 목적지가 어딘지, 어떤 오브젝트를 보낼 수 있는지, 이런 정보들을 그냥 "디스패쳐블" 이라는 용어만 가지고 파악하기는 어렵습니다.


그러므로 여기에서 디스패쳐블 핸들과 논디스패쳐블 핸들의 차이에 대해서 간단하게 짚고 넘어 가도록 하겠습니다.


Dispatchable Object


명세에 따르면, Vulkan 에서 디스패쳐블 오브젝트라는 것은 불투명한( opaque ) 포인터이며, 레이어가 API command 를 간섭( interception )하는 데 사용될 수 있는 포인터를 의미합니다( 불투명한 타입의 개념이 이해가 안 된다면, [ Vulkan Opaque Type ] 를 참고하시기 바랍니다 ). 그리고 핸들은 유일해야만 합니다.


이러한 유형의 핸들이 어떻게 구현되어 있는지는 모릅니다. 하지만 명세를 통해서 그것에 접근하기 위한 인터페이스를 정의해 놨기 때문에, 그것을 사용하는 레이어나 ICD( Installable Client Driver ) 등은 그것들을 사용할 수가 있습니다.


디스패쳐블 오브젝트는 다음과 같습니다 :


  • VkInstance
  • VkPhysicalDevice
  • VkDevice
  • VkQueue
  • VkCommandBuffer


이것은 "vulkan_core.h" 에서 다음과 같이 정의됩니다.



Non-Dispatchable Object


명세에 따르면, 논디스패쳐블 오브젝트는 구현측( 예 : 드라이버 제조사 )에 종속적인 64 비트 정수형이며, 이것의 의미는 구현측에 따라서 달라지며, 기저 오브젝트에 대한 참조라기 보다는 오브젝트 정보를 핸들에 직접적으로 인코딩하고 있을 수 있습니다. 그리고 그 핸들이 유일할 필요는 없습니다. 


논디스패쳐블 오브젝트는 다음과 같습니다 :


  • VkSemaphore
  • VkFence
  • VkBuffer
  • VkImage
  • VkEvent
  • VkQueryPool
  • VkBufferView
  • VkImageView
  • VkShaderModule
  • VkPipelineLayout
  • VkRenderPass
  • VkPipeline
  • VkDescriptorSetLayout
  • VkSampler
  • VkDescriptorPool
  • VkDescriptorSet
  • VkFramebuffer
  • VkCommandPool


이것은 "vulkan_core.h" 에서 다음과 같이 정의됩니다.



Difference ???


대부분의 환경에서 VK_DEFINE_HANDLE 이든 VK_DEFINE_NON_DISPATCHABLE_HANDLE 이든 최종 결과는 다음과 같습니다.



x86 환경에서는 두 번째 VK_DEFINE_NON_DISPATCHABLE_HANDLE 정의에 걸릴 수도 있겠지만, 요즘은 거의 x64 환경이므로 잘 모르겠네요.


사실상 디스패쳐블이든 논디스패쳐블이든 응용프로그램 입장에서 보면 불투명한 타입을 가지고 있습니다. 그렇다면 다른 기준에 의해 구분될 수 있어야 하는데, 딱히 구분할 만한 차이점을 느끼기가 어렵습니다.


이와 관련해서 검색을 해 봤는데, 공식적으로 인정받을 만한 명료한 대답을 내 놓는 사람은 없는 것 같습니다( 명세 자체가 이해가 안 가는 상황입니다 ). 단지 그럴싸한 답변은 있었습니다.


The reason is that with 64 bits, many implementations can actually encode the GPU address for the non-dispatchable object directly into the 64-bits, so they don't have to do any sort of dereference or indirection when you hand that object back. They just literally scribble what you hand them.


It's a performance optimization, even on 32-bit OSes.


출처 : Why are non dispatchable objects always typedefed to 64bit?


출처인 스레드에서는 이와 관련해서 여러 논쟁이 있었지만, 정리하자면 논디스패쳐블 오브젝트들은 GPU 메모리 주소에 직접적으로 매핑되어야 하기 때문에 64 bit 메모리 주소를 가지고 있어야만 한다는 겁니다. 


위에서 열거된 디스패쳐블 오브젝트의 종류와 논디스패쳐블 오브젝트의 종류를 비교해 보면 그럴싸 합니다. 그렇게 되면 x86 머신에서도 GPU 데이터에 대해서는 64 bit 주소를 유지할 수 있죠. 저는 이 주장을 믿기로 했습니다.


그렇게 생각하면 논디스패쳐블 오브젝트( GPU 관련 데이터 )는 드라이버 구현에 종속적이므로 간섭할 수 없는 데이터이고, 그렇지 않은 디스패쳐블 오브젝트는 간섭할 수 있는 오브젝트라는 쪽으로 논리를 이어갈 수 있습니다( 물론 이 부분은 조금 애매하긴 하지만 말이죠... ). 이런 관점에서는 디스패치 체인( Dispatch Chain )과 관련이 있는 오브젝트가 디스패쳐블 오브젝트라고 생각할 수 있을 것 같습니다( Dispatch Chain 에 들어 가니까 Dispatchable Object 이다??? ).


Object Lifetime


Vulkan 오브젝트들은 vkCreate* 와 vkAllocate* 커맨드를 통해서 생성됩니다. 그리고 이것과 쌍을 이루는 vkDestroy* 와 vkFree* 커맨드를 통해서 파괴됩니다. 절대 new delete 를 사용하지 않습니다.


vkCreate*/Destroy* 쌍은 일반적인 오브젝트 생성/파괴를 위해서 사용되며, vkAllocate*/Free* 쌍은 리소스 생성/파괴를 위해서 사용됩니다. 


Vulkan 오브젝트들의 타입은 불투명하기 때문에, 응용프로그램에서 생성파괴하는 것은 사실상 불가능하고 API 를 통해서만 생성/파괴하는 것이 가능합니다. 메모리 할당자를 통해서 오브젝트를 위한 메모리를 할당해 줄 수는 있겠지만, 그것은 단순히 메모리 할당이지 오브젝트 생성이 아닙니다. 실제 생성자나 소멸자는 감춰져 있는 모듈에서 호출됩니다.


그림1. VkCreate* 에서 오브젝트를 생성하는 과정.


이러한 오브젝트의 생명주기를 관리하는 것은 응용프로그램의 책임하에 있습니다. 그렇기 때문에 사용중인 오브젝트가 파괴되지 않도록 관리를 잘 해야 합니다. 


이에 대한 세부사항이 [ 1 ] 의 [ 2.3.1. Object Lifetime ] 섹션에 자세하게 나와 있습니다. 하지만 초심자들은 지금 본다고 해서 이해가 갈 수 있는 내용이 아니기 때문에 굳이 안 보셔도 상관이 없습니다. 어떤 오브젝트가 어떤 오브젝트에 소유권을 가지고 있는지를 텍스트로 기술하는 문서입니다. 예를 들면 "VkPipelineLayout 오브젝트가 커맨드 버퍼가 사용중일 때는 파괴되지 말아야 한다" 등의 내용입니다.


제가 생각했을 때, 이런 부분들은 스마트 포인터를 이용해 해결해야 할 부분이지 외워서 해결할 부분은 아닌 것 같습니다. 물론 불투명한 오브젝트의 생성자/소멸자를 응용프로그램에서 호출할 수 없으므로 래퍼( wrapper )를 사용해야 할 것입니다.


정리


Vulkan 에서 오브젝트는 디스패쳐블 오브젝트와 논디스패쳐블 오브젝트로 구분됩니다. 이것의 구분이 조금 모호하기는 하지만 디스패쳐블 오브젝트는 디스패치가 가능한 오브젝트이고 논디스패쳐블 오브젝트는 드라이버 관련 오브젝트라고 생각하는 것이 속이 편합니다.


Vulkan 오브젝트의 생성/파괴는 드라이버 구현에 의해서 블랙박스로 가려져 있는 상태이며, Vulkan 이 제공하는 API 를 통해서만 가능합니다. 이것은 메모리 할당자를 통해서 응용프로그램측에서 메모리를 제공하는 경우에도 마찬가지로 적용됩니다.


응용프로램은 오브젝트가 참조되고 있을 때 파괴되지 않도록 관리해야 합니다.


추가 : [ 2 ] 에서는 디스패쳐블 오브젝트에 대해서 다음과 같이 설명하고 있습니다. 위에서의 추측이 맞는 것 같습니다.




참고 자료


[ 1 ] Vulkan 1.1.89 - A Specification (with all registered Vulkan extensions), The Khronos Vulkan Working Group.


[ 2 ] Vulkan Loader DeepDive, Khronos.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

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

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



Extension Naming Rules


Khronos 는, OpenGL 도 그렇고 Vulkan 도 그렇고, 익스텐션이라는 개념을 사용해서 API 의 확장성을 보장합니다.


익스텐션 이름의 규칙은 다음과 같습니다.


VK_<author>_<name>


<author> 는 다음과 같은 규칙을 가집니다.


  • KHR : Khronos 익스텐션.
  • KHX : Khronos 의 실험적( experimental ) 익스텐션. Vulkan 1.1 에서는 모두 KHR 로 승격되었고 더 이상 사용되지 않음.
  • NV : Nvidia 익스텐션.
  • NVX : Nvidia 의 실험적 익스텐션.
  • VALVE : Valve 익스텐션.
  • EXT : 여러명의 제작자( 사 )가 개발한 경우의 익스텐션.
  • ANDROID : Android 용으로 Google 에서 개발된 익스텐션.


익스텐션에 대한 모든 명세는 [ 1 ] 에 포함되어 있습니다.


Emumerating Extensions for Layer


vkCreateInstance() 호출을 통해 명시적으로 로더를 로드하기 위해서는 현재 머신에서 어떤 레이어 익스텐션을 지원하고 있는지 알 필요가 있습니다. 


특정 레이어를 위해서 필요한 익스텐션들은 json 파일에서 찾아보실 수 있습니다. 예를 들어 "VKLayer_core_validationr.json" 파일에는 지원하고 있는 익스텐션의 리스트가 있습니다.



예를 들어 "VK_LAYER_LUNARG_core_validation" 레이어를 생성하기로 했다면, vkCreateInstance() 를 호출할 때 "instance_extensions" 필드에 있는 "VK_EXT_debug_report" 라는 익스텐션을 추가해 줘야 합니다. 그리고 vkCreateDevice() 를 호출할 때는 "device_extensions" 에 있는 "VK_EXT_debug_marker" 와 "VK_EXT_validation_cache" 라는 익스텐션을 추가해 줘야 합니다. 물론 사용하지 않으려면 넣지 않아도 상관없습니다.


그런데 이런 걸 외우고 다닐 수도 없고, 하드코딩하는 것도 좀 찝찝합니다. 그래서 Vulkan 은 vkEnumerateInstanceExtensionProperties() 와 vkEnumerateDeviceExtensionsProperties() 라는 함수를 지원하고 있습니다.


이 함수들은 다음과 같은 시그너쳐를 가집니다.



그런데 실제 property 개수를 모르기 때문에 처음에는 pProperties 를 nullptr 로 넘겨 개수만 받고 그 다음에 실제 array 를 넘겨서 데이터를 받습니다. 그 예가 아래에 나와 있습니다.



하지만 위의 코드에서는 첫 번째 파라미터인 pLayerName 을 nullptr 로 넘겼기 때문에 레이어 사용여부와는 상관없이 모든 레이어의 익스텐션이 반환됩니다. 그러므로 특정 레이어를 위한 익스텐션만을 뽑아 내고자 한다면 레이어의 이름을 공급해 줘야 합니다.


예를 들어 보죠. 아까 살펴 본 json 파일에서 레이어의 "name" 필드 값이 "VK_LAYER_LUNARG_core_validation" 였기 때문에 이것을 넘겨 보도록 하겠습니다.



이러면 "VK_EXT_debug_report" 라는 결과가 출력됩니다. json 의 내용과 같죠?


하지만 생각해 보면 이것도 좀 삽질이라는 느낌이 들겁니다. 레이어가 한 두 개도 아니고 이렇게 하는 것은 불합리합니다. 그러므로 모든 레이어를 돌면서 이 정보를 추출해 낼 수가 있습니다.



그런데 1.0 초반부터 레이어에 대한 디바이스 익스텐션은 없어진 것으로 보입니다. Vulkan spec 에서는 다음과 같이 이야기하고 있습니다.


30.1.1. Device Layer Deprecation


Previous version of this specification distinguished between instance and device layers. Instance layers were only able to intercept commands that operate on VkInstance and VkPhysicalDevice, except they were not able to intercept vkCreateDevice. Device layers were enabled for indivisual devices when they were created, and could only intercept commands operating on that device or its child objects.


Device-only layers are now deprecated, and this specification no longer distinguishes between instance and device layers. Layers are enabled during instance creation, and are able to intercept all commands operating on that instance or any of its child objects. At the time of deprecation there were no known device-only layers and no compelling reason to create one.


In order to maintain compatibility with implementations released prior to device-layer deprecation, applications should still enumerate and enable device layers. The behavior of vkEnumerateDeviceLayerProperties and valid usage of the ppEnabledLayernames member of VkDeviceCreateInfo maximizes compatibility with applications written to work with the previous requirements.


요약하자면 이제는 디바이스에다가 레이어 익스텐션을 넣을 필요가 없다는 겁니다. 그런 경우가 있다면 호환성을 유지하기 위해서라는 겁니다. 하지만 이제 시간이 꽤 지났기 때문에 그런 호환성조차 유지할 필요가 없을 것으로 보입니다.


Get functionality for extension


"base/VulkanDebug.cpp" 의 debug 에서는 다음과 같이 함수를 가지고 와서 호출합니다. vkGetInstanceProcAddr() 을 사용하여 함수포인터를 획득하는 것을 보실 수 있습니다.



정리


json 파일은 인스턴스 익스텐션 정보와 디바이스 익스텐션 정보를 포함합니다. 인스턴스와 디바이스를 생성할 때 각각의 익스텐션 정보를 추가하면 그 레이어의 기능을 이용할 수 있습니다. 하지만 디바이스 익스텐션은 이제 지원하지 않으므로 그냥 전부 인스턴스 익스텐션인 것처럼 생각하고 사용하시면 됩니다.


vkGetInstanceProcAddr() 를 호출하여 특정 함수에 대한 포인터를 얻어와 실행하는 것이 가능합니다.


참고자료


[ 1 ] Vulkan 1.1.89 - A Specification (with all registered Vulkan extensions), The Khronos Vulkan Working Group.


[ 2 ] Architecture of the Vulkan Loader Interfaces, LUANR XCHANGE.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

주의 : 허락받고 번역한 것이 아니므로 언제든 내려갈 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참조하세요.

원문 : Brief guide to Vulkan layers, RenderDoc.



Brief guide to Vulkan layers


Vulkan 은 정말 멋진 개념들을 많이 가지고 있습니다만, 아직까지는 많은 주의를 기울이지 않은 것이 하나 있는데, 그것은 API 에코시스템( ecosystem )에 내장된 레이어 시스템입니다.


다른 API 들은 일반적으로 런타임에 직접적으로 연결됩니다. 이 연결은 완전히 불투명( opaque )하며 그것의 모든 작업들에 대한 호출을 드라이버로 넘깁니다. 이는 그 툴들과 애드인( add-in ) 기능들에 대해 두 가지 옵션이 있음을 의미합니다: 특정 플랫폼에 국한된 인터셉션 핵( platform specific interception hack )을 사용해서 그것들을 응용프로그램과 API 사이에 밀어 넣을 수도 있고, 플랫폼 홀더( platform-holder )나 IHV 에 의해 빌드되어 런타임이나 드라이버 자체에서 훅( hook )을 작성할 수도 있습니다( 역주 : "platform-holder" 라는 것은 게임 소프트웨어가 동작하는 하드웨어를 제조하는 회사를 의미합니다. IHV 는 "Independent Hardware Vendor" 의 머릿글자로 하드웨어 제조사를 의미합니다 ).


이것이 적절한 유스케이스( use-case )인 것으로 보이기는 하지만, 사실은 그렇지 않습니다 - 모든 개발자들은 API 자체의 validation 및 checking 기능을 때때로 사용하고 있으며, Vulkan 과 같은 더 새로운 명시적 API 들에 대해서는 이게 시간을 절약하는 데 있어 매우 중요합니다. 이와 유사하게 디버거에서 프로우파일러까지 매우 다양한 툴들이 API 에 접근하기 위해서 필요하게 될 것입니다. 이 위에 런처 오우버레이( overlay )라든가 성능 측정기, 비디오 캡쳐같이 모두가 접근하기 원하는 것들이 존재합니다.


Having all of these systems trying to fight to themselves with no way of co-operating only leads to trouble, so having system built-in to the API to add-in these different things saves everyone a lot of hassle.


대부분의 개발자들이 validation, profiling, debugging 등을 위해 제공된 레이어들을 그냥 사용만 하고 싶어 하지만, 어떤 개발자들은 그 시스템이 실제로 동작하는 방식에 흥미를 가지며, 그것이 자신만의 레이어를 만드는데 도움이 되기를 원합니다. 이 조각들이 정확히 어떻게 맞춰져 가는 지에 대해서 설명하는 세부 명세가 있는데, 여러분은 반드시 그걸 읽어 봐야 합니다. 하지만 좀 이해하기가 힘들 수 있으므로, 그것들을 모아서 좀 친숙하게 소개해 보고자 합니다.


- baldurk


The Loader


로더는 Vulkan 런타임의 중심 결정권자입니다. 응용프로그램은 로더하고만 직접적으로 대화합니다. 그러면 로더는 요청된 레이어들을 열거( enumerating )하고 유효화( validating )하고, ICD( 역주 : Installable Client Driver ) 들을 열거하고, 그것들을 조직화하고, 응용프로그램에 대한 통합된( unified ) 인터페이스를 제공합니다.


로더는 - Vulkan ecosystem 에서 대부분 그러하듯이 - 오픈 소스이기 때문에, 여러분은 그것들이 어떻게 구현되어 있는지 정확하게 알 수 있습니다. 소스를 오픈해서 여기에서 필자가 이야기하는 개념들을 찾아 보는 것은 좋은 생각입니다. 필자가 앞에서 링크한 명세도 이용할 수 있습니다. 그것은 로더와 레이어, 그리고 ICD 가 상호작용하는 방식에 대해 정확하게 정의하고 있습니다.


응용프로그램이 vulkan-1.dll 이나 libvulkan.so.1 에 대해 링크를 할 대, 그것들은 로드에 대해서 링크하고 있는 것입니다. 로더는 vkCreateImage vkCmdDraw 와 같은 ( 익스텐션이 아닌 ) 모든 핵심 Vulkan 함수들을 익스포트합니다. 응용프로그램이 이들 중 하나를 호출하면, 그것들은 Vulkan 드라이버로 직접 넘겨지는 것이 아니라 로더로 넘겨집니다.


응용프로그램이 하게 될 첫 번째 동작은 인스턴스 익스텐션들과 레이어들에 대해 질의하는 것이고, 그리고 나서 vkCreateInstance 를 호출합니다. 이렇게 하기 위해서는 로더가 이미 어떤 레이어들을 이용할 수 있는지 알고 있어야 하며, 그 방식은 플랫폼마다 다릅니다. Windows 에서는 레이어들은 레지스트리 키( HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan ) 에 등록되어 있으며, Linux 에서는 시스템이나 유저가 지정한 방식에 따라 달라집니다. 등록된 파일들은 .json 매티페스트( manifest ) 인데, 그것은 어떤 레이어가 이용가능하며 어디에서 실제 라이브러리를 찾을 수 있는지를 기술합니다.


이러한 모든 질의는 json 매니페스트를 읽어들임으로써 충족되며, 레이어 모듈 자체를 읽어들일 필요가 없습니다. 각 레이어들이 제공하는 익스텐션들은 매니페스트 내부에 세부적으로 기술되어 있으며, 이 질의를 통해 획득할 수 있는 이름과 버전을 포함합니다. 이는 응용프로그램에 의해서 활성화되어 있지 않은 레이어들은 메모리에 절대 로드되지 않는다는 것을 의미합니다.


로더는 ICD 들을 로드해야만 합니다. 디바이스가 제공하는 인스턴스 익스텐션들을 결정하기 위해서입니다. ICD 는 매니페스트 파일과 매우 유사한 방식으로 등록되어 있습니다. 이 시점에 우리는 응용프로그램이 요청하는 레이어들을 생성할 수 있으며 그 레이어들은 이제 어떤 실제 동작을 취할 수 있게 됩니다.


필자는 단지 Windows 나 Linux 와 같은 데스크탑 플랫폼에서 사용중인 로더에 대해서만 다루고 있습니다. Android 로더는 좀 한계가 있고 제한적입니다. 그래서 필자는 그런 디바이스를 다루기 위해 필요한 부차적인 hoop 과 제약에 대해 다루고 싶지는 않습니다. 전체적인 원리는 여전히 동일합니다.


Dispatch Chains 


이 시점에 우리는 ( vkCreateInstance 같은 ) 단일 함수 호출이 로더와 ICD 들, 그리고 다양한 레이어들에 전달되는 방식에 대해서 이야기해 볼 필요가 있습니다.


응용프로그램이 로더가 정적으로 익스포트한 함수를 호출할 때, 그것은 트램펄린 함수( trampoline function ) 을 호출합니다( 역주 : 우리가 "방방"이라 부르는 그 트램펄린. [ 함수내의 함수선언과 trampoline ], [ Win32 API Hooking - Trampoline Hooking ] 참고하세요 ). 이 트램펄린 함수는 다시, 디스패치 체인( dispatch chain )이라 불리는 것을 호출합니다.


모호함을 줄이기 위해서 이야기하자면, 디스패치 체인의 개념( idea )은 실행이 흘러가는 것을 따라가는 함수 포인터의 체인입니다. 로더의 트램펄린 엔트리 포인트( entry point )에서 시작해, 트램펄린은 첫 번째 레이어를 호출하고, 첫 번째 레이어는 두 번째 레이어를 호출합니다. 이 체인은 최종적으로 실제로 작업을 수행하는 ICD 의 엔드포인트( endpoint )에 도달할 때까지 이어집니다.


vkCreateInstance 는 이런 특별한 함수들 중의 하나입니다. 디스패치 체인의 엔트리 포인트에 있는 트램펄린 함수에서, 그것은 먼저 요청된 레이어들과 익스텐션들이 유효한지를 검사합니다. 모든 것이 유효하다면, 그것은 Vulkan 인스턴스와 디스패치 체인을 할당하고, 첫 번째 레이어에서 vkCreateInstance 를 호출합니다. 첫 번째 레이어는 자신과 내부 구조체들에 대한 초기화를 수행하고, 실행을 다음 레이어의 vkCreateInstance 로 넘기는 식입니다.


이제 응용프로그램의 관점에서 Vulkan 인스턴스는 거의 로더 개념에 가깝습니다. They represent everything all together for its use of Vulkan, but they also combine all the available ICDs into one unified front. This means that when we reach the end of the dispatch chain for vkCreateInstance, we can't end up in an ICD. There could be several in use at the same time, and ICDs don't know about each other to chain together( 역주 : 여러 개의 ICD 가 존재하기 때문에, 특정 ICD 는 다른 ICD 들이 vkCreateInstance 호출과 연결되어서 처리되고 있는지 여부를 알 수 없으므로 함수 호출이 언제 종료되어지 판단하기 힘들다는 의미인듯 ).


로더는 호출하기 위한 마지막 레이어를 위해서 자신만의 종료 함수( terminator function )를 디스패치 체인에 삽입함으로써 이 문제를 해결합니다. 이 종료 함수는 이용가능한 각 ICD 상에서 호출되며, 그것들을 이후의 사용을 위해서 저장합니다. 아래 다이어그램은 로더 명세에서 가지고 왔습니다:



As we were going along, 레이어들은 자신을 초기화하고 디스패치 체인에서 자신의 공간을 준비하고 있었습니다. 특히 모든 레이어들은 vkGetInstanceProcAddr 를 사용해서 다음 레이어에서 호출하기를 원하는 엔트리 포인트를 찾습니다. 각 레이어들은 vkGetInstanceProcAddr 를 다음 레이어에서 호출하고 이들을 디스패치 테이블에 저장합니다. 이것은 그냥 함수 포인터들로 구성된 구조체입니다.


생성에 앞서 이렇게 하기 때문에, 로더가 체인의 첫 번째 레이어의 엔트리 포인트를 호출할 때, 그것은 이미 다음 위치( 역주 : 다음에 호출될 함수 )를 알고 있음을 의미합니다. 이것은 유용한 기능을 가능하도록 만들기도 합니다 - 레이어들은 모든 Vulkan 함수를 후킹할 필요가 없슶니다. 그냥 관심있는 것만 하면 됩니다.


이는 로더와 각 레이어가 vkGetInstanceProcAddr 를 호출해 디스패치 체인 내의 다음 함수를 검색하고 있었을 때 발생합니다. 만약 레이어가 함수 호출에 간섭( intercepting )하고 싶지 않다면, 자신의 함수를 반환할 필요가 없습니다. 대신에 vkGetInstanceProcAddr 호출을 다음 레이어에 전송하고 결과를 반환할 수 있습니다. 각각의 지점에서 디스패치 테이블이 다음에 호출된 함수가 무엇인지 알고 있는 한, 레이어를 한 두 개 스킵한다고 해도 문제가 되지 않습니다.


 

위의 예제에서, 레이어 A 와 레이어 B 는 모든 함수에 간섭하는 것은 아닙니다. The loader's dispatch table will go partly to A and partly to B, likewise A's dispatch table will go partly to B and partly directly to the ICD. 단순함을 위해 우리는 이런 함수들이 로더 종료 함수를 가지고 있지 않다고 가정합니다.


여러분은 이를 각 레이어에서 비균질적으로( sparsely ) 생성된 대규모 디스페치 체인으로 생각해도 되고, 아니면 길거나 짧을 수 있는 함수당 디스패치 체인으로 생각해도 됩니다. vkFunctionB 를 위한 디스패치 체인을 vkFunctionC 를 위한 체인과 비교해 보십시오.


레이어가 특정 함수를 위한 디스패치 체인에 참여하고 있지 않는다고 하더라도 원할 때 그 함수들을 호출할 수 없는 것은 아니라는 점에 주의해야만 합니다. 위의 예제에서, 레이어 A 는 vkFunctionD 에 간섭하는 것에 관심이 없다고 하더라도 자신의 기능의 일부로서 호출하기 원할 수도 있습니다.


vkQueuePresentKHR 에 대한 호출에 대해서만 간섭해서 화면에 그림을 그릴 수 있는 성능 오우버레이의 예를 생각해 봅시다. 하지만 그림을 그리기 위해서는 커맨드 버퍼 레코딩( recording ) 함수를 호출할 수 있어야 할 것입니다. 이 방식에서, 레이어 A 는 로더가 하는 것과 같은 방식으로 레이어 B 의 구현에 대한 포인터를 획득할 수 있을 것입니다.



디스패치 체인에 대해서 마지막으로 언급할 것이 있습니다. 사실 단순하지 않은( non-trivial ) Vulkan 응용프로그램을 만드는 동안에 사용되는 디스패치 체인은 두 개입니다.


그 이유는 다음과 같은 상황을 생각하면 명확해집니다. 위에서 이야기했듯이, Vulkan 인스턴스는 여러 개의 ICD 를 묶고 있어서, 디스패치 체인을 통해서 진행되는 모든 함수 호출들은 결과적으로 다른 ICD 로 이동해야 합니다. 그런데 일단 VkDevice 를 생성하고 나면, ( VkPhysicalDevice 를 제공하는 ) 특정 ICD 를 선택하고, 모든 호출들은 특정 ICD 에 대해서 수행됩니다. On top of that, 만약 각 물리 디바이스를 위해 여러 개의 디바이스를 생성한다면, 서로 다른 ICD 들을 향하는 서로 다른 체인들을 가질 수 있습니다!


그래서, 레이어 명세와 이런 디스패치 체인에 대해서 논의하는 문서들에서, 여러분은 인스턴스 체인과 디바이스 체인이 개별적으로 논의되는 것을 볼 수 있을 것입니다. 인스턴스 체인은 인스턴스, 물리 디바이스, 그리고 vkCreateDevice 상의 모든 함수 호출을 위해서 사용되는 것입니다. 디바이스를 생성하고 나면, 그것과 그것의 자식들에 대한 모든 호출들은 디바이스의 디바이스 체인에서 수행됩니다.


Implementation


이제 실제로 레이어들이 동작하는 방식에 대해서 알게 되었습니다 - this primary concept of dispatch tables and dispatch chains allows layers to be strung along arbitrarily and gives us a well-defined way of including many different functions all together without worrying about clashes in function hooking.


이제 이것이 실제로 어떻게 구현되는지 핵심을 알아 보도록 하겠습니다. 그리고 그 과정에서 각 커맨드 버퍼에 대해 간단한  drawcall 통계를 추적하는 예제 레이어를 만들어 보겠습니다.


JSON Manifest


첫 번째로 사려 볼 것은 앞에서 언급한 json 매니페스트입니다. 로더가 시스템에서 레이어를 검색하는 방법에 대해서 이야기할 때 언급했습니다. 이건 매우 직관적이라서, 바로 샘플 레이어를 위한 단순한 매니페스트를 살펴 보도록 하겠습니다.



대부분의 필드들은 VkLayerProperties 를 획득했을 때 직접적으로 매핑되는 것들입니다. file_format_version 은 매니페스트 포맷의 버전이며, library_path 는 ( JSON 위치에 대한 ) 상대 경로이거나 절대 경로, 혹은 단순한 파일이름일 수 있습니다. 마지막의 경우에, 다른 동적 모듈 로딩을 위해서 하는 것처럼 일반적인 경로 검색 로직이 사용됩니다 - OS 에 의해 정의되어 있는 것처럼 system path 들을 검색합니다. Windows 에서는 이것이 LoadLibrary 검색 경로일 수 있으며, Linux 에서는 dlopen 에서 사용되는 ld.so.conf 와 LD_LIBRARY_PATH 로부터의 경로일 수 있습니다.


type 필드는 이전 버전과의 호환성을 위한 것입니다. 원래 Vulkan 명세에서, 레이어들은 인스턴스 전용, 디바이스 전용, 혹은 둘 다 였습니다. 그래서 이 필드는 INSTANCE, DEVICE, GLOBAL 이어야 했습니다. 하지만 이제 레이어들은 그것들이 디바이스 체인에 존재할 필요가 없음에도 불구하고 원치 않는다면, 항상 둘 다 지원하는 것으로 여겨지고 있습니다. 대부분이 두 체인을 모두 사용할 것입니다. 왜냐하면 그곳이 대부분의 기능이 거주하고 있는 곳이기 때문입니다.


이 매니페스트 정도면 충분합니다. 로더는 이를 사용해 vkGetInstanceProcAddr vkGetDeviceProcAddr 엔트리 포인트들을 검색해서 디스패치 체인을 생성할 것입니다. 이것들이 모듈이 익스포트해야 하는 엔트리 포인트의 전부입니다.  API 함수와 완전히 동일한 이름을 가진 함수들을 익스포트해야 하는 것이 이상하고 불편할 수 있기 때문에, SampleLayer_GetInstanceProcAddr SampleLayer_GetDeviceProcAddr 같은 함수들을 대신 익스포트할 수 있습니다. 매니페스트에서 매핑을 추가하면 가능합니다.



이제 여러분의 레이어가 검색될 것이고 사용자가 레이어를 활성화할 때 GetProcAddr 함수가 호출될 것입니다.


vkGetInstanceProcAddr


이 함수들은 vulkan.h 에 리스팅되어 있는 것과 동일한 시그너쳐( signature, 역주 : 함수 원형 )를 가지고 있으며, 여러분이 원하는 대로 동작할 것입니다 - 여러분이 익스포트하는 각 함수에 대해서 순차적으로 pName 파라미터를 strcmp 할 수 있습니다. 그리고 나서 엔트리 포인트의 주소를 반환합니다. 만약 대량 작업을 해야 한다면 이를 위해서 매크로를 사용할 수도 있을 겁니다.



어이구... 모든 엔트리 포인트들에 간섭할 필요가 없다는 것에 대해서 감탄하려고 하는데, 문제가 발생했습니다 - 우리가 고려하지 않은 어떤 다른 함수들이 있을 때 무엇을 반환해야 할까요? 우리는 아직 다음 레이어가 무엇인지 혹은 그것의 vkGetInstanceProcAddr 이 어디에 있는지 알지 못합니다.


이 구현을 마무리하기 위해서는 vkCreateInstance 가 동작하는 방식에 대해서 먼저 살펴 볼 필요가 있습니다.


vkCreateInstance


vkCreateInstance 는 초기화를 획득하고 디스패치 테이블을 생성하는 곳입니다. 레이어가 인스턴스 자체에 대해서는 신경쓰지 않는다고 하더라도, 여전히 초기화 코드를 수행할 필요가 있는 곳이며, 모든 레이어들이 그 함수를 구현할 것을 요구받는 곳입니다. 이와 유사하게 어떤 디바이스 함수에 간섭하고자 한다면, 반드시 vkCreateDevice 와 초기화를 구현해야만 합니다. 그 함수 구현은 거의 대부분 동일합니다. 왜냐하면 디스패치 체인 개념 역시 같기 때문입니다.


레이어의 vkCreateInstance 가 호출될 때, 로더는 초기화에 사용할 수 있는 추가 데이터를 삽입합니다. 특히 Vulkan 의 sType/pNext 확장성을 사용합니다. VKInstanceCreateInfo 구조체에서, 그것은 다른 많은 Vulkan 구조체들에서와 정확히 같은 방식으로 VkStructureType sType 과 const void *pNext 로 시작합니다. 이 두 개의 요소는 추가적인 익스텐션 정보에 대한 연결 리스트( linked list )를 정의합니다. 그 리스트의 엔트리들에 대해서 인지하지 못하고 있다고 해도 그 리스트를 순회할 수 있습니다. 왜냐하면 그것들은 모두 sType/pNext 에서 시작하기 때문이죠.


로더는 추가적인 요소를 pNext 체인에다가 삽입하는데, 그것은 VK_STRUCTURE_TYPE_LAODER_INSTANCE_CREATE_INFO 타입을 가집니다. 이 구조체는 vk_layer.h 에 정의되어 있습니다. 그것은 이 GitHub 나 vulkan.h 로서 같은 SDK 안에 배포되어 있습니다. 이 구조체는 다음 레이어의 vkGetInstanceProcAddr 를 포함하는데, 그것이 우리가 초기화해야 할 필요가 있는 것의 전부입니다. 그 헤더는 VkLayerDispatchTable 구조체를 포함하기도 하는데, 이는 비확장( unextended ) Vlukan 을 위한 함수 포인터를 모두 포함합니다. 여러분이 그것을 사용해서는 안 되지만, 유용합니다.


각 레이어 호출은 직접적으로 같은 create info 구조체를 사용해서 다음 레이어로 넘겨지기 때문에, VkLayerInstanceCreateInfo 내에서도 작은 연결 리스트를 필요로 합니다. 기본적으로 이 리스트의 프런트를 빼고( pop off ) 넣습니다 - 함수 포인터를 취하고, 리스트 헤드를 이동시킵니다. 그래서 다음 레이어가 체인의 세 번째 레이어를 위한 정보를 획득할 수 있도록 하는 식입니다.


로더는 실제로는 두 개의 서로 다른 구조체를 이 타입과 함께 체인에 삽입하는데, VkLayerFunction 에 대한 서로 다른 값을 사용합니다 - 하나는 vkGetInstanceProcAddr 를 위한 것입니다. 다른 하나는 디스패쳐블 오브젝트를 초기화하는 데 사용될 수 있는 함수를 포함합니다. 디스패쳐블 오브젝트를 레이어에서 생성할 때, 여러분은 로더 콜백을 호출해서 디스패치 테이블을 적절히 초기화해 줄 필요가 있습니다 - 이 포스트의 뒤쪽에 있는 오브젝트 래핑( wrapping )을 참고하세요. 이 작업은 보통 트램펄린 함수에 의해서 수행됩니다.


그것이 작동하는 방식에 대한 세부사항은 이 포스트의 범위를 넘어섭니다. 그리고 샘플 레이어를 위해서는 필요하지 않은 기능이므로 그냥 넘어갈 필요가 있습니다. 명세에서 이것이 설정되는 모든 방식에 대한 정확한 세부사항을 읽어보실 수 있습니다.


이걸 전부 넣어서 이제 SampleLayer_CreateInstance 를 구현할 수 있습니다 :



여기에는 빠져 있는 부분이 하나 있습니다 - 이제 우리는 앞으로 계속 이어지는 함수 포인터들을 가진 디스패치 테이블을 생성했는데, 실제로 그것을 어디에 저장해야 하는 걸까요? 우리가 그것을 전역적으로 저장할 수는 없습니다. 왜냐하면 다중 인스턴스들이 생성되면 깨지기 때문입니다( 역주 : 스레드에서의 다중 접근에 의해 깨짐 ).


필자가 아는 한도에서는 이것을 구현하는 두 가지 방법이 있습니다. 첫 번째는 그냥 인스턴스부터 디스패치 테이블까지의 룩업 맵( look-up map )을 만들고 스레드에서의 접근 문제를 해결하기 위해서 전역 잠금( lock )을 거는 겁니다. 그것이 우리 구현에서 사용하게 될 것입니다. 왜냐하면 멋지고 단순하기 때문입니다. 다른 방식인 오브젝트 래핑에 대해서는 옆길로 새서 짧게 언급하도록 하겠습니다.


We'll see as we finish the implementation that the burden of all these locks grows quite quickly, and we're going to end up forcing a lot of our Vulkan use to be serial. That kind of defeats the purpose of having a highly parallelisable API( 역주 : lock 이 많이 발생해서 병렬화라는 목적에 위배될 수 있다는 의미인듯 ). 어떤 레이어들에 대해서는, 이것이 허용할 수 없는 비용이라 생각될 수 있습니다. 하지만 대안이 존재합니다.


Object Wrapping


생성 함수에 의해서 반환받은 핸들들은 - 위의 예제에서 *pInstance - 전체적으로 불투명하며, 중요하게는 원한다면 체인마다 서로 다른 뭔가를 반환할 수 있습니다. 사실 새로운 메모리를 할당하거나 디스패치 테이블을 그곳에 할당하거나 그 포인터를 체인으로 반환하고 그것이 실제인 것처럼 가장하는( pretending ) 것을 막을 수 있는 방법은 없습니다. 그러면 디스패치 테이블을 원할 때마다 그냥 커스텀 구조체를 검색하고 그것을 가지고 올 수 있습니다.


자, 그건 거의 아무것도 아닙니다. 첫 번째 문제는 그 핸들들이 불투명하지만 누군가에게는 매우 중요하다는 것입니다. 만약 핸들을 래핑하고 응용프로그램으로 향하는 체인에 서로 다른 뭔가를 반환했다고 한다면, ICD 쪽으로 체인이 되돌아올 때마다 그것을 잘 캐치해야 합니다. 그것을 다음으로 넘기기 전에 원래의 것으로 대체해야만 합니다. 왜냐하면 그렇게 하지 않으면 안 좋은 일이 발생할 것이기 때문입니다. 경험적으로 볼 때, 이것은 커스텀 메모리에 원래의 것을 저장하고 그 유형의 오브젝트를 사용하는 모든 Vulkan 함수들을 구현해서 그것을 다시 언래핑( unwrap )해야만 한다는 것을 의미합니다. 이제 더 이상 레이어에서 관심을 가지지 않는 함수들을 건너뛸 수가 없게 됩니다.


경험적으로 볼 때, 어떤 오브젝트를 래핑하는 거의 모든 레이어들이 그것들을 래핑하게 된다는 것을 의미합니다. 그래서 이는 all-or-nothing 접근법이 될 것입니다. 또한 여러분이 구현 시점에 알고 있지 않은 익스텐션이 그 오브젝트를 사용할 수도 있다는 것을 고려하면 두 배로 까다로워질 것입니다. 그런 익스텐션 함수 호출이 언래핑을 하도록 간섭할 수 있는 방법이 존재하지 않습니다. 이는 오브젝트 래핑을 하는 레이어들이 그 함수들을 추가하는 모든 익스텐션과 호환되지 않는다는 것을 의미합니다.


The second thing is more of an implementation note - 로더는 이 핸들들을 불투명한 것으로 취급하지 않습니다. 디스패쳐블 핸들 - VkInstance, VkPhysicalDevice, VkDevice, VkQueue, VkCommandBuffer - 들의 경우, 그것들이 가리키는 메모리는 반드시 첫 번째 sizeof( void* ) 바이트들 내부에 디스패치 테이블을 포함해야만 합니다. 경험적으로 볼 때, 이것은 커스텀 메모리를 할당할 때 로더의 디스패치 테이블을 첫 번째 포인터로 먼저 복사하고 다음으로 원래 오브젝트를 두 번째 포인터로 복사하고 여러분이 원하는 정보는 그게 무엇이든지 그 이후에 저장해야만 한다는 것을 의미합니다.


이런 개념은 매우 친숙하게 들릴 것입니다 - 이것은 C++ 의 vtable 의 개념과 매우 유사합니다. 가상함수는 기저 클래스에 대한 포인터를 통해서 호출할 필요 없이 오브젝트에 대해서 호출될 수 있습니다. 위의 요구는 사실상 오브젝트를 래핑할 때 vtable 을 예약할 것을 요청하는 것과 같습니다.


단점들이 존재하기는 하지만, 오브젝트 래핑은 오브젝트에 접근하기 위한 전역 잠금을 요구하지 않는다는 큰 이점을 가지고 있습니다 - 특정 데이터나 디스패치 테이블과 같은 오브젝트. 만약 조그만한 레이어를 작성하고 있다면, 이 방식을 따라하고 싶지 않을 것이지만, 많은 함수들에 대해 간섭하는 레이어들에 대해서는 오브젝트 래핑을 사용하는 것이 적절할 것입니다.  오브젝트 래핑이 가지는 책임에 대해서 주의하기만 하면 됩니다.


이제 전역 맵과 잠금을 정의할 수 있으며, 디스패치 테이블을 인스턴스에 저장할 수 있습니다:



I've also snuck in a little detail here - 나중에 명확해지기를 바라기 때문에, 인스턴스 핸들 자체를 맵의 키로 사용하는 것이 아니라 로더의 디스패치 테이블 포인터를 키로 사용합니다. GetKey 함수는 그냥 void* 포인터를 반환합니다.


이제 디스패치 테이블을 만들었으니, SamplerLayer_GetInstanceProcAddr 로 돌아가서, 우리가 간섭하지 않을 다른 함수들을 위해 다음 레이어로 전송하는 코드를 마무리할 수 있습니다: 



그렇게 함으로써 이제 우리 레이어는 인스턴스 체인상에 있게 됩니다! 대부분의 레이어들은 스스로를 디바이스 체인에서도 초기화하기를 원할 것입니다. 왜냐하면 관심있는 대부분의 함수들이 거기 있기 때문입니다. 하지만 그것이 엄격히 요구되는 것은 아닙니다.


vkGetInstanceProcAddr 구현에서 알아챘을지도 모르겠지만, 레이어들은 레이어와 익스텐션 프라퍼티( property )들을 위한 열거 함수( enumeration function )들도 익스포트해 줄 필요가 있습니다 - these either forward themselves on, or return the results for themselves if pLayerName matches their own name. 너무 단순해서 여기에서 코드를 포스팅하지는 않겠습니다. 하지만 샘플 코드의 마지막에서 전체 세부사항을 보실 수 있습니다. 이와 유사하게 SampleLayer_DestroyInstance 의 구현도 그냥 isntance_dispatch 의 맵을 제거하기만 합니다.


vkGetDeviceProcAddr & vkCreateDevice


SampleLayer_GetDeviceProcAddr SampleLayer_CreateDevice 함수를 구현하는 것은 인스턴스 버전을 구현했을 때와 거의 정확히 같은 패턴을 따릅니다. 왜냐하면 이 모든 개념들이 동일하게 적용되기 때문입니다. 차이가 있다면 주의해야 할 점이 있다는 겁니다 - VkDevice 와 그것의 자식들은 디바이스 상에 존재하지만, VkCreateDevice 함수는 VkInstance 상에서 호출되고 있으며, 인스턴스 체인에 거주한다는 것입니다.



이제 인스턴스 체인과 디바이스 체인을 초기화했습니다. 그리고 디스패치 체인에서 호출되기를 원하는 디스패치 테이블을 채우고 저장했습니다. 이 레이어는 전체적으로 수동적( passive )이라는 점에 주의하세요 - 그것은 전송되는 동안 어떤 Vulkan 함수도 호출하지 않습니다 - 그래서 우리의 디스패치 테이블은 우리가 간섭하고자 하는 그 함수와 거의 비슷합니다. 하지만 실제 레이어들을 작성할 때는 전체 테이블을 생성하기를 원할 겁니다.


Command buffer statstics


지금까지 실제로는 아무것도 하지 않는 레이어를 만들었습니다. 이제 어떤 기능을 추가해 봅시다. 각 커맨드 버퍼에 기록된 드로콜, 인스턴스, 정점들을 세서 커맨드 버퍼 기록이 끝날 때 출력할 것입니다.


먼저 커맨드 버퍼 데이터를 저장하기 위해서 구조체와 맵을 전역적으로 선언합니다:



멀티스레딩 환경에서의 기록이 문제를 발생시키지 않도록 하기 위해서 이것을 동일한 전역 잠금을 사용해 보호할 것입니다( see why the lock + map approach starts to have issues? ). 커맨드 버퍼의 생명주기를 적절히 추적하기 위해 커맨드 버퍼 해제와 커맨드 풀 생성/삭제 함수들에 대해 간섭한다든가 하는 일을 하지는 않을 겁니다. 그냥 이 맵을 그대로 놔둘 겁니다.


vkBeginCommandBuffer 에서 상태를 0 으로 재설정할 겁니다( 이 경우에 커맨드 버퍼는 이전에 기록되었다가 재설정되었습니다 ). vkEndCommadnBuffer 에서 그것들을 출력할 겁니다. 데이터를 기록하기 위해서 vkCmdDraw vkCmdDrawIndexed 에 대해서도 간섭할 겁니다. 각각의 경우에, 우리의 작업을 수행한 후에 체인의 다음 레이어로 이동할 것입니다.



여기에서 인스턴스 핸들 자체가 아니라 로더의 디스패치 테이블 포인터를, instance_dispatch 와 device_dispatch 맵의 키로 사용하는 이유를 알 수 있습니다. 두 개의 디스패치 체인만이 존재한다는 사실의 이점을 취할 수 있습니다 - 하나는 인스턴스이고 다른 하나는 디바이스 및 그것들의 자식입니다. 우리가 커맨드 버퍼상의 함수를 가지고 있고 디스패치 테이블에서 전송 포인터를 검색하고자 할 때, 모든 커맨드 버퍼들에 대한 포인터는 디바이스를 위한 포인터들과 정확하게 일치합니다. 그것들이 같은 디바이스 체인에 존재하기 때문입니다!


그렇게 함으로써, 믿든지 말든지, 우리 레이어는 실제적으로 완결되었습니다. 단순한 삭제 함수들과 레이어 열거/질의 함수들을 제외하고는 코드를 모두 포스팅했습니다 - 300 라인 정도 됩니다.


Explicit and Implicit layers


여기에서 필자가 짧게 언급하고 싶은 마지막 개념이 있습니다. 위에서 세부적으로 기술한 모든 것들은 여러분에게 "명시적"인 레이어로 알려진 것들에 대한 겁니다. 명시적 레이어는 사용자가 vkCreateInstance 에서 명시적으로 요청할 때만 활성화되는 레이어입니다. 명시적 레이어들의 리스트에 매니페스트가 등록되어 있으며, 일반적인 방식으로 열거됩니다.


하지만 어떤 경우에는 응용프로그램의 코드를 수정하지 않고 검색해서 활성화하기를 원하는 레이어가 있을 수 있습니다. 응용프로그램을 실행하기 위해서 사용하는 디버거나 프로우파일러같은 툴들을 생각해 보시죠. 여러분은 그것들의 레이어 이름이 무엇인지 알거나 컴파일하고 싶지 않을 겁니다.


레이어를 활성화하는 것은 코드를 변경하지 않으며, 여러분은 단지 레이어가 등록되는 방식만 변경하면 됩니다. JSON 매니페스트를 등록할 때, 레이어를 명시적 검색 경로에 등록할지 묵시적 검색 경로에 등록할지 선택하는 것이 가능합니다. 만약 묵시적인 레이어로 등록한다면, 매니페스트에 두 개의 추가적인 엔트리만 정의애햐 합니다.



그 두개의 엔트리는 - enable_environment 와 disable_environment 입니다 - 레이어를 활성화하고 비활성화하는 트리거( trigger )가 됩니다. 어떤 레이어도 항상 활성화되지는 않습니다. 대신에 사용자에 의해 명시적으로 요청되지 않은 레이어들은 로더가 그것을 활성화할 수 있도록 설정하는 환경변수를 가지고 있어야만 합니다.


그것은 또한 'off-switch' 도 가지고 있어야 합니다: 묵시적인 레이어를 관리하고 로드되는 것을 막을 수 잇는 환경변수. 이것은 응용프로그램이 호환되지 않는 묵시적인 레이어를 검색할 때나 로딩을 중지할 때 유용합니다. 만약 변수가 둘다 설정되어 있다면, 'off-switch' 가 우선순위를 가지며 레이어는 로드되지 않을 것입니다.


Conclusion


이 포스트를 읽고 난 후에 로더의 작동 방식, 레이어를 초기화하고 체인을 생성하는 방식, 자신만의 레이어를 구현하는 방식에 대해서 더 잘 이해할 수 있었기를 바랍니다. 여러분이 레이어를 구현하지 않는다고 하더라도, 응용프로그램 코드에서 vkGentInstanceProcAddr vkGetDeviceProcAddr 를 호출하는 것의 차이를 이해할 수 있게 되었을 겁니다.


응용프로램은 디스패치 체인을 사용해 하는 것이 아무것도 없기 때문에, 실제적인 차이는 vkGetDeviceAddr 가 디스패치 체인 내의 첫번째 엔트리를 직접적으로 가리키는 함수 포인터를 어떤 것을 반환하느냐에 따라 달려 있습니다 - 제공되는 레이어가 없다면 ICD 를 직접적으로 가리키는 함수 포인터를 반환합니다. vkGetInstanceProcAddr 는  로더가 익스포트하는 것과 동일한 트램펄린 함수를 반환할 수도 있습니다. 그것은 디스패쳐블 핸들로부터 디스패치 테이블을 로드( fetch )하여 거기로 점프합니다.


여러분이 살펴 볼 수 있도록 하기 위해 마지막 레이어를 위한 소스를 GitHub 에 저장해 놨습니다.


만약 질문이 있다면 twitteremail 을 통해서 자유롭게 하시기 바라며, 저도 최선을 다해 답변을 하거나 필요하다면 이 포스트를 갱신하도록 하겠습니다.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

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

주의 : 이상하면 참고자료를 참조하십시오.

정보 : 본문의 소스 코드는 Vulkan C++ exmaples and demos 를 기반으로 하고 있습니다. 



샘플 선정


직접 코드를 작성할까 아니면 이미 나와 있는 샘플을 사용할까 고민하다가, 전문가 중 한 명이 작성한 샘플을 사용하는 것이 좀 더 나은 신뢰성을 제공할 것이라는 생각에 후자를 선택했습니다( 틀려도 내탓하지 마!! ).


이 시리즈를 작성하면서 사용할 샘플은 Sascha Willems 의  [  Vulkan C++ exmaples and demos  ] 입니다. 이 분이 여러 가지 Vulkan Sample 을 만드셨더군요. 특히 이 분이 작성한 [ VulkanCapsViewer ] 는 매우 쓸만한 툴입니다. 이 분에 대해서 궁금하시다면 [ 개인 블로그 ] 를 확인하세요. 


VulkanCapsViewer 의 릴리스 빌드는 [ Vulkan GPU Info ]에서 받으실 수 있습니다. 이 시리즈를 진행하다가 제가 가진 디바이스의 Extension 같은 것들을 확인할 때 이 툴을 사용할 것입니다.


어쨌든 설명에 필요하다면 앞에서 언급한 샘플을 수정해 가면서 진행을 하도록 하겠습니다.


레이어 리스트 확인


VulkanCapsViewer 를 실행하고 가장 오른쪽 "Vulkan" Tap  에서 "Layer" 탭을 선택하면 현재 API 와 Device 에 의해 지원되는 Layer 들의 목록을 확인할 수 있습니다. 이 작업을 하다가 "Upload" 를 눌러 처음으로 "GeForce GTX 980" 을 Database 에 등록하는 영광(?)을 얻었습니다.


그림1. CapsViewer 로 살펴 본 레이어 리스트.


매우 많은 레이어를 지원하고 있네요. 문제는 이것을 어떻게 활성화하느냐는 겁니다.


명시적( Explicit ) 레이어와 묵시적( Implicit ) 레이어


Windows 에서 Vulkan loader 가 두 종류의 레이어를 검색합니다. 하나는 명식적 레이어와 묵시적 레이어입니다. 


Vulkan loader 는 다음과 같은 레지스트리를 검색해서 레이어의 목록을 찾아냅니다.


HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ExplicitLayers

HKEY_LOCAL_MACHINE\SOFTWARE\Khronos\Vulkan\ImplicitLayers


이 레지스트리 값은 DWORD 형으로서 키 이름이 레이어 파일의 경로를 담고 있습니다.


그림2. 명시적인 레이어 리스트.


그림3. 묵시적인 레이어 리스트.


명시적인 레이어는 사용자의 요청에 의해 VkInstance 생성시에 로드되고, 묵시적인 레이어는 사용자의 요청없이 특정 상황에서 묵시적으로 로드됩니다.


각 레이어를 위한 json 파일은 레이어를 로드하기 위한 여러 가지 정보를 담고 있으며, "VkLayer_core_validation.json" 파일을 살펴 보면 다음과 같이 되어 있습니다.



그리고 어떤 경우에는 여러 레이어를 하나로 묶어서 로드하는 경우도 있습니다. "VkLayer_standard_validation.json" 이 그런 경우입니다. 대부분의 샘플들을 보면 이 레이어를 사용해서 디버그 정보를 뽑습니다.



묵시적인 경우가 필요한 것인가라는 의문이 들 수 있겠지만, RenderDoc 와 같은 프로우파일러( profiler )를 사용해 보면 실시간에 애플리케이션을 어태치( Attach )하는 경우가 있습니다.


그림4. RenderDoc 의 "Attach to Running Instance" 메뉴.


이 경우에는 이미 애플리케이션이 시작되었고 VkInstance 도 생성된 상황이므로 명시적으로 레이어를 요청할 수가 없습니다. 그러므로 RendeDoc 같은 툴들이 이를 묵시적으로 로드할 수 있는 방법이 필요한 것입니다.


레이어 활성화하기


레이어를 활성화하는 방식은 여러 가지가 있지만, 여기에서는 프로그램 코드 내에서 명시적으로 활성화하는 방법에 대해서만 다루도록 하겠습니다. 명령창에서 환경변수 통해 레이어를 활성화하거나 레이어 설정 파일을 미리 만들어 놓는 등의 작업들에 대해서는 [ 1 ] 에서 확인하시기 바랍니다.


샘플의 "base/VulkanDebug.cpp" 파일에 보면 다음과 같이 디버거를 위해 사용할 레이어 이름을 설정하는 것을 보실 수 있습니다. 이 이름은 json 파일에 있는 "name" 오브젝트의 값입니다.



주의하실 점은 [ Vulkan 은 왜 C 로 구현되었을까? ] 에서 언급했듯이 Vulkan 은 C ABI 를 사용하므로 결국 ANSI 문자열을 사용한다는 겁니다. 요새 대부분은  Unicode( UTF-8 ) 문자열 프로젝트를 사용하기 때문에 이에 주의하셔야 합니다. 왠만하면 ANSI 와 UTF8 사이의 변환을 자유롭게 할 수 있는 유틸리티 클래스를 만들고 작업을 시작하시는 것이 좋습니다.


어쨌든 이러한 목록은 VkInstance 를 생성할 때 명시적으로 지정됩니다. 27 ~ 28 라인에서 이 작업을 수행하고 있습니다.



그런데 단순히 레이어를 생성한다고 해서 기능이 동작하는 것은 아닙니다. 이 레이어를 구동하는데 필요한 익스텐션( extension ) 들을 지정할 필요가 있죠. 20 라인에서 validation 을 위한 익스텐션을 추가하고 있는 것을 보실 수 있습니다.


우리가 처음에 "VkLayer_core_validation.json" 파일을 확인했었죠? 거기에는 "instance_extensions" 라는 오브젝트가 있는데 그것의 배열 값에는 해당 레이어를 위해서 요구되는 익스텐션의 리스트가 들어갑니다.


"VkLayer_core_validation.json" 의 경우에는 "VK_EXT_debug_report" 을 요구하구요, 만약 다른 익스텐션이 필요하다면 그것을 추가해 줄 필요가 있습니다. 이러한 익스텐션 이름은 보통 "vulkan_core.h" 에 매크로로 정의되어 있습니다. 이 경우에는 VK_EXT_DEBUG_REPORT_EXTENSION_NAME 입니다. 물론 직접 텍스트를 입력하셔도 무방하지만, 실수를 줄이려면 이런 매크로를 사용하시는 것이 좋겠죠.



이렇게 해서 VkInstance 와 함께 레이어를 로드하면, VkGetInstanceProcAddr() 를 사용해서 해당 레이어의 익스텐션을 위한 API 를 가지고 와서 사용할 수 있습니다. 다음은 "VulkanDebug.cpp" 에서 VK_EXT_Debug_Report 익스텐션을 위한 콜백들을 생성하고 파괴하는 메서드를 구현하는 모습을 보여 줍니다.



정리


레이어는 명시적 혹은 묵시적으로 로드되며, 명시적으로 로드되는 경우에는 VkInstance 생성시에 지정됩니다. 이 때 필요한 익스텐션들도 지정해 줄 필요가 있습니다. 인스턴스 생성 후에는 VkGetInstanceProcAddr() 함수를 통해서 관련 API 를 호출할 수 있습니다.


다음 시간에는 Vulkan Layer 의 내부적인 동작 방식에 대해서 자세히 알아 보도록 하겠습니다. [ 2 ] 에 대한 번역이 될 것입니다.


참고자료


[ 1 ] Layers Overview and Configuration, LUNAR XCHANGE.


[ 2 ] Brief guide to Vulkan layers, RenderDoc.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

주의 : 완벽히 이해하고 쓴 글이 아니라 공부하다가 정리한 것이므로 잘못된 내용이 있을 수 있습니다.

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



Ecosystem


"Software Ecosystem" 이라는 것은 우리 말로 하면 "소프트웨어 생태계" 정도가 될 것 같습니다. 이것의 의미는 다음과 같습니다.


같은 환경에서 개발되고 같이 진화하는( coevolve ) 소프트웨어 프로젝트의 집합


출처 : [ 1 ]


기본적으로 Vulkan 의 기능을 구성하는 많은 extension 및 layer 들은 이러한 ecosystem 을 공유하고 있다고 보시면 됩니다. 그런데 이런 환경을 구성하기 위해서는 중심이 되는 컴포넌트가 필요합니다.


이를 Vulkan 에서는 이런 역할을 수행하는 컴포넌트를 "Loader" 라 부르고 있으며, [ 2 ] 에서 소스 코드를 제공하고 있습니다. 물론 LunarG 를 설치하시면 따로 소스 코드를 빌드할 필요는 없습니다.


High level architecture


[ 3 ] 에서는 Vulkan ecosystem 의 고수준 아키텍쳐를 보여 주고 있습니다.


그림1. Vulkan High Level Architecture. 출처 : [ 3 ].


그림1 에서 볼 수 있듯이 Vulkan Loader 는 Appliation, Layer, ICD 를 연결하는 인터페이스( interface ) 역할을 해 주고 있습니다. 


"ICD" 는 "Installable Client Driver" 의 머릿글자로 Physical Device 와 Loader 사이에서 인터페이스 역할을 합니다. 우리가 OS 개발자나 Driver 개발자는 아니기 때문에, 현재 단계에서는 이 부분에 대해서 깊게 살펴 보지는 않겠습니다( 뭐... 능력도 안 됩니다 ㅠㅠ ).


Layer


Vulkan 은 기본적으로 런타임에 Error-Checking 이나 Debugging 을 위한 기능을 지원하지 않습니다. Memory 관리나 Multi-Threading 과 같은 기능이 성능 향상에 큰 영향을 줬지만, 이러한 기능을 배제하는 것도 성능에 영향을 줍니다.


하지만 개발 도중에 그런 기능들을 사용할 수 없다면, 안 그래도 사용하기 어려운 Vulkan 을 더욱 사용하기 어렵게 만들게 될 것입니다. 그래서 Vulkan 은 Layer 라는 형태로 그런 기능들을 지원하고 있습니다.


Layer 는 라이브러리 형태로 배포되며 이것은 Vulkan 의 API 를 hooking 하거나 기능을 추가합니다. 예를 들어 validation 을 해야 한다고 하면 특정 API 를 hooking 해서 로그를 삽입한다든가 하는 일을 할 수 있겠죠.


현재 LunarG 1.1.84 버전에서는 다음과 같은 layer 들을 제공하고 있습니다.


  • VK_LAYER_LUNARG_api_dump
  • VK_LAYER_LUNARG_assistant_layer
  • VK_LAYER_LUNARG_core_validation
  • VK_LAYER_LNUARG_divce_simulation
  • VK_LAYER_LUNARG_monitor
  • VK_LAYER_LUNARG_object_tracker
  • VK_LAYER_LUNARG_parameter_validation
  • VK_LAYER_LUNARG_screenshot
  • VK_LAYER_GOOGLE_thrading
  • VK_LAYER_GOOGLE_unique_objects


이런 레이어들은 전부 Open Source 입니다. 예를 들어 validation layer 와 관련한 Source 는 [ https://github.com/KhronosGroup/Vulkan-ValidationLayers ] 에 있습니다.


다음 몇 개의 연구 시리즈에서는 각 레이어들이 어떤 의미를 가지고 있고, 어떻게 사용되는지 알아 보도록 하겠습니다.


참고자료


[ 1 ] Software Ecosystem, Wikipedia.


[ 2 ] KhronosGroup/Vulkan-Loader, GitHub.


[ 3 ] Architecture of the Vulkan Loader Interfaces, LunarG.


[ 4 ] Vulkan Specification, Khronos.

주의 : 이 문서는 초심자 튜토리얼이 아닙니다. 기본 개념 정도는 안다고 가정합니다. 초심자는 [ Vulkan Tutorial ] 이나 [ Vulkan Samples Tutorial ] 을 보면서 같이 보시기 바랍니다.

경고 : Vulkan 에 대해서 완벽하게 이해하고 작성된 글은 아닙니다. 공부하면서 정리한 글이라 오류가 있을 수 있으니 이상하면 참고자료를 확인하세요.



원래 AMD 에는 Mantle 이라는 저수준 Graphics API 가 있었습니다. 


예전에 다니던 회사에 AMD 직원이 와서 홍보를 하던 기억이 나네요. 그 당시에 혹하기는 했지만, AMD 의 특정( 최신 ) 하드웨어에서만 지원된다는 점에서 그냥 "와~ 놀랍네" 하고 말았던 기억이 납니다.


역시나 특정 하드웨어에 종속되어 있기 때문에 대중화에 실패한 것 같습니다. 일부 콘솔 게임( video game )들만이 Mantle 로 출시되었습니다. 하지만 이것이 보여준 드라마틱한 성능은 Vulkan, Direct3D 12 와 같은 저수준 API 의 시대를 열었다는데 의의가 있다는 생각이 듭니다.


특히나 Vulkan 의 경우에는 Mantle 의 자손이라고 할 수 있습니다. AMD 가 Mantle 을 Khronos Group 에 기증했고 그걸 기반으로 Vulkan 이 만들어졌기 때문입니다. 역시 Open Source 핵심 진영인 Khronos 답게 이것을 Cross-Platform API 로 만들었습니다. 그리고 특정 언어에 종속되지 않는 SPIR-V binary 를 사용하여 언어의 호환성을 유지합니다. 그러므로 GLSL 이나 HLSL 을 사용해 shader 를 작성하는 것이 가능합니다.


재밌는 건 이것이 OpenGL ES 3.1 이나 OpenGL 4.x 이상을 지원하고 있다는 것입니다. Direct3D 12 가 Window 만을 지원하고 있고 Geforce GTX 급의 카드에서만 안정적으로 지원된다는 것을 고려하면, 요즘과 같은 Cross-Platform 시대에는 Vulkan 이 대세가 되지 않을까 싶습니다.


게다가 성능은 구현측에 달려 있기는 하지만 벤치마킹 업체에서 내 놓은 결과를 보면 의미심장합니다. 아래 이미지들은 [ Quick Look: Comparing Vulkan & DX12 API Overhead on 3DMark ] 에서 가지고 왔습니다.




흥미로운 점은 Mantle 을 만든 AMD 의 카드에서 Vulkan 성능이 더 안 나온다는 겁니다. 참 이상한 결과가 아닐 수 없습니다. 역시 AMD 가 Radeon 을 인수하고 나서 그래픽스 카드쪽으로는 별로 힘을 못 쓰는 것 같네요.


어쨌든 제 예상으로는 Cross-Platform 환경에서 Vulkan 은 대세가 될 것이라 봅니다. 특히나 extension 이나 layer 를 통해서 API 를 확장하고 강화할 수 있는 가능성을 열어 두고 있기 때문에, 성능과 기능을 개선할 수 있는 연구들이 많이 나오지 않을까라는 생각이 듭니다.


그런데 문제는 Vulkan 을 배우는 것이 너무 어렵다는 것입니다. Open Source 의 한계 때문인지 Specification 문서나 API 사용 튜토리얼 류의 글만 잔뜩 있습니다. 게다가 C 로 구현되어 있기 때문에 추상화된 관점에서 API 를 익히기가 어렵습니다.


한 달이 넘는 시간 동안 ( 시간이 날 때마다? ) 열심히 공부해서 렌더러를 구성해 보려고 노력했지만, 추상화와 Glslang 및 reflection 의 벽에 막혀 삼각형 하나도 렌더링하지 못했습니다. D3D9 이나 D3D11 으로 렌더러를 구현했을 때와 비교해 보면 정말 어렵다는 생각이 듭니다.


그래서 이번 기회에 저의 실력을 향상시키고 Vulkan 에 입문하는 분들에게 조금이라도 도움이 될 수 있었으면 하는 바람으로 [ Vulkan 연구 ] 시리즈를 만들어 보기로 했습니다. 


제대로 아는 것이 아니기 때문에 목차는 정하지 않았지만, Vulkan Specification 의 순서대로 진행을 하려고 하지만, 명세를 읽다가 궁금해지는 부분들을 집중적으로 파보는 방향으로 진행될 가능성이 높습니다.

주의 : 허락받고 번역한 것이 아니므로 언제든 내려갈 수 있습니다.

주의 : 2018-10-28 기준 spec 이므로 최신버전이 반영되어 있지 않을 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참고하십시오.

원문 : https://raw.githubusercontent.com/KhronosGroup/GLSL/master/extensions/khr/GL_KHR_vulkan_glsl.txt




Name


KHR_vulkan_glsl


Name Strings


GL_KHR_vulkan_glsl


Contact


John Kessenich (johnkessenich 'at' google.com), Google


Contributors


Jeff Bolz, NVIDIA

Kerch Holt, NVIDIA

Kenneth Benzie, Codeplay

Neil Henning, Codeplay

Neil Hickey, ARM

Daniel Koch, NVIDIA

Timothy Lottes, Epic Games

David Neto, Google


Notice


Copyright (c) 2015 The Khronos Group Inc. Copyright terms at

http://www.khronos.org/registry/speccopyright.html


Status


Approved by Vulkan working group 03-Dec-2015.

Ratified by the Khronos Board of Promoters 15-Jan-2016.


Version


Last Modified Date: 25-Jul-2018

Revision: 46


Number


TBD.


Dependencies


이 익스텐션( extension ) 은 OpenGL GLSL versions 1.40( #version 140 ) 이상 버전에 적용될 수 있습니다.


이 익스텐션은 OpenGL ES ESSL versions 3.10( #version 310 ) 이상 버전에 적용될 수 있습니다.


이 모든 버전들은 GLSL/ESSL 시맨틱( semantics )들을 동일한 SPIR-V 1.0 시맨틱으로 매핑합니다( 가장 최근의 GLSL/ESSL 버전으로 근사( approximating )합니다 ).


Overview


이것은 GL_KHR_vulkan_glsl 익스텐션의 100 버전입니다.


이 익스텐션은 Vulkan API 에서 고수준 언어로서 GLSL 을 사용할 수 있도록 수정한 것입니다. GLSL 은 SPIR-V 로 컴파일되는데, Vulkan API 는 그것을 사용하게 됩니다.


다음과 같은 기능들이 제거되었습니다:

* opaque 타입( 역주 : [ Vulkan Opaque Type ] 참고 )을 배제한 기본 uniform 들( uniform 블락에 존재하지 않는 유니폼 변수들 )

* 자동 카운터( atomic-counters )( atomic_uint 에 기반한 것들 )

* 서브루틴( subroutines )

* shared 및 packed 블락 레이아웃( layout )들

* 이미 사장된( deprecated ) 텍스쳐링 함수들( 예 : texture2D() )

* 이미 사장된 노이즈 함수들( 예 : noise1() )

* 호환 모드 전용 기능들

* gl_DepthRangeParametersgl_NumSamples

* gl_VertexID 와 gl_InstanceID


다음과 같은 기능들이 추가되었습니다:

* 푸시 상수 버퍼( push-constant buffers )

* 개별 텍스쳐 및 샘플러에 대한 셰이더 조합( shader-combining ).

* 디스크립터 셋들( descriptor sets )

* 특수화 상수들( specialization constants )

* gl_VertexIndexgl_InstanceIndex

* 서브패스 입력들( subpass inputs )

* uniform/buffer 블락들을 지원하지 않는 버전들을 위해 uniform/buffer 를 위한 offset 및 align 레이아웃 한정자( qualifiers )를 지원


다음과 같은 기능들이 변경되었습니다:

* 정밀도( precision ) 한정자들( mediumplowp )이 모든 버전에 대해 요구됩니다. 데스크탑 버전에 대해서도 누락되지 않습니다( 데스크탑 버전을 위한 기본 정밀도는 모든 타입에 대해 highp 입니다 )

* gl_FragColor 는 더 이상 implicit broadcast 를 지시하지 않습니다.

* uniform/buffer 블락의 배열들은 전체 오브젝트에 대해 하나의 바인딩 번호만을 가집니다. 배열 요소별로 번호를 가지는 것이 아닙니다.

* 기본 원점( origin )은 origin_lower_left 가 아니라 origin_upper_left 입니다.


이들 각각에 대해서는 아래에서 더욱 세부적으로 논의하겠습니다.


이 기능들을 활성화하는 법


이 익스텐션은 다른 익스텐션들처럼 #extension 을 사용해서 활성화되는 것이 아닙니다. 또한 profile 이나 #version 의 사용을 통해 활성화되는 것도 아닙니다. Vulkan 에서 지정한 용도( usage )를 제외하면, 의도된 GLSL/ESSL 기능들의 수준( level )은 #version, profile, #extension 에 대한 전통적인 사용을 통해서 결정됩니다.


GLSL 프런트 엔드( front-end )는 Vulkan 을 위해 SPIR-V 를 생성하기 위해서 사용됩니다. 그런 도구를 사용하는 것은 Aulkan API 나 GLSL 및 익스텐션을 정의하는 영역의 외부에서 수행됩니다. Vulkan 을 위해 SPIR-V 의 생성을 요청하는 방법에 대해서 알고자 한다면, 컴파일러 문서를 확인하시기 바랍니다( 역주 : Vulkan 을 이것을 "glslang" 이라고 부릅니다. [ GitHub ] 참고. 기본적으로 LunarG 에 포함되어 있으니 별도의 설치가 필요하지는 않습니다  ).


프런트 엔드가 이 익스텐션을 받아들이기 위해서 사용될 때, 그것은 반드시 에러를 검사하고 이 명세를 고수하지 않는 셰이더들을 거부해야만 합니다. 구현측-의존 최대값( maximums )이나 능력치( abilities )는 프런트 엔드 혹은 그 일부에 제공되어야 합니다. 그래야 그것들에 대한 에러 검사를 할 수 있습니다.


셰이더는 다음과 같은 정의를 사용해서 Vulkan 이 지원하는 레벨을 지정할 수 있습니다.



예를 들어, 이는 다음과 같은 셰이더 코드를 작성할 수 있도록 해 줍니다.



Specialization Constants


SPIR-V 특수화 상수들은, 나중에 클라이언트 API 에 의해서 설정될 수 있는데, 이는 layout(constant_id=...) 를 사용해서 선언됩니다. 예를 들어 기본값 12 를 사용해서 특수화 상수를 만들고자 한다면 다음과 같이 할 수 있습니다:



위에서 17 은 ID 인데, API 나 다른 도구들에서 나중에 이 특수화 상수를 식별하기 위해 참조할 수 있습니다. 그리고 나서 API 나 중간 도구( intermediate tool )는, 그것이 완전히 실행 코드( executable code )로 저수준화되기( lowered ) 전에, 그 값을 다른 상수 정수로 바꿀 수 있습니다. 만약 ID 값이 저수준화 전에 변경되지 않는다면, 그 변수의 값은 여전히 12 로 남아있게 될 것입니다.


특수화 상수는 그것이 folding 되지 않는다는 것을 제외하면 const 시맨틱을 가집니다( 역주 : constant folding 은 컴파일러에서의 상수 최적화를 의미하는 것입니다. 여러 상수가 컴파일시에 하나의 상수로 변합니다. [ constant folding ] 참조 ). 그러므로 배열이 위에 있는 'arraySize' 로 선언될 수 있습니다.



특수화 상수들은 표현식 안에 들어갈 수도 있습니다:



이는 data2 의 크기를 'arraySize' 가 지정하는 것의 2 배로 늘리게 되는데, 이는 셰이더를 실행코드로 저수준화할 때 발생하는 일입니다.


특수화 상수와 함께 구성되는 표현식은 셰이더 내에서 그냥 상수와는 다르게 특수화 상수인 것처럼 동작합니다.



그런 표현식은 상수와 같은 위치에서 사용될 수 있습니다.


constant_id 는 스칼라 정수( int ), 스칼라 실수( float ) 이나 스칼라 불리언( bool )에만 사용될 수 있습니다.


특수화 상수에는 기본 연산자와 생성자들만 적용될 수 있으며 그 역시 특수화 상수를 반환합니다:



SPIR-V 특수화 상수들이 스칼라에 대해서만 존재하는 반면에, 벡터 연산을 위해 스칼라가 사용될 수 있습니다:



내장 변수( built-in variable )에 constant_id 를 붙일 수 있습니다:



이는 그것이 특수화 상수처럼 동작하도록 만듭니다. 그것은 완전한 재선언( full redeclaration )은 아닙니다; 다른 모든 특성들은 원래의 내장 선언과 동일하게 온전히 남아 있게 됩니다.


특수 레이아웃인 local_size_{xyz}_id 들을 사용해서 특수화된 내장벡터 gl_WorkGroupSize 는 in 한정자에 적용됩니다. 예를 들어:



gl_WorkGroupSize.y 는 비-특수화 상수로 남아 있습니다. gl_WorkGroupSize 는 부분적으로 특수화된 벡터인 것입니다. 그것의 x 및 z 요소는 나중에 18 과 19 라는 ID 를 사용해서 특수화될 수 있습니다.



Push Constants


푸시 상수들은 uniform 블락 내에 존재하는데, 새로운 레이아웃 한정자 아이디인 push_constantuniform 블락 선언에 적용함으로써 선언됩니다. 이 API 는 상수 집합을 푸시 상수 버퍼에 씁니다. 그리고 셰이더는 push_constant 블락으로부터 그것들을 읽어들입니다.



푸시 상수 유니폼 블락을 위해 사용되는 메모리 회계( memory accounting )는 다른 유니폼 블락들과는 다릅니다: 이것이 맞춰야만 하는 개별적인 작은 풀( small pool )이 존재합니다. 기본적으로 푸시 상수 버퍼는 std430 packing rule 을 따라야만 합니다( 역주 : [ Interface Block (GLSL) ] 의 "Memory layout" 참조, [ ARB_shader_storage_buffer_object ] 참조 ).



Descriptor Sets


디스크립터 셋 내의 각 셰이더 리소스는 ( 셋 번호( set number ), 바인딩 번호( binding number ), 배열 요소( array element ) ) 로 구성된 튜플( tuple )을 할당하는데, 그것은 디스크립터 셋 레이아웃 내의 위치를 정의하게 됩니다.


GLSL 에서는 셋 번호와 바인딩 번호가 setbinding 이라는 레이아웃 한정자에 의해서 할당되었습니다. 그리고 배열 요소는 배열의 첫 번째 요소를 0 으로 하여 인덱스를 순서대로 증가시켜서 묵시적으로 할당되었습니다( 그리고 비-배열 변수를 위해서는 배열 요소가 0 이었습니다 ):



예를 들어, 결합된 두 개의 texture/sampler 오브젝트들은 서로 다른 디스크립터 셋에 다음과 같이 선언될 수 있습니다.



디스크립터 셋의 연산 모델에 대한 세부적인 사항에 대해서 알고자 한다면 API 문서를 참고하시기 바랍니다( 역주 : [ Vulkan spec ] 의 "13.2. Descriptor Sets" 참조 ).


Storage Images


GLSL 셰이더 소스에서 저장소 이미지는 적절한 차원성( dimentionality )과 ( 필요하다면 ) 포맷 레이아웃 한정자로 구성된 uniform image 변수들을 사용해서 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다.



Samplers


GLSL 셰이더 소스에서 샘플러는 uniform sampler 변수를 사용해서 선언되는데, 여기에서 샘플러 타입은 텍스쳐 차원성과 관련이 없습니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다.



Textures ( Sampled Images )


GLSL 셰이더 소스에서 텍스쳐는 적절한 차원성을 가진 uniform texture 변수를 사용해서 선언됩니다 :



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Combined Texture + Samplers


GLSL 셰이더 소스에서 텍스쳐와 샘플러의 결합은 적절한 차원성을 가진 uniform sampler 변수를 사용해서 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



결합된 이미지 샘플러의 디스크립터는 위의 섹션에서처럼 셰이더 내에서 선언된 이미지들이나 샘플러들만 참조할 수 있다는 것에 주의하시기 바랍니다.


Combining spearate samplers and textures


sampler 키워드를 사용해서 선언된 샘플러는 텍스쳐나 이미지가 아니라 필터링 정보만을 포함합니다:



texture2D 같은 키워드를 사용해서 선언된 텍스쳐는 필터링 정보가 아니라 이미지 정보만을 포함합니다:



그러면 텍스쳐 검색( lookup ) 호출을 만드는 시점에 샘플러와 텍스쳐를 결합하기 위해서 생성자가 사용될 수 있습니다:



Texture Buffers ( Uniform Texel Buffers )


GLSL 셰이더 소스에서 텍스쳐 버퍼는 uniform textureBuffer 변수를 사용해서 선언될 수 있습니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Image Buffers ( Storage Texel Buffers )


GLSL 셰이더 소스에서 이미지 버퍼는 uniform imageBuffer 변수를 사용함으로써 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Storage Buffers


GLSL 셰이더 소스에서 저장소 버퍼는buffer storage 한정자와 block 구문( syntax )을 사용함으로써 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Uniform Buffers


GLSL 셰이더 소스에서 저장소 버퍼는uniform storage 한정자와 block 구문을 사용함으로써 선언됩니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



Subpass Inputs


렌더링 패스 내에서, 서브패스는 츨력 타깃에 결과를 쓸 수 있는데, 그러면 다음 서브패스는 입력 서브패스로서 그 결과를 읽어들일 수 있습니다. "서브패스 입력" 기능은 출력 타깃을 읽을 수 있는 기능이 있다고 간주합니다.


서브패스들은 새로운 유형의 집합( set )들을 통해 읽어들여질 수 있는데, 프래그먼트( fragment ) 셰이더에 대해서만 가능합니다:


subpassInput

subpassInputMS

isubpassInput

isubpassInputMS

usubpassInput

usubpassInputMS


샘플러나 이미지 오브젝트들과는 다르게, 서브패스 입력은 프로그래먼트의 ( x, y, layer ) 좌표에 의해 묵시적으로 주소지정이 됩니다.


입력 어태치먼트( input attachment )들은 input_attachment_index 와 descriptor set, 그리고 binding 번호들을 포함합니다:



이것은 다음과 같은 SPIR-V 코드로 매핑됩니다:



i 에 대한 input_attachment_index 는 입력 패스 리스트에서 i 번째 엔트리( entry )를 선택합니다. ( 더 많은 정보를 원한다면 API 명세를 확인하세요 )


이러한 오브젝트들은 서브패스 입력을 다음과 같은 함수들을 통해서 읽어들일 수 있도록 지원합니다:



gl_FragColor


프래그먼트 스테이지 내장 gl_FragColor 는 모든 출력에 대한 broadcast 를 내포하며, SPIR-V 에 제출되지 않습니다. gl_FragColor 에 쓰는 것이 허용되는 셰이더들은 여전히 거기에 쓰기는 하는데, 단지 출력으로 쓰고 있음을 의미할 뿐입니다:

- gl_FragColor 와 같은 타입으로

- location 0 번에

- 내장 변수로 기능하지 않고

Implicit broadcast 는 존재하지 않습니다.


gl_VertexIndex 와 gl_InstanceIndex


새롭게 두 개의 내장 변수가 추가되었는데, gl_VertexIndex 와 gl_InstanceIndex 는 기존의 gl_VertexID 와 gl_InstanceID 를 대체합니다.


어떤 기저( base offset ) 에 대해 상대적인 색인( indexing )을 하는 상황에서, Vulkan 을 위한 이 내장 변수들이 정의되었는데, 이는 다음과 같은 값들을 취합니다:



여기에서 그것은 기저가 실제로 무엇이냐에에 달려 있습니다.


Mapping to SPIR-V


( 명세는 아니고 ) 정보 제공의 목적으로 이야기하자면, 다음은 GLSL 생성자를 SPIR-V 생성자로 매핑하는 구현을 위해 기대되는 방식들을 보여 줍니다:


storage class 매핑:


uniform sampler2D...;        -> UniformConstant

uniform blockN { ... } ...;  -> Uniform, with Block decoration

in / out variable            -> Input/Output, possibly with block (below)

in / out block...            -> Input/Output, with Block decoration

buffer  blockN { ... } ...;  -> Uniform, with BufferBlock decoration, or

  StorageBuffer, when requested

N/A                          -> AtomicCounter

shared                       -> Workgroup

<normal global>              -> Private


입출력 블락들이나 변수들을 매핑하는 것은 다른 버전의 GLSL 이나 ESSL 과 같습니다. 확장 변수들이나 멤버들을 이 버전에서 이용할 수 있으며, 그것의 위치는 다음과 같습니다:


이들은 SPIR-V 에 개별 변수들로 매핑되는데, ( 따로 표기된 것을 제외하고는 ) 내장 decoration( 역주 : prefix 나 한정자 등을 의미하는 듯 ) 들과 유사하게 발음됩니다:


Any stage :


in gl_NumWorkGroups

in gl_WorkGroupSize

in gl_WorkGroupID

in gl_LocalInvocationID

in gl_GlobalInvocationID

in gl_LocalInvocationIndex


in gl_VertexIndex

in gl_InstanceIndex

in gl_InvocationID

in gl_PatchVerticesIn      (PatchVertices)

in gl_PrimitiveIDIn        (PrimitiveID)

in/out gl_PrimitiveID      (in/out based only on storage qualifier)

in gl_TessCoord


in/out gl_Layer

in/out gl_ViewportIndex


patch in/out gl_TessLevelOuter  (uses Patch decoration)

patch in/out gl_TessLevelInner  (uses Patch decoration)


Fragment stage only:


in gl_FragCoord

in gl_FrontFacing

in gl_ClipDistance

in gl_CullDistance

in gl_PointCoord

in gl_SampleID

in gl_SamplePosition

in gl_HelperInvocation

out gl_FragDepth

in gl_SampleMaskIn        (SampleMask)

out gl_SampleMask         (in/out based only on storage qualifier)


These are mapped to SPIR-V blocks, as implied by the pseudo code, with the members decorated with similarly spelled built-in decorations:


Non-fragment stage:



적어도 하나의 입력 및 출력 블락이 SPIR-V 의 스테이지마다 존재합니다. 하위집합( subset ) 및 멤버의 순서는 인터페이스( interface )를 공유하고 있는 스테이지 사이에서 일치할 것입니다.


Mapping of precision qulifiers:


lowp     -> RelaxedPrecision, on storage variable and operation

mediump  -> RelaxedPrecision, on storage variable and operation

highp    -> 32-bit, same as int or float


portablility tool/mode   -> OpQuantizeToF16


Mapping of precise:


precise -> NoContraction


Mapping of images:


subpassInput  -> OpTypeImage with 'Dim' of SubpassData

subpassLoad() -> OpImageRead

imageLoad()   -> OpImageRead

imageStore()  -> OpImageWrite

texelFetch()  -> OpImageFetch


imageAtomicXXX(params, data)  -> %ptr = OpImageTexelPointer params 

OpAtomicXXX %ptr, data


XXXQueryXXX(combined) -> %image = OpImage combined 

OpXXXQueryXXX %image


Mapping of layouts:


std140/std430  ->  explicit offsets/strides on struct

shared/packed  ->  not allowed

<default>      ->  not shared, but std140 or std430


max_vertices   ->  OutputVertices


Mapping of barriers:


barrier() (compute) -> OpControlBarrier(/*Execution*/Workgroup,

/*Memory*/Workgroup,

/*Semantics*/AcquireRelease |

WorkgroupMemory)


barrier() (tess control) -> OpControlBarrier(/*Execution*/Workgroup,

/*Memory*/Invocation, 

/*Semantics*/None)


memoryBarrier() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease | 

UniformMemory | 

WorkgroupMemory | 

ImageMemory)


memoryBarrierBuffer() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease |

UniformMemory)


memoryBarrierShared() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease |

WorkgroupMemory)


memoryBarrierImage() -> OpMemoryBarrier(/*Memory*/Device,

/*Semantics*/AcquireRelease |

ImageMemory)


groupMemoryBarrier() -> OpMemoryBarrier(/*Memory*/Workgroup,

/*Semantics*/AcquireRelease |

UniformMemory |

WorkgroupMemory |

ImageMemory)


Mapping of atomics


all atomic builtin functions -> Semantics = None(Relaxed)

atomicExchange()      -> OpAtomicExchange

imageAtomicExchange() -> OpAtomicExchange

atomicCompSwap()      -> OpAtomicCompareExchange

imageAtomicCompSwap() -> OpAtomicCompareExchange

N/A                   -> OpAtomicCompareExchangeWeak


Mapping of other instructions:


%     -> OpUMod/OpSMod

mod() -> OpFMod

N/A   -> OpSRem/OpFRem


===== 이 아래는 [ OpenGL Shading Language Specification ] 의 변경사항과 관련한 내용이므로 따로 번역하지 않았습니다. 

하지만 본문에 언급되어 있지 않은 정보들도 꽤 있어 확인해 보시는 것이 좋을 것 같습니다 =====


Changes to Chapter 1 of the OpenGL Shading Language Specification


    Change the last paragraph of "1.3 Overview":  "The OpenGL Graphics System

    Specification will specify the OpenGL entry points used to manipulate and

    communicate with GLSL programs and GLSL shaders."


    Add a paragraph: "The Vulkan API will specify the Vulkan entry points used

    to manipulate SPIR-V shaders.  Independent offline tool chains will compile

    GLSL down to the SPIR-V intermediate language. Vulkan use is not enabled

    with a #extension, #version, or a profile.  Instead, use of GLSL for Vulkan

    is determined by offline tool-chain use. See the documentation of such

    tools to see how to request generation of SPIR-V for Vulkan."


    "GLSL -> SPIR-V compilers must be directed as to what SPIR-V *Capabilities*

    are legal at run-time and give errors for GLSL feature use outside those

    capabilities.  This is also true for implementation-dependent limits that

    can be error checked by the front-end against constants present in the

    GLSL source: the front-end can be informed of such limits, and report

    errors when they are exceeded."


Changes to Chapter 2 of the OpenGL Shading Language Specification


    Change the name from


    "2 Overview of OpenGL Shading"


    to


    "2 Overview of OpenGL and Vulkan Shading"


    Remove the word "OpenGL" from three introductory paragraphs.


Changes to Chapter 3 of the OpenGL Shading Language Specification


    Add a new paragraph at the end of section "3.3 Preprocessor":  "When

    shaders are compiled for Vulkan, the following predefined macro is

    available:


        #define VULKAN 100


    Add the following keywords to section 3.6 Keywords:


        texture1D        texture2D        texture3D

        textureCube      texture2DRect    texture1DArray

        texture2DArray   textureBuffer    texture2DMS

        texture2DMSArray textureCubeArray


        itexture1D        itexture2D       itexture3D

        itextureCube      itexture2DRect   itexture1DArray

        itexture2DArray   itextureBuffer

        itexture2DMS      itexture2DMSArray

        itextureCubeArray


        utexture1D        utexture2D        utexture3D

        utextureCube      utexture2DRect    utexture1DArray

        utexture2DArray   utextureBuffer    utexture2DMS

        utexture2DMSArray utextureCubeArray


        sampler    samplerShadow


        subpassInput      isubpassInput     usubpassInput

        subpassInputMS    isubpassInputMS   usubpassInputMS


    Move the following keywords in section 3.6 Keywords to the reserved

    section:


        atomic_uint

        subroutine


Changes to Chapter 4 of the OpenGL Shading Language Specification


    Add into the tables in section 4.1 Basic Types, interleaved with the

    existing types, using the existing descriptions (when not supplied

    below):


        Floating-Point Opaque Types


            texture1D

            texture2D

            texture3D

            textureCube

            texture2DRect

            texture1DArray

            texture2DArray

            textureBuffer

            texture2DMS

            texture2DMSArray

            textureCubeArray

            subpassInput            | a handle for accessing a floating-point

                                    | subpass input

            subpassInputMS          | a handle for accessing a multi-sampled

                                    | floating-point subpass input


        Signed Integer Opaque Types


            itexture1D

            itexture2D

            itexture3D

            itextureCube

            itexture2DRect

            itexture1DArray

            itexture2DArray

            itextureBuffer

            itexture2DMS

            itexture2DMSArray

            itextureCubeArray

            isubpassInput     | a handle for accessing an integer subpass input

            isubpassInputMS   | a handle for accessing a multi-sampled integer

                              | subpass input


        Unsigned Integer Opaque Types


            utexture1D

            utexture2D

            utexture3D

            utextureCube

            utexture2DRect

            utexture1DArray

            utexture2DArray

            utextureBuffer

            utexture2DMS

            utexture2DMSArray

            utextureCubeArray

            usubpassInput     | a handle for accessing an unsigned integer

                              | subpass input

            usubpassInputMS   | a handle for accessing a multi-sampled unsigned

                              | integer subpass input


    Remove the entry from the table in section 4.1 Basic Types:


        atomic_uint


    Add a new category in this section


      "Sampler Opaque Types


          sampler       |    a handle for accessing state describing how to

                        |    sample a texture"

          ---------------------------------------------------------------------

          samplerShadow |    a handle for accessing state describing how to

                        |    sample a depth texture with comparison"


    Remove "structure member selection" from 4.1.7 and instead add a sentence

    "Opaque types cannot be declared or nested in a structure (struct)."


    Modify subsection 4.1.3 Integers, for desktop versions of GLSL, to say:


        "Highp unsigned integers have exactly 32 bits of precision.  Highp

        signed integers use 32 bits, including a sign bit, in two's complement

        form. Mediump and lowp integers are as defined by the RelaxedPrecision

        decoration in SPIR-V."


    Add a subsection to 4.1.7 Opaque Types:


        "4.1.7.x Texture, *sampler*, and *samplerShadow* Types


        "Texture (e.g., *texture2D*), *sampler*, and *samplerShadow* types are opaque

        types, declared and behaving as described above for opaque types.  When

        aggregated into arrays within a shader, these types can only be indexed

        with a dynamically uniform expression, or texture lookup will result in

        undefined values. Texture variables are handles to one-, two-, and

        three-dimensional textures, cube maps, etc., as enumerated in the basic

        types tables. There are distinct

        texture types for each texture target, and for each of float, integer,

        and unsigned integer data types. Textures can be combined with a

        variable of type *sampler* or *samplerShadow* to create a sampler type

        (e.g., sampler2D, or sampler2DShadow). This is done with a constructor,

        e.g., sampler2D(texture2D, sampler),

        sampler2DShadow(texture2D, sampler),

        sampler2DShadow(texture2D, samplerShadow), or

        sampler2D(texture2D, samplerShadow)

        and is described in more detail in section 5.4 "Constructors"."


        "4.1.7.x Subpass Inputs


        "Subpass input types (e.g., subpassInput) are opaque types, declared

        and behaving as described above for opaque types.  When aggregated into

        arrays within a shader, they can only be indexed with a dynamically

        uniform integral expression, otherwise results are undefined.


        "Subpass input types are handles to two-dimensional single sampled or

        multi-sampled images, with distinct types for each of float, integer,

        and unsigned integer data types.


        "Subpass input types are only available in fragment shaders.  It is a

        compile-time error to use them in any other stage."


    Remove the section 4.1.7.3 Atomic Counters


    Change section 4.3.3 Constant Expressions:


      Add a new very first sentence to this section:


        "SPIR-V specialization constants are expressed in GLSL as const, with

        a layout qualifier identifier of constant_id, as described in section

        4.4.x Specialization-Constant Qualifier."


      Add to this sentence:


        "A constant expression is one of...

          * a variable declared with the const qualifier and an initializer,

            where the initializer is a constant expression"


      To make it say:


        "A constant expression is one of...

          * a variable declared with the const qualifier and an initializer,

            where the initializer is a constant expression; this includes both

            const declared with a specialization-constant layout qualifier,

            e.g., 'layout(constant_id = ...)' and those declared without a

            specialization-constant layout qualifier"


      Add to "including getting an element of a constant array," that


        "an array access with a specialization constant as an index does

        not result in a constant expression"


      Add to this sentence:


        "A constant expression is one of...

          * the value returned by a built-in function..."


      To make it say:


        "A constant expression is one of...

          * for non-specialization-constants only: the value returned by a

            built-in function... (when any function is called with an argument

            that is a specialization constant, the result is not a constant

            expression)"


      Rewrite the last half of the last paragraph to be its own paragraph

      saying:


        "Non-specialization constant expressions may be evaluated by the

        compiler's host platform, and are therefore not required ...

        [rest of paragraph stays the same]"


      Add a paragraph


        "Specialization constant expressions are never evaluated by the

        front-end, but instead retain the operations needed to evaluate them

        later on the host."


    Add to the table in section 4.4 Layout Qualifiers:


                             | Individual Variable | Block | Allowed Interface

      ------------------------------------------------------------------------

      constant_id =          |     scalar only     |       |      const

      ------------------------------------------------------------------------

      push_constant          |                     |   X   |      uniform

      ------------------------------------------------------------------------

      set =                  |     opaque only     |   X   |      uniform

      ------------------------------------------------------------------------

      input_attachment_index | subpass types only  |       |      uniform


    (The other columns remain blank.)


    Also add to this table:


                         | Qualifier Only | Allowed Interface

      -------------------------------------------------------

      local_size_x_id =  |        X       |       in

      local_size_y_id =  |        X       |       in

      local_size_z_id =  |        X       |       in


    (The other columns remain blank.)


    Expand this sentence in section 4.4.1 Input Layout Qualifiers:


      "Where integral-constant-expression is defined in section 4.3.3 Constant

      Expressions as 'integral constant expression'"


    To include the following:


      ", with it being a compile-time error for integer-constant-expression to

      be a specialization constant:  The constant used to set a layout

      identifier X in layout(layout-qualifier-name = X) must evaluate to a

      front-end constant containing no specialization constants."


    Change the rules about locations and inputs for doubles, by removing


      "If a vertex shader input is any scalar or vector type, it will consume

      a single location. If a non-vertex shader input is a scalar or vector

      type other than dvec3 or dvec4..."


    Replacing the above with


      "If an input is a scalar or vector type other than dvec3 or dvec4..."


    (Making all stages have the same rule that dvec3 takes two locations...)


    At the end of the paragraphs describing the *location* rules, add this

    paragraph:


      "When generating SPIR-V, all *in* and *out* qualified user-declared

      (non built-in) variables and blocks (or all their members) must have a

      shader-specified *location*. Otherwise, a compile-time error is

      generated."


    [Note that an earlier existing rule just above this says "If a block has

    no block-level *location* layout qualifier, it is required that either all

    or none of its members have a *location* layout qualifier, or a compile-

    time error results."]


    Change section 4.4.1.3 "Fragment Shader Inputs" from


      "By default, gl_FragCoord assumes a lower-left origin for window

      coordinates ... For example, the (x, y) location (0.5, 0.5) is

      returned for the lowerleft-most pixel in a window. The origin can be

      changed by redeclaring gl_FragCoord with the

      origin_upper_left identifier."


    To


      "The gl_FragCoord built-in variable assumes an upper-left origin for

      window coordinates ... For example, the (x, y) location (0.5, 0.5) is

      returned for the upper-left-most pixel in a window. The origin can be

      explicitly set by redeclaring gl_FragCoord with the origin_upper_left

      identifier.  It is a compile-time error to change it to

      origin_lower_left."


    Add to the end of section 4.4.3 Uniform Variable Layout Qualifiers:


      "The /push_constant/ identifier is used to declare an entire block, and

      represents a set of "push constants", as defined by the API.  It is a

      compile-time error to apply this to anything other than a uniform block

      declaration.  The values in the block will be initialized through the

      API, as per the Vulkan API specification.  A block declared with

      layout(push_constant) may optionally include an /instance-name/.

      There can be only one push_constant

      block per stage, or a compile-time or link-time error will result. A

      push-constant array can only be indexed with dynamically uniform indexes.

      Uniform blocks declared with push_constant use different resources

      than those without; and are accounted for separately.  See the API

      specification for more detail."


    After the paragraphs about binding ("The binding identifier..."), add


      "The /set/ identifier specifies the descriptor set this object belongs to.

      It is a compile-time error to apply /set/ to a standalone qualifier or to

      a member of a block.  It is a compile-time error to apply /set/ to a block

      qualified as a push_constant.  By default, any non-push_constant uniform

      or shader storage block declared without a /set/ identifier is assigned to

      descriptor set 0.  Similarly, any sampler, texture, or subpass input type

      declared as a uniform, but without a /set/ identifier is also assigned

      to descriptor set 0.


      "If applied to an object declared as an array, all elements of the array

      belong to the specified /set/.


      "It is a compile-time error for either the /set/ or /binding/ value

      to exceed a front-end-configuration supplied maximum value."


    Remove mention of subroutine throughout section 4.4 Layout Qualifiers,

    including removal of section 4.4.4 Subroutine Function Layout Qualifiers.


    Change section 4.4.5 Uniform and Shader Storage Block Layout Qualifiers:


      Change


      "If the binding identifier is used with a uniform or shader storage block

      instanced as an array, the first element of the array takes the specified

      block binding and each subsequent element takes the next consecutive

      uniform block binding point. For an array of arrays, each element (e.g.,

      6 elements for a[2][3]) gets a binding point, and they are ordered per the

      array-of-array ordering described in section 4.1.9 'Arrays.'"

"


      To


      "If the binding identifier is used with a uniform block or buffer block

      instanced as an array, the entire array takes only the provided binding

      number.  The next consecutive binding number is available for a different

      object. For an array of arrays, descriptor set array element numbers used

      in descriptor set accesses are ordered per the array-of-array ordering

      described in section 4.1.9 'Arrays.'"


    Change section 4.4.6 Opaque-Uniform Layout Qualifiers:


      Change


      "If the binding identifier is used with an array, the first element of

      the array takes the specified unit and each subsequent element takes the

      next consecutive unit."


      To


      "If the binding identifier is used with an array, the entire array

      takes only the provided binding number.  The next consecutive binding

      number is available for a different object."


    Remove section 4.4.6.1 Atomic Counter Layout Qualifiers


    Add a new subsection at the end of section 4.4:


      "4.4.x Specialization-Constant Qualifier


      "Specialization constants are declared using "layout(constant_id=...)".

      For example:


        layout(constant_id = 17) const int arraySize = 12;


      "The above makes a specialization constant with a default value of 12.

      17 is the ID by which the API or other tools can later refer to

      this specific specialization constant. If it is never changed before

      final lowering, it will retain the value of 12. It is a compile-time

      error to use the constant_id qualifier on anything but a scalar bool,

      int, uint, float, or double.


      "Built-in constants can be declared to be specialization constants.

      For example,


        layout(constant_id = 31) gl_MaxClipDistances;  // add specialization id


      "The declaration uses just the name of the previously declared built-in

      variable, with a constant_id layout declaration.  It is a compile-time

      error to do this after the constant has been used: Constants are strictly

      either non-specialization constants or specialization constants, not

      both.


      "The built-in constant vector gl_WorkGroupSize can be specialized using

      the local_size_{xyz}_id qualifiers, to individually give the components

      an id. For example:


          layout(local_size_x_id = 18, local_size_z_id = 19) in;


      "This leaves gl_WorkGroupSize.y as a non-specialization constant, with

      gl_WorkGroupSize being a partially specialized vector.  Its x and z

      components can be later specialized using the ids 18 and 19.  These ids

      are declared independently from declaring the work-group size:


        layout(local_size_x = 32, local_size_y = 32) in;   // size is (32,32,1)

        layout(local_size_x_id = 18) in;                   // constant_id for x

        layout(local_size_z_id = 19) in;                   // constant_id for z


      "Existing rules for declaring local_size_x, local_size_y, and

      local_size_z are not changed by this extension. For the local-size ids,

      it is a compile-time error to provide different id values for the same

      local-size id, or to provide them after any use.  Otherwise, order,

      placement, number of statements, and replication do not cause errors.


      "Two arrays sized with specialization constants are the same type only if

      sized with the same symbol, involving no operations.


        layout(constant_id = 51) const int aSize = 20;

        const int pad = 2;

        const int total = aSize + pad; // specialization constant

        int a[total], b[total];        // a and b have the same type

        int c[22];                     // different type than a or b

        int d[aSize + pad];            // different type than a, b, or c

        int e[aSize + 2];              // different type than a, b, c, or d


      "Types containing arrays sized with a specialization constant cannot be

      compared, assigned as aggregates, declared with an initializer, or used

      as an initializer.  They can, however, be passed as arguments to

      functions having formal parameters of the same type.


      "Arrays inside a block may be sized with a specialization constant, but

      the block will have a static layout.  Changing the specialized size will

      not re-layout the block. In the absence of explicit offsets, the layout

      will be based on the default size of the array."


    Add a new subsection at the end of section 4.4:


      "4.4.y Subpass Qualifier


      "Subpasses are declared with the basic 'subpassInput' types.  However,

      they must have the layout qualifier "input_attachment_index" declared

      with them, or a compile-time error results.  For example:


        layout(input_attachment_index = 2, ...) uniform subpassInput t;


      This selects which subpass input is being read from. The value assigned

      to 'input_attachment_index', say i (input_attachment_index = i), selects

      that entry (ith entry) in the input list for the pass.  See the API

      documentation for more detail about passes and the input list.


      "If an array of size N is declared, it consume N consecutive

      input_attachment_index values, starting with the one provided.


      "It is a compile-time or link-time error to have different variables

      declared with the same input_attachment_index.  This includes any overlap

      in the implicit input_attachment_index consumed by array declarations.


      "It is a compile-time error if the value assigned to an

      input_attachment_index is greater than or equal to

      gl_MaxInputAttachments."


    Remove all mention of the 'shared' and 'packed' layout qualifiers.


    Change section 4.4.5 Uniform and Shader Storage Block Layout Qualifiers


      "The initial state of compilation is as if the following were declared:


        layout(std140, column_major) uniform;  // without push_constant

        layout(std430, column_major) buffer;


      "However, when push_constant is declared, the default layout of the

      buffer will be std430. There is no method to globally set this default."


    Add to this statement:


      "The std430 qualifier is supported only for shader storage blocks; using

      std430 on a uniform block will result in a compile-time error"


    the following phrase:


      "unless it is also declared with push_constant"


    Add to section 4.4.5 Uniform and Shader Storage Block Layout Qualifiers,

    for versions not having 'offset' and 'align' description language,

    or replace with the following for versions that do have 'offset' and

    'align' description language:


      "The 'offset' qualifier can only be used on block members of 'uniform' or

      'buffer' blocks. The 'offset' qualifier forces the qualified member to

      start at or after the specified integral-constant-expression, which will

      be its byte offset from the beginning of the buffer. It is a compile-time

      error to have any offset, explicit or assigned, that lies within another

      member of the block. Two blocks linked together in the same program with

      the same block name must have the exact same set of members qualified

      with 'offset' and their integral-constant-expression values must be the

      same, or a link-time error results. The specified 'offset' must be a

      multiple of the base alignment of the type of the block member it

      qualifies, or a compile-time error results.


      "The 'align' qualifier can only be used on block members of 'uniform' or

      'buffer' blocks. The 'align' qualifier makes the start of each block

      buffer have a minimum byte alignment. It does not affect the internal

      layout within each member, which will still follow the std140 or std430

      rules. The specified alignment must be greater than 0 and a power of 2,

      or a compile-time error results.


      "The actual alignment of a member will be the greater of the specified

      'align' alignment and the standard (e.g., std140) base alignment for the

      member's type. The actual offset of a member is computed as follows:

      If 'offset' was declared, start with that offset, otherwise start with

      the offset immediately following the preceding member (in declaration

      order). If the resulting offset is not a multiple of the actual

      alignment, increase it to the first offset that is a multiple of the

      actual alignment. This results in the actual offset the member will have.


      "When 'align' is applied to an array, it affects only the start of the

      array, not the array's internal stride. Both an 'offset' and an 'align'

      qualifier can be specified on a declaration.


      "The 'align' qualifier, when used on a block, has the same effect as

      qualifying each member with the same 'align' value as declared on the

      block, and gets the same compile-time results and errors as if this had

      been done. As described in general earlier, an individual member can

      specify its own 'align', which overrides the block-level 'align', but

      just for that member."


    Remove the following preamble from section 4.7, which exists for desktop

    versions, but not ES versions.  Removal:


      "Precision qualifiers are added for code portability with OpenGL ES, not

      for functionality. They have the same syntax as in OpenGL ES, as

      described below, but they have no semantic meaning, which includes no

      effect on the precision used to store or operate on variables.


      "If an extension adds in the same semantics and functionality in the

      OpenGL ES 2.0 specification for precision qualifiers, then the extension

      is allowed to reuse the keywords below for that purpose.


      "For the purposes of determining if an output from one shader stage

      matches an input of the next stage, the precision qualifier need not

      match."


    Add:


      "For interface matching, uniform variables and uniform and buffer block

      members must have the same precision qualification. For matching *out*

      variables or block members to *in* variables and block members, the

      precision qualification does not have to match.


      "Global variables declared in different compilation units linked into the

      same shader stage must be declared with the same precision qualification."


    More generally, all versions will follow OpenGL ES semantic rules for

    precision qualifiers.


    Section 4.7.2 Precision Qualifiers (desktop only)


      Replace the table saying "none" for all precisions with this statement:


        "Mediump and lowp floating-point values have the precision defined by

        the RelaxedPrecision decoration in SPIR-V."


    Section 4.7.4 Default Precision Qualifiers:


      For desktop versions, replace the last three paragraphs that state the

      default precisions with the following instead:


        "All stages have default precision qualification of highp for all types

        that accept precision qualifiers."


Changes to Chapter 5 of the OpenGL Shading Language Specification


    Add a new subsection at the end of section 5.4 "Constructors":


        "5.4.x Sampler Constructors


        "Sampler types, like *sampler2D* can be declared with an initializer

        that is a constructor of the same type, and consuming a texture and a

        sampler.  For example:


          layout(...) uniform sampler s;   // handle to filtering information

          layout(...) uniform texture2D t; // handle to a texture

          layout(...) in vec2 tCoord;

          ...

          texture(sampler2D(t, s), tCoord);


        The result of a sampler constructor cannot be assigned to a variable:


          ... sampler2D sConstruct = sampler2D(t, s);  // ERROR


        Sampler constructors can only be consumed by a function parameter.


        Sampler constructors of arrays are illegal:


          layout(...) uniform texture2D tArray[6];

          ...

          ... sampler2D[](tArray, s) ...  // ERROR


        Formally:

         * every sampler type can be used as a constructor

         * the type of the constructor must match the type of the

           variable being declared

         * the constructor's first argument must be a texture type

         * the constructor's second argument must be a scalar of type

           *sampler* or *samplerShadow*

         * the dimensionality (1D, 2D, 3D, Cube, Rect, Buffer, MS, and Array)

           of the texture type must match that of the constructed sampler type

           (that is, the suffixes of the type of the first argument and the

           type of the constructor will be spelled the same way)

         * there is no control flow construct (e.g., "?:") that consumes any

           sampler type


         Note: Shadow mismatches are allowed between constructors and the

         second argument. Non-shadow samplers can be constructed from

         *samplerShadow* and shadow samplers can be constructed from *sampler*.


    Change section 5.9 Expressions


      Add under "The sequence (,) operator..."


        "Texture and sampler types cannot be used with the sequence (,)

        operator."


      Change under "The ternary selection operator (?:)..."


        "The second and third expressions can be any type, as long their types

        match."


      To


        "The second and third expressions can be any type, as long their types

        match, except for texture and sampler types, which result in a

        compile-time error."


    Add a section at the end of section 5


      "5.x Specialization Constant Operations"


      Only some operations discussed in this section may be applied to a

      specialization constant and still yield a result that is as

      specialization constant.  The operations allowed are listed below.

      When a specialization constant is operated on with one of these

      operators and with another constant or specialization constant, the

      result is implicitly a specialization constant.


       - int(), uint(), and bool() constructors for type conversions

         from any of the following types to any of the following types:

           * int

           * uint

           * bool

       - vector versions of the above conversion constructors

       - allowed implicit conversions of the above

       - swizzles (e.g., foo.yx)

       - The following when applied to integer or unsigned integer types:

           * unary negative ( - )

           * binary operations ( + , - , * , / , % )

           * shift ( <<, >> )

           * bitwise operations ( & , | , ^ )

       - The following when applied to integer or unsigned integer scalar types:

           * comparison ( == , != , > , >= , < , <= )

       - The following when applied to the Boolean scalar type:

           * not ( ! )

           * logical operations ( && , || , ^^ )

           * comparison ( == , != )

       - The ternary operator ( ? : )


Changes to Chapter 6 of the OpenGL Shading Language Specification


    Remove mention of subroutine throughout, including removal of

    section 6.1.2 Subroutines.


Changes to Chapter 7 of the OpenGL Shading Language Specification


    Changes to section 7.1 Built-In Language Variables


      Replace gl_VertexID and gl_InstanceID, for non-ES with:


        "in int gl_VertexIndex;"

        "in int gl_InstanceIndex;"


      For ES, add:


        "in highp int gl_VertexIndex;"

        "in highp int gl_InstanceIndex;"


      The following definition for gl_VertexIndex should replace the definition

      for gl_VertexID:


        "The variable gl_VertexIndex is a vertex language input variable that

        holds an integer index for the vertex, [See issue 7 regarding which

        name goes with which semantics] relative to a base.  While the

        variable gl_VertexIndex is always present, its value is not always

        defined.  See XXX in the API specification."


      The following definition for gl_InstanceIndex should replace the definition

      for gl_InstanceID:


        "The variable gl_InstanceIndex is a vertex language input variable that

        holds the instance number of the current primitive in an instanced draw

        call, relative to a base. If the current primitive does not come from

        an instanced draw call, the value of gl_InstanceIndex is zero."

        [See issue 7 regarding which name goes with which semantics]


    Changes to section 7.3 Built-In Constants


      Add


        "const int gl_MaxInputAttachments = 1;"


    Remove section 7.4 Built-In Uniform State (there is none in Vulkan).


Changes to Chapter 8 of the OpenGL Shading Language Specification


    Add the following ES language to desktop versions of the specification:


      "The operation of a built-in function can have a different precision

      qualification than the precision qualification of the resulting value.

      These two precision qualifications are established as follows.


      "The precision qualification of the operation of a built-in function is

      based on the precision qualification of its input arguments and formal

      parameters:  When a formal parameter specifies a precision qualifier,

      that is used, otherwise, the precision qualification of the calling

      argument is used.  The highest precision of these will be the precision

      qualification of the operation of the built-in function. Generally,

      this is applied across all arguments to a built-in function, with the

      exceptions being:

        - bitfieldExtract and bitfieldInsert ignore the 'offset' and 'bits'

          arguments.

        - interpolateAt* functions only look at the 'interpolant' argument.


      "The precision qualification of the result of a built-in function is

      determined in one of the following ways:


        - For the texture sampling, image load, and image store functions,

          the precision of the return type matches the precision of the

          sampler type:

             uniform lowp sampler2D sampler;

             highp vec2 coord;

             ...

             lowp vec4 col = texture (sampler, coord); // texture() returns lowp


        Otherwise:


        - For prototypes that do not specify a resulting precision qualifier,

          the precision will be the same as the precision of the operation.

          (As defined earlier.)


        - For prototypes that do specify a resulting precision qualifier,

          the specified precision qualifier is the precision qualification of

          the result."


    Add precision qualifiers to the following in desktop versions:


      genIType floatBitsToInt (highp genFType value)

      genUType floatBitsToUint(highp genFType value)

      genFType intBitsToFloat (highp genIType value)

      genFType uintBitsToFloat(highp genUType value)


      genFType frexp(highp genFType x, out highp genIType exp)

      genFType ldexp(highp genFType x,  in highp genIType exp)


      highp uint packSnorm2x16(vec2 v)

      vec2 unpackSnorm2x16(highp uint p)

      highp uint packUnorm2x16(vec2 v)

      vec2 unpackUnorm2x16(highp uint p)

      vec2 unpackHalf2x16(highp uint v)

      vec4 unpackUnorm4x8(highp uint v)

      vec4 unpackSnorm4x8(highp uint v)


      genIType bitfieldReverse(highp genIType value)

      genUType bitfieldReverse(highp genUType value)

      genIType findMSB(highp genIType value)

      genIType findMSB(highp genUType value)

      genUType uaddCarry(highp genUType x, highp genUType y,

                         out lowp genUType carry)

      genUType usubBorrow(highp genUType x, highp genUType y,

                          out lowp genUType borrow)

      void umulExtended(highp genUType x, highp genUType y,

                        out highp genUType msb, out highp genUType lsb)

      void imulExtended(highp genIType x, highp genIType y,

                        out highp genIType msb, out highp genIType lsb)


    Remove section 8.10 Atomic-Counter Functions


    Remove section 8.14 Noise Functions


    Add a section


      "8.X Subpass Functions


      "Subpass functions are only available in a fragment shader.


      "Subpass inputs are read through the built-in functions below. The gvec...

      and gsubpass... are matched, where they must both be the same floating

      point, integer, or unsigned integer variants.


    Add a table with these two entries (in the same cell):


      "gvec4 subpassLoad(gsubpassInput   subpass)

       gvec4 subpassLoad(gsubpassInputMS subpass, int sample)"


    With the description:


      "Read from a subpass input, from the implicit location (x, y, layer)

      of the current fragment coordinate."


Changes to the grammar


    Arrays can no longer require the size to be a compile-time folded constant

    expression.  Change


      | LEFT_BRACKET constant_expression RIGHT_BRACKET


    to


      | LEFT_BRACKET conditional_expression RIGHT_BRACKET


    and change


      | array_specifier LEFT_BRACKET constant_expression RIGHT_BRACKET


    to


      | array_specifier LEFT_BRACKET conditional_expression RIGHT_BRACKET


    Remove the ATOMIC_UINT type_specifier_nonarray.


    Remove all instances of the SUBROUTINE keyword.


Issues


1. Can we have specialization sizes in an array in a block?  That prevents

   putting known offsets on subsequent members.


   RESOLUTION: Yes, but it does not affect offsets.


2. Can a specialization-sized array be passed by value?


   RESOLUTION: Yes, if they are sized with the same specialization constant.


3. Can a texture array be variably indexed?  Dynamically uniform?


   Resolution (bug 14683): Dynamically uniform indexing.


4. Are arrays of a descriptor set all under the same set number, or does, say,

   an array of size 4 use up 4 descriptor sets?


   RESOLUTION: There is no array of descriptor sets.  Arrays of resources

   are in a single descriptor set and consume a single binding number.


5. Which descriptor set arrays can be variably or non-uniformly indexed?


   RESOLUTION: There is no array of descriptor sets.


6. Do we want an alternate way of doing composite member specialization

   constants?  For example,


       layout(constant_id = 18) gl_WorkGroupSize.y;


   Or


       layout(constant_id = 18, local_size_y = 16) in;


   Or


       layout(constant_id = 18) wgy = 16;

       const ivec3 gl_WorkGroupSize = ivec3(1, wgy, 1);


    RESOLUTION: No. Use local_size_x_id etc. for workgroup size, and

    defer any more generalized way of doing this for composites.


7. What names do we really want to use for

        gl_VertexIndex             base, base+1, base+2, ...

        gl_InstanceIndex           base, base+1, base+2, ...


     RESOLUTION: Use the names above.


     Note that gl_VertexIndex is equivalent to OpenGL's gl_VertexID in that

     it includes the value of the baseVertex parameter. gl_InstanceIndex is

     NOT equivalent to OpenGL's gl_InstanceID because gl_InstanceID does NOT

     include the baseInstance parameter.


8. What should "input subpasses" really be called?


   RESOLVED: subpassInput.


9. The spec currently does not restrict where sampler constructors can go,

   but should it?  E.g., can the user write a shader like the following:


     uniform texture2D t[MAX_TEXTURES];

     uniform sampler s[2];


     uniform int textureCount;

     uniform int sampleCount;

     uniform bool samplerCond;


     float ShadowLookup(bool pcf, vec2 tcBase[MAX_TEXTURES])

     {

         float result = 0;


         for (int textureIndex = 0; textureIndex < textureCount; ++textureIndex)

         {

             for (int sampleIndex = 0; sampleIndex < sampleCount; ++sampleIndex)

             {

                 vec2 tc = tcBase[textureIndex] + offsets[sampleIndex];

                 if (samplerCond)

                     result += texture(sampler2D(t[textureIndex], s[0]), tc).r;

                 else

                     result += texture(sampler2D(t[textureIndex], s[1]), tc).r;

             }


   Or, like this?


     uniform texture2D t[MAX_TEXTURES];

     uniform sampler s[2];


     uniform int textureCount;

     uniform int sampleCount;

     uniform bool samplerCond;


     sampler2D combined0[MAX_TEXTURES] = sampler2D(t, s[0]);

     sampler2D combined1[MAX_TEXTURES] = sampler2D(t, s[1]);


     float ShadowLookup(bool pcf, vec2 tcBase[MAX_TEXTURES])

     {

         for (int textureIndex = 0; textureIndex < textureCount; ++textureIndex) {

             for (int sampleIndex = 0; sampleIndex < sampleCount; ++sampleIndex) {

                 vec2 tc = tcBase[textureIndex] + offsets[sampleIndex];

                 if (samplerCond)

                     result += texture(combined0[textureIndex], tc).r;

                 else

                     result += texture(combined1[textureIndex], tc).r;

             }

         ...


    RESOLUTION (bug 14683): Only constructed at the point of use, where passed

    as an argument to a function parameter.


Revision History


    Rev.    Date         Author    Changes

    ----  -----------    -------  --------------------------------------------

    46    25-Jul-2018    JohnK    No longer require sampler constructors to

                                  check shadow matches: mismatches are allowed

    45    15-Dec-2017    TobiasH  moved resource binding examples in from Vulkan API spec

    44    12-Dec-2017    jbolz    Document mapping of barrier/atomic ops to

                                  SPIR-V

    43    25-Oct-2017    JohnK    remove the already deprecated noise functions

    42    07-Jul-2017    JohnK    arrays of buffers consume only one binding

    41    05-Jul-2017    JohnK    allow std430 on push_constant declarations

    40    21-May-2017    JohnK    Require in/out explicit locations

    39    14-Apr-2017    JohnK    Update overview for StorageBuffer storage

                                  class.

    38    14-Apr-2017    JohnK    Fix Vulkan public issue #466: texture2D typo.

    37    26-Mar-2017    JohnK    Fix glslang issue #369: remove gl_NumSamples.

    36    13-Feb-2017    JohnK    Fix public bug 428: allow anonymous

                                  push_constant blocks.

    35    07-Feb-2017    JohnK    Add 'offset' and 'align' to all versions

    34    26-Jan-2017    JohnK    Allow the ternary operator to result in a

                                  specialization constant

    33    30-Aug-2016    JohnK    Allow out-of-order offsets in a block

    32     1-Aug-2016    JohnK    Remove atomic_uint and more fully subroutine

    31    20-Jul-2016    JohnK    Have desktop versions respect mediump/lowp

    30    12-Apr-2016    JohnK    Restrict spec-const operations to non-float

    29     5-Apr-2016    JohnK    Clarify disallowance of spec-const arrays in

                                  initializers

    28     7-Mar-2016    JohnK    Make push_constants not have sets

    27    28-Feb-2016    JohnK    Make the default by origin_upper_left

    26    17-Feb-2016    JohnK    Expand specialized array semantics

    25    10-Feb-2016    JohnK    Incorporate resolutions from the face to face

    24    28-Jan-2016    JohnK    Update the resolutions from the face to face

    23     6-Jan-2016    Piers    Remove support for gl_VertexID and

                                  gl_InstanceID since they aren't supported by

                                  Vulkan.

    22    29-Dec-2015    JohnK    support old versions and add semantic mapping

    21    09-Dec-2015    JohnK    change spelling *subpass* -> *subpassInput* and

                                  include this and other texture/sample types in

                                  the descriptor-set-0 default scheme

    20    01-Dec-2015    JohnK    push_constant default to std430, opaque types

                                  can only aggregate as arrays

    19    25-Nov-2015    JohnK    Move "Shadow" from texture types to samplerShadow

    18    23-Nov-2015    JohnK    Bug 15206 - Indexing of push constant arrays

    17    18-Nov-2015    JohnK    Bug 15066: std140/std43 defaults

    16    18-Nov-2015    JohnK    Bug 15173: subpass inputs as arrays

    15    07-Nov-2015    JohnK    Bug 14683: new rules for separate texture/sampler

    14    07-Nov-2015    JohnK    Add specialization operators, local_size_*_id

                                  rules, and input dvec3/dvec4 always use two

                                  locations

    13    29-Oct-2015    JohnK    Rules for input att. numbers, constant_id,

                                  and no subpassLoadMS()

    12    29-Oct-2015    JohnK    Explain how gl_FragColor is handled

    11     9-Oct-2015    JohnK    Add issue: where can sampler constructors be

    10     7-Sep-2015    JohnK    Add first draft specification language

     9     5-Sep-2015    JohnK    - make specialization id's scalar only, and

                                    add local_size_x_id... for component-level

                                    workgroup size setting

                                  - address several review comments

     8     2-Sep-2015    JohnK    switch to using the *target* style of target

                                  types (bug 14304)

     7    15-Aug-2015    JohnK    add overview for input targets

     6    12-Aug-2015    JohnK    document gl_VertexIndex and gl_InstanceIndex

     5    16-Jul-2015    JohnK    push_constant is a layout qualifier

                                  VULKAN is the only versioning macro

                                  constantID  -> constant_id

     4    12-Jul-2015    JohnK    Rewrite for clarity, with proper overview,

                                  and prepare to add full semantics

     3    14-May-2015    JohnK    Minor changes from meeting discussion

     2    26-Apr-2015    JohnK    Add controlling features/capabilities

     1    26-Mar-2015    JohnK    Initial revision


Vulkan specification 을 보다가 보면 "opaque" 라는 표현이 자주 나옵니다.


Command pools are opaque objects that command buffer memory is allocated from, and which allow the implementation to amortize the cost of resource creation across multiple command buffers.


- 출처 : 5.2. Command Pools, Vulkan 1.1.83 Specification.


이게 무슨 의미인지 궁금해서 찾아 보니 Wikipedia 에 "opaque data type" 이라는 개념이 있었습니다.


컴퓨터 과학에서 opaque data type 은 인터페이스에서 그것의 concrete data structure 가 정의되지 않은 data type 입니다. 이는 정보 은닉( information hiding )을 강화하는데, 그것의 값들이, 그 알 수 없는 정보에 대한 접근을 허용하는, 서브루틴을 호출함으로써만 조작될 수 있기 때문입니다.


- 출처 : Opaque data type, Wikipedia.


"Opaque" 가 "불투명한" 혹은 "이해하기 힘든" 라는 뜻을 가지고 있기 때문에 말이 되는 표현인 것 같습니다. "concrete data structure" 라는 것은 "abstract data structure" 와 대칭되는 개념인 것 같더군요.


C/C++ 을 사용한지 15 년도 넘었는데 이런 기본적인 개념도 제대로 이해하고 있지 않다니 자괴감이 드네요. 어쨌든 C++ 로 치면 "abstract data type" 이라는 것은 interface( abstract class ) 이고 "concrete data type" 은 실제 class 라고 생각하시면 됩니다.


Vulkan 에서 특정 object 들에 대한 정의를 찾아 가면 다음과 같은 매크로들을 볼 수 있습니다. VK_DEFINE_HANDLE 은 dispatchable object 를 의미하고 VK_DEFINE_NON_DISPATCHABLE_HANDLE 은 non-dispatchable object 를 의미합니다. 여기에 대한 설명은 이 문서의 주제를 벗어나므로 여기에서 구체적인 언급은 하지 않겠습니다. 나중에 따로 다루도록 하겠습니다.



어쨌든 이 매크로의 정의를 보면 다음과 같습니다.



해당 type 에 대한 구체적인 정의는 없고 단순히 pointer 를 선언하고 있을 뿐입니다. 실제 내용은 라이브러리를 구현하는 측에서 채우게 되는 것이죠. 처음에는 이 선언이 매우 당황스러웠는데 opaque object 를 구현하기 위한 선언이라고 생각하니 이해가 가는군요.


같은 이름만 가지고 있다면 구현측에서 내부에 어떤 내용을 채워도 상관이 없다는 것입니다. Vulkan 에서는 object 생성시에 vkCreateXXX() 메서드를 호출하고 필요한 정보를 VkXXXCreateInfo 라는 구조체를 통해서 전달하게 하고 있습니다. 위에서 설명하듯이 구조체 내부 구현은 알 수 없지만 특정 메서드를 통해서 접근할 수 있도록 하고 있는 것입니다.


보시면 알겠지만 전부 pointer 형으로 선언되어 있습니다. 그래서 가끔 이러한 type 을 선언할 때 기본이 type 이 pointer 형이라는 것을 까먹는 경우가 많은데, Vulkan 을 사용할 때 주의해야 할 점 중에 하나입니다.

Motive


최근에 Vulkan 에 관심이 좀 생겨서 tutorial 을 따라하면서 구현하고 있습니다. 그런데 이걸 추상화해서 C++ class 로 만들어 내는 데 스트레스가 심해졌습니다. 


Vulkan 은 기본적으로 C 로 구현되어 있기 때문에 오브젝트가 다 structure 로 되어 있고 member function 을 사용하고 있지 않습니다. 그래서 함수가 매우 많은데 일일이 찾아야 하고 구조체에 일일이 sType 을 넣어 줘야 합니다. ( C++ template 을 사용하는 ) Type-Traits 가 존재하지 않기 때문에 구조체의 유효성 검사를 위해서는 어쩔 수 없겠죠.



게다가 호출할 때도 파라미터 개수가 많아지고 생성하고자 하는 오브젝트가 어디에 소속되어 있는 건지 알기가 어렵습니다.



D3D 처럼 vkPhysicalDevice 와 관련한 메서드가 클래스에 모여 있으면 얼마나 좋을까요?



저는 C++ 에 익숙해져 있는 개발자다 보니 Vulkan 이 D3D 보다 좀 산만하고 어렵게 느껴집니다.


그래서 회사에서 동료분이랑 "왜 Vulkan 은 C 로 작성되었을까?" 라는 주제에 대해서 이야기해 봤는데, "개발자가 C++ 을 싫어한다", "성능 차이가 있다", "OpenGL 과 API 의 호환성을 유지하려고 한다", "별로 편하게 해줄 생각이 없는 애들이다" 등등 여러 가지 의견이 나왔지만, 납득할 만한 답을 찾지는 못했습니다.


대체 왜 C++ 이 아닌 C 를 사용하게 되었는지 알 수가 없었습니다. 그런데 "Vulkan Specification" [ 1 ] 을 읽다가 보니 단서를 찾았습니다.


2.4 절에 보면 다음과 같은 내용이 나옵니다.


ABI 는 Vulkan 이 응용프로그램을 플랫폼 혹은 구현측( implementation )에 맞춰 정의될 수 있도록 해 주는 메카니즘입니다. 다양한 플랫폼상에서, 이 명세에서 기술된 C 인터페이스는 공유 라이브러리에 의해서 제공됩니다. 공유 라이브러리들은 그것을 사용하는 응용프로그램과는 독립적으로 변경될 수 있기 때문에, 그것들은 특정한 호환성 문제를 겪게 되며, 이 명세는 그것들에 대한 요구사항을 제시합니다.


공유 라이브러리 구현은 반드시 그 플랫폼을 위한 표준 C 컴파일러의 Application Binary Interface ( ABI ) 를 사용하거나, 응용프로그램 코드가 구현측의 non-default API 를 사용하도록 만드는 customized API 헤더를 제공해야만 합니다. 이 문맥에서 ABI 는 C 데이터 타입의 size, alignment, layout 을 의미합니다; 프로시저의 호출 규약( calling convention ); 그리고 C 함수와 연관되는 공유 라이브러리 심볼의 이름 규약( naming convention ). 플랫폼에 대해 호출 규약을 커스터마이징하는 것은 보통 vk_platform.h 에 있는 호출규약 매크로를 적절히 정의함으로써 가능합니다.


Vulkan 을 공유 라이브러리로서 제공하는 플랫폼에서, 라이브러리 심볼ㄹ은 "vk" 로 시작하고 구현측에서 사용하기 위해서 예약된 숫자와 대문자가 그 다음에 오게 됩니다. Vulkan 을 사용하는 응용프로그램은 절대 이런 심볼들에 대한 정의를 제공해서는 안 됩니다. 이렇게 해야, Vulkan 공유 라이브러리가 새로운 API 나 extension 을 위해 추가적인 심볼들을 갱신할 때, 그것들이 현재 존재하는 응용프로그램의 심볼과 충돌하지 않습니다.


출처 : [ 1 ].


즉 동일한 ABI 를 사용하기 위해서 C 를 사용한다는 것을 알 수 있습니다. 하지만 C++ 도 ABI 를 제공하는 것은 아닌데 왜 굳이 C 여야 하는지 의문이 생겼습니다.


C ABI vs C++ ABI


Application Binary Interface 는 Android 개발에 의해서 cross-platform 개발이 대중화되면서 잘 알려지기 시작했습니다. 저만해도 그때까지는 Windows Platform 만 대상으로 개발했기 때문에 ABI 존재를 알지 못했습니다. 


사실 Mobile 개발에 큰 관심은 없었기에 ABI 라는 게 Android 에서만 사용하는 개념인줄 알았습니다. "armeabi" 라든가 "armeabi-v7a", "arm64-v8a" 같은 것들을 봐도 별로 느낌이 안 왔습니다 [ 3 ]. 보통 UE4 나 Unity 3D 같은 엔진들이 자동화를 통해서 빌드해 주기 때문에, 그냥 체크 몇 개만 해도 되기 때문이었습니다. 하지만 Gradle 같은 것들을 사용해서 커스터마이징을 하다가 보니 이런 개념들에 대해서 몰라서는 일을 하기가 힘들어졌습니다. 이제는 "이런 개념들에 대해서 모르고 있다가는 밥줄이 끊기겠구나" 싶더군요. 


칸텐츠 개발자라면 모르겠지만 엔진 개발자들은 이런 주제에 대해서 공부해야 할 기회( 당위성 )가 생기면 간단하게라도 개념을 이해하고 넘어가야 겠다는 생각이 들었습니다. 그래서 이번 기회에 ABI 가 뭔지 확인해 보기로 했습니다.


Wikipedia 에서는 ABI 를 다음과 같이 정의합니다.


컴퓨터 소프트웨어에서, application binary interface( ABI ) 는 두 이진 프로그램 모듈간의 인터페이스를 의미합니다; 보통 이러한 모듈들 중 하나는 라이브러리나 운영체제 기능이며, 다른 하나는 사용자에 의해서 실행되고 있는 프로그램입니다.


ABI 는 데이터 구조와 계산 루틴( computational routine )들이 머신 코드에서 어떻게 접근되어야 하는지를 정의합니다. 이것은 저수준( low-level )이며 하드웨어 의존적인 포맷입니다; 반면에 API 는 이 접근이 소스 코드에서 이루어지고, 그것은 상대적으로 고수준( high-level )이며 상대적으로 하드웨어에 비의존적이며, 보통 인간이 읽을 수 있는 포맷입니다. ABI 에 대한 일반적인 관점은 호출 규약이며, 이는 데이터가 입력이나 계산 루틴으로부터의 출력으로서 어떻게 제공되어야 하는지를 정의합니다; 예를 들면 x86 호출 규약이 있습니다.


출처 : [ 4 ].


그런데 문제는 C++ ABI 의 경우에는 호환성 보장이 어렵다는 데 있습니다. 


같은 플랫폼에서 컴파일러 간에 C++ name mangling, exception propagation, calling convention 같은 세부사항을 표준화하는 ABI 들이 있기는 하지만 cross-platform 에서의 호환성을 요구하지는 마십시오.


C++ 은 method overloading 을 지원하기 때문에 같은 클래스 내에 같은 이름이 존재하는 것이 가능하죠. 그렇기 때문에 C++ 컴파일러는 name mangling 이라는 것을 수행하게 되는데, 이게 컴파일러마다 다릅니다. 아래 이미지는 [ 2 ] 에서 가지고 왔습니다. [ 2 ] 에서는 여러 가지 예제를 만들어서 C 와 C++ 에서 심볼 이름을 만들어 내는 과정에 대해서 자세히 설명하고 있습니다.



보시면 알겠지만 컴파일러마다 너무 이름이 달라서 심볼이름으로 검색할 때 차이가 발생할 수밖에 없음을 알 수 있습니다. 예를 들어서 Vulkan 에서 validation layer 에 대한 모니터 개체를 생성하려면 다음과 같이 해야 합니다.



만약 "vkCreateDebugUtilsMessengerEXT" 함수가 C++ mangling 규칙을 따르게 된다면 어떤 규칙으로 이름을 찾아야 하는지 알 수가 없게 되었을 겁니다.


하지만 C 는 mangling 규칙이라는 것이 존재하지 않습니다. 그냥 함수 이름 앞에 "_" 가 하나 붙게 되는 형식입니다. 이에 대해서 자세하게 알고자 한다면 [ 2 ] 를 확인하시는 것이 좋습니다.


Vulkan.hpp


호환성을 위해서 C 를 사용하는 것까지는 좋지만, 너무 불편합니다. 


그래서 Khronos 에서는 "Vulkan.hpp" 헤더를 통해서 C++ 을 지원하고 있습니다. 다음과 같이 header 를 include 할 수 있습니다.



만약 VULKAN_HPP_NAMESPACE 를 지정하지 않으면 기본값이 "vk" 가 됩니다. 왠지 멋이 없어서 저는 그냥 "Vulkan" 이라고 해 봤습니다.


다음과 같이 클래스로 메서드들이 encapsulation 되어 있으니 매우 편합니다.



그리고 enumeration 들도 wrapping 되서 짧아졌더군요.



매우 편하게 가지고 놀면 될 것 같습니다.


하지만 Extension 을 사용할 때는 external symbol link error 가 나는 경우가 있는 것 같더군요. 그쪽에서는 C++ 쪽 대응을 하지 않은 것으로 보입니다. 그래서 저같은 경우에는 DebugUtilMessenger 같은 걸 사용할 때는 그냥 C-style 로 작업을 했습니다.


Conclusion


Cross-Platform 개발을 위해서는 호환성을 유지하는 것이 매우 중요하므로, Vulkan 은 기본 컴파일러의 C ABI 를 사용한다는 결론을 내릴 수 있었습니다. 또한 개발의 편의성을 위해 C++ 래퍼 클래스를 제공하고 있기 때문에 C++ 을 선호하는 사람은 그것을 사용하면 편리합니다. 단점이 있다면 거의 대부분의 예제가 C API 를 사용한다는 것입니다.


References


[ 1 ] Vulkan 1.1.86 Specification, Khronos.


[ 2 ] C++ 상에서 발생하는 name mangling 에 관한 내용, 미카 프로젝트.


[ 3 ] ABI 관리, Android Developers.


[ 4 ] Application Binary Interface, Wikepedia.

Gradle 3.X Migration


UE4 4.20 으로 마이그레이션하면 "arr-imports.gradle" 파일 같은 곳에서 gradle build 에러가 날 수 있습니다. 원래 UE4 가 gradle 2.x 를 사용하고 있었는데, 4.20 에서 3.x 버전으로 변경하면서 "Build" 디렉토리에 있는 템플릿 "build.gradle" 파일과 Android 용 build-tool 코드에 변경이 있었습니다.


이 때문에 매우 다양한 에러가 발생할 수 있는데요, 대표적인 것이 implementation() 과 관련한 컴파일 에러입니다. 2.x 에서는 implementation() 이 없습니다. 3.x 에서 compile() 이 implementation() 으로 대체되었죠. 그래서 implementation() 을 찾을 수 없다고 에러가 납니다. 만약 그 에러가 뜬다면 gradle 이 3.x 로 갱신되지 않은 것입니다. 그러므로 Setup.bat 을 실행해서 새로운 build.gradle 파일을 받아야 합니다.


그 외에 UPL 을 통해 직접 gradle 코드를 삽입한 경우 compile() 을 implementation() 으로 변경하셔야 합니다. 3.x 버전에서는 compile() 이 에러까지는 아니지만 경고를 뱉습니다.


그 외에도 여러 가지 에러가 있을 수 있는데요, [ Gradle용 Android 플러그인 3.0.0으로 마이그레이션 ] 을 참고하시기 바랍니다.


Crashlytics Symbols 폴더 변경


아마 Crashlytics 를 사용하시는 분들은 당황하셨으리라 생각하는데, UE4 가 기존의 "jni" 와 "obj" 폴더를 제거하고 "jniLibs" 라는 폴더로 바이너리 출력 폴더를 통합했습니다. 아마도 4.20 에서 Android Studio 를 통한 디버깅 기능 개선을 하면서 정리를 한 것이 아닌가 싶습니다.


"jniLibs" 폴더는 debug symbols 를 포함하고 있으니, 반드시 폴더를 바꿔줘야 합니다. 그리고 "Libs" 에는 심볼이 없기 때문에 debug 든 release 든 모두 "jniLibs" 를 사용해야 합니다.



Motivation

 

개인적으로 진행하는 프로젝트에서 double 수학이 필요한 상황이 되었습니다. 그래서 UE4 가 가지고 있는 수학 라이브러리를 미믹( mimic )해서 double 수학 라이브러리를 플러그인에서 구현하고 있었죠.

 

그런데 이 작업을 하다가 보니 matrix-matrix 곱에서 막히게 되었습니다. UE4 는 SSE 를 사용한다는 가정하에서 matrix-matrix, matrix-vector 곱을 SSE 로 구현해 놨습니다. 문제는 double 은 single( single-precision floating point )와는 SSE 구현 방식이 다르다는 거죠.

 

간단하게 예를 들면 Intel CPU 는 16 개의 mmx 레지스터를 제공하는데, 이것을 __m128 로 접근하죠. 128 비트를 가지고 있으니 이걸 32 로 나누면 4 가 됩니다. 즉 4 개의 single 을 하나의 레지스터에 저장할 수 있다는 것이죠. 하지만 double 을 사용하기 위해서는 __m128d 를 사용해야 하며, 그것의 크기는 256 비트입니다. 즉 2 개의 mmx 레지스터를 사용해 single 을 위해 했던 작업을 에뮬레이션해야 한다는 이야기입니다.

 

이런 작업이 좋은 경험이 될 것 같기는 하지만 귀찮아서 그냥 non-SSE 코드를 구현하기로 했습니다. 하지만 시작부터 벽에 부딪히게 되었습니다. 일단 UE4 는 matrix 에 다음과 같은 주석을 달았습니다.

 

 

여기에서 몇 가지 고민해 볼 지점이 발생합니다.

    • pre-multiplication 이란 무엇인가?
    • 이 행렬의 major 는 무엇인가?
    • 계산 후에 행렬의 major 는 유지되는가?

 

이 때문에 어떤 식으로 계산해야 하는지 혼란이 왔고, 제가 생각하는 것보다 행렬 구현이 쉽지 않다는 것을 깨닫게 되었습니다. 역시 단순히 사용하는 것과 그것을 이해하고 구현하는 것은 다른 법이더군요.

 

이 문서에서는 그런 구현을 하는 데 있어서 필요한 배경 지식들을 정리하고자 합니다.

 

Basic rules & major

 

일단 기본을 점검해 보기로 했습니다. 수학에서 M X N 행렬과 N X K 행렬을 곱하면 M X K 행렬이 나옵니다. 그리고 앞의 행렬의 "행"과 뒤의 행렬의 "열"의 내적이 새로운 행렬의 행과 열이 되죠. [ 4 ] 에서는 다음과 같이 도식화해서 설명하고 있습니다.

 

그림1. Matrix multiplication rule. 출처 : [ 4 ]

 

그림 2 : Dot product rule. 출처 : [ 4 ].

 

수학적으로 볼 때 이런 규칙들은 반드시 지켜져야 합니다. 물론 프로그래밍 언어에서 구현할 때는 여러 가지 이유때문에 우리가 수학에서 보는 것과는 다른 순서로 데이터가 저장되어 있을 수 있습니다. 여기에서 major 라는 것이 중요해지죠.

 

[ 1 ] 에 의하면 row-major 와 column-major 를 구분하는 것은 메모리 상에서의 연속성이 행렬의 어느 방향을 향하고 있느냐를 의미한다고 합니다.

 

그림3. row-major 와 column-major 의 순서. 출처 : [ 1 ]

 

그림3 에서 볼 수 있듯이 실제 메모리에 행을 저장하느냐 열을 저장하느냐 에 따라 row-major 와 column-major 가 결정됩니다. [ 1 ] 에 의하면 row-major 를 택한 언어들은 C, C++, Objecttive-C, PLI, Pascal, Speakeasy, SAS, Rasdaman 등이고, column-major 를 택한 언어들은 Fortran, MATLAB, GNU Octave, S-Plus, R, Julia, Scilab 등이라고 합니다. OpenGL 과 OpenGL ES 의 경우에는 row 에다가 vector 를 저장함에도 불구하고 그것을 column 으로 취급합니다. 그리고 이도 저도 아닌 Java, C#, CLI, .NET, Scala, Swift, Python, Lua 같은 언어들도 있다고 하네요.

 

어쨌든 column-major 냐 row-major 냐는 것은 해석과 구현의 문제입니다. [ 3 ] 에서는 view matrix 를 다음과 같이 표현합니다.

 

그림4. View Matrix. 출처 : [ 3 ].

 

 

그리고 나서 열을 하나의 vector 로 보고 matrix 배열의 한 행에 넣습니다.

 

 

한 행에 벡터들이 들어 가고 있으므로, 즉 column 들이 연속적인 메모리 공간에서 이어지고 있으므로 이는 column-major 라고 할 수 있습니다. 메모리에서의 저장순서는 아래와 같습니다.

 

 

Pre/Post-Multiplication

 

이 챕터는 [ 2 ] 의 [ The Matrix Chapter ] 의 번역입니다.

 

행렬곱은 두 행렬들에 대한 곱셈입니다. 행렬곱에는 결합법칙( associative law )이 적용되지만 교환법칙( communitative law )은 적용되지 않습니다.

 

결합법칙이 허용되면 다음과 같이 됩니다:

 

 

하지만 교환법칙이 허용되지 않으면 다음과 같이 됩니다:

 

 

행렬곱은 pre-multiplication 이나 post-multiplication 을 사용하여 계산될 수 있습니다. 이것은 벡터가 matrix 의 왼쪽( 혹은 "앞" )에 있어야 하는지 아니면 오른쪽( 혹은 "뒤")에 있어야 하는지를 가리킵니다.

 

 

행렬곱에는 교환법칙이 적용되지 않기 때문에 행렬 곱을 계산할 때 벡터가 행렬의 어느쪽에 있느냐에 따라 완전히 다른 결과가 발생됩니다. 그래픽 API 가 pre 혹은 post multiplication 을 사용하기로 선택하면, 동일한 결과 벡터를 얻으려면 전치된( transoposed ) 행렬을 사용해야합니다.

 

수학 문서에서는 post-multiplicaiton 이 일반적입니다. 이것은 행렬곱이 다음과 같이 계산된다는 것을 의미합니다. 주어진 행 R 과 열 C 에서 결과의 각 구성 요소는 왼쪽 행 행 R 과 오른쪽 열 C 의 내적으로 계산됩니다. 예를 들어 :

 

 

일 때, post-multiplication 을 사용하면, C 행렬의 1 행 2 열의 컴포넌트 b 의 결과를 계산하기 위해, A 의 1 행과 B 의 2 열을 내적합니다( 이것을 row-major 표기법이라 합니다 ). 모든 결과는 다음과 같습니다.

 

 

그러므로 그 결과는 다음과 같습니다:

 

 

이어지는 글에서는 별도의 표기가 없다면 row-major 표기법을 사용합니다.

 

하지만 이것은 정방 행렬을 곱하지 않을 때는 조금 더 까다로워 집니다. 3x3 행렬에 3x1 행렬( 3 행 1 열 벡터 )을 곱하면 3x1 행렬이 됩니다. 4x2 행렬에 2x3 행렬을 곱하면 4x3 행렬이 됩니다. 첫 번째 행렬의 행과 두 번째 행렬의 열에 대한 내적을 취하기 때문에, 첫 번째 행렬에는 두 번째 행렬의 행 수와 동일한 개수의 열이 있어야 합니다. 첫 번째 행렬의 각 행과 두 번째 행렬의 각 열에 대해 이 작업을 수행하므로, 결과 행렬의 행 개수는 첫 번째 행렬의 행의 개수와 같고 열 개수는 두 번째 행렬의 열 개수와 같습니다.

 

일반적으로, 서로 옆에 쓰여진 두 행렬의 차원을 취합니다. 내부의 두 값은 같아야하고 결과 행렬은 외부 값의 크기를가집니다. 4x3 * 3x2 = 4x2. 2x4 * 4x1 = 2x1. 4x3 및 2x4 행렬을 곱할 수는 없습니다. 왜냐하면 3 은 2 와 같지 않기 때문입니다.

 

자, 이제 pre-multiplication 에 대해서 이야기해 보도록 하겠습니다.

 

Pre-multiplication 을 사용하면 이제 행 벡터를 사용해야합니다. 열 벡터는 4x1 행렬이지만, 4x1 행렬에 4x4 행렬을 곱할 수는 없습니다. 대신 1x4 행 벡터를 사용해 1x4 행렬에 4x4 행렬 곱하기( 내부/가장 가까운 두 값의 일치 )의 결과로 1x4 행렬을 만들 수 있습니다( 바깥 쪽 두 값은 1 과 4 입니다 ).

 

그러나 pre-multiplication 을 사용하면 연산이 달라집니다. post-multiplicatin 의 4x4 행렬에 4x1 행 벡터를 곱한 결과 행렬의 각 행에 벡터가 포함됩니다. 이제 pre-multiplication 을 통한 내적은 ( 행렬이 곱셈 연산자의 오른쪽에 있기 때문에 ) 벡터와 행렬의 각 열을 사용해 연산을 합니다.

 

고맙게도, 이것은 쉽게 수정할 수 있습니다. Pre-multiplication 을 위해 행렬을 계산할 때, 단순히 post-multiplicatin 을 위해 사용했던 행렬을 전치하기만 하면 됩니다. 이것은 벡터를 4x1 열 벡터에서 1x4 행 벡터로 변환하기 때문에 의미가 있습니다.

 

2x2 행렬과 2x1 벡터에 대한 post-multiplication 은 다음과 같습니다:

 

 

1x2 벡터와 전치된 2x2 에 대한 pre-multiplication 은 다음과 같습니다:

 

 

결과는 동일하지만 전치된 벡터입니다:

 

 

OpenGL에서 일반적으로 사용하는 것처럼, column-major 표기법으로 post-multiplication 을 사용하면 어떻게 될까요. 수학적 결과는 같지만 행렬 크기의 순서는 바뀝니다. 따라서 column-major 4x3 행렬에 3x2 행렬을 곱할 수는 없지만, 2x4 및 1x2의 곱을 취하면 4x1 행렬(4 행 행 벡터)이 됩니다. 결과와 작동 방식은 모두 동일합니다. 표기법 만 다를 뿐입니다.

 

The Tricky Part

 

중요한 것은 pre-multiplication 이 사용되었는지 post-multiplication 이 사용되었는지의 여부입니다. D3DX 수학 라이브러리는 pre-multiplication 을 사용하고 OpenGL의 표준 행렬 스택은 post-multiplication 을 사용합니다. 그것은 일반적으로 행렬이 전치되어야 한다는 것을 의미합니다.

 

그러나 D3DX 는 row-major 행렬을 사용하고 OpenGL은 column-major 행렬을 사용한다는 점을 기억하십시오. 따라서 그들은 이미 전치되어 있습니다. 다음의 간단한 3x3 변환 행렬을 살펴 보죠. 2 의 uniform scale 과 (10, -5) 의 translation 을 표현합니다.

 

다음의 3x3 행렬은 row-vector 와 pre-multiplication 을 위해 사용되도록 의도되었습니다:

 

 

이것의 row-major 메모리 레이아웃은 다음과 같습니다:

 

 

다음의 3x3 행렬은 column-vector 와 post-multiplication 을 위해 사용되도록 의도되었습니다:

 

 

이것의 column-major 메모리 레이아웃은 다음과 같습니다 :

 

 

행렬은 특정 개념으로 작성되면 전치됩니다. 하지만 메모리에 저장되면 똑같습니다.

 

그것은 까다로우면서 멋진 부분입니다. 여러분의 코드들은 서로 다른 표기법과 서로 다른 행렬곱 순서 때문에 다르게 느껴질 겁니다. 하지만 실제 행렬값은 서로 교환가능합니다.

 

결과적으로 Direct3D 및 OpenGL 스타일을 모두 처리하는 단일 행렬 클래스를 쉽게 작성할 수 있습니다. Column-major 와 post-multiplication 을 사용하거나, row-major 와 pre-multiplication 을 사용하면, 그 데이터는 완전히 호환됩니다.

 

이것을 다시 강조하기 위해서 : 손으로 하는 일은 이것과 아무 상관이 없습니다! 왼손 좌표계 행렬이나 오른손 좌표계 행렬이라는 것은 존재하지 않습니다. 좌표계는 완전히 별도의 이슈입니다.

 

References

 

[ 1 ] Row- and column-major order, Wikipedia.

 

[ 2 ] Matrices, Handedness, Pre and Post Multiplication, Row vs Column Major, and Notations, Game Development by Sean.

 

[ 3 ] Understanding the View Matrix, 3D Game Engine Programming.

 

[ 4 ] Linear Algebra, Artifical Inteligence.

주의 : 허락받고 번역한 것이 아니므로 언제든 내려갈 수 있습니다.

주의 : 번역이 개판이므로 이상하면 원문을 참조하세요.

원문 : The Perils of Floating Point.



제가 "The Perils of Floating Point" 를 작성할 때, 이게 어느 정도까지 일이 커질지 알지 못했습니다. 그 문서에 수 백개의 링크가 걸렸고, 수 천번 참조되었습니다. 대학이나 프로그래밍 문서, 그리고 출간된 책들이나 많은 블로그들에서 참조되었죠. 얼마나 많은 사람들이 이 도움을 받게 되었는지 알고 싶네요.


The Perils of Floating Point by Bruce M. Bush © 1996 Lahey Computer Systems, Inc.

원문에 대한 표기와 함께 복제가 허용됩니다.


The Perils of Floating Point by Bruce M.Bush


최근 수십년 동안에 있었던 수 많은 훌륭한 엔지니어링과 과학적 모험들은 디지털 컴퓨터의 floating-point( 부동소수점 ) 기능없이는 불가능했을 것입니다. 하지만 여전히 일부 부동소수점 계산의 결과는 좀 이상하고, 심지어는 수학적 경험을 몇 년 동안 해 온 사람들에게조차도 이상합니다. 저는 이러한 이상한 결과들이 발생하는 원인에 대해서 설명하고 적용가능한 제안들을 제공할 계획입니다.


부동소수점 표현과 연산( arithmetic )은 부정확합니다. 하지만 그게 대부분의 프로그래머들에게 특별히 문제가 된다고는 생각하지 않습니다. 많은 입력값들이 내재적으로 부정확한 양( measurement )입니다. 그래서 출력 값에 대한 질문은 에러가 존재하느냐가 아니라 얼마나 많은 에러가 예상되느냐입니다. 그래서 여러분이 부동소수점을 가지고 컴퓨터보다 더 정확한 결과를 암산할 수 있다면, 의혹을 가지기 시작할 것입니다.


저는 몇 가지 이유로 FORTRAN 을 사용해 예제를 프로그래밍 했습니다 :

  1. 포트란에서는 다른 컴퓨터 언어보다 더 많은 부동소수점 계산이 수행됩니다.
  2. Lahey Computer System 이라는 회사에서 일하고 있는데, FORTRAN 언어 시스템을 개발하고 판매하고 있습니다.


이상한 결과들이 나오는 원인의 핵심은 하나에 기반합니다 : 컴퓨터에서 부동소수점이 보통 2 진수라는 것입니다. 반면에 외부적인 표현은 10 진수입니다. 우리는 1/3 이 정확하게 표현되지 않을 것이라 생각하지만, 그것은 직관적입니다. .01 이 되죠. Not so! IEEE 단일정밀도 형식( single-precision format )에서 .01 은 정확히 10737418/1073741824 이거나 대략 0.009999999776482582 입니다. 여러분은 다음과 같은 코드를 확인하기 전까지는 그 차이를 인지하지도 못할 것입니다:




10 진수 부동소수점 구현은 이런 변칙을 포함하지 않습니다. 하지만 10 진수 부동소수점 구현은 거의 보기가 힘듭니다. 왜냐하면 2 진수( binary ) 연산이 디지털 컴퓨터에서는 훨씬 빠르기 때문입니다.


Inexactness


디지털 컴퓨터에서 부동소수점 연산은 내재적으로 부정확합니다. 32 비트 부동소수점 수의 ( 숨겨진 비트를 포함한 ) 24 비트의 가수( mantissa )는 약 7 개의 유효 십진수( significant decimal digit ) 를 표현합니다. 예를 들어 대략 8,388,607 개의 단일정밀도 숫자가 1.0 과 2.0 사이에 존재합니다. 반면에 1024.0 과 2024.0 사이에는 단지 8191 개만이 존재합니다.


모든 컴퓨터에서, 수학적으로 동등한 표현식이 부동소수점 연산을 사용하면 서로 다른 결과를 산출할 수 있습니다. 다음 예제에서, Z 와 Z1 은 보통 서로 다른 값을 가지게 되는데, (1/Y) 혹은 1/7 이 이진 부동소수점에서 정확하게 표현되지 않기 때문입니다.




Insignificant Digits



다음 코드 예제는 유효 숫자로 보일 수 있는 무의미한 숫자들이 나오는 현상을 설명합니다.




단일 정밀도 (REAL) 요소는 대략 최대 7 개의 십진수 정밀도를 표현할 수 있습니다. 그래서 위의 빼기는 (1000.200 - 1000.000) 을 표현합니다. 그러므로 그 결과는 약 3 개의 십진수만을 표현할 수 있습니다. 하지만 프로그램은 "0.200012" 를 출력할 것입니다. 1000.2 는 이진 부동소수점에서 정확하게 표현되지 않고 1000.0 도 그렇기 때문에, 결과 A 는 0.2 보다 약간 큽니다. 하지만 컴퓨터는 ".200" 뒤쪽에 있는 숫자가 무의미하다는 것을 알지 못합니다.


아마도 언젠가는 컴퓨터가 결과에 있는 여러 개의 비트들에 대한 추척을 하겠죠. 하지만 지금은 여전히 그것은 프로그래머의 책임하에 있습니다. 만약 여러분이 데이터형에 의해 표현되는 십진수의 개수에 주의한다면, 유효 숫자의 개수를 근사계산하는 것은 직관적입니다. 하지만 아마도 시간이 걸리는 작업이 되겠죠. 가장 주의해야 할 점에 대해서 이야기해 보도록 하겠습니다:

  1. 뺄셈은 값이 거의 같습니다.
  2. 덧셈은 가수값이 거의 같습니다. 하지만 부호( sign )가 반대인 숫자에서는
  3. 덧셈이나 뺄셈은 매우 다른 가수값을 반환합니다.



Crazy Conversions


정수로의 변환은 부동소수점 수에서의 부정확함을 드러낼 수 있습니다. 다음 예제에서 설명할 것입니다. 21.33 에 가장 가까운 단일정밀도 부동소수점 수는 21.33 보다는 약간 작습니다. 그래서 100. 을 곱하게 되면, 그 결과 Y 는 2133.0 보다 약간 작습니다. 만약 여러분이 Y 를 일반적인 부동소수점 형식으로 출력한다면, 반올림( rounding )이 발생해서 2133.00 으로 출력될 것입니다. 하지만, Y 를 정수에 할당하게 되면, 반올림이 발생하지 않습니다. 그래서 그 숫자는 2132 가 됩니다.



다음 프로그램은 Lahey 의 LF90 으로 컴파일하면 "1.66661000251770" 을 출력합니다.



여러분은 "왜 단일정밀도 수에 무작위인 것으로 보이는 '000251770' 이 붙은 걸까" 라는 의문이 들 것입니다. 글쎄요, 그 숫자는 무작위 숫자가 아닙니다; 컴퓨터의 부동소수점은 이진 표현에서 0 을 사용해 패딩( padding )함으로써 변환을 수행합니다. 그래서 D 는 X 와 정확히 같죠. 하지만 15 개의 십진수로 출력한다면, 부정확함이 드러나는 것입니다. 이것은 비유효( insignificant ) 숫자의 다른 예이기도 합니다. 단일정밀도 숫자를 배정밀도( double-precision ) 숫자로 변환하는 것은 유효 숫자의 개수를 증가시키지 않는다는 것을 기억하십시오.


Too Many Digits


여러분은 이전의 프로그램 예제를 사용해 D 와 X 를 같은 형식으로 출력함으로써 값을 확인해 보려고 할 것입니다:



일부 FORTRAN 구현에서는 두 숫자가 동일한 값을 출력합니다. 여러분은 만족하고 떠나겠죠. 하지만 실제로는 단일 정밀도 수의 낮은자리 숫자( low-order digits )가 출력되지 않는 것을 잘못 판단한 것입니다. Lahey FORTRAN 에서는 그 숫자들이 다음과 같이 출력됩니다.


1.66661000000000       1.66661000251770


이렇게 되는 이유는 매우 단순합니다: Formatted I/O 변환 루틴들은 절대적인 최대 십진수를 알고 있으며, 단일정밀도 요소를 출력할 때 모든 유효숫자는 9 가 됩니다. 나머지 부분들은 현재 "precision-fill" 문자로 채워집니다. 그것의 기본값은 "0" 입니다. Precision-fill 문자는 다른 ASCII 문자로 대체될 수 있는데요, '*'( asterik ) 나 ' '(blank )같은 것들을 예로 들 수 있습니다. Precision-fill 문자를 "*" 로 변경하는 것은 낮은자리 숫자의 비유효성을 강조합니다.


1.66661000******       1.66661000251770


Too Much Precision


IEEE 단일정밀도 형식은 23 비트의 가수, 8 비트의 지수, 1 비트의 부호로 구성되어 있습니다. 펜티엄( Pentium )과 같은 인텔( Intel ) 마이크로 프로세서에서는 내부 부동소수점 레지스터가 64 비트의 가수, 15 비트의 지수, 1 비트의 부호로 구성됩니다. 이는 다른 구현들보다 해상도 손실이 훨씬 더 적은 중간 계산( intermediate calculation )을 가능하게 합니다. 이것의 단점은 중간 값들이 레지스터에 유지되는 방식에 따라서 똑같아 보이는 계산의 결과가 달라질 수 있다는 것입니다.


다음 예제에서, 컴파일러는 A/B 를 계산해서 중간 결과를 단일 정밀도 임시 변수에 저장하고, X/Y 를 계산해서 임시 변수에서 빼고, 결과를 Z 에 저장하는 코드를 생성합니다. Z 는 0 이 아닐 것입니다. 왜냐하면 단일정밀도 임시변수에 저장하면서 해상도가 손실될 것이기 때문입니다. 만약 생성된 코드가 중간 값을 레지스터에 유지한다면, 해상도는 손실되지 않을 것이고, Z 는 0 이 될 것입니다.



다음 예제는 이전 예제의 변형들에 대해서 설명합니다. 컴파일러는 여전히 중간 결과 C 를 레지스터에 유지하는 코드를 생성할 수 있습니다. 이건 Z 값이 0 임을 의미합니다. 만약 A/B 를 C 에 넣음으로써 해상도가 손실된다면, Z 는 0 이 아닐 것입니다.



C 가 레지스터에서 유지되는 것을 막기 위해서 레이블( label ) 100 을 추가했습니다. 그래서 Z 는 아마도 거의 대부분의 컴파일러에서 0 이 아닐 것입니다.



Safe Comparison


다양한 컴퓨터들은 다양한 비트를 사용해 부동소수점 숫자를 저장합니다. 심지어는 같은 IEEE 형식을 사용해 숫자를 저장하고 있더라도, 계산하는 동안에는 다른 일이 벌어질 수 있습니다. 왜냐하면 중간 레지스터의 크기가 다르기 때문입니다. 이식을 증대하고 일정한( consistent ) 결과를 보장하기 위해서, 저는 FORTRAN 에서 실수의 정확한 동일성( equality )을 비교하는 것은 좋지 않다고 생각합니다. 더 좋은 기법은 두 숫자의 차이의 절대값을 비교하는 것인데, 이 때 적절한 엡실론( epsilon )값을 사용해서 거의 같은 관계, 명확하게 더 큰 관계 등을 얻는 것입니다. 예를 들어 :



비교 대상 중 하나를 사용하여 엡실론에 곱셈을하면 그 숫자의 범위가 조정됩니다. 그래서 하나의 엡실론을 여러 가지 비교를 위해 사용할 수 있습니다. 가장 예측 가능한 결과를 얻으려면 엡실론의 절반 정도로 큰 값을 사용하고 다음 예제에서와 같이 비교 대상의 합계를 곱합니다:



부동 소수점 계산이 수학적으로 가능하지 않은 값을 생성할 수 있기 때문에, 큼 이나 작거나 같음 등의 비교도 예기치 않은 결과를 생성 할 수 있습니다. 다음 예제에서 X 는 항상 수학적으로 J 보다 큽니다. 그래서 X/J 는 1.0 보다는 항상 커야 합니다. 하지만 큰 숫자의 J 를 사용했을 때 델타( delta )의 합은 X 로 표현되지 않습니다. 왜냐하면 가수 크기를 넘어서기 때문이죠.



Programming with the Perils


쉬운 답은 존재하지 않습니다. 앞에서 설명했던 방식으로 동작하는 것은 이진 부동소수점의 본성입니다. 컴퓨터 부동 소수점의 힘을 이용하려면 그 한계를 알아야하며 그 한계 내에서 작업해야합니다. 부동 소수점 연산을 프로그래밍 할 때 다음 사항을 염두에 두면 많은 도움이됩니다 :

  1. 단일 정밀도 IEEE 형식에서는 약 7 자릿수, 배 정밀도 IEEE 형식에서는 약 16 자릿수만 표현할 수 있습니다.
  2. 숫자가 외부 십진수에서 내부 이진수로 혹은 그 반대로 바뀔 때마다 정밀도가 손실 될 수 있습니다.
  3. 항상 안전한 비교를 사용하십시오.
  4. 덧셈과 뺄셈은 결과에서 실제 유효 숫자를 빠르게 침식할 수 있다는 것에 주의하십시오. 컴퓨터는 실제로 어떤 비트가 중요한지 알지 못합니다.
  5. 데이터 유형 간의 변환은 까다로울 수 있습니다. 배 정밀도로의 변환이 실제 유효 비트수를 늘리지는 않습니다. 부동소수점 숫자가 더 큰 정수로 출력되는 경우에도 정수로의 변환은 항상 0 으로 잘립니다.
  6. 두 가지 다른 부동소수점 구현에서 동일한 결과를 기대하지 마십시오.


제가 여러분에게 부동소수점 연산의 내부에서 어떤 일들이 발생하고 있는지 좀 더 알려줬기를 바랍니다. 그리고 그 이상한 결과들에 대해서 이해할 수 있게 되었기를 바랍니다.


"위험"중 일부는 피할 수 있지만 대부분은 이해하고 받아들일 필요가 있습니다.


IEEE Standard Floating-Point Formats


IEEE (Institute of Electrical and Electronics Engineers, Inc.)는 부동 소수점 표현 및 계산 결과에 대한 표준을 정의했습니다 (IEEE Std 754-1985). 이 절에서는 부동 소수점 수를 나타내는 IEEE 표준에 대한 개요를 제공합니다. 여기에 포함 된 데이터는이 기사의 나머지 부분에서 일부 내용을 설명하는 데 도움이되지만 기본 개념을 이해하는 데 반드시 필요한 것은 아닙니다.


대부분의 이진 부동 소수점 수는 1.ffffff x 2^n 으로 나타낼 수 있습니다. 여기서 1 은 정수 비트이고, f 는 소수 비트이며 n 은 지수입니다. 정수 비트와 소수 비트의 조합을 가수 ( 또는 significand )라고 합니다. 대부분의 숫자는 정수 비트에 1이 있도록 지수를 조정할 수 있기 때문에( 정규화라고 부르는 프로세스 ), 1을 저장할 필요가 없으므로 효율적으로 추가적인 정밀도를 허용하게 됩니다. 이 비트를 숨겨진 비트라고합니다. 숫자는 부호있는 가수로 표현되므로, 음수는 동일한 크기의 양수와 동일한 가수를 갖지만 부호 비트가 1 입니다. 바이어스( bias )라고 하는 상수가 지수에 더해져 모든 지수는 양수가 됩니다.


지수가 0이고 가수가 0 인 값 0.0 은 음수 기호를 가질 수 있습니다. 음수 0 은 대부분의 프로그램에서 분명하지 않은 미묘한 속성을 가지고 있습니다. 0 이 아닌 가수를 가진진 지수 0 은 "비정규 수( denormal )"입니다. 비정규 수는 크기가 너무 작아 정수 비트 1 로 표현 될 수가 없고 하나의 유효 비트보다 작은 값을 가질 수 있습니다.


지수의 비트값이 모두 1 인( 가장 큰 지수 ) 경우에는 특수한 숫자를 표현합니다. 가수가 0 이면 무한대( 양수 또는 음수 )를 표현합니다. 가수가 0 이 아니면 NAN( not-a-number )을 표현합니다. 잘못된 수치 연산의 결과로 발생하는 NAN 에 대해서는 이 기사에서 더 설명하지 않습니다.


IEEE 표준은 32 비트 및 64 비트 부동 소수점 표현을 정의합니다. 32 비트 ( 단일 정밀도 ) 형식은 상위에서 하위로, 부호 비트, 127 의 바이어스가 있는 8 비트 지수, 23 비트의 가수를 가집니다. 64 비트 ( 배 정밀도 ) 형식은 부호 비트, 1023 의 바이어스가 있는 11 비트 지수, 52 비트의 가수를 가집니다. 숨겨진 비트를 사용하면 정규화된 숫자의 유효 정밀도는 각각 24 비트와 53 비트입니다.


Single-precision format

31, 30-23, 22-0

S, Exponent, Significand


Double-precision format

63, 62-52, 51-0

S, Exponent, Significand


Bibliography


American National Standards Institute (1978), "American National Standard, Programming Language FORTRAN", ANSI X3.9-1978, ISO 1539-1980 (E).


IEEE Computer Society (1985), "IEEE Standard for Binary Floating-Point Arithmetic", IEEE Std 754-1985.

'물리_수학_기하학' 카테고리의 다른 글

중력과 수직항력  (10) 2019.07.07
장력( Tension )  (13) 2019.06.26
Matrix major & multiplication  (2) 2018.08.12
[ 번역 ] Depth Precision Visualized  (11) 2017.06.04
[ 번역 ] Moment of inertia 일부 번역  (0) 2016.11.07
[ 번역 ] 캐릭터 애니메이션 : 스켈레톤과 역운동학  (0) 2016.10.01
모멘트( moment )  (22) 2016.01.06
Curve  (0) 2015.10.04
[ 일부 번역 ] CLIPPING USING HOMOGENEOUS COORDINATES  (0) 2013.04.29
Arc length of curve  (0) 2012.09.21

개요

 

Gradle 을 사용하다가 보면 처음에 가장 이해하기 어려운 것이 dependency 입니다. 

 

특히 UE4 를 사용해서 cross-platform 작업을 하시는 분들은 AutomationTool 을 사용하고 있기 때문에 내부가 어떻게 돌아가고 있는지 잘 모르는 경향이 있습니다. 사실 저도 XBox 와 PS4 쪽 작업을 할 때는 그런 고민을 별로 안 했습니다.

 

그 플랫폼들은 자체 포맷으로 packaging 하는 것을 제외하고는 Neighborhood 와 같은 도구를 통해 쉽게 deploy 할 수 있도록 해 줍니다. 뭔가 추가적인 모듈을 사용하고 싶다면 이미 배포된 SDK/XDK 의 것을 가져다 쓰면 됩니다. 즉 C++ 코드 내에서 작업이 수행됩니다.

 

문제는 Android 입니다( IOS 는 제가 잘 모르겠습니다 ). 일단 Android 에서는 프로그램 실행을 위한 기본 언어가 Java 입니다. Native C++ 을 사용할 수 있도록 NDK( Native Development Kit ) 를 제공하고 있지만, 이건 그냥 라이브러리라고 생각하시면 됩니다. 물론 몇 가지 디버깅 툴이 있기는 하지만 이 문서의 주제와는 관련이 없으므로 그냥 넘어 가도록 하겠습니다. 어쨌든 UE4 에서 작업해서 나온 "libUE4.so" 파일을 사용하기 위해서는 JNI( Java Native Interface )를 통해서 Java 코드와 연관시켜야 합니다.

 

사실 이런 모든 작업은 AutomationTool 을 통해서 수행됩니다. 하지만! BUT! 본인이 UE4 에서 제공하는 모듈 이외의 것을 사용하려고 한다면, 그때부터는 지옥이 열립니다.

 

저 스스로에 대해서 한탄스럽게 생각하는 거긴 하지만, 일반적으로 Windwos 개발자들은 MAKE, CMake, Ant, Gradle 과 같은 도구 사용에 익숙하지 못합니다. 그런 게 존재하는지도 모르는 경우가 많죠.

 

우리는 Microsoft 의 Visual Studio 는 Windows 플랫폼에서 개발하기 위한 기본 설정들을 모두 해 줍니다. 하지만 외부 라이브러리 소스들을 받으면 조금 어렵습니다. "*.sln" 이나 ".vcxproj" 같은 구성 파일들이 없으면 힘들죠. 이 상황에서 매우 쉬운 길을 택하면 그냥 자기가 가지고 있던 프로젝트에 전부 포함시켜 버립니다. 그나마 좀 깔끔하게 라이브러리로 사용하겠다는 분들은 직접 프로젝트를 만들어서 넣습니다.

 

그런데 수작업하는 것도 규모가 작은 소스를 사용할 때의 이야기지 엄청난 규모의 소스를 사용하고 있고, 그 소스들이 내부적으로 서로를 DLL 로 참조해야 하는 제약이 있다거나 하면 '멘붕'에 빠지게 됩니다.

 

이를 좀 더 쉽게 할 수 있도록 해 주는 것이 CMake 입니다. 하지만 "알아야 면장이라도 한다"는 속담이 있듯이, 내가 원하는 대로 설정하려면 최소한의 기본지식은 있어야 하는데 지금까지 경험해 보지 못한 용어와 구조때문에 머리가 아픕니다. Visual Studio 에서는 NMAKE 프로젝트라는 것이 나오기도 했습니다. 현재 UE4 빌드시스템이 NMAKE 를 사용하고 있죠.

 

그나마 Windows 플랫폼에서 C++ 작업을 좀 해 보신 분들이라면 NMake 에는 익숙할 겁니다. 하지만 Ant, Gradle 은 신세계입니다. 기본적으로 Java 를 위한 프로젝트를 구성하고 컴파일, 배포까지 수행합니다. 뭔가 하는 일이 어마어마하게 많죠.

 

Windows 가 개발자에게 매력있게 다가올 수 있는 이유는 엄청난 자동화와 매우 편리한 IDE 일 것입니다. 하지만 오픈소스 프로젝트들은 누가 본격적으로 관리하지 않는 오픈소스답게 초심자들에게는 불편한 점이 많습니다. 그래서 Ant, Gradle 같은 자동화 도구들을 만드는 것 같은데, Visual Studio 를 사용하는 입장에서는 매우 불편하게 느껴지는 것이 현실입니다. 그리고 Windows 플랫폼에서 응용프로그램 개발을 하는 분들이라면 C# 이나 C++ 를 사용하지, Java 는 거의 사용하지 않을 겁니다( 물론 사용하는 분들이 있기는 하겠지만 적어도 게임업계에서는 그렇습니다 ).

 

서론이 길었는데요, 어쨌든 UE4 에서 Android 개발을 하는데, 뭔가 커스텀한 일을 수행하고자 한다면, Java 와 Gradle 은 필수라는 겁니다. 예전에는 Ant 가 기본 빌드 시스템이었는데, 최근에 Gradle 로 갈아탔다고 하더군요.

 

최근에 회사에서 Gradle 에 대해서 분석하고 해 봤는데, 제가 형식에만 집중하고 알맹이는 쏙 빠뜨렸다는 생각이 들어서 공부를 더 하고 있습니다. Gradle build script, Groovy, UPL, 이 세 가지 부분에 집중했는데, 정말 중요한 부분을 빠뜨리고 공부했습니다. 사실 앞에서 언급한 것들은 기본으로 알아야 하는 것이고, dependency 야 말로 가장 중요한 부분입니다. 제가 겪은 대부분의 에러는 이쪽에서 발생했습니다( 물론 제가 경험이 엄청 많다는 이야기는 아닙니다 ).

 

이 문서에서는 Gradle 의 dependency 개념에 대해 소개하고, UPL 과 dependency 가 어떻게 연관되는 지 설명할 것입니다. 아직 Gradle 의 기본 개념과 Groovy 문법에 대해서 모르신다면, 공부를 하고 보시는 것이 좋을 것 같습니다. 제가 그런 부분에 대해서까지 설명하기에는 좀 부족함이 많네요. UPL 에 대해서는 제 블로그의 [ UnrealPluginLanguage ( UPL ) 이란 ] 에 정리해 뒀습니다.

 

 

Gradle Dependency

 

Dependency 를 매우 저렴하게 표현하자면, Visual C++ 에서 볼 수 있는 헤더 경로와 라이브러리 경로입니다. 예를 들어 다음과 같은 호출을 생각할 수 있겠죠.

 

 

내가 사용하고자 하는 모듈의 헤더와 라이브러리를 포함하지 않고서는 해당 모듈이 제공하는 기능을 이용할 수 없습니다. 컴파일 에러나 링크 에러가 나겠죠. 그러면 이런 헤더와 라이브러리는 어디에서 구해야 하는 걸까요? 보통 VS 로 개발할 때는 소스코드를 아예 프로젝트에 포함시키거나, 소스코드의 라이브러리를 받아서 import 합니다. 혹은 라이브러리를 그대로 받아서 import 하죠.

 

Linux 에 관심이 있어서 깔아 보신 분들이라면, 아래와 같은 형식으로 패키지를 설치하는 걸 보신 적이 있을 겁니다.

 

 

특정 플랫폼을 대상으로 하는 특정 버전의 gzip 을 설치하는 거죠. 이렇게 하면 자동으로 http 를 통해 설치하고자 하는 패키지의 소스코드를 다운로드해서 빌드해서 설치해 줍니다. 참 쉽죠?

 

어쨌든 Linux 같은 오픈소스 진영에서는 외부에 저장소( repository )를 두고 거기에서 소스를 다운로드 받는 것이 일반화되어 있는 것 같습니다. Linux 는 매우 변종이 많기 때문에 정확한 플랫폼과 버전을 지정하지 않으면 안 되겠죠. Windows 야 자기들이 OS 를 만드니까 알아서 패치할 수 있지만, Linux 에서는 쉽지 않습니다. Ubuntu 같은 OS 들도 자기들이 원하는 건 설치하겠지만, 남들이 만들어 놓은 것은 어떻게 할 수 없겠죠.

 

Gradle 은 아래 그림에서 보이듯이 gradle 이 dependency 를 받는 방법은 3 가지 입니다. Network 를 통해 외부 repository 에서 받는 경우가 있고, local repository 에서 받는 경우가 있습니다. 마지막으로 이미 netowrk 를 통해 받은 repository 를 caching 해 놓고 거기에서 가지고 오는 경우가 있습니다.

 

 

 

머리가 아프실테니, 이야기를 더 진행하기 전에 기본 용어부터 정리하도록 하겠습니다. 아래 인용부분은 [ 2 ] 의 번역입니다.

 

Configuration

 

특정 목적을 위해 그룹화된 dependency 들의 named set 을 의미합니다: 예를 들어 "implementation" configuration 은 프로젝트를 컴파일하는 데 요구되는 dependency 들입니다. Configuration 은 module 과 artifact 에 대한 접근을 제공합니다. Configuration 이라는 말은 dependency management 문맥 바깥에서는 다른 의미를 가질 수 있으니 주의하세요.

 

Dependency

 

Module 을 빌드하고 테스트하고 실행하는 데 요구되는 소프트웨어의 일부에 대한 pointer 입니다.

 

Dependency constraint

 

Dependency constraint 는 어떤 dependency 에 대해서 module 이 유효한 결과를 산출하도록 하기 위해 필요한 요구사항을 의미합니다. 예를 들어 dependency constraint 는 지원되는 module version 집합을 한정할 수 있습니다. Dependency constraint 는 transitive dependency 에 대한 요청과 같은 것을 표현하는 데 사용될 수도 있습니다. 더 많은 정보를 원하신다면, "Managing versions of transitive dependencies with dependency constraints" 항목을 참조하세요.

 

Module

 

Google Guava 와 같은 소프트웨어 조각을 의미합니다. 이것은 시간이 지남에 따라서 바뀌고 있고 모든 버전을 포함한 개념입니다. 모든 module 은 이름을 가지고 있습니다. Module 의 각 배포본( release )은 module version 에 의해 표현됩니다. 편리하게 사용될 수 있도록 하기 위해, module 은 repository 에서 호스팅될 수 있습니다.

 

Module metadata

 

Module 배포본은 metadata 를 제공할 수 있습니다. Metadata는 module 에 대해 자세히 기술하는 데이터입니다. 예를 들어 repository 의 좌표( coordinate )라든가, project 나 요구되는 transitive dependency 에 대한 정보같은 것들이 있습니다. Maven 에서 metadata 파일은 ".pom" 이라 불리며, Ivy 에서는 "ivy.xml" 이라 불립니다.

 

Module version

 

Module version 은 배포된 module 이 변화할 때마다 만들어진 독립된 집합을 의미합니다. 예를 들어 18.0 은 com.google:gujava:18.0 이라는 좌표를 사용하는 module version 임을 의미합니다. 경험적으로 볼 때, module version 의 스키마에 대한 제약은 없습니다. 시간, 숫자, "-GA" 같은 특별한 접미어들이 식별자로 허용되고 있습니다. 가장 널리 사용되고 있는 versioning 전략은 "semantic versioning" 입니다.

 

Repository

 

Repository( 저장소 ) 는 module 집합을 호스팅합니다. 각각은 module version 에 의해 식별되는 하나 이상의 배포본들을 제공합니다. Repository 는 binary repository product( 예를 들어 Artifactory 나 Nexus ) 나 파일 시스템의 directory sturcture 에 기반하고 있습니다. 더 많은 정보를 원한다면 "Declaring repository" 를 참조하세요.

 

Resolution rule

 

Resolution rule 은 dependency 가 풀리는( resolved ) 방식에 영향을 줍니다. Resolution rule 은 빌드 로직의 일부로 정의됩니다. 더 많은 정보를 원하신다면 "Customizing Dependency Resolution Behavior" 를 참조하세요.

 

정리하자면 어떤 module 은 module version 과 함께 배포( release )되며, 이것은 repository 좌표를 찍어서 얻어 올 수 있는 겁니다.

 

Declaring Repository

 

그러면 여기에서 "어떤 repository 들이 있을가?" 라는 의문이 생기겠죠. Repository 의 종류는 "Repository Types" 문서에서 찾아 볼 수 있습니다. 일반적으로는 세 가지 종류의 repository 를 사용하고 있습니다.

 

출처 : [ 3 ]

 

예를 들어 JCenter 를 사용한다고 하면, Gradle 빌드 스크립트에 다음과 같이 적습니다.

 

 

하지만 Crashlytics 와 같은 특정 서비스들은 자신만의 repository 를 제공하기도 합니다. 그런 경우에는 URL 을 통해 접근해야 하죠.

 

 

URL 위쪽에 "maven" 이라는 closure 로 묶여 있는 것을 볼 수 있는데요, 이것은 RepositoryHandler 라고 부릅니다. "Repository Types" 에서 지정하고 있는 repository 에 대해 구체적인 정보를 설정할 수 있게 해 주는 것이죠.

 

Declaring Dependency

 

이제 사용할 dependency 를 선언해야 합니다. 일반적으로는 module 제공하는 사람들이 repository 와 version 을 알려줍니다. 예를 들어 Fabric Crashlytics 같은 경우에는 documentation 에서 이런 예제를 제공하죠.

 

 

 

첫 번째 코드는 gradle 프로젝트의 "base/build.gradle" 에 입력할 내용이고 두 번째 코드는 "library/build.grale" 에 입력할 코드입니다.

 

보시면 알겠지만 하나는 "dependencies" closure 에 "classpath" 라는 식별자를 사용하고 있고, 다른 하나는 "compile" 이라는 식별자를 사용하고 있습니다. 이 두 가지는 차이가 있습니다. "classpath" 는 플러그인에 접근하는 데 사용하는 경로입니다. 하지만 "compile" 은 라이브러리에 접근하는 데 사용합니다.

 

여기에서 구체적으로 언급하기에는 너무 내용이 많습니다. 그러므로 "Gradle User Guide" 에 가서 링크를 찾아 다니면서 내용을 숙지하시는 것이 좋습니다. 여기에서는 dependency 가 뭐고 UPL 과 어떤 관계를 가지는 지에 대해서만 정리할 것입니다.

 

UPL 과 Dependency

 

 

UnrealPluginLanguage 는 크게 "AndroidManifest.xml", "buildAddition.gradle", "*Activiy.java" 파일들을 수정할 수 있도록 해주는 일종의 스크립트입니다. 가장 쉬운 예제는 "Engine/Source/Runtime/Advertising/AndroidAdvertising" 에 있는 "AndroidAdvertising" 모듈에서 찾아 볼 수 있습니다.

 

이 모듈에서 핵심적인 파일은 두 개 입니다.

 

 

"AndroidAdvertising.Build.cs" 에서는 "AndroidAdvertising_APL.xml" 을 등록합니다. 여기에서 "UPL" 이 아니라 "APL" 이라는 이름을 사용해서 헷갈리는 분들이 계실텐데요, "APL" 은 "Android Plugin Language" 의 약자로 UPL 의 전신입니다. Android 전용으로만 사용되었을 때 사용하던 용어입니다.

 

어쨌든 다음과 같이 UPL 을 등록합니다.

 

 

그러면 AutomationTool 에서 Android BuildTool 을 실행하면서 이 UPL 파일을 평가하게 되는거죠. 이 UPL 파일 안쪽에 보면, "<buildGradleAdditions>" 엘리먼트에서 다음과 같이 "buildAddtions.gradle" 파일에 코드를 삽입하는 것을 볼 수 있습니다.

 

 

이건 또 "implementation" 이라는 키워드를 사용하는데요, 프로젝트 컴파일에 사용하는 구성이므로 그렇습니다.

 

UPL 은 정말 많은 커스터마이징 기능을 가지고 있습니다. 그 목록은 [ UnrealPluginLanguage ( UPL ) 이란 ] 에서 찾아 보세요.

 

참고자료

 

[ 1 ] Introduction to Dependency Management, Gradle Docs.

[ 2 ] Dependency Management Terminology, Gradle Docs.

[ 3 ] Declaring Repositories, Gradle Docs.

UE4 에서 Android 를 위해서 Crashlystics 를 붙여 보려고 하는데, 이를 위해서는 "AndroidMenifest.xml" 과 "build.gradle" 파일이 수정되어야 합니다. 하지만 이 파일들은 packaging 시에 생성되므로, 어떻게 손을 댈 방법이 없습니다.


어떤 사람들은 apk 가 나온 뒤에 Android Studio 같은 도구를 사용해서 바꾸라고 하는데, 저는 이렇게 추가적인 프로세스가 들어 가는 것은 불합리하다고 생각을 했습니다. 그래서 검색을 좀 해 보니, UE4 4.17 에 gradle 지원을 하면서, UnrealPluginLanguage 에 대한 언급을 한게 있더군요.


UE4 4.17 을 릴리스하면서, Gradle 에 대한 지원이 실험 빌드 옵션으로 제공됩니다. 이는 Android Project Settings 의 프로젝트당 설정으로 활성화되는데요, "Enable instead of Ant [Experimental]" 체크박스를 사용합니다. 만약 여러분이 Android SDK 라이선스 동의를 하지 않았다면, "동의" 라는 표시와 함께 다이얼로그가 활성화됩니다. "동의" 를 누르면 적절한 라이선스 파일이 생성될 것이며, Gradle 이 종속성을 다운로드할 수 있게 됩니다.


aar-imports.txt 나 Unreal Plugin Language( UPL )<AARImports/> 를 사용해 등록되는 AAR 파일들이 Gradle 의 종속성으로서 자동으로 추가됩니다. build.gradle 에 대한 추가사항은 UPL<buildGradleAdditions/> 노드를 사용해 만들어질 수 있습니다. build.gradle 을 추가하는 다른 방법은 "additions.gradle" 이라는 파일을 <prebuildCopies/> 노드를 사용해 JavaLibs 에 복사되는 디렉토리 중 하나에 저장하는 것입니다( Engine/Source/ThirdParty/AndroidPermission/permission_library 를 예제로 참고하세요 ).


새로운 Gradle build path 의 유용한 이점은 Android Studio 에서 launch 하거나 packging 한 후에 결과로 나오는 build.gradle 을 로드할 수 있다는 겁니다. 이 파일은 여러분의 프로젝트의 "Intermediate/Android/APK/gradle" 디렉토리에 존재할 겁니다. 이 구현을 사용해서 native debugging 이 가능하지는 않지만, 배치 파일을 먼저 실행해 packaging 으로부터 실행되거나 설치된 OBB 를 가지고 있다면, Run 이나 Debug 버튼을 누를 수 있습니다. Native 코드를 변경하지만 않았다면, Java 코드를 변경하거나 Java 를 디버깅하기 위해 중단점을 설정하거나, 이터레이션을 수행할 수 있습니다. 경고: 이 위치에서는 Java 코드의 복사본을 가지고 작업하고 있기 때문에, UE4 에디터에서 launch 나 package 를 사용하기 전에 정상 위치에 변경사항을 반영하시기 바랍니다. 그렇지 않으면 덮어써질 겁니다!


이 릴리스에서 Gradle 을 사용하다가 문제가 생기면 알려 주세요; Google 이 더 이상 Ant 를 지원하지 않으므로, 그것을 제거하려고 합니다.


출처 : Gradle support in UE4 4.17


여기에서 알 수 있는 건 단지 Gradle 로 빌드 체인을 변경하면서, UnrealPluginLanguage 라는 것을 사용해서 종속성을 추가해야 한다는 것입니다. 어떻게 사용하는 지는 모르겠구요.


찾아 보니, 이것에 대한 공식 문서는 존재하지 않고, 그냥 "UnrealPluginLanguage.cs" 파일에 주석으로 설명해 놨더군요.


UnrealPluginLanguage ( UPL ) 은 간단한 XML 기반 언어로 XML 을 조작하고 문자열을 반환하는데 사용됩니다. 이것은 <init> 섹션을 포함하고 있는데, 이 섹션은 다른 섹션들이 평가되기 전에 아키텍쳐 당 한 번만 평가됩니다. 상태가 보존되고, 다음 섹션들이 평가될 때 이전됩니다. 그러므로 섹션이 실행되는 순서는 중요합니다.


UPL 은 XML 을 수정하고 질의하는 데 있어 일반적인 시스템이지만, 그것은 특별히 플러그인들이 그것이 구성하고 있는 package 의 전역 설정에 영향을 줄 수 있도록 하는데 사용합니다. 예를 들어, 이것은 플러그인이 Android 의 APK AndroidManifest.xml 파일을 수정하거나 IOS IPA 의 plist 파일을 수정할 수 있도록 해 줍니다. UBT 는 플러그인의 UPL xml 파일을 질의해서 Android 의 .java 파일들과 같은 패키지에 있어 일반적으로 포함되어야 하는 파일들에 문자들을 포함시킬 수 있도록 합니다.


만약 여러분이 플러그인 칸텍스트( context )에서 실행될 명령들을 보기 원한다면 추적( tracing )을 활성화하십시오 :


<trace enabled="true"/>


이 명령 이후에는 <trace enable="false"/> 가 실행될 때까지 여러분의 칸텍스트에서 실행되는 모든 노드들이 로그에 기록될 겁니다. 다음 명령을 사용해서 여러분의 칸텍스트에 있는 모든 변수들을 출력할 수 있습니다 :


<dumpvars/>


Bool, Int, String 변수 유형들이 지원됩니다. 모든 애트리뷰트( attribute )들은 대부분 변수를 참조할 것이며, 다음 구문을 사용해 평가되기 전에 문자열로 치환될 겁니다.


$B(name) = boolean 변수 "name" 의 값.

$I(name) = integer 변수 "name" 의 값.

$S(name) = string 변수 "name" 의 값.

$E(name_ = element 변수 "name" 의 값.


다음 변수들은 자동으로 초기화됩니다:


$S(Output) = 이 섹션을 평가해서 반환된 출력( Input 에 대해 초기화됨 ).

$S(Architecture) = 대상 아키텍쳐( armeabi-armv7a, arm64-v8a, x86, x86_64 ).

$S(PluginDir) = XML 파일이 로드된 디렉토리.

$S(EngineDir) = 엔진 디렉토리.

$S(BuildDir) = 프로젝트의 플랫폼 관련 빌드 디렉토리( Intermediate 폴너 내부 ).

$S(Configuration) = 구성 유형( Debug, DebugGame, Development, Test, Shipping ).

$B(Distribution) = Distribution 빌드이면 true.


주의 : 위에 있는 변수들에서 예외가 발생하면, 플러그인의 칸텍스트에 있는 모든 것들은 namespace 충돌을 막습니다; 새로운 값을 위의 변수들에 설정하려고 시도하는데, Output 에서 예외가 발생하면, 현재 칸텍스트에만 영향을 줄 것입니다.


다음 노드들은 변수를 조작하는 데 사용됩니다:


<setBool result="" value=""/>

<setInt result="" value=""/>

<setString result="" value=""/>

<setElement result="" value=""/>

<setElement result="" value="" text=""/>

<setElement result="" xml=""/>


value 와 함께 사용되는 <setElement> 는 태그( tag )가 value 로 설정된 비어있는 XML 엘리먼트를 생성합니다.

value 및 text 와 함께 사용되는 <setElement> 는 태그가 value 로 설정된 파싱되지 않은 text 에 대한 XML 엘리먼트를 생성합니다.

xml 과 함께 사용되는 <setElement> 는 제공된 XML 을 파싱할 겁니다. escape character 를 넣으면 안 됩니다.


변수들은 ini 파일에 있는 속성( property )들로부터 설정될 수도 있습니다:


<setBoolFromProperty result="" ini="" section="" property="" default=""/>

<setBoolFromPropertyContains result="" ini="" section="" property="" default="" contains=""/>

<setIntFromProperty result="" ini="" section="" property="" default=""/>

<setStringFromProperty result="" ini="" section="" property="" default=""/>


문자열들은 <setStringFromEnvVar> 노드를 사용해 환경 변수로부터 설정될 수도 있습니다.

환경 변수들은 반드시 '%' 문자의 쌍으로 묶여 있어야 하며 'value' 애트리뷰트로서 지정되어야만 합니다.


<setStringFromEnvVar result="" value=""/>


특정 환경 변수가 정의되어 있는지를 검사하는 것도 가능합니다( 역시, '%' 문자로 묶여야 합니다 ):


<setBoolEnvVarDefined result="" value=""/>


환경 변수 노드를 사용하는 일반적인 예제는 다음과 같습니다:


<setBoolEnvVarDefined result="bHasNDK" value="%NDKROOT%"/>

<setStringFromEnvVar result="NDKPath" value="%NDKROOT%"/>


Boolean 변수들은 연산자가 적용된 결과로 설정될 수도 있습니다:


<setBoolNot result="" source=""/>

<setBoolAnd result="" arg1="" arg2=""/>

<setBoolOr result="" arg1="" arg2=""/>

<setBoolIsEqual result="" arg1="" arg2=""/>

<setBoolIsLess result="" arg1="" arg2=""/>

<setBoolIsLessEqual result="" arg1="" arg2=""/>

<setBoolIsGreater result="" arg1="" arg2=""/>

 <setBoolIsGreaterEqual result="" arg1="" arg2=""/>

<setBoolFromPropertyContains result="" ini="" section="" property="" contains=""/>


Integer 변수들은 수학 연산자들을 사용할 수 있습니다:


<setIntAdd result="" arg1="" arg2=""/>

<setIntSubtract result="" arg1="" arg2=""/>

<setIntMultiply result="" arg1="" arg2=""/>

<setIntDivide result="" arg1="" arg2=""/>


String 변수들은 다음과 같이 조작될 수 있습니다:


<setStringAdd result="" arg1="" arg2=""/>

<setStringSubstring result="" source="" start="" length=""/>

<setStringReplace result="" source="" find="" with=""/>


String 의 길이를 획득할 수도 있습니다:


<setIntLength result="" source=""/>


소스로부터 문자열을 검색해 인덱스를 획득할 수도 있습니다 :


<setIntFindString result="" source="" find=""/>


다음 예제는 문자열을 비교하는데, <setIntFindString> 을 사용하는 대신에 result 를 검사합니다:


<setBoolStartsWith result="" source="" find=""/>

<setBoolEndsWith result="" source="" find=""/>

<setBoolContains result="" source="" find=""/>


다음 노드를 사용하면 로그에 메시지를 기록할 수 있습니다:


<log text=""/>


다음 형식을 사용하면 조건 실행이 가능합니다:


<if condition="">

<true>

  <!-- condition 이 true 이면 실행 -->

  </true>

  <false>

  <!-- condition 이 false 이면 실행 -->

  </false>

 </if>


<true> 와 <false> 블락은 선택적입니다. 조건은 반드시 boolean 변수여야 합니다.

Boolean 연산자 노드를 결합해서 더욱 복잡한 조건을 위한 최종 상태를 만들 수 있습니다 :


<setBoolNot result="notDistribution" source="$B(Distribution)/>

<setBoolIsEqual result="isX86" arg1="$S(Architecture)" arg2="x86"/>

<setBoolIsEqual result="isX86_64" arg2="$S(Architecture)" arg2="x86_64/">

<setBoolOr result="isIntel" arg1="$B(isX86)" arg2="$B(isX86_64)"/>

<setBoolAnd result="intelAndNotDistribution" arg1="$B(isIntel)" arg2="$B(notDistribution)"/>

<if condition="intelAndNotDistribution">

<true>

<!-- do something for Intel if not a distribution build -->

</true>

</if>


"isIntel" 은 다음과 같이 수행될 수도 있음에 주목하세요:


<setStringSubstring result="subarch" source="$S(Architecture)" start="0" length="3"/>

<setBoolEquals result="isIntel" arg1="$S(subarch)" arg2="x86"/>


조건 실행을 위해서 아래 두 노드를 이용할 수 있습니다:


<isArch arch="armeabi-armv7">

<!-- do stuff -->

</isArch>


이것은 다음과 동일합니다:


<setBoolEquals result="temp" arg1="$S(Architecture)" arg2="armeabi-armv7">

<if condition="temp">

<true>

<!-- do stuff -->

</true>

</if>


그리고


<isDistribution>

<!-- do stuff -->

</isDistribution>


는 다음과 동일합니다 :


<if condition="Distribution">

<!-- do stuff -->

</if>


다음 노드들을 사용해 루프를 돌릴 수도 있습니다:


<while condition="">

<!-- do stuff -->

</while>


<break/>

<continue/>


<while> 바디는 condition 이 false 이거나 <break/> 를 만날 때까지 실행될 겁니다. <continue/> 는 condition 이 여전히 true 인 동안에는 루프 실행을 재개할 것이며 그렇지 않으면 루프를 나갈 겁니다.


주의 : <while> 바디 바깥쪽의 <break/> 는 <return/> 과 동일하게 동작합니다.


여기에 1 에서 5 까지 로그를 출력하는데, 3 은 건너 뛰는 예제가 있습니다. while 조건의 갱신은 continue 전에 수행되어야 한다는 것에 주목하세요. 그렇지 않으면 루프를 나가버립니다.


<setInt result="index" value="0"/>

<setBool result="loopRun" value="true"/>

<while condition="loopRun">

<setIntAdd result="index" arg1="$I(index)" arg2="1"/>

<setBoolIsLess result="loopRun" arg1="$I(index)" arg2="5"/>

<setBoolIsEqual result="indexIs3" arg1="$I(index)" arg2="3"/>

<if condition="indexIs3">

<true>

<continue/>

</true>

</if>

<log text="$I(index)"/>

</while>


result 와 name 을 생성할 때 변수 치환을 하는 것도 가능합니다. 이것은 루프 안에서 배열을 생성하는 것을 가능하게 해 줍니다:


<setString result="array_$I(index)" value="element $I(index) in array"/>


다음 노드를 사용해서 그것을 획득할 수 있습니다( 값이 변수 이름으로 취급됩니다 ):


<setStringFrom result="out" value="array_$I(index)"/>


Boolean 및 integer 유형에 대해 여러분은 <setBoolFrom/> 과 <setIntFrom/> 을 사용할 수 있습니다.


섹션에 텍스트를 삽입하기 위한 노드는 다음과 같습니다:


<insert> body </insert>

<insertNewline/>

<insertValue value=""/>

<loadLibrary name="" failmsg=""/>


첫 번째 것은 텍스트나 노드를 반환되는 섹션 문자열에 삽입합니다. escape character 를 사용하려면 다음과 같이 해야 한다는 것에 주의하세요 :


< = &lt;

> = &gt;

& = &amp;


<insertValue value=""/> 는 삽입 전에 value 의 변수를 평가합니다. 만약 value 가 쌍따옴표( " ) 를 포함하고 있다면, 그것을 &quot; 로 작성해야 합니다:


<loadLibrary name="" failmsg=""/> 는 system.LoadLibrary try/catch 블락을 삽입하기 위한 단축명령이며, 로딩에 실패했을 때 사용할 선택적인 로그 메시지를 포함합니다.


Output 을 검색해서 치환할 수도 있습니다:


<replace find="" with=""/>


실제 $S(Output) 을 직접 조작할 수도 있다는 것을 기억하세요. 위의 것이 훨씬 효율적입니다:


<setStringAdd result="Input" arg1="$S(Output)" arg2="sample\n"/>

<setStringReplace result="Input" source="$S(Output)" find=".LAUNCH" with=".INFO"/>


XML 조작은 다음 노드를 사용합니다:


<addElement tag="" name=""/>

<addElements tag=""> body </addElements>

<removeElement tag=""/>

<setStringFromTag result="" tag=""/>

<setStringFromAttribute result="" tag="" name=""/>

<setStringFromTagText result="" tag=""/>

<addAttribute tag="" name="" value=""/>

<removeAttribute tag="" name=""/>

<loopElements tag=""> instructions </loopElements>


현재 엘리먼트는 tag="$" 에 의해 참조됩니다. 엘리먼트 변수들은 $varname 을 사용해 참조되는데, $E(varname) 을 사용하면 XML 의 string 으로 확장되 버리기 때문입니다.


addElement, addElements, removeElement 는 기본적으로 일치하는 태그들에 대해 모두 적용됩니다. 선택적인 once="true" 애트리뷰트가 지정되면 처음으로 일치하는 태그에 대해서만 적용되도록 할 수 있습니다.


<uses-permission>, <uses-feature>, <uses-library> 는 다음과 같이 갱신되었습니다:


addPermission android:name="" .. />

<addFeature android:name="" .. />

<addLibrary android:name="" .. />


위의 명령들에서 모든 애트리뷰트들은 매니페이스에 추가되는 엘리먼트들에 복사되므로, 여러분은 다음과 같이 할 수 있습니다:


<addFeature android:name="android.hardware.usb.host" android:required="true"/>


마지막으로, 다음 노드들은 jar 이나 파일들을 스테이징하기 위해서 파일을을 복사하는 것을 허용합니다:


<copyFile src="" dst="" force=""/>

<copyDir src="" dst="" force=""/>


만약 force 가 false 라면, 길이나 수정시간이 다를 때만 파일들이 대체될 것입니다. 기본값은 true 입니다.


다음은 src 와 dst 경로를 위해 base 로 사용되어야 합니다:


$S(PluginDir) = XML 파일이 로드된 디렉토리.

$S(EngineDir) = 엔진 디렉토리.

$S(BuildDir) = 프로젝트의 플랫폼 관련 빌드 디렉토리.


APK 디렉토리 외부에 파일을 쓰는 것도 가능하지만, 추천하지는 않습니다.


만약 파일을 제거해야 한다면( 예를 들어 development-only 파일을 distribution 빌드에서 제거하려면 ), 다음 노드를 사용할 수 있습니다:


<deleteFiles filespec=""/>


이건 BuildDir 에서 파일을 제거하는 용도로만 사용될 수 있습니다. Occlus Signature( soig ) 파일들을 assets 디렉토리에서 제거하는 예제가 있습니다:


<deleteFiles filespec="assets/oculussig_*"/>


다음 섹션은 패키징과 디플로잉( deploy ) 스테이지에서 평가됩니다 :


** For all platforms **

<!-- init section is always evaluated once per architecture -->

<init> </init>

 

 ** Android Specific sections **

<!-- optional updates applied to AndroidManifest.xml -->

<androidManifestUpdates> </androidManifestUpdates>

<!-- optional additions to proguard -->

<proguardAdditions> </proguardAdditions>

<!-- optional AAR imports additions -->

<AARImports> </AARImports>

<!-- optional base build.gradle additions -->

<baseBuildGradleAdditions>  </baseBuildGradleAdditions>


<!-- optional base build.gradle buildscript additions -->

<buildscriptGradleAdditions>  </buildscriptGradleAdditions>

<!-- optional app build.gradle additions -->

<buildGradleAdditions>  </buildGradleAdditions>

<!-- optional additions to generated build.xml before ${sdk.dir}/tools/ant/build.xml import -->

<buildXmlPropertyAdditions> </buildXmlPropertyAdditions>


<!-- optional files or directories to copy or delete from Intermediate/Android/APK before ndk-build -->

<prebuildCopies> </prebuildCopies>

<!-- optional files or directories to copy or delete from Intermediate/Android/APK after ndk-build -->

<resourceCopies> </resourceCopies>

<!-- optional files or directories to copy or delete from Intermediate/Android/APK before Gradle -->

<gradleCopies> </gradleCopies>

<!-- optional properties to add to gradle.properties -->

<gradleProperties> </gradleProperties>


<!-- optional parameters to add to Gradle commandline (prefix with a space or will run into previous parameter(s)) -->

<gradleParameters> </gradleParameters>


  <!-- optional minimum SDK API level required -->

  <minimumSDKAPI> </minimumSDKAPI>

<!-- optional additions to the GameActivity imports in GameActivity.java -->

<gameActivityImportAdditions> </gameActivityImportAdditions>


<!-- optional additions to the GameActivity after imports in GameActivity.java -->

  <gameActivityPostImportAdditions> </gameActivityPostImportAdditions>

  

<!-- optional additions to the GameActivity class in GameActivity.java -->

<gameActivityClassAdditions> </gameActivityOnClassAdditions>

<!-- optional additions to GameActivity onCreate metadata reading in GameActivity.java -->

<gameActivityReadMetadata> </gameActivityReadMetadata>

 

<!-- optional additions to GameActivity onCreate in GameActivity.java -->

<gameActivityOnCreateAdditions> </gameActivityOnCreateAdditions>

<!-- optional additions to GameActivity onDestroy in GameActivity.java -->

<gameActivityOnDestroyAdditions> </gameActivityOnDestroyAdditions>

<!-- optional additions to GameActivity onStart in GameActivity.java -->

<gameActivityOnStartAdditions> </gameActivityOnStartAdditions>

<!-- optional additions to GameActivity onStop in GameActivity.java -->

<gameActivityOnStopAdditions> </gameActivityOnStopAdditions>

<!-- optional additions to GameActivity onPause in GameActivity.java -->

<gameActivityOnPauseAdditions> </gameActivityOnPauseAdditions>

<!-- optional additions to GameActivity onResume in GameActivity.java -->

<gameActivityOnResumeAdditions> </gameActivityOnResumeAdditions>

<!-- optional additions to GameActivity onNewIntent in GameActivity.java -->

<gameActivityOnNewIntentAdditions> </gameActivityOnNewIntentAdditions>

<!-- optional additions to GameActivity onActivityResult in GameActivity.java -->

<gameActivityOnActivityResultAdditions> </gameActivityOnActivityResultAdditions>

<!-- optional libraries to load in GameActivity.java before libUE4.so -->

<soLoadLibrary> </soLoadLibrary>


아래에는 지원되는 전체 노드 목록이 있습니다.


<isArch arch="">

<isDistribution>

<if> => <true> / <false>

<while condition="">

<return/>

<break/>

<continue/>

<log text=""/>

<insert> </insert>

<insertValue value=""/>

<replace find="" with""/>

<copyFile src="" dst=""/>

<copyDir src="" dst=""/>

<loadLibrary name="" failmsg=""/>

<setBool result="" value=""/>

<setBoolEnvVarDefined result="" value=""/>

<setBoolFrom result="" value=""/>

<setBoolFromProperty result="" ini="" section="" property="" default=""/>

<setBoolFromPropertyContains result="" ini="" section="" property="" contains=""/>

<setBoolNot result="" source=""/>

<setBoolAnd result="" arg1="" arg2=""/>

<setBoolOr result="" arg1="" arg2=""/>

<setBoolIsEqual result="" arg1="" arg2=""/>

<setBoolIsLess result="" arg1="" arg2=""/>

<setBoolIsLessEqual result="" arg1="" arg2=""/>

<setBoolIsGreater result="" arg1="" arg2=""/>

<setBoolIsGreaterEqual result="" arg1="" arg2=""/>

<setInt result="" value=""/>

<setIntFrom result="" value=""/>

<setIntFromProperty result="" ini="" section="" property="" default=""/>

<setIntAdd result="" arg1="" arg2=""/>

<setIntSubtract result="" arg1="" arg2=""/>

<setIntMultiply result="" arg1="" arg2=""/>

<setIntDivide result="" arg1="" arg2=""/>

<setIntLength result="" source=""/>

<setIntFindString result="" source="" find=""/>

<setString result="" value=""/>

<setStringFrom result="" value=""/>

<setStringFromEnvVar result="" value=""/>

<setStringFromProperty result="" ini="" section="" property="" default=""/>

<setStringAdd result="" arg1="" arg2=""/>

<setStringSubstring result="" source="" index="" length=""/>

<setStringReplace result="" source="" find="" with=""/>


뭔가 상당히 복잡해 보이는데, 과정에 익숙해지면 플랫폼별 빌드 구성을 하는데 있어서 매우 편리한 도구가 될 수 있을거라는 생각이 드네요.



일단 이 문서는 UPL 이 뭔지 정리하는 데 목적을 두고 있기 때문에 여기에서 마무리할 계획입니다. 다음에는 실제로 UPL 을 사용해 Crashlystics 와 연동하는 작업을 해 볼 계획입니다.

+ Recent posts