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

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

원문 : 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.

+ Recent posts