A trip through the Graphics Pipeline 2011 요약정리와 번역 중간 어디쯤(…) – Part 2

(이하는 A trip through the Graphics Pipeline 2011, part 2 를 번역한 내용임돠)

Part 2 – GPU 메모리 구조와 커맨드 프로세서

Not so fast

이 전 글에서 3D 렌더링 명령들이 GPU 에 도달하기까지 PC에서 거쳐가는 다양한 단계들에 대해 설명했슴다. 한 줄로 요약하자면 ‘님이 생각한거보다 더 복잡함’ 이져. 그리고는 커맨드 프로세서를 소개하고, 그것이 우리가 꼼꼼하게 준비한 커맨드 버퍼와 실질적으로 어떤 관련이 있는지 설명했구여. 음..어케 말해야되지.. 실은 거짓말을 했슴다. 실제로는 커맨드 프로세서를 이 글에서 처음 만나게 될 거예여. 이 커맨드 버퍼 어쩌구 하는 것은 모두 메모리를 통과한다는 사실을 명심하세여. 그 메모리는 PCI 익스프레스 버스를 통해 접근하는 시스템 메모리든, 로컬 비디오 메모리든 어쨌든 메모리임다. 그래픽스 파이프라인을 차례대로 살펴보기로 한 만큼, 커맨드 프로세서에 대해 알아보기 전에 이 메모리에 대해 먼저 잠깐 살펴보도록 하져.

메모리 서브시스템

GPU 는 범용의 CPU 나 다른 하드웨어에서 볼 수 있는 메모리 서브시스템과는 다른 형태의 메모리 서브시스템을 가지고 있슴다. 왜냐면 사용 패턴이 매우 다르기 때문이져. GPU의 메모리 서브시스템은 일반적인 머신에서 볼 수 있는 것과는 근본적으로 두 가지 면에서 차이가 남돠.

첫째로 GPU 의 메모리 서브시스템은 빠릅니다. 엄청 빠르져. Core i7 2600K 의 메모리 대역폭은 19GB/s 정도를 찍을 거예여. 날씨가 좋고 미풍이 불 때 다운힐을 한다면 말이져(…). 한편 GeForce GTX 480의 경우, 총 메모리 대역폭은 180GB/s 에 근접함다. 거의 한 자릿수가 차이나네여? 헐.

두번째는 GPU 메모리 서브시스템은 느립니다. 엄청 느려여. AnandTech에서 제공한 메모리 지연시간 자료에 클럭속도를 곱하면, 네할렘(1세대 Core i7) CPU에서 캐시 미스가 발생한 경우 약 140사이클 정도가 지연되는 걸 알 수 있슴다. 방금 언급한 GeForce GTX 480 의 경우 400에서 800사이클이 지연되져.(주 : 원문의 링크가 깨져서 같은 문서인 걸로 보이는 것을 찾아서 연결했는데, 원래 링크가 살아있을 때 본 적이 없어서 맞는지는 모르겠네여. 내용을 보니 맞는 거 같긴 한데…) 따라서 사이클 단위로 얘기한다면, GeForce GTX 480 은 Core i7 에 비해 4배가 조금 넘는 메모리 지연 시간을 가지고 있다..라고 할 수 있겠네여. 근데 방금 얘기한 Core i7 은 2.93GHz 로 동작하는 한편, GTX 480 의 셰이더 클럭은 1.4GHz 로 동작하니까.. 여기서 다시 2를 더 곱해줘야겠네여. 헐. 이거 또 한 자릿수 차이네여? 뭔가 희한한 일이 벌어지고 있군여. 이거 보아하니.. 뉴스에서 계속 떠들어대던 그 트레이드오프 라는 거 중에 하나구만!

넹 그렇슴다. GPU 는 대역폭을 엄청나게 증가시키는 대가로 엄청난 지연 시간을 지불한 거져(그리고 결과적으로 상당한 수준의 전력을 소비하게 되었지만, 이건 이 글의 범위를 벗어나니까). GPU 는 지연시간보다 처리량을 우선한다, 이게 일반적인 패턴이라는 것임다. 다시 말해 어떤 작업을 했는데 아직 결과가 안 나왔다면, 그걸 기다리고 있을 게 아니라 뭔가 다른 걸 한다! 라는 거져.

이게 바로 GPU 메모리에 대해 알아야 할 거의 대부분임다. 여기에 한 가지 더. DRAM에 대한 일반적인 내용인데, 이게 또 앞으로 계속 중요할거예여. DRAM 은 2D 격자 구조임다. 물리적으로든 논리적으로든 둘 다 그렇져. (가로) 행과 (세로) 열이 있어여. 이러한 행과 열이 교차하는 지점에는 트랜지스터와 커패시터가 있슴다. 여기서 이런 재료를 가지고 실제 메모리를 구성하는 방법이 궁금하다! 그러면 위키백과가 여러분의 친구가 되어줄거예여. 어쨌거나, 여기서 주목해야 할 부분은 DRAM 내부의 위치를 나타내는 주소는 행 주소와 열 주소로 나뉘어진다는 것이져. 그리고 DRAM 은 내부적으로 한 번에 읽기 쓰기를 할 때 항상 주어진 행의 모든 열에 접근을 한다는 것두여. 이것은 같은 분량의 접근이라 해도 DRAM 의 한 줄 내에서 다 이뤄질때가 여러 줄에 나뉘어 접근하는 경우보다 훨씬 싸게 먹힌다는 것을 의미하져. 지금은 이게 DRAM 에 관한 잡다한 정보들 중 하나인것 처럼 보이지만 앞으로 계속 더 중요해질검돠. 명심하셈. 시험에 꼭 나오는 거예여. 이러한 사실을 이 전 문단의 내용과 엮어 얘기해 보자면, 몇몇 바이트씩 메모리의 여기저기서 읽어대는것으로는 위에서 얘기한 메모리 대역폭 최고치에 도달할 수 없다 라는 것이져. 메모리 대역폭을 꽉 채우려면, 한 번 읽을 때 DRAM 한 줄을 완전히 채워서 읽어야 한다 이말임다.

PCIe 호스트 인터페이스

그래픽스 프로그래머 입장에서 하드웨어의 이 부분은 그닥 흥미롭지가 않슴다. 사실 GPU 하드웨어 구조도 마찬가지져. 하집만 중요한 건 얘가 너무 느려서 정체구간이 되기 때문에 언젠가 신경을 쓸 수 밖엔 없다는  사실이져. 따라서 님이 해야만 하는 건 이걸 잘 처리할 수 있는 능력자들을 구해서 이런 일이 일어나지 않게 하는거져(…). 그렇지 않다면 CPU 가 비디오 메모리나 여러 GPU 레지스터를 를 읽고 쓰는 일이라던가 GPU 가 메인 메모리(의 일부)를 읽고 쓰는 일이 이 PCIe 호스트 인터페이스를 통해서 일어나야 하는데, 이게 엄청 느려서 모두가 골치가 아플 거란 말이져. 얘가 느리기로는 메모리 지연보다도 더 심한데, 그게 신호가 칩에서 일단 나온 담에 슬롯으로 갔다가 메인보드 일부를 거쳐서 CPU어딘가에 도착하는데 한 1주일은 걸린단 말이져(아님 뭐 CPU/GPU 속도에 비해서 그만큼 느리게 느껴진다..라는 것이거나). 대역폭 자체는 그렇게 나쁘지 않슴다. 요새 GPU 대부분의 총 대역폭은 최대 8GB/s 로, 이는 CPU 총 대역폭의 절반에서 삼분의 일 정도로 쓸만한 수준임다.  그리고 옛날 표준이었던 AGP 가 CPU 에서 GPU 로의 채널은 고속인데 비해 반대 방향은 그렇지 못했던 것과 달리, PCIe 는 CPU와 GPU 간에 대칭형 연결 구조로 동작하져.

Some final memory bits and pieces

이제 3D 커맨드를 볼 수 있는 곳까지 정말 아주 가까이 왔슴다. 너무 가까이 와서 거의 맛을 볼 수 있을 정도져(주 : 이양반 드립 보소…). 하지만 여길 벗어나서 3D  커맨드로 가기위해서는 선택을 내려야 하는 것이 한 가지 더 있슴다. 바로 메모리에 대한 것인데, 메모리에는 (로컬) 비디오 메모리와 매핑된 시스템 메모리 두 종류가 있기 때문이져. 하나는 북쪽을 향해 하루 정도 가야 하고, 하나는 PCI Express 고속도로를 타고 1주일 정도 남쪽으로 가야 함돠(주 : 노스브리지와 사우스브리지 칩셋에 대한 비유인듯). 어느 쪽을 택해야할까여?

쉬운 해결책은 어디로 갈 지 알려주는 별도의 어드레스 라인을 추가해주는것임다. 간단하고 잘 동작하면서 많이 사용되었던 방법이져. 아니면 몇몇 콘솔에서처럼(하지만 PC는 해당사항이 없는) 단일 메모리 구조(Unified Memory Architecture) 로 가는 방법도 있슴다. 이 경우에는 ‘어느 쪽’으로 가느냐의 선택지가 없슴다. 그냥 ‘메모리’져. 뭔가 좀 더 근사한 걸 원한다면 MMU(메모리 관리 유닛, Memory Management Unit)을 추가하는 방법이 있슴다. 이렇게 하면 주소 공간을 완전히 가상화해서 빈번하게 접근이 일어나는 텍스처 부분들은 비디오 메모리(얘네는 빠르져)에 넣어두고, 다른 부분들은 시스템 메모리에, 그리고 나머지 대부분은 메모리에 아예 매핑하지도 않고 갑자기 짠 하고 나타나게 한다던가(주 : …) 아니면 좀 더 흔하게는 디스크에서 읽어들이는 마법을 부리게 할 수 있슴다. 근데 이 디스크에서 읽어들이는 게 50년 쯤 걸리는게 문제져. “메모리 접근 = 하루” 라는 비유를 기준으로 하면 HD(주 : 하드디스크인듯)에서 읽어들이는 건 정말로 이 정도 걸림돠. 제법 빠른 하드일 경우에여. 엿같져. 근데 옆길로 좀 샜네여.

그럼 MMU를 보도록 하져. 얘는 비디오메모리가 거의 다 썼을  때 실제로 데이터를 카피한다던가 하는 작업 없이도 비디오 메모리 주소 공간의 단편화를 제거할 수 있게 해 줌다. 짱이네염. 그리고 여러 프로세스가 하나의 GPU를 공유하는것을 훨씬 쉽게 만들어주기도 하구여. 이걸 하나 이용할 수 있긴 한데, 반드시 있어야한다고 규정되어있는지는 확실치가 않네여. 있으면 확실히 좋은 건 분명한데 말이져 (이 부분에 대해서 누구 좀 도와주실 분? 여기에 대해서 확실히 알게 되면 이 글을 업데이트할텐데, 지금은 솔직히 이걸 뒤져 볼 기분이 아니라서염(…)). 어쨌든, MMU/가상메모리는 캐시와 메모리 일관성consistency을 고려하지 않는 구조에서라고 해도 간단히 갖다 붙일 수 있을 만한 것이라고 할 수는 없슴다. 그런데 어떤 특정한 단계stage에 속한다고 하기도 어려운데, 어쨌든 어딘가에서 반드시 언급을 해야하긴 하기에 여기다 집어넣었슴다.

DMA엔진도 있슴다. 비싼 3D 하드웨어/셰이더 코어을 끌어들이지 않고 메모리를 이리저리 복사할 수 있져. 보통은 적어도 시스템 메모리와 비디오 메모리 간의 (양방향) 복사를 처리함다.  종종 비디오 메모리에서 비디오 메모리로 복사가 가능하기도 하져(VRAM 단편화 제거를 해야 할 때 유용하겠져). 보통 시스템 메모리에서 시스템 메모리로 복사는 처리할 수 없슴다. 메모리 복사 유닛이 아니라 GPU이기 때문이져. 시스템 메모리간 복사는 PCIe 버스를 두 번이나 통과할 필요가 없는 CPU에서 처리해야져.

업데이트 : 그림을 하나 그렸슴다. (그림을 집어넣기엔 레이아웃이 너무 좁아서 링크로 처리함다.) (…라고 저자가 썼으나 그냥 바로 그림 집어넣음ㅇㅇ 여긴 공간이 될거 같아서염 : 주)

이 그림에서 몇 가지 세부사항을 더 확인할 수 있슴다. GPU에는 여러 개의 메모리 컨트롤러가 달려 있고, 각각의 컨트롤러에는 여러 개의 메모리 뱅크를 제어하져. 그리고 이들의 앞에는 대역폭이 얼마나 필요하든 처리할 수 있을만큼 충분한 메모리 허브가 있슴다.

여기서 정리를 한 번 해 보져. CPU 단에서는 커맨드 버퍼가 준비되었슴다. 그리고 PCIe 호스트 인터페이스가 있는데, 이걸 통해서 CPU는 커맨드 버퍼에 대해 알려주고 그 주소를 레지스터에 쓸 수 있슴다. 그리고 그 주소로부터 데이터를 가져오는 로직이 있는데, 이는 다음과 같이 동작하져: 비디오 메모리에 커맨드 버퍼가 있어서 시스템 메모리로부터 (커맨드 버퍼 데이터가) PCIe 를 통해 전송되어야 할 경우, KMD가 DMA 전송을 준비해서 CPU든 GPU의 셰이더 코어든 어느 쪽도 계속해서 신경 써야 할 필요가 없게 해 줌다. 그러면 비디오 메모리에 메모리 서브시스템을 통해 접근할 수 있는 사본이 마련되져. 모든 경로가 파악되었으니, 바야흐로 몇몇 커맨드에 대해 알아볼 준비가 완료되었군여!

마침내 바로 그 커맨드 프로세서!

커맨드 프로세서에 대한 얘기는 요즈음 많이 볼 수 있는 다음 단어로 시작함다.

“버퍼링…”

앞서 언급한 바와 같이, 커맨드 프로세서까지의 메모리 경로 양쪽 모두 대역폭이 넓지만 동시에 지연시간 또한 큼다. 최근의 GPU 파이프라인에 있어서 이러한 문제를 우회하는 방법은 다수의 독립적 스레드를 돌리는 것이져. 하지만 이 경우에도, 커맨드 프로세서는 단 하나이고, 이 커맨드 프로세서는 커맨드 버퍼를 순서에 맞게 처리할 필요가 있슴다(왜냐면 이 커맨드 버퍼에는 상태 변경이라던가 올바른 순서대로 처리되어야 하는 렌더링 커맨드들이 들어 있기 때문이져). 그래서 차선택을 수행함다. 충분히 큰 버퍼를 마련해서 hiccup을 피할 수 있을만큼 많은 양의 커맨드들을 미리 가져다 두는 거져.

이 버퍼에서 커맨드들은 실제 커맨드 처리 프론트엔드로 보내어지는데, 이 프론트엔드는 기본적으로 어떻게 (하드웨어 특정적인 형태의) 명령들을 파싱해야하는지 아는 상태기계져. 몇몇 명령은 2D 렌더링과 관련이 있는데, 이는 별도의 2D 커맨드 프로세서가 없을 경우임다. 별도의 2D 커맨드 프로세서가 있으면 3D 프론트엔드는 이런 명령들을 볼 수 조차 없져. 어느 쪽이든 요즘 GPU들에는 여전히 2D 전용 하드웨어가 감춰져 있슴다. 다이 어딘가에 텍스트모드라던가 픽셀당 4비트 비트플레인 모드, 부드러운 스크롤 등을 처리하는 VGA 칩이 존재하는 것과 마찬가지로 말이져. 현미경 없이 다이에서 이런 것들을 찾아내는 데에 행운이 함께하기를. 어쨌든, 이러한 요소들이 존재하지만 앞으로 얘네들에 대해 언급하지는 않을거예여. 그리고 프리미티브들을 3D/셰이더 파이프로 넘겨주는 명령들이 있슴다. 유후~! 얘네는 다음 파트에서 다룰거예여. 3D/셰이더 파이프로 가는 또다른 명령들 중에는 여러 가지 이유로 인해(그리고 여러 파이프라인 설정하에서) 아무 것도 그리지 않는 명령도 있슴다. 얘네는 더 나중에 다룰게여.

그리고 상태를 변경하는 명령들이 있져. 프로그래머라면 얘네는 단순히 어떤 변수 값을 바꾸는 거라고 생각할 거예여. 그리고 기본적으로 그렇기도 하져. 하지만 GPU 는 대규모 병렬 컴퓨터massively parallel computer 이기에, 이러한 병렬 시스템에서 전역변수의 값을 그냥 바꾸고 아무 일 없기를 기대할 수는 없어여. 어떠한 강제된 불변식invariant에 의해 모든 것이 제대로 돌아갈 것이라는 보장이 있지 않는 한, 어딘가 버그가 있을 테고 언젠가는 그게 터질테니까여. 잘 알려진 여러 방법들이 존재하는데, 기본적으로 모든 칩들은 각각의 상태들에 대해 각기 다른 방법들을 사용함다.

  •  상태를 바꿀 때 마다, 그 상태값을 참조하는 모든 작업들은 완료되어야 함다(기본적으로 파이프라인의 일부를 비우는flush 것이져). 역사적으로 이 방법이 그래픽 칩들이 대부분의 상태 변경을 처리하는 방식임다 – 간단하고 비싸지 않져. 작업batch의 갯수가 적고, 삼각형 수도 얼마 안 되고 파이프라인도 짧다면 말이져. 그런데 맙소사, 작업과 삼각형 갯수가 늘어나고 파이프라인도 길어지면서, 이런 식의 접근에 드는 비용이 엄청 치고 올라가버렸네여. 물론 다음과 같은 경우에는 여전히 이런 방식이 사용되고 있슴다 : 자주 바뀌지 않는 상태거나(수십 번 정도의 부분적인 파이프라인 비우기flush 는 한 프레임 전체에 대해서는 그닥 큰 몫을 차지하지는 않져), 특정 방식으로의 구현 결과물이 매우 고비용이거나 구현하기 까다로운 상태일 경우져.
  • 하드웨어를 완전히 상태 없이stateless 구현할 수도 있슴다. 상태 변경 명령을 해당 변경을 처리해야 하는 단계까지 내려보낸 다음, 그 단계의 이후 단계로 보내는 모든 것에 현재 상태값을 덧붙여서 매 싸이클마다 보내면 되져. 상태값은 어디에도 저장되지 않되, 어디에나 함께 하게 되는것임다. 그렇게 하면 파이프라인상의 특정 스테이지에서 어떤 상태가 무슨 값인지 필요할때마다 읽어올 수 있게 되져. 왜냐면 그 상태값은 그 단계까지 넘겨져왔기 때문임다(그리고 그 상태값은 다시 그 다음 단계로 넘겨지게 되져). 만약 상태값을 저장하는 데 몇 비트만 필요하다면, 이 방법은 제법 저렴하고 실용적임다. 그렇지만 만약 그게 샘플 상태를 포함한 사용중인 텍스쳐 전체에 대한 상태값이라고 하면, 별로 효율적이지 못할거예여.
  • 상태값을 한 벌만 저장해두고 사용할 경우 렌더링 파이프라인의 어느 단계에서 상태값을 바꿀 때마다 파이프라인을 비우느라 내보내는 데이터 양이 엄청 많을 수 있슴다. 이럴 때엔 상태값 두 벌(혹은 네 벌)을 이용하게 하면 상태를 설정하는 앞단을 좀 더 앞서나가게 할 수 있져. 각 상태들에 대한 값을 두 벌 저장하기에 충분한 레지스터(슬롯)이 있다고 가정하고, 현재 처리중인 작업이 0번 슬롯을 참조하고 있다고 해 보져. 1번 슬롯의 상태값을 변경하는 것은 (0번 슬롯을 참조하며) 처리중인 그 작업을 멈추거나 영향을 주거나 하는 것 전혀 없이 처리가 가능함다. 이제 파이프라인을 따라 상태값 전체를 보낼 필요 없이, 명령당 0번과 1번 중 어느 슬롯인지를 선택하는데 필요한 1비트만 있으면 됨다. 물론, 슬롯 0번과 1번이 모두 사용중일 때 상태 변경 명령을 만나게 되면 대기해야 할 수 밖에 없겠져. 하지만 어쨌든 조금은 나아졌슴다. 이 기법은 슬롯이 두 개 보다 많을 경우에도 잘 동작함다.
  • 샘플러나 텍스처 Shader Resource View 상태의 경우, 한 번에 엄청나게 많은 수의 상태값을 설정할 수 있지만 실제로 그렇게 사용하는 경우는 잘 없슴다. 그래서 동시에 2벌의 상태값 세트를 운용하는 경우라고 해도, 이를 위해 2*128개의 텍스처 상태값을 저장하기 위한 공간을 예약해두지는 않져. 이 경우엔 일종의 ‘레지스터 이름 변경 기법’을 사용함다. 128개의 텍스처 디스크립터를 가지고 있는 풀pool을 이용하는 것이져. 만약 어떤 셰이더가 한번에 128개의 텍스처를 동시에 설정하려 한다면, 상태값 변경은 매우 오랜 시간이 걸리게 되겠져(주 : 텍스처 값 설정을 위해 결국 모든 물리적 텍스처 디스크립터들이 비워질때까지 이를 참조하는 이전 작업들이 완료되는 것을 기다려야 하기 때문). 그렇지만 보통은 한 번에 20개 미만의 텍스처를 사용할테고, 이 정도 갯수라면 이들을 여러 벌 운용할 만큼 충분한 공간이 되는 것임다.

위 항목들은 상태변경에 있어 모든 것을 포괄하는 것은 아님다. 하지만 요지는 애플리케이션단(혹은 이 문제에 있어서는 UMD나 KMD, 혹은 명령어 버퍼도)에 있어 변수의 값을 변경하는 것과 같이 간단해 보이는 것도, 처리를 하는 데 속도저하가 일어나지 않게 하기 위해서는 무시하지 못할 만큼 많은 분량의 하드웨어 지원이 필요할 수 있다는 것임다.

동기화

마침내 마지막 항목인 CPU/GPU 와 GPU/GPU 간의 동기화 처리 명령어군이네여.

일반적으로 이들은 모두 “이벤트 X가 발생하면 Y를 해라’의 형식임다. “Y를 해라”부분부터 먼저 다뤄볼게여. 여기엔 두 가지 적합한 선택지가 있슴다. 하나는 푸시 모델 통지push-model notification이예여. GPU가 CPU에게 무언가를 당장하라고 소리치는거져(“이봐 CPU! 지금 0번 디스플레이가 수직 귀선 기간vertical blanking interval에 들어가니까 티어링tearing 없이 버퍼를 바꾸려면 지금 해야할걸!”). 다른 하나는 풀 모델pull-model로, GPU가 뭔가 일어난 일들에 대해 기억하고 있고 CPU가 나중에 그걸 물어보는 것이져(“이봐 GPU, 가장 최근에 처리를 시작한 명령 버퍼 조각이 어느거지?” – “어디보자… id 303이네.”). 전자는 보통 인터럽트를 이용해 구현되고 자주 발생하지 않지만 우선순위가 높은 이벤트를 처리하는 쓰임다. 왜냐면 인터럽트가 꽤나 비싸거든여. 후자에 대해 알아야 하는 것은 CPU가 접근 가능한 GPU레지스터와, 어떤 이벤트가 발생했을 때 명령 버퍼로부터 이 레지스터들에 값을 쓰는 것이 어떻게 이뤄지는가 임다.

이러한 레지스터 16개가 있다고 가정하져. 레지스터 0번을 currentCommandBufferSeqId 라고 이름붙이져. GPU에 접수시키는 모든 커맨드 버퍼에 일련 번호를 붙이고(이 작업은 KMD에서 이뤄지져), 각 커맨드 버퍼의 처리를 시작할 때 “커맨드 버퍼 처리가 이 곳까지 도달했을 때, 레지스터 0에 값을 쓴다”는 동작을 추가함다. 그렇게 되면 오호라, 이제 GPU가 어느 커맨드 버퍼를 처리하는지 알 수 있게 되었네여! 그리고 커맨드 프로세서는 명령을 순서대로 엄격하게 처리하니까, 만약 id 303 번 커맨드 버퍼의 첫 번째 명령이 수행되었다면 id 302번을 포함해 그 앞의 커맨드 버퍼들은 모두 처리가 완료되었기에 KMD에 반환되어 할당을 해지하거나, 내용을 수정하거나, 싸구려 놀이공원으로 변경할 수 있다는 것이져.

이제 X가 무엇이 될 수 있는지 예를 들어보겠슴다. “여기까지 왔다면”이 그 중 하나가 될 수 있겠네여. 가장 간단하면서도 매우 유용한 예임다. 또다른 예는 “커맨드 버퍼 이 지점 이전에 오는 모든 셰이더들이 텍스처로부터 읽기를 완료했다면”이 될 수 있겠네여(이는 텍스처/렌더 타겟 메모리를 안전하게 반환할 수 있는 지점을 나타내져). “만약 활성화된 모든 렌더 타겟/UAV로의 렌더링이 완료되었다면” 도 있져(이는 해당 렌더타겟/UAV를 안전하게 텍스처로 이용할 수 있는 지점을 나타냄다). “만약 이 지점까지의 모든 작업이 완료되었다면” 이라던가, 그 외 여러가지가 있을 수 있슴다.

이러한 것들은 종종 담장fence으로 불리기도 하져. 상태 레지스터에 어떤 값들을 써넣을지 선택하는 데는 여러가지 방법이 있지만, 필자가 바람직하다고 보는 것은 순차적으로 증가하는 값을 사용하는 것임다(몇몇 비트는 다른 정보를 저장하는 데 쓸 수 있긴 하지만여). 넵, 지금 제가 어떤 합리적인 근거 없이 어떤 정보 한 조각을 덜렁 던진 것이긴 한데.. 왜 그랬는지 아마 님도 아실듯여. 여기에 대해선 나중에 별도의 포스팅으로 자세히 설명해볼까 함돠(이 시리즈는 아니지만여).

자 여기까지 절반은 왔슴다. 이제 GPU에서 CPU로 상태를 알려줘서 드라이버단에서 제대로 메모리 관리를 할 수 있게 되었네여(특히, 버텍스 버퍼나 명령 버퍼, 텍스처나 기타 리소스에 사용된 메모리를 언제 안전하게 반환할 수 있는지 알수 있게 되었져). 하지만 그게 전부가 아님돠. 퍼즐 한 조각이 빠져있져. 예를 들어, 만약 온전히 GPU단에서만 동기화를 해야 할 경우 어떻게 해야 할까여? 렌더 타겟의 예로 돌아가보져. 렌더타겟은 거기에 렌더링하는 작업이 끝나기 전에는 텍스처로 사용할 수 없슴다(그리고 몇 단계를 더 거쳐야 하는데, 이건 나중에 텍스처링 유닛을 다룰 때 상세히 설명할게여). 해법은 “대기”스타일의 명령어임다: “레지스터 M에 N이라는 값을 담길 때 까지 대기”같은 형태인거져. 값을 비교하는 연산은 “같거나” 가 되거나 “미만이거나”도 될 수 있슴다(이 경우 랩어라운드wraparound 도 고려하여 처리해야함을 유의하세여). 혹은 좀 더 복잡한 식이 될 수도 있는데, 간단히 설명하기 위해 “같거나”의 예를 든 것임다. 이 명령어가 있어 배치batch 를 접수시키기 전에 렌더타겟의 동기화가 가능해지져. 또한 GPU를 완전히 비우는flush 처리도 가능하게 해 줌다: “모든 대기중인 작업이 처리 완료되면 레지스터0번에 ++seqid 값을 기록” 이나 “레지스터0 의 값이 seqId 가 될 때 까지 대기” 같은 형태로 말이져. 좋아여 좋아. GPU/GPU 동기화가 문제가 해결되었네여. 그리고 좀 더 정교한 동기화 형태를 포함하는 Compute Shader 가 탑재된 DX11 이전까지, 이러한 방식은 GPU단 동기화를 처리하는 유일한 방법이었져. 일반적인 렌더링이라면 더 이상은 필요없져.

한편, 이러한 레지스터에 값을 쓰는 게 CPU단에서도 가능하게 되면, 이걸 식으로도 활용 가능함다. 특정 값이 될 때 까지 대기하는 명령을 포함하는 명령 버퍼의 일부를 접수시키고, GPU단이 아닌 CPU단에서 레지스터의 값을 변경하는 것이져. 이러한 기법은 D3D11 에서 지원되는, CPU단에서 락되어있는 버텍스/인덱스 버퍼(아마도 이는 다른 스레드에서 값을 쓰기 위해 락되어있겠져)를 참조하는 배치를 접수하는 식의 멀티스레드 렌더링을 구현하는 데 사용할 수 있슴다. 간단히 실제 렌더 콜 앞에 대기 명령을 채워넣고, 버텍스/인덱스 버퍼가 실제로 언락되었을 때 레지스터에 값을 쓰게 할 수도 있슴다. GPU가 해당 명령을 처리하기 전까지, 그 대기 명령은 단순한 no-op(주:아무 것도 하지 않는 명령)이져. 만약 그 명령을 처리하게 되면(주:그리고 그 때까지 버텍스/인덱스버퍼가 준비 완료되어 언락되지 않았다면), (커맨드 프로세서는) 시간을 보내며 데이터가 마련될 때까지 기다리게 되겠져. 근사하지 않나여? 사실 이러한 방식을 구현하는 건 CPU에서 쓰기 가능한 레지스터 없이도 구현 가능함다. “점프”명령을 통해 명령 버퍼를 접수시킨 후에도 수정 가능하다면 말이져. 자세한 사항은 관심있는 독자들을 위한 몫으로 남겨둘게여.

물론, 레지스터에 값을 쓰고 대기하는 모델이 아닌 다른 방식으로 구현도 가능함다. GPU/GPU 단 동기화를 처리하는데는 렌더타겟이 안전하게 사용할 수 있음을 보장하는 “렌더타겟 배리어rendertarget barrier” 명령과, “전부 비우기” 명령이 있으면 되져. 하지만 필자는 레지스터 형태의 모델을 더 선호함다. “사용중인 리소스의 CPU로의 역보고back-reporting” 와 “GPU단 내부에서의 동기화” 두 가지 몹을 잘 설계된 무기 하나로 일타쌍피할 수 있기 때문이져.

업데이트: 그림을 하나 그렸슴다. (주: 원 포스팅에서는 링크로 처리했지만 이번에도 그냥 그림을 바로 갖다박았슴다.)

좀 복잡해서 나중에 디테일을 좀 줄여 간단하게 만들긴 할텐데, 어쨌든 핵심은 다음과 같슴다: 커맨드 프로세서는 FIFO 가 선두에 있고, 그 담에 명령어 해석 로직이 있구여, 명령어 실행은 2D유닛, 3D 프론트엔드(일반적인 3D렌더링), 셰이더 유닛(컴퓨트 셰이더 처리)등과 같은 여러 블록이 서로 직접 통신하면서 처리됨다. 그리고 동기화/대기 명령을 처리하는 블럭이 있구여(얘네는 언급했던 외부 접근 가능한 레지스터를 가지고 있져), 명령 버퍼 점프/호출을 처리하는 유닛이 있슴다(얘가 FIFO로 가는 명령어를 가져올 주소를 변경하는 처리를 하져). 작업 처리를 요청받은 모든 유닛은 처리가 끝나면 완료 이벤트를 돌려줌다. 그러면 이를 통해 이를테면 어떤 텍스처가 더 이상 사용되지 않으면 거기에 할당된 메모리를 반환한다던가 하는 처리가 가능해지져.

닫는 글

다음으로는 실질적인 렌더링 작업을 처음으로 다루게 됨다. GPU를 다루는 시리즈 3번째 파트에서 마침내 실제 버텍스 데이터에 데이터에 대해 파보게 되는 것이져! (아직 삼각형을 그리는rasterize 건 아녜여. 요건 좀 더 있다가.)

사실 이미 이 단계에서 분기가 벌써 등장함다. 만약 컴퓨터 셰이더를 사용한다면, 다음 단계는… 컴퓨트 셰이더를 실행하는 것이져. 하지만 그러지 않을 거예여. 컴퓨트 셰이더는 좀 더 나중에 다루게 될 거예여. 일반적인 렌더링이 먼저져.

소소한 면책조항 하나: 다시 한 번 말하지만, 이 시리즈는 어떤 큰 그림을 그리면서, 필요한(혹은 재밌을만한) 부분을 상세히 들어다보는 형태로 진행됨다. 하지만 편의상(그리고 쉬운 이해를 위해) 생략된 부분이 많이 있다는 거, 하지만 정말 중요한 것은 빼먹지 않았다는 걸 믿어주십셔. 물론, 틀린 부분이 있을 수 있슴다. 만약 버그를 발견했다면 꼭 알려주세염!

그럼 다음 글에서 만나요~

A trip through the Graphics Pipeline 2011 요약정리와 번역 중간 어디쯤(…) – Part 1

(이하는 A trip through the Graphics Pipeline 2011, part 1 를 날림번역한 것임다.)


Part 1 – 소프트웨어 계층

 

응용 프로그램

니가 짠 코드ㅇㅇ. 니가 싼 버그(…)도 포함. (주:D3D나 OpenGL API 를 호출하는 코드가 포함된 프로그램을 얘기한다고 보면 될듯)

API 런타임

API 런타임은 다음과 같은 요청들을 니가 짠 코드로부터 받게 된다.

  • 리소스 생성
  • 스테이트 설정
  • 드로우콜 등

API 런타임은 넘겨받은 요청에 다음과 같은 처리를 한 뒤 작업들을 묶어서(batch) 유저 모드 드라이버(UMD)에게 넘겨준다. UMD 에 대한 설명은 바로 다음 섹션임.

  • 설정된 스테이트를 유지, 관리
  • 파라미터 유효성이나 그 외 에러나 일관성 검사 수행
  • 유저가 접근 가능한 리소스 관리
  • 셰이더 유효성 검사나 링키지(linkage)처리(D3D 는 이 단계에서 처리하지만, OpenGL 은 드라이버단에서 처리함)
  • 그 외 몇가지 작업들

유저 모드 그래픽 드라이버(user mode graphics driver, UMD)

CPU단의 마법들이 일어나는 곳. 니가 짠 코드가 호출한 API 에서 크래시가 발생할 때 운영체제가 “여기서 디졌어염 뿌우”하고 일러바치는 곳. 엔비디아는 nvd3dum.dll, AMD는 atiumd*.dll 뭐 그런 거.

유저모드라는 이름에서 알 수 있듯 응용 프로그램 코드와 같은 컨텍스트, 주소공간에서 돌아가고 권한 상승도 없다.

D3D가 호출하는 저수준 API(DDI)를 구현한 곳. DDI는 D3D API 와 유사한 형태인데, 메모리 관리와 같은 부분이 좀 더 명시적으로 처리되는 등의 차이가 있음.

(주 : 이 부분 부터는 순서도 좀 다르게 편집했고 의역도 엄청 들어감미다..)

셰이더 컴파일 과정의 일부가 처리되는 곳. 물론 셰이더는 HLSL컴파일러나 D3D API 런타임에서 처리되는 부분도 있기는 한데, 이러한 과정을 거친 것은 shader token stream 이며 UMD는 이를 받아 하드웨어와 밀접한 저수준의 컴파일단계를 처리한다.

좀 더 상세히 설명하자면, UMD 이전 단계에서의 셰이더 코드는 다음과 같은 검사를 거치게 된다.

  • 문법적으로 올바른지
  • D3D 제한사항들을 올바르게 지키고 있는지. 제한사항은 다음과 같다.
    • 올바른 타입을 사용하였는가
    • 텍스처/샘플 갯수 제한을 넘지 않았는가
    • 상수 버퍼 갯수 제한을 넘지 않았는가 등.

또한 여러 고수준 최적화가 적용된다. 이는 다음과 같다.

  • 다양한 루프 최적화
  • 사용하지 않는 코드 제거
  • constant propagation (주 : 어떤 수식이 컴파일타임에 상수로 계산될 수 있으면 그 식을 상수로 치환하는 것)
  • 분기 예측 등

UMD는 이러한 비싼 최적화들이 적용된 결과물을 넘겨받기 때문에 그만큼 부담을 덜게 된다.

UMD에서 수행되는 여러가지 저수준 최적화도 있는데, 이는 다음과 같다.

  • 레지스터 할당
  • loop unrolling (주 : 루프를 반복된 코드로 확장하는 것) 등

간단히 얘기하자면, 중간코드(Intermediate Representation, IR) 로 변환된 것을 뭔가 조금 더 컴파일해주는 정도라고 할 수 있는데, 이는 (저수준)컴파일러가 뭔가 좋은 결과를 내기 위해 엄청난 일을 할 필요가 없을만큼 D3D 바이트코드가 셰이더 하드웨어에 충분히 가깝기 때문이다. 하지만 그럼에도 불구하고 D3D가 알 필요도 없고 신경쓰지도 않는 하드웨어와 관련된 세부사항들이 있기에, 이 단계는 결코 사소한 단계라고 볼 수 없다. 이러한 세부사항들은 다음과 같다.

  • 하드웨어 자원 제한
  • 스케줄링 제약

그리고 유명한 게임들에 대해서는 엔비댜와 암드의 프로그래머들이 그 셰이더를 디벼보고 직접 새로 최적화해 작성한 셰이더가 대신 돌아가게 하는 경우가 있는데, 그러한 해당 게임의 검출과 셰이더 교체 또한 이 UMD 단에서 이뤄진다.

그리고 또한 몇몇 API state 들은 최종적으로 셰이더에 컴파일되어 적용된다. 예를 들어 드물게 사용되는 기능인 텍스처 경계 상태(texture border state)는 샘플러 코드에 구현되는 것이 아니라 셰이더에 추가 코드로서 에뮬레이션된다.(혹은 쌩까고 아예 동작하지 않도록 무시되기도 함). 이 말인 즉슨 한 셰이더가 대해 각각의 state 조합들이 적용된 여러 버전의 셰이더들이 존재할 수 있다는 얘기임.

덧붙여 말하자면 이게 바로 어떠한 셰이더나 리소스를 새로이 사용할 때에 지연이 발생하는 이유임ㅇㅇ. 왜냐면 수많은 생성/컴파일 작업이 드라이버에 의해 가능한한 늦춰지거든. 언제까지? 걔가 필요할 때 까지. 그래픽스 프로그래머들은 이 이야기를 다른 방향으로 접해서 알고 있는데, 그건 바로 뭔가가 그저 메모리만 공간만 확보받은 채로 있는 게 아니라 확실하게 생성되었음을 보장받으려면 그 뭔가를 사용하는 더미 드로우콜을 날려주는 것으로 “워밍업”을 하라는 것이다. 지저분하고 번거롭지만 이건 글쓴이가 1999년에 3D 하드웨어를 처음 시작했을 때 부터 주욱 그래왔으니 어쩌겠음? 그냥 익숙해지셈.

UMD는 또한 D3D9 “legacy” 셰이더들과 fixed pipeline 들도 처리한다. 물론 그들은 새로운 버전의 셰이더로 변환되어 사용된다. (그리고 그렇게 처리한지도 좀 되었음)

텍스처 생성 명령 처리 같은 메모리 관리도 UMD 에서 처리된다. 실질적으로는 KMD(kernel mode driver) 로 부터 얻은 커다란 메모리 영역을 다시 작은 영역으로 나눠 할당하는 것이다. 비디오 메모리의 어느 영역을 UMD가 볼 수 있는지, 그리고 시스템 메모리의 어느 영역에 GPU 가 접근할 수 있는지 매핑하는 것은 KMD의 권한이므로 UMD가 처리할 수 없다.

UMD는 텍스처의 swizzling 처리(주:원문 글쓴이의 다른 글로 링크함. shader 에서 쓰는 swizzling 과는 다른 개념. 텍스처 처리시 캐시 히트율을 높이기 위해 연속된 공간에 배치되는 픽셀의 순서를 바꾸어 저장하는 것을 말함..인데 언젠가는 원문도 각잡고 번역하든 요약정리를 하든 할 것임. 일단 그 전에는 원문 링크로…아. 이런 건 각주 시스템이 있으면 각주로 다는 게 좋을 거 같네ㅋ)를 하고 시스템 메모리와 (매핑된) 비디오 메모리간의 전송을 스케줄링하는 것과 같은 일을 처리하기도 한다. 물론 swizzling 의 경우 GPU 가 3D 파이프라인이 아닌 2D blitting 유닛을 통해 처리할 수도 있는데, 이렇게 할 수 없는 경우 UMD 에 의해서 CPU단에서 처리되는 것이지만. 가장 중요한 사실은 UMD 가 커맨드 버퍼(혹은 DMA 버퍼. 원문 글쓴이께서 커맨드버퍼와 DMA버퍼라는 단어를 섞어서 쓰시겠답니다ㅋ)의 내용을 작성한다는 것인데, 이 커맨드 버퍼는 KMD가 할당한 것이다. API런타임을 통해 요청한 상태 전환, 그리기 작업 등은 UMD에 의해 하드웨어가 알아먹는 커맨드로 변환된다. 텍스처나 셰이더를 비디오 메모리로 업로드하는 작업 등과 같은 많은 작업들이 이러한 변환에 의해 알아서 호출되게 된다.

UMD는 유저모드 코드이기에, 일반적으로 드라이버는 UMD로 최대한 많은 처리 요청을 밀어넣으려 할 것이다. UMD에 의해 처리되는 작업들은 비싼 커널모드 전환이 필요치 않고, 자유롭게 메모리를 할당할 수 있으며, 작업들을 멀티스레드로 돌릴 수 있는 등 여러 장점이 있는데, 이는 UMD가 보통의 DLL로 만들어져 있기 때문이다. (물론 애플리케이션에 의해 바로 로딩되는 것은 아니고 API에 의해 로딩되는 것이긴 하지만.) 드라이버 개발도중 만약 UMD가 크래시하면 이를 사용하는 앱 역시 크래시하지만 시스템은 크래시하지 않는다. 또한 시스템이 돌아가는 동안 간단히 교체할 수 있으며(왜냐면 그냥 DLL이니까!), 일반적인 디버거로 디버깅할 수 있는 등의 장점이 있다. 편리하고 효율적이져.

“유저 모드 드라이버” 가 아니라 “유저모드 드라이버들”

UMD는 이미 말했던바와 같이 단지 DLL이다. D3D의 가호를 받고 KMD에 곧바로 연결되어있기는 해도, 여전히 보통의 DLL이다. 그래서 호출한 프로세스와 같은 주소공간에서 돌아간다.

하지만 요즘의 OS들은 멀티태스킹이다. 그리고 사실 그런지 이미 좀 되었지.

계속해서 얘기하고 있는 이 “GPU”라는 물건은 공유 자원이다. 메인 디스플레이를 구동하는 GPU는 단 하나만 존재한다(SLI 나 크로스파이어 구성을 했더라도 말이지). (주 : 이러한 하나 뿐인 GPU를 멀티태스킹 OS에서 공유하여 쓸 수 있게 만드는 것은) 자동으로 되지 않는다. 옛날에는 3D를 사용하는 애플리케이션 하나가 활성화되면 다른 모든 애플리케이션들은 접근을 하지 못하게 하는 식으로 처리했다. 하지만 윈도우 시스템을 렌더링하는 데 GPU를 쓴다면 그렇게 할 수는 없다. 이것이 바로 GPU로의 접근을 중재하여 시분할로 처리해주는 같은 작업들을 행하는 구성요소가 필요한 이유이다.

스케줄러

스케줄러는 시스템 구성요소이다. 하지만 여기서 얘기하는 것은 CPU스케줄러나 IO스케줄러에 대해서가 아니라 그래픽스 스케줄러임을 명심하자. 스케줄러가 행하는 것은 니가 생각하는 바로 그것이다. 3D 파이프라인을 사용하려는 여러 앱들의 접근을 시분할로 중재해주는 것이져. 컨텍스트 스위칭이 발생하면, 적어도 GPU 의 상태 변환이 일어나고(이를 처리하기 위해 커맨드 버퍼에 추가 커맨드가 들어감), 비디오 메모리로 리소스가 올라가거나 내려오는 스와핑이 일어날 수도 있다. 그리고 당연히 어떤 한 시점에서 3D 파이프라인에 커맨드를 주는 것은 단 하나의 프로세스이다.

콘솔 프로그래머들은 PC 3D API 의 상당히 고수준이며 직접 제어할 수 없이 알아서 처리되는(hand-off) 특성에 의해 야기되는 성능 비용에 대해 종종 불만을 표시한다. 하지만 PC 의 3D API 나 드라이버가  해결해야 하는 문제들은 콘솔 게임에 비해 더 복잡하다. 예를 들자면 PC 에서는 모든 스테이트의 변화를 관리하여야 하는데, 이는 다른 앱이 언제 상태를 바꿔버릴지 알 수 없기 때문이다. 또한 PC 에서는 망가진 애플리케이션을 우회하고 이로 인해 발생하는 보이지 않는 성능 문제를 해결해야 한다. 이는 드라이버 개발자 자신을 포함해서 누구에게도 즐겁지 않은 상당히 성가신 관행이지만, 현실 세계에서는 비즈니스 관점이라는 게 있으니까여. 사람들은 잘 돌아가고 있는 것은 계속해서 (아주 부드럽게) 잘 돌아가기를 바라져 넵. 애플리케이션에다 대고 “그건 틀렸어 임마!”라고 외치고는 부루퉁한 표정으로 엄청 느린 길을 택해서 간다면 친구를 아무도 사귈 수 없을겁니다.

커널 모드 드라이버(kernel-mode drver, KMD)

KMD 는 하드웨어를 다루는 부분이다. UMD는 동시에 여러 인스턴스가 실행중일 수 있지만, KMD는 항상 오직 하나만 실행될 수 있다. 그리고 만약 그게 크래쉬된다면 잦되는거져ㅋ. 예전에는 블루스크린이었지만, 요즘 윈도는 크래쉬된 드라이버를 죽였다 다시 살리는 게 가능하다. 물론 이건 최소한 커널 메모리 손상이 아닌 단순한 크래시일때만 가능하다. 그렇지 않다면 끝장인거임ㅋ

커널 모드 드라이버는 모든 ‘단 하나 뿐인 것들’을 다룬다. 여러 앱이 GPU 메모리를 두고 다투지만, 실제 GPU 메모리는 하나밖에 없다. 누군가는 명령을 내리고 실제로 메모리를 할당하며 이를 매핑해야한다. 이와 유사하게 누군가는 시스템이 시작될 때 GPU를 초기화하거나, 디스플레이 모드를 설정하거나 디스플레이로부터 모드 정보를 얻어온다던가 하드웨어 마우스 커서를 관리하는 일, 하드웨어 타이머를 설정하여 GPU가 일정 시간 응답이 없으면 리셋을 하는 일, 인터럽트에 응답하는 일 등을 해야 한다. 이것이 바로 KMD가 하는 일이다.

또한 컨텐츠 보호 / DRM과 관련하여 비디오 플레이어와 GPU간에 보호/DRM 된 패스를 설정하여, 지저분한 유저 모드 코드들 통해 디코딩된 소중한 픽셀들이 드러나는 일이 없도록 해서, 이를 디스크에 덤프한다던가 하는 등의 끔찍한 일이 일어나지 않도록 해야 한다. KMD 는 이에 일부 관여한다.

가장 중요한 사실은 KMD가 관리하는 커맨드 버퍼가 하드웨어가 실제로 처리하는 바로 그 커맨드 버퍼라는 것이다. UMD 가 생성하는 커맨드 버퍼는 실제 커맨드 버퍼가 아니다. GPU 가 접근 가능한 랜덤한 메모리 조각에 불과하다. 실제로는 다음과 같은 일이 일어난다. UMD 가 커맨드 버퍼 작업을 마치면 이를 스케줄러에 접수시킨다. 스케줄러는 프로세스가 깨어날 때 까지 기다렸다가 UMD 커맨드버퍼를 KMD 커맨드버퍼에 넘겨준다. 그러면 KMD는 (UMD) 커맨드 버퍼를 호출하는 명령을 주 커맨드 버퍼에 써넣는데, 만약 GPU 가 메인 메모리를 읽을 수 없을 경우 (UMD) 커맨드 버퍼의 내용을 비디오 메모리로 먼저 DMA 한 다음에 이 작업을 수행한다. 메인 커맨드 버퍼는 보통 (아주 작은) 링 버퍼로, 거기에 실제 쓰여지는 것은 시스템/초기화 명령과 실제 3D 커맨드 버퍼를 호출하는 명령밖에 없다.

어쨌든 이 커맨드 버퍼는 메모리상에 존재하는 버퍼일 뿐이다. 그래픽 카드에게 이 버퍼의 위치는 보통 읽기 포인터와 쓰기 포인터를 통해 알려진다. 읽기 포인터는 주 커맨드 버퍼의 어디를 GPU 처리하고 있는지를 표시하며, 쓰기 포인터는 KMD 가 명령을 써 놓은 위치가 어디까지인지를(좀 더 정확히 얘기하자면 KMD 가 GPU 에게 어디까지 명령을 작성했는지 알려줬는지를) 나타낸다. 이들은 하드웨어 레지스터로, 메모리에 매핑되어 있으며 KMD 가 이를 주기적으로 업데이트한다(보통 KMD 가 새로운 작업 덩어리를 접수시킬 때 업데이트한다).

버스(bus)

KMD 가 이렇게 써 놨다고 해서 이게 바로 그래픽 카드로 가는 것은 아니다(그래픽 카드가 CPU 다이에 통합되어 있지 않은 다음에야). 먼저 버스를 통과해야 한다. 요즈음은 PCI 익스프레스이다. DMA 전송 등도 역시 버스를 거쳐야 한다. 이는 오래 걸리는 것은 아니지만, 어쨌든 이 여행의 또다른 단계이다. 그리고 마침내…

커맨드 프로세서command processor!

커맨드 프로세서는 GPU 의 프론트엔드이다. KMD 가 작성한 커맨드들을 실제로 읽어들이는 것이 이곳이다. 이미 글이 충분히 길어졌기에, 나머지는 다음 포스팅에서 계속.

OpenGL 의 몇가지 차이점들

OpenGL 은 이 글에서 설명한 내용과 매우 비슷하다. 하지만 API 와 UMD 가 딱 잘라 나뉘어지지 않는다. 그리고 D3D 와는 달리, (GLSL)셰이더 컴파일은 API가 아니라 드라이버단에서 전부 처리된다. 따라서 3D 하드웨어 제조사들마다 하나씩 GLSL 프론트엔드를 구현하게 되는데, 이는 기본적으로는 같은 스펙을 구현한 것이지만 각자의 버그나 특이성을 갖게 되는 부작용이 발생하게 된다. 노잼이져. 그리고 이는 드라이버가 셰이더를 만날 때 마다 최적화 – 그 ‘비싼’ 최적화들을 포함해서 – 를 처리해 한다는 것을 의미한다. D3D 의 바이트코드는 이런 문제에 대한 매우 깔끔한 해결책이다. 컴파일러가 하나밖에 없으니까. (따라서 제조사간에 미묘하게 달라서 호환되지 않는 방언들이 존재하는 문제도 발생하지 않게 된다.) 이 덕분에 너는 좀 더 비싼 데이터 흐름 분석을 할 수 있게 된다. <- 원문은 ‘and it allows for some costlier data-flow analysis than you would normally do’ 인데 costlier data-flow analysis 가 뭘 의미하는지 감이 안 잡히네? 뭔가 특정한 걸 의미하는 거 같은데… 걍 개별 드라이버에 의한 셰이더 버그나 특성 문제 해결하는 데 신경쓰지않고 나머지 작업에 집중할 수 있다 그런 뜻일라나?

생략되고 간소화된 부분들

이 글은 개요이기에 엄청나게 많은 미묘한 사항들을 적당히 얼버무리고 넘어갔다. 예를 들자면 스케줄러의 경우 딱 하나만 있는 것이 아니고 여러 가지 구현이 존재할 수 있다(드라이버가 이를 선택할 수 있음). CPU 와 GPU 간의 동기화에 대해서도 아직까지 전혀 설명하지 않았으며, 기타 등등 여러가지가 있다. 그리고 내가 무언가 중요한 것을 완전히 까먹고 있을 수도 있는데, 이러한 것은 고칠 수 있도록 알려 달라. 하지만 지금은 안녕. 담에보아~

A trip through the Graphics Pipeline 2011 요약정리 – Index

2013년 즈음이었는지 그보다 더 전이었는지 정확히 기억이 나지 않는데(원래 써놨는데 망할 텍큐 버그 때문에 한 문단을 날리면서…) 어쨌든 A trip through the Graphics Pipeline 2011 를 발견하고 읽으려고 시도했으나 몇 번 시도 끝에 엄청 더디게 진도는 나갔으나 곧 포기.. 또 읽다가 포기. 그리고 몇 년을 묵혀놨었는데…

마침 DirectX9 이후로 팽개쳐둔 그래픽스 API들에 대한 공부도 좀 해둬야겠단 생각이 든 차에 이 글이 다시 생각나서, 이번엔 좀 더 active 하게 읽기 위해 요약을 하면서 기록으로 남기고자 이 글을 시작.

…했으나 쓰다보니 요약이 아니라 전문번역에 가까워졌네-_-

어쨌거나 일단 이 글은 index 파트에 대한 요약임.

(이하는 A trip through the Graphics Pipeline 2011: Index 를 요약한 내용임다)

GPU에 실제로 구현된 D3D/OpenGL 그래픽스 파이프라인에 관한 글임.

GPU에 관해 넓은 범위에서의 개요를 다루거나 각 컴포넌트를 자세히 다루는 논문은 많지만 그 중간을 다루는 것이 없는 게 계속 신경쓰였다. (주:그래서 필자가 저 블로그 포스팅 시리즈들을 썼겠져.)

적어도 D3D9 이상, OpenGL 2.0 이상의 3D API 에 대한 지식을 가지고 있는 프로그래머를 대상으로 함.

초보용 아님.

레지스터라던가, FIFO 라던가, 캐시, 파이프라인이 뭐고 어떻게 동작하지와 같은 하드웨어에 대한 최소한의 지식도 필요함ㅇㅇ.

병렬 프로그래밍 메커니즘에 대한 최소한의 지식. 왜냐면 GPU 자체가 엄청 병렬 처리를 하는 컴퓨터니까.

(원래 여기에 한 문단이 더 있는데, 처음엔 요약으로 시작해서 빼버려도 될 거 같아서 생략했다가 Part 1을 진행하면서 번역 비슷하게 되어버려 좀 애매해졌는데.. 추후에 봐서 번역해 추가하던지 하겠슴ㅋ)

Part 2 – GPU memory architecture and the Command Processor.
Part 3 – 3D pipeline overview, vertex processing.
Part 4 – Texture samplers.
Part 5 – Primitive Assembly, Clip/Cull, Projection, and Viewport transform.
Part 6 – (Triangle) rasterization and setup.
Part 7 – Z/Stencil processing, 3 different ways.
Part 8 – Pixel processing – “fork phase”.
Part 9 – Pixel processing – “join phase”.
Part 10 – Geometry Shaders.
Part 11 – Stream-Out.
Part 12 – Tessellation.
Part 13 – Compute Shaders

Unity MatCap shader 분석(하려다 삽질한) 기록

작년 이맘때쯤엔 한창 신규 프로젝트의 세일즈 빌드 작업을 하고 있었다. 작업을 하다 보니 셰이더 코드를 좀 건드릴 일이 있었는데 그때 눈에 밟히는 부분이 있긴 했지만 딱히 파고 들 시간이 없어서 냅두다가 최근에서야 약간 여유가 생겨 다시 파보게 됨.

사실 딱히 복잡하다거나 뭔가 엄청난 신기술이거나 그런 거 아니고, 어찌보면 기본의 기본에 해당하는 부분인데 매번 ‘일단 돌아가니까 그걸로 ㅇㅋ’로 대충 뭉개고 넘어가던 부분인지라.. 이왕 삽질하면서 알게 된 거 정리라도 해 둬야 나중에 까먹고 또 삽질할때 시간이라도 좀 아끼지 하는 마음으로 써봄.

어쨌거나 문제의 발단이 된 코드는 이건데,

http://wiki.unity3d.com/index.php/MatCap

얼핏 봤을 땐 시선 방향을 범프를 적용해 어찌저찌 틀어서 이걸 이용해 matcap 텍스처의 픽셀을 가져다 쓰는..뭐 그런 코드인 거는 내가 잘 알겠다.

근데 구체적으로 뜯어보려니까 음? 어라라?? 스러운 부분이 한두군데가 아닌 것이었다. 정확히 얘기하자면 한두군데가 이상한 정도가 아니라 아니라 전체를 대충은 알아도 각 코드 한 줄 한 줄이 구체적으로 어떤 의미인지 정확히는 모르고 있었던 것이지라.

여튼 코드에서 뜯어봐야 할 핵심인 부분을 보자면,

vert 함수(버텍스 셰이더 코드)의

TANGENT_SPACE_ROTATION;
o.TtoV0 = mul(rotation, UNITY_MATRIX_IT_MV[0].xyz);
o.TtoV1 = mul(rotation, UNITY_MATRIX_IT_MV[1].xyz);

그리고 frag 함수(픽셀 셰이더 코드)의

vn.x = dot(i.TtoV0, normal);
vn.y = dot(i.TtoV1, normal);float4 matcapLookup = tex2D(_MatCap, vn*0.5 + 0.5);

각각 세 줄 씩.

일단 버텍스 셰이더 코드를 보자니.. 첨부터 못 보던 매크로 같은 물건이 보인다. TANGENT_SPACE_ROTATION

이건 뭔가 유니티 Shaderlab 에서 제공하는 변수 같은건가 하고 찾아보니까.. 딱히 매뉴얼에 정의가 된 건 안보임(내가 못찾았을수도),

그래서 혹시나 싶어 빌트인 셰이더 소스코드의 UnityCG.cginc 를 까보니 맞네… 거기 정의되어있는 매크로였음.

// Declares 3×3 matrix ‘rotation’, filled with tangent space basis
#define TANGENT_SPACE_ROTATION \
float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w; \
float3x3 rotation = float3x3( v.tangent.xyz, binormal, v.normal )

보니까 버텍스 셰이더 입력으로 노멀과 탄젠트가 들어오는데, 이걸로 바이노멀을 계산해서 탄젠트 스페이스상의 값으로 회전시켜주는(..것으로 보이는) rotation 이라는 변수명의 3×3 행렬을 만들어주는 매크로.

그리고 바로 다음줄과 그 다음줄에 등장하는 UNITY_MATRIX_IT_MV 라는 역시나 매크로스러워 보이는 이름의 무언가가 있는데.. 처음엔 이름으로 대충 때려잡기로 IT 는 inverse transform 일테고, MV 는 model 이랑 view 행렬을 얘기하는 거겠지..라고 생각.
근데 아니었음. 제대로 찾아보면 나오는데.. “Inverse transpose of model * view matrix” 임ㅋ. 그리고 매크로가 아니라 저거 자체가 변수이기도 하고.

어쨌든 여기까지 해서 모르던 변수나 매크로는 대충 확인이 되었으니.. 이걸 가지고 전체적인 흐름을 다시 한 번 보자면 버텍스 셰이더에서 탄젠트 스페이스로의 변환 행렬과 모델뷰 행렬을 곱해서 픽셀 셰이더로 넘기고, 이걸 픽셀 셰이더에서 노멀맵에서 언팩한 노멀 벡터랑 쿵짝해서 만든 실수값 두 개를 uv좌표로 써서 matcap 텍스처의 픽셀을 가져다 씀..인 것. 뭐 얼핏 봤을때의 그것이랑 같은 얘기. 근데 당장 제일 처음에 버텍스 셰이더에서 탄젠트 스페이스로 보내주는 벡터부터 뭐인지부터 모르겠다는거.

그래서 우선, 어떤 걸 모르고/헷갈리고 있었는지부터 적어보고자 함.

제일 처음이 바로 위에서 얘기한 탄젠트 스페이스로 보내지는..벡터가 뭔지 모르겠는데.. 이건 사실 코드상에서 뭘 곱해주는지가 명시적으로 드러나지 않는 상황. 그래서 나머지 코드들을 해석하고 나서 역으로 끼워맞춰야 하더라. 다 풀고 보니 결과적으로 이렇게 되긴 했는데, 모를 땐 처음부터 막혀서 막 헤매게 된 원인이기도 함.

그 다음이 TANGENT_SPACE_ROTATION 인데..
얘는 오브젝트의 로컬 스페이스에서 한 점의 탄젠트, 바이노멀, 노멀 벡터가 어느 방향인지를 나타내는 값을 가져다 이를 차례대로 행렬의 1, 2, 3행으로 쌓아서 행렬을 만든것.

근데 일단 이게 내가 생각한 것(탄젠트 스페이스로의 변환)이 맞는지 확인해보려고 하니 행렬과 벡터를 곱하는 순서가 어떻게 되는지부터 알아야겠단 생각이 들었음.

지금 회사에서 유니티 쓰기 전까지 일하면서는 주욱 D3D로만 작업을 하다보니 알고있고 익숙한 게 대부분 D3D기준인데, 예를 들자면 D3D 는 행렬에 벡터를 곱할 때 벡터가 먼저 오고 행렬이 뒤에 오는 형태라는 거. 이건 4×4 행렬로 트랜스폼을 한 방에 처리할 때 translation 에 해당하는 성분이 4행의 1, 2, 3열에 오게 된다.

근데 유니티의 셰이더코드는 Cg 기반이고, Cg는 예전에 얼핏 듣기로 행렬과 벡터의 곱셈이 행렬이 먼저 오고 벡터가 나중에 오는 형태라고 알고 있었는데.. 찾아보니 맞게 기억하고 있는거였음. 확인은 위키책의 해당 항목(이쪽이 좀 더 자세한데..위키책 사이트가 종종 터지는듯?) 이나 좀 덜 자세하지만 Cg 의 mul 함수 설명에서.

따라서 유니티 셰이더 코드의 4×4 행렬에서는 translation 에 해당하는 성분은 4열의 1, 2, 3행에 위치함. 사실 이 글에서 translation 위치 자체가 중요한 건 아닌데.. 내가 4×4 트랜스폼 행렬이 어느 방향을 향하는지 기억하는 기준이 translation 행 혹은 열의 위치인지라.

어쨌거나 다시 TANGENT_SPACE_ROTATION 로 돌아가서 보면, 매크로 코드에 의해 이 3×3 행렬의 1행은 탄젠트 벡터, 2행은 바이노멀 벡터, 3행이 노멀 벡터인데,

회전변환행렬을 구성할 때 회전된 결과인 세 축을 알면 그 축을 각각 회전행렬의 1, 2, 3행으로 쓸 수도 있다.. 정도의 아련한(…)기억이 있는지라, 일단 이게 맞는지 확인부터 해야겠다는 생각이 들었음. 근데 검색을 해도 찾기가 어려워서.. 그럼 이 행렬에다 x, y, z축의 방향벡터를 의미하는 (1, 0, 0), (0, 1, 0), (0, 0, 1) 을 곱해준 결과가 뭔지를 확인해보자로 넘어가게 됨.

즉, x축의 방향벡터인 (1, 0, 0)을 TANGENT_SPACE_ROTATION 의 결과물인 rotation 변수에 곱해주면 탄젠트 방향벡터가 나오겠지… 했는데 아니네? 탄젠트 벡터는 rotation 행렬의 1행인데 rotation 에 (1, 0, 0)을 곱해주면 1열 성분의 벡터가 나오자네. 즉 rotation 행렬을 transpose 한 것에 (1, 0, 0)을 곱해줘야 탄젠트벡터가 나오게 되니, 여기서 나는 rotation 변수를 계산할 때 단순히 1, 2, 3행에 탄젠트, 바이노멀, 노멀을 넣고 땡이 아니라 이걸 한 번 transpose 해 줘야 하는 코드에선 이걸 빼먹고 잘못 쓰고 있는 게 아닌가.. 라고 엉뚱한 생각을 했다-_-. 혹은 코드가 잘못되지 않았다면rotation 변수의 의미가 탄젠트 스페이스로의 회전이 아니라 탄젠트 스페이스에서 오브젝트의 로컬 스페이스로의 회전이 아닌가..라고 의심을 하게 됨(역시나 틀린 생각임). 이렇게 첫 부분도 그 다음도 계속 헷갈리고 있으니 나머지 부분도 엄청 삽질하게 될 것은 불을 보듯 뻔하고..

일단 그렇게 아리송다리송@_@한 상태에서.. 나머지 부분은 어케 문맥이랑 끼워맞춰보면 될라나 하는 심정으로 그 다음줄로 넘어가니…
어?
행렬이랑 행렬을 곱하는 게 나올 줄 알았는데 그게 아니라 행렬이랑 행렬의 1, 2 행 성분을 각각 곱해서 그걸 픽셀 셰이더로 넘겨주네?

이걸 보고서 노멀 언패킹때처럼 서로 직교하는 세 축의 경우 두 축만 값을 남기고 나머지 하나는 계산으로 만들어 쓰는건가? 아니면 행렬끼리 계산한 결과와 마지막에 곱해주는 벡터에 x, y 성분만 사용하는건가 하는 등의 생각이 들었는데.. 일단은 여기도 어떤 것인지 확실하게 확인하지 못한 상태로 또 넘어감(…제대로 하는 게 뭐냐ㅋ)

뭔지는 몰라도 어쨌든 행렬과 행렬을 곱하는거 같긴 한데.. 그렇다면 앞의 행렬(rotation 변수)과 뒤의 행렬(UNITY_MATRIX_IT_MV 변수) 의 을 곱해줘야 할텐데.. 왜 곱해주는 게 열이 아니라 행(UNITY_MATRIX_IT_MV[0] 과 UNITY_MATRIX_IT_MV[1])일까-_-. 여기서 혹시나, 정말 혹시나 행렬에 대해 [] 연산자를 쓸 경우 행이 아니라 열에 접근하는건가 하고 다시 아까 Cg 레퍼런스를 확인해봤는데 아님. [] 연산자로 접근했을 때 나오는 벡터는 행 하나가 맞음ㅋ.

그렇다면 저 식의 의미는 UNITY_MATRIX_IT_MV 가 아니라 UNITY_MATRIX_IT_MV 의 transpose 행렬을 곱해주는 것임. 그런데 여기서 UNITY_MATRIX_IT_MV를 직교행렬이라고 착각하고, 이것의 transpose 이니 역행렬이구나..라고 또 착각. 저 식이 UNITY_MATRIX_IT_MV 의 역행렬을 곱해주는 게 되려면 UNITY_MATRIX_IT_MV 가 직교행렬이어야 하는데, 정답편에서 얘기하겠지만 얘는 직교행렬이 아니기 때문에 역행렬이 아니라 그냥 transpose 인거. 왜 이런 식을 쓰는지 정확한 의미는 요 아래에서. 어쨌든 식에서 저 부분을 view space 서 model space 로 변환시켜주는 행렬, 다른 말로는 카메라 공간에서 모델의 로컬 공간으로 변환시켜주는 행렬이 된다고 생각했는데… 이 부분은 대략적으로는 맞긴 하지만 역시나 100% 정확한 표현은 아니고. 뭐 어쨌든 이런 혼돈의 카오스 상태에서 픽셀 셰이더 코드로 넘어갔음.

픽셀 셰이더 코드 조각에서는 첫 번째 줄과 두 번째 줄에서 내적을 해 주는 것을.. 버텍스 셰이더에서 픽셀 셰이더까지 이어지는 변환의 일부(마지막) 즉, 버텍스 셰이더에서 넘겨받은 행렬에 해당하는 것에 픽셀 셰이더가 노멀맵에서 뽑아낸 normal 벡터를 곱해서 변환을 하는 것으로 다시 한 번 거하게 착각을 해 버림. 근데 그거 아니구여… 어쨌거나 착각을 하고 있으니 결과가 어떤 의미인지도 감이 안 잡히고 어디서부터 손을 대야할지도 모르겠다 싶은 상황인거. 일단 이렇게 한 이틀 헤매다 3일째에 갑자기 지푸라기를 하나 붙잡게(…)되었다. 이제 정답편으로 넘어가보겠심다.

정답편

일단 첫빠따는 TANGENT_SPACE_ROTATION.
얘의 진정한 의미는 모델의 로컬 좌표상의 벡터값을 곱하면 탄젠트 스페이스상의 값이 튀어나오는 행렬, 즉, 모델 공간에서 탄젠트 공간으로 변환시켜주는 행렬임. 변수 이름과 설명에 rotation 이라고 되어 있어서 ‘회전을 시킨다’ 라고 생각을 하게 되어 내 경우엔 더 헷갈렸는데, 사실 A라는 좌표계(좌표축)상에서 어떤 방향을 나타내는 벡터가 있을 때 이걸 B라는 좌표축상에서는 어떤 값의 방향벡터로 나타내어지는지, 즉 사용하는 기준 좌표축이 바뀜에 따라 값이 변하는 것을 계산하기 위한 행렬임. 자세한 내용은 선형대수에서 기저변환(change of basis) 부분을 참조..해야 할 거임. 근데 지금 참고하는 책이랑 자료에서 학술적으루다가 정확하고 이쁘게 설명해 놓은 게 없음. 그래서 그냥 무식하게 확인한 방법을 설명해보겠심다.

우선 TANGENT_SPACE_ROTATION 의 결과로 나온 rotation 변수에 x축의 방향벡터 (1, 0, 0)을 곱해주는 상황을 다시 한 번 생각해보겠슴다. 그 곱해준 결과 벡터는… 음. 당장은 뭔지 모르겠네-_-? 탄젠트, 바이노멀, 노멀(TBN) 벡터의 각 x 성분으로 만든 벡터인데.. 이것만 봐선 뭔 의민지…

뭔가 알 수 있는게 나오려면 결과가 TBN 벡터 셋 중에 하나여야 하는데, 그러려면 rotation 이 아니라 rotation 의 transpose 에다가 (1, 0, 0) 을 곱해야겠져? 그래야 rotation 의 1행 성분인 탄젠트 벡터가 나오니께.

근데 이 탄젠트 벡터는 어느 공간상의 값이다? 넹. 모델 공간상의 값임다. 왜냐면 이걸 계산하는 데 쓰인 버텍스의 값이 모델 공간상의 값이니까여. 물론 이건 버텍스 셰이더에 넘어오기 이전에 이미 모델 데이터 레벨에서 정해지는 값이라.. 그러니까 애초에 모델링 툴에서 익스포트하거나 익스포트한 걸 엔진으로 임포트 할 때 정해지는(계산되는) 값이긴 한데.. 음.. 더 자세히 설명하면 여기서 너무 늘어질 수 있으니 일단 여기서는 그냥 모델공간상의 값입니다.. 정도로 넘어가는 걸로ㅋ

어쨌거나 x, y, z좌표축에 해당하는 (1, 0, 0), (0, 1, 0), (0, 0, 1)을 rotation 의 transpose 에다 각각 곱해주면 그 결과값은 모델공간에서의 TBN 벡터에 해당하는 방향값을 뱉아냄미당.

그런데 rotation 은 직교행렬(=행렬을 구성하는 서로 다른 행벡터끼리 내적하면 0. 왜냐면 탄젠트, 바이노멀, 노멀은 서로가 서로에게 수직인 벡터)이니까, rotation 의 transpose 는 rotation 의 역행렬이져.

바로 위 두 문장을 조합하면, rotation 의 역행렬은 좌표축 벡터를 모델공간상의 TBN(탄젠트, 바이노멀, 노멀) 벡터로 회전을 시켜주는 행렬이네여. 그럼 rotaion 은 뭐다? 모델공간상의 TBN 벡터를 좌표축 벡터가 되도록 회전시켜주겠져. 즉, 모델 공간에서 TBN벡터에 해당하는 벡터들을 rotation 행렬에 의해 변환시키면 결과값은 좌표축 벡터가 된다는 말임다. 탄젠트 바이노멀 노멀이 좌표축이 되는 공간이 뭐다? 뭐긴뭐야 탄젠트 공간이지.

즉, rotation 을 모델공간상의 값에다 곱해주면 결과값은 탄젠트 공간상의 값이 된다는 거. (워낙 말장난같긴 한데.. 좌표축 변환이라는 게 기준이 되는 축을 바꾸고 그 결과에 의해 값의 의미가 달라지는거라.. 아.. 나도 설명 좀 더 잘하고 싶다….)

한참을 장황하게 설명했는데, 어쨌든 결론은 TANGENT_SPACE_ROTATION 은 모델 공간의 값을 탄젠트 공간의 값으로 변화시켜주는 행렬을 계산해 이걸 rotation 이라는 이름의 변수에 넣어주는 매크로 라는 게 확실해졌슴다. 그리고 아직 끝나지 않았져. 결론은 결론인데 코드 첫 줄에 대한 결론이잖아. 나머지도 해석해야지ㅋ.

그 다음으로 UNITY_MATRIX_IT_MV 의 진정한 의미를 확인해보도록 하겠슴다.
유니티 문서에 따르면 UNITY_MATRIX_MV 는 Inverse transpose of model * view matrix 임다(아까도 한 얘기지만). 근데 말로 써놔서 좀 헷갈리긴 하는데, 저 말의 정확한 의미는 model * view matrix 를 inverse 한 것을 다시 transpose 한 거란 얘기져. transpose 한 것을 inverse 한 게 아님. (중요)

아우.. 뭘 inverse 랑 transpose 랑 구분을 하고 앉았냐. 둘이 같은거니 어느 게 먼저 와도 상관없이 그냥 상쇄되는거 아녀? 라고 생각하신다면.. 아녀. 그거 틀렸슴미다. model * view matrix 가 일단 직교행렬이 아닐 수 있으니까요. 언제 직교행렬이 아니냐? 며는.. 일단 translation 성분 값이 (0, 0, 0) 이 아닐 때도 그렇고, 스케일이 non-uniform 해도 그렇고.

근데 어차피 이 코드에선 4×4 행렬 중에 좌상단 3×3 값만 써서 노멀 회전시키는거니까, 그렇게되면 translation 성분은 사라지니 노멀이 좀 틀어져도 트랜스폼된 결과값만 노멀라이즈 해서 적당히 쓰면 상관없지 않음? 이라는 시도를 해 볼 수도 있겠지만.. 뭐 다른 수가 없다면 그렇게라도 할텐데 수학적으로 정확한 결과를 낼 수 있는 방법이 엄연히 있져. 바로 그게 inverse transpose of MV 라는 해괴한(…) 물건임. 구체적인 사항은 이 게시물에서 언급하는 이 게시물에서, 혹은 Eric Lengyel 저 3D 게임 프로그래밍&컴퓨터 그래픽을 위한 수학3.5장 법선벡터의 변환에서 확인 가능함다. (2판 한글판 기준으로 119쪽 부근). 그냥 간단히는 non-uniform scale 이 적용된 트랜스폼일때에도 노멀을 제대로 변환하는 용도로 사용..정도로만 정리해둡시다. 어쨌든 UNITY_MATRIX_IT_MV 에 의한 변환은 모델 공간의 노멀을 뷰 공간(카메라 공간)의 노멀로 바꾸는 용도임.

그럼 UNITY_MATRIX_IT_MV 의 1열과 2열이 아니라 1행과 2행을 가져다 rotation 에 곱하는 의미는 무엇인가… 우선 버텍스 셰이더 코드 두 번째 줄 우변의 코드를 식으로 옮겨 써 보겠슴다.

rotation x UNITY_MATRIX_IT_MV[0]

그리고 UNITY_MATRIX_IT_MV 를 네 문단 앞에서 얘기한대로  transpose(inverse(model x view)) 로 풀어서 치환해보져.

  rotation x UNITY_MATRIX_IT_MV[0]

= rotation x (transpose(inverse(model x view)))[0]

연산자를 보통은 이렇게 쓰지 않지만, 편의상 행렬의 열벡터를 가져오는 연산을 {} 이라 정의하면, 3×3행렬 M 에 대해서 다음과 같이 쓸 수 있겠져.

M[0] = (transpose(M)){0}

(transpose(M))[0] = M{0}

마지막 하늘색 박스에서 transpose(inverse(model x view)) 부분의 계산 결과를 하나의 행렬로 보고 바로 위 변환(두번째 줄)을 적용하면 다음과 같아집니다.

  rotation x (transpose(inverse(model x view)))[0]

= rotation x (inverse(model x view)){0}

그런데 행렬의 첫번째 열을 구하는 연산 M{0} 은 행렬 M 에 벡터 (1, 0, 0) 을 곱해주는 것과 같습니다. 따라서 다음과 같이 쓸 수 있게 되져.

  rotation x (inverse(model x view)){0}

= rotation x inverse(model x view)) x (1, 0, 0)

즉, 버텍스 셰이더 두 번째 줄 코드의 내용은 rotation x inverse(model x view) x (1, 0, 0) 이라는 식과 같다고 할 수 있겠네여. 이 식을 풀어 써 보면 벡터 (1, 0, 0) 를 model x view 행렬의 역행렬에 곱하고, 이걸 다시 rotation 에 곱해주는 식 되겠슴다.

model x view 행렬은 모델 로컬 공간에서 뷰(카메라) 공간으로 변환시켜주는 행렬인데.. 이것의 역행렬이니까 카메라 공간에서 모델 공간으로 변환시켜주는 행렬이져? 따라서 여기에 곱해주는 벡터 (1, 0, 0) 은 카메라 공간상의 벡터로 봐야 함다. 카메라 공간상에서 벡터 (1, 0, 0) 는? 화면상의 가로축이네여. 즉 식의 뒷부분은 화면상의 가로축을 모델 공간의 값으로 변환합니다.

rotation 은 저으기 위에서 얘기한대로 모델 공간에서 탄젠트 공간으로 변환시켜주는 행렬입니다. 바로 위에서 화면상의 가로축을 모델 공간상의 값으로 변환했는데, 여기에 rotation 을 곱해주면 다시 이 모델 공간상의 값이 탄젠트 공간의 값으로 바뀌는거죠. 결과적으로 이 식은 화면상의 가로축을 탄젠트 공간의 값으로 변환하게 됩니다.

같은 방식으로, 버텍스 셰이더의 세 번째 줄은 화면상의 세로축을 탄젠트 공간의 값으로 변환하게 되죠. 이렇게 구한 두 벡터는 버텍스 셰이더 출력값에 저장되어 픽셀 셰이더로 넘겨지게 됩니다.

픽셀 셰이더 코드의 해석은.. 일단 픽셀 셰이더가 넘겨받은 두 값 i.TtoV0 와 i.TtoV1 부터 봅시다. 얘네들은 바로 위 버텍스 셰이더에서 넘겨준 카메라공간의 가로와 세로축 방향을 나타내는 벡터입니다. 카메라 공간의 가로 세로 이러니 장황한데 좀 더 간단히 얘기하자면 화면의 가로와 세로 방향을 나타내는 벡터라는거져. 근데 이 값은 어느 공간상의 값이다? 넵. 탄젠트 공간.

이 탄젠트 공간상의 값과 내적을 해 주는 normal 벡터는 어디서 온 값이냐.. 이건 가져온 코드에서는 안 보이지만 노멀맵에서 언팩한 값이져(frag 함수 몸체 첫 줄의 UnpackNormal 호출 결과로 받는 값). 얘는 애초에 탄젠트 공간의 값임다. 왜 탄젠트 공간이냐면 탄젠트 공간이니까 탄젠트 공간이라 한 것인데 왜냐고 물으시면.. 이 아니라 이 부분에 대해서 풀어 쓰려니 분량이 너무 늘어날 거 같아 일단 UnpackNormal 의 결과값은 탄젠트 공간이다..라고만 해 두고 넘어가져. 탄젠트 공간상의 무슨 값? 노멀맵에서 가져온거니 노멀값이겠져? 정확히는 픽셀 셰이더에 의해서 그려지고 있는 해당 점의 노멀 방향값이져.

결국 이건 화면의 가로와 세로 방향 벡터랑 해당 점의 노멀 방향 벡터를 내적하는 게 되는데, 둘 다 탄젠트 공간의 값입니다. 따라서 애초에 착각했던것처럼 뭔가 변환은 아니게 되는거져. (굳이 결과에 끼워 맞추자면 텍스처의 uv 공간으로 변환하는거라고 할 수 있을지도 모르겠지만 이러면 더 헷갈리니까 이건 제쳐두고.)

두 벡터의 노멀을 어떤 의미로 봐야하는지를 알기 위해 두 벡터의 기하학적 정의를 한 번 살펴보도록 하져.

‘두 벡터의 내적’의 기하학적 정의는 두 벡터의 각각의 길이 값을 곱한 것에 두 벡터 사이각의 cosine 값을 곱한 값..입니다. 이 정의에 따라 픽셀 셰이더의 식을 해석해 보면, 버텍스 셰이더 코드의 첫째 줄은 화면의 가로방향 벡터와 노멀맵에서 가져온 노멀 벡터를 내적한 값을 계산하는 거져.

그런데 화면의 가로 방향 벡터는 식 사이에 숨어 있는 값 (1, 0, 0) 로 등장한 이후 픽셀셰이더로 넘어오기 전까지 계속해서 회전 변환만 적용된 상태입니다. 따라서 최초에 1이었던 길이는 픽셀 셰이더에서 내적 할 때 까지도 여전히 유지되고 있져. 방향 벡터는 보통 길이 1을 유지하도록 하는 경우가 많은데, 특히나 내적을 계산할 때 두 벡터 중 하나가 가 길이 1인 방향 벡터일 경우, 위에서 언급한 벡터 내적의 기하학적 정의에 대입해보면 나머지 한 벡터의 길이값에 두 벡터 사이각의 cosine 값을 곱한 것과 같은 값이 됩니다. 길이가 1인 벡터를 어떤 기준이 되는 벡터라고 보면, 이 기준 벡터와 다른 벡터를 내적한 값은 기준 벡터를 축으로 두었을 때 다른 벡터의 해당 축에 대한 좌표 성분값을 얻는 연산으로 볼 수 있는 것이져. 이런 관점에서 보면, 픽셀 셰이더의 첫째 줄과 둘째 줄 코드는 노멀 벡터를 화면상의 가로축과 세로축을 기준 축으로 하는 좌표계의 좌표값으로 변환하는 코드라 할 수 있슴다. 같은 말이긴 한데 좀 현실세계스러운 비유를 들어 설명하자면, 화면의 가로축과 세로축을 자(ruler)라고 생각하고, 노멀 벡터의 끝이 가르키는 점의 위치가 가로축과 세로축 방향으로 얼마나 되는지를 재어서 그 값을 vn.x 와 vn.y 에 저장하는 것이 픽셀셰이더 첫번째와 두번째 줄이 하는 일인 것이져.

그런데 노멀 벡터 역시 방향벡터로, 길이는 1을 유지하고 있습니다. 혹은 연산 도중 1이 아닌 값이 되게 되더라도 마지막이나 사용할 때에 1이 되도록 조정해주져. 방향 벡터이니 시작점을 원점에 두고, 길이가 1인 모든 벡터의 끝점에 점을 찍어보면… 넵. 아시다시피 이 점들의 집합은 원점을 중심으로 하는 반지름 1인 구면을 이루게 되져. 노멀 벡터들은 이 구면상의 값이라면 어떤 값이든 될 수 있는데, 픽셀 셰이더의 첫째 줄과 둘째 줄에 의한 연산을 하고 나온 결과값 (vn.x, vn.y)은 구면상의 각 점의 가로 세로 좌표값이니 이 값들을 2D 평면에 찍어보면 원점을 중심으로 반지름 1인 원을 가득 채우게 되져.

픽셀셰이더 두번째 줄 까지 계산한 결과를 좌표로 왜 2D평면상에 다시 점을 찍어보느냐는.. 이런 가정을 해 보져. 점을 찍을 평면에 그림이 그려져있다고 말이져. 그리고 해당 좌표에 점을 찍는 대신 그 점을 찍을 위치의 색깔을 가져다 다른 데 쓴다면? 넵. 그게 바로 픽셀셰이더 세번째 줄에서 tex2D 함수가 하는 일임돠.

그런데 tex2D 함수를 자세히 보면 파라미터로 넘겨주는 vx 를 바로 사용하지 않고 가공을 해 주고 있져? 이건 vx 의 x 와 y 값이 -1 에서 1 사이의 값이기 때문이져. 원점을 중심으로 반지름 1 짜리 원 내부의 점들이니까여. -1 에서 1 사이의 값에 0.5 를 곱해주면 -0.5 에서 0.5 사이의 값이 되고, 여기에 다시 0.5 를 더해주면 0 에서 1 사이의 값이 되져. 이렇게 하는 이유는 바로 텍스처 좌표가 0 에서 1 사이의 값을 가져야 하기 때문인 것이라는 거.

이렇게 구한 값을 픽셀셰이더의 출력값으로 사용하게 됩니다. 픽셀 셰이더라는 게 한 점을 그릴 때 그 점의 색깔을 무슨 값으로 하느냐를 계산하는 물건이져. 이 MatCap 셰이더의 경우는 해당 점의 노멀 방향벡터를 화면의 가로세로축이 만드는 평면상으로 옮겨서, 그 평면상에 붙어(…)있는 _MatCap 텍스처의 그 위치의 색깔로 화면에 그려주는 셰이더가 되는 겁니다. 그림을 그리면 좀 이해가 편할텐데 귀찮아서(…) 말로 설명을 하자면 노멀이 화면의 가로세로축과 완전히 수직한 경우, 즉 화면을 뚫고 나오거나 뚫고 들어가거나 하는 경우는 화면의 가로세로축에 의해 만들어지는 평면의 중점, 다시 말해 _MatCap 텍스처의 한 가운데 점의 색깔로 그려집니다. 그걸 기준으로 좌우로 기울어지든 상하로 기울어지든, 중점으로부터 좌/우측 혹은 아래/위쪽 위치에서 픽셀을 얻어 그 색깔로 그려지게 되는 것이져. 그리고 이쯤 되면 눈치 챌 수 있겠지만, _MatCap 텍스처에서 사용되지 않는 부분이 있는데 그건 바로 텍스처의 사각형 영역에서 이 영영을 꽉 채우는 원의 바깥부분, 즉 네 귀퉁이 인근 부분의 픽셀들이져. 얘네들을 가리키려면 해당 노멀의 길이가 1이 넘어야 하는데, 정상적인 노멀은 항상 길이가 1이므로 애는 절대 참조될 수 없기 때문에 영향을 미치지 않는 텍셀들이 되는거져.

어째 막판으로 갈수록 설명은 상세해지면서 내용은 더 날림이 된 거 같긴 한데, 일단 한 번 끊어야겠기에 여기까지 쓰고 일단락해야겠슴다. 나중에 좀 더 알아먹기 쉽게 고칠 수 있는 방법이 생각나면 그때 고치져 뭐. 끗.

맥스 플러그인 디버깅 그 두번째 시간

(…사실은 한번에 진행되었던거지만 -ㅂ-)

지난 글에 이은 야그.

대략적인 상황 설명을 세원옹에게 마친 뒤, 디버거를 돌리는 작업으로 돌입.

일단 컴터상엔 작업중인 FaceMarker 와 비교 대상인 Mesh Select 프로젝트를 켜 놓은 비졀스튜됴 외에도, 참고가 될만한 다른 프로젝트 파일들도 수없이 불러놓은 상태. 비졀스튜됴 인스턴스만 한 여섯개는 되었던듯하다-ㅂ-;;

여튼 처음 작업으론, 정상적인 동작을 보이고 있는 Mesh Select 의 코드를 최대한 FaceMarker 와 비슷하게 맞추어, 비정상 동작이 하는 순간을 잡아내자는 것.

..하지만 몇번 코드를 옮겨보아도 잘 동작하고 있는 Mesh Select 이놈을 엉뚱하게 움직이도록 만드는 것은 어렵다고 판단.

그렇다면 이번에는 FaceMarker 의 코드에다 Mesh Select 의 코드를 ‘복사’해 넣어서, 바뀌는 시점을 캐치하자는 방향으로 접근.

우선은 코드의 배열, MAX system API 호출 순서 등을 맞춰보았으나 역시나 문제의 부분은 오리무중..

그래서 이번엔, 아예 FaceMarker 특유의 기능 부분은 주석 처리하고, Mesh Select 의 코드를 갖다가 복사해 넣는 식으로 작업이 진행되었심..

…허나… 역시나 FaceMarker 는 face select 모드에서 선택된 face 를 출력하지 못하고 있는 상황이 반복되고..

어차피 플러그인이라는게 메인 루틴이 되는 MAX 측에서 필요할때 호출하는 함수들로 이뤄진 놈이니 코드의 전체 흐름을 디버거로 보기는 어려운 상황..

혹시 ‘이 함수는 아닐거야..’라고 생각해 제쳐두고 있을지 모르는 함수가 있을까 하여 제안된 방안은 ‘FaceMarker 의 모든 메소드 시작점에 브레이크포인트를 걸어보자!’

…..그렇지만 역시 이 방법도 좌절.. 예상했던 함수 외에 다른 용도로 호출되는 녀석은 없더라는 것.

이에 비장의 카드로 어셈블리 레벨 디버깅을 제안하는 세원옹!
옆에서 덜덜덜 떨면서 그러하시지요라고 대답하는 본인. (…어렵잖아!)

하지만 알지못할 MOV ADD JMP LEA CALL 들 사이를 수십수백번은 헤매야할거라 지레 겁먹었던것과는 달리..

트레이싱을 몇번 거치다 보니.. 어라? FaceMarker 코드를 호출하는 부분이 나오네..?

어랏..근데.. CPP 파일이 아니라 H 파일에 포함된 메소드!!
Modifier::ChannelsChanged() 였던 것이었던 것이었다.

이 메소드의 역할은 modifier 가 변화시키는 데이터 채널을 맥스에게 알려주는 것이다.

순간 ‘띵~’하면서 머리속을 지나가는 무언가 ‘…범인은 이녀석인가!’
하지만 이미 수십..까진 아니고 몇번을 그런 느낌을 받고 실패했던터라 아직까진 반신반의 상태..

여하튼 百見이 不如一打라.. 코드를 고치고 컴파일.. 맥스 띄우고 박스 만들고 edit mesh 건 다음 FaceMarker 적용.. face select..

“된다!!!!!!!!!!!!!!!! ;ㅁ;”

…… FaceMarker 의 역할이 mesh 데이터 자체에 ‘변경’을 가하는 것은 아니기에 이 함수를 들여다 볼 생각은 까맣게 잊고 있었던것.(PART_SELECT 라는 채널이 엄연히 존재하는데도 말이지)

바로 위에 오는 Modifier::ChannelsUsed() 은 작업 중에 손을 본 적도 있었는데.. 이 함수를 건드릴 생각을 못했던건.. 뭔가 씌었거나.. 그랴. 나의 불찰이다 ;ㅁ;

하지만.. face selection 은 일시적인 변경값인데 이것을 mesh data channel 의 하나로서 geometry 정보나 topology 정보와 동급으로 취급된다는건.. 어찌보면 설계할때 개념을 잘못 잡은거라고!! (..라는 식으로 책임회피.. 하지만 소용없다 lllorz )

여하간에.. 바로 위에 모든 메소드에 브레이크포인트를 찍어보자고 할 때에 걸릴번도 했던 이 녀석은..

거주지가 CPP 파일이 아니라 H 파일인지라 깜빡하고 넘어갔던 터에.. 디스어셈블까지 걸어보는 상황에서야 겨우 잡혀주시고 만 것.

테스트하느라 파엎은 코드들을 정리하는 작업까지만 가뿐히 마치고 갈까 했으나..

이미 야심한 시각. 숙소까지 느린 걸음으로도 10분이면 되는 나는 그렇다 쳐도 집까지 30분 차까지 끊긴 시간의 세원옹을 위해서라도 1분이라도 일찍 일어나야 한다!!

…..네..사실은 고치고나니 맥이 빠져서 귀찮아진거예요 ;ㅁ;

여튼 그렇게 그날의 디버깅세션은 막을 내리고..

집에가던 중 갈림길에서 본인은 세원옹에게 감사의 한마디를 건넨 것으로 오늘의 얘기는 끝이납니다.

.
.
.
앗!! 생각해보니 세원옹한테 맛난것 대접하기로 해놓고는 깜빡하고 있었다!! 쏘리 세원옹.

ps. 디버깅작업과는 직접적인 관계없지만, MAX 플러그인 관련하여 메모 몇가지.

1. modifier 에 의해 변경되는 mesh 의 출력은 각 modifier context 에 속한 mesh 가 아니라, 제일 밑단의 object 가 들고 있는 mesh 이다. modifier 들은 이 mesh 를 출력에 맞도록 변화시켜 줄 의무가 있다.

2. 하지만 각 modifier data 클래스는 이 mesh 들에 대한 사본을 가지고 유지할 필요가 있다. 이건.. 왜그랬더라? ;ㅁ; 까먹었네.. 일찍 좀 정리할걸..
여하튼 modifier data 클래스의 mesh 사본의 동기화는 Modifier::ModifyObject() 메소드에서 해 주면 된다.

3. face selection 의 출력 역시 원본 mesh 상의 selection data 에 값을 반영해주면 될 듯 하지만.. 이게 심히 fake 임. 원본 mesh 의 face selection bit array 에 값을 백날 때려넣어도 출력 안됨. 이는 Mesh::render() 메소드에는 face selection 을 출력하는 부분이 구현되어있지 않기 때문이다.(Modifier 에서 구현된건가 그럼..? 정확히 모르겠네 지금으로선) 여하튼, Modifier::ChannelsChanged() 의 반환값 중에 PART_SELECT 플래그가 확실히 세팅이 되어있어야 적용이 된다.

4. Mesh 클래스의 인스턴스를 마련하고, Mesh::render() 함수를 호출하는것만으로 뷰포트상에 임의 mesh 를 출력할 수 있다. 그러나, Mesh 클래스 인스턴스를 생성하는것만으로 무조건 뷰포트상에 렌더링이 걸리게 되는 것은 아니다. Display() 메소드 를 가진 녀석 내부에서 Mesh::render() 를 호출한다던가 하는 식으로 렌더링 메소드를 명시적으로 호출해줘야한다.

날림으로 적었더니 내용은 개발새발.. 다른사람들은 알아먹기도 힘들 말을 적어놨고..

괜찮아. 나중에 나만 알아볼수 있음 돼….
(…과연 알아 볼수나 있겠니? -ㅂ-; )

코드탐정 세원옹과 함께하는 신나는(…) MAX 플러그인 디버깅

3DS MAX SDK 로 modifier 플러그인을 작성하던 윤모씨.

작업도 막바지에 이르러.. 자체 최종 테스트를 진행하던 도중,
standard primitive 에 edit mesh 를 적용하고 그 위에 작성하던 플러그인(FaceMarker) 를 적용시켰을때 선택된 face 가 표시되지 않는 문제를 발견한다.

standard primitive->edit poly->FaceMarker : OK
standard primitive->convert to Editable Poly->FaceMarker : OK
standard primitive->convert to Editable Mesh->FaceMarker : OK

…이렇게 문제가 되는 상황과 유사한 조합 세가지는 멀쩡히 되던 터라, 한편으로는 별 문제 없겠지 라고 생각하고, 한편으로는 ‘이거 맥스의 버그 아녀?’ 라고 택도 없는 의심을 품던 차..

FaceMarker 대신 유사품 Mesh Select(맥스의 내장 플러그인)을 적용했을때는 전혀 문제가 없는것을 확인!! 본격적인 디버깅에 착수하게 된다.

…허나 반나절이면 끝날 줄 알았던 이 디버깅 작업이 하루가 되고 이틀이 되고 주말이 지나고..이러다가 또 일주일이 훌쩍 지나버릴지도 모르겠단 위기감이 뒷골을 타고 올라오면서(참고 : 다른 작업으로 이미 일주일을 말아먹은 상황-_-; )…
2006년 4월 19일 19시, 업무시간 종료시점에 다음과 같이 결심하게 된다. 당일 23시 59분까지 디버깅 작업을 진행해보고, 딱히 해결방안이 나오지 않으면 일단 제끼고 다음 작업을 진행하자고.

하지만 해결책이 보일듯말듯 상황은 점점 꼬여만 가던 22시 30분경..
작업을 마치고 귀가하려던 세원옹이 윤모씨의 작업에 눈을 돌리게 되었으니..

…to be continued